diff --git a/gameyfin/src/main/frontend/components/administration/ConfigFormField.tsx b/gameyfin/src/main/frontend/components/administration/ConfigFormField.tsx index d64e669..5c0f2e7 100644 --- a/gameyfin/src/main/frontend/components/administration/ConfigFormField.tsx +++ b/gameyfin/src/main/frontend/components/administration/ConfigFormField.tsx @@ -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) { ); + case "Array": + return ( + + ); default: return
Unsupported type: {configElement.type} for key {configElement.key}
; } } - return (inputElement(configElement!)); + return inputElement(configElement!); } \ No newline at end of file diff --git a/gameyfin/src/main/frontend/components/administration/LibraryManagement.tsx b/gameyfin/src/main/frontend/components/administration/LibraryManagement.tsx index ac4162e..df5e1b8 100644 --- a/gameyfin/src/main/frontend/components/administration/LibraryManagement.tsx +++ b/gameyfin/src/main/frontend/components/administration/LibraryManagement.tsx @@ -50,6 +50,7 @@ function LibraryManagementLayout({getConfig, formik}: any) {
+
diff --git a/gameyfin/src/main/frontend/components/administration/UserManagement.tsx b/gameyfin/src/main/frontend/components/administration/UserManagement.tsx index 3976a0a..b2e9321 100644 --- a/gameyfin/src/main/frontend/components/administration/UserManagement.tsx +++ b/gameyfin/src/main/frontend/components/administration/UserManagement.tsx @@ -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) ); }, []); diff --git a/gameyfin/src/main/frontend/components/administration/withConfigPage.tsx b/gameyfin/src/main/frontend/components/administration/withConfigPage.tsx index 6a8848c..addb458 100644 --- a/gameyfin/src/main/frontend/components/administration/withConfigPage.tsx +++ b/gameyfin/src/main/frontend/components/administration/withConfigPage.tsx @@ -62,13 +62,35 @@ export default function withConfigPage(WrappedComponent: React.ComponentType 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: diff --git a/gameyfin/src/main/frontend/components/general/input/ArrayInput.tsx b/gameyfin/src/main/frontend/components/general/input/ArrayInput.tsx new file mode 100644 index 0000000..e222b46 --- /dev/null +++ b/gameyfin/src/main/frontend/components/general/input/ArrayInput.tsx @@ -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(""); + + return ( + { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Enter" || event.key == "Tab" || event.key === ",") { + event.preventDefault(); + let trimmedValue = newElementValue.trim(); + if (trimmedValue !== "") { + arrayHelpers.push(trimmedValue); + setNewElementValue(""); + } + } + } + + return ( +
+
+

{label}

+ {field.value.length} {field.value.length == 1 ? "element" : "elements"} +
+ +
+ {field.value.map((element: any, index: number) => ( + arrayHelpers.remove(index)}> + {element} + + ))} + + + + + + setNewElementValue(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="New element..." + variant="bordered" + /> + + +
+ +
+ {meta.touched && meta.error && meta.error.trim().length > 0 && ( + + )} +
+
+ ); + }} + /> + ); +} + +export default ArrayInput; \ No newline at end of file diff --git a/gameyfin/src/main/frontend/components/general/input/CheckboxInput.tsx b/gameyfin/src/main/frontend/components/general/input/CheckboxInput.tsx index 1a39f16..cd9e088 100644 --- a/gameyfin/src/main/frontend/components/general/input/CheckboxInput.tsx +++ b/gameyfin/src/main/frontend/components/general/input/CheckboxInput.tsx @@ -7,7 +7,7 @@ const CheckboxInput = ({label, ...props}) => { const [field] = useField(props); return ( -
+
( true ) - data object GameFileExtensions : ConfigProperties( - String::class, + data object GameFileExtensions : ConfigProperties>( + Array::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" + ) ) } diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/config/ConfigService.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/config/ConfigService.kt index 95c1813..652577e 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/config/ConfigService.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/config/ConfigService.kt @@ -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 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).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 getValue(value: String, configProperty: ConfigProperties): 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 getValue(value: Serializable, configProperty: ConfigProperties): 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}") } } diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/config/dto/ConfigEntryDto.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/config/dto/ConfigEntryDto.kt index 9bfe4da..ef69b3c 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/config/dto/ConfigEntryDto.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/config/dto/ConfigEntryDto.kt @@ -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? ) \ No newline at end of file diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/config/dto/ConfigValuePairDto.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/config/dto/ConfigValuePairDto.kt index 5221659..27c2746 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/config/dto/ConfigValuePairDto.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/config/dto/ConfigValuePairDto.kt @@ -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? ) \ No newline at end of file diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/filesystem/FilesystemService.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/filesystem/FilesystemService.kt index a506163..4d42bfc 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/filesystem/FilesystemService.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/filesystem/FilesystemService.kt @@ -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 } } } diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/serialization/ArrayDeseriazlizer.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/serialization/ArrayDeseriazlizer.kt new file mode 100644 index 0000000..51b026c --- /dev/null +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/serialization/ArrayDeseriazlizer.kt @@ -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() { + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Serializable { + val node = p.codec.readTree(p) + return if (node.isArray) { + node.map { it.asText() }.toTypedArray() + } else { + p.codec.treeToValue(node, Serializable::class.java) + } + } +} \ No newline at end of file diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/messages/providers/AbstractMessageProvider.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/messages/providers/AbstractMessageProvider.kt index a92016b..eefcf62 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/messages/providers/AbstractMessageProvider.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/messages/providers/AbstractMessageProvider.kt @@ -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 diff --git a/gameyfin/src/main/resources/application-dev.yml b/gameyfin/src/main/resources/application-dev.yml index 95a362c..0ffe8cd 100644 --- a/gameyfin/src/main/resources/application-dev.yml +++ b/gameyfin/src/main/resources/application-dev.yml @@ -1,3 +1,7 @@ logging.level.de.grimsi.gameyfin: DEBUG logging.level.org.hibernate.SQL: DEBUG -logging.level.org.hibernate.type: TRACE \ No newline at end of file +logging.level.org.hibernate.type: TRACE + +spring: + datasource: + url: jdbc:h2:file:./db/${spring.datasource.db-name};AUTO_SERVER=TRUE \ No newline at end of file