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
@@ -0,0 +1,70 @@
name: 'Docker Build Platform'
description: 'Builds and pushes a single-platform Docker image by digest.'
runs:
using: 'composite'
steps:
- name: Prepare platform pair
id: prepare
shell: bash
run: |
platform="${{ inputs.platform }}"
echo "platform_pair=${platform//\//-}" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ inputs.ghcr_username }}
password: ${{ inputs.ghcr_token }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
env:
BUILDKIT_PROGRESS: plain
with:
context: ${{ inputs.context }}
file: ${{ inputs.file }}
platforms: ${{ inputs.platform }}
outputs: type=image,"name=${{ inputs.image_name }}",push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=build-${{ steps.prepare.outputs.platform_pair }}
cache-to: type=gha,mode=max,scope=build-${{ steps.prepare.outputs.platform_pair }}
- name: Export digest
shell: bash
run: |
mkdir -p "${{ runner.temp }}/digests"
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v7
with:
name: digests-${{ steps.prepare.outputs.platform_pair }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
inputs:
ghcr_username:
required: true
description: 'GHCR username'
ghcr_token:
required: true
description: 'GHCR token'
context:
required: true
description: 'Build context'
file:
required: true
description: 'Dockerfile path'
platform:
required: true
description: 'Target platform (e.g. linux/amd64)'
image_name:
required: true
description: 'Image name without tag (e.g. ghcr.io/gameyfin/gameyfin)'
@@ -1,8 +1,15 @@
name: 'Docker Build and Push' name: 'Docker Create Manifest'
description: 'Builds and pushes Docker images to GHCR with flexible tagging.' description: 'Creates and pushes a multi-arch manifest from per-platform digests.'
runs: runs:
using: 'composite' using: 'composite'
steps: steps:
- name: Download digests
uses: actions/download-artifact@v8
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -24,16 +31,19 @@ runs:
COMBINED_TAGS="$DEFAULT_TAGS,$UBUNTU_TAGS" COMBINED_TAGS="$DEFAULT_TAGS,$UBUNTU_TAGS"
echo "combined_tags=$COMBINED_TAGS" >> $GITHUB_OUTPUT echo "combined_tags=$COMBINED_TAGS" >> $GITHUB_OUTPUT
- name: Build and push Docker image (Ubuntu) - name: Create manifest list and push
uses: docker/build-push-action@v5 shell: bash
with: working-directory: ${{ runner.temp }}/digests
context: ${{ inputs.context }} run: |
file: docker/Dockerfile.ubuntu TAG_ARGS=$(echo "${{ steps.combined_tags.outputs.combined_tags }}" | tr ',' '\n' | xargs -I {} echo "-t {}" | tr '\n' ' ')
platforms: ${{ inputs.platforms }} docker buildx imagetools create $TAG_ARGS \
push: true $(printf '${{ inputs.image_name }}@sha256:%s ' *)
tags: ${{ steps.combined_tags.outputs.combined_tags }}
cache-from: type=gha - name: Inspect image
cache-to: type=gha shell: bash
run: |
TAG=$(echo "${{ inputs.tags }}" | cut -d',' -f1)
docker buildx imagetools inspect "$TAG"
inputs: inputs:
ghcr_username: ghcr_username:
@@ -42,16 +52,10 @@ inputs:
ghcr_token: ghcr_token:
required: true required: true
description: 'GHCR token' description: 'GHCR token'
context:
required: true
description: 'Build context'
dockerfile:
required: true
description: 'Dockerfile path'
platforms:
required: true
description: 'Platforms to build for'
tags: tags:
required: true required: true
description: 'Comma-separated list of image tags' description: 'Comma-separated list of image tags'
image_name:
required: true
description: 'Image name without tag (e.g. ghcr.io/gameyfin/gameyfin)'
+7
View File
@@ -0,0 +1,7 @@
---
description: Analyze and fix Sonar issues reported in this file
---
Sonar reported some issues in this file, please fix them.
Use the "list warnings/errors" command to get the list of issues, and then fix them one by one.
+34 -9
View File
@@ -34,16 +34,24 @@ jobs:
report_paths: '**/build/test-results/test/TEST-*.xml' report_paths: '**/build/test-results/test/TEST-*.xml'
- name: Upload build outputs - name: Upload build outputs
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@v7
with: with:
name: build-outputs name: build-outputs
path: | path: |
app/build/libs/** app/build/libs/**
plugins/**/build/libs/**/*.jar plugins/**/build/libs/**/*.jar
docker: docker-build:
needs: build needs: build
runs-on: ubuntu-latest strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-24.04
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions: permissions:
packages: write packages: write
steps: steps:
@@ -51,11 +59,30 @@ jobs:
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Download build outputs - name: Download build outputs
uses: actions/download-artifact@v7 uses: actions/download-artifact@v8
with: with:
name: build-outputs name: build-outputs
path: . path: .
- name: Build platform image
uses: ./.github/actions/docker-build-platform
with:
ghcr_username: ${{ github.actor }}
ghcr_token: ${{ secrets.GITHUB_TOKEN }}
context: .
file: docker/Dockerfile.ubuntu
platform: ${{ matrix.platform }}
image_name: ghcr.io/gameyfin/gameyfin
docker-merge:
needs: [build, docker-build]
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Extract tag from branch name - name: Extract tag from branch name
id: extract_tag id: extract_tag
run: | run: |
@@ -63,12 +90,10 @@ jobs:
TAG="${BRANCH_NAME#fix/}" TAG="${BRANCH_NAME#fix/}"
echo "tag=$TAG" >> $GITHUB_OUTPUT echo "tag=$TAG" >> $GITHUB_OUTPUT
- name: Build and push Docker image - name: Create and push manifest
uses: ./.github/actions/docker-build-push uses: ./.github/actions/docker-create-manifest
with: with:
ghcr_username: ${{ github.actor }} ghcr_username: ${{ github.actor }}
ghcr_token: ${{ secrets.GITHUB_TOKEN }} ghcr_token: ${{ secrets.GITHUB_TOKEN }}
context: .
dockerfile: docker/Dockerfile
platforms: linux/arm64/v8,linux/amd64
tags: ghcr.io/gameyfin/gameyfin:${{ steps.extract_tag.outputs.tag }} tags: ghcr.io/gameyfin/gameyfin:${{ steps.extract_tag.outputs.tag }}
image_name: ghcr.io/gameyfin/gameyfin
+35 -8
View File
@@ -64,16 +64,24 @@ jobs:
report_paths: '**/build/test-results/test/TEST-*.xml' report_paths: '**/build/test-results/test/TEST-*.xml'
- name: Upload build outputs - name: Upload build outputs
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@v7
with: with:
name: build-outputs name: build-outputs
path: | path: |
app/build/libs/** app/build/libs/**
plugins/**/build/libs/**/*.jar plugins/**/build/libs/**/*.jar
docker: docker-build:
needs: build needs: build
runs-on: ubuntu-latest strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-24.04
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions: permissions:
packages: write packages: write
steps: steps:
@@ -83,17 +91,36 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Download build outputs - name: Download build outputs
uses: actions/download-artifact@v7 uses: actions/download-artifact@v8
with: with:
name: build-outputs name: build-outputs
path: . path: .
- name: Build and push Docker image - name: Build platform image
uses: ./.github/actions/docker-build-push uses: ./.github/actions/docker-build-platform
with: with:
ghcr_username: ${{ github.actor }} ghcr_username: ${{ github.actor }}
ghcr_token: ${{ secrets.GITHUB_TOKEN }} ghcr_token: ${{ secrets.GITHUB_TOKEN }}
context: . context: .
dockerfile: docker/Dockerfile file: docker/Dockerfile.ubuntu
platforms: linux/arm64/v8,linux/amd64 platform: ${{ matrix.platform }}
image_name: ghcr.io/gameyfin/gameyfin
docker-merge:
needs: [build, docker-build]
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Create and push manifest
uses: ./.github/actions/docker-create-manifest
with:
ghcr_username: ${{ github.actor }}
ghcr_token: ${{ secrets.GITHUB_TOKEN }}
tags: ghcr.io/gameyfin/gameyfin:${{ needs.build.outputs.version }} tags: ghcr.io/gameyfin/gameyfin:${{ needs.build.outputs.version }}
image_name: ghcr.io/gameyfin/gameyfin
+43 -16
View File
@@ -50,7 +50,7 @@ jobs:
jq ".version = \"$RELEASE_VERSION\"" app/package.json > app/package.json.tmp && mv app/package.json.tmp app/package.json jq ".version = \"$RELEASE_VERSION\"" app/package.json > app/package.json.tmp && mv app/package.json.tmp app/package.json
- name: Upload modified files - name: Upload modified files
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@v7
with: with:
name: modified-files name: modified-files
path: | path: |
@@ -71,7 +71,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Download modified files - name: Download modified files
uses: actions/download-artifact@v7 uses: actions/download-artifact@v8
with: with:
name: modified-files name: modified-files
@@ -95,16 +95,24 @@ jobs:
report_paths: '**/build/test-results/test/TEST-*.xml' report_paths: '**/build/test-results/test/TEST-*.xml'
- name: Upload build outputs - name: Upload build outputs
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@v7
with: with:
name: build-outputs name: build-outputs
path: | path: |
app/build/libs/** app/build/libs/**
plugins/**/build/libs/**/*.jar plugins/**/build/libs/**/*.jar
docker: docker-build:
needs: [ setup, build ] needs: [setup, build]
runs-on: ubuntu-latest strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-24.04
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions: permissions:
packages: write packages: write
steps: steps:
@@ -114,16 +122,37 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Download modified files - name: Download modified files
uses: actions/download-artifact@v7 uses: actions/download-artifact@v8
with: with:
name: modified-files name: modified-files
- name: Download build outputs - name: Download build outputs
uses: actions/download-artifact@v7 uses: actions/download-artifact@v8
with: with:
name: build-outputs name: build-outputs
path: . path: .
- name: Build platform image
uses: ./.github/actions/docker-build-platform
with:
ghcr_username: ${{ github.actor }}
ghcr_token: ${{ secrets.GITHUB_TOKEN }}
context: .
file: docker/Dockerfile.ubuntu
platform: ${{ matrix.platform }}
image_name: ghcr.io/gameyfin/gameyfin
docker-merge:
needs: [setup, docker-build]
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Generate container image tags - name: Generate container image tags
id: docker_tags id: docker_tags
run: | run: |
@@ -138,15 +167,13 @@ jobs:
TAGS="$GHCR_TAGS" TAGS="$GHCR_TAGS"
echo "tags=$TAGS" >> $GITHUB_OUTPUT echo "tags=$TAGS" >> $GITHUB_OUTPUT
- name: Build and push Docker image - name: Create and push manifest
uses: ./.github/actions/docker-build-push uses: ./.github/actions/docker-create-manifest
with: with:
ghcr_username: ${{ github.actor }} ghcr_username: ${{ github.actor }}
ghcr_token: ${{ secrets.GITHUB_TOKEN }} ghcr_token: ${{ secrets.GITHUB_TOKEN }}
context: .
dockerfile: docker/Dockerfile
platforms: linux/arm64/v8,linux/amd64
tags: ${{ steps.docker_tags.outputs.tags }} tags: ${{ steps.docker_tags.outputs.tags }}
image_name: ghcr.io/gameyfin/gameyfin
plugin_api: plugin_api:
needs: setup needs: setup
@@ -158,7 +185,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Download modified files - name: Download modified files
uses: actions/download-artifact@v7 uses: actions/download-artifact@v8
with: with:
name: modified-files name: modified-files
@@ -180,7 +207,7 @@ jobs:
ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVENCENTRAL_PASSWORD }} ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVENCENTRAL_PASSWORD }}
finalize: finalize:
needs: [ docker, plugin_api ] needs: [ docker-merge, plugin_api ]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
@@ -189,7 +216,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Download modified files - name: Download modified files
uses: actions/download-artifact@v7 uses: actions/download-artifact@v8
with: with:
name: modified-files name: modified-files
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
uses: gradle/actions/setup-gradle@v5 uses: gradle/actions/setup-gradle@v5
- name: Cache SonarCloud packages - name: Cache SonarCloud packages
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ~/.sonar/cache path: ~/.sonar/cache
key: ${{ runner.os }}-sonar key: ${{ runner.os }}-sonar
+1
View File
@@ -57,5 +57,6 @@ out/
*.state.json *.state.json
/plugins/data/ /plugins/data/
/plugins/state/ /plugins/state/
/plugins/**/*.jar
/plugindata/ /plugindata/
/docker-debug/ /docker-debug/
+1 -1
View File
@@ -9,7 +9,7 @@
<module name="Gameyfin.app.main" /> <module name="Gameyfin.app.main" />
<option name="SHORTEN_COMMAND_LINE" value="ARGS_FILE" /> <option name="SHORTEN_COMMAND_LINE" value="ARGS_FILE" />
<option name="SPRING_BOOT_MAIN_CLASS" value="org.gameyfin.app.GameyfinApplication" /> <option name="SPRING_BOOT_MAIN_CLASS" value="org.gameyfin.app.GameyfinApplication" />
<option name="VM_PARAMETERS" value="-Dpf4j.mode=development -Djava.net.preferIPv4Stack=true" /> <option name="VM_PARAMETERS" value="-Dpf4j.mode=development -Djava.net.preferIPv4Stack=true -Dvaadin.frontend.hotdeploy=true -Dvaadin.copilot.enable=false" />
<extension name="coverage"> <extension name="coverage">
<pattern> <pattern>
<option name="PATTERN" value="org.gameyfin.app.*" /> <option name="PATTERN" value="org.gameyfin.app.*" />
+5
View File
@@ -0,0 +1,5 @@
{
"sonarCloudOrganization": "gameyfin",
"projectKey": "gameyfin_gameyfin",
"region": "EU"
}
+16 -5
View File
@@ -2,6 +2,13 @@
<a href="https://gameyfin.org"> <a href="https://gameyfin.org">
<img src="assets/v2/Banner.svg" width="auto" alt="Gameyfin Logo"> <img src="assets/v2/Banner.svg" width="auto" alt="Gameyfin Logo">
</a> </a>
[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=gameyfin_gameyfin&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=gameyfin_gameyfin)
[![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=gameyfin_gameyfin&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=gameyfin_gameyfin)
[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=gameyfin_gameyfin&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=gameyfin_gameyfin)
[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=gameyfin_gameyfin&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=gameyfin_gameyfin)
</div>
<div align="center">
<h2>Gameyfin</h2> <h2>Gameyfin</h2>
<h4>Manage your video games.</h4> <h4>Manage your video games.</h4>
<p>simple / fast / <a href="https://gameyfin.org/blog/2025/12/22/why-gameyfin-is-foss/">FOSS</a></p> <p>simple / fast / <a href="https://gameyfin.org/blog/2025/12/22/why-gameyfin-is-foss/">FOSS</a></p>
@@ -11,9 +18,12 @@
Name and functionality inspired by [Jellyfin](https://jellyfin.org/). Name and functionality inspired by [Jellyfin](https://jellyfin.org/).
Gameyfin will turn your disorganized collection of video games into a beautiful, easy-to-navigate library that you can access from any device with a web browser. Gameyfin will turn your disorganized collection of video games into a beautiful, easy-to-navigate library that you can
It will automatically scan your game folders, download metadata and cover images, and present everything in a user-friendly interface. access from any device with a web browser.
Download your game files directly from the web UI, share your library with friends, and enjoy your games like never before. It will automatically scan your game folders, download metadata and cover images, and present everything in a
user-friendly interface.
Download your game files directly from the web UI, share your library with friends, and enjoy your games like never
before.
### Documentation ### Documentation
@@ -48,6 +58,7 @@ Gameyfin v2 is written in Kotlin and uses the following libraries/frameworks:
### Acknowledgements ### Acknowledgements
[![YourKit Logo](https://www.yourkit.com/images/yklogo.png)](https://www.yourkit.com/) [![YourKit Logo](https://www.yourkit.com/images/yklogo.png)](https://www.yourkit.com/)
Gameyfin is supported by [YourKit](https://www.yourkit.com/), the makers of [YourKit Java Profiler](https://yourkit.com/java/profiler/), a powerful tool for profiling Java and Kotlin applications. Gameyfin is supported by [YourKit](https://www.yourkit.com/), the makers
of [YourKit Java Profiler](https://yourkit.com/java/profiler/), a powerful tool for profiling Java and Kotlin
applications.
+10 -7
View File
@@ -1,6 +1,7 @@
import org.apache.tools.ant.filters.ReplaceTokens import org.apache.tools.ant.filters.ReplaceTokens
group = "org.gameyfin" group = "org.gameyfin"
version = rootProject.version
val appMainClass = "org.gameyfin.app.GameyfinApplicationKt" val appMainClass = "org.gameyfin.app.GameyfinApplicationKt"
plugins { plugins {
@@ -20,6 +21,10 @@ application {
mainClass.set(appMainClass) mainClass.set(appMainClass)
} }
springBoot {
buildInfo()
}
allOpen { allOpen {
annotations("javax.persistence.Entity", "javax.persistence.MappedSuperclass", "javax.persistence.Embedabble") annotations("javax.persistence.Entity", "javax.persistence.MappedSuperclass", "javax.persistence.Embedabble")
} }
@@ -44,10 +49,7 @@ dependencies {
implementation(kotlin("reflect")) implementation(kotlin("reflect"))
// Reactive // Reactive
implementation("org.springframework.boot:spring-boot-starter-webflux") { 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("io.projectreactor.kotlin:reactor-kotlin-extensions") implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
@@ -55,9 +57,7 @@ dependencies {
implementation("com.vaadin:vaadin-core") { implementation("com.vaadin:vaadin-core") {
exclude("com.vaadin:flow-react") exclude("com.vaadin:flow-react")
} }
implementation("com.vaadin:vaadin-spring-boot-starter") { implementation("com.vaadin:vaadin-spring-boot-starter")
exclude(group = "org.springframework.boot", module = "spring-boot-starter-tomcat")
}
implementation("com.vaadin:hilla-spring-boot-starter") implementation("com.vaadin:hilla-spring-boot-starter")
// Logging // Logging
@@ -81,6 +81,9 @@ dependencies {
// Plugins // Plugins
implementation(project(":plugin-api")) implementation(project(":plugin-api"))
// Caching
implementation("com.github.ben-manes.caffeine:caffeine:${rootProject.extra["caffeineVersion"]}")
// Utils // Utils
implementation("org.apache.tika:tika-core:${rootProject.extra["tikaVersion"]}") implementation("org.apache.tika:tika-core:${rootProject.extra["tikaVersion"]}")
implementation("me.xdrop:fuzzywuzzy:${rootProject.extra["fuzzywuzzyVersion"]}") 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", "name": "gameyfin",
"version": "2.3.3", "version": "2.4.0-preview",
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@heroui/react": "^2.8.7", "@heroui/react": "^2.8.7",
@@ -8,20 +8,20 @@
"@react-stately/data": "^3.12.2", "@react-stately/data": "^3.12.2",
"@react-types/shared": "^3.28.0", "@react-types/shared": "^3.28.0",
"@tailwindcss/vite": "4.1.13", "@tailwindcss/vite": "4.1.13",
"@vaadin/aura": "25.0.3", "@vaadin/aura": "25.0.4",
"@vaadin/common-frontend": "0.0.19", "@vaadin/common-frontend": "0.0.19",
"@vaadin/hilla-file-router": "25.0.4", "@vaadin/hilla-file-router": "25.0.5",
"@vaadin/hilla-frontend": "25.0.4", "@vaadin/hilla-frontend": "25.0.5",
"@vaadin/hilla-lit-form": "25.0.4", "@vaadin/hilla-lit-form": "25.0.5",
"@vaadin/hilla-react-auth": "25.0.4", "@vaadin/hilla-react-auth": "25.0.5",
"@vaadin/hilla-react-crud": "25.0.4", "@vaadin/hilla-react-crud": "25.0.5",
"@vaadin/hilla-react-form": "25.0.4", "@vaadin/hilla-react-form": "25.0.5",
"@vaadin/hilla-react-i18n": "25.0.4", "@vaadin/hilla-react-i18n": "25.0.5",
"@vaadin/hilla-react-signals": "25.0.4", "@vaadin/hilla-react-signals": "25.0.5",
"@vaadin/react-components": "25.0.3", "@vaadin/react-components": "25.0.4",
"@vaadin/vaadin-development-mode-detector": "2.0.7", "@vaadin/vaadin-development-mode-detector": "2.0.7",
"@vaadin/vaadin-lumo-styles": "25.0.3", "@vaadin/vaadin-lumo-styles": "25.0.4",
"@vaadin/vaadin-themable-mixin": "25.0.3", "@vaadin/vaadin-themable-mixin": "25.0.4",
"@vaadin/vaadin-usage-statistics": "2.1.3", "@vaadin/vaadin-usage-statistics": "2.1.3",
"blurhash": "^2.0.5", "blurhash": "^2.0.5",
"classnames": "^2.5.1", "classnames": "^2.5.1",
@@ -37,11 +37,11 @@
"postcss": "^8.5.6", "postcss": "^8.5.6",
"postcss-import": "^16.1.1", "postcss-import": "^16.1.1",
"rand-seed": "^2.1.7", "rand-seed": "^2.1.7",
"react": "19.2.3", "react": "19.2.4",
"react-accessible-treeview": "^2.11.1", "react-accessible-treeview": "^2.11.1",
"react-aria-components": "^1.7.1", "react-aria-components": "^1.7.1",
"react-confetti-boom": "^1.0.0", "react-confetti-boom": "^1.0.0",
"react-dom": "19.2.3", "react-dom": "19.2.4",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-player": "^2.16.0", "react-player": "^2.16.0",
"react-realtime-chart": "^0.8.1", "react-realtime-chart": "^0.8.1",
@@ -58,21 +58,21 @@
"@preact/signals-react-transform": "0.6.0", "@preact/signals-react-transform": "0.6.0",
"@rollup/plugin-replace": "6.0.3", "@rollup/plugin-replace": "6.0.3",
"@rollup/pluginutils": "5.3.0", "@rollup/pluginutils": "5.3.0",
"@types/node": "25.0.3", "@types/node": "25.0.10",
"@types/react": "19.2.7", "@types/react": "19.2.9",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@types/react-window": "^1.8.8", "@types/react-window": "^1.8.8",
"@vaadin/hilla-generator-cli": "25.0.4", "@vaadin/hilla-generator-cli": "25.0.5",
"@vaadin/hilla-generator-core": "25.0.4", "@vaadin/hilla-generator-core": "25.0.5",
"@vaadin/hilla-generator-plugin-backbone": "25.0.4", "@vaadin/hilla-generator-plugin-backbone": "25.0.5",
"@vaadin/hilla-generator-plugin-barrel": "25.0.4", "@vaadin/hilla-generator-plugin-barrel": "25.0.5",
"@vaadin/hilla-generator-plugin-client": "25.0.4", "@vaadin/hilla-generator-plugin-client": "25.0.5",
"@vaadin/hilla-generator-plugin-model": "25.0.4", "@vaadin/hilla-generator-plugin-model": "25.0.5",
"@vaadin/hilla-generator-plugin-push": "25.0.4", "@vaadin/hilla-generator-plugin-push": "25.0.5",
"@vaadin/hilla-generator-plugin-signals": "25.0.4", "@vaadin/hilla-generator-plugin-signals": "25.0.5",
"@vaadin/hilla-generator-plugin-subtypes": "25.0.4", "@vaadin/hilla-generator-plugin-subtypes": "25.0.5",
"@vaadin/hilla-generator-plugin-transfertypes": "25.0.4", "@vaadin/hilla-generator-plugin-transfertypes": "25.0.5",
"@vaadin/hilla-generator-utils": "25.0.4", "@vaadin/hilla-generator-utils": "25.0.5",
"@vitejs/plugin-react": "5.1.2", "@vitejs/plugin-react": "5.1.2",
"@vitejs/plugin-react-swc": "^3.7.0", "@vitejs/plugin-react-swc": "^3.7.0",
"baseline-browser-mapping": "^2.9.19", "baseline-browser-mapping": "^2.9.19",
@@ -137,87 +137,87 @@
"react-window": "$react-window", "react-window": "$react-window",
"blurhash": "$blurhash", "blurhash": "$blurhash",
"@vaadin/aura": "$@vaadin/aura", "@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/router": "2.0.1",
"@vaadin/vertical-layout": "25.0.3", "@vaadin/a11y-base": "25.0.4",
"@vaadin/virtual-list": "25.0.3" "@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": { "vaadin": {
"dependencies": { "dependencies": {
"@vaadin/aura": "25.0.3", "@vaadin/aura": "25.0.4",
"@vaadin/common-frontend": "0.0.19", "@vaadin/common-frontend": "0.0.19",
"@vaadin/hilla-file-router": "25.0.4", "@vaadin/hilla-file-router": "25.0.5",
"@vaadin/hilla-frontend": "25.0.4", "@vaadin/hilla-frontend": "25.0.5",
"@vaadin/hilla-lit-form": "25.0.4", "@vaadin/hilla-lit-form": "25.0.5",
"@vaadin/hilla-react-auth": "25.0.4", "@vaadin/hilla-react-auth": "25.0.5",
"@vaadin/hilla-react-crud": "25.0.4", "@vaadin/hilla-react-crud": "25.0.5",
"@vaadin/hilla-react-form": "25.0.4", "@vaadin/hilla-react-form": "25.0.5",
"@vaadin/hilla-react-i18n": "25.0.4", "@vaadin/hilla-react-i18n": "25.0.5",
"@vaadin/hilla-react-signals": "25.0.4", "@vaadin/hilla-react-signals": "25.0.5",
"@vaadin/react-components": "25.0.3", "@vaadin/react-components": "25.0.4",
"@vaadin/vaadin-development-mode-detector": "2.0.7", "@vaadin/vaadin-development-mode-detector": "2.0.7",
"@vaadin/vaadin-lumo-styles": "25.0.3", "@vaadin/vaadin-lumo-styles": "25.0.4",
"@vaadin/vaadin-themable-mixin": "25.0.3", "@vaadin/vaadin-themable-mixin": "25.0.4",
"@vaadin/vaadin-usage-statistics": "2.1.3", "@vaadin/vaadin-usage-statistics": "2.1.3",
"date-fns": "4.1.0", "date-fns": "4.1.0",
"lit": "3.3.2", "lit": "3.3.2",
"react": "19.2.3", "react": "19.2.4",
"react-dom": "19.2.3", "react-dom": "19.2.4",
"react-router": "7.12.0" "react-router": "7.12.0"
}, },
"devDependencies": { "devDependencies": {
@@ -225,20 +225,20 @@
"@preact/signals-react-transform": "0.6.0", "@preact/signals-react-transform": "0.6.0",
"@rollup/plugin-replace": "6.0.3", "@rollup/plugin-replace": "6.0.3",
"@rollup/pluginutils": "5.3.0", "@rollup/pluginutils": "5.3.0",
"@types/node": "25.0.3", "@types/node": "25.0.10",
"@types/react": "19.2.7", "@types/react": "19.2.9",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@vaadin/hilla-generator-cli": "25.0.4", "@vaadin/hilla-generator-cli": "25.0.5",
"@vaadin/hilla-generator-core": "25.0.4", "@vaadin/hilla-generator-core": "25.0.5",
"@vaadin/hilla-generator-plugin-backbone": "25.0.4", "@vaadin/hilla-generator-plugin-backbone": "25.0.5",
"@vaadin/hilla-generator-plugin-barrel": "25.0.4", "@vaadin/hilla-generator-plugin-barrel": "25.0.5",
"@vaadin/hilla-generator-plugin-client": "25.0.4", "@vaadin/hilla-generator-plugin-client": "25.0.5",
"@vaadin/hilla-generator-plugin-model": "25.0.4", "@vaadin/hilla-generator-plugin-model": "25.0.5",
"@vaadin/hilla-generator-plugin-push": "25.0.4", "@vaadin/hilla-generator-plugin-push": "25.0.5",
"@vaadin/hilla-generator-plugin-signals": "25.0.4", "@vaadin/hilla-generator-plugin-signals": "25.0.5",
"@vaadin/hilla-generator-plugin-subtypes": "25.0.4", "@vaadin/hilla-generator-plugin-subtypes": "25.0.5",
"@vaadin/hilla-generator-plugin-transfertypes": "25.0.4", "@vaadin/hilla-generator-plugin-transfertypes": "25.0.5",
"@vaadin/hilla-generator-utils": "25.0.4", "@vaadin/hilla-generator-utils": "25.0.5",
"@vitejs/plugin-react": "5.1.2", "@vitejs/plugin-react": "5.1.2",
"magic-string": "0.30.21", "magic-string": "0.30.21",
"rollup-plugin-brotli": "3.1.0", "rollup-plugin-brotli": "3.1.0",
@@ -251,6 +251,6 @@
"workbox-build": "7.4.0" "workbox-build": "7.4.0"
}, },
"disableUsageStatistics": true, "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 {initializePluginState} from "Frontend/state/PluginState";
import {isAdmin} from "Frontend/util/utils"; import {isAdmin} from "Frontend/util/utils";
import {useRouteMetadata} from "Frontend/util/routing"; import {useRouteMetadata} from "Frontend/util/routing";
import {useEffect} from "react"; import {useCallback, useEffect} from "react";
import {initializeGameRequestState} from "Frontend/state/GameRequestState"; import {initializeGameRequestState} from "Frontend/state/GameRequestState";
import {initializePlatformState} from "Frontend/state/PlatformState"; import {initializePlatformState} from "Frontend/state/PlatformState";
import {initializeDownloadSessionState} from "Frontend/state/DownloadSessionState"; import {initializeDownloadSessionState} from "Frontend/state/DownloadSessionState";
@@ -25,6 +25,12 @@ export default function App() {
client.middlewares = [ErrorHandlingMiddleware]; client.middlewares = [ErrorHandlingMiddleware];
const navigate = useNavigate(); 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(); const routeMetadata = useRouteMetadata();
useEffect(() => { useEffect(() => {
@@ -32,7 +38,7 @@ export default function App() {
}, [routeMetadata, window.location.href]); }, [routeMetadata, window.location.href]);
return ( 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"> <NextThemesProvider attribute="class" themes={themeNames()} defaultTheme="gameyfin-violet-dark">
<AuthProvider> <AuthProvider>
<ViewWithAuth/> <ViewWithAuth/>
@@ -7,48 +7,68 @@ import ArrayInput from "Frontend/components/general/input/ArrayInput";
import NumberInput from "Frontend/components/general/input/NumberInput"; import NumberInput from "Frontend/components/general/input/NumberInput";
import SliderInput from "Frontend/components/general/input/SliderInput"; 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) { 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) { if (configElement.allowedValues != null && configElement.allowedValues.length > 0) {
return ( return (
<SelectInput label={configElement.description} name={configElement.key} <SelectInput label={configElement.name} name={configElement.key}
values={configElement.allowedValues} {...props}/> description={description} resetValue={defaultValue}
values={configElement.allowedValues} {...commonProps}/>
); );
} }
switch (configElement.type.toLowerCase()) { switch (configElement.type.toLowerCase()) {
case "boolean": case "boolean":
return ( return (
<CheckboxInput label={configElement.description} name={configElement.key} {...props}/> <CheckboxInput label={configElement.name} name={configElement.key}
description={description} resetValue={defaultValue} {...commonProps}/>
); );
case "string": case "string":
return ( return (
<Input label={configElement.description} name={configElement.key} <Input label={configElement.name} name={configElement.key}
type={props.type && "text"} {...props}/> description={description} resetValue={defaultValue}
type={inputType ?? "text"} {...commonProps}/>
); );
case "float": case "float":
return ( return (
<NumberInput label={configElement.description} name={configElement.key} <NumberInput label={configElement.name} name={configElement.key}
step={0.1} {...props}/> description={description} resetValue={defaultValue}
step={0.1} {...commonProps}/>
); );
case "int": case "int":
if (configElement.min != null && configElement.max != null) { if (configElement.min != null && configElement.max != null) {
return ( return (
<SliderInput label={configElement.description} name={configElement.key} <SliderInput label={configElement.name} name={configElement.key}
min={configElement.min} description={description} resetValue={defaultValue}
max={configElement.max} minValue={configElement.min as number}
step={configElement.step ?? 1} maxValue={configElement.max as number}
{...props}/> step={(configElement.step as number) ?? 1}
{...commonProps}/>
); );
} }
return ( return (
<NumberInput label={configElement.description} name={configElement.key} <NumberInput label={configElement.name} name={configElement.key}
step={1} {...props}/> description={description} resetValue={defaultValue}
step={1} {...commonProps}/>
); );
case "array": case "array":
return ( 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: default:
return <pre>Unsupported type: {configElement.type} for key {configElement.key}</pre>; return <pre>Unsupported type: {configElement.type} for key {configElement.key}</pre>;
@@ -1,11 +1,13 @@
import React from "react"; 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 ConfigFormField from "Frontend/components/administration/ConfigFormField";
import withConfigPage from "Frontend/components/administration/withConfigPage"; import withConfigPage from "Frontend/components/administration/withConfigPage";
import Section from "Frontend/components/general/Section"; import Section from "Frontend/components/general/Section";
import * as Yup from 'yup'; import * as Yup from 'yup';
import "Frontend/util/yup-extensions"; import "Frontend/util/yup-extensions";
import {Button, Divider, Tooltip, useDisclosure} from "@heroui/react"; 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 {LibraryOverviewCard} from "Frontend/components/general/cards/LibraryOverviewCard";
import LibraryCreationModal from "Frontend/components/general/modals/LibraryCreationModal"; import LibraryCreationModal from "Frontend/components/general/modals/LibraryCreationModal";
import {useSnapshot} from "valtio/react"; import {useSnapshot} from "valtio/react";
@@ -15,6 +17,7 @@ import {collectionState} from "Frontend/state/CollectionState";
import {CollectionOverviewCard} from "Frontend/components/general/cards/CollectionOverviewCard"; import {CollectionOverviewCard} from "Frontend/components/general/cards/CollectionOverviewCard";
import CollectionCreationModal from "Frontend/components/general/modals/CollectionCreationModal"; import CollectionCreationModal from "Frontend/components/general/modals/CollectionCreationModal";
import CollectionPrioritiesModal from "Frontend/components/general/modals/CollectionPrioritiesModal"; import CollectionPrioritiesModal from "Frontend/components/general/modals/CollectionPrioritiesModal";
import {pluginState} from "Frontend/state/PluginState";
function GameManagementLayout({getConfig, formik}: any) { function GameManagementLayout({getConfig, formik}: any) {
const libraries = useSnapshot(libraryState); const libraries = useSnapshot(libraryState);
@@ -25,11 +28,31 @@ function GameManagementLayout({getConfig, formik}: any) {
const collectionCreationModal = useDisclosure(); const collectionCreationModal = useDisclosure();
const collectionOrderModal = useDisclosure(); const collectionOrderModal = useDisclosure();
const hasActiveMetadataPlugins = useSnapshot(pluginState).hasActiveMetadataPlugins;
async function triggerScan(scanType: ScanType) {
await LibraryEndpoint.triggerScan(scanType, undefined);
}
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex flex-row items-baseline justify-between"> <div className="flex flex-row items-baseline justify-between">
<h2 className="text-xl font-bold mt-8 mb-1">Libraries</h2> <h2 className="text-xl font-bold mt-8 mb-1">Libraries</h2>
<div className="flex flex-row gap-2"> <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"> <Tooltip content="Change library order">
<Button isIconOnly variant="flat" onPress={libraryOrderModal.onOpen}> <Button isIconOnly variant="flat" onPress={libraryOrderModal.onOpen}>
<ListNumbersIcon/> <ListNumbersIcon/>
@@ -92,6 +115,7 @@ function GameManagementLayout({getConfig, formik}: any) {
</div> </div>
<ConfigFormField configElement={getConfig("library.scan.title-match-min-ratio")}/> <ConfigFormField configElement={getConfig("library.scan.title-match-min-ratio")}/>
<ConfigFormField configElement={getConfig("library.scan.game-file-extensions")}/> <ConfigFormField configElement={getConfig("library.scan.game-file-extensions")}/>
<ConfigFormField configElement={getConfig("library.scan.max-concurrency")}/>
</div> </div>
<Section title="Metadata"/> <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 Section from "Frontend/components/general/Section";
import Input from "Frontend/components/general/input/Input"; import Input from "Frontend/components/general/input/Input";
import {addToast, Button, Input as NextUiInput, Tooltip} from "@heroui/react"; import {addToast, Button, Input as NextUiInput, Tooltip} from "@heroui/react";
import {Form, Formik} from "formik"; import {Form, Formik, FormikProps} from "formik";
import { ArrowCounterClockwiseIcon, CheckIcon, InfoIcon, TrashIcon } from "@phosphor-icons/react"; import {ArrowCounterClockwiseIcon, CheckIcon, InfoIcon, TrashIcon} from "@phosphor-icons/react";
import React, {useEffect, useState} from "react"; import React, {useEffect, useState} from "react";
import {useAuth} from "Frontend/util/auth"; import {useAuth} from "Frontend/util/auth";
import * as Yup from "yup"; import * as Yup from "yup";
@@ -12,9 +12,16 @@ import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
import {removeAvatar, uploadAvatar} from "Frontend/endpoints/AvatarEndpoint"; import {removeAvatar, uploadAvatar} from "Frontend/endpoints/AvatarEndpoint";
import Avatar from "Frontend/components/general/Avatar"; import Avatar from "Frontend/components/general/Avatar";
interface ProfileFormValues {
username: string | undefined;
email: string | undefined;
newPassword: string;
passwordRepeat: string;
}
export default function ProfileManagement() { export default function ProfileManagement() {
const auth = useAuth(); const auth = useAuth();
const [avatar, setAvatar] = useState<any>(); const [avatar, setAvatar] = useState<File>();
const [configSaved, setConfigSaved] = useState(false); const [configSaved, setConfigSaved] = useState(false);
const [messagesEnabled, setMessagesEnabled] = useState(false); const [messagesEnabled, setMessagesEnabled] = useState(false);
@@ -29,11 +36,11 @@ export default function ProfileManagement() {
}, [configSaved]) }, [configSaved])
function onFileSelected(event: any) { function onFileSelected(event: React.ChangeEvent<HTMLInputElement>) {
setAvatar(event.target.files[0]); setAvatar(event.target.files?.[0]);
} }
async function handleSubmit(values: any) { async function handleSubmit(values: ProfileFormValues) {
const userUpdate: UserUpdateDto = { const userUpdate: UserUpdateDto = {
username: values.username, username: values.username,
email: values.email email: values.email
@@ -80,7 +87,7 @@ export default function ProfileManagement() {
.equals([Yup.ref('newPassword')], 'Passwords do not match') .equals([Yup.ref('newPassword')], 'Passwords do not match')
})} })}
> >
{(formik: { values: any; isSubmitting: any; dirty: boolean; }) => ( {(formik: FormikProps<ProfileFormValues>) => (
<Form> <Form>
<div className="flex flex-row grow justify-between mb-8"> <div className="flex flex-row grow justify-between mb-8">
<h2 className="text-2xl font-bold">My Profile</h2> <h2 className="text-2xl font-bold">My Profile</h2>
@@ -124,10 +131,10 @@ export default function ProfileManagement() {
<div className="flex flex-col grow"> <div className="flex flex-col grow">
<Section title="Personal information"/> <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}/> isDisabled={auth.state.user?.managedBySso}/>
<div className="flex flex-row gap-4"> <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}/> isDisabled={auth.state.user?.managedBySso || !messagesEnabled}/>
{(auth.state.user?.emailConfirmed === false && !auth.state.user.managedBySso) && {(auth.state.user?.emailConfirmed === false && !auth.state.user.managedBySso) &&
<Tooltip content="Resend email confirmation message"> <Tooltip content="Resend email confirmation message">
@@ -160,9 +167,9 @@ export default function ProfileManagement() {
} }
<Section title="Security"/> <Section title="Security"/>
<Input name="newPassword" label="New Password" type="password" <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" <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>
</div> </div>
</Form> </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-row items-start gap-8">
<div className="flex flex-col"> <div className="flex flex-col">
<h2 className="text-xl font-bold mb-4">General configuration</h2> <h2 className="text-xl font-bold mb-4">General configuration</h2>
<ConfigFormField className="mb-4" <ConfigFormField className="mb-4" configElement={getConfig("sso.oidc.enabled")}/>
configElement={getConfig("sso.oidc.enabled")}/>
<ConfigFormField configElement={getConfig("sso.oidc.match-existing-users-by")} <ConfigFormField configElement={getConfig("sso.oidc.match-existing-users-by")}
isDisabled={!formik.values.sso.oidc.enabled}/> 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")} <ConfigFormField configElement={getConfig("sso.oidc.roles-claim")}
isDisabled={!formik.values.sso.oidc.enabled}/> isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.oauth-scopes")} <ConfigFormField configElement={getConfig("sso.oidc.oauth-scopes")}
@@ -1,15 +1,22 @@
import React, {useEffect, useState} from "react"; import React, {useEffect, useState} from "react";
import {ConfigEndpoint} from "Frontend/generated/endpoints"; import {ConfigEndpoint} from "Frontend/generated/endpoints";
import ConfigEntryDto from "Frontend/generated/org/gameyfin/app/config/dto/ConfigEntryDto"; 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 {Button, Skeleton} from "@heroui/react";
import {CheckIcon, InfoIcon} from "@phosphor-icons/react"; import {CheckIcon, InfoIcon} from "@phosphor-icons/react";
import {SmallInfoField} from "Frontend/components/general/SmallInfoField"; import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
import {configState, initializeConfigState, NestedConfig} from "Frontend/state/ConfigState"; import {configState, initializeConfigState, NestedConfig} from "Frontend/state/ConfigState";
import {useSnapshot} from "valtio/react"; import {useSnapshot} from "valtio/react";
import * as Yup from "yup";
export default function withConfigPage(WrappedComponent: React.ComponentType<any>, title: String, validationSchema?: any) { export interface ConfigPageProps {
return function ConfigPage(props: any) { 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 [configSaved, setConfigSaved] = useState(false);
const [saveMessage, setSaveMessage] = useState<string>(); const [saveMessage, setSaveMessage] = useState<string>();
@@ -35,14 +42,14 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
return state.state[key]; return state.state[key];
} }
function getChangedValues(initial: NestedConfig, current: NestedConfig): Record<string, any> { function getChangedValues(initial: NestedConfig, current: NestedConfig): Record<string, NestedConfig[string]> {
const flatten = (obj: NestedConfig, parentKey = ''): Record<string, any> => { const flatten = (obj: NestedConfig, parentKey = ''): Record<string, NestedConfig[string]> => {
let result: Record<string, any> = {}; let result: Record<string, NestedConfig[string]> = {};
for (const key in obj) { for (const key in obj) {
if (obj.hasOwnProperty(key)) { if (obj.hasOwnProperty(key)) {
const newKey = parentKey ? `${parentKey}.${key}` : key; const newKey = parentKey ? `${parentKey}.${key}` : key;
if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[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 { } else {
result[newKey] = obj[key]; result[newKey] = obj[key];
} }
@@ -51,11 +58,11 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
return result; return result;
}; };
const arraysEqual = (a: any[], b: any[]): boolean => { const arraysEqual = (a: unknown[], b: unknown[]): boolean => {
if (a.length !== b.length) return false; if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) { for (let i = 0; i < a.length; i++) {
if (Array.isArray(a[i]) && Array.isArray(b[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]) { } else if (a[i] !== b[i]) {
return false; return false;
} }
@@ -66,7 +73,7 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
const flatInitial = flatten(initial); const flatInitial = flatten(initial);
const flatCurrent = flatten(current); const flatCurrent = flatten(current);
const changed: Record<string, any> = {}; const changed: Record<string, NestedConfig[string]> = {};
for (const key in flatCurrent) { for (const key in flatCurrent) {
const valA = flatCurrent[key]; const valA = flatCurrent[key];
const valB = flatInitial[key]; const valB = flatInitial[key];
@@ -15,7 +15,8 @@ import {libraryState} from "Frontend/state/LibraryState";
import {TargetIcon, WarningIcon} from "@phosphor-icons/react"; import {TargetIcon, WarningIcon} from "@phosphor-icons/react";
import {timeBetween, timeUntil, toTitleCase} from "Frontend/util/utils"; import {timeBetween, timeUntil, toTitleCase} from "Frontend/util/utils";
import LibraryScanStatus from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryScanStatus"; 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() { export default function ScanProgressPopover() {
const libraries = useSnapshot(libraryState).state; const libraries = useSnapshot(libraryState).state;
@@ -23,7 +24,10 @@ export default function ScanProgressPopover() {
const scanInProgress = useSnapshot(scanState).isScanning; const scanInProgress = useSnapshot(scanState).isScanning;
// Add state to track current time and force re-renders // 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 // Set up an interval to update the time every second
useEffect(() => { useEffect(() => {
@@ -35,6 +39,42 @@ export default function ScanProgressPopover() {
return () => clearInterval(intervalId); 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 ( return (
<Popover placement="bottom-end" showArrow={true}> <Popover placement="bottom-end" showArrow={true}>
<PopoverTrigger> <PopoverTrigger>
@@ -79,9 +119,14 @@ export default function ScanProgressPopover() {
{scan.status === LibraryScanStatus.IN_PROGRESS && {scan.status === LibraryScanStatus.IN_PROGRESS &&
(scan.currentStep.current && scan.currentStep.total ? (scan.currentStep.current && scan.currentStep.total ?
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex flex-row justify-between">
<p className="text-default-500"> <p className="text-default-500">
{`${scan.currentStep.description} (${scan.currentStep.current}/${scan.currentStep.total})`} {`${scan.currentStep.description} (${scan.currentStep.current}/${scan.currentStep.total})`}
</p> </p>
<p className="text-default-500">
{estimateTimeLeft(scan)}
</p>
</div>
<Progress <Progress
value={scan.currentStep.current / scan.currentStep.total * 100} value={scan.currentStep.current / scan.currentStep.total * 100}
size="sm"/> size="sm"/>
@@ -10,6 +10,7 @@ import {gameState} from "Frontend/state/GameState";
import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern"; import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern";
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto"; import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
import ChipList from "Frontend/components/general/ChipList"; import ChipList from "Frontend/components/general/ChipList";
import {pluginState} from "Frontend/state/PluginState";
interface LibraryOverviewCardProps { interface LibraryOverviewCardProps {
library: LibraryAdminDto; library: LibraryAdminDto;
@@ -20,6 +21,7 @@ export function LibraryOverviewCard({library}: LibraryOverviewCardProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const state = useSnapshot(gameState); const state = useSnapshot(gameState);
const randomGames = getRandomGames(); const randomGames = getRandomGames();
const hasActiveMetadataPlugins = useSnapshot(pluginState).hasActiveMetadataPlugins;
function getRandomGames() { function getRandomGames() {
if (!state.randomlyOrderedGamesByLibraryId[library.id]) return []; if (!state.randomlyOrderedGamesByLibraryId[library.id]) return [];
@@ -47,16 +49,20 @@ export function LibraryOverviewCard({library}: LibraryOverviewCardProps) {
} }
</div> </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"> <div className="absolute right-0 top-0 flex flex-row">
<Tooltip content="Scan library (quick)" placement="bottom" color="foreground"> <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/> <MagnifyingGlassIcon/>
</Button> </Button>
</Tooltip> </Tooltip>
<Tooltip content="Scan library (full)" placement="bottom" color="foreground"> <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/> <MagnifyingGlassPlusIcon/>
</Button> </Button>
</Tooltip> </Tooltip>
@@ -2,11 +2,20 @@ import {FieldArray, useField} from "formik";
import {Button, Chip, Input, Popover, PopoverContent, PopoverTrigger} from "@heroui/react"; import {Button, Chip, Input, Popover, PopoverContent, PopoverTrigger} from "@heroui/react";
import {KeyboardEvent, useState} from "react"; import {KeyboardEvent, useState} from "react";
import {PlusIcon} from "@phosphor-icons/react"; import {PlusIcon} from "@phosphor-icons/react";
import InfoPopup from "Frontend/components/administration/InfoPopup";
import ResetToDefaultButton from "Frontend/components/administration/ResetToDefaultButton";
// @ts-ignore interface ArrayInputProps {
const ArrayInput = ({label, ...props}) => { label: string;
// @ts-ignore name: string;
const [field, meta] = useField(props); 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>(""); const [newElementValue, setNewElementValue] = useState<string>("");
return ( return (
@@ -29,12 +38,17 @@ const ArrayInput = ({label, ...props}) => {
return ( return (
<div className="flex flex-col flex-1 gap-2"> <div className="flex flex-col flex-1 gap-2">
<div className="flex flex-row justify-between"> <div className="flex flex-row justify-between">
<span className="flex items-center gap-1">
<p>{label}</p> <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> <small>{field.value.length} {field.value.length == 1 ? "element" : "elements"}</small>
</div> </div>
<div className="flex flex-row flex-wrap gap-2 items-center"> <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} <Chip key={index}
onClose={() => arrayHelpers.remove(index)} onClose={() => arrayHelpers.remove(index)}
isDisabled={props.isDisabled} isDisabled={props.isDisabled}
@@ -76,5 +90,3 @@ const ArrayInput = ({label, ...props}) => {
/> />
); );
} }
export default ArrayInput;
@@ -1,29 +1,39 @@
import {useField} from "formik"; 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 interface CheckboxInputProps extends Omit<CheckboxProps, "name"> {
const CheckboxInput = ({label, ...props}) => { label: string;
// @ts-ignore name: string;
const [field, meta] = useField(props); description?: string;
resetValue?: unknown;
}
export default function CheckboxInput({label, description, resetValue, className, ...props}: CheckboxInputProps) {
const [field, meta] = useField({name: props.name, type: "checkbox"});
return ( return (
<CheckboxGroup <CheckboxGroup
className="flex flex-row flex-1 items-baseline gap-2" className={`flex flex-row flex-1 gap-2 ${className ?? ""}`}
isInvalid={!!meta.error} isInvalid={!!meta.error}
errorMessage={meta.initialError || meta.error} errorMessage={meta.initialError || meta.error}
value={field.value ? [field.name] : []} value={field.value ? [field.name] : []}
> >
<span className="flex items-center gap-1">
<Checkbox <Checkbox
className="items-baseline"
{...field} {...field}
{...props} {...props}
// @ts-ignore className="items-center"
value={field.name} value={field.name}
> >
{label} {label}
</Checkbox> </Checkbox>
{description && <InfoPopup content={description}/>}
{resetValue !== undefined &&
<ResetToDefaultButton fieldName={field.name} defaultValue={resetValue}/>}
</span>
</CheckboxGroup> </CheckboxGroup>
); );
} }
export default CheckboxInput;
@@ -1,12 +1,15 @@
import {useField} from "formik"; import {useField} from "formik";
import {DatePicker, DateValue} from "@heroui/react"; import {DatePicker, DatePickerProps, DateValue} from "@heroui/react";
import {parseDate} from "@internationalized/date"; import {parseDate} from "@internationalized/date";
import {useState} from "react"; import {useState} from "react";
// @ts-ignore interface DatePickerInputProps extends Omit<DatePickerProps, "name"> {
export default function DatePickerInput({label, showErrorUntouched = false, ...props}) { name: string;
// @ts-ignore showErrorUntouched?: boolean;
const [field, meta] = useField(props); }
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); const [value, setValue] = useState<DateValue | null>(field.value ? parseDate(field.value) : null);
return ( return (
@@ -26,7 +29,7 @@ export default function DatePickerInput({label, showErrorUntouched = false, ...p
} }
}); });
}} }}
id={label} id={label as string}
label={label} label={label}
isInvalid={(meta.touched || showErrorUntouched) && !!meta.error} isInvalid={(meta.touched || showErrorUntouched) && !!meta.error}
errorMessage={meta.initialError || meta.error} errorMessage={meta.initialError || meta.error}
@@ -3,13 +3,17 @@ import React from "react";
import {useField} from "formik"; import {useField} from "formik";
import {GameCoverPickerModal} from "Frontend/components/general/modals/GameCoverPickerModal"; import {GameCoverPickerModal} from "Frontend/components/general/modals/GameCoverPickerModal";
import {ImageBrokenIcon, PencilIcon} from "@phosphor-icons/react"; 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}: GameCoverPickerProps) {
export default function GameCoverPicker({game, showErrorUntouched = false, ...props}) {
// @ts-ignore const [field] = useField(props.name);
const [field] = useField(props);
const gameCoverPickerModal = useDisclosure(); const gameCoverPickerModal = useDisclosure();
@@ -3,13 +3,17 @@ import React from "react";
import {useField} from "formik"; import {useField} from "formik";
import {ImageBrokenIcon, PencilIcon} from "@phosphor-icons/react"; import {ImageBrokenIcon, PencilIcon} from "@phosphor-icons/react";
import {GameHeaderPickerModal} from "Frontend/components/general/modals/GameHeaderPickerModal"; 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}: GameHeaderPickerProps) {
export default function GameHeaderPicker({game, showErrorUntouched = false, ...props}) {
// @ts-ignore const [field] = useField(props.name);
const [field] = useField(props);
const gameHeaderPickerModal = useDisclosure(); const gameHeaderPickerModal = useDisclosure();
@@ -1,23 +1,43 @@
import {useField} from "formik"; 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 interface CustomInputProps extends Omit<InputProps, "name"> {
const Input = ({label, showErrorUntouched = false, ...props}) => { name: string;
// @ts-ignore showErrorUntouched?: boolean;
const [field, meta] = useField(props); resetValue?: unknown;
}
export default function Input({
label,
showErrorUntouched = false,
description,
className,
resetValue,
...props
}: CustomInputProps) {
const [field, meta] = useField(props.name);
return ( return (
<HeroUiInput <HeroUiInput
className="min-h-20 grow"
fullWidth={false} fullWidth={false}
{...props} {...props}
{...field} {...field}
id={label} className={`min-h-20 grow ${className ?? ""}`}
id={label as string}
label={label} 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} isInvalid={(meta.touched || showErrorUntouched) && !!meta.error}
errorMessage={meta.initialError || meta.error} errorMessage={meta.initialError || meta.error}
/> />
); );
} }
export default Input;
@@ -1,26 +1,46 @@
import {useField} from "formik"; 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 interface CustomNumberInputProps extends Omit<NumberInputProps, "name"> {
const NumberInput = ({label, showErrorUntouched = false, ...props}) => { name: string;
// @ts-ignore showErrorUntouched?: boolean;
const [field, meta, helpers] = useField(props); resetValue?: unknown;
}
export default function NumberInput({
label,
showErrorUntouched = false,
description,
className,
resetValue,
...props
}: CustomNumberInputProps) {
const [field, meta, helpers] = useField<number>(props.name);
return ( return (
<HeroUiNumberInput <HeroUiNumberInput
className="min-h-20 grow"
fullWidth={false} fullWidth={false}
{...props} {...props}
className={`min-h-20 grow ${className ?? ""}`}
value={field.value} value={field.value}
onValueChange={(value) => helpers.setValue(value)} onValueChange={(value) => helpers.setValue(value)}
onBlur={field.onBlur} onBlur={field.onBlur}
name={field.name} name={field.name}
id={label} id={label as string}
label={label} 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} isInvalid={(meta.touched || showErrorUntouched) && !!meta.error}
errorMessage={meta.initialError || meta.error} errorMessage={meta.initialError || meta.error}
/> />
); );
} }
export default NumberInput;
@@ -1,10 +1,18 @@
import {useField} from "formik"; 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 interface SelectInputProps extends Omit<SelectProps, "name" | "children"> {
const SelectInput = ({label, values, ...props}) => { label: string;
// @ts-ignore name: string;
const [field, meta] = useField(props); 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})); const items = values.map((v: string) => ({key: v, label: v}));
@@ -17,6 +25,15 @@ const SelectInput = ({label, values, ...props}) => {
label={label} label={label}
items={items} items={items}
selectedKeys={[field.value]} 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} isInvalid={!!meta.error}
errorMessage={meta.initialError || meta.error} errorMessage={meta.initialError || meta.error}
disallowEmptySelection disallowEmptySelection
@@ -26,5 +43,3 @@ const SelectInput = ({label, values, ...props}) => {
</div> </div>
); );
} }
export default SelectInput;
@@ -1,23 +1,41 @@
import {useField} from "formik"; 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 interface SliderInputProps extends Omit<SliderProps, "name"> {
const SliderInput = ({label, showErrorUntouched = false, ...props}) => { name: string;
// @ts-ignore description?: string;
const [field, meta, helpers] = useField(props); showErrorUntouched?: boolean;
resetValue?: unknown;
}
export default function SliderInput({
label,
showErrorUntouched = false,
description,
resetValue,
...props
}: SliderInputProps) {
const [field, , helpers] = useField<number>(props.name);
return ( return (
<HeroUiSlider <HeroUiSlider
className="min-h-20 grow" className="min-h-20 grow"
{...props} {...props}
value={field.value} value={field.value}
onChange={(value) => helpers.setValue(value)} onChange={(value) => helpers.setValue(value as number)}
onBlur={field.onBlur} onBlur={field.onBlur}
name={field.name} name={field.name}
id={label} id={label as string}
label={label} 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 {useField} from "formik";
import {Textarea} from "@heroui/react"; import {Textarea, TextAreaProps} from "@heroui/react";
// @ts-ignore interface TextAreaInputProps extends Omit<TextAreaProps, "name"> {
export default function TextAreaInput({label, showErrorUntouched = false, ...props}) { name: string;
// @ts-ignore showErrorUntouched?: boolean;
const [field, meta] = useField(props); }
export default function TextAreaInput({label, showErrorUntouched = false, ...props}: TextAreaInputProps) {
const [field, meta] = useField(props.name);
return ( return (
<Textarea <Textarea
@@ -12,7 +15,7 @@ export default function TextAreaInput({label, showErrorUntouched = false, ...pro
fullWidth={false} fullWidth={false}
{...props} {...props}
{...field} {...field}
id={label} id={label as string}
label={label} label={label}
isInvalid={(meta.touched || showErrorUntouched) && !!meta.error} isInvalid={(meta.touched || showErrorUntouched) && !!meta.error}
errorMessage={meta.initialError || meta.error} errorMessage={meta.initialError || meta.error}
@@ -1,5 +1,16 @@
import React, {useState} from "react"; 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 {Form, Formik} from "formik";
import {LibraryEndpoint} from "Frontend/generated/endpoints"; import {LibraryEndpoint} from "Frontend/generated/endpoints";
import Input from "Frontend/components/general/input/Input"; 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 {useSnapshot} from "valtio/react";
import {platformState} from "Frontend/state/PlatformState"; import {platformState} from "Frontend/state/PlatformState";
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto"; import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
import {pluginState} from "Frontend/state/PluginState";
interface LibraryCreationModalProps { interface LibraryCreationModalProps {
isOpen: boolean; isOpen: boolean;
@@ -22,9 +34,10 @@ export default function LibraryCreationModal({
const [scanAfterCreation, setScanAfterCreation] = useState<boolean>(true); const [scanAfterCreation, setScanAfterCreation] = useState<boolean>(true);
const availablePlatforms = useSnapshot(platformState).available; const availablePlatforms = useSnapshot(platformState).available;
const hasActiveMetadataPlugins = useSnapshot(pluginState).hasActiveMetadataPlugins;
async function createLibrary(library: LibraryAdminDto) { async function createLibrary(library: LibraryAdminDto) {
await LibraryEndpoint.createLibrary(library, scanAfterCreation); await LibraryEndpoint.createLibrary(library, hasActiveMetadataPlugins && scanAfterCreation);
addToast({ addToast({
title: "New library created", title: "New library created",
@@ -77,10 +90,21 @@ export default function LibraryCreationModal({
/> />
<DirectoryMappingInput name="directories"/> <DirectoryMappingInput name="directories"/>
</div> </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> </ModalBody>
<ModalFooter className="flex flex-row justify-between"> <ModalFooter className="flex flex-row justify-between">
<Checkbox isSelected={scanAfterCreation} onValueChange={setScanAfterCreation}>Scan <Checkbox
after creation?</Checkbox> isSelected={hasActiveMetadataPlugins && scanAfterCreation}
isDisabled={!hasActiveMetadataPlugins}
onValueChange={setScanAfterCreation}
>Scan after creation?</Checkbox>
<div className="flex flex-row"> <div className="flex flex-row">
<Button variant="light" onPress={onClose}> <Button variant="light" onPress={onClose}>
Cancel Cancel
@@ -6,7 +6,7 @@ import Markdown from "react-markdown";
import remarkBreaks from "remark-breaks"; import remarkBreaks from "remark-breaks";
import {PluginEndpoint} from "Frontend/generated/endpoints"; import {PluginEndpoint} from "Frontend/generated/endpoints";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto"; 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 PluginConfigMetadataDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginConfigMetadataDto";
import PluginConfigFormField from "Frontend/components/general/plugin/PluginConfigFormField"; 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 PluginUpdateDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginUpdateDto";
import {proxy} from "valtio/index"; import {proxy} from "valtio/index";
import {PluginEndpoint} from "Frontend/generated/endpoints"; import {PluginEndpoint} from "Frontend/generated/endpoints";
import Pf4jPluginState from "Frontend/generated/org/pf4j/PluginState";
export enum PluginType {
GameMetadataProvider = "GameMetadataProvider",
DownloadProvider = "DownloadProvider",
}
type PluginState = { type PluginState = {
subscription?: Subscription<PluginUpdateDto[]>; subscription?: Subscription<PluginUpdateDto[]>;
@@ -10,6 +16,7 @@ type PluginState = {
state: Record<string, PluginDto>; state: Record<string, PluginDto>;
plugins: PluginDto[]; plugins: PluginDto[];
sortedByType: Record<string, PluginDto[]>; sortedByType: Record<string, PluginDto[]>;
hasActiveMetadataPlugins: boolean;
}; };
export const pluginState = proxy<PluginState>({ export const pluginState = proxy<PluginState>({
@@ -22,6 +29,11 @@ export const pluginState = proxy<PluginState>({
}, },
get sortedByType() { get sortedByType() {
return sortPluginsByType(this.state); 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 **/ /** Computed **/
function sortPluginsByType(pluginsMap: Record<string, PluginDto>): Record<string, PluginDto[]> { 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 // Convert map to array of plugins
const plugins = Object.values(pluginsMap); const plugins = Object.values(pluginsMap);
@@ -60,4 +60,3 @@ const menuItems: MenuItem[] = [
] ]
export const AdministrationView = withSideMenu("/administration", menuItems); export const AdministrationView = withSideMenu("/administration", menuItems);
export default AdministrationView;
+51 -15
View File
@@ -2,43 +2,42 @@ import {CoverRow} from "Frontend/components/general/covers/CoverRow";
import {useSnapshot} from "valtio/react"; import {useSnapshot} from "valtio/react";
import {libraryState} from "Frontend/state/LibraryState"; import {libraryState} from "Frontend/state/LibraryState";
import {gameState} from "Frontend/state/GameState"; 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 LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import {collectionState} from "Frontend/state/CollectionState"; import {collectionState} from "Frontend/state/CollectionState";
import CollectionDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionDto"; import CollectionDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionDto";
import {StartPageDisplayCard} from "Frontend/components/general/cards/StartPageDisplayCard"; import {StartPageDisplayCard} from "Frontend/components/general/cards/StartPageDisplayCard";
import {Link} from "@heroui/react"; import {Link, Spinner} from "@heroui/react";
import {CaretRightIcon} from "@phosphor-icons/react"; import {CaretRightIcon, FolderOpenIcon} from "@phosphor-icons/react";
import {useAuth} from "Frontend/util/auth";
import {isAdmin} from "Frontend/util/utils";
export default function HomeView() { export default function HomeView() {
const auth = useAuth();
const librariesState = useSnapshot(libraryState); const librariesState = useSnapshot(libraryState);
const collectionsState = useSnapshot(collectionState); const collectionsState = useSnapshot(collectionState);
const gamesState = useSnapshot(gameState); const gamesState = useSnapshot(gameState);
const gamesByLibrary = gamesState.gamesByLibraryId; const gamesByLibrary = gamesState.gamesByLibraryId;
const gamesByCollection = gamesState.gamesByCollectionId; const gamesByCollection = gamesState.gamesByCollectionId;
const [filteredAndSortedLibraries, setFilteredAndSortedLibraries] = useState<LibraryDto[]>([]); const filteredAndSortedLibraries = useMemo(() =>
const [filteredAndSortedCollections, setFilteredAndSortedCollections] = useState<CollectionDto[]>([]); librariesState.sorted
useEffect(() => {
const libraries = librariesState.sorted
.filter(library => library.metadata!.displayOnHomepage) .filter(library => library.metadata!.displayOnHomepage)
.filter(library => .filter(library =>
gamesByLibrary[library.id] && gamesByLibrary[library.id].length > 0 gamesByLibrary[library.id] && gamesByLibrary[library.id].length > 0
),
[librariesState.sorted, gamesByLibrary]
); );
setFilteredAndSortedLibraries(libraries); const filteredAndSortedCollections = useMemo(() =>
collectionsState.sorted
const collections = collectionsState.sorted
.filter(collection => collection.metadata!.displayOnHomepage) .filter(collection => collection.metadata!.displayOnHomepage)
.filter(collection => .filter(collection =>
gamesByCollection[collection.id] && gamesByCollection[collection.id].length > 0 gamesByCollection[collection.id] && gamesByCollection[collection.id].length > 0
),
[collectionsState.sorted, gamesByCollection]
); );
setFilteredAndSortedCollections(collections);
}, [librariesState.sorted, collectionsState.sorted, gamesByLibrary, gamesByCollection]);
// Sort games by date added (newest first) for libraries // Sort games by date added (newest first) for libraries
const getSortedLibraryGames = (libraryId: number) => { const getSortedLibraryGames = (libraryId: number) => {
const games = gamesByLibrary[libraryId] || []; const games = gamesByLibrary[libraryId] || [];
@@ -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 ( return (
<div className="w-full"> <div className="w-full">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -50,13 +50,13 @@ class CollectionEndpoint(
@RolesAllowed(Role.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
fun deleteCollection(collectionId: Long) = collectionService.delete(collectionId) 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) @RolesAllowed(Role.Names.ADMIN)
fun _getAdminDto(id: Long): CollectionAdminDto = collectionService.getById(id).toAdminDto() fun _getAdminDto(id: Long): CollectionAdminDto = collectionService.getById(id).toAdminDto()
@Suppress("Unused", "FunctionName") @Suppress("Unused", "FunctionName", "kotlin:S100")
@RolesAllowed(Role.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
fun _getUserDto(id: Long): CollectionUserDto = collectionService.getById(id).toUserDto() fun _getUserDto(id: Long): CollectionUserDto = collectionService.getById(id).toUserDto()
} }
@@ -70,8 +70,8 @@ class CollectionService(
@Transactional @Transactional
fun create(dto: CollectionCreateDto) { fun create(dto: CollectionCreateDto) {
if (collectionRepository.findByName(dto.name) != null) { require(collectionRepository.findByName(dto.name) == null) {
throw IllegalArgumentException("Collection with name '${dto.name}' already exists") "Collection with name '${dto.name}' already exists"
} }
val entity = dto.toEntity() val entity = dto.toEntity()
dto.gameIds?.let { ids -> dto.gameIds?.let { ids ->
@@ -87,8 +87,8 @@ class CollectionService(
fun update(dto: CollectionUpdateDto): CollectionDto { fun update(dto: CollectionUpdateDto): CollectionDto {
val collection = getById(dto.id) val collection = getById(dto.id)
dto.name?.let { newName -> dto.name?.let { newName ->
if (newName != collection.name && collectionRepository.findByName(newName) != null) { require(collectionRepository.findByName(dto.name) == null) {
throw IllegalArgumentException("Collection with name '$newName' already exists") "Collection with name '${dto.name}' already exists"
} }
collection.name = newName collection.name = newName
} }
@@ -7,6 +7,7 @@ import kotlin.reflect.KClass
sealed class ConfigProperties<T : Serializable>( sealed class ConfigProperties<T : Serializable>(
val type: KClass<T>, val type: KClass<T>,
val key: String, val key: String,
val name: String,
val description: String, val description: String,
val default: T? = null, val default: T? = null,
val allowedValues: List<T>? = null, val allowedValues: List<T>? = null,
@@ -21,6 +22,7 @@ sealed class ConfigProperties<T : Serializable>(
Boolean::class, Boolean::class,
"security.allow-public-access", "security.allow-public-access",
"Allow access to Gameyfin without login", "Allow access to Gameyfin without login",
"When enabled, anyone can browse (and potentially download) games **without logging in**.",
false false
) )
} }
@@ -32,6 +34,7 @@ sealed class ConfigProperties<T : Serializable>(
Boolean::class, Boolean::class,
"library.scan.enable-filesystem-watcher", "library.scan.enable-filesystem-watcher",
"Enable automatic library scanning using file system watchers", "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 false
) )
@@ -39,6 +42,7 @@ sealed class ConfigProperties<T : Serializable>(
Boolean::class, Boolean::class,
"library.scan.scan-empty-directories", "library.scan.scan-empty-directories",
"Scan empty directories", "Scan empty directories",
"When enabled, empty folders inside a library path are also reported during a scan.",
false false
) )
@@ -46,6 +50,7 @@ sealed class ConfigProperties<T : Serializable>(
Boolean::class, Boolean::class,
"library.scan.extract-title-using-regex", "library.scan.extract-title-using-regex",
"Extract title from file names 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 false
) )
@@ -53,6 +58,7 @@ sealed class ConfigProperties<T : Serializable>(
String::class, String::class,
"library.scan.title-extraction-regex", "library.scan.title-extraction-regex",
"Regex to extract title from file names", "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, Int::class,
"library.scan.title-match-min-ratio", "library.scan.title-match-min-ratio",
"Minimum ratio for title matching. Higher values mean stricter matching.", "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, default = 90,
min = 0, min = 0,
max = 100, max = 100,
@@ -70,6 +78,7 @@ sealed class ConfigProperties<T : Serializable>(
Array<String>::class, Array<String>::class,
"library.scan.game-file-extensions", "library.scan.game-file-extensions",
"File extensions to consider as games", "File extensions to consider as games",
"Only files whose extension appears in this list are treated as games during a library scan. Add custom extensions to support additional formats.",
arrayOf( arrayOf(
"zip", "zip",
"tar", "tar",
@@ -93,6 +102,19 @@ sealed class ConfigProperties<T : Serializable>(
"elf" "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 { sealed class Metadata {
@@ -100,6 +122,7 @@ sealed class ConfigProperties<T : Serializable>(
Boolean::class, Boolean::class,
"library.metadata.update.enabled", "library.metadata.update.enabled",
"Enable periodic refresh of video game metadata", "Enable periodic refresh of video game metadata",
"When enabled, Gameyfin periodically re-fetches metadata (cover art, descriptions, genres, …) according to the configured schedule.",
true true
) )
@@ -107,6 +130,7 @@ sealed class ConfigProperties<T : Serializable>(
String::class, String::class,
"library.metadata.update.schedule", "library.metadata.update.schedule",
"Schedule for periodic metadata refresh in Spring cron format", "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" "@daily"
) )
} }
@@ -119,6 +143,7 @@ sealed class ConfigProperties<T : Serializable>(
Boolean::class, Boolean::class,
"requests.games.enabled", "requests.games.enabled",
"Enable submission of game requests", "Enable submission of game requests",
"Allows users to submit requests for games they would like to see added to the library.",
true true
) )
@@ -126,6 +151,7 @@ sealed class ConfigProperties<T : Serializable>(
Boolean::class, Boolean::class,
"requests.games.allow-guests-to-request-games", "requests.games.allow-guests-to-request-games",
"Allow guests (not logged in) to create game requests", "Allow guests (not logged in) to create game requests",
"When enabled, visitors who are **not logged in** can also submit game requests.",
false false
) )
@@ -133,6 +159,7 @@ sealed class ConfigProperties<T : Serializable>(
Int::class, Int::class,
"requests.games.max-open-requests-per-user", "requests.games.max-open-requests-per-user",
"Maximum number of pending requests per user. Set to 0 for unlimited.", "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 10
) )
} }
@@ -144,6 +171,7 @@ sealed class ConfigProperties<T : Serializable>(
Boolean::class, Boolean::class,
"downloads.bandwidth-limit.enabled", "downloads.bandwidth-limit.enabled",
"Enable per-user bandwidth limiting for downloads", "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 false
) )
@@ -151,6 +179,7 @@ sealed class ConfigProperties<T : Serializable>(
Int::class, Int::class,
"downloads.bandwidth-limit.mbps", "downloads.bandwidth-limit.mbps",
"Maximum download speed in Megabits per second (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 100
) )
} }
@@ -162,6 +191,7 @@ sealed class ConfigProperties<T : Serializable>(
Boolean::class, Boolean::class,
"users.sign-ups.allow", "users.sign-ups.allow",
"Allow new users to sign up by themselves", "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 false
) )
@@ -169,6 +199,7 @@ sealed class ConfigProperties<T : Serializable>(
Boolean::class, Boolean::class,
"users.sign-ups.confirmation-required", "users.sign-ups.confirmation-required",
"Admins need to confirm new users", "Admins need to confirm new users",
"When enabled, newly registered accounts are **inactive** until an administrator explicitly approves them.",
true true
) )
} }
@@ -181,6 +212,7 @@ sealed class ConfigProperties<T : Serializable>(
Boolean::class, Boolean::class,
"sso.oidc.enabled", "sso.oidc.enabled",
"Enable SSO via OIDC/OAuth2", "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 false
) )
@@ -188,14 +220,26 @@ sealed class ConfigProperties<T : Serializable>(
MatchUsersBy::class, MatchUsersBy::class,
"sso.oidc.match-existing-users-by", "sso.oidc.match-existing-users-by",
"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.username,
MatchUsersBy.entries 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>( data object RolesClaim : ConfigProperties<String>(
String::class, String::class,
"sso.oidc.roles-claim", "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" "roles"
) )
@@ -203,55 +247,64 @@ sealed class ConfigProperties<T : Serializable>(
Array<String>::class, Array<String>::class,
"sso.oidc.oauth-scopes", "sso.oidc.oauth-scopes",
"OAuth2 scopes to request", "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") arrayOf("openid", "profile", "email", "roles")
) )
data object ClientId : ConfigProperties<String>( data object ClientId : ConfigProperties<String>(
String::class, String::class,
"sso.oidc.client-id", "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>( data object ClientSecret : ConfigProperties<String>(
String::class, String::class,
"sso.oidc.client-secret", "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>( data object IssuerUrl : ConfigProperties<String>(
String::class, String::class,
"sso.oidc.issuer-url", "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>( data object AuthorizeUrl : ConfigProperties<String>(
String::class, String::class,
"sso.oidc.authorize-url", "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>( data object TokenUrl : ConfigProperties<String>(
String::class, String::class,
"sso.oidc.token-url", "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>( data object UserInfoUrl : ConfigProperties<String>(
String::class, String::class,
"sso.oidc.userinfo-url", "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>( data object JwksUrl : ConfigProperties<String>(
String::class, String::class,
"sso.oidc.jwks-url", "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>( data object LogoutUrl : ConfigProperties<String>(
String::class, String::class,
"sso.oidc.logout-url", "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, Boolean::class,
"messages.providers.email.enabled", "messages.providers.email.enabled",
"Enable E-Mail notifications", "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 false
) )
data object Host : ConfigProperties<String>( data object Host : ConfigProperties<String>(
String::class, String::class,
"messages.providers.email.host", "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>( data object Port : ConfigProperties<Int>(
Int::class, Int::class,
"messages.providers.email.port", "messages.providers.email.port",
"Port of the email server", "Port of the email server",
"TCP port of the SMTP server. Common values: `587` (STARTTLS), `465` (SSL/TLS), `25` (unencrypted).",
587 587
) )
data object Username : ConfigProperties<String>( data object Username : ConfigProperties<String>(
String::class, String::class,
"messages.providers.email.username", "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>( data object Password : ConfigProperties<String>(
String::class, String::class,
"messages.providers.email.password", "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, String::class,
"logs.folder", "logs.folder",
"Storage folder for log files", "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" "./logs"
) )
@@ -308,6 +367,7 @@ sealed class ConfigProperties<T : Serializable>(
Int::class, Int::class,
"logs.max-history-days", "logs.max-history-days",
"Log retention in 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 30
) )
@@ -316,6 +376,7 @@ sealed class ConfigProperties<T : Serializable>(
LogLevel::class, LogLevel::class,
"logs.level.gameyfin", "logs.level.gameyfin",
"Log 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.INFO,
LogLevel.entries LogLevel.entries
) )
@@ -324,6 +385,7 @@ sealed class ConfigProperties<T : Serializable>(
LogLevel::class, LogLevel::class,
"logs.level.root", "logs.level.root",
"Log 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.WARN,
LogLevel.entries LogLevel.entries
) )
@@ -94,6 +94,7 @@ class ConfigService(
value = get(configProperty), value = get(configProperty),
defaultValue = configProperty.default, defaultValue = configProperty.default,
type = configProperty.type.simpleName ?: "Unknown", type = configProperty.type.simpleName ?: "Unknown",
name = configProperty.name,
description = configProperty.description, description = configProperty.description,
elementType = configProperty.type.java.componentType?.simpleName, elementType = configProperty.type.java.componentType?.simpleName,
allowedValues = configProperty.allowedValues?.map { it.toString() }, allowedValues = configProperty.allowedValues?.map { it.toString() },
@@ -6,6 +6,7 @@ import java.io.Serializable
@JsonInclude(JsonInclude.Include.ALWAYS) @JsonInclude(JsonInclude.Include.ALWAYS)
data class ConfigEntryDto( data class ConfigEntryDto(
val key: String, val key: String,
val name: String,
val description: String, val description: String,
val value: Serializable?, val value: Serializable?,
val defaultValue: Serializable?, val defaultValue: Serializable?,
@@ -20,7 +20,8 @@ class Utils {
val jvmNanoTimeDiff: Long = System.currentTimeMillis() * 1_000_000 - System.nanoTime() val jvmNanoTimeDiff: Long = System.currentTimeMillis() * 1_000_000 - System.nanoTime()
fun maskEmail(email: String): String { fun maskEmail(email: String): String {
val regex = """(?:\G(?!^)|(?<=^[^@]{2}|@))[^@](?!\.[^.]+$)""".toRegex() @Suppress("RegExpUnnecessaryNonCapturingGroup")
val regex = """(?:\G(?!^)|(?<=(?:^[^@]{2})|(?:@)))[^@](?!\.[^.]+$)""".toRegex()
return email.replace(regex, "*") return email.replace(regex, "*")
} }
@@ -101,10 +102,7 @@ fun String.replaceRomanNumerals(): String {
return sum return sum
} }
val regex = Regex( val regex = Regex("""(?<=\s|^)([MDCLXVI]+)(?=\s|$)""", RegexOption.IGNORE_CASE)
"""(?<=\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
)
return regex.replace(this) { match -> return regex.replace(this) { match ->
val roman = match.value.uppercase() val roman = match.value.uppercase()
@@ -133,35 +131,29 @@ fun HttpServletRequest.getRemoteIp(lookupPolicy: LookupPolicy = LookupPolicy.ANY
// Add the direct remote address // Add the direct remote address
this.remoteAddr?.let { candidateIps.add(it) } this.remoteAddr?.let { candidateIps.add(it) }
when (lookupPolicy) { return when (lookupPolicy) {
LookupPolicy.IPV4_ONLY -> { LookupPolicy.IPV4_ONLY -> {
val ipv4Address = candidateIps.firstOrNull { isIpv4(it) } candidateIps.firstOrNull { isIpv4(it) } ?: "unknown"
return ipv4Address ?: "unknown"
} }
LookupPolicy.IPV6_ONLY -> { LookupPolicy.IPV6_ONLY -> {
val ipv6Address = candidateIps.firstOrNull { isIpv6(it) } candidateIps.firstOrNull { isIpv6(it) } ?: "unknown"
return ipv6Address ?: "unknown"
} }
LookupPolicy.IPV4_PREFERRED -> { LookupPolicy.IPV4_PREFERRED -> {
val ipv4Address = candidateIps.firstOrNull { isIpv4(it) } candidateIps.firstOrNull { isIpv4(it) }
return ipv4Address ?: run { ?: candidateIps.firstOrNull { isIpv6(it) }
val ipv6Address = candidateIps.firstOrNull { isIpv6(it) } ?: "unknown"
ipv6Address ?: "unknown"
}
} }
LookupPolicy.IPV6_PREFERRED -> { LookupPolicy.IPV6_PREFERRED -> {
val ipv6Address = candidateIps.firstOrNull { isIpv6(it) } candidateIps.firstOrNull { isIpv6(it) }
return ipv6Address ?: run { ?: candidateIps.firstOrNull { isIpv4(it) }
val ipv4Address = candidateIps.firstOrNull { isIpv4(it) } ?: "unknown"
ipv4Address ?: "unknown"
}
} }
LookupPolicy.ANY -> { 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 import org.springframework.context.annotation.Configuration
@Configuration @Configuration
class JpaConfiguration { class JpaConfig {
@Bean @Bean
fun hibernatePropertiesCustomizer(entityUpdateInterceptor: EntityUpdateInterceptor): HibernatePropertiesCustomizer { fun hibernatePropertiesCustomizer(entityUpdateInterceptor: EntityUpdateInterceptor): HibernatePropertiesCustomizer {
@@ -1,5 +1,6 @@
package org.gameyfin.app.core.download.bandwidth package org.gameyfin.app.core.download.bandwidth
import java.io.FilterOutputStream
import java.io.OutputStream import java.io.OutputStream
/** /**
@@ -13,12 +14,12 @@ import java.io.OutputStream
* @param remoteIp The remote IP address of the client (optional) * @param remoteIp The remote IP address of the client (optional)
*/ */
class SessionMonitoredOutputStream( class SessionMonitoredOutputStream(
private val outputStream: OutputStream, outputStream: OutputStream,
private val sessionTracker: SessionBandwidthTracker, private val sessionTracker: SessionBandwidthTracker,
private val gameId: Long? = null, private val gameId: Long? = null,
private val username: String? = null, private val username: String? = null,
private val remoteIp: String? = null private val remoteIp: String? = null
) : OutputStream() { ) : FilterOutputStream(outputStream) {
init { init {
sessionTracker.downloadStarted(gameId, username, remoteIp) sessionTracker.downloadStarted(gameId, username, remoteIp)
@@ -26,25 +27,17 @@ class SessionMonitoredOutputStream(
override fun write(b: Int) { override fun write(b: Int) {
sessionTracker.recordBytes(1) sessionTracker.recordBytes(1)
outputStream.write(b) out.write(b)
}
override fun write(b: ByteArray) {
write(b, 0, b.size)
} }
override fun write(b: ByteArray, off: Int, len: Int) { override fun write(b: ByteArray, off: Int, len: Int) {
sessionTracker.recordBytes(len.toLong()) sessionTracker.recordBytes(len.toLong())
outputStream.write(b, off, len) out.write(b, off, len)
}
override fun flush() {
outputStream.flush()
} }
override fun close() { override fun close() {
try { try {
outputStream.close() super.close()
} finally { } finally {
sessionTracker.downloadCompleted(gameId) sessionTracker.downloadCompleted(gameId)
} }
@@ -1,5 +1,6 @@
package org.gameyfin.app.core.download.bandwidth package org.gameyfin.app.core.download.bandwidth
import java.io.FilterOutputStream
import java.io.OutputStream import java.io.OutputStream
/** /**
@@ -13,12 +14,12 @@ import java.io.OutputStream
* @param remoteIp The remote IP address of the client (optional) * @param remoteIp The remote IP address of the client (optional)
*/ */
class SessionThrottledOutputStream( class SessionThrottledOutputStream(
private val outputStream: OutputStream, outputStream: OutputStream,
private val sessionTracker: SessionBandwidthTracker, private val sessionTracker: SessionBandwidthTracker,
private val gameId: Long? = null, private val gameId: Long? = null,
private val username: String? = null, private val username: String? = null,
private val remoteIp: String? = null private val remoteIp: String? = null
) : OutputStream() { ) : FilterOutputStream(outputStream) {
init { init {
sessionTracker.downloadStarted(gameId, username, remoteIp) sessionTracker.downloadStarted(gameId, username, remoteIp)
@@ -26,27 +27,17 @@ class SessionThrottledOutputStream(
override fun write(b: Int) { override fun write(b: Int) {
sessionTracker.throttle(1) sessionTracker.throttle(1)
outputStream.write(b) out.write(b)
}
override fun write(b: ByteArray) {
write(b, 0, b.size)
} }
override fun write(b: ByteArray, off: Int, len: Int) { 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()) sessionTracker.throttle(len.toLong())
outputStream.write(b, off, len) out.write(b, off, len)
}
override fun flush() {
outputStream.flush()
} }
override fun close() { override fun close() {
try { try {
outputStream.close() super.close()
} finally { } finally {
sessionTracker.downloadCompleted(gameId) 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.SessionMonitoredOutputStream
import org.gameyfin.app.core.download.bandwidth.SessionThrottledOutputStream import org.gameyfin.app.core.download.bandwidth.SessionThrottledOutputStream
import org.gameyfin.app.core.download.provider.DownloadProviderDto 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.GameyfinPluginDescriptor
import org.gameyfin.app.core.plugins.management.GameyfinPluginManager import org.gameyfin.app.core.plugins.management.GameyfinPluginManager
import org.gameyfin.app.games.entities.Game import org.gameyfin.app.games.entities.Game
@@ -25,6 +26,7 @@ class DownloadService(
private val pluginManager: GameyfinPluginManager, private val pluginManager: GameyfinPluginManager,
private val configService: ConfigService, private val configService: ConfigService,
private val sessionBandwidthManager: SessionBandwidthManager, private val sessionBandwidthManager: SessionBandwidthManager,
private val downloadMetrics: DownloadMetrics,
) { ) {
companion object { companion object {
@@ -80,7 +82,9 @@ class DownloadService(
// Always get a tracker to enable stats monitoring, even without throttling // Always get a tracker to enable stats monitoring, even without throttling
val tracker = sessionBandwidthManager.getTracker(sessionId, maxBytesPerSecond) val tracker = sessionBandwidthManager.getTracker(sessionId, maxBytesPerSecond)
val finalOutputStream = if (maxBytesPerSecond > 0) { val throttled = maxBytesPerSecond > 0
val finalOutputStream = if (throttled) {
log.debug { log.debug {
"Applying session-based bandwidth limit of $bandwidthLimitMbps Mbps ($maxBytesPerSecond bytes/sec) " + "Applying session-based bandwidth limit of $bandwidthLimitMbps Mbps ($maxBytesPerSecond bytes/sec) " +
"for download of '${game.title}' (active downloads for this session: ${tracker.activeDownloads.get()})" "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) SessionMonitoredOutputStream(outputStream, tracker, game.id, username, remoteIp)
} }
downloadMetrics.recordDownloadStarted(throttled)
try { try {
finalOutputStream.use { finalOutputStream.use {
val timeTaken = measureTime { val timeTaken = measureTime {
@@ -101,12 +107,17 @@ class DownloadService(
finalOutputStream.flush() finalOutputStream.flush()
} }
val bytesWritten = tracker.totalBytesTransferred
downloadMetrics.recordDownloadCompleted(bytesWritten)
log.debug { log.debug {
"Download of game '${game.title}' [ID ${game.id}] by user '${username ?: "anonymous user"}' " + "Download of game '${game.title}' [ID ${game.id}] by user '${username ?: "anonymous user"}' " +
"(session: $sessionId) completed in ${timeTaken.toString(DurationUnit.SECONDS)}" "(session: $sessionId) completed in ${timeTaken.toString(DurationUnit.SECONDS)}"
} }
} }
} catch (e: IOException) { } catch (e: IOException) {
downloadMetrics.recordDownloadFailed()
// Client disconnected (cancelled download, network error, etc.) // Client disconnected (cancelled download, network error, etc.)
// This is expected behavior, log at debug level instead of error // This is expected behavior, log at debug level instead of error
log.debug { log.debug {
@@ -100,11 +100,11 @@ class FilesystemService(
if (!it.isDirectory()) return@filter true if (!it.isDirectory()) return@filter true
val contents = safeReadDirectoryContents(it) 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" } log.debug { "Directory '$it' is empty and will be ignored" }
return@filter false false
} else { } 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.LoggerContext
import ch.qos.logback.classic.joran.JoranConfigurator import ch.qos.logback.classic.joran.JoranConfigurator
import org.gameyfin.app.config.ConfigService
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.gameyfin.app.config.ConfigProperties import org.gameyfin.app.config.ConfigProperties
import org.gameyfin.app.config.ConfigService
import org.gameyfin.app.core.logging.util.AsyncFileTailer import org.gameyfin.app.core.logging.util.AsyncFileTailer
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.boot.context.event.ApplicationStartedEvent 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_CONFIG_TEMPLATE = "templates/log-config-template.xml"
private const val LOG_FILE_NAME = "gameyfin" private const val LOG_FILE_NAME = "gameyfin"
private val LOG_REFRESH_INTERVAL = 5.seconds 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 {} private val log = KotlinLogging.logger {}
@@ -80,7 +80,7 @@ class LogService(
levelRoot: LogLevel levelRoot: LogLevel
): InputStream { ): InputStream {
val template = javaClass.classLoader.getResourceAsStream(LOG_CONFIG_TEMPLATE) 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() } val templateString = template.bufferedReader().use { it.readText() }
return templateString 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 org.springframework.stereotype.Service
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
import reactor.core.publisher.Sinks import reactor.core.publisher.Sinks
import kotlin.reflect.KClass
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.toJavaDuration import kotlin.time.toJavaDuration
@@ -70,6 +71,12 @@ class PluginService(
.map { toDto(it) } .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 { fun getPluginManagementEntry(clazz: Class<out ExtensionPoint>): PluginManagementEntry {
val pluginWrapper = pluginManager.whichPlugin(clazz) val pluginWrapper = pluginManager.whichPlugin(clazz)
return pluginManagementRepository.findByIdOrNull(pluginWrapper.pluginId) return pluginManagementRepository.findByIdOrNull(pluginWrapper.pluginId)
@@ -158,14 +165,15 @@ class PluginService(
} }
fun validatePluginConfig(pluginId: String, forceRevalidation: Boolean = false): PluginConfigValidationResult { 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" } log.debug { "Validating config for plugin $pluginId" }
val result = pluginManager.validatePluginConfig(pluginId) val result = pluginManager.validatePluginConfig(pluginId)
pluginConfigValidationCache[pluginId] = result pluginConfigValidationCache[pluginId] = result
return result result
} else { } else {
log.debug { "Using cached validation result for plugin $pluginId" } 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 @EmbeddedId
val id: PluginConfigEntryKey, val id: PluginConfigEntryKey,
@Lob
@Column(name = "`value`") @Column(name = "`value`")
@Convert(converter = EncryptionConverter::class) @Convert(converter = EncryptionConverter::class)
val value: String val value: String
@@ -20,9 +20,7 @@ class GameyfinJarPluginLoader(
} }
override fun loadPlugin(pluginPath: Path, pluginDescriptor: PluginDescriptor?): ClassLoader { override fun loadPlugin(pluginPath: Path, pluginDescriptor: PluginDescriptor?): ClassLoader {
if (pluginDescriptor == null) { requireNotNull(pluginDescriptor) { "Plugin descriptor cannot be null" }
throw IllegalArgumentException("Plugin descriptor cannot be null")
}
val pluginClassLoader = GameyfinPluginClassLoader( val pluginClassLoader = GameyfinPluginClassLoader(
pluginManager, pluginManager,
@@ -13,7 +13,7 @@ class GameyfinManifestPluginDescriptorFinder : ManifestPluginDescriptorFinder()
} }
public override fun createPluginDescriptor(manifest: Manifest?): GameyfinPluginDescriptor { 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) val pluginDescriptor = super.createPluginDescriptor(manifest)
@@ -22,10 +22,10 @@ class GameyfinManifestPluginDescriptorFinder : ManifestPluginDescriptorFinder()
return GameyfinPluginDescriptor( return GameyfinPluginDescriptor(
descriptor = pluginDescriptor, descriptor = pluginDescriptor,
name = attributes.getValue(PLUGIN_NAME) 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), shortDescription = attributes.getValue(PLUGIN_SHORT_DESCRIPTION),
author = attributes.getValue(PLUGIN_AUTHOR) 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), url = attributes.getValue(PLUGIN_URL),
) )
} }
@@ -19,6 +19,8 @@ class GameyfinPluginClassLoader(
try { try {
return super.loadClass(className) return super.loadClass(className)
} catch (_: SecurityException) { } 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 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 is because the internal (List<PluginDependency>) and external (List<String>) representation of the field differ
this.javaClass.superclass.getDeclaredField("dependencies").let { this.javaClass.superclass.getDeclaredField("dependencies").let {
it.isAccessible = true 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.pf4j.*
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import java.io.InputStream
import java.nio.file.Path import java.nio.file.Path
import java.security.PublicKey import java.security.PublicKey
import java.security.cert.CertificateFactory import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate import java.security.cert.X509Certificate
import java.util.jar.JarFile
import kotlin.io.path.Path import kotlin.io.path.Path
import kotlin.io.path.extension import kotlin.io.path.extension
import kotlin.reflect.KClass import kotlin.reflect.KClass
@@ -32,10 +30,11 @@ class GameyfinPluginManager(
companion object { companion object {
private const val PUBLIC_KEY_FILE = "certificates/gameyfin-plugins.pem" 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 publicKey: PublicKey = loadPluginSignaturePublicKey()
private val signatureVerifier = PluginSignatureVerifier(publicKey)
init { init {
// This took me way too long to figure out... // This took me way too long to figure out...
@@ -59,10 +58,24 @@ class GameyfinPluginManager(
} }
override fun createPluginLoader(): PluginLoader { override fun createPluginLoader(): PluginLoader {
return when (this.isDevelopment) { val pluginLoader = CompoundPluginLoader()
true -> GameyfinDevelopmentPluginLoader(this, javaClass.classLoader)
false -> GameyfinJarPluginLoader(this) 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 { override fun createPluginStatusProvider(): PluginStatusProvider {
@@ -83,11 +96,7 @@ class GameyfinPluginManager(
return extensionFinder return extensionFinder
} }
public override fun checkPluginId(pluginId: String) { public override fun loadPluginFromPath(pluginPath: Path): PluginWrapper? {
super.checkPluginId(pluginId)
}
override fun loadPluginFromPath(pluginPath: Path): PluginWrapper? {
if (pluginPath.endsWith("data") || pluginPath.endsWith("state")) { if (pluginPath.endsWith("data") || pluginPath.endsWith("state")) {
log.info { "Skipping non-plugin path '$pluginPath'" } log.info { "Skipping non-plugin path '$pluginPath'" }
@@ -95,7 +104,7 @@ class GameyfinPluginManager(
} }
val pluginWrapper = try { val pluginWrapper = try {
super.loadPluginFromPath(pluginPath) superLoadPluginFromPath(pluginPath)
} catch (e: Exception) { } catch (e: Exception) {
log.error { "Failed to load plugin '$pluginPath': ${e.message}" } log.error { "Failed to load plugin '$pluginPath': ${e.message}" }
null null
@@ -115,7 +124,7 @@ class GameyfinPluginManager(
PluginManagementEntry(pluginId = pluginWrapper.pluginId, priority = currentMaxPriority + 1) PluginManagementEntry(pluginId = pluginWrapper.pluginId, priority = currentMaxPriority + 1)
pluginManagementEntry.trustLevel = when (pluginPath.extension) { pluginManagementEntry.trustLevel = when (pluginPath.extension) {
"jar" -> verifyPluginSignature(pluginPath) "jar" -> signatureVerifier.verifyPluginSignature(pluginPath)
else -> PluginTrustLevel.BUNDLED else -> PluginTrustLevel.BUNDLED
} }
@@ -125,12 +134,14 @@ class GameyfinPluginManager(
) { ) {
pluginManagementEntry.enabled = true pluginManagementEntry.enabled = true
log.info { "Plugin ${pluginWrapper.pluginId} verified, starting" } 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) startPlugin(pluginWrapper.pluginId)
} }
} else { } else {
// Just re-verify the plugin if it was already in the database // Just re-verify the plugin if it was already in the database
pluginManagementEntry.trustLevel = when (pluginPath.extension) { pluginManagementEntry.trustLevel = when (pluginPath.extension) {
"jar" -> verifyPluginSignature(pluginPath) "jar" -> signatureVerifier.verifyPluginSignature(pluginPath)
else -> PluginTrustLevel.BUNDLED else -> PluginTrustLevel.BUNDLED
} }
} }
@@ -155,14 +166,23 @@ class GameyfinPluginManager(
if (pluginId == null) return PluginState.FAILED if (pluginId == null) return PluginState.FAILED
val trustLevel = pluginManagementRepository.findByIdOrNull(pluginId)?.trustLevel ?: PluginTrustLevel.UNKNOWN val trustLevel = pluginManagementRepository.findByIdOrNull(pluginId)?.trustLevel ?: PluginTrustLevel.UNKNOWN
if (trustLevel == PluginTrustLevel.UNTRUSTED) { when (trustLevel) {
PluginTrustLevel.UNTRUSTED -> {
val pluginWrapper = getPlugin(pluginId) val pluginWrapper = getPlugin(pluginId)
val pluginState = PluginState.UNLOADED val pluginState = PluginState.UNLOADED
this.firePluginStateEvent(PluginStateEvent(this, pluginWrapper, pluginState)) this.firePluginStateEvent(PluginStateEvent(this, pluginWrapper, pluginState))
return 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 // Validate config before starting the plugin
if (validatePluginConfig(pluginId).result == PluginConfigValidationResultType.INVALID) { if (validatePluginConfig(pluginId).result == PluginConfigValidationResultType.INVALID) {
log.warn { "Plugin $pluginId has invalid configuration" } log.warn { "Plugin $pluginId has invalid configuration" }
@@ -233,12 +253,16 @@ class GameyfinPluginManager(
.map { it.simpleName } .map { it.simpleName }
} }
fun getPluginForExtension(extensionClass: Class<ExtensionPoint>): PluginWrapper? { fun getPluginsForExtension(extensionClass: KClass<out ExtensionPoint>): List<PluginWrapper> {
return getPlugins().firstOrNull { pluginWrapper -> return getPlugins().filter { pluginWrapper ->
getExtensionClasses(pluginWrapper.pluginId).any { it == extensionClass } supportsExtensionType(pluginWrapper.pluginId, extensionClass)
} }
} }
fun getPluginForExtension(extensionClass: KClass<out ExtensionPoint>): PluginWrapper? {
return getPluginsForExtension(extensionClass).firstOrNull()
}
fun getManagementEntry(pluginId: String): PluginManagementEntry { fun getManagementEntry(pluginId: String): PluginManagementEntry {
return pluginManagementRepository.findByIdOrNull(pluginId) return pluginManagementRepository.findByIdOrNull(pluginId)
?: throw IllegalArgumentException("Plugin with ID $pluginId not found") ?: throw IllegalArgumentException("Plugin with ID $pluginId not found")
@@ -264,48 +288,9 @@ class GameyfinPluginManager(
return cert.publicKey return cert.publicKey
} }
private fun verifyPluginSignature(pluginPath: Path): PluginTrustLevel {
val jarFile = JarFile(pluginPath.toFile(), true)
val entries = jarFile.entries()
while (entries.hasMoreElements()) { // Needed for unit testing since super.loadPluginFromPath is protected
val entry = entries.nextElement() internal fun superLoadPluginFromPath(pluginPath: Path): PluginWrapper? {
if (entry.isDirectory || entry.name.startsWith("META-INF/")) continue return super.loadPluginFromPath(pluginPath)
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
} }
} }
@@ -8,7 +8,8 @@ import org.springframework.scheduling.annotation.Async
@Configuration @Configuration
class PluginManagerConfig( class PluginManagerConfig(
private val pluginManager: GameyfinPluginManager private val pluginManager: GameyfinPluginManager,
private val pluginsLoadedIndicator: PluginsLoadedIndicator
) { ) {
private val log = KotlinLogging.logger {} private val log = KotlinLogging.logger {}
@@ -18,5 +19,6 @@ class PluginManagerConfig(
pluginManager.loadPlugins() pluginManager.loadPlugins()
pluginManager.startPlugins() pluginManager.startPlugins()
log.info { "Loaded plugins: ${pluginManager.plugins.map { it.pluginId }}" } 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.Cipher
import javax.crypto.spec.SecretKeySpec 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 { class EncryptionUtils {
companion object { companion object {
private const val ALGORITHM = "AES" private const val ALGORITHM = "AES"
@@ -16,7 +20,7 @@ class EncryptionUtils {
// Extracted for testability // Extracted for testability
internal fun getAppKey(): String { internal fun getAppKey(): String {
return System.getenv("APP_KEY") 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 { 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.Bean
import org.springframework.context.annotation.Conditional import org.springframework.context.annotation.Conditional
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import org.springframework.core.annotation.Order
import org.springframework.core.env.Environment import org.springframework.core.env.Environment
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.builders.HttpSecurity
@@ -32,15 +33,26 @@ class SecurityConfig(
companion object { companion object {
const val SSO_PROVIDER_KEY = "oidc" 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 @Bean
fun filterChain(http: HttpSecurity, routeUtil: RouteUtil): SecurityFilterChain { fun filterChain(http: HttpSecurity, routeUtil: RouteUtil): SecurityFilterChain {
// Apply Vaadin configuration first to properly configure CSRF and request matchers // Apply Vaadin configuration first to properly configure CSRF and request matchers
if (config.get(ConfigProperties.SSO.OIDC.Enabled) == true) { if (config.get(ConfigProperties.SSO.OIDC.Enabled) == true) {
http.with(VaadinSecurityConfigurer.vaadin()) { configurer -> http.with(VaadinSecurityConfigurer.vaadin()) { configurer ->
// Redirect to SSO provider on logout // 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 // Use custom success handler to handle user registration
@@ -57,7 +69,7 @@ class SecurityConfig(
} else { } else {
// Use default Vaadin login URLs // Use default Vaadin login URLs
http.with(VaadinSecurityConfigurer.vaadin()) { configurer -> http.with(VaadinSecurityConfigurer.vaadin()) { configurer ->
configurer.loginView("/login") configurer.loginView(LOGIN_URL)
} }
} }
@@ -66,7 +78,7 @@ class SecurityConfig(
auth.requestMatchers(routeUtil::isRouteAllowed).permitAll() auth.requestMatchers(routeUtil::isRouteAllowed).permitAll()
// Gameyfin static resources and public endpoints // Gameyfin static resources and public endpoints
.requestMatchers( .requestMatchers(
"/login", LOGIN_URL,
"/loginredirect", "/loginredirect",
"/setup", "/setup",
"/reset-password", "/reset-password",
@@ -120,7 +132,7 @@ class SecurityConfig(
.clientId(config.get(ConfigProperties.SSO.OIDC.ClientId)) .clientId(config.get(ConfigProperties.SSO.OIDC.ClientId))
.clientSecret(config.get(ConfigProperties.SSO.OIDC.ClientSecret)) .clientSecret(config.get(ConfigProperties.SSO.OIDC.ClientSecret))
.scope(config.get(ConfigProperties.SSO.OIDC.OAuthScopes)?.toList()) .scope(config.get(ConfigProperties.SSO.OIDC.OAuthScopes)?.toList())
.userNameAttributeName("preferred_username") .userNameAttributeName(config.get(ConfigProperties.SSO.OIDC.UsernameClaim))
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.issuerUri(config.get(ConfigProperties.SSO.OIDC.IssuerUrl)) .issuerUri(config.get(ConfigProperties.SSO.OIDC.IssuerUrl))
.authorizationUri(config.get(ConfigProperties.SSO.OIDC.AuthorizeUrl)) .authorizationUri(config.get(ConfigProperties.SSO.OIDC.AuthorizeUrl))
@@ -35,6 +35,10 @@ class SsoAuthenticationSuccessHandler(
) { ) {
val oidcUser = authentication.principal as OidcUser 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 // Check if user is already registered via SSO
var matchedUser = userService.findByOidcProviderId(oidcUser.subject) var matchedUser = userService.findByOidcProviderId(oidcUser.subject)
@@ -42,27 +46,19 @@ class SsoAuthenticationSuccessHandler(
// This is meant to map existing users to SSO users // This is meant to map existing users to SSO users
if (matchedUser == null) { if (matchedUser == null) {
matchedUser = when (config.get(ConfigProperties.SSO.OIDC.MatchExistingUsersBy)) { 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) 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 // User could not be found in the database
if (matchedUser == null) { 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 // Register as new user
matchedUser = User(oidcUser) matchedUser = User(oidcUser, resolvedUsername)
} else { } else {
// Update user with new SSO data // Update user with new SSO data
matchedUser.username = oidcUser.preferredUsername matchedUser.username = resolvedUsername
matchedUser.email = oidcUser.email matchedUser.email = oidcUser.email
matchedUser.emailConfirmed = true matchedUser.emailConfirmed = true
matchedUser.oidcProviderId = oidcUser.subject matchedUser.oidcProviderId = oidcUser.subject
@@ -18,7 +18,7 @@ class DisplayableSerializer : ValueSerializer<Any>() {
// Use reflection to get the displayName property // Use reflection to get the displayName property
val displayName = value::class.java.getDeclaredField("displayName").apply { val displayName = value::class.java.getDeclaredField("displayName").apply {
isAccessible = true isAccessible = true
}.get(value) as String }[value] as String
gen.writeString(displayName) gen.writeString(displayName)
} }
@@ -103,7 +103,7 @@ class GameService(
fun getAll(): List<GameDto> { fun getAll(): List<GameDto> {
val entities = gameRepository.findAll() val entities = gameRepository.findAll().toList()
return entities.toDtos() return entities.toDtos()
} }
@@ -148,6 +148,7 @@ class GameService(
return gameRepository.saveAll(gamesToBePersisted) return gameRepository.saveAll(gamesToBePersisted)
} }
@Transactional
fun edit(gameUpdateDto: GameUpdateDto) { fun edit(gameUpdateDto: GameUpdateDto) {
val existingGame = gameRepository.findByIdOrNull(gameUpdateDto.id) val existingGame = gameRepository.findByIdOrNull(gameUpdateDto.id)
?: throw IllegalArgumentException("Game with ID $gameUpdateDto.id not found") ?: throw IllegalArgumentException("Game with ID $gameUpdateDto.id not found")
@@ -155,7 +156,7 @@ class GameService(
val user = when (val userDetails = getCurrentAuth()?.principal) { val user = when (val userDetails = getCurrentAuth()?.principal) {
is UserDetails -> userService.getByUsernameNonNull(userDetails.username) is UserDetails -> userService.getByUsernameNonNull(userDetails.username)
is OidcUser -> userService.getByUsernameNonNull(userDetails.preferredUsername) 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 // Update only non-null fields
@@ -466,7 +467,7 @@ class GameService(
try { try {
plugin.fetchByTitle(searchTerm, platformFilter, 10).map { plugin to it } plugin.fetchByTitle(searchTerm, platformFilter, 10).map { plugin to it }
} catch (e: Exception) { } 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.warn { "Error fetching metadata for searchterm '$searchTerm' with plugin '${(pluginWrapper?.descriptor as GameyfinPluginDescriptor?)?.pluginName ?: pluginWrapper?.pluginId ?: plugin.javaClass.name}': ${e.message}" }
log.debug(e) {} log.debug(e) {}
emptyList() emptyList()
@@ -601,7 +602,7 @@ class GameService(
try { try {
return@async plugin.fetchById(originalId) return@async plugin.fetchById(originalId)
} catch (e: Exception) { } 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.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) {} log.debug(e) {}
null null
@@ -749,7 +750,7 @@ class GameService(
try { try {
plugin.fetchByTitle(gameTitle, platforms).firstOrNull() plugin.fetchByTitle(gameTitle, platforms).firstOrNull()
} catch (e: Exception) { } 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.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) {} log.debug(e) {}
null null
@@ -807,120 +808,101 @@ class GameService(
sortedResults.forEach { (provider, metadata) -> sortedResults.forEach { (provider, metadata) ->
val sourcePlugin = providerToManagementEntry[provider] ?: return@forEach val sourcePlugin = providerToManagementEntry[provider] ?: return@forEach
if (metadata == null) return@forEach
metadata?.let { metadata ->
originalIdsMap[sourcePlugin] = metadata.originalId originalIdsMap[sourcePlugin] = metadata.originalId
applyMetadataFields(metadata, sourcePlugin, mergedGame, metadataMap)
}
metadata.platforms?.takeIf { it.isNotEmpty() }?.let { platforms -> mergedGame.metadata.fields = metadataMap
if (!metadataMap.containsKey("platforms")) { mergedGame.metadata.originalIds = originalIdsMap
mergedGame.platforms = platforms.toMutableList()
metadataMap["platforms"] = return mergedGame
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin)) }
/**
* 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))
} }
} }
metadata.title.takeIf { it.isNotBlank() }?.let { title ->
if (!metadataMap.containsKey("title")) { /**
mergedGame.title = title * Applies all metadata fields from a single plugin result onto the merged game,
metadataMap["title"] = GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin)) * 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
} }
metadata.description?.takeIf { it.isNotBlank() }?.let { description -> setFieldIfAbsent("summary", metadata.description?.takeIf { it.isNotBlank() }, metadataMap, sourcePlugin) {
if (!metadataMap.containsKey("summary")) { mergedGame.summary = it
mergedGame.summary = description
metadataMap["summary"] =
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
} }
} setFieldIfAbsent("coverImage", metadata.coverUrls?.firstOrNull(), metadataMap, sourcePlugin) {
metadata.coverUrls?.firstOrNull()?.let { coverUrl ->
if (!metadataMap.containsKey("coverImage")) {
mergedGame.coverImage = imageService.createOrGet( mergedGame.coverImage = imageService.createOrGet(
Image(originalUrl = coverUrl.toString(), type = ImageType.COVER) Image(originalUrl = it.toString(), type = ImageType.COVER)
) )
metadataMap["coverImage"] =
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
} }
} setFieldIfAbsent("headerImage", metadata.headerUrls?.firstOrNull(), metadataMap, sourcePlugin) {
metadata.headerUrls?.firstOrNull()?.let { headerUrl ->
if (!metadataMap.containsKey("headerImage")) {
mergedGame.headerImage = imageService.createOrGet( mergedGame.headerImage = imageService.createOrGet(
Image(originalUrl = headerUrl.toString(), type = ImageType.HEADER) Image(originalUrl = it.toString(), type = ImageType.HEADER)
) )
metadataMap["headerImage"] =
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
} }
setFieldIfAbsent("release", metadata.release, metadataMap, sourcePlugin) {
mergedGame.release = it
} }
metadata.release?.let { release -> setFieldIfAbsent("userRating", metadata.userRating, metadataMap, sourcePlugin) {
if (!metadataMap.containsKey("release")) { mergedGame.userRating = it
mergedGame.release = release
metadataMap["release"] =
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
} }
setFieldIfAbsent("criticRating", metadata.criticRating, metadataMap, sourcePlugin) {
mergedGame.criticRating = it
} }
metadata.userRating?.let { userRating -> setFieldIfAbsent("publishers", metadata.publishedBy?.takeIf { it.isNotEmpty() }, metadataMap, sourcePlugin) {
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 = mergedGame.publishers =
publishedBy.map { Company(name = it, type = CompanyType.PUBLISHER) }.toMutableList() it.map { name -> Company(name = name, type = CompanyType.PUBLISHER) }.toMutableList()
metadataMap["publishers"] =
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
} }
} setFieldIfAbsent("developers", metadata.developedBy?.takeIf { it.isNotEmpty() }, metadataMap, sourcePlugin) {
metadata.developedBy?.takeIf { it.isNotEmpty() }?.let { developedBy ->
if (!metadataMap.containsKey("developers")) {
mergedGame.developers = mergedGame.developers =
developedBy.map { Company(name = it, type = CompanyType.DEVELOPER) }.toMutableList() it.map { name -> Company(name = name, type = CompanyType.DEVELOPER) }.toMutableList()
metadataMap["developers"] =
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
} }
setFieldIfAbsent("genres", metadata.genres?.takeIf { it.isNotEmpty() }, metadataMap, sourcePlugin) {
mergedGame.genres = it.toList()
} }
metadata.genres?.takeIf { it.isNotEmpty() }?.let { genres -> setFieldIfAbsent("themes", metadata.themes?.takeIf { it.isNotEmpty() }, metadataMap, sourcePlugin) {
if (!metadataMap.containsKey("genres")) { mergedGame.themes = it.toList()
mergedGame.genres = genres.toList()
metadataMap["genres"] = GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
} }
setFieldIfAbsent("keywords", metadata.keywords?.takeIf { it.isNotEmpty() }, metadataMap, sourcePlugin) {
mergedGame.keywords = it.toList()
} }
metadata.themes?.takeIf { it.isNotEmpty() }?.let { themes -> setFieldIfAbsent("features", metadata.features?.takeIf { it.isNotEmpty() }, metadataMap, sourcePlugin) {
if (!metadataMap.containsKey("themes")) { mergedGame.features = it.toList()
mergedGame.themes = themes.toList()
metadataMap["themes"] = GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
} }
setFieldIfAbsent("perspectives", metadata.perspectives?.takeIf { it.isNotEmpty() }, metadataMap, sourcePlugin) {
mergedGame.perspectives = it.toList()
} }
metadata.keywords?.takeIf { it.isNotEmpty() }?.let { keywords -> setFieldIfAbsent(
if (!metadataMap.containsKey("keywords")) { "images",
mergedGame.keywords = keywords.toList() metadata.screenshotUrls?.takeIf { it.isNotEmpty() },
metadataMap["keywords"] = metadataMap,
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin)) sourcePlugin
} ) { screenshotUrls ->
}
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 { mergedGame.images = runBlocking {
screenshotUrls.map { screenshotUrls.map {
imageService.createOrGet( imageService.createOrGet(
@@ -928,23 +910,10 @@ class GameService(
) )
}.toMutableList() }.toMutableList()
} }
metadataMap["images"] = GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
} }
setFieldIfAbsent("videoUrls", metadata.videoUrls?.takeIf { it.isNotEmpty() }, metadataMap, sourcePlugin) {
mergedGame.videoUrls = it.toList()
} }
metadata.videoUrls?.takeIf { it.isNotEmpty() }?.let { videoUrls ->
if (!metadataMap.containsKey("videoUrls")) {
mergedGame.videoUrls = videoUrls.toList()
metadataMap["videoUrls"] =
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
}
}
}
}
mergedGame.metadata.fields = metadataMap
mergedGame.metadata.originalIds = originalIdsMap
return mergedGame
} }
private fun String.fuzzyMatchTitle(other: String): Boolean { private fun String.fuzzyMatchTitle(other: String): Boolean {
@@ -79,7 +79,7 @@ class Game(
@ManyToMany(cascade = [PERSIST, MERGE, REFRESH], fetch = FetchType.EAGER) @ManyToMany(cascade = [PERSIST, MERGE, REFRESH], fetch = FetchType.EAGER)
var images: MutableList<Image> = mutableListOf(), var images: MutableList<Image> = mutableListOf(),
@ElementCollection @ElementCollection(fetch = FetchType.EAGER)
var videoUrls: List<URI> = emptyList(), var videoUrls: List<URI> = emptyList(),
@ManyToMany(mappedBy = "games", fetch = FetchType.EAGER) @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.app.users.UserService
import org.gameyfin.pluginapi.gamemetadata.GameMetadataProvider import org.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.Instant import java.time.Instant
/** /**
@@ -52,6 +53,7 @@ class LibraryCoreService(
return library return library
} }
@Transactional
fun deleteGameFromLibrary(gameId: Long) { fun deleteGameFromLibrary(gameId: Long) {
val game = gameService.getById(gameId) val game = gameService.getById(gameId)
val library = game.library val library = game.library
@@ -1,7 +1,10 @@
package org.gameyfin.app.libraries package org.gameyfin.app.libraries
import io.github.oshai.kotlinlogging.KotlinLogging 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.filesystem.FilesystemService
import org.gameyfin.app.core.metrics.ScanMetrics
import org.gameyfin.app.core.plugins.PluginService import org.gameyfin.app.core.plugins.PluginService
import org.gameyfin.app.games.entities.Game import org.gameyfin.app.games.entities.Game
import org.gameyfin.app.games.repositories.GameRepository 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.UpdateExistingGamesResult
import org.gameyfin.app.libraries.scan.UpdateLibraryResult import org.gameyfin.app.libraries.scan.UpdateLibraryResult
import org.gameyfin.pluginapi.gamemetadata.GameMetadataProvider import org.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
import org.pf4j.PluginState
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
import reactor.core.publisher.Sinks import reactor.core.publisher.Sinks
import java.nio.file.Path import java.nio.file.Path
import java.time.Instant import java.time.Instant
import java.util.concurrent.Callable import java.util.*
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.*
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@@ -37,18 +40,30 @@ class LibraryScanService(
private val libraryGameProcessor: LibraryGameProcessor, private val libraryGameProcessor: LibraryGameProcessor,
private val gameRepository: GameRepository, private val gameRepository: GameRepository,
private val ignoredPathRepository: IgnoredPathRepository, private val ignoredPathRepository: IgnoredPathRepository,
private val pluginService: PluginService private val pluginService: PluginService,
private val configService: ConfigService,
private val scanMetrics: ScanMetrics
) { ) {
companion object { companion object {
private val log = KotlinLogging.logger {} private val log = KotlinLogging.logger {}
private val SCAN_RESULT_TTL = 24.hours.toJavaDuration() private val SCAN_PROGRESS_RETENTION = 24.hours.toJavaDuration()
private val scanProgressEvents = Sinks.many().replay().limit<LibraryScanProgress>(SCAN_RESULT_TTL)
/**
* 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>> { fun subscribeToScanProgressEvents(): Flux<List<LibraryScanProgress>> {
log.debug { "New subscription for scanProgressEvents" } 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()) .buffer(1.seconds.toJavaDuration())
.doOnSubscribe { .doOnSubscribe {
log.debug { "Subscriber added to scanProgressEvents [${scanProgressEvents.currentSubscriberCount()}]" } log.debug { "Subscriber added to scanProgressEvents [${scanProgressEvents.currentSubscriberCount()}]" }
@@ -59,18 +74,47 @@ class LibraryScanService(
} }
fun emit(scanProgressDto: LibraryScanProgress) { fun emit(scanProgressDto: LibraryScanProgress) {
latestProgressPerScan[scanProgressDto.scanId] = scanProgressDto
scanProgressEvents.tryEmitNext(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>() 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. * Wrapper function to trigger a scan for a list of libraries.
*/ */
fun triggerScan(scanType: ScanType, libraryIds: Collection<Long>?) { 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 -> libraries.forEach { library ->
val libraryId = library.id!! val libraryId = library.id!!
if (scansInProgress.putIfAbsent(libraryId, true) == null) { if (scansInProgress.putIfAbsent(libraryId, true) == null) {
@@ -100,6 +144,8 @@ class LibraryScanService(
) )
) )
emit(progress) emit(progress)
scanMetrics.recordScanStarted(ScanType.QUICK)
val scanStartTime = System.currentTimeMillis()
try { try {
val scanData = performFilesystemScan(library) val scanData = performFilesystemScan(library)
@@ -131,20 +177,32 @@ class LibraryScanService(
unmatched = newUnmatchedPaths.size unmatched = newUnmatchedPaths.size
) )
) )
scanMetrics.recordScanCompleted(
type = ScanType.QUICK,
durationMillis = System.currentTimeMillis() - scanStartTime,
newGames = persistedNewGames.size,
removedGames = removedGames.size,
unmatchedPaths = newUnmatchedPaths.size
)
} catch (e: Exception) { } catch (e: Exception) {
scanMetrics.recordScanFailed(ScanType.QUICK, System.currentTimeMillis() - scanStartTime)
handleScanError(e, library, progress, "quick scan") handleScanError(e, library, progress, "quick scan")
} }
} }
private fun fullScan(library: Library, triggeredBySchedule: Boolean) { private fun fullScan(library: Library, triggeredBySchedule: Boolean) {
val scanType = if (triggeredBySchedule) ScanType.SCHEDULED else ScanType.FULL
val progress = LibraryScanProgress( val progress = LibraryScanProgress(
libraryId = library.id!!, libraryId = library.id!!,
type = if (triggeredBySchedule) ScanType.SCHEDULED else ScanType.FULL, type = scanType,
currentStep = LibraryScanStep( currentStep = LibraryScanStep(
description = "Scanning filesystem" description = "Scanning filesystem"
) )
) )
emit(progress) emit(progress)
scanMetrics.recordScanStarted(scanType)
val scanStartTime = System.currentTimeMillis()
try { try {
val scanData = performFilesystemScan(library) val scanData = performFilesystemScan(library)
@@ -186,7 +244,17 @@ class LibraryScanService(
updated = updatedGames.size 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) { } catch (e: Exception) {
scanMetrics.recordScanFailed(scanType, System.currentTimeMillis() - scanStartTime)
handleScanError(e, library, progress, "full scan") handleScanError(e, library, progress, "full scan")
} }
} }
@@ -273,6 +341,7 @@ class LibraryScanService(
val tasks = gamePaths.map { path -> val tasks = gamePaths.map { path ->
Callable<Game?> { Callable<Game?> {
scanSemaphore.acquire()
try { try {
val persisted = libraryGameProcessor.processNewGame(path, library) val persisted = libraryGameProcessor.processNewGame(path, library)
@@ -305,6 +374,7 @@ class LibraryScanService(
return@Callable null return@Callable null
} finally { } finally {
scanSemaphore.release()
progress.currentStep.current = completed.incrementAndGet() progress.currentStep.current = completed.incrementAndGet()
emit(progress) emit(progress)
} }
@@ -374,6 +444,7 @@ class LibraryScanService(
val updateTasks = games.map { game -> val updateTasks = games.map { game ->
Callable<Game?> { Callable<Game?> {
scanSemaphore.acquire()
try { try {
val updated = libraryGameProcessor.processExistingGame(game) val updated = libraryGameProcessor.processExistingGame(game)
return@Callable updated return@Callable updated
@@ -382,6 +453,7 @@ class LibraryScanService(
log.debug(e) {} log.debug(e) {}
return@Callable null return@Callable null
} finally { } finally {
scanSemaphore.release()
progress.currentStep.current = completedUpdates.incrementAndGet() progress.currentStep.current = completedUpdates.incrementAndGet()
emit(progress) emit(progress)
} }
@@ -13,6 +13,7 @@ import org.gameyfin.app.libraries.extensions.toEntity
import org.gameyfin.app.users.UserService import org.gameyfin.app.users.UserService
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
import reactor.core.publisher.Sinks import reactor.core.publisher.Sinks
import java.time.Instant import java.time.Instant
@@ -72,7 +73,7 @@ class LibraryService(
* Retrieves all libraries from the repository. * Retrieves all libraries from the repository.
*/ */
fun getAll(): List<LibraryDto> { fun getAll(): List<LibraryDto> {
val entities = libraryRepository.findAll() val entities = libraryRepository.findAll().toList()
return entities.toDtos() return entities.toDtos()
} }
@@ -119,6 +120,7 @@ class LibraryService(
* @return The updated LibraryDto. * @return The updated LibraryDto.
* @throws IllegalArgumentException if the library ID is null or the library is not found. * @throws IllegalArgumentException if the library ID is null or the library is not found.
*/ */
@Transactional
fun update(libraryUpdateDto: LibraryUpdateDto) { fun update(libraryUpdateDto: LibraryUpdateDto) {
val library = libraryRepository.findByIdOrNull(libraryUpdateDto.id) val library = libraryRepository.findByIdOrNull(libraryUpdateDto.id)
?: throw IllegalArgumentException("Library with ID $libraryUpdateDto.id not found") ?: throw IllegalArgumentException("Library with ID $libraryUpdateDto.id not found")
@@ -196,6 +198,7 @@ class LibraryService(
/** /**
* Updates multiple libraries in the repository. * Updates multiple libraries in the repository.
*/ */
@Transactional
fun update(libraries: Collection<LibraryUpdateDto>) { fun update(libraries: Collection<LibraryUpdateDto>) {
libraries.forEach { update(it) } libraries.forEach { update(it) }
} }
@@ -80,7 +80,7 @@ class LibraryWatcherService(
running.set(true) running.set(true)
// Start watching all existing libraries // Start watching all existing libraries
val libraries = libraryRepository.findAll() val libraries = libraryRepository.findAll().toList()
libraries.forEach { library -> libraries.forEach { library ->
startWatchingLibrary(library) startWatchingLibrary(library)
} }
@@ -231,7 +231,7 @@ class LibraryWatcherService(
val watchKey = watchService?.poll(1, TimeUnit.SECONDS) ?: continue val watchKey = watchService?.poll(1, TimeUnit.SECONDS) ?: continue
val watchInfo = watchKeys[watchKey] ?: continue val watchInfo = watchKeys[watchKey] ?: continue
val events = watchKey.pollEvents() val events = watchKey.pollEvents().toList()
if (events.isEmpty()) { if (events.isEmpty()) {
watchKey.reset() watchKey.reset()
continue continue
@@ -21,7 +21,7 @@ class IgnoredPath(
return when (source) { return when (source) {
is IgnoredPathPluginSource -> IgnoredPathSourceType.PLUGIN is IgnoredPathPluginSource -> IgnoredPathSourceType.PLUGIN
is IgnoredPathUserSource -> IgnoredPathSourceType.USER is IgnoredPathUserSource -> IgnoredPathSourceType.USER
else -> throw IllegalStateException("Unknown IgnoredPathSource type") else -> error("Unknown IgnoredPathSource type")
} }
} }
} }
@@ -41,7 +41,7 @@ abstract class IgnoredPathSource(
@Entity @Entity
class IgnoredPathPluginSource( class IgnoredPathPluginSource(
@ManyToMany @ManyToMany(fetch = FetchType.EAGER)
val plugins: MutableList<PluginManagementEntry> val plugins: MutableList<PluginManagementEntry>
) : IgnoredPathSource() ) : IgnoredPathSource()
@@ -17,7 +17,7 @@ fun IgnoredPath.toDto(): IgnoredPathDto {
source = when (val source = this.source) { source = when (val source = this.source) {
is IgnoredPathPluginSource -> source.plugins.joinToString("\", \"", "[\"", "\"]") { it.pluginId } is IgnoredPathPluginSource -> source.plugins.joinToString("\", \"", "[\"", "\"]") { it.pluginId }
is IgnoredPathUserSource -> source.user.id.toString() 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. * Checks if a file exists for the given content ID.
*/ */
@@ -1,8 +1,10 @@
package org.gameyfin.app.media package org.gameyfin.app.media
import jakarta.persistence.* import jakarta.persistence.*
import org.hibernate.annotations.BatchSize
@Entity @Entity
@BatchSize(size = 100)
class Image( class Image(
@Id @Id
@GeneratedValue(strategy = GenerationType.AUTO) @GeneratedValue(strategy = GenerationType.AUTO)
@@ -3,6 +3,7 @@ package org.gameyfin.app.media
import com.vaadin.flow.server.auth.AnonymousAllowed import com.vaadin.flow.server.auth.AnonymousAllowed
import jakarta.annotation.security.PermitAll import jakarta.annotation.security.PermitAll
import jakarta.annotation.security.RolesAllowed import jakarta.annotation.security.RolesAllowed
import jakarta.servlet.http.HttpServletRequest
import org.gameyfin.app.core.Role import org.gameyfin.app.core.Role
import org.gameyfin.app.core.Utils import org.gameyfin.app.core.Utils
import org.gameyfin.app.core.annotations.DynamicPublicAccess 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.core.security.getCurrentAuth
import org.gameyfin.app.users.UserService import org.gameyfin.app.users.UserService
import org.springframework.core.io.ByteArrayResource import org.springframework.core.io.ByteArrayResource
import org.springframework.core.io.InputStreamResource import org.springframework.core.io.FileSystemResource
import org.springframework.http.HttpHeaders import org.springframework.core.io.Resource
import org.springframework.http.MediaType import org.springframework.http.*
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.* import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import java.nio.file.Files
import java.util.concurrent.TimeUnit
@RestController @RestController
@RequestMapping("/images") @RequestMapping("/images")
@@ -28,18 +30,18 @@ class ImageEndpoint(
) { ) {
@GetMapping("/screenshot/{id}") @GetMapping("/screenshot/{id}")
fun getScreenshot(@PathVariable id: Long): ResponseEntity<InputStreamResource>? { fun getScreenshot(@PathVariable id: Long, request: HttpServletRequest): ResponseEntity<Resource>? {
return getImageContent(id) return getImageContent(id, request)
} }
@GetMapping("/cover/{id}") @GetMapping("/cover/{id}")
fun getCover(@PathVariable id: Long): ResponseEntity<InputStreamResource>? { fun getCover(@PathVariable id: Long, request: HttpServletRequest): ResponseEntity<Resource>? {
return getImageContent(id) return getImageContent(id, request)
} }
@GetMapping("/header/{id}") @GetMapping("/header/{id}")
fun getHeader(@PathVariable id: Long): ResponseEntity<InputStreamResource>? { fun getHeader(@PathVariable id: Long, request: HttpServletRequest): ResponseEntity<Resource>? {
return getImageContent(id) return getImageContent(id, request)
} }
@GetMapping("/plugins/{pluginId}/logo") @GetMapping("/plugins/{pluginId}/logo")
@@ -49,16 +51,16 @@ class ImageEndpoint(
} }
@GetMapping("/avatar") @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() val avatar = userService.getAvatar(username) ?: return ResponseEntity.notFound().build()
if (avatar.id == null) return ResponseEntity.notFound().build() if (avatar.id == null) return ResponseEntity.notFound().build()
return getImageContent(avatar.id!!) return getImageContent(avatar.id!!, request)
} }
@PermitAll @PermitAll
@PostMapping("/avatar/upload") @PostMapping("/avatar/upload")
fun uploadAvatar(@RequestParam("file") file: MultipartFile) { 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)) { val image: Image = if (!userService.hasAvatar(auth.name)) {
imageService.createFromInputStream(ImageType.AVATAR, file.inputStream, file.contentType!!) imageService.createFromInputStream(ImageType.AVATAR, file.inputStream, file.contentType!!)
@@ -73,7 +75,7 @@ class ImageEndpoint(
@PermitAll @PermitAll
@PostMapping("/avatar/delete") @PostMapping("/avatar/delete")
fun deleteAvatar() { fun deleteAvatar() {
val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found") val auth = getCurrentAuth() ?: error("No authentication found")
userService.deleteAvatar(auth.name) userService.deleteAvatar(auth.name)
} }
@@ -83,21 +85,44 @@ class ImageEndpoint(
userService.deleteAvatar(name) 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 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() val headers = HttpHeaders()
image.contentLength?.let { headers.contentLength = it } image.contentLength?.let { headers.contentLength = it }
image.mimeType?.let { headers.contentType = MediaType.parseMediaType(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() return ResponseEntity.ok()
.headers(headers) .headers(headers)
.body(inputStreamResource) .body(resource)
} }
} }
@@ -1,6 +1,8 @@
package org.gameyfin.app.media package org.gameyfin.app.media
import com.github.benmanes.caffeine.cache.Cache
import com.vanniktech.blurhash.BlurHash import com.vanniktech.blurhash.BlurHash
import io.github.oshai.kotlinlogging.KotlinLogging
import org.apache.tika.Tika import org.apache.tika.Tika
import org.apache.tika.io.TikaInputStream import org.apache.tika.io.TikaInputStream
import org.gameyfin.app.core.events.GameDeletedEvent 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.GameRepository
import org.gameyfin.app.games.repositories.ImageRepository import org.gameyfin.app.games.repositories.ImageRepository
import org.gameyfin.app.users.persistence.UserRepository 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.dao.DataIntegrityViolationException
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.scheduling.annotation.Async import org.springframework.scheduling.annotation.Async
@@ -19,9 +23,11 @@ import org.springframework.transaction.event.TransactionPhase
import org.springframework.transaction.event.TransactionalEventListener import org.springframework.transaction.event.TransactionalEventListener
import java.awt.RenderingHints import java.awt.RenderingHints
import java.awt.image.BufferedImage import java.awt.image.BufferedImage
import java.io.ByteArrayInputStream
import java.io.InputStream import java.io.InputStream
import java.net.URI import java.net.URI
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import javax.imageio.ImageIO import javax.imageio.ImageIO
@Service @Service
@@ -29,9 +35,12 @@ class ImageService(
private val imageRepository: ImageRepository, private val imageRepository: ImageRepository,
private val fileStorageService: FileStorageService, private val fileStorageService: FileStorageService,
private val gameRepository: GameRepository, private val gameRepository: GameRepository,
private val userRepository: UserRepository private val userRepository: UserRepository,
private val imageCache: Cache<Long, Image>
) { ) {
companion object { companion object {
private val log = KotlinLogging.logger { }
private val tika = Tika() 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 @Async
@TransactionalEventListener( @TransactionalEventListener(
classes = [GameDeletedEvent::class], classes = [GameDeletedEvent::class],
phase = TransactionPhase.AFTER_COMPLETION phase = TransactionPhase.AFTER_COMPLETION
) )
fun onGameDeleted(event: GameDeletedEvent) { fun onGameDeleted(event: GameDeletedEvent) {
val imagesToDelete = listOfNotNull(event.game.coverImage, event.game.headerImage) val imagesToDelete = listOfNotNull(event.game.coverImage, event.game.headerImage) +
.toMutableList() event.game.images
.apply { addAll(event.game.images) }
imagesToDelete.forEach { deleteImageIfUnused(it) } imagesToDelete.forEach { deleteImageIfUnused(it) }
} }
@@ -84,14 +102,12 @@ class ImageService(
phase = TransactionPhase.AFTER_COMPLETION phase = TransactionPhase.AFTER_COMPLETION
) )
fun onGameUpdated(event: GameUpdatedEvent) { fun onGameUpdated(event: GameUpdatedEvent) {
val imagesBeforeUpdate = listOfNotNull(event.previousState.coverImage, event.previousState.headerImage) val imagesBeforeUpdate = (listOfNotNull(event.previousState.coverImage, event.previousState.headerImage) +
.toMutableList() event.previousState.images)
.apply { addAll(event.previousState.images) }
.toSet() .toSet()
val imagesStillInUse = listOfNotNull(event.currentState.coverImage, event.currentState.headerImage) val imagesStillInUse = (listOfNotNull(event.currentState.coverImage, event.currentState.headerImage) +
.toMutableList() event.currentState.images)
.apply { addAll(event.currentState.images) }
.toSet() .toSet()
imagesBeforeUpdate.minus(imagesStillInUse).forEach { deleteImageIfUnused(it) } imagesBeforeUpdate.minus(imagesStillInUse).forEach { deleteImageIfUnused(it) }
@@ -122,7 +138,7 @@ class ImageService(
val url = image.originalUrl val url = image.originalUrl
if (url.isNullOrBlank()) { if (url.isNullOrBlank()) {
// No original URL => cannot dedupe by URL; just persist as-is // 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 // Prefer a list lookup to avoid IncorrectResultSizeDataAccessException if duplicates exist pre-migration
@@ -131,7 +147,7 @@ class ImageService(
return try { return try {
val toSave = Image(originalUrl = url, type = image.type) 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) { } catch (e: DataIntegrityViolationException) {
// Unique (original_url) might have been inserted concurrently; fetch and return // Unique (original_url) might have been inserted concurrently; fetch and return
imageRepository.findAllByOriginalUrl(url).firstOrNull() imageRepository.findAllByOriginalUrl(url).firstOrNull()
@@ -140,7 +156,7 @@ class ImageService(
} }
fun downloadIfNew(image: Image) { 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 // Always try to get existing image first to avoid detached entity issues and duplicate lookups
val existingImage = imageRepository.findAllByOriginalUrl(image.originalUrl).firstOrNull() val existingImage = imageRepository.findAllByOriginalUrl(image.originalUrl).firstOrNull()
@@ -165,7 +181,7 @@ class ImageService(
// Save or update the image to ensure it's persisted // Save or update the image to ensure it's persisted
try { try {
imageRepository.save(image) imageRepository.save(image).also { saved -> saved.id?.let { imageCache.put(it, saved) } }
} catch (_: DataIntegrityViolationException) { } catch (_: DataIntegrityViolationException) {
// If another thread saved the same URL meanwhile, just ignore and proceed // 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 { fun createFromInputStream(type: ImageType, content: InputStream, mimeType: String): Image {
val image = Image(type = type, mimeType = mimeType) val image = Image(type = type, mimeType = mimeType)
processImageContent(image, content) 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? { 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? { fun getFileContent(image: Image): InputStream? {
return fileStorageService.getFile(image.contentId) return fileStorageService.getFile(image.contentId)
} }
fun getFilePath(image: Image): Path? {
return fileStorageService.getFilePath(image.contentId)
}
fun deleteImageIfUnused(image: Image) { fun deleteImageIfUnused(image: Image) {
val imageId = image.id ?: return val imageId = image.id ?: return
val isImageStillInUse = gameRepository.existsByImage(imageId) || userRepository.existsByAvatar(imageId) val isImageStillInUse = gameRepository.existsByImage(imageId) || userRepository.existsByAvatar(imageId)
if (!isImageStillInUse) { if (!isImageStillInUse) {
imageCache.invalidate(imageId)
imageRepository.delete(image) imageRepository.delete(image)
fileStorageService.deleteFile(image.contentId) fileStorageService.deleteFile(image.contentId)
} }
@@ -205,6 +229,9 @@ class ImageService(
// Process and store new content // Process and store new content
processImageContent(image, content) processImageContent(image, content)
// Invalidate cache so the next read picks up fresh data
image.id?.let { imageCache.invalidate(it) }
return imageRepository.save(image) return imageRepository.save(image)
} }
@@ -216,28 +243,40 @@ class ImageService(
} }
private fun processImageContent(image: Image, content: InputStream) { private fun processImageContent(image: Image, content: InputStream) {
// Read the input stream into a byte array so we can use it twice // Stream to a temp file to avoid holding the full image bytes on the heap.
val imageBytes = content.readBytes() // 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 val fileSize = Files.size(tempFile)
ByteArrayInputStream(imageBytes).use { blurhashStream ->
// 2. Calculate blurhash from the temp file
Files.newInputStream(tempFile).use { blurhashStream ->
image.blurhash = calculateBlurhash(blurhashStream) image.blurhash = calculateBlurhash(blurhashStream)
} }
// Store content // 3. Store content from the temp file
ByteArrayInputStream(imageBytes).use { contentStream -> Files.newInputStream(tempFile).use { contentStream ->
image.contentId = fileStorageService.saveFile(contentStream) image.contentId = fileStorageService.saveFile(contentStream)
image.contentLength = imageBytes.size.toLong() image.contentLength = fileSize
}
} finally {
Files.deleteIfExists(tempFile)
} }
} }
private fun calculateBlurhash(inputStream: InputStream): String? { private fun calculateBlurhash(inputStream: InputStream): String? {
return try { return try {
val originalImage = ImageIO.read(inputStream) val originalImage = ImageIO.read(inputStream) ?: return null
if (originalImage != null) { try {
// Scale down for much faster processing // Scale down for much faster processing and less memory
val scaledImage = scaleImageForBlurhash(originalImage) val scaledImage = scaleImageForBlurhash(originalImage)
try {
return if (scaledImage.width > scaledImage.height) { return if (scaledImage.width > scaledImage.height) {
// Landscape // Landscape
BlurHash.encode(scaledImage, componentX = 4, componentY = 3) BlurHash.encode(scaledImage, componentX = 4, componentY = 3)
@@ -248,8 +287,13 @@ class ImageService(
// Square // Square
BlurHash.encode(scaledImage, componentX = 3, componentY = 3) BlurHash.encode(scaledImage, componentX = 3, componentY = 3)
} }
} else { } finally {
null // Release scaled image native memory immediately
if (scaledImage !== originalImage) scaledImage.flush()
}
} finally {
// Release original image native memory immediately
originalImage.flush()
} }
} catch (_: Exception) { } catch (_: Exception) {
null null
@@ -61,8 +61,8 @@ class MessageService(
} }
try { try {
val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found") val auth = getCurrentAuth() ?: error("No authentication found")
val user = userService.getByUsername(auth.name) ?: throw IllegalStateException("User not found") val user = userService.getByUsername(auth.name) ?: error("User not found")
val template = templateService.getMessageTemplate(templateKey) val template = templateService.getMessageTemplate(templateKey)
sendNotification(user.email, "[Gameyfin] Test Notification", template, placeholders) sendNotification(user.email, "[Gameyfin] Test Notification", template, placeholders)
} catch (e: Exception) { } catch (e: Exception) {
@@ -40,8 +40,8 @@ class MessageTemplateService {
} }
fun fillMessageTemplate(template: MessageTemplates, type: TemplateType, placeholders: Map<String, String>): String { fun fillMessageTemplate(template: MessageTemplates, type: TemplateType, placeholders: Map<String, String>): String {
if (placeholders.keys != template.availablePlaceholders.toSet()) { require(placeholders.keys == template.availablePlaceholders.toSet()) {
throw IllegalArgumentException("Placeholders do not match available placeholders for template '${template.key}'") "Placeholders do not match available placeholders for template '${template.key}'"
} }
val content = getMessageTemplateContent(template.key, type) val content = getMessageTemplateContent(template.key, type)
@@ -82,7 +82,7 @@ class MessageTemplateService {
private fun getDefaultTemplateFile(key: String, type: TemplateType): Path { private fun getDefaultTemplateFile(key: String, type: TemplateType): Path {
log.debug { "No custom message template found for '$key.${type.extension}', returning default" } log.debug { "No custom message template found for '$key.${type.extension}', returning default" }
val resourceUrl = javaClass.classLoader.getResource("$DEFAULT_TEMPLATE_PATH/$key.${type.extension}") 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()) return Paths.get(resourceUrl.toURI())
} }
@@ -78,7 +78,7 @@ class GameRequestService(
} }
fun getAll(): List<GameRequestDto> { fun getAll(): List<GameRequestDto> {
val entities = gameRequestRepository.findAll() val entities = gameRequestRepository.findAll().toList()
return entities.toDtos() return entities.toDtos()
} }
@@ -177,9 +177,9 @@ class GameRequestService(
@Transactional @Transactional
fun toggleRequestVote(id: Long) { fun toggleRequestVote(id: Long) {
val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found") val auth = getCurrentAuth() ?: error("No authentication found")
val currentUser = 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) val gameRequest = gameRequestRepository.findById(id)
.orElseThrow { NoSuchElementException("No game request found with id $id") } .orElseThrow { NoSuchElementException("No game request found with id $id") }
@@ -192,8 +192,6 @@ class GameRequestService(
} }
gameRequest.voters = updatedVoters gameRequest.voters = updatedVoters
// Ensure the entity is marked as dirty
gameRequest.status = gameRequest.status
gameRequestRepository.save(gameRequest) gameRequestRepository.save(gameRequest)
} }
@@ -201,7 +199,7 @@ class GameRequestService(
private fun completeMatchingRequests(game: Game) { private fun completeMatchingRequests(game: Game) {
val gameTitle = game.title val gameTitle = game.title
val gameRelease = game.release val gameRelease = game.release
val gamePlatforms = game.platforms val gamePlatforms = game.platforms.toList()
if (gameTitle == null) { if (gameTitle == null) {
log.warn { "Game '${game.id}' is missing title, cannot complete matching requests" } log.warn { "Game '${game.id}' is missing title, cannot complete matching requests" }
@@ -17,6 +17,11 @@ class UserEndpoint(
private val userService: UserService, private val userService: UserService,
private val roleService: RoleService private val roleService: RoleService
) { ) {
companion object {
private const val NO_AUTH_FOUND = "No authentication found"
}
@AnonymousAllowed @AnonymousAllowed
fun getUserInfo(): ExtendedUserInfoDto? { fun getUserInfo(): ExtendedUserInfoDto? {
val auth = getCurrentAuth() val auth = getCurrentAuth()
@@ -26,7 +31,7 @@ class UserEndpoint(
@PermitAll @PermitAll
fun updateUser(updates: UserUpdateDto) { fun updateUser(updates: UserUpdateDto) {
val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found") val auth = getCurrentAuth() ?: error(NO_AUTH_FOUND)
userService.updateUser(auth.name, updates) userService.updateUser(auth.name, updates)
} }
@@ -57,7 +62,7 @@ class UserEndpoint(
@PermitAll @PermitAll
fun deleteUser() { fun deleteUser() {
val auth: Authentication = getCurrentAuth() ?: throw IllegalStateException("No authentication found") val auth: Authentication = getCurrentAuth() ?: error(NO_AUTH_FOUND)
userService.deleteUser(auth.name) userService.deleteUser(auth.name)
} }
@@ -73,7 +78,7 @@ class UserEndpoint(
@RolesAllowed(Role.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
fun getRolesBelow(): List<String> { 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 } return roleService.getRolesBelowAuth(auth).map { it.roleName }
} }
@@ -42,7 +42,11 @@ class UserService(
private val eventPublisher: ApplicationEventPublisher private val eventPublisher: ApplicationEventPublisher
) : UserDetailsService { ) : UserDetailsService {
companion object {
private const val NO_AUTH_FOUND = "No authentication found"
private val log = KotlinLogging.logger {} private val log = KotlinLogging.logger {}
}
val selfRegistrationAllowed: Boolean val selfRegistrationAllowed: Boolean
get() = config.get(ConfigProperties.Users.SignUps.Allow) == true get() = config.get(ConfigProperties.Users.SignUps.Allow) == true
@@ -106,7 +110,7 @@ class UserService(
} }
fun getUserInfo(): ExtendedUserInfoDto { fun getUserInfo(): ExtendedUserInfoDto {
val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found") val auth = getCurrentAuth() ?: error(NO_AUTH_FOUND)
val principal = auth.principal val principal = auth.principal
if (principal is OidcUser) { if (principal is OidcUser) {
@@ -159,13 +163,8 @@ class UserService(
} }
fun selfRegisterUser(registration: UserRegistrationDto) { fun selfRegisterUser(registration: UserRegistrationDto) {
if (!selfRegistrationAllowed) { check(selfRegistrationAllowed) { "Sign ups are not allowed" }
throw IllegalStateException("Sign ups are not allowed") check(!existsByUsername(registration.username)) { "User with username '${registration.username}' already exists" }
}
if (existsByUsername(registration.username)) {
throw IllegalStateException("User with username '${registration.username}' already exists")
}
userRepository.findByEmail(registration.email)?.let { userRepository.findByEmail(registration.email)?.let {
eventPublisher.publishEvent( eventPublisher.publishEvent(
@@ -216,7 +215,7 @@ class UserService(
) )
if (existsByUsername(user.username)) { 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) return userRepository.save(user)
@@ -252,7 +251,7 @@ class UserService(
return RoleAssignmentResult.NO_ROLES_PROVIDED return RoleAssignmentResult.NO_ROLES_PROVIDED
} }
val currentUser = getCurrentAuth() ?: throw IllegalStateException("No authentication found") val currentUser = getCurrentAuth() ?: error(NO_AUTH_FOUND)
val targetUser = getByUsernameNonNull(username) val targetUser = getByUsernameNonNull(username)
if (!canManage(targetUser)) { if (!canManage(targetUser)) {
@@ -280,7 +279,7 @@ class UserService(
} }
fun canManage(targetUser: org.gameyfin.app.users.entities.User): Boolean { 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 currentUserLevel = roleService.getHighestRoleFromAuthorities(currentUser.authorities).powerLevel
val targetUserLevel = roleService.getHighestRole(targetUser.roles).powerLevel val targetUserLevel = roleService.getHighestRole(targetUser.roles).powerLevel
return currentUserLevel > targetUserLevel return currentUserLevel > targetUserLevel
@@ -19,7 +19,7 @@ class EmailConfirmationEndpoint(
@PermitAll @PermitAll
fun resendEmailConfirmation() { fun resendEmailConfirmation() {
val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found") val auth = getCurrentAuth() ?: error("No authentication found")
userService.getByUsername(auth.name)?.let { userService.getByUsername(auth.name)?.let {
emailConfirmationService.resendEmailConfirmation(it) emailConfirmationService.resendEmailConfirmation(it)
} }
@@ -46,4 +46,12 @@ class User(
enabled = true, enabled = true,
oidcProviderId = oidcUser.subject 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( val gameRequests = entityManager.createQuery(
"SELECT gr FROM GameRequest gr WHERE :user MEMBER OF gr.voters OR gr.requester = :user", "SELECT gr FROM GameRequest gr WHERE :user MEMBER OF gr.voters OR gr.requester = :user",
GameRequest::class.java GameRequest::class.java
).setParameter("user", user).resultList ).setParameter("user", user).resultList.toList()
for (gr in gameRequests) { for (gr in gameRequests) {
gr.voters.remove(user) gr.voters.remove(user)
if (gr.requester == user) gr.requester = null if (gr.requester == user) gr.requester = null
@@ -26,10 +26,7 @@ class PasswordResetService(
private val secureRandom = SecureRandom() private val secureRandom = SecureRandom()
override fun generate(user: User): Token<TokenType.PasswordReset> { override fun generate(user: User): Token<TokenType.PasswordReset> {
if (user.oidcProviderId != null) { check(user.oidcProviderId == null) { "Cannot create password reset token for user '${user.username}' because user is managed externally" }
throw IllegalStateException("Cannot create password reset token for user '${user.username}' because user is managed externally")
}
return super.generate(user) return super.generate(user)
} }
@@ -44,9 +41,7 @@ class PasswordResetService(
val user = userService.getByUsername(username) val user = userService.getByUsername(username)
?: throw IllegalArgumentException("Cannot create password reset token for user '$username' because user does not exist") ?: throw IllegalArgumentException("Cannot create password reset token for user '$username' because user does not exist")
if (messageService.enabled && user.emailConfirmed) { check(!(messageService.enabled && user.emailConfirmed)) { "Cannot create password reset token for user '$username' because self-service is enabled" }
throw IllegalStateException("Cannot create password reset token for user '$username' because self-service is enabled")
}
val token = generate(user) val token = generate(user)
return TokenDto(token) return TokenDto(token)
@@ -135,7 +135,7 @@ class UserPreferencesService(
} }
private fun id(key: String): UserPreferenceKey { 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) val user = userService.getByUsernameNonNull(auth.name)
return UserPreferenceKey(key, user.id!!) return UserPreferenceKey(key, user.id!!)
} }
@@ -26,11 +26,10 @@ class InvitationService(
} }
fun createInvitation(email: String): TokenDto { fun createInvitation(email: String): TokenDto {
if (userService.existsByEmail(email)) check(!(userService.existsByEmail(email))) { "User with email ${Utils.maskEmail(email)} is already registered" }
throw IllegalStateException("User with email ${Utils.maskEmail(email)} is already registered")
val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found") val auth = getCurrentAuth() ?: error("No authentication found")
val user = userService.getByUsername(auth.name) ?: throw IllegalStateException("User not found") val user = userService.getByUsername(auth.name) ?: error("User not found")
val payload = mapOf(EMAIL_KEY to email) val payload = mapOf(EMAIL_KEY to email)
val token = super.generateWithPayload(user, payload) val token = super.generateWithPayload(user, payload)
@@ -15,6 +15,6 @@ object EntityManagerHolder : ApplicationContextAware {
} }
fun getEntityManager(): EntityManager { 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. * This method is called from Flyway migration V2.3.0.6.
* Uses multithreading and batch updates for performance. * Uses multithreading and batch updates for performance.
*/ */
@Suppress("kotlin:S3776")
@JvmStatic @JvmStatic
fun calculateBlurhashesForAllImages(conn: Connection, dataPath: String) { fun calculateBlurhashesForAllImages(conn: Connection, dataPath: String) {
val startTime = System.currentTimeMillis() 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: logging.level:
root: info root: info
org.gameyfin.GameyfinApplicationKt: info org.gameyfin: debug
org.gameyfin.app.GameyfinApplicationKt: info
org.flywaydb.core.internal.database.base: warn
spring: spring:
datasource: datasource:
+40 -25
View File
@@ -4,7 +4,8 @@ logging.level:
org.gameyfin: info org.gameyfin: info
org.flywaydb.core.internal.command: info org.flywaydb.core.internal.command: info
org.flywaydb.core.internal.sqlscript: warn 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 # Suppress false positive warnings from Spring Security 6
org.springframework.security.config.annotation.authentication.configuration.InitializeUserDetailsBeanManagerConfigurer: error org.springframework.security.config.annotation.authentication.configuration.InitializeUserDetailsBeanManagerConfigurer: error
@@ -13,12 +14,19 @@ server:
servlet: servlet:
session: session:
tracking-modes: cookie tracking-modes: cookie
timeout: 24h timeout: 4h
forward-headers-strategy: framework forward-headers-strategy: framework
jetty: tomcat:
remoteip:
protocol-header: X-Forwarded-Proto
remote-ip-header: X-Forwarded-For
threads: threads:
max: 200 max: 200
min: 8 min-spare: 10
max-connections: 10_000
max-keep-alive-requests: 100
connection-timeout: 10m
keep-alive-timeout: 10m
management: management:
server: server:
@@ -26,42 +34,49 @@ management:
endpoints: endpoints:
web: web:
exposure: exposure:
include: restart, health, info, metrics, prometheus include: restart, health, metrics, prometheus, info
endpoint: endpoint:
pause: pause:
enabled: false enabled: false
restart: restart:
access: unrestricted access: unrestricted
health:
group:
readiness:
include: pluginsLoaded
spring: 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: application:
name: Gameyfin name: Gameyfin
version: @project.version@ version: @project.version@
threads: threads:
virtual.enabled: true virtual.enabled: true
mvc: sql.init.mode: always
async.request-timeout: 0 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: flyway:
baseline-on-migrate: true baseline-on-migrate: true
baseline-version: 2.0.0 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: vaadin:
# To improve the performance during development. # 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. -- 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. -- 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. -- 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. -- 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) -- 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 -- 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 -- 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 -- Step 4: Drop the primary key constraint on SECRET
-- H2 uses auto-generated constraint names, so we need to find and drop it -- 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 -- 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 -- 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) -- 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 -- 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 -- 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;

Some files were not shown because too many files have changed in this diff Show More