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:
Simon
2026-03-13 15:34:06 +01:00
committed by GitHub
parent ecd369cd30
commit 3a932d953f
123 changed files with 6169 additions and 2003 deletions
+10 -7
View File
@@ -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"]}")
+978 -1014
View File
File diff suppressed because it is too large Load Diff
+114 -114
View File
@@ -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.
+8 -2
View File
@@ -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";
+16 -1
View File
@@ -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);
+53 -17
View File
@@ -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()
}
}
@@ -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 {
@@ -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)
}
@@ -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
@@ -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,
@@ -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),
)
}
@@ -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
@@ -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
}
}
@@ -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()
}
}
@@ -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
}
}
@@ -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))
@@ -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
@@ -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
+3 -1
View File
@@ -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:
+40 -25
View File
@@ -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.
@@ -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);
@@ -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 }
}
@@ -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
}
}
@@ -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
@@ -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"))
}
}
@@ -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