Implement type-safe config for plugins in BE and FE

This commit is contained in:
grimsi
2025-06-03 17:51:17 +02:00
parent 6e390df900
commit 0050ab1f74
30 changed files with 372 additions and 259 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ dependencies {
implementation("jakarta.validation:jakarta.validation-api:3.1.0")
// Kotlin extensions
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation(kotlin("reflect"))
// Reactive
implementation("org.springframework.boot:spring-boot-starter-webflux")
@@ -1,4 +1,4 @@
import React, {useEffect} from "react";
import React from "react";
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import withConfigPage from "Frontend/components/administration/withConfigPage";
import Section from "Frontend/components/general/Section";
@@ -11,16 +11,12 @@ import LibraryCreationModal from "Frontend/components/general/modals/LibraryCrea
import LibraryUpdateDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryUpdateDto";
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto";
import {useSnapshot} from "valtio/react";
import {initializeLibraryState, libraryState} from "Frontend/state/LibraryState";
import {libraryState} from "Frontend/state/LibraryState";
function LibraryManagementLayout({getConfig, formik}: any) {
const libraryCreationModal = useDisclosure();
const state = useSnapshot(libraryState);
useEffect(() => {
initializeLibraryState();
}, []);
async function updateLibrary(library: LibraryUpdateDto) {
await LibraryEndpoint.updateLibrary(library);
addToast({
@@ -52,9 +52,11 @@ function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) {
<div className="flex flex-row">
<ConfigFormField configElement={getConfig("sso.oidc.auto-register-new-users")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.match-existing-users-by")}
isDisabled={!formik.values.sso.oidc.enabled ||
!formik.values.sso.oidc["auto-register-new-users"]}/>
<div className="flex flex-row flex-1 justify-center gap-2">
<ConfigFormField configElement={getConfig("sso.oidc.match-existing-users-by")}
isDisabled={!formik.values.sso.oidc.enabled ||
!formik.values.sso.oidc["auto-register-new-users"]}/>
</div>
</div>
<Section title="SSO provider configuration"/>
@@ -23,9 +23,9 @@ import PluginTrustLevel from "Frontend/generated/de/grimsi/gameyfin/core/plugins
import {PluginEndpoint} from "Frontend/generated/endpoints";
import PluginDto from "Frontend/generated/de/grimsi/gameyfin/core/plugins/dto/PluginDto";
import PluginConfigValidationResult
from "Frontend/generated/de/grimsi/gameyfin/pluginapi/core/PluginConfigValidationResult";
from "Frontend/generated/de/grimsi/gameyfin/pluginapi/core/config/PluginConfigValidationResult";
import PluginConfigValidationResultType
from "Frontend/generated/de/grimsi/gameyfin/pluginapi/core/PluginConfigValidationResultType";
from "Frontend/generated/de/grimsi/gameyfin/pluginapi/core/config/PluginConfigValidationResultType";
export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
const pluginDetailsModal = useDisclosure();
@@ -0,0 +1,58 @@
import SelectInput from "Frontend/components/general/input/SelectInput";
import CheckboxInput from "Frontend/components/general/input/CheckboxInput";
import Input from "Frontend/components/general/input/Input";
import React from "react";
import PluginConfigMetadataDto from "Frontend/generated/de/grimsi/gameyfin/core/plugins/dto/PluginConfigMetadataDto";
export default function PluginConfigFormField({pluginConfigMetadata, ...props}: any) {
function inputElement(metadata: PluginConfigMetadataDto) {
if (metadata.allowedValues != null && metadata.allowedValues.length > 0) {
return (
<SelectInput label={metadata.label}
name={metadata.key}
values={metadata.allowedValues}
{...props}/>
);
}
switch (metadata.type) {
case "Boolean":
return (
<CheckboxInput label={metadata.label}
name={metadata.key}
{...props}/>
);
case "String":
return (
<Input label={metadata.label}
name={metadata.key}
type={metadata.secret ? "password" : "text"}
isRequired={metadata.required}
{...props}/>
);
case "Float":
return (
<Input label={metadata.label}
name={metadata.key}
type="number"
isRequired={metadata.required}
step="0.1"
{...props}/>
);
case "Int":
return (
<Input label={metadata.label}
name={metadata.key}
type="number"
isRequired={metadata.required}
step="1"
{...props}/>
);
default:
return <pre>Unsupported type: {metadata.type} for key {metadata.key}</pre>;
}
}
return inputElement(pluginConfigMetadata!);
}
@@ -6,23 +6,19 @@ const SelectInput = ({label, values, ...props}) => {
// @ts-ignore
const [field] = useField(props);
const items = values.map((v: string) => ({key: v, label: v}));
return (
<div className="flex flex-row flex-1 justify-center gap-2">
<Select
{...field}
{...props}
id={field.name}
label={label}
selectedKeys={[field.value]}
disallowEmptySelection
>
{values.map((value: string) => (
<SelectItem key={value}>
{value}
</SelectItem>
))}
</Select>
</div>
<Select
{...field}
{...props}
label={label}
items={items}
selectedKeys={[field.value]}
disallowEmptySelection
>
{(item: { key: string, label: string }) => <SelectItem>{item.label}</SelectItem>}
</Select>
);
}
@@ -1,14 +1,14 @@
import React, {useState} from "react";
import {addToast, Button, Link, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, Tooltip} from "@heroui/react";
import {Form, Formik} from "formik";
import PluginConfigElement from "Frontend/generated/de/grimsi/gameyfin/pluginapi/core/PluginConfigElement";
import Input from "Frontend/components/general/input/Input";
import PluginLogo from "Frontend/components/general/plugin/PluginLogo";
import Markdown from "react-markdown";
import remarkBreaks from "remark-breaks";
import {PluginEndpoint} from "Frontend/generated/endpoints";
import PluginDto from "Frontend/generated/de/grimsi/gameyfin/core/plugins/dto/PluginDto";
import {ArrowClockwise} from "@phosphor-icons/react";
import PluginConfigMetadataDto from "Frontend/generated/de/grimsi/gameyfin/core/plugins/dto/PluginConfigMetadataDto";
import PluginConfigFormField from "Frontend/components/general/input/PluginConfigFormField";
interface PluginDetailsModalProps {
plugin: PluginDto;
@@ -35,11 +35,28 @@ export default function PluginDetailsModal({plugin, isOpen, onOpenChange}: Plugi
});
}
function getEffectiveConfig(): Record<string, string> {
const effectiveConfig: Record<string, string> = {};
if (!plugin.configMetadata) return effectiveConfig;
for (const meta of plugin.configMetadata) {
const key = meta.key;
let value = plugin.config?.[key]?.toString();
if (value == null && meta.default != null) {
value = meta.default.toString();
}
if (value) {
effectiveConfig[key] = value;
}
}
return effectiveConfig;
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg">
<ModalContent>
{(onClose) => (
<Formik initialValues={plugin.config}
<Formik initialValues={getEffectiveConfig()}
initialErrors={plugin.configValidation?.errors}
enableReinitialize={true}
onSubmit={async (values: any) => {
@@ -138,10 +155,11 @@ export default function PluginDetailsModal({plugin, isOpen, onOpenChange}: Plugi
</>}
</div>
{(plugin.configMetadata && plugin.configMetadata.length > 0) ?
plugin.configMetadata.map((entry: PluginConfigElement) => (
<Input key={entry.key} name={entry.key} label={entry.name}
showErrorUntouched={true}
type={entry.secret ? "password" : "text"}/>
plugin.configMetadata.map((entry: PluginConfigMetadataDto) => (
<PluginConfigFormField
key={entry.key}
pluginConfigMetadata={entry}
showErrorUntouched={true}/>
)) : "This plugin has no configuration options."
}
</ModalBody>
+1 -1
View File
@@ -1,7 +1,7 @@
import {createRoot} from 'react-dom/client';
import {StrictMode} from "react";
import {RouterProvider} from "react-router";
import router from "./routes";
import {router} from './routes';
const container = document.getElementById('outlet')!;
const root = createRoot(container);
@@ -3,7 +3,7 @@ package de.grimsi.gameyfin.core.plugins
import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.core.Role
import de.grimsi.gameyfin.core.plugins.dto.PluginUpdateDto
import de.grimsi.gameyfin.pluginapi.core.PluginConfigValidationResult
import de.grimsi.gameyfin.pluginapi.core.config.PluginConfigValidationResult
import de.grimsi.gameyfin.users.util.isAdmin
import jakarta.annotation.security.PermitAll
import jakarta.annotation.security.RolesAllowed
@@ -3,16 +3,16 @@ package de.grimsi.gameyfin.core.plugins
import de.grimsi.gameyfin.core.plugins.config.PluginConfigEntry
import de.grimsi.gameyfin.core.plugins.config.PluginConfigEntryKey
import de.grimsi.gameyfin.core.plugins.config.PluginConfigRepository
import de.grimsi.gameyfin.core.plugins.dto.PluginConfigMetadataDto
import de.grimsi.gameyfin.core.plugins.dto.PluginDto
import de.grimsi.gameyfin.core.plugins.dto.PluginUpdateDto
import de.grimsi.gameyfin.core.plugins.management.GameyfinPluginDescriptor
import de.grimsi.gameyfin.core.plugins.management.GameyfinPluginManager
import de.grimsi.gameyfin.core.plugins.management.PluginManagementEntry
import de.grimsi.gameyfin.core.plugins.management.PluginManagementRepository
import de.grimsi.gameyfin.pluginapi.core.Configurable
import de.grimsi.gameyfin.pluginapi.core.GameyfinPlugin
import de.grimsi.gameyfin.pluginapi.core.PluginConfigElement
import de.grimsi.gameyfin.pluginapi.core.PluginConfigValidationResult
import de.grimsi.gameyfin.pluginapi.core.config.Configurable
import de.grimsi.gameyfin.pluginapi.core.config.PluginConfigValidationResult
import de.grimsi.gameyfin.pluginapi.core.wrapper.GameyfinPlugin
import io.github.oshai.kotlinlogging.KotlinLogging
import org.pf4j.ExtensionPoint
import org.pf4j.PluginWrapper
@@ -96,11 +96,24 @@ class PluginService(
return plugin.getLogo()
}
fun getConfigMetadata(pluginWrapper: PluginWrapper): List<PluginConfigElement> {
fun getConfigMetadata(pluginWrapper: PluginWrapper): List<PluginConfigMetadataDto>? {
log.debug { "Getting config metadata for plugin ${pluginWrapper.pluginId}" }
val plugin = pluginWrapper.plugin
if (plugin !is Configurable) return emptyList()
return plugin.configMetadata
if (plugin !is Configurable) return null
return plugin.configMetadata.map { meta ->
PluginConfigMetadataDto(
key = meta.key,
type = meta.type.simpleName ?: "Unknown",
label = meta.label,
description = meta.description,
default = meta.default,
isSecret = meta.isSecret,
isRequired = meta.isRequired,
allowedValues = meta.allowedValues?.map { it.toString() }
)
}
}
fun getConfig(pluginWrapper: PluginWrapper): Map<String, String?> {
@@ -0,0 +1,16 @@
package de.grimsi.gameyfin.core.plugins.dto
import com.fasterxml.jackson.annotation.JsonInclude
import java.io.Serializable
@JsonInclude(JsonInclude.Include.ALWAYS)
class PluginConfigMetadataDto(
val key: String,
val type: String,
val label: String,
val description: String,
val default: Serializable?,
val isSecret: Boolean,
val isRequired: Boolean,
val allowedValues: List<String>?
)
@@ -1,8 +1,7 @@
package de.grimsi.gameyfin.core.plugins.dto
import de.grimsi.gameyfin.core.plugins.management.PluginTrustLevel
import de.grimsi.gameyfin.pluginapi.core.PluginConfigElement
import de.grimsi.gameyfin.pluginapi.core.PluginConfigValidationResult
import de.grimsi.gameyfin.pluginapi.core.config.PluginConfigValidationResult
import org.pf4j.PluginState
data class PluginDto(
@@ -17,7 +16,7 @@ data class PluginDto(
val url: String? = null,
val hasLogo: Boolean,
val state: PluginState,
val configMetadata: List<PluginConfigElement>? = null,
val configMetadata: List<PluginConfigMetadataDto>? = null,
val config: Map<String, String?>? = null,
val configValidation: PluginConfigValidationResult? = null,
val priority: Int,
@@ -1,7 +1,7 @@
package de.grimsi.gameyfin.core.plugins.dto
import com.fasterxml.jackson.annotation.JsonInclude
import de.grimsi.gameyfin.pluginapi.core.PluginConfigValidationResult
import de.grimsi.gameyfin.pluginapi.core.config.PluginConfigValidationResult
import org.pf4j.PluginState
@JsonInclude(JsonInclude.Include.NON_NULL)
@@ -1,9 +1,9 @@
package de.grimsi.gameyfin.core.plugins.management
import de.grimsi.gameyfin.core.plugins.config.PluginConfigRepository
import de.grimsi.gameyfin.pluginapi.core.Configurable
import de.grimsi.gameyfin.pluginapi.core.PluginConfigValidationResult
import de.grimsi.gameyfin.pluginapi.core.PluginConfigValidationResultType
import de.grimsi.gameyfin.pluginapi.core.config.Configurable
import de.grimsi.gameyfin.pluginapi.core.config.PluginConfigValidationResult
import de.grimsi.gameyfin.pluginapi.core.config.PluginConfigValidationResultType
import io.github.oshai.kotlinlogging.KotlinLogging
import org.pf4j.*
import org.springframework.data.repository.findByIdOrNull
@@ -164,7 +164,7 @@ class GameyfinPluginManager(
fun restart(pluginId: String) {
val plugin = getPlugin(pluginId)?.plugin ?: return
stopPlugin(pluginId)
if (plugin is Configurable) plugin.config = getConfig(pluginId)
if (plugin is Configurable) plugin.loadConfig(getConfig(pluginId))
startPlugin(pluginId)
}
@@ -213,7 +213,7 @@ class GameyfinPluginManager(
val plugin = pluginWrapper.plugin
if (plugin is Configurable) {
val config = getConfig(pluginWrapper.pluginId)
plugin.config = config
plugin.loadConfig(config)
}
}
+15 -95
View File
@@ -16,20 +16,19 @@ import settings from './build/vaadin-dev-server-settings.json';
import {
AssetInfo,
ChunkInfo,
build,
defineConfig,
mergeConfig,
OutputOptions,
PluginOption,
InlineConfig,
UserConfigFn
} from 'vite';
import { getManifest, type ManifestTransform } from 'workbox-build';
import * as rollup from 'rollup';
import brotli from 'rollup-plugin-brotli';
import checker from 'vite-plugin-checker';
import postcssLit from './build/plugins/rollup-plugin-postcss-lit-custom/rollup-plugin-postcss-lit.js';
import vaadinI18n from './build/plugins/rollup-plugin-vaadin-i18n/rollup-plugin-vaadin-i18n.js';
import serviceWorkerPlugin from './build/plugins/vite-plugin-service-worker';
import { createRequire } from 'module';
@@ -41,8 +40,6 @@ import vitePluginFileSystemRouter from '@vaadin/hilla-file-router/vite-plugin.js
// Make `require` compatible with ES modules
const require = createRequire(import.meta.url);
const appShellUrl = '.';
const frontendFolder = path.resolve(__dirname, settings.frontendFolder);
const themeFolder = path.resolve(frontendFolder, settings.themeFolder);
const frontendBundleFolder = path.resolve(__dirname, settings.frontendBundleOutput);
@@ -56,6 +53,7 @@ const buildOutputFolder = devBundle ? devBundleFolder : frontendBundleFolder;
const statsFolder = path.resolve(__dirname, devBundle ? settings.devBundleStatsOutput : settings.statsOutput);
const statsFile = path.resolve(statsFolder, 'stats.json');
const bundleSizeFile = path.resolve(statsFolder, 'bundle-size.html');
const i18nFolder = path.resolve(__dirname, settings.i18nOutput);
const nodeModulesFolder = path.resolve(__dirname, 'node_modules');
const webComponentTags = '';
@@ -91,94 +89,6 @@ const target = ['safari15', 'es2022'];
console.trace = () => {};
console.debug = () => {};
function injectManifestToSWPlugin(): rollup.Plugin {
const rewriteManifestIndexHtmlUrl: ManifestTransform = (manifest) => {
const indexEntry = manifest.find((entry) => entry.url === 'index.html');
if (indexEntry) {
indexEntry.url = appShellUrl;
}
return { manifest, warnings: [] };
};
return {
name: 'vaadin:inject-manifest-to-sw',
async transform(code, id) {
if (/sw\.(ts|js)$/.test(id)) {
const { manifestEntries } = await getManifest({
globDirectory: buildOutputFolder,
globPatterns: ['**/*'],
globIgnores: ['**/*.br', 'pwa-icons/**'],
manifestTransforms: [rewriteManifestIndexHtmlUrl],
maximumFileSizeToCacheInBytes: 100 * 1024 * 1024 // 100mb,
});
return code.replace('self.__WB_MANIFEST', JSON.stringify(manifestEntries));
}
}
};
}
function buildSWPlugin(opts: { devMode: boolean }): PluginOption {
let buildConfig: InlineConfig;
let buildOutput: rollup.RollupOutput;
const devMode = opts.devMode;
return {
name: 'vaadin:build-sw',
enforce: 'post',
async configResolved(viteConfig) {
buildConfig = {
base: viteConfig.base,
root: viteConfig.root,
mode: viteConfig.mode,
resolve: viteConfig.resolve,
define: {
...viteConfig.define,
'process.env.NODE_ENV': JSON.stringify(viteConfig.mode),
},
build: {
write: !devMode,
minify: viteConfig.build.minify,
outDir: viteConfig.build.outDir,
target,
sourcemap: viteConfig.command === 'serve' || viteConfig.build.sourcemap,
emptyOutDir: false,
modulePreload: false,
rollupOptions: {
input: {
sw: settings.clientServiceWorkerSource
},
output: {
exports: 'none',
entryFileNames: 'sw.js',
inlineDynamicImports: true,
},
},
},
};
},
async buildStart() {
if (devMode) {
buildOutput = await build(buildConfig) as rollup.RollupOutput;
}
},
async load(id) {
if (id.endsWith('sw.js')) {
return buildOutput.output[0].code;
}
},
async closeBundle() {
if (!devMode) {
await build({
...buildConfig,
plugins: [injectManifestToSWPlugin(), brotli()]
});
}
},
};
}
function statsExtracterPlugin(): PluginOption {
function collectThemeJsonsInFrontend(themeJsonContents: Record<string, string>, themeName: string) {
const themeJson = path.resolve(frontendFolder, settings.themeFolder, themeName, 'theme.json');
@@ -276,7 +186,7 @@ function statsExtracterPlugin(): PluginOption {
const frontendFiles: Record<string, string> = {};
frontendFiles['index.html'] = createHash('sha256').update(customIndexData.replace(/\r\n/g, '\n'), 'utf8').digest('hex');
const projectFileExtensions = ['.js', '.js.map', '.ts', '.ts.map', '.tsx', '.tsx.map', '.css', '.css.map', '.'];
const projectFileExtensions = ['.js', '.js.map', '.ts', '.ts.map', '.tsx', '.tsx.map', '.css', '.css.map'];
const isThemeComponentsResource = (id: string) =>
id.startsWith(themeOptions.frontendGeneratedFolder.replace(/\\/g, '/'))
@@ -753,7 +663,9 @@ export const vaadinConfig: UserConfigFn = (env) => {
productionMode && brotli(),
devMode && vaadinBundlesPlugin(),
devMode && showRecompileReason(),
settings.offlineEnabled && buildSWPlugin({ devMode }),
settings.offlineEnabled && serviceWorkerPlugin({
srcPath: settings.clientServiceWorkerSource,
}),
!devMode && statsExtracterPlugin(),
!productionMode && preserveUsageStats(),
themePlugin({ devMode }),
@@ -795,6 +707,14 @@ export const vaadinConfig: UserConfigFn = (env) => {
].filter(Boolean)
}
}),
productionMode && vaadinI18n({
cwd: __dirname,
meta: {
output: {
dir: i18nFolder,
},
},
}),
{
name: 'vaadin:force-remove-html-middleware',
configureServer(server) {
-9
View File
@@ -20,13 +20,4 @@ publishing {
dependencies {
// PF4J (shared)
api("org.pf4j:pf4j:${rootProject.extra["pf4jVersion"]}")
implementation(kotlin("stdlib"))
// Test dependencies
testImplementation(kotlin("test"))
}
tasks.test {
useJUnitPlatform()
}
@@ -1,9 +0,0 @@
package de.grimsi.gameyfin.pluginapi.core
interface Configurable {
val configMetadata: List<PluginConfigElement>
var config: Map<String, String?>
fun validateConfig(): PluginConfigValidationResult = validateConfig(config)
fun validateConfig(config: Map<String, String?>): PluginConfigValidationResult
}
@@ -1,17 +0,0 @@
package de.grimsi.gameyfin.pluginapi.core
import org.pf4j.PluginWrapper
abstract class ConfigurableGameyfinPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper), Configurable {
companion object {
lateinit var plugin: ConfigurableGameyfinPlugin
private set
}
init {
plugin = this
}
override var config: Map<String, String?> = emptyMap()
}
@@ -1,8 +0,0 @@
package de.grimsi.gameyfin.pluginapi.core
data class PluginConfigElement(
val key: String,
val name: String,
val description: String,
val isSecret: Boolean = false
)
@@ -0,0 +1,21 @@
package de.grimsi.gameyfin.pluginapi.core.config
import java.io.Serializable
typealias PluginConfigMetadata = List<ConfigMetadata<*>>
data class ConfigMetadata<T : Serializable>(
val key: String,
val type: Class<T>,
val label: String,
val description: String,
val default: T? = null,
val isSecret: Boolean = false,
val isRequired: Boolean = true,
) {
var allowedValues: List<T>? = null
init {
allowedValues = type.enumConstants?.toList()
}
}
@@ -0,0 +1,15 @@
package de.grimsi.gameyfin.pluginapi.core.config
import java.io.Serializable
interface Configurable {
val configMetadata: PluginConfigMetadata
fun loadConfig(config: Map<String, String?>)
fun validateConfig(): PluginConfigValidationResult
fun validateConfig(config: Map<String, String?>): PluginConfigValidationResult
fun <T : Serializable> config(key: String): T
fun <T : Serializable> optionalConfig(key: String): T?
}
@@ -1,3 +1,3 @@
package de.grimsi.gameyfin.pluginapi.core
package de.grimsi.gameyfin.pluginapi.core.config
class PluginConfigError(message: String) : RuntimeException(message)
@@ -1,4 +1,4 @@
package de.grimsi.gameyfin.pluginapi.core
package de.grimsi.gameyfin.pluginapi.core.config
data class PluginConfigValidationResult(
val result: PluginConfigValidationResultType,
@@ -0,0 +1,92 @@
package de.grimsi.gameyfin.pluginapi.core.wrapper
import de.grimsi.gameyfin.pluginapi.core.config.ConfigMetadata
import de.grimsi.gameyfin.pluginapi.core.config.Configurable
import de.grimsi.gameyfin.pluginapi.core.config.PluginConfigError
import de.grimsi.gameyfin.pluginapi.core.config.PluginConfigValidationResult
import org.pf4j.PluginWrapper
import java.io.Serializable
@Suppress("UNCHECKED_CAST")
abstract class ConfigurableGameyfinPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper), Configurable {
private var config: Map<String, String?> = emptyMap()
override fun loadConfig(config: Map<String, String?>) {
this.config = config
}
override fun validateConfig(): PluginConfigValidationResult = validateConfig(config)
override fun validateConfig(config: Map<String, String?>): PluginConfigValidationResult {
val errors = mutableMapOf<String, String>()
for (meta in configMetadata) {
val value = resolveValue(meta.key, config)
if (meta.isRequired && value == null) {
errors[meta.key] = "${meta.label} is required"
continue
}
if (value != null) {
try {
castConfigValue(meta, value)
} catch (e: PluginConfigError) {
errors[meta.key] = e.message ?: "Invalid value"
}
}
}
return if (errors.isEmpty()) {
PluginConfigValidationResult.VALID
} else {
PluginConfigValidationResult.INVALID(errors)
}
}
override fun <T : Serializable> optionalConfig(key: String): T? {
val meta = resolveMetadata(key)
val value = resolveValue(key)
if (value == null) return null
return try {
castConfigValue(meta, value) as T
} catch (e: Exception) {
throw PluginConfigError("Failed to cast value for key '$key' to type ${meta.type.simpleName}: ${e.message}")
}
}
private fun castConfigValue(meta: ConfigMetadata<*>, value: Any): Any? {
val expectedType = meta.type
return if (expectedType.isEnum) {
try {
java.lang.Enum.valueOf(expectedType as Class<out Enum<*>>, value.toString())
} catch (_: IllegalArgumentException) {
throw PluginConfigError("Invalid value '${value}', must be one of ${meta.allowedValues!!.joinToString(", ")}")
}
} else {
if (!expectedType.isInstance(value)) {
throw PluginConfigError("Value for key '${meta.key}' is not of type ${expectedType.simpleName}")
}
value
}
}
override fun <T : Serializable> config(key: String): T {
val value = optionalConfig<T>(key)
if (value == null) {
throw PluginConfigError("Required configuration key '$key' is missing or has no value")
}
return value
}
private fun resolveMetadata(key: String): ConfigMetadata<*> {
return configMetadata.find { it.key == key }
?: throw PluginConfigError("Unknown configuration key: $key")
}
private fun resolveValue(key: String, configOverride: Map<String, Serializable?>? = null): Serializable? {
val meta = resolveMetadata(key)
val conf = configOverride ?: config
return conf[key] ?: meta.default
}
}
@@ -1,8 +1,9 @@
package de.grimsi.gameyfin.pluginapi.core
package de.grimsi.gameyfin.pluginapi.core.wrapper
import org.pf4j.Plugin
import org.pf4j.PluginWrapper
@Suppress("DEPRECATION")
abstract class GameyfinPlugin(wrapper: PluginWrapper) : Plugin(wrapper) {
companion object {
@@ -0,0 +1,18 @@
package de.grimsi.gameyfin.plugins.directdownload
import de.grimsi.gameyfin.plugins.directdownload.CompressionMode.*
import java.util.zip.Deflater
enum class CompressionMode {
NONE,
FAST,
BEST;
}
fun CompressionMode.deflaterLevel(): Int {
return when (this) {
NONE -> Deflater.NO_COMPRESSION
FAST -> Deflater.BEST_SPEED
BEST -> Deflater.BEST_COMPRESSION
}
}
@@ -1,8 +1,8 @@
package de.grimsi.gameyfin.plugins.directdownload
import de.grimsi.gameyfin.pluginapi.core.ConfigurableGameyfinPlugin
import de.grimsi.gameyfin.pluginapi.core.PluginConfigElement
import de.grimsi.gameyfin.pluginapi.core.PluginConfigValidationResult
import de.grimsi.gameyfin.pluginapi.core.config.ConfigMetadata
import de.grimsi.gameyfin.pluginapi.core.config.PluginConfigMetadata
import de.grimsi.gameyfin.pluginapi.core.wrapper.ConfigurableGameyfinPlugin
import de.grimsi.gameyfin.pluginapi.download.Download
import de.grimsi.gameyfin.pluginapi.download.DownloadProvider
import de.grimsi.gameyfin.pluginapi.download.FileDownload
@@ -14,7 +14,6 @@ import java.io.PipedInputStream
import java.io.PipedOutputStream
import java.nio.file.*
import java.nio.file.attribute.BasicFileAttributes
import java.util.zip.Deflater
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
import kotlin.io.path.exists
@@ -24,31 +23,25 @@ import kotlin.io.path.isDirectory
class DirectDownloadPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wrapper) {
override val configMetadata: List<PluginConfigElement> = listOf(
PluginConfigElement(
companion object {
lateinit var plugin: DirectDownloadPlugin
private set
}
init {
plugin = this
}
override val configMetadata: PluginConfigMetadata = listOf(
ConfigMetadata(
key = "compressionMode",
name = "Compression mode (\"none\" = default, \"fast\", \"best\")",
type = CompressionMode::class.java,
label = "Compression mode",
description = "Higher compression modes are more resource intensive, but save bandwidth",
default = CompressionMode.NONE
)
)
override fun validateConfig(config: Map<String, String?>): PluginConfigValidationResult {
val compressionMode = config["compressionMode"]
if (compressionMode != null) {
return try {
CompressionMode.valueOf(compressionMode.uppercase())
PluginConfigValidationResult.VALID
} catch (_: IllegalArgumentException) {
PluginConfigValidationResult.INVALID(
mapOf("compressionMode" to "Invalid compression mode: $compressionMode (must be \"none\", \"fast\", or \"best\")")
)
}
}
return PluginConfigValidationResult.VALID
}
@Extension
class DirectDownloadProvider : DownloadProvider {
override fun download(path: Path): Download {
@@ -95,9 +88,8 @@ class DirectDownloadPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(
try {
ZipOutputStream(pipeOut).use { zos ->
zos.setLevel(CompressionMode.toDeflaterLevel(plugin.config["compressionMode"]?.let {
CompressionMode.valueOf(it.uppercase())
} ?: CompressionMode.NONE))
val compressionMode = plugin.config<CompressionMode>("compressionMode")
zos.setLevel(compressionMode.deflaterLevel())
Files.walkFileTree(path, object : SimpleFileVisitor<Path>() {
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
@@ -124,21 +116,4 @@ class DirectDownloadPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(
return pipeIn
}
}
}
enum class CompressionMode {
NONE,
FAST,
BEST;
companion object {
fun toDeflaterLevel(mode: CompressionMode): Int {
return when (mode) {
NONE -> Deflater.NO_COMPRESSION
FAST -> Deflater.BEST_SPEED
BEST -> Deflater.BEST_COMPRESSION
}
}
}
}
@@ -5,7 +5,8 @@ import com.api.igdb.exceptions.RequestException
import com.api.igdb.request.IGDBWrapper
import com.api.igdb.request.TwitchAuthenticator
import com.api.igdb.request.games
import de.grimsi.gameyfin.pluginapi.core.*
import de.grimsi.gameyfin.pluginapi.core.config.*
import de.grimsi.gameyfin.pluginapi.core.wrapper.ConfigurableGameyfinPlugin
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
import me.xdrop.fuzzywuzzy.FuzzySearch
@@ -16,26 +17,35 @@ import proto.Game
import java.time.Instant
import java.util.concurrent.TimeUnit
class IgdbPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper), Configurable {
class IgdbPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wrapper) {
override val configMetadata = listOf(
PluginConfigElement(
override val configMetadata: PluginConfigMetadata = listOf(
ConfigMetadata(
key = "clientId",
name = "Twitch client ID",
type = String::class.java,
label = "Twitch client ID",
description = "Your Twitch Client ID"
),
PluginConfigElement(
ConfigMetadata(
key = "clientSecret",
name = "Twitch client secret",
type = String::class.java,
label = "Twitch client secret",
description = "Your Twitch Client Secret",
isSecret = true
)
)
override var config: Map<String, String?> = emptyMap()
override fun validateConfig(config: Map<String, String?>): PluginConfigValidationResult {
val pluginConfigValidationResult = super.validateConfig(config)
if (pluginConfigValidationResult.result == PluginConfigValidationResultType.INVALID) {
return pluginConfigValidationResult
}
try {
authenticate(config["clientId"], config["clientSecret"])
val clientIdToValidate = config["clientId"]
val clientSecretToValidate = config["clientSecret"]
authenticate(clientIdToValidate, clientSecretToValidate)
return PluginConfigValidationResult.VALID
} catch (e: PluginConfigError) {
log.error(e.message)
@@ -50,7 +60,7 @@ class IgdbPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper), Configurable
override fun start() {
try {
authenticate(config["clientId"], config["clientSecret"])
authenticate(config("clientId"), config("clientSecret"))
} catch (e: PluginConfigError) {
log.error(e.message)
}
@@ -1,6 +1,6 @@
package de.grimsi.gameyfin.plugins.steam
import de.grimsi.gameyfin.pluginapi.core.GameyfinPlugin
import de.grimsi.gameyfin.pluginapi.core.wrapper.GameyfinPlugin
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
import de.grimsi.gameyfin.plugins.steam.dto.SteamDetailsResultWrapper
@@ -1,9 +1,7 @@
package de.grimsi.gameyfin.plugins.steamgriddb
import de.grimsi.gameyfin.pluginapi.core.ConfigurableGameyfinPlugin
import de.grimsi.gameyfin.pluginapi.core.PluginConfigElement
import de.grimsi.gameyfin.pluginapi.core.PluginConfigError
import de.grimsi.gameyfin.pluginapi.core.PluginConfigValidationResult
import de.grimsi.gameyfin.pluginapi.core.config.*
import de.grimsi.gameyfin.pluginapi.core.wrapper.ConfigurableGameyfinPlugin
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
import de.grimsi.gameyfin.plugins.steamgriddb.api.SteamGridDbApiClient
@@ -20,18 +18,26 @@ class SteamGridDbPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wra
private var client: SteamGridDbApiClient? = null
}
override val configMetadata: List<PluginConfigElement> = listOf(
PluginConfigElement(
override val configMetadata: PluginConfigMetadata = listOf(
ConfigMetadata(
key = "apiKey",
name = "SteamGridDB API key",
description = "Your SteamGridDB API key",
type = String::class.java,
label = "SteamGridDB API key",
description = "The API key can be obtained from your SteamGridDB account preferences",
isSecret = true
)
)
override fun validateConfig(config: Map<String, String?>): PluginConfigValidationResult {
val pluginConfigValidationResult = super.validateConfig(config)
if (pluginConfigValidationResult.result == PluginConfigValidationResultType.INVALID) {
return pluginConfigValidationResult
}
try {
runBlocking { authenticate(config["apiKey"]) }
val apiKeyToValidate = config["apiKey"]
runBlocking { authenticate(apiKeyToValidate) }
return PluginConfigValidationResult.VALID
} catch (e: PluginConfigError) {
log.error(e.message)
@@ -43,7 +49,7 @@ class SteamGridDbPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wra
override fun start() {
try {
runBlocking { authenticate(config["apiKey"]) }
runBlocking { authenticate(config("apiKey")) }
} catch (e: PluginConfigError) {
log.error(e.message)
}