mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
Release 2.4.0 (#870)
* chore: bump version to v2.4.0-preview * Bump actions/cache from 4 to 5 (#865) Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/cache dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Increase maximum DB connection pool size (#876) Increase DB connection timeout * Disable length limit for DB field PLUGIN_CONFIG.value (#875) * Bump actions/cache from 4 to 5 (#871) Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/cache dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump actions/download-artifact from 7 to 8 (#882) Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 7 to 8. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v7...v8) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: '8' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump actions/upload-artifact from 6 to 7 (#881) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump actions/cache from 4 to 5 (#878) Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/cache dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Dont perform scans if no metadata plugins are enabled (#877) * Dont perform scans if no metadata plugins are enabled * Fix tests * Add PluginServiceTest.kt * Fix Sonar finding * Fix malformed external links (#886) * Fix external links being treated as internal * chore: bump version to v856-malformed-external-links-preview * Update JVM in Dockerfile to Java 25 * Revert incorrect version update * Allow loading .jar plugins in development mode (#885) * Allow loading .jar plugins in development mode * Remove unnecessary mock * Fix unit test * Add unit tests * Fix/879 add info and reset to config options (#887) * Fix gog.sh script * Add "description" property to ConfigProperties.kt Add InfoPopup.tsx and ResetToDefaultButton.tsx in UI * Bump actions/download-artifact from 7 to 8 (#891) Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 7 to 8. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v7...v8) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: '8' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump actions/cache from 4 to 5 (#890) Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/cache dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump actions/upload-artifact from 6 to 7 (#889) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Improve memory usage and performance (#888) mprove memory usage and performance by: * Using AOT cache * Using tuned JAVA_OPTIONS * Session timeout * Jetty threadpool * DB batch size * DB pool size * Library scanning * Make scan-concurrency configurable * Log retention * Off-load image processing to disk instead of RAM * Fix bug in PluginState * Update dependency version for ksp * Fix race condition preventing plugins from starting * Show remaining time (estimation) for library scans * Add unit test for plugin loading bugfix * Add unit tests for ImageService calculateBlurHash * Make username claim configurable (#895) Add fallbacks to resolve username * Fix sonar issues (#894) * Add custom "/sonar" command to GH copilot * Add Sonar plugin integration * Fix issues reported by Sonar * Ignore Sonar warning about AES/ECB * Add unit tests for GameyfinPluginManager * Add unit tests for GameService * Add more unit tests for GameService * Improve library card layout (#896) * Fix title not being centered * Add buttons to scan all libraries * Disable AVX for AOT cache training * Improve AOT cache training * Fix tests * Change output type of Docker Build CI action * Increase MAX_WAIT of aot-training to 5min * Optimize Docker CI pipeline * Add Sonar badges to README.md * Add custom metrics (downloads & scans) * Optimize DB connection & add cache for images * Adjusted logging on startup * * Show message on start page when no libraries/games are available * Disable "Scan" buttons when no metadata plugin is enabled * Fix thread pinning causing deadlocks * Pre-populate image cache at startup * Show "Loading" spinner while loading * Optimize static file serving (images) * Switch back to Tomcat (from Jetty) --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
+10
-7
@@ -1,6 +1,7 @@
|
||||
import org.apache.tools.ant.filters.ReplaceTokens
|
||||
|
||||
group = "org.gameyfin"
|
||||
version = rootProject.version
|
||||
val appMainClass = "org.gameyfin.app.GameyfinApplicationKt"
|
||||
|
||||
plugins {
|
||||
@@ -20,6 +21,10 @@ application {
|
||||
mainClass.set(appMainClass)
|
||||
}
|
||||
|
||||
springBoot {
|
||||
buildInfo()
|
||||
}
|
||||
|
||||
allOpen {
|
||||
annotations("javax.persistence.Entity", "javax.persistence.MappedSuperclass", "javax.persistence.Embedabble")
|
||||
}
|
||||
@@ -44,10 +49,7 @@ dependencies {
|
||||
implementation(kotlin("reflect"))
|
||||
|
||||
// Reactive
|
||||
implementation("org.springframework.boot:spring-boot-starter-webflux") {
|
||||
exclude(group = "org.springframework.boot", module = "spring-boot-starter-reactor-netty")
|
||||
}
|
||||
implementation("org.springframework.boot:spring-boot-starter-jetty")
|
||||
implementation("org.springframework.boot:spring-boot-starter-webflux")
|
||||
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
|
||||
|
||||
@@ -55,9 +57,7 @@ dependencies {
|
||||
implementation("com.vaadin:vaadin-core") {
|
||||
exclude("com.vaadin:flow-react")
|
||||
}
|
||||
implementation("com.vaadin:vaadin-spring-boot-starter") {
|
||||
exclude(group = "org.springframework.boot", module = "spring-boot-starter-tomcat")
|
||||
}
|
||||
implementation("com.vaadin:vaadin-spring-boot-starter")
|
||||
implementation("com.vaadin:hilla-spring-boot-starter")
|
||||
|
||||
// Logging
|
||||
@@ -81,6 +81,9 @@ dependencies {
|
||||
// Plugins
|
||||
implementation(project(":plugin-api"))
|
||||
|
||||
// Caching
|
||||
implementation("com.github.ben-manes.caffeine:caffeine:${rootProject.extra["caffeineVersion"]}")
|
||||
|
||||
// Utils
|
||||
implementation("org.apache.tika:tika-core:${rootProject.extra["tikaVersion"]}")
|
||||
implementation("me.xdrop:fuzzywuzzy:${rootProject.extra["fuzzywuzzyVersion"]}")
|
||||
|
||||
Generated
+978
-1014
File diff suppressed because it is too large
Load Diff
+114
-114
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gameyfin",
|
||||
"version": "2.3.3",
|
||||
"version": "2.4.0-preview",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@heroui/react": "^2.8.7",
|
||||
@@ -8,20 +8,20 @@
|
||||
"@react-stately/data": "^3.12.2",
|
||||
"@react-types/shared": "^3.28.0",
|
||||
"@tailwindcss/vite": "4.1.13",
|
||||
"@vaadin/aura": "25.0.3",
|
||||
"@vaadin/aura": "25.0.4",
|
||||
"@vaadin/common-frontend": "0.0.19",
|
||||
"@vaadin/hilla-file-router": "25.0.4",
|
||||
"@vaadin/hilla-frontend": "25.0.4",
|
||||
"@vaadin/hilla-lit-form": "25.0.4",
|
||||
"@vaadin/hilla-react-auth": "25.0.4",
|
||||
"@vaadin/hilla-react-crud": "25.0.4",
|
||||
"@vaadin/hilla-react-form": "25.0.4",
|
||||
"@vaadin/hilla-react-i18n": "25.0.4",
|
||||
"@vaadin/hilla-react-signals": "25.0.4",
|
||||
"@vaadin/react-components": "25.0.3",
|
||||
"@vaadin/hilla-file-router": "25.0.5",
|
||||
"@vaadin/hilla-frontend": "25.0.5",
|
||||
"@vaadin/hilla-lit-form": "25.0.5",
|
||||
"@vaadin/hilla-react-auth": "25.0.5",
|
||||
"@vaadin/hilla-react-crud": "25.0.5",
|
||||
"@vaadin/hilla-react-form": "25.0.5",
|
||||
"@vaadin/hilla-react-i18n": "25.0.5",
|
||||
"@vaadin/hilla-react-signals": "25.0.5",
|
||||
"@vaadin/react-components": "25.0.4",
|
||||
"@vaadin/vaadin-development-mode-detector": "2.0.7",
|
||||
"@vaadin/vaadin-lumo-styles": "25.0.3",
|
||||
"@vaadin/vaadin-themable-mixin": "25.0.3",
|
||||
"@vaadin/vaadin-lumo-styles": "25.0.4",
|
||||
"@vaadin/vaadin-themable-mixin": "25.0.4",
|
||||
"@vaadin/vaadin-usage-statistics": "2.1.3",
|
||||
"blurhash": "^2.0.5",
|
||||
"classnames": "^2.5.1",
|
||||
@@ -37,11 +37,11 @@
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-import": "^16.1.1",
|
||||
"rand-seed": "^2.1.7",
|
||||
"react": "19.2.3",
|
||||
"react": "19.2.4",
|
||||
"react-accessible-treeview": "^2.11.1",
|
||||
"react-aria-components": "^1.7.1",
|
||||
"react-confetti-boom": "^1.0.0",
|
||||
"react-dom": "19.2.3",
|
||||
"react-dom": "19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-player": "^2.16.0",
|
||||
"react-realtime-chart": "^0.8.1",
|
||||
@@ -58,21 +58,21 @@
|
||||
"@preact/signals-react-transform": "0.6.0",
|
||||
"@rollup/plugin-replace": "6.0.3",
|
||||
"@rollup/pluginutils": "5.3.0",
|
||||
"@types/node": "25.0.3",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/node": "25.0.10",
|
||||
"@types/react": "19.2.9",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@vaadin/hilla-generator-cli": "25.0.4",
|
||||
"@vaadin/hilla-generator-core": "25.0.4",
|
||||
"@vaadin/hilla-generator-plugin-backbone": "25.0.4",
|
||||
"@vaadin/hilla-generator-plugin-barrel": "25.0.4",
|
||||
"@vaadin/hilla-generator-plugin-client": "25.0.4",
|
||||
"@vaadin/hilla-generator-plugin-model": "25.0.4",
|
||||
"@vaadin/hilla-generator-plugin-push": "25.0.4",
|
||||
"@vaadin/hilla-generator-plugin-signals": "25.0.4",
|
||||
"@vaadin/hilla-generator-plugin-subtypes": "25.0.4",
|
||||
"@vaadin/hilla-generator-plugin-transfertypes": "25.0.4",
|
||||
"@vaadin/hilla-generator-utils": "25.0.4",
|
||||
"@vaadin/hilla-generator-cli": "25.0.5",
|
||||
"@vaadin/hilla-generator-core": "25.0.5",
|
||||
"@vaadin/hilla-generator-plugin-backbone": "25.0.5",
|
||||
"@vaadin/hilla-generator-plugin-barrel": "25.0.5",
|
||||
"@vaadin/hilla-generator-plugin-client": "25.0.5",
|
||||
"@vaadin/hilla-generator-plugin-model": "25.0.5",
|
||||
"@vaadin/hilla-generator-plugin-push": "25.0.5",
|
||||
"@vaadin/hilla-generator-plugin-signals": "25.0.5",
|
||||
"@vaadin/hilla-generator-plugin-subtypes": "25.0.5",
|
||||
"@vaadin/hilla-generator-plugin-transfertypes": "25.0.5",
|
||||
"@vaadin/hilla-generator-utils": "25.0.5",
|
||||
"@vitejs/plugin-react": "5.1.2",
|
||||
"@vitejs/plugin-react-swc": "^3.7.0",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
@@ -137,87 +137,87 @@
|
||||
"react-window": "$react-window",
|
||||
"blurhash": "$blurhash",
|
||||
"@vaadin/aura": "$@vaadin/aura",
|
||||
"@vaadin/a11y-base": "25.0.3",
|
||||
"@vaadin/accordion": "25.0.3",
|
||||
"@vaadin/app-layout": "25.0.3",
|
||||
"@vaadin/avatar": "25.0.3",
|
||||
"@vaadin/avatar-group": "25.0.3",
|
||||
"@vaadin/button": "25.0.3",
|
||||
"@vaadin/card": "25.0.3",
|
||||
"@vaadin/checkbox": "25.0.3",
|
||||
"@vaadin/checkbox-group": "25.0.3",
|
||||
"@vaadin/combo-box": "25.0.3",
|
||||
"@vaadin/component-base": "25.0.3",
|
||||
"@vaadin/confirm-dialog": "25.0.3",
|
||||
"@vaadin/context-menu": "25.0.3",
|
||||
"@vaadin/custom-field": "25.0.3",
|
||||
"@vaadin/date-picker": "25.0.3",
|
||||
"@vaadin/date-time-picker": "25.0.3",
|
||||
"@vaadin/details": "25.0.3",
|
||||
"@vaadin/dialog": "25.0.3",
|
||||
"@vaadin/email-field": "25.0.3",
|
||||
"@vaadin/field-base": "25.0.3",
|
||||
"@vaadin/field-highlighter": "25.0.3",
|
||||
"@vaadin/form-layout": "25.0.3",
|
||||
"@vaadin/grid": "25.0.3",
|
||||
"@vaadin/horizontal-layout": "25.0.3",
|
||||
"@vaadin/icon": "25.0.3",
|
||||
"@vaadin/icons": "25.0.3",
|
||||
"@vaadin/input-container": "25.0.3",
|
||||
"@vaadin/integer-field": "25.0.3",
|
||||
"@vaadin/item": "25.0.3",
|
||||
"@vaadin/list-box": "25.0.3",
|
||||
"@vaadin/lit-renderer": "25.0.3",
|
||||
"@vaadin/login": "25.0.3",
|
||||
"@vaadin/markdown": "25.0.3",
|
||||
"@vaadin/master-detail-layout": "25.0.3",
|
||||
"@vaadin/menu-bar": "25.0.3",
|
||||
"@vaadin/message-input": "25.0.3",
|
||||
"@vaadin/message-list": "25.0.3",
|
||||
"@vaadin/multi-select-combo-box": "25.0.3",
|
||||
"@vaadin/notification": "25.0.3",
|
||||
"@vaadin/number-field": "25.0.3",
|
||||
"@vaadin/overlay": "25.0.3",
|
||||
"@vaadin/password-field": "25.0.3",
|
||||
"@vaadin/popover": "25.0.3",
|
||||
"@vaadin/progress-bar": "25.0.3",
|
||||
"@vaadin/radio-group": "25.0.3",
|
||||
"@vaadin/scroller": "25.0.3",
|
||||
"@vaadin/select": "25.0.3",
|
||||
"@vaadin/side-nav": "25.0.3",
|
||||
"@vaadin/split-layout": "25.0.3",
|
||||
"@vaadin/tabs": "25.0.3",
|
||||
"@vaadin/tabsheet": "25.0.3",
|
||||
"@vaadin/text-area": "25.0.3",
|
||||
"@vaadin/text-field": "25.0.3",
|
||||
"@vaadin/time-picker": "25.0.3",
|
||||
"@vaadin/tooltip": "25.0.3",
|
||||
"@vaadin/upload": "25.0.3",
|
||||
"@vaadin/router": "2.0.1",
|
||||
"@vaadin/vertical-layout": "25.0.3",
|
||||
"@vaadin/virtual-list": "25.0.3"
|
||||
"@vaadin/a11y-base": "25.0.4",
|
||||
"@vaadin/accordion": "25.0.4",
|
||||
"@vaadin/app-layout": "25.0.4",
|
||||
"@vaadin/avatar": "25.0.4",
|
||||
"@vaadin/avatar-group": "25.0.4",
|
||||
"@vaadin/button": "25.0.4",
|
||||
"@vaadin/card": "25.0.4",
|
||||
"@vaadin/checkbox": "25.0.4",
|
||||
"@vaadin/checkbox-group": "25.0.4",
|
||||
"@vaadin/combo-box": "25.0.4",
|
||||
"@vaadin/component-base": "25.0.4",
|
||||
"@vaadin/confirm-dialog": "25.0.4",
|
||||
"@vaadin/context-menu": "25.0.4",
|
||||
"@vaadin/custom-field": "25.0.4",
|
||||
"@vaadin/date-picker": "25.0.4",
|
||||
"@vaadin/date-time-picker": "25.0.4",
|
||||
"@vaadin/details": "25.0.4",
|
||||
"@vaadin/dialog": "25.0.4",
|
||||
"@vaadin/email-field": "25.0.4",
|
||||
"@vaadin/field-base": "25.0.4",
|
||||
"@vaadin/field-highlighter": "25.0.4",
|
||||
"@vaadin/form-layout": "25.0.4",
|
||||
"@vaadin/grid": "25.0.4",
|
||||
"@vaadin/horizontal-layout": "25.0.4",
|
||||
"@vaadin/icon": "25.0.4",
|
||||
"@vaadin/icons": "25.0.4",
|
||||
"@vaadin/input-container": "25.0.4",
|
||||
"@vaadin/integer-field": "25.0.4",
|
||||
"@vaadin/item": "25.0.4",
|
||||
"@vaadin/list-box": "25.0.4",
|
||||
"@vaadin/lit-renderer": "25.0.4",
|
||||
"@vaadin/login": "25.0.4",
|
||||
"@vaadin/markdown": "25.0.4",
|
||||
"@vaadin/master-detail-layout": "25.0.4",
|
||||
"@vaadin/menu-bar": "25.0.4",
|
||||
"@vaadin/message-input": "25.0.4",
|
||||
"@vaadin/message-list": "25.0.4",
|
||||
"@vaadin/multi-select-combo-box": "25.0.4",
|
||||
"@vaadin/notification": "25.0.4",
|
||||
"@vaadin/number-field": "25.0.4",
|
||||
"@vaadin/overlay": "25.0.4",
|
||||
"@vaadin/password-field": "25.0.4",
|
||||
"@vaadin/popover": "25.0.4",
|
||||
"@vaadin/progress-bar": "25.0.4",
|
||||
"@vaadin/radio-group": "25.0.4",
|
||||
"@vaadin/scroller": "25.0.4",
|
||||
"@vaadin/select": "25.0.4",
|
||||
"@vaadin/side-nav": "25.0.4",
|
||||
"@vaadin/split-layout": "25.0.4",
|
||||
"@vaadin/tabs": "25.0.4",
|
||||
"@vaadin/tabsheet": "25.0.4",
|
||||
"@vaadin/text-area": "25.0.4",
|
||||
"@vaadin/text-field": "25.0.4",
|
||||
"@vaadin/time-picker": "25.0.4",
|
||||
"@vaadin/tooltip": "25.0.4",
|
||||
"@vaadin/upload": "25.0.4",
|
||||
"@vaadin/vertical-layout": "25.0.4",
|
||||
"@vaadin/virtual-list": "25.0.4"
|
||||
},
|
||||
"vaadin": {
|
||||
"dependencies": {
|
||||
"@vaadin/aura": "25.0.3",
|
||||
"@vaadin/aura": "25.0.4",
|
||||
"@vaadin/common-frontend": "0.0.19",
|
||||
"@vaadin/hilla-file-router": "25.0.4",
|
||||
"@vaadin/hilla-frontend": "25.0.4",
|
||||
"@vaadin/hilla-lit-form": "25.0.4",
|
||||
"@vaadin/hilla-react-auth": "25.0.4",
|
||||
"@vaadin/hilla-react-crud": "25.0.4",
|
||||
"@vaadin/hilla-react-form": "25.0.4",
|
||||
"@vaadin/hilla-react-i18n": "25.0.4",
|
||||
"@vaadin/hilla-react-signals": "25.0.4",
|
||||
"@vaadin/react-components": "25.0.3",
|
||||
"@vaadin/hilla-file-router": "25.0.5",
|
||||
"@vaadin/hilla-frontend": "25.0.5",
|
||||
"@vaadin/hilla-lit-form": "25.0.5",
|
||||
"@vaadin/hilla-react-auth": "25.0.5",
|
||||
"@vaadin/hilla-react-crud": "25.0.5",
|
||||
"@vaadin/hilla-react-form": "25.0.5",
|
||||
"@vaadin/hilla-react-i18n": "25.0.5",
|
||||
"@vaadin/hilla-react-signals": "25.0.5",
|
||||
"@vaadin/react-components": "25.0.4",
|
||||
"@vaadin/vaadin-development-mode-detector": "2.0.7",
|
||||
"@vaadin/vaadin-lumo-styles": "25.0.3",
|
||||
"@vaadin/vaadin-themable-mixin": "25.0.3",
|
||||
"@vaadin/vaadin-lumo-styles": "25.0.4",
|
||||
"@vaadin/vaadin-themable-mixin": "25.0.4",
|
||||
"@vaadin/vaadin-usage-statistics": "2.1.3",
|
||||
"date-fns": "4.1.0",
|
||||
"lit": "3.3.2",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-router": "7.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -225,20 +225,20 @@
|
||||
"@preact/signals-react-transform": "0.6.0",
|
||||
"@rollup/plugin-replace": "6.0.3",
|
||||
"@rollup/pluginutils": "5.3.0",
|
||||
"@types/node": "25.0.3",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/node": "25.0.10",
|
||||
"@types/react": "19.2.9",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vaadin/hilla-generator-cli": "25.0.4",
|
||||
"@vaadin/hilla-generator-core": "25.0.4",
|
||||
"@vaadin/hilla-generator-plugin-backbone": "25.0.4",
|
||||
"@vaadin/hilla-generator-plugin-barrel": "25.0.4",
|
||||
"@vaadin/hilla-generator-plugin-client": "25.0.4",
|
||||
"@vaadin/hilla-generator-plugin-model": "25.0.4",
|
||||
"@vaadin/hilla-generator-plugin-push": "25.0.4",
|
||||
"@vaadin/hilla-generator-plugin-signals": "25.0.4",
|
||||
"@vaadin/hilla-generator-plugin-subtypes": "25.0.4",
|
||||
"@vaadin/hilla-generator-plugin-transfertypes": "25.0.4",
|
||||
"@vaadin/hilla-generator-utils": "25.0.4",
|
||||
"@vaadin/hilla-generator-cli": "25.0.5",
|
||||
"@vaadin/hilla-generator-core": "25.0.5",
|
||||
"@vaadin/hilla-generator-plugin-backbone": "25.0.5",
|
||||
"@vaadin/hilla-generator-plugin-barrel": "25.0.5",
|
||||
"@vaadin/hilla-generator-plugin-client": "25.0.5",
|
||||
"@vaadin/hilla-generator-plugin-model": "25.0.5",
|
||||
"@vaadin/hilla-generator-plugin-push": "25.0.5",
|
||||
"@vaadin/hilla-generator-plugin-signals": "25.0.5",
|
||||
"@vaadin/hilla-generator-plugin-subtypes": "25.0.5",
|
||||
"@vaadin/hilla-generator-plugin-transfertypes": "25.0.5",
|
||||
"@vaadin/hilla-generator-utils": "25.0.5",
|
||||
"@vitejs/plugin-react": "5.1.2",
|
||||
"magic-string": "0.30.21",
|
||||
"rollup-plugin-brotli": "3.1.0",
|
||||
@@ -251,6 +251,6 @@
|
||||
"workbox-build": "7.4.0"
|
||||
},
|
||||
"disableUsageStatistics": true,
|
||||
"hash": "d2c583f908a126db3f53ccbc87688b5089107afb58a87159631dc257a3a279ae"
|
||||
"hash": "812856fcd393a00f84011d76741a6665711ccb1b42be83fab6d8f480425a45da"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -14,7 +14,7 @@ import {ToastProvider} from "@heroui/toast";
|
||||
import {initializePluginState} from "Frontend/state/PluginState";
|
||||
import {isAdmin} from "Frontend/util/utils";
|
||||
import {useRouteMetadata} from "Frontend/util/routing";
|
||||
import {useEffect} from "react";
|
||||
import {useCallback, useEffect} from "react";
|
||||
import {initializeGameRequestState} from "Frontend/state/GameRequestState";
|
||||
import {initializePlatformState} from "Frontend/state/PlatformState";
|
||||
import {initializeDownloadSessionState} from "Frontend/state/DownloadSessionState";
|
||||
@@ -25,6 +25,12 @@ export default function App() {
|
||||
client.middlewares = [ErrorHandlingMiddleware];
|
||||
|
||||
const navigate = useNavigate();
|
||||
const reactRouterUseHref = useHref;
|
||||
// Fixes an issue where external links would be treated as internal links
|
||||
const safeUseHref = useCallback(
|
||||
(href: string) => /^https?:\/\//i.test(href) ? href : reactRouterUseHref(href),
|
||||
[reactRouterUseHref]
|
||||
);
|
||||
const routeMetadata = useRouteMetadata();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -32,7 +38,7 @@ export default function App() {
|
||||
}, [routeMetadata, window.location.href]);
|
||||
|
||||
return (
|
||||
<HeroUIProvider className="size-full" navigate={navigate} useHref={useHref}>
|
||||
<HeroUIProvider className="size-full" navigate={navigate} useHref={safeUseHref}>
|
||||
<NextThemesProvider attribute="class" themes={themeNames()} defaultTheme="gameyfin-violet-dark">
|
||||
<AuthProvider>
|
||||
<ViewWithAuth/>
|
||||
|
||||
@@ -7,48 +7,68 @@ import ArrayInput from "Frontend/components/general/input/ArrayInput";
|
||||
import NumberInput from "Frontend/components/general/input/NumberInput";
|
||||
import SliderInput from "Frontend/components/general/input/SliderInput";
|
||||
|
||||
export default function ConfigFormField({configElement, ...props}: any) {
|
||||
interface ConfigFormFieldProps {
|
||||
configElement?: ConfigEntryDto;
|
||||
className?: string;
|
||||
isDisabled?: boolean;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
type CommonInputProps = Pick<ConfigFormFieldProps, "className" | "isDisabled">;
|
||||
|
||||
export default function ConfigFormField({configElement, type: inputType, className, isDisabled}: ConfigFormFieldProps) {
|
||||
function inputElement(configElement: ConfigEntryDto) {
|
||||
const commonProps: CommonInputProps = {className, isDisabled};
|
||||
const description = configElement.description;
|
||||
const defaultValue = configElement.defaultValue;
|
||||
|
||||
if (configElement.allowedValues != null && configElement.allowedValues.length > 0) {
|
||||
return (
|
||||
<SelectInput label={configElement.description} name={configElement.key}
|
||||
values={configElement.allowedValues} {...props}/>
|
||||
<SelectInput label={configElement.name} name={configElement.key}
|
||||
description={description} resetValue={defaultValue}
|
||||
values={configElement.allowedValues} {...commonProps}/>
|
||||
);
|
||||
}
|
||||
|
||||
switch (configElement.type.toLowerCase()) {
|
||||
case "boolean":
|
||||
return (
|
||||
<CheckboxInput label={configElement.description} name={configElement.key} {...props}/>
|
||||
<CheckboxInput label={configElement.name} name={configElement.key}
|
||||
description={description} resetValue={defaultValue} {...commonProps}/>
|
||||
);
|
||||
case "string":
|
||||
return (
|
||||
<Input label={configElement.description} name={configElement.key}
|
||||
type={props.type && "text"} {...props}/>
|
||||
<Input label={configElement.name} name={configElement.key}
|
||||
description={description} resetValue={defaultValue}
|
||||
type={inputType ?? "text"} {...commonProps}/>
|
||||
);
|
||||
case "float":
|
||||
return (
|
||||
<NumberInput label={configElement.description} name={configElement.key}
|
||||
step={0.1} {...props}/>
|
||||
<NumberInput label={configElement.name} name={configElement.key}
|
||||
description={description} resetValue={defaultValue}
|
||||
step={0.1} {...commonProps}/>
|
||||
);
|
||||
case "int":
|
||||
if (configElement.min != null && configElement.max != null) {
|
||||
return (
|
||||
<SliderInput label={configElement.description} name={configElement.key}
|
||||
min={configElement.min}
|
||||
max={configElement.max}
|
||||
step={configElement.step ?? 1}
|
||||
{...props}/>
|
||||
<SliderInput label={configElement.name} name={configElement.key}
|
||||
description={description} resetValue={defaultValue}
|
||||
minValue={configElement.min as number}
|
||||
maxValue={configElement.max as number}
|
||||
step={(configElement.step as number) ?? 1}
|
||||
{...commonProps}/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<NumberInput label={configElement.description} name={configElement.key}
|
||||
step={1} {...props}/>
|
||||
<NumberInput label={configElement.name} name={configElement.key}
|
||||
description={description} resetValue={defaultValue}
|
||||
step={1} {...commonProps}/>
|
||||
);
|
||||
case "array":
|
||||
return (
|
||||
<ArrayInput label={configElement.description} name={configElement.key} type="text" {...props}/>
|
||||
<ArrayInput label={configElement.name} name={configElement.key}
|
||||
description={description} resetValue={defaultValue}
|
||||
type="text" {...commonProps}/>
|
||||
);
|
||||
default:
|
||||
return <pre>Unsupported type: {configElement.type} for key {configElement.key}</pre>;
|
||||
@@ -56,4 +76,4 @@ export default function ConfigFormField({configElement, ...props}: any) {
|
||||
}
|
||||
|
||||
return inputElement(configElement!);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React from "react";
|
||||
import {LibraryEndpoint} from "Frontend/generated/endpoints";
|
||||
import ScanType from "Frontend/generated/org/gameyfin/app/libraries/enums/ScanType";
|
||||
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
|
||||
import withConfigPage from "Frontend/components/administration/withConfigPage";
|
||||
import Section from "Frontend/components/general/Section";
|
||||
import * as Yup from 'yup';
|
||||
import "Frontend/util/yup-extensions";
|
||||
import {Button, Divider, Tooltip, useDisclosure} from "@heroui/react";
|
||||
import {ListNumbersIcon, PlusIcon} from "@phosphor-icons/react";
|
||||
import {ListNumbersIcon, MagnifyingGlassIcon, MagnifyingGlassPlusIcon, PlusIcon} from "@phosphor-icons/react";
|
||||
import {LibraryOverviewCard} from "Frontend/components/general/cards/LibraryOverviewCard";
|
||||
import LibraryCreationModal from "Frontend/components/general/modals/LibraryCreationModal";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
@@ -15,6 +17,7 @@ import {collectionState} from "Frontend/state/CollectionState";
|
||||
import {CollectionOverviewCard} from "Frontend/components/general/cards/CollectionOverviewCard";
|
||||
import CollectionCreationModal from "Frontend/components/general/modals/CollectionCreationModal";
|
||||
import CollectionPrioritiesModal from "Frontend/components/general/modals/CollectionPrioritiesModal";
|
||||
import {pluginState} from "Frontend/state/PluginState";
|
||||
|
||||
function GameManagementLayout({getConfig, formik}: any) {
|
||||
const libraries = useSnapshot(libraryState);
|
||||
@@ -25,11 +28,31 @@ function GameManagementLayout({getConfig, formik}: any) {
|
||||
const collectionCreationModal = useDisclosure();
|
||||
const collectionOrderModal = useDisclosure();
|
||||
|
||||
const hasActiveMetadataPlugins = useSnapshot(pluginState).hasActiveMetadataPlugins;
|
||||
|
||||
async function triggerScan(scanType: ScanType) {
|
||||
await LibraryEndpoint.triggerScan(scanType, undefined);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-row items-baseline justify-between">
|
||||
<h2 className="text-xl font-bold mt-8 mb-1">Libraries</h2>
|
||||
<div className="flex flex-row gap-2">
|
||||
<Tooltip content="Scan all libraries (quick)">
|
||||
<Button isIconOnly variant="flat"
|
||||
isDisabled={!hasActiveMetadataPlugins}
|
||||
onPress={() => triggerScan(ScanType.QUICK)}>
|
||||
<MagnifyingGlassIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Scan all libraries (full)">
|
||||
<Button isIconOnly variant="flat"
|
||||
isDisabled={!hasActiveMetadataPlugins}
|
||||
onPress={() => triggerScan(ScanType.FULL)}>
|
||||
<MagnifyingGlassPlusIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Change library order">
|
||||
<Button isIconOnly variant="flat" onPress={libraryOrderModal.onOpen}>
|
||||
<ListNumbersIcon/>
|
||||
@@ -92,6 +115,7 @@ function GameManagementLayout({getConfig, formik}: any) {
|
||||
</div>
|
||||
<ConfigFormField configElement={getConfig("library.scan.title-match-min-ratio")}/>
|
||||
<ConfigFormField configElement={getConfig("library.scan.game-file-extensions")}/>
|
||||
<ConfigFormField configElement={getConfig("library.scan.max-concurrency")}/>
|
||||
</div>
|
||||
|
||||
<Section title="Metadata"/>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import {Link, Tooltip} from "@heroui/react";
|
||||
import {InfoIcon} from "@phosphor-icons/react";
|
||||
import Markdown from "react-markdown";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
import React from "react";
|
||||
|
||||
interface InfoPopupProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export default function InfoPopup({content}: InfoPopupProps) {
|
||||
return (
|
||||
<Tooltip placement="right" content={
|
||||
<Markdown
|
||||
remarkPlugins={[remarkBreaks]}
|
||||
components={{
|
||||
a(props) {
|
||||
return <Link isExternal
|
||||
showAnchorIcon
|
||||
color="foreground"
|
||||
underline="always"
|
||||
href={props.href}
|
||||
size="sm">
|
||||
{props.children}
|
||||
</Link>
|
||||
}
|
||||
}}
|
||||
>{content}</Markdown>
|
||||
}>
|
||||
<InfoIcon size={16} weight="fill" className="ml-1 z-50"/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import Section from "Frontend/components/general/Section";
|
||||
import Input from "Frontend/components/general/input/Input";
|
||||
import {addToast, Button, Input as NextUiInput, Tooltip} from "@heroui/react";
|
||||
import {Form, Formik} from "formik";
|
||||
import { ArrowCounterClockwiseIcon, CheckIcon, InfoIcon, TrashIcon } from "@phosphor-icons/react";
|
||||
import {Form, Formik, FormikProps} from "formik";
|
||||
import {ArrowCounterClockwiseIcon, CheckIcon, InfoIcon, TrashIcon} from "@phosphor-icons/react";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {useAuth} from "Frontend/util/auth";
|
||||
import * as Yup from "yup";
|
||||
@@ -12,9 +12,16 @@ import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
|
||||
import {removeAvatar, uploadAvatar} from "Frontend/endpoints/AvatarEndpoint";
|
||||
import Avatar from "Frontend/components/general/Avatar";
|
||||
|
||||
interface ProfileFormValues {
|
||||
username: string | undefined;
|
||||
email: string | undefined;
|
||||
newPassword: string;
|
||||
passwordRepeat: string;
|
||||
}
|
||||
|
||||
export default function ProfileManagement() {
|
||||
const auth = useAuth();
|
||||
const [avatar, setAvatar] = useState<any>();
|
||||
const [avatar, setAvatar] = useState<File>();
|
||||
const [configSaved, setConfigSaved] = useState(false);
|
||||
const [messagesEnabled, setMessagesEnabled] = useState(false);
|
||||
|
||||
@@ -29,11 +36,11 @@ export default function ProfileManagement() {
|
||||
}, [configSaved])
|
||||
|
||||
|
||||
function onFileSelected(event: any) {
|
||||
setAvatar(event.target.files[0]);
|
||||
function onFileSelected(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
setAvatar(event.target.files?.[0]);
|
||||
}
|
||||
|
||||
async function handleSubmit(values: any) {
|
||||
async function handleSubmit(values: ProfileFormValues) {
|
||||
const userUpdate: UserUpdateDto = {
|
||||
username: values.username,
|
||||
email: values.email
|
||||
@@ -80,7 +87,7 @@ export default function ProfileManagement() {
|
||||
.equals([Yup.ref('newPassword')], 'Passwords do not match')
|
||||
})}
|
||||
>
|
||||
{(formik: { values: any; isSubmitting: any; dirty: boolean; }) => (
|
||||
{(formik: FormikProps<ProfileFormValues>) => (
|
||||
<Form>
|
||||
<div className="flex flex-row grow justify-between mb-8">
|
||||
<h2 className="text-2xl font-bold">My Profile</h2>
|
||||
@@ -124,10 +131,10 @@ export default function ProfileManagement() {
|
||||
|
||||
<div className="flex flex-col grow">
|
||||
<Section title="Personal information"/>
|
||||
<Input name="username" label="Username" type="text" autocomplete="username"
|
||||
<Input name="username" label="Username" type="text" autoComplete="username"
|
||||
isDisabled={auth.state.user?.managedBySso}/>
|
||||
<div className="flex flex-row gap-4">
|
||||
<Input name="email" label="Email" type="email" autocomplete="email"
|
||||
<Input name="email" label="Email" type="email" autoComplete="email"
|
||||
isDisabled={auth.state.user?.managedBySso || !messagesEnabled}/>
|
||||
{(auth.state.user?.emailConfirmed === false && !auth.state.user.managedBySso) &&
|
||||
<Tooltip content="Resend email confirmation message">
|
||||
@@ -160,9 +167,9 @@ export default function ProfileManagement() {
|
||||
}
|
||||
<Section title="Security"/>
|
||||
<Input name="newPassword" label="New Password" type="password"
|
||||
autocomplete="new-password" isDisabled={auth.state.user?.managedBySso}/>
|
||||
autoComplete="new-password" isDisabled={auth.state.user?.managedBySso}/>
|
||||
<Input name="passwordRepeat" label="Repeat password" type="password"
|
||||
autocomplete="new-password" isDisabled={auth.state.user?.managedBySso}/>
|
||||
autoComplete="new-password" isDisabled={auth.state.user?.managedBySso}/>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import {Button, Tooltip} from "@heroui/react";
|
||||
import {ArrowUUpLeftIcon} from "@phosphor-icons/react";
|
||||
import {useFormikContext} from "formik";
|
||||
|
||||
interface ResetToDefaultButtonProps {
|
||||
fieldName: string;
|
||||
defaultValue: unknown;
|
||||
}
|
||||
|
||||
function valuesEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true;
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
if (a.length !== b.length) return false;
|
||||
return a.every((val, i) => valuesEqual(val, b[i]));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function formatDefaultValue(value: unknown): string {
|
||||
const str = Array.isArray(value) ? value.join(", ") : String(value ?? "");
|
||||
return str.length > 25 ? str.substring(0, 25) + "…" : str;
|
||||
}
|
||||
|
||||
export default function ResetToDefaultButton({fieldName, defaultValue}: ResetToDefaultButtonProps) {
|
||||
const {setFieldValue, getFieldMeta} = useFormikContext();
|
||||
const currentValue = getFieldMeta(fieldName).value;
|
||||
const isDefault = valuesEqual(currentValue, defaultValue);
|
||||
|
||||
return (
|
||||
<Tooltip placement="right" content={
|
||||
<span>Reset to default: <pre className="inline">{formatDefaultValue(defaultValue)}</pre></span>
|
||||
}>
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="light"
|
||||
radius="full"
|
||||
isDisabled={isDefault}
|
||||
className="-ml-2 z-50"
|
||||
onPress={() => setFieldValue(fieldName, defaultValue)}
|
||||
>
|
||||
<ArrowUUpLeftIcon size={16}/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,11 +51,12 @@ function SecurityManagementLayout({getConfig, formik, setSaveMessage}: any) {
|
||||
<div className="flex flex-row items-start gap-8">
|
||||
<div className="flex flex-col">
|
||||
<h2 className="text-xl font-bold mb-4">General configuration</h2>
|
||||
<ConfigFormField className="mb-4"
|
||||
configElement={getConfig("sso.oidc.enabled")}/>
|
||||
<ConfigFormField className="mb-4" configElement={getConfig("sso.oidc.enabled")}/>
|
||||
|
||||
<ConfigFormField configElement={getConfig("sso.oidc.match-existing-users-by")}
|
||||
isDisabled={!formik.values.sso.oidc.enabled}/>
|
||||
|
||||
<ConfigFormField configElement={getConfig("sso.oidc.username-claim")}
|
||||
isDisabled={!formik.values.sso.oidc.enabled}/>
|
||||
<ConfigFormField configElement={getConfig("sso.oidc.roles-claim")}
|
||||
isDisabled={!formik.values.sso.oidc.enabled}/>
|
||||
<ConfigFormField configElement={getConfig("sso.oidc.oauth-scopes")}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {ConfigEndpoint} from "Frontend/generated/endpoints";
|
||||
import ConfigEntryDto from "Frontend/generated/org/gameyfin/app/config/dto/ConfigEntryDto";
|
||||
import {Form, Formik} from "formik";
|
||||
import {Form, Formik, FormikProps} from "formik";
|
||||
import {Button, Skeleton} from "@heroui/react";
|
||||
import {CheckIcon, InfoIcon} from "@phosphor-icons/react";
|
||||
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
|
||||
import {configState, initializeConfigState, NestedConfig} from "Frontend/state/ConfigState";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import * as Yup from "yup";
|
||||
|
||||
export default function withConfigPage(WrappedComponent: React.ComponentType<any>, title: String, validationSchema?: any) {
|
||||
return function ConfigPage(props: any) {
|
||||
export interface ConfigPageProps {
|
||||
getConfig: (key: string) => ConfigEntryDto | undefined;
|
||||
formik: FormikProps<NestedConfig>;
|
||||
setSaveMessage: (message: string | undefined) => void;
|
||||
}
|
||||
|
||||
export default function withConfigPage(WrappedComponent: React.ComponentType<ConfigPageProps>, title: string, validationSchema?: Yup.ObjectSchema<Record<string, unknown>>) {
|
||||
return function ConfigPage(props: Record<string, never>) {
|
||||
const [configSaved, setConfigSaved] = useState(false);
|
||||
const [saveMessage, setSaveMessage] = useState<string>();
|
||||
|
||||
@@ -35,14 +42,14 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
|
||||
return state.state[key];
|
||||
}
|
||||
|
||||
function getChangedValues(initial: NestedConfig, current: NestedConfig): Record<string, any> {
|
||||
const flatten = (obj: NestedConfig, parentKey = ''): Record<string, any> => {
|
||||
let result: Record<string, any> = {};
|
||||
function getChangedValues(initial: NestedConfig, current: NestedConfig): Record<string, NestedConfig[string]> {
|
||||
const flatten = (obj: NestedConfig, parentKey = ''): Record<string, NestedConfig[string]> => {
|
||||
let result: Record<string, NestedConfig[string]> = {};
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key)) {
|
||||
const newKey = parentKey ? `${parentKey}.${key}` : key;
|
||||
if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) {
|
||||
Object.assign(result, flatten(obj[key], newKey));
|
||||
Object.assign(result, flatten(obj[key] as NestedConfig, newKey));
|
||||
} else {
|
||||
result[newKey] = obj[key];
|
||||
}
|
||||
@@ -51,11 +58,11 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
|
||||
return result;
|
||||
};
|
||||
|
||||
const arraysEqual = (a: any[], b: any[]): boolean => {
|
||||
const arraysEqual = (a: unknown[], b: unknown[]): boolean => {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (Array.isArray(a[i]) && Array.isArray(b[i])) {
|
||||
if (!arraysEqual(a[i], b[i])) return false;
|
||||
if (!arraysEqual(a[i] as unknown[], b[i] as unknown[])) return false;
|
||||
} else if (a[i] !== b[i]) {
|
||||
return false;
|
||||
}
|
||||
@@ -66,7 +73,7 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
|
||||
const flatInitial = flatten(initial);
|
||||
const flatCurrent = flatten(current);
|
||||
|
||||
const changed: Record<string, any> = {};
|
||||
const changed: Record<string, NestedConfig[string]> = {};
|
||||
for (const key in flatCurrent) {
|
||||
const valA = flatCurrent[key];
|
||||
const valB = flatInitial[key];
|
||||
|
||||
@@ -15,7 +15,8 @@ import {libraryState} from "Frontend/state/LibraryState";
|
||||
import {TargetIcon, WarningIcon} from "@phosphor-icons/react";
|
||||
import {timeBetween, timeUntil, toTitleCase} from "Frontend/util/utils";
|
||||
import LibraryScanStatus from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryScanStatus";
|
||||
import {useEffect, useState} from "react";
|
||||
import type LibraryScanProgress from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryScanProgress";
|
||||
import {useEffect, useRef, useState} from "react";
|
||||
|
||||
export default function ScanProgressPopover() {
|
||||
const libraries = useSnapshot(libraryState).state;
|
||||
@@ -23,7 +24,10 @@ export default function ScanProgressPopover() {
|
||||
const scanInProgress = useSnapshot(scanState).isScanning;
|
||||
|
||||
// Add state to track current time and force re-renders
|
||||
const [currentTime, setCurrentTime] = useState(Date.now());
|
||||
const [_currentTime, setCurrentTime] = useState(Date.now());
|
||||
|
||||
// Cache ETAs per scanId: { eta: string | null, computedAt: number }
|
||||
const etaCacheRef = useRef<Record<string, { eta: string | null; computedAt: number }>>({});
|
||||
|
||||
// Set up an interval to update the time every second
|
||||
useEffect(() => {
|
||||
@@ -35,6 +39,42 @@ export default function ScanProgressPopover() {
|
||||
return () => clearInterval(intervalId);
|
||||
}, []);
|
||||
|
||||
function estimateTimeLeft(scan: LibraryScanProgress): string | null {
|
||||
const now = Date.now();
|
||||
const cached = etaCacheRef.current[scan.scanId];
|
||||
// Only recompute every 5 seconds
|
||||
if (cached && now - cached.computedAt < 5000) {
|
||||
return cached.eta;
|
||||
}
|
||||
|
||||
const current = scan.currentStep.current;
|
||||
const total = scan.currentStep.total;
|
||||
if (!current || !total || current <= 0 || total <= 0) {
|
||||
etaCacheRef.current[scan.scanId] = {eta: null, computedAt: now};
|
||||
return null;
|
||||
}
|
||||
|
||||
const elapsed = (now - new Date(scan.startedAt).getTime()) / 1000;
|
||||
if (elapsed <= 0) {
|
||||
etaCacheRef.current[scan.scanId] = {eta: null, computedAt: now};
|
||||
return null;
|
||||
}
|
||||
|
||||
const rate = current / elapsed; // items per second
|
||||
const remaining = total - current;
|
||||
const secondsLeft = Math.round(remaining / rate);
|
||||
if (secondsLeft < 0) {
|
||||
etaCacheRef.current[scan.scanId] = {eta: null, computedAt: now};
|
||||
return null;
|
||||
}
|
||||
|
||||
const mins = Math.floor(secondsLeft / 60);
|
||||
const secs = secondsLeft % 60;
|
||||
const eta = `${mins}:${secs.toString().padStart(2, "0")} min left`;
|
||||
etaCacheRef.current[scan.scanId] = {eta, computedAt: now};
|
||||
return eta;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover placement="bottom-end" showArrow={true}>
|
||||
<PopoverTrigger>
|
||||
@@ -79,9 +119,14 @@ export default function ScanProgressPopover() {
|
||||
{scan.status === LibraryScanStatus.IN_PROGRESS &&
|
||||
(scan.currentStep.current && scan.currentStep.total ?
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-default-500">
|
||||
{`${scan.currentStep.description} (${scan.currentStep.current}/${scan.currentStep.total})`}
|
||||
</p>
|
||||
<div className="flex flex-row justify-between">
|
||||
<p className="text-default-500">
|
||||
{`${scan.currentStep.description} (${scan.currentStep.current}/${scan.currentStep.total})`}
|
||||
</p>
|
||||
<p className="text-default-500">
|
||||
{estimateTimeLeft(scan)}
|
||||
</p>
|
||||
</div>
|
||||
<Progress
|
||||
value={scan.currentStep.current / scan.currentStep.total * 100}
|
||||
size="sm"/>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {gameState} from "Frontend/state/GameState";
|
||||
import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern";
|
||||
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
|
||||
import ChipList from "Frontend/components/general/ChipList";
|
||||
import {pluginState} from "Frontend/state/PluginState";
|
||||
|
||||
interface LibraryOverviewCardProps {
|
||||
library: LibraryAdminDto;
|
||||
@@ -20,6 +21,7 @@ export function LibraryOverviewCard({library}: LibraryOverviewCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const state = useSnapshot(gameState);
|
||||
const randomGames = getRandomGames();
|
||||
const hasActiveMetadataPlugins = useSnapshot(pluginState).hasActiveMetadataPlugins;
|
||||
|
||||
function getRandomGames() {
|
||||
if (!state.randomlyOrderedGamesByLibraryId[library.id]) return [];
|
||||
@@ -47,16 +49,20 @@ export function LibraryOverviewCard({library}: LibraryOverviewCardProps) {
|
||||
}
|
||||
</div>
|
||||
|
||||
<p className="absolute text-2xl font-bold">{library.name}</p>
|
||||
<p className="mt-6 absolute text-2xl text-center font-bold">{library.name}</p>
|
||||
|
||||
<div className="absolute right-0 top-0 flex flex-row">
|
||||
<Tooltip content="Scan library (quick)" placement="bottom" color="foreground">
|
||||
<Button isIconOnly variant="light" onPress={() => triggerScan(ScanType.QUICK)}>
|
||||
<Button isIconOnly variant="light"
|
||||
isDisabled={!hasActiveMetadataPlugins}
|
||||
onPress={() => triggerScan(ScanType.QUICK)}>
|
||||
<MagnifyingGlassIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Scan library (full)" placement="bottom" color="foreground">
|
||||
<Button isIconOnly variant="light" onPress={() => triggerScan(ScanType.FULL)}>
|
||||
<Button isIconOnly variant="light"
|
||||
isDisabled={!hasActiveMetadataPlugins}
|
||||
onPress={() => triggerScan(ScanType.FULL)}>
|
||||
<MagnifyingGlassPlusIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
@@ -2,11 +2,20 @@ import {FieldArray, useField} from "formik";
|
||||
import {Button, Chip, Input, Popover, PopoverContent, PopoverTrigger} from "@heroui/react";
|
||||
import {KeyboardEvent, useState} from "react";
|
||||
import {PlusIcon} from "@phosphor-icons/react";
|
||||
import InfoPopup from "Frontend/components/administration/InfoPopup";
|
||||
import ResetToDefaultButton from "Frontend/components/administration/ResetToDefaultButton";
|
||||
|
||||
// @ts-ignore
|
||||
const ArrayInput = ({label, ...props}) => {
|
||||
// @ts-ignore
|
||||
const [field, meta] = useField(props);
|
||||
interface ArrayInputProps {
|
||||
label: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
description?: string;
|
||||
resetValue?: unknown;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export default function ArrayInput({label, description, resetValue, ...props}: ArrayInputProps) {
|
||||
const [field, meta] = useField<string[]>(props.name);
|
||||
const [newElementValue, setNewElementValue] = useState<string>("");
|
||||
|
||||
return (
|
||||
@@ -29,12 +38,17 @@ const ArrayInput = ({label, ...props}) => {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 gap-2">
|
||||
<div className="flex flex-row justify-between">
|
||||
<p>{label}</p>
|
||||
<span className="flex items-center gap-1">
|
||||
<p>{label}</p>
|
||||
{description && <InfoPopup content={description}/>}
|
||||
{resetValue !== undefined &&
|
||||
<ResetToDefaultButton fieldName={field.name} defaultValue={resetValue}/>}
|
||||
</span>
|
||||
<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) => (
|
||||
{field.value.map((element: string, index: number) => (
|
||||
<Chip key={index}
|
||||
onClose={() => arrayHelpers.remove(index)}
|
||||
isDisabled={props.isDisabled}
|
||||
@@ -76,5 +90,3 @@ const ArrayInput = ({label, ...props}) => {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ArrayInput;
|
||||
@@ -1,29 +1,39 @@
|
||||
import {useField} from "formik";
|
||||
import {Checkbox, CheckboxGroup} from "@heroui/react";
|
||||
import {Checkbox, CheckboxGroup, CheckboxProps} from "@heroui/react";
|
||||
import InfoPopup from "Frontend/components/administration/InfoPopup";
|
||||
import ResetToDefaultButton from "Frontend/components/administration/ResetToDefaultButton";
|
||||
|
||||
// @ts-ignore
|
||||
const CheckboxInput = ({label, ...props}) => {
|
||||
// @ts-ignore
|
||||
const [field, meta] = useField(props);
|
||||
interface CheckboxInputProps extends Omit<CheckboxProps, "name"> {
|
||||
label: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
resetValue?: unknown;
|
||||
}
|
||||
|
||||
export default function CheckboxInput({label, description, resetValue, className, ...props}: CheckboxInputProps) {
|
||||
const [field, meta] = useField({name: props.name, type: "checkbox"});
|
||||
|
||||
return (
|
||||
<CheckboxGroup
|
||||
className="flex flex-row flex-1 items-baseline gap-2"
|
||||
className={`flex flex-row flex-1 gap-2 ${className ?? ""}`}
|
||||
isInvalid={!!meta.error}
|
||||
errorMessage={meta.initialError || meta.error}
|
||||
value={field.value ? [field.name] : []}
|
||||
>
|
||||
<Checkbox
|
||||
className="items-baseline"
|
||||
{...field}
|
||||
{...props}
|
||||
// @ts-ignore
|
||||
value={field.name}
|
||||
>
|
||||
{label}
|
||||
</Checkbox>
|
||||
<span className="flex items-center gap-1">
|
||||
<Checkbox
|
||||
{...field}
|
||||
{...props}
|
||||
className="items-center"
|
||||
value={field.name}
|
||||
>
|
||||
{label}
|
||||
</Checkbox>
|
||||
{description && <InfoPopup content={description}/>}
|
||||
{resetValue !== undefined &&
|
||||
<ResetToDefaultButton fieldName={field.name} defaultValue={resetValue}/>}
|
||||
</span>
|
||||
</CheckboxGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export default CheckboxInput;
|
||||
@@ -1,12 +1,15 @@
|
||||
import {useField} from "formik";
|
||||
import {DatePicker, DateValue} from "@heroui/react";
|
||||
import {DatePicker, DatePickerProps, DateValue} from "@heroui/react";
|
||||
import {parseDate} from "@internationalized/date";
|
||||
import {useState} from "react";
|
||||
|
||||
// @ts-ignore
|
||||
export default function DatePickerInput({label, showErrorUntouched = false, ...props}) {
|
||||
// @ts-ignore
|
||||
const [field, meta] = useField(props);
|
||||
interface DatePickerInputProps extends Omit<DatePickerProps, "name"> {
|
||||
name: string;
|
||||
showErrorUntouched?: boolean;
|
||||
}
|
||||
|
||||
export default function DatePickerInput({label, showErrorUntouched = false, ...props}: DatePickerInputProps) {
|
||||
const [field, meta] = useField(props.name);
|
||||
const [value, setValue] = useState<DateValue | null>(field.value ? parseDate(field.value) : null);
|
||||
|
||||
return (
|
||||
@@ -26,7 +29,7 @@ export default function DatePickerInput({label, showErrorUntouched = false, ...p
|
||||
}
|
||||
});
|
||||
}}
|
||||
id={label}
|
||||
id={label as string}
|
||||
label={label}
|
||||
isInvalid={(meta.touched || showErrorUntouched) && !!meta.error}
|
||||
errorMessage={meta.initialError || meta.error}
|
||||
|
||||
@@ -3,13 +3,17 @@ import React from "react";
|
||||
import {useField} from "formik";
|
||||
import {GameCoverPickerModal} from "Frontend/components/general/modals/GameCoverPickerModal";
|
||||
import {ImageBrokenIcon, PencilIcon} from "@phosphor-icons/react";
|
||||
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||
|
||||
interface GameCoverPickerProps {
|
||||
game: GameDto;
|
||||
name: string;
|
||||
showErrorUntouched?: boolean;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
export default function GameCoverPicker({game, showErrorUntouched = false, ...props}) {
|
||||
export default function GameCoverPicker({game, showErrorUntouched = false, ...props}: GameCoverPickerProps) {
|
||||
|
||||
// @ts-ignore
|
||||
const [field] = useField(props);
|
||||
const [field] = useField(props.name);
|
||||
|
||||
const gameCoverPickerModal = useDisclosure();
|
||||
|
||||
|
||||
@@ -3,13 +3,17 @@ import React from "react";
|
||||
import {useField} from "formik";
|
||||
import {ImageBrokenIcon, PencilIcon} from "@phosphor-icons/react";
|
||||
import {GameHeaderPickerModal} from "Frontend/components/general/modals/GameHeaderPickerModal";
|
||||
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||
|
||||
interface GameHeaderPickerProps {
|
||||
game: GameDto;
|
||||
name: string;
|
||||
showErrorUntouched?: boolean;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
export default function GameHeaderPicker({game, showErrorUntouched = false, ...props}) {
|
||||
export default function GameHeaderPicker({game, showErrorUntouched = false, ...props}: GameHeaderPickerProps) {
|
||||
|
||||
// @ts-ignore
|
||||
const [field] = useField(props);
|
||||
const [field] = useField(props.name);
|
||||
|
||||
const gameHeaderPickerModal = useDisclosure();
|
||||
|
||||
|
||||
@@ -1,23 +1,43 @@
|
||||
import {useField} from "formik";
|
||||
import {Input as HeroUiInput} from "@heroui/react";
|
||||
import {Input as HeroUiInput, InputProps} from "@heroui/react";
|
||||
import InfoPopup from "Frontend/components/administration/InfoPopup";
|
||||
import ResetToDefaultButton from "Frontend/components/administration/ResetToDefaultButton";
|
||||
|
||||
// @ts-ignore
|
||||
const Input = ({label, showErrorUntouched = false, ...props}) => {
|
||||
// @ts-ignore
|
||||
const [field, meta] = useField(props);
|
||||
interface CustomInputProps extends Omit<InputProps, "name"> {
|
||||
name: string;
|
||||
showErrorUntouched?: boolean;
|
||||
resetValue?: unknown;
|
||||
}
|
||||
|
||||
export default function Input({
|
||||
label,
|
||||
showErrorUntouched = false,
|
||||
description,
|
||||
className,
|
||||
resetValue,
|
||||
...props
|
||||
}: CustomInputProps) {
|
||||
const [field, meta] = useField(props.name);
|
||||
|
||||
return (
|
||||
<HeroUiInput
|
||||
className="min-h-20 grow"
|
||||
fullWidth={false}
|
||||
{...props}
|
||||
{...field}
|
||||
id={label}
|
||||
className={`min-h-20 grow ${className ?? ""}`}
|
||||
id={label as string}
|
||||
label={label}
|
||||
endContent={
|
||||
(description || resetValue !== undefined) ? (
|
||||
<span className="flex items-center gap-1">
|
||||
{description && <InfoPopup content={description as string}/>}
|
||||
{resetValue !== undefined &&
|
||||
<ResetToDefaultButton fieldName={field.name} defaultValue={resetValue}/>}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
isInvalid={(meta.touched || showErrorUntouched) && !!meta.error}
|
||||
errorMessage={meta.initialError || meta.error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default Input;
|
||||
}
|
||||
@@ -1,26 +1,46 @@
|
||||
import {useField} from "formik";
|
||||
import {NumberInput as HeroUiNumberInput} from "@heroui/react";
|
||||
import {NumberInput as HeroUiNumberInput, NumberInputProps} from "@heroui/react";
|
||||
import InfoPopup from "Frontend/components/administration/InfoPopup";
|
||||
import ResetToDefaultButton from "Frontend/components/administration/ResetToDefaultButton";
|
||||
|
||||
// @ts-ignore
|
||||
const NumberInput = ({label, showErrorUntouched = false, ...props}) => {
|
||||
// @ts-ignore
|
||||
const [field, meta, helpers] = useField(props);
|
||||
interface CustomNumberInputProps extends Omit<NumberInputProps, "name"> {
|
||||
name: string;
|
||||
showErrorUntouched?: boolean;
|
||||
resetValue?: unknown;
|
||||
}
|
||||
|
||||
export default function NumberInput({
|
||||
label,
|
||||
showErrorUntouched = false,
|
||||
description,
|
||||
className,
|
||||
resetValue,
|
||||
...props
|
||||
}: CustomNumberInputProps) {
|
||||
const [field, meta, helpers] = useField<number>(props.name);
|
||||
|
||||
return (
|
||||
<HeroUiNumberInput
|
||||
className="min-h-20 grow"
|
||||
fullWidth={false}
|
||||
{...props}
|
||||
className={`min-h-20 grow ${className ?? ""}`}
|
||||
value={field.value}
|
||||
onValueChange={(value) => helpers.setValue(value)}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
id={label}
|
||||
id={label as string}
|
||||
label={label}
|
||||
endContent={
|
||||
(description || resetValue !== undefined) ? (
|
||||
<span className="flex items-center gap-1">
|
||||
{description && <InfoPopup content={description as string}/>}
|
||||
{resetValue !== undefined &&
|
||||
<ResetToDefaultButton fieldName={field.name} defaultValue={resetValue}/>}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
isInvalid={(meta.touched || showErrorUntouched) && !!meta.error}
|
||||
errorMessage={meta.initialError || meta.error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default NumberInput;
|
||||
@@ -1,10 +1,18 @@
|
||||
import {useField} from "formik";
|
||||
import {Select, SelectItem} from "@heroui/react";
|
||||
import {Select, SelectItem, SelectProps} from "@heroui/react";
|
||||
import InfoPopup from "Frontend/components/administration/InfoPopup";
|
||||
import ResetToDefaultButton from "Frontend/components/administration/ResetToDefaultButton";
|
||||
|
||||
// @ts-ignore
|
||||
const SelectInput = ({label, values, ...props}) => {
|
||||
// @ts-ignore
|
||||
const [field, meta] = useField(props);
|
||||
interface SelectInputProps extends Omit<SelectProps, "name" | "children"> {
|
||||
label: string;
|
||||
name: string;
|
||||
values: string[];
|
||||
description?: string;
|
||||
resetValue?: unknown;
|
||||
}
|
||||
|
||||
export default function SelectInput({label, values, description, resetValue, ...props}: SelectInputProps) {
|
||||
const [field, meta] = useField(props.name);
|
||||
|
||||
const items = values.map((v: string) => ({key: v, label: v}));
|
||||
|
||||
@@ -17,6 +25,15 @@ const SelectInput = ({label, values, ...props}) => {
|
||||
label={label}
|
||||
items={items}
|
||||
selectedKeys={[field.value]}
|
||||
endContent={
|
||||
(description || resetValue !== undefined) ? (
|
||||
<span className="flex items-center">
|
||||
{description && <InfoPopup content={description}/>}
|
||||
{resetValue !== undefined &&
|
||||
<ResetToDefaultButton fieldName={field.name} defaultValue={resetValue}/>}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
isInvalid={!!meta.error}
|
||||
errorMessage={meta.initialError || meta.error}
|
||||
disallowEmptySelection
|
||||
@@ -26,5 +43,3 @@ const SelectInput = ({label, values, ...props}) => {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectInput;
|
||||
@@ -1,23 +1,41 @@
|
||||
import {useField} from "formik";
|
||||
import {Slider as HeroUiSlider} from "@heroui/react";
|
||||
import {Slider as HeroUiSlider, SliderProps} from "@heroui/react";
|
||||
import InfoPopup from "Frontend/components/administration/InfoPopup";
|
||||
import ResetToDefaultButton from "Frontend/components/administration/ResetToDefaultButton";
|
||||
|
||||
// @ts-ignore
|
||||
const SliderInput = ({label, showErrorUntouched = false, ...props}) => {
|
||||
// @ts-ignore
|
||||
const [field, meta, helpers] = useField(props);
|
||||
interface SliderInputProps extends Omit<SliderProps, "name"> {
|
||||
name: string;
|
||||
description?: string;
|
||||
showErrorUntouched?: boolean;
|
||||
resetValue?: unknown;
|
||||
}
|
||||
|
||||
export default function SliderInput({
|
||||
label,
|
||||
showErrorUntouched = false,
|
||||
description,
|
||||
resetValue,
|
||||
...props
|
||||
}: SliderInputProps) {
|
||||
const [field, , helpers] = useField<number>(props.name);
|
||||
|
||||
return (
|
||||
<HeroUiSlider
|
||||
className="min-h-20 grow"
|
||||
{...props}
|
||||
value={field.value}
|
||||
onChange={(value) => helpers.setValue(value)}
|
||||
onChange={(value) => helpers.setValue(value as number)}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
id={label}
|
||||
label={label}
|
||||
id={label as string}
|
||||
label={
|
||||
<span className="flex items-center gap-1">
|
||||
{label}
|
||||
{description && <InfoPopup content={description}/>}
|
||||
{resetValue !== undefined &&
|
||||
<ResetToDefaultButton fieldName={field.name} defaultValue={resetValue}/>}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SliderInput;
|
||||
@@ -1,10 +1,13 @@
|
||||
import {useField} from "formik";
|
||||
import {Textarea} from "@heroui/react";
|
||||
import {Textarea, TextAreaProps} from "@heroui/react";
|
||||
|
||||
// @ts-ignore
|
||||
export default function TextAreaInput({label, showErrorUntouched = false, ...props}) {
|
||||
// @ts-ignore
|
||||
const [field, meta] = useField(props);
|
||||
interface TextAreaInputProps extends Omit<TextAreaProps, "name"> {
|
||||
name: string;
|
||||
showErrorUntouched?: boolean;
|
||||
}
|
||||
|
||||
export default function TextAreaInput({label, showErrorUntouched = false, ...props}: TextAreaInputProps) {
|
||||
const [field, meta] = useField(props.name);
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
@@ -12,7 +15,7 @@ export default function TextAreaInput({label, showErrorUntouched = false, ...pro
|
||||
fullWidth={false}
|
||||
{...props}
|
||||
{...field}
|
||||
id={label}
|
||||
id={label as string}
|
||||
label={label}
|
||||
isInvalid={(meta.touched || showErrorUntouched) && !!meta.error}
|
||||
errorMessage={meta.initialError || meta.error}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import React, {useState} from "react";
|
||||
import {addToast, Button, Checkbox, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
|
||||
import {
|
||||
addToast,
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Link,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader
|
||||
} from "@heroui/react";
|
||||
import {Form, Formik} from "formik";
|
||||
import {LibraryEndpoint} from "Frontend/generated/endpoints";
|
||||
import Input from "Frontend/components/general/input/Input";
|
||||
@@ -9,6 +20,7 @@ import ArrayInputAutocomplete from "Frontend/components/general/input/ArrayInput
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {platformState} from "Frontend/state/PlatformState";
|
||||
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
|
||||
import {pluginState} from "Frontend/state/PluginState";
|
||||
|
||||
interface LibraryCreationModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -22,9 +34,10 @@ export default function LibraryCreationModal({
|
||||
|
||||
const [scanAfterCreation, setScanAfterCreation] = useState<boolean>(true);
|
||||
const availablePlatforms = useSnapshot(platformState).available;
|
||||
const hasActiveMetadataPlugins = useSnapshot(pluginState).hasActiveMetadataPlugins;
|
||||
|
||||
async function createLibrary(library: LibraryAdminDto) {
|
||||
await LibraryEndpoint.createLibrary(library, scanAfterCreation);
|
||||
await LibraryEndpoint.createLibrary(library, hasActiveMetadataPlugins && scanAfterCreation);
|
||||
|
||||
addToast({
|
||||
title: "New library created",
|
||||
@@ -77,10 +90,21 @@ export default function LibraryCreationModal({
|
||||
/>
|
||||
<DirectoryMappingInput name="directories"/>
|
||||
</div>
|
||||
{!hasActiveMetadataPlugins &&
|
||||
<Alert color="warning">
|
||||
<p>No metadata plugins are currently enabled.</p>
|
||||
<p>Go to <Link underline="always" color="foreground"
|
||||
href="/administration/plugins">Plugins</Link> and enable
|
||||
at least one metadata plugin in order to scan your library.</p>
|
||||
</Alert>
|
||||
}
|
||||
</ModalBody>
|
||||
<ModalFooter className="flex flex-row justify-between">
|
||||
<Checkbox isSelected={scanAfterCreation} onValueChange={setScanAfterCreation}>Scan
|
||||
after creation?</Checkbox>
|
||||
<Checkbox
|
||||
isSelected={hasActiveMetadataPlugins && scanAfterCreation}
|
||||
isDisabled={!hasActiveMetadataPlugins}
|
||||
onValueChange={setScanAfterCreation}
|
||||
>Scan after creation?</Checkbox>
|
||||
<div className="flex flex-row">
|
||||
<Button variant="light" onPress={onClose}>
|
||||
Cancel
|
||||
|
||||
@@ -6,7 +6,7 @@ import Markdown from "react-markdown";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
import {PluginEndpoint} from "Frontend/generated/endpoints";
|
||||
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
||||
import { ArrowClockwiseIcon } from "@phosphor-icons/react";
|
||||
import {ArrowClockwiseIcon} from "@phosphor-icons/react";
|
||||
import PluginConfigMetadataDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginConfigMetadataDto";
|
||||
import PluginConfigFormField from "Frontend/components/general/plugin/PluginConfigFormField";
|
||||
|
||||
|
||||
@@ -3,6 +3,12 @@ import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/Plug
|
||||
import PluginUpdateDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginUpdateDto";
|
||||
import {proxy} from "valtio/index";
|
||||
import {PluginEndpoint} from "Frontend/generated/endpoints";
|
||||
import Pf4jPluginState from "Frontend/generated/org/pf4j/PluginState";
|
||||
|
||||
export enum PluginType {
|
||||
GameMetadataProvider = "GameMetadataProvider",
|
||||
DownloadProvider = "DownloadProvider",
|
||||
}
|
||||
|
||||
type PluginState = {
|
||||
subscription?: Subscription<PluginUpdateDto[]>;
|
||||
@@ -10,6 +16,7 @@ type PluginState = {
|
||||
state: Record<string, PluginDto>;
|
||||
plugins: PluginDto[];
|
||||
sortedByType: Record<string, PluginDto[]>;
|
||||
hasActiveMetadataPlugins: boolean;
|
||||
};
|
||||
|
||||
export const pluginState = proxy<PluginState>({
|
||||
@@ -22,6 +29,11 @@ export const pluginState = proxy<PluginState>({
|
||||
},
|
||||
get sortedByType() {
|
||||
return sortPluginsByType(this.state);
|
||||
},
|
||||
get hasActiveMetadataPlugins() {
|
||||
return this.sortedByType[PluginType.GameMetadataProvider]
|
||||
?.filter((p: PluginDto) => p.state == Pf4jPluginState.STARTED)
|
||||
.length > 0;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -53,7 +65,10 @@ export async function initializePluginState() {
|
||||
/** Computed **/
|
||||
|
||||
function sortPluginsByType(pluginsMap: Record<string, PluginDto>): Record<string, PluginDto[]> {
|
||||
const pluginsByType: Record<string, PluginDto[]> = {};
|
||||
// Initialize with empty arrays for all known plugin types so consumers never get undefined
|
||||
const pluginsByType: Record<string, PluginDto[]> = Object.fromEntries(
|
||||
Object.values(PluginType).map(type => [type, []])
|
||||
);
|
||||
|
||||
// Convert map to array of plugins
|
||||
const plugins = Object.values(pluginsMap);
|
||||
|
||||
@@ -59,5 +59,4 @@ const menuItems: MenuItem[] = [
|
||||
}
|
||||
]
|
||||
|
||||
export const AdministrationView = withSideMenu("/administration", menuItems);
|
||||
export default AdministrationView;
|
||||
export const AdministrationView = withSideMenu("/administration", menuItems);
|
||||
@@ -2,42 +2,41 @@ import {CoverRow} from "Frontend/components/general/covers/CoverRow";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {libraryState} from "Frontend/state/LibraryState";
|
||||
import {gameState} from "Frontend/state/GameState";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import React, {useMemo} from "react";
|
||||
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
|
||||
import {collectionState} from "Frontend/state/CollectionState";
|
||||
import CollectionDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionDto";
|
||||
import {StartPageDisplayCard} from "Frontend/components/general/cards/StartPageDisplayCard";
|
||||
import {Link} from "@heroui/react";
|
||||
import {CaretRightIcon} from "@phosphor-icons/react";
|
||||
import {Link, Spinner} from "@heroui/react";
|
||||
import {CaretRightIcon, FolderOpenIcon} from "@phosphor-icons/react";
|
||||
import {useAuth} from "Frontend/util/auth";
|
||||
import {isAdmin} from "Frontend/util/utils";
|
||||
|
||||
export default function HomeView() {
|
||||
const auth = useAuth();
|
||||
const librariesState = useSnapshot(libraryState);
|
||||
const collectionsState = useSnapshot(collectionState);
|
||||
const gamesState = useSnapshot(gameState);
|
||||
const gamesByLibrary = gamesState.gamesByLibraryId;
|
||||
const gamesByCollection = gamesState.gamesByCollectionId;
|
||||
|
||||
const [filteredAndSortedLibraries, setFilteredAndSortedLibraries] = useState<LibraryDto[]>([]);
|
||||
const [filteredAndSortedCollections, setFilteredAndSortedCollections] = useState<CollectionDto[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const libraries = librariesState.sorted
|
||||
const filteredAndSortedLibraries = useMemo(() =>
|
||||
librariesState.sorted
|
||||
.filter(library => library.metadata!.displayOnHomepage)
|
||||
.filter(library =>
|
||||
gamesByLibrary[library.id] && gamesByLibrary[library.id].length > 0
|
||||
);
|
||||
),
|
||||
[librariesState.sorted, gamesByLibrary]
|
||||
);
|
||||
|
||||
setFilteredAndSortedLibraries(libraries);
|
||||
|
||||
const collections = collectionsState.sorted
|
||||
const filteredAndSortedCollections = useMemo(() =>
|
||||
collectionsState.sorted
|
||||
.filter(collection => collection.metadata!.displayOnHomepage)
|
||||
.filter(collection =>
|
||||
gamesByCollection[collection.id] && gamesByCollection[collection.id].length > 0
|
||||
);
|
||||
|
||||
setFilteredAndSortedCollections(collections);
|
||||
|
||||
}, [librariesState.sorted, collectionsState.sorted, gamesByLibrary, gamesByCollection]);
|
||||
),
|
||||
[collectionsState.sorted, gamesByCollection]
|
||||
);
|
||||
|
||||
// Sort games by date added (newest first) for libraries
|
||||
const getSortedLibraryGames = (libraryId: number) => {
|
||||
@@ -65,6 +64,43 @@ export default function HomeView() {
|
||||
});
|
||||
};
|
||||
|
||||
const hasNoContent = filteredAndSortedLibraries.length === 0 && filteredAndSortedCollections.length === 0;
|
||||
const allStatesLoaded = librariesState.isLoaded && collectionsState.isLoaded && gamesState.isLoaded;
|
||||
|
||||
if (!allStatesLoaded) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-[70vh] text-center gap-4">
|
||||
<Spinner size="lg"/>
|
||||
<p className="text-xl font-semibold text-default-600">Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasNoContent) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-[70vh] text-center gap-4">
|
||||
<FolderOpenIcon size={64} className="text-default-300"/>
|
||||
<p className="text-xl font-semibold text-default-600">Nothing here yet</p>
|
||||
{isAdmin(auth) ? (
|
||||
<>
|
||||
<p className="text-default-400 max-w-lg">
|
||||
Get started by adding libraries and games in the{" "}
|
||||
<Link href="/administration/games" underline="always">
|
||||
administration panel
|
||||
</Link>.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-default-400 max-w-md">
|
||||
There is currently no content available. Check back later!
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
@@ -50,13 +50,13 @@ class CollectionEndpoint(
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun deleteCollection(collectionId: Long) = collectionService.delete(collectionId)
|
||||
|
||||
/* Unused endpoints for Hilla to generate typescript classes */
|
||||
/* Unused endpoints for Hilla to generate TypeScript classes */
|
||||
|
||||
@Suppress("Unused", "FunctionName")
|
||||
@Suppress("Unused", "FunctionName", "kotlin:S100")
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun _getAdminDto(id: Long): CollectionAdminDto = collectionService.getById(id).toAdminDto()
|
||||
|
||||
@Suppress("Unused", "FunctionName")
|
||||
@Suppress("Unused", "FunctionName", "kotlin:S100")
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun _getUserDto(id: Long): CollectionUserDto = collectionService.getById(id).toUserDto()
|
||||
}
|
||||
|
||||
@@ -70,8 +70,8 @@ class CollectionService(
|
||||
|
||||
@Transactional
|
||||
fun create(dto: CollectionCreateDto) {
|
||||
if (collectionRepository.findByName(dto.name) != null) {
|
||||
throw IllegalArgumentException("Collection with name '${dto.name}' already exists")
|
||||
require(collectionRepository.findByName(dto.name) == null) {
|
||||
"Collection with name '${dto.name}' already exists"
|
||||
}
|
||||
val entity = dto.toEntity()
|
||||
dto.gameIds?.let { ids ->
|
||||
@@ -87,8 +87,8 @@ class CollectionService(
|
||||
fun update(dto: CollectionUpdateDto): CollectionDto {
|
||||
val collection = getById(dto.id)
|
||||
dto.name?.let { newName ->
|
||||
if (newName != collection.name && collectionRepository.findByName(newName) != null) {
|
||||
throw IllegalArgumentException("Collection with name '$newName' already exists")
|
||||
require(collectionRepository.findByName(dto.name) == null) {
|
||||
"Collection with name '${dto.name}' already exists"
|
||||
}
|
||||
collection.name = newName
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import kotlin.reflect.KClass
|
||||
sealed class ConfigProperties<T : Serializable>(
|
||||
val type: KClass<T>,
|
||||
val key: String,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val default: T? = null,
|
||||
val allowedValues: List<T>? = null,
|
||||
@@ -21,6 +22,7 @@ sealed class ConfigProperties<T : Serializable>(
|
||||
Boolean::class,
|
||||
"security.allow-public-access",
|
||||
"Allow access to Gameyfin without login",
|
||||
"When enabled, anyone can browse (and potentially download) games **without logging in**.",
|
||||
false
|
||||
)
|
||||
}
|
||||
@@ -32,6 +34,7 @@ sealed class ConfigProperties<T : Serializable>(
|
||||
Boolean::class,
|
||||
"library.scan.enable-filesystem-watcher",
|
||||
"Enable automatic library scanning using file system watchers",
|
||||
"Watches your library folders for file changes and **updates this specific folder** when files are added, removed, or renamed.",
|
||||
false
|
||||
)
|
||||
|
||||
@@ -39,6 +42,7 @@ sealed class ConfigProperties<T : Serializable>(
|
||||
Boolean::class,
|
||||
"library.scan.scan-empty-directories",
|
||||
"Scan empty directories",
|
||||
"When enabled, empty folders inside a library path are also reported during a scan.",
|
||||
false
|
||||
)
|
||||
|
||||
@@ -46,6 +50,7 @@ sealed class ConfigProperties<T : Serializable>(
|
||||
Boolean::class,
|
||||
"library.scan.extract-title-using-regex",
|
||||
"Extract title from file names using regex",
|
||||
"Uses the regex defined in **Title Extraction Regex** to strip unwanted parts (e.g. release tags) from file names before matching.",
|
||||
false
|
||||
)
|
||||
|
||||
@@ -53,6 +58,7 @@ sealed class ConfigProperties<T : Serializable>(
|
||||
String::class,
|
||||
"library.scan.title-extraction-regex",
|
||||
"Regex to extract title from file names",
|
||||
"Java-compatible regular expression used to extract the game title from a file name. The first captured group (or full match) is used as the title.",
|
||||
"^[^\\[]+"
|
||||
)
|
||||
|
||||
@@ -60,6 +66,8 @@ sealed class ConfigProperties<T : Serializable>(
|
||||
Int::class,
|
||||
"library.scan.title-match-min-ratio",
|
||||
"Minimum ratio for title matching. Higher values mean stricter matching.",
|
||||
"""Used to match titles **across different metadata sources (plugins)**.
|
||||
|Raise this value to reduce false positives; lower it to match more liberally.""".trimMargin(),
|
||||
default = 90,
|
||||
min = 0,
|
||||
max = 100,
|
||||
@@ -70,6 +78,7 @@ sealed class ConfigProperties<T : Serializable>(
|
||||
Array<String>::class,
|
||||
"library.scan.game-file-extensions",
|
||||
"File extensions to consider as games",
|
||||
"Only files whose extension appears in this list are treated as games during a library scan. Add custom extensions to support additional formats.",
|
||||
arrayOf(
|
||||
"zip",
|
||||
"tar",
|
||||
@@ -93,6 +102,19 @@ sealed class ConfigProperties<T : Serializable>(
|
||||
"elf"
|
||||
)
|
||||
)
|
||||
|
||||
data object MaxConcurrency : ConfigProperties<Int>(
|
||||
Int::class,
|
||||
"library.scan.max-concurrency",
|
||||
"Scan concurrency",
|
||||
"""Controls how many games are processed simultaneously during a library scan (metadata fetching, image downloading, etc.).
|
||||
|Lower values reduce peak memory usage; higher values speed up large scans.
|
||||
|Does **not** affect already running scans.""".trimMargin(),
|
||||
default = 4,
|
||||
min = 1,
|
||||
max = 16,
|
||||
step = 1
|
||||
)
|
||||
}
|
||||
|
||||
sealed class Metadata {
|
||||
@@ -100,6 +122,7 @@ sealed class ConfigProperties<T : Serializable>(
|
||||
Boolean::class,
|
||||
"library.metadata.update.enabled",
|
||||
"Enable periodic refresh of video game metadata",
|
||||
"When enabled, Gameyfin periodically re-fetches metadata (cover art, descriptions, genres, …) according to the configured schedule.",
|
||||
true
|
||||
)
|
||||
|
||||
@@ -107,6 +130,7 @@ sealed class ConfigProperties<T : Serializable>(
|
||||
String::class,
|
||||
"library.metadata.update.schedule",
|
||||
"Schedule for periodic metadata refresh in Spring cron format",
|
||||
"Controls **when** the automatic metadata refresh runs. Accepts [Spring cron expressions](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/scheduling/support/CronExpression.html) or shortcuts such as `@daily`.",
|
||||
"@daily"
|
||||
)
|
||||
}
|
||||
@@ -119,6 +143,7 @@ sealed class ConfigProperties<T : Serializable>(
|
||||
Boolean::class,
|
||||
"requests.games.enabled",
|
||||
"Enable submission of game requests",
|
||||
"Allows users to submit requests for games they would like to see added to the library.",
|
||||
true
|
||||
)
|
||||
|
||||
@@ -126,6 +151,7 @@ sealed class ConfigProperties<T : Serializable>(
|
||||
Boolean::class,
|
||||
"requests.games.allow-guests-to-request-games",
|
||||
"Allow guests (not logged in) to create game requests",
|
||||
"When enabled, visitors who are **not logged in** can also submit game requests.",
|
||||
false
|
||||
)
|
||||
|
||||
@@ -133,6 +159,7 @@ sealed class ConfigProperties<T : Serializable>(
|
||||
Int::class,
|
||||
"requests.games.max-open-requests-per-user",
|
||||
"Maximum number of pending requests per user. Set to 0 for unlimited.",
|
||||
"Caps the number of **open (unresolved) requests** a single user can have at any time. Set to `0` to remove the limit.",
|
||||
10
|
||||
)
|
||||
}
|
||||
@@ -144,6 +171,7 @@ sealed class ConfigProperties<T : Serializable>(
|
||||
Boolean::class,
|
||||
"downloads.bandwidth-limit.enabled",
|
||||
"Enable per-user bandwidth limiting for downloads",
|
||||
"When enabled, each user's download speed is capped at the value specified in **Bandwidth Limit (Mbps)**.",
|
||||
false
|
||||
)
|
||||
|
||||
@@ -151,6 +179,7 @@ sealed class ConfigProperties<T : Serializable>(
|
||||
Int::class,
|
||||
"downloads.bandwidth-limit.mbps",
|
||||
"Maximum download speed in Megabits per second (Mbps)",
|
||||
"The maximum allowed download speed **per user** in Megabits per second. Only takes effect when bandwidth limiting is enabled.",
|
||||
100
|
||||
)
|
||||
}
|
||||
@@ -162,6 +191,7 @@ sealed class ConfigProperties<T : Serializable>(
|
||||
Boolean::class,
|
||||
"users.sign-ups.allow",
|
||||
"Allow new users to sign up by themselves",
|
||||
"When enabled, a **Register** button is shown on the login page so anyone can create an account.",
|
||||
false
|
||||
)
|
||||
|
||||
@@ -169,6 +199,7 @@ sealed class ConfigProperties<T : Serializable>(
|
||||
Boolean::class,
|
||||
"users.sign-ups.confirmation-required",
|
||||
"Admins need to confirm new users",
|
||||
"When enabled, newly registered accounts are **inactive** until an administrator explicitly approves them.",
|
||||
true
|
||||
)
|
||||
}
|
||||
@@ -181,6 +212,7 @@ sealed class ConfigProperties<T : Serializable>(
|
||||
Boolean::class,
|
||||
"sso.oidc.enabled",
|
||||
"Enable SSO via OIDC/OAuth2",
|
||||
"Activates the **OpenID Connect / OAuth 2.0** single sign-on integration. All other OIDC settings below are required when this is turned on.",
|
||||
false
|
||||
)
|
||||
|
||||
@@ -188,14 +220,26 @@ sealed class ConfigProperties<T : Serializable>(
|
||||
MatchUsersBy::class,
|
||||
"sso.oidc.match-existing-users-by",
|
||||
"Match existing users by",
|
||||
"Determines which field (`username` or `email`) is used to link an incoming SSO identity to an **existing Gameyfin account**.",
|
||||
MatchUsersBy.username,
|
||||
MatchUsersBy.entries
|
||||
)
|
||||
|
||||
data object UsernameClaim : ConfigProperties<String>(
|
||||
String::class,
|
||||
"sso.oidc.username-claim",
|
||||
"Username claim",
|
||||
"""Name of the OIDC / userinfo claim used as the Gameyfin username (e.g. `preferred_username`, `name`, `email`).
|
||||
|If the claim is absent or blank, Gameyfin falls back through `preferred_username` → `nickname` → `name` → `email` → `sub` automatically.""".trimMargin(),
|
||||
"preferred_username"
|
||||
)
|
||||
|
||||
data object RolesClaim : ConfigProperties<String>(
|
||||
String::class,
|
||||
"sso.oidc.roles-claim",
|
||||
"JWT claim to extract roles from",
|
||||
"Role claim",
|
||||
"""Name of the OIDC / userinfo claim that contains the user's roles.
|
||||
|Gameyfin maps these roles to its own permission system.""".trimMargin(),
|
||||
"roles"
|
||||
)
|
||||
|
||||
@@ -203,55 +247,64 @@ sealed class ConfigProperties<T : Serializable>(
|
||||
Array<String>::class,
|
||||
"sso.oidc.oauth-scopes",
|
||||
"OAuth2 scopes to request",
|
||||
"List of [OAuth 2.0 scopes](https://oauth.net/2/scope/) sent in the authorization request. Must include at least `openid`.",
|
||||
arrayOf("openid", "profile", "email", "roles")
|
||||
)
|
||||
|
||||
data object ClientId : ConfigProperties<String>(
|
||||
String::class,
|
||||
"sso.oidc.client-id",
|
||||
"Client ID"
|
||||
"Client ID",
|
||||
"The **client identifier** issued by your identity provider when you registered Gameyfin as an OAuth 2.0 application."
|
||||
)
|
||||
|
||||
data object ClientSecret : ConfigProperties<String>(
|
||||
String::class,
|
||||
"sso.oidc.client-secret",
|
||||
"Client secret"
|
||||
"Client secret",
|
||||
"The **client secret** issued by your identity provider. Keep this value confidential."
|
||||
)
|
||||
|
||||
data object IssuerUrl : ConfigProperties<String>(
|
||||
String::class,
|
||||
"sso.oidc.issuer-url",
|
||||
"Issuer URL"
|
||||
"Issuer URL",
|
||||
"The base URL of your identity provider (e.g. `https://auth.example.com/realms/myrealm`). Used for OIDC discovery."
|
||||
)
|
||||
|
||||
data object AuthorizeUrl : ConfigProperties<String>(
|
||||
String::class,
|
||||
"sso.oidc.authorize-url",
|
||||
"Authorize URL"
|
||||
"Authorize URL",
|
||||
"The **authorization endpoint** of your identity provider. Required when OIDC auto-discovery is not available."
|
||||
)
|
||||
|
||||
data object TokenUrl : ConfigProperties<String>(
|
||||
String::class,
|
||||
"sso.oidc.token-url",
|
||||
"Token URL"
|
||||
"Token URL",
|
||||
"The **token endpoint** used to exchange an authorization code for access and ID tokens."
|
||||
)
|
||||
|
||||
data object UserInfoUrl : ConfigProperties<String>(
|
||||
String::class,
|
||||
"sso.oidc.userinfo-url",
|
||||
"Userinfo URL"
|
||||
"Userinfo URL",
|
||||
"The **userinfo endpoint** from which Gameyfin retrieves profile claims (name, email, roles, …) after a successful login."
|
||||
)
|
||||
|
||||
data object JwksUrl : ConfigProperties<String>(
|
||||
String::class,
|
||||
"sso.oidc.jwks-url",
|
||||
"JWKS URL"
|
||||
"JWKS URL",
|
||||
"The **JSON Web Key Set endpoint** used to verify the signature of JWTs issued by your identity provider."
|
||||
)
|
||||
|
||||
data object LogoutUrl : ConfigProperties<String>(
|
||||
String::class,
|
||||
"sso.oidc.logout-url",
|
||||
"Logout URL"
|
||||
"Logout URL",
|
||||
"The **end-session endpoint** to which Gameyfin redirects users after they log out, ensuring they are also signed out from the identity provider."
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -264,32 +317,37 @@ sealed class ConfigProperties<T : Serializable>(
|
||||
Boolean::class,
|
||||
"messages.providers.email.enabled",
|
||||
"Enable E-Mail notifications",
|
||||
"When enabled, Gameyfin can send **e-mail notifications** (e.g. sign-up confirmations, request updates) via the configured SMTP server.",
|
||||
false
|
||||
)
|
||||
|
||||
data object Host : ConfigProperties<String>(
|
||||
String::class,
|
||||
"messages.providers.email.host",
|
||||
"URL of the email server"
|
||||
"URL of the email server",
|
||||
"Hostname or IP address of the **SMTP server** used to dispatch outgoing e-mails (e.g. `smtp.gmail.com`)."
|
||||
)
|
||||
|
||||
data object Port : ConfigProperties<Int>(
|
||||
Int::class,
|
||||
"messages.providers.email.port",
|
||||
"Port of the email server",
|
||||
"TCP port of the SMTP server. Common values: `587` (STARTTLS), `465` (SSL/TLS), `25` (unencrypted).",
|
||||
587
|
||||
)
|
||||
|
||||
data object Username : ConfigProperties<String>(
|
||||
String::class,
|
||||
"messages.providers.email.username",
|
||||
"Username for the email account"
|
||||
"Username for the email account",
|
||||
"The username (usually the full e-mail address) used to **authenticate** with the SMTP server."
|
||||
)
|
||||
|
||||
data object Password : ConfigProperties<String>(
|
||||
String::class,
|
||||
"messages.providers.email.password",
|
||||
"Password for the email account"
|
||||
"Password for the email account",
|
||||
"The password used to **authenticate** with the SMTP server. Keep this value confidential."
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -301,6 +359,7 @@ sealed class ConfigProperties<T : Serializable>(
|
||||
String::class,
|
||||
"logs.folder",
|
||||
"Storage folder for log files",
|
||||
"Path to the directory where Gameyfin writes its **log files**. Can be absolute or relative to the working directory.",
|
||||
"./logs"
|
||||
)
|
||||
|
||||
@@ -308,6 +367,7 @@ sealed class ConfigProperties<T : Serializable>(
|
||||
Int::class,
|
||||
"logs.max-history-days",
|
||||
"Log retention in days",
|
||||
"Number of days log files are kept before being **automatically deleted**. Set to `0` to disable automatic clean-up.",
|
||||
30
|
||||
)
|
||||
|
||||
@@ -316,6 +376,7 @@ sealed class ConfigProperties<T : Serializable>(
|
||||
LogLevel::class,
|
||||
"logs.level.gameyfin",
|
||||
"Log level (Gameyfin)",
|
||||
"Minimum severity level for Gameyfin's own log messages. Use `DEBUG` or `TRACE` for detailed troubleshooting output.",
|
||||
LogLevel.INFO,
|
||||
LogLevel.entries
|
||||
)
|
||||
@@ -324,6 +385,7 @@ sealed class ConfigProperties<T : Serializable>(
|
||||
LogLevel::class,
|
||||
"logs.level.root",
|
||||
"Log level (Root)",
|
||||
"Minimum severity level for **all other libraries and frameworks** (Spring, Hibernate, …). It is recommended to keep this at `WARN` or `ERROR` in production.",
|
||||
LogLevel.WARN,
|
||||
LogLevel.entries
|
||||
)
|
||||
|
||||
@@ -94,6 +94,7 @@ class ConfigService(
|
||||
value = get(configProperty),
|
||||
defaultValue = configProperty.default,
|
||||
type = configProperty.type.simpleName ?: "Unknown",
|
||||
name = configProperty.name,
|
||||
description = configProperty.description,
|
||||
elementType = configProperty.type.java.componentType?.simpleName,
|
||||
allowedValues = configProperty.allowedValues?.map { it.toString() },
|
||||
|
||||
@@ -6,6 +6,7 @@ import java.io.Serializable
|
||||
@JsonInclude(JsonInclude.Include.ALWAYS)
|
||||
data class ConfigEntryDto(
|
||||
val key: String,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val value: Serializable?,
|
||||
val defaultValue: Serializable?,
|
||||
|
||||
@@ -20,7 +20,8 @@ class Utils {
|
||||
val jvmNanoTimeDiff: Long = System.currentTimeMillis() * 1_000_000 - System.nanoTime()
|
||||
|
||||
fun maskEmail(email: String): String {
|
||||
val regex = """(?:\G(?!^)|(?<=^[^@]{2}|@))[^@](?!\.[^.]+$)""".toRegex()
|
||||
@Suppress("RegExpUnnecessaryNonCapturingGroup")
|
||||
val regex = """(?:\G(?!^)|(?<=(?:^[^@]{2})|(?:@)))[^@](?!\.[^.]+$)""".toRegex()
|
||||
return email.replace(regex, "*")
|
||||
}
|
||||
|
||||
@@ -101,10 +102,7 @@ fun String.replaceRomanNumerals(): String {
|
||||
return sum
|
||||
}
|
||||
|
||||
val regex = Regex(
|
||||
"""(?<=\s|^)(M{0,4}(CM|CD|D?C{0,3})?(XC|XL|L?X{0,3})?(IX|IV|V?I{0,3})?)(?=\b|\s|$)""",
|
||||
RegexOption.IGNORE_CASE
|
||||
)
|
||||
val regex = Regex("""(?<=\s|^)([MDCLXVI]+)(?=\s|$)""", RegexOption.IGNORE_CASE)
|
||||
|
||||
return regex.replace(this) { match ->
|
||||
val roman = match.value.uppercase()
|
||||
@@ -133,35 +131,29 @@ fun HttpServletRequest.getRemoteIp(lookupPolicy: LookupPolicy = LookupPolicy.ANY
|
||||
// Add the direct remote address
|
||||
this.remoteAddr?.let { candidateIps.add(it) }
|
||||
|
||||
when (lookupPolicy) {
|
||||
return when (lookupPolicy) {
|
||||
LookupPolicy.IPV4_ONLY -> {
|
||||
val ipv4Address = candidateIps.firstOrNull { isIpv4(it) }
|
||||
return ipv4Address ?: "unknown"
|
||||
candidateIps.firstOrNull { isIpv4(it) } ?: "unknown"
|
||||
}
|
||||
|
||||
LookupPolicy.IPV6_ONLY -> {
|
||||
val ipv6Address = candidateIps.firstOrNull { isIpv6(it) }
|
||||
return ipv6Address ?: "unknown"
|
||||
candidateIps.firstOrNull { isIpv6(it) } ?: "unknown"
|
||||
}
|
||||
|
||||
LookupPolicy.IPV4_PREFERRED -> {
|
||||
val ipv4Address = candidateIps.firstOrNull { isIpv4(it) }
|
||||
return ipv4Address ?: run {
|
||||
val ipv6Address = candidateIps.firstOrNull { isIpv6(it) }
|
||||
ipv6Address ?: "unknown"
|
||||
}
|
||||
candidateIps.firstOrNull { isIpv4(it) }
|
||||
?: candidateIps.firstOrNull { isIpv6(it) }
|
||||
?: "unknown"
|
||||
}
|
||||
|
||||
LookupPolicy.IPV6_PREFERRED -> {
|
||||
val ipv6Address = candidateIps.firstOrNull { isIpv6(it) }
|
||||
return ipv6Address ?: run {
|
||||
val ipv4Address = candidateIps.firstOrNull { isIpv4(it) }
|
||||
ipv4Address ?: "unknown"
|
||||
}
|
||||
candidateIps.firstOrNull { isIpv6(it) }
|
||||
?: candidateIps.firstOrNull { isIpv4(it) }
|
||||
?: "unknown"
|
||||
}
|
||||
|
||||
LookupPolicy.ANY -> {
|
||||
return candidateIps.firstOrNull() ?: "unknown"
|
||||
candidateIps.firstOrNull() ?: "unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package org.gameyfin.app.core.config
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache
|
||||
import com.github.benmanes.caffeine.cache.Caffeine
|
||||
import org.gameyfin.app.media.Image
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@Configuration
|
||||
class CacheConfig {
|
||||
|
||||
/**
|
||||
* Cache for Image entities keyed by ID.
|
||||
*/
|
||||
@Bean
|
||||
fun imageCache(): Cache<Long, Image> {
|
||||
return Caffeine.newBuilder()
|
||||
.maximumSize(10_000)
|
||||
.expireAfterWrite(15, TimeUnit.MINUTES)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
|
||||
@Configuration
|
||||
class JpaConfiguration {
|
||||
class JpaConfig {
|
||||
|
||||
@Bean
|
||||
fun hibernatePropertiesCustomizer(entityUpdateInterceptor: EntityUpdateInterceptor): HibernatePropertiesCustomizer {
|
||||
+6
-13
@@ -1,5 +1,6 @@
|
||||
package org.gameyfin.app.core.download.bandwidth
|
||||
|
||||
import java.io.FilterOutputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
/**
|
||||
@@ -13,12 +14,12 @@ import java.io.OutputStream
|
||||
* @param remoteIp The remote IP address of the client (optional)
|
||||
*/
|
||||
class SessionMonitoredOutputStream(
|
||||
private val outputStream: OutputStream,
|
||||
outputStream: OutputStream,
|
||||
private val sessionTracker: SessionBandwidthTracker,
|
||||
private val gameId: Long? = null,
|
||||
private val username: String? = null,
|
||||
private val remoteIp: String? = null
|
||||
) : OutputStream() {
|
||||
) : FilterOutputStream(outputStream) {
|
||||
|
||||
init {
|
||||
sessionTracker.downloadStarted(gameId, username, remoteIp)
|
||||
@@ -26,25 +27,17 @@ class SessionMonitoredOutputStream(
|
||||
|
||||
override fun write(b: Int) {
|
||||
sessionTracker.recordBytes(1)
|
||||
outputStream.write(b)
|
||||
}
|
||||
|
||||
override fun write(b: ByteArray) {
|
||||
write(b, 0, b.size)
|
||||
out.write(b)
|
||||
}
|
||||
|
||||
override fun write(b: ByteArray, off: Int, len: Int) {
|
||||
sessionTracker.recordBytes(len.toLong())
|
||||
outputStream.write(b, off, len)
|
||||
}
|
||||
|
||||
override fun flush() {
|
||||
outputStream.flush()
|
||||
out.write(b, off, len)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
try {
|
||||
outputStream.close()
|
||||
super.close()
|
||||
} finally {
|
||||
sessionTracker.downloadCompleted(gameId)
|
||||
}
|
||||
|
||||
+6
-15
@@ -1,5 +1,6 @@
|
||||
package org.gameyfin.app.core.download.bandwidth
|
||||
|
||||
import java.io.FilterOutputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
/**
|
||||
@@ -13,12 +14,12 @@ import java.io.OutputStream
|
||||
* @param remoteIp The remote IP address of the client (optional)
|
||||
*/
|
||||
class SessionThrottledOutputStream(
|
||||
private val outputStream: OutputStream,
|
||||
outputStream: OutputStream,
|
||||
private val sessionTracker: SessionBandwidthTracker,
|
||||
private val gameId: Long? = null,
|
||||
private val username: String? = null,
|
||||
private val remoteIp: String? = null
|
||||
) : OutputStream() {
|
||||
) : FilterOutputStream(outputStream) {
|
||||
|
||||
init {
|
||||
sessionTracker.downloadStarted(gameId, username, remoteIp)
|
||||
@@ -26,27 +27,17 @@ class SessionThrottledOutputStream(
|
||||
|
||||
override fun write(b: Int) {
|
||||
sessionTracker.throttle(1)
|
||||
outputStream.write(b)
|
||||
}
|
||||
|
||||
override fun write(b: ByteArray) {
|
||||
write(b, 0, b.size)
|
||||
out.write(b)
|
||||
}
|
||||
|
||||
override fun write(b: ByteArray, off: Int, len: Int) {
|
||||
// Throttle first, then write - this provides smoother bandwidth control
|
||||
// by acquiring permits before the actual write operation
|
||||
sessionTracker.throttle(len.toLong())
|
||||
outputStream.write(b, off, len)
|
||||
}
|
||||
|
||||
override fun flush() {
|
||||
outputStream.flush()
|
||||
out.write(b, off, len)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
try {
|
||||
outputStream.close()
|
||||
super.close()
|
||||
} finally {
|
||||
sessionTracker.downloadCompleted(gameId)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.gameyfin.app.core.download.bandwidth.SessionBandwidthManager
|
||||
import org.gameyfin.app.core.download.bandwidth.SessionMonitoredOutputStream
|
||||
import org.gameyfin.app.core.download.bandwidth.SessionThrottledOutputStream
|
||||
import org.gameyfin.app.core.download.provider.DownloadProviderDto
|
||||
import org.gameyfin.app.core.metrics.DownloadMetrics
|
||||
import org.gameyfin.app.core.plugins.management.GameyfinPluginDescriptor
|
||||
import org.gameyfin.app.core.plugins.management.GameyfinPluginManager
|
||||
import org.gameyfin.app.games.entities.Game
|
||||
@@ -25,6 +26,7 @@ class DownloadService(
|
||||
private val pluginManager: GameyfinPluginManager,
|
||||
private val configService: ConfigService,
|
||||
private val sessionBandwidthManager: SessionBandwidthManager,
|
||||
private val downloadMetrics: DownloadMetrics,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
@@ -80,7 +82,9 @@ class DownloadService(
|
||||
// Always get a tracker to enable stats monitoring, even without throttling
|
||||
val tracker = sessionBandwidthManager.getTracker(sessionId, maxBytesPerSecond)
|
||||
|
||||
val finalOutputStream = if (maxBytesPerSecond > 0) {
|
||||
val throttled = maxBytesPerSecond > 0
|
||||
|
||||
val finalOutputStream = if (throttled) {
|
||||
log.debug {
|
||||
"Applying session-based bandwidth limit of $bandwidthLimitMbps Mbps ($maxBytesPerSecond bytes/sec) " +
|
||||
"for download of '${game.title}' (active downloads for this session: ${tracker.activeDownloads.get()})"
|
||||
@@ -94,6 +98,8 @@ class DownloadService(
|
||||
SessionMonitoredOutputStream(outputStream, tracker, game.id, username, remoteIp)
|
||||
}
|
||||
|
||||
downloadMetrics.recordDownloadStarted(throttled)
|
||||
|
||||
try {
|
||||
finalOutputStream.use {
|
||||
val timeTaken = measureTime {
|
||||
@@ -101,12 +107,17 @@ class DownloadService(
|
||||
finalOutputStream.flush()
|
||||
}
|
||||
|
||||
val bytesWritten = tracker.totalBytesTransferred
|
||||
downloadMetrics.recordDownloadCompleted(bytesWritten)
|
||||
|
||||
log.debug {
|
||||
"Download of game '${game.title}' [ID ${game.id}] by user '${username ?: "anonymous user"}' " +
|
||||
"(session: $sessionId) completed in ${timeTaken.toString(DurationUnit.SECONDS)}"
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
downloadMetrics.recordDownloadFailed()
|
||||
|
||||
// Client disconnected (cancelled download, network error, etc.)
|
||||
// This is expected behavior, log at debug level instead of error
|
||||
log.debug {
|
||||
|
||||
@@ -100,11 +100,11 @@ class FilesystemService(
|
||||
if (!it.isDirectory()) return@filter true
|
||||
|
||||
val contents = safeReadDirectoryContents(it)
|
||||
if (contents.isEmpty() && !config.get(ConfigProperties.Libraries.Scan.ScanEmptyDirectories)!!) {
|
||||
return@filter if (contents.isEmpty() && !config.get(ConfigProperties.Libraries.Scan.ScanEmptyDirectories)!!) {
|
||||
log.debug { "Directory '$it' is empty and will be ignored" }
|
||||
return@filter false
|
||||
false
|
||||
} else {
|
||||
return@filter true
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ package org.gameyfin.app.core.logging
|
||||
|
||||
import ch.qos.logback.classic.LoggerContext
|
||||
import ch.qos.logback.classic.joran.JoranConfigurator
|
||||
import org.gameyfin.app.config.ConfigService
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.gameyfin.app.config.ConfigProperties
|
||||
import org.gameyfin.app.config.ConfigService
|
||||
import org.gameyfin.app.core.logging.util.AsyncFileTailer
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.boot.context.event.ApplicationStartedEvent
|
||||
@@ -27,7 +27,7 @@ class LogService(
|
||||
private const val LOG_CONFIG_TEMPLATE = "templates/log-config-template.xml"
|
||||
private const val LOG_FILE_NAME = "gameyfin"
|
||||
private val LOG_REFRESH_INTERVAL = 5.seconds
|
||||
private const val LOG_STREAM_RETENTION = 1000
|
||||
private const val LOG_STREAM_RETENTION = 500
|
||||
}
|
||||
|
||||
private val log = KotlinLogging.logger {}
|
||||
@@ -80,7 +80,7 @@ class LogService(
|
||||
levelRoot: LogLevel
|
||||
): InputStream {
|
||||
val template = javaClass.classLoader.getResourceAsStream(LOG_CONFIG_TEMPLATE)
|
||||
?: throw IllegalStateException("Log config template not found")
|
||||
?: error("Log config template not found")
|
||||
|
||||
val templateString = template.bufferedReader().use { it.readText() }
|
||||
return templateString
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package org.gameyfin.app.core.metrics
|
||||
|
||||
import io.micrometer.core.instrument.Counter
|
||||
import io.micrometer.core.instrument.MeterRegistry
|
||||
import org.gameyfin.app.core.download.bandwidth.SessionBandwidthManager
|
||||
import org.springframework.stereotype.Component
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* Prometheus metrics for game downloads.
|
||||
*
|
||||
* Exported metrics:
|
||||
* - `gameyfin_downloads_started_total` – counter of downloads started (tags: throttled)
|
||||
* - `gameyfin_downloads_completed_total` – counter of downloads completed successfully
|
||||
* - `gameyfin_downloads_failed_total` – counter of downloads that failed / were cancelled
|
||||
* - `gameyfin_downloads_active` – gauge of currently active downloads
|
||||
* - `gameyfin_downloads_bytes_total` – counter of total bytes streamed to clients
|
||||
* - `gameyfin_downloads_active_sessions` – gauge of sessions with at least one active download
|
||||
* - `gameyfin_downloads_bandwidth_bytes_per_second` – gauge of aggregate current bandwidth across all sessions
|
||||
*/
|
||||
@Component
|
||||
class DownloadMetrics(
|
||||
registry: MeterRegistry,
|
||||
sessionBandwidthManager: SessionBandwidthManager
|
||||
) {
|
||||
|
||||
private val activeDownloads = AtomicInteger(0)
|
||||
|
||||
private val downloadsStartedThrottled: Counter = Counter.builder("gameyfin.downloads.started")
|
||||
.description("Total number of downloads started")
|
||||
.tag("throttled", "true")
|
||||
.register(registry)
|
||||
|
||||
private val downloadsStartedUnthrottled: Counter = Counter.builder("gameyfin.downloads.started")
|
||||
.description("Total number of downloads started")
|
||||
.tag("throttled", "false")
|
||||
.register(registry)
|
||||
|
||||
private val downloadsCompleted: Counter = Counter.builder("gameyfin.downloads.completed")
|
||||
.description("Total number of downloads completed successfully")
|
||||
.register(registry)
|
||||
|
||||
private val downloadsFailed: Counter = Counter.builder("gameyfin.downloads.failed")
|
||||
.description("Total number of downloads that failed or were cancelled")
|
||||
.register(registry)
|
||||
|
||||
private val bytesTransferred: Counter = Counter.builder("gameyfin.downloads.bytes")
|
||||
.description("Total bytes streamed to clients")
|
||||
.baseUnit("bytes")
|
||||
.register(registry)
|
||||
|
||||
init {
|
||||
registry.gauge("gameyfin.downloads.active", activeDownloads) { it.get().toDouble() }
|
||||
|
||||
registry.gauge("gameyfin.downloads.active.sessions", sessionBandwidthManager) {
|
||||
it.getStats().values.count { s -> s.activeDownloads > 0 }.toDouble()
|
||||
}
|
||||
|
||||
registry.gauge("gameyfin.downloads.bandwidth.bytes.per.second", sessionBandwidthManager) {
|
||||
it.getStats().values.sumOf { s -> s.currentBytesPerSecond }.toDouble()
|
||||
}
|
||||
}
|
||||
|
||||
/** Call when a download starts. */
|
||||
fun recordDownloadStarted(throttled: Boolean) {
|
||||
activeDownloads.incrementAndGet()
|
||||
if (throttled) downloadsStartedThrottled.increment() else downloadsStartedUnthrottled.increment()
|
||||
}
|
||||
|
||||
/** Call when a download completes successfully. */
|
||||
fun recordDownloadCompleted(bytesWritten: Long) {
|
||||
activeDownloads.decrementAndGet()
|
||||
downloadsCompleted.increment()
|
||||
bytesTransferred.increment(bytesWritten.toDouble())
|
||||
}
|
||||
|
||||
/** Call when a download fails or is cancelled by the client. */
|
||||
fun recordDownloadFailed() {
|
||||
activeDownloads.decrementAndGet()
|
||||
downloadsFailed.increment()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package org.gameyfin.app.core.metrics
|
||||
|
||||
import io.micrometer.core.instrument.Counter
|
||||
import io.micrometer.core.instrument.MeterRegistry
|
||||
import io.micrometer.core.instrument.Timer
|
||||
import org.gameyfin.app.libraries.enums.ScanType
|
||||
import org.springframework.stereotype.Component
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* Prometheus metrics for library scanning.
|
||||
*
|
||||
* Exported metrics:
|
||||
* - `gameyfin_scans_started_total` – counter of scans started (tags: type)
|
||||
* - `gameyfin_scans_completed_total` – counter of scans completed (tags: type)
|
||||
* - `gameyfin_scans_failed_total` – counter of scans that failed (tags: type)
|
||||
* - `gameyfin_scans_active` – gauge of currently running scans
|
||||
* - `gameyfin_scans_duration_seconds` – timer of scan duration (tags: type)
|
||||
* - `gameyfin_scans_games_new_total` – counter of newly matched games
|
||||
* - `gameyfin_scans_games_removed_total` – counter of removed games
|
||||
* - `gameyfin_scans_games_updated_total` – counter of updated games
|
||||
* - `gameyfin_scans_games_unmatched_total`– counter of unmatched paths
|
||||
*/
|
||||
@Component
|
||||
class ScanMetrics(private val registry: MeterRegistry) {
|
||||
|
||||
private val activeScans = AtomicInteger(0)
|
||||
|
||||
// Pre-register per-type counters & timers
|
||||
private val scansStarted = ScanType.entries.associateWith { type ->
|
||||
Counter.builder("gameyfin.scans.started")
|
||||
.description("Total number of library scans started")
|
||||
.tag("type", type.name.lowercase())
|
||||
.register(registry)
|
||||
}
|
||||
|
||||
private val scansCompleted = ScanType.entries.associateWith { type ->
|
||||
Counter.builder("gameyfin.scans.completed")
|
||||
.description("Total number of library scans completed successfully")
|
||||
.tag("type", type.name.lowercase())
|
||||
.register(registry)
|
||||
}
|
||||
|
||||
private val scansFailed = ScanType.entries.associateWith { type ->
|
||||
Counter.builder("gameyfin.scans.failed")
|
||||
.description("Total number of library scans that failed")
|
||||
.tag("type", type.name.lowercase())
|
||||
.register(registry)
|
||||
}
|
||||
|
||||
private val scanDuration = ScanType.entries.associateWith { type ->
|
||||
Timer.builder("gameyfin.scans.duration")
|
||||
.description("Duration of library scans")
|
||||
.tag("type", type.name.lowercase())
|
||||
.register(registry)
|
||||
}
|
||||
|
||||
private val gamesNew: Counter = Counter.builder("gameyfin.scans.games.new")
|
||||
.description("Total number of new games found during scans")
|
||||
.register(registry)
|
||||
|
||||
private val gamesRemoved: Counter = Counter.builder("gameyfin.scans.games.removed")
|
||||
.description("Total number of games removed during scans")
|
||||
.register(registry)
|
||||
|
||||
private val gamesUpdated: Counter = Counter.builder("gameyfin.scans.games.updated")
|
||||
.description("Total number of games updated during scans")
|
||||
.register(registry)
|
||||
|
||||
private val gamesUnmatched: Counter = Counter.builder("gameyfin.scans.games.unmatched")
|
||||
.description("Total number of unmatched paths during scans")
|
||||
.register(registry)
|
||||
|
||||
init {
|
||||
registry.gauge("gameyfin.scans.active", activeScans) { it.get().toDouble() }
|
||||
}
|
||||
|
||||
/** Call when a scan starts. */
|
||||
fun recordScanStarted(type: ScanType) {
|
||||
activeScans.incrementAndGet()
|
||||
scansStarted.getValue(type).increment()
|
||||
}
|
||||
|
||||
/** Call when a scan completes successfully. */
|
||||
fun recordScanCompleted(
|
||||
type: ScanType,
|
||||
durationMillis: Long,
|
||||
newGames: Int,
|
||||
removedGames: Int,
|
||||
unmatchedPaths: Int,
|
||||
updatedGames: Int = 0
|
||||
) {
|
||||
activeScans.decrementAndGet()
|
||||
scansCompleted.getValue(type).increment()
|
||||
scanDuration.getValue(type).record(durationMillis, TimeUnit.MILLISECONDS)
|
||||
|
||||
if (newGames > 0) gamesNew.increment(newGames.toDouble())
|
||||
if (removedGames > 0) gamesRemoved.increment(removedGames.toDouble())
|
||||
if (updatedGames > 0) gamesUpdated.increment(updatedGames.toDouble())
|
||||
if (unmatchedPaths > 0) gamesUnmatched.increment(unmatchedPaths.toDouble())
|
||||
}
|
||||
|
||||
/** Call when a scan fails. */
|
||||
fun recordScanFailed(type: ScanType, durationMillis: Long) {
|
||||
activeScans.decrementAndGet()
|
||||
scansFailed.getValue(type).increment()
|
||||
scanDuration.getValue(type).record(durationMillis, TimeUnit.MILLISECONDS)
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.stereotype.Service
|
||||
import reactor.core.publisher.Flux
|
||||
import reactor.core.publisher.Sinks
|
||||
import kotlin.reflect.KClass
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.toJavaDuration
|
||||
|
||||
@@ -70,6 +71,12 @@ class PluginService(
|
||||
.map { toDto(it) }
|
||||
}
|
||||
|
||||
fun getAllByTypeAndState(type: KClass<out ExtensionPoint>, state: PluginState): List<PluginDto> {
|
||||
return pluginManager.getPluginsForExtension(type)
|
||||
.filter { it.pluginState == state }
|
||||
.map { toDto(it) }
|
||||
}
|
||||
|
||||
fun getPluginManagementEntry(clazz: Class<out ExtensionPoint>): PluginManagementEntry {
|
||||
val pluginWrapper = pluginManager.whichPlugin(clazz)
|
||||
return pluginManagementRepository.findByIdOrNull(pluginWrapper.pluginId)
|
||||
@@ -158,14 +165,15 @@ class PluginService(
|
||||
}
|
||||
|
||||
fun validatePluginConfig(pluginId: String, forceRevalidation: Boolean = false): PluginConfigValidationResult {
|
||||
if (forceRevalidation || !pluginConfigValidationCache.containsKey(pluginId)) {
|
||||
return if (forceRevalidation || !pluginConfigValidationCache.containsKey(pluginId)) {
|
||||
log.debug { "Validating config for plugin $pluginId" }
|
||||
val result = pluginManager.validatePluginConfig(pluginId)
|
||||
pluginConfigValidationCache[pluginId] = result
|
||||
return result
|
||||
result
|
||||
} else {
|
||||
log.debug { "Using cached validation result for plugin $pluginId" }
|
||||
return pluginConfigValidationCache[pluginId]!!
|
||||
pluginConfigValidationCache[pluginId]
|
||||
?: error("Plugin with id $pluginId not found in validation cache")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ data class PluginConfigEntry(
|
||||
@EmbeddedId
|
||||
val id: PluginConfigEntryKey,
|
||||
|
||||
@Lob
|
||||
@Column(name = "`value`")
|
||||
@Convert(converter = EncryptionConverter::class)
|
||||
val value: String
|
||||
|
||||
+1
-3
@@ -20,9 +20,7 @@ class GameyfinJarPluginLoader(
|
||||
}
|
||||
|
||||
override fun loadPlugin(pluginPath: Path, pluginDescriptor: PluginDescriptor?): ClassLoader {
|
||||
if (pluginDescriptor == null) {
|
||||
throw IllegalArgumentException("Plugin descriptor cannot be null")
|
||||
}
|
||||
requireNotNull(pluginDescriptor) { "Plugin descriptor cannot be null" }
|
||||
|
||||
val pluginClassLoader = GameyfinPluginClassLoader(
|
||||
pluginManager,
|
||||
|
||||
+3
-3
@@ -13,7 +13,7 @@ class GameyfinManifestPluginDescriptorFinder : ManifestPluginDescriptorFinder()
|
||||
}
|
||||
|
||||
public override fun createPluginDescriptor(manifest: Manifest?): GameyfinPluginDescriptor {
|
||||
if (manifest == null) throw IllegalArgumentException("Manifest cannot be null")
|
||||
requireNotNull(manifest) { "Manifest cannot be null" }
|
||||
|
||||
val pluginDescriptor = super.createPluginDescriptor(manifest)
|
||||
|
||||
@@ -22,10 +22,10 @@ class GameyfinManifestPluginDescriptorFinder : ManifestPluginDescriptorFinder()
|
||||
return GameyfinPluginDescriptor(
|
||||
descriptor = pluginDescriptor,
|
||||
name = attributes.getValue(PLUGIN_NAME)
|
||||
?: throw IllegalStateException("Plugin-Name not found in manifest"),
|
||||
?: error("Plugin-Name not found in manifest"),
|
||||
shortDescription = attributes.getValue(PLUGIN_SHORT_DESCRIPTION),
|
||||
author = attributes.getValue(PLUGIN_AUTHOR)
|
||||
?: throw IllegalStateException("Plugin-Author not found in manifest"),
|
||||
?: error("Plugin-Author not found in manifest"),
|
||||
url = attributes.getValue(PLUGIN_URL),
|
||||
)
|
||||
}
|
||||
|
||||
+2
@@ -19,6 +19,8 @@ class GameyfinPluginClassLoader(
|
||||
try {
|
||||
return super.loadClass(className)
|
||||
} catch (_: SecurityException) {
|
||||
// This can happen when the plugin JAR is signed but the signature is invalid (e.g. due to file corruption or tampering).
|
||||
// In this case, we want to catch the exception and return null to indicate that the class could not be loaded, instead of crashing the entire plugin loading process.
|
||||
}
|
||||
|
||||
return null
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ data class GameyfinPluginDescriptor(
|
||||
// This is because the internal (List<PluginDependency>) and external (List<String>) representation of the field differ
|
||||
this.javaClass.superclass.getDeclaredField("dependencies").let {
|
||||
it.isAccessible = true
|
||||
it.set(this, descriptor.dependencies)
|
||||
it[this] = descriptor.dependencies
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+50
-65
@@ -8,12 +8,10 @@ import org.gameyfin.pluginapi.core.config.PluginConfigValidationResultType
|
||||
import org.pf4j.*
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.stereotype.Component
|
||||
import java.io.InputStream
|
||||
import java.nio.file.Path
|
||||
import java.security.PublicKey
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.X509Certificate
|
||||
import java.util.jar.JarFile
|
||||
import kotlin.io.path.Path
|
||||
import kotlin.io.path.extension
|
||||
import kotlin.reflect.KClass
|
||||
@@ -32,10 +30,11 @@ class GameyfinPluginManager(
|
||||
|
||||
companion object {
|
||||
private const val PUBLIC_KEY_FILE = "certificates/gameyfin-plugins.pem"
|
||||
private val log = KotlinLogging.logger {}
|
||||
}
|
||||
|
||||
private val log = KotlinLogging.logger {}
|
||||
|
||||
private val publicKey: PublicKey = loadPluginSignaturePublicKey()
|
||||
private val signatureVerifier = PluginSignatureVerifier(publicKey)
|
||||
|
||||
init {
|
||||
// This took me way too long to figure out...
|
||||
@@ -59,10 +58,24 @@ class GameyfinPluginManager(
|
||||
}
|
||||
|
||||
override fun createPluginLoader(): PluginLoader {
|
||||
return when (this.isDevelopment) {
|
||||
true -> GameyfinDevelopmentPluginLoader(this, javaClass.classLoader)
|
||||
false -> GameyfinJarPluginLoader(this)
|
||||
val pluginLoader = CompoundPluginLoader()
|
||||
|
||||
val jarPluginLoader = GameyfinJarPluginLoader(this)
|
||||
pluginLoader.add(jarPluginLoader)
|
||||
|
||||
if (this.isDevelopment) {
|
||||
val classPluginLoader = GameyfinDevelopmentPluginLoader(this, javaClass.classLoader)
|
||||
pluginLoader.add(classPluginLoader)
|
||||
}
|
||||
|
||||
return pluginLoader
|
||||
}
|
||||
|
||||
override fun createPluginRepository(): PluginRepository? {
|
||||
return CompoundPluginRepository()
|
||||
.add(JarPluginRepository(getPluginsRoots()))
|
||||
.add(DefaultPluginRepository(getPluginsRoots()))
|
||||
.add(DevelopmentPluginRepository(getPluginsRoots())) { this.isDevelopment }
|
||||
}
|
||||
|
||||
override fun createPluginStatusProvider(): PluginStatusProvider {
|
||||
@@ -83,11 +96,7 @@ class GameyfinPluginManager(
|
||||
return extensionFinder
|
||||
}
|
||||
|
||||
public override fun checkPluginId(pluginId: String) {
|
||||
super.checkPluginId(pluginId)
|
||||
}
|
||||
|
||||
override fun loadPluginFromPath(pluginPath: Path): PluginWrapper? {
|
||||
public override fun loadPluginFromPath(pluginPath: Path): PluginWrapper? {
|
||||
|
||||
if (pluginPath.endsWith("data") || pluginPath.endsWith("state")) {
|
||||
log.info { "Skipping non-plugin path '$pluginPath'" }
|
||||
@@ -95,7 +104,7 @@ class GameyfinPluginManager(
|
||||
}
|
||||
|
||||
val pluginWrapper = try {
|
||||
super.loadPluginFromPath(pluginPath)
|
||||
superLoadPluginFromPath(pluginPath)
|
||||
} catch (e: Exception) {
|
||||
log.error { "Failed to load plugin '$pluginPath': ${e.message}" }
|
||||
null
|
||||
@@ -115,7 +124,7 @@ class GameyfinPluginManager(
|
||||
PluginManagementEntry(pluginId = pluginWrapper.pluginId, priority = currentMaxPriority + 1)
|
||||
|
||||
pluginManagementEntry.trustLevel = when (pluginPath.extension) {
|
||||
"jar" -> verifyPluginSignature(pluginPath)
|
||||
"jar" -> signatureVerifier.verifyPluginSignature(pluginPath)
|
||||
else -> PluginTrustLevel.BUNDLED
|
||||
}
|
||||
|
||||
@@ -125,12 +134,14 @@ class GameyfinPluginManager(
|
||||
) {
|
||||
pluginManagementEntry.enabled = true
|
||||
log.info { "Plugin ${pluginWrapper.pluginId} verified, starting" }
|
||||
// Save management entry before starting, so startPlugin can find it in the database
|
||||
pluginManagementRepository.save(pluginManagementEntry)
|
||||
startPlugin(pluginWrapper.pluginId)
|
||||
}
|
||||
} else {
|
||||
// Just re-verify the plugin if it was already in the database
|
||||
pluginManagementEntry.trustLevel = when (pluginPath.extension) {
|
||||
"jar" -> verifyPluginSignature(pluginPath)
|
||||
"jar" -> signatureVerifier.verifyPluginSignature(pluginPath)
|
||||
else -> PluginTrustLevel.BUNDLED
|
||||
}
|
||||
}
|
||||
@@ -155,12 +166,21 @@ class GameyfinPluginManager(
|
||||
if (pluginId == null) return PluginState.FAILED
|
||||
|
||||
val trustLevel = pluginManagementRepository.findByIdOrNull(pluginId)?.trustLevel ?: PluginTrustLevel.UNKNOWN
|
||||
if (trustLevel == PluginTrustLevel.UNTRUSTED) {
|
||||
val pluginWrapper = getPlugin(pluginId)
|
||||
val pluginState = PluginState.UNLOADED
|
||||
when (trustLevel) {
|
||||
PluginTrustLevel.UNTRUSTED -> {
|
||||
val pluginWrapper = getPlugin(pluginId)
|
||||
val pluginState = PluginState.UNLOADED
|
||||
this.firePluginStateEvent(PluginStateEvent(this, pluginWrapper, pluginState))
|
||||
return pluginState
|
||||
}
|
||||
|
||||
this.firePluginStateEvent(PluginStateEvent(this, pluginWrapper, pluginState))
|
||||
return pluginState
|
||||
PluginTrustLevel.UNKNOWN -> {
|
||||
val pluginWrapper = getPlugin(pluginId)
|
||||
log.warn { "Plugin $pluginId has unknown trust level, not starting" }
|
||||
return pluginWrapper?.pluginState
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
// Validate config before starting the plugin
|
||||
@@ -233,12 +253,16 @@ class GameyfinPluginManager(
|
||||
.map { it.simpleName }
|
||||
}
|
||||
|
||||
fun getPluginForExtension(extensionClass: Class<ExtensionPoint>): PluginWrapper? {
|
||||
return getPlugins().firstOrNull { pluginWrapper ->
|
||||
getExtensionClasses(pluginWrapper.pluginId).any { it == extensionClass }
|
||||
fun getPluginsForExtension(extensionClass: KClass<out ExtensionPoint>): List<PluginWrapper> {
|
||||
return getPlugins().filter { pluginWrapper ->
|
||||
supportsExtensionType(pluginWrapper.pluginId, extensionClass)
|
||||
}
|
||||
}
|
||||
|
||||
fun getPluginForExtension(extensionClass: KClass<out ExtensionPoint>): PluginWrapper? {
|
||||
return getPluginsForExtension(extensionClass).firstOrNull()
|
||||
}
|
||||
|
||||
fun getManagementEntry(pluginId: String): PluginManagementEntry {
|
||||
return pluginManagementRepository.findByIdOrNull(pluginId)
|
||||
?: throw IllegalArgumentException("Plugin with ID $pluginId not found")
|
||||
@@ -264,48 +288,9 @@ class GameyfinPluginManager(
|
||||
return cert.publicKey
|
||||
}
|
||||
|
||||
private fun verifyPluginSignature(pluginPath: Path): PluginTrustLevel {
|
||||
val jarFile = JarFile(pluginPath.toFile(), true)
|
||||
val entries = jarFile.entries()
|
||||
|
||||
while (entries.hasMoreElements()) {
|
||||
val entry = entries.nextElement()
|
||||
if (entry.isDirectory || entry.name.startsWith("META-INF/")) continue
|
||||
|
||||
try {
|
||||
val buffer = ByteArray(8192)
|
||||
val entryInputStream: InputStream = jarFile.getInputStream(entry)
|
||||
while ((entryInputStream.read(buffer, 0, buffer.size)) != -1) {
|
||||
// We just read
|
||||
// This will throw a SecurityException if a signature/digest check fails
|
||||
}
|
||||
} catch (_: SecurityException) {
|
||||
// Signature verification failed
|
||||
return PluginTrustLevel.UNTRUSTED
|
||||
}
|
||||
|
||||
val codeSigners = entry.codeSigners
|
||||
|
||||
if (codeSigners == null || codeSigners.isEmpty()) {
|
||||
// No code signers, so we can't verify the signature
|
||||
return PluginTrustLevel.THIRD_PARTY
|
||||
}
|
||||
|
||||
for (codeSigner in codeSigners) {
|
||||
val certs = codeSigner.signerCertPath.certificates
|
||||
|
||||
for (cert in certs) {
|
||||
if (cert is X509Certificate) {
|
||||
try {
|
||||
cert.verify(publicKey)
|
||||
} catch (_: Exception) {
|
||||
// Signature verification failed
|
||||
return PluginTrustLevel.UNTRUSTED
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return PluginTrustLevel.OFFICIAL
|
||||
// Needed for unit testing since super.loadPluginFromPath is protected
|
||||
internal fun superLoadPluginFromPath(pluginPath: Path): PluginWrapper? {
|
||||
return super.loadPluginFromPath(pluginPath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import org.springframework.scheduling.annotation.Async
|
||||
|
||||
@Configuration
|
||||
class PluginManagerConfig(
|
||||
private val pluginManager: GameyfinPluginManager
|
||||
private val pluginManager: GameyfinPluginManager,
|
||||
private val pluginsLoadedIndicator: PluginsLoadedIndicator
|
||||
) {
|
||||
private val log = KotlinLogging.logger {}
|
||||
|
||||
@@ -18,5 +19,6 @@ class PluginManagerConfig(
|
||||
pluginManager.loadPlugins()
|
||||
pluginManager.startPlugins()
|
||||
log.info { "Loaded plugins: ${pluginManager.plugins.map { it.pluginId }}" }
|
||||
pluginsLoadedIndicator.markReady()
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package org.gameyfin.app.core.plugins.management
|
||||
|
||||
import java.io.InputStream
|
||||
import java.nio.file.Path
|
||||
import java.security.CodeSigner
|
||||
import java.security.PublicKey
|
||||
import java.security.cert.Certificate
|
||||
import java.security.cert.X509Certificate
|
||||
import java.util.jar.JarEntry
|
||||
import java.util.jar.JarFile
|
||||
|
||||
/**
|
||||
* Verifies JAR plugin signatures against a trusted public key.
|
||||
*/
|
||||
class PluginSignatureVerifier(private val publicKey: PublicKey) {
|
||||
|
||||
fun verifyPluginSignature(pluginPath: Path): PluginTrustLevel {
|
||||
val jarFile = JarFile(pluginPath.toFile(), true)
|
||||
val entries = jarFile.entries()
|
||||
|
||||
while (entries.hasMoreElements()) {
|
||||
val entry = entries.nextElement()
|
||||
if (entry.isDirectory || entry.name.startsWith("META-INF/")) continue
|
||||
|
||||
if (!verifyEntryDigest(jarFile, entry)) return PluginTrustLevel.UNTRUSTED
|
||||
|
||||
val codeSigners = entry.codeSigners
|
||||
if (codeSigners.isNullOrEmpty()) return PluginTrustLevel.THIRD_PARTY
|
||||
|
||||
val signersTrustLevel = verifyCodeSigners(codeSigners)
|
||||
if (signersTrustLevel != PluginTrustLevel.OFFICIAL) return signersTrustLevel
|
||||
}
|
||||
return PluginTrustLevel.OFFICIAL
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the full entry stream to trigger JAR signature/digest verification.
|
||||
* Returns `true` if the entry is valid, `false` if signature verification failed.
|
||||
*/
|
||||
fun verifyEntryDigest(jarFile: JarFile, entry: JarEntry): Boolean {
|
||||
return try {
|
||||
val buffer = ByteArray(8192)
|
||||
val entryInputStream: InputStream = jarFile.getInputStream(entry)
|
||||
while (entryInputStream.read(buffer, 0, buffer.size) != -1) {
|
||||
// Reading to trigger SecurityException on digest mismatch
|
||||
}
|
||||
true
|
||||
} catch (_: SecurityException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that all code signers' certificates are signed with the expected public key.
|
||||
*/
|
||||
fun verifyCodeSigners(codeSigners: Array<CodeSigner>): PluginTrustLevel {
|
||||
for (codeSigner in codeSigners) {
|
||||
val certs = codeSigner.signerCertPath.certificates.toList()
|
||||
val trustLevel = verifyCertificates(certs)
|
||||
if (trustLevel != PluginTrustLevel.OFFICIAL) return trustLevel
|
||||
}
|
||||
return PluginTrustLevel.OFFICIAL
|
||||
}
|
||||
|
||||
fun verifyCertificates(certs: List<Certificate>): PluginTrustLevel {
|
||||
for (cert in certs) {
|
||||
if (cert !is X509Certificate) continue
|
||||
try {
|
||||
cert.verify(publicKey)
|
||||
} catch (_: Exception) {
|
||||
return PluginTrustLevel.UNTRUSTED
|
||||
}
|
||||
}
|
||||
return PluginTrustLevel.OFFICIAL
|
||||
}
|
||||
}
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package org.gameyfin.app.core.plugins.management
|
||||
|
||||
import org.springframework.boot.health.contributor.Health
|
||||
import org.springframework.boot.health.contributor.HealthIndicator
|
||||
import org.springframework.stereotype.Component
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
@Component("pluginsLoaded")
|
||||
class PluginsLoadedIndicator : HealthIndicator {
|
||||
|
||||
private val ready = AtomicBoolean(false)
|
||||
|
||||
fun markReady() {
|
||||
ready.set(true)
|
||||
}
|
||||
|
||||
override fun health(): Health {
|
||||
return if (ready.get()) {
|
||||
Health.up().withDetail("plugins", "loaded").build()
|
||||
} else {
|
||||
Health.outOfService().withDetail("plugins", "loading").build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ import java.util.*
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
// Suppress warnings about use of AES/ECB mode.
|
||||
// DB lookups (SQL WHERE) need deterministic values which is only possible when using a mode without IV.
|
||||
// This is a trade-off between security and practicability.
|
||||
@Suppress("kotlin:S5542")
|
||||
class EncryptionUtils {
|
||||
companion object {
|
||||
private const val ALGORITHM = "AES"
|
||||
@@ -16,7 +20,7 @@ class EncryptionUtils {
|
||||
// Extracted for testability
|
||||
internal fun getAppKey(): String {
|
||||
return System.getenv("APP_KEY")
|
||||
?: throw IllegalStateException("APP_KEY environment variable is not set or empty")
|
||||
?: error("APP_KEY environment variable is not set or empty")
|
||||
}
|
||||
|
||||
fun encrypt(value: String): String {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package org.gameyfin.app.core.security
|
||||
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser
|
||||
|
||||
/**
|
||||
* Resolves the username from an OidcUser, using [attributeName] as the primary claim.
|
||||
*
|
||||
* Falls back through the following chain when the preferred claim is absent or blank:
|
||||
* 1. `preferred_username`
|
||||
* 2. `nickname`
|
||||
* 3. `name`
|
||||
* 4. `email`
|
||||
* 5. `sub` (always present, used as last resort)
|
||||
*/
|
||||
fun OidcUser.resolvedUsername(attributeName: String = "preferred_username"): String {
|
||||
// Try the configured attribute first, then fall through the standard fallback chain
|
||||
val candidates = linkedSetOf(attributeName, "preferred_username", "nickname", "name", "email")
|
||||
for (claim in candidates) {
|
||||
val value = getClaim<String>(claim)
|
||||
if (!value.isNullOrBlank()) return value
|
||||
}
|
||||
// `sub` is mandatory in OIDC and always present
|
||||
return subject
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.gameyfin.app.config.ConfigService
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Conditional
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.core.annotation.Order
|
||||
import org.springframework.core.env.Environment
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||
@@ -32,15 +33,26 @@ class SecurityConfig(
|
||||
|
||||
companion object {
|
||||
const val SSO_PROVIDER_KEY = "oidc"
|
||||
const val LOGIN_URL = "/login"
|
||||
}
|
||||
|
||||
@Order(1)
|
||||
@Bean
|
||||
fun actuatorFilterChain(http: HttpSecurity): SecurityFilterChain {
|
||||
http.securityMatcher("/actuator/**")
|
||||
.authorizeHttpRequests { auth -> auth.anyRequest().permitAll() }
|
||||
.csrf { csrf -> csrf.disable() }
|
||||
return http.build()
|
||||
}
|
||||
|
||||
@Order(2)
|
||||
@Bean
|
||||
fun filterChain(http: HttpSecurity, routeUtil: RouteUtil): SecurityFilterChain {
|
||||
// Apply Vaadin configuration first to properly configure CSRF and request matchers
|
||||
if (config.get(ConfigProperties.SSO.OIDC.Enabled) == true) {
|
||||
http.with(VaadinSecurityConfigurer.vaadin()) { configurer ->
|
||||
// Redirect to SSO provider on logout
|
||||
configurer.loginView("/login", config.get(ConfigProperties.SSO.OIDC.LogoutUrl))
|
||||
configurer.loginView(LOGIN_URL, config.get(ConfigProperties.SSO.OIDC.LogoutUrl))
|
||||
}
|
||||
|
||||
// Use custom success handler to handle user registration
|
||||
@@ -57,7 +69,7 @@ class SecurityConfig(
|
||||
} else {
|
||||
// Use default Vaadin login URLs
|
||||
http.with(VaadinSecurityConfigurer.vaadin()) { configurer ->
|
||||
configurer.loginView("/login")
|
||||
configurer.loginView(LOGIN_URL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +78,7 @@ class SecurityConfig(
|
||||
auth.requestMatchers(routeUtil::isRouteAllowed).permitAll()
|
||||
// Gameyfin static resources and public endpoints
|
||||
.requestMatchers(
|
||||
"/login",
|
||||
LOGIN_URL,
|
||||
"/loginredirect",
|
||||
"/setup",
|
||||
"/reset-password",
|
||||
@@ -120,7 +132,7 @@ class SecurityConfig(
|
||||
.clientId(config.get(ConfigProperties.SSO.OIDC.ClientId))
|
||||
.clientSecret(config.get(ConfigProperties.SSO.OIDC.ClientSecret))
|
||||
.scope(config.get(ConfigProperties.SSO.OIDC.OAuthScopes)?.toList())
|
||||
.userNameAttributeName("preferred_username")
|
||||
.userNameAttributeName(config.get(ConfigProperties.SSO.OIDC.UsernameClaim))
|
||||
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.issuerUri(config.get(ConfigProperties.SSO.OIDC.IssuerUrl))
|
||||
.authorizationUri(config.get(ConfigProperties.SSO.OIDC.AuthorizeUrl))
|
||||
|
||||
+8
-12
@@ -35,6 +35,10 @@ class SsoAuthenticationSuccessHandler(
|
||||
) {
|
||||
val oidcUser = authentication.principal as OidcUser
|
||||
|
||||
// Resolve the username using the configured claim with automatic fallback
|
||||
val usernameAttributeName = config.get(ConfigProperties.SSO.OIDC.UsernameClaim) ?: "preferred_username"
|
||||
val resolvedUsername = oidcUser.resolvedUsername(usernameAttributeName)
|
||||
|
||||
// Check if user is already registered via SSO
|
||||
var matchedUser = userService.findByOidcProviderId(oidcUser.subject)
|
||||
|
||||
@@ -42,27 +46,19 @@ class SsoAuthenticationSuccessHandler(
|
||||
// This is meant to map existing users to SSO users
|
||||
if (matchedUser == null) {
|
||||
matchedUser = when (config.get(ConfigProperties.SSO.OIDC.MatchExistingUsersBy)) {
|
||||
MatchUsersBy.username -> userService.getByUsername(oidcUser.preferredUsername)
|
||||
MatchUsersBy.username -> userService.getByUsername(resolvedUsername)
|
||||
MatchUsersBy.email -> userService.getByEmail(oidcUser.email)
|
||||
else -> throw IllegalStateException("Unknown 'match users by' configuration")
|
||||
else -> error("Unknown 'match users by' configuration")
|
||||
}
|
||||
}
|
||||
|
||||
// User could not be found in the database
|
||||
if (matchedUser == null) {
|
||||
// TODO: User registration is currently forced, but this should be configurable.
|
||||
// However, this causes conflict with user preferences and game entities (since both reference the user entity)
|
||||
// Check if new user registration is enabled
|
||||
//if (config.get(ConfigProperties.SSO.OIDC.AutoRegisterNewUsers) == false) {
|
||||
// response.sendRedirect("/")
|
||||
// return
|
||||
//
|
||||
|
||||
// Register as new user
|
||||
matchedUser = User(oidcUser)
|
||||
matchedUser = User(oidcUser, resolvedUsername)
|
||||
} else {
|
||||
// Update user with new SSO data
|
||||
matchedUser.username = oidcUser.preferredUsername
|
||||
matchedUser.username = resolvedUsername
|
||||
matchedUser.email = oidcUser.email
|
||||
matchedUser.emailConfirmed = true
|
||||
matchedUser.oidcProviderId = oidcUser.subject
|
||||
|
||||
@@ -18,7 +18,7 @@ class DisplayableSerializer : ValueSerializer<Any>() {
|
||||
// Use reflection to get the displayName property
|
||||
val displayName = value::class.java.getDeclaredField("displayName").apply {
|
||||
isAccessible = true
|
||||
}.get(value) as String
|
||||
}[value] as String
|
||||
|
||||
gen.writeString(displayName)
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ class GameService(
|
||||
|
||||
|
||||
fun getAll(): List<GameDto> {
|
||||
val entities = gameRepository.findAll()
|
||||
val entities = gameRepository.findAll().toList()
|
||||
return entities.toDtos()
|
||||
}
|
||||
|
||||
@@ -148,6 +148,7 @@ class GameService(
|
||||
return gameRepository.saveAll(gamesToBePersisted)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun edit(gameUpdateDto: GameUpdateDto) {
|
||||
val existingGame = gameRepository.findByIdOrNull(gameUpdateDto.id)
|
||||
?: throw IllegalArgumentException("Game with ID $gameUpdateDto.id not found")
|
||||
@@ -155,7 +156,7 @@ class GameService(
|
||||
val user = when (val userDetails = getCurrentAuth()?.principal) {
|
||||
is UserDetails -> userService.getByUsernameNonNull(userDetails.username)
|
||||
is OidcUser -> userService.getByUsernameNonNull(userDetails.preferredUsername)
|
||||
else -> throw IllegalStateException("Unkown user type: ${userDetails?.javaClass?.name}")
|
||||
else -> error("Unknown user type: ${userDetails?.javaClass?.name}")
|
||||
}
|
||||
|
||||
// Update only non-null fields
|
||||
@@ -466,7 +467,7 @@ class GameService(
|
||||
try {
|
||||
plugin.fetchByTitle(searchTerm, platformFilter, 10).map { plugin to it }
|
||||
} catch (e: Exception) {
|
||||
val pluginWrapper = pluginManager.getPluginForExtension(plugin.javaClass)
|
||||
val pluginWrapper = pluginManager.getPluginForExtension(plugin::class)
|
||||
log.warn { "Error fetching metadata for searchterm '$searchTerm' with plugin '${(pluginWrapper?.descriptor as GameyfinPluginDescriptor?)?.pluginName ?: pluginWrapper?.pluginId ?: plugin.javaClass.name}': ${e.message}" }
|
||||
log.debug(e) {}
|
||||
emptyList()
|
||||
@@ -601,7 +602,7 @@ class GameService(
|
||||
try {
|
||||
return@async plugin.fetchById(originalId)
|
||||
} catch (e: Exception) {
|
||||
val pluginWrapper = pluginManager.getPluginForExtension(plugin.javaClass)
|
||||
val pluginWrapper = pluginManager.getPluginForExtension(plugin::class)
|
||||
log.warn { "Error fetching metadata for game [id: $originalId] with plugin '${(pluginWrapper?.descriptor as GameyfinPluginDescriptor?)?.pluginName ?: pluginWrapper?.pluginId ?: plugin.javaClass.name}': ${e.message}" }
|
||||
log.debug(e) {}
|
||||
null
|
||||
@@ -749,7 +750,7 @@ class GameService(
|
||||
try {
|
||||
plugin.fetchByTitle(gameTitle, platforms).firstOrNull()
|
||||
} catch (e: Exception) {
|
||||
val pluginWrapper = pluginManager.getPluginForExtension(plugin.javaClass)
|
||||
val pluginWrapper = pluginManager.getPluginForExtension(plugin::class)
|
||||
log.warn { "Error fetching metadata for game title '$gameTitle' with plugin '${(pluginWrapper?.descriptor as GameyfinPluginDescriptor?)?.pluginName ?: pluginWrapper?.pluginId ?: plugin.javaClass.name}': ${e.message}" }
|
||||
log.debug(e) {}
|
||||
null
|
||||
@@ -807,138 +808,10 @@ class GameService(
|
||||
|
||||
sortedResults.forEach { (provider, metadata) ->
|
||||
val sourcePlugin = providerToManagementEntry[provider] ?: return@forEach
|
||||
if (metadata == null) return@forEach
|
||||
|
||||
metadata?.let { metadata ->
|
||||
originalIdsMap[sourcePlugin] = metadata.originalId
|
||||
|
||||
metadata.platforms?.takeIf { it.isNotEmpty() }?.let { platforms ->
|
||||
if (!metadataMap.containsKey("platforms")) {
|
||||
mergedGame.platforms = platforms.toMutableList()
|
||||
metadataMap["platforms"] =
|
||||
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
|
||||
}
|
||||
}
|
||||
metadata.title.takeIf { it.isNotBlank() }?.let { title ->
|
||||
if (!metadataMap.containsKey("title")) {
|
||||
mergedGame.title = title
|
||||
metadataMap["title"] = GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
|
||||
}
|
||||
}
|
||||
metadata.description?.takeIf { it.isNotBlank() }?.let { description ->
|
||||
if (!metadataMap.containsKey("summary")) {
|
||||
mergedGame.summary = description
|
||||
metadataMap["summary"] =
|
||||
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
|
||||
}
|
||||
}
|
||||
metadata.coverUrls?.firstOrNull()?.let { coverUrl ->
|
||||
if (!metadataMap.containsKey("coverImage")) {
|
||||
mergedGame.coverImage = imageService.createOrGet(
|
||||
Image(originalUrl = coverUrl.toString(), type = ImageType.COVER)
|
||||
)
|
||||
metadataMap["coverImage"] =
|
||||
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
|
||||
}
|
||||
}
|
||||
metadata.headerUrls?.firstOrNull()?.let { headerUrl ->
|
||||
if (!metadataMap.containsKey("headerImage")) {
|
||||
mergedGame.headerImage = imageService.createOrGet(
|
||||
Image(originalUrl = headerUrl.toString(), type = ImageType.HEADER)
|
||||
)
|
||||
metadataMap["headerImage"] =
|
||||
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
|
||||
}
|
||||
}
|
||||
metadata.release?.let { release ->
|
||||
if (!metadataMap.containsKey("release")) {
|
||||
mergedGame.release = release
|
||||
metadataMap["release"] =
|
||||
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
|
||||
}
|
||||
}
|
||||
metadata.userRating?.let { userRating ->
|
||||
if (!metadataMap.containsKey("userRating")) {
|
||||
mergedGame.userRating = userRating
|
||||
metadataMap["userRating"] =
|
||||
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
|
||||
}
|
||||
}
|
||||
metadata.criticRating?.let { criticRating ->
|
||||
if (!metadataMap.containsKey("criticRating")) {
|
||||
mergedGame.criticRating = criticRating
|
||||
metadataMap["criticRating"] =
|
||||
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
|
||||
}
|
||||
}
|
||||
metadata.publishedBy?.takeIf { it.isNotEmpty() }?.let { publishedBy ->
|
||||
if (!metadataMap.containsKey("publishers")) {
|
||||
mergedGame.publishers =
|
||||
publishedBy.map { Company(name = it, type = CompanyType.PUBLISHER) }.toMutableList()
|
||||
metadataMap["publishers"] =
|
||||
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
|
||||
}
|
||||
}
|
||||
metadata.developedBy?.takeIf { it.isNotEmpty() }?.let { developedBy ->
|
||||
if (!metadataMap.containsKey("developers")) {
|
||||
mergedGame.developers =
|
||||
developedBy.map { Company(name = it, type = CompanyType.DEVELOPER) }.toMutableList()
|
||||
metadataMap["developers"] =
|
||||
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
|
||||
}
|
||||
}
|
||||
metadata.genres?.takeIf { it.isNotEmpty() }?.let { genres ->
|
||||
if (!metadataMap.containsKey("genres")) {
|
||||
mergedGame.genres = genres.toList()
|
||||
metadataMap["genres"] = GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
|
||||
}
|
||||
}
|
||||
metadata.themes?.takeIf { it.isNotEmpty() }?.let { themes ->
|
||||
if (!metadataMap.containsKey("themes")) {
|
||||
mergedGame.themes = themes.toList()
|
||||
metadataMap["themes"] = GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
|
||||
}
|
||||
}
|
||||
metadata.keywords?.takeIf { it.isNotEmpty() }?.let { keywords ->
|
||||
if (!metadataMap.containsKey("keywords")) {
|
||||
mergedGame.keywords = keywords.toList()
|
||||
metadataMap["keywords"] =
|
||||
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
|
||||
}
|
||||
}
|
||||
metadata.features?.takeIf { it.isNotEmpty() }?.let { features ->
|
||||
if (!metadataMap.containsKey("features")) {
|
||||
mergedGame.features = features.toList()
|
||||
metadataMap["features"] =
|
||||
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
|
||||
}
|
||||
}
|
||||
metadata.perspectives?.takeIf { it.isNotEmpty() }?.let { perspectives ->
|
||||
if (!metadataMap.containsKey("perspectives")) {
|
||||
mergedGame.perspectives = perspectives.toList()
|
||||
metadataMap["perspectives"] =
|
||||
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
|
||||
}
|
||||
}
|
||||
metadata.screenshotUrls?.takeIf { it.isNotEmpty() }?.let { screenshotUrls ->
|
||||
if (!metadataMap.containsKey("images")) {
|
||||
mergedGame.images = runBlocking {
|
||||
screenshotUrls.map {
|
||||
imageService.createOrGet(
|
||||
Image(originalUrl = it.toString(), type = ImageType.SCREENSHOT)
|
||||
)
|
||||
}.toMutableList()
|
||||
}
|
||||
metadataMap["images"] = GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
|
||||
}
|
||||
}
|
||||
metadata.videoUrls?.takeIf { it.isNotEmpty() }?.let { videoUrls ->
|
||||
if (!metadataMap.containsKey("videoUrls")) {
|
||||
mergedGame.videoUrls = videoUrls.toList()
|
||||
metadataMap["videoUrls"] =
|
||||
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
|
||||
}
|
||||
}
|
||||
}
|
||||
originalIdsMap[sourcePlugin] = metadata.originalId
|
||||
applyMetadataFields(metadata, sourcePlugin, mergedGame, metadataMap)
|
||||
}
|
||||
|
||||
mergedGame.metadata.fields = metadataMap
|
||||
@@ -947,6 +820,102 @@ class GameService(
|
||||
return mergedGame
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a field on the merged game if it has not already been set by a higher-priority plugin.
|
||||
*/
|
||||
private fun <T> setFieldIfAbsent(
|
||||
fieldName: String,
|
||||
value: T?,
|
||||
metadataMap: MutableMap<String, GameFieldMetadata>,
|
||||
sourcePlugin: PluginManagementEntry,
|
||||
setter: (T) -> Unit
|
||||
) {
|
||||
if (value != null && !metadataMap.containsKey(fieldName)) {
|
||||
setter(value)
|
||||
metadataMap[fieldName] = GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies all metadata fields from a single plugin result onto the merged game,
|
||||
* respecting the first-write-wins rule via [setFieldIfAbsent].
|
||||
*/
|
||||
private fun applyMetadataFields(
|
||||
metadata: PluginApiMetadata,
|
||||
sourcePlugin: PluginManagementEntry,
|
||||
mergedGame: Game,
|
||||
metadataMap: MutableMap<String, GameFieldMetadata>
|
||||
) {
|
||||
setFieldIfAbsent("platforms", metadata.platforms?.takeIf { it.isNotEmpty() }, metadataMap, sourcePlugin) {
|
||||
mergedGame.platforms = it.toMutableList()
|
||||
}
|
||||
setFieldIfAbsent("title", metadata.title.takeIf { it.isNotBlank() }, metadataMap, sourcePlugin) {
|
||||
mergedGame.title = it
|
||||
}
|
||||
setFieldIfAbsent("summary", metadata.description?.takeIf { it.isNotBlank() }, metadataMap, sourcePlugin) {
|
||||
mergedGame.summary = it
|
||||
}
|
||||
setFieldIfAbsent("coverImage", metadata.coverUrls?.firstOrNull(), metadataMap, sourcePlugin) {
|
||||
mergedGame.coverImage = imageService.createOrGet(
|
||||
Image(originalUrl = it.toString(), type = ImageType.COVER)
|
||||
)
|
||||
}
|
||||
setFieldIfAbsent("headerImage", metadata.headerUrls?.firstOrNull(), metadataMap, sourcePlugin) {
|
||||
mergedGame.headerImage = imageService.createOrGet(
|
||||
Image(originalUrl = it.toString(), type = ImageType.HEADER)
|
||||
)
|
||||
}
|
||||
setFieldIfAbsent("release", metadata.release, metadataMap, sourcePlugin) {
|
||||
mergedGame.release = it
|
||||
}
|
||||
setFieldIfAbsent("userRating", metadata.userRating, metadataMap, sourcePlugin) {
|
||||
mergedGame.userRating = it
|
||||
}
|
||||
setFieldIfAbsent("criticRating", metadata.criticRating, metadataMap, sourcePlugin) {
|
||||
mergedGame.criticRating = it
|
||||
}
|
||||
setFieldIfAbsent("publishers", metadata.publishedBy?.takeIf { it.isNotEmpty() }, metadataMap, sourcePlugin) {
|
||||
mergedGame.publishers =
|
||||
it.map { name -> Company(name = name, type = CompanyType.PUBLISHER) }.toMutableList()
|
||||
}
|
||||
setFieldIfAbsent("developers", metadata.developedBy?.takeIf { it.isNotEmpty() }, metadataMap, sourcePlugin) {
|
||||
mergedGame.developers =
|
||||
it.map { name -> Company(name = name, type = CompanyType.DEVELOPER) }.toMutableList()
|
||||
}
|
||||
setFieldIfAbsent("genres", metadata.genres?.takeIf { it.isNotEmpty() }, metadataMap, sourcePlugin) {
|
||||
mergedGame.genres = it.toList()
|
||||
}
|
||||
setFieldIfAbsent("themes", metadata.themes?.takeIf { it.isNotEmpty() }, metadataMap, sourcePlugin) {
|
||||
mergedGame.themes = it.toList()
|
||||
}
|
||||
setFieldIfAbsent("keywords", metadata.keywords?.takeIf { it.isNotEmpty() }, metadataMap, sourcePlugin) {
|
||||
mergedGame.keywords = it.toList()
|
||||
}
|
||||
setFieldIfAbsent("features", metadata.features?.takeIf { it.isNotEmpty() }, metadataMap, sourcePlugin) {
|
||||
mergedGame.features = it.toList()
|
||||
}
|
||||
setFieldIfAbsent("perspectives", metadata.perspectives?.takeIf { it.isNotEmpty() }, metadataMap, sourcePlugin) {
|
||||
mergedGame.perspectives = it.toList()
|
||||
}
|
||||
setFieldIfAbsent(
|
||||
"images",
|
||||
metadata.screenshotUrls?.takeIf { it.isNotEmpty() },
|
||||
metadataMap,
|
||||
sourcePlugin
|
||||
) { screenshotUrls ->
|
||||
mergedGame.images = runBlocking {
|
||||
screenshotUrls.map {
|
||||
imageService.createOrGet(
|
||||
Image(originalUrl = it.toString(), type = ImageType.SCREENSHOT)
|
||||
)
|
||||
}.toMutableList()
|
||||
}
|
||||
}
|
||||
setFieldIfAbsent("videoUrls", metadata.videoUrls?.takeIf { it.isNotEmpty() }, metadataMap, sourcePlugin) {
|
||||
mergedGame.videoUrls = it.toList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.fuzzyMatchTitle(other: String): Boolean {
|
||||
val minRatio = config.get(ConfigProperties.Libraries.Scan.TitleMatchMinRatio)!!
|
||||
return FuzzySearch.ratio(this.normalizeGameTitle(), other.normalizeGameTitle()) > minRatio
|
||||
|
||||
@@ -79,7 +79,7 @@ class Game(
|
||||
@ManyToMany(cascade = [PERSIST, MERGE, REFRESH], fetch = FetchType.EAGER)
|
||||
var images: MutableList<Image> = mutableListOf(),
|
||||
|
||||
@ElementCollection
|
||||
@ElementCollection(fetch = FetchType.EAGER)
|
||||
var videoUrls: List<URI> = emptyList(),
|
||||
|
||||
@ManyToMany(mappedBy = "games", fetch = FetchType.EAGER)
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.gameyfin.app.libraries.entities.Library
|
||||
import org.gameyfin.app.users.UserService
|
||||
import org.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
@@ -52,6 +53,7 @@ class LibraryCoreService(
|
||||
return library
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun deleteGameFromLibrary(gameId: Long) {
|
||||
val game = gameService.getById(gameId)
|
||||
val library = game.library
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package org.gameyfin.app.libraries
|
||||
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.gameyfin.app.config.ConfigProperties
|
||||
import org.gameyfin.app.config.ConfigService
|
||||
import org.gameyfin.app.core.filesystem.FilesystemService
|
||||
import org.gameyfin.app.core.metrics.ScanMetrics
|
||||
import org.gameyfin.app.core.plugins.PluginService
|
||||
import org.gameyfin.app.games.entities.Game
|
||||
import org.gameyfin.app.games.repositories.GameRepository
|
||||
@@ -16,14 +19,14 @@ import org.gameyfin.app.libraries.scan.MatchNewGamesResult
|
||||
import org.gameyfin.app.libraries.scan.UpdateExistingGamesResult
|
||||
import org.gameyfin.app.libraries.scan.UpdateLibraryResult
|
||||
import org.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
|
||||
import org.pf4j.PluginState
|
||||
import org.springframework.stereotype.Service
|
||||
import reactor.core.publisher.Flux
|
||||
import reactor.core.publisher.Sinks
|
||||
import java.nio.file.Path
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.Callable
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.*
|
||||
import java.util.concurrent.*
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
@@ -37,18 +40,30 @@ class LibraryScanService(
|
||||
private val libraryGameProcessor: LibraryGameProcessor,
|
||||
private val gameRepository: GameRepository,
|
||||
private val ignoredPathRepository: IgnoredPathRepository,
|
||||
private val pluginService: PluginService
|
||||
private val pluginService: PluginService,
|
||||
private val configService: ConfigService,
|
||||
private val scanMetrics: ScanMetrics
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private val log = KotlinLogging.logger {}
|
||||
|
||||
private val SCAN_RESULT_TTL = 24.hours.toJavaDuration()
|
||||
private val scanProgressEvents = Sinks.many().replay().limit<LibraryScanProgress>(SCAN_RESULT_TTL)
|
||||
private val SCAN_PROGRESS_RETENTION = 24.hours.toJavaDuration()
|
||||
|
||||
/**
|
||||
* Keeps only the **most recent** progress event per scan (keyed by scanId).
|
||||
*/
|
||||
private val latestProgressPerScan = ConcurrentHashMap<UUID, LibraryScanProgress>()
|
||||
private val scanProgressEvents = Sinks.many().multicast().onBackpressureBuffer<LibraryScanProgress>(1024, false)
|
||||
|
||||
fun subscribeToScanProgressEvents(): Flux<List<LibraryScanProgress>> {
|
||||
log.debug { "New subscription for scanProgressEvents" }
|
||||
return scanProgressEvents.asFlux()
|
||||
|
||||
// Replay the current snapshot first, then stream live updates
|
||||
val snapshot = Flux.fromIterable(latestProgressPerScan.values.toList())
|
||||
val live = scanProgressEvents.asFlux()
|
||||
|
||||
return Flux.concat(snapshot, live)
|
||||
.buffer(1.seconds.toJavaDuration())
|
||||
.doOnSubscribe {
|
||||
log.debug { "Subscriber added to scanProgressEvents [${scanProgressEvents.currentSubscriberCount()}]" }
|
||||
@@ -59,18 +74,47 @@ class LibraryScanService(
|
||||
}
|
||||
|
||||
fun emit(scanProgressDto: LibraryScanProgress) {
|
||||
latestProgressPerScan[scanProgressDto.scanId] = scanProgressDto
|
||||
scanProgressEvents.tryEmitNext(scanProgressDto)
|
||||
}
|
||||
|
||||
private val executor = Executors.newFixedThreadPool(16)
|
||||
/** Remove scans which are not running and have finished longer ago than the retention. */
|
||||
private fun evictStaleScanProgress() {
|
||||
val cutoff = Instant.now().minus(SCAN_PROGRESS_RETENTION)
|
||||
latestProgressPerScan.values.removeIf { it.finishedAt?.isBefore(cutoff) == true }
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var scanSemaphore = Semaphore(ConfigProperties.Libraries.Scan.MaxConcurrency.default!!)
|
||||
private val executor: ExecutorService = Executors.newVirtualThreadPerTaskExecutor()
|
||||
private val scansInProgress = ConcurrentHashMap<Long, Boolean>()
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-creates the concurrency semaphore from the current config value.
|
||||
* Called once at the start of each scan so that config changes take
|
||||
* effect without restarting the application.
|
||||
*/
|
||||
private fun refreshScanSemaphore() {
|
||||
val permits = configService.get(ConfigProperties.Libraries.Scan.MaxConcurrency)!!
|
||||
scanSemaphore = Semaphore(permits)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper function to trigger a scan for a list of libraries.
|
||||
*/
|
||||
fun triggerScan(scanType: ScanType, libraryIds: Collection<Long>?) {
|
||||
val libraries = libraryIds?.let { libraryRepository.findAllById(libraryIds) } ?: libraryRepository.findAll()
|
||||
check(pluginService.getAllByTypeAndState(GameMetadataProvider::class, PluginState.STARTED).isNotEmpty()) {
|
||||
"At least one metadata plugin must be enabled to perform a scan."
|
||||
}
|
||||
|
||||
refreshScanSemaphore()
|
||||
evictStaleScanProgress()
|
||||
|
||||
val libraries =
|
||||
if (libraryIds.isNullOrEmpty()) libraryRepository.findAll().toList()
|
||||
else libraryRepository.findAllById(libraryIds).toList()
|
||||
|
||||
libraries.forEach { library ->
|
||||
val libraryId = library.id!!
|
||||
if (scansInProgress.putIfAbsent(libraryId, true) == null) {
|
||||
@@ -100,6 +144,8 @@ class LibraryScanService(
|
||||
)
|
||||
)
|
||||
emit(progress)
|
||||
scanMetrics.recordScanStarted(ScanType.QUICK)
|
||||
val scanStartTime = System.currentTimeMillis()
|
||||
|
||||
try {
|
||||
val scanData = performFilesystemScan(library)
|
||||
@@ -131,20 +177,32 @@ class LibraryScanService(
|
||||
unmatched = newUnmatchedPaths.size
|
||||
)
|
||||
)
|
||||
|
||||
scanMetrics.recordScanCompleted(
|
||||
type = ScanType.QUICK,
|
||||
durationMillis = System.currentTimeMillis() - scanStartTime,
|
||||
newGames = persistedNewGames.size,
|
||||
removedGames = removedGames.size,
|
||||
unmatchedPaths = newUnmatchedPaths.size
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
scanMetrics.recordScanFailed(ScanType.QUICK, System.currentTimeMillis() - scanStartTime)
|
||||
handleScanError(e, library, progress, "quick scan")
|
||||
}
|
||||
}
|
||||
|
||||
private fun fullScan(library: Library, triggeredBySchedule: Boolean) {
|
||||
val scanType = if (triggeredBySchedule) ScanType.SCHEDULED else ScanType.FULL
|
||||
val progress = LibraryScanProgress(
|
||||
libraryId = library.id!!,
|
||||
type = if (triggeredBySchedule) ScanType.SCHEDULED else ScanType.FULL,
|
||||
type = scanType,
|
||||
currentStep = LibraryScanStep(
|
||||
description = "Scanning filesystem"
|
||||
)
|
||||
)
|
||||
emit(progress)
|
||||
scanMetrics.recordScanStarted(scanType)
|
||||
val scanStartTime = System.currentTimeMillis()
|
||||
|
||||
try {
|
||||
val scanData = performFilesystemScan(library)
|
||||
@@ -186,7 +244,17 @@ class LibraryScanService(
|
||||
updated = updatedGames.size
|
||||
)
|
||||
)
|
||||
|
||||
scanMetrics.recordScanCompleted(
|
||||
type = scanType,
|
||||
durationMillis = System.currentTimeMillis() - scanStartTime,
|
||||
newGames = persistedNewGames.size,
|
||||
removedGames = removedGames.size,
|
||||
unmatchedPaths = newUnmatchedPaths.size,
|
||||
updatedGames = updatedGames.size
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
scanMetrics.recordScanFailed(scanType, System.currentTimeMillis() - scanStartTime)
|
||||
handleScanError(e, library, progress, "full scan")
|
||||
}
|
||||
}
|
||||
@@ -273,6 +341,7 @@ class LibraryScanService(
|
||||
|
||||
val tasks = gamePaths.map { path ->
|
||||
Callable<Game?> {
|
||||
scanSemaphore.acquire()
|
||||
try {
|
||||
val persisted = libraryGameProcessor.processNewGame(path, library)
|
||||
|
||||
@@ -305,6 +374,7 @@ class LibraryScanService(
|
||||
|
||||
return@Callable null
|
||||
} finally {
|
||||
scanSemaphore.release()
|
||||
progress.currentStep.current = completed.incrementAndGet()
|
||||
emit(progress)
|
||||
}
|
||||
@@ -374,6 +444,7 @@ class LibraryScanService(
|
||||
|
||||
val updateTasks = games.map { game ->
|
||||
Callable<Game?> {
|
||||
scanSemaphore.acquire()
|
||||
try {
|
||||
val updated = libraryGameProcessor.processExistingGame(game)
|
||||
return@Callable updated
|
||||
@@ -382,6 +453,7 @@ class LibraryScanService(
|
||||
log.debug(e) {}
|
||||
return@Callable null
|
||||
} finally {
|
||||
scanSemaphore.release()
|
||||
progress.currentStep.current = completedUpdates.incrementAndGet()
|
||||
emit(progress)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import org.gameyfin.app.libraries.extensions.toEntity
|
||||
import org.gameyfin.app.users.UserService
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import reactor.core.publisher.Flux
|
||||
import reactor.core.publisher.Sinks
|
||||
import java.time.Instant
|
||||
@@ -72,7 +73,7 @@ class LibraryService(
|
||||
* Retrieves all libraries from the repository.
|
||||
*/
|
||||
fun getAll(): List<LibraryDto> {
|
||||
val entities = libraryRepository.findAll()
|
||||
val entities = libraryRepository.findAll().toList()
|
||||
return entities.toDtos()
|
||||
}
|
||||
|
||||
@@ -119,6 +120,7 @@ class LibraryService(
|
||||
* @return The updated LibraryDto.
|
||||
* @throws IllegalArgumentException if the library ID is null or the library is not found.
|
||||
*/
|
||||
@Transactional
|
||||
fun update(libraryUpdateDto: LibraryUpdateDto) {
|
||||
val library = libraryRepository.findByIdOrNull(libraryUpdateDto.id)
|
||||
?: throw IllegalArgumentException("Library with ID $libraryUpdateDto.id not found")
|
||||
@@ -196,6 +198,7 @@ class LibraryService(
|
||||
/**
|
||||
* Updates multiple libraries in the repository.
|
||||
*/
|
||||
@Transactional
|
||||
fun update(libraries: Collection<LibraryUpdateDto>) {
|
||||
libraries.forEach { update(it) }
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ class LibraryWatcherService(
|
||||
running.set(true)
|
||||
|
||||
// Start watching all existing libraries
|
||||
val libraries = libraryRepository.findAll()
|
||||
val libraries = libraryRepository.findAll().toList()
|
||||
libraries.forEach { library ->
|
||||
startWatchingLibrary(library)
|
||||
}
|
||||
@@ -231,7 +231,7 @@ class LibraryWatcherService(
|
||||
val watchKey = watchService?.poll(1, TimeUnit.SECONDS) ?: continue
|
||||
val watchInfo = watchKeys[watchKey] ?: continue
|
||||
|
||||
val events = watchKey.pollEvents()
|
||||
val events = watchKey.pollEvents().toList()
|
||||
if (events.isEmpty()) {
|
||||
watchKey.reset()
|
||||
continue
|
||||
|
||||
@@ -21,7 +21,7 @@ class IgnoredPath(
|
||||
return when (source) {
|
||||
is IgnoredPathPluginSource -> IgnoredPathSourceType.PLUGIN
|
||||
is IgnoredPathUserSource -> IgnoredPathSourceType.USER
|
||||
else -> throw IllegalStateException("Unknown IgnoredPathSource type")
|
||||
else -> error("Unknown IgnoredPathSource type")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ abstract class IgnoredPathSource(
|
||||
|
||||
@Entity
|
||||
class IgnoredPathPluginSource(
|
||||
@ManyToMany
|
||||
@ManyToMany(fetch = FetchType.EAGER)
|
||||
val plugins: MutableList<PluginManagementEntry>
|
||||
) : IgnoredPathSource()
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ fun IgnoredPath.toDto(): IgnoredPathDto {
|
||||
source = when (val source = this.source) {
|
||||
is IgnoredPathPluginSource -> source.plugins.joinToString("\", \"", "[\"", "\"]") { it.pluginId }
|
||||
is IgnoredPathUserSource -> source.user.id.toString()
|
||||
else -> throw IllegalStateException("Unknown IgnoredPathSource type")
|
||||
else -> error("Unknown IgnoredPathSource type")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -80,6 +80,16 @@ class FileStorageService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the file path for a given content ID.
|
||||
* Returns null if the content ID is null or the file doesn't exist.
|
||||
*/
|
||||
fun getFilePath(contentId: String?): Path? {
|
||||
if (contentId == null) return null
|
||||
val filePath = rootPath.resolve(contentId)
|
||||
return if (filePath.exists()) filePath else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a file exists for the given content ID.
|
||||
*/
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package org.gameyfin.app.media
|
||||
|
||||
import jakarta.persistence.*
|
||||
import org.hibernate.annotations.BatchSize
|
||||
|
||||
@Entity
|
||||
@BatchSize(size = 100)
|
||||
class Image(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.AUTO)
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.gameyfin.app.media
|
||||
import com.vaadin.flow.server.auth.AnonymousAllowed
|
||||
import jakarta.annotation.security.PermitAll
|
||||
import jakarta.annotation.security.RolesAllowed
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import org.gameyfin.app.core.Role
|
||||
import org.gameyfin.app.core.Utils
|
||||
import org.gameyfin.app.core.annotations.DynamicPublicAccess
|
||||
@@ -10,12 +11,13 @@ import org.gameyfin.app.core.plugins.PluginService
|
||||
import org.gameyfin.app.core.security.getCurrentAuth
|
||||
import org.gameyfin.app.users.UserService
|
||||
import org.springframework.core.io.ByteArrayResource
|
||||
import org.springframework.core.io.InputStreamResource
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.core.io.FileSystemResource
|
||||
import org.springframework.core.io.Resource
|
||||
import org.springframework.http.*
|
||||
import org.springframework.web.bind.annotation.*
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
import java.nio.file.Files
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/images")
|
||||
@@ -28,18 +30,18 @@ class ImageEndpoint(
|
||||
) {
|
||||
|
||||
@GetMapping("/screenshot/{id}")
|
||||
fun getScreenshot(@PathVariable id: Long): ResponseEntity<InputStreamResource>? {
|
||||
return getImageContent(id)
|
||||
fun getScreenshot(@PathVariable id: Long, request: HttpServletRequest): ResponseEntity<Resource>? {
|
||||
return getImageContent(id, request)
|
||||
}
|
||||
|
||||
@GetMapping("/cover/{id}")
|
||||
fun getCover(@PathVariable id: Long): ResponseEntity<InputStreamResource>? {
|
||||
return getImageContent(id)
|
||||
fun getCover(@PathVariable id: Long, request: HttpServletRequest): ResponseEntity<Resource>? {
|
||||
return getImageContent(id, request)
|
||||
}
|
||||
|
||||
@GetMapping("/header/{id}")
|
||||
fun getHeader(@PathVariable id: Long): ResponseEntity<InputStreamResource>? {
|
||||
return getImageContent(id)
|
||||
fun getHeader(@PathVariable id: Long, request: HttpServletRequest): ResponseEntity<Resource>? {
|
||||
return getImageContent(id, request)
|
||||
}
|
||||
|
||||
@GetMapping("/plugins/{pluginId}/logo")
|
||||
@@ -49,16 +51,16 @@ class ImageEndpoint(
|
||||
}
|
||||
|
||||
@GetMapping("/avatar")
|
||||
fun getAvatarByUsername(@RequestParam username: String): ResponseEntity<InputStreamResource>? {
|
||||
fun getAvatarByUsername(@RequestParam username: String, request: HttpServletRequest): ResponseEntity<Resource>? {
|
||||
val avatar = userService.getAvatar(username) ?: return ResponseEntity.notFound().build()
|
||||
if (avatar.id == null) return ResponseEntity.notFound().build()
|
||||
return getImageContent(avatar.id!!)
|
||||
return getImageContent(avatar.id!!, request)
|
||||
}
|
||||
|
||||
@PermitAll
|
||||
@PostMapping("/avatar/upload")
|
||||
fun uploadAvatar(@RequestParam("file") file: MultipartFile) {
|
||||
val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
|
||||
val auth = getCurrentAuth() ?: error("No authentication found")
|
||||
|
||||
val image: Image = if (!userService.hasAvatar(auth.name)) {
|
||||
imageService.createFromInputStream(ImageType.AVATAR, file.inputStream, file.contentType!!)
|
||||
@@ -73,7 +75,7 @@ class ImageEndpoint(
|
||||
@PermitAll
|
||||
@PostMapping("/avatar/delete")
|
||||
fun deleteAvatar() {
|
||||
val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
|
||||
val auth = getCurrentAuth() ?: error("No authentication found")
|
||||
userService.deleteAvatar(auth.name)
|
||||
}
|
||||
|
||||
@@ -83,21 +85,44 @@ class ImageEndpoint(
|
||||
userService.deleteAvatar(name)
|
||||
}
|
||||
|
||||
private fun getImageContent(id: Long): ResponseEntity<InputStreamResource> {
|
||||
private fun getImageContent(id: Long, request: HttpServletRequest): ResponseEntity<Resource> {
|
||||
val image = imageService.getImage(id) ?: return ResponseEntity.notFound().build()
|
||||
|
||||
val file = image.let { imageService.getFileContent(it) }
|
||||
// Use contentId as ETag — it changes whenever the file content changes
|
||||
val etag = image.contentId?.let { "\"$it\"" }
|
||||
|
||||
if (file == null) return ResponseEntity.notFound().build()
|
||||
// Check If-None-Match for conditional requests (304 Not Modified)
|
||||
if (etag != null) {
|
||||
val ifNoneMatch = request.getHeader(HttpHeaders.IF_NONE_MATCH)
|
||||
if (ifNoneMatch != null && (ifNoneMatch == etag || ifNoneMatch == "W/$etag")) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_MODIFIED)
|
||||
.eTag(etag)
|
||||
.cacheControl(CacheControl.maxAge(7, TimeUnit.DAYS))
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
val inputStreamResource = InputStreamResource(file)
|
||||
// Resolve the file path on disk for efficient zero-copy serving
|
||||
val filePath = imageService.getFilePath(image) ?: return ResponseEntity.notFound().build()
|
||||
|
||||
val resource = FileSystemResource(filePath)
|
||||
|
||||
val headers = HttpHeaders()
|
||||
image.contentLength?.let { headers.contentLength = it }
|
||||
image.mimeType?.let { headers.contentType = MediaType.parseMediaType(it) }
|
||||
headers.cacheControl = CacheControl.maxAge(7, TimeUnit.DAYS).headerValue
|
||||
etag?.let { headers.eTag = it }
|
||||
|
||||
// Add Last-Modified from the file's modification time
|
||||
try {
|
||||
val lastModified = Files.getLastModifiedTime(filePath).toInstant()
|
||||
headers.lastModified = lastModified.toEpochMilli()
|
||||
} catch (_: Exception) {
|
||||
// Ignore — Last-Modified is optional
|
||||
}
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.headers(headers)
|
||||
.body(inputStreamResource)
|
||||
.body(resource)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.gameyfin.app.media
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache
|
||||
import com.vanniktech.blurhash.BlurHash
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.apache.tika.Tika
|
||||
import org.apache.tika.io.TikaInputStream
|
||||
import org.gameyfin.app.core.events.GameDeletedEvent
|
||||
@@ -10,6 +12,8 @@ import org.gameyfin.app.core.events.UserUpdatedEvent
|
||||
import org.gameyfin.app.games.repositories.GameRepository
|
||||
import org.gameyfin.app.games.repositories.ImageRepository
|
||||
import org.gameyfin.app.users.persistence.UserRepository
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent
|
||||
import org.springframework.context.event.EventListener
|
||||
import org.springframework.dao.DataIntegrityViolationException
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.scheduling.annotation.Async
|
||||
@@ -19,9 +23,11 @@ import org.springframework.transaction.event.TransactionPhase
|
||||
import org.springframework.transaction.event.TransactionalEventListener
|
||||
import java.awt.RenderingHints
|
||||
import java.awt.image.BufferedImage
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.InputStream
|
||||
import java.net.URI
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.StandardCopyOption
|
||||
import javax.imageio.ImageIO
|
||||
|
||||
@Service
|
||||
@@ -29,9 +35,12 @@ class ImageService(
|
||||
private val imageRepository: ImageRepository,
|
||||
private val fileStorageService: FileStorageService,
|
||||
private val gameRepository: GameRepository,
|
||||
private val userRepository: UserRepository
|
||||
private val userRepository: UserRepository,
|
||||
private val imageCache: Cache<Long, Image>
|
||||
) {
|
||||
companion object {
|
||||
private val log = KotlinLogging.logger { }
|
||||
|
||||
private val tika = Tika()
|
||||
|
||||
/**
|
||||
@@ -66,15 +75,24 @@ class ImageService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-populate the image cache at startup
|
||||
*/
|
||||
@EventListener(ApplicationReadyEvent::class)
|
||||
fun prePopulateImageCache() {
|
||||
val images = imageRepository.findAll().toList()
|
||||
images.forEach { image -> image.id?.let { imageCache.put(it, image) } }
|
||||
log.debug { "Pre-populated image cache with ${images.size} entries" }
|
||||
}
|
||||
|
||||
@Async
|
||||
@TransactionalEventListener(
|
||||
classes = [GameDeletedEvent::class],
|
||||
phase = TransactionPhase.AFTER_COMPLETION
|
||||
)
|
||||
fun onGameDeleted(event: GameDeletedEvent) {
|
||||
val imagesToDelete = listOfNotNull(event.game.coverImage, event.game.headerImage)
|
||||
.toMutableList()
|
||||
.apply { addAll(event.game.images) }
|
||||
val imagesToDelete = listOfNotNull(event.game.coverImage, event.game.headerImage) +
|
||||
event.game.images
|
||||
|
||||
imagesToDelete.forEach { deleteImageIfUnused(it) }
|
||||
}
|
||||
@@ -84,14 +102,12 @@ class ImageService(
|
||||
phase = TransactionPhase.AFTER_COMPLETION
|
||||
)
|
||||
fun onGameUpdated(event: GameUpdatedEvent) {
|
||||
val imagesBeforeUpdate = listOfNotNull(event.previousState.coverImage, event.previousState.headerImage)
|
||||
.toMutableList()
|
||||
.apply { addAll(event.previousState.images) }
|
||||
val imagesBeforeUpdate = (listOfNotNull(event.previousState.coverImage, event.previousState.headerImage) +
|
||||
event.previousState.images)
|
||||
.toSet()
|
||||
|
||||
val imagesStillInUse = listOfNotNull(event.currentState.coverImage, event.currentState.headerImage)
|
||||
.toMutableList()
|
||||
.apply { addAll(event.currentState.images) }
|
||||
val imagesStillInUse = (listOfNotNull(event.currentState.coverImage, event.currentState.headerImage) +
|
||||
event.currentState.images)
|
||||
.toSet()
|
||||
|
||||
imagesBeforeUpdate.minus(imagesStillInUse).forEach { deleteImageIfUnused(it) }
|
||||
@@ -122,7 +138,7 @@ class ImageService(
|
||||
val url = image.originalUrl
|
||||
if (url.isNullOrBlank()) {
|
||||
// No original URL => cannot dedupe by URL; just persist as-is
|
||||
return imageRepository.save(image)
|
||||
return imageRepository.save(image).also { saved -> saved.id?.let { imageCache.put(it, saved) } }
|
||||
}
|
||||
|
||||
// Prefer a list lookup to avoid IncorrectResultSizeDataAccessException if duplicates exist pre-migration
|
||||
@@ -131,7 +147,7 @@ class ImageService(
|
||||
|
||||
return try {
|
||||
val toSave = Image(originalUrl = url, type = image.type)
|
||||
imageRepository.save(toSave)
|
||||
imageRepository.save(toSave).also { saved -> saved.id?.let { imageCache.put(it, saved) } }
|
||||
} catch (e: DataIntegrityViolationException) {
|
||||
// Unique (original_url) might have been inserted concurrently; fetch and return
|
||||
imageRepository.findAllByOriginalUrl(url).firstOrNull()
|
||||
@@ -140,7 +156,7 @@ class ImageService(
|
||||
}
|
||||
|
||||
fun downloadIfNew(image: Image) {
|
||||
if (image.originalUrl == null) throw IllegalArgumentException("Image must have an original URL")
|
||||
requireNotNull(image.originalUrl) { "Image must have an original URL" }
|
||||
|
||||
// Always try to get existing image first to avoid detached entity issues and duplicate lookups
|
||||
val existingImage = imageRepository.findAllByOriginalUrl(image.originalUrl).firstOrNull()
|
||||
@@ -165,7 +181,7 @@ class ImageService(
|
||||
|
||||
// Save or update the image to ensure it's persisted
|
||||
try {
|
||||
imageRepository.save(image)
|
||||
imageRepository.save(image).also { saved -> saved.id?.let { imageCache.put(it, saved) } }
|
||||
} catch (_: DataIntegrityViolationException) {
|
||||
// If another thread saved the same URL meanwhile, just ignore and proceed
|
||||
}
|
||||
@@ -174,23 +190,31 @@ class ImageService(
|
||||
fun createFromInputStream(type: ImageType, content: InputStream, mimeType: String): Image {
|
||||
val image = Image(type = type, mimeType = mimeType)
|
||||
processImageContent(image, content)
|
||||
return imageRepository.save(image)
|
||||
return imageRepository.save(image).also { saved -> saved.id?.let { imageCache.put(it, saved) } }
|
||||
}
|
||||
|
||||
fun getImage(id: Long): Image? {
|
||||
return imageRepository.findByIdOrNull(id)
|
||||
imageCache.getIfPresent(id)?.let { return it }
|
||||
val image = imageRepository.findByIdOrNull(id)
|
||||
if (image != null) imageCache.put(id, image)
|
||||
return image
|
||||
}
|
||||
|
||||
fun getFileContent(image: Image): InputStream? {
|
||||
return fileStorageService.getFile(image.contentId)
|
||||
}
|
||||
|
||||
fun getFilePath(image: Image): Path? {
|
||||
return fileStorageService.getFilePath(image.contentId)
|
||||
}
|
||||
|
||||
fun deleteImageIfUnused(image: Image) {
|
||||
val imageId = image.id ?: return
|
||||
|
||||
val isImageStillInUse = gameRepository.existsByImage(imageId) || userRepository.existsByAvatar(imageId)
|
||||
|
||||
if (!isImageStillInUse) {
|
||||
imageCache.invalidate(imageId)
|
||||
imageRepository.delete(image)
|
||||
fileStorageService.deleteFile(image.contentId)
|
||||
}
|
||||
@@ -205,6 +229,9 @@ class ImageService(
|
||||
// Process and store new content
|
||||
processImageContent(image, content)
|
||||
|
||||
// Invalidate cache so the next read picks up fresh data
|
||||
image.id?.let { imageCache.invalidate(it) }
|
||||
|
||||
return imageRepository.save(image)
|
||||
}
|
||||
|
||||
@@ -216,40 +243,57 @@ class ImageService(
|
||||
}
|
||||
|
||||
private fun processImageContent(image: Image, content: InputStream) {
|
||||
// Read the input stream into a byte array so we can use it twice
|
||||
val imageBytes = content.readBytes()
|
||||
// Stream to a temp file to avoid holding the full image bytes on the heap.
|
||||
// This is critical during library scans where multiple images are processed
|
||||
// concurrently — buffering each one as a byte[] can easily cause OOM.
|
||||
val tempFile = Files.createTempFile("gf-img-", ".tmp")
|
||||
try {
|
||||
// 1. Write the stream to disk
|
||||
content.use { input ->
|
||||
Files.copy(input, tempFile, StandardCopyOption.REPLACE_EXISTING)
|
||||
}
|
||||
|
||||
// Calculate blurhash
|
||||
ByteArrayInputStream(imageBytes).use { blurhashStream ->
|
||||
image.blurhash = calculateBlurhash(blurhashStream)
|
||||
}
|
||||
val fileSize = Files.size(tempFile)
|
||||
|
||||
// Store content
|
||||
ByteArrayInputStream(imageBytes).use { contentStream ->
|
||||
image.contentId = fileStorageService.saveFile(contentStream)
|
||||
image.contentLength = imageBytes.size.toLong()
|
||||
// 2. Calculate blurhash from the temp file
|
||||
Files.newInputStream(tempFile).use { blurhashStream ->
|
||||
image.blurhash = calculateBlurhash(blurhashStream)
|
||||
}
|
||||
|
||||
// 3. Store content from the temp file
|
||||
Files.newInputStream(tempFile).use { contentStream ->
|
||||
image.contentId = fileStorageService.saveFile(contentStream)
|
||||
image.contentLength = fileSize
|
||||
}
|
||||
} finally {
|
||||
Files.deleteIfExists(tempFile)
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateBlurhash(inputStream: InputStream): String? {
|
||||
return try {
|
||||
val originalImage = ImageIO.read(inputStream)
|
||||
if (originalImage != null) {
|
||||
// Scale down for much faster processing
|
||||
val originalImage = ImageIO.read(inputStream) ?: return null
|
||||
try {
|
||||
// Scale down for much faster processing and less memory
|
||||
val scaledImage = scaleImageForBlurhash(originalImage)
|
||||
|
||||
return if (scaledImage.width > scaledImage.height) {
|
||||
// Landscape
|
||||
BlurHash.encode(scaledImage, componentX = 4, componentY = 3)
|
||||
} else if (scaledImage.width < scaledImage.height) {
|
||||
// Portrait
|
||||
BlurHash.encode(scaledImage, componentX = 3, componentY = 4)
|
||||
} else {
|
||||
// Square
|
||||
BlurHash.encode(scaledImage, componentX = 3, componentY = 3)
|
||||
try {
|
||||
return if (scaledImage.width > scaledImage.height) {
|
||||
// Landscape
|
||||
BlurHash.encode(scaledImage, componentX = 4, componentY = 3)
|
||||
} else if (scaledImage.width < scaledImage.height) {
|
||||
// Portrait
|
||||
BlurHash.encode(scaledImage, componentX = 3, componentY = 4)
|
||||
} else {
|
||||
// Square
|
||||
BlurHash.encode(scaledImage, componentX = 3, componentY = 3)
|
||||
}
|
||||
} finally {
|
||||
// Release scaled image native memory immediately
|
||||
if (scaledImage !== originalImage) scaledImage.flush()
|
||||
}
|
||||
} else {
|
||||
null
|
||||
} finally {
|
||||
// Release original image native memory immediately
|
||||
originalImage.flush()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
|
||||
@@ -61,8 +61,8 @@ class MessageService(
|
||||
}
|
||||
|
||||
try {
|
||||
val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
|
||||
val user = userService.getByUsername(auth.name) ?: throw IllegalStateException("User not found")
|
||||
val auth = getCurrentAuth() ?: error("No authentication found")
|
||||
val user = userService.getByUsername(auth.name) ?: error("User not found")
|
||||
val template = templateService.getMessageTemplate(templateKey)
|
||||
sendNotification(user.email, "[Gameyfin] Test Notification", template, placeholders)
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -40,8 +40,8 @@ class MessageTemplateService {
|
||||
}
|
||||
|
||||
fun fillMessageTemplate(template: MessageTemplates, type: TemplateType, placeholders: Map<String, String>): String {
|
||||
if (placeholders.keys != template.availablePlaceholders.toSet()) {
|
||||
throw IllegalArgumentException("Placeholders do not match available placeholders for template '${template.key}'")
|
||||
require(placeholders.keys == template.availablePlaceholders.toSet()) {
|
||||
"Placeholders do not match available placeholders for template '${template.key}'"
|
||||
}
|
||||
|
||||
val content = getMessageTemplateContent(template.key, type)
|
||||
@@ -82,7 +82,7 @@ class MessageTemplateService {
|
||||
private fun getDefaultTemplateFile(key: String, type: TemplateType): Path {
|
||||
log.debug { "No custom message template found for '$key.${type.extension}', returning default" }
|
||||
val resourceUrl = javaClass.classLoader.getResource("$DEFAULT_TEMPLATE_PATH/$key.${type.extension}")
|
||||
?: throw IllegalStateException("Default template file not found for '$key.${type.extension}'")
|
||||
?: error("Default template file not found for '$key.${type.extension}'")
|
||||
return Paths.get(resourceUrl.toURI())
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ class GameRequestService(
|
||||
}
|
||||
|
||||
fun getAll(): List<GameRequestDto> {
|
||||
val entities = gameRequestRepository.findAll()
|
||||
val entities = gameRequestRepository.findAll().toList()
|
||||
return entities.toDtos()
|
||||
}
|
||||
|
||||
@@ -177,9 +177,9 @@ class GameRequestService(
|
||||
|
||||
@Transactional
|
||||
fun toggleRequestVote(id: Long) {
|
||||
val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
|
||||
val auth = getCurrentAuth() ?: error("No authentication found")
|
||||
val currentUser =
|
||||
userService.getByUsername(auth.name) ?: throw IllegalStateException("Current user not found")
|
||||
userService.getByUsername(auth.name) ?: error("Current user not found")
|
||||
val gameRequest = gameRequestRepository.findById(id)
|
||||
.orElseThrow { NoSuchElementException("No game request found with id $id") }
|
||||
|
||||
@@ -192,8 +192,6 @@ class GameRequestService(
|
||||
}
|
||||
gameRequest.voters = updatedVoters
|
||||
|
||||
// Ensure the entity is marked as dirty
|
||||
gameRequest.status = gameRequest.status
|
||||
|
||||
gameRequestRepository.save(gameRequest)
|
||||
}
|
||||
@@ -201,7 +199,7 @@ class GameRequestService(
|
||||
private fun completeMatchingRequests(game: Game) {
|
||||
val gameTitle = game.title
|
||||
val gameRelease = game.release
|
||||
val gamePlatforms = game.platforms
|
||||
val gamePlatforms = game.platforms.toList()
|
||||
|
||||
if (gameTitle == null) {
|
||||
log.warn { "Game '${game.id}' is missing title, cannot complete matching requests" }
|
||||
|
||||
@@ -17,6 +17,11 @@ class UserEndpoint(
|
||||
private val userService: UserService,
|
||||
private val roleService: RoleService
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val NO_AUTH_FOUND = "No authentication found"
|
||||
}
|
||||
|
||||
@AnonymousAllowed
|
||||
fun getUserInfo(): ExtendedUserInfoDto? {
|
||||
val auth = getCurrentAuth()
|
||||
@@ -26,7 +31,7 @@ class UserEndpoint(
|
||||
|
||||
@PermitAll
|
||||
fun updateUser(updates: UserUpdateDto) {
|
||||
val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
|
||||
val auth = getCurrentAuth() ?: error(NO_AUTH_FOUND)
|
||||
userService.updateUser(auth.name, updates)
|
||||
}
|
||||
|
||||
@@ -57,7 +62,7 @@ class UserEndpoint(
|
||||
|
||||
@PermitAll
|
||||
fun deleteUser() {
|
||||
val auth: Authentication = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
|
||||
val auth: Authentication = getCurrentAuth() ?: error(NO_AUTH_FOUND)
|
||||
userService.deleteUser(auth.name)
|
||||
}
|
||||
|
||||
@@ -73,7 +78,7 @@ class UserEndpoint(
|
||||
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun getRolesBelow(): List<String> {
|
||||
val auth: Authentication = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
|
||||
val auth: Authentication = getCurrentAuth() ?: error(NO_AUTH_FOUND)
|
||||
return roleService.getRolesBelowAuth(auth).map { it.roleName }
|
||||
}
|
||||
|
||||
|
||||
@@ -42,8 +42,12 @@ class UserService(
|
||||
private val eventPublisher: ApplicationEventPublisher
|
||||
) : UserDetailsService {
|
||||
|
||||
private val log = KotlinLogging.logger {}
|
||||
companion object {
|
||||
private const val NO_AUTH_FOUND = "No authentication found"
|
||||
|
||||
private val log = KotlinLogging.logger {}
|
||||
}
|
||||
|
||||
val selfRegistrationAllowed: Boolean
|
||||
get() = config.get(ConfigProperties.Users.SignUps.Allow) == true
|
||||
|
||||
@@ -106,7 +110,7 @@ class UserService(
|
||||
}
|
||||
|
||||
fun getUserInfo(): ExtendedUserInfoDto {
|
||||
val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
|
||||
val auth = getCurrentAuth() ?: error(NO_AUTH_FOUND)
|
||||
val principal = auth.principal
|
||||
|
||||
if (principal is OidcUser) {
|
||||
@@ -159,13 +163,8 @@ class UserService(
|
||||
}
|
||||
|
||||
fun selfRegisterUser(registration: UserRegistrationDto) {
|
||||
if (!selfRegistrationAllowed) {
|
||||
throw IllegalStateException("Sign ups are not allowed")
|
||||
}
|
||||
|
||||
if (existsByUsername(registration.username)) {
|
||||
throw IllegalStateException("User with username '${registration.username}' already exists")
|
||||
}
|
||||
check(selfRegistrationAllowed) { "Sign ups are not allowed" }
|
||||
check(!existsByUsername(registration.username)) { "User with username '${registration.username}' already exists" }
|
||||
|
||||
userRepository.findByEmail(registration.email)?.let {
|
||||
eventPublisher.publishEvent(
|
||||
@@ -216,7 +215,7 @@ class UserService(
|
||||
)
|
||||
|
||||
if (existsByUsername(user.username)) {
|
||||
throw IllegalStateException("User with username '${user.username}' already exists")
|
||||
error("User with username '${user.username}' already exists")
|
||||
}
|
||||
|
||||
return userRepository.save(user)
|
||||
@@ -252,7 +251,7 @@ class UserService(
|
||||
return RoleAssignmentResult.NO_ROLES_PROVIDED
|
||||
}
|
||||
|
||||
val currentUser = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
|
||||
val currentUser = getCurrentAuth() ?: error(NO_AUTH_FOUND)
|
||||
val targetUser = getByUsernameNonNull(username)
|
||||
|
||||
if (!canManage(targetUser)) {
|
||||
@@ -280,7 +279,7 @@ class UserService(
|
||||
}
|
||||
|
||||
fun canManage(targetUser: org.gameyfin.app.users.entities.User): Boolean {
|
||||
val currentUser = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
|
||||
val currentUser = getCurrentAuth() ?: error(NO_AUTH_FOUND)
|
||||
val currentUserLevel = roleService.getHighestRoleFromAuthorities(currentUser.authorities).powerLevel
|
||||
val targetUserLevel = roleService.getHighestRole(targetUser.roles).powerLevel
|
||||
return currentUserLevel > targetUserLevel
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ class EmailConfirmationEndpoint(
|
||||
|
||||
@PermitAll
|
||||
fun resendEmailConfirmation() {
|
||||
val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
|
||||
val auth = getCurrentAuth() ?: error("No authentication found")
|
||||
userService.getByUsername(auth.name)?.let {
|
||||
emailConfirmationService.resendEmailConfirmation(it)
|
||||
}
|
||||
|
||||
@@ -46,4 +46,12 @@ class User(
|
||||
enabled = true,
|
||||
oidcProviderId = oidcUser.subject
|
||||
)
|
||||
|
||||
constructor(oidcUser: OidcUser, resolvedUsername: String) : this(
|
||||
username = resolvedUsername,
|
||||
email = oidcUser.email,
|
||||
emailConfirmed = true,
|
||||
enabled = true,
|
||||
oidcProviderId = oidcUser.subject
|
||||
)
|
||||
}
|
||||
@@ -19,7 +19,7 @@ class UserEntityListener {
|
||||
val gameRequests = entityManager.createQuery(
|
||||
"SELECT gr FROM GameRequest gr WHERE :user MEMBER OF gr.voters OR gr.requester = :user",
|
||||
GameRequest::class.java
|
||||
).setParameter("user", user).resultList
|
||||
).setParameter("user", user).resultList.toList()
|
||||
for (gr in gameRequests) {
|
||||
gr.voters.remove(user)
|
||||
if (gr.requester == user) gr.requester = null
|
||||
|
||||
@@ -26,10 +26,7 @@ class PasswordResetService(
|
||||
private val secureRandom = SecureRandom()
|
||||
|
||||
override fun generate(user: User): Token<TokenType.PasswordReset> {
|
||||
if (user.oidcProviderId != null) {
|
||||
throw IllegalStateException("Cannot create password reset token for user '${user.username}' because user is managed externally")
|
||||
}
|
||||
|
||||
check(user.oidcProviderId == null) { "Cannot create password reset token for user '${user.username}' because user is managed externally" }
|
||||
return super.generate(user)
|
||||
}
|
||||
|
||||
@@ -44,9 +41,7 @@ class PasswordResetService(
|
||||
val user = userService.getByUsername(username)
|
||||
?: throw IllegalArgumentException("Cannot create password reset token for user '$username' because user does not exist")
|
||||
|
||||
if (messageService.enabled && user.emailConfirmed) {
|
||||
throw IllegalStateException("Cannot create password reset token for user '$username' because self-service is enabled")
|
||||
}
|
||||
check(!(messageService.enabled && user.emailConfirmed)) { "Cannot create password reset token for user '$username' because self-service is enabled" }
|
||||
|
||||
val token = generate(user)
|
||||
return TokenDto(token)
|
||||
|
||||
@@ -135,7 +135,7 @@ class UserPreferencesService(
|
||||
}
|
||||
|
||||
private fun id(key: String): UserPreferenceKey {
|
||||
val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
|
||||
val auth = getCurrentAuth() ?: error("No authentication found")
|
||||
val user = userService.getByUsernameNonNull(auth.name)
|
||||
return UserPreferenceKey(key, user.id!!)
|
||||
}
|
||||
|
||||
@@ -26,11 +26,10 @@ class InvitationService(
|
||||
}
|
||||
|
||||
fun createInvitation(email: String): TokenDto {
|
||||
if (userService.existsByEmail(email))
|
||||
throw IllegalStateException("User with email ${Utils.maskEmail(email)} is already registered")
|
||||
check(!(userService.existsByEmail(email))) { "User with email ${Utils.maskEmail(email)} is already registered" }
|
||||
|
||||
val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
|
||||
val user = userService.getByUsername(auth.name) ?: throw IllegalStateException("User not found")
|
||||
val auth = getCurrentAuth() ?: error("No authentication found")
|
||||
val user = userService.getByUsername(auth.name) ?: error("User not found")
|
||||
val payload = mapOf(EMAIL_KEY to email)
|
||||
val token = super.generateWithPayload(user, payload)
|
||||
|
||||
|
||||
@@ -15,6 +15,6 @@ object EntityManagerHolder : ApplicationContextAware {
|
||||
}
|
||||
|
||||
fun getEntityManager(): EntityManager {
|
||||
return entityManager ?: throw IllegalStateException("EntityManager not set")
|
||||
return entityManager ?: error("EntityManager not set")
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ object BlurhashMigration {
|
||||
* This method is called from Flyway migration V2.3.0.6.
|
||||
* Uses multithreading and batch updates for performance.
|
||||
*/
|
||||
@Suppress("kotlin:S3776")
|
||||
@JvmStatic
|
||||
fun calculateBlurhashesForAllImages(conn: Connection, dataPath: String) {
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# ── Spring profile activated only during AOT cache training ────────────
|
||||
# Overrides that let the app boot from scratch in an ephemeral container
|
||||
# without a pre-existing database or real external services.
|
||||
|
||||
logging.level:
|
||||
root: warn
|
||||
org.gameyfin: info
|
||||
|
||||
spring:
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: none
|
||||
flyway:
|
||||
baseline-on-migrate: true
|
||||
baseline-version: 0
|
||||
|
||||
server:
|
||||
shutdown: graceful
|
||||
|
||||
spring.lifecycle:
|
||||
timeout-per-shutdown-phase: 15s
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
logging.level:
|
||||
root: info
|
||||
org.gameyfin.GameyfinApplicationKt: info
|
||||
org.gameyfin: debug
|
||||
org.gameyfin.app.GameyfinApplicationKt: info
|
||||
org.flywaydb.core.internal.database.base: warn
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
|
||||
@@ -4,7 +4,8 @@ logging.level:
|
||||
org.gameyfin: info
|
||||
org.flywaydb.core.internal.command: info
|
||||
org.flywaydb.core.internal.sqlscript: warn
|
||||
org.gameyfin.GameyfinApplicationKt: warn
|
||||
org.flywaydb.core.internal.database.base: error
|
||||
org.gameyfin.app.GameyfinApplicationKt: warn
|
||||
# Suppress false positive warnings from Spring Security 6
|
||||
org.springframework.security.config.annotation.authentication.configuration.InitializeUserDetailsBeanManagerConfigurer: error
|
||||
|
||||
@@ -13,12 +14,19 @@ server:
|
||||
servlet:
|
||||
session:
|
||||
tracking-modes: cookie
|
||||
timeout: 24h
|
||||
timeout: 4h
|
||||
forward-headers-strategy: framework
|
||||
jetty:
|
||||
tomcat:
|
||||
remoteip:
|
||||
protocol-header: X-Forwarded-Proto
|
||||
remote-ip-header: X-Forwarded-For
|
||||
threads:
|
||||
max: 200
|
||||
min: 8
|
||||
min-spare: 10
|
||||
max-connections: 10_000
|
||||
max-keep-alive-requests: 100
|
||||
connection-timeout: 10m
|
||||
keep-alive-timeout: 10m
|
||||
|
||||
management:
|
||||
server:
|
||||
@@ -26,42 +34,49 @@ management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: restart, health, info, metrics, prometheus
|
||||
include: restart, health, metrics, prometheus, info
|
||||
endpoint:
|
||||
pause:
|
||||
enabled: false
|
||||
restart:
|
||||
access: unrestricted
|
||||
health:
|
||||
group:
|
||||
readiness:
|
||||
include: pluginsLoaded
|
||||
|
||||
spring:
|
||||
# Workaround for https://github.com/vaadin/hilla/issues/842
|
||||
devtools.restart.additional-exclude: dev/hilla/openapi.json
|
||||
jpa:
|
||||
# defer-datasource-initialization: true
|
||||
hibernate:
|
||||
ddl-auto: validate
|
||||
open-in-view: true
|
||||
mustache:
|
||||
check-template-location: false
|
||||
sql.init.mode: always
|
||||
datasource:
|
||||
username: gfadmin
|
||||
password: gameyfin
|
||||
db-name: gameyfin_db
|
||||
url: jdbc:h2:file:./db/${spring.datasource.db-name}
|
||||
driverClassName: org.h2.Driver
|
||||
content:
|
||||
fs.filesystem-root: ./data/
|
||||
application:
|
||||
name: Gameyfin
|
||||
version: @project.version@
|
||||
threads:
|
||||
virtual.enabled: true
|
||||
mvc:
|
||||
async.request-timeout: 0
|
||||
sql.init.mode: always
|
||||
datasource:
|
||||
username: gfadmin
|
||||
password: gameyfin
|
||||
db-name: gameyfin_db
|
||||
url: jdbc:h2:file:./db/${spring.datasource.db-name};CACHE_SIZE=16384
|
||||
driverClassName: org.h2.Driver
|
||||
hikari:
|
||||
maximum-pool-size: 10
|
||||
connection-timeout: 60_000
|
||||
flyway:
|
||||
baseline-on-migrate: true
|
||||
baseline-version: 2.0.0
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: validate
|
||||
open-in-view: false
|
||||
properties:
|
||||
hibernate:
|
||||
jdbc.batch_size: 25
|
||||
order_inserts: true
|
||||
order_updates: true
|
||||
content:
|
||||
fs.filesystem-root: ./data/
|
||||
mvc:
|
||||
async.request-timeout: 0
|
||||
|
||||
vaadin:
|
||||
# To improve the performance during development.
|
||||
|
||||
+15
-8
@@ -1,4 +1,4 @@
|
||||
-- Flyway Migration: V2.4.0
|
||||
-- Flyway Migration: V2.4.0.1
|
||||
-- Purpose: Refactor TOKEN table to support encryption on secret field by separating primary key from secret.
|
||||
-- Context: Hibernate 6.x (Spring Boot 4) does not allow AttributeConverter on @Id fields.
|
||||
-- The secret field contains sensitive token data (password reset tokens, etc.) that needs encryption.
|
||||
@@ -6,25 +6,32 @@
|
||||
-- Modify the existing TOKEN table in-place by adding a new ID column and restructuring constraints.
|
||||
|
||||
-- Step 1: Add new ID column (nullable initially to allow data population)
|
||||
ALTER TABLE TOKEN ADD COLUMN ID CHARACTER VARYING(255);
|
||||
ALTER TABLE TOKEN
|
||||
ADD COLUMN ID CHARACTER VARYING(255);
|
||||
|
||||
-- Step 2: Populate ID column with new UUIDs for existing rows
|
||||
UPDATE TOKEN SET ID = RANDOM_UUID() WHERE ID IS NULL;
|
||||
UPDATE TOKEN
|
||||
SET ID = RANDOM_UUID()
|
||||
WHERE ID IS NULL;
|
||||
|
||||
-- Step 3: Make ID column non-null now that it has values
|
||||
ALTER TABLE TOKEN ALTER COLUMN ID SET NOT NULL;
|
||||
ALTER TABLE TOKEN
|
||||
ALTER COLUMN ID SET NOT NULL;
|
||||
|
||||
-- Step 4: Drop the primary key constraint on SECRET
|
||||
-- H2 uses auto-generated constraint names, so we need to find and drop it
|
||||
-- The primary key constraint is typically named PRIMARY_KEY_XXX or CONSTRAINT_XXX
|
||||
ALTER TABLE TOKEN DROP PRIMARY KEY;
|
||||
ALTER TABLE TOKEN
|
||||
DROP PRIMARY KEY;
|
||||
|
||||
-- Step 5: Add primary key constraint on ID
|
||||
ALTER TABLE TOKEN ADD PRIMARY KEY (ID);
|
||||
ALTER TABLE TOKEN
|
||||
ADD PRIMARY KEY (ID);
|
||||
|
||||
-- Step 6: Add unique constraint on SECRET (it was previously the primary key, so it was already unique)
|
||||
-- The SECRET column should remain unique for lookups
|
||||
ALTER TABLE TOKEN ADD CONSTRAINT UK_TOKEN_SECRET UNIQUE (SECRET);
|
||||
ALTER TABLE TOKEN
|
||||
ADD CONSTRAINT UK_TOKEN_SECRET UNIQUE (SECRET);
|
||||
|
||||
-- Step 7: Create index on SECRET for fast lookups
|
||||
CREATE INDEX IDX_TOKEN_SECRET ON TOKEN(SECRET);
|
||||
CREATE INDEX IDX_TOKEN_SECRET ON TOKEN (SECRET);
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
-- Flyway Migration: V2.4.0.2
|
||||
-- Purpose: Make PLUGIN_CONFIG.value column unbounded to avoid length errors when storing large values.
|
||||
-- Context: Previously defined as CHARACTER VARYING(255); H2 raised 22001 (value too long).
|
||||
-- Strategy: Alter column type to CLOB (unlimited length in H2). This matches other large text usages (e.g., COMMENT, SUMMARY) which use CHARACTER LARGE OBJECT.
|
||||
|
||||
ALTER TABLE PLUGIN_CONFIG
|
||||
ALTER COLUMN "value" CLOB;
|
||||
@@ -330,7 +330,7 @@ class ConfigServiceTest {
|
||||
result.forEach { entry ->
|
||||
assertNotNull(entry.key)
|
||||
assertNotNull(entry.type)
|
||||
assertNotNull(entry.description)
|
||||
assertNotNull(entry.name)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package org.gameyfin.app.core.download.files
|
||||
|
||||
import io.mockk.*
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry
|
||||
import org.gameyfin.app.config.ConfigProperties
|
||||
import org.gameyfin.app.config.ConfigService
|
||||
import org.gameyfin.app.core.download.bandwidth.SessionBandwidthManager
|
||||
import org.gameyfin.app.core.download.bandwidth.SessionBandwidthTracker
|
||||
import org.gameyfin.app.core.metrics.DownloadMetrics
|
||||
import org.gameyfin.app.core.plugins.management.GameyfinPluginDescriptor
|
||||
import org.gameyfin.app.core.plugins.management.GameyfinPluginManager
|
||||
import org.gameyfin.app.games.entities.Game
|
||||
@@ -35,7 +37,8 @@ class DownloadServiceTest {
|
||||
pluginManager = mockk<GameyfinPluginManager>(relaxed = true)
|
||||
configService = mockk<ConfigService>(relaxed = true)
|
||||
sessionBandwidthManager = mockk<SessionBandwidthManager>(relaxed = true)
|
||||
service = DownloadService(pluginManager, configService, sessionBandwidthManager)
|
||||
val downloadMetrics = DownloadMetrics(SimpleMeterRegistry(), sessionBandwidthManager)
|
||||
service = DownloadService(pluginManager, configService, sessionBandwidthManager, downloadMetrics)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
package org.gameyfin.app.core.plugins
|
||||
|
||||
import io.mockk.*
|
||||
import org.gameyfin.app.core.plugins.config.PluginConfigEntry
|
||||
import org.gameyfin.app.core.plugins.config.PluginConfigEntryKey
|
||||
import org.gameyfin.app.core.plugins.config.PluginConfigRepository
|
||||
import org.gameyfin.app.core.plugins.dto.PluginUpdateDto
|
||||
import org.gameyfin.app.core.plugins.management.*
|
||||
import org.gameyfin.pluginapi.core.config.ConfigMetadata
|
||||
import org.gameyfin.pluginapi.core.config.Configurable
|
||||
import org.gameyfin.pluginapi.core.config.PluginConfigValidationResult
|
||||
import org.gameyfin.pluginapi.core.config.PluginConfigValidationResultType
|
||||
import org.gameyfin.pluginapi.core.wrapper.GameyfinPlugin
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertDoesNotThrow
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.pf4j.ExtensionPoint
|
||||
import org.pf4j.Plugin
|
||||
import org.pf4j.PluginState
|
||||
import org.pf4j.PluginWrapper
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import reactor.test.StepVerifier
|
||||
import kotlin.test.*
|
||||
|
||||
class PluginServiceTest {
|
||||
|
||||
private lateinit var pluginManager: GameyfinPluginManager
|
||||
private lateinit var pluginManagementRepository: PluginManagementRepository
|
||||
private lateinit var pluginConfigRepository: PluginConfigRepository
|
||||
private lateinit var service: PluginService
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
pluginManager = mockk(relaxed = true)
|
||||
pluginManagementRepository = mockk(relaxed = true)
|
||||
pluginConfigRepository = mockk(relaxed = true)
|
||||
|
||||
// Default stubs
|
||||
every { pluginManager.plugins } returns emptyList()
|
||||
every { pluginConfigRepository.findAllByPluginId(any()) } returns emptyList()
|
||||
every { pluginManagementRepository.findByIdOrNull(any()) } returns null
|
||||
|
||||
service = PluginService(pluginManager, pluginManagementRepository, pluginConfigRepository)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
unmockkAll()
|
||||
clearAllMocks()
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// subscribe / emit
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `subscribe should return a flux that receives emitted updates`() {
|
||||
val update = PluginUpdateDto("plugin1", PluginState.STARTED)
|
||||
|
||||
val flux = PluginService.subscribe()
|
||||
PluginService.emit(update)
|
||||
|
||||
StepVerifier.create(flux.take(1))
|
||||
.assertNext { batch -> assertTrue(batch.contains(update)) }
|
||||
.verifyComplete()
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// getSupportedPluginTypes
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `getSupportedPluginTypes should aggregate extension types from all plugins`() {
|
||||
val wrapper1 = mockPluginWrapper("p1")
|
||||
val wrapper2 = mockPluginWrapper("p2")
|
||||
|
||||
every { pluginManager.plugins } returns listOf(wrapper1, wrapper2)
|
||||
every { pluginManager.getExtensionTypes("p1") } returns listOf("DownloadProvider")
|
||||
every { pluginManager.getExtensionTypes("p2") } returns listOf("GameMetadataProvider")
|
||||
|
||||
val result = service.getSupportedPluginTypes()
|
||||
|
||||
assertEquals(listOf("DownloadProvider", "GameMetadataProvider"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getSupportedPluginTypes should return empty list when no plugins are registered`() {
|
||||
every { pluginManager.plugins } returns emptyList()
|
||||
|
||||
val result = service.getSupportedPluginTypes()
|
||||
|
||||
assertTrue(result.isEmpty())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// getAll
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `getAll should return a PluginDto for every loaded plugin`() {
|
||||
val wrapper = buildFullPluginWrapper("my-plugin")
|
||||
every { pluginManager.plugins } returns listOf(wrapper)
|
||||
|
||||
val result = service.getAll()
|
||||
|
||||
assertEquals(1, result.size)
|
||||
assertEquals("my-plugin", result[0].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getAll should return empty list when no plugins are loaded`() {
|
||||
every { pluginManager.plugins } returns emptyList()
|
||||
|
||||
assertTrue(service.getAll().isEmpty())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// getAllByTypeAndState
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `getAllByTypeAndState should only return plugins matching the given state`() {
|
||||
val startedWrapper = buildFullPluginWrapper("started-plugin", PluginState.STARTED)
|
||||
val stoppedWrapper = buildFullPluginWrapper("stopped-plugin", PluginState.STOPPED)
|
||||
|
||||
every { pluginManager.getPluginsForExtension(GameMetadataProviderStub::class) } returns
|
||||
listOf(startedWrapper, stoppedWrapper)
|
||||
|
||||
val result = service.getAllByTypeAndState(GameMetadataProviderStub::class, PluginState.STARTED)
|
||||
|
||||
assertEquals(1, result.size)
|
||||
assertEquals("started-plugin", result[0].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getAllByTypeAndState should return empty list when no plugins match`() {
|
||||
every { pluginManager.getPluginsForExtension(GameMetadataProviderStub::class) } returns emptyList()
|
||||
|
||||
val result = service.getAllByTypeAndState(GameMetadataProviderStub::class, PluginState.STARTED)
|
||||
|
||||
assertTrue(result.isEmpty())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// getPluginManagementEntry
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `getPluginManagementEntry should return entry for the plugin owning the class`() {
|
||||
val wrapper = mockPluginWrapper("owner-plugin")
|
||||
val entry = PluginManagementEntry("owner-plugin", enabled = true)
|
||||
|
||||
every { pluginManager.whichPlugin(GameMetadataProviderStub::class.java) } returns wrapper
|
||||
every { pluginManagementRepository.findByIdOrNull("owner-plugin") } returns entry
|
||||
|
||||
val result = service.getPluginManagementEntry(GameMetadataProviderStub::class.java)
|
||||
|
||||
assertEquals(entry, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getPluginManagementEntry should throw when no management entry exists`() {
|
||||
val wrapper = mockPluginWrapper("missing-plugin")
|
||||
every { pluginManager.whichPlugin(GameMetadataProviderStub::class.java) } returns wrapper
|
||||
every { pluginManagementRepository.findByIdOrNull("missing-plugin") } returns null
|
||||
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
service.getPluginManagementEntry(GameMetadataProviderStub::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// getPluginManagementEntries
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `getPluginManagementEntries should return entries for started plugins with matching type`() {
|
||||
val wrapper = buildFullPluginWrapper("started-plugin", PluginState.STARTED)
|
||||
val entry = PluginManagementEntry("started-plugin", enabled = true)
|
||||
|
||||
every { pluginManager.plugins } returns listOf(wrapper)
|
||||
every { pluginManager.getExtensionTypes("started-plugin") } returns listOf("GameMetadataProviderStub")
|
||||
every { pluginManagementRepository.findByIdOrNull("started-plugin") } returns entry
|
||||
|
||||
val result = service.getPluginManagementEntries(GameMetadataProviderStub::class.java, enabledOnly = true)
|
||||
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(entry, result[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getPluginManagementEntries with enabledOnly false should include non-started plugins`() {
|
||||
val stoppedWrapper = buildFullPluginWrapper("stopped-plugin", PluginState.STOPPED)
|
||||
val entry = PluginManagementEntry("stopped-plugin", enabled = false)
|
||||
|
||||
every { pluginManager.plugins } returns listOf(stoppedWrapper)
|
||||
every { pluginManager.getExtensionTypes("stopped-plugin") } returns listOf("GameMetadataProviderStub")
|
||||
every { pluginManagementRepository.findByIdOrNull("stopped-plugin") } returns entry
|
||||
|
||||
val result = service.getPluginManagementEntries(GameMetadataProviderStub::class.java, enabledOnly = false)
|
||||
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(entry, result[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getPluginManagementEntries should throw when management entry is missing`() {
|
||||
val wrapper = buildFullPluginWrapper("no-entry-plugin", PluginState.STARTED)
|
||||
|
||||
every { pluginManager.plugins } returns listOf(wrapper)
|
||||
every { pluginManager.getExtensionTypes("no-entry-plugin") } returns listOf("GameMetadataProviderStub")
|
||||
every { pluginManagementRepository.findByIdOrNull("no-entry-plugin") } returns null
|
||||
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
service.getPluginManagementEntries(GameMetadataProviderStub::class.java, enabledOnly = true)
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// enablePlugin / disablePlugin
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `enablePlugin should delegate to pluginManager`() {
|
||||
service.enablePlugin("my-plugin")
|
||||
verify(exactly = 1) { pluginManager.enablePlugin("my-plugin") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disablePlugin should delegate to pluginManager`() {
|
||||
service.disablePlugin("my-plugin")
|
||||
verify(exactly = 1) { pluginManager.disablePlugin("my-plugin") }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// setPluginPriorities
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `setPluginPriorities should persist updated priorities for each plugin`() {
|
||||
val entry1 = PluginManagementEntry("p1", priority = 1)
|
||||
val entry2 = PluginManagementEntry("p2", priority = 2)
|
||||
|
||||
every { pluginManager.getManagementEntry("p1") } returns entry1
|
||||
every { pluginManager.getManagementEntry("p2") } returns entry2
|
||||
every { pluginManagementRepository.save(any()) } returnsArgument 0
|
||||
|
||||
service.setPluginPriorities(mapOf("p1" to 10, "p2" to 5))
|
||||
|
||||
verify(exactly = 1) { pluginManagementRepository.save(match { it.pluginId == "p1" && it.priority == 10 }) }
|
||||
verify(exactly = 1) { pluginManagementRepository.save(match { it.pluginId == "p2" && it.priority == 5 }) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setPluginPriorities with empty map should not call save`() {
|
||||
service.setPluginPriorities(emptyMap())
|
||||
verify(exactly = 0) { pluginManagementRepository.save(any()) }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// getLogo
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `getLogo should return bytes from plugin`() {
|
||||
val logoBytes = byteArrayOf(1, 2, 3)
|
||||
val gameyfinPlugin = mockk<GameyfinPlugin>()
|
||||
val wrapper = mockPluginWrapper("logo-plugin")
|
||||
|
||||
every { wrapper.plugin } returns gameyfinPlugin
|
||||
every { pluginManager.getPlugin("logo-plugin") } returns wrapper
|
||||
every { gameyfinPlugin.getLogo() } returns logoBytes
|
||||
|
||||
val result = service.getLogo("logo-plugin")
|
||||
|
||||
assertContentEquals(logoBytes, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getLogo should return null when plugin has no logo`() {
|
||||
val gameyfinPlugin = mockk<GameyfinPlugin>()
|
||||
val wrapper = mockPluginWrapper("no-logo-plugin")
|
||||
|
||||
every { wrapper.plugin } returns gameyfinPlugin
|
||||
every { pluginManager.getPlugin("no-logo-plugin") } returns wrapper
|
||||
every { gameyfinPlugin.getLogo() } returns null
|
||||
|
||||
assertNull(service.getLogo("no-logo-plugin"))
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// getConfigMetadata
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `getConfigMetadata should return null for non-configurable plugin`() {
|
||||
val wrapper = mockPluginWrapper("plain-plugin")
|
||||
every { wrapper.plugin } returns mockk<Plugin>(relaxed = true)
|
||||
|
||||
assertNull(service.getConfigMetadata(wrapper))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getConfigMetadata should return metadata for configurable plugin`() {
|
||||
val meta = ConfigMetadata(
|
||||
key = "apiKey",
|
||||
type = String::class.java,
|
||||
label = "API Key",
|
||||
description = "Your API key",
|
||||
isSecret = true,
|
||||
isRequired = true,
|
||||
)
|
||||
|
||||
val configurablePlugin = mockk<TestConfigurablePlugin>()
|
||||
every { configurablePlugin.configMetadata } returns listOf(meta)
|
||||
|
||||
val wrapper = mockPluginWrapper("configurable-plugin")
|
||||
every { wrapper.plugin } returns configurablePlugin
|
||||
|
||||
val result = service.getConfigMetadata(wrapper)
|
||||
|
||||
assertNotNull(result)
|
||||
assertEquals(1, result.size)
|
||||
with(result[0]) {
|
||||
assertEquals("apiKey", key)
|
||||
assertEquals("String", type)
|
||||
assertEquals("API Key", label)
|
||||
assertEquals("Your API key", description)
|
||||
assertTrue(secret)
|
||||
assertTrue(required)
|
||||
assertNull(allowedValues)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getConfigMetadata should include allowed values for enum-typed config entries`() {
|
||||
val meta = ConfigMetadata(
|
||||
key = "mode",
|
||||
type = TestMode::class.java,
|
||||
label = "Mode",
|
||||
description = "Operation mode",
|
||||
)
|
||||
|
||||
val configurablePlugin = mockk<TestConfigurablePlugin>()
|
||||
every { configurablePlugin.configMetadata } returns listOf(meta)
|
||||
|
||||
val wrapper = mockPluginWrapper("enum-config-plugin")
|
||||
every { wrapper.plugin } returns configurablePlugin
|
||||
|
||||
val result = service.getConfigMetadata(wrapper)
|
||||
|
||||
assertNotNull(result)
|
||||
assertEquals(listOf("FAST", "SLOW"), result[0].allowedValues)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// getConfig
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `getConfig should return key-value map from repository`() {
|
||||
val wrapper = mockPluginWrapper("cfg-plugin")
|
||||
val entries = listOf(
|
||||
PluginConfigEntry(PluginConfigEntryKey("cfg-plugin", "host"), "localhost"),
|
||||
PluginConfigEntry(PluginConfigEntryKey("cfg-plugin", "port"), "8080")
|
||||
)
|
||||
every { pluginConfigRepository.findAllByPluginId("cfg-plugin") } returns entries
|
||||
|
||||
val result = service.getConfig(wrapper)
|
||||
|
||||
assertEquals(mapOf("host" to "localhost", "port" to "8080"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getConfig should return empty map when no config entries exist`() {
|
||||
val wrapper = mockPluginWrapper("empty-cfg-plugin")
|
||||
every { pluginConfigRepository.findAllByPluginId("empty-cfg-plugin") } returns emptyList()
|
||||
|
||||
assertTrue(service.getConfig(wrapper).isEmpty())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// updateConfig
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `updateConfig should persist entries, restart plugin, validate and emit update`() {
|
||||
val pluginId = "upd-plugin"
|
||||
val config = mapOf("key1" to "val1")
|
||||
val validationResult = PluginConfigValidationResult(PluginConfigValidationResultType.VALID)
|
||||
|
||||
every { pluginConfigRepository.saveAll(any<List<PluginConfigEntry>>()) } returns emptyList()
|
||||
every { pluginManager.restart(pluginId) } just Runs
|
||||
every { pluginManager.validatePluginConfig(pluginId) } returns validationResult
|
||||
|
||||
mockkObject(PluginService.Companion)
|
||||
every { PluginService.emit(any()) } just Runs
|
||||
|
||||
assertDoesNotThrow { service.updateConfig(pluginId, config) }
|
||||
|
||||
verify(exactly = 1) { pluginConfigRepository.saveAll(any<List<PluginConfigEntry>>()) }
|
||||
verify(exactly = 1) { pluginManager.restart(pluginId) }
|
||||
verify(exactly = 1) { PluginService.emit(match { it.id == pluginId && it.config == config }) }
|
||||
|
||||
unmockkObject(PluginService.Companion)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// validatePluginConfig (by pluginId)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `validatePluginConfig should return cached result on second call`() {
|
||||
val pluginId = "cache-plugin"
|
||||
val result = PluginConfigValidationResult(PluginConfigValidationResultType.VALID)
|
||||
|
||||
every { pluginManager.validatePluginConfig(pluginId) } returns result
|
||||
|
||||
service.validatePluginConfig(pluginId)
|
||||
service.validatePluginConfig(pluginId)
|
||||
|
||||
// Second call should use cache – manager is called only once
|
||||
verify(exactly = 1) { pluginManager.validatePluginConfig(pluginId) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validatePluginConfig with forceRevalidation should bypass cache`() {
|
||||
val pluginId = "force-plugin"
|
||||
val result = PluginConfigValidationResult(PluginConfigValidationResultType.VALID)
|
||||
|
||||
every { pluginManager.validatePluginConfig(pluginId) } returns result
|
||||
|
||||
service.validatePluginConfig(pluginId, forceRevalidation = false)
|
||||
service.validatePluginConfig(pluginId, forceRevalidation = true)
|
||||
|
||||
verify(exactly = 2) { pluginManager.validatePluginConfig(pluginId) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validatePluginConfig should return INVALID result when config is invalid`() {
|
||||
val pluginId = "invalid-plugin"
|
||||
val result = PluginConfigValidationResult(
|
||||
PluginConfigValidationResultType.INVALID,
|
||||
mapOf("apiKey" to "Required")
|
||||
)
|
||||
every { pluginManager.validatePluginConfig(pluginId) } returns result
|
||||
|
||||
val actual = service.validatePluginConfig(pluginId)
|
||||
|
||||
assertEquals(PluginConfigValidationResultType.INVALID, actual.result)
|
||||
assertEquals("Required", actual.errors?.get("apiKey"))
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// validatePluginConfig (with config map)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `validatePluginConfig with config map should delegate to pluginManager`() {
|
||||
val pluginId = "map-plugin"
|
||||
val config = mapOf("key" to "value")
|
||||
val result = PluginConfigValidationResult(PluginConfigValidationResultType.VALID)
|
||||
|
||||
every { pluginManager.validatePluginConfig(pluginId, config) } returns result
|
||||
|
||||
val actual = service.validatePluginConfig(pluginId, config)
|
||||
|
||||
assertEquals(result, actual)
|
||||
verify(exactly = 1) { pluginManager.validatePluginConfig(pluginId, config) }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Creates a minimal PluginWrapper mock with a given ID and state. */
|
||||
private fun mockPluginWrapper(pluginId: String, state: PluginState = PluginState.STARTED): PluginWrapper {
|
||||
val wrapper = mockk<PluginWrapper>(relaxed = true)
|
||||
every { wrapper.pluginId } returns pluginId
|
||||
every { wrapper.pluginState } returns state
|
||||
return wrapper
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a PluginWrapper mock that satisfies the full toDto() path inside PluginService,
|
||||
* including descriptor, management entry, and validation.
|
||||
*/
|
||||
private fun buildFullPluginWrapper(
|
||||
pluginId: String,
|
||||
state: PluginState = PluginState.STARTED
|
||||
): PluginWrapper {
|
||||
val wrapper = mockPluginWrapper(pluginId, state)
|
||||
|
||||
val descriptor = mockk<GameyfinPluginDescriptor>()
|
||||
every { descriptor.pluginId } returns pluginId
|
||||
every { descriptor.pluginName } returns "Test Plugin"
|
||||
every { descriptor.pluginDescription } returns "Description"
|
||||
every { descriptor.pluginShortDescription } returns null
|
||||
every { descriptor.version } returns "1.0.0"
|
||||
every { descriptor.author } returns "Author"
|
||||
every { descriptor.license } returns null
|
||||
every { descriptor.pluginUrl } returns null
|
||||
every { wrapper.descriptor } returns descriptor
|
||||
|
||||
// Non-GameyfinPlugin so hasLogo = false
|
||||
every { wrapper.plugin } returns mockk<Plugin>(relaxed = true)
|
||||
|
||||
val managementEntry = PluginManagementEntry(pluginId, priority = 1, trustLevel = PluginTrustLevel.OFFICIAL)
|
||||
every { pluginManager.getManagementEntry(pluginId) } returns managementEntry
|
||||
every { pluginManager.getExtensionTypes(pluginId) } returns emptyList()
|
||||
every { pluginConfigRepository.findAllByPluginId(pluginId) } returns emptyList()
|
||||
every { pluginManager.validatePluginConfig(pluginId) } returns
|
||||
PluginConfigValidationResult(PluginConfigValidationResultType.VALID)
|
||||
|
||||
return wrapper
|
||||
}
|
||||
|
||||
// Stub types used in tests
|
||||
interface GameMetadataProviderStub : ExtensionPoint
|
||||
|
||||
@Suppress("deprecation", "redundantSuppression")
|
||||
abstract class TestConfigurablePlugin : Plugin(mockk(relaxed = true)), Configurable
|
||||
|
||||
@Suppress("unused")
|
||||
enum class TestMode { FAST, SLOW }
|
||||
}
|
||||
|
||||
|
||||
+210
-18
@@ -12,12 +12,12 @@ import org.junit.jupiter.api.Assertions.assertDoesNotThrow
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import org.pf4j.ExtensionPoint
|
||||
import org.pf4j.Plugin
|
||||
import org.pf4j.PluginState
|
||||
import org.pf4j.PluginWrapper
|
||||
import org.pf4j.*
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import java.nio.file.Path
|
||||
import java.util.jar.JarEntry
|
||||
import java.util.jar.JarOutputStream
|
||||
import java.util.jar.Manifest
|
||||
import kotlin.test.*
|
||||
|
||||
class GameyfinPluginManagerTest {
|
||||
@@ -391,8 +391,7 @@ class GameyfinPluginManagerTest {
|
||||
every { pluginManager.getPlugins() } returns listOf(pluginWrapper)
|
||||
every { pluginManager.getExtensionClasses("test-plugin") } returns extensionClasses
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val result = pluginManager.getPluginForExtension(TestExtension1::class.java as Class<ExtensionPoint>)
|
||||
val result = pluginManager.getPluginForExtension(TestExtensionPoint1::class)
|
||||
|
||||
assertNotNull(result)
|
||||
assertEquals("test-plugin", result.pluginId)
|
||||
@@ -418,8 +417,7 @@ class GameyfinPluginManagerTest {
|
||||
every { pluginManager.getPlugins() } returns listOf(pluginWrapper)
|
||||
every { pluginManager.getExtensionClasses("test-plugin") } returns extensionClasses
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val result = pluginManager.getPluginForExtension(TestExtension2::class.java as Class<ExtensionPoint>)
|
||||
val result = pluginManager.getPluginForExtension(TestExtensionPoint2::class)
|
||||
|
||||
assertNull(result)
|
||||
}
|
||||
@@ -518,22 +516,12 @@ class GameyfinPluginManagerTest {
|
||||
)
|
||||
)
|
||||
|
||||
val entry = PluginManagementEntry("unknown-plugin", enabled = false, priority = 1)
|
||||
entry.trustLevel = PluginTrustLevel.UNTRUSTED
|
||||
|
||||
val pluginWrapper = mockk<PluginWrapper>()
|
||||
val pluginDescriptor = mockk<GameyfinPluginDescriptor>()
|
||||
|
||||
every { pluginWrapper.pluginId } returns "unknown-plugin"
|
||||
every { pluginWrapper.pluginState } returns PluginState.RESOLVED
|
||||
every { pluginWrapper.pluginClassLoader } returns mockk<ClassLoader>()
|
||||
every { pluginWrapper.plugin } returns mockk<Plugin>()
|
||||
every { pluginWrapper.descriptor } returns pluginDescriptor
|
||||
every { pluginDescriptor.pluginId } returns "unknown-plugin"
|
||||
every { pluginDescriptor.version } returns "unknown_version"
|
||||
every { pluginManagementRepository.findByIdOrNull("unknown-plugin") } returns null
|
||||
every { pluginManager.getPlugin("unknown-plugin") } returns pluginWrapper
|
||||
every { pluginManager.checkPluginId("unknown-plugin") } just runs
|
||||
|
||||
val result = pluginManager.startPlugin("unknown-plugin")
|
||||
|
||||
@@ -559,6 +547,98 @@ class GameyfinPluginManagerTest {
|
||||
assertNotNull(pluginManager.pluginsRoot)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createPluginLoader should return CompoundPluginLoader containing only GameyfinJarPluginLoader in non-development mode`() {
|
||||
System.setProperty("pf4j.pluginsDir", tempPluginsDir.toString())
|
||||
System.clearProperty("pf4j.mode")
|
||||
|
||||
pluginManager = GameyfinPluginManager(
|
||||
forwardingPluginStateListener,
|
||||
dbPluginStatusProvider,
|
||||
pluginConfigRepository,
|
||||
pluginManagementRepository
|
||||
)
|
||||
|
||||
val loader = invokeCreatePluginLoader(pluginManager)
|
||||
|
||||
assertIs<CompoundPluginLoader>(loader)
|
||||
val loaders = getLoadersFromCompound(loader)
|
||||
|
||||
assertEquals(1, loaders.size, "Expected exactly one loader in non-development mode")
|
||||
assertIs<GameyfinJarPluginLoader>(loaders[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createPluginLoader should return CompoundPluginLoader containing GameyfinJarPluginLoader and GameyfinDevelopmentPluginLoader in development mode`() {
|
||||
System.setProperty("pf4j.pluginsDir", tempPluginsDir.toString())
|
||||
System.setProperty("pf4j.mode", "development")
|
||||
|
||||
try {
|
||||
pluginManager = GameyfinPluginManager(
|
||||
forwardingPluginStateListener,
|
||||
dbPluginStatusProvider,
|
||||
pluginConfigRepository,
|
||||
pluginManagementRepository
|
||||
)
|
||||
|
||||
val loader = invokeCreatePluginLoader(pluginManager)
|
||||
|
||||
assertIs<CompoundPluginLoader>(loader)
|
||||
val loaders = getLoadersFromCompound(loader)
|
||||
|
||||
assertEquals(2, loaders.size, "Expected two loaders in development mode")
|
||||
assertIs<GameyfinJarPluginLoader>(loaders[0])
|
||||
assertIs<GameyfinDevelopmentPluginLoader>(loaders[1])
|
||||
} finally {
|
||||
System.clearProperty("pf4j.mode")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadPluginFromPath should save management entry before starting a new bundled plugin`() {
|
||||
System.setProperty("pf4j.pluginsDir", tempPluginsDir.toString())
|
||||
|
||||
pluginManager = spyk(
|
||||
GameyfinPluginManager(
|
||||
forwardingPluginStateListener,
|
||||
dbPluginStatusProvider,
|
||||
pluginConfigRepository,
|
||||
pluginManagementRepository
|
||||
)
|
||||
)
|
||||
|
||||
val pluginWrapper = mockk<PluginWrapper>(relaxed = true)
|
||||
val plugin = mockk<Plugin>(relaxed = true)
|
||||
|
||||
every { pluginWrapper.pluginId } returns "my-bundled-plugin"
|
||||
every { pluginWrapper.plugin } returns plugin
|
||||
|
||||
// Plugin is not yet in the database (new plugin)
|
||||
every { pluginManagementRepository.findByIdOrNull("my-bundled-plugin") } returns null
|
||||
every { pluginManagementRepository.findMaxPriority() } returns 5
|
||||
every { pluginManager.startPlugin("my-bundled-plugin") } returns PluginState.STARTED
|
||||
|
||||
// Mock the super.loadPluginFromPath to return our pluginWrapper
|
||||
every { pluginManager.superLoadPluginFromPath(any()) } returns pluginWrapper
|
||||
|
||||
// Create a fake non-jar plugin path (non-jar extension → BUNDLED trust level)
|
||||
val fakePath = tempPluginsDir.resolve("my-plugin")
|
||||
fakePath.toFile().mkdirs()
|
||||
|
||||
// Call our override
|
||||
val result = pluginManager.loadPluginFromPath(fakePath)
|
||||
|
||||
assertNotNull(result)
|
||||
|
||||
// Verify that save was called before startPlugin (line 139 followed by line 140)
|
||||
verifyOrder {
|
||||
pluginManagementRepository.save(match {
|
||||
it.pluginId == "my-bundled-plugin" && it.enabled && it.trustLevel == PluginTrustLevel.BUNDLED
|
||||
})
|
||||
pluginManager.startPlugin("my-bundled-plugin")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getExtensionTypeClasses should return empty list for plugin with no extensions`() {
|
||||
System.setProperty("pf4j.pluginsDir", tempPluginsDir.toString())
|
||||
@@ -589,5 +669,117 @@ class GameyfinPluginManagerTest {
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
abstract class TestConfigurablePlugin(wrapper: PluginWrapper) : Plugin(wrapper), Configurable
|
||||
|
||||
private fun invokeCreatePluginLoader(manager: GameyfinPluginManager): PluginLoader {
|
||||
val method = DefaultPluginManager::class.java.getDeclaredMethod("createPluginLoader")
|
||||
method.isAccessible = true
|
||||
return method.invoke(manager) as PluginLoader
|
||||
}
|
||||
|
||||
private fun getLoadersFromCompound(compoundLoader: CompoundPluginLoader): List<*> {
|
||||
val loadersField = CompoundPluginLoader::class.java.getDeclaredField("loaders")
|
||||
loadersField.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return loadersField.get(compoundLoader) as List<*>
|
||||
}
|
||||
|
||||
// ========================================================================================
|
||||
// Integration tests for loadPluginFromPath with JAR signature verification
|
||||
// ========================================================================================
|
||||
|
||||
@Test
|
||||
fun `loadPluginFromPath should set THIRD_PARTY trust level for unsigned JAR plugin`() {
|
||||
System.setProperty("pf4j.pluginsDir", tempPluginsDir.toString())
|
||||
|
||||
pluginManager = spyk(
|
||||
GameyfinPluginManager(
|
||||
forwardingPluginStateListener,
|
||||
dbPluginStatusProvider,
|
||||
pluginConfigRepository,
|
||||
pluginManagementRepository
|
||||
)
|
||||
)
|
||||
|
||||
val pluginWrapper = mockk<PluginWrapper>(relaxed = true)
|
||||
val plugin = mockk<Plugin>(relaxed = true)
|
||||
|
||||
every { pluginWrapper.pluginId } returns "unsigned-plugin"
|
||||
every { pluginWrapper.plugin } returns plugin
|
||||
every { pluginManagementRepository.findByIdOrNull("unsigned-plugin") } returns null
|
||||
every { pluginManagementRepository.findMaxPriority() } returns 0
|
||||
every { pluginManager.superLoadPluginFromPath(any()) } returns pluginWrapper
|
||||
|
||||
// Create an unsigned JAR with actual content entries
|
||||
val jarPath = createUnsignedJar("unsigned-plugin.jar", mapOf("com/example/MyClass.class" to "dummy"))
|
||||
|
||||
pluginManager.loadPluginFromPath(jarPath)
|
||||
|
||||
// Unsigned JAR → THIRD_PARTY trust level, which means not auto-enabled, not auto-started
|
||||
verify {
|
||||
pluginManagementRepository.save(match {
|
||||
it.pluginId == "unsigned-plugin" && it.trustLevel == PluginTrustLevel.THIRD_PARTY && !it.enabled
|
||||
})
|
||||
}
|
||||
// THIRD_PARTY plugins should NOT be auto-started
|
||||
verify(exactly = 0) { pluginManager.startPlugin("unsigned-plugin") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadPluginFromPath should re-verify existing plugin as THIRD_PARTY for unsigned JAR`() {
|
||||
System.setProperty("pf4j.pluginsDir", tempPluginsDir.toString())
|
||||
|
||||
pluginManager = spyk(
|
||||
GameyfinPluginManager(
|
||||
forwardingPluginStateListener,
|
||||
dbPluginStatusProvider,
|
||||
pluginConfigRepository,
|
||||
pluginManagementRepository
|
||||
)
|
||||
)
|
||||
|
||||
val pluginWrapper = mockk<PluginWrapper>(relaxed = true)
|
||||
val plugin = mockk<Plugin>(relaxed = true)
|
||||
val existingEntry = PluginManagementEntry("re-verified-plugin", enabled = true, priority = 1)
|
||||
existingEntry.trustLevel = PluginTrustLevel.OFFICIAL
|
||||
|
||||
every { pluginWrapper.pluginId } returns "re-verified-plugin"
|
||||
every { pluginWrapper.plugin } returns plugin
|
||||
every { pluginManagementRepository.findByIdOrNull("re-verified-plugin") } returns existingEntry
|
||||
every { pluginManager.superLoadPluginFromPath(any()) } returns pluginWrapper
|
||||
|
||||
val jarPath = createUnsignedJar("re-verified-plugin.jar", mapOf("com/example/MyClass.class" to "dummy"))
|
||||
|
||||
pluginManager.loadPluginFromPath(jarPath)
|
||||
|
||||
// Re-verification of an unsigned JAR should update trust level to THIRD_PARTY
|
||||
verify {
|
||||
pluginManagementRepository.save(match {
|
||||
it.pluginId == "re-verified-plugin" && it.trustLevel == PluginTrustLevel.THIRD_PARTY
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================================
|
||||
// JAR creation helpers
|
||||
// ========================================================================================
|
||||
|
||||
/**
|
||||
* Creates an unsigned JAR file with the given entries at the temp directory.
|
||||
*/
|
||||
private fun createUnsignedJar(fileName: String, entries: Map<String, String>): Path {
|
||||
val jarPath = tempPluginsDir.resolve(fileName)
|
||||
val manifest = Manifest()
|
||||
manifest.mainAttributes.putValue("Manifest-Version", "1.0")
|
||||
|
||||
JarOutputStream(jarPath.toFile().outputStream(), manifest).use { jos ->
|
||||
for ((name, content) in entries) {
|
||||
jos.putNextEntry(JarEntry(name))
|
||||
jos.write(content.toByteArray())
|
||||
jos.closeEntry()
|
||||
}
|
||||
}
|
||||
|
||||
return jarPath
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -13,11 +13,13 @@ class PluginManagerConfigTest {
|
||||
|
||||
private lateinit var pluginManager: GameyfinPluginManager
|
||||
private lateinit var pluginManagerConfig: PluginManagerConfig
|
||||
private lateinit var pluginsLoadedIndicator: PluginsLoadedIndicator
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
pluginManager = mockk(relaxed = true)
|
||||
pluginManagerConfig = PluginManagerConfig(pluginManager)
|
||||
pluginsLoadedIndicator = mockk(relaxed = true)
|
||||
pluginManagerConfig = PluginManagerConfig(pluginManager, pluginsLoadedIndicator)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
package org.gameyfin.app.core.plugins.management
|
||||
|
||||
import io.mockk.Runs
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
import java.security.CodeSigner
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.PublicKey
|
||||
import java.security.cert.CertPath
|
||||
import java.security.cert.Certificate
|
||||
import java.security.cert.X509Certificate
|
||||
import java.util.jar.JarEntry
|
||||
import java.util.jar.JarFile
|
||||
import java.util.jar.JarOutputStream
|
||||
import java.util.jar.Manifest
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class PluginSignatureVerifierTest {
|
||||
|
||||
private lateinit var publicKey: PublicKey
|
||||
private lateinit var verifier: PluginSignatureVerifier
|
||||
|
||||
@TempDir
|
||||
lateinit var tempDir: Path
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
val keyPairGenerator = KeyPairGenerator.getInstance("RSA")
|
||||
keyPairGenerator.initialize(2048)
|
||||
publicKey = keyPairGenerator.generateKeyPair().public
|
||||
verifier = PluginSignatureVerifier(publicKey)
|
||||
}
|
||||
|
||||
// ========================================================================================
|
||||
// verifyPluginSignature tests
|
||||
// ========================================================================================
|
||||
|
||||
@Test
|
||||
fun `verifyPluginSignature should return THIRD_PARTY for unsigned JAR`() {
|
||||
val jarPath = createUnsignedJar("unsigned-plugin.jar", mapOf("com/example/MyClass.class" to "dummy content"))
|
||||
|
||||
val result = verifier.verifyPluginSignature(jarPath)
|
||||
|
||||
assertEquals(PluginTrustLevel.THIRD_PARTY, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verifyPluginSignature should return OFFICIAL for JAR with only META-INF entries`() {
|
||||
val jarPath = createUnsignedJar("meta-only.jar", mapOf("META-INF/services/some.service" to "org.example.Impl"))
|
||||
|
||||
val result = verifier.verifyPluginSignature(jarPath)
|
||||
|
||||
assertEquals(PluginTrustLevel.OFFICIAL, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verifyPluginSignature should return OFFICIAL for empty JAR`() {
|
||||
val jarPath = createUnsignedJar("empty.jar", emptyMap())
|
||||
|
||||
val result = verifier.verifyPluginSignature(jarPath)
|
||||
|
||||
assertEquals(PluginTrustLevel.OFFICIAL, result)
|
||||
}
|
||||
|
||||
// ========================================================================================
|
||||
// verifyEntryDigest tests
|
||||
// ========================================================================================
|
||||
|
||||
@Test
|
||||
fun `verifyEntryDigest should return true for valid JAR entry`() {
|
||||
val jarPath = createUnsignedJar("valid-entry.jar", mapOf("test.txt" to "hello world"))
|
||||
val jarFile = JarFile(jarPath.toFile())
|
||||
val entry = jarFile.getJarEntry("test.txt")
|
||||
|
||||
val result = verifier.verifyEntryDigest(jarFile, entry)
|
||||
|
||||
assertTrue(result)
|
||||
}
|
||||
|
||||
// ========================================================================================
|
||||
// verifyCertificates tests
|
||||
// ========================================================================================
|
||||
|
||||
@Test
|
||||
fun `verifyCertificates should return OFFICIAL for empty certificate list`() {
|
||||
val result = verifier.verifyCertificates(emptyList())
|
||||
|
||||
assertEquals(PluginTrustLevel.OFFICIAL, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verifyCertificates should return OFFICIAL for non-X509 certificates`() {
|
||||
val nonX509Cert = mockk<Certificate>()
|
||||
|
||||
val result = verifier.verifyCertificates(listOf(nonX509Cert))
|
||||
|
||||
assertEquals(PluginTrustLevel.OFFICIAL, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verifyCertificates should return UNTRUSTED when certificate verification fails`() {
|
||||
val badCert = mockk<X509Certificate>()
|
||||
every { badCert.verify(any()) } throws java.security.SignatureException("bad signature")
|
||||
|
||||
val result = verifier.verifyCertificates(listOf(badCert))
|
||||
|
||||
assertEquals(PluginTrustLevel.UNTRUSTED, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verifyCertificates should return OFFICIAL when certificate verification succeeds`() {
|
||||
val goodCert = mockk<X509Certificate>()
|
||||
every { goodCert.verify(any()) } just Runs
|
||||
|
||||
val result = verifier.verifyCertificates(listOf(goodCert))
|
||||
|
||||
assertEquals(PluginTrustLevel.OFFICIAL, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verifyCertificates should return UNTRUSTED if any certificate in list fails`() {
|
||||
val goodCert = mockk<X509Certificate>()
|
||||
every { goodCert.verify(any()) } just Runs
|
||||
|
||||
val badCert = mockk<X509Certificate>()
|
||||
every { badCert.verify(any()) } throws java.security.SignatureException("bad signature")
|
||||
|
||||
val result = verifier.verifyCertificates(listOf(goodCert, badCert))
|
||||
|
||||
assertEquals(PluginTrustLevel.UNTRUSTED, result)
|
||||
}
|
||||
|
||||
// ========================================================================================
|
||||
// verifyCodeSigners tests
|
||||
// ========================================================================================
|
||||
|
||||
@Test
|
||||
fun `verifyCodeSigners should return OFFICIAL when all signers have valid certificates`() {
|
||||
val goodCert = mockk<X509Certificate>()
|
||||
every { goodCert.verify(any()) } just Runs
|
||||
|
||||
val certPath = mockk<CertPath>()
|
||||
every { certPath.certificates } returns listOf(goodCert)
|
||||
|
||||
val codeSigner = mockk<CodeSigner>()
|
||||
every { codeSigner.signerCertPath } returns certPath
|
||||
|
||||
val result = verifier.verifyCodeSigners(arrayOf(codeSigner))
|
||||
|
||||
assertEquals(PluginTrustLevel.OFFICIAL, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verifyCodeSigners should return UNTRUSTED when a signer has invalid certificate`() {
|
||||
val badCert = mockk<X509Certificate>()
|
||||
every { badCert.verify(any()) } throws java.security.SignatureException("bad signature")
|
||||
|
||||
val certPath = mockk<CertPath>()
|
||||
every { certPath.certificates } returns listOf(badCert)
|
||||
|
||||
val codeSigner = mockk<CodeSigner>()
|
||||
every { codeSigner.signerCertPath } returns certPath
|
||||
|
||||
val result = verifier.verifyCodeSigners(arrayOf(codeSigner))
|
||||
|
||||
assertEquals(PluginTrustLevel.UNTRUSTED, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verifyCodeSigners should return UNTRUSTED on first invalid signer and skip remaining`() {
|
||||
val badCert = mockk<X509Certificate>()
|
||||
every { badCert.verify(any()) } throws java.security.SignatureException("bad signature")
|
||||
|
||||
val badCertPath = mockk<CertPath>()
|
||||
every { badCertPath.certificates } returns listOf(badCert)
|
||||
|
||||
val badSigner = mockk<CodeSigner>()
|
||||
every { badSigner.signerCertPath } returns badCertPath
|
||||
|
||||
val goodCert = mockk<X509Certificate>()
|
||||
every { goodCert.verify(any()) } just Runs
|
||||
|
||||
val goodCertPath = mockk<CertPath>()
|
||||
every { goodCertPath.certificates } returns listOf(goodCert)
|
||||
|
||||
val goodSigner = mockk<CodeSigner>()
|
||||
every { goodSigner.signerCertPath } returns goodCertPath
|
||||
|
||||
val result = verifier.verifyCodeSigners(arrayOf(badSigner, goodSigner))
|
||||
|
||||
assertEquals(PluginTrustLevel.UNTRUSTED, result)
|
||||
}
|
||||
|
||||
// ========================================================================================
|
||||
// Helper methods
|
||||
// ========================================================================================
|
||||
|
||||
private fun createUnsignedJar(fileName: String, entries: Map<String, String>): Path {
|
||||
val jarPath = tempDir.resolve(fileName)
|
||||
val manifest = Manifest()
|
||||
manifest.mainAttributes.putValue("Manifest-Version", "1.0")
|
||||
|
||||
JarOutputStream(jarPath.toFile().outputStream(), manifest).use { jos ->
|
||||
for ((name, content) in entries) {
|
||||
jos.putNextEntry(JarEntry(name))
|
||||
jos.write(content.toByteArray())
|
||||
jos.closeEntry()
|
||||
}
|
||||
}
|
||||
|
||||
return jarPath
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package org.gameyfin.app.core.security
|
||||
|
||||
import io.mockk.clearAllMocks
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.unmockkAll
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class OidcUserExtensionsTest {
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
unmockkAll()
|
||||
clearAllMocks()
|
||||
}
|
||||
|
||||
private fun oidcUser(
|
||||
preferredUsername: String? = null,
|
||||
name: String? = null,
|
||||
email: String? = null,
|
||||
subject: String = "sub-12345"
|
||||
): OidcUser = mockk {
|
||||
every { getClaim<String>("preferred_username") } returns preferredUsername
|
||||
every { getClaim<String>("name") } returns name
|
||||
every { getClaim<String>("email") } returns email
|
||||
// support any other claim key by returning null
|
||||
every { getClaim<String>(not(or(or("preferred_username", "name"), "email"))) } returns null
|
||||
every { this@mockk.subject } returns subject
|
||||
}
|
||||
|
||||
// ── configured attribute present ──────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `returns configured attribute when it is present`() {
|
||||
val user = mockk<OidcUser> {
|
||||
every { getClaim<String>("upn") } returns "alice@corp"
|
||||
every { subject } returns "sub-1"
|
||||
}
|
||||
assertEquals("alice@corp", user.resolvedUsername("upn"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns preferred_username when it is configured and present`() {
|
||||
val user = oidcUser(preferredUsername = "alice")
|
||||
assertEquals("alice", user.resolvedUsername("preferred_username"))
|
||||
}
|
||||
|
||||
// ── configured attribute absent, fallback chain ───────────────────────────
|
||||
|
||||
@Test
|
||||
fun `falls back to preferred_username when configured attribute is absent`() {
|
||||
val user = mockk<OidcUser> {
|
||||
every { getClaim<String>("upn") } returns null
|
||||
every { getClaim<String>("preferred_username") } returns "alice"
|
||||
every { subject } returns "sub-1"
|
||||
}
|
||||
assertEquals("alice", user.resolvedUsername("upn"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls back to name when preferred_username is absent`() {
|
||||
val user = oidcUser(preferredUsername = null, name = "Alice Smith")
|
||||
assertEquals("Alice Smith", user.resolvedUsername("preferred_username"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls back to email when preferred_username and name are absent`() {
|
||||
val user = oidcUser(preferredUsername = null, name = null, email = "alice@example.com")
|
||||
assertEquals("alice@example.com", user.resolvedUsername("preferred_username"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls back to sub when all other claims are absent`() {
|
||||
val user = oidcUser(preferredUsername = null, name = null, email = null, subject = "sub-99")
|
||||
assertEquals("sub-99", user.resolvedUsername("preferred_username"))
|
||||
}
|
||||
|
||||
// ── blank / empty strings are treated as absent ───────────────────────────
|
||||
|
||||
@Test
|
||||
fun `skips blank preferred_username and falls back to name`() {
|
||||
val user = oidcUser(preferredUsername = " ", name = "Bob")
|
||||
assertEquals("Bob", user.resolvedUsername("preferred_username"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skips empty preferred_username and falls back to name`() {
|
||||
val user = oidcUser(preferredUsername = "", name = "Bob")
|
||||
assertEquals("Bob", user.resolvedUsername("preferred_username"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skips blank name and falls back to email`() {
|
||||
val user = oidcUser(preferredUsername = null, name = "", email = "bob@example.com")
|
||||
assertEquals("bob@example.com", user.resolvedUsername("preferred_username"))
|
||||
}
|
||||
|
||||
// ── default parameter ─────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `uses preferred_username by default when no attributeName argument is supplied`() {
|
||||
val user = oidcUser(preferredUsername = "charlie")
|
||||
assertEquals("charlie", user.resolvedUsername())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls back to sub by default when all standard claims are absent`() {
|
||||
val user = oidcUser(subject = "sub-default-fallback")
|
||||
assertEquals("sub-default-fallback", user.resolvedUsername())
|
||||
}
|
||||
|
||||
// ── configured attribute equals a fallback name ───────────────────────────
|
||||
|
||||
@Test
|
||||
fun `does not duplicate lookup when configured attribute is preferred_username`() {
|
||||
val user = oidcUser(preferredUsername = "dave")
|
||||
// should still work correctly without doubling up
|
||||
assertEquals("dave", user.resolvedUsername("preferred_username"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `configured attribute email returns email claim directly`() {
|
||||
// When "email" is explicitly configured as the attribute, it should be tried first
|
||||
val mockUser = mockk<OidcUser> {
|
||||
every { getClaim<String>("email") } returns "eve@example.com"
|
||||
every { getClaim<String>("preferred_username") } returns "other"
|
||||
every { getClaim<String>("name") } returns null
|
||||
every { subject } returns "sub-3"
|
||||
}
|
||||
assertEquals("eve@example.com", mockUser.resolvedUsername("email"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+433
@@ -0,0 +1,433 @@
|
||||
package org.gameyfin.app.core.security
|
||||
|
||||
import io.mockk.*
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
import org.gameyfin.app.config.ConfigProperties
|
||||
import org.gameyfin.app.config.ConfigService
|
||||
import org.gameyfin.app.config.MatchUsersBy
|
||||
import org.gameyfin.app.core.Role
|
||||
import org.gameyfin.app.users.RoleService
|
||||
import org.gameyfin.app.users.UserService
|
||||
import org.gameyfin.app.users.entities.User
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import org.springframework.security.access.hierarchicalroles.RoleHierarchy
|
||||
import org.springframework.security.core.Authentication
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||
import org.springframework.security.core.context.SecurityContext
|
||||
import org.springframework.security.core.context.SecurityContextHolder
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class SsoAuthenticationSuccessHandlerTest {
|
||||
|
||||
private lateinit var userService: UserService
|
||||
private lateinit var roleService: RoleService
|
||||
private lateinit var config: ConfigService
|
||||
private lateinit var roleHierarchy: RoleHierarchy
|
||||
private lateinit var handler: SsoAuthenticationSuccessHandler
|
||||
|
||||
private lateinit var request: HttpServletRequest
|
||||
private lateinit var response: HttpServletResponse
|
||||
private lateinit var authentication: Authentication
|
||||
private lateinit var securityContext: SecurityContext
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
userService = mockk(relaxed = true)
|
||||
roleService = mockk(relaxed = true)
|
||||
config = mockk()
|
||||
roleHierarchy = mockk(relaxed = true)
|
||||
|
||||
handler = SsoAuthenticationSuccessHandler(userService, roleService, config, roleHierarchy)
|
||||
|
||||
request = mockk()
|
||||
response = mockk(relaxed = true)
|
||||
authentication = mockk()
|
||||
securityContext = mockk(relaxed = true)
|
||||
|
||||
mockkStatic(SecurityContextHolder::class)
|
||||
every { SecurityContextHolder.getContext() } returns securityContext
|
||||
|
||||
// Default: no continue parameter → redirect to "/"
|
||||
every { request.getParameter("continue") } returns null
|
||||
|
||||
// Default role resolution
|
||||
every { roleService.extractGrantedAuthorities(any()) } returns emptyList()
|
||||
every { roleService.authoritiesToRoles(any()) } returns listOf(Role.USER)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
unmockkAll()
|
||||
clearAllMocks()
|
||||
}
|
||||
|
||||
// ── helper to build a minimal OidcUser mock ───────────────────────────────
|
||||
|
||||
private fun oidcUser(
|
||||
subject: String = "sub-1",
|
||||
preferredUsername: String? = "alice",
|
||||
email: String = "alice@example.com",
|
||||
extraClaims: Map<String, String?> = emptyMap()
|
||||
): OidcUser = mockk {
|
||||
every { this@mockk.subject } returns subject
|
||||
every { getClaim<String>("preferred_username") } returns preferredUsername
|
||||
every { getClaim<String>("name") } returns null
|
||||
every { getClaim<String>("email") } returns email
|
||||
extraClaims.forEach { (key, value) -> every { getClaim<String>(key) } returns value }
|
||||
every { authorities } returns emptyList()
|
||||
every { this@mockk.email } returns email
|
||||
}
|
||||
|
||||
// ── Username resolution: configured attribute ─────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `uses configured username attribute when claim is present`() {
|
||||
val oidcUser = mockk<OidcUser> {
|
||||
every { subject } returns "sub-1"
|
||||
every { getClaim<String>("upn") } returns "alice@corp"
|
||||
every { getClaim<String>("preferred_username") } returns "fallback"
|
||||
every { getClaim<String>("name") } returns null
|
||||
every { getClaim<String>("email") } returns "alice@example.com"
|
||||
every { authorities } returns emptyList()
|
||||
every { email } returns "alice@example.com"
|
||||
}
|
||||
|
||||
every { config.get(ConfigProperties.SSO.OIDC.UsernameClaim) } returns "upn"
|
||||
every { config.get(ConfigProperties.SSO.OIDC.MatchExistingUsersBy) } returns MatchUsersBy.username
|
||||
every { authentication.principal } returns oidcUser
|
||||
every { authentication.credentials } returns null
|
||||
|
||||
every { userService.findByOidcProviderId("sub-1") } returns null
|
||||
every { userService.getByUsername("alice@corp") } returns null
|
||||
val savedSlot = slot<User>()
|
||||
every { userService.registerOrUpdateUser(capture(savedSlot)) } returns mockk(relaxed = true)
|
||||
|
||||
handler.onAuthenticationSuccess(request, response, authentication)
|
||||
|
||||
assertEquals("alice@corp", savedSlot.captured.username)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls back to preferred_username when configured attribute is absent`() {
|
||||
val oidcUser = mockk<OidcUser> {
|
||||
every { subject } returns "sub-2"
|
||||
every { getClaim<String>("upn") } returns null
|
||||
every { getClaim<String>("preferred_username") } returns "alice"
|
||||
every { getClaim<String>("name") } returns null
|
||||
every { getClaim<String>("email") } returns "alice@example.com"
|
||||
every { authorities } returns emptyList()
|
||||
every { email } returns "alice@example.com"
|
||||
}
|
||||
|
||||
every { config.get(ConfigProperties.SSO.OIDC.UsernameClaim) } returns "upn"
|
||||
every { config.get(ConfigProperties.SSO.OIDC.MatchExistingUsersBy) } returns MatchUsersBy.username
|
||||
every { authentication.principal } returns oidcUser
|
||||
every { authentication.credentials } returns null
|
||||
|
||||
every { userService.findByOidcProviderId("sub-2") } returns null
|
||||
every { userService.getByUsername("alice") } returns null
|
||||
val savedSlot = slot<User>()
|
||||
every { userService.registerOrUpdateUser(capture(savedSlot)) } returns mockk(relaxed = true)
|
||||
|
||||
handler.onAuthenticationSuccess(request, response, authentication)
|
||||
|
||||
assertEquals("alice", savedSlot.captured.username)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls back to sub when all claims are absent`() {
|
||||
val oidcUser = mockk<OidcUser> {
|
||||
every { subject } returns "sub-99"
|
||||
every { getClaim<String>(any()) } returns null
|
||||
every { authorities } returns emptyList()
|
||||
every { email } returns "sub99@example.com"
|
||||
}
|
||||
|
||||
every { config.get(ConfigProperties.SSO.OIDC.UsernameClaim) } returns "preferred_username"
|
||||
every { config.get(ConfigProperties.SSO.OIDC.MatchExistingUsersBy) } returns MatchUsersBy.username
|
||||
every { authentication.principal } returns oidcUser
|
||||
every { authentication.credentials } returns null
|
||||
|
||||
every { userService.findByOidcProviderId("sub-99") } returns null
|
||||
every { userService.getByUsername("sub-99") } returns null
|
||||
val savedSlot = slot<User>()
|
||||
every { userService.registerOrUpdateUser(capture(savedSlot)) } returns mockk(relaxed = true)
|
||||
|
||||
handler.onAuthenticationSuccess(request, response, authentication)
|
||||
|
||||
assertEquals("sub-99", savedSlot.captured.username)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `uses preferred_username fallback when UsernameAttribute config is null`() {
|
||||
val oidcUser = oidcUser(preferredUsername = "alice")
|
||||
|
||||
every { config.get(ConfigProperties.SSO.OIDC.UsernameClaim) } returns null
|
||||
every { config.get(ConfigProperties.SSO.OIDC.MatchExistingUsersBy) } returns MatchUsersBy.username
|
||||
every { authentication.principal } returns oidcUser
|
||||
every { authentication.credentials } returns null
|
||||
|
||||
every { userService.findByOidcProviderId("sub-1") } returns null
|
||||
every { userService.getByUsername("alice") } returns null
|
||||
val savedSlot = slot<User>()
|
||||
every { userService.registerOrUpdateUser(capture(savedSlot)) } returns mockk(relaxed = true)
|
||||
|
||||
handler.onAuthenticationSuccess(request, response, authentication)
|
||||
|
||||
assertEquals("alice", savedSlot.captured.username)
|
||||
}
|
||||
|
||||
// ── New user registration ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `registers new user when not found by provider ID or username`() {
|
||||
val oidcUser = oidcUser(subject = "sub-new", preferredUsername = "newuser")
|
||||
|
||||
every { config.get(ConfigProperties.SSO.OIDC.UsernameClaim) } returns "preferred_username"
|
||||
every { config.get(ConfigProperties.SSO.OIDC.MatchExistingUsersBy) } returns MatchUsersBy.username
|
||||
every { authentication.principal } returns oidcUser
|
||||
every { authentication.credentials } returns null
|
||||
|
||||
every { userService.findByOidcProviderId("sub-new") } returns null
|
||||
every { userService.getByUsername("newuser") } returns null
|
||||
val savedSlot = slot<User>()
|
||||
every { userService.registerOrUpdateUser(capture(savedSlot)) } returns mockk(relaxed = true)
|
||||
|
||||
handler.onAuthenticationSuccess(request, response, authentication)
|
||||
|
||||
verify(exactly = 1) { userService.registerOrUpdateUser(any()) }
|
||||
assertEquals("newuser", savedSlot.captured.username)
|
||||
assertEquals("sub-new", savedSlot.captured.oidcProviderId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `registers new user when not found by provider ID or email`() {
|
||||
val oidcUser = oidcUser(subject = "sub-new2", preferredUsername = "newuser2", email = "new@example.com")
|
||||
|
||||
every { config.get(ConfigProperties.SSO.OIDC.UsernameClaim) } returns "preferred_username"
|
||||
every { config.get(ConfigProperties.SSO.OIDC.MatchExistingUsersBy) } returns MatchUsersBy.email
|
||||
every { authentication.principal } returns oidcUser
|
||||
every { authentication.credentials } returns null
|
||||
|
||||
every { userService.findByOidcProviderId("sub-new2") } returns null
|
||||
every { userService.getByEmail("new@example.com") } returns null
|
||||
val savedSlot = slot<User>()
|
||||
every { userService.registerOrUpdateUser(capture(savedSlot)) } returns mockk(relaxed = true)
|
||||
|
||||
handler.onAuthenticationSuccess(request, response, authentication)
|
||||
|
||||
assertEquals("newuser2", savedSlot.captured.username)
|
||||
}
|
||||
|
||||
// ── Existing user update ──────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `updates existing user found by provider ID with resolved username`() {
|
||||
val oidcUser =
|
||||
oidcUser(subject = "sub-existing", preferredUsername = "updated-name", email = "updated@example.com")
|
||||
val existingUser = User(
|
||||
id = 1L,
|
||||
username = "old-name",
|
||||
email = "old@example.com",
|
||||
oidcProviderId = "sub-existing",
|
||||
enabled = true
|
||||
)
|
||||
|
||||
every { config.get(ConfigProperties.SSO.OIDC.UsernameClaim) } returns "preferred_username"
|
||||
every { config.get(ConfigProperties.SSO.OIDC.MatchExistingUsersBy) } returns MatchUsersBy.username
|
||||
every { authentication.principal } returns oidcUser
|
||||
every { authentication.credentials } returns null
|
||||
|
||||
every { userService.findByOidcProviderId("sub-existing") } returns existingUser
|
||||
every { userService.registerOrUpdateUser(existingUser) } returns mockk(relaxed = true)
|
||||
|
||||
handler.onAuthenticationSuccess(request, response, authentication)
|
||||
|
||||
assertEquals("updated-name", existingUser.username)
|
||||
assertEquals("updated@example.com", existingUser.email)
|
||||
assertEquals(true, existingUser.emailConfirmed)
|
||||
assertEquals("sub-existing", existingUser.oidcProviderId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `links and updates existing user found by username match`() {
|
||||
val oidcUser = oidcUser(subject = "sub-link", preferredUsername = "existinguser")
|
||||
val existingUser = User(
|
||||
id = 2L,
|
||||
username = "existinguser",
|
||||
email = "existing@example.com",
|
||||
enabled = true
|
||||
)
|
||||
|
||||
every { config.get(ConfigProperties.SSO.OIDC.UsernameClaim) } returns "preferred_username"
|
||||
every { config.get(ConfigProperties.SSO.OIDC.MatchExistingUsersBy) } returns MatchUsersBy.username
|
||||
every { authentication.principal } returns oidcUser
|
||||
every { authentication.credentials } returns null
|
||||
|
||||
every { userService.findByOidcProviderId("sub-link") } returns null
|
||||
every { userService.getByUsername("existinguser") } returns existingUser
|
||||
every { userService.registerOrUpdateUser(existingUser) } returns mockk(relaxed = true)
|
||||
|
||||
handler.onAuthenticationSuccess(request, response, authentication)
|
||||
|
||||
assertEquals("sub-link", existingUser.oidcProviderId)
|
||||
assertEquals("existinguser", existingUser.username)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `links and updates existing user found by email match`() {
|
||||
val oidcUser =
|
||||
oidcUser(subject = "sub-email-link", preferredUsername = "renamed", email = "matched@example.com")
|
||||
val existingUser = User(
|
||||
id = 3L,
|
||||
username = "oldusername",
|
||||
email = "matched@example.com",
|
||||
enabled = true
|
||||
)
|
||||
|
||||
every { config.get(ConfigProperties.SSO.OIDC.UsernameClaim) } returns "preferred_username"
|
||||
every { config.get(ConfigProperties.SSO.OIDC.MatchExistingUsersBy) } returns MatchUsersBy.email
|
||||
every { authentication.principal } returns oidcUser
|
||||
every { authentication.credentials } returns null
|
||||
|
||||
every { userService.findByOidcProviderId("sub-email-link") } returns null
|
||||
every { userService.getByEmail("matched@example.com") } returns existingUser
|
||||
every { userService.registerOrUpdateUser(existingUser) } returns mockk(relaxed = true)
|
||||
|
||||
handler.onAuthenticationSuccess(request, response, authentication)
|
||||
|
||||
// Username should be updated to the resolved username from SSO
|
||||
assertEquals("renamed", existingUser.username)
|
||||
assertEquals("sub-email-link", existingUser.oidcProviderId)
|
||||
}
|
||||
|
||||
// ── Role assignment ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `assigns USER role when no roles extracted from SSO`() {
|
||||
val oidcUser = oidcUser()
|
||||
|
||||
every { config.get(ConfigProperties.SSO.OIDC.UsernameClaim) } returns "preferred_username"
|
||||
every { config.get(ConfigProperties.SSO.OIDC.MatchExistingUsersBy) } returns MatchUsersBy.username
|
||||
every { authentication.principal } returns oidcUser
|
||||
every { authentication.credentials } returns null
|
||||
|
||||
every { userService.findByOidcProviderId(any()) } returns null
|
||||
every { userService.getByUsername(any()) } returns null
|
||||
every { roleService.extractGrantedAuthorities(any()) } returns emptyList()
|
||||
every { roleService.authoritiesToRoles(any()) } returns emptyList() // empty → should default to USER
|
||||
|
||||
val savedSlot = slot<User>()
|
||||
every { userService.registerOrUpdateUser(capture(savedSlot)) } returns mockk(relaxed = true)
|
||||
|
||||
handler.onAuthenticationSuccess(request, response, authentication)
|
||||
|
||||
assertEquals(listOf(Role.USER), savedSlot.captured.roles)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `assigns ADMIN role when SSO provides it`() {
|
||||
val oidcUser = oidcUser()
|
||||
|
||||
every { config.get(ConfigProperties.SSO.OIDC.UsernameClaim) } returns "preferred_username"
|
||||
every { config.get(ConfigProperties.SSO.OIDC.MatchExistingUsersBy) } returns MatchUsersBy.username
|
||||
every { authentication.principal } returns oidcUser
|
||||
every { authentication.credentials } returns null
|
||||
|
||||
every { userService.findByOidcProviderId(any()) } returns null
|
||||
every { userService.getByUsername(any()) } returns null
|
||||
val adminAuthority = SimpleGrantedAuthority(Role.Names.ADMIN)
|
||||
every { roleService.extractGrantedAuthorities(any()) } returns listOf(adminAuthority)
|
||||
every { roleService.authoritiesToRoles(listOf(adminAuthority)) } returns listOf(Role.ADMIN)
|
||||
|
||||
val savedSlot = slot<User>()
|
||||
every { userService.registerOrUpdateUser(capture(savedSlot)) } returns mockk(relaxed = true)
|
||||
|
||||
handler.onAuthenticationSuccess(request, response, authentication)
|
||||
|
||||
assertEquals(listOf(Role.ADMIN), savedSlot.captured.roles)
|
||||
}
|
||||
|
||||
// ── Redirect behaviour ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `redirects to root when no continue parameter`() {
|
||||
val oidcUser = oidcUser()
|
||||
|
||||
every { config.get(ConfigProperties.SSO.OIDC.UsernameClaim) } returns "preferred_username"
|
||||
every { config.get(ConfigProperties.SSO.OIDC.MatchExistingUsersBy) } returns MatchUsersBy.username
|
||||
every { authentication.principal } returns oidcUser
|
||||
every { authentication.credentials } returns null
|
||||
every { request.getParameter("continue") } returns null
|
||||
|
||||
every { userService.findByOidcProviderId(any()) } returns null
|
||||
every { userService.getByUsername(any()) } returns null
|
||||
every { userService.registerOrUpdateUser(any()) } returns mockk(relaxed = true)
|
||||
|
||||
handler.onAuthenticationSuccess(request, response, authentication)
|
||||
|
||||
verify { response.sendRedirect("/") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `redirects to continue URL when it starts with slash`() {
|
||||
val oidcUser = oidcUser()
|
||||
|
||||
every { config.get(ConfigProperties.SSO.OIDC.UsernameClaim) } returns "preferred_username"
|
||||
every { config.get(ConfigProperties.SSO.OIDC.MatchExistingUsersBy) } returns MatchUsersBy.username
|
||||
every { authentication.principal } returns oidcUser
|
||||
every { authentication.credentials } returns null
|
||||
every { request.getParameter("continue") } returns "/collection/games"
|
||||
|
||||
every { userService.findByOidcProviderId(any()) } returns null
|
||||
every { userService.getByUsername(any()) } returns null
|
||||
every { userService.registerOrUpdateUser(any()) } returns mockk(relaxed = true)
|
||||
|
||||
handler.onAuthenticationSuccess(request, response, authentication)
|
||||
|
||||
verify { response.sendRedirect("/collection/games") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ignores continue URL that does not start with slash`() {
|
||||
val oidcUser = oidcUser()
|
||||
|
||||
every { config.get(ConfigProperties.SSO.OIDC.UsernameClaim) } returns "preferred_username"
|
||||
every { config.get(ConfigProperties.SSO.OIDC.MatchExistingUsersBy) } returns MatchUsersBy.username
|
||||
every { authentication.principal } returns oidcUser
|
||||
every { authentication.credentials } returns null
|
||||
every { request.getParameter("continue") } returns "https://evil.example.com"
|
||||
|
||||
every { userService.findByOidcProviderId(any()) } returns null
|
||||
every { userService.getByUsername(any()) } returns null
|
||||
every { userService.registerOrUpdateUser(any()) } returns mockk(relaxed = true)
|
||||
|
||||
handler.onAuthenticationSuccess(request, response, authentication)
|
||||
|
||||
verify { response.sendRedirect("/") }
|
||||
}
|
||||
|
||||
// ── Unknown MatchExistingUsersBy ──────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `throws when MatchExistingUsersBy has unknown value`() {
|
||||
val oidcUser = oidcUser()
|
||||
|
||||
every { config.get(ConfigProperties.SSO.OIDC.UsernameClaim) } returns "preferred_username"
|
||||
every { config.get(ConfigProperties.SSO.OIDC.MatchExistingUsersBy) } returns null
|
||||
every { authentication.principal } returns oidcUser
|
||||
every { authentication.credentials } returns null
|
||||
|
||||
every { userService.findByOidcProviderId(any()) } returns null
|
||||
|
||||
assertThrows<IllegalStateException> {
|
||||
handler.onAuthenticationSuccess(request, response, authentication)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ class TokenTypeUserTypeTest {
|
||||
fun `nullSafeGet should return PasswordReset for password-reset key`() {
|
||||
every { resultSet.getString(0) } returns "password-reset"
|
||||
|
||||
val result = userType.nullSafeGet(resultSet, 0, session, null)
|
||||
val result = userType.nullSafeGet(resultSet, 0, session)
|
||||
|
||||
assertNotNull(result)
|
||||
assertEquals(TokenType.PasswordReset, result)
|
||||
@@ -103,7 +103,7 @@ class TokenTypeUserTypeTest {
|
||||
fun `nullSafeGet should return EmailConfirmation for email-verification key`() {
|
||||
every { resultSet.getString(1) } returns "email-verification"
|
||||
|
||||
val result = userType.nullSafeGet(resultSet, 1, session, null)
|
||||
val result = userType.nullSafeGet(resultSet, 1, session)
|
||||
|
||||
assertNotNull(result)
|
||||
assertEquals(TokenType.EmailConfirmation, result)
|
||||
@@ -113,7 +113,7 @@ class TokenTypeUserTypeTest {
|
||||
fun `nullSafeGet should return Invitation for invitation key`() {
|
||||
every { resultSet.getString(2) } returns "invitation"
|
||||
|
||||
val result = userType.nullSafeGet(resultSet, 2, session, null)
|
||||
val result = userType.nullSafeGet(resultSet, 2, session)
|
||||
|
||||
assertNotNull(result)
|
||||
assertEquals(TokenType.Invitation, result)
|
||||
@@ -123,7 +123,7 @@ class TokenTypeUserTypeTest {
|
||||
fun `nullSafeGet should return null when database value is null`() {
|
||||
every { resultSet.getString(0) } returns null
|
||||
|
||||
val result = userType.nullSafeGet(resultSet, 0, session, null)
|
||||
val result = userType.nullSafeGet(resultSet, 0, session)
|
||||
|
||||
assertNull(result)
|
||||
}
|
||||
@@ -133,7 +133,7 @@ class TokenTypeUserTypeTest {
|
||||
every { resultSet.getString(0) } returns "unknown-type"
|
||||
|
||||
val exception = assertThrows(IllegalArgumentException::class.java) {
|
||||
userType.nullSafeGet(resultSet, 0, session, null)
|
||||
userType.nullSafeGet(resultSet, 0, session)
|
||||
}
|
||||
|
||||
assertEquals("Unknown TokenType: unknown-type", exception.message)
|
||||
@@ -144,58 +144,12 @@ class TokenTypeUserTypeTest {
|
||||
every { resultSet.getString(0) } returns ""
|
||||
|
||||
val exception = assertThrows(IllegalArgumentException::class.java) {
|
||||
userType.nullSafeGet(resultSet, 0, session, null)
|
||||
userType.nullSafeGet(resultSet, 0, session)
|
||||
}
|
||||
|
||||
assertEquals("Unknown TokenType: ", exception.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nullSafeSet should set string value for PasswordReset`() {
|
||||
val capturedValues = mutableListOf<String>()
|
||||
every { preparedStatement.setString(0, capture(capturedValues)) } returns Unit
|
||||
|
||||
userType.nullSafeSet(preparedStatement, TokenType.PasswordReset, 0, session)
|
||||
|
||||
assertEquals(1, capturedValues.size)
|
||||
assertEquals("password-reset", capturedValues[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nullSafeSet should set string value for EmailConfirmation`() {
|
||||
val capturedValues = mutableListOf<String>()
|
||||
every { preparedStatement.setString(1, capture(capturedValues)) } returns Unit
|
||||
|
||||
userType.nullSafeSet(preparedStatement, TokenType.EmailConfirmation, 1, session)
|
||||
|
||||
assertEquals(1, capturedValues.size)
|
||||
assertEquals("email-verification", capturedValues[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nullSafeSet should set string value for Invitation`() {
|
||||
val capturedValues = mutableListOf<String>()
|
||||
every { preparedStatement.setString(2, capture(capturedValues)) } returns Unit
|
||||
|
||||
userType.nullSafeSet(preparedStatement, TokenType.Invitation, 2, session)
|
||||
|
||||
assertEquals(1, capturedValues.size)
|
||||
assertEquals("invitation", capturedValues[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nullSafeSet should set null when value is null`() {
|
||||
val capturedIndices = mutableListOf<Int>()
|
||||
val capturedTypes = mutableListOf<Int>()
|
||||
every { preparedStatement.setNull(capture(capturedIndices), capture(capturedTypes)) } returns Unit
|
||||
|
||||
userType.nullSafeSet(preparedStatement, null, 0, session)
|
||||
|
||||
assertEquals(1, capturedIndices.size)
|
||||
assertEquals(0, capturedIndices[0])
|
||||
assertEquals(Types.VARCHAR, capturedTypes[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deepCopy should return same instance`() {
|
||||
val type = TokenType.PasswordReset
|
||||
|
||||
@@ -20,9 +20,7 @@ import org.gameyfin.app.media.ImageService
|
||||
import org.gameyfin.app.media.ImageType
|
||||
import org.gameyfin.app.users.UserService
|
||||
import org.gameyfin.app.users.entities.User
|
||||
import org.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
|
||||
import org.gameyfin.pluginapi.gamemetadata.Genre
|
||||
import org.gameyfin.pluginapi.gamemetadata.Platform
|
||||
import org.gameyfin.pluginapi.gamemetadata.*
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
@@ -33,6 +31,7 @@ import org.springframework.security.core.Authentication
|
||||
import org.springframework.security.core.context.SecurityContext
|
||||
import org.springframework.security.core.context.SecurityContextHolder
|
||||
import org.springframework.security.core.userdetails.UserDetails
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser
|
||||
import java.nio.file.Path
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
@@ -625,7 +624,7 @@ class GameServiceTest {
|
||||
every { pluginManager.getExtensions("test-plugin") } returns listOf(provider)
|
||||
every { pluginManager.getExtensions(GameMetadataProvider::class.java) } returns listOf(provider)
|
||||
every { pluginService.getPluginManagementEntry(provider.javaClass) } returns pluginEntry
|
||||
every { pluginManager.getPluginForExtension(provider.javaClass) } returns null
|
||||
every { pluginManager.getPluginForExtension(provider::class) } returns null
|
||||
|
||||
val result = gameService.updateMetadata(game)
|
||||
|
||||
@@ -895,7 +894,7 @@ class GameServiceTest {
|
||||
)
|
||||
every { pluginService.getPluginManagementEntry(workingProvider.javaClass) } returns pluginEntry1
|
||||
every { pluginService.getPluginManagementEntry(failingProvider.javaClass) } returns pluginEntry2
|
||||
every { pluginManager.getPluginForExtension(failingProvider.javaClass) } returns pluginWrapper
|
||||
every { pluginManager.getPluginForExtension(failingProvider::class) } returns pluginWrapper
|
||||
|
||||
val results = gameService.getPotentialMatches("Test Game", emptySet())
|
||||
|
||||
@@ -1372,6 +1371,946 @@ class GameServiceTest {
|
||||
)
|
||||
}
|
||||
|
||||
// ========================
|
||||
// create(List<Game>) batch — cover/header image branches
|
||||
// ========================
|
||||
|
||||
@Test
|
||||
fun `create with list should handle games with null coverImage and headerImage`() {
|
||||
val game = createTestGame(id = null)
|
||||
game.coverImage = null
|
||||
game.headerImage = null
|
||||
val games = listOf(game)
|
||||
|
||||
every { companyService.createOrGet(any()) } returns mockk(relaxed = true)
|
||||
every { imageService.createOrGet(any()) } returns mockk(relaxed = true)
|
||||
every { gameRepository.saveAll(games) } returns games
|
||||
|
||||
val result = gameService.create(games)
|
||||
|
||||
assertEquals(games, result)
|
||||
verify(exactly = 0) { imageService.createOrGet(match { it.type == ImageType.COVER }) }
|
||||
verify(exactly = 0) { imageService.createOrGet(match { it.type == ImageType.HEADER }) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create with list should process coverImage and headerImage when present`() {
|
||||
val coverImage = Image(originalUrl = "https://example.com/cover.jpg", type = ImageType.COVER)
|
||||
val headerImage = Image(originalUrl = "https://example.com/header.jpg", type = ImageType.HEADER)
|
||||
val processedCover = mockk<Image>(relaxed = true)
|
||||
val processedHeader = mockk<Image>(relaxed = true)
|
||||
|
||||
val game = createTestGame(id = null)
|
||||
game.coverImage = coverImage
|
||||
game.headerImage = headerImage
|
||||
val games = listOf(game)
|
||||
|
||||
every { companyService.createOrGet(any()) } returns mockk(relaxed = true)
|
||||
every { imageService.createOrGet(coverImage) } returns processedCover
|
||||
every { imageService.createOrGet(headerImage) } returns processedHeader
|
||||
every { imageService.createOrGet(match { it != coverImage && it != headerImage }) } returns mockk(relaxed = true)
|
||||
every { gameRepository.saveAll(games) } returns games
|
||||
|
||||
val result = gameService.create(games)
|
||||
|
||||
assertEquals(games, result)
|
||||
assertEquals(processedCover, game.coverImage)
|
||||
assertEquals(processedHeader, game.headerImage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create with list should skip all games when all have existing IDs`() {
|
||||
val game1 = createTestGame(id = 1L)
|
||||
val game2 = createTestGame(id = 2L)
|
||||
val games = listOf(game1, game2)
|
||||
|
||||
every { gameRepository.saveAll(emptyList()) } returns emptyList()
|
||||
|
||||
val result = gameService.create(games)
|
||||
|
||||
assertEquals(emptyList(), result)
|
||||
}
|
||||
|
||||
// ========================
|
||||
// edit — OidcUser branch
|
||||
// ========================
|
||||
|
||||
@Test
|
||||
fun `edit should resolve user from OidcUser principal`() {
|
||||
val gameId = 1L
|
||||
val existingGame = createTestGame(gameId)
|
||||
val oidcUser = mockk<OidcUser> {
|
||||
every { preferredUsername } returns "oidcuser"
|
||||
}
|
||||
|
||||
every { authentication.principal } returns oidcUser
|
||||
every { gameRepository.findByIdOrNull(gameId) } returns existingGame
|
||||
every { userService.getByUsernameNonNull("oidcuser") } returns mockUser
|
||||
every { gameRepository.save(existingGame) } returns existingGame
|
||||
|
||||
val updateDto = GameUpdateDto(
|
||||
id = gameId,
|
||||
title = "OIDC Updated Title",
|
||||
platforms = null,
|
||||
release = null,
|
||||
coverUrl = null,
|
||||
headerUrl = null,
|
||||
comment = null,
|
||||
summary = null,
|
||||
developers = null,
|
||||
publishers = null,
|
||||
genres = null,
|
||||
themes = null,
|
||||
keywords = null,
|
||||
features = null,
|
||||
perspectives = null,
|
||||
metadata = null
|
||||
)
|
||||
|
||||
gameService.edit(updateDto)
|
||||
|
||||
assertEquals("OIDC Updated Title", existingGame.title)
|
||||
verify(exactly = 1) { userService.getByUsernameNonNull("oidcuser") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `edit should throw error for unknown principal type`() {
|
||||
val gameId = 1L
|
||||
val existingGame = createTestGame(gameId)
|
||||
|
||||
every { authentication.principal } returns "unknownPrincipal"
|
||||
every { gameRepository.findByIdOrNull(gameId) } returns existingGame
|
||||
|
||||
val updateDto = GameUpdateDto(
|
||||
id = gameId,
|
||||
title = "Title",
|
||||
platforms = null,
|
||||
release = null,
|
||||
coverUrl = null,
|
||||
headerUrl = null,
|
||||
comment = null,
|
||||
summary = null,
|
||||
developers = null,
|
||||
publishers = null,
|
||||
genres = null,
|
||||
themes = null,
|
||||
keywords = null,
|
||||
features = null,
|
||||
perspectives = null,
|
||||
metadata = null
|
||||
)
|
||||
|
||||
assertThrows(IllegalStateException::class.java) {
|
||||
gameService.edit(updateDto)
|
||||
}
|
||||
}
|
||||
|
||||
// ========================
|
||||
// edit — headerUrl branch
|
||||
// ========================
|
||||
|
||||
@Test
|
||||
fun `edit should create and download new header image when headerUrl provided`() {
|
||||
val gameId = 1L
|
||||
val existingGame = createTestGame(gameId)
|
||||
val userDetails = mockk<UserDetails> {
|
||||
every { username } returns "testuser"
|
||||
}
|
||||
val newHeaderUrl = "https://example.com/new-header.jpg"
|
||||
val newHeaderImage = mockk<Image>(relaxed = true)
|
||||
|
||||
every { authentication.principal } returns userDetails
|
||||
every { gameRepository.findByIdOrNull(gameId) } returns existingGame
|
||||
every { userService.getByUsernameNonNull("testuser") } returns mockUser
|
||||
every { imageService.createOrGet(any()) } returns newHeaderImage
|
||||
every { imageService.downloadIfNew(newHeaderImage) } just Runs
|
||||
every { gameRepository.save(existingGame) } returns existingGame
|
||||
|
||||
val updateDto = GameUpdateDto(
|
||||
id = gameId,
|
||||
title = null,
|
||||
platforms = null,
|
||||
release = null,
|
||||
coverUrl = null,
|
||||
headerUrl = newHeaderUrl,
|
||||
comment = null,
|
||||
summary = null,
|
||||
developers = null,
|
||||
publishers = null,
|
||||
genres = null,
|
||||
themes = null,
|
||||
keywords = null,
|
||||
features = null,
|
||||
perspectives = null,
|
||||
metadata = null
|
||||
)
|
||||
|
||||
gameService.edit(updateDto)
|
||||
|
||||
assertEquals(newHeaderImage, existingGame.headerImage)
|
||||
verify(exactly = 1) {
|
||||
imageService.createOrGet(match { it.originalUrl == newHeaderUrl && it.type == ImageType.HEADER })
|
||||
}
|
||||
verify(exactly = 1) { imageService.downloadIfNew(newHeaderImage) }
|
||||
}
|
||||
|
||||
// ========================
|
||||
// edit — comment, summary, genres, themes, keywords, features, perspectives branches
|
||||
// ========================
|
||||
|
||||
@Test
|
||||
fun `edit should update comment when provided`() {
|
||||
val gameId = 1L
|
||||
val existingGame = createTestGame(gameId)
|
||||
val userDetails = mockk<UserDetails> {
|
||||
every { username } returns "testuser"
|
||||
}
|
||||
|
||||
every { authentication.principal } returns userDetails
|
||||
every { gameRepository.findByIdOrNull(gameId) } returns existingGame
|
||||
every { userService.getByUsernameNonNull("testuser") } returns mockUser
|
||||
every { gameRepository.save(existingGame) } returns existingGame
|
||||
|
||||
val updateDto = GameUpdateDto(
|
||||
id = gameId,
|
||||
title = null,
|
||||
platforms = null,
|
||||
release = null,
|
||||
coverUrl = null,
|
||||
headerUrl = null,
|
||||
comment = "Updated Comment",
|
||||
summary = null,
|
||||
developers = null,
|
||||
publishers = null,
|
||||
genres = null,
|
||||
themes = null,
|
||||
keywords = null,
|
||||
features = null,
|
||||
perspectives = null,
|
||||
metadata = null
|
||||
)
|
||||
|
||||
gameService.edit(updateDto)
|
||||
|
||||
assertEquals("Updated Comment", existingGame.comment)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `edit should update summary when provided`() {
|
||||
val gameId = 1L
|
||||
val existingGame = createTestGame(gameId)
|
||||
val userDetails = mockk<UserDetails> {
|
||||
every { username } returns "testuser"
|
||||
}
|
||||
|
||||
every { authentication.principal } returns userDetails
|
||||
every { gameRepository.findByIdOrNull(gameId) } returns existingGame
|
||||
every { userService.getByUsernameNonNull("testuser") } returns mockUser
|
||||
every { gameRepository.save(existingGame) } returns existingGame
|
||||
|
||||
val updateDto = GameUpdateDto(
|
||||
id = gameId,
|
||||
title = null,
|
||||
platforms = null,
|
||||
release = null,
|
||||
coverUrl = null,
|
||||
headerUrl = null,
|
||||
comment = null,
|
||||
summary = "Updated Summary",
|
||||
developers = null,
|
||||
publishers = null,
|
||||
genres = null,
|
||||
themes = null,
|
||||
keywords = null,
|
||||
features = null,
|
||||
perspectives = null,
|
||||
metadata = null
|
||||
)
|
||||
|
||||
gameService.edit(updateDto)
|
||||
|
||||
assertEquals("Updated Summary", existingGame.summary)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `edit should update genres when provided`() {
|
||||
val gameId = 1L
|
||||
val existingGame = createTestGame(gameId)
|
||||
val userDetails = mockk<UserDetails> {
|
||||
every { username } returns "testuser"
|
||||
}
|
||||
val newGenres = listOf(Genre.ACTION, Genre.ADVENTURE)
|
||||
|
||||
every { authentication.principal } returns userDetails
|
||||
every { gameRepository.findByIdOrNull(gameId) } returns existingGame
|
||||
every { userService.getByUsernameNonNull("testuser") } returns mockUser
|
||||
every { gameRepository.save(existingGame) } returns existingGame
|
||||
|
||||
val updateDto = GameUpdateDto(
|
||||
id = gameId,
|
||||
title = null,
|
||||
platforms = null,
|
||||
release = null,
|
||||
coverUrl = null,
|
||||
headerUrl = null,
|
||||
comment = null,
|
||||
summary = null,
|
||||
developers = null,
|
||||
publishers = null,
|
||||
genres = newGenres,
|
||||
themes = null,
|
||||
keywords = null,
|
||||
features = null,
|
||||
perspectives = null,
|
||||
metadata = null
|
||||
)
|
||||
|
||||
gameService.edit(updateDto)
|
||||
|
||||
assertEquals(newGenres.toList(), existingGame.genres)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `edit should update themes when provided`() {
|
||||
val gameId = 1L
|
||||
val existingGame = createTestGame(gameId)
|
||||
val userDetails = mockk<UserDetails> {
|
||||
every { username } returns "testuser"
|
||||
}
|
||||
val newThemes = listOf(Theme.FANTASY, Theme.SCIENCE_FICTION)
|
||||
|
||||
every { authentication.principal } returns userDetails
|
||||
every { gameRepository.findByIdOrNull(gameId) } returns existingGame
|
||||
every { userService.getByUsernameNonNull("testuser") } returns mockUser
|
||||
every { gameRepository.save(existingGame) } returns existingGame
|
||||
|
||||
val updateDto = GameUpdateDto(
|
||||
id = gameId,
|
||||
title = null,
|
||||
platforms = null,
|
||||
release = null,
|
||||
coverUrl = null,
|
||||
headerUrl = null,
|
||||
comment = null,
|
||||
summary = null,
|
||||
developers = null,
|
||||
publishers = null,
|
||||
genres = null,
|
||||
themes = newThemes,
|
||||
keywords = null,
|
||||
features = null,
|
||||
perspectives = null,
|
||||
metadata = null
|
||||
)
|
||||
|
||||
gameService.edit(updateDto)
|
||||
|
||||
assertEquals(newThemes.toList(), existingGame.themes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `edit should update keywords when provided`() {
|
||||
val gameId = 1L
|
||||
val existingGame = createTestGame(gameId)
|
||||
val userDetails = mockk<UserDetails> {
|
||||
every { username } returns "testuser"
|
||||
}
|
||||
val newKeywords = listOf("keyword1", "keyword2")
|
||||
|
||||
every { authentication.principal } returns userDetails
|
||||
every { gameRepository.findByIdOrNull(gameId) } returns existingGame
|
||||
every { userService.getByUsernameNonNull("testuser") } returns mockUser
|
||||
every { gameRepository.save(existingGame) } returns existingGame
|
||||
|
||||
val updateDto = GameUpdateDto(
|
||||
id = gameId,
|
||||
title = null,
|
||||
platforms = null,
|
||||
release = null,
|
||||
coverUrl = null,
|
||||
headerUrl = null,
|
||||
comment = null,
|
||||
summary = null,
|
||||
developers = null,
|
||||
publishers = null,
|
||||
genres = null,
|
||||
themes = null,
|
||||
keywords = newKeywords,
|
||||
features = null,
|
||||
perspectives = null,
|
||||
metadata = null
|
||||
)
|
||||
|
||||
gameService.edit(updateDto)
|
||||
|
||||
assertEquals(newKeywords, existingGame.keywords)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `edit should update features when provided`() {
|
||||
val gameId = 1L
|
||||
val existingGame = createTestGame(gameId)
|
||||
val userDetails = mockk<UserDetails> {
|
||||
every { username } returns "testuser"
|
||||
}
|
||||
val newFeatures = listOf(GameFeature.SINGLEPLAYER, GameFeature.MULTIPLAYER)
|
||||
|
||||
every { authentication.principal } returns userDetails
|
||||
every { gameRepository.findByIdOrNull(gameId) } returns existingGame
|
||||
every { userService.getByUsernameNonNull("testuser") } returns mockUser
|
||||
every { gameRepository.save(existingGame) } returns existingGame
|
||||
|
||||
val updateDto = GameUpdateDto(
|
||||
id = gameId,
|
||||
title = null,
|
||||
platforms = null,
|
||||
release = null,
|
||||
coverUrl = null,
|
||||
headerUrl = null,
|
||||
comment = null,
|
||||
summary = null,
|
||||
developers = null,
|
||||
publishers = null,
|
||||
genres = null,
|
||||
themes = null,
|
||||
keywords = null,
|
||||
features = newFeatures,
|
||||
perspectives = null,
|
||||
metadata = null
|
||||
)
|
||||
|
||||
gameService.edit(updateDto)
|
||||
|
||||
assertEquals(newFeatures.toList(), existingGame.features)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `edit should update perspectives when provided`() {
|
||||
val gameId = 1L
|
||||
val existingGame = createTestGame(gameId)
|
||||
val userDetails = mockk<UserDetails> {
|
||||
every { username } returns "testuser"
|
||||
}
|
||||
val newPerspectives = listOf(PlayerPerspective.FIRST_PERSON, PlayerPerspective.THIRD_PERSON)
|
||||
|
||||
every { authentication.principal } returns userDetails
|
||||
every { gameRepository.findByIdOrNull(gameId) } returns existingGame
|
||||
every { userService.getByUsernameNonNull("testuser") } returns mockUser
|
||||
every { gameRepository.save(existingGame) } returns existingGame
|
||||
|
||||
val updateDto = GameUpdateDto(
|
||||
id = gameId,
|
||||
title = null,
|
||||
platforms = null,
|
||||
release = null,
|
||||
coverUrl = null,
|
||||
headerUrl = null,
|
||||
comment = null,
|
||||
summary = null,
|
||||
developers = null,
|
||||
publishers = null,
|
||||
genres = null,
|
||||
themes = null,
|
||||
keywords = null,
|
||||
features = null,
|
||||
perspectives = newPerspectives,
|
||||
metadata = null
|
||||
)
|
||||
|
||||
gameService.edit(updateDto)
|
||||
|
||||
assertEquals(newPerspectives.toList(), existingGame.perspectives)
|
||||
}
|
||||
|
||||
// ========================
|
||||
// edit — all fields at once + metadata.fields source update branch
|
||||
// ========================
|
||||
|
||||
@Test
|
||||
fun `edit should update all fields and set metadata field sources when fields exist`() {
|
||||
val gameId = 1L
|
||||
val pluginEntry = mockk<PluginManagementEntry>(relaxed = true) {
|
||||
every { pluginId } returns "test-plugin"
|
||||
}
|
||||
|
||||
val existingGame = createTestGame(gameId)
|
||||
// Pre-populate metadata fields so that the ?.source = ... branch is exercised
|
||||
existingGame.metadata.fields = mutableMapOf(
|
||||
"title" to GameFieldMetadata(source = GameFieldPluginSource(plugin = pluginEntry)),
|
||||
"platforms" to GameFieldMetadata(source = GameFieldPluginSource(plugin = pluginEntry)),
|
||||
"release" to GameFieldMetadata(source = GameFieldPluginSource(plugin = pluginEntry)),
|
||||
"coverImage" to GameFieldMetadata(source = GameFieldPluginSource(plugin = pluginEntry)),
|
||||
"headerImage" to GameFieldMetadata(source = GameFieldPluginSource(plugin = pluginEntry)),
|
||||
"comment" to GameFieldMetadata(source = GameFieldPluginSource(plugin = pluginEntry)),
|
||||
"summary" to GameFieldMetadata(source = GameFieldPluginSource(plugin = pluginEntry)),
|
||||
"developers" to GameFieldMetadata(source = GameFieldPluginSource(plugin = pluginEntry)),
|
||||
"publishers" to GameFieldMetadata(source = GameFieldPluginSource(plugin = pluginEntry)),
|
||||
"genres" to GameFieldMetadata(source = GameFieldPluginSource(plugin = pluginEntry)),
|
||||
"themes" to GameFieldMetadata(source = GameFieldPluginSource(plugin = pluginEntry)),
|
||||
"keywords" to GameFieldMetadata(source = GameFieldPluginSource(plugin = pluginEntry)),
|
||||
"features" to GameFieldMetadata(source = GameFieldPluginSource(plugin = pluginEntry)),
|
||||
"perspectives" to GameFieldMetadata(source = GameFieldPluginSource(plugin = pluginEntry))
|
||||
)
|
||||
|
||||
val userDetails = mockk<UserDetails> {
|
||||
every { username } returns "testuser"
|
||||
}
|
||||
val newCoverImage = mockk<Image>(relaxed = true)
|
||||
val newHeaderImage = mockk<Image>(relaxed = true)
|
||||
|
||||
every { authentication.principal } returns userDetails
|
||||
every { gameRepository.findByIdOrNull(gameId) } returns existingGame
|
||||
every { userService.getByUsernameNonNull("testuser") } returns mockUser
|
||||
every { imageService.createOrGet(match { it.type == ImageType.COVER }) } returns newCoverImage
|
||||
every { imageService.createOrGet(match { it.type == ImageType.HEADER }) } returns newHeaderImage
|
||||
every { imageService.downloadIfNew(any()) } just Runs
|
||||
every { companyService.createOrGet(any()) } returns mockk(relaxed = true)
|
||||
every { gameRepository.save(existingGame) } returns existingGame
|
||||
|
||||
val updateDto = GameUpdateDto(
|
||||
id = gameId,
|
||||
title = "All Fields Title",
|
||||
platforms = setOf(Platform.PLAYSTATION_5),
|
||||
release = LocalDate.of(2025, 1, 1),
|
||||
coverUrl = "https://example.com/cover.jpg",
|
||||
headerUrl = "https://example.com/header.jpg",
|
||||
comment = "All Fields Comment",
|
||||
summary = "All Fields Summary",
|
||||
developers = listOf("DevA"),
|
||||
publishers = listOf("PubA"),
|
||||
genres = listOf(Genre.ROLE_PLAYING),
|
||||
themes = listOf(Theme.FANTASY),
|
||||
keywords = listOf("kw1"),
|
||||
features = listOf(GameFeature.SINGLEPLAYER),
|
||||
perspectives = listOf(PlayerPerspective.FIRST_PERSON),
|
||||
metadata = null
|
||||
)
|
||||
|
||||
gameService.edit(updateDto)
|
||||
|
||||
assertEquals("All Fields Title", existingGame.title)
|
||||
assertEquals(listOf(Platform.PLAYSTATION_5), existingGame.platforms)
|
||||
assertEquals(
|
||||
LocalDate.of(2025, 1, 1).atStartOfDay(ZoneOffset.UTC).toInstant(),
|
||||
existingGame.release
|
||||
)
|
||||
assertEquals(newCoverImage, existingGame.coverImage)
|
||||
assertEquals(newHeaderImage, existingGame.headerImage)
|
||||
assertEquals("All Fields Comment", existingGame.comment)
|
||||
assertEquals("All Fields Summary", existingGame.summary)
|
||||
assertEquals(listOf(Genre.ROLE_PLAYING), existingGame.genres)
|
||||
assertEquals(listOf(Theme.FANTASY), existingGame.themes)
|
||||
assertEquals(listOf("kw1"), existingGame.keywords)
|
||||
assertEquals(listOf(GameFeature.SINGLEPLAYER), existingGame.features)
|
||||
assertEquals(listOf(PlayerPerspective.FIRST_PERSON), existingGame.perspectives)
|
||||
|
||||
// Verify all field sources were updated to user source
|
||||
existingGame.metadata.fields.forEach { (_, meta) ->
|
||||
assert(meta.source is GameFieldUserSource) {
|
||||
"Expected GameFieldUserSource but got ${meta.source}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `edit should handle metadata with null matchConfirmed`() {
|
||||
val gameId = 1L
|
||||
val existingGame = createTestGame(gameId)
|
||||
existingGame.metadata.matchConfirmed = false
|
||||
|
||||
val userDetails = mockk<UserDetails> {
|
||||
every { username } returns "testuser"
|
||||
}
|
||||
|
||||
every { authentication.principal } returns userDetails
|
||||
every { gameRepository.findByIdOrNull(gameId) } returns existingGame
|
||||
every { userService.getByUsernameNonNull("testuser") } returns mockUser
|
||||
every { gameRepository.save(existingGame) } returns existingGame
|
||||
|
||||
val updateDto = GameUpdateDto(
|
||||
id = gameId,
|
||||
title = null,
|
||||
platforms = null,
|
||||
release = null,
|
||||
coverUrl = null,
|
||||
headerUrl = null,
|
||||
comment = null,
|
||||
summary = null,
|
||||
developers = null,
|
||||
publishers = null,
|
||||
genres = null,
|
||||
themes = null,
|
||||
keywords = null,
|
||||
features = null,
|
||||
perspectives = null,
|
||||
metadata = GameUpdateMetadataDto(matchConfirmed = null)
|
||||
)
|
||||
|
||||
gameService.edit(updateDto)
|
||||
|
||||
// matchConfirmed should remain unchanged when null is provided
|
||||
assertEquals(false, existingGame.metadata.matchConfirmed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `edit should not update fields when all are null`() {
|
||||
val gameId = 1L
|
||||
val existingGame = createTestGame(gameId)
|
||||
val originalTitle = existingGame.title
|
||||
val originalPlatforms = existingGame.platforms.toList()
|
||||
|
||||
val userDetails = mockk<UserDetails> {
|
||||
every { username } returns "testuser"
|
||||
}
|
||||
|
||||
every { authentication.principal } returns userDetails
|
||||
every { gameRepository.findByIdOrNull(gameId) } returns existingGame
|
||||
every { userService.getByUsernameNonNull("testuser") } returns mockUser
|
||||
every { gameRepository.save(existingGame) } returns existingGame
|
||||
|
||||
val updateDto = GameUpdateDto(
|
||||
id = gameId,
|
||||
title = null,
|
||||
platforms = null,
|
||||
release = null,
|
||||
coverUrl = null,
|
||||
headerUrl = null,
|
||||
comment = null,
|
||||
summary = null,
|
||||
developers = null,
|
||||
publishers = null,
|
||||
genres = null,
|
||||
themes = null,
|
||||
keywords = null,
|
||||
features = null,
|
||||
perspectives = null,
|
||||
metadata = null
|
||||
)
|
||||
|
||||
gameService.edit(updateDto)
|
||||
|
||||
assertEquals(originalTitle, existingGame.title)
|
||||
assertEquals(originalPlatforms, existingGame.platforms)
|
||||
verify(exactly = 1) { gameRepository.save(existingGame) }
|
||||
}
|
||||
|
||||
// ========================
|
||||
// create(single) — called via matchManually(persist=true) with images
|
||||
// ========================
|
||||
|
||||
@Test
|
||||
fun `matchManually with persist should download coverImage and headerImage`() {
|
||||
val originalIds = mapOf(
|
||||
"org.gameyfin.app.games.TestProvider" to ExternalProviderIdDto("test-plugin", "123")
|
||||
)
|
||||
val path = Path.of("/test/game.exe")
|
||||
val coverUri = java.net.URI("https://example.com/cover.jpg")
|
||||
val headerUri = java.net.URI("https://example.com/header.jpg")
|
||||
val screenshotUri = java.net.URI("https://example.com/screenshot.jpg")
|
||||
|
||||
val metadata = org.gameyfin.pluginapi.gamemetadata.GameMetadata(
|
||||
originalId = "123",
|
||||
title = "Image Test Game",
|
||||
platforms = setOf(Platform.PC_MICROSOFT_WINDOWS),
|
||||
coverUrls = setOf(coverUri),
|
||||
headerUrls = setOf(headerUri),
|
||||
screenshotUrls = setOf(screenshotUri)
|
||||
)
|
||||
|
||||
val provider = spyk(TestProvider(metadata))
|
||||
val pluginEntry = mockk<PluginManagementEntry>(relaxed = true) {
|
||||
every { pluginId } returns "test-plugin"
|
||||
every { priority } returns 1
|
||||
}
|
||||
|
||||
val coverImage = Image(originalUrl = coverUri.toString(), type = ImageType.COVER)
|
||||
val headerImage = Image(originalUrl = headerUri.toString(), type = ImageType.HEADER)
|
||||
val screenshotImage = Image(originalUrl = screenshotUri.toString(), type = ImageType.SCREENSHOT)
|
||||
|
||||
every { pluginManager.getExtensions(GameMetadataProvider::class.java) } returns listOf(provider)
|
||||
every { pluginService.getPluginManagementEntry(provider.javaClass) } returns pluginEntry
|
||||
every { companyService.createOrGet(any()) } returns mockk(relaxed = true)
|
||||
every { imageService.createOrGet(match { it.type == ImageType.COVER }) } returns coverImage
|
||||
every { imageService.createOrGet(match { it.type == ImageType.HEADER }) } returns headerImage
|
||||
every { imageService.createOrGet(match { it.type == ImageType.SCREENSHOT }) } returns screenshotImage
|
||||
every { imageService.downloadIfNew(any()) } just Runs
|
||||
every { filesystemService.calculateFileSize(any()) } returns 2000L
|
||||
every { gameRepository.save(any()) } answers { firstArg() }
|
||||
|
||||
val result = gameService.matchManually(originalIds, path, library, null, persist = true)
|
||||
|
||||
assertNotNull(result)
|
||||
verify(atLeast = 1) { imageService.downloadIfNew(any()) }
|
||||
verify(exactly = 1) { gameRepository.save(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `matchManually with persist should handle image download exception gracefully`() {
|
||||
val originalIds = mapOf(
|
||||
"org.gameyfin.app.games.TestProvider" to ExternalProviderIdDto("test-plugin", "123")
|
||||
)
|
||||
val path = Path.of("/test/game.exe")
|
||||
val coverUri = java.net.URI("https://example.com/cover.jpg")
|
||||
|
||||
val metadata = org.gameyfin.pluginapi.gamemetadata.GameMetadata(
|
||||
originalId = "123",
|
||||
title = "Error Image Game",
|
||||
platforms = setOf(Platform.PC_MICROSOFT_WINDOWS),
|
||||
coverUrls = setOf(coverUri)
|
||||
)
|
||||
|
||||
val provider = spyk(TestProvider(metadata))
|
||||
val pluginEntry = mockk<PluginManagementEntry>(relaxed = true) {
|
||||
every { pluginId } returns "test-plugin"
|
||||
every { priority } returns 1
|
||||
}
|
||||
|
||||
val coverImage = Image(originalUrl = coverUri.toString(), type = ImageType.COVER)
|
||||
|
||||
every { pluginManager.getExtensions(GameMetadataProvider::class.java) } returns listOf(provider)
|
||||
every { pluginService.getPluginManagementEntry(provider.javaClass) } returns pluginEntry
|
||||
every { companyService.createOrGet(any()) } returns mockk(relaxed = true)
|
||||
every { imageService.createOrGet(any()) } returns coverImage
|
||||
every { imageService.downloadIfNew(any()) } throws RuntimeException("Download failed")
|
||||
every { filesystemService.calculateFileSize(any()) } returns 2000L
|
||||
every { gameRepository.save(any()) } answers { firstArg() }
|
||||
|
||||
val result = gameService.matchManually(originalIds, path, library, null, persist = true)
|
||||
|
||||
// Should still succeed despite image download error
|
||||
assertNotNull(result)
|
||||
verify(exactly = 1) { gameRepository.save(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getEnumPropertyValues should return all enum entries`() {
|
||||
val result = gameService.getEnumPropertyValues()
|
||||
|
||||
assertEquals(Genre.entries, result.genres)
|
||||
assertEquals(Theme.entries, result.themes)
|
||||
assertEquals(GameFeature.entries, result.features)
|
||||
assertEquals(PlayerPerspective.entries, result.perspectives)
|
||||
}
|
||||
|
||||
// ========================
|
||||
// applyMetadataFields — all fields populated
|
||||
// ========================
|
||||
|
||||
@Test
|
||||
fun `matchManually should populate all metadata fields when fully-populated metadata is provided`() {
|
||||
val coverUri = java.net.URI("https://example.com/cover.jpg")
|
||||
val headerUri = java.net.URI("https://example.com/header.jpg")
|
||||
val screenshotUri = java.net.URI("https://example.com/screenshot.jpg")
|
||||
val videoUri = java.net.URI("https://example.com/video.mp4")
|
||||
val releaseInstant = Instant.parse("2024-06-15T00:00:00Z")
|
||||
|
||||
val metadata = org.gameyfin.pluginapi.gamemetadata.GameMetadata(
|
||||
originalId = "full-123",
|
||||
title = "Fully Populated Game",
|
||||
description = "A full description",
|
||||
platforms = setOf(Platform.PC_MICROSOFT_WINDOWS),
|
||||
coverUrls = setOf(coverUri),
|
||||
headerUrls = setOf(headerUri),
|
||||
screenshotUrls = setOf(screenshotUri),
|
||||
release = releaseInstant,
|
||||
userRating = 85,
|
||||
criticRating = 90,
|
||||
publishedBy = setOf("TestPublisher"),
|
||||
developedBy = setOf("TestDeveloper"),
|
||||
genres = setOf(Genre.ACTION),
|
||||
themes = setOf(Theme.FANTASY),
|
||||
keywords = setOf("keyword1", "keyword2"),
|
||||
features = setOf(GameFeature.SINGLEPLAYER),
|
||||
perspectives = setOf(PlayerPerspective.FIRST_PERSON),
|
||||
videoUrls = setOf(videoUri)
|
||||
)
|
||||
|
||||
val provider = spyk(TestProvider(metadata))
|
||||
val pluginEntry = mockk<PluginManagementEntry>(relaxed = true) {
|
||||
every { pluginId } returns "test-plugin"
|
||||
every { priority } returns 1
|
||||
}
|
||||
|
||||
val originalIds = mapOf(
|
||||
provider.javaClass.name to ExternalProviderIdDto("test-plugin", "full-123")
|
||||
)
|
||||
val path = Path.of("/test/full-game.exe")
|
||||
|
||||
every { pluginManager.getExtensions(GameMetadataProvider::class.java) } returns listOf(provider)
|
||||
every { pluginService.getPluginManagementEntry(provider.javaClass) } returns pluginEntry
|
||||
every { imageService.createOrGet(any()) } returns mockk(relaxed = true)
|
||||
every { filesystemService.calculateFileSize(any()) } returns 5000L
|
||||
|
||||
val result = gameService.matchManually(originalIds, path, library, null, persist = false)
|
||||
|
||||
assertNotNull(result)
|
||||
assertEquals("Fully Populated Game", result.title)
|
||||
assertEquals("A full description", result.summary)
|
||||
assertEquals(releaseInstant, result.release)
|
||||
assertEquals(85, result.userRating)
|
||||
assertEquals(90, result.criticRating)
|
||||
assertEquals(1, result.publishers.size)
|
||||
assertEquals("TestPublisher", result.publishers[0].name)
|
||||
assertEquals(CompanyType.PUBLISHER, result.publishers[0].type)
|
||||
assertEquals(1, result.developers.size)
|
||||
assertEquals("TestDeveloper", result.developers[0].name)
|
||||
assertEquals(CompanyType.DEVELOPER, result.developers[0].type)
|
||||
assertEquals(listOf(Genre.ACTION), result.genres)
|
||||
assertEquals(listOf(Theme.FANTASY), result.themes)
|
||||
assertEquals(listOf("keyword1", "keyword2"), result.keywords)
|
||||
assertEquals(listOf(GameFeature.SINGLEPLAYER), result.features)
|
||||
assertEquals(listOf(PlayerPerspective.FIRST_PERSON), result.perspectives)
|
||||
assertEquals(listOf(videoUri), result.videoUrls)
|
||||
assertEquals(1, result.images.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `matchManually should handle metadata with blank title and null-or-empty optional fields`() {
|
||||
// Metadata with empty/null optional fields to exercise the takeIf guards
|
||||
val metadata = org.gameyfin.pluginapi.gamemetadata.GameMetadata(
|
||||
originalId = "sparse-456",
|
||||
title = "Sparse Game",
|
||||
description = "", // blank description
|
||||
platforms = emptySet(), // empty platforms
|
||||
coverUrls = null,
|
||||
headerUrls = null,
|
||||
screenshotUrls = null,
|
||||
release = null,
|
||||
userRating = null,
|
||||
criticRating = null,
|
||||
publishedBy = emptySet(), // empty publishers
|
||||
developedBy = emptySet(), // empty developers
|
||||
genres = emptySet(), // empty genres
|
||||
themes = emptySet(), // empty themes
|
||||
keywords = emptySet(), // empty keywords
|
||||
features = emptySet(), // empty features
|
||||
perspectives = emptySet(), // empty perspectives
|
||||
videoUrls = emptySet() // empty videoUrls
|
||||
)
|
||||
|
||||
val provider = spyk(TestProvider(metadata))
|
||||
val pluginEntry = mockk<PluginManagementEntry>(relaxed = true) {
|
||||
every { pluginId } returns "test-plugin"
|
||||
every { priority } returns 1
|
||||
}
|
||||
|
||||
val originalIds = mapOf(
|
||||
provider.javaClass.name to ExternalProviderIdDto("test-plugin", "sparse-456")
|
||||
)
|
||||
val path = Path.of("/test/sparse-game.exe")
|
||||
|
||||
every { pluginManager.getExtensions(GameMetadataProvider::class.java) } returns listOf(provider)
|
||||
every { pluginService.getPluginManagementEntry(provider.javaClass) } returns pluginEntry
|
||||
every { imageService.createOrGet(any()) } returns mockk(relaxed = true)
|
||||
every { filesystemService.calculateFileSize(any()) } returns 1000L
|
||||
|
||||
val result = gameService.matchManually(originalIds, path, library, null, persist = false)
|
||||
|
||||
assertNotNull(result)
|
||||
// Title should still be set (it's non-blank)
|
||||
assertEquals("Sparse Game", result.title)
|
||||
// All optional fields should remain at defaults since metadata was empty/null
|
||||
assertNull(result.summary)
|
||||
assertNull(result.release)
|
||||
assertNull(result.coverImage)
|
||||
assertNull(result.headerImage)
|
||||
assertNull(result.userRating)
|
||||
assertNull(result.criticRating)
|
||||
assertEquals(emptyList<Company>(), result.publishers)
|
||||
assertEquals(emptyList<Company>(), result.developers)
|
||||
assertEquals(emptyList<Genre>(), result.genres)
|
||||
assertEquals(emptyList<Theme>(), result.themes)
|
||||
assertEquals(emptyList<String>(), result.keywords)
|
||||
assertEquals(emptyList<GameFeature>(), result.features)
|
||||
assertEquals(emptyList<PlayerPerspective>(), result.perspectives)
|
||||
assertEquals(emptyList<java.net.URI>(), result.videoUrls)
|
||||
assertEquals(emptyList<Image>(), result.images)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `matchManually should skip fields from lower priority plugin when higher priority already set them`() {
|
||||
val highPriorityMetadata = org.gameyfin.pluginapi.gamemetadata.GameMetadata(
|
||||
originalId = "hp-1",
|
||||
title = "HP Title",
|
||||
description = "HP Summary",
|
||||
platforms = setOf(Platform.PC_MICROSOFT_WINDOWS),
|
||||
release = Instant.parse("2024-01-01T00:00:00Z"),
|
||||
userRating = 95,
|
||||
criticRating = 88,
|
||||
publishedBy = setOf("HP Publisher"),
|
||||
developedBy = setOf("HP Developer"),
|
||||
genres = setOf(Genre.ROLE_PLAYING),
|
||||
themes = setOf(Theme.SCIENCE_FICTION),
|
||||
keywords = setOf("hp-kw"),
|
||||
features = setOf(GameFeature.MULTIPLAYER),
|
||||
perspectives = setOf(PlayerPerspective.THIRD_PERSON),
|
||||
videoUrls = setOf(java.net.URI("https://hp.com/video.mp4"))
|
||||
)
|
||||
|
||||
val lowPriorityMetadata = org.gameyfin.pluginapi.gamemetadata.GameMetadata(
|
||||
originalId = "lp-2",
|
||||
title = "LP Title",
|
||||
description = "LP Summary",
|
||||
platforms = setOf(Platform.PLAYSTATION_5),
|
||||
release = Instant.parse("2023-01-01T00:00:00Z"),
|
||||
userRating = 50,
|
||||
criticRating = 40,
|
||||
publishedBy = setOf("LP Publisher"),
|
||||
developedBy = setOf("LP Developer"),
|
||||
genres = setOf(Genre.ADVENTURE),
|
||||
themes = setOf(Theme.FANTASY),
|
||||
keywords = setOf("lp-kw"),
|
||||
features = setOf(GameFeature.SINGLEPLAYER),
|
||||
perspectives = setOf(PlayerPerspective.FIRST_PERSON),
|
||||
videoUrls = setOf(java.net.URI("https://lp.com/video.mp4"))
|
||||
)
|
||||
|
||||
val highProvider = spyk(TestProvider(highPriorityMetadata))
|
||||
val lowProvider = spyk(TestProvider(lowPriorityMetadata))
|
||||
|
||||
val highEntry = mockk<PluginManagementEntry>(relaxed = true) {
|
||||
every { pluginId } returns "hp-plugin"
|
||||
every { priority } returns 100
|
||||
}
|
||||
val lowEntry = mockk<PluginManagementEntry>(relaxed = true) {
|
||||
every { pluginId } returns "lp-plugin"
|
||||
every { priority } returns 10
|
||||
}
|
||||
|
||||
val originalIds = mapOf(
|
||||
highProvider.javaClass.name to ExternalProviderIdDto("hp-plugin", "hp-1"),
|
||||
lowProvider.javaClass.name to ExternalProviderIdDto("lp-plugin", "lp-2")
|
||||
)
|
||||
val path = Path.of("/test/priority-game.exe")
|
||||
|
||||
every { pluginManager.getExtensions(GameMetadataProvider::class.java) } returns listOf(
|
||||
highProvider,
|
||||
lowProvider
|
||||
)
|
||||
every { pluginService.getPluginManagementEntry(highProvider.javaClass) } returns highEntry
|
||||
every { pluginService.getPluginManagementEntry(lowProvider.javaClass) } returns lowEntry
|
||||
every { imageService.createOrGet(any()) } returns mockk(relaxed = true)
|
||||
every { filesystemService.calculateFileSize(any()) } returns 3000L
|
||||
|
||||
val result = gameService.matchManually(originalIds, path, library, null, persist = false)
|
||||
|
||||
assertNotNull(result)
|
||||
// All fields should come from the high-priority plugin
|
||||
assertEquals("HP Title", result.title)
|
||||
assertEquals("HP Summary", result.summary)
|
||||
assertEquals(Instant.parse("2024-01-01T00:00:00Z"), result.release)
|
||||
assertEquals(95, result.userRating)
|
||||
assertEquals(88, result.criticRating)
|
||||
assertEquals("HP Publisher", result.publishers[0].name)
|
||||
assertEquals("HP Developer", result.developers[0].name)
|
||||
assertEquals(listOf(Genre.ROLE_PLAYING), result.genres)
|
||||
assertEquals(listOf(Theme.SCIENCE_FICTION), result.themes)
|
||||
assertEquals(listOf("hp-kw"), result.keywords)
|
||||
assertEquals(listOf(GameFeature.MULTIPLAYER), result.features)
|
||||
assertEquals(listOf(PlayerPerspective.THIRD_PERSON), result.perspectives)
|
||||
assertEquals(listOf(java.net.URI("https://hp.com/video.mp4")), result.videoUrls)
|
||||
}
|
||||
|
||||
private fun createTestGame(id: Long?, title: String = "Test Game"): Game {
|
||||
return Game(
|
||||
id = id,
|
||||
@@ -1396,13 +2335,9 @@ class GameServiceTest {
|
||||
perspectives = emptyList(),
|
||||
images = mutableListOf(),
|
||||
videoUrls = emptyList(),
|
||||
metadata = GameMetadata(
|
||||
metadata = org.gameyfin.app.games.entities.GameMetadata(
|
||||
path = "/test/path",
|
||||
fileSize = 1000L,
|
||||
fields = mutableMapOf(),
|
||||
originalIds = emptyMap(),
|
||||
downloadCount = 0,
|
||||
matchConfirmed = false
|
||||
fileSize = 1000L
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package org.gameyfin.app.libraries
|
||||
|
||||
import io.mockk.*
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry
|
||||
import org.gameyfin.app.config.ConfigProperties
|
||||
import org.gameyfin.app.config.ConfigService
|
||||
import org.gameyfin.app.core.filesystem.FilesystemScanResult
|
||||
import org.gameyfin.app.core.filesystem.FilesystemService
|
||||
import org.gameyfin.app.core.metrics.ScanMetrics
|
||||
import org.gameyfin.app.core.plugins.PluginService
|
||||
import org.gameyfin.app.core.plugins.dto.PluginDto
|
||||
import org.gameyfin.app.games.entities.Game
|
||||
import org.gameyfin.app.games.entities.GameMetadata
|
||||
import org.gameyfin.app.games.repositories.GameRepository
|
||||
@@ -11,9 +16,12 @@ import org.gameyfin.app.libraries.entities.IgnoredPath
|
||||
import org.gameyfin.app.libraries.entities.Library
|
||||
import org.gameyfin.app.libraries.enums.ScanType
|
||||
import org.gameyfin.app.libraries.scan.LibraryGameProcessor
|
||||
import org.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import org.pf4j.PluginState
|
||||
import java.nio.file.Path
|
||||
import java.time.Instant
|
||||
import kotlin.io.path.Path
|
||||
@@ -28,6 +36,7 @@ class LibraryScanServiceTest {
|
||||
private lateinit var libraryScanService: LibraryScanService
|
||||
private lateinit var ignoredPathRepository: IgnoredPathRepository
|
||||
private lateinit var pluginService: PluginService
|
||||
private lateinit var configService: ConfigService
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
@@ -38,6 +47,15 @@ class LibraryScanServiceTest {
|
||||
gameRepository = mockk()
|
||||
ignoredPathRepository = mockk()
|
||||
pluginService = mockk()
|
||||
configService = mockk()
|
||||
|
||||
// By default, at least one GameMetadataProvider is started so scans are allowed
|
||||
every { pluginService.getAllByTypeAndState(GameMetadataProvider::class, PluginState.STARTED) } returns listOf(
|
||||
mockk<PluginDto>()
|
||||
)
|
||||
|
||||
// Return default max-concurrency value
|
||||
every { configService.get(ConfigProperties.Libraries.Scan.MaxConcurrency) } returns ConfigProperties.Libraries.Scan.MaxConcurrency.default
|
||||
|
||||
libraryScanService = LibraryScanService(
|
||||
libraryRepository,
|
||||
@@ -46,7 +64,9 @@ class LibraryScanServiceTest {
|
||||
libraryGameProcessor,
|
||||
gameRepository,
|
||||
ignoredPathRepository,
|
||||
pluginService
|
||||
pluginService,
|
||||
configService,
|
||||
ScanMetrics(SimpleMeterRegistry())
|
||||
)
|
||||
}
|
||||
|
||||
@@ -150,6 +170,23 @@ class LibraryScanServiceTest {
|
||||
verify(exactly = 0) { filesystemService.scanLibraryForGamefiles(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `triggerScan should throw when no GameMetadataProvider plugin is started`() {
|
||||
every {
|
||||
pluginService.getAllByTypeAndState(
|
||||
GameMetadataProvider::class,
|
||||
PluginState.STARTED
|
||||
)
|
||||
} returns emptyList()
|
||||
|
||||
assertThrows<IllegalStateException> {
|
||||
libraryScanService.triggerScan(ScanType.QUICK, null)
|
||||
}
|
||||
|
||||
verify(exactly = 0) { libraryRepository.findAll() }
|
||||
verify(exactly = 0) { filesystemService.scanLibraryForGamefiles(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `triggerScan should handle filesystem scan errors`() {
|
||||
val library = createTestLibrary(1L)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user