diff --git a/.github/workflows/docker-fix.yml b/.github/workflows/docker-fix.yml index e2c6a3a..51e411c 100644 --- a/.github/workflows/docker-fix.yml +++ b/.github/workflows/docker-fix.yml @@ -14,7 +14,7 @@ jobs: checks: write steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Set up JDK 21 uses: actions/setup-java@v5 @@ -52,7 +52,7 @@ jobs: variant: [ alpine, ubuntu ] steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Download build outputs uses: actions/download-artifact@v5 diff --git a/.github/workflows/docker-preview.yml b/.github/workflows/docker-preview.yml index 88cab9b..1efcc44 100644 --- a/.github/workflows/docker-preview.yml +++ b/.github/workflows/docker-preview.yml @@ -16,7 +16,7 @@ jobs: version: ${{ steps.extract_version.outputs.version }} steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 @@ -82,7 +82,7 @@ jobs: variant: [ alpine, ubuntu ] steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index acc4545..d488247 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,7 @@ jobs: release_version: ${{ steps.get_version.outputs.release_version }} steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 @@ -66,7 +66,7 @@ jobs: checks: write steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 @@ -113,7 +113,7 @@ jobs: variant: [ alpine, ubuntu ] steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 @@ -158,7 +158,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 @@ -189,7 +189,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 51cf756..d8a5574 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,5 @@ +import org.apache.tools.ant.filters.ReplaceTokens + group = "org.gameyfin" val appMainClass = "org.gameyfin.app.GameyfinApplicationKt" @@ -31,6 +33,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-aop") implementation("org.springframework.cloud:spring-cloud-starter") implementation("jakarta.validation:jakarta.validation-api:3.1.0") @@ -70,8 +73,9 @@ dependencies { implementation(project(":plugin-api")) // Utils - implementation("org.apache.tika:tika-core:3.1.0") + implementation("org.apache.tika:tika-core:3.2.3") implementation("me.xdrop:fuzzywuzzy:1.4.0") + implementation("com.vanniktech:blurhash:0.3.0") // Development developmentOnly("org.springframework.boot:spring-boot-devtools") @@ -98,4 +102,12 @@ dependencyManagement { tasks.withType { useJUnitPlatform() -} \ No newline at end of file +} + +tasks.named("processResources") { + val projectVersion = rootProject.version.toString() + filesMatching("application.yml") { + filter("tokens" to mapOf("project.version" to projectVersion)) + } +} + diff --git a/app/package-lock.json b/app/package-lock.json index 550a230..680f7b2 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "gameyfin", - "version": "2.2.0-preview", + "version": "2.3.0-preview", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gameyfin", - "version": "2.2.0-preview", + "version": "2.3.0-preview", "dependencies": { "@heroui/react": "^2.8.5", "@phosphor-icons/react": "^2.1.7", @@ -31,6 +31,7 @@ "@vaadin/vaadin-material-styles": "24.9.4", "@vaadin/vaadin-themable-mixin": "24.9.4", "@vaadin/vaadin-usage-statistics": "2.1.3", + "blurhash": "^2.0.5", "classnames": "^2.5.1", "construct-style-sheets-polyfill": "3.1.0", "date-fns": "2.29.3", @@ -54,10 +55,10 @@ "react-player": "^2.16.0", "react-realtime-chart": "^0.8.1", "react-router": "7.6.3", + "react-window": "^2.2.3", "remark-breaks": "^4.0.0", "swiper": "^11.2.6", "valtio": "^2.1.5", - "valtio-reactive": "^0.1.2", "yup": "^1.6.1" }, "devDependencies": { @@ -69,6 +70,7 @@ "@types/node": "^22.4.0", "@types/react": "19.1.17", "@types/react-dom": "19.1.11", + "@types/react-window": "^1.8.8", "@vaadin/hilla-generator-cli": "24.9.4", "@vaadin/hilla-generator-core": "24.9.4", "@vaadin/hilla-generator-plugin-backbone": "24.9.4", @@ -200,6 +202,7 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -3364,6 +3367,7 @@ "resolved": "https://registry.npmjs.org/@heroui/system/-/system-2.4.23.tgz", "integrity": "sha512-kgYvfkIOQKM6CCBIlNSE2tXMtNrS1mvEUbvwnaU3pEYbMlceBtwA5v7SlpaJy/5dqKcTbfmVMUCmXnY/Kw4vaQ==", "license": "MIT", + "peer": true, "dependencies": { "@heroui/react-utils": "2.1.14", "@heroui/system-rsc": "2.3.20", @@ -3449,6 +3453,7 @@ "resolved": "https://registry.npmjs.org/@heroui/theme/-/theme-2.4.23.tgz", "integrity": "sha512-5hoaRWG+/d/t06p7Pfhz70DUP0Uggjids7/z2Ytgup4A8KAOvDIXxvHUDlk6rRHKiN1wDMNA5H+EWsSXB/m03Q==", "license": "MIT", + "peer": true, "dependencies": { "@heroui/shared-utils": "2.1.12", "clsx": "^1.2.1", @@ -3997,7 +4002,8 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/@open-wc/dedupe-mixin/-/dedupe-mixin-1.4.0.tgz", "integrity": "sha512-Sj7gKl1TLcDbF7B6KUhtvr+1UCxdhMbNY5KxdU5IfMFWqL8oy1ZeAcCANjoB1TL0AJTcPmcCFsCbHf8X2jGDUA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@phosphor-icons/react": { "version": "2.1.10", @@ -4017,6 +4023,7 @@ "resolved": "https://registry.npmjs.org/@polymer/polymer/-/polymer-3.5.2.tgz", "integrity": "sha512-fWwImY/UH4bb2534DVSaX+Azs2yKg8slkMBHOyGeU2kKx7Xmxp6Lee0jP8p6B3d7c1gFUPB2Z976dTUtX81pQA==", "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@webcomponents/shadycss": "^1.9.1" } @@ -6946,6 +6953,7 @@ "integrity": "sha512-pAZSHMiagDR7cARo/cch1f3rXy0AEXwsVsVH09FcyeJVAzCnGgmYis7P3JidtTUjyadhTeSo8TgRPswstghDaw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -6955,6 +6963,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz", "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -6965,10 +6974,21 @@ "integrity": "sha512-3BKc/yGdNTYQVVw4idqHtSOcFsgGuBbMveKCOgF8wQ5QtrYOc3jDIlzg3jef04zcXFIHLelyGlj0T+BJ8+KN+w==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } }, + "node_modules/@types/react-window": { + "version": "1.8.8", + "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz", + "integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -7005,6 +7025,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/a11y-base/-/a11y-base-24.9.4.tgz", "integrity": "sha512-y8Rrq84MOyCYJ5rbzWtm7rqP3UNX5r5aspKFVYDNATLVcqFMFqUopz5Tn+YMRMb0MJ3K3GU3vy+xWVcF3WyzAg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -7017,6 +7038,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/accordion/-/accordion-24.9.4.tgz", "integrity": "sha512-AkeNGWA7TOVM8hrh0JzMNdWrRScIS9HN4t4UJ8DFSQVsYwDkixZqDOCkoCGoNJ+iRbKzrTcA2a9rHIuk+qjh+A==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -7034,6 +7056,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/app-layout/-/app-layout-24.9.4.tgz", "integrity": "sha512-VhFOyQSLV5ALm9C9YNmRw7TrsPZ3R/lwIYhowoYIDHE4o7NbW6EdbgdUYkEBa3EPI2oC5IYZzLkXz+kLYgvCIg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -7051,6 +7074,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/avatar/-/avatar-24.9.4.tgz", "integrity": "sha512-/CzAHhwjGC8fXpsBlm6oUpj1PKZh7a0Nz451R1XGkNF4We2RK76yEwTkZSEDlJR+6Fx1mDH+nnXU1wplGUVynw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -7068,6 +7092,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/avatar-group/-/avatar-group-24.9.4.tgz", "integrity": "sha512-ao2J8wsubP/HOV18ftl5a9C5gDQ41NYAskQOc+B2Mjv9K+Gt/Nsp+I7vl/vli0gTWYVCbpTAm8oXVSqaOYyRWQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -7423,6 +7448,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/button/-/button-24.9.4.tgz", "integrity": "sha512-Wg+gnrQ3LT9WpJGT91WCXFpX9HOpReyoq77K2jjsnzx2ZN6d3iHmPImVIdA658fLE6U3RCyRazQymoWQ01VEuA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -7439,6 +7465,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/card/-/card-24.9.4.tgz", "integrity": "sha512-DzHGUjA9ESkAC6oJrAUx28OunwSs33lwNWQzq0Xh64mWafUWMjc0k6QvGc45rdXOY/MZXkG21SWqahAVi7qWVA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@vaadin/component-base": "~24.9.4", "@vaadin/vaadin-lumo-styles": "~24.9.4", @@ -7452,6 +7479,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/checkbox/-/checkbox-24.9.4.tgz", "integrity": "sha512-B381QSKkmi0bpnmw+BV/oMGrEAkLVHxrL75mrrl0jEzA9nzc+vTsb72cONnbp6XCO17hb+sVQ6gdAxBrSsr59A==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -7469,6 +7497,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/checkbox-group/-/checkbox-group-24.9.4.tgz", "integrity": "sha512-LpqbUpzWLlC3lXGRNrbMPAb6rOA7sOQYPrfonw1aVM3u4IiS+T9ffKCQHeTrxeV+S0Ddmiu0rCA2q7j4EcMEPw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -7487,6 +7516,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/combo-box/-/combo-box-24.9.4.tgz", "integrity": "sha512-DrAxKvu6rF/o2t1rP5zEWEj2A4R0OqkbkdaqbFOJDMdVSTHYOTHzmu/M38jRB9owSsTGJW9q9L2ATJcio2NECA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -7520,6 +7550,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/component-base/-/component-base-24.9.4.tgz", "integrity": "sha512-bS28kpICJodSHTEJQwQexNGkhdXbPtPhmUJGWk6gx+9Dmuzeq5C/xlnkYpuRbGnvR4lGUL2SdK6ZlhcPzKXncQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -7533,6 +7564,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/confirm-dialog/-/confirm-dialog-24.9.4.tgz", "integrity": "sha512-RTkr9k8HaYYnV4a/DoPFuDWSO3+LjubM8+FhOio8RgGyMWyyEXIlhdFqsCwV7EmyEZ3rje8Mk5zpUSabBN9fVQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -7551,6 +7583,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/context-menu/-/context-menu-24.9.4.tgz", "integrity": "sha512-/ESoTcMP6EGpyVYoueEKwCSspbnOLlMFYChLiPreCTPJddvPAX1Wvip9VA9JHzEPqYlJ8f9sf0fCPCFYF+WNfQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -7571,6 +7604,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/custom-field/-/custom-field-24.9.4.tgz", "integrity": "sha512-hIcs5V7u6HnlL4t7/JIUTrCRTI0bcupH6Yo5gyO+8g9TojVLH9XJvg148P4pRD/aoSEaC7N2GjrB86051hdqhg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -7588,6 +7622,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/date-picker/-/date-picker-24.9.4.tgz", "integrity": "sha512-CiPt0KKPfmQvdijzP+J8nZmv/Gg1o2segUeeSovwX0y2zi9CDGPhBOEh7X+ZqoouRBc/XzaCaziZv5U1asExqg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.2.0", @@ -7608,6 +7643,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/date-time-picker/-/date-time-picker-24.9.4.tgz", "integrity": "sha512-GQsxm0h5vjam1KrM2g6AN7/fb9ZV3JiL8asW4t5FczvefjRUapSZCyqQem8z/AAqea/Nifyy/67tH8PwbEEISg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -7628,6 +7664,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/details/-/details-24.9.4.tgz", "integrity": "sha512-bEQsVGBCtTc49g9jpVUCHw/JfkZxHlHZ6N9y2CsYb3cCfpOoXPnhQsDPBmOlnCFSGOwwgQC2F2gi69P3rdZR0Q==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -7645,6 +7682,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/dialog/-/dialog-24.9.4.tgz", "integrity": "sha512-p+MXYfn1/OliqElQKy31/Wopo133HH5b/JoYt6dd2+Kn2YYXUR1LzDBxsMXumiEdkoowiUary7xFNvaSteStlw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -7662,6 +7700,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/email-field/-/email-field-24.9.4.tgz", "integrity": "sha512-GWf20H4j8Mad2+cm/2pViAKllW3kIweNjoIR64rqUvooSKrEHzoAmi+iKuCKTkaW3ljub2BVTQ3WCqYkAiA4jA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@polymer/polymer": "^3.0.0", "@vaadin/component-base": "~24.9.4", @@ -7677,6 +7716,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/field-base/-/field-base-24.9.4.tgz", "integrity": "sha512-ANUMWe08i0BTv9DoMcStdtxy8gKnvt7ERCLSINrIPAI19tAhrooifHXYxr6ig7ebK4gmjIc1TKqLeelWjesBhA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -7690,6 +7730,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/field-highlighter/-/field-highlighter-24.9.4.tgz", "integrity": "sha512-8hFSAjXyKUR5Y+fSytJTp9wiKxDAjXHoUd1TxsSUvOMmG7p9tYQ25xT9tnFCR3O9bFUdb0TPF8Uo2+6oFUjYNA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@polymer/polymer": "^3.0.0", "@vaadin/a11y-base": "~24.9.4", @@ -7706,6 +7747,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/form-layout/-/form-layout-24.9.4.tgz", "integrity": "sha512-o0HO2hrOiXT0Pdqf/DxqsckRTtoIyUjDZX+RC+A4h9KxyUKtIDGHNmJEFfZq9p9iZeJNCz/Yejvq8tb8BtwfwA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -7722,6 +7764,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/grid/-/grid-24.9.4.tgz", "integrity": "sha512-Dxr+hB4cjr3AKGE7bhn7V5TTjIU2aAvsDHx2canxUj823hTrJ6OuVY7auuDyxEwZCduTUVK4dSlY6C0GLSGqwA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -8133,6 +8176,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/horizontal-layout/-/horizontal-layout-24.9.4.tgz", "integrity": "sha512-DHQZTcLnOCMMl9kJn2DXOTFhY2N3m6eVS8hx6ehnFE1ySNNxk7M2SgCAZRiAmAiQGUzaCWIlPdeo6OBOdRZiFg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@polymer/polymer": "^3.0.0", "@vaadin/component-base": "~24.9.4", @@ -8147,6 +8191,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/icon/-/icon-24.9.4.tgz", "integrity": "sha512-0JTSsi/U3z2I/gDZisLqzwJSHYeem3hQcMhNiul9gn1iWVnrfzehpZRAG9L3UhlAKssvh+ttYRjHyeiXR/Rqhg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -8161,6 +8206,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/icons/-/icons-24.9.4.tgz", "integrity": "sha512-aps89oGV+SrDwGDlr6/cO2kt+4gaz0oU4nnLBhJE9p7ZNDSjD6O91mLednKlK/RbpdMDLZ6S9gFgtLrTnrE7Lw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@polymer/polymer": "^3.0.0", "@vaadin/icon": "~24.9.4" @@ -8171,6 +8217,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/input-container/-/input-container-24.9.4.tgz", "integrity": "sha512-uS+gVgc4NPAR3YcXvJZGbeN48G1tviQZ1n7ap3CAHxai5iCxBYUOwao3FLRkWtDG5KHrEKLFtAw6YCquM+P3NA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@polymer/polymer": "^3.0.0", "@vaadin/component-base": "~24.9.4", @@ -8185,6 +8232,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/integer-field/-/integer-field-24.9.4.tgz", "integrity": "sha512-a1iNJyIp6fGjIguHkZ6aV6e9PiEhZVCtSNItkoqmXCgmtNuhlpp0TwWniq5SoFSYFM/uExyv+JAXkxk7BbDuIw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@polymer/polymer": "^3.0.0", "@vaadin/component-base": "~24.9.4", @@ -8198,6 +8246,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/item/-/item-24.9.4.tgz", "integrity": "sha512-jSOA3SUrGVTYuPD/KqoL5lxVDoxwde5hzC+nDv6BtDvwNmnVMFUB7DKSKSuiPrult2DuUjqNGEJrTXTRhzodFg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -8214,6 +8263,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/list-box/-/list-box-24.9.4.tgz", "integrity": "sha512-idi7Cag09/4snCvVdmrfnB5iPnpvDXzOT6I3SBkFY3mx7l8kceV5/Bk2/1ZqYgWHs52UGwnAeXpT5AGeseGdqQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -8231,6 +8281,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/lit-renderer/-/lit-renderer-24.9.4.tgz", "integrity": "sha512-fXoM9XS0nWqUTO0GQVe5m2Oaa1Ae5Rhc1HwzIiX2nTiRuFlX3tfHPeaZJpcG+MuQLn3qtc6rAzHiGl1Y+Tovkw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "lit": "^3.0.0" } @@ -8240,6 +8291,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/login/-/login-24.9.4.tgz", "integrity": "sha512-IVNUZl7Gm50Jm2KNgV7EznL2blGroaYoZDj4vrnrd2mAPMgGfKqEEEMvQn1RNeFTguBblWMAABKXJ65q2dFzHg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -8259,6 +8311,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/markdown/-/markdown-24.9.4.tgz", "integrity": "sha512-afhIEWc6RbbCoNy5WkP8U0GxwMrKpghHC/k0JEntJ6DrcT1zAhrhasLzHRL7GcXTDbTtbQUKGXlAhjebwPXTvA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@vaadin/component-base": "~24.9.4", @@ -8275,6 +8328,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/master-detail-layout/-/master-detail-layout-24.9.4.tgz", "integrity": "sha512-XKL9ucABTdJDvHgAJRs64Wzot8NgPj/RpnuISoozcvHK6NtQTje+TjJgVehCcJnyefbabed00UCT/YsCOTnA3Q==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@vaadin/a11y-base": "~24.9.4", "@vaadin/component-base": "~24.9.4", @@ -8289,6 +8343,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/menu-bar/-/menu-bar-24.9.4.tgz", "integrity": "sha512-Fkto0kNLkuEfqaVTMm3VtRbUvawOVMsrSbyAe3zRSO72uXQ32Ir2OFHGWy3UD+xytAGz3yj5pqeESNAsoTIRsA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -8310,6 +8365,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/message-input/-/message-input-24.9.4.tgz", "integrity": "sha512-YZWmZgVBxZLasGL9ni0TTqY3UilcjrpKyJvBnziUq3W2NiXUvnQOGskXwpVkD5sQi2f/D5d18IfCaW2FSOR7Rg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -8327,6 +8383,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/message-list/-/message-list-24.9.4.tgz", "integrity": "sha512-8uY6/Z7rBYzeLTe6ASB8Ub7dmIWiM6hzcwL1U2k/FXGZYyQ1MK3BGlqbH/QX0B0rR+3+F//ORZmh8efg5gTH9A==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -8345,6 +8402,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/multi-select-combo-box/-/multi-select-combo-box-24.9.4.tgz", "integrity": "sha512-fH0WKt93ucm6cSTwuIAVH4e/Qm1oLspuO0em3OfgrEoZN7b5gPAAaqI/gyUDs5+PTRyqJ7YyIu1m5VI8fLJkmg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -8367,6 +8425,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/notification/-/notification-24.9.4.tgz", "integrity": "sha512-ZRqPo0PZo/VqExs0DKQ3kGjUEvUgm2DPsHAPI0LzQXv1OS7Roet9T2tL7y9//CK3JGlzfTmhqlH7XxUR/DvbCw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -8384,6 +8443,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/number-field/-/number-field-24.9.4.tgz", "integrity": "sha512-Z9kmiJTZvoWPWt7TQJOLu8jcI4J0F+dHhjBoeTfMtLkrMTHiKiWj1oKbamtsS1+6DfOMkcrsY2C/cfsPFlA45g==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -8402,6 +8462,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/overlay/-/overlay-24.9.4.tgz", "integrity": "sha512-0oYDV//7rCxzt2tXPVyJYqUtFn4EiHXrC1lDmbUzWtbGIQZwP3KALiYSk3ylP+bqxv2KTA0m6FjObj8wTj5s8g==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -8418,6 +8479,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/password-field/-/password-field-24.9.4.tgz", "integrity": "sha512-LfVsS3lSGqVyNbTJ5dVp8qe5pHYF1//8wg7IXw/AZqnqGbcqkI8gezUHaNnW0yFY1Wx0ZVmee5akZisB4z0H2g==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -8437,6 +8499,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/polymer-legacy-adapter/-/polymer-legacy-adapter-24.9.4.tgz", "integrity": "sha512-5j38OaD+M44c6qBnrrt84ZiTTDbjHW5/8RsJBkqmvuZs5jQ9KXBqLqCV1WCZKAWr/aGcPS2NPBTpuFLNn0czRw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@polymer/polymer": "^3.0.0", "@vaadin/vaadin-themable-mixin": "~24.9.4", @@ -8448,6 +8511,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/popover/-/popover-24.9.4.tgz", "integrity": "sha512-v5iZAukyG4V1TV6OTJ4feo8yuLn3fyu7QYEvhIQFhTp5THrlmtpjlx+FXIU8JWm/uoJ0RjRUeJXUkp+Zg6sdlA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@vaadin/a11y-base": "~24.9.4", @@ -8465,6 +8529,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/progress-bar/-/progress-bar-24.9.4.tgz", "integrity": "sha512-RBxOG5D0lsbUl0WreFDrKCVrtZSHEwrjfr7Wx+7QMI2c26TpElSF2yffF7Z+IRqqtF0Q71MbtQyEx23ej0AQ1A==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -8480,6 +8545,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/radio-group/-/radio-group-24.9.4.tgz", "integrity": "sha512-qQ4VlIEsq4p5SNaSg8EglHAmmLnVpz53cMp91qb7fCLPD0zlaoC3wvQKiISpSDnYKlskUf7r6yyYjDi5yQL9vQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -8581,6 +8647,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/scroller/-/scroller-24.9.4.tgz", "integrity": "sha512-yOfKlwbItnzD9XR51/WThvjyFUIBVeIKu75FHpmlmUnjjAnwj4kyw0AjnUu9itGX+R5D83ZidondbcWtqhFexQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -8597,6 +8664,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/select/-/select-24.9.4.tgz", "integrity": "sha512-Ii70lQGcys1alONgIKo2ufithPzFI4s0K5uDFFSH3e12NHFAZu8VxA8tYbWwLh1H5q209ib1MnfUuUevHghVUA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.2.0", @@ -8620,6 +8688,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/side-nav/-/side-nav-24.9.4.tgz", "integrity": "sha512-pLAcoZzzMA+vQHuqo6jy8N4G0bz6qFtIM+BpJ9kcHYiulUg/Y7NPa4K2YsHF5gChZwE9cbxGMc0lPoN6p8QeKg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@vaadin/a11y-base": "~24.9.4", @@ -8635,6 +8704,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/split-layout/-/split-layout-24.9.4.tgz", "integrity": "sha512-oTL0IT7Cm6flCOtMg3odZo+qEczSmL2OS61Mf67BIAYvOG08OMNZElKJ57tVe50NL5DIGMmMtcfyC5Mni/mWuw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -8650,6 +8720,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/tabs/-/tabs-24.9.4.tgz", "integrity": "sha512-YfFZ+SV+2HtHIvdhHf0jTFc0spUjt3gQK8ckdw12DrOR2OPnShx1BFEDkor0NQqVLmLgvksbPxYJPhVf8wvj1w==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -8667,6 +8738,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/tabsheet/-/tabsheet-24.9.4.tgz", "integrity": "sha512-Yy37yU2p2zazxqVx2rj2ljjrrZVq/jqu01HhgwCrnA7kpi21p9uVV8EvuAg17qU9uKFc8LQseNRKMtis9K7TcA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -8684,6 +8756,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/text-area/-/text-area-24.9.4.tgz", "integrity": "sha512-pihekoVk4wIeoqykvNyb8S81PS8NFHLKRZqcnsDgtMyIEg7UlRPSIgUZEbFAGzrU9WnY+NJehzhVnPFKBuePxw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -8702,6 +8775,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/text-field/-/text-field-24.9.4.tgz", "integrity": "sha512-1Yr8Hn5o4r3mBA0jXgRiMiAlfIOe2H/3tkWHj8KBdxH2PfwDGjzXvf5995g6kO4/b2E2sWNtMTQYufEr1HLlWw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -8720,6 +8794,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/time-picker/-/time-picker-24.9.4.tgz", "integrity": "sha512-4agGlkzbEfCXPtwiTNrG22r8ayA6R1U/jq0HeaXGFb6toLDZZyPsxKlZfA0FEvOF3EmyGHIKuEG6sR5X+GyCTg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -8740,6 +8815,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/tooltip/-/tooltip-24.9.4.tgz", "integrity": "sha512-p+zGTb5Q0HsCjbF8rbNU5SoadH9vC1Yz1qrvsgji61cDDcQ8VRQYbYjq85YHVQ9l0GHIhIJTIkC8MvqjPeGCtg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -8758,6 +8834,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/upload/-/upload-24.9.4.tgz", "integrity": "sha512-71yoqOL3p5mbuBZvMh9qzZLpEbbjXRUd+Q7kQsNLHaZHC+ca2usFvXRk9XK8GmFE9in9/+0fsMw9a33D1OF3Vw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -8775,13 +8852,15 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@vaadin/vaadin-development-mode-detector/-/vaadin-development-mode-detector-2.0.7.tgz", "integrity": "sha512-9FhVhr0ynSR3X2ao+vaIEttcNU5XfzCbxtmYOV8uIRnUCtNgbvMOIcyGBvntsX9I5kvIP2dV3cFAOG9SILJzEA==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/@vaadin/vaadin-lumo-styles": { "version": "24.9.4", "resolved": "https://registry.npmjs.org/@vaadin/vaadin-lumo-styles/-/vaadin-lumo-styles-24.9.4.tgz", "integrity": "sha512-g6qx0Fp9VOvjKUnYyxgE42Jl5c+LdiA9T6KxPCzOF8EiaHKsCLLKecnvwmV6SelQ45AbVLg4YiROFJ27k38d/w==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@polymer/polymer": "^3.0.0", "@vaadin/component-base": "~24.9.4", @@ -8805,6 +8884,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/vaadin-themable-mixin/-/vaadin-themable-mixin-24.9.4.tgz", "integrity": "sha512-XfxSq/pgVDAbGXKLJuDqR5uFT0zRac6UfY8Q+aNzv2R53PAm3vrt/S6XQ8VHDMCV/+8mVT2dZSnl22pafSEDcw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "lit": "^3.0.0", @@ -8817,6 +8897,7 @@ "integrity": "sha512-8r4TNknD7OJQADe3VygeofFR7UNAXZ2/jjBFP5dgI8+2uMfnuGYgbuHivasKr9WSQ64sPej6m8rDoM1uSllXjQ==", "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@vaadin/vaadin-development-mode-detector": "^2.0.0" }, @@ -8829,6 +8910,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/vertical-layout/-/vertical-layout-24.9.4.tgz", "integrity": "sha512-kiVhwIGUXSA+fi6klIzLXauVF4TfYu07ME2BkwnC0I4ymQqN4SZiAemUP+0/Gp5XRJ23H4q5cSTYws5u8C4cwA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@polymer/polymer": "^3.0.0", "@vaadin/component-base": "~24.9.4", @@ -8843,6 +8925,7 @@ "resolved": "https://registry.npmjs.org/@vaadin/virtual-list/-/virtual-list-24.9.4.tgz", "integrity": "sha512-Jc7puX8ln83CrVUQ2t3pK9B1crq+MJATEkKTwYZNZ1u4VfpwDWE3srN8ZTidIjUSOWWNXPHbBlnlAJSaOmyk/Q==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", @@ -8948,6 +9031,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -9201,6 +9285,12 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/blurhash": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/blurhash/-/blurhash-2.0.5.tgz", + "integrity": "sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -9238,6 +9328,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -9490,7 +9581,8 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/client-only": { "version": "0.0.1", @@ -10087,6 +10179,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -10390,6 +10483,7 @@ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -10900,6 +10994,7 @@ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.22.tgz", "integrity": "sha512-ZgGvdxXCw55ZYvhoZChTlG6pUuehecgvEAJz0BHoC5pQKW1EC5xf1Mul1ej5+ai+pVY0pylyFfdl45qnM1/GsA==", "license": "MIT", + "peer": true, "dependencies": { "motion-dom": "^12.23.21", "motion-utils": "^12.23.6", @@ -12385,6 +12480,7 @@ "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.1.tgz", "integrity": "sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==", "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", @@ -12489,6 +12585,7 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -13444,7 +13541,8 @@ "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/own-keys": { "version": "1.0.1", @@ -13686,6 +13784,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -13789,6 +13888,7 @@ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -13871,6 +13971,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -14000,6 +14101,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -14096,6 +14198,7 @@ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.3.tgz", "integrity": "sha512-zf45LZp5skDC6I3jDLXQUu0u26jtuP4lEGbc7BbdyxenBN1vJSTA18czM2D+h5qyMBuMrD+9uB+mU37HIoKGRA==", "license": "MIT", + "peer": true, "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" @@ -14167,6 +14270,16 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/react-window": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-2.2.3.tgz", + "integrity": "sha512-gTRqQYC8ojbiXyd9duYFiSn2TJw0ROXCgYjenOvNKITWzK0m0eCvkUsEUM08xvydkMh7ncp+LE0uS3DeNGZxnQ==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -14416,6 +14529,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -15260,6 +15374,7 @@ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz", "integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/dcastil" @@ -15376,6 +15491,7 @@ "integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==", "devOptional": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -15625,6 +15741,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15955,15 +16072,6 @@ } } }, - "node_modules/valtio-reactive": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/valtio-reactive/-/valtio-reactive-0.1.2.tgz", - "integrity": "sha512-9Zv/tFiFWQWEBzfDikJgY9lkQ6CXf4T+Rsk08AKQMMZVmI5YvkAS7qFnRtwd1uVPNT/wsK+QcKiFHBvjCRohYQ==", - "license": "MIT", - "peerDependencies": { - "valtio": ">=2.0.0" - } - }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -15997,6 +16105,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -16474,6 +16583,7 @@ "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "rollup": "dist/bin/rollup" }, diff --git a/app/package.json b/app/package.json index 2fec6b0..62b901d 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "gameyfin", - "version": "2.2.1", + "version": "2.3.0-preview", "type": "module", "dependencies": { "@heroui/react": "^2.8.5", @@ -26,6 +26,7 @@ "@vaadin/vaadin-material-styles": "24.9.4", "@vaadin/vaadin-themable-mixin": "24.9.4", "@vaadin/vaadin-usage-statistics": "2.1.3", + "blurhash": "^2.0.5", "classnames": "^2.5.1", "construct-style-sheets-polyfill": "3.1.0", "date-fns": "2.29.3", @@ -49,10 +50,10 @@ "react-player": "^2.16.0", "react-realtime-chart": "^0.8.1", "react-router": "7.6.3", + "react-window": "^2.2.3", "remark-breaks": "^4.0.0", "swiper": "^11.2.6", "valtio": "^2.1.5", - "valtio-reactive": "^0.1.2", "yup": "^1.6.1" }, "devDependencies": { @@ -64,6 +65,7 @@ "@types/node": "^22.4.0", "@types/react": "19.1.17", "@types/react-dom": "19.1.11", + "@types/react-window": "^1.8.8", "@vaadin/hilla-generator-cli": "24.9.4", "@vaadin/hilla-generator-core": "24.9.4", "@vaadin/hilla-generator-plugin-backbone": "24.9.4", @@ -137,7 +139,6 @@ "react-markdown": "$react-markdown", "remark-breaks": "$remark-breaks", "valtio": "$valtio", - "valtio-reactive": "$valtio-reactive", "fzf": "$fzf", "@vaadin/router": "2.0.0", "@tailwindcss/vite": "$@tailwindcss/vite", @@ -202,7 +203,9 @@ "@vaadin/upload": "24.9.4", "@vaadin/vertical-layout": "24.9.4", "@vaadin/virtual-list": "24.9.4", - "react-realtime-chart": "$react-realtime-chart" + "react-realtime-chart": "$react-realtime-chart", + "react-window": "$react-window", + "blurhash": "$blurhash" }, "vaadin": { "dependencies": { @@ -264,6 +267,6 @@ "workbox-precaching": "7.3.0" }, "disableUsageStatistics": true, - "hash": "45fe1cd9320d2da603b811b433279d79b37370c9732e877490fc304807ef6163" + "hash": "d06c4b56ae3a7bc3c4356d3669fc1ed559d83e5285df4e8b3e99bff46869f939" } -} +} \ No newline at end of file diff --git a/app/src/main/bundles/prod.bundle b/app/src/main/bundles/prod.bundle index 4c464a9..d03b228 100644 Binary files a/app/src/main/bundles/prod.bundle and b/app/src/main/bundles/prod.bundle differ diff --git a/app/src/main/frontend/App.tsx b/app/src/main/frontend/App.tsx index 6a1d8e0..9248d2d 100644 --- a/app/src/main/frontend/App.tsx +++ b/app/src/main/frontend/App.tsx @@ -19,6 +19,7 @@ import {initializeGameRequestState} from "Frontend/state/GameRequestState"; import {initializePlatformState} from "Frontend/state/PlatformState"; import {initializeDownloadSessionState} from "Frontend/state/DownloadSessionState"; import {initializeUserState} from "Frontend/state/UserState"; +import {initializeCollectionState} from "Frontend/state/CollectionState"; export default function App() { client.middlewares = [ErrorHandlingMiddleware]; @@ -48,10 +49,11 @@ function ViewWithAuth() { if (auth.state.initializing || auth.state.loading) return; initializeLibraryState(); - initializeGameState(); + initializeCollectionState(); initializePlatformState(); initializeGameRequestState(); initializePluginState(); + initializeGameState(); if (isAdmin(auth)) { initializeScanState(); diff --git a/app/src/main/frontend/components/ProfileMenu.tsx b/app/src/main/frontend/components/ProfileMenu.tsx index a89fb20..517ab48 100644 --- a/app/src/main/frontend/components/ProfileMenu.tsx +++ b/app/src/main/frontend/components/ProfileMenu.tsx @@ -1,5 +1,5 @@ import {useAuth} from "Frontend/util/auth"; -import { GearFineIcon, QuestionIcon, SignOutIcon, UserIcon } from "@phosphor-icons/react"; +import {GearFineIcon, QuestionIcon, SignOutIcon, UserIcon} from "@phosphor-icons/react"; import {Dropdown, DropdownItem, DropdownMenu, DropdownTrigger} from "@heroui/react"; import {useNavigate} from "react-router"; import Avatar from "Frontend/components/general/Avatar"; @@ -19,7 +19,7 @@ export default function ProfileMenu() { { label: "Administration", icon: , - onClick: () => navigate("/administration/libraries"), + onClick: () => navigate("/administration/games"), showIf: isAdmin(auth) }, { diff --git a/app/src/main/frontend/components/administration/DownloadManagement.tsx b/app/src/main/frontend/components/administration/DownloadManagement.tsx index 990b1f0..01443e4 100644 --- a/app/src/main/frontend/components/administration/DownloadManagement.tsx +++ b/app/src/main/frontend/components/administration/DownloadManagement.tsx @@ -12,7 +12,7 @@ import {DownloadSessionCard} from "Frontend/components/general/cards/DownloadSes import {humanFileSize} from "Frontend/util/utils"; function DownloadManagementLayout({getConfig, formik}: any) { - const sessions = useSnapshot(downloadSessionState).all as SessionStatsDto[]; + const sessions = useSnapshot(downloadSessionState).all; const [lastDaySum, setLastDaySum] = React.useState(0); React.useEffect(() => { diff --git a/app/src/main/frontend/components/administration/GameManagement.tsx b/app/src/main/frontend/components/administration/GameManagement.tsx new file mode 100644 index 0000000..ac94a8b --- /dev/null +++ b/app/src/main/frontend/components/administration/GameManagement.tsx @@ -0,0 +1,149 @@ +import React from "react"; +import ConfigFormField from "Frontend/components/administration/ConfigFormField"; +import withConfigPage from "Frontend/components/administration/withConfigPage"; +import Section from "Frontend/components/general/Section"; +import * as Yup from 'yup'; +import "Frontend/util/yup-extensions"; +import {Button, Divider, Tooltip, useDisclosure} from "@heroui/react"; +import {ListNumbersIcon, PlusIcon} from "@phosphor-icons/react"; +import {LibraryOverviewCard} from "Frontend/components/general/cards/LibraryOverviewCard"; +import LibraryCreationModal from "Frontend/components/general/modals/LibraryCreationModal"; +import {useSnapshot} from "valtio/react"; +import {libraryState} from "Frontend/state/LibraryState"; +import LibraryPrioritiesModal from "Frontend/components/general/modals/LibraryPrioritiesModal"; +import {collectionState} from "Frontend/state/CollectionState"; +import {CollectionOverviewCard} from "Frontend/components/general/cards/CollectionOverviewCard"; +import CollectionCreationModal from "Frontend/components/general/modals/CollectionCreationModal"; +import CollectionPrioritiesModal from "Frontend/components/general/modals/CollectionPrioritiesModal"; + +function GameManagementLayout({getConfig, formik}: any) { + const libraries = useSnapshot(libraryState); + const libraryCreationModal = useDisclosure(); + const libraryOrderModal = useDisclosure(); + + const collections = useSnapshot(collectionState); + const collectionCreationModal = useDisclosure(); + const collectionOrderModal = useDisclosure(); + + return ( +
+
+

Libraries

+
+ + + + + + +
+
+ + {libraries.sorted.length > 0 ? + // Aspect ratio of cover = 12/17 -> 5 covers = 60/17 -> 353px * 100px +
+ {libraries.sorted.map((library) => + // @ts-ignore + + )} +
: +

No libraries found

+ } + +
+

Collections

+
+ + + + + + +
+
+ + {collections.sorted.length > 0 ? + // Aspect ratio of cover = 12/17 -> 5 covers = 60/17 -> 353px * 100px +
+ {collections.sorted.map((collection) => + // @ts-ignore + + )} +
: +

No collections found

+ } + +
+
+ + +
+ + +
+ + +
+ +
+
+ + +
+ + + + + + + + + +
+ ); +} + +const validationSchema = Yup.object({ + library: Yup.object({ + metadata: Yup.object({ + update: Yup.object({ + enabled: Yup.boolean(), + schedule: Yup.string().when("enabled", { + is: true, + then: (schema) => schema.cron() + }), + }) + }), + scan: Yup.object({ + "extract-title-using-regex": Yup.boolean(), + "title-extraction-regex": Yup.string().when("extract-title-using-regex", { + is: true, + then: (schema) => schema.trim().required("Title extraction regex is required when enabled") + }), + "title-match-min-ratio": Yup.number().min(1, "Must be between 1-100").max(100, "Must be between 1-100") + }) + }) +}); + +export const GameManagement = withConfigPage(GameManagementLayout, "Games", validationSchema); \ No newline at end of file diff --git a/app/src/main/frontend/components/administration/GameRequestManagement.tsx b/app/src/main/frontend/components/administration/GameRequestManagement.tsx index 5e87a32..324a196 100644 --- a/app/src/main/frontend/components/administration/GameRequestManagement.tsx +++ b/app/src/main/frontend/components/administration/GameRequestManagement.tsx @@ -20,7 +20,7 @@ function GameRequestManagementLayout({getConfig, formik}: any) {
+ isDisabled={!formik.values.security["allow-public-access"]}/>
diff --git a/app/src/main/frontend/components/administration/LibraryManagement.tsx b/app/src/main/frontend/components/administration/LibraryManagement.tsx deleted file mode 100644 index 9dfe8ee..0000000 --- a/app/src/main/frontend/components/administration/LibraryManagement.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import React from "react"; -import ConfigFormField from "Frontend/components/administration/ConfigFormField"; -import withConfigPage from "Frontend/components/administration/withConfigPage"; -import Section from "Frontend/components/general/Section"; -import * as Yup from 'yup'; -import "Frontend/util/yup-extensions"; -import {addToast, Button, Divider, Tooltip, useDisclosure} from "@heroui/react"; -import {PlusIcon} from "@phosphor-icons/react"; -import {LibraryEndpoint} from "Frontend/generated/endpoints"; -import {LibraryOverviewCard} from "Frontend/components/general/cards/LibraryOverviewCard"; -import LibraryCreationModal from "Frontend/components/general/modals/LibraryCreationModal"; -import LibraryUpdateDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryUpdateDto"; -import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto"; -import {useSnapshot} from "valtio/react"; -import {libraryState} from "Frontend/state/LibraryState"; - -function LibraryManagementLayout({getConfig, formik}: any) { - const libraryCreationModal = useDisclosure(); - const state = useSnapshot(libraryState); - - async function updateLibrary(library: LibraryUpdateDto) { - await LibraryEndpoint.updateLibrary(library); - addToast({ - title: "Library updated", - description: `Library ${library.name} has been updated.`, - color: "success" - }) - } - - async function removeLibrary(library: LibraryDto) { - await LibraryEndpoint.deleteLibrary(library.id); - addToast({ - title: "Library removed", - description: `Library ${library.name} has been removed.`, - color: "success" - }) - } - - return ( -
-
- - -
-
- - -
- - -
- - -
- -
-
- - -
- -
-

Libraries

- - - -
- - {state.sorted.length > 0 ? - // Aspect ratio of cover = 12/17 -> 5 covers = 60/17 -> 353px * 100px -
- {state.sorted.map((library) => - // @ts-ignore - - )} -
: -

No libraries found

- } - - -
- ); -} - -const validationSchema = Yup.object({ - library: Yup.object({ - metadata: Yup.object({ - update: Yup.object({ - enabled: Yup.boolean(), - schedule: Yup.string().when("enabled", { - is: true, - then: (schema) => schema.cron() - }), - }) - }), - scan: Yup.object({ - "extract-title-using-regex": Yup.boolean(), - "title-extraction-regex": Yup.string().when("extract-title-using-regex", { - is: true, - then: (schema) => schema.trim().required("Title extraction regex is required when enabled") - }), - "title-match-min-ratio": Yup.number().min(1, "Must be between 1-100").max(100, "Must be between 1-100") - }) - }) -}); - -export const LibraryManagement = withConfigPage(LibraryManagementLayout, "Library Management", validationSchema); \ No newline at end of file diff --git a/app/src/main/frontend/components/administration/MessageManagement.tsx b/app/src/main/frontend/components/administration/MessageManagement.tsx index dc1ac6a..b1cf667 100644 --- a/app/src/main/frontend/components/administration/MessageManagement.tsx +++ b/app/src/main/frontend/components/administration/MessageManagement.tsx @@ -121,13 +121,13 @@ function MessageManagementLayout({getConfig, formik}: any) { ); diff --git a/app/src/main/frontend/components/administration/PluginManagement.tsx b/app/src/main/frontend/components/administration/PluginManagement.tsx index 65c2a39..6bb180a 100644 --- a/app/src/main/frontend/components/administration/PluginManagement.tsx +++ b/app/src/main/frontend/components/administration/PluginManagement.tsx @@ -19,7 +19,7 @@ export default function PluginManagement() {
{pluginTypes.map(type => // @ts-ignore - + )}
diff --git a/app/src/main/frontend/components/administration/SsoManagement.tsx b/app/src/main/frontend/components/administration/SecurityManagement.tsx similarity index 66% rename from app/src/main/frontend/components/administration/SsoManagement.tsx rename to app/src/main/frontend/components/administration/SecurityManagement.tsx index 672f618..3edd725 100644 --- a/app/src/main/frontend/components/administration/SsoManagement.tsx +++ b/app/src/main/frontend/components/administration/SecurityManagement.tsx @@ -3,14 +3,14 @@ import withConfigPage from "Frontend/components/administration/withConfigPage"; import * as Yup from 'yup'; import ConfigFormField from "Frontend/components/administration/ConfigFormField"; import Section from "Frontend/components/general/Section"; -import {addToast, Button, Checkbox, CheckboxGroup, Tooltip} from "@heroui/react"; -import { MagicWandIcon, WarningIcon } from "@phosphor-icons/react"; +import {addToast, Button} from "@heroui/react"; +import {MagicWandIcon} from "@phosphor-icons/react"; -function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) { +function SecurityManagementLayout({getConfig, formik, setSaveMessage}: any) { useEffect(() => { if (formik.dirty) { - setSaveMessage("Gameyfin must be restarted for the changes to take effect"); + setSaveMessage("Gameyfin must be restarted for changes in the SSO configuration to take effect"); } else { setSaveMessage(null); } @@ -43,41 +43,26 @@ function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) { return (
-
+ +
+ + +
+
+
+

General configuration

+ + + + + +
-
- - -
-
- -
- - Automatically create new users after registration - - - - -
-
- {/*TODO: enable when the issues with unregistered SSO users are sorted - - - */} - -
- -
- - -
- -
+

SSO Provider Configuration

enabled ? schema.required("Client ID is required") : schema @@ -141,4 +125,4 @@ const validationSchema = Yup.object({ }) }); -export const SsoManagement = withConfigPage(SsoManagementLayout, "Single Sign-On", validationSchema); \ No newline at end of file +export const SecurityManagement = withConfigPage(SecurityManagementLayout, "Security", validationSchema); \ No newline at end of file diff --git a/app/src/main/frontend/components/administration/UserManagement.tsx b/app/src/main/frontend/components/administration/UserManagement.tsx index b82209e..f7f9012 100644 --- a/app/src/main/frontend/components/administration/UserManagement.tsx +++ b/app/src/main/frontend/components/administration/UserManagement.tsx @@ -4,8 +4,7 @@ import withConfigPage from "Frontend/components/administration/withConfigPage"; import Section from "Frontend/components/general/Section"; import {UserEndpoint} from "Frontend/generated/endpoints"; import {UserManagementCard} from "Frontend/components/general/cards/UserManagementCard"; -import {SmallInfoField} from "Frontend/components/general/SmallInfoField"; -import { InfoIcon, UserPlusIcon } from "@phosphor-icons/react"; +import {UserPlusIcon} from "@phosphor-icons/react"; import {Button, Divider, Tooltip, useDisclosure} from "@heroui/react"; import InviteUserModal from "Frontend/components/general/modals/InviteUserModal"; import ExtendedUserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/ExtendedUserInfoDto"; @@ -32,10 +31,6 @@ function UserManagementLayout({getConfig, formik}: any) {

Users

- {!getConfig("sso.oidc.auto-register-new-users").value && - - } -
+
{scans.length === 0 ?

No scans in progress or in history. @@ -59,12 +58,12 @@ export default function ScanProgressPopover() { {scans.map((scan, index) =>

+ className="flex flex-row gap-4 justify-between items-center text-default-500 mb-1">

{toTitleCase(scan.type)} scan for library  + href={`/administration/games/library/${scan.libraryId}`}> {libraries[scan.libraryId].name}

diff --git a/app/src/main/frontend/components/general/SearchBar.tsx b/app/src/main/frontend/components/general/SearchBar.tsx index 585e164..fdba76d 100644 --- a/app/src/main/frontend/components/general/SearchBar.tsx +++ b/app/src/main/frontend/components/general/SearchBar.tsx @@ -1,8 +1,7 @@ import {Autocomplete, AutocompleteItem} from "@heroui/react"; -import { CaretRightIcon, MagnifyingGlassIcon } from "@phosphor-icons/react"; +import {CaretRightIcon, MagnifyingGlassIcon} from "@phosphor-icons/react"; import {useSnapshot} from "valtio/react"; import {gameState} from "Frontend/state/GameState"; -import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto"; import {useNavigate} from "react-router"; import {GameCover} from "Frontend/components/general/covers/GameCover"; @@ -10,7 +9,7 @@ export default function SearchBar() { const navigate = useNavigate(); const state = useSnapshot(gameState); - const games = state.games as GameDto[]; + const games = state.games; return ([]); + + useEffect(() => { + if (!state.randomlyOrderedGamesByCollectionId) return; + setRandomGames(getRandomGames()); + }, [state]); + + function getRandomGames() { + if (!state.randomlyOrderedGamesByCollectionId[collection.id]) return []; + const games = state.randomlyOrderedGamesByCollectionId[collection.id] + .filter(game => game.cover?.id != null); + if (!games) return []; + return games.slice(0, MAX_COVER_COUNT); + } + + + return ( + +
+
+ + {randomGames.length > 0 && +
+ {randomGames.map((game) => ( + + ))} +
+ } +
+ +

{collection.name}

+ +
+ + + +
+
+ + {collection.stats && +
+

Games

+

Downloads

+

Platforms

+

{collection.stats.gamesCount}

+

{collection.stats.downloadCount}

+ 0 ? "All" : "None"}/> +
+ } +
+ ); +} \ No newline at end of file diff --git a/app/src/main/frontend/components/general/cards/LibraryOverviewCard.tsx b/app/src/main/frontend/components/general/cards/LibraryOverviewCard.tsx index 257c013..8bc7e91 100644 --- a/app/src/main/frontend/components/general/cards/LibraryOverviewCard.tsx +++ b/app/src/main/frontend/components/general/cards/LibraryOverviewCard.tsx @@ -1,5 +1,4 @@ import {Button, Card, Tooltip} from "@heroui/react"; -import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto"; import React from "react"; import {LibraryEndpoint} from "Frontend/generated/endpoints"; import {GameCover} from "Frontend/components/general/covers/GameCover"; @@ -23,7 +22,9 @@ export function LibraryOverviewCard({library}: LibraryOverviewCardProps) { const randomGames = getRandomGames(); function getRandomGames() { - const games = state.randomlyOrderedGamesByLibraryId[library.id] as GameDto[]; + if (!state.randomlyOrderedGamesByLibraryId[library.id]) return []; + const games = state.randomlyOrderedGamesByLibraryId[library.id] + .filter(game => game.cover?.id != null); if (!games) return []; return games.slice(0, MAX_COVER_COUNT); } @@ -40,7 +41,7 @@ export function LibraryOverviewCard({library}: LibraryOverviewCardProps) { {randomGames.length > 0 &&
{randomGames.map((game) => ( - + ))}
} diff --git a/app/src/main/frontend/components/general/cards/PluginManagementCard.tsx b/app/src/main/frontend/components/general/cards/PluginManagementCard.tsx index d15b718..9bdf715 100644 --- a/app/src/main/frontend/components/general/cards/PluginManagementCard.tsx +++ b/app/src/main/frontend/components/general/cards/PluginManagementCard.tsx @@ -1,5 +1,20 @@ import {Button, Card, Chip, Tooltip, useDisclosure} from "@heroui/react"; -import { CheckCircleIcon, IconContext, PauseCircleIcon, PlayCircleIcon, PowerIcon, QuestionIcon, QuestionMarkIcon, SealCheckIcon, SealQuestionIcon, SealWarningIcon, SlidersHorizontalIcon, StopCircleIcon, WarningCircleIcon, XCircleIcon } from "@phosphor-icons/react"; +import { + CheckCircleIcon, + IconContext, + PauseCircleIcon, + PlayCircleIcon, + PowerIcon, + QuestionIcon, + QuestionMarkIcon, + SealCheckIcon, + SealQuestionIcon, + SealWarningIcon, + SlidersHorizontalIcon, + StopCircleIcon, + WarningCircleIcon, + XCircleIcon +} from "@phosphor-icons/react"; import PluginState from "Frontend/generated/org/pf4j/PluginState"; import React, {ReactNode} from "react"; import PluginDetailsModal from "Frontend/components/general/modals/PluginDetailsModal"; @@ -105,11 +120,11 @@ export function PluginManagementCard({plugin}: { plugin: PluginDto }) { return state === PluginState.DISABLED; } - function togglePluginEnabled() { + async function togglePluginEnabled() { if (isDisabled(plugin.state)) { - PluginEndpoint.enablePlugin(plugin.id); + await PluginEndpoint.enablePlugin(plugin.id); } else { - PluginEndpoint.disablePlugin(plugin.id); + await PluginEndpoint.disablePlugin(plugin.id); } } diff --git a/app/src/main/frontend/components/general/cards/StartPageDisplayCard.tsx b/app/src/main/frontend/components/general/cards/StartPageDisplayCard.tsx new file mode 100644 index 0000000..c228f8d --- /dev/null +++ b/app/src/main/frontend/components/general/cards/StartPageDisplayCard.tsx @@ -0,0 +1,84 @@ +import {Card, Chip, Image} from "@heroui/react"; +import React, {useMemo} from "react"; +import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto"; +import CollectionDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionDto"; +import {useSnapshot} from "valtio/react"; +import {gameState} from "Frontend/state/GameState"; +import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto"; +import Rand from "rand-seed"; +import {useNavigate} from "react-router"; + + +interface StartPageDisplayCardProps { + item: LibraryDto | CollectionDto; +} + +export function StartPageDisplayCard({item}: StartPageDisplayCardProps) { + const navigate = useNavigate(); + + const isCollection = (libraryOrCollection: LibraryDto | CollectionDto): libraryOrCollection is CollectionDto => { + return 'description' in libraryOrCollection; + }; + + const isLibrary = (libraryOrCollection: LibraryDto | CollectionDto): libraryOrCollection is LibraryDto => { + return !('description' in libraryOrCollection); + }; + + const gamesState = useSnapshot(gameState); + const randomImageId = useMemo(() => getRandomImageId(), [item]); + const link = useMemo(() => getLink(), [item]); + const type = isCollection(item) ? 'Collection' : 'Library'; + + /** + * Gets a random cover ID from the games in the specified library or collection. + * Since the Random class is seeded with the game ID, the same game and image will always be selected for a given library/collection (unless the games inside change). + * @return {number | null} The random cover ID or null if none found. + */ + function getRandomImageId(): number | null { + let games: GameDto[] = []; + + if (isCollection(item)) { + games = gamesState.randomlyOrderedGamesByCollectionId[item.id] as GameDto[]; + } else if (isLibrary(item)) { + games = gamesState.randomlyOrderedGamesByLibraryId[item.id] as GameDto[]; + } + + if (!games || games.length == 0) return null; + + // Find the first game that has at least one screenshot available + let game: GameDto | undefined = games.find(game => game.images && game.images.length > 0); + + if (!game) return null; + + const random = new Rand(`${item.id}-${game.id}`); + const randomImageIndex = Math.floor(random.next() * game.images!.length); + return game.images![randomImageIndex].id; + } + + function getLink(): string { + if (isCollection(item)) { + return `/collection/${item.id}`; + } else if (isLibrary(item)) { + return `/library/${item.id}`; + } + return '#'; + } + + return randomImageId && ( + navigate(link)} + className="h-48 w-96 relative overflow-hidden scale-95 hover:scale-100 shine transition-all select-none"> + +
+

+ {item.name} +

+ {type} +
+
+ ); +} \ No newline at end of file diff --git a/app/src/main/frontend/components/general/covers/CollectionHeader.tsx b/app/src/main/frontend/components/general/covers/CollectionHeader.tsx new file mode 100644 index 0000000..8547dcf --- /dev/null +++ b/app/src/main/frontend/components/general/covers/CollectionHeader.tsx @@ -0,0 +1,57 @@ +import CollectionAdminDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionAdminDto"; +import React, {useEffect, useState} from "react"; +import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto"; +import {useSnapshot} from "valtio/react"; +import {gameState} from "Frontend/state/GameState"; +import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern"; +import {Card} from "@heroui/react"; + +interface CollectionHeaderProps { + collection: CollectionAdminDto; + className?: string; +} + +export default function CollectionHeader({collection, className}: CollectionHeaderProps) { + const MAX_COVER_COUNT = 5; + const state = useSnapshot(gameState); + const [randomGames, setRandomGames] = useState([]); + + useEffect(() => { + if (!state.randomlyOrderedGamesByCollectionId) return; + setRandomGames(getRandomGames()); + }, [state]); + + function getRandomGames() { + if (!state.randomlyOrderedGamesByCollectionId[collection.id]) return []; + const games = state.randomlyOrderedGamesByCollectionId[collection.id] + .filter(game => game.images && game.images.length > 0); + if (!games) return []; + return games.slice(0, MAX_COVER_COUNT); + } + + return ( + + +
+ {randomGames.map((game, idx) => ( +
+ {`Image +
+ ))} +
+
+

{collection.name}

+
+
+ ); +} \ No newline at end of file diff --git a/app/src/main/frontend/components/general/covers/CoverGrid.tsx b/app/src/main/frontend/components/general/covers/CoverGrid.tsx index 303703a..a77a9ad 100644 --- a/app/src/main/frontend/components/general/covers/CoverGrid.tsx +++ b/app/src/main/frontend/components/general/covers/CoverGrid.tsx @@ -1,16 +1,110 @@ import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto"; import {GameCover} from "Frontend/components/general/covers/GameCover"; +import type {CellComponentProps} from "react-window"; +import {Grid} from "react-window"; +import {useEffect, useRef, useState} from "react"; interface CoverGridProps { games: GameDto[]; } +// Constants for grid layout +const MIN_COLUMN_WIDTH = 180; // Minimum width per item (minmax value from original) +const MAX_COLUMN_WIDTH = 212; // Maximum width per item (minmax value from original) +const GAP = 16; // gap-4 = 1rem = 16px +const ASPECT_RATIO = 12 / 17; // Game cover aspect ratio (width/height) + export default function CoverGrid({games}: CoverGridProps) { + const containerRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(0); + + // Update container width on resize + useEffect(() => { + const updateDimensions = () => { + if (containerRef.current) { + setContainerWidth(containerRef.current.offsetWidth); + } + }; + + const resizeObserver = new ResizeObserver(updateDimensions); + if (containerRef.current) { + resizeObserver.observe(containerRef.current); + } + + updateDimensions(); + + return () => resizeObserver.disconnect(); + }, []); + + // Calculate how many columns can fit + const columnCount = Math.max(1, Math.floor((containerWidth + GAP) / (MIN_COLUMN_WIDTH + GAP))); + + // Calculate actual column width to distribute space evenly (up to MAX_COLUMN_WIDTH) + const actualColumnWidth = Math.min( + MAX_COLUMN_WIDTH, + Math.floor((containerWidth - (columnCount - 1) * GAP) / columnCount) + ); + + // Calculate cover height based on width and aspect ratio + // GameCover's size prop is the height, so we need to calculate height from width + const coverHeight = Math.floor(actualColumnWidth / ASPECT_RATIO); + + // Calculate row count + const rowCount = Math.ceil(games.length / columnCount); + + // Cell renderer for react-window Grid + const Cell = ({ + columnIndex, + rowIndex, + style + }: CellComponentProps<{}>) => { + const gameIndex = rowIndex * columnCount + columnIndex; + + // Return empty cell if we're past the end of the games array + if (gameIndex >= games.length) { + return
; + } + + const game = games[gameIndex]; + + return ( +
+ +
+ ); + }; + + // Column width function to handle the last column differently + const getColumnWidth = (index: number) => { + // Last column doesn't need gap after it + if (index === columnCount - 1) { + return actualColumnWidth; + } + return actualColumnWidth + GAP; + }; + return ( -
- {games.map((game) => ( - - ))} +
+ {containerWidth > 0 && ( + + columnCount={columnCount} + columnWidth={getColumnWidth} + rowCount={rowCount} + rowHeight={coverHeight + GAP} + defaultWidth={containerWidth} + cellComponent={Cell} + cellProps={{}} + style={{overflowX: 'hidden'}} + /> + )}
); } \ No newline at end of file diff --git a/app/src/main/frontend/components/general/covers/CoverRow.tsx b/app/src/main/frontend/components/general/covers/CoverRow.tsx index 2aedee5..3216566 100644 --- a/app/src/main/frontend/components/general/covers/CoverRow.tsx +++ b/app/src/main/frontend/components/general/covers/CoverRow.tsx @@ -1,66 +1,166 @@ import React, {useEffect, useRef, useState} from "react"; import {GameCover} from "Frontend/components/general/covers/GameCover"; import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto"; -import {ArrowRightIcon} from "@phosphor-icons/react"; +import {CaretLeftIcon, CaretRightIcon} from "@phosphor-icons/react"; +import {Button, Link} from "@heroui/react"; +import {Grid, GridImperativeAPI} from "react-window"; interface CoverRowProps { games: GameDto[]; title: string; - onPressShowMore: () => void; + link: string; } const aspectRatio = 12 / 17; // aspect ratio of the game cover const defaultImageHeight = 300; // default height for the image const defaultImageWidth = aspectRatio * defaultImageHeight; // default width for the image +const gap = 8; // gap between items in pixels (gap-2 = 0.5rem = 8px) -export function CoverRow({games, title, onPressShowMore}: CoverRowProps) { - +export function CoverRow({games, title, link}: CoverRowProps) { + const gridRef = useRef(null); + const [scrollPosition, setScrollPosition] = useState(0); + const [containerWidth, setContainerWidth] = useState(0); const containerRef = useRef(null); - const [visibleCount, setVisibleCount] = useState(games.length); + // Update container width on resize useEffect(() => { - const calculateVisible = () => { + const updateWidth = () => { if (containerRef.current) { - const containerWidth = containerRef.current.offsetWidth; - const maxFit = Math.floor((containerWidth - defaultImageWidth) / defaultImageWidth) + 1; - setVisibleCount(maxFit < games.length ? maxFit : games.length); + setContainerWidth(containerRef.current.offsetWidth); } }; - const resizeObserver = new ResizeObserver(calculateVisible); + const resizeObserver = new ResizeObserver(updateWidth); if (containerRef.current) { resizeObserver.observe(containerRef.current); } - calculateVisible(); // initial calculation + updateWidth(); return () => resizeObserver.disconnect(); - }, [games.length]); + }, []); - const showMore = visibleCount < games.length; + // Handle scroll updates - track scroll position from the grid element + useEffect(() => { + let gridElement: HTMLDivElement | null = null; + + const handleScroll = () => { + if (gridElement) { + setScrollPosition(gridElement.scrollLeft); + } + }; + + // Small delay to ensure grid is mounted + const timer = setTimeout(() => { + gridElement = gridRef.current?.element ?? null; + if (gridElement) { + gridElement.addEventListener('scroll', handleScroll); + // Initial scroll position + setScrollPosition(gridElement.scrollLeft); + } + }, 100); + + return () => { + clearTimeout(timer); + if (gridElement) { + gridElement.removeEventListener('scroll', handleScroll); + } + }; + }, [containerWidth, games.length]); + + const totalWidth = games.length * (defaultImageWidth + gap); + const maxScroll = Math.max(0, totalWidth - containerWidth); + + const scrollLeft = () => { + const gridElement = gridRef.current?.element; + if (gridElement) { + const itemWidth = defaultImageWidth + gap; + const scrollAmount = itemWidth * 3; // Scroll exactly 3 items + const newPosition = Math.max(0, scrollPosition - scrollAmount); + gridElement.scrollTo({ + left: newPosition, + behavior: "smooth" + }); + } + }; + + const scrollRight = () => { + const gridElement = gridRef.current?.element; + if (gridElement) { + const itemWidth = defaultImageWidth + gap; + const scrollAmount = itemWidth * 3; // Scroll exactly 3 items + const newPosition = Math.min(maxScroll, scrollPosition + scrollAmount); + gridElement.scrollTo({ + left: newPosition, + behavior: "smooth" + }); + } + }; + + const canScrollLeft = scrollPosition > 1; // Allow small margin for floating point issues + const canScrollRight = scrollPosition < maxScroll - 1 && maxScroll > 0; + + // Cell renderer for react-window Grid + const Cell = ({columnIndex, style}: { + ariaAttributes: { "aria-colindex": number; role: "gridcell" }; + columnIndex: number; + rowIndex: number; + style: React.CSSProperties; + }) => { + const game = games[columnIndex]; + return ( +
+ +
+ ); + }; return (
-

{title}

-
-
- {games.slice(0, visibleCount).map((game, index) => ( - - ))} +
+ +

{title}

+ + +
+ +
- - {showMore && ( -
-
-
-

Show more

- -
-
+
+
+ {containerWidth > 0 && ( + + gridRef={gridRef} + columnCount={games.length} + columnWidth={defaultImageWidth + gap} + rowCount={1} + rowHeight={defaultImageHeight} + defaultHeight={defaultImageHeight} + defaultWidth={containerWidth} + cellComponent={Cell} + cellProps={{}} + className="scrollbar-hide" + style={{overflow: 'auto'}} + /> )}
diff --git a/app/src/main/frontend/components/general/covers/GameCover.tsx b/app/src/main/frontend/components/general/covers/GameCover.tsx index 3e1d1ce..e17e3f4 100644 --- a/app/src/main/frontend/components/general/covers/GameCover.tsx +++ b/app/src/main/frontend/components/general/covers/GameCover.tsx @@ -1,21 +1,105 @@ import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto"; import {Image} from "@heroui/react"; import {GameCoverFallback} from "Frontend/components/general/covers/GameCoverFallback"; +import {useEffect, useRef, useState} from "react"; +import {decode} from "blurhash"; + +// Cache to track which images have been loaded across component remounts +const loadedImagesCache = new Set(); interface GameCoverProps { game: GameDto; size?: number; radius?: "none" | "sm" | "md" | "lg"; interactive?: boolean; + lazy?: boolean; } -export function GameCover({game, size = 300, radius = "sm", interactive = false}: GameCoverProps) { - const coverContent = Number.isInteger(game.coverId) ? ( -
+export function GameCover({game, size = 300, radius = "sm", interactive = false, lazy = false}: GameCoverProps) { + const [shouldLoad, setShouldLoad] = useState(!lazy); + // Check cache to see if this image has already been loaded + const [isImageLoaded, setIsImageLoaded] = useState( + game.cover ? loadedImagesCache.has(game.cover.id) : false + ); + const [blurhashUrl, setBlurhashUrl] = useState(undefined); + const containerRef = useRef(null); + + // Generate blurhash placeholder image + useEffect(() => { + if (game.cover?.blurhash) { + try { + // Decode blurhash to pixel data + const pixels = decode(game.cover.blurhash, 32, 45); // Small size for placeholder + + // Create canvas and draw pixels + const canvas = document.createElement('canvas'); + canvas.width = 32; + canvas.height = 45; + const ctx = canvas.getContext('2d'); + + if (ctx) { + const imageData = ctx.createImageData(32, 45); + imageData.data.set(pixels); + ctx.putImageData(imageData, 0, 0); + + // Convert canvas to data URL + setBlurhashUrl(canvas.toDataURL()); + } + } catch (e) { + console.error('Failed to decode blurhash:', e); + } + } + }, [game.cover?.blurhash]); + + useEffect(() => { + if (!lazy || shouldLoad) return; + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + setShouldLoad(true); + observer.disconnect(); + } + }); + }, + { + rootMargin: '200px', // Start loading 200px before the element enters viewport + } + ); + + if (containerRef.current) { + observer.observe(containerRef.current); + } + + return () => observer.disconnect(); + }, [lazy, shouldLoad]); + + // Preload the real image when shouldLoad becomes true + useEffect(() => { + if (!shouldLoad || !game.cover || isImageLoaded) return; + + const img = document.createElement('img'); + img.src = `images/cover/${game.cover.id}`; + img.onload = () => { + loadedImagesCache.add(game.cover!.id); + setIsImageLoaded(true); + }; + img.onerror = () => { + // If image fails to load, we'll just show the fallback + setIsImageLoaded(true); + }; + }, [shouldLoad, game.cover, isImageLoaded]); + + const coverContent = game.cover ? ( +
{game.title}} diff --git a/app/src/main/frontend/components/general/covers/LibraryHeader.tsx b/app/src/main/frontend/components/general/covers/LibraryHeader.tsx index 963195d..6dca975 100644 --- a/app/src/main/frontend/components/general/covers/LibraryHeader.tsx +++ b/app/src/main/frontend/components/general/covers/LibraryHeader.tsx @@ -1,6 +1,5 @@ import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto"; import React from "react"; -import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto"; import {useSnapshot} from "valtio/react"; import {gameState} from "Frontend/state/GameState"; import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern"; @@ -17,7 +16,9 @@ export default function LibraryHeader({library, className}: LibraryHeaderProps) const randomGames = getRandomGames(); function getRandomGames() { - const games = state.randomlyOrderedGamesByLibraryId[library.id] as GameDto[]; + if (!state.randomlyOrderedGamesByLibraryId[library.id]) return []; + const games = state.randomlyOrderedGamesByLibraryId[library.id] + .filter(game => game.images && game.images.length > 0); if (!games) return []; return games.slice(0, MAX_COVER_COUNT); } @@ -36,7 +37,7 @@ export default function LibraryHeader({library, className}: LibraryHeaderProps) }} > {`Image
diff --git a/app/src/main/frontend/components/general/input/ArrayInput.tsx b/app/src/main/frontend/components/general/input/ArrayInput.tsx index 805c659..3073e73 100644 --- a/app/src/main/frontend/components/general/input/ArrayInput.tsx +++ b/app/src/main/frontend/components/general/input/ArrayInput.tsx @@ -1,7 +1,7 @@ import {FieldArray, useField} from "formik"; import {Button, Chip, Input, Popover, PopoverContent, PopoverTrigger} from "@heroui/react"; import {KeyboardEvent, useState} from "react"; -import { PlusIcon } from "@phosphor-icons/react"; +import {PlusIcon} from "@phosphor-icons/react"; // @ts-ignore const ArrayInput = ({label, ...props}) => { @@ -35,13 +35,23 @@ const ArrayInput = ({label, ...props}) => {
{field.value.map((element: any, index: number) => ( - arrayHelpers.remove(index)}> + arrayHelpers.remove(index)} + isDisabled={props.isDisabled} + > {element} ))} - +
- {field.value || game.coverId ? + {field.value || game.cover?.id ?
{game.title}
- {field.value || game.headerId ? + {field.value || game.header?.id ?
{game.title} + + diff --git a/app/src/main/frontend/components/general/library/LibraryManagementGames.tsx b/app/src/main/frontend/components/general/library/LibraryManagementGames.tsx index c16d506..775a26e 100644 --- a/app/src/main/frontend/components/general/library/LibraryManagementGames.tsx +++ b/app/src/main/frontend/components/general/library/LibraryManagementGames.tsx @@ -38,12 +38,12 @@ export default function LibraryManagementGames({library}: LibraryManagementGames const rowsPerPage = 25; const state = useSnapshot(gameState); - const games = state.gamesByLibraryId[library.id] ? state.gamesByLibraryId[library.id] as GameAdminDto[] : []; + const games = state.gamesByLibraryId[library.id] ? state.gamesByLibraryId[library.id] : []; const [searchTerm, setSearchTerm] = useState(""); const [filter, setFilter] = useState<"all" | "confirmed" | "nonConfirmed">("all"); const [sortDescriptor, setSortDescriptor] = useState({column: "title", direction: "ascending"}); - const [selectedGame, setSelectedGame] = useState(games[0]); + const [selectedGame, setSelectedGame] = useState(games[0] as GameAdminDto); const editGameModal = useDisclosure(); const matchGameModal = useDisclosure(); @@ -94,7 +94,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames function getFilteredGames() { let filteredGames = (games as GameAdminDto[]).filter((game) => - game.metadata.path!!.toLowerCase().includes(searchTerm.toLowerCase()) || + game.metadata.path!.toLowerCase().includes(searchTerm.toLowerCase()) || game.title.toLowerCase().includes(searchTerm.toLowerCase()) || game.publishers?.some(publisher => publisher.toLowerCase().includes(searchTerm.toLowerCase())) || game.developers?.some(developer => developer.toLowerCase().includes(searchTerm.toLowerCase())) @@ -102,10 +102,10 @@ export default function LibraryManagementGames({library}: LibraryManagementGames if (filter === "confirmed") { return filteredGames.filter(g => g.metadata.matchConfirmed); - } - if (filter === "nonConfirmed") { + } else if (filter === "nonConfirmed") { return filteredGames.filter(g => !g.metadata.matchConfirmed); } + return filteredGames; } @@ -178,7 +178,8 @@ export default function LibraryManagementGames({library}: LibraryManagementGames {item.title} ({item.release ? new Date(item.release).getFullYear() : "unknown"}) + underline="hover"> + {item.title} ({item.release ? new Date(item.release).getFullYear() : "unknown"}) @@ -238,7 +239,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames - + return library.ignoredPaths!.filter((path) => path.path.toLowerCase().includes(searchTerm.toLowerCase()) ) } @@ -165,7 +165,10 @@ export default function LibraryManagementIgnoredPaths({library}: LibraryManageme
diff --git a/app/src/main/frontend/components/general/modals/CollectionCreationModal.tsx b/app/src/main/frontend/components/general/modals/CollectionCreationModal.tsx new file mode 100644 index 0000000..0558b5b --- /dev/null +++ b/app/src/main/frontend/components/general/modals/CollectionCreationModal.tsx @@ -0,0 +1,90 @@ +import React from "react"; +import {addToast, Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react"; +import {Form, Formik} from "formik"; +import Input from "Frontend/components/general/input/Input"; +import {CollectionEndpoint} from "Frontend/generated/endpoints"; +import CollectionCreateDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionCreateDto"; +import * as Yup from "yup"; +import TextAreaInput from "Frontend/components/general/input/TextAreaInput"; + +interface CollectionCreationModalProps { + isOpen: boolean; + onOpenChange: () => void; +} + +export default function CollectionCreationModal({ + isOpen, + onOpenChange + }: CollectionCreationModalProps) { + + async function createCollection(collection: CollectionCreateDto) { + await CollectionEndpoint.createCollection(collection); + + addToast({ + title: "New collection created", + description: `Collection ${collection.name} created!`, + color: "success" + }); + } + + return (<> + + + {(onClose) => ( + { + await createCollection(values); + onClose(); + }} + > + {(formik) => +
+ Create a new collection + +
+ + +
+
+ + + + +
+ } +
+ )} +
+
+ + ); +} \ No newline at end of file diff --git a/app/src/main/frontend/components/general/modals/CollectionGamesTable.tsx b/app/src/main/frontend/components/general/modals/CollectionGamesTable.tsx new file mode 100644 index 0000000..44e5be3 --- /dev/null +++ b/app/src/main/frontend/components/general/modals/CollectionGamesTable.tsx @@ -0,0 +1,181 @@ +import {useSnapshot} from "valtio/react"; +import { + Button, + Input, + Link, + Select, + SelectItem, + SortDescriptor, + Table, + TableBody, + TableCell, + TableColumn, + TableHeader, + TableRow, + Tooltip +} from "@heroui/react"; +import React, {useMemo, useState} from "react"; +import {GameAdminDto} from "Frontend/dtos/GameDtos"; +import {CollectionEndpoint} from "Frontend/generated/endpoints"; +import {MinusIcon, PlusIcon} from "@phosphor-icons/react"; +import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto"; +import {libraryState} from "Frontend/state/LibraryState"; +import {gameState} from "Frontend/state/GameState"; +import {collectionState} from "Frontend/state/CollectionState"; + +interface CollectionGamesTableProps { + collectionId: number; +} + +export default function CollectionGamesTable({collectionId}: CollectionGamesTableProps) { + const gamesState = useSnapshot(gameState); + const games = gamesState.games as GameAdminDto[]; + const librariesState = useSnapshot(libraryState); + const libraries = librariesState.state as Record; + const collectionsState = useSnapshot(collectionState); + const collection = collectionsState.state[collectionId]; + + const [sortDescriptor, setSortDescriptor] = useState({column: "path", direction: "ascending"}); + const [searchTerm, setSearchTerm] = useState(""); + const [filter, setFilter] = useState<"all" | "inCollection" | "notInCollection">("all"); + + function libraryName(game: GameAdminDto) { + return libraries[game.libraryId]?.name || "Unknown"; + } + + const gameInCollectionMap = useMemo(() => { + const map = new Map(); + games.forEach(game => { + map.set(game.id, collection.gameIds!.includes(game.id)); + }); + return map; + }, [games, collection.gameIds]); + + function isGameInCollection(game: GameAdminDto) { + return gameInCollectionMap.get(game.id) ?? false; + } + + const filteredGames = useMemo(() => { + return games + .filter((game) => game.title.toLowerCase().includes(searchTerm.toLowerCase())) + .filter(game => { + if (filter === "inCollection") { + return isGameInCollection(game); + } else if (filter === "notInCollection") { + return !isGameInCollection(game); + } + return true; + }); + }, [games, searchTerm, filter, gameInCollectionMap]); + + const sortedGames = useMemo(() => { + return filteredGames + .slice() + .sort((a, b) => { + let cmp: number; + switch (sortDescriptor.column) { + case "title": + cmp = a.title.localeCompare(b.title); + break; + case "library": + cmp = (libraryName(a)).localeCompare(libraryName(b)); + break; + default: + cmp = 0; + } + if (sortDescriptor.direction === "descending") { + cmp *= -1; + } + return cmp; + }) + .map(game => ({...game, _inCollection: isGameInCollection(game)})); + }, [filteredGames, sortDescriptor, libraries, gameInCollectionMap]); + + async function addGameToCollection(game: GameAdminDto) { + await CollectionEndpoint.addGameToCollection(collectionId, game.id); + } + + async function removeGameFromCollection(game: GameAdminDto) { + await CollectionEndpoint.removeGameFromCollection(collectionId, game.id); + } + + return ( +
+
+ setSearchTerm(e.target.value)} + onClear={() => setSearchTerm("")} + /> + +
+ + + Title + Library + Actions + + + {(game) => ( + // Key includes _inCollection to force re-render when that value changes + + + + {game.title} ({game.release ? new Date(game.release).getFullYear() : "unknown"}) + + + + + {libraryName(game)} + + + +
+ + + + + + +
+
+
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/app/src/main/frontend/components/general/modals/CollectionPrioritiesModal.tsx b/app/src/main/frontend/components/general/modals/CollectionPrioritiesModal.tsx new file mode 100644 index 0000000..a7e4bc7 --- /dev/null +++ b/app/src/main/frontend/components/general/modals/CollectionPrioritiesModal.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import {CollectionEndpoint} from "Frontend/generated/endpoints"; +import {useSnapshot} from "valtio/react"; +import {collectionState} from "Frontend/state/CollectionState"; +import CollectionDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionDto"; +import CollectionUpdateDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionUpdateDto"; +import PrioritiesModal from "./PrioritiesModal"; + +interface CollectionPrioritiesModalProps { + isOpen: boolean; + onOpenChange: () => void; +} + +export default function CollectionPrioritiesModal({isOpen, onOpenChange}: CollectionPrioritiesModalProps) { + + const collections = useSnapshot(collectionState).sorted; + + const updateCollections = async (reorderedCollections: any[]) => { + const updateDtos: CollectionUpdateDto[] = reorderedCollections.map((collection, index): CollectionUpdateDto => { + return { + id: collection.id, + metadata: { + displayOnHomepage: collection.metadata!.displayOnHomepage, + displayOrder: index + } + }; + }); + await CollectionEndpoint.updateCollections(updateDtos); + }; + + return ( + + ); +} + diff --git a/app/src/main/frontend/components/general/modals/GameCoverPickerModal.tsx b/app/src/main/frontend/components/general/modals/GameCoverPickerModal.tsx index 771d33d..d80396f 100644 --- a/app/src/main/frontend/components/general/modals/GameCoverPickerModal.tsx +++ b/app/src/main/frontend/components/general/modals/GameCoverPickerModal.tsx @@ -7,7 +7,6 @@ import {ArrowRightIcon, MagnifyingGlassIcon} from "@phosphor-icons/react"; import PluginIcon from "Frontend/components/general/plugin/PluginIcon"; import {useSnapshot} from "valtio/react"; import {pluginState} from "Frontend/state/PluginState"; -import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto"; interface GameCoverPickerModalProps { game: GameDto; @@ -110,7 +109,7 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}: />
-

{cover.title}

diff --git a/app/src/main/frontend/components/general/modals/GameHeaderPickerModal.tsx b/app/src/main/frontend/components/general/modals/GameHeaderPickerModal.tsx index 1e2b74e..cd2e561 100644 --- a/app/src/main/frontend/components/general/modals/GameHeaderPickerModal.tsx +++ b/app/src/main/frontend/components/general/modals/GameHeaderPickerModal.tsx @@ -7,7 +7,6 @@ import {ArrowRightIcon, MagnifyingGlassIcon} from "@phosphor-icons/react"; import PluginIcon from "Frontend/components/general/plugin/PluginIcon"; import {useSnapshot} from "valtio/react"; import {pluginState} from "Frontend/state/PluginState"; -import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto"; interface GameHeaderPickerModalProps { game: GameDto; @@ -109,7 +108,7 @@ export function GameHeaderPickerModal({game, isOpen, onOpenChange, setHeaderUrl} />
-

{header.title}

diff --git a/app/src/main/frontend/components/general/modals/LibraryCreationModal.tsx b/app/src/main/frontend/components/general/modals/LibraryCreationModal.tsx index 6a8b595..e8293c9 100644 --- a/app/src/main/frontend/components/general/modals/LibraryCreationModal.tsx +++ b/app/src/main/frontend/components/general/modals/LibraryCreationModal.tsx @@ -1,15 +1,14 @@ import React, {useState} from "react"; import {addToast, Button, Checkbox, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react"; import {Form, Formik} from "formik"; -import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto"; import {LibraryEndpoint} from "Frontend/generated/endpoints"; import Input from "Frontend/components/general/input/Input"; import * as Yup from "yup"; import DirectoryMappingInput from "Frontend/components/general/input/DirectoryMappingInput"; -import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto"; import ArrayInputAutocomplete from "Frontend/components/general/input/ArrayInputAutocomplete"; import {useSnapshot} from "valtio/react"; import {platformState} from "Frontend/state/PlatformState"; +import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto"; interface LibraryCreationModalProps { isOpen: boolean; @@ -24,8 +23,8 @@ export default function LibraryCreationModal({ const [scanAfterCreation, setScanAfterCreation] = useState(true); const availablePlatforms = useSnapshot(platformState).available; - async function createLibrary(library: LibraryDto) { - await LibraryEndpoint.createLibrary(library as LibraryAdminDto, scanAfterCreation); + async function createLibrary(library: LibraryAdminDto) { + await LibraryEndpoint.createLibrary(library, scanAfterCreation); addToast({ title: "New library created", @@ -39,20 +38,25 @@ export default function LibraryCreationModal({ {(onClose) => ( - { - await createLibrary(values); - onClose(); - }} + { + await createLibrary(values); + onClose(); + }} > {(formik) =>
diff --git a/app/src/main/frontend/components/general/modals/LibraryPrioritiesModal.tsx b/app/src/main/frontend/components/general/modals/LibraryPrioritiesModal.tsx new file mode 100644 index 0000000..744d993 --- /dev/null +++ b/app/src/main/frontend/components/general/modals/LibraryPrioritiesModal.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import {LibraryEndpoint} from "Frontend/generated/endpoints"; +import {useSnapshot} from "valtio/react"; +import {libraryState} from "Frontend/state/LibraryState"; +import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto"; +import LibraryUpdateDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryUpdateDto"; +import PrioritiesModal from "./PrioritiesModal"; + +interface LibraryPrioritiesModalProps { + isOpen: boolean; + onOpenChange: () => void; +} + +export default function LibraryPrioritiesModal({isOpen, onOpenChange}: LibraryPrioritiesModalProps) { + + const libraries = useSnapshot(libraryState).sorted; + + const updateLibraries = async (reorderedLibraries: LibraryDto[]) => { + const updateDtos: LibraryUpdateDto[] = reorderedLibraries.map((library, index): LibraryUpdateDto => { + return { + id: library.id, + metadata: { + displayOnHomepage: library.metadata!.displayOnHomepage, + displayOrder: index + } + }; + }); + await LibraryEndpoint.updateLibraries(updateDtos); + }; + + return ( + + ); +} \ No newline at end of file diff --git a/app/src/main/frontend/components/general/modals/MatchGameModal.tsx b/app/src/main/frontend/components/general/modals/MatchGameModal.tsx index 2fe43ff..aed5262 100644 --- a/app/src/main/frontend/components/general/modals/MatchGameModal.tsx +++ b/app/src/main/frontend/components/general/modals/MatchGameModal.tsx @@ -19,7 +19,6 @@ import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/G import PluginIcon from "../plugin/PluginIcon"; import {useSnapshot} from "valtio/react"; import {pluginState} from "Frontend/state/PluginState"; -import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto"; import {libraryState} from "Frontend/state/LibraryState"; import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto"; @@ -129,7 +128,7 @@ export default function MatchGameModal({
{Object.values(item.originalIds).map( originalId => + plugin={state[originalId.pluginId]}/> )}
diff --git a/app/src/main/frontend/components/general/modals/PluginPrioritiesModal.tsx b/app/src/main/frontend/components/general/modals/PluginPrioritiesModal.tsx index c7ce8e3..2d7af27 100644 --- a/app/src/main/frontend/components/general/modals/PluginPrioritiesModal.tsx +++ b/app/src/main/frontend/components/general/modals/PluginPrioritiesModal.tsx @@ -1,113 +1,39 @@ import React from "react"; -import {addToast, Button, Chip, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react"; -import {ListBox, ListBoxItem, useDragAndDrop} from "react-aria-components"; -import { CaretUpDownIcon } from "@phosphor-icons/react"; -import {useListData} from "@react-stately/data"; import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto"; import {PluginEndpoint} from "Frontend/generated/endpoints"; +import PrioritiesModal from "./PrioritiesModal"; +import {useSnapshot} from "valtio/react"; +import {pluginState} from "Frontend/state/PluginState"; interface PluginPrioritiesModalProps { - plugins: PluginDto[]; isOpen: boolean; onOpenChange: () => void; + type: string; } -export default function PluginPrioritiesModal({plugins, isOpen, onOpenChange}: PluginPrioritiesModalProps) { +export default function PluginPrioritiesModal({isOpen, onOpenChange, type}: PluginPrioritiesModalProps) { + const plugins = useSnapshot(pluginState).sortedByType[type]; - const sortedPlugins = useListData({ - initialItems: plugins, // Already sorted in parent - getKey: (plugin) => plugin.id - }); + const updatePlugins = async (reorderedPlugins: PluginDto[]) => { + const prioritiesMap: Record = {}; + const totalPlugins = reorderedPlugins.length; - let {dragAndDropHooks} = useDragAndDrop({ - getItems: (keys) => - [...keys].map((key) => ({'text/plain': sortedPlugins.getItem(key)!.name})), - onReorder(e) { - if (e.keys.has(e.target.key)) return; - - if (e.target.dropPosition === 'before' || e.target.dropPosition === 'on') { - sortedPlugins.moveBefore(e.target.key, e.keys); - } else if (e.target.dropPosition === 'after') { - sortedPlugins.moveAfter(e.target.key, e.keys); - } - - // Recalculate priority based on new position (reversed) - sortedPlugins.items.forEach((plugin, index) => { - const reversedPriority = sortedPlugins.items.length - index; - sortedPlugins.update(plugin.id, {...plugin, priority: reversedPriority}); - }); - } - }); - - function generatePrioritiesMap(): Record { - let map: Record = {}; - const totalPlugins = sortedPlugins.items.length; - sortedPlugins.items.forEach((plugin, index) => { - map[plugin.id] = totalPlugins - index; // Reverse order + reorderedPlugins.forEach((plugin, index) => { + // Reverse order: first item gets highest priority + prioritiesMap[plugin.id] = totalPlugins - index; }); - return map; - } - async function setPluginPriorities(onClose: () => void) { - try { - const prioritiesMap = generatePrioritiesMap(); - await PluginEndpoint.setPluginPriorities(prioritiesMap); - - addToast({ - title: "Plugin order updated", - description: "Plugin order has been updated successfully.", - color: "success" - }); - onClose(); - } catch (e) { - addToast({ - title: "Error", - description: "An error occurred while updating plugin order.", - color: "warning" - }); - } - } + await PluginEndpoint.setPluginPriorities(prioritiesMap); + }; return ( - - - {(onClose) => ( - <> - -

Edit plugin order

-

Plugins higher on the list are preferred

-
- - - {(plugin: PluginDto) => ( - -
- - {sortedPlugins.items.findIndex(p => p.id === plugin.id) + 1} - -

{plugin.name}

-
- -
- )} -
- -
- - - - - - )} -
-
+ ); } \ No newline at end of file diff --git a/app/src/main/frontend/components/general/modals/PrioritiesModal.tsx b/app/src/main/frontend/components/general/modals/PrioritiesModal.tsx new file mode 100644 index 0000000..c9cd969 --- /dev/null +++ b/app/src/main/frontend/components/general/modals/PrioritiesModal.tsx @@ -0,0 +1,127 @@ +import React, {useEffect, useState} from "react"; +import {addToast, Button, Chip, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react"; +import {ListBox, ListBoxItem, useDragAndDrop} from "react-aria-components"; +import {CaretUpDownIcon} from "@phosphor-icons/react"; +import {useListData} from "@react-stately/data"; + +export interface PrioritizableItem { + id: number | string; + name: string; +} + +interface PrioritiesModalProps { + title: string; + subtitle: string; + items: T[]; + updateItems: (items: T[]) => Promise; + isOpen: boolean; + onOpenChange: () => void; +} + +export default function PrioritiesModal({ + items, + isOpen, + onOpenChange, + title, + subtitle, + updateItems + }: PrioritiesModalProps) { + + const sortedItems = useListData({ + initialItems: items, + getKey: (item) => item.id + }); + + // Track order changes to trigger re-renders + const [orderVersion, setOrderVersion] = useState(0); + + // Update sortedItems when items change + useEffect(() => { + sortedItems.setSelectedKeys(new Set()); + sortedItems.items.forEach(item => sortedItems.remove(item.id)); + items.forEach(item => sortedItems.append(item)); + setOrderVersion(prev => prev + 1); + }, [items]); + + let {dragAndDropHooks} = useDragAndDrop({ + getItems: (keys) => + [...keys].map((key) => ({'text/plain': sortedItems.getItem(key)!.name})), + onReorder(e) { + if (e.keys.has(e.target.key)) return; + + if (e.target.dropPosition === 'before' || e.target.dropPosition === 'on') { + sortedItems.moveBefore(e.target.key, e.keys); + } else if (e.target.dropPosition === 'after') { + sortedItems.moveAfter(e.target.key, e.keys); + } + // Trigger re-render after reorder + setOrderVersion(prev => prev + 1); + } + }); + + async function updateItemOrder(onClose: () => void) { + try { + // Pass the reordered items directly to the update function + // The parent component will handle the actual transformation + await updateItems(sortedItems.items); + + addToast({ + title: "Order updated", + description: "Item order has been updated successfully.", + color: "success" + }); + onClose(); + } catch (e) { + addToast({ + title: "Error", + description: "An error occurred while updating item order.", + color: "warning" + }); + } + } + + return ( + + + {(onClose) => ( + <> + +

{title}

+

{subtitle}

+
+ + + {(item: T) => ( + +
+ + {sortedItems.items.findIndex(p => p.id === item.id) + 1} + +

{item.name}

+
+ +
+ )} +
+ +
+ + + + + + )} +
+
+ ); +} + diff --git a/app/src/main/frontend/components/general/plugin/PluginManagementSection.tsx b/app/src/main/frontend/components/general/plugin/PluginManagementSection.tsx index e7337e1..e18f3bf 100644 --- a/app/src/main/frontend/components/general/plugin/PluginManagementSection.tsx +++ b/app/src/main/frontend/components/general/plugin/PluginManagementSection.tsx @@ -1,17 +1,19 @@ import {Button, Tooltip, useDisclosure} from "@heroui/react"; -import { ListNumbersIcon } from "@phosphor-icons/react"; +import {ListNumbersIcon} from "@phosphor-icons/react"; import {PluginManagementCard} from "Frontend/components/general/cards/PluginManagementCard"; import React from "react"; import PluginPrioritiesModal from "Frontend/components/general/modals/PluginPrioritiesModal"; import {camelCaseToTitle} from "Frontend/util/utils"; -import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto"; +import {useSnapshot} from "valtio/react"; +import {pluginState} from "Frontend/state/PluginState"; interface PluginManagementSectionProps { type: string; - plugins: PluginDto[]; } -export function PluginManagementSection({type, plugins = []}: PluginManagementSectionProps) { +export function PluginManagementSection({type}: PluginManagementSectionProps) { + const plugins = useSnapshot(pluginState).sortedByType[type]; + const pluginPrioritiesModal = useDisclosure(); return ( @@ -40,10 +42,9 @@ export function PluginManagementSection({type, plugins = []}: PluginManagementSe
} p.id + p.priority).join(',')} // force re-mount if plugin order changes - plugins={[...plugins].sort((a, b) => b.priority - a.priority)} isOpen={pluginPrioritiesModal.isOpen} onOpenChange={pluginPrioritiesModal.onOpenChange} + type={type} />
); } \ No newline at end of file diff --git a/app/src/main/frontend/index.tsx b/app/src/main/frontend/index.tsx index 0c4daa5..4f8d9c8 100644 --- a/app/src/main/frontend/index.tsx +++ b/app/src/main/frontend/index.tsx @@ -6,6 +6,10 @@ import {router} from './routes'; const container = document.getElementById('outlet')!; const root = createRoot(container); +declare module 'valtio' { + function useSnapshot(p: T): T +} + root.render( diff --git a/app/src/main/frontend/routes.tsx b/app/src/main/frontend/routes.tsx index 9f2c592..7b8c657 100644 --- a/app/src/main/frontend/routes.tsx +++ b/app/src/main/frontend/routes.tsx @@ -4,10 +4,10 @@ import HomeView from "Frontend/views/HomeView"; import SetupView from "Frontend/views/SetupView"; import {ThemeSelector} from "Frontend/components/theming/ThemeSelector"; import App from "Frontend/App"; -import {LibraryManagement} from "Frontend/components/administration/LibraryManagement"; +import {GameManagement} from "Frontend/components/administration/GameManagement"; import {UserManagement} from "Frontend/components/administration/UserManagement"; import ProfileManagement from "Frontend/components/administration/ProfileManagement"; -import {SsoManagement} from "Frontend/components/administration/SsoManagement"; +import {SecurityManagement} from "Frontend/components/administration/SecurityManagement"; import {AdministrationView} from "Frontend/views/AdministrationView"; import {ProfileView} from "Frontend/views/ProfileView"; import {MessageManagement} from "Frontend/components/administration/MessageManagement"; @@ -20,13 +20,14 @@ import {SystemManagement} from "Frontend/components/administration/SystemManagem import GameView from "Frontend/views/GameView"; import LibraryManagementView from "Frontend/views/LibraryManagementView"; import SearchView from "Frontend/views/SearchView"; -import RecentlyAddedView from "Frontend/views/RecentlyAddedView"; import LibraryView from "Frontend/views/LibraryView"; import {RouterConfigurationBuilder} from "@vaadin/hilla-file-router/runtime.js"; import ErrorView from "Frontend/views/ErrorView"; import GameRequestView from "Frontend/views/GameRequestView"; import {GameRequestManagement} from "Frontend/components/administration/GameRequestManagement"; import {DownloadManagement} from "Frontend/components/administration/DownloadManagement"; +import CollectionManagementView from "Frontend/views/CollectionManagementView"; +import CollectionView from "Frontend/views/CollectionView"; export const {router, routes} = new RouterConfigurationBuilder() .withReactRoutes([ @@ -45,11 +46,6 @@ export const {router, routes} = new RouterConfigurationBuilder() element: , handle: {title: 'Search'} }, - { - path: 'recently-added', - element: , - handle: {title: 'Recently Added'} - }, { path: '/requests', element: , @@ -59,6 +55,10 @@ export const {router, routes} = new RouterConfigurationBuilder() path: 'library/:libraryId', element: }, + { + path: 'collection/:collectionId', + element: + }, { path: 'game/:gameId', element: @@ -86,15 +86,20 @@ export const {router, routes} = new RouterConfigurationBuilder() handle: {title: 'Administration'}, children: [ { - path: 'libraries', - element: , - handle: {title: 'Administration - Libraries'} + path: 'games', + element: , + handle: {title: 'Administration - Games'} }, { - path: 'libraries/library/:libraryId', + path: 'games/library/:libraryId', element: , handle: {title: 'Administration - Library'} }, + { + path: 'games/collection/:collectionId', + element: , + handle: {title: 'Administration - Collection'} + }, { path: 'requests', element: , @@ -111,9 +116,9 @@ export const {router, routes} = new RouterConfigurationBuilder() handle: {title: 'Administration - Users'} }, { - path: 'sso', - element: , - handle: {title: 'Administration - SSO'} + path: 'security', + element: , + handle: {title: 'Administration - Security'} }, { path: 'messages', diff --git a/app/src/main/frontend/state/CollectionState.ts b/app/src/main/frontend/state/CollectionState.ts new file mode 100644 index 0000000..fbc603b --- /dev/null +++ b/app/src/main/frontend/state/CollectionState.ts @@ -0,0 +1,70 @@ +import {Subscription} from "@vaadin/hilla-frontend"; +import {proxy} from "valtio/index"; +import {CollectionEndpoint} from "Frontend/generated/endpoints"; +import CollectionDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionDto"; +import CollectionEvent from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionEvent"; + +type CollectionState = { + subscription?: Subscription; + isLoaded: boolean; + state: Record; + collections: CollectionDto[]; + sorted: CollectionDto[]; +}; + +export const collectionState = proxy({ + get isLoaded() { + return this.subscription != null; + }, + state: {}, + get collections() { + return Object.values(this.state); + }, + get sorted() { + return Object.values(this.state).sort((a: any, b: any) => { + const orderA = a.metadata?.displayOrder ?? -1; + const orderB = b.metadata?.displayOrder ?? -1; + + // Handle -1 as "end of list" + const effectiveOrderA = orderA === -1 ? Number.MAX_SAFE_INTEGER : orderA; + const effectiveOrderB = orderB === -1 ? Number.MAX_SAFE_INTEGER : orderB; + + const orderDiff = effectiveOrderA - effectiveOrderB; + if (orderDiff !== 0) { + return orderDiff; + } + + // Fallback to creation date (newer first) + return new Date(a.createdAt!).getTime() - new Date(b.createdAt!).getTime(); + }); + } +}); + +/** Subscribe to and process state updates from backend **/ +export async function initializeCollectionState() { + if (collectionState.isLoaded) return; + + // Fetch initial collection list + const initialEntries = await CollectionEndpoint.getAll(); + initialEntries.forEach((collection: CollectionDto) => { + collectionState.state[collection.id] = collection; + }); + + // Subscribe to real-time updates + collectionState.subscription = CollectionEndpoint.subscribeToCollectionEvents().onNext((collectionEvents: CollectionEvent[]) => { + collectionEvents.forEach((collectionEvent: CollectionEvent) => { + switch (collectionEvent.type) { + case "created": + case "updated": + //@ts-ignore + collectionState.state[collectionEvent.collection.id] = collectionEvent.collection; + break; + case "deleted": + //@ts-ignore + delete collectionState.state[collectionEvent.collectionId]; + break; + } + }) + }); +} + diff --git a/app/src/main/frontend/state/GameState.ts b/app/src/main/frontend/state/GameState.ts index a7d42b8..aa30def 100644 --- a/app/src/main/frontend/state/GameState.ts +++ b/app/src/main/frontend/state/GameState.ts @@ -11,10 +11,10 @@ type GameState = { state: Record; games: GameDto[]; gamesByLibraryId: Record; + gamesByCollectionId: Record; sortedAlphabetically: GameDto[]; - recentlyAdded: GameDto[]; - recentlyUpdated: GameDto[]; randomlyOrderedGamesByLibraryId: Record; + randomlyOrderedGamesByCollectionId: Record; knownPublishers: Set; knownDevelopers: Set; knownGenres: Set; @@ -38,26 +38,33 @@ export const gameState = proxy({ return acc; }, {}); }, + get gamesByCollectionId() { + return this.sortedAlphabetically.reduce((acc: Record, game: GameDto) => { + game.collectionIds?.forEach((collectionId: number) => { + (acc[collectionId] ||= []).push(game); + }); + return acc; + }, {}); + }, get sortedAlphabetically() { return this.games .sort((a: GameDto, b: GameDto) => a.title.localeCompare(b.title, undefined, {sensitivity: 'base'})); }, - get recentlyAdded() { - return this.games - .sort((a: GameDto, b: GameDto) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) - .slice(0, 25); - }, - get recentlyUpdated() { - return this.games - .sort((a: GameDto, b: GameDto) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()) - .slice(0, 25); - }, get randomlyOrderedGamesByLibraryId() { const result: Record = {}; for (const libraryId in this.gamesByLibraryId) { - const rand = new Rand(libraryId.toString()); + const rand = new Rand(`library-${libraryId}`); result[libraryId] = this.gamesByLibraryId[libraryId] - .filter((g: GameDto) => g.coverId && g.imageIds && g.imageIds.length > 0) + .sort((a: GameDto, b: GameDto) => a.id - b.id) + .sort(() => rand.next() - 0.5); + } + return result; + }, + get randomlyOrderedGamesByCollectionId() { + const result: Record = {}; + for (const collectionId in this.gamesByCollectionId) { + const rand = new Rand(`collection-${collectionId}`); + result[collectionId] = this.gamesByCollectionId[collectionId] .sort((a: GameDto, b: GameDto) => a.id - b.id) .sort(() => rand.next() - 0.5); } diff --git a/app/src/main/frontend/state/LibraryState.ts b/app/src/main/frontend/state/LibraryState.ts index 778a1ad..2cb662e 100644 --- a/app/src/main/frontend/state/LibraryState.ts +++ b/app/src/main/frontend/state/LibraryState.ts @@ -23,8 +23,20 @@ export const libraryState = proxy({ }, get sorted() { return Object.values(this.state).sort((a, b) => { - if (a.name === undefined || b.name === undefined) return 0; - return a.name.localeCompare(b.name); + const orderA = a.metadata!.displayOrder; + const orderB = b.metadata!.displayOrder; + + // Handle -1 as "end of list" + const effectiveOrderA = orderA === -1 ? Number.MAX_SAFE_INTEGER : orderA; + const effectiveOrderB = orderB === -1 ? Number.MAX_SAFE_INTEGER : orderB; + + const orderDiff = effectiveOrderA - effectiveOrderB; + if (orderDiff !== 0) { + return orderDiff; + } + + // Fallback to creation date (newer first) + return new Date(a.createdAt!).getTime() - new Date(b.createdAt!).getTime(); }); } }); diff --git a/app/src/main/frontend/state/PluginState.ts b/app/src/main/frontend/state/PluginState.ts index aecc300..e74b6f9 100644 --- a/app/src/main/frontend/state/PluginState.ts +++ b/app/src/main/frontend/state/PluginState.ts @@ -9,7 +9,7 @@ type PluginState = { isLoaded: boolean; state: Record; plugins: PluginDto[]; - pluginsByType: Record; + sortedByType: Record; }; export const pluginState = proxy({ @@ -20,8 +20,8 @@ export const pluginState = proxy({ get plugins() { return Object.values(this.state); }, - get pluginsByType() { - return groupPluginsByType(this.state); + get sortedByType() { + return sortPluginsByType(this.state); } }); @@ -52,7 +52,7 @@ export async function initializePluginState() { /** Computed **/ -function groupPluginsByType(pluginsMap: Record): Record { +function sortPluginsByType(pluginsMap: Record): Record { const pluginsByType: Record = {}; // Convert map to array of plugins @@ -72,5 +72,10 @@ function groupPluginsByType(pluginsMap: Record): Record b.priority - a.priority); + } + return pluginsByType; } \ No newline at end of file diff --git a/app/src/main/frontend/util/middleware.ts b/app/src/main/frontend/util/middleware.ts index da9d8e6..d380b0b 100644 --- a/app/src/main/frontend/util/middleware.ts +++ b/app/src/main/frontend/util/middleware.ts @@ -1,6 +1,5 @@ import {Middleware, MiddlewareContext, MiddlewareNext} from '@vaadin/hilla-frontend'; import {addToast} from "@heroui/react"; -import {getReasonPhrase} from "http-status-codes"; export const ErrorHandlingMiddleware: Middleware = async function ( context: MiddlewareContext, @@ -22,13 +21,13 @@ export const ErrorHandlingMiddleware: Middleware = async function ( if (json.type == "dev.hilla.exception.EndpointException" || json.type == "com.vaadin.hilla.exception.EndpointException") { addToast({ - title: getReasonPhrase(response.status), + title: "Error", description: json.message, color: "danger" }) } else { addToast({ - title: getReasonPhrase(response.status), + title: "Error", description: `${endpoint}.${method}`, color: "danger" }) diff --git a/app/src/main/frontend/views/AdministrationView.tsx b/app/src/main/frontend/views/AdministrationView.tsx index f518897..3d21676 100644 --- a/app/src/main/frontend/views/AdministrationView.tsx +++ b/app/src/main/frontend/views/AdministrationView.tsx @@ -13,8 +13,8 @@ import withSideMenu, {MenuItem} from "Frontend/components/general/withSideMenu"; const menuItems: MenuItem[] = [ { - title: "Libraries", - url: "libraries", + title: "Games", + url: "games", icon: }, { @@ -33,8 +33,8 @@ const menuItems: MenuItem[] = [ icon: }, { - title: "SSO", - url: "sso", + title: "Security", + url: "security", icon: }, { diff --git a/app/src/main/frontend/views/CollectionManagementView.tsx b/app/src/main/frontend/views/CollectionManagementView.tsx new file mode 100644 index 0000000..c3fb60a --- /dev/null +++ b/app/src/main/frontend/views/CollectionManagementView.tsx @@ -0,0 +1,127 @@ +import {useNavigate, useParams} from "react-router"; +import React, {useEffect} from "react"; +import {addToast, Button} from "@heroui/react"; +import {ArrowLeftIcon, CheckIcon} from "@phosphor-icons/react"; +import {useSnapshot} from "valtio/react"; +import CollectionAdminDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionAdminDto"; +import {collectionState} from "Frontend/state/CollectionState"; +import {Form, Formik} from "formik"; +import * as Yup from "yup"; +import Input from "Frontend/components/general/input/Input"; +import Section from "Frontend/components/general/Section"; +import {deepDiff} from "Frontend/util/utils"; +import {CollectionEndpoint} from "Frontend/generated/endpoints"; +import CollectionUpdateDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionUpdateDto"; +import TextAreaInput from "Frontend/components/general/input/TextAreaInput"; +import CollectionHeader from "Frontend/components/general/covers/CollectionHeader"; +import CollectionGamesTable from "Frontend/components/general/modals/CollectionGamesTable"; +import CheckboxInput from "Frontend/components/general/input/CheckboxInput"; + + +export default function CollectionManagementView() { + const {collectionId} = useParams(); + const navigate = useNavigate(); + const [collectionSaved, setCollectionSaved] = React.useState(false); + const collections = useSnapshot(collectionState); + + // Parse and validate collectionId early + const collectionIdNum = collectionId ? parseInt(collectionId) : null; + + // Early return if invalid collection ID + useEffect(() => { + if (!collectionIdNum || (collections.isLoaded && !collections.state[collectionIdNum])) { + navigate("/administration/games"); + } + }, [collections, collectionIdNum, navigate]); + + // If collectionId is invalid, return null (will redirect via useEffect) + if (!collectionIdNum) { + return null; + } + + // At this point, collectionIdNum is guaranteed to be a number + const collection = collections.state[collectionIdNum] as CollectionAdminDto; + + async function handleSubmit(values: CollectionUpdateDto): Promise { + const changed = deepDiff(collection, values) as CollectionUpdateDto; + + if (Object.keys(changed).length === 0) return; + + changed.id = collection.id; + await CollectionEndpoint.updateCollection(changed); + setCollectionSaved(true); + setTimeout(() => setCollectionSaved(false), 2000); + } + + async function deleteCollection(): Promise { + try { + await CollectionEndpoint.deleteCollection(collection.id); + + addToast({ + title: "Collection deleted", + description: `Collection ${collection.name} deleted!`, + color: "success" + }); + + navigate("/administration/games"); + } catch (e) { + addToast({ + title: "Error deleting collection", + description: `Collection ${collection.name} could not be deleted!`, + color: "warning" + }); + } + } + + return collection && ( +
+
+ +

Manage Collection

+
+ + + {(formik) => ( + +
+

Edit collection details

+ +
+ + + + + +
+

Manage games in collection

+ +
+ +
+ + + )} + +
+ ); +} \ No newline at end of file diff --git a/app/src/main/frontend/views/CollectionView.tsx b/app/src/main/frontend/views/CollectionView.tsx new file mode 100644 index 0000000..5ab713b --- /dev/null +++ b/app/src/main/frontend/views/CollectionView.tsx @@ -0,0 +1,32 @@ +import {useSnapshot} from "valtio/react"; +import {gameState} from "Frontend/state/GameState"; +import React, {useEffect} from "react"; +import {useNavigate, useParams} from "react-router"; +import CoverGrid from "Frontend/components/general/covers/CoverGrid"; +import {collectionState} from "Frontend/state/CollectionState"; + +export default function CollectionView() { + const {collectionId} = useParams(); + const navigate = useNavigate(); + const collections = useSnapshot(collectionState); + const games = collectionId ? useSnapshot(gameState).gamesByCollectionId[parseInt(collectionId!)] || [] : []; + + useEffect(() => { + window.scrollTo(0, 0) + }, []) + + useEffect(() => { + if (collections.isLoaded && (!collectionId || !collections.state[parseInt(collectionId)])) { + navigate("/", {replace: true}); + } + document.title = collections.state[parseInt(collectionId!)]?.name || "Gameyfin"; + }, [collectionId, collections]); + + return ( +
+

{collections.state[parseInt(collectionId!)]?.name}

+ + {games.length === 0 &&

This collection is empty.

} +
+ ); +} \ No newline at end of file diff --git a/app/src/main/frontend/views/GameView.tsx b/app/src/main/frontend/views/GameView.tsx index 58ed853..21c0b92 100644 --- a/app/src/main/frontend/views/GameView.tsx +++ b/app/src/main/frontend/views/GameView.tsx @@ -24,8 +24,9 @@ import EditGameMetadataModal from "Frontend/components/general/modals/EditGameMe import GameUpdateDto from "Frontend/generated/org/gameyfin/app/games/dto/GameUpdateDto"; import Markdown from "react-markdown"; import remarkBreaks from "remark-breaks"; -import {GameAdminDto} from "Frontend/dtos/GameDtos"; import ChipList from "Frontend/components/general/ChipList"; +import {collectionState} from "Frontend/state/CollectionState"; +import {GameMetadataAdminDto} from "Frontend/dtos/GameDtos"; export default function GameView() { const {gameId} = useParams(); @@ -37,7 +38,8 @@ export default function GameView() { const matchGameModal = useDisclosure(); const state = useSnapshot(gameState); - const game = gameId ? state.state[parseInt(gameId)] as GameAdminDto : undefined; + const game = gameId ? state.state[parseInt(gameId)] : undefined; + const collections = useSnapshot(collectionState).state; const [downloadOptions, setDownloadOptions] = useState>(); @@ -69,7 +71,7 @@ export default function GameView() { await GameEndpoint.updateGame( { id: game.id, - metadata: {matchConfirmed: !game.metadata.matchConfirmed} + metadata: {matchConfirmed: !(game.metadata as GameMetadataAdminDto).matchConfirmed} } as GameUpdateDto ) } @@ -87,17 +89,17 @@ export default function GameView() { return game && (
- {game.headerId ? ( + {game.header?.id ? ( Game header - ) : game.imageIds && game.imageIds.length > 0 ? ( + ) : game.images && game.images.length > 0 ? ( Game screenshot ) : (
@@ -137,7 +139,7 @@ export default function GameView() {
{isAdmin(auth) &&
@@ -302,22 +323,24 @@ export default function GameView() {

Media

`/images/screenshot/${id}`)} + imageUrls={game.images?.map(image => `/images/screenshot/${image.id}`)} videosUrls={game.videoUrls} className="-mx-24" />
- - + {isAdmin(auth) && <> + + + }
); } \ No newline at end of file diff --git a/app/src/main/frontend/views/HomeView.tsx b/app/src/main/frontend/views/HomeView.tsx index 93f63bc..cad656f 100644 --- a/app/src/main/frontend/views/HomeView.tsx +++ b/app/src/main/frontend/views/HomeView.tsx @@ -1,26 +1,78 @@ -import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto"; import {CoverRow} from "Frontend/components/general/covers/CoverRow"; import {useSnapshot} from "valtio/react"; import {libraryState} from "Frontend/state/LibraryState"; import {gameState} from "Frontend/state/GameState"; -import {useNavigate} from "react-router"; +import React, {useEffect, useState} from "react"; +import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto"; +import {collectionState} from "Frontend/state/CollectionState"; +import CollectionDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionDto"; +import {StartPageDisplayCard} from "Frontend/components/general/cards/StartPageDisplayCard"; +import {Link} from "@heroui/react"; +import {CaretRightIcon} from "@phosphor-icons/react"; export default function HomeView() { - const navigate = useNavigate(); const librariesState = useSnapshot(libraryState); + const collectionsState = useSnapshot(collectionState); const gamesState = useSnapshot(gameState); - const recentlyAddedGames = gamesState.recentlyAdded as GameDto[]; - const gamesByLibrary = gamesState.gamesByLibraryId as Record; + const gamesByLibrary = gamesState.gamesByLibraryId; + const gamesByCollection = gamesState.gamesByCollectionId; + + const [filteredAndSortedLibraries, setFilteredAndSortedLibraries] = useState([]); + const [filteredAndSortedCollections, setFilteredAndSortedCollections] = useState([]); + + useEffect(() => { + const libraries = librariesState.sorted + .filter(library => library.metadata!.displayOnHomepage) + .filter(library => + gamesByLibrary[library.id] && gamesByLibrary[library.id].length > 0 + ); + + setFilteredAndSortedLibraries(libraries); + + const collections = collectionsState.sorted + .filter(collection => collection.metadata!.displayOnHomepage) + .filter(collection => + gamesByCollection[collection.id] && gamesByCollection[collection.id].length > 0 + ); + + setFilteredAndSortedCollections(collections); + + }, [librariesState.sorted, collectionsState.sorted, gamesByLibrary, gamesByCollection]); return (
-
- navigate("/recently-added")}/> - {librariesState.libraries.map((library) => ( +
+ {(filteredAndSortedLibraries.length + filteredAndSortedCollections.length > 0) && +
+ +

Your games

+ + +
+ {filteredAndSortedLibraries.length > 0 && + filteredAndSortedLibraries.map((library: LibraryDto) => ( + + )) + } + {filteredAndSortedCollections.length > 0 && + filteredAndSortedCollections.map((collection: CollectionDto) => ( + + )) + } +
+
+ } + {filteredAndSortedLibraries.map((library) => ( navigate("/library/" + library.id)} + link={"/library/" + library.id} + /> + ))} + {filteredAndSortedCollections.map((collection) => ( + ))}
diff --git a/app/src/main/frontend/views/LibraryManagementView.tsx b/app/src/main/frontend/views/LibraryManagementView.tsx index 6e084aa..1951a24 100644 --- a/app/src/main/frontend/views/LibraryManagementView.tsx +++ b/app/src/main/frontend/views/LibraryManagementView.tsx @@ -19,18 +19,18 @@ export default function LibraryManagementView() { useEffect(() => { if (state.isLoaded && (!libraryId || !state.state[parseInt(libraryId)])) { - navigate("/administration/libraries"); + navigate("/administration/games"); } }, [state, libraryId]); return libraryId && state.state[parseInt(libraryId)] &&
-

Manage library

- + 0 ? hash : "#details"} onSelectionChange={(newKey) => navigate(newKey.toString(), {replace: true})}> diff --git a/app/src/main/frontend/views/LibraryView.tsx b/app/src/main/frontend/views/LibraryView.tsx index 597d7e1..ef89b31 100644 --- a/app/src/main/frontend/views/LibraryView.tsx +++ b/app/src/main/frontend/views/LibraryView.tsx @@ -4,25 +4,29 @@ import {gameState} from "Frontend/state/GameState"; import React, {useEffect} from "react"; import {useNavigate, useParams} from "react-router"; import CoverGrid from "Frontend/components/general/covers/CoverGrid"; -import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto"; export default function LibraryView() { const {libraryId} = useParams(); const navigate = useNavigate(); const libraries = useSnapshot(libraryState); - const games = (libraryId ? useSnapshot(gameState).gamesByLibraryId[parseInt(libraryId!!)] || [] : []) as GameDto[]; + const games = useSnapshot(gameState).gamesByLibraryId[parseInt(libraryId!)] || []; + + useEffect(() => { + window.scrollTo(0, 0) + }, []) useEffect(() => { if (libraries.isLoaded && (!libraryId || !libraries.state[parseInt(libraryId)])) { navigate("/", {replace: true}); } - document.title = libraries.state[parseInt(libraryId!!)]?.name || "Gameyfin"; + document.title = libraries.state[parseInt(libraryId!)]?.name || "Gameyfin"; }, [libraryId, libraries]); return (
-

{libraries.state[parseInt(libraryId!!)]?.name}

+

{libraries.state[parseInt(libraryId!)]?.name}

+ {games.length === 0 &&

This library is empty.

}
); } \ No newline at end of file diff --git a/app/src/main/frontend/views/RecentlyAddedView.tsx b/app/src/main/frontend/views/RecentlyAddedView.tsx deleted file mode 100644 index 8a02161..0000000 --- a/app/src/main/frontend/views/RecentlyAddedView.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import {useSnapshot} from "valtio/react"; -import {gameState} from "Frontend/state/GameState"; -import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto"; -import React from "react"; -import CoverGrid from "Frontend/components/general/covers/CoverGrid"; - -export default function RecentlyAddedView() { - const games = useSnapshot(gameState).recentlyAdded as GameDto[]; - - return ( -
-

Recently added

- -
- ); -} \ No newline at end of file diff --git a/app/src/main/frontend/views/SearchView.tsx b/app/src/main/frontend/views/SearchView.tsx index 7d8f67a..73c9d1e 100644 --- a/app/src/main/frontend/views/SearchView.tsx +++ b/app/src/main/frontend/views/SearchView.tsx @@ -13,19 +13,18 @@ import {useSearchParams} from "react-router"; import React, {useEffect, useMemo, useState} from "react"; import {Fzf} from "fzf"; import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto"; -import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto"; import CoverGrid from "Frontend/components/general/covers/CoverGrid"; import {compoundRating} from "Frontend/util/utils"; export default function SearchView() { - const games = useSnapshot(gameState).sortedAlphabetically as GameDto[]; - const knownDevelopers = useSnapshot(gameState).knownDevelopers as Set; + const games = useSnapshot(gameState).sortedAlphabetically; + const knownDevelopers = useSnapshot(gameState).knownDevelopers; const knownGenres = useSnapshot(gameState).knownGenres; const knownThemes = useSnapshot(gameState).knownThemes; const knownFeatures = useSnapshot(gameState).knownFeatures; const knownPerspectives = useSnapshot(gameState).knownPerspectives; const knownKeywords = useSnapshot(gameState).knownKeywords; - const libraries = useSnapshot(libraryState).libraries as LibraryDto[]; + const libraries = useSnapshot(libraryState).libraries; const [searchParams, setSearchParams] = useSearchParams(); const [initialLoadComplete, setInitialLoadComplete] = useState(false); @@ -46,6 +45,9 @@ export default function SearchView() { // Load initial filter values from URL parameters on component mount useEffect(() => { + // Scroll to top on load + window.scrollTo(0, 0) + // Get all parameters from the URL const term = searchParams.get("term") || ""; const libs = searchParams.getAll("lib"); diff --git a/app/src/main/kotlin/org/gameyfin/app/collections/CollectionEndpoint.kt b/app/src/main/kotlin/org/gameyfin/app/collections/CollectionEndpoint.kt new file mode 100644 index 0000000..07320c5 --- /dev/null +++ b/app/src/main/kotlin/org/gameyfin/app/collections/CollectionEndpoint.kt @@ -0,0 +1,62 @@ +package org.gameyfin.app.collections + +import com.vaadin.flow.server.auth.AnonymousAllowed +import com.vaadin.hilla.Endpoint +import jakarta.annotation.security.RolesAllowed +import org.gameyfin.app.collections.dto.* +import org.gameyfin.app.collections.extensions.toAdminDto +import org.gameyfin.app.collections.extensions.toDto +import org.gameyfin.app.collections.extensions.toUserDto +import org.gameyfin.app.core.Role +import org.gameyfin.app.core.annotations.DynamicPublicAccess +import org.gameyfin.app.core.security.isCurrentUserAdmin +import reactor.core.publisher.Flux + +@Endpoint +@DynamicPublicAccess +@AnonymousAllowed +class CollectionEndpoint( + private val collectionService: CollectionService +) { + fun subscribeToCollectionEvents(): Flux> { + return if (isCurrentUserAdmin()) { + CollectionService.subscribeAdmin() + } else { + CollectionService.subscribeUser() + } + } + + fun getAll(): List = collectionService.getAll() + + fun getById(id: Long): CollectionDto = collectionService.getById(id).toDto() + + @RolesAllowed(Role.Names.ADMIN) + fun createCollection(dto: CollectionCreateDto) = collectionService.create(dto) + + @RolesAllowed(Role.Names.ADMIN) + fun updateCollection(dto: CollectionUpdateDto) = collectionService.update(dto) + + @RolesAllowed(Role.Names.ADMIN) + fun updateCollections(collections: List) = collectionService.update(collections) + + @RolesAllowed(Role.Names.ADMIN) + fun addGameToCollection(collectionId: Long, gameId: Long) = + collectionService.addGame(collectionId, gameId) + + @RolesAllowed(Role.Names.ADMIN) + fun removeGameFromCollection(collectionId: Long, gameId: Long) = + collectionService.removeGame(collectionId, gameId) + + @RolesAllowed(Role.Names.ADMIN) + fun deleteCollection(collectionId: Long) = collectionService.delete(collectionId) + + /* Unused endpoints for Hilla to generate typescript classes */ + + @Suppress("Unused", "FunctionName") + @RolesAllowed(Role.Names.ADMIN) + fun _getAdminDto(id: Long): CollectionAdminDto = collectionService.getById(id).toAdminDto() + + @Suppress("Unused", "FunctionName") + @RolesAllowed(Role.Names.ADMIN) + fun _getUserDto(id: Long): CollectionUserDto = collectionService.getById(id).toUserDto() +} diff --git a/app/src/main/kotlin/org/gameyfin/app/collections/CollectionService.kt b/app/src/main/kotlin/org/gameyfin/app/collections/CollectionService.kt new file mode 100644 index 0000000..40c7224 --- /dev/null +++ b/app/src/main/kotlin/org/gameyfin/app/collections/CollectionService.kt @@ -0,0 +1,158 @@ +package org.gameyfin.app.collections + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gameyfin.app.collections.dto.* +import org.gameyfin.app.collections.entities.Collection +import org.gameyfin.app.collections.entities.CollectionMetadata +import org.gameyfin.app.collections.extensions.toDto +import org.gameyfin.app.collections.extensions.toEntity +import org.gameyfin.app.collections.repositories.CollectionRepository +import org.gameyfin.app.games.GameService +import org.gameyfin.app.games.entities.Game +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import reactor.core.publisher.Flux +import reactor.core.publisher.Sinks +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.toJavaDuration + +@Service +class CollectionService( + private val collectionRepository: CollectionRepository, + private val gameService: GameService +) { + companion object { + private val log = KotlinLogging.logger {} + + private val collectionUserEvents = + Sinks.many().multicast().onBackpressureBuffer(1024, false) + private val collectionAdminEvents = + Sinks.many().multicast().onBackpressureBuffer(1024, false) + + fun subscribeUser(): Flux> { + log.debug { "New user subscription for collectionUserEvents" } + return collectionUserEvents.asFlux() + .buffer(100.milliseconds.toJavaDuration()) + .doOnSubscribe { + log.debug { "Subscriber added to user collectionUserEvents [${collectionUserEvents.currentSubscriberCount()}]" } + } + .doFinally { + log.debug { "Subscriber removed from user collectionUserEvents with signal type $it [${collectionUserEvents.currentSubscriberCount()}]" } + } + } + + fun subscribeAdmin(): Flux> { + log.debug { "New admin subscription for collectionAdminEvents" } + return collectionAdminEvents.asFlux() + .buffer(100.milliseconds.toJavaDuration()) + .doOnSubscribe { + log.debug { "Subscriber added to admin collectionAdminEvents [${collectionAdminEvents.currentSubscriberCount()}]" } + } + .doFinally { + log.debug { "Subscriber removed from admin collectionAdminEvents with signal type $it [${collectionAdminEvents.currentSubscriberCount()}]" } + } + } + + fun emitUser(event: CollectionUserEvent) { + collectionUserEvents.tryEmitNext(event) + } + + fun emitAdmin(event: CollectionAdminEvent) { + collectionAdminEvents.tryEmitNext(event) + } + } + + fun getAll(): List = collectionRepository.findAll().map { it.toDto() } + + fun getById(id: Long): Collection = collectionRepository.findByIdOrNull(id) + ?: throw IllegalArgumentException("Collection with id $id not found") + + @Transactional + fun create(dto: CollectionCreateDto) { + if (collectionRepository.findByName(dto.name) != null) { + throw IllegalArgumentException("Collection with name '${dto.name}' already exists") + } + val entity = dto.toEntity() + dto.gameIds?.let { ids -> + ids.distinct().forEach { gameId -> + val game = gameService.getById(gameId) + entity.addGame(game) + } + } + collectionRepository.save(entity) + } + + @Transactional + fun update(dto: CollectionUpdateDto): CollectionDto { + val collection = getById(dto.id) + dto.name?.let { newName -> + if (newName != collection.name && collectionRepository.findByName(newName) != null) { + throw IllegalArgumentException("Collection with name '$newName' already exists") + } + collection.name = newName + } + dto.description?.let { collection.description = it } + dto.gameIds?.let { ids -> + // Replace entire set of games + val newGames: MutableList = mutableListOf() + ids.distinct().forEach { gameId -> + val game = gameService.getById(gameId) + newGames.add(game) + } + // Remove old backrefs + collection.games.forEach { it.collections.remove(collection) } + collection.games.clear() + newGames.forEach { collection.addGame(it) } + } + dto.metadata?.let { + collection.metadata = CollectionMetadata( + it.displayOnHomepage ?: collection.metadata.displayOnHomepage, + it.displayOrder ?: collection.metadata.displayOrder, + collection.metadata.gamesAddedAt + ) + } + + val saved = collectionRepository.save(collection) + + return saved.toDto() + } + + /** + * Updates multiple collections in the repository. + */ + @Transactional + fun update(collections: List) { + collections.forEach { update(it) } + } + + @Transactional + fun addGame(collectionId: Long, gameId: Long): CollectionDto { + val collection = getById(collectionId) + val game = gameService.getById(gameId) + + collection.addGame(game) + gameService.update(game) + + val saved = collectionRepository.save(collection) + + return saved.toDto() + } + + @Transactional + fun removeGame(collectionId: Long, gameId: Long): CollectionDto { + val collection = getById(collectionId) + val game = gameService.getById(gameId) + + collection.removeGame(game) + gameService.update(game) + + val saved = collectionRepository.save(collection) + + return saved.toDto() + } + + fun delete(collectionId: Long) { + collectionRepository.deleteById(collectionId) + } +} diff --git a/app/src/main/kotlin/org/gameyfin/app/collections/dto/CollectionDto.kt b/app/src/main/kotlin/org/gameyfin/app/collections/dto/CollectionDto.kt new file mode 100644 index 0000000..d9a8fe5 --- /dev/null +++ b/app/src/main/kotlin/org/gameyfin/app/collections/dto/CollectionDto.kt @@ -0,0 +1,62 @@ +package org.gameyfin.app.collections.dto + +import com.fasterxml.jackson.annotation.JsonInclude +import org.gameyfin.pluginapi.gamemetadata.Platform +import java.time.Instant + +interface CollectionDto { + val id: Long + val createdAt: Instant + val updatedAt: Instant + val name: String + val description: String? + val gameIds: List? + val metadata: CollectionMetadataDto? +} + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class CollectionUserDto( + override val id: Long, + override val createdAt: Instant, + override val updatedAt: Instant, + override val name: String, + override val description: String?, + override val gameIds: List = emptyList(), + override val metadata: CollectionMetadataDto?, +) : CollectionDto + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class CollectionAdminDto( + override val id: Long, + override val createdAt: Instant, + override val updatedAt: Instant, + override val name: String, + override val description: String?, + override val gameIds: List = emptyList(), + override val metadata: CollectionMetadataDto?, + val stats: CollectionStatsDto?, +) : CollectionDto + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class CollectionStatsDto( + val gamesCount: Int, + val downloadCount: Int, + val gamePlatforms: Set +) + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class CollectionCreateDto( + val name: String, + val description: String? = null, + val gameIds: List? = null +) + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class CollectionUpdateDto( + val id: Long, + val name: String? = null, + val description: String? = null, + val gameIds: List? = null, + val metadata: CollectionMetadataUpdateDto? = null +) + diff --git a/app/src/main/kotlin/org/gameyfin/app/collections/dto/CollectionEvents.kt b/app/src/main/kotlin/org/gameyfin/app/collections/dto/CollectionEvents.kt new file mode 100644 index 0000000..36a5a18 --- /dev/null +++ b/app/src/main/kotlin/org/gameyfin/app/collections/dto/CollectionEvents.kt @@ -0,0 +1,22 @@ +package org.gameyfin.app.collections.dto + +sealed interface CollectionEvent { + val type: String +} + +sealed class CollectionUserEvent : CollectionEvent { + data class Created(val collection: CollectionUserDto, override val type: String = "created") : CollectionUserEvent() + data class Updated(val collection: CollectionUserDto, override val type: String = "updated") : CollectionUserEvent() + data class Deleted(val collectionId: Long, override val type: String = "deleted") : CollectionUserEvent() +} + +sealed class CollectionAdminEvent : CollectionEvent { + data class Created(val collection: CollectionAdminDto, override val type: String = "created") : + CollectionAdminEvent() + + data class Updated(val collection: CollectionAdminDto, override val type: String = "updated") : + CollectionAdminEvent() + + data class Deleted(val collectionId: Long, override val type: String = "deleted") : CollectionAdminEvent() +} + diff --git a/app/src/main/kotlin/org/gameyfin/app/collections/dto/CollectionMetadataDto.kt b/app/src/main/kotlin/org/gameyfin/app/collections/dto/CollectionMetadataDto.kt new file mode 100644 index 0000000..0d200dd --- /dev/null +++ b/app/src/main/kotlin/org/gameyfin/app/collections/dto/CollectionMetadataDto.kt @@ -0,0 +1,14 @@ +package org.gameyfin.app.collections.dto + +import java.time.Instant + +data class CollectionMetadataDto( + val displayOnHomepage: Boolean, + val displayOrder: Int, + val gamesAddedAt: Map +) + +data class CollectionMetadataUpdateDto( + val displayOnHomepage: Boolean?, + val displayOrder: Int? +) \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/collections/entities/Collection.kt b/app/src/main/kotlin/org/gameyfin/app/collections/entities/Collection.kt new file mode 100644 index 0000000..bedce71 --- /dev/null +++ b/app/src/main/kotlin/org/gameyfin/app/collections/entities/Collection.kt @@ -0,0 +1,59 @@ +package org.gameyfin.app.collections.entities + +import jakarta.persistence.* +import org.gameyfin.app.games.entities.Game +import org.hibernate.annotations.CreationTimestamp +import org.hibernate.annotations.UpdateTimestamp +import java.time.Instant + +@Entity +@EntityListeners(CollectionEntityListener::class) +class Collection( + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + var id: Long? = null, + + @CreationTimestamp + @Column(nullable = false, updatable = false) + var createdAt: Instant? = null, + + @UpdateTimestamp + @Column(nullable = false) + var updatedAt: Instant? = null, + + @Column(nullable = false, unique = true) + var name: String, + + @Lob + var description: String? = null, + + @ManyToMany(fetch = FetchType.EAGER) + var games: MutableSet = mutableSetOf(), + + @Embedded + var metadata: CollectionMetadata = CollectionMetadata() +) { + fun addGame(game: Game) { + games.add(game) + if (!game.collections.contains(this)) { + game.collections.add(this) + } + // Track when the game was added + game.id?.let { gameId -> + metadata.gamesAddedAt[gameId] = Instant.now() + } + // Force update to trigger @PostUpdate callback + updatedAt = Instant.now() + } + + fun removeGame(game: Game) { + games.remove(game) + game.collections.remove(this) + // Remove the timestamp tracking for this game + game.id?.let { gameId -> + metadata.gamesAddedAt.remove(gameId) + } + // Force update to trigger @PostUpdate callback + updatedAt = Instant.now() + } +} diff --git a/app/src/main/kotlin/org/gameyfin/app/collections/entities/CollectionEntityListener.kt b/app/src/main/kotlin/org/gameyfin/app/collections/entities/CollectionEntityListener.kt new file mode 100644 index 0000000..b1036eb --- /dev/null +++ b/app/src/main/kotlin/org/gameyfin/app/collections/entities/CollectionEntityListener.kt @@ -0,0 +1,37 @@ +package org.gameyfin.app.collections.entities + +import jakarta.persistence.PostPersist +import jakarta.persistence.PostRemove +import jakarta.persistence.PostUpdate +import org.gameyfin.app.collections.CollectionService +import org.gameyfin.app.collections.dto.CollectionAdminEvent +import org.gameyfin.app.collections.dto.CollectionUserEvent +import org.gameyfin.app.collections.extensions.toAdminDto +import org.gameyfin.app.collections.extensions.toUserDto +import org.gameyfin.app.core.events.CollectionCreatedEvent +import org.gameyfin.app.core.events.CollectionDeletedEvent +import org.gameyfin.app.core.events.CollectionUpdatedEvent +import org.gameyfin.app.util.EventPublisherHolder + +class CollectionEntityListener { + @PostPersist + fun created(collection: Collection) { + CollectionService.emitUser(CollectionUserEvent.Created(collection.toUserDto())) + CollectionService.emitAdmin(CollectionAdminEvent.Created(collection.toAdminDto())) + EventPublisherHolder.publish(CollectionCreatedEvent(this, collection)) + } + + @PostUpdate + fun updated(collection: Collection) { + CollectionService.emitUser(CollectionUserEvent.Updated(collection.toUserDto())) + CollectionService.emitAdmin(CollectionAdminEvent.Updated(collection.toAdminDto())) + EventPublisherHolder.publish(CollectionUpdatedEvent(this, collection)) + } + + @PostRemove + fun deleted(collection: Collection) { + CollectionService.emitUser(CollectionUserEvent.Deleted(collection.id!!)) + CollectionService.emitAdmin(CollectionAdminEvent.Deleted(collection.id!!)) + EventPublisherHolder.publish(CollectionDeletedEvent(this, collection)) + } +} diff --git a/app/src/main/kotlin/org/gameyfin/app/collections/entities/CollectionMetadata.kt b/app/src/main/kotlin/org/gameyfin/app/collections/entities/CollectionMetadata.kt new file mode 100644 index 0000000..c9de46c --- /dev/null +++ b/app/src/main/kotlin/org/gameyfin/app/collections/entities/CollectionMetadata.kt @@ -0,0 +1,15 @@ +package org.gameyfin.app.collections.entities + +import jakarta.persistence.ElementCollection +import jakarta.persistence.Embeddable +import jakarta.persistence.FetchType +import java.time.Instant + +@Embeddable +class CollectionMetadata( + val displayOnHomepage: Boolean = true, + val displayOrder: Int = -1, + + @ElementCollection(fetch = FetchType.EAGER) + val gamesAddedAt: MutableMap = mutableMapOf() +) \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/collections/extensions/CollectionExtensions.kt b/app/src/main/kotlin/org/gameyfin/app/collections/extensions/CollectionExtensions.kt new file mode 100644 index 0000000..d1ca8a0 --- /dev/null +++ b/app/src/main/kotlin/org/gameyfin/app/collections/extensions/CollectionExtensions.kt @@ -0,0 +1,54 @@ +package org.gameyfin.app.collections.extensions + +import org.gameyfin.app.collections.dto.* +import org.gameyfin.app.collections.entities.Collection +import org.gameyfin.app.collections.entities.CollectionMetadata +import org.gameyfin.app.core.security.isCurrentUserAdmin + +fun Collection.toDto(): CollectionDto = if (isCurrentUserAdmin()) this.toAdminDto() else this.toUserDto() + +fun Collection.toAdminDto(): CollectionAdminDto = CollectionAdminDto( + id = id!!, + createdAt = createdAt!!, + updatedAt = updatedAt!!, + name = name, + description = description, + gameIds = games.mapNotNull { it.id }, + metadata = this.metadata.toDto(), + stats = CollectionStatsDto( + gamesCount = games.size, + downloadCount = games.sumOf { it.metadata.downloadCount }, + gamePlatforms = games.flatMap { it.platforms }.toSet() + ) +) + +fun Collection.toUserDto(): CollectionUserDto = CollectionUserDto( + id = id!!, + createdAt = createdAt!!, + updatedAt = updatedAt!!, + name = name, + description = description, + gameIds = games.mapNotNull { it.id }, + metadata = this.metadata.toDto() +) + +fun CollectionCreateDto.toEntity(): Collection = Collection( + name = name, + description = description +) + +fun CollectionMetadata.toDto(): CollectionMetadataDto { + return CollectionMetadataDto( + displayOnHomepage = this.displayOnHomepage, + displayOrder = this.displayOrder, + gamesAddedAt = this.gamesAddedAt.toMap() + ) +} + +fun CollectionMetadataDto.toEntity(): CollectionMetadata { + return CollectionMetadata( + displayOnHomepage = this.displayOnHomepage, + displayOrder = this.displayOrder, + gamesAddedAt = this.gamesAddedAt.toMutableMap() + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/collections/repositories/CollectionRepository.kt b/app/src/main/kotlin/org/gameyfin/app/collections/repositories/CollectionRepository.kt new file mode 100644 index 0000000..f43688e --- /dev/null +++ b/app/src/main/kotlin/org/gameyfin/app/collections/repositories/CollectionRepository.kt @@ -0,0 +1,9 @@ +package org.gameyfin.app.collections.repositories + +import org.gameyfin.app.collections.entities.Collection +import org.springframework.data.jpa.repository.JpaRepository + +interface CollectionRepository : JpaRepository { + fun findByName(name: String): Collection? +} + diff --git a/app/src/main/kotlin/org/gameyfin/app/config/ConfigProperties.kt b/app/src/main/kotlin/org/gameyfin/app/config/ConfigProperties.kt index 1af62b9..6b130ce 100644 --- a/app/src/main/kotlin/org/gameyfin/app/config/ConfigProperties.kt +++ b/app/src/main/kotlin/org/gameyfin/app/config/ConfigProperties.kt @@ -15,20 +15,23 @@ sealed class ConfigProperties( val step: Number? = null ) { - /** Libraries */ - sealed class Libraries { + /** Security */ + sealed class Security { data object AllowPublicAccess : ConfigProperties( Boolean::class, - "library.allow-public-access", + "security.allow-public-access", "Allow access to Gameyfin without login", false ) + } + /** Libraries */ + sealed class Libraries { sealed class Scan { data object EnableFilesystemWatcher : ConfigProperties( Boolean::class, "library.scan.enable-filesystem-watcher", - "Enable automatic library scanning using file system watchers (coming soonâ„¢)", + "Enable automatic library scanning using file system watchers", false ) @@ -189,13 +192,6 @@ sealed class ConfigProperties( MatchUsersBy.entries ) - data object AutoRegisterNewUsers : ConfigProperties( - Boolean::class, - "sso.oidc.auto-register-new-users", - "Automatically create new users after registration", - true - ) - data object RolesClaim : ConfigProperties( String::class, "sso.oidc.roles-claim", diff --git a/app/src/main/kotlin/org/gameyfin/app/config/ConfigService.kt b/app/src/main/kotlin/org/gameyfin/app/config/ConfigService.kt index 72f4114..e681be8 100644 --- a/app/src/main/kotlin/org/gameyfin/app/config/ConfigService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/config/ConfigService.kt @@ -1,5 +1,7 @@ package org.gameyfin.app.config +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.ObjectMapper import io.github.oshai.kotlinlogging.KotlinLogging import org.gameyfin.app.config.dto.ConfigEntryDto import org.gameyfin.app.config.dto.ConfigUpdateDto @@ -15,7 +17,8 @@ import kotlin.time.toJavaDuration @Service class ConfigService( - private val appConfigRepository: ConfigRepository + private val appConfigRepository: ConfigRepository, + private val objectMapper: ObjectMapper ) { companion object { private val log = KotlinLogging.logger {} @@ -50,7 +53,7 @@ class ConfigService( val appConfig = appConfigRepository.findByIdOrNull(configProperty.key) return if (appConfig != null) { - getValue(appConfig.value, configProperty) + deserializeValue(appConfig.value, configProperty) } else { configProperty.default ?: return null } @@ -101,6 +104,18 @@ class ConfigService( } } + /** + * Set the value for a specified key in a type-safe way. + * + * @param configProperty: The target config property + * @param value: Value to set the config property to + * @throws IllegalArgumentException if the value can't be cast to the type defined for the config property + */ + fun set(configProperty: ConfigProperties, value: T) { + return set(configProperty.key, value) + } + + /** * Set the value for a specified key. * Checks if the value can be cast to the type defined for the config property. @@ -117,16 +132,12 @@ class ConfigService( var configEntry = appConfigRepository.findByIdOrNull(key) - val parsedValue = - if (value.javaClass.isArray) { - (value as Array).joinToString(",") - } else - value.toString() + val serializedValue = serializeValue(value, key) if (configEntry == null) { - configEntry = ConfigEntry(configProperty.key, parsedValue) + configEntry = ConfigEntry(configProperty.key, serializedValue) } else { - configEntry.value = parsedValue + configEntry.value = serializedValue } appConfigRepository.save(configEntry) @@ -149,17 +160,6 @@ class ConfigService( emit(update) } - /** - * Set the value for a specified key in a type-safe way. - * - * @param configProperty: The target config property - * @param value: Value to set the config property to - * @throws IllegalArgumentException if the value can't be cast to the type defined for the config property - */ - fun set(configProperty: ConfigProperties, value: T) { - return set(configProperty.key, value) - } - /** * Remove a config property from the database. * This will also cause it to reset to its default value. @@ -175,41 +175,45 @@ class ConfigService( } /** - * Get the value of the config property in a type-safe way. + * Deserialize a value from the database to its proper type. + * + * @param value: The serialized value from the database + * @param configProperty: The config property containing type information + * @return The deserialized value */ @Suppress("UNCHECKED_CAST") - private fun getValue(value: Serializable, configProperty: ConfigProperties): T { - val value = value.toString() - return when { - configProperty.type == String::class -> value as T - configProperty.type == Boolean::class -> value.toBoolean() as T - configProperty.type == Int::class -> value.toFloat().toInt() as T - configProperty.type == Float::class -> value.toFloat() as T + private fun deserializeValue(value: Serializable, configProperty: ConfigProperties): T { + return try { + val typeReference = objectMapper.typeFactory.constructType(configProperty.type.java) + objectMapper.readValue(value.toString(), typeReference) as T + } catch (e: JsonProcessingException) { + throw IllegalArgumentException( + "Failed to deserialize value '$value' for key '${configProperty.key}' to type ${configProperty.type.simpleName}: ${e.message}", + e + ) + } catch (e: Exception) { + throw IllegalArgumentException( + "Failed to deserialize value '$value' for key '${configProperty.key}' to type ${configProperty.type.simpleName}: ${e.message}", + e + ) + } + } - configProperty.type.java.isEnum -> { - val enumConstants = configProperty.type.java.enumConstants - enumConstants.firstOrNull { it.toString() == value } - ?: throw IllegalArgumentException("Unknown enum value '$value' for key ${configProperty.key}") - } - - configProperty.type.java.isArray -> { - val componentType = configProperty.type.java.componentType - // Remove the brackets and split the string by commas - val elements = value - .removeSurrounding("[", "]") - .split(",") - .filter { it.isNotBlank() } - - when (componentType) { - String::class.java -> elements.toTypedArray() as T - Boolean::class.java -> elements.map { it.toBoolean() }.toTypedArray() as T - Int::class.java -> elements.map { it.toInt() }.toTypedArray() as T - Float::class.java -> elements.map { it.toFloat() }.toTypedArray() as T - else -> throw IllegalArgumentException("Unsupported array type: ${componentType.name}") - } - } - - else -> throw IllegalArgumentException("Unknown config type ${configProperty.type}: '$value' for key ${configProperty.key}") + /** + * Serialize a value to be stored in the database. + * + * @param value: The value to serialize + * @param key: The config key (for error messages) + * @return The serialized value as a string + */ + private fun serializeValue(value: T, key: String): String { + return try { + objectMapper.writeValueAsString(value) + } catch (e: JsonProcessingException) { + throw IllegalArgumentException( + "Failed to serialize value for key '$key': ${e.message}", + e + ) } } diff --git a/app/src/main/kotlin/org/gameyfin/app/config/entities/ConfigEntryEntityListener.kt b/app/src/main/kotlin/org/gameyfin/app/config/entities/ConfigEntryEntityListener.kt index a35958e..112acab 100644 --- a/app/src/main/kotlin/org/gameyfin/app/config/entities/ConfigEntryEntityListener.kt +++ b/app/src/main/kotlin/org/gameyfin/app/config/entities/ConfigEntryEntityListener.kt @@ -4,6 +4,7 @@ import jakarta.persistence.PostPersist import jakarta.persistence.PostRemove import jakarta.persistence.PostUpdate import org.gameyfin.app.config.ConfigProperties +import org.gameyfin.app.core.events.LibraryFilesystemWatcherConfigUpdatedEvent import org.gameyfin.app.core.events.LibraryScanScheduleUpdatedEvent import org.gameyfin.app.util.EventPublisherHolder @@ -19,7 +20,12 @@ class ConfigEntryEntityListener { } ConfigProperties.Libraries.Scan.EnableFilesystemWatcher.key -> { - TODO() + EventPublisherHolder.publish( + LibraryFilesystemWatcherConfigUpdatedEvent( + this, + configEntry.value.toBoolean() + ) + ) } } } diff --git a/app/src/main/kotlin/org/gameyfin/app/core/annotations/DynamicAccessInterceptor.kt b/app/src/main/kotlin/org/gameyfin/app/core/annotations/DynamicAccessInterceptor.kt index 8dc4e05..f4372e9 100644 --- a/app/src/main/kotlin/org/gameyfin/app/core/annotations/DynamicAccessInterceptor.kt +++ b/app/src/main/kotlin/org/gameyfin/app/core/annotations/DynamicAccessInterceptor.kt @@ -27,7 +27,7 @@ class DynamicAccessInterceptor( clazz.isAnnotationPresent(DynamicPublicAccess::class.java) if (hasDynamicPublicAccess) { - if (request.userPrincipal != null || config.get(ConfigProperties.Libraries.AllowPublicAccess) == true) { + if (request.userPrincipal != null || config.get(ConfigProperties.Security.AllowPublicAccess) == true) { return true } response.status = HttpServletResponse.SC_UNAUTHORIZED diff --git a/app/src/main/kotlin/org/gameyfin/app/core/events/Events.kt b/app/src/main/kotlin/org/gameyfin/app/core/events/Events.kt index decf6eb..0907c9e 100644 --- a/app/src/main/kotlin/org/gameyfin/app/core/events/Events.kt +++ b/app/src/main/kotlin/org/gameyfin/app/core/events/Events.kt @@ -1,5 +1,6 @@ package org.gameyfin.app.core.events +import org.gameyfin.app.collections.entities.Collection import org.gameyfin.app.core.token.Token import org.gameyfin.app.core.token.TokenType import org.gameyfin.app.games.entities.Game @@ -24,6 +25,7 @@ class PasswordResetRequestEvent(source: Any, val token: Token val title: String val platforms: List - val coverId: Long? - val headerId: Long? + val cover: ImageDto? + val header: ImageDto? val comment: String? val summary: String? val release: LocalDate? @@ -26,7 +28,7 @@ sealed interface GameDto { val keywords: List? val features: List? val perspectives: List? - val imageIds: List? + val images: List? val videoUrls: List? val metadata: GameMetadataDto } @@ -37,10 +39,11 @@ data class GameUserDto( override val createdAt: Instant, override val updatedAt: Instant, override val libraryId: Long, + override val collectionIds: List, override val title: String, override val platforms: List, - override val coverId: Long?, - override val headerId: Long?, + override val cover: ImageDto?, + override val header: ImageDto?, override val comment: String?, override val summary: String?, override val release: LocalDate?, @@ -53,7 +56,7 @@ data class GameUserDto( override val keywords: List?, override val features: List?, override val perspectives: List?, - override val imageIds: List?, + override val images: List?, override val videoUrls: List?, override val metadata: GameMetadataUserDto ) : GameDto @@ -64,10 +67,11 @@ data class GameAdminDto( override val createdAt: Instant, override val updatedAt: Instant, override val libraryId: Long, + override val collectionIds: List, override val title: String, override val platforms: List, - override val coverId: Long?, - override val headerId: Long?, + override val cover: ImageDto?, + override val header: ImageDto?, override val comment: String?, override val summary: String?, override val release: LocalDate?, @@ -80,7 +84,7 @@ data class GameAdminDto( override val keywords: List?, override val features: List?, override val perspectives: List?, - override val imageIds: List?, + override val images: List?, override val videoUrls: List?, override val metadata: GameMetadataAdminDto ) : GameDto diff --git a/app/src/main/kotlin/org/gameyfin/app/games/entities/Game.kt b/app/src/main/kotlin/org/gameyfin/app/games/entities/Game.kt index 63d4cfc..6cc67db 100644 --- a/app/src/main/kotlin/org/gameyfin/app/games/entities/Game.kt +++ b/app/src/main/kotlin/org/gameyfin/app/games/entities/Game.kt @@ -2,7 +2,9 @@ package org.gameyfin.app.games.entities import jakarta.persistence.* import jakarta.persistence.CascadeType.* +import org.gameyfin.app.collections.entities.Collection import org.gameyfin.app.libraries.entities.Library +import org.gameyfin.app.media.Image import org.gameyfin.pluginapi.gamemetadata.* import org.hibernate.annotations.CreationTimestamp import org.hibernate.annotations.UpdateTimestamp @@ -79,6 +81,9 @@ class Game( @ElementCollection var videoUrls: List = emptyList(), + @ManyToMany(mappedBy = "games", fetch = FetchType.EAGER) + var collections: MutableList = mutableListOf(), + @Embedded var metadata: GameMetadata ) { diff --git a/app/src/main/kotlin/org/gameyfin/app/games/extensions/GameExtensions.kt b/app/src/main/kotlin/org/gameyfin/app/games/extensions/GameExtensions.kt index b7b3b50..b2ae98d 100644 --- a/app/src/main/kotlin/org/gameyfin/app/games/extensions/GameExtensions.kt +++ b/app/src/main/kotlin/org/gameyfin/app/games/extensions/GameExtensions.kt @@ -3,6 +3,7 @@ package org.gameyfin.app.games.extensions import org.gameyfin.app.core.security.isCurrentUserAdmin import org.gameyfin.app.games.dto.* import org.gameyfin.app.games.entities.* +import org.gameyfin.app.media.toDto import java.time.ZoneOffset @@ -28,10 +29,11 @@ fun Game.toAdminDto(): GameAdminDto { createdAt = createdAt!!, updatedAt = updatedAt!!, libraryId = this.library.id!!, + collectionIds = this.collections.mapNotNull { it.id }, title = title!!, platforms = this.platforms, - coverId = this.coverImage?.id, - headerId = this.headerImage?.id, + cover = this.coverImage?.toDto(), + header = this.headerImage?.toDto(), comment = this.comment, summary = this.summary, release = this.release?.atZone(ZoneOffset.UTC)?.toLocalDate(), @@ -44,7 +46,7 @@ fun Game.toAdminDto(): GameAdminDto { keywords = this.keywords.toList(), features = this.features, perspectives = this.perspectives, - imageIds = this.images.mapNotNull { it.id }, + images = this.images.map { it.toDto() }, videoUrls = this.videoUrls.map { it.toString() }, metadata = this.metadata.toAdminDto() ) @@ -56,10 +58,11 @@ fun Game.toUserDto(): GameUserDto { createdAt = createdAt!!, updatedAt = updatedAt!!, libraryId = this.library.id!!, + collectionIds = this.collections.mapNotNull { it.id }, title = title!!, platforms = this.platforms, - coverId = this.coverImage?.id, - headerId = this.headerImage?.id, + cover = this.coverImage?.toDto(), + header = this.headerImage?.toDto(), comment = this.comment, summary = this.summary, release = this.release?.atZone(ZoneOffset.UTC)?.toLocalDate(), @@ -72,7 +75,7 @@ fun Game.toUserDto(): GameUserDto { keywords = this.keywords.toList(), features = this.features, perspectives = this.perspectives, - imageIds = this.images.mapNotNull { it.id }, + images = this.images.map { it.toDto() }, videoUrls = this.videoUrls.map { it.toString() }, metadata = this.metadata.toUserDto() ) diff --git a/app/src/main/kotlin/org/gameyfin/app/games/repositories/ImageContentStore.kt b/app/src/main/kotlin/org/gameyfin/app/games/repositories/ImageContentStore.kt index d247b15..5f5f271 100644 --- a/app/src/main/kotlin/org/gameyfin/app/games/repositories/ImageContentStore.kt +++ b/app/src/main/kotlin/org/gameyfin/app/games/repositories/ImageContentStore.kt @@ -1,6 +1,6 @@ package org.gameyfin.app.games.repositories -import org.gameyfin.app.games.entities.Image +import org.gameyfin.app.media.Image import org.springframework.content.commons.store.ContentStore import org.springframework.stereotype.Repository diff --git a/app/src/main/kotlin/org/gameyfin/app/games/repositories/ImageRepository.kt b/app/src/main/kotlin/org/gameyfin/app/games/repositories/ImageRepository.kt index 136e5fb..fcaf50c 100644 --- a/app/src/main/kotlin/org/gameyfin/app/games/repositories/ImageRepository.kt +++ b/app/src/main/kotlin/org/gameyfin/app/games/repositories/ImageRepository.kt @@ -1,6 +1,6 @@ package org.gameyfin.app.games.repositories -import org.gameyfin.app.games.entities.Image +import org.gameyfin.app.media.Image import org.springframework.data.jpa.repository.JpaRepository interface ImageRepository : JpaRepository { diff --git a/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryEndpoint.kt b/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryEndpoint.kt index ae211d5..fd62cfc 100644 --- a/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryEndpoint.kt +++ b/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryEndpoint.kt @@ -46,6 +46,9 @@ class LibraryEndpoint( @RolesAllowed(Role.Names.ADMIN) fun updateLibrary(library: LibraryUpdateDto) = libraryService.update(library) + @RolesAllowed(Role.Names.ADMIN) + fun updateLibraries(libraries: Collection) = libraryService.update(libraries) + @RolesAllowed(Role.Names.ADMIN) fun deleteLibrary(libraryId: Long) = libraryService.delete(libraryId) } \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryScanService.kt b/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryScanService.kt index bc30a3f..37bb6c0 100644 --- a/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryScanService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryScanService.kt @@ -102,60 +102,37 @@ class LibraryScanService( emit(progress) try { - val scanResult = filesystemService.scanLibraryForGamefiles(library) - val newPaths = scanResult.newPaths - val removedGamePaths = scanResult.removedGamePaths.map { it.toString() } - val removedIgnoredPaths = scanResult.removedIgnoredPaths - - // Get plugin-generated (system) ignored paths to re-scan - val pluginIgnoredPathsToRescan = library.ignoredPaths - .filter { it.getType() == IgnoredPathSourceType.PLUGIN } - .map { Path.of(it.path) } - - progress.currentStep = LibraryScanStep( - description = "Processing new games", - current = 0, - total = newPaths.size + pluginIgnoredPathsToRescan.size - ) - emit(progress) + val scanData = performFilesystemScan(library) // 1. Process each new game independently (including re-scanned plugin ignored paths) - val allPathsToProcess = newPaths + pluginIgnoredPathsToRescan - val (newUnmatchedPaths, persistedNewGames) = processNewGames(library, allPathsToProcess, progress) + val (newUnmatchedPaths, persistedNewGames) = processNewGamesWithProgress( + library, + scanData.allPathsToProcess, + progress + ) // 2. Update library (removed games/ignored paths, and add persisted new ones) val (removedGames) = updateLibrary( library, - removedIgnoredPaths, + scanData.removedIgnoredPaths, newUnmatchedPaths, - removedGamePaths + scanData.removedGamePaths ) // 3. Finish scan: persist library changes and report - progress.currentStep = LibraryScanStep( - description = "Finishing up", - current = 0, - total = persistedNewGames.size - ) - emit(progress) + finishScanWithProgress(persistedNewGames, library, progress) - finishScanPersisted(persistedNewGames, library, progress) - - progress.currentStep = LibraryScanStep(description = "Finished") - progress.finishedAt = Instant.now() - progress.status = LibraryScanStatus.COMPLETED - progress.result = QuickScanResult( - new = persistedNewGames.size, - removed = removedGames.size, - unmatched = newUnmatchedPaths.size + // 4. Send final progress update + completeScan( + progress, + QuickScanResult( + new = persistedNewGames.size, + removed = removedGames.size, + unmatched = newUnmatchedPaths.size + ) ) - emit(progress) } catch (e: Exception) { - log.error { "Error during quick scan for library ${library.id}: ${e.message}" } - log.debug(e) {} - progress.status = LibraryScanStatus.FAILED - progress.finishedAt = Instant.now() - emit(progress) + handleScanError(e, library, progress, "quick scan") } } @@ -170,16 +147,7 @@ class LibraryScanService( emit(progress) try { - val scanResult = filesystemService.scanLibraryForGamefiles(library) - val newPaths = scanResult.newPaths - val removedGamePaths = scanResult.removedGamePaths.map { it.toString() } - val removedIgnoredPaths = scanResult.removedIgnoredPaths - - // Get plugin-generated (system) ignored paths to re-scan - val pluginIgnoredPathsToRescan = library.ignoredPaths - .filter { it.getType() == IgnoredPathSourceType.PLUGIN } - .map { Path.of(it.path) } - + val scanData = performFilesystemScan(library) // 1. Update existing games (individually) progress.currentStep = LibraryScanStep( @@ -192,54 +160,109 @@ class LibraryScanService( val (updatedGames) = updateExistingGames(library.games, progress) // 2. Process new games (individually, including re-scanned plugin ignored paths) - val allPathsToProcess = newPaths + pluginIgnoredPathsToRescan - progress.currentStep = LibraryScanStep( - description = "Processing new games", - current = 0, - total = allPathsToProcess.size + val (newUnmatchedPaths, persistedNewGames) = processNewGamesWithProgress( + library, + scanData.allPathsToProcess, + progress ) - emit(progress) - - val (newUnmatchedPaths, persistedNewGames) = processNewGames(library, allPathsToProcess, progress) val (removedGames) = updateLibrary( library, - removedIgnoredPaths, + scanData.removedIgnoredPaths, newUnmatchedPaths, - removedGamePaths + scanData.removedGamePaths ) // 3. Finish scan - progress.currentStep = LibraryScanStep( - description = "Finishing up", - current = 0, - total = persistedNewGames.size - ) - emit(progress) - - finishScanPersisted(persistedNewGames, library, progress) + finishScanWithProgress(persistedNewGames, library, progress) // 4. Send final progress update - progress.currentStep = LibraryScanStep(description = "Finished") - progress.finishedAt = Instant.now() - progress.status = LibraryScanStatus.COMPLETED - progress.result = FullScanResult( - new = persistedNewGames.size, - removed = removedGames.size, - unmatched = newUnmatchedPaths.size, - updated = updatedGames.size + completeScan( + progress, + FullScanResult( + new = persistedNewGames.size, + removed = removedGames.size, + unmatched = newUnmatchedPaths.size, + updated = updatedGames.size + ) ) - emit(progress) } catch (e: Exception) { - log.error { "Error during full scan for library ${library.id}: ${e.message}" } - log.debug(e) {} - progress.status = LibraryScanStatus.FAILED - progress.finishedAt = Instant.now() - emit(progress) - return + handleScanError(e, library, progress, "full scan") } } + private data class FilesystemScanData( + val allPathsToProcess: List, + val removedGamePaths: List, + val removedIgnoredPaths: List + ) + + private fun performFilesystemScan(library: Library): FilesystemScanData { + val scanResult = filesystemService.scanLibraryForGamefiles(library) + val newPaths = scanResult.newPaths + val removedGamePaths = scanResult.removedGamePaths.map { it.toString() } + val removedIgnoredPaths = scanResult.removedIgnoredPaths + + // Get plugin-generated (system) ignored paths to re-scan + val pluginIgnoredPathsToRescan = library.ignoredPaths + .filter { it.getType() == IgnoredPathSourceType.PLUGIN } + .map { Path.of(it.path) } + + val allPathsToProcess = newPaths + pluginIgnoredPathsToRescan + + return FilesystemScanData( + allPathsToProcess = allPathsToProcess, + removedGamePaths = removedGamePaths, + removedIgnoredPaths = removedIgnoredPaths + ) + } + + private fun processNewGamesWithProgress( + library: Library, + gamePaths: List, + progress: LibraryScanProgress + ): MatchNewGamesResult { + progress.currentStep = LibraryScanStep( + description = "Processing new games", + current = 0, + total = gamePaths.size + ) + emit(progress) + + return processNewGames(library, gamePaths, progress) + } + + private fun finishScanWithProgress( + persistedNewGames: List, + library: Library, + progress: LibraryScanProgress + ) { + progress.currentStep = LibraryScanStep( + description = "Finishing up", + current = 0, + total = persistedNewGames.size + ) + emit(progress) + + finishScanPersisted(persistedNewGames, library, progress) + } + + private fun completeScan(progress: LibraryScanProgress, result: LibraryScanResult) { + progress.currentStep = LibraryScanStep(description = "Finished") + progress.finishedAt = Instant.now() + progress.status = LibraryScanStatus.COMPLETED + progress.result = result + emit(progress) + } + + private fun handleScanError(e: Exception, library: Library, progress: LibraryScanProgress, scanType: String) { + log.error { "Error during $scanType for library ${library.id}: ${e.message}" } + log.debug(e) {} + progress.status = LibraryScanStatus.FAILED + progress.finishedAt = Instant.now() + emit(progress) + } + private fun processNewGames( library: Library, gamePaths: List, diff --git a/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryService.kt b/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryService.kt index 1098a73..32f4a0b 100644 --- a/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryService.kt @@ -5,6 +5,7 @@ import io.github.oshai.kotlinlogging.KotlinLogging import org.gameyfin.app.core.security.getCurrentAuth import org.gameyfin.app.libraries.dto.* import org.gameyfin.app.libraries.entities.DirectoryMapping +import org.gameyfin.app.libraries.entities.IgnoredPathSourceType import org.gameyfin.app.libraries.entities.Library import org.gameyfin.app.libraries.enums.ScanType import org.gameyfin.app.libraries.extensions.toDtos @@ -34,7 +35,7 @@ class LibraryService( private val libraryAdminEvents = Sinks.many().multicast().onBackpressureBuffer(1024, false) fun subscribeUser(): Flux> { - log.debug { "New user subscription for libraryEvents" } + log.debug { "New user subscription for libraryUserEvents" } return libraryUserEvents.asFlux() .buffer(100.milliseconds.toJavaDuration()) .doOnSubscribe { @@ -46,7 +47,7 @@ class LibraryService( } fun subscribeAdmin(): Flux> { - log.debug { "New admin subscription for libraryEvents" } + log.debug { "New admin subscription for libraryAdminEvents" } return libraryAdminEvents.asFlux() .buffer(100.milliseconds.toJavaDuration()) .doOnSubscribe { @@ -166,15 +167,16 @@ class LibraryService( library.platforms.addAll(it) } + // Only allow updating USER sourced ignored paths; preserve PLUGIN sourced ones libraryUpdateDto.ignoredPaths ?.filter { it.sourceType == IgnoredPathSourceTypeDto.USER } // Only USER source type is supported for updates ?.let { dtos -> - // Get current user for USER source type paths val currentUser = getCurrentAuth()?.let { auth -> userService.getByUsername(auth.name) } - library.ignoredPaths.clear() + // Remove existing USER-sourced ignored paths, keep PLUGIN-sourced ones intact + library.ignoredPaths.removeIf { it.getType() == IgnoredPathSourceType.USER } - // Check for existing paths and reuse them if they exist + // Recreate user-sourced paths (reuse existing entity if same path already present globally) val pathsToAdd = dtos.map { dto -> val existingPath = ignoredPathRepository.findByPath(dto.path) existingPath ?: dto.toEntity(currentUser) @@ -183,10 +185,21 @@ class LibraryService( library.ignoredPaths.addAll(pathsToAdd) } + libraryUpdateDto.metadata?.let { + library.metadata = it.toEntity() + } + library.updatedAt = Instant.now() libraryRepository.save(library) } + /** + * Updates multiple libraries in the repository. + */ + fun update(libraries: Collection) { + libraries.forEach { update(it) } + } + /** * Deletes a library from the repository. * diff --git a/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryWatcherService.kt b/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryWatcherService.kt new file mode 100644 index 0000000..cc65fe3 --- /dev/null +++ b/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryWatcherService.kt @@ -0,0 +1,363 @@ +package org.gameyfin.app.libraries + +import io.github.oshai.kotlinlogging.KotlinLogging +import jakarta.annotation.PostConstruct +import jakarta.annotation.PreDestroy +import org.gameyfin.app.config.ConfigProperties +import org.gameyfin.app.config.ConfigService +import org.gameyfin.app.core.events.LibraryCreatedEvent +import org.gameyfin.app.core.events.LibraryDeletedEvent +import org.gameyfin.app.core.events.LibraryFilesystemWatcherConfigUpdatedEvent +import org.gameyfin.app.core.events.LibraryUpdatedEvent +import org.gameyfin.app.core.filesystem.FilesystemService +import org.gameyfin.app.games.repositories.GameRepository +import org.gameyfin.app.libraries.entities.Library +import org.gameyfin.app.libraries.enums.ScanType +import org.springframework.context.event.EventListener +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Service +import java.nio.file.* +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.io.path.isDirectory + +/** + * Service that monitors library directories for file system changes and automatically + * updates games and libraries when files are added, removed, or modified. + */ +@Service +class LibraryWatcherService( + private val libraryRepository: LibraryRepository, + private val libraryScanService: LibraryScanService, + private val gameRepository: GameRepository, + private val filesystemService: FilesystemService, + private val configService: ConfigService +) { + + companion object { + private val log = KotlinLogging.logger {} + } + + data class LibraryWatchInfo( + val libraryId: Long, + val path: Path + ) + + private var watchService: WatchService? = null + private val watchKeys = ConcurrentHashMap() + private val libraryWatchers = ConcurrentHashMap>() + private var executor = Executors.newSingleThreadExecutor { r -> + Thread(r, "library-watcher-thread").apply { isDaemon = true } + } + private val running = AtomicBoolean(false) + + @PostConstruct + fun start() { + // Check if filesystem watcher is enabled in config + val isEnabled = configService.get(ConfigProperties.Libraries.Scan.EnableFilesystemWatcher) ?: false + + if (!isEnabled) { + log.debug { "Library Watcher Service is disabled in configuration" } + return + } + + log.debug { "Starting Library Watcher Service" } + + // Create a new watch service if needed + if (watchService == null) { + watchService = FileSystems.getDefault().newWatchService() + } + + // Recreate executor if it was previously shut down + if (executor.isShutdown) { + executor = Executors.newSingleThreadExecutor { r -> + Thread(r, "library-watcher-thread").apply { isDaemon = true } + } + } + + running.set(true) + + // Start watching all existing libraries + val libraries = libraryRepository.findAll() + libraries.forEach { library -> + startWatchingLibrary(library) + } + + // Start the watch service thread + executor.submit { + watchForChanges() + } + + log.info { "Library Watcher Service started, monitoring ${libraries.size} libraries" } + } + + @PreDestroy + fun stop() { + log.debug { "Stopping Library Watcher Service" } + running.set(false) + + // Close all watch keys + watchKeys.keys.forEach { it.cancel() } + watchKeys.clear() + libraryWatchers.clear() + + // Shutdown executor + executor.shutdown() + try { + if (!executor.awaitTermination(5, TimeUnit.SECONDS)) { + executor.shutdownNow() + } + } catch (_: InterruptedException) { + executor.shutdownNow() + Thread.currentThread().interrupt() + } + + // Close watch service + watchService?.close() + watchService = null + log.info { "Library Watcher Service stopped" } + } + + @Async + @EventListener(LibraryCreatedEvent::class) + fun onLibraryCreated(event: LibraryCreatedEvent) { + if (!running.get()) { + log.debug { "Library created event received but watcher is not running, skipping" } + return + } + log.debug { "Library created event received for library ${event.library.id}" } + startWatchingLibrary(event.library) + } + + @Async + @EventListener(LibraryUpdatedEvent::class) + fun onLibraryUpdated(event: LibraryUpdatedEvent) { + if (!running.get()) { + log.debug { "Library updated event received but watcher is not running, skipping" } + return + } + log.debug { "Library updated event received for library ${event.currentState.id}" } + // Stop watching the old directories + stopWatchingLibrary(event.currentState.id!!) + // Start watching the new directories + startWatchingLibrary(event.currentState) + } + + @Async + @EventListener(LibraryDeletedEvent::class) + fun onLibraryDeleted(event: LibraryDeletedEvent) { + if (!running.get()) { + log.debug { "Library deleted event received but watcher is not running, skipping" } + return + } + log.debug { "Library deleted event received for library ${event.library.id}" } + stopWatchingLibrary(event.library.id!!) + } + + @Async + @EventListener(LibraryFilesystemWatcherConfigUpdatedEvent::class) + fun onFilesystemWatcherConfigUpdated(event: LibraryFilesystemWatcherConfigUpdatedEvent) { + log.debug { "Filesystem watcher configuration updated" } + + if (event.isEnabled && !running.get()) { + // Configuration changed to enabled and watcher is not running - start it + log.debug { "Filesystem watcher enabled, starting watchers" } + start() + } else if (!event.isEnabled && running.get()) { + // Configuration changed to disabled and watcher is running - stop it + log.debug { "Filesystem watcher disabled, stopping watchers" } + stop() + } + } + + private fun startWatchingLibrary(library: Library) { + val libraryId = library.id ?: return + + log.debug { "Starting to watch library '${library.name}' (ID: $libraryId)" } + + library.directories.forEach { directoryMapping -> + try { + val path = Paths.get(directoryMapping.internalPath) + + if (!path.isDirectory()) { + log.warn { "Path is not a directory: $path" } + return@forEach + } + + // Register the directory with the watch service + val service = watchService + if (service == null) { + log.warn { "Watch service is not initialized, cannot watch directory: $path" } + return@forEach + } + + val watchKey = path.register( + service, + StandardWatchEventKinds.ENTRY_CREATE, + StandardWatchEventKinds.ENTRY_DELETE, + StandardWatchEventKinds.ENTRY_MODIFY + ) + + val watchInfo = LibraryWatchInfo(libraryId, path) + watchKeys[watchKey] = watchInfo + libraryWatchers.computeIfAbsent(libraryId) { mutableListOf() }.add(watchKey) + + log.debug { "Registered watcher for directory: $path in library $libraryId" } + } catch (e: Exception) { + log.error(e) { "Failed to register watcher for directory: ${directoryMapping.internalPath}" } + } + } + } + + private fun stopWatchingLibrary(libraryId: Long) { + log.debug { "Stopping watchers for library $libraryId" } + + libraryWatchers[libraryId]?.forEach { watchKey -> + watchKey.cancel() + watchKeys.remove(watchKey) + } + libraryWatchers.remove(libraryId) + + log.debug { "Stopped all watchers for library $libraryId" } + } + + private fun watchForChanges() { + log.debug { "Watch service thread started" } + + while (running.get()) { + try { + val watchKey = watchService?.poll(1, TimeUnit.SECONDS) ?: continue + val watchInfo = watchKeys[watchKey] ?: continue + + val events = watchKey.pollEvents() + if (events.isEmpty()) { + watchKey.reset() + continue + } + + log.debug { "Detected ${events.size} file system events in library ${watchInfo.libraryId}" } + + // Group events by type + val hasCreates = events.any { it.kind() == StandardWatchEventKinds.ENTRY_CREATE } + val hasDeletes = events.any { it.kind() == StandardWatchEventKinds.ENTRY_DELETE } + val hasModifies = events.any { it.kind() == StandardWatchEventKinds.ENTRY_MODIFY } + + // Process the events + processFileSystemEvents(watchInfo, events, hasCreates, hasDeletes, hasModifies) + + // Reset the watch key + if (!watchKey.reset()) { + log.warn { "Watch key no longer valid for path: ${watchInfo.path}" } + watchKeys.remove(watchKey) + libraryWatchers[watchInfo.libraryId]?.remove(watchKey) + } + } catch (_: InterruptedException) { + log.debug { "Watch service thread interrupted" } + Thread.currentThread().interrupt() + break + } catch (e: Exception) { + log.error(e) { "Error processing file system events" } + } + } + + log.debug { "Watch service thread stopped" } + } + + private fun processFileSystemEvents( + watchInfo: LibraryWatchInfo, + events: List>, + hasCreates: Boolean, + hasDeletes: Boolean, + hasModifies: Boolean + ) { + try { + val library = libraryRepository.findById(watchInfo.libraryId).orElse(null) + if (library == null) { + log.warn { "Library ${watchInfo.libraryId} not found, stopping watcher" } + stopWatchingLibrary(watchInfo.libraryId) + return + } + + if (events.isEmpty()) { + log.debug { "No relevant game file changes detected" } + return + } + + log.debug { + "Processing ${events.size} relevant file changes in library '${library.name}' " + + "(creates: $hasCreates, deletes: $hasDeletes, modifies: $hasModifies)" + } + + // Handle creates (new games) + if (hasCreates) { + handleCreates(library, events.filter { + it.kind() == StandardWatchEventKinds.ENTRY_CREATE + }) + } + + // Handle deletes (removed games) + if (hasDeletes) { + handleDeletes(library, events.filter { + it.kind() == StandardWatchEventKinds.ENTRY_DELETE + }) + } + + // Handle modifies (changed file sizes) + if (hasModifies) { + handleModifies(library, watchInfo, events.filter { + it.kind() == StandardWatchEventKinds.ENTRY_MODIFY + }) + } + + } catch (e: Exception) { + log.error(e) { "Error processing file system events for library ${watchInfo.libraryId}" } + } + } + + private fun handleCreates(library: Library, events: List>) { + log.debug { "Handling ${events.size} create events for library ${library.id}" } + + // Trigger a quick scan to add new games + // The scan service will handle the actual game creation + libraryScanService.triggerScan(ScanType.QUICK, listOf(library.id!!)) + } + + private fun handleDeletes(library: Library, events: List>) { + log.debug { "Handling ${events.size} delete events for library ${library.id}" } + + // Trigger a quick scan to remove deleted games + // The scan service will handle the actual game deletion + libraryScanService.triggerScan(ScanType.QUICK, listOf(library.id!!)) + } + + private fun handleModifies(library: Library, watchInfo: LibraryWatchInfo, events: List>) { + log.debug { "Handling ${events.size} modify events for library ${library.id}" } + + events.forEach { event -> + @Suppress("UNCHECKED_CAST") + val watchEvent = event as WatchEvent + val filename = watchEvent.context() + val fullPath = watchInfo.path.resolve(filename) + + // Find games that match this path and update their file size + val gamesToUpdate = library.games.filter { game -> + game.metadata.path == fullPath.toString() + } + + if (gamesToUpdate.isNotEmpty()) { + log.debug { "Updating file size for ${gamesToUpdate.size} games: $fullPath" } + gamesToUpdate.forEach { game -> + val newFileSize = filesystemService.calculateFileSize(game.metadata.path) + if (game.metadata.fileSize != newFileSize) { + game.metadata.fileSize = newFileSize + gameRepository.save(game) + log.debug { "Updated file size for game '${game.title}' from ${game.metadata.fileSize} to $newFileSize bytes" } + } + } + } + } + } +} + diff --git a/app/src/main/kotlin/org/gameyfin/app/libraries/dto/LibraryDto.kt b/app/src/main/kotlin/org/gameyfin/app/libraries/dto/LibraryDto.kt index e95ac96..567907b 100644 --- a/app/src/main/kotlin/org/gameyfin/app/libraries/dto/LibraryDto.kt +++ b/app/src/main/kotlin/org/gameyfin/app/libraries/dto/LibraryDto.kt @@ -2,27 +2,34 @@ package org.gameyfin.app.libraries.dto import com.fasterxml.jackson.annotation.JsonInclude import org.gameyfin.pluginapi.gamemetadata.Platform +import java.time.Instant interface LibraryDto { val id: Long val name: String - val games: List? + val createdAt: Instant? + val gameIds: List? + val metadata: LibraryMetadataDto? } @JsonInclude(JsonInclude.Include.NON_NULL) data class LibraryUserDto( override val id: Long, override val name: String, - override val games: List? + override val createdAt: Instant?, + override val gameIds: List?, + override val metadata: LibraryMetadataDto? ) : LibraryDto @JsonInclude(JsonInclude.Include.NON_NULL) data class LibraryAdminDto( override val id: Long, override val name: String, + override val createdAt: Instant?, val directories: List, val platforms: List, - override val games: List?, + override val gameIds: List?, val stats: LibraryStatsDto?, - val ignoredPaths: List? + val ignoredPaths: List?, + override val metadata: LibraryMetadataDto? ) : LibraryDto diff --git a/app/src/main/kotlin/org/gameyfin/app/libraries/dto/LibraryMetadataDto.kt b/app/src/main/kotlin/org/gameyfin/app/libraries/dto/LibraryMetadataDto.kt new file mode 100644 index 0000000..ba1d0e8 --- /dev/null +++ b/app/src/main/kotlin/org/gameyfin/app/libraries/dto/LibraryMetadataDto.kt @@ -0,0 +1,6 @@ +package org.gameyfin.app.libraries.dto + +data class LibraryMetadataDto( + val displayOnHomepage: Boolean, + val displayOrder: Int +) \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/libraries/dto/LibraryUpdateDto.kt b/app/src/main/kotlin/org/gameyfin/app/libraries/dto/LibraryUpdateDto.kt index fde574d..548ad97 100644 --- a/app/src/main/kotlin/org/gameyfin/app/libraries/dto/LibraryUpdateDto.kt +++ b/app/src/main/kotlin/org/gameyfin/app/libraries/dto/LibraryUpdateDto.kt @@ -7,5 +7,6 @@ data class LibraryUpdateDto( val name: String? = null, val directories: List? = null, val platforms: List? = null, - val ignoredPaths: List? = null + val ignoredPaths: List? = null, + val metadata: LibraryMetadataDto? = null ) diff --git a/app/src/main/kotlin/org/gameyfin/app/libraries/entities/Library.kt b/app/src/main/kotlin/org/gameyfin/app/libraries/entities/Library.kt index f78035b..311aa70 100644 --- a/app/src/main/kotlin/org/gameyfin/app/libraries/entities/Library.kt +++ b/app/src/main/kotlin/org/gameyfin/app/libraries/entities/Library.kt @@ -35,5 +35,8 @@ class Library( var games: MutableList = ArrayList(), @OneToMany(fetch = FetchType.EAGER, orphanRemoval = true, cascade = [CascadeType.ALL]) - var ignoredPaths: MutableList = ArrayList() + var ignoredPaths: MutableList = ArrayList(), + + @Embedded + var metadata: LibraryMetadata = LibraryMetadata() ) \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/libraries/entities/LibraryMetadata.kt b/app/src/main/kotlin/org/gameyfin/app/libraries/entities/LibraryMetadata.kt new file mode 100644 index 0000000..34fe6f0 --- /dev/null +++ b/app/src/main/kotlin/org/gameyfin/app/libraries/entities/LibraryMetadata.kt @@ -0,0 +1,9 @@ +package org.gameyfin.app.libraries.entities + +import jakarta.persistence.Embeddable + +@Embeddable +class LibraryMetadata( + val displayOnHomepage: Boolean = true, + val displayOrder: Int = -1 +) \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/libraries/extensions/LibraryExtensions.kt b/app/src/main/kotlin/org/gameyfin/app/libraries/extensions/LibraryExtensions.kt index fadb793..e4f7e93 100644 --- a/app/src/main/kotlin/org/gameyfin/app/libraries/extensions/LibraryExtensions.kt +++ b/app/src/main/kotlin/org/gameyfin/app/libraries/extensions/LibraryExtensions.kt @@ -3,6 +3,7 @@ package org.gameyfin.app.libraries.extensions import org.gameyfin.app.core.security.isCurrentUserAdmin import org.gameyfin.app.libraries.dto.* import org.gameyfin.app.libraries.entities.Library +import org.gameyfin.app.libraries.entities.LibraryMetadata fun Library.toDto(): LibraryDto { @@ -25,7 +26,9 @@ fun Library.toUserDto(): LibraryUserDto { return LibraryUserDto( id = this.id!!, name = this.name, - games = this.games.mapNotNull { it.id } + createdAt = this.createdAt!!, + gameIds = this.games.mapNotNull { it.id }, + metadata = this.metadata.toDto() ) } @@ -33,13 +36,29 @@ fun Library.toAdminDto(): LibraryAdminDto { return LibraryAdminDto( id = this.id!!, name = this.name, + createdAt = this.createdAt!!, directories = this.directories.map { DirectoryMappingDto(it.internalPath, it.externalPath) }, platforms = this.platforms, - games = this.games.mapNotNull { it.id }, + gameIds = this.games.mapNotNull { it.id }, stats = LibraryStatsDto( gamesCount = this.games.size, downloadedGamesCount = this.games.sumOf { it.metadata.downloadCount } ), - ignoredPaths = this.ignoredPaths.toDtos() + ignoredPaths = this.ignoredPaths.toDtos(), + metadata = this.metadata.toDto() ) } + +fun LibraryMetadata.toDto(): LibraryMetadataDto { + return LibraryMetadataDto( + displayOnHomepage = this.displayOnHomepage, + displayOrder = this.displayOrder + ) +} + +fun LibraryMetadataDto.toEntity(): LibraryMetadata { + return LibraryMetadata( + displayOnHomepage = this.displayOnHomepage, + displayOrder = this.displayOrder + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/libraries/scan/LibraryGameProcessor.kt b/app/src/main/kotlin/org/gameyfin/app/libraries/scan/LibraryGameProcessor.kt index 667c32a..b5da9a7 100644 --- a/app/src/main/kotlin/org/gameyfin/app/libraries/scan/LibraryGameProcessor.kt +++ b/app/src/main/kotlin/org/gameyfin/app/libraries/scan/LibraryGameProcessor.kt @@ -51,7 +51,7 @@ class LibraryGameProcessor( // Note: GameService.update will load and save the managed entity inside this same transaction var updated: Game? = null try { - updated = gameService.update(game) + updated = gameService.updateMetadata(game) if (updated != null) { // Download any images now associated with the game downloadImagesForGame(updated) diff --git a/app/src/main/kotlin/org/gameyfin/app/games/entities/Image.kt b/app/src/main/kotlin/org/gameyfin/app/media/Image.kt similarity index 87% rename from app/src/main/kotlin/org/gameyfin/app/games/entities/Image.kt rename to app/src/main/kotlin/org/gameyfin/app/media/Image.kt index cb407d9..93e4efd 100644 --- a/app/src/main/kotlin/org/gameyfin/app/games/entities/Image.kt +++ b/app/src/main/kotlin/org/gameyfin/app/media/Image.kt @@ -1,4 +1,4 @@ -package org.gameyfin.app.games.entities +package org.gameyfin.app.media import jakarta.persistence.Entity import jakarta.persistence.GeneratedValue @@ -25,7 +25,9 @@ class Image( var contentLength: Long? = null, @MimeType - var mimeType: String? = null + var mimeType: String? = null, + + var blurhash: String? = null ) enum class ImageType { diff --git a/app/src/main/kotlin/org/gameyfin/app/media/ImageDto.kt b/app/src/main/kotlin/org/gameyfin/app/media/ImageDto.kt new file mode 100644 index 0000000..bd35598 --- /dev/null +++ b/app/src/main/kotlin/org/gameyfin/app/media/ImageDto.kt @@ -0,0 +1,7 @@ +package org.gameyfin.app.media + +data class ImageDto( + val id: Long, + val type: ImageType, + val blurhash: String? +) \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/media/ImageEndpoint.kt b/app/src/main/kotlin/org/gameyfin/app/media/ImageEndpoint.kt index 8b7fa57..5167304 100644 --- a/app/src/main/kotlin/org/gameyfin/app/media/ImageEndpoint.kt +++ b/app/src/main/kotlin/org/gameyfin/app/media/ImageEndpoint.kt @@ -8,8 +8,6 @@ import org.gameyfin.app.core.Utils import org.gameyfin.app.core.annotations.DynamicPublicAccess import org.gameyfin.app.core.plugins.PluginService import org.gameyfin.app.core.security.getCurrentAuth -import org.gameyfin.app.games.entities.Image -import org.gameyfin.app.games.entities.ImageType import org.gameyfin.app.users.UserService import org.springframework.core.io.ByteArrayResource import org.springframework.core.io.InputStreamResource diff --git a/app/src/main/kotlin/org/gameyfin/app/media/ImageExtensions.kt b/app/src/main/kotlin/org/gameyfin/app/media/ImageExtensions.kt new file mode 100644 index 0000000..fc06b7f --- /dev/null +++ b/app/src/main/kotlin/org/gameyfin/app/media/ImageExtensions.kt @@ -0,0 +1,13 @@ +package org.gameyfin.app.media + +fun Image.toDto(): ImageDto = ImageDto( + id = this.id!!, + type = this.type, + blurhash = this.blurhash +) + +fun ImageDto.toEntity(): Image = Image( + id = this.id, + type = this.type, + blurhash = this.blurhash +) \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/media/ImageService.kt b/app/src/main/kotlin/org/gameyfin/app/media/ImageService.kt index af24ec2..9a6e9c2 100644 --- a/app/src/main/kotlin/org/gameyfin/app/media/ImageService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/media/ImageService.kt @@ -1,13 +1,12 @@ package org.gameyfin.app.media +import com.vanniktech.blurhash.BlurHash import org.apache.tika.Tika import org.apache.tika.io.TikaInputStream import org.gameyfin.app.core.events.GameDeletedEvent import org.gameyfin.app.core.events.GameUpdatedEvent import org.gameyfin.app.core.events.UserDeletedEvent import org.gameyfin.app.core.events.UserUpdatedEvent -import org.gameyfin.app.games.entities.Image -import org.gameyfin.app.games.entities.ImageType import org.gameyfin.app.games.repositories.GameRepository import org.gameyfin.app.games.repositories.ImageContentStore import org.gameyfin.app.games.repositories.ImageRepository @@ -18,8 +17,12 @@ import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.event.TransactionPhase import org.springframework.transaction.event.TransactionalEventListener +import java.awt.RenderingHints +import java.awt.image.BufferedImage +import java.io.ByteArrayInputStream import java.io.InputStream import java.net.URI +import javax.imageio.ImageIO @Service class ImageService( @@ -30,6 +33,37 @@ class ImageService( ) { companion object { private val tika = Tika() + + /** + * Scale down image for faster blurhash calculation. + * Blurhash doesn't need full resolution - 100px width is plenty for a good blur. + */ + fun scaleImageForBlurhash(original: BufferedImage, maxWidth: Int = 100): BufferedImage { + val originalWidth = original.width + val originalHeight = original.height + + // If image is already small enough, return as-is + if (originalWidth <= maxWidth) { + return original + } + + val scale = maxWidth.toDouble() / originalWidth + val targetWidth = maxWidth + val targetHeight = (originalHeight * scale).toInt() + + val scaled = BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB) + val g2d = scaled.createGraphics() + + // Use fast scaling for blurhash - quality doesn't matter much for a blur + g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR) + g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_SPEED) + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF) + + g2d.drawImage(original, 0, 0, targetWidth, targetHeight, null) + g2d.dispose() + + return scaled + } } @TransactionalEventListener( @@ -126,7 +160,19 @@ class ImageService( // If no existing image or existing image has no valid content, download it TikaInputStream.get { URI.create(image.originalUrl).toURL().openStream() }.use { input -> image.mimeType = tika.detect(input) - imageContentStore.setContent(image, input) + + // Read the input stream into a byte array so we can use it twice + val imageBytes = input.readBytes() + + // Calculate blurhash + ByteArrayInputStream(imageBytes).use { blurhashStream -> + image.blurhash = calculateBlurhash(blurhashStream) + } + + // Store content + ByteArrayInputStream(imageBytes).use { contentStream -> + imageContentStore.setContent(image, contentStream) + } } // Save or update the image to ensure it's persisted @@ -139,8 +185,22 @@ class ImageService( fun createFromInputStream(type: ImageType, content: InputStream, mimeType: String): Image { val image = Image(type = type, mimeType = mimeType) - imageRepository.save(image) - return imageContentStore.setContent(image, content) + + // Read the input stream into a byte array so we can use it twice + val imageBytes = content.readBytes() + + // Calculate blurhash + ByteArrayInputStream(imageBytes).use { blurhashStream -> + image.blurhash = calculateBlurhash(blurhashStream) + } + + // Store content + ByteArrayInputStream(imageBytes).use { contentStream -> + imageContentStore.setContent(image, contentStream) + } + + // Save with blurhash + return imageRepository.save(image) } fun getImage(id: Long): Image? { @@ -165,12 +225,51 @@ class ImageService( fun updateFileContent(image: Image, content: InputStream, mimeType: String? = null): Image { mimeType?.let { image.mimeType = it } - imageRepository.save(image) - return imageContentStore.setContent(image, content) + + // Read the input stream into a byte array so we can use it twice + val imageBytes = content.readBytes() + + // Calculate blurhash + ByteArrayInputStream(imageBytes).use { blurhashStream -> + image.blurhash = calculateBlurhash(blurhashStream) + } + + // Store content + ByteArrayInputStream(imageBytes).use { contentStream -> + imageContentStore.setContent(image, contentStream) + } + + // Save with blurhash + return imageRepository.save(image) } private fun imageHasValidContent(image: Image): Boolean { val imageContent = imageContentStore.getContent(image) return imageContent != null && image.contentLength != null && image.contentLength!! > 0 } -} \ No newline at end of file + + private fun calculateBlurhash(inputStream: InputStream): String? { + return try { + val originalImage = ImageIO.read(inputStream) + if (originalImage != null) { + // Scale down for much faster processing + val scaledImage = scaleImageForBlurhash(originalImage) + + return if (scaledImage.width > scaledImage.height) { + // Landscape + BlurHash.encode(scaledImage, componentX = 4, componentY = 3) + } else if (scaledImage.width < scaledImage.height) { + // Portrait + BlurHash.encode(scaledImage, componentX = 3, componentY = 4) + } else { + // Square + BlurHash.encode(scaledImage, componentX = 3, componentY = 3) + } + } else { + null + } + } catch (_: Exception) { + null + } + } +} diff --git a/app/src/main/kotlin/org/gameyfin/app/platforms/PlatformService.kt b/app/src/main/kotlin/org/gameyfin/app/platforms/PlatformService.kt index b30b66f..eb02572 100644 --- a/app/src/main/kotlin/org/gameyfin/app/platforms/PlatformService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/platforms/PlatformService.kt @@ -48,9 +48,9 @@ class PlatformService( private val metadataPlugins: List get() = pluginManager.getExtensions(GameMetadataProvider::class.java) - private lateinit var _availablePlatforms: Set - private lateinit var _platformsInUseByGames: Set - private lateinit var _platformsInUseByLibraries: Set + private var _availablePlatforms: Set = emptySet() + private var _platformsInUseByGames: Set = emptySet() + private var _platformsInUseByLibraries: Set = emptySet() val availablePlatforms: Set get() = _availablePlatforms @@ -63,7 +63,7 @@ class PlatformService( @EventListener(ApplicationReadyEvent::class) fun initialize() { - log.info { "Initializing platform caches at startup" } + log.debug { "Initializing platform caches at startup" } calculateAvailablePlatforms() calculatePlatformsInUseByGames() calculatePlatformsInUseByLibraries() diff --git a/app/src/main/kotlin/org/gameyfin/app/users/UserService.kt b/app/src/main/kotlin/org/gameyfin/app/users/UserService.kt index 3d297d8..8de596b 100644 --- a/app/src/main/kotlin/org/gameyfin/app/users/UserService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/users/UserService.kt @@ -10,7 +10,7 @@ import org.gameyfin.app.core.events.EmailNeedsConfirmationEvent import org.gameyfin.app.core.events.RegistrationAttemptWithExistingEmailEvent import org.gameyfin.app.core.events.UserRegistrationWaitingForApprovalEvent import org.gameyfin.app.core.security.getCurrentAuth -import org.gameyfin.app.games.entities.Image +import org.gameyfin.app.media.Image import org.gameyfin.app.media.ImageService import org.gameyfin.app.users.dto.ExtendedUserInfoDto import org.gameyfin.app.users.dto.UserRegistrationDto diff --git a/app/src/main/kotlin/org/gameyfin/app/users/entities/User.kt b/app/src/main/kotlin/org/gameyfin/app/users/entities/User.kt index 9c5994f..c6a93b8 100644 --- a/app/src/main/kotlin/org/gameyfin/app/users/entities/User.kt +++ b/app/src/main/kotlin/org/gameyfin/app/users/entities/User.kt @@ -3,7 +3,7 @@ package org.gameyfin.app.users.entities import jakarta.persistence.* import org.gameyfin.app.core.Role import org.gameyfin.app.core.security.EncryptionConverter -import org.gameyfin.app.games.entities.Image +import org.gameyfin.app.media.Image import org.springframework.security.oauth2.core.oidc.user.OidcUser diff --git a/app/src/main/kotlin/org/gameyfin/app/users/preferences/UserPreferences.kt b/app/src/main/kotlin/org/gameyfin/app/users/preferences/UserPreferences.kt index 0f5a04a..bdba52a 100644 --- a/app/src/main/kotlin/org/gameyfin/app/users/preferences/UserPreferences.kt +++ b/app/src/main/kotlin/org/gameyfin/app/users/preferences/UserPreferences.kt @@ -10,4 +10,7 @@ sealed class UserPreferences( ) { data object PreferredTheme : UserPreferences(String::class, "preferred-theme") data object PreferredDownloadMethod : UserPreferences(String::class, "preferred-download-method") + + data object FavouriteGames : UserPreferences(String::class, "favourite-games") + data object RecentDownloads : UserPreferences(String::class, "recent-downloads") } \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/db/h2/BlurhashMigration.kt b/app/src/main/kotlin/org/gameyfin/db/h2/BlurhashMigration.kt new file mode 100644 index 0000000..900200c --- /dev/null +++ b/app/src/main/kotlin/org/gameyfin/db/h2/BlurhashMigration.kt @@ -0,0 +1,177 @@ +package org.gameyfin.db.h2 + +import com.vanniktech.blurhash.BlurHash +import java.awt.RenderingHints +import java.awt.image.BufferedImage +import java.io.File +import java.sql.Connection +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import javax.imageio.ImageIO + +/** + * Helper methods for calculating blurhashes during database migration. + */ +object BlurhashMigration { + + private data class ImageRecord(val id: Long, val contentId: String) + + /** + * Scale down image for faster blurhash calculation. + * Blurhash doesn't need full resolution - 100px width is plenty for a good blur. + */ + private fun scaleImageForBlurhash(original: BufferedImage, maxWidth: Int = 100): BufferedImage { + val originalWidth = original.width + val originalHeight = original.height + + // If image is already small enough, return as-is + if (originalWidth <= maxWidth) { + return original + } + + val scale = maxWidth.toDouble() / originalWidth + val targetWidth = maxWidth + val targetHeight = (originalHeight * scale).toInt() + + val scaled = BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB) + val g2d = scaled.createGraphics() + + // Use fast scaling for blurhash - quality doesn't matter much for a blur + g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR) + g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_SPEED) + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF) + + g2d.drawImage(original, 0, 0, targetWidth, targetHeight, null) + g2d.dispose() + + return scaled + } + + /** + * Calculate blurhash for all images in the database. + * This method is called from Flyway migration V2.3.0.6. + * Uses multithreading and batch updates for performance. + */ + @JvmStatic + fun calculateBlurhashesForAllImages(conn: Connection, dataPath: String) { + val startTime = System.currentTimeMillis() + + // Fetch all images first (fast) + val images = mutableListOf() + conn.prepareStatement("SELECT ID, CONTENT_ID FROM IMAGE WHERE CONTENT_ID IS NOT NULL").use { stmt -> + val rs = stmt.executeQuery() + while (rs.next()) { + images.add(ImageRecord(rs.getLong("ID"), rs.getString("CONTENT_ID"))) + } + } + + println("Found ${images.size} images to process") + + if (images.isEmpty()) { + println("No images to process") + return + } + + // Calculate blurhashes in parallel + val blurhashes = ConcurrentHashMap() + val processedCount = AtomicInteger(0) + val successCount = AtomicInteger(0) + val failedCount = AtomicInteger(0) + + // Use available processors, but cap at reasonable limit + val threadCount = minOf(Runtime.getRuntime().availableProcessors(), 8) + val executor = Executors.newFixedThreadPool(threadCount) + + println("Warning: This operation may take a while depending on the number of images and their sizes.") + println("Don't interrupt the process to avoid corrupting your database!") + + images.forEach { imageRecord -> + executor.submit { + try { + val imageFile = File(dataPath, imageRecord.contentId) + + if (imageFile.exists() && imageFile.canRead()) { + val originalImage = ImageIO.read(imageFile) + + if (originalImage != null) { + // Scale down for much faster processing + val scaledImage = scaleImageForBlurhash(originalImage) + + val blurhash = if (scaledImage.width > scaledImage.height) { + // Landscape + BlurHash.encode(scaledImage, componentX = 4, componentY = 3) + } else if (scaledImage.width < scaledImage.height) { + // Portrait + BlurHash.encode(scaledImage, componentX = 3, componentY = 4) + } else { + // Square + BlurHash.encode(scaledImage, componentX = 3, componentY = 3) + } + + blurhashes[imageRecord.id] = blurhash + successCount.incrementAndGet() + } else { + failedCount.incrementAndGet() + } + } else { + failedCount.incrementAndGet() + } + } catch (_: Exception) { + failedCount.incrementAndGet() + // Silently fail individual images to avoid spam + } finally { + val processed = processedCount.incrementAndGet() + if (processed % 100 == 0 || processed == images.size) { + println("Progress: $processed/${images.size} images processed ($successCount successful, $failedCount failed)") + } + } + } + } + + // Wait for all tasks to complete + executor.shutdown() + executor.awaitTermination(1, TimeUnit.HOURS) + + // Batch update the database (fast) + println("Updating database with ${blurhashes.size} blurhashes...") + conn.autoCommit = false + + try { + conn.prepareStatement("UPDATE IMAGE SET BLURHASH = ? WHERE ID = ?").use { updateStmt -> + var batchCount = 0 + + blurhashes.forEach { (imageId, blurhash) -> + updateStmt.setString(1, blurhash) + updateStmt.setLong(2, imageId) + updateStmt.addBatch() + batchCount++ + + // Execute batch every 500 records + if (batchCount % 500 == 0) { + updateStmt.executeBatch() + conn.commit() + batchCount = 0 + } + } + + // Execute remaining batch + if (batchCount > 0) { + updateStmt.executeBatch() + conn.commit() + } + } + } finally { + conn.autoCommit = true + } + + val duration = (System.currentTimeMillis() - startTime) / 1000.0 + println( + "Blurhash migration completed in %.2f seconds: %d of %d images processed successfully (%d failed)".format( + duration, successCount.get(), images.size, failedCount.get() + ) + ) + } +} + diff --git a/app/src/main/kotlin/org/gameyfin/db/h2/H2Aliases.kt b/app/src/main/kotlin/org/gameyfin/db/h2/H2Aliases.kt index f74ade9..a91a7c5 100644 --- a/app/src/main/kotlin/org/gameyfin/db/h2/H2Aliases.kt +++ b/app/src/main/kotlin/org/gameyfin/db/h2/H2Aliases.kt @@ -1,5 +1,7 @@ package org.gameyfin.db.h2 +import com.fasterxml.jackson.databind.ObjectMapper +import org.gameyfin.app.core.security.EncryptionUtils import java.sql.Connection import java.sql.SQLException @@ -10,6 +12,9 @@ import java.sql.SQLException * required at runtime for defining aliases in migration scripts. */ object H2Aliases { + + private val objectMapper = ObjectMapper() + /** * Renames a constraint if it exists, swallowing only H2 error code 90057 (constraint not found). */ @@ -26,5 +31,71 @@ object H2Aliases { } } } + + /** + * Convert a plain string to JSON string format (wrapped in quotes). + * Returns the input unchanged if it's already a JSON string. + */ + @JvmStatic + fun toJsonString(value: String?): String? { + if (value == null) return null + val decryptedValue = EncryptionUtils.decrypt(value) + // Check if already JSON string + if (decryptedValue.startsWith("\"") && decryptedValue.endsWith("\"")) return value + val jsonValue = objectMapper.writeValueAsString(decryptedValue) + return EncryptionUtils.encrypt(jsonValue) + } + + /** + * Convert a boolean string to JSON boolean format. + * Returns the input unchanged if it's already "true" or "false". + */ + @JvmStatic + fun toJsonBoolean(value: String?): String? { + if (value == null) return null + val decryptedValue = EncryptionUtils.decrypt(value) + // Already correct JSON format + if (decryptedValue == "true" || decryptedValue == "false") return value + return EncryptionUtils.encrypt(decryptedValue.lowercase()) + } + + /** + * Convert an integer string to JSON integer format. + * Returns the input unchanged if it's already a valid integer. + */ + @JvmStatic + fun toJsonInt(value: String?): String? { + if (value == null) return null + val decryptedValue = EncryptionUtils.decrypt(value) + return try { + EncryptionUtils.encrypt(decryptedValue.toInt().toString()) + } catch (_: NumberFormatException) { + value + } + } + + /** + * Convert a comma-separated string to JSON array format. + * Returns the input unchanged if it's already a JSON array. + */ + @JvmStatic + fun toJsonArray(value: String?): String? { + if (value == null) return null + val decryptedValue = EncryptionUtils.decrypt(value) + // Check if already JSON array + if (decryptedValue.startsWith("[") && decryptedValue.endsWith("]")) return value + + val elements = if (decryptedValue.isBlank()) { + emptyArray() + } else { + decryptedValue.split(",") + .map { it.trim() } + .filter { it.isNotBlank() } + .toTypedArray() + } + + val jsonValue = objectMapper.writeValueAsString(elements) + return EncryptionUtils.encrypt(jsonValue) + } } diff --git a/app/src/main/resources/application.yml b/app/src/main/resources/application.yml index 486ad24..08b8393 100644 --- a/app/src/main/resources/application.yml +++ b/app/src/main/resources/application.yml @@ -52,6 +52,7 @@ spring: fs.filesystem-root: ./data/ application: name: Gameyfin + version: @project.version@ threads: virtual.enabled: true mvc: diff --git a/app/src/main/resources/banner.txt b/app/src/main/resources/banner.txt index 4fc7f58..10c1d01 100644 --- a/app/src/main/resources/banner.txt +++ b/app/src/main/resources/banner.txt @@ -5,5 +5,5 @@ ${AnsiColor.BLUE}/ (_ / / _ `/ / ' \/ -_) / // /${AnsiColor.MAGENTA} / _/ / / ${AnsiColor.BLUE}\___/ \_,_/ /_/_/_/\__/ \_, / ${AnsiColor.MAGENTA}/_/ /_/ /_//_/ ${AnsiColor.BLUE} /___/ ${AnsiColor.DEFAULT} -${spring.application.name} ${application.version} +${spring.application.name} ${spring.application.version} diff --git a/app/src/main/resources/db/migration/V2.3.0.1__Convert_config_values_to_json_format.sql b/app/src/main/resources/db/migration/V2.3.0.1__Convert_config_values_to_json_format.sql new file mode 100644 index 0000000..a335780 --- /dev/null +++ b/app/src/main/resources/db/migration/V2.3.0.1__Convert_config_values_to_json_format.sql @@ -0,0 +1,64 @@ +-- Flyway Migration: V2.3.0.1 +-- Purpose: Convert config values from old format to JSON format for consistency +-- Context: ConfigService now uses ObjectMapper for all serialization/deserialization. +-- Old formats were: +-- - Primitives (String, Boolean, Int, Float): stored as plain strings +-- - Enums: stored as plain strings +-- - Arrays: stored as comma-separated values +-- New format: Everything stored as JSON + +-- Create aliases for conversion functions +CREATE ALIAS IF NOT EXISTS TO_JSON_STRING FOR "org.gameyfin.db.h2.H2Aliases.toJsonString"; +CREATE ALIAS IF NOT EXISTS TO_JSON_BOOLEAN FOR "org.gameyfin.db.h2.H2Aliases.toJsonBoolean"; +CREATE ALIAS IF NOT EXISTS TO_JSON_INT FOR "org.gameyfin.db.h2.H2Aliases.toJsonInt"; +CREATE ALIAS IF NOT EXISTS TO_JSON_ARRAY FOR "org.gameyfin.db.h2.H2Aliases.toJsonArray"; + +-- Convert String values to JSON format (wrap in quotes) +UPDATE APP_CONFIG SET "value" = TO_JSON_STRING("value") WHERE "key" = 'logs.folder'; +UPDATE APP_CONFIG SET "value" = TO_JSON_STRING("value") WHERE "key" = 'library.scan.title-extraction-regex'; +UPDATE APP_CONFIG SET "value" = TO_JSON_STRING("value") WHERE "key" = 'library.metadata.update.schedule'; +UPDATE APP_CONFIG SET "value" = TO_JSON_STRING("value") WHERE "key" = 'messages.providers.email.host'; +UPDATE APP_CONFIG SET "value" = TO_JSON_STRING("value") WHERE "key" = 'messages.providers.email.username'; +UPDATE APP_CONFIG SET "value" = TO_JSON_STRING("value") WHERE "key" = 'messages.providers.email.password'; +UPDATE APP_CONFIG SET "value" = TO_JSON_STRING("value") WHERE "key" = 'sso.oidc.roles-claim'; +UPDATE APP_CONFIG SET "value" = TO_JSON_STRING("value") WHERE "key" = 'sso.oidc.client-id'; +UPDATE APP_CONFIG SET "value" = TO_JSON_STRING("value") WHERE "key" = 'sso.oidc.client-secret'; +UPDATE APP_CONFIG SET "value" = TO_JSON_STRING("value") WHERE "key" = 'sso.oidc.issuer-url'; +UPDATE APP_CONFIG SET "value" = TO_JSON_STRING("value") WHERE "key" = 'sso.oidc.authorize-url'; +UPDATE APP_CONFIG SET "value" = TO_JSON_STRING("value") WHERE "key" = 'sso.oidc.token-url'; +UPDATE APP_CONFIG SET "value" = TO_JSON_STRING("value") WHERE "key" = 'sso.oidc.userinfo-url'; +UPDATE APP_CONFIG SET "value" = TO_JSON_STRING("value") WHERE "key" = 'sso.oidc.jwks-url'; +UPDATE APP_CONFIG SET "value" = TO_JSON_STRING("value") WHERE "key" = 'sso.oidc.logout-url'; + +-- Convert Boolean values to JSON format (true/false without quotes) +-- Note: These are likely already in correct format, but function is idempotent +UPDATE APP_CONFIG SET "value" = TO_JSON_BOOLEAN("value") WHERE "key" = 'library.allow-public-access'; +UPDATE APP_CONFIG SET "value" = TO_JSON_BOOLEAN("value") WHERE "key" = 'library.scan.enable-filesystem-watcher'; +UPDATE APP_CONFIG SET "value" = TO_JSON_BOOLEAN("value") WHERE "key" = 'library.scan.scan-empty-directories'; +UPDATE APP_CONFIG SET "value" = TO_JSON_BOOLEAN("value") WHERE "key" = 'library.scan.extract-title-using-regex'; +UPDATE APP_CONFIG SET "value" = TO_JSON_BOOLEAN("value") WHERE "key" = 'library.metadata.update.enabled'; +UPDATE APP_CONFIG SET "value" = TO_JSON_BOOLEAN("value") WHERE "key" = 'requests.games.enabled'; +UPDATE APP_CONFIG SET "value" = TO_JSON_BOOLEAN("value") WHERE "key" = 'requests.games.allow-guests-to-request-games'; +UPDATE APP_CONFIG SET "value" = TO_JSON_BOOLEAN("value") WHERE "key" = 'downloads.bandwidth-limit.enabled'; +UPDATE APP_CONFIG SET "value" = TO_JSON_BOOLEAN("value") WHERE "key" = 'users.sign-ups.allow'; +UPDATE APP_CONFIG SET "value" = TO_JSON_BOOLEAN("value") WHERE "key" = 'users.sign-ups.confirmation-required'; +UPDATE APP_CONFIG SET "value" = TO_JSON_BOOLEAN("value") WHERE "key" = 'sso.oidc.enabled'; +UPDATE APP_CONFIG SET "value" = TO_JSON_BOOLEAN("value") WHERE "key" = 'sso.oidc.auto-register-new-users'; +UPDATE APP_CONFIG SET "value" = TO_JSON_BOOLEAN("value") WHERE "key" = 'messages.providers.email.enabled'; + +-- Convert Int values to JSON format (plain numbers) +-- Note: These are likely already in correct format, but function is idempotent +UPDATE APP_CONFIG SET "value" = TO_JSON_INT("value") WHERE "key" = 'logs.max-history-days'; +UPDATE APP_CONFIG SET "value" = TO_JSON_INT("value") WHERE "key" = 'library.scan.title-match-min-ratio'; +UPDATE APP_CONFIG SET "value" = TO_JSON_INT("value") WHERE "key" = 'requests.games.max-open-requests-per-user'; +UPDATE APP_CONFIG SET "value" = TO_JSON_INT("value") WHERE "key" = 'downloads.bandwidth-limit.mbps'; +UPDATE APP_CONFIG SET "value" = TO_JSON_INT("value") WHERE "key" = 'messages.providers.email.port'; + +-- Convert Enum values to JSON format (quoted strings) +UPDATE APP_CONFIG SET "value" = TO_JSON_STRING("value") WHERE "key" = 'logs.level.gameyfin'; +UPDATE APP_CONFIG SET "value" = TO_JSON_STRING("value") WHERE "key" = 'logs.level.root'; +UPDATE APP_CONFIG SET "value" = TO_JSON_STRING("value") WHERE "key" = 'sso.oidc.match-existing-users-by'; + +-- Convert Array values to JSON format (from comma-separated to JSON array) +UPDATE APP_CONFIG SET "value" = TO_JSON_ARRAY("value") WHERE "key" = 'library.scan.game-file-extensions'; +UPDATE APP_CONFIG SET "value" = TO_JSON_ARRAY("value") WHERE "key" = 'sso.oidc.oauth-scopes'; \ No newline at end of file diff --git a/app/src/main/resources/db/migration/V2.3.0.2__Add_library_metadata_fields.sql b/app/src/main/resources/db/migration/V2.3.0.2__Add_library_metadata_fields.sql new file mode 100644 index 0000000..1e3aac1 --- /dev/null +++ b/app/src/main/resources/db/migration/V2.3.0.2__Add_library_metadata_fields.sql @@ -0,0 +1,11 @@ +-- Flyway Migration: V2.3.0.2 +-- Purpose: Add columns for library metadata fields to the LIBRARY table. +-- Context: These fields will store additional information about each library. + +--- Add COLUMN for "displayOnHomepage" +ALTER TABLE LIBRARY + ADD COLUMN DISPLAY_ON_HOMEPAGE BOOLEAN DEFAULT TRUE; + +--- Add COLUMN for "displayOrder" +ALTER TABLE LIBRARY + ADD COLUMN DISPLAY_ORDER INT DEFAULT -1; \ No newline at end of file diff --git a/app/src/main/resources/db/migration/V2.3.0.3__Create_collections_tables.sql b/app/src/main/resources/db/migration/V2.3.0.3__Create_collections_tables.sql new file mode 100644 index 0000000..6bf7d53 --- /dev/null +++ b/app/src/main/resources/db/migration/V2.3.0.3__Create_collections_tables.sql @@ -0,0 +1,29 @@ +-- Flyway Migration: V2.3.0.3 +-- Purpose: Create COLLECTION and COLLECTION_GAMES tables for Collections feature. + +CREATE SEQUENCE COLLECTION_SEQ INCREMENT BY 50; + +CREATE TABLE COLLECTION +( + ID BIGINT NOT NULL PRIMARY KEY, + CREATED_AT TIMESTAMP WITH TIME ZONE NOT NULL, + UPDATED_AT TIMESTAMP WITH TIME ZONE NOT NULL, + NAME CHARACTER VARYING(255) NOT NULL UNIQUE, + DESCRIPTION CHARACTER LARGE OBJECT, + DISPLAY_ON_HOMEPAGE BOOLEAN DEFAULT TRUE, + DISPLAY_ORDER INT DEFAULT -1 +); + +CREATE TABLE COLLECTION_GAMES +( + COLLECTIONS_ID BIGINT NOT NULL, + GAMES_ID BIGINT NOT NULL, + PRIMARY KEY (COLLECTIONS_ID, GAMES_ID), + CONSTRAINT FK_COLLECTION_GAMES_COLLECTION FOREIGN KEY (COLLECTIONS_ID) REFERENCES COLLECTION ON DELETE CASCADE, + CONSTRAINT FK_COLLECTION_GAMES_GAME FOREIGN KEY (GAMES_ID) REFERENCES GAME ON DELETE CASCADE +); + +-- Indexes to speed up lookup by GAMES_ID and COLLECTIONS_ID +CREATE INDEX IDX_COLLECTION_GAMES_GAME_ID ON COLLECTION_GAMES (GAMES_ID); +CREATE INDEX IDX_COLLECTION_GAMES_COLLECTION_ID ON COLLECTION_GAMES (COLLECTIONS_ID); + diff --git a/app/src/main/resources/db/migration/V2.3.0.4__Rename_library_allow_public_access_to_security.sql b/app/src/main/resources/db/migration/V2.3.0.4__Rename_library_allow_public_access_to_security.sql new file mode 100644 index 0000000..0a3a3ad --- /dev/null +++ b/app/src/main/resources/db/migration/V2.3.0.4__Rename_library_allow_public_access_to_security.sql @@ -0,0 +1,9 @@ +-- Flyway Migration: V2.3.0.4 +-- Purpose: Rename config key from 'library.allow-public-access' to 'security.allow-public-access' +-- Applies only if the old key has been set by the user + +-- Update key name if present +UPDATE APP_CONFIG +SET "key" = 'security.allow-public-access' +WHERE "key" = 'library.allow-public-access'; + diff --git a/app/src/main/resources/db/migration/V2.3.0.5__Add_collection_games_added_at_tracking.sql b/app/src/main/resources/db/migration/V2.3.0.5__Add_collection_games_added_at_tracking.sql new file mode 100644 index 0000000..0edc7f7 --- /dev/null +++ b/app/src/main/resources/db/migration/V2.3.0.5__Add_collection_games_added_at_tracking.sql @@ -0,0 +1,23 @@ +-- Flyway Migration: V2.3.0.5 +-- Purpose: Add tracking for when games are added to collections via COLLECTION_GAMES_ADDED_AT table + +-- Create table to track when each game was added to a collection +CREATE TABLE COLLECTION_GAMES_ADDED_AT +( + COLLECTION_ID BIGINT NOT NULL, + GAMES_ADDED_AT_KEY BIGINT NOT NULL, + GAMES_ADDED_AT TIMESTAMP WITH TIME ZONE NOT NULL, + PRIMARY KEY (COLLECTION_ID, GAMES_ADDED_AT_KEY), + CONSTRAINT FK_COLLECTION_GAMES_ADDED_AT_COLLECTION FOREIGN KEY (COLLECTION_ID) REFERENCES COLLECTION ON DELETE CASCADE +); + +-- Create index for better performance on lookups +CREATE INDEX IDX_COLLECTION_GAMES_ADDED_AT_COLLECTION_ID ON COLLECTION_GAMES_ADDED_AT (COLLECTION_ID); + +-- Initialize timestamps for existing collection-game relationships +-- Set the timestamp to the collection's created_at for all existing games +INSERT INTO COLLECTION_GAMES_ADDED_AT (COLLECTION_ID, GAMES_ADDED_AT_KEY, GAMES_ADDED_AT) +SELECT CG.COLLECTIONS_ID, CG.GAMES_ID, C.CREATED_AT +FROM COLLECTION_GAMES CG + JOIN COLLECTION C ON CG.COLLECTIONS_ID = C.ID; + diff --git a/app/src/main/resources/db/migration/V2.3.0.6__Add_blurhash_support_to_images.sql b/app/src/main/resources/db/migration/V2.3.0.6__Add_blurhash_support_to_images.sql new file mode 100644 index 0000000..4cfc1d0 --- /dev/null +++ b/app/src/main/resources/db/migration/V2.3.0.6__Add_blurhash_support_to_images.sql @@ -0,0 +1,17 @@ +-- Flyway Migration: V2.3.0.6 +-- Purpose: Add blurhash support to images for improved UI performance + +-- Add blurhash column to IMAGE table +ALTER TABLE IMAGE ADD COLUMN BLURHASH VARCHAR(255); + +-- Create alias for blurhash calculation helper +CREATE ALIAS IF NOT EXISTS CALCULATE_BLURHASHES_FOR_ALL_IMAGES FOR "org.gameyfin.db.h2.BlurhashMigration.calculateBlurhashesForAllImages"; + +-- Calculate blurhashes for all existing images +-- The data path is typically 'data' in the application root +-- Note: H2 automatically provides the Connection parameter to the Java method +CALL CALCULATE_BLURHASHES_FOR_ALL_IMAGES('data'); + +-- Drop the alias after use +DROP ALIAS IF EXISTS CALCULATE_BLURHASHES_FOR_ALL_IMAGES; + diff --git a/app/src/test/kotlin/org/gameyfin/app/collections/CollectionServiceTest.kt b/app/src/test/kotlin/org/gameyfin/app/collections/CollectionServiceTest.kt new file mode 100644 index 0000000..1f33a93 --- /dev/null +++ b/app/src/test/kotlin/org/gameyfin/app/collections/CollectionServiceTest.kt @@ -0,0 +1,199 @@ +package org.gameyfin.app.collections + +import io.mockk.* +import org.gameyfin.app.collections.dto.* +import org.gameyfin.app.collections.entities.Collection +import org.gameyfin.app.collections.repositories.CollectionRepository +import org.gameyfin.app.games.GameService +import org.gameyfin.app.games.entities.Game +import org.gameyfin.app.games.entities.GameMetadata +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.data.repository.findByIdOrNull +import reactor.core.publisher.Flux +import java.time.Duration +import java.time.Instant +import java.util.* +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class CollectionServiceTest { + + private lateinit var repository: CollectionRepository + private lateinit var gameService: GameService + private lateinit var service: CollectionService + + @BeforeEach + fun setup() { + repository = mockk() + gameService = mockk() + service = CollectionService(repository, gameService) + } + + @AfterEach + fun tearDown() { + unmockkAll() + clearAllMocks() + } + + @Test + fun `create should persist new collection`() { + val dto = CollectionCreateDto(name = "RPGs", description = "Role Playing Games", gameIds = listOf()) + val entitySlot = slot() + every { repository.findByName("RPGs") } returns null + every { repository.save(capture(entitySlot)) } answers { + entitySlot.captured.apply { + id = 1L; createdAt = Instant.now(); updatedAt = createdAt + } + } + + service.create(dto) + + verify { repository.save(any()) } + } + + @Test + fun `create should reject duplicate name`() { + val dto = CollectionCreateDto(name = "RPGs") + every { repository.findByName("RPGs") } returns Collection(name = "RPGs") + assertFailsWith { service.create(dto) } + } + + @Test + fun `update should modify name and description`() { + val existing = + Collection(name = "RPGs").apply { id = 1L; createdAt = Instant.now(); updatedAt = createdAt } + every { repository.findById(1L) } returns Optional.of(existing) + every { repository.findByName("New Name") } returns null + every { repository.save(existing) } returns existing + every { repository.findByIdOrNull(1L) } returns existing + + val dto = CollectionUpdateDto(id = 1L, name = "New Name", description = "Updated") + val result = service.update(dto) + assertEquals("New Name", result.name) + assertEquals("Updated", result.description) + } + + @Test + fun `addGame should associate game`() { + val existing = + Collection(name = "Action").apply { id = 1L; createdAt = Instant.now(); updatedAt = createdAt } + val game = Game( + library = mockk(relaxed = true), + metadata = GameMetadata(path = "test") + ) + game.id = 10L + every { repository.findByIdOrNull(1L) } returns existing + every { gameService.getById(10L) } returns game + every { gameService.update(game) } returns game.apply { collections += existing } + every { repository.save(existing) } returns existing + + val result = service.addGame(1L, 10L) + assertEquals(1, result.gameIds?.size) + assertEquals(10L, result.gameIds?.first()) + } + + @Test + fun `getAll should return mapped dtos`() { + val c1 = + Collection(name = "Indie").apply { id = 1L; createdAt = Instant.now(); updatedAt = createdAt } + val c2 = Collection(name = "AAA").apply { id = 2L; createdAt = Instant.now(); updatedAt = createdAt } + every { repository.findAll() } returns listOf(c1, c2) + val result = service.getAll() + assertEquals(2, result.size) + assertEquals("Indie", result[0].name) + assertEquals("AAA", result[1].name) + } + + @Test + fun `getById should throw when not found`() { + every { repository.findByIdOrNull(42L) } returns null + assertFailsWith { service.getById(42L) } + } + + @Test + fun `create should attach provided games`() { + val g1 = Game(library = mockk(relaxed = true), metadata = GameMetadata(path = "p1")).apply { id = 11L } + val g2 = Game(library = mockk(relaxed = true), metadata = GameMetadata(path = "p2")).apply { id = 12L } + val dto = CollectionCreateDto(name = "Favorites", gameIds = listOf(11L, 12L, 11L)) + val entitySlot = slot() + every { repository.findByName("Favorites") } returns null + every { gameService.getById(11L) } returns g1 + every { gameService.getById(12L) } returns g2 + every { repository.save(capture(entitySlot)) } answers { + entitySlot.captured.apply { id = 5L; createdAt = Instant.now(); updatedAt = createdAt } + } + val result = service.create(dto) + verify { repository.save(any()) } + } + + @Test + fun `update should reject duplicate name`() { + val existing = + Collection(name = "Old").apply { id = 3L; createdAt = Instant.now(); updatedAt = createdAt } + every { repository.findById(3L) } returns Optional.of(existing) + every { repository.findByName("Old") } returns existing + every { repository.findByName("New") } returns Collection(name = "New") + assertFailsWith { service.update(CollectionUpdateDto(id = 3L, name = "New")) } + } + + @Test + fun `update should replace games set`() { + val existing = + Collection(name = "Mix").apply { id = 4L; createdAt = Instant.now(); updatedAt = createdAt } + val g1 = Game(library = mockk(relaxed = true), metadata = GameMetadata(path = "a")).apply { id = 21L } + val g2 = Game(library = mockk(relaxed = true), metadata = GameMetadata(path = "b")).apply { id = 22L } + // pre-populate with one game to ensure replacement + val old = Game(library = mockk(relaxed = true), metadata = GameMetadata(path = "old")).apply { id = 99L } + existing.addGame(old) + every { repository.findById(4L) } returns Optional.of(existing) + every { gameService.getById(21L) } returns g1 + every { gameService.getById(22L) } returns g2 + every { repository.save(existing) } returns existing + every { repository.findByIdOrNull(4L) } returns existing + val dto = CollectionUpdateDto(id = 4L, gameIds = listOf(21L, 22L)) + val result = service.update(dto) + assertEquals(listOf(21L, 22L), result.gameIds) + } + + @Test + fun `removeGame should detach association`() { + val existing = + Collection(name = "Arcade").apply { id = 6L; createdAt = Instant.now(); updatedAt = createdAt } + val game = Game(library = mockk(relaxed = true), metadata = GameMetadata(path = "x")).apply { id = 77L } + existing.addGame(game) + every { repository.findByIdOrNull(6L) } returns existing + every { gameService.getById(77L) } returns game + every { gameService.update(game) } returns game.apply { collections -= existing } + every { repository.save(existing) } returns existing + + val result = service.removeGame(6L, 77L) + + assertEquals(0, result.gameIds?.size ?: 0) + } + + @Test + fun `delete should delegate to repository`() { + every { repository.deleteById(8L) } just Runs + service.delete(8L) + verify { repository.deleteById(8L) } + } + + @Test + fun `companion events emit and subscribe`() { + // subscribe and capture first buffered batch + val flux: Flux> = CollectionService.subscribeUser().take(1) + val now = Instant.now() + val userDto = + CollectionUserDto(1L, now, now, "n", null, emptyList(), CollectionMetadataDto(true, 1, emptyMap())) + CollectionService.emitUser(CollectionUserEvent.Created(userDto)) + val batch = flux.blockFirst(Duration.ofSeconds(1)) + assertEquals(1, batch?.size) + + val adminFlux: Flux> = CollectionService.subscribeAdmin().take(1) + CollectionService.emitAdmin(CollectionAdminEvent.Deleted(2L)) + val adminBatch = adminFlux.blockFirst(Duration.ofSeconds(1)) + assertEquals(1, adminBatch?.size) + } +} diff --git a/app/src/test/kotlin/org/gameyfin/app/collections/dto/CollectionEventsTest.kt b/app/src/test/kotlin/org/gameyfin/app/collections/dto/CollectionEventsTest.kt new file mode 100644 index 0000000..0f7cb67 --- /dev/null +++ b/app/src/test/kotlin/org/gameyfin/app/collections/dto/CollectionEventsTest.kt @@ -0,0 +1,24 @@ +package org.gameyfin.app.collections.dto + +import java.time.Instant +import kotlin.test.Test +import kotlin.test.assertEquals + +class CollectionEventsTest { + @Test + fun userEventsCarryType() { + val dto = CollectionUserDto(1L, Instant.now(), Instant.now(), "N", null, emptyList(), null) + assertEquals("created", CollectionUserEvent.Created(dto).type) + assertEquals("updated", CollectionUserEvent.Updated(dto).type) + assertEquals("deleted", CollectionUserEvent.Deleted(1L).type) + } + + @Test + fun adminEventsCarryType() { + val dto = CollectionAdminDto(1L, Instant.now(), Instant.now(), "N", null, emptyList(), null, null) + assertEquals("created", CollectionAdminEvent.Created(dto).type) + assertEquals("updated", CollectionAdminEvent.Updated(dto).type) + assertEquals("deleted", CollectionAdminEvent.Deleted(1L).type) + } +} + diff --git a/app/src/test/kotlin/org/gameyfin/app/collections/entities/CollectionEntityTest.kt b/app/src/test/kotlin/org/gameyfin/app/collections/entities/CollectionEntityTest.kt new file mode 100644 index 0000000..75f8f46 --- /dev/null +++ b/app/src/test/kotlin/org/gameyfin/app/collections/entities/CollectionEntityTest.kt @@ -0,0 +1,66 @@ +package org.gameyfin.app.collections.entities + +import io.mockk.every +import io.mockk.mockk +import org.gameyfin.app.games.entities.Game +import org.gameyfin.app.games.entities.GameMetadata +import org.gameyfin.app.libraries.entities.Library +import kotlin.test.* + +class CollectionEntityTest { + @Test + fun addAndRemoveGameMaintainBidirectionalAssociation() { + val collection = Collection(name = "Test") + val game = Game( + library = mockk(relaxed = true), + metadata = GameMetadata(path = "p") + ) + // ensure id not required for association behavior + assertEquals(0, collection.games.size) + assertFalse(game.collections.contains(collection)) + + collection.addGame(game) + assertEquals(1, collection.games.size) + assertTrue(game.collections.contains(collection)) + + collection.removeGame(game) + assertEquals(0, collection.games.size) + assertFalse(game.collections.contains(collection)) + } + + @Test + fun addGameTracksTimestamp() { + val collection = Collection(name = "Test") + val game = mockk(relaxed = true) + val gameId = 123L + every { game.id } returns gameId + every { game.collections } returns mutableListOf() + + // Before adding, no timestamp should exist + assertNull(collection.metadata.gamesAddedAt[gameId]) + + collection.addGame(game) + + // After adding, timestamp should be recorded + assertNotNull(collection.metadata.gamesAddedAt[gameId]) + assertTrue(collection.metadata.gamesAddedAt.containsKey(gameId)) + } + + @Test + fun removeGameRemovesTimestamp() { + val collection = Collection(name = "Test") + val game = mockk(relaxed = true) + val gameId = 456L + every { game.id } returns gameId + every { game.collections } returns mutableListOf() + + collection.addGame(game) + assertNotNull(collection.metadata.gamesAddedAt[gameId]) + + collection.removeGame(game) + + // After removing, timestamp should be removed + assertNull(collection.metadata.gamesAddedAt[gameId]) + assertFalse(collection.metadata.gamesAddedAt.containsKey(gameId)) + } +} diff --git a/app/src/test/kotlin/org/gameyfin/app/collections/extensions/CollectionExtensionsTest.kt b/app/src/test/kotlin/org/gameyfin/app/collections/extensions/CollectionExtensionsTest.kt new file mode 100644 index 0000000..e7eeb82 --- /dev/null +++ b/app/src/test/kotlin/org/gameyfin/app/collections/extensions/CollectionExtensionsTest.kt @@ -0,0 +1,65 @@ +package org.gameyfin.app.collections.extensions + +import io.mockk.every +import io.mockk.mockkStatic +import org.gameyfin.app.collections.entities.Collection +import org.gameyfin.app.core.security.isCurrentUserAdmin +import org.gameyfin.app.games.entities.Game +import org.gameyfin.app.games.entities.GameMetadata +import kotlin.test.Test +import kotlin.test.assertEquals + +class CollectionExtensionsTest { + @Test + fun toAdminDtoBuildsStats() { + val c = Collection(name = "Stats").apply { id = 1L; createdAt = java.time.Instant.now(); updatedAt = createdAt } + val g1 = Game(library = mockkLibrary(), metadata = GameMetadata(path = "p1")).apply { + id = 11L; platforms = mutableListOf(org.gameyfin.pluginapi.gamemetadata.Platform.PC_MICROSOFT_WINDOWS) + } + val g2 = Game(library = mockkLibrary(), metadata = GameMetadata(path = "p2")).apply { + id = 12L; platforms = mutableListOf( + org.gameyfin.pluginapi.gamemetadata.Platform.LINUX, + org.gameyfin.pluginapi.gamemetadata.Platform.PC_MICROSOFT_WINDOWS + ) + } + g1.metadata.downloadCount = 3 + g2.metadata.downloadCount = 7 + c.addGame(g1); c.addGame(g2) + + val dto = c.toAdminDto() + assertEquals(2, dto.stats?.gamesCount) + assertEquals(10, dto.stats?.downloadCount) + assertEquals( + setOf( + org.gameyfin.pluginapi.gamemetadata.Platform.PC_MICROSOFT_WINDOWS, + org.gameyfin.pluginapi.gamemetadata.Platform.LINUX + ), dto.stats?.gamePlatforms + ) + assertEquals(listOf(11L, 12L), dto.gameIds) + } + + @Test + fun toUserDtoOmitsStatsAndMapsIds() { + val c = Collection(name = "User").apply { id = 2L; createdAt = java.time.Instant.now(); updatedAt = createdAt } + val g = Game(library = mockkLibrary(), metadata = GameMetadata(path = "p")).apply { id = 21L } + c.addGame(g) + val dto = c.toUserDto() + assertEquals(listOf(21L), dto.gameIds) + } + + @Test + fun toDtoSwitchesByAdminFlag() { + mockkStatic(::isCurrentUserAdmin) + val c = + Collection(name = "Switch").apply { id = 3L; createdAt = java.time.Instant.now(); updatedAt = createdAt } + every { isCurrentUserAdmin() } returns true + val admin = c.toDto() + assertEquals("Switch", admin.name) + every { isCurrentUserAdmin() } returns false + val user = c.toDto() + assertEquals("Switch", user.name) + } + + private fun mockkLibrary(): org.gameyfin.app.libraries.entities.Library = + io.mockk.mockk(relaxed = true) +} diff --git a/app/src/test/kotlin/org/gameyfin/app/config/ConfigServiceTest.kt b/app/src/test/kotlin/org/gameyfin/app/config/ConfigServiceTest.kt new file mode 100644 index 0000000..7bc8bdd --- /dev/null +++ b/app/src/test/kotlin/org/gameyfin/app/config/ConfigServiceTest.kt @@ -0,0 +1,460 @@ +package org.gameyfin.app.config + +import com.fasterxml.jackson.databind.ObjectMapper +import io.mockk.* +import org.gameyfin.app.config.entities.ConfigEntry +import org.gameyfin.app.config.persistence.ConfigRepository +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.springframework.boot.logging.LogLevel +import org.springframework.data.repository.findByIdOrNull +import java.io.Serializable +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class ConfigServiceTest { + + private lateinit var configRepository: ConfigRepository + private lateinit var objectMapper: ObjectMapper + private lateinit var configService: ConfigService + + @BeforeEach + fun setup() { + configRepository = mockk() + objectMapper = ObjectMapper() + configService = ConfigService(configRepository, objectMapper) + } + + // ========== String Type Tests ========== + + @Test + fun `get returns String value from database`() { + val key = "logs.folder" + val value = "./logs" + val configEntry = ConfigEntry(key, objectMapper.writeValueAsString(value)) + + every { configRepository.findByIdOrNull(key) } returns configEntry + + val result = configService.get(ConfigProperties.Logs.Folder) + + assertEquals(value, result) + } + + @Test + fun `get returns default String value when not in database`() { + val key = "logs.folder" + + every { configRepository.findByIdOrNull(key) } returns null + + val result = configService.get(ConfigProperties.Logs.Folder) + + assertEquals("./logs", result) + } + + @Test + fun `set stores String value as JSON in database`() { + val key = "logs.folder" + val value = "/var/logs" + val slot = slot() + + every { configRepository.findByIdOrNull(key) } returns null + every { configRepository.save(capture(slot)) } returns mockk() + + configService.set(ConfigProperties.Logs.Folder, value) + + verify { configRepository.save(any()) } + assertEquals(key, slot.captured.key) + assertEquals(objectMapper.writeValueAsString(value), slot.captured.value) + } + + // ========== Boolean Type Tests ========== + + @Test + fun `get returns Boolean value from database`() { + val key = "security.allow-public-access" + val value = true + val configEntry = ConfigEntry(key, objectMapper.writeValueAsString(value)) + + every { configRepository.findByIdOrNull(key) } returns configEntry + + val result = configService.get(ConfigProperties.Security.AllowPublicAccess) + + assertEquals(value, result) + } + + @Test + fun `set stores Boolean value as JSON in database`() { + val key = "security.allow-public-access" + val value = true + val slot = slot() + + every { configRepository.findByIdOrNull(key) } returns null + every { configRepository.save(capture(slot)) } returns mockk() + + configService.set(ConfigProperties.Security.AllowPublicAccess, value) + + verify { configRepository.save(any()) } + assertEquals(key, slot.captured.key) + assertEquals(objectMapper.writeValueAsString(value), slot.captured.value) + } + + // ========== Int Type Tests ========== + + @Test + fun `get returns Int value from database`() { + val key = "logs.max-history-days" + val value = 30 + val configEntry = ConfigEntry(key, objectMapper.writeValueAsString(value)) + + every { configRepository.findByIdOrNull(key) } returns configEntry + + val result = configService.get(ConfigProperties.Logs.MaxHistoryDays) + + assertEquals(value, result) + } + + @Test + fun `set stores Int value as JSON in database`() { + val key = "logs.max-history-days" + val value = 60 + val slot = slot() + + every { configRepository.findByIdOrNull(key) } returns null + every { configRepository.save(capture(slot)) } returns mockk() + + configService.set(ConfigProperties.Logs.MaxHistoryDays, value) + + verify { configRepository.save(any()) } + assertEquals(key, slot.captured.key) + assertEquals(objectMapper.writeValueAsString(value), slot.captured.value) + } + + // ========== Enum Type Tests ========== + + @Test + fun `get returns Enum value from database`() { + val key = "logs.level.gameyfin" + val value = LogLevel.INFO + val configEntry = ConfigEntry(key, objectMapper.writeValueAsString(value)) + + every { configRepository.findByIdOrNull(key) } returns configEntry + + val result = configService.get(ConfigProperties.Logs.Level.Gameyfin) + + assertEquals(value, result) + } + + @Test + fun `set stores Enum value as JSON in database`() { + val key = "logs.level.gameyfin" + val value = LogLevel.DEBUG + val slot = slot() + + every { configRepository.findByIdOrNull(key) } returns null + every { configRepository.save(capture(slot)) } returns mockk() + + configService.set(ConfigProperties.Logs.Level.Gameyfin, value) + + verify { configRepository.save(any()) } + assertEquals(key, slot.captured.key) + assertEquals(objectMapper.writeValueAsString(value), slot.captured.value) + } + + @Test + fun `get returns default Enum value when not in database`() { + val key = "logs.level.gameyfin" + + every { configRepository.findByIdOrNull(key) } returns null + + val result = configService.get(ConfigProperties.Logs.Level.Gameyfin) + + assertEquals(LogLevel.INFO, result) + } + + // ========== Array Type Tests ========== + + @Test + fun `get returns String Array value from database`() { + val key = "sso.oidc.oauth-scopes" + val value = arrayOf("openid", "profile", "email") + val configEntry = ConfigEntry(key, objectMapper.writeValueAsString(value)) + + every { configRepository.findByIdOrNull(key) } returns configEntry + + val result = configService.get(ConfigProperties.SSO.OIDC.OAuthScopes) + + assertNotNull(result) + assertEquals(value.size, result.size) + assertEquals(value.toList(), result.toList()) + } + + @Test + fun `set stores String Array value as JSON in database`() { + val key = "sso.oidc.oauth-scopes" + val value = arrayOf("openid", "profile") + val slot = slot() + + every { configRepository.findByIdOrNull(key) } returns null + every { configRepository.save(capture(slot)) } returns mockk() + + configService.set(ConfigProperties.SSO.OIDC.OAuthScopes, value) + + verify { configRepository.save(any()) } + assertEquals(key, slot.captured.key) + assertEquals(objectMapper.writeValueAsString(value), slot.captured.value) + } + + @Test + fun `get returns empty String Array from database`() { + val key = "sso.oidc.oauth-scopes" + val value = arrayOf() + val configEntry = ConfigEntry(key, objectMapper.writeValueAsString(value)) + + every { configRepository.findByIdOrNull(key) } returns configEntry + + val result = configService.get(ConfigProperties.SSO.OIDC.OAuthScopes) + + assertNotNull(result) + assertEquals(0, result.size) + } + + // ========== Update Method Tests ========== + + @Test + fun `set updates existing config entry`() { + val key = "logs.folder" + val oldValue = "./logs" + val newValue = "/var/logs" + val existingEntry = ConfigEntry(key, objectMapper.writeValueAsString(oldValue)) + val slot = slot() + + every { configRepository.findByIdOrNull(key) } returns existingEntry + every { configRepository.save(capture(slot)) } returns mockk() + + configService.set(ConfigProperties.Logs.Folder, newValue) + + verify { configRepository.save(any()) } + assertEquals(key, slot.captured.key) + assertEquals(objectMapper.writeValueAsString(newValue), slot.captured.value) + } + + // ========== Delete Method Tests ========== + + @Test + fun `delete removes config entry from database`() { + val key = "logs.folder" + + every { configRepository.deleteById(key) } just Runs + + configService.delete(key) + + verify { configRepository.deleteById(key) } + } + + @Test + fun `delete with unknown key throws IllegalArgumentException`() { + val key = "unknown.key" + + assertThrows { + configService.delete(key) + } + } + + // ========== Get by String Key Tests ========== + + @Test + fun `get by string key returns value from database`() { + val key = "logs.folder" + val value = "./logs" + val configEntry = ConfigEntry(key, objectMapper.writeValueAsString(value)) + + every { configRepository.findByIdOrNull(key) } returns configEntry + + val result = configService.get(key) + + assertEquals(value, result) + } + + @Test + fun `get by string key with unknown key throws IllegalArgumentException`() { + val key = "unknown.key" + + assertThrows { + configService.get(key) + } + } + + // ========== Null Value Tests ========== + + @Test + fun `get returns null when value is not set and no default exists`() { + val key = "messages.providers.email.host" + + every { configRepository.findByIdOrNull(key) } returns null + + val result = configService.get(ConfigProperties.Messages.Providers.Email.Host) + + assertNull(result) + } + + // ========== Error Handling Tests ========== + + @Test + fun `deserializeValue throws IllegalArgumentException for invalid JSON`() { + val key = "logs.max-history-days" + val invalidJson = "not-a-number" + val configEntry = ConfigEntry(key, invalidJson) + + every { configRepository.findByIdOrNull(key) } returns configEntry + + assertThrows { + configService.get(ConfigProperties.Logs.MaxHistoryDays) + } + } + + // ========== GetAll Tests ========== + + @Test + fun `getAll returns all config properties`() { + // Mock all possible config entries + every { configRepository.findByIdOrNull(any()) } returns null + + val result = configService.getAll() + + // Verify we get multiple entries + assert(result.isNotEmpty()) + + // Check that each entry has the required fields + result.forEach { entry -> + assertNotNull(entry.key) + assertNotNull(entry.type) + assertNotNull(entry.description) + } + } + + @Test + fun `getAll returns values from database when available`() { + val key = "logs.folder" + val value = "/custom/logs" + val configEntry = ConfigEntry(key, objectMapper.writeValueAsString(value)) + + every { configRepository.findByIdOrNull(key) } returns configEntry + every { configRepository.findByIdOrNull(not(key)) } returns null + + val result = configService.getAll() + + val logsEntry = result.find { it.key == key } + assertNotNull(logsEntry) + assertEquals(value, logsEntry.value) + } + + // ========== Type-Safe Set Method Tests ========== + + @Test + fun `set with ConfigProperty delegates to set with string key`() { + val key = "logs.folder" + val value = "/var/logs" + val slot = slot() + + every { configRepository.findByIdOrNull(key) } returns null + every { configRepository.save(capture(slot)) } returns mockk() + + configService.set(ConfigProperties.Logs.Folder, value) + + verify { configRepository.save(any()) } + assertEquals(key, slot.captured.key) + } + + // ========== Complex Object Tests (Future-proofing) ========== + + class TestComplexObject() : Serializable { + var name: String = "" + var count: Int = 0 + var enabled: Boolean = false + + constructor(name: String, count: Int, enabled: Boolean) : this() { + this.name = name + this.count = count + this.enabled = enabled + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is TestComplexObject) return false + return name == other.name && count == other.count && enabled == other.enabled + } + + override fun hashCode(): Int { + var result = name.hashCode() + result = 31 * result + count + result = 31 * result + enabled.hashCode() + return result + } + } + + @Test + fun `ObjectMapper can serialize complex objects`() { + val complexObject = TestComplexObject("test", 42, true) + val json = objectMapper.writeValueAsString(complexObject) + + assertNotNull(json) + assert(json.contains("test")) + assert(json.contains("42")) + assert(json.contains("true")) + } + + @Test + fun `ObjectMapper can deserialize complex objects`() { + val complexObject = TestComplexObject("test", 42, true) + val json = objectMapper.writeValueAsString(complexObject) + val deserialized = objectMapper.readValue(json, TestComplexObject::class.java) + + assertEquals(complexObject, deserialized) + } + + // ========== Edge Cases ========== + + @Test + fun `set handles special characters in string values`() { + val key = "messages.providers.email.password" + val value = "p@ssw0rd!#$%^&*()" + val slot = slot() + + every { configRepository.findByIdOrNull(key) } returns null + every { configRepository.save(capture(slot)) } returns mockk() + + configService.set(ConfigProperties.Messages.Providers.Email.Password, value) + + verify { configRepository.save(any()) } + + // Deserialize the saved value to ensure it was properly serialized + val deserializedValue = objectMapper.readValue(slot.captured.value, String::class.java) + assertEquals(value, deserializedValue) + } + + @Test + fun `set handles empty string values`() { + val key = "messages.providers.email.host" + val value = "" + val slot = slot() + + every { configRepository.findByIdOrNull(key) } returns null + every { configRepository.save(capture(slot)) } returns mockk() + + configService.set(ConfigProperties.Messages.Providers.Email.Host, value) + + verify { configRepository.save(any()) } + assertEquals(objectMapper.writeValueAsString(value), slot.captured.value) + } + + @Test + fun `Array with special characters is properly serialized and deserialized`() { + val value = arrayOf("test@example.com", "user+tag@domain.com", "special!chars#here") + val json = objectMapper.writeValueAsString(value) + val deserialized = objectMapper.readValue(json, Array::class.java) + + assertEquals(value.toList(), deserialized.toList()) + } +} diff --git a/app/src/test/kotlin/org/gameyfin/app/core/exceptions/EndpointExceptionHandlerTest.kt b/app/src/test/kotlin/org/gameyfin/app/core/exceptions/EndpointExceptionHandlerTest.kt new file mode 100644 index 0000000..5a73e29 --- /dev/null +++ b/app/src/test/kotlin/org/gameyfin/app/core/exceptions/EndpointExceptionHandlerTest.kt @@ -0,0 +1,88 @@ +package org.gameyfin.app.core.exceptions + +import com.vaadin.hilla.Endpoint +import com.vaadin.hilla.exception.EndpointException +import io.mockk.clearAllMocks +import io.mockk.unmockkAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.EnableAspectJAutoProxy +import org.springframework.context.annotation.Import +import org.springframework.stereotype.Component +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +/** + * Test endpoint to verify exception handling + */ +@Endpoint +@Component +class TestEndpoint { + fun throwRuntimeException() { + throw RuntimeException("Test runtime exception") + } + + fun throwIllegalArgumentException() { + throw IllegalArgumentException("Test illegal argument") + } + + fun throwEndpointException() { + throw EndpointException("Already an endpoint exception") + } + + fun returnNormally(): String { + return "Success" + } +} + +@Configuration +@EnableAspectJAutoProxy +@Import(TestEndpoint::class, EndpointExceptionHandler::class) +class TestConfig + +@SpringBootTest(classes = [TestConfig::class]) +class EndpointExceptionHandlerTest { + + @Autowired + private lateinit var testEndpoint: TestEndpoint + + @AfterEach + fun tearDown() { + unmockkAll() + clearAllMocks() + } + + @Test + fun `should wrap RuntimeException in EndpointException`() { + val exception = assertFailsWith { + testEndpoint.throwRuntimeException() + } + assertEquals("Test runtime exception", exception.message) + } + + @Test + fun `should wrap IllegalArgumentException in EndpointException`() { + val exception = assertFailsWith { + testEndpoint.throwIllegalArgumentException() + } + assertEquals("Test illegal argument", exception.message) + } + + @Test + fun `should re-throw EndpointException as-is`() { + val exception = assertFailsWith { + testEndpoint.throwEndpointException() + } + assertEquals("Already an endpoint exception", exception.message) + } + + @Test + fun `should not interfere with normal execution`() { + val result = testEndpoint.returnNormally() + assertEquals("Success", result) + } +} + diff --git a/app/src/test/kotlin/org/gameyfin/app/core/security/DynamicPublicAccessAuthorizationManagerTest.kt b/app/src/test/kotlin/org/gameyfin/app/core/security/DynamicPublicAccessAuthorizationManagerTest.kt index e41277f..90463c6 100644 --- a/app/src/test/kotlin/org/gameyfin/app/core/security/DynamicPublicAccessAuthorizationManagerTest.kt +++ b/app/src/test/kotlin/org/gameyfin/app/core/security/DynamicPublicAccessAuthorizationManagerTest.kt @@ -39,7 +39,7 @@ class DynamicPublicAccessAuthorizationManagerTest { @Test fun `check should allow access when user is authenticated and public access is disabled`() { - every { configService.get(ConfigProperties.Libraries.AllowPublicAccess) } returns false + every { configService.get(ConfigProperties.Security.AllowPublicAccess) } returns false val authentication = UsernamePasswordAuthenticationToken( "user", @@ -56,7 +56,7 @@ class DynamicPublicAccessAuthorizationManagerTest { @Test fun `check should allow access when user is authenticated and public access is enabled`() { - every { configService.get(ConfigProperties.Libraries.AllowPublicAccess) } returns true + every { configService.get(ConfigProperties.Security.AllowPublicAccess) } returns true val authentication = UsernamePasswordAuthenticationToken( "user", @@ -73,7 +73,7 @@ class DynamicPublicAccessAuthorizationManagerTest { @Test fun `check should allow access when user is not authenticated and public access is enabled`() { - every { configService.get(ConfigProperties.Libraries.AllowPublicAccess) } returns true + every { configService.get(ConfigProperties.Security.AllowPublicAccess) } returns true val authentication = mockk() every { authentication.isAuthenticated } returns false @@ -89,7 +89,7 @@ class DynamicPublicAccessAuthorizationManagerTest { @Test fun `check should deny access when user is not authenticated and public access is disabled`() { - every { configService.get(ConfigProperties.Libraries.AllowPublicAccess) } returns false + every { configService.get(ConfigProperties.Security.AllowPublicAccess) } returns false val authentication = mockk() every { authentication.isAuthenticated } returns false @@ -105,7 +105,7 @@ class DynamicPublicAccessAuthorizationManagerTest { @Test fun `check should deny access when authentication is null and public access is disabled`() { - every { configService.get(ConfigProperties.Libraries.AllowPublicAccess) } returns false + every { configService.get(ConfigProperties.Security.AllowPublicAccess) } returns false val authSupplier = Supplier { null } @@ -117,7 +117,7 @@ class DynamicPublicAccessAuthorizationManagerTest { @Test fun `check should allow access when authentication is null and public access is enabled`() { - every { configService.get(ConfigProperties.Libraries.AllowPublicAccess) } returns true + every { configService.get(ConfigProperties.Security.AllowPublicAccess) } returns true val authSupplier = Supplier { null } @@ -129,7 +129,7 @@ class DynamicPublicAccessAuthorizationManagerTest { @Test fun `check should deny access when user is authenticated as anonymousUser and public access is disabled`() { - every { configService.get(ConfigProperties.Libraries.AllowPublicAccess) } returns false + every { configService.get(ConfigProperties.Security.AllowPublicAccess) } returns false val authentication = mockk() every { authentication.isAuthenticated } returns true @@ -145,7 +145,7 @@ class DynamicPublicAccessAuthorizationManagerTest { @Test fun `check should allow access when user is authenticated as non-anonymous and public access is disabled`() { - every { configService.get(ConfigProperties.Libraries.AllowPublicAccess) } returns false + every { configService.get(ConfigProperties.Security.AllowPublicAccess) } returns false val authentication = mockk() every { authentication.isAuthenticated } returns true @@ -161,7 +161,7 @@ class DynamicPublicAccessAuthorizationManagerTest { @Test fun `check should deny access when public access config is null and user not authenticated`() { - every { configService.get(ConfigProperties.Libraries.AllowPublicAccess) } returns null + every { configService.get(ConfigProperties.Security.AllowPublicAccess) } returns null val authentication = mockk() every { authentication.isAuthenticated } returns false @@ -177,7 +177,7 @@ class DynamicPublicAccessAuthorizationManagerTest { @Test fun `check should work when supplier is null`() { - every { configService.get(ConfigProperties.Libraries.AllowPublicAccess) } returns false + every { configService.get(ConfigProperties.Security.AllowPublicAccess) } returns false val decision = manager.authorize(null, context) @@ -187,7 +187,7 @@ class DynamicPublicAccessAuthorizationManagerTest { @Test fun `check should work when context is null`() { - every { configService.get(ConfigProperties.Libraries.AllowPublicAccess) } returns false + every { configService.get(ConfigProperties.Security.AllowPublicAccess) } returns false val authentication = UsernamePasswordAuthenticationToken( "user", diff --git a/app/src/test/kotlin/org/gameyfin/app/games/GameServiceTest.kt b/app/src/test/kotlin/org/gameyfin/app/games/GameServiceTest.kt index fddab06..02f1012 100644 --- a/app/src/test/kotlin/org/gameyfin/app/games/GameServiceTest.kt +++ b/app/src/test/kotlin/org/gameyfin/app/games/GameServiceTest.kt @@ -15,7 +15,9 @@ import org.gameyfin.app.games.entities.* import org.gameyfin.app.games.extensions.toDtos import org.gameyfin.app.games.repositories.GameRepository import org.gameyfin.app.libraries.entities.Library +import org.gameyfin.app.media.Image import org.gameyfin.app.media.ImageService +import org.gameyfin.app.media.ImageType import org.gameyfin.app.users.UserService import org.gameyfin.app.users.entities.User import org.gameyfin.pluginapi.gamemetadata.GameMetadataProvider @@ -587,7 +589,7 @@ class GameServiceTest { every { gameRepository.findByIdOrNull(999L) } returns null assertThrows(IllegalArgumentException::class.java) { - gameService.update(game) + gameService.updateMetadata(game) } } @@ -603,7 +605,7 @@ class GameServiceTest { every { pluginManager.getExtensions("test-plugin") } returns emptyList() assertThrows(NoSuchElementException::class.java) { - gameService.update(game) + gameService.updateMetadata(game) } } @@ -625,7 +627,7 @@ class GameServiceTest { every { pluginService.getPluginManagementEntry(provider.javaClass) } returns pluginEntry every { pluginManager.getPluginForExtension(provider.javaClass) } returns null - val result = gameService.update(game) + val result = gameService.updateMetadata(game) assertNull(result) } @@ -660,7 +662,7 @@ class GameServiceTest { every { filesystemService.calculateFileSize(any()) } returns 1000L every { library.platforms } returns mutableListOf(Platform.PC_MICROSOFT_WINDOWS) - val result = gameService.update(game) + val result = gameService.updateMetadata(game) assertNull(result) } @@ -690,7 +692,7 @@ class GameServiceTest { every { imageService.createOrGet(any()) } returns mockk(relaxed = true) every { filesystemService.calculateFileSize(any()) } returns 1000L - val result = gameService.update(game) + val result = gameService.updateMetadata(game) assertNotNull(result) assertEquals("New Title", result.title) @@ -725,7 +727,7 @@ class GameServiceTest { every { filesystemService.calculateFileSize(any()) } returns 1000L every { library.platforms } returns mutableListOf(Platform.PC_MICROSOFT_WINDOWS) - val result = gameService.update(game) + val result = gameService.updateMetadata(game) // Should return null because no fields were actually updated assertNull(result) @@ -763,7 +765,7 @@ class GameServiceTest { every { imageService.createOrGet(any()) } returns mockk(relaxed = true) every { filesystemService.calculateFileSize(any()) } returns 1000L - val result = gameService.update(game) + val result = gameService.updateMetadata(game) assertNotNull(result) assertEquals("New Title", result.title) diff --git a/app/src/test/kotlin/org/gameyfin/app/games/extensions/GameExtensionsTest.kt b/app/src/test/kotlin/org/gameyfin/app/games/extensions/GameExtensionsTest.kt index 9206283..2025944 100644 --- a/app/src/test/kotlin/org/gameyfin/app/games/extensions/GameExtensionsTest.kt +++ b/app/src/test/kotlin/org/gameyfin/app/games/extensions/GameExtensionsTest.kt @@ -8,8 +8,9 @@ import org.gameyfin.app.games.dto.GameUserDto import org.gameyfin.app.games.entities.Company import org.gameyfin.app.games.entities.CompanyType import org.gameyfin.app.games.entities.Game -import org.gameyfin.app.games.entities.Image import org.gameyfin.app.libraries.entities.Library +import org.gameyfin.app.media.Image +import org.gameyfin.app.media.ImageType import org.gameyfin.pluginapi.gamemetadata.* import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach @@ -103,8 +104,8 @@ class GameExtensionsTest { assertEquals(1L, result.libraryId) assertEquals("Test Game", result.title) assertEquals(listOf(Platform.PC_MICROSOFT_WINDOWS), result.platforms) - assertEquals(10L, result.coverId) - assertEquals(11L, result.headerId) + assertEquals(10L, result.cover!!.id) + assertEquals(11L, result.header!!.id) assertEquals("Test comment", result.comment) assertEquals("Test summary", result.summary) assertNotNull(result.release) @@ -117,7 +118,7 @@ class GameExtensionsTest { assertEquals(listOf("keyword1"), result.keywords) assertEquals(listOf(GameFeature.SINGLEPLAYER), result.features) assertEquals(listOf(PlayerPerspective.FIRST_PERSON), result.perspectives) - assertEquals(listOf(12L), result.imageIds) + assertEquals(listOf(12L), result.images!!.map { it.id }) assertEquals(listOf("https://example.com/video"), result.videoUrls) assertNotNull(result.metadata) } @@ -132,8 +133,8 @@ class GameExtensionsTest { assertEquals(1L, result.libraryId) assertEquals("Test Game", result.title) assertEquals(listOf(Platform.PC_MICROSOFT_WINDOWS), result.platforms) - assertEquals(10L, result.coverId) - assertEquals(11L, result.headerId) + assertEquals(10L, result.cover!!.id) + assertEquals(11L, result.header!!.id) assertEquals("Test comment", result.comment) assertEquals("Test summary", result.summary) assertNotNull(result.release) @@ -146,7 +147,7 @@ class GameExtensionsTest { assertEquals(listOf("keyword1"), result.keywords) assertEquals(listOf(GameFeature.SINGLEPLAYER), result.features) assertEquals(listOf(PlayerPerspective.FIRST_PERSON), result.perspectives) - assertEquals(listOf(12L), result.imageIds) + assertEquals(listOf(12L), result.images!!.map { it.id }) assertEquals(listOf("https://example.com/video"), result.videoUrls) assertNotNull(result.metadata) } @@ -166,8 +167,8 @@ class GameExtensionsTest { val result = game.toAdminDto() assertEquals("Test Game", result.title) - assertEquals(null, result.coverId) - assertEquals(null, result.headerId) + assertEquals(null, result.cover?.id) + assertEquals(null, result.header?.id) assertEquals(null, result.comment) assertEquals(null, result.summary) assertEquals(null, result.release) @@ -190,8 +191,8 @@ class GameExtensionsTest { val result = game.toUserDto() assertEquals("Test Game", result.title) - assertEquals(null, result.coverId) - assertEquals(null, result.headerId) + assertEquals(null, result.cover?.id) + assertEquals(null, result.header?.id) assertEquals(null, result.comment) assertEquals(null, result.summary) assertEquals(null, result.release) @@ -237,34 +238,6 @@ class GameExtensionsTest { assertEquals(listOf("https://example.com/video1", "https://example.com/video2"), result.videoUrls) } - @Test - fun `toAdminDto should filter out null image IDs`() { - val image1 = mockk { - every { id } returns 1L - } - val image2 = mockk { - every { id } returns null - } - val image3 = mockk { - every { id } returns 3L - } - - game = Game( - id = 1L, - createdAt = Instant.now(), - updatedAt = Instant.now(), - library = library, - title = "Test Game", - platforms = mutableListOf(), - images = mutableListOf(image1, image2, image3), - metadata = org.gameyfin.app.games.entities.GameMetadata(path = "/test/path") - ) - - val result = game.toAdminDto() - - assertEquals(listOf(1L, 3L), result.imageIds) - } - @Test fun `GameMetadata toAdminDto should include admin-specific fields`() { val pluginEntry = mockk { @@ -316,12 +289,18 @@ class GameExtensionsTest { private fun createTestGame(id: Long = 1L): Game { val coverImage = mockk { every { this@mockk.id } returns 10L + every { type } returns ImageType.COVER + every { blurhash } returns "mockedBlurhash" } val headerImage = mockk { every { this@mockk.id } returns 11L + every { type } returns ImageType.HEADER + every { blurhash } returns "mockedBlurhash" } val image = mockk { every { this@mockk.id } returns 12L + every { type } returns ImageType.SCREENSHOT + every { blurhash } returns "mockedBlurhash" } val publisher = Company(id = 1L, name = "Publisher1", type = CompanyType.PUBLISHER) val developer = Company(id = 2L, name = "Developer1", type = CompanyType.DEVELOPER) diff --git a/app/src/test/kotlin/org/gameyfin/app/libraries/LibraryEndpointTest.kt b/app/src/test/kotlin/org/gameyfin/app/libraries/LibraryEndpointTest.kt index 3226c7f..211b821 100644 --- a/app/src/test/kotlin/org/gameyfin/app/libraries/LibraryEndpointTest.kt +++ b/app/src/test/kotlin/org/gameyfin/app/libraries/LibraryEndpointTest.kt @@ -10,6 +10,7 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import reactor.core.publisher.Flux import reactor.test.StepVerifier +import java.time.Instant import kotlin.test.assertEquals class LibraryEndpointTest { @@ -51,7 +52,13 @@ class LibraryEndpointTest { fun `subscribeToLibraryEvents should return user flux when user is not admin`() { mockkObject(LibraryService.Companion) every { isCurrentUserAdmin() } returns false - val userDto = LibraryUserDto(id = 1L, name = "Test Library", games = emptyList()) + val userDto = LibraryUserDto( + id = 1L, + createdAt = Instant.now(), + name = "Test Library", + gameIds = emptyList(), + metadata = LibraryMetadataDto(true, 1) + ) val userEvent = LibraryUserEvent.Created(userDto) val userFlux: Flux> = Flux.just(listOf(userEvent)) every { LibraryService.subscribeUser() } returns userFlux @@ -217,6 +224,19 @@ class LibraryEndpointTest { verify(exactly = 1) { libraryService.update(updateDto) } } + @Test + fun `updateLibraries should call service to update libraries`() { + val updateDto1 = LibraryUpdateDto(id = 1L, name = "Updated Name 1") + val updateDto2 = LibraryUpdateDto(id = 2L, name = "Updated Name 2") + val updateDtos = listOf(updateDto1, updateDto2) + + every { libraryService.update(updateDtos) } just Runs + + libraryEndpoint.updateLibraries(updateDtos) + + verify(exactly = 1) { libraryService.update(updateDtos) } + } + @Test fun `deleteLibrary should call service to delete library`() { every { libraryService.delete(1L) } just Runs @@ -245,12 +265,14 @@ class LibraryEndpointTest { ): LibraryAdminDto { return LibraryAdminDto( id = id, + createdAt = Instant.now(), name = name, directories = emptyList(), platforms = emptyList(), - games = emptyList(), + gameIds = emptyList(), stats = LibraryStatsDto(0, 0), - ignoredPaths = emptyList() + ignoredPaths = emptyList(), + metadata = LibraryMetadataDto(true, 1) ) } } diff --git a/app/src/test/kotlin/org/gameyfin/app/libraries/LibraryServiceTest.kt b/app/src/test/kotlin/org/gameyfin/app/libraries/LibraryServiceTest.kt index f02a518..2271e3f 100644 --- a/app/src/test/kotlin/org/gameyfin/app/libraries/LibraryServiceTest.kt +++ b/app/src/test/kotlin/org/gameyfin/app/libraries/LibraryServiceTest.kt @@ -5,10 +5,7 @@ import io.mockk.* import org.gameyfin.app.core.security.getCurrentAuth import org.gameyfin.app.games.entities.Game import org.gameyfin.app.games.entities.GameMetadata -import org.gameyfin.app.libraries.dto.DirectoryMappingDto -import org.gameyfin.app.libraries.dto.LibraryAdminDto -import org.gameyfin.app.libraries.dto.LibraryStatsDto -import org.gameyfin.app.libraries.dto.LibraryUpdateDto +import org.gameyfin.app.libraries.dto.* import org.gameyfin.app.libraries.entities.DirectoryMapping import org.gameyfin.app.libraries.entities.IgnoredPath import org.gameyfin.app.libraries.entities.IgnoredPathUserSource @@ -397,7 +394,34 @@ class LibraryServiceTest { libraryService.update(updateDto) assertNotNull(library.updatedAt) - assertTrue(library.updatedAt!! >= beforeUpdate) + assertTrue(library.updatedAt!! > beforeUpdate) + } + + @Test + fun `update with list should call update for every element`() { + val library1 = createTestLibrary(1L) + val library2 = createTestLibrary(2L) + val updateDto1 = LibraryUpdateDto(id = 1L, name = "Updated 1") + val updateDto2 = LibraryUpdateDto(id = 2L, name = "Updated 2") + val beforeUpdate = Instant.now() + + every { libraryRepository.findByIdOrNull(1L) } returns library1 + every { libraryRepository.findByIdOrNull(2L) } returns library2 + every { libraryRepository.save(library1) } answers { + library1.updatedAt = Instant.now() + library1 + } + every { libraryRepository.save(library2) } answers { + library2.updatedAt = Instant.now() + library2 + } + + libraryService.update(listOf(updateDto1, updateDto2)) + + assertNotNull(library1.updatedAt) + assertTrue(library1.updatedAt!! > beforeUpdate) + assertNotNull(library2.updatedAt) + assertTrue(library2.updatedAt!! > beforeUpdate) } @Test @@ -474,12 +498,14 @@ class LibraryServiceTest { ): LibraryAdminDto { return LibraryAdminDto( id = id, + createdAt = Instant.now(), name = name, directories = directories, platforms = platforms, - games = emptyList(), + gameIds = emptyList(), stats = LibraryStatsDto(0, 0), - ignoredPaths = emptyList() + ignoredPaths = emptyList(), + metadata = LibraryMetadataDto(true, 1) ) } } diff --git a/app/src/test/kotlin/org/gameyfin/app/libraries/LibraryWatcherServiceTest.kt b/app/src/test/kotlin/org/gameyfin/app/libraries/LibraryWatcherServiceTest.kt new file mode 100644 index 0000000..74843fa --- /dev/null +++ b/app/src/test/kotlin/org/gameyfin/app/libraries/LibraryWatcherServiceTest.kt @@ -0,0 +1,545 @@ +package org.gameyfin.app.libraries + +import io.mockk.* +import org.gameyfin.app.config.ConfigProperties +import org.gameyfin.app.config.ConfigService +import org.gameyfin.app.core.events.LibraryCreatedEvent +import org.gameyfin.app.core.events.LibraryDeletedEvent +import org.gameyfin.app.core.events.LibraryFilesystemWatcherConfigUpdatedEvent +import org.gameyfin.app.core.events.LibraryUpdatedEvent +import org.gameyfin.app.core.filesystem.FilesystemService +import org.gameyfin.app.games.entities.Game +import org.gameyfin.app.games.entities.GameMetadata +import org.gameyfin.app.games.repositories.GameRepository +import org.gameyfin.app.libraries.entities.DirectoryMapping +import org.gameyfin.app.libraries.entities.Library +import org.gameyfin.app.libraries.enums.ScanType +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertDoesNotThrow +import java.nio.file.Files +import java.nio.file.Path +import java.util.* +import kotlin.io.path.createDirectories +import kotlin.io.path.createFile +import kotlin.io.path.deleteExisting +import kotlin.io.path.writeText +import kotlin.test.assertTrue + +class LibraryWatcherServiceTest { + + private lateinit var libraryRepository: LibraryRepository + private lateinit var libraryScanService: LibraryScanService + private lateinit var gameRepository: GameRepository + private lateinit var filesystemService: FilesystemService + private lateinit var configService: ConfigService + private lateinit var libraryWatcherService: LibraryWatcherService + + private lateinit var tempDir: Path + private val testGameExtensions = arrayOf("exe", "iso", "zip") + + @BeforeEach + fun setup() { + libraryRepository = mockk(relaxed = true) + libraryScanService = mockk(relaxed = true) + gameRepository = mockk(relaxed = true) + filesystemService = mockk(relaxed = true) + configService = mockk(relaxed = true) + + // Setup config service to return game file extensions and enable filesystem watcher + every { configService.get(ConfigProperties.Libraries.Scan.GameFileExtensions) } returns testGameExtensions + every { configService.get(ConfigProperties.Libraries.Scan.EnableFilesystemWatcher) } returns true + + // Create temporary directory for file system tests + tempDir = Files.createTempDirectory("library-watcher-test") + + libraryWatcherService = LibraryWatcherService( + libraryRepository, + libraryScanService, + gameRepository, + filesystemService, + configService + ) + } + + @AfterEach + fun tearDown() { + // Stop the service to clean up watchers + libraryWatcherService.stop() + + // Clean up temporary directory + tempDir.toFile().deleteRecursively() + + unmockkAll() + clearAllMocks() + } + + @Test + fun `start should initialize watcher for all existing libraries`() { + val library1 = createTestLibrary(1L, "Library 1", listOf(tempDir.resolve("lib1").toString())) + val library2 = createTestLibrary(2L, "Library 2", listOf(tempDir.resolve("lib2").toString())) + + // Create directories + tempDir.resolve("lib1").createDirectories() + tempDir.resolve("lib2").createDirectories() + + every { libraryRepository.findAll() } returns listOf(library1, library2) + + libraryWatcherService.start() + + // Verify that start was called + verify(exactly = 1) { libraryRepository.findAll() } + } + + @Test + fun `start should handle libraries with invalid directories`() { + val library = createTestLibrary(1L, "Test Library", listOf("/nonexistent/path")) + + every { libraryRepository.findAll() } returns listOf(library) + + assertDoesNotThrow { + libraryWatcherService.start() + } + } + + @Test + fun `stop should shutdown gracefully`() { + every { libraryRepository.findAll() } returns emptyList() + + libraryWatcherService.start() + + assertDoesNotThrow { + libraryWatcherService.stop() + } + } + + @Test + fun `onLibraryCreated should start watching new library`() { + val libraryDir = tempDir.resolve("new-library") + libraryDir.createDirectories() + + val library = createTestLibrary(1L, "New Library", listOf(libraryDir.toString())) + + every { libraryRepository.findAll() } returns emptyList() + libraryWatcherService.start() + + val event = LibraryCreatedEvent(this, library) + libraryWatcherService.onLibraryCreated(event) + + // Verify the event was processed without errors + assertTrue(true) + } + + @Test + fun `onLibraryUpdated should restart watchers for updated library`() { + val oldDir = tempDir.resolve("old-dir") + val newDir = tempDir.resolve("new-dir") + oldDir.createDirectories() + newDir.createDirectories() + + val oldLibrary = createTestLibrary(1L, "Test Library", listOf(oldDir.toString())) + val updatedLibrary = createTestLibrary(1L, "Test Library", listOf(newDir.toString())) + + every { libraryRepository.findAll() } returns listOf(oldLibrary) + libraryWatcherService.start() + + val event = LibraryUpdatedEvent(this, updatedLibrary) + libraryWatcherService.onLibraryUpdated(event) + + // Verify the event was processed without errors + assertTrue(true) + } + + @Test + fun `onLibraryDeleted should stop watching deleted library`() { + val libraryDir = tempDir.resolve("to-delete") + libraryDir.createDirectories() + + val library = createTestLibrary(1L, "To Delete", listOf(libraryDir.toString())) + + every { libraryRepository.findAll() } returns listOf(library) + libraryWatcherService.start() + + val event = LibraryDeletedEvent(this, library) + libraryWatcherService.onLibraryDeleted(event) + + // Verify the event was processed without errors + assertTrue(true) + } + + @Test + fun `file create event should trigger quick scan`() { + val libraryDir = tempDir.resolve("watch-create") + libraryDir.createDirectories() + + val library = createTestLibrary(1L, "Watch Library", listOf(libraryDir.toString())) + + every { libraryRepository.findAll() } returns listOf(library) + every { libraryRepository.findById(1L) } returns Optional.of(library) + every { libraryScanService.triggerScan(ScanType.QUICK, listOf(1L)) } just Runs + + libraryWatcherService.start() + + // Give watcher time to initialize + Thread.sleep(100) + + // Create a new game file + val gameFile = libraryDir.resolve("newgame.exe") + gameFile.createFile() + gameFile.writeText("test content") + + // Give watcher time to detect and process the event + Thread.sleep(500) + + // Verify quick scan was triggered + verify(atLeast = 1) { libraryScanService.triggerScan(ScanType.QUICK, listOf(1L)) } + } + + @Test + fun `file delete event should trigger quick scan`() { + val libraryDir = tempDir.resolve("watch-delete") + libraryDir.createDirectories() + + val gameFile = libraryDir.resolve("game.exe") + gameFile.createFile() + + val game = createTestGame(1L, gameFile.toString()) + val library = createTestLibrary(1L, "Watch Library", listOf(libraryDir.toString()), mutableListOf(game)) + + every { libraryRepository.findAll() } returns listOf(library) + every { libraryRepository.findById(1L) } returns Optional.of(library) + every { libraryRepository.save(library) } returns library + + libraryWatcherService.start() + + // Give watcher time to initialize + Thread.sleep(100) + + // Delete the game file + gameFile.deleteExisting() + + // Give watcher time to detect and process the event + Thread.sleep(500) + + // Verify game was removed from library + verify(atLeast = 1) { libraryScanService.triggerScan(ScanType.QUICK, listOf(1L)) } + } + + @Test + fun `directory delete event should trigger quick scan`() { + val libraryDir = tempDir.resolve("watch-delete-dir") + libraryDir.createDirectories() + + val gameDir = libraryDir.resolve("Game Folder") + gameDir.createDirectories() + + val library = createTestLibrary(1L, "Watch Library", listOf(libraryDir.toString())) + + every { libraryRepository.findAll() } returns listOf(library) + every { libraryRepository.findById(1L) } returns Optional.of(library) + every { libraryScanService.triggerScan(ScanType.QUICK, listOf(1L)) } just Runs + + libraryWatcherService.start() + + // Give watcher time to initialize + Thread.sleep(100) + + // Delete the game directory + gameDir.toFile().deleteRecursively() + + // Give watcher time to detect and process the event + Thread.sleep(500) + + // Verify quick scan was triggered (directories are treated as potential game folders) + verify(atLeast = 1) { libraryScanService.triggerScan(ScanType.QUICK, listOf(1L)) } + } + + @Test + fun `file modify event should update game file size`() { + val libraryDir = tempDir.resolve("watch-modify") + libraryDir.createDirectories() + + val gameFile = libraryDir.resolve("game.exe") + gameFile.createFile() + gameFile.writeText("initial content") + + val metadata = GameMetadata(path = gameFile.toString(), fileSize = 100L) + val game = createTestGame(1L, gameFile.toString(), metadata) + val library = createTestLibrary(1L, "Watch Library", listOf(libraryDir.toString()), mutableListOf(game)) + + val newFileSize = 2000L + every { libraryRepository.findAll() } returns listOf(library) + every { libraryRepository.findById(1L) } returns Optional.of(library) + every { filesystemService.calculateFileSize(gameFile.toString()) } returns newFileSize + every { gameRepository.save(game) } returns game + + libraryWatcherService.start() + + // Give watcher time to initialize + Thread.sleep(100) + + // Modify the file + gameFile.writeText("modified content with more data") + + // Give watcher time to detect and process the event + Thread.sleep(500) + + // Verify file size was recalculated + verify(atLeast = 1) { filesystemService.calculateFileSize(gameFile.toString()) } + } + + @Test + fun `should handle multiple rapid file changes`() { + val libraryDir = tempDir.resolve("watch-rapid") + libraryDir.createDirectories() + + val library = createTestLibrary(1L, "Watch Library", listOf(libraryDir.toString())) + + every { libraryRepository.findAll() } returns listOf(library) + every { libraryRepository.findById(1L) } returns Optional.of(library) + every { libraryScanService.triggerScan(any(), any()) } just Runs + + libraryWatcherService.start() + + // Give watcher time to initialize + Thread.sleep(100) + + // Create multiple files rapidly + repeat(5) { i -> + val gameFile = libraryDir.resolve("game$i.exe") + gameFile.createFile() + Thread.sleep(50) + } + + // Give watcher time to process all events + Thread.sleep(1000) + + // Verify that scans were triggered (may be batched) + verify(atLeast = 1) { libraryScanService.triggerScan(ScanType.QUICK, listOf(1L)) } + } + + @Test + fun `should handle library with multiple directories`() { + val dir1 = tempDir.resolve("lib-dir1") + val dir2 = tempDir.resolve("lib-dir2") + dir1.createDirectories() + dir2.createDirectories() + + val library = createTestLibrary(1L, "Multi-Dir Library", listOf(dir1.toString(), dir2.toString())) + + every { libraryRepository.findAll() } returns listOf(library) + every { libraryRepository.findById(1L) } returns Optional.of(library) + every { libraryScanService.triggerScan(any(), any()) } just Runs + + libraryWatcherService.start() + + // Give watcher time to initialize + Thread.sleep(100) + + // Create files in both directories + val game1 = dir1.resolve("game1.exe") + game1.createFile() + + Thread.sleep(300) + + val game2 = dir2.resolve("game2.iso") + game2.createFile() + + // Give watcher time to process events + Thread.sleep(500) + + // Verify scans were triggered for both directories + verify(atLeast = 2) { libraryScanService.triggerScan(ScanType.QUICK, listOf(1L)) } + } + + @Test + fun `should handle directory creation`() { + val libraryDir = tempDir.resolve("watch-dir") + libraryDir.createDirectories() + + val library = createTestLibrary(1L, "Watch Library", listOf(libraryDir.toString())) + + every { libraryRepository.findAll() } returns listOf(library) + every { libraryRepository.findById(1L) } returns Optional.of(library) + every { libraryScanService.triggerScan(any(), any()) } just Runs + + libraryWatcherService.start() + + // Give watcher time to initialize + Thread.sleep(100) + + // Create a new directory (which could be a game folder) + val newGameDir = libraryDir.resolve("New Game") + newGameDir.createDirectories() + + // Give watcher time to detect and process the event + Thread.sleep(500) + + // Verify quick scan was triggered + verify(atLeast = 1) { libraryScanService.triggerScan(ScanType.QUICK, listOf(1L)) } + } + + @Test + fun `should not crash when library is deleted while being watched`() { + val libraryDir = tempDir.resolve("watch-delete-lib") + libraryDir.createDirectories() + + val library = createTestLibrary(1L, "Watch Library", listOf(libraryDir.toString())) + + every { libraryRepository.findAll() } returns listOf(library) + every { libraryRepository.findById(1L) } returns Optional.empty() // Library deleted + + libraryWatcherService.start() + + // Give watcher time to initialize + Thread.sleep(100) + + // Create a file that will trigger an event + val gameFile = libraryDir.resolve("game.exe") + gameFile.createFile() + + // Give watcher time to process (should handle missing library gracefully) + Thread.sleep(500) + + // Verify no exception was thrown + assertTrue(true) + } + + @Test + fun `start and stop multiple times should work correctly`() { + every { libraryRepository.findAll() } returns emptyList() + + assertDoesNotThrow { + libraryWatcherService.start() + libraryWatcherService.stop() + + libraryWatcherService.start() + libraryWatcherService.stop() + } + } + + @Test + fun `should not start when filesystem watcher is disabled in config`() { + every { configService.get(ConfigProperties.Libraries.Scan.EnableFilesystemWatcher) } returns false + every { libraryRepository.findAll() } returns emptyList() + + libraryWatcherService.start() + + // Verify that no libraries were attempted to be watched + verify(exactly = 0) { libraryRepository.findAll() } + } + + @Test + fun `config update event should start watchers when enabled`() { + val libraryDir = tempDir.resolve("config-test") + libraryDir.createDirectories() + + val library = createTestLibrary(1L, "Test Library", listOf(libraryDir.toString())) + + every { libraryRepository.findAll() } returns listOf(library) + + libraryWatcherService.start() + + val event = LibraryFilesystemWatcherConfigUpdatedEvent(this, true) + libraryWatcherService.onFilesystemWatcherConfigUpdated(event) + + // Give some time for the watcher to initialize + Thread.sleep(200) + + // Verify service started + verify(atLeast = 1) { libraryRepository.findAll() } + } + + @Test + fun `config update event should stop watchers when disabled`() { + val libraryDir = tempDir.resolve("config-disable-test") + libraryDir.createDirectories() + + val library = createTestLibrary(1L, "Test Library", listOf(libraryDir.toString())) + + every { libraryRepository.findAll() } returns listOf(library) + + libraryWatcherService.start() + + // Give time to start + Thread.sleep(200) + + val event = LibraryFilesystemWatcherConfigUpdatedEvent(this, false) + libraryWatcherService.onFilesystemWatcherConfigUpdated(event) + + // Give time for shutdown + Thread.sleep(200) + + // Create a file after disabling - should not trigger scan + val gameFile = libraryDir.resolve("game.exe") + gameFile.createFile() + + Thread.sleep(300) + + // Verify no scan was triggered after disabling + verify(exactly = 0) { libraryScanService.triggerScan(any(), any()) } + } + + @Test + fun `library events should be ignored when watcher is not running`() { + val libraryDir = tempDir.resolve("events-disabled") + libraryDir.createDirectories() + + val library = createTestLibrary(1L, "Test Library", listOf(libraryDir.toString())) + + // Watcher is not running + every { configService.get(ConfigProperties.Libraries.Scan.EnableFilesystemWatcher) } returns false + every { libraryRepository.findAll() } returns emptyList() + + libraryWatcherService.start() + + // Try library created event + val createEvent = LibraryCreatedEvent(this, library) + libraryWatcherService.onLibraryCreated(createEvent) + + // Try library updated event + val updateEvent = LibraryUpdatedEvent(this, library) + libraryWatcherService.onLibraryUpdated(updateEvent) + + // Try library deleted event + val deleteEvent = LibraryDeletedEvent(this, library) + libraryWatcherService.onLibraryDeleted(deleteEvent) + + // All events should be ignored (no exceptions thrown) + assertTrue(true) + } + + // Helper methods + + private fun createTestLibrary( + id: Long, + name: String, + directoryPaths: List, + games: MutableList = mutableListOf() + ): Library { + val directories = directoryPaths.map { path -> + mockk(relaxed = true) { + every { internalPath } returns path + every { externalPath } returns null + } + }.toMutableList() + + return mockk(relaxed = true) { + every { this@mockk.id } returns id + every { this@mockk.name } returns name + every { this@mockk.directories } returns directories + every { this@mockk.games } returns games + } + } + + private fun createTestGame(id: Long, path: String, metadata: GameMetadata? = null): Game { + val gameMetadata = metadata ?: GameMetadata(path = path, fileSize = 1000L) + return mockk(relaxed = true) { + every { this@mockk.id } returns id + every { this@mockk.metadata } returns gameMetadata + every { this@mockk.title } returns "Test Game" + } + } +} + diff --git a/app/src/test/kotlin/org/gameyfin/app/libraries/extensions/LibraryExtensionsTest.kt b/app/src/test/kotlin/org/gameyfin/app/libraries/extensions/LibraryExtensionsTest.kt index 72a28f1..7ee7a11 100644 --- a/app/src/test/kotlin/org/gameyfin/app/libraries/extensions/LibraryExtensionsTest.kt +++ b/app/src/test/kotlin/org/gameyfin/app/libraries/extensions/LibraryExtensionsTest.kt @@ -100,7 +100,7 @@ class LibraryExtensionsTest { assertEquals(1L, result.id) assertEquals("Test Library", result.name) - assertEquals(listOf(1L, 2L), result.games) + assertEquals(listOf(1L, 2L), result.gameIds) } @Test @@ -110,7 +110,7 @@ class LibraryExtensionsTest { val result = library.toUserDto() assertEquals(1L, result.id) - assertTrue(result.games!!.isEmpty()) + assertTrue(result.gameIds!!.isEmpty()) } @Test @@ -125,8 +125,8 @@ class LibraryExtensionsTest { val result = library.toUserDto() - assertEquals(2, result.games!!.size) - assertEquals(listOf(1L, 3L), result.games) + assertEquals(2, result.gameIds!!.size) + assertEquals(listOf(1L, 3L), result.gameIds) } @Test @@ -159,7 +159,7 @@ class LibraryExtensionsTest { assertEquals("/ext1", result.directories[0].externalPath) assertEquals(2, result.platforms.size) assertTrue(result.platforms.contains(Platform.PC_MICROSOFT_WINDOWS)) - assertEquals(listOf(1L, 2L), result.games) + assertEquals(listOf(1L, 2L), result.gameIds) assertNotNull(result.stats) assertEquals(2, result.stats.gamesCount) assertEquals(15, result.stats.downloadedGamesCount) @@ -199,7 +199,7 @@ class LibraryExtensionsTest { val result = library.toAdminDto() - assertTrue(result.games!!.isEmpty()) + assertTrue(result.gameIds!!.isEmpty()) assertNotNull(result.stats) assertEquals(0, result.stats.gamesCount) assertEquals(0, result.stats.downloadedGamesCount) @@ -217,8 +217,8 @@ class LibraryExtensionsTest { val result = library.toAdminDto() - assertEquals(2, result.games!!.size) - assertEquals(listOf(1L, 3L), result.games) + assertEquals(2, result.gameIds!!.size) + assertEquals(listOf(1L, 3L), result.gameIds) } @Test diff --git a/app/src/test/kotlin/org/gameyfin/app/libraries/scan/LibraryGameProcessorTest.kt b/app/src/test/kotlin/org/gameyfin/app/libraries/scan/LibraryGameProcessorTest.kt index aa1a666..3f2187d 100644 --- a/app/src/test/kotlin/org/gameyfin/app/libraries/scan/LibraryGameProcessorTest.kt +++ b/app/src/test/kotlin/org/gameyfin/app/libraries/scan/LibraryGameProcessorTest.kt @@ -5,8 +5,8 @@ import org.gameyfin.app.core.filesystem.FilesystemService import org.gameyfin.app.games.GameService import org.gameyfin.app.games.entities.Game import org.gameyfin.app.games.entities.GameMetadata -import org.gameyfin.app.games.entities.Image import org.gameyfin.app.libraries.entities.Library +import org.gameyfin.app.media.Image import org.gameyfin.app.media.ImageService import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertThrows @@ -182,26 +182,26 @@ class LibraryGameProcessorTest { val game = createTestGame(1L, "/path/to/game") val updatedGame = createTestGame(1L, "/path/to/game") - every { gameService.update(game) } returns updatedGame + every { gameService.updateMetadata(game) } returns updatedGame every { imageService.downloadIfNew(any()) } just Runs every { filesystemService.calculateFileSize("/path/to/game") } returns 1024L val result = libraryGameProcessor.processExistingGame(game) assertEquals(updatedGame, result) - verify(exactly = 1) { gameService.update(game) } + verify(exactly = 1) { gameService.updateMetadata(game) } } @Test fun `processExistingGame should return null when game is not updated`() { val game = createTestGame(1L, "/path/to/game") - every { gameService.update(game) } returns null + every { gameService.updateMetadata(game) } returns null val result = libraryGameProcessor.processExistingGame(game) assertEquals(null, result) - verify(exactly = 1) { gameService.update(game) } + verify(exactly = 1) { gameService.updateMetadata(game) } } @Test @@ -210,7 +210,7 @@ class LibraryGameProcessorTest { val coverImage = createTestImage(1L) val updatedGame = createTestGame(1L, "/path/to/game", coverImage = coverImage) - every { gameService.update(game) } returns updatedGame + every { gameService.updateMetadata(game) } returns updatedGame every { imageService.downloadIfNew(coverImage) } just Runs every { imageService.downloadIfNew(any()) } just Runs every { filesystemService.calculateFileSize("/path/to/game") } returns 1024L @@ -226,7 +226,7 @@ class LibraryGameProcessorTest { val updatedGame = createTestGame(1L, "/path/to/game") val newSize = 4096L - every { gameService.update(game) } returns updatedGame + every { gameService.updateMetadata(game) } returns updatedGame every { imageService.downloadIfNew(any()) } just Runs every { filesystemService.calculateFileSize("/path/to/game") } returns newSize @@ -242,7 +242,7 @@ class LibraryGameProcessorTest { val coverImage = createTestImage(1L) val updatedGame = createTestGame(1L, "/path/to/game", coverImage = coverImage) - every { gameService.update(game) } returns updatedGame + every { gameService.updateMetadata(game) } returns updatedGame every { imageService.downloadIfNew(coverImage) } just Runs every { imageService.downloadIfNew(any()) } just Runs every { filesystemService.calculateFileSize("/path/to/game") } throws RuntimeException("File error") @@ -259,7 +259,7 @@ class LibraryGameProcessorTest { fun `processExistingGame should throw exception on update failure`() { val game = createTestGame(1L, "/path/to/game") - every { gameService.update(game) } throws RuntimeException("Update error") + every { gameService.updateMetadata(game) } throws RuntimeException("Update error") assertThrows(RuntimeException::class.java) { libraryGameProcessor.processExistingGame(game) @@ -270,7 +270,7 @@ class LibraryGameProcessorTest { fun `processExistingGame should not download images when game is not updated`() { val game = createTestGame(1L, "/path/to/game") - every { gameService.update(game) } returns null + every { gameService.updateMetadata(game) } returns null libraryGameProcessor.processExistingGame(game) @@ -290,7 +290,7 @@ class LibraryGameProcessorTest { images = mutableListOf(image1, image2, image3) ) - every { gameService.update(game) } returns updatedGame + every { gameService.updateMetadata(game) } returns updatedGame every { imageService.downloadIfNew(any()) } just Runs every { filesystemService.calculateFileSize("/path/to/game") } returns 1024L diff --git a/app/src/test/kotlin/org/gameyfin/app/media/ImageEndpointTest.kt b/app/src/test/kotlin/org/gameyfin/app/media/ImageEndpointTest.kt index 5fe8da2..aec45b5 100644 --- a/app/src/test/kotlin/org/gameyfin/app/media/ImageEndpointTest.kt +++ b/app/src/test/kotlin/org/gameyfin/app/media/ImageEndpointTest.kt @@ -3,8 +3,6 @@ package org.gameyfin.app.media import io.mockk.* import org.gameyfin.app.core.plugins.PluginService import org.gameyfin.app.core.security.getCurrentAuth -import org.gameyfin.app.games.entities.Image -import org.gameyfin.app.games.entities.ImageType import org.gameyfin.app.users.UserService import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertThrows diff --git a/app/src/test/kotlin/org/gameyfin/app/media/ImageServiceTest.kt b/app/src/test/kotlin/org/gameyfin/app/media/ImageServiceTest.kt index 53dc288..23a9530 100644 --- a/app/src/test/kotlin/org/gameyfin/app/media/ImageServiceTest.kt +++ b/app/src/test/kotlin/org/gameyfin/app/media/ImageServiceTest.kt @@ -7,8 +7,6 @@ import org.gameyfin.app.core.events.GameUpdatedEvent import org.gameyfin.app.core.events.UserDeletedEvent import org.gameyfin.app.core.events.UserUpdatedEvent import org.gameyfin.app.games.entities.Game -import org.gameyfin.app.games.entities.Image -import org.gameyfin.app.games.entities.ImageType import org.gameyfin.app.games.repositories.GameRepository import org.gameyfin.app.games.repositories.ImageContentStore import org.gameyfin.app.games.repositories.ImageRepository @@ -319,7 +317,7 @@ class ImageServiceTest { assertNotNull(result) verify(exactly = 1) { imageRepository.save(any()) } - verify(exactly = 1) { imageContentStore.setContent(any(), inputStream) } + verify(exactly = 1) { imageContentStore.setContent(any(), any()) } } @Test @@ -439,7 +437,7 @@ class ImageServiceTest { assertEquals("image/jpeg", image.mimeType) verify(exactly = 1) { imageRepository.save(image) } - verify(exactly = 1) { imageContentStore.setContent(image, inputStream) } + verify(exactly = 1) { imageContentStore.setContent(image, any()) } } @Test @@ -450,11 +448,11 @@ class ImageServiceTest { every { imageRepository.save(image) } returns image every { imageContentStore.setContent(any(), any()) } returns image - imageService.updateFileContent(image, inputStream, null) + imageService.updateFileContent(image, inputStream) assertEquals("image/png", image.mimeType) verify(exactly = 1) { imageRepository.save(image) } - verify(exactly = 1) { imageContentStore.setContent(image, inputStream) } + verify(exactly = 1) { imageContentStore.setContent(image, any()) } } @Test diff --git a/app/src/test/kotlin/org/gameyfin/app/users/UserServiceTest.kt b/app/src/test/kotlin/org/gameyfin/app/users/UserServiceTest.kt index 6519028..a202556 100644 --- a/app/src/test/kotlin/org/gameyfin/app/users/UserServiceTest.kt +++ b/app/src/test/kotlin/org/gameyfin/app/users/UserServiceTest.kt @@ -10,7 +10,7 @@ import org.gameyfin.app.core.events.EmailNeedsConfirmationEvent import org.gameyfin.app.core.events.RegistrationAttemptWithExistingEmailEvent import org.gameyfin.app.core.events.UserRegistrationWaitingForApprovalEvent import org.gameyfin.app.core.security.getCurrentAuth -import org.gameyfin.app.games.entities.Image +import org.gameyfin.app.media.Image import org.gameyfin.app.media.ImageService import org.gameyfin.app.users.dto.ExtendedUserInfoDto import org.gameyfin.app.users.dto.UserRegistrationDto diff --git a/app/src/test/kotlin/org/gameyfin/app/users/extensions/UserExtensionsTest.kt b/app/src/test/kotlin/org/gameyfin/app/users/extensions/UserExtensionsTest.kt index a8dcc8f..3180226 100644 --- a/app/src/test/kotlin/org/gameyfin/app/users/extensions/UserExtensionsTest.kt +++ b/app/src/test/kotlin/org/gameyfin/app/users/extensions/UserExtensionsTest.kt @@ -1,8 +1,8 @@ package org.gameyfin.app.users.extensions import org.gameyfin.app.core.Role -import org.gameyfin.app.games.entities.Image -import org.gameyfin.app.games.entities.ImageType +import org.gameyfin.app.media.Image +import org.gameyfin.app.media.ImageType import org.gameyfin.app.users.dto.ExtendedUserInfoDto import org.gameyfin.app.users.dto.UserInfoDto import org.gameyfin.app.users.entities.User diff --git a/build.gradle.kts b/build.gradle.kts index 24f5b55..035ceb9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile import java.nio.file.Files group = "org.gameyfin" -version = "2.2.1" +version = "2.3.0-preview" allprojects { repositories { diff --git a/scripts/gog.sh b/scripts/gog.sh old mode 100644 new mode 100755