From ad48d89dfe1a557ddebbc1ad0640a9ecd2c0081b Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Fri, 21 Aug 2026 17:41:44 +0200 Subject: [PATCH 01/10] chore(format): run prettier over src/ and scripts/ Formatting was configured but never enforced: `bun run format:check` reported 199 unformatted files and ran in no workflow and in no git hook, so .prettierrc (printWidth 100, trailing commas) described an intention rather than the tree. This is the one-time sweep that makes the check gateable. Whitespace and token-reflow only -- no behavioural change: `bun run check` reports 0 errors and all 1053 frontend tests pass before and after. Kept out of every other commit on purpose. A 199-file diff mixed with real changes is unreviewable, and the next commit turns format:check into a hard CI gate so this cannot silently accumulate again. --- docker-compose.yml | 4 +- scripts/extract-traces.test.ts | 18 +- scripts/extract-traces.ts | 41 +- scripts/release-notes.ts | 4 +- scripts/set-version.test.ts | 31 +- scripts/tauri-security-config.test.ts | 2 +- src/app.css | 8 +- src/app.html | 5 +- src/lib/api/autoplay.test.ts | 8 +- src/lib/api/autoplay.ts | 4 +- src/lib/api/backend-integration.test.ts | 36 +- src/lib/api/repository-client.test.ts | 37 +- src/lib/api/repository-client.ts | 47 +- src/lib/api/types.ts | 9 +- src/lib/components/AppHeader.svelte | 36 +- src/lib/components/BottomNav.svelte | 48 +- src/lib/components/FavoriteButton.svelte | 22 +- src/lib/components/Portal.svelte | 4 +- src/lib/components/Search.svelte | 14 +- src/lib/components/Toast.svelte | 9 +- src/lib/components/account/AccountMenu.svelte | 39 +- src/lib/components/auth/ReauthModal.svelte | 45 +- src/lib/components/common/BackButton.svelte | 6 +- src/lib/components/common/CachedImage.svelte | 10 +- .../components/common/ResultsCounter.svelte | 4 +- src/lib/components/common/ScrollPicker.svelte | 22 +- src/lib/components/common/SearchBar.test.ts | 2 +- .../components/downloads/DownloadItem.svelte | 109 +- .../downloads/DownloadedBrowse.svelte | 55 +- src/lib/components/home/Carousel.svelte | 12 +- src/lib/components/home/HeroBanner.svelte | 42 +- .../library/AlbumDownloadButton.svelte | 46 +- .../library/AlphabetScrollBar.svelte | 6 +- .../library/ArtistDetailView.svelte | 42 +- src/lib/components/library/ArtistLinks.svelte | 4 +- src/lib/components/library/CastSection.svelte | 4 +- .../library/ClearHistoryButton.svelte | 12 +- src/lib/components/library/CrewLinks.svelte | 19 +- .../components/library/DownloadButton.svelte | 20 +- .../library/DownloadButtonCore.svelte | 40 +- .../library/EpisodeFocusView.svelte | 218 ++-- .../library/EpisodeFocusView.test.ts | 5 +- src/lib/components/library/EpisodeRow.svelte | 51 +- .../library/GenericGenreBrowser.svelte | 38 +- .../GenericMediaListPage.searchEvent.test.ts | 8 +- .../library/GenericMediaListPage.svelte | 35 +- .../library/GenericMediaListPage.test.ts | 65 +- src/lib/components/library/GenreFilter.svelte | 4 +- src/lib/components/library/GenreTags.svelte | 25 +- src/lib/components/library/LibraryGrid.svelte | 32 +- .../components/library/LibraryListView.svelte | 74 +- .../components/library/LibraryViewTabs.svelte | 4 +- src/lib/components/library/MediaCard.svelte | 174 ++- src/lib/components/library/MosaicGrid.svelte | 11 +- src/lib/components/library/MosaicTile.svelte | 8 +- .../library/PersonDetailView.svelte | 4 +- .../library/PlaylistDetailView.svelte | 47 +- .../library/RelatedItemsSection.svelte | 31 +- .../library/SeasonDownloadButton.svelte | 77 +- .../components/library/SeasonSection.svelte | 10 +- .../library/SeriesDownloadButton.svelte | 44 +- .../library/TrackList.logic.test.ts | 6 +- src/lib/components/library/TrackList.svelte | 87 +- src/lib/components/library/TrackList.test.ts | 92 +- .../library/VideoDownloadButton.svelte | 78 +- .../library/WatchedToggleButton.svelte | 21 +- .../components/library/episodeStrip.test.ts | 28 +- src/lib/components/library/episodeStrip.ts | 9 +- .../components/library/libraryMosaic.test.ts | 6 +- src/lib/components/library/libraryMosaic.ts | 11 +- .../library/seriesNavigation.test.ts | 15 +- .../components/library/seriesNavigation.ts | 7 +- src/lib/components/player/AudioPlayer.svelte | 304 ++--- src/lib/components/player/Controls.svelte | 16 +- src/lib/components/player/MiniPlayer.svelte | 86 +- .../components/player/NextEpisodePopup.svelte | 23 +- src/lib/components/player/Queue.svelte | 52 +- .../components/player/SleepTimerModal.svelte | 49 +- .../player/VideoPlayer.logic.test.ts | 52 +- .../player/VideoPlayer.nativeReveal.test.ts | 30 +- .../VideoPlayer.scrubRegression.test.ts | 14 +- src/lib/components/player/VideoPlayer.svelte | 478 +++++--- .../player/VideoPlayer.tapSurface.test.ts | 2 +- .../player/VideoPlayer.touchScrub.test.ts | 14 +- .../components/player/VolumeControl.svelte | 2 +- .../player/backgroundAudioHandoff.ts | 4 +- src/lib/components/player/hlsRecovery.ts | 5 +- src/lib/components/player/mediaReady.test.ts | 18 +- src/lib/components/player/mediaReady.ts | 3 +- .../components/player/playerSurface.test.ts | 14 +- .../components/player/subtitleTracks.test.ts | 23 +- src/lib/components/player/subtitleTracks.ts | 4 +- src/lib/components/player/tapGestures.test.ts | 8 +- src/lib/components/player/tapGestures.ts | 8 +- src/lib/components/player/videoFit.ts | 5 +- .../playlist/AddToPlaylistModal.svelte | 21 +- .../playlist/CreatePlaylistModal.svelte | 9 +- src/lib/components/search/HeaderSearch.svelte | 9 +- .../components/search/HeaderSearch.test.ts | 2 +- .../components/search/SearchResults.svelte | 4 +- .../components/search/SearchScopeChips.svelte | 9 +- src/lib/components/sessions/CastButton.svelte | 16 +- .../components/sessions/RemoteControls.svelte | 21 +- .../components/sessions/SessionCard.svelte | 16 +- .../sessions/SessionPickerModal.svelte | 194 ++- .../components/sessions/SessionsList.svelte | 14 +- .../settings/SearchGroupOrderList.svelte | 29 +- .../components/sync/PendingSyncList.svelte | 6 +- .../components/sync/PendingSyncModal.svelte | 15 +- .../player/adapters/adapterSelection.test.ts | 4 +- src/lib/player/adapters/html5Adapter.test.ts | 8 +- src/lib/player/adapters/html5Adapter.ts | 6 +- src/lib/player/adapters/nativeAdapter.test.ts | 54 +- src/lib/player/adapters/nativeAdapter.ts | 2 +- src/lib/player/adapters/rustReportHost.ts | 8 +- src/lib/player/index.ts | 17 +- src/lib/player/localSource.test.ts | 2 +- src/lib/services/favorites.test.ts | 16 +- src/lib/services/favorites.ts | 5 +- src/lib/services/imageCache.test.ts | 40 +- src/lib/services/imageCache.ts | 5 +- src/lib/services/networkType.test.ts | 224 ++-- src/lib/services/networkType.ts | 102 +- .../services/offlineCatalog.reload.test.ts | 4 +- src/lib/services/offlineCatalog.ts | 8 +- src/lib/services/pendingSync.logic.test.ts | 4 +- src/lib/services/playbackReporting.test.ts | 37 +- src/lib/services/playbackReporting.ts | 19 +- .../services/playerEvents.regression.test.ts | 4 +- src/lib/services/playerEvents.test.ts | 2 +- src/lib/services/playerEvents.ts | 4 +- src/lib/services/preload.test.ts | 28 +- src/lib/services/preload.ts | 76 +- src/lib/services/skipReporting.test.ts | 4 +- src/lib/services/syncService.ts | 15 +- src/lib/services/webviewAudio.ts | 2 +- src/lib/stores/__mocks__/tauri.ts | 14 +- src/lib/stores/appState.ts | 2 +- src/lib/stores/auth.test.ts | 20 +- src/lib/stores/auth.ts | 59 +- src/lib/stores/connectivity.ts | 11 +- src/lib/stores/continueWatchingFilter.test.ts | 18 +- src/lib/stores/continueWatchingFilter.ts | 10 +- src/lib/stores/downloads.test.ts | 150 ++- src/lib/stores/downloads.ts | 1064 +++++++++-------- src/lib/stores/favorites.ts | 4 +- src/lib/stores/home.ts | 39 +- src/lib/stores/index.ts | 7 +- src/lib/stores/library.ts | 11 +- src/lib/stores/librarySearchScope.test.ts | 4 +- src/lib/stores/lmsSync.test.ts | 20 +- src/lib/stores/movies.ts | 18 +- src/lib/stores/music.ts | 80 +- src/lib/stores/nextEpisode.ts | 7 +- src/lib/stores/playbackMode.test.ts | 1 - src/lib/stores/playbackMode.ts | 31 +- src/lib/stores/player.ts | 36 +- src/lib/stores/queue.ts | 2 +- src/lib/stores/searchGroupOrder.test.ts | 12 +- src/lib/stores/sessions.test.ts | 2 +- src/lib/stores/sessions.ts | 49 +- src/lib/stores/sleepTimer.ts | 12 +- src/lib/stores/tv.ts | 27 +- src/lib/utils/debounce.test.ts | 2 +- src/lib/utils/duration.ts | 5 +- src/lib/utils/favoritesView.ts | 8 +- src/lib/utils/genreDiversity.test.ts | 21 +- src/lib/utils/genreDiversity.ts | 8 +- src/lib/utils/heroMix.test.ts | 8 +- src/lib/utils/heroMix.ts | 10 +- src/lib/utils/layoutShell.ts | 22 +- src/lib/utils/menuPosition.ts | 12 +- src/lib/utils/nativeVideoLayers.test.ts | 3 +- src/lib/utils/navigation.test.ts | 3 +- src/lib/utils/pictureInPicture.ts | 2 +- src/lib/utils/safeArea.test.ts | 4 +- src/lib/utils/scrollContainer.ts | 2 +- src/lib/utils/scrollRestore.ts | 5 +- src/lib/utils/searchScope.test.ts | 28 +- src/lib/utils/searchScope.ts | 22 +- src/lib/utils/validation.ts | 7 +- src/lib/utils/videoSurface.ts | 2 +- src/routes/+layout.svelte | 90 +- src/routes/+page.svelte | 251 ++-- src/routes/downloads/+page.svelte | 44 +- src/routes/library/+layout.svelte | 9 +- src/routes/library/+page.svelte | 90 +- src/routes/library/[id]/+page.svelte | 622 +++++----- src/routes/library/favorites/+page.svelte | 4 +- src/routes/library/movies/+page.svelte | 15 +- src/routes/library/music/+page.svelte | 38 +- .../library/music/playlists/+page.svelte | 9 +- src/routes/library/tv/+page.svelte | 15 +- src/routes/login/+page.svelte | 43 +- src/routes/player/[id]/+page.svelte | 352 ++++-- src/routes/search/+page.svelte | 4 +- src/routes/sessions/+page.svelte | 19 +- src/routes/settings/+page.svelte | 124 +- vitest.config.ts | 8 +- 199 files changed, 4698 insertions(+), 3453 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 191e0647..1c6030bf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -version: '3.8' +version: "3.8" services: # Test service - runs tests only @@ -31,7 +31,7 @@ services: depends_on: - test ports: - - "5172:5172" # In case you want to run dev server + - "5172:5172" # In case you want to run dev server # Linux desktop packages - deb + rpm + pacman into ./dist desktop-linux-build: diff --git a/scripts/extract-traces.test.ts b/scripts/extract-traces.test.ts index d88fcced..caef48f7 100644 --- a/scripts/extract-traces.test.ts +++ b/scripts/extract-traces.test.ts @@ -140,9 +140,10 @@ describe("findDanglingIds", () => { }); it("deduplicates and sorts, so one typo is reported once", () => { - expect( - findDanglingIds(["DR-189", "DR-189", "UR-999", "DR-189"], defined) - ).toEqual(["DR-189", "UR-999"]); + expect(findDanglingIds(["DR-189", "DR-189", "UR-999", "DR-189"], defined)).toEqual([ + "DR-189", + "UR-999", + ]); }); it("ignores IDs whose prefix is not a known trace type", () => { @@ -158,7 +159,7 @@ describe("coverage threshold", () => { // passes, which is how the 50%-while-actually-86% slack went unnoticed. const workflow = fs.readFileSync( path.resolve(HERE, "../.gitea/workflows/traceability-check.yml"), - "utf-8" + "utf-8", ); const match = workflow.match(/^\s*MIN_THRESHOLD=(\d+)\s*$/m); expect(match).not.toBeNull(); @@ -307,9 +308,7 @@ describe("generated matrix file links", () => { it("keeps the #Lnn line anchor on the href", () => { const link = formatMatrixFileLink("scripts/extract-traces.ts", 427); - expect(link).toBe( - "[`scripts/extract-traces.ts`](../scripts/extract-traces.ts#L427)" - ); + expect(link).toBe("[`scripts/extract-traces.ts`](../scripts/extract-traces.ts#L427)"); }); it("does not produce a bare repo-root href, which resolves to docs/", () => { @@ -334,10 +333,7 @@ describe("live requirements.md", () => { // row. Worse, the pins never guarded the actual defect — a stale denominator // is caught by the sum-consistency check below, and the >100% ratio it // produced is covered directly by the computeCoverage tests, on fixtures. - const md = fs.readFileSync( - path.resolve(HERE, "../docs/requirements.md"), - "utf-8" - ); + const md = fs.readFileSync(path.resolve(HERE, "../docs/requirements.md"), "utf-8"); const defined = countDefinedRequirements(md); // The parser found real rows of every type: a section silently failing to diff --git a/scripts/extract-traces.ts b/scripts/extract-traces.ts index 612bebc1..69cf4249 100644 --- a/scripts/extract-traces.ts +++ b/scripts/extract-traces.ts @@ -64,8 +64,7 @@ export const MIN_COVERAGE_PERCENT = 88; // `import.meta.dir` is a Bun extension and is undefined when this module is // imported by vitest (which runs it as an ordinary ESM module), so fall back to // import.meta.url — this file must stay importable for extract-traces.test.ts. -const SCRIPT_DIR = - import.meta.dir ?? path.dirname(new URL(import.meta.url).pathname); +const SCRIPT_DIR = import.meta.dir ?? path.dirname(new URL(import.meta.url).pathname); const BASE_DIR = path.resolve(SCRIPT_DIR, ".."); const TRACES_PATTERN = /TRACES:\s*([^\n]+)/gi; @@ -283,8 +282,7 @@ export function countDefinedRequirements(markdown: string): DefinedRequirements else ids.add(id); } - const countOf = (type: string) => - [...ids].filter((id) => id.startsWith(`${type}-`)).length; + const countOf = (type: string) => [...ids].filter((id) => id.startsWith(`${type}-`)).length; return { UR: countOf("UR"), @@ -311,16 +309,13 @@ export function countDefinedRequirements(markdown: string): DefinedRequirements * * TRACES: | DR-093 */ -export function findDanglingIds( - tracedIds: string[], - defined: DefinedRequirements -): string[] { +export function findDanglingIds(tracedIds: string[], defined: DefinedRequirements): string[] { const KNOWN_TYPE = /^(UR|IR|DR|JA|UT|IT)-\d{3}$/; const dangling = new Set( tracedIds .filter((id) => KNOWN_TYPE.test(id)) - .filter((id) => !defined.ids.has(id) && !defined.testIds.has(id)) + .filter((id) => !defined.ids.has(id) && !defined.testIds.has(id)), ); return [...dangling].sort(); @@ -336,10 +331,7 @@ export function findDanglingIds( * * TRACES: | DR-093 */ -export function computeCoverage( - tracedIds: string[], - defined: DefinedRequirements -): CoverageResult { +export function computeCoverage(tracedIds: string[], defined: DefinedRequirements): CoverageResult { // Only the four *requirement* types participate in coverage. UT/IT are test // identifiers defined in §4 of requirements.md — a different taxonomy, and // flagging them as orphans would bury real typos in ~60 lines of noise. @@ -352,10 +344,7 @@ export function computeCoverage( return { covered: covered.length, total: defined.total, - percent: - defined.total === 0 - ? 0 - : Math.round((covered.length / defined.total) * 100), + percent: defined.total === 0 ? 0 : Math.round((covered.length / defined.total) * 100), orphaned, }; } @@ -488,16 +477,14 @@ function reportCoverage(data: TracesData, minThreshold: number): number { if (cov.orphaned.length > 0) { console.log(""); - console.log( - `⚠️ Traced but not defined in requirements.md: ${cov.orphaned.join(", ")}` - ); + console.log(`⚠️ Traced but not defined in requirements.md: ${cov.orphaned.join(", ")}`); console.log(" Fix the TRACES comment or add the requirement."); } if (data.dangling && data.dangling.length > 0) { console.log(""); console.log( - `⚠️ Dangling IDs (incl. UT/IT): ${data.dangling.join(", ")} — run \`bun run traces:validate\`.` + `⚠️ Dangling IDs (incl. UT/IT): ${data.dangling.join(", ")} — run \`bun run traces:validate\`.`, ); } @@ -538,9 +525,7 @@ function reportDangling(data: TracesData): number { console.log("❌ TRACES reference IDs that docs/requirements.md does not define:"); console.log(""); for (const id of dangling) { - const files = [ - ...new Set((data.requirements[id] ?? []).map((e) => e.file)), - ].sort(); + const files = [...new Set((data.requirements[id] ?? []).map((e) => e.file))].sort(); console.log(` ${id}`); for (const file of files) console.log(` ${file}`); } @@ -554,9 +539,7 @@ function reportDangling(data: TracesData): number { // Main — guarded so this module stays importable from extract-traces.test.ts. if (import.meta.main) { const args = process.argv.slice(2); - const format = args.includes("--format") - ? args[args.indexOf("--format") + 1] - : "markdown"; + const format = args.includes("--format") ? args[args.indexOf("--format") + 1] : "markdown"; console.error("🔍 Extracting TRACES from codebase..."); const data = extractTraces(); @@ -583,7 +566,5 @@ if (import.meta.main) { console.log(generateMarkdown(data)); } - console.error( - `\n✅ Complete! Found ${data.totalTraces} TRACES across ${data.totalFiles} files` - ); + console.error(`\n✅ Complete! Found ${data.totalTraces} TRACES across ${data.totalFiles} files`); } diff --git a/scripts/release-notes.ts b/scripts/release-notes.ts index 5bb8d58e..79988167 100644 --- a/scripts/release-notes.ts +++ b/scripts/release-notes.ts @@ -55,9 +55,7 @@ function loadRequirementDescriptions(): Map { } function changedFiles(range: string): string[] { - const cmd = range - ? `git diff --name-only ${range}` - : "git ls-files"; // untagged repo: describe everything currently traced + const cmd = range ? `git diff --name-only ${range}` : "git ls-files"; // untagged repo: describe everything currently traced return sh(cmd) .split("\n") .filter((f) => f && existsSync(f)); diff --git a/scripts/set-version.test.ts b/scripts/set-version.test.ts index 22ce6d7b..1c6f5b29 100644 --- a/scripts/set-version.test.ts +++ b/scripts/set-version.test.ts @@ -34,30 +34,47 @@ function seed(dir: string) { fs.writeFileSync( path.join(dir, "package.json"), - JSON.stringify({ name: "jellytau", version: "0.0.1", dependencies: { hls: "1.2.3" } }, null, 2) + JSON.stringify({ name: "jellytau", version: "0.0.1", dependencies: { hls: "1.2.3" } }, null, 2), ); fs.writeFileSync( path.join(dir, "src-tauri", "tauri.conf.json"), - JSON.stringify({ productName: "jellytau", version: "0.0.1" }, null, 2) + JSON.stringify({ productName: "jellytau", version: "0.0.1" }, null, 2), ); // A dependency carrying its own `version =` is the trap: a greedy regex // rewrites it too and the build then resolves the wrong crate. fs.writeFileSync( path.join(dir, "src-tauri", "Cargo.toml"), - ['[package]', 'name = "jellytau"', 'version = "0.0.1"', '', '[dependencies]', 'serde = { version = "1.0.100" }', ''].join("\n") + [ + "[package]", + 'name = "jellytau"', + 'version = "0.0.1"', + "", + "[dependencies]", + 'serde = { version = "1.0.100" }', + "", + ].join("\n"), ); fs.writeFileSync( path.join(dir, "src-tauri", "Cargo.lock"), - ['[[package]]', 'name = "serde"', 'version = "1.0.100"', '', '[[package]]', 'name = "jellytau"', 'version = "0.0.1"', ''].join("\n") + [ + "[[package]]", + 'name = "serde"', + 'version = "1.0.100"', + "", + "[[package]]", + 'name = "jellytau"', + 'version = "0.0.1"', + "", + ].join("\n"), ); fs.writeFileSync( path.join(dir, "src-tauri", "gen", "android", "app", "tauri.properties"), - "tauri.android.versionCode=1\n" + "tauri.android.versionCode=1\n", ); fs.mkdirSync(path.join(dir, "packaging", "arch"), { recursive: true }); fs.writeFileSync( path.join(dir, "packaging", "arch", "PKGBUILD"), - ['pkgname=jellytau', 'pkgver=0.0.1', 'pkgrel=3', 'pkgdesc="x"', ''].join("\n") + ["pkgname=jellytau", "pkgver=0.0.1", "pkgrel=3", 'pkgdesc="x"', ""].join("\n"), ); } @@ -74,7 +91,7 @@ function read(rel: string): string { function versionCode(): number { const m = read("src-tauri/gen/android/app/tauri.properties").match( - /^tauri\.android\.versionCode=(\d+)$/m + /^tauri\.android\.versionCode=(\d+)$/m, ); return m ? Number(m[1]) : NaN; } diff --git a/scripts/tauri-security-config.test.ts b/scripts/tauri-security-config.test.ts index 5f92c785..13d90e45 100644 --- a/scripts/tauri-security-config.test.ts +++ b/scripts/tauri-security-config.test.ts @@ -16,7 +16,7 @@ import { readFileSync } from "fs"; import { resolve } from "path"; const config = JSON.parse( - readFileSync(resolve(__dirname, "../src-tauri/tauri.conf.json"), "utf-8") + readFileSync(resolve(__dirname, "../src-tauri/tauri.conf.json"), "utf-8"), ); const security = config.app.security; diff --git a/src/app.css b/src/app.css index 824a1ae8..49094cd1 100644 --- a/src/app.css +++ b/src/app.css @@ -46,7 +46,8 @@ } /* Global styles */ -html, body { +html, +body { @apply h-full; background-color: var(--color-background); } @@ -77,5 +78,8 @@ html[data-native-video="active"] [data-app-shell] { body { @apply text-white antialiased; - font-family: system-ui, -apple-system, sans-serif; + font-family: + system-ui, + -apple-system, + sans-serif; } diff --git a/src/app.html b/src/app.html index 4fc18c67..af62232e 100644 --- a/src/app.html +++ b/src/app.html @@ -9,10 +9,7 @@ and the bottom nav renders under the Android navigation bar. See $lib/utils/safeArea.ts for the other half (native WindowInsets → CSS vars). --> - + JellyTau %sveltekit.head% diff --git a/src/lib/api/autoplay.test.ts b/src/lib/api/autoplay.test.ts index 3956b0ef..ef32db1d 100644 --- a/src/lib/api/autoplay.test.ts +++ b/src/lib/api/autoplay.test.ts @@ -112,9 +112,7 @@ describe("autoplay API", () => { await setAutoplaySettings(settings); - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "player_set_autoplay_settings" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "player_set_autoplay_settings"); expect(call).toBeDefined(); expect(call![1]).toEqual({ userId: "user-1", settings }); }); @@ -155,9 +153,7 @@ describe("autoplay API", () => { const { invoke } = await import("@tauri-apps/api/core"); const invokeSpy = vi.mocked(invoke); - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "player_play_next_episode" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "player_play_next_episode"); expect(call).toBeDefined(); expect(call![1]).toEqual({ item: mockItem }); }); diff --git a/src/lib/api/autoplay.ts b/src/lib/api/autoplay.ts index f84ee43d..0aa8f255 100644 --- a/src/lib/api/autoplay.ts +++ b/src/lib/api/autoplay.ts @@ -14,9 +14,7 @@ export async function getAutoplaySettings(): Promise { return commands.playerGetAutoplaySettings(); } -export async function setAutoplaySettings( - settings: AutoplaySettings -): Promise { +export async function setAutoplaySettings(settings: AutoplaySettings): Promise { return commands.playerSetAutoplaySettings(auth.getUserId() ?? "", settings); } diff --git a/src/lib/api/backend-integration.test.ts b/src/lib/api/backend-integration.test.ts index 830430e9..368644a7 100644 --- a/src/lib/api/backend-integration.test.ts +++ b/src/lib/api/backend-integration.test.ts @@ -77,7 +77,7 @@ describe("Backend Integration - Refactored Business Logic", () => { options: expect.objectContaining({ sortBy: sortField, }), - }) + }), ); } }); @@ -99,7 +99,7 @@ describe("Backend Integration - Refactored Business Logic", () => { options: expect.objectContaining({ sortOrder: "Descending", }), - }) + }), ); }); @@ -148,7 +148,7 @@ describe("Backend Integration - Refactored Business Logic", () => { options: expect.objectContaining({ includeItemTypes: ["Audio", "MusicAlbum"], }), - }) + }), ); }); @@ -168,7 +168,7 @@ describe("Backend Integration - Refactored Business Logic", () => { options: expect.objectContaining({ genres: ["Rock", "Jazz"], }), - }) + }), ); }); @@ -195,7 +195,7 @@ describe("Backend Integration - Refactored Business Logic", () => { "repository_search", expect.objectContaining({ query: "query", - }) + }), ); }); @@ -217,7 +217,7 @@ describe("Backend Integration - Refactored Business Logic", () => { startIndex: 100, limit: 50, }), - }) + }), ); }); }); @@ -238,7 +238,7 @@ describe("Backend Integration - Refactored Business Logic", () => { "repository_search", expect.objectContaining({ query: "query", - }) + }), ); expect(result.items.length).toBe(2); @@ -260,7 +260,7 @@ describe("Backend Integration - Refactored Business Logic", () => { options: expect.objectContaining({ includeItemTypes: ["Audio"], }), - }) + }), ); }); @@ -302,7 +302,7 @@ describe("Backend Integration - Refactored Business Logic", () => { expect.objectContaining({ itemId: "item123", imageType: "Primary", - }) + }), ); }); @@ -327,23 +327,18 @@ describe("Backend Integration - Refactored Business Logic", () => { const url = await client.getVideoStreamUrl("item123"); expect(url).toBe(backendUrl); - expect(invoke).toHaveBeenCalledWith( - "repository_get_video_stream_url", - expect.any(Object) - ); + expect(invoke).toHaveBeenCalledWith("repository_get_video_stream_url", expect.any(Object)); }); it("should get subtitle URLs from backend", async () => { - const backendUrl = "https://server.com/Videos/item123/Subtitles/0/subtitles.vtt?api_key=token"; + const backendUrl = + "https://server.com/Videos/item123/Subtitles/0/subtitles.vtt?api_key=token"; (invoke as any).mockResolvedValueOnce(backendUrl); const url = await client.getSubtitleUrl("item123", "source456", 0); expect(url).toBe(backendUrl); - expect(invoke).toHaveBeenCalledWith( - "repository_get_subtitle_url", - expect.any(Object) - ); + expect(invoke).toHaveBeenCalledWith("repository_get_subtitle_url", expect.any(Object)); }); it("should get video download URLs from backend", async () => { @@ -353,10 +348,7 @@ describe("Backend Integration - Refactored Business Logic", () => { const url = await client.getVideoDownloadUrl("item123", "medium"); expect(url).toBe(backendUrl); - expect(invoke).toHaveBeenCalledWith( - "repository_get_video_download_url", - expect.any(Object) - ); + expect(invoke).toHaveBeenCalledWith("repository_get_video_download_url", expect.any(Object)); }); it("should never expose access token in frontend code", async () => { diff --git a/src/lib/api/repository-client.test.ts b/src/lib/api/repository-client.test.ts index 0cb6826d..3ef2b6ee 100644 --- a/src/lib/api/repository-client.test.ts +++ b/src/lib/api/repository-client.test.ts @@ -96,7 +96,8 @@ describe("RepositoryClient", () => { }); it("should pass multiple image options to backend", async () => { - const mockUrl = "https://server.com/Items/item123/Images/Backdrop?maxWidth=1920&maxHeight=1080&quality=90&api_key=token"; + const mockUrl = + "https://server.com/Items/item123/Images/Backdrop?maxWidth=1920&maxHeight=1080&quality=90&api_key=token"; (invoke as any).mockResolvedValueOnce(mockUrl); const options = { @@ -133,9 +134,7 @@ describe("RepositoryClient", () => { it("should throw error if not initialized before getImageUrl", async () => { const newClient = new RepositoryClient(); - await expect(newClient.getImageUrl("item123")).rejects.toThrow( - "Repository not initialized" - ); + await expect(newClient.getImageUrl("item123")).rejects.toThrow("Repository not initialized"); }); }); @@ -243,7 +242,7 @@ describe("RepositoryClient", () => { "repository_get_video_download_url", expect.objectContaining({ quality, - }) + }), ); } }); @@ -314,9 +313,7 @@ describe("RepositoryClient", () => { it("should search with backend search command", async () => { const mockResult = { - items: [ - { id: "item1", name: "Search Result 1", type: "Audio" }, - ], + items: [{ id: "item1", name: "Search Result 1", type: "Audio" }], totalRecordCount: 1, }; (invoke as any).mockResolvedValueOnce(mockResult); @@ -353,7 +350,10 @@ describe("RepositoryClient", () => { }); it("should get downloaded items with camelCase params", async () => { - const mockResult = { items: [{ id: "t1", name: "Track", type: "Audio" }], totalRecordCount: 1 }; + const mockResult = { + items: [{ id: "t1", name: "Track", type: "Audio" }], + totalRecordCount: 1, + }; (invoke as any).mockResolvedValueOnce(mockResult); const result = await client.getDownloadedItems("album1", { limit: 50 }); @@ -367,7 +367,12 @@ describe("RepositoryClient", () => { }); it("should get download disk usage from backend", async () => { - const mockUsage = { sizes: { t1: 1000 }, partialContainers: {}, deviceTotalBytes: 1000, itemCount: 1 }; + const mockUsage = { + sizes: { t1: 1000 }, + partialContainers: {}, + deviceTotalBytes: 1000, + itemCount: 1, + }; (invoke as any).mockResolvedValueOnce(mockUsage); const usage = await client.getDownloadDiskUsage(); @@ -589,7 +594,9 @@ describe("RepositoryClient", () => { it("should throw error if not initialized before playlist operations", async () => { const newClient = new RepositoryClient(); - await expect(newClient.getPlaylistItems("pl-1")).rejects.toThrow("Repository not initialized"); + await expect(newClient.getPlaylistItems("pl-1")).rejects.toThrow( + "Repository not initialized", + ); await expect(newClient.createPlaylist("test")).rejects.toThrow("Repository not initialized"); await expect(newClient.deletePlaylist("pl-1")).rejects.toThrow("Repository not initialized"); }); @@ -599,9 +606,9 @@ describe("RepositoryClient", () => { it("should throw error if invoke fails", async () => { (invoke as any).mockRejectedValueOnce(new Error("Network error")); - await expect(client.create("https://server.com", "user1", "token", "server1")).rejects.toThrow( - "Network error" - ); + await expect( + client.create("https://server.com", "user1", "token", "server1"), + ).rejects.toThrow("Network error"); }); it("should handle missing optional parameters", async () => { @@ -616,7 +623,7 @@ describe("RepositoryClient", () => { "repository_get_image_url", expect.objectContaining({ options: null, - }) + }), ); }); }); diff --git a/src/lib/api/repository-client.ts b/src/lib/api/repository-client.ts index 71d153a8..b762c595 100644 --- a/src/lib/api/repository-client.ts +++ b/src/lib/api/repository-client.ts @@ -40,7 +40,7 @@ export class RepositoryClient { serverUrl: string, userId: string, accessToken: string, - serverId: string + serverId: string, ): Promise { log.debug("Creating Rust repository..."); this.handle = await commands.repositoryCreate(serverUrl, userId, accessToken, serverId); @@ -137,7 +137,11 @@ export class RepositoryClient { } async getNextUpEpisodes(seriesId?: string, limit?: number): Promise { - return commands.repositoryGetNextUpEpisodes(this.ensureHandle(), seriesId ?? null, limit ?? null); + return commands.repositoryGetNextUpEpisodes( + this.ensureHandle(), + seriesId ?? null, + limit ?? null, + ); } /** @@ -181,7 +185,11 @@ export class RepositoryClient { /** Albums the user has played but not listened to recently ("rediscover"). */ async getRediscoverAlbums(parentId?: string, limit?: number): Promise { - return commands.repositoryGetRediscoverAlbums(this.ensureHandle(), parentId ?? null, limit ?? null); + return commands.repositoryGetRediscoverAlbums( + this.ensureHandle(), + parentId ?? null, + limit ?? null, + ); } async getGenres(parentId?: string): Promise { @@ -229,13 +237,13 @@ export class RepositoryClient { async getVideoStreamUrl( itemId: string, mediaSourceId?: string, - audioStreamIndex?: number + audioStreamIndex?: number, ): Promise { return commands.repositoryGetVideoStreamUrl( this.ensureHandle(), itemId, mediaSourceId ?? null, - audioStreamIndex ?? null + audioStreamIndex ?? null, ); } @@ -248,14 +256,14 @@ export class RepositoryClient { itemId: string, mediaSourceId?: string, startTimeSeconds?: number, - audioStreamIndex?: number + audioStreamIndex?: number, ): Promise { return commands.repositoryGetAudioOnlyStreamUrlForVideo( this.ensureHandle(), itemId, mediaSourceId ?? null, startTimeSeconds ?? null, - audioStreamIndex ?? null + audioStreamIndex ?? null, ); } @@ -282,7 +290,11 @@ export class RepositoryClient { * Get image URL from backend * The Rust backend constructs and returns the URL with proper credentials handling */ - async getImageUrl(itemId: string, imageType: ImageType = "Primary", options?: ImageOptions): Promise { + async getImageUrl( + itemId: string, + imageType: ImageType = "Primary", + options?: ImageOptions, + ): Promise { return commands.repositoryGetImageUrl(this.ensureHandle(), itemId, imageType, options ?? null); } @@ -294,9 +306,15 @@ export class RepositoryClient { itemId: string, mediaSourceId: string, streamIndex: number, - format: string = "vtt" + format: string = "vtt", ): Promise { - return commands.repositoryGetSubtitleUrl(this.ensureHandle(), itemId, mediaSourceId, streamIndex, format); + return commands.repositoryGetSubtitleUrl( + this.ensureHandle(), + itemId, + mediaSourceId, + streamIndex, + format, + ); } /** @@ -307,9 +325,14 @@ export class RepositoryClient { async getVideoDownloadUrl( itemId: string, quality: QualityPreset = "original", - mediaSourceId?: string + mediaSourceId?: string, ): Promise { - return commands.repositoryGetVideoDownloadUrl(this.ensureHandle(), itemId, quality, mediaSourceId ?? null); + return commands.repositoryGetVideoDownloadUrl( + this.ensureHandle(), + itemId, + quality, + mediaSourceId ?? null, + ); } // ===== Favorite Methods (via Rust) ===== diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index 98ad9789..d4405a48 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -81,11 +81,4 @@ export type PersonType = | "Lyricist"; export type SessionCommand = - | "PlayPause" - | "Stop" - | "Pause" - | "Unpause" - | "NextTrack" - | "PreviousTrack" - | "Mute" - | "Unmute"; + "PlayPause" | "Stop" | "Pause" | "Unpause" | "NextTrack" | "PreviousTrack" | "Mute" | "Unmute"; diff --git a/src/lib/components/AppHeader.svelte b/src/lib/components/AppHeader.svelte index ad6fa1da..96f837e5 100644 --- a/src/lib/components/AppHeader.svelte +++ b/src/lib/components/AppHeader.svelte @@ -19,36 +19,49 @@ const withSearch = $derived(showHeaderSearch({ pathname })); -
+
- - JellyTau - + JellyTau
diff --git a/src/lib/components/account/AccountMenu.svelte b/src/lib/components/account/AccountMenu.svelte index c1fa616f..8311c1d8 100644 --- a/src/lib/components/account/AccountMenu.svelte +++ b/src/lib/components/account/AccountMenu.svelte @@ -82,7 +82,9 @@
close()} - onkeydown={(e) => { if (e.key === "Enter" || e.key === " ") close(); }} + onkeydown={(e) => { + if (e.key === "Enter" || e.key === " ") close(); + }} role="button" tabindex="-1" aria-label="Close account menu" @@ -110,7 +112,12 @@ onclick={() => close(false)} > - + Downloads @@ -121,8 +128,18 @@ onclick={() => close(false)} > - - + + Settings @@ -133,7 +150,12 @@ onclick={() => close(false)} > - + Display @@ -146,7 +168,12 @@ class="w-full flex items-center gap-3 px-4 py-3 text-sm text-gray-300 hover:bg-gray-700 transition-colors" > - + Sign out diff --git a/src/lib/components/auth/ReauthModal.svelte b/src/lib/components/auth/ReauthModal.svelte index 46313918..4c2ca36f 100644 --- a/src/lib/components/auth/ReauthModal.svelte +++ b/src/lib/components/auth/ReauthModal.svelte @@ -15,7 +15,7 @@ let serverName = $state("Jellyfin Server"); // Load session info asynchronously - auth.getCurrentSession().then(session => { + auth.getCurrentSession().then((session) => { if (session) { username = session.username ?? "User"; serverName = session.serverName ?? "Jellyfin Server"; @@ -56,7 +56,9 @@
{ if (e.key === 'Escape') handleBackdropClick(); }} + onkeydown={(e) => { + if (e.key === "Escape") handleBackdropClick(); + }} role="dialog" aria-modal="true" aria-labelledby="reauth-title" @@ -70,13 +72,10 @@
-
- +
+
-

- Session Expired -

+

Session Expired

- Your session on {serverName} has expired. - Please enter your password to continue. + Your session on {serverName} has expired. Please + enter your password to continue.

