Implement realtime UI for plugins

Refactor PluginEndpoint
Switch from Set to List for a minor performance boost
This commit is contained in:
grimsi
2025-05-18 17:38:35 +02:00
parent 01cc758b07
commit 9794ecc1dd
37 changed files with 318 additions and 314 deletions
+10
View File
@@ -54,6 +54,7 @@
"remark-breaks": "^4.0.0",
"swiper": "^11.2.6",
"valtio": "^2.1.5",
"valtio-reactive": "^0.1.2",
"yup": "^1.6.1"
},
"devDependencies": {
@@ -18190,6 +18191,15 @@
}
}
},
"node_modules/valtio-reactive": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/valtio-reactive/-/valtio-reactive-0.1.2.tgz",
"integrity": "sha512-9Zv/tFiFWQWEBzfDikJgY9lkQ6CXf4T+Rsk08AKQMMZVmI5YvkAS7qFnRtwd1uVPNT/wsK+QcKiFHBvjCRohYQ==",
"license": "MIT",
"peerDependencies": {
"valtio": ">=2.0.0"
}
},
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
+4 -2
View File
@@ -49,6 +49,7 @@
"remark-breaks": "^4.0.0",
"swiper": "^11.2.6",
"valtio": "^2.1.5",
"valtio-reactive": "^0.1.2",
"yup": "^1.6.1"
},
"devDependencies": {
@@ -138,7 +139,8 @@
"react-player": "$react-player",
"react-markdown": "$react-markdown",
"remark-breaks": "$remark-breaks",
"valtio": "$valtio"
"valtio": "$valtio",
"valtio-reactive": "$valtio-reactive"
},
"vaadin": {
"dependencies": {
@@ -199,6 +201,6 @@
"workbox-core": "7.3.0",
"workbox-precaching": "7.3.0"
},
"hash": "c57af53043f6c6a9f0b03c75c28c3fde0bbfd828f9ce0e179263959c61ec888d"
"hash": "dc682332ca36d64f455f6e13888e1ffcca97e888cbad8d356973e830f7463a10"
}
}
@@ -1,22 +1,29 @@
import React from "react";
import {Divider} from "@heroui/react";
import React, {useEffect} from "react";
import {PluginManagementSection} from "Frontend/components/general/PluginManagementSection";
import {initializePluginState, pluginState} from "Frontend/state/PluginState";
import {useSnapshot} from "valtio/react";
export default function PluginManagement() {
// Defined manually for now to control the layout (order of categories)
const pluginTypes = ["GameMetadataProvider", "DownloadProvider"];
return (
const state = useSnapshot(pluginState);
useEffect(() => {
initializePluginState();
}, []);
return state.isLoaded && (
<div className="flex flex-col">
<div className="flex flex-row flex-grow justify-between mb-8">
<h2 className="text-2xl font-bold">Plugins</h2>
</div>
<Divider className="mb-4"/>
<div className="flex flex-col gap-8">
{pluginTypes.map(type =>
<PluginManagementSection key={type} pluginType={type}/>
// @ts-ignore
<PluginManagementSection key={type} type={type} plugins={state.pluginsByType[type]}/>
)}
</div>
</div>
@@ -5,7 +5,7 @@ import {Form, Formik} from "formik";
import {Button, Skeleton} from "@heroui/react";
import {Check, Info} from "@phosphor-icons/react";
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
import {configState, initializeConfig, NestedConfig} from "Frontend/state/ConfigState";
import {configState, initializeConfigState, NestedConfig} from "Frontend/state/ConfigState";
import {useSnapshot} from "valtio/react";
export default function withConfigPage(WrappedComponent: React.ComponentType<any>, title: String, validationSchema?: any) {
@@ -16,7 +16,7 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
const state = useSnapshot(configState);
useEffect(() => {
initializeConfig();
initializeConfigState();
}, []);
useEffect(() => {
@@ -26,14 +26,14 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
}, [configSaved])
async function handleSubmit(values: NestedConfig): Promise<void> {
const changed = getChangedValues(state.configNested, values);
const changed = getChangedValues(state.config, values);
await ConfigEndpoint.update({updates: changed});
setConfigSaved(true);
}
function getConfig(key: string): ConfigEntryDto | undefined {
// @ts-ignore
return state.configEntries[key];
return state.state[key];
}
function getChangedValues(initial: NestedConfig, current: NestedConfig): Record<string, any> {
@@ -86,7 +86,7 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
<>
{state.isLoaded ?
<Formik
initialValues={state.configNested}
initialValues={state.config}
onSubmit={handleSubmit}
validationSchema={validationSchema}
enableReinitialize={true}
@@ -1,7 +1,7 @@
import {Plug} from "@phosphor-icons/react";
import React from "react";
import PluginDto from "Frontend/generated/de/grimsi/gameyfin/core/plugins/management/PluginDto";
import {Image} from "@heroui/react";
import PluginDto from "Frontend/generated/de/grimsi/gameyfin/core/plugins/dto/PluginDto";
interface PluginLogoProps {
plugin: PluginDto;
@@ -1,41 +1,23 @@
import {Button, Tooltip, useDisclosure} from "@heroui/react";
import {ListNumbers} from "@phosphor-icons/react";
import {PluginManagementCard} from "Frontend/components/general/cards/PluginManagementCard";
import React, {useEffect, useState} from "react";
import PluginDto from "Frontend/generated/de/grimsi/gameyfin/core/plugins/management/PluginDto";
import {PluginManagementEndpoint} from "Frontend/generated/endpoints";
import React from "react";
import PluginPrioritiesModal from "Frontend/components/general/modals/PluginPrioritiesModal";
import {camelCaseToTitle} from "Frontend/util/utils";
import PluginDto from "Frontend/generated/de/grimsi/gameyfin/core/plugins/dto/PluginDto";
interface PluginManagementSectionProps {
pluginType: string;
type: string;
plugins: PluginDto[];
}
export function PluginManagementSection({pluginType}: PluginManagementSectionProps) {
const [plugins, setPlugins] = useState<PluginDto[]>([]);
export function PluginManagementSection({type, plugins}: PluginManagementSectionProps) {
const pluginPrioritiesModal = useDisclosure();
useEffect(() => {
PluginManagementEndpoint.getPlugins(pluginType).then((response) => {
let sortedPlugins: PluginDto[] = response
.filter(p => !!p)
.sort((a: PluginDto, b: PluginDto) => {
if (a.name === undefined || b.name === undefined) return 0;
return a.name.localeCompare(b.name);
});
setPlugins(sortedPlugins);
});
}, []);
function updatePlugin(plugin: PluginDto) {
setPlugins(plugins.map(p => p.id === plugin.id ? plugin : p));
}
return (
<div className="flex flex-col gap-2">
<div className="flex flex-row flex-grow justify-between">
<h2 className="text-xl font-bold">{camelCaseToTitle(pluginType)}</h2>
<h2 className="text-xl font-bold">{camelCaseToTitle(type)}</h2>
<Tooltip color="foreground" placement="left" content="Change plugin order">
<Button isIconOnly variant="flat" onPress={pluginPrioritiesModal.onOpen}>
@@ -45,9 +27,8 @@ export function PluginManagementSection({pluginType}: PluginManagementSectionPro
</div>
<div className="grid grid-cols-300px gap-4">
{plugins.map((plugin) => <PluginManagementCard plugin={plugin}
updatePlugin={updatePlugin}
key={plugin.name}/>
{plugins.map((plugin) =>
<PluginManagementCard plugin={plugin} key={plugin.id}/>
)}
</div>
@@ -15,8 +15,6 @@ import {
WarningCircle,
XCircle
} from "@phosphor-icons/react";
import {PluginManagementEndpoint} from "Frontend/generated/endpoints";
import PluginDto from "Frontend/generated/de/grimsi/gameyfin/core/plugins/management/PluginDto";
import PluginState from "Frontend/generated/org/pf4j/PluginState";
import React, {ReactNode, useEffect, useState} from "react";
import PluginDetailsModal from "Frontend/components/general/modals/PluginDetailsModal";
@@ -24,16 +22,15 @@ import PluginLogo from "Frontend/components/general/PluginLogo";
import PluginTrustLevel from "Frontend/generated/de/grimsi/gameyfin/core/plugins/management/PluginTrustLevel";
import PluginConfigValidationResult
from "Frontend/generated/de/grimsi/gameyfin/core/plugins/config/PluginConfigValidationResult";
import {PluginEndpoint} from "Frontend/generated/endpoints";
import PluginDto from "Frontend/generated/de/grimsi/gameyfin/core/plugins/dto/PluginDto";
export function PluginManagementCard({plugin, updatePlugin}: {
plugin: PluginDto,
updatePlugin: (plugin: PluginDto) => void
}) {
export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
const pluginDetailsModal = useDisclosure();
const [configValidationResult, setConfigValidationResult] = useState<PluginConfigValidationResult | undefined>(undefined);
useEffect(() => {
PluginManagementEndpoint.validatePluginConfig(plugin.id).then((response: PluginConfigValidationResult | undefined) => {
PluginEndpoint.validatePluginConfig(plugin.id).then((response: PluginConfigValidationResult | undefined) => {
if (response === undefined) return;
setConfigValidationResult(response);
});
@@ -55,6 +52,7 @@ export function PluginManagementCard({plugin, updatePlugin}: {
case PluginState.DISABLED:
return "warning";
case PluginState.FAILED:
case PluginState.STOPPED:
return "danger";
default:
return "default";
@@ -67,6 +65,7 @@ export function PluginManagementCard({plugin, updatePlugin}: {
return <PlayCircle/>;
case PluginState.DISABLED:
return <PauseCircle/>;
case PluginState.STOPPED:
case PluginState.FAILED:
return <StopCircle/>;
case PluginState.UNLOADED:
@@ -131,19 +130,9 @@ export function PluginManagementCard({plugin, updatePlugin}: {
function togglePluginEnabled() {
if (isDisabled(plugin.state)) {
PluginManagementEndpoint.enablePlugin(plugin.id).then(() => {
PluginManagementEndpoint.getPlugin(plugin.id).then((response) => {
if (response === undefined) return;
updatePlugin(response);
});
});
PluginEndpoint.enablePlugin(plugin.id);
} else {
PluginManagementEndpoint.disablePlugin(plugin.id).then(() => {
PluginManagementEndpoint.getPlugin(plugin.id).then((response) => {
if (response === undefined) return;
updatePlugin(response);
});
});
PluginEndpoint.disablePlugin(plugin.id);
}
}
@@ -195,7 +184,6 @@ export function PluginManagementCard({plugin, updatePlugin}: {
<PluginDetailsModal plugin={plugin}
isOpen={pluginDetailsModal.isOpen}
onOpenChange={pluginDetailsModal.onOpenChange}
updatePlugin={updatePlugin}
/>
</>
@@ -1,53 +1,35 @@
import React, {useEffect, useState} from "react";
import React from "react";
import {addToast, Button, Link, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import {Form, Formik} from "formik";
import {PluginConfigEndpoint, PluginManagementEndpoint} from "Frontend/generated/endpoints";
import PluginDto from "Frontend/generated/de/grimsi/gameyfin/core/plugins/management/PluginDto";
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/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";
interface PluginDetailsModalProps {
plugin: PluginDto;
isOpen: boolean;
onOpenChange: () => void;
updatePlugin: (plugin: PluginDto) => void;
}
export default function PluginDetailsModal({plugin, isOpen, onOpenChange, updatePlugin}: PluginDetailsModalProps) {
const [pluginConfigMeta, setPluginConfigMeta] = useState<(PluginConfigElement)[]>();
const [pluginConfig, setPluginConfig] = useState<Record<string, string>>();
useEffect(() => {
PluginConfigEndpoint.getConfigMetadata(plugin.id).then(response => {
if (response === undefined) return;
setPluginConfigMeta(response as PluginConfigElement[]);
});
PluginConfigEndpoint.getConfig(plugin.id).then(response => {
if (response === undefined) return;
setPluginConfig(response as Record<string, string>);
});
}, []);
export default function PluginDetailsModal({plugin, isOpen, onOpenChange}: PluginDetailsModalProps) {
async function saveConfig(values: Record<string, string>) {
await PluginConfigEndpoint.setConfigEntries(plugin.id, values);
await PluginEndpoint.updateConfig(plugin.id, values);
addToast({
title: "Configuration saved",
description: `Configuration for plugin ${plugin.name} saved!`,
color: "success"
});
let updatedPlugin = await PluginManagementEndpoint.getPlugin(plugin.id);
if (updatedPlugin === undefined) return;
updatePlugin(updatedPlugin);
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg">
<ModalContent>
{(onClose) => (
<Formik initialValues={pluginConfig}
<Formik initialValues={plugin.config}
enableReinitialize={true}
onSubmit={async (values: any) => {
await saveConfig(values);
@@ -107,8 +89,8 @@ export default function PluginDetailsModal({plugin, isOpen, onOpenChange, update
</div>
<h4 className="text-l font-bold mt-4">Configuration</h4>
{(pluginConfigMeta && pluginConfigMeta.length > 0) ?
pluginConfigMeta.map((entry: PluginConfigElement) => (
{(plugin.configMetadata && plugin.configMetadata.length > 0) ?
plugin.configMetadata.map((entry: PluginConfigElement) => (
<Input key={entry.key} name={entry.key} label={entry.name}
type={entry.secret ? "password" : "text"}/>
)) : "This plugin has no configuration options."
@@ -118,7 +100,7 @@ export default function PluginDetailsModal({plugin, isOpen, onOpenChange, update
<Button variant="light" onPress={onClose}>
Cancel
</Button>
{(pluginConfigMeta && pluginConfigMeta?.length > 0) ?
{(plugin.configMetadata && plugin.configMetadata?.length > 0) ?
<Button
color="primary"
isLoading={formik.isSubmitting}
@@ -1,10 +1,10 @@
import React from "react";
import {addToast, Button, Chip, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import {PluginManagementEndpoint} from "Frontend/generated/endpoints";
import PluginDto from "Frontend/generated/de/grimsi/gameyfin/core/plugins/management/PluginDto";
import {ListBox, ListBoxItem, useDragAndDrop} from "react-aria-components";
import {CaretUpDown} from "@phosphor-icons/react";
import {useListData} from "@react-stately/data";
import PluginDto from "Frontend/generated/de/grimsi/gameyfin/core/plugins/dto/PluginDto";
import {PluginEndpoint} from "Frontend/generated/endpoints";
interface PluginPrioritiesModalProps {
plugins: PluginDto[];
@@ -51,11 +51,11 @@ export default function PluginPrioritiesModal({plugins, isOpen, onOpenChange}: P
async function setPluginPriorities(onClose: () => void) {
try {
const prioritiesMap = generatePrioritiesMap();
await PluginManagementEndpoint.setPluginPriorities(prioritiesMap);
await PluginEndpoint.setPluginPriorities(prioritiesMap);
addToast({
title: "Plugin order updated",
description: "Plugin order have been updated successfully.",
description: "Plugin order has been updated successfully.",
color: "success"
});
onClose();
@@ -7,35 +7,35 @@ import {Subscription} from "@vaadin/hilla-frontend";
type ConfigState = {
subscription?: Subscription<ConfigUpdateDto>;
isLoaded: boolean;
configEntries: Record<string, ConfigEntryDto>;
configNested: NestedConfig;
state: Record<string, ConfigEntryDto>;
config: NestedConfig;
};
export const configState = proxy<ConfigState>({
get isLoaded() {
return this.subscription != null;
},
configEntries: {},
get configNested() {
return toNestedConfig(Object.values(this.configEntries));
state: {},
get config() {
return toNestedConfig(Object.values(this.state));
}
});
/** Subscribe to and process state updates from backend **/
export async function initializeConfig() {
export async function initializeConfigState() {
if (configState.isLoaded) return;
// Fetch initial configuration data
const initialEntries = await ConfigEndpoint.getAll();
initialEntries.forEach((entry) => {
configState.configEntries[entry.key] = entry;
configState.state[entry.key] = entry;
});
// Subscribe to real-time updates
configState.subscription = ConfigEndpoint.subscribe().onNext((updateDto: ConfigUpdateDto) => {
Object.entries(updateDto.updates).forEach(([key, value]) => {
if (configState.configEntries[key]) {
configState.configEntries[key].value = value;
if (configState.state[key]) {
configState.state[key].value = value;
}
});
});
@@ -0,0 +1,74 @@
import {Subscription} from "@vaadin/hilla-frontend";
import PluginDto from "Frontend/generated/de/grimsi/gameyfin/core/plugins/dto/PluginDto";
import PluginUpdateDto from "Frontend/generated/de/grimsi/gameyfin/core/plugins/dto/PluginUpdateDto";
import {proxy} from "valtio/index";
import {PluginEndpoint} from "Frontend/generated/endpoints";
type PluginState = {
subscription?: Subscription<PluginUpdateDto>;
isLoaded: boolean;
state: Record<string, PluginDto>;
plugins: PluginDto[];
pluginsByType: Record<string, PluginDto[]>;
};
export const pluginState = proxy<PluginState>({
get isLoaded() {
return this.subscription != null;
},
state: {},
get plugins() {
return Object.values<PluginDto>(this.state);
},
get pluginsByType() {
return groupPluginsByType(this.state);
}
});
/** Subscribe to and process state updates from backend **/
export async function initializePluginState() {
if (pluginState.isLoaded) return;
// Fetch initial plugin list
const initialEntries = await PluginEndpoint.getAll();
initialEntries.forEach((plugin: PluginDto) => {
pluginState.state[plugin.id] = plugin;
});
// Subscribe to real-time updates
pluginState.subscription = PluginEndpoint.subscribe().onNext((updateDto: PluginUpdateDto) => {
// Make sure the plugin exists in the state
if (pluginState.state[updateDto.id]) {
// Update the existing plugin by merging the new data using destructuring
pluginState.state[updateDto.id] = {
...pluginState.state[updateDto.id],
...updateDto
};
}
});
}
/** Computed **/
function groupPluginsByType(pluginsMap: Record<string, PluginDto>): Record<string, PluginDto[]> {
const pluginsByType: Record<string, PluginDto[]> = {};
// Convert map to array of plugins
const plugins = Object.values(pluginsMap);
// Iterate through each plugin
for (const plugin of plugins) {
// Each plugin can have multiple types
for (const type of plugin.types) {
// Initialize array for this type if it doesn't exist
if (!pluginsByType[type]) {
pluginsByType[type] = [];
}
// Add plugin to the appropriate type array
pluginsByType[type].push(plugin);
}
}
return pluginsByType;
}
@@ -10,42 +10,31 @@ import jakarta.annotation.security.RolesAllowed
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.userdetails.UserDetails
import reactor.core.publisher.Flux
import reactor.core.publisher.Sinks
@Endpoint
@RolesAllowed(Role.Names.ADMIN)
class ConfigEndpoint(
private val config: ConfigService
private val configService: ConfigService
) {
/** CRUD endpoints for admins **/
private val configUpdates = Sinks.many().multicast().onBackpressureBuffer<ConfigUpdateDto>()
fun getAll(): List<ConfigEntryDto> {
return config.getAll(null)
}
@PermitAll
fun subscribe(): Flux<ConfigUpdateDto> {
val user = SecurityContextHolder.getContext().authentication.principal as UserDetails
return if (user.isAdmin()) configUpdates.asFlux()
return if (user.isAdmin()) configService.subscribe()
else Flux.empty()
}
fun update(update: ConfigUpdateDto) {
config.update(update.updates)
configUpdates.tryEmitNext(update)
}
fun getAll(): List<ConfigEntryDto> = configService.getAll()
fun update(update: ConfigUpdateDto) = configService.update(update)
/** Specific read-only endpoint for all users **/
@PermitAll
fun isSsoEnabled(): Boolean? {
return config.get(ConfigProperties.SSO.OIDC.Enabled)
}
fun isSsoEnabled(): Boolean? = configService.get(ConfigProperties.SSO.OIDC.Enabled)
@PermitAll
fun getLogoutUrl(): String? {
return config.get(ConfigProperties.SSO.OIDC.LogoutUrl)
}
fun getLogoutUrl(): String? = configService.get(ConfigProperties.SSO.OIDC.LogoutUrl)
}
@@ -1,11 +1,14 @@
package de.grimsi.gameyfin.config
import de.grimsi.gameyfin.config.dto.ConfigEntryDto
import de.grimsi.gameyfin.config.dto.ConfigUpdateDto
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 reactor.core.publisher.Flux
import reactor.core.publisher.Sinks
import java.io.Serializable
@Service
@@ -14,6 +17,11 @@ class ConfigService(
) {
private val log = KotlinLogging.logger {}
private val configUpdates = Sinks.many().multicast().onBackpressureBuffer<ConfigUpdateDto>()
fun subscribe(): Flux<ConfigUpdateDto> {
return configUpdates.asFlux()
}
/**
* Get the current value of a config property in a type-safe way.
@@ -52,21 +60,16 @@ class ConfigService(
/**
* Get all known config values.
*
* @param prefix: Optional prefix to filter the config values
* @return A map of all config values
*/
fun getAll(prefix: String?): List<ConfigEntryDto> {
fun getAll(): List<ConfigEntryDto> {
log.debug { "Getting all config values for prefix '$prefix'" }
log.debug { "Getting all config values" }
var configProperties = ConfigProperties::class.sealedSubclasses.flatMap { subclass ->
val configProperties = ConfigProperties::class.sealedSubclasses.flatMap { subclass ->
subclass.objectInstance?.let { listOf(it) } ?: listOf()
}
if (prefix != null) {
configProperties = configProperties.filter { it.key.startsWith(prefix) }
}
return configProperties.map { configProperty ->
ConfigEntryDto(
key = configProperty.key,
@@ -115,16 +118,17 @@ class ConfigService(
* Set multiple config values at once.
* Configs with a null value will be deleted.
*
* @param updates: A map of key-value pairs to set
* @param update: A [ConfigUpdateDto] containing a map of key-value pairs to set
*/
fun update(updates: Map<String, Serializable?>) {
updates.forEach { (key, value) ->
fun update(update: ConfigUpdateDto) {
update.updates.forEach { (key, value) ->
if (value == null) {
delete(key)
} else {
set(key, value)
}
}
configUpdates.tryEmitNext(update)
}
/**
@@ -45,7 +45,7 @@ class SetupDataLoader(
email = "admin@gameyfin.org",
emailConfirmed = true,
enabled = true,
roles = setOf(Role.SUPERADMIN)
roles = listOf(Role.SUPERADMIN)
)
registerUserIfNotFound(superadmin)
@@ -56,7 +56,7 @@ class SetupDataLoader(
email = "user@gameyfin.org",
emailConfirmed = true,
enabled = true,
roles = setOf(Role.USER)
roles = listOf(Role.USER)
)
registerUserIfNotFound(user)
@@ -3,7 +3,7 @@ package de.grimsi.gameyfin.core.filesystem
import java.nio.file.Path
data class FilesystemScanResult(
val newPaths: Set<Path>,
val removedGamePaths: Set<Path>,
val removedUnmatchedPaths: Set<Path>
val newPaths: List<Path>,
val removedGamePaths: List<Path>,
val removedUnmatchedPaths: List<Path>
)
@@ -116,18 +116,18 @@ class FilesystemService(
val newPaths = currentFilesystemPaths.filter { path ->
val isInLibrary = allCurrentLibraryPaths.any { it == path }
!isInLibrary
}.toSet()
}
//Get all paths that are in the library (either as game or as unmatched path), but not on the filesystem
val removedGamePaths = currentLibraryGamePaths.filter { path ->
val isOnFilesystem = currentFilesystemPaths.any { it == path }
!isOnFilesystem
}.toSet()
}
val removedUnmatchedPaths = currentLibraryUnmatchedPaths.filter { path ->
val isOnFilesystem = currentFilesystemPaths.any { it == path }
!isOnFilesystem
}.toSet()
}
return FilesystemScanResult(
newPaths = newPaths,
@@ -0,0 +1,42 @@
package de.grimsi.gameyfin.core.plugins
import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.core.Role
import de.grimsi.gameyfin.core.plugins.config.PluginConfigValidationResult
import de.grimsi.gameyfin.core.plugins.dto.PluginUpdateDto
import de.grimsi.gameyfin.core.plugins.management.PluginManagementService
import de.grimsi.gameyfin.users.util.isAdmin
import jakarta.annotation.security.PermitAll
import jakarta.annotation.security.RolesAllowed
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.userdetails.UserDetails
import reactor.core.publisher.Flux
@Endpoint
@RolesAllowed(Role.Names.ADMIN)
class PluginEndpoint(
private val pluginManagementService: PluginManagementService
) {
@PermitAll
fun subscribe(): Flux<PluginUpdateDto> {
val user = SecurityContextHolder.getContext().authentication.principal as UserDetails
return if (user.isAdmin()) pluginManagementService.subscribe()
else Flux.empty()
}
fun getAll() = pluginManagementService.getAll()
fun enablePlugin(pluginId: String) = pluginManagementService.enablePlugin(pluginId)
fun disablePlugin(pluginId: String) = pluginManagementService.disablePlugin(pluginId)
fun setPluginPriorities(pluginPriorities: Map<String, Int>) =
pluginManagementService.setPluginPriorities(pluginPriorities)
fun validatePluginConfig(pluginId: String): PluginConfigValidationResult =
pluginManagementService.validatePluginConfig(pluginId)
fun updateConfig(pluginId: String, updatedConfig: Map<String, String>) =
pluginManagementService.updateConfig(pluginId, updatedConfig)
}
@@ -1,28 +0,0 @@
package de.grimsi.gameyfin.core.plugins.config
import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.core.Role
import de.grimsi.gameyfin.pluginapi.core.PluginConfigElement
import jakarta.annotation.security.RolesAllowed
@Endpoint
@RolesAllowed(Role.Names.ADMIN)
class PluginConfigEndpoint(
private val pluginConfigService: PluginConfigService
) {
fun getConfigMetadata(pluginId: String): List<PluginConfigElement> {
return pluginConfigService.getConfigMetadata(pluginId)
}
fun getConfig(pluginId: String): Map<String, String?> {
return pluginConfigService.getConfig(pluginId)
}
fun setConfigEntries(pluginId: String, config: Map<String, String>) {
pluginConfigService.setConfigEntries(pluginId, config)
}
fun setConfigEntry(pluginId: String, key: String, value: String) {
pluginConfigService.setConfigEntry(pluginId, key, value)
}
}
@@ -4,6 +4,7 @@ import de.grimsi.gameyfin.core.plugins.management.GameyfinPluginManager
import de.grimsi.gameyfin.pluginapi.core.Configurable
import de.grimsi.gameyfin.pluginapi.core.PluginConfigElement
import io.github.oshai.kotlinlogging.KotlinLogging
import org.pf4j.PluginWrapper
import org.springframework.stereotype.Service
@Service
@@ -14,36 +15,22 @@ class PluginConfigService(
private val log = KotlinLogging.logger {}
fun getConfigMetadata(pluginId: String): List<PluginConfigElement> {
log.debug { "Getting config metadata for plugin $pluginId" }
val plugin = try {
pluginManager.getPlugin(pluginId).plugin
} catch (_: NoClassDefFoundError) {
return emptyList()
}
fun getConfigMetadata(pluginWrapper: PluginWrapper): List<PluginConfigElement> {
log.debug { "Getting config metadata for plugin ${pluginWrapper.pluginId}" }
val plugin = pluginWrapper.plugin
if (plugin !is Configurable) return emptyList()
return plugin.configMetadata
}
fun getConfig(pluginId: String): Map<String, String?> {
log.debug { "Getting config for plugin $pluginId" }
return pluginConfigRepository.findAllById_PluginId(pluginId).associate { it.id.key to it.value }
fun getConfig(pluginWrapper: PluginWrapper): Map<String, String?> {
log.debug { "Getting config for plugin ${pluginWrapper.pluginId}" }
return pluginConfigRepository.findAllById_PluginId(pluginWrapper.pluginId).associate { it.id.key to it.value }
}
fun setConfigEntries(pluginId: String, config: Map<String, String>) {
fun updateConfig(pluginId: String, config: Map<String, String>) {
log.debug { "Setting config entries for plugin $pluginId" }
val entries = config.map { PluginConfigEntry(PluginConfigEntryKey(pluginId, it.key), it.value) }
pluginConfigRepository.saveAll(entries)
pluginManager.restart(pluginId)
}
fun setConfigEntry(pluginId: String, key: String, value: String) {
log.debug { "Setting config entry $key for plugin $pluginId" }
val entry = PluginConfigEntry(PluginConfigEntryKey(pluginId, key), value)
pluginConfigRepository.save(entry)
pluginManager.restart(pluginId)
}
}
@@ -1,9 +1,12 @@
package de.grimsi.gameyfin.core.plugins.management
package de.grimsi.gameyfin.core.plugins.dto
import de.grimsi.gameyfin.core.plugins.management.PluginTrustLevel
import de.grimsi.gameyfin.pluginapi.core.PluginConfigElement
import org.pf4j.PluginState
data class PluginDto(
val id: String,
val types: List<String>,
val name: String,
val description: String,
val shortDescription: String? = null,
@@ -13,6 +16,8 @@ data class PluginDto(
val url: String? = null,
val hasLogo: Boolean,
val state: PluginState,
val configMetadata: List<PluginConfigElement>? = null,
val config: Map<String, String?>? = null,
val priority: Int,
val trustLevel: PluginTrustLevel,
)
@@ -0,0 +1,12 @@
package de.grimsi.gameyfin.core.plugins.dto
import com.fasterxml.jackson.annotation.JsonInclude
import org.pf4j.PluginState
@JsonInclude(JsonInclude.Include.NON_NULL)
data class PluginUpdateDto(
val id: String,
val state: PluginState? = null,
val config: Map<String, String?>? = null,
val priority: Int? = null
)
@@ -181,19 +181,17 @@ class GameyfinPluginManager(
return PluginConfigValidationResult.INVALID
}
fun getExtensionTypeClasses(pluginId: String): Set<Class<ExtensionPoint>> {
fun getExtensionTypeClasses(pluginId: String): List<Class<ExtensionPoint>> {
return getExtensionClasses(pluginId)
.flatMap { it.interfaces.toList() }
.filterIsInstance<Class<ExtensionPoint>>()
.toSet()
}
fun getExtensionTypes(pluginId: String): Set<String> {
fun getExtensionTypes(pluginId: String): List<String> {
return getExtensionClasses(pluginId)
.flatMap { it.interfaces.toList() }
.filterIsInstance<Class<ExtensionPoint>>()
.map { it.simpleName }
.toSet()
}
private fun configurePlugin(pluginWrapper: PluginWrapper) {
@@ -1,39 +0,0 @@
package de.grimsi.gameyfin.core.plugins.management
import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.core.Role
import de.grimsi.gameyfin.core.plugins.config.PluginConfigValidationResult
import jakarta.annotation.security.RolesAllowed
@Endpoint
@RolesAllowed(Role.Names.ADMIN)
class PluginManagementEndpoint(
private val pluginManagementService: PluginManagementService
) {
fun getSupportedPluginTypes() = pluginManagementService.getSupportedPluginTypes()
fun getPlugins(type: String?) = pluginManagementService.getPluginDtos(type)
fun getPluginsMappedToTypes() = pluginManagementService.getPluginDtosMappedToTypes()
fun getPlugin(pluginId: String) = pluginManagementService.getPluginDto(pluginId)
fun startPlugin(pluginId: String) = pluginManagementService.startPlugin(pluginId)
fun stopPlugin(pluginId: String) = pluginManagementService.stopPlugin(pluginId)
fun restartPlugin(pluginId: String) = pluginManagementService.restartPlugin(pluginId)
fun enablePlugin(pluginId: String) = pluginManagementService.enablePlugin(pluginId)
fun disablePlugin(pluginId: String) = pluginManagementService.disablePlugin(pluginId)
fun validatePluginConfig(pluginId: String): PluginConfigValidationResult =
pluginManagementService.validatePluginConfig(pluginId)
fun setPluginPriority(pluginId: String, priority: Int) =
pluginManagementService.setPluginPriority(pluginId, priority)
fun setPluginPriorities(pluginPriorities: Map<String, Int>) =
pluginManagementService.setPluginPriorities(pluginPriorities)
}
@@ -1,43 +1,43 @@
package de.grimsi.gameyfin.core.plugins.management
import de.grimsi.gameyfin.core.plugins.config.PluginConfigService
import de.grimsi.gameyfin.core.plugins.config.PluginConfigValidationResult
import de.grimsi.gameyfin.core.plugins.dto.PluginDto
import de.grimsi.gameyfin.core.plugins.dto.PluginUpdateDto
import de.grimsi.gameyfin.pluginapi.core.GameyfinPlugin
import org.pf4j.ExtensionPoint
import org.pf4j.PluginWrapper
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Sinks
@Service
class PluginManagementService(
private val pluginManager: GameyfinPluginManager,
private val pluginConfigService: PluginConfigService,
private val pluginManagementRepository: PluginManagementRepository,
) {
private val pluginUpdates = Sinks.many().multicast().onBackpressureBuffer<PluginUpdateDto>()
fun getSupportedPluginTypes(): Set<String> {
init {
pluginManager.addPluginStateListener {
pluginUpdates.tryEmitNext(PluginUpdateDto(id = it.plugin.pluginId, state = it.pluginState))
}
}
fun subscribe(): Flux<PluginUpdateDto> {
return pluginUpdates.asFlux()
}
fun getSupportedPluginTypes(): List<String> {
return pluginManager.plugins
.flatMap { pluginManager.getExtensionTypes(it.pluginId) }
.toSet()
}
fun getPluginDtos(type: String?): Set<PluginDto> {
fun getAll(): List<PluginDto> {
return pluginManager.plugins
.filter { type == null || type in pluginManager.getExtensionTypes(it.pluginId) }
.map { toDto(it) }
.toSet()
}
fun getPluginDtosMappedToTypes(): Map<String, List<PluginDto>> {
return pluginManager.plugins
.flatMap { plugin ->
val types = pluginManager.getExtensionTypes(plugin.pluginId)
types.map { it to toDto(plugin) }
}
.groupBy({ it.first }, { it.second })
}
fun getPluginDto(pluginId: String): PluginDto {
val plugin = pluginManager.getPlugin(pluginId)
return toDto(plugin)
}
fun getPluginManagementEntry(pluginId: String): PluginManagementEntry {
@@ -51,18 +51,6 @@ class PluginManagementService(
?: throw IllegalArgumentException("Plugin with class $clazz not found")
}
fun startPlugin(pluginId: String) {
pluginManager.startPlugin(pluginId)
}
fun stopPlugin(pluginId: String) {
pluginManager.stopPlugin(pluginId)
}
fun restartPlugin(pluginId: String) {
pluginManager.restart(pluginId)
}
fun enablePlugin(pluginId: String) {
pluginManager.enablePlugin(pluginId)
}
@@ -75,10 +63,10 @@ class PluginManagementService(
return pluginManager.validatePluginConfig(pluginId)
}
fun setPluginPriority(pluginId: String, priority: Int) {
val pluginManagementEntry = getPluginManagementEntry(pluginId)
pluginManagementEntry.priority = priority
pluginManagementRepository.save(pluginManagementEntry)
fun updateConfig(pluginId: String, updatedConfig: Map<String, String>) {
pluginConfigService.updateConfig(pluginId, updatedConfig)
val update = PluginUpdateDto(pluginId, config = updatedConfig)
pluginUpdates.tryEmitNext(update)
}
fun setPluginPriorities(pluginPriorities: Map<String, Int>) {
@@ -110,6 +98,7 @@ class PluginManagementService(
return PluginDto(
id = descriptor.pluginId,
types = pluginManager.getExtensionTypes(pluginWrapper.pluginId),
name = descriptor.pluginName,
description = descriptor.pluginDescription,
shortDescription = descriptor.pluginShortDescription,
@@ -119,6 +108,8 @@ class PluginManagementService(
url = descriptor.pluginUrl,
hasLogo = hasLogo,
state = pluginWrapper.pluginState,
configMetadata = pluginConfigService.getConfigMetadata(pluginWrapper),
config = pluginConfigService.getConfig(pluginWrapper),
priority = pluginManagementEntry.priority,
trustLevel = pluginManagementEntry.trustLevel
)
@@ -47,12 +47,12 @@ class GameService(
}
@Transactional
fun create(games: Collection<Game>): Collection<Game> {
fun create(games: List<Game>): List<Game> {
val gamesToBePersisted = games.filter { it.id == null }
gamesToBePersisted.forEach { game ->
game.publishers = game.publishers.map { companyService.createOrGet(it) }.toSet()
game.developers = game.developers.map { companyService.createOrGet(it) }.toSet()
game.publishers = game.publishers.map { companyService.createOrGet(it) }
game.developers = game.developers.map { companyService.createOrGet(it) }
game
}
@@ -87,11 +87,11 @@ class GameService(
return mergedGame
}
fun getAllByPaths(paths: Collection<String>): Collection<Game> {
fun getAllByPaths(paths: List<String>): List<Game> {
return gameRepository.findAllByPathIn(paths)
}
fun getAllGames(): Collection<GameDto> {
fun getAllGames(): List<GameDto> {
val entities = gameRepository.findAll()
return entities.map { it.toDto() }
}
@@ -231,58 +231,58 @@ class GameService(
metadata.publishedBy?.takeIf { it.isNotEmpty() }?.let { publishedBy ->
if (!metadataMap.containsKey("publishers")) {
mergedGame.publishers =
publishedBy.map { Company(name = it, type = CompanyType.PUBLISHER) }.toSet()
publishedBy.map { Company(name = it, type = CompanyType.PUBLISHER) }
metadataMap["publishers"] = FieldMetadata(sourcePlugin)
}
}
metadata.developedBy?.takeIf { it.isNotEmpty() }?.let { developedBy ->
if (!metadataMap.containsKey("developers")) {
mergedGame.developers =
developedBy.map { Company(name = it, type = CompanyType.DEVELOPER) }.toSet()
developedBy.map { Company(name = it, type = CompanyType.DEVELOPER) }
metadataMap["developers"] = FieldMetadata(sourcePlugin)
}
}
metadata.genres?.takeIf { it.isNotEmpty() }?.let { genres ->
if (!metadataMap.containsKey("genres")) {
mergedGame.genres = genres
mergedGame.genres = genres.toList()
metadataMap["genres"] = FieldMetadata(sourcePlugin)
}
}
metadata.themes?.takeIf { it.isNotEmpty() }?.let { themes ->
if (!metadataMap.containsKey("themes")) {
mergedGame.themes = themes
mergedGame.themes = themes.toList()
metadataMap["themes"] = FieldMetadata(sourcePlugin)
}
}
metadata.keywords?.takeIf { it.isNotEmpty() }?.let { keywords ->
if (!metadataMap.containsKey("keywords")) {
mergedGame.keywords = keywords
mergedGame.keywords = keywords.toList()
metadataMap["keywords"] = FieldMetadata(sourcePlugin)
}
}
metadata.features?.takeIf { it.isNotEmpty() }?.let { features ->
if (!metadataMap.containsKey("features")) {
mergedGame.features = features
mergedGame.features = features.toList()
metadataMap["features"] = FieldMetadata(sourcePlugin)
}
}
metadata.perspectives?.takeIf { it.isNotEmpty() }?.let { perspectives ->
if (!metadataMap.containsKey("perspectives")) {
mergedGame.perspectives = perspectives
mergedGame.perspectives = perspectives.toList()
metadataMap["perspectives"] = FieldMetadata(sourcePlugin)
}
}
metadata.screenshotUrls?.takeIf { it.isNotEmpty() }?.let { screenshotUrls ->
if (!metadataMap.containsKey("images")) {
mergedGame.images = runBlocking {
screenshotUrls.map { Image(originalUrl = it.toURL(), type = ImageType.SCREENSHOT) }.toSet()
screenshotUrls.map { Image(originalUrl = it.toURL(), type = ImageType.SCREENSHOT) }
}
metadataMap["images"] = FieldMetadata(sourcePlugin)
}
}
metadata.videoUrls?.takeIf { it.isNotEmpty() }?.let { videoUrls ->
if (!metadataMap.containsKey("videoUrls")) {
mergedGame.videoUrls = videoUrls
mergedGame.videoUrls = videoUrls.toList()
metadataMap["videoUrls"] = FieldMetadata(sourcePlugin)
}
}
@@ -49,31 +49,31 @@ class Game(
var criticRating: Int? = null,
@ManyToMany(cascade = [CascadeType.PERSIST, CascadeType.MERGE, CascadeType.REFRESH])
var publishers: Set<Company> = emptySet(),
var publishers: List<Company> = emptyList(),
@ManyToMany(cascade = [CascadeType.PERSIST, CascadeType.MERGE, CascadeType.REFRESH])
var developers: Set<Company> = emptySet(),
var developers: List<Company> = emptyList(),
@ElementCollection(targetClass = Genre::class)
var genres: Set<Genre> = emptySet(),
var genres: List<Genre> = emptyList(),
@ElementCollection(targetClass = Theme::class)
var themes: Set<Theme> = emptySet(),
var themes: List<Theme> = emptyList(),
@ElementCollection
var keywords: Set<String> = emptySet(),
var keywords: List<String> = emptyList(),
@ElementCollection(targetClass = GameFeature::class)
var features: Set<GameFeature> = emptySet(),
var features: List<GameFeature> = emptyList(),
@ElementCollection(targetClass = PlayerPerspective::class)
var perspectives: Set<PlayerPerspective>? = null,
var perspectives: List<PlayerPerspective>? = null,
@OneToMany(cascade = [CascadeType.ALL], orphanRemoval = true)
var images: Set<Image> = emptySet(),
var images: List<Image> = emptyList(),
@ElementCollection
var videoUrls: Set<URI> = emptySet(),
var videoUrls: List<URI> = emptyList(),
@Column(unique = true)
val path: String,
@@ -6,7 +6,7 @@ import org.springframework.data.jpa.repository.JpaRepository
interface GameRepository : JpaRepository<Game, Long> {
fun findByPath(path: String): Game?
fun findAllByPathIn(paths: Collection<String>): Collection<Game>
fun findAllByPathIn(paths: List<String>): List<Game>
fun findByOrderByCreatedAtDesc(limit: Limit): List<Game>
fun findByOrderByUpdatedAtDesc(limit: Limit): List<Game>
}
@@ -12,11 +12,11 @@ class Library(
var name: String,
@OneToMany(fetch = FetchType.EAGER, orphanRemoval = true, cascade = [CascadeType.ALL])
var directories: MutableSet<DirectoryMapping> = HashSet(),
var directories: MutableList<DirectoryMapping> = ArrayList(),
@OneToMany(fetch = FetchType.EAGER, orphanRemoval = true)
var games: MutableSet<Game> = HashSet(),
var games: MutableList<Game> = ArrayList(),
@ElementCollection(fetch = FetchType.EAGER)
var unmatchedPaths: MutableSet<String> = HashSet()
var unmatchedPaths: MutableList<String> = ArrayList()
)
@@ -3,8 +3,8 @@ package de.grimsi.gameyfin.libraries
import de.grimsi.gameyfin.games.entities.Game
data class LibraryScanResult(
val libraries: Set<Library>,
val newGames: Set<Game>,
val removedGames: Set<Game>,
val newUnmatchedPaths: Set<String>
val libraries: List<Library>,
val newGames: List<Game>,
val removedGames: List<Game>,
val newUnmatchedPaths: List<String>
)
@@ -60,7 +60,7 @@ class LibraryService(
libraryDto.directories?.let {
existingLibrary.directories = it
.map { d -> DirectoryMapping(internalPath = d.internalPath, externalPath = d.externalPath) }
.toMutableSet()
.toMutableList()
}
val updatedLibrary = libraryRepository.save(existingLibrary)
@@ -271,10 +271,10 @@ class LibraryService(
libraryRepository.save(library)
return LibraryScanResult(
libraries = setOf(library),
newGames = persistedGames.toSet(),
removedGames = removedGames.toSet(),
newUnmatchedPaths = newUnmatchedPaths
libraries = listOf(library),
newGames = persistedGames,
removedGames = removedGames,
newUnmatchedPaths = newUnmatchedPaths.toList()
)
}
@@ -321,7 +321,7 @@ class LibraryService(
name = library.name,
directories = library.directories.map {
DirectoryMapping(internalPath = it.internalPath, externalPath = it.externalPath)
}.toMutableSet()
}.toMutableList()
)
}
}
@@ -3,5 +3,5 @@ package de.grimsi.gameyfin.libraries.dto
data class LibraryUpdateDto(
val id: Long,
val name: String? = null,
val directories: Set<DirectoryMappingDto>? = null,
val directories: List<DirectoryMappingDto>? = null,
)
@@ -17,8 +17,8 @@ class MessageTemplateEndpoint(
return messageTemplateService.getMessageTemplate(key)
}
fun getDefaultPlaceholders(type: TemplateType): Set<String> {
return messageTemplateService.getDefaultTemplatePlaceholders(type).keys
fun getDefaultPlaceholders(type: TemplateType): List<String> {
return messageTemplateService.getDefaultTemplatePlaceholders(type).keys.toList()
}
fun read(key: String, templateType: TemplateType): String {
@@ -32,7 +32,7 @@ class SetupService(
password = registration.password,
email = registration.email,
enabled = true,
roles = setOf(Role.SUPERADMIN)
roles = listOf(Role.SUPERADMIN)
)
val user = userService.registerOrUpdateUser(superAdmin)
@@ -48,8 +48,8 @@ class RoleService(
return Role.entries.filter { it.powerLevel < highestUserRole.powerLevel }
}
fun authoritiesToRoles(authorities: Collection<GrantedAuthority>): Set<Role> {
return authorities.mapNotNull { Role.safeValueOf(it.authority) }.toMutableSet()
fun authoritiesToRoles(authorities: Collection<GrantedAuthority>): List<Role> {
return authorities.mapNotNull { Role.safeValueOf(it.authority) }
}
/**
@@ -88,7 +88,6 @@ class UserService(
val userInfoDto = toUserInfo(oidcUser)
userInfoDto.roles = roleService.extractGrantedAuthorities(principal.authorities)
.mapNotNull { Role.safeValueOf(it.authority) }
.toSet()
return userInfoDto
}
@@ -148,7 +147,7 @@ class UserService(
password = passwordEncoder.encode(registration.password),
email = registration.email,
enabled = !adminNeedsToApprove,
roles = setOf(Role.USER)
roles = listOf(Role.USER)
)
user = userRepository.save(user)
@@ -172,7 +171,7 @@ class UserService(
email = email,
emailConfirmed = true,
enabled = true,
roles = setOf(Role.USER)
roles = listOf(Role.USER)
)
if (existsByUsername(user.username)) {
@@ -229,7 +228,7 @@ class UserService(
return RoleAssignmentResult.ASSIGNED_ROLE_POWER_LEVEL_TOO_HIGH
}
targetUser.roles = newAssignedRoles.toMutableSet()
targetUser.roles = newAssignedRoles
userRepository.save(targetUser)
return RoleAssignmentResult.SUCCESS
}
@@ -10,5 +10,5 @@ data class UserInfoDto(
val isEnabled: Boolean,
val hasAvatar: Boolean,
val avatarId: Long? = null,
var roles: Set<Role>
var roles: List<Role>
)
@@ -35,7 +35,7 @@ class User(
@ElementCollection(targetClass = Role::class, fetch = FetchType.EAGER)
@Enumerated(EnumType.STRING)
var roles: Set<Role> = emptySet()
var roles: List<Role> = emptyList()
) {
constructor(oidcUser: OidcUser) : this(