From d094d4e2ccd2aa3994c23864c77f6580e75e6768 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:21:37 -0400 Subject: [PATCH 001/128] Add web client and media/metadata server work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce an initial Vite/TypeScript web client (crates/client-web) with README, package.json, lockfile, entry HTML, and basic src API/mocks/styles. Update .gitignore to ignore the client build and node_modules. Add a series of SQL migrations (0000001–0000011) to evolve users, media catalog, metadata links, playback scoping, source roots and library settings. Add server-side media and metadata modules plus related web routes (media, settings) and update configuration, DB models/schema, globals, logging, tray and web routing to support the new features. Also add and update numerous tests and documentation (MOVIE_NAMING.md, ROADMAP.md) to cover the changes. --- .gitignore | 4 + crates/client-web/README.md | 59 + crates/client-web/index.html | 12 + crates/client-web/package-lock.json | 1167 ++++++ crates/client-web/package.json | 20 + crates/client-web/src/api.ts | 714 ++++ crates/client-web/src/main.ts | 3161 ++++++++++++++++ crates/client-web/src/mockApi.ts | 1051 ++++++ crates/client-web/src/style.css | 1581 ++++++++ crates/client-web/tsconfig.json | 19 + crates/client-web/vite.config.ts | 13 + crates/server/Cargo.toml | 3 + .../down.sql | 0 .../up.sql | 0 .../0000002_create_media_catalog/down.sql | 4 + .../0000002_create_media_catalog/up.sql | 34 + .../0000003_enhance_media_catalog/down.sql | 79 + .../0000003_enhance_media_catalog/up.sql | 14 + .../down.sql | 2 + .../0000004_create_item_metadata_links/up.sql | 16 + .../down.sql | 52 + .../up.sql | 15 + .../0000006_add_media_source_roots/down.sql | 68 + .../0000006_add_media_source_roots/up.sql | 71 + .../down.sql | 30 + .../up.sql | 11 + .../down.sql | 8 + .../up.sql | 8 + .../down.sql | 71 + .../up.sql | 2 + .../down.sql | 35 + .../up.sql | 39 + .../down.sql | 172 + .../up.sql | 300 ++ crates/server/src/config.rs | 380 +- crates/server/src/db/mod.rs | 15 +- crates/server/src/db/models.rs | 302 +- crates/server/src/db/schema.rs | 169 +- crates/server/src/globals.rs | 9 +- crates/server/src/lib.rs | 2 + crates/server/src/logging/mod.rs | 78 +- crates/server/src/media.rs | 3186 +++++++++++++++++ crates/server/src/metadata.rs | 1749 +++++++++ crates/server/src/tray.rs | 3 +- crates/server/src/web/mod.rs | 25 +- crates/server/src/web/routes/common.rs | 117 +- crates/server/src/web/routes/media.rs | 1708 +++++++++ crates/server/src/web/routes/mod.rs | 40 +- crates/server/src/web/routes/settings.rs | 320 ++ crates/server/src/web/routes/user.rs | 94 +- crates/server/tests/main.rs | 2 + crates/server/tests/test_auth.rs | 8 +- crates/server/tests/test_dependencies/mod.rs | 1 + crates/server/tests/test_media.rs | 893 +++++ crates/server/tests/test_metadata.rs | 72 + crates/server/tests/test_utils.rs | 5 + crates/server/tests/test_web/mod.rs | 2 +- crates/server/tests/test_web/routes/auth.rs | 5 +- crates/server/tests/test_web/routes/common.rs | 52 +- crates/server/tests/test_web/routes/media.rs | 186 + crates/server/tests/test_web/routes/mod.rs | 2 + .../server/tests/test_web/routes/settings.rs | 150 + crates/server/tests/test_web/routes/user.rs | 59 +- .../server/tests/test_web/test_auth_routes.rs | 31 +- docs/MOVIE_NAMING.md | 134 + docs/README.md | 27 +- docs/ROADMAP.md | 205 ++ 67 files changed, 18791 insertions(+), 75 deletions(-) create mode 100644 crates/client-web/README.md create mode 100644 crates/client-web/index.html create mode 100644 crates/client-web/package-lock.json create mode 100644 crates/client-web/package.json create mode 100644 crates/client-web/src/api.ts create mode 100644 crates/client-web/src/main.ts create mode 100644 crates/client-web/src/mockApi.ts create mode 100644 crates/client-web/src/style.css create mode 100644 crates/client-web/tsconfig.json create mode 100644 crates/client-web/vite.config.ts rename crates/server/sql/migrations/{0_create_users => 0000001_create_users}/down.sql (100%) rename crates/server/sql/migrations/{0_create_users => 0000001_create_users}/up.sql (100%) create mode 100644 crates/server/sql/migrations/0000002_create_media_catalog/down.sql create mode 100644 crates/server/sql/migrations/0000002_create_media_catalog/up.sql create mode 100644 crates/server/sql/migrations/0000003_enhance_media_catalog/down.sql create mode 100644 crates/server/sql/migrations/0000003_enhance_media_catalog/up.sql create mode 100644 crates/server/sql/migrations/0000004_create_item_metadata_links/down.sql create mode 100644 crates/server/sql/migrations/0000004_create_item_metadata_links/up.sql create mode 100644 crates/server/sql/migrations/0000005_extend_metadata_and_playback/down.sql create mode 100644 crates/server/sql/migrations/0000005_extend_metadata_and_playback/up.sql create mode 100644 crates/server/sql/migrations/0000006_add_media_source_roots/down.sql create mode 100644 crates/server/sql/migrations/0000006_add_media_source_roots/up.sql create mode 100644 crates/server/sql/migrations/0000007_store_library_settings_in_db/down.sql create mode 100644 crates/server/sql/migrations/0000007_store_library_settings_in_db/up.sql create mode 100644 crates/server/sql/migrations/0000008_normalize_legacy_metadata_provider_ids/down.sql create mode 100644 crates/server/sql/migrations/0000008_normalize_legacy_metadata_provider_ids/up.sql create mode 100644 crates/server/sql/migrations/0000009_track_metadata_match_attempts/down.sql create mode 100644 crates/server/sql/migrations/0000009_track_metadata_match_attempts/up.sql create mode 100644 crates/server/sql/migrations/0000010_scope_playback_progress_to_user/down.sql create mode 100644 crates/server/sql/migrations/0000010_scope_playback_progress_to_user/up.sql create mode 100644 crates/server/sql/migrations/0000011_logical_media_items_and_metadata_hierarchy/down.sql create mode 100644 crates/server/sql/migrations/0000011_logical_media_items_and_metadata_hierarchy/up.sql create mode 100644 crates/server/src/media.rs create mode 100644 crates/server/src/metadata.rs create mode 100644 crates/server/src/web/routes/media.rs create mode 100644 crates/server/src/web/routes/settings.rs create mode 100644 crates/server/tests/test_media.rs create mode 100644 crates/server/tests/test_metadata.rs create mode 100644 crates/server/tests/test_web/routes/media.rs create mode 100644 crates/server/tests/test_web/routes/settings.rs create mode 100644 docs/MOVIE_NAMING.md create mode 100644 docs/ROADMAP.md diff --git a/.gitignore b/.gitignore index 71ba4b12..208c8d5e 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,7 @@ build_rs_cov.profraw # unit tests test_data/ + +# web client +crates/client-web/node_modules/ +crates/client-web/dist/ diff --git a/crates/client-web/README.md b/crates/client-web/README.md new file mode 100644 index 00000000..59e0b65d --- /dev/null +++ b/crates/client-web/README.md @@ -0,0 +1,59 @@ +# Koko web client + +This is the initial browser client shell for Koko. + +Current scope: + +- configurable API base URL +- automatic mock-data fallback during local development when the server is unavailable +- server capability bootstrap +- media library list +- media item list +- media item detail view +- simple search against the Stage 1 media APIs + +## Development + +Install dependencies: + +```cmd +npm install +``` + +Start the dev server: + +```cmd +npm run dev +``` + +In development mode, the client will automatically fall back to mock data if the Koko server is unavailable. + +Start the dev server in forced mock mode: + +```cmd +npm run dev:mock +``` + +Use a custom backend base URL during development: + +```cmd +set VITE_API_BASE_URL=https://127.0.0.1:9191 +npm run dev +``` + +Build the client: + +```cmd +npm run build +``` + +After building, the Rust server serves the generated bundle from `crates/client-web/dist`. + +Type-check the client: + +```cmd +npm run check +``` + + + diff --git a/crates/client-web/index.html b/crates/client-web/index.html new file mode 100644 index 00000000..f9112921 --- /dev/null +++ b/crates/client-web/index.html @@ -0,0 +1,12 @@ + + + + + + Koko web client + + +
+ + + diff --git a/crates/client-web/package-lock.json b/crates/client-web/package-lock.json new file mode 100644 index 00000000..a2d912b6 --- /dev/null +++ b/crates/client-web/package-lock.json @@ -0,0 +1,1167 @@ +{ + "name": "koko-client-web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "koko-client-web", + "version": "0.0.0", + "dependencies": { + "lucide": "^0.468.0" + }, + "devDependencies": { + "typescript": "^5.8.3", + "vite": "^7.1.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lucide": { + "version": "0.468.0", + "resolved": "https://registry.npmjs.org/lucide/-/lucide-0.468.0.tgz", + "integrity": "sha512-UFbgwji/ZnAV7iTTE4jujyTV7J95AILKyATDUrqOJrMcUGfXvGjw3c1mcuHZUX2oJfkrAGU9KoxkrLQk2jjtiA==", + "license": "ISC" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/crates/client-web/package.json b/crates/client-web/package.json new file mode 100644 index 00000000..f57aeb14 --- /dev/null +++ b/crates/client-web/package.json @@ -0,0 +1,20 @@ +{ + "name": "koko-client-web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "dev:mock": "set VITE_USE_MOCK_API=true&& vite", + "build": "vite build", + "preview": "vite preview", + "check": "tsc --noEmit" + }, + "dependencies": { + "lucide": "^0.468.0" + }, + "devDependencies": { + "typescript": "^5.8.3", + "vite": "^7.1.0" + } +} diff --git a/crates/client-web/src/api.ts b/crates/client-web/src/api.ts new file mode 100644 index 00000000..eff63b62 --- /dev/null +++ b/crates/client-web/src/api.ts @@ -0,0 +1,714 @@ +import { + addMockLibrary, + createMockUser, + getMockCapabilities, + getMockBootstrap, + getMockHome, + getMockItems, + getMockItem, + getMockItemMetadata, + getMockLibraries, + getMockMetadataProviders, + getMockLogs, + getMockUsers, + getMockPlayback, + getMockSystemActivities, + refreshMockLibraryMetadata, + refreshMockItemMetadata, + getMockSettings, + linkMockItemMetadata, + loginMockUser, + removeMockLibrary, + searchMockItemMetadata, + searchMockItems, + updateMockPlaybackProgress, + updateMockSettings, +} from './mockApi'; + +export interface ServerCapabilities { + app_name: string; + version: string; + server_url: string; + https_enabled: boolean; + libraries_configured: number; + ffmpeg_strategy?: string; + api_versions: string[]; + transcoding: { + ffmpeg: { + available: boolean; + version?: string; + error?: string; + }; + ffprobe: { + available: boolean; + version?: string; + error?: string; + }; + }; +} + +export interface BootstrapUser { + id: number; + username: string; + admin: boolean; +} + +export interface AppBootstrapResponse { + has_users: boolean; + current_user?: BootstrapUser; +} + +export interface LoginRequest { + username: string; + password: string; +} + +export interface TokenResponse { + token: string; +} + +export interface CreateUserRequest { + username: string; + password: string; + pin?: string; + admin: boolean; +} + +export interface MediaLibrary { + id: number; + name: string; + path: string; + paths: string[]; + recursive: boolean; + kind: string; + status: string; + scan_revision: number; + last_scanned_at?: number; + total_files: number; + video_files: number; + audio_files: number; + image_files: number; + book_files: number; + other_files: number; + metadata_refresh_total: number; + metadata_refresh_pending: number; + metadata_refresh_completed: number; + metadata_refresh_failed: number; + error?: string; +} + +export interface MediaItemSummary { + id: number; + library_id: number; + parent_id?: number | null; + item_type: string; + display_title: string; + relative_path: string; + media_kind: string; + playable: boolean; + child_count: number; + season_number?: number; + episode_number?: number; + duration_ms?: number; + width?: number; + height?: number; + genres: string[]; + has_metadata?: boolean; + metadata_refresh_state?: string; + metadata_refresh_error?: string; + artwork_updated_at?: number; + modified_at?: number; +} + +export interface MediaItemDetail extends MediaItemSummary { + file_size?: number; + container?: string; + bit_rate?: number; + video_codec?: string; + audio_codec?: string; + metadata_json?: string; + metadata_updated_at?: number; + poster_url?: string; + backdrop_url?: string; + theme_song_url?: string; + theme_song_youtube_url?: string; + tagline?: string; + overview?: string; + genres: string[]; + release_year?: number; + linked_media_type?: string; + trailer_title?: string; + trailer_url?: string; + artwork_updated_at?: number; + subtitle_tracks: MediaSubtitleTrack[]; + hierarchy: MediaItemSummary[]; + children: MediaItemSummary[]; +} + +export interface MediaSubtitleTrack { + index: number; + label: string; + format: string; + url: string; +} + +export interface MetadataProviderStatus { + id: string; + display_name: string; + description: string; + supported_kinds: string[]; + requires_api_key: boolean; + implemented: boolean; + enabled: boolean; + configured: boolean; + language: string; +} + +export interface ItemMetadataMatch { + id: number; + provider_id: string; + external_id: string; + title?: string; + overview?: string; + artwork_url?: string; + backdrop_url?: string; + release_year?: number; + media_type?: string; + match_state: string; + provider_payload_json?: string; + cached_artwork_path?: string; + cached_backdrop_path?: string; + refresh_state?: string; + last_refreshed_at?: number; + next_refresh_at?: number; + refresh_error?: string; + updated_at?: number; +} + +export interface ItemMetadataResponse { + item_id: number; + providers: MetadataProviderStatus[]; + matches: ItemMetadataMatch[]; +} + +export interface MetadataSearchResult { + provider_id: string; + external_id: string; + media_type: string; + title: string; + overview?: string; + artwork_url?: string; + backdrop_url?: string; + release_year?: number; +} + +export interface MediaShelf { + id: string; + title: string; + items: MediaItemSummary[]; +} + +export interface MediaCollectionSummary { + id: string; + provider_id: string; + external_id: string; + name: string; + overview?: string; + artwork_url?: string; + backdrop_url?: string; + item_ids: number[]; + item_count: number; +} + +export interface MediaHome { + library_id?: number; + shelves: MediaShelf[]; + collections: MediaCollectionSummary[]; +} + +export interface PlaybackDecision { + item_id: number; + can_direct_play: boolean; + transcode_required: boolean; + reason: string; + stream_url?: string; + mime_type?: string; +} + +export interface MetadataProviderSettings { + id: string; + enabled: boolean; + api_key?: string | null; + language: string; + rate_limit_per_second: number; + retry_attempts: number; + retry_backoff_ms: number; +} + +export interface SystemActivity { + id: string; + category: string; + scope: string; + source: string; + state: string; + label: string; + provider_id?: string; + library_id?: number; + root_item_id?: number; + item_ids: number[]; + total_items: number; + completed_items: number; + failed_items: number; + queued_at: number; + started_at?: number; + updated_at: number; +} + +export interface SystemActivitiesResponse { + generated_at: number; + activities: SystemActivity[]; +} + +export interface LogEntry { + timestamp: string; + level: string; + module: string; + source_file_path: string; + line_number?: number; + message: string; +} + +export interface LogEntriesResponse { + log_path: string; + entries: LogEntry[]; +} + +export interface MediaLibrarySettings { + name: string; + path: string; + paths: string[]; + recursive: boolean; + kind: string; + metadata_providers: string[]; +} + +export interface SettingsSnapshot { + general: { + data_dir: string; + }; + media: { + libraries: MediaLibrarySettings[]; + }; + metadata: { + providers: MetadataProviderSettings[]; + }; + server: { + use_https: boolean; + address: string; + port: number; + cert_path: string; + key_path: string; + use_custom_certs: boolean; + }; + ffmpeg: { + strategy: string; + ffmpeg_path: string; + ffprobe_path: string; + }; +} + +export interface SettingsResponse { + settings: SettingsSnapshot; + settings_path: string; +} + +export interface PlaybackProgressRequest { + position_ms: number; + duration_ms?: number; + completed: boolean; +} + +export interface LinkMetadataRequest { + provider_id: string; + external_id: string; + media_type: string; +} + +export type ApiMode = 'live' | 'mock'; + +const LOCAL_STORAGE_KEY = 'koko-client-web-api-base'; +const AUTH_TOKEN_STORAGE_KEY = 'koko-client-web-auth-token'; +const ENV_API_BASE_URL = import.meta.env.VITE_API_BASE_URL?.trim(); +const ENV_USE_MOCK_API = import.meta.env.VITE_USE_MOCK_API === 'true'; +let activeApiMode: ApiMode = ENV_USE_MOCK_API ? 'mock' : 'live'; + +export function getStoredApiBase(): string { + if (ENV_API_BASE_URL) { + return ENV_API_BASE_URL.replace(/\/$/, ''); + } + + const stored = window.localStorage.getItem(LOCAL_STORAGE_KEY)?.trim(); + if (stored) { + return stored.replace(/\/$/, ''); + } + + return window.location.origin.replace(/\/$/, ''); +} + +export function getStoredAuthToken(): string | undefined { + return window.localStorage.getItem(AUTH_TOKEN_STORAGE_KEY)?.trim() || undefined; +} + +export function setStoredAuthToken(token: string): void { + window.localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, token.trim()); +} + +export function clearStoredAuthToken(): void { + window.localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY); +} + +export function getApiMode(): ApiMode { + return activeApiMode; +} + +function shouldUseMockApi(): boolean { + return ENV_USE_MOCK_API || activeApiMode === 'mock'; +} + +function useLiveApi(): void { + activeApiMode = 'live'; +} + +function useMockApi(): void { + activeApiMode = 'mock'; +} + +function getMockJsonResponse(method: string, path: string, body?: unknown): T { + const url = new URL(path, 'http://koko.local'); + + if (method === 'GET') { + switch (url.pathname) { + case '/api/v1/system/capabilities': + return getMockCapabilities() as T; + case '/api/v1/bootstrap': + return getMockBootstrap() as T; + case '/api/v1/users': + return getMockUsers() as T; + case '/api/v1/libraries': + return getMockLibraries() as T; + case '/api/v1/metadata/providers': + return getMockMetadataProviders() as T; + case '/api/v1/system/activities': + return getMockSystemActivities() as T; + case '/api/v1/settings': + return getMockSettings() as T; + case '/api/v1/settings/logs': + return getMockLogs( + url.searchParams.get('level') ?? undefined, + url.searchParams.get('module') ?? undefined, + url.searchParams.get('search') ?? undefined, + url.searchParams.get('since') ?? undefined, + url.searchParams.get('until') ?? undefined, + url.searchParams.get('limit') ? Number(url.searchParams.get('limit')) : undefined, + ) as T; + case '/api/v1/home': { + const libraryId = url.searchParams.get('library_id'); + return getMockHome(libraryId ? Number(libraryId) : undefined) as T; + } + case '/api/v1/items': { + const libraryId = url.searchParams.get('library_id'); + return getMockItems(libraryId ? Number(libraryId) : undefined) as T; + } + case '/api/v1/search': { + const query = url.searchParams.get('query') ?? ''; + const libraryId = url.searchParams.get('library_id'); + return searchMockItems(query, libraryId ? Number(libraryId) : undefined) as T; + } + default: { + const itemMetadataSearchMatch = url.pathname.match(/^\/api\/v1\/items\/(\d+)\/metadata\/search$/); + if (itemMetadataSearchMatch) { + return searchMockItemMetadata( + Number(itemMetadataSearchMatch[1]), + url.searchParams.get('query') ?? undefined, + ) as T; + } + + const itemMetadataMatch = url.pathname.match(/^\/api\/v1\/items\/(\d+)\/metadata$/); + if (itemMetadataMatch) { + const itemMetadata = getMockItemMetadata(Number(itemMetadataMatch[1])); + if (!itemMetadata) { + throw new Error('404 Not Found'); + } + + return itemMetadata as T; + } + + const itemPlaybackMatch = url.pathname.match(/^\/api\/v1\/items\/(\d+)\/playback$/); + if (itemPlaybackMatch) { + return getMockPlayback(Number(itemPlaybackMatch[1])) as T; + } + + const itemMatch = url.pathname.match(/^\/api\/v1\/items\/(\d+)$/); + if (itemMatch) { + const item = getMockItem(Number(itemMatch[1])); + if (!item) { + throw new Error('404 Not Found'); + } + + return item as T; + } + + throw new Error(`No mock response is defined for ${method} ${url.pathname}`); + } + } + } + + if (method === 'PUT' && url.pathname === '/api/v1/settings') { + return updateMockSettings(body as SettingsSnapshot) as T; + } + + if (method === 'POST' && url.pathname === '/login') { + return loginMockUser(body as LoginRequest) as T; + } + + if (method === 'POST' && url.pathname === '/create_user') { + return createMockUser(body as CreateUserRequest) as T; + } + + if (method === 'POST' && url.pathname === '/api/v1/settings/libraries') { + return addMockLibrary(body as { library: MediaLibrarySettings }) as T; + } + + const removeLibraryMatch = url.pathname.match(/^\/api\/v1\/settings\/libraries\/(\d+)$/); + if (method === 'DELETE' && removeLibraryMatch) { + return removeMockLibrary(Number(removeLibraryMatch[1])) as T; + } + + const itemProgressMatch = url.pathname.match(/^\/api\/v1\/items\/(\d+)\/progress$/); + if (method === 'POST' && itemProgressMatch) { + updateMockPlaybackProgress(Number(itemProgressMatch[1]), body as PlaybackProgressRequest); + return undefined as T; + } + + const itemLinkMatch = url.pathname.match(/^\/api\/v1\/items\/(\d+)\/metadata\/link$/); + if (method === 'POST' && itemLinkMatch) { + return linkMockItemMetadata(Number(itemLinkMatch[1]), body as LinkMetadataRequest) as T; + } + + const itemRefreshMatch = url.pathname.match(/^\/api\/v1\/items\/(\d+)\/metadata\/refresh$/); + if (method === 'POST' && itemRefreshMatch) { + return refreshMockItemMetadata(Number(itemRefreshMatch[1])) as T; + } + + const libraryRefreshMatch = url.pathname.match(/^\/api\/v1\/libraries\/(\d+)\/metadata\/refresh$/); + if (method === 'POST' && libraryRefreshMatch) { + return refreshMockLibraryMetadata(Number(libraryRefreshMatch[1])) as T; + } + + throw new Error(`No mock response is defined for ${method} ${url.pathname}`); +} + +async function requestJson(method: string, path: string, body?: unknown): Promise { + if (shouldUseMockApi()) { + useMockApi(); + return getMockJsonResponse(method, path, body); + } + + try { + const response = await fetch(`${getStoredApiBase()}${path}`, { + method, + headers: { + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + ...(getStoredAuthToken() ? { Authorization: `Bearer ${getStoredAuthToken()}` } : {}), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (!response.ok) { + if (response.status === 401) { + clearStoredAuthToken(); + } + const error = new Error(`${response.status} ${response.statusText}`); + if (import.meta.env.DEV) { + useMockApi(); + return getMockJsonResponse(method, path, body); + } + + return Promise.reject(error); + } + + useLiveApi(); + if (response.status === 204) { + return undefined as T; + } + if (response.headers.get('content-type')?.includes('application/json')) { + return response.json() as Promise; + } + + return undefined as T; + } catch (error) { + if (import.meta.env.DEV) { + useMockApi(); + return getMockJsonResponse(method, path, body); + } + + throw error; + } +} + +export function getCapabilities(): Promise { + return requestJson('GET', '/api/v1/system/capabilities'); +} + +export function getAppBootstrap(): Promise { + return requestJson('GET', '/api/v1/bootstrap'); +} + +export function loginUser(request: LoginRequest): Promise { + return requestJson('POST', '/login', request); +} + +export function createUser(request: CreateUserRequest): Promise { + return requestJson('POST', '/create_user', request); +} + +export function getUsers(): Promise { + return requestJson('GET', '/api/v1/users'); +} + +export function getLibraries(): Promise { + return requestJson('GET', '/api/v1/libraries'); +} + +export function getHome(libraryId?: number): Promise { + const query = typeof libraryId === 'number' ? `?library_id=${libraryId}` : ''; + return requestJson('GET', `/api/v1/home${query}`); +} + +export function getItems(libraryId?: number): Promise { + const query = typeof libraryId === 'number' ? `?library_id=${libraryId}` : ''; + return requestJson('GET', `/api/v1/items${query}`); +} + +export function searchItems(query: string, libraryId?: number): Promise { + const params = new URLSearchParams({ query }); + if (typeof libraryId === 'number') { + params.set('library_id', String(libraryId)); + } + + return requestJson('GET', `/api/v1/search?${params.toString()}`); +} + +export function getItem(itemId: number): Promise { + return requestJson('GET', `/api/v1/items/${itemId}`); +} + +export function getMetadataProviders(): Promise { + return requestJson('GET', '/api/v1/metadata/providers'); +} + +export function getSystemActivities(): Promise { + return requestJson('GET', '/api/v1/system/activities'); +} + +export function getItemMetadata(itemId: number): Promise { + return requestJson('GET', `/api/v1/items/${itemId}/metadata`); +} + +export function searchItemMetadata(itemId: number, query?: string): Promise { + const params = new URLSearchParams(); + if (query?.trim()) { + params.set('query', query.trim()); + } + const suffix = params.toString() ? `?${params.toString()}` : ''; + return requestJson('GET', `/api/v1/items/${itemId}/metadata/search${suffix}`); +} + +export function linkItemMetadata(itemId: number, request: LinkMetadataRequest): Promise { + return requestJson('POST', `/api/v1/items/${itemId}/metadata/link`, request); +} + +export function refreshItemMetadata(itemId: number): Promise { + return requestJson('POST', `/api/v1/items/${itemId}/metadata/refresh`); +} + +export function refreshLibraryMetadata(libraryId: number): Promise { + return requestJson('POST', `/api/v1/libraries/${libraryId}/metadata/refresh`); +} + +export function getPlaybackDecision(itemId: number): Promise { + return requestJson('GET', `/api/v1/items/${itemId}/playback`); +} + +export function updatePlaybackProgress(itemId: number, request: PlaybackProgressRequest): Promise { + return requestJson('POST', `/api/v1/items/${itemId}/progress`, request); +} + +export function getSettings(): Promise { + return requestJson('GET', '/api/v1/settings'); +} + +export function getLogs(filters?: { + level?: string; + module?: string; + search?: string; + since?: string; + until?: string; + limit?: number; +}): Promise { + const params = new URLSearchParams(); + if (filters?.level?.trim()) { + params.set('level', filters.level.trim()); + } + if (filters?.module?.trim()) { + params.set('module', filters.module.trim()); + } + if (filters?.search?.trim()) { + params.set('search', filters.search.trim()); + } + if (filters?.since?.trim()) { + params.set('since', filters.since.trim()); + } + if (filters?.until?.trim()) { + params.set('until', filters.until.trim()); + } + if (typeof filters?.limit === 'number' && Number.isFinite(filters.limit)) { + params.set('limit', String(filters.limit)); + } + + const suffix = params.toString() ? `?${params.toString()}` : ''; + return requestJson('GET', `/api/v1/settings/logs${suffix}`); +} + +export function updateSettings(settings: SettingsSnapshot): Promise { + return requestJson('PUT', '/api/v1/settings', settings); +} + +export function addLibrary(library: MediaLibrarySettings): Promise { + return requestJson('POST', '/api/v1/settings/libraries', { library }); +} + +export function deleteLibrary(libraryIndex: number): Promise { + return requestJson('DELETE', `/api/v1/settings/libraries/${libraryIndex}`); +} + +export function resolveApiUrl(path: string): string { + if (/^https?:\/\//i.test(path)) { + return path; + } + + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + return `${getStoredApiBase()}${normalizedPath}`; +} + +export function getStreamUrl(itemId: number): string { + return `${getStoredApiBase()}/api/v1/items/${itemId}/stream`; +} + +export function getArtworkUrl(itemId: number, kind: 'poster' | 'backdrop' = 'poster', revision?: number): string { + const params = new URLSearchParams({ kind }); + if (typeof revision === 'number') { + params.set('rev', String(revision)); + } + + return `${getStoredApiBase()}/api/v1/items/${itemId}/artwork?${params.toString()}`; +} diff --git a/crates/client-web/src/main.ts b/crates/client-web/src/main.ts new file mode 100644 index 00000000..12d89f1b --- /dev/null +++ b/crates/client-web/src/main.ts @@ -0,0 +1,3161 @@ +import './style.css'; +import { createIcons, icons } from 'lucide'; +import { + addLibrary, + clearStoredAuthToken, + createUser, + deleteLibrary, + getAppBootstrap, + getApiMode, + getArtworkUrl, + getCapabilities, + getHome, + getItem, + getItemMetadata, + getItems, + getLibraries, + getMetadataProviders, + getLogs, + getPlaybackDecision, + getSystemActivities, + refreshLibraryMetadata, + refreshItemMetadata, + getSettings, + getStoredAuthToken, + getStoredApiBase, + getStreamUrl, + getUsers, + linkItemMetadata, + loginUser, + resolveApiUrl, + searchItemMetadata, + searchItems, + setStoredAuthToken, + updatePlaybackProgress, + updateSettings, + type AppBootstrapResponse, + type ApiMode, + type BootstrapUser, + type CreateUserRequest, + type MediaCollectionSummary, + type ItemMetadataResponse, + type LoginRequest, + type MediaHome, + type MediaItemDetail, + type MediaItemSummary, + type MediaLibrary, + type MediaLibrarySettings, + type MetadataProviderStatus, + type MetadataSearchResult, + type LogEntriesResponse, + type PlaybackDecision, + type SettingsResponse, + type SettingsSnapshot, + type ServerCapabilities, + type SystemActivity, +} from './api'; + +type AppRoute = + | { page: 'home'; libraryId?: number } + | { page: 'item'; itemId: number } + | { page: 'settings' }; + +type HomeBrowseTab = 'recommended' | 'library' | 'collections' | 'playlists' | 'categories'; + +interface BrowseFilter { + kind: 'category' | 'collection'; + label: string; + itemIds: number[]; +} + +interface TrailerOption { + title: string; + url: string; +} + +interface AppState { + apiBase: string; + apiMode: ApiMode; + route: AppRoute; + bootstrap?: AppBootstrapResponse; + users: BootstrapUser[]; + capabilities?: ServerCapabilities; + libraries: MediaLibrary[]; + home?: MediaHome; + libraryItems: MediaItemSummary[]; + searchResults: MediaItemSummary[]; + metadataProviders: MetadataProviderStatus[]; + systemActivities: SystemActivity[]; + dashboardItems: MediaItemSummary[]; + settingsResponse?: SettingsResponse; + logsResponse?: LogEntriesResponse; + selectedItem?: MediaItemDetail; + selectedItemMetadata?: ItemMetadataResponse; + selectedPlayback?: PlaybackDecision; + metadataSearchResults: MetadataSearchResult[]; + searchQuery: string; + metadataSearchQuery: string; + homeTab: HomeBrowseTab; + browseFilter?: BrowseFilter; + isLoading: boolean; + isPlayerOpen: boolean; + isTrailerMenuOpen: boolean; + activeTrailer?: { title: string; url: string }; + error?: string; + hasDeferredAutoRefreshRender: boolean; + metadataDashboardFilters: { + libraryId: string; + itemType: string; + refreshState: string; + search: string; + }; + logFilters: { + level: string; + module: string; + search: string; + since: string; + until: string; + }; +} + +type AppIconName = + | 'arrow-left' + | 'book' + | 'clapperboard' + | 'film' + | 'house' + | 'image' + | 'layout-grid' + | 'link-2' + | 'log-in' + | 'log-out' + | 'music' + | 'play' + | 'plus' + | 'refresh-cw' + | 'save' + | 'search' + | 'settings' + | 'trash-2' + | 'tv' + | 'triangle-alert' + | 'user-plus' + | 'x'; + +const state: AppState = { + apiBase: getStoredApiBase(), + apiMode: getApiMode(), + route: parseRoute(), + users: [], + libraries: [], + libraryItems: [], + searchResults: [], + metadataProviders: [], + systemActivities: [], + dashboardItems: [], + metadataSearchResults: [], + searchQuery: '', + metadataSearchQuery: '', + homeTab: defaultHomeTab(parseRoute()), + isLoading: true, + hasDeferredAutoRefreshRender: false, + isPlayerOpen: false, + isTrailerMenuOpen: false, + activeTrailer: undefined, + metadataDashboardFilters: { + libraryId: '', + itemType: '', + refreshState: '', + search: '', + }, + logFilters: { + level: '', + module: '', + search: '', + since: '', + until: '', + }, +}; + +const app = document.querySelector('#app'); +if (!app) { + throw new Error('Failed to find app container'); +} +const appRoot = app; +let pendingLibraryRefreshHandle: number | undefined; +let pendingMetadataRefreshHandle: number | undefined; + +function activeMetadataRefreshActivities(): SystemActivity[] { + return state.systemActivities.filter((activity) => { + return activity.category === 'metadata_refresh' + && activity.state !== 'completed' + && activity.state !== 'failed'; + }); +} + +function activeMetadataRefreshItemIds(): Set { + return new Set(activeMetadataRefreshActivities().flatMap((activity) => activity.item_ids)); +} + +function itemHasActiveMetadataRefresh(itemId?: number): boolean { + return typeof itemId === 'number' && activeMetadataRefreshItemIds().has(itemId); +} + +function activityProgress(activity: Pick): { + completed: number; + total: number; + failed: number; + percent: number; +} { + const total = Math.max(0, activity.total_items); + const completed = Math.min(total, Math.max(0, activity.completed_items)); + const failed = Math.max(0, activity.failed_items); + const percent = total > 0 ? Math.min(100, Math.max(0, (completed / total) * 100)) : 0; + return { completed, total, failed, percent }; +} + +function metadataRefreshActivityProgressForLibrary(libraryId: number): { + completed: number; + total: number; + failed: number; + percent: number; +} | undefined { + const activities = activeMetadataRefreshActivities().filter((activity) => activity.library_id === libraryId); + if (!activities.length) { + return undefined; + } + + const totals = activities.reduce((summary, activity) => { + const progress = activityProgress(activity); + return { + completed: summary.completed + progress.completed, + total: summary.total + progress.total, + failed: summary.failed + progress.failed, + }; + }, { completed: 0, total: 0, failed: 0 }); + if (totals.total <= 0) { + return undefined; + } + + return { + ...totals, + percent: Math.min(100, Math.max(0, (totals.completed / totals.total) * 100)), + }; +} + +function currentLogFilterRequest(): { level?: string; module?: string; search?: string; since?: string; until?: string; limit: number } { + return { + level: state.logFilters.level || undefined, + module: state.logFilters.module || undefined, + search: state.logFilters.search || undefined, + since: state.logFilters.since || undefined, + until: state.logFilters.until || undefined, + limit: 200, + }; +} + +function snapshotJson(value: unknown): string { + return JSON.stringify(value); +} + +function shouldDeferAutoRefreshRender(): boolean { + if (state.route.page !== 'item') { + return false; + } + + if (state.isPlayerOpen || Boolean(state.activeTrailer)) { + return true; + } + + const themeAudio = document.querySelector('#theme-song-player'); + if (themeAudio && !themeAudio.paused && !themeAudio.ended) { + return true; + } + + return Boolean(document.querySelector('#theme-song-youtube-player')); +} + +function maybeRenderAfterAutoRefresh(shouldRender: boolean): void { + if (state.error) { + state.hasDeferredAutoRefreshRender = false; + render(); + return; + } + + if (state.hasDeferredAutoRefreshRender && !shouldAutoRefreshMetadata()) { + state.hasDeferredAutoRefreshRender = false; + render(); + return; + } + + if (!shouldRender) { + return; + } + + if (shouldDeferAutoRefreshRender()) { + state.hasDeferredAutoRefreshRender = true; + return; + } + + state.hasDeferredAutoRefreshRender = false; + render(); +} + +function defaultHomeTab(_route: AppRoute): HomeBrowseTab { + return 'recommended'; +} + +function parseRoute(): AppRoute { + const normalizedPath = window.location.pathname.replace(/\/+$/, '') || '/'; + + if (normalizedPath === '/settings') { + return { page: 'settings' }; + } + + const itemMatch = normalizedPath.match(/^\/items\/(\d+)$/); + if (itemMatch) { + return { page: 'item', itemId: Number(itemMatch[1]) }; + } + + const libraryMatch = normalizedPath.match(/^\/libraries\/(\d+)$/); + if (libraryMatch) { + return { page: 'home', libraryId: Number(libraryMatch[1]) }; + } + + return { page: 'home' }; +} + +function clearPendingLibraryRefresh(): void { + if (pendingLibraryRefreshHandle !== undefined) { + window.clearTimeout(pendingLibraryRefreshHandle); + pendingLibraryRefreshHandle = undefined; + } +} + +function shouldAutoRefreshLibraries(): boolean { + return state.route.page !== 'item' + && state.libraries.some((library) => library.status === 'never_scanned'); +} + +function schedulePendingLibraryRefresh(): void { + clearPendingLibraryRefresh(); + if (!shouldAutoRefreshLibraries()) { + return; + } + + pendingLibraryRefreshHandle = window.setTimeout(() => { + pendingLibraryRefreshHandle = undefined; + void refreshData(); + }, 1800); +} + +function clearPendingMetadataRefresh(): void { + if (pendingMetadataRefreshHandle !== undefined) { + window.clearTimeout(pendingMetadataRefreshHandle); + pendingMetadataRefreshHandle = undefined; + } +} + +function itemIsMetadataPending(item: Pick | undefined): boolean { + return item?.metadata_refresh_state === 'pending' || itemHasActiveMetadataRefresh(item?.id); +} + +function librariesHavePendingMetadataRefresh(): boolean { + return state.libraries.some((library) => library.metadata_refresh_pending > 0); +} + +function shouldAutoRefreshMetadata(): boolean { + if (activeMetadataRefreshActivities().length > 0) { + return true; + } + + if (librariesHavePendingMetadataRefresh()) { + return true; + } + + if (state.route.page === 'item') { + return itemIsMetadataPending(state.selectedItem) + || Boolean(state.selectedItem?.children.some((child) => itemIsMetadataPending(child))) + || Boolean(state.selectedItemMetadata?.matches.some((match) => match.refresh_state === 'pending')); + } + + const visibleShelfItems = state.home?.shelves.flatMap((shelf) => shelf.items) ?? []; + return [...state.libraryItems, ...state.searchResults, ...visibleShelfItems] + .some((item) => item.metadata_refresh_state === 'pending'); +} + +function schedulePendingMetadataRefresh(): void { + clearPendingMetadataRefresh(); + if (!shouldAutoRefreshMetadata()) { + return; + } + + pendingMetadataRefreshHandle = window.setTimeout(() => { + pendingMetadataRefreshHandle = undefined; + void refreshPendingMetadataData(); + }, 1500); +} + +function navigateTo(path: string, replace = false): void { + const currentPath = `${window.location.pathname}${window.location.search}`; + if (currentPath === path) { + state.route = parseRoute(); + render(); + return; + } + + if (replace) { + window.history.replaceState({}, '', path); + } else { + window.history.pushState({}, '', path); + } + state.route = parseRoute(); + if (state.route.page === 'home') { + state.homeTab = defaultHomeTab(state.route); + state.browseFilter = undefined; + } + state.isTrailerMenuOpen = false; + void refreshData(); +} + +function formatTimestamp(timestamp?: number): string { + if (!timestamp) { + return 'Unknown'; + } + + return new Date(timestamp * 1000).toLocaleString('en-US'); +} + +function formatDuration(durationMs?: number): string { + if (!durationMs) { + return 'Unknown'; + } + + const totalSeconds = Math.floor(durationMs / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + } + + return `${minutes}:${String(seconds).padStart(2, '0')}`; +} + +function formatFileSize(fileSize?: number): string { + if (!fileSize) { + return 'Unknown'; + } + + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let size = fileSize; + let unitIndex = 0; + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex += 1; + } + + return `${size.toFixed(size >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; +} + +function formatBitRate(bitRate?: number): string { + if (!bitRate) { + return 'Unknown'; + } + + if (bitRate >= 1_000_000) { + return `${(bitRate / 1_000_000).toFixed(bitRate >= 10_000_000 ? 0 : 1)} Mbps`; + } + + if (bitRate >= 1_000) { + return `${Math.round(bitRate / 1_000)} kbps`; + } + + return `${bitRate} bps`; +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function extractYouTubeVideoId(url: string): string | undefined { + const normalizedUrl = url.trim(); + if (!normalizedUrl) { + return undefined; + } + + if (/^[A-Za-z0-9_-]{11}$/.test(normalizedUrl)) { + return normalizedUrl; + } + + try { + const parsed = new URL(normalizedUrl); + const host = parsed.hostname.toLowerCase(); + if (host === 'youtu.be') { + const videoId = parsed.pathname.split('/').filter(Boolean)[0]; + return /^[A-Za-z0-9_-]{11}$/.test(videoId ?? '') ? videoId : undefined; + } + + if (host.endsWith('youtube.com')) { + if (parsed.pathname.startsWith('/watch')) { + const videoId = parsed.searchParams.get('v')?.trim(); + return /^[A-Za-z0-9_-]{11}$/.test(videoId ?? '') ? videoId : undefined; + } + + if (parsed.pathname.startsWith('/embed/')) { + const videoId = parsed.pathname.split('/')[2]?.trim(); + return /^[A-Za-z0-9_-]{11}$/.test(videoId ?? '') ? videoId : undefined; + } + } + } catch { + return undefined; + } + + return undefined; +} + +function buildYouTubeEmbedUrl( + url: string, + options: { autoplay?: boolean; controls?: boolean; loop?: boolean } = {}, +): string | undefined { + const videoId = extractYouTubeVideoId(url); + if (!videoId) { + return undefined; + } + + const embedUrl = new URL(`https://www.youtube.com/embed/${videoId}`); + embedUrl.searchParams.set('rel', '0'); + embedUrl.searchParams.set('playsinline', '1'); + embedUrl.searchParams.set('modestbranding', '1'); + embedUrl.searchParams.set('enablejsapi', '1'); + if (window.location.origin) { + embedUrl.searchParams.set('origin', window.location.origin); + } + embedUrl.searchParams.set('autoplay', options.autoplay ? '1' : '0'); + embedUrl.searchParams.set('controls', options.controls === false ? '0' : '1'); + if (options.loop) { + embedUrl.searchParams.set('loop', '1'); + embedUrl.searchParams.set('playlist', videoId); + } + + return embedUrl.toString(); +} + +function parseTrailerOptionsFromPayload(payloadJson?: string): TrailerOption[] { + if (!payloadJson) { + return []; + } + + try { + const payload = JSON.parse(payloadJson) as { videos?: { results?: Array> } }; + const results = Array.isArray(payload.videos?.results) ? payload.videos.results : []; + const seenVideoIds = new Set(); + + return results + .filter((entry) => entry.site === 'YouTube' && (entry.type === 'Trailer' || entry.type === 'Teaser')) + .sort((left, right) => { + const score = (entry: Record): number => { + const type = entry.type === 'Trailer' ? 'Trailer' : entry.type === 'Teaser' ? 'Teaser' : ''; + const official = entry.official === true; + if (official && type === 'Trailer') { + return 0; + } + + if (official && type === 'Teaser') { + return 1; + } + + if (type === 'Trailer') { + return 2; + } + + if (type === 'Teaser') { + return 3; + } + + return 4; + }; + + return score(left) - score(right); + }) + .flatMap((entry, index) => { + const videoId = typeof entry.key === 'string' ? entry.key.trim() : ''; + if (!videoId || seenVideoIds.has(videoId)) { + return []; + } + + seenVideoIds.add(videoId); + return [{ + title: typeof entry.name === 'string' && entry.name.trim() + ? entry.name.trim() + : `Trailer ${index + 1}`, + url: `https://www.youtube.com/watch?v=${videoId}`, + } satisfies TrailerOption]; + }); + } catch { + return []; + } +} + +function currentTrailerOptions(): TrailerOption[] { + const parsedOptions = parseTrailerOptionsFromPayload(state.selectedItemMetadata?.matches[0]?.provider_payload_json); + if (parsedOptions.length) { + return parsedOptions; + } + + if (!state.selectedItem?.trailer_url) { + return []; + } + + return [{ + title: state.selectedItem.trailer_title?.trim() || 'Trailer', + url: state.selectedItem.trailer_url, + }]; +} + +function openTrailer(option: TrailerOption | undefined): void { + if (!option) { + return; + } + + state.activeTrailer = option; + state.isTrailerMenuOpen = false; + render(); +} + +function libraryRefreshProgress(library: MediaLibrary): { completed: number; total: number; percent: number; failed: number } | undefined { + const activityProgress = metadataRefreshActivityProgressForLibrary(library.id); + if (activityProgress) { + return activityProgress; + } + + if (library.metadata_refresh_total <= 0 || library.metadata_refresh_pending <= 0) { + return undefined; + } + + const completed = Math.max(0, library.metadata_refresh_completed); + const percent = Math.min(100, Math.max(0, (completed / library.metadata_refresh_total) * 100)); + return { + completed, + total: library.metadata_refresh_total, + percent, + failed: library.metadata_refresh_failed, + }; +} + +function activeLibraryPendingRefreshCount(libraryId: number): number { + return activeMetadataRefreshActivities() + .filter((activity) => activity.library_id === libraryId) + .reduce((count, activity) => count + Math.max(0, activity.total_items - activity.completed_items), 0); +} + +function metadataDashboardRefreshState(item: MediaItemSummary): 'pending' | 'stalled' | 'error' | 'fresh' | 'unmatched' { + if (itemIsMetadataPending(item)) { + return itemHasActiveMetadataRefresh(item.id) ? 'pending' : 'stalled'; + } + + if (item.metadata_refresh_state === 'error') { + return 'error'; + } + + if (item.metadata_refresh_state === 'fresh' || item.has_metadata) { + return 'fresh'; + } + + return 'unmatched'; +} + +function metadataDashboardRefreshLabel(item: MediaItemSummary): string { + switch (metadataDashboardRefreshState(item)) { + case 'pending': + return 'Refreshing'; + case 'stalled': + return 'Pending without worker'; + case 'error': + return 'Failed'; + case 'fresh': + return 'Up to date'; + default: + return 'Not linked'; + } +} + +function filteredMetadataDashboardItems(): MediaItemSummary[] { + const libraryFilter = state.metadataDashboardFilters.libraryId; + const itemTypeFilter = state.metadataDashboardFilters.itemType; + const refreshStateFilter = state.metadataDashboardFilters.refreshState; + const searchFilter = state.metadataDashboardFilters.search.trim().toLowerCase(); + + const rank = (item: MediaItemSummary): number => { + switch (metadataDashboardRefreshState(item)) { + case 'error': + return 0; + case 'stalled': + return 1; + case 'pending': + return 2; + case 'unmatched': + return 3; + default: + return 4; + } + }; + + return [...state.dashboardItems] + .filter((item) => { + const matchesLibrary = libraryFilter ? String(item.library_id) === libraryFilter : true; + const matchesItemType = itemTypeFilter ? item.item_type === itemTypeFilter : true; + const matchesRefreshState = refreshStateFilter ? metadataDashboardRefreshState(item) === refreshStateFilter : true; + const matchesSearch = searchFilter + ? `${item.display_title} ${item.relative_path} ${item.metadata_refresh_error ?? ''}`.toLowerCase().includes(searchFilter) + : true; + return matchesLibrary && matchesItemType && matchesRefreshState && matchesSearch; + }) + .sort((left, right) => { + return rank(left) - rank(right) + || left.library_id - right.library_id + || left.display_title.localeCompare(right.display_title) + || left.relative_path.localeCompare(right.relative_path); + }); +} + +function metadataDashboardSummary(items: MediaItemSummary[]): { + failed: number; + pending: number; + stalled: number; + unmatched: number; +} { + return items.reduce((summary, item) => { + switch (metadataDashboardRefreshState(item)) { + case 'error': + summary.failed += 1; + break; + case 'pending': + summary.pending += 1; + break; + case 'stalled': + summary.stalled += 1; + break; + case 'unmatched': + summary.unmatched += 1; + break; + default: + break; + } + return summary; + }, { + failed: 0, + pending: 0, + stalled: 0, + unmatched: 0, + }); +} + +function renderLibraryRefreshIndicator(library: MediaLibrary): string { + const progress = libraryRefreshProgress(library); + if (!progress) { + return ''; + } + + const stalePending = Math.max(0, library.metadata_refresh_pending - activeLibraryPendingRefreshCount(library.id)); + const tooltipParts = [`Metadata refresh progress: ${progress.completed}/${progress.total}`]; + if (progress.failed > 0) { + tooltipParts.push(`${progress.failed} failed`); + } + if (stalePending > 0) { + tooltipParts.push(`${stalePending} pending without active worker`); + } + const tooltip = tooltipParts.join(' · '); + return ` + + + + `; +} + +function parsePathsInput(value: FormDataEntryValue | null | undefined): string[] { + return String(value ?? '') + .split(/\r?\n/) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function joinPaths(paths: string[]): string { + return paths.join('\n'); +} + +function activeLibraryId(): number | undefined { + if (state.route.page === 'home') { + return state.route.libraryId; + } + + return state.selectedItem?.library_id; +} + +function activeLibrary(): MediaLibrary | undefined { + return state.libraries.find((library) => library.id === activeLibraryId()); +} + +function persistedLibraryForSettings(library: MediaLibrarySettings): MediaLibrary | undefined { + const configuredPaths = [library.path, ...library.paths] + .map((path) => path.trim()) + .filter(Boolean); + return state.libraries.find((candidate) => { + return configuredPaths.includes(candidate.path) + || candidate.paths.some((path) => configuredPaths.includes(path)); + }); +} + +function selectedLibraryName(): string { + if (state.route.page === 'settings') { + return 'Settings'; + } + + return activeLibrary()?.name ?? (state.route.page === 'item' ? 'Item details' : 'Home'); +} + +function selectedLibraryIcon(kind?: string): AppIconName { + switch (kind) { + case 'mixed': + return 'layout-grid'; + case 'movies': + return 'clapperboard'; + case 'shows': + return 'tv'; + case 'music': + return 'music'; + case 'photos': + return 'image'; + case 'books': + return 'book'; + case 'home_videos': + return 'film'; + default: + return 'layout-grid'; + } +} + +function renderIcon(iconName: AppIconName, className = 'rail-icon'): string { + return ``; +} + +function renderButtonContent(label: string, iconName?: AppIconName, iconPosition: 'start' | 'end' = 'start'): string { + if (!iconName) { + return escapeHtml(label); + } + + return ` + + ${renderIcon(iconName, 'button-icon')} + ${escapeHtml(label)} + + `; +} + +function isRailCollapsed(): boolean { + return state.route.page === 'item'; +} + +function currentUser(): BootstrapUser | undefined { + return state.bootstrap?.current_user; +} + +function requiresSetup(): boolean { + return state.bootstrap?.has_users === false; +} + +function requiresLogin(): boolean { + return state.bootstrap?.has_users === true && !currentUser(); +} + +function canManageUsers(): boolean { + return currentUser()?.admin ?? false; +} + +function renderAuthShell(title: string, description: string, content: string): string { + return ` +
+
+
+
${renderIcon('clapperboard', 'brand-icon')}
+
+

Koko

+

${escapeHtml(description)}

+
+
+
+

${escapeHtml(title)}

+
+ ${state.error ? `
${escapeHtml(state.error)}
` : ''} + ${content} +
+
+ `; +} + +function renderWelcomeScreen(): string { + return renderAuthShell( + 'Create the first admin user', + 'Koko needs one administrator account before the media library can be used.', + ` +
+ + + + +
+ `, + ); +} + +function renderLoginScreen(): string { + return renderAuthShell( + 'Sign in', + 'Sign in with a Koko account to browse media and keep watch progress per user.', + ` +
+ + + +
+ `, + ); +} + +function renderUserManagement(): string { + if (!canManageUsers()) { + return ''; + } + + return ` +
+
+
+

Users

+
+
+ ${state.users.length + ? state.users.map((user) => ` +
+
+ ${escapeHtml(user.username)} +

${user.admin ? 'Administrator' : 'Standard user'}

+
+
+ ${user.admin ? 'Admin' : 'User'} +
+
+ `).join('') + : '
No users found.
'} +
+
+

Add user

+
+ + + + + +
+
+ `; +} + +function humanizeItemType(itemType: string): string { + switch (itemType) { + case 'show': + return 'Show'; + case 'season': + return 'Season'; + case 'episode': + return 'Episode'; + case 'movie': + return 'Movie'; + case 'track': + return 'Track'; + case 'photo': + return 'Photo'; + case 'book': + return 'Book'; + default: + return itemType.replace(/_/g, ' ').replace(/\b\w/g, (character) => character.toUpperCase()); + } +} + +function canManuallyLinkMetadata(item?: MediaItemSummary): boolean { + return item?.item_type === 'movie' || item?.item_type === 'show'; +} + +function backNavigationTarget(): { label: string; path: string } { + const hierarchy = state.selectedItem?.hierarchy ?? []; + const parent = hierarchy[hierarchy.length - 1]; + if (parent) { + return { + label: `Back to ${humanizeItemType(parent.item_type).toLowerCase()}`, + path: `/items/${parent.id}`, + }; + } + + const libraryId = state.selectedItem?.library_id; + return { + label: 'Back to library', + path: typeof libraryId === 'number' ? `/libraries/${libraryId}` : '/', + }; +} + +function formatChildCount(item: MediaItemSummary): string { + if (!item.child_count) { + return formatDuration(item.duration_ms); + } + + if (item.item_type === 'show') { + return `${item.child_count} season${item.child_count === 1 ? '' : 's'}`; + } + + if (item.item_type === 'season') { + return `${item.child_count} episode${item.child_count === 1 ? '' : 's'}`; + } + + return `${item.child_count} item${item.child_count === 1 ? '' : 's'}`; +} + +function libraryStatusLabel(status: string): string { + switch (status) { + case 'never_scanned': + return 'Scanning'; + case 'available': + return 'Ready'; + case 'missing_path': + return 'Missing path'; + case 'not_directory': + return 'Invalid folder'; + case 'unreadable': + return 'Unreadable'; + case 'empty_path': + return 'No folder'; + default: + return status.replace(/_/g, ' '); + } +} + +function topLevelLibraryItems(): MediaItemSummary[] { + return state.libraryItems.filter((item) => item.parent_id == null); +} + +function rootItemById(): Map { + return new Map(topLevelLibraryItems().map((item) => [item.id, item])); +} + +function mediaItemsById(): Map { + return new Map(state.libraryItems.map((item) => [item.id, item])); +} + +function rootAncestorForItem(item: MediaItemSummary, itemsById: Map): MediaItemSummary { + let current = item; + + while (typeof current.parent_id === 'number') { + const parent = itemsById.get(current.parent_id); + if (!parent) { + break; + } + current = parent; + } + + return current; +} + +function categorySummaries(): Array<{ genre: string; count: number; items: MediaItemSummary[] }> { + const itemsById = mediaItemsById(); + const rootsById = rootItemById(); + const categories = new Map>(); + + state.libraryItems.forEach((item) => { + if (!item.genres.length) { + return; + } + + const rootItem = rootAncestorForItem(item, itemsById); + const root = rootsById.get(rootItem.id) ?? rootItem; + item.genres.forEach((genre) => { + const normalizedGenre = genre.trim(); + if (!normalizedGenre) { + return; + } + + if (!categories.has(normalizedGenre)) { + categories.set(normalizedGenre, new Map()); + } + categories.get(normalizedGenre)!.set(root.id, root); + }); + }); + + return [...categories.entries()] + .map(([genre, items]) => ({ genre, count: items.size, items: [...items.values()] })) + .sort((left, right) => right.count - left.count || left.genre.localeCompare(right.genre)); +} + +function collectionSummaries(): MediaCollectionSummary[] { + return state.home?.collections ?? []; +} + +function filteredTopLevelLibraryItems(): MediaItemSummary[] { + const items = topLevelLibraryItems(); + if (!state.browseFilter) { + return items; + } + + const allowedIds = new Set(state.browseFilter.itemIds); + return items.filter((item) => allowedIds.has(item.id)); +} + +function applyBrowseFilter(filter: BrowseFilter): void { + state.browseFilter = filter; + state.homeTab = 'library'; + render(); +} + +function metadataBadgeMarkup(item: MediaItemSummary): string { + if (item.metadata_refresh_state === 'pending') { + return ''; + } + + if (item.has_metadata) { + return ''; + } + + return `${renderIcon('triangle-alert', 'status-icon')}`; +} + +function itemCardSubtitle(item: MediaItemSummary): string | undefined { + if (item.item_type === 'episode' && typeof item.episode_number === 'number') { + return `Episode ${item.episode_number}`; + } + + if (item.item_type === 'season' && typeof item.season_number === 'number') { + return `Season ${item.season_number}`; + } + + return undefined; +} + +function renderItemCard(item: MediaItemSummary): string { + const library = state.libraries.find((entry) => entry.id === item.library_id); + const artworkUrl = getArtworkUrl(item.id, 'poster', item.artwork_updated_at); + const cardSubtitle = itemCardSubtitle(item); + const isSeasonEpisodeCard = state.route.page === 'item' + && state.selectedItem?.item_type === 'season' + && item.item_type === 'episode'; + const secondaryMeta = isSeasonEpisodeCard + ? undefined + : state.route.page === 'home' && typeof state.route.libraryId === 'number' + ? humanizeItemType(item.item_type) + : `${library?.name ?? 'Library'} · ${humanizeItemType(item.item_type)}`; + const badgeMarkup = metadataBadgeMarkup(item); + + return ` + + `; +} + +function renderShelfStack(): string { + if (state.searchQuery.trim()) { + if (!state.searchResults.length) { + return '
No media items matched the current search.
'; + } + + return ` +
+
+

Search results

+ ${state.searchResults.length} matches +
+
${state.searchResults.map(renderItemCard).join('')}
+
+ `; + } + + const shelves = state.home?.shelves ?? []; + if (!shelves.length) { + return '
No shelves are available yet. Add a library to get started.
'; + } + + return shelves + .map((shelf) => ` +
+
+

${escapeHtml(shelf.title)}

+ ${shelf.items.length} items +
+ ${shelf.items.length + ? `
${shelf.items.map(renderItemCard).join('')}
` + : '
Nothing here yet.
'} +
+ `) + .join(''); +} + +function renderHomeTabs(): string { + const tabs: Array<{ id: HomeBrowseTab; label: string }> = [ + { id: 'recommended', label: 'Recommended' }, + { id: 'library', label: 'Library' }, + { id: 'collections', label: 'Collections' }, + { id: 'playlists', label: 'Playlists' }, + { id: 'categories', label: 'Categories' }, + ]; + + return ` + + `; +} + +function renderLibraryOverview(): string { + const library = activeLibrary(); + const refreshProgress = library ? libraryRefreshProgress(library) : undefined; + const stalePending = library ? Math.max(0, library.metadata_refresh_pending - activeLibraryPendingRefreshCount(library.id)) : 0; + + if (!library) { + return ` +
+
+
+ Libraries + ${state.libraries.length} +
+
+ Items + ${topLevelLibraryItems().length} +
+
+ Status + ${state.libraries.some((entry) => entry.status === 'never_scanned') ? 'Scanning' : 'Ready'} +
+
+
+ `; + } + + return ` +
+
+
+

Library overview

+

${escapeHtml(library.name)}

+
+
+ ${refreshProgress + ? `Refreshing metadata ${refreshProgress.completed}/${refreshProgress.total}` + : ''} +
+ ${escapeHtml(libraryStatusLabel(library.status))} + ${library.total_files} file${library.total_files === 1 ? '' : 's'} +
+
+
+
+
+ Top-level items + ${topLevelLibraryItems().length} +
+
+ Video files + ${library.video_files} +
+
+ Folders + ${library.paths.length} +
+
+ Last scanned + ${escapeHtml(formatTimestamp(library.last_scanned_at))} +
+
+ ${library.error ? `

${escapeHtml(library.error)}

` : ''} + ${library.status === 'never_scanned' ? '

Koko is scanning this library in the background. New items will appear automatically.

' : ''} + ${refreshProgress + ? `

Metadata refresh progress: ${refreshProgress.completed}/${refreshProgress.total}${refreshProgress.failed ? ` (${refreshProgress.failed} failed)` : ''}. Artwork and item cards update automatically as each item completes.

` + : ''} + ${stalePending > 0 + ? `

${stalePending} item${stalePending === 1 ? ' is' : 's are'} still marked pending without an active refresh worker. Open Settings → Metadata dashboard to inspect the affected items and errors.

` + : ''} +
+ `; +} + +function renderLibraryTab(): string { + const items = filteredTopLevelLibraryItems(); + const library = activeLibrary(); + const isSpecificLibrary = state.route.page === 'home' && typeof state.route.libraryId === 'number'; + + if (!items.length) { + if (state.browseFilter) { + return `
No items matched the current ${escapeHtml(state.browseFilter.kind)} filter.
`; + } + + if (library?.status === 'never_scanned') { + return '
Koko is scanning this library right now. The show, season, and episode hierarchy will appear when the scan completes.
'; + } + + if (library?.status && library.status !== 'available') { + return `
This library is not ready yet: ${escapeHtml(libraryStatusLabel(library.status))}.
`; + } + + return '
No browseable items are available yet for this library.
'; + } + + return ` +
+
+

${isSpecificLibrary ? 'All items' : 'All libraries'}

+ ${items.length} top-level item${items.length === 1 ? '' : 's'} +
+ ${state.browseFilter ? ` +
+ ${escapeHtml(state.browseFilter.kind === 'category' ? 'Category' : 'Collection')} + ${escapeHtml(state.browseFilter.label)} + +
+ ` : ''} +
${items.map(renderItemCard).join('')}
+
+ `; +} + +function renderCollectionsTab(): string { + const collections = collectionSummaries(); + if (!collections.length) { + return '
No linked collection data is available yet for this library.
'; + } + + return ` +
+ ${collections.map((collection) => ` + + `).join('')} +
+ `; +} + +function renderPlaylistsTab(): string { + return ` +
+
Playlist creation is planned. This tab will eventually let you build reusable watch queues and listening sessions.
+
+ `; +} + +function renderCategoriesTab(): string { + const categories = categorySummaries(); + if (!categories.length) { + return '
No genre metadata is available yet for the current library.
'; + } + + return ` +
+ ${categories.map((category) => ` + + `).join('')} +
+ `; +} + +function renderHomeTabContent(): string { + if (state.searchQuery.trim()) { + return renderShelfStack(); + } + + switch (state.homeTab) { + case 'library': + return renderLibraryTab(); + case 'collections': + return renderCollectionsTab(); + case 'playlists': + return renderPlaylistsTab(); + case 'categories': + return renderCategoriesTab(); + default: + return renderShelfStack(); + } +} + +function renderPageNavbar(eyebrow: string, title: string, description: string, actions = ''): string { + return ` +
+
+

${escapeHtml(eyebrow)}

+

${escapeHtml(title)}

+

${escapeHtml(description)}

+
+ ${actions ? `
${actions}
` : ''} +
+ `; +} + +function renderHomePage(): string { + const activeLibraryName = selectedLibraryName(); + const library = activeLibrary(); + const activeLibraryPaths = library?.paths ?? []; + const libraryRefreshPending = library ? Boolean(libraryRefreshProgress(library)) : false; + + return ` + ${renderPageNavbar( + 'Browse', + activeLibraryName, + activeLibraryPaths.length ? `${activeLibraryPaths.length} folder${activeLibraryPaths.length === 1 ? '' : 's'} connected for this library.` : 'Browse every configured library from one place.', + ` +
+
+ + + +
+ ${library + ? `` + : ''} +
+ `, + )} + ${renderHomeTabs()} + ${renderLibraryOverview()} +
${renderHomeTabContent()}
+ `; +} + +function renderMetadataSearchResults(): string { + const selectedItem = state.selectedItem; + if (!selectedItem) { + return ''; + } + + if (!state.metadataSearchResults.length) { + return '
Run a TMDB search to link rich metadata and artwork.
'; + } + + return state.metadataSearchResults + .map((result) => ` + + `) + .join(''); +} + +function renderLinkedMetadataSummary(): string { + const linkedMatch = state.selectedItemMetadata?.matches[0]; + if (!linkedMatch) { + return '
No external metadata is linked yet.
'; + } + + const metadataRefreshPending = itemIsMetadataPending(state.selectedItem); + const refreshStateLabel = metadataRefreshPending || linkedMatch.refresh_state === 'pending' + ? 'Refreshing' + : linkedMatch.refresh_state === 'error' + ? 'Refresh failed' + : 'Up to date'; + + return ` + + `; +} + +function subtitleLanguage(trackLabel: string): string { + const normalized = trackLabel.trim().toLowerCase(); + if (/^[a-z]{2,3}$/.test(normalized)) { + return normalized; + } + + return 'en'; +} + +function renderMetadataDashboard(): string { + const filteredItems = filteredMetadataDashboardItems(); + const summary = metadataDashboardSummary(state.dashboardItems); + const itemTypes = [...new Set(state.dashboardItems.map((item) => item.item_type))].sort((left, right) => left.localeCompare(right)); + + return ` + + `; +} + +function renderSystemActivitiesPanel(): string { + const activities = state.systemActivities.filter((activity) => activity.state !== 'completed' && activity.state !== 'failed'); + return ` +
+
+
+

Backend activities

+

Active background work that the browser is polling.

+
+ ${activities.length} active +
+ ${activities.length + ? `
${activities.map((activity) => { + const progress = activityProgress(activity); + return ` +
+
+
+ ${escapeHtml(activity.label)} +

${escapeHtml(activity.scope)} · ${escapeHtml(activity.source)}

+
+
+ ${escapeHtml(activity.state)} + ${activity.provider_id ? `${escapeHtml(activity.provider_id)}` : ''} +
+
+
+ + ${progress.completed}/${progress.total}${progress.failed ? ` · ${progress.failed} failed` : ''} +
+
+ `; + }).join('')}
` + : '
No background activities are running right now.
'} +
+ `; +} + +function renderLogViewer(): string { + const logEntries = state.logsResponse?.entries ?? []; + + return ` +
+
+
+

Logs

+

Structured logs from ${escapeHtml(state.logsResponse?.log_path ?? 'the current log file')}.

+
+ +
+
+
+ + +
+
+ + +
+ +
+ + +
+
+ ${logEntries.length + ? `
${logEntries.map((entry) => ` +
+
+
+ ${escapeHtml(entry.level)} + ${escapeHtml(entry.module)} +
+ ${escapeHtml(entry.timestamp)} +
+

${escapeHtml(entry.source_file_path)}${typeof entry.line_number === 'number' ? `:${entry.line_number}` : ''}

+
${escapeHtml(entry.message)}
+
+ `).join('')}
` + : '
No log entries matched the current filters.
'} +
+ `; +} + +function renderItemPage(): string { + if (!state.selectedItem) { + return '
Loading item details…
'; + } + + const posterUrl = state.selectedItem.poster_url + ? getArtworkUrl(state.selectedItem.id, 'poster', state.selectedItem.artwork_updated_at) + : undefined; + const themeSongUrl = state.selectedItem.theme_song_url ? resolveApiUrl(state.selectedItem.theme_song_url) : undefined; + const themeSongYouTubeUrl = state.selectedItem.theme_song_youtube_url + ? buildYouTubeEmbedUrl(state.selectedItem.theme_song_youtube_url, { autoplay: true, controls: false }) + : undefined; + const trailerOptions = currentTrailerOptions(); + const preferredTrailer = trailerOptions[0]; + const hasMultipleTrailers = trailerOptions.length > 1; + const trailerButtonTitle = hasMultipleTrailers + ? 'Click to play the first trailer. Right-click or press and hold to choose another trailer.' + : 'Play Trailer'; + const playback = state.selectedPlayback; + const library = state.libraries.find((entry) => entry.id === state.selectedItem?.library_id); + const linkedMatch = state.selectedItemMetadata?.matches[0]; + const overview = state.selectedItem.overview + ?? linkedMatch?.overview + ?? 'No description is stored for this item yet.'; + const genres = state.selectedItem.genres.length + ? state.selectedItem.genres + : []; + const technicalFacts = [ + { label: 'Duration', value: formatDuration(state.selectedItem.duration_ms) }, + { + label: 'Format', + value: [state.selectedItem.container?.toUpperCase(), state.selectedItem.media_kind.toUpperCase()].filter(Boolean).join(' • ') || 'Unknown', + }, + { + label: 'Codecs', + value: [state.selectedItem.video_codec, state.selectedItem.audio_codec].filter(Boolean).join(' / ') || 'Unknown', + }, + { + label: 'Resolution', + value: state.selectedItem.width && state.selectedItem.height ? `${state.selectedItem.width}×${state.selectedItem.height}` : 'Unknown', + }, + { label: 'Bitrate', value: formatBitRate(state.selectedItem.bit_rate) }, + { label: 'Size', value: formatFileSize(state.selectedItem.file_size) }, + ]; + const hierarchy = state.selectedItem.hierarchy; + const children = state.selectedItem.children; + const backTarget = backNavigationTarget(); + const supportsManualLinking = canManuallyLinkMetadata(state.selectedItem); + const metadataRefreshPending = itemIsMetadataPending(state.selectedItem); + const childSectionTitle = state.selectedItem.item_type === 'show' + ? 'Seasons' + : state.selectedItem.item_type === 'season' + ? 'Episodes' + : 'Contained items'; + const themeSongMarkup = !state.isPlayerOpen && !state.activeTrailer + ? themeSongUrl + ? `` + : themeSongYouTubeUrl + ? ` + + ` + : '' + : ''; + + return ` +
+ ${themeSongMarkup} + ${hierarchy.length ? ` + + ` : ''} +
+
+ ${posterUrl ? `${escapeHtml(state.selectedItem.display_title)} poster` : `${escapeHtml(state.selectedItem.display_title.slice(0, 1).toUpperCase())}`} +
+
+

${escapeHtml(state.selectedItem.display_title)}

+ ${state.selectedItem.tagline ? `

${escapeHtml(state.selectedItem.tagline)}

` : ''} +
+ ${state.selectedItem.release_year ? `${state.selectedItem.release_year}` : ''} + ${genres.map((genre) => `${escapeHtml(genre)}`).join('')} +
+

${escapeHtml(overview)}

+
+ ${state.selectedItem.playable ? `` : ''} + ${preferredTrailer ? `` : ''} + +
+ ${hasMultipleTrailers && state.isTrailerMenuOpen ? ` +
+
+

Choose a trailer

+ +
+
+ ${trailerOptions.map((option, index) => ` + + `).join('')} +
+
+ ` : ''} +

${escapeHtml(playback?.reason ?? 'Loading playback capabilities…')}

+
+ ${technicalFacts.map((fact) => ` +
+ ${escapeHtml(fact.label)} + ${escapeHtml(fact.value)} +
+ `).join('')} +
+
+
+ + ${children.length ? ` +
+
+

${escapeHtml(childSectionTitle)}

+ ${children.length} item${children.length === 1 ? '' : 's'} +
+
${children.map(renderItemCard).join('')}
+
+ ` : ''} + +
+
+
+

File and library

+
+
+
+ Library + ${escapeHtml(library?.name ?? 'Unknown')} +
+
+ Folders + ${escapeHtml(String(library?.paths.length ?? 0))} +
+
+ Source + ${escapeHtml(state.selectedItem.relative_path)} +
+
+ Updated + ${escapeHtml(formatTimestamp(state.selectedItem.modified_at))} +
+
+
+ + +
+
+ `; +} + +function metadataProviderCheckboxes(prefix: string, selectedProviders: string[]): string { + const allProviders = ['tmdb', 'musicbrainz', 'open_library', 'local_nfo']; + return allProviders + .map((providerId) => ` + + `) + .join(''); +} + +function renderExistingLibrariesSettings(settings: SettingsSnapshot): string { + if (!settings.media.libraries.length) { + return '
No libraries are configured yet.
'; + } + + return settings.media.libraries + .map((library, index) => { + const persistedLibrary = persistedLibraryForSettings(library); + const refreshPending = persistedLibrary ? Boolean(libraryRefreshProgress(persistedLibrary)) : false; + const refreshLabel = refreshPending + ? 'Refreshing metadata' + : 'Refresh metadata'; + + return ` +
+
+
+

Library ${index + 1}

+

${escapeHtml(library.name || `Library ${index + 1}`)}

+
+
+ ${persistedLibrary + ? `` + : ''} + +
+
+
+ + +
+ +
+ +
+
+ Metadata sources +
${metadataProviderCheckboxes(`existing_library_metadata_provider_${index}`, library.metadata_providers)}
+
+
+ `; + }) + .join(''); +} + +function libraryKindOptions(selectedKind: string): string { + return [ + ['mixed', 'Mixed'], + ['movies', 'Movies'], + ['shows', 'Shows'], + ['music', 'Music'], + ['photos', 'Photos'], + ['books', 'Books'], + ['home_videos', 'Home videos'], + ] + .map(([value, label]) => ``) + .join(''); +} + +function renderSettingsPage(): string { + const settings = state.settingsResponse?.settings; + if (!settings) { + return '
Settings are still loading…
'; + } + + const tmdb = settings.metadata.providers.find((provider) => provider.id === 'tmdb'); + + return ` + ${renderPageNavbar( + 'Settings', + 'Program configuration', + `Saved to ${state.settingsResponse?.settings_path ?? ''}`, + )} +
+
+
+

Server

+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+

FFmpeg

+
+ +
+
+ + +
+
+ +
+

Metadata providers

+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+

Libraries

+
+

Each logical library can now contain multiple folders. Enter one folder per line.

+
+ ${renderExistingLibrariesSettings(settings)} +
+
+ +
+ + +
+
+ +
+
+

Add library

+ + +
+ + +
+
+ Metadata sources +
${metadataProviderCheckboxes('library_metadata_provider', ['tmdb'])}
+
+
+ +
+ + ${renderUserManagement()} +
+ ${renderMetadataDashboard()} + ${renderSystemActivitiesPanel()} + ${renderLogViewer()} + `; +} + +function renderCurrentPage(): string { + switch (state.route.page) { + case 'item': + return renderItemPage(); + case 'settings': + return renderSettingsPage(); + default: + return renderHomePage(); + } +} + +function renderPlayerOverlay(): string { + if (state.activeTrailer) { + const trailerUrl = buildYouTubeEmbedUrl(state.activeTrailer.url, { autoplay: true, controls: true }) + ?? state.activeTrailer.url; + return ` +
+
+
+
+

Trailer

+

${escapeHtml(state.activeTrailer.title)}

+
+ +
+
+ +
+
+
+ `; + } + + if (!state.isPlayerOpen || !state.selectedItem || !state.selectedPlayback?.can_direct_play) { + return ''; + } + + const tag = state.selectedItem.media_kind === 'audio' ? 'audio' : 'video'; + const source = getStreamUrl(state.selectedItem.id); + const trackMarkup = tag === 'video' + ? state.selectedItem.subtitle_tracks + .map((track) => ``) + .join('') + : ''; + + return ` +
+
+
+
+

Now playing

+

${escapeHtml(state.selectedItem.display_title)}

+
+ +
+ ${tag === 'audio' + ? `` + : ``} +
+
+ `; +} + +function renderRail(): string { + const activeLibraryIdValue = activeLibraryId(); + const collapsed = isRailCollapsed(); + + return ` + + `; +} + +function render(preserveScroll = true): void { + const previousScrollTop = preserveScroll + ? document.querySelector('.main-shell')?.scrollTop ?? 0 + : 0; + + if (!state.bootstrap && state.isLoading) { + appRoot.innerHTML = renderAuthShell('Loading Koko', 'Checking server state and account access.', ''); + createIcons({ icons }); + return; + } + + if (requiresSetup()) { + appRoot.innerHTML = renderWelcomeScreen(); + createIcons({ icons }); + bindEvents(); + return; + } + + if (requiresLogin()) { + appRoot.innerHTML = renderLoginScreen(); + createIcons({ icons }); + bindEvents(); + return; + } + + const pageBackdropUrl = state.route.page === 'item' && state.selectedItem + && (state.selectedItem.backdrop_url || state.selectedItemMetadata?.matches.some((match) => Boolean(match.backdrop_url || match.cached_backdrop_path))) + ? getArtworkUrl(state.selectedItem.id, 'backdrop', state.selectedItem.artwork_updated_at) + : undefined; + const railCollapsed = isRailCollapsed(); + + appRoot.innerHTML = ` +
+ ${pageBackdropUrl ? `
` : ''} + ${renderRail()} +
+
+ ${state.error ? `
${escapeHtml(state.error)}
` : ''} + ${renderCurrentPage()} +
+
+ ${renderPlayerOverlay()} +
+ `; + + createIcons({ icons }); + bindEvents(); + bindThemeSongPlayer(); + if (preserveScroll) { + window.requestAnimationFrame(() => { + const shell = document.querySelector('.main-shell'); + if (shell) { + shell.scrollTop = previousScrollTop; + } + }); + } +} + +async function refreshData(): Promise { + state.route = parseRoute(); + state.isLoading = true; + state.error = undefined; + state.apiMode = getApiMode(); + render(false); + + try { + state.bootstrap = await getAppBootstrap().catch(async (error) => { + if (!getStoredAuthToken()) { + return Promise.reject(error); + } + + clearStoredAuthToken(); + return getAppBootstrap(); + }); + + if (requiresSetup() || requiresLogin()) { + clearPendingLibraryRefresh(); + clearPendingMetadataRefresh(); + state.capabilities = undefined; + state.libraries = []; + state.home = undefined; + state.libraryItems = []; + state.searchResults = []; + state.metadataProviders = []; + state.systemActivities = []; + state.dashboardItems = []; + state.settingsResponse = undefined; + state.logsResponse = undefined; + state.selectedItem = undefined; + state.selectedItemMetadata = undefined; + state.selectedPlayback = undefined; + state.metadataSearchResults = []; + state.users = []; + state.hasDeferredAutoRefreshRender = false; + return; + } + + const [capabilities, libraries, metadataProviders, settingsResponse, systemActivitiesResponse] = await Promise.all([ + getCapabilities(), + getLibraries(), + getMetadataProviders(), + getSettings(), + getSystemActivities(), + ]); + + state.capabilities = capabilities; + state.libraries = libraries; + state.metadataProviders = metadataProviders; + state.settingsResponse = settingsResponse; + state.systemActivities = systemActivitiesResponse.activities; + state.users = canManageUsers() ? await getUsers() : []; + + if (state.route.page === 'home') { + const [home, libraryItems, searchResults] = await Promise.all([ + getHome(state.route.libraryId), + getItems(state.route.libraryId), + state.searchQuery.trim() + ? searchItems(state.searchQuery, state.route.libraryId) + : Promise.resolve([]), + ]); + state.home = home; + state.libraryItems = libraryItems; + state.searchResults = searchResults; + state.selectedItem = undefined; + state.selectedItemMetadata = undefined; + state.selectedPlayback = undefined; + state.metadataSearchResults = []; + state.metadataSearchQuery = ''; + state.isPlayerOpen = false; + state.isTrailerMenuOpen = false; + state.activeTrailer = undefined; + state.hasDeferredAutoRefreshRender = false; + state.dashboardItems = []; + state.logsResponse = undefined; + } else if (state.route.page === 'item') { + state.home = undefined; + state.libraryItems = []; + state.searchResults = []; + state.metadataSearchResults = []; + state.metadataSearchQuery = ''; + state.isTrailerMenuOpen = false; + state.activeTrailer = undefined; + state.dashboardItems = []; + state.logsResponse = undefined; + const [item, metadata, playback] = await Promise.all([ + getItem(state.route.itemId), + getItemMetadata(state.route.itemId), + getPlaybackDecision(state.route.itemId), + ]); + state.selectedItem = item; + state.selectedItemMetadata = metadata; + state.selectedPlayback = playback; + } else { + state.home = undefined; + state.libraryItems = []; + state.searchResults = []; + state.selectedItem = undefined; + state.selectedItemMetadata = undefined; + state.selectedPlayback = undefined; + state.metadataSearchResults = []; + state.metadataSearchQuery = ''; + state.isPlayerOpen = false; + state.isTrailerMenuOpen = false; + state.activeTrailer = undefined; + state.hasDeferredAutoRefreshRender = false; + const [logsResponse, dashboardItems] = await Promise.all([ + getLogs(currentLogFilterRequest()), + getItems(), + ]); + state.logsResponse = logsResponse; + state.dashboardItems = dashboardItems; + } + + state.apiMode = getApiMode(); + } catch (error) { + state.error = error instanceof Error ? error.message : 'Failed to load server data.'; + state.apiMode = getApiMode(); + } finally { + state.isLoading = false; + schedulePendingLibraryRefresh(); + schedulePendingMetadataRefresh(); + render(false); + } +} + +async function refreshPendingMetadataData(): Promise { + const route = parseRoute(); + let shouldRender = false; + const previousError = state.error; + + try { + if (route.page === 'item') { + const itemId = route.itemId; + const previousSnapshot = snapshotJson({ + systemActivities: state.systemActivities, + libraries: state.libraries, + selectedItem: state.selectedItem, + selectedItemMetadata: state.selectedItemMetadata, + }); + const [activitiesResponse, libraries, item, metadata] = await Promise.all([ + getSystemActivities(), + getLibraries(), + getItem(itemId), + getItemMetadata(itemId), + ]); + if (state.route.page !== 'item' || state.route.itemId !== itemId) { + return; + } + + state.systemActivities = activitiesResponse.activities; + state.libraries = libraries; + state.selectedItem = item; + state.selectedItemMetadata = metadata; + state.error = undefined; + shouldRender = previousSnapshot !== snapshotJson({ + systemActivities: state.systemActivities, + libraries: state.libraries, + selectedItem: state.selectedItem, + selectedItemMetadata: state.selectedItemMetadata, + }) || previousError !== state.error; + } else if (route.page === 'home') { + const libraryId = route.libraryId; + const searchQuery = state.searchQuery.trim(); + const previousSnapshot = snapshotJson({ + systemActivities: state.systemActivities, + libraries: state.libraries, + home: state.home, + libraryItems: state.libraryItems, + searchResults: state.searchResults, + }); + const [activitiesResponse, libraries, home, libraryItems, searchResults] = await Promise.all([ + getSystemActivities(), + getLibraries(), + getHome(libraryId), + getItems(libraryId), + searchQuery + ? searchItems(searchQuery, libraryId) + : Promise.resolve([]), + ]); + if (state.route.page !== 'home' || state.route.libraryId !== libraryId) { + return; + } + + state.systemActivities = activitiesResponse.activities; + state.libraries = libraries; + state.home = home; + state.libraryItems = libraryItems; + state.searchResults = searchResults; + state.error = undefined; + shouldRender = previousSnapshot !== snapshotJson({ + systemActivities: state.systemActivities, + libraries: state.libraries, + home: state.home, + libraryItems: state.libraryItems, + searchResults: state.searchResults, + }) || previousError !== state.error; + } else { + const previousSnapshot = snapshotJson({ + systemActivities: state.systemActivities, + libraries: state.libraries, + logsResponse: state.logsResponse, + dashboardItems: state.dashboardItems, + }); + const [activitiesResponse, libraries, logsResponse, dashboardItems] = await Promise.all([ + getSystemActivities(), + getLibraries(), + getLogs(currentLogFilterRequest()), + getItems(), + ]); + state.systemActivities = activitiesResponse.activities; + state.libraries = libraries; + state.logsResponse = logsResponse; + state.dashboardItems = dashboardItems; + state.error = undefined; + shouldRender = previousSnapshot !== snapshotJson({ + systemActivities: state.systemActivities, + libraries: state.libraries, + logsResponse: state.logsResponse, + dashboardItems: state.dashboardItems, + }) || previousError !== state.error; + } + } catch (error) { + state.error = error instanceof Error ? error.message : 'Failed to refresh media metadata.'; + shouldRender = previousError !== state.error; + } finally { + schedulePendingMetadataRefresh(); + maybeRenderAfterAutoRefresh(shouldRender); + } +} + +function buildSettingsFromForm(formData: FormData): SettingsSnapshot | undefined { + const current = state.settingsResponse?.settings; + if (!current) { + return undefined; + } + + return { + general: { + data_dir: String(formData.get('data_dir') ?? current.general.data_dir), + }, + media: { + libraries: current.media.libraries.map((library, index) => { + const paths = parsePathsInput(formData.get(`existing_library_paths_${index}`)); + return { + name: String(formData.get(`existing_library_name_${index}`) ?? library.name), + path: paths[0] ?? library.path, + paths, + recursive: formData.get(`existing_library_recursive_${index}`) === 'on', + kind: String(formData.get(`existing_library_kind_${index}`) ?? library.kind), + metadata_providers: formData + .getAll(`existing_library_metadata_provider_${index}`) + .map((value) => String(value)), + }; + }), + }, + metadata: { + providers: current.metadata.providers.map((provider) => { + if (provider.id !== 'tmdb') { + return provider; + } + + return { + ...provider, + enabled: formData.get('tmdb_enabled') === 'on', + api_key: String(formData.get('tmdb_api_key') ?? ''), + language: String(formData.get('tmdb_language') ?? 'en-US'), + rate_limit_per_second: Math.max(1, Number(formData.get('tmdb_rate_limit_per_second') ?? provider.rate_limit_per_second)), + retry_attempts: Math.max(0, Number(formData.get('tmdb_retry_attempts') ?? provider.retry_attempts)), + retry_backoff_ms: Math.max(1, Number(formData.get('tmdb_retry_backoff_ms') ?? provider.retry_backoff_ms)), + }; + }), + }, + server: { + use_https: formData.get('use_https') === 'on', + address: String(formData.get('address') ?? current.server.address), + port: Number(formData.get('port') ?? current.server.port), + cert_path: String(formData.get('cert_path') ?? current.server.cert_path), + key_path: String(formData.get('key_path') ?? current.server.key_path), + use_custom_certs: formData.get('use_custom_certs') === 'on', + }, + ffmpeg: { + strategy: String(formData.get('ffmpeg_strategy') ?? current.ffmpeg.strategy), + ffmpeg_path: String(formData.get('ffmpeg_path') ?? current.ffmpeg.ffmpeg_path), + ffprobe_path: String(formData.get('ffprobe_path') ?? current.ffmpeg.ffprobe_path), + }, + }; +} + +function bindThemeSongPlayer(): void { + const themePlayer = document.querySelector('#theme-song-player'); + if (!themePlayer) { + return; + } + + themePlayer.volume = 0.45; + themePlayer.loop = false; + themePlayer.addEventListener('ended', () => { + if (state.hasDeferredAutoRefreshRender) { + state.hasDeferredAutoRefreshRender = false; + render(); + } + }, { once: true }); + void themePlayer.play().catch(() => { + // Autoplay can be blocked by the browser, so the page quietly falls back without looping. + }); +} + +async function refreshLogsView(): Promise { + if (state.route.page !== 'settings') { + return; + } + + try { + state.logsResponse = await getLogs(currentLogFilterRequest()); + state.error = undefined; + } catch (error) { + state.error = error instanceof Error ? error.message : 'Failed to load logs.'; + } finally { + render(); + } +} + +function bindPlayerProgress(): void { + const player = document.querySelector('#media-player'); + if (!player || !state.selectedItem) { + return; + } + + let lastSentSeconds = -1; + player.addEventListener('timeupdate', () => { + const currentSeconds = Math.floor(player.currentTime); + if (currentSeconds === lastSentSeconds || currentSeconds % 15 !== 0) { + return; + } + + lastSentSeconds = currentSeconds; + void updatePlaybackProgress(state.selectedItem!.id, { + position_ms: Math.floor(player.currentTime * 1000), + duration_ms: Number.isFinite(player.duration) ? Math.floor(player.duration * 1000) : state.selectedItem?.duration_ms, + completed: false, + }); + }); + + player.addEventListener('ended', () => { + void updatePlaybackProgress(state.selectedItem!.id, { + position_ms: Math.floor((Number.isFinite(player.duration) ? player.duration : 0) * 1000), + duration_ms: Number.isFinite(player.duration) ? Math.floor(player.duration * 1000) : state.selectedItem?.duration_ms, + completed: true, + }); + }); +} + +function bindEvents(): void { + document.querySelector('#welcome-user-form')?.addEventListener('submit', async (event) => { + event.preventDefault(); + const form = event.currentTarget as HTMLFormElement | null; + if (!form) { + return; + } + + const formData = new FormData(form); + const request: CreateUserRequest = { + username: String(formData.get('username') ?? '').trim(), + password: String(formData.get('password') ?? ''), + pin: String(formData.get('pin') ?? '').trim() || undefined, + admin: true, + }; + + try { + await createUser(request); + const token = await loginUser({ username: request.username, password: request.password }); + setStoredAuthToken(token.token); + await refreshData(); + } catch (error) { + state.error = error instanceof Error ? error.message : 'Failed to create the first user.'; + render(); + } + }); + + document.querySelector('#login-form')?.addEventListener('submit', async (event) => { + event.preventDefault(); + const form = event.currentTarget as HTMLFormElement | null; + if (!form) { + return; + } + + const formData = new FormData(form); + const request: LoginRequest = { + username: String(formData.get('username') ?? '').trim(), + password: String(formData.get('password') ?? ''), + }; + + try { + const token = await loginUser(request); + setStoredAuthToken(token.token); + await refreshData(); + } catch (error) { + clearStoredAuthToken(); + state.error = error instanceof Error ? error.message : 'Failed to sign in.'; + render(); + } + }); + + document.querySelector('[data-sign-out]')?.addEventListener('click', () => { + clearStoredAuthToken(); + state.bootstrap = state.bootstrap ? { ...state.bootstrap, current_user: undefined } : undefined; + void refreshData(); + }); + + document.querySelector('[data-nav-home]')?.addEventListener('click', () => { + navigateTo('/'); + }); + + document.querySelectorAll('[data-nav-library-id]').forEach((button) => { + button.addEventListener('click', () => { + const libraryId = Number(button.dataset.navLibraryId); + if (!Number.isFinite(libraryId)) { + return; + } + + navigateTo(`/libraries/${libraryId}`); + }); + }); + + document.querySelector('[data-nav-settings]')?.addEventListener('click', () => { + navigateTo('/settings'); + }); + + document.querySelector('#search-form')?.addEventListener('submit', (event) => { + event.preventDefault(); + const input = document.querySelector('#search-input'); + state.searchQuery = input?.value.trim() ?? ''; + void refreshData(); + }); + + document.querySelector('#reset-search')?.addEventListener('click', () => { + state.searchQuery = ''; + void refreshData(); + }); + + document.querySelector('#refresh-active-library-metadata')?.addEventListener('click', async () => { + const library = activeLibrary(); + if (!library || libraryRefreshProgress(library)) { + return; + } + + try { + const refreshedLibrary = await refreshLibraryMetadata(library.id); + state.libraries = state.libraries.map((entry) => entry.id === refreshedLibrary.id ? refreshedLibrary : entry); + await refreshPendingMetadataData(); + schedulePendingMetadataRefresh(); + } catch (error) { + state.error = error instanceof Error ? error.message : 'Failed to refresh library metadata.'; + render(); + } + }); + + document.querySelectorAll('[data-home-tab]').forEach((button) => { + button.addEventListener('click', () => { + const nextTab = button.dataset.homeTab as HomeBrowseTab | undefined; + if (!nextTab || state.homeTab === nextTab) { + return; + } + + state.homeTab = nextTab; + render(); + }); + }); + + document.querySelectorAll('[data-category-filter]').forEach((button) => { + button.addEventListener('click', () => { + const genre = button.dataset.categoryFilter; + if (!genre) { + return; + } + + const category = categorySummaries().find((entry) => entry.genre === genre); + if (!category) { + return; + } + + applyBrowseFilter({ + kind: 'category', + label: category.genre, + itemIds: category.items.map((item) => item.id), + }); + }); + }); + + document.querySelectorAll('[data-collection-filter]').forEach((button) => { + button.addEventListener('click', () => { + const collectionId = button.dataset.collectionFilter; + if (!collectionId) { + return; + } + + const collection = collectionSummaries().find((entry) => entry.id === collectionId); + if (!collection) { + return; + } + + applyBrowseFilter({ + kind: 'collection', + label: collection.name, + itemIds: collection.item_ids, + }); + }); + }); + + document.querySelector('#clear-browse-filter')?.addEventListener('click', () => { + state.browseFilter = undefined; + render(); + }); + + document.querySelectorAll('[data-item-id]').forEach((button) => { + button.addEventListener('click', () => { + const itemId = Number(button.dataset.itemId); + if (!Number.isFinite(itemId)) { + return; + } + + navigateTo(`/items/${itemId}`); + }); + }); + + document.querySelector('#back-to-library')?.addEventListener('click', () => { + navigateTo(backNavigationTarget().path); + }); + + document.querySelector('#metadata-search-form')?.addEventListener('submit', async (event) => { + event.preventDefault(); + if (!state.selectedItem) { + return; + } + + const input = document.querySelector('#metadata-search-input'); + state.metadataSearchQuery = input?.value.trim() ?? ''; + try { + state.metadataSearchResults = await searchItemMetadata(state.selectedItem.id, state.metadataSearchQuery); + render(); + } catch (error) { + state.error = error instanceof Error ? error.message : 'Failed to search metadata.'; + render(); + } + }); + + document.querySelectorAll('[data-link-metadata]').forEach((button) => { + button.addEventListener('click', async () => { + const encoded = button.dataset.linkMetadata; + if (!encoded || !state.selectedItem) { + return; + } + + const [itemId, providerId, externalId, mediaType] = encoded.split(':'); + try { + await linkItemMetadata(Number(itemId), { + provider_id: providerId, + external_id: externalId, + media_type: mediaType, + }); + state.selectedItemMetadata = await getItemMetadata(state.selectedItem.id); + state.metadataSearchResults = []; + await refreshPendingMetadataData(); + } catch (error) { + state.error = error instanceof Error ? error.message : 'Failed to link metadata.'; + render(); + } + }); + }); + + document.querySelector('#refresh-item-metadata')?.addEventListener('click', async () => { + if (!state.selectedItem) { + return; + } + + try { + await refreshItemMetadata(state.selectedItem.id); + await refreshPendingMetadataData(); + schedulePendingMetadataRefresh(); + } catch (error) { + state.error = error instanceof Error ? error.message : 'Failed to refresh item metadata.'; + render(); + } + }); + + const trailerButton = document.querySelector('#play-item-trailer'); + if (trailerButton) { + let trailerHoldHandle: number | undefined; + let suppressNextTrailerClick = false; + + const clearTrailerHoldHandle = (): void => { + if (trailerHoldHandle !== undefined) { + window.clearTimeout(trailerHoldHandle); + trailerHoldHandle = undefined; + } + }; + + const openTrailerChooser = (): void => { + if (currentTrailerOptions().length <= 1) { + return; + } + + suppressNextTrailerClick = true; + state.isTrailerMenuOpen = true; + render(); + }; + + trailerButton.addEventListener('click', () => { + if (suppressNextTrailerClick) { + suppressNextTrailerClick = false; + return; + } + + openTrailer(currentTrailerOptions()[0]); + }); + trailerButton.addEventListener('contextmenu', (event) => { + if (currentTrailerOptions().length <= 1) { + return; + } + + event.preventDefault(); + clearTrailerHoldHandle(); + openTrailerChooser(); + }); + trailerButton.addEventListener('mousedown', () => { + clearTrailerHoldHandle(); + if (currentTrailerOptions().length <= 1) { + return; + } + + trailerHoldHandle = window.setTimeout(() => { + trailerHoldHandle = undefined; + openTrailerChooser(); + }, 450); + }); + trailerButton.addEventListener('mouseup', clearTrailerHoldHandle); + trailerButton.addEventListener('mouseleave', clearTrailerHoldHandle); + trailerButton.addEventListener('touchstart', () => { + clearTrailerHoldHandle(); + if (currentTrailerOptions().length <= 1) { + return; + } + + trailerHoldHandle = window.setTimeout(() => { + trailerHoldHandle = undefined; + openTrailerChooser(); + }, 500); + }, { passive: true }); + trailerButton.addEventListener('touchend', clearTrailerHoldHandle); + trailerButton.addEventListener('touchcancel', clearTrailerHoldHandle); + } + + document.querySelector('#close-trailer-picker')?.addEventListener('click', () => { + state.isTrailerMenuOpen = false; + render(); + }); + + document.querySelectorAll('[data-play-trailer-index]').forEach((button) => { + button.addEventListener('click', () => { + const trailerIndex = Number(button.dataset.playTrailerIndex); + if (!Number.isFinite(trailerIndex)) { + return; + } + + openTrailer(currentTrailerOptions()[trailerIndex]); + }); + }); + + document.querySelector('#close-trailer')?.addEventListener('click', () => { + state.activeTrailer = undefined; + render(); + }); + + document.querySelector('#play-selected-item')?.addEventListener('click', () => { + if (!state.selectedPlayback?.can_direct_play) { + return; + } + + state.isPlayerOpen = true; + render(); + }); + + document.querySelector('#close-player')?.addEventListener('click', () => { + state.isPlayerOpen = false; + render(); + }); + + document.querySelector('#settings-form')?.addEventListener('submit', async (event) => { + event.preventDefault(); + const form = event.currentTarget as HTMLFormElement | null; + if (!form) { + return; + } + + const nextSettings = buildSettingsFromForm(new FormData(form)); + if (!nextSettings) { + return; + } + + try { + state.settingsResponse = await updateSettings(nextSettings); + await refreshData(); + } catch (error) { + state.error = error instanceof Error ? error.message : 'Failed to save settings.'; + render(); + } + }); + + document.querySelector('#go-home-from-settings')?.addEventListener('click', () => { + navigateTo('/'); + }); + + document.querySelector('#metadata-dashboard-filter-form')?.addEventListener('submit', (event) => { + event.preventDefault(); + const form = event.currentTarget as HTMLFormElement | null; + if (!form) { + return; + } + + const formData = new FormData(form); + state.metadataDashboardFilters = { + libraryId: String(formData.get('dashboard_library_id') ?? '').trim(), + itemType: String(formData.get('dashboard_item_type') ?? '').trim(), + refreshState: String(formData.get('dashboard_refresh_state') ?? '').trim(), + search: String(formData.get('dashboard_search') ?? '').trim(), + }; + render(); + }); + + document.querySelector('#clear-metadata-dashboard-filters')?.addEventListener('click', () => { + state.metadataDashboardFilters = { + libraryId: '', + itemType: '', + refreshState: '', + search: '', + }; + render(); + }); + + document.querySelector('#log-filter-form')?.addEventListener('submit', async (event) => { + event.preventDefault(); + const form = event.currentTarget as HTMLFormElement | null; + if (!form) { + return; + } + + const formData = new FormData(form); + state.logFilters = { + level: String(formData.get('log_level') ?? '').trim().toUpperCase(), + module: String(formData.get('log_module') ?? '').trim(), + search: String(formData.get('log_search') ?? '').trim(), + since: String(formData.get('log_since') ?? '').trim(), + until: String(formData.get('log_until') ?? '').trim(), + }; + await refreshLogsView(); + }); + + document.querySelector('#clear-log-filters')?.addEventListener('click', async () => { + state.logFilters = { + level: '', + module: '', + search: '', + since: '', + until: '', + }; + await refreshLogsView(); + }); + + document.querySelector('#refresh-log-viewer')?.addEventListener('click', async () => { + await refreshLogsView(); + }); + + document.querySelector('#create-user-form')?.addEventListener('submit', async (event) => { + event.preventDefault(); + const form = event.currentTarget as HTMLFormElement | null; + if (!form) { + return; + } + + const formData = new FormData(form); + const request: CreateUserRequest = { + username: String(formData.get('username') ?? '').trim(), + password: String(formData.get('password') ?? ''), + pin: String(formData.get('pin') ?? '').trim() || undefined, + admin: formData.get('admin') === 'on', + }; + + try { + await createUser(request); + form.reset(); + state.users = await getUsers(); + render(); + } catch (error) { + state.error = error instanceof Error ? error.message : 'Failed to create the user.'; + render(); + } + }); + + document.querySelectorAll('[data-remove-library-index]').forEach((button) => { + button.addEventListener('click', async () => { + const libraryIndex = Number(button.dataset.removeLibraryIndex); + if (!Number.isFinite(libraryIndex)) { + return; + } + + const confirmed = window.confirm('Remove this library from settings? This only removes the configuration, not the media files on disk.'); + if (!confirmed) { + return; + } + + try { + state.settingsResponse = await deleteLibrary(libraryIndex); + await refreshData(); + } catch (error) { + state.error = error instanceof Error ? error.message : 'Failed to remove library.'; + render(); + } + }); + }); + + document.querySelectorAll('[data-refresh-library-id]').forEach((button) => { + button.addEventListener('click', async () => { + const libraryId = Number(button.dataset.refreshLibraryId); + if (!Number.isFinite(libraryId)) { + return; + } + + try { + const library = await refreshLibraryMetadata(libraryId); + state.libraries = state.libraries.map((entry) => entry.id === library.id ? library : entry); + await refreshPendingMetadataData(); + schedulePendingMetadataRefresh(); + } catch (error) { + state.error = error instanceof Error ? error.message : 'Failed to refresh library metadata.'; + render(); + } + }); + }); + + document.querySelector('#add-library-form')?.addEventListener('submit', async (event) => { + event.preventDefault(); + const form = event.currentTarget as HTMLFormElement | null; + if (!form) { + return; + } + + const formData = new FormData(form); + const paths = parsePathsInput(formData.get('library_paths')); + const library: MediaLibrarySettings = { + name: String(formData.get('library_name') ?? ''), + path: paths[0] ?? '', + paths, + recursive: formData.get('library_recursive') === 'on', + kind: String(formData.get('library_kind') ?? 'mixed'), + metadata_providers: formData.getAll('library_metadata_provider').map((value) => String(value)), + }; + + try { + state.settingsResponse = await addLibrary(library); + form.reset(); + await refreshData(); + } catch (error) { + state.error = error instanceof Error ? error.message : 'Failed to add library.'; + render(); + } + }); + + bindPlayerProgress(); +} + +window.addEventListener('popstate', () => { + state.route = parseRoute(); + if (state.route.page === 'home') { + state.homeTab = defaultHomeTab(state.route); + state.browseFilter = undefined; + } + state.isTrailerMenuOpen = false; + void refreshData(); +}); + +render(); +void refreshData(); + diff --git a/crates/client-web/src/mockApi.ts b/crates/client-web/src/mockApi.ts new file mode 100644 index 00000000..3cb52c90 --- /dev/null +++ b/crates/client-web/src/mockApi.ts @@ -0,0 +1,1051 @@ +import type { + AppBootstrapResponse, + BootstrapUser, + CreateUserRequest, + ItemMetadataMatch, + ItemMetadataResponse, + LoginRequest, + LinkMetadataRequest, + MediaCollectionSummary, + MediaHome, + MediaItemDetail, + MediaItemSummary, + MediaLibrary, + MediaLibrarySettings, + MetadataProviderStatus, + MetadataSearchResult, + LogEntriesResponse, + PlaybackDecision, + PlaybackProgressRequest, + ServerCapabilities, + SettingsResponse, + SettingsSnapshot, + SystemActivity, + SystemActivitiesResponse, + TokenResponse, +} from './api'; + +let nextLibraryId = 4; +let nextUserId = 2; +const AUTH_TOKEN_STORAGE_KEY = 'koko-client-web-auth-token'; + +interface MockUserRecord extends BootstrapUser { + password: string; + pin?: string; +} + +const libraries: MediaLibrary[] = [ + { + id: 1, + name: 'Movies', + path: 'C:/Media/Movies', + paths: ['C:/Media/Movies', 'D:/Overflow/Movies'], + recursive: true, + kind: 'movies', + status: 'available', + scan_revision: 6, + last_scanned_at: 1760923200, + total_files: 2, + video_files: 2, + audio_files: 0, + image_files: 0, + book_files: 0, + other_files: 0, + metadata_refresh_total: 0, + metadata_refresh_pending: 0, + metadata_refresh_completed: 0, + metadata_refresh_failed: 0, + }, + { + id: 2, + name: 'Shows', + path: 'C:/Media/Shows', + paths: ['C:/Media/Shows'], + recursive: true, + kind: 'shows', + status: 'available', + scan_revision: 5, + last_scanned_at: 1760923150, + total_files: 1, + video_files: 1, + audio_files: 0, + image_files: 0, + book_files: 0, + other_files: 0, + metadata_refresh_total: 0, + metadata_refresh_pending: 0, + metadata_refresh_completed: 0, + metadata_refresh_failed: 0, + }, + { + id: 3, + name: 'Music', + path: 'C:/Media/Music', + paths: ['C:/Media/Music'], + recursive: true, + kind: 'music', + status: 'available', + scan_revision: 4, + last_scanned_at: 1760923100, + total_files: 2, + video_files: 0, + audio_files: 2, + image_files: 0, + book_files: 0, + other_files: 0, + metadata_refresh_total: 0, + metadata_refresh_pending: 0, + metadata_refresh_completed: 0, + metadata_refresh_failed: 0, + }, +]; + +const items: MediaItemDetail[] = [ + { + id: 101, + library_id: 1, + item_type: 'movie', + display_title: 'Mock Movie', + relative_path: 'Action/mock-movie.mp4', + media_kind: 'video', + playable: true, + child_count: 0, + duration_ms: 5_400_000, + width: 1920, + height: 1080, + modified_at: 1760923200, + file_size: 1_610_612_736, + container: 'mp4', + bit_rate: 2_400_000, + video_codec: 'h264', + audio_codec: 'aac', + metadata_json: JSON.stringify({ format: { format_name: 'mp4', duration: '5400.0' } }, null, 2), + metadata_updated_at: 1760923200, + poster_url: '/api/v1/items/101/artwork?kind=poster', + backdrop_url: '/api/v1/items/101/artwork?kind=backdrop', + theme_song_url: '/api/v1/items/101/theme', + theme_song_youtube_url: 'https://www.youtube.com/watch?v=SLBACEP6LsI', + tagline: 'Welcome to the real world.', + overview: 'A computer hacker learns the true nature of reality and his role in the war against its controllers.', + genres: ['Action', 'Science Fiction'], + release_year: 1999, + linked_media_type: 'movie', + has_metadata: true, + metadata_refresh_state: 'fresh', + artwork_updated_at: 1760923200, + trailer_title: 'Official Trailer', + trailer_url: 'https://www.youtube.com/embed/vKQi3bBA1y8?autoplay=1&rel=0', + subtitle_tracks: [ + { + index: 0, + label: 'EN', + format: 'SRT', + url: '/api/v1/items/101/subtitles/0', + }, + ], + hierarchy: [], + children: [], + }, + { + id: 201, + library_id: 2, + item_type: 'show', + display_title: 'Mock Show', + relative_path: 'Mock Show', + media_kind: 'video', + playable: false, + child_count: 1, + duration_ms: 2_700_000, + modified_at: 1760923150, + genres: ['Drama', 'Fantasy'], + has_metadata: true, + metadata_refresh_state: 'fresh', + theme_song_youtube_url: 'https://www.youtube.com/watch?v=uXZd_W5B7N0', + subtitle_tracks: [], + hierarchy: [], + children: [ + { + id: 202, + library_id: 2, + parent_id: 201, + item_type: 'season', + display_title: 'Season 1', + relative_path: 'Mock Show/Season 1', + media_kind: 'video', + playable: false, + child_count: 1, + season_number: 1, + duration_ms: 2_700_000, + genres: ['Drama', 'Fantasy'], + modified_at: 1760923150, + }, + ], + }, + { + id: 202, + library_id: 2, + parent_id: 201, + item_type: 'season', + display_title: 'Season 1', + relative_path: 'Mock Show/Season 1', + media_kind: 'video', + playable: false, + child_count: 1, + season_number: 1, + duration_ms: 2_700_000, + modified_at: 1760923150, + genres: ['Drama', 'Fantasy'], + has_metadata: true, + metadata_refresh_state: 'fresh', + theme_song_youtube_url: 'https://www.youtube.com/watch?v=uXZd_W5B7N0', + subtitle_tracks: [], + hierarchy: [ + { + id: 201, + library_id: 2, + item_type: 'show', + display_title: 'Mock Show', + relative_path: 'Mock Show', + media_kind: 'video', + playable: false, + child_count: 1, + duration_ms: 2_700_000, + genres: ['Drama', 'Fantasy'], + modified_at: 1760923150, + }, + ], + children: [ + { + id: 203, + library_id: 2, + parent_id: 202, + item_type: 'episode', + display_title: 'Mock Episode', + relative_path: 'Mock Show/Season 1/episode-01.mp4', + media_kind: 'video', + playable: true, + child_count: 0, + season_number: 1, + episode_number: 1, + duration_ms: 2_700_000, + width: 1280, + height: 720, + genres: ['Drama', 'Fantasy'], + modified_at: 1760923100, + }, + ], + }, + { + id: 203, + library_id: 2, + parent_id: 202, + item_type: 'episode', + display_title: 'Mock Episode', + relative_path: 'Mock Show/Season 1/episode-01.mp4', + media_kind: 'video', + playable: true, + child_count: 0, + season_number: 1, + episode_number: 1, + duration_ms: 2_700_000, + width: 1280, + height: 720, + modified_at: 1760923100, + file_size: 810_612_736, + container: 'mp4', + bit_rate: 1_800_000, + video_codec: 'h264', + audio_codec: 'aac', + metadata_json: JSON.stringify({ format: { format_name: 'mp4', duration: '2700.0' } }, null, 2), + metadata_updated_at: 1760923100, + poster_url: '/api/v1/items/203/artwork?kind=poster', + tagline: 'Winter is coming.', + overview: 'A major fantasy series entry used as mock TV metadata for the browser client.', + genres: ['Drama', 'Fantasy'], + release_year: 2011, + linked_media_type: 'tv', + has_metadata: true, + metadata_refresh_state: 'fresh', + theme_song_youtube_url: 'https://www.youtube.com/watch?v=uXZd_W5B7N0', + subtitle_tracks: [], + hierarchy: [ + { + id: 201, + library_id: 2, + item_type: 'show', + display_title: 'Mock Show', + relative_path: 'Mock Show', + media_kind: 'video', + playable: false, + child_count: 1, + duration_ms: 2_700_000, + genres: ['Drama', 'Fantasy'], + modified_at: 1760923150, + }, + { + id: 202, + library_id: 2, + parent_id: 201, + item_type: 'season', + display_title: 'Season 1', + relative_path: 'Mock Show/Season 1', + media_kind: 'video', + playable: false, + child_count: 1, + season_number: 1, + duration_ms: 2_700_000, + genres: ['Drama', 'Fantasy'], + modified_at: 1760923150, + }, + ], + children: [], + }, + { + id: 103, + library_id: 3, + item_type: 'track', + display_title: 'Mock Song', + relative_path: 'mock-artist/mock-song.flac', + media_kind: 'audio', + playable: true, + child_count: 0, + duration_ms: 215_000, + modified_at: 1760923000, + file_size: 35_610_736, + container: 'flac', + bit_rate: 970_000, + audio_codec: 'flac', + metadata_json: JSON.stringify({ format: { format_name: 'flac', duration: '215.0' } }, null, 2), + metadata_updated_at: 1760923000, + genres: [], + subtitle_tracks: [], + hierarchy: [], + children: [], + }, + { + id: 104, + library_id: 3, + item_type: 'track', + display_title: 'Roadtrip Mix', + relative_path: 'mock-artist/roadtrip-mix.mp3', + media_kind: 'audio', + playable: true, + child_count: 0, + duration_ms: 198_000, + modified_at: 1760922900, + file_size: 8_610_736, + container: 'mp3', + bit_rate: 320_000, + audio_codec: 'mp3', + metadata_json: JSON.stringify({ format: { format_name: 'mp3', duration: '198.0' } }, null, 2), + metadata_updated_at: 1760922900, + genres: [], + subtitle_tracks: [], + hierarchy: [], + children: [], + }, +]; + +const metadataProviders: MetadataProviderStatus[] = [ + { + id: 'tmdb', + display_name: 'TheMovieDB', + description: 'Primary movie and television metadata provider for Koko.', + supported_kinds: ['movies', 'shows'], + requires_api_key: true, + implemented: true, + enabled: true, + configured: true, + language: 'en-US', + }, + { + id: 'musicbrainz', + display_name: 'MusicBrainz', + description: 'Planned music metadata provider for albums, artists, and tracks.', + supported_kinds: ['music'], + requires_api_key: false, + implemented: false, + enabled: false, + configured: true, + language: 'en-US', + }, +]; + +const metadataSearchResults: Record = { + 101: [ + { + provider_id: 'tmdb', + external_id: '603', + media_type: 'movie', + title: 'The Matrix', + overview: 'A computer hacker learns the true nature of reality and his role in the war against its controllers.', + artwork_url: 'https://image.tmdb.org/t/p/w500/f89U3ADr1oiB1s9GkdPOEpXUk5H.jpg', + backdrop_url: 'https://image.tmdb.org/t/p/w1280/icmmSD4vTTDKOq2vvdulafOGw93.jpg', + release_year: 1999, + }, + ], + 203: [ + { + provider_id: 'tmdb', + external_id: '1399', + media_type: 'tv', + title: 'Game of Thrones', + overview: 'Nine noble families wage war against each other in order to gain control over the mythical land of Westeros.', + artwork_url: 'https://image.tmdb.org/t/p/w500/u3bZgnGQ9T01sWNhyveQz0wH0Hl.jpg', + backdrop_url: 'https://image.tmdb.org/t/p/w1280/suopoADq0k8YZr4dQXcU6pToj6s.jpg', + release_year: 2011, + }, + ], +}; + +const users: MockUserRecord[] = [ + { + id: 1, + username: 'admin', + password: 'adminpass', + admin: true, + }, +]; + +function activeMockUserId(): number | undefined { + const token = window.localStorage.getItem(AUTH_TOKEN_STORAGE_KEY)?.trim(); + if (!token?.startsWith('mock-token-')) { + return undefined; + } + + const parsed = Number(token.slice('mock-token-'.length)); + return Number.isFinite(parsed) ? parsed : undefined; +} + +const itemMetadata: Record = { + 101: { + item_id: 101, + providers: metadataProviders, + matches: [ + { + id: 1, + provider_id: 'tmdb', + external_id: '603', + title: 'The Matrix', + overview: 'A computer hacker learns the true nature of reality and his role in the war against its controllers.', + artwork_url: 'https://image.tmdb.org/t/p/w500/f89U3ADr1oiB1s9GkdPOEpXUk5H.jpg', + backdrop_url: 'https://image.tmdb.org/t/p/w1280/icmmSD4vTTDKOq2vvdulafOGw93.jpg', + release_year: 1999, + media_type: 'movie', + match_state: 'linked', + provider_payload_json: JSON.stringify({ + videos: { + results: [ + { + site: 'YouTube', + type: 'Trailer', + official: true, + name: 'Official Trailer', + key: 'vKQi3bBA1y8', + }, + { + site: 'YouTube', + type: 'Teaser', + official: false, + name: 'Legacy Teaser', + key: 'm8e-FF8MsqU', + }, + ], + }, + }), + refresh_state: 'fresh', + last_refreshed_at: 1760923200, + updated_at: 1760923200, + }, + ], + }, + 201: { + item_id: 201, + providers: metadataProviders, + matches: [], + }, + 202: { + item_id: 202, + providers: metadataProviders, + matches: [], + }, + 203: { + item_id: 203, + providers: metadataProviders, + matches: [], + }, + 103: { + item_id: 103, + providers: metadataProviders, + matches: [], + }, + 104: { + item_id: 104, + providers: metadataProviders, + matches: [], + }, +}; + +const playbackProgress = new Map(); +playbackProgress.set('1:101', { position_ms: 1_260_000, duration_ms: 5_400_000, completed: false }); +playbackProgress.set('1:103', { position_ms: 74_000, duration_ms: 215_000, completed: false }); + +const collections: MediaCollectionSummary[] = [ + { + id: 'tmdb:2344', + provider_id: 'tmdb', + external_id: '2344', + name: 'The Matrix Collection', + overview: 'A cyberpunk science-fiction collection centered around Neo, Zion, and the war against the machines.', + artwork_url: 'https://image.tmdb.org/t/p/w500/f89U3ADr1oiB1s9GkdPOEpXUk5H.jpg', + backdrop_url: 'https://image.tmdb.org/t/p/w1280/icmmSD4vTTDKOq2vvdulafOGw93.jpg', + item_ids: [101], + item_count: 1, + }, +]; + +let settings: SettingsSnapshot = { + general: { + data_dir: 'C:/Users/Mock/AppData/Local/Koko/data', + }, + media: { + libraries: [ + { + name: 'Movies', + path: 'C:/Media/Movies', + paths: ['C:/Media/Movies', 'D:/Overflow/Movies'], + recursive: true, + kind: 'movies', + metadata_providers: ['tmdb'], + }, + { + name: 'Shows', + path: 'C:/Media/Shows', + paths: ['C:/Media/Shows'], + recursive: true, + kind: 'shows', + metadata_providers: ['tmdb'], + }, + { + name: 'Music', + path: 'C:/Media/Music', + paths: ['C:/Media/Music'], + recursive: true, + kind: 'music', + metadata_providers: [], + }, + ], + }, + metadata: { + providers: [ + { + id: 'tmdb', + enabled: true, + api_key: 'mock-key', + language: 'en-US', + rate_limit_per_second: 4, + retry_attempts: 3, + retry_backoff_ms: 1000, + }, + ], + }, + server: { + use_https: false, + address: '127.0.0.1', + port: 9191, + cert_path: 'cert.pem', + key_path: 'key.pem', + use_custom_certs: false, + }, + ffmpeg: { + strategy: 'external_binaries', + ffmpeg_path: 'ffmpeg', + ffprobe_path: 'ffprobe', + }, +}; + +export function getMockCapabilities(): ServerCapabilities { + return { + app_name: 'Koko', + version: '0.0.0-dev', + server_url: 'http://127.0.0.1:9191', + https_enabled: false, + libraries_configured: libraries.length, + ffmpeg_strategy: 'external_binaries', + api_versions: ['v1'], + transcoding: { + ffmpeg: { + available: true, + version: 'ffmpeg mock build', + }, + ffprobe: { + available: true, + version: 'ffprobe mock build', + }, + }, + }; +} + +export function getMockBootstrap(): AppBootstrapResponse { + const currentUser = users.find((user) => user.id === activeMockUserId()); + return { + has_users: users.length > 0, + current_user: currentUser ? { id: currentUser.id, username: currentUser.username, admin: currentUser.admin } : undefined, + }; +} + +export function loginMockUser(request: LoginRequest): TokenResponse { + const user = users.find((candidate) => { + return candidate.username === request.username && candidate.password === request.password; + }); + if (!user) { + throw new Error('401 Unauthorized'); + } + return { token: `mock-token-${user.id}` }; +} + +export function createMockUser(request: CreateUserRequest): string { + const currentUser = users.find((user) => user.id === activeMockUserId()); + if (users.length > 0 && currentUser === undefined) { + throw new Error('401 Unauthorized'); + } + + if (users.length > 0 && !currentUser?.admin) { + throw new Error('403 Forbidden'); + } + + if (users.some((user) => user.username.toLowerCase() === request.username.trim().toLowerCase())) { + throw new Error('409 Conflict'); + } + + users.push({ + id: nextUserId, + username: request.username.trim(), + password: request.password, + pin: request.pin, + admin: users.length === 0 || request.admin, + }); + nextUserId += 1; + return 'User created'; +} + +export function getMockUsers(): BootstrapUser[] { + return users.map(({ id, username, admin }) => ({ id, username, admin })); +} + +export function getMockLibraries(): MediaLibrary[] { + syncAllMockLibraryRefreshProgress(); + return [...libraries]; +} + +function syncMockLibraryRefreshProgress(libraryId: number): void { + const library = libraries.find((candidate) => candidate.id === libraryId); + if (!library) { + return; + } + + const refreshableItems = items.filter((item) => item.library_id === libraryId && item.has_metadata); + library.metadata_refresh_total = refreshableItems.length; + library.metadata_refresh_pending = refreshableItems.filter((item) => item.metadata_refresh_state === 'pending').length; + library.metadata_refresh_failed = refreshableItems.filter((item) => item.metadata_refresh_state === 'error').length; + library.metadata_refresh_completed = Math.max(0, library.metadata_refresh_total - library.metadata_refresh_pending); +} + +function syncAllMockLibraryRefreshProgress(): void { + libraries.forEach((library) => { + syncMockLibraryRefreshProgress(library.id); + }); +} + +export function getMockMetadataProviders(): MetadataProviderStatus[] { + return metadataProviders.map((provider) => ({ ...provider })); +} + +export function getMockSystemActivities(): SystemActivitiesResponse { + const now = Math.floor(Date.now() / 1000); + const activities = libraries.reduce((entries, library) => { + const pendingItems = items.filter((item) => item.library_id === library.id && item.metadata_refresh_state === 'pending'); + if (!pendingItems.length) { + return entries; + } + + entries.push({ + id: `mock-activity-library-${library.id}`, + category: 'metadata_refresh', + scope: 'library', + source: 'mock_refresh', + state: 'running', + label: `Refresh metadata for ${library.name}`, + provider_id: 'tmdb', + library_id: library.id, + item_ids: pendingItems.map((item) => item.id), + total_items: pendingItems.length, + completed_items: 0, + failed_items: 0, + queued_at: now, + started_at: now, + updated_at: now, + }); + + return entries; + }, []); + + return { + generated_at: now, + activities, + }; +} + +export function getMockLogs( + level?: string, + moduleFilter?: string, + search?: string, + since?: string, + until?: string, + limit = 200, +): LogEntriesResponse { + const sinceTime = since ? new Date(since).getTime() : Number.NaN; + const untilTime = until ? new Date(until).getTime() : Number.NaN; + const entries = [ + { + timestamp: '2026-04-22T09:12:35.853-04:00', + level: 'INFO', + module: 'koko::web::routes::media', + source_file_path: 'src/web/routes/media.rs', + line_number: 540, + message: 'Completed TMDB metadata refresh for media item 201 "Mock Show" (show) in library 2 [Mock Show]', + }, + { + timestamp: '2026-04-22T09:12:00.810-04:00', + level: 'WARN', + module: 'koko::web::routes::media', + source_file_path: 'src/web/routes/media.rs', + line_number: 589, + message: 'Failed to fetch refreshed TMDB metadata snapshot for media item 417 "Season 1" (season) in library 2 [The Simpsons/Season 1] using target tv:456:season:1 (tv_season): TMDB season lookup failed with status 404 Not Found', + }, + { + timestamp: '2026-04-22T09:10:49.079-04:00', + level: 'DEBUG', + module: 'reqwest::connect', + source_file_path: 'src/connect.rs', + line_number: 118, + message: 'starting new connection: https://api.themoviedb.org/', + }, + ].filter((entry) => { + const levelMatches = level ? entry.level.toLowerCase() === level.toLowerCase() : true; + const moduleMatches = moduleFilter ? entry.module.toLowerCase().includes(moduleFilter.toLowerCase()) : true; + const searchMatches = search + ? `${entry.message} ${entry.module} ${entry.source_file_path}`.toLowerCase().includes(search.toLowerCase()) + : true; + const timestamp = new Date(entry.timestamp).getTime(); + const sinceMatches = Number.isNaN(sinceTime) || timestamp >= sinceTime; + const untilMatches = Number.isNaN(untilTime) || timestamp <= untilTime; + return levelMatches && moduleMatches && searchMatches && sinceMatches && untilMatches; + }); + + return { + log_path: 'C:/Users/Mock/AppData/Local/Koko/data/koko.log', + entries: entries.slice(0, Math.max(1, limit)), + }; +} + +export function getMockItem(itemId: number): MediaItemDetail | undefined { + return items.find((item) => item.id === itemId); +} + +export function getMockItemMetadata(itemId: number): ItemMetadataResponse | undefined { + return itemMetadata[itemId]; +} + +export function searchMockItemMetadata(itemId: number, query?: string): MetadataSearchResult[] { + const results = metadataSearchResults[itemId] ?? []; + const normalized = query?.trim().toLowerCase(); + if (!normalized) { + return [...results]; + } + + return results.filter((result) => { + return result.title.toLowerCase().includes(normalized) + || result.overview?.toLowerCase().includes(normalized); + }); +} + +export function getMockPlayback(itemId: number): PlaybackDecision { + const item = getMockItem(itemId); + if (!item) { + throw new Error('404 Not Found'); + } + + if (!item.playable) { + return { + item_id: itemId, + can_direct_play: false, + transcode_required: false, + reason: 'This item is a container and cannot be played directly.', + stream_url: undefined, + mime_type: undefined, + }; + } + + const canDirectPlay = item.container === 'mp4' || item.container === 'mp3' || item.container === 'flac'; + return { + item_id: itemId, + can_direct_play: canDirectPlay, + transcode_required: !canDirectPlay, + reason: canDirectPlay + ? 'Browser direct play is supported for this item.' + : 'A future FFmpeg-backed transcode path will be required for browser playback.', + stream_url: canDirectPlay ? `/api/v1/items/${itemId}/stream` : undefined, + mime_type: item.media_kind === 'video' ? 'video/mp4' : 'audio/mpeg', + }; +} + +export function getMockHome(libraryId?: number): MediaHome { + const filteredItems = getMockItems(libraryId); + const continueWatching = filteredItems.filter((item) => { + const userId = activeMockUserId(); + const progress = userId === undefined ? undefined : playbackProgress.get(`${userId}:${item.id}`); + return Boolean(progress && !progress.completed && progress.position_ms > 0); + }); + const recentlyAdded = [...filteredItems].sort((left, right) => (right.modified_at ?? 0) - (left.modified_at ?? 0)); + const recommended = filteredItems.filter((item) => !continueWatching.some((candidate) => candidate.id === item.id)); + + return { + library_id: libraryId, + shelves: [ + { id: 'continue_watching', title: 'Continue watching', items: continueWatching.slice(0, 12) }, + { id: 'recently_added', title: 'Recently added', items: recentlyAdded.slice(0, 12) }, + { id: 'recommended', title: 'Recommended', items: recommended.slice(0, 12) }, + ], + collections: collections.filter((collection) => collection.item_ids.some((itemId) => filteredItems.some((item) => item.id === itemId))), + }; +} + +export function getMockItems(libraryId?: number): MediaItemSummary[] { + return items + .filter((item) => (typeof libraryId === 'number' ? item.library_id === libraryId : true)) + .map(({ file_size: _fileSize, container: _container, bit_rate: _bitRate, video_codec: _videoCodec, audio_codec: _audioCodec, metadata_json: _metadataJson, metadata_updated_at: _metadataUpdatedAt, ...summary }) => summary); +} + +export function searchMockItems(query: string, libraryId?: number): MediaItemSummary[] { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) { + return []; + } + + return getMockItems(libraryId).filter((item) => { + return item.display_title.toLowerCase().includes(normalizedQuery) + || item.relative_path.toLowerCase().includes(normalizedQuery) + || item.media_kind.toLowerCase().includes(normalizedQuery); + }); +} + +export function getMockSettings(): SettingsResponse { + return { + settings: structuredClone(settings), + settings_path: 'C:/Users/Mock/AppData/Local/Koko/settings.yml', + }; +} + +export function updateMockSettings(nextSettings: SettingsSnapshot): SettingsResponse { + settings = structuredClone(nextSettings); + return getMockSettings(); +} + +export function addMockLibrary(request: { library: MediaLibrarySettings }): SettingsResponse { + const normalizedLibrary = structuredClone(request.library); + normalizedLibrary.paths = normalizedLibrary.paths.length ? normalizedLibrary.paths : [normalizedLibrary.path].filter(Boolean); + normalizedLibrary.path = normalizedLibrary.paths[0] ?? normalizedLibrary.path; + settings.media.libraries.push(normalizedLibrary); + libraries.push({ + id: nextLibraryId, + name: normalizedLibrary.name, + path: normalizedLibrary.path, + paths: [...normalizedLibrary.paths], + recursive: normalizedLibrary.recursive, + kind: normalizedLibrary.kind, + status: 'available', + scan_revision: 1, + last_scanned_at: Math.floor(Date.now() / 1000), + total_files: 0, + video_files: 0, + audio_files: 0, + image_files: 0, + book_files: 0, + other_files: 0, + metadata_refresh_total: 0, + metadata_refresh_pending: 0, + metadata_refresh_completed: 0, + metadata_refresh_failed: 0, + }); + nextLibraryId += 1; + return getMockSettings(); +} + +export function removeMockLibrary(libraryIndex: number): SettingsResponse { + if (libraryIndex < 0 || libraryIndex >= settings.media.libraries.length) { + throw new Error('404 Not Found'); + } + + const [removedLibrary] = settings.media.libraries.splice(libraryIndex, 1); + const libraryMatchIndex = libraries.findIndex((library) => { + return library.name === removedLibrary.name && library.path === removedLibrary.path; + }); + if (libraryMatchIndex >= 0) { + libraries.splice(libraryMatchIndex, 1); + } + + return getMockSettings(); +} + +export function updateMockPlaybackProgress(itemId: number, payload: PlaybackProgressRequest): void { + const userId = activeMockUserId(); + if (userId !== undefined) { + playbackProgress.set(`${userId}:${itemId}`, payload); + } +} + +export function linkMockItemMetadata(itemId: number, request: LinkMetadataRequest): ItemMetadataMatch { + const candidate = (metadataSearchResults[itemId] ?? []).find((result) => { + return result.provider_id === request.provider_id + && result.external_id === request.external_id + && result.media_type === request.media_type; + }); + if (!candidate) { + throw new Error('404 Not Found'); + } + + const linkedMatch: ItemMetadataMatch = { + id: Date.now(), + provider_id: candidate.provider_id, + external_id: candidate.external_id, + title: candidate.title, + overview: candidate.overview, + artwork_url: candidate.artwork_url, + backdrop_url: candidate.backdrop_url, + release_year: candidate.release_year, + media_type: candidate.media_type, + match_state: 'linked', + provider_payload_json: JSON.stringify(candidate, null, 2), + updated_at: Math.floor(Date.now() / 1000), + }; + + itemMetadata[itemId] = { + item_id: itemId, + providers: metadataProviders, + matches: [linkedMatch], + }; + const item = items.find((candidate) => candidate.id === itemId); + if (item) { + item.display_title = candidate.title; + } + + return linkedMatch; +} + +export function refreshMockItemMetadata(itemId: number): ItemMetadataMatch { + const response = itemMetadata[itemId]; + const existingMatch = response?.matches[0]; + if (!existingMatch) { + throw new Error('404 Not Found'); + } + + const pendingMatch: ItemMetadataMatch = { + ...existingMatch, + refresh_state: 'pending', + updated_at: Math.floor(Date.now() / 1000), + }; + + itemMetadata[itemId] = { + ...response, + matches: [pendingMatch], + }; + + const item = items.find((candidate) => candidate.id === itemId); + if (item) { + item.metadata_refresh_state = 'pending'; + syncMockLibraryRefreshProgress(item.library_id); + + window.setTimeout(() => { + const source = (metadataSearchResults[itemId] ?? []).find((candidate) => { + return candidate.provider_id === existingMatch.provider_id + && candidate.external_id === existingMatch.external_id + && candidate.media_type === existingMatch.media_type; + }); + const refreshedAt = Math.floor(Date.now() / 1000); + const refreshedMatch: ItemMetadataMatch = { + ...pendingMatch, + title: source?.title ?? existingMatch.title, + overview: source?.overview ?? existingMatch.overview, + artwork_url: source?.artwork_url ?? existingMatch.artwork_url, + backdrop_url: source?.backdrop_url ?? existingMatch.backdrop_url, + release_year: source?.release_year ?? existingMatch.release_year, + refresh_state: 'fresh', + updated_at: refreshedAt, + }; + + itemMetadata[itemId] = { + ...response, + matches: [refreshedMatch], + }; + item.display_title = refreshedMatch.title ?? item.display_title; + item.overview = refreshedMatch.overview ?? item.overview; + item.release_year = refreshedMatch.release_year ?? item.release_year; + item.linked_media_type = refreshedMatch.media_type ?? item.linked_media_type; + item.metadata_refresh_state = 'fresh'; + item.artwork_updated_at = refreshedAt; + syncMockLibraryRefreshProgress(item.library_id); + }, 900); + } + + return pendingMatch; +} + +export function refreshMockLibraryMetadata(libraryId: number): MediaLibrary { + const library = libraries.find((candidate) => candidate.id === libraryId); + if (!library) { + throw new Error('404 Not Found'); + } + + const refreshableItems = items.filter((item) => item.library_id === libraryId && item.has_metadata); + refreshableItems.forEach((item) => { + item.metadata_refresh_state = 'pending'; + const response = itemMetadata[item.id]; + const existingMatch = response?.matches[0]; + if (existingMatch && response) { + itemMetadata[item.id] = { + ...response, + matches: [{ + ...existingMatch, + refresh_state: 'pending', + updated_at: Math.floor(Date.now() / 1000), + }], + }; + } + }); + syncMockLibraryRefreshProgress(libraryId); + + window.setTimeout(() => { + const refreshedAt = Math.floor(Date.now() / 1000); + refreshableItems.forEach((item) => { + item.metadata_refresh_state = 'fresh'; + item.artwork_updated_at = refreshedAt; + const response = itemMetadata[item.id]; + const existingMatch = response?.matches[0]; + if (existingMatch && response) { + itemMetadata[item.id] = { + ...response, + matches: [{ + ...existingMatch, + refresh_state: 'fresh', + updated_at: refreshedAt, + }], + }; + } + }); + syncMockLibraryRefreshProgress(libraryId); + }, 1200); + + return { ...library }; +} + diff --git a/crates/client-web/src/style.css b/crates/client-web/src/style.css new file mode 100644 index 00000000..01e9485a --- /dev/null +++ b/crates/client-web/src/style.css @@ -0,0 +1,1581 @@ +:root { + --page-backdrop-image: none; + color-scheme: dark; + font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + line-height: 1.5; + font-weight: 400; + background: #0c111d; + color: #f4f7fb; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; + background: + radial-gradient(circle at top left, rgba(89, 124, 255, 0.18), transparent 24%), + radial-gradient(circle at bottom right, rgba(67, 214, 158, 0.16), transparent 22%), + #0c111d; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button { + border: 0; + border-radius: 12px; + background: linear-gradient(135deg, #5d7bff, #7c5cff); + color: #fff; + padding: 0.8rem 1rem; + cursor: pointer; + transition: transform 160ms ease, opacity 160ms ease, box-shadow 160ms ease; + box-shadow: 0 12px 30px rgba(93, 123, 255, 0.24); +} + +button:hover:not(:disabled) { + transform: translateY(-1px); +} + +button:disabled { + opacity: 0.45; + cursor: not-allowed; + box-shadow: none; +} + +.secondary-button { + background: rgba(255, 255, 255, 0.08); + box-shadow: none; + color: #dbe7ff; +} + +.button-content { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.55rem; +} + +.button-content.icon-end { + flex-direction: row-reverse; +} + +.button-icon { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.button-icon svg { + width: 1rem; + height: 1rem; + stroke-width: 2.1; +} + +input, +select, +textarea { + width: 100%; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + background: rgba(255, 255, 255, 0.05); + color: inherit; + padding: 0.8rem 0.9rem; +} + +textarea { + resize: vertical; + min-height: 6rem; +} + +fieldset { + margin: 0; + padding: 0; + border: 0; +} + +legend { + font-weight: 600; + margin-bottom: 0.6rem; +} + +#app { + min-height: 100vh; +} + +.auth-shell { + min-height: 100vh; + display: grid; + place-items: center; + padding: 1.5rem; +} + +.auth-panel { + width: min(480px, 100%); + padding: 1.4rem; +} + +.auth-header { + display: flex; + gap: 1rem; + align-items: center; + margin-bottom: 1rem; +} + +.auth-header h1, +.auth-copy h2 { + margin: 0; +} + +.auth-form { + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.auth-form label { + display: flex; + flex-direction: column; + gap: 0.45rem; +} + +.auth-error-panel { + margin-bottom: 1rem; +} + +.app-shell { + position: relative; + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + min-height: 100vh; + height: 100vh; + align-items: stretch; + overflow: hidden; +} + +.app-shell.rail-collapsed { + grid-template-columns: 88px minmax(0, 1fr); +} + +.page-backdrop { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 0; +} + +.page-backdrop::before { + content: ''; + position: absolute; + top: 0; + right: 0; + width: min(76vw, 1240px); + height: min(68vh, 840px); + background-image: var(--page-backdrop-image, none); + background-position: center 18%; + background-repeat: no-repeat; + background-size: cover; + opacity: 0.88; + transform: scale(1.04); + transform-origin: top right; + filter: saturate(1.04) contrast(1.02); + mask-image: radial-gradient(ellipse at 58% 34%, rgba(0, 0, 0, 0.98) 0%, rgba(0, 0, 0, 0.96) 34%, rgba(0, 0, 0, 0.82) 56%, rgba(0, 0, 0, 0.46) 74%, transparent 100%); + -webkit-mask-image: radial-gradient(ellipse at 58% 34%, rgba(0, 0, 0, 0.98) 0%, rgba(0, 0, 0, 0.96) 34%, rgba(0, 0, 0, 0.82) 56%, rgba(0, 0, 0, 0.46) 74%, transparent 100%); +} + +.page-backdrop::after { + content: ''; + position: absolute; + inset: 0; + background: + radial-gradient(circle at 66% 22%, rgba(12, 17, 29, 0) 0%, rgba(12, 17, 29, 0.16) 34%, rgba(12, 17, 29, 0.62) 72%, #0c111d 100%), + linear-gradient(180deg, rgba(12, 17, 29, 0.06) 0%, rgba(12, 17, 29, 0.18) 26%, rgba(12, 17, 29, 0.54) 54%, rgba(12, 17, 29, 0.84) 76%, #0c111d 100%), + linear-gradient(270deg, rgba(12, 17, 29, 0) 0%, rgba(12, 17, 29, 0.18) 18%, rgba(12, 17, 29, 0.78) 54%, #0c111d 100%); +} + +.app-shell > :not(.page-backdrop) { + position: relative; + z-index: 1; +} + +.library-rail { + grid-column: 1; + grid-row: 1; + display: flex; + flex-direction: column; + gap: 1.2rem; + width: 100%; + min-width: 0; + height: 100vh; + padding: 1.2rem 0.9rem; + border-right: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(7, 11, 21, 0.92); + backdrop-filter: blur(18px); + overflow: hidden; +} + +.app-shell.rail-collapsed .library-rail { + max-width: 88px; +} + +.library-rail-top, +.library-rail-bottom { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.library-rail-top { + min-height: 0; + flex: 1; +} + +.library-rail-bottom { + margin-top: auto; + padding-top: 0.2rem; +} + +.rail-user-card { + display: flex; + flex-direction: column; + gap: 0.15rem; + padding: 0.85rem 0.9rem; + border-radius: 16px; + background: rgba(255, 255, 255, 0.05); + color: #dbe7ff; +} + +.rail-user-card span { + color: #9ab1d1; + font-size: 0.82rem; +} + +.brand-block { + display: flex; + align-items: center; + gap: 0.7rem; + padding: 0.25rem 0.4rem; +} + +.brand-block h1 { + margin: 0; + font-size: 1rem; +} + +.brand-block p { + margin: 0.1rem 0 0; + font-size: 0.78rem; + color: #9ab1d1; +} + +.brand-mark { + width: 38px; + height: 38px; + display: grid; + place-items: center; + border-radius: 14px; + background: linear-gradient(135deg, #5d7bff, #42d69e); +} + +.brand-icon, +.rail-icon, +.card-icon { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.brand-icon svg, +.rail-icon svg, +.card-icon svg { + width: 1.1rem; + height: 1.1rem; + stroke-width: 2; +} + +.rail-nav { + display: flex; + flex-direction: column; + gap: 0.5rem; + flex: 1; + min-height: 0; + overflow-y: auto; + padding-right: 0.2rem; +} + +.rail-button { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 0.7rem; + width: 100%; + padding: 0.85rem 0.9rem; + border-radius: 16px; + background: transparent; + box-shadow: none; + color: #b6c4d8; +} + +.rail-button.active, +.rail-button:hover { + background: rgba(255, 255, 255, 0.08); + color: #fff; +} + +.rail-icon { + width: 1.5rem; + min-width: 1.5rem; + text-align: center; +} + +.library-rail.collapsed { + padding-inline: 0.7rem; +} + +.library-rail.collapsed .brand-block { + justify-content: center; +} + +.library-rail.collapsed .brand-block > div:last-child, +.library-rail.collapsed .rail-label, +.library-rail.collapsed .rail-user-card { + display: none; +} + +.library-rail.collapsed .rail-library-copy { + display: none; +} + +.library-rail.collapsed .rail-button { + justify-content: center; + padding-inline: 0.75rem; +} + +.rail-label { + max-width: 100%; + font-size: 0.92rem; + line-height: 1.2; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; +} + +.rail-library-copy { + display: inline-flex; + align-items: center; + gap: 0.55rem; + min-width: 0; +} + +.library-refresh-indicator { + display: inline-flex; + align-items: center; + justify-content: center; + width: 0.95rem; + height: 0.95rem; + flex: 0 0 auto; +} + +.library-refresh-ring { + position: relative; + width: 100%; + height: 100%; + border-radius: 999px; + background: conic-gradient(#5d7bff var(--library-refresh-progress, 0%), rgba(255, 255, 255, 0.14) 0); +} + +.library-refresh-ring::after { + content: ''; + position: absolute; + inset: 2px; + border-radius: inherit; + background: rgba(14, 20, 35, 0.96); +} + +.rail-settings { + width: 100%; +} + +.main-shell { + grid-column: 2; + grid-row: 1; + display: flex; + flex-direction: column; + width: 100%; + padding: 1.2rem 1.4rem; + height: 100vh; + min-width: 0; + overflow-y: auto; +} + +.panel { + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(14, 20, 35, 0.82); + backdrop-filter: blur(18px); + border-radius: 24px; +} + +.main-shell-inner { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.content-navbar { + position: sticky; + top: 0; + z-index: 5; + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: end; + padding: 1rem 1.2rem; +} + +.content-navbar h2, +.player-header h2 { + margin: 0.1rem 0; +} + +.content-navbar-copy { + display: flex; + flex-direction: column; + gap: 0.2rem; + min-width: 0; +} + +.content-navbar-actions { + display: flex; + align-items: center; + justify-content: flex-end; + flex: 1; + min-width: 0; +} + +.content-navbar-actions-stack { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.75rem; + flex-wrap: wrap; + width: 100%; +} + +.content-navbar-actions .search-form { + justify-content: flex-end; +} + +.browse-tabs { + display: flex; + gap: 0.7rem; + padding: 0.8rem; + overflow-x: auto; +} + +.browse-tab-button { + flex: 0 0 auto; + background: transparent; + box-shadow: none; + color: #9ab1d1; +} + +.browse-tab-button.active, +.browse-tab-button:hover { + background: rgba(255, 255, 255, 0.08); + color: #fff; +} + +.page-panel { + width: 100%; +} + +.eyebrow, +.label { + display: block; + font-size: 0.74rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #86a0c7; +} + +.error-panel { + padding: 1rem 1.2rem; + border-color: rgba(255, 132, 132, 0.35); + background: rgba(86, 24, 24, 0.38); + color: #ffd7d7; +} + +.workspace-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 380px; + gap: 1rem; + min-height: 0; +} + +.content-column { + display: flex; + flex-direction: column; + gap: 1rem; + min-width: 0; +} + +.shelf-stack, +.detail-panel { + padding: 1.2rem; +} + +.search-form { + display: flex; + gap: 0.7rem; + width: min(100%, 640px); +} + +.library-overview-panel { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem 1.2rem; +} + +.library-overview-header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: start; +} + +.library-overview-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.65rem; + align-items: center; +} + +.library-overview-header h3, +.category-card-header strong { + margin: 0; +} + +.library-status-tags { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; +} + +.library-overview-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 0.8rem; +} + +.library-stat-card, +.category-card { + padding: 0.95rem 1rem; + border-radius: 18px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.library-stat-card { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.library-stat-card strong { + font-size: 1.15rem; +} + +.library-overview-note { + margin: 0; +} + +.browse-section, +.placeholder-stack { + display: flex; + flex-direction: column; + gap: 0.9rem; +} + +.active-filter-bar { + display: flex; + flex-wrap: wrap; + gap: 0.7rem; + align-items: center; + padding: 0.85rem 0.95rem; + border-radius: 18px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.browse-section-header { + margin-bottom: 0.1rem; +} + +.category-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.9rem; +} + +.category-card-header { + display: flex; + justify-content: space-between; + gap: 0.8rem; + align-items: center; +} + +.filter-card-button { + text-align: left; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); + box-shadow: none; + color: inherit; +} + +.filter-card-button[style*='--collection-card-image'] { + background-image: var(--collection-card-image); + background-size: cover; + background-position: center; +} + +.filter-card-button:hover { + border-color: rgba(93, 123, 255, 0.35); + background: rgba(255, 255, 255, 0.06); +} + +.shelf-stack { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.shelf { + display: flex; + flex-direction: column; + gap: 0.8rem; +} + +.shelf-header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: center; +} + +.shelf-header h3, +.section-heading h3 { + margin: 0; +} + +.shelf-header span, +.metadata-match-meta, +.media-card-meta, +.detail-subtitle, +.muted, +.metadata-search-card p { + color: #9ab1d1; +} + +.shelf-row { + display: grid; + grid-auto-flow: column; + grid-auto-columns: 184px; + gap: 0.9rem; + overflow-x: auto; + padding-bottom: 0.2rem; + align-items: start; +} + +.shelf-row .media-card { + width: 184px; +} + +.item-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 1rem; +} + +.media-card { + display: flex; + flex-direction: column; + gap: 0.6rem; + align-items: stretch; + text-align: left; + padding: 0; + border-radius: 18px; + background: transparent; + box-shadow: none; +} + +.episode-card { + gap: 0.45rem; +} + +.media-card-art { + position: relative; + aspect-ratio: 2 / 3; + border-radius: 18px; + padding: 0.9rem; + display: flex; + justify-content: space-between; + align-items: start; + background: linear-gradient(180deg, rgba(93, 123, 255, 0.8), rgba(22, 31, 54, 0.92)); + background-position: center; + background-repeat: no-repeat; + background-size: cover; + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.media-card-art.episode { + aspect-ratio: 16 / 9; + align-items: end; +} + +.media-card-art.audio { + background: linear-gradient(180deg, rgba(66, 214, 158, 0.78), rgba(17, 44, 40, 0.94)); +} + +.media-card-kind-row { + display: flex; + justify-content: space-between; + gap: 0.5rem; + align-items: start; + width: 100%; +} + +.media-card-kind, +.media-card-duration { + padding: 0.35rem 0.55rem; + border-radius: 999px; + background: rgba(10, 14, 24, 0.36); + font-size: 0.76rem; + white-space: nowrap; +} + +.media-card-status { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.35rem 0.55rem; + border-radius: 999px; + background: rgba(10, 14, 24, 0.52); + font-size: 0.72rem; +} + +.media-card-status.icon-only { + min-width: 2rem; + min-height: 2rem; + justify-content: center; + padding: 0.35rem; +} + +.media-card-status.is-unmatched { + color: #ffe5b5; +} + +.media-card-status.is-loading { + color: #dce6ff; +} + +.loading-spinner { + width: 0.82rem; + height: 0.82rem; + border-radius: 999px; + border: 2px solid rgba(255, 255, 255, 0.25); + border-top-color: currentColor; + animation: spin 0.85s linear infinite; +} + +.status-icon svg { + width: 0.95rem; + height: 0.95rem; + stroke-width: 2.2; +} + +.media-card-title { + font-weight: 700; +} + +.media-card-subtitle { + font-size: 0.82rem; + color: #d8e5ff; +} + +.detail-panel { + position: sticky; + top: 1.2rem; + align-self: start; + max-height: calc(100vh - 2.4rem); + overflow: auto; +} + +.panel-placeholder, +.empty-state { + padding: 1rem; + border-radius: 18px; + background: rgba(255, 255, 255, 0.04); + color: #afc0db; +} + +.empty-state.tight { + padding: 0.8rem; +} + +.detail-card { + display: flex; + flex-direction: column; + gap: 1.2rem; +} + +.detail-hero { + display: grid; + grid-template-columns: 120px minmax(0, 1fr); + gap: 1rem; + width: 100%; +} + +.detail-art { + aspect-ratio: 2 / 3; + border-radius: 20px; + display: grid; + place-items: center; + overflow: hidden; + background: linear-gradient(180deg, rgba(93, 123, 255, 0.9), rgba(27, 37, 62, 0.96)); + font-size: 2.2rem; + font-weight: 800; +} + +.detail-art img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.detail-summary { + display: flex; + flex-direction: column; + gap: 0.5rem; + width: 100%; + min-width: 0; +} + +.hero-tagline { + margin: 0; + font-size: 1.05rem; + color: #d6e5ff; +} + +.hero-meta-row { + display: flex; + flex-wrap: wrap; + gap: 0.55rem; +} + +.hero-description { + margin: 0.2rem 0 0; + max-width: 70ch; + color: #dbe7ff; +} + +.metadata-refresh-error { + display: block; + margin-top: 0.25rem; + color: #ffd0d0; +} + +.detail-summary h2, +.metadata-search-card strong { + margin: 0; +} + +.detail-actions { + display: flex; + gap: 0.7rem; + flex-wrap: wrap; +} + +.trailer-picker { + display: flex; + flex-direction: column; + gap: 0.85rem; + padding: 0.9rem 1rem; + max-width: 760px; +} + +.trailer-picker-list { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; +} + +.trailer-option-button { + text-align: left; +} + +.detail-section { + display: flex; + flex-direction: column; + gap: 0.7rem; +} + +.metadata-search-list, +.item-page { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.item-page { + padding-top: 1rem; + padding-bottom: 1.2rem; +} + +.item-breadcrumbs { + display: flex; + align-items: center; + gap: 0.45rem; + flex-wrap: wrap; + padding: 0.85rem 1rem; +} + +.breadcrumb-button { + padding: 0; + background: transparent; + box-shadow: none; + color: #b7cae6; +} + +.breadcrumb-button:hover { + color: #fff; +} + +.breadcrumb-separator, +.breadcrumb-current { + color: #86a0c7; +} + +.item-hero { + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + gap: 1.5rem; + align-items: start; + min-height: min(58vh, 720px); + padding: 1.3rem 0 0.75rem; +} + +.item-hero.episode-hero { + grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); +} + +.item-poster { + width: min(100%, 220px); + box-shadow: 0 24px 44px rgba(0, 0, 0, 0.34); +} + +.item-thumbnail { + width: min(100%, 360px); + aspect-ratio: 16 / 9; +} + +.item-summary { + align-self: start; + padding: 0.35rem 0 1rem; +} + +.item-summary h2 { + font-size: clamp(2.2rem, 3vw, 3.6rem); + line-height: 1.04; + margin-top: 0; +} + +.item-fact-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 0.8rem; + margin-top: 0.5rem; +} + +.item-fact { + display: flex; + flex-direction: column; + gap: 0.2rem; + padding: 0.75rem 0.9rem; + border-radius: 18px; + background: rgba(8, 11, 18, 0.28); + border: 1px solid rgba(255, 255, 255, 0.08); + backdrop-filter: blur(16px); +} + +.item-support-grid { + display: grid; + grid-template-columns: minmax(260px, 360px) minmax(0, 1fr); + gap: 1rem; + align-items: start; +} + +.hierarchy-item-grid { + margin-top: 0.85rem; +} + +.hierarchy-item-grid.season-episodes-grid { + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 1.1rem; +} + +.hierarchy-item-grid.season-episodes-grid .episode-card { + gap: 0.55rem; +} + +.hierarchy-item-grid.season-episodes-grid .media-card-art.episode { + padding: 1rem; +} + +.item-section { + padding: 1.1rem; +} + +.item-info-list { + display: grid; + gap: 0.9rem; +} + +.item-info-list > div { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.section-heading-actions { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; +} + +.metadata-search-list { + display: flex; + flex-direction: column; + gap: 0.7rem; +} + +.metadata-search-card { + padding: 0.9rem 1rem; + border-radius: 16px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.metadata-search-card p { + margin: 0.35rem 0 0; +} + +.metadata-match-meta { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.metadata-current-link { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.6rem; + padding: 0.9rem 1rem; + border-radius: 18px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.metadata-current-copy { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; +} + +.metadata-search-card { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: start; +} + +.tag { + display: inline-flex; + align-items: center; + padding: 0.35rem 0.55rem; + border-radius: 999px; + font-size: 0.76rem; + background: rgba(255, 255, 255, 0.07); +} + +.tag.success { + background: rgba(66, 214, 158, 0.18); + color: #8bf3ca; +} + +.tag.warning { + background: rgba(255, 191, 84, 0.16); + color: #ffd78a; +} + +.metadata-search-form, +.settings-form { + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.settings-activity-panel, +.metadata-dashboard-panel, +.settings-log-panel { + padding: 1.2rem; +} + +.metadata-dashboard-filter-grid { + align-items: end; +} + +.metadata-dashboard-table { + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.metadata-dashboard-row { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: start; + padding: 1rem; + border-radius: 18px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.metadata-dashboard-copy { + display: flex; + flex-direction: column; + gap: 0.45rem; + min-width: 0; +} + +.metadata-dashboard-title-row { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; +} + +.metadata-dashboard-path, +.metadata-dashboard-meta, +.metadata-dashboard-error { + margin: 0; +} + +.metadata-dashboard-meta { + display: flex; + flex-wrap: wrap; + gap: 0.8rem; +} + +.metadata-dashboard-error { + color: #ffd0d0; +} + +.settings-system-activity-list, +.log-entry-list { + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.settings-system-activity, +.log-entry-card { + padding: 1rem; + border-radius: 18px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.settings-system-activity { + display: flex; + flex-direction: column; + gap: 0.8rem; +} + +.settings-system-activity-header, +.log-entry-header { + display: flex; + justify-content: space-between; + gap: 0.9rem; + align-items: start; +} + +.activity-progress-row { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.activity-progress-bar { + position: relative; + flex: 1; + min-width: 0; + height: 0.6rem; + border-radius: 999px; + overflow: hidden; + background: rgba(255, 255, 255, 0.08); +} + +.activity-progress-fill { + display: block; + width: var(--activity-progress, 0%); + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #5d7bff 0%, #8bf3ca 100%); +} + +.log-filter-form { + margin-bottom: 1rem; +} + +.log-filter-row { + align-items: end; +} + +.log-entry-source, +.log-entry-message { + margin: 0; +} + +.log-entry-message { + white-space: pre-wrap; + word-break: break-word; + font-family: 'Cascadia Mono', 'Fira Code', Consolas, monospace; + font-size: 0.86rem; + line-height: 1.5; + color: #dbe8ff; +} + +@media (max-width: 1320px) { + .page-backdrop::before { + inset: 0; + width: auto; + height: auto; + background-image: var(--page-backdrop-image, none); + background-position: center top; + opacity: 0.42; + transform: none; + filter: saturate(1.02) contrast(1.02); + mask-image: radial-gradient(circle at center 20%, rgba(0, 0, 0, 0.84) 0%, rgba(0, 0, 0, 0.68) 40%, rgba(0, 0, 0, 0.34) 66%, transparent 100%); + -webkit-mask-image: radial-gradient(circle at center 20%, rgba(0, 0, 0, 0.84) 0%, rgba(0, 0, 0, 0.68) 40%, rgba(0, 0, 0, 0.34) 66%, transparent 100%); + } + + .page-backdrop::after { + background: + radial-gradient(circle at center 16%, rgba(12, 17, 29, 0) 0%, rgba(12, 17, 29, 0.24) 30%, rgba(12, 17, 29, 0.7) 72%, #0c111d 100%), + linear-gradient(180deg, rgba(12, 17, 29, 0.12) 0%, rgba(12, 17, 29, 0.28) 32%, rgba(12, 17, 29, 0.72) 64%, #0c111d 100%); + } + + .item-hero, + .item-support-grid { + grid-template-columns: minmax(0, 1fr); + } +} + +.settings-drawer { + position: fixed; + top: 0; + right: 0; + width: min(520px, 100vw); + height: 100vh; + padding: 1.2rem; + overflow: auto; + border-left: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(9, 13, 24, 0.96); + backdrop-filter: blur(20px); + z-index: 20; +} + +.settings-header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: start; + margin-bottom: 1rem; +} + +.settings-form section { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 1rem; + border-radius: 18px; + background: rgba(255, 255, 255, 0.04); +} + +.settings-page-panel { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.settings-library-list { + display: flex; + flex-direction: column; + gap: 0.9rem; +} + +.user-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.settings-library-card { + display: flex; + flex-direction: column; + gap: 0.85rem; + padding: 1rem; + border-radius: 18px; + background: rgba(255, 255, 255, 0.04); +} + +.settings-library-header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: start; +} + +.settings-library-actions { + display: flex; + gap: 0.65rem; + flex-wrap: wrap; + justify-content: flex-end; +} + +.danger-button { + background: rgba(255, 107, 107, 0.14); + color: #ffb9b9; +} + +.page-actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +.settings-form label { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.form-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; +} + +.checkbox-row, +.checkbox-inline { + display: flex; + flex-wrap: wrap; + gap: 0.9rem; + align-items: center; +} + +.checkbox-inline { + flex-direction: row; +} + +.checkbox-inline input, +.checkbox-row input { + width: auto; +} + +.add-library-form { + margin-top: 1rem; +} + +.player-overlay { + position: fixed; + inset: 0; + display: grid; + place-items: center; + padding: 1rem; + background: rgba(5, 8, 16, 0.86); + z-index: 30; +} + +.trailer-shell { + width: min(100%, 1200px); +} + +.trailer-frame-shell { + position: relative; + aspect-ratio: 16 / 9; + border-radius: 20px; + overflow: hidden; + background: #000; +} + +.trailer-frame-shell iframe { + width: 100%; + height: 100%; + border: 0; + display: block; +} + +.theme-song-iframe { + position: fixed; + right: -8px; + bottom: -8px; + width: 1px; + height: 1px; + border: 0; + opacity: 0.01; + pointer-events: none; +} + +.danger-tag { + color: #ffd0d0; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.player-shell { + width: min(960px, 100%); + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem; + border-radius: 24px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(11, 16, 27, 0.96); +} + +.player-header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: start; +} + +#media-player { + width: 100%; + max-height: 72vh; + border-radius: 18px; + background: #000; +} + +#theme-song-player { + display: none; +} + +@media (max-width: 1280px) { + .workspace-grid { + grid-template-columns: minmax(0, 1fr); + } + + .detail-panel { + position: static; + max-height: none; + } +} + +@media (max-width: 960px) { + .app-shell { + grid-template-columns: 1fr; + height: auto; + min-height: 100vh; + overflow: visible; + } + + .library-rail { + grid-column: auto; + grid-row: auto; + height: auto; + max-width: none; + flex-direction: row; + align-items: center; + overflow: auto; + border-right: 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + } + + .library-rail-top, + .library-rail-bottom { + flex-direction: row; + align-items: center; + min-height: auto; + } + + .rail-nav { + flex-direction: row; + overflow: visible; + } + + .rail-button { + min-width: 110px; + } + + .main-shell { + grid-column: auto; + grid-row: auto; + height: auto; + overflow: visible; + padding: 0.9rem; + } + + .content-navbar, + .library-overview-header, + .section-heading-actions, + .settings-library-header, + .player-header { + flex-direction: column; + align-items: stretch; + } + + .search-form, + .form-row, + .item-hero, + .item-support-grid { + grid-template-columns: 1fr; + min-width: 0; + } + + .content-navbar-actions-stack, + .search-form { + flex-direction: column; + } + + .metadata-dashboard-row, + .metadata-dashboard-title-row, + .activity-progress-row, + .settings-system-activity-header, + .log-entry-header { + flex-direction: column; + align-items: stretch; + } + + .item-poster { + width: min(220px, 100%); + } +} diff --git a/crates/client-web/tsconfig.json b/crates/client-web/tsconfig.json new file mode 100644 index 00000000..fd8dc9d3 --- /dev/null +++ b/crates/client-web/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "types": ["vite/client"] + }, + "include": ["src"] +} + diff --git a/crates/client-web/vite.config.ts b/crates/client-web/vite.config.ts new file mode 100644 index 00000000..ff923a07 --- /dev/null +++ b/crates/client-web/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + server: { + host: '127.0.0.1', + port: 4173, + }, + preview: { + host: '127.0.0.1', + port: 4173, + }, +}); + diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index d54d0dc7..523ff2aa 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -62,12 +62,15 @@ once_cell = "1.20.3" rand = "0.9.0" rcgen = "0.13.2" regex = "1.11.1" +reqwest = { version = "0.13.2", default-features = false, features = ["json", "query", "rustls"] } rocket = { version = "0.5.1", features = ["tls"] } rocket_okapi = { version = "0.9.0", features = ["swagger", "rapidoc"] } rocket_sync_db_pools = { version = "0.1.0", features = ["diesel_sqlite_pool"] } schemars = "0.8.1" serde = "1.0.217" serde_json = "1.0.138" +serde_yaml = "0.9.34" +strsim = "0.11.1" tao = "0.35.0" tokio = { version = "1.0", features = ["full"] } tray-icon = "0.22.0" diff --git a/crates/server/sql/migrations/0_create_users/down.sql b/crates/server/sql/migrations/0000001_create_users/down.sql similarity index 100% rename from crates/server/sql/migrations/0_create_users/down.sql rename to crates/server/sql/migrations/0000001_create_users/down.sql diff --git a/crates/server/sql/migrations/0_create_users/up.sql b/crates/server/sql/migrations/0000001_create_users/up.sql similarity index 100% rename from crates/server/sql/migrations/0_create_users/up.sql rename to crates/server/sql/migrations/0000001_create_users/up.sql diff --git a/crates/server/sql/migrations/0000002_create_media_catalog/down.sql b/crates/server/sql/migrations/0000002_create_media_catalog/down.sql new file mode 100644 index 00000000..bdf93ab9 --- /dev/null +++ b/crates/server/sql/migrations/0000002_create_media_catalog/down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS media_files; +DROP TABLE IF EXISTS scan_state; +DROP TABLE IF EXISTS media_libraries; + diff --git a/crates/server/sql/migrations/0000002_create_media_catalog/up.sql b/crates/server/sql/migrations/0000002_create_media_catalog/up.sql new file mode 100644 index 00000000..9a8f3fe9 --- /dev/null +++ b/crates/server/sql/migrations/0000002_create_media_catalog/up.sql @@ -0,0 +1,34 @@ +CREATE TABLE IF NOT EXISTS media_libraries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + path TEXT NOT NULL UNIQUE, + kind TEXT NOT NULL, + recursive BOOLEAN NOT NULL DEFAULT TRUE +); + +CREATE TABLE IF NOT EXISTS scan_state ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + library_id INTEGER NOT NULL UNIQUE, + last_status TEXT NOT NULL DEFAULT 'never_scanned', + last_error TEXT DEFAULT NULL, + total_files BIGINT NOT NULL DEFAULT 0, + video_files BIGINT NOT NULL DEFAULT 0, + audio_files BIGINT NOT NULL DEFAULT 0, + image_files BIGINT NOT NULL DEFAULT 0, + book_files BIGINT NOT NULL DEFAULT 0, + other_files BIGINT NOT NULL DEFAULT 0, + FOREIGN KEY (library_id) REFERENCES media_libraries(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS media_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + library_id INTEGER NOT NULL, + relative_path TEXT NOT NULL, + file_size BIGINT NOT NULL, + modified_at BIGINT DEFAULT NULL, + media_kind TEXT NOT NULL, + fingerprint_seed TEXT NOT NULL, + FOREIGN KEY (library_id) REFERENCES media_libraries(id) ON DELETE CASCADE, + UNIQUE (library_id, relative_path) +); + diff --git a/crates/server/sql/migrations/0000003_enhance_media_catalog/down.sql b/crates/server/sql/migrations/0000003_enhance_media_catalog/down.sql new file mode 100644 index 00000000..92d32c80 --- /dev/null +++ b/crates/server/sql/migrations/0000003_enhance_media_catalog/down.sql @@ -0,0 +1,79 @@ +PRAGMA foreign_keys=off; + +CREATE TABLE media_files_previous ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + library_id INTEGER NOT NULL, + relative_path TEXT NOT NULL, + file_size BIGINT NOT NULL, + modified_at BIGINT DEFAULT NULL, + media_kind TEXT NOT NULL, + fingerprint_seed TEXT NOT NULL, + FOREIGN KEY (library_id) REFERENCES media_libraries(id) ON DELETE CASCADE, + UNIQUE (library_id, relative_path) +); + +INSERT INTO media_files_previous ( + id, + library_id, + relative_path, + file_size, + modified_at, + media_kind, + fingerprint_seed +) +SELECT + id, + library_id, + relative_path, + file_size, + modified_at, + media_kind, + fingerprint_seed +FROM media_files; + +DROP TABLE media_files; +ALTER TABLE media_files_previous RENAME TO media_files; + +CREATE TABLE scan_state_previous ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + library_id INTEGER NOT NULL UNIQUE, + last_status TEXT NOT NULL DEFAULT 'never_scanned', + last_error TEXT DEFAULT NULL, + total_files BIGINT NOT NULL DEFAULT 0, + video_files BIGINT NOT NULL DEFAULT 0, + audio_files BIGINT NOT NULL DEFAULT 0, + image_files BIGINT NOT NULL DEFAULT 0, + book_files BIGINT NOT NULL DEFAULT 0, + other_files BIGINT NOT NULL DEFAULT 0, + FOREIGN KEY (library_id) REFERENCES media_libraries(id) ON DELETE CASCADE +); + +INSERT INTO scan_state_previous ( + id, + library_id, + last_status, + last_error, + total_files, + video_files, + audio_files, + image_files, + book_files, + other_files +) +SELECT + id, + library_id, + last_status, + last_error, + total_files, + video_files, + audio_files, + image_files, + book_files, + other_files +FROM scan_state; + +DROP TABLE scan_state; +ALTER TABLE scan_state_previous RENAME TO scan_state; + +PRAGMA foreign_keys=on; diff --git a/crates/server/sql/migrations/0000003_enhance_media_catalog/up.sql b/crates/server/sql/migrations/0000003_enhance_media_catalog/up.sql new file mode 100644 index 00000000..7e7cad1d --- /dev/null +++ b/crates/server/sql/migrations/0000003_enhance_media_catalog/up.sql @@ -0,0 +1,14 @@ +ALTER TABLE scan_state ADD COLUMN scan_revision BIGINT NOT NULL DEFAULT 0; +ALTER TABLE scan_state ADD COLUMN last_scanned_at BIGINT DEFAULT NULL; + +ALTER TABLE media_files ADD COLUMN display_title TEXT DEFAULT NULL; +ALTER TABLE media_files ADD COLUMN container TEXT DEFAULT NULL; +ALTER TABLE media_files ADD COLUMN duration_ms BIGINT DEFAULT NULL; +ALTER TABLE media_files ADD COLUMN bit_rate BIGINT DEFAULT NULL; +ALTER TABLE media_files ADD COLUMN width INTEGER DEFAULT NULL; +ALTER TABLE media_files ADD COLUMN height INTEGER DEFAULT NULL; +ALTER TABLE media_files ADD COLUMN video_codec TEXT DEFAULT NULL; +ALTER TABLE media_files ADD COLUMN audio_codec TEXT DEFAULT NULL; +ALTER TABLE media_files ADD COLUMN metadata_json TEXT DEFAULT NULL; +ALTER TABLE media_files ADD COLUMN metadata_updated_at BIGINT DEFAULT NULL; + diff --git a/crates/server/sql/migrations/0000004_create_item_metadata_links/down.sql b/crates/server/sql/migrations/0000004_create_item_metadata_links/down.sql new file mode 100644 index 00000000..14c9b068 --- /dev/null +++ b/crates/server/sql/migrations/0000004_create_item_metadata_links/down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS item_metadata_links; + diff --git a/crates/server/sql/migrations/0000004_create_item_metadata_links/up.sql b/crates/server/sql/migrations/0000004_create_item_metadata_links/up.sql new file mode 100644 index 00000000..a20316ba --- /dev/null +++ b/crates/server/sql/migrations/0000004_create_item_metadata_links/up.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS item_metadata_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + media_file_id INTEGER NOT NULL, + provider_id TEXT NOT NULL, + external_id TEXT NOT NULL, + title TEXT DEFAULT NULL, + overview TEXT DEFAULT NULL, + artwork_url TEXT DEFAULT NULL, + backdrop_url TEXT DEFAULT NULL, + release_year INTEGER DEFAULT NULL, + match_state TEXT NOT NULL DEFAULT 'unmatched', + updated_at BIGINT DEFAULT NULL, + FOREIGN KEY (media_file_id) REFERENCES media_files(id) ON DELETE CASCADE, + UNIQUE (media_file_id, provider_id) +); + diff --git a/crates/server/sql/migrations/0000005_extend_metadata_and_playback/down.sql b/crates/server/sql/migrations/0000005_extend_metadata_and_playback/down.sql new file mode 100644 index 00000000..f38ed09d --- /dev/null +++ b/crates/server/sql/migrations/0000005_extend_metadata_and_playback/down.sql @@ -0,0 +1,52 @@ +PRAGMA foreign_keys=off; + +DROP TABLE IF EXISTS playback_progress; + +CREATE TABLE item_metadata_links_previous ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + media_file_id INTEGER NOT NULL, + provider_id TEXT NOT NULL, + external_id TEXT NOT NULL, + title TEXT DEFAULT NULL, + overview TEXT DEFAULT NULL, + artwork_url TEXT DEFAULT NULL, + backdrop_url TEXT DEFAULT NULL, + release_year INTEGER DEFAULT NULL, + match_state TEXT NOT NULL DEFAULT 'unmatched', + updated_at BIGINT DEFAULT NULL, + FOREIGN KEY (media_file_id) REFERENCES media_files(id) ON DELETE CASCADE, + UNIQUE (media_file_id, provider_id) +); + +INSERT INTO item_metadata_links_previous ( + id, + media_file_id, + provider_id, + external_id, + title, + overview, + artwork_url, + backdrop_url, + release_year, + match_state, + updated_at +) +SELECT + id, + media_file_id, + provider_id, + external_id, + title, + overview, + artwork_url, + backdrop_url, + release_year, + match_state, + updated_at +FROM item_metadata_links; + +DROP TABLE item_metadata_links; +ALTER TABLE item_metadata_links_previous RENAME TO item_metadata_links; + +PRAGMA foreign_keys=on; + diff --git a/crates/server/sql/migrations/0000005_extend_metadata_and_playback/up.sql b/crates/server/sql/migrations/0000005_extend_metadata_and_playback/up.sql new file mode 100644 index 00000000..111c7908 --- /dev/null +++ b/crates/server/sql/migrations/0000005_extend_metadata_and_playback/up.sql @@ -0,0 +1,15 @@ +ALTER TABLE item_metadata_links ADD COLUMN media_type TEXT DEFAULT NULL; +ALTER TABLE item_metadata_links ADD COLUMN provider_payload_json TEXT DEFAULT NULL; +ALTER TABLE item_metadata_links ADD COLUMN cached_artwork_path TEXT DEFAULT NULL; +ALTER TABLE item_metadata_links ADD COLUMN cached_backdrop_path TEXT DEFAULT NULL; + +CREATE TABLE IF NOT EXISTS playback_progress ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + media_file_id INTEGER NOT NULL UNIQUE, + position_ms BIGINT NOT NULL DEFAULT 0, + duration_ms BIGINT DEFAULT NULL, + completed BOOLEAN NOT NULL DEFAULT FALSE, + updated_at BIGINT DEFAULT NULL, + FOREIGN KEY (media_file_id) REFERENCES media_files(id) ON DELETE CASCADE +); + diff --git a/crates/server/sql/migrations/0000006_add_media_source_roots/down.sql b/crates/server/sql/migrations/0000006_add_media_source_roots/down.sql new file mode 100644 index 00000000..cc75ebf2 --- /dev/null +++ b/crates/server/sql/migrations/0000006_add_media_source_roots/down.sql @@ -0,0 +1,68 @@ +PRAGMA foreign_keys=off; + +CREATE TABLE media_files_previous ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + library_id INTEGER NOT NULL, + relative_path TEXT NOT NULL, + file_size BIGINT NOT NULL, + modified_at BIGINT DEFAULT NULL, + media_kind TEXT NOT NULL, + fingerprint_seed TEXT NOT NULL, + display_title TEXT DEFAULT NULL, + container TEXT DEFAULT NULL, + duration_ms BIGINT DEFAULT NULL, + bit_rate BIGINT DEFAULT NULL, + width INTEGER DEFAULT NULL, + height INTEGER DEFAULT NULL, + video_codec TEXT DEFAULT NULL, + audio_codec TEXT DEFAULT NULL, + metadata_json TEXT DEFAULT NULL, + metadata_updated_at BIGINT DEFAULT NULL, + FOREIGN KEY (library_id) REFERENCES media_libraries(id) ON DELETE CASCADE, + UNIQUE (library_id, relative_path) +); + +INSERT INTO media_files_previous ( + id, + library_id, + relative_path, + file_size, + modified_at, + media_kind, + fingerprint_seed, + display_title, + container, + duration_ms, + bit_rate, + width, + height, + video_codec, + audio_codec, + metadata_json, + metadata_updated_at +) +SELECT + id, + library_id, + relative_path, + file_size, + modified_at, + media_kind, + fingerprint_seed, + display_title, + container, + duration_ms, + bit_rate, + width, + height, + video_codec, + audio_codec, + metadata_json, + metadata_updated_at +FROM media_files; + +DROP TABLE media_files; +ALTER TABLE media_files_previous RENAME TO media_files; + +PRAGMA foreign_keys=on; + diff --git a/crates/server/sql/migrations/0000006_add_media_source_roots/up.sql b/crates/server/sql/migrations/0000006_add_media_source_roots/up.sql new file mode 100644 index 00000000..c8c9d236 --- /dev/null +++ b/crates/server/sql/migrations/0000006_add_media_source_roots/up.sql @@ -0,0 +1,71 @@ +PRAGMA foreign_keys=off; + +CREATE TABLE media_files_next ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + library_id INTEGER NOT NULL, + source_root_path TEXT NOT NULL DEFAULT '', + relative_path TEXT NOT NULL, + file_size BIGINT NOT NULL, + modified_at BIGINT DEFAULT NULL, + media_kind TEXT NOT NULL, + fingerprint_seed TEXT NOT NULL, + display_title TEXT DEFAULT NULL, + container TEXT DEFAULT NULL, + duration_ms BIGINT DEFAULT NULL, + bit_rate BIGINT DEFAULT NULL, + width INTEGER DEFAULT NULL, + height INTEGER DEFAULT NULL, + video_codec TEXT DEFAULT NULL, + audio_codec TEXT DEFAULT NULL, + metadata_json TEXT DEFAULT NULL, + metadata_updated_at BIGINT DEFAULT NULL, + FOREIGN KEY (library_id) REFERENCES media_libraries(id) ON DELETE CASCADE, + UNIQUE (library_id, source_root_path, relative_path) +); + +INSERT INTO media_files_next ( + id, + library_id, + source_root_path, + relative_path, + file_size, + modified_at, + media_kind, + fingerprint_seed, + display_title, + container, + duration_ms, + bit_rate, + width, + height, + video_codec, + audio_codec, + metadata_json, + metadata_updated_at +) +SELECT + id, + library_id, + '', + relative_path, + file_size, + modified_at, + media_kind, + fingerprint_seed, + display_title, + container, + duration_ms, + bit_rate, + width, + height, + video_codec, + audio_codec, + metadata_json, + metadata_updated_at +FROM media_files; + +DROP TABLE media_files; +ALTER TABLE media_files_next RENAME TO media_files; + +PRAGMA foreign_keys=on; + diff --git a/crates/server/sql/migrations/0000007_store_library_settings_in_db/down.sql b/crates/server/sql/migrations/0000007_store_library_settings_in_db/down.sql new file mode 100644 index 00000000..fe4c9840 --- /dev/null +++ b/crates/server/sql/migrations/0000007_store_library_settings_in_db/down.sql @@ -0,0 +1,30 @@ +PRAGMA foreign_keys=off; + +CREATE TABLE media_libraries_previous ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + path TEXT NOT NULL UNIQUE, + kind TEXT NOT NULL, + recursive BOOLEAN NOT NULL DEFAULT TRUE +); + +INSERT INTO media_libraries_previous ( + id, + name, + path, + kind, + recursive +) +SELECT + id, + name, + path, + kind, + recursive +FROM media_libraries; + +DROP TABLE media_libraries; +ALTER TABLE media_libraries_previous RENAME TO media_libraries; + +PRAGMA foreign_keys=on; + diff --git a/crates/server/sql/migrations/0000007_store_library_settings_in_db/up.sql b/crates/server/sql/migrations/0000007_store_library_settings_in_db/up.sql new file mode 100644 index 00000000..c9fc55cb --- /dev/null +++ b/crates/server/sql/migrations/0000007_store_library_settings_in_db/up.sql @@ -0,0 +1,11 @@ +ALTER TABLE media_libraries ADD COLUMN paths_json TEXT NOT NULL DEFAULT '[]'; +ALTER TABLE media_libraries ADD COLUMN metadata_providers_json TEXT NOT NULL DEFAULT '["tmdb"]'; + +UPDATE media_libraries +SET paths_json = json_array(path) +WHERE TRIM(COALESCE(paths_json, '')) = '' OR paths_json = '[]'; + +UPDATE media_libraries +SET metadata_providers_json = '["tmdb"]' +WHERE TRIM(COALESCE(metadata_providers_json, '')) = ''; + diff --git a/crates/server/sql/migrations/0000008_normalize_legacy_metadata_provider_ids/down.sql b/crates/server/sql/migrations/0000008_normalize_legacy_metadata_provider_ids/down.sql new file mode 100644 index 00000000..5348575c --- /dev/null +++ b/crates/server/sql/migrations/0000008_normalize_legacy_metadata_provider_ids/down.sql @@ -0,0 +1,8 @@ +UPDATE item_metadata_links +SET provider_id = 'music_brainz' +WHERE provider_id = 'musicbrainz'; + +UPDATE media_libraries +SET metadata_providers_json = REPLACE(metadata_providers_json, 'musicbrainz', 'music_brainz') +WHERE metadata_providers_json LIKE '%musicbrainz%'; + diff --git a/crates/server/sql/migrations/0000008_normalize_legacy_metadata_provider_ids/up.sql b/crates/server/sql/migrations/0000008_normalize_legacy_metadata_provider_ids/up.sql new file mode 100644 index 00000000..fbd93c8b --- /dev/null +++ b/crates/server/sql/migrations/0000008_normalize_legacy_metadata_provider_ids/up.sql @@ -0,0 +1,8 @@ +UPDATE item_metadata_links +SET provider_id = 'musicbrainz' +WHERE provider_id = 'music_brainz'; + +UPDATE media_libraries +SET metadata_providers_json = REPLACE(metadata_providers_json, 'music_brainz', 'musicbrainz') +WHERE metadata_providers_json LIKE '%music_brainz%'; + diff --git a/crates/server/sql/migrations/0000009_track_metadata_match_attempts/down.sql b/crates/server/sql/migrations/0000009_track_metadata_match_attempts/down.sql new file mode 100644 index 00000000..3569768f --- /dev/null +++ b/crates/server/sql/migrations/0000009_track_metadata_match_attempts/down.sql @@ -0,0 +1,71 @@ +PRAGMA foreign_keys=off; + +CREATE TABLE media_files_previous ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + library_id INTEGER NOT NULL, + source_root_path TEXT NOT NULL DEFAULT '', + relative_path TEXT NOT NULL, + file_size BIGINT NOT NULL, + modified_at BIGINT DEFAULT NULL, + media_kind TEXT NOT NULL, + fingerprint_seed TEXT NOT NULL, + display_title TEXT DEFAULT NULL, + container TEXT DEFAULT NULL, + duration_ms BIGINT DEFAULT NULL, + bit_rate BIGINT DEFAULT NULL, + width INTEGER DEFAULT NULL, + height INTEGER DEFAULT NULL, + video_codec TEXT DEFAULT NULL, + audio_codec TEXT DEFAULT NULL, + metadata_json TEXT DEFAULT NULL, + metadata_updated_at BIGINT DEFAULT NULL, + FOREIGN KEY (library_id) REFERENCES media_libraries(id) ON DELETE CASCADE, + UNIQUE (library_id, source_root_path, relative_path) +); + +INSERT INTO media_files_previous ( + id, + library_id, + source_root_path, + relative_path, + file_size, + modified_at, + media_kind, + fingerprint_seed, + display_title, + container, + duration_ms, + bit_rate, + width, + height, + video_codec, + audio_codec, + metadata_json, + metadata_updated_at +) +SELECT + id, + library_id, + source_root_path, + relative_path, + file_size, + modified_at, + media_kind, + fingerprint_seed, + display_title, + container, + duration_ms, + bit_rate, + width, + height, + video_codec, + audio_codec, + metadata_json, + metadata_updated_at +FROM media_files; + +DROP TABLE media_files; +ALTER TABLE media_files_previous RENAME TO media_files; + +PRAGMA foreign_keys=on; + diff --git a/crates/server/sql/migrations/0000009_track_metadata_match_attempts/up.sql b/crates/server/sql/migrations/0000009_track_metadata_match_attempts/up.sql new file mode 100644 index 00000000..54b8ca63 --- /dev/null +++ b/crates/server/sql/migrations/0000009_track_metadata_match_attempts/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE media_files ADD COLUMN metadata_match_attempted_at BIGINT DEFAULT NULL; + diff --git a/crates/server/sql/migrations/0000010_scope_playback_progress_to_user/down.sql b/crates/server/sql/migrations/0000010_scope_playback_progress_to_user/down.sql new file mode 100644 index 00000000..2498351d --- /dev/null +++ b/crates/server/sql/migrations/0000010_scope_playback_progress_to_user/down.sql @@ -0,0 +1,35 @@ +PRAGMA foreign_keys=off; + +CREATE TABLE playback_progress_previous ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + media_file_id INTEGER NOT NULL UNIQUE, + position_ms BIGINT NOT NULL DEFAULT 0, + duration_ms BIGINT DEFAULT NULL, + completed BOOLEAN NOT NULL DEFAULT FALSE, + updated_at BIGINT DEFAULT NULL, + FOREIGN KEY (media_file_id) REFERENCES media_files(id) ON DELETE CASCADE +); + +INSERT INTO playback_progress_previous ( + id, + media_file_id, + position_ms, + duration_ms, + completed, + updated_at +) +SELECT + MIN(id), + media_file_id, + position_ms, + duration_ms, + completed, + updated_at +FROM playback_progress +GROUP BY media_file_id; + +DROP TABLE playback_progress; +ALTER TABLE playback_progress_previous RENAME TO playback_progress; + +PRAGMA foreign_keys=on; + diff --git a/crates/server/sql/migrations/0000010_scope_playback_progress_to_user/up.sql b/crates/server/sql/migrations/0000010_scope_playback_progress_to_user/up.sql new file mode 100644 index 00000000..2b2a0703 --- /dev/null +++ b/crates/server/sql/migrations/0000010_scope_playback_progress_to_user/up.sql @@ -0,0 +1,39 @@ +PRAGMA foreign_keys=off; + +CREATE TABLE playback_progress_next ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER DEFAULT NULL, + media_file_id INTEGER NOT NULL, + position_ms BIGINT NOT NULL DEFAULT 0, + duration_ms BIGINT DEFAULT NULL, + completed BOOLEAN NOT NULL DEFAULT FALSE, + updated_at BIGINT DEFAULT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (media_file_id) REFERENCES media_files(id) ON DELETE CASCADE, + UNIQUE (user_id, media_file_id) +); + +INSERT INTO playback_progress_next ( + id, + user_id, + media_file_id, + position_ms, + duration_ms, + completed, + updated_at +) +SELECT + id, + NULL, + media_file_id, + position_ms, + duration_ms, + completed, + updated_at +FROM playback_progress; + +DROP TABLE playback_progress; +ALTER TABLE playback_progress_next RENAME TO playback_progress; + +PRAGMA foreign_keys=on; + diff --git a/crates/server/sql/migrations/0000011_logical_media_items_and_metadata_hierarchy/down.sql b/crates/server/sql/migrations/0000011_logical_media_items_and_metadata_hierarchy/down.sql new file mode 100644 index 00000000..1937f31a --- /dev/null +++ b/crates/server/sql/migrations/0000011_logical_media_items_and_metadata_hierarchy/down.sql @@ -0,0 +1,172 @@ +PRAGMA foreign_keys=off; + +CREATE TABLE playback_progress_prev ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER DEFAULT NULL, + media_file_id INTEGER NOT NULL, + position_ms BIGINT NOT NULL DEFAULT 0, + duration_ms BIGINT DEFAULT NULL, + completed BOOLEAN NOT NULL DEFAULT FALSE, + updated_at BIGINT DEFAULT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (media_file_id) REFERENCES media_files(id) ON DELETE CASCADE, + UNIQUE (user_id, media_file_id) +); + +INSERT INTO playback_progress_prev ( + id, + user_id, + media_file_id, + position_ms, + duration_ms, + completed, + updated_at +) +SELECT + progress.id, + progress.user_id, + COALESCE(files.id, progress.media_item_id), + progress.position_ms, + progress.duration_ms, + progress.completed, + progress.updated_at +FROM playback_progress AS progress +LEFT JOIN media_files AS files ON files.media_item_id = progress.media_item_id; + +DROP TABLE playback_progress; +ALTER TABLE playback_progress_prev RENAME TO playback_progress; + +DROP TABLE item_metadata_people; +DROP TABLE item_metadata_collections; + +CREATE TABLE item_metadata_links_prev ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + media_file_id INTEGER NOT NULL, + provider_id TEXT NOT NULL, + external_id TEXT NOT NULL, + title TEXT DEFAULT NULL, + overview TEXT DEFAULT NULL, + artwork_url TEXT DEFAULT NULL, + backdrop_url TEXT DEFAULT NULL, + release_year INTEGER DEFAULT NULL, + media_type TEXT DEFAULT NULL, + match_state TEXT NOT NULL, + provider_payload_json TEXT DEFAULT NULL, + cached_artwork_path TEXT DEFAULT NULL, + cached_backdrop_path TEXT DEFAULT NULL, + updated_at BIGINT DEFAULT NULL, + FOREIGN KEY (media_file_id) REFERENCES media_files(id) ON DELETE CASCADE +); + +INSERT INTO item_metadata_links_prev ( + id, + media_file_id, + provider_id, + external_id, + title, + overview, + artwork_url, + backdrop_url, + release_year, + media_type, + match_state, + provider_payload_json, + cached_artwork_path, + cached_backdrop_path, + updated_at +) +SELECT + links.id, + COALESCE(files.id, links.media_item_id), + links.provider_id, + links.external_id, + links.title, + links.overview, + links.artwork_url, + links.backdrop_url, + links.release_year, + links.media_type, + links.match_state, + links.provider_payload_json, + links.cached_artwork_path, + links.cached_backdrop_path, + links.updated_at +FROM item_metadata_links AS links +LEFT JOIN media_files AS files ON files.media_item_id = links.media_item_id; + +DROP TABLE item_metadata_links; +ALTER TABLE item_metadata_links_prev RENAME TO item_metadata_links; + +CREATE TABLE media_files_prev ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + library_id INTEGER NOT NULL, + source_root_path TEXT NOT NULL, + relative_path TEXT NOT NULL, + file_size BIGINT NOT NULL, + modified_at BIGINT DEFAULT NULL, + media_kind TEXT NOT NULL, + fingerprint_seed TEXT NOT NULL, + display_title TEXT DEFAULT NULL, + container TEXT DEFAULT NULL, + duration_ms BIGINT DEFAULT NULL, + bit_rate BIGINT DEFAULT NULL, + width INTEGER DEFAULT NULL, + height INTEGER DEFAULT NULL, + video_codec TEXT DEFAULT NULL, + audio_codec TEXT DEFAULT NULL, + metadata_json TEXT DEFAULT NULL, + metadata_updated_at BIGINT DEFAULT NULL, + metadata_match_attempted_at BIGINT DEFAULT NULL, + FOREIGN KEY (library_id) REFERENCES media_libraries(id) ON DELETE CASCADE +); + +INSERT INTO media_files_prev ( + id, + library_id, + source_root_path, + relative_path, + file_size, + modified_at, + media_kind, + fingerprint_seed, + display_title, + container, + duration_ms, + bit_rate, + width, + height, + video_codec, + audio_codec, + metadata_json, + metadata_updated_at, + metadata_match_attempted_at +) +SELECT + id, + library_id, + source_root_path, + relative_path, + file_size, + modified_at, + media_kind, + fingerprint_seed, + display_title, + container, + duration_ms, + bit_rate, + width, + height, + video_codec, + audio_codec, + metadata_json, + metadata_updated_at, + metadata_match_attempted_at +FROM media_files; + +DROP TABLE media_files; +ALTER TABLE media_files_prev RENAME TO media_files; + +DROP TABLE media_items; + +PRAGMA foreign_keys=on; + diff --git a/crates/server/sql/migrations/0000011_logical_media_items_and_metadata_hierarchy/up.sql b/crates/server/sql/migrations/0000011_logical_media_items_and_metadata_hierarchy/up.sql new file mode 100644 index 00000000..0395bf44 --- /dev/null +++ b/crates/server/sql/migrations/0000011_logical_media_items_and_metadata_hierarchy/up.sql @@ -0,0 +1,300 @@ +PRAGMA foreign_keys=off; + +CREATE TABLE media_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + library_id INTEGER NOT NULL, + parent_id INTEGER DEFAULT NULL, + identity_key TEXT NOT NULL UNIQUE, + item_type TEXT NOT NULL, + display_title TEXT NOT NULL, + relative_path TEXT DEFAULT NULL, + media_kind TEXT DEFAULT NULL, + season_number INTEGER DEFAULT NULL, + episode_number INTEGER DEFAULT NULL, + child_count INTEGER NOT NULL DEFAULT 0, + playable BOOLEAN NOT NULL DEFAULT FALSE, + file_size BIGINT DEFAULT NULL, + duration_ms BIGINT DEFAULT NULL, + modified_at BIGINT DEFAULT NULL, + created_at BIGINT DEFAULT NULL, + updated_at BIGINT DEFAULT NULL, + FOREIGN KEY (library_id) REFERENCES media_libraries(id) ON DELETE CASCADE, + FOREIGN KEY (parent_id) REFERENCES media_items(id) ON DELETE CASCADE +); + +INSERT INTO media_items ( + id, + library_id, + parent_id, + identity_key, + item_type, + display_title, + relative_path, + media_kind, + season_number, + episode_number, + child_count, + playable, + file_size, + duration_ms, + modified_at, + created_at, + updated_at +) +SELECT + id, + library_id, + NULL, + 'legacy-file-' || id, + CASE + WHEN media_kind = 'audio' THEN 'track' + WHEN media_kind = 'image' THEN 'photo' + WHEN media_kind = 'book' THEN 'book' + ELSE 'movie' + END, + COALESCE(display_title, relative_path), + relative_path, + media_kind, + NULL, + NULL, + 0, + CASE + WHEN media_kind IN ('video', 'audio') THEN TRUE + ELSE FALSE + END, + file_size, + duration_ms, + modified_at, + modified_at, + modified_at +FROM media_files; + +CREATE TABLE media_files_next ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + library_id INTEGER NOT NULL, + source_root_path TEXT NOT NULL, + relative_path TEXT NOT NULL, + file_size BIGINT NOT NULL, + modified_at BIGINT DEFAULT NULL, + media_kind TEXT NOT NULL, + fingerprint_seed TEXT NOT NULL, + display_title TEXT DEFAULT NULL, + container TEXT DEFAULT NULL, + duration_ms BIGINT DEFAULT NULL, + bit_rate BIGINT DEFAULT NULL, + width INTEGER DEFAULT NULL, + height INTEGER DEFAULT NULL, + video_codec TEXT DEFAULT NULL, + audio_codec TEXT DEFAULT NULL, + metadata_json TEXT DEFAULT NULL, + metadata_updated_at BIGINT DEFAULT NULL, + metadata_match_attempted_at BIGINT DEFAULT NULL, + media_item_id INTEGER DEFAULT NULL, + FOREIGN KEY (library_id) REFERENCES media_libraries(id) ON DELETE CASCADE, + FOREIGN KEY (media_item_id) REFERENCES media_items(id) ON DELETE SET NULL +); + +INSERT INTO media_files_next ( + id, + library_id, + source_root_path, + relative_path, + file_size, + modified_at, + media_kind, + fingerprint_seed, + display_title, + container, + duration_ms, + bit_rate, + width, + height, + video_codec, + audio_codec, + metadata_json, + metadata_updated_at, + metadata_match_attempted_at, + media_item_id +) +SELECT + id, + library_id, + source_root_path, + relative_path, + file_size, + modified_at, + media_kind, + fingerprint_seed, + display_title, + container, + duration_ms, + bit_rate, + width, + height, + video_codec, + audio_codec, + metadata_json, + metadata_updated_at, + metadata_match_attempted_at, + id +FROM media_files; + +DROP TABLE media_files; +ALTER TABLE media_files_next RENAME TO media_files; + +CREATE TABLE item_metadata_links_next ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + media_item_id INTEGER NOT NULL, + provider_id TEXT NOT NULL, + external_id TEXT NOT NULL, + title TEXT DEFAULT NULL, + overview TEXT DEFAULT NULL, + tagline TEXT DEFAULT NULL, + artwork_url TEXT DEFAULT NULL, + backdrop_url TEXT DEFAULT NULL, + release_year INTEGER DEFAULT NULL, + media_type TEXT DEFAULT NULL, + relation_kind TEXT NOT NULL DEFAULT 'primary', + match_state TEXT NOT NULL, + provider_payload_json TEXT DEFAULT NULL, + cached_artwork_path TEXT DEFAULT NULL, + cached_backdrop_path TEXT DEFAULT NULL, + refresh_state TEXT NOT NULL DEFAULT 'fresh', + refresh_interval_seconds BIGINT NOT NULL DEFAULT 604800, + last_refreshed_at BIGINT DEFAULT NULL, + next_refresh_at BIGINT DEFAULT NULL, + refresh_error TEXT DEFAULT NULL, + updated_at BIGINT DEFAULT NULL, + FOREIGN KEY (media_item_id) REFERENCES media_items(id) ON DELETE CASCADE, + UNIQUE (media_item_id, provider_id, relation_kind) +); + +INSERT INTO item_metadata_links_next ( + id, + media_item_id, + provider_id, + external_id, + title, + overview, + tagline, + artwork_url, + backdrop_url, + release_year, + media_type, + relation_kind, + match_state, + provider_payload_json, + cached_artwork_path, + cached_backdrop_path, + refresh_state, + refresh_interval_seconds, + last_refreshed_at, + next_refresh_at, + refresh_error, + updated_at +) +SELECT + id, + media_file_id, + provider_id, + external_id, + title, + overview, + NULL, + artwork_url, + backdrop_url, + release_year, + media_type, + 'primary', + match_state, + provider_payload_json, + cached_artwork_path, + cached_backdrop_path, + 'fresh', + 604800, + updated_at, + CASE + WHEN updated_at IS NULL THEN NULL + ELSE updated_at + 604800 + END, + NULL, + updated_at +FROM item_metadata_links; + +DROP TABLE item_metadata_links; +ALTER TABLE item_metadata_links_next RENAME TO item_metadata_links; + +CREATE TABLE item_metadata_people ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + metadata_link_id INTEGER NOT NULL, + external_id TEXT DEFAULT NULL, + name TEXT NOT NULL, + role TEXT DEFAULT NULL, + department TEXT DEFAULT NULL, + character_name TEXT DEFAULT NULL, + profile_url TEXT DEFAULT NULL, + image_url TEXT DEFAULT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (metadata_link_id) REFERENCES item_metadata_links(id) ON DELETE CASCADE +); + +CREATE TABLE item_metadata_collections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + metadata_link_id INTEGER NOT NULL, + provider_id TEXT NOT NULL, + external_id TEXT NOT NULL, + name TEXT NOT NULL, + overview TEXT DEFAULT NULL, + artwork_url TEXT DEFAULT NULL, + backdrop_url TEXT DEFAULT NULL, + provider_payload_json TEXT DEFAULT NULL, + updated_at BIGINT DEFAULT NULL, + FOREIGN KEY (metadata_link_id) REFERENCES item_metadata_links(id) ON DELETE CASCADE, + UNIQUE (metadata_link_id, provider_id, external_id) +); + +CREATE TABLE playback_progress_next ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER DEFAULT NULL, + media_item_id INTEGER NOT NULL, + position_ms BIGINT NOT NULL DEFAULT 0, + duration_ms BIGINT DEFAULT NULL, + completed BOOLEAN NOT NULL DEFAULT FALSE, + updated_at BIGINT DEFAULT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (media_item_id) REFERENCES media_items(id) ON DELETE CASCADE, + UNIQUE (user_id, media_item_id) +); + +INSERT INTO playback_progress_next ( + id, + user_id, + media_item_id, + position_ms, + duration_ms, + completed, + updated_at +) +SELECT + id, + user_id, + media_file_id, + position_ms, + duration_ms, + completed, + updated_at +FROM playback_progress; + +DROP TABLE playback_progress; +ALTER TABLE playback_progress_next RENAME TO playback_progress; + +CREATE INDEX idx_media_items_library_parent ON media_items (library_id, parent_id); +CREATE INDEX idx_media_items_identity_key ON media_items (identity_key); +CREATE INDEX idx_media_files_media_item_id ON media_files (media_item_id); +CREATE INDEX idx_item_metadata_links_media_item_id ON item_metadata_links (media_item_id); +CREATE INDEX idx_item_metadata_people_link_id ON item_metadata_people (metadata_link_id); +CREATE INDEX idx_item_metadata_collections_link_id ON item_metadata_collections (metadata_link_id); +CREATE INDEX idx_playback_progress_media_item_id ON playback_progress (media_item_id); + +PRAGMA foreign_keys=on; + diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs index 621f7148..0eac18e6 100644 --- a/crates/server/src/config.rs +++ b/crates/server/src/config.rs @@ -1,24 +1,268 @@ //! Configuration module for the application. +// standard imports +use std::fs; +use std::path::PathBuf; +use std::sync::RwLock; + // lib imports use config::{Config, ConfigError, Environment, File}; use dirs::config_local_dir; use once_cell::sync::Lazy; +use schemars::JsonSchema; use serde::Deserialize; +use serde::Serialize; // local imports use crate::globals::GLOBAL_APP_NAME; /// General settings. -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq)] pub struct GeneralSettings { /// The directory where application data is stored. #[serde(default)] pub data_dir: String, } +/// Supported library categories for configured media roots. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum MediaLibraryKind { + /// Mixed content when the library is not limited to a single media type. + #[default] + Mixed, + /// Feature films and similar long-form video content. + Movies, + /// Episodic television or serialized video content. + Shows, + /// Music, albums, and other audio-focused content. + Music, + /// Photos and other image collections. + Photos, + /// Books, comics, PDFs, and other reading material. + Books, + /// Home videos and other personal recordings. + HomeVideos, +} + +fn default_recursive_scan() -> bool { + true +} + +fn default_ffmpeg_path() -> String { + "ffmpeg".into() +} + +fn default_ffprobe_path() -> String { + "ffprobe".into() +} + +fn default_metadata_language() -> String { + "en-US".into() +} + +fn default_provider_enabled() -> bool { + true +} + +fn default_provider_rate_limit_per_second() -> u32 { + 4 +} + +fn default_provider_retry_attempts() -> u32 { + 3 +} + +fn default_provider_retry_backoff_ms() -> u32 { + 1_000 +} + +fn default_library_metadata_providers() -> Vec { + vec![MetadataProviderId::Tmdb] +} + +fn normalized_unique_strings(values: impl IntoIterator) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut normalized = Vec::new(); + + for value in values { + let trimmed = value.trim(); + if trimmed.is_empty() { + continue; + } + + let owned = trimmed.to_string(); + if seen.insert(owned.clone()) { + normalized.push(owned); + } + } + + normalized +} + +/// A configured media-library root. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq, Default)] +pub struct MediaLibrarySettings { + /// Human-friendly library name. + #[serde(default)] + pub name: String, + /// Filesystem path to the media-library root. + #[serde(default)] + pub path: String, + /// Filesystem paths for one logical library when multiple roots are configured. + #[serde(default)] + pub paths: Vec, + /// Whether the scanner should recurse into subdirectories. + #[serde(default = "default_recursive_scan")] + pub recursive: bool, + /// The intended media category for the library. + #[serde(default)] + pub kind: MediaLibraryKind, + /// Ordered metadata providers to use for this library. + #[serde(default = "default_library_metadata_providers")] + pub metadata_providers: Vec, +} + +impl MediaLibrarySettings { + /// Return all configured filesystem roots for this logical library. + pub fn configured_paths(&self) -> Vec { + normalized_unique_strings( + std::iter::once(self.path.clone()).chain(self.paths.iter().cloned()), + ) + } + + /// Return the first configured filesystem root for this library, when present. + pub fn primary_path(&self) -> String { + self.configured_paths().into_iter().next().unwrap_or_default() + } + + /// Normalize path and provider settings for persistence. + pub fn normalize(&mut self) { + let normalized_paths = self.configured_paths(); + self.path = normalized_paths.first().cloned().unwrap_or_default(); + self.paths = normalized_paths; + self.metadata_providers = normalized_unique_strings( + self.metadata_providers + .iter() + .map(|provider| provider.as_storage_value().to_string()), + ) + .into_iter() + .filter_map(|value| MetadataProviderId::from_storage_value(&value)) + .collect(); + if self.metadata_providers.is_empty() { + self.metadata_providers = default_library_metadata_providers(); + } + } +} + +/// Media scanning settings. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq, Default)] +pub struct MediaSettings { + /// Configured media-library roots. + #[serde(default)] + pub libraries: Vec, +} + +/// Supported external metadata providers. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq, Hash, Default)] +#[serde(rename_all = "snake_case")] +pub enum MetadataProviderId { + /// TheMovieDB for movie and television metadata. + #[default] + Tmdb, + /// MusicBrainz for music-oriented metadata. + #[serde(rename = "musicbrainz")] + MusicBrainz, + /// Open Library for book metadata. + OpenLibrary, + /// Local NFO files and sidecar metadata. + LocalNfo, +} + +impl MetadataProviderId { + /// Return the stable storage value for this provider identifier. + pub fn as_storage_value(&self) -> &'static str { + match self { + MetadataProviderId::Tmdb => "tmdb", + MetadataProviderId::MusicBrainz => "musicbrainz", + MetadataProviderId::OpenLibrary => "open_library", + MetadataProviderId::LocalNfo => "local_nfo", + } + } + + /// Parse a provider identifier from a stored string value. + pub fn from_storage_value(value: &str) -> Option { + match value.trim() { + "tmdb" => Some(MetadataProviderId::Tmdb), + "musicbrainz" => Some(MetadataProviderId::MusicBrainz), + "open_library" => Some(MetadataProviderId::OpenLibrary), + "local_nfo" => Some(MetadataProviderId::LocalNfo), + _ => None, + } + } +} + +/// Configuration for one metadata provider. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq)] +pub struct MetadataProviderSettings { + /// Provider identifier. + #[serde(default)] + pub id: MetadataProviderId, + /// Whether this provider is enabled. + #[serde(default = "default_provider_enabled")] + pub enabled: bool, + /// Provider-specific API key or token, when required. + #[serde(default)] + pub api_key: Option, + /// Preferred language for metadata results. + #[serde(default = "default_metadata_language")] + pub language: String, + /// Maximum request rate the provider should use when making API calls. + #[serde(default = "default_provider_rate_limit_per_second")] + pub rate_limit_per_second: u32, + /// Maximum number of retry attempts after transient provider failures. + #[serde(default = "default_provider_retry_attempts")] + pub retry_attempts: u32, + /// Base retry backoff in milliseconds. + #[serde(default = "default_provider_retry_backoff_ms")] + pub retry_backoff_ms: u32, +} + +/// Metadata acquisition settings. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq)] +pub struct MetadataSettings { + /// Ordered list of enabled and optional providers. + #[serde(default)] + pub providers: Vec, +} + +/// FFmpeg integration strategy. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum FfmpegStrategy { + /// Use external FFmpeg and ffprobe executables. + #[default] + ExternalBinaries, + /// Reserve room for future embedded-library support if licensing allows. + EmbeddedLibrariesPlanned, +} + +/// FFmpeg-related tooling settings. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq)] +pub struct FfmpegSettings { + /// Licensing-safe FFmpeg integration strategy. + #[serde(default)] + pub strategy: FfmpegStrategy, + /// Path or command name for the FFmpeg executable. + #[serde(default = "default_ffmpeg_path")] + pub ffmpeg_path: String, + /// Path or command name for the ffprobe executable. + #[serde(default = "default_ffprobe_path")] + pub ffprobe_path: String, +} + /// Server settings. -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq)] pub struct ServerSettings { /// Whether to use HTTPS. #[serde(default)] @@ -41,14 +285,23 @@ pub struct ServerSettings { } /// Application settings. -#[derive(Debug, Deserialize, Default)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq, Default)] pub struct Settings { /// General settings. #[serde(default)] pub general: GeneralSettings, + /// Media settings. + #[serde(default)] + pub media: MediaSettings, + /// Metadata-provider settings. + #[serde(default)] + pub metadata: MetadataSettings, /// Server settings. #[serde(default)] pub server: ServerSettings, + /// FFmpeg tooling settings. + #[serde(default)] + pub ffmpeg: FfmpegSettings, } impl Default for GeneralSettings { @@ -78,11 +331,41 @@ impl Default for ServerSettings { } } +impl Default for FfmpegSettings { + fn default() -> Self { + Self { + strategy: FfmpegStrategy::ExternalBinaries, + ffmpeg_path: default_ffmpeg_path(), + ffprobe_path: default_ffprobe_path(), + } + } +} + +impl Default for MetadataProviderSettings { + fn default() -> Self { + Self { + id: MetadataProviderId::Tmdb, + enabled: default_provider_enabled(), + api_key: None, + language: default_metadata_language(), + rate_limit_per_second: default_provider_rate_limit_per_second(), + retry_attempts: default_provider_retry_attempts(), + retry_backoff_ms: default_provider_retry_backoff_ms(), + } + } +} + +impl Default for MetadataSettings { + fn default() -> Self { + Self { + providers: vec![MetadataProviderSettings::default()], + } + } +} + impl Settings { /// Create a new instance of `Settings`. pub fn new() -> Result { - // Start with defaults provided via set_default and then merge in any provided config file - // or environment variables. let config = Config::builder() .set_default("general.data_dir", GeneralSettings::default().data_dir)? .set_default("server.use_https", ServerSettings::default().use_https)? @@ -94,24 +377,12 @@ impl Settings { "server.use_custom_certs", ServerSettings::default().use_custom_certs, )? - // Add other configuration sources; values here will override the defaults. - .add_source( - File::with_name( - config_local_dir() - .unwrap() - .join(GLOBAL_APP_NAME) - .join("settings") - .to_str() - .unwrap(), - ) - .required(false), - ) + .add_source(File::with_name(settings_base_path().to_str().unwrap()).required(false)) .add_source(Environment::with_prefix( GLOBAL_APP_NAME.to_uppercase().as_str(), )) .build()?; - // Deserialize the configuration into our Settings struct. config.try_deserialize() } @@ -121,5 +392,74 @@ impl Settings { } } -/// Global settings for the application. -pub static GLOBAL_SETTINGS: Lazy = Lazy::new(Settings::load); +/// Normalize settings values before persistence or runtime replacement. +pub fn normalize_settings(settings: &mut Settings) { + for library in &mut settings.media.libraries { + library.normalize(); + } +} + +/// Return a settings snapshot suitable for YAML persistence. +pub fn settings_for_persistence(settings: &Settings) -> Settings { + let mut normalized = settings.clone(); + normalize_settings(&mut normalized); + normalized.media.libraries.clear(); + normalized +} + +fn settings_base_path() -> PathBuf { + settings_directory_path().join("settings") +} + +/// Return the settings directory path. +pub fn settings_directory_path() -> PathBuf { + if let Ok(path) = std::env::var("KOKO_SETTINGS_DIR") { + let path = path.trim(); + if !path.is_empty() { + return PathBuf::from(path); + } + } + + config_local_dir().unwrap().join(GLOBAL_APP_NAME) +} + +/// Return the YAML settings file path. +pub fn settings_file_path() -> PathBuf { + if let Ok(path) = std::env::var("KOKO_SETTINGS_PATH") { + let path = path.trim(); + if !path.is_empty() { + return PathBuf::from(path); + } + } + + settings_directory_path().join("settings.yml") +} + +/// Save settings to disk. +pub fn save_settings(settings: &Settings) -> Result<(), String> { + let normalized = settings_for_persistence(settings); + let settings_path = settings_file_path(); + if let Some(parent) = settings_path.parent() { + fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + + let yaml = serde_yaml::to_string(&normalized).map_err(|error| error.to_string())?; + fs::write(settings_path, yaml).map_err(|error| error.to_string()) +} + +/// Global mutable settings state for the application. +pub static CURRENT_SETTINGS: Lazy> = Lazy::new(|| RwLock::new(Settings::load())); + +/// Return a clone of the current in-memory settings. +pub fn current_settings() -> Settings { + let mut settings = CURRENT_SETTINGS.read().unwrap().clone(); + normalize_settings(&mut settings); + settings +} + +/// Replace the in-memory settings state. +pub fn replace_current_settings(settings: Settings) { + let mut normalized = settings; + normalize_settings(&mut normalized); + *CURRENT_SETTINGS.write().unwrap() = normalized; +} diff --git a/crates/server/src/db/mod.rs b/crates/server/src/db/mod.rs index 4719bf22..955f684f 100644 --- a/crates/server/src/db/mod.rs +++ b/crates/server/src/db/mod.rs @@ -4,10 +4,10 @@ pub(crate) mod models; pub(crate) mod schema; // lib imports +use diesel::connection::SimpleConnection; use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations}; use rocket::{ - Build, - Rocket, + Build, Rocket, fairing::{Fairing, Info, Kind}, }; use rocket_sync_db_pools::{database, diesel}; @@ -15,6 +15,16 @@ use rocket_sync_db_pools::{database, diesel}; /// Embedded migrations for the SQLite database. pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("sql/migrations"); +/// Apply SQLite pragmas that improve concurrency and reduce lock contention. +pub fn configure_sqlite_connection(conn: &mut diesel::SqliteConnection) -> diesel::result::QueryResult<()> { + conn.batch_execute( + "PRAGMA foreign_keys = ON;\ + PRAGMA journal_mode = WAL;\ + PRAGMA synchronous = NORMAL;\ + PRAGMA busy_timeout = 5000;", + ) +} + /// Database connection fairing. #[database("sqlite_db")] pub struct DbConn(diesel::SqliteConnection); @@ -38,6 +48,7 @@ impl Fairing for Migrate { if let Some(conn) = DbConn::get_one(&rocket).await { let _ = conn .run(|c| { + configure_sqlite_connection(c).expect("Failed to configure SQLite connection"); c.run_pending_migrations(MIGRATIONS) .expect("Failed to run migrations"); }) diff --git a/crates/server/src/db/models.rs b/crates/server/src/db/models.rs index dcea4d07..ffd8e2c7 100644 --- a/crates/server/src/db/models.rs +++ b/crates/server/src/db/models.rs @@ -4,7 +4,10 @@ use diesel::prelude::*; // local imports -use crate::db::schema::users; +use crate::db::schema::{ + item_metadata_collections, item_metadata_links, item_metadata_people, media_files, + media_items, media_libraries, playback_progress, scan_state, users, +}; #[derive(Queryable, Selectable, Insertable, Debug)] #[diesel(table_name = users)] @@ -16,3 +19,300 @@ pub struct User { pub pin: Option, pub admin: bool, } + +#[derive(Queryable, Selectable, Identifiable, Debug, Clone)] +#[diesel(table_name = media_libraries)] +pub struct MediaLibrary { + pub id: i32, + pub name: String, + pub path: String, + pub paths_json: String, + pub kind: String, + pub recursive: bool, + pub metadata_providers_json: String, +} + +#[derive(Insertable, AsChangeset, Debug, Clone)] +#[diesel(table_name = media_libraries)] +pub struct NewMediaLibrary { + pub name: String, + pub path: String, + pub paths_json: String, + pub kind: String, + pub recursive: bool, + pub metadata_providers_json: String, +} + +#[derive(Queryable, Selectable, Identifiable, Associations, Debug)] +#[diesel(belongs_to(MediaLibrary, foreign_key = library_id))] +#[diesel(table_name = scan_state)] +pub struct ScanState { + pub id: i32, + pub library_id: i32, + pub last_status: String, + pub last_error: Option, + pub scan_revision: i64, + pub last_scanned_at: Option, + pub total_files: i64, + pub video_files: i64, + pub audio_files: i64, + pub image_files: i64, + pub book_files: i64, + pub other_files: i64, +} + +#[derive(Insertable, AsChangeset, Debug, Clone)] +#[diesel(table_name = scan_state)] +#[diesel(treat_none_as_null = true)] +pub struct NewScanState { + pub library_id: i32, + pub last_status: String, + pub last_error: Option, + pub scan_revision: i64, + pub last_scanned_at: Option, + pub total_files: i64, + pub video_files: i64, + pub audio_files: i64, + pub image_files: i64, + pub book_files: i64, + pub other_files: i64, +} + +#[derive(Queryable, Selectable, Identifiable, Associations, Debug)] +#[diesel(belongs_to(MediaLibrary, foreign_key = library_id))] +#[diesel(table_name = media_files)] +pub struct MediaFile { + pub id: i32, + pub library_id: i32, + pub source_root_path: String, + pub relative_path: String, + pub file_size: i64, + pub modified_at: Option, + pub media_kind: String, + pub fingerprint_seed: String, + pub display_title: Option, + pub container: Option, + pub duration_ms: Option, + pub bit_rate: Option, + pub width: Option, + pub height: Option, + pub video_codec: Option, + pub audio_codec: Option, + pub metadata_json: Option, + pub metadata_updated_at: Option, + pub metadata_match_attempted_at: Option, + pub media_item_id: Option, +} + +#[derive(Queryable, Selectable, Identifiable, Associations, Debug, Clone)] +#[diesel(belongs_to(MediaLibrary, foreign_key = library_id))] +#[diesel(belongs_to(MediaItem, foreign_key = parent_id))] +#[diesel(table_name = media_items)] +pub struct MediaItem { + pub id: i32, + pub library_id: i32, + pub parent_id: Option, + pub identity_key: String, + pub item_type: String, + pub display_title: String, + pub relative_path: Option, + pub media_kind: Option, + pub season_number: Option, + pub episode_number: Option, + pub child_count: i32, + pub playable: bool, + pub file_size: Option, + pub duration_ms: Option, + pub modified_at: Option, + pub created_at: Option, + pub updated_at: Option, +} + +#[derive(Insertable, AsChangeset, Debug, Clone)] +#[diesel(table_name = media_items)] +#[diesel(treat_none_as_null = true)] +pub struct NewMediaItem { + pub library_id: i32, + pub parent_id: Option, + pub identity_key: String, + pub item_type: String, + pub display_title: String, + pub relative_path: Option, + pub media_kind: Option, + pub season_number: Option, + pub episode_number: Option, + pub child_count: i32, + pub playable: bool, + pub file_size: Option, + pub duration_ms: Option, + pub modified_at: Option, + pub created_at: Option, + pub updated_at: Option, +} + +#[derive(Queryable, Selectable, Identifiable, Associations, Debug)] +#[diesel(belongs_to(MediaItem, foreign_key = media_item_id))] +#[diesel(table_name = item_metadata_links)] +pub struct ItemMetadataLink { + pub id: i32, + pub media_item_id: i32, + pub provider_id: String, + pub external_id: String, + pub title: Option, + pub overview: Option, + pub tagline: Option, + pub artwork_url: Option, + pub backdrop_url: Option, + pub release_year: Option, + pub media_type: Option, + pub relation_kind: String, + pub match_state: String, + pub provider_payload_json: Option, + pub cached_artwork_path: Option, + pub cached_backdrop_path: Option, + pub refresh_state: String, + pub refresh_interval_seconds: i64, + pub last_refreshed_at: Option, + pub next_refresh_at: Option, + pub refresh_error: Option, + pub updated_at: Option, +} + +#[derive(Insertable, AsChangeset, Debug, Clone)] +#[diesel(table_name = item_metadata_links)] +#[diesel(treat_none_as_null = true)] +pub struct NewItemMetadataLink { + pub media_item_id: i32, + pub provider_id: String, + pub external_id: String, + pub title: Option, + pub overview: Option, + pub tagline: Option, + pub artwork_url: Option, + pub backdrop_url: Option, + pub release_year: Option, + pub media_type: Option, + pub relation_kind: String, + pub match_state: String, + pub provider_payload_json: Option, + pub cached_artwork_path: Option, + pub cached_backdrop_path: Option, + pub refresh_state: String, + pub refresh_interval_seconds: i64, + pub last_refreshed_at: Option, + pub next_refresh_at: Option, + pub refresh_error: Option, + pub updated_at: Option, +} + +#[derive(Queryable, Selectable, Identifiable, Associations, Debug, Clone)] +#[diesel(belongs_to(ItemMetadataLink, foreign_key = metadata_link_id))] +#[diesel(table_name = item_metadata_people)] +pub struct ItemMetadataPerson { + pub id: i32, + pub metadata_link_id: i32, + pub external_id: Option, + pub name: String, + pub role: Option, + pub department: Option, + pub character_name: Option, + pub profile_url: Option, + pub image_url: Option, + pub sort_order: i32, +} + +#[derive(Insertable, AsChangeset, Debug, Clone)] +#[diesel(table_name = item_metadata_people)] +#[diesel(treat_none_as_null = true)] +pub struct NewItemMetadataPerson { + pub metadata_link_id: i32, + pub external_id: Option, + pub name: String, + pub role: Option, + pub department: Option, + pub character_name: Option, + pub profile_url: Option, + pub image_url: Option, + pub sort_order: i32, +} + +#[derive(Queryable, Selectable, Identifiable, Associations, Debug, Clone)] +#[diesel(belongs_to(ItemMetadataLink, foreign_key = metadata_link_id))] +#[diesel(table_name = item_metadata_collections)] +pub struct ItemMetadataCollection { + pub id: i32, + pub metadata_link_id: i32, + pub provider_id: String, + pub external_id: String, + pub name: String, + pub overview: Option, + pub artwork_url: Option, + pub backdrop_url: Option, + pub provider_payload_json: Option, + pub updated_at: Option, +} + +#[derive(Insertable, AsChangeset, Debug, Clone)] +#[diesel(table_name = item_metadata_collections)] +#[diesel(treat_none_as_null = true)] +pub struct NewItemMetadataCollection { + pub metadata_link_id: i32, + pub provider_id: String, + pub external_id: String, + pub name: String, + pub overview: Option, + pub artwork_url: Option, + pub backdrop_url: Option, + pub provider_payload_json: Option, + pub updated_at: Option, +} + +#[derive(Queryable, Selectable, Identifiable, Associations, Debug)] +#[diesel(belongs_to(MediaItem, foreign_key = media_item_id))] +#[diesel(table_name = playback_progress)] +pub struct PlaybackProgress { + pub id: i32, + pub user_id: Option, + pub media_item_id: i32, + pub position_ms: i64, + pub duration_ms: Option, + pub completed: bool, + pub updated_at: Option, +} + +#[derive(Insertable, AsChangeset, Debug, Clone)] +#[diesel(table_name = playback_progress)] +#[diesel(treat_none_as_null = true)] +pub struct NewPlaybackProgress { + pub user_id: i32, + pub media_item_id: i32, + pub position_ms: i64, + pub duration_ms: Option, + pub completed: bool, + pub updated_at: Option, +} + +#[derive(Insertable, AsChangeset, Debug, Clone)] +#[diesel(table_name = media_files)] +#[diesel(treat_none_as_null = true)] +pub struct NewMediaFile { + pub library_id: i32, + pub source_root_path: String, + pub relative_path: String, + pub file_size: i64, + pub modified_at: Option, + pub media_kind: String, + pub fingerprint_seed: String, + pub display_title: Option, + pub container: Option, + pub duration_ms: Option, + pub bit_rate: Option, + pub width: Option, + pub height: Option, + pub video_codec: Option, + pub audio_codec: Option, + pub metadata_json: Option, + pub metadata_updated_at: Option, + pub metadata_match_attempted_at: Option, + pub media_item_id: Option, +} diff --git a/crates/server/src/db/schema.rs b/crates/server/src/db/schema.rs index a9831b63..c215ef52 100644 --- a/crates/server/src/db/schema.rs +++ b/crates/server/src/db/schema.rs @@ -1,7 +1,174 @@ //! Database schema for the application. // lib imports -use diesel::table; +use diesel::{allow_tables_to_appear_in_same_query, joinable, table}; + +table! { + item_metadata_links (id) { + id -> Integer, + media_item_id -> Integer, + provider_id -> Text, + external_id -> Text, + title -> Nullable, + overview -> Nullable, + tagline -> Nullable, + artwork_url -> Nullable, + backdrop_url -> Nullable, + release_year -> Nullable, + media_type -> Nullable, + relation_kind -> Text, + match_state -> Text, + provider_payload_json -> Nullable, + cached_artwork_path -> Nullable, + cached_backdrop_path -> Nullable, + refresh_state -> Text, + refresh_interval_seconds -> BigInt, + last_refreshed_at -> Nullable, + next_refresh_at -> Nullable, + refresh_error -> Nullable, + updated_at -> Nullable, + } +} + +table! { + item_metadata_collections (id) { + id -> Integer, + metadata_link_id -> Integer, + provider_id -> Text, + external_id -> Text, + name -> Text, + overview -> Nullable, + artwork_url -> Nullable, + backdrop_url -> Nullable, + provider_payload_json -> Nullable, + updated_at -> Nullable, + } +} + +table! { + item_metadata_people (id) { + id -> Integer, + metadata_link_id -> Integer, + external_id -> Nullable, + name -> Text, + role -> Nullable, + department -> Nullable, + character_name -> Nullable, + profile_url -> Nullable, + image_url -> Nullable, + sort_order -> Integer, + } +} + +table! { + media_files (id) { + id -> Integer, + library_id -> Integer, + source_root_path -> Text, + relative_path -> Text, + file_size -> BigInt, + modified_at -> Nullable, + media_kind -> Text, + fingerprint_seed -> Text, + display_title -> Nullable, + container -> Nullable, + duration_ms -> Nullable, + bit_rate -> Nullable, + width -> Nullable, + height -> Nullable, + video_codec -> Nullable, + audio_codec -> Nullable, + metadata_json -> Nullable, + metadata_updated_at -> Nullable, + metadata_match_attempted_at -> Nullable, + media_item_id -> Nullable, + } +} + +table! { + media_items (id) { + id -> Integer, + library_id -> Integer, + parent_id -> Nullable, + identity_key -> Text, + item_type -> Text, + display_title -> Text, + relative_path -> Nullable, + media_kind -> Nullable, + season_number -> Nullable, + episode_number -> Nullable, + child_count -> Integer, + playable -> Bool, + file_size -> Nullable, + duration_ms -> Nullable, + modified_at -> Nullable, + created_at -> Nullable, + updated_at -> Nullable, + } +} + +table! { + media_libraries (id) { + id -> Integer, + name -> Text, + path -> Text, + paths_json -> Text, + kind -> Text, + recursive -> Bool, + metadata_providers_json -> Text, + } +} + +table! { + playback_progress (id) { + id -> Integer, + user_id -> Nullable, + media_item_id -> Integer, + position_ms -> BigInt, + duration_ms -> Nullable, + completed -> Bool, + updated_at -> Nullable, + } +} + +table! { + scan_state (id) { + id -> Integer, + library_id -> Integer, + last_status -> Text, + last_error -> Nullable, + scan_revision -> BigInt, + last_scanned_at -> Nullable, + total_files -> BigInt, + video_files -> BigInt, + audio_files -> BigInt, + image_files -> BigInt, + book_files -> BigInt, + other_files -> BigInt, + } +} + +joinable!(item_metadata_collections -> item_metadata_links (metadata_link_id)); +joinable!(item_metadata_links -> media_items (media_item_id)); +joinable!(item_metadata_people -> item_metadata_links (metadata_link_id)); +joinable!(media_files -> media_libraries (library_id)); +joinable!(media_files -> media_items (media_item_id)); +joinable!(media_items -> media_libraries (library_id)); +joinable!(playback_progress -> users (user_id)); +joinable!(playback_progress -> media_items (media_item_id)); +joinable!(scan_state -> media_libraries (library_id)); + +allow_tables_to_appear_in_same_query!( + item_metadata_collections, + item_metadata_links, + item_metadata_people, + media_files, + media_items, + media_libraries, + playback_progress, + scan_state, + users +); table! { users (id) { diff --git a/crates/server/src/globals.rs b/crates/server/src/globals.rs index d40035e5..7c435c61 100644 --- a/crates/server/src/globals.rs +++ b/crates/server/src/globals.rs @@ -4,7 +4,7 @@ use once_cell::sync::Lazy; // local imports -use crate::config::GLOBAL_SETTINGS; +use crate::config::current_settings; // global constants and variables pub(crate) static GLOBAL_APP_NAME: &str = "Koko"; @@ -50,7 +50,7 @@ impl AppPaths { let env = Environment::from_usize(CURRENT_ENV.load(std::sync::atomic::Ordering::Relaxed)); let base_dir = match env { Environment::Test => String::from("./test_data"), - Environment::Production => GLOBAL_SETTINGS.general.data_dir.clone(), + Environment::Production => current_settings().general.data_dir, }; std::fs::create_dir_all(&base_dir).unwrap(); @@ -64,10 +64,11 @@ impl AppPaths { /// Get the server URL based on the global settings. pub fn get_server_url() -> String { - let schema = if GLOBAL_SETTINGS.server.use_https { "https" } else { "http" }; + let settings = current_settings(); + let schema = if settings.server.use_https { "https" } else { "http" }; format!( "{}://{}:{}", - schema, GLOBAL_SETTINGS.server.address, GLOBAL_SETTINGS.server.port + schema, settings.server.address, settings.server.port ) } diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index f1409d76..b7e40c1b 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -11,6 +11,8 @@ pub mod db; pub mod dependencies; pub mod globals; mod logging; +pub mod media; +pub mod metadata; pub mod signal_handler; pub mod tray; pub mod web; diff --git a/crates/server/src/logging/mod.rs b/crates/server/src/logging/mod.rs index f650c9df..db0d9b09 100644 --- a/crates/server/src/logging/mod.rs +++ b/crates/server/src/logging/mod.rs @@ -2,6 +2,7 @@ // standard imports use std::io; +use std::path::Path; // lib imports use fern::colors::{Color, ColoredLevelConfig}; @@ -43,7 +44,7 @@ impl Logger { &self, message: &str, ) -> String { - let mut msg = message.to_string(); + let mut msg = message.replace("\r\n", " ↩ ").replace(['\n', '\r'], " ↩ "); for pattern in &self.sensitive_data_patterns { msg = pattern.replace_all(&msg, self.replace_str).to_string(); } @@ -61,14 +62,20 @@ impl Logger { if remove_ansi { msg = self.ansi_escape.replace_all(&msg, "").to_string(); } + let module = record.module_path().unwrap_or(record.target()); + let file = normalize_log_source_path(record.file().unwrap_or("unknown")); + let line = record.line().map(|value| value.to_string()).unwrap_or_else(|| "?".into()); out.finish(format_args!( - "{} [{}] {}", + "{} [{}] [{}] [{}:{}] {}", chrono::Local::now().format(self.time_format), if remove_ansi { record.level().to_string() } else { self.colors.color(record.level()).to_string() }, + module, + file, + line, msg )); } @@ -100,6 +107,73 @@ impl Logger { } } +fn normalize_path_separators(path: &str) -> String { + path.trim().replace('\\', "/") +} + +fn shorten_absolute_path(path: &str, segments: usize) -> String { + let parts = path + .split('/') + .filter(|segment| !segment.is_empty()) + .collect::>(); + if parts.len() <= segments { + return parts.join("/"); + } + + parts[parts.len().saturating_sub(segments)..].join("/") +} + +fn workspace_relative_path(path: &str) -> Option { + let manifest_dir = normalize_path_separators(env!("CARGO_MANIFEST_DIR")); + let manifest_path = Path::new(&manifest_dir); + let repo_root = manifest_path + .parent() + .and_then(Path::parent) + .map(|path| normalize_path_separators(&path.to_string_lossy()))?; + + [repo_root, manifest_dir] + .into_iter() + .find_map(|prefix| { + let normalized_prefix = prefix.trim_end_matches('/'); + path.strip_prefix(&format!("{normalized_prefix}/")) + .map(|value| value.to_string()) + .or_else(|| (path == normalized_prefix).then(String::new)) + }) +} + +pub fn normalize_display_path(path: &str) -> String { + normalize_path_separators(path) +} + +pub fn normalize_log_source_path(path: &str) -> String { + let normalized = normalize_path_separators(path); + if normalized.is_empty() { + return "unknown".into(); + } + + if let Some(relative) = workspace_relative_path(&normalized) { + return relative; + } + + if let Some((_, remainder)) = normalized.split_once("/.cargo/registry/src/") { + if let Some((_, crate_relative)) = remainder.split_once('/') { + return crate_relative.to_string(); + } + } + + if let Some((_, remainder)) = normalized.split_once("/cargo/registry/src/") { + if let Some((_, crate_relative)) = remainder.split_once('/') { + return crate_relative.to_string(); + } + } + + if normalized.contains(":/") || normalized.starts_with('/') { + return shorten_absolute_path(&normalized, 4); + } + + normalized +} + pub fn init() -> Result<(), Box> { let logger = Logger::new()?; logger.init() diff --git a/crates/server/src/media.rs b/crates/server/src/media.rs new file mode 100644 index 00000000..8472618a --- /dev/null +++ b/crates/server/src/media.rs @@ -0,0 +1,3186 @@ +//! Media-library inspection, persistence, and transcoding capability utilities. + +// standard imports +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::UNIX_EPOCH; + +// lib imports +use diesel::{ + ExpressionMethods, OptionalExtension, QueryDsl, RunQueryDsl, SelectableHelper, SqliteConnection, +}; +use schemars::JsonSchema; +use serde::Serialize; +use serde_json::Value; + +// local imports +use crate::config::{FfmpegSettings, MediaLibraryKind, MediaLibrarySettings, MetadataProviderId}; +use crate::db::models::{ + ItemMetadataLink, MediaFile, MediaItem, MediaLibrary, NewMediaFile, NewMediaItem, + NewMediaLibrary, NewPlaybackProgress, NewScanState, PlaybackProgress, ScanState, +}; +use crate::metadata::{ + ArtworkKind, MetadataCollectionSummary, get_primary_item_metadata_link, + list_metadata_collection_summaries, presentation_from_metadata_link, +}; + +/// Scan status for a configured media library. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum LibraryScanStatus { + /// The library exists in configuration but has not been scanned yet. + NeverScanned, + /// The library path exists and was scanned successfully. + Available, + /// The library path was empty. + EmptyPath, + /// The library path does not exist. + MissingPath, + /// The configured path exists but is not a directory. + NotDirectory, + /// The library path could not be read completely. + Unreadable, +} + +/// Summary of one configured media library. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub struct LibraryScanSummary { + /// Human-friendly library name. + pub name: String, + /// Configured filesystem path. + pub path: String, + /// Configured filesystem paths for this logical library. + pub paths: Vec, + /// Whether the scan is recursive. + pub recursive: bool, + /// Intended media category for the library. + pub kind: MediaLibraryKind, + /// Scan status for this library. + pub status: LibraryScanStatus, + /// Total number of files discovered. + pub total_files: u64, + /// Number of video files discovered. + pub video_files: u64, + /// Number of audio files discovered. + pub audio_files: u64, + /// Number of image files discovered. + pub image_files: u64, + /// Number of book or document files discovered. + pub book_files: u64, + /// Number of files that do not match known media extensions. + pub other_files: u64, + /// The last scan error, if any. + pub error: Option, +} + +/// Details about a discovered executable used for media processing. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub struct BinaryCapability { + /// Configured command or path. + pub configured_path: String, + /// Whether the executable could be launched successfully. + pub available: bool, + /// First line of the version output, when available. + pub version: Option, + /// Error details when the executable is unavailable. + pub error: Option, +} + +/// Current FFmpeg tooling availability. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub struct TranscodingCapability { + /// FFmpeg executable capability. + pub ffmpeg: BinaryCapability, + /// ffprobe executable capability. + pub ffprobe: BinaryCapability, +} + +/// Persisted media library summary with a stable database identity. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub struct PersistedLibrarySummary { + /// Stable database identifier for the library. + pub id: i32, + /// Human-friendly library name. + pub name: String, + /// Configured filesystem path. + pub path: String, + /// Configured filesystem paths for this logical library. + pub paths: Vec, + /// Whether the scan is recursive. + pub recursive: bool, + /// Intended media category for the library. + pub kind: MediaLibraryKind, + /// Scan status for this library. + pub status: LibraryScanStatus, + /// Monotonically increasing scan revision. + pub scan_revision: i64, + /// Last completed scan time as Unix seconds, when available. + pub last_scanned_at: Option, + /// Total number of files discovered. + pub total_files: i64, + /// Number of video files discovered. + pub video_files: i64, + /// Number of audio files discovered. + pub audio_files: i64, + /// Number of image files discovered. + pub image_files: i64, + /// Number of book or document files discovered. + pub book_files: i64, + /// Number of files that do not match known media extensions. + pub other_files: i64, + /// The last scan error, if any. + pub error: Option, + /// Number of linked metadata items tracked for refresh progress. + pub metadata_refresh_total: i64, + /// Number of linked metadata items still pending refresh. + pub metadata_refresh_pending: i64, + /// Number of linked metadata items already processed in the active refresh run. + pub metadata_refresh_completed: i64, + /// Number of linked metadata items whose latest refresh failed. + pub metadata_refresh_failed: i64, +} + +#[derive(Debug, Clone, Default)] +struct LibraryMetadataRefreshCounts { + total_items: i64, + pending_items: i64, + completed_items: i64, + failed_items: i64, +} + +/// Persisted media file summary for a library. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub struct PersistedMediaFileSummary { + /// Stable database identifier for the file row. + pub id: i32, + /// Owning media-library identifier. + pub library_id: i32, + /// Library-relative file path. + pub relative_path: String, + /// File size in bytes. + pub file_size: i64, + /// Last modified timestamp as Unix seconds, when available. + pub modified_at: Option, + /// Classified media type for the file. + pub media_kind: String, + /// Basic fingerprint seed for future change detection. + pub fingerprint_seed: String, + /// Browser-friendly title for the item. + pub display_title: String, + /// Container format reported by ffprobe when available. + pub container: Option, + /// Duration in milliseconds when available. + pub duration_ms: Option, + /// Video width when available. + pub width: Option, + /// Video height when available. + pub height: Option, + /// Video codec name when available. + pub video_codec: Option, + /// Audio codec name when available. + pub audio_codec: Option, +} + +/// Summary of a browser-visible media item. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub struct MediaItemSummary { + /// Stable database identifier for the item. + pub id: i32, + /// Owning media-library identifier. + pub library_id: i32, + /// Parent item identifier when this item belongs to a hierarchy. + pub parent_id: Option, + /// Logical item type such as movie, show, season, or episode. + pub item_type: String, + /// Display title for the item. + pub display_title: String, + /// Library-relative file path. + pub relative_path: String, + /// Classified media kind. + pub media_kind: String, + /// Whether the item can be played directly as a leaf item. + pub playable: bool, + /// Number of direct child items. + pub child_count: i32, + /// Season number when the item is a season or episode. + pub season_number: Option, + /// Episode number when the item is an episode. + pub episode_number: Option, + /// Duration in milliseconds when available. + pub duration_ms: Option, + /// Video width when available. + pub width: Option, + /// Video height when available. + pub height: Option, + /// Genre labels from linked metadata when available. + pub genres: Vec, + /// Whether the item currently has linked metadata. + pub has_metadata: bool, + /// Current metadata refresh state when metadata exists. + pub metadata_refresh_state: Option, + /// Last metadata refresh error, when available. + pub metadata_refresh_error: Option, + /// Revision timestamp for artwork cache-busting when linked metadata changes. + pub artwork_updated_at: Option, + /// Last modified timestamp as Unix seconds, when available. + pub modified_at: Option, +} + +/// Detailed browser-facing media item response. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub struct MediaItemDetail { + /// Stable database identifier for the item. + pub id: i32, + /// Owning media-library identifier. + pub library_id: i32, + /// Parent item identifier when this item belongs to a hierarchy. + pub parent_id: Option, + /// Logical item type such as movie, show, season, or episode. + pub item_type: String, + /// Display title for the item. + pub display_title: String, + /// Library-relative file path. + pub relative_path: String, + /// File size in bytes. + pub file_size: Option, + /// Last modified timestamp as Unix seconds, when available. + pub modified_at: Option, + /// Classified media kind. + pub media_kind: String, + /// Whether the item can be played directly as a leaf item. + pub playable: bool, + /// Number of direct child items. + pub child_count: i32, + /// Season number when the item is a season or episode. + pub season_number: Option, + /// Episode number when the item is an episode. + pub episode_number: Option, + /// Container format reported by ffprobe when available. + pub container: Option, + /// Duration in milliseconds when available. + pub duration_ms: Option, + /// Bit rate when available. + pub bit_rate: Option, + /// Video width when available. + pub width: Option, + /// Video height when available. + pub height: Option, + /// Video codec name when available. + pub video_codec: Option, + /// Audio codec name when available. + pub audio_codec: Option, + /// Raw ffprobe JSON payload, when available. + pub metadata_json: Option, + /// Metadata update timestamp as Unix seconds, when available. + pub metadata_updated_at: Option, + /// Local or managed poster artwork URL, when available. + pub poster_url: Option, + /// Local or managed backdrop artwork URL, when available. + pub backdrop_url: Option, + /// Theme-song URL, when available. + pub theme_song_url: Option, + /// Hidden YouTube-backed theme-song URL, when available. + pub theme_song_youtube_url: Option, + /// Tagline from linked metadata, when available. + pub tagline: Option, + /// Description or overview from linked metadata, when available. + pub overview: Option, + /// Genre labels from linked metadata. + pub genres: Vec, + /// Release year from linked metadata, when available. + pub release_year: Option, + /// Linked metadata media type such as movie or tv. + pub linked_media_type: Option, + /// Whether the item currently has linked metadata. + pub has_metadata: bool, + /// Current metadata refresh state when metadata exists. + pub metadata_refresh_state: Option, + /// Last metadata refresh error, when available. + pub metadata_refresh_error: Option, + /// Revision timestamp for artwork cache-busting when linked metadata changes. + pub artwork_updated_at: Option, + /// Browser-embeddable trailer title, when available. + pub trailer_title: Option, + /// Browser-embeddable trailer URL, when available. + pub trailer_url: Option, + /// Discovered subtitle sidecars for this item. + pub subtitle_tracks: Vec, + /// Breadcrumb-like hierarchy for this item. + pub hierarchy: Vec, + /// Direct child items for hierarchical browsing. + pub children: Vec, +} + +/// Subtitle track discovered for one media item. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub struct MediaSubtitleTrack { + /// Stable index used to request the subtitle asset. + pub index: usize, + /// Human-friendly track label. + pub label: String, + /// Subtitle container or format. + pub format: String, + /// Browser-facing asset URL. + pub url: String, +} + +/// One media shelf on the browser home screen. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub struct MediaShelf { + /// Stable shelf identifier. + pub id: String, + /// Shelf title. + pub title: String, + /// Items shown in the shelf. + pub items: Vec, +} + +/// Browser-facing home response. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub struct MediaHome { + /// Library filter currently applied. + pub library_id: Option, + /// Kodi/Plex-style shelves for the main page. + pub shelves: Vec, + /// Real collection groupings derived from linked metadata. + pub collections: Vec, +} + +/// Direct-play versus transcode decision for one media item. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub struct PlaybackDecision { + /// Stable database identifier for the item. + pub item_id: i32, + /// Whether the item can be played directly in the browser. + pub can_direct_play: bool, + /// Whether transcoding would be required for ideal playback. + pub transcode_required: bool, + /// Human-readable reason for the current decision. + pub reason: String, + /// Direct stream URL when direct play is supported. + pub stream_url: Option, + /// Browser media MIME type when known. + pub mime_type: Option, +} + +/// One unmatched media item that is eligible for automatic metadata linking. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AutomaticMetadataCandidate { + /// Stable item identifier. + pub item_id: i32, + /// Library-relative media path. + pub relative_path: String, + /// Current display title derived from scan data. + pub display_title: String, + /// Last modification timestamp used to prioritize recent items. + pub modified_at: Option, + /// Owning library kind. + pub library_kind: MediaLibraryKind, + /// Metadata providers enabled for the library. + pub metadata_providers: Vec, +} + +#[derive(Debug, Default)] +struct FileCounters { + total_files: u64, + video_files: u64, + audio_files: u64, + image_files: u64, + book_files: u64, + other_files: u64, +} + +#[derive(Debug, Clone)] +struct LibraryInspection { + summary: LibraryScanSummary, + files: Vec, +} + +#[derive(Debug, Clone)] +struct DiscoveredMediaFile { + full_path: PathBuf, + source_root_path: String, + relative_path: String, + file_size: i64, + modified_at: Option, + media_kind: String, + fingerprint_seed: String, + default_title: String, +} + +#[derive(Debug, Clone, Default)] +struct ExtractedMetadata { + display_title: Option, + container: Option, + duration_ms: Option, + bit_rate: Option, + width: Option, + height: Option, + video_codec: Option, + audio_codec: Option, + metadata_json: Option, + metadata_updated_at: Option, +} + +#[derive(Debug, Clone)] +struct PlannedMediaItem { + identity_key: String, + parent_identity_key: Option, + item_type: String, + display_title: String, + relative_path: Option, + media_kind: Option, + season_number: Option, + episode_number: Option, + playable: bool, + child_count: i32, + file_size: Option, + duration_ms: Option, + modified_at: Option, + explicit_id: Option, +} + +#[derive(Debug, Clone)] +struct PlannedLibraryItems { + items: Vec, + leaf_identity_by_file_id: HashMap, +} + +#[derive(Debug, Clone)] +struct ParsedShowPath { + show_title: String, + show_key: String, + season_title: String, + season_key: String, + season_number: Option, + episode_title: String, + episode_key: String, + episode_number: Option, +} + +#[derive(Debug, Clone, Copy)] +struct ProbeContext<'a> { + ffprobe_path: &'a str, + enabled: bool, +} + +/// Inspect configured media libraries and return lightweight scan summaries. +pub fn inspect_libraries(libraries: &[MediaLibrarySettings]) -> Vec { + libraries + .iter() + .map(inspect_library_with_inventory) + .map(|inspection| inspection.summary) + .collect() +} + +/// Detect FFmpeg and ffprobe availability from the configured settings. +pub fn inspect_transcoding_capability(settings: &FfmpegSettings) -> TranscodingCapability { + TranscodingCapability { + ffmpeg: detect_binary(&settings.ffmpeg_path), + ffprobe: detect_binary(&settings.ffprobe_path), + } +} + +/// Return the number of persisted media libraries. +pub fn count_persisted_libraries( + conn: &mut SqliteConnection, +) -> Result { + use crate::db::schema::media_libraries::dsl as media_libraries_dsl; + + media_libraries_dsl::media_libraries.count().get_result(conn) +} + +/// Ensure legacy library settings are imported into the database when needed. +pub fn migrate_legacy_library_settings( + conn: &mut SqliteConnection, + legacy_libraries: &[MediaLibrarySettings], +) -> Result { + if count_persisted_libraries(conn)? > 0 || legacy_libraries.is_empty() { + return Ok(false); + } + + for library in legacy_libraries { + insert_media_library(conn, library)?; + } + + Ok(true) +} + +/// Return the persisted media-library settings stored in the database. +pub fn list_library_settings( + conn: &mut SqliteConnection, + legacy_libraries: &[MediaLibrarySettings], +) -> Result, diesel::result::Error> { + use crate::db::schema::media_libraries::dsl as media_libraries_dsl; + + migrate_legacy_library_settings(conn, legacy_libraries)?; + + let rows = media_libraries_dsl::media_libraries + .order(media_libraries_dsl::id.asc()) + .select(MediaLibrary::as_select()) + .load::(conn)?; + + Ok(rows.into_iter().map(media_library_settings_from_row).collect()) +} + +/// Replace the persisted media-library settings stored in the database. +pub fn replace_library_settings( + conn: &mut SqliteConnection, + libraries: &[MediaLibrarySettings], +) -> Result, diesel::result::Error> { + use crate::db::schema::media_libraries::dsl as media_libraries_dsl; + + let existing = media_libraries_dsl::media_libraries + .order(media_libraries_dsl::id.asc()) + .select(MediaLibrary::as_select()) + .load::(conn)?; + + for (index, library) in libraries.iter().enumerate() { + if let Some(existing_row) = existing.get(index) { + update_media_library(conn, existing_row.id, library)?; + } else { + insert_media_library(conn, library)?; + } + } + + for stale_library in existing.into_iter().skip(libraries.len()) { + diesel::delete( + media_libraries_dsl::media_libraries.filter(media_libraries_dsl::id.eq(stale_library.id)), + ) + .execute(conn)?; + } + + list_library_settings(conn, &[]) +} + +/// Insert one persisted media library. +pub fn add_library_setting( + conn: &mut SqliteConnection, + library: &MediaLibrarySettings, +) -> Result, diesel::result::Error> { + insert_media_library(conn, library)?; + list_library_settings(conn, &[]) +} + +/// Remove one persisted media library by its database identifier. +pub fn remove_library_setting( + conn: &mut SqliteConnection, + library_index: usize, +) -> Result { + use crate::db::schema::media_libraries::dsl as media_libraries_dsl; + + let existing = media_libraries_dsl::media_libraries + .order(media_libraries_dsl::id.asc()) + .select(MediaLibrary::as_select()) + .load::(conn)?; + let Some(library_id) = existing.get(library_index).map(|library| library.id) else { + return Ok(false); + }; + + let deleted = diesel::delete( + media_libraries_dsl::media_libraries.filter(media_libraries_dsl::id.eq(library_id)), + ) + .execute(conn)?; + + Ok(deleted > 0) +} + +/// Sync persisted libraries from the database into the media catalog. +pub fn sync_persisted_library_catalog( + conn: &mut SqliteConnection, + legacy_libraries: &[MediaLibrarySettings], + ffmpeg_settings: &FfmpegSettings, +) -> Result, diesel::result::Error> { + let libraries = list_library_settings(conn, legacy_libraries)?; + sync_library_catalog(conn, &libraries, ffmpeg_settings) +} + +/// Return persisted media-library summaries without triggering a foreground rescan. +pub fn get_persisted_library_summaries( + conn: &mut SqliteConnection, + legacy_libraries: &[MediaLibrarySettings], +) -> Result, diesel::result::Error> { + use crate::db::schema::item_metadata_links::dsl as item_metadata_links_dsl; + use crate::db::schema::media_libraries::dsl as media_libraries_dsl; + use crate::db::schema::media_items::dsl as media_items_dsl; + use crate::db::schema::scan_state::dsl as scan_state_dsl; + + migrate_legacy_library_settings(conn, legacy_libraries)?; + + let libraries = media_libraries_dsl::media_libraries + .order(media_libraries_dsl::id.asc()) + .select(MediaLibrary::as_select()) + .load::(conn)?; + let states = scan_state_dsl::scan_state + .select(ScanState::as_select()) + .load::(conn)? + .into_iter() + .map(|state| (state.library_id, state)) + .collect::>(); + let item_library_ids = if libraries.is_empty() { + HashMap::new() + } else { + media_items_dsl::media_items + .filter(media_items_dsl::library_id.eq_any(libraries.iter().map(|library| library.id).collect::>())) + .select(MediaItem::as_select()) + .load::(conn)? + .into_iter() + .map(|item| (item.id, item.library_id)) + .collect::>() + }; + let refresh_counts = if item_library_ids.is_empty() { + HashMap::new() + } else { + item_metadata_links_dsl::item_metadata_links + .filter(item_metadata_links_dsl::media_item_id.eq_any(item_library_ids.keys().copied().collect::>())) + .filter(item_metadata_links_dsl::relation_kind.eq("primary")) + .select(ItemMetadataLink::as_select()) + .load::(conn)? + .into_iter() + .fold(HashMap::::new(), |mut grouped, link| { + let Some(library_id) = item_library_ids.get(&link.media_item_id).copied() else { + return grouped; + }; + + let counts = grouped.entry(library_id).or_default(); + counts.total_items += 1; + if link.refresh_state == "pending" { + counts.pending_items += 1; + } else { + counts.completed_items += 1; + if link.refresh_state == "error" { + counts.failed_items += 1; + } + } + + grouped + }) + }; + + Ok(libraries + .into_iter() + .map(|library| { + let settings = media_library_settings_from_row(library.clone()); + let state = states.get(&library.id); + let metadata_counts = refresh_counts.get(&library.id).cloned().unwrap_or_default(); + PersistedLibrarySummary { + id: library.id, + name: settings.name, + path: settings.path, + paths: settings.paths, + recursive: settings.recursive, + kind: settings.kind, + status: state + .map(|state| LibraryScanStatus::from_storage_value(&state.last_status)) + .unwrap_or(LibraryScanStatus::NeverScanned), + scan_revision: state.map(|state| state.scan_revision).unwrap_or_default(), + last_scanned_at: state.and_then(|state| state.last_scanned_at), + total_files: state.map(|state| state.total_files).unwrap_or_default(), + video_files: state.map(|state| state.video_files).unwrap_or_default(), + audio_files: state.map(|state| state.audio_files).unwrap_or_default(), + image_files: state.map(|state| state.image_files).unwrap_or_default(), + book_files: state.map(|state| state.book_files).unwrap_or_default(), + other_files: state.map(|state| state.other_files).unwrap_or_default(), + error: state.and_then(|state| state.last_error.clone()), + metadata_refresh_total: metadata_counts.total_items, + metadata_refresh_pending: metadata_counts.pending_items, + metadata_refresh_completed: metadata_counts.completed_items, + metadata_refresh_failed: metadata_counts.failed_items, + } + }) + .collect()) +} + +/// Sync configured libraries into the persistent catalog and refresh their inventory. +pub fn sync_library_catalog( + conn: &mut SqliteConnection, + libraries: &[MediaLibrarySettings], + ffmpeg_settings: &FfmpegSettings, +) -> Result, diesel::result::Error> { + use crate::db::schema::item_metadata_links::dsl as item_metadata_links_dsl; + use crate::db::schema::media_files::dsl as media_files_dsl; + use crate::db::schema::media_libraries::dsl as media_libraries_dsl; + use crate::db::schema::playback_progress::dsl as playback_progress_dsl; + use crate::db::schema::scan_state::dsl as scan_state_dsl; + + let probe_context = ProbeContext { + ffprobe_path: &ffmpeg_settings.ffprobe_path, + enabled: detect_binary(&ffmpeg_settings.ffprobe_path).available, + }; + let inspections: Vec = libraries + .iter() + .map(inspect_library_with_inventory) + .collect(); + let configured_storage_keys: HashSet = inspections + .iter() + .map(|inspection| inspection.summary.path.clone()) + .collect(); + let mut persisted = Vec::with_capacity(inspections.len()); + + for inspection in inspections { + let library_values = NewMediaLibrary { + name: inspection.summary.name.clone(), + path: inspection.summary.path.clone(), + paths_json: serde_json::to_string(&inspection.summary.paths).unwrap_or_else(|_| "[]".into()), + kind: inspection.summary.kind.as_storage_value(), + recursive: inspection.summary.recursive, + metadata_providers_json: serde_json::to_string( + &libraries + .iter() + .find(|library| library.primary_path() == inspection.summary.path) + .map(|library| { + library + .metadata_providers + .iter() + .map(|provider| provider.as_storage_value()) + .collect::>() + }) + .unwrap_or_else(|| vec![MetadataProviderId::Tmdb.as_storage_value()]), + ) + .unwrap_or_else(|_| "[\"tmdb\"]".into()), + }; + + let existing_library = media_libraries_dsl::media_libraries + .filter(media_libraries_dsl::path.eq(&library_values.path)) + .select(MediaLibrary::as_select()) + .first(conn) + .optional()?; + + let library_row = if let Some(existing_library) = existing_library { + diesel::update( + media_libraries_dsl::media_libraries + .filter(media_libraries_dsl::id.eq(existing_library.id)), + ) + .set(&library_values) + .execute(conn)?; + + media_libraries_dsl::media_libraries + .filter(media_libraries_dsl::id.eq(existing_library.id)) + .select(MediaLibrary::as_select()) + .first(conn)? + } else { + diesel::insert_into(media_libraries_dsl::media_libraries) + .values(&library_values) + .execute(conn)?; + + media_libraries_dsl::media_libraries + .filter(media_libraries_dsl::path.eq(&library_values.path)) + .select(MediaLibrary::as_select()) + .first(conn)? + }; + + let existing_state = scan_state_dsl::scan_state + .filter(scan_state_dsl::library_id.eq(library_row.id)) + .select(ScanState::as_select()) + .first(conn) + .optional()?; + let next_scan_revision = existing_state + .as_ref() + .map(|state| state.scan_revision + 1) + .unwrap_or(1); + let last_scanned_at = Some(current_timestamp()); + + let existing_files = media_files_dsl::media_files + .filter(media_files_dsl::library_id.eq(library_row.id)) + .select(MediaFile::as_select()) + .load::(conn)?; + let mut existing_files_by_path: HashMap = existing_files + .into_iter() + .map(|file| (media_file_inventory_key(&file), file)) + .collect(); + let mut seen_paths = HashSet::new(); + + for discovered_file in &inspection.files { + let inventory_key = discovered_media_file_inventory_key(discovered_file); + if !seen_paths.insert(inventory_key.clone()) { + continue; + } + + let existing_file = existing_files_by_path.remove(&inventory_key); + let should_refresh_title = existing_file + .as_ref() + .map(|file| file.display_title.as_deref() != Some(discovered_file.default_title.as_str())) + .unwrap_or(true); + let should_refresh_metadata = existing_file + .as_ref() + .map(|file| file.fingerprint_seed != discovered_file.fingerprint_seed) + .unwrap_or(true); + + let mut metadata = if should_refresh_metadata { + extract_metadata(discovered_file, probe_context) + } else { + existing_file + .as_ref() + .map(extracted_metadata_from_existing) + .unwrap_or_else(|| default_metadata(discovered_file)) + }; + metadata.display_title = Some(discovered_file.default_title.clone()); + let metadata_match_attempted_at = if should_refresh_metadata || should_refresh_title { + None + } else { + existing_file + .as_ref() + .and_then(|file| file.metadata_match_attempted_at) + }; + let file_values = discovered_file.to_new_media_file( + library_row.id, + metadata, + metadata_match_attempted_at, + ); + + if let Some(existing_file) = existing_file { + if existing_file.fingerprint_seed != discovered_file.fingerprint_seed || should_refresh_title { + diesel::update( + media_files_dsl::media_files + .filter(media_files_dsl::id.eq(existing_file.id)), + ) + .set(&file_values) + .execute(conn)?; + } + } else { + diesel::insert_into(media_files_dsl::media_files) + .values(&file_values) + .execute(conn)?; + } + } + + let deleted_file_ids: Vec = existing_files_by_path + .values() + .filter(|file| !seen_paths.contains(&media_file_inventory_key(file))) + .map(|file| file.id) + .collect(); + if !deleted_file_ids.is_empty() { + diesel::delete( + media_files_dsl::media_files.filter(media_files_dsl::id.eq_any(deleted_file_ids)), + ) + .execute(conn)?; + } + + sync_logical_media_items_for_library(conn, &library_row, &inspection.summary.kind)?; + + let state_values = NewScanState { + library_id: library_row.id, + last_status: inspection.summary.status.as_storage_value().to_string(), + last_error: inspection.summary.error.clone(), + scan_revision: next_scan_revision, + last_scanned_at, + total_files: inspection.summary.total_files as i64, + video_files: inspection.summary.video_files as i64, + audio_files: inspection.summary.audio_files as i64, + image_files: inspection.summary.image_files as i64, + book_files: inspection.summary.book_files as i64, + other_files: inspection.summary.other_files as i64, + }; + + if let Some(existing_state) = existing_state { + diesel::update( + scan_state_dsl::scan_state.filter(scan_state_dsl::id.eq(existing_state.id)), + ) + .set(&state_values) + .execute(conn)?; + } else { + diesel::insert_into(scan_state_dsl::scan_state) + .values(&state_values) + .execute(conn)?; + } + + persisted.push(PersistedLibrarySummary { + id: library_row.id, + name: inspection.summary.name, + path: inspection.summary.path, + paths: inspection.summary.paths, + recursive: inspection.summary.recursive, + kind: inspection.summary.kind, + status: inspection.summary.status, + scan_revision: next_scan_revision, + last_scanned_at, + total_files: state_values.total_files, + video_files: state_values.video_files, + audio_files: state_values.audio_files, + image_files: state_values.image_files, + book_files: state_values.book_files, + other_files: state_values.other_files, + error: state_values.last_error, + metadata_refresh_total: 0, + metadata_refresh_pending: 0, + metadata_refresh_completed: 0, + metadata_refresh_failed: 0, + }); + } + + let stale_library_rows = media_libraries_dsl::media_libraries + .select(MediaLibrary::as_select()) + .load::(conn)? + .into_iter() + .filter(|library| !configured_storage_keys.contains(&library.path)) + .collect::>(); + + if !stale_library_rows.is_empty() { + let stale_library_ids = stale_library_rows.iter().map(|library| library.id).collect::>(); + let stale_media_file_ids = media_files_dsl::media_files + .filter(media_files_dsl::library_id.eq_any(&stale_library_ids)) + .select(media_files_dsl::id) + .load::(conn)?; + + if !stale_media_file_ids.is_empty() { + diesel::delete( + item_metadata_links_dsl::item_metadata_links + .filter(item_metadata_links_dsl::media_item_id.eq_any(&stale_media_file_ids)), + ) + .execute(conn)?; + diesel::delete( + playback_progress_dsl::playback_progress + .filter(playback_progress_dsl::media_item_id.eq_any(&stale_media_file_ids)), + ) + .execute(conn)?; + diesel::delete( + media_files_dsl::media_files.filter(media_files_dsl::id.eq_any(&stale_media_file_ids)), + ) + .execute(conn)?; + } + + diesel::delete(scan_state_dsl::scan_state.filter(scan_state_dsl::library_id.eq_any(&stale_library_ids))) + .execute(conn)?; + diesel::delete(media_libraries_dsl::media_libraries.filter(media_libraries_dsl::id.eq_any(stale_library_ids))) + .execute(conn)?; + } + + Ok(persisted) +} + +fn sync_logical_media_items_for_library( + conn: &mut SqliteConnection, + library: &MediaLibrary, + library_kind: &MediaLibraryKind, +) -> Result<(), diesel::result::Error> { + use crate::db::schema::media_files::dsl as media_files_dsl; + use crate::db::schema::media_items::dsl as media_items_dsl; + + let files = media_files_dsl::media_files + .filter(media_files_dsl::library_id.eq(library.id)) + .order(media_files_dsl::relative_path.asc()) + .select(MediaFile::as_select()) + .load::(conn)?; + let existing_items = media_items_dsl::media_items + .filter(media_items_dsl::library_id.eq(library.id)) + .select(MediaItem::as_select()) + .load::(conn)?; + + let planned = plan_library_media_items(&files, library_kind); + let planned_keys = planned + .items + .iter() + .map(|item| item.identity_key.clone()) + .collect::>(); + let mut existing_by_key = existing_items + .iter() + .cloned() + .map(|item| (item.identity_key.clone(), item)) + .collect::>(); + let existing_by_id = existing_items + .iter() + .cloned() + .map(|item| (item.id, item)) + .collect::>(); + let mut item_ids_by_key = HashMap::new(); + + for plan in planned.items.iter().filter(|item| item.parent_identity_key.is_none()) { + upsert_planned_media_item( + conn, + library.id, + plan, + None, + &mut existing_by_key, + &existing_by_id, + &mut item_ids_by_key, + )?; + } + + for plan in planned.items.iter().filter(|item| item.parent_identity_key.is_some()) { + let parent_id = plan + .parent_identity_key + .as_ref() + .and_then(|identity_key| item_ids_by_key.get(identity_key)) + .copied(); + upsert_planned_media_item( + conn, + library.id, + plan, + parent_id, + &mut existing_by_key, + &existing_by_id, + &mut item_ids_by_key, + )?; + } + + for file in &files { + let Some(identity_key) = planned.leaf_identity_by_file_id.get(&file.id) else { + continue; + }; + let Some(item_id) = item_ids_by_key.get(identity_key).copied() else { + continue; + }; + diesel::update(media_files_dsl::media_files.filter(media_files_dsl::id.eq(file.id))) + .set(media_files_dsl::media_item_id.eq(item_id)) + .execute(conn)?; + } + + let stale_ids = existing_items + .into_iter() + .filter(|item| !planned_keys.contains(&item.identity_key)) + .map(|item| item.id) + .collect::>(); + if !stale_ids.is_empty() { + diesel::delete(media_items_dsl::media_items.filter(media_items_dsl::id.eq_any(stale_ids))) + .execute(conn)?; + } + + Ok(()) +} + +fn upsert_planned_media_item( + conn: &mut SqliteConnection, + library_id: i32, + plan: &PlannedMediaItem, + parent_id: Option, + existing_by_key: &mut HashMap, + existing_by_id: &HashMap, + item_ids_by_key: &mut HashMap, +) -> Result<(), diesel::result::Error> { + use crate::db::schema::media_items::dsl as media_items_dsl; + + let values = NewMediaItem { + library_id, + parent_id, + identity_key: plan.identity_key.clone(), + item_type: plan.item_type.clone(), + display_title: plan.display_title.clone(), + relative_path: plan.relative_path.clone(), + media_kind: plan.media_kind.clone(), + season_number: plan.season_number, + episode_number: plan.episode_number, + child_count: plan.child_count, + playable: plan.playable, + file_size: plan.file_size, + duration_ms: plan.duration_ms, + modified_at: plan.modified_at, + created_at: plan.modified_at.or_else(|| Some(current_timestamp())), + updated_at: Some(current_timestamp()), + }; + + let target = existing_by_key + .remove(&plan.identity_key) + .or_else(|| plan.explicit_id.and_then(|id| existing_by_id.get(&id).cloned())); + + let item_id = if let Some(existing) = target { + diesel::update(media_items_dsl::media_items.filter(media_items_dsl::id.eq(existing.id))) + .set(&values) + .execute(conn)?; + existing.id + } else if let Some(explicit_id) = plan.explicit_id { + let explicit_id_available = media_items_dsl::media_items + .filter(media_items_dsl::id.eq(explicit_id)) + .select(media_items_dsl::id) + .first::(conn) + .optional()? + .is_none(); + + if explicit_id_available { + diesel::insert_into(media_items_dsl::media_items) + .values((media_items_dsl::id.eq(explicit_id), &values)) + .execute(conn)?; + explicit_id + } else { + diesel::insert_into(media_items_dsl::media_items) + .values(&values) + .execute(conn)?; + media_items_dsl::media_items + .filter(media_items_dsl::identity_key.eq(&plan.identity_key)) + .select(media_items_dsl::id) + .first::(conn)? + } + } else { + diesel::insert_into(media_items_dsl::media_items) + .values(&values) + .execute(conn)?; + media_items_dsl::media_items + .filter(media_items_dsl::identity_key.eq(&plan.identity_key)) + .select(media_items_dsl::id) + .first::(conn)? + }; + + item_ids_by_key.insert(plan.identity_key.clone(), item_id); + Ok(()) +} + +fn plan_library_media_items( + files: &[MediaFile], + library_kind: &MediaLibraryKind, +) -> PlannedLibraryItems { + let mut items_by_key = HashMap::::new(); + let mut leaf_identity_by_file_id = HashMap::new(); + + for file in files { + if *library_kind == MediaLibraryKind::Shows && file.media_kind == "video" { + let fallback_title = fallback_title_from_relative_path(&file.relative_path); + let parsed = parse_show_path( + &file.relative_path, + file.display_title + .as_deref() + .unwrap_or(fallback_title.as_str()), + ); + upsert_planned_item( + &mut items_by_key, + PlannedMediaItem { + identity_key: parsed.show_key.clone(), + parent_identity_key: None, + item_type: "show".into(), + display_title: parsed.show_title.clone(), + relative_path: parent_relative_path(&file.relative_path, 1), + media_kind: Some("video".into()), + season_number: None, + episode_number: None, + playable: false, + child_count: 0, + file_size: None, + duration_ms: None, + modified_at: file.modified_at, + explicit_id: None, + }, + ); + upsert_planned_item( + &mut items_by_key, + PlannedMediaItem { + identity_key: parsed.season_key.clone(), + parent_identity_key: Some(parsed.show_key.clone()), + item_type: "season".into(), + display_title: parsed.season_title.clone(), + relative_path: parent_relative_path(&file.relative_path, 2), + media_kind: Some("video".into()), + season_number: parsed.season_number, + episode_number: None, + playable: false, + child_count: 0, + file_size: None, + duration_ms: None, + modified_at: file.modified_at, + explicit_id: None, + }, + ); + upsert_planned_item( + &mut items_by_key, + PlannedMediaItem { + identity_key: parsed.episode_key.clone(), + parent_identity_key: Some(parsed.season_key.clone()), + item_type: "episode".into(), + display_title: parsed.episode_title.clone(), + relative_path: Some(file.relative_path.clone()), + media_kind: Some(file.media_kind.clone()), + season_number: parsed.season_number, + episode_number: parsed.episode_number, + playable: true, + child_count: 0, + file_size: Some(file.file_size), + duration_ms: file.duration_ms, + modified_at: file.modified_at, + explicit_id: Some(file.id), + }, + ); + leaf_identity_by_file_id.insert(file.id, parsed.episode_key); + continue; + } + + let item_type = match file.media_kind.as_str() { + "audio" => "track", + "image" => "photo", + "book" => "book", + _ => "movie", + }; + let identity_key = format!("file:{}", file.id); + upsert_planned_item( + &mut items_by_key, + PlannedMediaItem { + identity_key: identity_key.clone(), + parent_identity_key: None, + item_type: item_type.into(), + display_title: file + .display_title + .clone() + .unwrap_or_else(|| fallback_title_from_relative_path(&file.relative_path)), + relative_path: Some(file.relative_path.clone()), + media_kind: Some(file.media_kind.clone()), + season_number: None, + episode_number: None, + playable: matches!(file.media_kind.as_str(), "video" | "audio"), + child_count: 0, + file_size: Some(file.file_size), + duration_ms: file.duration_ms, + modified_at: file.modified_at, + explicit_id: Some(file.id), + }, + ); + leaf_identity_by_file_id.insert(file.id, identity_key); + } + + let mut child_counts = HashMap::::new(); + for item in items_by_key.values() { + if let Some(parent_identity_key) = &item.parent_identity_key { + *child_counts.entry(parent_identity_key.clone()).or_default() += 1; + } + } + for item in items_by_key.values_mut() { + item.child_count = child_counts.get(&item.identity_key).copied().unwrap_or_default(); + } + + let depth_by_key = items_by_key + .keys() + .map(|identity_key| (identity_key.clone(), item_depth(identity_key, &items_by_key))) + .collect::>(); + let mut items = items_by_key.into_values().collect::>(); + items.sort_by(|left, right| { + depth_by_key + .get(&left.identity_key) + .copied() + .unwrap_or_default() + .cmp( + &depth_by_key + .get(&right.identity_key) + .copied() + .unwrap_or_default(), + ) + .then_with(|| left.season_number.cmp(&right.season_number)) + .then_with(|| left.episode_number.cmp(&right.episode_number)) + .then_with(|| left.display_title.cmp(&right.display_title)) + }); + + PlannedLibraryItems { + items, + leaf_identity_by_file_id, + } +} + +fn upsert_planned_item( + items_by_key: &mut HashMap, + item: PlannedMediaItem, +) { + items_by_key + .entry(item.identity_key.clone()) + .and_modify(|existing| { + existing.modified_at = existing.modified_at.max(item.modified_at); + existing.duration_ms = Some( + existing.duration_ms.unwrap_or_default() + item.duration_ms.unwrap_or_default(), + ) + .filter(|value| *value > 0); + existing.file_size = match (existing.file_size, item.file_size) { + (Some(left), Some(right)) => Some(left + right), + (Some(left), None) => Some(left), + (None, Some(right)) => Some(right), + (None, None) => None, + }; + if existing.display_title.trim().is_empty() { + existing.display_title = item.display_title.clone(); + } + }) + .or_insert(item); +} + +fn item_depth( + identity_key: &str, + items_by_key: &HashMap, +) -> usize { + let mut depth = 0; + let mut next_parent = items_by_key + .get(identity_key) + .and_then(|item| item.parent_identity_key.as_deref()); + + while let Some(parent_identity_key) = next_parent { + depth += 1; + next_parent = items_by_key + .get(parent_identity_key) + .and_then(|item| item.parent_identity_key.as_deref()); + } + + depth +} + +fn parent_relative_path(relative_path: &str, depth: usize) -> Option { + let parts = relative_path + .replace('\\', "/") + .split('/') + .filter(|part| !part.trim().is_empty()) + .take(depth) + .map(str::to_string) + .collect::>(); + (!parts.is_empty()).then(|| parts.join("/")) +} + +fn parse_show_path(relative_path: &str, fallback_title: &str) -> ParsedShowPath { + let normalized = relative_path.replace('\\', "/"); + let parts = normalized + .split('/') + .filter(|part| !part.trim().is_empty()) + .collect::>(); + let show_title = parts + .first() + .copied() + .filter(|value| !value.trim().is_empty()) + .unwrap_or(fallback_title) + .trim() + .to_string(); + let season_source = if parts.len() >= 2 { + parts[parts.len().saturating_sub(2)] + } else { + fallback_title + }; + let season_number = detect_season_number(season_source) + .or_else(|| detect_season_number(fallback_title)) + .filter(|value| *value > 0); + let episode_number = detect_episode_number(fallback_title) + .or_else(|| detect_episode_number(parts.last().copied().unwrap_or_default())) + .filter(|value| *value > 0); + let episode_title = cleaned_episode_title(fallback_title, episode_number) + .or_else(|| Some(fallback_title.trim().to_string())) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| fallback_title.to_string()); + let season_title = season_number + .map(|number| format!("Season {}", number)) + .unwrap_or_else(|| season_source.trim().to_string()); + let show_key = format!("show:{}", normalize_identity_segment(&show_title)); + let season_key = format!( + "{}:season:{}", + show_key, + season_number + .map(|value| value.to_string()) + .unwrap_or_else(|| normalize_identity_segment(&season_title)) + ); + let episode_key = format!( + "{}:episode:{}:{}", + season_key, + episode_number + .map(|value| value.to_string()) + .unwrap_or_else(|| "unknown".into()), + normalize_identity_segment(&episode_title) + ); + + ParsedShowPath { + show_title, + show_key, + season_title, + season_key, + season_number, + episode_title, + episode_key, + episode_number, + } +} + +fn detect_season_number(value: &str) -> Option { + let lower = value.to_ascii_lowercase(); + for marker in ["season ", "series ", "s"] { + if let Some(index) = lower.find(marker) { + let digits = lower[index + marker.len()..] + .chars() + .take_while(|character| character.is_ascii_digit()) + .collect::(); + if let Ok(number) = digits.parse::() { + return Some(number); + } + } + } + None +} + +fn detect_episode_number(value: &str) -> Option { + let lower = value.to_ascii_lowercase(); + if let Some(season_index) = lower.find('e') { + let digits = lower[season_index + 1..] + .chars() + .take_while(|character| character.is_ascii_digit()) + .collect::(); + if let Ok(number) = digits.parse::() { + return Some(number); + } + } + if let Some(x_index) = lower.find('x') { + let digits = lower[x_index + 1..] + .chars() + .take_while(|character| character.is_ascii_digit()) + .collect::(); + if let Ok(number) = digits.parse::() { + return Some(number); + } + } + None +} + +fn cleaned_episode_title(value: &str, episode_number: Option) -> Option { + let mut cleaned = value.replace(['.', '_'], " "); + if let Some(number) = episode_number { + let markers = [format!("E{:02}", number), format!("e{:02}", number), format!("x{:02}", number)]; + for marker in markers { + cleaned = cleaned.replace(&marker, " "); + } + } + let collapsed = cleaned.split_whitespace().collect::>().join(" "); + (!collapsed.trim().is_empty()).then(|| collapsed.trim().to_string()) +} + +fn normalize_identity_segment(value: &str) -> String { + value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() { + character.to_ascii_lowercase() + } else { + '-' + } + }) + .collect::() + .split('-') + .filter(|segment| !segment.is_empty()) + .collect::>() + .join("-") +} + +/// Return persisted media files for a synchronized library. +pub fn get_library_files( + conn: &mut SqliteConnection, + library_id: i32, +) -> Result, diesel::result::Error> { + use crate::db::schema::media_files::dsl as media_files_dsl; + + let rows = media_files_dsl::media_files + .filter(media_files_dsl::library_id.eq(library_id)) + .order(media_files_dsl::relative_path.asc()) + .select(MediaFile::as_select()) + .load::(conn)?; + + Ok(rows.into_iter().map(to_persisted_file_summary).collect()) +} + +/// Return whether a media library exists in the persistent catalog. +pub fn library_exists( + conn: &mut SqliteConnection, + library_id: i32, +) -> Result { + use crate::db::schema::media_libraries::dsl as media_libraries_dsl; + + Ok(media_libraries_dsl::media_libraries + .filter(media_libraries_dsl::id.eq(library_id)) + .select(MediaLibrary::as_select()) + .first(conn) + .optional()? + .is_some()) +} + +/// List browser-facing media items, optionally filtered to one library. +pub fn list_media_items( + conn: &mut SqliteConnection, + library_id: Option, +) -> Result, diesel::result::Error> { + use crate::db::schema::media_items::dsl as media_items_dsl; + + let mut query = media_items_dsl::media_items.into_boxed(); + if let Some(library_id) = library_id { + query = query.filter(media_items_dsl::library_id.eq(library_id)); + } + + let rows = query + .order(( + media_items_dsl::display_title.asc(), + media_items_dsl::relative_path.asc(), + )) + .select(MediaItem::as_select()) + .load::(conn)?; + + let mut items = Vec::with_capacity(rows.len()); + for row in rows { + items.push(media_item_summary_with_preferred_title(conn, row)?); + } + + Ok(items) +} + +/// Return unmatched movie-like items that are eligible for automatic metadata linking. +pub fn list_automatic_metadata_candidates( + conn: &mut SqliteConnection, + limit: usize, +) -> Result, diesel::result::Error> { + use crate::db::schema::item_metadata_links::dsl as item_metadata_links_dsl; + use crate::db::schema::media_files::dsl as media_files_dsl; + use crate::db::schema::media_items::dsl as media_items_dsl; + use crate::db::schema::media_libraries::dsl as media_libraries_dsl; + + let libraries = media_libraries_dsl::media_libraries + .select(MediaLibrary::as_select()) + .load::(conn)?; + let libraries_by_id = libraries + .into_iter() + .map(|library| (library.id, library)) + .collect::>(); + let linked_item_ids = item_metadata_links_dsl::item_metadata_links + .select(item_metadata_links_dsl::media_item_id) + .load::(conn)? + .into_iter() + .collect::>(); + + let rows = media_files_dsl::media_files + .order(( + media_files_dsl::modified_at.desc(), + media_files_dsl::id.asc(), + )) + .select(MediaFile::as_select()) + .load::(conn)?; + + let mut candidates = Vec::new(); + for row in rows { + if linked_item_ids.contains(&row.id) || row.metadata_match_attempted_at.is_some() { + continue; + } + if row.media_kind != "video" { + continue; + } + + let Some(library) = libraries_by_id.get(&row.library_id) else { + continue; + }; + let library_settings = media_library_settings_from_row(library.clone()); + if library_settings.kind != MediaLibraryKind::Movies { + continue; + } + if !library_settings + .metadata_providers + .iter() + .any(|provider| *provider == MetadataProviderId::Tmdb) + { + continue; + } + + candidates.push(AutomaticMetadataCandidate { + item_id: row.id, + relative_path: row.relative_path.clone(), + display_title: row + .display_title + .unwrap_or_else(|| fallback_title_from_relative_path(&row.relative_path)), + modified_at: row.modified_at, + library_kind: library_settings.kind, + metadata_providers: library_settings.metadata_providers, + }); + } + + let show_rows = media_items_dsl::media_items + .filter(media_items_dsl::item_type.eq("show")) + .select(MediaItem::as_select()) + .load::(conn)?; + for row in show_rows { + if linked_item_ids.contains(&row.id) { + continue; + } + + let Some(library) = libraries_by_id.get(&row.library_id) else { + continue; + }; + let library_settings = media_library_settings_from_row(library.clone()); + if library_settings.kind != MediaLibraryKind::Shows { + continue; + } + if !library_settings + .metadata_providers + .iter() + .any(|provider| *provider == MetadataProviderId::Tmdb) + { + continue; + } + + candidates.push(AutomaticMetadataCandidate { + item_id: row.id, + relative_path: row.relative_path.unwrap_or_default(), + display_title: row.display_title, + modified_at: row.modified_at, + library_kind: library_settings.kind, + metadata_providers: library_settings.metadata_providers, + }); + } + + candidates.sort_by(|left, right| { + right + .modified_at + .cmp(&left.modified_at) + .then_with(|| left.display_title.cmp(&right.display_title)) + }); + candidates.truncate(limit); + + Ok(candidates) +} + +/// Mark a media item as having been considered by the automatic metadata linker. +pub fn mark_metadata_match_attempted( + conn: &mut SqliteConnection, + item_id: i32, + attempted_at: i64, +) -> Result<(), diesel::result::Error> { + use crate::db::schema::media_files::dsl as media_files_dsl; + + diesel::update(media_files_dsl::media_files.filter(media_files_dsl::id.eq(item_id))) + .set(media_files_dsl::metadata_match_attempted_at.eq(attempted_at)) + .execute(conn)?; + Ok(()) +} + +/// Return a single browser-facing media item by its stable identifier. +pub fn get_media_item( + conn: &mut SqliteConnection, + item_id: i32, + data_dir: &str, +) -> Result, diesel::result::Error> { + use crate::db::schema::media_items::dsl as media_items_dsl; + + let item = media_items_dsl::media_items + .filter(media_items_dsl::id.eq(item_id)) + .select(MediaItem::as_select()) + .first(conn) + .optional()?; + + let Some(item) = item else { + return Ok(None); + }; + + let backing_file = load_backing_media_file(conn, item_id)?; + let mut detail = to_media_item_detail(item.clone(), backing_file.as_ref()); + detail.hierarchy = load_media_item_hierarchy(conn, &item)?; + detail.children = list_media_item_children(conn, item.id)?; + + if let Some(link) = get_primary_item_metadata_link(conn, item_id)? { + if let Some(title) = link.title.as_deref().map(str::trim).filter(|title| !title.is_empty()) { + detail.display_title = title.to_string(); + } + let presentation = presentation_from_metadata_link(&link); + detail.tagline = presentation.tagline; + detail.overview = presentation.overview; + detail.genres = presentation.genres; + detail.release_year = presentation.release_year; + detail.linked_media_type = presentation.media_type; + detail.has_metadata = true; + detail.metadata_refresh_state = Some(link.refresh_state.clone()); + detail.metadata_refresh_error = link.refresh_error.clone(); + detail.artwork_updated_at = link.updated_at; + detail.trailer_title = presentation.trailer_title; + detail.trailer_url = presentation.trailer_url; + if presentation.poster_available { + detail.poster_url = Some(format!("/api/v1/items/{}/artwork?kind=poster", item_id)); + } + if presentation.backdrop_available { + detail.backdrop_url = Some(format!("/api/v1/items/{}/artwork?kind=backdrop", item_id)); + } + } + + if let Some(source_path) = resolve_media_item_source_path(conn, item_id)? { + let assets = discover_item_assets(item_id, &source_path, data_dir); + if assets.poster_path.is_some() { + detail.poster_url = Some(format!("/api/v1/items/{}/artwork?kind=poster", item_id)); + } + if assets.backdrop_path.is_some() { + detail.backdrop_url = Some(format!("/api/v1/items/{}/artwork?kind=backdrop", item_id)); + } + detail.theme_song_url = assets + .theme_song_path + .as_ref() + .map(|_| format!("/api/v1/items/{}/theme", item_id)); + detail.subtitle_tracks = assets + .subtitle_paths + .iter() + .enumerate() + .map(|(index, subtitle_path)| MediaSubtitleTrack { + index, + label: subtitle_label_from_path(&source_path, subtitle_path), + format: subtitle_path + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_uppercase()) + .unwrap_or_else(|| "Subtitle".into()), + url: format!("/api/v1/items/{}/subtitles/{}", item_id, index), + }) + .collect(); + } + + Ok(Some(detail)) +} + +/// Search browser-facing media items by title or relative path. +pub fn search_media_items( + conn: &mut SqliteConnection, + query: &str, + library_id: Option, +) -> Result, diesel::result::Error> { + if query.trim().is_empty() { + return Ok(Vec::new()); + } + + let query = query.trim().to_ascii_lowercase(); + let items = list_media_items(conn, library_id)?; + + Ok(items + .into_iter() + .filter(|item| { + item.display_title.to_ascii_lowercase().contains(&query) + || item.relative_path.to_ascii_lowercase().contains(&query) + || item.media_kind.to_ascii_lowercase().contains(&query) + }) + .collect()) +} + +/// Return Kodi/Plex-style media shelves for the browser home screen. +pub fn get_media_home( + conn: &mut SqliteConnection, + user_id: Option, + library_id: Option, +) -> Result { + let items = list_media_items(conn, library_id)?; + + let continue_watching = get_continue_watching_items(conn, user_id, library_id)?; + let recently_added = sort_recently_added(&items); + let recommended = sort_recommended(&items, &continue_watching); + let collections = list_metadata_collection_summaries(conn, library_id)?; + + Ok(MediaHome { + library_id, + shelves: vec![ + MediaShelf { + id: "continue_watching".into(), + title: "Continue watching".into(), + items: continue_watching, + }, + MediaShelf { + id: "recently_added".into(), + title: "Recently added".into(), + items: recently_added, + }, + MediaShelf { + id: "recommended".into(), + title: "Recommended".into(), + items: recommended, + }, + ], + collections, + }) +} + +/// Return a browser playback decision for a media item. +pub fn get_playback_decision( + conn: &mut SqliteConnection, + item_id: i32, +) -> Result, diesel::result::Error> { + use crate::db::schema::media_items::dsl as media_items_dsl; + + let item = media_items_dsl::media_items + .filter(media_items_dsl::id.eq(item_id)) + .select(MediaItem::as_select()) + .first(conn) + .optional()?; + + let Some(item) = item else { + return Ok(None); + }; + + let backing_file = load_backing_media_file(conn, item_id)?; + + Ok(Some(if let Some(row) = backing_file { + let can_direct_play = item.playable && is_browser_direct_play(&row); + let mime_type = detect_mime_type(&row); + + PlaybackDecision { + item_id, + can_direct_play, + transcode_required: item.playable && !can_direct_play, + reason: if can_direct_play { + "Browser direct play is supported for this item.".into() + } else { + "A future FFmpeg-backed transcode path will be required for browser playback.".into() + }, + stream_url: can_direct_play.then(|| format!("/api/v1/items/{}/stream", item_id)), + mime_type, + } + } else { + PlaybackDecision { + item_id, + can_direct_play: false, + transcode_required: false, + reason: "This item is a container and cannot be played directly.".into(), + stream_url: None, + mime_type: None, + } + })) +} + +/// Resolve the direct-play source path for a media item. +pub fn resolve_media_item_source_path( + conn: &mut SqliteConnection, + item_id: i32, +) -> Result, diesel::result::Error> { + use crate::db::schema::media_libraries::dsl as media_libraries_dsl; + + let media_file = load_backing_media_file(conn, item_id)?; + let Some(media_file) = media_file else { + return Ok(None); + }; + + let library = media_libraries_dsl::media_libraries + .filter(media_libraries_dsl::id.eq(media_file.library_id)) + .select(MediaLibrary::as_select()) + .first(conn) + .optional()?; + + Ok(library.map(|library| { + if !media_file.source_root_path.trim().is_empty() { + PathBuf::from(media_file.source_root_path).join(&media_file.relative_path) + } else { + let fallback_root = media_library_settings_from_row(library) + .configured_paths() + .into_iter() + .next() + .unwrap_or_default(); + PathBuf::from(fallback_root).join(&media_file.relative_path) + } + })) +} + +/// Resolve a local theme-song asset path for a media item. +pub fn resolve_item_theme_song_path( + conn: &mut SqliteConnection, + item_id: i32, + data_dir: &str, +) -> Result, diesel::result::Error> { + let Some(source_path) = resolve_media_item_source_path(conn, item_id)? else { + return Ok(None); + }; + + Ok(discover_item_assets(item_id, &source_path, data_dir).theme_song_path) +} + +/// Return the TMDB movie or show identifier to use for ThemerrDB theme-song lookups. +pub fn get_item_theme_song_themerr_reference( + conn: &mut SqliteConnection, + item_id: i32, +) -> Result, diesel::result::Error> { + use crate::db::schema::media_items::dsl as media_items_dsl; + + let mut current_id = Some(item_id); + let mut visited = HashSet::new(); + + while let Some(current_item_id) = current_id { + if !visited.insert(current_item_id) { + break; + } + + if let Some(link) = get_primary_item_metadata_link(conn, current_item_id)? { + if link.provider_id == MetadataProviderId::Tmdb.as_storage_value() { + if let Some(media_type) = link.media_type.as_deref() { + if matches!(media_type, "movie" | "tv") { + return Ok(Some((media_type.to_string(), link.external_id))); + } + } + } + } + + current_id = media_items_dsl::media_items + .filter(media_items_dsl::id.eq(current_item_id)) + .select(media_items_dsl::parent_id) + .first::>(conn) + .optional()? + .flatten(); + } + + Ok(None) +} + +/// Resolve a local subtitle asset path for a media item by track index. +pub fn resolve_item_subtitle_path( + conn: &mut SqliteConnection, + item_id: i32, + track_index: usize, + data_dir: &str, +) -> Result, diesel::result::Error> { + let Some(source_path) = resolve_media_item_source_path(conn, item_id)? else { + return Ok(None); + }; + + Ok(discover_item_assets(item_id, &source_path, data_dir) + .subtitle_paths + .into_iter() + .nth(track_index)) +} + +/// Resolve a local poster or backdrop asset path for a media item. +pub fn resolve_local_item_artwork_path( + conn: &mut SqliteConnection, + item_id: i32, + kind: ArtworkKind, + data_dir: &str, +) -> Result, diesel::result::Error> { + let Some(source_path) = resolve_media_item_source_path(conn, item_id)? else { + return Ok(None); + }; + + let assets = discover_item_assets(item_id, &source_path, data_dir); + Ok(match kind { + ArtworkKind::Poster => assets.poster_path, + ArtworkKind::Backdrop => assets.backdrop_path, + }) +} + +/// Store or update playback progress for one media item. +pub fn upsert_playback_progress( + conn: &mut SqliteConnection, + user_id: i32, + item_id: i32, + position_ms: i64, + duration_ms: Option, + completed: bool, +) -> Result<(), diesel::result::Error> { + use crate::db::schema::playback_progress::dsl as playback_progress_dsl; + + let existing = playback_progress_dsl::playback_progress + .filter(playback_progress_dsl::user_id.eq(user_id)) + .filter(playback_progress_dsl::media_item_id.eq(item_id)) + .select(PlaybackProgress::as_select()) + .first(conn) + .optional()?; + + let progress = NewPlaybackProgress { + user_id, + media_item_id: item_id, + position_ms, + duration_ms, + completed, + updated_at: Some(current_timestamp()), + }; + + if let Some(existing) = existing { + diesel::update(playback_progress_dsl::playback_progress.filter(playback_progress_dsl::id.eq(existing.id))) + .set(&progress) + .execute(conn)?; + } else { + diesel::insert_into(playback_progress_dsl::playback_progress) + .values(&progress) + .execute(conn)?; + } + + Ok(()) +} + +fn get_continue_watching_items( + conn: &mut SqliteConnection, + user_id: Option, + library_id: Option, +) -> Result, diesel::result::Error> { + use crate::db::schema::playback_progress::dsl as playback_progress_dsl; + + let Some(user_id) = user_id else { + return Ok(Vec::new()); + }; + + let progress_rows = playback_progress_dsl::playback_progress + .filter(playback_progress_dsl::user_id.eq(user_id)) + .filter(playback_progress_dsl::completed.eq(false)) + .order(playback_progress_dsl::updated_at.desc()) + .select(PlaybackProgress::as_select()) + .load::(conn)?; + + let mut items = Vec::new(); + for progress in progress_rows { + if let Some(item) = get_media_item_summary(conn, progress.media_item_id)? { + if library_id.is_none() || Some(item.library_id) == library_id { + items.push(item); + } + } + } + + Ok(items.into_iter().take(12).collect()) +} + +fn sort_recently_added(items: &[MediaItemSummary]) -> Vec { + let items_by_id = items + .iter() + .cloned() + .map(|item| (item.id, item)) + .collect::>(); + let mut show_groups = HashMap::>::new(); + let mut entries = Vec::<(Option, MediaItemSummary)>::new(); + + let mut leaf_items = items + .iter() + .filter(|item| item.child_count == 0) + .cloned() + .collect::>(); + leaf_items.sort_by(|left, right| right.modified_at.cmp(&left.modified_at)); + + for item in leaf_items { + if item.item_type == "episode" { + if let Some(show_id) = root_show_item_id(&item, &items_by_id) { + show_groups.entry(show_id).or_default().push(item); + continue; + } + } + + entries.push((item.modified_at, item)); + } + + for (show_id, episodes) in show_groups { + let representative = if episodes.len() == 1 { + episodes[0].clone() + } else { + let unique_season_ids = episodes + .iter() + .filter_map(|episode| episode.parent_id) + .collect::>(); + if unique_season_ids.len() == 1 { + unique_season_ids + .into_iter() + .next() + .and_then(|season_id| items_by_id.get(&season_id).cloned()) + .unwrap_or_else(|| episodes[0].clone()) + } else { + items_by_id + .get(&show_id) + .cloned() + .unwrap_or_else(|| episodes[0].clone()) + } + }; + let modified_at = episodes + .iter() + .filter_map(|episode| episode.modified_at) + .max(); + entries.push((modified_at, representative)); + } + + entries.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.display_title.cmp(&right.1.display_title))); + entries + .into_iter() + .map(|(_, item)| item) + .take(12) + .collect() +} + +fn root_show_item_id( + item: &MediaItemSummary, + items_by_id: &HashMap, +) -> Option { + let mut current = item; + + while let Some(parent_id) = current.parent_id { + let parent = items_by_id.get(&parent_id)?; + if parent.item_type == "show" { + return Some(parent.id); + } + current = parent; + } + + None +} + +fn sort_recommended( + items: &[MediaItemSummary], + continue_watching: &[MediaItemSummary], +) -> Vec { + let continue_ids: std::collections::HashSet = + continue_watching.iter().map(|item| item.id).collect(); + + let mut items = items + .iter() + .filter(|item| !continue_ids.contains(&item.id)) + .cloned() + .collect::>(); + items.sort_by(|left, right| { + right + .duration_ms + .unwrap_or_default() + .cmp(&left.duration_ms.unwrap_or_default()) + .then_with(|| right.modified_at.cmp(&left.modified_at)) + }); + items.into_iter().take(12).collect() +} + +/// Return one browser-facing media item summary by its stable identifier. +pub fn get_media_item_summary( + conn: &mut SqliteConnection, + item_id: i32, +) -> Result, diesel::result::Error> { + use crate::db::schema::media_items::dsl as media_items_dsl; + + let row = media_items_dsl::media_items + .filter(media_items_dsl::id.eq(item_id)) + .select(MediaItem::as_select()) + .first(conn) + .optional()?; + + match row { + Some(row) => Ok(Some(media_item_summary_with_preferred_title(conn, row)?)), + None => Ok(None), + } +} + +fn is_browser_direct_play(file: &MediaFile) -> bool { + let extension = Path::new(&file.relative_path) + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()); + + match file.media_kind.as_str() { + "video" => { + let container_supported = matches!( + file.container.as_deref(), + Some("mp4" | "mov" | "matroska,webm" | "webm") + ) || matches!(extension.as_deref(), Some("mp4" | "m4v" | "webm")); + let video_codec_supported = matches!( + file.video_codec.as_deref(), + Some("h264" | "av1" | "vp8" | "vp9") + ); + let audio_codec_supported = file.audio_codec.is_none() + || matches!(file.audio_codec.as_deref(), Some("aac" | "mp3" | "opus" | "vorbis")); + + container_supported && video_codec_supported && audio_codec_supported + } + "audio" => matches!( + extension.as_deref(), + Some("mp3" | "m4a" | "wav" | "ogg" | "opus" | "flac") + ) || matches!(file.audio_codec.as_deref(), Some("mp3" | "aac" | "opus" | "vorbis" | "flac")), + _ => false, + } +} + +fn detect_mime_type(file: &MediaFile) -> Option { + let extension = Path::new(&file.relative_path) + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()); + + match extension.as_deref() { + Some("mp4" | "m4v") => Some("video/mp4".into()), + Some("webm") => Some("video/webm".into()), + Some("mp3") => Some("audio/mpeg".into()), + Some("m4a") => Some("audio/mp4".into()), + Some("ogg" | "opus") => Some("audio/ogg".into()), + Some("wav") => Some("audio/wav".into()), + Some("flac") => Some("audio/flac".into()), + _ => None, + } +} + +fn inspect_library_with_inventory(library: &MediaLibrarySettings) -> LibraryInspection { + let configured_paths = library.configured_paths(); + let path = configured_paths.first().cloned().unwrap_or_default(); + let name = display_name(library, &path); + + if configured_paths.is_empty() { + return LibraryInspection { + summary: LibraryScanSummary { + name, + path, + paths: Vec::new(), + recursive: library.recursive, + kind: library.kind.clone(), + status: LibraryScanStatus::EmptyPath, + total_files: 0, + video_files: 0, + audio_files: 0, + image_files: 0, + book_files: 0, + other_files: 0, + error: Some("Library path is empty".into()), + }, + files: Vec::new(), + }; + } + + let mut counters = FileCounters::default(); + let mut files = Vec::new(); + let mut errors = Vec::new(); + let mut first_failure_status = None; + + for configured_path in &configured_paths { + let filesystem_path = Path::new(configured_path); + if !filesystem_path.exists() { + first_failure_status.get_or_insert(LibraryScanStatus::MissingPath); + errors.push(format!("{}: path does not exist", configured_path)); + continue; + } + + if !filesystem_path.is_dir() { + first_failure_status.get_or_insert(LibraryScanStatus::NotDirectory); + errors.push(format!("{}: path is not a directory", configured_path)); + continue; + } + + match scan_directory(filesystem_path, filesystem_path, library.recursive, &library.kind) { + Ok((nested, nested_files)) => { + counters.total_files += nested.total_files; + counters.video_files += nested.video_files; + counters.audio_files += nested.audio_files; + counters.image_files += nested.image_files; + counters.book_files += nested.book_files; + counters.other_files += nested.other_files; + files.extend(nested_files); + } + Err(error) => { + first_failure_status.get_or_insert(LibraryScanStatus::Unreadable); + errors.push(format!("{}: {}", configured_path, error)); + } + } + } + + if !files.is_empty() || errors.len() < configured_paths.len() { + LibraryInspection { + summary: LibraryScanSummary { + name, + path, + paths: configured_paths, + recursive: library.recursive, + kind: library.kind.clone(), + status: LibraryScanStatus::Available, + total_files: counters.total_files, + video_files: counters.video_files, + audio_files: counters.audio_files, + image_files: counters.image_files, + book_files: counters.book_files, + other_files: counters.other_files, + error: (!errors.is_empty()).then(|| errors.join("; ")), + }, + files, + } + } else { + LibraryInspection { + summary: LibraryScanSummary { + name, + path, + paths: configured_paths, + recursive: library.recursive, + kind: library.kind.clone(), + status: first_failure_status.unwrap_or(LibraryScanStatus::Unreadable), + total_files: 0, + video_files: 0, + audio_files: 0, + image_files: 0, + book_files: 0, + other_files: 0, + error: Some(errors.join("; ")), + }, + files: Vec::new(), + } + } +} + +impl LibraryScanStatus { + fn as_storage_value(&self) -> &'static str { + match self { + LibraryScanStatus::NeverScanned => "never_scanned", + LibraryScanStatus::Available => "available", + LibraryScanStatus::EmptyPath => "empty_path", + LibraryScanStatus::MissingPath => "missing_path", + LibraryScanStatus::NotDirectory => "not_directory", + LibraryScanStatus::Unreadable => "unreadable", + } + } + + fn from_storage_value(value: &str) -> Self { + match value.trim() { + "available" => LibraryScanStatus::Available, + "empty_path" => LibraryScanStatus::EmptyPath, + "missing_path" => LibraryScanStatus::MissingPath, + "not_directory" => LibraryScanStatus::NotDirectory, + "unreadable" => LibraryScanStatus::Unreadable, + _ => LibraryScanStatus::NeverScanned, + } + } +} + +impl MediaLibraryKind { + fn as_storage_value(&self) -> String { + match self { + MediaLibraryKind::Mixed => "mixed", + MediaLibraryKind::Movies => "movies", + MediaLibraryKind::Shows => "shows", + MediaLibraryKind::Music => "music", + MediaLibraryKind::Photos => "photos", + MediaLibraryKind::Books => "books", + MediaLibraryKind::HomeVideos => "home_videos", + } + .to_string() + } + + fn from_storage_value(value: &str) -> Self { + match value.trim() { + "movies" => MediaLibraryKind::Movies, + "shows" => MediaLibraryKind::Shows, + "music" => MediaLibraryKind::Music, + "photos" => MediaLibraryKind::Photos, + "books" => MediaLibraryKind::Books, + "home_videos" => MediaLibraryKind::HomeVideos, + _ => MediaLibraryKind::Mixed, + } + } +} + +fn media_library_settings_from_row(row: MediaLibrary) -> MediaLibrarySettings { + let mut paths = serde_json::from_str::>(&row.paths_json).unwrap_or_default(); + if paths.is_empty() { + paths = parse_library_storage_paths(&row.path); + } + if paths.is_empty() { + paths.push(row.path.clone()); + } + + let mut metadata_providers = serde_json::from_str::>(&row.metadata_providers_json) + .unwrap_or_default() + .into_iter() + .filter_map(|value| MetadataProviderId::from_storage_value(&value)) + .collect::>(); + if metadata_providers.is_empty() { + metadata_providers.push(MetadataProviderId::Tmdb); + } + + let mut library = MediaLibrarySettings { + name: row.name, + path: paths.first().cloned().unwrap_or_default(), + paths, + recursive: row.recursive, + kind: MediaLibraryKind::from_storage_value(&row.kind), + metadata_providers, + }; + library.normalize(); + library +} + +fn media_library_record_values(library: &MediaLibrarySettings) -> NewMediaLibrary { + let mut normalized = library.clone(); + normalized.normalize(); + let primary_path = normalized.primary_path(); + + NewMediaLibrary { + name: normalized.name, + path: primary_path, + paths_json: serde_json::to_string(&normalized.paths).unwrap_or_else(|_| "[]".into()), + kind: normalized.kind.as_storage_value(), + recursive: normalized.recursive, + metadata_providers_json: serde_json::to_string( + &normalized + .metadata_providers + .iter() + .map(|provider| provider.as_storage_value()) + .collect::>(), + ) + .unwrap_or_else(|_| "[\"tmdb\"]".into()), + } +} + +fn insert_media_library( + conn: &mut SqliteConnection, + library: &MediaLibrarySettings, +) -> Result<(), diesel::result::Error> { + use crate::db::schema::media_libraries::dsl as media_libraries_dsl; + + diesel::insert_into(media_libraries_dsl::media_libraries) + .values(&media_library_record_values(library)) + .execute(conn)?; + Ok(()) +} + +fn update_media_library( + conn: &mut SqliteConnection, + library_id: i32, + library: &MediaLibrarySettings, +) -> Result<(), diesel::result::Error> { + use crate::db::schema::media_libraries::dsl as media_libraries_dsl; + + diesel::update(media_libraries_dsl::media_libraries.filter(media_libraries_dsl::id.eq(library_id))) + .set(&media_library_record_values(library)) + .execute(conn)?; + Ok(()) +} + +impl DiscoveredMediaFile { + fn to_new_media_file( + &self, + library_id: i32, + metadata: ExtractedMetadata, + metadata_match_attempted_at: Option, + ) -> NewMediaFile { + NewMediaFile { + library_id, + source_root_path: self.source_root_path.clone(), + relative_path: self.relative_path.clone(), + file_size: self.file_size, + modified_at: self.modified_at, + media_kind: self.media_kind.clone(), + fingerprint_seed: self.fingerprint_seed.clone(), + display_title: metadata + .display_title + .or_else(|| Some(self.default_title.clone())), + container: metadata.container, + duration_ms: metadata.duration_ms, + bit_rate: metadata.bit_rate, + width: metadata.width, + height: metadata.height, + video_codec: metadata.video_codec, + audio_codec: metadata.audio_codec, + metadata_json: metadata.metadata_json, + metadata_updated_at: metadata.metadata_updated_at, + metadata_match_attempted_at, + media_item_id: None, + } + } +} + +fn extracted_metadata_from_existing(existing: &MediaFile) -> ExtractedMetadata { + ExtractedMetadata { + display_title: existing.display_title.clone(), + container: existing.container.clone(), + duration_ms: existing.duration_ms, + bit_rate: existing.bit_rate, + width: existing.width, + height: existing.height, + video_codec: existing.video_codec.clone(), + audio_codec: existing.audio_codec.clone(), + metadata_json: existing.metadata_json.clone(), + metadata_updated_at: existing.metadata_updated_at, + } +} + +fn default_metadata(file: &DiscoveredMediaFile) -> ExtractedMetadata { + ExtractedMetadata { + display_title: Some(file.default_title.clone()), + ..Default::default() + } +} + +fn extract_metadata( + file: &DiscoveredMediaFile, + probe_context: ProbeContext<'_>, +) -> ExtractedMetadata { + if !matches!(file.media_kind.as_str(), "video" | "audio") { + return default_metadata(file); + } + + if !probe_context.enabled { + return default_metadata(file); + } + + let output = Command::new(probe_context.ffprobe_path) + .args([ + "-v", + "quiet", + "-print_format", + "json", + "-show_format", + "-show_streams", + ]) + .arg(&file.full_path) + .output(); + + match output { + Ok(output) if output.status.success() => { + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + parse_ffprobe_metadata(&stdout, &file.default_title) + .unwrap_or_else(|| default_metadata(file)) + } + _ => default_metadata(file), + } +} + +fn parse_ffprobe_metadata( + raw_json: &str, + default_title: &str, +) -> Option { + let parsed: Value = serde_json::from_str(raw_json).ok()?; + let format = parsed.get("format"); + let streams = parsed + .get("streams") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + + let video_stream = streams + .iter() + .find(|stream| stream.get("codec_type").and_then(Value::as_str) == Some("video")); + let audio_stream = streams + .iter() + .find(|stream| stream.get("codec_type").and_then(Value::as_str) == Some("audio")); + + Some(ExtractedMetadata { + display_title: Some(default_title.to_string()), + container: format + .and_then(|format| format.get("format_name")) + .and_then(Value::as_str) + .map(|value| value.split(',').next().unwrap_or(value).trim().to_string()) + .filter(|value| !value.is_empty()), + duration_ms: format + .and_then(|format| format.get("duration")) + .and_then(Value::as_str) + .and_then(|value| value.parse::().ok()) + .map(|value| (value * 1000.0).round() as i64), + bit_rate: format + .and_then(|format| format.get("bit_rate")) + .and_then(Value::as_str) + .and_then(|value| value.parse::().ok()), + width: video_stream + .and_then(|stream| stream.get("width")) + .and_then(Value::as_i64) + .and_then(|value| i32::try_from(value).ok()), + height: video_stream + .and_then(|stream| stream.get("height")) + .and_then(Value::as_i64) + .and_then(|value| i32::try_from(value).ok()), + video_codec: video_stream + .and_then(|stream| stream.get("codec_name")) + .and_then(Value::as_str) + .map(ToOwned::to_owned), + audio_codec: audio_stream + .and_then(|stream| stream.get("codec_name")) + .and_then(Value::as_str) + .map(ToOwned::to_owned), + metadata_json: Some(raw_json.to_string()), + metadata_updated_at: Some(current_timestamp()), + }) +} + +fn to_persisted_file_summary(row: MediaFile) -> PersistedMediaFileSummary { + PersistedMediaFileSummary { + id: row.id, + library_id: row.library_id, + relative_path: row.relative_path.clone(), + file_size: row.file_size, + modified_at: row.modified_at, + media_kind: row.media_kind, + fingerprint_seed: row.fingerprint_seed, + display_title: row + .display_title + .unwrap_or_else(|| fallback_title_from_relative_path(&row.relative_path)), + container: row.container, + duration_ms: row.duration_ms, + width: row.width, + height: row.height, + video_codec: row.video_codec, + audio_codec: row.audio_codec, + } +} + +fn to_media_item_summary(item: MediaItem) -> MediaItemSummary { + let relative_path = item.relative_path.unwrap_or_default(); + + MediaItemSummary { + id: item.id, + library_id: item.library_id, + parent_id: item.parent_id, + item_type: item.item_type.clone(), + display_title: item.display_title, + relative_path, + media_kind: item + .media_kind + .unwrap_or_else(|| default_media_kind_for_item_type(&item.item_type).to_string()), + playable: item.playable, + child_count: item.child_count, + season_number: item.season_number, + episode_number: item.episode_number, + duration_ms: item.duration_ms, + width: None, + height: None, + genres: Vec::new(), + has_metadata: false, + metadata_refresh_state: None, + metadata_refresh_error: None, + artwork_updated_at: None, + modified_at: item.modified_at, + } +} + +fn media_item_summary_with_preferred_title( + conn: &mut SqliteConnection, + row: MediaItem, +) -> Result { + let mut summary = to_media_item_summary(row); + if let Some(link) = get_primary_item_metadata_link(conn, summary.id)? { + if let Some(title) = link.title.as_deref().map(str::trim).filter(|title| !title.is_empty()) { + summary.display_title = title.to_string(); + } + let presentation = presentation_from_metadata_link(&link); + summary.genres = presentation.genres; + summary.has_metadata = true; + summary.metadata_refresh_state = Some(link.refresh_state.clone()); + summary.metadata_refresh_error = link.refresh_error.clone(); + summary.artwork_updated_at = link.updated_at; + } + + Ok(summary) +} + +fn to_media_item_detail(item: MediaItem, backing_file: Option<&MediaFile>) -> MediaItemDetail { + let relative_path = item.relative_path.unwrap_or_default(); + + MediaItemDetail { + id: item.id, + library_id: item.library_id, + parent_id: item.parent_id, + item_type: item.item_type.clone(), + display_title: item.display_title, + relative_path, + file_size: item.file_size, + modified_at: item.modified_at, + media_kind: item + .media_kind + .unwrap_or_else(|| default_media_kind_for_item_type(&item.item_type).to_string()), + playable: item.playable, + child_count: item.child_count, + season_number: item.season_number, + episode_number: item.episode_number, + container: backing_file.and_then(|file| file.container.clone()), + duration_ms: item.duration_ms, + bit_rate: backing_file.and_then(|file| file.bit_rate), + width: backing_file.and_then(|file| file.width), + height: backing_file.and_then(|file| file.height), + video_codec: backing_file.and_then(|file| file.video_codec.clone()), + audio_codec: backing_file.and_then(|file| file.audio_codec.clone()), + metadata_json: backing_file.and_then(|file| file.metadata_json.clone()), + metadata_updated_at: backing_file.and_then(|file| file.metadata_updated_at), + poster_url: None, + backdrop_url: None, + theme_song_url: None, + theme_song_youtube_url: None, + tagline: None, + overview: None, + genres: Vec::new(), + release_year: None, + linked_media_type: None, + has_metadata: false, + metadata_refresh_state: None, + metadata_refresh_error: None, + artwork_updated_at: None, + trailer_title: None, + trailer_url: None, + subtitle_tracks: Vec::new(), + hierarchy: Vec::new(), + children: Vec::new(), + } +} + +fn default_media_kind_for_item_type(item_type: &str) -> &'static str { + match item_type { + "track" => "audio", + "photo" => "image", + "book" => "book", + _ => "video", + } +} + +fn load_backing_media_file( + conn: &mut SqliteConnection, + item_id: i32, +) -> Result, diesel::result::Error> { + use crate::db::schema::media_files::dsl as media_files_dsl; + + let row = media_files_dsl::media_files + .filter(media_files_dsl::media_item_id.eq(item_id)) + .order(media_files_dsl::id.asc()) + .select(MediaFile::as_select()) + .first(conn) + .optional()?; + if row.is_some() { + return Ok(row); + } + + media_files_dsl::media_files + .filter(media_files_dsl::id.eq(item_id)) + .select(MediaFile::as_select()) + .first(conn) + .optional() +} + +fn load_media_item_hierarchy( + conn: &mut SqliteConnection, + item: &MediaItem, +) -> Result, diesel::result::Error> { + use crate::db::schema::media_items::dsl as media_items_dsl; + + let mut hierarchy = Vec::new(); + let mut next_parent_id = item.parent_id; + + while let Some(parent_id) = next_parent_id { + let Some(parent) = media_items_dsl::media_items + .filter(media_items_dsl::id.eq(parent_id)) + .select(MediaItem::as_select()) + .first(conn) + .optional()? + else { + break; + }; + + next_parent_id = parent.parent_id; + hierarchy.push(media_item_summary_with_preferred_title(conn, parent)?); + } + + hierarchy.reverse(); + Ok(hierarchy) +} + +/// Return direct child summaries for one browser-facing media item. +pub fn list_media_item_children( + conn: &mut SqliteConnection, + item_id: i32, +) -> Result, diesel::result::Error> { + use crate::db::schema::media_items::dsl as media_items_dsl; + + let rows = media_items_dsl::media_items + .filter(media_items_dsl::parent_id.eq(item_id)) + .order(( + media_items_dsl::season_number.asc(), + media_items_dsl::episode_number.asc(), + media_items_dsl::display_title.asc(), + media_items_dsl::relative_path.asc(), + )) + .select(MediaItem::as_select()) + .load::(conn)?; + + let mut items = Vec::with_capacity(rows.len()); + for row in rows { + items.push(media_item_summary_with_preferred_title(conn, row)?); + } + + Ok(items) +} + +fn display_name( + library: &MediaLibrarySettings, + path: &str, +) -> String { + if !library.name.trim().is_empty() { + return library.name.trim().to_string(); + } + + Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .map(|name| name.to_string()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| "Unnamed library".into()) +} + +fn scan_directory( + root: &Path, + path: &Path, + recursive: bool, + library_kind: &MediaLibraryKind, +) -> io::Result<(FileCounters, Vec)> { + let mut counters = FileCounters::default(); + let mut files = Vec::new(); + + for entry in fs::read_dir(path)? { + let entry = entry?; + let entry_path = entry.path(); + + if entry_path.is_dir() { + if recursive { + let (nested, nested_files) = scan_directory(root, &entry_path, true, library_kind)?; + counters.total_files += nested.total_files; + counters.video_files += nested.video_files; + counters.audio_files += nested.audio_files; + counters.image_files += nested.image_files; + counters.book_files += nested.book_files; + counters.other_files += nested.other_files; + files.extend(nested_files); + } + continue; + } + + if entry_path.is_file() { + let kind = classify_file(&entry_path); + if !should_include_library_item(&entry_path, kind, library_kind) { + continue; + } + + counters.total_files += 1; + match kind { + FileKind::Video => counters.video_files += 1, + FileKind::Audio => counters.audio_files += 1, + FileKind::Image => counters.image_files += 1, + FileKind::Book => counters.book_files += 1, + FileKind::Other => counters.other_files += 1, + } + + let metadata = entry.metadata()?; + let file_size = i64::try_from(metadata.len()).unwrap_or(i64::MAX); + let modified_at = metadata + .modified() + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .and_then(|duration| i64::try_from(duration.as_secs()).ok()); + let relative_path = normalize_relative_path(root, &entry_path); + let media_kind = kind.as_storage_value().to_string(); + let default_title = entry_path + .file_stem() + .and_then(|value| value.to_str()) + .map(|value| value.to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| fallback_title_from_relative_path(&relative_path)); + + files.push(DiscoveredMediaFile { + full_path: entry_path, + source_root_path: root.to_string_lossy().to_string(), + fingerprint_seed: format!( + "{}:{}:{}:{}", + root.to_string_lossy(), + relative_path, + file_size, + modified_at.unwrap_or_default() + ), + relative_path, + file_size, + modified_at, + media_kind, + default_title, + }); + } + } + + Ok((counters, files)) +} + +fn detect_binary(binary: &str) -> BinaryCapability { + let output = Command::new(binary).arg("-version").output(); + + match output { + Ok(output) if output.status.success() => { + let version = String::from_utf8_lossy(&output.stdout) + .lines() + .next() + .map(|line| line.trim().to_string()) + .filter(|line| !line.is_empty()); + + BinaryCapability { + configured_path: binary.to_string(), + available: true, + version, + error: None, + } + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let error = if !stderr.is_empty() { + stderr + } else if !stdout.is_empty() { + stdout + } else { + format!("Process exited with status {}", output.status) + }; + + BinaryCapability { + configured_path: binary.to_string(), + available: false, + version: None, + error: Some(error), + } + } + Err(error) => BinaryCapability { + configured_path: binary.to_string(), + available: false, + version: None, + error: Some(error.to_string()), + }, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FileKind { + Video, + Audio, + Image, + Book, + Other, +} + +impl FileKind { + fn as_storage_value(&self) -> &'static str { + match self { + FileKind::Video => "video", + FileKind::Audio => "audio", + FileKind::Image => "image", + FileKind::Book => "book", + FileKind::Other => "other", + } + } +} + +fn classify_file(path: &Path) -> FileKind { + let extension = path + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()); + + match extension.as_deref() { + Some("mkv" | "mp4" | "avi" | "mov" | "wmv" | "m4v" | "webm" | "ts") => FileKind::Video, + Some("mp3" | "flac" | "aac" | "wav" | "ogg" | "m4a" | "opus") => FileKind::Audio, + Some("jpg" | "jpeg" | "png" | "gif" | "bmp" | "webp" | "tiff") => FileKind::Image, + Some("pdf" | "epub" | "cbz" | "cbr" | "mobi") => FileKind::Book, + _ => FileKind::Other, + } +} + +fn should_include_library_item( + path: &Path, + kind: FileKind, + library_kind: &MediaLibraryKind, +) -> bool { + match library_kind { + MediaLibraryKind::Movies | MediaLibraryKind::Shows | MediaLibraryKind::HomeVideos => { + kind == FileKind::Video + } + MediaLibraryKind::Music => kind == FileKind::Audio, + MediaLibraryKind::Photos => kind == FileKind::Image, + MediaLibraryKind::Books => kind == FileKind::Book, + MediaLibraryKind::Mixed => { + matches!(kind, FileKind::Video | FileKind::Audio | FileKind::Image | FileKind::Book) + && !is_named_theme_asset(path) + } + } +} + + +fn parse_library_storage_paths(value: &str) -> Vec { + let trimmed = value.trim(); + if trimmed.starts_with('[') { + serde_json::from_str::>(trimmed).unwrap_or_default() + } else if trimmed.is_empty() { + Vec::new() + } else { + vec![trimmed.to_string()] + } +} + +fn media_file_inventory_key(file: &MediaFile) -> String { + format!("{}\u{1f}{}", file.source_root_path, file.relative_path) +} + +fn discovered_media_file_inventory_key(file: &DiscoveredMediaFile) -> String { + format!("{}\u{1f}{}", file.source_root_path, file.relative_path) +} + +#[derive(Debug, Default)] +struct ResolvedItemAssets { + poster_path: Option, + backdrop_path: Option, + theme_song_path: Option, + subtitle_paths: Vec, +} + +fn discover_item_assets( + item_id: i32, + source_path: &Path, + data_dir: &str, +) -> ResolvedItemAssets { + let managed_dir = managed_item_asset_dir(data_dir, item_id); + let source_dir = source_path.parent().unwrap_or_else(|| Path::new("")); + let source_stem = source_path + .file_stem() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()) + .unwrap_or_default(); + + let poster_path = find_first_existing_asset( + &[ + managed_dir.as_path(), + source_dir, + ], + &[ + "poster.jpg", + "poster.jpeg", + "poster.png", + "poster.webp", + "folder.jpg", + "folder.jpeg", + "folder.png", + "cover.jpg", + "cover.png", + ], + &[source_stem.clone(), format!("{}-poster", source_stem)], + &["jpg", "jpeg", "png", "webp"], + ); + let backdrop_path = find_first_existing_asset( + &[ + managed_dir.as_path(), + source_dir, + ], + &[ + "backdrop.jpg", + "backdrop.jpeg", + "backdrop.png", + "fanart.jpg", + "fanart.jpeg", + "fanart.png", + "background.jpg", + "background.png", + ], + &[format!("{}-backdrop", source_stem), format!("{}-fanart", source_stem)], + &["jpg", "jpeg", "png", "webp"], + ); + let theme_song_path = find_first_existing_asset( + &[ + managed_dir.as_path(), + source_dir, + ], + &[ + "theme.mp3", + "theme.flac", + "theme.m4a", + "theme.ogg", + "theme.opus", + "theme.wav", + ], + &[], + &[], + ); + + let subtitle_paths = collect_subtitle_assets(&[managed_dir.as_path(), source_dir], &source_stem); + + ResolvedItemAssets { + poster_path, + backdrop_path, + theme_song_path, + subtitle_paths, + } +} + +fn managed_item_asset_dir(data_dir: &str, item_id: i32) -> PathBuf { + let item_hex = format!("{:08x}", item_id.max(0)); + let shard = &item_hex[0..2]; + Path::new(data_dir).join("item_assets").join(shard).join(item_hex) +} + +fn find_first_existing_asset( + directories: &[&Path], + fixed_names: &[&str], + stem_names: &[String], + extensions: &[&str], +) -> Option { + for directory in directories { + for fixed_name in fixed_names { + let candidate = directory.join(fixed_name); + if candidate.is_file() { + return Some(candidate); + } + } + + for stem_name in stem_names { + for extension in extensions { + let candidate = directory.join(format!("{}.{}", stem_name, extension)); + if candidate.is_file() { + return Some(candidate); + } + } + } + } + + None +} + +fn collect_subtitle_assets(directories: &[&Path], source_stem: &str) -> Vec { + let mut subtitle_paths = Vec::new(); + let mut seen = HashSet::new(); + + for directory in directories { + let Ok(entries) = fs::read_dir(directory) else { + continue; + }; + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() || !is_subtitle_extension(&path) { + continue; + } + + let stem = path + .file_stem() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()) + .unwrap_or_default(); + if stem != source_stem && !stem.starts_with(&format!("{}.", source_stem)) { + continue; + } + + let key = path.to_string_lossy().to_string(); + if seen.insert(key) { + subtitle_paths.push(path); + } + } + } + + subtitle_paths.sort(); + subtitle_paths +} + +fn subtitle_label_from_path(source_path: &Path, subtitle_path: &Path) -> String { + let source_stem = source_path + .file_stem() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()) + .unwrap_or_default(); + let subtitle_stem = subtitle_path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + let normalized_subtitle_stem = subtitle_stem.to_ascii_lowercase(); + + if let Some(suffix) = normalized_subtitle_stem.strip_prefix(&format!("{}.", source_stem)) { + if !suffix.trim().is_empty() { + return suffix.to_ascii_uppercase(); + } + } + + subtitle_path + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_uppercase()) + .unwrap_or_else(|| "Subtitle".into()) +} + +fn is_named_theme_asset(path: &Path) -> bool { + path.file_stem() + .and_then(|value| value.to_str()) + .map(|value| value.eq_ignore_ascii_case("theme")) + .unwrap_or(false) +} + +fn is_subtitle_extension(path: &Path) -> bool { + matches!( + path.extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()) + .as_deref(), + Some("srt" | "vtt" | "ass" | "ssa" | "sub") + ) +} + +fn normalize_relative_path( + root: &Path, + path: &Path, +) -> String { + let relative: PathBuf = path.strip_prefix(root).unwrap_or(path).to_path_buf(); + relative.to_string_lossy().replace('\\', "/") +} + +fn fallback_title_from_relative_path(relative_path: &str) -> String { + Path::new(relative_path) + .file_stem() + .and_then(|value| value.to_str()) + .map(|value| value.to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| relative_path.to_string()) +} + +fn current_timestamp() -> i64 { + std::time::SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|duration| i64::try_from(duration.as_secs()).ok()) + .unwrap_or_default() +} diff --git a/crates/server/src/metadata.rs b/crates/server/src/metadata.rs new file mode 100644 index 00000000..1cb5d26b --- /dev/null +++ b/crates/server/src/metadata.rs @@ -0,0 +1,1749 @@ +//! Metadata-provider registry and persistence helpers. + +// standard imports +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +// lib imports +use diesel::{ExpressionMethods, OptionalExtension, QueryDsl, RunQueryDsl, SelectableHelper, SqliteConnection}; +use once_cell::sync::Lazy; +use regex::Regex; +use reqwest::StatusCode; +use reqwest::header::RETRY_AFTER; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use strsim::normalized_levenshtein; + +// local imports +use crate::config::{ + MediaLibraryKind, MetadataProviderId, MetadataProviderSettings, MetadataSettings, +}; +use crate::db::configure_sqlite_connection; +use crate::db::models::{ + ItemMetadataCollection, ItemMetadataLink, MediaItem, NewItemMetadataCollection, + NewItemMetadataLink, +}; + +const TMDB_API_BASE: &str = "https://api.themoviedb.org/3"; +const TMDB_IMAGE_BASE: &str = "https://image.tmdb.org/t/p"; +const THEMERR_API_BASE: &str = "https://app.lizardbyte.dev/ThemerrDB"; + +static HTTP_CLIENT: Lazy = Lazy::new(|| { + reqwest::Client::builder() + .user_agent(format!("Koko/{}", env!("CARGO_PKG_VERSION"))) + .build() + .expect("Failed to build shared HTTP client") +}); +static TMDB_RATE_LIMITER: Lazy> = Lazy::new(|| tokio::sync::Mutex::new(Instant::now())); + +/// High-level descriptor for a metadata provider. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub struct MetadataProviderDescriptor { + /// Stable identifier for the provider. + pub id: MetadataProviderId, + /// Human-friendly provider name. + pub display_name: String, + /// Short description of the provider's purpose. + pub description: String, + /// Supported media-library kinds. + pub supported_kinds: Vec, + /// Whether an API key is required. + pub requires_api_key: bool, + /// Whether the provider is implemented in the current build. + pub implemented: bool, +} + +/// Runtime status for a metadata provider after applying user settings. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub struct MetadataProviderStatus { + /// Stable identifier for the provider. + pub id: MetadataProviderId, + /// Human-friendly provider name. + pub display_name: String, + /// Short description of the provider's purpose. + pub description: String, + /// Supported media-library kinds. + pub supported_kinds: Vec, + /// Whether an API key is required. + pub requires_api_key: bool, + /// Whether the provider is implemented in the current build. + pub implemented: bool, + /// Whether the provider is enabled in configuration. + pub enabled: bool, + /// Whether the provider has enough configuration to be used. + pub configured: bool, + /// Configured language preference for the provider. + pub language: String, +} + +/// Stored metadata match summary for one media item. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub struct ItemMetadataSummary { + /// Stable row identifier for the metadata link. + pub id: i32, + /// Provider identifier for the linked metadata. + pub provider_id: MetadataProviderId, + /// Provider-side external identifier. + pub external_id: String, + /// Linked title, if available. + pub title: Option, + /// Linked overview, if available. + pub overview: Option, + /// Poster or artwork URL, if available. + pub artwork_url: Option, + /// Backdrop artwork URL, if available. + pub backdrop_url: Option, + /// Release year, if available. + pub release_year: Option, + /// Provider-specific media type such as `movie` or `tv`. + pub media_type: Option, + /// Current match state. + pub match_state: String, + /// Raw stored provider payload, when available. + pub provider_payload_json: Option, + /// Cached poster path, when available. + pub cached_artwork_path: Option, + /// Cached backdrop path, when available. + pub cached_backdrop_path: Option, + /// Current refresh state such as fresh, pending, or error. + pub refresh_state: String, + /// Last successful refresh timestamp as Unix seconds, if available. + pub last_refreshed_at: Option, + /// Scheduled next refresh time as Unix seconds, if available. + pub next_refresh_at: Option, + /// Last refresh error, when available. + pub refresh_error: Option, + /// Last update timestamp as Unix seconds, if available. + pub updated_at: Option, +} + +/// Search result returned by a metadata provider. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub struct MetadataSearchResult { + /// Provider identifier. + pub provider_id: MetadataProviderId, + /// Provider-side external identifier. + pub external_id: String, + /// Provider-specific media type. + pub media_type: String, + /// Candidate title. + pub title: String, + /// Candidate overview, if available. + pub overview: Option, + /// Candidate poster URL, if available. + pub artwork_url: Option, + /// Candidate backdrop URL, if available. + pub backdrop_url: Option, + /// Candidate release year, if available. + pub release_year: Option, +} + +/// Collection summary aggregated across linked metadata rows. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub struct MetadataCollectionSummary { + /// Stable client-facing identifier. + pub id: String, + /// Provider identifier. + pub provider_id: MetadataProviderId, + /// Provider-side external identifier. + pub external_id: String, + /// Collection name. + pub name: String, + /// Collection overview when available. + pub overview: Option, + /// Collection poster or artwork URL when available. + pub artwork_url: Option, + /// Collection backdrop URL when available. + pub backdrop_url: Option, + /// Root media item identifiers that belong to the collection. + pub item_ids: Vec, + /// Number of unique root items in the collection. + pub item_count: usize, +} + +/// Stored metadata snapshot fetched from a provider. +#[derive(Debug, Clone)] +pub struct StoredMetadataSnapshot { + /// Provider identifier. + pub provider_id: MetadataProviderId, + /// Provider-side external identifier. + pub external_id: String, + /// Provider-specific media type. + pub media_type: Option, + /// Canonical title. + pub title: Option, + /// Canonical overview. + pub overview: Option, + /// Poster URL. + pub artwork_url: Option, + /// Backdrop URL. + pub backdrop_url: Option, + /// Release year. + pub release_year: Option, + /// Raw provider payload. + pub provider_payload_json: Option, +} + +/// Presentation fields derived from one stored metadata link. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct LinkedMetadataPresentation { + /// Tagline or short promotional line. + pub tagline: Option, + /// Long-form description or overview. + pub overview: Option, + /// Genre labels from the provider payload. + pub genres: Vec, + /// Release year, when known. + pub release_year: Option, + /// Provider media type such as movie or tv. + pub media_type: Option, + /// Whether poster artwork is available either locally or remotely. + pub poster_available: bool, + /// Whether backdrop artwork is available either locally or remotely. + pub backdrop_available: bool, + /// Human-friendly trailer title, when available. + pub trailer_title: Option, + /// Browser-embeddable trailer URL, when available. + pub trailer_url: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ParsedMovieName { + title: String, + year: Option, + tmdb_id: Option, +} + +static BRACED_TAG_REGEX: Lazy = Lazy::new(|| Regex::new(r"\{([^}]*)}").unwrap()); +static YEAR_REGEX: Lazy = Lazy::new(|| Regex::new(r"\b(19\d{2}|20\d{2}|21\d{2})\b").unwrap()); +static SPLIT_SUFFIX_REGEX: Lazy = Lazy::new(|| { + Regex::new(r"(?i)\s*[-–]\s*(cd|disc|disk|dvd|part|pt)\s*\d+\s*$").unwrap() +}); +static NOISE_TOKEN_REGEX: Lazy = Lazy::new(|| { + Regex::new( + r"(?i)\b(2160p|1080p|720p|480p|x264|x265|h264|h265|hevc|hdr|dv|webrip|web[- ]dl|bluray|brrip|dvdrip|remux|proper|repack|extended|unrated|criterion|aac|dts|truehd|atmos)\b", + ) + .unwrap() +}); + +/// Provider contract for metadata implementations. +pub trait MetadataProvider { + /// Return the provider descriptor. + fn descriptor(&self) -> MetadataProviderDescriptor; +} + +/// Registry of known metadata providers. +pub struct MetadataRegistry { + providers: Vec>, +} + +impl MetadataRegistry { + /// Create a new registry containing the built-in providers. + pub fn new() -> Self { + Self { + providers: vec![ + Box::new(TmdbMetadataProvider), + Box::new(MusicBrainzMetadataProvider), + Box::new(OpenLibraryMetadataProvider), + Box::new(LocalNfoMetadataProvider), + ], + } + } + + /// Return all built-in provider descriptors. + pub fn descriptors(&self) -> Vec { + self.providers.iter().map(|provider| provider.descriptor()).collect() + } +} + +impl Default for MetadataRegistry { + fn default() -> Self { + Self::new() + } +} + +/// Return provider statuses after applying the current settings. +pub fn list_provider_statuses(settings: &MetadataSettings) -> Vec { + let configured_settings: std::collections::HashMap = settings + .providers + .iter() + .cloned() + .map(|provider| (provider.id.clone(), provider)) + .collect(); + + MetadataRegistry::new() + .descriptors() + .into_iter() + .map(|descriptor| { + let setting = configured_settings.get(&descriptor.id).cloned(); + let enabled = setting.as_ref().map(|provider| provider.enabled).unwrap_or(false); + let language = setting + .as_ref() + .map(|provider| provider.language.clone()) + .unwrap_or_else(|| "en-US".into()); + let configured = if descriptor.requires_api_key { + setting + .and_then(|provider| provider.api_key) + .map(|value| !value.trim().is_empty()) + .unwrap_or(false) + } else { + true + }; + + MetadataProviderStatus { + id: descriptor.id, + display_name: descriptor.display_name, + description: descriptor.description, + supported_kinds: descriptor.supported_kinds, + requires_api_key: descriptor.requires_api_key, + implemented: descriptor.implemented, + enabled, + configured, + language, + } + }) + .collect() +} + +/// Return stored metadata links for one media item. +pub fn get_item_metadata_summaries( + conn: &mut SqliteConnection, + item_id: i32, +) -> Result, diesel::result::Error> { + use crate::db::schema::item_metadata_links::dsl as metadata_links_dsl; + + let rows = metadata_links_dsl::item_metadata_links + .filter(metadata_links_dsl::media_item_id.eq(item_id)) + .order(metadata_links_dsl::provider_id.asc()) + .select(ItemMetadataLink::as_select()) + .load::(conn)?; + + Ok(rows.into_iter().map(to_item_metadata_summary).collect()) +} + +/// Search TMDB for metadata candidates using the current provider configuration. +pub async fn search_tmdb( + settings: &MetadataSettings, + query: &str, +) -> Result, String> { + let provider = tmdb_provider_settings(settings)?; + let payload = tmdb_get_json::( + &provider, + "search/multi", + vec![("query", query.to_string())], + &format!("search query {:?}", query), + ) + .await?; + Ok(payload + .results + .into_iter() + .filter_map(|item| { + let media_type = item.media_type.unwrap_or_default(); + if media_type != "movie" && media_type != "tv" { + return None; + } + + let title = item.title.or(item.name)?; + Some(MetadataSearchResult { + provider_id: MetadataProviderId::Tmdb, + external_id: item.id.to_string(), + media_type, + title, + overview: item.overview, + artwork_url: item.poster_path.map(|path| tmdb_image_url(&path, "w500")), + backdrop_url: item.backdrop_path.map(|path| tmdb_image_url(&path, "w1280")), + release_year: extract_release_year(item.release_date.or(item.first_air_date)), + }) + }) + .collect()) +} + +/// Fetch and normalize a TMDB metadata snapshot for one provider item. +pub async fn fetch_tmdb_metadata_snapshot( + settings: &MetadataSettings, + external_id: &str, + media_type: &str, +) -> Result { + let provider = tmdb_provider_settings(settings)?; + let payload = tmdb_get_text( + &provider, + &format!("{}/{}", media_type, external_id), + vec![("append_to_response", "videos".to_string())], + &format!("details lookup for {media_type}:{external_id}"), + ) + .await?; + let parsed: Value = serde_json::from_str(&payload).map_err(|error| error.to_string())?; + let title = parsed + .get("title") + .or_else(|| parsed.get("name")) + .and_then(Value::as_str) + .map(ToOwned::to_owned); + let overview = parsed + .get("overview") + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .filter(|value| !value.trim().is_empty()); + let artwork_url = parsed + .get("poster_path") + .and_then(Value::as_str) + .map(|path| tmdb_image_url(path, "w500")); + let backdrop_url = parsed + .get("backdrop_path") + .and_then(Value::as_str) + .map(|path| tmdb_image_url(path, "w1280")); + let release_year = parsed + .get("release_date") + .or_else(|| parsed.get("first_air_date")) + .and_then(Value::as_str) + .map(|value| value.to_string()) + .and_then(|value| extract_release_year(Some(value))); + + Ok(StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tmdb, + external_id: external_id.to_string(), + media_type: Some(media_type.to_string()), + title, + overview, + artwork_url, + backdrop_url, + release_year, + provider_payload_json: Some(payload), + }) +} + +/// Guess the best TMDB movie match for one library item using filename cleanup and fuzzy scoring. +pub async fn guess_tmdb_movie_match( + settings: &MetadataSettings, + relative_path: &str, + display_title: &str, +) -> Result, String> { + let parsed = parse_movie_name(relative_path, display_title); + if parsed.title.trim().is_empty() { + return Ok(None); + } + + if let Some(tmdb_id) = parsed.tmdb_id.clone() { + let snapshot = fetch_tmdb_metadata_snapshot(settings, &tmdb_id, "movie").await?; + return Ok(Some(MetadataSearchResult { + provider_id: MetadataProviderId::Tmdb, + external_id: tmdb_id, + media_type: "movie".into(), + title: snapshot.title.unwrap_or(parsed.title), + overview: snapshot.overview, + artwork_url: snapshot.artwork_url, + backdrop_url: snapshot.backdrop_url, + release_year: snapshot.release_year, + })); + } + + let mut best_result = None; + let mut best_score = 0.0; + for result in search_tmdb(settings, &parsed.title).await? { + if result.media_type != "movie" { + continue; + } + + let score = movie_match_score(&parsed, &result); + if score > best_score { + best_score = score; + best_result = Some(result); + } + } + + Ok((best_score >= 0.78).then_some(best_result).flatten()) +} + +/// Guess the best TMDB television match for one show item using the show title and path. +pub async fn guess_tmdb_show_match( + settings: &MetadataSettings, + relative_path: &str, + display_title: &str, +) -> Result, String> { + let query = show_search_query(relative_path, display_title); + if query.trim().is_empty() { + return Ok(None); + } + + let mut best_result = None; + let mut best_score = 0.0; + for result in search_tmdb(settings, &query).await? { + if result.media_type != "tv" { + continue; + } + + let score = normalized_levenshtein( + &cleanup_movie_title(&query).to_ascii_lowercase(), + &cleanup_movie_title(&result.title).to_ascii_lowercase(), + ); + if score > best_score { + best_score = score; + best_result = Some(result); + } + } + + Ok((best_score >= 0.78).then_some(best_result).flatten()) +} + +/// Fetch TMDB metadata for one season of a linked show. +pub async fn fetch_tmdb_season_metadata_snapshot( + settings: &MetadataSettings, + show_external_id: &str, + season_number: i32, +) -> Result { + let provider = tmdb_provider_settings(settings)?; + let payload = tmdb_get_text( + &provider, + &format!("tv/{}/season/{}", show_external_id, season_number), + Vec::new(), + &format!("season lookup for tv:{show_external_id}:season:{season_number}"), + ) + .await?; + let parsed: Value = serde_json::from_str(&payload).map_err(|error| error.to_string())?; + + Ok(StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tmdb, + external_id: tmdb_season_external_id(show_external_id, season_number), + media_type: Some("tv_season".into()), + title: parsed.get("name").and_then(Value::as_str).map(ToOwned::to_owned), + overview: parsed + .get("overview") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + artwork_url: parsed + .get("poster_path") + .and_then(Value::as_str) + .map(|path| tmdb_image_url(path, "w500")), + backdrop_url: None, + release_year: parsed + .get("air_date") + .and_then(Value::as_str) + .map(|value| value.to_string()) + .and_then(|value| extract_release_year(Some(value))), + provider_payload_json: Some(payload), + }) +} + +/// Fetch TMDB metadata for one episode of a linked show. +pub async fn fetch_tmdb_episode_metadata_snapshot( + settings: &MetadataSettings, + show_external_id: &str, + season_number: i32, + episode_number: i32, +) -> Result { + let provider = tmdb_provider_settings(settings)?; + let payload = tmdb_get_text( + &provider, + &format!( + "tv/{}/season/{}/episode/{}", + show_external_id, season_number, episode_number + ), + Vec::new(), + &format!( + "episode lookup for tv:{show_external_id}:season:{season_number}:episode:{episode_number}" + ), + ) + .await?; + let parsed: Value = serde_json::from_str(&payload).map_err(|error| error.to_string())?; + + Ok(StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tmdb, + external_id: tmdb_episode_external_id(show_external_id, season_number, episode_number), + media_type: Some("tv_episode".into()), + title: parsed.get("name").and_then(Value::as_str).map(ToOwned::to_owned), + overview: parsed + .get("overview") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + artwork_url: parsed + .get("still_path") + .and_then(Value::as_str) + .map(|path| tmdb_image_url(path, "w780")), + backdrop_url: None, + release_year: parsed + .get("air_date") + .and_then(Value::as_str) + .map(|value| value.to_string()) + .and_then(|value| extract_release_year(Some(value))), + provider_payload_json: Some(payload), + }) +} + +/// Resolve a ThemerrDB YouTube theme-song URL for one linked TMDB movie or show. +pub async fn fetch_themerr_youtube_theme_url( + tmdb_media_type: &str, + external_id: &str, +) -> Result, String> { + let Some(database_path) = themerr_database_path_for_tmdb_media_type(tmdb_media_type) else { + return Ok(None); + }; + let normalized_external_id = external_id.trim(); + if normalized_external_id.is_empty() { + return Ok(None); + } + + let response = reqwest::Client::new() + .get(format!( + "{}/{}/{}.json", + THEMERR_API_BASE, database_path, normalized_external_id + )) + .send() + .await + .map_err(|error| error.to_string())?; + + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + if !response.status().is_success() { + return Err(format!( + "ThemerrDB lookup failed with status {}", + response.status() + )); + } + + let payload = response.text().await.map_err(|error| error.to_string())?; + Ok(parse_themerr_youtube_theme_url(&payload)) +} + +/// Upsert a stored metadata snapshot for one media item. +pub fn upsert_item_metadata_snapshot( + conn: &mut SqliteConnection, + item_id: i32, + snapshot: &StoredMetadataSnapshot, +) -> Result { + use crate::db::schema::item_metadata_links::dsl as metadata_links_dsl; + configure_sqlite_connection(conn)?; + retry_sqlite_write(|| { + let existing = metadata_links_dsl::item_metadata_links + .filter(metadata_links_dsl::media_item_id.eq(item_id)) + .filter(metadata_links_dsl::provider_id.eq(snapshot.provider_id.as_storage_value())) + .select(ItemMetadataLink::as_select()) + .first(conn) + .optional()?; + + let payload = NewItemMetadataLink { + media_item_id: item_id, + provider_id: snapshot.provider_id.as_storage_value().to_string(), + external_id: snapshot.external_id.clone(), + title: snapshot.title.clone(), + overview: snapshot.overview.clone(), + tagline: snapshot + .provider_payload_json + .as_deref() + .and_then(|payload| serde_json::from_str::(payload).ok()) + .and_then(|payload| payload.get("tagline").and_then(Value::as_str).map(str::to_string)), + artwork_url: snapshot.artwork_url.clone(), + backdrop_url: snapshot.backdrop_url.clone(), + release_year: snapshot.release_year, + media_type: snapshot.media_type.clone(), + relation_kind: "primary".into(), + match_state: "linked".into(), + provider_payload_json: snapshot.provider_payload_json.clone(), + cached_artwork_path: existing.as_ref().and_then(|row| row.cached_artwork_path.clone()), + cached_backdrop_path: existing.as_ref().and_then(|row| row.cached_backdrop_path.clone()), + refresh_state: "fresh".into(), + refresh_interval_seconds: 7 * 24 * 60 * 60, + last_refreshed_at: Some(current_timestamp()), + next_refresh_at: Some(current_timestamp() + (7 * 24 * 60 * 60)), + refresh_error: None, + updated_at: Some(current_timestamp()), + }; + + if let Some(existing) = existing { + diesel::update(metadata_links_dsl::item_metadata_links.filter(metadata_links_dsl::id.eq(existing.id))) + .set(&payload) + .execute(conn)?; + } else { + diesel::insert_into(metadata_links_dsl::item_metadata_links) + .values(&payload) + .execute(conn)?; + } + + let row = metadata_links_dsl::item_metadata_links + .filter(metadata_links_dsl::media_item_id.eq(item_id)) + .filter(metadata_links_dsl::provider_id.eq(snapshot.provider_id.as_storage_value())) + .select(ItemMetadataLink::as_select()) + .first(conn)?; + + sync_item_metadata_collections(conn, row.id, snapshot)?; + + Ok(to_item_metadata_summary(row)) + }) +} + +/// Create or update one metadata-link refresh state for asynchronous work tracking. +pub fn set_item_metadata_refresh_state( + conn: &mut SqliteConnection, + item_id: i32, + provider_id: MetadataProviderId, + external_id: &str, + media_type: Option<&str>, + refresh_state: &str, + refresh_error: Option<&str>, +) -> Result { + use crate::db::schema::item_metadata_links::dsl as metadata_links_dsl; + configure_sqlite_connection(conn)?; + retry_sqlite_write(|| { + let existing = metadata_links_dsl::item_metadata_links + .filter(metadata_links_dsl::media_item_id.eq(item_id)) + .filter(metadata_links_dsl::provider_id.eq(provider_id.as_storage_value())) + .select(ItemMetadataLink::as_select()) + .first(conn) + .optional()?; + + let payload = NewItemMetadataLink { + media_item_id: item_id, + provider_id: provider_id.as_storage_value().to_string(), + external_id: external_id.to_string(), + title: existing.as_ref().and_then(|row| row.title.clone()), + overview: existing.as_ref().and_then(|row| row.overview.clone()), + tagline: existing.as_ref().and_then(|row| row.tagline.clone()), + artwork_url: existing.as_ref().and_then(|row| row.artwork_url.clone()), + backdrop_url: existing.as_ref().and_then(|row| row.backdrop_url.clone()), + release_year: existing.as_ref().and_then(|row| row.release_year), + media_type: media_type + .map(str::to_string) + .or_else(|| existing.as_ref().and_then(|row| row.media_type.clone())), + relation_kind: existing + .as_ref() + .map(|row| row.relation_kind.clone()) + .unwrap_or_else(|| "primary".into()), + match_state: existing + .as_ref() + .map(|row| row.match_state.clone()) + .unwrap_or_else(|| "linked".into()), + provider_payload_json: existing.as_ref().and_then(|row| row.provider_payload_json.clone()), + cached_artwork_path: existing.as_ref().and_then(|row| row.cached_artwork_path.clone()), + cached_backdrop_path: existing.as_ref().and_then(|row| row.cached_backdrop_path.clone()), + refresh_state: refresh_state.to_string(), + refresh_interval_seconds: existing + .as_ref() + .map(|row| row.refresh_interval_seconds) + .unwrap_or(7 * 24 * 60 * 60), + last_refreshed_at: existing.as_ref().and_then(|row| row.last_refreshed_at), + next_refresh_at: existing.as_ref().and_then(|row| row.next_refresh_at), + refresh_error: refresh_error.map(str::to_string), + updated_at: Some(current_timestamp()), + }; + + if let Some(existing) = existing { + diesel::update(metadata_links_dsl::item_metadata_links.filter(metadata_links_dsl::id.eq(existing.id))) + .set(&payload) + .execute(conn)?; + } else { + diesel::insert_into(metadata_links_dsl::item_metadata_links) + .values(&payload) + .execute(conn)?; + } + + let row = metadata_links_dsl::item_metadata_links + .filter(metadata_links_dsl::media_item_id.eq(item_id)) + .filter(metadata_links_dsl::provider_id.eq(provider_id.as_storage_value())) + .select(ItemMetadataLink::as_select()) + .first(conn)?; + + Ok(to_item_metadata_summary(row)) + }) +} + +/// Return collection summaries derived from stored metadata for the requested library scope. +pub fn list_metadata_collection_summaries( + conn: &mut SqliteConnection, + library_id: Option, +) -> Result, diesel::result::Error> { + use crate::db::schema::item_metadata_collections::dsl as collection_dsl; + use crate::db::schema::item_metadata_links::dsl as link_dsl; + use crate::db::schema::media_items::dsl as media_items_dsl; + + let mut item_query = media_items_dsl::media_items.into_boxed(); + if let Some(library_id) = library_id { + item_query = item_query.filter(media_items_dsl::library_id.eq(library_id)); + } + let items = item_query.select(MediaItem::as_select()).load::(conn)?; + if items.is_empty() { + return Ok(Vec::new()); + } + + let items_by_id = items + .iter() + .cloned() + .map(|item| (item.id, item)) + .collect::>(); + let link_rows = link_dsl::item_metadata_links + .filter(link_dsl::media_item_id.eq_any(items_by_id.keys().copied().collect::>())) + .select(ItemMetadataLink::as_select()) + .load::(conn)?; + if link_rows.is_empty() { + return Ok(Vec::new()); + } + + let links_by_id = link_rows + .into_iter() + .map(|link| (link.id, link)) + .collect::>(); + let collection_rows = collection_dsl::item_metadata_collections + .filter(collection_dsl::metadata_link_id.eq_any(links_by_id.keys().copied().collect::>())) + .select(ItemMetadataCollection::as_select()) + .load::(conn)?; + + let mut grouped = HashMap::)>::new(); + for collection in collection_rows { + let Some(link) = links_by_id.get(&collection.metadata_link_id) else { + continue; + }; + let Some(root_id) = root_media_item_id(link.media_item_id, &items_by_id) else { + continue; + }; + + grouped + .entry(format!("{}:{}", collection.provider_id, collection.external_id)) + .and_modify(|(_, item_ids)| { + item_ids.insert(root_id); + }) + .or_insert_with(|| { + let mut item_ids = HashSet::new(); + item_ids.insert(root_id); + (collection, item_ids) + }); + } + + let mut summaries = grouped + .into_values() + .map(|(collection, item_ids)| { + let mut item_ids = item_ids.into_iter().collect::>(); + item_ids.sort_unstable(); + MetadataCollectionSummary { + id: format!("{}:{}", collection.provider_id, collection.external_id), + provider_id: metadata_provider_id_from_db(&collection.provider_id), + external_id: collection.external_id, + name: collection.name, + overview: collection.overview, + artwork_url: collection.artwork_url, + backdrop_url: collection.backdrop_url, + item_count: item_ids.len(), + item_ids, + } + }) + .collect::>(); + summaries.sort_by(|left, right| left.name.cmp(&right.name)); + Ok(summaries) +} + +/// Return the first stored metadata link for a media item. +pub fn get_primary_item_metadata_link( + conn: &mut SqliteConnection, + item_id: i32, +) -> Result, diesel::result::Error> { + use crate::db::schema::item_metadata_links::dsl as metadata_links_dsl; + + metadata_links_dsl::item_metadata_links + .filter(metadata_links_dsl::media_item_id.eq(item_id)) + .order(metadata_links_dsl::updated_at.desc()) + .select(ItemMetadataLink::as_select()) + .first(conn) + .optional() +} + +/// Return an already stored metadata snapshot matching one provider item. +pub fn get_stored_metadata_snapshot( + conn: &mut SqliteConnection, + provider_id: MetadataProviderId, + external_id: &str, + media_type: Option<&str>, +) -> Result, diesel::result::Error> { + use crate::db::schema::item_metadata_links::dsl as metadata_links_dsl; + + let mut query = metadata_links_dsl::item_metadata_links + .filter(metadata_links_dsl::provider_id.eq(provider_id.as_storage_value())) + .filter(metadata_links_dsl::external_id.eq(external_id)) + .into_boxed(); + if let Some(media_type) = media_type { + query = query.filter(metadata_links_dsl::media_type.eq(media_type)); + } + + let row = query + .order(metadata_links_dsl::updated_at.desc()) + .select(ItemMetadataLink::as_select()) + .first(conn) + .optional()?; + + Ok(row.and_then(stored_snapshot_from_link)) +} + +/// Extract presentation-ready metadata from a stored link payload. +pub fn presentation_from_metadata_link(link: &ItemMetadataLink) -> LinkedMetadataPresentation { + let parsed_payload = link + .provider_payload_json + .as_deref() + .and_then(|payload| serde_json::from_str::(payload).ok()); + + let tagline = parsed_payload + .as_ref() + .and_then(|payload| payload.get("tagline")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let overview = parsed_payload + .as_ref() + .and_then(|payload| payload.get("overview")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| link.overview.clone()); + let genres = parsed_payload + .as_ref() + .and_then(|payload| payload.get("genres")) + .and_then(Value::as_array) + .map(|genres| { + genres + .iter() + .filter_map(|genre| genre.get("name").and_then(Value::as_str)) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect::>() + }) + .unwrap_or_default(); + let release_year = parsed_payload + .as_ref() + .and_then(|payload| { + payload + .get("release_date") + .or_else(|| payload.get("first_air_date")) + .and_then(Value::as_str) + .map(|value| value.to_string()) + }) + .and_then(|value| extract_release_year(Some(value))) + .or(link.release_year); + + LinkedMetadataPresentation { + tagline, + overview, + genres, + release_year, + media_type: link.media_type.clone(), + poster_available: link.cached_artwork_path.is_some() || link.artwork_url.is_some(), + backdrop_available: link.cached_backdrop_path.is_some() || link.backdrop_url.is_some(), + trailer_title: parsed_payload + .as_ref() + .and_then(tmdb_trailer_entry) + .and_then(|entry| entry.get("name")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + trailer_url: parsed_payload + .as_ref() + .and_then(tmdb_trailer_entry) + .and_then(|entry| entry.get("site").and_then(Value::as_str).zip(entry.get("key").and_then(Value::as_str))) + .and_then(|(site, key)| youtube_embed_url(site, key)), + } +} + +/// Persist stored metadata payload and cached artwork into the managed item asset structure. +pub async fn persist_item_metadata_assets( + snapshot: &StoredMetadataSnapshot, + item_id: i32, + data_dir: &str, +) -> Result<(Option, Option), String> { + let item_dir = managed_item_asset_dir(data_dir, item_id); + fs::create_dir_all(&item_dir).map_err(|error| error.to_string())?; + + if let Some(payload_json) = &snapshot.provider_payload_json { + let metadata_file_name = format!("{}.json", snapshot.provider_id.as_storage_value()); + fs::write(item_dir.join(metadata_file_name), payload_json).map_err(|error| error.to_string())?; + } + + let poster_path = if let Some(url) = &snapshot.artwork_url { + try_cache_item_artwork( + url, + &item_dir, + &format!("{}_poster", snapshot.provider_id.as_storage_value()), + ) + .await + } else { + None + }; + let backdrop_path = if let Some(url) = &snapshot.backdrop_url { + try_cache_item_artwork( + url, + &item_dir, + &format!("{}_backdrop", snapshot.provider_id.as_storage_value()), + ) + .await + } else { + None + }; + + Ok((poster_path, backdrop_path)) +} + +/// Persist a cached artwork path for a metadata link. +pub fn update_cached_artwork_path( + conn: &mut SqliteConnection, + link_id: i32, + kind: ArtworkKind, + cache_path: &Path, +) -> Result<(), diesel::result::Error> { + use crate::db::schema::item_metadata_links::dsl as metadata_links_dsl; + configure_sqlite_connection(conn)?; + retry_sqlite_write(|| { + match kind { + ArtworkKind::Poster => { + diesel::update(metadata_links_dsl::item_metadata_links.filter(metadata_links_dsl::id.eq(link_id))) + .set(metadata_links_dsl::cached_artwork_path.eq(cache_path.to_string_lossy().to_string())) + .execute(conn)?; + } + ArtworkKind::Backdrop => { + diesel::update(metadata_links_dsl::item_metadata_links.filter(metadata_links_dsl::id.eq(link_id))) + .set(metadata_links_dsl::cached_backdrop_path.eq(cache_path.to_string_lossy().to_string())) + .execute(conn)?; + } + } + + Ok(()) + }) +} + +/// Poster or backdrop artwork kind. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ArtworkKind { + /// Poster or cover art. + Poster, + /// Background or hero artwork. + Backdrop, +} + +impl ArtworkKind { + /// Parse an artwork kind from a query parameter. + pub fn from_query_value(value: Option<&str>) -> Self { + match value.unwrap_or_default() { + "backdrop" => ArtworkKind::Backdrop, + _ => ArtworkKind::Poster, + } + } +} + +/// Download and cache one artwork asset to disk. +pub async fn cache_artwork( + url: &str, + cache_dir: &Path, + cache_key: &str, +) -> Result { + fs::create_dir_all(cache_dir).map_err(|error| error.to_string())?; + + let extension = Path::new(url) + .extension() + .and_then(|value| value.to_str()) + .filter(|value| !value.is_empty()) + .unwrap_or("jpg"); + let cache_path = cache_dir.join(format!("{}.{}", sanitize_cache_key(cache_key), extension)); + if cache_path.is_file() { + return Ok(cache_path); + } + + let bytes = reqwest::get(url) + .await + .map_err(|error| error.to_string())? + .bytes() + .await + .map_err(|error| error.to_string())?; + fs::write(&cache_path, bytes).map_err(|error| error.to_string())?; + + Ok(cache_path) +} + +struct TmdbMetadataProvider; +struct MusicBrainzMetadataProvider; +struct OpenLibraryMetadataProvider; +struct LocalNfoMetadataProvider; + +impl MetadataProvider for TmdbMetadataProvider { + fn descriptor(&self) -> MetadataProviderDescriptor { + MetadataProviderDescriptor { + id: MetadataProviderId::Tmdb, + display_name: "TheMovieDB".into(), + description: "Primary movie and television metadata provider for Koko.".into(), + supported_kinds: vec![MediaLibraryKind::Movies, MediaLibraryKind::Shows], + requires_api_key: true, + implemented: true, + } + } +} + +impl MetadataProvider for MusicBrainzMetadataProvider { + fn descriptor(&self) -> MetadataProviderDescriptor { + MetadataProviderDescriptor { + id: MetadataProviderId::MusicBrainz, + display_name: "MusicBrainz".into(), + description: "Planned music metadata provider for albums, artists, and tracks.".into(), + supported_kinds: vec![MediaLibraryKind::Music], + requires_api_key: false, + implemented: false, + } + } +} + +impl MetadataProvider for OpenLibraryMetadataProvider { + fn descriptor(&self) -> MetadataProviderDescriptor { + MetadataProviderDescriptor { + id: MetadataProviderId::OpenLibrary, + display_name: "Open Library".into(), + description: "Planned book metadata provider for ebooks, PDFs, and comics.".into(), + supported_kinds: vec![MediaLibraryKind::Books], + requires_api_key: false, + implemented: false, + } + } +} + +impl MetadataProvider for LocalNfoMetadataProvider { + fn descriptor(&self) -> MetadataProviderDescriptor { + MetadataProviderDescriptor { + id: MetadataProviderId::LocalNfo, + display_name: "Local NFO".into(), + description: "Planned sidecar metadata provider for locally curated libraries.".into(), + supported_kinds: vec![ + MediaLibraryKind::Movies, + MediaLibraryKind::Shows, + MediaLibraryKind::Music, + MediaLibraryKind::Books, + MediaLibraryKind::HomeVideos, + ], + requires_api_key: false, + implemented: false, + } + } +} + +#[derive(Debug, Deserialize)] +struct TmdbSearchResponse { + results: Vec, +} + +#[derive(Debug, Deserialize)] +struct TmdbSearchItem { + id: i64, + media_type: Option, + title: Option, + name: Option, + overview: Option, + poster_path: Option, + backdrop_path: Option, + release_date: Option, + first_air_date: Option, +} + +fn tmdb_provider_settings(settings: &MetadataSettings) -> Result { + let provider = settings + .providers + .iter() + .find(|provider| provider.id == MetadataProviderId::Tmdb && provider.enabled) + .cloned() + .ok_or_else(|| "TMDB is not enabled in the current configuration.".to_string())?; + + let api_key = provider + .api_key + .clone() + .unwrap_or_default() + .trim() + .to_string(); + if api_key.is_empty() { + return Err("TMDB is enabled but no API key is configured.".into()); + } + + Ok(provider) +} + +async fn wait_for_tmdb_rate_limit(provider: &MetadataProviderSettings) { + let requests_per_second = provider.rate_limit_per_second.max(1); + let interval = Duration::from_secs_f64(1.0 / f64::from(requests_per_second)); + let mut next_available_at = TMDB_RATE_LIMITER.lock().await; + let now = Instant::now(); + if *next_available_at > now { + tokio::time::sleep((*next_available_at).saturating_duration_since(now)).await; + } + let base = Instant::now(); + *next_available_at = base.checked_add(interval).unwrap_or(base); +} + +async fn tmdb_get_text( + provider: &MetadataProviderSettings, + path: &str, + mut query: Vec<(&'static str, String)>, + context: &str, +) -> Result { + let api_key = provider.api_key.as_deref().unwrap_or_default().to_string(); + query.push(("api_key", api_key)); + query.push(("language", provider.language.clone())); + + let retry_attempts = usize::try_from(provider.retry_attempts).unwrap_or(0); + let base_backoff = Duration::from_millis(u64::from(provider.retry_backoff_ms.max(1))); + + for attempt in 0..=retry_attempts { + wait_for_tmdb_rate_limit(provider).await; + let request_url = format!("{}/{}", TMDB_API_BASE, path.trim_start_matches('/')); + let response = HTTP_CLIENT.get(&request_url).query(&query).send().await; + + match response { + Ok(response) => { + let status = response.status(); + let retry_after = response + .headers() + .get(RETRY_AFTER) + .and_then(|value| value.to_str().ok()) + .and_then(parse_retry_after_seconds) + .map(Duration::from_secs); + let payload = response.text().await.map_err(|error| error.to_string())?; + if status.is_success() { + return Ok(payload); + } + + let rate_limited = status == StatusCode::TOO_MANY_REQUESTS + || retry_after.is_some() + || payload.to_ascii_lowercase().contains("rate limit"); + let payload_snippet = format_payload_snippet(&payload); + let retryable = rate_limited || status.is_server_error(); + if retryable && attempt < retry_attempts { + let attempt_number = attempt + 1; + let multiplier = 1_u32.checked_shl(u32::try_from(attempt).unwrap_or(0)).unwrap_or(u32::MAX); + let backoff = retry_after.unwrap_or_else(|| base_backoff.saturating_mul(multiplier)); + log::warn!( + "TMDB request retry scheduled for {} after status {}{}{} (attempt {}/{} in {} ms)", + context, + status, + if rate_limited { " [rate limited]" } else { "" }, + payload_snippet, + attempt_number, + retry_attempts + 1, + backoff.as_millis() + ); + tokio::time::sleep(backoff).await; + continue; + } + + return Err(format!( + "TMDB {} failed with status {}{}{}", + context, + status, + if rate_limited { " [rate limited]" } else { "" }, + payload_snippet + )); + } + Err(error) => { + if attempt < retry_attempts { + let attempt_number = attempt + 1; + let multiplier = 1_u32.checked_shl(u32::try_from(attempt).unwrap_or(0)).unwrap_or(u32::MAX); + let backoff = base_backoff.saturating_mul(multiplier); + log::warn!( + "TMDB request retry scheduled for {} after transport error: {} (attempt {}/{} in {} ms)", + context, + error, + attempt_number, + retry_attempts + 1, + backoff.as_millis() + ); + tokio::time::sleep(backoff).await; + continue; + } + + return Err(format!("TMDB {} request failed: {}", context, error)); + } + } + } + + Err(format!("TMDB {} request failed after retries", context)) +} + +async fn tmdb_get_json( + provider: &MetadataProviderSettings, + path: &str, + query: Vec<(&'static str, String)>, + context: &str, +) -> Result +where + T: serde::de::DeserializeOwned, +{ + let payload = tmdb_get_text(provider, path, query, context).await?; + serde_json::from_str::(&payload).map_err(|error| format!("TMDB {} returned invalid JSON: {}", context, error)) +} + +fn parse_retry_after_seconds(value: &str) -> Option { + value.trim().parse::().ok() +} + +fn format_payload_snippet(payload: &str) -> String { + let snippet = payload.split_whitespace().collect::>().join(" "); + if snippet.is_empty() { + return String::new(); + } + + let truncated = if snippet.chars().count() > 180 { + let prefix = snippet.chars().take(180).collect::(); + format!("{}…", prefix) + } else { + snippet + }; + format!(" | response: {}", truncated) +} + +fn retry_sqlite_write(mut operation: F) -> Result +where + F: FnMut() -> Result, +{ + let mut attempts = 0; + loop { + match operation() { + Ok(value) => return Ok(value), + Err(error) if is_sqlite_locked_error(&error) && attempts < 4 => { + attempts += 1; + let backoff_ms = 25_u64.saturating_mul(2_u64.saturating_pow(attempts)); + std::thread::sleep(Duration::from_millis(backoff_ms)); + } + Err(error) => return Err(error), + } + } +} + +fn is_sqlite_locked_error(error: &diesel::result::Error) -> bool { + match error { + diesel::result::Error::DatabaseError(_, info) => info.message().to_ascii_lowercase().contains("database is locked"), + _ => error.to_string().to_ascii_lowercase().contains("database is locked"), + } +} + +fn to_item_metadata_summary(link: ItemMetadataLink) -> ItemMetadataSummary { + ItemMetadataSummary { + id: link.id, + provider_id: metadata_provider_id_from_db(&link.provider_id), + external_id: link.external_id, + title: link.title, + overview: link.overview, + artwork_url: link.artwork_url, + backdrop_url: link.backdrop_url, + release_year: link.release_year, + media_type: link.media_type, + match_state: link.match_state, + provider_payload_json: link.provider_payload_json, + cached_artwork_path: link.cached_artwork_path, + cached_backdrop_path: link.cached_backdrop_path, + refresh_state: link.refresh_state, + last_refreshed_at: link.last_refreshed_at, + next_refresh_at: link.next_refresh_at, + refresh_error: link.refresh_error, + updated_at: link.updated_at, + } +} + +fn tmdb_trailer_entry(payload: &Value) -> Option<&Value> { + let results = payload + .get("videos") + .and_then(|videos| videos.get("results")) + .and_then(Value::as_array)?; + + results + .iter() + .find(|entry| { + entry.get("site").and_then(Value::as_str) == Some("YouTube") + && entry.get("type").and_then(Value::as_str) == Some("Trailer") + && entry.get("official").and_then(Value::as_bool).unwrap_or(false) + }) + .or_else(|| { + results.iter().find(|entry| { + entry.get("site").and_then(Value::as_str) == Some("YouTube") + && matches!(entry.get("type").and_then(Value::as_str), Some("Trailer" | "Teaser")) + }) + }) +} + +fn youtube_embed_url(site: &str, key: &str) -> Option { + if site != "YouTube" || key.trim().is_empty() { + return None; + } + + Some(format!("https://www.youtube.com/embed/{}?autoplay=1&rel=0", key.trim())) +} + +fn themerr_database_path_for_tmdb_media_type(tmdb_media_type: &str) -> Option<&'static str> { + match tmdb_media_type.trim() { + "movie" => Some("movies/themoviedb"), + "tv" => Some("tv_shows/themoviedb"), + _ => None, + } +} + +fn parse_themerr_youtube_theme_url(payload_json: &str) -> Option { + serde_json::from_str::(payload_json) + .ok()? + .get("youtube_theme_url")? + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn stored_snapshot_from_link(link: ItemMetadataLink) -> Option { + link.provider_payload_json.as_ref()?; + + Some(StoredMetadataSnapshot { + provider_id: metadata_provider_id_from_db(&link.provider_id), + external_id: link.external_id, + media_type: link.media_type, + title: link.title, + overview: link.overview, + artwork_url: link.artwork_url, + backdrop_url: link.backdrop_url, + release_year: link.release_year, + provider_payload_json: link.provider_payload_json, + }) +} + +fn managed_item_asset_dir(data_dir: &str, item_id: i32) -> PathBuf { + let item_hex = format!("{:08x}", item_id.max(0)); + let shard = &item_hex[0..2]; + Path::new(data_dir).join("item_assets").join(shard).join(item_hex) +} + +async fn try_cache_item_artwork( + url: &str, + item_dir: &Path, + cache_key: &str, +) -> Option { + match cache_artwork(url, item_dir, cache_key).await { + Ok(path) => Some(path), + Err(error) => { + log::warn!("Failed to cache managed artwork asset from {}: {}", url, error); + None + } + } +} + +fn tmdb_image_url(path: &str, size: &str) -> String { + format!("{}/{}/{}", TMDB_IMAGE_BASE, size, path.trim_start_matches('/')) +} + +fn tmdb_season_external_id(show_external_id: &str, season_number: i32) -> String { + format!("tv:{show_external_id}:season:{season_number}") +} + +fn tmdb_episode_external_id(show_external_id: &str, season_number: i32, episode_number: i32) -> String { + format!("tv:{show_external_id}:season:{season_number}:episode:{episode_number}") +} + +fn extract_release_year(value: Option) -> Option { + value + .as_deref() + .and_then(|value| value.split('-').next()) + .and_then(|value| value.parse::().ok()) +} + +fn parse_movie_name(relative_path: &str, display_title: &str) -> ParsedMovieName { + let relative_path = relative_path.replace('\\', "/"); + let path = Path::new(&relative_path); + let file_stem = path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or(display_title); + let parent_name = path + .parent() + .and_then(Path::file_name) + .and_then(|value| value.to_str()) + .unwrap_or_default(); + + let preferred_source = if parent_name.eq_ignore_ascii_case(file_stem) || YEAR_REGEX.is_match(parent_name) { + parent_name + } else { + file_stem + }; + + let tmdb_id = BRACED_TAG_REGEX + .captures_iter(preferred_source) + .chain(BRACED_TAG_REGEX.captures_iter(file_stem)) + .find_map(|captures| { + let value = captures.get(1)?.as_str().trim(); + value + .strip_prefix("tmdb-") + .map(|id| id.trim().to_string()) + .filter(|id| !id.is_empty()) + }); + let year = YEAR_REGEX + .captures(preferred_source) + .or_else(|| YEAR_REGEX.captures(file_stem)) + .and_then(|captures| captures.get(1)) + .and_then(|value| value.as_str().parse::().ok()); + + let cleaned = cleanup_movie_title(preferred_source); + let fallback = cleanup_movie_title(display_title); + ParsedMovieName { + title: if cleaned.is_empty() { fallback } else { cleaned }, + year, + tmdb_id, + } +} + +fn show_search_query(relative_path: &str, display_title: &str) -> String { + let normalized_path = relative_path.replace('\\', "/"); + let first_segment = normalized_path + .split('/') + .find(|segment| !segment.trim().is_empty()) + .unwrap_or_default() + .to_string(); + let folder_name = Path::new(&normalized_path) + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_string(); + + [display_title.to_string(), first_segment, folder_name] + .into_iter() + .map(|value| cleanup_movie_title(&value)) + .find(|value| !value.trim().is_empty()) + .unwrap_or_default() +} + +fn cleanup_movie_title(value: &str) -> String { + let without_tags = BRACED_TAG_REGEX.replace_all(value, " "); + let without_split_suffix = SPLIT_SUFFIX_REGEX.replace(&without_tags, " "); + let mut normalized = without_split_suffix.replace(['.', '_'], " "); + if let Some(year_match) = YEAR_REGEX.find(&normalized) { + normalized = normalized[..year_match.start()].to_string(); + } + normalized = NOISE_TOKEN_REGEX.replace_all(&normalized, " ").to_string(); + + normalized + .split_whitespace() + .collect::>() + .join(" ") + .trim_matches(|character: char| !character.is_ascii_alphanumeric()) + .to_string() +} + +fn movie_match_score(parsed: &ParsedMovieName, result: &MetadataSearchResult) -> f64 { + let candidate_title = cleanup_movie_title(&result.title); + if candidate_title.is_empty() || parsed.title.is_empty() { + return 0.0; + } + + let mut score = normalized_levenshtein( + &parsed.title.to_ascii_lowercase(), + &candidate_title.to_ascii_lowercase(), + ); + if let Some(expected_year) = parsed.year { + match result.release_year { + Some(candidate_year) if candidate_year == expected_year => { + score += 0.18; + } + Some(candidate_year) if (candidate_year - expected_year).abs() == 1 => { + score += 0.05; + } + Some(_) => { + score -= 0.2; + } + None => { + score -= 0.04; + } + } + } + + score.clamp(0.0, 1.0) +} + +fn sanitize_cache_key(value: &str) -> String { + value + .chars() + .map(|character| if character.is_ascii_alphanumeric() { character } else { '_' }) + .collect() +} + +fn root_media_item_id(item_id: i32, items_by_id: &HashMap) -> Option { + let mut current_id = item_id; + let mut seen = HashSet::new(); + + loop { + let item = items_by_id.get(¤t_id)?; + let Some(parent_id) = item.parent_id else { + return Some(item.id); + }; + if !seen.insert(parent_id) { + return Some(item.id); + } + current_id = parent_id; + } +} + +fn sync_item_metadata_collections( + conn: &mut SqliteConnection, + metadata_link_id: i32, + snapshot: &StoredMetadataSnapshot, +) -> Result<(), diesel::result::Error> { + use crate::db::schema::item_metadata_collections::dsl as collection_dsl; + + diesel::delete( + collection_dsl::item_metadata_collections + .filter(collection_dsl::metadata_link_id.eq(metadata_link_id)), + ) + .execute(conn)?; + + let Some(collection) = snapshot + .provider_payload_json + .as_deref() + .and_then(parse_tmdb_collection_payload) + else { + return Ok(()); + }; + + diesel::insert_into(collection_dsl::item_metadata_collections) + .values(&NewItemMetadataCollection { + metadata_link_id, + provider_id: snapshot.provider_id.as_storage_value().to_string(), + external_id: collection.external_id, + name: collection.name, + overview: collection.overview, + artwork_url: collection.artwork_url, + backdrop_url: collection.backdrop_url, + provider_payload_json: collection.provider_payload_json, + updated_at: Some(current_timestamp()), + }) + .execute(conn)?; + + Ok(()) +} + +fn parse_tmdb_collection_payload(payload_json: &str) -> Option { + let payload = serde_json::from_str::(payload_json).ok()?; + let collection = payload.get("belongs_to_collection")?; + let external_id = collection.get("id")?.as_i64()?.to_string(); + let name = collection.get("name")?.as_str()?.trim().to_string(); + if name.is_empty() { + return None; + } + + Some(ParsedTmdbCollection { + external_id, + name, + overview: collection + .get("overview") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + artwork_url: collection + .get("poster_path") + .and_then(Value::as_str) + .map(|path| tmdb_image_url(path, "w500")), + backdrop_url: collection + .get("backdrop_path") + .and_then(Value::as_str) + .map(|path| tmdb_image_url(path, "w1280")), + provider_payload_json: Some(collection.to_string()), + }) +} + +#[derive(Debug, Clone)] +struct ParsedTmdbCollection { + external_id: String, + name: String, + overview: Option, + artwork_url: Option, + backdrop_url: Option, + provider_payload_json: Option, +} + +fn current_timestamp() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok() + .and_then(|duration| i64::try_from(duration.as_secs()).ok()) + .unwrap_or_default() +} + +fn metadata_provider_id_from_db(value: &str) -> MetadataProviderId { + MetadataProviderId::from_storage_value(value).unwrap_or_else(|| { + log::warn!("Ignoring unexpected stored metadata provider id: {}", value); + MetadataProviderId::Tmdb + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cleanup_movie_title_strips_tags_and_noise() { + assert_eq!( + cleanup_movie_title("Blade.Runner.1982.{edition-Final Cut}.1080p.BluRay.x264"), + "Blade Runner" + ); + assert_eq!( + parse_movie_name( + "Blade Runner (1982) {tmdb-78}/Blade Runner (1982) {edition-Final Cut}.mkv", + "Blade Runner (1982) {edition-Final Cut}" + ), + ParsedMovieName { + title: "Blade Runner".into(), + year: Some(1982), + tmdb_id: Some("78".into()), + } + ); + } + + #[test] + fn movie_match_score_prefers_matching_year() { + let parsed = ParsedMovieName { + title: "The Matrix".into(), + year: Some(1999), + tmdb_id: None, + }; + let matching_year = MetadataSearchResult { + provider_id: MetadataProviderId::Tmdb, + external_id: "603".into(), + media_type: "movie".into(), + title: "The Matrix".into(), + overview: None, + artwork_url: None, + backdrop_url: None, + release_year: Some(1999), + }; + let wrong_year = MetadataSearchResult { + release_year: Some(2003), + ..matching_year.clone() + }; + + assert!(movie_match_score(&parsed, &matching_year) > movie_match_score(&parsed, &wrong_year)); + } + + #[test] + fn parse_themerr_youtube_theme_url_extracts_watch_url() { + let payload = serde_json::json!({ + "id": 603, + "title": "The Matrix", + "youtube_theme_url": "https://www.youtube.com/watch?v=SLBACEP6LsI" + }) + .to_string(); + + assert_eq!( + parse_themerr_youtube_theme_url(&payload).as_deref(), + Some("https://www.youtube.com/watch?v=SLBACEP6LsI") + ); + } + + #[test] + fn parse_themerr_youtube_theme_url_rejects_missing_url() { + let payload = serde_json::json!({ + "id": 1399, + "name": "Game of Thrones" + }) + .to_string(); + + assert_eq!(parse_themerr_youtube_theme_url(&payload), None); + } +} + diff --git a/crates/server/src/tray.rs b/crates/server/src/tray.rs index 6ba73a90..63a392fd 100644 --- a/crates/server/src/tray.rs +++ b/crates/server/src/tray.rs @@ -6,8 +6,7 @@ use tao::{ event_loop::{ControlFlow, EventLoopBuilder}, }; use tray_icon::{ - TrayIconBuilder, - TrayIconEvent, + TrayIconBuilder, TrayIconEvent, menu::{AboutMetadata, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}, }; diff --git a/crates/server/src/web/mod.rs b/crates/server/src/web/mod.rs index 1078b842..f0ca87ba 100644 --- a/crates/server/src/web/mod.rs +++ b/crates/server/src/web/mod.rs @@ -12,7 +12,7 @@ use rocket_okapi::{rapidoc::*, swagger_ui::*}; // local imports use crate::certs; -use crate::config::GLOBAL_SETTINGS; +use crate::config::current_settings; use crate::db::{DbConn, Migrate}; use crate::globals; use crate::signal_handler::ShutdownSignal; @@ -24,17 +24,19 @@ pub fn rocket() -> rocket::Rocket { /// Build the web server with a custom database path (primarily for testing). pub fn rocket_with_db_path(custom_db_path: Option) -> rocket::Rocket { + let settings = current_settings(); + // the cert path changes depending on if the user wants to use custom certs let (cert_path, key_path); - if !GLOBAL_SETTINGS.server.use_custom_certs { - cert_path = format!("{}/cert.pem", GLOBAL_SETTINGS.general.data_dir); - key_path = format!("{}/key.pem", GLOBAL_SETTINGS.general.data_dir); + if !settings.server.use_custom_certs { + cert_path = format!("{}/cert.pem", settings.general.data_dir); + key_path = format!("{}/key.pem", settings.general.data_dir); } else { - cert_path = GLOBAL_SETTINGS.server.cert_path.clone(); - key_path = GLOBAL_SETTINGS.server.key_path.clone(); + cert_path = settings.server.cert_path.clone(); + key_path = settings.server.key_path.clone(); } - if GLOBAL_SETTINGS.server.use_https { + if settings.server.use_https { certs::ensure_certificates_exist(cert_path.clone(), key_path.clone()); } @@ -50,11 +52,11 @@ pub fn rocket_with_db_path(custom_db_path: Option) -> rocket::Rocket) -> rocket::Rocket) -> rocket::Rocket String { - format!("Welcome to {}!", globals::GLOBAL_APP_NAME) +pub async fn index() -> Result> { + let index_path = web_client_index_path(); + + if let Some(index_path) = index_path { + return NamedFile::open(index_path) + .await + .map_err(|_| RawHtml(web_client_missing_html())); + } + + Err(RawHtml(web_client_missing_html())) +} + +#[get("/", rank = 100)] +pub async fn spa_asset(path: PathBuf) -> Option { + let dist_dir = web_client_dist_dir()?; + let requested_path = dist_dir.join(&path); + + if requested_path.is_file() { + return NamedFile::open(requested_path).await.ok(); + } + + if Path::new(&path).extension().is_none() { + return NamedFile::open(dist_dir.join("index.html")).await.ok(); + } + + None +} + +fn web_client_index_path() -> Option { + let dist_dir = web_client_dist_dir()?; + let index_path = dist_dir.join("index.html"); + index_path.is_file().then_some(index_path) +} + +fn web_client_dist_dir() -> Option { + for candidate in web_client_dist_candidates() { + if candidate.is_dir() { + return Some(candidate); + } + } + + None +} + +fn web_client_dist_candidates() -> Vec { + let mut candidates = Vec::new(); + + if let Ok(path) = env::var("KOKO_WEB_CLIENT_DIST") { + let path = path.trim(); + if !path.is_empty() { + candidates.push(PathBuf::from(path)); + } + } + + if let Ok(current_dir) = env::current_dir() { + candidates.push(current_dir.join("crates").join("client-web").join("dist")); + } + + candidates.push( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("client-web") + .join("dist"), + ); + candidates +} + +fn web_client_missing_html() -> String { + format!( + r#" + + + + + {0} + + + +
+

{0}

+

The web client bundle is not available yet.

+

Build crates/client-web and make sure the output exists at crates/client-web/dist, or set KOKO_WEB_CLIENT_DIST to a built client directory.

+
+ +"#, + globals::GLOBAL_APP_NAME + ) } diff --git a/crates/server/src/web/routes/media.rs b/crates/server/src/web/routes/media.rs new file mode 100644 index 00000000..efb70734 --- /dev/null +++ b/crates/server/src/web/routes/media.rs @@ -0,0 +1,1708 @@ +//! Media and system discovery routes. + +// lib imports +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + +use once_cell::sync::Lazy; +use rocket::fs::NamedFile; +use rocket::get; +use rocket::http::Status; +use rocket::post; +use rocket::serde::Deserialize; +use rocket::serde::json::Json; +use rocket_okapi::openapi; +use schemars::JsonSchema; +use serde::Serialize; + +// local imports +use crate::auth::UserGuard; +use crate::config::{FfmpegStrategy, MetadataProviderId, Settings, current_settings}; +use crate::db::DbConn; +use crate::globals; +use crate::media::{ + MediaHome, MediaItemDetail, MediaItemSummary, PersistedLibrarySummary, + PersistedMediaFileSummary, PlaybackDecision, TranscodingCapability, + get_library_files, get_media_home, get_media_item, get_persisted_library_summaries, + get_item_theme_song_themerr_reference, get_media_item_summary, get_playback_decision, inspect_transcoding_capability, + library_exists, list_media_item_children, + list_automatic_metadata_candidates, list_library_settings, list_media_items, + mark_metadata_match_attempted, resolve_item_subtitle_path, resolve_item_theme_song_path, + resolve_local_item_artwork_path, resolve_media_item_source_path, search_media_items, + sync_persisted_library_catalog, upsert_playback_progress, +}; +use crate::metadata::{ + ArtworkKind, ItemMetadataSummary, MetadataProviderStatus, + MetadataSearchResult, StoredMetadataSnapshot, fetch_themerr_youtube_theme_url, + fetch_tmdb_episode_metadata_snapshot, + fetch_tmdb_metadata_snapshot, fetch_tmdb_season_metadata_snapshot, + get_item_metadata_summaries, get_primary_item_metadata_link, get_stored_metadata_snapshot, + guess_tmdb_movie_match, guess_tmdb_show_match, list_provider_statuses, + persist_item_metadata_assets, search_tmdb, set_item_metadata_refresh_state, update_cached_artwork_path, + upsert_item_metadata_snapshot, +}; + +/// Capability summary returned to clients during bootstrap. +#[derive(Debug, Serialize, JsonSchema)] +pub struct ServerCapabilitiesResponse { + /// Application name. + pub app_name: String, + /// Current server version. + pub version: String, + /// Base server URL derived from the current settings. + pub server_url: String, + /// Whether HTTPS is enabled. + pub https_enabled: bool, + /// Number of configured libraries. + pub libraries_configured: usize, + /// Current FFmpeg integration strategy. + pub ffmpeg_strategy: String, + /// Supported API versions. + pub api_versions: Vec, + /// Current transcoding-tool capability details. + pub transcoding: TranscodingCapability, +} + +/// Metadata response for one browser-facing media item. +#[derive(Debug, Serialize, JsonSchema)] +pub struct ItemMetadataResponse { + /// Stable database identifier for the item. + pub item_id: i32, + /// Provider statuses visible to the current server configuration. + pub providers: Vec, + /// Stored metadata matches for the item. + pub matches: Vec, +} + +/// Active backend activity summary that the browser can poll. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct SystemActivity { + /// Stable activity identifier. + pub id: String, + /// High-level activity category. + pub category: String, + /// Activity scope such as `item` or `library`. + pub scope: String, + /// Activity source such as `manual_item_refresh`. + pub source: String, + /// Current activity state such as `queued` or `running`. + pub state: String, + /// Human-friendly label for the activity. + pub label: String, + /// Provider identifier when the activity is metadata-related. + pub provider_id: Option, + /// Owning library identifier, when known. + pub library_id: Option, + /// Root item identifier for item-scoped work, when known. + pub root_item_id: Option, + /// All item identifiers currently tracked by the activity. + pub item_ids: Vec, + /// Total number of tracked items. + pub total_items: i32, + /// Number of completed item refreshes. + pub completed_items: i32, + /// Number of failed item refreshes. + pub failed_items: i32, + /// Unix timestamp when the activity was queued. + pub queued_at: i64, + /// Unix timestamp when the activity first started running. + pub started_at: Option, + /// Unix timestamp for the latest activity update. + pub updated_at: i64, +} + +/// Pollable activity response for the browser client. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct SystemActivitiesResponse { + /// Unix timestamp when the snapshot was generated. + pub generated_at: i64, + /// Active activities currently tracked by the backend. + pub activities: Vec, +} + +/// Playback progress payload from the browser client. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct PlaybackProgressRequest { + /// Current playback position in milliseconds. + pub position_ms: i64, + /// Current known duration in milliseconds, when available. + pub duration_ms: Option, + /// Whether playback has completed. + pub completed: bool, +} + +/// Request payload for linking a media item to provider metadata. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct LinkMetadataRequest { + /// Provider to link. + pub provider_id: MetadataProviderId, + /// Provider-side external identifier. + pub external_id: String, + /// Provider-specific media type such as `movie` or `tv`. + pub media_type: String, +} + +static BACKGROUND_LIBRARY_SCAN_RUNNING: Lazy = Lazy::new(|| AtomicBool::new(false)); +static NEXT_SYSTEM_ACTIVITY_ID: Lazy = Lazy::new(|| AtomicU64::new(1)); +static ACTIVE_SYSTEM_ACTIVITIES: Lazy>> = + Lazy::new(|| tokio::sync::RwLock::new(HashMap::new())); +static ACTIVE_METADATA_REFRESH_ITEMS: Lazy>> = + Lazy::new(|| tokio::sync::RwLock::new(HashMap::new())); + +#[derive(Debug, Clone)] +struct MetadataRefreshActivityRecord { + activity: SystemActivity, +} + +fn current_timestamp() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok() + .and_then(|duration| i64::try_from(duration.as_secs()).ok()) + .unwrap_or_default() +} + +fn next_system_activity_id() -> String { + format!( + "activity-{}", + NEXT_SYSTEM_ACTIVITY_ID.fetch_add(1, Ordering::SeqCst) + ) +} + +async fn persist_snapshot_for_item( + db: &DbConn, + item_id: i32, + snapshot: &StoredMetadataSnapshot, + data_dir: &str, +) -> Result { + let (poster_path, backdrop_path) = persist_item_metadata_assets(snapshot, item_id, data_dir) + .await + .map_err(|error| { + log::error!("Failed to persist metadata assets for media item {}: {}", item_id, error); + Status::BadGateway + })?; + + let mut summary = db + .run({ + let snapshot = snapshot.clone(); + move |conn| upsert_item_metadata_snapshot(conn, item_id, &snapshot) + }) + .await + .map_err(|error| { + log::error!("Failed to persist linked metadata for media item {}: {}", item_id, error); + Status::InternalServerError + })?; + + if let Some(poster_path) = poster_path { + let summary_id = summary.id; + let poster_path_string = poster_path.to_string_lossy().to_string(); + db.run(move |conn| update_cached_artwork_path(conn, summary_id, ArtworkKind::Poster, &poster_path)) + .await + .map_err(|error| { + log::error!("Failed to store poster cache path for media item {}: {}", item_id, error); + Status::InternalServerError + })?; + summary.cached_artwork_path = Some(poster_path_string); + } + + if let Some(backdrop_path) = backdrop_path { + let summary_id = summary.id; + let backdrop_path_string = backdrop_path.to_string_lossy().to_string(); + db.run(move |conn| update_cached_artwork_path(conn, summary_id, ArtworkKind::Backdrop, &backdrop_path)) + .await + .map_err(|error| { + log::error!("Failed to store backdrop cache path for media item {}: {}", item_id, error); + Status::InternalServerError + })?; + summary.cached_backdrop_path = Some(backdrop_path_string); + } + + Ok(summary) +} + +fn supports_manual_metadata_linking(item: &MediaItemSummary) -> bool { + matches!(item.item_type.as_str(), "movie" | "show") +} + +fn tmdb_search_media_type(item: &MediaItemSummary) -> Option<&'static str> { + match item.item_type.as_str() { + "movie" => Some("movie"), + "show" => Some("tv"), + _ => None, + } +} + +#[derive(Debug, Clone)] +enum MetadataRefreshFetchKind { + Direct, + ShowSeason { + show_external_id: String, + season_number: i32, + }, + ShowEpisode { + show_external_id: String, + season_number: i32, + episode_number: i32, + }, +} + +#[derive(Debug, Clone)] +struct MetadataRefreshTarget { + item_id: i32, + library_id: i32, + item_type: String, + display_title: String, + relative_path: String, + external_id: String, + media_type: String, + fetch_kind: MetadataRefreshFetchKind, +} + +#[derive(Debug, Clone)] +struct MetadataRefreshJob { + root: MetadataRefreshTarget, + descendants: Vec, +} + +fn describe_metadata_refresh_target(target: &MetadataRefreshTarget) -> String { + format!( + "media item {} \"{}\" ({}) in library {} [{}]", + target.item_id, + target.display_title, + target.item_type, + target.library_id, + target.relative_path + ) +} + +fn flatten_metadata_refresh_job(job: &MetadataRefreshJob) -> Vec { + let mut targets = Vec::with_capacity(1 + job.descendants.len()); + targets.push(job.root.clone()); + targets.extend(job.descendants.clone()); + targets +} + +async fn register_metadata_refresh_activity( + scope: &str, + source: &str, + label: String, + library_id: Option, + root_item_id: Option, + targets: Vec, +) -> Option<(String, Vec)> { + let mut item_registry = ACTIVE_METADATA_REFRESH_ITEMS.write().await; + let queued_targets = targets + .into_iter() + .filter(|target| !item_registry.contains_key(&target.item_id)) + .collect::>(); + if queued_targets.is_empty() { + return None; + } + + let activity_id = next_system_activity_id(); + for target in &queued_targets { + item_registry.insert(target.item_id, activity_id.clone()); + } + drop(item_registry); + + let now = current_timestamp(); + ACTIVE_SYSTEM_ACTIVITIES.write().await.insert( + activity_id.clone(), + MetadataRefreshActivityRecord { + activity: SystemActivity { + id: activity_id.clone(), + category: "metadata_refresh".into(), + scope: scope.into(), + source: source.into(), + state: "queued".into(), + label, + provider_id: Some(MetadataProviderId::Tmdb.as_storage_value().into()), + library_id, + root_item_id, + item_ids: queued_targets.iter().map(|target| target.item_id).collect(), + total_items: i32::try_from(queued_targets.len()).unwrap_or(i32::MAX), + completed_items: 0, + failed_items: 0, + queued_at: now, + started_at: None, + updated_at: now, + }, + }, + ); + + Some((activity_id, queued_targets)) +} + +async fn cancel_metadata_refresh_activity(activity_id: &str) { + let removed = ACTIVE_SYSTEM_ACTIVITIES.write().await.remove(activity_id); + if let Some(record) = removed { + let mut item_registry = ACTIVE_METADATA_REFRESH_ITEMS.write().await; + for item_id in &record.activity.item_ids { + if item_registry.get(item_id).map(|value| value.as_str()) == Some(activity_id) { + item_registry.remove(item_id); + } + } + } +} + +async fn mark_metadata_refresh_activity_running(activity_id: &str) { + if let Some(record) = ACTIVE_SYSTEM_ACTIVITIES.write().await.get_mut(activity_id) { + record.activity.state = "running".into(); + record.activity.started_at.get_or_insert_with(current_timestamp); + record.activity.updated_at = current_timestamp(); + } +} + +async fn record_metadata_refresh_activity_progress(activity_id: &str, failed: bool) { + if let Some(record) = ACTIVE_SYSTEM_ACTIVITIES.write().await.get_mut(activity_id) { + record.activity.completed_items += 1; + if failed { + record.activity.failed_items += 1; + } + record.activity.updated_at = current_timestamp(); + } +} + +async fn complete_metadata_refresh_activity(activity_id: &str) { + cancel_metadata_refresh_activity(activity_id).await; +} + +async fn current_system_activities() -> Vec { + let activities = ACTIVE_SYSTEM_ACTIVITIES.read().await; + let mut snapshot = activities + .values() + .map(|record| record.activity.clone()) + .collect::>(); + snapshot.sort_by(|left, right| { + right + .updated_at + .cmp(&left.updated_at) + .then_with(|| left.label.cmp(&right.label)) + }); + snapshot +} + +async fn load_show_descendant_refresh_targets( + db: &DbConn, + show_item_id: i32, + show_external_id: &str, +) -> Result, Status> { + let seasons = db + .run(move |conn| list_media_item_children(conn, show_item_id)) + .await + .map_err(|error| { + log::error!( + "Failed to load show children for automatic metadata propagation on item {}: {}", + show_item_id, + error + ); + Status::InternalServerError + })?; + + let mut targets = Vec::new(); + for season in seasons.into_iter().filter(|item| item.item_type == "season") { + let Some(season_number) = season.season_number else { + continue; + }; + let season_id = season.id; + targets.push(MetadataRefreshTarget { + item_id: season_id, + library_id: season.library_id, + item_type: season.item_type.clone(), + display_title: season.display_title.clone(), + relative_path: season.relative_path.clone(), + external_id: format!("tv:{show_external_id}:season:{season_number}"), + media_type: "tv_season".into(), + fetch_kind: MetadataRefreshFetchKind::ShowSeason { + show_external_id: show_external_id.to_string(), + season_number, + }, + }); + + let episodes = db + .run(move |conn| list_media_item_children(conn, season_id)) + .await + .map_err(|error| { + log::error!( + "Failed to load season children for automatic metadata propagation on item {}: {}", + season_id, + error + ); + Status::InternalServerError + })?; + + for episode in episodes.into_iter().filter(|item| item.item_type == "episode") { + let Some(episode_number) = episode.episode_number else { + continue; + }; + targets.push(MetadataRefreshTarget { + item_id: episode.id, + library_id: episode.library_id, + item_type: episode.item_type.clone(), + display_title: episode.display_title.clone(), + relative_path: episode.relative_path.clone(), + external_id: format!( + "tv:{show_external_id}:season:{season_number}:episode:{episode_number}" + ), + media_type: "tv_episode".into(), + fetch_kind: MetadataRefreshFetchKind::ShowEpisode { + show_external_id: show_external_id.to_string(), + season_number, + episode_number, + }, + }); + } + } + + Ok(targets) +} + +async fn mark_metadata_refresh_target_pending( + db: &DbConn, + target: &MetadataRefreshTarget, +) -> Result { + db.run({ + let target = target.clone(); + move |conn| { + set_item_metadata_refresh_state( + conn, + target.item_id, + MetadataProviderId::Tmdb, + &target.external_id, + Some(&target.media_type), + "pending", + None, + ) + } + }) + .await + .map_err(|error| { + log::error!( + "Failed to mark media item {} metadata refresh pending: {}", + target.item_id, + error + ); + Status::InternalServerError + }) +} + +async fn mark_metadata_refresh_targets_pending( + db: &DbConn, + targets: &[MetadataRefreshTarget], +) -> Result<(), Status> { + for target in targets { + mark_metadata_refresh_target_pending(db, target).await?; + } + + Ok(()) +} + +async fn record_metadata_refresh_error( + db: &DbConn, + target: &MetadataRefreshTarget, + message: &str, +) { + if let Err(error) = db + .run({ + let target = target.clone(); + let message = message.to_string(); + move |conn| { + set_item_metadata_refresh_state( + conn, + target.item_id, + MetadataProviderId::Tmdb, + &target.external_id, + Some(&target.media_type), + "error", + Some(&message), + ) + } + }) + .await + { + log::warn!( + "Failed to record metadata refresh error for media item {}: {}", + target.item_id, + error + ); + } +} + +async fn execute_metadata_refresh_target( + db: &DbConn, + target: &MetadataRefreshTarget, + settings: &crate::config::Settings, +) -> bool { + log::info!( + "Starting TMDB metadata refresh for {} using target {} ({})", + describe_metadata_refresh_target(target), + target.external_id, + target.media_type + ); + let snapshot_result = match &target.fetch_kind { + MetadataRefreshFetchKind::Direct => { + fetch_tmdb_metadata_snapshot(&settings.metadata, &target.external_id, &target.media_type).await + } + MetadataRefreshFetchKind::ShowSeason { + show_external_id, + season_number, + } => fetch_tmdb_season_metadata_snapshot(&settings.metadata, show_external_id, *season_number).await, + MetadataRefreshFetchKind::ShowEpisode { + show_external_id, + season_number, + episode_number, + } => { + fetch_tmdb_episode_metadata_snapshot( + &settings.metadata, + show_external_id, + *season_number, + *episode_number, + ) + .await + } + }; + + match snapshot_result { + Ok(snapshot) => { + if let Err(status) = persist_snapshot_for_item(db, target.item_id, &snapshot, &settings.general.data_dir).await { + let status_message = format!("{status:?}"); + log::warn!( + "Failed to persist refreshed TMDB metadata snapshot for {}: {}", + describe_metadata_refresh_target(target), + status_message + ); + record_metadata_refresh_error(db, target, &status_message).await; + return true; + } + + log::info!( + "Completed TMDB metadata refresh for {} using target {} ({})", + describe_metadata_refresh_target(target), + target.external_id, + target.media_type + ); + false + } + Err(error) => { + log::warn!( + "Failed to fetch refreshed TMDB metadata snapshot for {} using target {} ({}): {}", + describe_metadata_refresh_target(target), + target.external_id, + target.media_type, + error + ); + record_metadata_refresh_error(db, target, &error).await; + true + } + } +} + +async fn execute_metadata_refresh_targets( + db: &DbConn, + targets: &[MetadataRefreshTarget], + settings: &crate::config::Settings, +) { + for target in targets { + execute_metadata_refresh_target(db, target, settings).await; + } +} + +async fn build_metadata_refresh_job( + db: &DbConn, + item: &MediaItemSummary, + external_id: &str, + media_type: &str, +) -> Result { + let descendants = if item.item_type == "show" && media_type == "tv" { + load_show_descendant_refresh_targets(db, item.id, external_id).await? + } else { + Vec::new() + }; + + Ok(MetadataRefreshJob { + root: MetadataRefreshTarget { + item_id: item.id, + library_id: item.library_id, + item_type: item.item_type.clone(), + display_title: item.display_title.clone(), + relative_path: item.relative_path.clone(), + external_id: external_id.to_string(), + media_type: media_type.to_string(), + fetch_kind: MetadataRefreshFetchKind::Direct, + }, + descendants, + }) +} + +async fn load_tmdb_metadata_summary_for_item( + db: &DbConn, + item_id: i32, +) -> Result { + let summaries = db + .run(move |conn| get_item_metadata_summaries(conn, item_id)) + .await + .map_err(|error| { + log::error!( + "Failed to load current TMDB metadata summary for media item {}: {}", + item_id, + error + ); + Status::InternalServerError + })?; + + summaries + .into_iter() + .find(|summary| summary.provider_id == MetadataProviderId::Tmdb) + .ok_or(Status::NotFound) +} + +async fn persist_snapshot_tree_for_item( + db: &DbConn, + item_id: i32, + snapshot: &StoredMetadataSnapshot, + settings: &crate::config::Settings, +) -> Result { + let descendants = if snapshot.provider_id == MetadataProviderId::Tmdb && snapshot.media_type.as_deref() == Some("tv") { + load_show_descendant_refresh_targets(db, item_id, &snapshot.external_id).await? + } else { + Vec::new() + }; + if !descendants.is_empty() { + mark_metadata_refresh_targets_pending(db, &descendants).await?; + } + + let summary = persist_snapshot_for_item(db, item_id, snapshot, &settings.general.data_dir).await?; + if !descendants.is_empty() { + execute_metadata_refresh_targets(db, &descendants, settings).await; + } + + Ok(summary) +} + +fn linked_shows_needing_descendant_backfill( + conn: &mut rocket_sync_db_pools::diesel::SqliteConnection, +) -> Result, diesel::result::Error> { + let items = list_media_items(conn, None)?; + let mut pending = Vec::new(); + + for show in items.into_iter().filter(|item| item.item_type == "show") { + let Some(link) = get_primary_item_metadata_link(conn, show.id)? else { + continue; + }; + if link.provider_id != MetadataProviderId::Tmdb.as_storage_value() || link.media_type.as_deref() != Some("tv") { + continue; + } + + let seasons = list_media_item_children(conn, show.id)?; + let mut needs_backfill = false; + for season in seasons.into_iter().filter(|item| item.item_type == "season") { + if get_primary_item_metadata_link(conn, season.id)?.is_none() { + needs_backfill = true; + break; + } + + let episodes = list_media_item_children(conn, season.id)?; + if episodes.into_iter().any(|episode| { + episode.item_type == "episode" + && get_primary_item_metadata_link(conn, episode.id) + .ok() + .flatten() + .is_none() + }) { + needs_backfill = true; + break; + } + } + + if needs_backfill { + pending.push((show.id, link.external_id)); + } + } + + Ok(pending) +} + +async fn run_automatic_movie_metadata_linking(db: &DbConn, settings: &crate::config::Settings) { + let tmdb_ready = list_provider_statuses(&settings.metadata) + .into_iter() + .any(|provider| provider.id == MetadataProviderId::Tmdb && provider.enabled && provider.configured); + if !tmdb_ready { + return; + } + + let candidates = match db.run(|conn| list_automatic_metadata_candidates(conn, 8)).await { + Ok(candidates) => candidates, + Err(error) => { + log::warn!("Failed to load automatic metadata candidates: {}", error); + return; + } + }; + + for candidate in candidates { + let guess_result = match candidate.library_kind { + crate::config::MediaLibraryKind::Shows => { + guess_tmdb_show_match(&settings.metadata, &candidate.relative_path, &candidate.display_title).await + } + _ => { + guess_tmdb_movie_match(&settings.metadata, &candidate.relative_path, &candidate.display_title).await + } + }; + let guess = match guess_result { + Ok(result) => result, + Err(error) => { + log::warn!( + "Automatic TMDB match failed for item {} ({}): {}", + candidate.item_id, + candidate.relative_path, + error + ); + continue; + } + }; + + if let Some(result) = guess { + if let Err(error) = db + .run({ + let external_id = result.external_id.clone(); + let media_type = result.media_type.clone(); + move |conn| { + set_item_metadata_refresh_state( + conn, + candidate.item_id, + MetadataProviderId::Tmdb, + &external_id, + Some(&media_type), + "pending", + None, + ) + } + }) + .await + { + log::warn!( + "Failed to mark automatic metadata candidate {} pending: {}", + candidate.item_id, + error + ); + } + match fetch_tmdb_metadata_snapshot(&settings.metadata, &result.external_id, &result.media_type).await { + Ok(snapshot) => { + if let Err(status) = persist_snapshot_tree_for_item(db, candidate.item_id, &snapshot, settings).await { + log::warn!( + "Failed to persist automatic metadata snapshot for item {}: {:?}", + candidate.item_id, + status + ); + if let Err(error) = db + .run({ + let external_id = snapshot.external_id.clone(); + let media_type = snapshot.media_type.clone(); + let status_message = format!("{status:?}"); + move |conn| { + set_item_metadata_refresh_state( + conn, + candidate.item_id, + MetadataProviderId::Tmdb, + &external_id, + media_type.as_deref(), + "error", + Some(&status_message), + ) + } + }) + .await + { + log::warn!("Failed to record automatic metadata error for item {}: {}", candidate.item_id, error); + } + continue; + } + } + Err(error) => { + log::warn!("Failed to fetch automatic TMDB snapshot for item {}: {}", candidate.item_id, error); + if let Err(persist_error) = db + .run({ + let external_id = result.external_id.clone(); + let media_type = result.media_type.clone(); + let error_message = error.clone(); + move |conn| { + set_item_metadata_refresh_state( + conn, + candidate.item_id, + MetadataProviderId::Tmdb, + &external_id, + Some(&media_type), + "error", + Some(&error_message), + ) + } + }) + .await + { + log::warn!("Failed to record automatic metadata error for item {}: {}", candidate.item_id, persist_error); + } + continue; + } + } + } + + if candidate.library_kind != crate::config::MediaLibraryKind::Shows { + let attempted_at = current_timestamp(); + if let Err(error) = db + .run(move |conn| mark_metadata_match_attempted(conn, candidate.item_id, attempted_at)) + .await + { + log::warn!("Failed to record automatic metadata attempt for item {}: {}", candidate.item_id, error); + } + } + } + + let pending_show_backfills = match db.run(linked_shows_needing_descendant_backfill).await { + Ok(items) => items, + Err(error) => { + log::warn!("Failed to load linked shows needing metadata backfill: {}", error); + return; + } + }; + + for (show_item_id, external_id) in pending_show_backfills { + match load_show_descendant_refresh_targets(db, show_item_id, &external_id).await { + Ok(targets) => { + if let Err(status) = mark_metadata_refresh_targets_pending(db, &targets).await { + log::warn!( + "Failed to mark descendant metadata pending for show item {}: {:?}", + show_item_id, + status + ); + } + execute_metadata_refresh_targets(db, &targets, settings).await; + } + Err(status) => { + log::warn!( + "Failed to backfill descendant metadata for show item {}: {:?}", + show_item_id, + status + ); + } + } + } +} + +fn current_user_id(user_guard: Option<&UserGuard>) -> Result, Status> { + user_guard + .map(|user_guard| { + user_guard + .claims() + .sub + .parse::() + .map_err(|_| Status::Unauthorized) + }) + .transpose() +} + +async fn load_library_refresh_jobs( + db: &DbConn, + library_id: i32, +) -> Result, Status> { + let items = db + .run(move |conn| list_media_items(conn, Some(library_id))) + .await + .map_err(|error| { + log::error!( + "Failed to load media items for library {} metadata refresh: {}", + library_id, + error + ); + Status::InternalServerError + })?; + + let mut jobs = Vec::new(); + for item in items + .into_iter() + .filter(|item| item.parent_id.is_none() && supports_manual_metadata_linking(item)) + { + let item_id = item.id; + let link = db + .run(move |conn| get_primary_item_metadata_link(conn, item_id)) + .await + .map_err(|error| { + log::error!( + "Failed to load linked metadata for media item {} library refresh: {}", + item_id, + error + ); + Status::InternalServerError + })?; + let Some(link) = link else { + continue; + }; + + let provider_id = MetadataProviderId::from_storage_value(&link.provider_id).ok_or(Status::BadRequest)?; + if provider_id != MetadataProviderId::Tmdb { + continue; + } + + let Some(media_type) = link.media_type.clone() else { + continue; + }; + jobs.push(build_metadata_refresh_job(db, &item, &link.external_id, &media_type).await?); + } + + Ok(jobs) +} + +async fn load_library_summary( + db: &DbConn, + settings: &Settings, + library_id: i32, +) -> Result { + let legacy_libraries = settings.media.libraries.clone(); + let libraries = db + .run(move |conn| get_persisted_library_summaries(conn, &legacy_libraries)) + .await + .map_err(|error| { + log::error!("Failed to load media library summaries: {}", error); + Status::InternalServerError + })?; + + libraries + .into_iter() + .find(|library| library.id == library_id) + .ok_or(Status::NotFound) +} + +fn schedule_background_library_scan(db: DbConn, settings: Settings) { + if !BACKGROUND_LIBRARY_SCAN_RUNNING + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() + { + return; + } + + tokio::spawn(async move { + let legacy_libraries = settings.media.libraries.clone(); + let ffmpeg_settings = settings.ffmpeg.clone(); + let result = db + .run(move |conn| sync_persisted_library_catalog(conn, &legacy_libraries, &ffmpeg_settings)) + .await; + + if let Err(error) = result { + log::error!("Failed to sync media library catalog in the background: {}", error); + BACKGROUND_LIBRARY_SCAN_RUNNING.store(false, Ordering::SeqCst); + return; + } + + run_automatic_movie_metadata_linking(&db, &settings).await; + BACKGROUND_LIBRARY_SCAN_RUNNING.store(false, Ordering::SeqCst); + }); +} + +/// Return server bootstrap information for future browser and native clients. +#[openapi(tag = "Media")] +#[get("/api/v1/system/capabilities")] +pub async fn get_server_capabilities(db: DbConn) -> Result, Status> { + let settings = current_settings(); + let transcoding = inspect_transcoding_capability(&settings.ffmpeg); + let legacy_libraries = settings.media.libraries.clone(); + let libraries_configured = db + .run(move |conn| list_library_settings(conn, &legacy_libraries).map(|libraries| libraries.len())) + .await + .map_err(|error| { + log::error!("Failed to count persisted libraries: {}", error); + Status::InternalServerError + })?; + + Ok(Json(ServerCapabilitiesResponse { + app_name: globals::GLOBAL_APP_NAME.to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + server_url: globals::get_server_url(), + https_enabled: settings.server.use_https, + libraries_configured, + ffmpeg_strategy: match settings.ffmpeg.strategy { + FfmpegStrategy::ExternalBinaries => "external_binaries", + FfmpegStrategy::EmbeddedLibrariesPlanned => "embedded_libraries_planned", + } + .into(), + api_versions: vec!["v1".into()], + transcoding, + })) +} + +/// Return active backend activities such as metadata refresh work. +#[openapi(tag = "Media")] +#[get("/api/v1/system/activities")] +pub async fn get_system_activities() -> Json { + Json(SystemActivitiesResponse { + generated_at: current_timestamp(), + activities: current_system_activities().await, + }) +} + +/// Return metadata provider status for the current server configuration. +#[openapi(tag = "Media")] +#[get("/api/v1/metadata/providers")] +pub fn get_metadata_providers() -> Json> { + Json(list_provider_statuses(¤t_settings().metadata)) +} + +/// Return lightweight scan summaries for the configured media libraries. +#[openapi(tag = "Media")] +#[get("/api/v1/libraries")] +pub async fn get_libraries(db: DbConn) -> Result>, Status> { + let settings = current_settings(); + let legacy_libraries = settings.media.libraries.clone(); + + let libraries = db + .run(move |conn| get_persisted_library_summaries(conn, &legacy_libraries)) + .await + .map_err(|error| { + log::error!("Failed to load media library summaries: {}", error); + Status::InternalServerError + })?; + + schedule_background_library_scan(db, settings); + + Ok(Json(libraries)) +} + +/// Return Kodi/Plex-style shelves for the browser home screen. +#[openapi(tag = "Media")] +#[get("/api/v1/home?")] +pub async fn get_home( + db: DbConn, + user_guard: Option, + library_id: Option, +) -> Result, Status> { + let settings = current_settings(); + let user_id = current_user_id(user_guard.as_ref())?; + + let home = db + .run(move |conn| get_media_home(conn, user_id, library_id)) + .await + .map_err(|error| { + log::error!("Failed to build media home shelves: {}", error); + Status::InternalServerError + })?; + + schedule_background_library_scan(db, settings); + + Ok(Json(home)) +} + +/// Return the persisted file inventory for a synchronized media library. +#[openapi(tag = "Media")] +#[get("/api/v1/libraries//files")] +pub async fn get_library_inventory( + db: DbConn, + library_id: i32, +) -> Result>, Status> { + let file_rows = db + .run(move |conn| get_library_files(conn, library_id)) + .await + .map_err(|error| { + log::error!( + "Failed to load media library inventory for id {}: {}", + library_id, + error + ); + Status::InternalServerError + })?; + + if file_rows.is_empty() { + let exists = db + .run(move |conn| library_exists(conn, library_id)) + .await + .map_err(|error| { + log::error!( + "Failed to confirm media library existence for id {}: {}", + library_id, + error + ); + Status::InternalServerError + })?; + + if !exists { + return Err(Status::NotFound); + } + } + + Ok(Json(file_rows)) +} + +/// Return browser-facing media items, optionally filtered to one library. +#[openapi(tag = "Media")] +#[get("/api/v1/items?")] +pub async fn get_items( + db: DbConn, + library_id: Option, +) -> Result>, Status> { + let items = db + .run(move |conn| list_media_items(conn, library_id)) + .await + .map_err(|error| { + log::error!("Failed to load media items: {}", error); + Status::InternalServerError + })?; + + Ok(Json(items)) +} + +/// Return details for one browser-facing media item. +#[openapi(tag = "Media")] +#[get("/api/v1/items/")] +pub async fn get_item( + db: DbConn, + item_id: i32, +) -> Result, Status> { + let data_dir = current_settings().general.data_dir; + + let item = db + .run(move |conn| get_media_item(conn, item_id, &data_dir)) + .await + .map_err(|error| { + log::error!("Failed to load media item {}: {}", item_id, error); + Status::InternalServerError + })?; + + let mut item = item.ok_or(Status::NotFound)?; + if item.theme_song_url.is_none() { + let theme_reference = db + .run(move |conn| get_item_theme_song_themerr_reference(conn, item_id)) + .await + .map_err(|error| { + log::error!( + "Failed to resolve ThemerrDB theme-song reference for media item {}: {}", + item_id, + error + ); + Status::InternalServerError + })?; + + if let Some((media_type, external_id)) = theme_reference { + match fetch_themerr_youtube_theme_url(&media_type, &external_id).await { + Ok(Some(url)) => { + item.theme_song_youtube_url = Some(url); + } + Ok(None) => {} + Err(error) => { + log::warn!( + "Failed to load ThemerrDB theme song for media item {} ({} {}): {}", + item_id, + media_type, + external_id, + error + ); + } + } + } + } + + Ok(Json(item)) +} + +/// Return direct-play versus transcode information for a media item. +#[openapi(tag = "Media")] +#[get("/api/v1/items//playback")] +pub async fn get_item_playback( + db: DbConn, + item_id: i32, +) -> Result, Status> { + let decision = db + .run(move |conn| get_playback_decision(conn, item_id)) + .await + .map_err(|error| { + log::error!("Failed to build playback decision for media item {}: {}", item_id, error); + Status::InternalServerError + })?; + + decision.map(Json).ok_or(Status::NotFound) +} + +/// Serve a direct-play file stream for a browser-compatible media item. +#[get("/api/v1/items//stream")] +pub async fn stream_item( + db: DbConn, + item_id: i32, +) -> Result { + let decision = db + .run(move |conn| get_playback_decision(conn, item_id)) + .await + .map_err(|error| { + log::error!("Failed to build playback decision before streaming item {}: {}", item_id, error); + Status::InternalServerError + })? + .ok_or(Status::NotFound)?; + + if !decision.can_direct_play { + return Err(Status::Conflict); + } + + let source_path = db + .run(move |conn| resolve_media_item_source_path(conn, item_id)) + .await + .map_err(|error| { + log::error!("Failed to resolve stream source for media item {}: {}", item_id, error); + Status::InternalServerError + })? + .ok_or(Status::NotFound)?; + + NamedFile::open(source_path) + .await + .map_err(|_| Status::NotFound) +} + +/// Persist browser playback progress for a media item. +#[openapi(tag = "Media")] +#[post("/api/v1/items//progress", format = "json", data = "")] +pub async fn update_item_progress( + db: DbConn, + user_guard: UserGuard, + item_id: i32, + request: Json, +) -> Result { + let payload = request.into_inner(); + let user_id = current_user_id(Some(&user_guard))?.ok_or(Status::Unauthorized)?; + + db.run(move |conn| { + upsert_playback_progress( + conn, + user_id, + item_id, + payload.position_ms, + payload.duration_ms, + payload.completed, + ) + }) + .await + .map_err(|error| { + log::error!("Failed to update playback progress for media item {}: {}", item_id, error); + Status::InternalServerError + })?; + + Ok(Status::Ok) +} + +/// Return stored metadata matches and provider readiness for one media item. +#[openapi(tag = "Media")] +#[get("/api/v1/items//metadata")] +pub async fn get_item_metadata( + db: DbConn, + item_id: i32, +) -> Result, Status> { + let data_dir = current_settings().general.data_dir; + + let item_exists = db + .run(move |conn| get_media_item(conn, item_id, &data_dir)) + .await + .map_err(|error| { + log::error!( + "Failed to confirm media item {} before metadata load: {}", + item_id, + error + ); + Status::InternalServerError + })?; + + if item_exists.is_none() { + return Err(Status::NotFound); + } + + let matches = db + .run(move |conn| get_item_metadata_summaries(conn, item_id)) + .await + .map_err(|error| { + log::error!( + "Failed to load metadata matches for media item {}: {}", + item_id, + error + ); + Status::InternalServerError + })?; + + Ok(Json(ItemMetadataResponse { + item_id, + providers: list_provider_statuses(¤t_settings().metadata), + matches, + })) +} + +/// Search a configured provider for metadata candidates for a media item. +#[openapi(tag = "Media")] +#[get("/api/v1/items//metadata/search?")] +pub async fn search_item_metadata( + db: DbConn, + item_id: i32, + query: Option, +) -> Result>, Status> { + let settings = current_settings(); + let metadata_settings = settings.metadata; + let item = db + .run(move |conn| get_media_item_summary(conn, item_id)) + .await + .map_err(|error| { + log::error!("Failed to load media item summary {} for metadata search: {}", item_id, error); + Status::InternalServerError + })? + .ok_or(Status::NotFound)?; + if !supports_manual_metadata_linking(&item) { + return Err(Status::BadRequest); + } + + let fallback_query = item.display_title.clone(); + let expected_media_type = tmdb_search_media_type(&item) + .ok_or(Status::NotFound)?; + + let effective_query = query + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or(fallback_query); + + let results = search_tmdb(&metadata_settings, &effective_query) + .await + .map_err(|error| { + log::error!("Failed to search metadata for media item {}: {}", item_id, error); + Status::ServiceUnavailable + })?; + + Ok(Json( + results + .into_iter() + .filter(|result| result.media_type == expected_media_type) + .collect(), + )) +} + +/// Link a media item to a provider match and persist the fetched metadata snapshot. +#[openapi(tag = "Media")] +#[post("/api/v1/items//metadata/link", format = "json", data = "")] +pub async fn link_item_metadata( + db: DbConn, + item_id: i32, + request: Json, +) -> Result, Status> { + let request = request.into_inner(); + if request.provider_id != MetadataProviderId::Tmdb { + return Err(Status::BadRequest); + } + + let settings = current_settings(); + let item = db + .run(move |conn| get_media_item_summary(conn, item_id)) + .await + .map_err(|error| { + log::error!("Failed to load media item summary {} for metadata link: {}", item_id, error); + Status::InternalServerError + })? + .ok_or(Status::NotFound)?; + if !supports_manual_metadata_linking(&item) { + return Err(Status::BadRequest); + } + if Some(request.media_type.as_str()) != tmdb_search_media_type(&item) { + return Err(Status::BadRequest); + } + + let external_id = request.external_id.clone(); + let media_type = request.media_type.clone(); + let stored_snapshot = db + .run(move |conn| { + get_stored_metadata_snapshot( + conn, + MetadataProviderId::Tmdb, + &external_id, + Some(&media_type), + ) + }) + .await + .map_err(|error| { + log::error!("Failed to inspect stored metadata snapshot for media item {}: {}", item_id, error); + Status::InternalServerError + })?; + + let snapshot = if let Some(stored_snapshot) = stored_snapshot { + stored_snapshot + } else { + fetch_tmdb_metadata_snapshot( + &settings.metadata, + &request.external_id, + &request.media_type, + ) + .await + .map_err(|error| { + log::error!("Failed to fetch metadata snapshot for media item {}: {}", item_id, error); + Status::ServiceUnavailable + })? + }; + + let summary = persist_snapshot_tree_for_item(&db, item_id, &snapshot, &settings).await?; + + Ok(Json(summary)) +} + +/// Force-refresh the currently linked metadata snapshot for one media item. +#[openapi(tag = "Media")] +#[post("/api/v1/items//metadata/refresh")] +pub async fn refresh_item_metadata( + db: DbConn, + item_id: i32, +) -> Result, Status> { + let settings = current_settings(); + let item = db + .run(move |conn| get_media_item_summary(conn, item_id)) + .await + .map_err(|error| { + log::error!("Failed to load media item summary {} for metadata refresh: {}", item_id, error); + Status::InternalServerError + })? + .ok_or(Status::NotFound)?; + if !supports_manual_metadata_linking(&item) { + return Err(Status::BadRequest); + } + + let link = db + .run(move |conn| get_primary_item_metadata_link(conn, item_id)) + .await + .map_err(|error| { + log::error!("Failed to load linked metadata for media item {} refresh: {}", item_id, error); + Status::InternalServerError + })? + .ok_or(Status::NotFound)?; + + let provider_id = MetadataProviderId::from_storage_value(&link.provider_id) + .ok_or(Status::BadRequest)?; + if provider_id != MetadataProviderId::Tmdb { + return Err(Status::BadRequest); + } + + let media_type = link.media_type.clone().ok_or(Status::BadRequest)?; + let external_id = link.external_id.clone(); + let refresh_job = build_metadata_refresh_job(&db, &item, &external_id, &media_type).await?; + let refresh_targets = flatten_metadata_refresh_job(&refresh_job); + let Some((activity_id, queued_targets)) = register_metadata_refresh_activity( + "item", + "manual_item_refresh", + format!("Refresh metadata for {}", item.display_title), + Some(item.library_id), + Some(item.id), + refresh_targets, + ) + .await + else { + return Ok(Json(load_tmdb_metadata_summary_for_item(&db, item_id).await?)); + }; + + if let Err(status) = mark_metadata_refresh_targets_pending(&db, &queued_targets).await { + cancel_metadata_refresh_activity(&activity_id).await; + return Err(status); + } + + let pending_summary = load_tmdb_metadata_summary_for_item(&db, item_id).await?; + tokio::spawn(async move { + mark_metadata_refresh_activity_running(&activity_id).await; + for target in queued_targets { + let failed = execute_metadata_refresh_target(&db, &target, &settings).await; + record_metadata_refresh_activity_progress(&activity_id, failed).await; + } + complete_metadata_refresh_activity(&activity_id).await; + }); + + Ok(Json(pending_summary)) +} + +/// Force-refresh every linked metadata item within one library. +#[openapi(tag = "Media")] +#[post("/api/v1/libraries//metadata/refresh")] +pub async fn refresh_library_metadata( + db: DbConn, + library_id: i32, +) -> Result, Status> { + let settings = current_settings(); + let library_summary = load_library_summary(&db, &settings, library_id).await?; + + let refresh_jobs = load_library_refresh_jobs(&db, library_id).await?; + let refresh_targets = refresh_jobs + .iter() + .flat_map(flatten_metadata_refresh_job) + .collect::>(); + + let Some((activity_id, queued_targets)) = register_metadata_refresh_activity( + "library", + "manual_library_refresh", + format!("Refresh library metadata for {}", library_summary.name), + Some(library_id), + None, + refresh_targets, + ) + .await + else { + return Ok(Json(load_library_summary(&db, &settings, library_id).await?)); + }; + + if let Err(status) = mark_metadata_refresh_targets_pending(&db, &queued_targets).await { + cancel_metadata_refresh_activity(&activity_id).await; + return Err(status); + } + + let pending_summary = load_library_summary(&db, &settings, library_id).await?; + tokio::spawn(async move { + mark_metadata_refresh_activity_running(&activity_id).await; + for target in queued_targets { + let failed = execute_metadata_refresh_target(&db, &target, &settings).await; + record_metadata_refresh_activity_progress(&activity_id, failed).await; + } + complete_metadata_refresh_activity(&activity_id).await; + }); + + Ok(Json(pending_summary)) +} + +/// Serve poster or backdrop artwork for a linked media item, caching it locally on demand. +#[get("/api/v1/items//artwork?")] +pub async fn get_item_artwork( + db: DbConn, + item_id: i32, + kind: Option, +) -> Result { + let artwork_kind = ArtworkKind::from_query_value(kind.as_deref()); + let data_dir = current_settings().general.data_dir; + + if let Some(local_path) = db + .run(move |conn| resolve_local_item_artwork_path(conn, item_id, artwork_kind, &data_dir)) + .await + .map_err(|error| { + log::error!("Failed to resolve local artwork for media item {}: {}", item_id, error); + Status::InternalServerError + })? + { + return NamedFile::open(local_path) + .await + .map_err(|_| Status::NotFound); + } + + let link = db + .run(move |conn| get_primary_item_metadata_link(conn, item_id)) + .await + .map_err(|error| { + log::error!("Failed to load linked metadata for media item {} artwork: {}", item_id, error); + Status::InternalServerError + })? + .ok_or(Status::NotFound)?; + + let existing_cache = match artwork_kind { + ArtworkKind::Poster => link.cached_artwork_path.clone(), + ArtworkKind::Backdrop => link.cached_backdrop_path.clone(), + }; + if let Some(existing_cache) = existing_cache { + let existing_path = std::path::PathBuf::from(existing_cache); + if existing_path.is_file() { + return NamedFile::open(existing_path) + .await + .map_err(|_| Status::NotFound); + } + } + + let snapshot = StoredMetadataSnapshot { + provider_id: MetadataProviderId::from_storage_value(&link.provider_id) + .unwrap_or(MetadataProviderId::Tmdb), + external_id: link.external_id.clone(), + media_type: link.media_type.clone(), + title: link.title.clone(), + overview: link.overview.clone(), + artwork_url: link.artwork_url.clone(), + backdrop_url: link.backdrop_url.clone(), + release_year: link.release_year, + provider_payload_json: link.provider_payload_json.clone(), + }; + let data_dir = current_settings().general.data_dir; + let (poster_path, backdrop_path) = persist_item_metadata_assets(&snapshot, item_id, &data_dir) + .await + .map_err(|error| { + log::error!("Failed to cache artwork for media item {}: {}", item_id, error); + Status::BadGateway + })?; + let cached_path = match artwork_kind { + ArtworkKind::Poster => poster_path, + ArtworkKind::Backdrop => backdrop_path, + } + .ok_or(Status::NotFound)?; + + let link_id = link.id; + let stored_path = cached_path.clone(); + db.run(move |conn| update_cached_artwork_path(conn, link_id, artwork_kind, &stored_path)) + .await + .map_err(|error| { + log::error!("Failed to persist cached artwork path for media item {}: {}", item_id, error); + Status::InternalServerError + })?; + + NamedFile::open(cached_path) + .await + .map_err(|_| Status::NotFound) +} + +/// Serve a discovered theme-song asset for a media item. +#[get("/api/v1/items//theme")] +pub async fn get_item_theme( + db: DbConn, + item_id: i32, +) -> Result { + let data_dir = current_settings().general.data_dir; + let theme_path = db + .run(move |conn| resolve_item_theme_song_path(conn, item_id, &data_dir)) + .await + .map_err(|error| { + log::error!("Failed to resolve theme song for media item {}: {}", item_id, error); + Status::InternalServerError + })? + .ok_or(Status::NotFound)?; + + NamedFile::open(theme_path) + .await + .map_err(|_| Status::NotFound) +} + +/// Serve a discovered subtitle sidecar for a media item. +#[get("/api/v1/items//subtitles/")] +pub async fn get_item_subtitle( + db: DbConn, + item_id: i32, + track_index: usize, +) -> Result { + let data_dir = current_settings().general.data_dir; + let subtitle_path = db + .run(move |conn| resolve_item_subtitle_path(conn, item_id, track_index, &data_dir)) + .await + .map_err(|error| { + log::error!( + "Failed to resolve subtitle track {} for media item {}: {}", + track_index, + item_id, + error + ); + Status::InternalServerError + })? + .ok_or(Status::NotFound)?; + + NamedFile::open(subtitle_path) + .await + .map_err(|_| Status::NotFound) +} + +/// Search browser-facing media items by title or path. +#[openapi(tag = "Media")] +#[get("/api/v1/search?&")] +pub async fn search_items( + db: DbConn, + query: Option<&str>, + library_id: Option, +) -> Result>, Status> { + + let query = query.unwrap_or_default().to_string(); + let items = db + .run(move |conn| search_media_items(conn, &query, library_id)) + .await + .map_err(|error| { + log::error!("Failed to search media items: {}", error); + Status::InternalServerError + })?; + + Ok(Json(items)) +} diff --git a/crates/server/src/web/routes/mod.rs b/crates/server/src/web/routes/mod.rs index 636ffe42..94b02921 100644 --- a/crates/server/src/web/routes/mod.rs +++ b/crates/server/src/web/routes/mod.rs @@ -4,20 +4,56 @@ pub mod auth; pub mod common; pub mod dependencies; +pub mod media; +pub mod settings; pub mod user; // lib imports +use rocket::routes; use rocket_okapi::openapi_get_routes; // this is a replacement for the rocket::routes macro -pub fn all_routes() -> Vec { +pub fn api_routes() -> Vec { openapi_get_routes![ - common::index, auth::login, auth::logout, auth::jwt_test, auth::admin_test, auth::user_info, dependencies::get_dependencies, + media::get_server_capabilities, + media::get_system_activities, + media::get_metadata_providers, + media::get_home, + media::get_libraries, + media::refresh_library_metadata, + media::get_library_inventory, + media::get_items, + media::get_item, + media::get_item_metadata, + media::search_item_metadata, + media::link_item_metadata, + media::refresh_item_metadata, + media::get_item_playback, + media::search_items, + media::update_item_progress, + settings::get_settings, + settings::get_logs, + settings::update_settings, + settings::add_library, + settings::remove_library, + user::get_bootstrap, + user::list_users, user::create_user, ] } + +pub fn spa_routes() -> Vec { + routes![ + common::index, + common::spa_asset, + media::get_item_artwork, + media::get_item_theme, + media::get_item_subtitle, + media::stream_item + ] +} diff --git a/crates/server/src/web/routes/settings.rs b/crates/server/src/web/routes/settings.rs new file mode 100644 index 00000000..8f080fbd --- /dev/null +++ b/crates/server/src/web/routes/settings.rs @@ -0,0 +1,320 @@ +//! Settings and library-management routes. + +// lib imports +use chrono::{DateTime, Local, LocalResult, NaiveDate, NaiveDateTime, TimeZone}; +use once_cell::sync::Lazy; +use regex::Regex; +use rocket::delete; +use rocket::get; +use rocket::http::Status; +use rocket::post; +use rocket::put; +use rocket::serde::json::Json; +use rocket_okapi::openapi; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +// local imports +use crate::config::{ + MediaLibrarySettings, Settings, current_settings, replace_current_settings, save_settings, + settings_file_path, settings_for_persistence, +}; +use crate::db::DbConn; +use crate::globals; +use crate::logging::{normalize_display_path, normalize_log_source_path}; +use crate::media::{ + add_library_setting, list_library_settings, remove_library_setting, replace_library_settings, +}; + +static STRUCTURED_LOG_LINE_REGEX: Lazy = Lazy::new(|| { + Regex::new(r"^(?P\S+) \[(?P[^]]+)\] \[(?P[^]]+)\] \[(?P[^]]+)\] (?P.*)$") + .expect("Failed to compile structured log regex") +}); + +/// Settings response payload. +#[derive(Debug, Serialize, JsonSchema)] +pub struct SettingsResponse { + /// Current settings snapshot. + pub settings: Settings, + /// Path to the YAML settings file. + pub settings_path: String, +} + +/// Add-library request payload. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct AddLibraryRequest { + /// New library configuration. + pub library: MediaLibrarySettings, +} + +/// One structured log entry parsed from the application log file. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct LogEntry { + /// Original timestamp string from the log file. + pub timestamp: String, + /// Log level such as `INFO` or `WARN`. + pub level: String, + /// Module path emitted by the logger. + pub module: String, + /// Source file path for the log entry. + pub source_file_path: String, + /// Source line number, when available. + pub line_number: Option, + /// Human-readable log message. + pub message: String, +} + +/// Structured log response for the settings page. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct LogEntriesResponse { + /// Path to the active application log file. + pub log_path: String, + /// Parsed log entries matching the request filters. + pub entries: Vec, +} + +fn merged_settings_response(settings: Settings, libraries: Vec) -> SettingsResponse { + let mut merged = settings; + merged.media.libraries = libraries; + SettingsResponse { + settings: merged, + settings_path: normalize_display_path(&settings_file_path().to_string_lossy()), + } +} + +fn persist_runtime_settings(settings: Settings) -> Result<(), Status> { + let persisted = settings_for_persistence(&settings); + save_settings(&persisted).map_err(|error| { + log::error!("Failed to save settings: {}", error); + Status::InternalServerError + })?; + replace_current_settings(persisted); + Ok(()) +} + +fn parse_log_source(source: &str) -> (String, Option) { + let trimmed = normalize_log_source_path(source); + let Some((path, line)) = trimmed.rsplit_once(':') else { + return (trimmed.to_string(), None); + }; + + ( + path.to_string(), + line.trim().parse::().ok(), + ) +} + +fn parse_log_entry_timestamp(value: &str) -> Option> { + DateTime::parse_from_rfc3339(value.trim()).ok() +} + +fn parse_log_filter_timestamp(value: Option<&str>) -> Option> { + let value = value?.trim(); + if value.is_empty() { + return None; + } + + if let Ok(parsed) = DateTime::parse_from_rfc3339(value) { + return Some(parsed); + } + + for format in ["%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M", "%Y-%m-%d"] { + if format == "%Y-%m-%d" { + if let Ok(parsed) = NaiveDate::parse_from_str(value, format) { + let Some(naive) = parsed.and_hms_opt(0, 0, 0) else { + continue; + }; + return match Local.from_local_datetime(&naive) { + LocalResult::Single(date_time) => Some(date_time.fixed_offset()), + LocalResult::Ambiguous(date_time, _) => Some(date_time.fixed_offset()), + LocalResult::None => None, + }; + } + continue; + } + + if let Ok(parsed) = NaiveDateTime::parse_from_str(value, format) { + return match Local.from_local_datetime(&parsed) { + LocalResult::Single(date_time) => Some(date_time.fixed_offset()), + LocalResult::Ambiguous(date_time, _) => Some(date_time.fixed_offset()), + LocalResult::None => None, + }; + } + } + + None +} + +fn read_structured_log_entries( + level: Option<&str>, + module: Option<&str>, + search: Option<&str>, + since: Option<&str>, + until: Option<&str>, + limit: usize, +) -> Vec { + let contents = std::fs::read_to_string(&globals::APP_PATHS.log_path).unwrap_or_default(); + let level_filter = level.map(|value| value.trim().to_ascii_lowercase()).filter(|value| !value.is_empty()); + let module_filter = module.map(|value| value.trim().to_ascii_lowercase()).filter(|value| !value.is_empty()); + let search_filter = search.map(|value| value.trim().to_ascii_lowercase()).filter(|value| !value.is_empty()); + let since_filter = parse_log_filter_timestamp(since); + let until_filter = parse_log_filter_timestamp(until); + + let mut entries = contents + .lines() + .filter_map(|line| { + let captures = STRUCTURED_LOG_LINE_REGEX.captures(line)?; + let timestamp = captures.name("timestamp")?.as_str().to_string(); + let level = captures.name("level")?.as_str().to_string(); + let module_name = captures.name("module")?.as_str().to_string(); + let source = captures.name("source")?.as_str().to_string(); + let message = captures.name("message")?.as_str().to_string(); + let (source_file_path, line_number) = parse_log_source(&source); + + Some(LogEntry { + timestamp, + level, + module: module_name, + source_file_path, + line_number, + message, + }) + }) + .filter(|entry| { + let level_matches = level_filter + .as_ref() + .map(|filter| entry.level.to_ascii_lowercase() == *filter) + .unwrap_or(true); + let module_matches = module_filter + .as_ref() + .map(|filter| entry.module.to_ascii_lowercase().contains(filter)) + .unwrap_or(true); + let search_matches = search_filter.as_ref().map(|filter| { + entry.message.to_ascii_lowercase().contains(filter) + || entry.module.to_ascii_lowercase().contains(filter) + || entry.source_file_path.to_ascii_lowercase().contains(filter) + }).unwrap_or(true); + let timestamp_matches = parse_log_entry_timestamp(&entry.timestamp).map(|timestamp| { + let after_since = since_filter.as_ref().map(|filter| timestamp >= *filter).unwrap_or(true); + let before_until = until_filter.as_ref().map(|filter| timestamp <= *filter).unwrap_or(true); + after_since && before_until + }).unwrap_or(since_filter.is_none() && until_filter.is_none()); + level_matches && module_matches && search_matches && timestamp_matches + }) + .collect::>(); + + entries.reverse(); + entries.truncate(limit); + entries +} + +/// Return the current server settings snapshot. +#[openapi(tag = "Settings")] +#[get("/api/v1/settings")] +pub async fn get_settings(db: DbConn) -> Result, Status> { + let settings = current_settings(); + let legacy_libraries = settings.media.libraries.clone(); + let libraries = db + .run(move |conn| list_library_settings(conn, &legacy_libraries)) + .await + .map_err(|error| { + log::error!("Failed to load persisted library settings: {}", error); + Status::InternalServerError + })?; + + persist_runtime_settings(settings.clone())?; + + Ok(Json(merged_settings_response(settings, libraries))) +} + +/// Return structured application logs for the settings page. +#[openapi(tag = "Settings")] +#[get("/api/v1/settings/logs?&&&&&")] +pub fn get_logs( + level: Option<&str>, + module: Option<&str>, + search: Option<&str>, + since: Option<&str>, + until: Option<&str>, + limit: Option, +) -> Json { + Json(LogEntriesResponse { + log_path: normalize_display_path(&globals::APP_PATHS.log_path), + entries: read_structured_log_entries(level, module, search, since, until, limit.unwrap_or(200).clamp(1, 500)), + }) +} + +/// Replace the full settings snapshot and persist it to disk. +#[openapi(tag = "Settings")] +#[put("/api/v1/settings", format = "json", data = "")] +pub async fn update_settings( + db: DbConn, + settings: Json, +) -> Result, Status> { + let settings = settings.into_inner(); + let libraries = settings.media.libraries.clone(); + let persisted_libraries = db + .run(move |conn| replace_library_settings(conn, &libraries)) + .await + .map_err(|error| { + log::error!("Failed to replace persisted library settings: {}", error); + Status::InternalServerError + })?; + + persist_runtime_settings(settings.clone())?; + + Ok(Json(merged_settings_response(settings, persisted_libraries))) +} + +/// Append a new library to the persisted media-library settings. +#[openapi(tag = "Settings")] +#[post("/api/v1/settings/libraries", format = "json", data = "")] +pub async fn add_library( + db: DbConn, + request: Json, +) -> Result, Status> { + let mut library = request.into_inner().library; + library.normalize(); + + let libraries = db + .run(move |conn| add_library_setting(conn, &library)) + .await + .map_err(|error| { + log::error!("Failed to add persisted library setting: {}", error); + Status::InternalServerError + })?; + + let settings = current_settings(); + persist_runtime_settings(settings.clone())?; + + Ok(Json(merged_settings_response(settings, libraries))) +} + +/// Remove one configured library from the database and return the merged settings snapshot. +#[openapi(tag = "Settings")] +#[delete("/api/v1/settings/libraries/")] +pub async fn remove_library(db: DbConn, library_index: usize) -> Result, Status> { + let removed = db + .run(move |conn| remove_library_setting(conn, library_index)) + .await + .map_err(|error| { + log::error!("Failed to remove persisted library at index {}: {}", library_index, error); + Status::InternalServerError + })?; + if !removed { + return Err(Status::NotFound); + } + + let settings = current_settings(); + let libraries = db + .run(|conn| list_library_settings(conn, &[])) + .await + .map_err(|error| { + log::error!("Failed to reload persisted libraries after removal: {}", error); + Status::InternalServerError + })?; + + persist_runtime_settings(settings.clone())?; + + Ok(Json(merged_settings_response(settings, libraries))) +} diff --git a/crates/server/src/web/routes/user.rs b/crates/server/src/web/routes/user.rs index fd4d75ac..a8d46a98 100644 --- a/crates/server/src/web/routes/user.rs +++ b/crates/server/src/web/routes/user.rs @@ -1,13 +1,14 @@ // lib imports -use diesel::{QueryDsl, RunQueryDsl}; +use diesel::{ExpressionMethods, OptionalExtension, QueryDsl, RunQueryDsl, SelectableHelper}; +use rocket::get; use rocket::http::Status; use rocket::post; -use rocket::serde::{Deserialize, json::Json}; +use rocket::serde::{Deserialize, Serialize, json::Json}; use rocket_okapi::JsonSchema; use rocket_okapi::openapi; // local imports -use crate::auth::AdminGuard; +use crate::auth::{AdminGuard, UserGuard}; use crate::db::DbConn; use crate::db::models::User; @@ -19,6 +20,91 @@ pub struct CreateUserForm { pub admin: bool, } +#[derive(Debug, Serialize, JsonSchema)] +pub struct UserSummary { + pub id: i32, + pub username: String, + pub admin: bool, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct BootstrapResponse { + pub has_users: bool, + pub current_user: Option, +} + +#[openapi(tag = "Users")] +#[get("/api/v1/bootstrap")] +pub async fn get_bootstrap( + db: DbConn, + user_guard: Option, +) -> Result, Status> { + use crate::db::schema::users::dsl::*; + + let has_users = db + .run(|conn| users.count().get_result::(conn)) + .await + .map_err(|_| Status::InternalServerError)? + > 0; + + let current_user = if let Some(user_guard) = user_guard { + let user_id = user_guard + .claims() + .sub + .parse::() + .map_err(|_| Status::Unauthorized)?; + db.run(move |conn| { + users + .filter(id.eq(user_id)) + .select(User::as_select()) + .first::(conn) + .optional() + }) + .await + .map_err(|_| Status::InternalServerError)? + .map(|user| UserSummary { + id: user.id, + username: user.username, + admin: user.admin, + }) + } else { + None + }; + + Ok(Json(BootstrapResponse { + has_users, + current_user, + })) +} + +#[openapi(tag = "Users")] +#[get("/api/v1/users")] +pub async fn list_users( + db: DbConn, + _admin_guard: AdminGuard, +) -> Result>, Status> { + use crate::db::schema::users::dsl::*; + + let users_list = db + .run(|conn| { + users + .order(username.asc()) + .select(User::as_select()) + .load::(conn) + }) + .await + .map_err(|_| Status::InternalServerError)?; + + Ok(Json(users_list + .into_iter() + .map(|user| UserSummary { + id: user.id, + username: user.username, + admin: user.admin, + }) + .collect())) +} + #[openapi(tag = "Users")] #[post("/create_user", format = "json", data = "")] pub async fn create_user( @@ -68,7 +154,7 @@ pub async fn create_user( username: form.username, password: hashed_password, pin: hashed_pin, - admin: form.admin, + admin: existing_count == 0 || form.admin, }; // Insert new user diff --git a/crates/server/tests/main.rs b/crates/server/tests/main.rs index a203a9ea..2a3161b3 100644 --- a/crates/server/tests/main.rs +++ b/crates/server/tests/main.rs @@ -1,5 +1,7 @@ pub mod test_auth; pub mod test_dependencies; +pub mod test_media; +pub mod test_metadata; pub mod test_tray; pub mod test_utils; pub mod test_web; diff --git a/crates/server/tests/test_auth.rs b/crates/server/tests/test_auth.rs index dbf9ecdf..f5f52607 100644 --- a/crates/server/tests/test_auth.rs +++ b/crates/server/tests/test_auth.rs @@ -6,13 +6,7 @@ use rstest::rstest; // local imports use koko::auth::{ - AdminGuard, - AuthGuard, - UserGuard, - create_token, - decode_token, - hash_password, - verify_password, + AdminGuard, AuthGuard, UserGuard, create_token, decode_token, hash_password, verify_password, }; #[rstest] diff --git a/crates/server/tests/test_dependencies/mod.rs b/crates/server/tests/test_dependencies/mod.rs index 2d08ad92..13d6ad9b 100644 --- a/crates/server/tests/test_dependencies/mod.rs +++ b/crates/server/tests/test_dependencies/mod.rs @@ -63,6 +63,7 @@ fn dependency_exceptions() -> Vec<&'static str> { "koko", "dlopen2_derive", // https://github.com/OpenByteDev/dlopen2/issues/20 "ring", // https://github.com/briansmith/ring/blob/main/LICENSE + "webpki-root-certs", ] } diff --git a/crates/server/tests/test_media.rs b/crates/server/tests/test_media.rs new file mode 100644 index 00000000..d5f152ef --- /dev/null +++ b/crates/server/tests/test_media.rs @@ -0,0 +1,893 @@ +// standard imports +use std::fs; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; + +// lib imports +use diesel::RunQueryDsl; +use diesel::Connection; +use diesel::SqliteConnection; +use diesel_migrations::MigrationHarness; + +// local imports +use koko::config::{FfmpegSettings, MediaLibraryKind, MediaLibrarySettings, MetadataProviderId}; +use koko::db::MIGRATIONS; +use koko::media::{ + LibraryScanStatus, get_library_files, get_media_home, get_media_item, + get_persisted_library_summaries, + get_item_theme_song_themerr_reference, + inspect_libraries, inspect_transcoding_capability, list_automatic_metadata_candidates, + list_library_settings, list_media_items, remove_library_setting, + replace_library_settings, search_media_items, sync_library_catalog, + upsert_playback_progress, +}; +use koko::metadata::{StoredMetadataSnapshot, set_item_metadata_refresh_state, upsert_item_metadata_snapshot}; + +static MEDIA_TEST_COUNTER: AtomicU64 = AtomicU64::new(0); + +fn unique_temp_dir(name: &str) -> PathBuf { + let test_id = MEDIA_TEST_COUNTER.fetch_add(1, Ordering::SeqCst); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + + std::env::temp_dir().join(format!("koko_{}_{}_{}", name, test_id, timestamp)) +} + +fn create_test_connection(name: &str) -> (SqliteConnection, PathBuf) { + let db_path = unique_temp_dir(name).with_extension("db"); + let mut connection = SqliteConnection::establish(&db_path.to_string_lossy()) + .expect("Failed to establish SQLite test connection"); + + connection + .run_pending_migrations(MIGRATIONS) + .expect("Failed to run test migrations"); + + (connection, db_path) +} + +#[test] +fn test_inspect_libraries_counts_media_types() { + let root = unique_temp_dir("library_scan"); + let nested = root.join("nested"); + fs::create_dir_all(&nested).unwrap(); + + fs::write(root.join("movie.mkv"), b"video").unwrap(); + fs::write(root.join("song.mp3"), b"audio").unwrap(); + fs::write(root.join("cover.jpg"), b"image").unwrap(); + fs::write(root.join("book.epub"), b"book").unwrap(); + fs::write(nested.join("notes.txt"), b"other").unwrap(); + fs::write(nested.join("episode.mp4"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Primary library".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Mixed, + metadata_providers: vec![], + }]; + + let summaries = inspect_libraries(&libraries); + assert_eq!(summaries.len(), 1); + + let summary = &summaries[0]; + assert_eq!(summary.status, LibraryScanStatus::Available); + assert_eq!(summary.total_files, 5); + assert_eq!(summary.video_files, 2); + assert_eq!(summary.audio_files, 1); + assert_eq!(summary.image_files, 1); + assert_eq!(summary.book_files, 1); + assert_eq!(summary.other_files, 0); + assert!(summary.error.is_none()); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn test_inspect_libraries_detects_missing_and_empty_paths() { + let missing_path = unique_temp_dir("missing_library") + .to_string_lossy() + .to_string(); + let libraries = vec![ + MediaLibrarySettings { + name: "Empty".into(), + path: String::new(), + paths: vec![], + recursive: true, + kind: MediaLibraryKind::Mixed, + metadata_providers: vec![], + }, + MediaLibrarySettings { + name: "Missing".into(), + path: missing_path.clone(), + paths: vec![missing_path], + recursive: true, + kind: MediaLibraryKind::Movies, + metadata_providers: vec![], + }, + ]; + + let summaries = inspect_libraries(&libraries); + assert_eq!(summaries[0].status, LibraryScanStatus::EmptyPath); + assert_eq!(summaries[1].status, LibraryScanStatus::MissingPath); +} + +#[test] +fn test_movie_library_ignores_sidecar_audio_and_json_files() { + let root = unique_temp_dir("movie_library_filtering"); + fs::create_dir_all(&root).unwrap(); + + fs::write(root.join("movie.mkv"), b"video").unwrap(); + fs::write(root.join("theme.mp3"), b"audio").unwrap(); + fs::write(root.join("movie.json"), b"metadata").unwrap(); + fs::write(root.join("poster.jpg"), b"image").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Movies".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Movies, + metadata_providers: vec![], + }]; + + let summaries = inspect_libraries(&libraries); + assert_eq!(summaries.len(), 1); + assert_eq!(summaries[0].total_files, 1); + assert_eq!(summaries[0].video_files, 1); + assert_eq!(summaries[0].audio_files, 0); + assert_eq!(summaries[0].image_files, 0); + assert_eq!(summaries[0].other_files, 0); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn test_inspect_transcoding_capability_reports_missing_binary() { + let settings = FfmpegSettings { + ffmpeg_path: "koko-ffmpeg-missing-binary".into(), + ffprobe_path: "koko-ffprobe-missing-binary".into(), + ..FfmpegSettings::default() + }; + + let capability = inspect_transcoding_capability(&settings); + assert!(!capability.ffmpeg.available); + assert!(!capability.ffprobe.available); + assert!(capability.ffmpeg.error.is_some()); + assert!(capability.ffprobe.error.is_some()); +} + +#[test] +fn test_sync_library_catalog_persists_library_and_inventory() { + let root = unique_temp_dir("persist_library_scan"); + let nested = root.join("nested"); + fs::create_dir_all(&nested).unwrap(); + + fs::write(root.join("movie.mkv"), b"video").unwrap(); + fs::write(root.join("song.mp3"), b"audio").unwrap(); + fs::write(nested.join("episode.mp4"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Persistent library".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Mixed, + metadata_providers: vec![], + }]; + + let (mut connection, db_path) = create_test_connection("media_catalog"); + let persisted = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + + assert_eq!(persisted.len(), 1); + let library = &persisted[0]; + assert!(library.id > 0); + assert_eq!(library.status, LibraryScanStatus::Available); + assert_eq!(library.total_files, 3); + assert_eq!(library.video_files, 2); + assert_eq!(library.audio_files, 1); + + let files = get_library_files(&mut connection, library.id).unwrap(); + assert_eq!(files.len(), 3); + assert_eq!(files[0].library_id, library.id); + assert!(files.iter().any(|file| file.relative_path == "movie.mkv")); + assert!(files.iter().any(|file| file.relative_path == "song.mp3")); + assert!( + files + .iter() + .any(|file| file.relative_path == "nested/episode.mp4") + ); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_sync_library_catalog_updates_incrementally() { + let root = unique_temp_dir("incremental_library_scan"); + fs::create_dir_all(&root).unwrap(); + + fs::write(root.join("movie.mkv"), b"original-video").unwrap(); + fs::write(root.join("song.mp3"), b"audio").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Incremental library".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Mixed, + metadata_providers: vec![], + }]; + + let (mut connection, db_path) = create_test_connection("media_catalog_incremental"); + let first_sync = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let first_library = &first_sync[0]; + let first_files = get_library_files(&mut connection, first_library.id).unwrap(); + let first_movie = first_files + .iter() + .find(|file| file.relative_path == "movie.mkv") + .unwrap(); + + fs::remove_file(root.join("song.mp3")).unwrap(); + fs::write(root.join("movie.mkv"), b"updated-video-content").unwrap(); + fs::write(root.join("cover.jpg"), b"image").unwrap(); + + let second_sync = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let second_library = &second_sync[0]; + let second_files = get_library_files(&mut connection, second_library.id).unwrap(); + let second_movie = second_files + .iter() + .find(|file| file.relative_path == "movie.mkv") + .unwrap(); + + assert_eq!( + second_library.scan_revision, + first_library.scan_revision + 1 + ); + assert_eq!(second_library.total_files, 2); + assert_eq!(second_library.video_files, 1); + assert_eq!(second_library.image_files, 1); + assert_eq!(second_files.len(), 2); + assert_eq!(first_movie.id, second_movie.id); + assert!( + second_files + .iter() + .any(|file| file.relative_path == "cover.jpg") + ); + assert!( + !second_files + .iter() + .any(|file| file.relative_path == "song.mp3") + ); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_item_queries_and_search_work_on_persisted_catalog() { + let root = unique_temp_dir("item_query_library_scan"); + let nested = root.join("nested"); + fs::create_dir_all(&nested).unwrap(); + + fs::write(root.join("movie.mkv"), b"video").unwrap(); + fs::write(root.join("song.mp3"), b"audio").unwrap(); + fs::write(nested.join("episode.mp4"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Query library".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Mixed, + metadata_providers: vec![], + }]; + + let (mut connection, db_path) = create_test_connection("media_catalog_item_queries"); + let persisted = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library = &persisted[0]; + + let items = list_media_items(&mut connection, Some(library.id)).unwrap(); + assert_eq!(items.len(), 3); + assert!(items.iter().any(|item| item.display_title == "movie")); + + let movie = items + .iter() + .find(|item| item.relative_path == "movie.mkv") + .unwrap(); + let detail = get_media_item(&mut connection, movie.id, &root.to_string_lossy()) + .unwrap() + .expect("Expected movie detail to exist"); + assert_eq!(detail.display_title, "movie"); + assert_eq!(detail.relative_path, "movie.mkv"); + + let search_results = search_media_items(&mut connection, "episode", Some(library.id)).unwrap(); + assert_eq!(search_results.len(), 1); + assert_eq!(search_results[0].relative_path, "nested/episode.mp4"); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_shows_library_builds_show_season_episode_hierarchy() { + let root = unique_temp_dir("shows_library_hierarchy"); + let season = root.join("Mock Show").join("Season 1"); + fs::create_dir_all(&season).unwrap(); + fs::write(season.join("Mock Show - S01E01 - Pilot.mkv"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Shows".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Shows, + metadata_providers: vec![MetadataProviderId::Tmdb], + }]; + + let (mut connection, db_path) = create_test_connection("shows_library_hierarchy_db"); + let persisted = sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library = &persisted[0]; + let items = list_media_items(&mut connection, Some(library.id)).unwrap(); + + assert_eq!(items.len(), 3); + + let show = items.iter().find(|item| item.item_type == "show").unwrap(); + let season = items.iter().find(|item| item.item_type == "season").unwrap(); + let episode = items.iter().find(|item| item.item_type == "episode").unwrap(); + + assert_eq!(show.parent_id, None); + assert_eq!(show.child_count, 1); + assert_eq!(season.parent_id, Some(show.id)); + assert_eq!(season.child_count, 1); + assert_eq!(episode.parent_id, Some(season.id)); + assert!(episode.playable); + assert_eq!(episode.season_number, Some(1)); + assert_eq!(episode.episode_number, Some(1)); + + let show_detail = get_media_item(&mut connection, show.id, &root.to_string_lossy()) + .unwrap() + .expect("Expected show detail to exist"); + assert_eq!(show_detail.children.len(), 1); + assert_eq!(show_detail.children[0].id, season.id); + + let season_detail = get_media_item(&mut connection, season.id, &root.to_string_lossy()) + .unwrap() + .expect("Expected season detail to exist"); + assert_eq!(season_detail.hierarchy.len(), 1); + assert_eq!(season_detail.hierarchy[0].id, show.id); + assert_eq!(season_detail.children.len(), 1); + assert_eq!(season_detail.children[0].id, episode.id); + + let episode_detail = get_media_item(&mut connection, episode.id, &root.to_string_lossy()) + .unwrap() + .expect("Expected episode detail to exist"); + assert_eq!(episode_detail.hierarchy.len(), 2); + assert_eq!(episode_detail.hierarchy[0].id, show.id); + assert_eq!(episode_detail.hierarchy[1].id, season.id); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_shows_are_included_in_automatic_metadata_candidates() { + let root = unique_temp_dir("automatic_show_metadata_candidates"); + let season = root.join("Mock Show").join("Season 1"); + fs::create_dir_all(&season).unwrap(); + fs::write(season.join("Mock Show - S01E01 - Pilot.mkv"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Shows".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Shows, + metadata_providers: vec![MetadataProviderId::Tmdb], + }]; + + let (mut connection, db_path) = create_test_connection("automatic_show_metadata_candidates_db"); + let persisted = sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library = &persisted[0]; + let show = list_media_items(&mut connection, Some(library.id)) + .unwrap() + .into_iter() + .find(|item| item.item_type == "show") + .expect("Expected show item to exist"); + + let candidates = list_automatic_metadata_candidates(&mut connection, 8).unwrap(); + assert!(candidates.iter().any(|candidate| { + candidate.item_id == show.id + && candidate.display_title == show.display_title + && candidate.library_kind == MediaLibraryKind::Shows + })); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_show_recently_added_collapses_to_episode_season_or_show() { + let root = unique_temp_dir("show_recently_added_collapsed"); + let alpha = root.join("Alpha Show").join("Season 1"); + let beta = root.join("Beta Show").join("Season 1"); + let gamma_season_1 = root.join("Gamma Show").join("Season 1"); + let gamma_season_2 = root.join("Gamma Show").join("Season 2"); + fs::create_dir_all(&alpha).unwrap(); + fs::create_dir_all(&beta).unwrap(); + fs::create_dir_all(&gamma_season_1).unwrap(); + fs::create_dir_all(&gamma_season_2).unwrap(); + + fs::write(alpha.join("Alpha Show - S01E01 - Pilot.mkv"), b"video").unwrap(); + fs::write(beta.join("Beta Show - S01E01 - Pilot.mkv"), b"video").unwrap(); + fs::write(beta.join("Beta Show - S01E02 - Second.mkv"), b"video").unwrap(); + fs::write(gamma_season_1.join("Gamma Show - S01E01 - Pilot.mkv"), b"video").unwrap(); + fs::write(gamma_season_2.join("Gamma Show - S02E01 - Return.mkv"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Shows".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Shows, + metadata_providers: vec![MetadataProviderId::Tmdb], + }]; + + let (mut connection, db_path) = create_test_connection("show_recently_added_collapsed_db"); + let persisted = sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library = &persisted[0]; + let home = get_media_home(&mut connection, None, Some(library.id)).unwrap(); + let recently_added = home + .shelves + .iter() + .find(|shelf| shelf.id == "recently_added") + .expect("Expected recently added shelf"); + + assert_eq!(recently_added.items.len(), 3); + assert_eq!( + recently_added + .items + .iter() + .filter(|item| item.item_type == "episode") + .count(), + 1 + ); + assert_eq!( + recently_added + .items + .iter() + .filter(|item| item.item_type == "season") + .count(), + 1 + ); + assert_eq!( + recently_added + .items + .iter() + .filter(|item| item.item_type == "show") + .count(), + 1 + ); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_home_includes_real_collection_summaries() { + let root = unique_temp_dir("home_collection_summaries"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("collection-one.mkv"), b"video").unwrap(); + fs::write(root.join("collection-two.mkv"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Movies".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Movies, + metadata_providers: vec![MetadataProviderId::Tmdb], + }]; + + let (mut connection, db_path) = create_test_connection("home_collection_summaries_db"); + let persisted = sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library = &persisted[0]; + let items = list_media_items(&mut connection, Some(library.id)).unwrap(); + + for item in items.iter().take(2) { + let snapshot = StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tmdb, + external_id: format!("movie-{}", item.id), + media_type: Some("movie".into()), + title: Some(item.display_title.clone()), + overview: Some("Part of a test collection.".into()), + artwork_url: None, + backdrop_url: None, + release_year: Some(1999), + provider_payload_json: Some( + serde_json::json!({ + "title": item.display_title, + "overview": "Part of a test collection.", + "release_date": "1999-03-31", + "belongs_to_collection": { + "id": 4242, + "name": "Test Saga", + "overview": "A linked movie collection for home browsing.", + "poster_path": "/poster.jpg", + "backdrop_path": "/backdrop.jpg" + } + }) + .to_string(), + ), + }; + upsert_item_metadata_snapshot(&mut connection, item.id, &snapshot).unwrap(); + } + + let home = get_media_home(&mut connection, None, Some(library.id)).unwrap(); + assert_eq!(home.collections.len(), 1); + assert_eq!(home.collections[0].name, "Test Saga"); + assert_eq!(home.collections[0].item_count, 2); + assert_eq!(home.collections[0].item_ids.len(), 2); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_sync_restores_file_name_as_display_title() { + let root = unique_temp_dir("title_policy_refresh"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("Movie Name.mkv"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Movies".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Movies, + metadata_providers: vec![], + }]; + + let (mut connection, db_path) = create_test_connection("media_catalog_title_policy_refresh"); + let persisted = sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library = &persisted[0]; + let items = list_media_items(&mut connection, Some(library.id)).unwrap(); + let movie = items.first().unwrap(); + + diesel::sql_query("UPDATE media_files SET display_title = ? WHERE id = ?") + .bind::, _>(Some( + "Embedded Metadata Title".to_string(), + )) + .bind::(movie.id) + .execute(&mut connection) + .unwrap(); + + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let refreshed = get_media_item(&mut connection, movie.id, &root.to_string_lossy()) + .unwrap() + .expect("Expected item detail after refresh"); + assert_eq!(refreshed.display_title, "Movie Name"); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_item_detail_includes_linked_metadata_presentation() { + let root = unique_temp_dir("item_detail_linked_metadata"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("movie.mkv"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Movies".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Movies, + metadata_providers: vec![], + }]; + + let (mut connection, db_path) = create_test_connection("media_catalog_item_detail_linked_metadata"); + let persisted = sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library = &persisted[0]; + let items = list_media_items(&mut connection, Some(library.id)).unwrap(); + let movie = items.first().unwrap(); + + let snapshot = StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tmdb, + external_id: "603".into(), + media_type: Some("movie".into()), + title: Some("The Matrix".into()), + overview: Some("A hacker discovers reality is a simulation.".into()), + artwork_url: Some("https://image.tmdb.org/t/p/w500/poster.jpg".into()), + backdrop_url: Some("https://image.tmdb.org/t/p/w1280/backdrop.jpg".into()), + release_year: Some(1999), + provider_payload_json: Some( + serde_json::json!({ + "tagline": "Welcome to the real world.", + "overview": "A hacker discovers reality is a simulation.", + "genres": [ + { "id": 28, "name": "Action" }, + { "id": 878, "name": "Science Fiction" } + ], + "release_date": "1999-03-31", + "videos": { + "results": [ + { + "site": "YouTube", + "type": "Trailer", + "official": true, + "name": "Official Trailer", + "key": "vKQi3bBA1y8" + } + ] + } + }) + .to_string(), + ), + }; + upsert_item_metadata_snapshot(&mut connection, movie.id, &snapshot).unwrap(); + + let detail = get_media_item(&mut connection, movie.id, &root.to_string_lossy()) + .unwrap() + .expect("Expected linked movie detail to exist"); + let expected_poster_url = format!("/api/v1/items/{}/artwork?kind=poster", movie.id); + let expected_backdrop_url = format!("/api/v1/items/{}/artwork?kind=backdrop", movie.id); + assert_eq!(detail.tagline.as_deref(), Some("Welcome to the real world.")); + assert_eq!(detail.release_year, Some(1999)); + assert_eq!(detail.genres, vec!["Action", "Science Fiction"]); + assert!(detail.artwork_updated_at.is_some()); + assert_eq!(detail.trailer_title.as_deref(), Some("Official Trailer")); + assert_eq!( + detail.trailer_url.as_deref(), + Some("https://www.youtube.com/embed/vKQi3bBA1y8?autoplay=1&rel=0") + ); + assert_eq!(detail.poster_url.as_deref(), Some(expected_poster_url.as_str())); + assert_eq!(detail.backdrop_url.as_deref(), Some(expected_backdrop_url.as_str())); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_persisted_library_summaries_include_metadata_refresh_progress() { + let root = unique_temp_dir("library_refresh_progress"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("The Matrix (1999).mkv"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Movies".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Movies, + metadata_providers: vec![MetadataProviderId::Tmdb], + }]; + + let (mut connection, db_path) = create_test_connection("library_refresh_progress_db"); + let persisted = sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library = &persisted[0]; + let items = list_media_items(&mut connection, Some(library.id)).unwrap(); + let movie = items.iter().find(|item| item.item_type == "movie").unwrap(); + + upsert_item_metadata_snapshot( + &mut connection, + movie.id, + &StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tmdb, + external_id: "603".into(), + media_type: Some("movie".into()), + title: Some("The Matrix".into()), + overview: Some("A hacker discovers reality is a simulation.".into()), + artwork_url: Some("https://image.tmdb.org/t/p/w500/f89U3ADr1oiB1s9GkdPOEpXUk5H.jpg".into()), + backdrop_url: None, + release_year: Some(1999), + provider_payload_json: None, + }, + ) + .unwrap(); + + let fresh_summary = get_persisted_library_summaries(&mut connection, &libraries).unwrap(); + assert_eq!(fresh_summary[0].metadata_refresh_total, 1); + assert_eq!(fresh_summary[0].metadata_refresh_pending, 0); + assert_eq!(fresh_summary[0].metadata_refresh_completed, 1); + assert_eq!(fresh_summary[0].metadata_refresh_failed, 0); + + set_item_metadata_refresh_state( + &mut connection, + movie.id, + MetadataProviderId::Tmdb, + "603", + Some("movie"), + "pending", + None, + ) + .unwrap(); + let pending_summary = get_persisted_library_summaries(&mut connection, &libraries).unwrap(); + assert_eq!(pending_summary[0].metadata_refresh_total, 1); + assert_eq!(pending_summary[0].metadata_refresh_pending, 1); + assert_eq!(pending_summary[0].metadata_refresh_completed, 0); + assert_eq!(pending_summary[0].metadata_refresh_failed, 0); + + set_item_metadata_refresh_state( + &mut connection, + movie.id, + MetadataProviderId::Tmdb, + "603", + Some("movie"), + "error", + Some("boom"), + ) + .unwrap(); + let error_summary = get_persisted_library_summaries(&mut connection, &libraries).unwrap(); + assert_eq!(error_summary[0].metadata_refresh_total, 1); + assert_eq!(error_summary[0].metadata_refresh_pending, 0); + assert_eq!(error_summary[0].metadata_refresh_completed, 1); + assert_eq!(error_summary[0].metadata_refresh_failed, 1); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_themerr_theme_song_reference_inherits_from_linked_show() { + let root = unique_temp_dir("themerr_theme_song_reference_show"); + let season_dir = root.join("Mock Show").join("Season 1"); + fs::create_dir_all(&season_dir).unwrap(); + fs::write(season_dir.join("Mock Show - S01E01 - Winter Is Coming.mkv"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Shows".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Shows, + metadata_providers: vec![MetadataProviderId::Tmdb], + }]; + + let (mut connection, db_path) = create_test_connection("themerr_theme_song_reference_show_db"); + let persisted = sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library = &persisted[0]; + let items = list_media_items(&mut connection, Some(library.id)).unwrap(); + let show = items.iter().find(|item| item.item_type == "show").unwrap(); + let season = items.iter().find(|item| item.item_type == "season").unwrap(); + let episode = items.iter().find(|item| item.item_type == "episode").unwrap(); + + upsert_item_metadata_snapshot( + &mut connection, + show.id, + &StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tmdb, + external_id: "1399".into(), + media_type: Some("tv".into()), + title: Some("Mock Show".into()), + overview: None, + artwork_url: None, + backdrop_url: None, + release_year: Some(2011), + provider_payload_json: Some(serde_json::json!({ "name": "Mock Show" }).to_string()), + }, + ) + .unwrap(); + + assert_eq!( + get_item_theme_song_themerr_reference(&mut connection, show.id).unwrap(), + Some(("tv".into(), "1399".into())) + ); + assert_eq!( + get_item_theme_song_themerr_reference(&mut connection, season.id).unwrap(), + Some(("tv".into(), "1399".into())) + ); + assert_eq!( + get_item_theme_song_themerr_reference(&mut connection, episode.id).unwrap(), + Some(("tv".into(), "1399".into())) + ); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_library_settings_are_persisted_in_database() { + let root = unique_temp_dir("persisted_library_settings_movies"); + let updated_root = unique_temp_dir("persisted_library_settings_shows"); + fs::create_dir_all(&root).unwrap(); + fs::create_dir_all(&updated_root).unwrap(); + + let legacy_libraries = vec![MediaLibrarySettings { + name: "Movies".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Movies, + metadata_providers: vec![MetadataProviderId::Tmdb], + }]; + + let (mut connection, db_path) = create_test_connection("persisted_library_settings_db"); + let bootstrapped = list_library_settings(&mut connection, &legacy_libraries).unwrap(); + assert_eq!(bootstrapped.len(), 1); + assert_eq!(bootstrapped[0].name, "Movies"); + + let updated = replace_library_settings( + &mut connection, + &[MediaLibrarySettings { + name: "Shows".into(), + path: updated_root.to_string_lossy().to_string(), + paths: vec![updated_root.to_string_lossy().to_string()], + recursive: false, + kind: MediaLibraryKind::Shows, + metadata_providers: vec![MetadataProviderId::Tmdb], + }], + ) + .unwrap(); + assert_eq!(updated.len(), 1); + assert_eq!(updated[0].name, "Shows"); + assert_eq!(updated[0].kind, MediaLibraryKind::Shows); + + assert!(remove_library_setting(&mut connection, 0).unwrap()); + assert!(list_library_settings(&mut connection, &[]).unwrap().is_empty()); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_dir_all(updated_root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_playback_progress_is_scoped_per_user() { + let root = unique_temp_dir("playback_progress_per_user"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("movie.mkv"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Movies".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Movies, + metadata_providers: vec![MetadataProviderId::Tmdb], + }]; + + let (mut connection, db_path) = create_test_connection("playback_progress_per_user_db"); + diesel::sql_query("INSERT INTO users (username, password, pin, admin) VALUES ('alice', 'hash', NULL, 1), ('bob', 'hash', NULL, 0)") + .execute(&mut connection) + .unwrap(); + let persisted = sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library = &persisted[0]; + let item = list_media_items(&mut connection, Some(library.id)).unwrap().pop().unwrap(); + + upsert_playback_progress(&mut connection, 1, item.id, 120_000, item.duration_ms, false).unwrap(); + upsert_playback_progress(&mut connection, 2, item.id, 240_000, item.duration_ms, false).unwrap(); + + let alice_home = get_media_home(&mut connection, Some(1), Some(library.id)).unwrap(); + let bob_home = get_media_home(&mut connection, Some(2), Some(library.id)).unwrap(); + let anonymous_home = get_media_home(&mut connection, None, Some(library.id)).unwrap(); + + assert_eq!(alice_home.shelves[0].items.len(), 1); + assert_eq!(bob_home.shelves[0].items.len(), 1); + assert!(anonymous_home.shelves[0].items.is_empty()); + assert_eq!(alice_home.shelves[0].items[0].id, item.id); + assert_eq!(bob_home.shelves[0].items[0].id, item.id); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + diff --git a/crates/server/tests/test_metadata.rs b/crates/server/tests/test_metadata.rs new file mode 100644 index 00000000..3155bb28 --- /dev/null +++ b/crates/server/tests/test_metadata.rs @@ -0,0 +1,72 @@ +// local imports +use koko::config::{ + MediaLibraryKind, MediaLibrarySettings, MetadataProviderId, MetadataProviderSettings, + MetadataSettings, Settings, settings_for_persistence, +}; +use koko::metadata::list_provider_statuses; + +#[test] +fn test_metadata_provider_statuses_include_tmdb() { + let statuses = list_provider_statuses(&MetadataSettings::default()); + let tmdb = statuses + .iter() + .find(|provider| provider.id == MetadataProviderId::Tmdb) + .expect("Expected TMDB provider to be registered"); + + assert_eq!(tmdb.display_name, "TheMovieDB"); + assert!(tmdb.enabled); + assert!(tmdb.requires_api_key); + assert!(!tmdb.configured); + assert!(tmdb.implemented); +} + +#[test] +fn test_metadata_provider_statuses_respect_api_key_configuration() { + let settings = MetadataSettings { + providers: vec![MetadataProviderSettings { + id: MetadataProviderId::Tmdb, + enabled: true, + api_key: Some("test-key".into()), + language: "en-US".into(), + rate_limit_per_second: 4, + retry_attempts: 3, + retry_backoff_ms: 1_000, + }], + }; + + let statuses = list_provider_statuses(&settings); + let tmdb = statuses + .iter() + .find(|provider| provider.id == MetadataProviderId::Tmdb) + .expect("Expected TMDB provider to be registered"); + + assert!(tmdb.configured); +} + +#[test] +fn test_metadata_provider_id_rejects_legacy_musicbrainz_alias() { + let canonical: MetadataProviderId = serde_json::from_str("\"musicbrainz\"") + .expect("Expected canonical musicbrainz identifier to deserialize"); + let legacy = serde_json::from_str::("\"music_brainz\""); + + assert_eq!(canonical, MetadataProviderId::MusicBrainz); + assert!(legacy.is_err()); + assert_eq!(serde_json::to_string(&MetadataProviderId::MusicBrainz).unwrap(), "\"musicbrainz\""); +} + +#[test] +fn test_settings_persistence_clears_library_definitions() { + let mut settings = Settings::default(); + settings.media.libraries.push(MediaLibrarySettings { + name: "Movies".into(), + path: "C:/Media/Movies".into(), + paths: vec!["C:/Media/Movies".into()], + recursive: true, + kind: MediaLibraryKind::Movies, + metadata_providers: vec![MetadataProviderId::Tmdb], + }); + + let persisted = settings_for_persistence(&settings); + assert!(persisted.media.libraries.is_empty()); +} + diff --git a/crates/server/tests/test_utils.rs b/crates/server/tests/test_utils.rs index c6fa9d62..e05d2b38 100644 --- a/crates/server/tests/test_utils.rs +++ b/crates/server/tests/test_utils.rs @@ -4,6 +4,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; // lib imports +use once_cell::sync::Lazy; use rocket::http::{ContentType, Header, Status}; use rocket::local::asynchronous::Client; use serde_json::Value; @@ -13,6 +14,8 @@ use koko::web; // Global counter to ensure unique database files across all tests static GLOBAL_TEST_COUNTER: AtomicU64 = AtomicU64::new(0); +static TEST_CLIENT_CREATION_LOCK: Lazy> = + Lazy::new(|| std::sync::Mutex::new(())); /// Enhanced test response structure with headers pub struct TestResponse { @@ -23,6 +26,8 @@ pub struct TestResponse { /// Create a test client with an isolated database pub async fn create_test_client(prefix: Option<&str>) -> Client { + let _lock = TEST_CLIENT_CREATION_LOCK.lock().unwrap(); + // Set the test environment first use koko::globals::CURRENT_ENV; CURRENT_ENV.store(1, Ordering::SeqCst); diff --git a/crates/server/tests/test_web/mod.rs b/crates/server/tests/test_web/mod.rs index c92bf0cb..94f28ee4 100644 --- a/crates/server/tests/test_web/mod.rs +++ b/crates/server/tests/test_web/mod.rs @@ -47,7 +47,7 @@ async fn test_non_existent_route() { "/non-existent", None, None, - Some(Status::NotFound), + Some(Status::Ok), Some(false), ) .await; diff --git a/crates/server/tests/test_web/routes/auth.rs b/crates/server/tests/test_web/routes/auth.rs index 93d79488..1832dd16 100644 --- a/crates/server/tests/test_web/routes/auth.rs +++ b/crates/server/tests/test_web/routes/auth.rs @@ -4,10 +4,7 @@ use serde_json::json; // test imports use crate::test_utils::{ - create_and_login_user, - create_test_client, - create_test_user, - make_request, + create_and_login_user, create_test_client, create_test_user, make_request, }; #[rocket::async_test] diff --git a/crates/server/tests/test_web/routes/common.rs b/crates/server/tests/test_web/routes/common.rs index 8c37d376..26f1157f 100644 --- a/crates/server/tests/test_web/routes/common.rs +++ b/crates/server/tests/test_web/routes/common.rs @@ -8,6 +8,39 @@ use crate::test_utils::{create_test_client, make_request}; async fn test_root_route() { let client = create_test_client(Some("common_routes")).await; + let response = make_request( + Some(&client), + "get", + "/", + None, + None, + Some(Status::Ok), + Some(true), + ) + .await; + + let content_type = response + .headers + .iter() + .find(|header| header.name().as_str().eq_ignore_ascii_case("content-type")) + .map(|header| header.value().to_string()) + .unwrap_or_default(); + + assert!( + content_type.contains("text/html"), + "Expected HTML content type for the root route, got: {}", + content_type + ); + assert!( + response.body.contains(" Date: Thu, 23 Apr 2026 16:10:27 -0400 Subject: [PATCH 002/128] Add user profile fields & settings UI updates Add support for user profile fields (birthday, profile_image_url) across client, mock API and server. Include DB migration scripts to add the new columns and implement update user API handling in the client (PUT /api/v1/users/:id) and mock backend, with validation and admin checks. Introduce a request timeout (15s) with AbortController and explicit timeout error handling. Add metadata settings: automatic refresh interval (days) to settings model, default handling, normalization and UI control. Remove ffmpeg strategy from config and simplify FfmpegSettings default. Refactor the client settings UI into sectioned panels (General, Libraries, Dashboard, Logs) with a section nav and partial panel re-rendering. Improve metadata dashboard and log viewer table layouts, add inline user edit forms in settings, sync provider options when adding libraries, and refactor theme-song playback into a dedicated layer with sync logic. Update CSS for branding, tables and user-edit layout. Update tests and server code references accordingly. --- crates/client-web/src/api.ts | 34 +- crates/client-web/src/main.ts | 592 ++++++++++---- crates/client-web/src/mockApi.ts | 54 +- crates/client-web/src/style.css | 123 ++- .../0000012_add_user_profile_fields/down.sql | 18 + .../0000012_add_user_profile_fields/up.sql | 2 + crates/server/src/config.rs | 35 +- crates/server/src/db/mod.rs | 4 +- crates/server/src/db/models.rs | 6 +- crates/server/src/db/schema.rs | 2 + crates/server/src/logging/mod.rs | 33 +- crates/server/src/media.rs | 727 ++++++++++++++---- crates/server/src/metadata.rs | 506 ++++++++++-- crates/server/src/web/routes/media.rs | 725 ++++++++++++++--- crates/server/src/web/routes/mod.rs | 1 + crates/server/src/web/routes/settings.rs | 89 ++- crates/server/src/web/routes/user.rs | 118 ++- crates/server/tests/test_media.rs | 599 ++++++++++++++- crates/server/tests/test_metadata.rs | 63 +- crates/server/tests/test_web/routes/media.rs | 2 +- .../server/tests/test_web/routes/settings.rs | 30 +- crates/server/tests/test_web/routes/user.rs | 139 +++- 22 files changed, 3222 insertions(+), 680 deletions(-) create mode 100644 crates/server/sql/migrations/0000012_add_user_profile_fields/down.sql create mode 100644 crates/server/sql/migrations/0000012_add_user_profile_fields/up.sql diff --git a/crates/client-web/src/api.ts b/crates/client-web/src/api.ts index eff63b62..71603eb4 100644 --- a/crates/client-web/src/api.ts +++ b/crates/client-web/src/api.ts @@ -21,17 +21,19 @@ import { removeMockLibrary, searchMockItemMetadata, searchMockItems, + updateMockUser, updateMockPlaybackProgress, updateMockSettings, } from './mockApi'; +const REQUEST_TIMEOUT_MS = 15000; + export interface ServerCapabilities { app_name: string; version: string; server_url: string; https_enabled: boolean; libraries_configured: number; - ffmpeg_strategy?: string; api_versions: string[]; transcoding: { ffmpeg: { @@ -51,6 +53,8 @@ export interface BootstrapUser { id: number; username: string; admin: boolean; + birthday?: string; + profile_image_url?: string; } export interface AppBootstrapResponse { @@ -72,6 +76,15 @@ export interface CreateUserRequest { password: string; pin?: string; admin: boolean; + birthday?: string; + profile_image_url?: string; +} + +export interface UpdateUserRequest { + username: string; + admin: boolean; + birthday?: string; + profile_image_url?: string; } export interface MediaLibrary { @@ -301,6 +314,7 @@ export interface SettingsSnapshot { }; metadata: { providers: MetadataProviderSettings[]; + refresh_interval_days?: number | null; }; server: { use_https: boolean; @@ -311,7 +325,6 @@ export interface SettingsSnapshot { use_custom_certs: boolean; }; ffmpeg: { - strategy: string; ffmpeg_path: string; ffprobe_path: string; }; @@ -467,6 +480,11 @@ function getMockJsonResponse(method: string, path: string, body?: unknown): T return updateMockSettings(body as SettingsSnapshot) as T; } + const updateUserMatch = url.pathname.match(/^\/api\/v1\/users\/(\d+)$/); + if (method === 'PUT' && updateUserMatch) { + return updateMockUser(Number(updateUserMatch[1]), body as UpdateUserRequest) as T; + } + if (method === 'POST' && url.pathname === '/login') { return loginMockUser(body as LoginRequest) as T; } @@ -515,6 +533,8 @@ async function requestJson(method: string, path: string, body?: unknown): Pro } try { + const abortController = new AbortController(); + const timeoutHandle = window.setTimeout(() => abortController.abort(), REQUEST_TIMEOUT_MS); const response = await fetch(`${getStoredApiBase()}${path}`, { method, headers: { @@ -522,6 +542,9 @@ async function requestJson(method: string, path: string, body?: unknown): Pro ...(getStoredAuthToken() ? { Authorization: `Bearer ${getStoredAuthToken()}` } : {}), }, body: body === undefined ? undefined : JSON.stringify(body), + signal: abortController.signal, + }).finally(() => { + window.clearTimeout(timeoutHandle); }); if (!response.ok) { if (response.status === 401) { @@ -546,6 +569,9 @@ async function requestJson(method: string, path: string, body?: unknown): Pro return undefined as T; } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + throw new Error(`Request timed out after ${REQUEST_TIMEOUT_MS / 1000} seconds.`); + } if (import.meta.env.DEV) { useMockApi(); return getMockJsonResponse(method, path, body); @@ -575,6 +601,10 @@ export function getUsers(): Promise { return requestJson('GET', '/api/v1/users'); } +export function updateUser(userId: number, request: UpdateUserRequest): Promise { + return requestJson('PUT', `/api/v1/users/${userId}`, request); +} + export function getLibraries(): Promise { return requestJson('GET', '/api/v1/libraries'); } diff --git a/crates/client-web/src/main.ts b/crates/client-web/src/main.ts index 12d89f1b..22c55e66 100644 --- a/crates/client-web/src/main.ts +++ b/crates/client-web/src/main.ts @@ -1,4 +1,5 @@ import './style.css'; +import kokoLogoUrl from '../../../assets/Koko.svg'; import { createIcons, icons } from 'lucide'; import { addLibrary, @@ -33,10 +34,12 @@ import { setStoredAuthToken, updatePlaybackProgress, updateSettings, + updateUser, type AppBootstrapResponse, type ApiMode, type BootstrapUser, type CreateUserRequest, + type UpdateUserRequest, type MediaCollectionSummary, type ItemMetadataResponse, type LoginRequest, @@ -58,9 +61,10 @@ import { type AppRoute = | { page: 'home'; libraryId?: number } | { page: 'item'; itemId: number } - | { page: 'settings' }; + | { page: 'settings'; section?: SettingsSection }; type HomeBrowseTab = 'recommended' | 'library' | 'collections' | 'playlists' | 'categories'; +type SettingsSection = 'general' | 'libraries' | 'dashboard' | 'logs'; interface BrowseFilter { kind: 'category' | 'collection'; @@ -308,8 +312,9 @@ function defaultHomeTab(_route: AppRoute): HomeBrowseTab { function parseRoute(): AppRoute { const normalizedPath = window.location.pathname.replace(/\/+$/, '') || '/'; - if (normalizedPath === '/settings') { - return { page: 'settings' }; + const settingsMatch = normalizedPath.match(/^\/settings(?:\/(libraries|dashboard|logs))?$/); + if (settingsMatch) { + return { page: 'settings', section: (settingsMatch[1] as SettingsSection | undefined) ?? 'general' }; } const itemMatch = normalizedPath.match(/^\/items\/(\d+)$/); @@ -360,25 +365,46 @@ function itemIsMetadataPending(item: Pick { + const itemIds = new Set(); + if (state.route.page !== 'item' || !state.selectedItem) { + return itemIds; + } + + itemIds.add(state.selectedItem.id); + state.selectedItem.children.forEach((child) => itemIds.add(child.id)); + state.selectedItem.hierarchy.forEach((ancestor) => itemIds.add(ancestor.id)); + return itemIds; +} + function librariesHavePendingMetadataRefresh(): boolean { return state.libraries.some((library) => library.metadata_refresh_pending > 0); } function shouldAutoRefreshMetadata(): boolean { - if (activeMetadataRefreshActivities().length > 0) { - return true; - } - - if (librariesHavePendingMetadataRefresh()) { - return true; + if (state.route.page === 'settings') { + return false; } if (state.route.page === 'item') { + const itemPageIds = itemPageMetadataRefreshItemIds(); + const hasActiveItemPageRefresh = activeMetadataRefreshActivities() + .some((activity) => activity.item_ids.some((itemId) => itemPageIds.has(itemId))); + return itemIsMetadataPending(state.selectedItem) + || hasActiveItemPageRefresh || Boolean(state.selectedItem?.children.some((child) => itemIsMetadataPending(child))) || Boolean(state.selectedItemMetadata?.matches.some((match) => match.refresh_state === 'pending')); } + if (activeMetadataRefreshActivities().length > 0) { + return true; + } + + if (librariesHavePendingMetadataRefresh()) { + return true; + } + const visibleShelfItems = state.home?.shelves.flatMap((shelf) => shelf.items) ?? []; return [...state.libraryItems, ...state.searchResults, ...visibleShelfItems] .some((item) => item.metadata_refresh_state === 'pending'); @@ -883,7 +909,7 @@ function renderAuthShell(title: string, description: string, content: string): s
-
${renderIcon('clapperboard', 'brand-icon')}
+

Koko

${escapeHtml(description)}

@@ -908,6 +934,8 @@ function renderWelcomeScreen(): string { + + `, @@ -934,32 +962,40 @@ function renderUserManagement(): string { } return ` +
+
+

Users

+
+
+ ${state.users.length + ? state.users.map((user) => ` +
+
+ + + + +
+
+ ${user.admin ? 'Admin' : 'User'} + +
+
+ `).join('') + : '
No users found.
'} +
+
+
-
-

Users

-
-
- ${state.users.length - ? state.users.map((user) => ` -
-
- ${escapeHtml(user.username)} -

${user.admin ? 'Administrator' : 'Standard user'}

-
-
- ${user.admin ? 'Admin' : 'User'} -
-
- `).join('') - : '
No users found.
'} -

Add user

+ +
@@ -1594,31 +1630,42 @@ function renderMetadataDashboard(): string {
${filteredItems.length - ? `
`; @@ -1701,24 +1748,57 @@ function renderLogViewer(): string {
${logEntries.length - ? `
${logEntries.map((entry) => ` -
-
-
- ${escapeHtml(entry.level)} - ${escapeHtml(entry.module)} -
- ${escapeHtml(entry.timestamp)} -
-

${escapeHtml(entry.source_file_path)}${typeof entry.line_number === 'number' ? `:${entry.line_number}` : ''}

-
${escapeHtml(entry.message)}
-
- `).join('')}
` + ? `
+ + + + + + + + + + + ${logEntries.map((entry) => ` + + + + + + + + `).join('')} +
TimeLevelModuleSourceMessage
${escapeHtml(entry.timestamp)}${escapeHtml(entry.level)}${escapeHtml(entry.module)}${escapeHtml(entry.source_file_path)}${typeof entry.line_number === 'number' ? `:${entry.line_number}` : ''}
${escapeHtml(entry.message)}
+
` : '
No log entries matched the current filters.
'} `; } +function activeSettingsSection(): SettingsSection { + return state.route.page === 'settings' ? state.route.section ?? 'general' : 'general'; +} + +function renderSettingsSectionNav(): string { + const activeSection = activeSettingsSection(); + const sections: Array<{ id: SettingsSection; label: string; path: string }> = [ + { id: 'general', label: 'General', path: '/settings' }, + { id: 'libraries', label: 'Libraries', path: '/settings/libraries' }, + { id: 'dashboard', label: 'Dashboard', path: '/settings/dashboard' }, + { id: 'logs', label: 'Logs', path: '/settings/logs' }, + ]; + + return ` + + `; +} + function renderItemPage(): string { if (!state.selectedItem) { return '
Loading item details…
'; @@ -1727,10 +1807,6 @@ function renderItemPage(): string { const posterUrl = state.selectedItem.poster_url ? getArtworkUrl(state.selectedItem.id, 'poster', state.selectedItem.artwork_updated_at) : undefined; - const themeSongUrl = state.selectedItem.theme_song_url ? resolveApiUrl(state.selectedItem.theme_song_url) : undefined; - const themeSongYouTubeUrl = state.selectedItem.theme_song_youtube_url - ? buildYouTubeEmbedUrl(state.selectedItem.theme_song_youtube_url, { autoplay: true, controls: false }) - : undefined; const trailerOptions = currentTrailerOptions(); const preferredTrailer = trailerOptions[0]; const hasMultipleTrailers = trailerOptions.length > 1; @@ -1773,27 +1849,8 @@ function renderItemPage(): string { : state.selectedItem.item_type === 'season' ? 'Episodes' : 'Contained items'; - const themeSongMarkup = !state.isPlayerOpen && !state.activeTrailer - ? themeSongUrl - ? `` - : themeSongYouTubeUrl - ? ` - - ` - : '' - : ''; - return `
- ${themeSongMarkup} ${hierarchy.length ? `
`; @@ -1968,7 +2038,6 @@ function renderExistingLibrariesSettings(settings: SettingsSnapshot): string { function libraryKindOptions(selectedKind: string): string { return [ - ['mixed', 'Mixed'], ['movies', 'Movies'], ['shows', 'Shows'], ['music', 'Music'], @@ -1987,6 +2056,7 @@ function renderSettingsPage(): string { } const tmdb = settings.metadata.providers.find((provider) => provider.id === 'tmdb'); + const section = activeSettingsSection(); return ` ${renderPageNavbar( @@ -1994,6 +2064,8 @@ function renderSettingsPage(): string { 'Program configuration', `Saved to ${state.settingsResponse?.settings_path ?? ''}`, )} + ${renderSettingsSectionNav()} + ${section === 'general' ? `
@@ -2015,14 +2087,6 @@ function renderSettingsPage(): string {

FFmpeg

-
- -
@@ -2043,17 +2107,15 @@ function renderSettingsPage(): string {
- -
-
- -
-
-

Libraries

-
-

Each logical library can now contain multiple folders. Enter one folder per line.

-
- ${renderExistingLibrariesSettings(settings)} + +
@@ -2063,34 +2125,55 @@ function renderSettingsPage(): string { -
-
-

Add library

- - -
- - -
-
- Metadata sources -
${metadataProviderCheckboxes('library_metadata_provider', ['tmdb'])}
-
-
- -
- ${renderUserManagement()}
- ${renderMetadataDashboard()} - ${renderSystemActivitiesPanel()} - ${renderLogViewer()} + ` : ''} + ${section === 'libraries' ? ` +
+
+
+
+

Libraries

+
+

Each logical library can now contain multiple folders. Enter one folder per line.

+
+ ${renderExistingLibrariesSettings(settings)} +
+
+
+ +
+
+ +
+
+

Add library

+ + +
+ + +
+
+ Metadata sources +
${metadataProviderCheckboxes('library_metadata_provider', ['tmdb'])}
+
+
+ +
+
+ ` : ''} + ${section === 'dashboard' ? ` +
${renderMetadataDashboard()}
+
${renderSystemActivitiesPanel()}
+ ` : ''} + ${section === 'logs' ? `
${renderLogViewer()}
` : ''} `; } @@ -2172,10 +2255,10 @@ function renderRail(): string {
@@ -1333,7 +1334,7 @@ function renderLibraryOverview(): string { ${library.error ? `

${escapeHtml(library.error)}

` : ''} - ${library.status === 'never_scanned' ? '

Koko is scanning this library in the background. New items will appear automatically.

' : ''} + ${library.status === 'never_scanned' ? '

This library has not been scanned yet. It will populate after the next catalog scan starts.

' : ''} ${refreshProgress ? `

Metadata refresh progress: ${refreshProgress.completed}/${refreshProgress.total}${refreshProgress.failed ? ` (${refreshProgress.failed} failed)` : ''}. Artwork and item cards update automatically as each item completes.

` : ''} @@ -1355,7 +1356,7 @@ function renderLibraryTab(): string { } if (library?.status === 'never_scanned') { - return '
Koko is scanning this library right now. The show, season, and episode hierarchy will appear when the scan completes.
'; + return '
This library has not been scanned yet. The show, season, and episode hierarchy will appear after the first scan completes.
'; } if (library?.status && library.status !== 'available') { @@ -1493,7 +1494,12 @@ function renderHomePage(): string { ${library - ? `` + ? ` +
+ + +
+ ` : ''} `, @@ -1948,8 +1954,8 @@ function renderItemPage(): string { ${supportsManualLinking ? ` ` @@ -1962,11 +1968,20 @@ function renderItemPage(): string { const metadataProviderKinds: Record = { tmdb: ['movies', 'shows'], + tvdb: ['movies', 'shows'], musicbrainz: ['music'], open_library: ['books'], local_nfo: ['movies', 'shows', 'music', 'photos', 'books', 'home_videos'], }; +const metadataProviderLabels: Record = { + tmdb: 'TMDB', + tvdb: 'TheTVDB', + musicbrainz: 'MusicBrainz', + open_library: 'Open Library', + local_nfo: 'Local NFO', +}; + function metadataProviderCheckboxes(prefix: string, selectedProviders: string[], libraryKind?: string): string { return Object.keys(metadataProviderKinds) .filter((providerId) => !libraryKind || metadataProviderKinds[providerId].includes(libraryKind)) @@ -1979,7 +1994,7 @@ function metadataProviderCheckboxes(prefix: string, selectedProviders: string[], data-provider-kinds="${metadataProviderKinds[providerId].join(',')}" ${selectedProviders.includes(providerId) ? 'checked' : ''} /> - ${providerId} + ${metadataProviderLabels[providerId] ?? providerId} `) .join(''); @@ -2007,7 +2022,10 @@ function renderExistingLibrariesSettings(settings: SettingsSnapshot): string {
${persistedLibrary - ? `` + ? ` + + + ` : ''}
@@ -2056,6 +2074,7 @@ function renderSettingsPage(): string { } const tmdb = settings.metadata.providers.find((provider) => provider.id === 'tmdb'); + const tvdb = settings.metadata.providers.find((provider) => provider.id === 'tvdb'); const section = activeSettingsSection(); return ` @@ -2097,6 +2116,7 @@ function renderSettingsPage(): string {

Metadata providers

+
@@ -2108,6 +2128,17 @@ function renderSettingsPage(): string {
+ +
+
+ + +
+
+ + +
+
+ `).join('')} +
+ `; +} + function renderLinkedMetadataSummary(): string { const linkedMatch = state.selectedItemMetadata?.matches[0]; if (!linkedMatch) { @@ -1954,7 +1997,10 @@ function renderItemPage(): string { ${supportsManualLinking ? ` @@ -2460,6 +2506,9 @@ async function refreshData(showLoading = true): Promise { state.selectedPlayback = undefined; state.metadataSearchResults = []; state.metadataSearchQuery = ''; + state.metadataSearchYear = ''; + state.metadataSearchLanguage = 'en'; + state.metadataSearchProviders = []; state.isPlayerOpen = false; state.isTrailerMenuOpen = false; state.activeTrailer = undefined; @@ -2472,6 +2521,9 @@ async function refreshData(showLoading = true): Promise { state.searchResults = []; state.metadataSearchResults = []; state.metadataSearchQuery = ''; + state.metadataSearchYear = ''; + state.metadataSearchLanguage = 'en'; + state.metadataSearchProviders = []; state.isTrailerMenuOpen = false; state.activeTrailer = undefined; state.dashboardItems = []; @@ -2493,6 +2545,9 @@ async function refreshData(showLoading = true): Promise { state.selectedPlayback = undefined; state.metadataSearchResults = []; state.metadataSearchQuery = ''; + state.metadataSearchYear = ''; + state.metadataSearchLanguage = 'en'; + state.metadataSearchProviders = []; state.isPlayerOpen = false; state.isTrailerMenuOpen = false; state.activeTrailer = undefined; @@ -3097,9 +3152,21 @@ function bindEvents(): void { } const input = document.querySelector('#metadata-search-input'); + const yearInput = document.querySelector('#metadata-search-year'); + const languageInput = document.querySelector('#metadata-search-language'); state.metadataSearchQuery = input?.value.trim() ?? ''; + state.metadataSearchYear = yearInput?.value.trim() ?? ''; + state.metadataSearchLanguage = languageInput?.value.trim() ?? ''; + state.metadataSearchProviders = Array.from( + document.querySelectorAll('input[name="metadataSearchProvider"]:checked'), + ).map((provider) => provider.value); try { - state.metadataSearchResults = await searchItemMetadata(state.selectedItem.id, state.metadataSearchQuery); + state.metadataSearchResults = await searchItemMetadata(state.selectedItem.id, { + query: state.metadataSearchQuery, + providers: state.metadataSearchProviders, + year: state.metadataSearchYear, + language: state.metadataSearchLanguage, + }); render(); } catch (error) { state.error = error instanceof Error ? error.message : 'Failed to search metadata.'; diff --git a/crates/client-web/src/style.css b/crates/client-web/src/style.css index 4a7e7f41..bd871b0a 100644 --- a/crates/client-web/src/style.css +++ b/crates/client-web/src/style.css @@ -1111,6 +1111,15 @@ legend { border: 1px solid rgba(255, 255, 255, 0.06); } +.metadata-search-poster { + width: 56px; + aspect-ratio: 2 / 3; + object-fit: cover; + border-radius: 6px; + flex: 0 0 auto; + background: rgba(255, 255, 255, 0.06); +} + .metadata-search-card p { margin: 0.35rem 0 0; } @@ -1172,6 +1181,12 @@ legend { gap: 0.85rem; } +.metadata-provider-picker { + display: flex; + flex-wrap: wrap; + gap: 0.7rem; +} + .settings-activity-panel, .metadata-dashboard-panel, .settings-log-panel { diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 523ff2aa..a4dcc46f 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -73,6 +73,7 @@ serde_yaml = "0.9.34" strsim = "0.11.1" tao = "0.35.0" tokio = { version = "1.0", features = ["full"] } +tmdb_client = "1.8.0" tray-icon = "0.22.0" webbrowser = "1.0.3" # common = { path = "../common" } diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs index a271c0ac..41be7233 100644 --- a/crates/server/src/config.rs +++ b/crates/server/src/config.rs @@ -468,6 +468,11 @@ pub fn normalize_settings(settings: &mut Settings) { .as_ref() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()); + if provider.id == MetadataProviderId::Tvdb && provider.api_key.is_some() { + // Older settings snapshots can retain TVDB as disabled even after an API key + // is saved, which leaves TVDB-only libraries unusable. + provider.enabled = true; + } } if !settings diff --git a/crates/server/src/globals.rs b/crates/server/src/globals.rs index 7c435c61..1428af32 100644 --- a/crates/server/src/globals.rs +++ b/crates/server/src/globals.rs @@ -72,5 +72,14 @@ pub fn get_server_url() -> String { ) } +/// Return the current Unix timestamp in seconds. +pub fn current_timestamp() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok() + .and_then(|duration| i64::try_from(duration.as_secs()).ok()) + .unwrap_or_default() +} + /// Global AppPaths instance. pub static APP_PATHS: Lazy = Lazy::new(AppPaths::new); diff --git a/crates/server/src/media.rs b/crates/server/src/media.rs index 9abc5e6c..aab807c0 100644 --- a/crates/server/src/media.rs +++ b/crates/server/src/media.rs @@ -24,6 +24,7 @@ use crate::db::models::{ ItemMetadataLink, MediaFile, MediaItem, MediaLibrary, NewMediaFile, NewMediaItem, NewMediaLibrary, NewPlaybackProgress, NewScanState, PlaybackProgress, ScanState, }; +use crate::globals::current_timestamp; use crate::metadata::{ ArtworkKind, MetadataCollectionSummary, get_primary_item_metadata_link, list_metadata_collection_summaries, presentation_from_metadata_link, @@ -533,6 +534,25 @@ pub fn list_library_settings( .collect()) } +/// Return metadata providers configured for a persisted library id. +pub fn get_library_metadata_providers( + conn: &mut SqliteConnection, + library_id: i32, + legacy_libraries: &[MediaLibrarySettings], +) -> Result>, diesel::result::Error> { + use crate::db::schema::media_libraries::dsl as media_libraries_dsl; + + migrate_legacy_library_settings(conn, legacy_libraries)?; + + let library = media_libraries_dsl::media_libraries + .filter(media_libraries_dsl::id.eq(library_id)) + .select(MediaLibrary::as_select()) + .first::(conn) + .optional()?; + + Ok(library.map(|row| media_library_settings_from_row(row).metadata_providers)) +} + /// Replace the persisted media-library settings stored in the database. pub fn replace_library_settings( conn: &mut SqliteConnection, @@ -1563,6 +1583,7 @@ pub fn list_media_items( /// Return unmatched movie-like items that are eligible for automatic metadata linking. pub fn list_automatic_metadata_candidates( conn: &mut SqliteConnection, + library_id: Option, limit: usize, ) -> Result, diesel::result::Error> { use crate::db::schema::item_metadata_links::dsl as item_metadata_links_dsl; @@ -1608,15 +1629,14 @@ pub fn list_automatic_metadata_candidates( let Some(library) = libraries_by_id.get(&row.library_id) else { continue; }; + if library_id.is_some_and(|requested_library_id| requested_library_id != library.id) { + continue; + } let library_settings = media_library_settings_from_row(library.clone()); if library_settings.kind != MediaLibraryKind::Movies { continue; } - if !library_settings - .metadata_providers - .iter() - .any(|provider| *provider == MetadataProviderId::Tmdb) - { + if library_settings.metadata_providers.is_empty() { continue; } @@ -1644,15 +1664,14 @@ pub fn list_automatic_metadata_candidates( let Some(library) = libraries_by_id.get(&row.library_id) else { continue; }; + if library_id.is_some_and(|requested_library_id| requested_library_id != library.id) { + continue; + } let library_settings = media_library_settings_from_row(library.clone()); if library_settings.kind != MediaLibraryKind::Shows { continue; } - if !library_settings - .metadata_providers - .iter() - .any(|provider| *provider == MetadataProviderId::Tmdb) - { + if library_settings.metadata_providers.is_empty() { continue; } @@ -3547,11 +3566,3 @@ fn fallback_title_from_relative_path(relative_path: &str) -> String { .filter(|value| !value.is_empty()) .unwrap_or_else(|| relative_path.to_string()) } - -fn current_timestamp() -> i64 { - std::time::SystemTime::now() - .duration_since(UNIX_EPOCH) - .ok() - .and_then(|duration| i64::try_from(duration.as_secs()).ok()) - .unwrap_or_default() -} diff --git a/crates/server/src/metadata/mod.rs b/crates/server/src/metadata/mod.rs index 96ad1105..e9969754 100644 --- a/crates/server/src/metadata/mod.rs +++ b/crates/server/src/metadata/mod.rs @@ -14,8 +14,6 @@ use diesel::{ }; use once_cell::sync::Lazy; use regex::Regex; -use reqwest::StatusCode; -use reqwest::header::RETRY_AFTER; use schemars::JsonSchema; use serde::Serialize; use serde_json::Value; @@ -32,21 +30,13 @@ use crate::db::models::{ ItemMetadataCollection, ItemMetadataLink, MediaItem, NewItemMetadataCollection, NewItemMetadataLink, }; +use crate::globals::current_timestamp; -const TMDB_API_BASE: &str = "https://api.themoviedb.org/3"; const TMDB_IMAGE_BASE: &str = "https://image.tmdb.org/t/p"; const TVDB_API_BASE: &str = "https://api4.thetvdb.com/v4"; const THEMERR_API_BASE: &str = "https://app.lizardbyte.dev/ThemerrDB"; const DEFAULT_METADATA_REFRESH_INTERVAL_SECONDS: i64 = 30 * 24 * 60 * 60; -static HTTP_CLIENT: Lazy = Lazy::new(|| { - reqwest::Client::builder() - .user_agent(format!("Koko/{}", env!("CARGO_PKG_VERSION"))) - .build() - .expect("Failed to build shared HTTP client") -}); -static TMDB_RATE_LIMITER: Lazy> = - Lazy::new(|| tokio::sync::Mutex::new(Instant::now())); static TVDB_RATE_LIMITER: Lazy> = Lazy::new(|| tokio::sync::Mutex::new(Instant::now())); static TVDB_AUTH_TOKEN: Lazy>> = @@ -1400,129 +1390,6 @@ fn provider_settings( Ok(provider) } -async fn wait_for_tmdb_rate_limit(provider: &MetadataProviderSettings) { - let requests_per_second = provider.rate_limit_per_second.max(1); - let interval = Duration::from_secs_f64(1.0 / f64::from(requests_per_second)); - let mut next_available_at = TMDB_RATE_LIMITER.lock().await; - let now = Instant::now(); - if *next_available_at > now { - tokio::time::sleep((*next_available_at).saturating_duration_since(now)).await; - } - let base = Instant::now(); - *next_available_at = base.checked_add(interval).unwrap_or(base); -} - -async fn tmdb_get_text( - provider: &MetadataProviderSettings, - path: &str, - mut query: Vec<(&'static str, String)>, - context: &str, -) -> Result { - let api_key = provider.api_key.as_deref().unwrap_or_default().to_string(); - query.push(("api_key", api_key)); - query.push(("language", provider.language.clone())); - - let retry_attempts = usize::try_from(provider.retry_attempts).unwrap_or(0); - let base_backoff = Duration::from_millis(u64::from(provider.retry_backoff_ms.max(1))); - - for attempt in 0..=retry_attempts { - wait_for_tmdb_rate_limit(provider).await; - let request_url = format!("{}/{}", TMDB_API_BASE, path.trim_start_matches('/')); - let response = HTTP_CLIENT.get(&request_url).query(&query).send().await; - - match response { - Ok(response) => { - let status = response.status(); - let retry_after = response - .headers() - .get(RETRY_AFTER) - .and_then(|value| value.to_str().ok()) - .and_then(parse_retry_after_seconds) - .map(Duration::from_secs); - let payload = response.text().await.map_err(|error| error.to_string())?; - if status.is_success() { - return Ok(payload); - } - - let rate_limited = status == StatusCode::TOO_MANY_REQUESTS - || retry_after.is_some() - || payload.to_ascii_lowercase().contains("rate limit"); - let payload_snippet = format_payload_snippet(&payload); - let retryable = rate_limited || status.is_server_error(); - if retryable && attempt < retry_attempts { - let attempt_number = attempt + 1; - let multiplier = 1_u32 - .checked_shl(u32::try_from(attempt).unwrap_or(0)) - .unwrap_or(u32::MAX); - let backoff = - retry_after.unwrap_or_else(|| base_backoff.saturating_mul(multiplier)); - log::warn!( - "TMDB request retry scheduled for {} after status {}{}{} (attempt {}/{} in {} ms)", - context, - status, - if rate_limited { " [rate limited]" } else { "" }, - payload_snippet, - attempt_number, - retry_attempts + 1, - backoff.as_millis() - ); - tokio::time::sleep(backoff).await; - continue; - } - - return Err(format!( - "TMDB {} failed with status {}{}{}", - context, - status, - if rate_limited { " [rate limited]" } else { "" }, - payload_snippet - )); - } - Err(error) => { - if attempt < retry_attempts { - let attempt_number = attempt + 1; - let multiplier = 1_u32 - .checked_shl(u32::try_from(attempt).unwrap_or(0)) - .unwrap_or(u32::MAX); - let backoff = base_backoff.saturating_mul(multiplier); - log::warn!( - "TMDB request retry scheduled for {} after transport error: {} (attempt {}/{} in {} ms)", - context, - error, - attempt_number, - retry_attempts + 1, - backoff.as_millis() - ); - tokio::time::sleep(backoff).await; - continue; - } - - return Err(format!("TMDB {} request failed: {}", context, error)); - } - } - } - - Err(format!("TMDB {} request failed after retries", context)) -} - -async fn tmdb_get_json( - provider: &MetadataProviderSettings, - path: &str, - query: Vec<(&'static str, String)>, - context: &str, -) -> Result -where - T: serde::de::DeserializeOwned, -{ - let payload = tmdb_get_text(provider, path, query, context).await?; - serde_json::from_str::(&payload) - .map_err(|error| format!("TMDB {} returned invalid JSON: {}", context, error)) -} - -fn parse_retry_after_seconds(value: &str) -> Option { - value.trim().parse::().ok() -} - fn format_payload_snippet(payload: &str) -> String { let snippet = payload.split_whitespace().collect::>().join(" "); if snippet.is_empty() { @@ -2027,14 +1894,6 @@ struct ParsedTmdbCollection { provider_payload_json: Option, } -fn current_timestamp() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .ok() - .and_then(|duration| i64::try_from(duration.as_secs()).ok()) - .unwrap_or_default() -} - fn metadata_provider_id_from_db(value: &str) -> MetadataProviderId { MetadataProviderId::from_storage_value(value).unwrap_or_else(|| { log::warn!("Ignoring unexpected stored metadata provider id: {}", value); diff --git a/crates/server/src/metadata/providers/tmdb.rs b/crates/server/src/metadata/providers/tmdb.rs index 69b29823..ac479eb7 100644 --- a/crates/server/src/metadata/providers/tmdb.rs +++ b/crates/server/src/metadata/providers/tmdb.rs @@ -1,33 +1,15 @@ -use serde::Deserialize; use serde_json::Value; use strsim::normalized_levenshtein; +use tmdb_client::apis::client::APIClient as TmdbApiClient; +use tmdb_client::models::{EpisodeDetails, MovieDetails, SeasonDetails, TvDetails}; use crate::config::{MetadataProviderId, MetadataSettings}; use crate::metadata::{ MediaLibraryKind, MetadataProviderDescriptor, MetadataSearchResult, StoredMetadataSnapshot, - cleanup_movie_title, extract_release_year, movie_match_score, parse_movie_name, - show_search_query, tmdb_episode_external_id, tmdb_get_json, tmdb_get_text, tmdb_image_url, - tmdb_provider_settings, tmdb_season_external_id, + cleanup_movie_title, extract_release_year, movie_match_score, parse_movie_name, show_search_query, + tmdb_episode_external_id, tmdb_image_url, tmdb_provider_settings, tmdb_season_external_id, }; -#[derive(Debug, Deserialize)] -struct TmdbSearchResponse { - results: Vec, -} - -#[derive(Debug, Deserialize)] -struct TmdbSearchItem { - id: i64, - media_type: Option, - title: Option, - name: Option, - overview: Option, - poster_path: Option, - backdrop_path: Option, - release_date: Option, - first_air_date: Option, -} - pub(crate) fn descriptor() -> MetadataProviderDescriptor { MetadataProviderDescriptor { id: MetadataProviderId::Tmdb, @@ -47,37 +29,23 @@ pub(crate) async fn search( query: &str, ) -> Result, String> { let provider = tmdb_provider_settings(settings)?; - let payload = tmdb_get_json::( - &provider, - "search/multi", - vec![("query", query.to_string())], - &format!("search query {:?}", query), - ) - .await?; - Ok(payload - .results - .into_iter() - .filter_map(|item| { - let media_type = item.media_type.unwrap_or_default(); - if media_type != "movie" && media_type != "tv" { - return None; - } - - let title = item.title.or(item.name)?; - Some(MetadataSearchResult { - provider_id: MetadataProviderId::Tmdb, - external_id: item.id.to_string(), - media_type, - title, - overview: item.overview, - artwork_url: item.poster_path.map(|path| tmdb_image_url(&path, "w500")), - backdrop_url: item - .backdrop_path - .map(|path| tmdb_image_url(&path, "w1280")), - release_year: extract_release_year(item.release_date.or(item.first_air_date)), - }) - }) - .collect()) + let api_key = tmdb_api_key_from_provider(&provider)?; + let query = query.to_string(); + let language = provider.language; + run_tmdb_blocking(move || { + let client = TmdbApiClient::new_with_api_key(api_key); + let payload = client + .search_api() + .get_search_multi_paginated(&query, Some(&language), Some(1), Some(false), None) + .map_err(|error| format!("TMDB search query {:?} failed: {}", query, error))?; + Ok(payload + .results + .unwrap_or_default() + .into_iter() + .filter_map(search_result_from_value) + .collect()) + }) + .await } pub(crate) async fn fetch_snapshot( @@ -86,50 +54,61 @@ pub(crate) async fn fetch_snapshot( media_type: &str, ) -> Result { let provider = tmdb_provider_settings(settings)?; - let payload = tmdb_get_text( - &provider, - &format!("{}/{}", media_type, external_id), - vec![("append_to_response", "videos".to_string())], - &format!("details lookup for {media_type}:{external_id}"), - ) - .await?; - let parsed: Value = serde_json::from_str(&payload).map_err(|error| error.to_string())?; - let title = parsed - .get("title") - .or_else(|| parsed.get("name")) - .and_then(Value::as_str) - .map(ToOwned::to_owned); - let overview = parsed - .get("overview") - .and_then(Value::as_str) - .map(ToOwned::to_owned) - .filter(|value| !value.trim().is_empty()); - let artwork_url = parsed - .get("poster_path") - .and_then(Value::as_str) - .map(|path| tmdb_image_url(path, "w500")); - let backdrop_url = parsed - .get("backdrop_path") - .and_then(Value::as_str) - .map(|path| tmdb_image_url(path, "w1280")); - let release_year = parsed - .get("release_date") - .or_else(|| parsed.get("first_air_date")) - .and_then(Value::as_str) - .map(|value| value.to_string()) - .and_then(|value| extract_release_year(Some(value))); + let api_key = tmdb_api_key_from_provider(&provider)?; + let language = provider.language; + let external_id_number = parse_external_id(external_id, media_type)?; + let external_id_string = external_id.to_string(); + let normalized_media_type = match media_type { + "series" => "tv".to_string(), + other => other.to_string(), + }; - Ok(StoredMetadataSnapshot { - provider_id: MetadataProviderId::Tmdb, - external_id: external_id.to_string(), - media_type: Some(media_type.to_string()), - title, - overview, - artwork_url, - backdrop_url, - release_year, - provider_payload_json: Some(payload), + run_tmdb_blocking(move || match normalized_media_type.as_str() { + "movie" => { + let client = TmdbApiClient::new_with_api_key(api_key.clone()); + let details = client + .movies_api() + .get_movie_details( + external_id_number, + Some(&language), + None, + Some("videos"), + ) + .map_err(|error| { + format!( + "TMDB details lookup for movie:{} failed: {}", + external_id_string, error + ) + })?; + Ok(movie_snapshot_from_details( + &external_id_string, + &details, + )) + } + "tv" => { + let client = TmdbApiClient::new_with_api_key(api_key); + let details = client + .tv_api() + .get_tv_details( + external_id_number, + Some(&language), + None, + Some("videos"), + ) + .map_err(|error| { + format!( + "TMDB details lookup for tv:{} failed: {}", + external_id_string, error + ) + })?; + Ok(tv_snapshot_from_details( + &external_id_string, + &details, + )) + } + other => Err(format!("Unsupported TMDB media type: {}", other)), }) + .await } pub(crate) async fn guess_movie_match( @@ -209,88 +188,271 @@ pub(crate) async fn fetch_season_snapshot( season_number: i32, ) -> Result { let provider = tmdb_provider_settings(settings)?; - let payload = tmdb_get_text( - &provider, - &format!("tv/{}/season/{}", show_external_id, season_number), - Vec::new(), - &format!("season lookup for tv:{show_external_id}:season:{season_number}"), - ) - .await?; - let parsed: Value = serde_json::from_str(&payload).map_err(|error| error.to_string())?; + let api_key = tmdb_api_key_from_provider(&provider)?; + let language = provider.language; + let show_id = parse_external_id(show_external_id, "tv")?; + let show_external_id = show_external_id.to_string(); + run_tmdb_blocking(move || { + let client = TmdbApiClient::new_with_api_key(api_key); + let details = client + .tv_seasons_api() + .get_tv_season_details(show_id, season_number, Some(&language), None, None) + .map_err(|error| { + format!( + "TMDB season lookup for tv:{}:season:{} failed: {}", + show_external_id, season_number, error + ) + })?; + Ok(season_snapshot_from_details( + &show_external_id, + season_number, + &details, + )) + }) + .await +} - Ok(StoredMetadataSnapshot { +pub(crate) async fn fetch_episode_snapshot( + settings: &MetadataSettings, + show_external_id: &str, + season_number: i32, + episode_number: i32, +) -> Result { + let provider = tmdb_provider_settings(settings)?; + let api_key = tmdb_api_key_from_provider(&provider)?; + let language = provider.language; + let show_id = parse_external_id(show_external_id, "tv")?; + let show_external_id = show_external_id.to_string(); + run_tmdb_blocking(move || { + let client = TmdbApiClient::new_with_api_key(api_key); + let details = client + .tv_episodes_api() + .get_tv_season_episode_details( + show_id, + season_number, + episode_number, + Some(&language), + None, + None, + ) + .map_err(|error| { + format!( + "TMDB episode lookup for tv:{}:season:{}:episode:{} failed: {}", + show_external_id, season_number, episode_number, error + ) + })?; + Ok(episode_snapshot_from_details( + &show_external_id, + season_number, + episode_number, + &details, + )) + }) + .await +} + +fn parse_external_id( + external_id: &str, + media_type: &str, +) -> Result { + external_id.parse::().map_err(|_| { + format!( + "TMDB {} external id must be numeric, got {:?}", + media_type, external_id + ) + }) +} + +fn tmdb_api_key_from_provider( + provider: &crate::config::MetadataProviderSettings, +) -> Result { + let api_key = provider.api_key.clone().unwrap_or_default(); + let api_key = api_key.trim(); + if api_key.is_empty() { + return Err("TMDB is enabled but no API key is configured.".into()); + } + + Ok(api_key.to_string()) +} + +async fn run_tmdb_blocking(operation: F) -> Result +where + T: Send + 'static, + F: FnOnce() -> Result + Send + 'static, +{ + tokio::task::spawn_blocking(operation) + .await + .map_err(|error| format!("TMDB request task failed: {}", error))? +} + +fn search_result_from_value(item: Value) -> Option { + let media_type = item.get("media_type")?.as_str()?.to_ascii_lowercase(); + if media_type != "movie" && media_type != "tv" { + return None; + } + + let external_id = item.get("id")?.as_i64()?.to_string(); + let title = item + .get("title") + .or_else(|| item.get("name")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned)?; + let overview = item + .get("overview") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let artwork_url = item + .get("poster_path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|path| tmdb_image_url(path, "w500")); + let backdrop_url = item + .get("backdrop_path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|path| tmdb_image_url(path, "w1280")); + let release_year = item + .get("release_date") + .or_else(|| item.get("first_air_date")) + .and_then(Value::as_str) + .map(|value| value.to_string()) + .and_then(|value| extract_release_year(Some(value))); + + Some(MetadataSearchResult { + provider_id: MetadataProviderId::Tmdb, + external_id, + media_type, + title, + overview, + artwork_url, + backdrop_url, + release_year, + }) +} + +fn movie_snapshot_from_details( + external_id: &str, + details: &MovieDetails, +) -> StoredMetadataSnapshot { + StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tmdb, + external_id: external_id.to_string(), + media_type: Some("movie".into()), + title: details.title.clone(), + overview: details + .overview + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + artwork_url: details + .poster_path + .as_deref() + .map(|path| tmdb_image_url(path, "w500")), + backdrop_url: details + .backdrop_path + .as_deref() + .map(|path| tmdb_image_url(path, "w1280")), + release_year: details + .release_date + .clone() + .and_then(|value| extract_release_year(Some(value))), + provider_payload_json: serde_json::to_string(details).ok(), + } +} + +fn tv_snapshot_from_details( + external_id: &str, + details: &TvDetails, +) -> StoredMetadataSnapshot { + StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tmdb, + external_id: external_id.to_string(), + media_type: Some("tv".into()), + title: details.name.clone(), + overview: details + .overview + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + artwork_url: details + .poster_path + .as_deref() + .map(|path| tmdb_image_url(path, "w500")), + backdrop_url: details + .backdrop_path + .as_deref() + .map(|path| tmdb_image_url(path, "w1280")), + release_year: details + .first_air_date + .clone() + .and_then(|value| extract_release_year(Some(value))), + provider_payload_json: serde_json::to_string(details).ok(), + } +} + +fn season_snapshot_from_details( + show_external_id: &str, + season_number: i32, + details: &SeasonDetails, +) -> StoredMetadataSnapshot { + StoredMetadataSnapshot { provider_id: MetadataProviderId::Tmdb, external_id: tmdb_season_external_id(show_external_id, season_number), media_type: Some("tv_season".into()), - title: parsed - .get("name") - .and_then(Value::as_str) - .map(ToOwned::to_owned), - overview: parsed - .get("overview") - .and_then(Value::as_str) + title: details.name.clone(), + overview: details + .overview + .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned), - artwork_url: parsed - .get("poster_path") - .and_then(Value::as_str) + artwork_url: details + .poster_path + .as_deref() .map(|path| tmdb_image_url(path, "w500")), backdrop_url: None, - release_year: parsed - .get("air_date") - .and_then(Value::as_str) - .map(|value| value.to_string()) + release_year: details + .air_date + .clone() .and_then(|value| extract_release_year(Some(value))), - provider_payload_json: Some(payload), - }) + provider_payload_json: serde_json::to_string(details).ok(), + } } -pub(crate) async fn fetch_episode_snapshot( - settings: &MetadataSettings, +fn episode_snapshot_from_details( show_external_id: &str, season_number: i32, episode_number: i32, -) -> Result { - let provider = tmdb_provider_settings(settings)?; - let payload = tmdb_get_text( - &provider, - &format!( - "tv/{}/season/{}/episode/{}", - show_external_id, season_number, episode_number - ), - Vec::new(), - &format!( - "episode lookup for tv:{show_external_id}:season:{season_number}:episode:{episode_number}" - ), - ) - .await?; - let parsed: Value = serde_json::from_str(&payload).map_err(|error| error.to_string())?; - - Ok(StoredMetadataSnapshot { + details: &EpisodeDetails, +) -> StoredMetadataSnapshot { + StoredMetadataSnapshot { provider_id: MetadataProviderId::Tmdb, external_id: tmdb_episode_external_id(show_external_id, season_number, episode_number), media_type: Some("tv_episode".into()), - title: parsed - .get("name") - .and_then(Value::as_str) - .map(ToOwned::to_owned), - overview: parsed - .get("overview") - .and_then(Value::as_str) + title: details.name.clone(), + overview: details + .overview + .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned), - artwork_url: parsed - .get("still_path") - .and_then(Value::as_str) + artwork_url: details + .still_path + .as_deref() .map(|path| tmdb_image_url(path, "w780")), backdrop_url: None, - release_year: parsed - .get("air_date") - .and_then(Value::as_str) - .map(|value| value.to_string()) + release_year: details + .air_date + .clone() .and_then(|value| extract_release_year(Some(value))), - provider_payload_json: Some(payload), - }) + provider_payload_json: serde_json::to_string(details).ok(), + } } diff --git a/crates/server/src/metadata/providers/tvdb.rs b/crates/server/src/metadata/providers/tvdb.rs index 71fffaf9..873f4deb 100644 --- a/crates/server/src/metadata/providers/tvdb.rs +++ b/crates/server/src/metadata/providers/tvdb.rs @@ -3,16 +3,23 @@ use strsim::normalized_levenshtein; use crate::config::{MetadataProviderId, MetadataProviderSettings, MetadataSettings}; use crate::metadata::{ - HTTP_CLIENT, MediaLibraryKind, MetadataProviderDescriptor, MetadataSearchResult, - StoredMetadataSnapshot, TVDB_API_BASE, TVDB_AUTH_TOKEN, TVDB_RATE_LIMITER, TvdbCachedToken, - TvdbDescendantTarget, cleanup_movie_title, extract_release_year, format_payload_snippet, - movie_match_score, parse_movie_name, parse_retry_after_seconds, provider_settings, - show_search_query, + MediaLibraryKind, MetadataProviderDescriptor, MetadataSearchResult, StoredMetadataSnapshot, + TVDB_API_BASE, TVDB_AUTH_TOKEN, TVDB_RATE_LIMITER, TvdbCachedToken, TvdbDescendantTarget, + cleanup_movie_title, extract_release_year, format_payload_snippet, movie_match_score, + parse_movie_name, provider_settings, show_search_query, }; +use once_cell::sync::Lazy; use reqwest::StatusCode; use reqwest::header::RETRY_AFTER; use std::time::{Duration, Instant}; +static TVDB_HTTP_CLIENT: Lazy = Lazy::new(|| { + reqwest::Client::builder() + .user_agent(format!("Koko/{}", env!("CARGO_PKG_VERSION"))) + .build() + .expect("Failed to build TheTVDB HTTP client") +}); + pub(crate) fn descriptor() -> MetadataProviderDescriptor { MetadataProviderDescriptor { id: MetadataProviderId::Tvdb, @@ -57,27 +64,13 @@ pub(crate) async fn fetch_snapshot( external_id: &str, media_type: &str, ) -> Result { - let provider = provider_settings(settings, MetadataProviderId::Tvdb) - .map_err(|error| format!("TheTVDB {}", error))?; match media_type { "movie" => { - let payload = get_json( - &provider, - &format!("movies/{external_id}/extended"), - vec![("meta", "translations".to_string())], - &format!("movie details lookup for {external_id}"), - ) - .await?; + let payload = fetch_movie_payload(settings, external_id).await?; Ok(movie_snapshot_from_value(external_id, &payload)) } - "series" => { - let payload = get_json( - &provider, - &format!("series/{external_id}/extended"), - vec![("meta", "translations".to_string())], - &format!("series details lookup for {external_id}"), - ) - .await?; + "series" | "tv" => { + let payload = fetch_series_payload(settings, external_id).await?; Ok(series_snapshot_from_value(external_id, &payload)) } "season" => fetch_season_snapshot(settings, external_id, 0, external_id).await, @@ -151,9 +144,10 @@ pub(crate) async fn fetch_season_snapshot( ) -> Result { let provider = provider_settings(settings, MetadataProviderId::Tvdb) .map_err(|error| format!("TheTVDB {}", error))?; + let season_id = parse_tvdb_external_id(season_external_id, "season")?; let payload = get_json( &provider, - &format!("seasons/{season_external_id}/extended"), + &format!("seasons/{season_id}/extended"), vec![("meta", "translations".to_string())], &format!("season lookup for series:{show_external_id}:season:{season_external_id}"), ) @@ -175,9 +169,10 @@ pub(crate) async fn fetch_episode_snapshot( ) -> Result { let provider = provider_settings(settings, MetadataProviderId::Tvdb) .map_err(|error| format!("TheTVDB {}", error))?; + let episode_id = parse_tvdb_external_id(episode_external_id, "episode")?; let payload = get_json( &provider, - &format!("episodes/{episode_external_id}/extended"), + &format!("episodes/{episode_id}/extended"), vec![("meta", "translations".to_string())], &format!( "episode lookup for series:{show_external_id}:season:{season_number}:episode:{episode_external_id}" @@ -199,13 +194,15 @@ pub(crate) async fn load_show_descendant_targets( ) -> Result, String> { let provider = provider_settings(settings, MetadataProviderId::Tvdb) .map_err(|error| format!("TheTVDB {}", error))?; + let show_id = parse_tvdb_external_id(show_external_id, "series")?; let series_payload = get_json( &provider, - &format!("series/{show_external_id}/extended"), + &format!("series/{show_id}/extended"), vec![("meta", "translations".to_string())], &format!("series descendant lookup for {show_external_id}"), ) .await?; + let mut targets = Vec::new(); for season in series_payload .get("data") @@ -220,6 +217,7 @@ pub(crate) async fn load_show_descendant_targets( let Some(season_number) = season_number(&season) else { continue; }; + let season_payload = get_json( &provider, &format!("seasons/{season_id}/extended"), @@ -252,6 +250,18 @@ pub(crate) async fn load_show_descendant_targets( Ok(targets) } +fn parse_tvdb_external_id( + external_id: &str, + media_type: &str, +) -> Result { + external_id.parse::().map_err(|_| { + format!( + "TheTVDB {} external id must be numeric, got {:?}", + media_type, external_id + ) + }) +} + async fn wait_for_rate_limit(provider: &MetadataProviderSettings) { let requests_per_second = provider.rate_limit_per_second.max(1); let interval = Duration::from_secs_f64(1.0 / f64::from(requests_per_second)); @@ -284,7 +294,7 @@ async fn auth_token(provider: &MetadataProviderSettings) -> Result { @@ -435,14 +444,50 @@ async fn get_json( .map_err(|error| format!("TheTVDB {} returned invalid JSON: {}", context, error)) } +fn parse_retry_after_seconds(value: &str) -> Option { + value.trim().parse::().ok() +} + +async fn fetch_movie_payload( + settings: &MetadataSettings, + external_id: &str, +) -> Result { + let provider = provider_settings(settings, MetadataProviderId::Tvdb) + .map_err(|error| format!("TheTVDB {}", error))?; + let movie_id = parse_tvdb_external_id(external_id, "movie")?; + get_json( + &provider, + &format!("movies/{movie_id}/extended"), + vec![("meta", "translations".to_string())], + &format!("movie details lookup for {external_id}"), + ) + .await +} + +async fn fetch_series_payload( + settings: &MetadataSettings, + external_id: &str, +) -> Result { + let provider = provider_settings(settings, MetadataProviderId::Tvdb) + .map_err(|error| format!("TheTVDB {}", error))?; + let series_id = parse_tvdb_external_id(external_id, "series")?; + get_json( + &provider, + &format!("series/{series_id}/extended"), + vec![("meta", "translations".to_string())], + &format!("series details lookup for {external_id}"), + ) + .await +} + fn search_result_from_value(item: Value) -> Option { let item_type = item .get("type") .and_then(Value::as_str) .map(|value| value.to_ascii_lowercase())?; let media_type = match item_type.as_str() { - "series" | "tv series" => "series", - "movie" => "movie", + "series" | "tv series" | "tv" | "show" => "series", + "movie" | "film" | "feature film" => "movie", _ => return None, }; @@ -544,68 +589,74 @@ fn object_id(value: &Value) -> Option { .get("id") .and_then(Value::as_i64) .and_then(|id| i32::try_from(id).ok()) + .or_else(|| { + value + .get("id") + .and_then(Value::as_str) + .and_then(|id| id.parse::().ok()) + }) + .or_else(|| { + value + .get("tvdb_id") + .and_then(Value::as_str) + .and_then(|id| id.parse::().ok()) + }) + .or_else(|| { + value + .get("objectID") + .and_then(Value::as_str) + .and_then(|id| id.parse::().ok()) + }) } fn best_title(value: &Value) -> Option { value .get("name") .or_else(|| value.get("title")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|title| !title.is_empty()) - .map(ToOwned::to_owned) + .or_else(|| value.get("name_translated")) .or_else(|| { value .get("translations") .and_then(Value::as_object) .and_then(|translations| { - translations.values().find_map(|entries| { - entries.as_array().and_then(|entries| { - entries.iter().find_map(|entry| { - entry - .get("name") - .or_else(|| entry.get("title")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|title| !title.is_empty()) - .map(ToOwned::to_owned) - }) - }) - }) + ["eng", "en", "english"] + .iter() + .find_map(|key| translations.get(*key)) + .or_else(|| translations.values().next()) }) }) + .and_then(Value::as_str) + .map(str::trim) + .filter(|title| !title.is_empty()) + .map(ToOwned::to_owned) } fn best_overview(value: &Value) -> Option { value .get("overview") - .and_then(Value::as_str) - .map(str::trim) - .filter(|overview| !overview.is_empty()) - .map(ToOwned::to_owned) .or_else(|| { value - .get("translations") - .and_then(|translations| translations.get("overviewTranslations")) - .and_then(Value::as_array) - .and_then(|entries| { - entries.iter().find_map(|entry| { - entry - .get("overview") - .and_then(Value::as_str) - .map(str::trim) - .filter(|overview| !overview.is_empty()) - .map(ToOwned::to_owned) - }) + .get("overviews") + .and_then(Value::as_object) + .and_then(|overviews| { + ["eng", "en", "english"] + .iter() + .find_map(|key| overviews.get(*key)) + .or_else(|| overviews.values().next()) }) }) + .and_then(Value::as_str) + .map(str::trim) + .filter(|overview| !overview.is_empty()) + .map(ToOwned::to_owned) } fn artwork_url(value: &Value) -> Option { value .get("image") .or_else(|| value.get("image_url")) - .or_else(|| value.get("artwork")) + .or_else(|| value.get("poster")) + .or_else(|| value.get("thumbnail")) .and_then(Value::as_str) .map(str::trim) .filter(|url| !url.is_empty()) @@ -618,22 +669,12 @@ fn backdrop_url(value: &Value) -> Option { .and_then(Value::as_array) .and_then(|artworks| { artworks.iter().find_map(|artwork| { - let kind = artwork - .get("type") - .or_else(|| artwork.get("typeName")) + artwork + .get("image") .and_then(Value::as_str) - .unwrap_or_default() - .to_ascii_lowercase(); - if kind.contains("background") || kind.contains("fanart") || kind.contains("banner") - { - artwork - .get("image") - .or_else(|| artwork.get("image_url")) - .and_then(Value::as_str) - .map(ToOwned::to_owned) - } else { - None - } + .map(str::trim) + .filter(|url| !url.is_empty()) + .map(ToOwned::to_owned) }) }) } @@ -653,10 +694,17 @@ fn release_year(value: &Value) -> Option { .get("year") .and_then(Value::as_i64) .and_then(|year| i32::try_from(year).ok()) + .or_else(|| { + value + .get("year") + .and_then(Value::as_str) + .and_then(|year| year.parse::().ok()) + }) .or_else(|| { value .get("firstAired") .or_else(|| value.get("releaseDate")) + .or_else(|| value.get("first_release")) .and_then(Value::as_str) .map(|value| value.to_string()) .and_then(|value| extract_release_year(Some(value))) @@ -678,3 +726,49 @@ fn episode_number(value: &Value) -> Option { .and_then(Value::as_i64) .and_then(|number| i32::try_from(number).ok()) } + +#[cfg(test)] +mod tests { + use super::search_result_from_value; + use serde_json::json; + + #[test] + fn tvdb_search_result_accepts_object_id_and_translations() { + let result = search_result_from_value(json!({ + "type": "movie", + "objectID": "901", + "translations": { + "eng": "Top Gun: Maverick" + }, + "overviews": { + "eng": "After more than thirty years of service..." + }, + "year": "2022", + "image_url": "https://example.test/poster.jpg" + })) + .expect("expected TVDB search result to parse"); + + assert_eq!(result.external_id, "901"); + assert_eq!(result.media_type, "movie"); + assert_eq!(result.title, "Top Gun: Maverick"); + assert_eq!(result.release_year, Some(2022)); + assert_eq!( + result.overview.as_deref(), + Some("After more than thirty years of service...") + ); + } + + #[test] + fn tvdb_search_result_accepts_show_alias_type() { + let result = search_result_from_value(json!({ + "type": "show", + "tvdb_id": "42", + "name": "Example Show" + })) + .expect("expected TVDB show search result to parse"); + + assert_eq!(result.external_id, "42"); + assert_eq!(result.media_type, "series"); + assert_eq!(result.title, "Example Show"); + } +} diff --git a/crates/server/src/web/routes/media.rs b/crates/server/src/web/routes/media.rs index f53a1292..d9fb7f90 100644 --- a/crates/server/src/web/routes/media.rs +++ b/crates/server/src/web/routes/media.rs @@ -21,11 +21,13 @@ use crate::config::{MetadataProviderId, Settings, current_settings}; use crate::db::DbConn; use crate::db::models::ItemMetadataLink; use crate::globals; +use crate::globals::current_timestamp; use crate::media::{ MediaHome, MediaItemDetail, MediaItemSummary, PersistedLibrarySummary, PersistedMediaFileSummary, PlaybackDecision, TranscodingCapability, get_item_theme_song_themerr_references, get_library_files, get_media_home, get_media_item, - get_media_item_summary, get_persisted_library_summaries, get_playback_decision, + get_library_metadata_providers, get_media_item_summary, get_persisted_library_summaries, + get_playback_decision, get_preferred_item_metadata_link, infer_episode_number, inspect_transcoding_capability, library_exists, list_automatic_metadata_candidates, list_library_settings, list_media_item_children, list_media_items, mark_metadata_match_attempted, @@ -170,14 +172,6 @@ fn managed_item_asset_dir( .join(item_hex) } -fn current_timestamp() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .ok() - .and_then(|duration| i64::try_from(duration.as_secs()).ok()) - .unwrap_or_default() -} - fn next_system_activity_id() -> String { format!( "activity-{}", @@ -288,6 +282,14 @@ fn provider_search_media_type( } } +fn parse_metadata_provider_selection(value: Option) -> Vec { + value + .unwrap_or_default() + .split(',') + .filter_map(|provider| MetadataProviderId::from_storage_value(provider.trim())) + .collect() +} + #[derive(Debug, Clone)] enum MetadataRefreshFetchKind { Direct, @@ -1264,22 +1266,19 @@ fn descendant_metadata_needs_backfill(link: Option) -> bool { } } -#[allow(dead_code)] async fn run_automatic_movie_metadata_linking( db: &DbConn, settings: &crate::config::Settings, + library_id: Option, ) { - let tmdb_ready = list_provider_statuses(&settings.metadata) + let ready_providers = list_provider_statuses(&settings.metadata) .into_iter() - .any(|provider| { - provider.id == MetadataProviderId::Tmdb && provider.enabled && provider.configured - }); - if !tmdb_ready { - return; - } + .filter(|provider| provider.enabled && provider.configured && provider.implemented) + .map(|provider| provider.id) + .collect::>(); let candidates = match db - .run(|conn| list_automatic_metadata_candidates(conn, 8)) + .run(move |conn| list_automatic_metadata_candidates(conn, library_id, 8)) .await { Ok(candidates) => candidates, @@ -1290,49 +1289,63 @@ async fn run_automatic_movie_metadata_linking( }; for candidate in candidates { - let guess_result = match candidate.library_kind { - crate::config::MediaLibraryKind::Shows => { - guess_provider_show_match( - &settings.metadata, - MetadataProviderId::Tmdb, - &candidate.relative_path, - &candidate.display_title, - ) - .await - } - _ => { - guess_provider_movie_match( - &settings.metadata, - MetadataProviderId::Tmdb, - &candidate.relative_path, - &candidate.display_title, - ) - .await - } - }; - let guess = match guess_result { - Ok(result) => result, - Err(error) => { - log::warn!( - "Automatic TMDB match failed for item {} ({}): {}", - candidate.item_id, - candidate.relative_path, - error - ); - continue; + let mut guessed_provider_id = None; + let mut guess = None; + for provider_id in candidate + .metadata_providers + .iter() + .filter(|provider_id| ready_providers.contains(provider_id)) + { + let guess_result = match candidate.library_kind { + crate::config::MediaLibraryKind::Shows => { + guess_provider_show_match( + &settings.metadata, + provider_id.clone(), + &candidate.relative_path, + &candidate.display_title, + ) + .await + } + _ => { + guess_provider_movie_match( + &settings.metadata, + provider_id.clone(), + &candidate.relative_path, + &candidate.display_title, + ) + .await + } + }; + match guess_result { + Ok(Some(result)) => { + guessed_provider_id = Some(provider_id.clone()); + guess = Some(result); + break; + } + Ok(None) => {} + Err(error) => { + log::warn!( + "Automatic {} match failed for item {} ({}): {}", + provider_id.as_storage_value(), + candidate.item_id, + candidate.relative_path, + error + ); + } } - }; + } - if let Some(result) = guess { + if let (Some(provider_id), Some(result)) = (guessed_provider_id, guess) { if let Err(error) = db .run({ let external_id = result.external_id.clone(); let media_type = result.media_type.clone(); + let provider_id = provider_id.clone(); move |conn| { set_item_metadata_refresh_state( conn, candidate.item_id, - MetadataProviderId::Tmdb, + provider_id, &external_id, Some(&media_type), "pending", @@ -1350,7 +1363,7 @@ async fn run_automatic_movie_metadata_linking( } match fetch_provider_metadata_snapshot( &settings.metadata, - MetadataProviderId::Tmdb, + provider_id.clone(), &result.external_id, &result.media_type, ) @@ -1375,7 +1388,7 @@ async fn run_automatic_movie_metadata_linking( set_item_metadata_refresh_state( conn, candidate.item_id, - MetadataProviderId::Tmdb, + snapshot.provider_id, &external_id, media_type.as_deref(), "error", @@ -1405,11 +1418,12 @@ async fn run_automatic_movie_metadata_linking( let external_id = result.external_id.clone(); let media_type = result.media_type.clone(); let error_message = error.clone(); + let provider_id = provider_id.clone(); move |conn| { set_item_metadata_refresh_state( conn, candidate.item_id, - MetadataProviderId::Tmdb, + provider_id, &external_id, Some(&media_type), "error", @@ -1488,8 +1502,10 @@ async fn run_automatic_movie_metadata_linking( } } - recover_pending_metadata_refreshes(db, settings).await; - run_due_metadata_refreshes(db, settings).await; + if library_id.is_none() { + recover_pending_metadata_refreshes(db, settings).await; + run_due_metadata_refreshes(db, settings).await; + } } fn current_user_id(user_guard: Option<&UserGuard>) -> Result, Status> { @@ -1510,30 +1526,12 @@ async fn load_item_library_metadata_providers( library_id: i32, ) -> Result, Status> { let legacy_libraries = settings.media.libraries.clone(); - let persisted = db + let providers = db .run({ let legacy_libraries = legacy_libraries.clone(); - move |conn| get_persisted_library_summaries(conn, &legacy_libraries) + move |conn| get_library_metadata_providers(conn, library_id, &legacy_libraries) }) .await - .map_err(|error| { - log::error!( - "Failed to load library summaries for library {}: {}", - library_id, - error - ); - Status::InternalServerError - })?; - let Some(summary) = persisted - .into_iter() - .find(|library| library.id == library_id) - else { - return Err(Status::NotFound); - }; - - let libraries = db - .run(move |conn| list_library_settings(conn, &legacy_libraries)) - .await .map_err(|error| { log::error!( "Failed to load library metadata providers for library {}: {}", @@ -1543,15 +1541,7 @@ async fn load_item_library_metadata_providers( Status::InternalServerError })?; - libraries - .into_iter() - .find(|library| { - library.path == summary.path - && library.name == summary.name - && library.kind == summary.kind - }) - .map(|library| library.metadata_providers) - .ok_or(Status::NotFound) + providers.ok_or(Status::NotFound) } async fn load_library_refresh_jobs( @@ -1713,23 +1703,36 @@ pub async fn scan_library( let legacy_libraries = settings.media.libraries.clone(); let ffmpeg_settings = settings.ffmpeg.clone(); - let libraries = db - .run(move |conn| sync_persisted_library_catalog(conn, &legacy_libraries, &ffmpeg_settings)) + let exists = db + .run(move |conn| library_exists(conn, library_id)) .await .map_err(|error| { log::error!( - "Failed to run manual library scan for library {}: {}", + "Failed to inspect library {} before manual scan: {}", library_id, error ); Status::InternalServerError })?; + if !exists { + return Err(Status::NotFound); + } - libraries - .into_iter() - .find(|library| library.id == library_id) - .map(Json) - .ok_or(Status::NotFound) + let summary = load_library_summary(&db, &settings, library_id).await?; + tokio::spawn(async move { + if let Err(error) = db + .run(move |conn| sync_persisted_library_catalog(conn, &legacy_libraries, &ffmpeg_settings)) + .await + { + log::error!( + "Failed to run manual library scan for library {}: {}", + library_id, + error + ); + } + }); + + Ok(Json(summary)) } /// Return active backend activities such as metadata refresh work. @@ -2055,11 +2058,14 @@ pub async fn get_item_metadata( /// Search a configured provider for metadata candidates for a media item. #[openapi(tag = "Media")] -#[get("/api/v1/items//metadata/search?")] +#[get("/api/v1/items//metadata/search?&&&")] pub async fn search_item_metadata( db: DbConn, item_id: i32, query: Option, + providers: Option, + year: Option, + language: Option, ) -> Result>, Status> { let settings = current_settings(); let metadata_settings = settings.metadata.clone(); @@ -2079,12 +2085,24 @@ pub async fn search_item_metadata( return Err(Status::BadRequest); } - let providers = load_item_library_metadata_providers(&db, &settings, item.library_id).await?; + let library_providers = load_item_library_metadata_providers(&db, &settings, item.library_id).await?; + let requested_providers = parse_metadata_provider_selection(providers); + let providers = if requested_providers.is_empty() { + library_providers + } else { + requested_providers + }; let fallback_query = item.display_title.clone(); - let effective_query = query + let mut effective_query = query .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .unwrap_or(fallback_query); + if let Some(year) = year { + effective_query = format!("{effective_query} {year}"); + } + let _requested_language = language + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); let provider_statuses = list_provider_statuses(&metadata_settings) .into_iter() .map(|status| (status.id.clone(), status)) @@ -2101,10 +2119,10 @@ pub async fn search_item_metadata( let Some(status) = provider_statuses.get(&provider_id) else { continue; }; + saw_provider = true; if !status.enabled || !status.configured || !status.implemented { continue; } - saw_provider = true; match search_provider(&metadata_settings, provider_id.clone(), &effective_query).await { Ok(provider_results) => { @@ -2167,9 +2185,14 @@ pub async fn link_item_metadata( } let library_providers = load_item_library_metadata_providers(&db, &settings, item.library_id).await?; - if !library_providers.contains(&request.provider_id) { + let provider_status = list_provider_statuses(&settings.metadata) + .into_iter() + .find(|status| status.id == request.provider_id) + .ok_or(Status::BadRequest)?; + if !provider_status.enabled || !provider_status.configured || !provider_status.implemented { return Err(Status::BadRequest); } + let _library_default_provider = library_providers.contains(&request.provider_id); if Some(request.media_type.as_str()) != provider_search_media_type(request.provider_id.clone(), &item) { @@ -2328,9 +2351,11 @@ pub async fn refresh_library_metadata( ) .await else { - return Ok(Json( - load_library_summary(&db, &settings, library_id).await?, - )); + let summary = load_library_summary(&db, &settings, library_id).await?; + tokio::spawn(async move { + run_automatic_movie_metadata_linking(&db, &settings, Some(library_id)).await; + }); + return Ok(Json(summary)); }; if let Err(status) = mark_metadata_refresh_targets_pending(&db, &queued_targets).await { @@ -2346,6 +2371,7 @@ pub async fn refresh_library_metadata( record_metadata_refresh_activity_progress(&activity_id, failed).await; } complete_metadata_refresh_activity(&activity_id).await; + run_automatic_movie_metadata_linking(&db, &settings, Some(library_id)).await; }); Ok(Json(pending_summary)) diff --git a/crates/server/tests/test_media.rs b/crates/server/tests/test_media.rs index 0eb26e96..86da497c 100644 --- a/crates/server/tests/test_media.rs +++ b/crates/server/tests/test_media.rs @@ -433,7 +433,7 @@ fn test_shows_are_included_in_automatic_metadata_candidates() { .find(|item| item.item_type == "show") .expect("Expected show item to exist"); - let candidates = list_automatic_metadata_candidates(&mut connection, 8).unwrap(); + let candidates = list_automatic_metadata_candidates(&mut connection, None, 8).unwrap(); assert!(candidates.iter().any(|candidate| { candidate.item_id == show.id && candidate.display_title == show.display_title diff --git a/docs/MOVIE_NAMING.md b/docs/MOVIE_NAMING.md index f3c26c7b..a144102b 100644 --- a/docs/MOVIE_NAMING.md +++ b/docs/MOVIE_NAMING.md @@ -1,18 +1,19 @@ # Movie naming guidelines -Koko matches movie files more reliably when each media type lives under its own top-level folder and movie files follow a predictable naming pattern. +Koko matches movie files more reliably when each media type lives under its own top-level folder and movie files follow +a predictable naming pattern. ## Recommended folder layout Keep movies separate from shows, music, books, and photos. ```text -Media/ - Movies/ - TV Shows/ - Music/ - Books/ - Photos/ +/Media + /Books + /Movies + /Music + /Photos + /TV Shows ``` When you create a movie library in Koko, point it at the movie root such as `Media/Movies`. @@ -22,8 +23,8 @@ When you create a movie library in Koko, point it at the movie root such as `Med The most reliable option is one folder per movie: ```text -Movies/ - Movie Title (2024)/ +/Movies + /Movie Title (2024) Movie Title (2024).mkv ``` @@ -32,10 +33,10 @@ This layout works well when you also keep artwork, subtitles, or alternate editi Examples: ```text -Movies/ - Avatar (2009)/ +/Movies + /Avatar (2009) Avatar (2009).mkv - Batman Begins (2005)/ + /Batman Begins (2005) Batman Begins (2005).mp4 Batman Begins (2005).en.srt poster.jpg @@ -46,7 +47,7 @@ Movies/ Koko also supports movies stored directly inside the library root: ```text -Movies/ +/Movies Avatar (2009).mkv Batman Begins (2005).mp4 ``` @@ -79,10 +80,10 @@ Koko strips these tags before title matching and uses them as hints where possib If you keep more than one edition of the same movie, include the edition tag in the folder name, filename, or both. ```text -Movies/ - Blade Runner (1982) {edition-Director's Cut}/ +/Movies + /Blade Runner (1982) {edition-Director's Cut} Blade Runner (1982) {edition-Director's Cut}.mp4 - Blade Runner (1982) {edition-Final Cut}/ + /Blade Runner (1982) {edition-Final Cut} Blade Runner (1982) {edition-Final Cut}.mkv ``` @@ -102,8 +103,8 @@ Koko recognizes common part suffixes such as: Example: ```text -Movies/ - The Dark Knight (2008)/ +/Movies + /The Dark Knight (2008) The Dark Knight (2008) - pt1.mp4 The Dark Knight (2008) - pt2.mp4 ``` @@ -119,16 +120,15 @@ Movies/ ## Examples Koko matches well ```text -Movies/ - Alien (1979)/ +/Movies + /Alien (1979) Alien (1979).mkv -Movies/ - Dune Part Two (2024) {tmdb-693134}/ +/Movies + /Dune Part Two (2024) {tmdb-693134} Dune Part Two (2024) {tmdb-693134}.mkv -Movies/ - Mad Max Fury Road (2015) {edition-Black and Chrome}/ +/Movies + /Mad Max Fury Road (2015) {edition-Black and Chrome} Mad Max Fury Road (2015) {edition-Black and Chrome}.mkv ``` - From 9320c1e718899075e43c05a8321a8f61b2e97ee5 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 24 Apr 2026 17:25:40 -0400 Subject: [PATCH 005/128] Support localized metadata, provider attribution Add localization and attribution support for metadata across client and server. Database migrations introduce preferred_metadata_languages on users and locale_key/provider_locale_key on item_metadata_links (plus a migration to fix the locale uniqueness). Server models, schema and metadata logic were extended to normalize locale keys, map provider-specific locale keys, fetch snapshots per locale, store locale info on metadata links, and prefer metadata based on user language order. Presentation now extracts provider logo, rating and content rating; metadata assets are persisted into deterministic provider/external-id bundle paths (SHA1-based). Client API/types, mock API and UI were updated to surface preferred metadata languages, show provider attribution/logos and match scores, and allow managing language preferences. Also added utils (current_timestamp), movie title parsing/formatting improvements, and new dependencies (sha1, tvdb4). --- crates/client-web/src/api.ts | 13 + crates/client-web/src/main.ts | 26 +- crates/client-web/src/mockApi.ts | 18 + crates/client-web/src/style.css | 22 + crates/server/Cargo.toml | 2 + .../0000014_metadata_locales/down.sql | 65 ++ .../0000014_metadata_locales/up.sql | 52 ++ .../down.sql | 53 ++ .../0000015_fix_metadata_locale_unique/up.sql | 52 ++ crates/server/src/config.rs | 11 +- crates/server/src/db/models.rs | 7 +- crates/server/src/db/schema.rs | 3 + crates/server/src/globals.rs | 9 - crates/server/src/lib.rs | 1 + crates/server/src/media.rs | 92 ++- crates/server/src/metadata/mod.rs | 604 ++++++++++++++++-- crates/server/src/metadata/providers/tmdb.rs | 116 +++- crates/server/src/metadata/providers/tvdb.rs | 222 +++++-- crates/server/src/utils.rs | 10 + crates/server/src/web/mod.rs | 4 +- crates/server/src/web/routes/media.rs | 483 ++++++++++---- crates/server/src/web/routes/mod.rs | 1 + crates/server/src/web/routes/user.rs | 54 ++ crates/server/tests/test_media.rs | 157 ++++- crates/server/tests/test_metadata.rs | 38 +- docs/MOVIE_NAMING.md | 34 +- docs/SHOW_NAMING.md | 58 ++ 27 files changed, 1903 insertions(+), 304 deletions(-) create mode 100644 crates/server/sql/migrations/0000014_metadata_locales/down.sql create mode 100644 crates/server/sql/migrations/0000014_metadata_locales/up.sql create mode 100644 crates/server/sql/migrations/0000015_fix_metadata_locale_unique/down.sql create mode 100644 crates/server/sql/migrations/0000015_fix_metadata_locale_unique/up.sql create mode 100644 crates/server/src/utils.rs create mode 100644 docs/SHOW_NAMING.md diff --git a/crates/client-web/src/api.ts b/crates/client-web/src/api.ts index b31f0100..dbe55064 100644 --- a/crates/client-web/src/api.ts +++ b/crates/client-web/src/api.ts @@ -55,6 +55,7 @@ export interface BootstrapUser { admin: boolean; birthday?: string; profile_image_url?: string; + preferred_metadata_languages: string[]; } export interface AppBootstrapResponse { @@ -78,6 +79,7 @@ export interface CreateUserRequest { admin: boolean; birthday?: string; profile_image_url?: string; + preferred_metadata_languages?: string[]; } export interface UpdateUserRequest { @@ -85,6 +87,7 @@ export interface UpdateUserRequest { admin: boolean; birthday?: string; profile_image_url?: string; + preferred_metadata_languages?: string[]; } export interface MediaLibrary { @@ -149,6 +152,9 @@ export interface MediaItemDetail extends MediaItemSummary { overview?: string; genres: string[]; release_year?: number; + logo_url?: string; + rating?: number; + content_rating?: string; linked_media_type?: string; trailer_title?: string; trailer_url?: string; @@ -175,6 +181,10 @@ export interface MetadataProviderStatus { enabled: boolean; configured: boolean; language: string; + attribution_text: string; + attribution_url: string; + logo_light_url?: string; + logo_dark_url?: string; } export interface ItemMetadataMatch { @@ -189,6 +199,8 @@ export interface ItemMetadataMatch { media_type?: string; match_state: string; provider_payload_json?: string; + locale_key: string; + provider_locale_key?: string; cached_artwork_path?: string; cached_backdrop_path?: string; refresh_state?: string; @@ -213,6 +225,7 @@ export interface MetadataSearchResult { artwork_url?: string; backdrop_url?: string; release_year?: number; + score?: number; } export interface MediaShelf { diff --git a/crates/client-web/src/main.ts b/crates/client-web/src/main.ts index fbcb0afc..d90d2085 100644 --- a/crates/client-web/src/main.ts +++ b/crates/client-web/src/main.ts @@ -981,6 +981,7 @@ function renderUserManagement(): string { +
@@ -1003,6 +1004,7 @@ function renderUserManagement(): string { + @@ -1537,6 +1539,7 @@ function renderMetadataSearchResults(): string { ${escapeHtml(metadataProviderLabels[result.provider_id] ?? result.provider_id)} ${result.release_year ?? 'Unknown year'} ${escapeHtml(result.media_type)} + ${typeof result.score === 'number' ? `${Math.round(result.score * 100)}% match` : ''}
+ +
${items.map(renderItemCard).join('')}
+ + `; +} + function metadataBadgeMarkup(item: MediaItemSummary): string { if (item.metadata_refresh_state === 'pending') { return ''; @@ -1204,7 +1301,7 @@ function renderItemCard(item: MediaItemSummary): string { const badgeMarkup = metadataBadgeMarkup(item); return ` - + + `; +} + +function renderSearchResults(): string { + if (!state.searchResults.length) { + return '
No media items matched the current search.
'; } + return ` +
+
+

Search results

+ ${state.searchResults.length} matches +
+
+ ${state.searchResults.map((item) => { + const posterUrl = getArtworkUrl(item.id, 'poster', item.artwork_updated_at); + const library = state.libraries.find((entry) => entry.id === item.library_id); + return ` + + `; + }).join('')} +
+
+ `; +} + +function renderShelfStack(): string { const shelves = state.home?.shelves ?? []; if (!shelves.length) { return '
No shelves are available yet. Add a library to get started.
'; @@ -1249,7 +1400,11 @@ function renderShelfStack(): string { ${shelf.items.length} items ${shelf.items.length - ? `
${shelf.items.map(renderItemCard).join('')}
` + ? `
+ +
${shelf.items.map(renderItemCard).join('')}
+ +
` : '
Nothing here yet.
'} `) @@ -1266,7 +1421,7 @@ function renderHomeTabs(): string { ]; return ` -
- ${currentUser() ? ` -
- ${renderUserAvatar(currentUser()!, 'rail-avatar')} - - ${escapeHtml(currentUser()!.username)} - ${currentUser()!.admin ? 'Administrator' : 'Signed in'} - -
- ` : ''} + ${userCardMarkup}
- `).join('') - : '
No users found.
'} + `; + }).join(''); + } + + return ` +
+
+

Users

+
+
+ ${userListMarkup}
diff --git a/crates/client-web/src/app/dashboardView.ts b/crates/client-web/src/app/dashboardView.ts index 647bc1f7..d401bd71 100644 --- a/crates/client-web/src/app/dashboardView.ts +++ b/crates/client-web/src/app/dashboardView.ts @@ -111,6 +111,53 @@ export function renderMetadataDashboard(): string { const filteredItems = filteredMetadataDashboardItems(); const summary = metadataDashboardSummary(state.dashboardItems); const itemTypes = [...new Set(state.dashboardItems.map((item) => item.item_type))].sort((left, right) => left.localeCompare(right)); + let dashboardContent = '
No items matched the current dashboard filters.
'; + if (filteredItems.length) { + dashboardContent = ``; + } return ` `; } export function renderSystemActivitiesPanel(): string { const activities = state.systemActivities.filter((activity) => activity.state !== 'completed' && activity.state !== 'failed'); - return ` -
-
-
-

Backend activities

-

Active background work that the browser is polling.

-
- ${activities.length} active -
- ${activities.length - ? `
${activities.map((activity) => { + let activitiesContent = '
No background activities are running right now.
'; + if (activities.length) { + activitiesContent = `
${activities.map((activity) => { const progress = activityProgress(activity); return `
@@ -238,14 +240,58 @@ export function renderSystemActivitiesPanel(): string {
`; - }).join('')}
` - : '
No background activities are running right now.
'} + }).join('')}`; + } + + return ` +
+
+
+

Backend activities

+

Active background work that the browser is polling.

+
+ ${activities.length} active +
+ ${activitiesContent}
`; } export function renderLogViewer(): string { const logEntries = state.logsResponse?.entries ?? []; + let logEntriesContent = '
No log entries matched the current filters.
'; + if (logEntries.length) { + logEntriesContent = `
+ + + + + + + + + + + ${logEntries.map((entry) => { + let levelTagClass = ''; + if (entry.level === 'ERROR') { + levelTagClass = 'danger-tag'; + } else if (entry.level === 'WARN') { + levelTagClass = 'warning'; + } + return ` + + + + + + + + `; + }).join('')} +
TimeLevelModuleSourceMessage
${escapeHtml(entry.timestamp)}${escapeHtml(entry.level)}${escapeHtml(entry.module)}${escapeHtml(entry.source_file_path)}${typeof entry.line_number === 'number' ? `:${entry.line_number}` : ''}
${escapeHtml(entry.message)}
+
`; + } return `
@@ -280,30 +326,7 @@ export function renderLogViewer(): string { - ${logEntries.length - ? `
- - - - - - - - - - - ${logEntries.map((entry) => ` - - - - - - - - `).join('')} -
TimeLevelModuleSourceMessage
${escapeHtml(entry.timestamp)}${escapeHtml(entry.level)}${escapeHtml(entry.module)}${escapeHtml(entry.source_file_path)}${typeof entry.line_number === 'number' ? `:${entry.line_number}` : ''}
${escapeHtml(entry.message)}
-
` - : '
No log entries matched the current filters.
'} + ${logEntriesContent}
`; } diff --git a/crates/client-web/src/app/homeView.ts b/crates/client-web/src/app/homeView.ts index 9d6ec464..061cceb1 100644 --- a/crates/client-web/src/app/homeView.ts +++ b/crates/client-web/src/app/homeView.ts @@ -39,13 +39,28 @@ import { } from './ui'; export function browseDetailPath(kind: BrowseFilter['kind'], key: string): string { - const segment = kind === 'collection' ? 'collections' : kind === 'playlist' ? 'playlists' : 'categories'; + let segment = 'categories'; + if (kind === 'collection') { + segment = 'collections'; + } else if (kind === 'playlist') { + segment = 'playlists'; + } const encodedKey = encodeURIComponent(key); return typeof activeLibraryId() === 'number' ? `/libraries/${activeLibraryId()}/items/${segment}/${encodedKey}` : `/items/${segment}/${encodedKey}`; } +function browseFilterKindLabel(kind: BrowseFilter['kind']): string { + if (kind === 'collection') { + return 'Collection'; + } + if (kind === 'playlist') { + return 'Playlist'; + } + return 'Category'; +} + export function homeBrowsePath(): string { const libraryId = activeLibraryId(); return typeof libraryId === 'number' ? `/libraries/${libraryId}` : '/'; @@ -109,14 +124,16 @@ export function renderBrowseFilterDetail(): string { ? `style="--home-feature-image: url('${escapeHtml(filter.artworkUrl)}');"` : ''; const themeSongOption = currentThemeSongYouTubeTarget(); + const filterKindLabel = browseFilterKindLabel(filter.kind); + const filterOverview = filter.overview ?? `${items.length} title${items.length === 1 ? '' : 's'} in this ${filter.kind}.`; return `
-

${escapeHtml(filter.kind === 'collection' ? 'Collection' : filter.kind === 'playlist' ? 'Playlist' : 'Category')}

+

${escapeHtml(filterKindLabel)}

${escapeHtml(filter.label)}

-

${escapeHtml(filter.overview ?? `${items.length} title${items.length === 1 ? '' : 's'} in this ${filter.kind}.`)}

+

${escapeHtml(filterOverview)}

${items.length} title${items.length === 1 ? '' : 's'}
@@ -284,11 +301,10 @@ export function metadataBadgeMarkup(item: MediaItemSummary): string { return ''; } - const statusLabel = pending - ? unmatched - ? 'Matching metadata' - : 'Refreshing metadata' - : 'Metadata is not linked yet'; + let statusLabel = 'Metadata is not linked yet'; + if (pending) { + statusLabel = unmatched ? 'Matching metadata' : 'Refreshing metadata'; + } return ` ${unmatched ? `${renderIcon('triangle-alert', 'status-icon')}` : ''} @@ -398,11 +414,12 @@ export function renderItemCard(item: MediaItemSummary): string { const isSeasonEpisodeCard = state.route.page === 'item' && state.selectedItem?.item_type === 'season' && item.item_type === 'episode'; - const secondaryMeta = isSeasonEpisodeCard - ? undefined - : state.route.page === 'home' && typeof state.route.libraryId === 'number' + let secondaryMeta: string | undefined; + if (!isSeasonEpisodeCard) { + secondaryMeta = state.route.page === 'home' && typeof state.route.libraryId === 'number' ? humanizeItemType(item.item_type) : `${library?.name ?? 'Library'} · ${humanizeItemType(item.item_type)}`; + } const metricMarkup = item.missing_since ? missingItemBadgeMarkup(item) : `${escapeHtml(formatChildCount(item))}`; @@ -639,6 +656,29 @@ export function renderLibraryOverview(): string { `; } + let refreshStatusTag = ''; + if (activeRefreshProgress) { + refreshStatusTag = `Refreshing metadata ${activeRefreshProgress.completed}/${activeRefreshProgress.total}`; + } else if (stalePending > 0) { + refreshStatusTag = `Pending metadata ${library.metadata_refresh_completed}/${library.metadata_refresh_total}`; + } + let libraryStatusClass = ''; + if (library.status === 'available') { + libraryStatusClass = 'success'; + } else if (library.status === 'never_scanned') { + libraryStatusClass = 'warning'; + } + const metadataRefreshFailedSuffix = activeRefreshProgress?.failed + ? ` (${activeRefreshProgress.failed} failed)` + : ''; + const metadataRefreshNote = activeRefreshProgress + ? `

Metadata refresh progress: ${activeRefreshProgress.completed}/${activeRefreshProgress.total}${metadataRefreshFailedSuffix}. Artwork and item cards update automatically as each item completes.

` + : ''; + const stalePendingVerb = stalePending === 1 ? ' is' : 's are'; + const stalePendingNote = stalePending > 0 + ? `

${stalePending} item${stalePendingVerb} still marked pending without an active refresh worker. Use refresh metadata to resume the library refresh.

` + : ''; + return `
@@ -648,13 +688,9 @@ export function renderLibraryOverview(): string {
${scanPending ? 'Scanning catalog' : ''} - ${activeRefreshProgress - ? `Refreshing metadata ${activeRefreshProgress.completed}/${activeRefreshProgress.total}` - : stalePending > 0 - ? `Pending metadata ${library.metadata_refresh_completed}/${library.metadata_refresh_total}` - : ''} + ${refreshStatusTag}
- ${escapeHtml(libraryStatusLabel(library.status))} + ${escapeHtml(libraryStatusLabel(library.status))} ${library.total_files} file${library.total_files === 1 ? '' : 's'}
@@ -679,12 +715,8 @@ export function renderLibraryOverview(): string {
${library.error ? `

${escapeHtml(library.error)}

` : ''} ${library.status === 'never_scanned' ? '

This library has not been scanned yet. It will populate after the next catalog scan starts.

' : ''} - ${activeRefreshProgress - ? `

Metadata refresh progress: ${activeRefreshProgress.completed}/${activeRefreshProgress.total}${activeRefreshProgress.failed ? ` (${activeRefreshProgress.failed} failed)` : ''}. Artwork and item cards update automatically as each item completes.

` - : ''} - ${stalePending > 0 - ? `

${stalePending} item${stalePending === 1 ? ' is' : 's are'} still marked pending without an active refresh worker. Use refresh metadata to resume the library refresh.

` - : ''} + ${metadataRefreshNote} + ${stalePendingNote}
`; } @@ -693,6 +725,7 @@ export function renderLibraryTab(): string { const items = filteredTopLevelLibraryItems(); const library = activeLibrary(); const isSpecificLibrary = state.route.page === 'home' && typeof state.route.libraryId === 'number'; + const browseFilterKind = state.browseFilter ? browseFilterKindLabel(state.browseFilter.kind) : ''; if (!items.length) { if (state.libraryItemsLoading) { @@ -722,7 +755,7 @@ export function renderLibraryTab(): string { ${state.browseFilter ? `
- ${escapeHtml(state.browseFilter.kind === 'category' ? 'Category' : 'Collection')} + ${escapeHtml(browseFilterKind)} ${escapeHtml(state.browseFilter.label)}
@@ -877,6 +910,17 @@ export function renderHomeNavbar(): string { const libraryScanPending = library ? hasActiveLibraryScan(library.id) : hasActiveLibraryScan(); const hasSearch = Boolean(state.searchQuery) || state.searchResults.length > 0 || state.showFullSearchResults; const searchToggleLabel = hasSearch ? 'Clear search' : 'Search'; + const searchButtonType = hasSearch ? 'button' : 'submit'; + const searchClearAttribute = hasSearch ? 'data-clear-search' : ''; + const searchIcon = hasSearch ? 'x' : 'search'; + const scanButtonDisabled = libraryScanPending ? 'disabled' : ''; + const refreshButtonDisabled = libraryRefreshPending ? 'disabled' : ''; + const libraryActionButtons = library + ? ` + + + ` + : ''; return `
@@ -886,19 +930,14 @@ export function renderHomeNavbar(): string { + ${searchClearAttribute} + >${renderIcon(searchIcon)} - ${library - ? ` - - - ` - : ''} + ${libraryActionButtons} ${renderSearchPopover()}
diff --git a/crates/client-web/src/app/itemPersonView.ts b/crates/client-web/src/app/itemPersonView.ts index 655849a8..d350c612 100644 --- a/crates/client-web/src/app/itemPersonView.ts +++ b/crates/client-web/src/app/itemPersonView.ts @@ -63,14 +63,23 @@ export function renderMetadataSearchResults(): string { export function selectedItemMetadataProviderOptions(): MetadataProviderStatus[] { const itemType = state.selectedItem?.item_type; - const libraryKind = activeLibrary()?.kind - ?? (itemType === 'show' ? 'shows' : itemType === 'movie' ? 'movies' : undefined); + const libraryKind = activeLibrary()?.kind ?? libraryKindForItemType(itemType); return (state.selectedItemMetadata?.providers ?? state.metadataProviders) .filter((provider) => provider.role !== 'secondary') .filter((provider) => provider.configured && provider.implemented) .filter((provider) => !libraryKind || provider.supported_kinds.includes(libraryKind)); } +function libraryKindForItemType(itemType: string | undefined): string | undefined { + if (itemType === 'show') { + return 'shows'; + } + if (itemType === 'movie') { + return 'movies'; + } + return undefined; +} + export function defaultMetadataSearchProviderIds(): string[] { const providers = selectedItemMetadataProviderOptions(); const providerIds = new Set(providers.map((provider) => provider.id)); @@ -150,13 +159,20 @@ export function renderLinkedMetadataSummary(): string { const metadataRefreshPending = itemIsMetadataPending(state.selectedItem); const metadataRefreshActive = itemHasActiveMetadataRefresh(state.selectedItem); - const refreshStateLabel = metadataRefreshActive - ? 'Refreshing' - : metadataRefreshPending || linkedMatch.refresh_state === 'pending' - ? 'Pending without worker' - : linkedMatch.refresh_state === 'error' - ? 'Refresh failed' - : 'Up to date'; + let refreshStateLabel = 'Up to date'; + if (metadataRefreshActive) { + refreshStateLabel = 'Refreshing'; + } else if (metadataRefreshPending || linkedMatch.refresh_state === 'pending') { + refreshStateLabel = 'Pending without worker'; + } else if (linkedMatch.refresh_state === 'error') { + refreshStateLabel = 'Refresh failed'; + } + let refreshStateClass = ''; + if (metadataRefreshPending || linkedMatch.refresh_state === 'pending') { + refreshStateClass = 'warning'; + } else if (linkedMatch.refresh_state === 'error') { + refreshStateClass = 'danger-tag'; + } const providersById = new Map( (state.selectedItemMetadata?.providers ?? state.metadataProviders).map((provider) => [provider.id, provider]), ); @@ -183,7 +199,7 @@ export function renderLinkedMetadataSummary(): string { -
- ${group.seasons.map((seasonGroup) => ` + ${seasonTrayMarkup} + `; +} + +function renderPersonSeasonCreditTray(group: PersonCreditGroup, traySummary: string): string { + return ` +
+
+ ${escapeHtml(traySummary || 'Credits')} + +
+
+ ${group.seasons.map((seasonGroup) => { + const episodeTrayMarkup = seasonGroup.episodes.length ? renderPersonEpisodeCreditTray(seasonGroup) : ''; + return `
${renderItemCard(seasonGroup.season)}
- ${seasonGroup.episodes.length ? ` -
-
- ${seasonGroup.episodes.length} episode${seasonGroup.episodes.length === 1 ? '' : 's'} - -
-
- ${seasonGroup.episodes.map(renderItemCard).join('')} -
-
- ` : ''} - `).join('')} -
+ ${episodeTrayMarkup} + `; + }).join('')}
- ` : ''} +
+ `; +} + +function renderPersonEpisodeCreditTray(seasonGroup: PersonCreditGroup['seasons'][number]): string { + return ` +
+
+ ${escapeHtml(countLabel(seasonGroup.episodes.length, 'episode'))} + +
+
+ ${seasonGroup.episodes.map(renderItemCard).join('')} +
+
`; } @@ -468,9 +510,12 @@ export function renderPersonPage(): string { return '
Loading person details…
'; } - const personImageUrl = response.person.cached_image_path - ? getPersonImageUrl(response.person.id) - : response.person.image_url ? resolveApiUrl(response.person.image_url) : undefined; + let personImageUrl: string | undefined; + if (response.person.cached_image_path) { + personImageUrl = getPersonImageUrl(response.person.id); + } else if (response.person.image_url) { + personImageUrl = resolveApiUrl(response.person.image_url); + } const credits = response.credits; const creditGroups = personCreditGroups(credits); const age = personAgeLabel(response.person.birthday, response.person.deathday); @@ -691,11 +736,60 @@ export function renderItemPage(): string { const resumeMs = resumablePlaybackPositionMs(state.selectedItem); const playbackTarget = !state.selectedItem.playable ? state.selectedItem.playback_target : undefined; const restartPlaybackTarget = !state.selectedItem.playable ? state.selectedItem.restart_playback_target : undefined; - const childSectionTitle = state.selectedItem.item_type === 'show' - ? 'Seasons' - : state.selectedItem.item_type === 'season' - ? 'Episodes' - : 'Contained items'; + let childSectionTitle = 'Contained items'; + if (state.selectedItem.item_type === 'show') { + childSectionTitle = 'Seasons'; + } else if (state.selectedItem.item_type === 'season') { + childSectionTitle = 'Episodes'; + } + const itemHeroClass = state.selectedItem.item_type === 'episode' ? 'episode-hero' : ''; + const itemPosterClass = state.selectedItem.item_type === 'episode' ? 'item-thumbnail' : ''; + const posterMarkup = posterUrl + ? `${escapeHtml(state.selectedItem.display_title)} poster` + : `${escapeHtml(state.selectedItem.display_title.slice(0, 1).toUpperCase())}`; + const titleMarkup = logoUrl + ? `` + : `

${escapeHtml(state.selectedItem.display_title)}

`; + const resumeButtonMarkup = state.selectedItem.playable && resumeMs > 0 + ? `` + : ''; + let playButtonMarkup = ''; + if (state.selectedItem.playable) { + const playButtonClass = resumeMs > 0 ? 'secondary-button' : ''; + const playButtonLabel = resumeMs > 0 ? 'Start over' : 'Play now'; + playButtonMarkup = ``; + } + const childCountLabel = countLabel(children.length, 'item'); + const childGridClass = state.selectedItem.item_type === 'season' ? 'season-episodes-grid' : ''; + const childrenSectionMarkup = children.length + ? ` +
+
+

${escapeHtml(childSectionTitle)}

+ ${childCountLabel} +
+
${children.map(renderItemCard).join('')}
+
+ ` + : ''; + let metadataRefreshButtonMarkup = ''; + if (supportsManualLinking) { + const refreshButtonDisabled = linkedMatch && !metadataRefreshActive ? '' : 'disabled'; + const refreshButtonLabel = metadataRefreshActive ? 'Refreshing metadata' : 'Force refresh metadata'; + metadataRefreshButtonMarkup = ``; + } + const metadataSearchPanel = supportsManualLinking + ? ` + + + ` + : '
Season and episode metadata is inherited and refreshed automatically from the linked show.
'; return `
${hierarchy.length ? ` @@ -707,14 +801,12 @@ export function renderItemPage(): string { ${escapeHtml(state.selectedItem.display_title)} ` : ''} -
-
- ${posterUrl ? `${escapeHtml(state.selectedItem.display_title)} poster` : `${escapeHtml(state.selectedItem.display_title.slice(0, 1).toUpperCase())}`} +
+
+ ${posterMarkup}
- ${logoUrl - ? `` - : `

${escapeHtml(state.selectedItem.display_title)}

`} + ${titleMarkup} ${state.selectedItem.tagline ? `

${escapeHtml(state.selectedItem.tagline)}

` : ''}
${missingItemDetailBadgeMarkup(state.selectedItem)} @@ -726,8 +818,8 @@ export function renderItemPage(): string {
${renderCollapsibleText(overview, `item-overview:${state.selectedItem.id}`)}
- ${state.selectedItem.playable && resumeMs > 0 ? `` : ''} - ${state.selectedItem.playable ? `` : ''} + ${resumeButtonMarkup} + ${playButtonMarkup} ${playbackTarget ? renderPlaybackTargetButton(playbackTarget, false) : ''} ${restartPlaybackTarget ? renderPlaybackTargetButton(restartPlaybackTarget, true) : ''} ${preferredTrailer ? `` : ''} @@ -763,15 +855,7 @@ export function renderItemPage(): string { ${renderItemExtrasRail()} - ${children.length ? ` -
-
-

${escapeHtml(childSectionTitle)}

- ${children.length} item${children.length === 1 ? '' : 's'} -
-
${children.map(renderItemCard).join('')}
-
- ` : ''} + ${childrenSectionMarkup} ${renderSelectedItemCollectionRails()} @@ -803,23 +887,10 @@ export function renderItemPage(): string {
diff --git a/crates/client-web/src/app/mediaTargets.ts b/crates/client-web/src/app/mediaTargets.ts index dfc381cc..ca56dd3f 100644 --- a/crates/client-web/src/app/mediaTargets.ts +++ b/crates/client-web/src/app/mediaTargets.ts @@ -61,4 +61,3 @@ export function currentThemeSongYouTubeTarget(): { title: string; url: string; v videoId, }; } - diff --git a/crates/client-web/src/app/playbackController.ts b/crates/client-web/src/app/playbackController.ts index 615c15bc..e2205472 100644 --- a/crates/client-web/src/app/playbackController.ts +++ b/crates/client-web/src/app/playbackController.ts @@ -71,6 +71,29 @@ export function renderPlayerOverlay(): string { const trailerTitle = itemLogoUrl || !itemTitle ? state.activeTrailer.title : `${itemTitle} | ${state.activeTrailer.title}`; + const trailerVolumeValue = trailerMuted ? '0' : String(trailerVolume); + const trailerControlsMarkup = videoId + ? ` +
+ +
+
+ 0:00/0:00 +
+
+ + + +
+
+ + + +
+
+
+ ` + : ''; return `
@@ -103,26 +126,7 @@ export function renderPlayerOverlay(): string {
- ${videoId ? ` -
- -
-
- 0:00/0:00 -
-
- - - -
-
- - - -
-
-
- ` : ''} + ${trailerControlsMarkup} `; @@ -158,8 +162,11 @@ export function renderPlayerOverlay(): string { ? state.activePlaybackStartMs : 0; const source = getSessionStreamUrl(state.activePlaybackSession.session_id, streamStartMs, selectedAudioStreamIndex); + const transcodeReason = isRemuxingForAudio + ? 'Using a non-default audio track requires a remuxed stream.' + : state.activePlaybackSession.decision.reason; const transcodeBadge = state.activePlaybackSession.decision.transcode_required || isRemuxingForAudio - ? `Transcoding` + ? `Transcoding` : `Direct Play`; const audioTracks = playbackItem.audio_tracks ?? []; const activeAudioTrack = audioTracks.find((track) => track.index === selectedAudioStreamIndex) @@ -168,19 +175,53 @@ export function renderPlayerOverlay(): string { const audioTrackMenuTitle = activeAudioTrack ? `Audio track: ${activeAudioTrack.label}` : 'Audio track changes may require remuxing'; - - return ` -
-
- ${isAudio ? ` + const audioArtMarkup = posterUrl + ? `` + : renderIcon('music', 'audio-player-art-icon'); + const audioArtClass = posterUrl ? 'has-image' : ''; + const mediaElementMarkup = isAudio + ? ` -
- ${posterUrl ? `` : renderIcon('music', 'audio-player-art-icon')} +
+ ${audioArtMarkup}
- ` : ` + ` + : ` - `} + `; + let audioTrackMenuMarkup = ''; + if (!isAudio && audioTracks.length > 1) { + const audioTrackMenuExpanded = state.isAudioTrackMenuOpen ? 'true' : 'false'; + const audioTrackMenuClass = state.isAudioTrackMenuOpen ? '' : 'is-hidden'; + const audioTrackMenuHidden = state.isAudioTrackMenuOpen ? '' : 'hidden'; + const audioTrackOptions = audioTracks.map((track) => { + const isActiveTrack = track.index === activeAudioTrack?.index; + const activeTrackClass = isActiveTrack ? 'active' : ''; + const activeTrackChecked = isActiveTrack ? 'true' : 'false'; + const trackDetail = [track.language?.toUpperCase(), track.codec?.toUpperCase()].filter(Boolean).join(' · ') + || (track.default ? 'Default' : 'Audio'); + return ` + + `; + }).join(''); + audioTrackMenuMarkup = ` +
+ + +
+ `; + } + + return ` +
+
+ ${mediaElementMarkup}
@@ -215,19 +256,7 @@ export function renderPlayerOverlay(): string {
- ${!isAudio && audioTracks.length > 1 ? ` -
- - -
- ` : ''} + ${audioTrackMenuMarkup} ${isAudio ? '' : ``}
@@ -906,6 +935,16 @@ export function bindPlayerProgress(): void { let skipStepIndex = 0; let hasAppliedInitialDirectSeek = initialDirectSeekSeconds <= 0; + const playbackDurationSeconds = (): number => { + if (sourceDurationSeconds > 0) { + return sourceDurationSeconds; + } + if (Number.isFinite(player.duration) && player.duration > 0) { + return player.duration; + } + return 0; + }; + const setPlayerLoading = (loading: boolean): void => { const shouldShowLoading = loading && !player.ended && player.readyState < player.HAVE_FUTURE_DATA; shell?.classList.toggle('is-media-loading', shouldShowLoading); @@ -962,11 +1001,7 @@ export function bindPlayerProgress(): void { }; const updateTimeline = (): void => { - const duration = sourceDurationSeconds > 0 - ? sourceDurationSeconds - : Number.isFinite(player.duration) && player.duration > 0 - ? player.duration - : 0; + const duration = playbackDurationSeconds(); const currentPosition = Math.min(duration || Number.POSITIVE_INFINITY, playbackBaseOffsetSeconds + player.currentTime); if (progress && !isScrubbing) { progress.value = duration > 0 ? String(Math.min(1000, Math.max(0, (currentPosition / duration) * 1000))) : '0'; @@ -984,11 +1019,7 @@ export function bindPlayerProgress(): void { return; } - const duration = sourceDurationSeconds > 0 - ? sourceDurationSeconds - : Number.isFinite(player.duration) && player.duration > 0 - ? player.duration - : 0; + const duration = playbackDurationSeconds(); const targetPosition = duration > 0 ? Math.min(initialDirectSeekSeconds, Math.max(0, duration - 1)) : initialDirectSeekSeconds; @@ -1179,7 +1210,7 @@ export function bindPlayerProgress(): void { }); progress?.addEventListener('input', () => { isScrubbing = true; - const duration = sourceDurationSeconds > 0 ? sourceDurationSeconds : Number.isFinite(player.duration) ? player.duration : 0; + const duration = playbackDurationSeconds(); if (duration > 0) { const previewSeconds = (Number(progress.value) / 1000) * duration; if (currentTimeLabel) { @@ -1196,7 +1227,7 @@ export function bindPlayerProgress(): void { showControls(); }, { passive: false }); progress?.addEventListener('change', () => { - const duration = sourceDurationSeconds > 0 ? sourceDurationSeconds : Number.isFinite(player.duration) ? player.duration : 0; + const duration = playbackDurationSeconds(); if (duration > 0) { const targetPosition = (Number(progress.value) / 1000) * duration; if (isTranscoding) { diff --git a/crates/client-web/src/app/providers.ts b/crates/client-web/src/app/providers.ts index 5c59fcc3..b0a6dcc0 100644 --- a/crates/client-web/src/app/providers.ts +++ b/crates/client-web/src/app/providers.ts @@ -19,4 +19,3 @@ export function libraryProviderOptions(libraryKind?: string): MetadataProviderSt return state.metadataProviders .filter((provider) => !libraryKind || provider.supported_kinds.includes(libraryKind)); } - diff --git a/crates/client-web/src/app/routes.ts b/crates/client-web/src/app/routes.ts index 15ca2b1d..f18ec8d9 100644 --- a/crates/client-web/src/app/routes.ts +++ b/crates/client-web/src/app/routes.ts @@ -6,6 +6,16 @@ export function defaultHomeTab(_route: AppRoute): HomeBrowseTab { return 'recommended'; } +function browseKindFromSegment(segment: string): Extract['kind'] { + if (segment === 'collections') { + return 'collection'; + } + if (segment === 'playlists') { + return 'playlist'; + } + return 'category'; +} + /** Converts the current browser path into the web UI's route model. */ export function parseRoute(): AppRoute { const normalizedPath = globalThis.location.pathname.replace(/\/+$/, '') || '/'; @@ -30,11 +40,7 @@ export function parseRoute(): AppRoute { return { page: 'browse-detail', libraryId: Number(libraryBrowseMatch[1]), - kind: libraryBrowseMatch[2] === 'collections' - ? 'collection' - : libraryBrowseMatch[2] === 'playlists' - ? 'playlist' - : 'category', + kind: browseKindFromSegment(libraryBrowseMatch[2]), key: decodeURIComponent(libraryBrowseMatch[3]), }; } @@ -43,11 +49,7 @@ export function parseRoute(): AppRoute { if (browseMatch) { return { page: 'browse-detail', - kind: browseMatch[1] === 'collections' - ? 'collection' - : browseMatch[1] === 'playlists' - ? 'playlist' - : 'category', + kind: browseKindFromSegment(browseMatch[1]), key: decodeURIComponent(browseMatch[2]), }; } diff --git a/crates/client-web/src/app/settingsView.ts b/crates/client-web/src/app/settingsView.ts index 11fa3423..b076ebe5 100644 --- a/crates/client-web/src/app/settingsView.ts +++ b/crates/client-web/src/app/settingsView.ts @@ -85,7 +85,11 @@ export function metadataProviderCheckboxes(prefix: string, selectedProviders: st .sort((left, right) => { const leftIndex = selectedProviders.indexOf(left.id); const rightIndex = selectedProviders.indexOf(right.id); - return (left.role === right.role ? 0 : left.role === 'primary' ? -1 : 1) + let roleOrder = 0; + if (left.role !== right.role) { + roleOrder = left.role === 'primary' ? -1 : 1; + } + return roleOrder || (leftIndex < 0 ? Number.MAX_SAFE_INTEGER : leftIndex) - (rightIndex < 0 ? Number.MAX_SAFE_INTEGER : rightIndex) || left.display_name.localeCompare(right.display_name); @@ -153,6 +157,30 @@ export function renderExistingLibrariesSettings(settings: SettingsSnapshot): str const missingFiles = persistedLibrary?.missing_files ?? 0; const missingItems = persistedLibrary?.missing_items ?? 0; const hasMissingItems = missingFiles > 0 || missingItems > 0; + let persistedLibraryTags = ''; + let persistedLibraryActions = ''; + if (persistedLibrary) { + const scanPendingTag = scanPending ? 'Scanning catalog' : ''; + const missingItemsTagClass = hasMissingItems ? 'warning' : 'success'; + const missingItemsLabel = hasMissingItems ? `${missingItems} missing items` : 'No missing items'; + const missingFilesTag = missingFiles > 0 + ? `${escapeHtml(`${missingFiles} missing files`)}` + : ''; + const scanButtonDisabled = scanPending ? 'disabled' : ''; + const scanButtonLabel = scanPending ? 'Scanning' : 'Scan now'; + const refreshButtonDisabled = refreshPending ? 'disabled' : ''; + const deleteMissingDisabled = hasMissingItems ? '' : 'disabled'; + persistedLibraryTags = `
+ ${scanPendingTag} + ${escapeHtml(missingItemsLabel)} + ${missingFilesTag} +
`; + persistedLibraryActions = ` + + + + `; + } return `
@@ -160,22 +188,10 @@ export function renderExistingLibrariesSettings(settings: SettingsSnapshot): str

Library ${index + 1}

${escapeHtml(library.name || `Library ${index + 1}`)}

- ${persistedLibrary - ? `
- ${scanPending ? 'Scanning catalog' : ''} - ${escapeHtml(hasMissingItems ? `${missingItems} missing items` : 'No missing items')} - ${missingFiles > 0 ? `${escapeHtml(`${missingFiles} missing files`)}` : ''} -
` - : ''} + ${persistedLibraryTags}
- ${persistedLibrary - ? ` - - - - ` - : ''} + ${persistedLibraryActions}
@@ -393,29 +409,47 @@ export function renderProviderSettingsCard(provider: MetadataProviderSettings): const showApiKey = Boolean(status?.requires_api_key); const apiKeyConfigured = Boolean(provider.api_key_configured || provider.api_key_secret_ref || provider.api_key); const showRequestSettings = provider.id !== 'local_nfo'; + const logoMarkup = logoUrl ? `` : ''; + const providerRoleLabel = status?.role === 'secondary' ? 'Secondary' : 'Primary'; + const providerRoleTag = status?.role ? `${escapeHtml(providerRoleLabel)}` : ''; + const providerDescription = status?.description ? `

${escapeHtml(status.description)}

` : ''; + const providerAttribution = status?.attribution_text ? `

${escapeHtml(status.attribution_text)}

` : ''; + const apiKeyPlaceholder = apiKeyConfigured ? 'Saved' : ''; + const apiKeyField = showApiKey + ? `` + : ''; + const clearApiKeyField = showApiKey && apiKeyConfigured + ? `` + : ''; + const requestSettingsFields = showRequestSettings + ? ` + + + + ` + : ''; + const providerSettingsFields = showApiKey || showRequestSettings + ? `
+ ${apiKeyField} + ${clearApiKeyField} + ${requestSettingsFields} +
` + : '

This provider does not require provider-specific settings.

'; return `
- ${logoUrl ? `` : ''} + ${logoMarkup}

Provider

${escapeHtml(label)}

- ${status?.role ? `${escapeHtml(status.role === 'secondary' ? 'Secondary' : 'Primary')}` : ''} + ${providerRoleTag}
- ${status?.description ? `

${escapeHtml(status.description)}

` : ''} - ${status?.attribution_text ? `

${escapeHtml(status.attribution_text)}

` : ''} - ${showApiKey || showRequestSettings ? `
- ${showApiKey ? `` : ''} - ${showApiKey && apiKeyConfigured ? `` : ''} - ${showRequestSettings ? ` - - - - ` : ''} -
` : '

This provider does not require provider-specific settings.

'} + ${providerDescription} + ${providerAttribution} + ${providerSettingsFields}
`; } @@ -445,22 +479,10 @@ export function renderProviderSettingsPage(settings: SettingsSnapshot): string { `; } -export function renderSettingsPage(): string { - const settings = state.settingsResponse?.settings; - if (!settings) { - return '
Settings are still loading…
'; - } - - const section = activeSettingsSection(); - +function renderGeneralSettingsPage(settings: SettingsSnapshot): string { + const useHttpsChecked = settings.server.use_https ? 'checked' : ''; + const useCustomCertsChecked = settings.server.use_custom_certs ? 'checked' : ''; return ` - ${renderPageNavbar( - 'Settings', - 'Program configuration', - `Saved to ${state.settingsResponse?.settings_path ?? ''}`, - )} - ${renderSettingsSectionNav()} - ${section === 'general' ? `
@@ -471,8 +493,8 @@ export function renderSettingsPage(): string {
- - + +
@@ -502,73 +524,113 @@ export function renderSettingsPage(): string { ${renderUserManagement()}
- ` : ''} - ${section === 'providers' ? renderProviderSettingsPage(settings) : ''} - ${section === 'scheduled' ? renderScheduledTasksPage(settings) : ''} - ${section === 'libraries' ? ` -
- -
-
-

Libraries

-
-

Each logical library can now contain multiple folders. Enter one folder per line.

-
- ${renderExistingLibrariesSettings(settings)} -
-
-
- + `; +} + +function renderLibrarySettingsPage(settings: SettingsSnapshot): string { + return ` +
+ +
+
+

Libraries

+
+

Each logical library can now contain multiple folders. Enter one folder per line.

+
+ ${renderExistingLibrariesSettings(settings)}
- - -
-
-

Add library

- -
+
+ +
+
+ +
+
+

Add library

+ + +
+ -
- - - -
-
- - -
-
- -
-
- Metadata sources -
${metadataProviderCheckboxes('library_metadata_provider', ['tmdb'])}
-
-
- -
-
- ` : ''} - ${section === 'dashboard' ? ` + + +
+
+ + +
+
+ +
+
+ Metadata sources +
${metadataProviderCheckboxes('library_metadata_provider', ['tmdb'])}
+
+
+ + +
+ `; +} + +function renderSettingsSectionContent(section: SettingsSection, settings: SettingsSnapshot): string { + if (section === 'general') { + return renderGeneralSettingsPage(settings); + } + if (section === 'providers') { + return renderProviderSettingsPage(settings); + } + if (section === 'scheduled') { + return renderScheduledTasksPage(settings); + } + if (section === 'libraries') { + return renderLibrarySettingsPage(settings); + } + if (section === 'dashboard') { + return `
${renderMetadataDashboard()}
${renderSystemActivitiesPanel()}
- ` : ''} - ${section === 'logs' ? `
${renderLogViewer()}
` : ''} + `; + } + if (section === 'logs') { + return '
' + renderLogViewer() + '
'; + } + return ''; +} + +export function renderSettingsPage(): string { + const settings = state.settingsResponse?.settings; + if (!settings) { + return '
Settings are still loading…
'; + } + + const section = activeSettingsSection(); + const settingsContent = renderSettingsSectionContent(section, settings); + + return ` + ${renderPageNavbar( + 'Settings', + 'Program configuration', + `Saved to ${state.settingsResponse?.settings_path ?? ''}`, + )} + ${renderSettingsSectionNav()} + ${settingsContent} `; } @@ -578,6 +640,15 @@ export function buildSettingsFromForm(formData: FormData): SettingsSnapshot | un return undefined; } const settingsSection = activeSettingsSection(); + let metadataRefreshIntervalDays = current.metadata.refresh_interval_days; + if (formData.has('metadata_refresh_interval_days')) { + const refreshIntervalValue = String(formData.get('metadata_refresh_interval_days') ?? ''); + if (refreshIntervalValue === 'never') { + metadataRefreshIntervalDays = null; + } else { + metadataRefreshIntervalDays = Number(formData.get('metadata_refresh_interval_days') ?? current.metadata.refresh_interval_days ?? 30); + } + } return { general: { @@ -616,11 +687,7 @@ export function buildSettingsFromForm(formData: FormData): SettingsSnapshot | un }), }, metadata: { - refresh_interval_days: formData.has('metadata_refresh_interval_days') - ? String(formData.get('metadata_refresh_interval_days') ?? '') === 'never' - ? null - : Number(formData.get('metadata_refresh_interval_days') ?? current.metadata.refresh_interval_days ?? 30) - : current.metadata.refresh_interval_days, + refresh_interval_days: metadataRefreshIntervalDays, providers: current.metadata.providers.map((provider) => { const prefix = provider.id; if ( diff --git a/crates/client-web/src/app/ui.ts b/crates/client-web/src/app/ui.ts index 1564c598..1a63d3e9 100644 --- a/crates/client-web/src/app/ui.ts +++ b/crates/client-web/src/app/ui.ts @@ -11,8 +11,10 @@ export function renderCollapsibleText(text: string, key: string, className = 'he const shouldCollapse = normalized.length > COLLAPSIBLE_TEXT_LENGTH || lineCount > COLLAPSIBLE_TEXT_LINE_COUNT; const isExpanded = state.expandedTextKeys.has(key); const stateClass = shouldCollapse && !isExpanded ? 'is-collapsed' : ''; + const toggleExpanded = isExpanded ? 'true' : 'false'; + const toggleLabel = isExpanded ? 'show less' : '... see more'; const toggle = shouldCollapse - ? `` + ? `` : ''; return ` From 2402a21b61b0538b780983dd6b10152d79cef153 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 11 Jun 2026 22:12:00 -0400 Subject: [PATCH 099/128] Fix sonar typescript:S6551 --- crates/client-web/src/app/auth.ts | 2 +- crates/client-web/src/app/eventBindings.ts | 56 +++++++++++----------- crates/client-web/src/app/formUtils.ts | 16 +++++-- crates/client-web/src/app/settingsView.ts | 41 ++++++++-------- 4 files changed, 64 insertions(+), 51 deletions(-) diff --git a/crates/client-web/src/app/auth.ts b/crates/client-web/src/app/auth.ts index f8f0220a..48b4a081 100644 --- a/crates/client-web/src/app/auth.ts +++ b/crates/client-web/src/app/auth.ts @@ -50,7 +50,7 @@ export async function readProfileImageUpload(formData: FormData): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); - reader.addEventListener('load', () => resolve(String(reader.result ?? ''))); + reader.addEventListener('load', () => resolve(typeof reader.result === 'string' ? reader.result : '')); reader.addEventListener('error', () => reject(new Error('Failed to read profile image.'))); reader.readAsDataURL(file); }); diff --git a/crates/client-web/src/app/eventBindings.ts b/crates/client-web/src/app/eventBindings.ts index 845f1bac..134ba75c 100644 --- a/crates/client-web/src/app/eventBindings.ts +++ b/crates/client-web/src/app/eventBindings.ts @@ -5,7 +5,7 @@ import { currentLogFilterRequest, libraryHasActiveMetadataRefresh } from './acti import { readProfileImageUpload } from './auth'; import { renderLogViewer, renderMetadataDashboard } from './dashboardView'; import { escapeHtml } from './format'; -import { normalizedMetadataLanguages, parseMetadataLanguageInput, parsePathsInput } from './formUtils'; +import { formDataString, formDataStrings, normalizedMetadataLanguages, parseMetadataLanguageInput, parsePathsInput } from './formUtils'; import { mediaExtraToTrailerOption } from './mediaExtras'; import { currentThemeSongYouTubeTarget, currentTrailerOptions } from './mediaTargets'; import { @@ -433,11 +433,11 @@ function bindRenderEvents(context: AppEventBindingContext): void { try { const formData = new FormData(form); const request: CreateUserRequest = { - username: String(formData.get('username') ?? '').trim(), - password: String(formData.get('password') ?? ''), - pin: String(formData.get('pin') ?? '').trim() || undefined, + username: formDataString(formData.get('username')).trim(), + password: formDataString(formData.get('password')), + pin: formDataString(formData.get('pin')).trim() || undefined, admin: true, - birthday: String(formData.get('birthday') ?? '').trim() || undefined, + birthday: formDataString(formData.get('birthday')).trim() || undefined, profile_image_upload: await readProfileImageUpload(formData), preferred_metadata_languages: parseMetadataLanguageInput(formData.get('preferred_metadata_languages')), }; @@ -462,8 +462,8 @@ function bindRenderEvents(context: AppEventBindingContext): void { const formData = new FormData(form); const request: LoginRequest = { - username: String(formData.get('username') ?? '').trim(), - password: String(formData.get('password') ?? ''), + username: formDataString(formData.get('username')).trim(), + password: formDataString(formData.get('password')), }; try { @@ -1004,10 +1004,10 @@ function bindRenderEvents(context: AppEventBindingContext): void { const formData = new FormData(form); state.metadataDashboardFilters = { - libraryId: String(formData.get('dashboard_library_id') ?? '').trim(), - itemType: String(formData.get('dashboard_item_type') ?? '').trim(), - refreshState: String(formData.get('dashboard_refresh_state') ?? '').trim(), - search: String(formData.get('dashboard_search') ?? '').trim(), + libraryId: formDataString(formData.get('dashboard_library_id')).trim(), + itemType: formDataString(formData.get('dashboard_item_type')).trim(), + refreshState: formDataString(formData.get('dashboard_refresh_state')).trim(), + search: formDataString(formData.get('dashboard_search')).trim(), }; const root = document.querySelector('#metadata-dashboard-panel-root'); if (!root) { @@ -1045,11 +1045,11 @@ function bindRenderEvents(context: AppEventBindingContext): void { const formData = new FormData(form); state.logFilters = { - level: String(formData.get('log_level') ?? '').trim().toUpperCase(), - module: String(formData.get('log_module') ?? '').trim(), - search: String(formData.get('log_search') ?? '').trim(), - since: String(formData.get('log_since') ?? '').trim(), - until: String(formData.get('log_until') ?? '').trim(), + level: formDataString(formData.get('log_level')).trim().toUpperCase(), + module: formDataString(formData.get('log_module')).trim(), + search: formDataString(formData.get('log_search')).trim(), + since: formDataString(formData.get('log_since')).trim(), + until: formDataString(formData.get('log_until')).trim(), }; await refreshLogsView(context); }); @@ -1135,9 +1135,9 @@ function bindRenderEvents(context: AppEventBindingContext): void { const profileImageUpload = await readProfileImageUpload(formData); const removeProfileImage = formData.get('remove_profile_image') === 'on'; const request: UpdateUserRequest = { - username: String(formData.get('username') ?? '').trim(), + username: formDataString(formData.get('username')).trim(), admin: formData.get('admin') === 'on', - birthday: String(formData.get('birthday') ?? '').trim() || undefined, + birthday: formDataString(formData.get('birthday')).trim() || undefined, profile_image_upload: profileImageUpload, remove_profile_image: removeProfileImage, preferred_metadata_languages: parseMetadataLanguageInput(formData.get('preferred_metadata_languages')), @@ -1165,11 +1165,11 @@ function bindRenderEvents(context: AppEventBindingContext): void { try { const formData = new FormData(form); const request: CreateUserRequest = { - username: String(formData.get('username') ?? '').trim(), - password: String(formData.get('password') ?? ''), - pin: String(formData.get('pin') ?? '').trim() || undefined, + username: formDataString(formData.get('username')).trim(), + password: formDataString(formData.get('password')), + pin: formDataString(formData.get('pin')).trim() || undefined, admin: formData.get('admin') === 'on', - birthday: String(formData.get('birthday') ?? '').trim() || undefined, + birthday: formDataString(formData.get('birthday')).trim() || undefined, profile_image_upload: await readProfileImageUpload(formData), preferred_metadata_languages: parseMetadataLanguageInput(formData.get('preferred_metadata_languages')), }; @@ -1234,15 +1234,15 @@ function bindRenderEvents(context: AppEventBindingContext): void { const formData = new FormData(form); const paths = parsePathsInput(formData.get('library_paths')); const library: MediaLibrarySettings = { - name: String(formData.get('library_name') ?? ''), + name: formDataString(formData.get('library_name')), path: paths[0] ?? '', paths, recursive: formData.get('library_recursive') === 'on', - kind: String(formData.get('library_kind') ?? 'movies'), - scanner: String(formData.get('library_scanner') ?? 'auto'), - metadata_providers: formData.getAll('library_metadata_provider').map((value) => String(value)), - metadata_language_mode: String(formData.get('library_metadata_language_mode') ?? 'auto') === 'manual' ? 'manual' : 'auto', - metadata_languages: normalizedMetadataLanguages(formData.getAll('library_metadata_language').map((value) => String(value))), + kind: formDataString(formData.get('library_kind'), 'movies'), + scanner: formDataString(formData.get('library_scanner'), 'auto'), + metadata_providers: formDataStrings(formData.getAll('library_metadata_provider')), + metadata_language_mode: formDataString(formData.get('library_metadata_language_mode'), 'auto') === 'manual' ? 'manual' : 'auto', + metadata_languages: normalizedMetadataLanguages(formDataStrings(formData.getAll('library_metadata_language'))), allowed_user_ids: formData.getAll('library_allowed_user') .map((value) => Number(value)) .filter((value) => Number.isFinite(value) && value > 0), diff --git a/crates/client-web/src/app/formUtils.ts b/crates/client-web/src/app/formUtils.ts index 9edf9be6..77011bbb 100644 --- a/crates/client-web/src/app/formUtils.ts +++ b/crates/client-web/src/app/formUtils.ts @@ -1,11 +1,21 @@ /** Parses a multiline path input into folder entries. */ export function parsePathsInput(value: FormDataEntryValue | null | undefined): string[] { - return String(value ?? '') + return formDataString(value) .split(/\r?\n/) .map((entry) => entry.trim()) .filter(Boolean); } +/** Returns a submitted form value only when it is text, never a File object. */ +export function formDataString(value: FormDataEntryValue | null | undefined, fallback = ''): string { + return typeof value === 'string' ? value : fallback; +} + +/** Returns only text values from a repeated form field. */ +export function formDataStrings(values: FormDataEntryValue[]): string[] { + return values.filter((value): value is string => typeof value === 'string'); +} + /** Joins path entries for display in multiline textareas. */ export function joinPaths(paths: string[]): string { return paths.join('\n'); @@ -13,7 +23,7 @@ export function joinPaths(paths: string[]): string { /** Parses a comma-separated metadata language field into unique locale codes. */ export function parseMetadataLanguageInput(value: FormDataEntryValue | null): string[] { - const languages = String(value ?? '') + const languages = formDataString(value) .split(',') .map((language) => language.trim()) .filter(Boolean); @@ -35,7 +45,7 @@ export function parseBoundedInteger( min: number, max: number, ): number { - const parsed = Number(value ?? fallback); + const parsed = Number(formDataString(value, String(fallback))); if (!Number.isFinite(parsed)) { return fallback; } diff --git a/crates/client-web/src/app/settingsView.ts b/crates/client-web/src/app/settingsView.ts index b076ebe5..900932db 100644 --- a/crates/client-web/src/app/settingsView.ts +++ b/crates/client-web/src/app/settingsView.ts @@ -1,7 +1,7 @@ /** Renders settings sections and converts settings forms into API payloads. */ import type { MetadataProviderSettings, ScheduledTaskId, SettingsSnapshot } from '../api'; import { escapeHtml } from './format'; -import { joinPaths, normalizedMetadataLanguages, parseBoundedInteger, parsePathsInput } from './formUtils'; +import { formDataString, formDataStrings, joinPaths, normalizedMetadataLanguages, parseBoundedInteger, parsePathsInput } from './formUtils'; import { hasActiveLibraryScan, libraryRefreshProgress } from './activities'; import { renderLogViewer, renderMetadataDashboard, renderSystemActivitiesPanel } from './dashboardView'; import { renderUserManagement } from './auth'; @@ -642,17 +642,20 @@ export function buildSettingsFromForm(formData: FormData): SettingsSnapshot | un const settingsSection = activeSettingsSection(); let metadataRefreshIntervalDays = current.metadata.refresh_interval_days; if (formData.has('metadata_refresh_interval_days')) { - const refreshIntervalValue = String(formData.get('metadata_refresh_interval_days') ?? ''); + const refreshIntervalValue = formDataString(formData.get('metadata_refresh_interval_days')); if (refreshIntervalValue === 'never') { metadataRefreshIntervalDays = null; } else { - metadataRefreshIntervalDays = Number(formData.get('metadata_refresh_interval_days') ?? current.metadata.refresh_interval_days ?? 30); + metadataRefreshIntervalDays = Number(formDataString( + formData.get('metadata_refresh_interval_days'), + String(current.metadata.refresh_interval_days ?? 30), + )); } } return { general: { - data_dir: String(formData.get('data_dir') ?? current.general.data_dir), + data_dir: formDataString(formData.get('data_dir'), current.general.data_dir), }, media: { missing_item_auto_delete_days: null, @@ -665,18 +668,18 @@ export function buildSettingsFromForm(formData: FormData): SettingsSnapshot | un const paths = parsePathsInput(formData.get(pathsField)); const providerField = `existing_library_metadata_provider_${index}`; return { - name: String(formData.get(`existing_library_name_${index}`) ?? library.name), + name: formDataString(formData.get(`existing_library_name_${index}`), library.name), path: paths[0] ?? library.path, paths, recursive: formData.get(`existing_library_recursive_${index}`) === 'on', - kind: String(formData.get(`existing_library_kind_${index}`) ?? library.kind), - scanner: String(formData.get(`existing_library_scanner_${index}`) ?? library.scanner ?? 'auto'), - metadata_providers: formData.getAll(providerField).map((value) => String(value)), - metadata_language_mode: String(formData.get(`existing_library_metadata_language_mode_${index}`) ?? library.metadata_language_mode ?? 'auto') === 'manual' + kind: formDataString(formData.get(`existing_library_kind_${index}`), library.kind), + scanner: formDataString(formData.get(`existing_library_scanner_${index}`), library.scanner ?? 'auto'), + metadata_providers: formDataStrings(formData.getAll(providerField)), + metadata_language_mode: formDataString(formData.get(`existing_library_metadata_language_mode_${index}`), library.metadata_language_mode ?? 'auto') === 'manual' ? 'manual' : 'auto', metadata_languages: formData.has(`existing_library_metadata_language_${index}`) - ? normalizedMetadataLanguages(formData.getAll(`existing_library_metadata_language_${index}`).map((value) => String(value))) + ? normalizedMetadataLanguages(formDataStrings(formData.getAll(`existing_library_metadata_language_${index}`))) : normalizedMetadataLanguages(library.metadata_languages), allowed_user_ids: formData.has(`existing_library_allowed_user_${index}`) ? formData.getAll(`existing_library_allowed_user_${index}`) @@ -701,7 +704,7 @@ export function buildSettingsFromForm(formData: FormData): SettingsSnapshot | un } const submittedApiKey = formData.has(`${prefix}_api_key`) - ? String(formData.get(`${prefix}_api_key`) ?? '').trim() + ? formDataString(formData.get(`${prefix}_api_key`)).trim() : undefined; const clearApiKey = formData.get(`${prefix}_clear_api_key`) === 'on'; @@ -717,17 +720,17 @@ export function buildSettingsFromForm(formData: FormData): SettingsSnapshot | un }, server: { use_https: settingsSection === 'general' ? formData.get('use_https') === 'on' : current.server.use_https, - address: String(formData.get('address') ?? current.server.address), + address: formDataString(formData.get('address'), current.server.address), port: Number(formData.get('port') ?? current.server.port), - cert_path: String(formData.get('cert_path') ?? current.server.cert_path), - key_path: String(formData.get('key_path') ?? current.server.key_path), + cert_path: formDataString(formData.get('cert_path'), current.server.cert_path), + key_path: formDataString(formData.get('key_path'), current.server.key_path), use_custom_certs: settingsSection === 'general' ? formData.get('use_custom_certs') === 'on' : current.server.use_custom_certs, }, ffmpeg: { - ffmpeg_path: String(formData.get('ffmpeg_path') ?? current.ffmpeg.ffmpeg_path), - ffprobe_path: String(formData.get('ffprobe_path') ?? current.ffmpeg.ffprobe_path), + ffmpeg_path: formDataString(formData.get('ffmpeg_path'), current.ffmpeg.ffmpeg_path), + ffprobe_path: formDataString(formData.get('ffprobe_path'), current.ffmpeg.ffprobe_path), }, scheduled_tasks: parseScheduledTasksSettings(formData, current), }; @@ -739,7 +742,7 @@ export function parseScheduledTasksSettings(formData: FormData, current: Setting } const weekdays = formData.getAll('scheduled_window_weekday') - .map((value) => String(value)) + .filter((value): value is string => typeof value === 'string') .filter((value): value is SettingsSnapshot['scheduled_tasks']['window']['weekdays'][number] => ( ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'].includes(value) )); @@ -747,8 +750,8 @@ export function parseScheduledTasksSettings(formData: FormData, current: Setting return { enabled: formData.get('scheduled_tasks_enabled') === 'on', window: { - start_time: String(formData.get('scheduled_window_start_time') ?? current.scheduled_tasks.window.start_time), - stop_time: String(formData.get('scheduled_window_stop_time') ?? current.scheduled_tasks.window.stop_time), + start_time: formDataString(formData.get('scheduled_window_start_time'), current.scheduled_tasks.window.start_time), + stop_time: formDataString(formData.get('scheduled_window_stop_time'), current.scheduled_tasks.window.stop_time), weekdays: weekdays.length ? weekdays : current.scheduled_tasks.window.weekdays, }, metadata_refresh: { From 5ea9f799920dacd94e6562c33fe8bc5968b73ab0 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 11 Jun 2026 22:17:09 -0400 Subject: [PATCH 100/128] Fix sonar typescript:S6594 --- crates/client-web/src/api.ts | 32 ++++++++++++++--------------- crates/client-web/src/app/routes.ts | 12 +++++------ 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/crates/client-web/src/api.ts b/crates/client-web/src/api.ts index 43a07828..f548516f 100644 --- a/crates/client-web/src/api.ts +++ b/crates/client-web/src/api.ts @@ -787,7 +787,7 @@ function getMockJsonResponse(method: string, path: string, body?: unknown): T return searchMockItems(query) as T; } default: { - const itemMetadataSearchMatch = url.pathname.match(/^\/api\/v1\/items\/(\d+)\/metadata\/search$/); + const itemMetadataSearchMatch = /^\/api\/v1\/items\/(\d+)\/metadata\/search$/.exec(url.pathname); if (itemMetadataSearchMatch) { return searchMockItemMetadata( Number(itemMetadataSearchMatch[1]), @@ -795,7 +795,7 @@ function getMockJsonResponse(method: string, path: string, body?: unknown): T ) as T; } - const itemMetadataMatch = url.pathname.match(/^\/api\/v1\/items\/(\d+)\/metadata$/); + const itemMetadataMatch = /^\/api\/v1\/items\/(\d+)\/metadata$/.exec(url.pathname); if (itemMetadataMatch) { const itemMetadata = getMockItemMetadata(Number(itemMetadataMatch[1])); if (!itemMetadata) { @@ -805,17 +805,17 @@ function getMockJsonResponse(method: string, path: string, body?: unknown): T return itemMetadata as T; } - const itemPlaybackMatch = url.pathname.match(/^\/api\/v1\/items\/(\d+)\/playback$/); + const itemPlaybackMatch = /^\/api\/v1\/items\/(\d+)\/playback$/.exec(url.pathname); if (itemPlaybackMatch) { return getMockPlayback(Number(itemPlaybackMatch[1])) as T; } - const personMatch = url.pathname.match(/^\/api\/v1\/people\/(\d+)$/); + const personMatch = /^\/api\/v1\/people\/(\d+)$/.exec(url.pathname); if (personMatch) { return getMockPerson(Number(personMatch[1])) as T; } - const itemMatch = url.pathname.match(/^\/api\/v1\/items\/(\d+)$/); + const itemMatch = /^\/api\/v1\/items\/(\d+)$/.exec(url.pathname); if (itemMatch) { const item = getMockItem(Number(itemMatch[1])); if (!item) { @@ -825,7 +825,7 @@ function getMockJsonResponse(method: string, path: string, body?: unknown): T return item as T; } - const sessionStreamMatch = url.pathname.match(/^\/api\/v1\/sessions\/([^/]+)\/stream$/); + const sessionStreamMatch = /^\/api\/v1\/sessions\/([^/]+)\/stream$/.exec(url.pathname); if (sessionStreamMatch) { throw new Error('501 Not Implemented (mock streaming not fully supported)'); } @@ -839,7 +839,7 @@ function getMockJsonResponse(method: string, path: string, body?: unknown): T return updateMockSettings(body as SettingsSnapshot) as T; } - const updateUserMatch = url.pathname.match(/^\/api\/v1\/users\/(\d+)$/); + const updateUserMatch = /^\/api\/v1\/users\/(\d+)$/.exec(url.pathname); if (method === 'PUT' && updateUserMatch) { return updateMockUser(Number(updateUserMatch[1]), body as UpdateUserRequest) as T; } @@ -860,48 +860,48 @@ function getMockJsonResponse(method: string, path: string, body?: unknown): T return clearMockMetadataCache() as T; } - const scheduledTaskRunMatch = url.pathname.match(/^\/api\/v1\/scheduled-tasks\/([^/]+)\/run$/); + const scheduledTaskRunMatch = /^\/api\/v1\/scheduled-tasks\/([^/]+)\/run$/.exec(url.pathname); if (method === 'POST' && scheduledTaskRunMatch) { return runMockScheduledTask(scheduledTaskRunMatch[1] as ScheduledTaskId) as T; } - const removeLibraryMatch = url.pathname.match(/^\/api\/v1\/settings\/libraries\/(\d+)$/); + const removeLibraryMatch = /^\/api\/v1\/settings\/libraries\/(\d+)$/.exec(url.pathname); if (method === 'DELETE' && removeLibraryMatch) { return removeMockLibrary(Number(removeLibraryMatch[1])) as T; } - const missingItemsMatch = url.pathname.match(/^\/api\/v1\/libraries\/(\d+)\/missing$/); + const missingItemsMatch = /^\/api\/v1\/libraries\/(\d+)\/missing$/.exec(url.pathname); if (method === 'DELETE' && missingItemsMatch) { return deleteMockMissingItems(Number(missingItemsMatch[1])) as T; } - const deleteSessionMatch = url.pathname.match(/^\/api\/v1\/sessions\/([^/]+)$/); + const deleteSessionMatch = /^\/api\/v1\/sessions\/([^/]+)$/.exec(url.pathname); if (method === 'DELETE' && deleteSessionMatch) { return undefined as T; } - const itemProgressMatch = url.pathname.match(/^\/api\/v1\/items\/(\d+)\/progress$/); + const itemProgressMatch = /^\/api\/v1\/items\/(\d+)\/progress$/.exec(url.pathname); if (method === 'POST' && itemProgressMatch) { updateMockPlaybackProgress(Number(itemProgressMatch[1]), body as PlaybackProgressRequest); return undefined as T; } - const itemLinkMatch = url.pathname.match(/^\/api\/v1\/items\/(\d+)\/metadata\/link$/); + const itemLinkMatch = /^\/api\/v1\/items\/(\d+)\/metadata\/link$/.exec(url.pathname); if (method === 'POST' && itemLinkMatch) { return linkMockItemMetadata(Number(itemLinkMatch[1]), body as LinkMetadataRequest) as T; } - const itemRefreshMatch = url.pathname.match(/^\/api\/v1\/items\/(\d+)\/metadata\/refresh$/); + const itemRefreshMatch = /^\/api\/v1\/items\/(\d+)\/metadata\/refresh$/.exec(url.pathname); if (method === 'POST' && itemRefreshMatch) { return refreshMockItemMetadata(Number(itemRefreshMatch[1])) as T; } - const libraryRefreshMatch = url.pathname.match(/^\/api\/v1\/libraries\/(\d+)\/metadata\/refresh$/); + const libraryRefreshMatch = /^\/api\/v1\/libraries\/(\d+)\/metadata\/refresh$/.exec(url.pathname); if (method === 'POST' && libraryRefreshMatch) { return refreshMockLibraryMetadata(Number(libraryRefreshMatch[1])) as T; } - const libraryScanMatch = url.pathname.match(/^\/api\/v1\/libraries\/(\d+)\/scan$/); + const libraryScanMatch = /^\/api\/v1\/libraries\/(\d+)\/scan$/.exec(url.pathname); if (method === 'POST' && libraryScanMatch) { return refreshMockLibraryMetadata(Number(libraryScanMatch[1])) as T; } diff --git a/crates/client-web/src/app/routes.ts b/crates/client-web/src/app/routes.ts index f18ec8d9..1123c0e0 100644 --- a/crates/client-web/src/app/routes.ts +++ b/crates/client-web/src/app/routes.ts @@ -20,22 +20,22 @@ function browseKindFromSegment(segment: string): Extract Date: Thu, 11 Jun 2026 22:27:10 -0400 Subject: [PATCH 101/128] Fix sonar typescript:S4624 --- crates/client-web/src/app/eventBindings.ts | 3 ++- crates/client-web/src/app/homeView.ts | 12 +++++++++--- crates/client-web/src/app/itemPersonView.ts | 12 +++++++++--- crates/client-web/src/app/settingsView.ts | 3 ++- 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/crates/client-web/src/app/eventBindings.ts b/crates/client-web/src/app/eventBindings.ts index 134ba75c..5a051e53 100644 --- a/crates/client-web/src/app/eventBindings.ts +++ b/crates/client-web/src/app/eventBindings.ts @@ -516,7 +516,8 @@ function bindRenderEvents(context: AppEventBindingContext): void { document.querySelectorAll('[data-provider-settings]').forEach((button) => { button.addEventListener('click', () => { const providerId = button.dataset.providerSettings; - navigateTo(`/settings/providers${providerId ? `#provider-${providerId}` : ''}`); + const providerHash = providerId ? `#provider-${providerId}` : ''; + navigateTo(`/settings/providers${providerHash}`); }); }); diff --git a/crates/client-web/src/app/homeView.ts b/crates/client-web/src/app/homeView.ts index 061cceb1..451b3e59 100644 --- a/crates/client-web/src/app/homeView.ts +++ b/crates/client-web/src/app/homeView.ts @@ -370,10 +370,12 @@ export function playbackDetailBadgeMarkup(item: MediaItemSummary): string { const badges: string[] = []; if (watchCount > 0) { const watchedLabel = watchCount === 1 ? 'Watched' : `Watched ${watchCount}x`; - badges.push(`${renderIcon('circle-check', 'status-icon')}${escapeHtml(watchedLabel)}`); + const watchedTitle = item.last_watched_at ? `Last watched ${formatTimestamp(item.last_watched_at)}` : watchedLabel; + badges.push(`${renderIcon('circle-check', 'status-icon')}${escapeHtml(watchedLabel)}`); } if (progressPercent !== undefined) { - badges.push(`${escapeHtml(`${progressPercent}% watched`)}`); + const progressLabel = `${progressPercent}% watched`; + badges.push(`${escapeHtml(progressLabel)}`); } return badges.join(''); @@ -499,12 +501,16 @@ export function renderSearchResultRow(result: MediaSearchResult, compact: boolea const item = result.item; const posterUrl = getArtworkUrl(item.id, 'poster', item.artwork_updated_at); const library = state.libraries.find((entry) => entry.id === item.library_id); + const itemResultDetails = [library?.name ?? 'Library', humanizeItemType(item.item_type)]; + if (!compact) { + itemResultDetails.push(formatChildCount(item)); + } return ` diff --git a/crates/client-web/src/app/itemPersonView.ts b/crates/client-web/src/app/itemPersonView.ts index d350c612..231058c4 100644 --- a/crates/client-web/src/app/itemPersonView.ts +++ b/crates/client-web/src/app/itemPersonView.ts @@ -191,7 +191,8 @@ export function renderLinkedMetadataSummary(): string { .filter((provider): provider is MetadataProviderStatus => Boolean(provider?.attribution_text)) .map((provider) => { const logoUrl = providerAttributionLogo(provider.id); - return ``; + const logoMarkup = logoUrl ? `` : ''; + return ``; }) .join(''); @@ -519,6 +520,10 @@ export function renderPersonPage(): string { const credits = response.credits; const creditGroups = personCreditGroups(credits); const age = personAgeLabel(response.person.birthday, response.person.deathday); + const knownForTags = response.person.known_for + .map((title) => `${escapeHtml(title)}`) + .join(''); + const knownForMarkup = response.person.known_for.length ? `
${knownForTags}
` : ''; return `
@@ -536,7 +541,7 @@ export function renderPersonPage(): string { ${response.person.birth_place ? `

${escapeHtml(response.person.birth_place)}

` : ''} ${response.person.biography ? renderCollapsibleText(response.person.biography, `person-biography:${response.person.id}`) : ''} - ${response.person.known_for.length ? `
${response.person.known_for.map((title) => `${escapeHtml(title)}`).join('')}
` : ''} + ${knownForMarkup}
${response.person.profile_url ? `Provider page` : ''} @@ -750,8 +755,9 @@ export function renderItemPage(): string { const titleMarkup = logoUrl ? `` : `

${escapeHtml(state.selectedItem.display_title)}

`; + const resumeButtonLabel = `Resume ${formatDuration(resumeMs)}`; const resumeButtonMarkup = state.selectedItem.playable && resumeMs > 0 - ? `` + ? `` : ''; let playButtonMarkup = ''; if (state.selectedItem.playable) { diff --git a/crates/client-web/src/app/settingsView.ts b/crates/client-web/src/app/settingsView.ts index 900932db..7f26dbb4 100644 --- a/crates/client-web/src/app/settingsView.ts +++ b/crates/client-web/src/app/settingsView.ts @@ -163,8 +163,9 @@ export function renderExistingLibrariesSettings(settings: SettingsSnapshot): str const scanPendingTag = scanPending ? 'Scanning catalog' : ''; const missingItemsTagClass = hasMissingItems ? 'warning' : 'success'; const missingItemsLabel = hasMissingItems ? `${missingItems} missing items` : 'No missing items'; + const missingFilesLabel = `${missingFiles} missing files`; const missingFilesTag = missingFiles > 0 - ? `${escapeHtml(`${missingFiles} missing files`)}` + ? `${escapeHtml(missingFilesLabel)}` : ''; const scanButtonDisabled = scanPending ? 'disabled' : ''; const scanButtonLabel = scanPending ? 'Scanning' : 'Scan now'; From e7d0164a1b67158c39bed2826b71f54092c25c6d Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 11 Jun 2026 22:35:54 -0400 Subject: [PATCH 102/128] Fix sonar typescript:S4144 --- .../client-web/src/app/playbackController.ts | 70 +++++++------------ 1 file changed, 27 insertions(+), 43 deletions(-) diff --git a/crates/client-web/src/app/playbackController.ts b/crates/client-web/src/app/playbackController.ts index e2205472..3f4052b0 100644 --- a/crates/client-web/src/app/playbackController.ts +++ b/crates/client-web/src/app/playbackController.ts @@ -57,6 +57,9 @@ let trailerVolume = 1; let trailerMuted = false; +const ESCALATING_SEEK_STEPS = [10, 20, 30, 60, 120, 300] as const; +const ESCALATING_SEEK_WINDOW_MS = 900; + /** Renders the active playback overlay, including trailers and browser playback. */ export function renderPlayerOverlay(): string { if (state.activeTrailer) { @@ -633,6 +636,25 @@ function updateIconButton( createIcons({ icons }); } +/** Creates a seek handler that increases the step when repeated in one direction. */ +function createEscalatingSeekHandler(seekBy: (seconds: number) => void): (direction: number) => void { + let lastSkipDirection = 0; + let lastSkipAt = 0; + let skipStepIndex = 0; + + return (direction: number): void => { + const now = Date.now(); + if (direction !== 0 && direction === lastSkipDirection && now - lastSkipAt < ESCALATING_SEEK_WINDOW_MS) { + skipStepIndex = Math.min(ESCALATING_SEEK_STEPS.length - 1, skipStepIndex + 1); + } else { + skipStepIndex = 0; + } + lastSkipDirection = direction; + lastSkipAt = now; + seekBy(direction * ESCALATING_SEEK_STEPS[skipStepIndex]); + }; +} + function updateTrailerPlayerUi(): void { const player = trailerYouTubePlayer; if (!player) { @@ -691,12 +713,8 @@ export function bindTrailerPlayer(): void { const muteButton = document.querySelector('#trailer-mute-toggle'); const fullscreenButton = document.querySelector('#trailer-fullscreen'); const idleHitArea = document.querySelector('.trailer-idle-hit-area'); - const skipSteps = [10, 20, 30, 60, 120, 300]; let controlsHideHandle: number | undefined; let isScrubbing = false; - let lastSkipDirection = 0; - let lastSkipAt = 0; - let skipStepIndex = 0; const withTrailerPlayer = (action: (player: YouTubePlayer) => void): void => { if (trailerYouTubePlayer) { @@ -737,17 +755,7 @@ export function bindTrailerPlayer(): void { }); }; - const seekWithEscalation = (direction: number): void => { - const now = Date.now(); - if (direction !== 0 && direction === lastSkipDirection && now - lastSkipAt < 900) { - skipStepIndex = Math.min(skipSteps.length - 1, skipStepIndex + 1); - } else { - skipStepIndex = 0; - } - lastSkipDirection = direction; - lastSkipAt = now; - seekBy(direction * skipSteps[skipStepIndex]); - }; + const seekWithEscalation = createEscalatingSeekHandler(seekBy); const togglePlayback = (): void => { withTrailerPlayer((player) => { @@ -927,12 +935,8 @@ export function bindPlayerProgress(): void { const requestedPlaybackStartSeconds = Math.max(0, state.activePlaybackStartMs / 1000); const playbackBaseOffsetSeconds = isTranscoding ? requestedPlaybackStartSeconds : 0; const initialDirectSeekSeconds = isTranscoding ? 0 : requestedPlaybackStartSeconds; - const skipSteps = [10, 20, 30, 60, 120, 300]; let controlsHideHandle: number | undefined; let isScrubbing = false; - let lastSkipDirection = 0; - let lastSkipAt = 0; - let skipStepIndex = 0; let hasAppliedInitialDirectSeek = initialDirectSeekSeconds <= 0; const playbackDurationSeconds = (): number => { @@ -960,24 +964,14 @@ export function bindPlayerProgress(): void { shell?.classList.add('has-media-error'); }; - const setButtonIcon = (button: HTMLButtonElement | null | undefined, iconName: AppIconName, label: string): void => { - if (!button) { - return; - } - button.innerHTML = renderIcon(iconName, 'player-control-icon'); - button.title = label; - button.setAttribute('aria-label', label); - createIcons({ icons }); - }; - const updatePlayButtons = (): void => { const iconName: AppIconName = player.paused ? 'play' : 'pause'; const label = player.paused ? 'Play' : 'Pause'; - playButtons.forEach((button) => setButtonIcon(button, iconName, label)); + playButtons.forEach((button) => updateIconButton(button, iconName, label)); }; const updateMuteButton = (): void => { - setButtonIcon(muteButton, player.muted || player.volume === 0 ? 'volume-x' : 'volume-2', player.muted ? 'Unmute' : 'Mute'); + updateIconButton(muteButton, player.muted || player.volume === 0 ? 'volume-x' : 'volume-2', player.muted ? 'Unmute' : 'Mute'); if (volume && !isScrubbing) { volume.value = String(player.muted ? 0 : player.volume); } @@ -1049,18 +1043,6 @@ export function bindPlayerProgress(): void { }, 3200); }; - const seekWithEscalation = (direction: number): void => { - const now = Date.now(); - if (direction !== 0 && direction === lastSkipDirection && now - lastSkipAt < 900) { - skipStepIndex = Math.min(skipSteps.length - 1, skipStepIndex + 1); - } else { - skipStepIndex = 0; - } - lastSkipDirection = direction; - lastSkipAt = now; - seekBy(direction * skipSteps[skipStepIndex]); - }; - const seekBy = (seconds: number): void => { const currentPosition = playbackBaseOffsetSeconds + player.currentTime; const targetPosition = Math.max(0, currentPosition + seconds); @@ -1076,6 +1058,8 @@ export function bindPlayerProgress(): void { player.currentTime = Math.min(player.duration, targetPosition); }; + const seekWithEscalation = createEscalatingSeekHandler(seekBy); + const togglePlayback = (): void => { if (player.paused) { void player.play(); From 43a5ef87702f7404a9abedb9671ce4a9645c78d1 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:18:14 -0400 Subject: [PATCH 103/128] Fix sonar typescript:S3776 --- crates/client-web/src/api.ts | 420 +++++++++-------- crates/client-web/src/app/homeView.ts | 213 +++++---- crates/client-web/src/app/itemPersonView.ts | 442 ++++++++++++------ .../client-web/src/app/playbackController.ts | 281 +++++++---- crates/client-web/src/app/settingsView.ts | 177 ++++--- crates/client-web/src/app/youtube.ts | 105 +++-- 6 files changed, 1018 insertions(+), 620 deletions(-) diff --git a/crates/client-web/src/api.ts b/crates/client-web/src/api.ts index f548516f..0b566fc2 100644 --- a/crates/client-web/src/api.ts +++ b/crates/client-web/src/api.ts @@ -746,248 +746,304 @@ function useMockApi(): void { activeApiMode = 'mock'; } -function getMockJsonResponse(method: string, path: string, body?: unknown): T { - const url = new URL(path, 'http://koko.local'); +function getMockGetResponse(method: string, url: URL): T { + switch (url.pathname) { + case '/api/v1/system/capabilities': + return getMockCapabilities() as T; + case '/api/v1/bootstrap': + return getMockBootstrap() as T; + case '/api/v1/users': + return getMockUsers() as T; + case '/api/v1/libraries': + return getMockLibraries() as T; + case '/api/v1/metadata/providers': + return getMockMetadataProviders() as T; + case '/api/v1/system/activities': + return getMockSystemActivities() as T; + case '/api/v1/settings': + return getMockSettings() as T; + case '/api/v1/settings/logs': + return getMockLogs( + url.searchParams.get('level') ?? undefined, + url.searchParams.get('module') ?? undefined, + url.searchParams.get('search') ?? undefined, + url.searchParams.get('since') ?? undefined, + url.searchParams.get('until') ?? undefined, + url.searchParams.get('limit') ? Number(url.searchParams.get('limit')) : undefined, + ) as T; + case '/api/v1/home': + return getMockHome(optionalNumericSearchParam(url, 'library_id')) as T; + case '/api/v1/items': + return getMockItems(optionalNumericSearchParam(url, 'library_id')) as T; + case '/api/v1/search': + return searchMockItems(url.searchParams.get('query') ?? '') as T; + default: + return getMockDynamicGetResponse(method, url); + } +} + +function optionalNumericSearchParam(url: URL, name: string): number | undefined { + const value = url.searchParams.get(name); + return value ? Number(value) : undefined; +} + +function getMockDynamicGetResponse(method: string, url: URL): T { + const itemMetadataSearchMatch = /^\/api\/v1\/items\/(\d+)\/metadata\/search$/.exec(url.pathname); + if (itemMetadataSearchMatch) { + return searchMockItemMetadata( + Number(itemMetadataSearchMatch[1]), + url.searchParams.get('query') ?? undefined, + ) as T; + } + + const itemMetadataMatch = /^\/api\/v1\/items\/(\d+)\/metadata$/.exec(url.pathname); + if (itemMetadataMatch) { + const itemMetadata = getMockItemMetadata(Number(itemMetadataMatch[1])); + if (!itemMetadata) { + throw new Error('404 Not Found'); + } + + return itemMetadata as T; + } - if (method === 'GET') { - switch (url.pathname) { - case '/api/v1/system/capabilities': - return getMockCapabilities() as T; - case '/api/v1/bootstrap': - return getMockBootstrap() as T; - case '/api/v1/users': - return getMockUsers() as T; - case '/api/v1/libraries': - return getMockLibraries() as T; - case '/api/v1/metadata/providers': - return getMockMetadataProviders() as T; - case '/api/v1/system/activities': - return getMockSystemActivities() as T; - case '/api/v1/settings': - return getMockSettings() as T; - case '/api/v1/settings/logs': - return getMockLogs( - url.searchParams.get('level') ?? undefined, - url.searchParams.get('module') ?? undefined, - url.searchParams.get('search') ?? undefined, - url.searchParams.get('since') ?? undefined, - url.searchParams.get('until') ?? undefined, - url.searchParams.get('limit') ? Number(url.searchParams.get('limit')) : undefined, - ) as T; - case '/api/v1/home': { - const libraryId = url.searchParams.get('library_id'); - return getMockHome(libraryId ? Number(libraryId) : undefined) as T; - } - case '/api/v1/items': { - const libraryId = url.searchParams.get('library_id'); - return getMockItems(libraryId ? Number(libraryId) : undefined) as T; - } - case '/api/v1/search': { - const query = url.searchParams.get('query') ?? ''; - return searchMockItems(query) as T; - } - default: { - const itemMetadataSearchMatch = /^\/api\/v1\/items\/(\d+)\/metadata\/search$/.exec(url.pathname); - if (itemMetadataSearchMatch) { - return searchMockItemMetadata( - Number(itemMetadataSearchMatch[1]), - url.searchParams.get('query') ?? undefined, - ) as T; - } - - const itemMetadataMatch = /^\/api\/v1\/items\/(\d+)\/metadata$/.exec(url.pathname); - if (itemMetadataMatch) { - const itemMetadata = getMockItemMetadata(Number(itemMetadataMatch[1])); - if (!itemMetadata) { - throw new Error('404 Not Found'); - } - - return itemMetadata as T; - } - - const itemPlaybackMatch = /^\/api\/v1\/items\/(\d+)\/playback$/.exec(url.pathname); - if (itemPlaybackMatch) { - return getMockPlayback(Number(itemPlaybackMatch[1])) as T; - } - - const personMatch = /^\/api\/v1\/people\/(\d+)$/.exec(url.pathname); - if (personMatch) { - return getMockPerson(Number(personMatch[1])) as T; - } - - const itemMatch = /^\/api\/v1\/items\/(\d+)$/.exec(url.pathname); - if (itemMatch) { - const item = getMockItem(Number(itemMatch[1])); - if (!item) { - throw new Error('404 Not Found'); - } - - return item as T; - } - - const sessionStreamMatch = /^\/api\/v1\/sessions\/([^/]+)\/stream$/.exec(url.pathname); - if (sessionStreamMatch) { - throw new Error('501 Not Implemented (mock streaming not fully supported)'); - } - - throw new Error(`No mock response is defined for ${method} ${url.pathname}`); - } + const itemPlaybackMatch = /^\/api\/v1\/items\/(\d+)\/playback$/.exec(url.pathname); + if (itemPlaybackMatch) { + return getMockPlayback(Number(itemPlaybackMatch[1])) as T; + } + + const personMatch = /^\/api\/v1\/people\/(\d+)$/.exec(url.pathname); + if (personMatch) { + return getMockPerson(Number(personMatch[1])) as T; + } + + const itemMatch = /^\/api\/v1\/items\/(\d+)$/.exec(url.pathname); + if (itemMatch) { + const item = getMockItem(Number(itemMatch[1])); + if (!item) { + throw new Error('404 Not Found'); } + + return item as T; + } + + const sessionStreamMatch = /^\/api\/v1\/sessions\/([^/]+)\/stream$/.exec(url.pathname); + if (sessionStreamMatch) { + throw new Error('501 Not Implemented (mock streaming not fully supported)'); } - if (method === 'PUT' && url.pathname === '/api/v1/settings') { + throw new Error(`No mock response is defined for ${method} ${url.pathname}`); +} + +function getMockPutResponse(method: string, url: URL, body?: unknown): T { + if (url.pathname === '/api/v1/settings') { return updateMockSettings(body as SettingsSnapshot) as T; } const updateUserMatch = /^\/api\/v1\/users\/(\d+)$/.exec(url.pathname); - if (method === 'PUT' && updateUserMatch) { + if (updateUserMatch) { return updateMockUser(Number(updateUserMatch[1]), body as UpdateUserRequest) as T; } - if (method === 'POST' && url.pathname === '/login') { + throw new Error(`No mock response is defined for ${method} ${url.pathname}`); +} + +function getMockPostResponse(method: string, url: URL, body?: unknown): T { + if (url.pathname === '/login') { return loginMockUser(body as LoginRequest) as T; } - - if (method === 'POST' && url.pathname === '/create_user') { + if (url.pathname === '/create_user') { return createMockUser(body as CreateUserRequest) as T; } - - if (method === 'POST' && url.pathname === '/api/v1/settings/libraries') { + if (url.pathname === '/api/v1/settings/libraries') { return addMockLibrary(body as { library: MediaLibrarySettings }) as T; } - - if (method === 'POST' && url.pathname === '/api/v1/settings/metadata-cache/clear') { + if (url.pathname === '/api/v1/settings/metadata-cache/clear') { return clearMockMetadataCache() as T; } + if (url.pathname === '/api/v1/sessions') { + return createMockPlaybackSessionResponse(body); + } const scheduledTaskRunMatch = /^\/api\/v1\/scheduled-tasks\/([^/]+)\/run$/.exec(url.pathname); - if (method === 'POST' && scheduledTaskRunMatch) { + if (scheduledTaskRunMatch) { return runMockScheduledTask(scheduledTaskRunMatch[1] as ScheduledTaskId) as T; } - const removeLibraryMatch = /^\/api\/v1\/settings\/libraries\/(\d+)$/.exec(url.pathname); - if (method === 'DELETE' && removeLibraryMatch) { - return removeMockLibrary(Number(removeLibraryMatch[1])) as T; - } - - const missingItemsMatch = /^\/api\/v1\/libraries\/(\d+)\/missing$/.exec(url.pathname); - if (method === 'DELETE' && missingItemsMatch) { - return deleteMockMissingItems(Number(missingItemsMatch[1])) as T; - } - - const deleteSessionMatch = /^\/api\/v1\/sessions\/([^/]+)$/.exec(url.pathname); - if (method === 'DELETE' && deleteSessionMatch) { - return undefined as T; - } - const itemProgressMatch = /^\/api\/v1\/items\/(\d+)\/progress$/.exec(url.pathname); - if (method === 'POST' && itemProgressMatch) { + if (itemProgressMatch) { updateMockPlaybackProgress(Number(itemProgressMatch[1]), body as PlaybackProgressRequest); return undefined as T; } const itemLinkMatch = /^\/api\/v1\/items\/(\d+)\/metadata\/link$/.exec(url.pathname); - if (method === 'POST' && itemLinkMatch) { + if (itemLinkMatch) { return linkMockItemMetadata(Number(itemLinkMatch[1]), body as LinkMetadataRequest) as T; } const itemRefreshMatch = /^\/api\/v1\/items\/(\d+)\/metadata\/refresh$/.exec(url.pathname); - if (method === 'POST' && itemRefreshMatch) { + if (itemRefreshMatch) { return refreshMockItemMetadata(Number(itemRefreshMatch[1])) as T; } const libraryRefreshMatch = /^\/api\/v1\/libraries\/(\d+)\/metadata\/refresh$/.exec(url.pathname); - if (method === 'POST' && libraryRefreshMatch) { + if (libraryRefreshMatch) { return refreshMockLibraryMetadata(Number(libraryRefreshMatch[1])) as T; } const libraryScanMatch = /^\/api\/v1\/libraries\/(\d+)\/scan$/.exec(url.pathname); - if (method === 'POST' && libraryScanMatch) { + if (libraryScanMatch) { return refreshMockLibraryMetadata(Number(libraryScanMatch[1])) as T; } - if (method === 'POST' && url.pathname === '/api/v1/sessions') { - // Basic mock for create_session - const request = body as CreateSessionRequest; - const item = getMockItem(request.item_id); - const preferredLanguages = getMockBootstrap().current_user?.preferred_metadata_languages ?? ['en-US']; - const audioStreamIndex = item?.audio_tracks?.find((track) => { - const language = track.language?.toLowerCase(); - return language && preferredLanguages.some((preferred) => { - const normalized = preferred.toLowerCase(); - return normalized.startsWith(language) || language.startsWith(normalized.split('-')[0]); - }); - })?.index; - return { - session_id: 'mock-session-123', - item_id: request.item_id, - client_profile: request.client_profile, - decision: getMockPlayback(request.item_id), - created_at: Date.now(), - audio_stream_index: audioStreamIndex, - } as T; + throw new Error(`No mock response is defined for ${method} ${url.pathname}`); +} + +function createMockPlaybackSessionResponse(body?: unknown): T { + const request = body as CreateSessionRequest; + const item = getMockItem(request.item_id); + const preferredLanguages = getMockBootstrap().current_user?.preferred_metadata_languages ?? ['en-US']; + const audioStreamIndex = item?.audio_tracks?.find((track) => { + const language = track.language?.toLowerCase(); + return language && preferredLanguages.some((preferred) => { + const normalized = preferred.toLowerCase(); + return normalized.startsWith(language) || language.startsWith(normalized.split('-')[0]); + }); + })?.index; + + return { + session_id: 'mock-session-123', + item_id: request.item_id, + client_profile: request.client_profile, + decision: getMockPlayback(request.item_id), + created_at: Date.now(), + audio_stream_index: audioStreamIndex, + } as T; +} + +function getMockDeleteResponse(method: string, url: URL): T { + const removeLibraryMatch = /^\/api\/v1\/settings\/libraries\/(\d+)$/.exec(url.pathname); + if (removeLibraryMatch) { + return removeMockLibrary(Number(removeLibraryMatch[1])) as T; + } + + const missingItemsMatch = /^\/api\/v1\/libraries\/(\d+)\/missing$/.exec(url.pathname); + if (missingItemsMatch) { + return deleteMockMissingItems(Number(missingItemsMatch[1])) as T; + } + + const deleteSessionMatch = /^\/api\/v1\/sessions\/([^/]+)$/.exec(url.pathname); + if (deleteSessionMatch) { + return undefined as T; } throw new Error(`No mock response is defined for ${method} ${url.pathname}`); } +function getMockJsonResponse(method: string, path: string, body?: unknown): T { + const url = new URL(path, 'http://koko.local'); + + switch (method) { + case 'GET': + return getMockGetResponse(method, url); + case 'PUT': + return getMockPutResponse(method, url, body); + case 'POST': + return getMockPostResponse(method, url, body); + case 'DELETE': + return getMockDeleteResponse(method, url); + default: + throw new Error(`No mock response is defined for ${method} ${url.pathname}`); + } +} + +function getMockJsonFallback(method: string, path: string, body?: unknown): T { + useMockApi(); + return getMockJsonResponse(method, path, body); +} + +function requestHeaders(body?: unknown): Record { + const headers: Record = {}; + const token = getStoredAuthToken(); + if (body !== undefined) { + headers['Content-Type'] = 'application/json'; + } + if (token) { + headers.Authorization = `Bearer ${token}`; + } + return headers; +} + +async function fetchJsonResponse(method: string, path: string, body?: unknown): Promise { + const abortController = new AbortController(); + const timeoutHandle = globalThis.setTimeout(() => abortController.abort(), REQUEST_TIMEOUT_MS); + return fetch(`${getStoredApiBase()}${path}`, { + method, + headers: requestHeaders(body), + body: body === undefined ? undefined : JSON.stringify(body), + signal: abortController.signal, + }).finally(() => { + globalThis.clearTimeout(timeoutHandle); + }); +} + +async function responseError(response: Response): Promise { + const responseText = (await response.text()).trim(); + return new Error( + responseText + ? `${response.status} ${response.statusText}: ${responseText}` + : `${response.status} ${response.statusText}`, + ); +} + +async function handleErrorResponse(method: string, path: string, body: unknown, response: Response): Promise { + if (response.status === 401) { + clearStoredAuthToken(); + } + const error = await responseError(response); + if (import.meta.env.DEV) { + return getMockJsonFallback(method, path, body); + } + + throw error; +} + +function handleRequestFailure(method: string, path: string, body: unknown, error: unknown): T { + if (error instanceof DOMException && error.name === 'AbortError') { + throw new Error(`Request timed out after ${REQUEST_TIMEOUT_MS / 1000} seconds.`); + } + if (import.meta.env.DEV) { + return getMockJsonFallback(method, path, body); + } + + throw error; +} + +async function readJsonResponse(response: Response): Promise { + if (response.status === 204) { + return undefined as T; + } + if (response.headers.get('content-type')?.includes('application/json')) { + return response.json() as Promise; + } + + return undefined as T; +} + async function requestJson(method: string, path: string, body?: unknown): Promise { if (shouldUseMockApi()) { - useMockApi(); - return getMockJsonResponse(method, path, body); + return getMockJsonFallback(method, path, body); } try { - const abortController = new AbortController(); - const timeoutHandle = globalThis.setTimeout(() => abortController.abort(), REQUEST_TIMEOUT_MS); - const response = await fetch(`${getStoredApiBase()}${path}`, { - method, - headers: { - ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), - ...(getStoredAuthToken() ? { Authorization: `Bearer ${getStoredAuthToken()}` } : {}), - }, - body: body === undefined ? undefined : JSON.stringify(body), - signal: abortController.signal, - }).finally(() => { - globalThis.clearTimeout(timeoutHandle); - }); + const response = await fetchJsonResponse(method, path, body); if (!response.ok) { - if (response.status === 401) { - clearStoredAuthToken(); - } - const responseText = (await response.text()).trim(); - const error = new Error( - responseText - ? `${response.status} ${response.statusText}: ${responseText}` - : `${response.status} ${response.statusText}`, - ); - if (import.meta.env.DEV) { - useMockApi(); - return getMockJsonResponse(method, path, body); - } - - return Promise.reject(error); + return handleErrorResponse(method, path, body, response); } useLiveApi(); - if (response.status === 204) { - return undefined as T; - } - if (response.headers.get('content-type')?.includes('application/json')) { - return response.json() as Promise; - } - - return undefined as T; + return readJsonResponse(response); } catch (error) { - if (error instanceof DOMException && error.name === 'AbortError') { - throw new Error(`Request timed out after ${REQUEST_TIMEOUT_MS / 1000} seconds.`); - } - if (import.meta.env.DEV) { - useMockApi(); - return getMockJsonResponse(method, path, body); - } - - throw error; + return handleRequestFailure(method, path, body, error); } } diff --git a/crates/client-web/src/app/homeView.ts b/crates/client-web/src/app/homeView.ts index 451b3e59..c02a79db 100644 --- a/crates/client-web/src/app/homeView.ts +++ b/crates/client-web/src/app/homeView.ts @@ -1,5 +1,5 @@ /** Renders home, browse, shelf, and media-card markup. */ -import type { MediaItemSummary, MediaPlaybackTarget, MediaSearchResult, MediaShelf } from '../api'; +import type { MediaItemSummary, MediaLibrary, MediaPlaybackTarget, MediaSearchResult, MediaShelf } from '../api'; import { getArtworkUrl, getPersonImageUrl, resolveApiUrl } from '../api'; import { HOME_SHELF_CHUNK_SIZE } from './constants'; import { escapeHtml, formatTimestamp } from './format'; @@ -496,58 +496,64 @@ export function renderHomeFeature(): string { `; } -export function renderSearchResultRow(result: MediaSearchResult, compact: boolean): string { - if (result.result_type === 'item') { - const item = result.item; - const posterUrl = getArtworkUrl(item.id, 'poster', item.artwork_updated_at); - const library = state.libraries.find((entry) => entry.id === item.library_id); - const itemResultDetails = [library?.name ?? 'Library', humanizeItemType(item.item_type)]; - if (!compact) { - itemResultDetails.push(formatChildCount(item)); - } - return ` - - `; - } +type ItemSearchResult = Extract; +type CollectionSearchResult = Extract; +type PersonSearchResult = Extract; +type PlaylistSearchResult = Extract; - if (result.result_type === 'collection') { - const collection = result.collection; - const posterUrl = collection.artwork_url ?? collection.backdrop_url; - return ` - - `; +function renderItemSearchResultRow(result: ItemSearchResult, compact: boolean): string { + const item = result.item; + const posterUrl = getArtworkUrl(item.id, 'poster', item.artwork_updated_at); + const library = state.libraries.find((entry) => entry.id === item.library_id); + const itemResultDetails = [library?.name ?? 'Library', humanizeItemType(item.item_type)]; + if (!compact) { + itemResultDetails.push(formatChildCount(item)); } - if (result.result_type === 'person') { - const person = result.person; - const imageUrl = person.cached_image_path || person.image_url ? getPersonImageUrl(person.id) : undefined; - const knownFor = person.known_for.slice(0, 3).join(' · '); - return ` - - `; - } + return ` + + `; +} + +function renderCollectionSearchResultRow(result: CollectionSearchResult, compact: boolean): string { + const collection = result.collection; + const posterUrl = collection.artwork_url ?? collection.backdrop_url; + return ` + + `; +} + +function renderPersonSearchResultRow(result: PersonSearchResult, compact: boolean): string { + const person = result.person; + const imageUrl = person.cached_image_path || person.image_url ? getPersonImageUrl(person.id) : undefined; + const knownFor = person.known_for.slice(0, 3).join(' · '); + return ` + + `; +} +function renderPlaylistSearchResultRow(result: PlaylistSearchResult, compact: boolean): string { const playlist = result.playlist; return `
diff --git a/crates/client-web/src/app/itemPersonView.ts b/crates/client-web/src/app/itemPersonView.ts index 231058c4..34a6be37 100644 --- a/crates/client-web/src/app/itemPersonView.ts +++ b/crates/client-web/src/app/itemPersonView.ts @@ -1,5 +1,14 @@ /** Renders item detail, metadata search, person detail, and credit trays. */ -import type { ItemMetadataPerson, MediaItemExtra, MediaItemSummary, MetadataPersonItemCredit, MetadataProviderStatus } from '../api'; +import type { + ItemMetadataMatch, + ItemMetadataPerson, + MediaItemDetail, + MediaItemExtra, + MediaItemSummary, + MediaPlaybackTarget, + MetadataPersonItemCredit, + MetadataProviderStatus, +} from '../api'; import { getArtworkUrl, getPersonImageUrl, resolveApiUrl } from '../api'; import { escapeHtml, formatBitRate, formatDuration, formatFileSize, formatTimestamp } from './format'; import { normalizedMetadataLanguages } from './formUtils'; @@ -10,7 +19,7 @@ import { itemHasActiveMetadataRefresh, itemIsMetadataPending } from './activitie import { resumablePlaybackPositionMs } from './playbackProgress'; import { providerAttributionLogo, providerDisplayName } from './providers'; import { state } from './state'; -import type { PersonCreditGroup } from './types'; +import type { PersonCreditGroup, TrailerOption } from './types'; import { activeLibrary, activeLibrarySettings, @@ -691,148 +700,136 @@ export function bindPersonCreditTrays(): void { }); } -export function renderItemPage(): string { - if (!state.selectedItem) { - return '
Loading item details…
'; - } - - const posterUrl = state.selectedItem.poster_url - ? getArtworkUrl(state.selectedItem.id, 'poster', state.selectedItem.artwork_updated_at) +function selectedItemPosterUrl(item: MediaItemDetail): string | undefined { + return item.poster_url + ? getArtworkUrl(item.id, 'poster', item.artwork_updated_at) : undefined; - const trailerOptions = currentTrailerOptions(); - const preferredTrailer = trailerOptions[0]; - const hasMultipleTrailers = trailerOptions.length > 1; - const themeSongOption = currentThemeSongYouTubeTarget(); - const trailerButtonTitle = hasMultipleTrailers - ? 'Click to play the first trailer. Right-click or press and hold to choose another trailer.' - : 'Play Trailer'; - const playback = state.selectedPlayback; - const library = state.libraries.find((entry) => entry.id === state.selectedItem?.library_id); - const linkedMatch = state.selectedItemMetadata?.matches[0]; - const overview = state.selectedItem.overview - ?? linkedMatch?.overview +} + +function selectedItemLogoUrl(item: MediaItemDetail): string | undefined { + return item.logo_url ? resolveApiUrl(item.logo_url) : undefined; +} + +function selectedItemOverview(item: MediaItemDetail): string { + return item.overview + ?? state.selectedItemMetadata?.matches[0]?.overview ?? 'No description is stored for this item yet.'; - const genres = state.selectedItem.genres.length - ? state.selectedItem.genres - : []; - const logoUrl = state.selectedItem.logo_url ? resolveApiUrl(state.selectedItem.logo_url) : undefined; - const technicalFacts = [ - { label: 'Duration', value: formatDuration(state.selectedItem.duration_ms) }, - { - label: 'Format', - value: [state.selectedItem.container?.toUpperCase(), state.selectedItem.media_kind.toUpperCase()].filter(Boolean).join(' • ') || 'Unknown', - }, - { - label: 'Codecs', - value: [state.selectedItem.video_codec, state.selectedItem.audio_codec].filter(Boolean).join(' / ') || 'Unknown', - }, - { - label: 'Resolution', - value: state.selectedItem.width && state.selectedItem.height ? `${state.selectedItem.width}×${state.selectedItem.height}` : 'Unknown', - }, - { label: 'Bitrate', value: formatBitRate(state.selectedItem.bit_rate) }, - { label: 'Size', value: formatFileSize(state.selectedItem.file_size) }, - ]; - const hierarchy = state.selectedItem.hierarchy; - const children = state.selectedItem.children; - const backTarget = backNavigationTarget(); - const supportsManualLinking = canManuallyLinkMetadata(state.selectedItem); - const metadataRefreshActive = itemHasActiveMetadataRefresh(state.selectedItem); - const resumeMs = resumablePlaybackPositionMs(state.selectedItem); - const playbackTarget = !state.selectedItem.playable ? state.selectedItem.playback_target : undefined; - const restartPlaybackTarget = !state.selectedItem.playable ? state.selectedItem.restart_playback_target : undefined; - let childSectionTitle = 'Contained items'; - if (state.selectedItem.item_type === 'show') { - childSectionTitle = 'Seasons'; - } else if (state.selectedItem.item_type === 'season') { - childSectionTitle = 'Episodes'; - } - const itemHeroClass = state.selectedItem.item_type === 'episode' ? 'episode-hero' : ''; - const itemPosterClass = state.selectedItem.item_type === 'episode' ? 'item-thumbnail' : ''; - const posterMarkup = posterUrl - ? `${escapeHtml(state.selectedItem.display_title)} poster` - : `${escapeHtml(state.selectedItem.display_title.slice(0, 1).toUpperCase())}`; - const titleMarkup = logoUrl - ? `` - : `

${escapeHtml(state.selectedItem.display_title)}

`; - const resumeButtonLabel = `Resume ${formatDuration(resumeMs)}`; - const resumeButtonMarkup = state.selectedItem.playable && resumeMs > 0 - ? `` - : ''; - let playButtonMarkup = ''; - if (state.selectedItem.playable) { - const playButtonClass = resumeMs > 0 ? 'secondary-button' : ''; - const playButtonLabel = resumeMs > 0 ? 'Start over' : 'Play now'; - playButtonMarkup = ``; - } - const childCountLabel = countLabel(children.length, 'item'); - const childGridClass = state.selectedItem.item_type === 'season' ? 'season-episodes-grid' : ''; - const childrenSectionMarkup = children.length - ? ` -
-
-

${escapeHtml(childSectionTitle)}

- ${childCountLabel} -
-
${children.map(renderItemCard).join('')}
-
- ` - : ''; - let metadataRefreshButtonMarkup = ''; - if (supportsManualLinking) { - const refreshButtonDisabled = linkedMatch && !metadataRefreshActive ? '' : 'disabled'; - const refreshButtonLabel = metadataRefreshActive ? 'Refreshing metadata' : 'Force refresh metadata'; - metadataRefreshButtonMarkup = ``; - } - const metadataSearchPanel = supportsManualLinking - ? ` - - - ` - : '
Season and episode metadata is inherited and refreshed automatically from the linked show.
'; +} + +function selectedItemChildSectionTitle(item: MediaItemDetail): string { + if (item.item_type === 'show') { + return 'Seasons'; + } + + return item.item_type === 'season' ? 'Episodes' : 'Contained items'; +} + +function renderSelectedItemBreadcrumbs(item: MediaItemDetail): string { + if (!item.hierarchy.length) { + return ''; + } + return ` -
- ${hierarchy.length ? ` - ` : ''} -
-
- ${posterMarkup} -
-
- ${titleMarkup} - ${state.selectedItem.tagline ? `

${escapeHtml(state.selectedItem.tagline)}

` : ''} -
- ${missingItemDetailBadgeMarkup(state.selectedItem)} - ${playbackDetailBadgeMarkup(state.selectedItem)} - ${state.selectedItem.release_year ? `${state.selectedItem.release_year}` : ''} - ${state.selectedItem.content_rating ? `${escapeHtml(state.selectedItem.content_rating)}` : ''} - ${typeof state.selectedItem.rating === 'number' ? `${escapeHtml(state.selectedItem.rating.toFixed(1))}` : ''} - ${genres.map((genre) => `${escapeHtml(genre)}`).join('')} -
- ${renderCollapsibleText(overview, `item-overview:${state.selectedItem.id}`)} + `; +} + +function renderSelectedItemPoster(item: MediaItemDetail, posterUrl: string | undefined): string { + return posterUrl + ? `${escapeHtml(item.display_title)} poster` + : `${escapeHtml(item.display_title.slice(0, 1).toUpperCase())}`; +} + +function renderSelectedItemTitle(item: MediaItemDetail, logoUrl: string | undefined): string { + return logoUrl + ? `` + : `

${escapeHtml(item.display_title)}

`; +} + +function renderSelectedItemHeroMeta(item: MediaItemDetail, genres: string[]): string { + const tags = [ + missingItemDetailBadgeMarkup(item), + playbackDetailBadgeMarkup(item), + ]; + if (item.release_year) { + tags.push(`${item.release_year}`); + } + if (item.content_rating) { + tags.push(`${escapeHtml(item.content_rating)}`); + } + if (typeof item.rating === 'number') { + tags.push(`${escapeHtml(item.rating.toFixed(1))}`); + } + tags.push(...genres.map((genre) => `${escapeHtml(genre)}`)); + + return `
${tags.join('')}
`; +} + +function renderResumeButton(item: MediaItemDetail, resumeMs: number): string { + if (!item.playable || resumeMs <= 0) { + return ''; + } + + return ``; +} + +function renderPrimaryPlayButton(item: MediaItemDetail, resumeMs: number): string { + if (!item.playable) { + return ''; + } + + const playButtonClass = resumeMs > 0 ? 'secondary-button' : ''; + const playButtonLabel = resumeMs > 0 ? 'Start over' : 'Play now'; + return ``; +} + +function renderTrailerActionButton(preferredTrailer: TrailerOption | undefined, trailerButtonTitle: string): string { + return preferredTrailer + ? `` + : ''; +} + +function renderThemeSongButton(themeSongOption: ReturnType): string { + return themeSongOption + ? `` + : ''; +} + +function renderSelectedItemActions( + item: MediaItemDetail, + resumeMs: number, + playbackTarget: MediaPlaybackTarget | undefined, + restartPlaybackTarget: MediaPlaybackTarget | undefined, + preferredTrailer: TrailerOption | undefined, + themeSongOption: ReturnType, + trailerButtonTitle: string, + backTarget: ReturnType, +): string { + return `
- ${resumeButtonMarkup} - ${playButtonMarkup} + ${renderResumeButton(item, resumeMs)} + ${renderPrimaryPlayButton(item, resumeMs)} ${playbackTarget ? renderPlaybackTargetButton(playbackTarget, false) : ''} ${restartPlaybackTarget ? renderPlaybackTargetButton(restartPlaybackTarget, true) : ''} - ${preferredTrailer ? `` : ''} - ${themeSongOption ? `` : ''} + ${renderTrailerActionButton(preferredTrailer, trailerButtonTitle)} + ${renderThemeSongButton(themeSongOption)}
- ${hasMultipleTrailers && state.isTrailerMenuOpen ? ` + `; +} + +function renderTrailerPicker(trailerOptions: TrailerOption[], hasMultipleTrailers: boolean): string { + if (!hasMultipleTrailers || !state.isTrailerMenuOpen) { + return ''; + } + + return `

Choose a trailer

@@ -844,27 +841,129 @@ export function renderItemPage(): string { `).join('')}
- ` : ''} -

${escapeHtml(playback?.reason ?? 'Loading playback capabilities…')}

+ `; +} + +function selectedItemTechnicalFacts(item: MediaItemDetail): Array<{ label: string; value: string }> { + return [ + { label: 'Duration', value: formatDuration(item.duration_ms) }, + { + label: 'Format', + value: [item.container?.toUpperCase(), item.media_kind.toUpperCase()].filter(Boolean).join(' • ') || 'Unknown', + }, + { + label: 'Codecs', + value: [item.video_codec, item.audio_codec].filter(Boolean).join(' / ') || 'Unknown', + }, + { + label: 'Resolution', + value: item.width && item.height ? `${item.width}×${item.height}` : 'Unknown', + }, + { label: 'Bitrate', value: formatBitRate(item.bit_rate) }, + { label: 'Size', value: formatFileSize(item.file_size) }, + ]; +} + +function renderSelectedItemFactList(item: MediaItemDetail): string { + return `
- ${technicalFacts.map((fact) => ` + ${selectedItemTechnicalFacts(item).map((fact) => `
${escapeHtml(fact.label)} ${escapeHtml(fact.value)}
`).join('')}
+ `; +} + +function renderSelectedItemHero( + item: MediaItemDetail, + posterUrl: string | undefined, + logoUrl: string | undefined, + overview: string, + genres: string[], + actionsMarkup: string, + trailerPickerMarkup: string, +): string { + const itemHeroClass = item.item_type === 'episode' ? 'episode-hero' : ''; + const itemPosterClass = item.item_type === 'episode' ? 'item-thumbnail' : ''; + return ` +
+
+ ${renderSelectedItemPoster(item, posterUrl)} +
+
+ ${renderSelectedItemTitle(item, logoUrl)} + ${item.tagline ? `

${escapeHtml(item.tagline)}

` : ''} + ${renderSelectedItemHeroMeta(item, genres)} + ${renderCollapsibleText(overview, `item-overview:${item.id}`)} + ${actionsMarkup} + ${trailerPickerMarkup} +

${escapeHtml(state.selectedPlayback?.reason ?? 'Loading playback capabilities…')}

+ ${renderSelectedItemFactList(item)}
+ `; +} - ${renderPeopleRail()} +function renderSelectedItemChildrenSection(item: MediaItemDetail): string { + if (!item.children.length) { + return ''; + } - ${renderItemExtrasRail()} + const childCountLabel = countLabel(item.children.length, 'item'); + const childGridClass = item.item_type === 'season' ? 'season-episodes-grid' : ''; + return ` +
+
+

${escapeHtml(selectedItemChildSectionTitle(item))}

+ ${childCountLabel} +
+
${item.children.map(renderItemCard).join('')}
+
+ `; +} - ${childrenSectionMarkup} +function renderMetadataRefreshButton( + supportsManualLinking: boolean, + linkedMatch: ItemMetadataMatch | undefined, + metadataRefreshActive: boolean, +): string { + if (!supportsManualLinking) { + return ''; + } - ${renderSelectedItemCollectionRails()} + const refreshButtonDisabled = linkedMatch && !metadataRefreshActive ? '' : 'disabled'; + const refreshButtonLabel = metadataRefreshActive ? 'Refreshing metadata' : 'Force refresh metadata'; + return ``; +} + +function renderMetadataSearchPanel(supportsManualLinking: boolean): string { + if (!supportsManualLinking) { + return '
Season and episode metadata is inherited and refreshed automatically from the linked show.
'; + } + + return ` + + + `; +} +function renderSelectedItemSupportGrid( + item: MediaItemDetail, + library: ReturnType, + supportsManualLinking: boolean, + metadataRefreshButtonMarkup: string, + metadataSearchPanel: string, +): string { + return `
@@ -881,11 +980,11 @@ export function renderItemPage(): string {
Source - ${escapeHtml(state.selectedItem.relative_path)} + ${escapeHtml(item.relative_path)}
Updated - ${escapeHtml(formatTimestamp(state.selectedItem.modified_at))} + ${escapeHtml(formatTimestamp(item.modified_at))}
@@ -899,6 +998,67 @@ export function renderItemPage(): string { ${metadataSearchPanel}
+ `; +} + +function renderSelectedItemPage(item: MediaItemDetail): string { + const trailerOptions = currentTrailerOptions(); + const hasMultipleTrailers = trailerOptions.length > 1; + const supportsManualLinking = canManuallyLinkMetadata(item); + const linkedMatch = state.selectedItemMetadata?.matches[0]; + const metadataRefreshActive = itemHasActiveMetadataRefresh(item); + const resumeMs = resumablePlaybackPositionMs(item); + const trailerButtonTitle = hasMultipleTrailers + ? 'Click to play the first trailer. Right-click or press and hold to choose another trailer.' + : 'Play Trailer'; + const actionsMarkup = renderSelectedItemActions( + item, + resumeMs, + !item.playable ? item.playback_target ?? undefined : undefined, + !item.playable ? item.restart_playback_target ?? undefined : undefined, + trailerOptions[0], + currentThemeSongYouTubeTarget(), + trailerButtonTitle, + backNavigationTarget(), + ); + const metadataRefreshButtonMarkup = renderMetadataRefreshButton(supportsManualLinking, linkedMatch, metadataRefreshActive); + + return ` +
+ ${renderSelectedItemBreadcrumbs(item)} + ${renderSelectedItemHero( + item, + selectedItemPosterUrl(item), + selectedItemLogoUrl(item), + selectedItemOverview(item), + item.genres.length ? item.genres : [], + actionsMarkup, + renderTrailerPicker(trailerOptions, hasMultipleTrailers), + )} + + ${renderPeopleRail()} + + ${renderItemExtrasRail()} + + ${renderSelectedItemChildrenSection(item)} + + ${renderSelectedItemCollectionRails()} + + ${renderSelectedItemSupportGrid( + item, + state.libraries.find((entry) => entry.id === item.library_id), + supportsManualLinking, + metadataRefreshButtonMarkup, + renderMetadataSearchPanel(supportsManualLinking), + )}
`; } + +export function renderItemPage(): string { + if (!state.selectedItem) { + return '
Loading item details…
'; + } + + return renderSelectedItemPage(state.selectedItem); +} diff --git a/crates/client-web/src/app/playbackController.ts b/crates/client-web/src/app/playbackController.ts index 3f4052b0..30f6427c 100644 --- a/crates/client-web/src/app/playbackController.ts +++ b/crates/client-web/src/app/playbackController.ts @@ -1,7 +1,7 @@ /** Controls trailer, theme-song, and browser playback UI state. */ import { createIcons, icons } from 'lucide'; import type { AppIconName, ThemeSongSource, TrailerOption, YouTubePlayer } from './types'; -import type { MediaItemDetail } from '../api'; +import type { MediaAudioTrack, MediaItemDetail, PlaybackSession } from '../api'; import { createPlaybackSession, deletePlaybackSession, @@ -62,21 +62,15 @@ const ESCALATING_SEEK_WINDOW_MS = 900; /** Renders the active playback overlay, including trailers and browser playback. */ export function renderPlayerOverlay(): string { - if (state.activeTrailer) { - const videoId = extractYouTubeVideoId(state.activeTrailer.url); - const watchUrl = buildYouTubeWatchUrl(state.activeTrailer.url); - const label = state.activeTrailer.label ?? 'Trailer'; - const externalUrl = watchUrl ?? state.activeTrailer.url; - const externalLinkLabel = watchUrl ? 'Open on YouTube' : 'Open Source'; - const errorHint = watchUrl ? 'Open it on YouTube or try again in a moment.' : 'Open the source link or try another extra.'; - const itemTitle = state.selectedItem?.display_title.trim(); - const itemLogoUrl = state.selectedItem?.logo_url ? resolveApiUrl(state.selectedItem.logo_url) : undefined; - const trailerTitle = itemLogoUrl || !itemTitle - ? state.activeTrailer.title - : `${itemTitle} | ${state.activeTrailer.title}`; - const trailerVolumeValue = trailerMuted ? '0' : String(trailerVolume); - const trailerControlsMarkup = videoId - ? ` + return state.activeTrailer ? renderTrailerOverlay() : renderMediaPlayerOverlay(); +} + +function renderTrailerControlsMarkup(videoId: string | undefined): string { + if (!videoId) { + return ''; + } + + return `
@@ -90,20 +84,61 @@ export function renderPlayerOverlay(): string {
- +
- ` - : ''; + `; +} + +function renderTrailerFrameMarkup(videoId: string | undefined): string { + return videoId + ? '
' + : '
This external media URL is not a controllable YouTube video.
'; +} + +function renderTrailerTitleMarkup( + itemLogoUrl: string | undefined, + itemTitle: string | undefined, + trailerTitle: string, + brandedTrailerTitle: string, +): string { + if (itemLogoUrl) { return ` +
+ +

${escapeHtml(brandedTrailerTitle)}

+
+ `; + } + + return `

${escapeHtml(trailerTitle)}

`; +} + +function renderTrailerOverlay(): string { + const activeTrailer = state.activeTrailer; + if (!activeTrailer) { + return ''; + } + + const videoId = extractYouTubeVideoId(activeTrailer.url); + const watchUrl = buildYouTubeWatchUrl(activeTrailer.url); + const label = activeTrailer.label ?? 'Trailer'; + const externalUrl = watchUrl ?? activeTrailer.url; + const externalLinkLabel = watchUrl ? 'Open on YouTube' : 'Open Source'; + const errorHint = watchUrl ? 'Open it on YouTube or try again in a moment.' : 'Open the source link or try another extra.'; + const itemTitle = state.selectedItem?.display_title.trim(); + const itemLogoUrl = state.selectedItem?.logo_url ? resolveApiUrl(state.selectedItem.logo_url) : undefined; + const trailerTitle = itemLogoUrl || !itemTitle + ? activeTrailer.title + : `${itemTitle} | ${activeTrailer.title}`; + const trailerControlsMarkup = renderTrailerControlsMarkup(videoId); + return `
-
- ${videoId - ? '
' - : '
This external media URL is not a controllable YouTube video.
'} +
+ ${renderTrailerFrameMarkup(videoId)}
@@ -117,12 +152,7 @@ export function renderPlayerOverlay(): string {
${escapeHtml(label)} - ${itemLogoUrl ? ` -
- -

${escapeHtml(state.activeTrailer.title)}

-
- ` : `

${escapeHtml(trailerTitle)}

`} + ${renderTrailerTitleMarkup(itemLogoUrl, itemTitle, trailerTitle, activeTrailer.title)}
${externalUrl ? `${renderButtonContent(externalLinkLabel, 'arrow-right')}` : ''} @@ -133,97 +163,160 @@ export function renderPlayerOverlay(): string {
`; - } +} - const playbackItem = state.activePlaybackItem ?? state.selectedItem; - if (!state.isPlayerOpen || !playbackItem || !state.activePlaybackSession) { +function renderSubtitleTrackMarkup(playbackItem: MediaItemDetail, isAudio: boolean): string { + if (isAudio) { return ''; } - const isAudio = playbackItem.media_kind === 'audio'; - const tag = isAudio ? 'audio' : 'video'; - const isExplicitAudioTrackSelection = state.activeAudioStreamIndex !== undefined; - const selectedAudioStreamIndex = isExplicitAudioTrackSelection - ? state.activeAudioStreamIndex - : state.activePlaybackSession.audio_stream_index; - const posterUrl = playbackItem.poster_url - ? getArtworkUrl(playbackItem.id, 'poster', playbackItem.artwork_updated_at) - : undefined; - const backdropUrl = playbackItem.backdrop_url - ? getArtworkUrl(playbackItem.id, 'backdrop', playbackItem.artwork_updated_at) - : posterUrl; - const logoUrl = playbackItem.logo_url ? resolveApiUrl(playbackItem.logo_url) : undefined; - const trackMarkup = tag === 'video' - ? playbackItem.subtitle_tracks - .map((track) => ``) - .join('') - : ''; + return playbackItem.subtitle_tracks + .map((track) => ``) + .join(''); +} + +function renderMediaElementMarkup(isAudio: boolean, source: string, posterUrl: string | undefined, trackMarkup: string): string { + if (!isAudio) { + return ` + + `; + } - const isAudioStreamOverride = selectedAudioStreamIndex !== undefined && selectedAudioStreamIndex > 0; - const isRemuxingForAudio = isAudioStreamOverride && !state.activePlaybackSession.decision.transcode_required; - const streamStartMs = state.activePlaybackSession.decision.transcode_required || isRemuxingForAudio - ? state.activePlaybackStartMs - : 0; - const source = getSessionStreamUrl(state.activePlaybackSession.session_id, streamStartMs, selectedAudioStreamIndex); - const transcodeReason = isRemuxingForAudio - ? 'Using a non-default audio track requires a remuxed stream.' - : state.activePlaybackSession.decision.reason; - const transcodeBadge = state.activePlaybackSession.decision.transcode_required || isRemuxingForAudio - ? `Transcoding` - : `Direct Play`; - const audioTracks = playbackItem.audio_tracks ?? []; - const activeAudioTrack = audioTracks.find((track) => track.index === selectedAudioStreamIndex) - ?? audioTracks.find((track) => track.default) - ?? audioTracks[0]; - const audioTrackMenuTitle = activeAudioTrack - ? `Audio track: ${activeAudioTrack.label}` - : 'Audio track changes may require remuxing'; const audioArtMarkup = posterUrl ? `` : renderIcon('music', 'audio-player-art-icon'); const audioArtClass = posterUrl ? 'has-image' : ''; - const mediaElementMarkup = isAudio - ? ` + return `
${audioArtMarkup}
- ` - : ` - `; - let audioTrackMenuMarkup = ''; - if (!isAudio && audioTracks.length > 1) { - const audioTrackMenuExpanded = state.isAudioTrackMenuOpen ? 'true' : 'false'; - const audioTrackMenuClass = state.isAudioTrackMenuOpen ? '' : 'is-hidden'; - const audioTrackMenuHidden = state.isAudioTrackMenuOpen ? '' : 'hidden'; - const audioTrackOptions = audioTracks.map((track) => { - const isActiveTrack = track.index === activeAudioTrack?.index; - const activeTrackClass = isActiveTrack ? 'active' : ''; - const activeTrackChecked = isActiveTrack ? 'true' : 'false'; - const trackDetail = [track.language?.toUpperCase(), track.codec?.toUpperCase()].filter(Boolean).join(' · ') - || (track.default ? 'Default' : 'Audio'); - return ` +} + +function activeAudioTrackForSelection(audioTracks: MediaAudioTrack[], selectedAudioStreamIndex: number | undefined): MediaAudioTrack | undefined { + return audioTracks.find((track) => track.index === selectedAudioStreamIndex) + ?? audioTracks.find((track) => track.default) + ?? audioTracks[0]; +} + +function renderAudioTrackOptions(audioTracks: MediaAudioTrack[], activeAudioTrack: MediaAudioTrack | undefined): string { + return audioTracks.map((track) => { + const isActiveTrack = track.index === activeAudioTrack?.index; + const activeTrackClass = isActiveTrack ? 'active' : ''; + const activeTrackChecked = isActiveTrack ? 'true' : 'false'; + const trackDetail = [track.language?.toUpperCase(), track.codec?.toUpperCase()].filter(Boolean).join(' · ') + || (track.default ? 'Default' : 'Audio'); + return ` `; - }).join(''); - audioTrackMenuMarkup = ` + }).join(''); +} + +function renderAudioTrackMenuMarkup(isAudio: boolean, audioTracks: MediaAudioTrack[], activeAudioTrack: MediaAudioTrack | undefined): string { + if (isAudio || audioTracks.length <= 1) { + return ''; + } + + const audioTrackMenuTitle = activeAudioTrack + ? `Audio track: ${activeAudioTrack.label}` + : 'Audio track changes may require remuxing'; + const audioTrackMenuExpanded = state.isAudioTrackMenuOpen ? 'true' : 'false'; + const audioTrackMenuClass = state.isAudioTrackMenuOpen ? '' : 'is-hidden'; + const audioTrackMenuHidden = state.isAudioTrackMenuOpen ? '' : 'hidden'; + return `
`; +} + +function renderPlayerTitleMarkup(logoUrl: string | undefined, title: string): string { + return logoUrl + ? `` + : `

${escapeHtml(title)}

`; +} + +function renderTranscodeBadge(isRemuxingForAudio: boolean): string { + const session = state.activePlaybackSession; + if (!session) { + return ''; + } + + const transcodeReason = isRemuxingForAudio + ? 'Using a non-default audio track requires a remuxed stream.' + : session.decision.reason; + return session.decision.transcode_required || isRemuxingForAudio + ? `Transcoding` + : `Direct Play`; +} + +function selectedAudioStreamIndexForPlayback(session: PlaybackSession): number | undefined { + return state.activeAudioStreamIndex ?? session.audio_stream_index; +} + +function playbackPosterUrl(playbackItem: MediaItemDetail): string | undefined { + return playbackItem.poster_url + ? getArtworkUrl(playbackItem.id, 'poster', playbackItem.artwork_updated_at) + : undefined; +} + +function playbackBackdropUrl(playbackItem: MediaItemDetail, posterUrl: string | undefined): string | undefined { + return playbackItem.backdrop_url + ? getArtworkUrl(playbackItem.id, 'backdrop', playbackItem.artwork_updated_at) + : posterUrl; +} + +function mediaStreamStartMs(session: PlaybackSession, isRemuxingForAudio: boolean): number { + return session.decision.transcode_required || isRemuxingForAudio + ? state.activePlaybackStartMs + : 0; +} + +function mediaPlayerShellClass(isAudio: boolean): string { + return isAudio ? 'audio-player-shell' : 'video-player-shell'; +} + +function playerBackdropStyle(backdropUrl: string | undefined): string { + return backdropUrl ? `style="--player-backdrop-image: url('${escapeHtml(backdropUrl)}');"` : ''; +} + +function renderPictureInPictureButton(isAudio: boolean): string { + return isAudio + ? '' + : ``; +} + +function renderMediaPlayerOverlay(): string { + const playbackItem = state.activePlaybackItem ?? state.selectedItem; + const playbackSession = state.activePlaybackSession; + if (!state.isPlayerOpen || !playbackItem || !playbackSession) { + return ''; } + const isAudio = playbackItem.media_kind === 'audio'; + const selectedAudioStreamIndex = selectedAudioStreamIndexForPlayback(playbackSession); + const posterUrl = playbackPosterUrl(playbackItem); + const backdropUrl = playbackBackdropUrl(playbackItem, posterUrl); + const logoUrl = playbackItem.logo_url ? resolveApiUrl(playbackItem.logo_url) : undefined; + const isAudioStreamOverride = selectedAudioStreamIndex !== undefined && selectedAudioStreamIndex > 0; + const isRemuxingForAudio = isAudioStreamOverride && !playbackSession.decision.transcode_required; + const source = getSessionStreamUrl(playbackSession.session_id, mediaStreamStartMs(playbackSession, isRemuxingForAudio), selectedAudioStreamIndex); + const audioTracks = playbackItem.audio_tracks ?? []; + const activeAudioTrack = activeAudioTrackForSelection(audioTracks, selectedAudioStreamIndex); + const mediaElementMarkup = renderMediaElementMarkup(isAudio, source, posterUrl, renderSubtitleTrackMarkup(playbackItem, isAudio)); + const audioTrackMenuMarkup = renderAudioTrackMenuMarkup(isAudio, audioTracks, activeAudioTrack); + return `
-
+
${mediaElementMarkup}
@@ -236,12 +329,10 @@ export function renderPlayerOverlay(): string {
Now playing - ${logoUrl - ? `` - : `

${escapeHtml(playbackItem.display_title)}

`} + ${renderPlayerTitleMarkup(logoUrl, playbackItem.display_title)}
- ${transcodeBadge} + ${renderTranscodeBadge(isRemuxingForAudio)}
@@ -260,7 +351,7 @@ export function renderPlayerOverlay(): string { ${audioTrackMenuMarkup} - ${isAudio ? '' : ``} + ${renderPictureInPictureButton(isAudio)}
diff --git a/crates/client-web/src/app/settingsView.ts b/crates/client-web/src/app/settingsView.ts index 7f26dbb4..13a880e4 100644 --- a/crates/client-web/src/app/settingsView.ts +++ b/crates/client-web/src/app/settingsView.ts @@ -1,5 +1,5 @@ /** Renders settings sections and converts settings forms into API payloads. */ -import type { MetadataProviderSettings, ScheduledTaskId, SettingsSnapshot } from '../api'; +import type { MediaLibrary, MediaLibrarySettings, MetadataProviderSettings, MetadataProviderStatus, ScheduledTaskId, SettingsSnapshot } from '../api'; import { escapeHtml } from './format'; import { formDataString, formDataStrings, joinPaths, normalizedMetadataLanguages, parseBoundedInteger, parsePathsInput } from './formUtils'; import { hasActiveLibraryScan, libraryRefreshProgress } from './activities'; @@ -80,37 +80,48 @@ export function userPermissionSelect(name: string, allowedUserIds?: number[]): s `; } -export function metadataProviderCheckboxes(prefix: string, selectedProviders: string[], libraryKind?: string): string { - const providers = libraryProviderOptions(libraryKind) - .sort((left, right) => { - const leftIndex = selectedProviders.indexOf(left.id); - const rightIndex = selectedProviders.indexOf(right.id); - let roleOrder = 0; - if (left.role !== right.role) { - roleOrder = left.role === 'primary' ? -1 : 1; - } - return roleOrder - || (leftIndex < 0 ? Number.MAX_SAFE_INTEGER : leftIndex) - - (rightIndex < 0 ? Number.MAX_SAFE_INTEGER : rightIndex) - || left.display_name.localeCompare(right.display_name); - }); - const selected = new Set(selectedProviders); - let primaryPriority = 0; +function metadataProviderSortIndex(selectedProviders: string[], providerId: string): number { + const selectedIndex = selectedProviders.indexOf(providerId); + return selectedIndex < 0 ? Number.MAX_SAFE_INTEGER : selectedIndex; +} + +function metadataProviderRoleOrder(provider: MetadataProviderStatus): number { + return provider.role === 'primary' ? 0 : 1; +} + +function compareMetadataProviderOptions(selectedProviders: string[]): (left: MetadataProviderStatus, right: MetadataProviderStatus) => number { + return (left, right) => { + return metadataProviderRoleOrder(left) - metadataProviderRoleOrder(right) + || metadataProviderSortIndex(selectedProviders, left.id) - metadataProviderSortIndex(selectedProviders, right.id) + || left.display_name.localeCompare(right.display_name); + }; +} + +function renderPrimaryProviderMoveButtons(label: string, isSecondary: boolean): string { + return isSecondary + ? '' + : ` + + + `; +} + +function renderMetadataProviderOption( + prefix: string, + provider: MetadataProviderStatus, + selected: Set, + primaryPriority: number, +): string { + const providerId = provider.id; + const label = provider.display_name; + const isSecondary = provider.role === 'secondary'; + const secondaryAvailable = isSecondary + ? provider.extends_provider_ids.some((primaryProviderId) => selected.has(primaryProviderId)) + : true; + const checked = selected.has(providerId) && secondaryAvailable; + const providerPriorityLabel = isSecondary ? 'Secondary' : `Priority ${primaryPriority}`; return ` - @@ -338,7 +348,7 @@ export function renderScheduledTasksPage(settings: SettingsSnapshot): string {

Metadata refresh

- + ${renderScheduledStatusTag(scheduled.metadata_refresh.enabled, 'Scheduled', 'Manual')} ${renderScheduledTaskRunButton('metadata_refresh')}
@@ -364,7 +374,7 @@ export function renderScheduledTasksPage(settings: SettingsSnapshot): string {

Trash cleanup

- ${scheduled.trash_cleanup.enabled ? 'Scheduled' : 'Manual'} + ${renderScheduledStatusTag(scheduled.trash_cleanup.enabled, 'Scheduled', 'Manual', 'warning')} ${renderScheduledTaskRunButton('trash_cleanup')}
@@ -388,7 +398,7 @@ export function renderScheduledTasksPage(settings: SettingsSnapshot): string {

Database maintenance

- ${scheduled.database_maintenance.enabled ? 'Scheduled' : 'Manual'} + ${renderScheduledStatusTag(scheduled.database_maintenance.enabled, 'Scheduled', 'Manual')} ${renderScheduledTaskRunButton('database_maintenance')}
@@ -719,7 +729,7 @@ export function buildSettingsFromForm(formData: FormData): SettingsSnapshot | un : normalizedMetadataLanguages(library.metadata_languages), allowed_user_ids: formData.has(`existing_library_allowed_user_${index}`) ? formData.getAll(`existing_library_allowed_user_${index}`) - .map((value) => Number(value)) + .map(Number) .filter((value) => Number.isFinite(value) && value > 0) : library.allowed_user_ids, }; diff --git a/crates/client-web/src/app/youtube.ts b/crates/client-web/src/app/youtube.ts index c5fcc40c..2f5208f1 100644 --- a/crates/client-web/src/app/youtube.ts +++ b/crates/client-web/src/app/youtube.ts @@ -45,7 +45,7 @@ function isYouTubeHost(host: string): boolean { function extractVideoIdFromParsedUrl(parsed: URL): string | undefined { const host = parsed.hostname.toLowerCase().replace(/^www\./, ''); if (host === 'youtu.be') { - return validYouTubeVideoId(parsed.pathname.split('/').filter(Boolean)[0]); + return validYouTubeVideoId(parsed.pathname.split('/').find((segment) => segment.length > 0)); } if (!isYouTubeHost(host)) { diff --git a/crates/client-web/src/mockApi.ts b/crates/client-web/src/mockApi.ts index 43e6a731..0f308fea 100644 --- a/crates/client-web/src/mockApi.ts +++ b/crates/client-web/src/mockApi.ts @@ -3,6 +3,7 @@ import type { BootstrapUser, CreateUserRequest, ItemMetadataMatch, + ItemMetadataPerson, ItemMetadataResponse, LoginRequest, LinkMetadataRequest, @@ -15,6 +16,7 @@ import type { MediaSearchResult, MissingItemsCleanupResponse, MetadataProviderStatus, + MetadataPersonItemCredit, MetadataPersonResponse, MetadataSearchResult, LogEntriesResponse, @@ -1323,32 +1325,50 @@ export function linkMockItemMetadata(itemId: number, request: LinkMetadataReques return linkedMatch; } +function getMockPersonCreditsForMatch( + itemId: number, + item: MediaItemSummary, + match: ItemMetadataMatch, + personId: number, +): MetadataPersonItemCredit[] { + return match.people + .filter((person) => person.person_id === personId) + .map((person) => mockPersonCredit(itemId, item, match, person)); +} + +function mockPersonCredit( + itemId: number, + item: MediaItemSummary, + match: ItemMetadataMatch, + person: ItemMetadataPerson, +): MetadataPersonItemCredit { + return { + credit: { + id: person.id, + metadata_link_id: match.id, + media_item_id: itemId, + role: person.role, + department: person.department, + character_name: person.character_name, + sort_order: person.sort_order, + }, + item, + hierarchy: item.hierarchy ?? [], + }; +} + +function getMockPersonCreditsForResponse(response: ItemMetadataResponse, personId: number): MetadataPersonItemCredit[] { + const item = items.find((candidate) => candidate.id === response.item_id); + if (!item) { + return []; + } + + return response.matches.flatMap((match) => getMockPersonCreditsForMatch(response.item_id, item, match, personId)); +} + export function getMockPerson(personId: number): MetadataPersonResponse { const credits = Object.values(itemMetadata) - .flatMap((response) => response.matches.flatMap((match) => { - return match.people - .filter((person) => person.person_id === personId) - .flatMap((person) => { - const item = items.find((candidate) => candidate.id === response.item_id); - if (!item) { - return []; - } - - return [{ - credit: { - id: person.id, - metadata_link_id: match.id, - media_item_id: response.item_id, - role: person.role, - department: person.department, - character_name: person.character_name, - sort_order: person.sort_order, - }, - item, - hierarchy: item.hierarchy ?? [], - }]; - }); - })); + .flatMap((response) => getMockPersonCreditsForResponse(response, personId)); const firstCredit = credits[0]; const personCredit = Object.values(itemMetadata) .flatMap((response) => response.matches) From 43c6b757626f95825a5ac1e630c038ab9f203010 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:38:21 -0400 Subject: [PATCH 105/128] Fix sonar css:S4666 --- crates/client-web/src/style.css | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/crates/client-web/src/style.css b/crates/client-web/src/style.css index 55cd3881..cebb3d85 100644 --- a/crates/client-web/src/style.css +++ b/crates/client-web/src/style.css @@ -430,7 +430,6 @@ legend { justify-content: center; } -.library-rail.collapsed .brand-block > div:last-child, .library-rail.collapsed .brand-block>div:last-child, .library-rail.collapsed .rail-label, .library-rail.collapsed .rail-user-card { @@ -1867,6 +1866,11 @@ legend { } .metadata-search-card { + display: flex; + justify-content: flex-start; + gap: 1rem; + align-items: start; + width: 100%; padding: 0.9rem 1rem; border-radius: 16px; background: rgba(255, 255, 255, 0.04); @@ -1931,14 +1935,6 @@ legend { object-fit: contain; } -.metadata-search-card { - display: flex; - justify-content: flex-start; - gap: 1rem; - align-items: start; - width: 100%; -} - .metadata-search-card > div { flex: 1; min-width: 0; @@ -2059,18 +2055,15 @@ legend { } .settings-system-activity { + display: flex; + flex-direction: column; + gap: 0.8rem; padding: 1rem; border-radius: 18px; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.06); } -.settings-system-activity { - display: flex; - flex-direction: column; - gap: 0.8rem; -} - .settings-system-activity-header { display: flex; justify-content: space-between; @@ -2998,7 +2991,6 @@ legend { min-width: 0; } - .content-navbar-actions-stack, .content-navbar-actions-stack { flex-direction: column; } From daab7b42eafa608a30ed5a286c4bc801cdacd945 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 12 Jun 2026 21:49:17 -0400 Subject: [PATCH 106/128] Separate and style media-card badges Wrap metadata and playback badges in distinct containers and only render a single dynamic-badges block when needed. homeView.ts now conditionally builds a .media-card-dynamic-badges wrapper containing .media-card-state-badges and .media-card-playback-badges spans (instead of duplicating wrappers), improving markup clarity and avoiding empty wrappers. style.css updates adjust legend alignment and add styles for the new badge containers so state badges and playback badges can wrap, space correctly, and have playback badges aligned to the right for improved layout and responsiveness. --- crates/client-web/src/app/homeView.ts | 30 +++++++++++++++++++++++---- crates/client-web/src/style.css | 15 +++++++++++++- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/crates/client-web/src/app/homeView.ts b/crates/client-web/src/app/homeView.ts index c02a79db..6f2ea730 100644 --- a/crates/client-web/src/app/homeView.ts +++ b/crates/client-web/src/app/homeView.ts @@ -405,6 +405,30 @@ export function itemCardSubtitle(item: MediaItemSummary): string | undefined { return undefined; } +function mediaCardBadgeGroup(markup: string, className: string): string { + if (!markup) { + return ''; + } + + return `${markup}`; +} + +function mediaCardDynamicBadges(badgeMarkup: string, playbackBadgeMarkup: string): string { + const badgeGroups = [ + mediaCardBadgeGroup(badgeMarkup, 'media-card-state-badges'), + mediaCardBadgeGroup(playbackBadgeMarkup, 'media-card-playback-badges'), + ].join(''); + if (!badgeGroups) { + return ''; + } + + return ` + + ${badgeGroups} + + `; +} + export function renderItemCard(item: MediaItemSummary): string { const library = state.libraries.find((entry) => entry.id === item.library_id); const artworkItemId = item.artwork_item_id ?? item.id; @@ -425,9 +449,7 @@ export function renderItemCard(item: MediaItemSummary): string { const metricMarkup = item.missing_since ? missingItemBadgeMarkup(item) : `${escapeHtml(formatChildCount(item))}`; - const badgeMarkup = metadataBadgeMarkup(item); - const playbackBadgeMarkup = playbackStatusBadgeMarkup(item); - const dynamicBadges = `${badgeMarkup}${playbackBadgeMarkup}`; + const dynamicBadges = mediaCardDynamicBadges(metadataBadgeMarkup(item), playbackStatusBadgeMarkup(item)); return ` diff --git a/crates/client-web/src/app/types.ts b/crates/client-web/src/app/types.ts index de8f379a..60ba8a42 100644 --- a/crates/client-web/src/app/types.ts +++ b/crates/client-web/src/app/types.ts @@ -171,6 +171,7 @@ export type AppIconName = | 'house' | 'image' | 'languages' + | 'layers' | 'layout-grid' | 'link-2' | 'log-in' From 29ad77b9c2ccf7b567be2a4ae861adb460fcf652 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 12 Jun 2026 23:33:54 -0400 Subject: [PATCH 108/128] Add cargo-deny license check and deny.toml Add a new CI job (dependency_policy) that sets up Rust, installs cargo-deny, and runs `cargo deny check licenses -A no-license-field` to enforce license policy in CI (actions versions pinned). Add deny.toml with the allowed license list, an exception for webpki-root-certs, include-dev enabled, and private packages ignored. Remove the custom test_dependencies test module and its mod reference in crates/server/tests/main.rs since license validation is now handled by cargo-deny. --- .github/workflows/ci.yml | 21 +++++ crates/server/tests/main.rs | 1 - crates/server/tests/test_dependencies/mod.rs | 88 -------------------- deny.toml | 43 ++++++++++ 4 files changed, 64 insertions(+), 89 deletions(-) delete mode 100644 crates/server/tests/test_dependencies/mod.rs create mode 100644 deny.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e262000c..c432fa97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,27 @@ jobs: with: github_token: ${{ secrets.GITHUB_TOKEN }} + dependency_policy: + name: Dependency Policy + permissions: + contents: read + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Setup Rust + uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0 + with: + cache: true + cache-on-failure: false + + - name: Install cargo-deny + run: cargo install --locked cargo-deny + + - name: Check licenses + run: cargo deny check licenses + build: strategy: fail-fast: false diff --git a/crates/server/tests/main.rs b/crates/server/tests/main.rs index 2a3161b3..84122922 100644 --- a/crates/server/tests/main.rs +++ b/crates/server/tests/main.rs @@ -1,5 +1,4 @@ pub mod test_auth; -pub mod test_dependencies; pub mod test_media; pub mod test_metadata; pub mod test_tray; diff --git a/crates/server/tests/test_dependencies/mod.rs b/crates/server/tests/test_dependencies/mod.rs deleted file mode 100644 index 73d2e053..00000000 --- a/crates/server/tests/test_dependencies/mod.rs +++ /dev/null @@ -1,88 +0,0 @@ -// lib imports -use rstest::rstest; - -// local imports -use koko::dependencies::get_dependencies; - -#[rstest] -#[case("Apache-2.0")] -#[case("BSD-2-Clause")] -#[case("BSD-3-Clause")] -#[case("CC0-1.0")] -#[case("ISC")] -#[case("MIT")] -#[case("MPL-2.0")] -#[case("NCSA")] -#[case("Unicode-3.0")] -#[case("Unlicense")] -#[case("Zlib")] -fn test_individual_license_compatibility(#[case] license: &str) { - assert!( - is_license_compatible(license), - "License '{}' should be compatible", - license - ); -} - -#[rstest] -#[case("GPL-3.0")] -#[case("AGPL-3.0")] -#[case("Custom License")] -#[case("Proprietary")] -fn test_individual_license_incompatibility(#[case] license: &str) { - assert!( - !is_license_compatible(license), - "License '{}' should be incompatible", - license - ); -} - -fn is_license_compatible(license: &str) -> bool { - let compatible_licenses = vec![ - // compatible: https://www.gnu.org/licenses/license-list.en.html#GPLCompatibleLicenses - // format: https://spdx.github.io/license-list-data/ - "Apache-2.0", - "BSD-2-Clause", - "BSD-3-Clause", - "CC0-1.0", - "ISC", - "MIT", - "MPL-2.0", - "NCSA", - "Unicode-3.0", - "Unlicense", - "Zlib", - ]; - - compatible_licenses.iter().any(|&l| license.contains(l)) -} - -/// Deps that are allowed to have incompatible licenses. -fn dependency_exceptions() -> Vec<&'static str> { - vec![ - "koko", - "xtask", - "dlopen2_derive", // https://github.com/OpenByteDev/dlopen2/issues/20 - "ring", // https://github.com/briansmith/ring/blob/main/LICENSE - "webpki-root-certs", - ] -} - -#[test] -fn test_dependencies_licenses() { - let dependencies = get_dependencies().unwrap(); - - for package in dependencies { - if dependency_exceptions().contains(&package.name.as_str()) { - continue; - } - - let license = package.license.as_deref().unwrap_or(""); - assert!( - is_license_compatible(license), - "License '{}' of package {} is not compatible", - license, - package.name - ); - } -} diff --git a/deny.toml b/deny.toml new file mode 100644 index 00000000..5b5ff889 --- /dev/null +++ b/deny.toml @@ -0,0 +1,43 @@ +[licenses] +include-dev = true +unused-allowed-license = "allow" + +# cargo-deny's license check is allow-list based: every license not listed here +# or in a scoped exception is rejected. This intentionally excludes copyleft, +# proprietary, and custom license terms such as GPL-3.0, AGPL-3.0, LGPL, and +# non-SPDX "Custom License" or "Proprietary" declarations. +allow = [ + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "CC0-1.0", + "ISC", + "MIT", + "MPL-2.0", + "NCSA", + "Unicode-3.0", + "Unlicense", + "Zlib", +] + +exceptions = [ + { crate = "webpki-root-certs", allow = ["CDLA-Permissive-2.0"] }, +] + +[[licenses.clarify]] +crate = "cfg_block" +expression = "Apache-2.0" +license-files = [ + { path = "LICENSE", hash = 0xc9663a1e }, +] + +[[licenses.clarify]] +crate = "dlopen2_derive" +expression = "MIT" +license-files = [ + { path = "LICENSE", hash = 0xea2c3d1e }, +] + +[licenses.private] +ignore = true From 04283cd1f0252b701bbfdf630d0aa60bc4220b51 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 13 Jun 2026 15:29:17 -0400 Subject: [PATCH 109/128] CI: build web client; fix logs test timestamp Add Node setup and a web client build step to CI: configure actions/setup-node (Node 24) with npm caching and run npm ci && npm run build in crates/client-web (cache-dependency-path points to crates/client-web/package-lock.json). Update test in crates/server to use a full ISO timestamp (including seconds and timezone) in the logs query so the expected request matches normalized timestamps. --- .github/workflows/ci.yml | 13 +++++++++++++ crates/server/tests/test_web/routes/settings.rs | 4 +++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c432fa97..5c02a526 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -302,6 +302,13 @@ jobs: cache: true cache-on-failure: false + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '24' + cache: npm + cache-dependency-path: crates/client-web/package-lock.json + # TODO: it may be possible to use cargo-bin in the future to install cargo dependencies, # but right now it doesn't work without a lock file # https://github.com/dustinblackman/cargo-run-bin/issues/27 @@ -319,6 +326,12 @@ jobs: cargo update --workspace cargo metadata --locked --no-deps --format-version 1 > /dev/null + - name: Build web client + working-directory: crates/client-web + run: | + npm ci --ignore-scripts + npm run build + - name: Test id: test run: | diff --git a/crates/server/tests/test_web/routes/settings.rs b/crates/server/tests/test_web/routes/settings.rs index 2095c7ea..70f9c527 100644 --- a/crates/server/tests/test_web/routes/settings.rs +++ b/crates/server/tests/test_web/routes/settings.rs @@ -350,7 +350,9 @@ async fn test_get_logs_route_filters_and_normalizes_paths() { let filtered_response = make_request( Some(&client), "get", - &format!("/api/v1/settings/logs?search={unique}&since=2026-04-22T11%3A06&limit=10"), + &format!( + "/api/v1/settings/logs?search={unique}&since=2026-04-22T11%3A06%3A00-04%3A00&limit=10" + ), None, None, Some(Status::Ok), From a8f211db1a2ffbe7e3e3b09f32716331bfb16d27 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 13 Jun 2026 16:17:13 -0400 Subject: [PATCH 110/128] Refactor media/metadata helpers and small cleanups Multiple refactors and small cleanups across server crates: simplify Db fairing call by passing function pointer; introduce ProviderEpisodeMetadataSnapshotFetch and MetadataCollectionUpsert structs and update related fetch/upsert APIs to accept structured args; change playback_target_from_episode signature and update callers; simplify various sorts and string normalizations (use sort_by_key, Reverse, and replace with char array); initialize transcode ffmpeg args with vec! literal; make web_client_dist_dir use find; and tweak log formatting for scan output. These changes improve clarity, reduce boilerplate, and centralize parameters for future maintenance. --- crates/server/src/db/mod.rs | 2 +- crates/server/src/media.rs | 33 ++------ crates/server/src/metadata/mod.rs | 109 +++++++++++++++++-------- crates/server/src/transcode.rs | 14 ++-- crates/server/src/web/routes/common.rs | 10 +-- crates/server/src/web/routes/media.rs | 33 ++++---- 6 files changed, 111 insertions(+), 90 deletions(-) diff --git a/crates/server/src/db/mod.rs b/crates/server/src/db/mod.rs index 176bc203..b98573ba 100644 --- a/crates/server/src/db/mod.rs +++ b/crates/server/src/db/mod.rs @@ -311,7 +311,7 @@ impl Fairing for ReleaseDatabase { rocket: &Rocket, ) { if let Some(conn) = DbConn::get_one(rocket).await { - conn.run(|c| release_sqlite_database_lock(c)).await; + conn.run(release_sqlite_database_lock).await; } } } diff --git a/crates/server/src/media.rs b/crates/server/src/media.rs index 4b3c8ca8..a2ffc7d9 100644 --- a/crates/server/src/media.rs +++ b/crates/server/src/media.rs @@ -2075,7 +2075,7 @@ pub fn upsert_show_metadata_descendant_items( .map(move |episode| ((season_number, episode.episode_number), episode)) }) .collect::>(); - episode_numbers.sort_by(|left, right| left.0.cmp(&right.0)); + episode_numbers.sort_by_key(|entry| entry.0); episode_numbers.dedup_by(|left, right| left.0 == right.0); let mut materialized_season_numbers = episode_numbers @@ -4115,13 +4115,8 @@ fn container_playback_targets( } if let Some((episode, progress, _)) = latest_resume { - let primary = playback_target_from_episode( - item, - episode, - progress.position_ms, - true, - show_has_started, - ); + let primary = + playback_target_from_episode(episode, progress.position_ms, true, show_has_started); let restart = restart_playback_target(item, first_episode, &primary, show_has_started); return Ok(ContainerPlaybackTargets { primary: Some(primary), @@ -4137,7 +4132,7 @@ fn container_playback_targets( .is_none_or(|progress| progress.watch_count == 0) }) .unwrap_or(first_episode); - let primary = playback_target_from_episode(item, next_episode, 0, false, show_has_started); + let primary = playback_target_from_episode(next_episode, 0, false, show_has_started); let restart = restart_playback_target(item, first_episode, &primary, show_has_started); Ok(ContainerPlaybackTargets { @@ -4156,14 +4151,7 @@ fn restart_playback_target( return None; } - Some(playback_target_from_episode( - container, - first_episode, - 0, - false, - false, - )) - .map(|mut target| { + Some(playback_target_from_episode(first_episode, 0, false, false)).map(|mut target| { target.label = if container.item_type == "season" { "Start season".into() } else { @@ -4174,7 +4162,6 @@ fn restart_playback_target( } fn playback_target_from_episode( - container: &MediaItem, episode: &PlayableEpisodeTarget, start_ms: i64, resume: bool, @@ -4186,8 +4173,6 @@ fn playback_target_from_episode( format!("Resume {episode_label}") } else if show_has_started { format!("Play next {episode_label}") - } else if container.item_type == "season" { - format!("Play {episode_label}") } else { format!("Play {episode_label}") }; @@ -4440,7 +4425,7 @@ fn sort_recently_added(items: &[MediaItemSummary]) -> Vec { .filter(|item| item.child_count == 0) .cloned() .collect::>(); - leaf_items.sort_by(|left, right| right.modified_at.cmp(&left.modified_at)); + leaf_items.sort_by_key(|item| std::cmp::Reverse(item.modified_at)); for item in leaf_items { if item.item_type == "episode" { @@ -4644,11 +4629,7 @@ fn codec_matches( } fn normalize_codec_name(codec: &str) -> String { - let normalized = codec - .trim() - .to_ascii_lowercase() - .replace('-', "") - .replace('_', ""); + let normalized = codec.trim().to_ascii_lowercase().replace(['-', '_'], ""); match normalized.as_str() { "avc1" | "avc" | "h264" | "x264" => "h264".into(), diff --git a/crates/server/src/metadata/mod.rs b/crates/server/src/metadata/mod.rs index fbea876d..9512211d 100644 --- a/crates/server/src/metadata/mod.rs +++ b/crates/server/src/metadata/mod.rs @@ -461,6 +461,22 @@ impl Default for MetadataSnapshotFetchOptions { } } +/// Inputs for fetching a provider episode metadata snapshot. +pub struct ProviderEpisodeMetadataSnapshotFetch<'a> { + /// Provider show identifier that owns the episode. + pub show_external_id: &'a str, + /// Season number for the requested episode. + pub season_number: i32, + /// Episode number within the requested season. + pub episode_number: i32, + /// Provider episode identifier when the source has one. + pub episode_external_id: Option<&'a str>, + /// Koko locale key requested for the snapshot. + pub locale_key: &'a str, + /// Fetch behavior options. + pub options: MetadataSnapshotFetchOptions, +} + /// Provider-normalized metadata fields that are persisted into Koko tables. #[derive(Debug, Clone, Default, PartialEq)] pub struct ProviderMetadataDetails { @@ -1773,12 +1789,14 @@ pub async fn fetch_provider_episode_metadata_snapshot_for_locale( fetch_provider_episode_metadata_snapshot_for_locale_with_options( settings, provider_id, - show_external_id, - season_number, - episode_number, - episode_external_id, - locale_key, - MetadataSnapshotFetchOptions::FULL, + ProviderEpisodeMetadataSnapshotFetch { + show_external_id, + season_number, + episode_number, + episode_external_id, + locale_key, + options: MetadataSnapshotFetchOptions::FULL, + }, ) .await } @@ -1787,13 +1805,16 @@ pub async fn fetch_provider_episode_metadata_snapshot_for_locale( pub async fn fetch_provider_episode_metadata_snapshot_for_locale_with_options( settings: &MetadataSettings, provider_id: MetadataProviderId, - show_external_id: &str, - season_number: i32, - episode_number: i32, - episode_external_id: Option<&str>, - locale_key: &str, - options: MetadataSnapshotFetchOptions, + fetch: ProviderEpisodeMetadataSnapshotFetch<'_>, ) -> Result { + let ProviderEpisodeMetadataSnapshotFetch { + show_external_id, + season_number, + episode_number, + episode_external_id, + locale_key, + options, + } = fetch; let locale_key = normalize_locale_key(locale_key); let episode_external_key = episode_external_id.unwrap_or_default(); let season_number_key = season_number.to_string(); @@ -2788,14 +2809,16 @@ pub fn upsert_secondary_collection_theme_song_url( }; let secondary_collection_id = upsert_metadata_collection( conn, - provider_id.as_storage_value(), - &source_collection.source_provider_id, - &source_collection.source_external_id, - "secondary", - DEFAULT_METADATA_LOCALE, - None, - secondary_collection, - now, + MetadataCollectionUpsert { + provider_id: provider_id.as_storage_value(), + source_provider_id: &source_collection.source_provider_id, + source_external_id: &source_collection.source_external_id, + relation_kind: "secondary", + locale_key: DEFAULT_METADATA_LOCALE, + provider_locale_key: None, + collection: secondary_collection, + now, + }, )?; diesel::delete( @@ -4021,14 +4044,16 @@ fn sync_item_metadata_collections( let collection_external_id = collection.external_id.clone(); let collection_id = upsert_metadata_collection( conn, - &provider_id, - &provider_id, - &collection_external_id, - "primary", - &snapshot.locale_key, - snapshot.provider_locale_key.clone(), - collection, - now, + MetadataCollectionUpsert { + provider_id: &provider_id, + source_provider_id: &provider_id, + source_external_id: &collection_external_id, + relation_kind: "primary", + locale_key: &snapshot.locale_key, + provider_locale_key: snapshot.provider_locale_key.clone(), + collection, + now, + }, )?; if !seen_collection_ids.insert(collection_id) { continue; @@ -4056,17 +4081,31 @@ fn sync_item_metadata_collections( Ok(()) } -fn upsert_metadata_collection( - conn: &mut SqliteConnection, - provider_id: &str, - source_provider_id: &str, - source_external_id: &str, - relation_kind: &str, - locale_key: &str, +struct MetadataCollectionUpsert<'a> { + provider_id: &'a str, + source_provider_id: &'a str, + source_external_id: &'a str, + relation_kind: &'a str, + locale_key: &'a str, provider_locale_key: Option, collection: ProviderMetadataCollection, now: i64, +} + +fn upsert_metadata_collection( + conn: &mut SqliteConnection, + upsert: MetadataCollectionUpsert<'_>, ) -> Result { + let MetadataCollectionUpsert { + provider_id, + source_provider_id, + source_external_id, + relation_kind, + locale_key, + provider_locale_key, + collection, + now, + } = upsert; use crate::db::schema::metadata_collections::dsl as collections_dsl; let theme_song_url = collection.theme_song_url.clone(); diff --git a/crates/server/src/transcode.rs b/crates/server/src/transcode.rs index 665cbcdf..8f6f711f 100644 --- a/crates/server/src/transcode.rs +++ b/crates/server/src/transcode.rs @@ -80,14 +80,14 @@ impl TranscodeSpec { &self, output_target: &str, ) -> Vec { - let mut args = Vec::new(); - // Avoid writing banner and stats - args.push("-hide_banner".into()); - args.push("-loglevel".into()); - args.push("warning".into()); - args.push("-fflags".into()); - args.push("+genpts".into()); + let mut args = vec![ + "-hide_banner".into(), + "-loglevel".into(), + "warning".into(), + "-fflags".into(), + "+genpts".into(), + ]; if let Some(start_time) = self.start_time_ms { let start_sec = start_time as f64 / 1000.0; diff --git a/crates/server/src/web/routes/common.rs b/crates/server/src/web/routes/common.rs index 8f55ac5d..8634c0b9 100644 --- a/crates/server/src/web/routes/common.rs +++ b/crates/server/src/web/routes/common.rs @@ -63,13 +63,9 @@ fn web_client_index_path() -> Option { } fn web_client_dist_dir() -> Option { - for candidate in web_client_dist_candidates() { - if candidate.is_dir() { - return Some(candidate); - } - } - - None + web_client_dist_candidates() + .into_iter() + .find(|candidate| candidate.is_dir()) } fn web_client_dist_candidates() -> Vec { diff --git a/crates/server/src/web/routes/media.rs b/crates/server/src/web/routes/media.rs index 0499d3a8..a0c61c6b 100644 --- a/crates/server/src/web/routes/media.rs +++ b/crates/server/src/web/routes/media.rs @@ -116,6 +116,7 @@ use crate::metadata::{ MetadataSearchResult, MetadataSnapshotFetchOptions, ProviderDescendantTarget, + ProviderEpisodeMetadataSnapshotFetch, ProviderMetadataPerson, StoredMetadataSnapshot, expected_artwork_cache_path, @@ -1791,12 +1792,14 @@ async fn fetch_metadata_refresh_snapshots_for_language( fetch_provider_episode_metadata_snapshot_for_locale_with_options( &settings.metadata, target.provider_id.clone(), - show_external_id, - *season_number, - *episode_number, - None, - language, - fetch_options, + ProviderEpisodeMetadataSnapshotFetch { + show_external_id, + season_number: *season_number, + episode_number: *episode_number, + episode_external_id: None, + locale_key: language, + options: fetch_options, + }, ) .await } @@ -1825,12 +1828,14 @@ async fn fetch_metadata_refresh_snapshots_for_language( fetch_provider_episode_metadata_snapshot_for_locale_with_options( &settings.metadata, target.provider_id.clone(), - show_external_id, - *season_number, - *episode_number, - Some(episode_external_id), - language, - fetch_options, + ProviderEpisodeMetadataSnapshotFetch { + show_external_id, + season_number: *season_number, + episode_number: *episode_number, + episode_external_id: Some(episode_external_id), + locale_key: language, + options: fetch_options, + }, ) .await } @@ -3077,11 +3082,11 @@ pub async fn scan_library( Ok(Some(summary)) => { log::info!( "Completed manual media library catalog scan for library {} ({}): {} file(s), \ - status {}", + status {:?}", summary.id, summary.name, summary.total_files, - format!("{:?}", summary.status) + summary.status ); false } From 0992d3d4d68c0dd06c9c12ef097d6faf659a6179 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 13 Jun 2026 17:30:37 -0400 Subject: [PATCH 111/128] Refactor CI into reusable workflow components Add reusable GitHub workflows for Clippy, Coverage, Release and Rust builds (ci-clippy.yml, ci-coverage.yml, ci-release.yml, ci-rust.yml) and refactor .github/workflows/ci.yml to call them via workflow_call. The new ci-rust workflow centralizes matrix builds, cross-compilation setup, testing, coverage collection and artifact publishing; ci-coverage and ci-release handle Codecov upload and release creation respectively. This modularizes the CI, reduces duplication, and makes the build/test/release steps easier to maintain and reuse. --- .github/workflows/ci-clippy.yml | 37 +++ .github/workflows/ci-coverage.yml | 44 ++++ .github/workflows/ci-release.yml | 48 ++++ .github/workflows/ci-rust.yml | 362 ++++++++++++++++++++++++++ .github/workflows/ci.yml | 408 +++++------------------------- 5 files changed, 548 insertions(+), 351 deletions(-) create mode 100644 .github/workflows/ci-clippy.yml create mode 100644 .github/workflows/ci-coverage.yml create mode 100644 .github/workflows/ci-release.yml create mode 100644 .github/workflows/ci-rust.yml diff --git a/.github/workflows/ci-clippy.yml b/.github/workflows/ci-clippy.yml new file mode 100644 index 00000000..2a7c0ecf --- /dev/null +++ b/.github/workflows/ci-clippy.yml @@ -0,0 +1,37 @@ +--- +name: CI-Clippy +permissions: {} + +on: + workflow_call: + +jobs: + clippy: + name: Clippy + permissions: + contents: read + runs-on: ubuntu-latest + env: + CARGO_TERM_COLOR: always + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libayatana-appindicator3-dev \ + libglib2.0-dev \ + libgtk-3-dev \ + libxdo-dev + + - name: Setup Rust + uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0 + with: + components: clippy + cache: true + cache-on-failure: false + + - name: Clippy + run: cargo clippy --locked -- -D warnings diff --git a/.github/workflows/ci-coverage.yml b/.github/workflows/ci-coverage.yml new file mode 100644 index 00000000..7a5e9c89 --- /dev/null +++ b/.github/workflows/ci-coverage.yml @@ -0,0 +1,44 @@ +--- +name: CI-Coverage +permissions: {} + +on: + workflow_call: + secrets: + CODECOV_TOKEN: + required: false + +jobs: + coverage: + strategy: + fail-fast: false + matrix: + target: + - x86_64-unknown-linux-gnu + - aarch64-unknown-linux-gnu + - x86_64-apple-darwin + - aarch64-apple-darwin + - x86_64-pc-windows-msvc + name: Coverage (${{ matrix.target }}) + permissions: + contents: read + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Download coverage artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: coverage-${{ matrix.target }} + path: _coverage + + - name: Upload coverage + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + disable_search: true + fail_ci_if_error: true + files: ./_coverage/cobertura.xml + flags: ${{ matrix.target }} + token: ${{ secrets.CODECOV_TOKEN }} + verbose: true diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml new file mode 100644 index 00000000..b4d6f5eb --- /dev/null +++ b/.github/workflows/ci-release.yml @@ -0,0 +1,48 @@ +--- +name: CI-Release +permissions: {} + +on: + workflow_call: + inputs: + release_body: + required: true + type: string + release_generate_release_notes: + required: true + type: string + release_tag: + required: true + type: string + secrets: + GH_BOT_TOKEN: + required: true + +jobs: + release: + name: Release + permissions: + contents: read + runs-on: ubuntu-latest + steps: + - name: Download build artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: artifacts + pattern: koko-* + merge-multiple: true + + - name: Debug artifacts + run: ls -l artifacts + + - name: Create/Update GitHub Release + uses: LizardByte/actions/actions/release_create@200eaeb897a2b065a65cb6f16b41077432007490 # v2026.605.34721 + with: + allowUpdates: true + body: ${{ inputs.release_body }} + draft: true + generateReleaseNotes: ${{ inputs.release_generate_release_notes }} + name: ${{ inputs.release_tag }} + prerelease: true + tag: ${{ inputs.release_tag }} + token: ${{ secrets.GH_BOT_TOKEN }} diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml new file mode 100644 index 00000000..17f0c7cb --- /dev/null +++ b/.github/workflows/ci-rust.yml @@ -0,0 +1,362 @@ +--- +name: CI-Rust +permissions: {} + +on: + workflow_call: + inputs: + job_name: + required: true + type: string + publish_release: + default: false + required: false + type: boolean + release_version: + default: '' + required: false + type: string + run_build: + required: true + type: boolean + run_tests: + required: true + type: boolean + +jobs: + rust: + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu # Debian + os: ubuntu-latest + container: '' + shell: bash + cargo_env: '' + # TODO: Fix compiling for musl + # - target: x86_64-unknown-linux-musl # Alpine + # os: ubuntu-latest + # container: alpine:latest + # shell: sh + # cargo_env: "source $HOME/.cargo/env" + - target: aarch64-unknown-linux-gnu # Debian + os: ubuntu-24.04-arm + container: '' + shell: bash + cargo_env: '' + # TODO: Fix cross compiling for the below targets + # - target: aarch64-unknown-linux-musl # Alpine + # os: ubuntu-24.04-arm + # container: alpine:latest + # shell: sh + # cargo_env: "source $HOME/.cargo/env" + # - target: armv7-unknown-linux-gnueabihf # Raspberry Pi 2-5/Debian + # os: ubuntu-24.04-arm + # shell: bash + # - target: armv7-unknown-linux-musleabihf # Raspberry Pi 2-5/Alpine + # os: ubuntu-24.04-arm + # container: alpine:latest + # shell: sh + # cargo_env: "source $HOME/.cargo/env" + # - target: arm-unknown-linux-gnueabihf # Raspberry Pi 0-1/Debian + # os: ubuntu-24.04-arm + # shell: bash + # - target: arm-unknown-linux-musleabihf # Raspberry Pi 0-1/Alpine + # os: ubuntu-24.04-arm + # container: alpine:latest + # shell: sh + # cargo_env: "source $HOME/.cargo/env" + - target: x86_64-apple-darwin # macOS/Intel + os: macos-latest + container: '' + shell: bash + cargo_env: '' + - target: aarch64-apple-darwin # macOS/Apple Silicon + os: macos-latest + container: '' + shell: bash + cargo_env: '' + - target: x86_64-pc-windows-msvc # Windows + os: windows-latest + container: '' + shell: bash + cargo_env: '' + name: ${{ inputs.job_name }} (${{ matrix.target }}) + permissions: + contents: read + runs-on: ${{ matrix.os }} + container: + image: ${{ matrix.container }} + defaults: + run: + shell: ${{ matrix.shell }} + env: + CARGO_TERM_COLOR: always + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Setup cross compiling (Debian) + id: cross_compile + if: contains(matrix.os, 'ubuntu') && matrix.container == null + run: | + echo "::group::distro detection" + # detect dist name like bionic, focal, etc + dist_name=$(lsb_release -cs) + ubuntu_version=$(lsb_release -rs) + ubuntu_major_version=${ubuntu_version%%.*} + echo "detected dist name: $dist_name" + echo "detected ubuntu version: $ubuntu_version" + echo "detected ubuntu major version: $ubuntu_major_version" + echo "::endgroup::" + + echo "::group::install aptitude" + sudo apt-get update # must run before changing sources file + sudo apt-get install -y \ + aptitude + echo "::endgroup::" + + echo "::group::dependencies prep" + dependencies=() + + # extra dependencies for cross-compiling + cross_compile=false + package_arch=$(dpkg --print-architecture) + pkg_config_sysroot_dir="/usr/lib/${package_arch}" + qemu_command="" + if [[ ${{ matrix.target }} == *"aarch64"* && $package_arch != "arm64" ]]; then + dependencies+=("crossbuild-essential-arm64") + cross_compile=true + package_arch="arm64" + pkg_config_sysroot_dir="/usr/lib/aarch64-linux-gnu" + qemu_command="qemu-aarch64-static" + elif [[ ${{ matrix.target }} == *"arm"* && $package_arch != "armhf" ]]; then + dependencies+=("crossbuild-essential-armhf") + cross_compile=true + package_arch="armhf" + pkg_config_sysroot_dir="/usr/lib/arm-linux-gnueabihf" + qemu_command="qemu-arm-static" + fi + + if [[ $cross_compile == true ]]; then + dependencies+=( + "qemu-user" + "qemu-user-static" + ) + fi + + if [[ ${{ matrix.target }} == *"musl"* ]]; then + dependencies+=("musl-tools") + fi + + echo "cross compiling: $cross_compile" + echo "package architecture: $package_arch" + + dependencies+=( + "libayatana-appindicator3-dev:${package_arch}" # tray icon + "libglib2.0-dev:${package_arch}" + "libgtk-3-dev:${package_arch}" + "libxdo-dev:${package_arch}" + ) + echo "::endgroup::" + + echo "::group::apt sources" + extra_sources=$(cat <<- VAREOF + Types: deb + URIs: mirror+file:/etc/apt/apt-mirrors.txt + Suites: ${dist_name} ${dist_name}-updates ${dist_name}-backports ${dist_name}-security + Components: main universe restricted multiverse + Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg + Architectures: $(dpkg --print-architecture) + + Types: deb + URIs: https://ports.ubuntu.com/ubuntu-ports + Suites: ${dist_name} ${dist_name}-updates ${dist_name}-backports ${dist_name}-security + Components: main universe restricted multiverse + Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg + Architectures: ${package_arch} + VAREOF + ) + + # source file changed in 24.04 + if [[ $ubuntu_major_version -ge 24 ]]; then + source_file="/etc/apt/sources.list.d/ubuntu.sources" + else + source_file="/etc/apt/sources.list" + fi + + if [[ ${cross_compile} == true ]]; then + # print original sources + echo "original sources:" + sudo cat ${source_file} + echo "----" + + sudo dpkg --add-architecture ${package_arch} + + echo "$extra_sources" | sudo tee ${source_file} > /dev/null + echo "----" + echo "new sources:" + sudo cat ${source_file} + echo "----" + fi + echo "::endgroup::" + + echo "::group::output" + echo "CROSS_COMPILE=${cross_compile}" + echo "DEPENDENCIES=${dependencies[@]}" + echo "PKG_CONFIG_SYSROOT_DIR=${pkg_config_sysroot_dir}" + echo "PKG_CONFIG_PATH=${pkg_config_sysroot_dir}/pkgconfig" + echo "QEMU_COMMAND=${qemu_command}" + + { + echo "CROSS_COMPILE=${cross_compile}" + echo "DEPENDENCIES=${dependencies[@]}" + echo "QEMU_COMMAND=${qemu_command}" + } >> "${GITHUB_OUTPUT}" + + { + echo "PKG_CONFIG_SYSROOT_DIR=${pkg_config_sysroot_dir}" + echo "PKG_CONFIG_PATH=${pkg_config_sysroot_dir}/pkgconfig" + } >> "${GITHUB_ENV}" + echo "::endgroup::" + + - name: Install system dependencies (Debian) + if: contains(matrix.os, 'ubuntu') && matrix.container == null + run: | + echo "::group::apt update" + sudo apt-get update + echo "::endgroup::" + + echo "::group::install dependencies" + sudo aptitude install -y --without-recommends ${{ steps.cross_compile.outputs.DEPENDENCIES }} + echo "::endgroup::" + + - name: Install system dependencies (Alpine) + if: contains(matrix.os, 'ubuntu') && contains(matrix.container, 'alpine') + run: | + echo "::group::apk update" + apk update + echo "::endgroup::" + + echo "::group::install dependencies" + apk add --no-cache \ + build-base \ + cargo \ + gcc \ + g++ \ + glib-dev \ + gtk+3.0-dev \ + libayatana-appindicator-dev \ + musl-dev \ + openssl-dev \ + xdotool-dev \ + pango-dev \ + harfbuzz-dev \ + cairo-dev \ + gdk-pixbuf-dev \ + wayland-dev \ + zlib-dev \ + gettext-dev + echo "::endgroup::" + + - name: Setup Rust + uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0 + with: + target: ${{ matrix.target }} + cache: true + cache-on-failure: false + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '24' + cache: npm + cache-dependency-path: crates/client-web/package-lock.json + + - name: Install cargo-edit + if: inputs.run_build && inputs.publish_release + run: cargo install cargo-edit + + - name: Install cargo-tarpaulin + if: inputs.run_tests + run: cargo install --locked cargo-tarpaulin + + - name: Update Version + if: inputs.run_build && inputs.publish_release + env: + INPUTS_RELEASE_VERSION: ${{ inputs.release_version }} + run: | + cargo set-version ${INPUTS_RELEASE_VERSION} + cargo update --workspace + cargo metadata --locked --no-deps --format-version 1 > /dev/null + + - name: Build web client + working-directory: crates/client-web + run: | + npm ci --ignore-scripts + npm run build + + - name: Test + id: test + if: inputs.run_tests + run: | + ${{ matrix.cargo_env }} + cargo tarpaulin \ + --locked \ + --color always \ + --engine llvm \ + --no-fail-fast \ + --out Xml \ + --target ${{ matrix.target }} \ + --verbose + + - name: Upload coverage artifact + if: >- + always() && + inputs.run_tests && + (steps.test.outcome == 'success' || steps.test.outcome == 'failure') + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + if-no-files-found: error + name: coverage-${{ matrix.target }} + path: cobertura.xml + + - name: Build + if: inputs.run_build + run: | + ${{ matrix.cargo_env }} + cargo build --locked --target ${{ matrix.target }} --release + + - name: Strip all debug symbols + # TODO: is this necessary + if: inputs.run_build && contains(matrix.os, 'ubuntu') + run: strip --strip-all target/${{ matrix.target }}/release/koko + + - name: Enable reading of cache + # TODO: is this necessary + if: inputs.run_build + continue-on-error: true + run: chmod -R a+rwX $HOME/.cargo target + + - name: Create 7z archive + if: inputs.run_build + run: | + mkdir -p artifacts + + extension="" + if [[ "${{ matrix.target }}" == *"windows"* ]]; then + extension=".exe" + fi + + 7z a "./artifacts/koko-${{ matrix.target }}.7z" \ + "./assets" \ + "./target/${{ matrix.target }}/release/koko${extension}" + + - name: Upload Artifacts + if: inputs.run_build + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + if-no-files-found: error + name: koko-${{ matrix.target }} + path: artifacts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c02a526..394dfc86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,358 +57,64 @@ jobs: - name: Check licenses run: cargo deny check licenses - build: - strategy: - fail-fast: false - matrix: - include: - - target: x86_64-unknown-linux-gnu # Debian - os: ubuntu-latest - container: '' - shell: bash - cargo_env: '' - # TODO: Fix compiling for musl - # - target: x86_64-unknown-linux-musl # Alpine - # os: ubuntu-latest - # container: alpine:latest - # shell: sh - # cargo_env: "source $HOME/.cargo/env" - - target: aarch64-unknown-linux-gnu # Debian - os: ubuntu-24.04-arm - container: '' - shell: bash - cargo_env: '' - # TODO: Fix cross compiling for the below targets - # - target: aarch64-unknown-linux-musl # Alpine - # os: ubuntu-24.04-arm - # container: alpine:latest - # shell: sh - # cargo_env: "source $HOME/.cargo/env" - # - target: armv7-unknown-linux-gnueabihf # Raspberry Pi 2-5/Debian - # os: ubuntu-24.04-arm - # shell: bash - # - target: armv7-unknown-linux-musleabihf # Raspberry Pi 2-5/Alpine - # os: ubuntu-24.04-arm - # container: alpine:latest - # shell: sh - # cargo_env: "source $HOME/.cargo/env" - # - target: arm-unknown-linux-gnueabihf # Raspberry Pi 0-1/Debian - # os: ubuntu-24.04-arm - # shell: bash - # - target: arm-unknown-linux-musleabihf # Raspberry Pi 0-1/Alpine - # os: ubuntu-24.04-arm - # container: alpine:latest - # shell: sh - # cargo_env: "source $HOME/.cargo/env" - - target: x86_64-apple-darwin # macOS/Intel - os: macos-latest - container: '' - shell: bash - cargo_env: '' - - target: aarch64-apple-darwin # macOS/Apple Silicon - os: macos-latest - container: '' - shell: bash - cargo_env: '' - - target: x86_64-pc-windows-msvc # Windows - os: windows-latest - container: '' - shell: bash - cargo_env: '' - name: Build (${{ matrix.target }}) - needs: setup_release + clippy: + name: Clippy permissions: contents: read - runs-on: ${{ matrix.os }} - container: - image: ${{ matrix.container }} - defaults: - run: - shell: ${{ matrix.shell }} - env: - CARGO_TERM_COLOR: always - steps: - - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - - name: Setup cross compiling (Debian) - id: cross_compile - if: contains(matrix.os, 'ubuntu') && matrix.container == null - run: | - echo "::group::distro detection" - # detect dist name like bionic, focal, etc - dist_name=$(lsb_release -cs) - ubuntu_version=$(lsb_release -rs) - ubuntu_major_version=${ubuntu_version%%.*} - echo "detected dist name: $dist_name" - echo "detected ubuntu version: $ubuntu_version" - echo "detected ubuntu major version: $ubuntu_major_version" - echo "::endgroup::" - - echo "::group::install aptitude" - sudo apt-get update # must run before changing sources file - sudo apt-get install -y \ - aptitude - echo "::endgroup::" - - echo "::group::dependencies prep" - dependencies=() - - # extra dependencies for cross-compiling - cross_compile=false - package_arch=$(dpkg --print-architecture) - pkg_config_sysroot_dir="/usr/lib/${package_arch}" - qemu_command="" - if [[ ${{ matrix.target }} == *"aarch64"* && $package_arch != "arm64" ]]; then - dependencies+=("crossbuild-essential-arm64") - cross_compile=true - package_arch="arm64" - pkg_config_sysroot_dir="/usr/lib/aarch64-linux-gnu" - qemu_command="qemu-aarch64-static" - elif [[ ${{ matrix.target }} == *"arm"* && $package_arch != "armhf" ]]; then - dependencies+=("crossbuild-essential-armhf") - cross_compile=true - package_arch="armhf" - pkg_config_sysroot_dir="/usr/lib/arm-linux-gnueabihf" - qemu_command="qemu-arm-static" - fi - - if [[ $cross_compile == true ]]; then - dependencies+=( - "qemu-user" - "qemu-user-static" - ) - fi - - if [[ ${{ matrix.target }} == *"musl"* ]]; then - dependencies+=("musl-tools") - fi - - echo "cross compiling: $cross_compile" - echo "package architecture: $package_arch" - - dependencies+=( - "libayatana-appindicator3-dev:${package_arch}" # tray icon - "libglib2.0-dev:${package_arch}" - "libgtk-3-dev:${package_arch}" - "libxdo-dev:${package_arch}" - ) - echo "::endgroup::" - - echo "::group::apt sources" - extra_sources=$(cat <<- VAREOF - Types: deb - URIs: mirror+file:/etc/apt/apt-mirrors.txt - Suites: ${dist_name} ${dist_name}-updates ${dist_name}-backports ${dist_name}-security - Components: main universe restricted multiverse - Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg - Architectures: $(dpkg --print-architecture) - - Types: deb - URIs: https://ports.ubuntu.com/ubuntu-ports - Suites: ${dist_name} ${dist_name}-updates ${dist_name}-backports ${dist_name}-security - Components: main universe restricted multiverse - Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg - Architectures: ${package_arch} - VAREOF - ) + uses: ./.github/workflows/ci-clippy.yml - # source file changed in 24.04 - if [[ $ubuntu_major_version -ge 24 ]]; then - source_file="/etc/apt/sources.list.d/ubuntu.sources" - else - source_file="/etc/apt/sources.list" - fi - - if [[ ${cross_compile} == true ]]; then - # print original sources - echo "original sources:" - sudo cat ${source_file} - echo "----" - - sudo dpkg --add-architecture ${package_arch} - - echo "$extra_sources" | sudo tee ${source_file} > /dev/null - echo "----" - echo "new sources:" - sudo cat ${source_file} - echo "----" - fi - echo "::endgroup::" - - echo "::group::output" - echo "CROSS_COMPILE=${cross_compile}" - echo "DEPENDENCIES=${dependencies[@]}" - echo "PKG_CONFIG_SYSROOT_DIR=${pkg_config_sysroot_dir}" - echo "PKG_CONFIG_PATH=${pkg_config_sysroot_dir}/pkgconfig" - echo "QEMU_COMMAND=${qemu_command}" - - { - echo "CROSS_COMPILE=${cross_compile}" - echo "DEPENDENCIES=${dependencies[@]}" - echo "QEMU_COMMAND=${qemu_command}" - } >> "${GITHUB_OUTPUT}" - - { - echo "PKG_CONFIG_SYSROOT_DIR=${pkg_config_sysroot_dir}" - echo "PKG_CONFIG_PATH=${pkg_config_sysroot_dir}/pkgconfig" - } >> "${GITHUB_ENV}" - echo "::endgroup::" - - - name: Install system dependencies (Debian) - if: contains(matrix.os, 'ubuntu') && matrix.container == null - run: | - echo "::group::apt update" - sudo apt-get update - echo "::endgroup::" - - echo "::group::install dependencies" - sudo aptitude install -y --without-recommends ${{ steps.cross_compile.outputs.DEPENDENCIES }} - echo "::endgroup::" - - - name: Install system dependencies (Alpine) - if: contains(matrix.os, 'ubuntu') && contains(matrix.container, 'alpine') - run: | - echo "::group::apk update" - apk update - echo "::endgroup::" - - echo "::group::install dependencies" - apk add --no-cache \ - build-base \ - cargo \ - gcc \ - g++ \ - glib-dev \ - gtk+3.0-dev \ - libayatana-appindicator-dev \ - musl-dev \ - openssl-dev \ - xdotool-dev \ - pango-dev \ - harfbuzz-dev \ - cairo-dev \ - gdk-pixbuf-dev \ - wayland-dev \ - zlib-dev \ - gettext-dev - echo "::endgroup::" - - - name: Setup Rust - uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0 - with: - target: ${{ matrix.target }} - components: 'clippy' - cache: true - cache-on-failure: false - - - name: Setup Node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: '24' - cache: npm - cache-dependency-path: crates/client-web/package-lock.json - - # TODO: it may be possible to use cargo-bin in the future to install cargo dependencies, - # but right now it doesn't work without a lock file - # https://github.com/dustinblackman/cargo-run-bin/issues/27 - # cargo install cargo-run-bin - # cargo-bin --install - - name: Install cargo packages - run: | - cargo install cargo-edit - cargo install --locked cargo-tarpaulin - - - name: Update Version - if: ${{ needs.setup_release.outputs.publish_release == 'true' }} - run: | - cargo set-version ${{ needs.setup_release.outputs.release_version }} - cargo update --workspace - cargo metadata --locked --no-deps --format-version 1 > /dev/null - - - name: Build web client - working-directory: crates/client-web - run: | - npm ci --ignore-scripts - npm run build - - - name: Test - id: test - run: | - ${{ matrix.cargo_env }} - cargo tarpaulin \ - --locked \ - --color always \ - --engine llvm \ - --no-fail-fast \ - --out Xml \ - --target ${{ matrix.target }} \ - --verbose - - - name: Upload coverage - # any except canceled or skipped - if: >- - always() && - (steps.test.outcome == 'success' || steps.test.outcome == 'failure') && - startsWith(github.repository, 'LizardByte/') - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 - with: - disable_search: true - fail_ci_if_error: true - files: cobertura.xml - flags: ${{ matrix.target }} - token: ${{ secrets.CODECOV_TOKEN }} - verbose: true - - - name: Clippy - run: | - ${{ matrix.cargo_env }} - cargo clippy --locked -- -D warnings - - - name: Build - run: | - ${{ matrix.cargo_env }} - cargo build --locked --target ${{ matrix.target }} --release - - - name: Strip all debug symbols - # TODO: is this necessary - if: contains(matrix.os, 'ubuntu') - run: strip --strip-all target/${{ matrix.target }}/release/koko - - - name: Enable reading of cache - # TODO: is this necessary - continue-on-error: true - run: chmod -R a+rwX $HOME/.cargo target - - - name: Create 7z archive - run: | - mkdir -p artifacts - - extension="" - if [[ "${{ matrix.target }}" == *"windows"* ]]; then - extension=".exe" - fi - - 7z a "./artifacts/koko-${{ matrix.target }}.7z" \ - "./assets" \ - "./target/${{ matrix.target }}/release/koko${extension}" - - - name: Upload Artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - if-no-files-found: 'error' - name: koko-${{ matrix.target }} - path: artifacts + test: + name: Test + permissions: + contents: read + uses: ./.github/workflows/ci-rust.yml + with: + job_name: Test + run_build: false + run_tests: true - - name: Create/Update GitHub Release - if: ${{ needs.setup_release.outputs.publish_release == 'true' }} - uses: LizardByte/actions/actions/release_create@200eaeb897a2b065a65cb6f16b41077432007490 # v2026.605.34721 - with: - allowUpdates: true - body: ${{ needs.setup_release.outputs.release_body }} - draft: true - generateReleaseNotes: ${{ needs.setup_release.outputs.release_generate_release_notes }} - name: ${{ needs.setup_release.outputs.release_tag }} - prerelease: true - tag: ${{ needs.setup_release.outputs.release_tag }} - token: ${{ secrets.GH_BOT_TOKEN }} + build: + name: Build + needs: setup_release + permissions: + contents: read + uses: ./.github/workflows/ci-rust.yml + with: + job_name: Build + publish_release: ${{ needs.setup_release.outputs.publish_release == 'true' }} + release_version: ${{ needs.setup_release.outputs.release_version }} + run_build: true + run_tests: false + + coverage: + name: Coverage + if: >- + always() && + !cancelled() && + startsWith(github.repository, 'LizardByte/') + needs: test + permissions: + contents: read + uses: ./.github/workflows/ci-coverage.yml + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + release: + name: Release + if: >- + needs.setup_release.outputs.publish_release == 'true' && + startsWith(github.repository, 'LizardByte/') + needs: + - setup_release + - clippy + - test + - build + permissions: + contents: read + uses: ./.github/workflows/ci-release.yml + with: + release_body: ${{ needs.setup_release.outputs.release_body }} + release_generate_release_notes: ${{ needs.setup_release.outputs.release_generate_release_notes }} + release_tag: ${{ needs.setup_release.outputs.release_tag }} + secrets: + GH_BOT_TOKEN: ${{ secrets.GH_BOT_TOKEN }} From c6896340aa6f35f28fb27a2229b37d5e82f9f558 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 13 Jun 2026 17:42:33 -0400 Subject: [PATCH 112/128] Remove strip and chmod steps from CI Remove two workflow steps from .github/workflows/ci-rust.yml: the Ubuntu-only "Strip all debug symbols" step and the "Enable reading of cache" chmod step. Both steps had TODOs questioning their necessity; removing them simplifies the CI flow and avoids platform-specific stripping and potential cache permission changes while keeping the build and archive steps intact. --- .github/workflows/ci-rust.yml | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml index 17f0c7cb..1db74ebe 100644 --- a/.github/workflows/ci-rust.yml +++ b/.github/workflows/ci-rust.yml @@ -328,17 +328,6 @@ jobs: ${{ matrix.cargo_env }} cargo build --locked --target ${{ matrix.target }} --release - - name: Strip all debug symbols - # TODO: is this necessary - if: inputs.run_build && contains(matrix.os, 'ubuntu') - run: strip --strip-all target/${{ matrix.target }}/release/koko - - - name: Enable reading of cache - # TODO: is this necessary - if: inputs.run_build - continue-on-error: true - run: chmod -R a+rwX $HOME/.cargo target - - name: Create 7z archive if: inputs.run_build run: | From 334435eb101cc9cb123eb40aabc84602901cd0cf Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 13 Jun 2026 19:54:13 -0400 Subject: [PATCH 113/128] CI: switch to musl targets and Alpine Update GitHub Actions workflows to use musl targets and Alpine-based containers for Linux builds. ci-coverage.yml now tests x86_64/aarch64-unknown-linux-musl. ci-rust.yml simplifies the matrix and replaces the previous Debian/apt cross-compilation setup with an Alpine container (rust:1.96-alpine) for musl targets, installing needed apk packages and configuring the environment accordingly. Also adjust step ordering and conditionals (Checkout, Setup Rust/Node only when not using container), remove legacy cargo_env usage from test/build steps, and fix the Windows extension detection. These changes streamline musl builds and remove complex Debian cross-compile hacks. --- .github/workflows/ci-coverage.yml | 4 +- .github/workflows/ci-rust.yml | 226 ++++-------------------------- 2 files changed, 29 insertions(+), 201 deletions(-) diff --git a/.github/workflows/ci-coverage.yml b/.github/workflows/ci-coverage.yml index 7a5e9c89..a2b5665d 100644 --- a/.github/workflows/ci-coverage.yml +++ b/.github/workflows/ci-coverage.yml @@ -14,8 +14,8 @@ jobs: fail-fast: false matrix: target: - - x86_64-unknown-linux-gnu - - aarch64-unknown-linux-gnu + - x86_64-unknown-linux-musl + - aarch64-unknown-linux-musl - x86_64-apple-darwin - aarch64-apple-darwin - x86_64-pc-windows-msvc diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml index 1db74ebe..78bdb92e 100644 --- a/.github/workflows/ci-rust.yml +++ b/.github/workflows/ci-rust.yml @@ -29,59 +29,26 @@ jobs: fail-fast: false matrix: include: - - target: x86_64-unknown-linux-gnu # Debian + - target: x86_64-unknown-linux-musl # Alpine os: ubuntu-latest - container: '' + container: rust:1.96-alpine shell: bash - cargo_env: '' - # TODO: Fix compiling for musl - # - target: x86_64-unknown-linux-musl # Alpine - # os: ubuntu-latest - # container: alpine:latest - # shell: sh - # cargo_env: "source $HOME/.cargo/env" - - target: aarch64-unknown-linux-gnu # Debian + - target: aarch64-unknown-linux-musl # Alpine os: ubuntu-24.04-arm - container: '' + container: rust:1.96-alpine shell: bash - cargo_env: '' - # TODO: Fix cross compiling for the below targets - # - target: aarch64-unknown-linux-musl # Alpine - # os: ubuntu-24.04-arm - # container: alpine:latest - # shell: sh - # cargo_env: "source $HOME/.cargo/env" - # - target: armv7-unknown-linux-gnueabihf # Raspberry Pi 2-5/Debian - # os: ubuntu-24.04-arm - # shell: bash - # - target: armv7-unknown-linux-musleabihf # Raspberry Pi 2-5/Alpine - # os: ubuntu-24.04-arm - # container: alpine:latest - # shell: sh - # cargo_env: "source $HOME/.cargo/env" - # - target: arm-unknown-linux-gnueabihf # Raspberry Pi 0-1/Debian - # os: ubuntu-24.04-arm - # shell: bash - # - target: arm-unknown-linux-musleabihf # Raspberry Pi 0-1/Alpine - # os: ubuntu-24.04-arm - # container: alpine:latest - # shell: sh - # cargo_env: "source $HOME/.cargo/env" - target: x86_64-apple-darwin # macOS/Intel os: macos-latest container: '' shell: bash - cargo_env: '' - target: aarch64-apple-darwin # macOS/Apple Silicon os: macos-latest container: '' shell: bash - cargo_env: '' - target: x86_64-pc-windows-msvc # Windows os: windows-latest container: '' shell: bash - cargo_env: '' name: ${{ inputs.job_name }} (${{ matrix.target }}) permissions: contents: read @@ -94,173 +61,36 @@ jobs: env: CARGO_TERM_COLOR: always steps: - - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - - name: Setup cross compiling (Debian) - id: cross_compile - if: contains(matrix.os, 'ubuntu') && matrix.container == null - run: | - echo "::group::distro detection" - # detect dist name like bionic, focal, etc - dist_name=$(lsb_release -cs) - ubuntu_version=$(lsb_release -rs) - ubuntu_major_version=${ubuntu_version%%.*} - echo "detected dist name: $dist_name" - echo "detected ubuntu version: $ubuntu_version" - echo "detected ubuntu major version: $ubuntu_major_version" - echo "::endgroup::" - - echo "::group::install aptitude" - sudo apt-get update # must run before changing sources file - sudo apt-get install -y \ - aptitude - echo "::endgroup::" - - echo "::group::dependencies prep" - dependencies=() - - # extra dependencies for cross-compiling - cross_compile=false - package_arch=$(dpkg --print-architecture) - pkg_config_sysroot_dir="/usr/lib/${package_arch}" - qemu_command="" - if [[ ${{ matrix.target }} == *"aarch64"* && $package_arch != "arm64" ]]; then - dependencies+=("crossbuild-essential-arm64") - cross_compile=true - package_arch="arm64" - pkg_config_sysroot_dir="/usr/lib/aarch64-linux-gnu" - qemu_command="qemu-aarch64-static" - elif [[ ${{ matrix.target }} == *"arm"* && $package_arch != "armhf" ]]; then - dependencies+=("crossbuild-essential-armhf") - cross_compile=true - package_arch="armhf" - pkg_config_sysroot_dir="/usr/lib/arm-linux-gnueabihf" - qemu_command="qemu-arm-static" - fi - - if [[ $cross_compile == true ]]; then - dependencies+=( - "qemu-user" - "qemu-user-static" - ) - fi - - if [[ ${{ matrix.target }} == *"musl"* ]]; then - dependencies+=("musl-tools") - fi - - echo "cross compiling: $cross_compile" - echo "package architecture: $package_arch" - - dependencies+=( - "libayatana-appindicator3-dev:${package_arch}" # tray icon - "libglib2.0-dev:${package_arch}" - "libgtk-3-dev:${package_arch}" - "libxdo-dev:${package_arch}" - ) - echo "::endgroup::" - - echo "::group::apt sources" - extra_sources=$(cat <<- VAREOF - Types: deb - URIs: mirror+file:/etc/apt/apt-mirrors.txt - Suites: ${dist_name} ${dist_name}-updates ${dist_name}-backports ${dist_name}-security - Components: main universe restricted multiverse - Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg - Architectures: $(dpkg --print-architecture) - - Types: deb - URIs: https://ports.ubuntu.com/ubuntu-ports - Suites: ${dist_name} ${dist_name}-updates ${dist_name}-backports ${dist_name}-security - Components: main universe restricted multiverse - Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg - Architectures: ${package_arch} - VAREOF - ) + - name: Fix arm64 Alpine container + if: runner.arch == 'ARM64' && contains(matrix.container, 'alpine') + uses: laverdet/alpine-arm64@7f0f72ee2f71eb2324e5888e8b6e42b1b53e6160 # v1.0.0 - # source file changed in 24.04 - if [[ $ubuntu_major_version -ge 24 ]]; then - source_file="/etc/apt/sources.list.d/ubuntu.sources" - else - source_file="/etc/apt/sources.list" - fi - - if [[ ${cross_compile} == true ]]; then - # print original sources - echo "original sources:" - sudo cat ${source_file} - echo "----" - - sudo dpkg --add-architecture ${package_arch} - - echo "$extra_sources" | sudo tee ${source_file} > /dev/null - echo "----" - echo "new sources:" - sudo cat ${source_file} - echo "----" - fi - echo "::endgroup::" - - echo "::group::output" - echo "CROSS_COMPILE=${cross_compile}" - echo "DEPENDENCIES=${dependencies[@]}" - echo "PKG_CONFIG_SYSROOT_DIR=${pkg_config_sysroot_dir}" - echo "PKG_CONFIG_PATH=${pkg_config_sysroot_dir}/pkgconfig" - echo "QEMU_COMMAND=${qemu_command}" - - { - echo "CROSS_COMPILE=${cross_compile}" - echo "DEPENDENCIES=${dependencies[@]}" - echo "QEMU_COMMAND=${qemu_command}" - } >> "${GITHUB_OUTPUT}" - - { - echo "PKG_CONFIG_SYSROOT_DIR=${pkg_config_sysroot_dir}" - echo "PKG_CONFIG_PATH=${pkg_config_sysroot_dir}/pkgconfig" - } >> "${GITHUB_ENV}" - echo "::endgroup::" - - - name: Install system dependencies (Debian) - if: contains(matrix.os, 'ubuntu') && matrix.container == null + - name: Prepare Alpine container + if: contains(matrix.container, 'alpine') + shell: sh run: | - echo "::group::apt update" - sudo apt-get update - echo "::endgroup::" - - echo "::group::install dependencies" - sudo aptitude install -y --without-recommends ${{ steps.cross_compile.outputs.DEPENDENCIES }} - echo "::endgroup::" - - - name: Install system dependencies (Alpine) - if: contains(matrix.os, 'ubuntu') && contains(matrix.container, 'alpine') - run: | - echo "::group::apk update" - apk update - echo "::endgroup::" - - echo "::group::install dependencies" + set -eux apk add --no-cache \ + bash \ build-base \ - cargo \ - gcc \ - g++ \ + git \ glib-dev \ gtk+3.0-dev \ libayatana-appindicator-dev \ - musl-dev \ + nodejs \ + npm \ openssl-dev \ - xdotool-dev \ - pango-dev \ - harfbuzz-dev \ - cairo-dev \ - gdk-pixbuf-dev \ - wayland-dev \ - zlib-dev \ - gettext-dev - echo "::endgroup::" + p7zip \ + pkgconf \ + xdotool-dev + + echo "/usr/local/cargo/bin" >> "${GITHUB_PATH}" + + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Setup Rust + if: matrix.container == '' uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0 with: target: ${{ matrix.target }} @@ -268,6 +98,7 @@ jobs: cache-on-failure: false - name: Setup Node + if: matrix.container == '' uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '24' @@ -301,7 +132,6 @@ jobs: id: test if: inputs.run_tests run: | - ${{ matrix.cargo_env }} cargo tarpaulin \ --locked \ --color always \ @@ -324,9 +154,7 @@ jobs: - name: Build if: inputs.run_build - run: | - ${{ matrix.cargo_env }} - cargo build --locked --target ${{ matrix.target }} --release + run: cargo build --locked --target ${{ matrix.target }} --release - name: Create 7z archive if: inputs.run_build @@ -334,7 +162,7 @@ jobs: mkdir -p artifacts extension="" - if [[ "${{ matrix.target }}" == *"windows"* ]]; then + if [[ "${{ matrix.target }}" = *"windows"* ]]; then extension=".exe" fi From 0d2a4973c719ed2f824529a0264b71dcd766d084 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 13 Jun 2026 21:30:05 -0400 Subject: [PATCH 114/128] Add optional tray and native-secret-store features Introduce feature flags and conditional builds for tray and native secret storage. Cargo.toml: add features (default includes "native-secret-store" and "tray"), make keyring, objc2-core-foundation, tao, tray-icon, and webbrowser optional, and adjust keyring-core features. .cargo/config.toml: switch musl linkers to alpine-specific cross-compilers. CI: update containers to rust:1.96-alpine-3.24, expose matrix.cargo_features to test and build steps, and adjust installed packages (openssl-libs-static, zlib-dev/zlib-static). Code: gate tray module, icon path, and main() behind the tray feature and add a fallback main() that runs the web server when tray is disabled. Secrets: add sample store helper, make named/native store usage conditional on the native-secret-store feature and fall back to an in-memory/sample store when disabled. Tests: gate test_tray behind the tray feature. These changes allow building without GUI/native secret dependencies and enable CI to run with --no-default-features. --- .cargo/config.toml | 6 +++--- .github/workflows/ci-rust.yml | 19 ++++++++++++------- crates/server/Cargo.toml | 25 +++++++++++++++++++------ crates/server/src/globals.rs | 1 + crates/server/src/lib.rs | 17 ++++++++++++++++- crates/server/src/secrets.rs | 29 +++++++++++++++++++++++++++-- crates/server/tests/main.rs | 1 + crates/server/tests/test_tray.rs | 2 ++ 8 files changed, 81 insertions(+), 19 deletions(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index 781fad4a..415ab6e3 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -7,15 +7,15 @@ new-migration = "run --quiet -p xtask -- new-migration" # linker = "x86_64-linux-gnu-gcc" # rustflags = ["-C", "target-feature=+crt-static"] -# [target.x86_64-unknown-linux-musl] -# rustflags = ["-L/usr/local/lib", "-L/usr/lib", "-L/lib"] +[target.x86_64-unknown-linux-musl] +linker = "x86_64-alpine-linux-musl-gcc" [target.aarch64-unknown-linux-gnu] linker = "aarch64-linux-gnu-gcc" # rustflags = ["-C", "target-feature=+crt-static"] [target.aarch64-unknown-linux-musl] -linker = "aarch64-linux-gnu-gcc" +linker = "aarch64-alpine-linux-musl-gcc" [target.armv7-unknown-linux-gnueabihf] linker = "arm-linux-gnueabihf-gcc" diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml index 78bdb92e..00532aee 100644 --- a/.github/workflows/ci-rust.yml +++ b/.github/workflows/ci-rust.yml @@ -31,23 +31,28 @@ jobs: include: - target: x86_64-unknown-linux-musl # Alpine os: ubuntu-latest - container: rust:1.96-alpine + container: rust:1.96-alpine3.24 + cargo_features: '--no-default-features' shell: bash - target: aarch64-unknown-linux-musl # Alpine os: ubuntu-24.04-arm - container: rust:1.96-alpine + container: rust:1.96-alpine3.24 + cargo_features: '--no-default-features' shell: bash - target: x86_64-apple-darwin # macOS/Intel os: macos-latest container: '' + cargo_features: '' shell: bash - target: aarch64-apple-darwin # macOS/Apple Silicon os: macos-latest container: '' + cargo_features: '' shell: bash - target: x86_64-pc-windows-msvc # Windows os: windows-latest container: '' + cargo_features: '' shell: bash name: ${{ inputs.job_name }} (${{ matrix.target }}) permissions: @@ -74,15 +79,14 @@ jobs: bash \ build-base \ git \ - glib-dev \ - gtk+3.0-dev \ - libayatana-appindicator-dev \ nodejs \ npm \ openssl-dev \ + openssl-libs-static \ p7zip \ pkgconf \ - xdotool-dev + zlib-dev \ + zlib-static echo "/usr/local/cargo/bin" >> "${GITHUB_PATH}" @@ -138,6 +142,7 @@ jobs: --engine llvm \ --no-fail-fast \ --out Xml \ + ${{ matrix.cargo_features }} \ --target ${{ matrix.target }} \ --verbose @@ -154,7 +159,7 @@ jobs: - name: Build if: inputs.run_build - run: cargo build --locked --target ${{ matrix.target }} --release + run: cargo build --locked ${{ matrix.cargo_features }} --target ${{ matrix.target }} --release - name: Create 7z archive if: inputs.run_build diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 4e1cec5d..a96d9dda 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -43,6 +43,19 @@ path = "src/main.rs" [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ["cfg(tarpaulin_include)"] } +[features] +default = [ + "native-secret-store", + "tray", +] +native-secret-store = ["dep:keyring"] +tray = [ + "dep:objc2-core-foundation", + "dep:tao", + "dep:tray-icon", + "dep:webbrowser", +] + [dependencies] # ensure deps are compatible: https://www.gnu.org/licenses/license-list.en.html#GPLCompatibleLicenses base64 = "0.22.1" @@ -57,8 +70,8 @@ fern = { version = "0.7.1", features = ["colored"] } image = "0.25.5" imohash = "0.1.2" jsonwebtoken = "9.3.1" -keyring = "4.0.0" -keyring-core = "1.0.0" +keyring = { version = "4.0.0", optional = true } +keyring-core = { version = "1.0.0", features = ["sample"] } libsqlite3-sys = { version = "0.35", features = ["bundled"] } # this is needed for proper linking log = "0.4.25" once_cell = "1.20.3" @@ -75,16 +88,16 @@ serde_json = "1.0.138" serde_yaml = "0.9.34" sha2 = "0.11.0" strsim = "0.11.1" -tao = "0.35.0" +tao = { version = "0.35.0", optional = true } tokio = { version = "1.0", features = ["full"] } tmdb_client = "1.8.0" -tray-icon = "0.22.0" +tray-icon = { version = "0.22.0", optional = true } tvdb4 = "0.1.0" -webbrowser = "1.0.3" +webbrowser = { version = "1.0.3", optional = true } # common = { path = "../common" } [target.'cfg(target_os = "macos")'.dependencies] -objc2-core-foundation = "0.3.0" +objc2-core-foundation = { version = "0.3.0", optional = true } [dev-dependencies] async-std.workspace = true diff --git a/crates/server/src/globals.rs b/crates/server/src/globals.rs index 7c435c61..bf6a7a22 100644 --- a/crates/server/src/globals.rs +++ b/crates/server/src/globals.rs @@ -8,6 +8,7 @@ use crate::config::current_settings; // global constants and variables pub(crate) static GLOBAL_APP_NAME: &str = "Koko"; +#[cfg(feature = "tray")] pub(crate) static GLOBAL_ICON_ICO_PATH: &str = "assets/icon.ico"; /// Environment type for the application diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 047b142c..31fce3cf 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -18,13 +18,14 @@ pub mod scheduled_tasks; mod secrets; pub mod signal_handler; pub mod transcode; +#[cfg(feature = "tray")] pub mod tray; pub mod utils; pub mod web; /// Main entry point for the application. /// Initializes logging, the web server, and tray icon. -#[cfg(not(tarpaulin_include))] +#[cfg(all(not(tarpaulin_include), feature = "tray"))] pub fn main() { logging::init().expect("Failed to initialize logging"); @@ -54,3 +55,17 @@ pub fn main() { log::info!("Application shutdown complete"); } + +/// Main entry point for the application without tray support. +/// Initializes logging and runs the web server on the main thread. +#[cfg(all(not(tarpaulin_include), not(feature = "tray")))] +pub fn main() { + logging::init().expect("Failed to initialize logging"); + log::info!("Starting without tray support"); + + let runtime = + tokio::runtime::Runtime::new().expect("Failed to create tokio runtime for web server"); + runtime.block_on(web::launch_with_shutdown( + signal_handler::ShutdownSignal::new(), + )); +} diff --git a/crates/server/src/secrets.rs b/crates/server/src/secrets.rs index 0414f9ba..a9029261 100644 --- a/crates/server/src/secrets.rs +++ b/crates/server/src/secrets.rs @@ -8,6 +8,7 @@ use std::sync::Mutex; use keyring_core::{ Entry, Error, + set_default_store, }; use once_cell::sync::Lazy; @@ -26,13 +27,31 @@ fn initialize_secret_store() -> Result<(), String> { "" | "native" | "os" => use_native_secret_store(), "memory" | "mock" | "sample" => { let config = HashMap::from([("persist", "false")]); - keyring::use_sample_store(&config) + use_sample_secret_store(&config) } - store => keyring::use_named_store(store), + store => use_named_secret_store(store), } .map_err(|error| format!("Failed to initialize credential store: {error}")) } +fn use_sample_secret_store(config: &HashMap<&str, &str>) -> keyring_core::Result<()> { + set_default_store(keyring_core::sample::Store::new_with_configuration(config)?); + Ok(()) +} + +#[cfg(feature = "native-secret-store")] +fn use_named_secret_store(store: &str) -> keyring_core::Result<()> { + keyring::use_named_store(store) +} + +#[cfg(not(feature = "native-secret-store"))] +fn use_named_secret_store(store: &str) -> keyring_core::Result<()> { + Err(Error::NotSupportedByStore(format!( + "credential store {store:?} is not available in this build" + ))) +} + +#[cfg(feature = "native-secret-store")] fn use_native_secret_store() -> keyring_core::Result<()> { #[cfg(target_os = "linux")] { @@ -44,6 +63,12 @@ fn use_native_secret_store() -> keyring_core::Result<()> { } } +#[cfg(not(feature = "native-secret-store"))] +fn use_native_secret_store() -> keyring_core::Result<()> { + let config = HashMap::from([("persist", "false")]); + use_sample_secret_store(&config) +} + fn ensure_secret_store() -> Result<(), String> { let mut guard = SECRET_STORE_INIT .lock() diff --git a/crates/server/tests/main.rs b/crates/server/tests/main.rs index 84122922..849f6834 100644 --- a/crates/server/tests/main.rs +++ b/crates/server/tests/main.rs @@ -1,6 +1,7 @@ pub mod test_auth; pub mod test_media; pub mod test_metadata; +#[cfg(feature = "tray")] pub mod test_tray; pub mod test_utils; pub mod test_web; diff --git a/crates/server/tests/test_tray.rs b/crates/server/tests/test_tray.rs index d400c807..417fcb20 100644 --- a/crates/server/tests/test_tray.rs +++ b/crates/server/tests/test_tray.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "tray")] + // standard imports use std::path::Path; use std::thread; From baaea2ae4896b10b6064967ffe25a55e47c2cf79 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 14 Jun 2026 10:47:16 -0400 Subject: [PATCH 115/128] Add Flatpak packaging and CI integration Add Flatpak packaging for dev.lizardbyte.app.Koko and CI support to build Flatpak artifacts. Introduces a reusable CI workflow (.github/workflows/ci-flatpak.yml) and wires it into ci.yml; adds .gitmodules with submodules for flatpak-builder-tools and shared-modules. Add packaging/linux/flatpak files (manifest, desktop entry, metainfo, modules/xdotool.json, exceptions, flathub metadata, helper script, README) plus scripts to generate npm/Cargo sources. Add Python tooling (pyproject.toml and uv.lock) to support the generators. Update ci-rust.yml to include Ubuntu linux-gnu and aarch64-gnu targets and install required Ubuntu build deps. Tweak .cargo/config.toml to remove the aarch64-unknown-linux-gnu linker entry and keep musl targets. The workflow produces cached builds and uploads Flatpak artifacts for release. --- .cargo/config.toml | 4 - .github/workflows/ci-flatpak.yml | 186 ++++++++++ .github/workflows/ci-rust.yml | 23 ++ .github/workflows/ci.yml | 11 + .gitmodules | 8 + assets/Koko.png | Bin 36236 -> 42488 bytes packaging/linux/flatpak/README.md | 6 + .../linux/flatpak/deps/flatpak-builder-tools | 1 + packaging/linux/flatpak/deps/shared-modules | 1 + .../flatpak/dev.lizardbyte.app.Koko.desktop | 10 + .../dev.lizardbyte.app.Koko.metainfo.xml | 28 ++ .../linux/flatpak/dev.lizardbyte.app.Koko.yml | 79 ++++ packaging/linux/flatpak/exceptions.json | 8 + packaging/linux/flatpak/flathub.json | 3 + packaging/linux/flatpak/modules/xdotool.json | 23 ++ packaging/linux/flatpak/scripts/koko.sh | 3 + pyproject.toml | 34 ++ uv.lock | 350 ++++++++++++++++++ 18 files changed, 774 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/ci-flatpak.yml create mode 100644 .gitmodules create mode 100644 packaging/linux/flatpak/README.md create mode 160000 packaging/linux/flatpak/deps/flatpak-builder-tools create mode 160000 packaging/linux/flatpak/deps/shared-modules create mode 100644 packaging/linux/flatpak/dev.lizardbyte.app.Koko.desktop create mode 100644 packaging/linux/flatpak/dev.lizardbyte.app.Koko.metainfo.xml create mode 100644 packaging/linux/flatpak/dev.lizardbyte.app.Koko.yml create mode 100644 packaging/linux/flatpak/exceptions.json create mode 100644 packaging/linux/flatpak/flathub.json create mode 100644 packaging/linux/flatpak/modules/xdotool.json create mode 100644 packaging/linux/flatpak/scripts/koko.sh create mode 100644 pyproject.toml create mode 100644 uv.lock diff --git a/.cargo/config.toml b/.cargo/config.toml index 415ab6e3..806c651d 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -10,10 +10,6 @@ new-migration = "run --quiet -p xtask -- new-migration" [target.x86_64-unknown-linux-musl] linker = "x86_64-alpine-linux-musl-gcc" -[target.aarch64-unknown-linux-gnu] -linker = "aarch64-linux-gnu-gcc" -# rustflags = ["-C", "target-feature=+crt-static"] - [target.aarch64-unknown-linux-musl] linker = "aarch64-alpine-linux-musl-gcc" diff --git a/.github/workflows/ci-flatpak.yml b/.github/workflows/ci-flatpak.yml new file mode 100644 index 00000000..8a8ed051 --- /dev/null +++ b/.github/workflows/ci-flatpak.yml @@ -0,0 +1,186 @@ +--- +name: CI-Flatpak +permissions: {} + +on: + workflow_call: + inputs: + release_commit: + required: true + type: string + release_version: + required: true + type: string + +env: + APP_ID: dev.lizardbyte.app.Koko + FREEDESKTOP_SDK_VERSION: "25.08" + NODE_VERSION: "24" + PYTHON_VERSION: "3.14" + +jobs: + flatpak: + name: ${{ matrix.arch }} + env: + MATRIX_ARCH: ${{ matrix.arch }} + permissions: + contents: read + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - arch: x86_64 + runner: ubuntu-22.04 + - arch: aarch64 + runner: ubuntu-24.04-arm + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + submodules: recursive + + - name: Setup Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Setup uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: true + + - name: Sync Python tools + run: | + uv sync --locked --only-group flatpak \ + --python "${PYTHON_VERSION}" \ + --no-python-downloads \ + --no-install-project + + - name: Setup Flatpak dependencies + run: | + sudo apt-get update -y + sudo apt-get install -y flatpak + + sudo su "$(whoami)" -c "flatpak --user remote-add --if-not-exists flathub \ + https://flathub.org/repo/flathub.flatpakrepo + " + + sudo su "$(whoami)" -c "flatpak --user install -y flathub \ + org.flatpak.Builder \ + org.freedesktop.Platform/${MATRIX_ARCH}/${FREEDESKTOP_SDK_VERSION} \ + org.freedesktop.Sdk/${MATRIX_ARCH}/${FREEDESKTOP_SDK_VERSION} \ + org.freedesktop.Sdk.Extension.node${NODE_VERSION}/${MATRIX_ARCH}/${FREEDESKTOP_SDK_VERSION} \ + org.freedesktop.Sdk.Extension.rust-stable/${MATRIX_ARCH}/${FREEDESKTOP_SDK_VERSION} \ + " + + flatpak run org.flatpak.Builder --version + + - name: Generate Flatpak Node sources + run: | + uv run --locked --no-sync python -m flatpak_node_generator \ + npm crates/client-web/package-lock.json \ + --node-sdk-extension "org.freedesktop.Sdk.Extension.node${NODE_VERSION}//${FREEDESKTOP_SDK_VERSION}" \ + --output generated-node-sources.json + + - name: Generate Flatpak Cargo sources + run: | + uv run --locked --no-sync python \ + ./packaging/linux/flatpak/deps/flatpak-builder-tools/cargo/flatpak-cargo-generator.py \ + Cargo.lock \ + --output generated-cargo-sources.json + + - name: Configure Flatpak manifest + env: + INPUT_RELEASE_COMMIT: ${{ inputs.release_commit }} + INPUT_RELEASE_VERSION: ${{ inputs.release_version }} + REPOSITORY_CLONE_URL: ${{ github.event.repository.clone_url }} + run: | + set -euo pipefail + build_date="$(git show -s --format=%cs "${INPUT_RELEASE_COMMIT}")" + + mkdir -p build artifacts + cp generated-node-sources.json build/ + cp generated-cargo-sources.json build/ + cp -r packaging/linux/flatpak/deps/shared-modules build/shared-modules + cp -r packaging/linux/flatpak/modules build/modules + cp "packaging/linux/flatpak/${APP_ID}.yml" "build/${APP_ID}.yml" + cp "packaging/linux/flatpak/${APP_ID}.metainfo.xml" "build/${APP_ID}.metainfo.xml" + + sed -i \ + -e "s|@BUILD_DATE@|${build_date}|g" \ + -e "s|@BUILD_VERSION@|${INPUT_RELEASE_VERSION}|g" \ + -e "s|@GITHUB_CLONE_URL@|${REPOSITORY_CLONE_URL}|g" \ + -e "s|@GITHUB_COMMIT@|${INPUT_RELEASE_COMMIT}|g" \ + "build/${APP_ID}.yml" + + sed -i \ + -e "s|@BUILD_DATE@|${build_date}|g" \ + -e "s|@BUILD_VERSION@|${INPUT_RELEASE_VERSION}|g" \ + "build/${APP_ID}.metainfo.xml" + + - name: Debug manifest + working-directory: build + run: cat "${APP_ID}.yml" + + - name: Cache Flatpak build + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ./build/.flatpak-builder + key: flatpak-${{ matrix.arch }}-${{ github.sha }} + restore-keys: | + flatpak-${{ matrix.arch }}- + + - name: Build Linux Flatpak + working-directory: build + run: | + sudo su "$(whoami)" -c "flatpak run org.flatpak.Builder \ + --arch=${MATRIX_ARCH} \ + --force-clean \ + --repo=repo \ + --sandbox \ + build-koko ${APP_ID}.yml" + + sudo su "$(whoami)" -c "flatpak build-bundle \ + --arch=${MATRIX_ARCH} \ + ./repo \ + ../artifacts/koko_${MATRIX_ARCH}.flatpak ${APP_ID}" + + - name: Lint Flatpak + working-directory: build + run: | + exceptions_file="${GITHUB_WORKSPACE}/packaging/linux/flatpak/exceptions.json" + + flatpak run --command=flatpak-builder-lint org.flatpak.Builder \ + --exceptions \ + --user-exceptions "${exceptions_file}" \ + manifest \ + "${APP_ID}.yml" + + flatpak run --command=flatpak-builder-lint org.flatpak.Builder \ + --exceptions \ + --user-exceptions "${exceptions_file}" \ + repo \ + repo + + - name: Package Flathub repo archive + if: matrix.arch == 'x86_64' + run: | + mkdir -p flathub/modules + cp "./build/generated-cargo-sources.json" "./flathub/" + cp "./build/generated-node-sources.json" "./flathub/" + cp "./build/${APP_ID}.yml" "./flathub/" + cp "./packaging/linux/flatpak/${APP_ID}.desktop" "./flathub/" + cp "./build/${APP_ID}.metainfo.xml" "./flathub/" + cp "./packaging/linux/flatpak/README.md" "./flathub/" + cp "./packaging/linux/flatpak/flathub.json" "./flathub/" + cp -r "./packaging/linux/flatpak/modules/." "./flathub/modules/" + # submodules will need to be handled in the workflow that creates the PR + tar -czf ./artifacts/flathub.tar.gz -C ./flathub . + + - name: Upload Artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + if-no-files-found: error + name: koko-flatpak-${{ matrix.arch }} + path: artifacts/ diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml index 00532aee..5eab01bc 100644 --- a/.github/workflows/ci-rust.yml +++ b/.github/workflows/ci-rust.yml @@ -39,6 +39,16 @@ jobs: container: rust:1.96-alpine3.24 cargo_features: '--no-default-features' shell: bash + - target: x86_64-unknown-linux-gnu # Ubuntu + os: ubuntu-latest + container: '' + cargo_features: '' + shell: bash + - target: aarch64-unknown-linux-gnu # Ubuntu + os: ubuntu-24.04-arm + container: '' + cargo_features: '' + shell: bash - target: x86_64-apple-darwin # macOS/Intel os: macos-latest container: '' @@ -93,6 +103,19 @@ jobs: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Install Ubuntu dependencies + if: matrix.container == '' && contains(matrix.os, 'ubuntu') + run: | + sudo apt-get update + sudo apt-get install -y \ + libayatana-appindicator3-dev \ + libdbus-1-dev \ + libgtk-3-dev \ + libssl-dev \ + libxdo-dev \ + p7zip-full \ + pkg-config + - name: Setup Rust if: matrix.container == '' uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 394dfc86..7a7edfbb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,6 +86,16 @@ jobs: run_build: true run_tests: false + build_flatpak: + name: Build Flatpak + needs: setup_release + permissions: + contents: read + uses: ./.github/workflows/ci-flatpak.yml + with: + release_commit: ${{ needs.setup_release.outputs.release_commit }} + release_version: ${{ needs.setup_release.outputs.release_version }} + coverage: name: Coverage if: >- @@ -109,6 +119,7 @@ jobs: - clippy - test - build + - build_flatpak permissions: contents: read uses: ./.github/workflows/ci-release.yml diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..4a37930c --- /dev/null +++ b/.gitmodules @@ -0,0 +1,8 @@ +[submodule "packaging/linux/flatpak/deps/flatpak-builder-tools"] + path = packaging/linux/flatpak/deps/flatpak-builder-tools + url = https://github.com/flatpak/flatpak-builder-tools.git + branch = master +[submodule "packaging/linux/flatpak/deps/shared-modules"] + path = packaging/linux/flatpak/deps/shared-modules + url = https://github.com/flathub/shared-modules.git + branch = master diff --git a/assets/Koko.png b/assets/Koko.png index bb422f03234ab457d02a1a5589a22e15db28c11a..adf1c3379884e2c16b522e77e0d06f97787666df 100644 GIT binary patch literal 42488 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 Cxs0Fy literal 36236 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 diff --git a/packaging/linux/flatpak/README.md b/packaging/linux/flatpak/README.md new file mode 100644 index 00000000..e78cc3f2 --- /dev/null +++ b/packaging/linux/flatpak/README.md @@ -0,0 +1,6 @@ +# Koko Flatpak + +This directory contains the Flatpak manifest and desktop metadata for Koko. + +The CI workflow generates npm and Cargo source manifests before invoking +`flatpak-builder`, so the build runs without network access inside the sandbox. diff --git a/packaging/linux/flatpak/deps/flatpak-builder-tools b/packaging/linux/flatpak/deps/flatpak-builder-tools new file mode 160000 index 00000000..737c0085 --- /dev/null +++ b/packaging/linux/flatpak/deps/flatpak-builder-tools @@ -0,0 +1 @@ +Subproject commit 737c0085912f9f7dabf9341d4608e2a77a51a73a diff --git a/packaging/linux/flatpak/deps/shared-modules b/packaging/linux/flatpak/deps/shared-modules new file mode 160000 index 00000000..75fa45bd --- /dev/null +++ b/packaging/linux/flatpak/deps/shared-modules @@ -0,0 +1 @@ +Subproject commit 75fa45bdee634e6b3ad36e1db33a69c1ba43417c diff --git a/packaging/linux/flatpak/dev.lizardbyte.app.Koko.desktop b/packaging/linux/flatpak/dev.lizardbyte.app.Koko.desktop new file mode 100644 index 00000000..b402fa3f --- /dev/null +++ b/packaging/linux/flatpak/dev.lizardbyte.app.Koko.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Categories=AudioVideo;Network; +Comment=Self-hosted media server +Exec=koko +Icon=dev.lizardbyte.app.Koko +Keywords=media;server;movies;tv;music;photos; +Name=Koko +Terminal=false +Type=Application +Version=1.0 diff --git a/packaging/linux/flatpak/dev.lizardbyte.app.Koko.metainfo.xml b/packaging/linux/flatpak/dev.lizardbyte.app.Koko.metainfo.xml new file mode 100644 index 00000000..34632f31 --- /dev/null +++ b/packaging/linux/flatpak/dev.lizardbyte.app.Koko.metainfo.xml @@ -0,0 +1,28 @@ + + + dev.lizardbyte.app.Koko + Koko + Self-hosted media server + CC0-1.0 + LicenseRef-LizardByte-Source-Available + + LizardByte + + +

+ Koko is a self-hosted media server for managing and streaming personal media libraries. +

+
+ + + https://github.com/LizardByte/Koko/releases/tag/v@BUILD_VERSION@ + +

See the changelog on GitHub

+
+
+
+ dev.lizardbyte.app.Koko.desktop + https://app.lizardbyte.dev + https://app.lizardbyte.dev/support + +
diff --git a/packaging/linux/flatpak/dev.lizardbyte.app.Koko.yml b/packaging/linux/flatpak/dev.lizardbyte.app.Koko.yml new file mode 100644 index 00000000..9695d45b --- /dev/null +++ b/packaging/linux/flatpak/dev.lizardbyte.app.Koko.yml @@ -0,0 +1,79 @@ +--- +app-id: dev.lizardbyte.app.Koko +runtime: org.freedesktop.Platform +runtime-version: "25.08" +sdk: org.freedesktop.Sdk +sdk-extensions: + - org.freedesktop.Sdk.Extension.rust-stable +add-build-extensions: + org.freedesktop.Sdk.Extension.node24: + directory: lib/sdk/node24 + version: "25.08" + no-autodownload: true + autodelete: false +command: koko +separate-locales: false + +finish-args: + - --filesystem=home + - --share=network + - --share=ipc + - --socket=fallback-x11 + - --socket=wayland + - --talk-name=org.freedesktop.secrets + - --talk-name=org.kde.StatusNotifierWatcher + +cleanup: + - /include + - /lib/cmake + - /lib/pkgconfig + - /lib/*.la + - /lib/*.a + - /share/man + +modules: + - shared-modules/libayatana-appindicator/libayatana-appindicator-gtk3.json + - modules/xdotool.json + + - name: koko + buildsystem: simple + build-options: + append-path: /usr/lib/sdk/rust-stable/bin:/usr/lib/sdk/node24/bin + env: + BUILD_VERSION: "@BUILD_VERSION@" + CARGO_HOME: /run/build/koko/cargo + CARGO_NET_OFFLINE: "true" + LD_LIBRARY_PATH: /app/lib + LIBRARY_PATH: /app/lib + npm_config_cache: /run/build/koko/flatpak-node/npm-cache + npm_config_nodedir: /usr/lib/sdk/node24 + npm_config_offline: "true" + NPM_CONFIG_LOGLEVEL: info + PKG_CONFIG_PATH: /app/lib/pkgconfig:/app/share/pkgconfig + XDG_CACHE_HOME: /run/build/koko/flatpak-node/cache + build-commands: + - install -Dm0644 cargo/config .cargo/config.toml + - cd crates/client-web && npm ci --offline --ignore-scripts && npm run build + - cargo build --offline --locked --release + - install -Dm0755 target/release/koko /app/libexec/koko/koko + - install -Dm0755 packaging/linux/flatpak/scripts/koko.sh /app/bin/koko + - install -dm0755 /app/share/koko + - cp -a crates/client-web/dist /app/share/koko/client-web + - install -Dm0644 assets/Koko.svg /app/share/icons/hicolor/scalable/apps/dev.lizardbyte.app.Koko.svg + - >- + install -Dm0644 + packaging/linux/flatpak/dev.lizardbyte.app.Koko.desktop + /app/share/applications/dev.lizardbyte.app.Koko.desktop + - >- + install -Dm0644 + packaging/linux/flatpak/dev.lizardbyte.app.Koko.metainfo.xml + /app/share/metainfo/dev.lizardbyte.app.Koko.metainfo.xml + sources: + - type: git + url: "@GITHUB_CLONE_URL@" + commit: "@GITHUB_COMMIT@" + - type: file + path: dev.lizardbyte.app.Koko.metainfo.xml + dest: packaging/linux/flatpak + - generated-node-sources.json + - generated-cargo-sources.json diff --git a/packaging/linux/flatpak/exceptions.json b/packaging/linux/flatpak/exceptions.json new file mode 100644 index 00000000..6a31def4 --- /dev/null +++ b/packaging/linux/flatpak/exceptions.json @@ -0,0 +1,8 @@ +{ + "dev.lizardbyte.app.Koko": [ + "appid-url-not-reachable", + "appstream-screenshots-not-mirrored-in-ostree", + "metainfo-missing-screenshots", + "finish-args-home-filesystem-access" + ] +} diff --git a/packaging/linux/flatpak/flathub.json b/packaging/linux/flatpak/flathub.json new file mode 100644 index 00000000..2de28147 --- /dev/null +++ b/packaging/linux/flatpak/flathub.json @@ -0,0 +1,3 @@ +{ + "disable-external-data-checker": true +} diff --git a/packaging/linux/flatpak/modules/xdotool.json b/packaging/linux/flatpak/modules/xdotool.json new file mode 100644 index 00000000..847d2e77 --- /dev/null +++ b/packaging/linux/flatpak/modules/xdotool.json @@ -0,0 +1,23 @@ +{ + "name": "xdotool", + "buildsystem": "simple", + "build-commands": [ + "make libxdo.so libxdo.so.4 libxdo.pc PREFIX=\"${FLATPAK_DEST}\" WITHOUT_RPATH_FIX=1", + "install -Dm0755 libxdo.so \"${FLATPAK_DEST}/lib/libxdo.so.4\"", + "ln -sf libxdo.so.4 \"${FLATPAK_DEST}/lib/libxdo.so\"", + "install -Dm0644 xdo.h \"${FLATPAK_DEST}/include/xdo.h\"", + "install -Dm0644 libxdo.pc \"${FLATPAK_DEST}/lib/pkgconfig/libxdo.pc\"" + ], + "cleanup": [ + "/include", + "/lib/pkgconfig" + ], + "sources": [ + { + "type": "git", + "url": "https://github.com/jordansissel/xdotool.git", + "tag": "v4.20260303.1", + "commit": "d37c16111dbe38ea29194f20ac9de03cff8dfc1c" + } + ] +} diff --git a/packaging/linux/flatpak/scripts/koko.sh b/packaging/linux/flatpak/scripts/koko.sh new file mode 100644 index 00000000..7436143b --- /dev/null +++ b/packaging/linux/flatpak/scripts/koko.sh @@ -0,0 +1,3 @@ +#!/bin/sh +export KOKO_WEB_CLIENT_DIST="${KOKO_WEB_CLIENT_DIST:-/app/share/koko/client-web}" +exec /app/libexec/koko/koko "$@" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..45bb7533 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,34 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "Koko" +version = "0.0.0" +description = "Python tooling for Koko" +requires-python = ">=3.14" +license = {text = "LicenseRef-LizardByte-Source-Available"} +authors = [ + {name = "LizardByte", email = "lizardbyte@users.noreply.github.com"} +] + +dependencies = [] + +[dependency-groups] +flatpak = [ + "aiohttp==3.14.1", + "flatpak_node_generator==0.1.0", + "pyyaml==6.0.3", + "tomlkit==0.15.0", +] + +[project.urls] +Homepage = "https://app.lizardbyte.dev" +Repository = "https://github.com/LizardByte/Koko" +Issues = "https://github.com/LizardByte/Koko/issues" + +[tool.setuptools] +py-modules = [] + +[tool.uv.sources] +flatpak_node_generator = { path = "packaging/linux/flatpak/deps/flatpak-builder-tools/node" } diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..df569804 --- /dev/null +++ b/uv.lock @@ -0,0 +1,350 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/c6/61a2d7b7572279226bb2e7f61d7a19ca7c90da0329c93fa0d560cbf288d8/aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64", size = 22591, upload-time = "2026-05-20T15:12:24.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4", size = 15062, upload-time = "2026-05-20T15:12:23.328Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, + { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, + { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, + { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, + { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, + { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, + { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, + { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, + { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, + { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, + { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, + { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, + { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, + { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, + { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, + { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, + { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, + { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, + { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "flatpak-node-generator" +version = "0.1.0" +source = { directory = "packaging/linux/flatpak/deps/flatpak-builder-tools/node" } +dependencies = [ + { name = "aiohttp" }, + { name = "pyyaml" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiohttp", specifier = ">=3.9.0,<4.0.0" }, + { name = "pyyaml", specifier = ">=6.0,<7.0" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "koko" +version = "0.0.0" +source = { editable = "." } + +[package.dev-dependencies] +flatpak = [ + { name = "aiohttp" }, + { name = "flatpak-node-generator" }, + { name = "pyyaml" }, + { name = "tomlkit" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +flatpak = [ + { name = "aiohttp", specifier = "==3.14.1" }, + { name = "flatpak-node-generator", directory = "packaging/linux/flatpak/deps/flatpak-builder-tools/node" }, + { name = "pyyaml", specifier = "==6.0.3" }, + { name = "tomlkit", specifier = "==0.15.0" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" }, +] + +[[package]] +name = "yarl" +version = "1.24.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670, upload-time = "2026-05-19T21:29:56.631Z" }, + { url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916, upload-time = "2026-05-19T21:29:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625, upload-time = "2026-05-19T21:30:00.412Z" }, + { url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574, upload-time = "2026-05-19T21:30:02.544Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534, upload-time = "2026-05-19T21:30:04.319Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481, upload-time = "2026-05-19T21:30:05.988Z" }, + { url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529, upload-time = "2026-05-19T21:30:07.738Z" }, + { url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338, upload-time = "2026-05-19T21:30:09.713Z" }, + { url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147, upload-time = "2026-05-19T21:30:11.365Z" }, + { url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272, upload-time = "2026-05-19T21:30:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962, upload-time = "2026-05-19T21:30:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063, upload-time = "2026-05-19T21:30:16.683Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438, upload-time = "2026-05-19T21:30:18.769Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458, upload-time = "2026-05-19T21:30:21.024Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589, upload-time = "2026-05-19T21:30:23.412Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424, upload-time = "2026-05-19T21:30:25.425Z" }, + { url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690, upload-time = "2026-05-19T21:30:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248, upload-time = "2026-05-19T21:30:29.297Z" }, + { url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084, upload-time = "2026-05-19T21:30:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272, upload-time = "2026-05-19T21:30:33.062Z" }, + { url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497, upload-time = "2026-05-19T21:30:34.842Z" }, + { url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002, upload-time = "2026-05-19T21:30:37.724Z" }, + { url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524, upload-time = "2026-05-19T21:30:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165, upload-time = "2026-05-19T21:30:41.888Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010, upload-time = "2026-05-19T21:30:43.985Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128, upload-time = "2026-05-19T21:30:46.291Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382, upload-time = "2026-05-19T21:30:48.085Z" }, + { url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964, upload-time = "2026-05-19T21:30:49.785Z" }, + { url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204, upload-time = "2026-05-19T21:30:51.862Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510, upload-time = "2026-05-19T21:30:53.62Z" }, + { url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584, upload-time = "2026-05-19T21:30:55.962Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410, upload-time = "2026-05-19T21:30:57.962Z" }, + { url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980, upload-time = "2026-05-19T21:30:59.735Z" }, + { url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, +] From 199c1f602ed54d91bbc1d938e124307dbf1c8afa Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 14 Jun 2026 17:33:40 -0400 Subject: [PATCH 116/128] Update test_signal_handler.rs --- crates/server/tests/test_signal_handler.rs | 109 +++++++++++++-------- 1 file changed, 69 insertions(+), 40 deletions(-) diff --git a/crates/server/tests/test_signal_handler.rs b/crates/server/tests/test_signal_handler.rs index 10689565..1574402d 100644 --- a/crates/server/tests/test_signal_handler.rs +++ b/crates/server/tests/test_signal_handler.rs @@ -116,32 +116,45 @@ mod shutdown_signal { fn wait_with_timeout() { let signal = ShutdownSignal::new(); let signal_clone = signal.clone(); + let (waiting_tx, waiting_rx) = std::sync::mpsc::channel(); - // Spawn a thread that will set shutdown after a short delay - thread::spawn(move || { - thread::sleep(Duration::from_millis(50)); - signal_clone.shutdown(); - }); + // Use a custom wait implementation that can timeout for testing. + let handle = thread::spawn(move || { + let start = std::time::Instant::now(); + let mut waited = false; + let mut waiting_sent = false; - // Test wait with a reasonable timeout - let start = std::time::Instant::now(); + while !signal_clone.is_shutdown() { + waited = true; + if !waiting_sent { + waiting_tx + .send(()) + .expect("Should notify that wait loop started"); + waiting_sent = true; + } - // Use a custom wait implementation that can timeout for testing - let mut waited = false; - while !signal.is_shutdown() { - thread::sleep(Duration::from_millis(10)); - if start.elapsed() > Duration::from_millis(200) { - break; + if start.elapsed() > Duration::from_secs(5) { + return Err("Timed out waiting for shutdown signal"); + } + + thread::sleep(Duration::from_millis(10)); } - waited = true; - } + + Ok(waited) + }); + + waiting_rx + .recv_timeout(Duration::from_secs(5)) + .expect("Wait loop should start before shutdown"); + signal.shutdown(); + + let waited = handle + .join() + .expect("Wait thread should complete without panicking") + .expect("Should not have timed out"); assert!(waited, "Should have waited for shutdown signal"); assert!(signal.is_shutdown(), "Signal should be shutdown after wait"); - assert!( - start.elapsed() < Duration::from_millis(200), - "Should not have timed out" - ); } #[test] @@ -245,28 +258,37 @@ mod shutdown_signal { fn wait_functionality() { let signal = ShutdownSignal::new(); let signal_clone = signal.clone(); + let (waiting_tx, waiting_rx) = std::sync::mpsc::channel(); + let (completed_tx, completed_rx) = std::sync::mpsc::channel(); - // Spawn a thread that will signal shutdown after a delay let handle = thread::spawn(move || { - thread::sleep(Duration::from_millis(50)); - signal_clone.shutdown(); + waiting_tx + .send(()) + .expect("Should notify that wait is about to start"); + signal_clone.wait(); + completed_tx + .send(signal_clone.is_shutdown()) + .expect("Should notify that wait completed"); }); - // Test the wait method - this should return once shutdown is signaled - let start = std::time::Instant::now(); - signal.wait(); - let elapsed = start.elapsed(); - - // Should have waited for about 50ms + waiting_rx + .recv_timeout(Duration::from_secs(5)) + .expect("Wait thread should start"); assert!( - elapsed >= Duration::from_millis(40), - "Should have waited for shutdown signal" + matches!( + completed_rx.try_recv(), + Err(std::sync::mpsc::TryRecvError::Empty) + ), + "Wait should block until shutdown is signaled" ); + + signal.shutdown(); assert!( - elapsed < Duration::from_millis(300), - "Should not have waited too long" + completed_rx + .recv_timeout(Duration::from_secs(5)) + .expect("Wait should return after shutdown is signaled"), + "Signal should be shutdown after wait" ); - assert!(signal.is_shutdown(), "Signal should be shutdown after wait"); handle.join().unwrap(); } @@ -278,16 +300,23 @@ mod shutdown_signal { // Signal shutdown first signal.shutdown(); - // Then call wait - should return immediately - let start = std::time::Instant::now(); - signal.wait(); - let elapsed = start.elapsed(); + let signal_clone = signal.clone(); + let (completed_tx, completed_rx) = std::sync::mpsc::channel(); + + let handle = thread::spawn(move || { + signal_clone.wait(); + completed_tx + .send(signal_clone.is_shutdown()) + .expect("Should notify that wait completed"); + }); - // Should return almost immediately since signal is already shutdown assert!( - elapsed < Duration::from_millis(50), - "Wait should return immediately for already shutdown signal" + completed_rx + .recv_timeout(Duration::from_secs(5)) + .expect("Wait should return for already shutdown signal"), + "Signal should remain shutdown after wait" ); + handle.join().unwrap(); } } From 5e2aedaa38a542abe4a69c0273924f53b224f5d8 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 14 Jun 2026 21:41:41 -0400 Subject: [PATCH 117/128] Add GNU targets and adjust tarpaulin for aarch64 Include x86_64-unknown-linux-gnu and aarch64-unknown-linux-gnu in the coverage matrix. In CI Rust workflow, add a conditional to set tarpaulin to --jobs 1 for the aarch64-unknown-linux-gnu target and pass the computed tarpaulin_args into the cargo tarpaulin invocation to avoid parallel-run issues on that target. --- .github/workflows/ci-coverage.yml | 2 ++ .github/workflows/ci-rust.yml | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/.github/workflows/ci-coverage.yml b/.github/workflows/ci-coverage.yml index a2b5665d..2c5bb337 100644 --- a/.github/workflows/ci-coverage.yml +++ b/.github/workflows/ci-coverage.yml @@ -16,6 +16,8 @@ jobs: target: - x86_64-unknown-linux-musl - aarch64-unknown-linux-musl + - x86_64-unknown-linux-gnu + - aarch64-unknown-linux-gnu - x86_64-apple-darwin - aarch64-apple-darwin - x86_64-pc-windows-msvc diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml index 5eab01bc..0ccede9b 100644 --- a/.github/workflows/ci-rust.yml +++ b/.github/workflows/ci-rust.yml @@ -159,10 +159,16 @@ jobs: id: test if: inputs.run_tests run: | + tarpaulin_args=() + if [[ "${{ matrix.target }}" == "aarch64-unknown-linux-gnu" ]]; then + tarpaulin_args+=(--jobs 1) + fi + cargo tarpaulin \ --locked \ --color always \ --engine llvm \ + "${tarpaulin_args[@]}" \ --no-fail-fast \ --out Xml \ ${{ matrix.cargo_features }} \ From 254fc0d36444dfe97b4825674ce364e11c2f3ccc Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 14 Jun 2026 23:20:21 -0400 Subject: [PATCH 118/128] Add Windows installer CI and WiX packaging Extend CI to produce Windows builds and installers: update ci-rust workflow to include Windows/ARM64 target, upload Windows build artifacts, and add a windows_installer job that packages MSI installers with cargo-wix/WiX, signs executables/installers via Azure Trusted Signing when configured, and archives artifacts. Wire Azure signing inputs/secrets through ci.yml. Add cargo-wix to crates/server/Cargo.toml and add a WiX manifest at crates/server/wix/main.wxs to define the installer layout and shortcuts. --- .github/workflows/ci-coverage.yml | 1 + .github/workflows/ci-rust.yml | 21 ++- .github/workflows/ci-windows-installer.yml | 161 +++++++++++++++++++++ .github/workflows/ci.yml | 20 +++ crates/server/Cargo.toml | 1 + crates/server/wix/main.wxs | 105 ++++++++++++++ 6 files changed, 306 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/ci-windows-installer.yml create mode 100644 crates/server/wix/main.wxs diff --git a/.github/workflows/ci-coverage.yml b/.github/workflows/ci-coverage.yml index 2c5bb337..a126bd09 100644 --- a/.github/workflows/ci-coverage.yml +++ b/.github/workflows/ci-coverage.yml @@ -21,6 +21,7 @@ jobs: - x86_64-apple-darwin - aarch64-apple-darwin - x86_64-pc-windows-msvc + - aarch64-pc-windows-msvc name: Coverage (${{ matrix.target }}) permissions: contents: read diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml index 0ccede9b..b4ce76b9 100644 --- a/.github/workflows/ci-rust.yml +++ b/.github/workflows/ci-rust.yml @@ -64,6 +64,11 @@ jobs: container: '' cargo_features: '' shell: bash + - target: aarch64-pc-windows-msvc # Windows/ARM64 + os: windows-11-arm + container: '' + cargo_features: '' + shell: bash name: ${{ inputs.job_name }} (${{ matrix.target }}) permissions: contents: read @@ -160,7 +165,7 @@ jobs: if: inputs.run_tests run: | tarpaulin_args=() - if [[ "${{ matrix.target }}" == "aarch64-unknown-linux-gnu" ]]; then + if [[ "${{ matrix.target }}" == "aarch64-unknown-linux-gnu" || "${{ matrix.target }}" == "aarch64-pc-windows-msvc" ]]; then tarpaulin_args+=(--jobs 1) fi @@ -191,7 +196,7 @@ jobs: run: cargo build --locked ${{ matrix.cargo_features }} --target ${{ matrix.target }} --release - name: Create 7z archive - if: inputs.run_build + if: inputs.run_build && !contains(matrix.target, 'windows') run: | mkdir -p artifacts @@ -204,8 +209,18 @@ jobs: "./assets" \ "./target/${{ matrix.target }}/release/koko${extension}" + - name: Upload Windows build artifact + if: inputs.run_build && contains(matrix.target, 'windows') + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + if-no-files-found: error + name: windows-build-${{ matrix.target }} + path: | + target/${{ matrix.target }}/release/koko.exe + crates/client-web/dist + - name: Upload Artifacts - if: inputs.run_build + if: inputs.run_build && !contains(matrix.target, 'windows') uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: if-no-files-found: error diff --git a/.github/workflows/ci-windows-installer.yml b/.github/workflows/ci-windows-installer.yml new file mode 100644 index 00000000..db5a1c41 --- /dev/null +++ b/.github/workflows/ci-windows-installer.yml @@ -0,0 +1,161 @@ +--- +name: CI-Windows-Installer +permissions: {} + +on: + workflow_call: + inputs: + azure_signing_account: + default: '' + required: false + type: string + azure_signing_cert_profile: + default: '' + required: false + type: string + azure_signing_endpoint: + default: '' + required: false + type: string + publish_release: + default: false + required: false + type: boolean + release_version: + default: '' + required: false + type: string + secrets: + AZURE_CLIENT_ID: + required: false + AZURE_CLIENT_SECRET: + required: false + AZURE_TENANT_ID: + required: false + +jobs: + windows_installer: + name: Windows Installer (${{ matrix.target }}) + permissions: + contents: read + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-pc-windows-msvc + - target: aarch64-pc-windows-msvc + env: + CARGO_TERM_COLOR: always + CARGO_WIX_REV: fde983c2e901970267e76b8fd68120fdd5457a57 + WIX_EXTENSION_VERSION: 7.0.0 + WIX_TOOL_PATH: ${{ github.workspace }}\.wix + WIX_VERSION: 7.0.0 + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Download Windows build artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: windows-build-${{ matrix.target }} + path: . + + - name: Setup Rust + uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0 + with: + target: ${{ matrix.target }} + cache: true + cache-on-failure: false + + - name: Install cargo-edit + if: inputs.publish_release + run: cargo install cargo-edit + + - name: Update Version + if: inputs.publish_release + env: + INPUTS_RELEASE_VERSION: ${{ inputs.release_version }} + shell: pwsh + run: | + cargo set-version $env:INPUTS_RELEASE_VERSION + cargo update --workspace + cargo metadata --locked --no-deps --format-version 1 > $null + + - name: Setup dotnet + uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 + with: + dotnet-version: '10.x' + + - name: Install WiX + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path $env:WIX_TOOL_PATH | Out-Null + dotnet tool install --tool-path $env:WIX_TOOL_PATH wix --version $env:WIX_VERSION + $env:WIX_TOOL_PATH >> $env:GITHUB_PATH + $env:PATH = "$env:WIX_TOOL_PATH;$env:PATH" + wix eula accept wix7 + wix extension add "WixToolset.UI.wixext/$env:WIX_EXTENSION_VERSION" + wix extension add "WixToolset.Util.wixext/$env:WIX_EXTENSION_VERSION" + wix --version + + - name: Install cargo-wix + run: >- + cargo install --locked + --git https://github.com/volks73/cargo-wix.git + --rev ${{ env.CARGO_WIX_REV }} + cargo-wix + + - name: Sign Windows executable + if: inputs.publish_release && inputs.azure_signing_account != '' + uses: azure/trusted-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0 + with: + azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} + azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }} + azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} + certificate-profile-name: ${{ inputs.azure_signing_cert_profile }} + endpoint: ${{ inputs.azure_signing_endpoint }} + files: ${{ github.workspace }}\target\${{ matrix.target }}\release\koko.exe + signing-account-name: ${{ inputs.azure_signing_account }} + + - name: Create Windows archive + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path artifacts | Out-Null + 7z a "artifacts\koko-${{ matrix.target }}.7z" ` + ".\assets" ` + ".\target\${{ matrix.target }}\release\koko.exe" + + - name: Package Windows installer + shell: pwsh + run: | + cargo wix ` + --toolset modern ` + --target ${{ matrix.target }} ` + --no-build ` + --target-bin-dir "${{ github.workspace }}\target\${{ matrix.target }}\release" ` + --package koko ` + --output "artifacts\koko-${{ matrix.target }}-installer.msi" ` + --nocapture ` + crates/server/Cargo.toml + + - name: Sign Windows installer + if: inputs.publish_release && inputs.azure_signing_account != '' + uses: azure/trusted-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0 + with: + azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} + azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }} + azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} + certificate-profile-name: ${{ inputs.azure_signing_cert_profile }} + endpoint: ${{ inputs.azure_signing_endpoint }} + files-folder: artifacts + files-folder-filter: msi + files-folder-recurse: false + signing-account-name: ${{ inputs.azure_signing_account }} + + - name: Upload Artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + if-no-files-found: error + name: koko-${{ matrix.target }} + path: artifacts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a7edfbb..8da00199 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,6 +86,25 @@ jobs: run_build: true run_tests: false + build_windows_installer: + name: Build Windows Installer + needs: + - setup_release + - build + permissions: + contents: read + uses: ./.github/workflows/ci-windows-installer.yml + with: + azure_signing_account: ${{ vars.AZURE_SIGNING_ACCOUNT }} + azure_signing_cert_profile: ${{ vars.AZURE_SIGNING_CERT_PROFILE }} + azure_signing_endpoint: ${{ vars.AZURE_SIGNING_ENDPOINT }} + publish_release: ${{ needs.setup_release.outputs.publish_release == 'true' }} + release_version: ${{ needs.setup_release.outputs.release_version }} + secrets: + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + build_flatpak: name: Build Flatpak needs: setup_release @@ -119,6 +138,7 @@ jobs: - clippy - test - build + - build_windows_installer - build_flatpak permissions: contents: read diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index a96d9dda..f9ba445b 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -109,3 +109,4 @@ cargo-run-bin = "1.7.4" [package.metadata.bin] cargo-edit = { version = "0.13.1" } cargo-tarpaulin = { version = "0.31.5" } +cargo-wix = { version = "0.3.9" } diff --git a/crates/server/wix/main.wxs b/crates/server/wix/main.wxs new file mode 100644 index 00000000..aa3fd2e0 --- /dev/null +++ b/crates/server/wix/main.wxs @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From efaab06078fa060600d522b561f6f9fd852bb1eb Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:46:38 -0400 Subject: [PATCH 119/128] Improve tray icon lookup and Flatpak assets Make tray icon resolution more robust by adding icon_path() and icon_path_candidates() that search multiple locations (including an env KOKO_ASSETS_DIR and executable-relative share/koko/assets) and fall back to the source path. Enable tao EventLoopBuilder::with_any_thread on Windows so the tray can run on any thread, and adjust load_icon to accept a Path reference. Packaging: copy assets into the Flatpak dev bundle and set a default KOKO_ASSETS_DIR in packaging/linux/flatpak/scripts/koko.sh so the runtime can point to bundled assets. --- crates/server/src/tray.rs | 67 +++++++++++++++++-- .../linux/flatpak/dev.lizardbyte.app.Koko.yml | 1 + packaging/linux/flatpak/scripts/koko.sh | 1 + 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/crates/server/src/tray.rs b/crates/server/src/tray.rs index b6230d18..de443149 100644 --- a/crates/server/src/tray.rs +++ b/crates/server/src/tray.rs @@ -1,6 +1,14 @@ //! Tray icon utilities for the application. +// standard imports +use std::path::{ + Path, + PathBuf, +}; + // lib imports +#[cfg(target_os = "windows")] +use tao::platform::windows::EventLoopBuilderExtWindows; use tao::{ event::Event, event_loop::{ @@ -25,6 +33,9 @@ use tray_icon::{ use crate::globals; use crate::signal_handler::ShutdownSignal; +const KOKO_ASSETS_DIR_ENV: &str = "KOKO_ASSETS_DIR"; +const ICON_FILE_NAME: &str = "icon.ico"; + #[derive(Debug)] enum UserEvent { TrayIconEvent(TrayIconEvent), @@ -33,9 +44,12 @@ enum UserEvent { /// Launch the tray icon and event loop with graceful shutdown support. pub fn launch_with_shutdown(shutdown_signal: ShutdownSignal) { - let path = std::path::Path::new(globals::GLOBAL_ICON_ICO_PATH); + let path = icon_path(); - let event_loop = EventLoopBuilder::::with_user_event().build(); + let mut event_loop_builder = EventLoopBuilder::::with_user_event(); + #[cfg(target_os = "windows")] + event_loop_builder.with_any_thread(true); + let event_loop = event_loop_builder.build(); // set a tray event handler that forwards the event and wakes up the event loop let proxy = event_loop.create_proxy(); @@ -132,7 +146,7 @@ pub fn launch_with_shutdown(shutdown_signal: ShutdownSignal) { match event { Event::NewEvents(tao::event::StartCause::Init) => { - let icon = load_icon(std::path::Path::new(path)); + let icon = load_icon(&path); // We create the icon once the event loop is actually running // to prevent issues like https://github.com/tauri-apps/tray-icon/issues/90 @@ -215,8 +229,53 @@ pub fn launch_with_shutdown(shutdown_signal: ShutdownSignal) { }) } +fn icon_path() -> PathBuf { + icon_path_candidates() + .into_iter() + .find(|path| path.exists()) + .unwrap_or_else(source_icon_path) +} + +fn icon_path_candidates() -> Vec { + let configured_path = PathBuf::from(globals::GLOBAL_ICON_ICO_PATH); + + let mut candidates = Vec::new(); + + if let Ok(assets_dir) = std::env::var(KOKO_ASSETS_DIR_ENV) { + candidates.push(PathBuf::from(assets_dir).join(ICON_FILE_NAME)); + } + + candidates.push(configured_path.clone()); + + if let Ok(executable_path) = std::env::current_exe() { + if let Some(executable_dir) = executable_path.parent() { + candidates.push(executable_dir.join(&configured_path)); + + for ancestor in executable_dir.ancestors() { + candidates.push( + ancestor + .join("share") + .join("koko") + .join("assets") + .join(ICON_FILE_NAME), + ); + } + } + } + + candidates.push(source_icon_path()); + candidates +} + +fn source_icon_path() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join(globals::GLOBAL_ICON_ICO_PATH) +} + /// Load an icon from a file path. -pub fn load_icon(path: &std::path::Path) -> tray_icon::Icon { +pub fn load_icon(path: &Path) -> tray_icon::Icon { let (icon_rgba, icon_width, icon_height) = { let image = image::open(path) .expect("Failed to open icon path") diff --git a/packaging/linux/flatpak/dev.lizardbyte.app.Koko.yml b/packaging/linux/flatpak/dev.lizardbyte.app.Koko.yml index 9695d45b..a79d9bb7 100644 --- a/packaging/linux/flatpak/dev.lizardbyte.app.Koko.yml +++ b/packaging/linux/flatpak/dev.lizardbyte.app.Koko.yml @@ -58,6 +58,7 @@ modules: - install -Dm0755 target/release/koko /app/libexec/koko/koko - install -Dm0755 packaging/linux/flatpak/scripts/koko.sh /app/bin/koko - install -dm0755 /app/share/koko + - cp -a assets /app/share/koko/assets - cp -a crates/client-web/dist /app/share/koko/client-web - install -Dm0644 assets/Koko.svg /app/share/icons/hicolor/scalable/apps/dev.lizardbyte.app.Koko.svg - >- diff --git a/packaging/linux/flatpak/scripts/koko.sh b/packaging/linux/flatpak/scripts/koko.sh index 7436143b..402573c6 100644 --- a/packaging/linux/flatpak/scripts/koko.sh +++ b/packaging/linux/flatpak/scripts/koko.sh @@ -1,3 +1,4 @@ #!/bin/sh +export KOKO_ASSETS_DIR="${KOKO_ASSETS_DIR:-/app/share/koko/assets}" export KOKO_WEB_CLIENT_DIST="${KOKO_WEB_CLIENT_DIST:-/app/share/koko/client-web}" exec /app/libexec/koko/koko "$@" From dd2d078ae13143aad5d0b91767a08d14ba2214a1 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:44:03 -0400 Subject: [PATCH 120/128] Add launch_rocket_with_shutdown and isolate web tests Refactor web server startup to support launching a pre-built Rocket instance with graceful shutdown by introducing launch_rocket_with_shutdown(rocket, shutdown_signal). Retain launch_with_shutdown as a thin wrapper. Update integration test to run an isolated web server: add TestServerStateGuard and configure_isolated_web_server_settings to create a temporary data dir, override settings, and provide a test DB path. The test now constructs a rocket via web::rocket_with_db_path(...), attaches a liftoff oneshot notifier to wait for server start, calls launch_rocket_with_shutdown, and increases the shutdown wait timeout from 2s to 10s. Cleanup restores original settings and removes the temporary test directory. --- crates/server/src/web/mod.rs | 10 ++- crates/server/tests/test_signal_handler.rs | 72 ++++++++++++++++++++-- 2 files changed, 76 insertions(+), 6 deletions(-) diff --git a/crates/server/src/web/mod.rs b/crates/server/src/web/mod.rs index b0b971b4..d8f2ef10 100644 --- a/crates/server/src/web/mod.rs +++ b/crates/server/src/web/mod.rs @@ -193,7 +193,15 @@ fn sqlite_database_url(db_path: &str) -> String { /// Launch the web server with graceful shutdown support. pub async fn launch_with_shutdown(shutdown_signal: ShutdownSignal) { - let rocket = rocket().ignite().await.expect("Failed to ignite rocket"); + launch_rocket_with_shutdown(rocket(), shutdown_signal).await; +} + +/// Launch a configured Rocket instance with graceful shutdown support. +pub async fn launch_rocket_with_shutdown( + rocket: rocket::Rocket, + shutdown_signal: ShutdownSignal, +) { + let rocket = rocket.ignite().await.expect("Failed to ignite rocket"); let rocket_shutdown = rocket.shutdown(); // Start the rocket server diff --git a/crates/server/tests/test_signal_handler.rs b/crates/server/tests/test_signal_handler.rs index 1574402d..db7a3b1a 100644 --- a/crates/server/tests/test_signal_handler.rs +++ b/crates/server/tests/test_signal_handler.rs @@ -17,12 +17,59 @@ use std::time::Duration; use tokio::time::timeout; // local imports +use koko::config::{ + Settings, + current_settings, + replace_current_settings, +}; use koko::signal_handler::{ ShutdownCoordinator, ShutdownSignal, }; use koko::web; +struct TestServerStateGuard { + original_settings: Settings, + test_dir: std::path::PathBuf, +} + +impl Drop for TestServerStateGuard { + fn drop(&mut self) { + replace_current_settings(self.original_settings.clone()); + let _ = std::fs::remove_dir_all(&self.test_dir); + } +} + +fn configure_isolated_web_server_settings() -> (TestServerStateGuard, String) { + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let test_dir = std::env::temp_dir().join(format!( + "koko_web_shutdown_{}_{}", + std::process::id(), + timestamp + )); + let data_dir = test_dir.join("data"); + std::fs::create_dir_all(&data_dir).expect("Failed to create isolated web server data dir"); + + let original_settings = current_settings(); + let mut settings = original_settings.clone(); + settings.general.data_dir = data_dir.to_string_lossy().to_string(); + settings.server.port = 0; + settings.server.use_https = false; + replace_current_settings(settings); + + let db_path = test_dir.join("koko.db").to_string_lossy().to_string(); + ( + TestServerStateGuard { + original_settings, + test_dir, + }, + db_path, + ) +} + mod shutdown_signal { use super::*; @@ -661,25 +708,40 @@ mod integration { #[tokio::test] async fn web_server_shutdown_signal_handling() { + let (_test_server_state_guard, db_path) = configure_isolated_web_server_settings(); let shutdown_signal = ShutdownSignal::new(); let shutdown_signal_clone = shutdown_signal.clone(); + let (launched_tx, launched_rx) = tokio::sync::oneshot::channel(); + let launch_notifier = Arc::new(std::sync::Mutex::new(Some(launched_tx))); + let rocket = web::rocket_with_db_path(Some(db_path)).attach( + rocket::fairing::AdHoc::on_liftoff("Notify test launch", move |_| { + let launch_notifier = Arc::clone(&launch_notifier); + Box::pin(async move { + if let Some(launched_tx) = launch_notifier.lock().unwrap().take() { + let _ = launched_tx.send(()); + } + }) + }), + ); // Start web server in background let web_handle = tokio::spawn(async move { - web::launch_with_shutdown(shutdown_signal_clone).await; + web::launch_rocket_with_shutdown(rocket, shutdown_signal_clone).await; }); - // Give the server a moment to start - tokio::time::sleep(Duration::from_millis(100)).await; + timeout(Duration::from_secs(30), launched_rx) + .await + .expect("Web server should launch within 30 seconds") + .expect("Web server task should not exit before launch"); // Signal shutdown shutdown_signal.shutdown(); // Web server should shut down within a reasonable time - let result = timeout(Duration::from_secs(2), web_handle).await; + let result = timeout(Duration::from_secs(10), web_handle).await; assert!( result.is_ok(), - "Web server should shut down within 2 seconds" + "Web server should shut down within 10 seconds" ); } From c836e0e5a49104cd0ce5b23736d0e03ac74ee5c6 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:58:39 -0400 Subject: [PATCH 121/128] Make tray event loop exit cleanly and update tests Use EventLoopExtRunReturn::run_return and mutable event loop so the tray loop can set ControlFlow::Exit instead of calling std::process::exit, allowing the function to return cleanly for tests and shutdown handling. Clear global tray/menu event handlers after the loop to avoid lingering state. Import the run_return extension and adjust variable mutability. Update tests: replace the previous spawn-based non-panicking test with a focused load_icon test and add a Windows-only test that verifies launch_with_shutdown exits immediately when signaled. --- .github/workflows/ci-rust.yml | 2 +- crates/server/src/tray.rs | 17 +++++++++----- crates/server/tests/test_tray.rs | 38 ++++++++++++++++++-------------- 3 files changed, 33 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml index b4ce76b9..5580f2d1 100644 --- a/.github/workflows/ci-rust.yml +++ b/.github/workflows/ci-rust.yml @@ -165,7 +165,7 @@ jobs: if: inputs.run_tests run: | tarpaulin_args=() - if [[ "${{ matrix.target }}" == "aarch64-unknown-linux-gnu" || "${{ matrix.target }}" == "aarch64-pc-windows-msvc" ]]; then + if [[ "${{ matrix.target }}" == "aarch64-unknown-linux-gnu" ]]; then tarpaulin_args+=(--jobs 1) fi diff --git a/crates/server/src/tray.rs b/crates/server/src/tray.rs index de443149..f82da528 100644 --- a/crates/server/src/tray.rs +++ b/crates/server/src/tray.rs @@ -15,6 +15,7 @@ use tao::{ ControlFlow, EventLoopBuilder, }, + platform::run_return::EventLoopExtRunReturn, }; use tray_icon::{ TrayIconBuilder, @@ -49,7 +50,7 @@ pub fn launch_with_shutdown(shutdown_signal: ShutdownSignal) { let mut event_loop_builder = EventLoopBuilder::::with_user_event(); #[cfg(target_os = "windows")] event_loop_builder.with_any_thread(true); - let event_loop = event_loop_builder.build(); + let mut event_loop = event_loop_builder.build(); // set a tray event handler that forwards the event and wakes up the event loop let proxy = event_loop.create_proxy(); @@ -131,12 +132,13 @@ pub fn launch_with_shutdown(shutdown_signal: ShutdownSignal) { let mut tray_icon = None; - event_loop.run(move |event, _, control_flow| { + event_loop.run_return(move |event, _, control_flow| { // Always check for shutdown signal first and exit immediately if shutdown_signal.is_shutdown() { log::info!("Tray received shutdown signal, exiting immediately"); tray_icon.take(); - std::process::exit(0); + *control_flow = ControlFlow::Exit; + return; } // Use Poll with a short timeout to check shutdown frequently @@ -181,7 +183,7 @@ pub fn launch_with_shutdown(shutdown_signal: ShutdownSignal) { id if id == quit_i.id() => { log::info!("Quit requested from tray menu"); tray_icon.take(); - std::process::exit(0); + *control_flow = ControlFlow::Exit; } id if id == options_disable_tray_i.id() => { // TODO: adjust application config first @@ -222,11 +224,14 @@ pub fn launch_with_shutdown(shutdown_signal: ShutdownSignal) { if shutdown_signal.is_shutdown() { log::info!("Tray event - shutdown detected, exiting immediately"); tray_icon.take(); - std::process::exit(0); + *control_flow = ControlFlow::Exit; } } } - }) + }); + + TrayIconEvent::set_event_handler(Option::::None); + MenuEvent::set_event_handler(Option::::None); } fn icon_path() -> PathBuf { diff --git a/crates/server/tests/test_tray.rs b/crates/server/tests/test_tray.rs index 417fcb20..b253f126 100644 --- a/crates/server/tests/test_tray.rs +++ b/crates/server/tests/test_tray.rs @@ -2,25 +2,29 @@ // standard imports use std::path::Path; -use std::thread; -use std::time::Duration; -/// Because `launch()` runs an event loop indefinitely, this test spawns a thread to -/// call `launch()`, waits a brief moment, then ends the test. It mainly verifies that the -/// function can be invoked without immediate panics. Adjust as needed for full integration testing. +/// Tests that an existing source icon can be decoded for the tray. #[test] -fn test_launch_does_not_panic_immediately() { - // We run this in a separate thread because `launch()` never returns under normal circumstances. - let handle = thread::spawn(|| { - koko::tray::launch(); - }); - - // Wait a short moment to see if a panic happens right away. - thread::sleep(Duration::from_secs(1)); - - // We don't join the thread because `launch()` won't return in this example, - // but dropping the handle will end the spawned thread here. - drop(handle); +fn test_load_icon_source_icon() { + use koko::tray::load_icon; + + let icon_path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("assets") + .join("icon.ico"); + + let _icon = load_icon(&icon_path); +} + +/// Tests that the tray event loop honors shutdown without creating a long-running test process. +#[cfg(target_os = "windows")] +#[test] +fn test_launch_with_shutdown_exits() { + let shutdown_signal = koko::signal_handler::ShutdownSignal::new(); + shutdown_signal.shutdown(); + + koko::tray::launch_with_shutdown(shutdown_signal); } /// Tests the `load_icon` function with a path that does not exist. From 8dedf6c82ca49c901147eb732ba747b2fe1d82c4 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:45:24 -0400 Subject: [PATCH 122/128] Use catch_unwind for panicking tests Replace #[should_panic] expectations with explicit std::panic::catch_unwind checks in tests to assert on panic messages more robustly. Updated test_auth.rs and test_tray.rs to capture the panic, extract the message, and assert it contains the expected text. In test_signal_handler.rs, removed the #[should_panic] on the async runtime test, renamed it to async_thread_runtime_creation_success, and removed an artificial panic so the test validates the normal runtime creation path; the extreme failure path remains documented but not forced in tests. These changes make test failures clearer and avoid brittle should_panic usage. --- .github/workflows/ci-rust.yml | 8 +++++--- crates/server/tests/test_auth.rs | 15 +++++++++++++-- crates/server/tests/test_signal_handler.rs | 16 ++-------------- crates/server/tests/test_tray.rs | 15 +++++++++++++-- 4 files changed, 33 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml index 5580f2d1..050de8cc 100644 --- a/.github/workflows/ci-rust.yml +++ b/.github/workflows/ci-rust.yml @@ -165,9 +165,11 @@ jobs: if: inputs.run_tests run: | tarpaulin_args=() - if [[ "${{ matrix.target }}" == "aarch64-unknown-linux-gnu" ]]; then - tarpaulin_args+=(--jobs 1) - fi + case "${{ matrix.target }}" in + aarch64-unknown-linux-gnu|aarch64-pc-windows-msvc) + tarpaulin_args+=(--jobs 1) + ;; + esac cargo tarpaulin \ --locked \ diff --git a/crates/server/tests/test_auth.rs b/crates/server/tests/test_auth.rs index 9e68c59a..9923b4fe 100644 --- a/crates/server/tests/test_auth.rs +++ b/crates/server/tests/test_auth.rs @@ -184,9 +184,20 @@ fn test_claims_struct_functionality() { } #[test] -#[should_panic(expected = "Invalid role constant")] fn test_auth_guard_invalid_role_constant() { // This should panic because role constant 99 is not valid // We test the panic by calling the role() method with an invalid const generic - let _ = AuthGuard::<99>::role(); + let result = std::panic::catch_unwind(AuthGuard::<99>::role); + let panic = result.expect_err("Invalid role constant should panic"); + let message = panic + .downcast_ref::<&str>() + .copied() + .or_else(|| panic.downcast_ref::().map(String::as_str)) + .unwrap_or(""); + + assert!( + message.contains("Invalid role constant"), + "unexpected panic message: {}", + message + ); } diff --git a/crates/server/tests/test_signal_handler.rs b/crates/server/tests/test_signal_handler.rs index db7a3b1a..dc5d868d 100644 --- a/crates/server/tests/test_signal_handler.rs +++ b/crates/server/tests/test_signal_handler.rs @@ -604,17 +604,7 @@ mod shutdown_coordinator { } #[test] - #[should_panic(expected = "Failed to create tokio runtime")] - fn async_thread_runtime_creation_failure() { - // This test is tricky to trigger in practice, but we can document it - // The panic path occurs when tokio runtime creation fails - // In normal circumstances this should never happen, but the panic is there for safety - - // Since we can't easily mock runtime creation failure, we'll create a separate test - // that documents this behavior. The actual panic line will be covered when/if - // runtime creation actually fails in extreme circumstances. - - // For now, let's verify that normal async thread creation works fine + fn async_thread_runtime_creation_success() { let mut coordinator = create_test_coordinator(); coordinator.register_async_thread("normal-async", |_| async move { @@ -623,9 +613,7 @@ mod shutdown_coordinator { coordinator.wait_for_completion(); - // If we reach here, runtime creation worked fine - // The panic path is for extreme error conditions that are hard to reproduce in tests - panic!("Failed to create tokio runtime for test_panic_scenario"); + // If we reach here, runtime creation worked fine. } #[test] diff --git a/crates/server/tests/test_tray.rs b/crates/server/tests/test_tray.rs index b253f126..5bccdc98 100644 --- a/crates/server/tests/test_tray.rs +++ b/crates/server/tests/test_tray.rs @@ -31,12 +31,23 @@ fn test_launch_with_shutdown_exits() { /// We expect a panic, because the code calls `image::open` /// and it should fail on a non-existent file. #[test] -#[should_panic(expected = "Failed to open icon path")] fn test_load_icon_non_existent_path_panics() { use koko::tray::load_icon; let non_existent_path = Path::new("non_existent_file.ico"); // This should panic based on the logic within `load_icon`. - let _icon = load_icon(non_existent_path); + let result = std::panic::catch_unwind(|| load_icon(non_existent_path)); + let panic = result.expect_err("Missing icon path should panic"); + let message = panic + .downcast_ref::<&str>() + .copied() + .or_else(|| panic.downcast_ref::().map(String::as_str)) + .unwrap_or(""); + + assert!( + message.contains("Failed to open icon path"), + "unexpected panic message: {}", + message + ); } From ecdaee88b23e4bdfe746776b0a6bd563d1aa1988 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:17:18 -0400 Subject: [PATCH 123/128] Refactor CI to use workspace metadata for tools Unify and simplify CI tool installation and input handling across workflows. Workflows now expose inputs via env vars (INPUT_*) and use those env values in build/test/signing conditionals. Install Cargo helper tools via cargo-run-bin (version read from workspace.metadata.ci) and invoke tool binaries through `cargo bin` instead of installing many standalone crates in workflows. Move tool version/metadata into top-level Cargo.toml workspace.metadata and remove the duplicate package.metadata entries from crates/server/Cargo.toml. Adjust ci.yml invocation to stop passing publish_release and rely on the new env-driven logic. --- .github/workflows/ci-rust.yml | 52 +++++++++++++--------- .github/workflows/ci-windows-installer.yml | 50 +++++++++++---------- .github/workflows/ci.yml | 1 - Cargo.toml | 8 ++++ crates/server/Cargo.toml | 8 ---- crates/server/wix/main.wxs | 14 ------ 6 files changed, 64 insertions(+), 69 deletions(-) diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml index 050de8cc..2f7af563 100644 --- a/.github/workflows/ci-rust.yml +++ b/.github/workflows/ci-rust.yml @@ -8,10 +8,6 @@ on: job_name: required: true type: string - publish_release: - default: false - required: false - type: boolean release_version: default: '' required: false @@ -80,6 +76,9 @@ jobs: shell: ${{ matrix.shell }} env: CARGO_TERM_COLOR: always + INPUT_RELEASE_VERSION: ${{ inputs.release_version }} + INPUT_RUN_BUILD: ${{ inputs.run_build }} + INPUT_RUN_TESTS: ${{ inputs.run_tests }} steps: - name: Fix arm64 Alpine container if: runner.arch == 'ARM64' && contains(matrix.container, 'alpine') @@ -137,20 +136,29 @@ jobs: cache: npm cache-dependency-path: crates/client-web/package-lock.json - - name: Install cargo-edit - if: inputs.run_build && inputs.publish_release - run: cargo install cargo-edit - - - name: Install cargo-tarpaulin - if: inputs.run_tests - run: cargo install --locked cargo-tarpaulin + - name: Install Cargo tools + if: env.INPUT_RUN_TESTS == 'true' || (env.INPUT_RUN_BUILD == 'true' && env.INPUT_RELEASE_VERSION != '') + run: | + cargo_run_bin_version="$( + cargo metadata --locked --no-deps --format-version 1 \ + | sed -n 's/.*"cargo-run-bin"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' + )" + if [[ -z "${cargo_run_bin_version}" ]]; then + echo "Failed to parse cargo-run-bin version from cargo metadata" >&2 + exit 1 + fi + cargo install --locked cargo-run-bin --version "${cargo_run_bin_version}" + if [[ "${INPUT_RUN_BUILD}" == "true" && -n "${INPUT_RELEASE_VERSION}" ]]; then + cargo bin cargo-set-version --version + fi + if [[ "${INPUT_RUN_TESTS}" == "true" ]]; then + cargo bin cargo-tarpaulin --version + fi - name: Update Version - if: inputs.run_build && inputs.publish_release - env: - INPUTS_RELEASE_VERSION: ${{ inputs.release_version }} + if: env.INPUT_RUN_BUILD == 'true' && env.INPUT_RELEASE_VERSION != '' run: | - cargo set-version ${INPUTS_RELEASE_VERSION} + cargo bin cargo-set-version "${INPUT_RELEASE_VERSION}" cargo update --workspace cargo metadata --locked --no-deps --format-version 1 > /dev/null @@ -162,7 +170,7 @@ jobs: - name: Test id: test - if: inputs.run_tests + if: env.INPUT_RUN_TESTS == 'true' run: | tarpaulin_args=() case "${{ matrix.target }}" in @@ -171,7 +179,7 @@ jobs: ;; esac - cargo tarpaulin \ + cargo bin cargo-tarpaulin \ --locked \ --color always \ --engine llvm \ @@ -185,7 +193,7 @@ jobs: - name: Upload coverage artifact if: >- always() && - inputs.run_tests && + env.INPUT_RUN_TESTS == 'true' && (steps.test.outcome == 'success' || steps.test.outcome == 'failure') uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -194,11 +202,11 @@ jobs: path: cobertura.xml - name: Build - if: inputs.run_build + if: env.INPUT_RUN_BUILD == 'true' run: cargo build --locked ${{ matrix.cargo_features }} --target ${{ matrix.target }} --release - name: Create 7z archive - if: inputs.run_build && !contains(matrix.target, 'windows') + if: env.INPUT_RUN_BUILD == 'true' && !contains(matrix.target, 'windows') run: | mkdir -p artifacts @@ -212,7 +220,7 @@ jobs: "./target/${{ matrix.target }}/release/koko${extension}" - name: Upload Windows build artifact - if: inputs.run_build && contains(matrix.target, 'windows') + if: env.INPUT_RUN_BUILD == 'true' && contains(matrix.target, 'windows') uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: if-no-files-found: error @@ -222,7 +230,7 @@ jobs: crates/client-web/dist - name: Upload Artifacts - if: inputs.run_build && !contains(matrix.target, 'windows') + if: env.INPUT_RUN_BUILD == 'true' && !contains(matrix.target, 'windows') uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: if-no-files-found: error diff --git a/.github/workflows/ci-windows-installer.yml b/.github/workflows/ci-windows-installer.yml index db5a1c41..585e06af 100644 --- a/.github/workflows/ci-windows-installer.yml +++ b/.github/workflows/ci-windows-installer.yml @@ -47,7 +47,11 @@ jobs: - target: aarch64-pc-windows-msvc env: CARGO_TERM_COLOR: always - CARGO_WIX_REV: fde983c2e901970267e76b8fd68120fdd5457a57 + INPUT_AZURE_SIGNING_ACCOUNT: ${{ inputs.azure_signing_account }} + INPUT_AZURE_SIGNING_CERT_PROFILE: ${{ inputs.azure_signing_cert_profile }} + INPUT_AZURE_SIGNING_ENDPOINT: ${{ inputs.azure_signing_endpoint }} + INPUT_PUBLISH_RELEASE: ${{ inputs.publish_release }} + INPUT_RELEASE_VERSION: ${{ inputs.release_version }} WIX_EXTENSION_VERSION: 7.0.0 WIX_TOOL_PATH: ${{ github.workspace }}\.wix WIX_VERSION: 7.0.0 @@ -68,17 +72,22 @@ jobs: cache: true cache-on-failure: false - - name: Install cargo-edit - if: inputs.publish_release - run: cargo install cargo-edit + - name: Install Cargo tools + shell: pwsh + run: | + $metadata = cargo metadata --locked --no-deps --format-version 1 | ConvertFrom-Json + $cargoRunBinVersion = $metadata.metadata.ci.'cargo-run-bin' + cargo install --locked cargo-run-bin --version $cargoRunBinVersion + if ($env:INPUT_RELEASE_VERSION) { + cargo bin cargo-set-version --version + } + cargo bin cargo-wix --version - name: Update Version - if: inputs.publish_release - env: - INPUTS_RELEASE_VERSION: ${{ inputs.release_version }} + if: env.INPUT_RELEASE_VERSION != '' shell: pwsh run: | - cargo set-version $env:INPUTS_RELEASE_VERSION + cargo bin cargo-set-version $env:INPUT_RELEASE_VERSION cargo update --workspace cargo metadata --locked --no-deps --format-version 1 > $null @@ -99,24 +108,17 @@ jobs: wix extension add "WixToolset.Util.wixext/$env:WIX_EXTENSION_VERSION" wix --version - - name: Install cargo-wix - run: >- - cargo install --locked - --git https://github.com/volks73/cargo-wix.git - --rev ${{ env.CARGO_WIX_REV }} - cargo-wix - - name: Sign Windows executable - if: inputs.publish_release && inputs.azure_signing_account != '' + if: env.INPUT_PUBLISH_RELEASE == 'true' && env.INPUT_AZURE_SIGNING_ACCOUNT != '' uses: azure/trusted-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0 with: azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }} azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} - certificate-profile-name: ${{ inputs.azure_signing_cert_profile }} - endpoint: ${{ inputs.azure_signing_endpoint }} + certificate-profile-name: ${{ env.INPUT_AZURE_SIGNING_CERT_PROFILE }} + endpoint: ${{ env.INPUT_AZURE_SIGNING_ENDPOINT }} files: ${{ github.workspace }}\target\${{ matrix.target }}\release\koko.exe - signing-account-name: ${{ inputs.azure_signing_account }} + signing-account-name: ${{ env.INPUT_AZURE_SIGNING_ACCOUNT }} - name: Create Windows archive shell: pwsh @@ -129,7 +131,7 @@ jobs: - name: Package Windows installer shell: pwsh run: | - cargo wix ` + cargo bin cargo-wix ` --toolset modern ` --target ${{ matrix.target }} ` --no-build ` @@ -140,18 +142,18 @@ jobs: crates/server/Cargo.toml - name: Sign Windows installer - if: inputs.publish_release && inputs.azure_signing_account != '' + if: env.INPUT_PUBLISH_RELEASE == 'true' && env.INPUT_AZURE_SIGNING_ACCOUNT != '' uses: azure/trusted-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0 with: azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }} azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} - certificate-profile-name: ${{ inputs.azure_signing_cert_profile }} - endpoint: ${{ inputs.azure_signing_endpoint }} + certificate-profile-name: ${{ env.INPUT_AZURE_SIGNING_CERT_PROFILE }} + endpoint: ${{ env.INPUT_AZURE_SIGNING_ENDPOINT }} files-folder: artifacts files-folder-filter: msi files-folder-recurse: false - signing-account-name: ${{ inputs.azure_signing_account }} + signing-account-name: ${{ env.INPUT_AZURE_SIGNING_ACCOUNT }} - name: Upload Artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8da00199..6574fe88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,7 +81,6 @@ jobs: uses: ./.github/workflows/ci-rust.yml with: job_name: Build - publish_release: ${{ needs.setup_release.outputs.publish_release == 'true' }} release_version: ${{ needs.setup_release.outputs.release_version }} run_build: true run_tests: false diff --git a/Cargo.toml b/Cargo.toml index ee784c7a..a9e05acd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,3 +35,11 @@ publish = false # disable publishing to crates.io # ensure deps are compatible: https://www.gnu.org/licenses/license-list.en.html#GPLCompatibleLicenses async-std = { version = "1.13.0", features = ["attributes", "tokio1"] } rstest = "0.25.0" + +[workspace.metadata.ci] +cargo-run-bin = "1.7.4" + +[workspace.metadata.bin] +cargo-edit = { version = "0.13.1", bins = ["cargo-set-version"], locked = true } +cargo-tarpaulin = { version = "0.35.4", locked = true } +cargo-wix = { version = "0.3.9", git = "https://github.com/volks73/cargo-wix.git", rev = "fde983c2e901970267e76b8fd68120fdd5457a57", locked = true } diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index f9ba445b..07c0b8a7 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -102,11 +102,3 @@ objc2-core-foundation = { version = "0.3.0", optional = true } [dev-dependencies] async-std.workspace = true rstest.workspace = true - -[package.metadata.ci] -cargo-run-bin = "1.7.4" - -[package.metadata.bin] -cargo-edit = { version = "0.13.1" } -cargo-tarpaulin = { version = "0.31.5" } -cargo-wix = { version = "0.3.9" } diff --git a/crates/server/wix/main.wxs b/crates/server/wix/main.wxs index aa3fd2e0..bc49a780 100644 --- a/crates/server/wix/main.wxs +++ b/crates/server/wix/main.wxs @@ -43,20 +43,6 @@ - - From 604e8abcee6082b7cd2ae5e24aa65fbf00d53dda Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:41:00 -0400 Subject: [PATCH 124/128] CI: matrix-driven Rust build/test workflow Replace separate test/build inputs with a matrix-driven run axis and expanded target matrix in the Rust workflow. Removed legacy inputs (job_name, run_build, run_tests) and switched conditional steps to use matrix.run (test|build) and matrix.target, excluding the aarch64-pc-windows-msvc test combination. Adjusted installation, versioning, test, build and artifact upload steps to rely on the matrix values. Also consolidated the top-level workflow to use a single rust job (instead of separate test/build jobs) and updated job dependencies to reflect the new rust job. Minor change in coverage workflow: removed aarch64-pc-windows-msvc from the coverage matrix. --- .github/workflows/ci-coverage.yml | 1 - .github/workflows/ci-rust.yml | 50 +++++++++++++++++-------------- .github/workflows/ci.yml | 24 ++++----------- 3 files changed, 32 insertions(+), 43 deletions(-) diff --git a/.github/workflows/ci-coverage.yml b/.github/workflows/ci-coverage.yml index a126bd09..2c5bb337 100644 --- a/.github/workflows/ci-coverage.yml +++ b/.github/workflows/ci-coverage.yml @@ -21,7 +21,6 @@ jobs: - x86_64-apple-darwin - aarch64-apple-darwin - x86_64-pc-windows-msvc - - aarch64-pc-windows-msvc name: Coverage (${{ matrix.target }}) permissions: contents: read diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml index 2f7af563..75c94882 100644 --- a/.github/workflows/ci-rust.yml +++ b/.github/workflows/ci-rust.yml @@ -5,25 +5,28 @@ permissions: {} on: workflow_call: inputs: - job_name: - required: true - type: string release_version: default: '' required: false type: string - run_build: - required: true - type: boolean - run_tests: - required: true - type: boolean jobs: rust: strategy: fail-fast: false matrix: + run: + - test + - build + target: + - x86_64-unknown-linux-musl + - aarch64-unknown-linux-musl + - x86_64-unknown-linux-gnu + - aarch64-unknown-linux-gnu + - x86_64-apple-darwin + - aarch64-apple-darwin + - x86_64-pc-windows-msvc + - aarch64-pc-windows-msvc include: - target: x86_64-unknown-linux-musl # Alpine os: ubuntu-latest @@ -65,7 +68,10 @@ jobs: container: '' cargo_features: '' shell: bash - name: ${{ inputs.job_name }} (${{ matrix.target }}) + exclude: + - run: test + target: aarch64-pc-windows-msvc + name: ${{ matrix.run == 'test' && 'Test' || 'Build' }} (${{ matrix.target }}) permissions: contents: read runs-on: ${{ matrix.os }} @@ -77,8 +83,6 @@ jobs: env: CARGO_TERM_COLOR: always INPUT_RELEASE_VERSION: ${{ inputs.release_version }} - INPUT_RUN_BUILD: ${{ inputs.run_build }} - INPUT_RUN_TESTS: ${{ inputs.run_tests }} steps: - name: Fix arm64 Alpine container if: runner.arch == 'ARM64' && contains(matrix.container, 'alpine') @@ -137,7 +141,7 @@ jobs: cache-dependency-path: crates/client-web/package-lock.json - name: Install Cargo tools - if: env.INPUT_RUN_TESTS == 'true' || (env.INPUT_RUN_BUILD == 'true' && env.INPUT_RELEASE_VERSION != '') + if: matrix.run == 'test' || env.INPUT_RELEASE_VERSION != '' run: | cargo_run_bin_version="$( cargo metadata --locked --no-deps --format-version 1 \ @@ -148,15 +152,15 @@ jobs: exit 1 fi cargo install --locked cargo-run-bin --version "${cargo_run_bin_version}" - if [[ "${INPUT_RUN_BUILD}" == "true" && -n "${INPUT_RELEASE_VERSION}" ]]; then + if [[ -n "${INPUT_RELEASE_VERSION}" ]]; then cargo bin cargo-set-version --version fi - if [[ "${INPUT_RUN_TESTS}" == "true" ]]; then + if [[ "${{ matrix.run }}" == "test" ]]; then cargo bin cargo-tarpaulin --version fi - name: Update Version - if: env.INPUT_RUN_BUILD == 'true' && env.INPUT_RELEASE_VERSION != '' + if: matrix.run == 'build' && env.INPUT_RELEASE_VERSION != '' run: | cargo bin cargo-set-version "${INPUT_RELEASE_VERSION}" cargo update --workspace @@ -170,11 +174,11 @@ jobs: - name: Test id: test - if: env.INPUT_RUN_TESTS == 'true' + if: matrix.run == 'test' run: | tarpaulin_args=() case "${{ matrix.target }}" in - aarch64-unknown-linux-gnu|aarch64-pc-windows-msvc) + aarch64-unknown-linux-gnu) tarpaulin_args+=(--jobs 1) ;; esac @@ -193,7 +197,7 @@ jobs: - name: Upload coverage artifact if: >- always() && - env.INPUT_RUN_TESTS == 'true' && + matrix.run == 'test' && (steps.test.outcome == 'success' || steps.test.outcome == 'failure') uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -202,11 +206,11 @@ jobs: path: cobertura.xml - name: Build - if: env.INPUT_RUN_BUILD == 'true' + if: matrix.run == 'build' run: cargo build --locked ${{ matrix.cargo_features }} --target ${{ matrix.target }} --release - name: Create 7z archive - if: env.INPUT_RUN_BUILD == 'true' && !contains(matrix.target, 'windows') + if: matrix.run == 'build' && !contains(matrix.target, 'windows') run: | mkdir -p artifacts @@ -220,7 +224,7 @@ jobs: "./target/${{ matrix.target }}/release/koko${extension}" - name: Upload Windows build artifact - if: env.INPUT_RUN_BUILD == 'true' && contains(matrix.target, 'windows') + if: matrix.run == 'build' && contains(matrix.target, 'windows') uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: if-no-files-found: error @@ -230,7 +234,7 @@ jobs: crates/client-web/dist - name: Upload Artifacts - if: env.INPUT_RUN_BUILD == 'true' && !contains(matrix.target, 'windows') + if: matrix.run == 'build' && !contains(matrix.target, 'windows') uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: if-no-files-found: error diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6574fe88..e465de7b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,33 +63,20 @@ jobs: contents: read uses: ./.github/workflows/ci-clippy.yml - test: - name: Test - permissions: - contents: read - uses: ./.github/workflows/ci-rust.yml - with: - job_name: Test - run_build: false - run_tests: true - - build: - name: Build + rust: + name: Rust needs: setup_release permissions: contents: read uses: ./.github/workflows/ci-rust.yml with: - job_name: Build release_version: ${{ needs.setup_release.outputs.release_version }} - run_build: true - run_tests: false build_windows_installer: name: Build Windows Installer needs: - setup_release - - build + - rust permissions: contents: read uses: ./.github/workflows/ci-windows-installer.yml @@ -120,7 +107,7 @@ jobs: always() && !cancelled() && startsWith(github.repository, 'LizardByte/') - needs: test + needs: rust permissions: contents: read uses: ./.github/workflows/ci-coverage.yml @@ -135,8 +122,7 @@ jobs: needs: - setup_release - clippy - - test - - build + - rust - build_windows_installer - build_flatpak permissions: From d8a5ea3a6fc2472881dd75778e89775735d3cc70 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:41:44 -0400 Subject: [PATCH 125/128] Add macOS DMG packaging and CI integration Add a complete macOS DMG packaging flow and integrate it into CI: introduce .github/workflows/ci-macos-dmg.yml to build, codesign, submit for notarization, staple and upload macOS DMGs (supports arm64 and x86_64) and wire it into ci.yml as build_macos_dmg. Add packaging/macos/package-dmg.sh to create .app bundles and DMGs, generate icons, write Info.plist, and optionally codesign. Adjust CI artifact handling in ci-rust.yml: introduce matrix fields for build_artifact_prefix/build_artifact_path, set per-target prefixes (build-alpine/build-ubuntu/build-macos/build-windows), change artifact preparation for macOS/Windows targets (copy build outputs into build-artifact when needed), and unify upload names. Update downstream workflow ci-windows-installer.yml to download the renamed build-windows-* artifacts. Add runtime resource lookup improvements: crates/server/src/tray.rs and crates/server/src/web/routes/common.rs now include platform-specific candidate paths for bundled resources/client-web dist on macOS (Resources/...) and Linux (share/koko/...). These changes ensure the packaged app can find icons and the web client when run from app bundles or system install locations. --- .github/workflows/ci-macos-dmg.yml | 212 ++++++++++++++++++++ .github/workflows/ci-rust.yml | 51 +++-- .github/workflows/ci-windows-installer.yml | 2 +- .github/workflows/ci.yml | 22 +++ crates/server/src/tray.rs | 27 ++- crates/server/src/web/routes/common.rs | 21 ++ packaging/macos/package-dmg.sh | 213 +++++++++++++++++++++ 7 files changed, 525 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/ci-macos-dmg.yml create mode 100644 packaging/macos/package-dmg.sh diff --git a/.github/workflows/ci-macos-dmg.yml b/.github/workflows/ci-macos-dmg.yml new file mode 100644 index 00000000..a1a9e473 --- /dev/null +++ b/.github/workflows/ci-macos-dmg.yml @@ -0,0 +1,212 @@ +--- +name: CI-macOS-DMG +permissions: {} + +on: + workflow_call: + inputs: + publish_release: + required: true + type: string + release_version: + required: true + type: string + secrets: + # email address + APPLE_ID: + required: false + # 10-character Team ID + APPLE_TEAM_ID: + required: false + # app-specific password in APPLE_ID's account that must be named "notarytool" + # https://support.apple.com/en-us/102654 + APPLE_NOTARYTOOL_PASSWORD: + required: false + # Developer ID Application: Full Name (TEAMIDHERE) + APPLE_CODESIGN_IDENTITY: + required: false + # pkcs12 export from Xcode in base64 + APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64: + required: false + # pkcs12 password added by Xcode export + APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD: + required: false + +env: + CARGO_TERM_COLOR: always + INPUT_RELEASE_VERSION: ${{ inputs.release_version }} + MACOSX_DEPLOYMENT_TARGET: 14.2 + +jobs: + build_dmg: + name: ${{ matrix.name }} + permissions: + contents: read + runs-on: ${{ matrix.os }} + outputs: + notarytool_submission_id_arm64: ${{ steps.notarize_submit.outputs.submission_id_arm64 }} + notarytool_submission_id_x86_64: ${{ steps.notarize_submit.outputs.submission_id_x86_64 }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-14 + name: macOS-arm64 + arch: arm64 + target: aarch64-apple-darwin + - os: macos-15-intel + name: macOS-x86_64 + arch: x86_64 + target: x86_64-apple-darwin + steps: + - name: Install Apple certificate + if: inputs.publish_release == 'true' + uses: apple-actions/import-codesign-certs@5142e029c445c10ffc7149d172e540235a065466 # v7.0.0 + with: + p12-file-base64: ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64 }} + p12-password: ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD }} + + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Download macOS build artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: build-macos-${{ matrix.target }} + path: . + + - name: Package DMG + env: + APPLE_CODESIGN_IDENTITY: ${{ secrets.APPLE_CODESIGN_IDENTITY }} + SHOULD_SIGN: ${{ inputs.publish_release }} + run: | + sign_args=() + if [[ "${SHOULD_SIGN}" == "true" ]]; then + sign_args+=(--sign) + fi + + bash packaging/macos/package-dmg.sh \ + --target "${{ matrix.target }}" \ + --version "${INPUT_RELEASE_VERSION:-0.0.0}" \ + --binary "target/${{ matrix.target }}/release/koko" \ + --output-dir artifacts \ + "${sign_args[@]}" + + - name: Submit for notarization + id: notarize_submit + if: inputs.publish_release == 'true' + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_NOTARYTOOL_PASSWORD: ${{ secrets.APPLE_NOTARYTOOL_PASSWORD }} + MATRIX_ARCH: ${{ matrix.arch }} + MATRIX_TARGET: ${{ matrix.target }} + run: | + if [[ -n "${APPLE_NOTARYTOOL_PASSWORD}" ]]; then + submission_id=$(xcrun notarytool submit "artifacts/koko-${MATRIX_TARGET}.dmg" \ + --apple-id "${APPLE_ID}" \ + --team-id "${APPLE_TEAM_ID}" \ + --password "${APPLE_NOTARYTOOL_PASSWORD}" \ + --output-format json \ + | jq -r '.id') + echo "Submission ID: ${submission_id}" + echo "submission_id_${MATRIX_ARCH}=${submission_id}" >> "${GITHUB_OUTPUT}" + fi + + - name: Set artifact prefix + id: artifact_prefix + env: + INPUTS_PUBLISH_RELEASE: ${{ inputs.publish_release }} + run: | + if [[ "${INPUTS_PUBLISH_RELEASE}" == "true" ]]; then + echo "prefix=unsigned" >> "${GITHUB_OUTPUT}" + else + echo "prefix=koko" >> "${GITHUB_OUTPUT}" + fi + + - name: Upload Artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + if-no-files-found: error + name: ${{ steps.artifact_prefix.outputs.prefix }}-${{ matrix.name }}-dmg + path: artifacts/ + + notarize_dmg: + name: Notarize ${{ matrix.name }} + needs: build_dmg + if: inputs.publish_release == 'true' + permissions: + contents: read + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-14 + name: macOS-arm64 + arch: arm64 + target: aarch64-apple-darwin + - os: macos-15-intel + name: macOS-x86_64 + arch: x86_64 + target: x86_64-apple-darwin + steps: + - name: Install Apple certificate + uses: apple-actions/import-codesign-certs@5142e029c445c10ffc7149d172e540235a065466 # v7.0.0 + with: + p12-file-base64: ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64 }} + p12-password: ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD }} + + - name: Download DMG artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: unsigned-${{ matrix.name }}-dmg + path: artifacts + + - name: Wait for notarization and staple + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_NOTARYTOOL_PASSWORD: ${{ secrets.APPLE_NOTARYTOOL_PASSWORD }} + MATRIX_TARGET: ${{ matrix.target }} + SUBMISSION_ID: ${{ matrix.arch == 'arm64' + && needs.build_dmg.outputs.notarytool_submission_id_arm64 + || needs.build_dmg.outputs.notarytool_submission_id_x86_64 }} + run: | + if [[ -z "${SUBMISSION_ID}" ]]; then + echo "No submission ID found; skipping notarization wait." + exit 0 + fi + + echo "Polling notarization status for submission: ${SUBMISSION_ID}" + while true; do + status=$(xcrun notarytool info "${SUBMISSION_ID}" \ + --apple-id "${APPLE_ID}" \ + --team-id "${APPLE_TEAM_ID}" \ + --password "${APPLE_NOTARYTOOL_PASSWORD}" \ + --output-format json \ + | jq -r '.status') + echo "Current status: ${status}" + if [[ "${status}" == "Accepted" ]]; then + echo "Notarization accepted." + break + elif [[ "${status}" == "Invalid" || "${status}" == "Rejected" ]]; then + echo "Notarization failed with status: ${status}" + xcrun notarytool log "${SUBMISSION_ID}" \ + --apple-id "${APPLE_ID}" \ + --team-id "${APPLE_TEAM_ID}" \ + --password "${APPLE_NOTARYTOOL_PASSWORD}" + exit 1 + fi + echo "Status is '${status}', waiting 30 seconds before retrying..." + sleep 30 + done + + xcrun stapler staple -v "artifacts/koko-${MATRIX_TARGET}.dmg" + + - name: Upload stapled artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + if-no-files-found: error + name: koko-${{ matrix.name }}-dmg + path: artifacts/ diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml index 75c94882..9f42049b 100644 --- a/.github/workflows/ci-rust.yml +++ b/.github/workflows/ci-rust.yml @@ -33,41 +33,57 @@ jobs: container: rust:1.96-alpine3.24 cargo_features: '--no-default-features' shell: bash + build_artifact_prefix: build-alpine + build_artifact_path: artifacts - target: aarch64-unknown-linux-musl # Alpine os: ubuntu-24.04-arm container: rust:1.96-alpine3.24 cargo_features: '--no-default-features' shell: bash + build_artifact_prefix: build-alpine + build_artifact_path: artifacts - target: x86_64-unknown-linux-gnu # Ubuntu os: ubuntu-latest container: '' cargo_features: '' shell: bash + build_artifact_prefix: build-ubuntu + build_artifact_path: artifacts - target: aarch64-unknown-linux-gnu # Ubuntu os: ubuntu-24.04-arm container: '' cargo_features: '' shell: bash + build_artifact_prefix: build-ubuntu + build_artifact_path: artifacts - target: x86_64-apple-darwin # macOS/Intel os: macos-latest container: '' cargo_features: '' shell: bash + build_artifact_prefix: build-macos + build_artifact_path: build-artifact - target: aarch64-apple-darwin # macOS/Apple Silicon os: macos-latest container: '' cargo_features: '' shell: bash + build_artifact_prefix: build-macos + build_artifact_path: build-artifact - target: x86_64-pc-windows-msvc # Windows os: windows-latest container: '' cargo_features: '' shell: bash + build_artifact_prefix: build-windows + build_artifact_path: build-artifact - target: aarch64-pc-windows-msvc # Windows/ARM64 os: windows-11-arm container: '' cargo_features: '' shell: bash + build_artifact_prefix: build-windows + build_artifact_path: build-artifact exclude: - run: test target: aarch64-pc-windows-msvc @@ -210,7 +226,7 @@ jobs: run: cargo build --locked ${{ matrix.cargo_features }} --target ${{ matrix.target }} --release - name: Create 7z archive - if: matrix.run == 'build' && !contains(matrix.target, 'windows') + if: matrix.run == 'build' && matrix.build_artifact_path == 'artifacts' run: | mkdir -p artifacts @@ -223,20 +239,27 @@ jobs: "./assets" \ "./target/${{ matrix.target }}/release/koko${extension}" - - name: Upload Windows build artifact - if: matrix.run == 'build' && contains(matrix.target, 'windows') - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - if-no-files-found: error - name: windows-build-${{ matrix.target }} - path: | - target/${{ matrix.target }}/release/koko.exe - crates/client-web/dist + - name: Prepare build artifact + if: matrix.run == 'build' && matrix.build_artifact_path == 'build-artifact' + run: | + mkdir -p \ + "build-artifact/target/${{ matrix.target }}/release" \ + "build-artifact/crates/client-web" - - name: Upload Artifacts - if: matrix.run == 'build' && !contains(matrix.target, 'windows') + extension="" + if [[ "${{ matrix.target }}" = *"windows"* ]]; then + extension=".exe" + fi + + cp \ + "target/${{ matrix.target }}/release/koko${extension}" \ + "build-artifact/target/${{ matrix.target }}/release/koko${extension}" + cp -R "crates/client-web/dist" "build-artifact/crates/client-web/dist" + + - name: Upload build artifact + if: matrix.run == 'build' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: if-no-files-found: error - name: koko-${{ matrix.target }} - path: artifacts + name: ${{ matrix.build_artifact_prefix }}-${{ matrix.target }} + path: ${{ matrix.build_artifact_path }} diff --git a/.github/workflows/ci-windows-installer.yml b/.github/workflows/ci-windows-installer.yml index 585e06af..392feb65 100644 --- a/.github/workflows/ci-windows-installer.yml +++ b/.github/workflows/ci-windows-installer.yml @@ -62,7 +62,7 @@ jobs: - name: Download Windows build artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: windows-build-${{ matrix.target }} + name: build-windows-${{ matrix.target }} path: . - name: Setup Rust diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e465de7b..721ba2ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,6 +91,27 @@ jobs: AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + build_macos_dmg: + name: Build macOS DMG + needs: + - setup_release + - rust + permissions: + contents: read + uses: ./.github/workflows/ci-macos-dmg.yml + with: + publish_release: ${{ needs.setup_release.outputs.publish_release }} + release_version: ${{ needs.setup_release.outputs.release_version }} + secrets: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_NOTARYTOOL_PASSWORD: ${{ secrets.APPLE_NOTARYTOOL_PASSWORD }} + APPLE_CODESIGN_IDENTITY: ${{ secrets.APPLE_CODESIGN_IDENTITY }} + APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64: >- + ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64 }} + APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD: >- + ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD }} + build_flatpak: name: Build Flatpak needs: setup_release @@ -124,6 +145,7 @@ jobs: - clippy - rust - build_windows_installer + - build_macos_dmg - build_flatpak permissions: contents: read diff --git a/crates/server/src/tray.rs b/crates/server/src/tray.rs index f82da528..095373ba 100644 --- a/crates/server/src/tray.rs +++ b/crates/server/src/tray.rs @@ -256,14 +256,25 @@ fn icon_path_candidates() -> Vec { if let Some(executable_dir) = executable_path.parent() { candidates.push(executable_dir.join(&configured_path)); - for ancestor in executable_dir.ancestors() { - candidates.push( - ancestor - .join("share") - .join("koko") - .join("assets") - .join(ICON_FILE_NAME), - ); + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + for ancestor in executable_dir.ancestors() { + #[cfg(target_os = "macos")] + candidates.push( + ancestor + .join("Resources") + .join("assets") + .join(ICON_FILE_NAME), + ); + #[cfg(target_os = "linux")] + candidates.push( + ancestor + .join("share") + .join("koko") + .join("assets") + .join(ICON_FILE_NAME), + ); + } } } } diff --git a/crates/server/src/web/routes/common.rs b/crates/server/src/web/routes/common.rs index 8634c0b9..ed9a9293 100644 --- a/crates/server/src/web/routes/common.rs +++ b/crates/server/src/web/routes/common.rs @@ -82,6 +82,27 @@ fn web_client_dist_candidates() -> Vec { candidates.push(current_dir.join("crates").join("client-web").join("dist")); } + if let Ok(executable_path) = env::current_exe() { + if let Some(executable_dir) = executable_path.parent() { + candidates.push( + executable_dir + .join("crates") + .join("client-web") + .join("dist"), + ); + + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + for ancestor in executable_dir.ancestors() { + #[cfg(target_os = "macos")] + candidates.push(ancestor.join("Resources").join("client-web").join("dist")); + #[cfg(target_os = "linux")] + candidates.push(ancestor.join("share").join("koko").join("client-web")); + } + } + } + } + candidates.push( PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("..") diff --git a/packaging/macos/package-dmg.sh b/packaging/macos/package-dmg.sh new file mode 100644 index 00000000..7e4a4d0b --- /dev/null +++ b/packaging/macos/package-dmg.sh @@ -0,0 +1,213 @@ +#!/usr/bin/env bash +set -euo pipefail + +app_name="Koko" +bundle_id="dev.lizardbyte.app.Koko" +target="" +version="" +binary_path="" +output_dir="artifacts" +work_dir="target/macos-package" +sign_bundle="false" +codesign_identity="${APPLE_CODESIGN_IDENTITY:-}" + +function usage() { + cat <&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ -z "${target}" ]]; then + target="$(rustc -vV | sed -n 's/^host: //p')" +fi + +if [[ -z "${version}" ]]; then + version="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n 1)" +fi + +if [[ -z "${binary_path}" ]]; then + binary_path="target/${target}/release/koko" +fi + +if [[ -z "${target}" || -z "${version}" ]]; then + echo "Both --target and --version must resolve to non-empty values." >&2 + exit 1 +fi + +if [[ ! -f "${binary_path}" ]]; then + echo "Koko binary not found: ${binary_path}" >&2 + exit 1 +fi +chmod +x "${binary_path}" + +if [[ ! -f "crates/client-web/dist/index.html" ]]; then + echo "Web client bundle not found. Run npm run build in crates/client-web first." >&2 + exit 1 +fi + +if [[ "${sign_bundle}" == "true" && -z "${codesign_identity}" ]]; then + echo "APPLE_CODESIGN_IDENTITY must be set when --sign is used." >&2 + exit 1 +fi + +bundle_version="$( + printf '%s' "${version}" \ + | sed -E 's/^[vV]//; s/[^0-9.]/./g; s/\.+/./g; s/^\.//; s/\.$//' +)" +if [[ -z "${bundle_version}" ]]; then + bundle_version="0" +fi + +package_dir="${work_dir}/${target}" +app_dir="${package_dir}/${app_name}.app" +contents_dir="${app_dir}/Contents" +macos_dir="${contents_dir}/MacOS" +resources_dir="${contents_dir}/Resources" +dmg_root="${package_dir}/dmg-root" +dmg_path="${output_dir}/koko-${target}.dmg" + +rm -rf "${package_dir}" +mkdir -p "${macos_dir}" "${resources_dir}" "${dmg_root}" "${output_dir}" + +install -m 0755 "${binary_path}" "${macos_dir}/koko" +ditto "assets" "${resources_dir}/assets" +ditto "crates/client-web/dist" "${resources_dir}/client-web/dist" +install -m 0644 "LICENSE" "${resources_dir}/LICENSE" + +iconset_dir="${package_dir}/${app_name}.iconset" +mkdir -p "${iconset_dir}" +for size in 16 32 128 256 512; do + sips -z "${size}" "${size}" "assets/Koko.png" \ + --out "${iconset_dir}/icon_${size}x${size}.png" >/dev/null + + retina_size=$((size * 2)) + if [[ "${retina_size}" -le 512 ]]; then + sips -z "${retina_size}" "${retina_size}" "assets/Koko.png" \ + --out "${iconset_dir}/icon_${size}x${size}@2x.png" >/dev/null + fi +done +iconutil -c icns "${iconset_dir}" -o "${resources_dir}/${app_name}.icns" + +cat > "${contents_dir}/Info.plist" < + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + ${app_name} + CFBundleExecutable + koko + CFBundleIconFile + ${app_name}.icns + CFBundleIdentifier + ${bundle_id} + CFBundleName + ${app_name} + CFBundlePackageType + APPL + CFBundleShortVersionString + ${bundle_version} + CFBundleVersion + ${bundle_version} + LSApplicationCategoryType + public.app-category.entertainment + LSMinimumSystemVersion + 14.2 + LSUIElement + + NSLocalNetworkUsageDescription + ${app_name} serves your media library on your local network. + + +EOF +plutil -lint "${contents_dir}/Info.plist" + +if [[ "${sign_bundle}" == "true" ]]; then + xattr -rc "${app_dir}" + codesign --force --timestamp --options runtime \ + --sign "${codesign_identity}" \ + "${macos_dir}/koko" + codesign --force --timestamp --options runtime \ + --sign "${codesign_identity}" \ + "${app_dir}" + codesign --verify --deep --strict --verbose=2 "${app_dir}" +fi + +ditto "${app_dir}" "${dmg_root}/${app_name}.app" +ln -s /Applications "${dmg_root}/Applications" + +rm -f "${dmg_path}" +if ! hdiutil create -volname "${app_name}" -srcfolder "${dmg_root}" -ov -format UDZO "${dmg_path}"; then + echo "hdiutil failed, retrying once..." >&2 + sleep 5 + hdiutil create -volname "${app_name}" -srcfolder "${dmg_root}" -ov -format UDZO "${dmg_path}" +fi + +if [[ "${sign_bundle}" == "true" ]]; then + codesign --force --timestamp \ + --sign "${codesign_identity}" \ + "${dmg_path}" + codesign --verify --verbose=2 "${dmg_path}" +fi + +echo "Created ${dmg_path}" From ea83ef9478a6e2124810d6139c538baf9a62e9a4 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:52:19 -0400 Subject: [PATCH 126/128] Install cargo-deny via cargo-run-bin in CI Update CI to install cargo-run-bin at the version specified in cargo metadata, then invoke cargo-deny through `cargo bin` instead of installing it directly. The workflow now parses `cargo-run-bin`'s version from `cargo metadata`, fails if parsing fails, and runs `cargo bin cargo-deny check licenses`. Add `cargo-deny` to workspace.metadata.bin in Cargo.toml with a pinned version. --- .github/workflows/ci.yml | 16 +++++++++++++--- Cargo.toml | 1 + 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 721ba2ca..c004936c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,11 +51,21 @@ jobs: cache: true cache-on-failure: false - - name: Install cargo-deny - run: cargo install --locked cargo-deny + - name: Install Cargo tools + run: | + cargo_run_bin_version="$( + cargo metadata --locked --no-deps --format-version 1 \ + | sed -n 's/.*"cargo-run-bin"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' + )" + if [[ -z "${cargo_run_bin_version}" ]]; then + echo "Failed to parse cargo-run-bin version from cargo metadata" >&2 + exit 1 + fi + cargo install --locked cargo-run-bin --version "${cargo_run_bin_version}" + cargo bin cargo-deny --version - name: Check licenses - run: cargo deny check licenses + run: cargo bin cargo-deny check licenses clippy: name: Clippy diff --git a/Cargo.toml b/Cargo.toml index a9e05acd..f3cac5d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ rstest = "0.25.0" cargo-run-bin = "1.7.4" [workspace.metadata.bin] +cargo-deny = { version = "0.19.9", locked = true } cargo-edit = { version = "0.13.1", bins = ["cargo-set-version"], locked = true } cargo-tarpaulin = { version = "0.35.4", locked = true } cargo-wix = { version = "0.3.9", git = "https://github.com/volks73/cargo-wix.git", rev = "fde983c2e901970267e76b8fd68120fdd5457a57", locked = true } From 159606dd4e6b1ca44329483cb468ca3680d107f2 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 18 Jun 2026 08:25:33 -0400 Subject: [PATCH 127/128] Rename media_type->item_type and tighten item kinds Standardize references to item-level kinds by renaming media_type to item_type across metadata, provider, web route and media logic. Providers (Themerr, TMDB, TVDB, TrailerDb) now expect exact item types (e.g. "show" instead of aliases like "tv"/"series") and perform stricter normalization, avoiding permissive alias mapping. Updated secondary-reference plumbing and candidate structs to use item_type, adjusted theme-song lookup to propagate item_type, and made tvdb/person handling use the correct "people" vs "person" bucket. Client mock API: added a TVDB provider entry, included linked_media_type on an item, and generate secondary Themerr matches alongside primary matches. Tests updated to match the new exact item-type behavior. --- crates/client-web/src/mockApi.ts | 89 ++++++++++++++++++- crates/server/src/media.rs | 41 ++++----- crates/server/src/metadata/mod.rs | 12 +-- crates/server/src/metadata/providers/mod.rs | 28 +++--- .../server/src/metadata/providers/themerr.rs | 66 +++++++------- crates/server/src/metadata/providers/tmdb.rs | 39 +++++--- .../src/metadata/providers/trailerdb.rs | 21 +++-- crates/server/src/metadata/providers/tvdb.rs | 70 +++++++++++---- crates/server/src/web/routes/media.rs | 30 ++++--- crates/server/tests/test_media.rs | 12 +-- 10 files changed, 271 insertions(+), 137 deletions(-) diff --git a/crates/client-web/src/mockApi.ts b/crates/client-web/src/mockApi.ts index 0f308fea..306fff65 100644 --- a/crates/client-web/src/mockApi.ts +++ b/crates/client-web/src/mockApi.ts @@ -210,6 +210,7 @@ const items: MediaItemDetail[] = [ duration_ms: 2_700_000, modified_at: 1760923150, genres: ['Drama', 'Fantasy'], + linked_media_type: 'tv', has_metadata: true, metadata_refresh_state: 'fresh', theme_song_url: 'https://www.youtube.com/watch?v=uXZd_W5B7N0', @@ -462,6 +463,23 @@ const metadataProviders: MetadataProviderStatus[] = [ logo_light_url: undefined, logo_dark_url: undefined, }, + { + id: 'tvdb', + display_name: 'TheTVDB', + description: 'Alternative movie and television metadata provider with strong series and episode coverage.', + supported_kinds: ['movies', 'shows'], + requires_api_key: true, + implemented: true, + role: 'primary', + extends_provider_ids: [], + enabled: false, + configured: false, + language: 'en-US', + attribution_text: 'Metadata and artwork provided by TheTVDB.', + attribution_url: 'https://thetvdb.com/', + logo_light_url: undefined, + logo_dark_url: undefined, + }, { id: 'musicbrainz', display_name: 'MusicBrainz', @@ -487,7 +505,7 @@ const metadataProviders: MetadataProviderStatus[] = [ requires_api_key: false, implemented: true, role: 'secondary', - extends_provider_ids: ['tmdb'], + extends_provider_ids: ['tmdb', 'tvdb'], enabled: true, configured: true, language: 'en-US', @@ -599,7 +617,28 @@ const itemMetadata: Record = { 201: { item_id: 201, providers: metadataProviders, - matches: [], + matches: metadataMatchesWithSecondaries( + items.find((item) => item.id === 201), + { + id: 3, + provider_id: 'tmdb', + external_id: '1399', + title: 'Game of Thrones', + overview: 'Nine noble families wage war against each other in order to gain control over the mythical land of Westeros.', + artwork_url: 'https://image.tmdb.org/t/p/w500/u3bZgnGQ9T01sWNhyveQz0wH0Hl.jpg', + backdrop_url: 'https://image.tmdb.org/t/p/w1280/suopoADq0k8YZr4dQXcU6pToj6s.jpg', + release_year: 2011, + media_type: 'tv', + relation_kind: 'primary', + match_state: 'linked', + genres: ['Drama', 'Fantasy'], + people: [], + locale_key: 'en-US', + refresh_state: 'fresh', + last_refreshed_at: 1760923200, + updated_at: 1760923200, + }, + ), }, 202: { item_id: 202, @@ -623,6 +662,47 @@ const itemMetadata: Record = { }, }; +function themerrSecondaryMatch( + item: MediaItemSummary, + primaryMatch: ItemMetadataMatch, + id: number, +): ItemMetadataMatch | undefined { + if (item.item_type !== 'movie' && item.item_type !== 'show') { + return undefined; + } + if (primaryMatch.provider_id !== 'tmdb') { + return undefined; + } + + return { + id, + provider_id: 'themerr', + external_id: `${item.item_type}:tmdb:${primaryMatch.external_id}`, + media_type: item.item_type, + relation_kind: 'secondary', + match_state: 'linked', + theme_song_url: item.item_type === 'show' + ? 'https://www.youtube.com/watch?v=uXZd_W5B7N0' + : 'https://www.youtube.com/watch?v=SLBACEP6LsI', + genres: [], + people: [], + locale_key: 'en-US', + refresh_state: 'fresh', + last_refreshed_at: primaryMatch.last_refreshed_at, + updated_at: primaryMatch.updated_at, + }; +} + +function metadataMatchesWithSecondaries( + item: MediaItemSummary | undefined, + primaryMatch: ItemMetadataMatch, +): ItemMetadataMatch[] { + const secondaryMatch = item + ? themerrSecondaryMatch(item, primaryMatch, primaryMatch.id + 1) + : undefined; + return secondaryMatch ? [primaryMatch, secondaryMatch] : [primaryMatch]; +} + interface MockPlaybackProgress extends PlaybackProgressRequest { watch_count: number; last_watched_at?: number; @@ -1312,12 +1392,13 @@ export function linkMockItemMetadata(itemId: number, request: LinkMetadataReques updated_at: Math.floor(Date.now() / 1000), }; + const item = items.find((candidate) => candidate.id === itemId); + itemMetadata[itemId] = { item_id: itemId, providers: metadataProviders, - matches: [linkedMatch], + matches: metadataMatchesWithSecondaries(item, linkedMatch), }; - const item = items.find((candidate) => candidate.id === itemId); if (item) { item.display_title = candidate.title; } diff --git a/crates/server/src/media.rs b/crates/server/src/media.rs index a2ffc7d9..bff88d5a 100644 --- a/crates/server/src/media.rs +++ b/crates/server/src/media.rs @@ -88,7 +88,7 @@ use crate::utils::current_timestamp; #[derive(Debug)] struct SecondaryMetadataReferenceCandidate { - media_type: String, + item_type: String, database_id: String, external_id: String, priority: usize, @@ -3509,6 +3509,16 @@ fn get_item_theme_song_source_references( break; } + let Some((parent_id, item_type)) = media_items_dsl::media_items + .filter(media_items_dsl::id.eq(current_item_id)) + .filter(media_items_dsl::deleted_at.is_null()) + .select((media_items_dsl::parent_id, media_items_dsl::item_type)) + .first::<(Option, String)>(conn) + .optional()? + else { + break; + }; + let links = get_item_theme_song_source_metadata_links(conn, current_item_id, source_provider_ids)?; if !links.is_empty() { @@ -3518,6 +3528,7 @@ fn get_item_theme_song_source_references( append_theme_song_source_references( conn, secondary_provider, + &item_type, &link, &mut references, &mut seen, @@ -3526,13 +3537,7 @@ fn get_item_theme_song_source_references( return Ok(references); } - current_id = media_items_dsl::media_items - .filter(media_items_dsl::id.eq(current_item_id)) - .filter(media_items_dsl::deleted_at.is_null()) - .select(media_items_dsl::parent_id) - .first::>(conn) - .optional()? - .flatten(); + current_id = parent_id; } Ok(Vec::new()) @@ -3541,6 +3546,7 @@ fn get_item_theme_song_source_references( fn append_theme_song_source_references( conn: &mut SqliteConnection, secondary_provider: &(dyn MetadataProvider + Send + Sync), + item_type: &str, link: &ItemMetadataLink, references: &mut Vec<(String, String, String)>, seen: &mut HashSet<(String, String, String)>, @@ -3548,18 +3554,13 @@ fn append_theme_song_source_references( let Some(source_provider_id) = MetadataProviderId::from_storage_value(&link.provider_id) else { return Ok(()); }; - if let Some(media_type) = link - .media_type - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { + if !item_type.trim().is_empty() { let mut candidates = Vec::new(); let mut order = 0; push_supported_secondary_reference_candidate( secondary_provider, &source_provider_id, - media_type, + item_type, link.provider_id.clone(), link.external_id.clone(), order, @@ -3571,7 +3572,7 @@ fn append_theme_song_source_references( push_supported_secondary_reference_candidate( secondary_provider, &source_provider_id, - media_type, + item_type, database_id, external_id, order, @@ -3582,7 +3583,7 @@ fn append_theme_song_source_references( candidates.sort_by_key(|candidate| (candidate.priority, candidate.order)); for candidate in candidates { let reference = ( - candidate.media_type, + candidate.item_type, candidate.database_id, candidate.external_id, ); @@ -3598,7 +3599,7 @@ fn append_theme_song_source_references( fn push_supported_secondary_reference_candidate( secondary_provider: &(dyn MetadataProvider + Send + Sync), source_provider_id: &MetadataProviderId, - media_type: &str, + item_type: &str, database_id: String, external_id: String, order: usize, @@ -3606,14 +3607,14 @@ fn push_supported_secondary_reference_candidate( ) { let Some(priority) = secondary_provider.secondary_metadata_reference_priority( source_provider_id, - media_type, + item_type, &database_id, ) else { return; }; candidates.push(SecondaryMetadataReferenceCandidate { - media_type: media_type.to_string(), + item_type: item_type.to_string(), database_id, external_id, priority, diff --git a/crates/server/src/metadata/mod.rs b/crates/server/src/metadata/mod.rs index 9512211d..bd211040 100644 --- a/crates/server/src/metadata/mod.rs +++ b/crates/server/src/metadata/mod.rs @@ -1939,7 +1939,7 @@ pub async fn load_provider_show_descendant_targets( /// Resolve item-level metadata fields contributed by a secondary provider. pub async fn fetch_provider_secondary_metadata( provider_id: MetadataProviderId, - media_type: &str, + item_type: &str, database_id: &str, external_id: &str, locale_key: &str, @@ -1952,14 +1952,14 @@ pub async fn fetch_provider_secondary_metadata( ) })?; provider - .fetch_secondary_metadata(media_type, database_id, external_id, locale_key) + .fetch_secondary_metadata(item_type, database_id, external_id, locale_key) .await } /// Resolve collection-level metadata fields contributed by a secondary provider. pub async fn fetch_provider_secondary_collection_metadata( provider_id: MetadataProviderId, - media_type: &str, + item_type: &str, database_id: &str, external_id: &str, locale_key: &str, @@ -1972,7 +1972,7 @@ pub async fn fetch_provider_secondary_collection_metadata( ) })?; provider - .fetch_secondary_collection_metadata(media_type, database_id, external_id, locale_key) + .fetch_secondary_collection_metadata(item_type, database_id, external_id, locale_key) .await } @@ -2781,7 +2781,7 @@ pub fn upsert_secondary_collection_theme_song_url( conn: &mut SqliteConnection, source_collection_id: i32, provider_id: MetadataProviderId, - media_type: &str, + item_type: &str, database_id: &str, external_id: &str, theme_song_url: &str, @@ -2800,7 +2800,7 @@ pub fn upsert_secondary_collection_theme_song_url( .first::(conn)?; let now = current_timestamp(); let secondary_collection = ProviderMetadataCollection { - external_id: format!("{media_type}:{database_id}:{external_id}"), + external_id: format!("{item_type}:{database_id}:{external_id}"), name: None, overview: None, artwork_url: None, diff --git a/crates/server/src/metadata/providers/mod.rs b/crates/server/src/metadata/providers/mod.rs index 5731bb65..bf1c1110 100644 --- a/crates/server/src/metadata/providers/mod.rs +++ b/crates/server/src/metadata/providers/mod.rs @@ -159,10 +159,10 @@ pub trait MetadataProvider { fn supports_secondary_metadata_reference( &self, source_provider_id: &MetadataProviderId, - media_type: &str, + item_type: &str, database_id: &str, ) -> bool { - self.secondary_metadata_reference_priority(source_provider_id, media_type, database_id) + self.secondary_metadata_reference_priority(source_provider_id, item_type, database_id) .is_some() } @@ -170,7 +170,7 @@ pub trait MetadataProvider { fn secondary_metadata_reference_priority( &self, _source_provider_id: &MetadataProviderId, - _media_type: &str, + _item_type: &str, _database_id: &str, ) -> Option { Some(0) @@ -179,7 +179,7 @@ pub trait MetadataProvider { /// Resolve item-level metadata fields contributed by a secondary provider. fn fetch_secondary_metadata<'a>( &'a self, - _media_type: &'a str, + _item_type: &'a str, _database_id: &'a str, _external_id: &'a str, _locale_key: &'a str, @@ -190,7 +190,7 @@ pub trait MetadataProvider { /// Resolve collection-level metadata fields contributed by a secondary provider. fn fetch_secondary_collection_metadata<'a>( &'a self, - _media_type: &'a str, + _item_type: &'a str, _database_id: &'a str, _external_id: &'a str, _locale_key: &'a str, @@ -523,21 +523,21 @@ impl MetadataProvider for ThemerrMetadataProvider { fn secondary_metadata_reference_priority( &self, source_provider_id: &MetadataProviderId, - media_type: &str, + item_type: &str, database_id: &str, ) -> Option { - themerr::item_lookup_reference_priority(source_provider_id, media_type, database_id) + themerr::item_lookup_reference_priority(source_provider_id, item_type, database_id) } fn fetch_secondary_metadata<'a>( &'a self, - media_type: &'a str, + item_type: &'a str, database_id: &'a str, external_id: &'a str, _locale_key: &'a str, ) -> MetadataProviderFuture<'a, Option> { Box::pin(themerr::fetch_youtube_theme_metadata( - media_type, + item_type, database_id, external_id, )) @@ -545,17 +545,17 @@ impl MetadataProvider for ThemerrMetadataProvider { fn fetch_secondary_collection_metadata<'a>( &'a self, - media_type: &'a str, + item_type: &'a str, database_id: &'a str, external_id: &'a str, _locale_key: &'a str, ) -> MetadataProviderFuture<'a, Option> { Box::pin(async move { Ok( - themerr::fetch_youtube_theme_url(media_type, database_id, external_id) + themerr::fetch_youtube_theme_url(item_type, database_id, external_id) .await? .map(|theme_song_url| ProviderMetadataCollection { - external_id: format!("{media_type}:{database_id}:{external_id}"), + external_id: format!("{item_type}:{database_id}:{external_id}"), name: None, overview: None, artwork_url: None, @@ -585,13 +585,13 @@ impl MetadataProvider for TrailerDbMetadataProvider { fn fetch_secondary_metadata<'a>( &'a self, - media_type: &'a str, + item_type: &'a str, database_id: &'a str, external_id: &'a str, locale_key: &'a str, ) -> MetadataProviderFuture<'a, Option> { Box::pin(trailerdb::fetch_secondary_metadata( - media_type, + item_type, database_id, external_id, locale_key, diff --git a/crates/server/src/metadata/providers/themerr.rs b/crates/server/src/metadata/providers/themerr.rs index 44ebe93d..43524809 100644 --- a/crates/server/src/metadata/providers/themerr.rs +++ b/crates/server/src/metadata/providers/themerr.rs @@ -43,14 +43,14 @@ pub(crate) fn descriptor() -> MetadataProviderDescriptor { } pub(crate) async fn fetch_youtube_theme_url( - media_type: &str, + item_type: &str, database_id: &str, external_id: &str, ) -> Result, String> { - let Some(database_path) = database_path_for_media_type(media_type) else { + let Some(database_path) = database_path_for_item_type(item_type) else { return Ok(None); }; - let Some(database_id) = normalize_database_id(media_type, database_id) else { + let Some(database_id) = normalize_database_id(item_type, database_id) else { return Ok(None); }; let normalized_external_id = external_id.trim(); @@ -82,12 +82,11 @@ pub(crate) async fn fetch_youtube_theme_url( } pub(crate) async fn fetch_youtube_theme_metadata( - media_type: &str, + item_type: &str, database_id: &str, external_id: &str, ) -> Result, String> { - let Some(theme_song_url) = - fetch_youtube_theme_url(media_type, database_id, external_id).await? + let Some(theme_song_url) = fetch_youtube_theme_url(item_type, database_id, external_id).await? else { return Ok(None); }; @@ -109,13 +108,13 @@ pub(crate) async fn fetch_youtube_theme_metadata( pub(crate) fn item_lookup_reference_priority( source_provider_id: &MetadataProviderId, - media_type: &str, + item_type: &str, database_id: &str, ) -> Option { - let normalized_database_id = normalize_database_id(media_type, database_id)?; + let normalized_database_id = normalize_database_id(item_type, database_id)?; match source_provider_id { MetadataProviderId::Tmdb | MetadataProviderId::Tvdb => { - if !themerr_supports_item_media_type(media_type) { + if !themerr_supports_item_type(item_type) { return None; } match normalized_database_id { @@ -155,30 +154,30 @@ async fn fetch_youtube_oembed_metadata(url: &str) -> Option Option<&'static str> { - match media_type.trim() { +fn database_path_for_item_type(item_type: &str) -> Option<&'static str> { + match item_type.trim() { "movie" => Some("movies"), - "tv" | "series" | "show" => Some("tv_shows"), - "collection" | "movie_collection" => Some("movie_collections"), + "show" => Some("tv_shows"), + "collection" => Some("movie_collections"), _ => None, } } -fn themerr_supports_item_media_type(media_type: &str) -> bool { +fn themerr_supports_item_type(item_type: &str) -> bool { matches!( - media_type.trim().to_ascii_lowercase().as_str(), - "movie" | "tv" | "series" | "show" + item_type.trim().to_ascii_lowercase().as_str(), + "movie" | "show" ) } fn normalize_database_id( - media_type: &str, + item_type: &str, database_id: &str, ) -> Option<&'static str> { - let normalized_media_type = media_type.trim().to_ascii_lowercase(); + let normalized_item_type = item_type.trim().to_ascii_lowercase(); match database_id.trim().to_ascii_lowercase().as_str() { - "themoviedb" | "tmdb" => Some("themoviedb"), - "imdb" if normalized_media_type == "movie" => Some("imdb"), + "tmdb" => Some("themoviedb"), + "imdb" if normalized_item_type == "movie" => Some("imdb"), _ => None, } } @@ -210,7 +209,7 @@ fn text_field( #[cfg(test)] mod tests { use super::{ - database_path_for_media_type, + database_path_for_item_type, item_lookup_reference_priority, normalize_database_id, parse_youtube_theme_url, @@ -246,7 +245,7 @@ mod tests { #[test] fn collection_theme_lookup_uses_movie_collection_database() { assert_eq!( - database_path_for_media_type("collection"), + database_path_for_item_type("collection"), Some("movie_collections") ); assert_eq!( @@ -258,25 +257,26 @@ mod tests { #[test] fn imdb_theme_lookup_is_movie_only() { - assert_eq!(database_path_for_media_type("movie"), Some("movies")); - assert_eq!(database_path_for_media_type("series"), Some("tv_shows")); + assert_eq!(database_path_for_item_type("movie"), Some("movies")); + assert_eq!(database_path_for_item_type("show"), Some("tv_shows")); + assert_eq!(database_path_for_item_type("series"), None); + assert_eq!(database_path_for_item_type("tv"), None); assert_eq!( - database_path_for_media_type("collection"), + database_path_for_item_type("collection"), Some("movie_collections") ); assert_eq!(normalize_database_id("movie", "imdb"), Some("imdb")); - assert_eq!(normalize_database_id("series", "imdb"), None); - assert_eq!(normalize_database_id("tv", "imdb"), None); + assert_eq!(normalize_database_id("show", "imdb"), None); assert_eq!(normalize_database_id("collection", "imdb"), None); } #[test] - fn item_lookup_reference_support_follows_source_provider_and_media_type() { + fn item_lookup_reference_support_follows_source_provider_and_item_type() { assert!( item_lookup_reference_priority(&MetadataProviderId::Tmdb, "movie", "tmdb").is_some() ); assert!( - item_lookup_reference_priority(&MetadataProviderId::Tmdb, "series", "tmdb").is_some() + item_lookup_reference_priority(&MetadataProviderId::Tmdb, "show", "tmdb").is_some() ); assert!( item_lookup_reference_priority(&MetadataProviderId::Tmdb, "movie", "imdb").is_some() @@ -285,11 +285,15 @@ mod tests { item_lookup_reference_priority(&MetadataProviderId::Tvdb, "movie", "imdb").is_some() ); assert!( - item_lookup_reference_priority(&MetadataProviderId::Tvdb, "series", "tmdb").is_some() + item_lookup_reference_priority(&MetadataProviderId::Tvdb, "show", "tmdb").is_some() ); assert!( - item_lookup_reference_priority(&MetadataProviderId::Tvdb, "series", "imdb").is_none() + item_lookup_reference_priority(&MetadataProviderId::Tvdb, "show", "imdb").is_none() ); + assert!( + item_lookup_reference_priority(&MetadataProviderId::Tmdb, "series", "tmdb").is_none() + ); + assert!(item_lookup_reference_priority(&MetadataProviderId::Tvdb, "tv", "tmdb").is_none()); assert!( item_lookup_reference_priority(&MetadataProviderId::Tvdb, "movie", "thetvdb").is_none() ); diff --git a/crates/server/src/metadata/providers/tmdb.rs b/crates/server/src/metadata/providers/tmdb.rs index 095de967..3c2ac407 100644 --- a/crates/server/src/metadata/providers/tmdb.rs +++ b/crates/server/src/metadata/providers/tmdb.rs @@ -93,7 +93,7 @@ pub(crate) fn metadata_item_kind(media_type: Option<&str>) -> MetadataItemKind { "tv_season" => MetadataItemKind::Season, "tv_episode" => MetadataItemKind::Episode, "collection" => MetadataItemKind::Collection, - "person" | "people" => MetadataItemKind::Person, + "person" => MetadataItemKind::Person, "company" => MetadataItemKind::Company, _ => MetadataItemKind::Item, } @@ -108,7 +108,7 @@ pub(crate) async fn search( let api_key = tmdb_api_key_from_provider(&provider)?; let query = query.to_string(); let language = provider.language; - let expected_media_type = media_type.map(normalize_tmdb_search_media_type); + let expected_media_type = media_type.map(|value| value.trim().to_ascii_lowercase()); run_tmdb_blocking(move || { let client = TmdbApiClient::new_with_api_key(api_key); let payload = client @@ -131,13 +131,6 @@ pub(crate) async fn search( .await } -fn normalize_tmdb_search_media_type(media_type: &str) -> String { - match media_type { - "series" => "tv".into(), - other => other.into(), - } -} - pub(crate) async fn fetch_snapshot( settings: &MetadataSettings, external_id: &str, @@ -150,10 +143,7 @@ pub(crate) async fn fetch_snapshot( let image_languages = tmdb_include_image_languages(&language); let external_id_number = parse_external_id(external_id, media_type)?; let external_id_string = external_id.to_string(); - let normalized_media_type = match media_type { - "series" => "tv".to_string(), - other => other.to_string(), - }; + let normalized_media_type = media_type.trim().to_ascii_lowercase(); run_tmdb_blocking(move || match normalized_media_type.as_str() { "movie" => { @@ -1776,10 +1766,14 @@ fn sort_and_dedupe_people(people: Vec) -> Vec String { } pub(crate) async fn fetch_secondary_metadata( - media_type: &str, + item_type: &str, database_id: &str, external_id: &str, locale_key: &str, ) -> Result, String> { - let Some(path) = trailerdb_path(media_type, database_id, external_id) else { + let Some(path) = trailerdb_path(item_type, database_id, external_id) else { return Ok(None); }; @@ -74,7 +74,7 @@ pub(crate) async fn fetch_secondary_metadata( } fn trailerdb_path( - media_type: &str, + item_type: &str, database_id: &str, external_id: &str, ) -> Option { @@ -84,11 +84,11 @@ fn trailerdb_path( } match ( - media_type.trim().to_ascii_lowercase().as_str(), + item_type.trim().to_ascii_lowercase().as_str(), database_id.trim().to_ascii_lowercase().as_str(), ) { ("movie", "imdb") => Some(format!("movie/{external_id}")), - ("tv" | "series" | "show", "tmdb" | "themoviedb") => Some(format!("series/{external_id}")), + ("show", "tmdb") => Some(format!("series/{external_id}")), _ => None, } } @@ -263,14 +263,13 @@ mod tests { #[test] fn show_lookup_uses_tmdb_series_detail_endpoint() { assert_eq!( - trailerdb_path("tv", "tmdb", "1399").as_deref(), + trailerdb_path("show", "tmdb", "1399").as_deref(), Some("series/1399") ); - assert_eq!( - trailerdb_path("show", "themoviedb", "1399").as_deref(), - Some("series/1399") - ); - assert_eq!(trailerdb_path("tv", "imdb", "tt0944947"), None); + assert_eq!(trailerdb_path("tv", "tmdb", "1399"), None); + assert_eq!(trailerdb_path("series", "tmdb", "1399"), None); + assert_eq!(trailerdb_path("show", "themoviedb", "1399"), None); + assert_eq!(trailerdb_path("show", "imdb", "tt0944947"), None); } #[test] diff --git a/crates/server/src/metadata/providers/tvdb.rs b/crates/server/src/metadata/providers/tvdb.rs index 6c0bf784..59f355cb 100644 --- a/crates/server/src/metadata/providers/tvdb.rs +++ b/crates/server/src/metadata/providers/tvdb.rs @@ -86,11 +86,11 @@ pub(crate) fn metadata_item_kind(media_type: Option<&str>) -> MetadataItemKind { .as_str() { "movie" => MetadataItemKind::Movie, - "series" | "tv" => MetadataItemKind::Show, + "series" => MetadataItemKind::Show, "season" => MetadataItemKind::Season, "episode" => MetadataItemKind::Episode, "list" => MetadataItemKind::Collection, - "people" | "person" | "actor" => MetadataItemKind::Person, + "people" => MetadataItemKind::Person, "company" => MetadataItemKind::Company, "award" => MetadataItemKind::Award, _ => MetadataItemKind::Item, @@ -108,7 +108,7 @@ pub(crate) async fn search( ("query", query.to_string()), ("limit", "20".to_string()), ]; - if let Some(media_type) = media_type.map(normalize_tvdb_search_media_type) { + if let Some(media_type) = media_type.map(|value| value.trim().to_ascii_lowercase()) { query_params.push(("type", media_type)); } let payload = get_json( @@ -124,7 +124,7 @@ pub(crate) async fn search( .cloned() .unwrap_or_default(); - let expected_media_type = media_type.map(normalize_tvdb_search_media_type); + let expected_media_type = media_type.map(|value| value.trim().to_ascii_lowercase()); Ok(results .into_iter() .filter_map(|result| search_result_from_value(result, &provider.language)) @@ -137,13 +137,6 @@ pub(crate) async fn search( .collect()) } -fn normalize_tvdb_search_media_type(media_type: &str) -> String { - match media_type { - "tv" => "series".into(), - other => other.into(), - } -} - pub(crate) async fn fetch_snapshot( settings: &MetadataSettings, external_id: &str, @@ -166,7 +159,7 @@ pub(crate) async fn fetch_snapshot( &provider.language, )) } - "series" | "tv" => { + "series" => { let provider = provider_settings(settings, MetadataProviderId::Tvdb) .map_err(|error| format!("TheTVDB {}", error))?; let payload = @@ -800,8 +793,8 @@ fn search_result_from_value( .and_then(Value::as_str) .map(|value| value.to_ascii_lowercase())?; let media_type = match item_type.as_str() { - "series" | "tv series" | "tv" | "show" => "series", - "movie" | "film" | "feature film" => "movie", + "series" => "series", + "movie" => "movie", _ => return None, }; @@ -1226,7 +1219,7 @@ async fn cache_tvdb_people_payload_images( data_dir, snapshot.provider_id.clone(), &external_id, - Some("person"), + Some("people"), &snapshot.locale_key, ); let cache_key = format!("{}_profile", snapshot.provider_id.as_storage_value()); @@ -2152,11 +2145,13 @@ mod tests { backdrop_url, best_overview, metadata_details, + metadata_item_kind, movie_snapshot_from_value, search_result_from_value, tvdb_logo_url, tvdb_people_with_language, }; + use crate::metadata::MetadataItemKind; use serde_json::json; #[test] @@ -2231,10 +2226,25 @@ mod tests { } #[test] - fn tvdb_search_result_accepts_show_alias_type() { + fn tvdb_metadata_item_kind_uses_exact_provider_media_types() { + assert_eq!(metadata_item_kind(Some("movie")), MetadataItemKind::Movie); + assert_eq!(metadata_item_kind(Some("series")), MetadataItemKind::Show); + assert_eq!(metadata_item_kind(Some("season")), MetadataItemKind::Season); + assert_eq!( + metadata_item_kind(Some("episode")), + MetadataItemKind::Episode + ); + assert_eq!(metadata_item_kind(Some("people")), MetadataItemKind::Person); + assert_eq!(metadata_item_kind(Some("tv")), MetadataItemKind::Item); + assert_eq!(metadata_item_kind(Some("person")), MetadataItemKind::Item); + assert_eq!(metadata_item_kind(Some("actor")), MetadataItemKind::Item); + } + + #[test] + fn tvdb_search_result_accepts_series_type() { let result = search_result_from_value( json!({ - "type": "show", + "type": "series", "tvdb_id": "42", "name": "Example Show" }), @@ -2247,6 +2257,32 @@ mod tests { assert_eq!(result.title, "Example Show"); } + #[test] + fn tvdb_search_result_rejects_media_type_aliases() { + assert!( + search_result_from_value( + json!({ + "type": "show", + "tvdb_id": "42", + "name": "Example Show" + }), + "eng", + ) + .is_none() + ); + assert!( + search_result_from_value( + json!({ + "type": "feature film", + "objectID": "901", + "name": "Example Movie" + }), + "eng", + ) + .is_none() + ); + } + #[test] fn tvdb_movie_snapshot_prefers_translation_payload_for_overview_and_tagline() { let payload = json!({ diff --git a/crates/server/src/web/routes/media.rs b/crates/server/src/web/routes/media.rs index a0c61c6b..7ad23cd9 100644 --- a/crates/server/src/web/routes/media.rs +++ b/crates/server/src/web/routes/media.rs @@ -651,10 +651,10 @@ async fn persist_secondary_metadata_for_item( for locale_key in &languages { let provider_locale = uses_localized_metadata .then(|| provider_locale_key(provider_id.clone(), locale_key)); - for (media_type, database_id, external_id) in &references { + for (item_type, database_id, external_id) in &references { match fetch_provider_secondary_metadata( provider_id.clone(), - media_type, + item_type, database_id, external_id, locale_key, @@ -664,7 +664,7 @@ async fn persist_secondary_metadata_for_item( Ok(Some(details)) => { db.run({ let provider_id = provider_id.clone(); - let media_type = media_type.clone(); + let item_type = item_type.clone(); let database_id = database_id.clone(); let external_id = external_id.clone(); let locale_key = locale_key.clone(); @@ -675,10 +675,8 @@ async fn persist_secondary_metadata_for_item( move |conn| { let snapshot = StoredMetadataSnapshot { provider_id, - external_id: format!( - "{media_type}:{database_id}:{external_id}" - ), - media_type: Some(media_type), + external_id: format!("{item_type}:{database_id}:{external_id}"), + media_type: Some(item_type), title: None, overview: None, artwork_url: None, @@ -717,7 +715,7 @@ async fn persist_secondary_metadata_for_item( provider_id.as_storage_value(), item_id, locale_key, - media_type, + item_type, database_id, external_id, error @@ -743,10 +741,10 @@ async fn persist_secondary_metadata_for_item( Status::InternalServerError })?; - for (collection_id, media_type, database_id, external_id) in collection_references { + for (collection_id, item_type, database_id, external_id) in collection_references { match fetch_provider_secondary_collection_metadata( provider_id.clone(), - &media_type, + &item_type, &database_id, &external_id, crate::metadata::DEFAULT_METADATA_LOCALE, @@ -759,7 +757,7 @@ async fn persist_secondary_metadata_for_item( }; db.run({ let provider_id = provider_id.clone(); - let media_type = media_type.clone(); + let item_type = item_type.clone(); let database_id = database_id.clone(); let external_id = external_id.clone(); move |conn| { @@ -767,7 +765,7 @@ async fn persist_secondary_metadata_for_item( conn, collection_id, provider_id, - &media_type, + &item_type, &database_id, &external_id, &url, @@ -793,7 +791,7 @@ async fn persist_secondary_metadata_for_item( {}): {}", provider_id.as_storage_value(), item_id, - media_type, + item_type, database_id, external_id, error @@ -2476,11 +2474,15 @@ async fn cache_deferred_person_image( let Some(image_url) = details.image_url.as_deref() else { return; }; + let person_media_type = match &target.provider_id { + MetadataProviderId::Tvdb => "people", + _ => "person", + }; let person_dir = managed_metadata_asset_dir( &settings.general.data_dir, target.provider_id.clone(), &target.external_id, - Some("person"), + Some(person_media_type), &target.locale_key, ); let cache_key = format!("{}_profile", target.provider_id.as_storage_value()); diff --git a/crates/server/tests/test_media.rs b/crates/server/tests/test_media.rs index 980ffe06..db0b5ac4 100644 --- a/crates/server/tests/test_media.rs +++ b/crates/server/tests/test_media.rs @@ -3067,7 +3067,7 @@ fn test_secondary_theme_song_reference_inherits_from_linked_show() { MetadataProviderId::Themerr ) .unwrap(), - vec![("tv".into(), "tmdb".into(), "1399".into())] + vec![("show".into(), "tmdb".into(), "1399".into())] ); assert_eq!( get_item_youtube_theme_provider_references( @@ -3076,7 +3076,7 @@ fn test_secondary_theme_song_reference_inherits_from_linked_show() { MetadataProviderId::Themerr ) .unwrap(), - vec![("tv".into(), "tmdb".into(), "1399".into())] + vec![("show".into(), "tmdb".into(), "1399".into())] ); assert_eq!( get_item_youtube_theme_provider_references( @@ -3085,7 +3085,7 @@ fn test_secondary_theme_song_reference_inherits_from_linked_show() { MetadataProviderId::Themerr ) .unwrap(), - vec![("tv".into(), "tmdb".into(), "1399".into())] + vec![("show".into(), "tmdb".into(), "1399".into())] ); drop(connection); @@ -3158,8 +3158,8 @@ fn test_item_detail_theme_song_inherits_from_show() { show.id, &StoredMetadataSnapshot { provider_id: MetadataProviderId::Themerr, - external_id: "tv:tmdb:1399".into(), - media_type: Some("tv".into()), + external_id: "show:tmdb:1399".into(), + media_type: Some("show".into()), title: None, overview: None, artwork_url: None, @@ -3422,7 +3422,7 @@ fn test_themerr_references_include_tvdb_show_tmdb_fallback() { MetadataProviderId::Themerr ) .unwrap(), - vec![("series".into(), "tmdb".into(), "1399".into())] + vec![("show".into(), "tmdb".into(), "1399".into())] ); drop(connection); From 9d9d8054060335fb192e3df529d39aac6a451f57 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 21 Jun 2026 23:06:50 -0400 Subject: [PATCH 128/128] address pr comments --- .github/workflows/ci-macos-dmg.yml | 2 +- packaging/linux/flatpak/dev.lizardbyte.app.Koko.yml | 8 +++++++- packaging/linux/flatpak/exceptions.json | 1 + .../flatpak/modules/{xdotool.json => libxdo.json} | 2 +- packaging/macos/package-dmg.sh | 11 ++++++++++- 5 files changed, 20 insertions(+), 4 deletions(-) rename packaging/linux/flatpak/modules/{xdotool.json => libxdo.json} (96%) diff --git a/.github/workflows/ci-macos-dmg.yml b/.github/workflows/ci-macos-dmg.yml index a1a9e473..2c02532c 100644 --- a/.github/workflows/ci-macos-dmg.yml +++ b/.github/workflows/ci-macos-dmg.yml @@ -35,7 +35,7 @@ on: env: CARGO_TERM_COLOR: always INPUT_RELEASE_VERSION: ${{ inputs.release_version }} - MACOSX_DEPLOYMENT_TARGET: 14.2 + MACOSX_DEPLOYMENT_TARGET: "14.2" jobs: build_dmg: diff --git a/packaging/linux/flatpak/dev.lizardbyte.app.Koko.yml b/packaging/linux/flatpak/dev.lizardbyte.app.Koko.yml index a79d9bb7..e4ea2fdc 100644 --- a/packaging/linux/flatpak/dev.lizardbyte.app.Koko.yml +++ b/packaging/linux/flatpak/dev.lizardbyte.app.Koko.yml @@ -16,6 +16,7 @@ separate-locales: false finish-args: - --filesystem=home + - --filesystem=host:ro - --share=network - --share=ipc - --socket=fallback-x11 @@ -33,7 +34,8 @@ cleanup: modules: - shared-modules/libayatana-appindicator/libayatana-appindicator-gtk3.json - - modules/xdotool.json + # Required by the Linux tray/menu stack through the muda crate. + - modules/libxdo.json - name: koko buildsystem: simple @@ -69,6 +71,10 @@ modules: install -Dm0644 packaging/linux/flatpak/dev.lizardbyte.app.Koko.metainfo.xml /app/share/metainfo/dev.lizardbyte.app.Koko.metainfo.xml + run-tests: true + test-rule: "" + test-commands: + - cargo test --offline --locked --workspace --lib --bins --release sources: - type: git url: "@GITHUB_CLONE_URL@" diff --git a/packaging/linux/flatpak/exceptions.json b/packaging/linux/flatpak/exceptions.json index 6a31def4..9a537de7 100644 --- a/packaging/linux/flatpak/exceptions.json +++ b/packaging/linux/flatpak/exceptions.json @@ -3,6 +3,7 @@ "appid-url-not-reachable", "appstream-screenshots-not-mirrored-in-ostree", "metainfo-missing-screenshots", + "finish-args-host-ro-filesystem-access", "finish-args-home-filesystem-access" ] } diff --git a/packaging/linux/flatpak/modules/xdotool.json b/packaging/linux/flatpak/modules/libxdo.json similarity index 96% rename from packaging/linux/flatpak/modules/xdotool.json rename to packaging/linux/flatpak/modules/libxdo.json index 847d2e77..012a59e7 100644 --- a/packaging/linux/flatpak/modules/xdotool.json +++ b/packaging/linux/flatpak/modules/libxdo.json @@ -1,5 +1,5 @@ { - "name": "xdotool", + "name": "libxdo", "buildsystem": "simple", "build-commands": [ "make libxdo.so libxdo.so.4 libxdo.pc PREFIX=\"${FLATPAK_DEST}\" WITHOUT_RPATH_FIX=1", diff --git a/packaging/macos/package-dmg.sh b/packaging/macos/package-dmg.sh index 7e4a4d0b..d755728d 100644 --- a/packaging/macos/package-dmg.sh +++ b/packaging/macos/package-dmg.sh @@ -22,6 +22,9 @@ Options: --output-dir PATH Directory for the generated DMG. Default: artifacts. --sign Sign the app bundle and DMG with APPLE_CODESIGN_IDENTITY. -h, --help Show this help text. + +Environment: + MACOSX_DEPLOYMENT_TARGET Minimum macOS version to write into Info.plist. EOF } @@ -116,6 +119,12 @@ if [[ -z "${bundle_version}" ]]; then bundle_version="0" fi +minimum_system_version="${MACOSX_DEPLOYMENT_TARGET:-}" +if [[ -z "${minimum_system_version}" ]]; then + echo "MACOSX_DEPLOYMENT_TARGET must be set to write LSMinimumSystemVersion." >&2 + exit 1 +fi + package_dir="${work_dir}/${target}" app_dir="${package_dir}/${app_name}.app" contents_dir="${app_dir}/Contents" @@ -172,7 +181,7 @@ cat > "${contents_dir}/Info.plist" <LSApplicationCategoryType public.app-category.entertainment LSMinimumSystemVersion - 14.2 + ${minimum_system_version} LSUIElement NSLocalNetworkUsageDescription