mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
Added support for Array in ConfigProperties
Added ArrayInput to config page Added game file extension management
This commit is contained in:
@@ -3,6 +3,7 @@ import React from "react";
|
||||
import Input from "Frontend/components/general/input/Input";
|
||||
import CheckboxInput from "Frontend/components/general/input/CheckboxInput";
|
||||
import SelectInput from "Frontend/components/general/input/SelectInput";
|
||||
import ArrayInput from "Frontend/components/general/input/ArrayInput";
|
||||
|
||||
export default function ConfigFormField({configElement, ...props}: any) {
|
||||
function inputElement(configElement: ConfigEntryDto) {
|
||||
@@ -34,10 +35,14 @@ export default function ConfigFormField({configElement, ...props}: any) {
|
||||
<Input label={configElement.description} name={configElement.key} type="number"
|
||||
step="1" {...props}/>
|
||||
);
|
||||
case "Array":
|
||||
return (
|
||||
<ArrayInput label={configElement.description} name={configElement.key} type="text" {...props}/>
|
||||
);
|
||||
default:
|
||||
return <pre>Unsupported type: {configElement.type} for key {configElement.key}</pre>;
|
||||
}
|
||||
}
|
||||
|
||||
return (inputElement(configElement!));
|
||||
return inputElement(configElement!);
|
||||
}
|
||||
@@ -50,6 +50,7 @@ function LibraryManagementLayout({getConfig, formik}: any) {
|
||||
|
||||
<Section title="Scanning"/>
|
||||
<ConfigFormField configElement={getConfig("library.scan.enable-filesystem-watcher")}/>
|
||||
<ConfigFormField configElement={getConfig("library.scan.game-file-extensions")}/>
|
||||
|
||||
<Section title="Metadata"/>
|
||||
<div className="flex flex-row">
|
||||
|
||||
@@ -21,7 +21,7 @@ function UserManagementLayout({getConfig, formik}: any) {
|
||||
);
|
||||
|
||||
ConfigEndpoint.get("sso.oidc.auto-register-new-users").then(
|
||||
(response) => setAutoRegisterNewUsers(response === "true")
|
||||
(response) => setAutoRegisterNewUsers(response as boolean)
|
||||
);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -62,13 +62,35 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
|
||||
let value: any;
|
||||
switch (item.type) {
|
||||
case 'Boolean':
|
||||
value = item.value === 'true';
|
||||
value = typeof item.value == 'boolean' ? item.value : item.value === 'true';
|
||||
break;
|
||||
case 'Int':
|
||||
value = parseInt(item.value!);
|
||||
value = typeof item.value == 'number' ? item.value : 0;
|
||||
break;
|
||||
case 'Float':
|
||||
value = parseFloat(item.value!);
|
||||
value = typeof item.value == 'number' ? item.value : 0.0;
|
||||
break;
|
||||
case 'Array':
|
||||
if (Array.isArray(item.value)) {
|
||||
switch (item.elementType) {
|
||||
case 'Boolean':
|
||||
value = item.value.map(v => typeof v === 'boolean' ? v : v === 'true');
|
||||
break;
|
||||
case 'Int':
|
||||
case 'Integer':
|
||||
value = item.value.map(v => typeof v == 'number' ? v : 0);
|
||||
break;
|
||||
case 'Float':
|
||||
value = item.value.map(v => typeof v == 'number' ? v : 0.0);
|
||||
break;
|
||||
case 'String':
|
||||
default:
|
||||
value = item.value.map(v => v.toString());
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
value = [];
|
||||
}
|
||||
break;
|
||||
case 'String':
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import {FieldArray, useField} from "formik";
|
||||
import {Button, Chip, Input, Popover, PopoverContent, PopoverTrigger} from "@heroui/react";
|
||||
import {KeyboardEvent, useState} from "react";
|
||||
import {Plus, XCircle} from "@phosphor-icons/react";
|
||||
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
|
||||
|
||||
// @ts-ignore
|
||||
const ArrayInput = ({label, ...props}) => {
|
||||
// @ts-ignore
|
||||
const [field, meta] = useField(props);
|
||||
const [newElementValue, setNewElementValue] = useState<string>("");
|
||||
|
||||
return (
|
||||
<FieldArray name={field.name}
|
||||
render={arrayHelpers => {
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === "Enter" || event.key == "Tab" || event.key === ",") {
|
||||
event.preventDefault();
|
||||
let trimmedValue = newElementValue.trim();
|
||||
if (trimmedValue !== "") {
|
||||
arrayHelpers.push(trimmedValue);
|
||||
setNewElementValue("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-row justify-between">
|
||||
<p>{label}</p>
|
||||
<small>{field.value.length} {field.value.length == 1 ? "element" : "elements"}</small>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row flex-wrap gap-2 items-center">
|
||||
{field.value.map((element: any, index: number) => (
|
||||
<Chip key={index} onClose={() => arrayHelpers.remove(index)}>
|
||||
{element}
|
||||
</Chip>
|
||||
))}
|
||||
<Popover placement="bottom" showArrow={true}>
|
||||
<PopoverTrigger>
|
||||
<Button isIconOnly size="sm" variant="light" radius="full"><Plus/></Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<Input
|
||||
value={newElementValue}
|
||||
onChange={(e) => setNewElementValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="New element..."
|
||||
variant="bordered"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="min-h-6 text-danger">
|
||||
{meta.touched && meta.error && meta.error.trim().length > 0 && (
|
||||
<SmallInfoField icon={XCircle} message={meta.error}/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ArrayInput;
|
||||
@@ -7,7 +7,7 @@ const CheckboxInput = ({label, ...props}) => {
|
||||
const [field] = useField(props);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row flex-1 items-center gap-2 mb-2">
|
||||
<div className="flex flex-row flex-1 items-center gap-2 mb-6">
|
||||
<Checkbox
|
||||
{...field}
|
||||
{...props}
|
||||
|
||||
@@ -6,6 +6,7 @@ import de.grimsi.gameyfin.config.dto.ConfigValuePairDto
|
||||
import de.grimsi.gameyfin.core.Role
|
||||
import jakarta.annotation.security.PermitAll
|
||||
import jakarta.annotation.security.RolesAllowed
|
||||
import java.io.Serializable
|
||||
|
||||
@Endpoint
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
@@ -19,7 +20,7 @@ class ConfigEndpoint(
|
||||
return config.getAll(prefix)
|
||||
}
|
||||
|
||||
fun get(key: String): String? {
|
||||
fun get(key: String): Serializable? {
|
||||
return config.get(key)
|
||||
}
|
||||
|
||||
@@ -32,7 +33,7 @@ class ConfigEndpoint(
|
||||
}
|
||||
|
||||
fun resetConfig(key: String) {
|
||||
config.resetConfigValue(key)
|
||||
config.deleteConfig(key)
|
||||
}
|
||||
|
||||
fun deleteConfig(key: String) {
|
||||
|
||||
@@ -29,11 +29,32 @@ sealed class ConfigProperties<T : Serializable>(
|
||||
true
|
||||
)
|
||||
|
||||
data object GameFileExtensions : ConfigProperties<String>(
|
||||
String::class,
|
||||
data object GameFileExtensions : ConfigProperties<Array<String>>(
|
||||
Array<String>::class,
|
||||
"library.scan.game-file-extensions",
|
||||
"File extensions to consider as games",
|
||||
"zip, tar, gz, rar, 7z, bz2, xz, iso, jar, tgz, exe, bat, cmd, com, msi, bin, run, app, dmg, elf"
|
||||
arrayOf(
|
||||
"zip",
|
||||
"tar",
|
||||
"gz",
|
||||
"rar",
|
||||
"7z",
|
||||
"bz2",
|
||||
"xz",
|
||||
"iso",
|
||||
"jar",
|
||||
"tgz",
|
||||
"exe",
|
||||
"bat",
|
||||
"cmd",
|
||||
"com",
|
||||
"msi",
|
||||
"bin",
|
||||
"run",
|
||||
"app",
|
||||
"dmg",
|
||||
"elf"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import de.grimsi.gameyfin.config.dto.ConfigValuePairDto
|
||||
import de.grimsi.gameyfin.config.entities.ConfigEntry
|
||||
import de.grimsi.gameyfin.config.persistence.ConfigRepository
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.stereotype.Service
|
||||
import java.io.Serializable
|
||||
|
||||
@@ -33,14 +34,22 @@ class ConfigService(
|
||||
}
|
||||
|
||||
return configProperties.map { configProperty ->
|
||||
val appConfig = appConfigRepository.findById(configProperty.key).orElse(null)
|
||||
val appConfig = appConfigRepository.findByIdOrNull(configProperty.key)
|
||||
|
||||
val parsedValue =
|
||||
if (configProperty.type.java.isArray)
|
||||
appConfig?.value?.split(",")?.toTypedArray()
|
||||
else
|
||||
appConfig?.value
|
||||
|
||||
ConfigEntryDto(
|
||||
key = configProperty.key,
|
||||
value = appConfig?.value ?: configProperty.default?.toString(),
|
||||
defaultValue = configProperty.default?.toString(),
|
||||
value = parsedValue ?: configProperty.default,
|
||||
defaultValue = configProperty.default,
|
||||
type = configProperty.type.simpleName ?: "Unknown",
|
||||
description = configProperty.description,
|
||||
allowedValues = configProperty.allowedValues?.map { it.toString() }
|
||||
elementType = configProperty.type.java.componentType?.simpleName,
|
||||
allowedValues = configProperty.allowedValues?.map { it.toString() },
|
||||
description = configProperty.description
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -71,18 +80,18 @@ class ConfigService(
|
||||
* @param key: The key of the config property
|
||||
* @return The current value if set or the default value or null if no value is set and no default value exists
|
||||
*/
|
||||
fun get(key: String): String? {
|
||||
fun get(key: String): Serializable? {
|
||||
|
||||
log.info { "Getting config value '$key'" }
|
||||
|
||||
val configProperty = findConfigProperty(key)
|
||||
val appConfig = appConfigRepository.findById(configProperty.key).orElse(null)
|
||||
|
||||
return if (appConfig != null) {
|
||||
getValue(appConfig.value, configProperty).toString()
|
||||
} else {
|
||||
configProperty.default?.toString() ?: return null
|
||||
}
|
||||
if (appConfig != null) return getValue(appConfig.value, configProperty)
|
||||
|
||||
if (configProperty.default == null) return null
|
||||
|
||||
return configProperty.default
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,51 +125,32 @@ class ConfigService(
|
||||
* @param value: Value to set the config property to
|
||||
* @throws IllegalArgumentException if the value can't be cast to the type defined for the config property
|
||||
*/
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun <T : Serializable> set(key: String, value: T) {
|
||||
log.info { "Set config value '$key'" }
|
||||
|
||||
val configKey = findConfigProperty(key)
|
||||
val configProperty = findConfigProperty(key)
|
||||
|
||||
// Check if the value can be cast to the type defined for the config property
|
||||
val castedValue = getValue(value.toString(), configKey)
|
||||
var configEntry = appConfigRepository.findByIdOrNull(key)
|
||||
|
||||
var configEntry = appConfigRepository.findById(key).orElse(null)
|
||||
val parsedValue =
|
||||
if (value.javaClass.isArray)
|
||||
(value as Array<Serializable>).joinToString(",")
|
||||
else
|
||||
value.toString()
|
||||
|
||||
if (configEntry == null) {
|
||||
configEntry = ConfigEntry(configKey.key, castedValue.toString())
|
||||
configEntry = ConfigEntry(configProperty.key, parsedValue)
|
||||
} else {
|
||||
configEntry.value = castedValue.toString()
|
||||
configEntry.value = parsedValue
|
||||
}
|
||||
|
||||
appConfigRepository.save(configEntry)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset a given config property to its default value if it has a default value.
|
||||
* Otherwise, delete the config key from the database.
|
||||
*
|
||||
* @param key: Key of the config property
|
||||
*/
|
||||
fun resetConfigValue(key: String) {
|
||||
|
||||
log.info { "Reset config value '$key'" }
|
||||
|
||||
val configKey = findConfigProperty(key)
|
||||
|
||||
if (configKey.default == null) {
|
||||
deleteConfig(key)
|
||||
return
|
||||
}
|
||||
|
||||
val appConfig = appConfigRepository.findById(configKey.key).orElse(null)
|
||||
if (appConfig != null) {
|
||||
appConfig.value = configKey.default.toString()
|
||||
appConfigRepository.save(appConfig)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a config property from the database
|
||||
* Remove a config property from the database.
|
||||
* This will also cause it to reset to its default value.
|
||||
*
|
||||
* @param key: Key of the config property
|
||||
*/
|
||||
@@ -176,21 +166,34 @@ class ConfigService(
|
||||
* Get the value of the config property in a type-safe way.
|
||||
*/
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T : Serializable> getValue(value: String, configProperty: ConfigProperties<T>): T {
|
||||
return when (configProperty.type) {
|
||||
String::class -> value as T
|
||||
Boolean::class -> value.toBoolean() as T
|
||||
Int::class -> value.toFloat().toInt() as T
|
||||
Float::class -> value.toFloat() as T
|
||||
else -> {
|
||||
if (configProperty.type.java.isEnum) {
|
||||
val enumConstants = configProperty.type.java.enumConstants
|
||||
enumConstants.firstOrNull { it.toString() == value }
|
||||
?: throw IllegalArgumentException("Unknown enum value '$value' for key ${configProperty.key}")
|
||||
} else {
|
||||
throw IllegalArgumentException("Unknown config type ${configProperty.type}: '$value' for key ${configProperty.key}")
|
||||
private fun <T : Serializable> getValue(value: Serializable, configProperty: ConfigProperties<T>): T {
|
||||
val value = value.toString()
|
||||
return when {
|
||||
configProperty.type == String::class -> value as T
|
||||
configProperty.type == Boolean::class -> value.toBoolean() as T
|
||||
configProperty.type == Int::class -> value.toFloat().toInt() as T
|
||||
configProperty.type == Float::class -> value.toFloat() as T
|
||||
|
||||
configProperty.type.java.isEnum -> {
|
||||
val enumConstants = configProperty.type.java.enumConstants
|
||||
enumConstants.firstOrNull { it.toString() == value }
|
||||
?: throw IllegalArgumentException("Unknown enum value '$value' for key ${configProperty.key}")
|
||||
}
|
||||
|
||||
configProperty.type.java.isArray -> {
|
||||
val componentType = configProperty.type.java.componentType
|
||||
// Remove the brackets and split the string by commas
|
||||
val elements = value.removeSurrounding("[", "]").split(",")
|
||||
when (componentType) {
|
||||
String::class.java -> elements.toTypedArray() as T
|
||||
Boolean::class.java -> elements.map { it.toBoolean() }.toTypedArray() as T
|
||||
Int::class.java -> elements.map { it.toInt() }.toTypedArray() as T
|
||||
Float::class.java -> elements.map { it.toFloat() }.toTypedArray() as T
|
||||
else -> throw IllegalArgumentException("Unsupported array type: ${componentType.name}")
|
||||
}
|
||||
}
|
||||
|
||||
else -> throw IllegalArgumentException("Unknown config type ${configProperty.type}: '$value' for key ${configProperty.key}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
package de.grimsi.gameyfin.config.dto
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude
|
||||
import jakarta.annotation.Nonnull
|
||||
import java.io.Serializable
|
||||
|
||||
@JsonInclude(JsonInclude.Include.ALWAYS)
|
||||
data class ConfigEntryDto(
|
||||
@field:Nonnull val key: String,
|
||||
val value: String?,
|
||||
val defaultValue: String?,
|
||||
@field:Nonnull val type: String,
|
||||
@field:Nonnull val description: String,
|
||||
val key: String,
|
||||
val description: String,
|
||||
val value: Serializable?,
|
||||
val defaultValue: Serializable?,
|
||||
val type: String,
|
||||
val elementType: String?,
|
||||
val allowedValues: List<String>?
|
||||
)
|
||||
@@ -1,6 +1,12 @@
|
||||
package de.grimsi.gameyfin.config.dto
|
||||
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
|
||||
import de.grimsi.gameyfin.core.serialization.ArrayDeserializer
|
||||
import java.io.Serializable
|
||||
|
||||
data class ConfigValuePairDto(
|
||||
val key: String,
|
||||
val value: String?
|
||||
|
||||
@field:JsonDeserialize(using = ArrayDeserializer::class)
|
||||
val value: Serializable?
|
||||
)
|
||||
@@ -18,9 +18,7 @@ class FilesystemService(
|
||||
private val log = KotlinLogging.logger {}
|
||||
|
||||
private val gameFileExtensions
|
||||
get() = config.get(ConfigProperties.Libraries.Scan.GameFileExtensions)!!
|
||||
.split(",")
|
||||
.map { it.trim().lowercase() }
|
||||
get() = config.get(ConfigProperties.Libraries.Scan.GameFileExtensions)!!.map { it.trim().lowercase() }
|
||||
|
||||
/**
|
||||
* Lists all files and directories in the given path.
|
||||
@@ -45,7 +43,7 @@ class FilesystemService(
|
||||
return safeReadDirectoryContents(roots.first().toString())
|
||||
}
|
||||
|
||||
var path = FilenameUtils.separatorsToSystem(path)
|
||||
val path = FilenameUtils.separatorsToSystem(path)
|
||||
|
||||
return safeReadDirectoryContents(path)
|
||||
}
|
||||
@@ -93,8 +91,8 @@ class FilesystemService(
|
||||
}
|
||||
|
||||
// Return all paths that are directories or match the game file extensions
|
||||
return validDirectories.flatMap {
|
||||
safeReadDirectoryContents(it)
|
||||
return validDirectories.flatMap { validDirectory ->
|
||||
safeReadDirectoryContents(validDirectory)
|
||||
.filter { it.isDirectory() || it.extension.lowercase() in gamefileExtensions }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package de.grimsi.gameyfin.core.serialization
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParser
|
||||
import com.fasterxml.jackson.databind.DeserializationContext
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import java.io.Serializable
|
||||
|
||||
class ArrayDeserializer : JsonDeserializer<Serializable>() {
|
||||
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Serializable {
|
||||
val node = p.codec.readTree<JsonNode>(p)
|
||||
return if (node.isArray) {
|
||||
node.map { it.asText() }.toTypedArray()
|
||||
} else {
|
||||
p.codec.treeToValue(node, Serializable::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -16,7 +16,7 @@ abstract class AbstractMessageProvider(
|
||||
private val configKey = String.format("%s.%s.enabled", BASE_KEY, providerKey)
|
||||
|
||||
val enabled: Boolean
|
||||
get() = config.get(configKey).toBoolean()
|
||||
get() = config.get(configKey) as Boolean
|
||||
|
||||
abstract fun testCredentials(credentials: Properties): Boolean
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
logging.level.de.grimsi.gameyfin: DEBUG
|
||||
logging.level.org.hibernate.SQL: DEBUG
|
||||
logging.level.org.hibernate.type: TRACE
|
||||
logging.level.org.hibernate.type: TRACE
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:h2:file:./db/${spring.datasource.db-name};AUTO_SERVER=TRUE
|
||||
Reference in New Issue
Block a user