mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
Release 2.4.0 (#870)
* chore: bump version to v2.4.0-preview * Bump actions/cache from 4 to 5 (#865) Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/cache dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Increase maximum DB connection pool size (#876) Increase DB connection timeout * Disable length limit for DB field PLUGIN_CONFIG.value (#875) * Bump actions/cache from 4 to 5 (#871) Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/cache dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump actions/download-artifact from 7 to 8 (#882) Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 7 to 8. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v7...v8) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: '8' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump actions/upload-artifact from 6 to 7 (#881) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump actions/cache from 4 to 5 (#878) Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/cache dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Dont perform scans if no metadata plugins are enabled (#877) * Dont perform scans if no metadata plugins are enabled * Fix tests * Add PluginServiceTest.kt * Fix Sonar finding * Fix malformed external links (#886) * Fix external links being treated as internal * chore: bump version to v856-malformed-external-links-preview * Update JVM in Dockerfile to Java 25 * Revert incorrect version update * Allow loading .jar plugins in development mode (#885) * Allow loading .jar plugins in development mode * Remove unnecessary mock * Fix unit test * Add unit tests * Fix/879 add info and reset to config options (#887) * Fix gog.sh script * Add "description" property to ConfigProperties.kt Add InfoPopup.tsx and ResetToDefaultButton.tsx in UI * Bump actions/download-artifact from 7 to 8 (#891) Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 7 to 8. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v7...v8) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: '8' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump actions/cache from 4 to 5 (#890) Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/cache dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump actions/upload-artifact from 6 to 7 (#889) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Improve memory usage and performance (#888) mprove memory usage and performance by: * Using AOT cache * Using tuned JAVA_OPTIONS * Session timeout * Jetty threadpool * DB batch size * DB pool size * Library scanning * Make scan-concurrency configurable * Log retention * Off-load image processing to disk instead of RAM * Fix bug in PluginState * Update dependency version for ksp * Fix race condition preventing plugins from starting * Show remaining time (estimation) for library scans * Add unit test for plugin loading bugfix * Add unit tests for ImageService calculateBlurHash * Make username claim configurable (#895) Add fallbacks to resolve username * Fix sonar issues (#894) * Add custom "/sonar" command to GH copilot * Add Sonar plugin integration * Fix issues reported by Sonar * Ignore Sonar warning about AES/ECB * Add unit tests for GameyfinPluginManager * Add unit tests for GameService * Add more unit tests for GameService * Improve library card layout (#896) * Fix title not being centered * Add buttons to scan all libraries * Disable AVX for AOT cache training * Improve AOT cache training * Fix tests * Change output type of Docker Build CI action * Increase MAX_WAIT of aot-training to 5min * Optimize Docker CI pipeline * Add Sonar badges to README.md * Add custom metrics (downloads & scans) * Optimize DB connection & add cache for images * Adjusted logging on startup * * Show message on start page when no libraries/games are available * Disable "Scan" buttons when no metadata plugin is enabled * Fix thread pinning causing deadlocks * Pre-populate image cache at startup * Show "Loading" spinner while loading * Optimize static file serving (images) * Switch back to Tomcat (from Jetty) --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
@@ -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)'
|
||||||
|
|
||||||
+25
-21
@@ -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)'
|
||||||
|
|
||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -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.*" />
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"sonarCloudOrganization": "gameyfin",
|
||||||
|
"projectKey": "gameyfin_gameyfin",
|
||||||
|
"region": "EU"
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
[](https://sonarcloud.io/summary/new_code?id=gameyfin_gameyfin)
|
||||||
|
[](https://sonarcloud.io/summary/new_code?id=gameyfin_gameyfin)
|
||||||
|
[](https://sonarcloud.io/summary/new_code?id=gameyfin_gameyfin)
|
||||||
|
[](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
|
||||||
|
|
||||||
|
|
||||||
[](https://www.yourkit.com/)
|
[](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
@@ -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"]}")
|
||||||
|
|||||||
Generated
+978
-1014
File diff suppressed because it is too large
Load Diff
+114
-114
@@ -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.
@@ -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,7 +1,7 @@
|
|||||||
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";
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+1
-1
@@ -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 {
|
||||||
+6
-13
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-15
@@ -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
|
||||||
|
|||||||
+1
-3
@@ -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,
|
||||||
|
|||||||
+3
-3
@@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -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
|
||||||
|
|||||||
+1
-1
@@ -38,7 +38,7 @@ data class GameyfinPluginDescriptor(
|
|||||||
// This is because the internal (List<PluginDependency>) and external (List<String>) representation of the field differ
|
// This 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+46
-61
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
package org.gameyfin.app.core.plugins.management
|
||||||
|
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.security.CodeSigner
|
||||||
|
import java.security.PublicKey
|
||||||
|
import java.security.cert.Certificate
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import java.util.jar.JarEntry
|
||||||
|
import java.util.jar.JarFile
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies JAR plugin signatures against a trusted public key.
|
||||||
|
*/
|
||||||
|
class PluginSignatureVerifier(private val publicKey: PublicKey) {
|
||||||
|
|
||||||
|
fun verifyPluginSignature(pluginPath: Path): PluginTrustLevel {
|
||||||
|
val jarFile = JarFile(pluginPath.toFile(), true)
|
||||||
|
val entries = jarFile.entries()
|
||||||
|
|
||||||
|
while (entries.hasMoreElements()) {
|
||||||
|
val entry = entries.nextElement()
|
||||||
|
if (entry.isDirectory || entry.name.startsWith("META-INF/")) continue
|
||||||
|
|
||||||
|
if (!verifyEntryDigest(jarFile, entry)) return PluginTrustLevel.UNTRUSTED
|
||||||
|
|
||||||
|
val codeSigners = entry.codeSigners
|
||||||
|
if (codeSigners.isNullOrEmpty()) return PluginTrustLevel.THIRD_PARTY
|
||||||
|
|
||||||
|
val signersTrustLevel = verifyCodeSigners(codeSigners)
|
||||||
|
if (signersTrustLevel != PluginTrustLevel.OFFICIAL) return signersTrustLevel
|
||||||
|
}
|
||||||
|
return PluginTrustLevel.OFFICIAL
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the full entry stream to trigger JAR signature/digest verification.
|
||||||
|
* Returns `true` if the entry is valid, `false` if signature verification failed.
|
||||||
|
*/
|
||||||
|
fun verifyEntryDigest(jarFile: JarFile, entry: JarEntry): Boolean {
|
||||||
|
return try {
|
||||||
|
val buffer = ByteArray(8192)
|
||||||
|
val entryInputStream: InputStream = jarFile.getInputStream(entry)
|
||||||
|
while (entryInputStream.read(buffer, 0, buffer.size) != -1) {
|
||||||
|
// Reading to trigger SecurityException on digest mismatch
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} catch (_: SecurityException) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies that all code signers' certificates are signed with the expected public key.
|
||||||
|
*/
|
||||||
|
fun verifyCodeSigners(codeSigners: Array<CodeSigner>): PluginTrustLevel {
|
||||||
|
for (codeSigner in codeSigners) {
|
||||||
|
val certs = codeSigner.signerCertPath.certificates.toList()
|
||||||
|
val trustLevel = verifyCertificates(certs)
|
||||||
|
if (trustLevel != PluginTrustLevel.OFFICIAL) return trustLevel
|
||||||
|
}
|
||||||
|
return PluginTrustLevel.OFFICIAL
|
||||||
|
}
|
||||||
|
|
||||||
|
fun verifyCertificates(certs: List<Certificate>): PluginTrustLevel {
|
||||||
|
for (cert in certs) {
|
||||||
|
if (cert !is X509Certificate) continue
|
||||||
|
try {
|
||||||
|
cert.verify(publicKey)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
return PluginTrustLevel.UNTRUSTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return PluginTrustLevel.OFFICIAL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package org.gameyfin.app.core.plugins.management
|
||||||
|
|
||||||
|
import org.springframework.boot.health.contributor.Health
|
||||||
|
import org.springframework.boot.health.contributor.HealthIndicator
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
|
@Component("pluginsLoaded")
|
||||||
|
class PluginsLoadedIndicator : HealthIndicator {
|
||||||
|
|
||||||
|
private val ready = AtomicBoolean(false)
|
||||||
|
|
||||||
|
fun markReady() {
|
||||||
|
ready.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun health(): Health {
|
||||||
|
return if (ready.get()) {
|
||||||
|
Health.up().withDetail("plugins", "loaded").build()
|
||||||
|
} else {
|
||||||
|
Health.outOfService().withDetail("plugins", "loading").build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,6 +4,10 @@ import java.util.*
|
|||||||
import javax.crypto.Cipher
|
import javax.crypto.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))
|
||||||
|
|||||||
+8
-12
@@ -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
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+14
-7
@@ -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);
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
-- Flyway Migration: V2.4.0.2
|
||||||
|
-- Purpose: Make PLUGIN_CONFIG.value column unbounded to avoid length errors when storing large values.
|
||||||
|
-- Context: Previously defined as CHARACTER VARYING(255); H2 raised 22001 (value too long).
|
||||||
|
-- Strategy: Alter column type to CLOB (unlimited length in H2). This matches other large text usages (e.g., COMMENT, SUMMARY) which use CHARACTER LARGE OBJECT.
|
||||||
|
|
||||||
|
ALTER TABLE PLUGIN_CONFIG
|
||||||
|
ALTER COLUMN "value" CLOB;
|
||||||
@@ -330,7 +330,7 @@ class ConfigServiceTest {
|
|||||||
result.forEach { entry ->
|
result.forEach { entry ->
|
||||||
assertNotNull(entry.key)
|
assertNotNull(entry.key)
|
||||||
assertNotNull(entry.type)
|
assertNotNull(entry.type)
|
||||||
assertNotNull(entry.description)
|
assertNotNull(entry.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user