Added support for Array in ConfigProperties

Added ArrayInput to config page
Added game file extension management
This commit is contained in:
grimsi
2025-05-03 16:02:42 +02:00
parent 5c0e06c1d7
commit 1e6a448fac
15 changed files with 229 additions and 81 deletions
@@ -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)
}
}
}
@@ -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