diff --git a/.github/workflows/docker-fix.yml b/.github/workflows/docker-fix.yml index 51e411c..e2c6a3a 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@v6 + uses: actions/checkout@v5 - 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@v6 + uses: actions/checkout@v5 - name: Download build outputs uses: actions/download-artifact@v5 diff --git a/.github/workflows/docker-preview.yml b/.github/workflows/docker-preview.yml index 1efcc44..88cab9b 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@v6 + uses: actions/checkout@v5 with: fetch-depth: 0 @@ -82,7 +82,7 @@ jobs: variant: [ alpine, ubuntu ] steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v5 with: fetch-depth: 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d488247..acc4545 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@v6 + uses: actions/checkout@v5 with: fetch-depth: 0 @@ -66,7 +66,7 @@ jobs: checks: write steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v5 with: fetch-depth: 0 @@ -113,7 +113,7 @@ jobs: variant: [ alpine, ubuntu ] steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v5 with: fetch-depth: 0 @@ -158,7 +158,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v5 with: fetch-depth: 0 @@ -189,7 +189,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v5 with: fetch-depth: 0 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d8a5574..51cf756 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,3 @@ -import org.apache.tools.ant.filters.ReplaceTokens - group = "org.gameyfin" val appMainClass = "org.gameyfin.app.GameyfinApplicationKt" @@ -33,7 +31,6 @@ 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") @@ -73,9 +70,8 @@ dependencies { implementation(project(":plugin-api")) // Utils - implementation("org.apache.tika:tika-core:3.2.3") + implementation("org.apache.tika:tika-core:3.1.0") implementation("me.xdrop:fuzzywuzzy:1.4.0") - implementation("com.vanniktech:blurhash:0.3.0") // Development developmentOnly("org.springframework.boot:spring-boot-devtools") @@ -102,12 +98,4 @@ dependencyManagement { tasks.withType { useJUnitPlatform() -} - -tasks.named("processResources") { - val projectVersion = rootProject.version.toString() - filesMatching("application.yml") { - filter("tokens" to mapOf("project.version" to projectVersion)) - } -} - +} \ No newline at end of file diff --git a/app/package-lock.json b/app/package-lock.json index 680f7b2..550a230 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "gameyfin", - "version": "2.3.0-preview", + "version": "2.2.0-preview", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gameyfin", - "version": "2.3.0-preview", + "version": "2.2.0-preview", "dependencies": { "@heroui/react": "^2.8.5", "@phosphor-icons/react": "^2.1.7", @@ -31,7 +31,6 @@ "@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", @@ -55,10 +54,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": { @@ -70,7 +69,6 @@ "@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", @@ -202,7 +200,6 @@ "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", @@ -3367,7 +3364,6 @@ "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", @@ -3453,7 +3449,6 @@ "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", @@ -4002,8 +3997,7 @@ "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", - "peer": true + "license": "MIT" }, "node_modules/@phosphor-icons/react": { "version": "2.1.10", @@ -4023,7 +4017,6 @@ "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" } @@ -6953,7 +6946,6 @@ "integrity": "sha512-pAZSHMiagDR7cARo/cch1f3rXy0AEXwsVsVH09FcyeJVAzCnGgmYis7P3JidtTUjyadhTeSo8TgRPswstghDaw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -6963,7 +6955,6 @@ "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" } @@ -6974,21 +6965,10 @@ "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", @@ -7025,7 +7005,6 @@ "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", @@ -7038,7 +7017,6 @@ "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", @@ -7056,7 +7034,6 @@ "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", @@ -7074,7 +7051,6 @@ "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", @@ -7092,7 +7068,6 @@ "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", @@ -7448,7 +7423,6 @@ "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", @@ -7465,7 +7439,6 @@ "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", @@ -7479,7 +7452,6 @@ "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", @@ -7497,7 +7469,6 @@ "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", @@ -7516,7 +7487,6 @@ "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", @@ -7550,7 +7520,6 @@ "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", @@ -7564,7 +7533,6 @@ "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", @@ -7583,7 +7551,6 @@ "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", @@ -7604,7 +7571,6 @@ "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", @@ -7622,7 +7588,6 @@ "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", @@ -7643,7 +7608,6 @@ "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", @@ -7664,7 +7628,6 @@ "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", @@ -7682,7 +7645,6 @@ "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", @@ -7700,7 +7662,6 @@ "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", @@ -7716,7 +7677,6 @@ "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", @@ -7730,7 +7690,6 @@ "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", @@ -7747,7 +7706,6 @@ "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", @@ -7764,7 +7722,6 @@ "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", @@ -8176,7 +8133,6 @@ "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", @@ -8191,7 +8147,6 @@ "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", @@ -8206,7 +8161,6 @@ "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" @@ -8217,7 +8171,6 @@ "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", @@ -8232,7 +8185,6 @@ "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", @@ -8246,7 +8198,6 @@ "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", @@ -8263,7 +8214,6 @@ "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", @@ -8281,7 +8231,6 @@ "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" } @@ -8291,7 +8240,6 @@ "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", @@ -8311,7 +8259,6 @@ "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", @@ -8328,7 +8275,6 @@ "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", @@ -8343,7 +8289,6 @@ "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", @@ -8365,7 +8310,6 @@ "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", @@ -8383,7 +8327,6 @@ "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", @@ -8402,7 +8345,6 @@ "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", @@ -8425,7 +8367,6 @@ "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", @@ -8443,7 +8384,6 @@ "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", @@ -8462,7 +8402,6 @@ "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", @@ -8479,7 +8418,6 @@ "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", @@ -8499,7 +8437,6 @@ "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", @@ -8511,7 +8448,6 @@ "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", @@ -8529,7 +8465,6 @@ "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", @@ -8545,7 +8480,6 @@ "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", @@ -8647,7 +8581,6 @@ "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", @@ -8664,7 +8597,6 @@ "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", @@ -8688,7 +8620,6 @@ "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", @@ -8704,7 +8635,6 @@ "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", @@ -8720,7 +8650,6 @@ "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", @@ -8738,7 +8667,6 @@ "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", @@ -8756,7 +8684,6 @@ "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", @@ -8775,7 +8702,6 @@ "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", @@ -8794,7 +8720,6 @@ "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", @@ -8815,7 +8740,6 @@ "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", @@ -8834,7 +8758,6 @@ "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", @@ -8852,15 +8775,13 @@ "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", - "peer": true + "license": "Apache-2.0" }, "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", @@ -8884,7 +8805,6 @@ "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", @@ -8897,7 +8817,6 @@ "integrity": "sha512-8r4TNknD7OJQADe3VygeofFR7UNAXZ2/jjBFP5dgI8+2uMfnuGYgbuHivasKr9WSQ64sPej6m8rDoM1uSllXjQ==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@vaadin/vaadin-development-mode-detector": "^2.0.0" }, @@ -8910,7 +8829,6 @@ "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", @@ -8925,7 +8843,6 @@ "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", @@ -9031,7 +8948,6 @@ "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", @@ -9285,12 +9201,6 @@ "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", @@ -9328,7 +9238,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -9581,8 +9490,7 @@ "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", - "peer": true + "license": "MIT" }, "node_modules/client-only": { "version": "0.0.1", @@ -10179,7 +10087,6 @@ "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" } @@ -10483,7 +10390,6 @@ "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" } @@ -10994,7 +10900,6 @@ "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", @@ -12480,7 +12385,6 @@ "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", @@ -12585,7 +12489,6 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -13541,8 +13444,7 @@ "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/own-keys": { "version": "1.0.1", @@ -13784,7 +13686,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -13888,7 +13789,6 @@ "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", @@ -13971,7 +13871,6 @@ "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" } @@ -14101,7 +14000,6 @@ "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" }, @@ -14198,7 +14096,6 @@ "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" @@ -14270,16 +14167,6 @@ "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", @@ -14529,7 +14416,6 @@ "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" }, @@ -15374,7 +15260,6 @@ "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" @@ -15491,7 +15376,6 @@ "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", @@ -15741,7 +15625,6 @@ "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" @@ -16072,6 +15955,15 @@ } } }, + "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", @@ -16105,7 +15997,6 @@ "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", @@ -16583,7 +16474,6 @@ "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 62b901d..2fec6b0 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "gameyfin", - "version": "2.3.0-preview", + "version": "2.2.1", "type": "module", "dependencies": { "@heroui/react": "^2.8.5", @@ -26,7 +26,6 @@ "@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", @@ -50,10 +49,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": { @@ -65,7 +64,6 @@ "@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", @@ -139,6 +137,7 @@ "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", @@ -203,9 +202,7 @@ "@vaadin/upload": "24.9.4", "@vaadin/vertical-layout": "24.9.4", "@vaadin/virtual-list": "24.9.4", - "react-realtime-chart": "$react-realtime-chart", - "react-window": "$react-window", - "blurhash": "$blurhash" + "react-realtime-chart": "$react-realtime-chart" }, "vaadin": { "dependencies": { @@ -267,6 +264,6 @@ "workbox-precaching": "7.3.0" }, "disableUsageStatistics": true, - "hash": "d06c4b56ae3a7bc3c4356d3669fc1ed559d83e5285df4e8b3e99bff46869f939" + "hash": "45fe1cd9320d2da603b811b433279d79b37370c9732e877490fc304807ef6163" } -} \ No newline at end of file +} diff --git a/app/src/main/bundles/prod.bundle b/app/src/main/bundles/prod.bundle index d03b228..4c464a9 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 9248d2d..6a1d8e0 100644 --- a/app/src/main/frontend/App.tsx +++ b/app/src/main/frontend/App.tsx @@ -19,7 +19,6 @@ 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]; @@ -49,11 +48,10 @@ function ViewWithAuth() { if (auth.state.initializing || auth.state.loading) return; initializeLibraryState(); - initializeCollectionState(); + initializeGameState(); 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 517ab48..a89fb20 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/games"), + onClick: () => navigate("/administration/libraries"), showIf: isAdmin(auth) }, { diff --git a/app/src/main/frontend/components/administration/DownloadManagement.tsx b/app/src/main/frontend/components/administration/DownloadManagement.tsx index 01443e4..990b1f0 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; + const sessions = useSnapshot(downloadSessionState).all as SessionStatsDto[]; 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 deleted file mode 100644 index ac94a8b..0000000 --- a/app/src/main/frontend/components/administration/GameManagement.tsx +++ /dev/null @@ -1,149 +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 {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 324a196..5e87a32 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.library["allow-public-access"]}/>
diff --git a/app/src/main/frontend/components/administration/LibraryManagement.tsx b/app/src/main/frontend/components/administration/LibraryManagement.tsx new file mode 100644 index 0000000..9dfe8ee --- /dev/null +++ b/app/src/main/frontend/components/administration/LibraryManagement.tsx @@ -0,0 +1,115 @@ +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 b1cf667..dc1ac6a 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 6bb180a..65c2a39 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/SecurityManagement.tsx b/app/src/main/frontend/components/administration/SsoManagement.tsx similarity index 66% rename from app/src/main/frontend/components/administration/SecurityManagement.tsx rename to app/src/main/frontend/components/administration/SsoManagement.tsx index 3edd725..672f618 100644 --- a/app/src/main/frontend/components/administration/SecurityManagement.tsx +++ b/app/src/main/frontend/components/administration/SsoManagement.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} from "@heroui/react"; -import {MagicWandIcon} from "@phosphor-icons/react"; +import {addToast, Button, Checkbox, CheckboxGroup, Tooltip} from "@heroui/react"; +import { MagicWandIcon, WarningIcon } from "@phosphor-icons/react"; -function SecurityManagementLayout({getConfig, formik, setSaveMessage}: any) { +function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) { useEffect(() => { if (formik.dirty) { - setSaveMessage("Gameyfin must be restarted for changes in the SSO configuration to take effect"); + setSaveMessage("Gameyfin must be restarted for the changes to take effect"); } else { setSaveMessage(null); } @@ -43,26 +43,41 @@ function SecurityManagementLayout({getConfig, formik, setSaveMessage}: any) { return (
- -
- - -
-
-
-

General configuration

- - - - - -
+
-

SSO Provider Configuration

+
+ + +
+
+ +
+ + Automatically create new users after registration + + + + +
+
+ {/*TODO: enable when the issues with unregistered SSO users are sorted + + + */} + +
+ +
+ + +
+ +
enabled ? schema.required("Client ID is required") : schema @@ -125,4 +141,4 @@ const validationSchema = Yup.object({ }) }); -export const SecurityManagement = withConfigPage(SecurityManagementLayout, "Security", validationSchema); \ No newline at end of file +export const SsoManagement = withConfigPage(SsoManagementLayout, "Single Sign-On", 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 f7f9012..b82209e 100644 --- a/app/src/main/frontend/components/administration/UserManagement.tsx +++ b/app/src/main/frontend/components/administration/UserManagement.tsx @@ -4,7 +4,8 @@ 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 {UserPlusIcon} from "@phosphor-icons/react"; +import {SmallInfoField} from "Frontend/components/general/SmallInfoField"; +import { InfoIcon, 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"; @@ -31,6 +32,10 @@ function UserManagementLayout({getConfig, formik}: any) {

Users

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

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

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

{toTitleCase(scan.type)} scan for library  + href={`/administration/libraries/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 fdba76d..585e164 100644 --- a/app/src/main/frontend/components/general/SearchBar.tsx +++ b/app/src/main/frontend/components/general/SearchBar.tsx @@ -1,7 +1,8 @@ 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"; @@ -9,7 +10,7 @@ export default function SearchBar() { const navigate = useNavigate(); const state = useSnapshot(gameState); - const games = state.games; + const games = state.games as GameDto[]; 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 8bc7e91..257c013 100644 --- a/app/src/main/frontend/components/general/cards/LibraryOverviewCard.tsx +++ b/app/src/main/frontend/components/general/cards/LibraryOverviewCard.tsx @@ -1,4 +1,5 @@ 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"; @@ -22,9 +23,7 @@ export function LibraryOverviewCard({library}: LibraryOverviewCardProps) { const randomGames = getRandomGames(); function getRandomGames() { - if (!state.randomlyOrderedGamesByLibraryId[library.id]) return []; - const games = state.randomlyOrderedGamesByLibraryId[library.id] - .filter(game => game.cover?.id != null); + const games = state.randomlyOrderedGamesByLibraryId[library.id] as GameDto[]; if (!games) return []; return games.slice(0, MAX_COVER_COUNT); } @@ -41,7 +40,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 9bdf715..d15b718 100644 --- a/app/src/main/frontend/components/general/cards/PluginManagementCard.tsx +++ b/app/src/main/frontend/components/general/cards/PluginManagementCard.tsx @@ -1,20 +1,5 @@ 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"; @@ -120,11 +105,11 @@ export function PluginManagementCard({plugin}: { plugin: PluginDto }) { return state === PluginState.DISABLED; } - async function togglePluginEnabled() { + function togglePluginEnabled() { if (isDisabled(plugin.state)) { - await PluginEndpoint.enablePlugin(plugin.id); + PluginEndpoint.enablePlugin(plugin.id); } else { - await PluginEndpoint.disablePlugin(plugin.id); + 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 deleted file mode 100644 index c228f8d..0000000 --- a/app/src/main/frontend/components/general/cards/StartPageDisplayCard.tsx +++ /dev/null @@ -1,84 +0,0 @@ -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 deleted file mode 100644 index 8547dcf..0000000 --- a/app/src/main/frontend/components/general/covers/CollectionHeader.tsx +++ /dev/null @@ -1,57 +0,0 @@ -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 a77a9ad..303703a 100644 --- a/app/src/main/frontend/components/general/covers/CoverGrid.tsx +++ b/app/src/main/frontend/components/general/covers/CoverGrid.tsx @@ -1,110 +1,16 @@ 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 ( -
- {containerWidth > 0 && ( - - columnCount={columnCount} - columnWidth={getColumnWidth} - rowCount={rowCount} - rowHeight={coverHeight + GAP} - defaultWidth={containerWidth} - cellComponent={Cell} - cellProps={{}} - style={{overflowX: 'hidden'}} - /> - )} +
+ {games.map((game) => ( + + ))}
); } \ 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 3216566..2aedee5 100644 --- a/app/src/main/frontend/components/general/covers/CoverRow.tsx +++ b/app/src/main/frontend/components/general/covers/CoverRow.tsx @@ -1,166 +1,66 @@ 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 {CaretLeftIcon, CaretRightIcon} from "@phosphor-icons/react"; -import {Button, Link} from "@heroui/react"; -import {Grid, GridImperativeAPI} from "react-window"; +import {ArrowRightIcon} from "@phosphor-icons/react"; interface CoverRowProps { games: GameDto[]; title: string; - link: string; + onPressShowMore: () => void; } 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, link}: CoverRowProps) { - const gridRef = useRef(null); - const [scrollPosition, setScrollPosition] = useState(0); - const [containerWidth, setContainerWidth] = useState(0); +export function CoverRow({games, title, onPressShowMore}: CoverRowProps) { + const containerRef = useRef(null); + const [visibleCount, setVisibleCount] = useState(games.length); - // Update container width on resize useEffect(() => { - const updateWidth = () => { + const calculateVisible = () => { if (containerRef.current) { - setContainerWidth(containerRef.current.offsetWidth); + const containerWidth = containerRef.current.offsetWidth; + const maxFit = Math.floor((containerWidth - defaultImageWidth) / defaultImageWidth) + 1; + setVisibleCount(maxFit < games.length ? maxFit : games.length); } }; - const resizeObserver = new ResizeObserver(updateWidth); + const resizeObserver = new ResizeObserver(calculateVisible); if (containerRef.current) { resizeObserver.observe(containerRef.current); } - updateWidth(); + calculateVisible(); // initial calculation return () => resizeObserver.disconnect(); - }, []); + }, [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 ( -
- -
- ); - }; + const showMore = visibleCount < games.length; return (
-
- -

{title}

- - -
- - +

{title}

+
+
+ {games.slice(0, visibleCount).map((game, index) => ( + + ))}
-
-
- {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'}} - /> + + {showMore && ( +
+
+
+

Show more

+ +
+
)}
diff --git a/app/src/main/frontend/components/general/covers/GameCover.tsx b/app/src/main/frontend/components/general/covers/GameCover.tsx index e17e3f4..3e1d1ce 100644 --- a/app/src/main/frontend/components/general/covers/GameCover.tsx +++ b/app/src/main/frontend/components/general/covers/GameCover.tsx @@ -1,105 +1,21 @@ 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, 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 ? ( -
+export function GameCover({game, size = 300, radius = "sm", interactive = false}: GameCoverProps) { + const coverContent = Number.isInteger(game.coverId) ? ( +
{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 6dca975..963195d 100644 --- a/app/src/main/frontend/components/general/covers/LibraryHeader.tsx +++ b/app/src/main/frontend/components/general/covers/LibraryHeader.tsx @@ -1,5 +1,6 @@ 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"; @@ -16,9 +17,7 @@ export default function LibraryHeader({library, className}: LibraryHeaderProps) const randomGames = getRandomGames(); function getRandomGames() { - if (!state.randomlyOrderedGamesByLibraryId[library.id]) return []; - const games = state.randomlyOrderedGamesByLibraryId[library.id] - .filter(game => game.images && game.images.length > 0); + const games = state.randomlyOrderedGamesByLibraryId[library.id] as GameDto[]; if (!games) return []; return games.slice(0, MAX_COVER_COUNT); } @@ -37,7 +36,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 3073e73..805c659 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,23 +35,13 @@ const ArrayInput = ({label, ...props}) => {
{field.value.map((element: any, index: number) => ( - arrayHelpers.remove(index)} - isDisabled={props.isDisabled} - > + arrayHelpers.remove(index)}> {element} ))} - +
- {field.value || game.cover?.id ? + {field.value || game.coverId ?
{game.title}
- {field.value || game.header?.id ? + {field.value || game.headerId ?
{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 775a26e..c16d506 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] : []; + const games = state.gamesByLibraryId[library.id] ? state.gamesByLibraryId[library.id] as GameAdminDto[] : []; 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] as GameAdminDto); + const [selectedGame, setSelectedGame] = useState(games[0]); 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); - } else if (filter === "nonConfirmed") { + } + if (filter === "nonConfirmed") { return filteredGames.filter(g => !g.metadata.matchConfirmed); } - return filteredGames; } @@ -178,8 +178,7 @@ 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"}) @@ -239,7 +238,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames - + return library.ignoredPaths!!.filter((path) => path.path.toLowerCase().includes(searchTerm.toLowerCase()) ) } @@ -165,10 +165,7 @@ 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 deleted file mode 100644 index 0558b5b..0000000 --- a/app/src/main/frontend/components/general/modals/CollectionCreationModal.tsx +++ /dev/null @@ -1,90 +0,0 @@ -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 deleted file mode 100644 index 44e5be3..0000000 --- a/app/src/main/frontend/components/general/modals/CollectionGamesTable.tsx +++ /dev/null @@ -1,181 +0,0 @@ -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 deleted file mode 100644 index a7e4bc7..0000000 --- a/app/src/main/frontend/components/general/modals/CollectionPrioritiesModal.tsx +++ /dev/null @@ -1,42 +0,0 @@ -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 d80396f..771d33d 100644 --- a/app/src/main/frontend/components/general/modals/GameCoverPickerModal.tsx +++ b/app/src/main/frontend/components/general/modals/GameCoverPickerModal.tsx @@ -7,6 +7,7 @@ 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; @@ -109,7 +110,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 cd2e561..1e2b74e 100644 --- a/app/src/main/frontend/components/general/modals/GameHeaderPickerModal.tsx +++ b/app/src/main/frontend/components/general/modals/GameHeaderPickerModal.tsx @@ -7,6 +7,7 @@ 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; @@ -108,7 +109,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 e8293c9..6a8b595 100644 --- a/app/src/main/frontend/components/general/modals/LibraryCreationModal.tsx +++ b/app/src/main/frontend/components/general/modals/LibraryCreationModal.tsx @@ -1,14 +1,15 @@ 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; @@ -23,8 +24,8 @@ export default function LibraryCreationModal({ const [scanAfterCreation, setScanAfterCreation] = useState(true); const availablePlatforms = useSnapshot(platformState).available; - async function createLibrary(library: LibraryAdminDto) { - await LibraryEndpoint.createLibrary(library, scanAfterCreation); + async function createLibrary(library: LibraryDto) { + await LibraryEndpoint.createLibrary(library as LibraryAdminDto, scanAfterCreation); addToast({ title: "New library created", @@ -38,25 +39,20 @@ 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 deleted file mode 100644 index 744d993..0000000 --- a/app/src/main/frontend/components/general/modals/LibraryPrioritiesModal.tsx +++ /dev/null @@ -1,41 +0,0 @@ -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 aed5262..2fe43ff 100644 --- a/app/src/main/frontend/components/general/modals/MatchGameModal.tsx +++ b/app/src/main/frontend/components/general/modals/MatchGameModal.tsx @@ -19,6 +19,7 @@ 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"; @@ -128,7 +129,7 @@ export default function MatchGameModal({
{Object.values(item.originalIds).map( originalId => + plugin={state[originalId.pluginId] as PluginDto}/> )}
diff --git a/app/src/main/frontend/components/general/modals/PluginPrioritiesModal.tsx b/app/src/main/frontend/components/general/modals/PluginPrioritiesModal.tsx index 2d7af27..c7ce8e3 100644 --- a/app/src/main/frontend/components/general/modals/PluginPrioritiesModal.tsx +++ b/app/src/main/frontend/components/general/modals/PluginPrioritiesModal.tsx @@ -1,39 +1,113 @@ 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({isOpen, onOpenChange, type}: PluginPrioritiesModalProps) { - const plugins = useSnapshot(pluginState).sortedByType[type]; +export default function PluginPrioritiesModal({plugins, isOpen, onOpenChange}: PluginPrioritiesModalProps) { - const updatePlugins = async (reorderedPlugins: PluginDto[]) => { - const prioritiesMap: Record = {}; - const totalPlugins = reorderedPlugins.length; + const sortedPlugins = useListData({ + initialItems: plugins, // Already sorted in parent + getKey: (plugin) => plugin.id + }); - reorderedPlugins.forEach((plugin, index) => { - // Reverse order: first item gets highest priority - prioritiesMap[plugin.id] = totalPlugins - index; + 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 }); + return map; + } - await PluginEndpoint.setPluginPriorities(prioritiesMap); - }; + 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" + }); + } + } 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 deleted file mode 100644 index c9cd969..0000000 --- a/app/src/main/frontend/components/general/modals/PrioritiesModal.tsx +++ /dev/null @@ -1,127 +0,0 @@ -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 e18f3bf..e7337e1 100644 --- a/app/src/main/frontend/components/general/plugin/PluginManagementSection.tsx +++ b/app/src/main/frontend/components/general/plugin/PluginManagementSection.tsx @@ -1,19 +1,17 @@ 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 {useSnapshot} from "valtio/react"; -import {pluginState} from "Frontend/state/PluginState"; +import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto"; interface PluginManagementSectionProps { type: string; + plugins: PluginDto[]; } -export function PluginManagementSection({type}: PluginManagementSectionProps) { - const plugins = useSnapshot(pluginState).sortedByType[type]; - +export function PluginManagementSection({type, plugins = []}: PluginManagementSectionProps) { const pluginPrioritiesModal = useDisclosure(); return ( @@ -42,9 +40,10 @@ export function PluginManagementSection({type}: PluginManagementSectionProps) {
} 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 4f8d9c8..0c4daa5 100644 --- a/app/src/main/frontend/index.tsx +++ b/app/src/main/frontend/index.tsx @@ -6,10 +6,6 @@ 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 7b8c657..9f2c592 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 {GameManagement} from "Frontend/components/administration/GameManagement"; +import {LibraryManagement} from "Frontend/components/administration/LibraryManagement"; import {UserManagement} from "Frontend/components/administration/UserManagement"; import ProfileManagement from "Frontend/components/administration/ProfileManagement"; -import {SecurityManagement} from "Frontend/components/administration/SecurityManagement"; +import {SsoManagement} from "Frontend/components/administration/SsoManagement"; import {AdministrationView} from "Frontend/views/AdministrationView"; import {ProfileView} from "Frontend/views/ProfileView"; import {MessageManagement} from "Frontend/components/administration/MessageManagement"; @@ -20,14 +20,13 @@ 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([ @@ -46,6 +45,11 @@ export const {router, routes} = new RouterConfigurationBuilder() element: , handle: {title: 'Search'} }, + { + path: 'recently-added', + element: , + handle: {title: 'Recently Added'} + }, { path: '/requests', element: , @@ -55,10 +59,6 @@ export const {router, routes} = new RouterConfigurationBuilder() path: 'library/:libraryId', element: }, - { - path: 'collection/:collectionId', - element: - }, { path: 'game/:gameId', element: @@ -86,20 +86,15 @@ export const {router, routes} = new RouterConfigurationBuilder() handle: {title: 'Administration'}, children: [ { - path: 'games', - element: , - handle: {title: 'Administration - Games'} + path: 'libraries', + element: , + handle: {title: 'Administration - Libraries'} }, { - path: 'games/library/:libraryId', + path: 'libraries/library/:libraryId', element: , handle: {title: 'Administration - Library'} }, - { - path: 'games/collection/:collectionId', - element: , - handle: {title: 'Administration - Collection'} - }, { path: 'requests', element: , @@ -116,9 +111,9 @@ export const {router, routes} = new RouterConfigurationBuilder() handle: {title: 'Administration - Users'} }, { - path: 'security', - element: , - handle: {title: 'Administration - Security'} + path: 'sso', + element: , + handle: {title: 'Administration - SSO'} }, { path: 'messages', diff --git a/app/src/main/frontend/state/CollectionState.ts b/app/src/main/frontend/state/CollectionState.ts deleted file mode 100644 index fbc603b..0000000 --- a/app/src/main/frontend/state/CollectionState.ts +++ /dev/null @@ -1,70 +0,0 @@ -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 aa30def..a7d42b8 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,33 +38,26 @@ 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(`library-${libraryId}`); + const rand = new Rand(libraryId.toString()); result[libraryId] = this.gamesByLibraryId[libraryId] - .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] + .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); } diff --git a/app/src/main/frontend/state/LibraryState.ts b/app/src/main/frontend/state/LibraryState.ts index 2cb662e..778a1ad 100644 --- a/app/src/main/frontend/state/LibraryState.ts +++ b/app/src/main/frontend/state/LibraryState.ts @@ -23,20 +23,8 @@ export const libraryState = proxy({ }, get sorted() { return Object.values(this.state).sort((a, b) => { - 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(); + if (a.name === undefined || b.name === undefined) return 0; + return a.name.localeCompare(b.name); }); } }); diff --git a/app/src/main/frontend/state/PluginState.ts b/app/src/main/frontend/state/PluginState.ts index e74b6f9..aecc300 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[]; - sortedByType: Record; + pluginsByType: Record; }; export const pluginState = proxy({ @@ -20,8 +20,8 @@ export const pluginState = proxy({ get plugins() { return Object.values(this.state); }, - get sortedByType() { - return sortPluginsByType(this.state); + get pluginsByType() { + return groupPluginsByType(this.state); } }); @@ -52,7 +52,7 @@ export async function initializePluginState() { /** Computed **/ -function sortPluginsByType(pluginsMap: Record): Record { +function groupPluginsByType(pluginsMap: Record): Record { const pluginsByType: Record = {}; // Convert map to array of plugins @@ -72,10 +72,5 @@ function sortPluginsByType(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 d380b0b..da9d8e6 100644 --- a/app/src/main/frontend/util/middleware.ts +++ b/app/src/main/frontend/util/middleware.ts @@ -1,5 +1,6 @@ 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, @@ -21,13 +22,13 @@ export const ErrorHandlingMiddleware: Middleware = async function ( if (json.type == "dev.hilla.exception.EndpointException" || json.type == "com.vaadin.hilla.exception.EndpointException") { addToast({ - title: "Error", + title: getReasonPhrase(response.status), description: json.message, color: "danger" }) } else { addToast({ - title: "Error", + title: getReasonPhrase(response.status), description: `${endpoint}.${method}`, color: "danger" }) diff --git a/app/src/main/frontend/views/AdministrationView.tsx b/app/src/main/frontend/views/AdministrationView.tsx index 3d21676..f518897 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: "Games", - url: "games", + title: "Libraries", + url: "libraries", icon: }, { @@ -33,8 +33,8 @@ const menuItems: MenuItem[] = [ icon: }, { - title: "Security", - url: "security", + title: "SSO", + url: "sso", icon: }, { diff --git a/app/src/main/frontend/views/CollectionManagementView.tsx b/app/src/main/frontend/views/CollectionManagementView.tsx deleted file mode 100644 index c3fb60a..0000000 --- a/app/src/main/frontend/views/CollectionManagementView.tsx +++ /dev/null @@ -1,127 +0,0 @@ -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 deleted file mode 100644 index 5ab713b..0000000 --- a/app/src/main/frontend/views/CollectionView.tsx +++ /dev/null @@ -1,32 +0,0 @@ -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 21c0b92..58ed853 100644 --- a/app/src/main/frontend/views/GameView.tsx +++ b/app/src/main/frontend/views/GameView.tsx @@ -24,9 +24,8 @@ 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(); @@ -38,8 +37,7 @@ export default function GameView() { const matchGameModal = useDisclosure(); const state = useSnapshot(gameState); - const game = gameId ? state.state[parseInt(gameId)] : undefined; - const collections = useSnapshot(collectionState).state; + const game = gameId ? state.state[parseInt(gameId)] as GameAdminDto : undefined; const [downloadOptions, setDownloadOptions] = useState>(); @@ -71,7 +69,7 @@ export default function GameView() { await GameEndpoint.updateGame( { id: game.id, - metadata: {matchConfirmed: !(game.metadata as GameMetadataAdminDto).matchConfirmed} + metadata: {matchConfirmed: !game.metadata.matchConfirmed} } as GameUpdateDto ) } @@ -89,17 +87,17 @@ export default function GameView() { return game && (
- {game.header?.id ? ( + {game.headerId ? ( Game header - ) : game.images && game.images.length > 0 ? ( + ) : game.imageIds && game.imageIds.length > 0 ? ( Game screenshot ) : (
@@ -139,7 +137,7 @@ export default function GameView() {
{isAdmin(auth) &&
@@ -323,24 +302,22 @@ export default function GameView() {

Media

`/images/screenshot/${image.id}`)} + imageUrls={game.imageIds?.map(id => `/images/screenshot/${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 cad656f..93f63bc 100644 --- a/app/src/main/frontend/views/HomeView.tsx +++ b/app/src/main/frontend/views/HomeView.tsx @@ -1,78 +1,26 @@ +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 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"; +import {useNavigate} from "react-router"; export default function HomeView() { + const navigate = useNavigate(); const librariesState = useSnapshot(libraryState); - const collectionsState = useSnapshot(collectionState); const gamesState = useSnapshot(gameState); - 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]); + const recentlyAddedGames = gamesState.recentlyAdded as GameDto[]; + const gamesByLibrary = gamesState.gamesByLibraryId as Record; return (
-
- {(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("/recently-added")}/> + {librariesState.libraries.map((library) => ( - ))} - {filteredAndSortedCollections.map((collection) => ( - navigate("/library/" + library.id)} /> ))}
diff --git a/app/src/main/frontend/views/LibraryManagementView.tsx b/app/src/main/frontend/views/LibraryManagementView.tsx index 1951a24..6e084aa 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/games"); + navigate("/administration/libraries"); } }, [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 ef89b31..597d7e1 100644 --- a/app/src/main/frontend/views/LibraryView.tsx +++ b/app/src/main/frontend/views/LibraryView.tsx @@ -4,29 +4,25 @@ 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 = useSnapshot(gameState).gamesByLibraryId[parseInt(libraryId!)] || []; - - useEffect(() => { - window.scrollTo(0, 0) - }, []) + const games = (libraryId ? useSnapshot(gameState).gamesByLibraryId[parseInt(libraryId!!)] || [] : []) as GameDto[]; 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 new file mode 100644 index 0000000..8a02161 --- /dev/null +++ b/app/src/main/frontend/views/RecentlyAddedView.tsx @@ -0,0 +1,16 @@ +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 73c9d1e..7d8f67a 100644 --- a/app/src/main/frontend/views/SearchView.tsx +++ b/app/src/main/frontend/views/SearchView.tsx @@ -13,18 +13,19 @@ 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; - const knownDevelopers = useSnapshot(gameState).knownDevelopers; + const games = useSnapshot(gameState).sortedAlphabetically as GameDto[]; + const knownDevelopers = useSnapshot(gameState).knownDevelopers as Set; 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; + const libraries = useSnapshot(libraryState).libraries as LibraryDto[]; const [searchParams, setSearchParams] = useSearchParams(); const [initialLoadComplete, setInitialLoadComplete] = useState(false); @@ -45,9 +46,6 @@ 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 deleted file mode 100644 index 07320c5..0000000 --- a/app/src/main/kotlin/org/gameyfin/app/collections/CollectionEndpoint.kt +++ /dev/null @@ -1,62 +0,0 @@ -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 deleted file mode 100644 index 40c7224..0000000 --- a/app/src/main/kotlin/org/gameyfin/app/collections/CollectionService.kt +++ /dev/null @@ -1,158 +0,0 @@ -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 deleted file mode 100644 index d9a8fe5..0000000 --- a/app/src/main/kotlin/org/gameyfin/app/collections/dto/CollectionDto.kt +++ /dev/null @@ -1,62 +0,0 @@ -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 deleted file mode 100644 index 36a5a18..0000000 --- a/app/src/main/kotlin/org/gameyfin/app/collections/dto/CollectionEvents.kt +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 0d200dd..0000000 --- a/app/src/main/kotlin/org/gameyfin/app/collections/dto/CollectionMetadataDto.kt +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index bedce71..0000000 --- a/app/src/main/kotlin/org/gameyfin/app/collections/entities/Collection.kt +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index b1036eb..0000000 --- a/app/src/main/kotlin/org/gameyfin/app/collections/entities/CollectionEntityListener.kt +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index c9de46c..0000000 --- a/app/src/main/kotlin/org/gameyfin/app/collections/entities/CollectionMetadata.kt +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index d1ca8a0..0000000 --- a/app/src/main/kotlin/org/gameyfin/app/collections/extensions/CollectionExtensions.kt +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index f43688e..0000000 --- a/app/src/main/kotlin/org/gameyfin/app/collections/repositories/CollectionRepository.kt +++ /dev/null @@ -1,9 +0,0 @@ -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 6b130ce..1af62b9 100644 --- a/app/src/main/kotlin/org/gameyfin/app/config/ConfigProperties.kt +++ b/app/src/main/kotlin/org/gameyfin/app/config/ConfigProperties.kt @@ -15,23 +15,20 @@ sealed class ConfigProperties( val step: Number? = null ) { - /** Security */ - sealed class Security { + /** Libraries */ + sealed class Libraries { data object AllowPublicAccess : ConfigProperties( Boolean::class, - "security.allow-public-access", + "library.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", + "Enable automatic library scanning using file system watchers (coming soonâ„¢)", false ) @@ -192,6 +189,13 @@ 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 e681be8..72f4114 100644 --- a/app/src/main/kotlin/org/gameyfin/app/config/ConfigService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/config/ConfigService.kt @@ -1,7 +1,5 @@ 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 @@ -17,8 +15,7 @@ import kotlin.time.toJavaDuration @Service class ConfigService( - private val appConfigRepository: ConfigRepository, - private val objectMapper: ObjectMapper + private val appConfigRepository: ConfigRepository ) { companion object { private val log = KotlinLogging.logger {} @@ -53,7 +50,7 @@ class ConfigService( val appConfig = appConfigRepository.findByIdOrNull(configProperty.key) return if (appConfig != null) { - deserializeValue(appConfig.value, configProperty) + getValue(appConfig.value, configProperty) } else { configProperty.default ?: return null } @@ -104,18 +101,6 @@ 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. @@ -132,12 +117,16 @@ class ConfigService( var configEntry = appConfigRepository.findByIdOrNull(key) - val serializedValue = serializeValue(value, key) + val parsedValue = + if (value.javaClass.isArray) { + (value as Array).joinToString(",") + } else + value.toString() if (configEntry == null) { - configEntry = ConfigEntry(configProperty.key, serializedValue) + configEntry = ConfigEntry(configProperty.key, parsedValue) } else { - configEntry.value = serializedValue + configEntry.value = parsedValue } appConfigRepository.save(configEntry) @@ -160,6 +149,17 @@ 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,45 +175,41 @@ class ConfigService( } /** - * 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 + * Get the value of the config property in a type-safe way. */ @Suppress("UNCHECKED_CAST") - 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 - ) - } - } + 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 - /** - * 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 - ) + 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}") } } 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 112acab..a35958e 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,7 +4,6 @@ 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 @@ -20,12 +19,7 @@ class ConfigEntryEntityListener { } ConfigProperties.Libraries.Scan.EnableFilesystemWatcher.key -> { - EventPublisherHolder.publish( - LibraryFilesystemWatcherConfigUpdatedEvent( - this, - configEntry.value.toBoolean() - ) - ) + TODO() } } } 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 f4372e9..8dc4e05 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.Security.AllowPublicAccess) == true) { + if (request.userPrincipal != null || config.get(ConfigProperties.Libraries.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 0907c9e..decf6eb 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,6 +1,5 @@ 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 @@ -25,7 +24,6 @@ class PasswordResetRequestEvent(source: Any, val token: Token val title: String val platforms: List - val cover: ImageDto? - val header: ImageDto? + val coverId: Long? + val headerId: Long? val comment: String? val summary: String? val release: LocalDate? @@ -28,7 +26,7 @@ sealed interface GameDto { val keywords: List? val features: List? val perspectives: List? - val images: List? + val imageIds: List? val videoUrls: List? val metadata: GameMetadataDto } @@ -39,11 +37,10 @@ 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 cover: ImageDto?, - override val header: ImageDto?, + override val coverId: Long?, + override val headerId: Long?, override val comment: String?, override val summary: String?, override val release: LocalDate?, @@ -56,7 +53,7 @@ data class GameUserDto( override val keywords: List?, override val features: List?, override val perspectives: List?, - override val images: List?, + override val imageIds: List?, override val videoUrls: List?, override val metadata: GameMetadataUserDto ) : GameDto @@ -67,11 +64,10 @@ 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 cover: ImageDto?, - override val header: ImageDto?, + override val coverId: Long?, + override val headerId: Long?, override val comment: String?, override val summary: String?, override val release: LocalDate?, @@ -84,7 +80,7 @@ data class GameAdminDto( override val keywords: List?, override val features: List?, override val perspectives: List?, - override val images: List?, + override val imageIds: 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 6cc67db..63d4cfc 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,9 +2,7 @@ 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 @@ -81,9 +79,6 @@ 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/media/Image.kt b/app/src/main/kotlin/org/gameyfin/app/games/entities/Image.kt similarity index 87% rename from app/src/main/kotlin/org/gameyfin/app/media/Image.kt rename to app/src/main/kotlin/org/gameyfin/app/games/entities/Image.kt index 93e4efd..cb407d9 100644 --- a/app/src/main/kotlin/org/gameyfin/app/media/Image.kt +++ b/app/src/main/kotlin/org/gameyfin/app/games/entities/Image.kt @@ -1,4 +1,4 @@ -package org.gameyfin.app.media +package org.gameyfin.app.games.entities import jakarta.persistence.Entity import jakarta.persistence.GeneratedValue @@ -25,9 +25,7 @@ class Image( var contentLength: Long? = null, @MimeType - var mimeType: String? = null, - - var blurhash: String? = null + var mimeType: String? = null ) enum class ImageType { 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 b2ae98d..b7b3b50 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,7 +3,6 @@ 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 @@ -29,11 +28,10 @@ fun Game.toAdminDto(): GameAdminDto { createdAt = createdAt!!, updatedAt = updatedAt!!, libraryId = this.library.id!!, - collectionIds = this.collections.mapNotNull { it.id }, title = title!!, platforms = this.platforms, - cover = this.coverImage?.toDto(), - header = this.headerImage?.toDto(), + coverId = this.coverImage?.id, + headerId = this.headerImage?.id, comment = this.comment, summary = this.summary, release = this.release?.atZone(ZoneOffset.UTC)?.toLocalDate(), @@ -46,7 +44,7 @@ fun Game.toAdminDto(): GameAdminDto { keywords = this.keywords.toList(), features = this.features, perspectives = this.perspectives, - images = this.images.map { it.toDto() }, + imageIds = this.images.mapNotNull { it.id }, videoUrls = this.videoUrls.map { it.toString() }, metadata = this.metadata.toAdminDto() ) @@ -58,11 +56,10 @@ fun Game.toUserDto(): GameUserDto { createdAt = createdAt!!, updatedAt = updatedAt!!, libraryId = this.library.id!!, - collectionIds = this.collections.mapNotNull { it.id }, title = title!!, platforms = this.platforms, - cover = this.coverImage?.toDto(), - header = this.headerImage?.toDto(), + coverId = this.coverImage?.id, + headerId = this.headerImage?.id, comment = this.comment, summary = this.summary, release = this.release?.atZone(ZoneOffset.UTC)?.toLocalDate(), @@ -75,7 +72,7 @@ fun Game.toUserDto(): GameUserDto { keywords = this.keywords.toList(), features = this.features, perspectives = this.perspectives, - images = this.images.map { it.toDto() }, + imageIds = this.images.mapNotNull { it.id }, 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 5f5f271..d247b15 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.media.Image +import org.gameyfin.app.games.entities.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 fcaf50c..136e5fb 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.media.Image +import org.gameyfin.app.games.entities.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 fd62cfc..ae211d5 100644 --- a/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryEndpoint.kt +++ b/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryEndpoint.kt @@ -46,9 +46,6 @@ 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 37bb6c0..bc30a3f 100644 --- a/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryScanService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryScanService.kt @@ -102,37 +102,60 @@ class LibraryScanService( emit(progress) try { - val scanData = performFilesystemScan(library) + 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) // 1. Process each new game independently (including re-scanned plugin ignored paths) - val (newUnmatchedPaths, persistedNewGames) = processNewGamesWithProgress( - library, - scanData.allPathsToProcess, - progress - ) + val allPathsToProcess = newPaths + pluginIgnoredPathsToRescan + val (newUnmatchedPaths, persistedNewGames) = processNewGames(library, allPathsToProcess, progress) // 2. Update library (removed games/ignored paths, and add persisted new ones) val (removedGames) = updateLibrary( library, - scanData.removedIgnoredPaths, + removedIgnoredPaths, newUnmatchedPaths, - scanData.removedGamePaths + removedGamePaths ) // 3. Finish scan: persist library changes and report - finishScanWithProgress(persistedNewGames, library, progress) - - // 4. Send final progress update - completeScan( - progress, - QuickScanResult( - new = persistedNewGames.size, - removed = removedGames.size, - unmatched = newUnmatchedPaths.size - ) + progress.currentStep = LibraryScanStep( + description = "Finishing up", + current = 0, + total = persistedNewGames.size ) + emit(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 + ) + emit(progress) } catch (e: Exception) { - handleScanError(e, library, progress, "quick scan") + 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) } } @@ -147,7 +170,16 @@ class LibraryScanService( emit(progress) try { - val scanData = performFilesystemScan(library) + 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) } + // 1. Update existing games (individually) progress.currentStep = LibraryScanStep( @@ -160,109 +192,54 @@ class LibraryScanService( val (updatedGames) = updateExistingGames(library.games, progress) // 2. Process new games (individually, including re-scanned plugin ignored paths) - val (newUnmatchedPaths, persistedNewGames) = processNewGamesWithProgress( - library, - scanData.allPathsToProcess, - progress + val allPathsToProcess = newPaths + pluginIgnoredPathsToRescan + progress.currentStep = LibraryScanStep( + description = "Processing new games", + current = 0, + total = allPathsToProcess.size ) + emit(progress) + + val (newUnmatchedPaths, persistedNewGames) = processNewGames(library, allPathsToProcess, progress) val (removedGames) = updateLibrary( library, - scanData.removedIgnoredPaths, + removedIgnoredPaths, newUnmatchedPaths, - scanData.removedGamePaths + removedGamePaths ) // 3. Finish scan - finishScanWithProgress(persistedNewGames, library, progress) + progress.currentStep = LibraryScanStep( + description = "Finishing up", + current = 0, + total = persistedNewGames.size + ) + emit(progress) + + finishScanPersisted(persistedNewGames, library, progress) // 4. Send final progress update - completeScan( - progress, - FullScanResult( - new = persistedNewGames.size, - removed = removedGames.size, - unmatched = newUnmatchedPaths.size, - updated = updatedGames.size - ) + 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 ) + emit(progress) } catch (e: Exception) { - handleScanError(e, library, progress, "full scan") + 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 } } - 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 32f4a0b..1098a73 100644 --- a/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryService.kt @@ -5,7 +5,6 @@ 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 @@ -35,7 +34,7 @@ class LibraryService( private val libraryAdminEvents = Sinks.many().multicast().onBackpressureBuffer(1024, false) fun subscribeUser(): Flux> { - log.debug { "New user subscription for libraryUserEvents" } + log.debug { "New user subscription for libraryEvents" } return libraryUserEvents.asFlux() .buffer(100.milliseconds.toJavaDuration()) .doOnSubscribe { @@ -47,7 +46,7 @@ class LibraryService( } fun subscribeAdmin(): Flux> { - log.debug { "New admin subscription for libraryAdminEvents" } + log.debug { "New admin subscription for libraryEvents" } return libraryAdminEvents.asFlux() .buffer(100.milliseconds.toJavaDuration()) .doOnSubscribe { @@ -167,16 +166,15 @@ 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) } - // Remove existing USER-sourced ignored paths, keep PLUGIN-sourced ones intact - library.ignoredPaths.removeIf { it.getType() == IgnoredPathSourceType.USER } + library.ignoredPaths.clear() - // Recreate user-sourced paths (reuse existing entity if same path already present globally) + // Check for existing paths and reuse them if they exist val pathsToAdd = dtos.map { dto -> val existingPath = ignoredPathRepository.findByPath(dto.path) existingPath ?: dto.toEntity(currentUser) @@ -185,21 +183,10 @@ 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 deleted file mode 100644 index cc65fe3..0000000 --- a/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryWatcherService.kt +++ /dev/null @@ -1,363 +0,0 @@ -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 567907b..e95ac96 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,34 +2,27 @@ 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 createdAt: Instant? - val gameIds: List? - val metadata: LibraryMetadataDto? + val games: List? } @JsonInclude(JsonInclude.Include.NON_NULL) data class LibraryUserDto( override val id: Long, override val name: String, - override val createdAt: Instant?, - override val gameIds: List?, - override val metadata: LibraryMetadataDto? + override val games: List? ) : 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 gameIds: List?, + override val games: List?, val stats: LibraryStatsDto?, - val ignoredPaths: List?, - override val metadata: LibraryMetadataDto? + val ignoredPaths: List? ) : 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 deleted file mode 100644 index ba1d0e8..0000000 --- a/app/src/main/kotlin/org/gameyfin/app/libraries/dto/LibraryMetadataDto.kt +++ /dev/null @@ -1,6 +0,0 @@ -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 548ad97..fde574d 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,6 +7,5 @@ data class LibraryUpdateDto( val name: String? = null, val directories: List? = null, val platforms: List? = null, - val ignoredPaths: List? = null, - val metadata: LibraryMetadataDto? = null + val ignoredPaths: List? = 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 311aa70..f78035b 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,8 +35,5 @@ class Library( var games: MutableList = ArrayList(), @OneToMany(fetch = FetchType.EAGER, orphanRemoval = true, cascade = [CascadeType.ALL]) - var ignoredPaths: MutableList = ArrayList(), - - @Embedded - var metadata: LibraryMetadata = LibraryMetadata() + var ignoredPaths: MutableList = ArrayList() ) \ 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 deleted file mode 100644 index 34fe6f0..0000000 --- a/app/src/main/kotlin/org/gameyfin/app/libraries/entities/LibraryMetadata.kt +++ /dev/null @@ -1,9 +0,0 @@ -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 e4f7e93..fadb793 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,7 +3,6 @@ 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 { @@ -26,9 +25,7 @@ fun Library.toUserDto(): LibraryUserDto { return LibraryUserDto( id = this.id!!, name = this.name, - createdAt = this.createdAt!!, - gameIds = this.games.mapNotNull { it.id }, - metadata = this.metadata.toDto() + games = this.games.mapNotNull { it.id } ) } @@ -36,29 +33,13 @@ 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, - gameIds = this.games.mapNotNull { it.id }, + games = this.games.mapNotNull { it.id }, stats = LibraryStatsDto( gamesCount = this.games.size, downloadedGamesCount = this.games.sumOf { it.metadata.downloadCount } ), - ignoredPaths = this.ignoredPaths.toDtos(), - metadata = this.metadata.toDto() + ignoredPaths = this.ignoredPaths.toDtos() ) } - -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 b5da9a7..667c32a 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.updateMetadata(game) + updated = gameService.update(game) if (updated != null) { // Download any images now associated with the game downloadImagesForGame(updated) diff --git a/app/src/main/kotlin/org/gameyfin/app/media/ImageDto.kt b/app/src/main/kotlin/org/gameyfin/app/media/ImageDto.kt deleted file mode 100644 index bd35598..0000000 --- a/app/src/main/kotlin/org/gameyfin/app/media/ImageDto.kt +++ /dev/null @@ -1,7 +0,0 @@ -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 5167304..8b7fa57 100644 --- a/app/src/main/kotlin/org/gameyfin/app/media/ImageEndpoint.kt +++ b/app/src/main/kotlin/org/gameyfin/app/media/ImageEndpoint.kt @@ -8,6 +8,8 @@ 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 deleted file mode 100644 index fc06b7f..0000000 --- a/app/src/main/kotlin/org/gameyfin/app/media/ImageExtensions.kt +++ /dev/null @@ -1,13 +0,0 @@ -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 9a6e9c2..af24ec2 100644 --- a/app/src/main/kotlin/org/gameyfin/app/media/ImageService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/media/ImageService.kt @@ -1,12 +1,13 @@ 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 @@ -17,12 +18,8 @@ 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( @@ -33,37 +30,6 @@ 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( @@ -160,19 +126,7 @@ 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) - - // 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) - } + imageContentStore.setContent(image, input) } // Save or update the image to ensure it's persisted @@ -185,22 +139,8 @@ class ImageService( fun createFromInputStream(type: ImageType, content: InputStream, mimeType: String): Image { val image = Image(type = type, mimeType = mimeType) - - // 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) + imageRepository.save(image) + return imageContentStore.setContent(image, content) } fun getImage(id: Long): Image? { @@ -225,51 +165,12 @@ class ImageService( fun updateFileContent(image: Image, content: InputStream, mimeType: String? = null): Image { mimeType?.let { image.mimeType = it } - - // 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) + imageRepository.save(image) + return imageContentStore.setContent(image, content) } private fun imageHasValidContent(image: Image): Boolean { val imageContent = imageContentStore.getContent(image) return imageContent != null && image.contentLength != null && image.contentLength!! > 0 } - - 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 - } - } -} +} \ No newline at end of file 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 eb02572..b30b66f 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 var _availablePlatforms: Set = emptySet() - private var _platformsInUseByGames: Set = emptySet() - private var _platformsInUseByLibraries: Set = emptySet() + private lateinit var _availablePlatforms: Set + private lateinit var _platformsInUseByGames: Set + private lateinit var _platformsInUseByLibraries: Set val availablePlatforms: Set get() = _availablePlatforms @@ -63,7 +63,7 @@ class PlatformService( @EventListener(ApplicationReadyEvent::class) fun initialize() { - log.debug { "Initializing platform caches at startup" } + log.info { "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 8de596b..3d297d8 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.media.Image +import org.gameyfin.app.games.entities.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 c6a93b8..9c5994f 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.media.Image +import org.gameyfin.app.games.entities.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 bdba52a..0f5a04a 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,7 +10,4 @@ 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 deleted file mode 100644 index 900200c..0000000 --- a/app/src/main/kotlin/org/gameyfin/db/h2/BlurhashMigration.kt +++ /dev/null @@ -1,177 +0,0 @@ -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 a91a7c5..f74ade9 100644 --- a/app/src/main/kotlin/org/gameyfin/db/h2/H2Aliases.kt +++ b/app/src/main/kotlin/org/gameyfin/db/h2/H2Aliases.kt @@ -1,7 +1,5 @@ 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 @@ -12,9 +10,6 @@ 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). */ @@ -31,71 +26,5 @@ 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 08b8393..486ad24 100644 --- a/app/src/main/resources/application.yml +++ b/app/src/main/resources/application.yml @@ -52,7 +52,6 @@ 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 10c1d01..4fc7f58 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} ${spring.application.version} +${spring.application.name} ${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 deleted file mode 100644 index a335780..0000000 --- a/app/src/main/resources/db/migration/V2.3.0.1__Convert_config_values_to_json_format.sql +++ /dev/null @@ -1,64 +0,0 @@ --- 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 deleted file mode 100644 index 1e3aac1..0000000 --- a/app/src/main/resources/db/migration/V2.3.0.2__Add_library_metadata_fields.sql +++ /dev/null @@ -1,11 +0,0 @@ --- 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 deleted file mode 100644 index 6bf7d53..0000000 --- a/app/src/main/resources/db/migration/V2.3.0.3__Create_collections_tables.sql +++ /dev/null @@ -1,29 +0,0 @@ --- 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 deleted file mode 100644 index 0a3a3ad..0000000 --- a/app/src/main/resources/db/migration/V2.3.0.4__Rename_library_allow_public_access_to_security.sql +++ /dev/null @@ -1,9 +0,0 @@ --- 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 deleted file mode 100644 index 0edc7f7..0000000 --- a/app/src/main/resources/db/migration/V2.3.0.5__Add_collection_games_added_at_tracking.sql +++ /dev/null @@ -1,23 +0,0 @@ --- 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 deleted file mode 100644 index 4cfc1d0..0000000 --- a/app/src/main/resources/db/migration/V2.3.0.6__Add_blurhash_support_to_images.sql +++ /dev/null @@ -1,17 +0,0 @@ --- 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 deleted file mode 100644 index 1f33a93..0000000 --- a/app/src/test/kotlin/org/gameyfin/app/collections/CollectionServiceTest.kt +++ /dev/null @@ -1,199 +0,0 @@ -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 deleted file mode 100644 index 0f7cb67..0000000 --- a/app/src/test/kotlin/org/gameyfin/app/collections/dto/CollectionEventsTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 75f8f46..0000000 --- a/app/src/test/kotlin/org/gameyfin/app/collections/entities/CollectionEntityTest.kt +++ /dev/null @@ -1,66 +0,0 @@ -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 deleted file mode 100644 index e7eeb82..0000000 --- a/app/src/test/kotlin/org/gameyfin/app/collections/extensions/CollectionExtensionsTest.kt +++ /dev/null @@ -1,65 +0,0 @@ -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 deleted file mode 100644 index 7bc8bdd..0000000 --- a/app/src/test/kotlin/org/gameyfin/app/config/ConfigServiceTest.kt +++ /dev/null @@ -1,460 +0,0 @@ -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 deleted file mode 100644 index 5a73e29..0000000 --- a/app/src/test/kotlin/org/gameyfin/app/core/exceptions/EndpointExceptionHandlerTest.kt +++ /dev/null @@ -1,88 +0,0 @@ -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 90463c6..e41277f 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.Security.AllowPublicAccess) } returns false + every { configService.get(ConfigProperties.Libraries.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.Security.AllowPublicAccess) } returns true + every { configService.get(ConfigProperties.Libraries.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.Security.AllowPublicAccess) } returns true + every { configService.get(ConfigProperties.Libraries.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.Security.AllowPublicAccess) } returns false + every { configService.get(ConfigProperties.Libraries.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.Security.AllowPublicAccess) } returns false + every { configService.get(ConfigProperties.Libraries.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.Security.AllowPublicAccess) } returns true + every { configService.get(ConfigProperties.Libraries.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.Security.AllowPublicAccess) } returns false + every { configService.get(ConfigProperties.Libraries.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.Security.AllowPublicAccess) } returns false + every { configService.get(ConfigProperties.Libraries.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.Security.AllowPublicAccess) } returns null + every { configService.get(ConfigProperties.Libraries.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.Security.AllowPublicAccess) } returns false + every { configService.get(ConfigProperties.Libraries.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.Security.AllowPublicAccess) } returns false + every { configService.get(ConfigProperties.Libraries.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 02f1012..fddab06 100644 --- a/app/src/test/kotlin/org/gameyfin/app/games/GameServiceTest.kt +++ b/app/src/test/kotlin/org/gameyfin/app/games/GameServiceTest.kt @@ -15,9 +15,7 @@ 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 @@ -589,7 +587,7 @@ class GameServiceTest { every { gameRepository.findByIdOrNull(999L) } returns null assertThrows(IllegalArgumentException::class.java) { - gameService.updateMetadata(game) + gameService.update(game) } } @@ -605,7 +603,7 @@ class GameServiceTest { every { pluginManager.getExtensions("test-plugin") } returns emptyList() assertThrows(NoSuchElementException::class.java) { - gameService.updateMetadata(game) + gameService.update(game) } } @@ -627,7 +625,7 @@ class GameServiceTest { every { pluginService.getPluginManagementEntry(provider.javaClass) } returns pluginEntry every { pluginManager.getPluginForExtension(provider.javaClass) } returns null - val result = gameService.updateMetadata(game) + val result = gameService.update(game) assertNull(result) } @@ -662,7 +660,7 @@ class GameServiceTest { every { filesystemService.calculateFileSize(any()) } returns 1000L every { library.platforms } returns mutableListOf(Platform.PC_MICROSOFT_WINDOWS) - val result = gameService.updateMetadata(game) + val result = gameService.update(game) assertNull(result) } @@ -692,7 +690,7 @@ class GameServiceTest { every { imageService.createOrGet(any()) } returns mockk(relaxed = true) every { filesystemService.calculateFileSize(any()) } returns 1000L - val result = gameService.updateMetadata(game) + val result = gameService.update(game) assertNotNull(result) assertEquals("New Title", result.title) @@ -727,7 +725,7 @@ class GameServiceTest { every { filesystemService.calculateFileSize(any()) } returns 1000L every { library.platforms } returns mutableListOf(Platform.PC_MICROSOFT_WINDOWS) - val result = gameService.updateMetadata(game) + val result = gameService.update(game) // Should return null because no fields were actually updated assertNull(result) @@ -765,7 +763,7 @@ class GameServiceTest { every { imageService.createOrGet(any()) } returns mockk(relaxed = true) every { filesystemService.calculateFileSize(any()) } returns 1000L - val result = gameService.updateMetadata(game) + val result = gameService.update(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 2025944..9206283 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,9 +8,8 @@ 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 @@ -104,8 +103,8 @@ class GameExtensionsTest { assertEquals(1L, result.libraryId) assertEquals("Test Game", result.title) assertEquals(listOf(Platform.PC_MICROSOFT_WINDOWS), result.platforms) - assertEquals(10L, result.cover!!.id) - assertEquals(11L, result.header!!.id) + assertEquals(10L, result.coverId) + assertEquals(11L, result.headerId) assertEquals("Test comment", result.comment) assertEquals("Test summary", result.summary) assertNotNull(result.release) @@ -118,7 +117,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.images!!.map { it.id }) + assertEquals(listOf(12L), result.imageIds) assertEquals(listOf("https://example.com/video"), result.videoUrls) assertNotNull(result.metadata) } @@ -133,8 +132,8 @@ class GameExtensionsTest { assertEquals(1L, result.libraryId) assertEquals("Test Game", result.title) assertEquals(listOf(Platform.PC_MICROSOFT_WINDOWS), result.platforms) - assertEquals(10L, result.cover!!.id) - assertEquals(11L, result.header!!.id) + assertEquals(10L, result.coverId) + assertEquals(11L, result.headerId) assertEquals("Test comment", result.comment) assertEquals("Test summary", result.summary) assertNotNull(result.release) @@ -147,7 +146,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.images!!.map { it.id }) + assertEquals(listOf(12L), result.imageIds) assertEquals(listOf("https://example.com/video"), result.videoUrls) assertNotNull(result.metadata) } @@ -167,8 +166,8 @@ class GameExtensionsTest { val result = game.toAdminDto() assertEquals("Test Game", result.title) - assertEquals(null, result.cover?.id) - assertEquals(null, result.header?.id) + assertEquals(null, result.coverId) + assertEquals(null, result.headerId) assertEquals(null, result.comment) assertEquals(null, result.summary) assertEquals(null, result.release) @@ -191,8 +190,8 @@ class GameExtensionsTest { val result = game.toUserDto() assertEquals("Test Game", result.title) - assertEquals(null, result.cover?.id) - assertEquals(null, result.header?.id) + assertEquals(null, result.coverId) + assertEquals(null, result.headerId) assertEquals(null, result.comment) assertEquals(null, result.summary) assertEquals(null, result.release) @@ -238,6 +237,34 @@ 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 { @@ -289,18 +316,12 @@ 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 211b821..3226c7f 100644 --- a/app/src/test/kotlin/org/gameyfin/app/libraries/LibraryEndpointTest.kt +++ b/app/src/test/kotlin/org/gameyfin/app/libraries/LibraryEndpointTest.kt @@ -10,7 +10,6 @@ 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 { @@ -52,13 +51,7 @@ 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, - createdAt = Instant.now(), - name = "Test Library", - gameIds = emptyList(), - metadata = LibraryMetadataDto(true, 1) - ) + val userDto = LibraryUserDto(id = 1L, name = "Test Library", games = emptyList()) val userEvent = LibraryUserEvent.Created(userDto) val userFlux: Flux> = Flux.just(listOf(userEvent)) every { LibraryService.subscribeUser() } returns userFlux @@ -224,19 +217,6 @@ 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 @@ -265,14 +245,12 @@ class LibraryEndpointTest { ): LibraryAdminDto { return LibraryAdminDto( id = id, - createdAt = Instant.now(), name = name, directories = emptyList(), platforms = emptyList(), - gameIds = emptyList(), + games = emptyList(), stats = LibraryStatsDto(0, 0), - ignoredPaths = emptyList(), - metadata = LibraryMetadataDto(true, 1) + ignoredPaths = emptyList() ) } } 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 2271e3f..f02a518 100644 --- a/app/src/test/kotlin/org/gameyfin/app/libraries/LibraryServiceTest.kt +++ b/app/src/test/kotlin/org/gameyfin/app/libraries/LibraryServiceTest.kt @@ -5,7 +5,10 @@ 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.* +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.entities.DirectoryMapping import org.gameyfin.app.libraries.entities.IgnoredPath import org.gameyfin.app.libraries.entities.IgnoredPathUserSource @@ -394,34 +397,7 @@ class LibraryServiceTest { libraryService.update(updateDto) assertNotNull(library.updatedAt) - 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) + assertTrue(library.updatedAt!! >= beforeUpdate) } @Test @@ -498,14 +474,12 @@ class LibraryServiceTest { ): LibraryAdminDto { return LibraryAdminDto( id = id, - createdAt = Instant.now(), name = name, directories = directories, platforms = platforms, - gameIds = emptyList(), + games = emptyList(), stats = LibraryStatsDto(0, 0), - ignoredPaths = emptyList(), - metadata = LibraryMetadataDto(true, 1) + ignoredPaths = emptyList() ) } } diff --git a/app/src/test/kotlin/org/gameyfin/app/libraries/LibraryWatcherServiceTest.kt b/app/src/test/kotlin/org/gameyfin/app/libraries/LibraryWatcherServiceTest.kt deleted file mode 100644 index 74843fa..0000000 --- a/app/src/test/kotlin/org/gameyfin/app/libraries/LibraryWatcherServiceTest.kt +++ /dev/null @@ -1,545 +0,0 @@ -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 7ee7a11..72a28f1 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.gameIds) + assertEquals(listOf(1L, 2L), result.games) } @Test @@ -110,7 +110,7 @@ class LibraryExtensionsTest { val result = library.toUserDto() assertEquals(1L, result.id) - assertTrue(result.gameIds!!.isEmpty()) + assertTrue(result.games!!.isEmpty()) } @Test @@ -125,8 +125,8 @@ class LibraryExtensionsTest { val result = library.toUserDto() - assertEquals(2, result.gameIds!!.size) - assertEquals(listOf(1L, 3L), result.gameIds) + assertEquals(2, result.games!!.size) + assertEquals(listOf(1L, 3L), result.games) } @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.gameIds) + assertEquals(listOf(1L, 2L), result.games) 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.gameIds!!.isEmpty()) + assertTrue(result.games!!.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.gameIds!!.size) - assertEquals(listOf(1L, 3L), result.gameIds) + assertEquals(2, result.games!!.size) + assertEquals(listOf(1L, 3L), result.games) } @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 3f2187d..aa1a666 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.updateMetadata(game) } returns updatedGame + every { gameService.update(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.updateMetadata(game) } + verify(exactly = 1) { gameService.update(game) } } @Test fun `processExistingGame should return null when game is not updated`() { val game = createTestGame(1L, "/path/to/game") - every { gameService.updateMetadata(game) } returns null + every { gameService.update(game) } returns null val result = libraryGameProcessor.processExistingGame(game) assertEquals(null, result) - verify(exactly = 1) { gameService.updateMetadata(game) } + verify(exactly = 1) { gameService.update(game) } } @Test @@ -210,7 +210,7 @@ class LibraryGameProcessorTest { val coverImage = createTestImage(1L) val updatedGame = createTestGame(1L, "/path/to/game", coverImage = coverImage) - every { gameService.updateMetadata(game) } returns updatedGame + every { gameService.update(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.updateMetadata(game) } returns updatedGame + every { gameService.update(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.updateMetadata(game) } returns updatedGame + every { gameService.update(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.updateMetadata(game) } throws RuntimeException("Update error") + every { gameService.update(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.updateMetadata(game) } returns null + every { gameService.update(game) } returns null libraryGameProcessor.processExistingGame(game) @@ -290,7 +290,7 @@ class LibraryGameProcessorTest { images = mutableListOf(image1, image2, image3) ) - every { gameService.updateMetadata(game) } returns updatedGame + every { gameService.update(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 aec45b5..5fe8da2 100644 --- a/app/src/test/kotlin/org/gameyfin/app/media/ImageEndpointTest.kt +++ b/app/src/test/kotlin/org/gameyfin/app/media/ImageEndpointTest.kt @@ -3,6 +3,8 @@ 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 23a9530..53dc288 100644 --- a/app/src/test/kotlin/org/gameyfin/app/media/ImageServiceTest.kt +++ b/app/src/test/kotlin/org/gameyfin/app/media/ImageServiceTest.kt @@ -7,6 +7,8 @@ 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 @@ -317,7 +319,7 @@ class ImageServiceTest { assertNotNull(result) verify(exactly = 1) { imageRepository.save(any()) } - verify(exactly = 1) { imageContentStore.setContent(any(), any()) } + verify(exactly = 1) { imageContentStore.setContent(any(), inputStream) } } @Test @@ -437,7 +439,7 @@ class ImageServiceTest { assertEquals("image/jpeg", image.mimeType) verify(exactly = 1) { imageRepository.save(image) } - verify(exactly = 1) { imageContentStore.setContent(image, any()) } + verify(exactly = 1) { imageContentStore.setContent(image, inputStream) } } @Test @@ -448,11 +450,11 @@ class ImageServiceTest { every { imageRepository.save(image) } returns image every { imageContentStore.setContent(any(), any()) } returns image - imageService.updateFileContent(image, inputStream) + imageService.updateFileContent(image, inputStream, null) assertEquals("image/png", image.mimeType) verify(exactly = 1) { imageRepository.save(image) } - verify(exactly = 1) { imageContentStore.setContent(image, any()) } + verify(exactly = 1) { imageContentStore.setContent(image, inputStream) } } @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 a202556..6519028 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.media.Image +import org.gameyfin.app.games.entities.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 3180226..a8dcc8f 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.media.Image -import org.gameyfin.app.media.ImageType +import org.gameyfin.app.games.entities.Image +import org.gameyfin.app.games.entities.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 035ceb9..24f5b55 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.3.0-preview" +version = "2.2.1" allprojects { repositories { diff --git a/scripts/gog.sh b/scripts/gog.sh old mode 100755 new mode 100644