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 Input from "Frontend/components/general/input/Input";
import CheckboxInput from "Frontend/components/general/input/CheckboxInput"; import CheckboxInput from "Frontend/components/general/input/CheckboxInput";
import SelectInput from "Frontend/components/general/input/SelectInput"; import SelectInput from "Frontend/components/general/input/SelectInput";
import ArrayInput from "Frontend/components/general/input/ArrayInput";
export default function ConfigFormField({configElement, ...props}: any) { export default function ConfigFormField({configElement, ...props}: any) {
function inputElement(configElement: ConfigEntryDto) { function inputElement(configElement: ConfigEntryDto) {
@@ -34,10 +35,14 @@ export default function ConfigFormField({configElement, ...props}: any) {
<Input label={configElement.description} name={configElement.key} type="number" <Input label={configElement.description} name={configElement.key} type="number"
step="1" {...props}/> step="1" {...props}/>
); );
case "Array":
return (
<ArrayInput label={configElement.description} name={configElement.key} type="text" {...props}/>
);
default: default:
return <pre>Unsupported type: {configElement.type} for key {configElement.key}</pre>; 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"/> <Section title="Scanning"/>
<ConfigFormField configElement={getConfig("library.scan.enable-filesystem-watcher")}/> <ConfigFormField configElement={getConfig("library.scan.enable-filesystem-watcher")}/>
<ConfigFormField configElement={getConfig("library.scan.game-file-extensions")}/>
<Section title="Metadata"/> <Section title="Metadata"/>
<div className="flex flex-row"> <div className="flex flex-row">
@@ -21,7 +21,7 @@ function UserManagementLayout({getConfig, formik}: any) {
); );
ConfigEndpoint.get("sso.oidc.auto-register-new-users").then( 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; let value: any;
switch (item.type) { switch (item.type) {
case 'Boolean': case 'Boolean':
value = item.value === 'true'; value = typeof item.value == 'boolean' ? item.value : item.value === 'true';
break; break;
case 'Int': case 'Int':
value = parseInt(item.value!); value = typeof item.value == 'number' ? item.value : 0;
break; break;
case 'Float': 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; break;
case 'String': case 'String':
default: 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); const [field] = useField(props);
return ( 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 <Checkbox
{...field} {...field}
{...props} {...props}
@@ -6,6 +6,7 @@ import de.grimsi.gameyfin.config.dto.ConfigValuePairDto
import de.grimsi.gameyfin.core.Role import de.grimsi.gameyfin.core.Role
import jakarta.annotation.security.PermitAll import jakarta.annotation.security.PermitAll
import jakarta.annotation.security.RolesAllowed import jakarta.annotation.security.RolesAllowed
import java.io.Serializable
@Endpoint @Endpoint
@RolesAllowed(Role.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
@@ -19,7 +20,7 @@ class ConfigEndpoint(
return config.getAll(prefix) return config.getAll(prefix)
} }
fun get(key: String): String? { fun get(key: String): Serializable? {
return config.get(key) return config.get(key)
} }
@@ -32,7 +33,7 @@ class ConfigEndpoint(
} }
fun resetConfig(key: String) { fun resetConfig(key: String) {
config.resetConfigValue(key) config.deleteConfig(key)
} }
fun deleteConfig(key: String) { fun deleteConfig(key: String) {
@@ -29,11 +29,32 @@ sealed class ConfigProperties<T : Serializable>(
true true
) )
data object GameFileExtensions : ConfigProperties<String>( data object GameFileExtensions : ConfigProperties<Array<String>>(
String::class, Array<String>::class,
"library.scan.game-file-extensions", "library.scan.game-file-extensions",
"File extensions to consider as games", "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.entities.ConfigEntry
import de.grimsi.gameyfin.config.persistence.ConfigRepository import de.grimsi.gameyfin.config.persistence.ConfigRepository
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.io.Serializable import java.io.Serializable
@@ -33,14 +34,22 @@ class ConfigService(
} }
return configProperties.map { configProperty -> 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( ConfigEntryDto(
key = configProperty.key, key = configProperty.key,
value = appConfig?.value ?: configProperty.default?.toString(), value = parsedValue ?: configProperty.default,
defaultValue = configProperty.default?.toString(), defaultValue = configProperty.default,
type = configProperty.type.simpleName ?: "Unknown", type = configProperty.type.simpleName ?: "Unknown",
description = configProperty.description, elementType = configProperty.type.java.componentType?.simpleName,
allowedValues = configProperty.allowedValues?.map { it.toString() } allowedValues = configProperty.allowedValues?.map { it.toString() },
description = configProperty.description
) )
} }
} }
@@ -71,18 +80,18 @@ class ConfigService(
* @param key: The key of the config property * @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 * @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'" } log.info { "Getting config value '$key'" }
val configProperty = findConfigProperty(key) val configProperty = findConfigProperty(key)
val appConfig = appConfigRepository.findById(configProperty.key).orElse(null) val appConfig = appConfigRepository.findById(configProperty.key).orElse(null)
return if (appConfig != null) { if (appConfig != null) return getValue(appConfig.value, configProperty)
getValue(appConfig.value, configProperty).toString()
} else { if (configProperty.default == null) return null
configProperty.default?.toString() ?: return null
} return configProperty.default
} }
/** /**
@@ -116,51 +125,32 @@ class ConfigService(
* @param value: Value to set the config property to * @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 * @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) { fun <T : Serializable> set(key: String, value: T) {
log.info { "Set config value '$key'" } 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 var configEntry = appConfigRepository.findByIdOrNull(key)
val castedValue = getValue(value.toString(), configKey)
var configEntry = appConfigRepository.findById(key).orElse(null) val parsedValue =
if (value.javaClass.isArray)
(value as Array<Serializable>).joinToString(",")
else
value.toString()
if (configEntry == null) { if (configEntry == null) {
configEntry = ConfigEntry(configKey.key, castedValue.toString()) configEntry = ConfigEntry(configProperty.key, parsedValue)
} else { } else {
configEntry.value = castedValue.toString() configEntry.value = parsedValue
} }
appConfigRepository.save(configEntry) appConfigRepository.save(configEntry)
} }
/** /**
* Reset a given config property to its default value if it has a default value. * Remove a config property from the database.
* Otherwise, delete the config key from the database. * This will also cause it to reset to its default value.
*
* @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
* *
* @param key: Key of the config property * @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. * Get the value of the config property in a type-safe way.
*/ */
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
private fun <T : Serializable> getValue(value: String, configProperty: ConfigProperties<T>): T { private fun <T : Serializable> getValue(value: Serializable, configProperty: ConfigProperties<T>): T {
return when (configProperty.type) { val value = value.toString()
String::class -> value as T return when {
Boolean::class -> value.toBoolean() as T configProperty.type == String::class -> value as T
Int::class -> value.toFloat().toInt() as T configProperty.type == Boolean::class -> value.toBoolean() as T
Float::class -> value.toFloat() as T configProperty.type == Int::class -> value.toFloat().toInt() as T
else -> { configProperty.type == Float::class -> value.toFloat() as T
if (configProperty.type.java.isEnum) {
val enumConstants = configProperty.type.java.enumConstants configProperty.type.java.isEnum -> {
enumConstants.firstOrNull { it.toString() == value } val enumConstants = configProperty.type.java.enumConstants
?: throw IllegalArgumentException("Unknown enum value '$value' for key ${configProperty.key}") enumConstants.firstOrNull { it.toString() == value }
} else { ?: throw IllegalArgumentException("Unknown enum value '$value' for key ${configProperty.key}")
throw IllegalArgumentException("Unknown config type ${configProperty.type}: '$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 package de.grimsi.gameyfin.config.dto
import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.annotation.JsonInclude
import jakarta.annotation.Nonnull import java.io.Serializable
@JsonInclude(JsonInclude.Include.ALWAYS) @JsonInclude(JsonInclude.Include.ALWAYS)
data class ConfigEntryDto( data class ConfigEntryDto(
@field:Nonnull val key: String, val key: String,
val value: String?, val description: String,
val defaultValue: String?, val value: Serializable?,
@field:Nonnull val type: String, val defaultValue: Serializable?,
@field:Nonnull val description: String, val type: String,
val elementType: String?,
val allowedValues: List<String>? val allowedValues: List<String>?
) )
@@ -1,6 +1,12 @@
package de.grimsi.gameyfin.config.dto 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( data class ConfigValuePairDto(
val key: String, 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 log = KotlinLogging.logger {}
private val gameFileExtensions private val gameFileExtensions
get() = config.get(ConfigProperties.Libraries.Scan.GameFileExtensions)!! get() = config.get(ConfigProperties.Libraries.Scan.GameFileExtensions)!!.map { it.trim().lowercase() }
.split(",")
.map { it.trim().lowercase() }
/** /**
* Lists all files and directories in the given path. * Lists all files and directories in the given path.
@@ -45,7 +43,7 @@ class FilesystemService(
return safeReadDirectoryContents(roots.first().toString()) return safeReadDirectoryContents(roots.first().toString())
} }
var path = FilenameUtils.separatorsToSystem(path) val path = FilenameUtils.separatorsToSystem(path)
return safeReadDirectoryContents(path) return safeReadDirectoryContents(path)
} }
@@ -93,8 +91,8 @@ class FilesystemService(
} }
// Return all paths that are directories or match the game file extensions // Return all paths that are directories or match the game file extensions
return validDirectories.flatMap { return validDirectories.flatMap { validDirectory ->
safeReadDirectoryContents(it) safeReadDirectoryContents(validDirectory)
.filter { it.isDirectory() || it.extension.lowercase() in gamefileExtensions } .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) private val configKey = String.format("%s.%s.enabled", BASE_KEY, providerKey)
val enabled: Boolean val enabled: Boolean
get() = config.get(configKey).toBoolean() get() = config.get(configKey) as Boolean
abstract fun testCredentials(credentials: Properties): Boolean abstract fun testCredentials(credentials: Properties): Boolean
@@ -1,3 +1,7 @@
logging.level.de.grimsi.gameyfin: DEBUG logging.level.de.grimsi.gameyfin: DEBUG
logging.level.org.hibernate.SQL: 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