From 1e6a448fac4bdb12ecc4e5fe5581298e9a09d729 Mon Sep 17 00:00:00 2001
From: grimsi <9295182+grimsi@users.noreply.github.com>
Date: Sat, 3 May 2025 16:02:42 +0200
Subject: [PATCH] Added support for Array in ConfigProperties Added ArrayInput
to config page Added game file extension management
---
.../administration/ConfigFormField.tsx | 7 +-
.../administration/LibraryManagement.tsx | 1 +
.../administration/UserManagement.tsx | 2 +-
.../administration/withConfigPage.tsx | 28 ++++-
.../components/general/input/ArrayInput.tsx | 68 +++++++++++
.../general/input/CheckboxInput.tsx | 2 +-
.../grimsi/gameyfin/config/ConfigEndpoint.kt | 5 +-
.../gameyfin/config/ConfigProperties.kt | 27 ++++-
.../grimsi/gameyfin/config/ConfigService.kt | 113 +++++++++---------
.../gameyfin/config/dto/ConfigEntryDto.kt | 13 +-
.../gameyfin/config/dto/ConfigValuePairDto.kt | 8 +-
.../core/filesystem/FilesystemService.kt | 10 +-
.../core/serialization/ArrayDeseriazlizer.kt | 18 +++
.../providers/AbstractMessageProvider.kt | 2 +-
.../src/main/resources/application-dev.yml | 6 +-
15 files changed, 229 insertions(+), 81 deletions(-)
create mode 100644 gameyfin/src/main/frontend/components/general/input/ArrayInput.tsx
create mode 100644 gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/serialization/ArrayDeseriazlizer.kt
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