@@ -102,7 +99,10 @@
Username
-
+
{username}
@@ -140,8 +140,19 @@ > {#if $isAuthLoading} - - + + Authenticating... {:else} diff --git a/src/lib/components/common/BackButton.svelte b/src/lib/components/common/BackButton.svelte index c95b219c..b606b8d4 100644 --- a/src/lib/components/common/BackButton.svelte +++ b/src/lib/components/common/BackButton.svelte @@ -27,7 +27,11 @@ aria-label={label} class={`text-gray-400 hover:text-white transition-colors ${className}`} > - + diff --git a/src/lib/components/common/CachedImage.svelte b/src/lib/components/common/CachedImage.svelte index 93832afc..c8ee8c86 100644 --- a/src/lib/components/common/CachedImage.svelte +++ b/src/lib/components/common/CachedImage.svelte @@ -86,11 +86,17 @@ {#if loading} -
+
{:else if error || !imageUrl}
- +
{:else} diff --git a/src/lib/components/common/ResultsCounter.svelte b/src/lib/components/common/ResultsCounter.svelte index 93bf1c1e..3aec3955 100644 --- a/src/lib/components/common/ResultsCounter.svelte +++ b/src/lib/components/common/ResultsCounter.svelte @@ -25,7 +25,9 @@ playlist: { singular: "playlist", plural: "playlists" }, }; - const labels = $derived(itemTypeLabels[itemType] || { singular: itemType, plural: `${itemType}s` }); + const labels = $derived( + itemTypeLabels[itemType] || { singular: itemType, plural: `${itemType}s` }, + ); const label = $derived(count === 1 ? labels.singular : labels.plural); diff --git a/src/lib/components/common/ScrollPicker.svelte b/src/lib/components/common/ScrollPicker.svelte index 68e4e403..a8699231 100644 --- a/src/lib/components/common/ScrollPicker.svelte +++ b/src/lib/components/common/ScrollPicker.svelte @@ -46,17 +46,17 @@ // Initialize scroll position $effect(() => { if (scrollContainer) { - const idx = Math.max(0, items.findIndex((i) => i.value === selectedValue)); + const idx = Math.max( + 0, + items.findIndex((i) => i.value === selectedValue), + ); scrollContainer.scrollTop = idx * itemHeight; selectedIndex = idx; } }); -
+
-
-
+
+
scrollToIndex(i)} class="w-full snap-center flex items-center justify-center transition-all duration-150 {i === selectedIndex - ? 'text-white text-2xl font-bold' - : 'text-gray-500 text-lg font-medium opacity-60'}" + ? 'text-white text-2xl font-bold' + : 'text-gray-500 text-lg font-medium opacity-60'}" style="height: {itemHeight}px" > {item.label} diff --git a/src/lib/components/common/SearchBar.test.ts b/src/lib/components/common/SearchBar.test.ts index 68fded9f..4bffcbd0 100644 --- a/src/lib/components/common/SearchBar.test.ts +++ b/src/lib/components/common/SearchBar.test.ts @@ -146,7 +146,7 @@ describe("SearchBar", () => { it("should handle special characters in value", () => { render(SearchBar, { props: { - value: '@$%^&*()', + value: "@$%^&*()", placeholder: "Search...", onInput: vi.fn(), }, diff --git a/src/lib/components/downloads/DownloadItem.svelte b/src/lib/components/downloads/DownloadItem.svelte index 06ec20eb..a6f99257 100644 --- a/src/lib/components/downloads/DownloadItem.svelte +++ b/src/lib/components/downloads/DownloadItem.svelte @@ -59,11 +59,11 @@ function getSourceBorderColor(): string { // Green for user downloads, blue for auto-cached - return download.downloadSource === 'user' ? 'border-green-500/50' : 'border-blue-500/50'; + return download.downloadSource === "user" ? "border-green-500/50" : "border-blue-500/50"; } function getSourceLabel(): string { - return download.downloadSource === 'user' ? 'Downloaded' : 'Auto-Cached'; + return download.downloadSource === "user" ? "Downloaded" : "Auto-Cached"; } async function handlePause() { @@ -107,7 +107,9 @@ } -
+
@@ -115,12 +117,32 @@
{#if download.mediaType === "video"} - - + + {:else} - - + + {/if}
@@ -135,10 +157,16 @@

{download.seriesName} {#if download.seasonNumber !== undefined && download.episodeNumber !== undefined} - • S{String(download.seasonNumber).padStart(2, '0')}E{String(download.episodeNumber).padStart(2, '0')} + + • S{String(download.seasonNumber).padStart(2, "0")}E{String( + download.episodeNumber, + ).padStart(2, "0")} {/if} {#if download.qualityPreset && download.qualityPreset !== "original"} - {download.qualityPreset} + {download.qualityPreset} {/if}

{:else if download.mediaType === "video"} @@ -146,20 +174,27 @@

Movie {#if download.qualityPreset && download.qualityPreset !== "original"} - {download.qualityPreset} + {download.qualityPreset} {/if}

{:else if download.artistName || download.albumName}

- {download.artistName}{download.artistName && download.albumName ? ' • ' : ''}{download.albumName} + {download.artistName}{download.artistName && download.albumName + ? " • " + : ""}{download.albumName}

{/if}
{getStatusText()} - {#if download.downloadSource === 'auto'} - Auto + {#if download.downloadSource === "auto"} + Auto {/if}
@@ -210,7 +245,13 @@ class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-red-400 transition-colors" title="Cancel download" > - + @@ -231,7 +272,13 @@ class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-red-400 transition-colors" title="Cancel download" > - + @@ -242,7 +289,13 @@ class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-red-400 transition-colors" title="Cancel download" > - + @@ -253,7 +306,13 @@ class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-red-400 transition-colors" title="Delete download" > - + - + - +
- - + +

{formatBytes($downloadedDeviceTotal)} @@ -131,10 +141,7 @@ {#if currentLibrary}

- / @@ -163,8 +170,18 @@ {:else if $downloadedLibraries.length === 0}
- - + +

Nothing downloaded yet

@@ -185,12 +202,26 @@ onclick={() => openLibrary(lib)} class="group/card flex flex-col text-left transition-transform duration-200 hover:scale-105" > -

- - +
+ +
-

+

{lib.name}

diff --git a/src/lib/components/home/Carousel.svelte b/src/lib/components/home/Carousel.svelte index f1e4710c..2cb41972 100644 --- a/src/lib/components/home/Carousel.svelte +++ b/src/lib/components/home/Carousel.svelte @@ -21,8 +21,7 @@ if (!scrollContainer) return; showLeftArrow = scrollContainer.scrollLeft > 0; showRightArrow = - scrollContainer.scrollLeft < - scrollContainer.scrollWidth - scrollContainer.clientWidth - 10; + scrollContainer.scrollLeft < scrollContainer.scrollWidth - scrollContainer.clientWidth - 10; } function scrollLeft() { @@ -39,10 +38,7 @@

{title}

{#if showAll} - {/if} @@ -74,7 +70,7 @@ aria-label="Scroll left" > - + {/if} @@ -86,7 +82,7 @@ aria-label="Scroll right" > - + {/if} diff --git a/src/lib/components/home/HeroBanner.svelte b/src/lib/components/home/HeroBanner.svelte index aced0a72..828f5af5 100644 --- a/src/lib/components/home/HeroBanner.svelte +++ b/src/lib/components/home/HeroBanner.svelte @@ -29,13 +29,21 @@ // 1. Try backdrop image first (best for hero display) if (currentItem.backdropImageTags?.[0]) { - return { itemId: currentItem.id, imageType: "Backdrop" as const, tag: currentItem.backdropImageTags[0] }; + return { + itemId: currentItem.id, + imageType: "Backdrop" as const, + tag: currentItem.backdropImageTags[0], + }; } // 2. For episodes, try series/season backdrops if (currentItem.kind === "episode") { if (currentItem.seriesId && currentItem.parentBackdropImageTags?.[0]) { - return { itemId: currentItem.seriesId, imageType: "Backdrop" as const, tag: currentItem.parentBackdropImageTags[0] }; + return { + itemId: currentItem.seriesId, + imageType: "Backdrop" as const, + tag: currentItem.parentBackdropImageTags[0], + }; } if (currentItem.seriesId) { return { itemId: currentItem.seriesId, imageType: "Backdrop" as const, tag: undefined }; @@ -153,7 +161,9 @@ class="absolute inset-0 w-full h-full object-cover" /> {:else} -
+
{/if} @@ -175,7 +185,9 @@ {#if currentItem.communityRating} - + {currentItem.communityRating.toFixed(1)} @@ -200,7 +212,7 @@ class="px-8 py-3 bg-white text-black hover:bg-white/90 rounded-lg font-semibold text-lg flex items-center gap-2 transition-colors" > - + Play @@ -225,12 +237,17 @@ {#if items.length > 1} -
+
{#each items as _, idx} {/each}
@@ -242,7 +259,12 @@ aria-label="Previous item" > - + @@ -252,7 +274,7 @@ aria-label="Next item" > - + {/if} diff --git a/src/lib/components/library/AlbumDownloadButton.svelte b/src/lib/components/library/AlbumDownloadButton.svelte index 32fe5666..a176b7e6 100644 --- a/src/lib/components/library/AlbumDownloadButton.svelte +++ b/src/lib/components/library/AlbumDownloadButton.svelte @@ -19,36 +19,23 @@ // Calculate download status for all tracks in album const downloadStatuses = $derived( - tracks.map((track) => - Object.values($downloads.downloads).find((d) => d.itemId === track.id) - ) + tracks.map((track) => Object.values($downloads.downloads).find((d) => d.itemId === track.id)), ); - const completedCount = $derived( - downloadStatuses.filter((d) => d?.status === "completed").length - ); + const completedCount = $derived(downloadStatuses.filter((d) => d?.status === "completed").length); const downloadingCount = $derived( - downloadStatuses.filter( - (d) => d?.status === "downloading" || d?.status === "pending" - ).length + downloadStatuses.filter((d) => d?.status === "downloading" || d?.status === "pending").length, ); - const failedCount = $derived( - downloadStatuses.filter((d) => d?.status === "failed").length - ); + const failedCount = $derived(downloadStatuses.filter((d) => d?.status === "failed").length); const totalProgress = $derived(() => { if (tracks.length === 0) return 0; - const activeDownloads = downloadStatuses.filter( - (d) => d?.status === "downloading" - ); + const activeDownloads = downloadStatuses.filter((d) => d?.status === "downloading"); if (activeDownloads.length === 0) return completedCount / tracks.length; - const downloadingProgress = activeDownloads.reduce( - (sum, d) => sum + (d?.progress || 0), - 0 - ); + const downloadingProgress = activeDownloads.reduce((sum, d) => sum + (d?.progress || 0), 0); return (completedCount + downloadingProgress) / tracks.length; }); @@ -77,10 +64,7 @@ } else if (isDownloading) { // Cancel all active downloads for this album for (const status of downloadStatuses) { - if ( - status?.id && - (status.status === "downloading" || status.status === "pending") - ) { + if (status?.id && (status.status === "downloading" || status.status === "pending")) { await downloads.cancel(status.id); } } @@ -184,13 +168,7 @@ {:else if isFullyDownloaded} - + {:else if failedCount > 0} @@ -210,13 +188,7 @@ {:else} - + jumpTo(letter)} class="w-5 leading-tight text-[10px] sm:text-xs font-semibold transition-colors - {enabled ? 'text-gray-400 hover:text-[var(--color-jellyfin)]' : 'text-gray-700 cursor-default'} + {enabled + ? 'text-gray-400 hover:text-[var(--color-jellyfin)]' + : 'text-gray-700 cursor-default'} {activeLetter === letter && enabled ? 'text-[var(--color-jellyfin)] scale-125' : ''}" aria-label={`Jump to ${letter}`} > diff --git a/src/lib/components/library/ArtistDetailView.svelte b/src/lib/components/library/ArtistDetailView.svelte index 2af02d53..0b4d1106 100644 --- a/src/lib/components/library/ArtistDetailView.svelte +++ b/src/lib/components/library/ArtistDetailView.svelte @@ -46,9 +46,9 @@ includeItemTypes: ["MusicAlbum"], limit: 50, sortBy: "DateCreated", - sortOrder: "Descending" + sortOrder: "Descending", }); - albums = albumsResult.items.filter(item => item.kind === "album"); + albums = albumsResult.items.filter((item) => item.kind === "album"); } catch (e) { log.warn("Failed to load albums:", e); } finally { @@ -61,9 +61,9 @@ includeItemTypes: ["Audio"], limit: 10, sortBy: "CommunityRating", - sortOrder: "Descending" + sortOrder: "Descending", }); - topTracks = tracksResult.items.filter(item => item.kind === "track"); + topTracks = tracksResult.items.filter((item) => item.kind === "track"); } catch (e) { log.warn("Failed to load tracks:", e); } finally { @@ -78,10 +78,10 @@ genres: artist.genres.slice(0, 2), limit: 12, sortBy: "CommunityRating", - sortOrder: "Descending" + sortOrder: "Descending", }); relatedArtists = relatedResult.items - .filter(item => item.id !== artist.id && item.kind === "artist") + .filter((item) => item.id !== artist.id && item.kind === "artist") .slice(0, 6); } } catch (e) { @@ -110,7 +110,9 @@ maxWidth={1920} class="w-full h-full object-cover opacity-40" /> -
+
{/if} @@ -168,11 +170,10 @@ {:else}
{#each albums as album (album.id)} - -
+ +
{#if album.imageId} {/if}
-

+

{truncateMiddle(album.name, 40)}

{#if album.productionYear} @@ -226,11 +229,10 @@ {:else}
{#each relatedArtists as relatedArtist (relatedArtist.id)} - -
+ +
{#if relatedArtist.imageId} {/if}
-

+

{relatedArtist.name}

diff --git a/src/lib/components/library/ArtistLinks.svelte b/src/lib/components/library/ArtistLinks.svelte index e739a100..85cac7ca 100644 --- a/src/lib/components/library/ArtistLinks.svelte +++ b/src/lib/components/library/ArtistLinks.svelte @@ -29,9 +29,7 @@ onNavigate, }: Props = $props(); - const linkable = $derived( - (artistItems ?? []).filter((a) => a.id && a.id.trim() !== "") - ); + const linkable = $derived((artistItems ?? []).filter((a) => a.id && a.id.trim() !== "")); function handleClick(artistId: string, e: MouseEvent) { e.preventDefault(); diff --git a/src/lib/components/library/CastSection.svelte b/src/lib/components/library/CastSection.svelte index 2948b437..f322a133 100644 --- a/src/lib/components/library/CastSection.svelte +++ b/src/lib/components/library/CastSection.svelte @@ -95,7 +95,9 @@
-

+

{person.name}

{#if person.role} diff --git a/src/lib/components/library/ClearHistoryButton.svelte b/src/lib/components/library/ClearHistoryButton.svelte index 90f2092b..91bca942 100644 --- a/src/lib/components/library/ClearHistoryButton.svelte +++ b/src/lib/components/library/ClearHistoryButton.svelte @@ -43,7 +43,7 @@ !confirm( `Erase watch history for ${subject}?\n\n` + "Every episode is marked unwatched and resume positions are cleared. " + - "This cannot be undone." + "This cannot be undone.", ) ) { return; @@ -55,9 +55,7 @@ onCleared?.(); } catch (e) { log.error("Failed to clear watch history:", e); - alert( - `Could not clear watch history: ${e instanceof Error ? e.message : String(e)}` - ); + alert(`Could not clear watch history: ${e instanceof Error ? e.message : String(e)}`); } finally { busy = false; } @@ -81,11 +79,7 @@ {size === 'lg' ? 'w-5 h-5' : 'w-4 h-4'}" >
{:else} - + roleFilter.includes(p.type || "") && p.id && p.id.trim() !== "") - .slice(0, maxShow) + .filter((p) => roleFilter.includes(p.type || "") && p.id && p.id.trim() !== "") + .slice(0, maxShow), ); const totalMatching = $derived( - people.filter(p => roleFilter.includes(p.type || "") && p.id && p.id.trim() !== "").length + people.filter((p) => roleFilter.includes(p.type || "") && p.id && p.id.trim() !== "").length, ); function handlePersonClick(personId: string, e: MouseEvent) { diff --git a/src/lib/components/library/DownloadButton.svelte b/src/lib/components/library/DownloadButton.svelte index b5857d4e..53e052d6 100644 --- a/src/lib/components/library/DownloadButton.svelte +++ b/src/lib/components/library/DownloadButton.svelte @@ -24,13 +24,20 @@ className?: string; } - let { itemId, itemName = "", artistName = "", albumName = "", size = "md", className = "" }: Props = $props(); + let { + itemId, + itemName = "", + artistName = "", + albumName = "", + size = "md", + className = "", + }: Props = $props(); let isProcessing = $state(false); // Find download for this item const downloadInfo = $derived( - Object.values($downloads.downloads).find((d) => d.itemId === itemId) + Object.values($downloads.downloads).find((d) => d.itemId === itemId), ); const status = $derived(downloadInfo?.status || "not_downloaded"); @@ -130,5 +137,12 @@
- +
diff --git a/src/lib/components/library/DownloadButtonCore.svelte b/src/lib/components/library/DownloadButtonCore.svelte index b88ff5ed..1dc11ce8 100644 --- a/src/lib/components/library/DownloadButtonCore.svelte +++ b/src/lib/components/library/DownloadButtonCore.svelte @@ -22,7 +22,14 @@ className?: string; } - let { state, size = "md", title, onClick, isProcessing = false, className = "" }: Props = $props(); + let { + state, + size = "md", + title, + onClick, + isProcessing = false, + className = "", + }: Props = $props(); const sizeMap = { sm: { icon: "w-4 h-4", ring: "w-8 h-8" }, @@ -46,13 +53,21 @@ onclick={onClick} disabled={isProcessing || state.status === "downloading"} aria-label={title} - title={title} + {title} class={`relative transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${colorMap[state.status]} ${className}`} > {#if state.status === "downloading"} - + - + {Math.round(state.progress * 100)}% @@ -79,7 +100,9 @@ {:else if state.status === "failed"} - + {:else if state.status === "pending"} @@ -90,7 +113,12 @@ {:else} - + {/if} diff --git a/src/lib/components/library/EpisodeFocusView.svelte b/src/lib/components/library/EpisodeFocusView.svelte index 73896e62..509f97b7 100644 --- a/src/lib/components/library/EpisodeFocusView.svelte +++ b/src/lib/components/library/EpisodeFocusView.svelte @@ -53,13 +53,21 @@ // Compute best backdrop source (no fetch, pure derivation) const backdropSource = $derived.by(() => { if (episode.backdropImageTags?.[0]) { - return { itemId: episode.id, imageType: "Backdrop" as const, tag: episode.backdropImageTags[0] }; + return { + itemId: episode.id, + imageType: "Backdrop" as const, + tag: episode.backdropImageTags[0], + }; } if (episode.imageId) { return { itemId: episode.id, imageType: "Primary" as const, tag: episode.imageId }; } if (series?.backdropImageTags?.[0]) { - return { itemId: series.id, imageType: "Backdrop" as const, tag: series.backdropImageTags[0] }; + return { + itemId: series.id, + imageType: "Backdrop" as const, + tag: series.backdropImageTags[0], + }; } return null; }); @@ -67,8 +75,8 @@ // Cast and genres are the episode's own when the server sent them, else the // series' — a list-level episode fetch often carries neither, and an empty // Cast row on an episode of a show with a known cast reads as broken. - const people = $derived(episode.people?.length ? episode.people : series?.people ?? []); - const genres = $derived(episode.genres?.length ? episode.genres : series?.genres ?? []); + const people = $derived(episode.people?.length ? episode.people : (series?.people ?? [])); + const genres = $derived(episode.genres?.length ? episode.genres : (series?.genres ?? [])); // "More Like This" on an episode means similar *shows* (UR-048), so it keys // off the series rather than the episode. @@ -77,7 +85,7 @@ const seasonHref = $derived( series && episode.parentIndexNumber != null ? `/library/${series.id}#${seasonAnchorId(episode.parentIndexNumber)}` - : null + : null, ); function formatDuration(ms?: number | null): string { @@ -108,9 +116,7 @@ goto(`/library/${series.id}?episode=${ep.id}`); } - const episodeLabel = $derived( - `S${episode.parentIndexNumber || 1}E${episode.indexNumber || 1}` - ); + const episodeLabel = $derived(`S${episode.parentIndexNumber || 1}E${episode.indexNumber || 1}`); const duration = $derived(formatDuration(episode.durationMs)); const progress = $derived(getProgress(episode)); @@ -128,12 +134,16 @@ class="absolute inset-0 w-full h-full object-cover" /> {:else} -
+
{/if}
-
+
{#if onBack} @@ -143,7 +153,12 @@ title="Back to series" > - + {/if} @@ -156,7 +171,10 @@ {#if seriesName}

{#if seriesHref} - + {seriesName} {:else} @@ -192,7 +210,9 @@ {#if episode.communityRating} - + {episode.communityRating.toFixed(1)} @@ -200,7 +220,7 @@ {#if episode.userData?.isPlayed} - + Watched @@ -218,10 +238,7 @@ {#if progress > 0 && progress < 95}

-
+

{Math.round(progress)}% watched @@ -237,7 +254,7 @@ class="px-8 py-3 bg-white text-black hover:bg-white/90 rounded-lg font-semibold text-lg flex items-center gap-2 transition-colors" > - + {progress > 0 && progress < 95 ? "Resume" : "Play"} @@ -271,89 +288,106 @@ strip — continuation content comes before discovery content (ux-flows §5B.2). TRACES: UR-048 | DR-061, DR-062 --> {#if hasEpisodeStrip} -

-

More Episodes

+
+

More Episodes

-
- {#each adjacentEpisodes() as ep (ep.id)} - {@const isCurrent = isCurrentEpisode(ep)} - {@const epProgress = getProgress(ep)} - - {/each} + + +
+
+ + {stripCardLabel(ep, episode)} + +

+ {ep.name} +

+
+ {#if ep.overview} +

+ {ep.overview} +

+ {/if} +
+ + {/each} +
-
{/if} -
+
-
+
-
+
- +
@@ -98,10 +98,7 @@ {#if progress() > 0}
-
+
{/if} @@ -110,7 +107,13 @@
{#if isDownloaded}
- +
@@ -153,7 +156,9 @@ {episodeNumber}. -

+

{truncateMiddle(episode.name, 56)}

{#if current} @@ -165,8 +170,12 @@ {/if} {#if episode.userData?.isPlayed} - - + + {/if}
diff --git a/src/lib/components/library/GenericGenreBrowser.svelte b/src/lib/components/library/GenericGenreBrowser.svelte index 5992b058..1f27b2d1 100644 --- a/src/lib/components/library/GenericGenreBrowser.svelte +++ b/src/lib/components/library/GenericGenreBrowser.svelte @@ -72,9 +72,7 @@ // Auto-select a genre when linked with ?genre= (e.g. from a genre tag) const requestedGenre = $page.url.searchParams.get("genre"); if (requestedGenre) { - const match = genres.find( - (g) => g.name.toLowerCase() === requestedGenre.toLowerCase(), - ); + const match = genres.find((g) => g.name.toLowerCase() === requestedGenre.toLowerCase()); if (match) { await loadGenreItems(match); } @@ -157,14 +155,21 @@ } } - const aspectRatioClass = $derived(config.itemDisplayMode === "poster" ? "aspect-[2/3]" : "aspect-square"); + const aspectRatioClass = $derived( + config.itemDisplayMode === "poster" ? "aspect-[2/3]" : "aspect-square", + ); const gridColsClass = $derived( config.itemDisplayMode === "poster" ? "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5" - : "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6" + : "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6", + ); + const searchPlaceholder = $derived( + config.searchPlaceholder || `Search ${config.title.toLowerCase()}...`, + ); + const noItemsMessage = $derived( + config.noItemsMessage || + `No ${config.itemTypes[0]?.toLowerCase() || "items"} found in this genre`, ); - const searchPlaceholder = $derived(config.searchPlaceholder || `Search ${config.title.toLowerCase()}...`); - const noItemsMessage = $derived(config.noItemsMessage || `No ${config.itemTypes[0]?.toLowerCase() || "items"} found in this genre`);
@@ -188,7 +193,7 @@ {#if !loading && filteredGenres.length > 0} - + {/if} @@ -220,7 +225,9 @@ {@html config.genreIcon}
-

+

{genre.name}

@@ -244,11 +251,16 @@
{:else}
- +
{#each genreItems as item (item.id)} @@ -320,7 +321,7 @@ {#if !loading} - + {/if} @@ -349,7 +350,13 @@
{#if config.displayComponent === "grid"} - + {:else if config.displayComponent === "tracklist"} {/if} diff --git a/src/lib/components/library/GenericMediaListPage.test.ts b/src/lib/components/library/GenericMediaListPage.test.ts index 9bd55aca..a705c3af 100644 --- a/src/lib/components/library/GenericMediaListPage.test.ts +++ b/src/lib/components/library/GenericMediaListPage.test.ts @@ -200,7 +200,7 @@ describe("GenericMediaListPage", () => { }; vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue( - mockRepository as any + mockRepository as any, ); const config = { @@ -231,7 +231,7 @@ describe("GenericMediaListPage", () => { includeItemTypes: ["Audio"], limit: 10000, }), - expect.any(Number) + expect.any(Number), ); }); }); @@ -248,7 +248,7 @@ describe("GenericMediaListPage", () => { }; vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue( - mockRepository as any + mockRepository as any, ); const config = { @@ -266,11 +266,14 @@ describe("GenericMediaListPage", () => { }); await waitFor(() => { - expect(mockGetItemsFn).toHaveBeenCalledWith("lib123", expect.objectContaining({ - includeItemTypes: ["Audio"], - sortBy: "SortName", - sortOrder: "Ascending", - })); + expect(mockGetItemsFn).toHaveBeenCalledWith( + "lib123", + expect.objectContaining({ + includeItemTypes: ["Audio"], + sortBy: "SortName", + sortOrder: "Ascending", + }), + ); }); }); @@ -293,7 +296,7 @@ describe("GenericMediaListPage", () => { }; vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue( - mockRepository as any + mockRepository as any, ); const config = { @@ -342,7 +345,7 @@ describe("GenericMediaListPage", () => { }; vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue( - mockRepository as any + mockRepository as any, ); const config = { @@ -363,10 +366,13 @@ describe("GenericMediaListPage", () => { }); await waitFor(() => { - expect(mockGetItemsFn).toHaveBeenCalledWith("lib123", expect.objectContaining({ - sortBy: "SortName", - sortOrder: "Ascending", - })); + expect(mockGetItemsFn).toHaveBeenCalledWith( + "lib123", + expect.objectContaining({ + sortBy: "SortName", + sortOrder: "Ascending", + }), + ); }); }); @@ -382,7 +388,7 @@ describe("GenericMediaListPage", () => { }; vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue( - mockRepository as any + mockRepository as any, ); const config = { @@ -428,7 +434,7 @@ describe("GenericMediaListPage", () => { }; vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue( - mockRepository as any + mockRepository as any, ); const config = { @@ -446,9 +452,12 @@ describe("GenericMediaListPage", () => { }); await waitFor(() => { - expect(mockGetItemsFn).toHaveBeenCalledWith("lib123", expect.objectContaining({ - includeItemTypes: ["Audio"], - })); + expect(mockGetItemsFn).toHaveBeenCalledWith( + "lib123", + expect.objectContaining({ + includeItemTypes: ["Audio"], + }), + ); }); }); @@ -469,7 +478,7 @@ describe("GenericMediaListPage", () => { }; vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue( - mockRepository as any + mockRepository as any, ); const config = { @@ -497,7 +506,7 @@ describe("GenericMediaListPage", () => { expect.objectContaining({ includeItemTypes: ["MusicAlbum"], }), - expect.any(Number) + expect.any(Number), ); }); }); @@ -506,10 +515,10 @@ describe("GenericMediaListPage", () => { describe("Loading State", () => { it("should show loading indicator during data fetch", async () => { const mockGetItemsFn = vi.fn( - () => new Promise((resolve) => setTimeout( - () => resolve({ items: [], totalRecordCount: 0 }), - 100 - )) + () => + new Promise((resolve) => + setTimeout(() => resolve({ items: [], totalRecordCount: 0 }), 100), + ), ); const mockRepository = { @@ -518,7 +527,7 @@ describe("GenericMediaListPage", () => { }; vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue( - mockRepository as any + mockRepository as any, ); const config = { @@ -554,7 +563,7 @@ describe("GenericMediaListPage", () => { }; vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue( - mockRepository as any + mockRepository as any, ); const config = { @@ -589,7 +598,7 @@ describe("GenericMediaListPage", () => { }; vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue( - mockRepository as any + mockRepository as any, ); // Deliver a null current library for this test only. diff --git a/src/lib/components/library/GenreFilter.svelte b/src/lib/components/library/GenreFilter.svelte index a682cabd..532c8d25 100644 --- a/src/lib/components/library/GenreFilter.svelte +++ b/src/lib/components/library/GenreFilter.svelte @@ -38,8 +38,8 @@ onclick={() => handleToggleGenre(genre.name)} class="px-3 py-1 rounded-full text-sm transition-colors {isSelected - ? 'bg-[var(--color-jellyfin)] text-white' - : 'bg-[var(--color-surface)] text-gray-300 hover:bg-[var(--color-surface-hover)]'}" + ? 'bg-[var(--color-jellyfin)] text-white' + : 'bg-[var(--color-surface)] text-gray-300 hover:bg-[var(--color-surface-hover)]'}" > {genre.name} diff --git a/src/lib/components/library/GenreTags.svelte b/src/lib/components/library/GenreTags.svelte index 85a8f58f..36293884 100644 --- a/src/lib/components/library/GenreTags.svelte +++ b/src/lib/components/library/GenreTags.svelte @@ -6,17 +6,12 @@ interface Props { genres: string[]; - maxShow?: number; // Default: unlimited - clickable?: boolean; // Default: true - itemKind?: MediaKind; // Determines which genre browse page to open + maxShow?: number; // Default: unlimited + clickable?: boolean; // Default: true + itemKind?: MediaKind; // Determines which genre browse page to open } - let { - genres, - maxShow, - clickable = true, - itemKind - }: Props = $props(); + let { genres, maxShow, clickable = true, itemKind }: Props = $props(); // Map the item kind to its genre-browse surface. Video genres are a tab of // the library page now, not a route of their own (DR-105); linking straight @@ -39,13 +34,9 @@ } } - const displayGenres = $derived( - maxShow ? genres.slice(0, maxShow) : genres - ); + const displayGenres = $derived(maxShow ? genres.slice(0, maxShow) : genres); - const hiddenCount = $derived( - maxShow && genres.length > maxShow ? genres.length - maxShow : 0 - ); + const hiddenCount = $derived(maxShow && genres.length > maxShow ? genres.length - maxShow : 0); function handleGenreClick(genre: string) { if (clickable) { @@ -60,7 +51,9 @@ diff --git a/src/lib/components/library/LibraryGrid.svelte b/src/lib/components/library/LibraryGrid.svelte index ef5f24a1..c9bdadb2 100644 --- a/src/lib/components/library/LibraryGrid.svelte +++ b/src/lib/components/library/LibraryGrid.svelte @@ -23,7 +23,17 @@ onItemRemove?: (item: MediaItem | Library) => void; } - let { items, title, loading = false, showViewToggle = true, musicContent = false, onItemClick, sizeLabelFor, downloadedBadgeFor, onItemRemove }: Props = $props(); + let { + items, + title, + loading = false, + showViewToggle = true, + musicContent = false, + onItemClick, + sizeLabelFor, + downloadedBadgeFor, + onItemRemove, + }: Props = $props();
@@ -38,22 +48,26 @@
@@ -64,7 +78,11 @@
{#each Array(6) as _}
-
+
@@ -75,7 +93,7 @@

No items found

{:else if $viewMode === "list"} - + {:else}
{#each items as item, index (item.id)} diff --git a/src/lib/components/library/LibraryListView.svelte b/src/lib/components/library/LibraryListView.svelte index 75ec6a54..b587eb95 100644 --- a/src/lib/components/library/LibraryListView.svelte +++ b/src/lib/components/library/LibraryListView.svelte @@ -19,7 +19,11 @@ } function getImageTag(item: MediaItem | Library): string | undefined { - return "imageId" in item ? (item.imageId ?? undefined) : ("imageTag" in item ? (item.imageTag ?? undefined) : undefined); + return "imageId" in item + ? (item.imageId ?? undefined) + : "imageTag" in item + ? (item.imageTag ?? undefined) + : undefined; } function getSubtitle(item: MediaItem | Library): string { @@ -31,7 +35,9 @@ case "MusicAlbum": return item.artistItems?.map((a) => a.name).join(", ") || ""; case "Episode": - return item.seriesName ? `${item.seriesName} - S${item.parentIndexNumber}E${item.indexNumber}` : ""; + return item.seriesName + ? `${item.seriesName} - S${item.parentIndexNumber}E${item.indexNumber}` + : ""; case "Movie": case "Series": return item.productionYear?.toString() || ""; @@ -40,9 +46,14 @@ } } - function getProgress(item: MediaItem | Library): number { - if (!showProgress || !("userData" in item) || !item.userData || !("durationMs" in item) || !item.durationMs) { + if ( + !showProgress || + !("userData" in item) || + !item.userData || + !("durationMs" in item) || + !item.durationMs + ) { return 0; } return ((item.userData.playbackPositionMs ?? 0) / item.durationMs) * 100; @@ -65,7 +76,8 @@ {@const isPlayed = "userData" in item && item.userData?.isPlayed} {@const downloadInfo = getDownloadInfo(item.id)} {@const isDownloaded = downloadInfo?.status === "completed"} - {@const isDownloading = downloadInfo?.status === "downloading" || downloadInfo?.status === "pending"} + {@const isDownloading = + downloadInfo?.status === "downloading" || downloadInfo?.status === "pending"} diff --git a/src/lib/components/library/MediaCard.svelte b/src/lib/components/library/MediaCard.svelte index 43724af5..c0f2eaaf 100644 --- a/src/lib/components/library/MediaCard.svelte +++ b/src/lib/components/library/MediaCard.svelte @@ -57,7 +57,19 @@ aspect?: "square" | "video" | "poster"; } - let { item, size = "medium", showProgress = false, showDownloadStatus = true, sizeLabel, downloadedBadge, onRemove, onclick, onLongPress, showFavorite = true, aspect }: Props = $props(); + let { + item, + size = "medium", + showProgress = false, + showDownloadStatus = true, + sizeLabel, + downloadedBadge, + onRemove, + onclick, + onLongPress, + showFavorite = true, + aspect, + }: Props = $props(); // Long-press detection. We arm a timer on pointerdown; if it fires before the // pointer is released (or moves too far), we treat it as a long press and set a @@ -114,12 +126,12 @@ // Check if this item is downloaded const downloadInfo = $derived( - Object.values($downloads.downloads).find((d) => d.itemId === item.id) + Object.values($downloads.downloads).find((d) => d.itemId === item.id), ); const isDownloaded = $derived(downloadInfo?.status === "completed"); const isDownloading = $derived( - downloadInfo?.status === "downloading" || downloadInfo?.status === "pending" + downloadInfo?.status === "downloading" || downloadInfo?.status === "pending", ); const downloadProgress = $derived(downloadInfo?.progress || 0); @@ -135,14 +147,14 @@ // transferring. A `pending` (queued-for-reconnect) item stays server-only so // it can show the Queued badge in place of the queue button. const isServerOnly = $derived( - isMediaItem && !$isConnected && $showServerCatalog && !isDownloaded && !isActivelyDownloading + isMediaItem && !$isConnected && $showServerCatalog && !isDownloaded && !isActivelyDownloading, ); // The heart is about an item, so libraries never get one, and a greyed // server-only card has nothing actionable to offer. TRACES: UR-068 | DR-119 const showHeart = $derived(showFavorite && isMediaItem && !isServerOnly); const isFavorited = $derived( - isMediaItem ? resolveIsFavorite(item as MediaItem, $favoriteOverrides) : false + isMediaItem ? resolveIsFavorite(item as MediaItem, $favoriteOverrides) : false, ); let queueError = $state(null); @@ -171,7 +183,7 @@ undefined, media.name, media.artists?.join(", ") ?? undefined, - media.albumName ?? undefined + media.albumName ?? undefined, ); } catch (err) { log.error("Failed to queue download:", err); @@ -186,7 +198,11 @@ }; const isMusicType = $derived( - "kind" in item && (item.kind === "track" || item.kind === "album" || item.kind === "artist" || item.kind === "playlist") + "kind" in item && + (item.kind === "track" || + item.kind === "album" || + item.kind === "artist" || + item.kind === "playlist"), ); const FIXED_ASPECT = { @@ -201,11 +217,13 @@ return isMusicType ? "aspect-square" : "aspect-[2/3]"; } // Library - return "collectionType" in item && item.collectionType === "music" ? "aspect-square" : "aspect-video"; + return "collectionType" in item && item.collectionType === "music" + ? "aspect-square" + : "aspect-video"; }); const imageTag = $derived( - "imageId" in item ? item.imageId : ("imageTag" in item ? item.imageTag : undefined) + "imageId" in item ? item.imageId : "imageTag" in item ? item.imageTag : undefined, ); const maxWidth = $derived(size === "large" ? 400 : size === "medium" ? 300 : 200); @@ -226,7 +244,9 @@ case "MusicAlbum": return item.artistItems?.map((a) => a.name).join(", ") || ""; case "Episode": - return item.seriesName ? `${item.seriesName} - S${item.parentIndexNumber}E${item.indexNumber}` : ""; + return item.seriesName + ? `${item.seriesName} - S${item.parentIndexNumber}E${item.indexNumber}` + : ""; case "Movie": return item.productionYear?.toString() || ""; case "Series": @@ -241,7 +261,9 @@ this={isServerOnly ? "div" : "button"} type={isServerOnly ? undefined : "button"} role={isServerOnly ? "group" : undefined} - class="group/card flex flex-col text-left {sizeClasses[size]} flex-shrink-0 transition-transform duration-200 {isServerOnly ? '' : 'hover:scale-105'}" + class="group/card flex flex-col text-left {sizeClasses[ + size + ]} flex-shrink-0 transition-transform duration-200 {isServerOnly ? '' : 'hover:scale-105'}" style={onLongPress ? "touch-action: manipulation; -webkit-touch-callout: none;" : undefined} onclick={isServerOnly ? undefined : handleClick} onpointerdown={isServerOnly ? undefined : handlePointerDown} @@ -250,23 +272,35 @@ onpointercancel={isServerOnly ? undefined : handlePointerUp} oncontextmenu={onLongPress ? (e: Event) => e.preventDefault() : undefined} > -
+
-
-
-
+
+
+
- +
@@ -275,10 +309,7 @@ {#if progress() > 0}
-
+
{/if} @@ -288,7 +319,7 @@
{#if "userData" in item && item.userData?.isPlayed} - + {/if} {#if showHeart} @@ -319,7 +350,13 @@ {#if isDownloaded}
- +
@@ -348,7 +385,13 @@ class="transition-all duration-300" /> - +
@@ -360,13 +403,20 @@ {#if onRemove} {/if} @@ -379,13 +429,28 @@ > {#if downloadedBadge === "full"}
- +
{:else} -
- +
+
@@ -398,13 +463,30 @@ {#if isServerOnly}
{#if isQueued} -
-
- - +
+
+ +
- Queued + Queued
{:else} {/if}
{#if queueError} -
+
{queueError}
{/if} @@ -429,7 +523,9 @@
-

+

{truncateMiddle(item.name, 40)}

{#if subtitle()} diff --git a/src/lib/components/library/MosaicGrid.svelte b/src/lib/components/library/MosaicGrid.svelte index 16ebc52c..3eaabccc 100644 --- a/src/lib/components/library/MosaicGrid.svelte +++ b/src/lib/components/library/MosaicGrid.svelte @@ -16,12 +16,7 @@
{/each} {/if} diff --git a/src/lib/components/library/SeasonSection.svelte b/src/lib/components/library/SeasonSection.svelte index 5d9cfda6..b7a4bb25 100644 --- a/src/lib/components/library/SeasonSection.svelte +++ b/src/lib/components/library/SeasonSection.svelte @@ -37,14 +37,14 @@ }: Props = $props(); const holdsCurrentEpisode = $derived( - currentEpisodeId != null && episodes.some((e) => e.id === currentEpisodeId) + currentEpisodeId != null && episodes.some((e) => e.id === currentEpisodeId), ); const watchedCount = $derived(episodes.filter((e) => e.userData?.isPlayed).length); const episodeCount = $derived(episodes.length); const seasonNumber = $derived(season.indexNumber ?? season.parentIndexNumber); const seasonName = $derived( - season.name || (seasonNumber != null ? `Season ${seasonNumber}` : "Unknown Season") + season.name || (seasonNumber != null ? `Season ${seasonNumber}` : "Unknown Season"), ); // Seasons have no page of their own; a season link scrolls to this anchor // inside the series' single continuous episode list. @@ -55,7 +55,9 @@
-
+
d.seriesName === seriesName) - ); + const seriesDownloads = $derived($videoDownloads.filter((d) => d.seriesName === seriesName)); - const completedCount = $derived( - seriesDownloads.filter((d) => d.status === "completed").length - ); + const completedCount = $derived(seriesDownloads.filter((d) => d.status === "completed").length); const inProgressCount = $derived( - seriesDownloads.filter((d) => d.status === "downloading" || d.status === "pending").length + seriesDownloads.filter((d) => d.status === "downloading" || d.status === "pending").length, ); const hasDownloads = $derived(completedCount > 0 || inProgressCount > 0); @@ -59,7 +55,7 @@ seriesName, userId, basePath, - quality + quality, ); log.debug(` Queued ${downloadIds.length} episodes for download`); @@ -113,13 +109,21 @@ {/each} {/if} diff --git a/src/lib/components/library/TrackList.logic.test.ts b/src/lib/components/library/TrackList.logic.test.ts index 5bc4b51d..37e46356 100644 --- a/src/lib/components/library/TrackList.logic.test.ts +++ b/src/lib/components/library/TrackList.logic.test.ts @@ -84,7 +84,7 @@ describe("TrackList Logic Tests", () => { mediaType: "Audio", streamUrl: await repo.getAudioStreamUrl(t.id), jellyfinItemId: t.id, - })) + })), ); expect(queueItems).toHaveLength(2); @@ -110,7 +110,7 @@ describe("TrackList Logic Tests", () => { id: t.id, streamUrl, }; - }) + }), ); expect(mockRepository.getAudioStreamUrl).toHaveBeenCalledTimes(2); @@ -279,7 +279,7 @@ describe("TrackList Logic Tests", () => { startIndex: 0, shuffle: false, }, - }) + }), ).rejects.toThrow("Network error"); }); }); diff --git a/src/lib/components/library/TrackList.svelte b/src/lib/components/library/TrackList.svelte index f90f3dfc..f1441ee3 100644 --- a/src/lib/components/library/TrackList.svelte +++ b/src/lib/components/library/TrackList.svelte @@ -40,7 +40,7 @@ showArtist = true, showDownload = false, context, - onTrackClick + onTrackClick, }: Props = $props(); let isPlayingTrack = $state(null); @@ -93,7 +93,7 @@ // Queue will auto-update from Rust backend event } catch (e) { - const errorMessage = e instanceof Error ? e.message : 'Unknown error'; + const errorMessage = e instanceof Error ? e.message : "Unknown error"; log.error("Failed to play track:", errorMessage); toast.error(`Failed to play track: ${errorMessage}`, 5000); } finally { @@ -110,7 +110,6 @@ } } - function toggleMenu(trackId: string, buttonElement: HTMLElement, e: Event) { e.stopPropagation(); @@ -158,9 +157,7 @@ {#if loading}
{#each Array(10) as _} -
+
@@ -199,7 +196,13 @@
{#each tracks as track, index (track.id)} -
+
@@ -332,7 +348,9 @@
{#if isPlayingTrack === track.id} -
+
{:else} {index + 1}

{#if currentlyPlayingId === track.id} @@ -361,7 +382,7 @@ role="button" tabindex="0" onclick={(e) => handleArtistClick(artist.id, e)} - onkeydown={(e) => e.key === 'Enter' && handleArtistClick(artist.id, e)} + onkeydown={(e) => e.key === "Enter" && handleArtistClick(artist.id, e)} class="text-[var(--color-jellyfin)] hover:underline cursor-pointer" > {artist.name} @@ -379,7 +400,7 @@ role="button" tabindex="0" onclick={(e) => handleAlbumClick(track.albumId, e)} - onkeydown={(e) => e.key === 'Enter' && handleAlbumClick(track.albumId, e)} + onkeydown={(e) => e.key === "Enter" && handleAlbumClick(track.albumId, e)} class="text-[var(--color-jellyfin)] hover:underline cursor-pointer" > {track.albumName || "-"} @@ -394,7 +415,7 @@ role="button" tabindex="0" onclick={(e) => handleArtistClick(artist.id, e)} - onkeydown={(e) => e.key === 'Enter' && handleArtistClick(artist.id, e)} + onkeydown={(e) => e.key === "Enter" && handleArtistClick(artist.id, e)} class="text-[var(--color-jellyfin)] hover:underline cursor-pointer" > {artist.name} @@ -412,7 +433,7 @@ role="button" tabindex="0" onclick={(e) => handleAlbumClick(track.albumId, e)} - onkeydown={(e) => e.key === 'Enter' && handleAlbumClick(track.albumId, e)} + onkeydown={(e) => e.key === "Enter" && handleAlbumClick(track.albumId, e)} class="text-[var(--color-jellyfin)] hover:underline cursor-pointer" > {track.albumName || "-"} @@ -447,7 +468,9 @@ aria-label="More options" > - +

@@ -459,7 +482,7 @@ {#if openMenuId && menuPosition} - {@const selectedTrack = tracks.find(t => t.id === openMenuId)} + {@const selectedTrack = tracks.find((t) => t.id === openMenuId)} {#if selectedTrack}
- + Play Next @@ -482,7 +510,12 @@ class="w-full px-4 py-2 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-2" > - + Add to Queue @@ -496,7 +529,9 @@ class="w-full px-4 py-2 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-2" > - + Add to Playlist @@ -508,7 +543,7 @@ addToPlaylistTrackId = null} + onClose={() => (addToPlaylistTrackId = null)} trackIds={addToPlaylistTrackId ? [addToPlaylistTrackId] : []} /> diff --git a/src/lib/components/library/TrackList.test.ts b/src/lib/components/library/TrackList.test.ts index b8b8bb0f..e9364c0f 100644 --- a/src/lib/components/library/TrackList.test.ts +++ b/src/lib/components/library/TrackList.test.ts @@ -28,7 +28,12 @@ vi.mock("$lib/stores/queue", () => ({ setQueue: vi.fn(), addToQueue: vi.fn(), }, - currentQueueItem: { subscribe: vi.fn((fn: any) => { fn(null); return () => {}; }) }, + currentQueueItem: { + subscribe: vi.fn((fn: any) => { + fn(null); + return () => {}; + }), + }, })); vi.mock("./DownloadButton.svelte", () => ({ @@ -42,10 +47,30 @@ vi.mock("$lib/stores/library", () => ({ loadItem: vi.fn(), setCurrentLibrary: vi.fn(), }, - libraries: { subscribe: vi.fn((fn: any) => { fn([]); return () => {}; }) }, - libraryItems: { subscribe: vi.fn((fn: any) => { fn([]); return () => {}; }) }, - currentLibrary: { subscribe: vi.fn((fn: any) => { fn(null); return () => {}; }) }, - isLibraryLoading: { subscribe: vi.fn((fn: any) => { fn(false); return () => {}; }) }, + libraries: { + subscribe: vi.fn((fn: any) => { + fn([]); + return () => {}; + }), + }, + libraryItems: { + subscribe: vi.fn((fn: any) => { + fn([]); + return () => {}; + }), + }, + currentLibrary: { + subscribe: vi.fn((fn: any) => { + fn(null); + return () => {}; + }), + }, + isLibraryLoading: { + subscribe: vi.fn((fn: any) => { + fn(false); + return () => {}; + }), + }, })); // Now import the modules after mocks are set up @@ -120,7 +145,9 @@ describe("TrackList", () => { expect(getAllByText("Song 1").length).toBeGreaterThan(0); expect(getAllByText("Song 2").length).toBeGreaterThan(0); // Long names are abbreviated in the middle via truncateMiddle(name, 48). - expect(getAllByText(/Song 3 with a Very Long .*Should Be Truncated/).length).toBeGreaterThan(0); + expect(getAllByText(/Song 3 with a Very Long .*Should Be Truncated/).length).toBeGreaterThan( + 0, + ); }); it("shows loading skeleton when loading=true", () => { @@ -234,7 +261,7 @@ describe("TrackList", () => { // Find and click the first track button const buttons = container.querySelectorAll("button"); const firstTrackButton = Array.from(buttons).find((btn) => - btn.textContent?.includes("Song 1") + btn.textContent?.includes("Song 1"), ); expect(firstTrackButton).toBeTruthy(); @@ -250,7 +277,7 @@ describe("TrackList", () => { startIndex: 0, shuffle: false, }), - }) + }), ); }); }); @@ -261,7 +288,7 @@ describe("TrackList", () => { const buttons = container.querySelectorAll("button"); const secondTrackButton = Array.from(buttons).find((btn) => - btn.textContent?.includes("Song 2") + btn.textContent?.includes("Song 2"), ); await fireEvent.click(secondTrackButton!); @@ -278,7 +305,7 @@ describe("TrackList", () => { const buttons = container.querySelectorAll("button"); const thirdTrackButton = Array.from(buttons).find((btn) => - btn.textContent?.includes("Song 3") + btn.textContent?.includes("Song 3"), ); await fireEvent.click(thirdTrackButton!); @@ -297,7 +324,7 @@ describe("TrackList", () => { const buttons = container.querySelectorAll("button"); const firstTrackButton = Array.from(buttons).find((btn) => - btn.textContent?.includes("Song 1") + btn.textContent?.includes("Song 1"), ); await fireEvent.click(firstTrackButton!); @@ -305,7 +332,7 @@ describe("TrackList", () => { await waitFor(() => { expect(toastSpy).toHaveBeenCalledWith( expect.stringContaining("Failed to play track"), - expect.anything() + expect.anything(), ); }); @@ -322,7 +349,7 @@ describe("TrackList", () => { const buttons = container.querySelectorAll("button"); const firstTrackButton = Array.from(buttons).find((btn) => - btn.textContent?.includes("Song 1") + btn.textContent?.includes("Song 1"), ); await fireEvent.click(firstTrackButton!); @@ -330,7 +357,7 @@ describe("TrackList", () => { await waitFor(() => { expect(toastSpy).toHaveBeenCalledWith( expect.stringContaining("Failed to play track"), - expect.anything() + expect.anything(), ); }); @@ -339,7 +366,6 @@ describe("TrackList", () => { // Restore mock for other tests (auth.getRepository as any).mockReturnValue(mockRepository as any); }); - }); describe("Custom Callback Tests", () => { @@ -351,7 +377,7 @@ describe("TrackList", () => { const buttons = container.querySelectorAll("button"); const firstTrackButton = Array.from(buttons).find((btn) => - btn.textContent?.includes("Song 1") + btn.textContent?.includes("Song 1"), ); await fireEvent.click(firstTrackButton!); @@ -363,7 +389,7 @@ describe("TrackList", () => { it("does not call player_play_queue when custom callback provided", async () => { const onTrackClick = vi.fn(); - const invokeMock = (invoke as any); + const invokeMock = invoke as any; const { container } = render(TrackList, { props: { tracks: mockTracks, onTrackClick }, @@ -371,7 +397,7 @@ describe("TrackList", () => { const buttons = container.querySelectorAll("button"); const firstTrackButton = Array.from(buttons).find((btn) => - btn.textContent?.includes("Song 1") + btn.textContent?.includes("Song 1"), ); await fireEvent.click(firstTrackButton!); @@ -391,7 +417,7 @@ describe("TrackList", () => { const buttons = container.querySelectorAll("button"); const secondTrackButton = Array.from(buttons).find((btn) => - btn.textContent?.includes("Song 2") + btn.textContent?.includes("Song 2"), ); await fireEvent.click(secondTrackButton!); @@ -409,7 +435,7 @@ describe("TrackList", () => { const buttons = container.querySelectorAll("button"); const firstTrackButton = Array.from(buttons).find((btn) => - btn.textContent?.includes("Song 1") + btn.textContent?.includes("Song 1"), ); await fireEvent.click(firstTrackButton!); @@ -429,7 +455,7 @@ describe("TrackList", () => { const buttons = container.querySelectorAll("button"); const firstTrackButton = Array.from(buttons).find((btn) => - btn.textContent?.includes("Song 1") + btn.textContent?.includes("Song 1"), ); await fireEvent.click(firstTrackButton!); @@ -454,9 +480,7 @@ describe("TrackList", () => { const { container } = render(TrackList, { props: { tracks: singleTrack } }); const buttons = container.querySelectorAll("button"); - const trackButton = Array.from(buttons).find((btn) => - btn.textContent?.includes("Song 1") - ); + const trackButton = Array.from(buttons).find((btn) => btn.textContent?.includes("Song 1")); await fireEvent.click(trackButton!); @@ -473,7 +497,7 @@ describe("TrackList", () => { const buttons = container.querySelectorAll("button"); const firstTrackButton = Array.from(buttons).find((btn) => - btn.textContent?.includes("Song 1") + btn.textContent?.includes("Song 1"), ); await fireEvent.click(firstTrackButton!); @@ -490,7 +514,7 @@ describe("TrackList", () => { const buttons = container.querySelectorAll("button"); const lastTrackButton = Array.from(buttons).find((btn) => - btn.textContent?.includes("Song 3") + btn.textContent?.includes("Song 3"), ); await fireEvent.click(lastTrackButton!); @@ -505,15 +529,13 @@ describe("TrackList", () => { describe("Loading State", () => { it("shows loading spinner when track is clicked", async () => { // Make invoke slow to capture loading state - (invoke as any).mockImplementation( - () => new Promise((resolve) => setTimeout(resolve, 100)) - ); + (invoke as any).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100))); const { container } = render(TrackList, { props: { tracks: mockTracks } }); const buttons = container.querySelectorAll("button"); const firstTrackButton = Array.from(buttons).find((btn) => - btn.textContent?.includes("Song 1") + btn.textContent?.includes("Song 1"), ); fireEvent.click(firstTrackButton!); @@ -526,15 +548,13 @@ describe("TrackList", () => { }); it("disables track buttons during loading", async () => { - (invoke as any).mockImplementation( - () => new Promise((resolve) => setTimeout(resolve, 100)) - ); + (invoke as any).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100))); const { container } = render(TrackList, { props: { tracks: mockTracks } }); const buttons = container.querySelectorAll("button"); const firstTrackButton = Array.from(buttons).find((btn) => - btn.textContent?.includes("Song 1") + btn.textContent?.includes("Song 1"), ); fireEvent.click(firstTrackButton!); @@ -542,8 +562,8 @@ describe("TrackList", () => { // Track selection buttons should be disabled during loading await waitFor(() => { // Find track buttons (ones containing song names) - const trackButtons = Array.from(container.querySelectorAll("button")).filter( - (btn) => btn.textContent?.includes("Song") + const trackButtons = Array.from(container.querySelectorAll("button")).filter((btn) => + btn.textContent?.includes("Song"), ); expect(trackButtons.length).toBeGreaterThan(0); trackButtons.forEach((btn) => { diff --git a/src/lib/components/library/VideoDownloadButton.svelte b/src/lib/components/library/VideoDownloadButton.svelte index 3d9677db..c8ad8623 100644 --- a/src/lib/components/library/VideoDownloadButton.svelte +++ b/src/lib/components/library/VideoDownloadButton.svelte @@ -30,7 +30,7 @@ episodeNumber, seasonNumber, size = "md", - className = "" + className = "", }: Props = $props(); const sizeClasses = { @@ -46,7 +46,7 @@ // Find download for this item const downloadInfo = $derived( - Object.values($downloads.downloads).find((d) => d.itemId === itemId) + Object.values($downloads.downloads).find((d) => d.itemId === itemId), ); const status = $derived(downloadInfo?.status || "not_downloaded"); @@ -83,7 +83,7 @@ filePath = `videos/movies/${safeName}.mp4`; } else if (seriesName && seasonNumber !== undefined && episodeNumber !== undefined) { const safeSeriesName = seriesName.replace(/[/\\:*?"<>|]/g, "_"); - filePath = `videos/${safeSeriesName}/S${String(seasonNumber).padStart(2, '0')}E${String(episodeNumber).padStart(2, '0')}_${safeName}.mp4`; + filePath = `videos/${safeSeriesName}/S${String(seasonNumber).padStart(2, "0")}E${String(episodeNumber).padStart(2, "0")}_${safeName}.mp4`; } else { filePath = `videos/${safeName}.mp4`; } @@ -96,13 +96,13 @@ userId, filePath, "video/mp4", - isMovie ? 500 : (1000 - (episodeNumber || 0)), // Movies have medium priority, episodes ordered by number + isMovie ? 500 : 1000 - (episodeNumber || 0), // Movies have medium priority, episodes ordered by number itemName || undefined, quality, seriesName, seasonName, episodeNumber, - seasonNumber + seasonNumber, ); log.debug(" Download queued with ID:", downloadId); @@ -232,27 +232,59 @@ viewBox="0 0 24 24" stroke-width="2.5" > - + {:else if status === "completed"} - + {:else if status === "pending"} - - + + {:else if status === "failed"} - - + + {:else} - - + + {/if}
@@ -263,16 +295,14 @@ {#if showQualityPicker}
-
- Select Quality -
+
Select Quality
{#each Object.entries(QUALITY_PRESETS) as [key, preset]} {/each} - -
-

Now Playing

- {#if $isRemoteMode && $selectedSession} -

- - - - {$selectedSession.deviceName} -

- {/if} -
- -
- - - - + +
- +
- - -
-
+
+ + - -
-
- {#if artworkItemId} - - {:else} -
- - + +
- {/if} -
-
+ - -
- -
-

{truncateMiddle(displayMedia?.name, 48)}

-
- {#if displayMedia?.artistItems?.length} - {#each displayMedia?.artistItems as artist, i} + + + + +
+
+ + +
+
+ {#if artworkItemId} + + {:else} +
+ + + +
+ {/if} +
+
+ + +
+ +
+

+ {truncateMiddle(displayMedia?.name, 48)} +

+
+ {#if displayMedia?.artistItems?.length} + {#each displayMedia?.artistItems as artist, i} + {#if i < (displayMedia?.artistItems?.length ?? 0) - 1},{/if} + {/each} + {:else if displayMedia?.artists?.length} + {displayMedia?.artists.join(", ")} + {/if} + {#if displayMedia?.albumId && displayMedia?.albumName} + {#if displayMedia?.artistItems?.length || displayMedia?.artists?.length} + + {/if} {#if i < (displayMedia?.artistItems?.length ?? 0) - 1},{/if} - {/each} - {:else if displayMedia?.artists?.length} - {displayMedia?.artists.join(", ")} - {/if} - {#if displayMedia?.albumId && displayMedia?.albumName} - {#if displayMedia?.artistItems?.length || displayMedia?.artists?.length} - + {displayMedia?.albumName} + + {:else if displayMedia?.albumName} + {displayMedia?.albumName} {/if} - - {:else if displayMedia?.albumName} - {displayMedia?.albumName} - {/if} +
-
- -
- -
- {formatTime(displayPosition)} - {formatTime(displayDuration)} + +
+ +
+ {formatTime(displayPosition)} + {formatTime(displayDuration)} +
-
- -
- (showSleepTimerModal = true)} - /> + +
+ (showSleepTimerModal = true)} + /> +
-
+
{/if} - (showSleepTimerModal = false)} -/> + (showSleepTimerModal = false)} /> {#if showQueue} diff --git a/src/lib/components/player/Controls.svelte b/src/lib/components/player/Controls.svelte index 84857c33..ef155635 100644 --- a/src/lib/components/player/Controls.svelte +++ b/src/lib/components/player/Controls.svelte @@ -90,8 +90,12 @@ aria-label="Sleep timer" > - + {:else} @@ -113,7 +117,9 @@ title="Shuffle" > - + @@ -192,7 +198,9 @@ > {#if repeat === "one"} - + {:else} diff --git a/src/lib/components/player/MiniPlayer.svelte b/src/lib/components/player/MiniPlayer.svelte index 2853cd9b..aeff4ca2 100644 --- a/src/lib/components/player/MiniPlayer.svelte +++ b/src/lib/components/player/MiniPlayer.svelte @@ -25,7 +25,7 @@ mergedIsPlaying, mergedPosition, mergedDuration, - shouldShowAudioMiniPlayer + shouldShowAudioMiniPlayer, } from "$lib/stores/player"; import { currentQueueItem } from "$lib/stores/queue"; import { isRemoteMode } from "$lib/stores/playbackMode"; @@ -101,9 +101,7 @@ // State machine gated visibility - only show when player is playing/paused AND media is audio const shouldShow = $derived($shouldShowAudioMiniPlayer); - const progress = $derived( - calculateProgress(displayPosition, displayDuration) - ); + const progress = $derived(calculateProgress(displayPosition, displayDuration)); function navigateToArtist(event: MouseEvent, artistId: string) { event.stopPropagation(); @@ -284,12 +282,23 @@ {#if shouldShow && displayMedia} -
+
{#if $isRemoteMode && $selectedSession} -
- - +
+ + Playing on {$selectedSession.deviceName} @@ -308,7 +317,9 @@ style="width: {progress}%" >
-
+
-
+
{#if displayMedia} - +
{/if} @@ -345,9 +358,7 @@
-
+
{truncateMiddle(displayMedia?.name, 40)}
@@ -381,11 +392,7 @@ {#if displayMedia} - + {/if} @@ -402,7 +409,9 @@ aria-label="More options" > - + @@ -417,7 +426,12 @@ class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3" > - + View Queue @@ -428,7 +442,9 @@ class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3" > - + Go to Album @@ -440,7 +456,9 @@ class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3" > - + Go to Artist @@ -451,7 +469,12 @@ class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3" > - + Add to Playlist @@ -461,7 +484,12 @@ class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3" > - + Share @@ -508,7 +536,7 @@ {#if showOverflowMenu} {/if} diff --git a/src/lib/components/player/NextEpisodePopup.svelte b/src/lib/components/player/NextEpisodePopup.svelte index 95158702..8b63d13c 100644 --- a/src/lib/components/player/NextEpisodePopup.svelte +++ b/src/lib/components/player/NextEpisodePopup.svelte @@ -7,15 +7,14 @@ initialCountdownSeconds, isCountdownActive, } from "$lib/stores/nextEpisode"; - import { - cancelAutoPlay, - watchNextManually, - } from "$lib/services/nextEpisodeService"; + import { cancelAutoPlay, watchNextManually } from "$lib/services/nextEpisodeService"; import { auth } from "$lib/stores/auth"; import CachedImage from "../common/CachedImage.svelte"; // Use series primary image for better visual consistency - const imageId = $derived($nextEpisodeItem ? ($nextEpisodeItem.seriesId || $nextEpisodeItem.id) : null); + const imageId = $derived( + $nextEpisodeItem ? $nextEpisodeItem.seriesId || $nextEpisodeItem.id : null, + ); // Format episode info (S1:E5) const episodeInfo = $derived.by(() => { @@ -75,9 +74,7 @@
-
+
{#if imageId && $nextEpisodeItem.imageId} -
- +
+
diff --git a/src/lib/components/player/Queue.svelte b/src/lib/components/player/Queue.svelte index 9074202e..f7a44fb5 100644 --- a/src/lib/components/player/Queue.svelte +++ b/src/lib/components/player/Queue.svelte @@ -17,12 +17,7 @@ onClose?: () => void; } - let { - items, - currentIndex = null, - onItemClick, - onClose, - }: Props = $props(); + let { items, currentIndex = null, onItemClick, onClose }: Props = $props(); // Add unique IDs for dnd-zone (required) interface DndItem extends MediaItem { @@ -33,7 +28,7 @@ items.map((item, index) => ({ ...item, dndId: `${item.id}-${index}`, - })) + })), ); let dragDisabled = $state(true); @@ -47,7 +42,9 @@ return `${mins}:${secs.toString().padStart(2, "0")}`; } - function handleConsider(e: CustomEvent<{ items: DndItem[]; info: { source: string; trigger: string } }>) { + function handleConsider( + e: CustomEvent<{ items: DndItem[]; info: { source: string; trigger: string } }>, + ) { const { items: newItems, info } = e.detail; // Update local state during drag if (info.source === SOURCES.KEYBOARD && info.trigger === TRIGGERS.DRAG_STOPPED) { @@ -59,8 +56,8 @@ const { items: newItems, info } = e.detail; // Find the moved item by comparing old and new positions - const oldIds = dndItems.map(i => i.dndId); - const newIds = newItems.map(i => i.dndId); + const oldIds = dndItems.map((i) => i.dndId); + const newIds = newItems.map((i) => i.dndId); // Find indices that changed let fromIndex = -1; @@ -126,7 +123,12 @@ aria-label="Close queue" > - +
@@ -151,7 +153,10 @@ {#each dndItems as item, index (item.dndId)}
  • @@ -177,7 +184,11 @@
    {#if currentIndex === index} - + {:else} @@ -201,7 +212,11 @@
    -

    +

    {truncateMiddle(item.name, 48)}

    {#if item.artists?.length} @@ -225,7 +240,12 @@ aria-label="Remove from queue" > - +
    diff --git a/src/lib/components/player/SleepTimerModal.svelte b/src/lib/components/player/SleepTimerModal.svelte index 54ff0aff..5dba317c 100644 --- a/src/lib/components/player/SleepTimerModal.svelte +++ b/src/lib/components/player/SleepTimerModal.svelte @@ -1,10 +1,6 @@ - + diff --git a/src/lib/components/search/HeaderSearch.test.ts b/src/lib/components/search/HeaderSearch.test.ts index 1508ff97..ed8d82e8 100644 --- a/src/lib/components/search/HeaderSearch.test.ts +++ b/src/lib/components/search/HeaderSearch.test.ts @@ -104,7 +104,7 @@ describe("on /search", () => { expect(goto).toHaveBeenCalledWith( "/search?q=jazzy&scope=tv", - expect.objectContaining({ replaceState: true }) + expect.objectContaining({ replaceState: true }), ); }); }); diff --git a/src/lib/components/search/SearchResults.svelte b/src/lib/components/search/SearchResults.svelte index ec023688..33708fc3 100644 --- a/src/lib/components/search/SearchResults.svelte +++ b/src/lib/components/search/SearchResults.svelte @@ -34,7 +34,9 @@ {:else if groups.length === 0}
    - +

    No results found

    diff --git a/src/lib/components/search/SearchScopeChips.svelte b/src/lib/components/search/SearchScopeChips.svelte index 077345f3..fd33ba1a 100644 --- a/src/lib/components/search/SearchScopeChips.svelte +++ b/src/lib/components/search/SearchScopeChips.svelte @@ -31,11 +31,7 @@ } -
    +
    {#each SEARCH_SCOPES as s, i (s)} diff --git a/src/lib/components/sessions/RemoteControls.svelte b/src/lib/components/sessions/RemoteControls.svelte index f2cd6d53..637f0b00 100644 --- a/src/lib/components/sessions/RemoteControls.svelte +++ b/src/lib/components/sessions/RemoteControls.svelte @@ -16,7 +16,7 @@ const supportsSeek = $derived(playState?.canSeek ?? false); const supportsNextPrevious = $derived( (session.supportedCommands ?? []).includes("NextTrack") && - (session.supportedCommands ?? []).includes("PreviousTrack") + (session.supportedCommands ?? []).includes("PreviousTrack"), ); async function handlePlayPause() { @@ -81,9 +81,9 @@ const hours = Math.floor(minutes / 60); if (hours > 0) { - return `${hours}:${String(minutes % 60).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`; + return `${hours}:${String(minutes % 60).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`; } - return `${minutes}:${String(seconds % 60).padStart(2, '0')}`; + return `${minutes}:${String(seconds % 60).padStart(2, "0")}`; } const positionPercent = $derived(() => { @@ -197,11 +197,15 @@
    {#if playState.isMuted || (playState.volumeLevel ?? 0) === 0} - + {:else if (playState.volumeLevel ?? 0) < 50} {:else} - + {/if} @@ -224,7 +228,12 @@ {:else}
    - + 0) { - return `${hours}:${String(minutes % 60).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`; + return `${hours}:${String(minutes % 60).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`; } - return `${minutes}:${String(seconds % 60).padStart(2, '0')}`; + return `${minutes}:${String(seconds % 60).padStart(2, "0")}`; } const playState = $derived(session.playState); @@ -28,11 +28,11 @@
    @@ -76,7 +76,11 @@ Paused {:else} - + Playing diff --git a/src/lib/components/sessions/SessionPickerModal.svelte b/src/lib/components/sessions/SessionPickerModal.svelte index 24c64b7a..af3a4cf6 100644 --- a/src/lib/components/sessions/SessionPickerModal.svelte +++ b/src/lib/components/sessions/SessionPickerModal.svelte @@ -19,13 +19,15 @@ let { isOpen = false, onClose, onSelectSession }: Props = $props(); // MACs currently part of any sync group, for rendering toggle state. - const groupedMacs = $derived(new Set(($lmsSync.groups ?? []).flatMap( - (g) => [g.masterMac, ...(g.slaveMacs ?? [])] - ))); + const groupedMacs = $derived( + new Set(($lmsSync.groups ?? []).flatMap((g) => [g.masterMac, ...(g.slaveMacs ?? [])])), + ); // The sync master is the LMS zone we're currently controlling. Zones can only // be fused once we have a master to fuse them into. - const masterMac = $derived(isLmsSession($selectedSession) ? macForSession($selectedSession) : null); + const masterMac = $derived( + isLmsSession($selectedSession) ? macForSession($selectedSession) : null, + ); function isZoneFused(session: Session): boolean { const mac = macForSession(session); @@ -111,11 +113,23 @@ function getSessionIcon(client: string | null | undefined): string { const clientLower = (client ?? "").toLowerCase(); - if (clientLower.includes("tv") || clientLower.includes("roku") || clientLower.includes("android tv")) { + if ( + clientLower.includes("tv") || + clientLower.includes("roku") || + clientLower.includes("android tv") + ) { return "tv"; - } else if (clientLower.includes("web") || clientLower.includes("chrome") || clientLower.includes("firefox")) { + } else if ( + clientLower.includes("web") || + clientLower.includes("chrome") || + clientLower.includes("firefox") + ) { return "web"; - } else if (clientLower.includes("mobile") || clientLower.includes("ios") || clientLower.includes("android")) { + } else if ( + clientLower.includes("mobile") || + clientLower.includes("ios") || + clientLower.includes("android") + ) { return "phone"; } return "device"; @@ -134,7 +148,9 @@
    { if (e.key === 'Escape' && onClose) onClose(); }} + onkeydown={(e) => { + if (e.key === "Escape" && onClose) onClose(); + }} role="dialog" aria-modal="true" aria-labelledby="session-picker-title" @@ -148,16 +164,19 @@ >
    -

    - Cast to Device -

    +

    Cast to Device

    @@ -168,7 +187,9 @@
    -
    +

    Searching for devices...

    @@ -177,9 +198,13 @@
    {#if $selectedSession} -
    +
    - Connected + Connected
    -
    - +
    + {#if getSessionIcon($selectedSession.client) === "tv"} - + {:else if getSessionIcon($selectedSession.client) === "web"} - + {:else if getSessionIcon($selectedSession.client) === "phone"} - + {:else} - + {/if}
    @@ -215,28 +254,41 @@ {@const lmsZone = isLmsSession(session)} {@const fused = lmsZone && isZoneFused(session)} @@ -354,7 +438,9 @@ {#if $isTransferring}
    -
    +

    Transferring playback...

    @@ -392,10 +487,14 @@ {#if $transferError} -
    +
    - + {$transferError}
    @@ -405,7 +504,12 @@ aria-label="Dismiss error" > - +
    diff --git a/src/lib/components/sessions/SessionsList.svelte b/src/lib/components/sessions/SessionsList.svelte index fe168fb4..2e08466d 100644 --- a/src/lib/components/sessions/SessionsList.svelte +++ b/src/lib/components/sessions/SessionsList.svelte @@ -48,7 +48,9 @@ {#if $sessions.isLoading && $sessions.sessions.length === 0}
    -
    +

    Loading sessions...

    @@ -75,7 +77,12 @@ {:else if !$sessions.isLoading}
    - +

    No Active Sessions

    - No controllable Jellyfin sessions found. Start playing media on another device to control it from here. + No controllable Jellyfin sessions found. Start playing media on another device to control it + from here.

    {/if} diff --git a/src/lib/components/settings/SearchGroupOrderList.svelte b/src/lib/components/settings/SearchGroupOrderList.svelte index 0002bfdf..24b93613 100644 --- a/src/lib/components/settings/SearchGroupOrderList.svelte +++ b/src/lib/components/settings/SearchGroupOrderList.svelte @@ -64,7 +64,7 @@ ondrop={(e) => onDrop(e, i)} ondragend={onDragEnd} class="flex items-center gap-3 px-3 py-2 rounded-lg bg-gray-800 border transition-colors {overIndex === - i && dragIndex !== i + i && dragIndex !== i ? 'border-[var(--color-jellyfin)]' : 'border-gray-700'} {dragIndex === i ? 'opacity-50' : ''}" > @@ -76,7 +76,9 @@ viewBox="0 0 24 24" aria-hidden="true" > - + {i + 1} @@ -89,7 +91,13 @@ aria-label="Move {GROUP_LABELS[id]} up" class="p-2 rounded text-gray-300 hover:bg-gray-700 hover:text-white transition-colors disabled:opacity-30 disabled:hover:bg-transparent" > -
  • diff --git a/src/lib/components/sync/PendingSyncList.svelte b/src/lib/components/sync/PendingSyncList.svelte index bfc42642..8bef7ea2 100644 --- a/src/lib/components/sync/PendingSyncList.svelte +++ b/src/lib/components/sync/PendingSyncList.svelte @@ -74,9 +74,9 @@

    - Changes made while the server was unreachable — watch positions and watched - flags — waiting to reach Jellyfin. They send themselves when the server comes - back. This is not the download queue; downloaded media lives under Downloads. + Changes made while the server was unreachable — watch positions and watched flags — waiting to + reach Jellyfin. They send themselves when the server comes back. This is not the download queue; + downloaded media lives under Downloads.

    {#if loading} diff --git a/src/lib/components/sync/PendingSyncModal.svelte b/src/lib/components/sync/PendingSyncModal.svelte index 8ac9cdbc..d04e41fc 100644 --- a/src/lib/components/sync/PendingSyncModal.svelte +++ b/src/lib/components/sync/PendingSyncModal.svelte @@ -23,7 +23,9 @@
    { if (e.key === "Escape") onClose(); }} + onkeydown={(e) => { + if (e.key === "Escape") onClose(); + }} role="dialog" aria-modal="true" aria-labelledby="pending-sync-title" @@ -35,16 +37,19 @@ role="none" >
    -

    - Waiting to sync -

    +

    Waiting to sync

    diff --git a/src/lib/player/adapters/adapterSelection.test.ts b/src/lib/player/adapters/adapterSelection.test.ts index 011f8def..9ba18e1e 100644 --- a/src/lib/player/adapters/adapterSelection.test.ts +++ b/src/lib/player/adapters/adapterSelection.test.ts @@ -82,14 +82,14 @@ describe("createAdapter", () => { it("requires a bridge for the HTML5 adapter", () => { expect(() => - createAdapter({ backendKind: "html5", host, experimentalNativeVideo: false }) + createAdapter({ backendKind: "html5", host, experimentalNativeVideo: false }), ).toThrow(/bridge/i); }); // The native adapter owns no DOM element, so it must not demand a bridge. it("does not require a bridge for the native adapter", () => { expect(() => - createAdapter({ backendKind: "native", host, experimentalNativeVideo: true }) + createAdapter({ backendKind: "native", host, experimentalNativeVideo: true }), ).not.toThrow(); }); }); diff --git a/src/lib/player/adapters/html5Adapter.test.ts b/src/lib/player/adapters/html5Adapter.test.ts index c44c3279..23d918bb 100644 --- a/src/lib/player/adapters/html5Adapter.test.ts +++ b/src/lib/player/adapters/html5Adapter.test.ts @@ -51,7 +51,9 @@ function makeBridge(overrides: Partial = {}): Html5ElementBr return { getElement: () => null, getSeekOffset: () => offset, - setSeekOffset: vi.fn((o: number) => { offset = o; }), + setSeekOffset: vi.fn((o: number) => { + offset = o; + }), setStreamUrl: vi.fn(), destroyHls: vi.fn(), getMediaSourceId: () => "msid-1", @@ -102,7 +104,7 @@ describe("Html5PlayerAdapter", () => { it("play() does not report an interrupted-by-pause AbortError as an error", async () => { const abort = new DOMException( "The play() request was interrupted by a call to pause().", - "AbortError" + "AbortError", ); video.play = vi.fn(async () => { throw abort; @@ -135,7 +137,7 @@ describe("Html5PlayerAdapter", () => { video.paused = false; r(); }; - }) + }), ); const first = adapter.play(); diff --git a/src/lib/player/adapters/html5Adapter.ts b/src/lib/player/adapters/html5Adapter.ts index bb11a8b0..d06bb0b4 100644 --- a/src/lib/player/adapters/html5Adapter.ts +++ b/src/lib/player/adapters/html5Adapter.ts @@ -257,11 +257,7 @@ export class Html5PlayerAdapter implements PlayerAdapter { * distinction is the caller's to act on: a missing `seeked` is cosmetic, a * missing `canplay` means the reload failed. */ - private waitForEvent( - el: HTMLVideoElement, - event: string, - timeoutMs: number - ): Promise { + private waitForEvent(el: HTMLVideoElement, event: string, timeoutMs: number): Promise { return new Promise((resolve) => { const done = (fired: boolean) => { el.removeEventListener(event, listener); diff --git a/src/lib/player/adapters/nativeAdapter.test.ts b/src/lib/player/adapters/nativeAdapter.test.ts index e8b76585..3b93db0e 100644 --- a/src/lib/player/adapters/nativeAdapter.test.ts +++ b/src/lib/player/adapters/nativeAdapter.test.ts @@ -30,8 +30,14 @@ import type { AdapterHost } from "./types"; function makeHost(): AdapterHost { return { - onState: vi.fn(), onPosition: vi.fn(), onMediaLoaded: vi.fn(), onEnded: vi.fn(), - onError: vi.fn(), onStreamUrlChanged: vi.fn(), onBuffering: vi.fn(), onReady: vi.fn(), + onState: vi.fn(), + onPosition: vi.fn(), + onMediaLoaded: vi.fn(), + onEnded: vi.fn(), + onError: vi.fn(), + onStreamUrlChanged: vi.fn(), + onBuffering: vi.fn(), + onReady: vi.fn(), }; } @@ -67,9 +73,14 @@ describe("NativePlayerAdapter", () => { it("load() seeds a resume position", async () => { await adapter.load("url", { - mediaId: "m", mediaSourceId: null, needsTranscoding: false, - initialPosition: 90, isLive: false, audioTrackIndex: null, - knownDuration: 0, subtitleTracks: [], + mediaId: "m", + mediaSourceId: null, + needsTranscoding: false, + initialPosition: 90, + isLive: false, + audioTrackIndex: null, + knownDuration: 0, + subtitleTracks: [], }); expect(adapter.getPosition()).toBe(90); }); @@ -80,18 +91,28 @@ describe("NativePlayerAdapter", () => { // adapter must actually *issue* the seek. it("load() issues the resume seek to the backend, not just records it", async () => { await adapter.load("url", { - mediaId: "m", mediaSourceId: null, needsTranscoding: false, - initialPosition: 90, isLive: false, audioTrackIndex: null, - knownDuration: 0, subtitleTracks: [], + mediaId: "m", + mediaSourceId: null, + needsTranscoding: false, + initialPosition: 90, + isLive: false, + audioTrackIndex: null, + knownDuration: 0, + subtitleTracks: [], }); expect(playerSeek).toHaveBeenCalledWith(90); }); it("load() does not seek when starting from the beginning", async () => { await adapter.load("url", { - mediaId: "m", mediaSourceId: null, needsTranscoding: false, - initialPosition: 0, isLive: false, audioTrackIndex: null, - knownDuration: 0, subtitleTracks: [], + mediaId: "m", + mediaSourceId: null, + needsTranscoding: false, + initialPosition: 0, + isLive: false, + audioTrackIndex: null, + knownDuration: 0, + subtitleTracks: [], }); expect(playerSeek).not.toHaveBeenCalled(); }); @@ -100,9 +121,14 @@ describe("NativePlayerAdapter", () => { // no-op and at worst knocks the HLS window off its live edge. it("load() never seeks a live stream", async () => { await adapter.load("url", { - mediaId: "m", mediaSourceId: null, needsTranscoding: false, - initialPosition: 90, isLive: true, audioTrackIndex: null, - knownDuration: 0, subtitleTracks: [], + mediaId: "m", + mediaSourceId: null, + needsTranscoding: false, + initialPosition: 90, + isLive: true, + audioTrackIndex: null, + knownDuration: 0, + subtitleTracks: [], }); expect(playerSeek).not.toHaveBeenCalled(); }); diff --git a/src/lib/player/adapters/nativeAdapter.ts b/src/lib/player/adapters/nativeAdapter.ts index ba0d0719..248f9cbb 100644 --- a/src/lib/player/adapters/nativeAdapter.ts +++ b/src/lib/player/adapters/nativeAdapter.ts @@ -101,7 +101,7 @@ export class NativePlayerAdapter implements PlayerAdapter { } async selectSubtitle(streamIndex: number | null, arrayIndex?: number): Promise { - const indexToUse = streamIndex === null ? null : arrayIndex ?? streamIndex; + const indexToUse = streamIndex === null ? null : (arrayIndex ?? streamIndex); await commands.playerSetSubtitleTrack(indexToUse); } diff --git a/src/lib/player/adapters/rustReportHost.ts b/src/lib/player/adapters/rustReportHost.ts index d0971054..84504e78 100644 --- a/src/lib/player/adapters/rustReportHost.ts +++ b/src/lib/player/adapters/rustReportHost.ts @@ -35,7 +35,7 @@ let lastPositionReport = 0; export async function reportState( state: "playing" | "paused" | "loading" | "stopped" | "idle", - mediaId: string | null + mediaId: string | null, ): Promise { try { await commands.playerReportState(state, mediaId); @@ -47,7 +47,7 @@ export async function reportState( export async function reportPosition( position: number, duration: number, - { force = false }: ReportPositionOptions = {} + { force = false }: ReportPositionOptions = {}, ): Promise { const now = Date.now(); if (!force && now - lastPositionReport < POSITION_REPORT_INTERVAL_MS) { @@ -81,7 +81,9 @@ export function resetReporting(): void { */ export function createRustReportHost( mediaId: string, - view: Partial> = {} + view: Partial< + Pick + > = {}, ): AdapterHost { return { onState: (state) => void reportState(state, mediaId), diff --git a/src/lib/player/index.ts b/src/lib/player/index.ts index 7581bb7f..03824bcf 100644 --- a/src/lib/player/index.ts +++ b/src/lib/player/index.ts @@ -136,7 +136,7 @@ async function seek(positionSeconds: number) { async function seekVideo( positionSeconds: number, mediaSourceId: string | null, - audioTrackIndex: number | null + audioTrackIndex: number | null, ): Promise { const adapter = activeAdapter; if (!adapter) { @@ -148,7 +148,7 @@ async function seekVideo( positionSeconds, mediaSourceId, audioTrackIndex, - adapter.kind === "html5" + adapter.kind === "html5", )) as any; // Serde keeps these snake_case (only the "strategy" tag is camelCase). if (response.strategy === "reloadStream") { @@ -169,7 +169,7 @@ async function switchAudioTrack( streamIndex: number, arrayIndex: number, currentPosition: number | null, - mediaSourceId: string | null + mediaSourceId: string | null, ): Promise { const adapter = activeAdapter; if (!adapter) return; @@ -179,7 +179,7 @@ async function switchAudioTrack( arrayIndex, adapter.kind === "html5", currentPosition, - mediaSourceId + mediaSourceId, )) as any; if (response.strategy === "reloadStream") { await adapter.reloadSource(response.new_url!, response.position!); @@ -198,7 +198,7 @@ async function setStreamQuality( quality: StreamingQuality, currentPosition: number | null, mediaSourceId: string | null, - audioTrackIndex: number | null + audioTrackIndex: number | null, ): Promise { const adapter = activeAdapter; if (!adapter) return; @@ -208,7 +208,7 @@ async function setStreamQuality( adapter.kind === "html5", currentPosition, mediaSourceId, - audioTrackIndex + audioTrackIndex, )) as any; // Serde keeps these snake_case (only the "strategy" tag is camelCase). if (response.strategy === "reloadStream") { @@ -305,10 +305,7 @@ async function addTrackById(trackId: string, position: "next" | "end" = "end") { } /** Add multiple tracks to the queue by ID. */ -async function addTracksByIds( - trackIds: string[], - position: "next" | "end" = "end" -) { +async function addTracksByIds(trackIds: string[], position: "next" | "end" = "end") { await commands.playerAddTracksByIds(requireHandle(), { trackIds, position }); } diff --git a/src/lib/player/localSource.test.ts b/src/lib/player/localSource.test.ts index ea75aa3d..dd542722 100644 --- a/src/lib/player/localSource.test.ts +++ b/src/lib/player/localSource.test.ts @@ -19,7 +19,7 @@ describe("downloadedFilePath", () => { // Rows only hold a relative path before the worker completes them, but a // half-migrated database can still carry one. expect(downloadedFilePath("/var/data/jellytau", "videos/film.mp4")).toBe( - "/var/data/jellytau/videos/film.mp4" + "/var/data/jellytau/videos/film.mp4", ); }); diff --git a/src/lib/services/favorites.test.ts b/src/lib/services/favorites.test.ts index e94480cb..f6bc2d09 100644 --- a/src/lib/services/favorites.test.ts +++ b/src/lib/services/favorites.test.ts @@ -51,9 +51,7 @@ describe("favorites service", () => { await toggleFavorite("item-123", false); - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "storage_toggle_favorite" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "storage_toggle_favorite"); expect(call).toBeDefined(); expect(call![1]).toHaveProperty("itemId", "item-123"); expect(call![1]).toHaveProperty("isFavorite", true); @@ -65,9 +63,7 @@ describe("favorites service", () => { await toggleFavorite("item-123", false); - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "storage_toggle_favorite" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "storage_toggle_favorite"); expect(call![1]).toHaveProperty("userId", "user-123"); }); @@ -105,9 +101,7 @@ describe("favorites service", () => { await toggleFavorite("item-123", false); - const markSyncedCall = invokeSpy.mock.calls.find( - (c) => c[0] === "storage_mark_synced" - ); + const markSyncedCall = invokeSpy.mock.calls.find((c) => c[0] === "storage_mark_synced"); expect(markSyncedCall).toBeDefined(); expect(markSyncedCall![1]).toHaveProperty("itemId", "item-123"); }); @@ -117,9 +111,7 @@ describe("favorites service", () => { const authModule = vi.mocked(auth); authModule.getUserId = vi.fn(() => null); - await expect(toggleFavorite("item-123", false)).rejects.toThrow( - "Not authenticated" - ); + await expect(toggleFavorite("item-123", false)).rejects.toThrow("Not authenticated"); }); it("should handle server sync failure gracefully", async () => { diff --git a/src/lib/services/favorites.ts b/src/lib/services/favorites.ts index 5d7fe11a..dbbd4eeb 100644 --- a/src/lib/services/favorites.ts +++ b/src/lib/services/favorites.ts @@ -23,10 +23,7 @@ const log = createLogger("Favorites"); * @returns The new favorite status * @throws Error if not authenticated or database update fails */ -export async function toggleFavorite( - itemId: string, - currentIsFavorite: boolean -): Promise { +export async function toggleFavorite(itemId: string, currentIsFavorite: boolean): Promise { const userId = auth.getUserId(); if (!userId) { throw new Error("Not authenticated"); diff --git a/src/lib/services/imageCache.test.ts b/src/lib/services/imageCache.test.ts index 3e7ab4c8..b30c6210 100644 --- a/src/lib/services/imageCache.test.ts +++ b/src/lib/services/imageCache.test.ts @@ -52,34 +52,22 @@ describe("image cache service", () => { describe("getCachedImageUrl", () => { it("should build server URL with default image type", async () => { - const url = await getCachedImageUrl( - "http://server.local:8096", - "item-123" - ); + const url = await getCachedImageUrl("http://server.local:8096", "item-123"); expect(url).toContain("http://server.local:8096/Items/item-123/Images/Primary"); }); it("should build server URL with custom image type", async () => { - const url = await getCachedImageUrl( - "http://server.local:8096", - "item-123", - "Backdrop" - ); + const url = await getCachedImageUrl("http://server.local:8096", "item-123", "Backdrop"); expect(url).toContain("Backdrop"); }); it("should include image options in URL", async () => { - const url = await getCachedImageUrl( - "http://server.local:8096", - "item-123", - "Primary", - { - maxWidth: 300, - maxHeight: 400, - quality: 90, - tag: "abc123", - } - ); + const url = await getCachedImageUrl("http://server.local:8096", "item-123", "Primary", { + maxWidth: 300, + maxHeight: 400, + quality: 90, + tag: "abc123", + }); expect(url).toContain("maxWidth=300"); expect(url).toContain("maxHeight=400"); expect(url).toContain("quality=90"); @@ -92,9 +80,7 @@ describe("image cache service", () => { await getCachedImageUrl("http://server.local:8096", "item-123"); - const saveCall = invokeSpy.mock.calls.find( - (call) => call[0] === "thumbnail_save" - ); + const saveCall = invokeSpy.mock.calls.find((call) => call[0] === "thumbnail_save"); expect(saveCall).toBeDefined(); expect(saveCall![1]).toHaveProperty("itemId", "item-123"); expect(saveCall![1]).toHaveProperty("imageType", "Primary"); @@ -117,9 +103,7 @@ describe("image cache service", () => { const { invoke } = await import("@tauri-apps/api/core"); const invokeSpy = vi.mocked(invoke); - const setLimitCall = invokeSpy.mock.calls.find( - (call) => call[0] === "thumbnail_set_limit" - ); + const setLimitCall = invokeSpy.mock.calls.find((call) => call[0] === "thumbnail_set_limit"); expect(setLimitCall).toBeDefined(); expect(setLimitCall![1]).toHaveProperty("limitBytes", limit); }); @@ -139,9 +123,7 @@ describe("image cache service", () => { const { invoke } = await import("@tauri-apps/api/core"); const invokeSpy = vi.mocked(invoke); - const deleteCall = invokeSpy.mock.calls.find( - (call) => call[0] === "thumbnail_delete_item" - ); + const deleteCall = invokeSpy.mock.calls.find((call) => call[0] === "thumbnail_delete_item"); expect(deleteCall).toBeDefined(); expect(deleteCall![1]).toHaveProperty("itemId", "item-456"); }); diff --git a/src/lib/services/imageCache.ts b/src/lib/services/imageCache.ts index efb60063..381f888a 100644 --- a/src/lib/services/imageCache.ts +++ b/src/lib/services/imageCache.ts @@ -35,7 +35,7 @@ export async function getCachedImageUrl( maxHeight?: number; quality?: number; tag?: string; - } = {} + } = {}, ): Promise { const tag = options.tag || "default"; @@ -114,8 +114,7 @@ export async function deleteItemCache(itemId: string): Promise { export function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - if (bytes < 1024 * 1024 * 1024) - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; } diff --git a/src/lib/services/networkType.test.ts b/src/lib/services/networkType.test.ts index 157ef4a2..17e984a0 100644 --- a/src/lib/services/networkType.test.ts +++ b/src/lib/services/networkType.test.ts @@ -4,153 +4,153 @@ * TRACES: UR-053 | DR-074 | UT-066 */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; const setNetworkState = vi.fn(); const getDownloadsAllowed = vi.fn(); -vi.mock('$lib/api/bindings', () => ({ - commands: { - setNetworkState: (...args: unknown[]) => setNetworkState(...args), - getDownloadsAllowed: () => getDownloadsAllowed() - } +vi.mock("$lib/api/bindings", () => ({ + commands: { + setNetworkState: (...args: unknown[]) => setNetworkState(...args), + getDownloadsAllowed: () => getDownloadsAllowed(), + }, })); import { - isNetworkDetectionSupported, - reportNetworkState, - startNetworkReporting, - areDownloadsAllowed -} from './networkType'; + isNetworkDetectionSupported, + reportNetworkState, + startNetworkReporting, + areDownloadsAllowed, +} from "./networkType"; /** Install a fake Android bridge on window. */ function installBridge(overrides: Partial> = {}) { - const bridge = { - currentType: vi.fn(() => 'wifi'), - isUnmetered: vi.fn(() => true), - isAcceptable: vi.fn(() => true), - isSupported: vi.fn(() => true), - ...overrides - }; - (window as unknown as Record).AndroidNetworkType = bridge; - return bridge; + const bridge = { + currentType: vi.fn(() => "wifi"), + isUnmetered: vi.fn(() => true), + isAcceptable: vi.fn(() => true), + isSupported: vi.fn(() => true), + ...overrides, + }; + (window as unknown as Record).AndroidNetworkType = bridge; + return bridge; } function removeBridge() { - delete (window as unknown as Record).AndroidNetworkType; + delete (window as unknown as Record).AndroidNetworkType; } -describe('networkType service', () => { - beforeEach(() => { - vi.clearAllMocks(); - setNetworkState.mockResolvedValue(null); - getDownloadsAllowed.mockResolvedValue(true); - removeBridge(); - }); +describe("networkType service", () => { + beforeEach(() => { + vi.clearAllMocks(); + setNetworkState.mockResolvedValue(null); + getDownloadsAllowed.mockResolvedValue(true); + removeBridge(); + }); - afterEach(() => { - removeBridge(); - }); + afterEach(() => { + removeBridge(); + }); - describe('isNetworkDetectionSupported', () => { - it('is false with no Android bridge (desktop)', () => { - expect(isNetworkDetectionSupported()).toBe(false); - }); + describe("isNetworkDetectionSupported", () => { + it("is false with no Android bridge (desktop)", () => { + expect(isNetworkDetectionSupported()).toBe(false); + }); - it('is true when the Android bridge is present', () => { - installBridge(); - expect(isNetworkDetectionSupported()).toBe(true); - }); + it("is true when the Android bridge is present", () => { + installBridge(); + expect(isNetworkDetectionSupported()).toBe(true); + }); - it('is false when the bridge throws', () => { - installBridge({ - isSupported: vi.fn(() => { - throw new Error('bridge exploded'); - }) - }); - expect(isNetworkDetectionSupported()).toBe(false); - }); - }); + it("is false when the bridge throws", () => { + installBridge({ + isSupported: vi.fn(() => { + throw new Error("bridge exploded"); + }), + }); + expect(isNetworkDetectionSupported()).toBe(false); + }); + }); - describe('reportNetworkState', () => { - it('does not call the backend on desktop', async () => { - await reportNetworkState(); - expect(setNetworkState).not.toHaveBeenCalled(); - }); + describe("reportNetworkState", () => { + it("does not call the backend on desktop", async () => { + await reportNetworkState(); + expect(setNetworkState).not.toHaveBeenCalled(); + }); - it('reports transport and metered-ness from the bridge', async () => { - installBridge({ - currentType: vi.fn(() => 'cellular'), - isUnmetered: vi.fn(() => false) - }); + it("reports transport and metered-ness from the bridge", async () => { + installBridge({ + currentType: vi.fn(() => "cellular"), + isUnmetered: vi.fn(() => false), + }); - await reportNetworkState(); + await reportNetworkState(); - expect(setNetworkState).toHaveBeenCalledWith({ - networkType: 'cellular', - unmetered: false - }); - }); + expect(setNetworkState).toHaveBeenCalledWith({ + networkType: "cellular", + unmetered: false, + }); + }); - it('reports metered WiFi as WiFi-but-metered, not as unmetered', async () => { - // A phone hotspot: WiFi transport, metered connection. - installBridge({ - currentType: vi.fn(() => 'wifi'), - isUnmetered: vi.fn(() => false) - }); + it("reports metered WiFi as WiFi-but-metered, not as unmetered", async () => { + // A phone hotspot: WiFi transport, metered connection. + installBridge({ + currentType: vi.fn(() => "wifi"), + isUnmetered: vi.fn(() => false), + }); - await reportNetworkState(); + await reportNetworkState(); - expect(setNetworkState).toHaveBeenCalledWith({ - networkType: 'wifi', - unmetered: false - }); - }); + expect(setNetworkState).toHaveBeenCalledWith({ + networkType: "wifi", + unmetered: false, + }); + }); - it('swallows backend errors so the UI never breaks', async () => { - installBridge(); - setNetworkState.mockRejectedValue(new Error('ipc down')); + it("swallows backend errors so the UI never breaks", async () => { + installBridge(); + setNetworkState.mockRejectedValue(new Error("ipc down")); - await expect(reportNetworkState()).resolves.toBeUndefined(); - }); - }); + await expect(reportNetworkState()).resolves.toBeUndefined(); + }); + }); - describe('startNetworkReporting', () => { - it('reports once immediately and again on network change', async () => { - installBridge(); + describe("startNetworkReporting", () => { + it("reports once immediately and again on network change", async () => { + installBridge(); - const stop = startNetworkReporting(); - await vi.waitFor(() => expect(setNetworkState).toHaveBeenCalledTimes(1)); + const stop = startNetworkReporting(); + await vi.waitFor(() => expect(setNetworkState).toHaveBeenCalledTimes(1)); - window.dispatchEvent(new CustomEvent('jellytau-network-changed')); - await vi.waitFor(() => expect(setNetworkState).toHaveBeenCalledTimes(2)); + window.dispatchEvent(new CustomEvent("jellytau-network-changed")); + await vi.waitFor(() => expect(setNetworkState).toHaveBeenCalledTimes(2)); - stop(); - }); + stop(); + }); - it('stops reporting after teardown', async () => { - installBridge(); + it("stops reporting after teardown", async () => { + installBridge(); - const stop = startNetworkReporting(); - await vi.waitFor(() => expect(setNetworkState).toHaveBeenCalledTimes(1)); - stop(); + const stop = startNetworkReporting(); + await vi.waitFor(() => expect(setNetworkState).toHaveBeenCalledTimes(1)); + stop(); - window.dispatchEvent(new CustomEvent('jellytau-network-changed')); - // Give any stray listener a chance to fire before asserting. - await new Promise((resolve) => setTimeout(resolve, 10)); - expect(setNetworkState).toHaveBeenCalledTimes(1); - }); - }); + window.dispatchEvent(new CustomEvent("jellytau-network-changed")); + // Give any stray listener a chance to fire before asserting. + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(setNetworkState).toHaveBeenCalledTimes(1); + }); + }); - describe('areDownloadsAllowed', () => { - it('returns the backend verdict', async () => { - getDownloadsAllowed.mockResolvedValue(false); - expect(await areDownloadsAllowed()).toBe(false); - }); + describe("areDownloadsAllowed", () => { + it("returns the backend verdict", async () => { + getDownloadsAllowed.mockResolvedValue(false); + expect(await areDownloadsAllowed()).toBe(false); + }); - it('fails open if the query errors, so the UI never falsely blames WiFi', async () => { - getDownloadsAllowed.mockRejectedValue(new Error('ipc down')); - expect(await areDownloadsAllowed()).toBe(true); - }); - }); + it("fails open if the query errors, so the UI never falsely blames WiFi", async () => { + getDownloadsAllowed.mockRejectedValue(new Error("ipc down")); + expect(await areDownloadsAllowed()).toBe(true); + }); + }); }); diff --git a/src/lib/services/networkType.ts b/src/lib/services/networkType.ts index e44d9bb8..ec1d0eb6 100644 --- a/src/lib/services/networkType.ts +++ b/src/lib/services/networkType.ts @@ -10,41 +10,41 @@ * TRACES: UR-053 | DR-074 */ -import { commands } from '$lib/api/bindings'; -import type { NetworkType } from '$lib/api/bindings'; -import { createLogger } from '$lib/utils/logger'; +import { commands } from "$lib/api/bindings"; +import type { NetworkType } from "$lib/api/bindings"; +import { createLogger } from "$lib/utils/logger"; -const log = createLogger('NetworkType'); +const log = createLogger("NetworkType"); /** The Android bridge, present only in the Android WebView. */ interface AndroidNetworkTypeBridge { - currentType(): NetworkType; - isUnmetered(): boolean; - isAcceptable(wifiOnly: boolean): boolean; - isSupported(): boolean; + currentType(): NetworkType; + isUnmetered(): boolean; + isAcceptable(wifiOnly: boolean): boolean; + isSupported(): boolean; } declare global { - interface Window { - AndroidNetworkType?: AndroidNetworkTypeBridge; - } + interface Window { + AndroidNetworkType?: AndroidNetworkTypeBridge; + } } /** Event dispatched into the WebView by MainActivity on any network change. */ -const NETWORK_CHANGED_EVENT = 'jellytau-network-changed'; +const NETWORK_CHANGED_EVENT = "jellytau-network-changed"; function bridge(): AndroidNetworkTypeBridge | undefined { - if (typeof window === 'undefined') return undefined; - return window.AndroidNetworkType; + if (typeof window === "undefined") return undefined; + return window.AndroidNetworkType; } /** Whether native network detection is available (Android only). */ export function isNetworkDetectionSupported(): boolean { - try { - return bridge()?.isSupported() ?? false; - } catch { - return false; - } + try { + return bridge()?.isSupported() ?? false; + } catch { + return false; + } } /** @@ -54,19 +54,19 @@ export function isNetworkDetectionSupported(): boolean { * means downloads run unconditionally. */ export async function reportNetworkState(): Promise { - const android = bridge(); - if (!android) return; + const android = bridge(); + if (!android) return; - try { - const networkType = android.currentType(); - const unmetered = android.isUnmetered(); + try { + const networkType = android.currentType(); + const unmetered = android.isUnmetered(); - await commands.setNetworkState({ networkType, unmetered }); - } catch (error) { - // Never let network reporting break the UI — the gate fails closed on - // the Rust side, so a missed report at worst delays a queued download. - log.warn('Failed to report network state:', error); - } + await commands.setNetworkState({ networkType, unmetered }); + } catch (error) { + // Never let network reporting break the UI — the gate fails closed on + // the Rust side, so a missed report at worst delays a queued download. + log.warn("Failed to report network state:", error); + } } /** @@ -77,25 +77,25 @@ export async function reportNetworkState(): Promise { * Returns a teardown function. */ export function startNetworkReporting(): () => void { - if (typeof window === 'undefined') return () => {}; + if (typeof window === "undefined") return () => {}; - void reportNetworkState(); + void reportNetworkState(); - const onChange = () => { - void reportNetworkState(); - }; + const onChange = () => { + void reportNetworkState(); + }; - window.addEventListener(NETWORK_CHANGED_EVENT, onChange); - // The browser's own online/offline events are a useful extra nudge on - // desktop-style webviews where the native callback may not fire. - window.addEventListener('online', onChange); - window.addEventListener('offline', onChange); + window.addEventListener(NETWORK_CHANGED_EVENT, onChange); + // The browser's own online/offline events are a useful extra nudge on + // desktop-style webviews where the native callback may not fire. + window.addEventListener("online", onChange); + window.addEventListener("offline", onChange); - return () => { - window.removeEventListener(NETWORK_CHANGED_EVENT, onChange); - window.removeEventListener('online', onChange); - window.removeEventListener('offline', onChange); - }; + return () => { + window.removeEventListener(NETWORK_CHANGED_EVENT, onChange); + window.removeEventListener("online", onChange); + window.removeEventListener("offline", onChange); + }; } /** @@ -103,10 +103,10 @@ export function startNetworkReporting(): () => void { * downloads UI to show "Waiting for WiFi" instead of a stuck-looking queue. */ export async function areDownloadsAllowed(): Promise { - try { - return await commands.getDownloadsAllowed(); - } catch (error) { - log.warn('Failed to query download gate:', error); - return true; - } + try { + return await commands.getDownloadsAllowed(); + } catch (error) { + log.warn("Failed to query download gate:", error); + return true; + } } diff --git a/src/lib/services/offlineCatalog.reload.test.ts b/src/lib/services/offlineCatalog.reload.test.ts index 45398ac1..65f1f448 100644 --- a/src/lib/services/offlineCatalog.reload.test.ts +++ b/src/lib/services/offlineCatalog.reload.test.ts @@ -44,7 +44,7 @@ const h = vi.hoisted(() => { // issued" and "command accepted". pending: [] as Array<() => void>, setShowServerCatalog: vi.fn( - () => new Promise((resolve) => h.pending.push(() => resolve())) + () => new Promise((resolve) => h.pending.push(() => resolve())), ), }; }); @@ -77,7 +77,7 @@ describe("offline filter refetch signal (DR-143)", () => { beforeEach(() => { h.setShowServerCatalog.mockReset(); h.setShowServerCatalog.mockImplementation( - () => new Promise((resolve) => h.pending.push(() => resolve())) + () => new Promise((resolve) => h.pending.push(() => resolve())), ); h.pending.length = 0; h.isConnectedStore.reset(true); diff --git a/src/lib/services/offlineCatalog.ts b/src/lib/services/offlineCatalog.ts index a8917b99..69ffaa92 100644 --- a/src/lib/services/offlineCatalog.ts +++ b/src/lib/services/offlineCatalog.ts @@ -118,9 +118,7 @@ export async function syncCatalog(): Promise { syncInProgress = true; try { const result = await commands.syncFullCatalog(handle); - log.info( - `Synced ${result.itemsCached} items (${result.librariesFailed} libraries failed)` - ); + log.info(`Synced ${result.itemsCached} items (${result.librariesFailed} libraries failed)`); await refreshSyncStatus(); } catch (err) { log.warn("Full catalog sync failed:", err); @@ -139,9 +137,7 @@ export async function resumeQueued(): Promise { try { const result = await commands.resumeQueuedDownloads(handle); if (result.resolved > 0 || result.failed > 0) { - log.info( - `Resumed queued downloads: ${result.resolved} resolved, ${result.failed} failed` - ); + log.info(`Resumed queued downloads: ${result.resolved} resolved, ${result.failed} failed`); } } catch (err) { log.warn("Failed to resume queued downloads:", err); diff --git a/src/lib/services/pendingSync.logic.test.ts b/src/lib/services/pendingSync.logic.test.ts index 1061cdd9..a591ac43 100644 --- a/src/lib/services/pendingSync.logic.test.ts +++ b/src/lib/services/pendingSync.logic.test.ts @@ -39,9 +39,7 @@ describe("pending sync row description", () => { }); it("names the item when the catalog knows it, and falls back to the id", () => { - expect(describeSubject(row({ itemName: "The Expanse S01E01" }))).toBe( - "The Expanse S01E01", - ); + expect(describeSubject(row({ itemName: "The Expanse S01E01" }))).toBe("The Expanse S01E01"); expect(describeSubject(row({ itemName: null, itemId: "abc123" }))).toBe("abc123"); expect(describeSubject(row({ itemName: null, itemId: null }))).toBe("Unknown item"); }); diff --git a/src/lib/services/playbackReporting.test.ts b/src/lib/services/playbackReporting.test.ts index bfe5b420..b875e745 100644 --- a/src/lib/services/playbackReporting.test.ts +++ b/src/lib/services/playbackReporting.test.ts @@ -47,7 +47,7 @@ describe("playback reporting service", () => { it("should accept optional contextType and contextId", async () => { await expect( - reportPlaybackStart("item-123", 0, "container", "container-456") + reportPlaybackStart("item-123", 0, "container", "container-456"), ).resolves.toBeUndefined(); }); @@ -57,9 +57,7 @@ describe("playback reporting service", () => { await reportPlaybackStart("item-123", 60); - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "storage_update_playback_context" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "storage_update_playback_context"); expect(call).toBeDefined(); expect(call![1]).toHaveProperty("positionMs", 60000); // 60 seconds }); @@ -70,9 +68,7 @@ describe("playback reporting service", () => { await reportPlaybackStart("item-123", 30); - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "storage_update_playback_context" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "storage_update_playback_context"); expect(call![1]).toHaveProperty("contextType", "single"); expect(call![1]).toHaveProperty("contextId", null); }); @@ -83,9 +79,7 @@ describe("playback reporting service", () => { await reportPlaybackStart("item-123", 0); - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "storage_update_playback_context" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "storage_update_playback_context"); expect(call![1]).toHaveProperty("userId", "user-123"); }); }); @@ -105,9 +99,7 @@ describe("playback reporting service", () => { await reportPlaybackProgress("item-123", 30); - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "storage_update_playback_progress" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "storage_update_playback_progress"); expect(call).toBeDefined(); expect(call![1]).toHaveProperty("itemId", "item-123"); }); @@ -118,9 +110,7 @@ describe("playback reporting service", () => { await reportPlaybackProgress("item-123", 45); - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "storage_update_playback_progress" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "storage_update_playback_progress"); expect(call![1]).toHaveProperty("positionMs", 45000); // 45 seconds }); }); @@ -136,9 +126,7 @@ describe("playback reporting service", () => { await reportPlaybackStopped("item-123", 120); - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "storage_update_playback_progress" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "storage_update_playback_progress"); expect(call).toBeDefined(); }); @@ -167,7 +155,7 @@ describe("playback reporting service", () => { expect(mockRepo.reportPlaybackStopped).toHaveBeenCalledWith( "item-123", - 90000 // 90 seconds in ms + 90000, // 90 seconds in ms ); }); @@ -192,9 +180,7 @@ describe("playback reporting service", () => { await markAsPlayed("item-123"); - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "storage_mark_played" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "storage_mark_played"); expect(call).toBeDefined(); expect(call![1]).toHaveProperty("itemId", "item-123"); }); @@ -215,10 +201,7 @@ describe("playback reporting service", () => { await markAsPlayed("item-123"); expect(mockRepo.getItem).toHaveBeenCalledWith("item-123"); - expect(mockRepo.reportPlaybackStopped).toHaveBeenCalledWith( - "item-123", - 10000 - ); + expect(mockRepo.reportPlaybackStopped).toHaveBeenCalledWith("item-123", 10000); }); it("should handle items without durationMs", async () => { diff --git a/src/lib/services/playbackReporting.ts b/src/lib/services/playbackReporting.ts index d59fd834..2260fad1 100644 --- a/src/lib/services/playbackReporting.ts +++ b/src/lib/services/playbackReporting.ts @@ -30,7 +30,7 @@ export async function reportPlaybackStart( itemId: string, positionSeconds: number, contextType: "container" | "single" = "single", - contextId: string | null = null + contextId: string | null = null, ): Promise { const positionMs = Math.floor(positionSeconds * 1000); const userId = auth.getUserId(); @@ -42,13 +42,19 @@ export async function reportPlaybackStart( positionSeconds, "context:", contextType, - contextId + contextId, ); // Update local DB with context (always works, even offline) if (userId) { try { - await commands.storageUpdatePlaybackContext(userId, itemId, positionMs, contextType, contextId); + await commands.storageUpdatePlaybackContext( + userId, + itemId, + positionMs, + contextType, + contextId, + ); } catch (e) { log.error("Failed to update playback context:", e); } @@ -72,7 +78,7 @@ export async function reportPlaybackStart( export async function reportPlaybackProgress( itemId: string, positionSeconds: number, - _isPaused = false + _isPaused = false, ): Promise { const positionMs = Math.floor(positionSeconds * 1000); const userId = auth.getUserId(); @@ -100,7 +106,10 @@ export async function reportPlaybackProgress( * * TRACES: UR-005, UR-025 | DR-028 */ -export async function reportPlaybackStopped(itemId: string, positionSeconds: number): Promise { +export async function reportPlaybackStopped( + itemId: string, + positionSeconds: number, +): Promise { const positionMs = Math.floor(positionSeconds * 1000); const userId = auth.getUserId(); diff --git a/src/lib/services/playerEvents.regression.test.ts b/src/lib/services/playerEvents.regression.test.ts index 9cf17c5c..f900051b 100644 --- a/src/lib/services/playerEvents.regression.test.ts +++ b/src/lib/services/playerEvents.regression.test.ts @@ -168,7 +168,7 @@ describe("Player Events — recoverable errors get one chance before stopping", it("does not stop the player when Rust re-opened the stream", async () => { const { invoke } = await import("@tauri-apps/api/core"); vi.mocked(invoke).mockImplementation(async (cmd: string) => - cmd === "player_recover_stream" ? true : null + cmd === "player_recover_stream" ? true : null, ); const { initPlayerEvents } = await import("./playerEvents"); @@ -187,7 +187,7 @@ describe("Player Events — recoverable errors get one chance before stopping", it("stops the player when recovery declines", async () => { const { invoke } = await import("@tauri-apps/api/core"); vi.mocked(invoke).mockImplementation(async (cmd: string) => - cmd === "player_recover_stream" ? false : null + cmd === "player_recover_stream" ? false : null, ); const { initPlayerEvents } = await import("./playerEvents"); diff --git a/src/lib/services/playerEvents.test.ts b/src/lib/services/playerEvents.test.ts index 4ac16132..6664f64f 100644 --- a/src/lib/services/playerEvents.test.ts +++ b/src/lib/services/playerEvents.test.ts @@ -102,7 +102,7 @@ describe("Player Events Service", () => { // console.error is called with: ("Failed to initialize player events:", Error) expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining("Failed to initialize player events"), - expect.any(Error) + expect.any(Error), ); }); }); diff --git a/src/lib/services/playerEvents.ts b/src/lib/services/playerEvents.ts index 662c39a7..b1458960 100644 --- a/src/lib/services/playerEvents.ts +++ b/src/lib/services/playerEvents.ts @@ -139,7 +139,7 @@ function handlePlayerEvent(event: PlayerStatusEvent): void { event.current_episode, event.next_episode, event.countdown_seconds, - event.auto_advance + event.auto_advance, ); break; @@ -377,7 +377,7 @@ function handleShowNextEpisodePopup( currentEpisodeItem: MediaItem, nextEpisodeItem: MediaItem, countdownSeconds: number, - autoAdvance: boolean + autoAdvance: boolean, ): void { // Update next episode store to show popup nextEpisode.showPopup(currentEpisodeItem, nextEpisodeItem, countdownSeconds, autoAdvance); diff --git a/src/lib/services/preload.test.ts b/src/lib/services/preload.test.ts index e26c1b1f..de66bffb 100644 --- a/src/lib/services/preload.test.ts +++ b/src/lib/services/preload.test.ts @@ -70,9 +70,7 @@ describe("preload service", () => { await preloadUpcomingTracks(); - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "player_preload_upcoming" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "player_preload_upcoming"); expect(call).toBeDefined(); }); @@ -82,9 +80,7 @@ describe("preload service", () => { await preloadUpcomingTracks(); - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "player_preload_upcoming" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "player_preload_upcoming"); expect(call![1]).toHaveProperty("userId", "user-123"); }); @@ -94,9 +90,7 @@ describe("preload service", () => { await preloadUpcomingTracks({ userId: "user-456" }); - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "player_preload_upcoming" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "player_preload_upcoming"); expect(call![1]).toHaveProperty("userId", "user-456"); }); @@ -110,9 +104,7 @@ describe("preload service", () => { await preloadUpcomingTracks(); - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "player_preload_upcoming" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "player_preload_upcoming"); expect(call).toBeUndefined(); }); @@ -141,9 +133,7 @@ describe("preload service", () => { await preloadUpcomingTracks({ debug: true, userId: "user-789" }); - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "player_preload_upcoming" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "player_preload_upcoming"); expect(call![1]).toHaveProperty("userId", "user-789"); }); }); @@ -165,9 +155,7 @@ describe("preload service", () => { const config = makeConfig({ queuePrecacheEnabled: true }); await updateCacheConfig(config); - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "player_set_cache_config" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "player_set_cache_config"); expect(call).toBeDefined(); expect(call![1]).toHaveProperty("config", config); }); @@ -203,9 +191,7 @@ describe("preload service", () => { await getCacheConfig(); - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "player_get_cache_config" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "player_get_cache_config"); expect(call).toBeDefined(); }); diff --git a/src/lib/services/preload.ts b/src/lib/services/preload.ts index 745c8b02..9e7df9bc 100644 --- a/src/lib/services/preload.ts +++ b/src/lib/services/preload.ts @@ -5,18 +5,18 @@ * TRACES: UR-004, UR-011 | DR-006, DR-015 */ -import { commands } from '$lib/api/bindings'; -import type { CacheConfig } from '$lib/api/bindings'; -import { auth } from '$lib/stores/auth'; -import { createLogger } from '$lib/utils/logger'; +import { commands } from "$lib/api/bindings"; +import type { CacheConfig } from "$lib/api/bindings"; +import { auth } from "$lib/stores/auth"; +import { createLogger } from "$lib/utils/logger"; -const log = createLogger('Preload'); +const log = createLogger("Preload"); interface PreloadOptions { - /** Enable debug logging */ - debug?: boolean; - /** Override user ID (defaults to current session user) */ - userId?: string; + /** Enable debug logging */ + debug?: boolean; + /** Override user ID (defaults to current session user) */ + userId?: string; } /** @@ -24,51 +24,51 @@ interface PreloadOptions { * This should be called after playback starts or advances to the next track */ export async function preloadUpcomingTracks(options: PreloadOptions = {}): Promise { - const { debug = false, userId: overrideUserId } = options; + const { debug = false, userId: overrideUserId } = options; - try { - // Get current user ID - const userId = overrideUserId || auth.getUserId(); + try { + // Get current user ID + const userId = overrideUserId || auth.getUserId(); - if (!userId) { - if (debug) log.debug('No active user session, skipping preload'); - return; - } + if (!userId) { + if (debug) log.debug("No active user session, skipping preload"); + return; + } - if (debug) log.debug('Triggering preload for user:', userId); + if (debug) log.debug("Triggering preload for user:", userId); - // downloadBasePath is currently unused in the backend - const result = await commands.playerPreloadUpcoming(userId, '/downloads'); + // downloadBasePath is currently unused in the backend + const result = await commands.playerPreloadUpcoming(userId, "/downloads"); - if (debug) { - log.debug('Result:', { - queued: result.queuedCount, - alreadyDownloaded: result.alreadyDownloaded, - skipped: result.skipped - }); - } + if (debug) { + log.debug("Result:", { + queued: result.queuedCount, + alreadyDownloaded: result.alreadyDownloaded, + skipped: result.skipped, + }); + } - // Log meaningful results - if (result.queuedCount > 0) { - log.debug(`Queued ${result.queuedCount} track(s) for background download`); - } - } catch (error) { - // Fail silently - preloading is a background optimization - // Don't interrupt the user's playback experience - log.warn('Failed to preload upcoming tracks:', error); - } + // Log meaningful results + if (result.queuedCount > 0) { + log.debug(`Queued ${result.queuedCount} track(s) for background download`); + } + } catch (error) { + // Fail silently - preloading is a background optimization + // Don't interrupt the user's playback experience + log.warn("Failed to preload upcoming tracks:", error); + } } /** * Update smart cache configuration */ export async function updateCacheConfig(config: CacheConfig): Promise { - await commands.playerSetCacheConfig(config); + await commands.playerSetCacheConfig(config); } /** * Get current cache configuration */ export async function getCacheConfig(): Promise { - return await commands.playerGetCacheConfig(); + return await commands.playerGetCacheConfig(); } diff --git a/src/lib/services/skipReporting.test.ts b/src/lib/services/skipReporting.test.ts index d757f25f..69bee9ca 100644 --- a/src/lib/services/skipReporting.test.ts +++ b/src/lib/services/skipReporting.test.ts @@ -12,9 +12,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; const markAsPlayed = vi.fn(async (_itemId: string) => undefined); -const reportPlaybackStopped = vi.fn( - async (_itemId: string, _positionSeconds: number) => undefined -); +const reportPlaybackStopped = vi.fn(async (_itemId: string, _positionSeconds: number) => undefined); vi.mock("./playbackReporting", () => ({ markAsPlayed: (itemId: string) => markAsPlayed(itemId), diff --git a/src/lib/services/syncService.ts b/src/lib/services/syncService.ts index 9663f850..a5520a02 100644 --- a/src/lib/services/syncService.ts +++ b/src/lib/services/syncService.ts @@ -63,7 +63,7 @@ class SyncService { async queueMutation( operation: SyncOperation, itemId: string, - payload?: Record + payload?: Record, ): Promise { const userId = auth.getUserId(); if (!userId) { @@ -74,7 +74,7 @@ class SyncService { userId, operation, itemId, - payload ? JSON.stringify(payload) : null + payload ? JSON.stringify(payload) : null, ); log.debug(`Queued ${operation} for item ${itemId}, id: ${id}`); @@ -90,10 +90,7 @@ class SyncService { * Queue playback progress update * Also updates local state immediately */ - async queuePlaybackProgress( - itemId: string, - positionMs: number - ): Promise { + async queuePlaybackProgress(itemId: string, positionMs: number): Promise { // Update local state first await commands.storageUpdatePlaybackProgress(auth.getUserId() ?? "", itemId, positionMs); @@ -184,7 +181,11 @@ class SyncService { return this.queueMutation("playlist_remove_items", playlistId, { entryIds }); } - async queuePlaylistReorderItem(playlistId: string, itemId: string, newIndex: number): Promise { + async queuePlaylistReorderItem( + playlistId: string, + itemId: string, + newIndex: number, + ): Promise { return this.queueMutation("playlist_reorder_item", playlistId, { itemId, newIndex }); } diff --git a/src/lib/services/webviewAudio.ts b/src/lib/services/webviewAudio.ts index da61de62..a7fca42b 100644 --- a/src/lib/services/webviewAudio.ts +++ b/src/lib/services/webviewAudio.ts @@ -60,7 +60,7 @@ async function handleLoad( url: string, mediaId: string | null, position: number, - autoplay: boolean + autoplay: boolean, ): Promise { if (!audioEl) return; diff --git a/src/lib/stores/__mocks__/tauri.ts b/src/lib/stores/__mocks__/tauri.ts index e2bd816d..97c7adfa 100644 --- a/src/lib/stores/__mocks__/tauri.ts +++ b/src/lib/stores/__mocks__/tauri.ts @@ -13,10 +13,7 @@ const invokeResponses: Map = new Map(); /** * Mock invoke function that captures calls */ -export const mockInvoke = async ( - command: string, - args?: Record -): Promise => { +export const mockInvoke = async (command: string, args?: Record): Promise => { const callArgs = args || {}; invokeHistory.push({ command, args: callArgs }); @@ -72,10 +69,7 @@ export const clearInvokeHistory = (): void => { /** * Verify a command was called with expected parameters */ -export const expectInvokeCall = ( - command: string, - expectedArgs: Record -): void => { +export const expectInvokeCall = (command: string, expectedArgs: Record): void => { const calls = getInvokeCalls_ForCommand(command); if (calls.length === 0) { @@ -92,7 +86,7 @@ export const expectInvokeCall = ( throw new Error( `Parameter "${key}" mismatch:\n` + ` Expected: ${JSON.stringify(expectedValue)}\n` + - ` Actual: ${JSON.stringify(actualValue)}` + ` Actual: ${JSON.stringify(actualValue)}`, ); } } @@ -104,7 +98,7 @@ export const expectInvokeCall = ( export const getInvokeParameter = ( command: string, paramName: string, - callIndex = -1 // -1 = last call + callIndex = -1, // -1 = last call ): any => { const calls = getInvokeCalls_ForCommand(command); diff --git a/src/lib/stores/appState.ts b/src/lib/stores/appState.ts index 368c42a0..94b08342 100644 --- a/src/lib/stores/appState.ts +++ b/src/lib/stores/appState.ts @@ -1,6 +1,6 @@ // Application-wide UI state store // TRACES: UR-005 | DR-005, DR-009 -import { writable } from 'svelte/store'; +import { writable } from "svelte/store"; // App-wide state (root layout) export const isInitialized = writable(false); diff --git a/src/lib/stores/auth.test.ts b/src/lib/stores/auth.test.ts index ef35aafb..551ef462 100644 --- a/src/lib/stores/auth.test.ts +++ b/src/lib/stores/auth.test.ts @@ -237,9 +237,7 @@ describe("auth store", () => { // Expected - might fail due to mocking } - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "auth_connect_to_server" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "auth_connect_to_server"); expect(call).toBeDefined(); expect(call![1]).toHaveProperty("serverUrl"); }); @@ -265,9 +263,7 @@ describe("auth store", () => { const { invoke } = await import("@tauri-apps/api/core"); const invokeSpy = vi.mocked(invoke); - const loginCall = invokeSpy.mock.calls.find( - (c) => c[0] === "auth_login" - ); + const loginCall = invokeSpy.mock.calls.find((c) => c[0] === "auth_login"); expect(loginCall).toBeDefined(); expect(loginCall![1]).toHaveProperty("username", "testuser"); expect(loginCall![1]).toHaveProperty("password", "password123"); @@ -288,7 +284,7 @@ describe("auth store", () => { expect.objectContaining({ username: "user", password: "pass", - }) + }), ); }); }); @@ -323,8 +319,10 @@ describe("auth store", () => { await auth.logout(); // Either auth_get_session or auth_logout should be called - const callNames = invokeSpy.mock.calls.map(c => c[0]); - expect(callNames.some(name => ["auth_get_session", "player_disable_jellyfin"].includes(name))).toBe(true); + const callNames = invokeSpy.mock.calls.map((c) => c[0]); + expect( + callNames.some((name) => ["auth_get_session", "player_disable_jellyfin"].includes(name)), + ).toBe(true); }); }); @@ -340,9 +338,7 @@ describe("auth store", () => { await auth.getCurrentSession(); - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "auth_get_session" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "auth_get_session"); expect(call).toBeDefined(); }); }); diff --git a/src/lib/stores/auth.ts b/src/lib/stores/auth.ts index 246e228a..add0bcbe 100644 --- a/src/lib/stores/auth.ts +++ b/src/lib/stores/auth.ts @@ -172,7 +172,12 @@ function createAuthStore() { // we mark authenticated — the first screen (library overview) reads // through it — so keep it awaited. repository = new RepositoryClient(); - await repository.create(session.serverUrl, session.userId, session.accessToken, session.serverId); + await repository.create( + session.serverUrl, + session.userId, + session.accessToken, + session.serverId, + ); // Configure the Rust player for playback reporting. This is NOT needed to // render the first screen (it only matters once playback starts), so run @@ -185,7 +190,7 @@ function createAuthStore() { session.serverUrl, session.accessToken, session.userId, - deviceId + deviceId, ); log.debug("Rust player configured for automatic playback reporting"); } catch (error) { @@ -209,19 +214,21 @@ function createAuthStore() { // Start connectivity monitoring early to avoid appearing offline on startup log.debug("Starting early connectivity monitoring..."); - connectivity.startMonitoring(session.serverUrl, { - onServerReconnected: () => { - // Retry session verification when server becomes reachable - retryVerification(); - // Resume downloads queued while offline, then refresh the catalog. - // Lazy import to avoid an auth <-> offlineCatalog import cycle. - import("$lib/services/offlineCatalog") - .then((m) => m.onReconnected()) - .catch((err) => log.warn("Catalog reconnect failed:", err)); - }, - }).catch((error) => { - log.error("Failed to start connectivity monitoring:", error); - }); + connectivity + .startMonitoring(session.serverUrl, { + onServerReconnected: () => { + // Retry session verification when server becomes reachable + retryVerification(); + // Resume downloads queued while offline, then refresh the catalog. + // Lazy import to avoid an auth <-> offlineCatalog import cycle. + import("$lib/services/offlineCatalog") + .then((m) => m.onReconnected()) + .catch((err) => log.warn("Catalog reconnect failed:", err)); + }, + }) + .catch((error) => { + log.error("Failed to start connectivity monitoring:", error); + }); // Start background session verification — fire-and-forget. This is // already asynchronous work (results arrive via the auth:* events wired @@ -313,7 +320,7 @@ function createAuthStore() { authResult.user.id, authResult.serverId, authResult.user.name, - authResult.accessToken + authResult.accessToken, ); await commands.storageSetActiveUser(authResult.user.id, authResult.serverId); @@ -332,7 +339,12 @@ function createAuthStore() { // Create RepositoryClient repository = new RepositoryClient(); - await repository.create(serverUrl, authResult.user.id, authResult.accessToken, authResult.serverId); + await repository.create( + serverUrl, + authResult.user.id, + authResult.accessToken, + authResult.serverId, + ); // Configure Rust player try { @@ -341,7 +353,7 @@ function createAuthStore() { serverUrl, authResult.accessToken, authResult.user.id, - playerDeviceId + playerDeviceId, ); log.debug("Rust player configured for playback reporting"); } catch (error) { @@ -398,7 +410,7 @@ function createAuthStore() { authResult.user.id, authResult.serverId, authResult.user.name, - authResult.accessToken + authResult.accessToken, ); // Recreate repository with new credentials @@ -406,7 +418,12 @@ function createAuthStore() { await repository.destroy(); const session = await commands.authGetSession(); if (session) { - await repository.create(session.serverUrl, authResult.user.id, authResult.accessToken, authResult.serverId); + await repository.create( + session.serverUrl, + authResult.user.id, + authResult.accessToken, + authResult.serverId, + ); } } @@ -417,7 +434,7 @@ function createAuthStore() { repository ? await getCurrentSessionServerUrl() : "", authResult.accessToken, authResult.user.id, - playerDeviceId + playerDeviceId, ); } catch (error) { log.error("Failed to reconfigure player:", error); diff --git a/src/lib/stores/connectivity.ts b/src/lib/stores/connectivity.ts index 8bde0b58..d7c2e390 100644 --- a/src/lib/stores/connectivity.ts +++ b/src/lib/stores/connectivity.ts @@ -166,8 +166,10 @@ function createConnectivityStore() { isChecking: status.isChecking, })); - log.debug("Started monitoring. Initial status:", - status.isServerReachable ? "ONLINE" : "OFFLINE"); + log.debug( + "Started monitoring. Initial status:", + status.isServerReachable ? "ONLINE" : "OFFLINE", + ); } catch (error) { log.error("Failed to start monitoring:", error); update((s) => ({ @@ -246,8 +248,5 @@ export const isServerReachable = derived(connectivity, ($c) => $c.isServerReacha // a brief full-catalog flash before the first probe beats flipping the app to // "offline" on launch. See docs/architecture/07-connectivity.md. // TRACES: UR-052 | DR-079 -export const isConnected = derived( - connectivity, - ($c) => $c.isServerReachable -); +export const isConnected = derived(connectivity, ($c) => $c.isServerReachable); export const connectionError = derived(connectivity, ($c) => $c.connectionError); diff --git a/src/lib/stores/continueWatchingFilter.test.ts b/src/lib/stores/continueWatchingFilter.test.ts index 951427c1..7e137609 100644 --- a/src/lib/stores/continueWatchingFilter.test.ts +++ b/src/lib/stores/continueWatchingFilter.test.ts @@ -14,16 +14,13 @@ import { describe, it, expect } from "vitest"; import type { MediaItem } from "$lib/api/types"; -import { - filterSupersededResumeItems, - filterInProgressNextUpItems, -} from "./continueWatchingFilter"; +import { filterSupersededResumeItems, filterInProgressNextUpItems } from "./continueWatchingFilter"; function episode( id: string, seriesId: string, season: number | undefined, - index: number | undefined + index: number | undefined, ): MediaItem { return { id, @@ -115,15 +112,12 @@ describe("filterSupersededResumeItems", () => { const result = filterSupersededResumeItems(resume, nextUp); - expect(result.map(i => i.id)).toEqual(["a-s1e2", "c-s1e3"]); + expect(result.map((i) => i.id)).toEqual(["a-s1e2", "c-s1e3"]); }); it("uses the furthest-ahead next-up entry for a series", () => { const resume = [episode("s1e2", "series-a", 1, 2)]; - const nextUp = [ - episode("s1e1", "series-a", 1, 1), - episode("s1e8", "series-a", 1, 8), - ]; + const nextUp = [episode("s1e1", "series-a", 1, 1), episode("s1e8", "series-a", 1, 8)]; expect(filterSupersededResumeItems(resume, nextUp)).toEqual([]); }); @@ -143,7 +137,7 @@ describe("filterInProgressNextUpItems", () => { const resume = [episode("s1e4", "series-a", 1, 4)]; const nextUp = [episode("s1e5", "series-a", 1, 5)]; - expect(filterInProgressNextUpItems(nextUp, resume).map(i => i.id)).toEqual(["s1e5"]); + expect(filterInProgressNextUpItems(nextUp, resume).map((i) => i.id)).toEqual(["s1e5"]); }); it("only suppresses the started episode, not the rest of the row", () => { @@ -156,7 +150,7 @@ describe("filterInProgressNextUpItems", () => { const result = filterInProgressNextUpItems(nextUp, resume); - expect(result.map(i => i.id)).toEqual(["b-s1e1", "c-s2e3"]); + expect(result.map((i) => i.id)).toEqual(["b-s1e1", "c-s2e3"]); }); it("is a no-op when nothing is in progress", () => { diff --git a/src/lib/stores/continueWatchingFilter.ts b/src/lib/stores/continueWatchingFilter.ts index 303dbf14..33f935e7 100644 --- a/src/lib/stores/continueWatchingFilter.ts +++ b/src/lib/stores/continueWatchingFilter.ts @@ -54,7 +54,7 @@ function isAheadOf(a: MediaItem, b: MediaItem): boolean { */ export function filterSupersededResumeItems( resumeItems: MediaItem[], - nextUpItems: MediaItem[] + nextUpItems: MediaItem[], ): MediaItem[] { if (nextUpItems.length === 0) return resumeItems; @@ -69,7 +69,7 @@ export function filterSupersededResumeItems( } } - return resumeItems.filter(item => { + return resumeItems.filter((item) => { if (item.kind !== "episode" || !item.seriesId) return true; const ahead = frontier.get(item.seriesId); if (!ahead) return true; @@ -92,10 +92,10 @@ export function filterSupersededResumeItems( */ export function filterInProgressNextUpItems( nextUpItems: MediaItem[], - resumeItems: MediaItem[] + resumeItems: MediaItem[], ): MediaItem[] { if (resumeItems.length === 0) return nextUpItems; - const inProgress = new Set(resumeItems.map(item => item.id)); - return nextUpItems.filter(item => !inProgress.has(item.id)); + const inProgress = new Set(resumeItems.map((item) => item.id)); + return nextUpItems.filter((item) => !inProgress.has(item.id)); } diff --git a/src/lib/stores/downloads.test.ts b/src/lib/stores/downloads.test.ts index 5a845cfb..68d2e840 100644 --- a/src/lib/stores/downloads.test.ts +++ b/src/lib/stores/downloads.test.ts @@ -24,10 +24,12 @@ describe("downloads store", () => { mockInvoke.mockReset(); // Capture the event handler when listen is called - mockListen.mockImplementation((_event: string, handler: (event: { payload: unknown }) => void) => { - eventHandler = handler; - return Promise.resolve(() => {}); - }); + mockListen.mockImplementation( + (_event: string, handler: (event: { payload: unknown }) => void) => { + eventHandler = handler; + return Promise.resolve(() => {}); + }, + ); }); afterEach(async () => { @@ -76,7 +78,7 @@ describe("downloads store", () => { "user-1", "/path/to/file.mp3", "audio/mpeg", - 10 + 10, ); expect(mockInvoke).toHaveBeenCalledWith("download_item", { @@ -98,34 +100,32 @@ describe("downloads store", () => { it("should refresh downloads after queuing", async () => { const { downloads } = await import("./downloads"); - mockInvoke - .mockResolvedValueOnce(123) - .mockResolvedValueOnce({ - downloads: [ - { - id: 123, - itemId: "item-1", - userId: "user-1", - filePath: "/path/to/file.mp3", - status: "pending", - progress: 0, - bytesDownloaded: 0, - queuedAt: "2024-01-01T00:00:00Z", - retryCount: 0, - priority: 10, - mediaType: "audio", - downloadSource: "user", - }, - ], - stats: { - total: 1, - activeCount: 0, - queuedCount: 1, - completedCount: 0, - failedCount: 0, - pausedCount: 0, + mockInvoke.mockResolvedValueOnce(123).mockResolvedValueOnce({ + downloads: [ + { + id: 123, + itemId: "item-1", + userId: "user-1", + filePath: "/path/to/file.mp3", + status: "pending", + progress: 0, + bytesDownloaded: 0, + queuedAt: "2024-01-01T00:00:00Z", + retryCount: 0, + priority: 10, + mediaType: "audio", + downloadSource: "user", }, - }); + ], + stats: { + total: 1, + activeCount: 0, + queuedCount: 1, + completedCount: 0, + failedCount: 0, + pausedCount: 0, + }, + }); await downloads.downloadItem("item-1", "user-1", "/path/to/file.mp3"); @@ -315,18 +315,76 @@ describe("downloads store", () => { // `transfers` derivation: active + pending + failed. // TRACES: UR-055 | DR-084 | UT-052 it("transfers set excludes completed downloads", async () => { - const { downloads, activeDownloads, pendingDownloads, failedDownloads } = await import( - "./downloads" - ); + const { downloads, activeDownloads, pendingDownloads, failedDownloads } = + await import("./downloads"); mockInvoke.mockResolvedValueOnce({ downloads: [ - { id: 1, itemId: "a", userId: "u", filePath: "/a", status: "downloading", progress: 0.5, bytesDownloaded: 5, queuedAt: "t", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user" }, - { id: 2, itemId: "b", userId: "u", filePath: "/b", status: "pending", progress: 0, bytesDownloaded: 0, queuedAt: "t", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user" }, - { id: 3, itemId: "c", userId: "u", filePath: "/c", status: "completed", progress: 1, bytesDownloaded: 9, queuedAt: "t", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user" }, - { id: 4, itemId: "d", userId: "u", filePath: "/d", status: "failed", progress: 0, bytesDownloaded: 0, queuedAt: "t", retryCount: 1, priority: 0, mediaType: "audio", downloadSource: "user" }, + { + id: 1, + itemId: "a", + userId: "u", + filePath: "/a", + status: "downloading", + progress: 0.5, + bytesDownloaded: 5, + queuedAt: "t", + retryCount: 0, + priority: 0, + mediaType: "audio", + downloadSource: "user", + }, + { + id: 2, + itemId: "b", + userId: "u", + filePath: "/b", + status: "pending", + progress: 0, + bytesDownloaded: 0, + queuedAt: "t", + retryCount: 0, + priority: 0, + mediaType: "audio", + downloadSource: "user", + }, + { + id: 3, + itemId: "c", + userId: "u", + filePath: "/c", + status: "completed", + progress: 1, + bytesDownloaded: 9, + queuedAt: "t", + retryCount: 0, + priority: 0, + mediaType: "audio", + downloadSource: "user", + }, + { + id: 4, + itemId: "d", + userId: "u", + filePath: "/d", + status: "failed", + progress: 0, + bytesDownloaded: 0, + queuedAt: "t", + retryCount: 1, + priority: 0, + mediaType: "audio", + downloadSource: "user", + }, ], - stats: { total: 4, activeCount: 1, queuedCount: 1, completedCount: 1, failedCount: 1, pausedCount: 0 }, + stats: { + total: 4, + activeCount: 1, + queuedCount: 1, + completedCount: 1, + failedCount: 1, + pausedCount: 0, + }, }); await downloads.refresh("u"); @@ -920,9 +978,9 @@ describe("downloads store", () => { return Promise.reject(new Error(`Unexpected command: ${command}`)); }); - await expect( - downloads.downloadItem("item-1", "user-1", "/path/to/file.mp3") - ).rejects.toThrow("Backend error: failed to queue download"); + await expect(downloads.downloadItem("item-1", "user-1", "/path/to/file.mp3")).rejects.toThrow( + "Backend error: failed to queue download", + ); }); it("should throw error when refresh fails", async () => { @@ -935,9 +993,9 @@ describe("downloads store", () => { return Promise.reject(new Error(`Unexpected command: ${command}`)); }); - await expect( - downloads.refresh("user-1") - ).rejects.toThrow("Backend error: failed to fetch downloads"); + await expect(downloads.refresh("user-1")).rejects.toThrow( + "Backend error: failed to fetch downloads", + ); }); }); }); diff --git a/src/lib/stores/downloads.ts b/src/lib/stores/downloads.ts index 0593cae2..f717ec01 100644 --- a/src/lib/stores/downloads.ts +++ b/src/lib/stores/downloads.ts @@ -1,79 +1,79 @@ // Download manager state store // TRACES: UR-011, UR-013, UR-018 | DR-015, DR-017 -import { writable, derived, get } from 'svelte/store'; -import { commands } from '$lib/api/bindings'; -import { listen, type UnlistenFn } from '@tauri-apps/api/event'; -import { createLogger } from '$lib/utils/logger'; +import { writable, derived, get } from "svelte/store"; +import { commands } from "$lib/api/bindings"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { createLogger } from "$lib/utils/logger"; -const log = createLogger('Downloads'); +const log = createLogger("Downloads"); // Event listener state let unlistenFn: UnlistenFn | null = null; let isEventsInitialized = false; export interface DownloadInfo { - id: number; - itemId: string; - userId: string; - filePath: string; - fileSize?: number; - mimeType?: string; - status: 'pending' | 'downloading' | 'completed' | 'failed' | 'paused'; - progress: number; - bytesDownloaded: number; - queuedAt: string; - startedAt?: string; - completedAt?: string; - errorMessage?: string; - retryCount: number; - priority: number; - // Item metadata for display (audio) - itemName?: string; - artistName?: string; - albumName?: string; - // Video-specific metadata - seriesName?: string; - seasonName?: string; - episodeNumber?: number; - seasonNumber?: number; - qualityPreset?: string; - mediaType: 'audio' | 'video'; - // Download source tracking - downloadSource: 'user' | 'auto'; + id: number; + itemId: string; + userId: string; + filePath: string; + fileSize?: number; + mimeType?: string; + status: "pending" | "downloading" | "completed" | "failed" | "paused"; + progress: number; + bytesDownloaded: number; + queuedAt: string; + startedAt?: string; + completedAt?: string; + errorMessage?: string; + retryCount: number; + priority: number; + // Item metadata for display (audio) + itemName?: string; + artistName?: string; + albumName?: string; + // Video-specific metadata + seriesName?: string; + seasonName?: string; + episodeNumber?: number; + seasonNumber?: number; + qualityPreset?: string; + mediaType: "audio" | "video"; + // Download source tracking + downloadSource: "user" | "auto"; } export interface DownloadEvent { - type: - | 'queued' - | 'started' - | 'progress' - | 'completed' - | 'failed' - | 'paused' - | 'cancelled' - | 'waitingForNetwork'; - /** Absent on 'waitingForNetwork', which is queue-wide rather than per-download. */ - downloadId: number; - itemId: string; - bytesDownloaded?: number; - totalBytes?: number; - progress?: number; - filePath?: string; - error?: string; + type: + | "queued" + | "started" + | "progress" + | "completed" + | "failed" + | "paused" + | "cancelled" + | "waitingForNetwork"; + /** Absent on 'waitingForNetwork', which is queue-wide rather than per-download. */ + downloadId: number; + itemId: string; + bytesDownloaded?: number; + totalBytes?: number; + progress?: number; + filePath?: string; + error?: string; } export interface DownloadStats { - total: number; - activeCount: number; - queuedCount: number; - completedCount: number; - failedCount: number; - pausedCount: number; + total: number; + activeCount: number; + queuedCount: number; + completedCount: number; + failedCount: number; + pausedCount: number; } interface DownloadsState { - downloads: Record; - stats: DownloadStats; + downloads: Record; + stats: DownloadStats; } /** @@ -86,430 +86,437 @@ interface DownloadsState { export const waitingForNetwork = writable(false); function createDownloadsStore() { - const { subscribe, update, set } = writable({ - downloads: {}, - stats: { - total: 0, - activeCount: 0, - queuedCount: 0, - completedCount: 0, - failedCount: 0, - pausedCount: 0 - } - }); + const { subscribe, update, set } = writable({ + downloads: {}, + stats: { + total: 0, + activeCount: 0, + queuedCount: 0, + completedCount: 0, + failedCount: 0, + pausedCount: 0, + }, + }); - // Prevent concurrent refresh calls (race condition protection) - let refreshInProgress = false; - let pendingRefreshRequest: { userId: string; statusFilter?: string[] } | null = null; + // Prevent concurrent refresh calls (race condition protection) + let refreshInProgress = false; + let pendingRefreshRequest: { userId: string; statusFilter?: string[] } | null = null; - // Helper function to refresh downloads (avoids `this` binding issues) - async function refreshDownloads(userId: string, statusFilter?: string[]): Promise { - // If a refresh is already in progress, queue this request instead - if (refreshInProgress) { - log.debug('🔄 Refresh already in progress, queuing request for user:', userId); - pendingRefreshRequest = { userId, statusFilter }; - return; - } + // Helper function to refresh downloads (avoids `this` binding issues) + async function refreshDownloads(userId: string, statusFilter?: string[]): Promise { + // If a refresh is already in progress, queue this request instead + if (refreshInProgress) { + log.debug("🔄 Refresh already in progress, queuing request for user:", userId); + pendingRefreshRequest = { userId, statusFilter }; + return; + } - refreshInProgress = true; + refreshInProgress = true; - try { - log.debug('🔄 Refreshing downloads for user:', userId); - const response = (await commands.getDownloads( - userId, - statusFilter ?? null - )) as unknown as { downloads: DownloadInfo[]; stats: DownloadStats }; - log.debug(' Got', response.downloads.length, 'downloads from backend'); - log.debug(' Stats:', response.stats); + try { + log.debug("🔄 Refreshing downloads for user:", userId); + const response = (await commands.getDownloads(userId, statusFilter ?? null)) as unknown as { + downloads: DownloadInfo[]; + stats: DownloadStats; + }; + log.debug(" Got", response.downloads.length, "downloads from backend"); + log.debug(" Stats:", response.stats); - update((state) => { - const downloadsMap: Record = {}; + update((state) => { + const downloadsMap: Record = {}; - for (const download of response.downloads) { - downloadsMap[download.id] = download; - } + for (const download of response.downloads) { + downloadsMap[download.id] = download; + } - // No count calculation - use pre-computed stats from Rust! - return { - downloads: downloadsMap, - stats: response.stats - }; - }); - } catch (error) { - log.error('Failed to refresh downloads:', error); - throw error; - } finally { - refreshInProgress = false; + // No count calculation - use pre-computed stats from Rust! + return { + downloads: downloadsMap, + stats: response.stats, + }; + }); + } catch (error) { + log.error("Failed to refresh downloads:", error); + throw error; + } finally { + refreshInProgress = false; - // Process queued request if any - if (pendingRefreshRequest) { - const { userId: queuedUserId, statusFilter: queuedFilter } = pendingRefreshRequest; - pendingRefreshRequest = null; - await refreshDownloads(queuedUserId, queuedFilter); - } - } - } + // Process queued request if any + if (pendingRefreshRequest) { + const { userId: queuedUserId, statusFilter: queuedFilter } = pendingRefreshRequest; + pendingRefreshRequest = null; + await refreshDownloads(queuedUserId, queuedFilter); + } + } + } - return { - subscribe, + return { + subscribe, - /** - * Queue a single item for download - */ - async downloadItem( - itemId: string, - userId: string, - filePath: string, - mimeType?: string, - priority?: number, - itemName?: string, - artistName?: string, - albumName?: string - ): Promise { - try { - log.debug('📥 downloadItem called:', { itemId, userId, filePath, itemName, artistName, albumName }); - const downloadId = await commands.downloadItem({ - itemId, - userId, - filePath, - mimeType: mimeType ?? null, - priority: priority ?? null, - itemName: itemName ?? null, - artistName: artistName ?? null, - albumName: albumName ?? null, - expectedSize: null - }); - log.debug(' Got download ID from backend:', downloadId); + /** + * Queue a single item for download + */ + async downloadItem( + itemId: string, + userId: string, + filePath: string, + mimeType?: string, + priority?: number, + itemName?: string, + artistName?: string, + albumName?: string, + ): Promise { + try { + log.debug("📥 downloadItem called:", { + itemId, + userId, + filePath, + itemName, + artistName, + albumName, + }); + const downloadId = await commands.downloadItem({ + itemId, + userId, + filePath, + mimeType: mimeType ?? null, + priority: priority ?? null, + itemName: itemName ?? null, + artistName: artistName ?? null, + albumName: albumName ?? null, + expectedSize: null, + }); + log.debug(" Got download ID from backend:", downloadId); - // Fetch download info and add to store - log.debug(' Refreshing downloads...'); - await refreshDownloads(userId); - log.debug(' Refresh complete. Store state:', get({ subscribe })); + // Fetch download info and add to store + log.debug(" Refreshing downloads..."); + await refreshDownloads(userId); + log.debug(" Refresh complete. Store state:", get({ subscribe })); - return downloadId; - } catch (error) { - log.error('Failed to queue download:', error); - throw error; - } - }, + return downloadId; + } catch (error) { + log.error("Failed to queue download:", error); + throw error; + } + }, - /** - * Queue an entire album for download. - * - * The backend does all of it — listing the album's tracks, queueing them, - * resolving each stream URL and starting the queue. It returns the queued - * row ids for reporting only; nothing here pairs them back to tracks. - * - * TRACES: UR-018, UR-055 | DR-173 - */ - async downloadAlbum( - handle: string, - albumId: string, - userId: string, - basePath: string - ): Promise { - try { - log.debug('📥 downloadAlbum called:', { albumId, userId, basePath }); - const downloadIds = await commands.downloadAlbum(handle, albumId, userId, basePath); - log.debug(' Got download IDs from backend:', downloadIds); + /** + * Queue an entire album for download. + * + * The backend does all of it — listing the album's tracks, queueing them, + * resolving each stream URL and starting the queue. It returns the queued + * row ids for reporting only; nothing here pairs them back to tracks. + * + * TRACES: UR-018, UR-055 | DR-173 + */ + async downloadAlbum( + handle: string, + albumId: string, + userId: string, + basePath: string, + ): Promise { + try { + log.debug("📥 downloadAlbum called:", { albumId, userId, basePath }); + const downloadIds = await commands.downloadAlbum(handle, albumId, userId, basePath); + log.debug(" Got download IDs from backend:", downloadIds); - // Refresh downloads - await refreshDownloads(userId); + // Refresh downloads + await refreshDownloads(userId); - return downloadIds; - } catch (error) { - log.error('Failed to queue album download:', error); - throw error; - } - }, + return downloadIds; + } catch (error) { + log.error("Failed to queue album download:", error); + throw error; + } + }, - /** - * Queue a video item (movie/episode) for download with quality preset - */ - async downloadVideo( - itemId: string, - userId: string, - filePath: string, - mimeType?: string, - priority?: number, - itemName?: string, - qualityPreset?: string, - seriesName?: string, - seasonName?: string, - episodeNumber?: number, - seasonNumber?: number - ): Promise { - try { - log.debug('🎬 downloadVideo called:', { - itemId, - userId, - filePath, - itemName, - qualityPreset, - seriesName - }); - const downloadId = await commands.downloadVideo({ - itemId, - userId, - filePath, - mimeType: mimeType ?? null, - priority: priority ?? null, - itemName: itemName ?? null, - qualityPreset: qualityPreset ?? null, - seriesName: seriesName ?? null, - seasonName: seasonName ?? null, - episodeNumber: episodeNumber ?? null, - seasonNumber: seasonNumber ?? null - }); - log.debug(' Got download ID from backend:', downloadId); + /** + * Queue a video item (movie/episode) for download with quality preset + */ + async downloadVideo( + itemId: string, + userId: string, + filePath: string, + mimeType?: string, + priority?: number, + itemName?: string, + qualityPreset?: string, + seriesName?: string, + seasonName?: string, + episodeNumber?: number, + seasonNumber?: number, + ): Promise { + try { + log.debug("🎬 downloadVideo called:", { + itemId, + userId, + filePath, + itemName, + qualityPreset, + seriesName, + }); + const downloadId = await commands.downloadVideo({ + itemId, + userId, + filePath, + mimeType: mimeType ?? null, + priority: priority ?? null, + itemName: itemName ?? null, + qualityPreset: qualityPreset ?? null, + seriesName: seriesName ?? null, + seasonName: seasonName ?? null, + episodeNumber: episodeNumber ?? null, + seasonNumber: seasonNumber ?? null, + }); + log.debug(" Got download ID from backend:", downloadId); - // Refresh downloads - await refreshDownloads(userId); + // Refresh downloads + await refreshDownloads(userId); - return downloadId; - } catch (error) { - log.error('Failed to queue video download:', error); - throw error; - } - }, + return downloadId; + } catch (error) { + log.error("Failed to queue video download:", error); + throw error; + } + }, - /** - * Queue all episodes of a series for download - */ - async downloadSeries( - seriesId: string, - seriesName: string, - userId: string, - basePath: string, - qualityPreset?: string - ): Promise { - try { - log.debug('📺 downloadSeries called:', { - seriesId, - seriesName, - userId, - basePath, - qualityPreset - }); - const downloadIds = await commands.downloadSeries( - seriesId, - seriesName, - userId, - basePath, - qualityPreset ?? null - ); - log.debug(' Queued', downloadIds.length, 'episodes for download'); + /** + * Queue all episodes of a series for download + */ + async downloadSeries( + seriesId: string, + seriesName: string, + userId: string, + basePath: string, + qualityPreset?: string, + ): Promise { + try { + log.debug("📺 downloadSeries called:", { + seriesId, + seriesName, + userId, + basePath, + qualityPreset, + }); + const downloadIds = await commands.downloadSeries( + seriesId, + seriesName, + userId, + basePath, + qualityPreset ?? null, + ); + log.debug(" Queued", downloadIds.length, "episodes for download"); - // Refresh downloads - await refreshDownloads(userId); + // Refresh downloads + await refreshDownloads(userId); - return downloadIds; - } catch (error) { - log.error('Failed to queue series download:', error); - throw error; - } - }, + return downloadIds; + } catch (error) { + log.error("Failed to queue series download:", error); + throw error; + } + }, - /** - * Queue all episodes of a season for download - */ - async downloadSeason( - seasonId: string, - seriesName: string, - seasonName: string, - seasonNumber: number, - userId: string, - basePath: string, - qualityPreset?: string - ): Promise { - try { - log.debug('📺 downloadSeason called:', { - seasonId, - seriesName, - seasonName, - seasonNumber, - qualityPreset - }); - const downloadIds = await commands.downloadSeason( - seasonId, - seriesName, - seasonName, - seasonNumber, - userId, - basePath, - qualityPreset ?? null - ); - log.debug(' Queued', downloadIds.length, 'episodes for download'); + /** + * Queue all episodes of a season for download + */ + async downloadSeason( + seasonId: string, + seriesName: string, + seasonName: string, + seasonNumber: number, + userId: string, + basePath: string, + qualityPreset?: string, + ): Promise { + try { + log.debug("📺 downloadSeason called:", { + seasonId, + seriesName, + seasonName, + seasonNumber, + qualityPreset, + }); + const downloadIds = await commands.downloadSeason( + seasonId, + seriesName, + seasonName, + seasonNumber, + userId, + basePath, + qualityPreset ?? null, + ); + log.debug(" Queued", downloadIds.length, "episodes for download"); - // Refresh downloads - await refreshDownloads(userId); + // Refresh downloads + await refreshDownloads(userId); - return downloadIds; - } catch (error) { - log.error('Failed to queue season download:', error); - throw error; - } - }, + return downloadIds; + } catch (error) { + log.error("Failed to queue season download:", error); + throw error; + } + }, - /** - * Pin an item's metadata (protects from cache clear) - */ - async pinItem(itemId: string): Promise { - try { - await commands.pinItem(itemId); - } catch (error) { - log.error('Failed to pin item:', error); - throw error; - } - }, + /** + * Pin an item's metadata (protects from cache clear) + */ + async pinItem(itemId: string): Promise { + try { + await commands.pinItem(itemId); + } catch (error) { + log.error("Failed to pin item:", error); + throw error; + } + }, - /** - * Unpin an item's metadata - */ - async unpinItem(itemId: string): Promise { - try { - await commands.unpinItem(itemId); - } catch (error) { - log.error('Failed to unpin item:', error); - throw error; - } - }, + /** + * Unpin an item's metadata + */ + async unpinItem(itemId: string): Promise { + try { + await commands.unpinItem(itemId); + } catch (error) { + log.error("Failed to unpin item:", error); + throw error; + } + }, - /** - * Check if an item is pinned - */ - async isItemPinned(itemId: string): Promise { - try { - return await commands.isItemPinned(itemId); - } catch (error) { - log.error('Failed to check pin status:', error); - return false; - } - }, + /** + * Check if an item is pinned + */ + async isItemPinned(itemId: string): Promise { + try { + return await commands.isItemPinned(itemId); + } catch (error) { + log.error("Failed to check pin status:", error); + return false; + } + }, - /** - * Pause a download - */ - async pause(downloadId: number): Promise { - try { - await commands.pauseDownload(downloadId); - } catch (error) { - log.error('Failed to pause download:', error); - throw error; - } - }, + /** + * Pause a download + */ + async pause(downloadId: number): Promise { + try { + await commands.pauseDownload(downloadId); + } catch (error) { + log.error("Failed to pause download:", error); + throw error; + } + }, - /** - * Resume a paused download - */ - async resume(downloadId: number): Promise { - try { - await commands.resumeDownload(downloadId); - } catch (error) { - log.error('Failed to resume download:', error); - throw error; - } - }, + /** + * Resume a paused download + */ + async resume(downloadId: number): Promise { + try { + await commands.resumeDownload(downloadId); + } catch (error) { + log.error("Failed to resume download:", error); + throw error; + } + }, - /** - * Cancel a download - */ - async cancel(downloadId: number): Promise { - try { - await commands.cancelDownload(downloadId); - } catch (error) { - log.error('Failed to cancel download:', error); - throw error; - } - }, + /** + * Cancel a download + */ + async cancel(downloadId: number): Promise { + try { + await commands.cancelDownload(downloadId); + } catch (error) { + log.error("Failed to cancel download:", error); + throw error; + } + }, - /** - * Delete a completed download - */ - async delete(downloadId: number): Promise { - try { - await commands.deleteDownload(downloadId); - update((state) => { - const { [downloadId]: removed, ...remaining } = state.downloads; - return { ...state, downloads: remaining }; - }); - } catch (error) { - log.error('Failed to delete download:', error); - throw error; - } - }, + /** + * Delete a completed download + */ + async delete(downloadId: number): Promise { + try { + await commands.deleteDownload(downloadId); + update((state) => { + const { [downloadId]: removed, ...remaining } = state.downloads; + return { ...state, downloads: remaining }; + }); + } catch (error) { + log.error("Failed to delete download:", error); + throw error; + } + }, - /** - * Refresh downloads list from backend - */ - refresh: refreshDownloads, + /** + * Refresh downloads list from backend + */ + refresh: refreshDownloads, - /** - * Update a specific download in the store (for event handling) - */ - updateDownload(downloadId: number, updates: Partial): void { - update((state) => { - const download = state.downloads[downloadId]; - if (!download) { - log.debug(' Download not in store:', downloadId); - return state; - } + /** + * Update a specific download in the store (for event handling) + */ + updateDownload(downloadId: number, updates: Partial): void { + update((state) => { + const download = state.downloads[downloadId]; + if (!download) { + log.debug(" Download not in store:", downloadId); + return state; + } - const updatedDownload = { ...download, ...updates }; - const newDownloads = { ...state.downloads, [downloadId]: updatedDownload }; + const updatedDownload = { ...download, ...updates }; + const newDownloads = { ...state.downloads, [downloadId]: updatedDownload }; - log.debug(' Store updated for download', downloadId, ':', updates); - // No count calculation - stats remain as-is until next refresh - return { - downloads: newDownloads, - stats: state.stats - }; - }); - }, + log.debug(" Store updated for download", downloadId, ":", updates); + // No count calculation - stats remain as-is until next refresh + return { + downloads: newDownloads, + stats: state.stats, + }; + }); + }, - /** - * Remove a download from the store - */ - removeDownload(downloadId: number): void { - update((state) => { - const { [downloadId]: removed, ...remaining } = state.downloads; - if (!removed) return state; + /** + * Remove a download from the store + */ + removeDownload(downloadId: number): void { + update((state) => { + const { [downloadId]: removed, ...remaining } = state.downloads; + if (!removed) return state; - // No count calculation - stats remain as-is until next refresh - return { - downloads: remaining, - stats: state.stats - }; - }); - } - }; + // No count calculation - stats remain as-is until next refresh + return { + downloads: remaining, + stats: state.stats, + }; + }); + }, + }; } export const downloads = createDownloadsStore(); // Derived stores export const activeDownloads = derived(downloads, ($d) => - Object.values($d.downloads).filter((d) => d.status === 'downloading') + Object.values($d.downloads).filter((d) => d.status === "downloading"), ); export const completedDownloads = derived(downloads, ($d) => - Object.values($d.downloads).filter((d) => d.status === 'completed') + Object.values($d.downloads).filter((d) => d.status === "completed"), ); export const pendingDownloads = derived(downloads, ($d) => - Object.values($d.downloads).filter((d) => d.status === 'pending') + Object.values($d.downloads).filter((d) => d.status === "pending"), ); export const failedDownloads = derived(downloads, ($d) => - Object.values($d.downloads).filter((d) => d.status === 'failed') + Object.values($d.downloads).filter((d) => d.status === "failed"), ); export const videoDownloads = derived(downloads, ($d) => - Object.values($d.downloads).filter((d) => d.mediaType === 'video') + Object.values($d.downloads).filter((d) => d.mediaType === "video"), ); export const audioDownloads = derived(downloads, ($d) => - Object.values($d.downloads).filter((d) => d.mediaType === 'audio' || !d.mediaType) + Object.values($d.downloads).filter((d) => d.mediaType === "audio" || !d.mediaType), ); /** @@ -517,32 +524,32 @@ export const audioDownloads = derived(downloads, ($d) => * Should be called once when the app starts (e.g., in +layout.svelte). */ export async function initDownloadEvents(): Promise { - if (isEventsInitialized) { - log.warn('Download events already initialized'); - return; - } + if (isEventsInitialized) { + log.warn("Download events already initialized"); + return; + } - try { - log.debug('🎧 Setting up download event listener...'); - unlistenFn = await listen('download-event', (event) => { - const payload = event.payload; - log.debug('📬 Received download event:', payload.type, 'for download:', payload.downloadId); - log.debug(' Full event payload:', JSON.stringify(payload)); + try { + log.debug("🎧 Setting up download event listener..."); + unlistenFn = await listen("download-event", (event) => { + const payload = event.payload; + log.debug("📬 Received download event:", payload.type, "for download:", payload.downloadId); + log.debug(" Full event payload:", JSON.stringify(payload)); - // Update the store based on event type - downloads.subscribe((state) => { - const download = state.downloads[payload.downloadId]; - log.debug(' Current download state:', download ? download.status : 'NOT IN STORE'); - })(); // Immediately unsubscribe after reading + // Update the store based on event type + downloads.subscribe((state) => { + const download = state.downloads[payload.downloadId]; + log.debug(" Current download state:", download ? download.status : "NOT IN STORE"); + })(); // Immediately unsubscribe after reading - handleDownloadEvent(payload); - }); + handleDownloadEvent(payload); + }); - isEventsInitialized = true; - log.debug('✅ Download event listener registered successfully'); - } catch (err) { - log.error('❌ Failed to register download event listener:', err); - } + isEventsInitialized = true; + log.debug("✅ Download event listener registered successfully"); + } catch (err) { + log.error("❌ Failed to register download event listener:", err); + } } /** @@ -550,121 +557,122 @@ export async function initDownloadEvents(): Promise { * Should be called when the app is destroyed. */ export function cleanupDownloadEvents(): void { - if (unlistenFn) { - unlistenFn(); - unlistenFn = null; - } - isEventsInitialized = false; + if (unlistenFn) { + unlistenFn(); + unlistenFn = null; + } + isEventsInitialized = false; } /** * Check if the download event listener is initialized. */ export function isDownloadEventsInitialized(): boolean { - return isEventsInitialized; + return isEventsInitialized; } /** * Handle a download event and update the store. */ function handleDownloadEvent(payload: DownloadEvent): void { - const currentState = get(downloads); - const download = currentState.downloads[payload.downloadId]; + const currentState = get(downloads); + const download = currentState.downloads[payload.downloadId]; - switch (payload.type) { - case 'queued': - // Just increment queue count - the download will be fetched on refresh - break; + switch (payload.type) { + case "queued": + // Just increment queue count - the download will be fetched on refresh + break; - case 'started': - if (download) { - updateDownloadInStore(payload.downloadId, { - status: 'downloading', - startedAt: new Date().toISOString() - }); - } - break; + case "started": + if (download) { + updateDownloadInStore(payload.downloadId, { + status: "downloading", + startedAt: new Date().toISOString(), + }); + } + break; - case 'progress': - if (download && payload.progress !== undefined) { - updateDownloadInStore(payload.downloadId, { - progress: payload.progress, - bytesDownloaded: payload.bytesDownloaded || download.bytesDownloaded, - fileSize: payload.totalBytes || download.fileSize - }); - } - break; + case "progress": + if (download && payload.progress !== undefined) { + updateDownloadInStore(payload.downloadId, { + progress: payload.progress, + bytesDownloaded: payload.bytesDownloaded || download.bytesDownloaded, + fileSize: payload.totalBytes || download.fileSize, + }); + } + break; - case 'completed': - if (download) { - // Persist to database - commands.markDownloadCompleted( - payload.downloadId, - payload.totalBytes || download.fileSize || download.bytesDownloaded, - payload.filePath || download.filePath - ).catch((err) => log.error('Failed to persist download completion:', err)); + case "completed": + if (download) { + // Persist to database + commands + .markDownloadCompleted( + payload.downloadId, + payload.totalBytes || download.fileSize || download.bytesDownloaded, + payload.filePath || download.filePath, + ) + .catch((err) => log.error("Failed to persist download completion:", err)); - updateDownloadInStore(payload.downloadId, { - status: 'completed', - progress: 1.0, - completedAt: new Date().toISOString(), - filePath: payload.filePath || download.filePath - }); - } - break; + updateDownloadInStore(payload.downloadId, { + status: "completed", + progress: 1.0, + completedAt: new Date().toISOString(), + filePath: payload.filePath || download.filePath, + }); + } + break; - case 'failed': - if (download) { - // Persist to database - commands.markDownloadFailed( - payload.downloadId, - payload.error || 'Unknown error' - ).catch((err) => log.error('Failed to persist download failure:', err)); + case "failed": + if (download) { + // Persist to database + commands + .markDownloadFailed(payload.downloadId, payload.error || "Unknown error") + .catch((err) => log.error("Failed to persist download failure:", err)); - updateDownloadInStore(payload.downloadId, { - status: 'failed', - errorMessage: payload.error - }); - } - break; + updateDownloadInStore(payload.downloadId, { + status: "failed", + errorMessage: payload.error, + }); + } + break; - case 'paused': - if (download) { - updateDownloadInStore(payload.downloadId, { - status: 'paused' - }); - } - break; + case "paused": + if (download) { + updateDownloadInStore(payload.downloadId, { + status: "paused", + }); + } + break; - case 'cancelled': - removeDownloadFromStore(payload.downloadId); - break; + case "cancelled": + removeDownloadFromStore(payload.downloadId); + break; - case 'waitingForNetwork': - // Queue-wide, not tied to one download: the pump refused to start - // anything because WiFi-only is on and we're on a metered network. - waitingForNetwork.set(true); - break; - } + case "waitingForNetwork": + // Queue-wide, not tied to one download: the pump refused to start + // anything because WiFi-only is on and we're on a metered network. + waitingForNetwork.set(true); + break; + } - // Any per-download progress proves the gate isn't holding us any more. - if (payload.type === 'started' || payload.type === 'progress') { - waitingForNetwork.set(false); - } + // Any per-download progress proves the gate isn't holding us any more. + if (payload.type === "started" || payload.type === "progress") { + waitingForNetwork.set(false); + } } /** * Helper to update a download in the store. */ function updateDownloadInStore(downloadId: number, updates: Partial): void { - log.debug(' updateDownloadInStore:', downloadId, updates); - downloads.updateDownload(downloadId, updates); + log.debug(" updateDownloadInStore:", downloadId, updates); + downloads.updateDownload(downloadId, updates); } /** * Helper to remove a download from the store. */ function removeDownloadFromStore(downloadId: number): void { - log.debug(' removeDownloadFromStore:', downloadId); - downloads.removeDownload(downloadId); + log.debug(" removeDownloadFromStore:", downloadId); + downloads.removeDownload(downloadId); } diff --git a/src/lib/stores/favorites.ts b/src/lib/stores/favorites.ts index e32507eb..acf83c67 100644 --- a/src/lib/stores/favorites.ts +++ b/src/lib/stores/favorites.ts @@ -56,7 +56,7 @@ export function clearAllFavorites(): void { */ export function resolveIsFavorite( item: Pick | null | undefined, - overrideMap: Map + overrideMap: Map, ): boolean { if (!item) return false; const override = overrideMap.get(item.id); @@ -80,7 +80,7 @@ export function isFavoriteNow(item: Pick): boolean */ export function retainFavorites>( items: T[], - overrideMap: Map + overrideMap: Map, ): T[] { return items.filter((item) => resolveIsFavorite(item, overrideMap)); } diff --git a/src/lib/stores/home.ts b/src/lib/stores/home.ts index 95ebf294..ff5dd016 100644 --- a/src/lib/stores/home.ts +++ b/src/lib/stores/home.ts @@ -3,10 +3,7 @@ import { writable, derived } from "svelte/store"; import type { MediaItem } from "$lib/api/types"; import { auth } from "./auth"; -import { - filterSupersededResumeItems, - filterInProgressNextUpItems, -} from "./continueWatchingFilter"; +import { filterSupersededResumeItems, filterInProgressNextUpItems } from "./continueWatchingFilter"; import { createLogger } from "$lib/utils/logger"; const log = createLogger("HomeStore"); @@ -45,7 +42,11 @@ function createHomeStore() { async function loadHomeSections() { // Only show loading spinner when no data is available yet - update(s => ({ ...s, isLoading: s.heroItems.length === 0 && s.latestItems.length === 0, error: null })); + update((s) => ({ + ...s, + isLoading: s.heroItems.length === 0 && s.latestItems.length === 0, + error: null, + })); try { const repo = auth.getRepository(); @@ -67,7 +68,9 @@ function createHomeStore() { ]); const valueOr = (i: number, fallback: T): T => - settled[i].status === "fulfilled" ? (settled[i] as PromiseFulfilledResult).value : fallback; + settled[i].status === "fulfilled" + ? (settled[i] as PromiseFulfilledResult).value + : fallback; const rawResume = valueOr(0, [] as typeof initialState.resumeItems); const rawNextUp = valueOr(1, [] as typeof initialState.nextUpItems); @@ -90,7 +93,7 @@ function createHomeStore() { // Use resume items or latest as hero items const hero = resume.length >= 3 ? resume.slice(0, 5) : latest.slice(0, 5); - update(s => ({ + update((s) => ({ ...s, heroItems: hero, resumeItems: resume, @@ -105,7 +108,7 @@ function createHomeStore() { })); } catch (error) { const message = error instanceof Error ? error.message : "Failed to load home sections"; - update(s => ({ ...s, isLoading: false, error: message })); + update((s) => ({ ...s, isLoading: false, error: message })); log.error("Failed to load home sections:", error); } } @@ -124,13 +127,13 @@ function createHomeStore() { export const home = createHomeStore(); // Derived stores for convenience -export const heroItems = derived(home, $home => $home.heroItems); -export const resumeItems = derived(home, $home => $home.resumeItems); -export const nextUpItems = derived(home, $home => $home.nextUpItems); -export const latestItems = derived(home, $home => $home.latestItems); -export const recentlyPlayedAudio = derived(home, $home => $home.recentlyPlayedAudio); -export const resumeMovies = derived(home, $home => $home.resumeMovies); -export const favoriteMovies = derived(home, $home => $home.favoriteMovies); -export const favoriteShows = derived(home, $home => $home.favoriteShows); -export const favoriteMusic = derived(home, $home => $home.favoriteMusic); -export const isHomeLoading = derived(home, $home => $home.isLoading); +export const heroItems = derived(home, ($home) => $home.heroItems); +export const resumeItems = derived(home, ($home) => $home.resumeItems); +export const nextUpItems = derived(home, ($home) => $home.nextUpItems); +export const latestItems = derived(home, ($home) => $home.latestItems); +export const recentlyPlayedAudio = derived(home, ($home) => $home.recentlyPlayedAudio); +export const resumeMovies = derived(home, ($home) => $home.resumeMovies); +export const favoriteMovies = derived(home, ($home) => $home.favoriteMovies); +export const favoriteShows = derived(home, ($home) => $home.favoriteShows); +export const favoriteMusic = derived(home, ($home) => $home.favoriteMusic); +export const isHomeLoading = derived(home, ($home) => $home.isLoading); diff --git a/src/lib/stores/index.ts b/src/lib/stores/index.ts index 43cd08eb..b80401d9 100644 --- a/src/lib/stores/index.ts +++ b/src/lib/stores/index.ts @@ -50,12 +50,7 @@ export { } from "./queue"; // Sessions store -export { - sessions, - activeSessions, - selectedSession, - controllableSessions, -} from "./sessions"; +export { sessions, activeSessions, selectedSession, controllableSessions } from "./sessions"; // Sleep timer store export { diff --git a/src/lib/stores/library.ts b/src/lib/stores/library.ts index 4420c2e8..9fadc012 100644 --- a/src/lib/stores/library.ts +++ b/src/lib/stores/library.ts @@ -115,7 +115,7 @@ function createLibraryStore() { async function loadItems( parentId: string, - options: { startIndex?: number; limit?: number; genres?: string[] } = {} + options: { startIndex?: number; limit?: number; genres?: string[] } = {}, ) { update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null })); @@ -206,7 +206,7 @@ function createLibraryStore() { const item = await repo.getItem(itemId); log.debug(`loadItem(${itemId}): ${item.name} (${item.kind})`); - log.debug(`- Has people? ${item.people ? `YES (${item.people.length})` : 'NO'}`); + log.debug(`- Has people? ${item.people ? `YES (${item.people.length})` : "NO"}`); if (item.people && item.people.length > 0) { item.people.forEach((p, i) => { log.debug(` [${i}] ${p.name} (type: "${p.type}", id: "${p.id}")`); @@ -257,7 +257,7 @@ function createLibraryStore() { // Add 10-second timeout to prevent indefinite hanging const timeoutPromise = new Promise((_, reject) => - setTimeout(() => reject(new Error("Search timeout - please try again")), 10000) + setTimeout(() => reject(new Error("Search timeout - please try again")), 10000), ); // Phase 1: the command resolves with instant local-cache results. The @@ -267,10 +267,7 @@ function createLibraryStore() { // never names a Jellyfin item type in connection with search. const options: SearchOptions = { limit: 10000, scope }; - const result = await Promise.race([ - repo.search(query, options, requestId), - timeoutPromise - ]); + const result = await Promise.race([repo.search(query, options, requestId), timeoutPromise]); // Only apply if this is still the active query (a newer search may have // started while we awaited). diff --git a/src/lib/stores/librarySearchScope.test.ts b/src/lib/stores/librarySearchScope.test.ts index 871ca1f6..487ed83c 100644 --- a/src/lib/stores/librarySearchScope.test.ts +++ b/src/lib/stores/librarySearchScope.test.ts @@ -98,9 +98,7 @@ describe("library.search scoping", () => { // First search resolves *after* a newer one has already started; its // results must not clobber the fresher ones. let resolveFirst: (value: unknown) => void = () => {}; - searchMock.mockImplementationOnce( - () => new Promise((resolve) => (resolveFirst = resolve)) - ); + searchMock.mockImplementationOnce(() => new Promise((resolve) => (resolveFirst = resolve))); searchMock.mockResolvedValueOnce(result([{ id: "new", type: "Movie" }])); const first = library.search("old", "all"); diff --git a/src/lib/stores/lmsSync.test.ts b/src/lib/stores/lmsSync.test.ts index 508a5ea7..bc2d917c 100644 --- a/src/lib/stores/lmsSync.test.ts +++ b/src/lib/stores/lmsSync.test.ts @@ -43,12 +43,16 @@ describe("lmsSync store", () => { it("fusing preserves existing slaves and adds the new zone", async () => { // Initial group: master M with slave A. - mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: ["A"], slaveNames: [] }]); + mockInvoke.mockResolvedValueOnce([ + { masterMac: "M", masterName: "M", slaveMacs: ["A"], slaveNames: [] }, + ]); await lmsSync.refresh(); // create returns void; refresh after returns the updated group. mockInvoke.mockResolvedValueOnce(undefined); - mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: ["A", "B"], slaveNames: [] }]); + mockInvoke.mockResolvedValueOnce([ + { masterMac: "M", masterName: "M", slaveMacs: ["A", "B"], slaveNames: [] }, + ]); await lmsSync.fuseZone("M", "B"); @@ -60,11 +64,15 @@ describe("lmsSync store", () => { }); it("decoupling a slave unsyncs just that player", async () => { - mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: ["A"], slaveNames: [] }]); + mockInvoke.mockResolvedValueOnce([ + { masterMac: "M", masterName: "M", slaveMacs: ["A"], slaveNames: [] }, + ]); await lmsSync.refresh(); mockInvoke.mockResolvedValueOnce(undefined); // unsync - mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: [], slaveNames: [] }]); + mockInvoke.mockResolvedValueOnce([ + { masterMac: "M", masterName: "M", slaveMacs: [], slaveNames: [] }, + ]); await lmsSync.decoupleZone("A"); @@ -72,7 +80,9 @@ describe("lmsSync store", () => { }); it("decoupling the master dissolves the whole group", async () => { - mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: ["A"], slaveNames: [] }]); + mockInvoke.mockResolvedValueOnce([ + { masterMac: "M", masterName: "M", slaveMacs: ["A"], slaveNames: [] }, + ]); await lmsSync.refresh(); mockInvoke.mockResolvedValueOnce(undefined); // dissolve diff --git a/src/lib/stores/movies.ts b/src/lib/stores/movies.ts index 457657b4..1e7425a9 100644 --- a/src/lib/stores/movies.ts +++ b/src/lib/stores/movies.ts @@ -51,7 +51,7 @@ function createMoviesStore() { !!(i.backdropImageTags && i.backdropImageTags.length > 0) || !!i.imageId; async function loadSections(libraryId: string) { - update(s => ({ + update((s) => ({ ...s, isLoading: s.continueWatching.length === 0 && s.recentlyAdded.length === 0, error: null, @@ -72,7 +72,7 @@ function createMoviesStore() { recursive: true, limit: SECTION_LIMIT, }) - .then(r => r.items) + .then((r) => r.items) .catch(() => [] as MediaItem[]), ]); @@ -80,7 +80,7 @@ function createMoviesStore() { // additions, then random picks from across the library. const heroItems = buildHeroMix([resume, latest, surprise], hasArt); - update(s => ({ + update((s) => ({ ...s, continueWatching: resume, recentlyAdded: latest, @@ -93,7 +93,7 @@ function createMoviesStore() { loadGenreRows(libraryId); } catch (error) { const message = error instanceof Error ? error.message : "Failed to load movie sections"; - update(s => ({ ...s, isLoading: false, error: message })); + update((s) => ({ ...s, isLoading: false, error: message })); log.error("Failed to load movie sections:", error); } } @@ -126,15 +126,15 @@ function createMoviesStore() { log.warn(`Failed to load genre row "${genre.name}":`, e); return { id: genre.id, name: genre.name, items: [] }; } - }) + }), ); const genreRows = rows - .filter(row => row.items.length > 0) + .filter((row) => row.items.length > 0) .sort((a, b) => b.items.length - a.items.length) .slice(0, MAX_GENRE_ROWS); - update(s => ({ ...s, genreRows })); + update((s) => ({ ...s, genreRows })); } catch (e) { log.warn("Failed to load movie genre rows:", e); } @@ -153,5 +153,5 @@ function createMoviesStore() { export const movies = createMoviesStore(); -export const moviesHeroItems = derived(movies, $m => $m.heroItems); -export const isMoviesLoading = derived(movies, $m => $m.isLoading); +export const moviesHeroItems = derived(movies, ($m) => $m.heroItems); +export const isMoviesLoading = derived(movies, ($m) => $m.isLoading); diff --git a/src/lib/stores/music.ts b/src/lib/stores/music.ts index d077e817..8ba8c7bf 100644 --- a/src/lib/stores/music.ts +++ b/src/lib/stores/music.ts @@ -60,7 +60,7 @@ function createMusicStore() { !!i.imageId || !!(i.backdropImageTags && i.backdropImageTags.length > 0); async function loadSections(libraryId: string) { - update(s => ({ + update((s) => ({ ...s, isLoading: s.recentlyPlayed.length === 0 && s.newlyAdded.length === 0, error: null, @@ -69,35 +69,37 @@ function createMusicStore() { try { const repo = auth.getRepository(); - const [recentlyPlayed, newlyAdded, playlistsResult, rediscover, surprise] = await Promise.all([ - repo.getRecentlyPlayedAudio(SECTION_LIMIT), - repo.getItems(libraryId, { - includeItemTypes: ["MusicAlbum"], - sortBy: "DateCreated", - sortOrder: "Descending", - recursive: true, - limit: SECTION_LIMIT, - }), - repo.getItems(libraryId, { - includeItemTypes: ["Playlist"], - sortBy: "SortName", - sortOrder: "Ascending", - recursive: true, - limit: SECTION_LIMIT, - }), - repo.getRediscoverAlbums(libraryId, SECTION_LIMIT), - // Random pool so the hero rotation changes between visits (SortBy=Random - // shuffles server-side online, and via SQLite RANDOM() offline). - repo - .getItems(libraryId, { + const [recentlyPlayed, newlyAdded, playlistsResult, rediscover, surprise] = await Promise.all( + [ + repo.getRecentlyPlayedAudio(SECTION_LIMIT), + repo.getItems(libraryId, { includeItemTypes: ["MusicAlbum"], - sortBy: "Random", + sortBy: "DateCreated", + sortOrder: "Descending", recursive: true, limit: SECTION_LIMIT, - }) - .then(r => r.items) - .catch(() => [] as MediaItem[]), - ]); + }), + repo.getItems(libraryId, { + includeItemTypes: ["Playlist"], + sortBy: "SortName", + sortOrder: "Ascending", + recursive: true, + limit: SECTION_LIMIT, + }), + repo.getRediscoverAlbums(libraryId, SECTION_LIMIT), + // Random pool so the hero rotation changes between visits (SortBy=Random + // shuffles server-side online, and via SQLite RANDOM() offline). + repo + .getItems(libraryId, { + includeItemTypes: ["MusicAlbum"], + sortBy: "Random", + recursive: true, + limit: SECTION_LIMIT, + }) + .then((r) => r.items) + .catch(() => [] as MediaItem[]), + ], + ); // Nothing is filtered here: folders the user chose to hide are already // gone, dropped by the repository layer that answered these queries. @@ -107,7 +109,7 @@ function createMusicStore() { // random albums from across the library. const heroItems = buildHeroMix([recentlyPlayed, rediscover, surprise], hasArt); - update(s => ({ + update((s) => ({ ...s, recentlyPlayed, newlyAdded: newlyAdded.items, @@ -122,7 +124,7 @@ function createMusicStore() { loadGenreRows(libraryId); } catch (error) { const message = error instanceof Error ? error.message : "Failed to load music sections"; - update(s => ({ ...s, isLoading: false, error: message })); + update((s) => ({ ...s, isLoading: false, error: message })); log.error("Failed to load music sections:", error); } } @@ -172,19 +174,17 @@ function createMusicStore() { // Treat that as "no usable counts" and fall through to the probe path, // which ranks by genres' real album counts instead. const positiveCounts = genres - .map(g => g.albumCount) + .map((g) => g.albumCount) .filter((c): c is number => c != null && c > 0); const hasUsefulCounts = new Set(positiveCounts).size > 1; let genreRows: GenreRow[]; if (hasUsefulCounts) { // Rank by reported count, pick a diverse subset, then fetch only those. - const ranked = [...genres].sort( - (a, b) => (b.albumCount ?? 0) - (a.albumCount ?? 0) - ); + const ranked = [...genres].sort((a, b) => (b.albumCount ?? 0) - (a.albumCount ?? 0)); const chosen = selectDiverseGenres(ranked, MAX_GENRE_ROWS); - genreRows = (await Promise.all(chosen.map(g => loadGenreRow(libraryId, g)))).filter( - row => row.items.length > 0 + genreRows = (await Promise.all(chosen.map((g) => loadGenreRow(libraryId, g)))).filter( + (row) => row.items.length > 0, ); } else { // No usable counts (offline, a server that ignores Fields=ItemCounts, @@ -194,14 +194,14 @@ function createMusicStore() { // list instead, so the probe pool spans A→Z; then drop empties, rank // by what came back, and pick a diverse subset. const probed = sampleAcross(genres, MAX_GENRES_PROBED); - const rows = await Promise.all(probed.map(g => loadGenreRow(libraryId, g))); + const rows = await Promise.all(probed.map((g) => loadGenreRow(libraryId, g))); const populated = rows - .filter(row => row.items.length > 0) + .filter((row) => row.items.length > 0) .sort((a, b) => b.items.length - a.items.length); genreRows = selectDiverseGenres(populated, MAX_GENRE_ROWS); } - update(s => ({ ...s, genreRows })); + update((s) => ({ ...s, genreRows })); } catch (e) { log.warn("Failed to load music genre rows:", e); } @@ -220,5 +220,5 @@ function createMusicStore() { export const music = createMusicStore(); -export const musicHeroItems = derived(music, $m => $m.heroItems); -export const isMusicLoading = derived(music, $m => $m.isLoading); +export const musicHeroItems = derived(music, ($m) => $m.heroItems); +export const isMusicLoading = derived(music, ($m) => $m.isLoading); diff --git a/src/lib/stores/nextEpisode.ts b/src/lib/stores/nextEpisode.ts index 6f492623..f2e0e6ba 100644 --- a/src/lib/stores/nextEpisode.ts +++ b/src/lib/stores/nextEpisode.ts @@ -47,7 +47,7 @@ function createNextEpisodeStore() { currentEpisode: MediaItem, nextEpisode: MediaItem, countdownSeconds: number, - autoPlayEnabled: boolean + autoPlayEnabled: boolean, ): void { update((s) => ({ ...s, @@ -105,4 +105,7 @@ export const currentEpisodeItem = derived(nextEpisode, ($ne) => $ne.currentEpiso export const countdownSeconds = derived(nextEpisode, ($ne) => $ne.countdownSeconds); export const initialCountdownSeconds = derived(nextEpisode, ($ne) => $ne.initialCountdownSeconds); export const isAutoPlayEnabled = derived(nextEpisode, ($ne) => $ne.autoPlayEnabled); -export const isCountdownActive = derived(nextEpisode, ($ne) => $ne.isVisible && $ne.countdownSeconds > 0); +export const isCountdownActive = derived( + nextEpisode, + ($ne) => $ne.isVisible && $ne.countdownSeconds > 0, +); diff --git a/src/lib/stores/playbackMode.test.ts b/src/lib/stores/playbackMode.test.ts index f86510e5..10193e62 100644 --- a/src/lib/stores/playbackMode.test.ts +++ b/src/lib/stores/playbackMode.test.ts @@ -387,7 +387,6 @@ describe("playbackMode store", () => { expect(state.remoteSessionId).toBeNull(); expect(mockSelectSession).toHaveBeenCalledWith(null); }); - }); describe("transfer reconciles to Rust on completion", () => { diff --git a/src/lib/stores/playbackMode.ts b/src/lib/stores/playbackMode.ts index e3ff04d8..89204594 100644 --- a/src/lib/stores/playbackMode.ts +++ b/src/lib/stores/playbackMode.ts @@ -107,7 +107,12 @@ function createPlaybackModeStore() { // Rust handles everything - just wait for it to complete // It includes its own 5-second timeout for track loading - log.debug("About to invoke playback_mode_transfer_to_remote with sessionId:", sessionId, "position:", positionOverride); + log.debug( + "About to invoke playback_mode_transfer_to_remote with sessionId:", + sessionId, + "position:", + positionOverride, + ); await commands.playbackModeTransferToRemote(sessionId ?? "", positionOverride); log.debug("Invoke completed successfully"); @@ -298,9 +303,7 @@ function createPlaybackModeStore() { const currentState = get({ subscribe }); if (currentState.mode === "remote") { log.debug("Lockscreen requested disconnect; transferring to local"); - transferToLocal().catch((e) => - log.error("Lockscreen-triggered transfer failed:", e), - ); + transferToLocal().catch((e) => log.error("Lockscreen-triggered transfer failed:", e)); } return; } @@ -317,8 +320,7 @@ function createPlaybackModeStore() { return; } const mode = event.payload.mode as PlaybackMode; - const remoteSessionId = - mode === "remote" ? event.payload.session_id ?? null : null; + const remoteSessionId = mode === "remote" ? (event.payload.session_id ?? null) : null; // Ignore no-op re-broadcasts. The backend re-emits on every set_mode, and // local playback drives set_mode("local") from BOTH the frontend @@ -327,10 +329,7 @@ function createPlaybackModeStore() { // one, deselecting the remote session mid-cast and tripping the // disconnect-to-idle watchdog (breaking the lockscreen card, remote // volume, and — via the resulting mode flap — local audio). - if ( - currentState.mode === mode && - currentState.remoteSessionId === remoteSessionId - ) { + if (currentState.mode === mode && currentState.remoteSessionId === remoteSessionId) { return; } @@ -352,8 +351,16 @@ function createPlaybackModeStore() { // If we're in remote mode but session is gone or lost control capability // Don't interfere during an active transfer (we intentionally clear the session) - if (currentState.mode === "remote" && currentState.remoteSessionId && !currentState.isTransferring) { - if (!session || session.id !== currentState.remoteSessionId || !session.supportsMediaControl) { + if ( + currentState.mode === "remote" && + currentState.remoteSessionId && + !currentState.isTransferring + ) { + if ( + !session || + session.id !== currentState.remoteSessionId || + !session.supportsMediaControl + ) { consecutiveMisses++; log.warn(`Remote session miss ${consecutiveMisses}/${DISCONNECT_THRESHOLD}`); diff --git a/src/lib/stores/player.ts b/src/lib/stores/player.ts index 756e7947..dda1725c 100644 --- a/src/lib/stores/player.ts +++ b/src/lib/stores/player.ts @@ -93,7 +93,7 @@ function createPlayerStore() { ...s.state, position, // Update duration if provided and valid - duration: duration !== undefined && duration > 0 ? duration : s.state.duration + duration: duration !== undefined && duration > 0 ? duration : s.state.duration, }, }; } @@ -174,10 +174,13 @@ function nowPlayingToMediaItem(npi: NowPlayingItem): MediaItem { // (Type, runTimeTicks, primaryImageTag). Map it onto the neutral MediaItem the // UI consumes. Only the coarse audio/video split matters here for display. const kind: MediaKind = - npi.Type === "Movie" ? "movie" : - npi.Type === "Episode" ? "episode" : - npi.Type === "MusicAlbum" ? "album" : - "track"; + npi.Type === "Movie" + ? "movie" + : npi.Type === "Episode" + ? "episode" + : npi.Type === "MusicAlbum" + ? "album" + : "track"; return { id: npi.id ?? "", name: npi.name ?? "", @@ -194,15 +197,12 @@ function nowPlayingToMediaItem(npi: NowPlayingItem): MediaItem { export const mergedMedia = derived< [typeof isRemoteMode, typeof selectedSession, typeof currentMedia], MediaItem | null ->( - [isRemoteMode, selectedSession, currentMedia], - ([$isRemote, $session, $local]) => { - if ($isRemote && $session?.nowPlayingItem) { - return nowPlayingToMediaItem($session.nowPlayingItem); - } - return $local ?? null; +>([isRemoteMode, selectedSession, currentMedia], ([$isRemote, $session, $local]) => { + if ($isRemote && $session?.nowPlayingItem) { + return nowPlayingToMediaItem($session.nowPlayingItem); } -); + return $local ?? null; +}); /** * Merged isPlaying state - prefers remote session when in remote mode @@ -214,7 +214,7 @@ export const mergedIsPlaying = derived( return !$session.playState.isPaused; } return $localIsPlaying; - } + }, ); /** @@ -227,7 +227,7 @@ export const mergedPosition = derived( return ticksToSeconds($session.playState.positionTicks ?? 0); } return $localPosition; - } + }, ); /** @@ -240,7 +240,7 @@ export const mergedDuration = derived( return ticksToSeconds($session.nowPlayingItem.runTimeTicks); } return $localDuration; - } + }, ); /** @@ -255,7 +255,7 @@ export const mergedVolume = derived( return ($session.playState.volumeLevel ?? 100) / 100; } return $localVolume; - } + }, ); /** @@ -317,5 +317,5 @@ export const shouldShowAudioMiniPlayer = derived( // playing / paused / loading / seeking — audio is active, show the bar. return true; - } + }, ); diff --git a/src/lib/stores/queue.ts b/src/lib/stores/queue.ts index e3894018..19f62eb5 100644 --- a/src/lib/stores/queue.ts +++ b/src/lib/stores/queue.ts @@ -192,7 +192,7 @@ export const queue = createQueueStore(); export const queueItems = derived(queue, ($q) => $q.items); export const currentQueueIndex = derived(queue, ($q) => $q.currentIndex); export const currentQueueItem = derived(queue, ($q) => - $q.currentIndex !== null ? $q.items[$q.currentIndex] : null + $q.currentIndex !== null ? $q.items[$q.currentIndex] : null, ); export const isShuffle = derived(queue, ($q) => $q.shuffle); export const repeatMode = derived(queue, ($q) => $q.repeat); diff --git a/src/lib/stores/searchGroupOrder.test.ts b/src/lib/stores/searchGroupOrder.test.ts index 9ad86dfe..520db85e 100644 --- a/src/lib/stores/searchGroupOrder.test.ts +++ b/src/lib/stores/searchGroupOrder.test.ts @@ -42,7 +42,7 @@ describe("searchGroupOrder", () => { it("loads a stored order", async () => { localStorage.setItem( STORAGE_KEY, - JSON.stringify(["episodes", "shows", "movies", "songs", "albums", "artists", "people"]) + JSON.stringify(["episodes", "shows", "movies", "songs", "albums", "artists", "people"]), ); const { searchGroupOrder } = await import("./searchGroupOrder"); expect(get(searchGroupOrder)).toEqual([ @@ -97,15 +97,7 @@ describe("searchGroupOrder", () => { // Default is shows, episodes, movies, songs, … — move movies up one. searchGroupOrder.move("movies", -1); - const expected = [ - "shows", - "movies", - "episodes", - "songs", - "albums", - "artists", - "people", - ]; + const expected = ["shows", "movies", "episodes", "songs", "albums", "artists", "people"]; expect(get(searchGroupOrder)).toEqual(expected); expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual(expected); diff --git a/src/lib/stores/sessions.test.ts b/src/lib/stores/sessions.test.ts index b7432dbc..08aedeab 100644 --- a/src/lib/stores/sessions.test.ts +++ b/src/lib/stores/sessions.test.ts @@ -76,7 +76,7 @@ describe("sessions store", () => { vi.useFakeTimers(); // Ensure window.setInterval and window.clearInterval are available - if (typeof window !== 'undefined') { + if (typeof window !== "undefined") { global.window = window as any; } }); diff --git a/src/lib/stores/sessions.ts b/src/lib/stores/sessions.ts index 2edb7f4e..e05afb4c 100644 --- a/src/lib/stores/sessions.ts +++ b/src/lib/stores/sessions.ts @@ -33,7 +33,9 @@ function createSessionsStore() { const sessions = event.payload.sessions as unknown as Session[]; log.debug(`Received ${sessions.length} sessions from backend`); sessions.forEach((s, i) => { - log.debug(`Session ${i}: id=${s.id}, device=${s.deviceName}, supportsRemoteControl=${s.supportsRemoteControl}`); + log.debug( + `Session ${i}: id=${s.id}, device=${s.deviceName}, supportsRemoteControl=${s.supportsRemoteControl}`, + ); }); update((s) => ({ ...s, @@ -55,7 +57,9 @@ function createSessionsStore() { log.debug(`Manual refresh returned ${sessions.length} sessions`); sessions.forEach((s, i) => { - log.debug(`Session ${i}: id=${s.id}, device=${s.deviceName}, supportsRemoteControl=${s.supportsRemoteControl}`); + log.debug( + `Session ${i}: id=${s.id}, device=${s.deviceName}, supportsRemoteControl=${s.supportsRemoteControl}`, + ); }); update((s) => ({ @@ -76,7 +80,6 @@ function createSessionsStore() { } } - /** * Select a session for control */ @@ -140,7 +143,10 @@ function createSessionsStore() { /** * Seek to position (in ticks) */ - async function sendSeek(sessionId: string | null | undefined, positionTicks: number): Promise { + async function sendSeek( + sessionId: string | null | undefined, + positionTicks: number, + ): Promise { try { await commands.remoteSessionSeek(sessionId ?? "", positionTicks); // Don't refresh immediately for seek to avoid UI lag @@ -182,7 +188,7 @@ function createSessionsStore() { async function playOnSession( sessionId: string | null | undefined, itemIds: string[], - startIndex = 0 + startIndex = 0, ): Promise { log.debug("========== playOnSession called =========="); log.debug("sessionId:", sessionId); @@ -224,9 +230,8 @@ export const sessions = createSessionsStore(); /** * Sessions that are currently playing media */ -export const activeSessions = derived( - sessions, - ($sessions) => $sessions.sessions.filter((s) => s.nowPlayingItem !== null) +export const activeSessions = derived(sessions, ($sessions) => + $sessions.sessions.filter((s) => s.nowPlayingItem !== null), ); /** @@ -234,22 +239,22 @@ export const activeSessions = derived( */ export const selectedSession = derived( sessions, - ($sessions) => - $sessions.sessions.find((s) => s.id === $sessions.selectedSessionId) ?? null + ($sessions) => $sessions.sessions.find((s) => s.id === $sessions.selectedSessionId) ?? null, ); /** * Controllable sessions (support remote control) */ -export const controllableSessions = derived( - sessions, - ($sessions) => { - const controllable = $sessions.sessions.filter((s) => s.supportsRemoteControl); - log.debug(`Filtering ${$sessions.sessions.length} total sessions, ${controllable.length} are controllable`); - $sessions.sessions.forEach((s, i) => { - const status = s.supportsRemoteControl ? "✓ CONTROLLABLE" : "✗ NOT CONTROLLABLE"; - log.debug(` ${status}: ${s.deviceName} (id=${s.id}, supportsRemoteControl=${s.supportsRemoteControl})`); - }); - return controllable; - } -); +export const controllableSessions = derived(sessions, ($sessions) => { + const controllable = $sessions.sessions.filter((s) => s.supportsRemoteControl); + log.debug( + `Filtering ${$sessions.sessions.length} total sessions, ${controllable.length} are controllable`, + ); + $sessions.sessions.forEach((s, i) => { + const status = s.supportsRemoteControl ? "✓ CONTROLLABLE" : "✗ NOT CONTROLLABLE"; + log.debug( + ` ${status}: ${s.deviceName} (id=${s.id}, supportsRemoteControl=${s.supportsRemoteControl})`, + ); + }); + return controllable; +}); diff --git a/src/lib/stores/sleepTimer.ts b/src/lib/stores/sleepTimer.ts index 7e4e3178..9f060dc8 100644 --- a/src/lib/stores/sleepTimer.ts +++ b/src/lib/stores/sleepTimer.ts @@ -69,16 +69,10 @@ export const sleepTimerExpiredSignal = writable(0); // Derived stores for convenient access export const sleepTimerMode = derived(sleepTimer, ($s) => $s.mode); -export const sleepTimerActive = derived( - sleepTimer, - ($s) => $s.mode.kind !== "off" -); +export const sleepTimerActive = derived(sleepTimer, ($s) => $s.mode.kind !== "off"); -export const sleepTimerRemainingSeconds = derived( - sleepTimer, - ($s) => $s.remainingSeconds -); +export const sleepTimerRemainingSeconds = derived(sleepTimer, ($s) => $s.remainingSeconds); export const sleepTimerRemainingEpisodes = derived(sleepTimer, ($s) => - $s.mode.kind === "episodes" ? $s.mode.remaining : 0 + $s.mode.kind === "episodes" ? $s.mode.remaining : 0, ); diff --git a/src/lib/stores/tv.ts b/src/lib/stores/tv.ts index 8b07242c..1ff59829 100644 --- a/src/lib/stores/tv.ts +++ b/src/lib/stores/tv.ts @@ -5,10 +5,7 @@ import { writable, derived } from "svelte/store"; import type { MediaItem } from "$lib/api/types"; import { auth } from "./auth"; import { buildHeroMix } from "$lib/utils/heroMix"; -import { - filterSupersededResumeItems, - filterInProgressNextUpItems, -} from "./continueWatchingFilter"; +import { filterSupersededResumeItems, filterInProgressNextUpItems } from "./continueWatchingFilter"; import { createLogger } from "$lib/utils/logger"; const log = createLogger("TvStore"); @@ -60,7 +57,7 @@ function createTvStore() { !!i.imageId; async function loadSections(libraryId: string) { - update(s => ({ + update((s) => ({ ...s, isLoading: s.continueWatching.length === 0 && s.recentlyAdded.length === 0, error: null, @@ -82,7 +79,7 @@ function createTvStore() { recursive: true, limit: SECTION_LIMIT, }) - .then(r => r.items) + .then((r) => r.items) .catch(() => [] as MediaItem[]), ]); @@ -91,8 +88,8 @@ function createTvStore() { // Then drop episodes the user has moved past — a stale partial position // behind the series' Next Up entry isn't something to continue. const continueWatching = filterSupersededResumeItems( - resume.filter(i => i.kind === "episode" || i.kind === "movie"), - rawNextUp + resume.filter((i) => i.kind === "episode" || i.kind === "movie"), + rawNextUp, ); // And drop from Next Up the episodes that are already under way — those // are Continue Watching's, or the two rows show the same cards. @@ -102,7 +99,7 @@ function createTvStore() { // recent additions, and random series from across the library. const heroItems = buildHeroMix([continueWatching, nextUp, latest, surprise], hasArt); - update(s => ({ + update((s) => ({ ...s, continueWatching, nextUp, @@ -116,7 +113,7 @@ function createTvStore() { loadGenreRows(libraryId); } catch (error) { const message = error instanceof Error ? error.message : "Failed to load TV sections"; - update(s => ({ ...s, isLoading: false, error: message })); + update((s) => ({ ...s, isLoading: false, error: message })); log.error("Failed to load TV sections:", error); } } @@ -149,15 +146,15 @@ function createTvStore() { log.warn(`Failed to load genre row "${genre.name}":`, e); return { id: genre.id, name: genre.name, items: [] }; } - }) + }), ); const genreRows = rows - .filter(row => row.items.length > 0) + .filter((row) => row.items.length > 0) .sort((a, b) => b.items.length - a.items.length) .slice(0, MAX_GENRE_ROWS); - update(s => ({ ...s, genreRows })); + update((s) => ({ ...s, genreRows })); } catch (e) { log.warn("Failed to load TV genre rows:", e); } @@ -176,5 +173,5 @@ function createTvStore() { export const tv = createTvStore(); -export const tvHeroItems = derived(tv, $t => $t.heroItems); -export const isTvLoading = derived(tv, $t => $t.isLoading); +export const tvHeroItems = derived(tv, ($t) => $t.heroItems); +export const isTvLoading = derived(tv, ($t) => $t.isLoading); diff --git a/src/lib/utils/debounce.test.ts b/src/lib/utils/debounce.test.ts index d1c21fbc..4f07e887 100644 --- a/src/lib/utils/debounce.test.ts +++ b/src/lib/utils/debounce.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; */ export function createDebouncedFunction any>( fn: T, - delayMs: number = 300 + delayMs: number = 300, ) { let timeout: ReturnType | null = null; diff --git a/src/lib/utils/duration.ts b/src/lib/utils/duration.ts index ca728f6d..46eea385 100644 --- a/src/lib/utils/duration.ts +++ b/src/lib/utils/duration.ts @@ -37,7 +37,10 @@ export function formatDuration(ms?: number | null, format: "mm:ss" | "hh:mm:ss" * @param format Format type: "mm:ss" (default) or "hh:mm:ss" * @returns Formatted duration string */ -export function formatSecondsDuration(seconds: number, format: "mm:ss" | "hh:mm:ss" = "mm:ss"): string { +export function formatSecondsDuration( + seconds: number, + format: "mm:ss" | "hh:mm:ss" = "mm:ss", +): string { if (format === "hh:mm:ss") { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); diff --git a/src/lib/utils/favoritesView.ts b/src/lib/utils/favoritesView.ts index c1135ca3..34c0a206 100644 --- a/src/lib/utils/favoritesView.ts +++ b/src/lib/utils/favoritesView.ts @@ -43,13 +43,9 @@ export function resolveFavoritesScope(raw: string | null | undefined): Favorites * * TRACES: UR-075 | DR-175 */ -export function asFavoritesScope( - scope: SearchScope | null | undefined, -): FavoritesScope | null { +export function asFavoritesScope(scope: SearchScope | null | undefined): FavoritesScope | null { if (!scope) return null; - return (FAVORITE_SCOPES as readonly string[]).includes(scope) - ? (scope as FavoritesScope) - : null; + return (FAVORITE_SCOPES as readonly string[]).includes(scope) ? (scope as FavoritesScope) : null; } /** URL for a tab. The default scope is omitted, keeping the base URL clean. */ diff --git a/src/lib/utils/genreDiversity.test.ts b/src/lib/utils/genreDiversity.test.ts index 5d984d37..34335914 100644 --- a/src/lib/utils/genreDiversity.test.ts +++ b/src/lib/utils/genreDiversity.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; import { selectDiverseGenres, sampleAcross } from "./genreDiversity"; -const g = (...names: string[]) => names.map(name => ({ name })); +const g = (...names: string[]) => names.map((name) => ({ name })); describe("sampleAcross", () => { it("returns input unchanged when at or under the count", () => { @@ -32,7 +32,7 @@ describe("selectDiverseGenres", () => { it("seeds with the most populous genre (first in input)", () => { const out = selectDiverseGenres(g("Rock", "Jazz", "Hip Hop"), 1); - expect(out.map(x => x.name)).toEqual(["Rock"]); + expect(out.map((x) => x.name)).toEqual(["Rock"]); }); it("spreads the family instead of stacking near-synonyms", () => { @@ -45,35 +45,28 @@ describe("selectDiverseGenres", () => { "Pop Rock", "Jazz", "Hip Hop", - "Classical" + "Classical", ); - const names = selectDiverseGenres(input, 4).map(x => x.name); + const names = selectDiverseGenres(input, 4).map((x) => x.name); expect(names[0]).toBe("Rock"); // seeded by count expect(names).toContain("Jazz"); expect(names).toContain("Hip Hop"); expect(names).toContain("Classical"); // Only the seed represents the Rock cluster. - expect(names.filter(n => n.includes("Rock"))).toEqual(["Rock"]); + expect(names.filter((n) => n.includes("Rock"))).toEqual(["Rock"]); }); it("preserves count order as the tie-breaker among equally-distinct genres", () => { // All four are mutually distinct (no shared tokens), so every pick after // the seed is a distance tie and should follow input (count) order. const input = g("Rock", "Jazz", "Blues", "Folk"); - expect(selectDiverseGenres(input, 3).map(x => x.name)).toEqual([ - "Rock", - "Jazz", - "Blues", - ]); + expect(selectDiverseGenres(input, 3).map((x) => x.name)).toEqual(["Rock", "Jazz", "Blues"]); }); it("is case- and separator-insensitive when comparing", () => { const input = g("Hip Hop", "hip-hop", "Reggae"); // "Hip Hop" and "hip-hop" share all tokens → the second is redundant. - expect(selectDiverseGenres(input, 2).map(x => x.name)).toEqual([ - "Hip Hop", - "Reggae", - ]); + expect(selectDiverseGenres(input, 2).map((x) => x.name)).toEqual(["Hip Hop", "Reggae"]); }); }); diff --git a/src/lib/utils/genreDiversity.ts b/src/lib/utils/genreDiversity.ts index d9c2c9a2..c6336c28 100644 --- a/src/lib/utils/genreDiversity.ts +++ b/src/lib/utils/genreDiversity.ts @@ -34,7 +34,7 @@ function tokenize(name: string): Set { name .toLowerCase() .split(/[^a-z0-9]+/) - .filter(Boolean) + .filter(Boolean), ); } @@ -54,11 +54,11 @@ function jaccard(a: Set, b: Set): number { */ export function selectDiverseGenres( candidates: T[], - limit: number + limit: number, ): T[] { if (candidates.length <= limit) return candidates.slice(); - const tokens = candidates.map(c => tokenize(c.name)); + const tokens = candidates.map((c) => tokenize(c.name)); const chosen: number[] = []; const remaining = new Set(candidates.map((_, i) => i)); @@ -91,5 +91,5 @@ export function selectDiverseGenres( remaining.delete(best); } - return chosen.map(i => candidates[i]); + return chosen.map((i) => candidates[i]); } diff --git a/src/lib/utils/heroMix.test.ts b/src/lib/utils/heroMix.test.ts index a3532665..e6785ffe 100644 --- a/src/lib/utils/heroMix.test.ts +++ b/src/lib/utils/heroMix.test.ts @@ -36,13 +36,13 @@ describe("buildHeroMix", () => { it("filters items without artwork", () => { const result = buildHeroMix([[item("a", false), item("b")]], hasArt); - expect(result.map(i => i.id)).toEqual(["b"]); + expect(result.map((i) => i.id)).toEqual(["b"]); }); it("de-duplicates across pools", () => { const a = item("a"); const result = buildHeroMix([[a], [a, item("b")]], hasArt); - const ids = result.map(i => i.id); + const ids = result.map((i) => i.id); expect(ids).toHaveLength(new Set(ids).size); expect(ids).toContain("a"); expect(ids).toContain("b"); @@ -63,8 +63,8 @@ describe("buildHeroMix", () => { // count 4 with perPool 2: exactly 2 from each pool, no backfill needed. for (let run = 0; run < 20; run++) { const result = buildHeroMix([a, b], hasArt, 4, 2); - const fromA = result.filter(i => i.id.startsWith("a")).length; - const fromB = result.filter(i => i.id.startsWith("b")).length; + const fromA = result.filter((i) => i.id.startsWith("a")).length; + const fromB = result.filter((i) => i.id.startsWith("b")).length; expect(fromA).toBe(2); expect(fromB).toBe(2); } diff --git a/src/lib/utils/heroMix.ts b/src/lib/utils/heroMix.ts index 4a499108..9cb5f11f 100644 --- a/src/lib/utils/heroMix.ts +++ b/src/lib/utils/heroMix.ts @@ -31,15 +31,15 @@ export function buildHeroMix( pools: MediaItem[][], hasArt: (item: MediaItem) => boolean, count = 6, - perPool = 2 + perPool = 2, ): MediaItem[] { const seen = new Set(); - const usable = pools.map(pool => - pool.filter(item => { + const usable = pools.map((pool) => + pool.filter((item) => { if (!hasArt(item) || seen.has(item.id)) return false; seen.add(item.id); return true; - }) + }), ); const picked = new Set(); @@ -54,6 +54,6 @@ export function buildHeroMix( if (picks.length === 0) return []; const [leader, ...rest] = picks; - const leftovers = shuffle(usable.flat().filter(item => !picked.has(item.id))); + const leftovers = shuffle(usable.flat().filter((item) => !picked.has(item.id))); return [leader, ...shuffle(rest), ...leftovers].slice(0, count); } diff --git a/src/lib/utils/layoutShell.ts b/src/lib/utils/layoutShell.ts index 2d2418ce..05b95752 100644 --- a/src/lib/utils/layoutShell.ts +++ b/src/lib/utils/layoutShell.ts @@ -30,15 +30,8 @@ export interface BottomUiVisibilityInput { * The bottom nav is shown on every authenticated route except the full-screen * player and the login route. */ -export function showBottomNav({ - pathname, - isAuthenticated, -}: BottomUiVisibilityInput): boolean { - return ( - isAuthenticated && - !pathname.startsWith("/player/") && - !pathname.startsWith("/login") - ); +export function showBottomNav({ pathname, isAuthenticated }: BottomUiVisibilityInput): boolean { + return isAuthenticated && !pathname.startsWith("/player/") && !pathname.startsWith("/login"); } /** @@ -81,15 +74,8 @@ export function routeOwnsLayout({ pathname }: { pathname: string }): boolean { * * TRACES: UR-054 | DR-076 */ -export function showGlobalHeader({ - pathname, - isAuthenticated, -}: BottomUiVisibilityInput): boolean { - return ( - isAuthenticated && - !routeOwnsLayout({ pathname }) && - !pathname.startsWith("/settings") - ); +export function showGlobalHeader({ pathname, isAuthenticated }: BottomUiVisibilityInput): boolean { + return isAuthenticated && !routeOwnsLayout({ pathname }) && !pathname.startsWith("/settings"); } /** diff --git a/src/lib/utils/menuPosition.ts b/src/lib/utils/menuPosition.ts index e061c329..891e38b5 100644 --- a/src/lib/utils/menuPosition.ts +++ b/src/lib/utils/menuPosition.ts @@ -6,7 +6,7 @@ export interface MenuPosition { x: number; y: number; - placement: 'bottom' | 'top'; + placement: "bottom" | "top"; } /** @@ -19,7 +19,7 @@ export interface MenuPosition { export function calculateMenuPosition( triggerElement: HTMLElement, menuWidth: number = 160, - menuHeight: number = 120 + menuHeight: number = 120, ): MenuPosition { const rect = triggerElement.getBoundingClientRect(); const viewportHeight = window.innerHeight; @@ -32,20 +32,20 @@ export function calculateMenuPosition( const fitsAbove = spaceAbove >= menuHeight + 8; let y: number; - let placement: 'bottom' | 'top'; + let placement: "bottom" | "top"; if (fitsBelow) { // Prefer below if there's space y = rect.bottom + 4; // 4px gap - placement = 'bottom'; + placement = "bottom"; } else if (fitsAbove) { // Show above if no space below y = rect.top - menuHeight - 4; // 4px gap - placement = 'top'; + placement = "top"; } else { // Not enough space either way - prefer below and let it extend y = rect.bottom + 4; - placement = 'bottom'; + placement = "bottom"; } // Horizontal positioning - align right edge of menu with right edge of button diff --git a/src/lib/utils/nativeVideoLayers.test.ts b/src/lib/utils/nativeVideoLayers.test.ts index a280dc62..c8b49832 100644 --- a/src/lib/utils/nativeVideoLayers.test.ts +++ b/src/lib/utils/nativeVideoLayers.test.ts @@ -79,8 +79,7 @@ describe("native-video compositing layers (DR-185)", () => { .filter((attr) => attr !== "data-native-video"); const unset = [...new Set(attributes)].filter((attr) => !markup.includes(attr)); - expect(unset, `app.css targets attributes no component sets: ${unset.join(", ")}`) - .toEqual([]); + expect(unset, `app.css targets attributes no component sets: ${unset.join(", ")}`).toEqual([]); }); it("still sets data-native-video on from the store", () => { diff --git a/src/lib/utils/navigation.test.ts b/src/lib/utils/navigation.test.ts index 800423db..3111c6b8 100644 --- a/src/lib/utils/navigation.test.ts +++ b/src/lib/utils/navigation.test.ts @@ -3,8 +3,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const goto = vi.fn(); // Capture the afterNavigate callback so tests can simulate navigations and thus // drive the in-app depth counter that canGoBack/navigateBack rely on. -let afterNavigateCb: ((nav: { from: unknown; to: unknown; delta?: number }) => void) | null = - null; +let afterNavigateCb: ((nav: { from: unknown; to: unknown; delta?: number }) => void) | null = null; vi.mock("$app/navigation", () => ({ goto: (...args: unknown[]) => goto(...args), afterNavigate: (cb: (nav: any) => void) => { diff --git a/src/lib/utils/pictureInPicture.ts b/src/lib/utils/pictureInPicture.ts index 0682b0b3..575d79a5 100644 --- a/src/lib/utils/pictureInPicture.ts +++ b/src/lib/utils/pictureInPicture.ts @@ -114,7 +114,7 @@ export function setHtml5VideoState( active: boolean, width: number, height: number, - playing: boolean + playing: boolean, ): void { try { bridge()?.setHtml5VideoState(active, Math.round(width), Math.round(height), playing); diff --git a/src/lib/utils/safeArea.test.ts b/src/lib/utils/safeArea.test.ts index 1bf5f6fd..84aae266 100644 --- a/src/lib/utils/safeArea.test.ts +++ b/src/lib/utils/safeArea.test.ts @@ -212,8 +212,8 @@ describe("safe-area wiring in source", () => { for (const edge of ["top", "right", "bottom", "left"]) { expect(css).toMatch( new RegExp( - `--safe-${edge}:\\s*max\\(\\s*env\\(safe-area-inset-${edge}[^)]*\\)\\s*,\\s*var\\(--jt-inset-${edge}[^)]*\\)\\s*\\)` - ) + `--safe-${edge}:\\s*max\\(\\s*env\\(safe-area-inset-${edge}[^)]*\\)\\s*,\\s*var\\(--jt-inset-${edge}[^)]*\\)\\s*\\)`, + ), ); } }); diff --git a/src/lib/utils/scrollContainer.ts b/src/lib/utils/scrollContainer.ts index e6d72b51..2a6eae80 100644 --- a/src/lib/utils/scrollContainer.ts +++ b/src/lib/utils/scrollContainer.ts @@ -46,7 +46,7 @@ export function clearScrollMemories(): void { export function useScrollRestore( getElement: () => HTMLElement | null | undefined, - containerId: string + containerId: string, ): void { const memory = memoryFor(containerId); diff --git a/src/lib/utils/scrollRestore.ts b/src/lib/utils/scrollRestore.ts index b620b21c..354e51c3 100644 --- a/src/lib/utils/scrollRestore.ts +++ b/src/lib/utils/scrollRestore.ts @@ -30,10 +30,7 @@ export type NavKind = "enter" | "popstate" | "forward"; /** What to do with the container once the new route has rendered. */ -export type ScrollAction = - | { kind: "reset" } - | { kind: "restore"; top: number } - | { kind: "none" }; +export type ScrollAction = { kind: "reset" } | { kind: "restore"; top: number } | { kind: "none" }; /** * Collapse SvelteKit's navigation types into the three cases that matter. diff --git a/src/lib/utils/searchScope.test.ts b/src/lib/utils/searchScope.test.ts index 62a5d0b1..09f16b32 100644 --- a/src/lib/utils/searchScope.test.ts +++ b/src/lib/utils/searchScope.test.ts @@ -155,7 +155,7 @@ describe("groupsForScope", () => { "albums", "artists", "people", - ]) + ]), ).toEqual(["movies", "songs", "shows", "episodes", "albums", "artists", "people"]); }); @@ -291,7 +291,7 @@ describe("composeSearchGroups", () => { const groups = composeSearchGroups( [{ id: "x", type: null }, { id: "y" }] as { id: string; type?: string | null }[], "all", - DEFAULT_GROUP_ORDER + DEFAULT_GROUP_ORDER, ); expect(groups).toEqual([]); }); @@ -311,13 +311,7 @@ describe("moveGroup", () => { }); it("moves a group down", () => { - expect(moveGroup(order, "songs", 1)).toEqual([ - "albums", - "songs", - "artists", - "movies", - "shows", - ]); + expect(moveGroup(order, "songs", 1)).toEqual(["albums", "songs", "artists", "movies", "shows"]); }); it("is a no-op at the boundaries", () => { @@ -340,20 +334,8 @@ describe("reorderGroups", () => { const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "shows"]; it("moves an item from one index to another", () => { - expect(reorderGroups(order, 0, 4)).toEqual([ - "albums", - "artists", - "movies", - "shows", - "songs", - ]); - expect(reorderGroups(order, 4, 0)).toEqual([ - "shows", - "songs", - "albums", - "artists", - "movies", - ]); + expect(reorderGroups(order, 0, 4)).toEqual(["albums", "artists", "movies", "shows", "songs"]); + expect(reorderGroups(order, 4, 0)).toEqual(["shows", "songs", "albums", "artists", "movies"]); }); it("is a no-op for equal or out-of-range indices", () => { diff --git a/src/lib/utils/searchScope.ts b/src/lib/utils/searchScope.ts index 73de0fec..fa74f99f 100644 --- a/src/lib/utils/searchScope.ts +++ b/src/lib/utils/searchScope.ts @@ -98,7 +98,7 @@ export function shouldNavigateToSearch(pathname: string, query: string): boolean /** Read a `?scope=` value, falling back when it is absent or unrecognised. */ export function parseSearchScope( raw: string | null | undefined, - fallback: SearchScope = "all" + fallback: SearchScope = "all", ): SearchScope { return SEARCH_SCOPES.includes(raw as SearchScope) ? (raw as SearchScope) : fallback; } @@ -126,7 +126,7 @@ export interface SearchSeed { */ export function seedFromSearchUrl( params: URLSearchParams, - applied: SearchSeed | null + applied: SearchSeed | null, ): SearchSeed | null { const seed: SearchSeed = { query: params.get("q") ?? "", @@ -141,13 +141,7 @@ export function seedFromSearchUrl( // --------------------------------------------------------------------------- export type SearchGroupId = - | "shows" - | "episodes" - | "movies" - | "songs" - | "albums" - | "artists" - | "people"; + "shows" | "episodes" | "movies" | "songs" | "albums" | "artists" | "people"; /** * Shipped default order. @@ -268,12 +262,12 @@ export function normalizeGroupOrder(stored: unknown): SearchGroupId[] { /** Groups visible under a scope, in the user's configured order. */ export function groupsForScope( scope: SearchScope, - order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER + order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER, ): SearchGroupId[] { // A `null` GROUP_SCOPE (people) belongs to no narrow scope, so it survives // only under `all` — the `=== scope` test already excludes it elsewhere. return normalizeGroupOrder(order as SearchGroupId[]).filter( - (id) => scope === "all" || GROUP_SCOPE[id] === scope + (id) => scope === "all" || GROUP_SCOPE[id] === scope, ); } @@ -292,7 +286,7 @@ export interface SearchGroup { export function composeSearchGroups( results: readonly T[], scope: SearchScope, - order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER + order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER, ): SearchGroup[] { return groupsForScope(scope, order) .map((id) => { @@ -310,7 +304,7 @@ export function composeSearchGroups( export function moveGroup( order: readonly SearchGroupId[], id: SearchGroupId, - delta: number + delta: number, ): SearchGroupId[] { const next = [...order]; const from = next.indexOf(id); @@ -325,7 +319,7 @@ export function moveGroup( export function reorderGroups( order: readonly SearchGroupId[], from: number, - to: number + to: number, ): SearchGroupId[] { const next = [...order]; if (from < 0 || from >= next.length || to < 0 || to >= next.length || from === to) return next; diff --git a/src/lib/utils/validation.ts b/src/lib/utils/validation.ts index 8e8820a1..1a23cec3 100644 --- a/src/lib/utils/validation.ts +++ b/src/lib/utils/validation.ts @@ -87,7 +87,12 @@ export function validateUrlPathSegment(segment: string): void { /** * Validate numeric parameter (width, height, quality, etc.) */ -export function validateNumericParam(value: unknown, min = 0, max = 10000, name = "parameter"): number { +export function validateNumericParam( + value: unknown, + min = 0, + max = 10000, + name = "parameter", +): number { // Must be an actual number, not a string that looks like a number if (typeof value !== "number") { throw new Error(`Invalid ${name}: must be an integer`); diff --git a/src/lib/utils/videoSurface.ts b/src/lib/utils/videoSurface.ts index 41eeb281..5409ae10 100644 --- a/src/lib/utils/videoSurface.ts +++ b/src/lib/utils/videoSurface.ts @@ -78,7 +78,7 @@ export function enableNativeVideoCompositing(): void { // console bridge forwards this to logcat under the JellyTauWeb tag. log.error( "AndroidVideoSurface bridge is MISSING - the webview will " + - "stay opaque and native video will play as audio with no picture" + "stay opaque and native video will play as audio with no picture", ); return; } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 5cb6a5ea..901253ee 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -13,7 +13,12 @@ import { downloads, initDownloadEvents, cleanupDownloadEvents } from "$lib/stores/downloads"; import { syncService } from "$lib/services/syncService"; import { clearFavorite } from "$lib/stores/favorites"; - import { onReconnected as onCatalogReconnected, refreshSyncStatus, showServerCatalog, lastCatalogSync } from "$lib/services/offlineCatalog"; + import { + onReconnected as onCatalogReconnected, + refreshSyncStatus, + showServerCatalog, + lastCatalogSync, + } from "$lib/services/offlineCatalog"; import { playbackMode } from "$lib/stores/playbackMode"; import { sessions } from "$lib/stores/sessions"; import ReauthModal from "$lib/components/auth/ReauthModal.svelte"; @@ -22,7 +27,12 @@ import BottomUi from "$lib/components/BottomUi.svelte"; import AppHeader from "$lib/components/AppHeader.svelte"; import PendingSyncModal from "$lib/components/sync/PendingSyncModal.svelte"; - import { isInitialized, pendingSyncCount, isAndroid, showSleepTimerModal } from "$lib/stores/appState"; + import { + isInitialized, + pendingSyncCount, + isAndroid, + showSleepTimerModal, + } from "$lib/stores/appState"; import { showBottomNav as computeShowBottomNav, showGlobalMiniPlayer as computeShowGlobalMiniPlayer, @@ -73,7 +83,7 @@ // gone. See BottomUi.svelte. const pathname = $derived($page.url.pathname); const showBottomNav = $derived( - computeShowBottomNav({ pathname, isAuthenticated: $isAuthenticated }) + computeShowBottomNav({ pathname, isAuthenticated: $isAuthenticated }), ); const showGlobalMiniPlayer = $derived(computeShowGlobalMiniPlayer({ pathname })); @@ -81,7 +91,7 @@ // authenticated non-immersive route that doesn't own its own layout. Library // renders its own AppHeader; settings/player/login get none. (UR-054) const showGlobalHeader = $derived( - computeShowGlobalHeader({ pathname, isAuthenticated: $isAuthenticated }) + computeShowGlobalHeader({ pathname, isAuthenticated: $isAuthenticated }), ); // Library/settings/player/login own their own full-height flex column @@ -95,7 +105,7 @@ // bottom UI at all (login, the full-screen player). Exactly one owner, or the // bar is either ignored or double-padded. (UR-066) const shellPadsBottom = $derived( - shellReservesBottomInset({ pathname, isAuthenticated: $isAuthenticated }) + shellReservesBottomInset({ pathname, isAuthenticated: $isAuthenticated }), ); onMount(async () => { @@ -138,12 +148,9 @@ // the session overrides for those ids so the next render reads the freshly // cached server value rather than a stale local guess. // TRACES: UR-069 | DR-120 - stopFavoritesListener = await listen<{ itemIds: string[] }>( - "favorites-changed", - (event) => { - for (const id of event.payload?.itemIds ?? []) clearFavorite(id); - } - ); + stopFavoritesListener = await listen<{ itemIds: string[] }>("favorites-changed", (event) => { + for (const id of event.payload?.itemIds ?? []) clearFavorite(id); + }); // Report the network transport to the backend and keep it current, so the // WiFi-only download gate has real data to act on (UR-053). No-op on @@ -156,9 +163,7 @@ // not-downloaded until the user opens the Downloads page. const userId = get(auth).user?.id; if (userId) { - downloads.refresh(userId).catch((err) => - log.error("Initial downloads refresh failed:", err) - ); + downloads.refresh(userId).catch((err) => log.error("Initial downloads refresh failed:", err)); } // Start sync service for offline mutation queue @@ -170,9 +175,7 @@ // whole time. Safe when it isn't: an unreachable server leaves rows queued // without spending their retry budget (DR-131). if (get(auth).user?.id) { - commands.syncProcessPending().catch((err) => - log.debug("Startup sync drain skipped:", err) - ); + commands.syncProcessPending().catch((err) => log.debug("Startup sync drain skipped:", err)); } // Load the last-sync hint for the offline banner. The catalog *index* is no @@ -210,16 +213,18 @@ connectivity.forceCheck().catch((error) => { // If check fails, monitoring might not be started yet, so start it log.debug("Queue status check failed, starting monitoring:", error); - connectivity.startMonitoring(session.serverUrl, { - onServerReconnected: () => { - // Retry session verification when server becomes reachable - auth.retryVerification(); - // Resume offline-queued downloads and refresh the catalog. - void onCatalogReconnected(); - }, - }).catch((monitorError) => { - log.error("Failed to start connectivity monitoring:", monitorError); - }); + connectivity + .startMonitoring(session.serverUrl, { + onServerReconnected: () => { + // Retry session verification when server becomes reachable + auth.retryVerification(); + // Resume offline-queued downloads and refresh the catalog. + void onCatalogReconnected(); + }, + }) + .catch((monitorError) => { + log.error("Failed to start connectivity monitoring:", monitorError); + }); }); } }); @@ -280,9 +285,16 @@ {#if isInitialized} {#if $isAuthenticated && !$isConnected} -
    +
    - + You're offline. Some features may be limited. - showSleepTimerModal.set(false)} - /> + showSleepTimerModal.set(false)} /> - (showPendingSync = false)} - /> + (showPendingSync = false)} /> {:else}
    -
    +
    {/if}
    diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index cd7a3ffc..c58e25da 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -115,7 +115,7 @@ // Playlist libraries live inside the Music landing page, not as top-level shortcuts. const shortcutLibraries = $derived( - $libraries.filter((lib) => lib.collectionType !== "playlists") + $libraries.filter((lib) => lib.collectionType !== "playlists"), ); // The shortcut strip is a mosaic row: one height, each tile as wide as its own @@ -124,7 +124,11 @@ // TRACES: UR-075 | DR-174 const LIBRARY_STRIP_HEIGHT = 132; const libraryTiles = $derived( - shortcutLibraries.map((lib) => ({ key: lib.id, ratio: assumedLibraryRatio(lib), library: lib })) + shortcutLibraries.map((lib) => ({ + key: lib.id, + ratio: assumedLibraryRatio(lib), + library: lib, + })), ); function handleLibraryClick(lib: Library) { @@ -148,9 +152,9 @@ } const heroItems = $derived($home.heroItems); - const resumeItems = $derived($home.resumeItems.filter( - i => i.kind === "movie" || i.kind === "episode" - )); + const resumeItems = $derived( + $home.resumeItems.filter((i) => i.kind === "movie" || i.kind === "episode"), + ); const nextUpItems = $derived($home.nextUpItems); const latestItems = $derived($home.latestItems); const recentlyPlayedAudio = $derived($home.recentlyPlayedAudio); @@ -165,136 +169,145 @@ {#if isLoading}
    -
    +
    {:else} -
    +
    + + {#if heroItems.length > 0} + + {/if} - - {#if heroItems.length > 0} - - {/if} - - - {#if shortcutLibraries.length > 0} -
    -

    Your Libraries

    -
    - - {#snippet tile(entry)} - handleLibraryClick(entry.library)} - /> - {/snippet} - + + {#if shortcutLibraries.length > 0} +
    +

    Your Libraries

    +
    + + {#snippet tile(entry)} + handleLibraryClick(entry.library)} + /> + {/snippet} + +
    -
    - {/if} + {/if} - - {#if resumeMovies.length > 0} - - {/if} + + {#if resumeMovies.length > 0} + + {/if} - - {#if nextUpItems.length > 0} - - {/if} + + {#if nextUpItems.length > 0} + + {/if} - - {#if recentlyPlayedAudio.length > 0} - - {/if} + + {#if recentlyPlayedAudio.length > 0} + + {/if} - - {#if resumeItems.length > 0} - - {/if} + + {#if resumeItems.length > 0} + + {/if} - - {#if latestItems.length > 0} - - {/if} + + {#if latestItems.length > 0} + + {/if} - - {#if favoriteMovies.length > 0} - goto("/library/favorites?scope=movies")} - /> - {/if} + {#if favoriteMovies.length > 0} + goto("/library/favorites?scope=movies")} + /> + {/if} - {#if favoriteShows.length > 0} - goto("/library/favorites?scope=tv")} - /> - {/if} + {#if favoriteShows.length > 0} + goto("/library/favorites?scope=tv")} + /> + {/if} - {#if favoriteMusic.length > 0} - goto("/library/favorites?scope=music")} - /> - {/if} + {#if favoriteMusic.length > 0} + goto("/library/favorites?scope=music")} + /> + {/if} - -
    - -
    + +
    + +
    {/if} diff --git a/src/routes/downloads/+page.svelte b/src/routes/downloads/+page.svelte index f01f7d5a..521c9207 100644 --- a/src/routes/downloads/+page.svelte +++ b/src/routes/downloads/+page.svelte @@ -16,7 +16,13 @@ @@ -106,7 +106,10 @@

    {$currentLibrary?.name ?? "Movies"}

    {:else if isLoading}
    -
    +
    {:else}
    @@ -168,7 +173,9 @@ {/each} {#if !hasContent} -

    Nothing here yet. Add some movies to your library to fill this page.

    +

    + Nothing here yet. Add some movies to your library to fill this page. +

    {/if}
    {/if} diff --git a/src/routes/library/music/+page.svelte b/src/routes/library/music/+page.svelte index ebecc46c..e5859d68 100644 --- a/src/routes/library/music/+page.svelte +++ b/src/routes/library/music/+page.svelte @@ -97,13 +97,15 @@ recentlyPlayed.length > 0 || newlyAdded.length > 0 || playlists.length > 0 || - rediscover.length > 0 + rediscover.length > 0, ); {#if isLoading}
    -
    +
    {:else}
    @@ -111,13 +113,21 @@

    Music

    @@ -129,11 +139,7 @@ {#if recentlyPlayed.length > 0} - + {/if} @@ -176,7 +182,9 @@ {/each} {#if !hasContent} -

    Nothing here yet. Start playing some music to fill this page.

    +

    + Nothing here yet. Start playing some music to fill this page. +

    {/if} @@ -188,8 +196,14 @@ onclick={() => goto(category.route)} class="group relative flex items-center gap-3 bg-[var(--color-surface)] hover:bg-white/10 rounded-xl p-4 text-left transition-colors" > -
    - +
    +
    diff --git a/src/routes/library/music/playlists/+page.svelte b/src/routes/library/music/playlists/+page.svelte index 2b5ba45e..1a223896 100644 --- a/src/routes/library/music/playlists/+page.svelte +++ b/src/routes/library/music/playlists/+page.svelte @@ -27,19 +27,16 @@
    - showCreateModal = false} -/> + (showCreateModal = false)} /> diff --git a/src/routes/library/tv/+page.svelte b/src/routes/library/tv/+page.svelte index 2256d80f..67a1dc40 100644 --- a/src/routes/library/tv/+page.svelte +++ b/src/routes/library/tv/+page.svelte @@ -114,7 +114,7 @@ heroItems.length > 0 || continueWatching.length > 0 || nextUp.length > 0 || - recentlyAdded.length > 0 + recentlyAdded.length > 0, ); @@ -123,7 +123,10 @@

    {$currentLibrary?.name ?? "TV Shows"}

    {:else if isLoading}
    -
    +
    {:else}
    @@ -190,7 +195,9 @@ {/each} {#if !hasContent} -

    Nothing here yet. Start watching something to fill this page.

    +

    + Nothing here yet. Start watching something to fill this page. +

    {/if}
    {/if} diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index 00e87d01..b559e1ab 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -28,7 +28,8 @@ // Reject plain HTTP — all connections must use HTTPS if (serverUrl.trim().toLowerCase().startsWith("http://")) { - localError = "HTTP connections are not allowed. Please use HTTPS (e.g., https://your-server.com)."; + localError = + "HTTP connections are not allowed. Please use HTTPS (e.g., https://your-server.com)."; connecting = false; return; } @@ -80,7 +81,9 @@ {#if $isLoading}
    -
    +
    {:else if step === "server"} @@ -111,7 +114,9 @@ class="w-full py-3 px-4 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] disabled:opacity-50 disabled:cursor-not-allowed rounded-lg font-medium transition-colors flex items-center justify-center gap-2" > {#if connecting} -
    +
    Connecting... {:else} Connect @@ -126,7 +131,12 @@ class="text-gray-400 hover:text-white text-sm flex items-center gap-1" > - + Back @@ -186,13 +196,28 @@ {#if showPassword} - + {:else} - - + + {/if} @@ -211,7 +236,9 @@ class="w-full py-3 px-4 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] disabled:opacity-50 disabled:cursor-not-allowed rounded-lg font-medium transition-colors flex items-center justify-center gap-2" > {#if loggingIn} -
    +
    Signing in... {:else} Sign In diff --git a/src/routes/player/[id]/+page.svelte b/src/routes/player/[id]/+page.svelte index d258c9b8..33472657 100644 --- a/src/routes/player/[id]/+page.svelte +++ b/src/routes/player/[id]/+page.svelte @@ -8,13 +8,27 @@ import type { MediaItem, MediaKind } from "$lib/api/types"; import { auth } from "$lib/stores/auth"; import { library } from "$lib/stores/library"; - import { queue, currentQueueItem, isShuffle, repeatMode, hasNext as hasNextStore, hasPrevious as hasPreviousStore } from "$lib/stores/queue"; + import { + queue, + currentQueueItem, + isShuffle, + repeatMode, + hasNext as hasNextStore, + hasPrevious as hasPreviousStore, + } from "$lib/stores/queue"; import { downloads, type DownloadInfo } from "$lib/stores/downloads"; - import { playbackPosition, playbackDuration, currentMedia as storeCurrentMedia } from "$lib/stores/player"; + import { + playbackPosition, + playbackDuration, + currentMedia as storeCurrentMedia, + } from "$lib/stores/player"; import { get } from "svelte/store"; import AudioPlayer from "$lib/components/player/AudioPlayer.svelte"; import VideoPlayer from "$lib/components/player/VideoPlayer.svelte"; - import { shouldReuseActivePlayback, resolvePlayerSurface } from "$lib/components/player/playerSurface"; + import { + shouldReuseActivePlayback, + resolvePlayerSurface, + } from "$lib/components/player/playerSurface"; import NextEpisodePopup from "$lib/components/player/NextEpisodePopup.svelte"; import { reportPlaybackStart, @@ -100,7 +114,14 @@ const id = itemId; const restart = restartParam; if (id && id !== loadedItemId) { - autoPlayLog.debug("$effect triggered: loading new item", id, "(was:", loadedItemId, ") restart:", restart); + autoPlayLog.debug( + "$effect triggered: loading new item", + id, + "(was:", + loadedItemId, + ") restart:", + restart, + ); // restart=true (advancing to next episode) forces start-from-beginning, // bypassing the resume-progress check. loadAndPlay(id, restart ? 0 : undefined, restart); @@ -121,8 +142,7 @@ // treat it as video when it carries a video media stream. function isVideoChannelItem(item: MediaItem): boolean { return ( - item.kind === "channelItem" && - (item.mediaStreams?.some((s) => s.kind === "video") ?? false) + item.kind === "channelItem" && (item.mediaStreams?.some((s) => s.kind === "video") ?? false) ); } @@ -140,7 +160,15 @@ currentMedia = item; // Check if this is a non-playable collection type that should be viewed in library instead - const collectionKinds: MediaKind[] = ["album", "artist", "series", "season", "folder", "playlist", "channel"]; + const collectionKinds: MediaKind[] = [ + "album", + "artist", + "series", + "season", + "folder", + "playlist", + "channel", + ]; if (item.kind && collectionKinds.includes(item.kind)) { log.debug("loadAndPlay: Redirecting collection type to library:", item.kind); goto(`/library/${id}`); @@ -150,7 +178,8 @@ // Determine if this is video content (Movie, Episode, live TV channels, and // channel leaf items that carry a video stream). isLive = item.kind === "liveChannel"; - isVideo = item.kind === "movie" || item.kind === "episode" || isLive || isVideoChannelItem(item); + isVideo = + item.kind === "movie" || item.kind === "episode" || isLive || isVideoChannelItem(item); // If this track is already playing in the backend, just show the UI // without restarting playback (e.g., when expanding from MiniPlayer). @@ -190,7 +219,16 @@ // When forceRestart is set (advancing to a next episode) we always start // from the beginning, skipping the resume check and resume dialog. const userId = auth.getUserId(); - log.debug("Resume check - userId:", userId, "itemId:", id, "startPosition:", startPosition, "forceRestart:", forceRestart); + log.debug( + "Resume check - userId:", + userId, + "itemId:", + id, + "startPosition:", + startPosition, + "forceRestart:", + forceRestart, + ); // Live streams have no fixed position - never resume. if (!startPosition && !forceRestart && userId && !isLive) { @@ -203,7 +241,14 @@ const totalSeconds = item.durationMs / 1000; const progressPercent = (positionSeconds / totalSeconds) * 100; - log.debug("Resume check - positionSeconds:", positionSeconds, "totalSeconds:", totalSeconds, "progressPercent:", progressPercent); + log.debug( + "Resume check - positionSeconds:", + positionSeconds, + "totalSeconds:", + totalSeconds, + "progressPercent:", + progressPercent, + ); // Store for later use regardless of whether dialog is shown retrievedProgressSeconds = positionSeconds; @@ -216,10 +261,22 @@ loading = false; return; // Wait for user decision } else { - log.debug("Resume check - NOT showing dialog. Position > 30?", positionSeconds > 30, "Progress < 90?", progressPercent < 90); + log.debug( + "Resume check - NOT showing dialog. Position > 30?", + positionSeconds > 30, + "Progress < 90?", + progressPercent < 90, + ); } } else { - log.debug("Resume check - No valid progress found. Has progress?", !!progress, "Has position?", progress?.positionMs, "Has runtime?", !!item.durationMs); + log.debug( + "Resume check - No valid progress found. Has progress?", + !!progress, + "Has position?", + progress?.positionMs, + "Has runtime?", + !!item.durationMs, + ); } } catch (e) { log.error("Failed to check saved progress:", e); @@ -232,12 +289,15 @@ // Check if this item is downloaded locally const downloadsState = get(downloads); const localDownload = Object.values(downloadsState.downloads).find( - (d: DownloadInfo) => d.itemId === id && d.status === "completed" + (d: DownloadInfo) => d.itemId === id && d.status === "completed", ); if (localDownload) { // Use local file for playback - log.debug("loadAndPlay: Found local download, using offline playback:", localDownload.filePath); + log.debug( + "loadAndPlay: Found local download, using offline playback:", + localDownload.filePath, + ); isOfflinePlayback = true; // Get the storage path and resolve the file's location. A completed @@ -309,7 +369,12 @@ if (isVideo) { // Playback API now detects HEVC/10-bit and returns transcoded URL when needed - log.debug("loadAndPlay: Using video stream, directPlay:", playbackInfo.directPlay, "needsTranscoding:", playbackInfo.needsTranscoding); + log.debug( + "loadAndPlay: Using video stream, directPlay:", + playbackInfo.directPlay, + "needsTranscoding:", + playbackInfo.needsTranscoding, + ); mediaSourceId = playbackInfo.mediaSourceId; // Prefer a completed download over streaming. Audio has done this @@ -336,7 +401,7 @@ log.debug( source.isLocal ? "loadAndPlay: Playing downloaded file from disk" - : `loadAndPlay: Using stream URL: ${streamUrl}` + : `loadAndPlay: Using stream URL: ${streamUrl}`, ); // Set initial position for the video player to seek to after load. @@ -358,68 +423,105 @@ // For audio, use MPV backend log.debug("loadAndPlay: Using MPV backend for audio"); - // Check if we have a queue parameter (e.g., queue=parent:albumId) - const queueParamValue = queueParam; - if (queueParamValue?.startsWith("parent:")) { - const parentId = queueParamValue.substring(7); // Remove "parent:" prefix - log.debug("loadAndPlay: Loading queue from parent:", parentId); + // Check if we have a queue parameter (e.g., queue=parent:albumId) + const queueParamValue = queueParam; + if (queueParamValue?.startsWith("parent:")) { + const parentId = queueParamValue.substring(7); // Remove "parent:" prefix + log.debug("loadAndPlay: Loading queue from parent:", parentId); - // Fetch all tracks from the parent (album/playlist) - const result = await repo.getItems(parentId, { - sortBy: "SortName", - sortOrder: "Ascending", - limit: 500, - }); - const audioTracks = result.items.filter(t => t.kind === "track"); + // Fetch all tracks from the parent (album/playlist) + const result = await repo.getItems(parentId, { + sortBy: "SortName", + sortOrder: "Ascending", + limit: 500, + }); + const audioTracks = result.items.filter((t) => t.kind === "track"); - if (audioTracks.length > 0) { - // Find the index of the current item in the tracks - const startIndex = audioTracks.findIndex(t => t.id === id); - const actualStartIndex = startIndex >= 0 ? startIndex : 0; + if (audioTracks.length > 0) { + // Find the index of the current item in the tracks + const startIndex = audioTracks.findIndex((t) => t.id === id); + const actualStartIndex = startIndex >= 0 ? startIndex : 0; - log.debug("loadAndPlay: Building queue with", audioTracks.length, "tracks, startIndex:", actualStartIndex); + log.debug( + "loadAndPlay: Building queue with", + audioTracks.length, + "tracks, startIndex:", + actualStartIndex, + ); - // Build queue items with stream URLs - // Add error handling and logging for each track - const queueItems = await Promise.all(audioTracks.map(async (t, idx) => { - try { - log.debug(`loadAndPlay: Fetching stream URL for track ${idx + 1}/${audioTracks.length}: ${t.name}`); - const trackStreamUrl = await repo.getAudioStreamUrl(t.id); - if (!trackStreamUrl) { - log.error(`loadAndPlay: Empty stream URL for track: ${t.name}`); - throw new Error(`Failed to get stream URL for ${t.name}`); - } - return { - id: t.id, - title: t.name, - artist: t.artists?.join(", ") || null, - album: t.albumName || null, - duration: t.durationMs ? t.durationMs / 1000 : null, - artworkUrl: t.imageId - ? repo.getImageUrl(t.albumId || t.id, "Primary", { maxWidth: 300, tag: t.imageId }) - : null, - mediaType: "audio", - streamUrl: trackStreamUrl, - jellyfinItemId: t.id, - }; - } catch (e) { - log.error(`loadAndPlay: Failed to build queue item for track ${t.name}:`, e); - throw e; // Re-throw to fail fast and show error to user - } - })); + // Build queue items with stream URLs + // Add error handling and logging for each track + const queueItems = await Promise.all( + audioTracks.map(async (t, idx) => { + try { + log.debug( + `loadAndPlay: Fetching stream URL for track ${idx + 1}/${audioTracks.length}: ${t.name}`, + ); + const trackStreamUrl = await repo.getAudioStreamUrl(t.id); + if (!trackStreamUrl) { + log.error(`loadAndPlay: Empty stream URL for track: ${t.name}`); + throw new Error(`Failed to get stream URL for ${t.name}`); + } + return { + id: t.id, + title: t.name, + artist: t.artists?.join(", ") || null, + album: t.albumName || null, + duration: t.durationMs ? t.durationMs / 1000 : null, + artworkUrl: t.imageId + ? repo.getImageUrl(t.albumId || t.id, "Primary", { + maxWidth: 300, + tag: t.imageId, + }) + : null, + mediaType: "audio", + streamUrl: trackStreamUrl, + jellyfinItemId: t.id, + }; + } catch (e) { + log.error(`loadAndPlay: Failed to build queue item for track ${t.name}:`, e); + throw e; // Re-throw to fail fast and show error to user + } + }), + ); - // Use player_play_queue to set up the backend queue - await commands.playerPlayQueue({ - items: queueItems, - startIndex: actualStartIndex, - shuffle: shuffleParam, - } as unknown as PlayQueueRequest); + // Use player_play_queue to set up the backend queue + await commands.playerPlayQueue({ + items: queueItems, + startIndex: actualStartIndex, + shuffle: shuffleParam, + } as unknown as PlayQueueRequest); - // Queue will auto-update from Rust backend event - log.debug("loadAndPlay: Successfully set up queue with", audioTracks.length, "tracks"); + // Queue will auto-update from Rust backend event + log.debug( + "loadAndPlay: Successfully set up queue with", + audioTracks.length, + "tracks", + ); + } else { + // Fallback to single item playback + log.debug( + "loadAndPlay: No audio tracks found in parent, falling back to single item", + ); + // Use player_play_tracks - backend fetches all metadata from single ID + const repo = auth.getRepository(); + const repositoryHandle = repo.getHandle(); + + await commands.playerPlayTracks(repositoryHandle, { + trackIds: [item.id], + startIndex: 0, + shuffle: false, + context: { + type: "search", + searchQuery: "", + }, + }); + + // Queue will auto-update from Rust backend event + log.debug("loadAndPlay: Set queue with single item:", item.name); + } } else { - // Fallback to single item playback - log.debug("loadAndPlay: No audio tracks found in parent, falling back to single item"); + // No queue parameter - single item playback // Use player_play_tracks - backend fetches all metadata from single ID const repo = auth.getRepository(); const repositoryHandle = repo.getHandle(); @@ -437,25 +539,6 @@ // Queue will auto-update from Rust backend event log.debug("loadAndPlay: Set queue with single item:", item.name); } - } else { - // No queue parameter - single item playback - // Use player_play_tracks - backend fetches all metadata from single ID - const repo = auth.getRepository(); - const repositoryHandle = repo.getHandle(); - - await commands.playerPlayTracks(repositoryHandle, { - trackIds: [item.id], - startIndex: 0, - shuffle: false, - context: { - type: "search", - searchQuery: "", - }, - }); - - // Queue will auto-update from Rust backend event - log.debug("loadAndPlay: Set queue with single item:", item.name); - } // Seek to start position if provided if (startPosition) { @@ -468,11 +551,22 @@ loading = false; // Fetch next episode for video episodes (for skip button) - nextEpisodeLog.debug("Post-load check: isVideo=", isVideo, "currentMedia=", currentMedia?.kind, currentMedia?.name); + nextEpisodeLog.debug( + "Post-load check: isVideo=", + isVideo, + "currentMedia=", + currentMedia?.kind, + currentMedia?.name, + ); if (isVideo && currentMedia) { fetchNextEpisode(currentMedia); } else { - nextEpisodeLog.debug("Skipped fetchNextEpisode - isVideo:", isVideo, "currentMedia:", !!currentMedia); + nextEpisodeLog.debug( + "Skipped fetchNextEpisode - isVideo:", + isVideo, + "currentMedia:", + !!currentMedia, + ); } } catch (e) { log.error("loadAndPlay error:", e); @@ -545,7 +639,10 @@ * * TRACES: UR-004, UR-005, UR-021 | DR-181 */ - async function handleVideoSeek(_positionSeconds: number, audioStreamIndex?: number): Promise { + async function handleVideoSeek( + _positionSeconds: number, + audioStreamIndex?: number, + ): Promise { const repo = auth.getRepository(); const id = itemId; if (!id) throw new Error("No item ID"); @@ -604,7 +701,13 @@ // and check for next episodes. HTML5 video plays independently of the Rust // backend queue, so the backend needs these to know what just finished. const mediaId = currentMedia?.id ?? null; - autoPlayLog.debug("Video ended. currentMedia:", mediaId, currentMedia?.name, "itemId (URL):", itemId); + autoPlayLog.debug( + "Video ended. currentMedia:", + mediaId, + currentMedia?.name, + "itemId (URL):", + itemId, + ); try { const repo = auth.getRepository(); const repoHandle = repo.getHandle(); @@ -616,7 +719,14 @@ async function fetchNextEpisode(media: MediaItem) { nextEpisode = null; - nextEpisodeLog.debug("fetchNextEpisode called:", { kind: media.kind, seriesId: media.seriesId, seasonId: media.seasonId, indexNumber: media.indexNumber, id: media.id, name: media.name }); + nextEpisodeLog.debug("fetchNextEpisode called:", { + kind: media.kind, + seriesId: media.seriesId, + seasonId: media.seasonId, + indexNumber: media.indexNumber, + id: media.id, + name: media.name, + }); if (media.kind !== "episode" || !media.seasonId || media.indexNumber == null) { nextEpisodeLog.debug("Skipping - not an episode or missing seasonId/indexNumber"); return; @@ -624,17 +734,37 @@ try { const repo = auth.getRepository(); // Fetch all episodes in the season sorted by episode number - const result = await repo.getItems(media.seasonId, { sortBy: "IndexNumber", sortOrder: "Ascending", limit: 500 }); - const episodes = result.items.filter(e => e.kind === "episode"); - nextEpisodeLog.debug("Season has", episodes.length, "episodes, current index:", media.indexNumber); + const result = await repo.getItems(media.seasonId, { + sortBy: "IndexNumber", + sortOrder: "Ascending", + limit: 500, + }); + const episodes = result.items.filter((e) => e.kind === "episode"); + nextEpisodeLog.debug( + "Season has", + episodes.length, + "episodes, current index:", + media.indexNumber, + ); // Find the episode after the current one by index number - const currentIdx = episodes.findIndex(e => e.id === media.id); + const currentIdx = episodes.findIndex((e) => e.id === media.id); if (currentIdx >= 0 && currentIdx < episodes.length - 1) { nextEpisode = episodes[currentIdx + 1]; - nextEpisodeLog.debug("Set nextEpisode:", nextEpisode.name, "index:", nextEpisode.indexNumber); + nextEpisodeLog.debug( + "Set nextEpisode:", + nextEpisode.name, + "index:", + nextEpisode.indexNumber, + ); } else { - nextEpisodeLog.debug("No next episode in season (current position:", currentIdx, "of", episodes.length, ")"); + nextEpisodeLog.debug( + "No next episode in season (current position:", + currentIdx, + "of", + episodes.length, + ")", + ); } } catch (e) { nextEpisodeLog.error("Failed to fetch next episode:", e); @@ -664,9 +794,9 @@ const secs = Math.floor(seconds % 60); if (hours > 0) { - return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + return `${hours}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`; } - return `${minutes}:${secs.toString().padStart(2, '0')}`; + return `${minutes}:${secs.toString().padStart(2, "0")}`; } @@ -675,7 +805,9 @@

    Resume Playback?

    - You've watched {savedProgress.progressPercent.toFixed(0)}% of this {isVideo ? 'video' : 'audio'}. + You've watched {savedProgress.progressPercent.toFixed(0)}% of this {isVideo + ? "video" + : "audio"}.

    Resume from {formatTime(savedProgress.positionSeconds)} or start from the beginning? @@ -700,11 +832,9 @@

    Playback Error

    -
    {error}
    -
    @@ -713,7 +843,9 @@
    -
    +
    {:else if surface === "video" && streamUrl} - +

    Search your entire library

    Find music, movies, shows, and more

    diff --git a/src/routes/sessions/+page.svelte b/src/routes/sessions/+page.svelte index 4102fb0d..e7343831 100644 --- a/src/routes/sessions/+page.svelte +++ b/src/routes/sessions/+page.svelte @@ -18,9 +18,7 @@

    Remote Sessions

    -

    - Control playback on other Jellyfin clients -

    +

    Control playback on other Jellyfin clients

    @@ -38,8 +36,15 @@
    {:else} -
    - +
    + -
    +

    How to use Remote Sessions

    • Start playing media on another Jellyfin client (TV, web browser, mobile app)
    • diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index d43d5d1d..ce2fe824 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -26,10 +26,7 @@ import PendingSyncList from "$lib/components/sync/PendingSyncList.svelte"; import { library, viewMode } from "$lib/stores/library"; import { auth } from "$lib/stores/auth"; - import { - isNetworkDetectionSupported, - reportNetworkState, - } from "$lib/services/networkType"; + import { isNetworkDetectionSupported, reportNetworkState } from "$lib/services/networkType"; import { experimentalNativeVideo } from "$lib/stores/nativeVideo"; import { getPlaybackCapabilities } from "$lib/services/playbackCapabilities"; import { createLogger } from "$lib/utils/logger"; @@ -442,9 +439,7 @@

      Display

      -

      - How your library and collections are laid out -

      +

      How your library and collections are laid out

      Layout

      @@ -486,10 +481,9 @@

      Hidden Folders

      - Folders to leave out of music browsing and search. Useful when a - music library also holds podcasts or audiobooks. Hidden folders can - still be opened from a direct link, and anything already playing or - downloaded is unaffected. + Folders to leave out of music browsing and search. Useful when a music library also + holds podcasts or audiobooks. Hidden folders can still be opened from a direct link, and + anything already playing or downloaded is unaffected.

      @@ -497,8 +491,7 @@

      Loading folders...

      {:else if exclusionCandidates.length === 0}

      - No music folders to choose from. Connect to your server to pick - folders to hide. + No music folders to choose from. Connect to your server to pick folders to hide.

      {:else}
      @@ -506,7 +499,7 @@

      - Changes apply to listings loaded from now on; reopen a page to see - them take effect. + Changes apply to listings loaded from now on; reopen a page to see them take effect.

      {/if}
      @@ -538,9 +528,7 @@

      Crossfade

      -

      - Fade between tracks for seamless transitions -

      +

      Fade between tracks for seamless transitions

      @@ -569,9 +557,7 @@

      Gapless Playback

      -

      - Eliminate silence between tracks in albums -

      +

      Eliminate silence between tracks in albums

      @@ -820,10 +802,10 @@

      Streaming Quality

      - Limit how much bandwidth video streams may use. Lower settings ask the - server to transcode before sending, which saves data on metered or slow - connections at the cost of picture quality. You can also change this for - a single video from the player's quality menu. + Limit how much bandwidth video streams may use. Lower settings ask the server to + transcode before sending, which saves data on metered or slow connections at the cost of + picture quality. You can also change this for a single video from the player's quality + menu.

      {#each streamingQualities as [quality, label, detail]} @@ -831,8 +813,8 @@ onclick={() => handleStreamingQualityChange(quality)} class="py-3 px-3 rounded-lg transition-all text-left {videoSettings.streamingQuality === quality - ? 'bg-[var(--color-jellyfin)] text-white' - : 'bg-gray-700 text-gray-300 hover:bg-gray-600'}" + ? 'bg-[var(--color-jellyfin)] text-white' + : 'bg-gray-700 text-gray-300 hover:bg-gray-600'}" aria-pressed={videoSettings.streamingQuality === quality} >
      {label}
      @@ -841,8 +823,8 @@ {/each}

      - Applies to videos started from now on; a video already playing keeps the - quality it started at. + Applies to videos started from now on; a video already playing keeps the quality it + started at.

      @@ -861,11 +843,10 @@

      - Decode video with the device's hardware decoder instead of the - built-in web player, for better performance and battery life, - and so picture-in-picture shows the video rather than the app. - On by default. Turn it off to fall back to the built-in web - player if a video misbehaves. + Decode video with the device's hardware decoder instead of the built-in web + player, for better performance and battery life, and so picture-in-picture shows + the video rather than the app. On by default. Turn it off to fall back to the + built-in web player if a video misbehaves.

      -

      - Takes effect the next time you start a video. -

      +

      Takes effect the next time you start a video.

      {/if}
      @@ -896,8 +875,8 @@

      Result Group Order

      - Drag or use the arrows to choose the order search result groups appear in. - Empty groups are hidden automatically. + Drag or use the arrows to choose the order search result groups appear in. Empty groups + are hidden automatically.

      @@ -928,7 +907,9 @@ Loading... {:else if cacheStats} - {formatBytes(cacheStats.totalSizeBytes)} / {cacheStats.limitBytes === 0 ? "Unlimited" : formatBytes(cacheStats.limitBytes)} + {formatBytes(cacheStats.totalSizeBytes)} / {cacheStats.limitBytes === 0 + ? "Unlimited" + : formatBytes(cacheStats.limitBytes)} {/if}
    @@ -937,7 +918,11 @@
    @@ -999,9 +984,7 @@

    Storage Limit

    -

    - Maximum storage for offline downloads -

    +

    Maximum storage for offline downloads

    @@ -1100,9 +1081,7 @@ onclick={handleWifiOnlyToggle} class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors {cacheConfig.wifiOnly ? 'bg-[var(--color-jellyfin)]' - : 'bg-gray-600'} {networkDetectionSupported - ? '' - : 'opacity-50 cursor-not-allowed'}" + : 'bg-gray-600'} {networkDetectionSupported ? '' : 'opacity-50 cursor-not-allowed'}" aria-label="Toggle WiFi only downloads" aria-pressed={cacheConfig.wifiOnly} disabled={!networkDetectionSupported} @@ -1135,16 +1114,15 @@

    About these settings:

    • - Crossfade smoothly blends the end of one track with the - beginning of the next + Crossfade smoothly blends the end of one track with the beginning of + the next
    • - Gapless removes silence between tracks for continuous - album playback + Gapless removes silence between tracks for continuous album playback
    • - Normalization evens out loudness between tracks - in real time, toward your selected level + Normalization evens out loudness between tracks in real time, toward + your selected level
    diff --git a/vitest.config.ts b/vitest.config.ts index 0c55890d..70244235 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,13 +14,7 @@ export default defineConfig({ coverage: { provider: "v8", reporter: ["text", "json", "html"], - exclude: [ - "node_modules/", - "src/test/", - "**/*.test.ts", - "**/*.spec.ts", - "src-tauri/", - ], + exclude: ["node_modules/", "src/test/", "**/*.test.ts", "**/*.spec.ts", "src-tauri/"], }, }, resolve: { -- 2.39.5 From 8f5c9023d0fd0e14b6edff73b916c016560ff295 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Fri, 21 Aug 2026 18:11:26 +0200 Subject: [PATCH 02/10] ci: make the frontend gates real, and fix the coverage script The repo configured four frontend gates and enforced one of them. eslint and prettier ran in no workflow and no hook; `bun run check` ran only in build-release.yml, so a type error could sit on master until somebody cut a tag; and `bun run test:coverage` had been dead for months. CI (build-and-test.yml) now runs format:check, lint, check and coverage alongside the existing boundary and doc-link tripwires. The coverage script failure was a version mismatch, not a config problem: @vitest/coverage-v8 resolved to 4.1.10, whose peer range pins vitest exactly, while package.json asked for ">=1.0.0 <5.0.0" and got 4.0.16 -- every run died on a missing BaseCoverageProvider export. The loose range is what allowed the pair to drift, so it is now ^4.1.10. Two ratchets, same policy as MIN_THRESHOLD in traceability-check.yml: eslint --max-warnings=159 (0 errors; 159 is today's backlog, only ever lower it) vitest thresholds (statements 51 / branches 45 / functions 46 / lines 52, measured at 54.6 / 48.7 / 49.6 / 55.1) no-console is promoted from "off" to "error": the logger-facade migration it was waiting on is finished -- 8 calls remained, 2 of them real stragglers in the settings page, now on the facade the file already imported. The sink itself, tests, and scripts/ are exempted; a CLI whose stdout is the product is not a stray debug statement. The threshold was verified to bite by raising it to 99 and watching the run go red, not by assuming an unfailed gate works. DR-205 moves to Done; the coverage gate is DR-215. --- .gitea/workflows/build-and-test.yml | 30 ++++++++++++++++++++++- CLAUDE.md | 18 +++++++++----- bun.lock | 34 +++++++++++--------------- docs/requirements.md | 3 ++- eslint.config.js | 38 +++++++++++++++++++++++------ package.json | 2 +- scripts/hooks/pre-commit | 6 +++++ src/routes/settings/+page.svelte | 4 +-- vitest.config.ts | 25 +++++++++++++++++++ 9 files changed, 122 insertions(+), 38 deletions(-) diff --git a/.gitea/workflows/build-and-test.yml b/.gitea/workflows/build-and-test.yml index 996fee72..c7079fb4 100644 --- a/.gitea/workflows/build-and-test.yml +++ b/.gitea/workflows/build-and-test.yml @@ -82,10 +82,38 @@ jobs: - name: Check documentation links run: bash scripts/check-doc-links.sh + # Formatting, linting and type-checking were all configured in this repo + # and enforced by nothing: .prettierrc described a tree where 199 files did + # not match it, eslint.config.js ran in no workflow and in no hook, and + # `bun run check` ran only in build-release.yml — i.e. a type error could + # sit on master until somebody cut a tag. These three steps are what make + # those configs load-bearing. All are project deps installed by + # `bun install`; nothing is fetched at job time. + - name: Check formatting + run: bun run format:check + + # RATCHET — this number only ever goes DOWN. Same policy as MIN_THRESHOLD + # in traceability-check.yml and the coverage thresholds in + # vitest.config.ts. 159 is what the tree carried when the gate went in; the + # backlog is real findings (dead bindings, unkeyed {#each}, `any` at the + # IPC boundary) that eslint.config.js documents rule by rule, each parked + # at "warn" until its class is cleared and it can be promoted to "error". + # Lower this as you clear them. Never raise it to make a build pass. + - name: Lint + run: bun run lint -- --max-warnings=159 + + - name: Check TypeScript + run: | + bunx svelte-kit sync + bun run check + + # Coverage rather than a bare `bun run test`: same suite, plus the + # thresholds in vitest.config.ts, so a large untested module or a deleted + # test fails here instead of being noticed months later. - name: Run frontend tests run: | bunx svelte-kit sync - bun run test + bun run test:coverage # CLAUDE.md has required `cargo fmt` + `cargo clippy` before every commit # for as long as the rule has existed, but nothing in CI checked either, diff --git a/CLAUDE.md b/CLAUDE.md index 6dc8f00d..8d09fa7d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,8 @@ bun run check # svelte-check (types) bun run test # vitest (frontend unit/integration) bun run test:rust # cargo test (scripts/test-rust.sh) bun run test:all # full suite (scripts/test-all.sh) -bun run test:e2e # webdriverio e2e +bun run lint # eslint (src/, scripts/, root configs) +bun run format:check # prettier # Android — canonical entry points (see scripts/): bun run android:build # debug APK @@ -61,7 +62,8 @@ only against the mirror if one exists; the canonical remote is ## Before Committing -- Frontend: `bun run check` and `bun run test` must pass. +- Frontend: `bun run check`, `bun run test`, `bun run format:check` and + `bun run lint` (0 errors; the warning count is a CI ratchet) must pass. - Rust: `cd src-tauri && cargo fmt` then `cargo clippy`, plus `bun run test:rust`. - **Boundary**: `bun run check:boundary` must pass — no domain taxonomy (Jellyfin item-type category sets) leaked into the frontend. See below. @@ -112,10 +114,12 @@ rename that missed a call site can no longer pass silently. **CI is Gitea Actions** (`.gitea/workflows/`, remote `gitea.tourolle.paris`), not GitHub. `traceability-check.yml` fails the build if coverage drops below -**82%** (`MIN_THRESHOLD`, a *ratchet* — raise it as coverage climbs, never lower +**88%** (`MIN_THRESHOLD`, a *ratchet* — raise it as coverage climbs, never lower it to make a build pass) or if any traced ID is undefined; `build-and-test.yml` -runs frontend + Rust tests, `cargo fmt --check`, an advisory `cargo clippy`, and -an Android `cargo check`. See [docs/traceability-ci.md](docs/traceability-ci.md) +runs frontend tests **with coverage thresholds**, `bun run check`, `format:check`, +a `--max-warnings` eslint ratchet, Rust tests, `cargo fmt --check`, `cargo clippy +-D warnings`, and an Android `cargo check`. See +[docs/traceability-ci.md](docs/traceability-ci.md) and [docs/traces-quick-ref.md](docs/traces-quick-ref.md). ### Traces drive release notes @@ -212,7 +216,9 @@ and [docs/build/build-release.md](docs/build/build-release.md). ## Writing specs -New feature specs go in [docs/specs/](docs/specs/). **Start from +New feature specs go in [docs/specs/](docs/specs/) — see its +[README](docs/specs/README.md) for the index and what is already built. +**Start from [SPEC-TEMPLATE.md](docs/specs/SPEC-TEMPLATE.md)** — its "Layer assignment" section forces each piece of *logic* to be placed in the correct layer (Rust = domain, frontend = presentation) *with a reason*, which is what prevents boundary leaks. diff --git a/bun.lock b/bun.lock index 5a5ccf6a..731349e7 100644 --- a/bun.lock +++ b/bun.lock @@ -35,7 +35,7 @@ "typescript": "~5.6.2", "typescript-eslint": "^8.67.0", "vite": "^6.0.3", - "vitest": ">=1.0.0 <5.0.0", + "vitest": "^4.1.10", }, }, }, @@ -328,17 +328,17 @@ "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.10", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.10", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.10", "vitest": "4.1.10" }, "optionalPeers": ["@vitest/browser"] }, "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g=="], - "@vitest/expect": ["@vitest/expect@4.0.16", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.16", "@vitest/utils": "4.0.16", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA=="], + "@vitest/expect": ["@vitest/expect@4.1.11", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw=="], - "@vitest/mocker": ["@vitest/mocker@4.0.16", "", { "dependencies": { "@vitest/spy": "4.0.16", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg=="], + "@vitest/mocker": ["@vitest/mocker@4.1.11", "", { "dependencies": { "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ=="], - "@vitest/pretty-format": ["@vitest/pretty-format@4.0.16", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA=="], + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.11", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw=="], - "@vitest/runner": ["@vitest/runner@4.0.16", "", { "dependencies": { "@vitest/utils": "4.0.16", "pathe": "^2.0.3" } }, "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q=="], + "@vitest/runner": ["@vitest/runner@4.1.11", "", { "dependencies": { "@vitest/utils": "4.1.11", "pathe": "^2.0.3" } }, "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw=="], - "@vitest/snapshot": ["@vitest/snapshot@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA=="], + "@vitest/snapshot": ["@vitest/snapshot@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog=="], - "@vitest/spy": ["@vitest/spy@4.0.16", "", {}, "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw=="], + "@vitest/spy": ["@vitest/spy@4.1.11", "", {}, "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA=="], "@vitest/ui": ["@vitest/ui@4.0.16", "", { "dependencies": { "@vitest/utils": "4.0.16", "fflate": "^0.8.2", "flatted": "^3.3.3", "pathe": "^2.0.3", "sirv": "^3.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "vitest": "4.0.16" } }, "sha512-rkoPH+RqWopVxDnCBE/ysIdfQ2A7j1eDmW8tCxxrR9nnFBa9jKf86VgsSAzxBd1x+ny0GC4JgiD3SNfRHv3pOg=="], @@ -410,7 +410,7 @@ "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], - "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], @@ -706,7 +706,7 @@ "vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="], - "vitest": ["vitest@4.0.16", "", { "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", "@vitest/pretty-format": "4.0.16", "@vitest/runner": "4.0.16", "@vitest/snapshot": "4.0.16", "@vitest/spy": "4.0.16", "@vitest/utils": "4.0.16", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.16", "@vitest/browser-preview": "4.0.16", "@vitest/browser-webdriverio": "4.0.16", "@vitest/ui": "4.0.16", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q=="], + "vitest": ["vitest@4.1.11", "", { "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", "@vitest/pretty-format": "4.1.11", "@vitest/runner": "4.1.11", "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.11", "@vitest/browser-preview": "4.1.11", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw=="], "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], @@ -756,13 +756,11 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], - "@vitest/expect/@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="], + "@vitest/expect/@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="], - "@vitest/expect/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="], + "@vitest/runner/@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="], - "@vitest/pretty-format/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="], - - "@vitest/runner/@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="], + "@vitest/snapshot/@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="], "@vitest/ui/@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="], @@ -788,13 +786,9 @@ "tsx/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], - "vitest/@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="], + "vitest/@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="], - "vitest/std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], - - "vitest/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="], - - "@vitest/runner/@vitest/utils/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="], + "@vitest/ui/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@4.0.16", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA=="], "svelte-eslint-parser/espree/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], diff --git a/docs/requirements.md b/docs/requirements.md index b0756705..b5e7a3ca 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -394,7 +394,7 @@ Internal architecture, components, and application logic. | DR-138 | Loopback is exempted from Android's cleartext ban, and nothing else is. Release builds set `usesCleartextTraffic="false"`, so the webview's request to the local media server (DR-137) was rejected by network security policy before any I/O — `