# JellyTau Documentation

Cross-platform Jellyfin client — business logic in a Rust backend, SvelteKit + TypeScript frontend, talking over Tauri v2 IPC.

- Requirements Specification — user, integration, and development requirements.
- Traceability Matrix — generated map from requirements to code (regenerated on every publish).
- Architecture — backend, frontend, data flow, platform backends.
- Rust API Reference — rustdoc for the `src-tauri` backend.

This site is published automatically from `master` by the `publish-docs` CI job.

# Requirements Specification

This document captures JellyTau's user requirements, software requirements, traceability matrix, test traceability, and known technical debt.

For a narrative overview of the system design, see docs/architecture/. For development workflows, see the README and scripts/README.md.

## 1. User Requirements

ID | Requirement | Priority | Status
--- | --- | --- | ---
UR-001 | Run the app on multiple platforms (Linux, Android) | High | In Progress
UR-002 | Access media when online or offline | High | Done
UR-003 | Play videos | High | Done
UR-004 | Play audio uninterrupted | High | Done
UR-005 | Control media playback (pause, play, skip, scrub) | High | Done
UR-006 | Control media when device is on lock screen or via BLE headsets | Medium | Done (Android); not implemented on Linux — see IR-005
UR-007 | Navigate media in library | High | Done
UR-008 | Search media across libraries | High | Done
UR-009 | Connect to Jellyfin to access media | High | Done
UR-010 | Control playback of Jellyfin remote sessions | Low | Done
UR-011 | Download media on demand | Medium | Done
UR-012 | Login info shall be stored securely and persistently | High | Done
UR-013 | View and manage downloaded media | Medium | Done
UR-014 | Make and edit playlists of music that sync back to Jellyfin | Medium | Done
UR-015 | View and manage current audio queue (add, reorder tracks) | Medium | Done
UR-016 | Change system settings while playing (brightness, volume) | Low | Planned
UR-017 | Like or unlike audio, albums, movies, etc. | Medium | Done
UR-018 | Choose to download series, albums, songs, artist discography | Medium | Done
UR-019 | Resume playback from where you left off (movies, shows, albums) | High | Done
UR-020 | Select subtitles for video content | High | Done
UR-021 | Select audio track for video content | High | Done
UR-022 | Control streaming quality and transcoding settings | Medium | Planned
UR-023 | View "Next Up" / Continue Watching on home screen; auto-play next episode with countdown popup and configurable episode limit | Medium | Done
UR-024 | View recently added content on server | Medium | Done
UR-025 | Sync watch history and progress back to Jellyfin | High | Done
UR-026 | Sleep timer for audio and video playback (roller UI, time/track/episode modes) | Low | Done
UR-027 | Audio equalizer for sound customization | Low | Done (Linux; Android pending device verification)
UR-028 | Navigate to artist/album by tapping names in now playing view | High | Done
UR-029 | Toggle between grid and list view in library | Medium | Done
UR-030 | Quick genre browsing and filtering | Medium | Done
UR-031 | Crossfade between audio tracks | Low | Not implemented (blocked — see DR-034)
UR-032 | Gapless playback for seamless album listening | Medium | Done (Linux; Android pending device verification)
UR-033 | Volume normalization to prevent volume jumps between tracks | Low | Done (Linux; Android pending device verification)
UR-034 | Rich home screen with hero banners, carousels, and personalized sections | High | Done
UR-035 | View cast/crew (actors, directors) on movie/show detail pages | High | Done
UR-036 | Navigate to actor/person page showing their filmography | Medium | Done
UR-037 | Visually appealing video library with poster grids and metadata | High | Done
UR-038 | Movie/show detail page with backdrop, ratings, and rich metadata | High | Done
UR-039 | Navigate between main sections via bottom navigation bar | High | Done
UR-040 | Keep a video's audio playing when the app is backgrounded or the screen is locked, stopping video decode until the app returns to the foreground (per-player toggle; Android) | Medium | Done (pending device verification)
UR-041 | Continue watching locally-playing video in a floating picture-in-picture window when leaving the app (Android) — PiP applies to video only, never to audio playback, library/menu browsing, or remote/cast sessions | Medium | Done
UR-042 | Authenticate to a server and manage the session lifecycle (connect, log in, Quick Connect, background session verification, re-authenticate, log out) | High | Done
UR-043 | Automatically detect server reachability and switch between online and offline operation without user intervention | High | Done
UR-044 | Pin downloaded media so it is protected from automatic cache eviction | Low | Done
UR-045 | Predictively pre-cache likely-next media (queue lookahead and album affinity) within a storage budget | Low | Done
UR-046 | Group multiple remote players into a synchronized playback group (LMS SyncGroups) | Low | Done
UR-047 | Manage multiple Jellyfin servers (add, list, remove) and switch the active server/account | Medium | Planned (backend store done; switcher UI pending)
UR-048 | See the next episodes of a series directly below the episode/series being viewed, above cast and similar-shows content, so continuing a show is the shortest path (see ux-flows.md §5B) | High | Done
UR-049 | Search is scoped by where it was started — inside a library it searches that library, from Home/library-root/search-tab it searches everything — with the scope shown as filter chips under the search bar that preselect from context and can be changed without retyping (see ux-flows.md §6.1) | High | Implemented
UR-050 | Reorder search result groups (Songs, Albums, Artists, Movies, TV Shows) by drag and drop in settings, so the media a user cares about most appears first (see ux-flows.md §6.3) | Medium | Implemented
UR-051 | Browse library pages in a consistent layout where card shape signals media type (square music, poster video, thumbnail episode), ordinal content stays listed, and the grid/list preference persists across pages (see ux-flows.md §5A) | Medium | Partial (implemented; toggle not reachable from settings)
UR-052 | While offline, library pages show only media available on the device by default, with an opt-in toggle that additionally reveals the cached server catalog as greyed-out entries which can be queued for download on the next reconnect | High | Done
UR-053 | Restrict media downloads to unmetered networks via a "WiFi Only" setting: when enabled, queued downloads are held while the device is on cellular or a metered connection (including metered WiFi hotspots) and resume automatically once an unmetered network is available | Medium | Done (pending device verification)
UR-054 | Reach account actions (Settings, Downloads, Display preferences, Sign out) from every authenticated screen via a single account menu anchored to the user's name, identical on desktop and mobile (see ux-flows.md §1.2) | High | Done
UR-055 | Browse downloaded media as an offline-scoped library — reusing the same library grids, cards, and detail pages as online browsing, showing only libraries/containers with downloaded content — with the transfer-progress list demoted to a secondary "Transfers" view (see ux-flows.md §7.2) | High | Done
UR-056 | See how much disk each downloaded item/album/series consumes, in familiar rounded units shown on the card and detail page, with a device total on the Downloaded surface and a reclaim amount stated at the point of removal (see ux-flows.md §7.3.1) | Medium | Done
UR-057 | Settings apply the instant a control is changed — no "Save" button and no save/dirty state — so leaving the page never loses a change; sliders show a live readout while dragging but persist on release (see ux-flows.md §8.1) | Medium | Done
UR-058 | On the home screen, a tap on a media card opens the item (movie/episode detail page, or the series Episode Focus View for episodes) rather than starting playback; a long-press starts "play now" after a confirm; an episode detail/focus page links back to its parent series and season (see ux-flows.md §5B.5 and §5B.1) | Medium | Done
UR-059 | Skipping to the next episode records the episode left behind as fully watched rather than saving a mid-episode resume point — skipping means "done with this one", not "stopped here" — and Continue Watching hides episodes the viewer has already moved past (a partial position behind that series' next-up episode), so the row only ever offers genuinely unfinished media | Medium | Done

UR-060

Search results are ordered by how well they match: a name that starts with the query outranks one matching mid-word (typing "parks" finds "Parks and Recreation" before "Sparks of Love"), and at equal match quality a container outranks its contents (a series before its episodes). Results are grouped into distinct categories — TV Shows, Episodes, Movies, Songs, Albums, Artists and People — so a show never competes with its own episodes for the same slot, and searching an actor's name reaches their bio

High

Done

UR-061

Double tapping the video skips within it — right half jumps forward 30 seconds, left half jumps back 10 seconds — with an on-screen indicator naming the amount. A double tap leaves the play state unchanged — playing jumps and keeps playing, paused jumps and stays paused — because the second tap re-toggles what the first tap toggled (see DR-098); the skip lands relative to the position the player actually reports, and repeated double taps accumulate rather than all skipping from the same spot

Medium

Done

UR-062

Opening a TV series lands the viewer where they are in it, not at season 1: the series page scrolls the current season into view and highlights the current episode, and the hero button opens that episode (labelled `Resume S2E4` / `Play S1E1`). "Current" means the episode in progress, else the server's Next Up for that series, else the first unwatched episode, else the first — resolved by the backend so it also works offline. A season is never a page of its own: every route that names a season lands on the series with that season in view, so the episodes of all seasons are always one continuous scrollable list

High

Done

UR-063

Each video library is one page, not three. Browsing (hero, Continue Watching, Next Up, Recently Added, genre rows), the full title grid, and the genre browser are tabs of `/library/tv` and `/library/movies` rather than separate routes with inconsistent names (`/library/tv/shows` vs `/library/movies/all`, `/library/shows/genres` vs `/library/movies/genres`). The old routes redirect so existing links keep working

Medium

Done

UR-064

Watch history can be erased, per series and per season, from the series page. Clearing marks every episode inside unwatched and clears resume positions, so the show returns to "never watched" and reopens on its premiere. It asks for confirmation first (it cannot be undone) and requires a connection to the server, since history cleared only locally would be undone by the next sync

Medium

Done

UR-065

Search answers from a locally indexed copy of the library, so results appear as fast as the device can query rather than at the speed of a round trip to the server, and the same results are found with the server unreachable. A background job keeps the index current — refreshing on a schedule rather than only at app start, dropping media removed from the server, and covering everything the result groups can show (including artists and people). The server is still queried in the background so media added since the last index still turns up, merged in without reordering what is already on screen

High

Implemented

UR-066

The app's own chrome stays clear of the device's system chrome. On Android the bottom navigation sits above the navigation/gesture bar instead of underneath it, the header clears the status bar, and full-screen video and audio playback keep their controls inside the usable screen — clear of the gesture bar and, in landscape, of the display notch. This must hold across navigation modes (gesture and 3-button) and rotation, not only on the handsets it happened to be tested on

High

Done

UR-067 | Favourited media can be found again. A Favourites page lists everything favourited across all libraries, scoped by tabs (All / Movies / Shows / Music); the home screen carries favourite rows for movies, shows and music, hidden when a category is empty; and each library page can be filtered to favourites in place. Without this the like button writes to a store nothing reads | Medium | Done
UR-068 | Anything the app shows can be favourited where it is shown — from a movie, series, episode, album, artist or playlist page, and from any card in a grid or carousel — not only from the player while the item happens to be playing | Medium | Done
UR-069 | Favourite state agrees with the server in both directions. An item favourited in another Jellyfin client shows as favourited here without being touched, and an item favourited here while the server is unreachable reaches the server once it returns — without the user going back to the screen where they marked it | Medium | Done

UR-070

Playback quality is the viewer's choice: the player offers the bitrates the server can produce for what is playing, and changing one resumes at the same point with the same audio and subtitle tracks. Because the chosen rendition can change at any moment, nothing that streams for playback is treated as a stored copy unless it happens to be byte-identical to the real file

Medium

Proposed

UR-071 | Media the viewer is watching can be kept, by a whole-file download that runs in the background independently of playback and at its own quality, so it is unaffected by bitrate changes. Where the streamed bytes already are that file (direct play), they are kept rather than fetched twice. A completed download is then played from disk rather than streamed again | Medium | Proposed
UR-073 | Watched state is something the viewer can set, not only something playback records. Any episode, season, series or movie can be marked watched — or unwatched again — from where it is shown, without sitting through it or erasing its history wholesale. Marking a season or series covers the episodes inside it, and works with the server unreachable | Medium | Done
UR-072 | Each page opens where a page should open. Moving to a new screen starts at the top of it, and going Back returns the viewer to the place they left — their position in a long library grid or home screen, not the top of it. A page never inherits the scroll position of the page before it | Medium | Done

UR-075

Artwork is shown at the shape it was made in. Where a screen presents a set of things side by side — the libraries on the library page and on home — they are laid out as a mosaic: rows of a common height in which each tile is as wide as its own picture, rather than a grid that crops every cover to one box. Favourites are reachable per category from that same mosaic, beside the library they belong to, not only as one undifferentiated list

Medium

Done

UR-076

Music browsing shows only what the listener considers music. A Jellyfin server commonly keeps podcasts, audiobooks, sound effects or sample packs in their own folders inside a music library; those folders can be excluded by choice, once, and every music surface — library grids, artist and album listings, genre rows, search and the home screen — then agrees on what is in scope. The choice is by folder, not by a name the app happens to recognise, so a folder called anything at all can be excluded and an item is never dropped because its title matched a word

Medium

Done

UR-077

The app can update itself, or tell the user how. Somebody who installed an AppImage or ran the Windows installer had no upgrade path at all: nothing in the app ever mentioned that a newer version existed, and the release notes were the only announcement. On Linux and Windows the app checks a signed manifest, offers the new version with its notes, and installs and relaunches on request — the signature check is the point, since it is what stops a substituted download from being installed by the app itself. Android cannot do this (an app may not overwrite its own APK; that is the package installer's job) and is given the honest alternative, a link to the releases page, rather than a button that would throw

Medium

Done

UR-078

JellyTau keeps a record of what it did, and can hand it over. The app forgot everything the moment it exited: the backend logged to stdout only — which a user launching from a desktop icon never sees, and which on Android is not logcat, so the Rust half was invisible on the platform carrying the hardest bugs. A crash left nothing at all. Logs are now written to a size-capped rotating file, a panic is recorded before the process dies, the frontend's messages land in the same timeline as the backend's, and Settings exports the lot as one file to attach to a bug report. Nothing is transmitted anywhere — the user attaches it themselves, which is also what keeps this from being telemetry. Access tokens and passwords never reach the file

Medium

Done

UR-079

The app decides what stream to play and says so. Playing a video used to mean asking the server to re-encode it, always — a decision made nowhere, written down nowhere, and re-derived downstream by whoever needed it: the player worked out whether it had been handed a playlist by looking for `.m3u8` in the URL. So a viewer paid for a transcode of a file their device could have played untouched, and the app could not tell them which it was. Now one negotiation produces one self-describing answer — direct play, remux, or transcode; over a playlist, a plain HTTP file, or a local one — and every renderer consumes that same answer instead of guessing from a string. On Android, where the player decodes almost everything the library holds, this stops around 85% of plays from starting a transcode nobody needed

Medium

Done

UR-080

Video on the desktop plays as itself. The picture was drawn by a webview `<video>` element, which decodes little beyond h264 — so the app told the server it could accept only h264, and the server re-encoded almost everything before sending it. That was never a statement about the machine: the same machine already runs mpv for audio, which decodes essentially the whole library. Measured against a real library, 93% of desktop playback was a transcode nobody needed, against 15% on Android where a real decoder does the work. mpv now draws the picture, the app claims what it can genuinely decode, and video is sent as it was stored wherever that is possible — sparing the server the work, the network the bitrate, and the picture a generation of re-encoding

Medium

Proposed

UR-081 | Playback behaves the same whichever engine renders it | High | In Progress
UR-082 | A shared device holds more than one account from the same server, and changing who is using it takes a couple of taps rather than a password. Switching away leaves the account it left able to come straight back, and each account sees only its own library, its own progress and its own downloads — including offline, where the server is not there to filter | Medium | Proposed
UR-083 | An account can be locked behind a short numeric code, so that on a family device the accounts that need protecting are protected and the ones that do not are one tap away. The code gates switching to that account, not what the account may watch. Repeated wrong guesses stop being answered | Medium | Proposed
UR-084 | Forgetting the code is not a lockout: the account's ordinary password gets in, and a new code can be set from there | Medium | Proposed

UR-074

Video streaming can be held to a bandwidth budget the viewer sets, rather than spent at whatever rate the server would otherwise send. A ceiling chosen once — from the source's own bitrate down to a rung that still plays on a poor connection — governs every video the app opens, live TV included, and survives a restart, so a metered connection is not quietly drained by the next thing played. A single video can be moved to a different ceiling from the player, resuming where it was, without disturbing that default

Medium

Done

## 2. Software Requirements

### 2.1 Integration Requirements

External system integrations and platform-specific implementations.

ID | Requirement | Category | Traces To | Status
--- | --- | --- | --- | ---
IR-001 | Build system supporting multiple targets (Linux, Android) | Build | UR-001 | Done
IR-002 | Build scripts for Android and Linux | Build | UR-001 | Done
IR-003 | Integration of libmpv for Linux playback | Playback | UR-003, UR-004 | Done
IR-004 | Integration of ExoPlayer for Android playback | Playback | UR-003, UR-004 | In Progress (basic playback works, audio settings missing)

IR-005

MPRIS D-Bus integration for Linux lockscreen/media controls

Platform

UR-006

Planned — genuinely absent: no `mpris`/`souvlaki`/`zbus`/`dbus` code or dependency in the project (`zbus` appears in `Cargo.lock` only transitively, via `tauri-plugin-opener`), and no `navigator.mediaSession` use in the frontend. `player::update_lockscreen_metadata` is a no-op off Android. UR-006 is therefore Android-only

IR-006 | Android MediaSession integration for lockscreen controls | Platform | UR-006 | Done
IR-007 | Bluetooth AVRCP integration via system media session | Platform | UR-006 | Planned
IR-008 | Android audio focus handling (pause on call) | Platform | UR-004, UR-006 | Done
IR-009 | Jellyfin API client for authentication | API | UR-009, UR-012 | Done
IR-010 | Jellyfin API client for library browsing | API | UR-007, UR-008 | Done
IR-011 | Jellyfin API client for playback streaming | API | UR-003, UR-004 | Done
IR-012 | Jellyfin Sessions API for remote playback control | API | UR-010 | Done
IR-021 | Android MediaRouter integration for remote volume in system panel | Platform | UR-010, UR-016 | Planned
IR-013 | SQLite integration for local database | Storage | UR-002, UR-011 | Done
IR-014 | Secure credential storage (keyring/keychain) | Security | UR-012 | Done
IR-015 | Jellyfin API client for playback progress reporting | API | UR-019, UR-025 | Done
IR-016 | Jellyfin API client for subtitle/audio track info | API | UR-020, UR-021 | Done
IR-017 | Jellyfin API client for transcoding parameters | API | UR-022 | Planned

IR-018

Subtitle rendering and selection in the video playback backends: ExoPlayer sideloads each track as a `MediaItem.SubtitleConfiguration` and selects by text-track-group position (Android), and the WebKitGTK HTML5 `<video>` element renders `<track kind="subtitles">` children carrying `data-stream-index` (Linux). Originally scoped to libmpv, which never implemented it: `MpvBackend` is the audio-only backend here and does not override `PlayerBackend::set_subtitle_track`, so the default `not_implemented()` still stands there. UR-020 is satisfied by the two paths above rather than by MPV

Playback

UR-020

Done

IR-019

Audio track selection in the video playback backends: ExoPlayer switches track by index natively (Android), while the HTML5 `<video>` path cannot switch a track in the element and instead re-opens the stream at the chosen `AudioStreamIndex` and resumes at the same position (Linux) — the two outcomes `AudioTrackSwitchResponse` distinguishes. Originally scoped to libmpv, which never implemented it: `MpvBackend` does not override `PlayerBackend::set_audio_track`, so the default `not_implemented()` still stands there. UR-021 is satisfied by the two paths above rather than by MPV

Playback

UR-021

Done

IR-020 | libmpv/ExoPlayer equalizer integration | Playback | UR-027 | Done (Linux/MPV and Android/`audiofx.Equalizer`; Android pending device verification)
IR-022 | Jellyfin API client for person/cast data | API | UR-035, UR-036 | Done
IR-023 | Database schema for person/cast caching | Storage | UR-035, UR-036 | Done
IR-024 | Jellyfin API client for home screen data (featured, continue watching) | API | UR-034 | Done
IR-025 | Android background-audio handoff: WebView `<video>` → native ExoPlayer foreground service on background/lock, and back on foreground (audio continues, video decode stops) | Platform | UR-040 | Done (pending device verification)

IR-026

Android picture-in-picture: auto-enter on user-leave-hint via `enterPictureInPictureMode`, only while a local video surface is actively rendering (never for audio-only playback, menu/library browsing, or remote/cast sessions — enforced by the native `canEnterPip` guard, re-checked at leave time); aspect-ratio sizing; a play/pause RemoteAction that reflects live player play/pause state (updated whenever playback state changes, not only on button press); WebView hide/restore on mode change

Platform

UR-041

Done

IR-027 | Jellyfin `/System/Info/Public` reachability probe used as an offline→online recovery detector | API | UR-043 | Done
IR-028 | Jellyfin/LMS SyncGroups API client (list, create, join, unsync, dissolve sync groups) | API | UR-046 | Done
IR-029 | Android `ConnectivityManager`/`NetworkCapabilities` transport probe with a `NetworkCallback` change subscription, surfaced to the frontend via the `AndroidNetworkType` JS bridge and the `jellytau-network-changed` WebView event (requires `ACCESS_NETWORK_STATE`) | Platform | UR-053 | Done (pending device verification)
IR-030 | Scheduled full-catalog crawl of every library (`Recursive=true`, paged) feeding the local index, driven by a Rust background task and the `ConnectivityMonitor` reconnect signal rather than by the frontend | Storage | UR-065 | Implemented
IR-031 | Android `WindowInsets` bridge: an `OnApplyWindowInsetsListener` on the decor view reports `systemBars() | displayCutout()`in CSS pixels, pushed into the WebView as`jt-inset`CSS custom properties plus a`jellytau-insets-changed`event, and pullable via the`AndroidInsets` JS bridge | Platform | UR-066
IR-032 | Whole-file background download of the item being played, reusing the existing resumable download worker and the Range-capable `/Videos/{id}/stream.mp4` endpoint; plus per-platform read-through caching hooks (ExoPlayer `CacheDataSource`, mpv `stream-record`) for direct-play sessions only | Storage | UR-071 | Proposed

IR-033

libmpv render-API integration for video: `vo=libmpv` driving an OpenGL FBO bound by the host toolkit, with GL entry points resolved through libepoxy. Note that libepoxy exports them as data symbols — there is no `glFoo` function, only an `epoxy_glFoo` variable holding a lazily-resolving pointer — so `get_proc_address` must return the pointer stored at that symbol; returning the symbol's own address makes mpv jump into non-executable data and take SIGSEGV on the first GL call. The `epoxy` crate resolves this correctly but is unusable, its `gl_generator` dependency pulling a yanked `xml-rs`

Playback

UR-080

Proposed

IR-034 | One downloaded file serves every account that asked for it: the download row owns the bytes, a per-user grant owns the claim, and the file is unlinked only when the last grant goes. The on-disk layout is already content-derived rather than user-derived, so this formalises what the paths already imply and stops two accounts clobbering one file | Storage | UR-082 | Proposed

> Where a UR is met by a different mechanism than its IR anticipated. Several integration requirements were written when libmpv was expected to be the single playback backend. It is not: `MpvBackend` is the audio-only backend, Linux plays video through a WebKitGTK HTML5 `<video>` element (HLS/h264), and Android plays through ExoPlayer. So:

> - UR-020 / UR-021 (subtitle and audio track selection) are Done, but not by MPV — `MpvBackend` overrides neither `PlayerBackend::set_subtitle_track` nor `set_audio_track`, leaving the trait's `not_implemented()` default. IR-018 and IR-019 have been re-scoped to the backends that actually deliver them (ExoPlayer sideloaded `SubtitleConfiguration`s and native track switching; HTML5 `<track>` children and stream re-open at the chosen `AudioStreamIndex`) and marked Done on that basis. IT-008 / IT-009 were re-worded to match.
> - UR-006 (lockscreen / BLE headset control) is Done on Android only, via `MediaSessionCompat` (IR-006) and ExoPlayer/`AudioManager` focus (IR-008). IR-005 (MPRIS) remains Planned because it genuinely does not exist — there is no MPRIS/D-Bus code or dependency in the project, and `player::update_lockscreen_metadata` is a no-op off Android. UR-006's status was corrected rather than IR-005's.

### 2.2 Jellyfin API Requirements

API endpoints and data contracts required for Jellyfin integration.

ID | Requirement | Endpoint Category | Traces To | Status
--- | --- | --- | --- | ---
JA-001 | Server connection and discovery | System | UR-009 | Done
JA-002 | User authentication (username/password) | Users | UR-009, UR-012 | Done
JA-003 | Get user library views | UserViews | UR-007 | Done
JA-004 | Get library items (paginated) | Items | UR-007 | Done
JA-005 | Get item details and metadata | Items | UR-007 | Done
JA-006 | Search across libraries | Items | UR-008 | Done
JA-007 | Get playback info and stream URL | MediaInfo | UR-003, UR-004 | Done
JA-008 | Get available subtitles for item | MediaInfo | UR-020 | Done
JA-009 | Get available audio tracks for item | MediaInfo | UR-021 | Done
JA-010 | Report playback start | Sessions | UR-025 | Done
JA-011 | Report playback progress (periodic) | Sessions | UR-025 | Done
JA-012 | Report playback stopped | Sessions | UR-025 | Done
JA-013 | Get resume position for item | UserData | UR-019 | Done
JA-014 | Get "Next Up" items | Shows | UR-023 | Done
JA-015 | Get "Continue Watching" items | Items | UR-023 | Done
JA-016 | Get recently added items | Items | UR-024 | Done
JA-017 | Mark item as favorite | UserData | UR-017 | Done
JA-018 | Remove item from favorites | UserData | UR-017 | Done
JA-019 | Get/create/update playlists | Playlists | UR-014 | Done
JA-020 | Add/remove items from playlist | Playlists | UR-014 | Done
JA-021 | Get active sessions list | Sessions | UR-010 | Done
JA-022 | Send playback commands to remote session (play/pause/stop) | Sessions | UR-010 | Done
JA-023 | Send seek command to remote session | Sessions | UR-010 | Done
JA-024 | Send next/previous track commands to remote session | Sessions | UR-010 | Done
JA-025 | Play specific item on remote session | Sessions | UR-010 | Done
JA-026 | Send volume/mute commands to remote session | Sessions | UR-010 | Done
JA-027 | Get transcoding options | MediaInfo | UR-022 | Planned
JA-028 | Get image/artwork URLs | Images | UR-007 | Done
JA-029 | Get cast/crew for item (actors, directors) | Items | UR-035 | Done
JA-030 | Get person details and filmography | Persons | UR-036 | Done
JA-031 | Get items by person (actor/director filmography) | Items | UR-036 | Done
JA-032 | Get audio-only stream URL for a video item (selected audio-stream index) | MediaInfo | UR-040 | Done
JA-033 | Query favourite items (`Filters=IsFavorite`, recursive, scoped by item type) | Items | UR-067 | Done
JA-034 | Read `UserData` (favourite, played, resume position) from item responses | UserData | UR-069 | Done
JA-035 | Mark item played (`POST /Users/{userId}/PlayedItems/{itemId}`) | UserData | UR-025 | Done
JA-036 | Query next-up episodes excluding in-progress ones (`/Shows/NextUp` with `EnableResumable=false`) | Shows | UR-059 | Done

### 2.3 Development Requirements

Internal architecture, components, and application logic.

ID | Requirement | Category | Traces To | Status
--- | --- | --- | --- | ---
DR-001 | Player state machine (idle, loading, playing, paused, seeking, error) | Player | UR-005 | Done
DR-002 | MediaItem struct tracking source, location, duration, metadata | Player | UR-003, UR-004 | Done
DR-003 | Source-agnostic media abstraction (Remote, Local, DirectUrl) | Player | UR-002, UR-011 | Done
DR-004 | PlayerBackend trait for platform-agnostic playback | Player | UR-003, UR-004 | Done
DR-005 | Queue manager with shuffle, repeat, history | Player | UR-005, UR-015 | Done
DR-006 | Audio pre-caching for seamless track transitions | Player | UR-004 | Planned
DR-007 | Library browsing screens (grid view, search, filters) | UI | UR-007, UR-008 | Done
DR-008 | Album/Series detail view with track listing | UI | UR-007 | Done
DR-009 | Audio player UI (mini player, full screen) | UI | UR-005 | Done
DR-010 | Video player UI (fullscreen, controls overlay) | UI | UR-003, UR-005 | Done
DR-011 | Search bar with cross-library search | UI | UR-008 | Done
DR-012 | Local database for media metadata cache | Storage | UR-002 | Done
DR-013 | Repository pattern for online/offline data access | Storage | UR-002 | Done
DR-014 | Offline mutation queue for sync-back operations | Storage | UR-002, UR-014, UR-017 | Done
DR-015 | Download manager with queue and progress tracking | Storage | UR-011, UR-018 | Done
DR-016 | Thumbnail caching and sync with server | Storage | UR-007 | Done
DR-017 | "Manage Downloads" screen for local media management | UI | UR-013 | Done
DR-018 | Download buttons on library/album/player screens | UI | UR-011, UR-018 | Done
DR-019 | Playlist creation and editing UI | UI | UR-014 | Done
DR-020 | Queue management UI (add, remove, reorder) | UI | UR-015 | Done
DR-021 | Like/favorite functionality on media items | UI | UR-017 | Done
DR-022 | Resume position tracking and restoration on play | Player | UR-019 | Done
DR-023 | Subtitle selection UI in video player | UI | UR-020 | Done
DR-024 | Audio track selection UI in video player | UI | UR-021 | Done
DR-025 | Quality/transcoding settings UI | UI | UR-022 | Planned
DR-026 | "Continue Watching" / "Next Up" home section | UI | UR-023 | Done
DR-027 | "Recently Added" home section | UI | UR-024 | Done
DR-028 | Playback progress sync service (periodic reporting) | Player | UR-025 | Done
DR-029 | Sleep timer with roller UI, time/track/episode modes, and auto-stop (audio + video players) | Player | UR-026 | Done
DR-049 | Auto-play episode limit (configurable max episodes per session) | Player | UR-023 | Done
DR-050 | Reusable scroll picker (roller) component | UI | UR-026 | Done
DR-030 | Equalizer UI with presets and custom bands | UI | UR-027 | Done
DR-031 | Clickable artist/album links in now playing view | UI | UR-028 | Done
DR-032 | List view option for library browsing (albums, artists) | UI | UR-029 | Done
DR-033 | Genre browsing screen with quick filters | UI | UR-030 | Done
DR-034 | Crossfade engine with configurable duration (0-12s) | Player | UR-031 | Not implemented (blocked on MPV: single-stream audio chain; `acrossfade` needs 2 inputs — see docs/specs/playback-backend-unification.md)
DR-035 | Gapless playback between sequential tracks | Player | UR-032 | Done (Linux via MPV; Android via `pauseAtEndOfMediaItems` — pending device verification)
DR-036 | Volume normalization with preset levels (Loud/Normal/Quiet) | Player | UR-033 | Done (Linux via MPV `dynaudnorm`; Android via `LoudnessEnhancer` — pending device verification)
DR-037 | Remote session browser and control UI | UI | UR-010 | Done
DR-038 | Home screen with hero banner carousel (featured/continue watching) | UI | UR-034 | Done
DR-039 | Home screen horizontal carousels (recently added, recommendations) | UI | UR-034, UR-024 | Done
DR-040 | Cast/crew section on movie/show detail pages | UI | UR-035 | Done
DR-041 | Person/actor detail page with filmography grid | UI | UR-036 | Done
DR-042 | Video library grid with poster cards, year, and rating badges | UI | UR-037 | Done
DR-043 | Movie/show detail page with backdrop hero, synopsis, and metadata | UI | UR-038 | Done
DR-044 | Horizontal scrolling actor/cast row with profile images | UI | UR-035 | Done
DR-045 | Bottom navigation bar with Home, Library, Search buttons | UI | UR-039 | Done
DR-046 | Dedicated search page with input and results | UI | UR-039 | Done
DR-047 | Next episode auto-play popup with configurable countdown and episode limit | Player | UR-023 | Done
DR-048 | Video settings (auto-play toggle, countdown duration, episode limit) | Settings | UR-023, UR-026 | Done
DR-051 | Background-audio toggle button in the video player controls (suppresses auto-PiP while enabled) | UI | UR-040 | Done (pending device verification)
DR-052 | Background-audio handoff state machine: on background/lock tear down the WebView `<video>`/HLS decode and start native audio-only playback at the current position; on foreground return position and resume `<video>`; exactly one audio source active at every transition (no dual audio) | Player | UR-040 | Done (pending device verification)

DR-053

PictureInPictureManager: `canEnterPip` gate (local video surface actively rendering — false for audio, browsing, and remote/cast), aspect-ratio clamp, a RemoteAction play/pause receiver whose icon reflects live player state (refreshed on every playback-state change while in PiP, not only on button press), WebView hide/restore, surface re-fit on exit; plus the `AndroidPictureInPicture` JS bridge and the PiP button (shown only when PiP is supported) in the video player

UI

UR-041

Done

DR-054 | Auth manager and session lifecycle: connect-to-server, login, Quick Connect verification poll (start/stop), session get/set, background session verifier, re-authenticate, logout | Auth | UR-042 | Done
DR-055 | ConnectivityMonitor deriving reachability from real repository traffic, with online/offline state, mark-reachable/unreachable reporting, and a probe-based recovery poller active only while offline | Connectivity | UR-043 | Done
DR-056 | Download pinning (pin/unpin/is-pinned) that excludes an item from smart-cache eviction | Storage | UR-044 | Done
DR-057 | Smart cache manager: album-affinity tracking, queue-lookahead pre-cache, storage-limit enforcement, config, stats, and recommendations | Storage | UR-045 | Done
DR-058 | Remote sync-group control (LMS SyncGroups): list, create, unsync a player, dissolve a group | Player | UR-046 | Done
DR-059 | Playback-mode transfer state machine: get/set current mode, transferring guard, transfer-to-remote / transfer-to-local, remote session status | Player | UR-010 | Done
DR-060 | Multi-server store and active-account selection: save/get/delete server, save/get user, set/get active user (per-server), active-session resolution | Storage | UR-047 | Partial (store done; server-switcher UI pending)
DR-061 | Episode Focus View: episode hero followed immediately by the "More Episodes" strip — a forward-biased window (~3 before / ~6 after) around the current episode, spanning season boundaries in series order, with the current episode present and badged, per-card resume progress and watched state, and click-to-swap focus (no playback) | UI | UR-048 | Done
DR-062 | Detail-page section ordering: continuation content precedes discovery content — Episode Focus View renders hero → episode strip → cast → similar; Series renders hero → seasons/episodes → cast → similar | UI | UR-048 | Done

DR-063

Search scope taxonomy owned by Rust: `SearchScope` (All / Music / Movies / TV) crosses IPC as an opaque enum and `SearchScope::item_types()` expands it to Jellyfin item types, resolved once in `repository_search` before the cache and server paths diverge so online and offline filter identically; `All` expands to no filter rather than the union of the other scopes (which would drop People and folders). The frontend maps the originating route to a scope (`resolveSearchScope`, presentation) and never names an item type for search

Backend

UR-049

Implemented

DR-064 | Scope chip row rendered under the search bar on both the search page and the in-library header search: preselected from context, horizontally scrollable, re-runs the search preserving the query on change | UI | UR-049 | Implemented
DR-065 | Thread `SearchOptions.includeItemTypes` through `library.search()` so the global/header search honours scope (backend online + offline paths already support it) | UI | UR-049 | Implemented
DR-066 | Persisted search result group order with a drag-and-drop settings list, keyboard-accessible reordering, a shipped default (see DR-091 for the current group set and order), and empty-group omission | Settings | UR-050 | Implemented
DR-067 | `SearchResults` renders groups in the user-configured order rather than hardcoded markup order, without altering intra-group ranking | UI | UR-050 | Implemented
DR-068 | Library card shape by media type: 1:1 square for music (circular mask for artists), 2:3 poster for movies/series/seasons, 16:9 for episodes and collection folders | UI | UR-051 | Done
DR-069 | Responsive library grid (2/3/4/5/6 columns across base→xl) with two-line truncated card text and artwork-overlay progress/watched state | UI | UR-051 | Done
DR-070 | Global persisted grid/list view preference honoured by browse pages, suppressed for ordinal content (album tracks, season episodes) | UI | UR-051, UR-029 | Partial (persisted store + page-header toggle; no settings entry)
DR-075 | Shared `AccountMenu` component: identity header (user + server), Downloads / Settings / Display entries, divider, Sign out last; anchored to the username/avatar trigger and identical on desktop and mobile | UI | UR-054 | Done
DR-076 | App shell exposes the header (and therefore the account menu) on every authenticated non-immersive route, including `/`, `/search`, and `/downloads`; only `/player/*` and `/login` remain chrome-free | UI | UR-054 | Done
DR-077 | Display section in Settings binding the existing persisted grid/list `viewMode` store, giving the preference a discoverable home | Settings | UR-054, UR-029 | Done
DR-078 | Catalog-visibility gate spanning the "Show all server media" toggle → `set_show_server_catalog` → `INCLUDE_CATALOG_BROWSE` → the synced-catalog UNION branch of offline `get_items`. Visibility resolves to `serverReachable || showServerCatalog`, so offline with the toggle off lists downloaded/local media only | Storage | UR-052, UR-002 | Done
DR-079 | `isConnected` derives from backend-reported server reachability alone; `navigator.onLine` is advisory and may only trigger a recheck, never force or clear the offline state (a reachable LAN server while the browser reports offline, and an unreachable server on a live link, must both resolve correctly) | Connectivity | UR-052, UR-043 | Done
DR-080 | With the catalog-browse gate off, an empty offline `get_items` result is authoritative "no downloads here" and must be returned as-is; the hybrid repository must not treat it as a cache miss and fall through to the server | Storage | UR-052, UR-013 | Done

DR-074

WiFi-only download gate: `NetworkState`/`NetworkType` transport model reported from the platform via `set_network_state`, checked in `pump_download_queue` before starting any pending row (cellular/metered/unknown fail closed, WiFi and Ethernet require `NOT_METERED`); blocked rows stay `pending` and re-pump on network change, with a `waitingForNetwork` event driving the "Waiting for WiFi" notice. Also wires the previously inert Smart Caching / Queue Pre-caching / WiFi Only settings toggles to `CacheConfig`

Downloads

UR-053

Done (pending device verification)

DR-081 | `/downloads` split into a default Downloaded browse view and a secondary Transfers activity view, with a view switch and a Transfers badge shown only while transfers are active | UI | UR-055 | Done
DR-082 | Offline-scoped browse entry point in the repository client: browse downloaded content only (offline repository `get_items`/`get_libraries` — downloaded items plus their containers) independent of server reachability, without merging server catalog | Storage | UR-055 | Done
DR-083 | Downloaded browse reuses library grids, cards, and detail pages via the offline-scoped source; omits libraries/containers with no downloaded content; badges partially- vs fully-downloaded containers; play uses the local file; remove available at item/album/season/series level | UI | UR-055 | Done
DR-084 | Transfers view renders only in-flight rows (downloading/queued/paused/failed/waiting-for-WiFi) with Pause/Resume/Cancel/Retry; completed transfers leave the view and appear in Downloaded | UI | UR-055 | Done
DR-085 | Per-item on-disk size: stat downloaded files, aggregate to album/season/series subtotals and a device total, format in consistent rounded human units; surface size on cards and detail pages, the device total on the Downloaded surface, and a reclaim figure in the remove confirmation | Downloads | UR-056 | Done
DR-086 | Settings page persists each control on change via per-group writers (`playerSetAudioSettings` / `playerSetVideoSettings` / `updateCacheConfig`) rather than a batch Save action; slider controls persist on `change` (pointer release) not each `input` tick; no Save button, `saving`, or `saveMessage` state | Settings | UR-057 | Done

DR-087

`MediaCard` gains an `onLongPress` prop with pointer-based long-press detection (~500 ms hold, cancelled on >10 px move so carousel scroll is unaffected, trailing click suppressed); home carousels wire tap→detail/focus routing and long-press→confirm→player; episode taps route to `/library/<seriesId>?episode=<id>`; the bare-episode detail page links to its parent series/season

UI

UR-058

Done

DR-088 | Skip-to-next-episode marks the outgoing episode played (`markAsPlayed`) instead of reporting a stop position, and arms a one-shot suppression consumed by the player's stop handler so `VideoPlayer`'s post-navigation unmount stop report cannot overwrite the 100% progress with the partial position | UI | UR-059 | Done
DR-089 | Continue Watching suppresses resume entries superseded by Next Up: an in-progress episode whose series has a next-up entry strictly later in series order (season, then episode) is dropped from the Home and TV rows; movies, series without a next-up entry, and items with unknown/mixed episode ordering are always kept | UI | UR-059 | Done

DR-090

Relevance ranking in Rust (`domain/search_rank.rs`): results sort by match position (prefix → word-start → mid-word substring → no name match) then by media kind (containers before their contents), stably so the backend's own relevance breaks ties. Applied in `repository_search` to both the instant cache result and the merged cache+server union, so the list does not reshuffle when server results land

Backend

UR-060

Done

DR-091 | Search result groups split TV into separate Shows and Episodes groups and add a People group (default order: Shows → Episodes → Movies → Songs → Albums → Artists → People); a stored `tvShows` order from before the split expands in place to shows+episodes so an upgrading user keeps their arrangement | UI | UR-060 | Done

DR-092

Video tap gestures resolve in `tapGestures.ts` (pure, unit-tested) rather than inline in `VideoPlayer.svelte`: `registerTap` classifies each tap and the component acts on it immediately — `togglePlayPause` for a first tap, or `seek` (+30 s right / −10 s left) plus a re-toggle for a second tap inside `DOUBLE_TAP_WINDOW_MS` (300 ms). A consumed pair resets the state, and a swipe forgets the tap. The deferral this originally used was removed in DR-098, which also covers suppressing the compatibility `click` the browser synthesizes after a touch tap. `resolveSeekTarget` converts the delta to the absolute position the facade requires, clamped per DR-095 and chained off a still-in-flight `pendingSeekTarget` so back-to-back skips accumulate instead of all resolving against a not-yet-updated position

UI

UR-061

Done

DR-094

Frontend boundary tripwire (`scripts/check-frontend-boundary.sh`) detects Jellyfin item-type array literals anywhere in `src/` rather than only inline at an `includeItemTypes:` query site, so a category→type mapping cannot evade the check by being assigned to a named const (the evasion that let the `scoped-search` leak pass CI); requires two adjacent type literals so single-type presentation and `item.type ===` inspection stay legal, and caps the allowlist to force taxonomy into Rust instead of accumulating exceptions

Tooling

-

Done

DR-098

Video tap gestures act immediately — no deferral, no timer, and only first/second taps exist. A first tap toggles play/pause; a second tap inside `DOUBLE_TAP_WINDOW_MS` seeks and toggles again, so the two toggles cancel and a double tap preserves the play state (playing → jump and keep playing; paused → jump and stay paused). This replaces a design that deferred the first tap behind a 300 ms timer so a second tap could cancel it: the timer cleared its own handle before invoking the toggle, which reopened the `tapTimeout !== null` guard in `handleVideoClick` meant to suppress the compatibility `click` Android's WebView synthesizes after a touch — the late click then toggled a second time, producing a pause/unpause loop (long-press was unaffected, which is what identified the tap path). Click suppression no longer depends on the timer: `handleVideoClick` ignores `detail === 0` and any click within `TOUCH_CLICK_SUPPRESS_MS` of a touch tap. A swipe undoes the touchstart toggle exactly once (latched on `swipeGestureActive`) so brightness swipes never change play state. Click suppression is shared by every click target layered over the video via `isSynthesizedTouchClick`, not just the `<video>`: pausing renders a full-screen play-overlay button, so the synthesized click lands on that and an unguarded handler there resumed immediately — pausing appeared impossible while unpausing worked, because unpausing removes the overlay

UI

UR-061

Done

DR-099

The video seek bar is usable by touch. Two Android-only defects made dragging or tapping it move the thumb without moving playback. (a) Gesture hijack: the container-level gesture layer skips `touchstart` on a control (DR-098) but kept handling `touchmove`, so a seek-bar drag was measured against the previous gesture's start point — a huge bogus vertical delta that read as a brightness swipe, dimmed the screen to the 0.3 floor, and fired a spurious play/pause "correction" mid-drag. A gesture is now latched at `touchstart` (`playerGestureActive`) and `touchmove` ignores anything not latched, since re-checking the move target cannot recover a start point that was never recorded. (b) Commit signal: the seek was committed only from `change`, which Android's WebView does not reliably fire for a touch interaction on a range input — the thumb moved to the tapped position and no seek ever ran. `touchend`/`mouseup` now commit as well; `input` arms a one-shot latch so whichever release signal arrives first commits and the other is a no-op. `seekRelative` shares the same `commitSeek` entry point instead of fabricating a synthetic `change` event

UI

UR-005, UR-061

Done

DR-097

Transport authority (play/pause/toggle) lives in Rust for webview-rendered media, not just native. The controller tracks the state the HTML5 element reports (`html5_playing`, fed by `report_html5_state`, which now stores rather than only re-emitting); `play`/`pause`/`toggle_playback` consult it and drive the element by emitting a `ControlCommand` that `playerEvents.handleControlCommand` executes against the active adapter. A `stopped`/`idle` report clears it so the native backend (MPV/ExoPlayer) regains authority for music. The frontend facade no longer short-circuits transport into the adapter: `adapter.toggle()` previously decided play-vs-pause by reading `el.paused` off the DOM, a value that flips transiently while an element buffers or settles a seek — so two intents ~150 ms apart read different values, performed opposing actions, and self-sustained a play/pause loop needing no further input (observed on Android with a fully-buffered `readyState=4 networkState=1` element). Same "backend decides, adapter executes the primitive" split as `player_seek_video`

Player

UR-005

Done

DR-096

`Html5PlayerAdapter.play()` is resilient to stall recovery: an in-flight attempt is memoised so concurrent callers (UI plus hls.js gap-controller recovery) share one `element.play()` instead of stacking calls, and an `AbortError` ("play() request was interrupted by a call to pause()") is logged at debug rather than pushed to `host.onError`. The browser raises it whenever a pending play promise is superseded by a pause/seek/source change, which hls.js does routinely while nudging past a stall — reporting it surfaced a player error roughly once per second for the whole stall and left the UI stuck showing paused

Player

UR-005

Done

DR-095

Seek targets clamp strictly inside the media (`clampSeekTarget`, `END_SEEK_MARGIN_SECONDS` = 6 s ≈ one HLS segment) instead of to the exact `duration`. Landing on the duration makes hls.js request the segment whose start time lies past the end of the media (e.g. a 6330.324 s item → segment 1055 starting at 6336.33 s), which Jellyfin never produces; the fetch times out and hls.js' gap-controller stalls at the last buffered position, presenting as "unpausing or skipping bounces straight back to paused". Applied on both seek paths — the relative-skip `resolveSeekTarget` and the seek-bar drag, whose range input `max` is the duration itself — and floored at 0 so media shorter than the margin still seeks to the start

UI

UR-061

Done

DR-100

Leaving a video and re-entering it renders the video player, never the audio one. Both halves of the `/player/[id]` decision are pure and unit-tested in `playerSurface.ts`. (a) `shouldReuseActivePlayback` excludes video: the "already playing, just show the UI" shortcut (added for expanding the audio mini player) returns before a stream URL is fetched, which is fine for audio — the backend owns the stream and the route only mirrors it — but leaves `<VideoPlayer>` with nothing to render. Closing a webview-rendered video deliberately emits no `stopped` state (that would break the autoplay handoff, see DR-047), so the Rust controller still reports that movie/episode as its loaded media and re-entering the same item hit the shortcut. (b) `resolvePlayerSurface` maps video-without-a-stream-URL to `pending` (spinner) instead of falling through to `<AudioPlayer>`, so no future path can put video content in the audio surface. Video now always takes the full load path, which fetches the stream URL and applies the stored resume position

UI

UR-005

Done

DR-101

"Where is this viewer in this series" is resolved in Rust, not the frontend. `repository_get_series_episodes` performs the season fan-out (`get_items(series_id)` → seasons → `get_items(season_id)`, plus the flat-series fallback for shows whose children are episodes rather than season folders) and returns them in series order — season index ascending, episode index ascending, specials (season 0) after every numbered season. `repository_get_series_current_episode` layers the pure policy `pick_current_episode` over that list: an in-progress episode wins (earliest in series order on a tie — it is literally where playback stopped, and Next Up would skip past it), then the server's Next Up for that series, then the first unwatched episode, then the first. The third rung is the offline path, not dead code: `OfflineRepository::get_next_up_episodes` returns an empty vec, so without it the feature would be online-only. A failing Next Up or resume lookup degrades to empty rather than failing the call. `repository_get_next_up_episodes` had accepted a `series_id` since it was written and no caller had ever passed one

Repository

UR-062

Done

DR-102

The series detail page anchors on that answer. It calls `repositoryGetSeriesEpisodes` once instead of fanning out over seasons in TypeScript (the fan-out and its flat-series fallback were domain knowledge in the presentation layer), groups the returned episodes under season headers by `parentIndexNumber`, and passes the resolved current episode to `SeasonSection` → `EpisodeRow`, which renders a highlight ring and scrolls itself into view. The hero button navigates to `/library/<seriesId>?episode=<currentId>` — the Episode Focus View, where an explicit Play/Resume commits — per ux-flows §5B.5: Play on a container is navigation, Play on a leaf commits. It previously resolved `$libraryItems[0]`, the first season by `SortName`, and navigated to `/player/<seasonId>`, which the player route bounced back to `/library/<seasonId>` — so Play on a series played nothing and landed on the season-1 page

UI

UR-062

Done

DR-103

A season is not a destination. `/library/<seasonId>` redirects to `/library/<seriesId>#season-<indexNumber>`, the anchor `SeasonSection` renders, so a season link scrolls the series' continuous episode list rather than opening a page. Every inbound link follows: the episode breadcrumb, `handleItemClick case "season"`, the TV landing page's `case "Season"`, and `DownloadedBrowse`. A season carrying no `seriesId` (deep link into a stale cache) still renders the generic view so the user is never stranded. This removes a surface that had no route of its own — it fell through the detail page's `kind` chain to the generic "Contents" poster grid, contradicting ux-flows §5A.2 (episodes must be a row list), and clicking an episode there opened a bare Episode page, which §5B.1 forbids

UI

UR-062

Done

DR-104

The "More Episodes" strip spans the whole series in series order, per ux-flows §5B.2's cross-season continuity rule: at the end of a season the window runs on into the next season's first episodes instead of dead-ending. `adjacentEpisodes` previously filtered the pool to `parentIndexNumber === current.parentIndexNumber` and sorted by `indexNumber` alone, so the window could never leave the current season — and, when episodes of several seasons did reach it, sorting by episode number alone interleaved them. Cards crossing a season boundary are labelled `SxEy` rather than a bare episode number so the jump is legible

UI

UR-062

Done

DR-105

Video library routes collapse to one per library. `/library/tv` and `/library/movies` render browse / all-titles / genres as in-page tabs driven by `?view=`, omitted for the default `browse` (the convention `searchRouteUrl` already uses for the `all` scope); `resolveLibraryView` is pure and unit-tested. The four legacy routes become redirect-only `+page.ts` loads rather than deletions, because `GenreTags` links to them and users have them in history; `resolveSearchScope` keeps its `/library/shows` branch for the same reason. The "Browse" tile grid at the bottom of both landing pages is removed — it was a second navigation affordance to the same destinations the carousels' "Show all" links already reach

UI

UR-063

Done

DR-106

Erasing watch history goes through the repository, not the local cache: `clear_watch_history(item_id)` maps to Jellyfin's `DELETE /Users/{userId}/PlayedItems/{itemId}`, which clears the played flag and zeroes the resume position, and which the server applies recursively to a folder — so one call handles a whole series or season. `OfflineRepository` returns `RepoError::Offline` rather than clearing locally, because history diverged only on the device would be silently undone by the next sync; the button disables itself while the server is unreachable. `ClearHistoryButton` is shared by the series hero and each `SeasonSection` header, confirms before acting (there is no undo), and reloads the page on success so the recomputed current episode — the premiere, for a fully cleared series — is what the viewer sees

Repository

UR-064

Done

DR-107

Seasons on the series page are collapsible, and only the current season is expanded on load — the one holding the episode DR-101 resolved. A show with ten seasons otherwise renders every episode of every season at once, burying the one episode the viewer came for under hundreds of rows. Expansion state is per season and pure (`initialExpandedSeasons` in `seriesNavigation.ts`): the current season, or the first season when there is no current episode, so a never-watched show still opens on season 1 rather than fully collapsed. A `?episode=` deep link expands that episode's season too. Toggling is local and not persisted — it is a reading position, not a preference

UI

UR-062

Done

DR-108

The instant (cache) leg of `repository_search` searches the synced catalog, not just downloads. `OfflineRepository::search` replaces its `downloaded_items` CTE with the `available_items` CTE `get_items` already uses — the same downloads branches plus a `synced_at IS NOT NULL` branch gated on the same `include_catalog_browse()` flag — so search and browse cannot diverge on what is visible. Online (flag true) search reads the whole index and answers before any HTTP request completes; offline with "Show all server media" off (flag false) it stays downloads-only, unchanged. Requires no frontend change, since the flag is already set correctly for all three states. The `include_item_types` filter is switched from string interpolation to bound parameters, as `SearchOptions` is settable from the frontend and not only from `SearchScope`

Backend

UR-065

Implemented

DR-109

Index freshness is a Rust-owned policy, not a frontend startup call. A tokio task ticks every 30 min and runs a full pass when a repository is active, the server is reachable, and `last_catalog_sync` (already persisted to `app_settings`, previously read only for a UI hint) is older than `CATALOG_INDEX_TTL` (6 h); the `ConnectivityMonitor` reconnect signal re-evaluates the same condition immediately. An `AtomicBool` prevents concurrent passes, replacing `offlineCatalog.ts`'s `syncInProgress` — the frontend trigger is removed rather than left alongside, since two triggers with one guard each is how double-crawls happen. `RepositoryManager` gains an active-handle slot so the task has something to run against. Progress is emitted as the kebab-case `catalog-index-event`

Backend

UR-065

Implemented

DR-110

Index hygiene. `save_to_cache` switches from `INSERT OR REPLACE INTO items` to `ON CONFLICT(id) DO UPDATE`: REPLACE fires no `AFTER DELETE` trigger unless `recursive_triggers` is on (it is not — only `foreign_keys` and `journal_mode` are set), so `items_ad` never ran, and because `items.id` is a `TEXT PRIMARY KEY` each replacement also took a fresh rowid and appended a second `items_fts` entry — a duplicate index per sync, invisible in results but permanently degrading `MATCH`. The upsert preserves the rowid `items_fts` keys on and fires `items_au`; migration `021_rebuild_items_fts` clears orphans on existing installs. Separately, a post-crawl sweep deletes synced-but-not-downloaded rows a successful library crawl did not return, so media removed from the server stops being searchable; it skips items with completed downloads and skips any library whose crawl errored, because `items.parent_id` is `ON DELETE CASCADE` and a partial crawl would cascade away a whole series

Storage

UR-065

Implemented

DR-111

The index covers what the result groups render: `CATALOG_ITEM_TYPES` gains `MusicArtist` and `Playlist`, and migration `022_people_fts` adds a `people_fts` virtual table over the existing `people` table (which had no FTS, and is populated incidentally by item-detail fetches) with the same trigger pattern as `items_fts`. `OfflineRepository::search` UNIONs `people_fts` matches in as `Person` items when the resolved scope admits them — i.e. `SearchScope::All`, which expands to no filter (DR-063). Without this, the Artists and People groups UR-060 mandates can only ever be filled by the server leg

Storage

UR-065, UR-060

Implemented

DR-112

Safe-area insets come from native, not from `env()` alone. `env(safe-area-inset-*)` is 0px without `viewport-fit=cover` (missing from `app.html`, so every safe-area rule in the app was already a no-op), and even with it Android WebView maps only the display cutout — never the status bar or navigation bar. Since `enableEdgeToEdge()` plus `targetSdk 36` make edge-to-edge unconditional, the WebView always spans the system bars, so CSS could not learn about them by any route. `WindowInsetsBridge` reads the real insets and publishes `jt-inset` custom properties; `app.css` folds them with `env()` via `max()` into `--safe-*`, which is the only thing components may pad from. Ownership is exactly one element per edge: the app shell takes top/left/right, and BottomUi takes bottom wherever it renders (`shellReservesBottomInset` hands it back to the shell on routes with no bottom UI) so the padding sits inside BottomUi's surface box and the colour extends behind the gesture bar. The full-screen players inset their control layers only, leaving video and artwork edge-to-edge. The theme's `fitsSystemWindows=true` — which claimed the opposite and was overridden at runtime and ignored at this target SDK — is removed

UI

UR-066

Done

DR-113

`MediaItem.user_data` is populated from the server instead of being hardcoded `None`. `JellyfinItem` gains a `UserData` field (`#[serde(alias = "UserData")]` → the existing `UserData` type) and `to_media_item` maps it, so every list and detail response carries favourite/played/resume state. `UserData` is named explicitly in the `Fields=` list rather than relying on Jellyfin's default. Without this no card or detail page can render a favourite it did not itself set, and the mini player's per-track `storageGetPlaybackProgress` fetch is the only way to colour one heart

Repository

UR-069

Done

DR-114

Server favourite state is mirrored into the local `user_data` table by `OfflineRepository::save_to_cache` — the single choke point every cached server result passes through — so offline browsing and the offline Favourites page see the same favourites as the server. The upsert carries `pending_sync = 0` and is guarded by `WHERE user_data.pending_sync = 0`, which is the conflict rule: a toggle made offline is never overwritten by a stale server value before it has been pushed

Storage

UR-069

Done

DR-115

Cross-library favourites query: a `get_favorites(scope, options)` repository method plus the `repository_get_favorites` command. Online issues `Filters=IsFavorite&Recursive=true` with `IncludeItemTypes` expanded from `SearchScope::item_types()` in Rust (the frontend sends the opaque scope, never a type list — DR-063); offline reads `items ⨝ user_data (is_favorite = 1)` under the same `include_catalog_browse()` gate as browsing; hybrid races cache against server like `get_items` — saving server results through to the cache on a miss, so the favourites page does not re-query the server every visit and the DR-114 mirror is filled on a fresh install — and applies the DR-080 rule that an empty offline result is authoritative when the gate is off. The command falls back to this read when nothing is cached, rather than painting an empty state it will correct a round trip later. A separate method rather than `get_items` because favourites span libraries and `get_items` is `ParentId`-shaped

Repository

UR-067

Done

DR-116 | `GetItemsOptions.favorites_only` filters an existing library listing in place — online by appending `Filters=IsFavorite`, offline by joining `user_data` into the existing `available_items` CTE so the downloads-only gate still applies. This is what backs the per-library favourites toggle, and composes with the genre and item-type filters already there | Repository | UR-067 | Done

DR-117

The Favourites page (`/library/favorites`) renders favourites across libraries with All / Movies / Shows / Music scope tabs, reusing `LibraryViewTabs` + `LibraryGrid` + `MediaCard` so card shape still follows the media (§5A.1) and a mixed All tab reads as posters, squares and thumbnails side by side. Each tab sends a `SearchScope` value and nothing else. Reached from the library overview and from "See all" on the home rows

UI

UR-067

Done

DR-118 | Home carries favourite rows for movies, shows and music, loaded via `repository_get_favorites` per scope and rendered below Recently Added. A row with no items does not render at all, so a fresh install shows no empty favourite rows | UI | UR-067 | Done

DR-119

`FavoriteButton` is mounted wherever a whole item is shown — movie/series/episode detail heroes, album/artist/playlist headers, and as a `MediaCard` artwork overlay — and a `favorites` store holds in-session optimistic state so un-hearting on one surface updates every other without a refetch. Resolution order is `store override ?? item.userData?.isFavorite ?? false`. On a card the heart is its own button and stops propagation, so hearting never also opens, plays, or triggers the §5B.5 long-press; it is suppressed on server-only (greyed) cards

UI

UR-068

Done

DR-120

Favourite toggles made while offline reach the server. A Rust drain, triggered by the `ConnectivityMonitor` offline→online transition, pushes every `user_data` row with `pending_sync = 1` and clears the flag on success, leaving failures pending for the next transition. It lives in Rust rather than the frontend because a frontend drain dies with the component that started it. Both the drain and the hybrid background refresh emit the kebab-case `favorites-changed` event (`{ itemIds }`) so open views update — without it a favourite marked on another client appears only on the second visit to a page, since the cache-first read returns local rows and the server refresh is invisible to the frontend. Supersedes the unused `syncService.queueFavorite`, which is deleted rather than left as a second queue

Backend

UR-069

Done

DR-121

Player quality selector: Rust reports the bitrates available for the current media source and owns the quality→transcode-parameter mapping (the one `get_video_download_url` already holds — playback calls into it rather than restating it, or the two tables drift). Changing quality re-negotiates the stream URL and resumes at the current position with audio/subtitle selection preserved. On Linux, video re-negotiates within HLS: returning `stream.mp4` is the documented cause of transcoded playback never starting. The frontend renders the list and remembers the choice; it does not decide what the choice resolves to

UI

UR-070

Proposed

DR-122 | The playback path is ephemeral. Streamed bytes are never persisted unless DR-124 rules them keepable, and any in-flight capture is abandoned — partial file deleted, never promoted — the moment the viewer changes quality, because a capture spanning a rendition change is a splice of two encodings rather than a playable file | Playback | UR-070 | Proposed

DR-123

The download path is independent of playback: a whole-file fetch through the existing download manager at one canonical quality (default `original`, the direct static copy) over the Range-capable `/Videos/{id}/stream.mp4`, unaffected by bitrate changes and completing into an ordinary `downloads` row so offline browsing and `refresh_queue_local_sources` pick it up unchanged. Prerequisite: downloaded video is currently never played locally — `repository_get_video_stream_url` goes straight to the online repo and the player route calls it with no local check, so a completed video download is still streamed. Without that fix nothing in this spec is observable for video

Repository

UR-071

In Progress

DR-124

Streamed bytes are kept only where they are the download artifact — a direct-play session. Android uses ExoPlayer `SimpleCache`/`CacheDataSource` keyed by item and media-source id so renditions cannot collide, sharing the existing smart-cache storage budget rather than opening a second one over the same disk; Linux audio uses mpv `stream-record`, abandoned on seek because it is documented as intended for linear streams and seeking breaks the recording. Transcoded Linux video is not captured: HLS segments are not a file, and assembling one needs ffmpeg, which is not a dependency and which CI may not install at job time — DR-123 covers that case instead

Playback

UR-071

Proposed

DR-125

A capture is promoted to a completed `downloads` row only when it covers the whole resource; partials stay evictable cache. A new `downloads.source_rendition` column records the negotiated quality/container/codec (`NULL` for the existing paths, which are always `original`) so a captured transcode and a real download are distinguishable rows and an "upgrade to original" remains possible. A quality change never touches a file that already exists — not a permanent download, and not a completed temporary one, both of which stay valid copies of the rendition they hold. It invalidates only an in-flight capture or background download of cached media, which is abandoned and restarted at the newly chosen quality, because a capture spanning a rendition change is a splice of two encodings rather than a playable file

Storage

UR-071

Proposed

DR-126

Cache eviction only reclaims the temporary tier. `evict_lru_async` selected every completed download ordered by `completed_at ASC` with no `download_source` filter, so hitting the 10 GB storage limit deleted the oldest download — typically a film saved deliberately for offline — to make room for a newly precached track. It now evicts only `COALESCE(download_source, 'user') = 'auto'` rows; `COALESCE` rather than a bare equality because rows predating migration 012 can be NULL and unknown provenance must be treated as the user's, never as disposable. Freeing less than requested is the correct outcome when only user downloads remain — the caller reports "unable to free enough space" instead of silently deleting them

Storage

UR-071

Done

DR-127

A cache entry is a download with a shorter life: same `downloads` row and same file handling, distinguished by `download_source = 'auto'` plus an expiry, so there is one storage model rather than a cache and a download library that can disagree. Temporary rows are reclaimed on whichever comes first — the life limit elapsing, or eviction under space pressure (DR-126). Permanent (`'user'`) rows have no expiry. A temporary row can be promoted to permanent by the user choosing to keep it, which only clears the expiry and flips the source; the bytes never move

Storage

UR-071

Done

DR-128

Audio-only playback of downloaded media reads the local file rather than fetching an audio-only stream. No transcode is involved or wanted: the Linux backend already runs MPV with `video: no`, so handing it the downloaded video file decodes the audio track and ignores the video, and ExoPlayer disables its video renderer equivalently. Transcoding to a separate audio artifact would cost CPU and battery, need an encoder the project does not ship, and produce a second file to keep in step — for no gain over simply not decoding the video

Playback

UR-071

Done

DR-129

A stream that stops delivering is recovered, not treated as terminal. Two failure shapes, because the streams differ. (a) Phantom end — the background audio-only handoff uses a progressive mp3 transcode over plain HTTP, chunked and therefore length-less, so a dropped connection reaches the player as end-of-input and ExoPlayer reports `STATE_ENDED` indistinguishably from the real end. The item's runtime is the only thing that can tell them apart: an end reported more than a tolerance short of it (comparing the absolute position — handoff base plus the player's relative position) is a truncation. Left unhandled, playback parked in `STATE_ENDED` and the next play intent from the lockscreen, notification or a Bluetooth reconnect seeks an ended player to position 0 — the user-visible "the episode randomly restarted". (b) Recoverable error — music (`/Audio/{id}/stream?Static=true`) and video (`/Videos/{id}/master.m3u8`) declare their length, so the player detects the truncation itself and raises an error; the frontend's handler stopped playback outright, turning a hiccup into silence. Both resume the current item in place (never via `play_item`, which would replace the queue with a single item and lose the album), the error path after a per-attempt backoff. Seekable streams are re-prepared at the URL they already have and seeked; the length-less transcode, which cannot be seeked, has `StartTimeTicks` rewritten into its existing URL so the user's audio-track selection survives and recovery needs no network round-trip. Only `Remote` sources qualify — a local file cannot fail from the network. A shared budget of consecutive attempts at the same position, refilled whenever playback progresses, stops an unreachable server from looping

Playback

UR-040, UR-004

Done

DR-130

A backend's position and duration must survive the end of the file they describe. MPV exposes `time-pos`/`duration` as properties of the loaded file, so at EOF it unloads and both stop resolving — the accessors reported `0.0`/unknown at exactly the moment end-of-file handling asks where playback reached, and any position-versus-runtime check would have read every natural end as a truncation. The poll thread records the last reading and the accessors fall back to it. Linux resilience is layered on the same principle that the stream, not the player, is what failed: MPV is configured with ffmpeg reconnection (`stream-lavf-o`, `network-timeout`) so ordinary blips never surface, and `EndFile(ERROR)` — previously a bare log, which left playback halted while the UI still showed "playing" — is emitted as a recoverable error. Because MpvBackend is constructed before `PlayerController` exists, it cannot decide in-process like the Android JNI callback: the frontend echoes the error into `player_recover_stream`, which keeps the decision in Rust (the same shape as `PlaybackEnded` → `player_on_playback_ended`). Android reports errors it has already declined as unrecoverable, so the echo never asks twice

Playback

UR-004, UR-040

Done

DR-131

The offline mutation queue is drained. `sync_queue` had producers and no consumer: `PlaybackReporter::queue_for_sync` writes a row for every start/stop/mark-played that cannot reach the server, `sync_mark_processing`/`_completed`/`_failed` were registered commands with no callers, and no Rust task processed the table — so queued watch positions never reached Jellyfin and the offline banner's count only ever grew. A drain hangs off the same `connectivity:reconnected` transition as DR-120 (in Rust, because a drain started by a component dies with it) and replays rows oldest-first, so a stale start cannot move the server's resume position backwards after a later stop. `update_progress` replays as stopped at N rather than as progress — replaying a mid-playback report hours later would claim the item is still playing — and payloads are read in both dialects that exist in users' databases (`position_ticks` from Rust, camelCase `positionMs` from the frontend helper). A failed row stays queued for the next reconnect; after `MAX_SYNC_ATTEMPTS` it is `abandoned` and stops counting, because a row nothing can ever push is what turns the queue into a counter that only grows. An unreachable server is not counted as an attempt at all — the row goes back to `pending` untouched — so opening the app offline a few times cannot abandon good rows; only a server that answers and refuses spends the budget. The drain also runs once at startup, because a queue built in a previous session would otherwise sit untouched for a whole run whenever the server was reachable the entire time and no offline→online transition ever fired. Requires `MediaRepository::mark_played` (JA-035) — the previous stand-in reported a stop at `i64::MAX`

Backend

UR-025, UR-002

Done

DR-132

The pending-sync count is answerable. The offline banner's badge read "N pending sync(s)" and led nowhere, so it was taken for pending transfers and looked for on the Downloads page — which lists the `downloads` table and structurally cannot show `sync_queue` rows. The badge becomes a button opening the queue it counts: each row's operation, the item's title (resolved by a `LEFT JOIN items` in `sync_get_pending`, not a per-row frontend fetch), when it was queued, and the error of anything failing, plus a "Sync now" that runs the DR-131 drain on demand. The same list is a Settings section, because a row that keeps failing is still queued when the server is reachable and no banner is on screen. The drain emits `sync-queue-changed` so the badge updates on reconnect instead of lagging by up to one 10s poll

UI

UR-025

Done

DR-133

A downloaded file has exactly one on-disk path, and the row that names it is authoritative. `downloads.file_path` starts relative to the storage root, but the worker rewrites it to the absolute path it actually wrote when the transfer completes — so a completed row is already rooted. The video player's offline branch rooted it a second time, handing the asset protocol `/data/user/0/app//data/user/0/app/videos/x.mp4`; the webview reported `MEDIA_ERR_SRC_NOT_SUPPORTED` with `NETWORK_NO_SOURCE`, so every downloaded video failed to play while audio — which resolves the same column through Rust's `resolve_local_media_path`, without re-rooting — played fine. The join is absolute-aware (POSIX, Windows drive letters and UNC) so rows written before completion still resolve

Playback

UR-071

Done

DR-134

The webview can actually fetch the local files it is handed. `convertFileSrc` rewrites a path to `http://asset.localhost/…` unconditionally, but Tauri only answers that origin when the `protocol-asset` cargo feature is compiled in and `app.security.assetProtocol.enable` is set — neither was, so every such URL reached a protocol with no handler and the webview reported `NETWORK_NO_SOURCE`. This silently defeated both offline video (`<video src>`) and the cached-thumbnail path in `imageCache`, which fails soft to the server copy and so hid the breakage whenever the server was reachable. The scope was `$APPDATA/**` — the storage root under which the database, `downloads/` and the thumbnail cache all live — rather than an unrestricted grant; DR-198 narrows it further to `$APPDATA/thumbnails/**`, since DR-137 moved downloaded media off this protocol and thumbnails are all it still serves

Security

UR-071

Done

DR-140

An audio track is pinned only when the user picked one. Jellyfin's `MediaStream.Index` is global across every stream in a media source, so index 0 is the video stream on virtually all files — yet `AudioStreamIndex=0` was sent as "the first audio track" on the HLS transcode URL, the background audio-only handoff URL, the direct-play fallback URL, and the `PlaybackInfo` negotiation body. A server that honours the request literally then transcodes the video stream into the audio slot and the result plays as a picture with no sound; only servers that silently correct the index hid the bug, which is why it presented as "some videos have no audio". The parameter is now omitted whenever no track has been chosen, so the server resolves the source's `DefaultAudioStreamIndex`; an explicit selection from `player_switch_audio_track` is still carried through unchanged. On the `static=true` direct-play URL it is dropped outright — the original file is served untouched, so the parameter could only mislead

Playback

UR-004, UR-040

Done

DR-147

One search input per screen, and the URL is the search's single source of truth. The header bar rendered only under `/library/**` and merely navigated to `/search` (DR-063), so a desktop search handed the user to a screen whose input was a different element — the header box cleared itself and vanished, and the page's own box took over mid-word. That page then re-derived its input from `?q=` against `library.searchQuery` on every store write, so the next keystroke re-ran the effect and snapped the text back to the query the header had sent (and a scope chip back to the URL's scope); entering from the bottom-nav Search tab skipped it only because the effect early-returned on an empty query. The bar now renders on `/search` too (`showHeaderSearch`) and is the sole md+ input — the page's own input is `md:hidden` — and on that route it republishes the query into the URL with `replaceState`, so a whole session of typing costs one history entry. The page consumes that URL once per distinct value (`seedFromSearchUrl` against a non-reactive `applied` marker) instead of continuously reconciling it, and the scope chips publish through the same URL so the bar and the chips cannot disagree. Landing on `/search` with a seeded query focuses the bar and puts the caret at the end, because the box the user was typing in belonged to the unmounted route

UI

UR-049, UR-054

Done

DR-142

An episode has exactly one surface, and it is complete. Two divergent renderings existed: `EpisodeFocusView` (reached from Continue Watching, the series episode list, the TV landing page and Downloads — i.e. every real entry point) offered only Play and Favourite, while the bare `/library/<episodeId>` page nobody routed to carried the download button, the series/season breadcrumbs and the cast section. Opening an episode the normal way therefore silently lost the ability to download it. The Focus View is now the single surface and carries the full §5B.2 composition — hero action row `Play / Download / Favourite`, series name and `SxEy` badge as links back to the series and to that season's anchor, then genres → cast → similar shows below the episode strip, never above it (DR-062). `/library/<episodeId>` redirects into it (`episodeRedirectTarget`, the same rule seasons follow under DR-103), and an episode with no `seriesId` renders the same component series-less rather than falling back to a second, lesser page. The focused episode is fetched in full rather than reused from the season fan-out, because that is a list query and carries neither cast nor genres — the sections would have rendered empty. The strip hides itself when the episode has no siblings, a card that only shows the episode you are already on being noise

UI

UR-048, UR-058

Done

DR-141

The device profile states how many channels the audio route can actually voice. `MediaCodecList` answers "can this device decode 5.1", which is not the question that decides whether the user hears anything — a phone decodes an AC-3 5.1 track happily and still has two channels to play it out of. With no `MaxAudioChannels` in the profile, Jellyfin was free to direct-play the multichannel track, and the result is device dependent: a failed `AudioSink` configuration (silence) or dialogue folded into surround channels that go nowhere. media3's `AudioCapabilities.maxChannelCount` for the current route is reported over JNI alongside the codec lists, and bounds both the direct-play profile and the transcoding profiles, so the server downmixes rather than shipping channels the sink cannot take. Codecs are never removed from the profile — a device with genuine surround output keeps direct-playing it. A missing or zero reading means "route not yet established", not "no audio", and falls back to stereo, the one capability every sink has

Playback

UR-004

Done

DR-145

Video playback starts only once the app actually holds audio focus. Video manages focus by hand (`handleAudioFocus=false`, because ExoPlayer's automatic handling is reserved for the audio path), and the request's three outcomes were all treated as success: `AUDIOFOCUS_REQUEST_DELAYED` — which `setAcceptsDelayedFocusGain(true)` explicitly invites, and which means the system is withholding our audio until it calls back — and an outright `REQUEST_FAILED` were logged and then followed by `playWhenReady = true`. The picture rolled with no sound, indistinguishable to the user from a broken stream. Playback is now held when focus is not granted and started from the `AUDIOFOCUS_GAIN` callback; an explicit `play()` re-requests focus rather than resuming into a stream the system is still muting, guarded by a held-focus flag so repeated plays do not leak focus requests. A `LOSS` clears the pending flag, so an unrelated later `GAIN` cannot start playback the user never asked for

Playback

UR-004

Done

DR-146

The no-audio-track fallback picks a track the renderer can actually play. When ExoPlayer selected no audio track, the recovery forced group 0 / track 0 unconditionally — but the most likely reason nothing was selected is that this very track cannot be decoded on this device, so the override reinstated the silence it was meant to fix. It now scans the groups for the first `isTrackSupported` track and overrides to that, and clears `setTrackTypeDisabled(TRACK_TYPE_AUDIO)` because audio may equally have been off at the type level, which an override alone does not undo. When no group holds a supported track the condition is logged as an error — the server was expected to transcode — rather than leaving a silent video with no explanation in the log

Playback

UR-004

Done

DR-148

The video direct-play profile advertises only what the webview can decode. The audio codec list comes from `MediaCodecList`, which describes ExoPlayer — but video does not play through ExoPlayer on either platform: Android force-renders every video in the webview `<video>` element (the interim override in `VideoPlayer.svelte`, because the native SurfaceView sits behind an opaque webview) and Linux always has. Chromium and WebKit decode a far narrower set than the platform does, and the gap is widest on devices whose vendor licenses Dolby: a phone shipping `/vendor/etc/media_codecs_dolby_audio.xml` reports `ac3,eac3`, so Jellyfin direct-played an E-AC-3 track with `static=true` and the webview built a video decoder and no audio decoder at all — full picture, no sound. The defect is triggered by capability, not the lack of it, which is why it reproduced on one Motorola while a Fairphone and an Honor tablet played the same file on the same build: a device without the Dolby decoder never claims the codec, so the server transcodes to AAC and it plays. `video_audio_codecs` narrows the platform list to the webview-decodable set (`aac,mp3,opus,vorbis,flac`) for the video direct-play profile only — the audio-only profile keeps the full list, since that playback really is the native player's and narrowing it would transcode music that plays perfectly well. A list with nothing decodable still claims `aac` rather than going out empty, because a profile that claims nothing invites the server to give up instead of transcoding. The video codec list is deliberately untouched: HEVC direct-plays through the webview correctly, so the constraint is specific to audio

Playback

UR-004

Done

DR-149

The client decides whether its own renderer can decode the audio, rather than trusting the server's negotiation. Advertising a webview-shaped profile (DR-148) turned out to be necessary but not sufficient: Jellyfin 10.11.5 enforces a `DirectPlayProfile`'s `Container` and `VideoCodec` — excluding either returns `SupportsDirectPlay: false` with `TranscodeReasons=ContainerNotSupported` / `VideoCodecNotSupported` — but ignores its `AudioCodec`, offering an E-AC-3 track for direct play against a profile listing only `aac,flac,mp3,opus,vorbis`. Neither a `VideoAudio` `CodecProfile` forbidding the codec nor a `MaxAudioChannels: 2` against a 6-channel track changes the answer, so no profile the client can send fixes it and the picture plays silent. The negotiated source's audio is therefore checked locally against what the webview decodes, and an undecodable track forces the existing h264/aac HLS transcode URL regardless of the server saying direct play is fine — `direct_play` and `needs_transcoding` are corrected to match, so the frontend and the reporting path agree with the URL actually used. The track judged is the one the server would serve: the default, or the first when nothing is marked default, since a supported track further down the list is not the one that plays. A source with no audio streams, or a stream whose codec the server did not name, is left alone — forcing a transcode on a guess spends server CPU on files that already play

Playback

UR-004

Done

DR-150

Android video renders on the native ExoPlayer surface behind a transparent WebView, behind the `experimentalNativeVideo` opt-in. Rust already reported `use_html5_element: false` on Android, but two frontend overrides discarded it — `createAdapter()` hardcoded `"html5"`, and `VideoPlayer.svelte` forced `useHtml5Element = true` and stopped the native backend `player_play_item` had just started. The flag is a suppressor, never a promoter: off forces HTML5 even where Rust says native, so an in-progress spike cannot ship as the default, but it can never select native where Rust reported HTML5 (Linux cannot composite behind WebKitGTK, so promoting there is a black screen). Compositing requires clearing two independent opaque layers, and clearing only one leaves audio over a black picture — the WebView widget background and window drawable from Kotlin (`AndroidVideoSurface.setTransparent`), and the page's `html`/`body` and app-shell background from CSS (`data-native-video`). Transparency is declared in `tauri.android.conf.json` rather than the base config, because a transparent window on Linux has nothing behind it, and is toggled per playback session rather than set once, because a permanently transparent window shows the launcher through the rest of the app

Playback

UR-003, UR-004

Done (behind `experimentalNativeVideo`, default on since DR-194/DR-196)

DR-151

The player's video SurfaceView actually reaches the view hierarchy. `JellyTauPlayer.setActivity()` had zero callers, so `currentActivity` was always null and `autoAttachSurface()` returned at "Cannot attach surface - no Activity reference". The surface was created and handed to ExoPlayer but never added to the content view, so native video decoded to a surface that was never on screen — independent of any webview transparency. `MainActivity.onCreate` now supplies the reference, which also revives PiP on the video path: `canEnterPip()` gates on `isVideoSurfaceAttached()`, which had been permanently false

Playback

UR-003, UR-041

Done

DR-152

Platform playback facilities are reported by Rust, not sniffed from the user agent. `webviewAudio.ts` re-derived "does this platform have a native audio backend" by matching `navigator.userAgent` against `android`/`linux` — a second copy of the `cfg!` gate the backends are compiled under, free to drift from it. `player_get_capabilities` now returns `usesWebviewAudio` and `supportsNativeVideo` from the same cfg gates, and the frontend consumes them; the settings toggle for native video is hidden entirely where the platform cannot support it

Player

UR-003, UR-005

Done

DR-153

The git tag is the single source of truth for a release version. The version lived in four files (`package.json`, `tauri.conf.json`, `Cargo.toml`, `Cargo.lock`) that had to be hand-edited in lockstep, and CI's release job rewrote exactly one of them — so a tagged build produced an installer named for the tag wrapped around package metadata naming the previous release, while the Linux job had no version step at all and shipped whatever was committed. `scripts/set-version.sh` writes all four from one argument and is the only thing that does; every release job calls it with the tag. The Android `versionCode` is derived in the same place as `1000 + major*10000 + minor*100 + patch`, which is monotonic in semver order and clears the 1000 floor already installed in the field — a lower code than the installed one makes Android refuse the update. A prerelease suffix is stripped before that arithmetic, which would otherwise abort the script, and a non-tag ref (CI passes `${GITHUB_REF#refs/tags/}` unconditionally) falls back to `git describe` rather than failing a branch build

Build

-

Done

DR-154

A watch position that cannot reach the server is queued, not dropped. `sync_queue` and its drain (DR-131) were built, tested and running, but the stop-report path never fed them: `HybridRepository::report_playback_stopped` is a bare pass-through to the online repository ("Playback reporting goes directly to server"), and on failure the error surfaced to a frontend `catch` whose own comment read "Server error - could queue, but for now just log". Both producers that would have queued it — `PlaybackReporter::queue_for_sync` in Rust and `syncService.queuePlaybackProgress` on the frontend — have no callers on the playback path, so closing a video while the server was unreachable lost the resume point outright even though `user_data.pending_sync` was dutifully set to 1 and nothing ever drains that flag for positions (unlike favourites, DR-120). The command layer now enqueues a `report_playback_stopped` row whenever the push fails, which the existing drain already knows how to parse and replay. The pending row for an item is superseded in place rather than appended to: progress is reported every 10s, so a server that stays down would otherwise add a row per tick, all of them obsoleted by the newest — the unbounded queue DR-131 exists to prevent. Only `pending`/`failed` rows are superseded, because an `abandoned` row has been given up on and reviving it would restore that same growing counter. Queueing is best-effort and never fails the command: the local position is already saved, so a failed queue write must not be reported as a lost position

Backend

UR-025, UR-002

Done

DR-155

A watch position set on another device reaches this one. The resume check reads the local `user_data` row and nothing else, but `mirror_user_data` — the only path by which server `UserData` lands in that table — mirrored `is_favorite` alone, and returned early whenever that field was absent, which is exactly the shape of an ordinary watched episode. So `playback_position_ticks` was write-only from this device's perspective: watch 40 minutes in a browser, open JellyTau, and it resumed from whatever this device last saw or offered no resume at all — the same user-visible symptom as DR-150's Android bug, from an unrelated cause, which is why resume read as broadly flaky. The mirror now carries the position alongside the favourite flag under the same `pending_sync = 0` conflict rule, so a local position still waiting to be pushed is never pulled backwards by a server that has not yet heard where we got to; `COALESCE(excluded.x, user_data.x)` means a field the server omitted keeps its stored value rather than being nulled, and a row with neither field is still skipped rather than fabricated as zeroes. Mirroring alone was not sufficient: `get_item` — the call the player route makes — returned the cached copy on a hit and never consulted the server, so for an already-cached item the mirror never ran. It now refreshes in the background on a cache hit (`race_with_refresh`, the reusable form of what `get_items` already did inline), which is why browsing a season picked up other devices' state while opening the episode directly did not. The refreshed value lands for the next read, the cache-first race still answering immediately

Backend

UR-025, UR-002

Done

DR-156

A page no longer inherits the previous page's scroll position. The shell keeps its scrollers alive across navigation by design — the root layout, the home page and the library layout each own a `flex-1 overflow-y-auto` box that outlives the route inside it, which is what lets `BottomUi` be a flex sibling rather than a measured overlay — but the element therefore never remounts and its `scrollTop` survives the route change. SvelteKit's own scroll restoration could not help: it saves and restores `window` scroll, and in this app the window never scrolls at all, so there was no scroll handling of any kind. The symptom was that opening an item from half-way down a library grid dropped the viewer half-way down the detail page, and returning to the grid landed at the top of it — exactly backwards. `ScrollMemory` (pure, one instance per container, keyed on path + query so a genre-filtered grid keeps its own place) records the offset a route is left at in `beforeNavigate` and decides in `afterNavigate`: `link`/`goto`/`form` reset to the top, `popstate` restores that route's saved offset, and the initial `enter` is left alone. Deciding does not consume the offset, so a route returned to more than once restores each time. Applied via the `scrollContainer` action on all three scrollers

UI

UR-072

Done

DR-160

Picture-in-picture works on the path that actually plays video. PiP shrinks the whole Activity, so `canEnterPip` demanded a native ExoPlayer `SurfaceView` be attached and rendering — `isPlayingVideo() && getSurfaceView() != null && isVideoSurfaceAttached()`. But the native path sits behind `experimentalNativeVideo`, which defaulted to off, so in the shipping configuration video played in the WebView's `<video>` element and all three conditions were false. `enterPip` bailed with "Not entering PiP: no local video playing" every single time: the button was offered (gated only on OS capability) and could not work, however it was pressed. The manager now accepts either surface. The frontend reports the element through `AndroidPictureInPicture.setHtml5VideoState(active, width, height, playing)` — intrinsic size because the PiP window's aspect ratio came from the letterboxed surface's measured bounds, which do not exist here, and play state because `ExoPlayer.isPlaying` is false on this path and the PiP play/pause action would be frozen on "Play" mid-playback. Two behaviours invert when the WebView is the video: it must stay visible in PiP rather than be hidden (`hideWebView` is now gated on the native path — hiding it would leave an empty black window), and the play/pause `RemoteAction` has to reach the element, so the receiver dispatches `jellytau-pip-play`/`jellytau-pip-pause` DOM events instead of driving ExoPlayer. `jellytau-pip-entered`/`-exited` let the player strip its own chrome, since controls, title and gradients would otherwise be rendered into a window a couple of inches wide. The `<video>` is deregistered on teardown so PiP is never offered over a video that has gone

UI

UR-041

Done (pending device verification)

DR-167

Each downloaded library shows only its own media. Cached items carry no link back to their library — `library_id` and `parent_id` are NULL on every row ([[offline-libraries-never-cached]]) — so `get_downloaded_items` matched the library branch with `EXISTS (SELECT 1 FROM libraries l WHERE l.id = ?)`, which asserts only that the requested library exists and never constrains the item to it. Opening any downloaded library therefore listed every downloaded top-level item on the server: films under Music, albums under TV. The sibling query that decides which libraries appear already carried the right rule — a `collection_type` ↔ `item_type` mapping — so the two disagreed about the same question. That mapping is now the named constant `LIBRARY_HOLDS_ITEM`, used by both, and a library of unknown collection type still keeps everything rather than being emptied by a rule that cannot classify it. The taxonomy stays in Rust, never the frontend

Downloads

UR-055

Done

DR-168

Pause and resume actually stop and restart the bytes. `pause_download` wrote `status = 'paused'` and did nothing else, and no cancellation existed anywhere in the download stack — no token, no flag, no abort — so the streaming task ran on, kept writing, and overwrote the row with `completed`/`failed` when it finished: the row flicked to "paused" and undid itself. `resume_download` had the mirror defect, flipping the row to `pending` without calling `pump_download_queue`; the pump runs when something calls it rather than polling, so a resumed download sat untouched until an unrelated event happened to pump the queue. A per-download stop flag (`download::stop`) is the missing half — a module-level registry because the two sides never meet, the command holding Tauri state and the worker running detached in `async_runtime::spawn`. The worker reads it between chunks and on retry (so a pause is not swallowed by a 45-second backoff), flushes, and returns `Stopped`, which is deliberately not retryable and not recorded as a failure: the `.part` file is left intact because that is exactly what the resume's Range request continues from. Registering returns a fresh flag, or a resumed download would inherit the pause that stopped it and halt instantly. Cancel and `clear_stale_downloads` signal it too, so neither deletes a file still being written

Downloads

UR-055

Done

DR-169

Partial files are actually reaped. The worker named its sidecar with `Path::with_extension("part")`, which replaces the extension — `movie.mp4` became `movie.part` — while every cleanup path deleted `"{file_path}.part"`, i.e. `movie.mp4.part`. The two never matched, so the partial file of every cancelled or failed download stayed on disk indefinitely, invisible to the disk-usage totals because no `downloads` row pointed at it. `partial_path` appends instead, is the single definition both the writer and the cleaners use, and incidentally removes a collision the old form had, where `movie.mp4` and `movie.mkv` mapped to one `movie.part`

Downloads

UR-055

Done

DR-173

Downloading an album queues the whole album, and every track it queued is findable offline afterwards. Two independent gaps left an album with a handful of its tracks on the device while the button reported the album as downloaded. First, `download_album` took its track list from `items WHERE album_id = ?` — the local catalog cache. Jellyfin does not return `AlbumId` on every listing endpoint, so tracks cached by one of those endpoints sit in `items` with a NULL `album_id` and are invisible to that query; on the reporter's database three whole albums (18, 12 and 9 tracks) had it NULL on every track, so "download album" would have queued nothing for them, and a partially-linked album queued only the linked subset. Second, the frontend then resolved one stream URL per track from its own list and paired it with the returned row ids by position — a pairing with no basis, since the ids came back in the backend's `index_number` order over a different set of rows, so a row could be handed another track's URL and any track past the end of the shorter list was never started at all; on Android that loop also stopped wherever the webview was suspended. The same `album_id` is what `OfflineRepository::get_items` joins a track to its album on, so a track that did download stayed invisible under its album offline — the two halves of the same missing link. The operation now belongs to Rust end to end: `HybridRepository::get_album_tracks` asks the server what the album contains (cache-first `get_items` is right for browsing and wrong for deciding what to download) and errors offline so the caller falls back to the ungated local catalog, keeping the queue-while-offline flow; `queue_album_tracks` writes the album link onto every track it queues — queuing a track is the statement that it belongs to the album, rather than something to hope a listing endpoint recorded — and the stream URLs are resolved here through the existing reconnect resolver, now scoped to the rows just queued so one album cannot start every unrelated pending row. Nothing crosses the IPC boundary but the album id. Re-queuing a broken album heals it: the missing tracks are added and the tracks already on disk get their link. `download_series`/`download_season` still derive their episode lists from the cache the same way and want the same treatment

Downloads

UR-018, UR-055

Done

DR-170

Downloads at a chosen bitrate are no longer corrupted by their own retries. Only the `original` preset asks for `Static=true`; every other rung requests a transcode, which Jellyfin serves chunked, with no `Content-Length`, and cannot byte-seek — so it ignores `Range` and answers `200` with the whole stream from the beginning rather than `206` with the requested tail. The worker sent the Range header whenever a `.part` existed and appended the body unconditionally, so each retry and each resume concatenated a fresh copy of the entire transcode onto the bytes already on disk: the file grew past its real size and would not play, which is why "downloads for different bitrates" stayed broken after the `videoBitRate` casing fix (DR-adc460f3) corrected the request. `resume_offset` makes the response decide — append only on a `206`, otherwise truncate and take the stream from the top — and the total size is computed from that offset rather than from a partial length the server never agreed to

Downloads

UR-071

Done

DR-172

Native Android video is opt-in again, because as a default it shipped as audio with no picture. DR-161 flipped `experimentalNativeVideo` on so picture-in-picture could shrink a real video surface; on a device that produced sound and a blank screen. The decode path was never the problem — logcat showed ExoPlayer running (`Position update` ticks) and feeding a live `SurfaceView` with an active BufferQueue. The compositing was: the SurfaceView sits behind the WebView, and the step that clears the opaque layers above it never took effect, with `WebView transparent = false` logged and `= true` never appearing. So the video rendered correctly the whole time, behind an opaque page. This is exactly the defect the flag existed to contain — `VideoPlayer.scrubRegression.test.ts` had recorded that "the native SurfaceView has never been visible through the webview" — and enabling it by default shipped a verified decode path on top of an unverified display path. Reverting costs nothing that matters: PiP does not depend on it (DR-160 drives PiP from the WebView `<video>`), and working video outranks PiP showing a native surface. The flag stays available in Settings, now described as incomplete rather than as a performance win, and the scrub-regression mocks that were made explicit under DR-161 are kept explicit so those tests state which path they guard rather than inheriting a default that has now moved twice. Fixing the compositing is the prerequisite for trying this default again

UI

UR-003, UR-004, UR-041

Done

DR-171

A downloaded video keeps audio the device can actually decode. `original` quality asked for `Static=true`, which hands back the source file byte-for-byte — E-AC-3/AC-3/DTS/TrueHD track included — and video is rendered on both platforms by the webview `<video>` element, which decodes none of them. Streaming already knew this: DR-149 judges the track the server would serve against `WEBVIEW_AUDIO_CODECS` and forces a transcode over Jellyfin's own direct-play offer, because 10.11.5 honours a `DirectPlayProfile`'s container and video codec but ignores its audio codec. The download path never consulted that policy, so the same film had sound when streamed and played as picture in silence once downloaded — and offline a download is the only source a video has, so there was no working path left to fall back to. The rule is now one rule: `served_audio_codec` picks the track the server will serve (the default, or the first when none is marked) and both callers judge it, the streaming verdict staying a bool and the download path needing the codec itself so it can say what to re-encode. Only the audio is re-encoded — `allowVideoStreamCopy=true` keeps an h264 source's picture byte-for-byte and no bitrate or resolution cap is added, so `original` still means original quality; a source the webview could not have rendered anyway (HEVC) becomes h264 as a side effect, which is the only form of it that would have played. The decision is per item rather than blanket because the transcode costs the byte-range resumability `Static=true` gives the download worker (see DR-170 for what a chunked, length-less response does to a resume), so a file whose audio already plays keeps the direct copy. An unknown codec — item not fetchable, or the server named none — changes nothing: the policy only ever adds a transcode, so it cannot make a working download worse. The codec set judged against is the webview's, not the platform's, even though DR-161 made ExoPlayer the Android default: `experimentalNativeVideo` is a user setting, a downloaded file outlives whatever it was set to when the file arrived, and the narrow list is the only one that holds on both sides of it — at the cost of a Dolby-licensed device re-encoding a track its ExoPlayer could have played. `resolve_video_download_url` is the single entrance for all three resolution sites (the frontend's per-item command, the bulk series/season enqueue, and the offline-queued resume), since the pure builder cannot look a codec up and a caller that forgets to is exactly how the silent downloads shipped. Files already downloaded stay silent — the bytes on disk are the wrong bytes and only a re-download replaces them

Downloads

UR-071, UR-004

Done

DR-162

Video streams are opened against a bandwidth ceiling the user chose, instead of a fixed allowance nobody could change. Every video URL carried `MaxStreamingBitrate=20000000`/`VideoBitrate=18000000`, `PlaybackInfo` negotiated at 20 Mbps, and the device profile advertised `999999999` — so on a metered or slow connection the only lever was not watching. `StreamingQuality` is a ladder of ceilings (Original, 20/10/8/4/2/1 Mbps, 720 kbps) in which a step is not a label but a bundle of transcode parameters: the total ceiling, the audio share of it, and the resolution that budget can carry. It lives in Rust because those numbers are Jellyfin encoding vocabulary — the frontend names a variant and reads labels back over `player_get_streaming_qualities`, the same arrangement as the EQ preset curves. The video bitrate is the total minus the audio share, so the two together honour the cap rather than overshooting it by the size of the audio track, and `MaxHeight` falls with the ladder so a small budget is not spent on pixels it cannot afford. The cap has to reach the negotiation, not only the transcode URL: `max_static_bitrate` in the device profile is what makes the server refuse to direct-play a source fatter than the ceiling, and without it a 30 Mbps remux is handed over untouched and every URL parameter downstream is moot — which is why it is applied at all four places that decide bandwidth (the HLS builder, `PlaybackInfo`, `open_live_stream`, and the background-audio handoff, which takes the lower of the cap and its own 384 kbps). The ceiling is process-wide rather than a field on `OnlineRepository`, mirroring `INCLUDE_CATALOG_BROWSE`: it is a preference about this device's connection, it must survive a repository rebuilt on re-login, and every builder plus the negotiation have to agree on it or the cap leaks. Settings owns the durable default and is the only writer to `app_settings` — persisted unlike the rest of `VideoSettings`, because a limit set for a metered connection that silently reverts to uncapped on the next launch spends the user's data with no changed setting to show for it — and it is restored at startup from the async runtime, defaulting to uncapped if the read fails so a database problem degrades to the old behaviour rather than to an arbitrary limit. The in-player menu is the per-video override: a cap is a property of the stream the server is producing, so it cannot be applied to one already in flight — `player_set_stream_quality` re-opens the stream at the new quality and resumes at the current position, reloading a native backend itself and handing HTML5 a URL for the same `reloadSource` primitive the audio-track switch uses, so no strategy branch lives in the UI. It deliberately does not persist. This gives UR-070 its resume-at-the-same-point mechanism; the server-offered per-item rendition list that requirement also asks for remains proposed

Playback

UR-074, UR-070

Done

DR-174

Tiles of mixed shapes are laid out justified rather than gridded. A CSS grid gives every cell one box, so on a page holding square music covers, 16:9 library backdrops and 2:3 posters at once, everything that is not the chosen shape is cropped to it — the home shortcut strip was explicitly forcing `aspect="video"` on music libraries for exactly this reason, which lined the row up by cutting the covers down. `layoutMosaic` packs tiles into rows of a shared height and gives each its own width from its own aspect ratio: it adds tiles to a row until the height needed to fill the container has fallen to the target, closes the row there (so rows land at or below the target, never above), and justifies the row to the container width by absorbing the rounding remainder into its widest tile, where a pixel is least visible. The last row is deliberately not justified — with one tile left over, filling the width would inflate it to a banner — so it sits at the target height, left-aligned. Ratios are clamped to a band, which costs a crop on genuine outliers and stops one panorama owning a row or one very tall image shrinking to a sliver. It is a pure module with no DOM: the component supplies only the two things the DOM knows — the measured container width, and the artwork's decoded aspect ratio, reported by `CachedImage` so the layout uses the shape an image actually has rather than the one its item type implies. Those measurements are committed in one debounced batch rather than per image, because artwork arrives over several hundred milliseconds and re-packing on each arrival would shuffle the grid under the pointer repeatedly. Labels are drawn over the bottom of each tile rather than beneath it: a caption below sits outside the computed box, and one that wraps to two lines would break the row alignment the layout exists to provide

UI

UR-075

Done

DR-175

A library knows which favourites category it belongs to, and the frontend does not work it out. The mosaic offers a favourites tile per category beside its library, which needs a collection-type → category answer; deriving it in Svelte would have re-created the exact leak `SearchScope::item_types` was extracted to close (docs/specs/scoped-search-boundary.md) — one table of Jellyfin vocabulary, differing only in which vocabulary. `SearchScope::for_collection_type` maps `movies`/`tvshows`/`music` and returns `None` for everything else, so a Live TV or books library gets no tile at all rather than one opening an unfiltered list; `All` is never derived from a library, being the cross-library entry offered beside them rather than a property of one. `Library::new` stamps the result onto every library at construction — a constructor rather than a struct literal precisely so a derived field cannot be forgotten at one of the four sites — and it rides to the frontend as an optional `favoritesScope`, absent rather than null when there is none. The UI's remaining share is presentation only: what to call the tile, where to put it, and showing a category's tile once however many libraries share it, since two movie libraries have one favourites list between them

UI

UR-075, UR-067

Done

DR-176

The server is never asked to burn a subtitle into the picture. `PlaybackInfo` omitted `SubtitleStreamIndex`, which does not mean "none" — the server then honours the source's default/forced flag and picks a track itself. On a source whose default subtitle is image-based (PGS/DVD/DVB) that track cannot go out as a sidecar, so the server falls back to `SubtitleMethod=Encode` and composites it into the video. The cost lands on the video, not the subtitle: burn-in rules out remuxing, so an HEVC stream the device could have taken untouched is re-encoded frame by frame. Observed on an HEVC + E-AC-3 + PGSSUB episode, where only the audio actually needed transcoding: the server could not sustain the re-encode in real time, the buffer never grew past a single segment, and playback stalled every few seconds — taking seeking with it, since each seek restarted the encoder and cost seconds before the first frame. The fix is to request `SubtitleStreamIndex=-1` explicitly and to advertise every text format we can render (`srt`/`subrip`/`ass`/`ssa`/`vtt`) as `External`, so a subtitle can only ever arrive as a sidecar. Nothing is lost, because the app already fetches subtitle tracks itself and draws them over the video (UR-020) — the server's composited copy was always redundant. Image-based tracks are consequently not offered, which is honest rather than a regression: the renderer cannot composite a bitmap, and the previous behaviour paid for them by making the whole stream unwatchable. Both halves of that hold at the layer that can enforce them. The sentinel travels on the stream URL as well as in the negotiation, because the negotiation is not what opens most streams — a quality switch, a transcoded seek and an audio-track switch each rebuild the URL on their own, and an omitted index there lets the server pick the default track back up out of whatever session state it still holds. And "not offered" is enforced where the offer is made: each subtitle stream crosses the boundary carrying the backend's verdict on whether it can arrive as a sidecar, so the picker lists only tracks the app can draw instead of showing an entry that ticks and displays nothing. Only an explicit "no" hides a track, so a stream carrying no verdict behaves as before

Playback

UR-020, UR-004

Done

DR-177

Each video transcode this device opens is its own server-side job, and the one it replaces is stopped. Jellyfin keys a transcode job by device and play session, and every stream URL the app built carried the same hardcoded `DeviceId` with no `PlaySessionId` at all — so the second stream for an item was indistinguishable from the first. Re-opening a stream is not rare: a mid-playback quality switch (UR-074), a transcoded seek and an audio-track switch all do it, each leaving the previous ffmpeg running. Observed on-device when switching bitrate mid-film: the server served the new playlist, then rejected the new job's segments with `400 hls1/main/0.ts` while the two jobs contended for one transcode path, and playback stalled — reproducible against the server, where a second stream for a live job's item alternates between serving bytes and 400ing per attempt, which is what made it read as flaky rather than broken. `begin_video_play_session` mints a session id per open and reports the one it supersedes; the URL builder stops that job (`DELETE /Videos/ActiveEncodings`, un-retried and best-effort — a slow stop must not delay playback, and the new stream no longer collides either way) before returning. Placing it in the URL builder rather than in each caller means every re-open path is covered by construction. Two client faults made the same incident worse and are fixed with it: the fatal-HLS-error handler added the transcode seek offset to a position that already included it, so past roughly the halfway mark of a film any transient network error cleared the "near end" threshold and was reported as end-of-stream — turning a recoverable stall into a skip to the next item, exactly when a quality switch had just made the offset large; and the HTML5 reload primitive resolved on its own `canplay` timeout, so a reload the server never served reported success, leaving the picker showing a quality that was not playing and the caller with nothing to revert

Playback

UR-074, UR-004

Done

DR-178

Every position that leaves the app is read from the controller, not from a backend that may not be playing anything. `PlayerController::position()` forwards to the native backend, which is authoritative for exactly one of the three ways this app renders media. On the webview path — the shipping default for video on both platforms — nothing is loaded into that backend at all: the `<video>` element is the player, its ticks were re-emitted to the frontend and then dropped, and the backend answered 0 forever. During a background-audio handoff the base that converts the stream's relative timeline to the episode's is applied once at the native tick boundary (DR-159), so before ExoPlayer's first tick nothing has applied it and the reading is 0 there too. Both holes surfaced as the same user-visible bug through different doors: returning to the foreground while the audio-only transcode was still opening handed the frontend `0.0`, and the video reloaded at `StartTimeTicks=0` — the episode restarting from the beginning — while the `Stopped` report that followed wrote that zero to Jellyfin as the resume point. `absolute_position()` answers for all three paths: the maximum of the backend's reading, the last position webview-rendered media reported, and the handoff base. The maximum is exact rather than a heuristic, because at most one term is ever meaningful at a time and the base is a floor the stream cannot physically be behind. `duration()` gains the same fallback for the same reason. The element's reading is cleared wherever it stops being the player — teardown, a handoff taking over, a different item loading — so it can never be attributed to what plays next

Player

UR-005, UR-025, UR-040

Done (pending device verification)

DR-179

Jellyfin is told what was played: progress while it plays, and a stop when it ends. A device trace of 35 minutes' playback requested `/Sessions/Playing/Progress` zero times and sent 14 `Stopped` reports, every one of them at position 0. Three faults, one subject. Progress never left the device: the frontend service writes it to the local DB by design, and nothing on the Rust side reported it for webview-rendered media — so the server learned a position only when the player was closed, and a crash or a swipe-away cost the session. It is now reported from the controller's own position ticks, through the 30s throttler it already owned and shares with the native audio path, which covers all three rendering paths in one place instead of adding a second frequent IPC caller. Zero-position stops were sent: Jellyfin stores the reported position as the resume point, so a zero does not merely fail to inform, it instructs the server to forget — and no zero was ever real, each one coming from asking a player that was not rendering the media (see DR-178). They are withheld; one landed 40s after the frontend had correctly reported 15:22 for the same episode, overwriting it. A finished episode reported nothing at all: Jellyfin decides "watched" from the stop report and its percentage, and in background audio-only mode nobody sends one — the webview is suspended and its element was torn down at the handoff, while the backend advances to the next episode without a word about the one that ended, so an episode listened to end-to-end on the lockscreen never counted as watched. `on_playback_ended` now reports it stopped at its runtime (not the last tick, which can be seconds short or, on a handoff whose ticks stopped early, nowhere near the end) before any advance, since after one the queue's current item is the next episode. Scoped to the audio-only handoff, the case the frontend provably cannot cover, so foreground playback keeps its single existing report; music ending natively remains unreported and wants its own change. The reporting seam is a `PlaybackReportSink` the controller sends to, which also collapses three copies of the spawn-a-task-and-hope block into one and is what let all of this be written as failing tests rather than found on a device a second time

Player

UR-025, UR-005, UR-040

Done (pending device verification)

DR-180

A background-audio handoff of a downloaded episode starts where the video left off. The handoff prefers a local file over the audio-only stream (DR-128), but the two begin in different places and were treated alike: a stream is built with `StartTimeTicks`, so the server makes the handoff point that stream's zero and the base is the handoff position with no seek — while a file has no such parameter and begins at the episode's own zero, so basing it at the handoff position claimed minutes of audio that were about to play from the beginning. Backgrounding a downloaded episode therefore restarted it while the lockscreen scrubber, dutifully adding the base, showed the position it should have been at. `background_audio_plan` splits the two: a file gets no base and a real seek, a stream keeps the base and no seek (seeking one would skip past the content by the handoff position again). The same distinction settles an inbound seek — `seek_absolute` re-opens a streamed handoff at the requested position because a chunked length-less transcode cannot honour a seek, which is not true of local media, and `resume_stream_at` refuses a non-remote source outright, so routing a lockscreen scrub of a downloaded episode through it failed the seek rather than performing it

Player

UR-040, UR-071

Done (pending device verification)

DR-181

A resumed transcode plays. Every video stream URL carried the resume position as `StartTimeTicks`, which is correct for a progressive response and fatal for an HLS one: Jellyfin builds each segment URI by echoing the master playlist's query string into it, and its segment handler opens by rejecting any request carrying `StartTimeTicks > 0` (`ArgumentException` → `400`). One position on the playlist therefore 400s every `hls1/main/N.ts` behind it, so hls.js exhausted its retries and gave up — presenting as an episode that will not resume while the same episode from the beginning is fine, the `> 0` being exactly why the beginning survived. The parameter is also unnecessary there: a playlist spans the whole item and asking for segment N is the seek, which the server transcodes from. So it is removed from the URL builder entirely rather than conditionalised — the builder has one caller shape and no way to know whether the response will be segmented — and the position becomes what it always was for HLS, a seek issued once the player has loaded: the seek path reloads at zero and seeks the element, and the resume path lets the player seek itself. The progressive `/Audio/universal` builder used by the background-audio handoff is a different endpoint with no segments and keeps its `StartTimeTicks`, which is why an audio-only handoff resumes correctly and a video one did not

Playback

UR-004, UR-074

Done

DR-182

Native video shows a picture. The poster/title card is an opaque `bg-black` overlay drawn over the whole video area while `isMediaReady` is false, and every signal that clears it is emitted by the HTML5 `<video>` element — `canplay`, `loadedmetadata`, hls.js `FRAG_BUFFERED`, the `playing` event, and two `readyState` timeouts. The native path renders no such element (`{#if !!useHtml5Element}`), so on Android nothing could ever clear it: ExoPlayer decoded to a live SurfaceView behind a black div for the entire session. That is DR-172's "audio with no picture" report, and it is indistinguishable on screen from the compositing failure DR-172 attributed it to — which is why the flag was reverted rather than fixed. Both the overlay and the native branch date from the original POC commit, so the native path has never been able to reveal itself; the 2026-08-11 device verification predates neither and does not contradict this, since a spike run that never reached a steady state would not have shown it. The backend's own events are the equivalent signals and `nativeSignalRevealsVideo` is the rule for reading them: `state === "playing"` mirrors the element's `playing` event, and a position tick carrying a real position or duration mirrors the `readyState` backstops, covering a first state event that is dropped or arrives before the listener is attached. `buffering`/`paused`/`stopped`/`error` deliberately do not qualify — revealing on `error` would replace the title card with a transparent hole showing the launcher through the app. The rule is a pure module rather than a branch inside the component because the decision that was missing is exactly the part worth guarding, and the component needs a DOM and a mounted player to exercise

UI

UR-003, UR-004, UR-041

Done

DR-183

The JavaScript bridges are installed before the page that uses them loads. WebView binds an injected object into JS at page-load time: an `addJavascriptInterface` call landing after the page has loaded does not appear to that page. They were installed from `configureWebViewForMedia`, which finds the WebView by walking the view tree 500 ms after `onCreate` — a race against Tauri's own page load, and one that is permanent when lost, because the identity guard added for DR-097's stale-proxy bug then declines to re-inject on every later resume pass. The whole set (`AndroidVideoSurface`, `AndroidPictureInPicture`, `AndroidBackgroundAudio`, `AndroidNetworkType`, `AndroidImmersive`, `AndroidInsets`) would simply be absent from `window`, and silently: every call site optional-chains the bridge, so a missing one is a no-op rather than an error. This is a candidate explanation for DR-172's other piece of evidence — `WebView transparent = false` logged, `= true` never appearing, i.e. the enable call never reaching Kotlin at all. `WryActivity.setWebView()` calls the `onWebViewCreate` hook immediately before wry issues the first `loadUrl` (confirmed in wry 0.55's `main_pipe.rs`, where the `setWebView` JNI call precedes `load_url`), so a bridge installed there is bound by the time any page runs. The hook can fire during `super.onCreate()`, before the rest of our own `onCreate`, so only work needing nothing but the WebView moves into it — insets stay in `configureWebViewForMedia`, which runs later and on every resume. The tree-walk path is kept as a fallback, and `enableNativeVideoCompositing` now logs an explicit error when the bridge is missing, so the ambiguity that left DR-172 unresolved cannot recur silently

Android

UR-003, UR-004, UR-040, UR-041

Done

DR-184

The video SurfaceView leaves the view hierarchy when the video does. `VideoOverlayManager.detachVideoSurface` had no callers anywhere in the tree — the mirror of the DR-151 defect, where `setActivity` had none — so `attachVideoSurface` was one-way: `JellyTauPlayer.clearVideoSurface()` dropped its `surfaceView` reference and cleared ExoPlayer's without removing the view, leaving it parented to the content view for the life of the process, with the next native video adding another SurfaceView beneath it. The stack was invisible while the WebView was opaque, which is why it went unnoticed. Two consequences outlive the leak: `isVideoSurfaceAttached()` gates `PictureInPictureManager.canEnterPip` through `isNativeVideoPath()`, so it reported an attached surface forever after the first native video (saved from offering PiP over nothing only by the `isPlayingVideo()` check beside it), and every abandoned surface held its `OnLayoutChangeListener` on the content view. Detach is called from `clearVideoSurface`, which covers stop, the switch to audio, and the background-audio handoff, and always runs on the main thread because every caller is already inside a `mainHandler.post`. It removes the view from its own parent rather than looking the content view up from an Activity reference, so an Activity recreated underneath it cannot strand the view

Android

UR-003, UR-041

Done

DR-185

The app shell stops painting over the video surface. `app.css` clears the page's opaque layers for native video through three selectors, and one of them — `html[data-native-video="active"] [data-app-shell]` — was written against an attribute no component has ever set, in any commit. The shell is `+layout.svelte`'s root `div`, which paints `--color-background` across the entire viewport; VideoPlayer is `fixed inset-0 z-50` and correctly makes itself transparent on the native path, but it stacks above the shell, so the WebView still composited the shell's opaque background over the whole screen and the SurfaceView behind it could never be seen. This is the missing half of the compositing DR-172 went looking for: the spec's own layer table lists this layer as "cleared by `data-native-video` → app.css", which was written but never wired, and `html`/`body` being genuinely transparent made the CSS look correct in isolation. The failure is invisible three ways over — the CSS is valid, the selector is plausible, and a rule matching nothing looks exactly like a rule matching something already transparent — while the symptom (black screen, audio fine) is identical to a real compositing failure, which is how it survived DR-150 through DR-172. Fixed by setting the attribute the rule was written for, and guarded by asserting the relationship rather than the rule: every attribute the compositing block targets must be set somewhere in the app, so a selector aimed at nothing fails the suite instead of failing silently on a device

UI

UR-003, UR-004, UR-041

Done

DR-186

The play overlay comes down when the backend plays. `isPlaying` was assigned once from the `player_play_item` response and thereafter only by the `player://state-changed` listener — a channel the backend never emits, the same dead wire that DR-182's first fix was mistakenly hung on. On the native path the flag therefore froze at whatever the initial response said: with ExoPlayer playing, the UI still believed it was paused, so the `bg-black/30` play-button overlay stayed raised across the whole video area and the transport button kept showing ▶. The video was simultaneously dimmed and covered while it played, which reads as "the overlay never goes away" and is easily mistaken for a second compositing fault. The mirror reads the same `player` store `playerEvents.ts` feeds, which is what the architecture already says is authoritative — the player reports state, the UI consumes it — and is gated to the native path so HTML5 keeps its element-event wiring, which is authoritative there

UI

UR-003, UR-005

Done

DR-187

The system bars go away with the player, not only with the fullscreen button. `enterImmersive()` had exactly one caller, `toggleFullscreen()`, so opening the player left the status and navigation bars painted over it until the user pressed a button most never press. On the native path this is worse than cosmetic: the SurfaceView fills the content view, so the bars sit directly on top of the video. The player is a full-screen surface by construction — `fixed inset-0 z-50` over a `MATCH_PARENT` surface — so entry is the right moment. Called synchronously in `onMount` before any `await`, per the native-mode pitfall, and paired with the `exitImmersive()` already unconditional in `onDestroy`, so a player torn down while immersive cannot leave the rest of the app without bars

UI

UR-066, UR-003

Done

DR-188

Native Android video is ready to be the default except for the background-audio handoff, and the flip therefore waits. The picture defects behind DR-172 are all found, fixed and device-verified — DR-185 (the app shell painted over the surface through a CSS rule targeting an attribute nothing set), DR-182 (nothing could lift the poster card on a path with no `<video>` element), DR-183 (the JS bridges raced the page load, so `setTransparent(true)` could never arrive), DR-184 (the SurfaceView was never detached), plus DR-186 and DR-187, the two UI defects only this path could reveal. On a device logcat now carries `WebView transparent = true` and `Marking media ready` with video on screen, which is the pair DR-172 went looking for and could not find, and skip, seek and rotation were exercised by hand. Turning the default on then surfaced a different unverified sub-path: the background-audio handoff could only return through the HTML5 element, so coming back from the lockscreen left playback dead, and the flip waited for that rather than shipping a verified sub-path over an unverified one as DR-161 had. The default is now on. The two defects holding it back are fixed and device-verified — DR-196 (the handoff return restarts the renderer that is actually on screen) and DR-194 (the letterbox bars are painted rather than retaining stale framebuffer content) — with the evidence this default has been held to since DR-161: an audio handoff at 69:54 returning to video playing at 70:18, and clean bars across playback, the control bar and a rotation round-trip. An explicit stored choice still wins in both directions, so an opt-out survives the flip (the stored value is null-checked rather than compared to "true", which would have silently re-enabled it for everyone who turned it off)

Android

UR-003, UR-004

Done

DR-189

The control bar comes down on a touchscreen. Its hide timer was armed from exactly one place — the player container's `onmousemove` — and a touchscreen never fires `mousemove`, so on Android the bar was never scheduled to hide and sat over the video for the whole film. It went unnoticed for as long as the native video surface was itself invisible (DR-172/DR-185): with nothing behind it to obscure, a permanent control bar reads as the UI rather than as a defect. Two changes, because there were two faults. `revealControls()` replaces `handleMouseMove` and is called on entry and on every touch interaction as well as on mouse movement, so touch arms the countdown. And the countdown became an `$effect` over the state rather than a one-shot timer armed by the input event: the first attempt armed a timer on entry, three seconds later playback had not started, `shouldHideControls` correctly declined, and nothing ever re-armed it — the timer has to follow the conditions that permit hiding, which arrive on their own schedule. The decision itself is `shouldHideControls` in `controlsVisibility.ts`, pure and separated from the clock and the DOM, because what was wrong here was the conditions and not the `setTimeout`: the bar stays up while paused (a user who paused by tapping the surface has no other way back), mid-seek (the position readout is the point of the bar then), and while any track/subtitle/quality menu is open (the menus are anchored to the bar, so hiding it would take the open menu with it)

UI

UR-003, UR-066

Done

DR-191

Forcing the WebView overlay to redraw from the Activity, because with the ExoPlayer SurfaceView beneath it the overlay's ordinary damage stopped reaching the screen: the page kept mutating — the clock text every second, the control bar's opacity going to 0 — while the display held whatever frame it last presented, over video that animated perfectly. Not a state defect; the live DOM showed the slider advancing 476 → 479 across three seconds behind a screen showing neither. Only structural changes got through, which is why the play overlay always appeared to work (an `{#if}` block, added and removed) while the progress bar never did, and why rotation lost the transport UI. A CSS animation cannot help, since opacity animates on the compositor without repainting the layer. Superseded by DR-192: this drove `postInvalidateOnAnimation` in a loop, which treats the symptom — the cause is the SurfaceView's separate layer, and removing that removes the need. Kept as the record of how the mechanism was identified

Android

UR-003, UR-004

Superseded by DR-192

DR-195

Play/pause works on the native path, because the frontend stops claiming a webview element is playing when there is none. `html5_playing` is Rust's record of "a webview `<video>` is active and in this state", and `toggle_playback`, `play` and `pause` all route transport to that element whenever it is set. The player route mirrored element state into it unconditionally — from `handleReportStart` and, fatally, from `handleReportProgress`, which VideoPlayer calls on a 10-second interval — so on the native path the frontend re-declared every ten seconds that an element was playing when none existed, and every transport intent was emitted into the void. The pause button was dead from the on-screen tap, from the control bar, and from a direct `player_toggle` invocation, while seek and skip kept working because `player_seek_video` decides elsewhere; that asymmetry is the signature. It also explains the flashing, since the control bar and the JRay overlay both key off `isPlaying`, which was being contradicted on every interval tick. DR-193 clearing the flag at load was necessary but insufficient on its own — the interval put it straight back. The mirror now lives in `mirrorElementStateToRust` in VideoPlayer, gated on `useHtml5Element`, which is the only place that knows whether an element renders at all; the route cannot tell the two paths apart, which is precisely how it came to lie. Confirmed on device by ADB: surface tap and control bar each pause (position frozen across repeated samples, transport label flipped) and resume

Playback

UR-005, UR-003

Done

DR-196

Returning from background audio brings the picture back on the native path, because the return now restarts the renderer that is actually on screen. The two paths resume by different means: the webview `<video>` reloads off its stream URL, watched by an `$effect` that reinitialises HLS and lets `canplay` drive the seek — while ExoPlayer owns no element and nothing watches the URL on its behalf, so its playback is only ever started by an explicit `player_play_item` + adapter load, issued once from `onMount`. `exitBackgroundAudioHandoff` did only the URL assignment, for both paths, so on the native path it restarted nothing: `player_exit_background_audio` had already stopped the handoff's audio player, leaving the backend holding no item at all. The symptom is a black screen with a play overlay pinned at 0:00, a seek bar at zero, and a play button that does nothing — the process alive and the frontend still logging, since nothing crashed; the transition was simply dropped. The branch is decided by `planHandoffReturn` (pure, in `backgroundAudioHandoff.ts`), which also folds in `shouldResumeOnForeground` so a lockscreen pause during the handoff still wins over the snapshot taken on the way out. Subtitle configurations are reused from the ones resolved at mount, since ExoPlayer sideloads them as `MediaItem.SubtitleConfiguration`s and cannot accept one after `prepare()`. Verified on device: handoff to audio at 69:54, return restored video playing at 70:18

Playback

UR-040, UR-003

Done

DR-197

Continue Watching and Next Up stop showing the same episode. Jellyfin's `/Shows/NextUp` defaults `EnableResumable=true`, which returns a partially-watched episode as its own series' next up — precisely the episode `/Items/Resume` already returns — so the Home "Next Episode" row and the TV landing's Next Up row duplicated Continue Watching card for card. `build_next_up_endpoint` sends `EnableResumable=false`, and because servers predating that parameter ignore it, `filterInProgressNextUpItems` also drops any next-up entry whose id appears in the resume list. It is the mirror of DR-089 and lives beside it: same presentation-layer de-duplication over two lists the frontend already holds, no Jellyfin taxonomy involved. The resume filter still reads its frontier from the unfiltered Next Up list, so removing in-progress entries cannot resurrect a stale resume card. The division is then exact: Continue Watching offers episodes the viewer has started and not finished, Next Up offers the episode after the ones they finished

Repository

UR-059

Done

DR-200

The lockscreen notification is exempt from `POST_NOTIFICATIONS`, because of the session token, not because it belongs to a foreground service — and the difference is what the code now records. `POST_NOTIFICATIONS` was declared in the manifest and requested nowhere, so on Android 13+ it sat permanently denied; an audit read that as a threat to UR-006, since the media notification is what carries the lockscreen transport controls. It is not. Android's own wording is that the permission covers "non-exempt (including Foreground Services (FGS)) notifications", with denied users seeing FGS notices "in the Task Manager but [not] in the notification drawer" — so an FGS notification is explicitly not exempt — while separately "Notifications related to media sessions are exempt from this behavior change". The platform predicate is `Notification.isMediaNotification()`, which requires `MediaStyle` and a non-null `EXTRA_MEDIA_SESSION`, and it is byte-identical across API 33–36. `NotificationManagerService` uses it to decide whether to drop the post, and SystemUI's media carousel (`MediaDataProcessor.onNotificationAdded`) is gated on the same predicate — so a token-less notification is not merely absent from the shade, it never reaches the notification listener and the lockscreen/Quick-Settings controls do not exist at all. Confirmed on device (HONOR ROD2-W09, Android 16 / SDK 36): appops `POST_NOTIFICATION: ignore`, `granted=false`, and the service simultaneously `isForeground=true` with `foregroundNoti=Notification(category=transport actions=3 vis=PUBLIC)`. So no runtime permission request is added — a prompt the app does not need is a prompt that can be permanently denied for nothing — and no `checkSelfPermission` gate is placed on `startForeground`, which would trade a cosmetic problem for the "did not then call Service.startForeground()" kill. What is added is the guard that matches the real precondition: `mediaSessionCompat?.sessionToken` is a null-safe call, and the exemption hangs entirely on it, so both builders now bind the token once and log an error if it is ever null while the permission is denied — converting a failure that is invisible unless the tester happened to deny the permission (most grant it reflexively) into a logcat line. The manifest declaration is kept, unrequested, and documented: media3 does not need it (media3-session declares no permissions and the `MediaSessionService` guide asks only for the two `FOREGROUND_SERVICE` ones), but the exemption covers media and self-managed-call notifications only, so a download-completion notice (UR-011) would be an ordinary notification and silently dropped — keeping the declaration is what makes adding one a one-file change

Android

UR-006

Done

DR-201

A lockscreen skip means different things depending on what is playing, and the backend decides which. `onSkipToNext`/`onSkipToPrevious` forwarded a bare `"next"`/`"previous"` to Rust, which always advanced the queue — correct for music, wrong for a video whose audio is running through a background-audio handoff (UR-040), where the buttons should scrub. Pressing skip to re-hear a line jumped to the next episode instead. `resolve_skip_action` in `player/seek.rs` maps the command to either `Advance` or `SeekTo`, and `is_background_audio_active()` is the whole test: the handoff exists only for video, and an episode played through it reports `MediaType::Audio`, so media type cannot distinguish the case. Forward jumps 30s, back 10s — asymmetric because the back button replays dialogue just missed rather than travels — and both clamp to `[0, duration]`, since a negative offset is rejected by backends and a seek past the end reads as EOF and would advance, the very outcome being prevented. Routed through the same spawn-then-`seek_absolute` path as the scrubber, because a handoff seek re-opens the stream and must not run under the blocking lock (DR-159). The Kotlin keeps sending the same opaque command; only the `PlaybackStateCompat` gains `ACTION_FAST_FORWARD`/`ACTION_REWIND` so the system draws seek affordances rather than skip arrows that lie about what they do

Playback

UR-040, UR-006

Done

DR-202

Video keeps the display awake. Android counts its display timeout from the last user input, and watching something is exactly the case where there is none, so the screen dimmed and slept mid-film unless the user kept tapping it. Nothing held it: `FLAG_KEEP_SCREEN_ON` appeared nowhere in the app, and neither renderer supplies a hold for free — ExoPlayer's `setWakeMode` is a CPU/wifi wake lock that says nothing about the display, and it draws into the `TextureView` this app owns (DR-192) rather than media3's `PlayerView`, which is the widget that would otherwise set `keepScreenOn` itself; the WebView `<video>` path is no better, because the display wake lock Chrome takes for video lives in the browser layer and not in an embedded WebView. `ScreenWakeManager` toggles `FLAG_KEEP_SCREEN_ON` on the Activity window — window-scoped, so it stops applying the moment the app is not visible and cannot outlive a crash the way an explicitly acquired `PowerManager.WakeLock` can, and it needs no permission (the manifest's `WAKE_LOCK` is the media service's). The two rendering paths are independent holders OR-ed in the pure `ScreenWakeState`: the native path follows `onIsPlayingChanged` plus surface teardown, so the hold tracks what ExoPlayer reports rather than what the UI intends, and the webview path reuses the `setHtml5VideoState` report the frontend already sends for PiP (DR-160) rather than adding a bridge. Audio is deliberately not a holder — playing music with the screen off is the point of that path — so the hold is gated on the media type being video, and it is dropped on pause, on stop, on surface teardown, and on a new WebView, since a page that goes away never sends its own final `active = false`. Also the repo's first Kotlin JVM unit tests: `ScreenWakeState` is framework-free so the decision is testable off-device with `./gradlew :app:testUniversalDebugUnitTest`. Verified on device (FP5, native path): `IS PLAYING CHANGED: true` → `keepScreenOn = true` 17 ms later and `fl=KEEP_SCREEN_ON` on the window in `dumpsys`, a pause releasing it and the resume re-taking it. The webview path is unverified

Android

UR-003, UR-004

Done

DR-203

The background-audio handoff stops silently rewinding to the point it started. A player retry is only a retry if it can resume where the load failed, and ExoPlayer decides that in `ProgressiveMediaPeriod.configureRetry`: it keeps the load position when the content length is known or the extractor produced a seek map with a duration, and otherwise assumes the source is live — the data at the URL is taken to have changed, so every sample queue is reset and the URL is re-requested from offset 0. The handoff transcode (`/Audio/{id}/universal?Container=mp3&TranscodingProtocol=http`, DR-129) satisfies neither condition: chunked, so no `Content-Length`, and a live mp3 encode carries no `Xing` header, so the duration is unset — on device every position tick reads `<position> / 0.0`. Its URL carries `StartTimeTicks` = the handoff point, so "from offset 0" is the handoff point, and after any transient load error playback resumed there and ran on normally. Nothing was reported: a successful retry raises no error and no `STATE_ENDED`, so neither arm of DR-129 was ever consulted, no `onPositionDiscontinuity` handler existed, and the app's only trace of it was a position that went backwards — which is why it read as random, since it needs a network blip to land while a load is in flight rather than while the ~50s buffer covers it, and why it survived the two earlier fixes for the same symptom (DR-129's phantom end, DR-159's relative-timeline leak). The decision is Rust's: `player_retry_restarts_stream` marks a `Remote` audio-only video item, and `loadWithMetadata` carries the answer to Kotlin, where the pure `StreamRetryDecision` holds it for a `DefaultLoadErrorHandlingPolicy` subclass that returns `C.TIME_UNSET` — which makes `onLoadError` answer `DONT_RETRY_FATAL` before reaching `configureRetry`. The rewind therefore becomes a recoverable error, and `recoverable_error_resume` already knows what to do with one: re-open at the position playback actually reached, `StartTimeTicks` rewritten, with backoff and the shared attempt budget. Every other source keeps the player's retry, because a static file and an HLS playlist both declare their timeline and are resumed in place. A `onPositionDiscontinuity` handler is added for the log line alone, so a recurrence is visible rather than invisible — loud for `DISCONTINUITY_REASON_INTERNAL`, which is the rewind's own signature, and quiet for the backwards jump a resume's re-prepare legitimately makes. Reproduced and verified on device (FP5), same procedure both times: background-audio handoff, 60s to fill the buffer, a 45s radio outage, then watch. Before — the outage passed unnoticed and 3.5 minutes later, with nothing logged in between, `BUFFERING` → `READY` → position `1165.4s` → `840.3s`, exactly the handoff base, no error and no `STATE_ENDED`; the same log line reports `Media ready! Duration: -9.223372036854776E15`, which is `C.TIME_UNSET` and the precondition itself. After — `Load error on a stream that cannot be resumed in place — declining the player's retry` at the outage, playback continuing undisturbed off the buffer for 69s (a fatal load error is only raised when the renderer next needs data), then `ERROR_CODE_IO_NETWORK_CONNECTION_FAILED` → `re-opening at 785.6s in 2s` → `READY`, playing on from 785.6s with no rewind in the following 7 minutes

Playback

UR-040, UR-004

Done

DR-199

The webview stops undoing the network security config. `MainActivity.configureWebViewSettings` set `mixedContentMode = MIXED_CONTENT_ALWAYS_ALLOW` together with `allowFileAccess = true` and `allowContentAccess = true`, which is a blanket cleartext opt-in reached by hand — exactly the thing `network_security_config.xml` exists to prevent and its own comment warns against (DR-138). Nothing needed any of the three. `file://` is never loaded: cached thumbnails go through `convertFileSrc`, which on Android resolves to `http://asset.localhost/…` and is answered by wry's request interceptor rather than the filesystem, and downloaded media goes over the loopback HTTP server (DR-137), which exists precisely because the asset/file route cannot stream a large file. `content://` is never loaded either — the manifest's `FileProvider` is for outbound share intents, not webview navigation. And mixed content never arises: Tauri serves the UI from `http://tauri.localhost` (`use_https_scheme` defaults false and is not set in `tauri.conf.json`), while both `127.0.0.1` and `asset.localhost` are loopback/`.localhost` origins that Chromium treats as potentially trustworthy, so they are not mixed content to begin with. A plain-HTTP remote Jellyfin server would be, but the network security config already rejects it before any mixed-content check runs — so `ALWAYS_ALLOW` bought nothing and only widened the hole. `COMPATIBILITY_MODE` rather than `NEVER_ALLOW` is a deliberate hedge and not the default — the platform default at targetSdk 21+ is `NEVER_ALLOW` — because none of this can be verified anywhere but a device, and compatibility mode keeps passive content (images) working if the analysis missed a path. `allowFileAccess = false` restores the targetSdk-30+ default; `allowContentAccess = false` is a genuine tightening (its default is true) and is the first thing to look at if something that used to render stops. The two files now cross-reference each other so the pair cannot drift apart again

Security

UR-071

Done (pending device verification)

DR-194

Stale pixels in the letterbox bars — the rotation "flash of the previous frame", a ghost control bar stranded in the top bar, each new clock digit drawn over the last (`35:42` with the `1` still showing through the `2`), and menus (sleep timer, quality) leaving their imprint behind. One cause for all of it: nothing painted the bars. The window surface is opaque (the theme is not translucent), and for an opaque surface HWUI deliberately does not clear the damaged region before replaying a frame — it assumes the view hierarchy covers every pixel. That hierarchy is window background → video `TextureView` → transparent WebView, and `fitSurfaceToScreen` sizes the TextureView to the letterboxed video rect, so the bars were the window background's alone to paint. `setTransparent(true)` cleared that background to `TRANSPARENT`, leaving the bars painted by nobody and whatever was last in the framebuffer surviving in them. Fixed by keeping the window background opaque black while compositing; the WebView's own background is what lets the video through, and the TextureView is drawn on top of the window background, so an opaque one cannot hide it. Three earlier fixes aimed at the window's rotation animation and at TextureView frame-retention (two `postOnAnimation` hops, an `onSurfaceTextureUpdated` reveal, then `ROTATION_ANIMATION_JUMPCUT` + `FLAG_FULLSCREEN`) all missed, because the pixels were never the animation's; the alpha-hiding among them made it worse by blanking the one view that reliably paints its own rect. Those are removed, `FLAG_FULLSCREEN` included — it fought edge-to-edge insets for no gain. Verified on device: ghosting reproduced with native video on, then absent after the fix, across playback, the control bar and a rotation round-trip

Android

UR-003, UR-066

Done

DR-193

Play/pause reaches the player that is actually rendering. `toggle_playback`, `play` and `pause` all route to the webview element when `is_html5_active()`, which is `html5_playing.is_some()` — a flag written only by the element's own state reports and cleared only when it reports "stopped"/"idle" (or on a background-audio handoff). An element that went away without that final report, or webview-rendered music earlier in the same process, therefore left the flag set, and on Android's native video path every transport intent was emitted as a `ControlCommand` at an element that no longer existed: the pause button did nothing, from the on-screen tap and from the control bar alike, while seek and skip kept working because `player_seek_video` decides elsewhere. Whether it happened at all depended on what had played before, which is exactly what made it read as flaky rather than broken. `load_and_play` — the native load path, and the one the HTML5 video path deliberately avoids via `set_current_item` — now clears the flag, because loading into the native backend is the statement that native renders this item. Nothing is lost on the webview path: an element re-establishes its own authority the moment it reports again, so this is the existing "element is gone" semantics applied where it can be known directly rather than inferred from a report that may never arrive

Playback

UR-005, UR-003

Done

DR-192

Native video presents through a TextureView, not a SurfaceView. A SurfaceView renders on its own layer outside the app window and punches a transparent region through it; everything drawn above that hole — for us the entire Svelte UI in a transparent WebView — depends on that composition path, and Android's own graphics documentation states that "overlays do not currently work correctly with SurfaceView or TextureView". The consequences were four symptoms of one cause (DR-191): a frozen progress bar, controls that would not fade, rotation losing the transport UI, and overlays that lingered after the DOM removed them. A TextureView is an ordinary view whose frames are drawn as a texture in the window's normal rendering pass, so there is no second layer and no transparent region, and the WebView above composites like it would over any other view — which is why media3 offers `surface_type="texture_view"` and why it is the standard remedy for ExoPlayer overlay problems. The trade is accepted rather than hidden: TextureView costs more power and memory than SurfaceView and adds a frame of latency, but hardware decode through MediaCodec is untouched, so the reason native video exists survives it. `setVideoTextureView` installs ExoPlayer's own `SurfaceTextureListener`, so the old `SurfaceHolder.Callback` wiring is deleted rather than ported — adding a listener of ours would displace it and the video would never appear. PiP needs no change, since a TextureView is a View and the aspect-ratio probe reads its measured bounds

Android

UR-003, UR-004, UR-041

Done

DR-190

The background-audio handoff can return to the native path. Everything that restores playback on the way back is written around the WebView `<video>`: `applyPendingForegroundSeek` returns early on `!videoElement`, the HLS re-init `$effect` returns early on `!useHtml5Element`, and `pendingForegroundSeek`/`pendingForegroundPlay` — which own the post-handoff position and play/pause — are consumed only by `handleCanPlay` and `markMediaReady`, an element event and a path that reaches the same guard. On the native path there is no element, so `exitBackgroundAudioHandoff` completes, clears `handoffState`, blanks and reassigns `currentStreamUrl` to force an effect that will not run, and nothing ever restarts ExoPlayer: the user returns from the lockscreen to a dead player. This never showed while the path was opt-in and its picture was invisible anyway. The return needs the native equivalent of the element reload — re-issue the item to the backend, seek to the position `player_exit_background_audio` reports, then honour `wasPlaying` — routed through the adapter rather than the element, so both paths restore through one contract

Playback

UR-040, UR-003

Superseded by DR-196

DR-161

Native video is the default, so picture-in-picture has a real surface. DR-160 makes PiP work on the HTML5 path, but that path can only ever shrink the UI into the PiP window; showing the video itself needs the SurfaceView behind the WebView, which is what `experimentalNativeVideo` gates. The flag now defaults to on when the user has never chosen, with an explicit stored choice still winning in both directions so anyone who turned it off keeps it off. This is a deliberate acceptance of risk: the flag existed because the native path was an unfinished spike, and `VideoPlayer.scrubRegression.test.ts` documents its history — a native init that flipped to HTML5 mid-lifecycle and left seeks going down one path while ExoPlayer played on another. Those tests pin the flag-off interim override (native response overridden to HTML5, backend stopped once), which the default no longer selects, so they now mock the flag off rather than inherit it: they still guard that path, but they no longer describe what ships. The native scrub/seek path is consequently not covered by the suite and needs device verification

UI

UR-041, UR-003

Needs device verification

DR-159

The background-audio handoff stops leaking its relative timeline. The handoff plays the episode as a relative stream — the audio-only URL is built with `StartTimeTicks` = the position the screen was locked at, so ExoPlayer's zero is the handoff point — and `background_audio_base` holds the offset that turns one back into a real position. The base was a display-only correction, applied in exactly two places (the lockscreen scrubber and the internal truncation maths) while every other consumer worked in the relative timeline treating the number as absolute. Each crossing threw away exactly `base` seconds, which is why the jump-back distance varied with where the screen was locked and read as random. Three crossings were live: progress reporting to Jellyfin sent the relative position every 30s, so the server was told `real − base` — and since DR-155 now mirrors the server's position back and refreshes on a cache hit, that regressed value returned as the resume point (lock at 40 min, listen to 90, reopen at 50); lockscreen seeks went out absolute and came back relative, against a chunked length-less transcode that cannot honour a seek at all, so a clamped seek landed at stream zero; and media3's own `seekToDefaultPosition`/`seekBack`/`seekForward` bypassed the `ForwardingPlayer` wrapper entirely, reaching the real ExoPlayer — `Util.handlePlayButtonAction` seeking an ended player to the relative zero being the same mechanism as DR-129's truncation bug through a different door. The fix converts once, at the boundary: `JellyTauPlayer`'s position tick adds the base (and shifts the duration with it, since the stream's own length is only what remains) before either `nativeOnPositionUpdate` or the lockscreen sees it, so position updates, progress reports, the frontend and the truncation check all speak the episode's timeline and none needs to know a handoff happened. The base is consequently removed from `claim_stream_resume`, `truncated_stream_resume_position` and `player_exit_background_audio`, where adding it now double-counts, and the lockscreen's `positionOffsetMs` addition goes with it (the field remains, read-only, as the tick's input). Inbound seeks go the other way: `seek_absolute` is the new boundary for every outside seek, re-opening the stream at the requested position via `resume_stream_at` when a handoff is active — which is what `onSeekTo` had claimed for months in a comment describing code that did not exist — and an ordinary seek otherwise. `seekToDefaultPosition` is swallowed rather than forwarded, since Rust already owns what "play after the stream ended" means and the `play()` that follows reaches it. Exit reads the position before clearing either base, or a tick landing in between hands back a relative one

Player

UR-040, UR-005, UR-025

Done (pending device verification)

DR-158

A watched toggle, on the episode row, the season header, the series and movie hero, and the Episode Focus View. Both halves of the backend already existed and neither had a caller: `mark_played` (`POST /PlayedItems`) was reachable only from the sync drain replaying rows the reporter had queued, and `clear_watch_history` (`DELETE /PlayedItems`) only from the destructive "erase this series' history" button — so the sole way to mark something watched was to play it. Jellyfin applies both recursively over a season or series, so the container case needs no client-side fan-out online. Offline it does: `storage_set_watched` writes the item and its descendants (drawn from `items` by `parent_id`/`album_id`/`season_id`/`series_id`, so an uncached id selects nothing and the statement no-ops instead of raising a foreign-key error), because otherwise marking a season watched with no server would tick the season and leave every episode inside it unwatched. It is deliberately separate from `storage_mark_played`, which stays the single-item "this finished playing" path that increments `play_count`. Un-marking clears the resume position as well as the flag, matching the server. `QueuedOp::MarkUnplayed` gives the queue the missing direction — pushing as `clear_watch_history` — so the toggle works offline both ways rather than only one; without it un-marking would have been the half that needed a connection. The button is an everyday toggle, so unlike `ClearHistoryButton` it does not confirm, and it holds an optimistic state because the caller's `watched` prop only catches up after a reload (a season means a round trip, during which the button would otherwise appear to ignore the tap)

UI

UR-073

Done

DR-157

Full-screen video on Android actually goes full screen. `toggleFullscreen` called `document.documentElement.requestFullscreen()` and nothing else, which inside an Android WebView does not touch the Activity window — it expands the element within a viewport that already spans the whole screen, because `enableEdgeToEdge()` is called in `onCreate` and SDK 36 ignores the opt-out. So the control did nothing visible while the status bar and navigation/gesture bar stayed painted over the video, and (unlike DR-112's chrome-clearance work, which is about reserving space for the bars) here the bars should not be there at all. `ImmersiveModeBridge` hides them via `WindowInsetsControllerCompat` with `BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE`, so an edge swipe brings them back transiently over the video instead of resizing the window mid-playback, and the system's own gestures stay reachable. Exposed as the `AndroidImmersive` bridge and posted to the main thread, since `@JavascriptInterface` methods arrive on a WebView binder thread. `requestFullscreen()` is kept for the platforms where it does work, but its rejection is caught rather than allowed to abort the immersive call. Restoring is wired to three paths, not one: leaving fullscreen, Escape (which previously called `document.exitFullscreen()` directly, bypassing the flag and the bars), and `onDestroy` — the bars belong to the Activity, so a player torn down while immersive would strand every screen behind it without them. The `--jt-inset-*` properties need no special handling: hiding the bars fires the decor view's inset listener with zeroes and `WindowInsetsBridge` republishes them

UI

UR-066

Done

DR-143

Flipping the offline downloaded-only gate actually re-queries the listing. The gate (DR-078) is a process-wide flag in Rust consulted only while a query runs, but no library surface re-queried when its inputs changed: `useServerReachabilityReload` fires only on the offline → online transition, and `GenericMediaListPage`, `GenericGenreBrowser` and the favourites page never even called its `checkServerReachability`. So going offline left the full server catalog on screen under a now-closed gate, and toggling "Show all server media" only greyed cards — `MediaCard.isServerOnly` is a pure frontend derivation that updates instantly — without adding or removing a single row. The filter therefore read as "shows everything until I filter, then greys some of it" while the backend gate was correct and simply never exercised. `catalogFilterVersion` is the refetch signal: `pushCatalogVisibility` now awaits `set_show_server_catalog` and bumps the version only after the backend accepts the new flag, since a reload racing the push would re-query under the old gate and undo itself. A failed push clears `lastIncludeCatalog` instead of latching it, so the next identical transition is retried rather than skipped as a no-op and left permanently disagreeing with the backend. `useOfflineFilterReload` subscribes pages to that signal, skipping the value they already loaded under; it is wired into both generic list components and the movies/music/tv/favourites landing pages and the `/library/[id]` detail page

UI

UR-052

Done

DR-135

A download's media type comes from the item, not a default. `download_item` — the path a media card uses to queue an item while offline — never records `media_type`, and the reconnect resolver read that NULL as `'audio'`, so a movie queued from a card had its URL resolved by `get_audio_stream_url`. The file that landed on disk was an audio-only transcode, which is why a "downloaded" film could never play offline no matter how the path or protocol was fixed. The resolver now falls back to the item's own `item_type` (`VIDEO_ITEM_TYPES` in Rust, so the frontend never learns which types are video) and only defaults to audio when the item is not cached locally. An explicit `media_type` on the row still wins

Downloads

UR-071, UR-052

Done

DR-136

Rows already downloaded under the audio default are repaired, not just prevented. They are identifiable after the fact — no `media_type`, but a video item — so on reconnect they are reset to `pending` with their audio URL cleared and re-resolved by DR-135's corrected logic, overwriting the audio file in place. Without this the fix is invisible to anyone who had already queued a film: the row still reads "downloaded" and still fails to play. Rows carrying an explicit `media_type` and genuine audio downloads are left untouched

Downloads

UR-071

Done

DR-137

Local media is served to the player over a loopback HTTP server, not the asset protocol. Tauri's `asset` protocol answers a request carrying no `Range` header by reading the whole file into memory, and only advertises `Accept-Ranges: bytes` from inside its range branch — so the first request never learns ranges exist and a multi-gigabyte body is attempted instead. Chromium abandoned it with `PIPELINE_ERROR_READ` after ~31s, which reached the user as "downloaded video does not play offline". Real HTTP on `127.0.0.1` is chosen over a custom URI scheme deliberately: range support becomes a property of the transport rather than depending on whether a platform's webview forwards `Range` to a custom scheme. No response ever exceeds a 4 MiB chunk and bodies stream from the file handle, so memory is bounded regardless of file size. Because loopback is shared between apps on Android, the server binds `127.0.0.1` only and every URL carries a random per-session token; paths are additionally confined to the app data directory, so a leaked URL cannot read outside it. This is stage 1 of making the server the single media origin — remote passthrough and download-while-watching are deliberately out of scope here

Playback

UR-071

Done

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 — `<video>` failed in the same millisecond as `loadstart`, with `NETWORK_NO_SOURCE` and no server-side log at all, which is why it looked identical to a missing file. A `network-security-config` resource permits cleartext for `127.0.0.1` only and keeps `base-config cleartextTrafficPermitted="false"`, so a remote server must still be HTTPS; this is deliberately not a blanket opt-in. The manifest attribute is ignored once the config is present, so the config is the single authority. `sync-android-sources.sh` also had to learn to copy `res/xml`, which it skipped — the manifest references the resource, so a missed copy fails the resource link rather than degrading quietly

Security

UR-071

Done

DR-093

Traceability coverage gate derives its requirement denominators from `requirements.md` at run time rather than hardcoded literals: `countDefinedRequirements` counts an ID only where it leads a markdown table row (ignoring the "Traces To" column and prose) and deduplicates IDs listed both in the definition tables and in the §3 traceability matrix; `computeCoverage` reports the intersection of traced and defined IDs so an ID traced in code but absent from `requirements.md` is surfaced as `orphaned` instead of inflating the ratio past 100%. UT/IT test identifiers are excluded as a separate taxonomy. CI and `bun run traces:coverage` share this computation and fail on both a sub-threshold and an impossible >100% result

Tooling

-

Done

DR-204

A leveled logging facade for the frontend, replacing raw `console.*` calls. One module owns the log sinks, so a level (error/warn/info/debug) decides at run time what is emitted rather than every call site deciding permanently at authoring time: a release build stays quiet, a developer chasing a playback bug turns the player's debug output on without editing and rebuilding, and nothing that reaches the console is written by a `console.log` nobody can find again. Scoped loggers carry the subsystem in the message, so a filtered console is usable while a player, a download worker and a store are all talking

Tooling

-

Done

DR-205

ESLint + Prettier run as a gate over the frontend, so lint and formatting are decided once by configuration rather than per reviewer. Formatting is not a matter of opinion at review time, and the classes of bug a linter sees (unused bindings, floating promises, accidental globals) should never reach a human reviewer at all. Wired as an npm script so the same command runs locally and in CI, matching how `check:boundary` and the traceability gate already work

Tooling

-

Done

DR-206

The Rust toolchain is pinned in-repo (`rust-toolchain.toml`) and the pin is what both a developer's machine and CI use. Without it, `cargo fmt --check` and `cargo clippy` are run by whatever version each host happens to have, so a formatting or lint result differs between a laptop and the builder image and CI fails on a diff that was clean locally — the failure mode is a red build nobody can reproduce. The builder image carries the pinned toolchain, so pinning is a declaration, not a CI-time install (see the no-toolchain-installs rule)

Tooling

-

Done

DR-207

A pre-commit hook runs the "Before Committing" gates — frontend checks and tests, `cargo fmt`, clippy, the boundary tripwire and the traceability checks — so the gates are enforced at the commit rather than discovered in CI. The gates already exist and are already documented; what is missing is that nothing runs them, which makes compliance a matter of memory. The hook is the mechanism that makes the documented list actually binding

Tooling

-

Done

DR-208

Documentation link integrity is checked mechanically (`scripts/check-doc-links.sh`): every relative markdown link in every tracked `.md` must resolve to a file that exists on disk. This is a real defect class, not hygiene — the generated traceability matrix shipped ~2,800 dead file links because it was written to `docs/` while its hrefs were repo-root-relative, and nothing noticed for months because no check existed and nobody clicks 2,800 links. The check validates paths, deliberately not anchors or external URLs: anchor resolution needs a markdown renderer's slug rules and network checks make the gate flaky, so both are out of scope and stated as such in the script

Tooling

-

Done

DR-209

Library folders are excluded from music browsing server-side, by folder id, replacing a hardcoded frontend filter that dropped anything whose name contained "Podcasts". The name filter was wrong in three separate ways: it encoded a domain classification in the presentation layer, it matched on a title rather than on what an item is (so an album legitimately called "Podcasts" vanished while a podcast folder named anything else did not), and it applied only where someone had remembered to call it, so the same library was in scope on one screen and out of scope on the next. Excluded folder ids are stored as user configuration and applied by the repository layer to every music query — libraries, artists, albums, genres, search and the home rows — so scope is decided in one place and is the same everywhere

Repository

UR-076

Done

DR-210

Thumbnail cache writes are confined to the cache directory. The filename was built from `item_id`, `image_type` and `tag`, but only `tag` was sanitised — and `Path::join` neither folds `..` nor keeps the base when handed an absolute path, so a value arriving verbatim from server JSON decided where a file landed. The tag's existing rule (non-alphanumerics become `_`) now applies to all three parts, and the resolved path is checked with `starts_with(cache_dir)` at the point of use. The database keeps the raw key and the resolved path, so lookups still match and pre-existing rows still resolve. Not exploitable as shipped — server URLs must be HTTPS and Android blocks cleartext, so the id comes from a server the user chose to trust — the value is making the write path consistent with how caller-supplied paths are handled elsewhere

Storage

UR-012

Done

DR-211

Download paths are confined to the download root. `file_path` and `target_dir` reached `PathBuf::join` unchecked from the frontend, and `mark_download_completed` persisted a caller-supplied path later passed to `remove_file`. A correct sanitiser already existed and `download_item_and_start` used it, but `download_item` is itself a command accepting `file_path` raw, so the guard was bypassable rather than absent — the fix moves it inside instead of adding a second one. Sanitising is per path component: whole-string sanitising would rewrite `downloads/x.mp3` to `downloads_x.mp3` and relocate every existing download. Confinement happens after the join, since a join with an absolute second half discards the root

Downloads

UR-011

Done

DR-212

Query and URL construction bind or encode their inputs. Three sites interpolated caller-supplied values directly: the offline `get_items` item-type filter built `IN ('a','b')` by string formatting, `build_get_items_endpoint` wrote `ParentId`/`IncludeItemTypes`/`SortBy`/`SortOrder` into a URL unencoded, and `player_set_volume` accepted NaN and out-of-range floats. Each is a consistency defect rather than a novel one — the same file already did it correctly a few lines away (parameter placeholders in `search`, `urlencoding::encode` for genres, `clamp` in every player backend). List separators stay unencoded and encoding is per element, because Jellyfin splits these parameters on the comma

Repository

UR-007, UR-065

Done

DR-213

Containerised builds hand their artifacts back to the host user. The compose services bind-mount the repo and run as root — their caches live at `/root/.cargo` and `/root/.bun`, so a non-root container user cannot write them — which leaves root-owned files accumulating in the developer's working tree: 11,124 of them when this was found, enough that `cargo clean` and `scripts/clean.sh` failed with EACCES and a plain `cargo build` died part-way, since build scripts compile for the host and land in `target/debug` even during a cross-build. Ownership is restored at the end of each containerised build, reading the intended owner from the checkout so no uid needs plumbing through. Running the containers as the host uid is the tidier fix and remains open; it needs the cache volumes relocated off `/root` first

Tooling

-

Done

DR-214

The app identifies itself correctly everywhere a user or a package manager reads its name. `productName` was the scaffold's lowercase `jellytau`, which is what the Android release build showed under its icon and what the deb/rpm/NSIS bundles carried as their display name — invisible in development because `build.gradle.kts` overrides the label to "JellyTau Debug" for the debug build type, so the install a developer looks at daily was the only correctly-cased one. `mainBinaryName` pins the executable filename so nothing that resolves a path by name has to change. `strings.xml` moves into the canonical android tree, where `sync-android-sources.sh` already copies `res/values/*.xml`, so the fix survives regenerating `gen/`. Bundle metadata (publisher, copyright, category, descriptions, licence) was entirely absent, which is why the packages shipped with no maintainer or description — the hand-written Arch PKGBUILD and `.desktop` had all of it, so only the generated packaging was wrong

Packaging

-

Done

DR-215

Frontend test coverage is a ratcheted CI gate rather than a number nobody looks at. `test:coverage` had been configured since the suite was created and was silently broken: `@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 — so every invocation died on a missing `BaseCoverageProvider` export and no coverage figure had been produced in months. Fixing the range is half the requirement; the other half is that a measured figure that gates nothing decays the same way an unrun script does. Thresholds sit a few points under the measured result (statements 54.6, branches 48.7, functions 49.6, lines 55.1 when this landed) and only ever move up, matching `MIN_THRESHOLD` in the traceability gate and the eslint `--max-warnings` ratchet. The absolute numbers are held down by `.svelte` components, which this project deliberately does not test directly — the pattern is to extract the logic to a plain module and test that

Tooling

-

Done

DR-216

Dependencies are gated on known vulnerabilities and on licence compatibility, and the build graph is pinned to what is actually shipped. The project had no scanning of any kind: nothing checked the ~500-crate Rust graph or the JS packages against an advisory feed, and nothing checked that everything redistributed inside an MIT-licensed bundle permits it. The first run found eight vulnerabilities and one unsoundness — `bytes`, four in `rustls-webpki`, `time`, two in `quick-xml`, `rand` — every one closed by a `cargo update` nobody had reason to run. `cargo deny` (src-tauri/deny.toml) now runs in CI over advisories, licences, bans and sources. Two structural fixes matter as much as the gate: the graph is scoped to the targets actually shipped, so an advisory against an Apple-only path is correctly absent rather than ignored by ID; and the one git dependency (`libmpv`) is pinned by revision instead of by branch, since a branch means any `cargo update` silently substitutes new upstream code in the one dependency that is unsigned and links a C library into the player. Licence findings are recorded rather than waved through — `libmpv`/`libmpv-sys` are LGPL-2.1, which the app satisfies by dynamic linking, and that carries obligations (keep the linkage dynamic; ship libmpv's licence text with any bundle carrying the .so)

Tooling

-

Done

DR-217

In-app update, desktop only, over a manifest we control. `tauri-plugin-updater` and `tauri-plugin-process` are compiled for everything except Android/iOS — spelled as a target-triple cfg rather than `cfg(desktop)`, which Cargo does not evaluate in a `[target.'cfg(…)']` table and which therefore drops the dependency silently, surfacing much later as "Permission updater:default not found". The release workflow signs updater artifacts with a minisign key held in Gitea secrets and publishes `latest.json` to a dedicated `updater` branch, read over Gitea's raw-file URL: this instance serves `/releases/download/<tag>/<asset>` but returns 404 for `/releases/latest/download/<asset>`, so there is no stable latest-release URL to point at, and the docs branch is force-pushed by publish-docs.yml so it cannot host the manifest either. Bundle targets gain `appimage`, which the release notes had been advertising for months while `tauri.conf.json` never built it — the artifact step globbed for `*.AppImage`, found nothing, and said nothing

Tooling

UR-077

Done

DR-218

Persistent, redacted logging and a diagnostics export. `tauri-plugin-log` replaces the `env_logger` stdout-only init, giving a rotating 5 MB file, a webview target in dev, and — the single largest gain — logcat on Android, where `env_logger`'s stdout went nowhere. Redaction runs in the log formatter, not at export: a credential in a file on the device is already a disclosure, so stripping it on the way out would be too late; the exporter redacts a second time to cover files written by older builds. `api_key`/`X-Emby-Token`/`Authorization`/`"AccessToken"`/`Token="…"` all reduce to `[REDACTED]` while host, item ids and filenames are deliberately kept — a bundle scrubbed of those is one nobody can debug from. The server URL is reduced to scheme and host, dropping any embedded `user:pass@`. The panic hook chains to the previous hook rather than replacing it, because `utils/lock.rs` installs a silencing hook around tests that provoke poisoned locks on purpose. The chosen level persists to disk and is re-applied at startup, since reproducing a bug usually means restarting into it. The frontend facade keeps its untouched `console.*` pass-through (DR-204) and additionally forwards a stringified copy at info and above, so one file holds both halves of the app in order — which is what makes a race between them legible after the fact

Tooling

UR-078

Done

DR-219

Release notes are the reviewed CHANGELOG entry, not a generated draft. Every release from v0.0.1 to v0.9.1 published the same ~1,050 bytes of generic install instructions whose "What's New" section said "See CHANGELOG.md" — a link that does not resolve from a release page. Thirty-five releases, byte-identical, telling a reader nothing about what changed. The workflow now publishes the `## <version>` section of CHANGELOG.md and fails the release if that section is absent, since notes that say nothing are worse than a build that waits for two sentences. `release:notes` is printed into the job log as a drafting aid but is deliberately not published: CLAUDE.md calls its output "a reviewed draft, not a final changelog", and publishing it unreviewed proved why — a range containing a repo-wide formatting sweep resolved to nearly the entire requirement matrix and produced notes claiming one release had added the whole application. The script now skips cosmetic commits (`chore(format)`, `chore(deps)`, `style`) when deriving a range's files, and says how many it skipped rather than silently reporting a smaller set

Tooling

-

Done

DR-220

A release ships only its own artifacts. `src-tauri/target/*/release/bundle/` is not versioned, cargo never cleans it, and the CI runner reuses the target directory — so the copy step's `bundle/**/*-setup.exe` glob collected every installer ever built there. Every release from v0.1.0 to v0.8.2 shipped its predecessors': sixteen Windows installers on v0.8.2, thirteen of them stale, and a download list on v0.5.0 reaching back to 0.1.0. It went unnoticed for eight months because there was nothing to notice — the upload loop reported success, the files were real, and the page looked busy rather than wrong. It stopped only when an unrelated cache change wiped the runner's target dir, leaving the defect dormant rather than fixed. Both desktop builds now clear the bundle directory first, so a stale file cannot exist to be copied — filtering the copy by version would have hidden it instead. `scripts/check-release-artifacts.sh` is the backstop for the next route nobody predicts: it runs before the SBOM, the checksums and the upload, and refuses to publish when any artifact's embedded version disagrees with the tag

Tooling

-

Done

DR-221

The release path is exercised before a tag exists. Nothing in `build-and-test.yml` runs `tauri build` — only a tag does — so a whole class of breakage was invisible until release day, and two instances of it were sitting on master at once. Tauri refuses to build when a plugin's Rust crate and npm package differ by minor version, which the updater and logging work had introduced (`tauri-plugin-log 2.8.0` against `@tauri-apps/plugin-log 2.9.0`) while `cargo check`, clippy, the tests and `svelte-check` all passed; both sides are now pinned exactly rather than by caret, since a caret is what let them separate, and CI runs `tauri info` to compare them without building. The AppImage target had never once been built: linuxdeploy carries a `strip` too old to parse the `.relr.dyn` section modern toolchains emit, so bundling failed on every library — and Ubuntu 23.10+ links with `-z pack-relative-relocs` by default, so the builder image fails the same way a modern Arch host does. `NO_STRIP=true` is linuxdeploy's documented escape hatch; the cost is a larger, unstripped bundle. Both were found by building the target locally before tagging rather than by publishing a release that could not build

Tooling

-

Done

DR-222

Build tooling matches the package manager the project declares. `scripts/build-android.sh` ran `npm install` on its clean-build path — in a bun project, where `packageManager` says bun and `bun.lock` is the committed lockfile. npm ignores that lockfile, re-resolves the whole tree from package.json, and writes a `package-lock.json` that `.gitignore` then hides. That is not a style preference: the JS halves of the Tauri plugins are pinned exactly against Cargo.lock because the CLI refuses to build when a plugin's crate and package differ by minor version, and a silent re-resolve is precisely how they drift apart. It survived because clean builds are rare — the shape shared by nearly every defect found preparing v0.10.0, where the code running on every commit was healthy and the code running on a release, a tag or a clean build had no guard at all. `scripts/check-tooling.sh` fails on any npm/yarn/pnpm invocation or foreign lockfile

Tooling

-

Done

DR-223

The Android JavaVM and Application are published into `ndk_context` by this crate, not by a transitive dependency. Seven call sites (five in credentials.rs, two in lib.rs) read that process-global to reach JNI, and nothing here ever set it — `tao` did, three levels below anything this project names in Cargo.toml. tao 0.35.3 moved those pointers into a private struct and stopped publishing them, so the Tauri 2.11 upgrade made the first credential read abort the process on every launch: `PANIC ... android context was not initialized`. Our code had not changed; an undocumented side effect of the windowing layer had gone. The invariant is now owned here rather than assumed: `JNI_OnLoad` captures the JavaVM as the shared library loads, and the Application is resolved lazily via `ActivityThread.currentApplication()` and pinned as a global reference for the process lifetime — the Application rather than the Activity, since that is what `SecureStorage.initialize()` immediately reduces its argument to. Failure degrades to the encrypted-file credential path and is logged, rather than aborting. Found only by installing on a device: nothing in CI runs the app

Security

UR-012

Done

DR-224

Backgrounding the app obeys the background-audio toggle on every renderer. The toggle (UR-040) was built for the WebView `<video>` path, where losing visibility kills the decode: it chose between handing off to a native audio stream and letting playback stop. Native video then became the default renderer (DR-188), and on that path playback runs through ExoPlayer inside a `MediaSessionService` — a foreground media service whose purpose is to keep playing while the app is hidden. Nothing paused it and nothing in the codebase paused on background, so locking the screen kept the audio going whether or not the toggle was on: the toggle governed a handoff that no longer had a gap to bridge, and users got background playback they never asked for. The decision now lives in Rust (`player/background_policy.rs`) and both renderers obey it: a video with the toggle off pauses, with the toggle on hands off to audio, music is never paused by backgrounding, and picture-in-picture keeps playing because the window is still on screen (UR-041). It takes no renderer parameter on purpose — the split between the two paths is what produced the defect

Player

UR-040

Done

DR-225

`StreamSelection` replaces the bare URL returned for playback: URL, `Transport` (hls / progressive / localFile), `PlaybackKind` (directPlay / directStream / transcode), the negotiated `Rendition`, the ladder this source can offer, and a `needs_transcoding` flag derived in Rust so "which kinds count as transcoding" is answered once. Both enums are serde-tagged (`{"type":"hls"}`) so the frontend matches a discriminant rather than comparing text. The field that mattered most is `transport`: `VideoPlayer.svelte` chose its loader with `url.includes(".m3u8")` in two places, a domain fact reconstructed in the presentation layer — the same class of error as leaking item-type taxonomy, and one that fails silently in both directions (a progressive file served from a path containing the substring gets an HLS loader; a playlist served from one without it does not). The paths that never negotiate — a downloaded file, a live channel — get the same shape from Rust (`media_local_selection`, `LiveStreamInfo.transport`) rather than having the page assemble one, so there is no second place where a transport is decided

Playback

UR-079

Done

DR-226

The bandwidth ceiling is two-level: a durable device default (Settings, persisted, restored at startup) and a per-playback override the in-player picker sets. The picker's own documentation had called it a "this film, this connection" control since it was written, but it was implemented by writing the process-wide default — so dropping one awkward film to 2 Mbps silently capped every video played afterwards for the rest of the process, while the Settings screen still displayed the old value and nothing in the UI admitted the change. The override is cleared whenever playback moves to a new item, which is what keeps it from surviving into an autoplayed next episode where nobody would reopen the picker. `effective_streaming_quality()` is the single resolution point; every URL builder and the `PlaybackInfo` negotiation go through it, because a negotiation that authorises a direct play the URL builder then constrains (or the reverse) leaks the cap

Playback

UR-074, UR-079

Done

DR-227

The quality picker is filled from what this media source can offer, not from the fixed eight-rung enum. Rust marks each rung `exceeds_source` when its ceiling is at or above the source's own bitrate — such a rung produces the same bytes as `Original`, so offering it is another way to spell one choice — and the frontend simply does not draw those. `Original` is never marked (it is the source) and a source whose bitrate the server does not report (the sampled library has `avi` files with none) marks nothing redundant, keeping every rung offered, which is the safe direction. The picker also shows what the server is actually doing with the stream, which only became knowable once `PlaybackKind` existed. Labels and detail lines come from Rust beside the numbers they describe, so a relabelled rung cannot drift out of step with what it does

UI

UR-070, UR-079

Done

DR-228

Direct play and direct stream are negotiated rather than assumed away. `get_video_stream_url` always built an HLS transcode URL, so every video play burned server CPU even when the file would have played untouched. The decision now comes from `PlaybackInfo` under the device profile and the ceiling in force, with two client-side overrides applied on top because the server's answer is right about the file and wrong about what this app will do with it: undecodable audio (Jellyfin 10.11.5 honours a DirectPlayProfile's container and video codec but ignores its audio codec, so it offers direct play for an E-AC-3 track the webview renders in silence) and a viewer-pinned audio track the source file does not default to. Measured against the development server over a 400-item sample: 85% direct play on the Android profile, 7% on the Linux one — the library is ~80% hevc and WebKitGTK can only claim h264, so the Linux figure is a property of the renderer, not of this code, and is what `linux-native-video-spike.md` exists to change. A direct stream is a remux and is deliberately not counted as transcoding

Playback

UR-079

Done

DR-229

Mid-playback re-negotiation on throughput was scoped and dropped on measurement. The premise — that hls.js gives this app real adaptive bitrate and mpv would lose it — does not hold: a master playlist from the development server carries exactly one `EXT-X-STREAM-INF`, because Jellyfin builds it from the single rendition the request asked for rather than publishing a ladder. There is no adaptation to preserve, so "adapt mid-stream" collapses into "pick well at open", which is what DR-225 and DR-226 already are. Recorded rather than deleted because the conclusion is a measurement, not an opinion, and a server that does publish a ladder would change it — the DR-224 re-negotiation path is the hook that work would build on

Playback

UR-079

Won't Do

DR-230

Every player backend consumes the same selection, proving the contract is player-agnostic rather than HTML5-shaped. The queue item carries the negotiated `transport`, so `player_seek_video` picks its seek strategy from the backend's own decision instead of the last `stream_url.contains(".m3u8")` in the codebase; items queued by a path that never negotiated (audio tracks, direct URLs) carry `None` and fall back to `needs_transcoding`, which is exact rather than a guess because every transcode this app requests is HLS (DR-140). The webview adapter's bridge carries the whole selection rather than a URL, so the component's HLS effect reads a tag instead of searching a string, and the background-audio handoff states the transport it is moving to (progressive mp3 out, HLS back) rather than leaving it to be inferred

Playback

UR-003, UR-004, UR-079

Done

DR-231

An mpv video backend that composites beneath the transparent webview, the desktop counterpart of the Android TextureView arrangement. mpv renders through its render API into an FBO the toolkit binds (`vo=libmpv` + `mpv_render_context_create` with `MPV_RENDER_PARAM_OPENGL_FBO`), rather than by embedding a foreign window — which is what the 2024 "not possible on Wayland at all" conclusion was about and why it does not apply. On Linux that is a `GtkOverlay` with a `GtkGLArea` as main child and Tauri's own webview reparented as the overlay child; the mpv half is shared and only the surface differs per platform. Webview transparency alone suffices — no window-level transparency is used or needed

Playback

UR-080

Proposed

DR-232

The mpv render context's lifetime is bound to the GL context it draws into: created on `realize`, freed on `unrealize`, on the same thread, with the update callback unregistered before the free so a callback cannot land on a freed context. This is DR-184 on Android restated — a surface outliving its player — and it is a requirement in its own right rather than a fix for a specific crash. The spike observed one SIGSEGV in a decoder thread that three targeted soaks failed to reproduce; what is not in doubt is that the spike never called `mpv_render_context_free` and never tore down on `unrealize`, so nothing defended against the GL context being recreated underneath. Removing the likeliest cause is worth doing whether or not it was the cause

Playback

UR-080

Proposed

DR-233

Frame pacing goes through mpv's update callback, with `mpv_render_context_report_swap` after each render. Recorded as a requirement because the failure mode misleads: driving the widget's frame clock every tick without reporting the swap leaves mpv with nothing to time against, which looks fine in a window and judders at fullscreen — reading as a compositing or GPU limit and being neither

Playback

UR-080

Proposed

DR-234

The device profile is derived from the renderer that will decode the stream, not from a compile-time platform constant. `video_codecs` was `#[cfg(target_os)]`, which is correct only while a build has one video renderer; once mpv and the webview element coexist it must be runtime state. This is the change that converts the measured 7% desktop direct-play rate toward the 85% the Android profile achieves on the same library, because the two differ by nothing except which component decodes. It looks like configuration and is not — it is the input that decides whether the server re-encodes, and getting it wrong fails silently, a claimed codec the renderer cannot decode being a black picture or silence (DR-148, and DR-227's audio override). The webview's narrower audio set stops applying to the video path once mpv decodes it, while the multichannel bound still does, since a 5.1 track direct-played into a two-channel sink is silence or inaudible dialogue

Repository

UR-080, UR-070

In Progress

DR-235

The webview video path is deleted, not merely bypassed. Staged, because a path cannot be removed while a shipped platform still needs it: Linux moves to mpv first, Windows follows, and only then do `hls.js`, `html5Adapter.ts`, `videoLoaderFor` and the `<video>` element go. The staging is the point — a Linux-only version would leave the fork alive permanently, taking video from three renderers to four and giving every seek strategy, track switch and lifecycle bug one more place to be got right. Android keeps ExoPlayer and keeps the webview as its documented opt-out; the background-audio `<audio>` path is untouched. With no HTML5 fallback left, a failed mpv init emits `backend-init-failed` and surfaces a real error rather than silently degrading to the transcode this work exists to stop paying for

Playback

UR-080

Proposed

DR-236

Hardware-decode policy is decided from what mpv reports it selected (`hwdec-current`), never from what it was asked for. The spike established that hardware decode works through the render API at all — the load-bearing result, since it means direct play is not bought with software decoding — but also that `auto` reached for the discrete GPU in copy-back mode on a hybrid Intel+NVIDIA laptop, the least efficient hardware path, and that `vaapi` fell back to software silently because the libva driver was absent. So zero-copy VA-API on the integrated GPU is preferred where the driver is present, `auto` is a fallback rather than the default, and a missing driver is detected and logged rather than mistaken for a compositing limit

Playback

UR-080

Proposed

DR-237

Windows reaches the same mpv path, reusing everything except the surface. The surface is genuinely different code — a native child window beneath a transparent WebView2, not GTK — but the render context, lifetime discipline, frame pacing, device profile and hwdec policy are shared, which is why none of them may be guarded on `cfg!(target_os = "linux")`. The cost is mostly build, not video: `libmpv` is currently a Linux-only dependency while Windows is cross-compiled from Linux via `x86_64-pc-windows-msvc` + `cargo-xwin`, so a Windows libmpv must reach that cross-build and its DLL must ship in the NSIS bundle, carrying the LGPL obligations DR-216 already records — dynamic linkage, licence text shipped alongside. Windows gains a native audio decoder as a side effect, which is what the long-blocked Windows audio work wants and cannot otherwise have

Playback

UR-080

Proposed

DR-238

A transcoded seek re-negotiates the stream on every renderer, not just the webview. Jellyfin produces a transcode from `StartTimeTicks`, so where a seek lands is a property of the request rather than of the stream in hand. `determine_video_seek_strategy` treated `is_hls` as a proxy for "seekable in place", which held only because hls.js was always the HLS renderer — it seeks within the VOD playlist it is handed and lets the server catch up. mpv's HLS demuxer cannot make the server transcode from a new offset, so with native video on, every transcoded seek became a backend seek that silently did nothing and presented as "resume does not work". The rule is now written on `needs_transcoding` with hls.js as the stated exception; all four webview cells are unchanged

Player

UR-040

Done

DR-239

Properties the mpv event loop handles are registered with `observe_property`. libmpv delivers `PropertyChange` only for observed properties, so a `match` arm for an unobserved one is unreachable code that reads as implemented — the handler is right there. `pause` was handled and never observed, so `StateChanged` was never emitted on pause or resume and the play/pause control never moved. It stayed invisible while Linux video played in the webview, because the `<video>` element's own DOM events drove that control; native video made the UI depend on the event that never came

Player

UR-005

Done

DR-240

Fullscreen moves whatever actually owns the pixels. `requestFullscreen()` fullscreens the document, which sufficed while every renderer lived inside it — the HTML5 `<video>` element is part of the document, so WebKit scaled it and the OS window's real size never mattered. A native surface is drawn behind the webview at window size, so a document-only fullscreen expands the page and leaves the picture where it was; on WebKitGTK the result is a maximised window with decorations still holding a strip of the screen, which reads as "fullscreen is broken" rather than as a windowing problem. Android needed the same rule for the system bars (DR-157); this is its desktop half

Player

UR-066

Done

DR-241

A seek issued before MPV has a file to seek in is honoured, not dropped. `loadfile` returns as soon as the command is queued, so `time-pos` — a live property of the loaded file — does not resolve yet and setting it fails. The two callers that always hit that window are the ones a viewer notices: resume, and a transcoded seek, both of which re-open the stream and then ask for a position. The failed seek was discarded and the stream played from zero, which reads as "resume is broken" and "I cannot skip". The position is now held and applied by the `FileLoaded` handler; a seek that lands normally clears any deferred one, so the newer intent wins

Player

UR-040, UR-005

Done

DR-242

The player contract expresses intent, not device operations. `MediaPlayer::open` carries the start position, so no caller sequences load-then-seek and none can race an engine's asynchronous load; `seek` states a destination and leaves in-place-vs-re-open to the engine, which is the only layer that knows its own transport; `snapshot` is one coherent read; and `Phase::Opening` names the window a seek used to be lost in. Replaces `PlayerBackend`, which abstracted a device and required each of the three engines to re-derive the same rules

Player

UR-081

In Progress

DR-243

Every engine passes one conformance suite, and a `FakePlayer` implements the contract deterministically. The suite is written before the second engine so it cannot encode whatever the first happened to do, and it drives readiness through a harness rather than sleeping. `FakePlayer` models the one behaviour that matters — opening is not instantaneous — so the load/seek race can be expressed on purpose, and lets the controller, queue, autoplay and session logic be tested with no engine at all

Player

UR-081

In Progress

DR-244

`MpvPlayer` implements `MediaPlayer` over libmpv, applying the start position at load time via mpv's own `start` option rather than seeking after an asynchronous `loadfile`, and holding a seek that arrives during `Opening` until the file loads. A standalone `player-conformance` binary runs the suite against it with audio and video routed to null, so a wrapper is verifiable without building or launching the app

Player

UR-081, UR-040

Done

DR-245

`PlayerController` holds a `MediaPlayer` rather than a `PlayerBackend`, and every engine reaches it through that one contract — `LegacyPlayer` carries the not-yet-ported ones across unchanged, so the port swaps a seam rather than four implementations. Loading an item is now a single `open` carrying its start position, and the controller maps the engine's `Phase` back onto `PlayerState` using the queue, so nothing outside changes. `LegacyPlayer` drives the old `PlayerBackend` through the `MediaPlayer` contract, so engines not yet ported keep working during the migration and the two designs can be compared on one engine and one file. It reproduces the old load-then-play-then-seek sequence faithfully rather than a fixed-up version, because making it pass would defeat its purpose

Player

UR-081

Done

DR-246

The seek strategy turns on an ability the engine declares, not on the container the stream arrives in. `Capabilities::seeks_transcoded_in_place` is stated by each engine — true for hls.js, which seeks within the VOD playlist it was handed; false for mpv, which cannot make the server transcode from a new offset — and the command asks the engine currently rendering instead of inferring from `is_hls` and `use_html5`. The item's transport is no longer read at the seek site at all. Re-negotiating a stream needs the repository, which sits above the engine, so the engine states the capability and the caller acts on it rather than the engine owning the whole decision

Player

UR-040, UR-081

Done

DR-247

ExoPlayer can be told where to start. `JellyTauPlayer.load(url, mediaId)` had no way to express a start position, so every caller loaded and then seeked; the position is now handed to ExoPlayer with the media item via `setMediaItem(item, startPositionMs)`, and the two-argument form delegates to it. Running the conformance cases on a device also settled which half of DR-241 was engine-specific: ExoPlayer already queues a seek issued before `prepare()` completes, so it never had the lost-seek defect mpv did — only the missing vocabulary for a start position

Player

UR-081, UR-005

Done

DR-250

Stopping means nothing is playing, from any renderer — not "whatever we believe owns playback has been asked to stop". A background-audio handoff swaps which renderer that is, and the swap is bookkeeping that can be mid-flight: `exit_background_audio` marks the webview element the player again the moment it is called, while the element has not reloaded. The teardown's stop was gated on flags describing what the component started, so after a handoff it described a player that was no longer making sound and the stop was skipped — the audio stream kept running and the mini player adopted it, which is why a movie reappeared as an audio track. The stop is now unconditional (it is idempotent) and clears the handoff base and flag, so a later position read cannot be interpreted against a handoff that no longer exists

Player

UR-040, UR-005

Done

DR-251

A duration of zero is treated as "the engine does not know yet", and falls back to the runtime the item already carries. ExoPlayer reports `C.TIME_UNSET` until it resolves one and `JellyTauPlayer.getDuration()` maps that to `0.0`, so the engine answered `Some(0.0)` rather than `None` — which satisfied every "unknown duration" fallback and left the seek bar with no scale. It presented as scrubbing being broken rather than as a duration that never arrived, and the catalog had the runtime the whole time

Player

UR-005, UR-040

Done

DR-252

Seconds reported by an engine are converted to a `Duration` only when finite and positive. `Duration::from_secs_f64` panics on a negative or non-finite value and no engine promises otherwise: ExoPlayer reports `C.TIME_UNSET` (`Long::MIN_VALUE`, about -9.2e15) for a stream whose length it does not know, which is every background-audio handoff — `/Audio/{id}/universal` is a chunked, length-less transcode. Held as a float that junk was harmless; converted to a `Duration` by the `MediaPlayer` adapter it became a panic that killed the backend mid-handoff and left a black screen with no controls. One guard on the contract, used by every engine crossing into it

Player

UR-005

Done

DR-253

A deferred seek is discarded when the file it was issued against stops being the one loading. `seek` holds a position while MPV has nothing loaded and the `FileLoaded` handler applies it (DR-241), but neither `load` nor `stop` cleared it — so scrubbing near the end of a transcoded item, which re-opens the stream, and then skipping to the next item before the reload completed applied the old position to the new item. It started wherever the previous one had been scrubbed to, silently

Player

UR-040, UR-005

Done

DR-254

Advancing to the next episode drops a per-playback quality override. The override is process-wide and describes one playback: a viewer who drops to 720p for a struggling episode has said nothing about the next. Every advance the frontend drives clears it via `player_play_item`; the background audio-only advance loads the next episode in Rust and skipped all three clearing sites, so every later episode stayed capped with nothing in the UI saying why

Repository

UR-074

Done

DR-255

One helper answers "what URL should an engine open". `playback_url` was gated to Android because only ExoPlayer needed it, and that gate is why a byte-identical copy was later added for the cross-platform open path — the original is invisible in a Linux build, so nothing warned. Two matches over `MediaSource` meant a new variant could be handled in one and forgotten in the other

Player

UR-081

Done

DR-256

The video control bar opens at most one menu at a time, and opens it where it can be read. Audio track, quality and subtitles each owned a `show…` boolean that no other toggle cleared, so a second menu opened stacked over the first — two panels in the same corner, the newer one covering rows of the older, both still taking clicks. A single `openMenu` value replaces them, which makes "one menu" a property of the state rather than something every handler must remember; the desktop volume popup joins the same group through `VolumeControl`'s optional controlled-open props. Placement was the second half of the same defect: every panel was `absolute right-0` against its own icon button, and those icons sit mid-row, so a 220 px panel hung off the left edge of a portrait phone and half the tracks could not be read or tapped. One shared panel now anchors to the control ROW's right edge, clamped to `min(20rem, 100vw − 2rem)` wide and `min(300px, 45vh)` tall, with a full-screen dismiss layer inside the controls subtree so a tap elsewhere closes it without reaching the container's tap gestures (DR-098). The icon row itself wraps instead of overflowing — in portrait the transport controls plus nine icons are wider than the screen, which put fullscreen and close past the edge

UI

UR-020, UR-021, UR-066, UR-074

Done

DR-257

A container's children are ordered by what the container is, decided in Rust. The frontend pinned `SortBy=SortName` onto every drill-down, so a Jellypod podcast — a Jellyfin channel folder whose plugin returns episodes newest-first and prefixes played ones with "[Played]" — listed alphabetically, which both discarded the release order and clumped every heard episode at the top. `ChannelFolderItem` with `is_folder` now maps to its own `MediaKind::ChannelFolder` rather than collapsing into `Folder`, which is what makes the two distinguishable at all; `default_listing_sort` maps that kind to `PremiereDate` descending and every other container to `SortName` ascending, and a caller that names no container still gets no `SortBy`, so paths relying on the server's own order (a playlist's stored order) keep it. An explicit sort always wins. The offline leg of the cache/server race applies the same order, so the cached list does not flash in name order before the server's arrives. The store now names the container and never a sort field — the ordering rule is domain vocabulary, the same division as `SearchScope`

Repository

UR-007

Done

DR-258

An audio-track change is honoured by re-opening the stream when the stream cannot carry the track. Jellyfin builds a transcode around one `AudioStreamIndex`, so the alternate tracks are not in it — but the native path only ever called `setAudioTrack(n)`, which indexes ExoPlayer's audio track groups. On Android that is the common case, since any source whose default audio codec the device cannot decode is transcoded: ExoPlayer held one audio track while the menu listed every track in the file, so every selection warned `Invalid audio track index` and was dropped, leaving the default track playing with nothing in the UI saying so. `determine_audio_track_switch_strategy` now decides by whether the stream in front of the engine carries the track at all — a direct play still selects in place, a transcode is re-negotiated at the chosen index and resumed. Where it resumes is the player's answer, not the UI's: the native path has no `<video>` element to read, so it sends no position, and defaulting that to zero re-opened the film at the beginning

Player

UR-021, UR-005

Done

DR-259

Subtitle URLs address Jellyfin's route, `/Videos/{item}/{source}/Subtitles/{index}/Stream.{format}`. The `Stream.` segment was missing, which matches no route and 404s, so every sideloaded subtitle failed to fetch. Since media3 1.5 a sideloaded text track only becomes a track group once its file is parsed, so 42 failed fetches left ExoPlayer with no text tracks at all and subtitle selection warned `available: 0` and did nothing. The URL tests that existed asserted the shape of a mock helper duplicating the format string rather than the URL the app requests, which is why a route error survived from the first release

Repository

UR-020

Done

DR-260

Subtitle cues are drawn. ExoPlayer decodes subtitles and delivers them to a listener; it draws none of them itself, and native video here is a bare `TextureView` the WebView composites over rather than a `PlayerView`, so nothing was holding the cues and a selected track rendered nowhere. The gap was invisible while every subtitle URL 404ed (DR-259) — with no text track to select there was never a cue to drop, so fixing the URL is what exposed it. `media3-ui`'s `SubtitleView` now takes each `CueGroup` from `onCues` and is attached at index 1 of the content view: above the video, still below the WebView, so cues sit over the picture and under the app's own controls. It is fitted to the letterboxed video rect rather than the screen, so cues stay inside the picture and follow it on rotation, and is torn down with the surface it belongs to. Verified on a device

Player

UR-020, UR-003

Done

DR-261

Subtitles are drawn over the picture, not on a black bar across it. `SubtitleView.setUserDefaultStyle()` reads Android's captioning preferences and falls back to media3's `DEFAULT` when the viewer has set none — and that default is white on opaque black, so every line arrived in a box as wide as the text. The viewer's own style is kept and only the two colours that paint a box, background and window, are cleared: someone who has configured captions in accessibility settings has said something specific about colour, typeface and edges, and replacing all of it to remove a background would answer a question they did not ask. A style specifying no edge gets a black outline, because without a box the text must supply its own contrast or it is unreadable over a bright scene; a style that already names an edge keeps it

Player

UR-020

Done (pending device verification)

DR-262

The A-Z jump strip is bounded by the scroller it lives in, not by the viewport minus a guess at the bottom bars. `AlphabetScrollBar` sized itself as `window.innerHeight` minus a hardcoded `bottomGap` — 5rem, 7rem or 11rem, picked by platform and whether the mini player was showing — which dates from when the mini player and bottom nav were `position: fixed` overlays. They have been in-flow flex siblings below the scroller since BottomUi (DR-009), so the scroller's own bottom edge is the top of the mini player and can simply be measured. The guess was short on every device with a navigation or gesture bar, because `--safe-bottom` is padded inside BottomUi (DR-112) and no guess knew about it: the strip overran the scrollport by ~45px with the nav alone, ~18px with the mini player and ~50px in remote mode, burying one to three letters where they could not be tapped. The ancestor is resolved by computed `overflow-y` rather than `closest("main")`, since the root shell scrolls in a plain `<div>` and a miss silently fell back to the viewport — reinstating the bug on any route outside `/library`. Observing the scroller for resize is also what makes the mini player appearing re-measure, so the component no longer subscribes to player or platform stores at all

UI

UR-007

Done

DR-263

Autoplay crosses the season boundary. `fetch_next_episode_for_item` listed the episodes of the current season and stopped dead at the last one, so the end of a season produced `AutoplayDecision::Stop`. On the Android background-audio handoff (UR-040) that is felt as playback simply pausing mid-binge with the screen locked and no UI to un-pause it — the same end that mid-season advances through in the backend. The lookup now walks the series' seasons, sorted client-side by index number because the offline repository ignores `sort_by`, and takes the first episode of the next season that has any, skipping empty ones. Specials are never rolled into: Jellyfin numbers them 0 so they sort ahead of season 1, but a server that leaves the index unset sorts them last, exactly where the walk would land. The lookup sits below the sleep-timer gate in `on_playback_ended`, so a timer set to end-of-episode or a remaining-episode count still stops at the boundary rather than being carried past it

Player

UR-023, UR-040

Done

DR-264

The episode a viewer just finished is no longer offered as the one they are up to. Nothing records completion locally: the stop report writes a position through `storage_update_playback_progress` (which never sets `is_played`), and the cache mirror carried the server's flag not at all — so on a cache hit every episode read back as unwatched. Leaving the player with Back reloads the series page within a second of the stop report, inside the window where Jellyfin's Next Up still names the episode that just ended, and `pick_current_episode` handed it straight back: the season view kept the yellow ring and the "Up next" badge on the episode the viewer had just watched, and scrolled to it. Two halves. (a) `is_finished` — the played flag or a position at or past `MAX_PROGRESS_FRACTION` of the runtime, the same 95% threshold that already disqualifies an episode from counting as in-progress — replaces the bare `is_played` in the furthest-watched scan and the first-unwatched fallback, and screens the Next Up candidate: the server is one stop-report behind for a moment, the local position is not. (b) `OfflineRepository::mirror_user_data` carries `is_played` alongside the favourite flag and the position, under the same `pending_sync = 0` conflict rule, so watched state survives a cache write instead of being dropped — that flag was previously written by nothing but an explicit local toggle

Repository

UR-062

Done

DR-265

The player's position variable keeps advancing behind a picture-in-picture window. `VideoPlayer` tracks the absolute position in its own `currentTime` rather than reading `videoElement.currentTime` at the point of use — transcoded HLS resets the element to 0 on every segment rebuild, so only the running total is meaningful — and that variable had exactly one writer while playing: a `requestAnimationFrame` loop. RAF is driven by the document being rendered, and an Android activity behind a PiP window is paused, so the loop stops while the element plays on. The `timeupdate` handler that would have covered the gap was written as a fallback "for when RAF isn't running" and gated itself on `!isPlaying`, switching itself off at precisely the moment it was the only source left. `currentTime` therefore froze at the instant PiP was entered, and every consumer froze with it: the seek bar, the ten-second progress reports, the position mirrored into Rust through `html5Adapter`, and — the reported symptom — the background-audio handoff, which resumed the audio-only stream at the PiP-entry position while the picture carried on where it really was. The gate is now `shouldApplyTimeUpdate` and turns only on the things that genuinely own the position instead: an in-flight seek, a seek-bar drag, and an element whose `readyState` is below `HAVE_CURRENT_DATA` (which reads 0 and would rewind). Both writers producing the same derived value costs nothing — the element is the authority either way

Player

UR-004, UR-041

Done

DR-266

PiP and the background-audio handoff can no longer be armed at once, and neither can a single stale boolean end the picture. They are alternatives — one keeps the video on screen, the other throws it away — but exclusivity was enforced from one side only: arming the toggle called `setAutoEnterEnabled(false)`, while the PiP button stayed ungated and still worked, so pressing it left both live. What then decided between them was `isInPictureInPictureMode`, sampled once inside `MainActivity.onStop()` and passed to `background_action`. That sample is not reliable: there are orderings — the keyguard dismissing the window, the window being stashed, OEM variance in when `onPictureInPictureModeChanged(false)` lands relative to `onStop` — where the activity is stopped with a PiP window still on screen and the flag reads false. Backgrounding then meant "the app is gone" and handed a video the user was watching in the window off to audio-only. Two halves. (a) `enteringPictureInPicture` disarms background audio, because pressing PiP is an unambiguous request to keep the picture; both directions now go through one `BackgroundBehaviour` pair rather than two ad-hoc call sites. (b) `inPictureInPicture` accepts either witness — the native sample or the frontend's own latch over `jellytau-pip-entered`/`jellytau-pip-exited`. The latch cannot report a window that has closed, because both events reach the WebView through the same message queue in dispatch order, so a genuine exit is always known before the background signal that follows it. The decision itself stays in Rust; the frontend only supplies a fact it can establish more reliably than the activity can

Player

UR-040, UR-041

Done

DR-267

`profiles_*` commands expose the accounts already stored in `users` — list, add, remove, and a startup target that decides between resuming the last account and showing the picker. `storage_get_users` and `storage_set_active_user` have existed and gone uncalled since the schema was written; what was missing was never the storage but the decision of who may switch to what, which is domain logic and stays in Rust. Adding an account authenticates against the current server and takes no URL, which is how the same-server constraint is enforced rather than by omitting a form field

Auth

UR-082

Proposed

DR-268

A profile's PIN is an Argon2id hash in `user_pins` with the failure count and lockout deadline beside it, both read and written only in Rust. The PIN deliberately does not encrypt the access token: wrapping it would leave a locked profile unable to resume its own downloads, drain its own `sync_queue` or poll its own sessions until someone typed the code, which on a device that reboots nightly costs more than it defends against a four-digit secret. The gate is against a member of the household, and the security doc says so rather than implying at-rest protection it does not provide

Auth

UR-083

Proposed

DR-269 | A forgotten PIN falls through to the ordinary password login against the same server, after which a new PIN can be set. There is no reset token, no recovery secret and no administrator approval path — the account's own password is already the authority, and inventing a second one would be a weaker credential guarding the same thing | Auth | UR-084 | Proposed

DR-270

Switching profiles is its own operation, not a logout. `auth_logout` calls Jellyfin's logout endpoint and invalidates the token server-side, which is exactly the behaviour a switch must not have. The switch runs as a state machine over a `ProfileSession` — stop playback and drop the queue, park the outgoing user's sync queue, stop the poller, destroy the repository, flip `is_active`, rebuild — so the teardown ordering can be unit-tested with no player and no server. The queue cannot outlive its owner: a straggler reporting after the flip would attribute one account's viewing to another, which is silent and unrecoverable

Auth

UR-082

Proposed

DR-271

Cache visibility is a byproduct of the write path rather than a maintained index. `save_to_cache` is the single choke point through which every cached item passes and it already holds the `user_id` it fetched for, so it stamps `user_item_visibility` in the same transaction; reads join through it. The stamp cannot disagree with the server because it is the record of what the server returned, and there is nothing to reconcile. It does not shrink when permissions tighten — that drift is bounded by re-deriving from `/UserViews` on unlock while online, and is stale-permissive offline by design

Repository

UR-082

Proposed

DR-272 | `offline_is_available` answers per user instead of per item. It counted completed `downloads` rows for an `item_id` with no user predicate, so every profile on the device saw every other profile's downloads as its own — the same class of leak as the shared metadata cache, in the one place where the server is not present to filter | Storage | UR-082 | Proposed
DR-273 | Each profile derives its own `DeviceId` as `uuid5(device_uuid, user_id)` rather than sharing the installation's. Jellyfin identifies a session by device, so a shared id makes a family look like one device that keeps changing user — playback history, the Devices dashboard and the remote-control target all collapse together | Auth | UR-082 | Proposed
DR-274 | Startup shows the picker only when the last-used profile has a PIN, or when more than one profile exists and the setting asks for it; otherwise it resumes exactly as before. The feature is invisible to a single-account install, which is what makes it safe to ship without a migration anyone has to think about | Auth | UR-082 | Proposed

DR-275

Idle re-lock separates "the UI is locked" from "who is the active profile", so audio keeps playing and keeps reporting as the account that started it while the screen is locked. Lockscreen transport controls keep working untouched, because nothing on a lockscreen browses or starts new content — the locked UI refuses only what reaches past the current queue. The timer starts when playback stops rather than when the UI goes quiet, and unlocking to a different profile stops playback. It lives in Rust beside the player state machine: it needs authoritative playback state, and a frontend timer dies with the WebView on Android

Player

UR-083

Proposed

DR-276

The picker and PIN pad render an opaque `unlock_method` and an `UnlockOutcome` union the backend returns; the frontend never compares a PIN, counts an attempt, or infers that an account without a PIN is a child's. "Child account" is not modelled at all — a child profile is simply one with no PIN — so no role taxonomy is invented on either side of a boundary that has leaked taxonomy before

Frontend

UR-082, UR-083

Proposed

DR-277

A library listing is scoped to that library. The cached-browse query matched a library parent with an `EXISTS` that never referenced the item — it asked only whether a library with the requested id existed — so the clause was true for every cached row on the server. Music, Movies and TV concealed it because their landing pages pass `include_item_types`, which narrowed the result; the generic library page passes none, so opening Books, Photos, Collections or a mixed library served whatever happened to be cached. The stored `library_id` now decides wherever the cache kept one, because that is the server's own answer and the only thing able to scope a library whose type has no mapping or none at all; the `collection_type` ↔ `item_type` taxonomy is the fallback for rows written before it was stored, and a library with neither matches nothing and falls through to the server. The taxonomy itself is now a single macro shared with the downloaded listing, which had the identical defect fixed in isolation (DR-167) while this path kept it

Repository

UR-007

Done

DR-198

The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for data, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant

Security

UR-012, UR-071

Done

## 3. Traceability Matrix

### User Requirements to Software Requirements

User Req | Integration Requirements | Development Requirements
--- | --- | ---
UR-001 | IR-001, IR-002 | -
UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014
UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010, DR-182, DR-183, DR-184, DR-185, DR-186, DR-187, DR-188, DR-190, DR-191, DR-192, DR-193, DR-194, DR-195, DR-196
UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171, DR-176, DR-177, DR-181, DR-182, DR-183, DR-185, DR-188, DR-203, DR-265
UR-005 | - | DR-001, DR-005, DR-009, DR-178, DR-179, DR-186, DR-193, DR-195
UR-006 | IR-005, IR-006, IR-007, IR-008 | DR-200, DR-201
UR-007 | IR-010 | DR-007, DR-008, DR-016, DR-257, DR-262, DR-277
UR-008 | IR-010 | DR-007, DR-011
UR-009 | IR-009, IR-010, IR-011 | -
UR-010 | IR-012, IR-021 | DR-037, DR-059
UR-011 | IR-013 | DR-003, DR-015, DR-018
UR-012 | IR-009, IR-014 | DR-198
UR-013 | IR-013 | DR-017
UR-014 | IR-010 | DR-014, DR-019
UR-015 | - | DR-005, DR-020
UR-016 | - | -
UR-017 | - | DR-014, DR-021
UR-018 | IR-013 | DR-015, DR-018, DR-173
UR-019 | IR-015 | DR-022
UR-020 | IR-016, IR-018 | DR-023, DR-176
UR-021 | IR-016, IR-019 | DR-024
UR-022 | IR-017 | DR-025
UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049, DR-263
UR-024 | IR-010 | DR-027
UR-025 | IR-015 | DR-028, DR-131, DR-132, DR-178, DR-179
UR-026 | - | DR-029, DR-048, DR-050
UR-027 | IR-020 | DR-030
UR-028 | - | DR-031
UR-029 | - | DR-032
UR-030 | IR-010 | DR-033
UR-031 | - | DR-034
UR-032 | - | DR-035
UR-033 | - | DR-036
UR-034 | IR-010, IR-024 | DR-038, DR-039
UR-035 | IR-022, IR-023 | DR-040, DR-044
UR-036 | IR-022, IR-023 | DR-041
UR-037 | IR-010 | DR-042
UR-038 | IR-010 | DR-043
UR-039 | - | DR-045, DR-046
UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159, DR-178, DR-179, DR-180, DR-183, DR-190, DR-196, DR-201, DR-203, DR-263, DR-266
UR-041 | IR-026 | DR-053, DR-160, DR-161, DR-172, DR-182, DR-183, DR-184, DR-185, DR-188, DR-265, DR-266
UR-042 | IR-009, IR-014 | DR-054
UR-043 | IR-027 | DR-055
UR-044 | - | DR-056
UR-045 | - | DR-057
UR-046 | IR-028 | DR-058
UR-047 | IR-013 | DR-060
UR-048 | - | DR-061, DR-062, DR-142
UR-049 | IR-010 | DR-063, DR-064, DR-065, DR-147
UR-050 | - | DR-066, DR-067
UR-051 | - | DR-068, DR-069, DR-070
UR-052 | IR-027 | DR-078, DR-079, DR-080, DR-143
UR-053 | IR-029 | DR-074
UR-054 | - | DR-075, DR-076, DR-077, DR-147
UR-055 | - | DR-081, DR-082, DR-083, DR-084, DR-167, DR-168, DR-169, DR-173
UR-056 | - | DR-085
UR-057 | - | DR-086
UR-058 | - | DR-087, DR-142
UR-060 | - | DR-090, DR-091, DR-111
UR-061 | - | DR-092
UR-062 | - | DR-101, DR-102, DR-103, DR-104, DR-107
UR-063 | - | DR-105
UR-064 | - | DR-106
UR-065 | IR-030 | DR-108, DR-109, DR-110, DR-111
UR-066 | IR-031 | DR-112, DR-157, DR-187, DR-194
UR-067 | - | DR-115, DR-116, DR-117, DR-118
UR-068 | - | DR-119
UR-069 | - | DR-113, DR-114, DR-120
UR-070 | - | DR-121, DR-122
UR-071 | IR-032 | DR-123, DR-124, DR-125, DR-126, DR-127, DR-128, DR-133, DR-134, DR-135, DR-136, DR-137, DR-138, DR-170, DR-171, DR-180, DR-198, DR-199
UR-072 | - | DR-156
UR-073 | - | DR-158
UR-074 | - | DR-162, DR-177, DR-181
UR-075 | - | DR-174, DR-175
UR-076 | - | DR-209
UR-077 | - | DR-217
UR-078 | - | DR-218
UR-079 | - | DR-225, DR-226, DR-227, DR-228, DR-229, DR-230
UR-080 | IR-033 | DR-231, DR-232, DR-233, DR-234, DR-235, DR-236, DR-237
UR-082 | IR-034 | DR-267, DR-270, DR-271, DR-272, DR-273, DR-274, DR-276
UR-083 | - | DR-268, DR-275, DR-276
UR-084 | - | DR-269

## 4. Test Traceability

### Unit Tests to Software Requirements

Test ID | Test Description | Traces To | Status
--- | --- | --- | ---
UT-001 | Player state transitions | DR-001 | Pending
UT-002 | MediaItem source URL resolution | DR-002, DR-003 | Pending
UT-003 | Queue next/previous navigation | DR-005 | Pending
UT-004 | Queue shuffle order generation | DR-005 | Pending
UT-005 | Queue repeat mode behavior | DR-005 | Pending
UT-006 | Jellyfin authentication flow | IR-009 | Pending
UT-007 | Jellyfin library items parsing | IR-010 | Pending
UT-008 | Repository pattern online/offline switching | DR-013 | Pending
UT-009 | Offline mutation queue persistence | DR-014 | Pending
UT-010 | Download queue management | DR-015 | Done
UT-011 | Resume position storage and retrieval | DR-022 | Pending
UT-012 | Sleep timer countdown logic | DR-029 | Pending
UT-013 | Playback progress reporting throttling | DR-028 | Pending
UT-014 | Database open and in-memory mode | IR-013, DR-012 | Done
UT-015 | Database migrations run successfully | IR-013, DR-012 | Done
UT-016 | All database tables created | IR-013, DR-012 | Done
UT-017 | FTS5 search table created | IR-013, DR-012 | Done
UT-018 | Server CRUD operations | IR-013, DR-012 | Done
UT-019 | User CRUD operations | IR-013, DR-012 | Done
UT-020 | Cascade delete server removes users | IR-013, DR-012 | Done
UT-021 | Item insert and FTS search | IR-013, DR-012 | Done
UT-022 | User data playback position storage | IR-013, DR-012, DR-022 | Done
UT-023 | Sync queue operations | IR-013, DR-014 | Done
UT-024 | Downloads table operations | IR-013, DR-015 | Done
UT-025 | Migrations are idempotent | IR-013, DR-012 | Done
UT-026 | NullBackend volume default value | DR-004 | Done
UT-027 | NullBackend set volume | DR-004 | Done
UT-028 | NullBackend volume clamping (high/low) | DR-004 | Done
UT-029 | NullBackend volume boundary values | DR-004 | Done
UT-030 | PlayerController volume default | DR-004, DR-009 | Done
UT-031 | PlayerController set volume | DR-004, DR-009 | Done
UT-032 | PlayerController muted default | DR-004, DR-009 | Done
UT-033 | PlayerController volume delegates to backend | DR-004, DR-009 | Done
UT-034 | Download event serialization roundtrip | DR-015 | Done
UT-035 | Download event completed serialization | DR-015 | Done
UT-036 | Download event failed serialization | DR-015 | Done
UT-037 | Download worker exponential backoff | DR-015 | Done
UT-038 | Download worker error retryable check | DR-015 | Done
UT-039 | Download manager creation | DR-015 | Done
UT-040 | Download manager set max concurrent | DR-015 | Done
UT-041 | Download info serialization | DR-015 | Done
UT-042 | Download command filename sanitization | DR-015, DR-018 | Done
UT-043 | Download command filename extension preservation | DR-015, DR-018 | Done
UT-044 | Offline item serialization | DR-017 | Done
UT-045 | Smart cache default config | DR-015 | Done
UT-046 | Smart cache album affinity tracking | DR-015 | Done
UT-047 | Smart cache queue precache config | DR-015 | Done
UT-048 | Smart cache storage limit check | DR-015 | Done
UT-049 | Playlist create (offline) | DR-019, JA-019 | Done
UT-050 | Playlist delete (offline) | DR-019, JA-019 | Done
UT-051 | Playlist rename (offline) | DR-019, JA-019 | Done
UT-052 | Playlist get items (offline) | DR-019, JA-019 | Done
UT-053 | Playlist add items (offline) | DR-019, JA-020 | Done
UT-054 | Playlist remove items (offline) | DR-019, JA-020 | Done
UT-055 | Playlist reorder items (offline) | DR-019, JA-020 | Done
UT-056 | Playlist entry serialization | DR-019, JA-019 | Done
UT-057 | Playlist Tauri command param naming (camelCase) | DR-019, JA-019, JA-020 | Done
UT-058 | Playlist repository client methods | DR-019, JA-019, JA-020 | Done
UT-059 | Audio-only stream URL builder for a video item (selected audio-stream index) | JA-032, DR-052 | Pending
UT-060 | Background-audio handoff state machine (background→audio, foreground→video; no dual audio) | DR-052 | Pending
UT-061 | Background-audio Tauri command param naming (camelCase) | DR-052 | Pending
UT-062 | `setBackgroundAudioEnabled` reports whether the native bridge was actually reached (missing bridge, stale proxy, throwing method) so a dead bridge cannot look armed | UR-040, IR-025, DR-051 | Done
UT-067 | Offline `get_items` gates the synced-catalog UNION on the catalog-browse flag (downloads only when off, full catalog when on) | DR-078 | Done
UT-068 | Catalog visibility resolves to `serverReachable || showServerCatalog`, and is pushed to the backend on every change of either input | DR-078, DR-079 | Done
UT-069 | `isConnected` follows backend reachability alone: false when the server is unreachable on a live link, true for a reachable server while `navigator.onLine` is false | DR-079 | Done
UT-070 | Hybrid `get_items` returns an empty offline result as-is when the catalog-browse gate is off, without querying the server | DR-080 | Done
UT-066 | WiFi-only download gate: cellular and metered WiFi blocked, unmetered WiFi/Ethernet allowed, unknown/none fail closed, desktop default ungated; plus the frontend network reporter (transport reporting, change subscription, teardown, fail-open queries) | DR-074 | Done
UT-071 | Byte-size formatter: zero/negative/non-finite → "0 B"; decimal unit thresholds; 2–3 significant-figure banding; trailing-zero trimming; largest-unit cap | DR-085 | Done
UT-072 | Downloaded-only browse returns a downloaded leaf and its container, filtered to the requested album parent; a non-downloaded sibling is omitted | DR-082, DR-083 | Done
UT-073 | An empty downloaded-only browse is authoritative — no rows, no error — regardless of the catalog-browse flag | DR-082 | Done
UT-074 | Only libraries with downloaded content are listed; an empty one is omitted | DR-082 | Done
UT-075 | Disk usage reports a leaf's own size, a container's summed descendants, and reconciles the device total with the sum of leaves | DR-085 | Done
UT-076 | Downloaded library browse lists album containers, not their individual tracks; drilling into the album returns the tracks | DR-082, DR-083 | Done
UT-077 | Downloaded TV library browse lists the series, not seasons/episodes; drilling returns the season then the episode | DR-082, DR-083 | Done
UT-078 | A downloaded leaf with no cached container (e.g. a movie) still surfaces at the library level | DR-082, DR-083 | Done
UT-079 | Each EQ preset returns a 10-band gain curve within range; Flat is all zeros; Bass Boost lifts lows and leaves highs flat | DR-030 | Done
UT-080 | `with_equalizer_normalised` clamps out-of-range gains and forces the band vector to exactly 10 entries (pad short, truncate long) | DR-030 | Done
UT-081 | Old persisted AudioSettings JSON without EQ fields loads as disabled + flat | DR-030 | Done
UT-082 | EQ fields serialize as camelCase (`equalizerEnabled`/`equalizerBands`) and round-trip | DR-030 | Done
UT-083 | EQ filter entries are empty when disabled or when the curve is flat (clears the `af` filter) | IR-020 | Done
UT-084 | Enabled EQ builds one peaking `equalizer` per non-zero band at the right frequency and gain inside a single `lavfi` chain | IR-020 | Done
UT-085 | A first tap resolves to `togglePlayPause` immediately — no deferral and no timer | DR-092, DR-098 | Done
UT-086 | A second tap inside the window seeks (+30 s right half, −10 s left half) with the matching feedback side and re-toggles play/pause, so the two toggles cancel and the play state is unchanged by a double tap | DR-092, DR-098 | Done
UT-087 | A tap after the window, and the tap following a consumed pair, are each fresh first taps that toggle (there is no third-tap case); repeated double taps keep seeking; `cancel()` makes the next tap a first tap so an interpreted swipe cannot seek | DR-092, DR-098 | Done
UT-088 | `resolveSeekTarget` applies the delta to the reported position, clamps into `[0, duration - END_SEEK_MARGIN_SECONDS]`, chains off an in-flight pending target so rapid skips accumulate, and ignores that target once the player reports past it | DR-092, DR-095 | Done
UT-089 | A touch drag on the video seek bar seeks to the dragged position, never toggles play/pause, and never alters brightness — the container gesture layer stays out of a control drag entirely | DR-098, DR-099 | Done
UT-090 | The seek bar commits its seek on `touchend` even when the engine never fires `change`, and commits exactly once when both signals arrive | DR-099 | Done
UT-091 | Transport intents (play/pause/toggle) reach the backend even while a video adapter is registered, and never call the adapter's own `play`/`pause`/`toggle` — the webview must not decide play-vs-pause from the DOM | DR-097 | Done
UT-092 | `shouldReuseActivePlayback` reuses backend playback for an already-loaded audio track but never for video, and never when an explicit start position or a next-episode restart was requested | DR-100 | Done
UT-093 | `resolvePlayerSurface` returns `video` only with a stream URL, `pending` for video whose stream URL is still missing (never `audio`), and `audio` for audio content | DR-100 | Done
UT-094 | `parseNativeInsets` accepts the bridge's JSON or a decoded object, and coerces missing/negative/non-finite edges to 0 rather than emitting `NaNpx` (which would invalidate the whole padding declaration) | DR-112 | Done
UT-095 | `safeAreaCssVars`/`applySafeAreaInsets` emit px-suffixed `jt-inset` custom properties for all four edges | DR-112 | Done
UT-096 | `readNativeInsets` returns null with no bridge and survives a stale WebView proxy (missing or throwing `get`) instead of throwing out of layout init | IR-031, DR-112 | Done
UT-097 | `initSafeArea` primes the document on start, re-applies on `jellytau-insets-changed` (rotation, nav-mode switch), unsubscribes on teardown, and writes nothing without a bridge so `env()` still wins on iOS/desktop | IR-031, DR-112 | Done
UT-098 | `shellReservesBottomInset` gives the bottom inset to BottomUi wherever one renders and to the app shell only on routes without one, so the gesture bar is never ignored nor double-padded | DR-112 | Done
UT-099 | A Jellyfin item payload carrying `UserData.IsFavorite` maps to `MediaItem.user_data.is_favorite` | DR-113, JA-034 | Done
UT-100 | `OnlineRepository::get_favorites` builds `Filters=IsFavorite` + `Recursive=true` + the scope's `IncludeItemTypes`, and omits the type filter entirely for `SearchScope::All` | DR-115, JA-033 | Done
UT-101 | `OfflineRepository::get_favorites` returns only `is_favorite = 1` rows, honours the scope type filter, and stays downloads-only when the catalog-browse gate is off | DR-115 | Done
UT-102 | The `save_to_cache` favourite mirror does not overwrite a row with `pending_sync = 1` | DR-114 | Done
UT-103 | The reconnect drain pushes pending favourites, clears `pending_sync`, and leaves failed rows pending | DR-120 | Done
UT-104 | `get_items` with `favorites_only` filters online (endpoint) and offline (SQL) | DR-116 | Done
UT-105 | `favorites` store precedence: override beats `userData.isFavorite` beats `false` | DR-119 | Done
UT-106 | Un-favouriting removes an item from a favourites listing view | DR-117, DR-119 | Done
UT-107 | The hybrid background refresh emits `favorites-changed` only for ids whose favourite state actually flipped | DR-120 | Done
UT-109 | Search covers synced-but-not-downloaded items when catalog browse is on, and stays downloads-only when off | DR-108 | Done
UT-110 | Search item-type filter is bound, not interpolated: a quote-bearing type neither errors nor widens results | DR-108 | Done
UT-111 | FTS prefix queries quote each token, so apostrophes/hyphens/slashes are data; empty or punctuation-only input returns no rows rather than erroring | DR-108 | Done
UT-112 | Repeated catalog passes leave one `items_fts` entry per item, not one per pass | DR-110 | Done
UT-113 | The stale-catalog sweep removes vanished synced rows, keeps downloaded ones, keeps uncrawled types, and stays scoped to one server | DR-110 | Done
UT-114 | Cached people are reachable from unscoped search and excluded from scoped search | DR-111 | Done
UT-115 | Re-index staleness policy: never-indexed and unparseable timestamps are due, fresh ones are not, future ones are not | DR-109 | Done
UT-116 | `resolve_local_media_path` returns a completed download's file, and `None` for an in-progress download, a row whose file has been deleted, or an unknown item | DR-123 | Done
UT-118 | `resolveVideoSource` prefers a downloaded file, never marks a local file as needing transcoding, and falls back to streaming for a blank path | DR-123 | Done
UT-119 | The audio-only handoff picks a downloaded file over the audio-only stream URL, preserving the Jellyfin id for progress sync | DR-128 | Done
UT-120 | Expiry reclaim takes only expired temporary entries: derived from `completed_at`+TTL, honouring an `expires_at` override, never a user download, and disabled by a zero TTL | DR-127 | Done
UT-108 | LRU eviction reclaims only `'auto'` downloads and never a user's own, even when the user's is the oldest | DR-126 | Done

UT-117

A background audio-only stream cut short resumes where it died instead of ending the episode; a real end still advances; the absolute position is compared against the runtime; retries at a stuck position give up. A recoverable error resumes music and video too, with growing backoff, leaving the rest of the queue intact and the seekable stream's URL untouched; local and DirectUrl sources are excluded

DR-129

Done

UT-124 | `downloadedFilePath` leaves a completed download's absolute path alone (POSIX and Windows) and only roots one that is still relative | DR-133 | Done
UT-125 | A NULL `media_type` resolves from the item type — Movie and Episode as video, a track as audio — an uncached item still defaults to audio, and an explicit `media_type` overrides the item | DR-135 | Done
UT-126 | Requeueing takes only video rows downloaded under the audio default, clearing their URL, and leaves correctly-typed video rows and real audio downloads alone | DR-136 | Done

UT-127

The media server bounds and confines every response: a range-less request yields one chunk rather than the whole file, no range exceeds the chunk cap, explicit/open-ended/suffix ranges resolve correctly, a range past the end is unsatisfiable rather than clamped, a malformed header falls back to the first chunk, path traversal and unrelated absolute paths are refused, a wrong or absent token is rejected, and content type comes from the extension then the magic bytes

DR-137

Done

UT-121 | An EOF reads as the last observed timestamp, not zero: live readings win while the file is loaded, a not-yet-established duration is not recorded as a real zero, a seek updates the position before the next poll, and loading a new file clears the previous one's | DR-130 | Done
UT-122 | The sync-queue drain pushes queued playback reports oldest-first, defers failures for the next reconnect, abandons a row after `MAX_SYNC_ATTEMPTS`, ignores other users' rows, and parses both payload dialects | DR-131 | Done
UT-123 | Pending-sync rows describe themselves: every queueable operation has a label, an unknown one still renders, the item title falls back to its id, and rows list oldest-first | DR-132 | Done
UT-130 | Video and background-audio stream URLs omit `AudioStreamIndex` when no track was chosen, and carry the exact index when one was | DR-140 | Done
UT-131 | The Episode Focus View hero offers a download control | DR-142 | Done
UT-132 | The series name links to the series and the `SxEy` badge to that season's anchor | DR-142 | Done
UT-133 | Cast renders below the "More Episodes" strip, never above it | DR-062, DR-142 | Done
UT-134 | The episode strip is hidden when the episode has no siblings | DR-142 | Done
UT-135 | An episode with no `seriesId` still renders the Focus View, with title, Play and download | DR-142 | Done
UT-136 | `episodeRedirectTarget` sends a bare episode page into its series' Focus View, and returns null with no series | DR-142 | Done
UT-137 | Going offline with the toggle off pushes the closed gate and bumps `catalogFilterVersion` | DR-143 | Done
UT-138 | The version bumps only after `set_show_server_catalog` resolves, never before | DR-143 | Done
UT-139 | A failed visibility push is retried on the next identical transition rather than latched | DR-143 | Done
UT-140 | `useOfflineFilterReload` skips the value a page already loaded under and reloads on each later change | DR-143 | Done
UT-141 | The advertised channel cap: an unknown or zero reading falls back to stereo, a real route keeps its channels, an absurd driver reading is capped at 7.1, and mono is taken at its word | DR-141 | Done
UT-148 | Forcing a transcode from the client: an undecodable default track forces one, a decodable track does not, the default track decides rather than the first, the first decides when nothing is marked default, and neither an audio-less source nor an unnamed codec is second-guessed | DR-149 | Done
UT-149 | `createAdapter` returns the native adapter only when Rust reports native AND `experimentalNativeVideo` is on; the flag off forces HTML5 even when Rust says native, and the flag on never promotes a platform Rust reported as HTML5 | DR-150 | Done
UT-150 | `set-version.sh` stamps all four manifests without touching dependency versions, and the Android versionCode is monotonic across an upgrade sequence, clears the 1000 floor, and survives a prerelease suffix | DR-153 | Done
UT-151 | An unreportable stop lands in the queue and is pushed by the existing drain; re-queueing the same item supersedes the earlier position rather than adding a row, distinct items keep their own positions, and an abandoned row is not revived by a later report | DR-154 | Done
UT-152 | Caching a server result mirrors its watch position locally — including for an item carrying a position but no favourite flag — without inventing a row for an item the server reported no user data for, and without pulling a still-unsynced local position backwards | DR-155 | Done
UT-162 | Each downloaded library lists only its own media: the music library shows the album and neither the film nor the series, the movie library only the film, the TV library only the series | DR-163 | Done
UT-163 | `partial_path` appends rather than replacing the extension, so it matches what the cleanup paths delete, keeps two sources for one title apart, and still produces a sidecar for an extension-less target | DR-165 | Done

UT-170

`queue_album_tracks` queues a row for every track of the album — including tracks the cache holds without an `album_id` and tracks it has never seen at all — links each one to its album so offline browsing can find it, returns the row ids in track order, and is idempotent: re-queuing fills the gaps without duplicating rows or resetting a completed track. `cached_album_tracks` (the offline fallback) finds tracks by either album link and does not sweep in another album's

DR-173

Done

UT-171 | `resolve_pending_download_urls` restricted to a set of row ids resolves only those rows and leaves other pending rows untouched, and an empty id set resolves nothing rather than sweeping everything | DR-173 | Done
UT-172 | `album_file_names` gives every track of an album its own file: a title repeated within the album (deluxe edition, two discs) is disambiguated by track number and item id instead of the second download overwriting the first, an unambiguous title keeps its own name, and path separators in a title are sanitised so a track cannot escape the album directory | DR-173 | Done
UT-164 | `resume_offset` appends only when the server answered `206`; a `200` after a Range request restarts the file, because that body is the whole stream | DR-166 | Done
UT-165 | A registered download starts unflagged, `signal` sets the flag its worker reads, signalling an unregistered id reports not-in-flight, `clear` forgets it, and re-registering drops a previous stop so a resumed download does not halt instantly | DR-164 | Done

UT-166

`original` quality re-encodes audio the webview cannot decode (E-AC-3/AC-3/DTS/TrueHD) to AAC without capping bitrate or resolution, keeps the `Static=true` direct copy for audio that plays here (AAC/MP3/Opus/Vorbis/FLAC) and for an unknown codec, leaves the explicit quality presets untouched, and picks the served track by the same default-or-first rule the streaming verdict uses

DR-171

Done

UT-155 | A seek during a background-audio handoff re-opens the stream at the requested absolute position (`StartTimeTicks`) and rebases the handoff to it, while a seek outside a handoff stays an ordinary seek and invents no base | DR-159 | Done
UT-154 | `mark_unplayed` parses to `QueuedOp::MarkUnplayed` and is rejected without an item id, and a queued un-mark drains to the server as `clear_watch_history` | DR-158 | Done
UT-156 | A capped step reaches the transcode URL as all four of its parts (total ceiling, the video/audio split summing to the cap, and a `MaxHeight`), the uncapped default keeps the historical 20/18 Mbps allowance and constrains no resolution, and the background-audio handoff takes the lower of the cap and its own 384 kbps | DR-162 | Done
UT-157 | The quality ladder is internally consistent — video + audio equals the cap at every step, audio never consumes the budget, only `Original` is uncapped — descends in bitrate, resolution and audio share together, and round-trips through the serde token it is persisted as | DR-162 | Done
UT-158 | Justified rows fill the container width exactly and never overflow it, every tile in a row shares one height, and each tile's width follows its own aspect ratio — a 16:9 tile coming out more than twice the width of a 2:3 tile at the same height | DR-174 | Done

UT-159

The awkward cases of the packing: a short last row is left at the target height rather than stretched across the container, a last row that would overflow is brought down, an extreme ratio is clamped instead of taking a row to itself, a missing or nonsensical ratio falls back to square instead of collapsing the tile, an unmeasured container renders nothing rather than 1px tiles, and every tile is placed exactly once in order

DR-174

Done

UT-160 | The default row height suits its container: it grows with the width, stays inside its bounds, and at phone width still fits two 16:9 tiles side by side | DR-174 | Done
UT-161 | A collection type maps to its favourites scope (`movies`/`tvshows`/`music`), every other kind — Live TV, channels, box sets, books, unknown — maps to none rather than to `All`, and a constructed library carries the scope across the wire as `favoritesScope`, omitted entirely when it has none | DR-175 | Done
UT-167 | The mosaic's composition: the cross-library favourites entry leads, each library is followed by its own category tile pointing at that category's tab, a category shared by two libraries still yields one tile, a library kind favourites do not carve up yields none, a scope the page offers no tab for is ignored, and every tile is uniquely keyed | DR-174, DR-175 | Done

UT-168

Subtitles are negotiated as sidecars, never burned in: the requested `SubtitleStreamIndex` is the explicit "none" sentinel (`-1`) rather than omitted, every text format we can render (`srt`/`subrip`/`ass`/`ssa`/`vtt`) is advertised as `External`, and the burn-in verdict is by format — text never forces it, image formats (PGSSUB, dvdsub) always do, case-insensitively. The same sentinel rides the stream URL itself, so a stream re-opened without a fresh negotiation cannot inherit a subtitle. And the verdict reaches the picker: a subtitle stream carries `supportsExternalDelivery` — set only for subtitles, `false` for a bitmap format and for one the server left unnamed — which drops the tracks the app could never draw from the menu, the `<track>` children and the native play request alike, without even fetching their URLs, while a stream carrying no verdict at all is still offered

DR-176

Done

UT-173 | Every video stream URL carries a `PlaySessionId`, each open mints a fresh one, and the open reports the session it superseded so that job can be stopped | DR-177 | Done
UT-174 | A fatal HLS network error is read against the absolute position: mid-film — including after a quality switch, where the seek offset carries the whole resume position — it is retried rather than reported as the end of the stream, the last tenth of a known runtime is treated as the end, an unknown runtime retries, and retries stop once the budget is spent | DR-177 | Done
UT-175 | A stream reload that never becomes playable is reported as a failure instead of resolving as success, so the caller can revert its selection rather than leave the UI claiming a stream that is not playing | DR-177 | Done
UT-176 | A handoff's position is floored at its base: with no tick yet landed the exit position is the point the screen was locked at rather than 0, and once ticks are flowing (the base already applied natively) it is not added twice | DR-178 | Done
UT-177 | Webview-rendered media's reported position and duration are the controller's, and are dropped the moment that element stops being the player — on teardown, and when a handoff takes over | DR-178 | Done
UT-178 | A stop report at position 0 is withheld rather than sent (it would clear the resume point), while a real position is still reported from either rendering path — the element's on the webview path, the backend's on the native one | DR-179 | Done
UT-179 | An audio-only episode that ends naturally is reported stopped at its runtime, so Jellyfin marks it played; a truncated stream, which is about to be re-opened, reports nothing | DR-179 | Done
UT-180 | Position ticks report progress to the server, throttled to one report per item per window rather than one per tick | DR-179 | Done
UT-181 | The handoff plan matches its source: a downloaded file takes no base and a seek, a stream takes the base and no seek, and a handoff at 0:00 takes neither; a downloaded handoff's absolute seek stays an ordinary seek instead of a stream rebuild | DR-180 | Done
UT-153 | Scroll handling per navigation kind: a forward move always lands at the top even when the previous page was scrolled and even when the target was visited before, Back restores that route's own saved offset (and the top when it has none), offsets are kept per route rather than shared, a repeated Back still restores, and the initial load leaves the container alone | DR-156 | Done
UT-142 | The audio codecs offered for video direct play: a Dolby device's real `MediaCodecList` output drops `ac3`/`eac3`, AMR and raw PCM are dropped too, a fully-supported list is passed through untouched, a list with nothing decodable still claims `aac`, and stray spacing or casing does not decide whether the user gets sound | DR-148 | Done
UT-143 | Subtitle URLs resolve to plain strings before they reach the markup (never a Promise), unresolvable tracks are dropped, a stale selection collapses to "Off", and a server-default track is never auto-selected | UR-020, DR-023 | Done
UT-144 | VideoPlayer actually renders `<track kind="subtitles">` children carrying `data-stream-index`, with no `default` attribute and no async `getSubtitleUrl()` bound to `src` | UR-020, DR-023 | Done
UT-145 | The frontend's subtitle payload survives the IPC hop: a camelCase `PlayItemRequest` carrying `subtitles` deserializes, `create_media_item` lands them on `MediaItem.subtitles` in the order sent, and a request without the field still defaults to empty | UR-020, IR-016 | Done
UT-146 | The subtitle JSON serialized across the JNI boundary uses the keys `JellyTauPlayer.load()` reads — `url`, `language`, `label` and `mime_type`, never `mimeType` | UR-020, IR-016, JA-008 | Done
UT-147 | The native subtitle payload and the track-selection index come from the same resolved list: the wire shape keeps `mime_type` and stream order, `playerPlayItem` actually sends it, and the index is a position in the sent list (so a track whose URL failed to resolve cannot shift the others) rather than the menu's row number | UR-020, IR-016 | Done
UT-182 | An HLS video URL never carries `StartTimeTicks` — with a position supplied or not — while the master playlist, codec, media source and chosen audio track still ride on it | DR-181 | Done
UT-183 | A reloaded stream is resumed by seeking the element to the absolute position with the transcode offset cleared to zero — never by carrying the position as an offset base, which since DR-181 would display the position while playing the item from its start — and a reload to 0:00 waits for no seek | DR-181 | Done
UT-184 | The native reveal rule fires on `state === "playing"` and on a position tick carrying a position or a duration, and on nothing else — not `buffering`, `paused`, `stopped`, `ended` or `error`, not an empty tick, and not a negative position | DR-182 | Done
UT-188 | The control-bar auto-hide rule permits hiding only during uninterrupted playback: it declines while paused, while a seek is in flight, and while a track/subtitle/quality menu is open — asserted against the pure `shouldHideControls` rule rather than a clock or a DOM | DR-189 | Done
UT-189 | On the native path the player never calls `player_report_state` — driven through the real 10-second progress interval under fake timers, which is the call site that mattered; asserting on a freshly mounted player passes with the guard deleted and guards nothing | DR-195 | Done
UT-187 | On the native path the play overlay follows the backend: it clears when the backend resumes after a pause and is raised again when the backend pauses, and the system bars are hidden on player entry rather than only by the fullscreen button | DR-186, DR-187 | Done
UT-186 | Every attribute the native-video compositing block in app.css targets is set somewhere in the app — `[data-app-shell]` in particular — so a selector aimed at nothing fails the suite instead of failing silently on a device | DR-185 | Done
UT-185 | Mounted on the native path (backend reports native, opt-in flag on, no `<video>` element rendered and the backend not stopped), VideoPlayer keeps the poster card up until the backend reports something, drops it on a playing state or a position tick with a duration, and keeps it up through `error` and `stopped` | DR-182 | Done
UT-190 | `build_next_up_endpoint` sends `EnableResumable=false` with the user and limit, and no `SeriesId` filter when none was requested | DR-197, JA-036 | Done
UT-191 | A per-series next-up query keeps `SeriesId` and the resumable exclusion, and defaults the limit | DR-197 | Done
UT-192 | `filterInProgressNextUpItems` drops an episode present in the resume list, keeps the genuinely unstarted next episode, leaves the rest of the row intact, and is a no-op when nothing is in progress | DR-197 | Done
UT-193 | The shipped Tauri security config stays restrictive: `csp` is set, `script-src` carries no `'unsafe-inline'`/`'unsafe-eval'`/wildcard, `object-src`/`frame-src` are `'none'`, the directives playback needs (asset scheme, loopback, `blob:`, `ipc:`) are present, and the asset-protocol scope covers only the thumbnail cache — never the storage root that holds the database | DR-198 | Done
UT-194 | Normal audio (no background-audio handoff) keeps queue advance on both skip buttons | DR-201 | Done
UT-195 | In background-audio mode a skip scrubs +30s/-10s instead of advancing the queue — the reported defect | DR-201 | Done
UT-196 | Skipping back near the start clamps to zero rather than seeking negative | DR-201 | Done
UT-197 | Skipping forward near the end clamps to the duration rather than running past it into an EOF-driven advance | DR-201 | Done
UT-198 | An unknown duration still scrubs and still refuses to go negative | DR-201 | Done
UT-199 | The screen-wake decision: video playing holds the display, pausing releases it, audio playing never holds it, a webview element going inactive releases even without a pause report, either renderer alone is enough to hold, and teardown drops both | DR-202 | Done
UT-201 | The logging facade gates by level: a message below the active level is not emitted at all, one at or above it reaches the sink, changing the level at run time changes what passes without touching the call sites, and a scoped logger tags its output with the subsystem | DR-204 | Proposed
UT-202 | Generated traceability-matrix file links resolve from `docs/`: an emitted href, resolved against the directory `traceability.md` is written to, points at a file that exists on disk; the visible link text stays repo-root-relative; the `#Lnn` anchor survives; and a bare repo-root href — the regression that made every link 404 as `docs/<path>` — is rejected | DR-093 | Done
UT-203 | Library folder exclusion filters by id, not by name: an excluded folder's items are absent from a music query, an item whose title merely contains an excluded folder's name is kept, and clearing the exclusion restores the items | DR-209 | Proposed
UT-204 | Thumbnail cache writes stay inside the cache directory: a traversal-style and an absolute `item_id` both fail to produce a file outside it, a filename made only of already-safe characters is byte-identical to the one the previous code produced, and an odd id still round-trips through `get_cached_path` | DR-210 | Done
UT-205 | Queued download paths cannot escape the download root — traversal, absolute and `..` forms are refused — while the four real path shapes the app builds, including the absolute one `download_series` produces, come back unchanged; and a completed download cannot register a file outside the root | DR-211 | Done
UT-206 | The offline item-type filter is bound rather than interpolated (a value containing a quote and `OR 1=1` matches nothing instead of disabling the `WHERE`), `build_get_items_endpoint` percent-encodes its values while preserving the commas Jellyfin splits on, and volume normalisation clamps out-of-range input and maps NaN to a finite value | DR-212 | Done
UT-200 | The stream a player could only restart is refused its retry: the handoff transcode answers yes to `player_retry_restarts_stream` while music, video and a downloaded episode answer no, and the Kotlin decision starts permissive, flips on a non-resumable load, and is restored by the next ordinary one | DR-203 | Done
UT-207 | The hero banner's rotation timer restarts from the moment of a manual change: a swipe 5.5s into a 6s interval waits a further 6s instead of firing the leftover 500ms, repeated restarts never stack timers, and `stop()` ends rotation | DR-038 | Done

UT-208

The update decision: each numeric version field is compared in order, the installed version is not offered to itself, a leading `v` is tolerated because that is how the tags are written, a pre-release sorts below the release of the same number so 0.9.2-rc1 is not offered to somebody on 0.9.2, a missing patch field reads as zero rather than NaN, mobile reports link-only while desktop reports install, and absent release notes normalise to null rather than undefined

DR-217

Done

UT-209

Redaction and forwarding. Rust: every credential shape reduces to `[REDACTED]` while the host, username and neighbouring parameters survive; redaction is idempotent, leaves ordinary lines alone, does not fire on the word "token" in prose, and does not panic on multi-byte input; a server URL keeps only scheme and host and drops an embedded `user:pass@`; an unparseable level falls back to info rather than failing at startup. Frontend: info and above forward while debug does not, a message the level filter suppressed is not forwarded, a throwing forwarder neither propagates nor prevents the console write, and an `Error` renders as name and message rather than the `{}` that `JSON.stringify` produces

DR-218

Done

UT-210 | Cosmetic-commit detection for release notes: a `chore(format)`, `chore(deps)` or `style` subject is skipped when deriving a range's changed files, while `fix`, `feat`, `ci`, `docs`, a bare `chore:` and `chore(release):` are kept; and the word "format" appearing later in a subject ("fix(duration): format times over 24 hours") does not make a real fix look cosmetic | DR-219 | Done

UT-211

The background decision: a video with the toggle off pauses (the reported defect, where the media service kept playing regardless), a video with it on hands off to audio, music keeps playing whatever the toggle says because it has no picture to lose, picture-in-picture keeps playing in every combination since the window is still visible, and the answer does not vary by renderer

DR-224

Done

UT-212

The stream-selection contract. `Transport` and `PlaybackKind` each serialise to exactly the tag the frontend matches (`{"type":"hls"}`, `{"type":"directPlay"}`, …) and round-trip; nested `StreamSelection` fields are camelCase on the wire including `playbackKind`, `mediaSourceId` and `maxBitrate`; only `Transcode` counts as transcoding, so a direct stream does not; a local file is a direct play over a local transport with no ladder. The ladder: every rung at or above a 1.12 Mbps source is marked redundant while the three that constrain it are not, `Original` is never marked for any bitrate including zero and unknown, an unreported source bitrate keeps all eight rungs offered, a 40 Mbps source marks none, and each option carries the ladder's own label and detail

DR-224, DR-226

Done

UT-213

The direct-play negotiation, one test per branch, against `PlaybackInfo` fixtures whose shapes were all observed on a live server: a supported source direct-plays; a remuxable one direct-streams and reports itself as not transcoding; an unsupported codec transcodes; undecodable audio overrides the server's direct-play offer (silent picture is worse than a transcode); a pinned audio track forces a transcode; a ceiling below the source bitrate transcodes even though the codec is fine, and the ladder agrees that rung constrains it; direct play wins over direct stream when both are offered. Plus the ceiling: a per-playback override governs the stream being opened without disturbing the durable default the Settings screen shows, and dropping it returns to that default

DR-225, DR-227

Done

UT-214

The loader comes from the transport, never the URL. hls.js is attached for `hls` when available and the element's own loader when not; progressive and local files load directly; the element's `src` is emptied only when hls.js drives it. The two cases that fail against a substring check, and the reason the field exists: a `progressive` stream whose URL contains `.m3u8` is not given an HLS loader, and an `hls` stream whose URL contains no `.m3u8` is. Both failed against the pre-DR-225 implementation before the fix landed

DR-224

Done

UT-215

Waiting for the repository rather than racing it: it resolves immediately when the session is already restored, resolves when the session arrives later (the race the player page lost on mount), still rejects when there genuinely is no session, unsubscribes once settled so a later store change cannot re-settle it, and leaves no armed timer to reject an already-resolved promise

DR-013

Done

UT-216 | The native-video opt-in is read from one place and only explicit truthy values enable it: absent, empty, `0`, `no`, `false` and anything unrecognised all mean off, because a half-set variable that half-enabled the renderer would configure mpv for video with nothing drawing it — audio over a black rectangle | DR-231 | Done
UT-217 | A transcoded HLS stream on the native backend re-negotiates rather than seeking in place, while the same stream under hls.js still seeks in place — the cell that native video made reachable for the first time | DR-238 | Done
UT-218 | Every property name matched by the mpv event loop also appears in an `observe_property` call, asserted against the source because the registration cannot be observed at runtime without a live mpv | DR-239 | Done
UT-219 | A fullscreen toggle moves the document only when an in-document `<video>` renders, and moves the OS window as well when a native surface does | DR-240 | Done
UT-220 | The conformance suite: opening at a position starts there and never at zero, a seek issued while opening is honoured and overrides the start it overtook, pause and play are observable, close is silent and idempotent, and an open cancelled by close never begins playing | DR-242, DR-243 | In Progress
UT-221 | An engine that cannot report a duration does not erase the one the item carries: with the queue holding a 1800s item and the engine answering nothing usable, the controller still reports 1800s | DR-251 | Done
UT-222 | The values that killed the backend are rejected rather than converted: `C.TIME_UNSET` as seconds, negatives, zero, NaN and both infinities all yield no duration, while a real runtime survives | DR-252 | Done

UT-223

The adapter survives an engine that answers badly. A `HostileBackend` reports `C.TIME_UNSET` as seconds, NaN, both infinities, a negative and a zero; reading a snapshot yields no duration and a zero position rather than panicking, and a well-behaved engine still round-trips. The conformance suite could not have caught this — it only ever drives engines that report sane numbers, which is why it stayed green while a real one took the backend down

DR-252

Done

UT-224 | Stopping clears an active background-audio handoff, both the flag and the base offset, so a later position read cannot be interpreted against a handoff that no longer exists. Previously verified only by listening to a device | DR-250 | Done
UT-225 | Both `load` and `stop` discard a deferred seek, so a position held for a file that is no longer loading cannot be applied to whatever loads next | DR-253 | Done
UT-226 | The background episode advance clears the per-playback quality override, so a ceiling chosen for one episode does not cap every episode after it | DR-254 | Done
UT-227 | Opening any one of the control bar's menus closes whichever was open — track, quality, subtitle and the desktop volume popup are one group, never two panels at once — and a second click on the open menu's own toggle closes it | DR-256 | Done
UT-228 | The open menu panel is anchored to the control row rather than to the icon that opened it, and carries a viewport-clamped width, so it cannot hang off the edge of a portrait screen | DR-256 | Done
UT-229 | A channel folder's children are requested by release date, newest first, while every other container keeps name order; an explicit sort still wins, and a caller naming no container gets no `SortBy` at all | DR-257 | Done
UT-230 | A `ChannelFolderItem` that is a folder maps to `ChannelFolder`, not to the generic `Folder` it was indistinguishable from | DR-257 | Done
UT-231 | The library store sends the container's kind and no sort field, defaulting to a plain folder when the caller names none | DR-257 | Done
UT-232 | A transcode's audio-track change re-opens the stream, a direct play selects in place, and an HTML5 element reloads either way — the engine is only asked to select a track the stream actually carries | DR-258 | Done
UT-233 | The position a re-opened stream resumes at comes from the engine when the caller has none, and a non-finite or negative position is treated as absent rather than passed to a backend that rejects it | DR-258 | Done
UT-234 | A subtitle URL targets Jellyfin's `Stream.{format}` route, asserted against the repository that builds it rather than a mock that restates it | DR-259 | Done
UT-235 | The A-Z strip's last letter stays above the bottom nav, above the mini player while audio plays, and above the taller remote-mode mini player | DR-262 | Done
UT-236 | The strip still fills the space it does have rather than stopping a letter short | DR-262 | Done
UT-237 | The strip falls back to the viewport with no scroll container, and never computes a negative height when scrolled past its floor | DR-262 | Done
UT-238 | The last episode of a season rolls over into the first of the next, skips an empty season on the way, and still stops there when the sleep timer says so | DR-263 | Done
UT-239 | An episode watched to the end is not the current episode: not when the server's Next Up still names it (the stale answer is skipped in favour of the one after it), and not offline, where it counts as watched in the furthest-watched scan | DR-264 | Done
UT-240 | Caching a server result mirrors its played flag locally — as synced, never invented for an item that carries no user data, and never over an unsynced local toggle | DR-264 | Done
UT-241 | A newly-imported album reads as one album card, not one card per song: three tracks sharing an album id collapse into a single `MusicAlbum` entry that opens the album, keeps its artwork and album artist, drops track-only detail (track number, duration, album link), and takes the position of the first of its tracks so recency order and the neighbouring movie survive | JA-016 | Done
UT-242 | When the server did group, its own album row wins — its overview and detail survive and the tracks it also returned add no second card for the same album | JA-016 | Done
UT-243 | A track that names no album has no container to collapse into and stays a track, the same way a movie does | JA-016 | Done
UT-244 | Recently Added over-fetches before collapsing, so folding one 14-track import together does not leave the row nearly empty | JA-016 | Done

| UT-245 | A `timeupdate` is applied while the video is playing — the case that froze the position behind a PiP window — and still yields to an in-flight seek, a seek-bar drag, and an element with no current data | DR-265 | Done | | UT-246 | Opening a PiP window disarms background audio, and a background signal arriving with the native PiP flag false is still treated as PiP while the frontend's latch says the window is open — without resurrecting one it has already seen close | DR-266 | Done | | UT-247 | A library whose `collection_type` has no mapping — Books, Photos, a mixed library — does not return the server's films, albums and shows from cache | DR-277 | Done | | UT-248 | Narrowing the library clause does not starve the libraries that do have landing pages: music, movies and TV each still list their own media and none of the others | DR-277 | Done | | UT-249 | Opening an individual collection still lists its own children: a BoxSet's members are matched by the stored `parent_id`, not by the library clause, so narrowing that clause did not empty collections | DR-277 | Done |

### Integration Tests

Test ID | Test Description | Traces To | Status
--- | --- | --- | ---
IT-001 | End-to-end authentication with Jellyfin server | IR-009, UR-009 | Pending
IT-002 | Library browsing and item loading | IR-010, UR-007 | Pending
IT-003 | Audio playback via libmpv | IR-003, UR-004 | Pending
IT-004 | Video playback via libmpv | IR-003, UR-003 | Pending
IT-005 | MPRIS lockscreen controls on Linux | IR-005, UR-006 | Pending
IT-006 | Offline mode with local database | IR-013, UR-002 | Pending
IT-007 | Media download and local playback | DR-015, UR-011 | Pending
IT-008 | Subtitle track selection on the video backends (ExoPlayer sideloaded tracks; HTML5 `<track>` children) — not via libmpv, which does not implement it | IR-018, UR-020 | Pending
IT-009 | Audio track selection on the video backends (ExoPlayer track switch; HTML5 stream re-open at the chosen `AudioStreamIndex`) — not via libmpv, which does not implement it | IR-019, UR-021 | Pending
IT-010 | Playback progress sync to Jellyfin | IR-015, UR-025 | Pending
IT-011 | Resume playback from server position | IR-015, UR-019 | Pending
IT-012 | Equalizer bands via libmpv | IR-020, UR-027 | Pending
IT-013 | Background-audio handoff on Android: background/lock continues audio via native service and stops video decode; foreground resumes video at position | IR-025, UR-040 | Pending
IT-016 | Offline library listing end-to-end: with the server unreachable, a library page lists only downloaded media with the toggle off, and additionally reveals greyed-out cached catalog entries with the toggle on | UR-052, DR-078, DR-079, DR-080 | Done
IT-017 | A download queued from a greyed-out offline catalog entry persists and is resolved and started on reconnect | UR-052, UR-011 | Done
IT-018 | The conformance cases run against ExoPlayer on a device: opening from the beginning and at a position, a seek issued while still preparing, a seek after open, pause and play observable, stop silent and idempotent, and a load cancelled by stop never playing. The fixture is a silent WAV synthesised at setup, so the repo carries no media and the duration is exact | DR-247 | Done

## 5. Technical Debt

### Open items carried over from the v0.6.0 codebase audit

The 2026-08-16 audit (v0.6.0, commit `be907b49`) was a point-in-time snapshot with no status markers, and by v0.8.2 most of it had been either fixed or overtaken. It was retired rather than left to rot into a document that half-describes the code: what survived it is the table below, which is now the record. Each row is self-contained — the audit is not needed to act on it.

What was dropped as demonstrably closed, so it is not re-raised: the CSP and asset-protocol scope findings (now DR-198), cloud backup and credential restore, the WebView mixed-content override (DR-199), `POST_NOTIFICATIONS` and the media-session exemption (DR-200), the `jvmTarget` 1.8 pin (now 17), the half-declared Android TV leanback category (removed), the untraced-but-Done requirements and the contradictory UR/IR statuses (re-scoped in §2.1), the 50% traceability gate (ratcheted, and gated on a live denominator by DR-093), the flaky `offlineCatalog` test, the clippy warning backlog (cleared, and `cargo fmt --check` plus clippy now run in CI), and the "820 production `unwrap()`s" figure — a measurement error that counted test modules, corrected in the audit itself to ~19 and standing at 27 today, none of them in a command handler. The three `Runtime::new().unwrap()` sites that genuinely matter survive as row 5.

Ordered by what would hurt most if left.

> Closed 2026-08-17: the R8-minified release APK was validated on device. That was the last item gating confidence in the v0.8.0 release itself; R8 stripping JNI-loaded classes has broken release builds here before, and v0.8.0 added a new Kotlin path (`onFastForward`/`onRewind`) that the unminified debug pass did not cover.

# | Item | Why it matters | Size
--- | --- | --- | ---

1

Android 16 Local Network Protections

The rare platform change that could stop the app working at all: JellyTau's core function is reaching a Jellyfin server that, for most users, is on the LAN. Opt-in for testing in Android 16, enforcement signalled for a later release — so nothing is broken today and no device test will surface it. Far cheaper to handle before it is mandatory. An Android 16 device is already to hand to test the opt-in flag against

M

2

The traceability matrix cannot see Kotlin

`scripts/extract-traces.ts` walks only `src`, `src-tauri/src` and `scripts`, so every `TRACES:` comment in `src-tauri/android/**` is invisible — pre-existing ones included. A whole platform is unmeasured, which is plausibly why the Android IRs sat untagged for so long, and it means the 90% coverage figure is computed over a codebase that excludes the Android tree

S

3

Delete the asset protocol outright

It is not narrowly used, it is unused. `getCachedImageUrl` has no production callers (only its own test file), so `convertFileSrc` never executes; images arrive as base64 `data:` URIs from `image_get_url`. Confirmed on device: zero `asset.localhost` requests across a full browsing session. Dropping `protocol-asset` and the `assetProtocol` block retires the surface instead of shrinking it, and `imageCache.ts` goes with it

S

4 | Tighten `img-src` | The v0.8.0 CSP grants `img-src … http: https:` on the premise that thumbnails are fetched direct-from-server by the webview. They are not (see #3). With no webview-side server image loads anywhere in `src/`, `'self' data: blob:` should suffice. Needs its own device pass — a wrong `img-src` blanks every image, silently | S

5

Three `Runtime::new().unwrap()` in playback-critical threads

`session_poller/mod.rs:102`, `player/mpv_backend.rs:424`, `player/android/mod.rs:761`. A panic strands the app offline with nothing surfaced, freezes the scrubber mid-playback, or kills progress reporting across a JNI boundary. One shared helper returning `Option<Runtime>` and logging on failure retires all three. (The wider "820 unwraps" figure was a measurement error — the real count is 19, and none are in command handlers)

S

6

Confirm the playback service rejects unknown callers

`JellyTauPlaybackService` is `exported="true"` with a `MediaSessionService` intent filter — conventional for Media3, but it means any app on the device can attempt to bind and drive playback. The session's `onConnect` should reject unknown packages. (Predictive back, raised alongside this, was verified working on device and needs nothing)

S

7 | Media3 is several minor versions behind | Pinned at 1.5.0 across exoplayer/hls/session/common. Much of this app's hard-won behaviour lives in ExoPlayer edge cases — truncated progressive streams, background-audio handoff, HLS resume — so its bug-fix releases have unusually high value here. Schedule with a device pass over the playback regression list | M
8 | Shipped desktop bundles have no update path | deb/rpm/nsis are built but `tauri-plugin-updater` is absent, so every desktop user upgrades by manually fetching a package — in practice a long tail of installs pinned to whatever they first downloaded. Add the updater with a signed manifest, or document the manual path so the omission is deliberate | M
9 | `DR-042` overstates what ships | It promises "poster cards, year, and rating badges", but `MediaCard.svelte` renders only `productionYear`; `CommunityRating`/`OfficialRating` appear solely as sort keys, never as a badge. Either build the badge or correct the requirement text — a requirement that describes unbuilt behaviour is worse than an untraced one | S
10 | Stray duplicate `JellyTauPlayer.kt` | A copy exists at `src-tauri/android/app/src/main/java/.../player/JellyTauPlayer.kt`, outside the canonical `src-tauri/android/src` tree that `sync-android-sources.sh` reads. Two files with one name in a tree with a strict canonical-source rule is a trap for the next edit | S

11

Six modules carry a disproportionate share of the complexity

`src-tauri/src/player/mod.rs` (4,732 lines), `src-tauri/src/repository/offline.rs` (4,705), `src-tauri/src/repository/online.rs` (3,760), `src-tauri/src/commands/player/mod.rs` (3,327), `src-tauri/src/commands/download/mod.rs` (3,238) and `src/lib/components/player/VideoPlayer.svelte` (2,786) — all still growing. The cost is not the line count itself, it is that these are the same modules `CLAUDE.md`'s Gotchas section keeps having to warn about: the deadlock rule about locking in event callbacks, the `AutoplayDecision` scrutinee, the "no lifecycle calls after an `await` in `onMount`" rule, the HLS `master.m3u8` rule, the download concurrency cap. A file that needs a standing warning in the project's onboarding document is a file whose invariants are no longer local to it, and every such warning is a rule a newcomer has to be told rather than one the structure enforces. Recorded, not scheduled — a speculative refactor of six files this size buys nothing on its own. The trigger is the next time one of them needs substantial work: splitting it then is likely cheaper than growing it, and each rule that moves from Gotchas into a module boundary is one fewer thing to remember

L

### Linux Keyring Integration Workaround

Issue: The `keyring-rs` crate (v3.x) has issues with retrieving credentials from the Linux Secret Service API, despite successfully saving them.

Symptoms:

- Credentials are saved to the system keyring successfully (verified with `secret-tool search`)
- Retrieval via the `keyring-rs` library fails with `NoEntry` error
- Session restoration fails on app restart even though credentials exist

Root Cause: The `keyring-rs` library's Linux backend doesn't correctly retrieve entries from the Secret Service that it previously stored. This appears to be a bug in how the library interfaces with the Secret Service D-Bus API.

Current Workaround: We bypass the `keyring-rs` library on Linux and use direct system calls to `secret-tool`:

- Save: `secret-tool store --label <label> service <service> username <username>`
- Retrieve: `secret-tool lookup service <service> username <username>`
- Delete: `secret-tool clear service <service> username <username>`

Implementation: See src-tauri/src/credentials.rs for the Linux-specific `secret-tool` save/get/delete paths.

Future Fix:

- Monitor `keyring-rs` for bug fixes in future versions
- Consider alternative secure storage libraries
- Test if newer versions of `keyring-rs` (v4.x+) resolve the issue
- Once fixed, remove the Linux-specific workaround and use the cross-platform `keyring-rs` API

Impact:

- Low - The workaround is functionally equivalent to proper keyring integration
- Credentials are stored securely in the system keyring
- Session restoration works correctly
- Only affects Linux; macOS and Windows use the standard `keyring-rs` implementation

Dependencies:

- Requires `secret-tool` to be installed on Linux systems (part of `libsecret-tools` package)
- Already available on most Linux distributions by default

### Platform Playback Backend Parity (Linux vs Android)

Issue: The Linux (MPV) and Android (ExoPlayer) playback backends have diverged in feature implementation and architecture patterns.

Symptoms (as first recorded; the audio half is now closed — see Status):

- Audio settings (gapless playback, volume normalization, equalizer) worked on Linux but not on Android
- Position update frequency differs between platforms (Linux: 250ms polling, Android: on-demand callbacks)
- Thread safety models differ (Linux: `Arc<Mutex<>>`, Android: global `OnceLock` statics)

Root Cause: The `PlayerBackend` trait defines optional audio settings methods with default empty implementations. `MpvBackend` overrode these with MPV property commands; `ExoPlayerBackend` took the silent defaults, so the Settings › Audio panel rendered controls that did nothing on Android. `ExoPlayerBackend` now overrides them too, but the trait default is still a silent `Ok(())` — a backend that omits the method still reports success rather than failing loudly.

Affected Files:

- src-tauri/src/player/backend.rs - Trait; defaults still return `Ok(())` silently
- src-tauri/src/player/mpv_backend.rs - Full audio settings support
- src-tauri/src/player/android/mod.rs - Audio settings carried to Kotlin as JSON over JNI

Feature Parity Matrix:

Feature | Linux (MPV) | Android (ExoPlayer) | Status
--- | --- | --- | ---
Basic playback | ✅ | ✅ | Parity
Volume control | ✅ | ✅ | Parity
Seek | ✅ | ✅ | Parity
Crossfade | ❌ | ❌ | Not implemented (blocked on MPV)
Gapless playback | ✅ | ⚠️ | Implemented, pending on-device verification
Volume normalization | ✅ | ⚠️ | Implemented (LoudnessEnhancer — gain stage, approximate vs MPV's dynaudnorm), pending on-device verification
Equalizer (10-band) | ✅ | ⚠️ | Implemented (resampled onto device bands), pending on-device verification
Position updates | 250ms | On-demand | Inconsistent

Status (see docs/architecture/05-platform-backends.md, "Audio settings on ExoPlayer"):

1. ✅ `set_audio_settings()` implemented in `ExoPlayerBackend` (JSON over JNI)
2. ✅ Gapless via ExoPlayer's `pauseAtEndOfMediaItems`
3. ✅ Volume normalization via `LoudnessEnhancer`
4. ✅ Equalizer via `android.media.audiofx.Equalizer`, canonical 10 bands resampled onto the device's band centres
5. ⬜ Not yet verified on a physical device — the EQ/normalization effects depend on device-specific `AudioEffect` availability and band layouts
6. ⬜ Flip the trait's `set_audio_settings` default from `Ok(())` to `Err(not_implemented())` so a backend that omits it fails loudly instead of silently reporting success. Deferred until (5) confirms the Android path works
7. ⬜ Standardize position update frequency across platforms

Crossfade is deliberately absent: it is unimplemented on every platform and architecturally blocked on MPV, so building it on Android alone would invert the parity gap. (The previously suggested `ConcatenatingMediaSource` is also deprecated in current Media3.)

Impact:

- Medium - Android users lack audio enhancement features advertised in requirements
- User experience differs between platforms
- UR-032 (Gapless), UR-033 (Normalization) and UR-027 (Equalizer) are now implemented on Android as well as Linux, pending on-device verification
- UR-031 (Crossfade) works nowhere — see DR-034

Traces To: IR-004, UR-031, UR-032, UR-033, DR-034, DR-035, DR-036

### Frontend Playback Code Duplication

Issue: Playback control handlers and state derivations are duplicated between `AudioPlayer.svelte` and `MiniPlayer.svelte`.

Symptoms:

- Identical try-catch wrapped handler functions in both components (~44 lines duplicated)
- Same `$derived` state merging logic for local/remote playback in both components
- Position conversion (ticks ↔ seconds) scattered across multiple files

Affected Files:

- src/lib/components/player/AudioPlayer.svelte - Duplicate handlers
- src/lib/components/player/MiniPlayer.svelte - Duplicate handlers
- src/lib/utils/playbackUnits.ts - Position conversion (the shared helper the "Future Fix" below called for; `playbackControl.ts`, previously listed here, has since been removed)
- src/lib/stores/playbackMode.ts - Position conversion
- src/lib/services/playbackReporting.ts - Position conversion

Duplicated Code:

    // These handlers are identical in both AudioPlayer and MiniPlayer:
    handlePlayPause(), handleNext(), handlePrevious(),
    handleToggleShuffle(), handleCycleRepeat(), handleVolumeChange()

    // These derived states use identical logic:
    displayMedia, displayIsPlaying, displayPosition, displayDuration

Future Fix:

1. Create `src/lib/utils/playbackUnits.ts`:

       export const TICKS_PER_SECOND = 10_000_000;
       export const secondsToTicks = (s: number) => Math.floor(s * TICKS_PER_SECOND);
       export const ticksToSeconds = (t: number) => t / TICKS_PER_SECOND;

2. Create `src/lib/composables/useMergedPlaybackState.svelte.ts`:

   - Export `displayMedia`, `displayIsPlaying`, `displayPosition`, `displayDuration`
   - Single source of truth for merged local/remote state

3. Simplify handler wrappers using a utility:

       export const withErrorHandler = (fn: () => Promise<void>, context: string) =>
         async () => { try { await fn(); } catch (e) { console.error(`${context}:`, e); } };

Impact:

- Low - Code works correctly but violates DRY principle
- Maintenance burden when logic needs to change
- Risk of handlers diverging over time

Traces To: DR-009

# Code Traceability Matrix

Generated: 9/6/2026, 11:53:31 PM

## Summary

- Total Files Scanned: 426
- Total TRACES Found: 1255
- Requirements Covered:
  - User Requirements (UR): 83
  - Integration Requirements (IR): 26
  - Development Requirements (DR): 233
  - Jellyfin API Requirements (JA): 35

## Requirements by Type

### User Requirements (UR)

    UR-002, UR-003, UR-004, UR-005, UR-006, UR-007, UR-008, UR-009, UR-010, UR-011, UR-012, UR-013, UR-014, UR-015, UR-016, UR-017, UR-018, UR-019, UR-020, UR-021, UR-022, UR-023, UR-024, UR-025, UR-026, UR-027, UR-028, UR-029, UR-030, UR-031, UR-032, UR-033, UR-034, UR-035, UR-036, UR-037, UR-038, UR-039, UR-040, UR-041, UR-042, UR-043, UR-044, UR-045, UR-046, UR-047, UR-048, UR-049, UR-050, UR-051, UR-052, UR-053, UR-054, UR-055, UR-056, UR-057, UR-058, UR-059, UR-060, UR-061, UR-062, UR-063, UR-064, UR-065, UR-066, UR-067, UR-068, UR-069, UR-070, UR-071, UR-072, UR-073, UR-074, UR-075, UR-076, UR-077, UR-078, UR-079, UR-080, UR-081, UR-082, UR-083, UR-084

### Integration Requirements (IR)

    IR-003, IR-004, IR-006, IR-008, IR-009, IR-010, IR-011, IR-012, IR-013, IR-014, IR-015, IR-016, IR-018, IR-019, IR-020, IR-021, IR-022, IR-023, IR-024, IR-025, IR-026, IR-027, IR-028, IR-030, IR-031, IR-033

### Development Requirements (DR)

    DR-001, DR-002, DR-003, DR-004, DR-005, DR-006, DR-007, DR-009, DR-010, DR-011, DR-012, DR-013, DR-014, DR-015, DR-016, DR-017, DR-018, DR-020, DR-021, DR-022, DR-023, DR-024, DR-025, DR-026, DR-027, DR-028, DR-029, DR-030, DR-032, DR-033, DR-034, DR-035, DR-036, DR-037, DR-038, DR-039, DR-040, DR-041, DR-042, DR-043, DR-044, DR-045, DR-047, DR-048, DR-049, DR-050, DR-051, DR-052, DR-053, DR-054, DR-055, DR-056, DR-057, DR-058, DR-059, DR-060, DR-061, DR-062, DR-063, DR-064, DR-065, DR-066, DR-067, DR-068, DR-069, DR-070, DR-074, DR-075, DR-076, DR-077, DR-078, DR-079, DR-080, DR-081, DR-082, DR-083, DR-084, DR-085, DR-086, DR-087, DR-088, DR-089, DR-090, DR-091, DR-092, DR-093, DR-095, DR-096, DR-097, DR-098, DR-099, DR-100, DR-101, DR-102, DR-103, DR-104, DR-105, DR-106, DR-107, DR-108, DR-109, DR-110, DR-111, DR-112, DR-113, DR-114, DR-115, DR-116, DR-117, DR-118, DR-119, DR-120, DR-121, DR-123, DR-126, DR-127, DR-128, DR-129, DR-130, DR-131, DR-132, DR-133, DR-134, DR-135, DR-136, DR-137, DR-140, DR-141, DR-142, DR-143, DR-147, DR-148, DR-149, DR-150, DR-151, DR-152, DR-153, DR-154, DR-155, DR-156, DR-157, DR-158, DR-159, DR-160, DR-162, DR-167, DR-168, DR-169, DR-170, DR-171, DR-173, DR-174, DR-175, DR-176, DR-177, DR-178, DR-179, DR-180, DR-181, DR-182, DR-183, DR-185, DR-186, DR-188, DR-189, DR-193, DR-195, DR-196, DR-197, DR-198, DR-201, DR-203, DR-204, DR-205, DR-206, DR-207, DR-209, DR-210, DR-211, DR-212, DR-213, DR-215, DR-216, DR-217, DR-218, DR-219, DR-220, DR-221, DR-223, DR-224, DR-225, DR-226, DR-227, DR-228, DR-230, DR-231, DR-232, DR-233, DR-234, DR-235, DR-238, DR-239, DR-240, DR-241, DR-242, DR-243, DR-244, DR-245, DR-246, DR-250, DR-251, DR-252, DR-253, DR-254, DR-255, DR-256, DR-257, DR-258, DR-259, DR-262, DR-263, DR-264, DR-265, DR-266, DR-267, DR-268, DR-269, DR-270, DR-271, DR-272, DR-274, DR-276, DR-277

### Jellyfin API Requirements (JA)

    JA-001, JA-002, JA-003, JA-004, JA-005, JA-006, JA-007, JA-008, JA-009, JA-010, JA-011, JA-012, JA-013, JA-014, JA-015, JA-016, JA-017, JA-018, JA-019, JA-020, JA-021, JA-022, JA-023, JA-024, JA-025, JA-026, JA-028, JA-029, JA-030, JA-031, JA-032, JA-033, JA-034, JA-035, JA-036

## Detailed Mapping

### IR-003

Locations: 2 file(s)

- File: `src-tauri/src/player/backend.rs`
  - Line: 39
  - Context: `pub fn playback_failed<S: Into<String>>(message: S) -> Self {`
- File: `src-tauri/src/player/mpv_backend_test.rs`
  - Line: 8
  - Context: `Unknown`

### IR-004

Locations: 1 file(s)

- File: `src-tauri/src/player/backend.rs`
  - Line: 39
  - Context: `pub fn playback_failed<S: Into<String>>(message: S) -> Self {`

### IR-006

Locations: 4 file(s)

- File: `src-tauri/src/player/mod.rs`
  - Line: 179
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 201
  - Context: `Unknown`
- File: `src-tauri/src/player/android/mod.rs`
  - Line: 1123
  - Context: `Unknown`
- File: `src-tauri/src/player/android/mod.rs`
  - Line: 1425
  - Context: `Unknown`

### IR-008

Locations: 1 file(s)

- File: `src-tauri/src/player/android/mod.rs`
  - Line: 438
  - Context: `Unknown`

### IR-009

Locations: 6 file(s)

- File: `src/lib/stores/auth.ts`
  - Line: 6
  - Context: `Unknown`
- File: `src/lib/stores/auth.ts`
  - Line: 319
  - Context: `Unknown`
- File: `src/lib/stores/auth.ts`
  - Line: 346
  - Context: `Unknown`
- File: `src/lib/stores/auth.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src-tauri/src/commands/auth.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/jellyfin/client.rs`
  - Line: 1
  - Context: `Unknown`

### IR-010

Locations: 3 file(s)

- File: `src-tauri/src/jellyfin/client.rs`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2194
  - Context: `Unknown`

### IR-011

Locations: 1 file(s)

- File: `src-tauri/src/jellyfin/client.rs`
  - Line: 1
  - Context: `Unknown`

### IR-012

Locations: 2 file(s)

- File: `src/lib/stores/playbackMode.ts`
  - Line: 9
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/remote.rs`
  - Line: 3
  - Context: `Unknown`

### IR-013

Locations: 3 file(s)

- File: `src-tauri/src/commands/storage/mod.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/storage/schema.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 8
  - Context: `Unknown`

### IR-014

Locations: 10 file(s)

- File: `src/lib/stores/auth.ts`
  - Line: 6
  - Context: `Unknown`
- File: `src/lib/stores/auth.ts`
  - Line: 346
  - Context: `Unknown`
- File: `src/lib/stores/auth.ts`
  - Line: 508
  - Context: `Unknown`
- File: `src/lib/stores/auth.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src-tauri/src/commands/auth.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/credentials.rs`
  - Line: 10
  - Context: `Unknown`
- File: `src-tauri/src/credentials.rs`
  - Line: 480
  - Context: `Unknown`
- File: `src-tauri/src/credentials.rs`
  - Line: 905
  - Context: `Unknown`
- File: `src-tauri/src/credentials.rs`
  - Line: 924
  - Context: `Unknown`
- File: `src-tauri/src/credentials.rs`
  - Line: 941
  - Context: `Unknown`

### IR-015

Locations: 1 file(s)

- File: `src-tauri/src/commands/playback_reporting.rs`
  - Line: 3
  - Context: `Unknown`

### IR-016

Locations: 18 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 2813
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3621
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 408
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 509
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 986
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 2563
  - Context: `Unknown`
- File: `src/lib/components/player/subtitleTracks.test.ts`
  - Line: 233
  - Context: `Unknown`
- File: `src/lib/components/player/subtitleTracks.test.ts`
  - Line: 353
  - Context: `Unknown`
- File: `src/lib/components/player/subtitleTracks.ts`
  - Line: 20
  - Context: `Unknown`
- File: `src/lib/components/player/subtitleTracks.ts`
  - Line: 210
  - Context: `Unknown`
- File: `src/lib/components/player/subtitleTracks.ts`
  - Line: 243
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 236
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 459
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 3114
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 3171
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 3203
  - Context: `Unknown`
- File: `src-tauri/src/player/media.rs`
  - Line: 42
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1859
  - Context: `Unknown`

### IR-018

Locations: 3 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 191
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1941
  - Context: `Unknown`
- File: `src-tauri/src/player/backend.rs`
  - Line: 119
  - Context: `Unknown`

### IR-019

Locations: 7 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 142
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 178
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1629
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1920
  - Context: `Unknown`
- File: `src-tauri/src/player/backend.rs`
  - Line: 106
  - Context: `Unknown`
- File: `src-tauri/src/player/track_switch.rs`
  - Line: 34
  - Context: `Unknown`
- File: `src-tauri/src/player/track_switch.rs`
  - Line: 63
  - Context: `Unknown`

### IR-020

Locations: 8 file(s)

- File: `src-tauri/src/commands/player/settings.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 811
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 831
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 900
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 912
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 986
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 35
  - Context: `Unknown`

### IR-021

Locations: 1 file(s)

- File: `src-tauri/src/playback_mode/mod.rs`
  - Line: 165
  - Context: `Unknown`

### IR-022

Locations: 4 file(s)

- File: `src-tauri/src/commands/repository.rs`
  - Line: 4
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1859
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2888
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2901
  - Context: `Unknown`

### IR-023

Locations: 1 file(s)

- File: `src-tauri/src/commands/storage/people.rs`
  - Line: 3
  - Context: `Unknown`

### IR-024

Locations: 17 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 1554
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1562
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 4
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 307
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 333
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1299
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1315
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1335
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1374
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1875
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1906
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1933
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2111
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4284
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4326
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4353
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4369
  - Context: `Unknown`

### IR-025

Locations: 2 file(s)

- File: `src/lib/utils/backgroundAudio.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/utils/backgroundAudio.test.ts`
  - Line: 7
  - Context: `Unknown`

### IR-026

Locations: 1 file(s)

- File: `src/lib/utils/pictureInPicture.ts`
  - Line: 4
  - Context: `Unknown`

### IR-027

Locations: 1 file(s)

- File: `src-tauri/src/commands/connectivity.rs`
  - Line: 3
  - Context: `Unknown`

### IR-028

Locations: 1 file(s)

- File: `src-tauri/src/commands/player/remote.rs`
  - Line: 3
  - Context: `Unknown`

### IR-030

Locations: 2 file(s)

- File: `src-tauri/src/commands/catalog.rs`
  - Line: 327
  - Context: `Unknown`
- File: `src-tauri/src/lib.rs`
  - Line: 1675
  - Context: `Unknown`

### IR-031

Locations: 2 file(s)

- File: `src/lib/utils/safeArea.test.ts`
  - Line: 17
  - Context: `Unknown`
- File: `src/lib/utils/safeArea.ts`
  - Line: 4
  - Context: `Unknown`

### IR-033

Locations: 4 file(s)

- File: `src-tauri/src/player/mpv_render.rs`
  - Line: 11
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_render.rs`
  - Line: 60
  - Context: `/ holding a lazily-resolving function pointer. So the address` dlsym`...`
- File: `src-tauri/src/player/mpv_render.rs`
  - Line: 160
  - Context: `impl MpvRenderContext {`
- File: `src-tauri/src/player/video_surface.rs`
  - Line: 29
  - Context: `Unknown`

### DR-001

Locations: 17 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 3059
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3116
  - Context: `Unknown`
- File: `src/lib/player/adapters/rustReportHost.ts`
  - Line: 13
  - Context: `Unknown`
- File: `src/lib/player/adapters/html5Adapter.ts`
  - Line: 20
  - Context: `Unknown`
- File: `src/lib/player/adapters/types.ts`
  - Line: 16
  - Context: `Unknown`
- File: `src/lib/player/index.ts`
  - Line: 15
  - Context: `Unknown`
- File: `src/lib/services/playerEvents.regression.test.ts`
  - Line: 9
  - Context: `Unknown`
- File: `src/lib/services/playerEvents.ts`
  - Line: 8
  - Context: `Unknown`
- File: `src/lib/services/playerEvents.ts`
  - Line: 189
  - Context: `Unknown`
- File: `src/lib/services/playerEvents.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/stores/player.ts`
  - Line: 8
  - Context: `Unknown`
- File: `src/lib/stores/player.ts`
  - Line: 31
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/player/events.rs`
  - Line: 6
  - Context: `Unknown`
- File: `src-tauri/src/player/events.rs`
  - Line: 23
  - Context: `Unknown`
- File: `src-tauri/src/player/state.rs`
  - Line: 7
  - Context: `Unknown`
- File: `src-tauri/src/player/state.rs`
  - Line: 25
  - Context: `Unknown`

### DR-002

Locations: 2 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 2925
  - Context: `export type PlaybackProgress = { itemId: string;`
- File: `src-tauri/src/player/media.rs`
  - Line: 60
  - Context: `Unknown`

### DR-003

Locations: 2 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 3041
  - Context: `Unknown`
- File: `src-tauri/src/player/media.rs`
  - Line: 154
  - Context: `pub enum MediaType {`

### DR-004

Locations: 16 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 211
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 2863
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 1094
  - Context: `Unknown`
- File: `src/lib/player/adapters/webviewAudioAdapter.ts`
  - Line: 19
  - Context: `Unknown`
- File: `src/lib/player/adapters/index.ts`
  - Line: 20
  - Context: `Unknown`
- File: `src/lib/player/adapters/nativeAdapter.ts`
  - Line: 26
  - Context: `Unknown`
- File: `src/lib/player/adapters/nativeAdapter.ts`
  - Line: 59
  - Context: `Unknown`
- File: `src/lib/services/playbackCapabilities.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/services/webviewAudio.ts`
  - Line: 18
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 2177
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 2194
  - Context: `Unknown`
- File: `src-tauri/src/player/backend.rs`
  - Line: 39
  - Context: `pub fn playback_failed<S: Into<String>>(message: S) -> Self {`
- File: `src-tauri/src/player/backend.rs`
  - Line: 251
  - Context: `Unknown`
- File: `src-tauri/src/player/backend.rs`
  - Line: 307
  - Context: `Unknown`
- File: `src-tauri/src/player/webview_audio_backend.rs`
  - Line: 25
  - Context: `Unknown`
- File: `src-tauri/src/player/webview_audio_backend.rs`
  - Line: 184
  - Context: `Unknown`

### DR-005

Locations: 13 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 3385
  - Context: `Unknown`
- File: `src/lib/stores/appState.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src/lib/stores/queue.ts`
  - Line: 7
  - Context: `Unknown`
- File: `src/lib/stores/queue.ts`
  - Line: 108
  - Context: `Unknown`
- File: `src/lib/stores/queue.ts`
  - Line: 113
  - Context: `async function next() {`
- File: `src/lib/stores/queue.ts`
  - Line: 118
  - Context: `async function previous() {`
- File: `src/lib/stores/queue.ts`
  - Line: 123
  - Context: `async function skipTo(index: number) {`
- File: `src/lib/stores/queue.ts`
  - Line: 128
  - Context: `async function toggleShuffle() {`
- File: `src/lib/stores/queue.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/queue.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/player/queue.rs`
  - Line: 8
  - Context: `Unknown`
- File: `src-tauri/src/player/queue.rs`
  - Line: 20
  - Context: `pub enum RepeatMode {`
- File: `src-tauri/src/player/queue.rs`
  - Line: 535
  - Context: `pub enum AddPosition {`

### DR-006

Locations: 2 file(s)

- File: `src/lib/services/preload.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/services/preload.ts`
  - Line: 5
  - Context: `Unknown`

### DR-007

Locations: 11 file(s)

- File: `src/lib/components/library/GenericMediaListPage.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/library/alphabetStrip.ts`
  - Line: 22
  - Context: `Unknown`
- File: `src/lib/components/library/AlphabetScrollBar.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/library/alphabetStrip.test.ts`
  - Line: 18
  - Context: `Unknown`
- File: `src/lib/stores/music.ts`
  - Line: 3
  - Context: `Unknown`
- File: `src/lib/stores/movies.ts`
  - Line: 3
  - Context: `Unknown`
- File: `src/lib/stores/library.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src/lib/stores/tv.ts`
  - Line: 3
  - Context: `Unknown`
- File: `src/routes/library/tv/+page.svelte`
  - Line: 8
  - Context: `Unknown`
- File: `src/routes/library/movies/+page.svelte`
  - Line: 8
  - Context: `Unknown`
- File: `src/routes/library/music/+page.svelte`
  - Line: 1
  - Context: `Unknown`

### DR-009

Locations: 12 file(s)

- File: `src/lib/components/BottomUi.svelte`
  - Line: 23
  - Context: `Unknown`
- File: `src/lib/components/player/AudioPlayer.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/player/Controls.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/player/MiniPlayer.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/player/index.ts`
  - Line: 15
  - Context: `Unknown`
- File: `src/lib/utils/layoutShell.test.ts`
  - Line: 14
  - Context: `Unknown`
- File: `src/lib/utils/layoutShell.ts`
  - Line: 17
  - Context: `Unknown`
- File: `src/lib/stores/playerVisibility.test.ts`
  - Line: 9
  - Context: `Unknown`
- File: `src/lib/stores/appState.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src/lib/stores/player.ts`
  - Line: 8
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/session.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/commands/conversions.rs`
  - Line: 3
  - Context: `Unknown`

### DR-010

Locations: 1 file(s)

- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 1
  - Context: `Unknown`

### DR-011

Locations: 9 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 681
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 697
  - Context: `Unknown`
- File: `src/lib/services/deviceId.test.ts`
  - Line: 7
  - Context: `Unknown`
- File: `src/lib/services/deviceId.ts`
  - Line: 8
  - Context: `Unknown`
- File: `src/lib/services/deviceId.ts`
  - Line: 26
  - Context: `Unknown`
- File: `src/lib/stores/library.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src-tauri/src/commands/device.rs`
  - Line: 4
  - Context: `Unknown`
- File: `src-tauri/src/commands/device.rs`
  - Line: 24
  - Context: `Unknown`
- File: `src-tauri/src/commands/device.rs`
  - Line: 81
  - Context: `Unknown`

### DR-012

Locations: 7 file(s)

- File: `src-tauri/src/commands/storage/mod.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/storage/schema.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/storage/mod.rs`
  - Line: 157
  - Context: `pub fn file_size(&self) -> Option<u64> {`
- File: `src-tauri/src/storage/db_service.rs`
  - Line: 311
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 8
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 3
  - Context: `Unknown`

### DR-013

Locations: 9 file(s)

- File: `src/lib/stores/auth.waitForRepository.test.ts`
  - Line: 13
  - Context: `Unknown`
- File: `src/lib/stores/auth.ts`
  - Line: 81
  - Context: `Unknown`
- File: `src/lib/stores/connectivity.ts`
  - Line: 10
  - Context: `Unknown`
- File: `src/routes/player/[id]/+page.svelte`
  - Line: 362
  - Context: `Unknown`
- File: `src-tauri/src/commands/storage/mod.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 8
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 381
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1
  - Context: `Unknown`

### DR-014

Locations: 4 file(s)

- File: `src/lib/services/syncService.ts`
  - Line: 7
  - Context: `Unknown`
- File: `src/lib/services/syncService.ts`
  - Line: 61
  - Context: `Unknown`
- File: `src/lib/services/syncService.ts`
  - Line: 195
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync.rs`
  - Line: 7
  - Context: `Unknown`

### DR-015

Locations: 5 file(s)

- File: `src/lib/services/preload.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/services/preload.ts`
  - Line: 5
  - Context: `Unknown`
- File: `src/lib/stores/downloads.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src/lib/stores/downloads.test.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 2527
  - Context: `Unknown`

### DR-016

Locations: 3 file(s)

- File: `src/lib/services/imageCache.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/services/imageCache.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src-tauri/src/commands/storage/thumbnails.rs`
  - Line: 3
  - Context: `Unknown`

### DR-017

Locations: 3 file(s)

- File: `src/lib/stores/downloads.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src/lib/stores/downloads.test.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src-tauri/src/commands/offline.rs`
  - Line: 136
  - Context: `Unknown`

### DR-018

Locations: 1 file(s)

- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 2527
  - Context: `Unknown`

### DR-020

Locations: 8 file(s)

- File: `src/lib/stores/queue.ts`
  - Line: 7
  - Context: `Unknown`
- File: `src/lib/stores/queue.ts`
  - Line: 118
  - Context: `async function previous() {`
- File: `src/lib/stores/queue.ts`
  - Line: 133
  - Context: `async function cycleRepeat() {`
- File: `src/lib/stores/queue.ts`
  - Line: 138
  - Context: `async function removeFromQueue(index: number) {`
- File: `src/lib/stores/queue.ts`
  - Line: 143
  - Context: `async function moveInQueue(fromIndex: number, toIndex: number) {`
- File: `src/lib/stores/queue.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/queue.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/player/queue.rs`
  - Line: 20
  - Context: `pub enum RepeatMode {`

### DR-021

Locations: 4 file(s)

- File: `src/lib/components/FavoriteButton.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/services/favorites.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/services/favorites.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2748
  - Context: `Unknown`

### DR-022

Locations: 1 file(s)

- File: `src-tauri/src/commands/storage/mod.rs`
  - Line: 3
  - Context: `Unknown`

### DR-023

Locations: 15 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 191
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 211
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 2863
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 500
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 2530
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 2563
  - Context: `Unknown`
- File: `src/lib/components/player/subtitleTracks.test.ts`
  - Line: 18
  - Context: `Unknown`
- File: `src/lib/components/player/subtitleTracks.ts`
  - Line: 20
  - Context: `Unknown`
- File: `src/lib/player/adapters/html5Adapter.ts`
  - Line: 20
  - Context: `Unknown`
- File: `src/lib/player/adapters/types.ts`
  - Line: 16
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1941
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 2177
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 2194
  - Context: `Unknown`
- File: `src-tauri/src/player/backend.rs`
  - Line: 119
  - Context: `Unknown`

### DR-024

Locations: 17 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 142
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 178
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 211
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 2863
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 408
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 2629
  - Context: `Unknown`
- File: `src/lib/player/adapters/html5Adapter.ts`
  - Line: 20
  - Context: `Unknown`
- File: `src/lib/player/adapters/types.ts`
  - Line: 16
  - Context: `Unknown`
- File: `src-tauri/src/commands/storage/series_prefs.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1629
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1920
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 2177
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 2194
  - Context: `Unknown`
- File: `src-tauri/src/player/backend.rs`
  - Line: 106
  - Context: `Unknown`
- File: `src-tauri/src/player/track_switch.rs`
  - Line: 34
  - Context: `Unknown`
- File: `src-tauri/src/player/track_switch.rs`
  - Line: 63
  - Context: `Unknown`

### DR-025

Locations: 1 file(s)

- File: `src-tauri/src/commands/player/settings.rs`
  - Line: 3
  - Context: `Unknown`

### DR-026

Locations: 6 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 1554
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1562
  - Context: `Unknown`
- File: `src/lib/stores/home.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src/lib/stores/nextEpisode.ts`
  - Line: 9
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 307
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 333
  - Context: `Unknown`

### DR-027

Locations: 2 file(s)

- File: `src/lib/stores/home.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 3
  - Context: `Unknown`

### DR-028

Locations: 20 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 3116
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 1094
  - Context: `Unknown`
- File: `src/lib/player/adapters/rustReportHost.ts`
  - Line: 13
  - Context: `Unknown`
- File: `src/lib/player/adapters/html5Adapter.ts`
  - Line: 20
  - Context: `Unknown`
- File: `src/lib/player/adapters/nativeAdapter.ts`
  - Line: 26
  - Context: `Unknown`
- File: `src/lib/player/adapters/nativeAdapter.ts`
  - Line: 59
  - Context: `Unknown`
- File: `src/lib/player/adapters/types.ts`
  - Line: 16
  - Context: `Unknown`
- File: `src/lib/services/playerEvents.ts`
  - Line: 8
  - Context: `Unknown`
- File: `src/lib/services/playerEvents.ts`
  - Line: 159
  - Context: `Unknown`
- File: `src/lib/services/playerEvents.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/services/playbackReporting.ts`
  - Line: 10
  - Context: `Unknown`
- File: `src/lib/services/playbackReporting.ts`
  - Line: 27
  - Context: `Unknown`
- File: `src/lib/services/playbackReporting.ts`
  - Line: 76
  - Context: `* This function is therefore the *local* half only, which is what its ...`
- File: `src/lib/services/playbackReporting.ts`
  - Line: 107
  - Context: `Unknown`
- File: `src/lib/services/playbackReporting.ts`
  - Line: 143
  - Context: `Unknown`
- File: `src/lib/services/playbackReporting.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/utils/duration.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src-tauri/src/commands/playback_reporting.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/player/events.rs`
  - Line: 6
  - Context: `Unknown`
- File: `src-tauri/src/player/events.rs`
  - Line: 23
  - Context: `Unknown`

### DR-029

Locations: 10 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 3445
  - Context: `export type SessionInfo = { id?: string | null; userId?: string | null...`
- File: `src/lib/components/player/SleepTimerModal.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/services/playerEvents.ts`
  - Line: 269
  - Context: `function handleMediaLoaded(duration: number): void {`
- File: `src/lib/services/playerEvents.ts`
  - Line: 329
  - Context: `Unknown`
- File: `src/lib/stores/sleepTimer.ts`
  - Line: 10
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/timers.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 463
  - Context: `Unknown`
- File: `src-tauri/src/player/sleep_timer.rs`
  - Line: 4
  - Context: `Unknown`
- File: `src-tauri/src/player/sleep_timer.rs`
  - Line: 81
  - Context: `pub fn cancel(&mut self) {`
- File: `src-tauri/src/player/autoplay.rs`
  - Line: 2
  - Context: `Unknown`

### DR-030

Locations: 18 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 251
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 2373
  - Context: `Unknown`
- File: `src/routes/settings/+page.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/settings.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/settings.rs`
  - Line: 50
  - Context: `Unknown`
- File: `src-tauri/src/player/android/mod.rs`
  - Line: 579
  - Context: `Unknown`
- File: `src-tauri/src/player/android/mod.rs`
  - Line: 617
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 35
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 48
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 142
  - Context: `pub fn with_crossfade_clamped(mut self) -> Self {`
- File: `src-tauri/src/settings.rs`
  - Line: 383
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 400
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 424
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 460
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 489
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 516
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 527
  - Context: `Unknown`

### DR-032

Locations: 1 file(s)

- File: `src/lib/components/library/GenericMediaListPage.svelte`
  - Line: 1
  - Context: `Unknown`

### DR-033

Locations: 2 file(s)

- File: `src/lib/components/library/GenericMediaListPage.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/stores/library.ts`
  - Line: 2
  - Context: `Unknown`

### DR-034

Locations: 2 file(s)

- File: `src-tauri/src/commands/player/settings.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 1
  - Context: `Unknown`

### DR-035

Locations: 7 file(s)

- File: `src-tauri/src/commands/player/settings.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/player/android/mod.rs`
  - Line: 579
  - Context: `Unknown`
- File: `src-tauri/src/player/android/mod.rs`
  - Line: 617
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 383
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 400
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 424
  - Context: `Unknown`

### DR-036

Locations: 12 file(s)

- File: `src-tauri/src/commands/player/settings.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 811
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 865
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 931
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 941
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 986
  - Context: `Unknown`
- File: `src-tauri/src/player/android/mod.rs`
  - Line: 579
  - Context: `Unknown`
- File: `src-tauri/src/player/android/mod.rs`
  - Line: 617
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 383
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 400
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 424
  - Context: `Unknown`

### DR-037

Locations: 12 file(s)

- File: `src/lib/components/sessions/SessionPickerModal.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/sessions/CastButton.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/sessions/RemoteControls.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/sessions/SessionsList.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/sessions/SessionCard.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/stores/playbackMode.test.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src/lib/stores/lmsSync.ts`
  - Line: 12
  - Context: `Unknown`
- File: `src/lib/stores/playbackMode.ts`
  - Line: 9
  - Context: `Unknown`
- File: `src/lib/stores/sessions.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src/lib/stores/sessions.test.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/remote.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/commands/sessions.rs`
  - Line: 1
  - Context: `Unknown`

### DR-038

Locations: 13 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 1554
  - Context: `Unknown`
- File: `src/lib/components/home/heroRotation.ts`
  - Line: 8
  - Context: `Unknown`
- File: `src/lib/components/home/HeroBanner.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/home/heroRotation.test.ts`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/utils/heroMix.ts`
  - Line: 8
  - Context: `Unknown`
- File: `src/lib/stores/home.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src/lib/stores/music.ts`
  - Line: 3
  - Context: `Unknown`
- File: `src/lib/stores/movies.ts`
  - Line: 3
  - Context: `Unknown`
- File: `src/lib/stores/tv.ts`
  - Line: 3
  - Context: `Unknown`
- File: `src/routes/library/tv/+page.svelte`
  - Line: 8
  - Context: `Unknown`
- File: `src/routes/library/movies/+page.svelte`
  - Line: 8
  - Context: `Unknown`
- File: `src/routes/library/music/+page.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 307
  - Context: `Unknown`

### DR-039

Locations: 8 file(s)

- File: `src/lib/components/home/Carousel.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/stores/home.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src/lib/stores/music.ts`
  - Line: 3
  - Context: `Unknown`
- File: `src/lib/stores/movies.ts`
  - Line: 3
  - Context: `Unknown`
- File: `src/lib/stores/tv.ts`
  - Line: 3
  - Context: `Unknown`
- File: `src/routes/library/tv/+page.svelte`
  - Line: 8
  - Context: `Unknown`
- File: `src/routes/library/movies/+page.svelte`
  - Line: 8
  - Context: `Unknown`
- File: `src/routes/library/music/+page.svelte`
  - Line: 1
  - Context: `Unknown`

### DR-040

Locations: 3 file(s)

- File: `src/lib/components/library/CrewLinks.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/library/CastSection.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/commands/storage/people.rs`
  - Line: 3
  - Context: `Unknown`

### DR-041

Locations: 2 file(s)

- File: `src/lib/components/library/PersonDetailView.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/commands/storage/people.rs`
  - Line: 3
  - Context: `Unknown`

### DR-042

Locations: 4 file(s)

- File: `src/lib/components/library/LibraryGrid.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/library/MediaCard.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/routes/library/tv/+page.svelte`
  - Line: 8
  - Context: `Unknown`
- File: `src/routes/library/movies/+page.svelte`
  - Line: 8
  - Context: `Unknown`

### DR-043

Locations: 1 file(s)

- File: `src/routes/library/[id]/+page.svelte`
  - Line: 1
  - Context: `Unknown`

### DR-044

Locations: 1 file(s)

- File: `src/lib/components/library/CastSection.svelte`
  - Line: 1
  - Context: `Unknown`

### DR-045

Locations: 1 file(s)

- File: `src/lib/components/BottomNav.svelte`
  - Line: 1
  - Context: `Unknown`

### DR-047

Locations: 15 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 349
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3116
  - Context: `Unknown`
- File: `src/lib/services/nextEpisodeService.ts`
  - Line: 11
  - Context: `Unknown`
- File: `src/lib/services/playerEvents.ts`
  - Line: 8
  - Context: `Unknown`
- File: `src/lib/services/playerEvents.ts`
  - Line: 269
  - Context: `function handleMediaLoaded(duration: number): void {`
- File: `src/lib/services/playerEvents.ts`
  - Line: 374
  - Context: `Unknown`
- File: `src/lib/services/playerEvents.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/services/playbackReporting.ts`
  - Line: 10
  - Context: `Unknown`
- File: `src/lib/services/playbackReporting.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/stores/nextEpisode.ts`
  - Line: 9
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/timers.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/timers.rs`
  - Line: 145
  - Context: `Unknown`
- File: `src-tauri/src/player/events.rs`
  - Line: 6
  - Context: `Unknown`
- File: `src-tauri/src/player/events.rs`
  - Line: 23
  - Context: `Unknown`
- File: `src-tauri/src/player/autoplay.rs`
  - Line: 2
  - Context: `Unknown`

### DR-048

Locations: 6 file(s)

- File: `src/lib/services/nextEpisodeService.ts`
  - Line: 11
  - Context: `Unknown`
- File: `src/lib/services/playerEvents.ts`
  - Line: 374
  - Context: `Unknown`
- File: `src/lib/stores/nextEpisode.ts`
  - Line: 9
  - Context: `Unknown`
- File: `src/routes/settings/+page.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/player/autoplay.rs`
  - Line: 2
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 1
  - Context: `Unknown`

### DR-049

Locations: 1 file(s)

- File: `src-tauri/src/commands/player/timers.rs`
  - Line: 3
  - Context: `Unknown`

### DR-050

Locations: 1 file(s)

- File: `src/lib/components/player/SleepTimerModal.svelte`
  - Line: 1
  - Context: `Unknown`

### DR-051

Locations: 3 file(s)

- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/utils/backgroundAudio.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/utils/backgroundAudio.test.ts`
  - Line: 7
  - Context: `Unknown`

### DR-052

Locations: 20 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 63
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 69
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 349
  - Context: `Unknown`
- File: `src/lib/components/player/backgroundAudioHandoff.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/components/player/backgroundAudioHandoff.ts`
  - Line: 69
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/player/backgroundAudioHandoff.test.ts`
  - Line: 15
  - Context: `Unknown`
- File: `src/lib/player/backgroundAudio.commands.test.ts`
  - Line: 7
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/timers.rs`
  - Line: 145
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 787
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 942
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 296
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 307
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1692
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1706
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1722
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1731
  - Context: `pub fn exit_background_audio(&self) -> f64 {`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1738
  - Context: `pub fn is_background_audio_active(&self) -> bool {`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1753
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1995
  - Context: `Unknown`

### DR-053

Locations: 1 file(s)

- File: `src/lib/utils/pictureInPicture.ts`
  - Line: 4
  - Context: `Unknown`

### DR-054

Locations: 2 file(s)

- File: `src-tauri/src/commands/auth.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/auth/mod.rs`
  - Line: 139
  - Context: `Unknown`

### DR-055

Locations: 2 file(s)

- File: `src/lib/stores/connectivity.ts`
  - Line: 10
  - Context: `Unknown`
- File: `src-tauri/src/commands/connectivity.rs`
  - Line: 3
  - Context: `Unknown`

### DR-056

Locations: 1 file(s)

- File: `src-tauri/src/commands/download/pinning.rs`
  - Line: 3
  - Context: `Unknown`

### DR-057

Locations: 1 file(s)

- File: `src-tauri/src/commands/download/smart_cache.rs`
  - Line: 3
  - Context: `Unknown`

### DR-058

Locations: 1 file(s)

- File: `src-tauri/src/commands/player/remote.rs`
  - Line: 3
  - Context: `Unknown`

### DR-059

Locations: 2 file(s)

- File: `src-tauri/src/commands/playback_mode.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/playback_mode/mod.rs`
  - Line: 165
  - Context: `Unknown`

### DR-060

Locations: 1 file(s)

- File: `src-tauri/src/commands/storage/mod.rs`
  - Line: 3
  - Context: `Unknown`

### DR-061

Locations: 2 file(s)

- File: `src/lib/components/library/EpisodeFocusView.svelte`
  - Line: 7
  - Context: `Unknown`
- File: `src/lib/components/library/EpisodeFocusView.svelte`
  - Line: 278
  - Context: `Unknown`

### DR-062

Locations: 6 file(s)

- File: `src/lib/components/library/EpisodeFocusView.svelte`
  - Line: 7
  - Context: `Unknown`
- File: `src/lib/components/library/EpisodeFocusView.svelte`
  - Line: 278
  - Context: `Unknown`
- File: `src/lib/components/library/EpisodeFocusView.svelte`
  - Line: 383
  - Context: `Unknown`
- File: `src/lib/components/library/episodeStrip.ts`
  - Line: 8
  - Context: `Unknown`
- File: `src/lib/components/library/EpisodeFocusView.test.ts`
  - Line: 7
  - Context: `Unknown`
- File: `src/routes/library/[id]/+page.svelte`
  - Line: 1
  - Context: `Unknown`

### DR-063

Locations: 17 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 1637
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3420
  - Context: `export type SearchResult = { items: MediaItem[]; totalRecordCount: num...`
- File: `src/lib/components/search/HeaderSearch.svelte`
  - Line: 12
  - Context: `Unknown`
- File: `src/lib/utils/searchScope.ts`
  - Line: 9
  - Context: `Unknown`
- File: `src/lib/utils/searchScope.ts`
  - Line: 36
  - Context: `Unknown`
- File: `src/lib/utils/searchScope.ts`
  - Line: 61
  - Context: `Unknown`
- File: `src/lib/utils/searchScope.ts`
  - Line: 91
  - Context: `Unknown`
- File: `src/lib/utils/searchScope.ts`
  - Line: 125
  - Context: `Unknown`
- File: `src/lib/utils/layoutShell.ts`
  - Line: 101
  - Context: `Unknown`
- File: `src/lib/stores/library.ts`
  - Line: 250
  - Context: `Unknown`
- File: `src/routes/library/+page.svelte`
  - Line: 26
  - Context: `Unknown`
- File: `src/routes/search/+page.svelte`
  - Line: 21
  - Context: `Unknown`
- File: `src/routes/search/+page.svelte`
  - Line: 34
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 494
  - Context: `pub struct SearchUpdateEvent {`
- File: `src-tauri/src/repository/types.rs`
  - Line: 385
  - Context: `Unknown`
- File: `src-tauri/src/repository/types.rs`
  - Line: 403
  - Context: `impl SearchScope {`
- File: `src-tauri/src/repository/types.rs`
  - Line: 469
  - Context: `impl SearchOptions {`

### DR-064

Locations: 5 file(s)

- File: `src/lib/components/search/HeaderSearch.svelte`
  - Line: 12
  - Context: `Unknown`
- File: `src/lib/components/search/SearchScopeChips.svelte`
  - Line: 7
  - Context: `Unknown`
- File: `src/lib/utils/searchScope.ts`
  - Line: 125
  - Context: `Unknown`
- File: `src/routes/search/+page.svelte`
  - Line: 34
  - Context: `Unknown`
- File: `src/routes/search/+page.svelte`
  - Line: 62
  - Context: `Unknown`

### DR-065

Locations: 2 file(s)

- File: `src/lib/stores/librarySearchScope.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/stores/library.ts`
  - Line: 250
  - Context: `Unknown`

### DR-066

Locations: 5 file(s)

- File: `src/lib/components/settings/SearchGroupOrderList.svelte`
  - Line: 8
  - Context: `Unknown`
- File: `src/lib/utils/searchScope.ts`
  - Line: 9
  - Context: `Unknown`
- File: `src/lib/utils/searchScope.ts`
  - Line: 234
  - Context: `Unknown`
- File: `src/lib/stores/searchGroupOrder.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/stores/searchGroupOrder.ts`
  - Line: 6
  - Context: `Unknown`

### DR-067

Locations: 3 file(s)

- File: `src/lib/components/search/SearchResults.svelte`
  - Line: 9
  - Context: `Unknown`
- File: `src/lib/utils/searchScope.ts`
  - Line: 9
  - Context: `Unknown`
- File: `src/lib/utils/searchScope.ts`
  - Line: 284
  - Context: `Unknown`

### DR-068

Locations: 1 file(s)

- File: `src/lib/components/library/MediaCard.svelte`
  - Line: 1
  - Context: `Unknown`

### DR-069

Locations: 1 file(s)

- File: `src/lib/components/library/LibraryGrid.svelte`
  - Line: 1
  - Context: `Unknown`

### DR-070

Locations: 1 file(s)

- File: `src/lib/components/library/LibraryGrid.svelte`
  - Line: 1
  - Context: `Unknown`

### DR-074

Locations: 17 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 1231
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1242
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 2677
  - Context: `export type MergedMediaItem = { id: string; title: string; artist: str...`
- File: `src/lib/api/bindings.ts`
  - Line: 2686
  - Context: `export type NetworkStateWrapperArg = { networkType: NetworkType; unmet...`
- File: `src/lib/services/networkType.ts`
  - Line: 10
  - Context: `Unknown`
- File: `src/lib/services/networkType.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/stores/downloads.ts`
  - Line: 84
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 31
  - Context: `pub struct DownloadManagerWrapper(pub Mutex<DownloadManager>);`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 40
  - Context: `pub struct NetworkStateWrapper(pub NetworkStateHandle);`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 83
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 96
  - Context: `pub struct NetworkStateWrapperArg {`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 1744
  - Context: `Unknown`
- File: `src-tauri/src/download/network.rs`
  - Line: 22
  - Context: `Unknown`
- File: `src-tauri/src/download/network.rs`
  - Line: 42
  - Context: `Unknown`
- File: `src-tauri/src/download/network.rs`
  - Line: 70
  - Context: `impl NetworkState {`
- File: `src-tauri/src/download/events.rs`
  - Line: 47
  - Context: `Unknown`
- File: `src-tauri/src/download/cache.rs`
  - Line: 86
  - Context: `Unknown`

### DR-075

Locations: 1 file(s)

- File: `src/lib/components/account/AccountMenu.svelte`
  - Line: 7
  - Context: `Unknown`

### DR-076

Locations: 2 file(s)

- File: `src/lib/components/AppHeader.svelte`
  - Line: 10
  - Context: `Unknown`
- File: `src/lib/utils/layoutShell.ts`
  - Line: 86
  - Context: `Unknown`

### DR-077

Locations: 2 file(s)

- File: `src/lib/stores/viewMode.test.ts`
  - Line: 7
  - Context: `Unknown`
- File: `src/routes/settings/+page.svelte`
  - Line: 1
  - Context: `Unknown`

### DR-078

Locations: 7 file(s)

- File: `src/lib/components/library/MediaCard.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/services/offlineCatalog.reload.test.ts`
  - Line: 11
  - Context: `Unknown`
- File: `src/lib/services/offlineCatalog.ts`
  - Line: 14
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 24
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 43
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 3108
  - Context: `Unknown`

### DR-079

Locations: 2 file(s)

- File: `src/lib/stores/connectivity.ts`
  - Line: 10
  - Context: `Unknown`
- File: `src/lib/stores/connectivity.ts`
  - Line: 250
  - Context: `Unknown`

### DR-080

Locations: 2 file(s)

- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 8
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 43
  - Context: `Unknown`

### DR-081

Locations: 2 file(s)

- File: `src/lib/components/downloads/DownloadedBrowse.svelte`
  - Line: 10
  - Context: `Unknown`
- File: `src/routes/downloads/+page.svelte`
  - Line: 14
  - Context: `Unknown`

### DR-082

Locations: 21 file(s)

- File: `src/lib/api/repository-client.ts`
  - Line: 100
  - Context: `Unknown`
- File: `src/lib/api/repository-client.ts`
  - Line: 108
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1512
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1520
  - Context: `Unknown`
- File: `src/lib/components/downloads/DownloadedBrowse.svelte`
  - Line: 10
  - Context: `Unknown`
- File: `src/lib/services/downloadedCatalog.ts`
  - Line: 13
  - Context: `Unknown`
- File: `src/routes/downloads/+page.svelte`
  - Line: 14
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 222
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 237
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 274
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 289
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 882
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 891
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 918
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 1024
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 3787
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 3809
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 3919
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 3965
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 3986
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 4005
  - Context: `Unknown`

### DR-083

Locations: 19 file(s)

- File: `src/lib/api/repository-client.ts`
  - Line: 108
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1014
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1520
  - Context: `Unknown`
- File: `src/lib/components/library/MediaCard.svelte`
  - Line: 29
  - Context: `Unknown`
- File: `src/lib/components/library/MediaCard.svelte`
  - Line: 34
  - Context: `Unknown`
- File: `src/lib/components/downloads/DownloadedBrowse.svelte`
  - Line: 10
  - Context: `Unknown`
- File: `src/lib/services/downloadedCatalog.ts`
  - Line: 13
  - Context: `Unknown`
- File: `src/lib/services/downloadedCatalog.ts`
  - Line: 96
  - Context: `function sizeOf(itemId: string): number {`
- File: `src/routes/downloads/+page.svelte`
  - Line: 14
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 2425
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 237
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 274
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 891
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 918
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 3787
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 3809
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 3919
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 3965
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 4066
  - Context: `Unknown`

### DR-084

Locations: 2 file(s)

- File: `src/lib/stores/downloads.test.ts`
  - Line: 316
  - Context: `Unknown`
- File: `src/routes/downloads/+page.svelte`
  - Line: 14
  - Context: `Unknown`

### DR-085

Locations: 15 file(s)

- File: `src/lib/api/repository-client.ts`
  - Line: 116
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1528
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 2298
  - Context: `Unknown`
- File: `src/lib/components/library/LibraryGrid.svelte`
  - Line: 17
  - Context: `Unknown`
- File: `src/lib/components/library/MediaCard.svelte`
  - Line: 23
  - Context: `Unknown`
- File: `src/lib/components/downloads/DownloadedBrowse.svelte`
  - Line: 10
  - Context: `Unknown`
- File: `src/lib/services/downloadedCatalog.ts`
  - Line: 13
  - Context: `Unknown`
- File: `src/lib/utils/formatBytes.ts`
  - Line: 8
  - Context: `Unknown`
- File: `src/lib/utils/formatBytes.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/routes/downloads/+page.svelte`
  - Line: 14
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 254
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 296
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 1070
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 4027
  - Context: `Unknown`
- File: `src-tauri/src/repository/types.rs`
  - Line: 303
  - Context: `Unknown`

### DR-086

Locations: 1 file(s)

- File: `src/routes/settings/+page.svelte`
  - Line: 1
  - Context: `Unknown`

### DR-087

Locations: 3 file(s)

- File: `src/lib/components/library/MediaCard.svelte`
  - Line: 42
  - Context: `Unknown`
- File: `src/routes/+page.svelte`
  - Line: 95
  - Context: `Unknown`
- File: `src/routes/+page.svelte`
  - Line: 119
  - Context: `Unknown`

### DR-088

Locations: 2 file(s)

- File: `src/lib/services/skipReporting.test.ts`
  - Line: 9
  - Context: `Unknown`
- File: `src/lib/services/skipReporting.ts`
  - Line: 14
  - Context: `Unknown`

### DR-089

Locations: 4 file(s)

- File: `src/lib/stores/continueWatchingFilter.test.ts`
  - Line: 12
  - Context: `Unknown`
- File: `src/lib/stores/home.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src/lib/stores/continueWatchingFilter.ts`
  - Line: 15
  - Context: `Unknown`
- File: `src/lib/stores/tv.ts`
  - Line: 3
  - Context: `Unknown`

### DR-090

Locations: 1 file(s)

- File: `src-tauri/src/domain/search_rank.rs`
  - Line: 110
  - Context: `Unknown`

### DR-091

Locations: 2 file(s)

- File: `src/lib/utils/searchScope.ts`
  - Line: 149
  - Context: `export type SearchGroupId =`
- File: `src/lib/utils/searchScope.ts`
  - Line: 215
  - Context: `Unknown`

### DR-092

Locations: 2 file(s)

- File: `src/lib/components/player/tapGestures.ts`
  - Line: 21
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 1
  - Context: `Unknown`

### DR-093

Locations: 9 file(s)

- File: `scripts/extract-traces.ts`
  - Line: 57
  - Context: `Unknown`
- File: `scripts/extract-traces.ts`
  - Line: 309
  - Context: `Unknown`
- File: `scripts/extract-traces.ts`
  - Line: 342
  - Context: `Unknown`
- File: `scripts/extract-traces.ts`
  - Line: 385
  - Context: `Unknown`
- File: `scripts/extract-traces.ts`
  - Line: 407
  - Context: `Unknown`
- File: `scripts/extract-traces.ts`
  - Line: 444
  - Context: `Unknown`
- File: `scripts/extract-traces.ts`
  - Line: 454
  - Context: `export const MATRIX_LINK_PREFIX = "../";`
- File: `scripts/extract-traces.ts`
  - Line: 537
  - Context: `function generateJson(data: TracesData): string {`
- File: `scripts/extract-traces.ts`
  - Line: 590
  - Context: `Unknown`

### DR-095

Locations: 1 file(s)

- File: `src/lib/components/player/tapGestures.ts`
  - Line: 21
  - Context: `Unknown`

### DR-096

Locations: 1 file(s)

- File: `src/lib/player/adapters/html5Adapter.ts`
  - Line: 20
  - Context: `Unknown`

### DR-097

Locations: 9 file(s)

- File: `src/lib/player/transport.authority.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/player/index.ts`
  - Line: 15
  - Context: `Unknown`
- File: `src/lib/services/playerEvents.ts`
  - Line: 8
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 307
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 701
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 709
  - Context: `pub fn is_html5_active(&self) -> bool {`
- File: `src-tauri/src/player/mod.rs`
  - Line: 753
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1706
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1722
  - Context: `Unknown`

### DR-098

Locations: 4 file(s)

- File: `src/lib/components/player/VideoPlayer.tapSurface.test.ts`
  - Line: 5
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.touchScrub.test.ts`
  - Line: 21
  - Context: `Unknown`
- File: `src/lib/components/player/tapGestures.ts`
  - Line: 21
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 1
  - Context: `Unknown`

### DR-099

Locations: 2 file(s)

- File: `src/lib/components/player/VideoPlayer.touchScrub.test.ts`
  - Line: 21
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 1
  - Context: `Unknown`

### DR-100

Locations: 2 file(s)

- File: `src/lib/components/player/playerSurface.test.ts`
  - Line: 13
  - Context: `Unknown`
- File: `src/lib/components/player/playerSurface.ts`
  - Line: 8
  - Context: `Unknown`

### DR-101

Locations: 8 file(s)

- File: `src/lib/api/repository-client.ts`
  - Line: 151
  - Context: `Unknown`
- File: `src/lib/api/repository-client.ts`
  - Line: 161
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1575
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1588
  - Context: `Unknown`
- File: `src/routes/library/[id]/+page.svelte`
  - Line: 209
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 356
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 377
  - Context: `Unknown`
- File: `src-tauri/src/repository/series_progress.rs`
  - Line: 12
  - Context: `Unknown`

### DR-102

Locations: 7 file(s)

- File: `src/lib/components/library/SeasonSection.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/library/EpisodeRow.svelte`
  - Line: 17
  - Context: `Unknown`
- File: `src/lib/components/library/seriesNavigation.ts`
  - Line: 13
  - Context: `Unknown`
- File: `src/lib/components/library/seriesNavigation.test.ts`
  - Line: 1
  - Context: `Unknown`
- File: `src/routes/library/[id]/+page.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/routes/library/[id]/+page.svelte`
  - Line: 209
  - Context: `Unknown`
- File: `src/routes/library/[id]/+page.svelte`
  - Line: 319
  - Context: `async function handlePlayAll() {`

### DR-103

Locations: 6 file(s)

- File: `src/lib/components/library/SeasonSection.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/library/seriesNavigation.ts`
  - Line: 13
  - Context: `Unknown`
- File: `src/lib/components/library/seriesNavigation.test.ts`
  - Line: 1
  - Context: `Unknown`
- File: `src/routes/library/tv/+page.svelte`
  - Line: 8
  - Context: `Unknown`
- File: `src/routes/library/[id]/+page.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/routes/library/[id]/+page.svelte`
  - Line: 125
  - Context: `Unknown`

### DR-104

Locations: 1 file(s)

- File: `src/lib/components/library/episodeStrip.ts`
  - Line: 8
  - Context: `Unknown`

### DR-105

Locations: 10 file(s)

- File: `src/lib/components/library/GenericMediaListPage.svelte`
  - Line: 48
  - Context: `Unknown`
- File: `src/lib/components/library/GenericGenreBrowser.svelte`
  - Line: 45
  - Context: `Unknown`
- File: `src/lib/components/library/LibraryViewTabs.svelte`
  - Line: 8
  - Context: `Unknown`
- File: `src/lib/utils/libraryView.ts`
  - Line: 9
  - Context: `Unknown`
- File: `src/routes/library/tv/+page.svelte`
  - Line: 8
  - Context: `Unknown`
- File: `src/routes/library/tv/shows/+page.ts`
  - Line: 6
  - Context: `Unknown`
- File: `src/routes/library/movies/all/+page.ts`
  - Line: 6
  - Context: `Unknown`
- File: `src/routes/library/movies/genres/+page.ts`
  - Line: 6
  - Context: `Unknown`
- File: `src/routes/library/movies/+page.svelte`
  - Line: 8
  - Context: `Unknown`
- File: `src/routes/library/shows/genres/+page.ts`
  - Line: 6
  - Context: `Unknown`

### DR-106

Locations: 7 file(s)

- File: `src/lib/api/repository-client.ts`
  - Line: 172
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1602
  - Context: `Unknown`
- File: `src/lib/components/library/ClearHistoryButton.svelte`
  - Line: 9
  - Context: `Unknown`
- File: `src/lib/components/library/SeasonSection.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 399
  - Context: `Unknown`
- File: `src-tauri/src/repository/mod.rs`
  - Line: 250
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2794
  - Context: `Unknown`

### DR-107

Locations: 2 file(s)

- File: `src/lib/components/library/SeasonSection.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/library/SeasonSection.svelte`
  - Line: 20
  - Context: `Unknown`

### DR-108

Locations: 5 file(s)

- File: `src-tauri/src/repository/offline.rs`
  - Line: 62
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 2630
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 2670
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 3388
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 3444
  - Context: `Unknown`

### DR-109

Locations: 6 file(s)

- File: `src-tauri/src/commands/repository.rs`
  - Line: 51
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 164
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 281
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 327
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 764
  - Context: `Unknown`
- File: `src-tauri/src/lib.rs`
  - Line: 1675
  - Context: `Unknown`

### DR-110

Locations: 8 file(s)

- File: `src-tauri/src/commands/storage/people.rs`
  - Line: 56
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 164
  - Context: `Unknown`
- File: `src-tauri/src/storage/schema.rs`
  - Line: 758
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 64
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 300
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 525
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 3226
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 3335
  - Context: `Unknown`

### DR-111

Locations: 5 file(s)

- File: `src-tauri/src/commands/storage/people.rs`
  - Line: 56
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 96
  - Context: `Unknown`
- File: `src-tauri/src/storage/schema.rs`
  - Line: 770
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 357
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 3169
  - Context: `Unknown`

### DR-112

Locations: 6 file(s)

- File: `src/lib/components/BottomUi.svelte`
  - Line: 23
  - Context: `Unknown`
- File: `src/lib/utils/layoutShell.test.ts`
  - Line: 158
  - Context: `Unknown`
- File: `src/lib/utils/safeArea.test.ts`
  - Line: 17
  - Context: `Unknown`
- File: `src/lib/utils/safeArea.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/utils/layoutShell.ts`
  - Line: 127
  - Context: `Unknown`
- File: `src/routes/+layout.svelte`
  - Line: 269
  - Context: `Unknown`

### DR-113

Locations: 4 file(s)

- File: `src-tauri/src/repository/online.rs`
  - Line: 1167
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1580
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4428
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4459
  - Context: `Unknown`

### DR-114

Locations: 2 file(s)

- File: `src-tauri/src/repository/offline.rs`
  - Line: 716
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 4712
  - Context: `Unknown`

### DR-115

Locations: 16 file(s)

- File: `src/lib/api/repository-client.ts`
  - Line: 384
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1764
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 927
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 964
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 214
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 230
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 1050
  - Context: `Unknown`
- File: `src-tauri/src/repository/mod.rs`
  - Line: 238
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 2096
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 4452
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 4487
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1455
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2724
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4017
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4039
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4048
  - Context: `Unknown`

### DR-116

Locations: 11 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 2426
  - Context: `export type GetItemsOptions = { startIndex?: number | null; limit?: nu...`
- File: `src/lib/components/library/GenericMediaListPage.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/library/GenericMediaListPage.svelte`
  - Line: 152
  - Context: `Unknown`
- File: `src/lib/components/library/GenericMediaListPage.svelte`
  - Line: 168
  - Context: `function handleSearch(query: string) {`
- File: `src/lib/components/library/GenericMediaListPage.svelte`
  - Line: 281
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 1322
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 4532
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1199
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1273
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4070
  - Context: `Unknown`
- File: `src-tauri/src/repository/types.rs`
  - Line: 342
  - Context: `Unknown`

### DR-117

Locations: 8 file(s)

- File: `src/lib/utils/favoritesView.test.ts`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/utils/favoritesView.ts`
  - Line: 8
  - Context: `Unknown`
- File: `src/lib/stores/favorites.test.ts`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/stores/favorites.ts`
  - Line: 79
  - Context: `Unknown`
- File: `src/routes/library/favorites/+page.svelte`
  - Line: 9
  - Context: `Unknown`
- File: `src/routes/library/favorites/+page.svelte`
  - Line: 44
  - Context: `Unknown`
- File: `src/routes/library/+page.svelte`
  - Line: 226
  - Context: `Unknown`
- File: `src/routes/library/+page.svelte`
  - Line: 289
  - Context: `Unknown`

### DR-118

Locations: 5 file(s)

- File: `src/lib/stores/home.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src/lib/stores/home.ts`
  - Line: 18
  - Context: `Unknown`
- File: `src/lib/stores/home.ts`
  - Line: 64
  - Context: `Unknown`
- File: `src/routes/+page.svelte`
  - Line: 183
  - Context: `Unknown`
- File: `src/routes/+page.svelte`
  - Line: 292
  - Context: `Unknown`

### DR-119

Locations: 15 file(s)

- File: `src/lib/components/library/PlaylistDetailView.svelte`
  - Line: 229
  - Context: `Unknown`
- File: `src/lib/components/library/EpisodeFocusView.svelte`
  - Line: 239
  - Context: `Unknown`
- File: `src/lib/components/library/MediaCard.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/library/MediaCard.svelte`
  - Line: 48
  - Context: `Unknown`
- File: `src/lib/components/library/MediaCard.svelte`
  - Line: 154
  - Context: `Unknown`
- File: `src/lib/components/library/MediaCard.svelte`
  - Line: 329
  - Context: `Unknown`
- File: `src/lib/components/library/ArtistDetailView.svelte`
  - Line: 138
  - Context: `Unknown`
- File: `src/lib/components/FavoriteButton.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/services/favorites.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src/lib/services/favorites.ts`
  - Line: 38
  - Context: `Unknown`
- File: `src/lib/stores/favorites.test.ts`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/stores/favorites.ts`
  - Line: 8
  - Context: `Unknown`
- File: `src/lib/stores/favorites.ts`
  - Line: 55
  - Context: `Unknown`
- File: `src/lib/stores/favorites.ts`
  - Line: 79
  - Context: `Unknown`
- File: `src/routes/library/[id]/+page.svelte`
  - Line: 612
  - Context: `Unknown`

### DR-120

Locations: 19 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 1764
  - Context: `Unknown`
- File: `src/routes/library/favorites/+page.svelte`
  - Line: 76
  - Context: `Unknown`
- File: `src/routes/+layout.svelte`
  - Line: 150
  - Context: `Unknown`
- File: `src-tauri/src/commands/favorites.rs`
  - Line: 12
  - Context: `Unknown`
- File: `src-tauri/src/commands/favorites.rs`
  - Line: 78
  - Context: `Unknown`
- File: `src-tauri/src/commands/favorites.rs`
  - Line: 142
  - Context: `Unknown`
- File: `src-tauri/src/commands/favorites.rs`
  - Line: 286
  - Context: `Unknown`
- File: `src-tauri/src/commands/favorites.rs`
  - Line: 320
  - Context: `Unknown`
- File: `src-tauri/src/commands/favorites.rs`
  - Line: 340
  - Context: `Unknown`
- File: `src-tauri/src/commands/favorites.rs`
  - Line: 356
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 887
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 893
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 908
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 927
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 1081
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 1106
  - Context: `Unknown`
- File: `src-tauri/src/lib.rs`
  - Line: 1682
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 47
  - Context: `pub fn new(online: OnlineRepository, offline: OfflineRepository) -> Se...`
- File: `src-tauri/src/repository/online.rs`
  - Line: 195
  - Context: `impl OnlineRepository {`

### DR-121

Locations: 5 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 3320
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 361
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 111
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 241
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 363
  - Context: `Unknown`

### DR-123

Locations: 6 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 408
  - Context: `Unknown`
- File: `src/routes/player/[id]/+page.svelte`
  - Line: 406
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 547
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 599
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 3321
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2612
  - Context: `Unknown`

### DR-126

Locations: 2 file(s)

- File: `src-tauri/src/download/cache.rs`
  - Line: 320
  - Context: `Unknown`
- File: `src-tauri/src/download/cache.rs`
  - Line: 550
  - Context: `Unknown`

### DR-127

Locations: 6 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 2205
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 359
  - Context: `Unknown`
- File: `src-tauri/src/storage/schema.rs`
  - Line: 814
  - Context: `Unknown`
- File: `src-tauri/src/download/cache.rs`
  - Line: 33
  - Context: `Unknown`
- File: `src-tauri/src/download/cache.rs`
  - Line: 220
  - Context: `Unknown`
- File: `src-tauri/src/download/cache.rs`
  - Line: 428
  - Context: `Unknown`

### DR-128

Locations: 3 file(s)

- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 476
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 805
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 3237
  - Context: `Unknown`

### DR-129

Locations: 9 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 349
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/timers.rs`
  - Line: 145
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 316
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1810
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1843
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1900
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1931
  - Context: `Unknown`
- File: `src-tauri/src/player/stream_end.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/player/stream_end.rs`
  - Line: 54
  - Context: `Unknown`

### DR-130

Locations: 7 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 372
  - Context: `Unknown`
- File: `src/lib/services/playerEvents.regression.test.ts`
  - Line: 155
  - Context: `Unknown`
- File: `src/lib/services/playerEvents.ts`
  - Line: 292
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/timers.rs`
  - Line: 299
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 717
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 726
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1900
  - Context: `Unknown`

### DR-131

Locations: 16 file(s)

- File: `src-tauri/src/commands/sync.rs`
  - Line: 7
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 14
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 76
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 236
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 427
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 606
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 672
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 702
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 736
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 765
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 811
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 992
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 1007
  - Context: `Unknown`
- File: `src-tauri/src/lib.rs`
  - Line: 1689
  - Context: `Unknown`
- File: `src-tauri/src/repository/mod.rs`
  - Line: 258
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2839
  - Context: `Unknown`

### DR-132

Locations: 12 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 1377
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3654
  - Context: `export type SyncQueueItem = { id: number; userId: string; operation: s...`
- File: `src/lib/components/sync/PendingSyncList.svelte`
  - Line: 12
  - Context: `Unknown`
- File: `src/lib/components/sync/PendingSyncModal.svelte`
  - Line: 5
  - Context: `Unknown`
- File: `src/lib/services/pendingSync.logic.test.ts`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/services/pendingSync.logic.ts`
  - Line: 9
  - Context: `Unknown`
- File: `src/routes/settings/+page.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/routes/settings/+page.svelte`
  - Line: 1052
  - Context: `Unknown`
- File: `src/routes/+layout.svelte`
  - Line: 303
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync.rs`
  - Line: 7
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync.rs`
  - Line: 33
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 474
  - Context: `Unknown`

### DR-133

Locations: 2 file(s)

- File: `src/lib/player/localSource.ts`
  - Line: 17
  - Context: `Unknown`
- File: `src/routes/player/[id]/+page.svelte`
  - Line: 315
  - Context: `Unknown`

### DR-134

Locations: 2 file(s)

- File: `src/lib/services/imageCache.ts`
  - Line: 50
  - Context: `Unknown`
- File: `src-tauri/src/lib.rs`
  - Line: 1170
  - Context: `Unknown`

### DR-135

Locations: 5 file(s)

- File: `src-tauri/src/commands/catalog.rs`
  - Line: 114
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 551
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 1001
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 1039
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 1062
  - Context: `Unknown`

### DR-136

Locations: 3 file(s)

- File: `src-tauri/src/commands/catalog.rs`
  - Line: 490
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 689
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 1090
  - Context: `Unknown`

### DR-137

Locations: 18 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 1054
  - Context: `Unknown`
- File: `src/routes/player/[id]/+page.svelte`
  - Line: 329
  - Context: `Unknown`
- File: `src/routes/player/[id]/+page.svelte`
  - Line: 406
  - Context: `Unknown`
- File: `src-tauri/src/commands/storage/mod.rs`
  - Line: 93
  - Context: `Unknown`
- File: `src-tauri/src/lib.rs`
  - Line: 1170
  - Context: `Unknown`
- File: `src-tauri/src/lib.rs`
  - Line: 1592
  - Context: `Unknown`
- File: `src-tauri/src/media_server.rs`
  - Line: 27
  - Context: `Unknown`
- File: `src-tauri/src/media_server.rs`
  - Line: 70
  - Context: `pub fn url_for(&self, path: &str) -> String {`
- File: `src-tauri/src/media_server.rs`
  - Line: 282
  - Context: `Unknown`
- File: `src-tauri/src/media_server.rs`
  - Line: 327
  - Context: `Unknown`
- File: `src-tauri/src/media_server.rs`
  - Line: 398
  - Context: `Unknown`
- File: `src-tauri/src/media_server.rs`
  - Line: 449
  - Context: `Unknown`
- File: `src-tauri/src/media_server.rs`
  - Line: 458
  - Context: `Unknown`
- File: `src-tauri/src/media_server.rs`
  - Line: 473
  - Context: `Unknown`
- File: `src-tauri/src/media_server.rs`
  - Line: 508
  - Context: `Unknown`
- File: `src-tauri/src/media_server.rs`
  - Line: 515
  - Context: `Unknown`
- File: `src-tauri/src/media_server.rs`
  - Line: 540
  - Context: `Unknown`
- File: `src-tauri/src/media_server.rs`
  - Line: 567
  - Context: `Unknown`

### DR-140

Locations: 3 file(s)

- File: `src-tauri/src/repository/online.rs`
  - Line: 591
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 700
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3427
  - Context: `Unknown`

### DR-141

Locations: 2 file(s)

- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 29
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 40
  - Context: `pub fn clamp_max_audio_channels(reported: Option<u32>) -> u32 {`

### DR-142

Locations: 11 file(s)

- File: `src/lib/components/library/EpisodeFocusView.svelte`
  - Line: 7
  - Context: `Unknown`
- File: `src/lib/components/library/EpisodeFocusView.svelte`
  - Line: 239
  - Context: `Unknown`
- File: `src/lib/components/library/EpisodeFocusView.svelte`
  - Line: 383
  - Context: `Unknown`
- File: `src/lib/components/library/EpisodeFocusView.test.ts`
  - Line: 7
  - Context: `Unknown`
- File: `src/lib/components/library/seriesNavigation.ts`
  - Line: 13
  - Context: `Unknown`
- File: `src/lib/components/library/seriesNavigation.test.ts`
  - Line: 1
  - Context: `Unknown`
- File: `src/routes/library/[id]/+page.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/routes/library/[id]/+page.svelte`
  - Line: 140
  - Context: `Unknown`
- File: `src/routes/library/[id]/+page.svelte`
  - Line: 237
  - Context: `Unknown`
- File: `src/routes/library/[id]/+page.svelte`
  - Line: 460
  - Context: `Unknown`
- File: `src/routes/search/+page.svelte`
  - Line: 97
  - Context: `Unknown`

### DR-143

Locations: 11 file(s)

- File: `src/lib/components/library/GenericMediaListPage.svelte`
  - Line: 101
  - Context: `Unknown`
- File: `src/lib/components/library/GenericGenreBrowser.svelte`
  - Line: 67
  - Context: `async function reloadGenreBrowse() {`
- File: `src/lib/services/offlineCatalog.reload.test.ts`
  - Line: 11
  - Context: `Unknown`
- File: `src/lib/services/offlineCatalog.ts`
  - Line: 46
  - Context: `Unknown`
- File: `src/lib/composables/useOfflineFilterReload.test.ts`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/composables/useOfflineFilterReload.ts`
  - Line: 26
  - Context: `Unknown`
- File: `src/routes/library/favorites/+page.svelte`
  - Line: 88
  - Context: `Unknown`
- File: `src/routes/library/tv/+page.svelte`
  - Line: 74
  - Context: `async function load() {`
- File: `src/routes/library/movies/+page.svelte`
  - Line: 73
  - Context: `async function load() {`
- File: `src/routes/library/music/+page.svelte`
  - Line: 71
  - Context: `async function load() {`
- File: `src/routes/library/[id]/+page.svelte`
  - Line: 101
  - Context: `Unknown`

### DR-147

Locations: 5 file(s)

- File: `src/lib/components/search/HeaderSearch.test.ts`
  - Line: 9
  - Context: `Unknown`
- File: `src/lib/components/search/HeaderSearch.svelte`
  - Line: 12
  - Context: `Unknown`
- File: `src/routes/search/+page.svelte`
  - Line: 34
  - Context: `Unknown`
- File: `src/routes/search/+page.svelte`
  - Line: 62
  - Context: `Unknown`
- File: `src/routes/search/searchPage.test.ts`
  - Line: 14
  - Context: `Unknown`

### DR-148

Locations: 2 file(s)

- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 213
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4672
  - Context: `Unknown`

### DR-149

Locations: 3 file(s)

- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 325
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 347
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 366
  - Context: `Unknown`

### DR-150

Locations: 4 file(s)

- File: `src/lib/player/adapters/adapterSelection.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/player/adapters/index.ts`
  - Line: 20
  - Context: `Unknown`
- File: `src/lib/utils/videoSurface.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/stores/nativeVideo.ts`
  - Line: 3
  - Context: `Unknown`

### DR-151

Locations: 1 file(s)

- File: `src/lib/utils/videoSurface.ts`
  - Line: 4
  - Context: `Unknown`

### DR-152

Locations: 2 file(s)

- File: `src/lib/services/playbackCapabilities.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/stores/nativeVideo.ts`
  - Line: 3
  - Context: `Unknown`

### DR-153

Locations: 1 file(s)

- File: `scripts/set-version.test.ts`
  - Line: 4
  - Context: `Unknown`

### DR-154

Locations: 7 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 1732
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 380
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 856
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 885
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 915
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 954
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 764
  - Context: `Unknown`

### DR-155

Locations: 5 file(s)

- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 426
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 653
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 716
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 4799
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 4977
  - Context: `Unknown`

### DR-156

Locations: 2 file(s)

- File: `src/lib/utils/scrollContainer.ts`
  - Line: 23
  - Context: `Unknown`
- File: `src/lib/utils/scrollRestore.ts`
  - Line: 26
  - Context: `Unknown`

### DR-157

Locations: 1 file(s)

- File: `src/lib/utils/immersive.ts`
  - Line: 4
  - Context: `Unknown`

### DR-158

Locations: 6 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 871
  - Context: `Unknown`
- File: `src/lib/components/library/WatchedToggleButton.svelte`
  - Line: 13
  - Context: `Unknown`
- File: `src/lib/services/syncService.ts`
  - Line: 121
  - Context: `Unknown`
- File: `src-tauri/src/commands/storage/mod.rs`
  - Line: 915
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 1047
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 1064
  - Context: `Unknown`

### DR-159

Locations: 3 file(s)

- File: `src-tauri/src/player/mod.rs`
  - Line: 944
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4561
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4604
  - Context: `Unknown`

### DR-160

Locations: 1 file(s)

- File: `src/lib/utils/pictureInPicture.ts`
  - Line: 111
  - Context: `Unknown`

### DR-162

Locations: 39 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 270
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 295
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3510
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3596
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3805
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 344
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 929
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 2487
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 3091
  - Context: `Unknown`
- File: `src/lib/player/index.ts`
  - Line: 202
  - Context: `Unknown`
- File: `src/routes/settings/+page.svelte`
  - Line: 86
  - Context: `Unknown`
- File: `src/routes/settings/+page.svelte`
  - Line: 425
  - Context: `function handleEpisodeLimitChange(value: number) {`
- File: `src/routes/settings/+page.svelte`
  - Line: 925
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 369
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1793
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/settings.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/settings.rs`
  - Line: 25
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/settings.rs`
  - Line: 85
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/settings.rs`
  - Line: 107
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/settings.rs`
  - Line: 122
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/settings.rs`
  - Line: 158
  - Context: `Unknown`
- File: `src-tauri/src/lib.rs`
  - Line: 1549
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 30
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 56
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 67
  - Context: `pub fn set_streaming_quality(quality: StreamingQuality) {`
- File: `src-tauri/src/repository/online.rs`
  - Line: 122
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 591
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 737
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 821
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2436
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3321
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3349
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3371
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3394
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 167
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 302
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 653
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 689
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 716
  - Context: `Unknown`

### DR-167

Locations: 3 file(s)

- File: `src-tauri/src/repository/offline.rs`
  - Line: 100
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 882
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 3849
  - Context: `Unknown`

### DR-168

Locations: 6 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 964
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 977
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 1226
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 1260
  - Context: `Unknown`
- File: `src-tauri/src/download/stop.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/download/worker.rs`
  - Line: 42
  - Context: `Unknown`

### DR-169

Locations: 2 file(s)

- File: `src-tauri/src/download/worker.rs`
  - Line: 247
  - Context: `/ One function so the writer and the cleaners cannot disagree again.`
- File: `src-tauri/src/download/worker.rs`
  - Line: 336
  - Context: `Unknown`

### DR-170

Locations: 2 file(s)

- File: `src-tauri/src/download/worker.rs`
  - Line: 219
  - Context: `Unknown`
- File: `src-tauri/src/download/worker.rs`
  - Line: 313
  - Context: `Unknown`

### DR-171

Locations: 9 file(s)

- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 366
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 546
  - Context: `Unknown`
- File: `src-tauri/src/repository/mod.rs`
  - Line: 215
  - Context: `Unknown`
- File: `src-tauri/src/repository/mod.rs`
  - Line: 350
  - Context: `Unknown`
- File: `src-tauri/src/repository/mod.rs`
  - Line: 372
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2689
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3909
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3941
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3967
  - Context: `Unknown`

### DR-173

Locations: 17 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 922
  - Context: `Unknown`
- File: `src/lib/stores/downloads.ts`
  - Line: 210
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 442
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 473
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 524
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 546
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 679
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 3238
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 3303
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 3334
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 3367
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 3396
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 3410
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 3437
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 916
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 957
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 153
  - Context: `Unknown`

### DR-174

Locations: 8 file(s)

- File: `src/lib/components/library/libraryMosaic.ts`
  - Line: 12
  - Context: `Unknown`
- File: `src/lib/components/library/MosaicGrid.svelte`
  - Line: 14
  - Context: `Unknown`
- File: `src/lib/components/library/mosaic.ts`
  - Line: 12
  - Context: `Unknown`
- File: `src/lib/components/library/MosaicTile.svelte`
  - Line: 10
  - Context: `Unknown`
- File: `src/lib/components/common/CachedImage.svelte`
  - Line: 18
  - Context: `Unknown`
- File: `src/routes/library/+page.svelte`
  - Line: 53
  - Context: `Unknown`
- File: `src/routes/library/+page.svelte`
  - Line: 289
  - Context: `Unknown`
- File: `src/routes/+page.svelte`
  - Line: 145
  - Context: `Unknown`

### DR-175

Locations: 9 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 2467
  - Context: `export type Library = { id: string; name: string; collectionType: stri...`
- File: `src/lib/components/library/libraryMosaic.ts`
  - Line: 12
  - Context: `Unknown`
- File: `src/lib/utils/favoritesView.ts`
  - Line: 44
  - Context: `Unknown`
- File: `src/routes/library/+page.svelte`
  - Line: 53
  - Context: `Unknown`
- File: `src-tauri/src/repository/types.rs`
  - Line: 47
  - Context: `Unknown`
- File: `src-tauri/src/repository/types.rs`
  - Line: 434
  - Context: `Unknown`
- File: `src-tauri/src/repository/types.rs`
  - Line: 761
  - Context: `Unknown`
- File: `src-tauri/src/repository/types.rs`
  - Line: 782
  - Context: `Unknown`
- File: `src-tauri/src/repository/types.rs`
  - Line: 794
  - Context: `Unknown`

### DR-176

Locations: 25 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 2661
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 482
  - Context: `Unknown`
- File: `src/lib/components/player/subtitleTracks.test.ts`
  - Line: 67
  - Context: `Unknown`
- File: `src/lib/components/player/subtitleTracks.test.ts`
  - Line: 93
  - Context: `Unknown`
- File: `src/lib/components/player/subtitleTracks.test.ts`
  - Line: 110
  - Context: `Unknown`
- File: `src/lib/components/player/subtitleTracks.ts`
  - Line: 75
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 84
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 99
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 120
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 150
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 184
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 382
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 395
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 433
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 451
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 465
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 479
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 914
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1001
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2296
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2409
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2457
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3550
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3573
  - Context: `Unknown`
- File: `src-tauri/src/repository/types.rs`
  - Line: 265
  - Context: `Unknown`

### DR-177

Locations: 9 file(s)

- File: `src/lib/components/player/hlsRecovery.ts`
  - Line: 11
  - Context: `Unknown`
- File: `src/lib/components/player/hlsRecovery.test.ts`
  - Line: 16
  - Context: `Unknown`
- File: `src/lib/player/adapters/html5Adapter.test.ts`
  - Line: 274
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 136
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 152
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 530
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 591
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3521
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3617
  - Context: `Unknown`

### DR-178

Locations: 13 file(s)

- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 2229
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 340
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 541
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1041
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1056
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1067
  - Context: `pub fn observed_duration(&self) -> Option<f64> {`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1105
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4641
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4660
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4677
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4692
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4706
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4755
  - Context: `Unknown`

### DR-179

Locations: 15 file(s)

- File: `src/lib/services/playbackReporting.ts`
  - Line: 27
  - Context: `Unknown`
- File: `src/lib/services/playbackReporting.ts`
  - Line: 76
  - Context: `* This function is therefore the *local* half only, which is what its ...`
- File: `src-tauri/src/player/mod.rs`
  - Line: 102
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 154
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 835
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1075
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1084
  - Context: `pub fn set_report_sink(&self, sink: Arc<dyn PlaybackReportSink>) {`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1427
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1671
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4728
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4755
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4793
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4821
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4850
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4868
  - Context: `Unknown`

### DR-180

Locations: 6 file(s)

- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 497
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 517
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 3285
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 3305
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 949
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4498
  - Context: `Unknown`

### DR-181

Locations: 11 file(s)

- File: `src/lib/api/repository-client.ts`
  - Line: 235
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1655
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 2069
  - Context: `Unknown`
- File: `src/lib/player/adapters/html5Adapter.test.ts`
  - Line: 235
  - Context: `Unknown`
- File: `src/lib/player/adapters/html5Adapter.ts`
  - Line: 215
  - Context: `Unknown`
- File: `src/routes/player/[id]/+page.svelte`
  - Line: 442
  - Context: `Unknown`
- File: `src/routes/player/[id]/+page.svelte`
  - Line: 666
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 592
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 591
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3427
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3471
  - Context: `Unknown`

### DR-182

Locations: 4 file(s)

- File: `src/lib/components/player/VideoPlayer.nativeReveal.test.ts`
  - Line: 16
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 633
  - Context: `Unknown`
- File: `src/lib/components/player/mediaReady.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/components/player/mediaReady.test.ts`
  - Line: 4
  - Context: `Unknown`

### DR-183

Locations: 1 file(s)

- File: `src/lib/utils/videoSurface.ts`
  - Line: 4
  - Context: `Unknown`

### DR-185

Locations: 1 file(s)

- File: `src/lib/utils/nativeVideoLayers.test.ts`
  - Line: 4
  - Context: `Unknown`

### DR-186

Locations: 1 file(s)

- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 662
  - Context: `Unknown`

### DR-188

Locations: 2 file(s)

- File: `src/lib/stores/nativeVideo.ts`
  - Line: 73
  - Context: `Unknown`
- File: `src/lib/stores/nativeVideo.default.test.ts`
  - Line: 15
  - Context: `Unknown`

### DR-189

Locations: 5 file(s)

- File: `src/lib/components/player/controlsVisibility.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/components/player/controlsVisibility.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 600
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 2212
  - Context: `Unknown`
- File: `scripts/extract-traces.test.ts`
  - Line: 61
  - Context: `Unknown`

### DR-193

Locations: 2 file(s)

- File: `src-tauri/src/player/mod.rs`
  - Line: 578
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 3586
  - Context: `Unknown`

### DR-195

Locations: 1 file(s)

- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 1699
  - Context: `Unknown`

### DR-196

Locations: 3 file(s)

- File: `src/lib/components/player/backgroundAudioHandoff.ts`
  - Line: 110
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 2098
  - Context: `Unknown`
- File: `src/lib/components/player/backgroundAudioHandoff.test.ts`
  - Line: 98
  - Context: `Unknown`

### DR-197

Locations: 8 file(s)

- File: `src/lib/stores/continueWatchingFilter.test.ts`
  - Line: 12
  - Context: `Unknown`
- File: `src/lib/stores/home.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src/lib/stores/continueWatchingFilter.ts`
  - Line: 15
  - Context: `Unknown`
- File: `src/lib/stores/continueWatchingFilter.ts`
  - Line: 91
  - Context: `Unknown`
- File: `src/lib/stores/tv.ts`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1432
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4388
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4409
  - Context: `Unknown`

### DR-198

Locations: 3 file(s)

- File: `src/lib/services/imageCache.ts`
  - Line: 50
  - Context: `Unknown`
- File: `src-tauri/src/lib.rs`
  - Line: 1170
  - Context: `Unknown`
- File: `scripts/tauri-security-config.test.ts`
  - Line: 11
  - Context: `Unknown`

### DR-201

Locations: 7 file(s)

- File: `src-tauri/src/lib.rs`
  - Line: 491
  - Context: `Unknown`
- File: `src-tauri/src/player/seek.rs`
  - Line: 121
  - Context: `Unknown`
- File: `src-tauri/src/player/seek.rs`
  - Line: 153
  - Context: `Unknown`
- File: `src-tauri/src/player/seek.rs`
  - Line: 170
  - Context: `Unknown`
- File: `src-tauri/src/player/seek.rs`
  - Line: 186
  - Context: `/ which backends reject (the "Raw(-10)" class of error).`
- File: `src-tauri/src/player/seek.rs`
  - Line: 199
  - Context: `/ this function exists to prevent.`
- File: `src-tauri/src/player/seek.rs`
  - Line: 210
  - Context: `Unknown`

### DR-203

Locations: 2 file(s)

- File: `src-tauri/src/player/stream_end.rs`
  - Line: 54
  - Context: `Unknown`
- File: `src-tauri/src/player/stream_end.rs`
  - Line: 91
  - Context: `Unknown`

### DR-204

Locations: 4 file(s)

- File: `src/lib/utils/logger.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/utils/logger.ts`
  - Line: 202
  - Context: `export function isLevelEnabled(level: LogLevel): boolean {`
- File: `src/lib/utils/logger.test.ts`
  - Line: 22
  - Context: `Unknown`
- File: `src/lib/utils/logger.test.ts`
  - Line: 333
  - Context: `Unknown`

### DR-205

Locations: 1 file(s)

- File: `eslint.config.js`
  - Line: 3
  - Context: `Unknown`

### DR-206

Locations: 1 file(s)

- File: `src-tauri/rust-toolchain.toml`
  - Line: 3
  - Context: `Unknown`

### DR-207

Locations: 2 file(s)

- File: `scripts/install-hooks.sh`
  - Line: 4
  - Context: `Unknown`
- File: `scripts/hooks/pre-commit`
  - Line: 5
  - Context: `Unknown`

### DR-209

Locations: 44 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 1146
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1158
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1176
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 2384
  - Context: `export type EqPreset = "flat" | "rock" | "pop" | "jazz" | "classical" ...`
- File: `src/lib/api/bindings.ts`
  - Line: 2482
  - Context: `Unknown`
- File: `src/lib/components/library/GenericMediaListPage.svelte`
  - Line: 123
  - Context: `Unknown`
- File: `src/lib/stores/music.ts`
  - Line: 106
  - Context: `Unknown`
- File: `src/routes/settings/+page.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/routes/settings/+page.svelte`
  - Line: 107
  - Context: `Unknown`
- File: `src/routes/settings/+page.svelte`
  - Line: 208
  - Context: `Unknown`
- File: `src/routes/settings/+page.svelte`
  - Line: 227
  - Context: `Unknown`
- File: `src/routes/settings/+page.svelte`
  - Line: 603
  - Context: `Unknown`
- File: `src-tauri/src/commands/library.rs`
  - Line: 10
  - Context: `Unknown`
- File: `src-tauri/src/commands/library.rs`
  - Line: 47
  - Context: `Unknown`
- File: `src-tauri/src/commands/library.rs`
  - Line: 68
  - Context: `Unknown`
- File: `src-tauri/src/commands/library.rs`
  - Line: 83
  - Context: `Unknown`
- File: `src-tauri/src/commands/library.rs`
  - Line: 112
  - Context: `Unknown`
- File: `src-tauri/src/commands/library.rs`
  - Line: 184
  - Context: `Unknown`
- File: `src-tauri/src/commands/library.rs`
  - Line: 220
  - Context: `Unknown`
- File: `src-tauri/src/commands/library.rs`
  - Line: 274
  - Context: `Unknown`
- File: `src-tauri/src/commands/library.rs`
  - Line: 295
  - Context: `Unknown`
- File: `src-tauri/src/commands/library.rs`
  - Line: 307
  - Context: `Unknown`
- File: `src-tauri/src/lib.rs`
  - Line: 1561
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 182
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 283
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 381
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 426
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 544
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 629
  - Context: `Unknown`
- File: `src-tauri/src/repository/exclusions.rs`
  - Line: 22
  - Context: `Unknown`
- File: `src-tauri/src/repository/exclusions.rs`
  - Line: 34
  - Context: `Unknown`
- File: `src-tauri/src/repository/exclusions.rs`
  - Line: 48
  - Context: `Unknown`
- File: `src-tauri/src/repository/exclusions.rs`
  - Line: 63
  - Context: `Unknown`
- File: `src-tauri/src/repository/exclusions.rs`
  - Line: 87
  - Context: `/ pure function and can be tested without touching process state.`
- File: `src-tauri/src/repository/exclusions.rs`
  - Line: 123
  - Context: `Unknown`
- File: `src-tauri/src/repository/exclusions.rs`
  - Line: 191
  - Context: `Unknown`
- File: `src-tauri/src/repository/exclusions.rs`
  - Line: 203
  - Context: `Unknown`
- File: `src-tauri/src/repository/exclusions.rs`
  - Line: 232
  - Context: `Unknown`
- File: `src-tauri/src/repository/exclusions.rs`
  - Line: 275
  - Context: `Unknown`
- File: `src-tauri/src/repository/exclusions.rs`
  - Line: 293
  - Context: `Unknown`
- File: `src-tauri/src/repository/exclusions.rs`
  - Line: 316
  - Context: `Unknown`
- File: `src-tauri/src/repository/exclusions.rs`
  - Line: 334
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 337
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 357
  - Context: `impl LibrarySettings {`

### DR-210

Locations: 8 file(s)

- File: `src-tauri/src/thumbnail/cache.rs`
  - Line: 39
  - Context: `Unknown`
- File: `src-tauri/src/thumbnail/cache.rs`
  - Line: 74
  - Context: `Unknown`
- File: `src-tauri/src/thumbnail/cache.rs`
  - Line: 200
  - Context: `Unknown`
- File: `src-tauri/src/thumbnail/cache.rs`
  - Line: 628
  - Context: `Unknown`
- File: `src-tauri/src/thumbnail/cache.rs`
  - Line: 668
  - Context: `Unknown`
- File: `src-tauri/src/thumbnail/cache.rs`
  - Line: 706
  - Context: `Unknown`
- File: `src-tauri/src/thumbnail/cache.rs`
  - Line: 735
  - Context: `Unknown`
- File: `src-tauri/src/thumbnail/cache.rs`
  - Line: 766
  - Context: `Unknown`

### DR-211

Locations: 10 file(s)

- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 138
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 156
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 186
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 329
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 1374
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 1514
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 1867
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 2560
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 2589
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 2616
  - Context: `Unknown`

### DR-212

Locations: 9 file(s)

- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1963
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1982
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 3070
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 1306
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 4582
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 4635
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1210
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4109
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4145
  - Context: `Unknown`

### DR-213

Locations: 1 file(s)

- File: `scripts/restore-ownership.sh`
  - Line: 4
  - Context: `Unknown`

### DR-215

Locations: 1 file(s)

- File: `vitest.config.ts`
  - Line: 3
  - Context: `Unknown`

### DR-216

Locations: 1 file(s)

- File: `src-tauri/deny.toml`
  - Line: 3
  - Context: `Unknown`

### DR-217

Locations: 5 file(s)

- File: `src/lib/utils/updateCheck.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/utils/updateCheck.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/routes/settings/+page.svelte`
  - Line: 453
  - Context: `Unknown`
- File: `src/routes/settings/+page.svelte`
  - Line: 1346
  - Context: `Unknown`
- File: `src-tauri/src/lib.rs`
  - Line: 1281
  - Context: `Unknown`

### DR-218

Locations: 16 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 1945
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1953
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1967
  - Context: `Unknown`
- File: `src/lib/utils/logger.ts`
  - Line: 202
  - Context: `export function isLevelEnabled(level: LogLevel): boolean {`
- File: `src/lib/utils/logger.test.ts`
  - Line: 352
  - Context: `Unknown`
- File: `src/routes/settings/+page.svelte`
  - Line: 501
  - Context: `Unknown`
- File: `src/routes/settings/+page.svelte`
  - Line: 1266
  - Context: `Unknown`
- File: `src-tauri/src/commands/diagnostics.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/commands/diagnostics.rs`
  - Line: 91
  - Context: `Unknown`
- File: `src-tauri/src/commands/diagnostics.rs`
  - Line: 115
  - Context: `Unknown`
- File: `src-tauri/src/commands/diagnostics.rs`
  - Line: 187
  - Context: `Unknown`
- File: `src-tauri/src/lib.rs`
  - Line: 1061
  - Context: `Unknown`
- File: `src-tauri/src/lib.rs`
  - Line: 1191
  - Context: `Unknown`
- File: `src-tauri/src/lib.rs`
  - Line: 1230
  - Context: `pub fn run() {`
- File: `src-tauri/src/lib.rs`
  - Line: 1273
  - Context: `Unknown`
- File: `src-tauri/src/utils/diagnostics.rs`
  - Line: 3
  - Context: `Unknown`

### DR-219

Locations: 2 file(s)

- File: `scripts/release-notes.ts`
  - Line: 74
  - Context: `Unknown`
- File: `scripts/release-notes.test.ts`
  - Line: 4
  - Context: `Unknown`

### DR-220

Locations: 1 file(s)

- File: `scripts/check-release-artifacts.sh`
  - Line: 4
  - Context: `Unknown`

### DR-221

Locations: 2 file(s)

- File: `scripts/build-windows-cross.sh`
  - Line: 49
  - Context: `Unknown`
- File: `scripts/build-desktop-linux.sh`
  - Line: 29
  - Context: `Unknown`

### DR-223

Locations: 5 file(s)

- File: `src-tauri/src/android_context.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/android_context.rs`
  - Line: 80
  - Context: `Unknown`
- File: `src-tauri/src/android_context.rs`
  - Line: 95
  - Context: `Unknown`
- File: `src-tauri/src/lib.rs`
  - Line: 639
  - Context: `Unknown`
- File: `src-tauri/src/lib.rs`
  - Line: 1343
  - Context: `Unknown`

### DR-224

Locations: 3 file(s)

- File: `src-tauri/src/player/background_policy.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/player/background_policy.rs`
  - Line: 53
  - Context: `Unknown`
- File: `src-tauri/src/player/background_policy.rs`
  - Line: 80
  - Context: `Unknown`

### DR-225

Locations: 49 file(s)

- File: `src/lib/api/repository-client.ts`
  - Line: 263
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 42
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1070
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1673
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 2508
  - Context: `export type LiveStreamInfo = { streamUrl: string; playSessionId: strin...`
- File: `src/lib/api/bindings.ts`
  - Line: 2768
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3017
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3359
  - Context: `export type RemoteSessionStatus = { position: number; duration: number...`
- File: `src/lib/api/bindings.ts`
  - Line: 3537
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3578
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3674
  - Context: `* domain fact reconstructed in the presentation layer — the same class...`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 103
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 205
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 293
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 682
  - Context: `Unknown`
- File: `src/lib/player/adapters/html5Adapter.ts`
  - Line: 40
  - Context: `Unknown`
- File: `src/lib/player/adapters/html5Adapter.ts`
  - Line: 80
  - Context: `Unknown`
- File: `src/lib/player/adapters/types.ts`
  - Line: 54
  - Context: `Unknown`
- File: `src/lib/player/adapters/types.ts`
  - Line: 130
  - Context: `Unknown`
- File: `src/lib/player/streamTransport.ts`
  - Line: 7
  - Context: `Unknown`
- File: `src/lib/player/streamTransport.ts`
  - Line: 61
  - Context: `Unknown`
- File: `src/lib/player/streamTransport.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/routes/player/[id]/+page.svelte`
  - Line: 85
  - Context: `Unknown`
- File: `src/routes/player/[id]/+page.svelte`
  - Line: 329
  - Context: `Unknown`
- File: `src/routes/player/[id]/+page.svelte`
  - Line: 406
  - Context: `Unknown`
- File: `src/routes/player/[id]/+page.svelte`
  - Line: 422
  - Context: `Unknown`
- File: `src-tauri/src/commands/storage/mod.rs`
  - Line: 117
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 191
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 335
  - Context: `pub enum VideoSeekResponse {`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 357
  - Context: `pub enum AudioTrackSwitchResponse {`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 396
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 914
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 621
  - Context: `Unknown`
- File: `src-tauri/src/player/media.rs`
  - Line: 126
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 106
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 15
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 33
  - Context: `/ domain fact reconstructed in the presentation layer — the same class...`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 88
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 136
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 163
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 174
  - Context: `impl StreamSelection {`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 200
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 268
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 314
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 328
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 781
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 951
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1641
  - Context: `Unknown`
- File: `src-tauri/src/repository/types.rs`
  - Line: 509
  - Context: `Unknown`

### DR-226

Locations: 24 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 295
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3359
  - Context: `export type RemoteSessionStatus = { position: number; duration: number...`
- File: `src/lib/api/bindings.ts`
  - Line: 3524
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 2487
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 2511
  - Context: `Unknown`
- File: `src/lib/player/index.ts`
  - Line: 202
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 383
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 708
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1002
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1793
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1845
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1857
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 2577
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 88
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 46
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 74
  - Context: `pub fn streaming_quality() -> StreamingQuality {`
- File: `src-tauri/src/repository/online.rs`
  - Line: 85
  - Context: `pub fn set_playback_quality_override(quality: StreamingQuality) {`
- File: `src-tauri/src/repository/online.rs`
  - Line: 92
  - Context: `pub fn clear_playback_quality_override() {`
- File: `src-tauri/src/repository/online.rs`
  - Line: 105
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 951
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3339
  - Context: `impl Drop for QualityFixture {`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4704
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4752
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4785
  - Context: `Unknown`

### DR-227

Locations: 25 file(s)

- File: `src/lib/api/repository-client.ts`
  - Line: 263
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1673
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3320
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3524
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3537
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 103
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 361
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 929
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 2487
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 2511
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 3091
  - Context: `Unknown`
- File: `src/routes/player/[id]/+page.svelte`
  - Line: 422
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 383
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 396
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 621
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 106
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 111
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 136
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 241
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 363
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 403
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 419
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 433
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 443
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 951
  - Context: `Unknown`

### DR-228

Locations: 26 file(s)

- File: `src/lib/api/repository-client.ts`
  - Line: 263
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1673
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 2892
  - Context: `export type PlaybackInfo = { mediaSourceId: string; playSessionId: str...`
- File: `src/lib/api/bindings.ts`
  - Line: 3537
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3578
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 2951
  - Context: `Unknown`
- File: `src/routes/player/[id]/+page.svelte`
  - Line: 422
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 621
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 106
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 55
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 76
  - Context: `impl PlaybackKind {`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 136
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 163
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 284
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 303
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 781
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 951
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1641
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1769
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4621
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4634
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4653
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4672
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4688
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4704
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4732
  - Context: `Unknown`

### DR-230

Locations: 7 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 2768
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3017
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 1002
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 2106
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 191
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 453
  - Context: `Unknown`
- File: `src-tauri/src/player/media.rs`
  - Line: 126
  - Context: `Unknown`

### DR-231

Locations: 13 file(s)

- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 744
  - Context: `Unknown`
- File: `src-tauri/src/lib.rs`
  - Line: 1427
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_render.rs`
  - Line: 11
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_render.rs`
  - Line: 160
  - Context: `impl MpvRenderContext {`
- File: `src-tauri/src/player/mpv_render.rs`
  - Line: 260
  - Context: `Unknown`
- File: `src-tauri/src/player/video_surface.rs`
  - Line: 29
  - Context: `Unknown`
- File: `src-tauri/src/player/video_surface.rs`
  - Line: 148
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 117
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 123
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 190
  - Context: `Unknown`
- File: `src-tauri/src/player/native_video.rs`
  - Line: 15
  - Context: `! It is a function rather than three` env::var`checks for the reason ...`
- File: `src-tauri/src/player/native_video.rs`
  - Line: 29
  - Context: `Unknown`
- File: `src-tauri/src/player/native_video.rs`
  - Line: 51
  - Context: `Unknown`

### DR-232

Locations: 4 file(s)

- File: `src-tauri/src/player/mpv_render.rs`
  - Line: 11
  - Context: `Unknown`
- File: `src-tauri/src/player/video_surface.rs`
  - Line: 29
  - Context: `Unknown`
- File: `src-tauri/src/player/video_surface.rs`
  - Line: 148
  - Context: `Unknown`
- File: `src-tauri/src/player/video_surface.rs`
  - Line: 272
  - Context: `Unknown`

### DR-233

Locations: 7 file(s)

- File: `src-tauri/src/player/mpv_render.rs`
  - Line: 225
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_render.rs`
  - Line: 251
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_render.rs`
  - Line: 304
  - Context: `Unknown`
- File: `src-tauri/src/player/video_surface.rs`
  - Line: 29
  - Context: `Unknown`
- File: `src-tauri/src/player/video_surface.rs`
  - Line: 120
  - Context: `Unknown`
- File: `src-tauri/src/player/video_surface.rs`
  - Line: 148
  - Context: `Unknown`
- File: `src-tauri/src/player/video_surface.rs`
  - Line: 208
  - Context: `Unknown`

### DR-234

Locations: 7 file(s)

- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 218
  - Context: `pub fn video_audio_codecs(detected: &str) -> String {`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 261
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 307
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 351
  - Context: `pub fn audio_forces_transcode(streams: &[(Option<&str>, bool)]) -> boo...`
- File: `src-tauri/src/repository/online.rs`
  - Line: 630
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 791
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 873
  - Context: `Unknown`

### DR-235

Locations: 6 file(s)

- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 744
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 2203
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 2217
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 190
  - Context: `Unknown`
- File: `src-tauri/src/player/native_video.rs`
  - Line: 15
  - Context: `! It is a function rather than three` env::var`checks for the reason ...`
- File: `src-tauri/src/player/native_video.rs`
  - Line: 29
  - Context: `Unknown`

### DR-238

Locations: 2 file(s)

- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1481
  - Context: `Unknown`
- File: `src-tauri/src/player/seek.rs`
  - Line: 272
  - Context: `Unknown`

### DR-239

Locations: 2 file(s)

- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 294
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend_test.rs`
  - Line: 33
  - Context: `Unknown`

### DR-240

Locations: 1 file(s)

- File: `src/lib/components/player/fullscreenTarget.ts`
  - Line: 20
  - Context: `Unknown`

### DR-241

Locations: 3 file(s)

- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 48
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 315
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 670
  - Context: `Unknown`

### DR-242

Locations: 1 file(s)

- File: `src-tauri/src/player/media_player.rs`
  - Line: 22
  - Context: `Unknown`

### DR-243

Locations: 4 file(s)

- File: `src-tauri/src/player/conformance.rs`
  - Line: 25
  - Context: `Unknown`
- File: `src-tauri/src/player/fake_player.rs`
  - Line: 17
  - Context: `Unknown`
- File: `src-tauri/src/player/media.rs`
  - Line: 214
  - Context: `impl MediaItem {`
- File: `src-tauri/src/player/fake_player_conformance.rs`
  - Line: 7
  - Context: `Unknown`

### DR-244

Locations: 3 file(s)

- File: `src-tauri/src/bin/player_conformance.rs`
  - Line: 11
  - Context: `! access to the player internals — one exported function rather than a...`
- File: `src-tauri/src/player/mpv_player.rs`
  - Line: 13
  - Context: `Unknown`
- File: `src-tauri/src/conformance_runner.rs`
  - Line: 13
  - Context: `Unknown`

### DR-245

Locations: 5 file(s)

- File: `src-tauri/src/lib.rs`
  - Line: 1455
  - Context: `Unknown`
- File: `src-tauri/src/bin/player_conformance.rs`
  - Line: 11
  - Context: `! access to the player internals — one exported function rather than a...`
- File: `src-tauri/src/player/media.rs`
  - Line: 197
  - Context: `Unknown`
- File: `src-tauri/src/player/legacy_player.rs`
  - Line: 19
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 200
  - Context: `Unknown`

### DR-246

Locations: 4 file(s)

- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1481
  - Context: `Unknown`
- File: `src-tauri/src/lib.rs`
  - Line: 760
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1010
  - Context: `Unknown`
- File: `src-tauri/src/player/seek.rs`
  - Line: 272
  - Context: `Unknown`

### DR-250

Locations: 3 file(s)

- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 1310
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 808
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 2466
  - Context: `Unknown`

### DR-251

Locations: 2 file(s)

- File: `src-tauri/src/player/mod.rs`
  - Line: 1118
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 2505
  - Context: `Unknown`

### DR-252

Locations: 3 file(s)

- File: `src-tauri/src/player/media_player.rs`
  - Line: 49
  - Context: `Unknown`
- File: `src-tauri/src/player/media_player.rs`
  - Line: 311
  - Context: `Unknown`
- File: `src-tauri/src/player/legacy_player.rs`
  - Line: 236
  - Context: `Unknown`

### DR-253

Locations: 3 file(s)

- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 600
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 646
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend_test.rs`
  - Line: 77
  - Context: `Unknown`

### DR-254

Locations: 2 file(s)

- File: `src-tauri/src/player/mod.rs`
  - Line: 2006
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 2438
  - Context: `Unknown`

### DR-255

Locations: 1 file(s)

- File: `src-tauri/src/player/media.rs`
  - Line: 197
  - Context: `Unknown`

### DR-256

Locations: 7 file(s)

- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 323
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 2897
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 2901
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 3072
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 3148
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.menus.test.ts`
  - Line: 5
  - Context: `Unknown`
- File: `src/lib/components/player/VolumeControl.svelte`
  - Line: 15
  - Context: `Unknown`

### DR-257

Locations: 11 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 2435
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 2602
  - Context: `Unknown`
- File: `src/lib/stores/library.ts`
  - Line: 122
  - Context: `Unknown`
- File: `src/lib/stores/libraryListingOrder.test.ts`
  - Line: 10
  - Context: `Unknown`
- File: `src/routes/library/[id]/+page.svelte`
  - Line: 176
  - Context: `Unknown`
- File: `src-tauri/src/domain/media.rs`
  - Line: 57
  - Context: `Unknown`
- File: `src-tauri/src/domain/from_jellyfin.rs`
  - Line: 151
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1237
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4180
  - Context: `Unknown`
- File: `src-tauri/src/repository/types.rs`
  - Line: 350
  - Context: `Unknown`
- File: `src-tauri/src/repository/types.rs`
  - Line: 367
  - Context: `Unknown`

### DR-258

Locations: 4 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 178
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1629
  - Context: `Unknown`
- File: `src-tauri/src/player/track_switch.rs`
  - Line: 34
  - Context: `Unknown`
- File: `src-tauri/src/player/track_switch.rs`
  - Line: 63
  - Context: `Unknown`

### DR-259

Locations: 2 file(s)

- File: `src-tauri/src/repository/online.rs`
  - Line: 2605
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3174
  - Context: `Unknown`

### DR-262

Locations: 2 file(s)

- File: `src/lib/components/library/alphabetStrip.ts`
  - Line: 22
  - Context: `Unknown`
- File: `src/lib/components/library/AlphabetScrollBar.svelte`
  - Line: 1
  - Context: `Unknown`

### DR-263

Locations: 4 file(s)

- File: `src-tauri/src/player/mod.rs`
  - Line: 2286
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4164
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4188
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4211
  - Context: `Unknown`

### DR-264

Locations: 5 file(s)

- File: `src-tauri/src/repository/series_progress.rs`
  - Line: 12
  - Context: `Unknown`
- File: `src-tauri/src/repository/series_progress.rs`
  - Line: 474
  - Context: `Unknown`
- File: `src-tauri/src/repository/series_progress.rs`
  - Line: 491
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 716
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 4886
  - Context: `Unknown`

### DR-265

Locations: 2 file(s)

- File: `src/lib/components/player/timeTracking.test.ts`
  - Line: 5
  - Context: `Unknown`
- File: `src/lib/components/player/timeTracking.ts`
  - Line: 4
  - Context: `Unknown`

### DR-266

Locations: 3 file(s)

- File: `src/lib/components/player/backgroundAudioHandoff.ts`
  - Line: 128
  - Context: `Unknown`
- File: `src/lib/components/player/backgroundAudioHandoff.ts`
  - Line: 166
  - Context: `export function enteringPictureInPicture(_current: BackgroundBehaviour...`
- File: `src/lib/components/player/backgroundAudioHandoff.test.ts`
  - Line: 145
  - Context: `Unknown`

### DR-267

Locations: 17 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 1836
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1857
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1869
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1910
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3305
  - Context: `Unknown`
- File: `src/lib/stores/profiles.ts`
  - Line: 11
  - Context: `Unknown`
- File: `src-tauri/src/commands/profiles.rs`
  - Line: 9
  - Context: `Unknown`
- File: `src-tauri/src/commands/profiles.rs`
  - Line: 74
  - Context: `Unknown`
- File: `src-tauri/src/commands/profiles.rs`
  - Line: 149
  - Context: `Unknown`
- File: `src-tauri/src/commands/profiles.rs`
  - Line: 279
  - Context: `Unknown`
- File: `src-tauri/src/commands/profiles.rs`
  - Line: 372
  - Context: `Unknown`
- File: `src-tauri/src/profiles/store.rs`
  - Line: 8
  - Context: `Unknown`
- File: `src-tauri/src/profiles/store.rs`
  - Line: 23
  - Context: `Unknown`
- File: `src-tauri/src/profiles/store.rs`
  - Line: 60
  - Context: `Unknown`
- File: `src-tauri/src/profiles/store.rs`
  - Line: 196
  - Context: `Unknown`
- File: `src-tauri/src/profiles/mod.rs`
  - Line: 19
  - Context: `Unknown`
- File: `src-tauri/src/profiles/mod.rs`
  - Line: 44
  - Context: `pub enum UnlockMethod {`

### DR-268

Locations: 23 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 1889
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1910
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3716
  - Context: `Unknown`
- File: `src/lib/components/settings/ProfileSecuritySettings.svelte`
  - Line: 15
  - Context: `Unknown`
- File: `src/lib/utils/pinForm.ts`
  - Line: 12
  - Context: `Unknown`
- File: `src-tauri/src/commands/auth.rs`
  - Line: 39
  - Context: `Unknown`
- File: `src-tauri/src/commands/profiles.rs`
  - Line: 9
  - Context: `Unknown`
- File: `src-tauri/src/commands/profiles.rs`
  - Line: 149
  - Context: `Unknown`
- File: `src-tauri/src/commands/profiles.rs`
  - Line: 338
  - Context: `Unknown`
- File: `src-tauri/src/storage/schema.rs`
  - Line: 845
  - Context: `Unknown`
- File: `src-tauri/src/profiles/store.rs`
  - Line: 8
  - Context: `Unknown`
- File: `src-tauri/src/profiles/store.rs`
  - Line: 96
  - Context: `Unknown`
- File: `src-tauri/src/profiles/store.rs`
  - Line: 127
  - Context: `Unknown`
- File: `src-tauri/src/profiles/store.rs`
  - Line: 152
  - Context: `Unknown`
- File: `src-tauri/src/profiles/store.rs`
  - Line: 167
  - Context: `Unknown`
- File: `src-tauri/src/profiles/pin.rs`
  - Line: 15
  - Context: `Unknown`
- File: `src-tauri/src/profiles/pin.rs`
  - Line: 34
  - Context: `Unknown`
- File: `src-tauri/src/profiles/pin.rs`
  - Line: 67
  - Context: `/ doing the comparison here is what keeps this function pure and cheap...`
- File: `src-tauri/src/profiles/pin.rs`
  - Line: 114
  - Context: `Unknown`
- File: `src-tauri/src/profiles/pin.rs`
  - Line: 127
  - Context: `Unknown`
- File: `src-tauri/src/profiles/pin.rs`
  - Line: 141
  - Context: `Unknown`
- File: `src-tauri/src/profiles/mod.rs`
  - Line: 19
  - Context: `Unknown`
- File: `src-tauri/src/profiles/mod.rs`
  - Line: 65
  - Context: `Unknown`

### DR-269

Locations: 6 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 1925
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3716
  - Context: `Unknown`
- File: `src/lib/stores/profiles.ts`
  - Line: 11
  - Context: `Unknown`
- File: `src-tauri/src/commands/profiles.rs`
  - Line: 9
  - Context: `Unknown`
- File: `src-tauri/src/commands/profiles.rs`
  - Line: 219
  - Context: `Unknown`
- File: `src-tauri/src/profiles/mod.rs`
  - Line: 65
  - Context: `Unknown`

### DR-270

Locations: 9 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 1910
  - Context: `Unknown`
- File: `src/lib/stores/auth.ts`
  - Line: 621
  - Context: `Unknown`
- File: `src-tauri/src/commands/profiles.rs`
  - Line: 9
  - Context: `Unknown`
- File: `src-tauri/src/commands/profiles.rs`
  - Line: 149
  - Context: `Unknown`
- File: `src-tauri/src/commands/profiles.rs`
  - Line: 433
  - Context: `Unknown`
- File: `src-tauri/src/commands/profiles.rs`
  - Line: 504
  - Context: `Unknown`
- File: `src-tauri/src/profiles/switch.rs`
  - Line: 20
  - Context: `! So the ordering lives here as a pure function returning a list of st...`
- File: `src-tauri/src/profiles/switch.rs`
  - Line: 24
  - Context: `Unknown`
- File: `src-tauri/src/profiles/switch.rs`
  - Line: 64
  - Context: `Unknown`

### DR-271

Locations: 1 file(s)

- File: `src-tauri/src/storage/schema.rs`
  - Line: 845
  - Context: `Unknown`

### DR-272

Locations: 1 file(s)

- File: `src-tauri/src/storage/schema.rs`
  - Line: 845
  - Context: `Unknown`

### DR-274

Locations: 13 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 1849
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1877
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1899
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3480
  - Context: `export type SmartCacheStats = { total_size: number; storage_limit: num...`
- File: `src/lib/stores/profiles.ts`
  - Line: 11
  - Context: `Unknown`
- File: `src/routes/settings/+page.svelte`
  - Line: 998
  - Context: `Unknown`
- File: `src-tauri/src/commands/auth.rs`
  - Line: 39
  - Context: `Unknown`
- File: `src-tauri/src/commands/profiles.rs`
  - Line: 9
  - Context: `Unknown`
- File: `src-tauri/src/commands/profiles.rs`
  - Line: 90
  - Context: `Unknown`
- File: `src-tauri/src/commands/profiles.rs`
  - Line: 114
  - Context: `Unknown`
- File: `src-tauri/src/commands/profiles.rs`
  - Line: 124
  - Context: `Unknown`
- File: `src-tauri/src/profiles/mod.rs`
  - Line: 92
  - Context: `Unknown`
- File: `src-tauri/src/profiles/mod.rs`
  - Line: 115
  - Context: `Unknown`

### DR-276

Locations: 18 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 3697
  - Context: `Unknown`
- File: `src/lib/components/PinPad.svelte`
  - Line: 12
  - Context: `Unknown`
- File: `src/lib/components/settings/ProfileSecuritySettings.svelte`
  - Line: 15
  - Context: `Unknown`
- File: `src/lib/components/account/AccountMenu.svelte`
  - Line: 7
  - Context: `Unknown`
- File: `src/lib/components/account/AccountMenu.test.ts`
  - Line: 84
  - Context: `Unknown`
- File: `src/lib/utils/layoutShell.test.ts`
  - Line: 214
  - Context: `Unknown`
- File: `src/lib/utils/pinForm.ts`
  - Line: 12
  - Context: `Unknown`
- File: `src/lib/utils/pinForm.ts`
  - Line: 37
  - Context: `Unknown`
- File: `src/lib/utils/pinForm.ts`
  - Line: 74
  - Context: `Unknown`
- File: `src/lib/utils/profileTiles.ts`
  - Line: 12
  - Context: `Unknown`
- File: `src/lib/utils/profileTiles.ts`
  - Line: 21
  - Context: `Unknown`
- File: `src/lib/utils/profileTiles.ts`
  - Line: 36
  - Context: `Unknown`
- File: `src/lib/utils/profileTiles.ts`
  - Line: 53
  - Context: `Unknown`
- File: `src/lib/utils/profileTiles.ts`
  - Line: 68
  - Context: `Unknown`
- File: `src/lib/stores/profiles.ts`
  - Line: 11
  - Context: `Unknown`
- File: `src/routes/settings/+page.svelte`
  - Line: 998
  - Context: `Unknown`
- File: `src/routes/profiles/+page.svelte`
  - Line: 10
  - Context: `Unknown`
- File: `src-tauri/src/profiles/mod.rs`
  - Line: 32
  - Context: `Unknown`

### DR-277

Locations: 4 file(s)

- File: `src-tauri/src/repository/offline.rs`
  - Line: 100
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 5036
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 5079
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 5119
  - Context: `Unknown`

### JA-001

Locations: 1 file(s)

- File: `src-tauri/src/jellyfin/client.rs`
  - Line: 1
  - Context: `Unknown`

### JA-002

Locations: 2 file(s)

- File: `src-tauri/src/commands/auth.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/jellyfin/client.rs`
  - Line: 1
  - Context: `Unknown`

### JA-003

Locations: 1 file(s)

- File: `src-tauri/src/jellyfin/client.rs`
  - Line: 1
  - Context: `Unknown`

### JA-004

Locations: 3 file(s)

- File: `src-tauri/src/commands/repository.rs`
  - Line: 4
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/jellyfin/client.rs`
  - Line: 1
  - Context: `Unknown`

### JA-005

Locations: 2 file(s)

- File: `src-tauri/src/commands/repository.rs`
  - Line: 4
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1859
  - Context: `Unknown`

### JA-006

Locations: 2 file(s)

- File: `src-tauri/src/commands/repository.rs`
  - Line: 4
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2194
  - Context: `Unknown`

### JA-007

Locations: 1 file(s)

- File: `src-tauri/src/jellyfin/client.rs`
  - Line: 1
  - Context: `Unknown`

### JA-008

Locations: 10 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 2813
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3621
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 986
  - Context: `Unknown`
- File: `src/lib/components/player/subtitleTracks.test.ts`
  - Line: 233
  - Context: `Unknown`
- File: `src/lib/components/player/subtitleTracks.ts`
  - Line: 210
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 236
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 3203
  - Context: `Unknown`
- File: `src-tauri/src/player/media.rs`
  - Line: 42
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2605
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3174
  - Context: `Unknown`

### JA-009

Locations: 2 file(s)

- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 408
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1859
  - Context: `Unknown`

### JA-010

Locations: 2 file(s)

- File: `src-tauri/src/commands/playback_reporting.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/jellyfin/client.rs`
  - Line: 1
  - Context: `Unknown`

### JA-011

Locations: 2 file(s)

- File: `src-tauri/src/commands/playback_reporting.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/jellyfin/client.rs`
  - Line: 1
  - Context: `Unknown`

### JA-012

Locations: 2 file(s)

- File: `src-tauri/src/commands/playback_reporting.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/jellyfin/client.rs`
  - Line: 1
  - Context: `Unknown`

### JA-013

Locations: 5 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 1554
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 307
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1167
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1906
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2111
  - Context: `Unknown`

### JA-014

Locations: 4 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 1562
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 333
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1432
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1933
  - Context: `Unknown`

### JA-015

Locations: 4 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 1554
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 307
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1906
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2111
  - Context: `Unknown`

### JA-016

Locations: 10 file(s)

- File: `src-tauri/src/commands/catalog.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1299
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1315
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1335
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1374
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1875
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4284
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4326
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4353
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4369
  - Context: `Unknown`

### JA-017

Locations: 1 file(s)

- File: `src-tauri/src/jellyfin/client.rs`
  - Line: 1
  - Context: `Unknown`

### JA-018

Locations: 1 file(s)

- File: `src-tauri/src/repository/online.rs`
  - Line: 2748
  - Context: `Unknown`

### JA-019

Locations: 1 file(s)

- File: `src-tauri/src/commands/playlist.rs`
  - Line: 4
  - Context: `Unknown`

### JA-020

Locations: 1 file(s)

- File: `src-tauri/src/commands/playlist.rs`
  - Line: 4
  - Context: `Unknown`

### JA-021

Locations: 6 file(s)

- File: `src/lib/components/sessions/SessionPickerModal.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/sessions/SessionsList.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/stores/lmsSync.ts`
  - Line: 12
  - Context: `Unknown`
- File: `src-tauri/src/commands/sessions.rs`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/session_poller/mod.rs`
  - Line: 6
  - Context: `Unknown`
- File: `src-tauri/src/jellyfin/client.rs`
  - Line: 1
  - Context: `Unknown`

### JA-022

Locations: 3 file(s)

- File: `src/lib/components/sessions/RemoteControls.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/remote.rs`
  - Line: 3
  - Context: `Unknown`

### JA-023

Locations: 3 file(s)

- File: `src/lib/components/sessions/RemoteControls.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/remote.rs`
  - Line: 3
  - Context: `Unknown`

### JA-024

Locations: 2 file(s)

- File: `src/lib/components/sessions/RemoteControls.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1
  - Context: `Unknown`

### JA-025

Locations: 4 file(s)

- File: `src/lib/components/sessions/SessionPickerModal.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/stores/lmsSync.ts`
  - Line: 12
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/remote.rs`
  - Line: 3
  - Context: `Unknown`

### JA-026

Locations: 3 file(s)

- File: `src/lib/components/sessions/RemoteControls.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/remote.rs`
  - Line: 3
  - Context: `Unknown`

### JA-028

Locations: 1 file(s)

- File: `src-tauri/src/commands/storage/thumbnails.rs`
  - Line: 3
  - Context: `Unknown`

### JA-029

Locations: 3 file(s)

- File: `src/lib/components/library/CrewLinks.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/library/CastSection.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 4
  - Context: `Unknown`

### JA-030

Locations: 3 file(s)

- File: `src/lib/components/library/PersonDetailView.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 4
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2888
  - Context: `Unknown`

### JA-031

Locations: 3 file(s)

- File: `src/lib/components/library/PersonDetailView.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 4
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2901
  - Context: `Unknown`

### JA-032

Locations: 10 file(s)

- File: `src/lib/api/repository-client.ts`
  - Line: 281
  - Context: `Unknown`
- File: `src/lib/api/repository-client.test.ts`
  - Line: 442
  - Context: `Unknown`
- File: `src/lib/api/repository-client.test.ts`
  - Line: 459
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1687
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 640
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 121
  - Context: `Unknown`
- File: `src-tauri/src/repository/mod.rs`
  - Line: 134
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 700
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3648
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3689
  - Context: `Unknown`

### JA-033

Locations: 7 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 1764
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 927
  - Context: `Unknown`
- File: `src-tauri/src/repository/mod.rs`
  - Line: 238
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1455
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2724
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2794
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4017
  - Context: `Unknown`

### JA-034

Locations: 3 file(s)

- File: `src-tauri/src/repository/online.rs`
  - Line: 1167
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1580
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4428
  - Context: `Unknown`

### JA-035

Locations: 2 file(s)

- File: `src-tauri/src/repository/mod.rs`
  - Line: 258
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2839
  - Context: `Unknown`

### JA-036

Locations: 2 file(s)

- File: `src-tauri/src/repository/online.rs`
  - Line: 1432
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4388
  - Context: `Unknown`

### UR-003

Locations: 50 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 211
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 2768
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 2863
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 2925
  - Context: `export type PlaybackProgress = { itemId: string;`
- File: `src/lib/api/bindings.ts`
  - Line: 3017
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3041
  - Context: `Unknown`
- File: `src/lib/components/player/controlsVisibility.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/components/player/backgroundAudioHandoff.ts`
  - Line: 110
  - Context: `Unknown`
- File: `src/lib/components/player/controlsVisibility.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.nativeReveal.test.ts`
  - Line: 16
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 600
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 633
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 662
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 1699
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 2098
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 2212
  - Context: `Unknown`
- File: `src/lib/components/player/mediaReady.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/components/player/backgroundAudioHandoff.test.ts`
  - Line: 98
  - Context: `Unknown`
- File: `src/lib/components/player/mediaReady.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/player/adapters/rustReportHost.ts`
  - Line: 13
  - Context: `Unknown`
- File: `src/lib/player/adapters/adapterSelection.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/player/adapters/html5Adapter.ts`
  - Line: 20
  - Context: `Unknown`
- File: `src/lib/player/adapters/webviewAudioAdapter.ts`
  - Line: 19
  - Context: `Unknown`
- File: `src/lib/player/adapters/index.ts`
  - Line: 20
  - Context: `Unknown`
- File: `src/lib/player/adapters/nativeAdapter.ts`
  - Line: 26
  - Context: `Unknown`
- File: `src/lib/player/adapters/types.ts`
  - Line: 16
  - Context: `Unknown`
- File: `src/lib/services/playbackCapabilities.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/services/webviewAudio.ts`
  - Line: 18
  - Context: `Unknown`
- File: `src/lib/utils/videoSurface.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/utils/nativeVideoLayers.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/stores/nativeVideo.ts`
  - Line: 3
  - Context: `Unknown`
- File: `src/lib/stores/nativeVideo.ts`
  - Line: 73
  - Context: `Unknown`
- File: `src/lib/stores/nativeVideo.default.test.ts`
  - Line: 15
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 191
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 2177
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 2194
  - Context: `Unknown`
- File: `src-tauri/src/commands/mod.rs`
  - Line: 2
  - Context: `Unknown`
- File: `src-tauri/src/player/backend.rs`
  - Line: 39
  - Context: `pub fn playback_failed<S: Into<String>>(message: S) -> Self {`
- File: `src-tauri/src/player/backend.rs`
  - Line: 251
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 2
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 578
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 3586
  - Context: `Unknown`
- File: `src-tauri/src/player/media.rs`
  - Line: 60
  - Context: `Unknown`
- File: `src-tauri/src/player/media.rs`
  - Line: 126
  - Context: `Unknown`
- File: `src-tauri/src/player/media.rs`
  - Line: 154
  - Context: `pub enum MediaType {`
- File: `src-tauri/src/player/mpv_backend_test.rs`
  - Line: 8
  - Context: `Unknown`
- File: `src-tauri/src/player/webview_audio_backend.rs`
  - Line: 25
  - Context: `Unknown`
- File: `src-tauri/src/player/webview_audio_backend.rs`
  - Line: 184
  - Context: `Unknown`

### UR-004

Locations: 81 file(s)

- File: `src/lib/api/repository-client.ts`
  - Line: 235
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 372
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1655
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 2768
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 2925
  - Context: `export type PlaybackProgress = { itemId: string;`
- File: `src/lib/api/bindings.ts`
  - Line: 3017
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3041
  - Context: `Unknown`
- File: `src/lib/components/player/hlsRecovery.ts`
  - Line: 11
  - Context: `Unknown`
- File: `src/lib/components/player/timeTracking.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/components/player/AudioPlayer.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.nativeReveal.test.ts`
  - Line: 16
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 633
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 2069
  - Context: `Unknown`
- File: `src/lib/components/player/mediaReady.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/components/player/mediaReady.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/components/player/hlsRecovery.test.ts`
  - Line: 16
  - Context: `Unknown`
- File: `src/lib/player/adapters/html5Adapter.test.ts`
  - Line: 235
  - Context: `Unknown`
- File: `src/lib/player/adapters/adapterSelection.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/player/adapters/html5Adapter.ts`
  - Line: 215
  - Context: `Unknown`
- File: `src/lib/player/adapters/index.ts`
  - Line: 20
  - Context: `Unknown`
- File: `src/lib/services/playerEvents.regression.test.ts`
  - Line: 155
  - Context: `Unknown`
- File: `src/lib/services/playerEvents.ts`
  - Line: 292
  - Context: `Unknown`
- File: `src/lib/services/preload.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/services/preload.ts`
  - Line: 5
  - Context: `Unknown`
- File: `src/lib/utils/videoSurface.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/utils/nativeVideoLayers.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/stores/auth.waitForRepository.test.ts`
  - Line: 13
  - Context: `Unknown`
- File: `src/lib/stores/nativeVideo.ts`
  - Line: 3
  - Context: `Unknown`
- File: `src/lib/stores/nativeVideo.ts`
  - Line: 73
  - Context: `Unknown`
- File: `src/lib/stores/nativeVideo.default.test.ts`
  - Line: 15
  - Context: `Unknown`
- File: `src/routes/player/[id]/+page.svelte`
  - Line: 362
  - Context: `Unknown`
- File: `src/routes/player/[id]/+page.svelte`
  - Line: 442
  - Context: `Unknown`
- File: `src/routes/player/[id]/+page.svelte`
  - Line: 666
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/timers.rs`
  - Line: 299
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 191
  - Context: `Unknown`
- File: `src-tauri/src/commands/mod.rs`
  - Line: 2
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 592
  - Context: `Unknown`
- File: `src-tauri/src/player/backend.rs`
  - Line: 39
  - Context: `pub fn playback_failed<S: Into<String>>(message: S) -> Self {`
- File: `src-tauri/src/player/backend.rs`
  - Line: 251
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 2
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1810
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1900
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1931
  - Context: `Unknown`
- File: `src-tauri/src/player/media.rs`
  - Line: 60
  - Context: `Unknown`
- File: `src-tauri/src/player/media.rs`
  - Line: 126
  - Context: `Unknown`
- File: `src-tauri/src/player/media.rs`
  - Line: 154
  - Context: `pub enum MediaType {`
- File: `src-tauri/src/player/stream_end.rs`
  - Line: 91
  - Context: `Unknown`
- File: `src-tauri/src/player/android/mod.rs`
  - Line: 438
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend_test.rs`
  - Line: 8
  - Context: `Unknown`
- File: `src-tauri/src/player/webview_audio_backend.rs`
  - Line: 25
  - Context: `Unknown`
- File: `src-tauri/src/player/webview_audio_backend.rs`
  - Line: 184
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 29
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 40
  - Context: `pub fn clamp_max_audio_channels(reported: Option<u32>) -> u32 {`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 120
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 150
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 213
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 218
  - Context: `pub fn video_audio_codecs(detected: &str) -> String {`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 261
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 307
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 325
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 347
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 351
  - Context: `pub fn audio_forces_transcode(streams: &[(Option<&str>, bool)]) -> boo...`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 366
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 395
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 433
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 451
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 591
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 630
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 781
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 791
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 873
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 914
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1001
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2296
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2409
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2689
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3427
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3471
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3550
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3909
  - Context: `Unknown`

### UR-005

Locations: 125 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 178
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 211
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 2863
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3059
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3116
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3385
  - Context: `Unknown`
- File: `src/lib/components/BottomUi.svelte`
  - Line: 23
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.tapSurface.test.ts`
  - Line: 5
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.touchScrub.test.ts`
  - Line: 21
  - Context: `Unknown`
- File: `src/lib/components/player/videoFit.ts`
  - Line: 7
  - Context: `Unknown`
- File: `src/lib/components/player/backgroundAudioHandoff.ts`
  - Line: 69
  - Context: `Unknown`
- File: `src/lib/components/player/AudioPlayer.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/player/tapGestures.ts`
  - Line: 21
  - Context: `Unknown`
- File: `src/lib/components/player/playerSurface.test.ts`
  - Line: 13
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 662
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 1094
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 1310
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 1699
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 2629
  - Context: `Unknown`
- File: `src/lib/components/player/Controls.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/player/playerSurface.ts`
  - Line: 8
  - Context: `Unknown`
- File: `src/lib/components/player/MiniPlayer.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/player/adapters/html5Adapter.test.ts`
  - Line: 235
  - Context: `Unknown`
- File: `src/lib/player/adapters/rustReportHost.ts`
  - Line: 13
  - Context: `Unknown`
- File: `src/lib/player/adapters/html5Adapter.ts`
  - Line: 20
  - Context: `Unknown`
- File: `src/lib/player/adapters/html5Adapter.ts`
  - Line: 215
  - Context: `Unknown`
- File: `src/lib/player/adapters/webviewAudioAdapter.ts`
  - Line: 19
  - Context: `Unknown`
- File: `src/lib/player/adapters/nativeAdapter.ts`
  - Line: 26
  - Context: `Unknown`
- File: `src/lib/player/adapters/nativeAdapter.ts`
  - Line: 59
  - Context: `Unknown`
- File: `src/lib/player/adapters/types.ts`
  - Line: 16
  - Context: `Unknown`
- File: `src/lib/player/transport.authority.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/player/index.ts`
  - Line: 15
  - Context: `Unknown`
- File: `src/lib/services/playerEvents.regression.test.ts`
  - Line: 9
  - Context: `Unknown`
- File: `src/lib/services/playbackCapabilities.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/services/playerEvents.ts`
  - Line: 8
  - Context: `Unknown`
- File: `src/lib/services/playerEvents.ts`
  - Line: 159
  - Context: `Unknown`
- File: `src/lib/services/playerEvents.ts`
  - Line: 189
  - Context: `Unknown`
- File: `src/lib/services/webviewAudio.ts`
  - Line: 18
  - Context: `Unknown`
- File: `src/lib/services/playerEvents.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/services/playbackReporting.ts`
  - Line: 10
  - Context: `Unknown`
- File: `src/lib/services/playbackReporting.ts`
  - Line: 27
  - Context: `Unknown`
- File: `src/lib/services/playbackReporting.ts`
  - Line: 76
  - Context: `* This function is therefore the *local* half only, which is what its ...`
- File: `src/lib/services/playbackReporting.ts`
  - Line: 107
  - Context: `Unknown`
- File: `src/lib/services/playbackReporting.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/utils/layoutShell.test.ts`
  - Line: 14
  - Context: `Unknown`
- File: `src/lib/utils/layoutShell.ts`
  - Line: 17
  - Context: `Unknown`
- File: `src/lib/utils/duration.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/stores/playerVisibility.test.ts`
  - Line: 9
  - Context: `Unknown`
- File: `src/lib/stores/appState.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src/lib/stores/player.ts`
  - Line: 8
  - Context: `Unknown`
- File: `src/lib/stores/player.ts`
  - Line: 31
  - Context: `Unknown`
- File: `src/lib/stores/queue.ts`
  - Line: 7
  - Context: `Unknown`
- File: `src/lib/stores/queue.ts`
  - Line: 108
  - Context: `Unknown`
- File: `src/lib/stores/queue.ts`
  - Line: 113
  - Context: `async function next() {`
- File: `src/lib/stores/queue.ts`
  - Line: 118
  - Context: `async function previous() {`
- File: `src/lib/stores/queue.ts`
  - Line: 123
  - Context: `async function skipTo(index: number) {`
- File: `src/lib/stores/queue.ts`
  - Line: 128
  - Context: `async function toggleShuffle() {`
- File: `src/lib/stores/queue.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/routes/player/[id]/+page.svelte`
  - Line: 666
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1629
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1857
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 2177
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 2194
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 2229
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/session.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/commands/mod.rs`
  - Line: 2
  - Context: `Unknown`
- File: `src-tauri/src/commands/conversions.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/player/media_player.rs`
  - Line: 49
  - Context: `Unknown`
- File: `src-tauri/src/player/media_player.rs`
  - Line: 311
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 48
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 294
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 315
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 600
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 646
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 670
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 717
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 726
  - Context: `Unknown`
- File: `src-tauri/src/player/backend.rs`
  - Line: 42
  - Context: `Unknown`
- File: `src-tauri/src/player/backend.rs`
  - Line: 46
  - Context: `Unknown`
- File: `src-tauri/src/player/backend.rs`
  - Line: 50
  - Context: `Unknown`
- File: `src-tauri/src/player/backend.rs`
  - Line: 54
  - Context: `Unknown`
- File: `src-tauri/src/player/backend.rs`
  - Line: 58
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 2
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 340
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 541
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 578
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 701
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 709
  - Context: `pub fn is_html5_active(&self) -> bool {`
- File: `src-tauri/src/player/mod.rs`
  - Line: 753
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 808
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 835
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 944
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1041
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1056
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1067
  - Context: `pub fn observed_duration(&self) -> Option<f64> {`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1105
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1118
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1427
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1706
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1722
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 2466
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 2505
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 3586
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4561
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4604
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4677
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4692
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4850
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4868
  - Context: `Unknown`
- File: `src-tauri/src/player/events.rs`
  - Line: 6
  - Context: `Unknown`
- File: `src-tauri/src/player/events.rs`
  - Line: 23
  - Context: `Unknown`
- File: `src-tauri/src/player/track_switch.rs`
  - Line: 63
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_player.rs`
  - Line: 13
  - Context: `Unknown`
- File: `src-tauri/src/player/queue.rs`
  - Line: 8
  - Context: `Unknown`
- File: `src-tauri/src/player/queue.rs`
  - Line: 20
  - Context: `pub enum RepeatMode {`
- File: `src-tauri/src/player/queue.rs`
  - Line: 535
  - Context: `pub enum AddPosition {`
- File: `src-tauri/src/player/legacy_player.rs`
  - Line: 236
  - Context: `Unknown`
- File: `src-tauri/src/player/state.rs`
  - Line: 7
  - Context: `Unknown`
- File: `src-tauri/src/player/state.rs`
  - Line: 25
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend_test.rs`
  - Line: 33
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend_test.rs`
  - Line: 77
  - Context: `Unknown`
- File: `src-tauri/src/player/webview_audio_backend.rs`
  - Line: 25
  - Context: `Unknown`
- File: `src-tauri/src/player/webview_audio_backend.rs`
  - Line: 184
  - Context: `Unknown`

### UR-009

Locations: 14 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 681
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 697
  - Context: `Unknown`
- File: `src/lib/services/deviceId.test.ts`
  - Line: 7
  - Context: `Unknown`
- File: `src/lib/services/deviceId.ts`
  - Line: 8
  - Context: `Unknown`
- File: `src/lib/services/deviceId.ts`
  - Line: 26
  - Context: `Unknown`
- File: `src/lib/stores/auth.ts`
  - Line: 6
  - Context: `Unknown`
- File: `src/lib/stores/auth.ts`
  - Line: 319
  - Context: `Unknown`
- File: `src/lib/stores/auth.ts`
  - Line: 346
  - Context: `Unknown`
- File: `src/lib/stores/auth.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src-tauri/src/commands/mod.rs`
  - Line: 2
  - Context: `Unknown`
- File: `src-tauri/src/commands/device.rs`
  - Line: 4
  - Context: `Unknown`
- File: `src-tauri/src/commands/device.rs`
  - Line: 24
  - Context: `Unknown`
- File: `src-tauri/src/commands/device.rs`
  - Line: 81
  - Context: `Unknown`
- File: `src-tauri/src/jellyfin/client.rs`
  - Line: 1
  - Context: `Unknown`

### UR-018

Locations: 20 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 922
  - Context: `Unknown`
- File: `src/lib/stores/downloads.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src/lib/stores/downloads.ts`
  - Line: 210
  - Context: `Unknown`
- File: `src/lib/stores/downloads.test.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 442
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 473
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 524
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 546
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 679
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 2527
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 3238
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 3303
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 3334
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 3367
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 3396
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 3410
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 3437
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 916
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 957
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 153
  - Context: `Unknown`

### UR-019

Locations: 18 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 1554
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3116
  - Context: `Unknown`
- File: `src/lib/services/playerEvents.ts`
  - Line: 8
  - Context: `Unknown`
- File: `src/lib/services/playerEvents.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/services/playbackReporting.ts`
  - Line: 10
  - Context: `Unknown`
- File: `src/lib/services/playbackReporting.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/routes/player/[id]/+page.svelte`
  - Line: 442
  - Context: `Unknown`
- File: `src-tauri/src/commands/storage/mod.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/commands/mod.rs`
  - Line: 2
  - Context: `Unknown`
- File: `src-tauri/src/commands/playback_reporting.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 307
  - Context: `Unknown`
- File: `src-tauri/src/storage/mod.rs`
  - Line: 157
  - Context: `pub fn file_size(&self) -> Option<u64> {`
- File: `src-tauri/src/player/mod.rs`
  - Line: 2
  - Context: `Unknown`
- File: `src-tauri/src/player/events.rs`
  - Line: 6
  - Context: `Unknown`
- File: `src-tauri/src/player/events.rs`
  - Line: 23
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1167
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1906
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2111
  - Context: `Unknown`

### UR-020

Locations: 56 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 191
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 2661
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 2813
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3621
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 323
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 482
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 500
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 509
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 986
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 2530
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 2563
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.menus.test.ts`
  - Line: 5
  - Context: `Unknown`
- File: `src/lib/components/player/subtitleTracks.test.ts`
  - Line: 18
  - Context: `Unknown`
- File: `src/lib/components/player/subtitleTracks.test.ts`
  - Line: 67
  - Context: `Unknown`
- File: `src/lib/components/player/subtitleTracks.test.ts`
  - Line: 93
  - Context: `Unknown`
- File: `src/lib/components/player/subtitleTracks.test.ts`
  - Line: 110
  - Context: `Unknown`
- File: `src/lib/components/player/subtitleTracks.test.ts`
  - Line: 233
  - Context: `Unknown`
- File: `src/lib/components/player/subtitleTracks.test.ts`
  - Line: 353
  - Context: `Unknown`
- File: `src/lib/components/player/subtitleTracks.ts`
  - Line: 20
  - Context: `Unknown`
- File: `src/lib/components/player/subtitleTracks.ts`
  - Line: 75
  - Context: `Unknown`
- File: `src/lib/components/player/subtitleTracks.ts`
  - Line: 210
  - Context: `Unknown`
- File: `src/lib/components/player/subtitleTracks.ts`
  - Line: 243
  - Context: `Unknown`
- File: `src/lib/player/adapters/html5Adapter.ts`
  - Line: 20
  - Context: `Unknown`
- File: `src/lib/player/adapters/types.ts`
  - Line: 16
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 236
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 459
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1941
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 3114
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 3171
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 3203
  - Context: `Unknown`
- File: `src-tauri/src/player/backend.rs`
  - Line: 119
  - Context: `Unknown`
- File: `src-tauri/src/player/media.rs`
  - Line: 42
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 84
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 99
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 120
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 150
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 184
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 382
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 395
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 433
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 451
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 465
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 479
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 914
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1001
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1589
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2296
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2409
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2457
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2605
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3174
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3550
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3573
  - Context: `Unknown`
- File: `src-tauri/src/repository/types.rs`
  - Line: 265
  - Context: `Unknown`

### UR-021

Locations: 18 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 142
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 178
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 323
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 408
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.menus.test.ts`
  - Line: 5
  - Context: `Unknown`
- File: `src/lib/player/adapters/html5Adapter.ts`
  - Line: 20
  - Context: `Unknown`
- File: `src/lib/player/adapters/types.ts`
  - Line: 16
  - Context: `Unknown`
- File: `src/routes/player/[id]/+page.svelte`
  - Line: 666
  - Context: `Unknown`
- File: `src-tauri/src/commands/storage/series_prefs.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1629
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1920
  - Context: `Unknown`
- File: `src-tauri/src/player/backend.rs`
  - Line: 106
  - Context: `Unknown`
- File: `src-tauri/src/player/track_switch.rs`
  - Line: 34
  - Context: `Unknown`
- File: `src-tauri/src/player/track_switch.rs`
  - Line: 63
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1859
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4688
  - Context: `Unknown`

### UR-022

Locations: 1 file(s)

- File: `src-tauri/src/commands/player/settings.rs`
  - Line: 3
  - Context: `Unknown`

### UR-023

Locations: 36 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 349
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1554
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1562
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3116
  - Context: `Unknown`
- File: `src/lib/services/nextEpisodeService.ts`
  - Line: 11
  - Context: `Unknown`
- File: `src/lib/services/playerEvents.ts`
  - Line: 8
  - Context: `Unknown`
- File: `src/lib/services/playerEvents.ts`
  - Line: 269
  - Context: `function handleMediaLoaded(duration: number): void {`
- File: `src/lib/services/playerEvents.ts`
  - Line: 374
  - Context: `Unknown`
- File: `src/lib/services/playerEvents.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/stores/home.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src/lib/stores/movies.ts`
  - Line: 3
  - Context: `Unknown`
- File: `src/lib/stores/tv.ts`
  - Line: 3
  - Context: `Unknown`
- File: `src/lib/stores/nextEpisode.ts`
  - Line: 9
  - Context: `Unknown`
- File: `src/routes/library/tv/+page.svelte`
  - Line: 8
  - Context: `Unknown`
- File: `src/routes/library/movies/+page.svelte`
  - Line: 8
  - Context: `Unknown`
- File: `src/routes/settings/+page.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/timers.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/timers.rs`
  - Line: 145
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 4
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 307
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 333
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 2
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 463
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1753
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1995
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 2286
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4164
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4188
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4211
  - Context: `Unknown`
- File: `src-tauri/src/player/events.rs`
  - Line: 6
  - Context: `Unknown`
- File: `src-tauri/src/player/events.rs`
  - Line: 23
  - Context: `Unknown`
- File: `src-tauri/src/player/autoplay.rs`
  - Line: 2
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1432
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1906
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1933
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 1
  - Context: `Unknown`

### UR-027

Locations: 23 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 251
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 2373
  - Context: `Unknown`
- File: `src/routes/settings/+page.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/settings.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/settings.rs`
  - Line: 50
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 811
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 831
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 900
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 912
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 986
  - Context: `Unknown`
- File: `src-tauri/src/player/android/mod.rs`
  - Line: 579
  - Context: `Unknown`
- File: `src-tauri/src/player/android/mod.rs`
  - Line: 617
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 35
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 48
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 142
  - Context: `pub fn with_crossfade_clamped(mut self) -> Self {`
- File: `src-tauri/src/settings.rs`
  - Line: 383
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 400
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 424
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 460
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 489
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 516
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 527
  - Context: `Unknown`

### UR-031

Locations: 2 file(s)

- File: `src-tauri/src/commands/player/settings.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 1
  - Context: `Unknown`

### UR-032

Locations: 7 file(s)

- File: `src-tauri/src/commands/player/settings.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/player/android/mod.rs`
  - Line: 579
  - Context: `Unknown`
- File: `src-tauri/src/player/android/mod.rs`
  - Line: 617
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 383
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 400
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 424
  - Context: `Unknown`

### UR-033

Locations: 12 file(s)

- File: `src-tauri/src/commands/player/settings.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 811
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 865
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 931
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 941
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 986
  - Context: `Unknown`
- File: `src-tauri/src/player/android/mod.rs`
  - Line: 579
  - Context: `Unknown`
- File: `src-tauri/src/player/android/mod.rs`
  - Line: 617
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 383
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 400
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 424
  - Context: `Unknown`

### UR-034

Locations: 27 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 1554
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1562
  - Context: `Unknown`
- File: `src/lib/components/home/heroRotation.ts`
  - Line: 8
  - Context: `Unknown`
- File: `src/lib/components/home/HeroBanner.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/home/Carousel.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/home/heroRotation.test.ts`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/utils/heroMix.ts`
  - Line: 8
  - Context: `Unknown`
- File: `src/lib/stores/home.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src/lib/stores/music.ts`
  - Line: 3
  - Context: `Unknown`
- File: `src/lib/stores/movies.ts`
  - Line: 3
  - Context: `Unknown`
- File: `src/lib/stores/tv.ts`
  - Line: 3
  - Context: `Unknown`
- File: `src/routes/library/tv/+page.svelte`
  - Line: 8
  - Context: `Unknown`
- File: `src/routes/library/movies/+page.svelte`
  - Line: 8
  - Context: `Unknown`
- File: `src/routes/library/music/+page.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 4
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 307
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 333
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1299
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1315
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1335
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1374
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1875
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2111
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4284
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4326
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4353
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4369
  - Context: `Unknown`

### UR-038

Locations: 1 file(s)

- File: `src/routes/library/[id]/+page.svelte`
  - Line: 1
  - Context: `Unknown`

### UR-042

Locations: 2 file(s)

- File: `src-tauri/src/commands/auth.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/auth/mod.rs`
  - Line: 139
  - Context: `Unknown`

### UR-044

Locations: 1 file(s)

- File: `src-tauri/src/commands/download/pinning.rs`
  - Line: 3
  - Context: `Unknown`

### UR-047

Locations: 1 file(s)

- File: `src-tauri/src/commands/storage/mod.rs`
  - Line: 3
  - Context: `Unknown`

### UR-049

Locations: 22 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 1637
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3420
  - Context: `export type SearchResult = { items: MediaItem[]; totalRecordCount: num...`
- File: `src/lib/components/search/HeaderSearch.test.ts`
  - Line: 9
  - Context: `Unknown`
- File: `src/lib/components/search/HeaderSearch.svelte`
  - Line: 12
  - Context: `Unknown`
- File: `src/lib/components/search/SearchScopeChips.svelte`
  - Line: 7
  - Context: `Unknown`
- File: `src/lib/utils/searchScope.ts`
  - Line: 9
  - Context: `Unknown`
- File: `src/lib/utils/searchScope.ts`
  - Line: 36
  - Context: `Unknown`
- File: `src/lib/utils/searchScope.ts`
  - Line: 61
  - Context: `Unknown`
- File: `src/lib/utils/searchScope.ts`
  - Line: 91
  - Context: `Unknown`
- File: `src/lib/utils/searchScope.ts`
  - Line: 125
  - Context: `Unknown`
- File: `src/lib/utils/layoutShell.ts`
  - Line: 101
  - Context: `Unknown`
- File: `src/lib/stores/librarySearchScope.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/stores/library.ts`
  - Line: 250
  - Context: `Unknown`
- File: `src/routes/library/+page.svelte`
  - Line: 26
  - Context: `Unknown`
- File: `src/routes/search/+page.svelte`
  - Line: 21
  - Context: `Unknown`
- File: `src/routes/search/+page.svelte`
  - Line: 34
  - Context: `Unknown`
- File: `src/routes/search/+page.svelte`
  - Line: 62
  - Context: `Unknown`
- File: `src/routes/search/searchPage.test.ts`
  - Line: 14
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 494
  - Context: `pub struct SearchUpdateEvent {`
- File: `src-tauri/src/repository/types.rs`
  - Line: 385
  - Context: `Unknown`
- File: `src-tauri/src/repository/types.rs`
  - Line: 403
  - Context: `impl SearchScope {`
- File: `src-tauri/src/repository/types.rs`
  - Line: 469
  - Context: `impl SearchOptions {`

### UR-050

Locations: 9 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 1637
  - Context: `Unknown`
- File: `src/lib/components/search/SearchResults.svelte`
  - Line: 9
  - Context: `Unknown`
- File: `src/lib/components/settings/SearchGroupOrderList.svelte`
  - Line: 8
  - Context: `Unknown`
- File: `src/lib/utils/searchScope.ts`
  - Line: 9
  - Context: `Unknown`
- File: `src/lib/utils/searchScope.ts`
  - Line: 234
  - Context: `Unknown`
- File: `src/lib/utils/searchScope.ts`
  - Line: 284
  - Context: `Unknown`
- File: `src/lib/stores/searchGroupOrder.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/stores/searchGroupOrder.ts`
  - Line: 6
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 494
  - Context: `pub struct SearchUpdateEvent {`

### UR-055

Locations: 56 file(s)

- File: `src/lib/api/repository-client.ts`
  - Line: 100
  - Context: `Unknown`
- File: `src/lib/api/repository-client.ts`
  - Line: 108
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 922
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 964
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 977
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1014
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1512
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1520
  - Context: `Unknown`
- File: `src/lib/components/library/MediaCard.svelte`
  - Line: 29
  - Context: `Unknown`
- File: `src/lib/components/library/MediaCard.svelte`
  - Line: 34
  - Context: `Unknown`
- File: `src/lib/components/downloads/DownloadedBrowse.svelte`
  - Line: 10
  - Context: `Unknown`
- File: `src/lib/services/downloadedCatalog.ts`
  - Line: 13
  - Context: `Unknown`
- File: `src/lib/services/downloadedCatalog.ts`
  - Line: 96
  - Context: `function sizeOf(itemId: string): number {`
- File: `src/lib/stores/downloads.ts`
  - Line: 210
  - Context: `Unknown`
- File: `src/lib/stores/downloads.test.ts`
  - Line: 316
  - Context: `Unknown`
- File: `src/routes/downloads/+page.svelte`
  - Line: 14
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 442
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 473
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 524
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 546
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 679
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 1226
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 1260
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 2425
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 3238
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 3303
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 3334
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 3367
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 3396
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 3410
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 3437
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 222
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 237
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 916
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 957
  - Context: `Unknown`
- File: `src-tauri/src/download/stop.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/download/worker.rs`
  - Line: 42
  - Context: `Unknown`
- File: `src-tauri/src/download/worker.rs`
  - Line: 247
  - Context: `/ One function so the writer and the cleaners cannot disagree again.`
- File: `src-tauri/src/download/worker.rs`
  - Line: 336
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 153
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 274
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 289
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 100
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 882
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 891
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 918
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 1024
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 3787
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 3809
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 3849
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 3919
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 3965
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 3986
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 4005
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 4066
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 5036
  - Context: `Unknown`

### UR-056

Locations: 17 file(s)

- File: `src/lib/api/repository-client.ts`
  - Line: 116
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1528
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 2298
  - Context: `Unknown`
- File: `src/lib/components/library/LibraryGrid.svelte`
  - Line: 17
  - Context: `Unknown`
- File: `src/lib/components/library/MediaCard.svelte`
  - Line: 23
  - Context: `Unknown`
- File: `src/lib/components/library/MediaCard.svelte`
  - Line: 34
  - Context: `Unknown`
- File: `src/lib/components/downloads/DownloadedBrowse.svelte`
  - Line: 10
  - Context: `Unknown`
- File: `src/lib/services/downloadedCatalog.ts`
  - Line: 13
  - Context: `Unknown`
- File: `src/lib/services/downloadedCatalog.ts`
  - Line: 96
  - Context: `function sizeOf(itemId: string): number {`
- File: `src/lib/utils/formatBytes.ts`
  - Line: 8
  - Context: `Unknown`
- File: `src/lib/utils/formatBytes.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/routes/downloads/+page.svelte`
  - Line: 14
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 254
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 296
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 1070
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 4027
  - Context: `Unknown`
- File: `src-tauri/src/repository/types.rs`
  - Line: 303
  - Context: `Unknown`

### UR-057

Locations: 1 file(s)

- File: `src/routes/settings/+page.svelte`
  - Line: 1
  - Context: `Unknown`

### UR-062

Locations: 21 file(s)

- File: `src/lib/api/repository-client.ts`
  - Line: 151
  - Context: `Unknown`
- File: `src/lib/api/repository-client.ts`
  - Line: 161
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1575
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1588
  - Context: `Unknown`
- File: `src/lib/components/library/episodeStrip.ts`
  - Line: 8
  - Context: `Unknown`
- File: `src/lib/components/library/SeasonSection.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/library/SeasonSection.svelte`
  - Line: 20
  - Context: `Unknown`
- File: `src/lib/components/library/EpisodeRow.svelte`
  - Line: 17
  - Context: `Unknown`
- File: `src/lib/components/library/seriesNavigation.ts`
  - Line: 13
  - Context: `Unknown`
- File: `src/lib/components/library/seriesNavigation.test.ts`
  - Line: 1
  - Context: `Unknown`
- File: `src/routes/library/[id]/+page.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/routes/library/[id]/+page.svelte`
  - Line: 125
  - Context: `Unknown`
- File: `src/routes/library/[id]/+page.svelte`
  - Line: 209
  - Context: `Unknown`
- File: `src/routes/library/[id]/+page.svelte`
  - Line: 319
  - Context: `async function handlePlayAll() {`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 356
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 377
  - Context: `Unknown`
- File: `src-tauri/src/repository/series_progress.rs`
  - Line: 12
  - Context: `Unknown`
- File: `src-tauri/src/repository/series_progress.rs`
  - Line: 474
  - Context: `Unknown`
- File: `src-tauri/src/repository/series_progress.rs`
  - Line: 491
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 716
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 4886
  - Context: `Unknown`

### UR-064

Locations: 7 file(s)

- File: `src/lib/api/repository-client.ts`
  - Line: 172
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1602
  - Context: `Unknown`
- File: `src/lib/components/library/ClearHistoryButton.svelte`
  - Line: 9
  - Context: `Unknown`
- File: `src/lib/components/library/SeasonSection.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 399
  - Context: `Unknown`
- File: `src-tauri/src/repository/mod.rs`
  - Line: 250
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2794
  - Context: `Unknown`

### UT-085

Locations: 2 file(s)

- File: `src/lib/components/player/tapGestures.ts`
  - Line: 21
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 931
  - Context: `Unknown`

### UT-086

Locations: 2 file(s)

- File: `src/lib/components/player/tapGestures.ts`
  - Line: 21
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 941
  - Context: `Unknown`

### UT-087

Locations: 2 file(s)

- File: `src/lib/components/player/tapGestures.ts`
  - Line: 21
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 986
  - Context: `Unknown`

### UT-088

Locations: 1 file(s)

- File: `src/lib/components/player/tapGestures.ts`
  - Line: 21
  - Context: `Unknown`

### UT-091

Locations: 2 file(s)

- File: `src/lib/player/transport.authority.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/player/index.ts`
  - Line: 15
  - Context: `Unknown`

### UT-093

Locations: 2 file(s)

- File: `src/lib/components/player/playerSurface.test.ts`
  - Line: 13
  - Context: `Unknown`
- File: `src/lib/components/player/playerSurface.ts`
  - Line: 8
  - Context: `Unknown`

### UT-107

Locations: 6 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 1764
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 893
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 908
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 927
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 1081
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 1106
  - Context: `Unknown`

### UT-117

Locations: 8 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 372
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/timers.rs`
  - Line: 299
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1810
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1843
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1900
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1931
  - Context: `Unknown`
- File: `src-tauri/src/player/stream_end.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/player/stream_end.rs`
  - Line: 54
  - Context: `Unknown`

### UT-123

Locations: 2 file(s)

- File: `src/lib/services/pendingSync.logic.test.ts`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/services/pendingSync.logic.ts`
  - Line: 9
  - Context: `Unknown`

### UT-124

Locations: 1 file(s)

- File: `src/lib/player/localSource.ts`
  - Line: 17
  - Context: `Unknown`

### UT-137

Locations: 1 file(s)

- File: `src/lib/services/offlineCatalog.reload.test.ts`
  - Line: 11
  - Context: `Unknown`

### UT-138

Locations: 1 file(s)

- File: `src/lib/services/offlineCatalog.reload.test.ts`
  - Line: 11
  - Context: `Unknown`

### UT-139

Locations: 1 file(s)

- File: `src/lib/services/offlineCatalog.reload.test.ts`
  - Line: 11
  - Context: `Unknown`

### UT-143

Locations: 3 file(s)

- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 500
  - Context: `Unknown`
- File: `src/lib/components/player/subtitleTracks.test.ts`
  - Line: 18
  - Context: `Unknown`
- File: `src/lib/components/player/subtitleTracks.ts`
  - Line: 20
  - Context: `Unknown`

### UT-144

Locations: 3 file(s)

- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 500
  - Context: `Unknown`
- File: `src/lib/components/player/subtitleTracks.test.ts`
  - Line: 18
  - Context: `Unknown`
- File: `src/lib/components/player/subtitleTracks.ts`
  - Line: 20
  - Context: `Unknown`

### UT-146

Locations: 3 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 3621
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 3203
  - Context: `Unknown`
- File: `src-tauri/src/player/media.rs`
  - Line: 42
  - Context: `Unknown`

### UT-147

Locations: 8 file(s)

- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 509
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 986
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 2563
  - Context: `Unknown`
- File: `src/lib/components/player/subtitleTracks.test.ts`
  - Line: 233
  - Context: `Unknown`
- File: `src/lib/components/player/subtitleTracks.test.ts`
  - Line: 353
  - Context: `Unknown`
- File: `src/lib/components/player/subtitleTracks.ts`
  - Line: 20
  - Context: `Unknown`
- File: `src/lib/components/player/subtitleTracks.ts`
  - Line: 210
  - Context: `Unknown`
- File: `src/lib/components/player/subtitleTracks.ts`
  - Line: 243
  - Context: `Unknown`

### UT-149

Locations: 2 file(s)

- File: `src/lib/player/adapters/adapterSelection.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/player/adapters/index.ts`
  - Line: 20
  - Context: `Unknown`

### UT-151

Locations: 7 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 1732
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 380
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 856
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 885
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 915
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 954
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 764
  - Context: `Unknown`

### UT-167

Locations: 1 file(s)

- File: `src/lib/components/library/libraryMosaic.ts`
  - Line: 12
  - Context: `Unknown`

### UT-182

Locations: 5 file(s)

- File: `src/lib/api/repository-client.ts`
  - Line: 235
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1655
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 592
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 591
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3471
  - Context: `Unknown`

### UR-002

Locations: 25 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 3041
  - Context: `Unknown`
- File: `src/lib/services/syncService.ts`
  - Line: 7
  - Context: `Unknown`
- File: `src/lib/services/offlineCatalog.ts`
  - Line: 14
  - Context: `Unknown`
- File: `src/lib/stores/auth.waitForRepository.test.ts`
  - Line: 13
  - Context: `Unknown`
- File: `src/lib/stores/auth.ts`
  - Line: 81
  - Context: `Unknown`
- File: `src/lib/stores/connectivity.ts`
  - Line: 10
  - Context: `Unknown`
- File: `src/routes/player/[id]/+page.svelte`
  - Line: 362
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync.rs`
  - Line: 7
  - Context: `Unknown`
- File: `src-tauri/src/commands/storage/mod.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/commands/mod.rs`
  - Line: 2
  - Context: `Unknown`
- File: `src-tauri/src/commands/offline.rs`
  - Line: 136
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 14
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 236
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/storage/schema.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/storage/mod.rs`
  - Line: 157
  - Context: `pub fn file_size(&self) -> Option<u64> {`
- File: `src-tauri/src/storage/db_service.rs`
  - Line: 311
  - Context: `Unknown`
- File: `src-tauri/src/lib.rs`
  - Line: 1689
  - Context: `Unknown`
- File: `src-tauri/src/player/media.rs`
  - Line: 154
  - Context: `pub enum MediaType {`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 8
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 381
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 426
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 653
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1
  - Context: `Unknown`

### UR-007

Locations: 36 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 2435
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 2602
  - Context: `Unknown`
- File: `src/lib/components/library/GenericMediaListPage.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/library/alphabetStrip.ts`
  - Line: 22
  - Context: `Unknown`
- File: `src/lib/components/library/AlphabetScrollBar.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/library/alphabetStrip.test.ts`
  - Line: 18
  - Context: `Unknown`
- File: `src/lib/services/imageCache.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/services/imageCache.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src/lib/stores/music.ts`
  - Line: 3
  - Context: `Unknown`
- File: `src/lib/stores/movies.ts`
  - Line: 3
  - Context: `Unknown`
- File: `src/lib/stores/library.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src/lib/stores/library.ts`
  - Line: 122
  - Context: `Unknown`
- File: `src/lib/stores/tv.ts`
  - Line: 3
  - Context: `Unknown`
- File: `src/lib/stores/libraryListingOrder.test.ts`
  - Line: 10
  - Context: `Unknown`
- File: `src/routes/library/tv/+page.svelte`
  - Line: 8
  - Context: `Unknown`
- File: `src/routes/library/movies/+page.svelte`
  - Line: 8
  - Context: `Unknown`
- File: `src/routes/library/music/+page.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/routes/library/[id]/+page.svelte`
  - Line: 176
  - Context: `Unknown`
- File: `src-tauri/src/commands/storage/thumbnails.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 4
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/domain/media.rs`
  - Line: 57
  - Context: `Unknown`
- File: `src-tauri/src/domain/from_jellyfin.rs`
  - Line: 151
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 100
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 5036
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 5079
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 5119
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1199
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1210
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1237
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4109
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4145
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4180
  - Context: `Unknown`
- File: `src-tauri/src/repository/types.rs`
  - Line: 350
  - Context: `Unknown`
- File: `src-tauri/src/repository/types.rs`
  - Line: 367
  - Context: `Unknown`

### UR-008

Locations: 4 file(s)

- File: `src/lib/components/library/GenericMediaListPage.searchEvent.test.ts`
  - Line: 12
  - Context: `Unknown`
- File: `src/lib/stores/library.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 4
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2194
  - Context: `Unknown`

### UR-011

Locations: 11 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 3041
  - Context: `Unknown`
- File: `src/lib/services/preload.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/services/preload.ts`
  - Line: 5
  - Context: `Unknown`
- File: `src/lib/stores/downloads.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src/lib/stores/downloads.test.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src-tauri/src/commands/storage/mod.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 2527
  - Context: `Unknown`
- File: `src-tauri/src/commands/mod.rs`
  - Line: 2
  - Context: `Unknown`
- File: `src-tauri/src/commands/offline.rs`
  - Line: 136
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 878
  - Context: `Unknown`
- File: `src-tauri/src/player/media.rs`
  - Line: 154
  - Context: `pub enum MediaType {`

### UR-026

Locations: 20 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 349
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3116
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3445
  - Context: `export type SessionInfo = { id?: string | null; userId?: string | null...`
- File: `src/lib/components/player/SleepTimerModal.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/services/playerEvents.ts`
  - Line: 8
  - Context: `Unknown`
- File: `src/lib/services/playerEvents.ts`
  - Line: 269
  - Context: `function handleMediaLoaded(duration: number): void {`
- File: `src/lib/services/playerEvents.ts`
  - Line: 329
  - Context: `Unknown`
- File: `src/lib/services/playerEvents.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/stores/sleepTimer.ts`
  - Line: 10
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/timers.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/timers.rs`
  - Line: 145
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 2
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 463
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4211
  - Context: `Unknown`
- File: `src-tauri/src/player/events.rs`
  - Line: 6
  - Context: `Unknown`
- File: `src-tauri/src/player/events.rs`
  - Line: 23
  - Context: `Unknown`
- File: `src-tauri/src/player/sleep_timer.rs`
  - Line: 4
  - Context: `Unknown`
- File: `src-tauri/src/player/sleep_timer.rs`
  - Line: 81
  - Context: `pub fn cancel(&mut self) {`
- File: `src-tauri/src/player/autoplay.rs`
  - Line: 2
  - Context: `Unknown`

### UR-036

Locations: 5 file(s)

- File: `src/lib/components/library/PersonDetailView.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/commands/storage/people.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 4
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2888
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2901
  - Context: `Unknown`

### UR-037

Locations: 4 file(s)

- File: `src/lib/components/library/LibraryGrid.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/library/MediaCard.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/routes/library/tv/+page.svelte`
  - Line: 8
  - Context: `Unknown`
- File: `src/routes/library/movies/+page.svelte`
  - Line: 8
  - Context: `Unknown`

### UR-040

Locations: 94 file(s)

- File: `src/lib/api/repository-client.ts`
  - Line: 281
  - Context: `Unknown`
- File: `src/lib/api/repository-client.test.ts`
  - Line: 442
  - Context: `Unknown`
- File: `src/lib/api/repository-client.test.ts`
  - Line: 459
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 42
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 63
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 69
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 349
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 372
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1687
  - Context: `Unknown`
- File: `src/lib/components/player/backgroundAudioHandoff.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/components/player/backgroundAudioHandoff.ts`
  - Line: 69
  - Context: `Unknown`
- File: `src/lib/components/player/backgroundAudioHandoff.ts`
  - Line: 110
  - Context: `Unknown`
- File: `src/lib/components/player/backgroundAudioHandoff.ts`
  - Line: 128
  - Context: `Unknown`
- File: `src/lib/components/player/backgroundAudioHandoff.ts`
  - Line: 166
  - Context: `export function enteringPictureInPicture(_current: BackgroundBehaviour...`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 293
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 1310
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 2069
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 2098
  - Context: `Unknown`
- File: `src/lib/components/player/backgroundAudioHandoff.test.ts`
  - Line: 15
  - Context: `Unknown`
- File: `src/lib/components/player/backgroundAudioHandoff.test.ts`
  - Line: 98
  - Context: `Unknown`
- File: `src/lib/player/backgroundAudio.commands.test.ts`
  - Line: 7
  - Context: `Unknown`
- File: `src/lib/services/playerEvents.regression.test.ts`
  - Line: 155
  - Context: `Unknown`
- File: `src/lib/services/playerEvents.ts`
  - Line: 292
  - Context: `Unknown`
- File: `src/lib/utils/backgroundAudio.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/utils/backgroundAudio.test.ts`
  - Line: 7
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/timers.rs`
  - Line: 145
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/timers.rs`
  - Line: 299
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 497
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 517
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 787
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 914
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 942
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1481
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 3285
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 3305
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 640
  - Context: `Unknown`
- File: `src-tauri/src/lib.rs`
  - Line: 491
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 48
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 315
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 600
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 646
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 670
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 154
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 296
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 307
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 316
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 808
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 944
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1041
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1118
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1671
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1692
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1706
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1722
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1731
  - Context: `pub fn exit_background_audio(&self) -> f64 {`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1738
  - Context: `pub fn is_background_audio_active(&self) -> bool {`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1753
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1810
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1843
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1900
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1931
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1995
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 2286
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 2466
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 2505
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4164
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4498
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4561
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4641
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4660
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4706
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4793
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4821
  - Context: `Unknown`
- File: `src-tauri/src/player/background_policy.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/player/background_policy.rs`
  - Line: 53
  - Context: `Unknown`
- File: `src-tauri/src/player/background_policy.rs`
  - Line: 80
  - Context: `Unknown`
- File: `src-tauri/src/player/seek.rs`
  - Line: 121
  - Context: `Unknown`
- File: `src-tauri/src/player/seek.rs`
  - Line: 170
  - Context: `Unknown`
- File: `src-tauri/src/player/seek.rs`
  - Line: 186
  - Context: `/ which backends reject (the "Raw(-10)" class of error).`
- File: `src-tauri/src/player/seek.rs`
  - Line: 199
  - Context: `/ this function exists to prevent.`
- File: `src-tauri/src/player/seek.rs`
  - Line: 210
  - Context: `Unknown`
- File: `src-tauri/src/player/seek.rs`
  - Line: 272
  - Context: `Unknown`
- File: `src-tauri/src/player/stream_end.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/player/stream_end.rs`
  - Line: 54
  - Context: `Unknown`
- File: `src-tauri/src/player/stream_end.rs`
  - Line: 91
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_player.rs`
  - Line: 13
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend_test.rs`
  - Line: 77
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 121
  - Context: `Unknown`
- File: `src-tauri/src/repository/mod.rs`
  - Line: 134
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 700
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3394
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3648
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3689
  - Context: `Unknown`

### UR-041

Locations: 9 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 42
  - Context: `Unknown`
- File: `src/lib/components/player/timeTracking.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/components/player/backgroundAudioHandoff.ts`
  - Line: 128
  - Context: `Unknown`
- File: `src/lib/components/player/backgroundAudioHandoff.ts`
  - Line: 166
  - Context: `export function enteringPictureInPicture(_current: BackgroundBehaviour...`
- File: `src/lib/components/player/VideoPlayer.nativeReveal.test.ts`
  - Line: 16
  - Context: `Unknown`
- File: `src/lib/utils/pictureInPicture.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/utils/pictureInPicture.ts`
  - Line: 111
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 914
  - Context: `Unknown`
- File: `src-tauri/src/player/background_policy.rs`
  - Line: 53
  - Context: `Unknown`

### UR-048

Locations: 6 file(s)

- File: `src/lib/components/library/EpisodeFocusView.svelte`
  - Line: 7
  - Context: `Unknown`
- File: `src/lib/components/library/EpisodeFocusView.svelte`
  - Line: 278
  - Context: `Unknown`
- File: `src/lib/components/library/EpisodeFocusView.svelte`
  - Line: 383
  - Context: `Unknown`
- File: `src/lib/components/library/episodeStrip.ts`
  - Line: 8
  - Context: `Unknown`
- File: `src/lib/components/library/EpisodeFocusView.test.ts`
  - Line: 7
  - Context: `Unknown`
- File: `src/routes/library/[id]/+page.svelte`
  - Line: 1
  - Context: `Unknown`

### UR-051

Locations: 2 file(s)

- File: `src/lib/components/library/LibraryGrid.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/library/MediaCard.svelte`
  - Line: 1
  - Context: `Unknown`

### UR-053

Locations: 17 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 1231
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1242
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 2677
  - Context: `export type MergedMediaItem = { id: string; title: string; artist: str...`
- File: `src/lib/api/bindings.ts`
  - Line: 2686
  - Context: `export type NetworkStateWrapperArg = { networkType: NetworkType; unmet...`
- File: `src/lib/services/networkType.ts`
  - Line: 10
  - Context: `Unknown`
- File: `src/lib/services/networkType.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/stores/downloads.ts`
  - Line: 84
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 31
  - Context: `pub struct DownloadManagerWrapper(pub Mutex<DownloadManager>);`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 40
  - Context: `pub struct NetworkStateWrapper(pub NetworkStateHandle);`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 83
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 96
  - Context: `pub struct NetworkStateWrapperArg {`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 1744
  - Context: `Unknown`
- File: `src-tauri/src/download/network.rs`
  - Line: 22
  - Context: `Unknown`
- File: `src-tauri/src/download/network.rs`
  - Line: 42
  - Context: `Unknown`
- File: `src-tauri/src/download/network.rs`
  - Line: 70
  - Context: `impl NetworkState {`
- File: `src-tauri/src/download/events.rs`
  - Line: 47
  - Context: `Unknown`
- File: `src-tauri/src/download/cache.rs`
  - Line: 86
  - Context: `Unknown`

### UR-058

Locations: 11 file(s)

- File: `src/lib/components/library/EpisodeFocusView.svelte`
  - Line: 7
  - Context: `Unknown`
- File: `src/lib/components/library/EpisodeFocusView.svelte`
  - Line: 239
  - Context: `Unknown`
- File: `src/lib/components/library/MediaCard.svelte`
  - Line: 42
  - Context: `Unknown`
- File: `src/lib/components/library/EpisodeFocusView.test.ts`
  - Line: 7
  - Context: `Unknown`
- File: `src/routes/library/[id]/+page.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/routes/library/[id]/+page.svelte`
  - Line: 140
  - Context: `Unknown`
- File: `src/routes/library/[id]/+page.svelte`
  - Line: 237
  - Context: `Unknown`
- File: `src/routes/library/[id]/+page.svelte`
  - Line: 460
  - Context: `Unknown`
- File: `src/routes/search/+page.svelte`
  - Line: 97
  - Context: `Unknown`
- File: `src/routes/+page.svelte`
  - Line: 95
  - Context: `Unknown`
- File: `src/routes/+page.svelte`
  - Line: 119
  - Context: `Unknown`

### UR-067

Locations: 42 file(s)

- File: `src/lib/api/repository-client.ts`
  - Line: 384
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1764
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 2426
  - Context: `export type GetItemsOptions = { startIndex?: number | null; limit?: nu...`
- File: `src/lib/components/library/GenericMediaListPage.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/library/GenericMediaListPage.svelte`
  - Line: 152
  - Context: `Unknown`
- File: `src/lib/components/library/GenericMediaListPage.svelte`
  - Line: 168
  - Context: `function handleSearch(query: string) {`
- File: `src/lib/components/library/GenericMediaListPage.svelte`
  - Line: 281
  - Context: `Unknown`
- File: `src/lib/components/library/libraryMosaic.ts`
  - Line: 12
  - Context: `Unknown`
- File: `src/lib/utils/favoritesView.test.ts`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/utils/favoritesView.ts`
  - Line: 8
  - Context: `Unknown`
- File: `src/lib/stores/favorites.test.ts`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/stores/home.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src/lib/stores/home.ts`
  - Line: 18
  - Context: `Unknown`
- File: `src/lib/stores/home.ts`
  - Line: 64
  - Context: `Unknown`
- File: `src/lib/stores/favorites.ts`
  - Line: 79
  - Context: `Unknown`
- File: `src/routes/library/favorites/+page.svelte`
  - Line: 9
  - Context: `Unknown`
- File: `src/routes/library/favorites/+page.svelte`
  - Line: 44
  - Context: `Unknown`
- File: `src/routes/library/+page.svelte`
  - Line: 226
  - Context: `Unknown`
- File: `src/routes/library/+page.svelte`
  - Line: 289
  - Context: `Unknown`
- File: `src/routes/+page.svelte`
  - Line: 183
  - Context: `Unknown`
- File: `src/routes/+page.svelte`
  - Line: 292
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 927
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 964
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 214
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 230
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 1050
  - Context: `Unknown`
- File: `src-tauri/src/repository/mod.rs`
  - Line: 238
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 1322
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 2096
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 4452
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 4487
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 4532
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 4635
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1199
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1273
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1455
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2724
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4017
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4039
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4048
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4070
  - Context: `Unknown`
- File: `src-tauri/src/repository/types.rs`
  - Line: 342
  - Context: `Unknown`

### UR-068

Locations: 14 file(s)

- File: `src/lib/components/library/PlaylistDetailView.svelte`
  - Line: 229
  - Context: `Unknown`
- File: `src/lib/components/library/EpisodeFocusView.svelte`
  - Line: 239
  - Context: `Unknown`
- File: `src/lib/components/library/MediaCard.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/library/MediaCard.svelte`
  - Line: 48
  - Context: `Unknown`
- File: `src/lib/components/library/MediaCard.svelte`
  - Line: 154
  - Context: `Unknown`
- File: `src/lib/components/library/MediaCard.svelte`
  - Line: 329
  - Context: `Unknown`
- File: `src/lib/components/library/ArtistDetailView.svelte`
  - Line: 138
  - Context: `Unknown`
- File: `src/lib/components/FavoriteButton.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/services/favorites.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src/lib/services/favorites.ts`
  - Line: 38
  - Context: `Unknown`
- File: `src/lib/stores/favorites.test.ts`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/stores/favorites.ts`
  - Line: 8
  - Context: `Unknown`
- File: `src/lib/stores/favorites.ts`
  - Line: 55
  - Context: `Unknown`
- File: `src/routes/library/[id]/+page.svelte`
  - Line: 612
  - Context: `Unknown`

### UR-070

Locations: 17 file(s)

- File: `src/lib/api/repository-client.ts`
  - Line: 263
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1673
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3320
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 361
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 3091
  - Context: `Unknown`
- File: `src/routes/player/[id]/+page.svelte`
  - Line: 422
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 396
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 621
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 106
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 111
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 241
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 363
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 403
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 419
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 433
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 443
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 951
  - Context: `Unknown`

### UR-071

Locations: 66 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 408
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1054
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1070
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 2205
  - Context: `Unknown`
- File: `src/lib/player/localSource.ts`
  - Line: 17
  - Context: `Unknown`
- File: `src/routes/player/[id]/+page.svelte`
  - Line: 315
  - Context: `Unknown`
- File: `src/routes/player/[id]/+page.svelte`
  - Line: 329
  - Context: `Unknown`
- File: `src/routes/player/[id]/+page.svelte`
  - Line: 406
  - Context: `Unknown`
- File: `src-tauri/src/commands/storage/mod.rs`
  - Line: 93
  - Context: `Unknown`
- File: `src-tauri/src/commands/storage/mod.rs`
  - Line: 117
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 476
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 497
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 517
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 547
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 599
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 805
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 3237
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 3285
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 3305
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 3321
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 359
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 114
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 490
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 551
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 689
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 1001
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 1039
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 1062
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 1090
  - Context: `Unknown`
- File: `src-tauri/src/storage/schema.rs`
  - Line: 814
  - Context: `Unknown`
- File: `src-tauri/src/lib.rs`
  - Line: 1170
  - Context: `Unknown`
- File: `src-tauri/src/lib.rs`
  - Line: 1592
  - Context: `Unknown`
- File: `src-tauri/src/media_server.rs`
  - Line: 27
  - Context: `Unknown`
- File: `src-tauri/src/media_server.rs`
  - Line: 70
  - Context: `pub fn url_for(&self, path: &str) -> String {`
- File: `src-tauri/src/media_server.rs`
  - Line: 282
  - Context: `Unknown`
- File: `src-tauri/src/media_server.rs`
  - Line: 327
  - Context: `Unknown`
- File: `src-tauri/src/media_server.rs`
  - Line: 398
  - Context: `Unknown`
- File: `src-tauri/src/media_server.rs`
  - Line: 449
  - Context: `Unknown`
- File: `src-tauri/src/media_server.rs`
  - Line: 458
  - Context: `Unknown`
- File: `src-tauri/src/media_server.rs`
  - Line: 473
  - Context: `Unknown`
- File: `src-tauri/src/media_server.rs`
  - Line: 508
  - Context: `Unknown`
- File: `src-tauri/src/media_server.rs`
  - Line: 515
  - Context: `Unknown`
- File: `src-tauri/src/media_server.rs`
  - Line: 540
  - Context: `Unknown`
- File: `src-tauri/src/media_server.rs`
  - Line: 567
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 949
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4498
  - Context: `Unknown`
- File: `src-tauri/src/download/cache.rs`
  - Line: 33
  - Context: `Unknown`
- File: `src-tauri/src/download/cache.rs`
  - Line: 220
  - Context: `Unknown`
- File: `src-tauri/src/download/cache.rs`
  - Line: 320
  - Context: `Unknown`
- File: `src-tauri/src/download/cache.rs`
  - Line: 428
  - Context: `Unknown`
- File: `src-tauri/src/download/cache.rs`
  - Line: 550
  - Context: `Unknown`
- File: `src-tauri/src/download/worker.rs`
  - Line: 219
  - Context: `Unknown`
- File: `src-tauri/src/download/worker.rs`
  - Line: 313
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 174
  - Context: `impl StreamSelection {`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 314
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 366
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 546
  - Context: `Unknown`
- File: `src-tauri/src/repository/mod.rs`
  - Line: 215
  - Context: `Unknown`
- File: `src-tauri/src/repository/mod.rs`
  - Line: 350
  - Context: `Unknown`
- File: `src-tauri/src/repository/mod.rs`
  - Line: 372
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2612
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2689
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3909
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3941
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3967
  - Context: `Unknown`
- File: `scripts/tauri-security-config.test.ts`
  - Line: 11
  - Context: `Unknown`

### UR-073

Locations: 6 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 871
  - Context: `Unknown`
- File: `src/lib/components/library/WatchedToggleButton.svelte`
  - Line: 13
  - Context: `Unknown`
- File: `src/lib/services/syncService.ts`
  - Line: 121
  - Context: `Unknown`
- File: `src-tauri/src/commands/storage/mod.rs`
  - Line: 915
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 1047
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 1064
  - Context: `Unknown`

### UR-074

Locations: 70 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 270
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 295
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3510
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3524
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3596
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3805
  - Context: `Unknown`
- File: `src/lib/components/player/hlsRecovery.ts`
  - Line: 11
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 323
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 344
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 929
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 2487
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 2511
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 3091
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.menus.test.ts`
  - Line: 5
  - Context: `Unknown`
- File: `src/lib/components/player/hlsRecovery.test.ts`
  - Line: 16
  - Context: `Unknown`
- File: `src/lib/player/adapters/html5Adapter.test.ts`
  - Line: 274
  - Context: `Unknown`
- File: `src/lib/player/index.ts`
  - Line: 202
  - Context: `Unknown`
- File: `src/routes/settings/+page.svelte`
  - Line: 86
  - Context: `Unknown`
- File: `src/routes/settings/+page.svelte`
  - Line: 425
  - Context: `function handleEpisodeLimitChange(value: number) {`
- File: `src/routes/settings/+page.svelte`
  - Line: 925
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 369
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 383
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 708
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1002
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1793
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1845
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1857
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 2577
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/settings.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/settings.rs`
  - Line: 25
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/settings.rs`
  - Line: 85
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/settings.rs`
  - Line: 107
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/settings.rs`
  - Line: 122
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/settings.rs`
  - Line: 158
  - Context: `Unknown`
- File: `src-tauri/src/lib.rs`
  - Line: 1549
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 2006
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 2438
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 30
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 46
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 56
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 67
  - Context: `pub fn set_streaming_quality(quality: StreamingQuality) {`
- File: `src-tauri/src/repository/online.rs`
  - Line: 74
  - Context: `pub fn streaming_quality() -> StreamingQuality {`
- File: `src-tauri/src/repository/online.rs`
  - Line: 85
  - Context: `pub fn set_playback_quality_override(quality: StreamingQuality) {`
- File: `src-tauri/src/repository/online.rs`
  - Line: 92
  - Context: `pub fn clear_playback_quality_override() {`
- File: `src-tauri/src/repository/online.rs`
  - Line: 105
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 122
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 136
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 152
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 530
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 591
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 737
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 781
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 821
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2436
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3321
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3339
  - Context: `impl Drop for QualityFixture {`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3349
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3371
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3394
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3471
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3521
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3617
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4704
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4752
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4785
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 167
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 302
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 653
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 689
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 716
  - Context: `Unknown`

### UR-076

Locations: 44 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 1146
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1158
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1176
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 2384
  - Context: `export type EqPreset = "flat" | "rock" | "pop" | "jazz" | "classical" ...`
- File: `src/lib/api/bindings.ts`
  - Line: 2482
  - Context: `Unknown`
- File: `src/lib/components/library/GenericMediaListPage.svelte`
  - Line: 123
  - Context: `Unknown`
- File: `src/lib/stores/music.ts`
  - Line: 106
  - Context: `Unknown`
- File: `src/routes/settings/+page.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/routes/settings/+page.svelte`
  - Line: 107
  - Context: `Unknown`
- File: `src/routes/settings/+page.svelte`
  - Line: 208
  - Context: `Unknown`
- File: `src/routes/settings/+page.svelte`
  - Line: 227
  - Context: `Unknown`
- File: `src/routes/settings/+page.svelte`
  - Line: 603
  - Context: `Unknown`
- File: `src-tauri/src/commands/library.rs`
  - Line: 10
  - Context: `Unknown`
- File: `src-tauri/src/commands/library.rs`
  - Line: 47
  - Context: `Unknown`
- File: `src-tauri/src/commands/library.rs`
  - Line: 68
  - Context: `Unknown`
- File: `src-tauri/src/commands/library.rs`
  - Line: 83
  - Context: `Unknown`
- File: `src-tauri/src/commands/library.rs`
  - Line: 112
  - Context: `Unknown`
- File: `src-tauri/src/commands/library.rs`
  - Line: 184
  - Context: `Unknown`
- File: `src-tauri/src/commands/library.rs`
  - Line: 220
  - Context: `Unknown`
- File: `src-tauri/src/commands/library.rs`
  - Line: 274
  - Context: `Unknown`
- File: `src-tauri/src/commands/library.rs`
  - Line: 295
  - Context: `Unknown`
- File: `src-tauri/src/commands/library.rs`
  - Line: 307
  - Context: `Unknown`
- File: `src-tauri/src/lib.rs`
  - Line: 1561
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 182
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 283
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 381
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 426
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 544
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 629
  - Context: `Unknown`
- File: `src-tauri/src/repository/exclusions.rs`
  - Line: 22
  - Context: `Unknown`
- File: `src-tauri/src/repository/exclusions.rs`
  - Line: 34
  - Context: `Unknown`
- File: `src-tauri/src/repository/exclusions.rs`
  - Line: 48
  - Context: `Unknown`
- File: `src-tauri/src/repository/exclusions.rs`
  - Line: 63
  - Context: `Unknown`
- File: `src-tauri/src/repository/exclusions.rs`
  - Line: 87
  - Context: `/ pure function and can be tested without touching process state.`
- File: `src-tauri/src/repository/exclusions.rs`
  - Line: 123
  - Context: `Unknown`
- File: `src-tauri/src/repository/exclusions.rs`
  - Line: 191
  - Context: `Unknown`
- File: `src-tauri/src/repository/exclusions.rs`
  - Line: 203
  - Context: `Unknown`
- File: `src-tauri/src/repository/exclusions.rs`
  - Line: 232
  - Context: `Unknown`
- File: `src-tauri/src/repository/exclusions.rs`
  - Line: 275
  - Context: `Unknown`
- File: `src-tauri/src/repository/exclusions.rs`
  - Line: 293
  - Context: `Unknown`
- File: `src-tauri/src/repository/exclusions.rs`
  - Line: 316
  - Context: `Unknown`
- File: `src-tauri/src/repository/exclusions.rs`
  - Line: 334
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 337
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 357
  - Context: `impl LibrarySettings {`

### UR-079

Locations: 92 file(s)

- File: `src/lib/api/repository-client.ts`
  - Line: 263
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 295
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1070
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1673
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 2508
  - Context: `export type LiveStreamInfo = { streamUrl: string; playSessionId: strin...`
- File: `src/lib/api/bindings.ts`
  - Line: 2768
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 2892
  - Context: `export type PlaybackInfo = { mediaSourceId: string; playSessionId: str...`
- File: `src/lib/api/bindings.ts`
  - Line: 3017
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3320
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3359
  - Context: `export type RemoteSessionStatus = { position: number; duration: number...`
- File: `src/lib/api/bindings.ts`
  - Line: 3524
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3537
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3578
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3674
  - Context: `* domain fact reconstructed in the presentation layer — the same class...`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 103
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 205
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 293
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 361
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 682
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 929
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 1002
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 2106
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 2487
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 2511
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 2951
  - Context: `Unknown`
- File: `src/lib/player/adapters/html5Adapter.ts`
  - Line: 40
  - Context: `Unknown`
- File: `src/lib/player/adapters/html5Adapter.ts`
  - Line: 80
  - Context: `Unknown`
- File: `src/lib/player/adapters/types.ts`
  - Line: 54
  - Context: `Unknown`
- File: `src/lib/player/adapters/types.ts`
  - Line: 130
  - Context: `Unknown`
- File: `src/lib/player/streamTransport.ts`
  - Line: 7
  - Context: `Unknown`
- File: `src/lib/player/streamTransport.ts`
  - Line: 61
  - Context: `Unknown`
- File: `src/lib/player/index.ts`
  - Line: 202
  - Context: `Unknown`
- File: `src/lib/player/streamTransport.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/routes/player/[id]/+page.svelte`
  - Line: 85
  - Context: `Unknown`
- File: `src/routes/player/[id]/+page.svelte`
  - Line: 422
  - Context: `Unknown`
- File: `src-tauri/src/commands/storage/mod.rs`
  - Line: 117
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 191
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 335
  - Context: `pub enum VideoSeekResponse {`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 357
  - Context: `pub enum AudioTrackSwitchResponse {`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 383
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 396
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 453
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 708
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1002
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1481
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1793
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1845
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 2577
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 621
  - Context: `Unknown`
- File: `src-tauri/src/player/media.rs`
  - Line: 126
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 106
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 15
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 33
  - Context: `/ domain fact reconstructed in the presentation layer — the same class...`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 55
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 76
  - Context: `impl PlaybackKind {`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 88
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 111
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 136
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 163
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 174
  - Context: `impl StreamSelection {`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 200
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 241
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 268
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 284
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 303
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 314
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 328
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 363
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 403
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 419
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 433
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 443
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 46
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 74
  - Context: `pub fn streaming_quality() -> StreamingQuality {`
- File: `src-tauri/src/repository/online.rs`
  - Line: 85
  - Context: `pub fn set_playback_quality_override(quality: StreamingQuality) {`
- File: `src-tauri/src/repository/online.rs`
  - Line: 92
  - Context: `pub fn clear_playback_quality_override() {`
- File: `src-tauri/src/repository/online.rs`
  - Line: 105
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 781
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 951
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1641
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1769
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3339
  - Context: `impl Drop for QualityFixture {`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4621
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4634
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4653
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4672
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4688
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4704
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4732
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4752
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4785
  - Context: `Unknown`
- File: `src-tauri/src/repository/types.rs`
  - Line: 509
  - Context: `Unknown`

### IT-013

Locations: 4 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 63
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 69
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 787
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 942
  - Context: `Unknown`

### UT-060

Locations: 4 file(s)

- File: `src/lib/components/player/backgroundAudioHandoff.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/components/player/backgroundAudioHandoff.ts`
  - Line: 69
  - Context: `Unknown`
- File: `src/lib/components/player/backgroundAudioHandoff.ts`
  - Line: 110
  - Context: `Unknown`
- File: `src/lib/components/player/backgroundAudioHandoff.test.ts`
  - Line: 15
  - Context: `Unknown`

### UT-061

Locations: 9 file(s)

- File: `src/lib/api/repository-client.test.ts`
  - Line: 442
  - Context: `Unknown`
- File: `src/lib/api/repository-client.test.ts`
  - Line: 459
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 63
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 69
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1687
  - Context: `Unknown`
- File: `src/lib/player/backgroundAudio.commands.test.ts`
  - Line: 7
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 787
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 942
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 640
  - Context: `Unknown`

### UT-089

Locations: 1 file(s)

- File: `src/lib/components/player/VideoPlayer.touchScrub.test.ts`
  - Line: 21
  - Context: `Unknown`

### UT-090

Locations: 1 file(s)

- File: `src/lib/components/player/VideoPlayer.touchScrub.test.ts`
  - Line: 21
  - Context: `Unknown`

### UT-092

Locations: 3 file(s)

- File: `src/lib/components/player/VideoPlayer.tapSurface.test.ts`
  - Line: 5
  - Context: `Unknown`
- File: `src/lib/components/player/playerSurface.test.ts`
  - Line: 13
  - Context: `Unknown`
- File: `src/lib/components/player/playerSurface.ts`
  - Line: 8
  - Context: `Unknown`

### UT-116

Locations: 4 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 408
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 547
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 599
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 3321
  - Context: `Unknown`

### UT-131

Locations: 1 file(s)

- File: `src/lib/components/library/EpisodeFocusView.test.ts`
  - Line: 7
  - Context: `Unknown`

### UT-132

Locations: 1 file(s)

- File: `src/lib/components/library/EpisodeFocusView.test.ts`
  - Line: 7
  - Context: `Unknown`

### UT-133

Locations: 1 file(s)

- File: `src/lib/components/library/EpisodeFocusView.test.ts`
  - Line: 7
  - Context: `Unknown`

### UT-134

Locations: 1 file(s)

- File: `src/lib/components/library/EpisodeFocusView.test.ts`
  - Line: 7
  - Context: `Unknown`

### UT-135

Locations: 1 file(s)

- File: `src/lib/components/library/EpisodeFocusView.test.ts`
  - Line: 7
  - Context: `Unknown`

### UT-136

Locations: 1 file(s)

- File: `src/lib/components/library/seriesNavigation.test.ts`
  - Line: 1
  - Context: `Unknown`

### UT-158

Locations: 1 file(s)

- File: `src/lib/components/library/mosaic.ts`
  - Line: 12
  - Context: `Unknown`

### UT-159

Locations: 1 file(s)

- File: `src/lib/components/library/mosaic.ts`
  - Line: 12
  - Context: `Unknown`

### UT-160

Locations: 1 file(s)

- File: `src/lib/components/library/mosaic.ts`
  - Line: 12
  - Context: `Unknown`

### UT-170

Locations: 8 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 922
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 546
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 679
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 3238
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 3303
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 3334
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 3410
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 3437
  - Context: `Unknown`

### UT-174

Locations: 2 file(s)

- File: `src/lib/components/player/hlsRecovery.ts`
  - Line: 11
  - Context: `Unknown`
- File: `src/lib/components/player/hlsRecovery.test.ts`
  - Line: 16
  - Context: `Unknown`

### UT-175

Locations: 1 file(s)

- File: `src/lib/player/adapters/html5Adapter.test.ts`
  - Line: 274
  - Context: `Unknown`

### UT-183

Locations: 2 file(s)

- File: `src/lib/player/adapters/html5Adapter.test.ts`
  - Line: 235
  - Context: `Unknown`
- File: `src/lib/player/adapters/html5Adapter.ts`
  - Line: 215
  - Context: `Unknown`

### UT-184

Locations: 2 file(s)

- File: `src/lib/components/player/mediaReady.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/components/player/mediaReady.test.ts`
  - Line: 4
  - Context: `Unknown`

### UT-185

Locations: 2 file(s)

- File: `src/lib/components/player/VideoPlayer.nativeReveal.test.ts`
  - Line: 16
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 633
  - Context: `Unknown`

### UT-187

Locations: 1 file(s)

- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 662
  - Context: `Unknown`

### UT-188

Locations: 5 file(s)

- File: `src/lib/components/player/controlsVisibility.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/components/player/controlsVisibility.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 600
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 2212
  - Context: `Unknown`
- File: `scripts/extract-traces.test.ts`
  - Line: 61
  - Context: `Unknown`

### UT-189

Locations: 1 file(s)

- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 1699
  - Context: `Unknown`

### UT-207

Locations: 2 file(s)

- File: `src/lib/components/home/heroRotation.ts`
  - Line: 8
  - Context: `Unknown`
- File: `src/lib/components/home/heroRotation.test.ts`
  - Line: 1
  - Context: `Unknown`

### UT-212

Locations: 13 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 42
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 914
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 241
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 268
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 284
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 303
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 314
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 328
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 363
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 403
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 419
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 433
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 443
  - Context: `Unknown`

### UT-213

Locations: 14 file(s)

- File: `src/lib/api/repository-client.ts`
  - Line: 263
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1673
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 621
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 951
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1769
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4621
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4634
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4653
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4672
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4688
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4704
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4732
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4752
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4785
  - Context: `Unknown`

### UR-025

Locations: 69 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 1377
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1732
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3654
  - Context: `export type SyncQueueItem = { id: number; userId: string; operation: s...`
- File: `src/lib/components/sync/PendingSyncList.svelte`
  - Line: 12
  - Context: `Unknown`
- File: `src/lib/components/sync/PendingSyncModal.svelte`
  - Line: 5
  - Context: `Unknown`
- File: `src/lib/services/pendingSync.logic.test.ts`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/services/playerEvents.ts`
  - Line: 159
  - Context: `Unknown`
- File: `src/lib/services/pendingSync.logic.ts`
  - Line: 9
  - Context: `Unknown`
- File: `src/lib/services/skipReporting.test.ts`
  - Line: 9
  - Context: `Unknown`
- File: `src/lib/services/syncService.ts`
  - Line: 7
  - Context: `Unknown`
- File: `src/lib/services/syncService.ts`
  - Line: 61
  - Context: `Unknown`
- File: `src/lib/services/skipReporting.ts`
  - Line: 14
  - Context: `Unknown`
- File: `src/lib/services/playbackReporting.ts`
  - Line: 10
  - Context: `Unknown`
- File: `src/lib/services/playbackReporting.ts`
  - Line: 27
  - Context: `Unknown`
- File: `src/lib/services/playbackReporting.ts`
  - Line: 107
  - Context: `Unknown`
- File: `src/lib/services/playbackReporting.ts`
  - Line: 143
  - Context: `Unknown`
- File: `src/lib/services/playbackReporting.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/routes/settings/+page.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/routes/settings/+page.svelte`
  - Line: 1052
  - Context: `Unknown`
- File: `src/routes/+layout.svelte`
  - Line: 303
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync.rs`
  - Line: 7
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync.rs`
  - Line: 33
  - Context: `Unknown`
- File: `src-tauri/src/commands/storage/mod.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/commands/mod.rs`
  - Line: 2
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 14
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 76
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 236
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 380
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 427
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 474
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 606
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 672
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 702
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 736
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 765
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 811
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 856
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 885
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 915
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 954
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 992
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 1007
  - Context: `Unknown`
- File: `src-tauri/src/commands/playback_reporting.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 764
  - Context: `Unknown`
- File: `src-tauri/src/storage/mod.rs`
  - Line: 157
  - Context: `pub fn file_size(&self) -> Option<u64> {`
- File: `src-tauri/src/lib.rs`
  - Line: 1689
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 102
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 154
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 340
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 835
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1041
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1075
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1084
  - Context: `pub fn set_report_sink(&self, sink: Arc<dyn PlaybackReportSink>) {`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1427
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1671
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4677
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4728
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4755
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4793
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4821
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4850
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 426
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 653
  - Context: `Unknown`
- File: `src-tauri/src/repository/mod.rs`
  - Line: 258
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 716
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 4799
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 4886
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 4977
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2839
  - Context: `Unknown`

### UR-029

Locations: 5 file(s)

- File: `src/lib/components/library/GenericMediaListPage.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/library/LibraryGrid.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/stores/library.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src/lib/stores/viewMode.test.ts`
  - Line: 7
  - Context: `Unknown`
- File: `src/routes/settings/+page.svelte`
  - Line: 1
  - Context: `Unknown`

### UR-030

Locations: 2 file(s)

- File: `src/lib/components/library/GenericMediaListPage.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/stores/library.ts`
  - Line: 2
  - Context: `Unknown`

### UR-035

Locations: 7 file(s)

- File: `src/lib/components/library/CrewLinks.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/library/CastSection.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/routes/library/[id]/+page.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/commands/storage/people.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 4
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1859
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2888
  - Context: `Unknown`

### UR-052

Locations: 23 file(s)

- File: `src/lib/components/library/GenericMediaListPage.svelte`
  - Line: 101
  - Context: `Unknown`
- File: `src/lib/components/library/GenericGenreBrowser.svelte`
  - Line: 67
  - Context: `async function reloadGenreBrowse() {`
- File: `src/lib/components/library/MediaCard.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/services/offlineCatalog.reload.test.ts`
  - Line: 11
  - Context: `Unknown`
- File: `src/lib/services/offlineCatalog.ts`
  - Line: 14
  - Context: `Unknown`
- File: `src/lib/services/offlineCatalog.ts`
  - Line: 46
  - Context: `Unknown`
- File: `src/lib/stores/connectivity.ts`
  - Line: 10
  - Context: `Unknown`
- File: `src/lib/stores/connectivity.ts`
  - Line: 250
  - Context: `Unknown`
- File: `src/lib/composables/useOfflineFilterReload.test.ts`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/composables/useOfflineFilterReload.ts`
  - Line: 26
  - Context: `Unknown`
- File: `src/routes/library/favorites/+page.svelte`
  - Line: 88
  - Context: `Unknown`
- File: `src/routes/library/tv/+page.svelte`
  - Line: 74
  - Context: `async function load() {`
- File: `src/routes/library/movies/+page.svelte`
  - Line: 73
  - Context: `async function load() {`
- File: `src/routes/library/music/+page.svelte`
  - Line: 71
  - Context: `async function load() {`
- File: `src/routes/library/[id]/+page.svelte`
  - Line: 101
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 551
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 878
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 1001
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 8
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 24
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 43
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 3108
  - Context: `Unknown`

### UR-063

Locations: 10 file(s)

- File: `src/lib/components/library/GenericMediaListPage.svelte`
  - Line: 48
  - Context: `Unknown`
- File: `src/lib/components/library/GenericGenreBrowser.svelte`
  - Line: 45
  - Context: `Unknown`
- File: `src/lib/components/library/LibraryViewTabs.svelte`
  - Line: 8
  - Context: `Unknown`
- File: `src/lib/utils/libraryView.ts`
  - Line: 9
  - Context: `Unknown`
- File: `src/routes/library/tv/+page.svelte`
  - Line: 8
  - Context: `Unknown`
- File: `src/routes/library/tv/shows/+page.ts`
  - Line: 6
  - Context: `Unknown`
- File: `src/routes/library/movies/all/+page.ts`
  - Line: 6
  - Context: `Unknown`
- File: `src/routes/library/movies/genres/+page.ts`
  - Line: 6
  - Context: `Unknown`
- File: `src/routes/library/movies/+page.svelte`
  - Line: 8
  - Context: `Unknown`
- File: `src/routes/library/shows/genres/+page.ts`
  - Line: 6
  - Context: `Unknown`

### UR-065

Locations: 25 file(s)

- File: `src-tauri/src/commands/storage/people.rs`
  - Line: 56
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 51
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 96
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 164
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 281
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 327
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 764
  - Context: `Unknown`
- File: `src-tauri/src/storage/schema.rs`
  - Line: 758
  - Context: `Unknown`
- File: `src-tauri/src/storage/schema.rs`
  - Line: 770
  - Context: `Unknown`
- File: `src-tauri/src/lib.rs`
  - Line: 1675
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 64
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 62
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 300
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 357
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 525
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 1306
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 2630
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 2670
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 3169
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 3226
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 3335
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 3388
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 3444
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 4582
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 4635
  - Context: `Unknown`

### UR-075

Locations: 12 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 2467
  - Context: `export type Library = { id: string; name: string; collectionType: stri...`
- File: `src/lib/components/library/libraryMosaic.ts`
  - Line: 12
  - Context: `Unknown`
- File: `src/lib/components/library/MosaicGrid.svelte`
  - Line: 14
  - Context: `Unknown`
- File: `src/lib/components/library/mosaic.ts`
  - Line: 12
  - Context: `Unknown`
- File: `src/lib/components/library/MosaicTile.svelte`
  - Line: 10
  - Context: `Unknown`
- File: `src/lib/components/common/CachedImage.svelte`
  - Line: 18
  - Context: `Unknown`
- File: `src/lib/utils/favoritesView.ts`
  - Line: 44
  - Context: `Unknown`
- File: `src/routes/library/+page.svelte`
  - Line: 53
  - Context: `Unknown`
- File: `src/routes/library/+page.svelte`
  - Line: 289
  - Context: `Unknown`
- File: `src/routes/+page.svelte`
  - Line: 145
  - Context: `Unknown`
- File: `src-tauri/src/repository/types.rs`
  - Line: 47
  - Context: `Unknown`
- File: `src-tauri/src/repository/types.rs`
  - Line: 434
  - Context: `Unknown`

### UR-078

Locations: 15 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 1945
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1953
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1967
  - Context: `Unknown`
- File: `src/lib/utils/logger.ts`
  - Line: 202
  - Context: `export function isLevelEnabled(level: LogLevel): boolean {`
- File: `src/routes/settings/+page.svelte`
  - Line: 501
  - Context: `Unknown`
- File: `src/routes/settings/+page.svelte`
  - Line: 1266
  - Context: `Unknown`
- File: `src-tauri/src/commands/diagnostics.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/commands/diagnostics.rs`
  - Line: 91
  - Context: `Unknown`
- File: `src-tauri/src/commands/diagnostics.rs`
  - Line: 115
  - Context: `Unknown`
- File: `src-tauri/src/commands/diagnostics.rs`
  - Line: 187
  - Context: `Unknown`
- File: `src-tauri/src/lib.rs`
  - Line: 1061
  - Context: `Unknown`
- File: `src-tauri/src/lib.rs`
  - Line: 1191
  - Context: `Unknown`
- File: `src-tauri/src/lib.rs`
  - Line: 1230
  - Context: `pub fn run() {`
- File: `src-tauri/src/lib.rs`
  - Line: 1273
  - Context: `Unknown`
- File: `src-tauri/src/utils/diagnostics.rs`
  - Line: 3
  - Context: `Unknown`

### UR-082

Locations: 43 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 1836
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1849
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1857
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1869
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1877
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1899
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1910
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3305
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3480
  - Context: `export type SmartCacheStats = { total_size: number; storage_limit: num...`
- File: `src/lib/components/settings/ProfileSecuritySettings.svelte`
  - Line: 15
  - Context: `Unknown`
- File: `src/lib/components/account/AccountMenu.svelte`
  - Line: 7
  - Context: `Unknown`
- File: `src/lib/components/account/AccountMenu.test.ts`
  - Line: 84
  - Context: `Unknown`
- File: `src/lib/utils/layoutShell.test.ts`
  - Line: 214
  - Context: `Unknown`
- File: `src/lib/utils/profileTiles.ts`
  - Line: 12
  - Context: `Unknown`
- File: `src/lib/utils/profileTiles.ts`
  - Line: 21
  - Context: `Unknown`
- File: `src/lib/utils/profileTiles.ts`
  - Line: 36
  - Context: `Unknown`
- File: `src/lib/utils/profileTiles.ts`
  - Line: 53
  - Context: `Unknown`
- File: `src/lib/stores/auth.ts`
  - Line: 621
  - Context: `Unknown`
- File: `src/lib/stores/profiles.ts`
  - Line: 11
  - Context: `Unknown`
- File: `src/routes/settings/+page.svelte`
  - Line: 998
  - Context: `Unknown`
- File: `src/routes/profiles/+page.svelte`
  - Line: 10
  - Context: `Unknown`
- File: `src-tauri/src/commands/profiles.rs`
  - Line: 9
  - Context: `Unknown`
- File: `src-tauri/src/commands/profiles.rs`
  - Line: 74
  - Context: `Unknown`
- File: `src-tauri/src/commands/profiles.rs`
  - Line: 90
  - Context: `Unknown`
- File: `src-tauri/src/commands/profiles.rs`
  - Line: 114
  - Context: `Unknown`
- File: `src-tauri/src/commands/profiles.rs`
  - Line: 124
  - Context: `Unknown`
- File: `src-tauri/src/commands/profiles.rs`
  - Line: 149
  - Context: `Unknown`
- File: `src-tauri/src/commands/profiles.rs`
  - Line: 279
  - Context: `Unknown`
- File: `src-tauri/src/commands/profiles.rs`
  - Line: 372
  - Context: `Unknown`
- File: `src-tauri/src/commands/profiles.rs`
  - Line: 433
  - Context: `Unknown`
- File: `src-tauri/src/commands/profiles.rs`
  - Line: 504
  - Context: `Unknown`
- File: `src-tauri/src/storage/schema.rs`
  - Line: 845
  - Context: `Unknown`
- File: `src-tauri/src/profiles/switch.rs`
  - Line: 20
  - Context: `! So the ordering lives here as a pure function returning a list of st...`
- File: `src-tauri/src/profiles/switch.rs`
  - Line: 24
  - Context: `Unknown`
- File: `src-tauri/src/profiles/switch.rs`
  - Line: 64
  - Context: `Unknown`
- File: `src-tauri/src/profiles/store.rs`
  - Line: 8
  - Context: `Unknown`
- File: `src-tauri/src/profiles/store.rs`
  - Line: 23
  - Context: `Unknown`
- File: `src-tauri/src/profiles/store.rs`
  - Line: 60
  - Context: `Unknown`
- File: `src-tauri/src/profiles/store.rs`
  - Line: 196
  - Context: `Unknown`
- File: `src-tauri/src/profiles/mod.rs`
  - Line: 19
  - Context: `Unknown`
- File: `src-tauri/src/profiles/mod.rs`
  - Line: 44
  - Context: `pub enum UnlockMethod {`
- File: `src-tauri/src/profiles/mod.rs`
  - Line: 92
  - Context: `Unknown`
- File: `src-tauri/src/profiles/mod.rs`
  - Line: 115
  - Context: `Unknown`

### UR-083

Locations: 33 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 1889
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 1910
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3697
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3716
  - Context: `Unknown`
- File: `src/lib/components/PinPad.svelte`
  - Line: 12
  - Context: `Unknown`
- File: `src/lib/components/settings/ProfileSecuritySettings.svelte`
  - Line: 15
  - Context: `Unknown`
- File: `src/lib/utils/pinForm.ts`
  - Line: 12
  - Context: `Unknown`
- File: `src/lib/utils/pinForm.ts`
  - Line: 37
  - Context: `Unknown`
- File: `src/lib/utils/pinForm.ts`
  - Line: 74
  - Context: `Unknown`
- File: `src/lib/utils/profileTiles.ts`
  - Line: 12
  - Context: `Unknown`
- File: `src/lib/utils/profileTiles.ts`
  - Line: 68
  - Context: `Unknown`
- File: `src/lib/stores/profiles.ts`
  - Line: 11
  - Context: `Unknown`
- File: `src/routes/settings/+page.svelte`
  - Line: 998
  - Context: `Unknown`
- File: `src/routes/profiles/+page.svelte`
  - Line: 10
  - Context: `Unknown`
- File: `src-tauri/src/commands/auth.rs`
  - Line: 39
  - Context: `Unknown`
- File: `src-tauri/src/commands/profiles.rs`
  - Line: 9
  - Context: `Unknown`
- File: `src-tauri/src/commands/profiles.rs`
  - Line: 149
  - Context: `Unknown`
- File: `src-tauri/src/commands/profiles.rs`
  - Line: 338
  - Context: `Unknown`
- File: `src-tauri/src/storage/schema.rs`
  - Line: 845
  - Context: `Unknown`
- File: `src-tauri/src/profiles/store.rs`
  - Line: 8
  - Context: `Unknown`
- File: `src-tauri/src/profiles/store.rs`
  - Line: 96
  - Context: `Unknown`
- File: `src-tauri/src/profiles/store.rs`
  - Line: 127
  - Context: `Unknown`
- File: `src-tauri/src/profiles/store.rs`
  - Line: 152
  - Context: `Unknown`
- File: `src-tauri/src/profiles/store.rs`
  - Line: 167
  - Context: `Unknown`
- File: `src-tauri/src/profiles/pin.rs`
  - Line: 15
  - Context: `Unknown`
- File: `src-tauri/src/profiles/pin.rs`
  - Line: 34
  - Context: `Unknown`
- File: `src-tauri/src/profiles/pin.rs`
  - Line: 67
  - Context: `/ doing the comparison here is what keeps this function pure and cheap...`
- File: `src-tauri/src/profiles/pin.rs`
  - Line: 114
  - Context: `Unknown`
- File: `src-tauri/src/profiles/pin.rs`
  - Line: 127
  - Context: `Unknown`
- File: `src-tauri/src/profiles/pin.rs`
  - Line: 141
  - Context: `Unknown`
- File: `src-tauri/src/profiles/mod.rs`
  - Line: 19
  - Context: `Unknown`
- File: `src-tauri/src/profiles/mod.rs`
  - Line: 32
  - Context: `Unknown`
- File: `src-tauri/src/profiles/mod.rs`
  - Line: 65
  - Context: `Unknown`

### UR-084

Locations: 8 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 1925
  - Context: `Unknown`
- File: `src/lib/api/bindings.ts`
  - Line: 3716
  - Context: `Unknown`
- File: `src/lib/stores/profiles.ts`
  - Line: 11
  - Context: `Unknown`
- File: `src/routes/profiles/+page.svelte`
  - Line: 10
  - Context: `Unknown`
- File: `src-tauri/src/commands/profiles.rs`
  - Line: 9
  - Context: `Unknown`
- File: `src-tauri/src/commands/profiles.rs`
  - Line: 219
  - Context: `Unknown`
- File: `src-tauri/src/profiles/mod.rs`
  - Line: 19
  - Context: `Unknown`
- File: `src-tauri/src/profiles/mod.rs`
  - Line: 65
  - Context: `Unknown`

### UT-104

Locations: 7 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 2426
  - Context: `export type GetItemsOptions = { startIndex?: number | null; limit?: nu...`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 1322
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 4532
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1199
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1273
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4070
  - Context: `Unknown`
- File: `src-tauri/src/repository/types.rs`
  - Line: 342
  - Context: `Unknown`

### UT-145

Locations: 5 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 2813
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 236
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 459
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 3114
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 3171
  - Context: `Unknown`

### UT-168

Locations: 24 file(s)

- File: `src/lib/api/bindings.ts`
  - Line: 2661
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 482
  - Context: `Unknown`
- File: `src/lib/components/player/subtitleTracks.test.ts`
  - Line: 67
  - Context: `Unknown`
- File: `src/lib/components/player/subtitleTracks.test.ts`
  - Line: 93
  - Context: `Unknown`
- File: `src/lib/components/player/subtitleTracks.test.ts`
  - Line: 110
  - Context: `Unknown`
- File: `src/lib/components/player/subtitleTracks.ts`
  - Line: 75
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 84
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 99
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 120
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 150
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 184
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 382
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 395
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 433
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 451
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 465
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 479
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 914
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2296
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2409
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2457
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3550
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3573
  - Context: `Unknown`
- File: `src-tauri/src/repository/types.rs`
  - Line: 265
  - Context: `Unknown`

### UT-214

Locations: 4 file(s)

- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 682
  - Context: `Unknown`
- File: `src/lib/player/streamTransport.ts`
  - Line: 7
  - Context: `Unknown`
- File: `src/lib/player/streamTransport.ts`
  - Line: 61
  - Context: `Unknown`
- File: `src/lib/player/streamTransport.test.ts`
  - Line: 4
  - Context: `Unknown`

### UT-219

Locations: 1 file(s)

- File: `src/lib/components/player/fullscreenTarget.ts`
  - Line: 20
  - Context: `Unknown`

### UT-227

Locations: 2 file(s)

- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 323
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.menus.test.ts`
  - Line: 5
  - Context: `Unknown`

### UT-228

Locations: 1 file(s)

- File: `src/lib/components/player/VideoPlayer.menus.test.ts`
  - Line: 5
  - Context: `Unknown`

### UT-235

Locations: 1 file(s)

- File: `src/lib/components/library/alphabetStrip.test.ts`
  - Line: 18
  - Context: `Unknown`

### UT-236

Locations: 1 file(s)

- File: `src/lib/components/library/alphabetStrip.test.ts`
  - Line: 18
  - Context: `Unknown`

### UT-237

Locations: 1 file(s)

- File: `src/lib/components/library/alphabetStrip.test.ts`
  - Line: 18
  - Context: `Unknown`

### UR-010

Locations: 16 file(s)

- File: `src/lib/components/sessions/SessionPickerModal.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/sessions/CastButton.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/sessions/RemoteControls.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/sessions/SessionsList.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/sessions/SessionCard.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/stores/playbackMode.test.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src/lib/stores/lmsSync.ts`
  - Line: 12
  - Context: `Unknown`
- File: `src/lib/stores/playbackMode.ts`
  - Line: 9
  - Context: `Unknown`
- File: `src/lib/stores/sessions.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src/lib/stores/sessions.test.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/remote.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/commands/playback_mode.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/commands/sessions.rs`
  - Line: 1
  - Context: `Unknown`
- File: `src-tauri/src/session_poller/mod.rs`
  - Line: 6
  - Context: `Unknown`
- File: `src-tauri/src/playback_mode/mod.rs`
  - Line: 165
  - Context: `Unknown`

### UR-012

Locations: 21 file(s)

- File: `src/lib/services/imageCache.ts`
  - Line: 50
  - Context: `Unknown`
- File: `src/lib/stores/auth.ts`
  - Line: 6
  - Context: `Unknown`
- File: `src/lib/stores/auth.ts`
  - Line: 346
  - Context: `Unknown`
- File: `src/lib/stores/auth.ts`
  - Line: 508
  - Context: `Unknown`
- File: `src/lib/stores/auth.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src-tauri/src/commands/storage/mod.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/commands/mod.rs`
  - Line: 2
  - Context: `Unknown`
- File: `src-tauri/src/credentials.rs`
  - Line: 10
  - Context: `Unknown`
- File: `src-tauri/src/credentials.rs`
  - Line: 480
  - Context: `Unknown`
- File: `src-tauri/src/credentials.rs`
  - Line: 905
  - Context: `Unknown`
- File: `src-tauri/src/credentials.rs`
  - Line: 924
  - Context: `Unknown`
- File: `src-tauri/src/credentials.rs`
  - Line: 941
  - Context: `Unknown`
- File: `src-tauri/src/android_context.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/android_context.rs`
  - Line: 80
  - Context: `Unknown`
- File: `src-tauri/src/android_context.rs`
  - Line: 95
  - Context: `Unknown`
- File: `src-tauri/src/storage/mod.rs`
  - Line: 157
  - Context: `pub fn file_size(&self) -> Option<u64> {`
- File: `src-tauri/src/storage/db_service.rs`
  - Line: 311
  - Context: `Unknown`
- File: `src-tauri/src/lib.rs`
  - Line: 639
  - Context: `Unknown`
- File: `src-tauri/src/lib.rs`
  - Line: 1170
  - Context: `Unknown`
- File: `src-tauri/src/lib.rs`
  - Line: 1343
  - Context: `Unknown`
- File: `scripts/tauri-security-config.test.ts`
  - Line: 11
  - Context: `Unknown`

### UR-017

Locations: 11 file(s)

- File: `src/lib/components/player/MiniPlayer.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/FavoriteButton.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/services/favorites.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/services/favorites.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src/lib/services/syncService.ts`
  - Line: 7
  - Context: `Unknown`
- File: `src/lib/services/syncService.ts`
  - Line: 61
  - Context: `Unknown`
- File: `src/lib/services/syncService.ts`
  - Line: 195
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync.rs`
  - Line: 7
  - Context: `Unknown`
- File: `src-tauri/src/commands/storage/mod.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/commands/mod.rs`
  - Line: 2
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2748
  - Context: `Unknown`

### UR-039

Locations: 1 file(s)

- File: `src/lib/components/BottomNav.svelte`
  - Line: 1
  - Context: `Unknown`

### UR-054

Locations: 7 file(s)

- File: `src/lib/components/AppHeader.svelte`
  - Line: 10
  - Context: `Unknown`
- File: `src/lib/components/search/HeaderSearch.test.ts`
  - Line: 9
  - Context: `Unknown`
- File: `src/lib/components/search/HeaderSearch.svelte`
  - Line: 12
  - Context: `Unknown`
- File: `src/lib/components/account/AccountMenu.svelte`
  - Line: 7
  - Context: `Unknown`
- File: `src/lib/utils/layoutShell.ts`
  - Line: 86
  - Context: `Unknown`
- File: `src/lib/utils/layoutShell.ts`
  - Line: 101
  - Context: `Unknown`
- File: `src/lib/utils/scrollRestore.ts`
  - Line: 26
  - Context: `Unknown`

### UR-059

Locations: 11 file(s)

- File: `src/lib/services/skipReporting.test.ts`
  - Line: 9
  - Context: `Unknown`
- File: `src/lib/services/skipReporting.ts`
  - Line: 14
  - Context: `Unknown`
- File: `src/lib/stores/continueWatchingFilter.test.ts`
  - Line: 12
  - Context: `Unknown`
- File: `src/lib/stores/home.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src/lib/stores/continueWatchingFilter.ts`
  - Line: 15
  - Context: `Unknown`
- File: `src/lib/stores/continueWatchingFilter.ts`
  - Line: 91
  - Context: `Unknown`
- File: `src/lib/stores/tv.ts`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1432
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1933
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4388
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4409
  - Context: `Unknown`

### UR-061

Locations: 4 file(s)

- File: `src/lib/components/player/VideoPlayer.tapSurface.test.ts`
  - Line: 5
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.touchScrub.test.ts`
  - Line: 21
  - Context: `Unknown`
- File: `src/lib/components/player/tapGestures.ts`
  - Line: 21
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 1
  - Context: `Unknown`

### UR-066

Locations: 15 file(s)

- File: `src/lib/components/BottomUi.svelte`
  - Line: 23
  - Context: `Unknown`
- File: `src/lib/components/player/controlsVisibility.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/components/player/controlsVisibility.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 600
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 2212
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 2897
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.svelte`
  - Line: 3072
  - Context: `Unknown`
- File: `src/lib/components/player/VideoPlayer.menus.test.ts`
  - Line: 5
  - Context: `Unknown`
- File: `src/lib/components/player/fullscreenTarget.ts`
  - Line: 20
  - Context: `Unknown`
- File: `src/lib/utils/immersive.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/utils/layoutShell.test.ts`
  - Line: 158
  - Context: `Unknown`
- File: `src/lib/utils/safeArea.test.ts`
  - Line: 17
  - Context: `Unknown`
- File: `src/lib/utils/safeArea.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/lib/utils/layoutShell.ts`
  - Line: 127
  - Context: `Unknown`
- File: `src/routes/+layout.svelte`
  - Line: 269
  - Context: `Unknown`

### UT-010

Locations: 1 file(s)

- File: `src/lib/stores/downloads.test.ts`
  - Line: 2
  - Context: `Unknown`

### UT-024

Locations: 1 file(s)

- File: `src/lib/stores/downloads.test.ts`
  - Line: 2
  - Context: `Unknown`

### UT-052

Locations: 1 file(s)

- File: `src/lib/stores/downloads.test.ts`
  - Line: 316
  - Context: `Unknown`

### UT-062

Locations: 1 file(s)

- File: `src/lib/utils/backgroundAudio.test.ts`
  - Line: 7
  - Context: `Unknown`

### UT-066

Locations: 1 file(s)

- File: `src/lib/services/networkType.test.ts`
  - Line: 4
  - Context: `Unknown`

### UT-071

Locations: 1 file(s)

- File: `src/lib/utils/formatBytes.test.ts`
  - Line: 4
  - Context: `Unknown`

### UT-094

Locations: 1 file(s)

- File: `src/lib/utils/safeArea.test.ts`
  - Line: 17
  - Context: `Unknown`

### UT-095

Locations: 1 file(s)

- File: `src/lib/utils/safeArea.test.ts`
  - Line: 17
  - Context: `Unknown`

### UT-096

Locations: 1 file(s)

- File: `src/lib/utils/safeArea.test.ts`
  - Line: 17
  - Context: `Unknown`

### UT-097

Locations: 1 file(s)

- File: `src/lib/utils/safeArea.test.ts`
  - Line: 17
  - Context: `Unknown`

### UT-098

Locations: 1 file(s)

- File: `src/lib/utils/layoutShell.test.ts`
  - Line: 158
  - Context: `Unknown`

### UT-106

Locations: 3 file(s)

- File: `src/lib/stores/favorites.test.ts`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/stores/favorites.ts`
  - Line: 79
  - Context: `Unknown`
- File: `src/routes/library/favorites/+page.svelte`
  - Line: 44
  - Context: `Unknown`

### UT-140

Locations: 1 file(s)

- File: `src/lib/composables/useOfflineFilterReload.test.ts`
  - Line: 1
  - Context: `Unknown`

### UT-201

Locations: 2 file(s)

- File: `src/lib/utils/logger.test.ts`
  - Line: 22
  - Context: `Unknown`
- File: `src/lib/utils/logger.test.ts`
  - Line: 333
  - Context: `Unknown`

### UT-208

Locations: 1 file(s)

- File: `src/lib/utils/updateCheck.test.ts`
  - Line: 4
  - Context: `Unknown`

### UT-245

Locations: 2 file(s)

- File: `src/lib/components/player/timeTracking.test.ts`
  - Line: 5
  - Context: `Unknown`
- File: `src/lib/components/player/timeTracking.ts`
  - Line: 4
  - Context: `Unknown`

### UT-246

Locations: 3 file(s)

- File: `src/lib/components/player/backgroundAudioHandoff.ts`
  - Line: 128
  - Context: `Unknown`
- File: `src/lib/components/player/backgroundAudioHandoff.ts`
  - Line: 166
  - Context: `export function enteringPictureInPicture(_current: BackgroundBehaviour...`
- File: `src/lib/components/player/backgroundAudioHandoff.test.ts`
  - Line: 145
  - Context: `Unknown`

### UR-028

Locations: 2 file(s)

- File: `src/lib/components/player/AudioPlayer.svelte`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/components/player/MiniPlayer.svelte`
  - Line: 1
  - Context: `Unknown`

### UR-060

Locations: 7 file(s)

- File: `src/lib/utils/searchScope.ts`
  - Line: 149
  - Context: `export type SearchGroupId =`
- File: `src/lib/utils/searchScope.ts`
  - Line: 215
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 96
  - Context: `Unknown`
- File: `src-tauri/src/domain/search_rank.rs`
  - Line: 110
  - Context: `Unknown`
- File: `src-tauri/src/storage/schema.rs`
  - Line: 770
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 357
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 3169
  - Context: `Unknown`

### UR-072

Locations: 1 file(s)

- File: `src/lib/utils/scrollContainer.ts`
  - Line: 23
  - Context: `Unknown`

### UR-077

Locations: 4 file(s)

- File: `src/lib/utils/updateCheck.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src/routes/settings/+page.svelte`
  - Line: 453
  - Context: `Unknown`
- File: `src/routes/settings/+page.svelte`
  - Line: 1346
  - Context: `Unknown`
- File: `src-tauri/src/lib.rs`
  - Line: 1281
  - Context: `Unknown`

### UT-105

Locations: 3 file(s)

- File: `src/lib/stores/favorites.test.ts`
  - Line: 1
  - Context: `Unknown`
- File: `src/lib/stores/favorites.ts`
  - Line: 8
  - Context: `Unknown`
- File: `src/lib/stores/favorites.ts`
  - Line: 55
  - Context: `Unknown`

### UT-186

Locations: 1 file(s)

- File: `src/lib/utils/nativeVideoLayers.test.ts`
  - Line: 4
  - Context: `Unknown`

### UT-192

Locations: 1 file(s)

- File: `src/lib/stores/continueWatchingFilter.test.ts`
  - Line: 12
  - Context: `Unknown`

### UT-209

Locations: 1 file(s)

- File: `src/lib/utils/logger.test.ts`
  - Line: 352
  - Context: `Unknown`

### UT-215

Locations: 1 file(s)

- File: `src/lib/stores/auth.waitForRepository.test.ts`
  - Line: 13
  - Context: `Unknown`

### UR-013

Locations: 2 file(s)

- File: `src/lib/stores/downloads.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src/lib/stores/downloads.test.ts`
  - Line: 2
  - Context: `Unknown`

### UR-015

Locations: 13 file(s)

- File: `src/lib/stores/queue.ts`
  - Line: 7
  - Context: `Unknown`
- File: `src/lib/stores/queue.ts`
  - Line: 108
  - Context: `Unknown`
- File: `src/lib/stores/queue.ts`
  - Line: 113
  - Context: `async function next() {`
- File: `src/lib/stores/queue.ts`
  - Line: 118
  - Context: `async function previous() {`
- File: `src/lib/stores/queue.ts`
  - Line: 123
  - Context: `async function skipTo(index: number) {`
- File: `src/lib/stores/queue.ts`
  - Line: 128
  - Context: `async function toggleShuffle() {`
- File: `src/lib/stores/queue.ts`
  - Line: 133
  - Context: `async function cycleRepeat() {`
- File: `src/lib/stores/queue.ts`
  - Line: 138
  - Context: `async function removeFromQueue(index: number) {`
- File: `src/lib/stores/queue.ts`
  - Line: 143
  - Context: `async function moveInQueue(fromIndex: number, toIndex: number) {`
- File: `src/lib/stores/queue.test.ts`
  - Line: 4
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/queue.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/player/queue.rs`
  - Line: 20
  - Context: `pub enum RepeatMode {`
- File: `src-tauri/src/player/queue.rs`
  - Line: 535
  - Context: `pub enum AddPosition {`

### UR-024

Locations: 11 file(s)

- File: `src/lib/stores/home.ts`
  - Line: 2
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1299
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1315
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1335
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1374
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1875
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4284
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4326
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4353
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4369
  - Context: `Unknown`

### UR-043

Locations: 2 file(s)

- File: `src/lib/stores/connectivity.ts`
  - Line: 10
  - Context: `Unknown`
- File: `src-tauri/src/commands/connectivity.rs`
  - Line: 3
  - Context: `Unknown`

### UT-231

Locations: 2 file(s)

- File: `src/lib/stores/library.ts`
  - Line: 122
  - Context: `Unknown`
- File: `src/lib/stores/libraryListingOrder.test.ts`
  - Line: 10
  - Context: `Unknown`

### UR-069

Locations: 24 file(s)

- File: `src/routes/library/favorites/+page.svelte`
  - Line: 76
  - Context: `Unknown`
- File: `src/routes/+layout.svelte`
  - Line: 150
  - Context: `Unknown`
- File: `src-tauri/src/commands/favorites.rs`
  - Line: 12
  - Context: `Unknown`
- File: `src-tauri/src/commands/favorites.rs`
  - Line: 78
  - Context: `Unknown`
- File: `src-tauri/src/commands/favorites.rs`
  - Line: 142
  - Context: `Unknown`
- File: `src-tauri/src/commands/favorites.rs`
  - Line: 286
  - Context: `Unknown`
- File: `src-tauri/src/commands/favorites.rs`
  - Line: 320
  - Context: `Unknown`
- File: `src-tauri/src/commands/favorites.rs`
  - Line: 340
  - Context: `Unknown`
- File: `src-tauri/src/commands/favorites.rs`
  - Line: 356
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 887
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 893
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 908
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 1081
  - Context: `Unknown`
- File: `src-tauri/src/commands/repository.rs`
  - Line: 1106
  - Context: `Unknown`
- File: `src-tauri/src/lib.rs`
  - Line: 1682
  - Context: `Unknown`
- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 47
  - Context: `pub fn new(online: OnlineRepository, offline: OfflineRepository) -> Se...`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 716
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 4712
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 4799
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 195
  - Context: `impl OnlineRepository {`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1167
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1580
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4428
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4459
  - Context: `Unknown`

### IT-003

Locations: 1 file(s)

- File: `src-tauri/src/player/mpv_backend_test.rs`
  - Line: 8
  - Context: `Unknown`

### IT-004

Locations: 1 file(s)

- File: `src-tauri/src/player/mpv_backend_test.rs`
  - Line: 8
  - Context: `Unknown`

### IT-016

Locations: 1 file(s)

- File: `src-tauri/src/repository/offline.rs`
  - Line: 3108
  - Context: `Unknown`

### UT-003

Locations: 1 file(s)

- File: `src-tauri/src/player/queue.rs`
  - Line: 535
  - Context: `pub enum AddPosition {`

### UT-004

Locations: 1 file(s)

- File: `src-tauri/src/player/queue.rs`
  - Line: 535
  - Context: `pub enum AddPosition {`

### UT-005

Locations: 1 file(s)

- File: `src-tauri/src/player/queue.rs`
  - Line: 535
  - Context: `pub enum AddPosition {`

### UT-012

Locations: 1 file(s)

- File: `src-tauri/src/player/sleep_timer.rs`
  - Line: 81
  - Context: `pub fn cancel(&mut self) {`

### UT-042

Locations: 1 file(s)

- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 2527
  - Context: `Unknown`

### UT-043

Locations: 1 file(s)

- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 2527
  - Context: `Unknown`

### UT-044

Locations: 1 file(s)

- File: `src-tauri/src/commands/offline.rs`
  - Line: 136
  - Context: `Unknown`

### UT-051

Locations: 1 file(s)

- File: `src-tauri/src/repository/offline.rs`
  - Line: 4066
  - Context: `Unknown`

### UT-059

Locations: 3 file(s)

- File: `src-tauri/src/repository/online.rs`
  - Line: 700
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3648
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3689
  - Context: `Unknown`

### UT-067

Locations: 1 file(s)

- File: `src-tauri/src/repository/offline.rs`
  - Line: 3108
  - Context: `Unknown`

### UT-072

Locations: 1 file(s)

- File: `src-tauri/src/repository/offline.rs`
  - Line: 3787
  - Context: `Unknown`

### UT-073

Locations: 1 file(s)

- File: `src-tauri/src/repository/offline.rs`
  - Line: 3986
  - Context: `Unknown`

### UT-074

Locations: 1 file(s)

- File: `src-tauri/src/repository/offline.rs`
  - Line: 4005
  - Context: `Unknown`

### UT-075

Locations: 1 file(s)

- File: `src-tauri/src/repository/offline.rs`
  - Line: 4027
  - Context: `Unknown`

### UT-076

Locations: 1 file(s)

- File: `src-tauri/src/repository/offline.rs`
  - Line: 3809
  - Context: `Unknown`

### UT-077

Locations: 1 file(s)

- File: `src-tauri/src/repository/offline.rs`
  - Line: 3919
  - Context: `Unknown`

### UT-078

Locations: 1 file(s)

- File: `src-tauri/src/repository/offline.rs`
  - Line: 3965
  - Context: `Unknown`

### UT-079

Locations: 1 file(s)

- File: `src-tauri/src/settings.rs`
  - Line: 460
  - Context: `Unknown`

### UT-080

Locations: 1 file(s)

- File: `src-tauri/src/settings.rs`
  - Line: 489
  - Context: `Unknown`

### UT-081

Locations: 1 file(s)

- File: `src-tauri/src/settings.rs`
  - Line: 516
  - Context: `Unknown`

### UT-082

Locations: 1 file(s)

- File: `src-tauri/src/settings.rs`
  - Line: 527
  - Context: `Unknown`

### UT-099

Locations: 3 file(s)

- File: `src-tauri/src/repository/online.rs`
  - Line: 1167
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4428
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4459
  - Context: `Unknown`

### UT-100

Locations: 6 file(s)

- File: `src-tauri/src/repository/mod.rs`
  - Line: 238
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1455
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2724
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4017
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4039
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4048
  - Context: `Unknown`

### UT-101

Locations: 4 file(s)

- File: `src-tauri/src/repository/mod.rs`
  - Line: 238
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 2096
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 4452
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 4487
  - Context: `Unknown`

### UT-102

Locations: 2 file(s)

- File: `src-tauri/src/repository/offline.rs`
  - Line: 716
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 4712
  - Context: `Unknown`

### UT-103

Locations: 6 file(s)

- File: `src-tauri/src/commands/favorites.rs`
  - Line: 12
  - Context: `Unknown`
- File: `src-tauri/src/commands/favorites.rs`
  - Line: 78
  - Context: `Unknown`
- File: `src-tauri/src/commands/favorites.rs`
  - Line: 286
  - Context: `Unknown`
- File: `src-tauri/src/commands/favorites.rs`
  - Line: 320
  - Context: `Unknown`
- File: `src-tauri/src/commands/favorites.rs`
  - Line: 340
  - Context: `Unknown`
- File: `src-tauri/src/commands/favorites.rs`
  - Line: 356
  - Context: `Unknown`

### UT-108

Locations: 2 file(s)

- File: `src-tauri/src/download/cache.rs`
  - Line: 320
  - Context: `Unknown`
- File: `src-tauri/src/download/cache.rs`
  - Line: 550
  - Context: `Unknown`

### UT-109

Locations: 1 file(s)

- File: `src-tauri/src/repository/offline.rs`
  - Line: 3388
  - Context: `Unknown`

### UT-110

Locations: 1 file(s)

- File: `src-tauri/src/repository/offline.rs`
  - Line: 3444
  - Context: `Unknown`

### UT-111

Locations: 3 file(s)

- File: `src-tauri/src/repository/offline.rs`
  - Line: 62
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 2630
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 2670
  - Context: `Unknown`

### UT-112

Locations: 2 file(s)

- File: `src-tauri/src/repository/offline.rs`
  - Line: 525
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 3335
  - Context: `Unknown`

### UT-113

Locations: 2 file(s)

- File: `src-tauri/src/repository/offline.rs`
  - Line: 300
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 3226
  - Context: `Unknown`

### UT-114

Locations: 2 file(s)

- File: `src-tauri/src/repository/offline.rs`
  - Line: 357
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 3169
  - Context: `Unknown`

### UT-119

Locations: 2 file(s)

- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 476
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 3237
  - Context: `Unknown`

### UT-120

Locations: 2 file(s)

- File: `src-tauri/src/download/cache.rs`
  - Line: 220
  - Context: `Unknown`
- File: `src-tauri/src/download/cache.rs`
  - Line: 428
  - Context: `Unknown`

### UT-122

Locations: 11 file(s)

- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 14
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 76
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 236
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 606
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 672
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 702
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 736
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 765
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 811
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 992
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 1007
  - Context: `Unknown`

### UT-130

Locations: 3 file(s)

- File: `src-tauri/src/repository/online.rs`
  - Line: 591
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 700
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3427
  - Context: `Unknown`

### UT-141

Locations: 2 file(s)

- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 29
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 40
  - Context: `pub fn clamp_max_audio_channels(reported: Option<u32>) -> u32 {`

### UT-142

Locations: 1 file(s)

- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 213
  - Context: `Unknown`

### UT-148

Locations: 3 file(s)

- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 325
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 347
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 366
  - Context: `Unknown`

### UT-150

Locations: 1 file(s)

- File: `scripts/set-version.test.ts`
  - Line: 4
  - Context: `Unknown`

### UT-152

Locations: 4 file(s)

- File: `src-tauri/src/repository/hybrid.rs`
  - Line: 653
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 716
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 4799
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 4977
  - Context: `Unknown`

### UT-154

Locations: 2 file(s)

- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 1047
  - Context: `Unknown`
- File: `src-tauri/src/commands/sync_drain.rs`
  - Line: 1064
  - Context: `Unknown`

### UT-155

Locations: 3 file(s)

- File: `src-tauri/src/player/mod.rs`
  - Line: 944
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4561
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4604
  - Context: `Unknown`

### UT-156

Locations: 4 file(s)

- File: `src-tauri/src/repository/online.rs`
  - Line: 591
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3349
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3371
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3394
  - Context: `Unknown`

### UT-157

Locations: 3 file(s)

- File: `src-tauri/src/settings.rs`
  - Line: 653
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 689
  - Context: `Unknown`
- File: `src-tauri/src/settings.rs`
  - Line: 716
  - Context: `Unknown`

### UT-161

Locations: 4 file(s)

- File: `src-tauri/src/repository/types.rs`
  - Line: 434
  - Context: `Unknown`
- File: `src-tauri/src/repository/types.rs`
  - Line: 761
  - Context: `Unknown`
- File: `src-tauri/src/repository/types.rs`
  - Line: 782
  - Context: `Unknown`
- File: `src-tauri/src/repository/types.rs`
  - Line: 794
  - Context: `Unknown`

### UT-162

Locations: 1 file(s)

- File: `src-tauri/src/repository/offline.rs`
  - Line: 3849
  - Context: `Unknown`

### UT-163

Locations: 1 file(s)

- File: `src-tauri/src/download/worker.rs`
  - Line: 336
  - Context: `Unknown`

### UT-164

Locations: 1 file(s)

- File: `src-tauri/src/download/worker.rs`
  - Line: 313
  - Context: `Unknown`

### UT-166

Locations: 7 file(s)

- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 366
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 546
  - Context: `Unknown`
- File: `src-tauri/src/repository/mod.rs`
  - Line: 350
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 2689
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3909
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3941
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3967
  - Context: `Unknown`

### UT-172

Locations: 3 file(s)

- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 524
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 3367
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 3396
  - Context: `Unknown`

### UT-173

Locations: 5 file(s)

- File: `src-tauri/src/repository/online.rs`
  - Line: 136
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 152
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 591
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3521
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3617
  - Context: `Unknown`

### UT-176

Locations: 3 file(s)

- File: `src-tauri/src/player/mod.rs`
  - Line: 1041
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4641
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4660
  - Context: `Unknown`

### UT-177

Locations: 6 file(s)

- File: `src-tauri/src/player/mod.rs`
  - Line: 1041
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1056
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1067
  - Context: `pub fn observed_duration(&self) -> Option<f64> {`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4677
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4692
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4706
  - Context: `Unknown`

### UT-178

Locations: 3 file(s)

- File: `src-tauri/src/player/mod.rs`
  - Line: 835
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4728
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4755
  - Context: `Unknown`

### UT-181

Locations: 6 file(s)

- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 497
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 517
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 3285
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 3305
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 949
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4498
  - Context: `Unknown`

### UR-045

Locations: 1 file(s)

- File: `src-tauri/src/commands/download/smart_cache.rs`
  - Line: 3
  - Context: `Unknown`

### UR-046

Locations: 1 file(s)

- File: `src-tauri/src/commands/player/remote.rs`
  - Line: 3
  - Context: `Unknown`

### UR-080

Locations: 29 file(s)

- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 744
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 2203
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 2217
  - Context: `Unknown`
- File: `src-tauri/src/lib.rs`
  - Line: 1427
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_render.rs`
  - Line: 11
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_render.rs`
  - Line: 60
  - Context: `/ holding a lazily-resolving function pointer. So the address` dlsym`...`
- File: `src-tauri/src/player/mpv_render.rs`
  - Line: 160
  - Context: `impl MpvRenderContext {`
- File: `src-tauri/src/player/mpv_render.rs`
  - Line: 225
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_render.rs`
  - Line: 251
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_render.rs`
  - Line: 260
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_render.rs`
  - Line: 304
  - Context: `Unknown`
- File: `src-tauri/src/player/video_surface.rs`
  - Line: 29
  - Context: `Unknown`
- File: `src-tauri/src/player/video_surface.rs`
  - Line: 120
  - Context: `Unknown`
- File: `src-tauri/src/player/video_surface.rs`
  - Line: 148
  - Context: `Unknown`
- File: `src-tauri/src/player/video_surface.rs`
  - Line: 208
  - Context: `Unknown`
- File: `src-tauri/src/player/video_surface.rs`
  - Line: 272
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 117
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 123
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 190
  - Context: `Unknown`
- File: `src-tauri/src/player/native_video.rs`
  - Line: 15
  - Context: `! It is a function rather than three` env::var`checks for the reason ...`
- File: `src-tauri/src/player/native_video.rs`
  - Line: 29
  - Context: `Unknown`
- File: `src-tauri/src/player/native_video.rs`
  - Line: 51
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 218
  - Context: `pub fn video_audio_codecs(detected: &str) -> String {`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 261
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 307
  - Context: `Unknown`
- File: `src-tauri/src/repository/device_profile.rs`
  - Line: 351
  - Context: `pub fn audio_forces_transcode(streams: &[(Option<&str>, bool)]) -> boo...`
- File: `src-tauri/src/repository/online.rs`
  - Line: 630
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 791
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 873
  - Context: `Unknown`

### UT-203

Locations: 10 file(s)

- File: `src-tauri/src/commands/library.rs`
  - Line: 274
  - Context: `Unknown`
- File: `src-tauri/src/commands/library.rs`
  - Line: 295
  - Context: `Unknown`
- File: `src-tauri/src/commands/library.rs`
  - Line: 307
  - Context: `Unknown`
- File: `src-tauri/src/repository/exclusions.rs`
  - Line: 191
  - Context: `Unknown`
- File: `src-tauri/src/repository/exclusions.rs`
  - Line: 203
  - Context: `Unknown`
- File: `src-tauri/src/repository/exclusions.rs`
  - Line: 232
  - Context: `Unknown`
- File: `src-tauri/src/repository/exclusions.rs`
  - Line: 275
  - Context: `Unknown`
- File: `src-tauri/src/repository/exclusions.rs`
  - Line: 293
  - Context: `Unknown`
- File: `src-tauri/src/repository/exclusions.rs`
  - Line: 316
  - Context: `Unknown`
- File: `src-tauri/src/repository/exclusions.rs`
  - Line: 334
  - Context: `Unknown`

### UT-205

Locations: 10 file(s)

- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 138
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 156
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 186
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 329
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 1374
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 1514
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 1867
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 2560
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 2589
  - Context: `Unknown`
- File: `src-tauri/src/commands/download/mod.rs`
  - Line: 2616
  - Context: `Unknown`

### UT-206

Locations: 9 file(s)

- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1963
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 1982
  - Context: `Unknown`
- File: `src-tauri/src/commands/player/mod.rs`
  - Line: 3070
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 1306
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 4582
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 4635
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1210
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4109
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4145
  - Context: `Unknown`

### UR-014

Locations: 1 file(s)

- File: `src-tauri/src/commands/playlist.rs`
  - Line: 4
  - Context: `Unknown`

### IT-017

Locations: 1 file(s)

- File: `src-tauri/src/commands/catalog.rs`
  - Line: 878
  - Context: `Unknown`

### UT-014

Locations: 2 file(s)

- File: `src-tauri/src/storage/mod.rs`
  - Line: 157
  - Context: `pub fn file_size(&self) -> Option<u64> {`
- File: `src-tauri/src/storage/db_service.rs`
  - Line: 311
  - Context: `Unknown`

### UT-015

Locations: 2 file(s)

- File: `src-tauri/src/storage/mod.rs`
  - Line: 157
  - Context: `pub fn file_size(&self) -> Option<u64> {`
- File: `src-tauri/src/storage/db_service.rs`
  - Line: 311
  - Context: `Unknown`

### UT-016

Locations: 2 file(s)

- File: `src-tauri/src/storage/mod.rs`
  - Line: 157
  - Context: `pub fn file_size(&self) -> Option<u64> {`
- File: `src-tauri/src/storage/db_service.rs`
  - Line: 311
  - Context: `Unknown`

### UT-017

Locations: 2 file(s)

- File: `src-tauri/src/storage/mod.rs`
  - Line: 157
  - Context: `pub fn file_size(&self) -> Option<u64> {`
- File: `src-tauri/src/storage/db_service.rs`
  - Line: 311
  - Context: `Unknown`

### UT-018

Locations: 2 file(s)

- File: `src-tauri/src/storage/mod.rs`
  - Line: 157
  - Context: `pub fn file_size(&self) -> Option<u64> {`
- File: `src-tauri/src/storage/db_service.rs`
  - Line: 311
  - Context: `Unknown`

### UT-019

Locations: 2 file(s)

- File: `src-tauri/src/storage/mod.rs`
  - Line: 157
  - Context: `pub fn file_size(&self) -> Option<u64> {`
- File: `src-tauri/src/storage/db_service.rs`
  - Line: 311
  - Context: `Unknown`

### UT-020

Locations: 2 file(s)

- File: `src-tauri/src/storage/mod.rs`
  - Line: 157
  - Context: `pub fn file_size(&self) -> Option<u64> {`
- File: `src-tauri/src/storage/db_service.rs`
  - Line: 311
  - Context: `Unknown`

### UT-021

Locations: 2 file(s)

- File: `src-tauri/src/storage/mod.rs`
  - Line: 157
  - Context: `pub fn file_size(&self) -> Option<u64> {`
- File: `src-tauri/src/storage/db_service.rs`
  - Line: 311
  - Context: `Unknown`

### UT-022

Locations: 2 file(s)

- File: `src-tauri/src/storage/mod.rs`
  - Line: 157
  - Context: `pub fn file_size(&self) -> Option<u64> {`
- File: `src-tauri/src/storage/db_service.rs`
  - Line: 311
  - Context: `Unknown`

### UT-023

Locations: 2 file(s)

- File: `src-tauri/src/storage/mod.rs`
  - Line: 157
  - Context: `pub fn file_size(&self) -> Option<u64> {`
- File: `src-tauri/src/storage/db_service.rs`
  - Line: 311
  - Context: `Unknown`

### UT-025

Locations: 2 file(s)

- File: `src-tauri/src/storage/mod.rs`
  - Line: 157
  - Context: `pub fn file_size(&self) -> Option<u64> {`
- File: `src-tauri/src/storage/db_service.rs`
  - Line: 311
  - Context: `Unknown`

### UT-026

Locations: 2 file(s)

- File: `src-tauri/src/player/backend.rs`
  - Line: 251
  - Context: `Unknown`
- File: `src-tauri/src/player/backend.rs`
  - Line: 307
  - Context: `Unknown`

### UT-027

Locations: 1 file(s)

- File: `src-tauri/src/player/backend.rs`
  - Line: 251
  - Context: `Unknown`

### UT-028

Locations: 1 file(s)

- File: `src-tauri/src/player/backend.rs`
  - Line: 251
  - Context: `Unknown`

### UT-029

Locations: 1 file(s)

- File: `src-tauri/src/player/backend.rs`
  - Line: 251
  - Context: `Unknown`

### UT-030

Locations: 1 file(s)

- File: `src-tauri/src/player/backend.rs`
  - Line: 251
  - Context: `Unknown`

### UT-031

Locations: 1 file(s)

- File: `src-tauri/src/player/backend.rs`
  - Line: 251
  - Context: `Unknown`

### UT-083

Locations: 1 file(s)

- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 900
  - Context: `Unknown`

### UT-084

Locations: 1 file(s)

- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 912
  - Context: `Unknown`

### UT-115

Locations: 2 file(s)

- File: `src-tauri/src/commands/catalog.rs`
  - Line: 281
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 764
  - Context: `Unknown`

### UT-121

Locations: 2 file(s)

- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 717
  - Context: `Unknown`
- File: `src-tauri/src/player/mpv_backend.rs`
  - Line: 726
  - Context: `Unknown`

### UT-125

Locations: 3 file(s)

- File: `src-tauri/src/commands/catalog.rs`
  - Line: 1001
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 1039
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 1062
  - Context: `Unknown`

### UT-126

Locations: 2 file(s)

- File: `src-tauri/src/commands/catalog.rs`
  - Line: 490
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 1090
  - Context: `Unknown`

### UT-127

Locations: 11 file(s)

- File: `src-tauri/src/media_server.rs`
  - Line: 27
  - Context: `Unknown`
- File: `src-tauri/src/media_server.rs`
  - Line: 282
  - Context: `Unknown`
- File: `src-tauri/src/media_server.rs`
  - Line: 327
  - Context: `Unknown`
- File: `src-tauri/src/media_server.rs`
  - Line: 398
  - Context: `Unknown`
- File: `src-tauri/src/media_server.rs`
  - Line: 449
  - Context: `Unknown`
- File: `src-tauri/src/media_server.rs`
  - Line: 458
  - Context: `Unknown`
- File: `src-tauri/src/media_server.rs`
  - Line: 473
  - Context: `Unknown`
- File: `src-tauri/src/media_server.rs`
  - Line: 508
  - Context: `Unknown`
- File: `src-tauri/src/media_server.rs`
  - Line: 515
  - Context: `Unknown`
- File: `src-tauri/src/media_server.rs`
  - Line: 540
  - Context: `Unknown`
- File: `src-tauri/src/media_server.rs`
  - Line: 567
  - Context: `Unknown`

### UT-171

Locations: 2 file(s)

- File: `src-tauri/src/commands/catalog.rs`
  - Line: 916
  - Context: `Unknown`
- File: `src-tauri/src/commands/catalog.rs`
  - Line: 957
  - Context: `Unknown`

### UT-204

Locations: 8 file(s)

- File: `src-tauri/src/thumbnail/cache.rs`
  - Line: 39
  - Context: `Unknown`
- File: `src-tauri/src/thumbnail/cache.rs`
  - Line: 74
  - Context: `Unknown`
- File: `src-tauri/src/thumbnail/cache.rs`
  - Line: 200
  - Context: `Unknown`
- File: `src-tauri/src/thumbnail/cache.rs`
  - Line: 628
  - Context: `Unknown`
- File: `src-tauri/src/thumbnail/cache.rs`
  - Line: 668
  - Context: `Unknown`
- File: `src-tauri/src/thumbnail/cache.rs`
  - Line: 706
  - Context: `Unknown`
- File: `src-tauri/src/thumbnail/cache.rs`
  - Line: 735
  - Context: `Unknown`
- File: `src-tauri/src/thumbnail/cache.rs`
  - Line: 766
  - Context: `Unknown`

### UT-230

Locations: 1 file(s)

- File: `src-tauri/src/domain/from_jellyfin.rs`
  - Line: 151
  - Context: `Unknown`

### UR-006

Locations: 8 file(s)

- File: `src-tauri/src/lib.rs`
  - Line: 491
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 179
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 201
  - Context: `Unknown`
- File: `src-tauri/src/player/seek.rs`
  - Line: 121
  - Context: `Unknown`
- File: `src-tauri/src/player/seek.rs`
  - Line: 153
  - Context: `Unknown`
- File: `src-tauri/src/player/android/mod.rs`
  - Line: 438
  - Context: `Unknown`
- File: `src-tauri/src/player/android/mod.rs`
  - Line: 1123
  - Context: `Unknown`
- File: `src-tauri/src/player/android/mod.rs`
  - Line: 1425
  - Context: `Unknown`

### UR-081

Locations: 14 file(s)

- File: `src-tauri/src/lib.rs`
  - Line: 760
  - Context: `Unknown`
- File: `src-tauri/src/lib.rs`
  - Line: 1455
  - Context: `Unknown`
- File: `src-tauri/src/bin/player_conformance.rs`
  - Line: 11
  - Context: `! access to the player internals — one exported function rather than a...`
- File: `src-tauri/src/player/conformance.rs`
  - Line: 25
  - Context: `Unknown`
- File: `src-tauri/src/player/fake_player.rs`
  - Line: 17
  - Context: `Unknown`
- File: `src-tauri/src/player/media_player.rs`
  - Line: 22
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1010
  - Context: `Unknown`
- File: `src-tauri/src/player/media.rs`
  - Line: 197
  - Context: `Unknown`
- File: `src-tauri/src/player/media.rs`
  - Line: 214
  - Context: `impl MediaItem {`
- File: `src-tauri/src/player/mpv_player.rs`
  - Line: 13
  - Context: `Unknown`
- File: `src-tauri/src/player/legacy_player.rs`
  - Line: 19
  - Context: `Unknown`
- File: `src-tauri/src/player/fake_player_conformance.rs`
  - Line: 7
  - Context: `Unknown`
- File: `src-tauri/src/repository/stream_selection.rs`
  - Line: 200
  - Context: `Unknown`
- File: `src-tauri/src/conformance_runner.rs`
  - Line: 13
  - Context: `Unknown`

### UT-216

Locations: 1 file(s)

- File: `src-tauri/src/player/native_video.rs`
  - Line: 51
  - Context: `Unknown`

### UT-220

Locations: 2 file(s)

- File: `src-tauri/src/player/conformance.rs`
  - Line: 25
  - Context: `Unknown`
- File: `src-tauri/src/player/fake_player_conformance.rs`
  - Line: 7
  - Context: `Unknown`

### UT-222

Locations: 1 file(s)

- File: `src-tauri/src/player/media_player.rs`
  - Line: 311
  - Context: `Unknown`

### UR-016

Locations: 2 file(s)

- File: `src-tauri/src/player/backend.rs`
  - Line: 62
  - Context: `Unknown`
- File: `src-tauri/src/player/backend.rs`
  - Line: 307
  - Context: `Unknown`

### UT-032

Locations: 1 file(s)

- File: `src-tauri/src/player/backend.rs`
  - Line: 251
  - Context: `Unknown`

### UT-033

Locations: 1 file(s)

- File: `src-tauri/src/player/backend.rs`
  - Line: 251
  - Context: `Unknown`

### UT-179

Locations: 4 file(s)

- File: `src-tauri/src/player/mod.rs`
  - Line: 154
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 1671
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4793
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4821
  - Context: `Unknown`

### UT-180

Locations: 3 file(s)

- File: `src-tauri/src/player/mod.rs`
  - Line: 1427
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4850
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4868
  - Context: `Unknown`

### UT-190

Locations: 2 file(s)

- File: `src-tauri/src/repository/online.rs`
  - Line: 1432
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4388
  - Context: `Unknown`

### UT-191

Locations: 2 file(s)

- File: `src-tauri/src/repository/online.rs`
  - Line: 1432
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4409
  - Context: `Unknown`

### UT-193

Locations: 1 file(s)

- File: `scripts/tauri-security-config.test.ts`
  - Line: 11
  - Context: `Unknown`

### UT-194

Locations: 1 file(s)

- File: `src-tauri/src/player/seek.rs`
  - Line: 153
  - Context: `Unknown`

### UT-195

Locations: 1 file(s)

- File: `src-tauri/src/player/seek.rs`
  - Line: 170
  - Context: `Unknown`

### UT-196

Locations: 1 file(s)

- File: `src-tauri/src/player/seek.rs`
  - Line: 186
  - Context: `/ which backends reject (the "Raw(-10)" class of error).`

### UT-197

Locations: 1 file(s)

- File: `src-tauri/src/player/seek.rs`
  - Line: 199
  - Context: `/ this function exists to prevent.`

### UT-198

Locations: 1 file(s)

- File: `src-tauri/src/player/seek.rs`
  - Line: 210
  - Context: `Unknown`

### UT-200

Locations: 2 file(s)

- File: `src-tauri/src/player/stream_end.rs`
  - Line: 54
  - Context: `Unknown`
- File: `src-tauri/src/player/stream_end.rs`
  - Line: 91
  - Context: `Unknown`

### UT-202

Locations: 2 file(s)

- File: `scripts/extract-traces.ts`
  - Line: 444
  - Context: `Unknown`
- File: `scripts/extract-traces.ts`
  - Line: 454
  - Context: `export const MATRIX_LINK_PREFIX = "../";`

### UT-210

Locations: 1 file(s)

- File: `scripts/release-notes.test.ts`
  - Line: 4
  - Context: `Unknown`

### UT-211

Locations: 2 file(s)

- File: `src-tauri/src/player/background_policy.rs`
  - Line: 3
  - Context: `Unknown`
- File: `src-tauri/src/player/background_policy.rs`
  - Line: 53
  - Context: `Unknown`

### UT-217

Locations: 1 file(s)

- File: `src-tauri/src/player/seek.rs`
  - Line: 272
  - Context: `Unknown`

### UT-218

Locations: 1 file(s)

- File: `src-tauri/src/player/mpv_backend_test.rs`
  - Line: 33
  - Context: `Unknown`

### UT-221

Locations: 1 file(s)

- File: `src-tauri/src/player/mod.rs`
  - Line: 2505
  - Context: `Unknown`

### UT-223

Locations: 1 file(s)

- File: `src-tauri/src/player/legacy_player.rs`
  - Line: 236
  - Context: `Unknown`

### UT-224

Locations: 1 file(s)

- File: `src-tauri/src/player/mod.rs`
  - Line: 2466
  - Context: `Unknown`

### UT-225

Locations: 1 file(s)

- File: `src-tauri/src/player/mpv_backend_test.rs`
  - Line: 77
  - Context: `Unknown`

### UT-226

Locations: 1 file(s)

- File: `src-tauri/src/player/mod.rs`
  - Line: 2438
  - Context: `Unknown`

### UT-229

Locations: 3 file(s)

- File: `src-tauri/src/repository/online.rs`
  - Line: 1237
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4180
  - Context: `Unknown`
- File: `src-tauri/src/repository/types.rs`
  - Line: 367
  - Context: `Unknown`

### UT-232

Locations: 1 file(s)

- File: `src-tauri/src/player/track_switch.rs`
  - Line: 34
  - Context: `Unknown`

### UT-233

Locations: 1 file(s)

- File: `src-tauri/src/player/track_switch.rs`
  - Line: 63
  - Context: `Unknown`

### UT-234

Locations: 2 file(s)

- File: `src-tauri/src/repository/online.rs`
  - Line: 2605
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 3174
  - Context: `Unknown`

### UT-238

Locations: 4 file(s)

- File: `src-tauri/src/player/mod.rs`
  - Line: 2286
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4164
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4188
  - Context: `Unknown`
- File: `src-tauri/src/player/mod.rs`
  - Line: 4211
  - Context: `Unknown`

### UT-239

Locations: 3 file(s)

- File: `src-tauri/src/repository/series_progress.rs`
  - Line: 12
  - Context: `Unknown`
- File: `src-tauri/src/repository/series_progress.rs`
  - Line: 474
  - Context: `Unknown`
- File: `src-tauri/src/repository/series_progress.rs`
  - Line: 491
  - Context: `Unknown`

### UT-240

Locations: 2 file(s)

- File: `src-tauri/src/repository/offline.rs`
  - Line: 716
  - Context: `Unknown`
- File: `src-tauri/src/repository/offline.rs`
  - Line: 4886
  - Context: `Unknown`

### UT-241

Locations: 4 file(s)

- File: `src-tauri/src/repository/online.rs`
  - Line: 1335
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1374
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1875
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4284
  - Context: `Unknown`

### UT-242

Locations: 2 file(s)

- File: `src-tauri/src/repository/online.rs`
  - Line: 1335
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4326
  - Context: `Unknown`

### UT-243

Locations: 2 file(s)

- File: `src-tauri/src/repository/online.rs`
  - Line: 1335
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4353
  - Context: `Unknown`

### UT-244

Locations: 3 file(s)

- File: `src-tauri/src/repository/online.rs`
  - Line: 1315
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 1875
  - Context: `Unknown`
- File: `src-tauri/src/repository/online.rs`
  - Line: 4369
  - Context: `Unknown`

### UT-247

Locations: 1 file(s)

- File: `src-tauri/src/repository/offline.rs`
  - Line: 5036
  - Context: `Unknown`

### UT-248

Locations: 1 file(s)

- File: `src-tauri/src/repository/offline.rs`
  - Line: 5119
  - Context: `Unknown`

### UT-249

Locations: 1 file(s)

- File: `src-tauri/src/repository/offline.rs`
  - Line: 5079
  - Context: `Unknown`

# Requirement Traceability CI/CD Pipeline

This document explains the automated requirement traceability validation system for JellyTau.

## Overview

The CI/CD pipeline automatically validates that code changes are properly traced to requirements. This ensures:

- ✅ Requirements are implemented with clear traceability
- ✅ No requirement coverage regressions
- ✅ Code changes are linked to specific requirements
- ✅ Quality metrics are tracked over time

## Gitea Actions Workflows

Traceability validation lives in `.gitea/workflows/traceability-check.yml`:

- ✅ Automatic trace extraction
- ✅ Coverage validation against minimum threshold (88%, ratcheted)
- ✅ Modified file checking
- ✅ Artifact preservation
- ✅ Summary reports

Runs on: Every push and pull request to `master`/`main`/`develop`

A second workflow, `traceability.yml`, previously duplicated this one as a "GitHub-compatible alternative". It was removed: CI here is Gitea Actions, and its only unique step (PR comments via `actions/github-script`) depended on the GitHub REST client, which Gitea does not provide. To add PR comments, post to Gitea's `/api/v1/repos/{owner}/{repo}/issues/{index}/comments` from `traceability-check.yml` rather than reviving the old file.

## What Gets Validated

### 1. Trace Extraction

    bun run traces:json > traces-report.json

Extracts all TRACES comments from:

- TypeScript files (`src/**/*.ts`)
- Svelte components (`src/**/*.svelte`)
- Rust code (`src-tauri/src/**/*.rs`)
- Test files

### 2. Coverage Thresholds

The workflow checks:

- Minimum overall coverage: 88% (`MIN_THRESHOLD`)

Denominators are derived from `docs/requirements.md` at run time — they are never hardcoded here or in the workflow. Run `bun run traces:coverage` for the current per-type breakdown; any number written into this document is a snapshot that will drift.

> Why this matters. The workflow used to divide by frozen literals (UR/39, IR/24, DR/48, JA/3, total 114) while `requirements.md` had grown past 200. It reported 158% coverage, so the 50% threshold was unreachable and the job could not fail regardless of how far coverage dropped. See The fix derives the denominators from `requirements.md` at run time.

Coverage is the intersection of traced and defined IDs: an ID that appears in a `TRACES:` comment but is not defined in `requirements.md` is reported as orphaned and does not count toward coverage. UT/IT test identifiers are a separate taxonomy and are excluded entirely.

The workflow fails and blocks merge if coverage drops below the threshold — or if it computes above 100%, which can only mean the gate is miscounting.

#### Ratchet policy

`MIN_THRESHOLD` only ever goes up. It is deliberately set a few points below the coverage actually achieved (88 against a real ~90%), so a genuine regression trips it. It previously sat at 50 while true coverage was 86%: nearly half the matrix could have rotted before CI objected. It was ratcheted 50 → 82 when that was found, and 82 → 88 once coverage had held above 88% for several releases.

When coverage rises durably, raise the threshold to just under the new figure. Never lower it to make a red build pass — add the missing TRACES comments instead. The same number lives in `MIN_COVERAGE_PERCENT` in `scripts/extract-traces.ts` (so `bun run traces:coverage` gates locally on the same bar); `scripts/extract-traces.test.ts` fails if the two drift apart.

### 2b. Dangling requirement IDs

    bun run traces:validate

Every ID named by a `TRACES:` comment must be defined as a table row in `docs/requirements.md`. The extractor used to accept any well-formed ID silently, so a typo or a rename that missed a call site passed unnoticed — `DR-189` and `UT-188` were referenced from three source files, defined nowhere, for months.

This check spans all six ID types (UR/IR/DR/JA/UT/IT), unlike the coverage `orphaned` list above, which considers only the four requirement types so that UT/IT noise cannot bury a real typo in the ratio's reporting. The workflow step fails the build on any dangling ID and prints each offender with the files that reference it.

### 3. Modified File Checking

On pull requests, the workflow:

1. Detects all changed TypeScript/Svelte/Rust files
2. Warns if new/modified files lack TRACES comments
3. Suggests the TRACES format for missing comments

## How to Add Traces to New Code

When you add new code or modify existing code, include TRACES comments:

### TypeScript/Svelte Example

    // TRACES: UR-005, UR-026 | DR-029
    export function handlePlayback() {
      // Implementation...
    }

### Rust Example

    #![allow(unused)]
    fn main() {
    /// TRACES: UR-005 | DR-001
    pub fn player_state_changed(state: PlayerState) {
        // Implementation...
    }
    }

### Test Example

    #![allow(unused)]
    fn main() {
    // TRACES: UR-005 | DR-001 | UT-026, UT-027
    #[cfg(test)]
    mod tests {
        // Tests...
    }
    }

## TRACES Format

    TRACES: [UR-###, ...] | [IR-###, ...] | [DR-###, ...] | [JA-###, ...]

- `UR-###` - User Requirements (features users see)
- `IR-###` - Integration Requirements (API/platform integration)
- `DR-###` - Development Requirements (internal architecture)
- `JA-###` - Jellyfin API Requirements (Jellyfin API usage)

Examples:

- `// TRACES: UR-005` - Single requirement
- `// TRACES: UR-005, UR-026` - Multiple of same type
- `// TRACES: UR-005 | DR-029` - Multiple types
- `// TRACES: UR-005, UR-026 | DR-001, DR-029 | UT-001` - Complex

## Workflow Behavior

### On Push to Main Branch

1. ✅ Extracts all traces from code
2. ✅ Validates coverage is >= 88%
3. ✅ Generates full traceability report
4. ✅ Saves report as artifact

### On Pull Request

1. ✅ Extracts all traces
2. ✅ Validates coverage >= 88%
3. ✅ Checks modified files for TRACES
4. ✅ Warns if new code lacks TRACES
5. ✅ Suggests proper format
6. ✅ Generates report artifact

### Failure Scenarios

The workflow fails (blocks merge) if:

- Coverage drops below 88%
- A `TRACES:` comment names an ID `docs/requirements.md` does not define
- JSON extraction fails
- Invalid trace format

The workflow warns (but doesn't block) if:

- New files lack TRACES comments
- Coverage drops (but still above threshold)

## Viewing Reports

### In Gitea Actions UI

1. Go to Actions tab
2. Click the Traceability Validation workflow run
3. Download traceability-reports artifact
4. View:
   - `traces-report.json` - Raw trace data
   - `docs/traceability.md` - Formatted report

### Locally

    # Extract current traces
    bun run traces:json | jq '.byType'

    # Generate full report
    bun run traces:markdown
    cat docs/traceability.md

## Coverage Goals

### Current Status

Run `bun run traces:coverage` — it prints the live figure and exits non-zero below threshold. Numbers are deliberately not pinned here; the previous snapshot in this section (51%, 56/114) was stale by roughly 100 requirements and was what made the broken CI arithmetic look plausible for so long.

As of August 2026 overall coverage is ~90%.

### Targets

- Short term (Sprint): Maintain ≥88% overall (the current ratchet)
- Medium term (Month): Hold above 90% and ratchet the gate to match
- Long term (Release): Reach 95% coverage with focus on:
  - IR requirements (API clients)
  - JA requirements (Jellyfin API endpoints)
  - Remaining UR/DR requirements

## Improving Coverage

### For Missing User Requirements (UR)

1. Review README.md for unimplemented features
2. Add TRACES to code that implements them
3. Focus on high-priority features (High/Medium priority)

### For Missing Integration Requirements (IR)

1. Add TRACES to Jellyfin API client methods
2. Add TRACES to platform-specific backends (Android/Linux)
3. Link to corresponding Jellyfin API endpoints

### For Missing Development Requirements (DR)

1. Add TRACES to UI components in `src/lib/components/`
2. Add TRACES to composables in `src/lib/composables/`
3. Add TRACES to player backend in `src-tauri/src/player/`

### For Jellyfin API Requirements (JA)

1. Add TRACES to Jellyfin API wrapper methods
2. Document which endpoints map to which requirements
3. Link to Jellyfin API documentation

## Example PR Checklist

When submitting a pull request:

- All new code has TRACES comments linking to requirements
- TRACES format is correct: `// TRACES: UR-001 | DR-002`
- Workflow passes (coverage ≥ 88%)
- No coverage regressions
- Artifact traceability report was generated

## Troubleshooting

### "Coverage below minimum threshold"

Problem: Workflow fails with coverage < 88%

Solution:

1. Run `bun run traces:json` locally
2. Check which requirements are traced
3. Add TRACES to untraced code sections
4. Re-run extraction to verify

### "New files without TRACES"

Problem: Workflow warns about new files lacking TRACES

Solution:

1. Add TRACES comments to all new code
2. Format: `// TRACES: UR-001 | DR-002`
3. Map code to specific requirements from README.md
4. Re-push

### "Invalid JSON format"

Problem: Trace extraction produces invalid JSON

Solution:

1. Check for malformed TRACES comments
2. Run locally: `bun run traces:json`
3. Look for parsing errors
4. Fix and retry

## Integration with Development

### Before Committing

    # Check your traces
    bun run traces:json | jq '.byType'

    # Regenerate report
    bun run traces:markdown

    # Verify traces syntax
    grep "TRACES:" src/**/*.ts src/**/*.rs

### In Your IDE

Add a file watcher to regenerate traces on save:

    {
      "fileWatcher.watchPatterns": [
        "src/**/*.ts",
        "src/**/*.svelte",
        "src-tauri/src/**/*.rs"
      ],
      "fileWatcher.command": "bun run traces:markdown"
    }

### Git Hooks

Add a pre-push hook to validate traces:

    #!/bin/bash
    # .git/hooks/pre-push
    bun run traces:json > /dev/null
    if [ $? -ne 0 ]; then
      echo "❌ Invalid TRACES format"
      exit 1
    fi

## References

- Extract Traces Script
- Requirements Specification
- Traceability Matrix
- Gitea Actions Documentation

## Support

For issues or questions:

1. Check this document
2. Review example traces in `src/lib/stores/`
3. Check existing TRACES comments for format
4. Review workflow logs in Gitea Actions

Last Updated: 2026-02-13

# TRACES Quick Reference Guide

## What are TRACES?

TRACES are requirement identifiers embedded in code comments to track which requirements are implemented where.

Format: `// TRACES: UR-001, UR-002 | DR-003`

## Quick Examples

### TypeScript

    // TRACES: UR-005, UR-026 | DR-029
    export function handlePlayback() { }

    /**
     * Resume playback from saved position
     * TRACES: UR-019 | DR-022
     */
    export async function resumePlayback(itemId: string) { }

### Svelte

    <!-- TRACES: UR-007, UR-008 | DR-007 -->
    <script>
      export let items = [];
    </script>

### Rust

    #![allow(unused)]
    fn main() {
    /// TRACES: UR-005 | DR-001
    pub enum PlayerState { ... }

    #[test]
    fn test_queue_next() {
        // TRACES: UR-005 | DR-005 | UT-003
    }
    }

## Requirement Types

Type | Meaning | Example
--- | --- | ---
UR | User Requirement | UR-005: Control media playback
IR | Integration Requirement | IR-003: LibMPV integration
DR | Development Requirement | DR-001: Player state machine
JA | Jellyfin API Requirement | JA-007: Get playback info
UT | Unit Test | UT-001: Player state transitions
IT | Integration Test | IT-003: Audio playback via libmpv

## Where to Find Requirements

1. User Requirements (UR): requirements.md
2. Integration Requirements (IR): requirements.md
3. Development Requirements (DR): requirements.md
4. Jellyfin API (JA): requirements.md

## How to Add TRACES

### Step 1: Find the Requirement

Look up the requirement in README.md or the traceability matrix.

Example: `UR-005: Control media playback (pause, play, skip, scrub)`

### Step 2: Add Comment

Add TRACES comment at the top of the function/type/module:

    // TRACES: UR-005
    export async function playMedia(itemId: string) {
      // Implementation
    }

### Step 3: Run Extraction

Verify the trace is captured:

    bun run traces:json | jq '.requirements | keys | grep "UR-005"'

## Common Patterns

### Single Requirement

    // TRACES: UR-005
    function handlePlay() { }

### Multiple Requirements, Same Type

    // TRACES: UR-005, UR-026, UR-019
    function handlePlaybackState() { }

### Multiple Types

    // TRACES: UR-005, UR-026 | DR-029
    function autoplayNextEpisode() { }

### Test Coverage

    // TRACES: UR-005 | UT-001
    #[test]
    fn test_player_state_transition() { }

### Modules/Files

    /**
     * Player event handling
     * TRACES: UR-005, UR-019, UR-023 | DR-001, DR-028
     */

## Validation

### Check Your Changes

    # View current coverage
    bun run traces:json | jq '.byType'

    # Generate full report
    bun run traces:markdown

    # Check specific requirement
    bun run traces:json | jq '.requirements."UR-005"'

### Before Committing

1. Ensure all new code has TRACES
2. Format is correct: `// TRACES: ...`
3. Requirements exist in `docs/requirements.md` — `bun run traces:validate`
4. No typos in requirement IDs (same command catches them)

## CI/CD Validation

The workflow automatically checks:

- ✅ Coverage stays >= 88% (a ratchet — raise it, never lower it)
- ✅ Every traced ID is defined in `docs/requirements.md`
- ✅ New files have TRACES
- ✅ JSON format is valid
- ✅ Reports are generated

See traceability-ci.md for details.

## Tips & Tricks

### Find Related Code

    # Find all code tracing to UR-005
    bun run traces:json | jq '.requirements."UR-005"'

    # List all tests
    bun run traces:json | jq '.requirements | keys | map(select(startswith("UT")))'

### Update Your Editor

VS Code:

    {
      "editor.wordBasedSuggestions": false,
      "editor.suggest.custom": [
        {
          "name": "TRACES Format",
          "insertText": "// TRACES: $1",
          "insertTextRules": "InsertAsSnippet"
        }
      ]
    }

### Find Untraced Code

    # Files modified without TRACES
    git diff --name-only | xargs grep -L "TRACES:" | head -10

## FAQ

Q: Do I need TRACES on every function? A: Only for code that implements requirements. Internal helpers don't need TRACES.

Q: Can I use TRACES on multiple related functions? A: Yes! Add at the file/module level or on individual functions.

Q: What if code doesn't relate to any requirement? A: Leave it untraced. TRACES are for requirement-driven development.

Q: How often should I regenerate reports? A: Automatically on push (CI/CD). Manually after changes: `bun run traces:markdown`

Q: Can I trace to requirements that aren't implemented yet? A: Yes! TRACES show your implementation plan.

## See Also

- Full Traceability Matrix
- CI/CD Pipeline Guide
- Requirements Specification
- Extraction Script

Quick Start:

1. Add `// TRACES: UR-XXX` to new code
2. Run `bun run traces:markdown`
3. Check `docs/traceability.md`
4. Submit PR - workflow validates automatically!

# JellyTau Software Architecture

This document describes the current architecture of JellyTau, a cross-platform Jellyfin client built with Tauri, SvelteKit, and Rust.

Last Updated: 2026-06-20

## Architecture Overview

JellyTau uses a client-server architecture: business logic lives in a comprehensive Rust backend, while a UI-rich Svelte frontend handles presentation and interaction.

### Architecture Principles

- Business Logic in Rust: Core logic — playback, repository, sync, downloads, connectivity — lives in Rust for performance, reliability, and type safety.
- Presentation in Svelte: The frontend (~20.5k non-test lines) owns UI, layout, navigation, and interaction state and invokes Rust commands. It is intentionally UI-heavy, not a thin wrapper. Largest pieces: components + routes (~14.6k lines), stores (~3.4k), api/services/utils (~2.4k); `VideoPlayer.svelte` alone is ~1.6k lines.
- Events + Polling hybrid: Rust emits events the frontend listens to, and the UI also polls status on short intervals in a few hot spots (e.g. queue status in `library/+layout.svelte`, playback progress in `VideoPlayer.svelte`).
- Unified player boundary: UI components control playback only through the frontend facade `src/lib/player/index.ts` (`playerController`), never by calling `commands.player*` directly. Webview-rendered HTML5 video reports its state back into Rust via `src/lib/player/html5Adapter.ts` and the `player_report_*` commands, so the `PlayerController` stays the single source of truth in both native (MPV/ExoPlayer) and HTML5 modes (see 05-platform-backends.md).
- Handle-Based Resources: UUID handles for stateful Rust objects.
- Cache-First: Parallel queries with intelligent fallback.
- Single source of truth for reachability: Server reachability is derived from the outcome of real repository traffic, not a side-channel poller. The `OnlineRepository` reports each server result to the `ConnectivityMonitor` (classified via `RepoError`), which applies a time-window debounce before declaring the server offline and recovers instantly on the first success. The standalone `/System/Info/Public` probe runs only while offline, as a recovery detector for idle sessions.
- Poison-tolerant locking: Shared `std::sync` state is accessed via the `MutexSafe`/`RwLockSafe` helpers in `utils/lock.rs`, which recover a poisoned lock instead of cascading a panic across the player.
- Graceful backend init: If a native player backend (MPV/ExoPlayer) fails to initialize, the app falls back to a no-op backend and emits a `backend-init-failed` event rather than crashing.

    flowchart TB
        subgraph Frontend["Svelte Frontend"]
            subgraph Stores["Stores (Thin Wrappers)"]
                auth["auth"]
                player["player"]
                queue["queue"]
                library["library"]
                connectivity["connectivity"]
                playbackMode["playbackMode"]
            end
            subgraph Components
                playerComp["player/"]
                libraryComp["library/"]
                Search["Search"]
            end
            subgraph Routes
                routeLibrary["/library"]
                routePlayer["/player"]
                routeRoot["/"]
            end
            subgraph API["API Layer (Thin Client)"]
                RepositoryClient["RepositoryClient<br/>(Handle-based)"]
                JellyfinClient["JellyfinClient<br/>(Helper)"]
            end
        end

        Frontend -->|"Tauri IPC (invoke)"| Backend

        subgraph Backend["Rust Backend (Business Logic)"]
            subgraph Commands["Tauri Commands (90+)"]
                PlayerCmds["player.rs"]
                RepoCmds["repository.rs (27)"]
                PlaybackModeCmds["playback_mode.rs (5)"]
                StorageCmds["storage.rs"]
                ConnectivityCmds["connectivity.rs (7)"]
            end

            subgraph Core["Core Modules"]
                MediaSessionManager["MediaSessionManager<br/>(Audio/Movie/TvShow/Idle)"]

                PlayerController["PlayerController<br/>+ PlayerBackend<br/>+ QueueManager"]

                Repository["Repository Layer<br/>HybridRepository (cache-first)<br/>OnlineRepository (HTTP)<br/>OfflineRepository (SQLite)"]

                PlaybackModeManager["PlaybackModeManager<br/>(Local/Remote/Idle)"]

                ConnectivityMonitor["ConnectivityMonitor<br/>(Adaptive polling)"]

                HttpClient["HttpClient<br/>(Exponential backoff retry)"]
            end

            subgraph Storage["Storage Layer"]
                DatabaseService["DatabaseService<br/>(Async trait)"]
                SQLite["SQLite Database<br/>(13 tables)"]
            end

            Commands --> Core
            Core --> Storage
            Repository --> HttpClient
            Repository --> DatabaseService
            Repository -->|"reports server outcome<br/>(success / RepoError)"| ConnectivityMonitor
        end

> The `Repository --> ConnectivityMonitor` edge is the source of truth for the offline/online banner: every server request the user actually makes updates reachability. The monitor's own polling is now an offline-only recovery probe (see 07-connectivity.md).

## Detailed Documentation

Each major subsystem is documented in its own file in this directory:

Document | Contents
--- | ---
01 - Rust Backend | Media session state machine, player state machine, playback mode, media items, queue manager, favorites (marking + browsing), player backend trait, player controller, playlist system, domain vocabulary owned by Rust (search scope, library exclusions, streaming quality ladder), background workers (catalog indexer, drains), Tauri commands
02 - Svelte Frontend | Store structure, music library navigation, playback reporting, repository architecture, playback mode system, database service abstraction, component hierarchy, MiniPlayer, sleep timer, auto-play, navigation guard, playlist management UI, library mosaic, series/episode navigation, downloaded browse, safe-area insets, native-video store, logging
03 - Data Flow | Repository query flow (cache-first), locally-indexed search, playback initiation, playback mode transfer, queue navigation, volume control
04 - Type Sync & Threading | Rust/TypeScript type synchronization, Tauri v2 IPC parameter naming convention, thread safety patterns
05 - Platform Backends | Player events system, HTML5 video adapter, MpvBackend (Linux), ExoPlayerBackend (Android) incl. audio settings parity, native video compositing, MediaSession & remote volume, album art caching, backend initialization
06 - Downloads & Offline | Download manager, download worker, smart caching engine, one storage model (cache entries are downloads), offline catalog visibility, download/offline commands, player integration, frontend store, UI components
07 - Connectivity | HTTP client with retry logic, connectivity monitor, network resilience architecture
08 - Database Design | Entity relationships, all table definitions (servers, users, libraries, items, user_data, downloads, media_streams, sync_queue, thumbnails, playlists), key queries, data flow diagrams, storage estimates
09 - Security | Authentication token storage, secure storage module, network security, webview CSP + asset-protocol scope, path confinement and input binding, local data protection

## File Structure Summary

    src-tauri/src/
    ├── lib.rs                    # Tauri app setup, state initialization
    ├── commands/                 # Tauri command handlers (~245 #[tauri::command] fns)
    │   ├── mod.rs               # Command exports
    │   ├── player/              # Player commands: queue, remote, session, settings, timers
    │   ├── repository.rs        # Repository commands (items, search, favourites, disk usage)
    │   ├── catalog.rs           # Catalog sync + the background index pass
    │   ├── favorites.rs         # Offline favourite drain
    │   ├── library.rs           # Library listing + folder exclusions
    │   ├── playlist.rs          # Playlist commands
    │   ├── playback_mode.rs     # Local/remote transfer
    │   ├── playback_reporting.rs
    │   ├── connectivity.rs      # Connectivity commands
    │   ├── storage/             # Storage & database commands: people, series_prefs, thumbnails
    │   ├── download/            # Download commands: mod, pinning, smart_cache
    │   ├── offline.rs           # Offline commands
    │   ├── device.rs            # Device id / capabilities
    │   ├── sessions.rs          # Remote sessions
    │   ├── sync.rs              # Sync queue commands
    │   └── sync_drain.rs        # Background sync-queue drain
    ├── repository/              # Repository pattern implementation
    │   ├── mod.rs               # MediaRepository trait, handle management
    │   ├── types.rs             # RepoError, Library, MediaItem, etc.
    │   ├── hybrid.rs            # HybridRepository with cache-first racing
    │   ├── online.rs            # OnlineRepository (HTTP API)
    │   └── offline.rs           # OfflineRepository (SQLite queries)
    ├── playback_mode/           # Playback mode manager
    │   └── mod.rs               # PlaybackMode enum, transfer logic
    ├── connectivity/            # Connectivity monitoring
    │   └── mod.rs               # ConnectivityMonitor, adaptive polling
    ├── jellyfin/                # Jellyfin API client
    │   ├── mod.rs               # Module exports
    │   ├── http_client.rs       # HTTP client with retry logic
    │   └── client.rs            # JellyfinClient for API calls
    ├── storage/                 # Database layer
    │   ├── mod.rs               # Database struct, migrations
    │   ├── db_service.rs        # DatabaseService trait (async wrapper)
    │   ├── schema.rs            # Table definitions
    │   └── queries/             # Query modules
    ├── download/                # Download manager module
    │   ├── mod.rs               # DownloadManager, DownloadInfo, DownloadTask
    │   ├── worker.rs            # DownloadWorker, HTTP streaming, retry logic
    │   ├── events.rs            # DownloadEvent enum
    │   └── cache.rs             # SmartCache, CacheConfig, LRU eviction
    └── player/                  # Player subsystem
        ├── mod.rs               # PlayerController
        ├── session.rs           # MediaSessionManager, MediaSessionType
        ├── state.rs             # PlayerState, PlayerEvent
        ├── media.rs             # MediaItem, MediaSource, MediaType
        ├── queue.rs             # QueueManager, RepeatMode
        ├── backend.rs           # PlayerBackend trait, NullBackend
        ├── events.rs            # PlayerStatusEvent, TauriEventEmitter
        ├── mpv/                 # Linux MPV backend
        │   ├── mod.rs           # MpvBackend implementation
        │   └── event_loop.rs    # Dedicated thread for MPV operations
        └── android/             # Android ExoPlayer backend
            └── mod.rs           # ExoPlayerBackend + JNI bindings

    src/lib/
    ├── api/                     # Thin API layer (~200 lines total)
    │   ├── types.ts             # TypeScript type definitions
    │   ├── repository-client.ts # RepositoryClient wrapper (~100 lines)
    │   ├── client.ts            # JellyfinClient (helper for streaming)
    │   └── sessions.ts          # SessionsApi (remote session control)
    ├── player/                  # Unified player boundary (frontend)
    │   ├── index.ts             # playerController facade — the only write-side entry point for playback
    │   └── html5Adapter.ts      # Reports webview <video> DOM events back into Rust (player_report_*)
    ├── services/
    │   ├── playerEvents.ts      # Tauri event listener for player events
    │   └── playbackReporting.ts # Thin wrapper (~50 lines)
    ├── stores/                  # Thin reactive wrappers over Rust commands
    │   ├── index.ts             # Re-exports
    │   ├── auth.ts              # Auth store (calls Rust commands)
    │   ├── player.ts            # Player store
    │   ├── queue.ts             # Queue store
    │   ├── library.ts           # Library store
    │   ├── playbackMode.ts      # Playback mode store (~150 lines)
    │   ├── connectivity.ts      # Connectivity store (~250 lines)
    │   └── downloads.ts         # Downloads store with event listeners
    └── components/
        ├── Search.svelte
        ├── player/              # Player UI components
        ├── playlist/            # Playlist modals (Create, AddTo)
        ├── sessions/            # Remote session control UI
        ├── downloads/           # Download UI components
        └── library/             # Library UI components + PlaylistDetailView

## Key Architecture Changes

What moved to Rust (~3,500 lines of business logic):

1. HTTP Client (338 lines) - Retry logic with exponential backoff
2. Connectivity Monitor (301 lines) - Reachability derived from real repository traffic, time-window debounce, offline-only recovery probe, event emission
3. Repository Pattern (1061 lines) - Cache-first hybrid with parallel racing
4. Database Service - Async wrapper preventing UI freezing
5. Playback Mode (303 lines) - Local/remote transfer coordination

Svelte/TypeScript frontend (~20.5k non-test lines, plus ~9.6k test lines):

- Components + routes (~14.6k lines) — UI and presentation
- Stores (~3.4k lines) — reactive state that invokes Rust commands and listens for events
- api / services / utils (~2.4k lines) — typed clients, event listeners, conversion helpers

The frontend is genuinely UI-heavy; business decisions live in Rust, but the UI owns layout, navigation, and interaction state.

Total Commands: ~245 `#[tauri::command]` functions across 17 command modules (~58k lines of Rust, ~37k non-test lines of TypeScript/Svelte).

> Counts and line totals in this file are periodic snapshots, not gates — the authority is the tree. Regenerate with `grep -rc '#\[tauri::command\]' src-tauri/src` and `wc -l`.

# Rust Backend Architecture

Location: `src-tauri/src/`

## Media Session State Machine

Location: `src-tauri/src/player/session.rs`

The media session tracks the high-level playback context (what kind of media is being consumed) and persists beyond individual playback states. This enables persistent UI (miniplayer for audio) and proper transitions between content types.

Architecture Note: The session manager is a separate app-level state manager (not inside PlayerController), coordinated by the commands layer. This maintains clean separation of concerns.

    stateDiagram-v2
        [*] --> Idle

        Idle --> AudioActive : play_queue(audio)
        Idle --> MovieActive : play_item(movie)
        Idle --> TvShowActive : play_item(episode)

        state "Audio Session" as AudioSession {
            [*] --> AudioActive
            AudioActive --> AudioInactive : playback_ended
            AudioInactive --> AudioActive : resume/play
            AudioActive --> AudioActive : next/previous
        }

        state "Movie Session" as MovieSession {
            [*] --> MovieActive
            MovieActive --> MovieInactive : playback_ended
            MovieInactive --> MovieActive : resume
        }

        state "TV Show Session" as TvShowSession {
            [*] --> TvShowActive
            TvShowActive --> TvShowInactive : playback_ended
            TvShowInactive --> TvShowActive : next_episode/resume
        }

        AudioSession --> Idle : dismiss/clear_queue
        AudioSession --> MovieSession : play_item(movie)
        AudioSession --> TvShowSession : play_item(episode)

        MovieSession --> Idle : dismiss/playback_complete
        MovieSession --> AudioSession : play_queue(audio)

        TvShowSession --> Idle : dismiss/series_complete
        TvShowSession --> AudioSession : play_queue(audio)

        note right of Idle
            No active media session
            Queue may exist but not playing
            No miniplayer/video player shown
        end note

        note right of AudioSession
            SHOW: Miniplayer (always visible)
            - Active: Play/pause/skip controls enabled
            - Inactive: Play button to resume queue
            Persists until explicit dismiss
        end note

        note right of MovieSession
            SHOW: Full video player
            - Active: Video playing/paused
            - Inactive: Resume dialog
            Auto-dismiss when playback ends
        end note

        note right of TvShowSession
            SHOW: Full video player + Next Episode UI
            - Active: Video playing/paused
            - Inactive: Next episode prompt
            Auto-dismiss when series ends
        end note

Session State Enum:

    #![allow(unused)]
    fn main() {
    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
    #[serde(tag = "type", rename_all = "snake_case")]
    pub enum MediaSessionType {
        /// No active session - browsing library
        Idle,

        /// Audio playback session (music, audiobooks, podcasts)
        /// Persists until explicitly dismissed
        Audio {
            /// Last/current track being played
            last_item: Option<MediaItem>,
            /// True = playing/paused, False = stopped/ended
            is_active: bool,
        },

        /// Movie playback (single video, auto-dismiss on end)
        Movie {
            item: MediaItem,
            is_active: bool,  // true = playing/paused, false = ended
        },

        /// TV show playback (supports next episode auto-advance)
        TvShow {
            item: MediaItem,
            series_id: String,
            is_active: bool,  // true = playing/paused, false = ended
        },
    }
    }

State Transitions & Rules:

From State | Event | To State | UI Behavior | Notes
--- | --- | --- | --- | ---
Idle | `play_queue(audio)` | Audio (active) | Show miniplayer | Creates audio session
Idle | `play_item(movie)` | Movie (active) | Show video player | Creates movie session
Idle | `play_item(episode)` | TvShow (active) | Show video player | Creates TV session
Audio (active) | `playback_ended` | Audio (inactive) | Miniplayer stays visible | Queue preserved
Audio (inactive) | `play/resume` | Audio (active) | Miniplayer enabled | Resume from queue
Audio (active/inactive) | `dismiss` | Idle | Hide miniplayer | Clear session
Audio (active/inactive) | `play_item(movie)` | Movie (active) | Switch to video player | Replace session
Movie (active) | `playback_ended` | Idle | Hide video player | Auto-dismiss
Movie (active) | `dismiss` | Idle | Hide video player | User dismiss
TvShow (active) | `playback_ended` | TvShow (inactive) | Show next episode UI | Wait for user choice
TvShow (inactive) | `next_episode` | TvShow (active) | Play next episode | Stay in session
TvShow (inactive) | `series_complete` | Idle | Hide video player | No more episodes

Key Design Decisions:

1. Audio Sessions Persist: Miniplayer stays visible even when queue ends, allows easy resume
2. Video Sessions Auto-Dismiss: Movies auto-close when finished (unless paused)
3. Single Active Session: Playing new content type replaces current session
4. Explicit Dismiss for Audio: User must click close button to clear audio session
5. Session != PlayerState: Session is higher-level, PlayerState tracks playing/paused/seeking

Edge Cases Handled:

- Album finishes: Session goes inactive, miniplayer shows last track with play disabled
- User wants to dismiss: Close button clears session -> Idle
- Switch content types: New session replaces old (audio -> movie)
- Paused for extended time: Session persists indefinitely
- Playback errors: Session stays inactive, allows retry
- Queue operations while idle: Queue exists but no session created until play

## Player State Machine (Low-Level Playback)

Location: `src-tauri/src/player/state.rs`

The player uses a deterministic state machine with 6 states (operates within a media session):

    stateDiagram-v2
        [*] --> Idle
        Idle --> Loading : Load
        Loading --> Playing : MediaLoaded
        Playing --> Paused : Pause
        Paused --> Playing : Play
        Paused --> Seeking : Seek
        Seeking --> Playing : PositionUpdate
        Playing --> Idle : Stop
        Paused --> Idle : Stop
        Idle --> Error : Error
        Loading --> Error : Error
        Playing --> Error : Error
        Paused --> Error : Error
        Seeking --> Error : Error

        state Playing {
            [*] : position, duration
        }
        state Paused {
            [*] : position, duration
        }
        state Seeking {
            [*] : target
        }
        state Error {
            [*] : error message
        }

State Enum:

    #![allow(unused)]
    fn main() {
    pub enum PlayerState {
        Idle,
        Loading { media: MediaItem },
        Playing { media: MediaItem, position: f64, duration: f64 },
        Paused { media: MediaItem, position: f64, duration: f64 },
        Seeking { media: MediaItem, target: f64 },
        Error { media: Option<MediaItem>, error: String },
    }
    }

Event Enum:

    #![allow(unused)]
    fn main() {
    pub enum PlayerEvent {
        Load(MediaItem),
        Play,
        Pause,
        Stop,
        Seek(f64),
        Next,
        Previous,
        MediaLoaded(f64),      // duration
        PositionUpdate(f64),   // position
        PlaybackEnded,
        Error(String),
    }
    }

## Playback Mode State Machine

Location: `src-tauri/src/playback_mode/mod.rs`

The playback mode manages whether media is playing locally on the device or remotely on another Jellyfin session (TV, browser, etc.):

    stateDiagram-v2
        [*] --> Idle

        Idle --> Local : play_queue()
        Idle --> Remote : transfer_to_remote(session_id)

        Local --> Remote : transfer_to_remote(session_id)
        Local --> Idle : stop()

        Remote --> Local : transfer_to_local()
        Remote --> Idle : session_disconnected()
        Remote --> Idle : stop()

        state Local {
            [*] : Playing on device
            [*] : ExoPlayer active
            [*] : Volume buttons -> device
        }

        state Remote {
            [*] : Controlling session
            [*] : session_id
            [*] : Volume buttons -> remote
            [*] : Android: VolumeProvider active
        }

        state Idle {
            [*] : No active playback
        }

State Enum:

    #![allow(unused)]
    fn main() {
    pub enum PlaybackMode {
        Local,                           // Playing on local device
        Remote { session_id: String },   // Controlling remote Jellyfin session
        Idle,                            // No active playback
    }
    }

State Transitions:

From | Event | To | Side Effects
--- | --- | --- | ---
Idle | `play_queue()` | Local | Start local playback
Idle | `transfer_to_remote(session_id)` | Remote | Send queue to remote session
Local | `transfer_to_remote(session_id)` | Remote | Stop local, send queue to remote, enable remote volume (Android)
Local | `stop()` | Idle | Stop local playback
Remote | `transfer_to_local()` | Local | Get remote state, stop remote, start local at same position, disable remote volume
Remote | `stop()` | Idle | Stop remote playback, disable remote volume
Remote | `session_disconnected()` | Idle | Session lost, disable remote volume

Integration with Player State Machine:

- When `PlaybackMode = Local`: Player state machine is active (Idle/Loading/Playing/Paused/etc.)
- When `PlaybackMode = Remote`: Player state is typically Idle (remote session controls playback)
- When `PlaybackMode = Idle`: Player state is Idle

Android Volume Control Integration:

When transitioning to `Remote` mode on Android:

1. Call `enable_remote_volume(initial_volume)`
2. VolumeProviderCompat intercepts hardware volume buttons
3. PlaybackStateCompat is set to STATE_PLAYING (shows volume UI)
4. Volume commands routed to remote session via Jellyfin API

When transitioning away from `Remote` mode:

1. Call `disable_remote_volume()`
2. Volume buttons return to controlling device volume
3. PlaybackStateCompat set to STATE_NONE
4. VolumeProviderCompat is cleared

## Media Item & Source

Location: `src-tauri/src/player/media.rs`

    #![allow(unused)]
    fn main() {
    pub struct MediaItem {
        pub id: String,
        pub title: String,
        pub artist: Option<String>,
        pub album: Option<String>,
        pub duration: Option<f64>,
        pub artwork_url: Option<String>,
        pub media_type: MediaType,
        pub source: MediaSource,
    }

    pub enum MediaType {
        Audio,
        Video,
    }

    pub enum MediaSource {
        Remote {
            stream_url: String,
            jellyfin_item_id: String,
        },
        Local {
            file_path: PathBuf,
            jellyfin_item_id: Option<String>,
        },
        DirectUrl {
            url: String,
        },
    }
    }

The `MediaSource` enum enables:

- Remote: Streaming from Jellyfin server
- Local: Downloaded/cached files (future offline support)
- DirectUrl: Direct URLs (channel plugins, external sources)

## Queue Manager

Location: `src-tauri/src/player/queue.rs`

    #![allow(unused)]
    fn main() {
    pub struct QueueManager {
        items: Vec<MediaItem>,
        current_index: Option<usize>,
        shuffle: bool,
        repeat: RepeatMode,
        shuffle_order: Vec<usize>,  // Fisher-Yates permutation
        history: Vec<usize>,        // For back navigation in shuffle
    }

    pub enum RepeatMode {
        Off,
        All,
        One,
    }
    }

Queue Navigation Logic:

    flowchart TB
        QM[QueueManager]
        QM --> Shuffle
        QM --> Repeat
        QM --> History

        subgraph Shuffle["Shuffle Mode"]
            ShuffleOff["OFF<br/>next() returns index + 1"]
            ShuffleOn["ON<br/>next() follows shuffle_order[]"]
        end

        subgraph Repeat["Repeat Mode"]
            RepeatOff["OFF<br/>next() at end: -> None"]
            RepeatAll["ALL<br/>next() at end: -> wrap to index 0"]
            RepeatOne["ONE<br/>next() returns same item"]
        end

        subgraph History["History"]
            HistoryDesc["Used for previous()<br/>in shuffle mode"]
        end

## Favorites System

Location:

- Commands: `src-tauri/src/commands/favorites.rs` (offline drain), `src-tauri/src/commands/repository.rs` (query + toggle), `src-tauri/src/commands/storage/` (local `user_data` writes)
- Repository: `get_favorites` on the trait, implemented by `online.rs`, `offline.rs` and `hybrid.rs`
- Frontend: `src/lib/services/favorites.ts`, `src/lib/components/FavoriteButton.svelte`, `/library/favorites`

Favouriting has two halves that are easy to confuse: marking an item, which has existed since UR-017, and browsing what was marked, which arrived with UR-067…069 (DR-113 … DR-120). Both go through the repository, not around it.

### Marking

Optimistic local write, then server sync:

    flowchart TB
        UI[FavoriteButton] -->|Click| Service[toggleFavorite]
        Service -->|"1. Optimistic"| LocalDB[("SQLite user_data<br/>is_favorite, pending_sync")]
        Service -->|"2. Sync"| Repo[Repository]
        Repo -->|POST / DELETE| JellyfinAPI["/Users/{id}/FavoriteItems/{itemId}"]
        Service -->|"3. Mark synced"| LocalDB
        Drain["spawn_favorites_drain<br/>(background task)"] -->|"pending_sync = 1"| Repo

1. The local row is updated immediately, so the heart fills without a round trip.
2. The repository is asked to mark or unmark on the server.
3. On success `pending_sync` is cleared; on failure the row stays pending.
4. A background drain (`spawn_favorites_drain`, started in `lib.rs` setup) retries pending rows, so a favourite marked offline still reaches the server (DR-120). This is the same pattern as the sync-queue drain — see Background workers.

### Browsing

`get_favorites(scope, options)` answers "what did this user favourite", across libraries, with the scope owned by Rust — the frontend sends a `SearchScope` variant and never names an item type. `HybridRepository` splits it the same way it splits every query:

Method | Used for
--- | ---
`get_favorites_cache_only` | The instant leg — the local `user_data` join
`get_favorites_server_only` | The reconciliation leg
`get_favorites` | Cache-first with server merge, per the repository's usual policy

`GetItemsOptions.favorites_only` is the other entry point: it filters an existing library listing rather than starting a cross-library query (DR-116), which is what a library page's favourites filter uses.

Server favourite state is mirrored into the local `user_data` table on catalog sync (DR-113/DR-114), so a favourite marked in another Jellyfin client shows up here — before this, `MediaItem.user_data` was left empty and no query anywhere asked for favourites.

Tauri commands:

Command | Description
--- | ---
`repository_get_favorites` | Cross-library favourites for a scope
`repository_mark_favorite` / `repository_unmark_favorite` | Toggle on the server, through the repository
`storage_toggle_favorite` | Local optimistic write (`is_favorite`, `pending_sync`)
`storage_mark_synced` | Clear `pending_sync` after a successful server write

Frontend surfaces (DR-117 … DR-119): the `/library/favorites` page with a scope selector, favourite rows on home (`favoriteMovies` / `favoriteShows` / `favoriteMusic` in `stores/home.ts`), a favourites tile per category in the library mosaic, and `FavoriteButton` mounted wherever a whole item is shown — movie, series, episode, album, artist and playlist detail views as well as the mini player.

## Player Backend Trait

Location: `src-tauri/src/player/backend.rs`

    #![allow(unused)]
    fn main() {
    pub trait PlayerBackend: Send + Sync {
        fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError>;
        fn play(&mut self) -> Result<(), PlayerError>;
        fn pause(&mut self) -> Result<(), PlayerError>;
        fn stop(&mut self) -> Result<(), PlayerError>;
        fn seek(&mut self, position: f64) -> Result<(), PlayerError>;
        fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError>;
        fn position(&self) -> f64;
        fn duration(&self) -> Option<f64>;
        fn state(&self) -> PlayerState;
        fn is_loaded(&self) -> bool;
        fn volume(&self) -> f32;
    }
    }

Implementations:

- `NullBackend` - Mock backend for testing
- `MpvBackend` - Linux playback via libmpv (see 05-platform-backends.md)
- `ExoPlayerBackend` - Android playback via ExoPlayer/Media3 (see 05-platform-backends.md)

## Player Controller

Location: `src-tauri/src/player/mod.rs`

The `PlayerController` orchestrates playback:

    #![allow(unused)]
    fn main() {
    pub struct PlayerController {
        backend: Arc<Mutex<Box<dyn PlayerBackend>>>,
        queue: Arc<Mutex<QueueManager>>,
        muted: bool,
        sleep_timer: Arc<Mutex<SleepTimerState>>,
        autoplay_settings: Arc<Mutex<AutoplaySettings>>,
        autoplay_episode_count: Arc<Mutex<u32>>,  // Session-based counter
        repository: Arc<Mutex<Option<Arc<dyn MediaRepository>>>>,
        event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
        // ... other fields
    }
    }

Key Methods:

- `play_item(item)`: Load and play single item (resets autoplay counter)
- `play_queue(items, start_index)`: Load queue and start playback (resets autoplay counter)
- `next()` / `previous()`: Queue navigation (resets autoplay counter)
- `toggle_shuffle()` / `cycle_repeat()`: Mode changes
- `set_sleep_timer(mode)` / `cancel_sleep_timer()`: Sleep timer control
- `on_playback_ended()`: Autoplay decision making (checks sleep timer, episode limit, queue)

## Playlist System

Location: `src-tauri/src/commands/playlist.rs`, `src-tauri/src/repository/`

TRACES: UR-014 | JA-019 | JA-020

The playlist system provides full CRUD operations for Jellyfin playlists with offline support through the cache-first repository pattern.

Types:

    #![allow(unused)]
    fn main() {
    /// A media item within a playlist, with its distinct playlist entry ID
    #[derive(Debug, Clone, Serialize, Deserialize)]
    #[serde(rename_all = "camelCase")]
    pub struct PlaylistEntry {
        /// Jellyfin's PlaylistItemId (distinct from the media item ID)
        pub playlist_item_id: String,
        #[serde(flatten)]
        pub item: MediaItem,
    }

    /// Result of creating a new playlist
    #[derive(Debug, Clone, Serialize, Deserialize)]
    #[serde(rename_all = "camelCase")]
    pub struct PlaylistCreatedResult {
        pub id: String,
    }
    }

Key Design Decision: `PlaylistEntry` wraps a `MediaItem` with a distinct `playlist_item_id`. This is critical because removing items from a playlist requires the playlist entry ID (not the media item ID), since the same track can appear multiple times.

MediaRepository Trait Methods:

    #![allow(unused)]
    fn main() {
    async fn create_playlist(&self, name: &str, item_ids: Option<Vec<String>>) -> Result<PlaylistCreatedResult, RepoError>;
    async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError>;
    async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError>;
    async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError>;
    async fn add_to_playlist(&self, playlist_id: &str, item_ids: Vec<String>) -> Result<(), RepoError>;
    async fn remove_from_playlist(&self, playlist_id: &str, entry_ids: Vec<String>) -> Result<(), RepoError>;
    async fn move_playlist_item(&self, playlist_id: &str, item_id: &str, new_index: u32) -> Result<(), RepoError>;
    }

Cache Strategy:

- Write operations (create, delete, rename, add, remove, move): Delegate directly to online repository
- Read operation (`get_playlist_items`): Uses cache-first parallel racing (100ms cache timeout, server fallback)
- Background cache update after server fetch via `save_playlist_items_to_cache()`

Playlist Tauri Commands:

Command | Parameters | Returns
--- | --- | ---
`playlist_create` | `handle, name, item_ids?` | `PlaylistCreatedResult`
`playlist_delete` | `handle, playlist_id` | `()`
`playlist_rename` | `handle, playlist_id, name` | `()`
`playlist_get_items` | `handle, playlist_id` | `Vec<PlaylistEntry>`
`playlist_add_items` | `handle, playlist_id, item_ids` | `()`
`playlist_remove_items` | `handle, playlist_id, entry_ids` | `()`
`playlist_move_item` | `handle, playlist_id, item_id, new_index` | `()`

## Tauri Commands (Player)

Location: `src-tauri/src/commands/player.rs`

Command | Parameters | Returns
--- | --- | ---
`player_play_item` | `PlayItemRequest` | `PlayerStatus`
`player_play_queue` | `items, start_index, shuffle` | `PlayerStatus`
`player_play` | - | `PlayerStatus`
`player_pause` | - | `PlayerStatus`
`player_toggle` | - | `PlayerStatus`
`player_stop` | - | `PlayerStatus`
`player_next` | - | `PlayerStatus`
`player_previous` | - | `PlayerStatus`
`player_seek` | `position: f64` | `PlayerStatus`
`player_set_volume` | `volume: f32` | `PlayerStatus`
`player_toggle_shuffle` | - | `QueueStatus`
`player_cycle_repeat` | - | `QueueStatus`
`player_get_status` | - | `PlayerStatus`
`player_get_queue` | - | `QueueStatus`
`player_get_session` | - | `MediaSessionType`
`player_dismiss_session` | - | `()`
`player_set_sleep_timer` | `mode: SleepTimerMode` | `()`
`player_cancel_sleep_timer` | - | `()`
`player_set_video_settings` | `settings: VideoSettings` | `VideoSettings`
`player_get_video_settings` | - | `VideoSettings`
`player_set_autoplay_settings` | `settings: AutoplaySettings` | `AutoplaySettings`
`player_get_autoplay_settings` | - | `AutoplaySettings`
`player_on_playback_ended` | - | `()`

## Domain Vocabulary Owned by Rust

The frontend is presentation-only and must not encode Jellyfin's taxonomy — the rule in CLAUDE.md and scoped-search-boundary.md. These are the places where that vocabulary actually lives.

### Search scope and the taxonomy boundary

Location: `src-tauri/src/repository/types.rs`

`SearchScope` is the canonical example the boundary rule is taught from. The frontend sends an opaque variant; Rust expands it into Jellyfin item types:

    #![allow(unused)]
    fn main() {
    pub enum SearchScope { All, Music, Movies, Tv }

    impl SearchScope {
        /// The Jellyfin item types this scope requests, or `None` for `All`.
        pub fn item_types(self) -> Option<Vec<String>> { … }

        /// The scope a library of this Jellyfin `CollectionType` belongs to.
        pub fn for_collection_type(collection_type: &str) -> Option<SearchScope> { … }
    }
    }

Two details that are load-bearing:

- `All` returns `None`, not the union of every listed type. An explicit `includeItemTypes` list filters out anything not named in it, so a union would silently drop People, folders, and any type nobody enumerated. Callers must omit the filter entirely on `None`.
- `for_collection_type` maps a Jellyfin `CollectionType` to a favourites category (DR-175). It changes when Jellyfin renames a collection type, not when the library page is redesigned — which is the test for whether something belongs on this side of the boundary.

⚠️ The result side has not moved yet. `GROUP_ITEM_TYPES` in `src/lib/utils/searchScope.ts` still maps result groups to item types in the frontend, and `check:boundary` does not match its shape. Tracked as Stage 2 of scoped-search-boundary-implementation.md.

### Library exclusions

Location: `src-tauri/src/repository/exclusions.rs` (TRACES: UR-076 | DR-209)

Folders the user has chosen to keep out of music browsing — a "Podcasts" folder inside a music library being the canonical case. Excluded by item id, not by name, in a process-wide `RwLock<Vec<String>>` restored from the database at startup, and applied by the repository layer to every music query (libraries, artists, albums, genres, search, home rows).

The id is normalised (`trim`, strip `-`, lowercase) because Jellyfin writes the same GUID both dashed and undashed depending on the endpoint. The predecessor was a frontend filter matching the English string "Podcasts" — wrong in three ways at once, and the reason this lives in the repository.

The set is process-wide rather than a field on a repository for the same reason as `online::STREAMING_QUALITY`: it is a preference about this user's browsing, not about a server session, so it must survive a repository being rebuilt on re-login.

### Streaming quality ladder

Location: `src-tauri/src/settings.rs` (TRACES: UR-074 | DR-162)

`StreamingQuality` is a bandwidth ladder (`Original`, 20/10/8/4/2/1 Mbps, 720 kbps), not a resolution picker: it exists to fit a connection, and the resolution cap is chosen from the bitrate so the encoder does not spend a small budget on pixels it cannot afford.

Method | Answers
--- | ---
`max_bitrate()` | Total bits/s (video + audio), `None` for `Original`
`audio_bitrate()` | The audio share — shrinks down the ladder, so 384 kbps is not a third of the budget at the bottom
`video_bitrate()` | Total minus audio, so the two together honour the ceiling
`max_height()` | Resolution ceiling that suits the bitrate

The ceiling goes to `PlaybackInfo` as `MaxStreamingBitrate` and into the device profile. Sending it there — not just on the transcode URL — is what makes the cap real: a stream the server decides to direct play is served at the source file's own bitrate, and no URL parameter afterwards can reduce it.

#### Two levels of ceiling

Location: `src-tauri/src/repository/online.rs` (TRACES: UR-074, UR-079 | DR-226)

There are two, and they are not the same thing:

| Set by | Lives until | Read via
--- | --- | --- | ---
Device default | Settings (`player_set_video_settings`) | Persisted; restored at startup | `streaming_quality()`
Per-playback override | The in-player picker (`player_set_stream_quality`) | The next item starts playing | `playback_quality_override()`

`effective_streaming_quality()` resolves the pair — override first, else default — and is the only thing stream construction may read. Every URL builder and the `PlaybackInfo` negotiation go through it, for the reason the process-wide static existed in the first place: if the negotiation and the URL builder disagree, the cap leaks — the negotiation authorises a direct play the builder then never gets to constrain, or the reverse.

> The override exists because a single global cannot express "this 4K remux needs a ceiling, that podcast does not". The picker had documented itself as a "this film, this connection" control since it was written, but was implemented by writing the default — so dropping one awkward film to 2 Mbps silently capped every video played afterwards for the rest of the process, with Settings still showing the old value. It is cleared on every `player_play_item` / `player_play_queue` / `player_play_tracks`, which is what stops it surviving into an autoplayed next episode where nobody would reopen the picker.

### Stream selection

Location: `src-tauri/src/repository/stream_selection.rs`, `OnlineRepository::get_stream_selection` (TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228)

Rust decides what stream. The player decides how to deliver it. That line is the whole design. A backend with genuine adaptive selection (ExoPlayer over a multi-variant playlist) is left to do it; Rust chooses what to request and never paces bytes.

`get_stream_selection` returns one self-describing `StreamSelection` in place of the bare URL `get_video_stream_url` used to hand out:

Field | Carries
--- | ---
`url` | What to open
`transport` | `Hls` / `Progressive` / `LocalFile` — how to fetch it
`playback_kind` | `DirectPlay` / `DirectStream` / `Transcode` — what the server is doing to the source
`rendition` | The negotiated ceiling and codecs; `None` for a direct play, which is the source
`available` | The quality ladder as it applies to this media source (DR-227)
`needs_transcoding` | Derived from `playback_kind`, so the rule is answered once

Both enums are serde-tagged (`{"type":"hls"}`) so the frontend matches a discriminant rather than comparing text.

> Why `transport` exists. `VideoPlayer.svelte` chose its loader with `url.includes(".m3u8")`, in two places. Rust built that URL and knows exactly what it is; re-deriving it downstream by substring match is a domain fact reconstructed in the presentation layer — the same class of error as leaking item-type taxonomy, and one that fails silently in both directions: a progressive file served from a path containing the substring gets an HLS loader, and a playlist served from a path without it does not.

> The paths that never negotiate get the same shape from Rust rather than letting a caller assemble one — `media_local_selection` for a downloaded file, `LiveStreamInfo.transport` for a live channel — so there is no second place where a transport is decided.

#### The playback-kind decision

`decide_playback_kind` is a free function and pure, so every branch is testable from `PlaybackInfo` fixtures without a server. Order matters — the two client-side overrides come first, because each describes a case where the server's answer is right about the file and wrong about what this app will do with it:

1. Undecodable audio → `Transcode`. Jellyfin 10.11.5 honours a DirectPlayProfile's container and video codec but ignores its audio codec, so it offers direct play for an E-AC-3 track the webview renders in silence. A silent direct play is worse than a transcode.
2. A pinned audio track → `Transcode`. Not a defect in the server's answer, a different question: the file has one default track and the viewer asked for another.
3. Otherwise `supports_direct_play` → `DirectPlay`, else `supports_direct_stream` → `DirectStream`, else `Transcode`.

A direct stream is a remux — codecs copied, container repackaged. It is cheap and is deliberately not counted as transcoding; conflating the two would report a free passthrough as a server-side re-encode.

> What this is worth, measured. Against the development server (Jellyfin 10.11.5), 400 items sampled for codec mix and 40 put through a real negotiation per profile:

> Profile | Direct play
> --- | ---
> Linux / WebKitGTK (`h264` only, 2ch) | 3/40 — 7%
> Android / ExoPlayer (`h264,hevc,vp8,vp9,av1,mpeg4` + `ac3,eac3`, 6ch) | 34/40 — 85%

> The library is ~80% hevc (`hevc+eac3` alone is a third of it), which is why the two diverge so hard.

> Read that 85% as a ceiling, not a result. It was measured with a profile containing `ac3,eac3`. The Android device this was later run on reports neither in its `MediaCodecList` — no Dolby licence, which is normal for a tablet — so eac3 content, about a third of the sampled library, correctly transcodes there. What any given device achieves depends on its own codec list, and on the profile being derived from the renderer at all (DR-234), which it was not when the figure was taken.

> The payoff is still overwhelmingly Android, because that is where a real decoder is already doing the work. Linux stays near 7% until libmpv decodes the picture — the h264-only profile is a WebKitGTK constraint, not a JellyTau choice, and is what `linux-native-video-spike.md` exists to remove. A reviewer should not expect this code to fix Linux on its own.

#### The quality ladder per source

`quality_options_for_source(source_bitrate)` returns every rung, each marked with `exceeds_source`: true when that rung's ceiling is at or above what the source itself carries, so selecting it produces the same bytes as `Original`. The frontend draws the list and drops the redundant rungs; it does not decide which they are.

- `Original` is never marked — it is the source.
- An unreported source bitrate (some containers have none; the sampled library has `avi` files with no bitrate at all) marks nothing redundant, keeping every rung offered. That is the safe direction: the viewer keeps every choice.

#### No adaptive ladder to preserve

TRACES: UR-079 | DR-229 (Won't Do)

Mid-playback re-negotiation on throughput was scoped and dropped on measurement. A master playlist from this server carries exactly one `EXT-X-STREAM-INF`: Jellyfin builds it from the single rendition the request asked for rather than publishing a ladder. So there is no adaptation for hls.js to be preserving and none that mpv would lose — the claim that there was is recorded in `playback-backend-unification.md` and does not hold. "Adapt mid-stream" collapses into "pick well at open", which is what the two levels of ceiling and the per-source ladder already are.

Kept here because it is a measurement, not an opinion: a server that does publish a ladder would change the answer, and the re-negotiation path below is the hook that work would build on.

#### Re-negotiation

One mechanism, not two. `player_seek_video`, `player_switch_audio_track` and `player_set_stream_quality` all return a tagged `strategy` saying who reloads — the backend handles a native backend itself and hands the webview a `StreamSelection` for `reloadSource`. Note the wire wart: tauri-specta keeps these response fields snake_case (`seek_offset`), while the `strategy` tag itself is camelCase.

The frontend names a variant and nothing else; the labels the picker shows are served over IPC — from `available` on the selection, or `player_get_streaming_qualities` for the Settings list.

## Background workers

Three long-lived tasks are spawned from the Tauri `setup` hook in `lib.rs`. All three exist because when something happens is a backend policy, not something a page load should decide.

Worker | Location | Responsibility
--- | --- | ---
`spawn_catalog_indexer` | `commands/catalog.rs` | Keeps the local FTS5 catalog fresh (DR-109, IR-030)
`spawn_favorites_drain` | `commands/favorites.rs` | Retries favourite toggles made while offline (DR-120)
`spawn_sync_queue_drain` | `commands/sync_drain.rs` | Drains the offline mutation queue (DR-131)

### Catalog indexer

Replaces the frontend's startup-only `syncCatalog()` call. It ticks on `CATALOG_INDEX_TICK` and runs a pass when three things hold: a repository exists, the server is reachable, and the index is due per `index_is_due`. A tick is nearly free — one indexed `app_settings` lookup — which is what makes it responsive to events it cannot subscribe to, such as signing in: a fresh install would otherwise sit unindexed until the next scheduled pass.

`index_is_due` treats both "never indexed" and an unparseable stored timestamp as due; a corrupt timestamp should trigger a re-index, not silently freeze the catalog. A failed pass is never fatal — it leaves the existing index in place and warns. Progress is emitted on `CATALOG_INDEX_EVENT` for the staleness hint in the UI.

# Svelte Frontend Architecture

## Store Structure

Location: `src/lib/stores/`

    flowchart TB
        subgraph Stores
            subgraph auth["auth.ts"]
                AuthState["AuthState<br/>- user<br/>- serverUrl<br/>- token<br/>- isLoading"]
            end
            subgraph playerStore["player.ts"]
                PlayerStoreState["PlayerState<br/>- kind<br/>- media<br/>- position<br/>- duration"]
            end
            subgraph queueStore["queue.ts"]
                QueueState["QueueState<br/>- items<br/>- index<br/>- shuffle<br/>- repeat"]
            end
            subgraph libraryStore["library.ts"]
                LibraryState["LibraryState<br/>- libraries<br/>- items<br/>- loading"]
            end
            subgraph Derived["Derived Stores"]
                DerivedList["isAuthenticated, currentUser<br/>isPlaying, isPaused, currentMedia<br/>hasNext, hasPrevious, isShuffle<br/>libraryItems, isLibraryLoading"]
            end
        end

## Music Library Architecture

Category-Based Navigation:

JellyTau's music library uses a category-based navigation system with a dedicated landing page that routes users to specialized views for different content types.

Route Structure:

    graph TD
        Music["/library/music<br/>(Landing page with category cards)"]
        Tracks["Tracks<br/>(List view only)"]
        Artists["Artists<br/>(Grid view)"]
        Albums["Albums<br/>(Grid view)"]
        Playlists["Playlists<br/>(Grid view)"]
        Genres["Genres<br/>(Genre browser)"]

        Music --> Tracks
        Music --> Artists
        Music --> Albums
        Music --> Playlists
        Music --> Genres

View Enforcement:

Ordinal content (where position carries meaning) is always a list. Everything else honours the user's persisted grid/list preference — see ux-flows.md §5A.2.

Content Type | View Mode | Toggle Visible | Component Used
--- | --- | --- | ---
Tracks | List (forced — ordinal) | No | `TrackList`
Artists | User preference | Yes | `LibraryGrid`
Albums | User preference | Yes | `LibraryGrid`
Playlists | User preference | Yes | `LibraryGrid`
Genres | User preference (both levels) | Yes | `LibraryGrid`
Album Detail Tracks | List (forced — ordinal) | No | `TrackList`
Season Episodes | List (forced — ordinal) | No | `SeasonSection`

TrackList Component:

The `TrackList` component (`src/lib/components/library/TrackList.svelte`) is a dedicated component for displaying songs in list format:

- No Thumbnails: Track numbers only (transform to play button on hover)
- Desktop Layout: Table with columns: #, Title, Artist, Album, Duration
- Mobile Layout: Compact rows with track number and metadata
- Configurable Columns: `showArtist` and `showAlbum` props control column visibility
- Click Behavior: Clicking a track plays it and queues all filtered tracks

Example Usage:

    <TrackList
      tracks={filteredTracks}
      loading={loading}
      showArtist={true}
      showAlbum={true}
    />

LibraryGrid view mode:

`LibraryGrid` reads the global `viewMode` store (persisted to `localStorage`) and renders `LibraryListView` or the card grid accordingly. The `showViewToggle` prop controls whether the toggle buttons appear in the page header; the grid itself always follows the stored preference.

A `forceGrid` prop previously existed to pin pages to grid regardless of preference. No caller ever passed it, so it was removed — pages that were documented as "forced grid" have in practice always honoured the toggle.

## Playback Reporting Service

Location: `src/lib/services/playbackReporting.ts`

The playback reporting service ensures playback progress is synced to both the Jellyfin server AND the local SQLite database. This dual-write approach enables:

- Offline "Continue Watching" functionality
- Sync queue for when network is unavailable
- Consistent progress across app restarts

    sequenceDiagram
        participant VideoPlayer
        participant PlaybackService as playbackReporting.ts
        participant LocalDB as Local SQLite<br/>(Tauri Commands)
        participant Jellyfin as Jellyfin Server

        VideoPlayer->>PlaybackService: reportPlaybackProgress(itemId, position)

        par Local Storage (always works)
            PlaybackService->>LocalDB: invoke("storage_update_playback_progress")
            LocalDB-->>PlaybackService: Ok (pending_sync = true)
        and Server Sync (if online)
            PlaybackService->>Jellyfin: POST /Sessions/Playing/Progress
            Jellyfin-->>PlaybackService: Ok
            PlaybackService->>LocalDB: invoke("storage_mark_synced")
        end

Service Functions:

- `reportPlaybackStart(itemId, positionSeconds)` - Called when playback begins
- `reportPlaybackProgress(itemId, positionSeconds, isPaused)` - Called periodically (every 10s)
- `reportPlaybackStopped(itemId, positionSeconds)` - Called when player closes or video ends

Tauri Commands:

Command | Description
--- | ---
`storage_update_playback_progress` | Update position in local DB (marks `pending_sync = true`)
`storage_mark_played` | Mark item as played, increment play count
`storage_get_playback_progress` | Get stored progress for an item
`storage_mark_synced` | Clear `pending_sync` flag after successful server sync

Database Schema Notes:

- The `user_data` table stores playback progress using Jellyfin IDs directly (as TEXT)
- Playback progress can be tracked even when the full item metadata hasn't been downloaded yet

Resume Playback Feature:

- When loading media for playback, the app checks local database for saved progress
- If progress exists (>30 seconds watched and <90% complete), shows resume dialog
- User can choose to "Resume" from saved position or "Start from Beginning"
- For video: Uses `startTimeSeconds` parameter in stream URL to begin transcoding from resume point
- For audio: Seeks to resume position after loading via MPV backend
- Implemented in `src/routes/player/[id]/+page.svelte`

## Repository Architecture (Rust-Based)

Location: `src-tauri/src/repository/`

    classDiagram
        class MediaRepository {
            <<trait>>
            +get_libraries()
            +get_items(parent_id, options)
            +get_item(item_id)
            +search(query, options)
            +get_latest_items(parent_id, limit)
            +get_resume_items(parent_id, limit)
            +get_next_up_episodes(series_id, limit)
            +get_genres(parent_id)
            +get_playback_info(item_id)
            +report_playback_start(item_id, position_ticks)
            +report_playback_progress(item_id, position_ticks, is_paused)
            +report_playback_stopped(item_id, position_ticks)
            +mark_favorite(item_id)
            +unmark_favorite(item_id)
            +get_person(person_id)
            +get_items_by_person(person_id, options)
            +get_image_url(item_id, image_type, options)
            +create_playlist(name, item_ids)
            +delete_playlist(playlist_id)
            +rename_playlist(playlist_id, name)
            +get_playlist_items(playlist_id)
            +add_to_playlist(playlist_id, item_ids)
            +remove_from_playlist(playlist_id, entry_ids)
            +move_playlist_item(playlist_id, item_id, new_index)
        }

        class OnlineRepository {
            -http_client: Arc~HttpClient~
            -server_url: String
            -user_id: String
            -access_token: String
            -connectivity: Option~Arc~ConnectivityMonitor~~
            +new()
            +with_connectivity()
            -report_outcome()
        }

        class OfflineRepository {
            -db_service: Arc~DatabaseService~
            -server_id: String
            -user_id: String
            +new()
            +cache_library()
            +cache_items()
            +cache_item()
        }

        class HybridRepository {
            -online: Arc~OnlineRepository~
            -offline: Arc~OfflineRepository~
            +new()
            -parallel_race()
            -cache_with_timeout()
        }

        MediaRepository <|.. OnlineRepository
        MediaRepository <|.. OfflineRepository
        MediaRepository <|.. HybridRepository

        HybridRepository --> OnlineRepository
        HybridRepository --> OfflineRepository

Key Implementation Details:

1. Cache-First Racing Strategy (`hybrid.rs`):

   - Runs cache (SQLite) and server (HTTP) queries in parallel
   - Cache has 100ms timeout
   - Returns cache result if it has meaningful content
   - Falls back to server result otherwise
   - Background cache updates planned
   - Connectivity feedback: `OnlineRepository` reports the outcome of every server request to the `ConnectivityMonitor` (classified via `RepoError`). This is the source of truth for the offline/online banner — see 07-connectivity.md. The frontend `connectivity` store is a pure reflection of the resulting events; `navigator.onLine` is only an advisory hint that triggers an immediate recheck.

2. Handle-Based Resource Management (`repository.rs` commands):

       #![allow(unused)]
       fn main() {
       // Frontend creates repository with UUID handle
       repository_create(server_url, user_id, access_token, server_id) -> String (UUID)

       // All operations use handle for identification
       repository_get_libraries(handle: String) -> Vec<Library>
       repository_get_items(handle: String, ...) -> SearchResult

       // Cleanup when done
       repository_destroy(handle: String)
       }

   - Enables multiple concurrent repository instances
   - Thread-safe with `Arc<Mutex<HashMap<String, Arc<HybridRepository>>>>`
   - No global state conflicts

3. Frontend API Layer (`src/lib/api/repository-client.ts`):

   - Thin TypeScript wrapper over Rust commands
   - Maintains handle throughout session
   - All methods: `invoke<T>("repository_operation", { handle, ...args })`
   - ~100 lines (down from 1061 lines)

## Playback Mode System

Location: `src-tauri/src/playback_mode/mod.rs`

The playback mode system manages transitions between local device playback and remote Jellyfin session control:

    #![allow(unused)]
    fn main() {
    pub enum PlaybackMode {
        Local,                     // Playing on local device
        Remote { session_id: String }, // Controlling remote session
        Idle,                      // Not playing
    }

    pub struct PlaybackModeManager {
        current_mode: PlaybackMode,
        player_controller: Arc<Mutex<PlayerController>>,
        jellyfin_client: Arc<JellyfinClient>,
    }
    }

Key Operations:

1. Transfer to Remote (`transfer_to_remote(session_id)`):

       sequenceDiagram
           participant UI
           participant Manager as PlaybackModeManager
           participant Player as PlayerController
           participant Jellyfin as Jellyfin API

           UI->>Manager: transfer_to_remote(session_id)
           Manager->>Player: Extract queue items
           Manager->>Manager: Get Jellyfin IDs from queue
           Manager->>Jellyfin: POST /Sessions/{id}/Playing
           Note over Jellyfin: Start playback with queue
           Manager->>Jellyfin: POST /Sessions/{id}/Playing/Seek
           Note over Jellyfin: Seek to current position
           Manager->>Player: Stop local playback
           Manager->>Manager: Set mode to Remote

2. Transfer to Local (`transfer_to_local(item_id, position_ticks)`):

   - Stops remote session playback
   - Prepares local player to resume
   - Sets mode to Local

Tauri Commands (`playback_mode.rs`):

- `playback_mode_get_current()` -> Returns current PlaybackMode
- `playback_mode_transfer_to_remote(session_id)` -> Async transfer
- `playback_mode_transfer_to_local(item_id, position_ticks)` -> Async transfer back
- `playback_mode_is_transferring()` -> Check transfer state
- `playback_mode_set(mode)` -> Direct mode setting

Frontend Store (`src/lib/stores/playbackMode.ts`):

- Thin wrapper calling Rust commands
- Maintains UI state (isTransferring, transferError)
- Listens to mode change events from Rust

## Database Service Abstraction

Location: `src-tauri/src/storage/db_service.rs`

Async database interface wrapping synchronous `rusqlite` to prevent blocking the Tokio runtime:

    #![allow(unused)]
    fn main() {
    #[async_trait]
    pub trait DatabaseService: Send + Sync {
        async fn execute(&self, query: Query) -> Result<usize, DatabaseError>;
        async fn execute_batch(&self, queries: Vec<Query>) -> Result<(), DatabaseError>;
        async fn query_one<T, F>(&self, query: Query, mapper: F) -> Result<T, DatabaseError>
            where F: FnOnce(&Row) -> Result<T> + Send + 'static;
        async fn query_optional<T, F>(&self, query: Query, mapper: F) -> Result<Option<T>, DatabaseError>
            where F: FnOnce(&Row) -> Result<T> + Send + 'static;
        async fn query_many<T, F>(&self, query: Query, mapper: F) -> Result<Vec<T>, DatabaseError>
            where F: Fn(&Row) -> Result<T> + Send + 'static;
        async fn transaction<F, T>(&self, f: F) -> Result<T, DatabaseError>
            where F: FnOnce(Transaction) -> Result<T> + Send + 'static;
    }

    pub struct RusqliteService {
        connection: Arc<Mutex<Connection>>,
    }

    impl DatabaseService for RusqliteService {
        async fn execute(&self, query: Query) -> Result<usize, DatabaseError> {
            let conn = self.connection.clone();
            tokio::task::spawn_blocking(move || {
                // Execute query on blocking thread pool
            }).await?
        }
        // ... other methods use spawn_blocking
    }
    }

Key Benefits:

- No Freezing: All blocking DB ops run in thread pool via `spawn_blocking`
- Type Safety: `QueryParam` enum prevents SQL injection
- Future Proof: Easy to swap to native async DB (tokio-rusqlite)
- Testable: Can mock DatabaseService for tests

Usage Pattern:

    #![allow(unused)]
    fn main() {
    // Before (blocking - causes UI freeze)
    let conn = database.connection();
    let conn = conn.lock().unwrap();  // BLOCKS
    conn.query_row(...)  // BLOCKS

    // After (async - no freezing)
    let db_service = database.service();
    let query = Query::with_params("SELECT ...", vec![...]);
    db_service.query_one(query, |row| {...}).await  // spawn_blocking internally
    }

## Component Hierarchy

    graph TD
        subgraph Routes["Routes (src/routes/)"]
            LoginPage["Login Page"]
            LibLayout["Library Layout"]
            LibDetail["Album/Series Detail"]
            MusicCategory["Music Category Landing"]
            Tracks["Tracks"]
            Artists["Artists"]
            Albums["Albums"]
            Playlists["Playlists"]
            Genres["Genres"]
            Downloads["Downloads Page"]
            Settings["Settings Page"]
            PlayerPage["Player Page"]
        end

        subgraph PlayerComps["Player Components"]
            AudioPlayer["AudioPlayer"]
            VideoPlayer["VideoPlayer"]
            MiniPlayer["MiniPlayer"]
            Controls["Controls"]
            Queue["Queue"]
            SleepTimerModal["SleepTimerModal"]
            SleepTimerIndicator["SleepTimerIndicator"]
        end

        subgraph SessionComps["Sessions Components"]
            CastButton["CastButton"]
            SessionModal["SessionPickerModal"]
            SessionCard["SessionCard"]
            SessionsList["SessionsList"]
            RemoteControls["RemoteControls"]
        end

        subgraph LibraryComps["Library Components"]
            LibGrid["LibraryGrid"]
            LibListView["LibraryListView"]
            TrackList["TrackList"]
            PlaylistDetail["PlaylistDetailView"]
            DownloadBtn["DownloadButton"]
            MediaCard["MediaCard"]
        end

        subgraph PlaylistComps["Playlist Components"]
            CreatePlaylistModal["CreatePlaylistModal"]
            AddToPlaylistModal["AddToPlaylistModal"]
        end

        subgraph CommonComps["Common Components"]
            ScrollPicker["ScrollPicker"]
        end

        subgraph OtherComps["Other Components"]
            Search["Search"]
            FavoriteBtn["FavoriteButton"]
            DownloadItem["DownloadItem"]
        end

        LibLayout --> PlayerComps
        LibLayout --> LibDetail
        MusicCategory --> Tracks
        MusicCategory --> Artists
        MusicCategory --> Albums
        MusicCategory --> Playlists
        MusicCategory --> Genres
        LibDetail --> LibraryComps
        Playlists --> PlaylistComps
        Playlists --> PlaylistDetail
        Downloads --> DownloadItem
        PlayerPage --> PlayerComps

        MiniPlayer --> CastButton
        CastButton --> SessionModal
        SleepTimerModal --> ScrollPicker
        PlayerComps --> LibraryComps

## MiniPlayer Behavior

Location: `src/lib/components/player/MiniPlayer.svelte`

The MiniPlayer is a persistent bottom bar for audio playback that supports touch gestures and playback controls.

Touch Gesture Handling:

The MiniPlayer uses touch events to distinguish between taps (on controls) and swipe-up gestures (to expand to full player page):

    function handleTouchStart(e: TouchEvent) {
      touchStartX = e.touches[0].clientX;
      touchStartY = e.touches[0].clientY;
      touchEndX = touchStartX;   // Initialize to start position
      touchEndY = touchStartY;   // Prevents taps being treated as swipes
      isSwiping = true;
    }

Key Design Decision: `touchEndX`/`touchEndY` must be initialized to the start position in `handleTouchStart`. Without this, a pure tap (no `touchmove` event fired) would compute the swipe distance against (0,0), making every tap look like a massive swipe-up and inadvertently navigating to the player page.

Skip Button State:

The MiniPlayer's next/previous buttons are enabled based on `appState.hasNext`/`hasPrevious`, which are updated by `playerEvents.ts` calling `invoke("player_get_queue")` on every `StateChanged` event from the backend.

## Sleep Timer Architecture

Location: `src-tauri/src/player/sleep_timer.rs`, `src-tauri/src/player/mod.rs`

TRACES: UR-026 | DR-029

The sleep timer supports three modes for stopping playback:

    #![allow(unused)]
    fn main() {
    #[serde(tag = "kind", rename_all = "camelCase")]
    pub enum SleepTimerMode {
        Off,
        Time { end_time: i64 },      // Unix timestamp in milliseconds
        EndOfTrack,                    // Stop after current track/episode
        Episodes { remaining: u32 },   // Stop after N more episodes
    }
    }

Timer Modes:

Mode | Trigger | How It Stops
--- | --- | ---
Time | User selects 15/30/45/60 min via roller UI | Background timer thread stops backend when `remaining_seconds == 0`; also checked at track boundaries in `on_playback_ended()`
EndOfTrack | User clicks "End of current track" | Checked in `on_playback_ended()`, returns `AutoplayDecision::Stop`
Episodes | User selects 1-10 episodes | `decrement_episode()` in `on_playback_ended()`, stops when counter reaches 0

Time-Based Timer Flow:

    sequenceDiagram
        participant UI as SleepTimerModal
        participant Store as sleepTimer store
        participant Rust as PlayerController
        participant Thread as Timer Thread
        participant Backend as PlayerBackend

        UI->>Store: setTimeTimer(30)
        Store->>Rust: invoke("player_set_sleep_timer", {mode})
        Rust->>Rust: Set SleepTimerMode::Time { end_time }
        Rust->>UI: Emit SleepTimerChanged event

        loop Every 1 second
            Thread->>Thread: update_remaining_seconds()
            Thread->>UI: Emit SleepTimerChanged (countdown)
            alt remaining_seconds == 0
                Thread->>Backend: stop()
                Thread->>UI: Emit SleepTimerChanged (Off)
            end
        end

Frontend Components:

- ScrollPicker (`src/lib/components/common/ScrollPicker.svelte`): Reusable scroll-wheel picker using CSS `scroll-snap-type: y mandatory`. Configurable items, visible count, and item height. Used by SleepTimerModal for time selection.
- SleepTimerModal (`src/lib/components/player/SleepTimerModal.svelte`): Modal with three sections - time picker (roller), end of track button, episode counter. Time section uses ScrollPicker with 15/30/45/60 min options. Accepts optional `mediaType` prop to override queue-based detection (used by VideoPlayer since video playback clears the audio queue).
- SleepTimerIndicator (`src/lib/components/player/SleepTimerIndicator.svelte`): Compact indicator showing active timer status with countdown.
- Sleep buttons: Clock icon buttons on AudioPlayer header, Controls bar, MiniPlayer, and VideoPlayer control bar. Shows clock icon when inactive, SleepTimerIndicator when active.

Key Design Decisions:

1. All logic in Rust: Frontend only displays state and invokes commands
2. Background timer thread: Handles time-based countdown independently of track boundaries
3. Dual stop mechanism for Time mode: Timer thread stops mid-track; `on_playback_ended()` catches edge case at track boundary
4. Event-driven UI updates: Timer thread emits `SleepTimerChanged` every second for countdown display

## Auto-Play Episode Limit

> ⚠️ Autoplay is season-bounded. `player/mod.rs:fetch_next_episode_for_item` does not cross a season boundary, so autoplay stops at the end of a season even though the "More Episodes" strip runs past it. Fixing it should reuse `repository_get_series_episodes`, but it touches the playback state machine and the Android JNI advance path (see the `AutoplayDecision` deadlock note in CLAUDE.md) — its own change, not a drive-by.

Location: `src-tauri/src/player/mod.rs`, `src-tauri/src/player/autoplay.rs`, `src-tauri/src/settings.rs`

TRACES: UR-023 | DR-049

Limits how many episodes auto-play consecutively before requiring manual intervention.

Settings:

    #![allow(unused)]
    fn main() {
    // In AutoplaySettings (runtime, in PlayerController)
    pub struct AutoplaySettings {
        pub enabled: bool,
        pub countdown_seconds: u32,
        pub max_episodes: u32,  // 0 = unlimited
    }

    // In VideoSettings (persisted, settings page)
    pub struct VideoSettings {
        pub auto_play_next_episode: bool,
        pub auto_play_countdown_seconds: u32,
        pub auto_play_max_episodes: u32,  // 0 = unlimited
    }
    }

Session-Based Counter:

The `autoplay_episode_count` field in `PlayerController` tracks consecutive auto-played episodes:

- Incremented: In `on_playback_ended()` when auto-playing next episode
- Reset: On any manual user action (`play_item()`, `play_queue()`, `next()`, `previous()`)
- Limit check: When `max_episodes > 0` and `count >= max_episodes`, the popup shows with `auto_advance: false` - user must manually click "Play Now" to continue

    flowchart TB
        PlaybackEnded["on_playback_ended()"] --> CheckEpisode{"Is video<br/>episode?"}
        CheckEpisode -->|"No"| AudioFlow["Audio queue logic"]
        CheckEpisode -->|"Yes"| FetchNext["Fetch next episode"]
        FetchNext --> IncrementCount["increment_autoplay_count()"]
        IncrementCount --> CheckLimit{"max_episodes > 0<br/>AND count >= max?"}
        CheckLimit -->|"No"| ShowPopup["ShowNextEpisodePopup<br/>auto_advance: true"]
        CheckLimit -->|"Yes"| ShowPopupManual["ShowNextEpisodePopup<br/>auto_advance: false"]
        ShowPopupManual --> UserClick["User clicks 'Play Now'"]
        UserClick --> PlayItem["play_item() -> resets counter"]

Settings Sync:

`VideoSettings` (settings page) and `AutoplaySettings` (PlayerController runtime) are synced via `player_set_video_settings`, which updates both the `VideoSettingsWrapper` state and calls `controller.set_autoplay_settings()`.

Database: Migration 016 adds `autoplay_max_episodes INTEGER DEFAULT 0` to `user_player_settings`.

Settings UI: Button grid with options: Unlimited, 1, 2, 3, 5, 10 episodes. Visible only when auto-play is enabled.

## Player Page Navigation Guard

Location: `src/routes/player/[id]/+page.svelte`

When the user navigates to the full player page (e.g., by swiping up on MiniPlayer), the `loadAndPlay` function checks whether the track is already playing before initiating new playback:

    const alreadyPlayingMedia = get(storeCurrentMedia);
    if (alreadyPlayingMedia?.id === id && !startPosition) {
      // Track already playing - show UI without restarting playback
      // Fetch queue status for hasNext/hasPrevious
      return;
    }

Why This Matters: Without this guard, navigating to the player page would restart playback with a single-track queue, destroying the existing album/playlist queue that the backend is playing. The Rust backend maintains the full queue (visible on the Android lock screen), but the frontend `loadAndPlay` function would overwrite it by calling `player_play_tracks` with just the current track.

## Playlist Management UI

TRACES: UR-014 | JA-019 | JA-020

Location: `src/lib/components/playlist/`, `src/lib/components/library/PlaylistDetailView.svelte`

The playlist UI provides full CRUD operations for Jellyfin playlists with offline sync support.

Components:

- CreatePlaylistModal (`src/lib/components/playlist/CreatePlaylistModal.svelte`):

  - Modal for creating new playlists with a name input
  - Accepts optional `initialItemIds` to pre-populate with tracks
  - Keyboard support: Enter to create, Escape to close
  - Navigates to new playlist detail page on creation

- AddToPlaylistModal (`src/lib/components/playlist/AddToPlaylistModal.svelte`):

  - Modal listing all existing playlists to add tracks to
  - "New Playlist" button for inline creation flow
  - Shows playlist artwork via CachedImage
  - Loading state with skeleton placeholders

- PlaylistDetailView (`src/lib/components/library/PlaylistDetailView.svelte`):

  - Full playlist detail page with artwork, name, track count, total duration
  - Click-to-rename with inline editing
  - Play all / shuffle play buttons
  - Delete with confirmation dialog
  - Per-track removal buttons
  - Uses `TrackList` component for track display
  - Passes `{ type: "playlist", playlistId, playlistName }` context to player

- Playlists Page (`src/routes/library/music/playlists/+page.svelte`):

  - Grid view using `GenericMediaListPage`
  - Floating action button (FAB) to create new playlists
  - Search by playlist name

Frontend API Methods (`src/lib/api/repository-client.ts`):

- `createPlaylist(name, itemIds?)` -> `PlaylistCreatedResult`
- `deletePlaylist(playlistId)`
- `renamePlaylist(playlistId, name)`
- `getPlaylistItems(playlistId)` -> `PlaylistEntry[]`
- `addToPlaylist(playlistId, itemIds)`
- `removeFromPlaylist(playlistId, entryIds)`
- `movePlaylistItem(playlistId, itemId, newIndex)`

Offline Sync (`src/lib/services/syncService.ts`): All playlist mutations are queued for offline sync:

- `queuePlaylistCreate`, `queuePlaylistDelete`, `queuePlaylistRename`
- `queuePlaylistAddItems`, `queuePlaylistRemoveItems`, `queuePlaylistReorderItem`

## App Shell and Chrome

Location: `src/lib/utils/layoutShell.ts` (pure rules), `src/lib/components/AppHeader.svelte`, `src/lib/components/account/AccountMenu.svelte`, `BottomUi.svelte` TRACES: UR-054 | DR-075, DR-076, DR-077

Account actions used to be reachable only from `/library/*` — the header that hosted them belonged to the library layout, the bottom nav offered Home / Search / Library, and the desktop username was inert text. From `/`, `/search` or `/downloads` there was no route to Settings or Sign out at all. The header is now shared and rendered from the root layout.

### Visibility rules

All four rules are pure functions in `layoutShell.ts`, so the contract is unit-testable rather than a scattering of `$derived` booleans that drift per route and platform (which is what they were):

Function | Rule
--- | ---
`showBottomNav` | Every authenticated route except `/player/*` and `/login`
`showGlobalMiniPlayer` | Everything except `/player/*`, `/login`, `/settings`. Not gated on platform or `/library` — the root owns the mini player everywhere, so the library route must never render a second one
`routeOwnsLayout` | `/library`, `/player/`, `/login` render their own full-height flex column; everything else renders into the root scroller
`showGlobalHeader` | Authenticated, not a layout-owning route, not `/settings` (the user is already there)

### The structural fix worth not undoing

The "last row hidden behind the nav" bug is solved structurally, not by measurement: the bottom UI is an in-flow flex child below the scroller (`BottomUi.svelte`), so the scroller is physically bounded above it and cannot render behind it. There is no measurement and no reserved padding. If you restructure the shell, preserve the scroll containment — reintroducing padding math reintroduces the bug.

### AccountMenu

One component for both breakpoints, anchored to the username/avatar (a real button with `aria-expanded`, not a bare three-dot icon). Fixed item order: identity block (user + server) → Downloads, Settings, Display → divider → Sign out, destructive and last. Dismissal is backdrop click, `Escape`, and focus return to the trigger.

The identity block falls back to the bare host of the server URL when the server has no human-readable name, so it always shows something server-identifying.

Settings' Display section and the library page-header toggle are two views onto the same persisted `viewMode` store (`jellytau-view-mode`) — no second state, no migration, and they stay in sync for free.

## Library Mosaic

Location: `src/lib/components/library/libraryMosaic.ts` (pure), `MosaicGrid.svelte`, `MosaicTile.svelte` TRACES: UR-075, UR-067 | DR-174, DR-175

The library overview and the home "Your Libraries" strip are a mosaic, not a grid: rows share one height and each tile is as wide as its own artwork is, so a square music cover, a 16:9 library backdrop and a 2:3 poster sit in the same row at their own proportions instead of all three being cropped into whichever box a grid picked.

`libraryMosaic.ts` is deliberately pure — it takes the libraries and returns the tiles to draw, so ordering and de-duplication are unit-testable rather than buried in markup. Tiles start at an assumed aspect (square, 16:9) and a measured image overrides it in `MosaicGrid`.

Note what this file does not decide: which favourites category a library belongs to. That is Jellyfin vocabulary and arrives on the library itself as `favoritesScope`, from `SearchScope::for_collection_type` in Rust (see 01-rust-backend.md). The frontend only decides what to call it and where to put it.

## Series and Episode Navigation

Location: `src/lib/components/library/` — `SeasonSection.svelte`, `EpisodeFocusView.svelte`, `episodeStrip.ts` (pure) TRACES: UR-062 … UR-064 | DR-101 … DR-107

Opening a series lands the viewer where they actually are in it. "Where is this viewer in this series" is resolved in Rust (DR-101), not by the page: the series detail page asks the repository and anchors on the answer — the current season expanded, the current episode highlighted and scrolled into view, and a hero button labelled `Resume S2E4` / `Play S1E1`.

A season is not a destination: `/library/<seasonId>` redirects to its series (DR-103). Video library routes collapse to one per library (DR-105).

`episodeStrip.ts` holds the pure logic for the "More Episodes" strip, extracted from the component because it had three distinct bugs that markup made untestable: the strip collapsing to just the current episode while real siblings existed, number-less episodes all matching as "current" (`undefined === undefined`), and the window dead-ending at a season boundary instead of running past it. It matches by id first and only falls back to season+episode number when both numbers are known on both sides.

## Downloaded Browse

Location: `src/lib/services/downloadedCatalog.ts`, `src/lib/components/downloads/DownloadedBrowse.svelte` TRACES: UR-055, UR-056 | DR-081 … DR-085

`/downloads` is two views: Downloaded (the default) — the library filtered to what is on the device, reusing the same grids, cards and detail pages as online browsing — and Transfers, the in-flight progress rows demoted to a secondary tab.

`downloadedCatalog` reads the offline-only browse path on the repository, never the hybrid merge. That is the point: an empty result means "nothing downloaded here", never "server unreachable", so the view is authoritative regardless of connectivity. It also owns disk usage — a per-item/container byte map plus the device total, aggregated by the backend from `downloads.file_size` (DR-085).

## Safe-area Insets

Location: `src/app.css`, `WindowInsetsBridge.kt` TRACES: UR-066 | DR-112, IR-031

The Android WebView does not reliably report system-bar insets through `env(safe-area-inset-*)`. Native `WindowInsets` (`systemBars() | displayCutout()`) are therefore pushed in as CSS custom properties, and every edge takes the larger of the two sources:

    --safe-top: max(env(safe-area-inset-top, 0px), var(--jt-inset-top, 0px));

Two rules keep this from going wrong: one owner per edge (two components both padding the top edge double-pads it), and no nested `h-screen` — a full-height child inside a full-height parent that has already consumed the inset overflows by exactly the inset.

Unlike `addJavascriptInterface`, the inset push only writes CSS properties, so it can safely be re-sent on resume.

## Stream Transport

Location: `src/lib/player/streamTransport.ts` TRACES: UR-079 | DR-225 | UT-214

`videoLoaderFor(selection, capabilities)` picks the loader for the webview `<video>` element — `hlsjs`, `nativeHls`, or `direct` — from the backend's tagged `selection.transport`. `elementSrcFor` is its template companion: the element's `src` is emptied only when hls.js is driving it.

The split is the point. The transport is the stream's property and comes from Rust; whether a given loader exists is the browser's, and is the only thing decided here.

> This replaced `currentStreamUrl.includes(".m3u8")`, which appeared twice in `VideoPlayer.svelte` — once in the HLS `$effect` and once inline in the template's `src`. Rust builds that URL and knows what it is; re-deriving it here by substring match was a domain fact reconstructed in the presentation layer, and it fails silently in both directions. The two tests that pin it are the ones that failed against the old implementation: a `progressive` stream whose URL contains `.m3u8` must not get an HLS loader, and an `hls` stream whose URL contains no `.m3u8` must.

> Logic lives in a plain `.ts` module rather than in the component for the usual reason — it is testable there. Same pattern as `episodeStrip.ts`.

`VideoPlayer` holds a `currentSelection`, not a URL string; `currentStreamUrl` is derived from it. A reload replaces the selection wholesale (the adapter's bridge takes a `StreamSelection`, not a URL), so transport and URL can never drift apart. The background-audio handoff states the transport it is moving to — progressive mp3 out, HLS back — via `selectionAt()`, rather than leaving it to be inferred.

The quality picker is filled from `selection.available` (DR-227): rungs the backend marked `exceedsSource` are not drawn, because they produce the same bytes as `Original`. Nothing is optimistically assigned when the viewer picks a rung — what the menu shows comes from the selection the backend hands back, since a ceiling above the source bitrate is the source.

## Native Video Store

Location: `src/lib/stores/nativeVideo.ts` TRACES: UR-003, UR-004 | DR-188

Two separate concerns live here, deliberately:

- `experimentalNativeVideo` — the user-facing opt-in flag, defaulting to on. Rust already decides which backend this platform has (`useHtml5Element` from `player_play_item`); this flag only suppresses that decision. It never turns native on where Rust says HTML5. An explicit stored choice wins in both directions, so someone who opted out is not re-enabled by a default flip — hence the `null` check rather than a bare `=== "true"`.
- `nativeVideoActive` — whether a native surface is on screen right now. Setting it toggles `data-native-video` on `<html>`, which is what the CSS in `app.css` keys off to clear the app's opaque backgrounds. It is deliberately not derived from the flag: the backgrounds must come back the moment the player unmounts.

See 05-platform-backends.md for what is behind the WebView.

## Logging

Location: `src/lib/utils/logger.ts` TRACES: DR-204

The frontend's equivalent of the Rust `log` crate: four levels (`debug < info < warn < error`), a compile-environment default (dev → `debug`, production → `warn`), and a runtime override that is the moral equivalent of `RUST_LOG`. Scoped loggers carry the subsystem in the message, so a filtered console stays usable while a player, a download worker and a store are all talking.

Production deliberately keeps warn and error: this is a client talking to a server that may or may not be there, and a silent failure is worse to support than a noisy console. Only the chatter is suppressed.

`no-console` is an ESLint error, with the sink module itself the only exception, so a raw `console.*` cannot re-appear.

# Data Flow

## Repository Query Flow (Cache-First)

    sequenceDiagram
        participant UI as Svelte Component
        participant Client as RepositoryClient (TS)
        participant Rust as Tauri Command
        participant Hybrid as HybridRepository
        participant Cache as OfflineRepository (SQLite)
        participant Server as OnlineRepository (HTTP)
        participant Conn as ConnectivityMonitor

        UI->>Client: getItems(parentId)
        Client->>Rust: invoke("repository_get_items", {handle, parentId})
        Rust->>Hybrid: get_items()

        par Parallel Racing
            Hybrid->>Cache: get_items() with 100ms timeout
            Hybrid->>Server: get_items() (no timeout)
        end

        Note over Server,Conn: Every server request reports its outcome
        alt Server succeeds (or answers with 4xx/5xx)
            Server->>Conn: mark_reachable() (server is up)
        else Network failure / timeout
            Server->>Conn: mark_unreachable() (debounced)
        end

        alt Cache returns with content
            Cache-->>Hybrid: Result with items
            Hybrid-->>Rust: Return cache result
        else Cache timeout or empty
            Server-->>Hybrid: Fresh result
            Hybrid-->>Rust: Return server result
        end

        Rust-->>Client: SearchResult
        Client-->>UI: items[]
        Note over UI: Reactive update

Key Points:

- Cache queries have 100ms timeout for responsiveness
- Server queries always run for fresh data
- Cache wins if it has meaningful content
- Automatic fallback to server if cache is empty/stale
- Background cache updates (planned)
- Connectivity side-effect: each server request feeds the `ConnectivityMonitor`, which is the source of truth for the offline/online banner (see 07-connectivity.md). A server-answered error (401/404/5xx) still counts as reachable — only network failures, sustained past a debounce window, flip the app to offline.

### Listing order is decided in Rust

TRACES: UR-007 | DR-257

A browse call names the container (`GetItemsOptions.parentKind`, the neutral `MediaKind` the caller already holds) and not a sort field. `default_listing_sort` in `repository/types.rs` turns that kind into the order:

Container kind | Order
--- | ---
`channelFolder` — one podcast inside a plugin channel | `PremiereDate` descending
any other container | `SortName` ascending
none given | no `SortBy` — the server's own order stands

Both legs of the race apply it, so the cached list does not flash in name order before the server's arrives. An explicit `sortBy` from the caller always wins; the default only fills the gap.

This is a domain rule, not a display preference, which is why it is not in the frontend: the store that asks for a podcast's episodes has no business knowing that podcasts are read newest-first. `MediaKind::ChannelFolder` exists for the same reason — Jellyfin gives a channel container and an ordinary folder the same item type (`ChannelFolderItem`), and while both mapped to `Folder` there was nothing to key the rule on. The defect this prevents: every Jellypod podcast listed alphabetically, which discarded the release order and clumped every `[Played] …` episode at the top of the list.

## Search Flow (Locally Indexed)

TRACES: UR-065 | DR-108 … DR-111, IR-030

Search does not depend on a per-keystroke round trip to Jellyfin. The instant leg reads the local SQLite catalog, which is already synced and already FTS5-indexed, so results appear as fast as SQLite can answer — online or offline. The server query stays, demoted to a background reconciliation that merges in late results.

    sequenceDiagram
        participant UI as Search UI
        participant Rust as repository_search
        participant Cache as Local catalog (FTS5)
        participant Server as Jellyfin
        participant Indexer as spawn_catalog_indexer

        UI->>Rust: search(query, scope)
        Rust->>Cache: FTS5 query, scope expanded by SearchScope::item_types()
        Cache-->>UI: instant results
        Rust->>Server: reconciliation query (background)
        Server-->>UI: search-event with late/merged results
        Note over Indexer,Cache: Independent of any query:<br/>scheduled crawl keeps the index fresh,<br/>prunes items deleted on the server

Key points:

- The scope is opaque on the wire. The frontend sends a `SearchScope` variant; Rust expands it to item types (01-rust-backend.md).
- Index freshness is a Rust policy, not a frontend startup call — a scheduled background pass, not "whatever was synced when the app last launched" (DR-109). See Background workers.
- Index hygiene matters as much as freshness: the catalog save path uses `INSERT OR REPLACE` and the crawl prunes rows for content deleted on the server, or search keeps returning items that no longer exist (DR-110).
- The index covers exactly the types the result groups render (DR-111) — including Artists, which the crawl must reach or the Artists group is silently always empty.

Deliberately not done, with reasons:

- Incremental indexing (Jellyfin's `MinDateLastSaved`). A full crawl is what makes the deletion sweep sound — it yields the authoritative id set per library, and an incremental pass cannot detect deletions. Worth revisiting if full crawls prove slow on large libraries; measure first.
- Removing the server leg. The reconciliation query stays.

> ⚠️ Two dead search implementations still exist: `storage_search_items` (`commands/storage/mod.rs`) and `offline_search` (`commands/offline.rs`). Both are registered in `lib.rs` and exported to `bindings.ts`; neither is called from the frontend. Deleting them is correct and unclaimed.

## Playback Initiation Flow

    sequenceDiagram
        participant User
        participant AudioPlayer
        participant Tauri as Tauri IPC
        participant Command as player_play_item()
        participant Controller as PlayerController
        participant Backend as PlayerBackend
        participant Store as Frontend Store

        User->>AudioPlayer: clicks play
        AudioPlayer->>Tauri: invoke("player_play_item", {item})
        Tauri->>Command: player_play_item()
        Command->>Command: Convert PlayItemRequest -> MediaItem
        Command->>Controller: play_item(item)
        Controller->>Backend: load(item)
        Note over Backend: State -> Loading
        Controller->>Backend: play()
        Note over Backend: State -> Playing
        Controller-->>Command: Ok(())
        Command-->>Tauri: PlayerStatus {state, position, duration, volume}
        Tauri-->>AudioPlayer: status
        AudioPlayer->>Store: player.setPlaying(media, position, duration)
        Note over Store: UI updates reactively

## Video Stream Selection Flow

TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228

Before a video plays, Rust decides what stream — direct play, remux or transcode, over which transport — and hands the player one self-describing `StreamSelection`. The page no longer inspects the URL to work any of this out.

    sequenceDiagram
        participant Page as player/[id]/+page.svelte
        participant Repo as HybridRepository
        participant Online as OnlineRepository
        participant Server as Jellyfin
        participant VP as VideoPlayer.svelte

        Page->>Repo: playerLocalMediaPath(id)
        alt a completed download exists
            Page->>Repo: mediaLocalSelection(path)
            Note over Page: LocalFile / DirectPlay, no ladder —<br/>nothing about a file on disk re-negotiates
        else stream from the server
            Page->>Repo: getStreamSelection(id, mediaSourceId)
            Repo->>Online: get_stream_selection()
            Online->>Online: effective_streaming_quality()
            Note over Online: per-playback override, else device default
            Online->>Server: POST /Items/{id}/PlaybackInfo<br/>(device profile + ceiling)
            Server-->>Online: MediaSource {supportsDirectPlay,<br/>supportsDirectStream, transcodingUrl, bitrate}
            Online->>Online: decide_playback_kind()
            alt Transcode
                Online->>Online: adopt/stop prior play session,<br/>build HLS URL
                Note over Online: Transport::Hls
            else DirectPlay / DirectStream
                Online->>Online: /Videos/{id}/stream?static=true
                Note over Online: Transport::Progressive,<br/>rendition = None (it IS the source)
            end
            Online->>Online: quality_options_for_source(bitrate)
            Online-->>Page: StreamSelection
        end
        Page->>VP: selection
        VP->>VP: videoLoaderFor(selection, caps)
        Note over VP: hls.js / native HLS / direct —<br/>from the tag, never from the URL

The selection travels with the stream from then on. A reload — a quality change, an audio-track switch, a transcoded seek — returns a new selection through the same tagged `strategy` response, so transport and URL can never disagree; and the queue item carries the transport so `player_seek_video` picks its seek strategy from the backend's decision rather than from the URL string.

## Playback Mode Transfer Flow

    sequenceDiagram
        participant UI as Cast Button
        participant Store as playbackMode store
        participant Rust as Tauri Command
        participant Manager as PlaybackModeManager
        participant Player as PlayerController
        participant Jellyfin as Jellyfin API

        UI->>Store: transferToRemote(sessionId)
        Store->>Rust: invoke("playback_mode_transfer_to_remote", {sessionId})
        Rust->>Manager: transfer_to_remote()

        Manager->>Player: Get current queue
        Player-->>Manager: Vec<MediaItem>
        Manager->>Manager: Extract Jellyfin IDs

        Manager->>Jellyfin: POST /Sessions/{id}/Playing<br/>{itemIds, startIndex}
        Jellyfin-->>Manager: 200 OK

        Manager->>Jellyfin: POST /Sessions/{id}/Playing/Seek<br/>{positionTicks}
        Jellyfin-->>Manager: 200 OK

        Manager->>Player: stop()
        Manager->>Manager: mode = Remote {sessionId}

        Manager-->>Rust: Ok(())
        Rust-->>Store: PlaybackMode
        Store->>UI: Update cast icon

## Queue Navigation Flow

    flowchart TB
        User["User clicks Next"] --> Invoke["invoke('player_next')"]
        Invoke --> ControllerNext["controller.next()"]
        ControllerNext --> QueueNext["queue.next()<br/>- Check repeat mode<br/>- Check shuffle<br/>- Update history"]

        QueueNext --> None["None<br/>(at end)"]
        QueueNext --> Some["Some(next)"]
        QueueNext --> Same["Same<br/>(repeat one)"]

        Some --> PlayItem["play_item(next)<br/>Returns new status"]

## Volume Control Flow

    sequenceDiagram
        participant User
        participant Slider as Volume Slider
        participant Handler as handleVolumeChange()
        participant Tauri as Tauri IPC
        participant Command as player_set_volume
        participant Controller as PlayerController
        participant Backend as MpvBackend/NullBackend
        participant Events as playerEvents.ts
        participant Store as Player Store
        participant UI

        User->>Slider: adjusts (0-100)
        Slider->>Handler: oninput event
        Handler->>Handler: Convert 0-100 -> 0.0-1.0
        Handler->>Tauri: invoke("player_set_volume", {volume})
        Tauri->>Command: player_set_volume
        Command->>Controller: set_volume(volume)
        Controller->>Backend: set_volume(volume)
        Backend->>Backend: Clamp to 0.0-1.0
        Note over Backend: MpvBackend: Send to MPV loop
        Backend-->>Tauri: emit "player-event"
        Tauri-->>Events: VolumeChanged event
        Events->>Store: player.setVolume(volume)
        Store-->>UI: Reactive update
        Note over UI: Both AudioPlayer and<br/>MiniPlayer stay in sync

Key Implementation Details:

- Volume is stored in the backend (NullBackend/MpvBackend)
- `PlayerController.volume()` delegates to backend
- `get_player_status()` returns `controller.volume()` (not hardcoded)
- Frontend uses normalized 0.0-1.0 scale, UI shows 0-100

# Type Synchronization & Thread Safety

## PlayerState (Rust <-> TypeScript)

Rust:

    #![allow(unused)]
    fn main() {
    pub enum PlayerState {
        Idle,
        Loading { media: MediaItem },
        Playing { media: MediaItem, position: f64, duration: f64 },
        Paused { media: MediaItem, position: f64, duration: f64 },
        Seeking { media: MediaItem, target: f64 },
        Error { media: Option<MediaItem>, error: String },
    }
    }

TypeScript:

    type PlayerState =
      | { kind: "idle" }
      | { kind: "loading"; media: MediaItem }
      | { kind: "playing"; media: MediaItem; position: number; duration: number }
      | { kind: "paused"; media: MediaItem; position: number; duration: number }
      | { kind: "seeking"; media: MediaItem; target: number }
      | { kind: "error"; media: MediaItem | null; error: string };

## MediaItem Serialization

    #![allow(unused)]
    fn main() {
    // Rust (serde serialization)
    #[derive(Serialize, Deserialize)]
    pub struct MediaItem {
        pub id: String,
        pub title: String,
        #[serde(skip_serializing_if = "Option::is_none")]
        pub artist: Option<String>,
        // ...
    }
    }

    // TypeScript
    interface MediaItem {
      id: string;
      title: string;
      artist?: string;
      // ...
    }

## Tauri v2 IPC Parameter Naming Convention

CRITICAL: Tauri v2's `#[tauri::command]` macro automatically converts snake_case Rust parameter names to camelCase for the frontend. All `invoke()` calls must use camelCase for top-level parameters.

Rule: Rust `fn cmd(repository_handle: String)` -> Frontend sends `{ repositoryHandle: "..." }`

    // CORRECT - Tauri v2 auto-converts snake_case -> camelCase
    await invoke("player_play_tracks", {
      repositoryHandle: "handle-123",     // Rust: repository_handle
      request: { trackIds: ["id1"], startIndex: 0 }
    });

    await invoke("remote_send_command", {
      sessionId: "session-123",           // Rust: session_id
      command: "PlayPause"
    });

    await invoke("pin_item", {
      itemId: "item-123"                  // Rust: item_id
    });

    // WRONG - snake_case causes "invalid args request" error on Android
    await invoke("player_play_tracks", {
      repository_handle: "handle-123",    // Will fail!
    });

Parameter Name Mapping (Rust -> Frontend):

Rust Parameter | Frontend Parameter | Used By
--- | --- | ---
`repository_handle` | `repositoryHandle` | `player_play_tracks`, `player_add_track_by_id`, `player_play_album_track`
`session_id` | `sessionId` | `remote_send_command`, `remote_play_on_session`, `remote_session_seek`
`item_id` | `itemId` | `pin_item`, `unpin_item`
`current_item_id` | `currentItemId` | `playback_mode_transfer_to_local`
`position_ticks` | `positionTicks` | `playback_mode_transfer_to_local`, `remote_session_seek`
`item_ids` | `itemIds` | `remote_play_on_session`
`start_index` | `startIndex` | `remote_play_on_session`

Nested struct fields use `#[serde(rename_all = "camelCase")]` separately - this is serde deserialization, not the command macro. Both layers convert independently.

Test Coverage: Integration tests in `src/lib/utils/tauriIntegration.test.ts` validate all invoke calls use correct camelCase parameter names.

## Rust Backend Thread Safety

    #![allow(unused)]
    fn main() {
    // Shared state wrapped in Arc<Mutex<>>
    pub struct PlayerController {
        backend: Arc<Mutex<Box<dyn PlayerBackend>>>,
        queue: Arc<Mutex<QueueManager>>,
        // ...
    }

    // Tauri state wrapper
    pub struct PlayerStateWrapper(pub Mutex<PlayerController>);

    // Command handler pattern
    #[tauri::command]
    pub fn player_play(state: State<PlayerStateWrapper>) -> Result<PlayerStatus, String> {
        let mut controller = state.0.lock().unwrap();  // Acquire lock
        controller.play()?;                             // Operate
        Ok(get_player_status(&controller))              // Lock released
    }
    }

## Frontend Stores

Svelte stores are inherently reactive and thread-safe for UI updates:

    const { subscribe, update } = writable<PlayerStore>(initialState);

    // Atomic updates
    function setPlaying(media: MediaItem, position: number, duration: number) {
      update(state => ({
        ...state,
        state: { kind: "playing", media, position, duration }
      }));
    }

# Platform-Specific Player Backends

## Player Events System

Location: `src-tauri/src/player/events.rs`

The player uses a push-based event system to notify the frontend of state changes:

    #![allow(unused)]
    fn main() {
    pub enum PlayerStatusEvent {
        /// Playback position updated (emitted periodically during playback)
        PositionUpdate { position: f64, duration: f64 },

        /// Player state changed
        StateChanged { state: String, media_id: Option<String> },

        /// Media has finished loading and is ready to play
        MediaLoaded { duration: f64 },

        /// Playback has ended naturally
        PlaybackEnded,

        /// Buffering state changed
        Buffering { percent: u8 },

        /// An error occurred during playback
        Error { message: String, recoverable: bool },

        /// Volume changed
        VolumeChanged { volume: f32, muted: bool },

        /// Sleep timer state changed
        SleepTimerChanged {
            mode: SleepTimerMode,
            remaining_seconds: u32,
        },

        /// Show next episode popup with countdown
        ShowNextEpisodePopup {
            current_episode: MediaItem,
            next_episode: MediaItem,
            countdown_seconds: u32,
            auto_advance: bool,
        },

        /// Countdown tick (emitted every second during autoplay countdown)
        CountdownTick { remaining_seconds: u32 },

        /// Queue changed (items added, removed, reordered, or playback mode changed)
        QueueChanged {
            items: Vec<MediaItem>,
            current_index: Option<usize>,
            shuffle: bool,
            repeat: RepeatMode,
            has_next: bool,
            has_previous: bool,
        },

        /// Media session changed (activity context changed: Audio/Movie/TvShow/Idle)
        SessionChanged { session: MediaSessionType },
    }
    }

Events are emitted via Tauri's event system:

    flowchart LR
        subgraph Backend["Player Backend"]
            MPV["MPV/ExoPlayer"]
        end

        subgraph EventSystem["Event System"]
            Emitter["TauriEventEmitter<br/>emit()"]
            Bus["Tauri Event Bus<br/>'player-event'"]
        end

        subgraph Frontend["Frontend"]
            Listener["playerEvents.ts<br/>Frontend Listener"]
            Store["Player Store Update<br/>(position, state, etc)"]
        end

        MPV --> Emitter --> Bus --> Listener --> Store

Frontend Listener (`src/lib/services/playerEvents.ts`):

- Listens for `player-event` Tauri events
- Updates player/queue stores based on event type
- Auto-advances to next track on `PlaybackEnded`
- On `StateChanged` events, calls `invoke("player_get_queue")` to update `appState.hasNext`/`hasPrevious` -- this enables MiniPlayer skip button state

Important: The command is `player_get_queue` (returns `QueueStatus` with `hasNext`/`hasPrevious`). There is no `player_get_queue_status` command.

## HTML5 Video Adapter (webview-rendered video)

Location: `src/lib/player/html5Adapter.ts`, `src/lib/player/index.ts`, report commands in `src-tauri/src/commands/player/timers.rs`

Video on desktop (Linux WebKitGTK) — and, per current interim behavior, Android — is rendered by an HTML5 `<video>`/HLS element inside the webview. libmpv is initialized audio-only (`vo=null`, `video=false`), so the native backend cannot render or observe this element. The `<video>` is therefore the real player, living outside Rust's reach.

To keep the `PlayerController` the single source of truth (matching the audio path), the HTML5 element is treated as a dumb output device that reports back into Rust, rather than an independent state authority:

    flowchart LR
        subgraph Webview["Webview"]
            Video["HTML5 <video> / HLS.js"]
            Adapter["html5Adapter.ts<br/>(reports DOM events)"]
        end
        subgraph Backend["Rust"]
            Cmds["player_report_state<br/>player_report_position<br/>player_report_media_loaded"]
            Controller["PlayerController"]
            Emitter["TauriEventEmitter"]
        end
        subgraph Frontend["Frontend"]
            Events["playerEvents.ts"]
            Store["player store"]
        end

        Video -->|DOM events| Adapter --> Cmds --> Controller --> Emitter --> Events --> Store

Key points:

- The adapter re-emits the same `PlayerStatusEvent`s (`StateChanged`, `PositionUpdate`, `MediaLoaded`) the native backends emit, so `playerEvents.ts` needs no HTML5-specific branch — HTML5 is just another event source feeding the existing pipeline.
- Position reports are throttled (~250ms) to match the MPV cadence and avoid flooding IPC from the 60fps RAF loop.
- Boundary rule: UI components never touch the report commands or `videoElement` state directly. Playback control goes through the unified facade `src/lib/player/index.ts` (`playerController`); HTML5 state reporting goes through `html5Adapter.ts`. This restores the documented invariant ("frontend only displays state and invokes commands") for the video path.

## MpvBackend (Linux)

Location: `src-tauri/src/player/mpv/`

The MPV backend uses libmpv for audio playback on Linux. Since MPV handles are not `Send`, all operations occur on a dedicated thread.

    flowchart TB
        subgraph MainThread["Main Thread"]
            MpvBackend["MpvBackend<br/>- command_tx<br/>- shared_state<br/>- shutdown"]
            Commands["Commands:<br/>Load, Play, Pause<br/>Stop, Seek, SetVolume"]
        end

        subgraph EventLoopThread["MPV Event Loop Thread"]
            EventLoop["event_loop.rs<br/>- MPV Handle<br/>- command_rx<br/>- Event Emitter"]
            TauriEmitter["TauriEventEmitter"]
        end

        MpvBackend -->|"MpvCommand"| EventLoop
        MpvBackend <-->|"Arc<Mutex<>>"| EventLoop
        EventLoop -->|"Events"| TauriEmitter
        TauriEmitter --> FrontendStore["Frontend Store"]

Key Components:

    #![allow(unused)]
    fn main() {
    // Command enum sent to event loop thread
    pub enum MpvCommand {
        Load { url: String, media: MediaItem },
        Play,
        Pause,
        Stop,
        Seek(f64),
        SetVolume(f32),
        Quit,
    }

    // Shared state between main thread and event loop
    pub struct MpvSharedState {
        pub state: PlayerState,
        pub position: f64,
        pub duration: Option<f64>,
        pub volume: f32,
        pub is_loaded: bool,
        pub current_media: Option<MediaItem>,
    }
    }

Event Loop (`event_loop.rs`):

- Initializes MPV with audio-only config (`vo=null`, `video=false`)
- Observes properties: `time-pos`, `duration`, `pause`, `volume`
- Emits position updates every 250ms during playback
- Processes commands from channel (non-blocking)
- Handles MPV events: `FileLoaded`, `EndFile`, `PropertyChange`

## ExoPlayerBackend (Android)

Location: `src-tauri/src/player/android/` and Kotlin sources

The ExoPlayer backend uses Android's Media3/ExoPlayer library via JNI.

    flowchart TB
        subgraph RustNative["Rust (Native)"]
            ExoBackend["ExoPlayerBackend<br/>- player_ref<br/>- shared_state"]
            NativeFuncs["JNI Callbacks<br/>nativeOnPosition...<br/>nativeOnState...<br/>nativeOnMediaLoaded<br/>nativeOnPlaybackEnd"]
            TauriEmitter2["TauriEventEmitter"]
        end

        subgraph KotlinJVM["Kotlin (JVM)"]
            JellyTauPlayer["JellyTauPlayer<br/>- ExoPlayer<br/>- Player.Listener"]
        end

        ExoBackend -->|"JNI Calls"| JellyTauPlayer
        JellyTauPlayer -->|"Callbacks"| NativeFuncs
        NativeFuncs --> TauriEmitter2
        TauriEmitter2 --> FrontendStore2["Frontend Store"]

Kotlin Player (`JellyTauPlayer.kt`):

    class JellyTauPlayer(context: Context) {
        private val exoPlayer: ExoPlayer
        private var positionUpdateJob: Job?

        // Methods callable from Rust via JNI
        fun load(url: String, mediaId: String)
        fun play()
        fun pause()
        fun stop()
        fun seek(positionSeconds: Double)
        fun setVolume(volume: Float)

        // Native callbacks to Rust
        private external fun nativeOnPositionUpdate(position: Double, duration: Double)
        private external fun nativeOnStateChanged(state: String, mediaId: String?)
        private external fun nativeOnMediaLoaded(duration: Double)
        private external fun nativeOnPlaybackEnded()
    }

JNI Callbacks (Rust):

    #![allow(unused)]
    fn main() {
    #[no_mangle]
    pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnPositionUpdate(
        _env: JNIEnv, _class: JClass, position: jdouble, duration: jdouble
    ) {
        // Update shared state
        // Emit PlayerStatusEvent::PositionUpdate
    }
    }

### Audio settings on ExoPlayer

TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036

`PlayerBackend` declares `set_audio_settings` with a default `Ok(())` body. For a long time `ExoPlayerBackend` took that default, so Settings › Audio rendered controls that silently did nothing on Android — the parity gap recorded in requirements.md, now closed.

The settings cross to Kotlin as JSON over JNI, not as a wide signature, so new fields do not change the method signature — the same approach `load()` uses for subtitles:

    #![allow(unused)]
    fn main() {
    fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
        let json = audio_settings_jni_payload(settings)?;
        env.call_method(&self.player_ref, "setAudioSettings", "(Ljava/lang/String;)V", …)?;
        // Store the sanitised form, so audio_settings() reflects what was applied.
        self.shared_state.lock_safe().audio_settings =
            settings.clone().with_crossfade_clamped().with_equalizer_normalised();
    }
    }

Kotlin owns the mechanics — attaching `AudioEffect`s to the audio session — while the canonical band layout and preset curves stay in Rust:

Feature | Android mechanism | Notes
--- | --- | ---
Gapless | `pauseAtEndOfMediaItems` |
Volume normalization | `LoudnessEnhancer` | A gain stage — approximate next to MPV's `dynaudnorm`
Equalizer | `android.media.audiofx.Equalizer` | The canonical 10 bands are resampled onto the device's own band centres
Crossfade | — | Unimplemented on every platform (DR-034), architecturally blocked on MPV. Building it on Android alone would invert the parity gap

Two things are deliberately still open: the effects are not yet verified on a physical device (`AudioEffect` availability and band layouts are device-specific), and the trait default is still a silent `Ok(())` rather than an error, so a backend that omits the method still reports success. Flipping that default waits on the device verification.

### The equalizer, and where its vocabulary lives

TRACES: UR-027 | DR-030, IR-020

The canonical band layout (`EQ_BANDS`) and the preset curves live in `settings.rs`, not in either backend and not in the UI: a preset is a gain curve defined by the band layout, and the layout is a property of the audio engine rather than of the picker that renders it. Presets are Flat, Rock, Pop, Jazz, Classical, Bass Boost, Treble Boost and Vocal, all conservative (within ±8 dB) so they stack safely with volume normalization.

Platform | Mechanism
--- | ---
Linux | One ffmpeg two-pole peaking `equalizer` filter per band, composed by `build_af_filter` into MPV's `af` property alongside the normalization filter: `equalizer=f=31:width_type=o:width=1:g=5`
Android | `android.media.audiofx.Equalizer`, with the canonical 10 bands resampled onto whatever band centres the device actually has

Gains are normalised (`with_equalizer_normalised`) before use, and bands beyond `EQ_BANDS` are ignored, so a malformed settings payload cannot produce a filter chain of unbounded length.

## Background Audio Handoff (Android)

TRACES: UR-040 | IR-025, DR-051, DR-052, DR-178 … DR-180, DR-196, DR-203

Keeping a video's audio alive when the app is backgrounded or the screen locks, while video decode stops. Two verified facts drive the whole design:

1. An Android WebView `<video>` does not keep playing audio once the app is backgrounded — the system throttles the WebView and media pauses.
2. Keeping audio alive in the background requires a native foreground media service, which already exists for music (`JellyTauPlaybackService` + `JellyTauPlayer` + `MediaSessionCompat`).

So this is a handoff, not "keep the WebView alive": on background, tear down the current renderer and play the same item audio-only through the native service; on foreground, hand back. In the project's one-directional playback model this is a change of which player is authoritative, and the position must transfer cleanly across it.

    sequenceDiagram
        participant App as App backgrounded
        participant FE as VideoPlayer
        participant Rust as player_enter/exit_background_audio
        participant Exo as Native audio service

        App-->>FE: jellytau-background (DOM CustomEvent)
        FE->>Rust: enter(item, position, audioStreamIndex)
        Rust->>Exo: play audio-only at position
        Note over Exo: lockscreen + notification, existing MediaSession
        App-->>FE: jellytau-foreground
        FE->>Rust: exit() -> final position
        Rust-->>FE: position
        FE->>FE: restart the renderer that is on screen

Details that were each a shipped defect:

- Position is absolute. Transcoded HLS tracks time as `videoElement.currentTime + seekOffset` (the element resets to 0 after each transcode reload). `computeHandoffPosition` sums both terms; using the element time alone rewinds by the offset.
- A downloaded episode takes no base URL and an ordinary seek (DR-180); a stream takes the base and no seek; a handoff at 0:00 takes neither.
- The return must restart the renderer that is actually on screen (DR-196). The two paths resume by different means — the webview `<video>` reloads off its stream URL, watched by an `$effect`; ExoPlayer owns no element and nothing watches the URL for it, so it needs an explicit re-issue. Doing only the URL assignment restarted nothing on the native path and left a black screen with a play button that did nothing.
- `wasPlaying` is captured on the way out so play/pause survives the round trip, and the handoff does not silently rewind (DR-203).
- Mutually exclusive with PiP. Toggle on → `setAutoEnterEnabled(false)`; toggle off → PiP on background, the status quo. The frontend re-asserts the value whenever the toggle changes and on unmount, so a stale setting cannot leak into the next player.
- The pure arithmetic and state transitions live in `backgroundAudioHandoff.ts`, free of Svelte and the DOM, so they are testable without mounting the player.

Native signals background/foreground to the frontend as DOM CustomEvents (`jellytau-background` / `jellytau-foreground`); the frontend carries the toggle state to native through the `AndroidBackgroundAudio` bridge. No-op on every non-Android platform.

## Native Video Compositing (Android)

TRACES: UR-003, UR-004 | DR-150 … DR-152, DR-182 … DR-196

Android can render video on the native ExoPlayer surface behind a transparent Tauri WebView, with the Svelte controls drawn over it. This is on by default; the HTML5 `<video>` path remains the fallback and is not being removed. The default has been flipped and reverted twice and each revert has a named cause — the per-defect record is in `requirements.md` (DR-150 … DR-196).

    flowchart TB
        subgraph Window["One Android window"]
            Texture["TextureView (index 0)<br/>ExoPlayer video"]
            WebView["Tauri WebView (above)<br/>transparent, Svelte controls"]
        end
        Rust["ExoPlayerBackend"] -->|JNI| Player["JellyTauPlayer"]
        Player --> Texture
        MainActivity -->|"setTransparent(true)"| WebView
        VideoOverlayManager -->|"attach / detach"| Texture

Load-bearing details, each of which was a shipped defect:

- TextureView, not SurfaceView (DR-192). A SurfaceView renders on its own layer outside the app window and punches a transparent hole through it; everything drawn above that hole — for us the whole UI — depends on that composition path, which Android's own documentation says does not reliably work. A TextureView makes "behind" ordinary view z-order within one window.
- Attached at index 0 by `VideoOverlayManager`, and detached when the video goes (DR-184) — a surface left in the hierarchy outlives its player.
- Bridges are installed before the page that uses them (DR-183). `addJavascriptInterface` must run once per WebView instance and a call that lands after the page has loaded never reaches it, so `setTransparent(true)` could be dropped entirely.
- The app shell stops painting over the surface (DR-185). `app.css` clears its opaque backgrounds off `[data-native-video]`; before that, a CSS rule targeted an attribute nothing ever set, so the fix looked applied and was not.
- The poster card can lift on a path with no `<video>` element (DR-182) — the native reveal fires on a `playing` state or a position tick carrying a position or duration, and on nothing else.
- Letterbox bars are painted, not left holding whatever was last in the framebuffer (DR-194).
- There is deliberately no audio-focus bridge: manual focus requests from the WebView competed with Chromium's `AudioFocusDelegate` and with ExoPlayer, and the resulting `AUDIOFOCUS_LOSS` paused playback.

Related Kotlin pieces in the same window: `PictureInPictureManager` (DR-160/161), `ScreenWakeManager` (DR-202 — Android counts its display timeout from touch events, which a playing video does not generate), `ImmersiveModeBridge` and `WindowInsetsBridge` (IR-031/DR-112 — see 02-svelte-frontend.md).

## Android MediaSession & Remote Volume Control

Location: `JellyTauPlaybackService.kt`

JellyTau uses a dual MediaSession architecture for Android to support both Media3 playback controls and remote volume control:

    flowchart TB
        subgraph Service["JellyTauPlaybackService"]
            MediaSession["Media3 MediaSession<br/>- Lockscreen controls<br/>- Media notifications<br/>- Play/Pause/Next/Previous"]

            MediaSessionCompat["MediaSessionCompat<br/>- Remote volume control<br/>- Hardware button interception"]

            VolumeProvider["VolumeProviderCompat<br/>- onSetVolumeTo()<br/>- onAdjustVolume()"]

            MediaSessionCompat --> VolumeProvider
        end

        subgraph Hardware["System"]
            VolumeButtons["Hardware Volume Buttons"]
            Lockscreen["Lockscreen Controls"]
            Notification["Media Notification"]
        end

        subgraph Rust["Rust Backend"]
            JNI["JNI Callbacks<br/>nativeOnRemoteVolumeChange()"]
            PlaybackMode["PlaybackModeManager<br/>send_remote_volume_command()"]
            JellyfinAPI["Jellyfin API<br/>session_set_volume()"]
        end

        VolumeButtons --> VolumeProvider
        Lockscreen --> MediaSession
        Notification --> MediaSession

        VolumeProvider --> JNI
        JNI --> PlaybackMode
        PlaybackMode --> JellyfinAPI

Architecture Rationale:

JellyTau maintains both MediaSession types because they serve different purposes:

1. Media3 MediaSession: Handles lockscreen/notification playback controls (play/pause/next/previous)
2. MediaSessionCompat: Intercepts hardware volume button presses for remote playback control

When in remote playback mode (controlling a Jellyfin session on another device):

- Volume buttons are routed through `VolumeProviderCompat`
- Volume changes are sent to the remote session via Jellyfin API
- System volume UI shows the remote session's volume level

Remote Volume Flow:

    sequenceDiagram
        participant User
        participant VolumeButton as Hardware Volume Button
        participant VolumeProvider as VolumeProviderCompat
        participant JNI as nativeOnRemoteVolumeChange
        participant PlaybackMode as PlaybackModeManager
        participant Jellyfin as Jellyfin Server
        participant RemoteSession as Remote Session (TV/Browser)

        User->>VolumeButton: Press Volume Up
        VolumeButton->>VolumeProvider: onAdjustVolume(ADJUST_RAISE)
        VolumeProvider->>VolumeProvider: remoteVolumeLevel += 2
        VolumeProvider->>VolumeProvider: currentVolume = remoteVolumeLevel
        VolumeProvider->>JNI: nativeOnRemoteVolumeChange("VolumeUp", level)
        JNI->>PlaybackMode: send_remote_volume_command("VolumeUp", level)
        PlaybackMode->>Jellyfin: POST /Sessions/{id}/Command/VolumeUp
        Jellyfin->>RemoteSession: Set volume to new level
        RemoteSession-->>User: Volume changes on TV/Browser

Key Implementation Details:

Enabling Remote Volume (`enableRemoteVolume()`):

    fun enableRemoteVolume(initialVolume: Int) {
        volumeProvider = object : VolumeProviderCompat(
            VolumeProviderCompat.VOLUME_CONTROL_ABSOLUTE,
            100,  // Max volume
            initialVolume
        ) {
            override fun onSetVolumeTo(volume: Int) {
                remoteVolumeLevel = volume.coerceIn(0, 100)
                nativeOnRemoteVolumeChange("SetVolume", remoteVolumeLevel)
            }

            override fun onAdjustVolume(direction: Int) {
                when (direction) {
                    AudioManager.ADJUST_RAISE -> {
                        remoteVolumeLevel = (remoteVolumeLevel + 2).coerceAtMost(100)
                        nativeOnRemoteVolumeChange("VolumeUp", remoteVolumeLevel)
                        currentVolume = remoteVolumeLevel
                    }
                    AudioManager.ADJUST_LOWER -> {
                        remoteVolumeLevel = (remoteVolumeLevel - 2).coerceAtLeast(0)
                        nativeOnRemoteVolumeChange("VolumeDown", remoteVolumeLevel)
                        currentVolume = remoteVolumeLevel
                    }
                }
            }
        }

        mediaSessionCompat.setPlaybackToRemote(volumeProvider)
    }

Disabling Remote Volume (`disableRemoteVolume()`):

    fun disableRemoteVolume() {
        mediaSessionCompat.setPlaybackToLocal(AudioManager.STREAM_MUSIC)
        volumeProvider = null
    }

Rust Integration (`src-tauri/src/player/android/mod.rs`):

    #![allow(unused)]
    fn main() {
    /// Enable remote volume control on Android
    pub fn enable_remote_volume(initial_volume: i32) -> Result<(), String> {
        start_playback_service()?;
        let service_instance = get_playback_service_instance()?;
        env.call_method(&service_instance, "enableRemoteVolume", "(I)V",
            &[JValue::Int(initial_volume)])?;
        Ok(())
    }
    }

Dependencies (`src-tauri/android/build.gradle.kts`):

    dependencies {
        implementation("androidx.media3:media3-session:1.5.1")  // Media3 MediaSession
        implementation("androidx.media:media:1.7.0")            // MediaSessionCompat
    }

Integration with Playback Mode:

Remote volume is automatically enabled/disabled during playback mode transfers:

    #![allow(unused)]
    fn main() {
    // In PlaybackModeManager::transfer_to_remote()
    #[cfg(target_os = "android")]
    {
        if let Err(e) = crate::player::enable_remote_volume(50) {
            log::warn!("Failed to enable remote volume: {}", e);
        }
    }

    // In PlaybackModeManager::transfer_to_local()
    #[cfg(target_os = "android")]
    {
        if let Err(e) = crate::player::disable_remote_volume() {
            log::warn!("Failed to disable remote volume: {}", e);
        }
    }
    }

## Android Album Art Caching

Location: `src-tauri/android/src/main/java/com/dtourolle/jellytau/player/AlbumArtCache.kt`

Album art caching provides efficient bitmap storage for lock screen notifications with automatic LRU eviction and memory management.

    flowchart TB
        subgraph JellyTauPlayer["JellyTauPlayer.kt"]
            LoadMedia["loadWithMetadata()<br/>- Store artworkUrl<br/>- Launch async download"]
            AsyncDownload["Coroutine<br/>- Non-blocking<br/>- Dispatchers.IO"]
        end

        subgraph Cache["AlbumArtCache.kt"]
            MemoryCache["LruCache<String, Bitmap><br/>- 1/8 of heap<br/>- ~12-16MB typical<br/>- 50-100 albums capacity"]
            Download["Download & Scale<br/>- 512x512 max<br/>- Exponential backoff"]
            ErrorHandle["Error Handling<br/>- Graceful fallback<br/>- Auto-retry"]
        end

        subgraph Service["JellyTauPlaybackService.kt"]
            UpdateMeta["updateMediaMetadata()<br/>- Accept Bitmap parameter<br/>- Add METADATA_KEY_ALBUM_ART"]
            Notification["Notification<br/>- setLargeIcon()<br/>- Lock screen display"]
        end

        LoadMedia --> AsyncDownload
        AsyncDownload --> MemoryCache
        MemoryCache --> Download
        Download --> ErrorHandle
        AsyncDownload --> UpdateMeta
        UpdateMeta --> Notification

AlbumArtCache Singleton:

    class AlbumArtCache(context: Context) {
        private val memoryCache = object : LruCache<String, Bitmap>(cacheSize) {
            override fun sizeOf(key: String, bitmap: Bitmap): Int {
                return bitmap.byteCount / 1024  // Size in KB
            }
        }

        suspend fun getArtwork(url: String): Bitmap? {
            memoryCache.get(url)?.let { return it }
            return downloadAndCache(url)
        }

        private suspend fun downloadAndCache(url: String): Bitmap? =
            withContext(Dispatchers.IO) {
                // HTTP download with 5s timeout
                // Scale to 512x512 max
                // Auto-evict LRU if needed
            }
    }

Integration Flow:

1. Track Load (`loadWithMetadata()`):

   - Store artwork URL in `currentArtworkUrl`
   - Reset bitmap to null
   - Start playback immediately (non-blocking)

2. Async Download (Background Coroutine):

   - Check cache: instant hit if available
   - Network miss: download, scale, cache
   - Auto-retry on network failure with exponential backoff
   - Graceful fallback if artwork unavailable

3. Notification Update:

   - Pass bitmap to `updatePlaybackServiceNotification()`
   - Add to `MediaMetadataCompat` with `METADATA_KEY_ALBUM_ART`
   - Display as large icon in notification
   - Show on lock screen

Memory Management:

Metric | Value
--- | ---
Cache Size | 1/8 of heap (12-16MB typical)
Max Resolution | 512x512 pixels
Capacity | ~50-100 album arts
Eviction Policy | LRU (Least Recently Used)
Lifetime | In-memory only (app session)
Network Timeout | 5 seconds per download

Performance Characteristics:

- Cache Hit: ~1ms (in-memory retrieval)
- Cache Miss: ~200-500ms (download + scale)
- Playback Impact: Zero (async downloads)
- Memory Overhead: Max 16MB (auto-eviction)
- Error Recovery: Automatic with exponential backoff

## Backend Initialization

Location: `src-tauri/src/lib.rs`

Backend selection is platform-specific:

    #![allow(unused)]
    fn main() {
    fn create_player_backend(app_handle: tauri::AppHandle) -> Box<dyn PlayerBackend> {
        let event_emitter = Arc::new(TauriEventEmitter::new(app_handle));

        #[cfg(target_os = "linux")]
        {
            match MpvBackend::new(event_emitter.clone()) {
                Ok(backend) => return Box::new(backend),
                Err(e) => eprintln!("MPV init failed: {}", e),
            }
        }

        #[cfg(target_os = "android")]
        {
            // ExoPlayer requires Activity context, initialized separately
        }

        // Fallback
        Box::new(NullBackend::new())
    }
    }

# Download Manager & Offline Architecture

## Overview

Location: `src-tauri/src/download/`

The download manager provides offline media support with priority-based queue management, progress tracking, retry logic, and smart caching.

    flowchart TB
        subgraph Frontend["Frontend"]
            DownloadButton["DownloadButton.svelte"]
            DownloadsPage["/downloads"]
            DownloadsStore["downloads.ts store"]
        end

        subgraph Backend["Rust Backend"]
            Commands["Download Commands"]
            DownloadManager["DownloadManager"]
            DownloadWorker["DownloadWorker"]
            SmartCache["SmartCache Engine"]
        end

        subgraph Storage["Storage"]
            SQLite[("SQLite DB")]
            MediaFiles[("Downloaded Files")]
        end

        DownloadButton -->|"invoke('download_item')"| Commands
        DownloadsPage -->|"invoke('get_downloads')"| Commands
        Commands --> DownloadManager
        DownloadManager --> DownloadWorker
        DownloadManager --> SmartCache
        DownloadWorker -->|"HTTP Stream"| MediaFiles
        DownloadWorker -->|"Events"| DownloadsStore
        Commands <--> SQLite
        SmartCache <--> SQLite

## Download Worker

Location: `src-tauri/src/download/worker.rs`

The download worker handles HTTP streaming with retry logic and resume support:

    #![allow(unused)]
    fn main() {
    pub struct DownloadWorker {
        client: reqwest::Client,
        max_retries: u32,
    }

    pub struct DownloadTask {
        pub id: i64,
        pub item_id: String,
        pub user_id: String,
        pub priority: i32,
        pub url: String,
        pub target_path: PathBuf,
        pub mime_type: Option<String>,
        pub expected_size: Option<i64>,
    }
    }

Retry Strategy:

- Exponential backoff: 5s, 15s, 45s
- Maximum 3 retry attempts
- HTTP Range requests for resume support
- Progress events emitted every 1MB

Download Flow:

    sequenceDiagram
        participant UI
        participant Command as download_item
        participant DB as SQLite
        participant Worker as DownloadWorker
        participant Jellyfin as Jellyfin Server
        participant Store as downloads store

        UI->>Command: download_item(itemId, userId)
        Command->>DB: INSERT INTO downloads
        Command->>Worker: Start download task
        Worker->>Jellyfin: GET /Items/{id}/Download

        loop Progress Updates
            Jellyfin->>Worker: Stream chunks
            Worker->>Worker: Write to .part file
            Worker->>Store: Emit progress event
            Store->>UI: Update progress bar
        end

        Worker->>Worker: Rename .part to final
        Worker->>DB: UPDATE status='completed'
        Worker->>Store: Emit completed event
        Store->>UI: Show completed

## Smart Caching Engine

Location: `src-tauri/src/download/cache.rs`

The smart caching system provides predictive downloads based on listening patterns:

    #![allow(unused)]
    fn main() {
    pub struct SmartCache {
        config: Arc<Mutex<CacheConfig>>,
        album_play_history: Arc<Mutex<HashMap<String, Vec<String>>>>,
    }

    pub struct CacheConfig {
        pub queue_precache_enabled: bool,
        pub queue_precache_count: usize,        // Default: 5
        pub album_affinity_enabled: bool,
        pub album_affinity_threshold: usize,    // Default: 3
        pub storage_limit: u64,                 // Default: 10GB
        pub wifi_only: bool,                    // Default: true
    }
    }

Caching Strategies:

1. Queue Pre-caching: Auto-download next 5 tracks when playing (WiFi only)
2. Album Affinity: If user plays 3+ tracks from album, cache entire album
3. LRU Eviction: Remove least recently accessed when storage limit reached

    flowchart TB
        Play["Track Played"] --> CheckQueue{"Queue<br/>Pre-cache?"}
        CheckQueue -->|"Yes"| CacheNext5["Download<br/>Next 5 Tracks"]

        Play --> TrackHistory["Track Play History"]
        TrackHistory --> CheckAlbum{"3+ Tracks<br/>from Album?"}
        CheckAlbum -->|"Yes"| CacheAlbum["Download<br/>Full Album"]

        CacheNext5 --> CheckStorage{"Storage<br/>Limit?"}
        CacheAlbum --> CheckStorage
        CheckStorage -->|"Exceeded"| EvictLRU["Evict LRU Items"]
        CheckStorage -->|"OK"| Download["Queue Download"]

## One Storage Model: Cache Entries Are Downloads

TRACES: UR-071 | DR-126, DR-127

A cache entry is a download with a shorter life: the same `downloads` row and the same file handling, distinguished by `download_source` plus an expiry. There is one storage model rather than a cache and a download library that can disagree about what is on disk.

`download_source` | Life | Reclaimed by
--- | --- | ---
`'auto'` (temporary) | Expiry, or eviction under space pressure | Both
`'user'` (permanent) | No expiry | Neither

Eviction only reclaims the temporary tier. `evict_lru_async` originally selected every completed download ordered by `completed_at ASC` with no source filter, so hitting the storage limit deleted the oldest download — typically a film saved deliberately for offline — to make room for a newly precached track. It now evicts only `COALESCE(download_source, 'user') = 'auto'` rows. `COALESCE` rather than a bare equality is load-bearing: rows predating the migration can be NULL, and unknown provenance must be treated as the user's, never as disposable. Freeing less than requested is the correct outcome when only user downloads remain — the caller reports "unable to free enough".

A temporary row can be promoted to permanent when the user chooses to keep it. That only clears the expiry and flips the source; the bytes never move.

## Offline Catalog Visibility

TRACES: UR-052 | DR-078, DR-079, DR-080

Offline, a library page shows only media on the device. A "Show all server media" toggle additionally reveals the cached server catalog, greyed out and queueable for download on reconnect.

The gate is a process-global `INCLUDE_CATALOG_BROWSE` in `repository/offline.rs`, written by the `set_show_server_catalog` command. It gates the synced-catalog leg of `get_items`; without it the toggle rendered but every server item still appeared, which is the defect the spec was written for. `isConnected` derives from backend-reported reachability alone (DR-079) — see 07-connectivity.md.

Per-item disk usage comes from `repository_get_download_disk_usage` (`DownloadDiskUsage`), aggregated from `downloads.file_size` — used by the Downloaded browse cards, detail pages, the device total and the remove confirmation (DR-085).

## Download Commands

Location: `src-tauri/src/commands/download/` — `mod.rs` (the commands below), `pinning.rs`, `smart_cache.rs`

Command | Parameters | Description
--- | --- | ---
`download_item` | `item_id, user_id, file_path` | Queue single item download
`download_album` | `album_id, user_id` | Queue all tracks in album
`get_downloads` | `user_id, status_filter` | Get download list
`pause_download` | `download_id` | Pause active download
`resume_download` | `download_id` | Resume paused download
`cancel_download` | `download_id` | Cancel and delete partial
`delete_download` | `download_id` | Delete completed download
`download_video` / `download_series` / `download_season` | item ids | Queue video content
`get_download_storage_stats` | `user_id` | Device totals for the downloads screen
`delete_album_downloads` / `delete_downloads_under` / `delete_all_downloads` | container id | Bulk removal
`pin_item` / `unpin_item` / `is_item_pinned` | `item_id` | Protect metadata from a cache clear
`set_max_concurrent_downloads` | `max` | Worker concurrency (3 by default)

## Offline Commands

Location: `src-tauri/src/commands/offline.rs`

Command | Parameters | Description
--- | --- | ---
`offline_is_available` | `item_id` | Check if item downloaded
`offline_get_items` | `user_id` | Get all offline items
`offline_search` | `user_id, query` | Search downloaded items

## Player Integration

Location: `src-tauri/src/commands/player.rs` (modified)

The player checks for local downloads before streaming:

    #![allow(unused)]
    fn main() {
    fn create_media_item(req: PlayItemRequest, db: Option<&DatabaseWrapper>) -> MediaItem {
        let local_path = db.and_then(|db_wrapper| {
            check_for_local_download(db_wrapper, &jellyfin_id).ok().flatten()
        });

        let source = if let Some(path) = local_path {
            MediaSource::Local {
                file_path: PathBuf::from(path),
                jellyfin_item_id: Some(jellyfin_id.clone())
            }
        } else {
            MediaSource::Remote {
                stream_url: req.stream_url,
                jellyfin_item_id: jellyfin_id.clone()
            }
        };

        MediaItem { source, /* ... */ }
    }
    }

## Frontend Downloads Store

Location: `src/lib/stores/downloads.ts`

    interface DownloadsState {
      downloads: Record<number, DownloadInfo>;
      activeCount: number;
      queuedCount: number;
    }

    const downloads = createDownloadsStore();

    // Actions
    downloads.downloadItem(itemId, userId, filePath)
    downloads.downloadAlbum(albumId, userId)
    downloads.pause(downloadId)
    downloads.resume(downloadId)
    downloads.cancel(downloadId)
    downloads.delete(downloadId)
    downloads.refresh(userId, statusFilter)

    // Derived stores
    export const activeDownloads = derived(downloads, ($d) =>
      Object.values($d.downloads).filter((d) => d.status === 'downloading')
    );

Event Handling:

The store listens to Tauri events for real-time updates:

    listen<DownloadEvent>('download-event', (event) => {
      const payload = event.payload;

      switch (payload.type) {
        case 'started':
          // Update status to 'downloading'
        case 'progress':
          // Update progress and bytes_downloaded
        case 'completed':
          // Update status to 'completed', progress to 1.0
        case 'failed':
          // Update status to 'failed', store error message
      }
    });

## Download UI Components

DownloadButton (`src/lib/components/library/DownloadButton.svelte`):

- Multiple states: available, downloading, completed, failed, paused
- Circular progress ring during download
- Size variants: sm, md, lg
- Integrated into TrackList with `showDownload={true}` prop

DownloadItem (`src/lib/components/downloads/DownloadItem.svelte`):

- Individual download list item with progress bar
- Action buttons: pause, resume, cancel, delete
- Status indicators with color coding

Downloads Page (`src/routes/downloads/+page.svelte`):

- Active/Completed tabs
- Bulk actions: Pause All, Resume All, Clear Completed
- Empty states with helpful instructions

## Database Schema

downloads table:

    CREATE TABLE downloads (
        id              INTEGER PRIMARY KEY AUTOINCREMENT,
        item_id         TEXT NOT NULL,
        user_id         TEXT NOT NULL,
        file_path       TEXT,
        file_size       INTEGER,
        mime_type       TEXT,
        status          TEXT DEFAULT 'pending',  -- pending, downloading, completed, failed, paused
        progress        REAL DEFAULT 0.0,
        bytes_downloaded INTEGER DEFAULT 0,
        priority        INTEGER DEFAULT 0,
        error_message   TEXT,
        retry_count     INTEGER DEFAULT 0,
        queued_at       TEXT DEFAULT CURRENT_TIMESTAMP,
        started_at      TEXT,
        completed_at    TEXT
    );

    CREATE INDEX idx_downloads_queue
      ON downloads(status, priority DESC, queued_at ASC)
      WHERE status IN ('pending', 'downloading');

# Connectivity & Network Architecture

## HTTP Client with Retry Logic

Location: `src-tauri/src/jellyfin/http_client.rs`

The HTTP client provides automatic retry with exponential backoff for network resilience:

    #![allow(unused)]
    fn main() {
    pub struct HttpClient {
        client: reqwest::Client,
        config: HttpConfig,
    }

    pub struct HttpConfig {
        pub timeout: Duration,        // Default: 30s (large library queries can be slow)
        pub max_retries: u32,         // Default: 3
    }
    }

> Note: ordinary requests use the 30s timeout above. The connectivity recovery probe (`ping`) uses a shorter, dedicated 5s timeout so an unreachable server is detected quickly while offline.

Retry Strategy:

- Retry delays: 1s, 2s, 4s (exponential backoff)
- Retries on: Network errors, 5xx server errors
- No retry on: 4xx client errors, 401/403 authentication errors

Error Classification:

    #![allow(unused)]
    fn main() {
    pub enum ErrorKind {
        Network,        // Connection failures, timeouts, DNS errors
        Authentication, // 401/403 responses
        Server,         // 5xx server errors
        Client,         // Other 4xx errors
    }
    }

## Connectivity Monitor

Location: `src-tauri/src/connectivity/mod.rs`

The connectivity monitor is the single source of truth for server reachability. Its primary signal is the outcome of real repository traffic — every server request the user actually makes. A standalone `/System/Info/Public` probe is kept only as an offline recovery detector.

### Source of truth: repository traffic

`OnlineRepository` reports the result of each server request to the monitor, classified via `RepoError`:

Repository outcome | Meaning | Effect on reachability
--- | --- | ---
`Ok(_)` | Server answered successfully | Mark reachable (instant recovery)
`Err(Authentication)` | Server answered with 401/403 | Mark reachable (server is up; request was rejected)
`Err(NotFound)` | Server answered with 404 | Mark reachable (server is up)
`Err(Server)` | Server answered with 5xx / bad body | Mark reachable (server is up)
`Err(Network)` | Connection failure / timeout / DNS | Candidate for offline (see debounce)
`Err(Database)` | Local cache error only | No effect (not a server signal)

This classification fixes the previous bug where a successful `/System/Info/Public` ping reported "online" even while the user's authenticated data calls were failing — and vice versa.

### Time-window debounce (offline) + instant recovery (online)

To stop the banner from flapping on a single dropped request, the transition to offline is debounced over a time window:

- On the first `Network` failure, the monitor records `first_failure_at`.
- It flips `is_server_reachable = false` only once `Network` failures have persisted continuously for `OFFLINE_CONFIRM_WINDOW` (5s) with no intervening success.
- Any success (or server-answered error) clears `first_failure_at` and immediately marks reachable.

Recovery is therefore instant and asymmetric: one good response brings the app back online, but a brief blip never trips the banner.

### Offline-only recovery probe

    flowchart TB
        Repo["OnlineRepository"] -->|"success / RepoError"| Monitor["ConnectivityMonitor"]
        Monitor --> State{"is_server_reachable?"}
        State -->|"Online"| NoProbe["No background polling<br/>(real traffic is the signal)"]
        State -->|"Offline"| Probe["5s /System/Info/Public probe<br/>(recovery detector)"]
        Probe -->|"reachable again"| Monitor
        Monitor -->|"on change"| Emit["Emit connectivity:changed<br/>+ connectivity:reconnected"]
        Emit --> Frontend["Frontend Store → banner"]

While online, there is no background polling — real requests keep the state fresh. While offline, the fast 5s probe runs so an idle app still detects the server returning even when no user traffic is flowing.

Features:

- Traffic-driven: Reachability follows the requests the user actually makes.
- Time-window debounce: Offline declared only after `OFFLINE_CONFIRM_WINDOW` (5s) of sustained network failure; recovery is instant.
- Offline-only probe: 5s `/System/Info/Public` probe runs only while offline.
- Event Emission: Emits `connectivity:changed` and `connectivity:reconnected` events.
- Thread-Safe: Uses `Arc<RwLock<>>` for shared state.

Tauri Commands:

Command | Description
--- | ---
`connectivity_check_server` | Manual reachability check (also used by the frontend's advisory `navigator.onLine` hint)
`connectivity_set_server_url` | Update monitored server URL
`connectivity_get_status` | Get current connectivity status
`connectivity_start_monitoring` | Start the offline recovery probe
`connectivity_stop_monitoring` | Stop the probe
`connectivity_mark_reachable` | Mark reachable — driven by `OnlineRepository` on every server success
`connectivity_mark_unreachable` | Mark unreachable — driven by `OnlineRepository` on `RepoError::Network` (subject to debounce)

Frontend Integration:

    // The store is a pure reflection of backend events — it no longer decides
    // reachability itself. navigator.onLine is advisory: it triggers an immediate
    // recheck rather than forcing the offline state.
    listen<{ isReachable: boolean }>("connectivity:changed", (event) => {
      updateConnectivityState(event.payload.isReachable);
    });

## Network Resilience Architecture

The connectivity system provides resilience through multiple layers:

1. HTTP Client Layer: Automatic retry with exponential backoff
2. Connectivity Monitoring: Reachability derived from real repository traffic, with an offline-only recovery probe
3. Frontend Integration: Offline mode detection and UI updates (a pure reflection of backend events)
4. Sync Queue: Offline mutations queued for later (see 06-downloads-and-offline.md)

Design Principles:

- Single source of truth: Reachability follows the outcome of real requests, classified via `RepoError`; the frontend store and the probe never compete to decide it.
- Fail Fast: Don't retry 4xx errors (client errors, authentication).
- Fail Slow: Retry network and 5xx errors with increasing delays.
- Debounced offline, instant online: Declare offline only after a sustained failure window; recover on the first success.
- Probe only when needed: Background polling runs only while offline, as a recovery detector.
- Event-Driven: Frontend reacts to connectivity changes via events.

# Offline Database Design

## Entity Relationship Diagram

    erDiagram
        servers ||--o{ users : "has"
        servers ||--o{ libraries : "has"
        libraries ||--o{ items : "contains"
        items ||--o{ items : "parent_of"
        items ||--o{ user_data : "has"
        items ||--o{ downloads : "has"
        items ||--o{ media_streams : "has"
        items ||--o{ thumbnails : "has"
        users ||--o{ user_data : "owns"
        users ||--o{ downloads : "owns"
        users ||--o{ sync_queue : "owns"

        servers {
            int id PK
            string jellyfin_id UK
            string name
            string url
            string version
            datetime last_sync
        }

        users {
            int id PK
            string jellyfin_id
            int server_id FK
            string name
            boolean is_active
        }

        libraries {
            int id PK
            string jellyfin_id
            int server_id FK
            string name
            string collection_type
            string image_tag
        }

        items {
            int id PK
            string jellyfin_id
            int server_id FK
            int library_id FK
            int parent_id FK
            string type
            string name
            string sort_name
            string overview
            int production_year
            float community_rating
            string official_rating
            int runtime_ticks
            string primary_image_tag
            string backdrop_image_tag
            string album_id
            string album_name
            string album_artist
            json artists
            json genres
            int index_number
            int parent_index_number
            string premiere_date
            json metadata_json
            datetime created_at
            datetime updated_at
            datetime last_sync
        }

        user_data {
            int id PK
            int item_id FK
            int user_id FK
            int position_ticks
            int play_count
            boolean is_favorite
            boolean played
            datetime last_played
            datetime updated_at
            datetime synced_at
        }

        downloads {
            int id PK
            int item_id FK
            int user_id FK
            string file_path
            int file_size
            string status
            float progress
            int priority
            string error_message
            datetime created_at
            datetime completed_at
        }

        media_streams {
            int id PK
            int item_id FK
            int stream_index
            string type
            string codec
            string language
            string display_title
            boolean is_default
            boolean is_forced
            boolean is_external
        }

        sync_queue {
            int id PK
            int user_id FK
            string operation
            string entity_type
            string entity_id
            json payload
            datetime created_at
            int attempts
            datetime last_attempt
            string status
        }

        thumbnails {
            int id PK
            int item_id FK
            string image_type
            string image_tag
            string file_path
            int width
            int height
            datetime cached_at
        }

## Table Definitions

### servers

Stores connected Jellyfin server information.

    CREATE TABLE servers (
        id              INTEGER PRIMARY KEY AUTOINCREMENT,
        jellyfin_id     TEXT NOT NULL UNIQUE,
        name            TEXT NOT NULL,
        url             TEXT NOT NULL,
        version         TEXT,
        last_sync       DATETIME,
        created_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
        updated_at      DATETIME DEFAULT CURRENT_TIMESTAMP
    );

### users

Stores user accounts per server. Access tokens are stored separately in secure storage (see 09-security.md).

    CREATE TABLE users (
        id              INTEGER PRIMARY KEY AUTOINCREMENT,
        jellyfin_id     TEXT NOT NULL,
        server_id       INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
        name            TEXT NOT NULL,
        is_active       BOOLEAN DEFAULT 0,
        created_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
        updated_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
        UNIQUE(jellyfin_id, server_id)
    );

### libraries

Stores library/collection metadata.

    CREATE TABLE libraries (
        id              INTEGER PRIMARY KEY AUTOINCREMENT,
        jellyfin_id     TEXT NOT NULL,
        server_id       INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
        name            TEXT NOT NULL,
        collection_type TEXT,
        image_tag       TEXT,
        sort_order      INTEGER DEFAULT 0,
        last_sync       DATETIME,
        created_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
        updated_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
        UNIQUE(jellyfin_id, server_id)
    );

    CREATE INDEX idx_libraries_server ON libraries(server_id);

### items

Main table for all media items (movies, episodes, albums, songs, etc.).

    CREATE TABLE items (
        id                   INTEGER PRIMARY KEY AUTOINCREMENT,
        jellyfin_id          TEXT NOT NULL,
        server_id            INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
        library_id           INTEGER REFERENCES libraries(id) ON DELETE SET NULL,
        parent_id            INTEGER REFERENCES items(id) ON DELETE CASCADE,

        -- Basic metadata
        type                 TEXT NOT NULL,
        name                 TEXT NOT NULL,
        sort_name            TEXT,
        overview             TEXT,

        -- Media info
        production_year      INTEGER,
        community_rating     REAL,
        official_rating      TEXT,
        runtime_ticks        INTEGER,

        -- Images
        primary_image_tag    TEXT,
        backdrop_image_tag   TEXT,

        -- Audio-specific
        album_id             TEXT,
        album_name           TEXT,
        album_artist         TEXT,
        artists              TEXT,                   -- JSON array

        -- Series/Season-specific
        index_number         INTEGER,
        parent_index_number  INTEGER,
        series_id            TEXT,
        series_name          TEXT,
        season_id            TEXT,

        -- Additional
        genres               TEXT,                   -- JSON array
        premiere_date        TEXT,
        metadata_json        TEXT,

        -- Sync tracking
        created_at           DATETIME DEFAULT CURRENT_TIMESTAMP,
        updated_at           DATETIME DEFAULT CURRENT_TIMESTAMP,
        last_sync            DATETIME,

        UNIQUE(jellyfin_id, server_id)
    );

    -- Performance indexes
    CREATE INDEX idx_items_server ON items(server_id);
    CREATE INDEX idx_items_library ON items(library_id);
    CREATE INDEX idx_items_parent ON items(parent_id);
    CREATE INDEX idx_items_type ON items(type);
    CREATE INDEX idx_items_album ON items(album_id);
    CREATE INDEX idx_items_series ON items(series_id);
    CREATE INDEX idx_items_name ON items(name COLLATE NOCASE);

    -- Full-text search
    CREATE VIRTUAL TABLE items_fts USING fts5(
        name,
        overview,
        artists,
        album_name,
        album_artist,
        content='items',
        content_rowid='id'
    );

    -- Triggers to keep FTS in sync
    CREATE TRIGGER items_ai AFTER INSERT ON items BEGIN
        INSERT INTO items_fts(rowid, name, overview, artists, album_name, album_artist)
        VALUES (new.id, new.name, new.overview, new.artists, new.album_name, new.album_artist);
    END;

    CREATE TRIGGER items_ad AFTER DELETE ON items BEGIN
        INSERT INTO items_fts(items_fts, rowid, name, overview, artists, album_name, album_artist)
        VALUES ('delete', old.id, old.name, old.overview, old.artists, old.album_name, old.album_artist);
    END;

    CREATE TRIGGER items_au AFTER UPDATE ON items BEGIN
        INSERT INTO items_fts(items_fts, rowid, name, overview, artists, album_name, album_artist)
        VALUES ('delete', old.id, old.name, old.overview, old.artists, old.album_name, old.album_artist);
        INSERT INTO items_fts(rowid, name, overview, artists, album_name, album_artist)
        VALUES (new.id, new.name, new.overview, new.artists, new.album_name, new.album_artist);
    END;

### media_streams

Stores subtitle and audio track information for items.

    CREATE TABLE media_streams (
        id              INTEGER PRIMARY KEY AUTOINCREMENT,
        item_id         INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
        stream_index    INTEGER NOT NULL,
        type            TEXT NOT NULL,
        codec           TEXT,
        language        TEXT,
        display_title   TEXT,
        is_default      BOOLEAN DEFAULT 0,
        is_forced       BOOLEAN DEFAULT 0,
        is_external     BOOLEAN DEFAULT 0,
        path            TEXT,
        UNIQUE(item_id, stream_index)
    );

    CREATE INDEX idx_media_streams_item ON media_streams(item_id);

### user_data

Stores per-user data for items (favorites, progress, play count).

    CREATE TABLE user_data (
        id              INTEGER PRIMARY KEY AUTOINCREMENT,
        item_id         INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
        user_id         INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,

        -- Playback state
        position_ticks  INTEGER DEFAULT 0,
        play_count      INTEGER DEFAULT 0,
        played          BOOLEAN DEFAULT 0,
        last_played     DATETIME,

        -- User preferences
        is_favorite     BOOLEAN DEFAULT 0,
        user_rating     REAL,

        -- Sync tracking
        updated_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
        synced_at       DATETIME,
        needs_sync      BOOLEAN DEFAULT 0,

        UNIQUE(item_id, user_id)
    );

    CREATE INDEX idx_user_data_item ON user_data(item_id);
    CREATE INDEX idx_user_data_user ON user_data(user_id);
    CREATE INDEX idx_user_data_needs_sync ON user_data(needs_sync) WHERE needs_sync = 1;
    CREATE INDEX idx_user_data_favorites ON user_data(user_id, is_favorite) WHERE is_favorite = 1;
    CREATE INDEX idx_user_data_in_progress ON user_data(user_id, position_ticks)
        WHERE position_ticks > 0 AND played = 0;

### downloads

Tracks downloaded media files.

    CREATE TABLE downloads (
        id              INTEGER PRIMARY KEY AUTOINCREMENT,
        item_id         INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
        user_id         INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,

        file_path       TEXT,
        file_size       INTEGER,
        file_hash       TEXT,

        status          TEXT NOT NULL DEFAULT 'pending',
        progress        REAL DEFAULT 0,
        bytes_downloaded INTEGER DEFAULT 0,

        transcode_profile TEXT,

        priority        INTEGER DEFAULT 0,
        error_message   TEXT,
        retry_count     INTEGER DEFAULT 0,

        created_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
        started_at      DATETIME,
        completed_at    DATETIME,
        expires_at      DATETIME,

        UNIQUE(item_id, user_id)
    );

    CREATE INDEX idx_downloads_status ON downloads(status);
    CREATE INDEX idx_downloads_user ON downloads(user_id);
    CREATE INDEX idx_downloads_queue ON downloads(status, priority DESC, created_at ASC)
        WHERE status IN ('pending', 'downloading');

### sync_queue

Stores mutations to sync back to server when online.

    CREATE TABLE sync_queue (
        id              INTEGER PRIMARY KEY AUTOINCREMENT,
        user_id         INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,

        operation       TEXT NOT NULL,
        entity_type     TEXT NOT NULL,
        entity_id       TEXT NOT NULL,
        payload         TEXT,

        status          TEXT DEFAULT 'pending',
        attempts        INTEGER DEFAULT 0,
        max_attempts    INTEGER DEFAULT 5,
        last_attempt    DATETIME,
        error_message   TEXT,

        created_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
        completed_at    DATETIME
    );

    CREATE INDEX idx_sync_queue_status ON sync_queue(status, created_at ASC)
        WHERE status = 'pending';
    CREATE INDEX idx_sync_queue_user ON sync_queue(user_id);

### thumbnails

Caches downloaded artwork.

    CREATE TABLE thumbnails (
        id              INTEGER PRIMARY KEY AUTOINCREMENT,
        item_id         INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
        image_type      TEXT NOT NULL,
        image_tag       TEXT,
        file_path       TEXT NOT NULL,
        width           INTEGER,
        height          INTEGER,
        file_size       INTEGER,
        cached_at       DATETIME DEFAULT CURRENT_TIMESTAMP,
        last_accessed   DATETIME DEFAULT CURRENT_TIMESTAMP,
        UNIQUE(item_id, image_type, width)
    );

    CREATE INDEX idx_thumbnails_item ON thumbnails(item_id);
    CREATE INDEX idx_thumbnails_lru ON thumbnails(last_accessed ASC);

### playlists (for local/synced playlists)

    CREATE TABLE playlists (
        id              INTEGER PRIMARY KEY AUTOINCREMENT,
        jellyfin_id     TEXT,
        user_id         INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
        name            TEXT NOT NULL,
        description     TEXT,
        is_local_only   BOOLEAN DEFAULT 0,
        created_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
        updated_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
        synced_at       DATETIME,
        needs_sync      BOOLEAN DEFAULT 0
    );

    CREATE TABLE playlist_items (
        id              INTEGER PRIMARY KEY AUTOINCREMENT,
        playlist_id     INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
        item_id         INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
        sort_order      INTEGER NOT NULL,
        added_at        DATETIME DEFAULT CURRENT_TIMESTAMP,
        UNIQUE(playlist_id, item_id)
    );

    CREATE INDEX idx_playlist_items_playlist ON playlist_items(playlist_id, sort_order);

## Key Queries

### Get items for offline library browsing

    -- Get all albums in a music library
    SELECT * FROM items
    WHERE library_id = ? AND type = 'MusicAlbum'
    ORDER BY sort_name;

    -- Get tracks for an album
    SELECT * FROM items
    WHERE album_id = ? AND type = 'Audio'
    ORDER BY parent_index_number, index_number;

### Resume / Continue Watching

    SELECT i.*, ud.position_ticks, ud.last_played
    FROM items i
    JOIN user_data ud ON ud.item_id = i.id
    WHERE ud.user_id = ?
      AND ud.position_ticks > 0
      AND ud.played = 0
    ORDER BY ud.last_played DESC
    LIMIT 20;

### Offline search

    SELECT i.* FROM items i
    JOIN items_fts fts ON fts.rowid = i.id
    WHERE items_fts MATCH ?
    ORDER BY rank;

### Download queue management

    -- Get next item to download
    SELECT d.*, i.name, i.type
    FROM downloads d
    JOIN items i ON i.id = d.item_id
    WHERE d.status = 'pending'
    ORDER BY d.priority DESC, d.created_at ASC
    LIMIT 1;

    -- Get download progress for UI
    SELECT
        d.status,
        COUNT(*) as count,
        SUM(d.file_size) as total_size,
        SUM(d.bytes_downloaded) as downloaded
    FROM downloads d
    WHERE d.user_id = ?
    GROUP BY d.status;

### Sync queue processing

    -- Get pending sync operations (oldest first)
    SELECT * FROM sync_queue
    WHERE status = 'pending'
      AND attempts < max_attempts
    ORDER BY created_at ASC
    LIMIT 10;

    -- Mark operation complete
    UPDATE sync_queue
    SET status = 'completed', completed_at = CURRENT_TIMESTAMP
    WHERE id = ?;

## Data Flow

### Online Mode

    flowchart TB
        subgraph OnlineMode["Online Mode"]
            JellyfinServer["Jellyfin Server"]
            OnlineRepo["OnlineRepo"]
            SQLite["SQLite"]
            HybridRepo["HybridRepository"]
            UI["UI / Stores"]

            JellyfinServer -->|"API Response"| OnlineRepo
            OnlineRepo -->|"Cache"| SQLite
            SQLite -->|"Sync"| JellyfinServer
            OnlineRepo -->|"Response"| HybridRepo
            SQLite -->|"Fallback"| HybridRepo
            HybridRepo --> UI
        end

### Offline Mode

    flowchart TB
        subgraph OfflineMode["Offline Mode"]
            OfflineRepo["OfflineRepo"]
            SQLite2["SQLite"]
            SyncQueue["sync_queue<br/>(Queued for later)"]
            HybridRepo2["HybridRepository"]
            UI2["UI / Stores"]

            OfflineRepo <-->|"Query"| SQLite2
            SQLite2 -->|"Mutations"| SyncQueue
            OfflineRepo --> HybridRepo2
            HybridRepo2 --> UI2
        end

### Sync on Reconnect

    flowchart LR
        NetworkRestored["Network restored"]
        SyncService["SyncService"]
        SyncQueue2["sync_queue"]
        JellyfinAPI["Jellyfin API"]
        MarkSynced["Mark synced"]

        NetworkRestored --> SyncService
        SyncService -->|"Read"| SyncQueue2
        SyncQueue2 -->|"Send"| JellyfinAPI
        JellyfinAPI -->|"Success"| MarkSynced
        MarkSynced --> SyncService

## Storage Estimates

Content Type | Metadata Size | Thumbnail Size | Media Size
--- | --- | --- | ---
Song | ~2 KB | ~50 KB (300px) | 5-15 MB
Album (12 tracks) | ~30 KB | ~100 KB | 60-180 MB
Movie | ~5 KB | ~200 KB | 1-8 GB
Episode | ~3 KB | ~100 KB | 300 MB - 2 GB
Full music library (5000 songs) | ~10 MB | ~250 MB | 25-75 GB

## Rust Module Structure

    src-tauri/src/storage/
    ├── mod.rs              # Module exports, Database struct
    ├── schema.rs           # Table definitions, migrations
    ├── models.rs           # Rust structs matching tables
    ├── queries/
    │   ├── mod.rs
    │   ├── items.rs        # Item CRUD operations
    │   ├── user_data.rs    # User data operations
    │   ├── downloads.rs    # Download queue operations
    │   └── sync.rs         # Sync queue operations
    └── sync/
        ├── mod.rs          # SyncService
        ├── manager.rs      # Background sync manager
        └── operations.rs   # Individual sync operation handlers

# Security

## Authentication Token Storage

Access tokens are not stored in the SQLite database. Instead, they are stored using platform-native secure storage:

    flowchart TB
        LoginSuccess["Login Success"]
        KeyringCheck{"System Keyring<br/>Available?"}
        OSCredential["Store in OS Credential Manager<br/>- Linux: libsecret/GNOME Keyring<br/>- macOS: Keychain<br/>- Windows: Credential Manager<br/>- Android: EncryptedSharedPrefs"]
        EncryptedFallback["Encrypted File Fallback<br/>(AES-256-GCM)"]

        LoginSuccess --> KeyringCheck
        KeyringCheck -->|"Yes"| OSCredential
        KeyringCheck -->|"No"| EncryptedFallback

Key Format:

    jellytau::{server_id}::{user_id}::access_token

Rationale:

- Tokens in SQLite would be readable if the database file is accessed
- System keyrings provide OS-level encryption and access control
- Fallback ensures functionality on minimal systems without a keyring daemon

## Secure Storage Module

Location: `src-tauri/src/secure_storage/` (planned)

    #![allow(unused)]
    fn main() {
    pub trait SecureStorage: Send + Sync {
        fn store(&self, key: &str, value: &str) -> Result<(), SecureStorageError>;
        fn retrieve(&self, key: &str) -> Result<Option<String>, SecureStorageError>;
        fn delete(&self, key: &str) -> Result<(), SecureStorageError>;
    }

    // Platform implementations
    pub struct KeyringStorage;      // Uses keyring crate
    pub struct EncryptedFileStorage; // AES-256-GCM fallback
    }

## Network Security

Aspect | Implementation
--- | ---
Transport | HTTPS required for all Jellyfin API calls
Certificate Validation | System CA store (configurable for self-signed)
Token Transmission | Bearer token in `Authorization` header only
Token Refresh | Handled by Jellyfin server (long-lived tokens)
Android cleartext | `res/xml/network_security_config.xml` blocks cleartext everywhere except `127.0.0.1` (the loopback media server, DR-137/DR-138). The manifest's `usesCleartextTraffic` is ignored once the config is present, so the config is the single authority
Android WebView | `mixedContentMode = COMPATIBILITY` with `allowFileAccess`/`allowContentAccess` both `false` (DR-199). These are the second half of the cleartext policy: `ALWAYS_ALLOW` re-opened by hand what the network security config closes. Change the two together

## Webview Content Security Policy

`app.security.csp` in `tauri.conf.json` (TRACES: UR-012, UR-071 | DR-198). It was `null` — CSP disabled — which meant any script that reached the web layer inherited the full IPC surface. Tauri computes the header from this value when it serves the embedded HTML, injecting a nonce for SvelteKit's inline bootstrap script, so `script-src` needs no `'unsafe-inline'`.

    default-src 'self';
    script-src  'self';
    style-src   'self' 'unsafe-inline';
    font-src    'self' data:;
    img-src     'self' data: blob: asset: http://asset.localhost http: https:;
    media-src   'self' blob: asset: http://asset.localhost http://127.0.0.1:* http: https:;
    connect-src 'self' ipc: http://ipc.localhost http: https:;
    worker-src  'self' blob:;
    object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'

Directive | Why
--- | ---
`default-src 'self'` | Everything not named below is same-origin only.
`script-src 'self'` | The genuinely restrictive half. Bundled JS only; Tauri's build-time nonce covers the one inline `<script>` in `index.html`. Adding `'unsafe-inline'` here would silently do nothing anyway — a nonce in a directive voids it.
`style-src 'self' 'unsafe-inline'` | Svelte compiles `style="…"` attributes into markup, including `app.html`'s `display: contents` wrapper, and CSP treats a style attribute as inline. Safe only while no `<style>` element survives into `index.html`: Tauri would nonce it, and the nonce would then void `'unsafe-inline'`. The production build extracts all CSS to files, so it currently has none.
`img-src` | Thumbnails come from two places: the asset protocol (`asset://localhost/…` on Linux/macOS, `http://asset.localhost/…` on Windows/Android — the same protocol, named differently by `convertFileSrc`) and, on a cache miss, straight from the Jellyfin server. `data:`/`blob:` cover inline and generated images.
`media-src` | `<video>`/`<audio>` sources: HLS transcodes and progressive streams from the server, the token-guarded loopback media server on `http://127.0.0.1:<random port>` (DR-137), and `blob:` for the MSE object URL hls.js attaches.
`connect-src` | `ipc:` / `http://ipc.localhost` is Tauri's `invoke` transport (custom scheme on Linux/macOS, `http` host on Windows/Android) — without it every command is blocked. `http:`/`https:` is hls.js fetching manifests and segments; ordinary API traffic goes through Rust and is not subject to CSP.
`worker-src 'self' blob:` | hls.js runs its demuxer in a worker built from a blob (`enableWorker: true`). Without `blob:` it falls back to main-thread demuxing — playback survives but costs more CPU.
`object-src`, `frame-src` = `'none'` | No plugins, no iframes; both are classic injection sinks.
`base-uri 'self'`, `form-action 'self'`, `frame-ancestors 'none'` | Block `<base>` hijacking, form exfiltration and framing. `frame-ancestors` is only honoured when the policy is delivered as a header, which is platform-dependent; it is harmless where it is not.

`img-src`/`media-src`/`connect-src` are deliberately permissive. The Jellyfin origin is typed in by the user at run time and is routinely plain `http` on a LAN, so it cannot be enumerated at build time. `http: https:` is a wide grant for data — but it still bars `file:`, `filesystem:` and scripting schemes, and it does not touch `script-src`, which is where an injected origin would actually hurt. A run-time policy naming the server exactly was considered and rejected: Tauri derives the header from immutable config at the moment it serves the HTML, so it would mean rebuilding the config and reloading the webview whenever the user adds or switches a server, to constrain a destination the user chooses anyway.

`devCsp` mirrors the policy with `'unsafe-inline' 'unsafe-eval'` on `script-src` and `ws:`/`wss:` on `connect-src`, because the Vite dev server injects styles and code and drives HMR over a websocket. It applies only to `tauri dev`.

### Asset protocol scope

`app.security.assetProtocol.scope` is `$APPDATA/thumbnails/**` — not the storage root. `imageCache.ts` is the only `convertFileSrc` caller left in the frontend: downloaded media moved to the loopback media server in DR-137, and downloaded audio is opened by MPV/ExoPlayer directly from its path. The old `$APPDATA/**` grant let the webview read the SQLite database and the encrypted-token fallback file alongside the thumbnails it actually needs.

If a new feature hands the webview a local file, widen this scope to that subdirectory specifically; a path outside it resolves to nothing and the webview reports `NETWORK_NO_SOURCE` (which is exactly how DR-134's failure presented).

## Local Data Protection

Data Type | Protection
--- | ---
Access Tokens | System keyring or encrypted file
Database (SQLite) | Plaintext (metadata only, no secrets)
Downloaded Media | Filesystem permissions only
Cached Thumbnails | Filesystem permissions only

## Path Confinement and Input Binding

Two classes of defect, both of the same shape: a value that arrived from outside decided something it should not, at a site whose neighbours a few lines away already did it correctly.

### Filesystem path confinement

Surface | Rule | TRACES
--- | --- | ---
Thumbnail cache | The filename is built from `item_id`, `image_type` and `tag`; all three are sanitised (non-alphanumerics → `_`), and the resolved path is checked with `starts_with(cache_dir)` at the point of use | DR-210
Downloads | `file_path` and `target_dir` are sanitised inside `download_item` itself, not only in `download_item_and_start` — the latter is what made the existing guard bypassable rather than absent | DR-211

Two mechanics worth remembering, because both are easy to get subtly wrong:

- `Path::join` neither folds `..` nor keeps the base when handed an absolute path. Confinement therefore has to be checked after the join, not before.
- Sanitising is per path component. Whole-string sanitising would rewrite `downloads/x.mp3` to `downloads_x.mp3` and relocate every existing download.

The database keeps both the raw key and the resolved path, so lookups still match and pre-existing rows still resolve.

### Query and URL construction

Caller-supplied values are bound or encoded, never interpolated (DR-212):

- The offline `get_items` item-type filter uses parameter placeholders rather than formatting `IN ('a','b')`.
- `build_get_items_endpoint` encodes `ParentId` / `IncludeItemTypes` / `SortBy` / `SortOrder`. Encoding is per element and list separators stay unencoded, because Jellyfin splits these parameters on the comma.
- `player_set_volume` clamps at the command boundary — it previously accepted NaN and out-of-range floats even though every backend clamps internally.

## Security Considerations

1. No Secrets in SQLite: The database contains only non-sensitive metadata
2. Token Isolation: Each user/server combination has a separate token entry
3. Logout Cleanup: Token deletion from secure storage on logout
4. No Token Logging: Tokens are never written to logs or debug output
5. IPC Security: Tauri's IPC uses structured commands, not arbitrary code execution
6. Webview Containment: A restrictive `script-src` keeps injected script off the IPC surface; the asset protocol is scoped to the thumbnail cache only (see above)

# JellyTau UX Flows & Screen Transitions

This document describes the expected user experience flows, screen transitions, and navigation patterns in JellyTau.

## 1. Core Navigation Structure

### 1.1 Navigation System

JellyTau uses a unified navigation system with a bottom navigation bar visible on all platforms (mobile and desktop) and additional header navigation for desktop.

Bottom Navigation Bar (All Platforms - DR-045, UR-039):

The bottom navigation bar is the primary navigation and is always visible on all platforms (mobile and desktop) except when:

- Full-screen video player is active
- User is on the login screen

Bottom Nav Structure:

    ┌─────────────────────────────────────────┐
    │    [Home]  [Library]  [Search]         │
    └─────────────────────────────────────────┘

Routes:

- Home → `/` (home page with carousels and featured content)
- Library → `/library` (library selector showing all libraries)
- Search → `/search` (dedicated search page)

Note: Available on both mobile and desktop for consistent navigation access.

Header Navigation (Desktop):

On desktop (md breakpoint and above), the header contains:

- Logo (links to `/library`)
- Navigation links: Home, Library, Downloads, Settings
- Search bar (inline)
- Account menu (see §1.2)

Mobile Navigation:

On mobile, the header contains:

- Logo
- Account menu button (see §1.2)

### 1.2 Account Menu

Account-level destinations — the ones that are about the user rather than about media — live behind a single account menu, anchored to the user's name/avatar at the right of the header.

Contents, in order:

    ┌──────────────────────────┐
    │  Signed in as <name>     │   ← identity, not a menu item
    │  <server host>           │
    ├──────────────────────────┤
    │  ⬇  Downloads            │
    │  ⚙  Settings             │
    │  ▦  Display              │   ← grid/list preference (§5A.2)
    ├──────────────────────────┤
    │  ⇥  Sign out             │
    └──────────────────────────┘

Rules:

- One menu, both platforms. Desktop and mobile show the same items in the same order. A user who learns where Settings lives on one form factor finds it in the same place on the other.
- Anchored to identity. The trigger is the username/avatar, because that is where users look for account actions. A bare three-dot icon does not signal "your account".
- Sign out is separated by a divider and placed last — it is destructive and must not sit adjacent to routine navigation.
- The menu is reachable from every authenticated screen, not only from library routes. See §1.3.

Access Points Summary:

- Downloads → header icon (desktop) + account menu (both)
- Settings → header nav link (desktop) + account menu (both)
- Sign out → account menu only

### 1.3 Chrome availability

The header is shared across chrome-bearing routes. Routes fall into three groups:

Route group | Header | Bottom nav | Account menu reachable?
--- | --- | --- | ---
`/library/*` | Yes (own layout, shared `AppHeader`) | Yes | Yes
`/`, `/search`, `/downloads` | Yes (root-owned `AppHeader`) | Yes | Yes
`/settings` | Own layout | No | n/a — already there
`/player/*`, `/login` | No | No | No (by design)

The rule the app honours: every authenticated, non-immersive screen exposes the account menu. Only the full-screen player and the login screen are chrome-free.

### 1.4 Known deviations

(None — the account-menu and chrome-availability defects tracked here under UR-054 were resolved. Settings, Downloads, Display, and Sign out are now reachable from every authenticated non-immersive screen via the shared `AccountMenu`, the username/avatar is the menu trigger, desktop and mobile share one menu, and the Display preference has a Settings entry — UR-029, §5A.4.)

## 2. Initial App Launch Flow

### 2.1 First-Time Launch

    flowchart TB
        Launch[App Launch] --> CheckAuth{Stored<br/>Credentials?}
        CheckAuth -->|No| LoginScreen[Login Screen<br/>/login]
        CheckAuth -->|Yes| AutoLogin[Auto-login]

        LoginScreen --> EnterURL[Enter Server URL]
        EnterURL --> EnterCreds[Enter Username/Password]
        EnterCreds --> LoginSuccess{Success?}
        LoginSuccess -->|No| LoginError[Show Error]
        LoginError --> EnterCreds
        LoginSuccess -->|Yes| StoreToken[Store Token in Keyring]

        AutoLogin --> TokenValid{Token Valid?}
        TokenValid -->|No| LoginScreen
        TokenValid -->|Yes| HomePage

        StoreToken --> HomePage[Home Page<br/>/]

Screens:

1. Login Screen (`/login`)

   - Server URL input
   - Username input
   - Password input
   - "Remember me" checkbox (default: on)
   - Login button
   - No header, no bottom nav

2. Home Page (`/`)

   - Default landing page after successful login
   - Shows featured content, carousels, continue watching
   - No MiniPlayer visible (nothing playing yet)
   - Bottom nav: Home tab active
   - Header with navigation links

### 2.2 Subsequent Launches

    flowchart TB
        Launch[App Launch] --> LoadAuth[Load Stored Token]
        LoadAuth --> Validate{Token Valid?}
        Validate -->|Yes| RestoreState[Restore Last Screen]
        Validate -->|No| LoginScreen[Login Screen<br/>/login]

        RestoreState --> CheckPlayer{Was Player<br/>Active?}
        CheckPlayer -->|Yes| ShowMiniPlayer[Show MiniPlayer<br/>at bottom]
        CheckPlayer -->|No| HideMiniPlayer[No MiniPlayer]

        ShowMiniPlayer --> LastScreen[Last Active Screen<br/>with MiniPlayer]
        HideMiniPlayer --> HomePage[Home Page<br/>/]

State Restoration:

- Last viewed screen (route) is restored (defaults to `/` if none)
- If audio was playing, MiniPlayer appears at bottom
- Playback state is NOT automatically resumed (user must press play)
- Queue is restored if it existed

## 3. Audio Playback Flows

### 3.1 Starting Audio Playback

    flowchart TB
        Start[User Action] --> Action{Action Type?}

        Action -->|Click Track| TrackList[TrackList Component]
        Action -->|Click Album| AlbumDetail[Album Detail Page]
        Action -->|Click Play on Album| AlbumPlay[Play Album Button]

        TrackList --> PlayTrack[Play Single Track]
        PlayTrack --> QueueAll[Queue All Filtered Tracks]

        AlbumPlay --> PlayAlbum[Play All Album Tracks]
        PlayAlbum --> QueueAlbum[Queue Album Tracks]

        QueueAll --> InvokePlay[invoke player_play_queue]
        QueueAlbum --> InvokePlay

        InvokePlay --> PlayerStarts[Player State: Playing]
        PlayerStarts --> MiniAppears[MiniPlayer Slides Up<br/>from Bottom]

        MiniAppears --> StayOnPage[User Stays on<br/>Current Screen]

Entry Points for Audio Playback:

1. TrackList (`/library/music/tracks`, `/library/music/albums/[id]`)

   - Click track number → Play track + queue all visible tracks
   - Clicking track #3 in an album → Play track 3, queue tracks 1-10

2. Album Card (grid views)

   - Click album → Navigate to album detail
   - Play button on card → Play album immediately

3. Search Results

   - Click track → Play track + queue search results
   - Click album → Navigate to album detail

MiniPlayer Behavior:

- Slides up from bottom with animation (300ms)
- Height: 64px on mobile, 80px on desktop
- Shows: artwork, title, artist, play/pause, next, favorite
- Stays visible on ALL screens (except video player)
- Click anywhere on MiniPlayer → Navigate to full player

Track Highlighting: When audio is playing, the currently playing track is visually highlighted in track lists and album pages:

- Subtle blue background tint
- Left border accent in Jellyfin blue
- Title text colored in Jellyfin blue
- Desktop: Animated pulsing dots indicator next to title
- Mobile: Play arrow (▶) inline with title
- Highlight updates automatically when skipping to next/previous track

### 3.2 MiniPlayer → Full Player Transition

    flowchart TB
        Mini[MiniPlayer Visible] --> UserClick{User Action}

        UserClick -->|Click MiniPlayer| NavFullPlayer[Navigate to<br/>/player/[id]]
        UserClick -->|Swipe Up| SwipeGesture[Swipe Gesture<br/>Planned]

        NavFullPlayer --> FullPlayer[Full Audio Player Screen]
        SwipeGesture --> FullPlayer

        FullPlayer --> ShowControls[Show Full Controls:<br/>- Large artwork<br/>- Progress bar<br/>- Volume slider<br/>- Queue button<br/>- Shuffle/Repeat<br/>- Favorite button]

        ShowControls --> MiniHidden[MiniPlayer Hidden]

Full Player Screen (`/player/[id]`)

- Header: Song title, artist (clickable links to artist/album pages)
- Artwork: Large album art (centered, dominant)
- Progress: Seek bar with current time / total duration
- Controls: Previous, Play/Pause, Next (large touch targets)
- Secondary Controls: Shuffle, Repeat mode, Queue, Favorite
- Volume: Volume slider
- Bottom Nav: Still visible (can navigate away while playing)
- Back button: Returns to previous screen, MiniPlayer reappears

### 3.3 Full Player → Back to Browsing

    flowchart TB
        FullPlayer[Full Player Screen] --> UserAction{User Action}

        UserAction -->|Back Button / Close| HistoryBack[window.history.back]
        UserAction -->|Bottom Nav Click| NavOther[Navigate to<br/>Other Screen]

        HistoryBack --> PrevScreen[Return to Previous Screen<br/>in Browser History]
        NavOther --> NewScreen[Navigate to New Screen]

        PrevScreen --> MiniReappears[MiniPlayer Slides Up<br/>from Bottom]
        NewScreen --> MiniReappears

        MiniReappears --> PlaybackContinues[Playback Continues<br/>in Background]

Navigation Behavior:

- Back Button: Uses browser history (`window.history.back()`) to return to the previous page
- Expected behavior: Returns user to the screen they were on before opening full player
- Example: User browsing album → clicks track → full player opens → clicks back → returns to album

Key UX Principles:

- Playback Never Stops: Navigating away from player does NOT stop playback
- MiniPlayer Persistence: MiniPlayer visible on ALL screens (except video/login)
- Queue Preserved: Current queue remains intact
- State Restoration: Returning to full player shows same state (position, volume, etc.)
- Natural Navigation: Back button behaves as expected (returns to previous page, not just closes modal)

## 4. Video Playback Flows

### 4.1 Starting Video Playback

    flowchart TB
        Start[User Action] --> Action{Action Type?}

        Action -->|Click Movie| MovieDetail[Movie Detail Page]
        Action -->|Click Episode| EpisodeClick[Episode Click]
        Action -->|Click Play Button| PlayButton[Play Button]

        MovieDetail --> PlayMovie[Play Movie Button]
        EpisodeClick --> PlayEpisode[Play Episode]

        PlayMovie --> CheckResume{Resume<br/>Position?}
        PlayEpisode --> CheckResume

        CheckResume -->|Yes, >30s| ShowDialog[Resume Dialog]
        CheckResume -->|No| DirectPlay[Start from Beginning]

        ShowDialog --> UserChoice{User Choice}
        UserChoice -->|Resume| ResumePlay[Start at Saved Position]
        UserChoice -->|Start Over| DirectPlay

        ResumePlay --> FullscreenVideo[Fullscreen Video Player<br/>/player/[id]]
        DirectPlay --> FullscreenVideo

        FullscreenVideo --> HideUI[Hide All UI:<br/>- No Bottom Nav<br/>- No MiniPlayer<br/>- Fullscreen only]

Resume Dialog:

    ┌─────────────────────────────────────────┐
    │  Continue Watching?                     │
    │                                         │
    │  [Movie Title]                          │
    │  Resume from 12:34 / 1:45:00           │
    │                                         │
    │  [Start from Beginning]  [Resume]      │
    └─────────────────────────────────────────┘

### 4.2 Video Player Screen (IR-003, IR-004, UR-003)

Initial State (First 3 seconds):

- Controls visible overlay
- Top bar: Back button, title
- Bottom bar: Play/Pause, seek bar, time, settings (subtitles, audio track)
- Center: Large play/pause button

After 3 Seconds (Idle):

- All controls fade out (500ms animation)
- Fullscreen video only
- System UI hidden (status bar, nav bar)

User Interaction:

- Tap screen: Controls reappear for 3 seconds
- Double tap left side: Rewind 10 seconds (shows animated feedback with "-10" indicator)
- Double tap right side: Forward 30 seconds (shows animated feedback with "+30" indicator)
- Single tap play/pause is deferred by the 300 ms double-tap window, so a double tap skips without also toggling pause (UR-061)
- Swipe up/down on left side: Adjust brightness (0.3-1.7x, shows brightness indicator with progress bar)
- Swipe up/down on right side: Adjust volume (0-100%, shows volume indicator with progress bar)
- Keyboard arrows: ← rewind 10s, → forward 30s (desktop/external keyboard)
- Keyboard space/K: Toggle play/pause
- Keyboard F: Toggle fullscreen
- Pinch: Zoom (planned)

### 4.3 Exiting Video Player

    flowchart TB
        VideoPlaying[Video Playing] --> UserAction{User Action}

        UserAction -->|Back Button| StopVideo[Stop Playback]
        UserAction -->|Home Button| Background[App to Background]
        UserAction -->|Video Ends| VideoEnd[Playback Ended]

        StopVideo --> SaveProgress[Save Progress<br/>to Local DB + Server]
        VideoEnd --> SaveComplete[Mark as Watched<br/>Save Progress]
        Background --> PauseVideo[Pause Video]

        SaveProgress --> ExitFullscreen[Exit Fullscreen]
        SaveComplete --> AutoNext{Next Episode<br/>Available?}

        AutoNext -->|Yes| ShowCountdown[Show Countdown<br/>Next in 5s...]
        AutoNext -->|No| ExitFullscreen

        ShowCountdown --> UserCancel{User Cancels?}
        UserCancel -->|Yes| ExitFullscreen
        UserCancel -->|No, timeout| PlayNext[Play Next Episode]

        ExitFullscreen --> RestoreUI[Restore UI:<br/>- Bottom Nav<br/>- Previous Screen]

        PlayNext --> VideoPlaying

        PauseVideo --> ShowNotification[Show Notification:<br/>Tap to Resume]

Auto-Next Overlay:

    ┌─────────────────────────────────────────┐
    │                                         │
    │         [Episode Thumbnail]             │
    │                                         │
    │  Next: S01E02 - Episode Title          │
    │  Starting in 5 seconds...              │
    │                                         │
    │  [Cancel]              [Play Now]      │
    └─────────────────────────────────────────┘

## 5. Music Library Navigation Flows

### 5.1 Music Category Landing Page

    flowchart TB
        LibraryHome[Library Home<br/>/library] --> ClickMusic[Click Music Library]

        ClickMusic --> MusicLanding[Music Landing Page<br/>/library/music]

        MusicLanding --> ShowCategories[Show Category Cards:<br/>- Tracks<br/>- Artists<br/>- Albums<br/>- Playlists<br/>- Genres]

        ShowCategories --> UserClick{User Clicks Category}

        UserClick -->|Tracks| TracksPage[All Tracks Page<br/>/library/music/tracks]
        UserClick -->|Artists| ArtistsPage[Artists Grid<br/>/library/music/artists]
        UserClick -->|Albums| AlbumsPage[Albums Grid<br/>/library/music/albums]
        UserClick -->|Playlists| PlaylistsPage[Playlists Grid<br/>/library/music/playlists]
        UserClick -->|Genres| GenresPage[Genres Browser<br/>/library/music/genres]

Category Cards:

    ┌─────────────────────────────────────────┐
    │  ┌──────┐  ┌──────┐  ┌──────┐          │
    │  │ 🎵  │  │ 👤  │  │ 💿  │          │
    │  │Track│  │Artist│  │Album│          │
    │  └──────┘  └──────┘  └──────┘          │
    │  ┌──────┐  ┌──────┐                    │
    │  │ 📝  │  │ 🎭  │                    │
    │  │List │  │Genre│                    │
    │  └──────┘  └──────┘                    │
    └─────────────────────────────────────────┘

### 5.2 Albums View Flow

    flowchart TB
        AlbumsGrid[Albums Grid<br/>grid/list per §5A] --> UserAction{User Action}

        UserAction -->|Click Album| AlbumDetail[Album Detail Page<br/>/library/[id]]
        UserAction -->|Click Play on Card| PlayAlbum[Play Album Immediately]

        AlbumDetail --> ShowAlbum[Show Album:<br/>- Album Art<br/>- Title, Artist<br/>- Track List<br/>- Download Button<br/>- Favorite Button]

        ShowAlbum --> TrackAction{User Action}

        TrackAction -->|Click Track| PlayTrack[Play Track + Queue Album]
        TrackAction -->|Click Artist| NavArtist[Navigate to Artist Page]
        TrackAction -->|Download Album| DownloadFlow[Download Flow]
        TrackAction -->|Back Button| BackToGrid[Return to Albums Grid]

Album Detail Layout:

    ┌─────────────────────────────────────────┐
    │  [←]              [♡] [⬇]               │
    │                                         │
    │      ┌────────────────────┐             │
    │      │                    │             │
    │      │   Album Artwork    │             │
    │      │                    │             │
    │      └────────────────────┘             │
    │                                         │
    │  Album Title                            │
    │  Artist Name (clickable)                │
    │  2024 • 12 tracks • 45:23              │
    │                                         │
    │  [▶ Play]  [🔀 Shuffle]                │
    │                                         │
    │  ─────────────────────────────────────  │
    │  1  Track Title               3:45     │
    │  2  Track Title               4:12     │
    │  3  Track Title               3:28     │
    │  ...                                    │
    └─────────────────────────────────────────┘

### 5.3 Artist Navigation

    flowchart TB
        ArtistsGrid[Artists Grid] --> ClickArtist[Click Artist]

        ClickArtist --> ArtistPage[Artist Detail Page<br/>/library/artist/[id]]

        ArtistPage --> ShowContent[Show Artist Content:<br/>- Artist Photo<br/>- Biography<br/>- Albums Grid<br/>- Top Tracks<br/>- Similar Artists]

        ShowContent --> UserAction{User Action}

        UserAction -->|Click Album| AlbumDetail[Album Detail Page]
        UserAction -->|Play Top Tracks| PlayArtist[Play Artist Radio]
        UserAction -->|Click Similar Artist| OtherArtist[Other Artist Page]

## 5A. Library Page Layouts

Every browse page is one of two shapes: a card grid or a row list. This section is the rule for which shape a page takes, what a card looks like, and what the user is allowed to change.

### 5A.1 Card shape follows the media, not the page

Card aspect ratio is a property of what the item is, and is never overridden per-page. This is the single most important layout rule: a user scanning a grid recognises content type by silhouette before reading a word.

Item type | Aspect | Rationale
--- | --- | ---
Album, Artist, Track, Playlist | 1:1 square | Matches album art; the universal music convention (Spotify)
Movie, Series, Season | 2:3 poster | Matches printed poster art; the universal video convention (Netflix)
Episode | 16:9 thumbnail | A frame from the episode, not cover art — signals "a thing you watch next"
Library / collection folder | 16:9 | Reads as a container, distinct from the items inside it

Artist cards are square but rendered circular-masked, so artists are distinguishable from albums at a glance within the same music grid.

### 5A.2 Grid vs. list

    flowchart TB
        Page[Library browse page] --> Kind{Content kind}

        Kind -->|Visual-first<br/>albums, artists, movies,<br/>shows, playlists| Grid[Card grid<br/>user may switch to list]
        Kind -->|Ordinal<br/>tracks in an album,<br/>episodes in a season| List[Row list<br/>always; no toggle]

        Grid --> Toggle[View toggle in page header]
        Toggle --> Persist[Choice persists globally<br/>across all grid pages]

- Grids are the default for anything with cover art worth scanning.
- Lists are mandatory, not optional, where position carries meaning — a track's number within an album, an episode's number within a season. A grid destroys that ordering cue, so these pages expose no toggle.
- The toggle is global, not per-page. A user who prefers dense lists prefers them everywhere; making them re-set it on each page is friction. The choice persists across launches.

Responsive columns (grid mode), tuned so cards stay large enough to read cover art on a phone and don't become postage stamps on a desktop:

Breakpoint | Columns
--- | ---
base (phone) | 2
sm | 3
md | 4
lg | 5
xl | 6

### 5A.3 What a card shows

       ┌─────────────┐
       │             │   ← cover art (aspect per §5A.1)
       │   artwork   │     • progress bar overlay if partially played
       │             │     • watched/played check if complete
       │        [▶]  │     • play affordance on hover/focus
       └─────────────┘
       Primary line       ← title, truncated to one line
       Secondary line     ← artist / year+rating / SxEy — one line, dimmed

- Two lines of text maximum. Titles truncate rather than wrap; a card that grows to fit its title breaks grid alignment and makes scanning harder.
- Progress and watched state live on the artwork, not in the text — they must be readable while scanning, without reading.
- Hover/focus reveals play, so a card is both a navigation target and a playback target without a second control competing for space at rest.

### 5A.4 Known deviations

These are places the implementation currently diverges from the rules above. They are recorded here so the gap is explicit rather than mistaken for intent.

- The view toggle is discoverable only on a browse page. The preference is already global and persisted, but the only control that sets it is the pair of icon buttons in a library page header. Settings has no display section, so there is nowhere to look for it. (UR-029)

## 5B. Video Detail Page Composition

Movie, Series, and Episode detail pages all live at `/library/[id]`. Which surface renders is decided by item type plus the `?episode=` query param, and section order is part of the spec — it is what makes "keep watching this show" the path of least resistance.

### 5B.1 Which surface renders

    flowchart TB
        Nav[Navigate to /library/&#91;id&#93;] --> Type{Item type}

        Type -->|Person| Person[PersonDetailView]
        Type -->|Movie| Movie[Movie detail<br/>§5B.3]
        Type -->|Series| Ep{?episode= param<br/>present?}

        Ep -->|Yes| Focus[Episode Focus View<br/>§5B.2]
        Ep -->|No| Series[Series detail<br/>§5B.4]

        Focus -->|Back to series| Series
        Series -->|Click episode| Focus

An episode is never browsed as a bare `Episode` item page. Clicking an episode anywhere — a series' season list, a Home carousel (§5B.5), etc. — navigates to `/library/<seriesId>?episode=<episodeId>`, so the episode is always shown in the context of its series and the series' full episode list is already loaded. Should an episode ever arrive without a `seriesId` (deep link, stale cache), the bare Episode page renders as a fallback and links back to its parent series and season by title so the user is never stranded.

### 5B.2 Episode Focus View — section order

The next episodes appear directly below the current episode, above cast and similar shows. Nothing may be inserted between the episode hero and the episode strip.

    ┌─────────────────────────────────────────────────┐
    │ [←]                                             │
    │   ┌───────────────────────────────────────────┐ │
    │   │            episode backdrop               │ │
    │   │  Series Name                              │ │  ← 1. HERO
    │   │  Episode Title                            │ │
    │   │  S2E4 • 48m • ★8.1                        │ │
    │   │  Overview…                                │ │
    │   │  ▓▓▓▓▓░░░░░  32m left                     │ │
    │   │  [▶ Play]  [⬇]  [♡]                       │ │
    │   └───────────────────────────────────────────┘ │
    │                                                 │
    │  More Episodes                                  │  ← 2. EPISODE STRIP
    │  ┌──────┐┌──────┐┌──────┐┌──────┐               │     (immediately below hero)
    │  │ E3   ││▓E4▓  ││ E5   ││ E6   │  →  scroll    │
    │  │      ││NOW   ││      ││      │               │
    │  └──────┘└──────┘└──────┘└──────┘               │
    │                                                 │
    │  Cast                                           │  ← 3. CAST
    │  ( ○ )( ○ )( ○ )( ○ )                           │
    │                                                 │
    │  More Like This                                 │  ← 4. SIMILAR
    │  ┌────┐┌────┐┌────┐┌────┐                       │
    └─────────────────────────────────────────────────┘

Rules for the episode strip:

- Position is fixed. Hero → episode strip → cast → similar. The strip sits between the current episode and every other section; cast and related content are below it, never above.
- Window, not full list. The strip shows a window around the current episode — roughly 3 before and 6 after — so the immediate next episodes are visible without scrolling, and earlier ones remain reachable by scrolling left. It is horizontally scrollable, not a wrapped grid.
- Forward bias. More episodes are shown after the current one than before it: the dominant intent on this screen is "watch the next one."
- The current episode is present and marked. It renders in-strip with a "NOW" badge and a highlight ring, and is not clickable. It anchors the user's position in the season rather than being hidden.
- Cross-season continuity. The window spans the whole series in episode order, so the strip runs past a season boundary into the next season's first episodes rather than dead-ending at the end of a season.
- Per-episode state. Each card shows a thumbnail, `SxEy` + title, a resume progress bar when partially watched, and a watched checkmark when complete.
- Clicking an episode swaps focus in place (`?episode=` changes); it does not start playback. Playback starts only from the hero's Play button.

### 5B.3 Movie detail — section order

    Hero (poster, title, metadata, Play / Download / Favorite)
      → Crew links (Directed by / Written by / Music by)
      → Genre tags
      → Cast
      → More Like This

A movie has no continuation set, so cast follows the hero directly.

### 5B.4 Series detail — section order

    Hero (poster, title, metadata, Resume SxEy / Download / Favorite / Clear history)
      → Crew links
      → Genre tags
      → Seasons (collapsible; only the current season expanded)
      → Cast
      → More Like This

The same principle as §5B.2: episodes come before cast and similar shows. The reason a user opens a series page is to pick an episode; discovery content is secondary and sits underneath.

Rules for the seasons block (UR-062, UR-064):

- The page opens where the viewer is. The backend resolves the current episode — in progress, else Next Up, else first unwatched, else the premiere — and the page scrolls it into view with an `Up next` badge and a highlight ring. Never season 1 by default, unless season 1 is where the viewer is.
- Seasons collapse; only the current one is expanded. A ten-season show otherwise renders hundreds of rows and buries the episode the viewer came for. A collapsed season still names its episode count and watched count, so progress is readable without expanding it.
- The hero button opens, it does not play. It reads `Resume S2E4` / `Play S1E1` — naming its target — and navigates to that episode's Focus View, where Play commits. Play on a container is navigation (§5B.5); Play on a leaf is the commitment.
- A season is never its own page. `/library/<seasonId>` redirects to `/library/<seriesId>#season-N`. Every affordance that names a season — the episode breadcrumb, a season card in a grid, a Downloads drill-in — lands on the series with that season in view, so the episodes of all seasons stay one browsable list.
- Watch history is erasable per series (hero) and per season (season header). It confirms first, cannot be undone, and needs the server. Clearing a whole series returns it to S1E1 by the same path a never-watched show takes.

### 5B.5 Home-card interaction — tap opens, long-press plays

Cards on the Home screen carousels (Next Movie, Next Episode, Continue Watching, Recently Added, …) do not play on tap. A plain tap opens the item; playback is the deliberate, second gesture.

Card kind | Tap (short) | Long-press (~500 ms hold)
--- | --- | ---
Movie | Movie detail page (`/library/<id>`) | Confirm → play now (`/player/<id>`)
Episode | Series Episode Focus View (`/library/<seriesId>?episode=<id>`, per §5B.1) | Confirm → play now (`/player/<id>`)
Series / Season / Album / Artist / Playlist / Folder | Detail page (`/library/<id>`) | Same as tap (no single "play now" target)
Channel / live leaf | Player (`/player/<id>`) — no detail page exists | Confirm → play now

Rationale and rules:

- Tap is navigation, not commitment. Previously a tap on a movie/episode jumped straight into the player, which made it easy to lose your place in a half-watched item or start a stream you only meant to inspect. Tap now lands on the detail/focus page, where Play is an explicit button.
- Long-press is the shortcut for "just play it." It surfaces a native confirm (`Play "<name>" now?`) before starting playback, so an accidental hold never blows away a resume position silently.
- The long-press must not fight the carousel. Detection cancels if the pointer moves more than ~10 px (a horizontal scroll of the row), so holding to scroll never triggers play.
- Episodes still obey §5B.1 — a home tap on an episode opens the series Focus View, never a bare Episode page, so the series context loads.

This behavior lives in `MediaCard` (`onLongPress` prop + pointer-based detection) so any surface can opt in; today the Home carousels are the only opt-in. Grids and other surfaces keep tap-to-open with no long-press.

## 5C. Favourites

Favouriting is a two-sided promise: the heart takes the input, and the app must be able to give it back. This section covers both sides — where you can mark a favourite, and where marked favourites resurface.

See architecture/01-rust-backend.md for the layer assignment and wire shapes.

### 5C.1 The heart appears wherever an item does

A favourite is a property of an item, so the affordance follows the item rather than living on one privileged screen. Any surface that shows a whole item shows its heart.

Surface | Heart position | Notes
--- | --- | ---
Movie / Series detail hero | In the button row, after Play and Download | §5B.3, §5B.4
Episode Focus View hero | Same row as Play / Download | §5B.2
Album, Artist, Playlist detail | In the header button row | §5.2
Media card (any grid or carousel) | Top-right overlay on the artwork | Hidden on server-only (greyed) cards
Mini player | Right of the track metadata | Existing behaviour, unchanged
Full player | Secondary controls row | §3.2 — not yet built, see §5C.5

Rules:

- The heart never competes with the card. On a media card it is its own button and swallows the tap, so hearting an item never also opens or plays it, and never triggers the §5B.5 long-press.
- State is shown, not guessed. A filled heart means the server considers the item a favourite (or you just tapped it). An item favourited in Jellyfin Web, on another device, or by another client renders filled here without being touched in JellyTau.
- Feedback is immediate. The heart fills on tap and a toast confirms; neither waits for the server round-trip.

### 5C.2 Three ways back to what you favourited

Favourites are not one destination — they are a lens, and the right surface depends on whether the user is browsing, deciding, or hunting.

    flowchart TB
        User[User wants their favourites] --> How{Intent}

        How -->|Passive: show me something| Home[Home carousels<br/>Favourite Movies / Shows / Music]
        How -->|Deliberate: my whole collection| Page[Favourites page<br/>/library/favorites]
        How -->|Narrowing: within this library| Filter[Favourites filter<br/>on a library page]

        Home -->|See all| Page
        Page --> Detail[Item detail page]
        Filter --> Detail

Home carousels. Rows for favourite movies, shows and music sit below Recently Added. A row with nothing in it does not render — a fresh install shows no empty favourite rows. Each row ends with See all, landing on the matching tab of the Favourites page.

The Favourites page (`/library/favorites`) is the complete collection, scoped by tabs:

    ┌─────────────────────────────────────────────────┐
    │ [←]  Favourites                                 │
    │  ┌─────┬────────┬───────┬───────┐               │
    │  │ All │ Movies │ Shows │ Music │  ← scope tabs │
    │  └─────┴────────┴───────┴───────┘               │
    │                                                 │
    │  ┌────┐┌────┐┌────┐┌────┐┌────┐                 │
    │  │ ♥  ││ ♥  ││ ♥  ││ ♥  ││ ♥  │   grid/list    │
    │  └────┘└────┘└────┘└────┘└────┘   per §5A      │
    └─────────────────────────────────────────────────┘

- Cards obey §5A in full — shape follows the media, so a mixed All tab reads as posters, squares and thumbnails side by side rather than one forced shape.
- Reached from a card on the library overview (`/library`) and from See all on any home favourites row.
- Sorted by name. Jellyfin does not record when an item was favourited, so "recently favourited" is not offerable — see §5C.5.
- Empty state, per tab: "Nothing favourited yet — tap the heart on anything you like."

The in-library filter is for narrowing where the user already is: a favourites toggle in the header of the Movies, TV and Music browse pages, filtering the current list in place. It is session-scoped and not persisted — a sticky filter that silently hides most of a library reads as data loss on the next launch.

### 5C.3 Removing a favourite removes it everywhere, at once

Un-hearting an item on the Favourites page removes its card from the grid immediately; the same item disappears from the home rows and shows an empty heart on its detail page without a manual refresh. The reverse holds for favouriting. There is no confirmation prompt — the action is one tap to undo.

### 5C.4 Offline

- Marking works offline. The heart fills, the toast confirms, and the change is held locally.
- It reaches the server on reconnect, without the user returning to the screen where they made it.
- Browsing offline shows favourites among media on the device, subject to the same "Show all server media" gate as every other browse surface (§7.2) — with the gate off, an empty Favourites tab means nothing favourited is downloaded, and the page does not quietly fall back to the server catalog.

### 5C.5 Known deviations

- The full player has no heart. §3.2 and §3.3 list a Favorite button among the full player's secondary controls; it was never built, and this pass does not add it. The mini player heart above it is the only in-player affordance. (UR-067)
- No "recently favourited" sort. Jellyfin's API does not expose a favourite timestamp, so favourites can only be ordered by name. Recording the timestamp locally at toggle time would order this device's favourites only, which is worse than a consistent name sort.
- Music is one tab, not three. The Music scope mixes albums, artists and tracks in a single grid rather than offering sub-tabs. Acceptable while favourite counts are small; revisit if the tab becomes unscannable.

## 6. Search Flow

Search is context-scoped: what you are looking at when you start a search determines what the search covers. A search begun inside the Music library searches music. A search begun from Home or the top-level library page searches everything. The scope is always shown, and always overridable.

### 6.1 Scope is inherited from context

    flowchart TB
        Start[User starts a search] --> Where{Where from?}

        Where -->|Home &#40;/&#41;| All[Scope: All]
        Where -->|Library root &#40;/library&#41;| All
        Where -->|Search tab| All
        Where -->|Inside Music| Music[Scope: Music]
        Where -->|Inside Movies| Movies[Scope: Movies]
        Where -->|Inside TV| TV[Scope: TV]

        All --> Chips[Filter chips shown<br/>All chip selected]
        Music --> Chips2[Filter chips shown<br/>Music chip preselected]
        Movies --> Chips2
        TV --> Chips2

        Chips --> Results[Results, grouped by type]
        Chips2 --> Results

        Results --> Change{User taps a chip}
        Change --> Rescope[Re-run search at new scope<br/>query preserved]
        Rescope --> Results

Rules:

- Context sets the initial chip, never a locked filter. Entering search from TV preselects the TV chip; the user can tap "All" to widen without retyping the query. Scope is a starting point, not a cage.

- Home, `/library`, and the search tab all start at "All". These are the places a user has expressed no narrower intent.

- Changing scope preserves the query and re-runs the search. Changing the query preserves the scope.

- Scope maps to item types, resolved at the point of search:

  Chip | `includeItemTypes`
  --- | ---
  All | (unset — every type)
  Music | `MusicAlbum`, `MusicArtist`, `Audio`, `Playlist`
  Movies | `Movie`
  TV | `Series`, `Episode`

- Chips render under the search bar, on both the dedicated search page and the in-library header search. They are horizontally scrollable if they overflow, never wrapped onto a second row.

### 6.2 Search page layout

    ┌─────────────────────────────────────────┐
    │  [🔍 Search...]                  [✕]    │
    │                                         │
    │  ( All ) (•Music•) ( Movies ) ( TV )    │  ← scope chips
    │                                         │
    │  Songs ────────────────────────────     │
    │  ♪ Song Title - Artist          3:45    │
    │  ♪ Song Title - Artist          4:12    │
    │  See all (23)                           │
    │                                         │
    │  Albums ───────────────────────────     │
    │  [Cover] Album Title                    │
    │  See all (8)                            │
    │                                         │
    │  Artists ──────────────────────────     │
    │  ( Photo ) Artist Name                  │
    │  See all (5)                            │
    └─────────────────────────────────────────┘

- Results stay grouped by type even when a scope is selected — a Music search still separates Songs / Albums / Artists.
- Each group shows a bounded preview with a See all (n) affordance rather than an unbounded list, so no single type can bury the others.
- Live search is debounced as the user types; a query that becomes empty clears results rather than searching for the empty string.

### 6.3 Result group order is user-configurable

Which kind of thing a user is usually searching for is personal: a music-first user wants Songs at the top, a TV-first user wants Shows. Rather than guessing, the group order is a setting.

    flowchart TB
        Settings[Settings → Search] --> List[Draggable list of result groups]

        List --> Drag[User drags a group up or down]
        Drag --> Persist[Order persisted]

        Persist --> Render[Rendering a result set]
        Scope[Active scope chip §6.1] --> Render

        Render --> Filter[1 - Drop groups outside the active scope]
        Filter --> Sort[2 - Sort remaining groups by user order]
        Sort --> Prune[3 - Omit groups with no results]
        Prune --> Show[Render]

Scope and order compose — they are two independent axes. The scope chip decides which groups are eligible; the settings list decides what sequence the eligible ones appear in. Order is preserved as a relative ranking, never renumbered per scope:

- Scope Music with order `Movies → Songs → Albums → Artists → TV` renders `Songs → Albums → Artists`. Movies and TV are filtered out; the surviving groups keep their relative order.
- Scope All with the same setting renders all five in exactly that order.
- Changing scope never rewrites the saved order. A user who narrows to Music and back to All sees their original arrangement intact.

Rules:

- Drag and drop to reorder, in a settings list showing every result group (Songs, Albums, Artists, Movies, TV Shows).
- The order applies to grouped results everywhere — the search page and the in-library header search alike.
- Order is presentation-only. It never changes which results are returned or how they are ranked within a group, only the sequence groups appear in.
- Empty groups are skipped, not gapped. A group with no results is omitted entirely; it does not reserve space or leave a stray heading.
- A sensible default ships (Songs → Albums → Artists → Movies → TV Shows) so the setting is an adjustment, never a prerequisite.
- Keyboard/accessible reordering must exist alongside dragging — a drag-only control is unusable with a screen reader or without a pointer.

### 6.4 Known deviations

Recorded so the gap between this spec and the build is explicit.

- Scope is not implemented. The in-library header search calls the same unscoped query as the global search page, so searching inside TV returns music. The backend already accepts `includeItemTypes` on both the online and offline paths, and the per-page list search already uses it — only the global path ignores it. (UR-049)
- Filter chips do not exist on either search surface. (UR-049)
- Group order is hardcoded to Music → Movies → TV in the results markup, with no setting. (UR-050)

## 7. Download Flows

### 7.1 Initiating Downloads

    flowchart TB
        User[User on Album/Track Page] --> ClickDownload[Click Download Button]

        ClickDownload --> CheckType{Download Type?}

        CheckType -->|Single Track| DownloadTrack[Download Single File]
        CheckType -->|Album| DownloadAlbum[Download All Tracks]
        CheckType -->|Artist| ShowOptions[Show Options Dialog]

        ShowOptions --> UserChoice{User Choice}
        UserChoice -->|Discography| DownloadAll[Download All Albums]
        UserChoice -->|Select Albums| AlbumPicker[Album Selection UI]

        DownloadTrack --> QueueDownload[Queue in Download Manager]
        DownloadAlbum --> QueueMultiple[Queue Multiple Files]

        QueueDownload --> ShowProgress[Show Progress Ring<br/>on Download Button]
        QueueMultiple --> ShowProgress

        ShowProgress --> DownloadActive[Download Active:<br/>Button shows % complete]

Download Button States:

    States:
    1. [⬇] Available     - Gray outline
    2. [○ 45%] Downloading - Blue ring progress
    3. [✓] Downloaded    - Green checkmark
    4. [!] Failed        - Red with retry option
    5. [⏸] Paused        - Yellow pause icon

### 7.2 Downloads = a browsable offline library, not a flat list

The central idea: "my downloads" is not a list of file-transfer rows — it is the library, filtered to what's on the device. A user who has downloaded three seasons of a show and two albums thinks in terms of shows and albums, not seventy-odd individual episode/track transfers. So the primary Downloads surface reuses the library browse screens, scoped to downloaded content, and keeps the transfer-progress list as a secondary "Transfers" view for the act of downloading.

This splits one overloaded page into two clear jobs:

Surface | Answers | Reuses
--- | --- | ---
Downloaded (browse) | "What do I have offline, and let me play it" | Library grids, detail pages, cards (§5A)
Transfers (activity) | "What is downloading right now, and control it" | The existing progress-row list

    flowchart TB
        Nav[Open Downloads] --> Downloads[/downloads]

        Downloads --> View{View}
        View -->|Downloaded &#40;default&#41;| Browse[Offline library browse]
        View -->|Transfers| Activity[Transfer activity list]

        Browse --> Libs[Libraries — only those with<br/>downloaded content]
        Libs --> Grid[Library grid, offline-scoped<br/>same cards/layout as online §5A]
        Grid --> Detail[Detail page<br/>same as online]
        Detail --> Play[Play from local file]
        Detail --> Remove[Remove download<br/>frees space, keeps browsable? — see rules]

        Activity --> Rows[Per-transfer rows:<br/>downloading / queued / paused / failed /<br/>waiting-for-WiFi]
        Rows --> Ctl[Pause / Resume / Cancel / Retry]

Why reuse the library screens (not a bespoke list):

- One mental model. Browsing offline should feel identical to browsing online — same grids, same card shapes, same detail pages, same play action. The only difference is what's present, not how it looks.
- It already works in the backend. The offline repository's `get_items` already returns downloaded items plus their containers (an album with any downloaded track, a series/season with any downloaded episode). That is a browsable tree today — see §7.4.
- It scales. A flat completed-list becomes unusable at a few dozen items; a browsable library does not.

### 7.3 The Downloaded browse surface

    ┌─────────────────────────────────────────────┐
    │  Downloads                                  │
    │  ( Downloaded )  ( Transfers )     ← view switch
    │                                             │
    │  [~ 3.4 GB on device · 12 items]  Manage ▸  │  ← storage summary
    │                                             │
    │  Music                                      │  ← only libraries that
    │  ┌────┐┌────┐┌────┐                          │     have downloaded content
    │  │alb ││alb ││art │                          │
    │  └────┘└────┘└────┘                          │
    │                                             │
    │  TV                                         │
    │  ┌────┐┌────┐                                │
    │  │show││show│                                │
    │  └────┘└────┘                                │
    └─────────────────────────────────────────────┘

Rules:

- Libraries with nothing downloaded are omitted, not shown empty. If only music is downloaded, only Music appears.
- Cards, grids, and detail pages are the library's own (§5A) — offline browse is the same components with an offline-scoped data source, never a parallel re-implementation.
- A downloaded badge / "on device" affordance distinguishes fully-downloaded from partially-downloaded containers (e.g. a season with 6 of 10 episodes).
- Disk usage is shown where the user already looks, in familiar units — see §7.3.1.
- Play always plays the local file here; nothing on this surface streams.
- Remove is available at every level — item, album/season, series — and states clearly what it frees. Removing the last downloaded child of a container removes the container from the browse.
- This surface works identically online and offline. It is "what's on the device," a question whose answer does not depend on connectivity. It must not wait for, or be emptied by, server reachability.

#### 7.3.1 Disk usage — familiar, in place, not a separate audit

Users want to know what each thing costs on disk, but that information has to feel like the storage views they already know (phone Settings → Storage, a file browser), not a developer's byte dump.

- Size rides along with the item, on the card and the detail page — a small secondary label (`1.2 GB`, `340 MB`, `48 MB`), never a separate "storage report" screen the user has to go find.
- Containers show their total. A series shows the sum of its downloaded episodes; an album the sum of its tracks; a season its own subtotal. The number a user sees on the "Breaking Bad" card is what removing it frees.
- Human units, rounded, consistent. Binary or decimal is a choice — pick one and use it everywhere. Show 2–3 significant figures (`1.2 GB`, not `1,283,048,192 bytes` and not `1.28394 GB`).
- A single device total sits at the top of the Downloaded surface (`3.4 GB on device · 12 items`) so the headline number is answered before the user scans. It reconciles with the sum of what's listed.
- Remove restates the reclaim in the same units at the point of action ("Remove download · frees 1.2 GB"), so the cost of keeping vs. freeing is legible exactly when the user decides.
- Sort/filter by size is a reasonable enhancement ("biggest first" to find what to clear) but is not required for v1.

The bytes-on-disk per item are a backend fact (the download manager writes the files and can stat them); this is a display and aggregation task, not new tracking. See §7.7 deviations for what's missing today.

### 7.4 Transfers (activity) view

The existing progress-row list, unchanged in spirit, demoted to a secondary tab. It is about transfers in flight, so it shows only rows that are doing or waiting to do something:

- States: downloading (with progress), queued, paused, failed, waiting-for-WiFi (§7.5).
- Controls: Pause / Resume / Cancel / Retry per row; the 3-concurrent cap and auto-pump are backend concerns and are not surfaced as manual controls.
- Completed transfers fall off this view once done — the finished item lives in Downloaded, not here. A transient "just finished" confirmation is fine; a permanent completed-list is not (that's what Downloaded is for).
- Empty state points at the library: "Nothing downloading. Browse your library and tap download to save media for offline."

### 7.5 Navigation & entry points

- Reached via the account menu (§1.2) and, on desktop, the header Downloads link/icon → `/downloads`.
- `/downloads` opens on Downloaded by default; Transfers is one tap away and should draw attention (badge/count) only while transfers are active.
- Initiating a download is unchanged (§7.1): the download button lives on item/album/series detail pages. The Downloads page manages and browses; it is not where you start a download.

### 7.7 Known deviations

Recorded so the gap between this spec and the build is explicit.

- Downloads is a flat two-tab list today (Active / Completed), rendering one row per individual transfer with no browsing, grouping, or reuse of the library screens. Completed downloads never collapse into their album/series. (UR-055)
- No offline-scoped browse entry point exists in the client. All browsing goes through the hybrid repository, which merges cache and server; there is no way to ask for "downloaded content only" as a browse surface. The offline repository supports it (§7.2) but is not reachable independently. (UR-055, DR-082)
- The "on device" storage summary and per-container remove are absent from the completed list. (UR-055, UR-056)
- Per-item disk usage is not displayed anywhere. Cards and detail pages show no size; there is no device total, no container subtotal, and Remove does not state what it frees. (UR-056)

## 8. Settings & Account Flows

### 8.1 Settings Navigation

    flowchart TB
        User[User] --> NavChoice{Navigation Path}

        NavChoice -->|Desktop| HeaderSettings[Header: Click Settings Link]
        NavChoice -->|Mobile| OverflowMenu[Click Overflow Menu<br/>→ Settings]
        NavChoice -->|Direct| TypeURL[Navigate to /settings]

        HeaderSettings --> SettingsPage[Settings Page<br/>/settings]
        OverflowMenu --> SettingsPage
        TypeURL --> SettingsPage

        SettingsPage --> ShowSections[Show Sections:<br/>- Account<br/>- Playback<br/>- Downloads<br/>- Appearance<br/>- About]

        ShowSections --> UserClick{User Clicks Section}

        UserClick -->|Account| AccountSettings[Account Settings:<br/>- Server URL<br/>- Username<br/>- Logout button]
        UserClick -->|Playback| PlaybackSettings[Playback Settings:<br/>- Gapless playback<br/>- Volume normalization<br/>- Crossfade duration]
        UserClick -->|Downloads| DownloadSettings[Download Settings:<br/>- Max concurrent<br/>- WiFi only<br/>- Storage location<br/>- Auto-cache next tracks]
        UserClick -->|Appearance| AppearanceSettings[Appearance Settings:<br/>- Dark mode<br/>- Accent color]

Navigation to Settings:

- Desktop: Click "Settings" link in header navigation
- Mobile: Click three-dot overflow menu → Select "Settings"
- Direct: Navigate to `/settings` route

Settings apply instantly. Every control on the Settings page persists the moment the user changes it — toggling a switch, picking a level, or releasing a slider writes that setting immediately. There is no "Save" button and no save/dirty state to reason about; leaving the page never risks losing a change. Sliders update their live readout while dragging but only persist on release (`change`, not each `input` tick) to avoid flooding the backend.

### 8.2 Logout Flow

    flowchart TB
        AnyScreen[Any Screen] --> ClickLogout[Click Logout Button<br/>in Header]

        ClickLogout --> ConfirmDialog[Show Confirmation:<br/>"Log out of [Server]?"]

        ConfirmDialog --> UserConfirm{User Confirms?}

        UserConfirm -->|No| CancelLogout[Cancel - Stay on Current Screen]
        UserConfirm -->|Yes| StopPlayer[Stop Playback]

        StopPlayer --> ClearToken[Delete Token from Keyring]
        ClearToken --> ClearState[Clear App State:<br/>- Player state<br/>- Queue<br/>- Current screen]

        ClearState --> NavLogin[Navigate to Login Screen<br/>/login]

        NavLogin --> ShowLogin[Show Login Screen:<br/>- No Header<br/>- No Bottom Nav<br/>- No MiniPlayer]

Logout Button Location:

- Always visible in header user menu (logout icon)
- Accessible from any authenticated screen

## 9. Background & Lock Screen Behavior

### 9.1 Audio Playback in Background (Android)

    flowchart TB
        Playing[Audio Playing] --> Background{User Action}

        Background -->|Home Button| AppBackground[App to Background]
        Background -->|Screen Lock| ScreenLock[Screen Locked]

        AppBackground --> ContinuePlay[Playback Continues]
        ScreenLock --> ContinuePlay

        ContinuePlay --> ShowNotification[Show Media Notification:<br/>- Artwork<br/>- Title/Artist<br/>- Play/Pause<br/>- Next/Previous]

        ShowNotification --> LockScreen[Lock Screen Controls:<br/>Media Session Integration]

        LockScreen --> UserInteract{User Interaction}

        UserInteract -->|Tap Notification| OpenApp[Open App to Last Screen<br/>with MiniPlayer]
        UserInteract -->|Lock Screen Controls| SendCommand[Send Command to Player]
        UserInteract -->|BLE Headset Button| HeadsetControl[AVRCP Command]

Notification Layout (Android):

    ┌─────────────────────────────────────────┐
    │  [Artwork] Song Title                   │
    │           Artist Name                   │
    │           Album Name                    │
    │                                         │
    │  [⏮]  [⏸]  [⏭]                [✕]      │
    └─────────────────────────────────────────┘

### 9.2 Video Playback in Background (Android — PiP & Background Audio)

Leaving the app while a local video is playing does not simply pause it. What happens depends on which background behaviour is active. The two are mutually exclusive, and both apply only to locally-rendering video — audio-only playback, library/menu browsing, and remote/cast sessions never trigger PiP (see decision gate below).

    flowchart TB
        Leave[User leaves app<br/>Home / gesture / screen lock] --> Gate{Local video surface<br/>actively rendering?<br/>canEnterPip}

        Gate -->|No — audio, browsing,<br/>or remote/cast| Normal[App backgrounds normally<br/>audio, if any, continues via<br/>media notification &#40;§9.1&#41;]

        Gate -->|Yes| Mode{Background mode armed?}

        Mode -->|Background-audio toggle ON<br/>UR-040| Handoff[Hand off to native audio service<br/>WebView &lt;video&gt; torn down,<br/>video decode stops, audio continues]
        Mode -->|Default<br/>UR-041| PiP[Auto-enter Picture-in-Picture<br/>on onUserLeaveHint]

        PiP --> PiPWindow[Floating PiP window:<br/>- Video keeps rendering into surface<br/>- WebView hidden<br/>- Play/Pause RemoteAction<br/>  &#40;reflects live player state&#41;]

        PiPWindow --> PiPReturn{User action}
        PiPReturn -->|Tap window| Restore[Return to full player<br/>WebView restored, surface re-fit]
        PiPReturn -->|Close window| Stop[Playback stops]

        Handoff --> Foreground[On return to foreground:<br/>resume WebView video at position]

Key rules:

- Video-only gate. Auto-PiP is guarded by the native `canEnterPip` check (local video surface actively rendering). Audio playback and menu/library browsing background normally; remote/cast sessions render nothing locally, so a PiP window would be an empty box and is refused. (UR-041, IR-026)
- Only one background behaviour at a time. The background-audio toggle (UR-040) disarms auto-PiP while it is on, so a video is either handed to the audio service or floated in PiP, never both.
- PiP controls track the player. The play/pause RemoteAction in the PiP window reflects the live player state and updates on every playback-state change, not only when the button is pressed. (DR-053)
- Non-disruptive transition. ExoPlayer keeps rendering into the same surface across enter/exit, so entering or leaving PiP never interrupts the video; on exit the surface is re-fit to full-screen bounds. (DR-053)

PiP window (Android):

            ┌───────────────────┐
            │                   │
            │   ▶ video frame   │
            │                   │
            │       [⏸]         │  ← play/pause RemoteAction
            └───────────────────┘
       sized to the video's aspect ratio

## 10. Error States & Edge Cases

### 10.1 Network Loss During Streaming

    flowchart TB
        Streaming[Streaming Audio/Video] --> LoseNetwork[Network Connection Lost]

        LoseNetwork --> CheckLocal{Local Copy<br/>Available?}

        CheckLocal -->|Yes| SwitchLocal[Switch to Local Playback<br/>Seamlessly]
        CheckLocal -->|No| ShowBuffer[Show Buffering Spinner]

        ShowBuffer --> WaitReconnect[Wait for Reconnection<br/>30 second timeout]

        WaitReconnect --> Reconnect{Reconnected?}

        Reconnect -->|Yes| Resume[Resume Streaming]
        Reconnect -->|No| ShowError[Show Error Toast:<br/>"Unable to stream.<br/>Check connection."]

        ShowError --> OfferRetry[Offer Retry Button]
        ShowError --> OfferDownload[Offer "Download for Offline"]

### 10.2 Server Unreachable

    flowchart TB
        Action[User Action Requires Server] --> TryConnect[Attempt Connection]

        TryConnect --> Timeout{Connection<br/>Timeout?}

        Timeout -->|Yes| ShowError[Show Error:<br/>"Server unreachable"]
        Timeout -->|No| Success[Action Succeeds]

        ShowError --> OfferOptions[Offer Options:<br/>- Retry<br/>- Switch to Offline Mode<br/>- Change Server]

### 10.3 Download Failed

    flowchart TB
        Downloading[Download in Progress] --> Failure{Failure Type?}

        Failure -->|Network Error| Retry[Auto-retry<br/>with Backoff]
        Failure -->|Disk Full| ShowDiskError[Show Error:<br/>"Not enough storage"]
        Failure -->|Server Error| ShowServerError[Show Error:<br/>"Server error"]

        Retry --> RetryCount{Retry Count<br/>< 3?}
        RetryCount -->|Yes| Downloading
        RetryCount -->|No| Failed[Mark as Failed]

        ShowDiskError --> Failed
        ShowServerError --> Failed

        Failed --> UserAction[Show in Downloads:<br/>with Retry Button]

## 11. Platform-Specific UX Patterns

### 11.1 Android-Specific

Hardware Back Button:

- In Full Player: Return to previous screen, show MiniPlayer
- In Video Player: Stop playback, exit fullscreen
- In Album Detail: Return to library grid
- At Library Home: Exit app (show confirmation)

System Volume Buttons:

- While playing audio: Adjust playback volume
- While controlling remote session: Adjust remote session volume (shows session name in volume panel)
- In menus: Adjust system volume (default behavior)

Share Integration:

- Long-press album/song → Share menu
- Options: Share with other apps, Copy link

### 11.2 Linux Desktop-Specific

Keyboard Shortcuts:

- `Space`: Play/Pause
- `→`: Next track
- `←`: Previous track
- `/`: Focus search
- `Ctrl+Q`: Quit

Window Behavior:

- Minimize to tray (playback continues)
- Close window (show confirmation if playing)
- MPRIS integration for desktop media controls

Mouse Interactions:

- Hover over MiniPlayer: Show additional controls (volume, queue peek)
- Right-click: Context menu (Add to playlist, Go to artist, Download)

## 12. UX Principles Summary

### 12.1 Core Principles

1. Playback Persistence:

   - Audio playback never stops unless user explicitly stops it
   - MiniPlayer visible on all screens (except video/login)
   - Queue and position preserved across navigation

2. Non-Blocking UI:

   - Downloads happen in background
   - Sync operations never block user interaction
   - Optimistic updates (favorite, progress) with background sync

3. Offline-First:

   - Downloaded content works offline
   - Seamless switch between online/offline
   - Progress and preferences saved locally

4. Progressive Disclosure:

   - Simple defaults, advanced options hidden
   - Context menus for secondary actions
   - Settings organized by category

5. Responsive Design:

   - Mobile-first UI
   - Desktop enhancements (hover states, keyboard shortcuts)
   - Tablet: Grid layouts with more columns

### 12.2 Animation & Transitions

Transition | Duration | Easing
--- | --- | ---
MiniPlayer slide up/down | 300ms | ease-out
Screen navigation | 200ms | ease-in-out
Video controls fade | 500ms | ease-out
Download button state change | 150ms | ease-in-out
Modal appear | 200ms | ease-out
Toast notification | 250ms | ease-in-out

### 12.3 Touch Targets (Mobile)

Element | Minimum Size
--- | ---
Bottom nav buttons | 48x48 dp
List item (track, album) | Full width x 56 dp
Player controls | 56x56 dp
MiniPlayer | Full width x 64 dp
Download button | 40x40 dp
Favorite button | 40x40 dp

## 13. Future UX Enhancements

### 13.1 Planned Features

1. Gesture Navigation:

   - Swipe up on MiniPlayer → Full player
   - Swipe down on full player → Back to previous screen
   - Swipe between tracks in full player

2. Queue Management UI (DR-020):

   - Drag to reorder
   - Swipe to remove
   - Add to queue vs. Play next

3. Sleep Timer (UR-026):

   - Accessible from full player menu
   - Presets: 15min, 30min, 1hr, End of track, End of album
   - Countdown visible in MiniPlayer

4. Home Screen (UR-034):

   - Hero banner carousel
   - Continue watching/listening
   - Recently added
   - Personalized recommendations

5. Cast/Remote Control Enhancements:

   - Picture-in-picture for remote sessions
   - Multi-room audio (play on multiple devices)
   - Handoff (transfer playback to phone from TV)

### 13.2 Accessibility Enhancements

- Screen reader optimization
- High contrast mode
- Larger text option
- Voice control integration
- Haptic feedback for controls

This UX flow documentation should be updated as new features are implemented and user feedback is incorporated.

# Specs index

Feature specs for JellyTau. Start a new one from SPEC-TEMPLATE.md and run it past SPEC-REVIEW-CHECKLIST.md before accepting it.

## What lives here

Only work that has not shipped. Once a spec is fully implemented its design is folded into the architecture docs — which are the maintained description of the build — and the spec file is deleted. Git history keeps the original, including its rejected alternatives and acceptance criteria; the architecture docs keep the reasoning that a future change still needs.

So: a file in this directory is a promise, not a description. If you want to know how something works, read docs/architecture/. If you want to know what is planned, read here.

Status vocabulary

Status | Meaning
--- | ---
Proposed | Written, not accepted. Nothing built.
Accepted | Agreed as the design; implementation not started or not finished.
Partially implemented | Some parts shipped; the spec names what is left.
Design authority | No code of its own — it records a decision later specs act on.

Next free requirement ids (always re-check requirements.md before allocating): UR-079, IR-033, DR-232. Three specs below suggested ids that have since been taken by other work; each carries a ⚠️ note at the top.

## Partially implemented

Spec | What landed | What is left
--- | --- | ---
frontend-domain-model.md | Catalog surface: `MediaKind`, `from_jellyfin` isolated, ticks → ms | `primaryImageTag` → `imageId` (~30 sites); player/session/reporting tick math; `stream.type`
libmpv2-migration.md | `LICENSE` | The `libmpv` → `libmpv2` crate swap
read-through-media-cache.md | DR-126…128, DR-133…138 — cache entries are download rows; local playback of downloads | DR-122/124/125 — the read-through capture. DR-121 shipped as backend-owned stream selection and left this spec
scoped-search-boundary-implementation.md | Stage 1: `SearchScope` owned by Rust (DR-063…067) | Stage 2: result-side grouping (`GROUP_ITEM_TYPES` still in `searchScope.ts`)

## Not started

Spec | Blocked on / note
--- | ---
desktop-native-video.md | mpv draws video on every desktop platform, then the webview `<video>` path and hls.js are deleted. Converts a measured 7% direct-play rate toward Android's 85%. Stacked on backend-owned stream selection.
backend-owned-stream-selection.md | Rust owns direct-play-vs-transcode, transport and quality; players consume one `StreamSelection`. Partly built — `StreamSelection`, `Transport` and the `.m3u8` sniff removal have landed.
build-provenance.md | `build.rs` is still bare. ⚠️ suggested id DR-093 is taken.
player-facade-enforcement.md | ~60 `commands.player*` sites still outside the facade; no lint rule. ⚠️ suggested id DR-095 is taken.
windows-native-audio-backend.md | Blocked on the libmpv2 swap. ⚠️ suggested id IR-030 is taken.
linux-native-video-spike.md | Spike run 2026-08-21: compositing works on Linux, X11 and Wayland. G1-G6 green bar the Tauri `default_vbox()` half of G1. The adaptive-bitrate question it was waiting on is answered: the server publishes one `EXT-X-STREAM-INF`, so there is no ladder for mpv to lose (DR-229). `StreamSelection` (DR-225) is the contract to consume.

## Design authority

Spec | Role
--- | ---
playback-backend-unification.md | Why video cannot unify onto one native engine and audio can. The audio half has since shipped on Android; Windows has not.
scoped-search-boundary.md | The boundary design the `check:boundary` rule came from. Stage 1 built.
scoped-search.md | Superseded in part — its "frontend only, no Rust changes" decision is the leak the boundary spec reversed. UX still current.

## Where the shipped specs went

Sixteen specs were folded into the architecture docs and deleted (2026-08-21). Where to look for each:

Shipped work | Now documented in
--- | ---
Account menu & global chrome | 02-svelte-frontend.md — App Shell and Chrome
Library mosaic | 02-svelte-frontend.md — Library Mosaic
Series current-episode navigation | 02-svelte-frontend.md — Series and Episode Navigation
Downloads as an offline library | 02-svelte-frontend.md — Downloaded Browse
Favourites browsing | 01-rust-backend.md — Favorites System
Streaming bitrate cap | 01-rust-backend.md — Streaming quality ladder
Locally-indexed search | 03-data-flow.md — Search Flow; 01-rust-backend.md — Background workers
Offline downloaded-only filter | 06-downloads-and-offline.md — Offline Catalog Visibility
Audio equalizer · Android audio settings parity | 05-platform-backends.md — Audio settings on ExoPlayer
Android native video spike | 05-platform-backends.md — Native Video Compositing
Video background audio | 05-platform-backends.md — Background Audio Handoff
Traceability gate repair | traceability-ci.md
Boundary tripwire hardening | `scripts/check-frontend-boundary.sh` (its header is the spec)
Playback docs corrections · req-coverage script removal | Nothing to document — both were corrections that have been applied

# Spec: <feature name>

Status: Proposed Requirements: UX spec: Supersedes / revises: Destination on completion:

## Summary

## Motivation

## Layer assignment

Logic / responsibility | Layer | Why it belongs there
--- | --- | ---
| Rust |
| Frontend |

## Design

## Out of scope

## Acceptance criteria

- `bun run check` and `bun run test` pass.
- `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes (if Rust changed).
- `bun run check:boundary` passes (no taxonomy leak into the frontend).
- New requirement-implementing code carries `// TRACES:` comments.
- `bindings.ts` regenerated if Rust types changed.

## Testing

## TRACES

## Notes for the implementer

# Spec review checklist

Run a spec past this before accepting it. It exists because JellyTau's backend/frontend boundary is a stated rule with, historically, no gate — the rule lived in the architecture docs, but nothing forced a spec author to check a new design against it, and a "minimal-change" spec quietly leaked domain taxonomy into the frontend (see scoped-search-boundary.md). This checklist is the human gate. The CI check (`scripts/check-frontend-boundary.sh`) is only a crude tripwire for one leak signature — it does not replace this.

Copy the boxes into the review comment (or the PR) and tick them.

## Boundary (the one that bites)

- The spec has a filled-in "Layer assignment" table, and it assigns logic, not files. A spec without this section is not ready to review.
- No domain vocabulary is placed in the frontend. In particular: Jellyfin item-type sets that define a category (what "Music"/"TV"/"Movies" means), query-shaping rules, business rules, reachability/sync policy. If the frontend names a set of item types to define a category, that is a leak — it belongs behind an opaque enum the backend expands.
- "The backend already accepts this parameter" was not used as the reason to place the deciding logic in the frontend. Accepting a parameter ≠ owning the decision of its value.
- The `Scope:` / effort framing is not optimizing for "least backend change." "Frontend only, no Rust changes" is a description, never a goal. The goal is correct layer placement; sometimes that is more Rust work.
- Ran the litmus test on each borderline responsibility: would it change if Jellyfin's API changed? → Rust. Only if the UI were redesigned? → frontend. Borderline defaults to Rust.
- Single-type presentation (`itemType: "Movie"`, "this page shows albums") is not over-corrected into the backend. The rule targets category taxonomy, not every mention of a type. Don't invent a backend enum per list page.

## IPC contract

- Anything crossing the boundary has its wire shape specified.
- camelCase rule accounted for: top-level params auto-convert; nested structs get `#[serde(rename_all = "camelCase")]`; tagged unions match tags on both sides; events are kebab-case. (CLAUDE.md §IPC, 04-type-sync-and-threading.md.)
- Any result that arrives twice (command return and a later event — e.g. the search cache/server merge) has both payloads in the new shape.
- `bindings.ts` is regenerated from Rust, not hand-edited.

## Requirements & traceability

- Linked to existing URs, or new URs/DRs are allocated in requirements.md.
- Requirement-implementing code will carry `// TRACES:` comments (CLAUDE.md).
- Traceability coverage stays ≥ 88% (the CI gate — a ratchet, so check `bun run traces:coverage` rather than trusting this number).

## Lifecycle

- "Destination on completion" names a real architecture doc and section. This spec file is deleted when it ships; something has to absorb the design. If nothing fits, the layer assignment is probably unclear — go back to that table.
- The spec separates the durable half (invariants, rejected alternatives, the defect a decision exists to prevent) from the disposable half (phases, migration steps, acceptance criteria). Only the first is folded in.
- Anything listed as out of scope but still worth doing is written where it will be found after this file is gone — beside the code it concerns.

## Conflicts & hygiene

- If this spec revises/supersedes another, the older spec gets a banner pointing here — no two specs silently contradicting.
- Acceptance criteria include the standard gates: `bun run check`, `bun run test`, `bun run check:boundary`, and (if Rust changed) `cargo fmt`/`cargo clippy`/`bun run test:rust`.
- Notes flag that a parallel Claude session may be active in the repo.

If any Boundary box can't be ticked, the spec is not ready — fix the layer assignment first. Every other section can be negotiated; that one is the whole reason this file exists.

# Spec: Playback backend unification — findings and strategy

Status: Accepted (analysis; no code changes) Requirements: IR-004, UR-031, UR-032, UR-033 — revises the "Platform Playback Backend Parity" issue in requirements.md UX spec: n/a Supersedes / revises: informed the Android native-video and audio-parity work (both since shipped — see 05-platform-backends.md) and windows-native-audio-backend.md, still open

## Summary

This spec records the outcome of an investigation into unifying JellyTau's playback backends (Linux/MPV, Android/ExoPlayer, Windows/webview) onto a single engine with hardware acceleration everywhere. The conclusion is that video cannot be unified onto a native engine, and should not be attempted. Audio can be, and that is where the remaining specs direct effort.

No code changes follow from this spec directly. It exists so the decision is written down with its evidence, and so a future session does not re-run the same investigation.

## Motivation

The requirements doc carries a "Platform Playback Backend Parity" issue noting that audio settings work on Linux but not Android, and proposing eventual convergence. The natural next question — "should we just run one engine everywhere?" — needed answering before spending effort on per-backend patches.

The investigation also surfaced that several statements in requirements.md and in code comments are factually wrong. Those corrections are part of the deliverable.

## Findings

### 1. The current architecture is not what the docs describe

Platform | Audio | Video
--- | --- | ---
Linux | MPV (native, audio-only) | webview `<video>` + hls.js
Android | ExoPlayer (native) | webview `<video>` + hls.js
Windows | webview `<audio>` | webview `<video>` + hls.js

Two surprises:

- MPV never decodes video. `mpv_backend.rs` sets `video = no` and `audio-display = no` at construction. Linux video has always been the webview. Correspondingly, `player_play_item` deliberately does not load into MPV on Linux (it calls `set_current_item`, which only updates the queue).
- Android video is also the webview. `createAdapter()` in `src/lib/player/adapters/index.ts` hardcodes `const effectiveKind = "html5"` and does `void backendKind`, discarding the `use_html5_element` signal that `get_player_status` computes in Rust. `NativePlayerAdapter` is dead code, and ExoPlayer's `SurfaceView` path in `JellyTauPlayer.kt` is unreachable.

So video is already unified — on HTML5, everywhere, by accident of that hardcode — and on the path without hardware decoding on Android.

### 2. Native video cannot be composited with a Tauri webview

This is the load-bearing finding. It is not an mpv limitation; it defeats every candidate engine identically:

- mpv: `tauri-plugin-libmpv`'s own platform table reads Linux ⚠️ "Experimental. Window embedding is not working."
- GStreamer (wry discussion #284, 2024): "Gstreamer was rendering above the surface and covering all html elements."
- libVLC (tauri discussion #6343, 2024): "I had to render the webview in a child window though because vlc kept rendering on top of it."

Root cause, from Tauri maintainer amrbashir (tauri#9220, 2024-03-30):

> "we are limited to using Webkit2GTK on Linux and that requires a GTK window. While possible to add a GTK widget as a child X11 window inside raw X11 window, this is however a bit hacky and it is not possible on Wayland at all."

WebKitGTK, WebView2, and Android WebView each draw into their own compositor surface. A native video surface is either entirely above or entirely below the webview; it cannot interleave with HTML. Every working example in the ecosystem is the same hack — a separate child window position-synced to a `getBoundingClientRect()` div — which breaks on resize, scroll, and any UI drawn over the video. For JellyTau that means the controls, subtitle overlay, and mini-player.

The most recent comment on tauri#6343 (2026-05-23) confirms it is still unsolved:

> "I'm faking it and the window is not truly embedded, basically when the parent moves or resizes I reset the position and size of the libmpv window to align it with an HTML div."

The principle to carry forward: audio can unify on a native engine; video cannot, because video needs a surface and the webview owns the surface.

> Re-opened on Linux (2026-08-21). This finding's general form has since been falsified on Android — native video now composites behind a transparent Tauri WebView and ships on by default (see 05-platform-backends.md). The evidence above is also entirely about foreign-window embedding; mpv's render API, drawing into a GL context we own inside Tauri's own GTK tree, was never tested. linux-native-video-spike.md tests that one claim on Linux. Findings 3-6 below are untouched by it - in particular finding 3, which is an independent disqualifier a green spike would not clear.

### 3. mpv would regress streaming quality

mpv has no adaptive bitrate. It delegates HLS to FFmpeg's demuxer, which selects one variant at open time and never adapts; mpv#3548 (2016) requested ABR and it never landed. `--hls-bitrate` is a static picker defaulting to `max`.

The webview path already has real ABR via hls.js. Moving video to mpv would be a downgrade on every platform — no graceful degradation on weak networks, and quality changes requiring teardown and reload.

> Premise in doubt (2026-08-21). "The webview path already has real ABR" was not verified against the URLs this app actually builds. `get_video_stream_url` requests a single rendition (one `VideoBitrate`, one `MaxHeight`), the frontend has no level-handling code (`hls.levels`, `LEVEL_SWITCH`, `currentLevel` appear nowhere), and this repo implements a quality switch by re-opening the stream — all of which point to a single-variant playlist, i.e. no ABR to lose. The decisive test is counting `#EXT-X-STREAM-INF` lines in a real `master.m3u8`; it needs a live server and has not been run. See linux-native-video-spike.md.

### 4. Crossfade is architecturally blocked on mpv

mpv's audio chain is single-stream. FFmpeg's `acrossfade` is an `N→A` filter requiring two input streams, so there is no second input to feed it. Real crossfade needs two libmpv instances with manually ramped volumes. Upstream maintainer response (mpv#4512, closed three minutes after opening):

> "No. I also find crossfading stupid and complex, so the likeliness of that happening is low."

GStreamer could do it via `audiomixer`. mpv cannot, at any reasonable cost.

### 5. Engine comparison summary

Criterion | mpv | GStreamer | libVLC
--- | --- | --- | ---
Webview compositing | ❌ Linux broken | ❌ same wall | ❌ same wall
Adaptive bitrate HLS | ❌ none | ✅ adaptivedemux2 | ✅ adaptive module
Rust bindings | ⚠️ `libmpv2` active; our pin is dead | ✅ `gstreamer-rs` excellent | ❌ `vlc-rs` abandoned (2018)
Windows cross-MSVC | ⚠️ prebuilt DLL | ❌ pkg-config vs cargo-xwin | ❌ no better
Android packaging | ✅ Maven AAR (used by Findroid) | ⚠️ Cerbero/NDK, painful | ✅ mature AAR
ASS/SSA subtitles | ✅ libass built in | ✅ libass | ✅ libass
Crossfade | ❌ impossible | ✅ `audiomixer` | ⚠️ unclear

Every candidate fails the first row, which is the disqualifying one.

### 6. Two further options ruled out

Webview `<audio>`/`<video>` everywhere (i.e. delete the native audio backends too) is dead on Android: `navigator.mediaSession` is deliberately compiled out of Android WebView (Chromium CL 2613133003), so lockscreen/media-notification control would be impossible. Chromium has also never shipped `audioTracks`. It remains fine for Windows video, which is what we already do.

FFmpeg-direct / Rust-native (`ffmpeg-next`, `rsmpeg`, Symphonia) is not close: the safe bindings do not expose hardware decode at all, `ffmpeg-next` is self-declared maintenance-only, and Symphonia lacks HE-AAC and gapless AAC. This is a multi-person-year path to reach parity with what we already have.

### 7. If libmpv is ever revisited on Android

Recorded so the next investigation starts from evidence rather than repeating the search. The `dev.jdtech.mpv:libmpv` AAR — maintained by Findroid's author, i.e. another Jellyfin Android client — was inspected directly:

- `libmpv.so` exports the full 54-function `mpv_*` C API with zero `Java_` symbols; JNI is a separate optional ~19 KB `libplayer.so`. So it is drivable from Rust without a Java shim. (This is precisely what disqualifies libVLC, whose Android video path hard-requires a Java `AWindow` jobject.)
- ~23 MB/ABI, versus libVLC's ~46 MB/ABI.
- 🔴 The published AAR is built `--enable-gpl --enable-version3` — it is GPLv3, not LGPL. Fine for us (see libmpv2-migration.md), but it would be a hard constraint for anyone shipping closed source, and an LGPL rebuild would be your own build to own.
- Top unverified risk if anyone tries this: whether `libmpv2-sys` can cross-compile for `aarch64-linux-android` against that prebuilt `.so`. No working example of `libmpv2` on Android was found.

None of this changes the verdict — the cost is the MediaSession/foreground-service rewrite, not the bindings.

## Decision

1. Do not unify video onto a native engine. Video stays in the webview with hls.js on all platforms. This is not a compromise — it is the configuration that falls out of the compositing constraint, and it is the only one that gives us ABR for free.
2. Android native video is worth a bounded spike anyway — not for unification, but because ExoPlayer's `SurfaceView` path already exists and would restore hardware decode plus ASS/SSA subtitles. See 05-platform-backends.md.
3. Audio parity is the real gap and is achievable without touching any of the above. See 05-platform-backends.md and windows-native-audio-backend.md.
4. Migrate the dead libmpv pin regardless of any of this. See libmpv2-migration.md.

## Corrections to existing docs

These are factual errors found during the investigation. Fixing them is in scope for this spec.

Location | Says | Actually
--- | --- | ---
`requirements.md` UR-031 (line ~44) | "Done (Linux only)" | Not implemented on any platform.
`requirements.md` DR-034 (line ~196) | "Done (Linux only)" | Not implemented anywhere — `mpv_backend.rs` has a bare `// TODO: Implement crossfade via MPV audio filters if needed`. Architecturally blocked on mpv (finding 4).
`requirements.md` parity matrix | Crossfade ✅ Linux / ❌ Android | ❌ / ❌
`requirements.md` parity matrix | (no EQ row) | EQ is also Linux-only — `build_af_filter`/`eq_filter_entries` exist only in `mpv_backend.rs`. Same root cause, same fix.
`nativeAdapter.ts:11-14` | Native Android video "blocked upstream by tauri#10152" | tauri#10152 is a stale feature request, dead since 2024-07-01. The capability shipped in tauri commit `27d01834` (2024-09-02). Not a blocker.

## Layer assignment

No new logic. The one boundary observation worth recording:

Logic / responsibility | Layer | Why it belongs there
--- | --- | ---
Which video backend a platform uses (`use_html5_element`) | Rust | Already correctly computed in `get_player_status`. The frontend currently discards it — that is the bug, not the design. Restoring it means the frontend consumes a backend decision rather than making its own.

## Out of scope

- Any code change. This spec is analysis; the sibling specs carry the work.
- iOS/macOS. Not current targets.
- Replacing hls.js.

## Acceptance criteria

- `requirements.md` DR-034 status corrected; parity matrix updated (crossfade ❌/❌, EQ row added).
- Stale tauri#10152 comment in `nativeAdapter.ts` corrected.
- The four sibling specs exist and are linked from here.

## Testing

n/a — documentation only.

## TRACES

No new code. Requirement text changes only; DR-034's status line is the one substantive edit.

## Notes for the implementer

- The evidence above was gathered in July 2026. The compositing constraint has been stable since 2021 (wry#284) and is maintainer-declared unfixable, so it is unlikely to change soon — but if someone revisits this, tauri#6343 and wry#284 are the threads to re-read first.
- A parallel Claude session may be active in this repo — `git diff` before "repairing" unexpected changes.

# Spec: Linux native video — bounded compositing spike

Status: Run 2026-08-21 — compositing works; G5 carries an open crash. The compositing claim it set out to test is falsified on Linux. See "Result". This file stays open until the implementation spec exists. ABR is resolved — the playlist carries one `EXT-X-STREAM-INF`, so finding 3 is false and there is no adaptation for mpv to lose. The remaining blocker is the unexplained SIGSEGV under G5, which is a lifetime problem, not a compositing one. Requirements: none allocated. This spike produces a decision record, not product code — same shape as playback-backend-unification.md, which is Accepted with no requirement ids of its own. Ids are allocated by the implementation spec that follows a green result. UX spec: n/a Supersedes / revises: re-opens finding 2 of playback-backend-unification.md on Linux only. Its findings 3, 4, 5 and 6 stand unchallenged and are not in scope here.

Destination on completion: 05-platform-backends.md — a "Native Video Compositing (Linux)" section alongside the existing Android one. The durable half is the mechanism and the two traps below; the gates and phases are disposable.

## Summary

Test one falsifiable claim: a native video surface cannot be composited with a Tauri webview on Linux. The claim is load-bearing — it is why Linux video goes through an h264 HLS transcode into a WebKitGTK `<video>` element instead of decoding directly in the mpv instance we already run. The spike renders one mpv frame beneath the webview, on both X11 and Wayland, and stops. It ships no product code and flips no defaults.

A green result does not authorise native video on Linux; it authorises writing the spec that would.

## Motivation

playback-backend-unification.md finding 2 concluded that native video cannot be composited with a Tauri webview, on evidence from `tauri-plugin-libmpv`'s platform table, wry#284, tauri#6343, and a Tauri maintainer's 2024 statement that a GTK widget as a child X11 window is "a bit hacky and it is not possible on Wayland at all."

Two things have changed since that was written, and one thing was never tested.

1. The general claim has already been falsified on one platform — by us. Android now renders ExoPlayer video on a TextureView at index 0 behind a transparent Tauri WebView, with the Svelte controls drawn over it, on by default. See 05-platform-backends.md. That is exactly the composition finding 2 said was impossible, shipped. What survives of the finding is a narrower, WebKitGTK-specific claim — which is worth testing on its own terms rather than inheriting.

2. A Tauri app now ships Linux native mpv as an active platform. MaxVideoPlayer (354 commits) embeds libmpv via EGL + X11 child window / Wayland subsurface, with Linux and macOS active and Windows only planned — the inverse of the plugin matrix finding 2 sampled. Its existence does not prove our case works, but it does mean the Wayland half of the maintainer quote is out of date.

3. The render API was never tested. Every source in finding 2 describes foreign-window embedding: `--wid`, child windows, a second toplevel position-synced to a `getBoundingClientRect()` div. That is a different mechanism from mpv's render API, where we own the GL context and mpv draws into an FBO we hand it (`mpv_render_context_create` / `mpv_render_context_render`, with an upstream GTK example). Tauri v2 exposes `WebviewWindow::gtk_window()` and `default_vbox()`, so the target is a widget inside Tauri's own GTK tree — not a foreign window, not a second toplevel, and therefore not the thing that was found broken.

The prize is direct play: no h264 transcode, hardware decode, libass subtitles, and no server CPU burned on every Linux play.

## The blocker a green spike does not clear

🔴 Read this before treating a green result as a green light.

Finding 3 of the unification spec stands: mpv has no adaptive bitrate. It delegates HLS to FFmpeg's demuxer, which picks one variant at open and never adapts. The webview path has real ABR via hls.js. Compositing is necessary for native video on Linux; it is not sufficient.

There is a plausible answer, and this spike exists partly to make it testable: ABR only matters on the transcode path. A direct-played file has no variant ladder to adapt between — the adaptation the server offers is the transcode. So "mpv when the stream is direct-play, HTML5 + hls.js when the server transcodes" would sidestep finding 3 rather than fight it, and it maps onto a decision Rust already makes when it builds the stream URL.

That is a hypothesis, not a conclusion. It is out of scope here. Record it in the spike's decision note so the follow-up spec starts from it.

## Layer assignment

The spike introduces no product logic. The table below is the assignment the follow-up would inherit, written now so a green result cannot drift into frontend decisions during implementation.

Logic / responsibility | Layer | Why it belongs there
--- | --- | ---
Which backend renders video on this platform (`use_html5_element`, `supports_native_video`) | Rust | Already there — `get_player_status` in `commands/player/mod.rs` computes it from a `cfg!`. The spike would widen that `cfg!`, not relocate the decision. The frontend already consumes it via `createAdapter`.
Whether this stream is direct-play or transcoded, and therefore whether mpv or hls.js renders it | Rust | Domain. It depends on Jellyfin's `PlaybackInfo` response, container/codec support, and the bitrate cap — all of which change when Jellyfin's API or our quality ladder changes. The frontend must never re-derive it from a URL shape.
Creating, sizing, and destroying the GL surface; the mpv render context | Rust | Owns the backend and the GTK window handle. There is no presentation decision in it.
Where controls, subtitles, and the mini-player sit above the video, and the letterbox/poster treatment | Frontend | Pure presentation; changes only if the UI is redesigned. Precisely the split the Android path already uses.
Reserving the video rectangle in layout and marking the shell transparent | Frontend | Presentation. `nativeVideo.ts` + the `[data-native-video="active"]` rule in `app.css` already do this for Android and are platform-agnostic.

Borderline row, stated with its tie-breaker: "is the surface currently attached?" reads like view state, but the Android work found that a surface left in the hierarchy outlives its player (DR-184). Attachment is backend lifecycle → Rust, with the frontend told about it, not asked.

## Design

A throwaway branch. No merge to `master` except the decision note.

### What gets built

One `#[cfg(target_os = "linux")]` experiment behind a feature flag, in a scratch binary or an ignored test — not in `MpvBackend`'s constructor path:

1. From `app.get_webview_window(...)`, take `gtk_window()` and `default_vbox()`.
2. Reparent the webview into a `gtk::Overlay`: `GLArea` as the main child, the webview as the overlay child.
3. Set the webview background to fully transparent (wry does this when `"transparent": true`; verify it reaches `webkit_web_view_set_background_color`).
4. In the `GLArea`'s `render` signal, drive `mpv_render_context_render` with `MPV_RENDER_PARAM_OPENGL_FBO` pointing at the FBO GTK bound for us.
5. Play one local file. Draw an opaque HTML element over the video area.

`video = no` and `audio-display = no` are set in mpv_backend.rs:135-141; the spike overrides them on its own `Mpv` handle rather than editing that path.

### Bindings

The current pin is `libmpv = { git = "…/libmpv-rs", branch = "master" }` — the dead pin libmpv2-migration.md exists to replace. The render API lives in `libmpv2-sys` (`mpv_render_context_render`); the safe wrapper was only ever a PR against the old crate. Use `libmpv2-sys` raw FFI directly in the spike. Do not block the spike on the migration, and do not let the spike half-perform it — if the spike goes green the migration becomes a hard prerequisite of the implementation, which is the ordering windows-native-audio-backend.md already sits in.

### IPC

None. The spike crosses no boundary. If it goes green, the follow-up changes only the value of the existing `useHtml5Element` / `supportsNativeVideo` fields — no new wire shapes, no `bindings.ts` regeneration.

## Gates

Each is pass/fail with a named failure. Stop at the first red and write it up — a red result is a successful spike.

# | Question | Fails if
--- | --- | ---
G1 | Can a custom GTK widget join Tauri's widget tree and survive the window's lifetime? | `default_vbox()` is absent/unusable, or reparenting the webview breaks input or crashes.
G2 | Does the webview still paint, with a transparent backdrop, over that widget? | The backdrop renders opaque black (wry#1540) or the webview stops repainting (tauri#12800). This is the highest-risk gate.
G3 | Does mpv render a frame into our FBO? | The render context refuses GTK's context, or frames land in the wrong buffer.
G4 | Does HTML drawn over the video area actually appear over it? | Video covers the controls — the exact failure wry#284 and tauri#6343 report. Without this, the whole thing is worthless: our controls, subtitles and mini-player all sit over the video.
G5 | Does it survive resize, fullscreen, and SPA navigation away and back? | Flicker on resize, or a surface that outlives its route.
G6 | Does it hold on both X11 and Wayland? | Either session backend fails. Wayland is the one the 2024 maintainer quote says is impossible — test it first, not last.

G6 is not a nice-to-have. A result that only holds on X11 is red for a project shipping to current desktops.

### Time box

If G1–G4 are not all green, stop and write the result up. The value of this spike is a dated, method-specific answer — including "still no, and here is the mechanism" — not a working player.

## Result (2026-08-21)

Run on GNOME, kernel 7.1.8, libmpv 2.5.0 (mpv 0.41.0), GTK 3.24.52, WebKitGTK 2.52.6, wry 0.53.5 — the versions `src-tauri/Cargo.lock` resolves. Spike source: a ~250-line standalone crate using wry + gtk + `libmpv2-sys` raw FFI, driving mpv's render API with an update callback, frame-gated repaints and `report_swap`.

Gate | Result | Observed mechanism
--- | --- | ---
G1 widget in GTK tree | 🟡 partial | `GtkOverlay` with `GtkGLArea` as main child and the wry webview as overlay child works, built directly. Tauri's own `default_vbox()` was not exercised — see below.
G2 webview paints transparently over it | ✅ green | `with_transparent(true)` alone. No window-level transparency was used or needed.
G3 mpv renders into our FBO | ✅ green | `vo=libmpv` + `mpv_render_context_create` with `MPV_RENDER_PARAM_OPENGL_FBO` into the FBO GTK binds.
G4 HTML over video | ✅ green | Opaque panel and a translucent control bar both drew over moving video.
G5 resize / drag / fullscreen | 🟡 green on appearance, suspect underneath | No flicker, gap or misalignment, and smooth once frame pacing was correct (trap 3). But the only crash observed came from the only session where fullscreen was exercised — see "What is still open".
G6 X11 and Wayland | ✅ green | Identical on both; `GDK_BACKEND` flipped between runs.

Finding 2 of playback-backend-unification.md is false on Linux when tested by the render API rather than by foreign-window embedding. Wayland — the half the 2024 maintainer quote called impossible — is green.

Better than the gate asked for: the translucent bar composited alpha against the video, not merely opaque-over. Scrims, gradient fades and subtitle backdrops therefore work, which is most of how a player UI actually looks. mpv also painted the letterbox bars black on its own — the Android equivalent was a shipped defect (DR-194).

### Three traps, each of which cost a debugging cycle

Carry these into the implementation; each produced a failure that looked like a platform limitation and was not.

1. `LC_NUMERIC` must be reset after `gtk::init()`, not before. mpv refuses to start under a non-C numeric locale. `mpv_backend.rs` already handles this, but it has no GTK init in front of it; on this path `gtk::init()` applies the user's locale afterwards and `mpv_create` returns null.
2. libepoxy exports GL entry points as data symbols. There is no `glFoo` function to resolve — there is `epoxy_glFoo`, a variable holding a lazily resolving function pointer. `get_proc_address` must return the pointer stored at that symbol; returning the symbol's own address makes mpv jump into non-executable data and take SIGSEGV/SEGV_ACCERR on the first GL call. The `epoxy` crate does this correctly but is unusable — its `gl_generator` dependency pulls a yanked `xml-rs`.
3. Frame pacing is not optional, and its symptom is misleading. Driving `queue_render()` off the widget's frame clock on every tick, without calling `mpv_render_context_report_swap` after each render, leaves mpv with nothing to time against. Playback looks fine in a window and judders at fullscreen — which reads as a compositing or GPU limit and is neither. The fix is to register `mpv_render_context_set_update_callback`, redraw only when it says a frame is ready, and report the swap afterwards. Fullscreen was smooth immediately once both were in place.

### Hardware decode through the render API

Tested by asking mpv what it actually selected (`hwdec-current`), not what it was asked for. All three ran 20s clean at a steady 30 fps.

`hwdec` | `hwdec-current` | Note
--- | --- | ---
`vaapi` | `no` | Did not engage on this box — silently fell back to software. `vainfo` is not installed, so the libva driver for the Iris Xe iGPU is likely absent. No render-API error; this looks like a missing driver package, not a compositing limit.
`auto` | `nvdec-copy` | Hardware decode does work through the render API, on the discrete RTX 3050. Copy-back rather than zero-copy interop.
`no` | `no` | Software. Clean baseline.

The load-bearing result is the middle row: hardware decode is compatible with mpv's render API, so the direct-play prize is real and not traded away for software decoding. Which decoder to prefer is an implementation question — on a hybrid Intel+NVIDIA laptop `auto` reached for the discrete GPU in copy-back mode, which is the least efficient hardware path. An implementation should evaluate zero-copy VA-API on the iGPU (after confirming the driver is installed) before accepting `auto`.

`hwdec=auto-safe` probes Vulkan video decode, which this GPU does not support. It logs two `Failed setup for format vulkan` / `no frame!` pairs at start-up and then settles on `nvdec-copy` — the same place `auto` lands. A first reading of these logs mistook the start-up pair for a per-frame flood; it is not. Every run, clean or crashed, contains exactly two. `auto-safe` is not implicated in anything.

### What is still open

- The Tauri half of G1. The spike built its own `GtkOverlay`. The app must instead reach `WebviewWindow::gtk_window()` / `default_vbox()` and reparent Tauri's existing webview into an overlay. Low risk — the same widgets, one extra reparent — but unproven, and it is the only place Tauri-specific behaviour could still bite.

- ✅ ABR — resolved. Finding 3's premise is false. Finding 3 said mpv would regress streaming quality because "the webview path already has real ABR via hls.js". Three pieces of evidence in this repo suggested that is not true of the URLs we actually build:

  1. `get_video_stream_url` (`repository/online.rs`) requests a single rendition — one `VideoBitrate`, one `MaxStreamingBitrate`, one `MaxHeight`. Jellyfin transcodes to what it is asked for; it does not build a ladder.
  2. The frontend contains no level-handling code at all — no `hls.levels`, no `LEVEL_SWITCH`, no `currentLevel`. The `abrEwma*` options in `VideoPlayer.svelte` are default tuning with nothing to act on. hls.js is serving as an HLS demuxer (WebKitGTK cannot play HLS natively), not as an adaptation engine.
  3. That function's own comment describes a quality switch as rebuilding the URL — "every path that re-opens a stream (quality switch, transcoded seek, audio-track switch)". Manual selection by stream re-open is what you build when there is no adaptation, and mpv can do the same thing.

  The decisive test has now been run (2026-08-21, against the development server, Jellyfin 10.11.5):

      curl -s ".../Videos/<itemId>/master.m3u8?…&TranscodingProtocol=hls&…" \
        | grep -c EXT-X-STREAM-INF
      1

  One line. The playlist carries a single `EXT-X-STREAM-INF` plus an `EXT-X-IMAGE-STREAM-INF` trickplay entry, which is not a rendition. Jellyfin builds the master playlist from the rendition the request asked for; it does not publish a ladder. So there is no ABR to lose, and this blocker is closed — hls.js is serving as an HLS demuxer, exactly as (2) above supposed, and mpv gives up nothing by replacing it.

  Recorded as DR-229 (Won't Do) rather than deleted, because it is a measurement: a server that does publish a ladder would change the answer, and the re-negotiation path is the hook that work would build on.

  The direct-play path now exists. It did not when this spike was written — every video play went through the HLS transcode endpoint. Backend-owned stream selection (DR-225 … DR-230) built it: Rust negotiates direct play / direct stream / transcode and hands every backend one `StreamSelection` carrying the URL, the transport and the chosen rendition. That is the contract this implementation consumes — mpv is a consumer of a decision already made, not a place to re-derive it.

  It also sizes the prize precisely. Measured over the same server, 40 items through a real negotiation per profile:

  Profile | Direct play
  --- | ---
  Linux / WebKitGTK — `h264` only, 2ch | 7%
  Android / ExoPlayer — `h264,hevc,vp8,vp9,av1,mpeg4` + `ac3,eac3`, 6ch | 85%

  The 85% is a ceiling, not a shipped result — it was measured with a profile containing `ac3,eac3`, which the Android device later used for verification does not support.

  The library sampled is ~80% hevc. Linux sits at 7% solely because the WebKitGTK profile can only claim h264 — not because of anything about the server or the negotiation. mpv decodes hevc, so widening the Linux device profile once mpv renders the picture is what converts that 7% toward the Android figure. That conversion is the actual product of this work; the compositing proven above is the mechanism that permits it.

- 🔴 One unexplained SIGSEGV. A ~180s run died in a decoder thread (libavcodec -> `av_log` -> libmpv's log handler -> libc). No Tauri, wry, WebKitGTK, GTK or GL frame appears anywhere in the stack, so the fault is on the mpv/ffmpeg side of the process rather than in the compositing seam.

  Three hypotheses were tested and none reproduced it:

  Hypothesis | Test | Result
  --- | --- | ---
  `hwdec=auto-safe`'s Vulkan failures | 300s soak on `auto-safe` | Survived. Also based on a misreading — the failures are 2 per run at start-up, not per-frame. Dead.
  Fullscreen transitions recreating the GL context under mpv's render context | 240s soak, ~120 automated transitions | Survived, no core dumped.
  Continuous resize thrashing the GL framebuffer | 240s soak, ~2000 resizes | Survived, no core dumped.

  The crash is therefore unexplained. It was observed exactly once, in the only session a human interacted with, and did not recur in ~13 minutes of targeted stress across the three most plausible causes. It is recorded here rather than dismissed precisely because nothing explains it: an intermittent fault that nobody can reproduce is worse to inherit than a deterministic one, not better.

  The underlying concern stands regardless of which test eventually reproduces it. A SIGSEGV in an unrelated thread is characteristic of memory corruption, and this spike never calls `mpv_render_context_free` and never tears down on `unrealize` — it has no defence against the GL context being recreated beneath the render context. That is DR-184 on Android restated: a surface outliving its player. An implementation must bind the two lifetimes together whether or not this particular crash is ever explained.

  Therefore G5 is recorded green on appearance only, and this crash is the single largest piece of unfinished business in the spike. Do not read the green gates above as "safe to build on" until it is explained or a long soak clears it.

- Long-run stability, seeking, track switching, HDR, and multi-window were not exercised at all.

## Out of scope

- Any change to the shipping Linux video path. `experimentalNativeVideo` in `adapters/index.ts` is a suppressor, never a promoter; the spike must not change that.
- Adaptive bitrate. See "The blocker a green spike does not clear".
- Windows and macOS — different mechanisms, and Windows is the easier case, not the endangered one. See below.
- Android. Already shipped; it is the precedent, not the target.
- Crossfade, the libmpv2 migration, and the audio-parity work.

### Why Windows is unaffected, and cheaper

Nothing here can regress Windows. `use_html5_element` is already a per-platform `cfg!` in `get_player_status` — Android native, everything else HTML5 — so divergent video paths are the existing design rather than something this introduces. Windows keeps `<video>` + hls.js whatever this spike returns.

The mechanism does not port: `default_vbox()`, `GtkOverlay` and `GtkGLArea` are GTK3/WebKitGTK concepts. But the question is already answered more favourably there. Both mpv plugins list Windows as fully tested and Linux as broken, because WebView2 honours a transparent background — the "native surface beneath a transparent webview" approach that fails on WebKitGTK is the one that works on Windows. That asymmetry is why windows-native-audio-backend.md can call Windows "the cleanest available win".

Windows' cost is packaging, not compositing: the build cross-compiles with MSVC + `cargo-xwin`, so libmpv arrives as a bundled prebuilt DLL (the ⚠️ in finding 5's comparison table). That cost is already committed for audio. Once the DLL ships to replace `WebviewAudioBackend`, Windows video is largely a follow-on.

Sequencing, if native video is ever pursued on both:

1. libmpv2-migration.md — prerequisite for either.
2. windows-native-audio-backend.md — already specced; lands the DLL and a real Windows backend.
3. Windows native video — cheap once 2 exists, and does not need this spike.
4. Linux native video — needs this spike, and runs independently of 1–3.

### Does this add a backend?

No — and the trajectory is convergence, not proliferation.

`create_player_backend` in `lib.rs` already selects between four `PlayerBackend` impls by `cfg!`: `MpvBackend` (Linux), `ExoPlayerBackend` (Android), `WebviewAudioBackend` (Windows and anything else), and `NullBackend` as the graceful-init fallback. The HTML5 video path is not among them — it is a frontend adapter reporting through `player_report_*`, not a `PlayerBackend`.

This spike adds none of these. `MpvBackend` already exists and already runs on Linux; it merely sets `video = no` at construction. Giving it video widens an existing backend rather than introducing an engine.

Following the sequence above, the count goes down: replacing `WebviewAudioBackend` with mpv on Windows leaves two native engines — mpv (Linux + Windows) and ExoPlayer (Android) — with native video riding on both.

Two is the floor, for a reason worth stating so nobody re-litigates it: Android cannot drop ExoPlayer even if libmpv runs there, because the foreground service, `MediaSessionCompat` and lockscreen control are built on it (finding 7 puts the cost at that rewrite, not at the bindings). The HTML5 path does not go away either — it is the transcode/ABR route and the fallback.

The trait surface converges too: `ExoPlayerBackend` already implements the video-surface lifecycle for Android native compositing, so teaching `MpvBackend` video follows a path already walked rather than opening a second one.

- Adopting `tauri-plugin-libmpv` or `tauri-plugin-mpv` as dependencies. Both report Linux window embedding as not working and are small projects (20 and ~70 commits); read them, do not depend on them.

## Acceptance criteria

The deliverable is a decision, not a feature.

- Each of G1–G6 recorded green/red with the observed mechanism, not just the verdict.
- X11 and Wayland results reported separately, each naming the compositor and WebKitGTK version tested.
- The direct-play/transcode ABR hypothesis recorded as open, with whatever the spike learned about it.
- `docs/specs/README.md` updated — this spec listed, and its row moved or deleted per the result.
- The Linux claim in the `createAdapter` doc comment (adapters/index.ts:12-13) corrected either way: if red, cite this spike instead of asserting it; if green, it is wrong and must be rewritten.
- On red: finding 2 of playback-backend-unification.md gains a dated note naming the render-API method as also tested, and this file is deleted. The verdict lives in the design-authority spec, not in a second file that contradicts nothing.
- On green: an implementation spec exists, allocating ids from UR-077 / IR-033 / DR-216 (re-check `requirements.md` — the README's "next free DR-215" is stale, DR-215 landed), and it must answer ABR before being accepted.
- No spike code on `master`. If any lands, the standard gates apply: `bun run check`, `bun run test`, `bun run check:boundary`, `cargo fmt`, `cargo clippy`, `bun run test:rust`.

## Testing

No automated tests. A compositing result is a visual, per-session-backend observation and cannot be asserted in `cargo test` or vitest — pretending otherwise would produce a test that passes on a headless runner and tells us nothing.

Capture a screenshot per gate. G4 specifically: an opaque HTML element over the video area, photographed showing the video behind it.

If it goes green, the implementation spec inherits the testable surface the Android work already established — `nativeVideoLayers.test.ts` asserts the `app.css` selector list and the `data-native-video` contract, and both are platform-agnostic.

## TRACES

None. No requirement-implementing code is produced. The implementation spec that follows a green result allocates from DR-216 and tags there.

## Notes for the implementer

- Read playback-backend-unification.md first, in full. This spike disputes exactly one of its six findings, on one platform, by one method it did not try. Everything else in it is still binding — particularly finding 3.
- Test Wayland first. It is the gate most likely to be red and the one that makes the rest moot.
- The frontend plumbing already exists from the Android work: `createAdapter`, `NativePlayerAdapter`, `nativeVideo.ts`, `videoSurface.ts`, and the `[data-native-video="active"]` rule. A green spike is far cheaper to implement than it would have been a year ago — which is itself part of why the question is worth re-asking.
- The Android record in 05-platform-backends.md lists six shipped defects from getting this right on one platform. Expect the Linux equivalents (the surface outliving its player, the shell painting over it, unpainted letterbox bars) rather than rediscovering them.
- A parallel Claude session may be active in this repo — `git diff` before "repairing" unexpected changes.

# Spec: Backend-owned stream selection

Status: Proposed Requirements: UR-079 (new) → DR-219 … DR-224 (new); implements and extends DR-121, currently allocated to read-through-media-cache.md and not started. Re-check `requirements.md` before allocating — the ids moved twice while this was being written (`DR` max was 215, then 218). UX spec: the quality selector in `VideoPlayer.svelte` already exists; this changes what fills it, not how it looks. Supersedes / revises: takes DR-121 out of read-through-media-cache.md, which should keep only its capture/eviction half. Unblocks linux-native-video-spike.md.

Destination on completion: 01-rust-backend.md — extends the "Streaming quality ladder" section; and 03-data-flow.md — playback initiation. The durable half is the layer line and the `StreamSelection` contract; phases and acceptance criteria are disposable.

## Summary

Make Rust the single owner of which stream to play — direct play or transcode, at what ceiling, over what transport — and hand every player backend a self-describing selection instead of a bare URL. mpv, ExoPlayer and the HTML5 `<video>`/hls.js path all become consumers of the same decision rather than three places that re-derive it.

Nothing about how playback looks changes. What changes is that the frontend stops inferring transport from a URL string, and that direct play becomes possible at all.

## Motivation

Four concrete problems, all the same shape.

1. The frontend sniffs transport out of the URL. VideoPlayer.svelte:569:

    const isHlsStream = currentStreamUrl.includes(".m3u8");

and again inline at line 2364. Rust built that URL and knows exactly what it is; the frontend re-derives it by substring match. Change the endpoint, add a DASH path, serve a progressive file, and this silently picks wrong. This is the boundary rule in miniature — not item-type taxonomy, but the same error: a domain fact reconstructed in the presentation layer because the wire shape did not carry it.

2. There is no direct-play path. `get_video_stream_url` always builds an HLS transcode URL (`TranscodingProtocol=hls`, `VideoCodec=h264` first). Every video play burns server CPU, even when the file would play untouched. This is the cost the Linux native-video work exists to remove, and it cannot be removed without a decision that does not currently exist anywhere in the codebase.

3. Quality is a process-wide global. `streaming_quality()` / `set_streaming_quality()` in `repository/online.rs` read and write a static. It is not per-session or per-item, so it cannot express "this 4K remux needs a ceiling, that podcast does not", and two concurrent playbacks would share one setting.

4. Rust cannot say what qualities this media source supports. The selector is populated from a fixed enum rather than from what the source actually offers. DR-121 already names this; it has not been built.

### The prior question

Finding 3 of playback-backend-unification.md holds that hls.js gives us real adaptive bitrate and mpv would lose it. Evidence in this repo suggests there is no ABR today: a single rendition is requested, no level-handling code exists anywhere in the frontend, and a quality switch is implemented by re-opening the stream.

Run this before sizing the adaptation work. It needs a live server:

    curl -s "https://<server>/Videos/<itemId>/master.m3u8?api_key=<key>&…" \
      | grep -c EXT-X-STREAM-INF

`1` → there is no adaptation to preserve, and the adaptation half of this spec collapses to "pick well at open". `>1` → finding 3 stands and DR-223 applies. Everything else in this spec is worth doing either way — the ownership problems above are independent of the answer.

## Layer assignment

Logic / responsibility | Layer | Why it belongs there
--- | --- | ---
Direct play vs direct stream vs transcode | Rust | Depends on Jellyfin's `PlaybackInfo`, container/codec support and the device profile. Changes when Jellyfin's API or our profile changes → domain, by the litmus test.
Transport of the chosen stream (HLS / progressive / local file) | Rust | Rust constructs the URL; it is the only place that knows rather than infers. Today the frontend guesses from `.m3u8`.
Which qualities this media source can offer | Rust | Derived from the source's own streams and the quality→transcode-parameter mapping that `get_video_download_url` already holds. DR-121.
The quality ceiling in force, per playback session | Rust | Domain state that outlives any one view and must survive a backend swap or a mode transfer. Currently a process-wide static.
Deciding to re-negotiate mid-playback (if adaptation is needed) | Rust | It performs the HTTP and already derives reachability from real traffic via `ConnectivityMonitor`. Throughput estimation is the same pattern on the same data — a side-channel probe would repeat the mistake that principle exists to prevent.
Frame-level delivery within the selected stream, including a player's own ABR | Player | ExoPlayer has genuine adaptive selection; if Rust hands it a multi-variant playlist it should use it. Rust chooses what to request, never how a player paces bytes. See "The line".
Rendering the selector, showing the current quality, ordering the list | Frontend | Pure presentation over a backend-supplied list.
Poster, letterbox, controls, overlay z-order | Frontend | Unchanged.

### The line

Rust decides what stream. The player decides how to deliver it.

This matters most for ExoPlayer, which already does real adaptive track selection over HLS. This spec must not reimplement that or fight it — if a multi-variant playlist reaches ExoPlayer, ExoPlayer adapts and Rust stays out of the way. The same restraint applies to any future backend that gains the capability. Rust only steps in where the player has no such ability (mpv) and the server actually offers a ladder.

Borderline row, with its tie-breaker: "which media source of a multi-source item" looks like a user choice, and its presentation is. The default and the constraint set are domain → Rust, per the borderline-defaults-to-Rust rule.

## Design

### The contract

One self-describing selection replaces the bare URL. Nested fields are camelCase over the wire (`#[serde(rename_all = "camelCase")]`); the enums are tagged so the frontend matches a tag instead of parsing a string.

    #![allow(unused)]
    fn main() {
    #[derive(Serialize, Type)]
    #[serde(rename_all = "camelCase")]
    pub struct StreamSelection {
        pub url: String,
        pub transport: Transport,
        pub playback_kind: PlaybackKind,
        /// The negotiated rendition; None when direct-playing the source as-is.
        pub rendition: Option<Rendition>,
        /// What this media source can offer — fills the selector (DR-121).
        pub available: Vec<QualityOption>,
    }

    #[derive(Serialize, Type)]
    #[serde(tag = "type", rename_all = "camelCase")]
    pub enum Transport { Hls, Progressive, LocalFile }

    #[derive(Serialize, Type)]
    #[serde(tag = "type", rename_all = "camelCase")]
    pub enum PlaybackKind { DirectPlay, DirectStream, Transcode }
    }

`Transport` is the field that deletes the `.m3u8` sniff. The frontend picks hls.js on `Hls` and the element's own loader otherwise — a tag match, not a substring search.

### Re-negotiation

Rust emits `stream-selection-changed` (kebab-case, per convention) carrying a new `StreamSelection` plus the position to resume at. The existing `playerSetStreamQuality` response already has exactly the right shape — a tagged `strategy` that tells the caller who reloads, with the backend handling native itself and handing HTML5 a URL for `reloadSource` (index.ts:198). Extend that; do not invent a second mechanism. It is the one piece of this that is already right.

Note the existing wart to preserve or fix deliberately, not accidentally: tauri-specta keeps those response fields snake_case (`new_url`), and the facade comments say so.

### Phases

1. DR-219 `StreamSelection` + `Transport`; delete the `.m3u8` sniff. No behaviour change — pure ownership move, and independently shippable.
2. DR-220 Per-session quality ceiling replacing the `online.rs` static.
3. DR-221 `available` populated from the media source (DR-121's substance).
4. DR-222 Direct-play/direct-stream negotiation via `PlaybackInfo`. This is the phase that unlocks native video and removes the transcode.
5. DR-223 Adaptation, only if the playlist check says a ladder exists. Cheapest sufficient design: re-negotiate on sustained throughput drop, reusing the phase-1 re-negotiation path. A local proxy synthesizing a single-variant playlist is a last resort, not a starting point.
6. DR-224 ExoPlayer and mpv consume `StreamSelection` unchanged, proving the contract is player-agnostic rather than HTML5-shaped.

Phases 1–4 stand on their own merits with no dependency on the ladder question.

## Out of scope

- Rendering, compositing, and the Linux native-video work itself. This spec unblocks linux-native-video-spike.md; it does not contain it.
- Replacing hls.js. It stays as the HLS loader for the webview path.
- Reimplementing or overriding ExoPlayer's own adaptive selection. See "The line".
- The download/capture half of read-through-media-cache.md (DR-122, DR-124, DR-125), which keeps its own spec.
- Audio. The same argument applies, but video is where the transcode cost is.

## Acceptance criteria

- The `.m3u8` substring check is gone from `VideoPlayer.svelte` (both sites) and transport comes from the tagged enum.
- `bun run check`, `bun run test`, `bun run format:check`, `bun run lint` pass.
- `cargo fmt` clean, `cargo clippy -D warnings` clean, `bun run test:rust` passes.
- `bun run check:boundary` passes — and the reviewer confirms by reading that no transport/kind decision was reconstructed in `src/`, since the tripwire only catches item-type array literals.
- `bindings.ts` regenerated from Rust, not hand-edited.
- New code carries `// TRACES:` comments; `bun run traces:validate` passes and coverage stays ≥ the CI ratchet.
- The `EXT-X-STREAM-INF` count is recorded in this spec before DR-223 is started or dropped.
- DR-121 is removed from `read-through-media-cache.md` with a pointer here.

## Testing

- Rust: `PlaybackInfo` fixtures → expected `PlaybackKind`, one per branch (supported container direct-plays; unsupported codec transcodes; a ceiling below the source bitrate transcodes even when the codec is fine).
- Rust: `Transport` round-trips through serde with the tag the frontend matches.
- Frontend: adapter selection driven by `transport`, including the case a URL ending `.m3u8` is served as `Progressive` — that test fails on today's code, which is the point.
- Extend `tauriIntegration.test.ts` for the new command params (camelCase rule).
- No test asserts a URL substring.

## TRACES

Piece | Tag
--- | ---
`StreamSelection` / `Transport` | `UR-079 | DR-219`
Per-session ceiling | `UR-074 | DR-220`
`available` from media source | `UR-079 | DR-221, DR-121`
Direct-play negotiation | `UR-079 | DR-222`
Adaptation, if built | `UR-079 | DR-223`
ExoPlayer/mpv consumers | `UR-003, UR-004 | DR-224`

## Notes for the implementer

- Phase 1 is worth doing on its own, even if everything after it is dropped. It removes a real leak and costs almost nothing.
- Do not frame any phase as "no Rust changes required" — that framing is what produced the leak `scoped-search-boundary.md` records.
- `ConnectivityMonitor` is the precedent for DR-223: derive network facts from real traffic, never from a side-channel poller.
- A parallel Claude session may be active in this repo — `git diff` before "repairing" unexpected changes. Requirement ids in particular moved twice during the writing of this spec.

# Spec: Enforce the unified player boundary

Status: Proposed — not started. The count below has not improved: ~60 `commands.player*` call sites still live outside `src/lib/player/`, and no lint rule enforces the boundary. This remains the one stated design principle with no automated check. Requirements: ⚠️ the suggested id DR-095 has since been allocated to seek clamping — allocate a fresh id (DR-215 or later) on implementation. Relates to UR-005 and the unified-player-boundary principle in CLAUDE.md and 02-svelte-frontend.md UX spec: n/a — refactor, no user-visible change. Supersedes / revises: n/a

## Summary

The stated principle is that UI controls playback only through `playerController` (src/lib/player/index.ts), never by calling `commands.player*` directly. There are 52 direct call sites outside that facade. This spec routes the genuine playback-control calls through the facade, narrows the principle's wording so it stops forbidding things it never meant to forbid, and adds the lint rule that keeps it true — because this rule is the one design principle in the audit with no automated check at all, and it is also the one that drifted furthest.

## Motivation

Direct `commands.player*` usage outside `src/lib/player/`, by file:

File | Sites
--- | ---
queue.ts | 10
player/[id]/+page.svelte | 9
VideoPlayer.svelte | 8
settings/+page.svelte | 5
sleepTimer.ts / auth.ts / autoplay.ts | 4 each
preload.ts | 3
library/[id], playerEvents.ts, playbackMode.ts | 1–2 each

These are not equivalent violations, and treating them as one number is why the rule has been easy to ignore. Three distinct groups:

(a) Genuine violations — playback control with a facade method that already exists. `playerStop` ×6, `playerPlayTracks` ×4, `playerSeek` ×2, `playerPlayAlbumTrack` ×2, `playerNext`, `playerPrevious`, `playerSkipTo`, `playerToggleShuffle`, `playerCycleRepeat`, `playerRemoveFromQueue`, `playerMoveInQueue`, `playerAddTrackById`, `playerAddTracksByIds`, `playerSetSubtitleTrack`, `playerPlayItem`. The facade exposes `stop()`, `seek()`, `next()`, `previous()`, `skipTo()`, `toggleShuffle()`, `cycleRepeat()`, `removeFromQueue()`, `moveInQueue()`, `addTrackById()`, `addTracksByIds()`, `setSubtitleTrack()`, `playTracks()`, `playAlbumTrack()`, `playItem()` — every one of these has a facade equivalent that is simply not being called. `queue.ts` is the starkest case: it imports `commands` directly and re-implements ten methods the facade already provides.

(b) Playback control with no facade method. `playerPlayQueue`, `playerGetQueue`, `playerGetStatus`, `playerEnterBackgroundAudio`, `playerExitBackgroundAudio`, `playerSetSleepTimer`, `playerCancelSleepTimer`, `playerPlayNextEpisode`, `playerCancelAutoplayCountdown`. In scope for the principle, but currently impossible to comply with — the facade has no surface for them. A rule that cannot be followed is not being broken so much as it is unfinished.

(c) Not playback control. `playerConfigureJellyfin` ×3, `playerDisableJellyfin`, `playerGet/SetAudioSettings`, `playerGet/SetVideoSettings`, `playerGetEqPresets`, `playerGet/SetAutoplaySettings`, `playerGet/SetCacheConfig`, `playerPreloadUpcoming`. These are configuration and lifecycle calls that happen to live under the `player_` command prefix. The principle is about who is authoritative for playback state — settings CRUD isn't that.

The audit's read: the rule as written is violated 52 times, which makes real drift indistinguishable from acceptable usage, and that ambiguity is what lets group (a) persist. Note also that the principle is well-honoured where it matters most — the read side is clean, with UI reading state exclusively from the facade's re-exported stores. The write side is what drifted.

## Layer assignment

Frontend-internal refactor. No domain logic moves and nothing new crosses IPC — the same Rust commands are called, through one module instead of many.

Logic / responsibility | Layer | Why it belongs there
--- | --- | ---
Playback command dispatch (adapter routing: native vs HTML5) | Frontend — `src/lib/player/` only | Presentation-layer plumbing, but must be centralised: the facade picks between the native backend and the HTML5 `<video>` adapter. A caller bypassing it silently skips that routing.
Playback authority (position, pause, rate, track changes) | Rust / the player | Unchanged. The player is authoritative; UI is a consumer. This spec does not touch that direction.
Queue mutation commands | Frontend facade → Rust | Rust owns queue state; the facade is the single call path to it.
Player settings CRUD (EQ, video, autoplay, cache) | Frontend, outside the facade | Configuration, not playback control — read/written on a settings page with no adapter routing. Explicitly carved out below.
Backend→frontend event handling | `playerEvents.ts` | Already correct. It is the facade's own plumbing, not a bypassing consumer.

No Jellyfin taxonomy is involved, so no boundary-leak risk.

## Design

### 1. Narrow the principle to what it actually means

Amend CLAUDE.md and 02-svelte-frontend.md:

> Unified player boundary. UI controls playback — transport, queue mutation, track selection, playback initiation — only through `playerController`. Player configuration commands (`player_*_settings`, `player_configure_jellyfin`, `player_*_cache_config`, `player_preload_upcoming`) are ordinary IPC and may be called directly from settings surfaces.

This is a clarification, not a relaxation: it makes group (c) explicitly fine so that a violation count means something. A rule with 52 nominal violations, most of them acceptable, provides no signal.

### 2. Fill the facade gaps (group b)

Add to `playerController`, each a thin pass-through preserving current behaviour:

    playQueue, getQueue, getStatus,
    enterBackgroundAudio, exitBackgroundAudio,
    setSleepTimer, cancelSleepTimer,
    playNextEpisode, cancelAutoplayCountdown,

Do this first — group (a) cannot be fully migrated while callers still need a direct import for a neighbouring call, and a file that imports `commands` for one reason will keep using it for others.

### 3. Migrate group (a)

Mechanical: replace `commands.playerX(...)` with `playerController.x(...)`. Highest-value first: `queue.ts` (10 sites, all direct facade equivalents), then `player/[id]/+page.svelte`, `VideoPlayer.svelte`, `sleepTimer.ts`, `playbackMode.ts`, `library/[id]/+page.svelte`.

Two sites need care rather than substitution:

- `playerEvents.ts` (`playerOnPlaybackEnded`, `playerStop` in the error path). This module is the facade's event plumbing — the counterpart to `index.ts`, inside the boundary conceptually though not by directory. Treat `src/lib/services/playerEvents.ts` as inside the boundary and exempt it, rather than making it call the facade that calls back into it. Record this in the lint config with the reason.
- `VideoPlayer.svelte` — registers its own adapter via `setActiveAdapter`. Its `playerStop`/`playerPlayItem` calls interact with adapter lifecycle, and CLAUDE.md's gotcha ("no lifecycle calls after an `await` in `onMount`") applies. Migrate this file last and on its own, so an Android seek regression is bisectable to one commit.

### 4. Add the lint rule (the part that makes it stick)

The audit's finding was that principles with working checks held up and principles without them drifted. This principle has no check. Add `scripts/check-player-boundary.sh`, wired as `bun run check:player-boundary` and into `test-all.sh`:

    # Playback-control commands that MUST go through the facade.
    CONTROL='player(Play|Pause|Toggle|Stop|Seek|Next|Previous|SkipTo|ToggleShuffle|CycleRepeat|RemoveFromQueue|MoveInQueue|SetVolume|ToggleMute|SetSubtitleTrack|SeekVideo|SwitchAudioTrack|PlayTracks|PlayAlbumTrack|PlayItem|PlayQueue|AddTrackById|AddTracksByIds|GetQueue|GetStatus|EnterBackgroundAudio|ExitBackgroundAudio|SetSleepTimer|CancelSleepTimer|PlayNextEpisode|CancelAutoplayCountdown|OnPlaybackEnded)'

    # Inside the boundary: the facade and its event plumbing.
    EXEMPT='^src/lib/player/|^src/lib/services/playerEvents\.ts$'

Flag `commands.$CONTROL` in non-test `src/` files outside `EXEMPT`. Config commands are deliberately absent from the list, matching §1 — so the check encodes the narrowed rule rather than the aspirational one.

An ESLint `no-restricted-syntax` rule would give better editor feedback, but the project has no ESLint config; a shell check matches the existing `check:boundary` precedent and adds no dependency.

## Out of scope

- Changing playback behaviour — pure refactor.
- The one-directional state principle (audited clean; UI reads from facade stores only).
- Moving settings CRUD behind the facade (§1 explicitly carves it out).
- Introducing ESLint.
- Refactoring `VideoPlayer.svelte`'s 2079 lines generally, beyond its facade call sites.
- The `commands.player*` calls inside `src/lib/player/` — that is the facade doing its job.

## Acceptance criteria

- `playerController` exposes the group-(b) methods listed in §2.
- `grep -rn "commands\.player" src/ --include='*.ts' --include='*.svelte' | grep -v '^src/lib/player/' | grep -v 'playerEvents\.ts' | grep -v '\.test\.' | grep -v bindings.ts` returns only configuration commands per §1 — no transport, queue, or playback-initiation call.
- `queue.ts` no longer imports `commands` from bindings.
- `bun run check:player-boundary` exists, is wired into `test-all.sh`, and passes.
- The check fails when a `commands.playerStop()` is added to a non-exempt file — verify explicitly, as with the other gates in this batch.
- The check does not fail on `commands.playerSetAudioSettings()` in `settings/+page.svelte` (the §1 carve-out works).
- CLAUDE.md and `02-svelte-frontend.md` carry the narrowed wording, including the config carve-out and the `playerEvents.ts` exemption with its reason.
- No behavioural change: audio and video playback, queue reorder, shuffle/repeat, sleep timer, background audio, and autoplay all behave as before on both Linux and Android.
- Android seek and `onMount` lifecycle still correct after the `VideoPlayer.svelte` migration (the known-fragile path).
- `bun run check` and `bun run test` pass.
- `bun run check:boundary` passes.
- Changed code carries `// TRACES:` comments.
- No Rust change, so no `bindings.ts` regeneration.

## Testing

Frontend (`bun run test`):

- Extend the existing facade tests to cover each new group-(b) method: it forwards to the right command with the right arguments, and routes to the active adapter where applicable.
- `queue.ts` tests: assert calls land on `playerController`, not `commands`. Mock the facade — a test that mocks `commands` would pass either way and guard nothing.
- Keep `tauriIntegration.test.ts` and the other IPC param-naming tests green; they cover the camelCase rule this refactor must not disturb.

Manual (no automated coverage for these paths):

- Linux: play/pause/seek/next/prev, queue reorder, shuffle, repeat, sleep timer, transcoded video (HLS), background audio enter/exit.
- Android: the same, plus lockscreen/MediaSession controls, and seek after entering the player — the specific regression CLAUDE.md warns about.

Because this is a pure refactor, the strongest signal is that no test changes expectation. A test needing its assertions rewritten means behaviour moved — investigate rather than update it.

## TRACES

Allocate in `requirements.md`:

- DR-095 — "UI playback control is routed exclusively through the `playerController` facade (`src/lib/player/`), with `playerEvents.ts` inside the boundary as its event plumbing and player configuration commands explicitly outside it; enforced by `scripts/check-player-boundary.sh`." Category: Player. Traces to UR-005. Status: Done on merge.

    // src/lib/player/index.ts
    // TRACES: UR-005 | DR-095

New facade tests take `@req-test: UT-089` onward (next free UT is UT-089; coordinate if landing alongside the sibling specs, which draw from the same pool).

## Notes for the implementer

- A parallel Claude session may be active in this repo — `git diff` before "repairing" unexpected changes (CLAUDE.md §Gotchas).
- Order matters: §2 (fill gaps) → §3 (migrate, `VideoPlayer.svelte` last and alone) → §4 (add the check). Adding the check first turns `master` red.
- 🔴 `VideoPlayer.svelte`: no lifecycle calls after an `await` in `onMount` — it flips to HTML5 mode and breaks Android seek. Do not let a mechanical substitution introduce an `await` before a lifecycle call.
- The facade's `requireHandle()` may throw where a raw `commands` call did not. Check each migrated call site's error handling rather than assuming the try/catch still covers the same cases.
- `playbackMode.ts` interacts with remote-mode routing (`play_on_session` vs local MPV). Verify remote casting still works after migrating its `playerPlayTracks` call.
- This spec is deliberately the lowest priority of the audit batch: it is the largest diff and the only one carrying real regression risk, while the traceability gate is a few lines and restores a dead safety net.

# Spec: Windows native audio backend

Status: Proposed — not started. Windows still runs on `WebviewAudioBackend`. Blocked on libmpv2-migration.md, whose crate swap has not landed either. Requirements: UR-003, UR-027, UR-032, UR-033 → DR-030, DR-035, DR-036; ⚠️ the suggested id IR-030 has since been allocated to the scheduled catalog crawl — allocate a fresh id (IR-033 or later) on implementation UX spec: n/a — Settings › Audio already renders the controls Supersedes / revises: acts on the "audio can unify, video cannot" conclusion in playback-backend-unification.md

## Summary

Give Windows a real native audio backend instead of the current webview `<audio>` shim. Windows is the only platform where audio playback has no decoder of its own: `WebviewAudioBackend` hands a URL to a frontend `<audio>` element and relays transport commands. It cannot set volume, cannot apply any audio setting, and reports state only via DOM events.

Audio needs no rendering surface, so none of the webview-compositing problems that block unified video apply here. This is the cleanest available win.

## Motivation

`WebviewAudioBackend` was a deliberate stopgap ("audio-only playback for platforms without a native audio backend"), and it works — but it has a hard functional gap. From `webview_audio_backend.rs`:

    #![allow(unused)]
    fn main() {
    fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
        // ...stores locally only; there is no ControlCommand action for volume
    }
    }

So volume changes never reach the element; the frontend has to observe the player store and apply volume itself. `set_audio_settings` likewise stores values that nothing consumes — EQ, normalization, and gapless are all inert on Windows.

Meanwhile the backend-unification investigation established that a native audio engine is unproblematic on Windows specifically: `tauri-plugin-libmpv` lists Windows as its fully tested platform (in contrast to Linux, where embedding is broken — but that is a video surface problem, which audio does not have).

## Layer assignment

Logic / responsibility | Layer | Why it belongs there
--- | --- | ---
Decoding and playing the audio stream | Rust | Playback is domain logic; every other platform already decodes in Rust or a native player. The webview shim is the anomaly.
Applying `AudioSettings` (EQ/normalize/gapless) | Rust | Same `AudioSettings` contract as MPV/ExoPlayer; band layout and presets stay canonical in `settings.rs`.
Position/state reporting | Rust | Restores the project's core principle — the player is the authoritative source of state. Today Windows inverts this: the DOM element is authoritative and Rust mirrors it.
Volume | Rust | Currently broken precisely because it is split across the boundary.
Rendering the player UI | Frontend | Unchanged.

The strongest argument for this change is the third row. CLAUDE.md states playback state is one-directional with the player authoritative; on Windows that is currently false, and the `player_report_*` round-trip exists to paper over it.

## Design

### Engine choice

Two viable options; libmpv is recommended for consistency with the Linux audio backend.

| libmpv | GStreamer
--- | --- | ---
Windows status | ✅ `tauri-plugin-libmpv` reports fully tested | ✅ works, but…
Rust bindings | `libmpv2` 6.0.0, active | `gstreamer-rs` 0.25.x, excellent
Cross-MSVC from Linux | ⚠️ needs prebuilt DLL + import lib | ❌ `gstreamer-sys` uses pkg-config, fights `cargo-xwin`
Code reuse | ✅ `MpvBackend` logic is directly reusable | ❌ a second engine to learn
Crossfade capable | ❌ single-stream chain | ✅ `audiomixer`

libmpv wins on reuse: `MpvBackend`'s `set_audio_settings` — the `af` lavfi graph built by `build_af_filter`, `eq_filter_entries`, `normalize_filter_entry` — is platform-independent and would apply unchanged.

The one reason to prefer GStreamer is crossfade (UR-031), which mpv structurally cannot do. If crossfade becomes a priority, revisit; it would then argue for GStreamer on both Linux and Windows, which is a much larger change.

### Structure

Rename the cfg gate so `MpvBackend` is no longer Linux-only:

    #![allow(unused)]
    fn main() {
    // src-tauri/src/player/mod.rs
    #[cfg(any(target_os = "linux", target_os = "windows"))]
    pub mod mpv_backend;
    }

`MpvBackend::new` needs one platform-specific branch: `detect_audio_system()` currently probes `pactl`/`pw-cli`/`/proc/asound/cards` to pick an `ao`. On Windows the equivalent is `wasapi` (mpv's default), so the detection is a `#[cfg]` returning `"wasapi"` — no probing needed.

Everything else — the event loop, the 250ms position thread, the seek-suppression window, the `af` filter graph — is unchanged.

`WebviewAudioBackend` stays for other targets (macOS and anything else hitting the `not(any(...))` arm) and as the fallback if libmpv fails to initialize. The existing `emit_backend_init_failed` path already handles that gracefully.

### Build

`libmpv2-sys` is well-suited to cross-compilation: no pkg-config, vendored headers, pregenerated bindings (no libclang). It emits `cargo:rustc-link-lib=mpv` unconditionally, so the build must supply a linkable import library for `x86_64-pc-windows-msvc`.

Keep the `build_libmpv` feature off — its Unix path shells out to mpv-build and explicitly rejects cross-compilation.

🔴 Per CLAUDE.md, the prebuilt libmpv must be added to the builder image (`Dockerfile.builder` → rebuild + push via `scripts/build-builder-image.sh`), not installed at CI job time. `libmpv-2.dll` must also be bundled into the NSIS installer via `tauri.conf.json`'s resources.

### Verified build mechanics

The cross-compile path was tested hands-on from Linux (July 2026), not inferred:

- Neither shinchiro nor zhongfly ships an `mpv.def` or MSVC `mpv.lib` — only a MinGW `libmpv.dll.a`. (Several online sources claim otherwise; they are wrong.)
- An MSVC-style import lib can be generated locally with LLVM tools only: `llvm-readobj --coff-exports libmpv-2.dll` → synthesize `mpv.def` → `llvm-dlltool -m i386:x86-64 -d mpv.def -l mpv.lib`. `llvm-lib /def:` produces a byte-identical result.
- A real `lld-link` link against that import lib succeeds, and the resulting import table resolves `mpv_client_api_version` from `libmpv-2.dll`. `lld-link` is the linker `cargo-xwin` uses, so this is the load-bearing step.
- Linking directly against the shipped MinGW `libmpv.dll.a` also succeeds, so def-generation may be skippable — but that relies on lld's GNU-archive tolerance rather than a documented contract. Keep `llvm-dlltool` as the fallback.
- MinGW origin is not an ABI problem: libmpv exports a pure C ABI, and the x86-64 Windows calling convention is platform-defined. The upstream note that MSVC cannot build mpv is frequently misread as "MSVC cannot link libmpv" — that is not what it says.
- 🔴 Never free/realloc across the DLL boundary — use `mpv_free`.

Build wiring is ordinary: `cargo:rustc-link-lib=dylib=mpv` plus `cargo:rustc-link-search`. Nothing about libmpv conflicts with `cargo-xwin`.

### Size and shipping

Measured uncompressed: 93 MiB (zhongfly `mpv-dev-lgpl-x86_64`) vs 112 MiB (shinchiro, full GPL build); ~26–30 MB compressed in the `.7z`.

Ship the zhongfly LGPL build — smaller, and there is no reason to pull the GPL variant in for an audio-only use.

Import-table inspection confirms no companion DLLs are needed: every dependency is a system DLL (`KERNEL32`, `USER32`, `d2d1`, `DWrite`, `OPENGL32`, `vulkan-1`, UCRT `api-ms-win-*`). One file to bundle.

93 MiB is still substantial against a Tauri app's usual few MB. Since we use mpv audio-only, investigate whether a pruned build (no video decoders, no libplacebo) is worth producing for the builder image — but treat that as an optimization, not a blocker.

## Out of scope

- Windows video. Stays in WebView2 + hls.js — it works and has ABR.
- Crossfade (UR-031/DR-034) — not implemented anywhere; needs its own spec.
- Replacing `WebviewAudioBackend` for macOS.
- MPRIS/SMTC media-key integration — worth a follow-up, not this spec.

## Acceptance criteria

- Windows build produces a `MpvBackend`-backed player; `backend-init-failed` is emitted (not a crash) if libmpv is unavailable.
- Volume control works from the UI — the current hard gap.
- EQ, normalization, and gapless audibly take effect on Windows.
- Position/state originate in Rust; the `<audio>` element is no longer in the audio path.
- Seek, next/previous, and queue advance work; sleep timer stops playback.
- `libmpv-2.dll` ships in the NSIS installer and the app runs on a clean Windows VM with no mpv installed.
- Builder image carries the Windows libmpv artefacts; no toolchain install added to any CI step.
- `bun run check`, `bun run test`, `bun run check:boundary` pass.
- `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
- New requirement-implementing code carries `// TRACES:` comments.

## Testing

Rust: the existing `mpv_backend_test.rs` and the `build_af_filter` / `normalize_filter_entry` / `eq_filter_entries` unit tests already cover the filter-graph logic and are platform-independent — they should pass unchanged under a Windows `cargo check`/test. Add a test asserting `detect_audio_system()` returns `wasapi` under `cfg(windows)`.

Manual, on Windows: volume, EQ preset change, normalization toggle, gapless between two tracks, seek, queue advance, sleep timer. Then the packaging test — install the NSIS output on a clean VM and confirm it launches and plays.

Per CLAUDE.md, the volume gap is a bug fix: write a failing test for "`set_volume` reaches the backend" before implementing.

## TRACES

- Windows `MpvBackend` construction in `create_player_backend` → `// TRACES: UR-003 | IR-030`
- `detect_audio_system` Windows branch → `IR-030`
- Existing `set_audio_settings` gains Windows coverage → `UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036`
- Allocate IR-030 in `requirements.md` ("libmpv integration for Windows audio playback").

## Notes for the implementer

- Do this after libmpv2-migration.md — porting the current dead `libmpv` git pin to a second platform would double the migration work.
- `libmpv2` has broken its API in every major release (4.0 removed command helpers, 5.0 removed `mpv_node`, 6.0 changed `RenderContext` ownership). Pin an exact version.
- Only the `render`-feature parts of `libmpv2` concern video; audio-only use does not need it, and disabling the default `render` feature may shrink the build.
- A parallel Claude session may be active — `git diff` first.

# Spec: Migrate to libmpv2 and declare the project licence

Status: Partially implemented — the `LICENSE` file has landed (part 2). The `libmpv` → `libmpv2` swap (part 1) is not done: `src-tauri/Cargo.toml` still pins the abandoned crate to a git branch. Requirements: UR-003 → IR-003 (revises the MPV integration); no new user-facing behaviour UX spec: n/a Supersedes / revises: dependency and licensing housekeeping identified in playback-backend-unification.md

## Summary

Two related pieces of housekeeping that block or complicate later work:

1. Replace the abandoned `libmpv` crate (pinned to a git branch) with the maintained `libmpv2`.
2. Add a `LICENSE` file. The project has none, which leaves its legal status undefined while it links GPL-licensed libmpv.

Neither changes user-visible behaviour. Both are prerequisites for windows-native-audio-backend.md.

## Motivation

### The dependency is dead

    # src-tauri/Cargo.toml
    libmpv = { git = "https://github.com/ParadoxSpiral/libmpv-rs.git", branch = "master" }

- crates.io `libmpv` 2.0.1 was published 2020-09-29.
- The upstream repo's last commit was 2023-01-08; nothing since was released.
- We pin a git branch, so builds are not reproducible — the same lockfile-less checkout can resolve differently over time, and CI has no protection if the branch moves or the repo disappears.

`libmpv2` (kohsine/libmpv2-rs) is a maintained fork of exactly this crate: 6.0.0 released 2026-05-12, ~23.5k recent downloads against the original's ~1.1k, releases roughly quarterly since 2024.

### The project has no licence

There is no `LICENSE`/`COPYING` file and `src-tauri/Cargo.toml` has no `license` field. The project is open source and will never be commercial, so this is purely an omission — but it matters because we link libmpv, and "no licence" defaults to all rights reserved, which is incompatible with distributing a GPL-derived work.

## Design

### Part 1 — licence

Use GPLv3. This is forced, not chosen:

- mpv's default build is GPLv2-or-later, so the combined work must be GPL-compatible.
- Apache-2.0 is GPLv2-incompatible (patent-termination and indemnification clauses) but GPLv3-compatible.
- A scan of the dependency tree found Apache-2.0-only crates with no alternative arm — most importantly `tao` (Tauri's own windowing crate), plus `sync_wrapper`, `gethostname`, and `ring` (Apache-2.0 AND ISC).

`tao` is unavoidable in a Tauri app, so GPLv2 is unavailable. Exercising mpv's "or later" option puts the combination at GPLv3.

Actions:

- Add `LICENSE` containing the GPLv3 text.
- Add `license = "GPL-3.0-or-later"` to `src-tauri/Cargo.toml` and `license` to `package.json`.
- Note in the README that the binary links libmpv (GPLv2+) and FFmpeg.

Because the project is open source, we use mpv's default GPL build — no `-Dgpl=false`, no LGPL FFmpeg build, and none of the LGPL §6 relinking analysis that a proprietary app would need. We keep VAAPI/VDPAU/X11 and every GPL FFmpeg filter.

🔴 Never build FFmpeg with `--enable-nonfree` — that produces a binary that is unredistributable under any licence, open source or not.

### Part 2 — libmpv → libmpv2

    # Linux (and later Windows, per the Windows audio spec)
    libmpv2 = "=6.0.0"

Pin exactly: `libmpv2` has broken its API in every major release.

Breaking changes to expect, from the changelog:

Version | Change | Impact here
--- | --- | ---
4.0.0 | Removed command helper methods — call `mpv.command(...)` directly | Low; we already use `command`/`set_property`
5.0.0 | Removed `mpv_node` support entirely (properties return strings; parse JSON yourself); `EventContext` folded into `Mpv`; `ProtocolContext` → `Protocol` | Medium — `start_event_loop` uses `create_event_context()`; check whether that call still exists
6.0.0 | `RenderContext::new()` → `Mpv::create_render_context()`; `'static` bound on `OpenGLInitParams`; render context now borrows `Mpv` (fixes a use-after-free) | None — we do not use the render API

The last row matters: we run mpv audio-only (`video = no`), so the entire render surface is irrelevant to us. Consider disabling the default `render` feature to reduce build surface.

The main porting work is the event loop in `mpv_backend.rs` — `wait_event`, `disable_deprecated_events`, and the `FileLoaded` / `PlaybackRestart` / `PropertyChange` / `EndFile` handling, given 5.0.0 folded `EventContext` into `Mpv`.

Everything else — `set_property` calls, the `af` filter graph, the 250ms position thread, the seek-suppression window — should port unchanged.

## Layer assignment

No logic moves. This is a dependency swap plus a licence file; the `PlayerBackend` trait boundary is untouched.

Logic / responsibility | Layer | Why it belongs there
--- | --- | ---
mpv event → `PlayerStatusEvent` mapping | Rust (unchanged) | Already correct; only the binding API beneath it changes.

## Out of scope

- Any behaviour change. If playback behaves differently after this, that is a bug.
- Windows support — separate spec, but this must land first.
- Adopting the render API. We are audio-only on mpv.
- Re-licensing decisions beyond adding the file the project already implies.

## Acceptance criteria

- `LICENSE` (GPLv3) present; `license` field set in `Cargo.toml` and `package.json`.
- A full dependency-licence audit has been run (`cargo install cargo-license && cargo license`) and confirms no GPLv3-incompatible dependency. (The scan behind this spec resolved 441 of 575 crates from the local registry cache; the remaining 134 are unverified.)
- `libmpv` git dependency removed; `libmpv2` pinned to an exact version.
- Linux audio playback works identically: play/pause/seek/volume, queue advance, gapless, EQ, normalization, sleep timer.
- Position updates still arrive at 250ms; the 150ms post-seek suppression still prevents the jump-to-zero glitch.
- `EndFile` still emits `PlaybackEnded` only for EOF (not STOP/QUIT/ERROR) — autoplay depends on this.
- Builder image updated if the libmpv dev package requirement changed; no toolchain install added to any CI step.
- `bun run check`, `bun run test`, `bun run check:boundary` pass.
- `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.

## Testing

The existing `mpv_backend_test.rs` plus the `build_af_filter`, `eq_filter_entries`, and `normalize_filter_entry` tests are the regression net — they must pass unchanged, since none of them touch the binding API.

The event loop has no unit tests and is where the risk concentrates. Verify manually on Linux:

1. Play → pause → play; confirm position does not flash to 0:00 (the known playing-event regression).
2. Seek mid-track; confirm no jump-to-zero within 150ms.
3. Let a track end naturally; confirm autoplay advances (exercises `EndFile` EOF).
4. Press stop; confirm autoplay does not advance.
5. Sleep-timer expiry; confirm it stops without triggering autoplay.

Cases 3–5 are the ones most likely to break silently, and each corresponds to a bug already fixed once in this codebase.

## TRACES

- `MpvBackend` construction / event loop → existing `// TRACES: UR-003 | IR-003`, unchanged
- No new requirement IDs; this is a dependency migration.

## Notes for the implementer

- Do this before the Windows audio backend.
- Read the 4.0/5.0/6.0 changelogs before writing code — the crate has broken API in every major release, most recently two months before this spec.
- The crates.io `repository` field for `libmpv2` points at `kohsine/libmpv-rs`, but the repo was renamed to `libmpv2-rs`; the old raw URLs 404.
- `libmpv2-sys` ships pregenerated bindings and vendored headers, so no libclang is needed at build time — relevant to keeping the builder image thin.
- A parallel Claude session may be active — `git diff` before "repairing" unexpected changes.

# Spec: Two-path media — selectable playback bitrate, independent whole-file download

Status: Partially implemented. Landed: the cache/download unification (DR-126, DR-127 — a cache entry is a `downloads` row with a shorter life, and eviction only reclaims the temporary tier), local playback of downloaded media (DR-128), and the one-path/one-row invariants that followed (DR-133 … DR-138). DR-123 is in progress. Still open: the read-through capture itself — DR-122, DR-124, DR-125.

DR-121 has shipped and left this spec. The player quality selector, the per-playback bitrate ceiling, and the backend-owned stream decision it needed were built as backend-owned stream selection (DR-225 … DR-228) and are described in 01-rust-backend.md and 03-data-flow.md. The settings-level ceiling (DR-162) is the same section. What remains here is the capture half only — this spec no longer specifies anything about choosing a bitrate.

Requirements: UR-070, UR-071 → DR-122, DR-123, DR-124, DR-125; IR-032 Related: the locally-indexed search and downloaded-browse work, both shipped — see 03-data-flow.md and 06-downloads-and-offline.md

## Summary

Two things that are today tangled become explicitly separate:

- The playback path streams at a bitrate the viewer can change from the player. It is ephemeral and its rendition is volatile.
- The download path fetches the whole file at one canonical quality, in the background, independently of whatever playback is doing.

Bytes fetched for playback are kept only when the playback rendition happens to be the same artifact the download path would produce — i.e. direct play. Otherwise playback bytes are discarded and the download path does its own fetch.

## Motivation

The appealing version of this — "stream and download at once, switch when enough has arrived" — breaks the moment the viewer can change bitrate. A capture taken while the rendition changes underneath it is a splice of two encodings: not a playable file, and not something that can be honestly recorded as a download. Once bitrate is selectable, one stream cannot serve both jobs.

Separating the paths also removes the thing that made the original idea expensive: there is no mid-playback source swap to engineer, because the download never has to take over the live session. It lands on disk and is used at the next natural boundary — next episode, or next time the item is played.

What exists already and is not this: `SmartCache` predictively downloads other items, `player_preload_upcoming` warms the next one, and `refresh_queue_local_sources` swaps queue entries to local at boundaries. All of it concerns items you are not currently playing.

## Layer assignment

Logic / responsibility | Layer | Why it belongs there
--- | --- | ---
Available bitrate options for an item | Rust | Derived from Jellyfin's media sources and playback-info negotiation; changes with the API.
Mapping a chosen bitrate to transcode parameters | Rust | Domain vocabulary. `get_video_download_url` already owns the quality→params mapping; playback must reuse it, not restate it.
Deciding whether playback bytes are keepable (direct play vs transcode) | Rust | Depends on the negotiated session.
Canonical download quality | Rust | Policy over domain data.
Cache eviction, storage budget, sparse-range bookkeeping | Rust | Storage policy.
Promotion to a `downloads` row, and what invalidates a cache entry | Rust | Domain state.
Rendering the quality selector; remembering the last choice | Frontend | Presentation and a view preference. The list comes from Rust.
WiFi-only / opt-in toggles | Frontend collects, Rust enforces | The control is UI; the gate must hold even if the UI never calls.

Borderline, recorded: the default playback bitrate could look like a user preference (frontend). It goes to Rust because it must be reconcilable with what the server can actually produce for a given media source — a preference the backend has to validate is not a preference the frontend can own alone. The frontend stores the user's choice; Rust decides what that choice resolves to.

## Design

### DR-121 — moved out (shipped)

Bitrate selection in the player shipped as DR-225 … DR-228; see 01-rust-backend.md.

The one constraint here that the capture work still has to respect: a quality change re-negotiates within HLS. Returning a progressive `stream.mp4` for a transcode means playback never starts, because the server encodes the whole file before serving a byte (DR-140). That is why DR-122 below abandons a capture on a quality change rather than trying to splice one.

### DR-122 — The playback path is ephemeral

Playback bytes are not persisted unless DR-124 says they are keepable. No partial capture is ever retained across a quality change: on change, any in-flight capture for that session is abandoned and its partial file deleted.

### DR-123 — The download path is independent

Downloading the whole file is a separate operation through the existing download manager, at one canonical quality (default `original`, the direct static copy), using `/Videos/{id}/stream.mp4` — progressive and Range-capable, which is what the resumable download worker relies on. It is unaffected by what playback is doing, and playback is unaffected by it.

Once complete it becomes an ordinary download row, so everything already built on top of downloads — offline browsing, `refresh_queue_local_sources`, the Downloads page — picks it up with no further work.

Prerequisite: downloaded video is currently never played locally. `repository_get_video_stream_url` goes straight to the online repo and player/[id]/+page.svelte:316 calls it with no local check — so a completed video download is still streamed. This must be fixed or the whole feature is invisible for video.

### DR-124 — Keep playback bytes only when they are the download

Capture is enabled only where the played bytes and the canonical download artifact are the same thing — a direct-play session. Then:

Path | Mechanism
--- | ---
Android / ExoPlayer | `SimpleCache` + `CacheDataSource`, keyed by item id and media-source id so renditions never collide. LRU evictor sharing the existing smart-cache budget — not a second budget over the same disk.
Linux audio / MPV | `stream-record`, set through the existing `set_property` plumbing.
Linux video (HLS transcode) | Not captured. Segments are not a file; assembling one needs ffmpeg, which is not a dependency and which CI is forbidden from installing at job time. The download path (DR-123) covers this case instead.

Two abandonment rules, both of which must delete the partial rather than promote it:

- Seek during an mpv capture. `stream-record` is documented as intended for linear streams; seeking breaks the recording. Straight-through listening captures, scrubbing does not.
- Any quality change (DR-122).

### DR-125 — Promotion, rendition, and invalidation

A capture is promoted to a `downloads` row (`status = 'completed'`) only when it covers the whole resource. Partial captures stay cache and remain evictable.

A new `downloads.source_rendition` column records the negotiated quality/container/codec of whatever produced the bytes; `NULL` for rows fetched by the existing paths, which are always `original`. This is what makes an "upgrade to original" action possible later, and what stops a 720p capture and a 4K download being indistinguishable rows.

Invalidation. A quality change never touches a file that already exists — neither a permanent download nor a completed temporary one. Both remain valid copies of the rendition they hold, and deleting either would throw away bytes already paid for.

What a quality change does invalidate is an in-flight capture or background download of cached media: it is abandoned and restarted at the newly chosen quality, because a capture spanning a rendition change is a splice of two encodings rather than a playable file (DR-122).

So the rule is about ongoing work, not stored files. Nothing in this spec deletes user data.

### Gating

Capture and background download obey the existing WiFi-only gate and storage budget, and are off unless opted in. Enforcement is in Rust.

## Out of scope

- Mid-playback switch onto a completing download. Two independent paths make it unnecessary; the download is used from the next boundary.
- Backfilling the unplayed remainder of a capture. Watch 40 minutes and you have 40 minutes; completing it needs sparse-range bookkeeping and a resumable tail fetch. The DR-123 download path already produces a complete file, which is the reason this can wait.
- Bundling ffmpeg to make transcoded video capturable. Real option, large packaging decision, its own proposal.
- Routing Linux video playback through `stream.mp4`. Regresses a documented, hard-won fix.

## Acceptance criteria

- The player offers the qualities Rust reports, and changing one resumes at the same position with audio/subtitle selection preserved.
- A quality change abandons any in-flight capture and leaves no partial file.
- A quality change never deletes a `downloads` row.
- A completed background download of a video is played from disk on the next play (the DR-123 prerequisite).
- A direct-play session played start-to-finish leaves a complete local file with no second fetch; replaying it fetches no media bytes.
- Seeking during an mpv capture abandons it; no truncated file is promoted.
- A transcoded Linux video session is never captured, and never partially promoted.
- Promoted rows record their rendition; existing paths still record `NULL`/`original`.
- Gates hold with the setting off and with the frontend never sending it.
- Eviction cannot delete bytes backing a promoted download row.
- `bun run check`, `bun run test`, `cargo fmt`, `cargo clippy`, `bun run test:rust`, `bun run check:boundary` pass; `bindings.ts` regenerated if Rust types changed.

## Testing

Rust, table-driven and pure where possible: quality→params resolution shared with the download path; keepability (direct play vs transcode vs gate off); promotion (complete → promoted, partial → not, seek-abandoned → not, quality-changed → not); invalidation (evicts cache, never a download row); rendition round-trip.

Android: instrumented — a played direct-play item yields cache entries, and a replay issues no media network request.

Frontend: the quality list renders from backend data with no item-type or codec taxonomy in `src/`; the selector's remembered choice is a view preference.

## TRACES

Piece | Tag
--- | ---
Ephemeral playback / capture abandonment | `// TRACES: UR-070 | DR-122`
Independent whole-file download + local video playback fix | `// TRACES: UR-071 | DR-123, IR-032`
ExoPlayer cache / mpv stream-record / keepability | `// TRACES: UR-071 | DR-124`
Promotion, `source_rendition`, invalidation | `// TRACES: UR-071 | DR-125`

## Notes for the implementer

- A parallel Claude session is active in this repo. `git diff` before "repairing" anything you did not write.
- Do not duplicate the quality→transcode-parameter table. Call the existing one.
- Reuse the smart-cache storage budget; two budgets over one disk is how devices fill up.
- The `downloads` FK to `items` is relaxed (migration 005) — exercise promotion for an item that was never cached.
- Build DR-123's local-playback fix first. Without it nothing in this spec is observable for video.

# Spec: Context-scoped search with filter chips and configurable group order

> ⚠️ Superseded in part by scoped-search-boundary.md. The "frontend only, no Rust changes" decision below (§Background 2, §Design "Scope model" and "Threading scope through the store") left Jellyfin's item-type taxonomy in the presentation layer, which violates the backend/frontend boundary. The taxonomy is being moved into Rust. The user-facing behaviour and UX in this spec are unchanged; only where the scope→item-type mapping and result bucketing live changes. Read the boundary spec before touching search code.

> Progress: the scope→item-type mapping now lives in Rust (`SearchScope::item_types()`); the frontend sends an opaque scope. Result-side bucketing (`GROUP_ITEM_TYPES`) is still frontend-side — see scoped-search-boundary-implementation.md §Stage 2.

Status: Implemented (boundary revision: query side done, result side pending) Scope: Frontend only. No Rust changes required. (Revised — see banner.) Requirements: UR-049 → DR-063, DR-064, DR-065; UR-050 → DR-066, DR-067 (see requirements.md). UX spec: ux-flows.md §6 — §6.1 scope, §6.2 layout, §6.3 group order, §6.4 current deviations.

## Summary

Two related changes to search:

1. Scope — a search started inside a library searches that library. Started from Home, `/library`, or the search tab, it searches everything. The active scope shows as a chip row under the search bar, preselected from context and freely changeable without retyping.
2. Group order — the order result groups appear in (Songs, Albums, Artists, Movies, TV Shows) becomes a drag-and-drop setting instead of being hardcoded.

## Motivation

Searching "office" while browsing TV currently returns music albums, because both search entry points call the same unscoped query. The user has already told us what they're looking at; ignoring that makes search feel indiscriminate and pushes the relevant result below unrelated media.

## Background: what already exists

Verified in code — most of the plumbing is already there. This is substantially a wiring task, not new infrastructure.

1. `SearchOptions` already carries the filter. bindings.ts — `SearchOptions = { limit?, includeItemTypes?, searchTerm? }`.

2. Rust already honours `include_item_types` on both paths — online (online.rs, in the `get_items` options mapping) and offline (offline.rs, which builds a SQL type filter from it). Do not add Rust code for filtering.

3. Per-page list search already does this correctly. GenericMediaListPage.svelte passes `includeItemTypes: [config.itemType]` to `repo.search(...)`. Use it as the reference for the call shape, including the `requestId` handling.

4. The gap is exactly one function. library.ts — `search(query)` takes only a query and calls `repo.search(query, { limit: 10000 }, requestId)`, dropping any scope. Both callers (search/+page.svelte and library/+layout.svelte) go through it.

5. Group order is hardcoded in markup. SearchResults.svelte categorizes into `music{tracks,albums,artists} / movies / tvShows` and renders three fixed sections in source order.

6. Frontend preferences persist via `localStorage`, per the existing `viewMode` precedent in library.ts (`jellytau-view-mode`). Follow that pattern — do not add a Rust settings command for this.

## Design

### Scope model

One `SearchScope` type, defined once and shared:

Scope | `includeItemTypes` | Chip label
--- | --- | ---
`all` | unset | All
`music` | `MusicAlbum`, `MusicArtist`, `Audio`, `Playlist` | Music
`movies` | `Movie` | Movies
`tv` | `Series`, `Episode` | TV

`all` must send no `includeItemTypes` key rather than a list of every type — the two are not equivalent for item types not enumerated here (Person, folders).

### Route → scope resolution (DR-063)

A pure function, unit-testable without a DOM:

    resolveSearchScope(pathname: string): SearchScope

- `/library/music*` → `music`
- `/library/movies*` → `movies`
- `/library/tv*` → `tv`
- `/`, `/library`, `/search`, anything else → `all`

Note `/library/shows/genres` exists as a route; treat `shows` as `tv`. Check the current route list before finalising — do not assume this table is exhaustive.

### Scope is a starting point, not a lock (DR-064)

The resolved scope sets the initial chip only. Once the user taps a chip, their choice governs until they leave the search surface. Concretely: derive the initial value from the route, hold it in component state, and do not re-derive it on every navigation — otherwise a user who widens to All snaps back to TV.

Changing a chip re-runs the current query at the new scope. Changing the query keeps the current scope.

### Threading scope through the store (DR-065)

Extend the store's search signature to accept an optional scope and pass `includeItemTypes` down to `repo.search`. Preserve the existing behaviour exactly: the `requestId` bump, the stale-response guard, the `search-event` listener merge, the 10s timeout, and the empty-query clear path. This is an additive parameter — no caller should break.

### Group order (DR-066, DR-067)

Persist an ordered array of group ids:

    ["songs", "albums", "artists", "movies", "tvShows"]   // shipped default

Rendering composes scope and order as two independent axes, in this order:

1. drop groups outside the active scope,
2. sort the remainder by the user's saved order,
3. omit groups that came back empty.

Scope never rewrites the saved order — narrowing to Music and back to All must restore the user's full arrangement. See ux-flows.md §6.3 for the worked example.

Settings gets a reorderable list. Dragging alone is not sufficient: provide keyboard-operable move up/down controls with proper labels, or the setting is unusable with a screen reader and on any pointerless input.

Unknown or missing ids in the stored array must not crash rendering — treat the stored order as a hint, append any group it doesn't mention, and ignore ids that no longer exist. A user upgrading from a build with fewer groups must not lose the new ones.

## Out of scope

- Ranking within a group. Order is presentation-only.
- Server-side search ranking or the Jellyfin query itself.
- Scope chips on the per-page list search in `GenericMediaListPage` — that page is already implicitly scoped by its own `itemType`.
- Any Rust change.

## Acceptance criteria

- Searching from inside Music returns no movies or TV; from inside TV, no music.
- Searching from Home, `/library`, or the search tab returns all types.
- The chip row renders under the search bar on both the search page and the in-library header search, with the context-derived chip preselected.
- Tapping a chip re-runs the search with the query preserved; editing the query preserves the selected chip.
- Tapping "All" from a context-scoped search widens results without retyping.
- Result groups render in the user's configured order, with out-of-scope and empty groups omitted and relative order preserved.
- Group order is reorderable by drag and by keyboard, persists across restarts, and ships with the documented default.
- Offline search respects scope (the offline path already filters — verify, don't reimplement).
- `bun run check` and `bun run test` pass.

## Testing

Follow the existing frontend test conventions (vitest, `src/lib/**/*.test.ts`).

- `resolveSearchScope` — pure unit tests over the route table, including the `/library/shows/genres` case and unknown routes falling back to `all`.
- Scope → `includeItemTypes` mapping, asserting `all` omits the key entirely.
- The compose step: scope filter + user order + empty-group omission, including the "narrow then widen restores order" case and a stored order containing an unknown id.
- Store-level: scoped search forwards `includeItemTypes` to the repository, and the existing stale-`requestId` guard still discards superseded responses.

New requirement-implementing code needs `TRACES:` comments — see CLAUDE.md. Suggested tags: the scope resolver and chip row `UR-049 | DR-063, DR-064`, the store change `UR-049 | DR-065`, the settings list and ordered rendering `UR-050 | DR-066, DR-067`.

## Notes for the implementer

- Read ux-flows.md §6 first — it is the behavioural spec; this document is the implementation plan.
- The IPC camelCase rule applies to anything new that crosses the boundary (CLAUDE.md) — though this change should not add commands.
- Another session may be active in this repo. Check `git diff` before "repairing" unexpected changes.

# Spec: Move search scope taxonomy behind the Rust boundary

Status: Design authority — Stage 1 implemented, Stage 2 outstanding. The scope→item-type mapping now lives in Rust (`SearchScope::item_types()` in `repository/types.rs`, DR-063 … DR-067). The result-side grouping table (`GROUP_ITEM_TYPES` in `src/lib/utils/searchScope.ts`) is still in the frontend, and `check:boundary` does not match its shape. Delivery status and the remaining work live in scoped-search-boundary-implementation.md; this spec remains the design authority. Scope: Rust + Frontend. Revises a decision in scoped-search.md. Requirements: UR-049, UR-050 (existing) → new DRs for the boundary move (allocate on implementation; suggested DR-063/DR-065/DR-067 revisions plus one new DR for the grouped result shape — see requirements.md). UX spec: unchanged — ux-flows.md §6. This is a pure architecture/boundary change with no user-visible behaviour difference.

## Why this spec exists

scoped-search.md shipped scoped search as "frontend only, no Rust changes." That was the smallest wiring change, and it worked — but it left Jellyfin's item-type taxonomy encoded in the presentation layer, which violates the project's core boundary rule ("Svelte frontend — presentation only"; all business logic in Rust — see CLAUDE.md and architecture/02-svelte-frontend.md).

The offending knowledge lives in searchScope.ts:

    const SCOPE_ITEM_TYPES = {
      music:  ["MusicAlbum", "MusicArtist", "Audio", "Playlist"],
      movies: ["Movie"],
      tv:     ["Series", "Episode"],
    };
    const GROUP_ITEM_TYPES = {
      songs: ["Audio"], albums: ["MusicAlbum"], artists: ["MusicArtist"],
      movies: ["Movie"], tvShows: ["Series", "Episode"],
    };

This is a domain definition — "what the category Music means in Jellyfin's vocabulary" — expressed twice, in the wrong layer. The concrete failure it creates: the day the backend starts returning a type the frontend never enumerated (e.g. `MusicVideo`, or Jellyfin renaming a kind), search silently drops it from both the query filter and the result buckets, and nothing in the Rust layer — the actual authority on Jellyfin's API — can correct it. Two sources of truth that will drift.

This must be fixed while the feature is uncommitted, before the leak ships baked into a released wire contract.

### What is not a leak (leave it alone)

Single concrete-type list pages are not business logic and stay as-is:

- `music.ts` → `["MusicAlbum"]` / `["Playlist"]`, `movies.ts` → `["Movie"]`, `tv.ts` → `["Series"]`
- `GenericMediaListPage.svelte` → `[config.itemType]`
- `ArtistDetailView`, `RelatedItemsSection`, `AddToPlaylistModal`, `PersonDetailView`

"This page shows albums" is a legitimate presentation choice expressed through a generic `getItems(parentId, { includeItemTypes })` API. Only the search scope taxonomy (a semantic category → many types, defined once and reused) crosses the line. Do not invent a backend enum for every list page — that is over-abstraction, not cleaner separation.

## The boundary rule after this change

> The frontend never names a Jellyfin item type in connection with search. It sends an opaque `scope`, and receives results already sorted into labelled groups. The frontend owns only group order (presentation) and rendering.

## Design

### Rust owns scope → item-types (query side)

Add an opaque enum that crosses IPC, and move the expansion table into Rust:

    #![allow(unused)]
    fn main() {
    // repository/types.rs
    #[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug)]
    #[serde(rename_all = "camelCase")]
    pub enum SearchScope { All, Music, Movies, Tv }

    impl SearchScope {
        /// The Jellyfin item types this scope requests, or None for `All`
        /// (which must send NO includeItemTypes — see below).
        pub fn item_types(self) -> Option<Vec<String>> {
            match self {
                SearchScope::All    => None,
                SearchScope::Music  => Some(vec!["MusicAlbum", "MusicArtist", "Audio", "Playlist"]
                                            .into_iter().map(String::from).collect()),
                SearchScope::Movies => Some(vec!["Movie".into()]),
                SearchScope::Tv     => Some(vec!["Series".into(), "Episode".into()]),
            }
        }
    }
    }

`SearchOptions` gains `scope` and the search command resolves it into the existing `include_item_types` filter inside Rust, before dispatching to the online/offline paths (which already honour `include_item_types` — do not touch their filtering, per scoped-search.md §Background 2).

    #![allow(unused)]
    fn main() {
    pub struct SearchOptions {
        pub limit: Option<usize>,
        pub search_term: Option<String>,
        pub scope: Option<SearchScope>,        // NEW
        // include_item_types stays for the single-type list-page callers,
        // but the SEARCH command derives it from `scope` when scope is set.
    }
    }

Precedence: if `scope` is set it wins; `include_item_types` remains for the non-search `getItems` callers. Document this so a future reader does not send both.

`All` sends no filter. Preserve the existing invariant: `All` must omit `includeItemTypes` entirely, not send the union of every enumerated type — types nobody listed (Person, folders) would otherwise be filtered out. This is why `item_types()` returns `Option`, and the command must skip the filter on `None`.

### Rust owns result bucketing (result side)

Results arrive pre-grouped. Rust classifies each returned `MediaItem` into a group by its type — the `GROUP_ITEM_TYPES` knowledge, moved to the authority:

    #![allow(unused)]
    fn main() {
    #[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug)]
    #[serde(rename_all = "camelCase")]
    pub enum SearchGroupId { Songs, Albums, Artists, Movies, TvShows }

    #[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
    #[serde(rename_all = "camelCase")]
    pub struct SearchGroup { pub id: SearchGroupId, pub items: Vec<MediaItem> }

    #[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
    #[serde(rename_all = "camelCase")]
    pub struct GroupedSearchResult { pub groups: Vec<SearchGroup> }
    }

Rust emits every non-empty group it can classify, in a stable canonical order. It does not apply the user's ordering or drop out-of-scope groups — those are presentation and stay frontend-side (see below). Items whose type maps to no group are omitted from grouped output (same as today's frontend filter).

### 🔴 The `search-event` wrinkle — both payloads must change

Search returns results twice: the command resolves with instant local-cache results, then the merged cache+server union arrives later via the `search-event` listener (see library.ts `search()` and architecture/03-data-flow.md). Both the command return value and the `search-event` payload must carry `GroupedSearchResult`. If only one is converted, the instant results group and the merged ones do not (or vice versa), and the UI flickers between shapes. This is the single largest part of the change and the easiest to half-do.

### What the frontend keeps (all pure presentation)

searchScope.ts retains:

- `SearchScope` type — now sourced from the generated bindings, mirroring the Rust enum (delete the hand-written union).
- `SCOPE_LABELS`, `SEARCH_SCOPES` (chip labels / order).
- `resolveSearchScope(pathname)` — route → initial scope. Pure, DOM-free, unit-tested. Stays exactly as-is.
- `SearchGroupId` (from bindings), `GROUP_LABELS`.
- `normalizeGroupOrder`, `groupsForScope`, `moveGroup`, `reorderGroups`, `DEFAULT_GROUP_ORDER` — group-order persistence and reordering, all presentation.

searchScope.ts loses:

- `SCOPE_ITEM_TYPES`, `GROUP_ITEM_TYPES` (moved to Rust).
- `scopeItemTypes()`, `groupItemTypes()`.
- The `.type`-inspecting body of `composeSearchGroups()`.

`composeSearchGroups()` shrinks to a presentation composition over Rust's groups — no `.type` inspection anywhere:

    // Take Rust's pre-bucketed groups; drop out-of-scope, sort by saved order,
    // attach labels, omit empties. No Jellyfin type vocabulary.
    composeSearchGroups(groups: SearchGroup[], scope, order): DisplayGroup[]

`GROUP_SCOPE` (which group belongs to which scope) is a borderline case: it is "is Songs part of the Music scope," arguably taxonomy. But because Rust already filtered the query by scope, out-of-scope groups will simply be empty and drop out via the empty-omit rule — so the frontend does not strictly need `GROUP_SCOPE` for correctness once Rust filters. Recommendation: delete `GROUP_SCOPE` and rely on empty-omission; if kept for belt-and-suspenders, treat it as a display hint, not authority.

### Frontend call-site changes

- library.ts `search(query, scope)` sends `{ scope }` in `SearchOptions` instead of computing `includeItemTypes`. Everything else (requestId bump, stale guard, 10s timeout, empty-query clear, event merge) is preserved.
- SearchResults.svelte consumes `SearchGroup[]` from the store instead of a flat `MediaItem[]` + client-side `composeSearchGroups(results, …)`. The store now holds grouped results.
- search/+page.svelte is unchanged in behaviour; only the type it passes to `SearchResults` changes.

## Out of scope

- Any change to online/offline `include_item_types` filtering — it already works; only the source of the type list moves.
- Single concrete-type list pages (see "What is not a leak").
- Ranking within or across groups.
- The UX / chip behaviour / persistence mechanism — all unchanged from scoped-search.md.

## Acceptance criteria

- No Jellyfin item-type string literal (`"MusicAlbum"`, `"Audio"`, …) remains in `searchScope.ts` or any search call path. Verify: `grep -rn '"MusicAlbum"\|"MusicArtist"\|"Audio"\|"Series"\|"Episode"\|"Movie"\|"Playlist"' src/lib/utils/searchScope.ts src/lib/stores/library.ts` returns nothing.
- `SearchScope` and `SearchGroupId` in the frontend come from the generated `bindings.ts`, not hand-written unions.
- Search behaviour is identical to today for the user: same scoping, same groups, same order, same empty/out-of-scope omission, offline included.
- Both the command return and the `search-event` payload carry the grouped shape; no shape flicker between instant and merged results.
- `All` scope still sends no `includeItemTypes` (assert in a Rust test).
- Adding a hypothetical new type to a scope requires editing only Rust.
- `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
- `bun run check` and `bun run test` pass; `bindings.ts` regenerated and committed.

## Testing

Rust (`src-tauri`, `cargo test`):

- `SearchScope::item_types()`: each scope's list, and `All` → `None`.
- Search command: `scope: Music` resolves to the four music types on the query; `scope: All` sends no `include_item_types`.
- Bucketing: a mixed `Vec<MediaItem>` classifies into the right `SearchGroupId`s; unknown types are dropped; groups come out in canonical order.
- The `search-event` payload is the grouped shape (guard the wrinkle).

Frontend (vitest, `src/lib/**/*.test.ts`) — update existing tests:

- `librarySearchScope.test.ts` currently asserts `includeItemTypes` on the outgoing options — rewrite to assert `scope` is sent instead.
- `searchScope.test.ts` — drop `scopeItemTypes`/`groupItemTypes` cases; keep and extend `resolveSearchScope`, order normalize/move/reorder, and the new compose-over-groups (order + empty-omit, no type inspection).
- `searchGroupOrder.test.ts` — unchanged.

## TRACES

Per CLAUDE.md, tag requirement-implementing code:

- `SearchScope` enum + `item_types()` + search command scope resolution: `UR-049 | DR-063` (revised — resolution now Rust-side).
- Grouped result shape + bucketing: `UR-050 | DR-067` (revised) + a new DR for the wire shape.
- `library.ts` store change: `UR-049 | DR-065` (revised — sends scope not types).

## Notes for the implementer

- This spec revises scoped-search.md §Background 2 and §Design "Scope model / Threading scope through the store," which asserted no Rust change. Update that spec's status to note the boundary was moved, or add a banner pointing here — do not leave the two specs contradicting silently.
- The IPC camelCase rule applies to the new enums and structs (CLAUDE.md): `#[serde(rename_all = "camelCase")]` on structs; the tagged-enum tag convention if any enum becomes tagged. Add/extend a `tauriIntegration`-style test if a new command is introduced.
- Regenerate `bindings.ts` via the tauri-specta build step after changing Rust types; do not hand-edit it.
- Another Claude session may be active in these same files (per project memory). `git diff` before repairing anything unexpected; these search files are exactly the ones a parallel session touched.

# Spec: Land the scoped-search boundary fix (implementation)

Status: Stage 1 Implemented — Stage 2 (result-side grouping) outstanding Requirements: UR-049, UR-050 | DR-063, DR-066, DR-067 (existing — no new IDs) UX spec: n/a — zero user-visible change is the point (see Acceptance criteria). Supersedes / revises: implements scoped-search-boundary.md, which specified this fix but was never built. That spec remains the design authority; this one is the delivery plan and status correction.

## Summary

scoped-search-boundary.md diagnosed a domain-taxonomy leak, specified the fix in full detail, and became the justification for the project's boundary rule in CLAUDE.md, the `check:boundary` tripwire, and the spec-review checklist. The fix was never implemented. The leak it describes is still live in `main`. This spec exists to close that gap and to correct the record — the codebase currently enforces a rule against a violation it still contains.

## Motivation

The mapping the rule forbids is present and in use:

    // src/lib/utils/searchScope.ts:29-32
    const SCOPE_ITEM_TYPES: Record<Exclude<SearchScope, "all">, string[]> = {
      music: ["MusicAlbum", "MusicArtist", "Audio", "Playlist"],
      movies: ["Movie"],
      tv: ["Series", "Episode"],
    };

This is not dead code. library.ts:262 calls `scopeItemTypes(scope)` and puts the result straight into `options.includeItemTypes`. Meanwhile there is no `SearchScope` anywhere in `src-tauri/`:

    $ grep -rn "SearchScope" src-tauri/src --include='*.rs'
    (no output)

Three things make this the highest-value item found in the design-principles audit:

1. The rule's own founding incident is unremediated. CLAUDE.md cites this spec as "the incident this rule came from." A rule whose originating violation is still shipping is not credible.
2. The tripwire cannot see it. `bun run check:boundary` passes — it greps for a multi-type array literal at the query site, and this one is assigned to a named const and dereferenced elsewhere. Broadening the tripwire is specified separately by the tripwire hardening (DR-094, shipped); note that hardening it without landing this fix would turn `master` red.
3. The spec's own acceptance criterion fails today. "Adding a hypothetical new type to a scope requires editing only Rust" — adding a type to the Music scope right now requires editing `searchScope.ts`.

## Layer assignment

Unchanged from scoped-search-boundary.md §Design; restated so this spec is reviewable on its own.

Logic / responsibility | Layer | Why it belongs there
--- | --- | ---
Scope → Jellyfin item types (`music` → `MusicAlbum`, `MusicArtist`, `Audio`, `Playlist`) | Rust | Domain vocabulary. Changes if Jellyfin adds/renames an item type — the litmus test's "yes" case. This is the leak being fixed.
Result item → search group bucketing | Rust | Same taxonomy, result side. Classifying a `MediaItem` as a Song vs Album is Jellyfin vocabulary, not layout.
`All` sends no filter at all (≠ union of enumerated types) | Rust | A query-shaping rule with a correctness consequence (Person/folder results would be silently dropped). Belongs with the expansion it qualifies.
Group display order, labels, reordering, persistence | Frontend | Pure presentation — changes only if the UI is redesigned. Explicitly retained frontend-side.
`resolveSearchScope(pathname)` — route → initial scope | Frontend | Routing/navigation, no Jellyfin vocabulary. Stays exactly as-is.
Chip labels (`SCOPE_LABELS`), scope order (`SEARCH_SCOPES`) | Frontend | Display strings over an opaque enum.
`GROUP_SCOPE` (which group belongs to which scope) | Delete | Borderline taxonomy, made redundant: once Rust filters by scope, out-of-scope groups arrive empty and drop via the empty-omit rule. Borderline defaults to Rust; here it defaults to gone.

The `SearchScope` and `SearchGroupId` types come to the frontend from generated `bindings.ts`. Naming an opaque enum variant is not taxonomy; knowing what item types it expands to is.

## Design

Follow scoped-search-boundary.md §Design as written — `SearchScope` enum + `item_types()` in `repository/types.rs`, `SearchOptions.scope`, `SearchGroupId`/`SearchGroup`/`GroupedSearchResult`, scope-wins precedence, `All` → `None` → no filter. It is not restated here; duplicating it would create two drifting copies of the same design.

This spec adds only the delivery sequencing that the original left implicit.

### Staging: land it in two reviewable pieces

The original bundles the query side and the result side into one change. That is a large diff touching Rust types, `bindings.ts`, the store, and a component, with the `search-event` dual-payload hazard in the middle. Split it:

Stage 1 — query side (closes the leak). `SearchScope` enum, `SearchOptions.scope`, command resolves scope → `include_item_types` in Rust, `library.ts` sends `{ scope }`, delete `SCOPE_ITEM_TYPES` and `scopeItemTypes()`. Result grouping stays as it is.

After Stage 1 the actual boundary violation is gone and the hardened tripwire (DR-094) can pass.

Stage 2 — result side. `SearchGroupId`/`SearchGroup`/`GroupedSearchResult`, Rust bucketing, both payloads converted, `composeSearchGroups()` shrunk, `GROUP_ITEM_TYPES`/`groupItemTypes()`/`GROUP_SCOPE` deleted.

Both stages are required for the original spec's acceptance criteria to pass; Stage 1 alone leaves `GROUP_ITEM_TYPES` in the frontend. Stage 1 is not a stopping point — it is a review boundary. Do not mark the parent spec Implemented until Stage 2 lands.

### Stage 1 — delivered (July 2026)

- `SearchScope` enum + `item_types()` in repository/types.rs; `All` → `None` → no filter.
- `SearchOptions.scope` with `resolve_scope()`; scope wins over `include_item_types`, which stays for the non-search `get_items` callers.
- `repository_search` resolves the scope once, before the cache/server split, so both phases filter identically.
- `SCOPE_ITEM_TYPES` and `scopeItemTypes()` deleted; `searchScope.ts` now re-exports `SearchScope` from the generated bindings instead of a hand-written union.
- library.ts sends `{ scope }`.
- 8 Rust tests (`search_scope_tests`); the frontend suite now asserts the opaque scope is sent rather than an item-type list.

Verified: adding `"AudioBook"` to the Music scope changed zero files under `src/` — the criterion that failed before this work.

Stage 2 remains open: `GROUP_ITEM_TYPES` / `groupItemTypes()` (result-side bucketing, single-type-per-group) are still in `searchScope.ts`, and both search payloads still carry a flat `MediaItem[]` rather than `GroupedSearchResult`.

### 🔴 The `search-event` dual payload (Stage 2)

The original flags this as "the single largest part of the change and the easiest to half-do." Restating because it is the one thing that silently breaks: search resolves twice — the command returns instant cache results, then the merged cache+server union arrives via `search-event`. Both payloads must carry `GroupedSearchResult`. Convert one and the UI flickers between shapes as server results land.

Write the failing test for the event payload first — the command return is the obvious half, the event is the half that gets forgotten.

### Note on `SearchOptions.scope` and specta

`SearchOptions` is already `#[serde(rename_all = "camelCase")]` with `skip_serializing_if = "Option::is_none"`. Add `scope: Option<SearchScope>` following that pattern so `All`/absent omits the key. Regenerate `bindings.ts` — `SearchOptions` there is currently `{ limit?, includeItemTypes?, searchTerm? }` and must gain `scope?`. Never hand-edit it.

## Out of scope

- Redesigning anything in scoped-search-boundary.md. If implementation shows the design wrong, revise that spec, don't fork it.
- Online/offline `include_item_types` filtering — already correct; only the source of the type list moves.
- Ranking within or across groups (DR-090 territory).
- Chip UX, scope persistence, group-order persistence — unchanged.
- The two lesser type-set sites in `DownloadedBrowse.svelte` and `GenericMediaListPage.svelte`, handled in the hardened tripwire (DR-094, see `scripts/check-frontend-boundary.sh`).
- Broadening the tripwire itself — same sibling spec.

## Acceptance criteria

Inherits every criterion from scoped-search-boundary.md §Acceptance criteria. Additionally:

- `grep -rn "SearchScope" src-tauri/src --include='*.rs'` returns matches — the enum exists in Rust (it does not today).
- `grep -n "SCOPE_ITEM_TYPES\|scopeItemTypes\|GROUP_ITEM_TYPES\|groupItemTypes" src/lib/utils/searchScope.ts` returns nothing.
- `grep -rn "scopeItemTypes" src/` returns nothing — including the `library.ts` import and call site.
- `SearchOptions` in `bindings.ts` includes `scope`; regenerated, not hand-edited.
- Behaviour is byte-identical for the user: same scoping, same groups, same order, same empty-group omission, offline included. This spec is a pure refactor — any visible change is a defect.
- `All` scope sends no `includeItemTypes` (asserted in a Rust test, not by inspection).
- Adding a type to the Music scope requires editing only Rust — demonstrate by making the edit and confirming no `src/` file changes.
- `scoped-search-boundary.md` status flips to Implemented, and `scoped-search.md`'s "frontend only, no Rust changes" framing gets a banner pointing at the corrected design.
- `bun run check` and `bun run test` pass.
- `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
- `bun run check:boundary` passes.
- Changed code carries `// TRACES:` comments (IDs below).

## Testing

Follow scoped-search-boundary.md §Testing. Emphases:

Rust (`cargo test`):

- `SearchScope::item_types()` per scope; `All` → `None`.
- Scope resolution happens before the online/offline split, so both paths get the same filter — a regression here is invisible until someone searches offline.
- `scope` set + `include_item_types` set → scope wins (the documented precedence; assert it rather than trusting the doc).
- Stage 2: mixed `Vec<MediaItem>` buckets correctly; unknown types dropped; canonical group order; the `search-event` payload is the grouped shape.

Frontend (`bun run test`):

- `resolveSearchScope()` tests in `searchScope.test.ts` must pass unchanged — they cover the part that is not moving, and are the regression net proving the refactor didn't disturb routing.
- `library.ts` sends `{ scope }` and never `includeItemTypes` for search.
- `composeSearchGroups()` over fixture `SearchGroup[]` with no `.type` inspection in the implementation.

Offline parity: run a scoped search with the server unreachable and confirm identical grouping. The offline repository path honours `include_item_types` independently, and this is the case most likely to be missed.

## TRACES

No new requirement IDs — this implements existing ones. Retag as the code moves:

    #![allow(unused)]
    fn main() {
    // src-tauri/src/repository/types.rs
    /// TRACES: UR-049 | DR-063
    pub enum SearchScope { … }
    }

    // src/lib/utils/searchScope.ts — keep the file header; it retains
    // resolveSearchScope + group-order presentation logic.
    // TRACES: UR-049, UR-050 | DR-063, DR-066, DR-067

Update DR-063's text in `requirements.md` to state that scope expansion is owned by Rust, so the requirement stops describing the leaked design. New Rust tests take `@req-test: UT-089` onward (next free UT is UT-089).

## Notes for the implementer

- A parallel Claude session may be active in this repo — `git diff` before "repairing" unexpected changes (CLAUDE.md §Gotchas).
- Read scoped-search-boundary.md first. This spec is deliberately thin on design; that one is the authority.
- Sequence with the sibling specs: Stage 1 here → then the hardened tripwire (DR-094). Hardening the tripwire first turns `master` red on a known-unfixed violation.
- `git log --oneline -- docs/specs/scoped-search-boundary.md` is worth a look before starting — understanding why the fix stalled may surface a constraint the spec didn't record.
- The user-visible-change count for this spec is zero. If QA reports a difference in search results, that is a bug in the refactor, not an improvement.

# Spec: Remove Jellyfin-specific models from the frontend

> Implementation status (branch `frontend-domain-model`, worktree `../JellyTau-domain-model`): Catalog surface done. The frontend's item classification and time units no longer speak Jellyfin:

> - `domain/` module is the single source of truth; `MediaKind` enum + isolated `from_jellyfin` mapping. The model gained real distinctions the flat `item_type` had hidden: `LiveChannel` / `ChannelItem` / `Channel`.
> - Every catalog `item.type === "..."` → `item.kind` (0 remaining in `src/`).
> - Catalog ticks → milliseconds (`durationMs`, `playbackPositionMs`); `formatDuration` takes ms; progress bars are unit-consistent.
> - User-facing type badge → `kindLabel()`.
> - Old Jellyfin-named fields remain dual-carried on the wire so nothing broke.

> Deferred (tracked, not done):

> - `primaryImageTag` → `imageId` rename (naming-only; ~40 sites across catalog + `PlayerMediaItem`/`MergedMediaItem`, the latter needing a Rust `image_id` round-trip). Catalog `MediaItem` already has `imageId`.
> - Player/session/reporting tick math (`Queue`, `SessionCard`, `RemoteControls`, `playbackReporting`, `playerEvents`) — crosses storage/Jellyfin command signatures in ticks; needs those commands to accept ms (phase 4).
> - `stream.type` (`mediaStreams[].type`) — Jellyfin stream vocabulary (phase 4).
> - Delete `playbackUnits.ts` / `jellyfinFieldMapping.ts` once their last consumers migrate; drop the dual-carried fields once nothing reads them.

Status: Partially implemented (catalog surface); see banner. Requirements: Architectural (boundary integrity — CLAUDE.md core principles). Allocate new DRs on acceptance; suggested: DR for the domain `MediaItem`/`MediaKind` type, DR for tick/image-tag hoisting, DR for the phased frontend migration (see requirements.md). Relates to UR-007, UR-008, UR-034. UX spec: n/a — zero user-visible behaviour change. This is a pure architecture/boundary migration. Supersedes / revises: none. Extends the boundary work started in scoped-search-boundary.md from taxonomy to the whole media model.

## Summary

The frontend currently consumes Jellyfin's data model directly: `MediaItem` is a Jellyfin DTO (`runTimeTicks`, `primaryImageTag`, `parentIndexNumber`, a stringly-typed `type: string` carrying Jellyfin's item vocabulary), mirrored via specta into 36+ frontend files, with 127 `item.type === "…"` string comparisons across 23 files and two frontend utility modules (`playbackUnits.ts`, `jellyfinFieldMapping.ts`) doing Jellyfin-specific unit and field conversion in the presentation layer.

This spec defines a provider-neutral domain model, owned by Rust, that the Jellyfin repository maps into. The frontend consumes only that model. When done, no Jellyfin vocabulary — item-type strings, ticks, image tags, Jellyfin field names — remains in `src/`.

## Motivation

Two concrete problems, one strategic:

1. Boundary violation at scale. Per CLAUDE.md, the frontend is presentation-only and Rust owns the domain. Today the domain model itself is Jellyfin's wire shape, propagated unchanged across IPC. The frontend knows what a "tick" is, what `primaryImageTag` means, and that `"Audio"` is a track. That is domain knowledge in the wrong layer, 36 files deep.
2. Fragility. `type: string` is unchecked: a typo (`"Epis0de"`) or a Jellyfin rename fails silently at runtime with no compiler help, across 127 sites. Tick math (`* 10_000_000`) duplicated frontend-side is a class of bug the backend should have already resolved.
3. Strategic (the reason we chose the ambitious target): a neutral domain model is the precondition for ever supporting a non-Jellyfin backend (Plex, local files, Subsonic). As long as the UI speaks Jellyfin, that door is welded shut.

## Layer assignment

Logic / responsibility | Layer | Why it belongs there
--- | --- | ---
Definition of the media domain model (`MediaItem`, `MediaKind`) | Rust | The canonical shape the whole app reasons about; must not be a provider's wire format.
Jellyfin DTO → domain mapping (ticks→ms, image tag→url/id, `"Audio"`→`Track`, `PremiereDate`→`releaseDate`) | Rust, in the Jellyfin repository | Provider-specific translation; changes if Jellyfin changes; is the definition of "how Jellyfin maps to our domain."
Tick arithmetic (`playbackUnits.ts`) | Rust | A Jellyfin unit. The frontend should never see ticks; it receives `durationMs`/`positionMs`.
Sort-field mapping (`jellyfinFieldMapping.ts`, `title→SortName`) | Rust | Maps neutral sort keys to Jellyfin query fields — provider vocabulary. Frontend sends a neutral `SortKey`.
`MediaKind` classification (is this a track / album / episode?) | Rust | Derived from Jellyfin's `item_type`; the frontend receives the already-classified kind.
Choosing which kind renders as a card vs a list row; grid/list toggle; group order | Frontend | Pure presentation over the neutral `kind`. Changes only if the UI is redesigned.
Navigation decisions (`kind === Track && albumId` → go to album) | Frontend | Presentation/routing over neutral fields.

Borderline calls, resolved:

- `MergedMediaItem` (the lightweight now-playing projection) is already half-neutral (`title`, `artist`, `duration`) — it becomes a straightforward subset of the new domain model, not a special case.
- Context discriminators `"album"`, `"playlist"`, `"remote"` (in `TrackList`, playback context, sessions) are already domain-neutral — they are our vocabulary, not Jellyfin's. They stay as-is; do not confuse them with `item_type`. Only the Jellyfin item-type strings move.
- `mediaStreams[].type === "Audio"/"Subtitle"/"Video"` (track selection in VideoPlayer) is Jellyfin stream vocabulary too, but is lower-risk and self-contained — deferred to a late phase, not phase 1.

## Design

### Single canonical model, one location, isolated mappings

The domain model is defined once, in a dedicated top-level Rust module `src-tauri/src/domain/`, and is the single source of truth shared across the whole app:

    src-tauri/src/domain/
      media.rs         canonical MediaItem, MediaKind, and the other media types
      from_jellyfin.rs Jellyfin DTO -> domain mapping, ISOLATED here
      mod.rs           re-exports
            |  tauri-specta (export_typescript_bindings test)
            v
      src/lib/api/bindings.ts   generated MediaItem/MediaKind — the frontend copy

- One definition. `domain::MediaItem` is the model. Rust (repositories, player, downloads) uses it directly. The frontend uses the generated `bindings.ts` projection of it. There is no second hand-written copy in either language, so it cannot drift — "shared between frontend and backend" is realized by generation, not duplication.
- Mappings live beside the model, never in consumers. All provider translation (`JellyfinItem` → `domain::MediaItem`, ticks→ms, image-tag→id, item-type→`MediaKind`) lives in `domain/from_jellyfin.rs`. It is the only place Jellyfin vocabulary touches the domain type. Adding a second provider later means a new `from_<provider>.rs` beside it — the model and every consumer stay untouched.
- `domain` is a top-level module (not under `repository/`) because `MediaItem` is used by `player/`, `download/`, and `playback_mode/` too — it is not repository-specific.
- The existing `JellyfinItem` DTO + `to_media_item()` in online.rs is the seam that already exists; it moves into `domain/from_jellyfin.rs` and is enriched to do real translation instead of copying `item_type` through.

### The domain model (Rust)

    #![allow(unused)]
    fn main() {
    // src-tauri/src/domain/media.rs — provider-neutral. NO Jellyfin vocabulary.
    #[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
    #[serde(rename_all = "camelCase")]
    pub enum MediaKind {
        Track, Album, Artist, Playlist,          // music
        Movie, Series, Season, Episode,          // video
        Person,                                  // cast/crew
        Channel, Folder,                         // containers/live
    }

    #[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
    #[serde(rename_all = "camelCase")]
    pub struct MediaItem {
        pub id: String,
        pub name: String,
        pub kind: MediaKind,          // was: type: String
        pub is_folder: bool,
        pub server_id: String,

        // Times in milliseconds — NEVER ticks.
        pub duration_ms: Option<i64>,        // was: run_time_ticks

        // Image as a resolved identifier the frontend turns into a URL via the
        // existing image command — no raw Jellyfin tag semantics leak.
        pub image_id: Option<String>,        // was: primary_image_tag
        pub backdrop_image_ids: Option<Vec<String>>,

        pub overview: Option<String>,
        pub genres: Option<Vec<String>>,
        pub production_year: Option<i32>,
        pub release_date: Option<String>,    // was: premiere_date (ISO-8601)
        pub community_rating: Option<f64>,
        pub official_rating: Option<String>,

        // Relationships — already neutral, kept.
        pub album_id: Option<String>, pub album_name: Option<String>,
        pub album_artist: Option<String>, pub artists: Option<Vec<String>>,
        pub artist_items: Option<Vec<ArtistItem>>,
        pub series_id: Option<String>, pub series_name: Option<String>,
        pub season_id: Option<String>, pub season_name: Option<String>,

        // Ordinal position — rename off Jellyfin's index vocabulary.
        pub track_number: Option<i32>,       // was: index_number
        pub disc_number: Option<i32>,        // was: parent_index_number

        pub user_data: Option<UserData>,
        pub media_streams: Option<Vec<MediaStream>>,
        pub media_sources: Option<Vec<MediaSource>>,
        pub people: Option<Vec<Person>>,
    }
    }

The existing `JellyfinItem` DTO (already defined in `online.rs`, deserialized from the Jellyfin JSON) moves into `domain/from_jellyfin.rs` and stays private to that module. Its `to_media_item()` — today a near-passthrough that copies `item_type` straight across — is enriched into the single, tested place that:

- classifies `item_type: String` → `MediaKind` (including the edge cases found in the audit: `"ChannelFolderItem"` → `Channel`/`Folder` by `is_folder`, `"TvChannel"` → `Channel`, `"Composer"/"Director"/"Writer"` → `Person`, `"Video"` → `Movie` or a video leaf). Unknown strings map to `Folder` or a new `Other` variant — decide at implementation; must not panic.
- converts `run_time_ticks` → `duration_ms` (`ticks / 10_000`).
- maps `PremiereDate` → `release_date`, image tags → image ids.

`SortKey` enum + its Jellyfin field mapping (`jellyfinFieldMapping.ts` contents) moves into the Jellyfin repository; the command takes a neutral `SortKey`.

### 🔴 The `search-event` / dual-payload rule applies again

Every path that returns `MediaItem` — command returns and the `search-event` and any other event payloads — emits the new domain shape. Both sides of a twice-delivered result must match (same rule as scoped-search-boundary.md). Grep for `MediaItem` in event definitions before declaring a phase done.

### Frontend after

- `MediaItem`/`MediaKind` come from generated `bindings.ts`.
- `item.type === "Audio"` → `item.kind === "track"` (127 sites, mechanical).
- `runTimeTicks` usages → `durationMs`; delete `playbackUnits.ts` (ticks no longer cross the boundary; keep only any purely-display seconds↔clock helpers if they exist, which are not Jellyfin-specific).
- `primaryImageTag` → `imageId` through the existing image-URL command.
- Delete `jellyfinFieldMapping.ts`; sort options send a neutral `SortKey`.
- Assert with the boundary tripwire + a new grep (see acceptance).

## Phased migration

This is too large and too collision-prone for one change. Phases are independently shippable, each keeps all tests green, and each is a reviewable PR:

1. Establish the `domain/` module + enriched mapping, tests — no frontend change yet. Create `src-tauri/src/domain/{media,from_jellyfin,mod}.rs`. Move `JellyfinItem`/`to_media_item` in. Add `MediaKind` and the neutral fields to `domain::MediaItem` as additive, defaulted fields, and populate them in the mapping, while keeping the old Jellyfin-named fields too (dual-carry). The wire shape is a superset of today's, so the frontend still compiles and behaves identically. Lands the authority + full mapping unit coverage first, with zero blast radius on the 52 construction sites (they set the old fields; new ones default).
2. Flip the wire shape. Commands + events emit the new `MediaItem`. Regenerate `bindings.ts`. Frontend breaks to compile errors — fix them mechanically (`type`→`kind`, values `"Audio"`→`"track"`, `runTimeTicks`→ `durationMs`, `primaryImageTag`→`imageId`). This is the big mechanical PR; `bun run check` is the driver.
3. Delete the frontend conversion helpers (`playbackUnits.ts` ticks, `jellyfinFieldMapping.ts`) and route sorting through the neutral `SortKey`.
4. Stream vocabulary (`mediaStreams[].type`) and any remaining stragglers; tighten the boundary check to forbid Jellyfin item-type strings in `src/` outside tests.

Ship 1 → 2 → 3 → 4 as separate PRs. Do not attempt all four at once.

## Out of scope

- Actually adding a second backend (Plex/Subsonic). This spec only unblocks it.
- Changing any user-visible behaviour, layout, or copy.
- The player-internal `PlayerMediaItem` / `MediaSessionType` shapes, except where they carry the fields being renamed — align them in phase 2 only if the compiler demands it.
- Context discriminators (`"album"`, `"playlist"`, `"remote"`) — already neutral.

## Acceptance criteria

- No Jellyfin item-type string (`"Audio"`, `"MusicAlbum"`, `"Series"`, …) is compared against `.type`/`.kind` anywhere in `src/` (outside tests). Verify: `grep -rIn '\.kind === "\(Audio\|MusicAlbum\|MusicArtist\|Series\|Episode\|Movie\|Playlist\)"' src/` returns nothing.
- No `Ticks`, `runTimeTicks`, `primaryImageTag`, `PremiereDate`, or Jellyfin sort-field name (`SortName`, `RunTimeTicks`, …) appears in `src/` outside tests. `playbackUnits.ts` (ticks) and `jellyfinFieldMapping.ts` are deleted.
- `MediaItem`/`MediaKind`/`SortKey` in the frontend come from `bindings.ts`.
- The `From<JellyfinMediaDto>` mapping is total and never panics on an unknown item type (Rust test with a garbage type string).
- Behaviour is identical: same library/search/home rendering, same sorting, same navigation, offline included.
- Both command returns and event payloads carry the new shape (no flicker).
- `bun run check`, `bun run test`, `bun run check:boundary` pass; `cargo fmt`/`cargo clippy`/`bun run test:rust` pass; `bindings.ts` regenerated.

## Testing

Rust (`cargo test`): the `From<JellyfinMediaDto> for MediaItem` mapping is the critical surface —

- every known `item_type` → correct `MediaKind` (table test over all 20 values found in the audit, incl. `ChannelFolderItem`, `TvChannel`, `Composer`);
- unknown type string → safe fallback, no panic;
- `run_time_ticks` → `duration_ms` (10_000 divisor), boundary/None cases;
- `SortKey` → Jellyfin field mapping (port `jellyfinFieldMapping.ts`'s cases).

Frontend (vitest): update the many tests asserting `.type`/`runTimeTicks`; they become `.kind`/`durationMs`. `jellyfinFieldMapping`/`playbackUnits` tests are deleted with their modules. Add a compose/render test proving `kind`-based branching matches the old `type`-based branching for a representative mix.

## TRACES

Per CLAUDE.md: the domain type + mapping `UR-007, UR-008 | <new DR>`; the tick/field hoist `<new DR>`; frontend migration phases share the DRs of the capability each touches (don't invent per-file DRs).

## Notes for the implementer

- This is the highest-collision change in the repo's history — it touches 36+ frontend files and the core Rust types. A parallel Claude session in any media file will conflict. Strongly prefer a dedicated worktree per phase, and `git diff` before repairing anything (CLAUDE.md gotchas / project memory).
- Phase 1 deliberately maps back to the old shape so it can land safely ahead of the disruptive flip. Resist the urge to skip it.
- IPC camelCase rules apply to the new enums/structs (04-type-sync-and-threading.md): `#[serde(rename_all = "camelCase")]`; tagged-enum tag convention; regenerate `bindings.ts`, never hand-edit.
- Reviewed against SPEC-REVIEW-CHECKLIST.md — the Layer assignment table above is the load-bearing section.

# Spec: Desktop native video — mpv renders the picture, everywhere

Status: Proposed Requirements: UR-080 (new) → DR-231 … DR-237 (new); IR-033 (new) UX spec: n/a — nothing about the player's appearance changes. What changes is what is behind the controls. Supersedes / revises: consumes and closes linux-native-video-spike.md, whose gates authorised exactly this spec and nothing more. Settles finding 2 of playback-backend-unification.md on the desktop; finding 3 was already settled by DR-229. Absorbs the video half of what windows-native-audio-backend.md leaves open. Depends on: backend-owned stream selection (DR-225 … DR-230), the branch below this one. mpv is a consumer of `StreamSelection`, never a second place to decide what to play.

Destination on completion: 05-platform-backends.md — a "Native Video Compositing (Desktop)" section beside the existing Android one, which this mirrors; and 01-rust-backend.md — the device profile becomes renderer-dependent, beside the stream-selection section. The spike is deleted in the same commit, its three traps and its hardware-decode table folded in; they are the durable half.

## Summary

mpv decodes and draws video on every desktop platform, composited beneath the transparent webview, exactly as Android already does with ExoPlayer. The HTML5 `<video>` path and hls.js are then deleted, not merely bypassed.

The user-visible change is that most video stops being re-encoded by the server before it can be watched. The change for whoever maintains this is that video goes from three renderers to two.

## Motivation

### The transcode is a decoder constraint, not a rendering one

Desktop video goes through an h264 HLS transcode because the picture is drawn by a WebKitGTK `<video>` element, and that element decodes little else. The device profile therefore claims `h264` alone. That is not a statement about the machine — the same machine runs mpv, which decodes essentially everything in the library — it is a statement about which widget is holding the frame.

DR-228 made the cost measurable. Over 40 items negotiated against the development server:

Profile | Direct play
--- | ---
Desktop / WebKitGTK — `h264` only, 2ch | 7%
Android / ExoPlayer — `h264,hevc,vp8,vp9,av1,mpeg4` + `ac3,eac3`, 6ch | 85%

The sampled library is ~80% hevc. Those rows differ only by which component decodes.

Moving the picture to mpv is what lets the desktop row claim what the machine can actually do, and that — not the compositing — is the product.

> The 85% is a ceiling, not a shipped result. It was measured with a profile containing `ac3,eac3`. The Android device later used for verification reports neither in its `MediaCodecList` — no Dolby licence, normal for a tablet — so eac3 content, about a third of the sampled library, correctly transcodes there. Realising any of this depends on DR-234, deriving the profile from the renderer rather than from the platform, which is why that requirement is load-bearing and not tidy-up.

### One desktop video path, not two

This is why the spec covers Windows rather than stopping at Linux.

Today video has three renderers: ExoPlayer, the WebKitGTK `<video>` element, and (on Android, via the opt-out) that same element again. A Linux-only version of this work would make it four, permanently: mpv on Linux, HTML5 on Windows, ExoPlayer on Android, plus hls.js underneath the HTML5 one. Every seek strategy, every track switch, every quality change, every lifecycle bug would then have one more place to be got right — and the HTML5 path would survive indefinitely because something would still need it.

Finishing the job removes that: mpv on desktop, ExoPlayer on Android, and `hls.js`, `html5Adapter.ts`, `videoLoaderFor` and the webview video element all go. The maintenance win is the reason Windows is in this spec and not in a follow-up that never gets written.

### Three blockers are gone

1. Compositing works, including Wayland. The spike ran all six gates; the 2024 "not possible on Wayland at all" claim is out of date when the render API is used instead of foreign-window embedding.
2. There is no ABR to lose. DR-229: the server's master playlist carries one `EXT-X-STREAM-INF`. hls.js was demuxing, not adapting.
3. A direct-play path exists. It did not when the spike was written. DR-228 built it; DR-230 proved the contract is player-agnostic.

And on Windows specifically, `tauri-plugin-libmpv` lists Windows as its fully tested platform — the inverse of the Linux situation the spike had to disprove. The embedding difficulty was always WebKitGTK-specific.

## Layer assignment

Logic / responsibility | Layer | Why it belongs there
--- | --- | ---
Which codecs this device can decode | Rust | Domain: it is the input to Jellyfin's `PlaybackInfo` negotiation. It stops being a property of the platform and becomes a property of the renderer in use — see "The structural change".
Which backend renders video | Rust | Rust already owns this (`use_html5_element` / `VideoBackend`). It stops being a `cfg!` constant and becomes a runtime fact.
What stream to play (direct / remux / transcode, transport, ceiling) | Rust — already decided | DR-225. mpv consumes `StreamSelection`. Re-deriving any of it in a new backend would be the defect DR-225 exists to remove, restated.
Creating the GL surface, reparenting the webview, owning the render context | Rust (platform layer) | Native window and GL-context lifetime. Not presentation, and not expressible above the IPC boundary at all.
Render-context ↔ GL-context lifetime binding | Rust | A correctness invariant over native resources. DR-232.
Frame pacing (update callback, `report_swap`) | Rust | Timing against the compositor; mpv's own contract.
Hardware-decode selection | Rust | A capability question about the machine, answered from what mpv reports it actually selected.
Z-order of controls over video, overlay chrome, letterbox colour | Frontend / mpv | Presentation. Controls already draw over a transparent webview on Android; mpv paints its own letterbox bars (better than the Android equivalent, which shipped DR-194 as a defect).
Whether the surface is visible right now | Frontend | `nativeVideoActive` already exists and toggles `data-native-video`. Unchanged.

### The structural change

Everything above is routine except one row, and it carries the whole benefit.

`video_codecs` in `build_device_profile` is a compile-time constant per platform:

    #![allow(unused)]
    fn main() {
    #[cfg(all(not(target_os = "android"), target_os = "linux"))]
    let (video_codecs, audio_codecs) = ("h264".to_string(), "aac,mp3,opus,…");
    }

That is correct only while a build has exactly one video renderer. It must be derived from which renderer will decode this stream, which is runtime state.

It looks like configuration and is not: it is the input that decides whether the server re-encodes, it changes when Jellyfin's API or our renderer changes, and getting it wrong fails silently — a claimed codec the renderer cannot decode is a black picture or silence, which is DR-148 and DR-228's audio override already.

Write this against "the active video renderer", never `cfg!(target_os)`. It is the single piece that must not be Linux-shaped, because phase 2 reuses it unchanged.

## Design

### Backend and compositing (DR-231, IR-033)

An `MpvVideoBackend` beside the existing `MpvBackend` (audio). The mpv side — render context, FBO, update callback, hwdec — is shared; only the surface differs per platform:

Platform | Surface | Status
--- | --- | ---
Linux (X11 + Wayland) | `gdk_cairo_draw_from_gl()` in the default vbox's `draw` handler, over a `GdkGLContext` on its `GdkWindow`. No reparenting — see below | Render path proven by the spike; the overlay approach it used is rejected
Windows | Native HWND child beneath a transparent WebView2 | Phase 2

`vo=libmpv` plus `mpv_render_context_create` with `MPV_RENDER_PARAM_OPENGL_FBO`. Webview transparency via `with_transparent(true)` — no window-level transparency; the spike showed it is neither used nor needed.

G1's untested half failed, and the design changed because of it.

Reparenting Tauri's webview into a `GtkOverlay` attaches cleanly and then aborts the process on the first click. `tauri-runtime-wry` connects a button-press handler to the webview that walks a hard-coded path:

    #![allow(unused)]
    fn main() {
    webview.parent()            // "This one should be GtkBox"
           .parent()            // ...and this one the GtkWindow
           .downcast::<gtk::Window>().unwrap()
    }

An overlay makes that chain `webview → GtkOverlay → GtkBox`, the downcast fails, and the panic is non-unwinding so it kills the app. Nothing in configuration avoids it: on Linux `attach_resize_handler` is called unconditionally (the Windows equivalent is guarded by `is_decorated()`), and the decoration check that would make the handler inert runs after the unwrap.

So the webview is not moved at all. mpv draws into the default vbox's own `draw` handler instead, via `gdk_cairo_draw_from_gl()` over a `GdkGLContext` created on that widget's `GdkWindow`. GTK3 draws a container before its children, so the webview composites on top for free — the same z-order the overlay was for, without touching the widget tree Tauri walks.

That is strictly better than the overlay it replaces: no reparent, no extra widget, and the arrangement cannot be broken by a Tauri upgrade that assumes its own layout. It is also why "the surface attached successfully" is not the gate — a click is.

Three traps from the spike, each of which cost a debugging cycle and each of which looks like a platform limitation and is not:

1. `LC_NUMERIC` must be reset after `gtk::init()`. mpv refuses to start under a non-C numeric locale. `mpv_backend.rs` already handles this but has no GTK init in front of it; here `gtk::init()` applies the user's locale afterwards and `mpv_create` returns null.
2. libepoxy exports GL entry points as data symbols. There is no `glFoo` function — there is `epoxy_glFoo`, a variable holding a lazily-resolving pointer. `get_proc_address` must return the pointer stored at that symbol; returning the symbol's own address makes mpv jump into non-executable data and take SIGSEGV on the first GL call. The `epoxy` crate does this correctly but is unusable — its `gl_generator` dependency pulls a yanked `xml-rs`.
3. Frame pacing is not optional and its symptom misleads. See DR-233.

### Render-context lifetime (DR-232) — the crash defence

The spike's one unexplained SIGSEGV landed in a decoder thread with no Tauri, GTK or GL frame in the stack, and three plausible causes failed to reproduce it across ~13 minutes of targeted stress.

What is not unexplained is that the spike had no defence: it never calls `mpv_render_context_free` and never tears down on `unrealize`, so nothing stopped the GL context being recreated beneath the render context. That is DR-184 on Android restated — a surface outliving its player.

Built as a requirement in its own right, not as a fix for a crash we cannot yet reproduce:

- Render context created on `realize`, freed on `unrealize`, same thread, before the GL context goes away.
- The update callback is unregistered before the context is freed, so a callback cannot land on a freed context.
- Playback teardown and surface teardown are ordered, not racing.

If the crash recurs after this, it is a different bug and the likeliest cause is out of the search space. If it does not, we needed this anyway.

### Frame pacing (DR-233)

Register `mpv_render_context_set_update_callback`; redraw only when it reports a frame ready; call `mpv_render_context_report_swap` after each render.

Recorded because the failure mode is a trap: driving `queue_render()` off the frame clock every tick without reporting the swap leaves mpv nothing to time against. It looks fine in a window and judders at fullscreen, which reads as a compositing or GPU limit and is neither.

### Renderer-dependent device profile (DR-234)

`build_device_profile` takes the active video renderer and derives the codec lists from it:

Renderer | Video codecs | Audio (video direct play) | Channels
--- | --- | --- | ---
mpv (desktop native) | `h264,hevc,vp8,vp9,av1,mpeg4` | platform list incl. `ac3,eac3` where the sink can voice it | from the audio route
WebKitGTK `<video>` | `h264` | webview-decodable set only | 2
ExoPlayer (Android) | unchanged | unchanged | unchanged

The existing `video_audio_codecs()` narrowing exists because the webview decodes a narrower audio set than the platform. With mpv decoding, that no longer applies to the video path — but the multichannel bound still does, since a 5.1 track direct-played into a 2-channel sink is silence or inaudible dialogue. Both constraints stay, sourced from the renderer rather than assumed.

This is what converts the 7% figure upward (toward, not necessarily to, the 85% ceiling — see the caveat above), and it is also the change most able to break playback silently — so it lands after compositing is proven, covered by the DR-228 override tests.

### Deleting the webview video path (DR-235)

`get_player_status` stops reporting `use_html5_element: true` on desktop; `supports_native_video` becomes true there.

Deletion is staged, because a path cannot be removed while a shipped platform still needs it:

Phase | Linux | Windows | HTML5 video path
--- | --- | --- | ---
1 | mpv | HTML5 | alive — Windows needs it
2 | mpv | mpv | alive but unreached
3 | mpv | mpv | deleted, with hls.js

Phase 3 is a real phase with its own acceptance criterion, not a "later". The whole maintenance argument for including Windows collapses if the fork survives.

Android keeps ExoPlayer and keeps the webview as its documented opt-out; the `<audio>` element and the background-audio handoff are untouched throughout.

What happens when mpv fails to initialise. With no HTML5 path there is no silent fallback, and inventing one resurrects what we deleted. The graceful-backend-init principle applies as written: fall back to the no-op backend, emit `backend-init-failed`, and surface a real error rather than a black rectangle. An honest failure beats a hidden downgrade to the transcode we are trying to stop paying for.

### Hardware decode (DR-236)

The spike established the load-bearing fact: hardware decode works through the render API (`hwdec-current` reported `nvdec-copy` on the discrete GPU), so the direct-play prize is not traded for software decoding.

Policy is decided from what mpv reports it selected, never from what it was asked for:

- Prefer zero-copy VA-API on the integrated GPU where the driver is present.
- `auto` reached for the discrete GPU in copy-back mode on a hybrid Intel+NVIDIA laptop — the least efficient hardware path — so `auto` is a fallback, not the default.
- `vaapi` silently fell back to software on the spike box because `vainfo` was absent. A missing driver must be detected and logged, not mistaken for a compositing limit.
- Log `hwdec-current` at start-up; knowing what was actually chosen is the whole diagnostic value.

### Windows: what phase 2 actually costs (DR-237)

Not hidden, because it is the part most likely to be underestimated:

- The surface is different code. WebView2 in an HWND, not GTK. A transparent WebView2 over a native child window is a solved arrangement, but DR-231's Linux surface does not transfer. Everything else does.
- libmpv is currently a Linux-only dependency, and Windows is cross-compiled from Linux via `x86_64-pc-windows-msvc` + `cargo-xwin`. Phase 2 must source a Windows libmpv (DLL + import library) into that cross-build and ship the DLL in the NSIS bundle.
- LGPL obligations follow the DLL. DR-216 already records them for Linux: keep the linkage dynamic, ship libmpv's licence text with any bundle carrying it. The Windows bundle inherits both.
- `bun run test:rust` and CI must still build. Per the CI rule, any tool this needs goes into the builder image and is pushed — never installed at job time.

Windows also gains a native audio decoder as a side effect, which is what windows-native-audio-backend.md wants and cannot currently have. If that spec lands first, phase 2 inherits its build work and shrinks to the surface.

## Out of scope

- Android. Unchanged in every respect.
- macOS. Not a shipped target. If it becomes one it joins phase 2's shape.
- Audio backends. mpv already plays audio on Linux; this adds a video renderer beside it. Windows audio is its own spec.
- HDR, tone mapping, multi-window. Not exercised by the spike at all.
- Re-deciding what stream to play. DR-225 owns that. If this spec finds itself choosing a URL, something has gone wrong.

## Acceptance criteria

Phase 1 — Linux

- Tauri's own webview reparents into the overlay (the untested half of G1), on X11 and Wayland.
- Video plays, seeks and switches audio track in mpv, with the Svelte controls composited over it and alpha blending intact.
- The render context is freed on `unrealize` and the update callback unregistered before the free; a test demonstrates the ordering.
- A direct-play negotiation returns `DirectPlay` for an hevc source that today returns `Transcode`, and it plays.
- Direct-play rate over the same 40-item sample rises from 7% toward the Android figure. Record the number.
- mpv init failure emits `backend-init-failed` and surfaces an error rather than falling back to a transcode.
- `hwdec-current` is logged and is not copy-back where zero-copy is available.
- A soak covering seek, track switch and fullscreen runs clean for an agreed duration. The spike's SIGSEGV is why this is a criterion.

Phase 2 — Windows

- libmpv links in the `cargo-xwin` cross-build; the DLL and its licence ship in the NSIS bundle; any new tool lives in the builder image, not in a CI step.
- Video plays composited under a transparent WebView2.
- The device profile, lifetime and hwdec code are reused, not reimplemented — a reviewer confirms no `cfg!(target_os = "linux")` guards them.

Phase 3 — deletion

- `use_html5_element` is false on every desktop platform.
- `hls.js` is gone from `package.json`; `html5Adapter.ts`, `videoLoaderFor` and the `<video>` element are deleted; Android's opt-out and the background-audio `<audio>` path still work.

Throughout

- `bun run check`, `bun run test`, `bun run format:check`, `bun run lint` pass.
- `cargo fmt` clean, `cargo clippy -D warnings` clean, `bun run test:rust` passes.
- `bun run check:boundary` passes, and a reviewer confirms no stream decision was reconstructed in the new backend.
- `bindings.ts` regenerated from Rust.
- `bun run traces:validate` passes; coverage stays ≥ the CI ratchet.
- The spike and this spec are folded into 05-platform-backends.md and both deleted in the same commit.

## Testing

- Rust, pure: the device profile per renderer — mpv claims hevc, the webview does not, the multichannel bound survives both. The DR-234 table as a table-driven test.
- Rust, pure: `PlaybackInfo` fixtures that transcode under the webview profile and direct-play under the mpv profile — the direct-play conversion as a unit test, not only as a measurement.
- Rust: teardown ordering — callback unregistered before context freed, freed before GL context destroyed. Structure it so the ordering is assertable without a live GL context.
- Frontend: no desktop path selects an HTML5 video adapter. After phase 3, the adapter does not exist and the test goes with it.
- Manual / soak: the criterion above. The spike's automated fullscreen and resize soaks are reusable and already written.

## TRACES

Piece | Tag
--- | ---
mpv video backend + compositing | `UR-080 | DR-231, IR-033`
Render-context lifetime binding | `UR-080 | DR-232`
Frame pacing | `UR-080 | DR-233`
Renderer-dependent device profile | `UR-080, UR-070 | DR-234`
Webview video path removed | `UR-080 | DR-235`
Hardware-decode policy | `UR-080 | DR-236`
Windows surface + cross-build | `UR-080 | DR-237`

## Notes for the implementer

- Read the spike before writing a line. Its three traps and its hardware-decode table are the most valuable things in this directory, and each cost a debugging cycle to find.
- mpv consumes `StreamSelection`; it does not decide. The transport is on the queue item (DR-230). If you are parsing a URL, stop.
- Guard nothing on `cfg!(target_os = "linux")` that phase 2 will need. That is the one avoidable mistake here.
- The Android backend is the reference for the shape of this — transparent webview over a native surface at index 0. Read `05-platform-backends.md`'s Android section for what shipped and what its defects were (DR-184 surface lifetime, DR-194 letterbox).
- Do not call sync/blocking APIs from mpv event callbacks that can re-enter the player or hold a lock. The existing deadlock gotchas apply.
- A parallel Claude session may be active in this repo — `git diff` before "repairing" unexpected changes.
- This branch is stacked on backend-owned stream selection. Rebase when that merges rather than merging master into it.

# Spec: Build provenance (git describe + build profile)

Status: Proposed — not started. `src-tauri/build.rs` still contains only `tauri_build::build()`, and nothing reports a version over IPC. Note that `scripts/set-version.sh` has since landed, which changes the "three hand-bumped files" premise below: versions are now stamped from one place. Requirements: ⚠️ the suggested id DR-093 has since been allocated to the traceability coverage gate — allocate a fresh id (DR-215 or later) on implementation. Build provenance surfaced in-app and in logs; no UR — this is a diagnostic capability, not a user feature UX spec: n/a — adds an About block to Settings; no new flow Supersedes / revises: —

## Summary

Make every build say exactly what it is. Today a running JellyTau reports no version at all — not in the UI, not in the logs — and the only version string in the tree is the hand-maintained `0.2.0` duplicated across three files.

This adds a `build.rs`-generated provenance string (`git describe` + short SHA + dirty flag + debug/release profile), exposes it over IPC, and renders it in a new Settings › About block. It also removes one of the three hand-bumped version files.

## Motivation

The concrete problem: when a user reports "the equalizer does nothing on my device" — which is a live risk for v0.2.0, whose Android audio settings are not yet device-verified — there is currently no way to tell which build they are running. Tag? Master? A local debug build from three weeks ago? The bug report cannot distinguish them.

Two smaller irritations this also fixes:

- Debug builds masquerade as releases. `0.2.0` is `0.2.0` whether it came from a tagged release or `bun run tauri dev`.
- Three files carry the version. `package.json`, `src-tauri/Cargo.toml` and `src-tauri/tauri.conf.json` must be bumped in lockstep; the release checklist exists partly to stop them drifting.

### What this deliberately does not do

The canonical version stays hand-bumped in `Cargo.toml`. Cargo requires a literal semver string at manifest-parse time and cannot derive it from git. The same is true of `tauri.conf.json`. Attempting to source the release version from a tag trades a scripted, reviewable bump for a fragile build-time dependency that breaks in exactly the environment we care most about (CI, in Docker, from a shallow clone).

So: the release version is authored; the build provenance is derived. They answer different questions — "what release is this?" versus "what commit is this binary actually built from?" — and only the second benefits from git.

## Layer assignment

Logic / responsibility | Layer | Why it belongs there
--- | --- | ---
Capturing git describe / SHA / dirty state at compile time | Rust (`build.rs`) | Only the Rust build has a compile step that can shell out to git and bake the result into the binary. A frontend equivalent would report the dev server's state, not the shipped binary's.
Degrading to a sentinel when git is unavailable | Rust (`build.rs`) | Build-environment concern. Must never fail the build — CI runs in Docker from a shallow clone.
Release version (`0.2.0`) | Rust (`Cargo.toml`, authored) | Domain fact about the product, not derivable from the environment.
Deciding what a build is (release / dev / dirty) | Rust | Domain classification. The frontend must not infer "this is a dev build" from a string shape — it renders what it is told.
Rendering the About block, copy-to-clipboard | Frontend | Pure presentation.

Borderline row: the release/dev/dirty classification could be done in the frontend by pattern-matching the describe string. It goes to Rust because that is a rule about what constitutes a release build, and it would have to change if the tagging scheme changed — the litmus test in the template puts that in Rust. Send a typed enum, not a string for the frontend to parse.

## Design

### `build.rs`

    fn main() {
        emit_build_provenance();
        tauri_build::build()
    }

    fn emit_build_provenance() {
        let describe = std::process::Command::new("git")
            .args(["describe", "--tags", "--always", "--dirty"])
            .output()
            .ok()
            .filter(|o| o.status.success())
            .and_then(|o| String::from_utf8(o.stdout).ok())
            .map(|s| s.trim().to_string())
            .unwrap_or_else(|| "unknown".to_string());

        println!("cargo:rustc-env=JELLYTAU_GIT_DESCRIBE={describe}");

        // Rebuild when HEAD moves or a ref is written, so the string does not go
        // stale across commits. Guarded: these paths do not exist in a git-less
        // source tarball, and emitting rerun-if-changed for a missing path would
        // force a rebuild every time.
        for p in [".git/HEAD", ".git/refs"] {
            if std::path::Path::new("../").join(p).exists() {
                println!("cargo:rerun-if-changed=../{p}");
            }
        }
    }

🔴 `build.rs` must never fail the build. Every git call is `.ok()`-swallowed; a missing git binary, a shallow clone, or a source tarball all yield `"unknown"`. A build that breaks because git is absent would be a worse bug than the one this fixes.

Note the `../` prefixes: `build.rs` runs with CWD at `src-tauri/`, so the repo's `.git` is one level up.

### The provenance type

    #![allow(unused)]
    fn main() {
    /// TRACES: DR-093
    #[derive(specta::Type, Serialize)]
    #[serde(rename_all = "camelCase")]
    pub struct BuildInfo {
        /// Authored release version (Cargo.toml).
        pub version: String,
        /// `git describe --tags --always --dirty`, or "unknown".
        pub git_describe: String,
        /// What kind of build this is — classified in Rust, not inferred by the UI.
        pub kind: BuildKind,
    }

    /// TRACES: DR-093
    #[derive(specta::Type, Serialize)]
    #[serde(rename_all = "camelCase")]
    pub enum BuildKind {
        /// Built from a clean, exactly-tagged commit in release mode.
        Release,
        /// Release-mode build that is not on a clean tag (e.g. master, or dirty).
        Untagged,
        /// debug_assertions build.
        Development,
        /// Git state unavailable at build time.
        Unknown,
    }
    }

Classification:

    #![allow(unused)]
    fn main() {
    let kind = if cfg!(debug_assertions) {
        BuildKind::Development
    } else if describe == "unknown" {
        BuildKind::Unknown
    } else if describe.contains('-') {   // "v0.2.0-3-gcb79a37" or "...-dirty"
        BuildKind::Untagged
    } else {
        BuildKind::Release
    };
    }

### Command

    #![allow(unused)]
    fn main() {
    /// TRACES: DR-093
    #[tauri::command]
    #[specta::specta]
    pub fn get_build_info() -> BuildInfo { … }
    }

No parameters, so the camelCase param rule does not apply; the struct fields do need `#[serde(rename_all = "camelCase")]` (above). Regenerate `bindings.ts`.

Also log the provenance once at startup, next to the existing init logging — that is what makes a user-submitted log file self-identifying, which is most of the value.

### Settings › About

A new block at the bottom of `src/routes/settings/+page.svelte`, rendering version, describe string, and a badge for non-release builds. One copy-to-clipboard button that yields a paste-ready block for bug reports:

    JellyTau 0.2.0 (v0.2.0-3-gcb79a37-dirty, development)
    linux x86_64

Platform/arch come from the existing Tauri APIs; do not shell out.

### Removing one version file

`tauri.conf.json`'s `"version"` field can be omitted, in which case Tauri falls back to the Cargo version. That takes the bump from three files to two.

Verify before adopting: confirm the Android `versionName`/`versionCode` and the NSIS installer version still resolve correctly with the field absent — Android packaging in particular reads the Tauri config. If either regresses, keep the field and drop this part; it is a convenience, not the point of the spec.

## Out of scope

- Deriving the release version from git tags (see Motivation).
- A build-time timestamp. It defeats reproducible builds and adds little over the commit SHA.
- CI provenance/attestation, SBOM, signing.
- Displaying the Jellyfin server version (separate concern, already available from `/System/Info`).

## Acceptance criteria

- `cargo build` succeeds with git absent, from a shallow clone, and from a source tarball with no `.git` — yielding `"unknown"` in each case, never a build failure.
- A tagged clean release build reports `BuildKind::Release`; `bun run tauri dev` reports `Development`; a dirty tree reports `Untagged` (release mode) with `-dirty` in the describe string.
- The describe string changes after a new commit without a manual `cargo clean` (rerun-if-changed works).
- Provenance is logged once at startup.
- Settings › About renders version + describe + build-kind badge, with working copy-to-clipboard.
- 🔴 CI checkouts that build a shippable artifact set `fetch-depth: 0`, or their artifacts are knowingly stamped `unknown`. Currently only `publish-docs.yml` sets it; `build-release.yml` has five checkouts and `build-and-test.yml` two, all of which would report `unknown` as-is.
- No toolchain installed in CI — git is already present in the builder image; nothing new is added.
- `bun run check`, `bun run test`, `bun run check:boundary` pass.
- `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
- `bindings.ts` regenerated.
- DR-093 allocated in `requirements.md`; new code carries `// TRACES:`.

## Testing

Rust: the classification is pure and must be extracted from the command as `classify_build(describe: &str, debug: bool) -> BuildKind` so it can be tested directly. Cover: `"v0.2.0"` → `Release`; `"v0.2.0-3-gcb79a37"` → `Untagged`; `"v0.2.0-dirty"` → `Untagged`; `"unknown"` → `Unknown`; `debug = true` → always `Development` regardless of describe.

`build.rs` itself is not unit-testable. Verify its failure path manually by building with `PATH` stripped of git, and from a `git archive` tarball — both must succeed with `"unknown"`.

Frontend: assert the About block renders each `BuildKind` correctly, and that it renders the backend-supplied kind rather than re-deriving it from the string (a test that passes a `Release` kind with a `-dirty` describe and asserts the badge follows the kind would catch that regression).

## TRACES

- `build.rs` provenance emission → `// TRACES: | DR-093`
- `BuildInfo` / `BuildKind` / `classify_build` → `// TRACES: | DR-093`
- `get_build_info` command → `// TRACES: | DR-093`
- Settings About block → `// TRACES: | DR-093`
- `classify_build` tests → `UT-BUILD-1`
- Allocate DR-093 in `requirements.md` ("Build provenance: git describe and build profile surfaced in-app and in logs"). Next free DR at time of writing is DR-093.

## Notes for the implementer

- Do the `build.rs` + command + logging first; the About UI is the smaller half and the logging alone delivers most of the diagnostic value.
- The `fetch-depth: 0` change is the easiest part to forget and the one that makes CI artifacts useless if missed — it is why that acceptance box is flagged. Weigh it per workflow: test-only jobs do not need it.
- Do not add a build timestamp "while you are in there" — see Out of scope.
- A parallel Claude session may be active — `git diff` before "repairing" unexpected changes.

# Build & Release Workflow

This document explains the automated build and release process for JellyTau.

## Overview

The CI/CD pipeline automatically:

1. ✅ Runs all tests (frontend + Rust)
2. ✅ Builds Linux binaries (AppImage + DEB)
3. ✅ Builds Android APK and AAB
4. ✅ Creates releases with artifacts
5. ✅ Tags releases with version numbers

## Workflow Triggers

### Automatic Trigger

When you push a version tag:

    git tag v1.0.0
    git push origin v1.0.0

The workflow automatically:

1. Runs tests
2. Builds both platforms
3. Creates a GitHub release with artifacts
4. Tags it as release/prerelease based on version

### Manual Trigger

In Gitea Actions UI:

1. Go to Actions tab
2. Click Build & Release workflow
3. Click Run workflow
4. Optionally specify a version
5. Workflow runs without creating a release

## Version Tagging

### Format

Version tags follow semantic versioning: `v{MAJOR}.{MINOR}.{PATCH}`

Examples:

- `v1.0.0` - Release version
- `v1.0.0-rc1` - Release candidate (marked as prerelease)
- `v1.0.0-beta` - Beta version (marked as prerelease)
- `v0.1.0-alpha` - Alpha version (marked as prerelease)

### Creating a Release

    # Create and push a version tag
    git tag v1.0.0 -m "Release version 1.0.0"
    git push origin v1.0.0

    # Or create from main branch
    git tag -a v1.0.0 -m "Release version 1.0.0" main
    git push origin v1.0.0

### Release Status

Versions containing `rc`, `beta`, or `alpha` are marked as prerelease:

    git tag v1.0.0-rc1      # ⚠️ Prerelease
    git tag v1.0.0-beta     # ⚠️ Prerelease
    git tag v1.0.0-alpha    # ⚠️ Prerelease
    git tag v1.0.0          # ✅ Full release

## Workflow Steps

### 1. Test Phase

Runs on all tags and manual triggers:

- Frontend tests (`vitest`)
- Rust tests (`cargo test`)
- TypeScript type checking

Failure: Stops workflow, no build/release

### 2. Build Linux Phase

Runs after tests pass:

- Installs system dependencies
- Builds with Tauri
- Generates:
  - AppImage - Universal Linux binary
  - DEB - Debian/Ubuntu package

Output: `artifacts/linux/`

### 3. Build Android Phase

Runs in parallel with Linux build:

- Installs Android SDK/NDK
- Configures Rust for Android targets
- Builds with Tauri
- Generates:
  - APK - Android app package (installable)
  - AAB - Android App Bundle (for Play Store)

Output: `artifacts/android/`

### 4. Create Release Phase

Runs after both builds succeed (only on version tags):

- Prepares release notes
- Downloads build artifacts
- Creates GitHub/Gitea release
- Uploads all artifacts
- Tags as prerelease if applicable

## Artifacts

### Linux Artifacts

#### AppImage

- File: `jellytau_*.AppImage`
- Size: ~100-150 MB
- Use: Run directly on any Linux distro
- Installation:

      chmod +x JellyTau_*.AppImage
      ./JellyTau_*.AppImage

#### DEB Package

- File: `JellyTau_*.deb`
- Size: ~80-120 MB
- Use: Install on Debian/Ubuntu/similar
- Installation:

      sudo dpkg -i JellyTau_*.deb
      jellytau

- Note: the Debian package is named `jelly-tau` (Tauri kebab-cases `productName`), while the command stays `jellytau`. The package declares `Replaces`/`Conflicts`/`Provides: jellytau`, so upgrading from a release built before the rename replaces it rather than installing a second copy.

#### RPM Package

- File: `JellyTau-*.rpm`
- Use: Install on Fedora/openSUSE/similar
- Installation:

      sudo rpm -i JellyTau-*.rpm
      jellytau

### Android Artifacts

#### APK

- File: `jellytau-release.apk`
- Size: ~60-100 MB
- Use: Direct installation on Android devices
- Installation:

      adb install jellytau-release.apk
      # Or sideload via file manager

#### AAB (Android App Bundle)

- File: `jellytau-release.aab`
- Size: ~50-90 MB
- Use: Upload to Google Play Console
- Note: Cannot be installed directly; for Play Store distribution

## Release Notes

Release notes are automatically generated with:

- Version number
- Download links
- Installation instructions
- System requirements
- Known issues link
- Changelog reference

## Build Matrix

Platform | OS | Architecture | Format
--- | --- | --- | ---
Linux | Any | x86_64 | AppImage, DEB
Android | 8.0+ | arm64, armv7, x86_64 | APK, AAB

## Troubleshooting

### Build Fails During Test Phase

1. Check test output in Gitea Actions
2. Run tests locally: `bun run test` and `bun run test:rust`
3. Fix failing tests
4. Create new tag with fixed code

### Linux Build Fails

1. Check system dependencies installed
2. Verify Tauri configuration
3. Check cargo dependencies
4. Clear cache: Delete `.cargo` and `target/` directories

### Android Build Fails

1. Check Android SDK/NDK setup
2. Verify Java 17 is installed
3. Check Rust Android targets: `rustup target list`
4. Clear cache and rebuild

### Release Not Created

1. Tag must start with `v` (e.g., `v1.0.0`)
2. Tests must pass
3. Both builds must succeed
4. Check workflow logs for errors

## GitHub Release vs Gitea

The workflow uses GitHub Actions SDK but is designed for Gitea. For Gitea-native releases:

1. Workflow creates artifacts
2. Artifacts are available in Actions artifacts
3. Download and manually create Gitea release, or
4. Set up Gitea API integration to auto-publish

## Customization

### Change Release Notes Template

Edit `.gitea/workflows/build-release.yml`, section `Prepare release notes`:

    - name: Prepare release notes
      id: release_notes
      run: |
        # Add your custom release notes format here
        echo "Custom notes" > release_notes.md

### Add New Platforms

To add macOS or Windows builds:

1. Add new `build-{platform}` job
2. Set appropriate `runs-on` runner
3. Add platform-specific dependencies
4. Update artifact upload
5. Include in `needs: [build-linux, build-android, build-{platform}]`

### Change Build Targets

Modify Tauri configuration or add targets:

    - name: Build for Linux
      run: |
        # Add target specification
        bun run tauri build -- --target x86_64-unknown-linux-gnu

## Monitoring

### Check Status

1. Go to Actions tab in Gitea
2. View Build & Release workflow runs
3. Click specific run to see logs

### Notifications

Set up notifications for:

- Build failures
- Release creation
- Tag pushes

## Performance

### Build Times (Approximate)

- Test phase: 5-10 minutes
- Linux build: 10-15 minutes
- Android build: 15-20 minutes
- Total: 30-45 minutes

### Caching

Workflow caches:

- Rust dependencies (cargo)
- Bun node_modules
- Android SDK components

## Security

### Secrets

The workflow uses:

- `GITHUB_TOKEN` - Built-in, no setup needed
- No credentials needed for Gitea

### Verification

To verify build integrity:

1. Download artifacts
2. Verify signatures (if implemented)
3. Check file hashes
4. Test on target platform

## Best Practices

### Versioning

1. Follow semantic versioning: `v{MAJOR}.{MINOR}.{PATCH}`
2. Tag releases in git
3. Update CHANGELOG.md before tagging
4. Include release notes in tag message

### Testing Before Release

    # Local testing before release
    bun run test          # Frontend tests
    bun run test:rust     # Rust tests
    bun run check         # Type checking
    bun run tauri build   # Local build test

### Documentation

1. Update CHANGELOG.md with changes
2. Update README.md with new features
3. Document breaking changes
4. Add migration guide if needed

## Example Release Workflow

    # 1. Update version in relevant files (package.json, Cargo.toml, etc.)
    vim package.json
    vim src-tauri/tauri.conf.json

    # 2. Update CHANGELOG
    vim CHANGELOG.md

    # 3. Commit changes
    git add .
    git commit -m "Bump version to v1.0.0"

    # 4. Create annotated tag
    git tag -a v1.0.0 -m "Release version 1.0.0

    Features:
    - Feature 1
    - Feature 2

    Fixes:
    - Fix 1
    - Fix 2"

    # 5. Push tag to trigger workflow
    git push origin v1.0.0

    # 6. Monitor workflow in Gitea Actions
    # Wait for tests → Linux build → Android build → Release

    # 7. Download artifacts and test
    # Visit release page and verify downloads

## References

- Tauri Documentation
- Semantic Versioning
- GitHub Release Best Practices
- Android App Bundle
- AppImage Documentation

Last Updated: 2026-02-13

# Release Checklist

Quick reference for creating a JellyTau release.

## Pre-Release (1-2 days before)

- Code is on `master`/`main` branch
- All feature branches are merged and tested
- No failing tests locally: `bun run test` and `bun run test:rust`
- Requirement traceability check passes: `bun run traces:json`
- Type checking passes: `bun run check`

## Update Version (Day before)

- Decide on version number (semantic versioning)

  - Example: `v1.2.0` (major.minor.patch)
  - Example: `v1.0.0-rc1` (release candidate)
  - Example: `v1.0.0-beta` (beta)

- Update version in files:

      # Check these files for version numbers
      cat package.json | grep version
      cat src-tauri/tauri.conf.json | grep version
      cat src-tauri/Cargo.toml | grep version

- Update `CHANGELOG.md`:

  - Add section for new version
  - List all features added
  - List all bugs fixed
  - List breaking changes (if any)
  - Add upgrade instructions (if needed)
  - Format: Markdown with clear sections

- Update `README.md`:

  - Update any version references
  - Update feature list if applicable
  - Update requirements if changed

- Commit changes:

      git add .
      git commit -m "Bump version to v1.2.0"
      git push origin master

## Final Check Before Release

- Run full test suite:

      bun run test          # Frontend tests
      bun run test:rust     # Rust tests
      bun run check         # Type checking

- Build locally (optional but recommended):

      # Test Linux build
      bun run tauri build

      # Test Android build
      bun run tauri android build

- No uncommitted changes:

      git status  # Should show clean working directory

## Release (Tag & Push)

    # 1. Create annotated tag with release notes
    git tag -a v1.2.0 -m "Release version 1.2.0

    Features:
    - New feature 1
    - New feature 2

    Fixes:
    - Fixed bug 1
    - Fixed bug 2

    Improvements:
    - Performance improvement 1
    - UI improvement 1

    Breaking Changes:
    - None (or list if applicable)

    Migration:
    - No action required (or include steps if applicable)"

    # 2. Push tag to trigger workflow
    git push origin v1.2.0

    # 3. Monitor in Gitea Actions
    # Go to Actions tab and watch the workflow run

## During Release (While Workflow Runs)

- Watch workflow progress in Gitea Actions
- Monitor for test failures
- Monitor for build failures
- Check build logs if any step fails

## After Release (Workflow Complete)

- Download artifacts from release page:

  - `JellyTau_*.AppImage` (Linux)
  - `JellyTau_*.deb` (Linux)
  - `JellyTau-*.rpm` (Linux)
  - `jellytau-release.apk` (Android)
  - `jellytau-release.aab` (Android)

- Basic testing of artifacts:

  - Linux AppImage runs
  - Linux DEB installs and runs
  - Android APK installs (via `adb` or sideload)

- Verify release page:

  - Title is correct: "JellyTau vX.Y.Z"
  - Release notes are formatted correctly
  - All artifacts are uploaded
  - Release type is correct (prerelease vs release)

- Verify integrity metadata (DR-216):

  - `SHA256SUMS` is present, and `sha256sum -c SHA256SUMS` passes in the directory you downloaded into
  - SBOM files are present (`*.cdx.json`, `frontend-dependencies.txt`)

- Verify the update path (DR-217) — this is the step that catches a broken updater before users hit it, because a bad manifest fails only on their machine:

  - `latest.json` is live and names this version: `curl -s https://gitea.tourolle.paris/dtourolle/jellytau/raw/branch/updater/latest.json | jq .version`
  - Both platform entries carry a non-empty `signature`
  - The `.AppImage.tar.gz`, its `.sig`, and the NSIS `.sig` are among the release assets — the manifest points at them
  - Install the previous release, launch it, and use Settings → Updates: it should offer this version, install it, and relaunch
  - On Android, Settings → Updates offers the releases page rather than an install button (the updater plugin is not compiled for that target)

- Announce release:

  - Post to relevant channels/communities
  - Update website/docs
  - Tag contributors if applicable

## Rollback (If Issues Found)

If critical issues are found after release:

    # Option 1: Delete tag locally and remotely
    git tag -d v1.2.0
    git push origin :refs/tags/v1.2.0

    # Option 2: Mark as prerelease in release page
    # Then plan immediate patch release (v1.2.1)

    # Option 3: Create hotfix branch and release v1.2.1
    git checkout -b hotfix/v1.2.1
    # Fix issues
    git commit -m "Fix critical issue"
    git tag v1.2.1
    git push origin hotfix/v1.2.1 v1.2.1

## Version Examples

### Major Release

    v2.0.0 - Major version bump
    - Significant new features
    - Breaking API changes
    - Major UI redesign

### Minor Release

    v1.2.0 - Feature release
    - New features
    - Backward compatible
    - Bug fixes

### Patch Release

    v1.1.1 - Bug fix/patch
    - Bug fixes only
    - No new features
    - Backward compatible

### Pre-releases

    v1.2.0-alpha    - Early development
    v1.2.0-beta     - Late development, feature complete
    v1.2.0-rc1      - Release candidate, minimal fixes only

## File Locations

Key files for versioning:

- `package.json` - Frontend version
- `src-tauri/tauri.conf.json` - Tauri config version
- `src-tauri/Cargo.toml` - Rust version
- `CHANGELOG.md` - Release history
- `README.md` - Project documentation

## Troubleshooting

### Tests Fail Before Release

1. Don't push tag yet
2. Fix failing tests locally
3. Push fixes to master
4. Re-run test suite
5. Then tag and push

### Build Fails in CI

1. Check detailed logs in Gitea Actions
2. Fix issue locally
3. Delete tag: `git tag -d v1.2.0 && git push origin :refs/tags/v1.2.0`
4. Push fix to master
5. Create new tag with fix

### Release Already Exists

1. If workflow runs twice, artifacts may conflict
2. Check release page
3. If duplicates exist, delete and re-release

### Artifacts Missing

1. Check build logs for errors
2. Verify platform-specific dependencies
3. Delete tag and retry after fixes

## Performance Tips

- Tests: ~5-10 minutes
- Linux build: ~10-15 minutes
- Android build: ~15-20 minutes
- Total release time: ~30-45 minutes

First build takes longer (cache warming). Subsequent releases are faster due to caching.

## Template: Release Notes

    ## 🎉 JellyTau vX.Y.Z

    ### ✨ Features
    - New feature 1
    - New feature 2

    ### 🐛 Bug Fixes
    - Fixed issue #123
    - Fixed issue #456

    ### 🚀 Performance
    - Improvement 1
    - Improvement 2

    ### 📱 Downloads
    - [Linux AppImage](#) - Run on any Linux
    - [Linux DEB](#) - Install on Ubuntu/Debian
    - [Android APK](#) - Install on Android devices
    - [Android AAB](#) - For Google Play Store

    ### 📋 Requirements
    **Linux:** 64-bit, GLIBC 2.29+
    **Android:** 8.0+

    ### 🔗 Links
    - [Changelog](https://gitea.tourolle.paris/dtourolle/jellytau/src/branch/master/CHANGELOG.md)
    - [Issues](https://gitea.tourolle.paris/dtourolle/jellytau/issues)

    ---
    Built with Tauri, SvelteKit, and Rust 🦀

## Quick Commands

    # View existing tags
    git tag -l

    # Create release locally (dry run)
    git tag -a v1.2.0 -m "Release v1.2.0" --dry-run

    # List commits since last tag
    git log v1.1.0..HEAD --oneline

    # Show tag details
    git show v1.2.0

    # Rename tag (if needed)
    git tag v1.2.0_old v1.2.0
    git tag -d v1.2.0
    git push origin v1.2.0_old v1.2.0

    # Delete tag locally and remotely
    git tag -d v1.2.0
    git push origin :refs/tags/v1.2.0

Tips:

- ✅ Always test locally before release
- ✅ Use semantic versioning consistently
- ✅ Document changes in CHANGELOG
- ✅ Wait for full workflow completion
- ✅ Test release artifacts before announcing

Remember: A good release is a tested release! 🚀

# Native player — verification plan

What to check before the `MediaPlayer` contract and Linux native video reach `master`.

This is not a generic smoke test. Every case below exists because something specific went wrong, and most of them were found on hardware after the automated suites were green. Treat the sequences as load-bearing: several defects only appeared in a particular order of actions, and testing the same features in a different order missed them entirely.

Companion to release-checklist.md, which covers the release mechanics. This covers whether the player is fit to release at all.

## What is risky about this change

- `PlayerController` now talks to a `MediaPlayer` contract instead of `PlayerBackend`. Every engine reaches it through an adapter that did not exist before (DR-245).
- mpv decodes video on Linux for the first time, composited under the webview (DR-231).
- Seek strategy is driven by an ability each engine declares rather than by a truth table (DR-246).
- Two regressions were introduced during this work and caught only on a device: a wrong capability for ExoPlayer (DR-246 follow-up) and a `Duration` panic (DR-252). Both were invisible to the test suites.

The suites originally verified only engines that behave, which is why both regressions passed them. That gap is now partly closed in code rather than in this document: `UT-223` drives a deliberately hostile engine — `C.TIME_UNSET`, NaN, infinities, negatives — through the adapter, and fails with the exact panic that produced a black screen on a tablet. `UT-224` pins the handoff clearing that was previously verified by listening to a device.

Prefer moving cases out of this file and into tests. Anything here that could fail automatically should; a checklist depends on someone remembering to follow it, and the two defects it was written for cost hardware time that would have been better spent making the suites realistic. What is left below is what genuinely needs eyes, ears, or a display — not what merely has not been automated yet.

## 1. Automated gates

Cheap, fast, and non-negotiable. Run from the worktree.

    bun run check                      # 0 errors, 0 warnings
    bun run test                       # frontend
    bun run test:rust                  # Rust
    bun run format:check
    bun run lint                       # 0 errors; warnings at or below the CI ratchet
    bun run check:boundary
    bun run traces:validate
    bun run traces:coverage            # at or above MIN_THRESHOLD
    cd src-tauri && cargo fmt --check && cargo clippy --all-targets -- -D warnings
    cargo clippy --all-targets --features conformance -- -D warnings

The eslint warning count is a ratchet: equal to the CI limit is a pass, one over fails the build. Going one over is how a piece of dead state was found during this work — do not raise the limit to get past it.

## 2. Engine conformance

    bun run test:player                # mpv + legacy, desktop
    bun run test:player:android        # ExoPlayer, on a connected device

Expected, and each deviation is meaningful rather than noise:

Engine | Result | If it differs
--- | --- | ---
`MpvPlayer` | 9/9 | A real regression. Stop.
`LegacyPlayer` | 8/9 | The one failure is `transport_settings_round_trip`: the old trait has no mute or rate. Any other failure is a regression.
ExoPlayer (device) | 7/7 | Two cases are absent because the Kotlin player exposes no mute or rate.

A green conformance run is not sufficient evidence to ship. Both regressions introduced during this work passed conformance.

## 3. Desktop (Linux)

Run with native video on, since that is what is new:

    JELLYTAU_NATIVE_VIDEO=1 bun run tauri dev

- Direct play — a file the server does not transcode. Picture and sound.
- Transcoded play — something the server must re-encode (4K, HEVC, or an audio codec the renderer cannot take).
- Resume — an item watched previously on this install. The prompt appears and playback starts at the offered position, not at zero. (Resume is device-local — see "Known open".)
- Scrub on a direct-play item; position lands and playback continues.
- Scrub on a transcoded item. Separate case on purpose: it takes a different path, and it silently did nothing for months (DR-238).
- Pause and resume — the button follows the player. It stopped doing so when a property was handled but never observed (DR-239).
- Fullscreen — the window really fills the display. Measure it if unsure: the log prints `rendering WxH`, and a height short of the panel means the document went fullscreen and the window did not (DR-240).
- Exit the player — audio stops. Listen; do not assume.
- Audio-only playback still works: mini player, queue, next/previous.
- Nothing in the log matches `PANIC` or `ERROR`.

## 4. Android

The tablet needs the side-by-side build. Do not uninstall the release app to make an install succeed — see "Known open" for why the normal command is currently wrong.

    bun run android:build --device
    ./scripts/sync-android-sources.sh
    cd src-tauri/gen/android && ANDROID_HOME="$HOME/Android/Sdk" ./gradlew \
      :app:assembleUniversalDebug -x :app:rustBuildUniversalDebug \
      -x :app:rustBuildArm64Debug -x :app:rustBuildArmDebug \
      -x :app:rustBuildX86Debug -x :app:rustBuildX86_64Debug
    adb install -r app/build/outputs/apk/universal/debug/app-universal-debug.apk

Confirm the package is `com.dtourolle.jellytau.debug` before installing:

    aapt2 dump packagename <apk>

If it says `com.dtourolle.jellytau`, the suffix was lost — stop, re-sync and re-assemble. Installing it would try to replace the real app.

Then, with `adb logcat` capturing:

- Play a video. Picture, sound, and controls.

- Scrub. The bar has a scale — a duration of `0.0` means the seek bar has nothing to scrub against (DR-251).

- Transcoded seek lands rather than restarting the stream. ExoPlayer seeks a transcode in place; declaring otherwise re-opened it (DR-246).

- PiP.

- Lockscreen: controls respond and position tracks.

- The handoff sequence, in this exact order: 1. play a video 2. enable background audio 3. background the app — audio continues 4. foreground the app — video returns 5. exit the player — everything stops

      Steps 4 and 5 are where two separate defects lived (DR-250, DR-252). Doing
      the same actions in another order finds neither.

- `grep -c 'PANIC at' <logcat>` returns 0.

## 5. Regression checks with a named cause

Each of these presented as something other than its cause, which is why they are listed separately from the feature passes above.

Symptom to look for | Was actually | Ref
--- | --- | ---
Skip on a transcoded item does nothing, or jumps to zero | Seek strategy keyed on the container, not the engine | DR-238, DR-246
Play/pause button does not follow the player | A property handled but never observed, so the event never arrived | DR-239
Fullscreen leaves a strip of desktop | The document went fullscreen, the window did not | DR-240
Resume plays from the beginning | A seek issued before the engine had a file was discarded | DR-241
Scrub bar has no scale | Duration reported as `0.0` and believed | DR-251
Black screen, no controls, after a background-audio round trip | A junk duration converted to a `Duration` panicked the backend | DR-252
Audio still playing after leaving the player | The stop was aimed at whichever renderer bookkeeping believed was active | DR-250

## Known open — decide, do not discover

None of these are fixed. Each needs an explicit ship / do-not-ship call rather than being met with surprise during testing.

- Resume is device-local. Progress is read from the local database and nothing consults the server's `UserData`. A fresh install, a second device or a reinstall offers no resume even though the server knows the position. Not a regression — it has always been so.
- The background-audio handoff is an unconfirmed state swap. `exit_background_audio` marks the video element the player again the moment it is called, while the element has not reloaded. DR-250 makes the visible symptom impossible; the race is intact and can still misdirect a lockscreen command or a position read. See media-player-controller.md.
- 🔴 The side-by-side debug install is broken. `bun run android:dev` produces an APK with the release application id, because the Tauri build regenerates `gen/build.gradle.kts` after the sync drops the `.debug` suffix in. It then fails on signatures, and its own error message advises uninstalling — which would destroy the real app's data. Fix this before anyone else builds for Android.
- `PlayerBackend` still exists behind `LegacyPlayer`, and the frontend still carries some playback state. DR-248 and DR-249 are not started.

## Ship criteria

Ship when:

1. Every automated gate in §1 passes.
2. Conformance matches §2 exactly, deviations included.
3. §3 and §4 are complete, on real hardware, by a person.
4. §5 shows no symptom returning.
5. Every item in "Known open" has a recorded decision.

Do not ship on green suites alone. Both regressions introduced during this work passed every suite and were caught by a person using the app.

# Desktop packaging (Linux, Arch, Windows)

How to produce distributable desktop packages for JellyTau. All three flows can run in Docker so no host toolchain setup is required. Outputs land in `./dist`.

## One builder image (shared with CI)

The deb/rpm and Windows-cross flows build on the unified registry builder (../Dockerfile.builder → `gitea.tourolle.paris/dtourolle/jellytau-builder`), the same image CI uses. It carries every packaging tool: Android SDK/NDK, `rpm`/`file` (Linux bundler), `cargo-xwin` + `lld` + `llvm` + `nsis` + the `x86_64-pc-windows-msvc` rust target (Windows). There is one dependency source of truth — no per-stage tool installs.

The desktop stages in ../Dockerfile are thin `FROM ${BUILDER_IMAGE}` environments; the actual build runs at container-run time on your bind-mounted source (like the `dev` service), so source edits need no image rebuild.

If you changed `Dockerfile.builder` (e.g. added a tool), rebuild and push it first, or the packaging flows use the stale registry image:

    scripts/build-builder-image.sh           # build + push :latest to the registry
    # ...or iterate locally without pushing:
    docker build -f Dockerfile.builder -t jellytau-builder:latest .
    BUILDER_IMAGE=jellytau-builder:latest bun run docker:build:windows

Arch uses a separate `archlinux` image (../Dockerfile.arch) because `makepkg` is Arch-specific — it is not part of the unified builder.

Target | Format | Docker command | Functional?
--- | --- | --- | ---
Debian/Ubuntu, Fedora | `.deb`, `.rpm` | `bun run docker:build:linux` | ✅ yes
Arch Linux | `.pkg.tar.zst` | `bun run docker:build:arch` | ✅ yes
Windows | NSIS installer + `.exe` | `bun run docker:build:windows` | ✅ yes (unsigned)

## Linux: deb + rpm

Tauri's bundler produces these natively. The build runs on the existing Ubuntu builder image (../Dockerfile, `desktop-linux-build` stage):

    bun run docker:build:linux            # deb + rpm -> ./dist
    # or, on a host with the Tauri Linux deps installed:
    BUNDLES="deb,rpm" scripts/build-desktop-linux.sh

Runtime dependency: the app links libmpv (audio) and WebKitGTK (webview + HTML5 transcoded video). The deb/rpm declare these.

> Note: `appimage` is also a valid Tauri target if you want a portable bundle — add it to `BUNDLES`.

## Arch Linux: pacman package

Tauri has no `pacman` bundle target (as of tauri-cli 2.9.x — valid targets are deb/rpm/appimage/msi/nsis/app/dmg). So we ship a hand-written PKGBUILD in ../packaging/arch/PKGBUILD and build it with `makepkg` on an Arch base image (../Dockerfile.arch):

    bun run docker:build:arch             # .pkg.tar.zst -> ./dist

The PKGBUILD is AUR-ready: swap its `source=()` for a release tarball/VCS URL to publish. Runtime deps: `webkit2gtk-4.1`, `mpv`, `gtk3`, `libayatana-appindicator`.

`makepkg` refuses to run as root, so the Docker stage builds as a non-root `builder` user. Because the image `COPY`s the source at build time, the `arch-build` compose service does not bind-mount the repo — rebuild the image to pick up source changes.

## Windows: NSIS installer cross-compiled from Linux

Produces a working (unsigned) NSIS installer + `.exe` via the official Tauri cross-compile path — the `x86_64-pc-windows-msvc` target driven by `cargo-xwin`. Video plays via WebView2 and audio via the webview `<audio>` backend. See build-windows.md for the full explanation.

    bun run docker:build:windows                     # NSIS installer + .exe -> ./dist
    WIN_BUNDLES=none bun run docker:build:windows     # exe only, skip bundling

The Docker `windows-cross` stage is a thin layer over the builder, which carries `cargo-xwin` + `lld` + `llvm` + `nsis` + the `x86_64-pc-windows-msvc` target. Cross-compilation is Tauri's "last resort" path (less tested than building on Windows); a `windows-latest` CI job is the fallback if it misbehaves.

# Windows build

JellyTau targets Linux and Android primarily, but a working Windows build — including an NSIS installer cross-compiled from Linux — is produced by the Docker tooling. It is not yet a first-class release target (no code signing / CI job / SMTC lockscreen), but it runs and plays media.

## How playback works on Windows

- Video — renders through the webview HTML5 `<video>` element (hls.js) on every platform; on Windows that is WebView2 (Chromium/Edge), which plays HLS + h264 fine. No Windows-specific code.
- Audio-only (music) — the native audio backends are libmpv (Linux) and ExoPlayer (Android); neither exists on Windows. Instead `create_player_backend()` in ../src-tauri/src/lib.rs uses `WebviewAudioBackend` on non-Linux/non-Android targets: it hands the stream URL to a webview `<audio>` element (see ../src/lib/services/webviewAudio.ts), which reports state back through the same `player_report_*` round-trip the video path uses. Pure Rust + Tauri events.

## Cross-compiling from Linux (MSVC + cargo-xwin)

We use the official Tauri cross-compile path: the MSVC target (`x86_64-pc-windows-msvc`) driven by `cargo-xwin`, which downloads the MSVC CRT / Windows SDK headers and links with `lld`. MSVC is the target Tauri officially supports for Windows (mingw/GNU is not), and — unlike GNU — it lets the Tauri CLI bundle the NSIS installer from a Linux host.

> Why not mingw/GNU? The GNU target does link a valid `.exe`, but the Tauri CLI gates `--bundles` by the host OS unless it recognizes a real Windows build. `--runner cargo-xwin --target x86_64-pc-windows-msvc` is what flips it into Windows mode and enables the `nsis`/`msi` bundlers on Linux.

The builder image (../Dockerfile.builder) bakes in the whole toolchain: the `x86_64-pc-windows-msvc` rust target, `cargo-xwin`, `lld`, `llvm`, and `nsis`.

    bun run docker:build:windows            # NSIS installer + .exe -> ./dist
    WIN_BUNDLES=none bun run docker:build:windows   # exe only, skip bundling

Or directly on a host that has the toolchain:

    scripts/build-windows-cross.sh                    # nsis installer + exe
    WIN_BUNDLES=none scripts/build-windows-cross.sh   # exe only

Under the hood the build runs:

    tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis

Outputs:

- `.exe` — `src-tauri/target/x86_64-pc-windows-msvc/release/jellytau.exe`
- NSIS installer — `.../release/bundle/nsis/*-setup.exe`

(both copied to `./dist` when `OUTPUT_DIR` is set).

## Caveats

- Cross-compilation is a last resort per Tauri's own docs — it's less tested than building on Windows. If it misbehaves, a `windows-latest` CI job or a Windows VM building natively (`tauri build --bundles nsis`) is the fallback.
- Code signing is not wired up — the installer is unsigned, so Windows SmartScreen will warn on first run.

## Outstanding for a first-class Windows release

1. Gapless/crossfade + SMTC (lockscreen) — currently no-ops in the webview audio path.
2. Downloaded (`Local` source) file playback needs `convertFileSrc` on the frontend; streaming works today.
3. Code signing + a Windows packaging CI job.

# Defect windows — which bugs were present when

For each fixed defect, the releases it was actually present in. Companion to CHANGELOG.md, which says what changed; this says how long each fault had been shipping before it did.

"Present since" is the first release containing the defective code, not the first release where a user could hit it — those differ, sometimes by months, and the gap is called out where it matters. "How dated" records the evidence, so a row can be re-checked or disputed:

Method | Meaning
--- | ---
`pickaxe` | `git log -S<token>` on the defective token — the commit that introduced the exact string, then the earliest tag containing it. Strongest evidence.
`feature` | The defect is inseparable from a feature that landed whole (bad rung in a new algorithm, missing caller in new plumbing), dated to that feature's release.
`absence` | The fix adds something that was never there. Dated to when the surrounding code was built, since there is no introducing commit to find. Weakest — treat as "no later than".

## Present since the first release

Sixteen defects date to the initial proof of concept (v0.0.1, 2026-06-23) and shipped for between two weeks and two months before anyone hit them. That is the dominant pattern here: not regressions, but original assumptions that went unexercised until a later feature leaned on them.

DR-265 is the clearest example of the "present since" / "reachable since" gap this file warns about: the `!isPlaying` gate has been there since the first commit, but nothing paused the document underneath a playing `<video>` until PiP started working on the HTML5 path in v0.5.3. Defective for ~9 weeks, hittable for ~2.

Defect | Present since | Fixed in | Shipped broken for | How dated
--- | --- | --- | --- | ---
`AudioStreamIndex=0` pinned the video stream as the audio track (DR-140) | v0.0.1 | v0.4.6 | ~7 weeks | pickaxe
Download URL spelled `videoBitrate`, which Jellyfin does not bind (DR-123) | v0.0.1 | v0.5.1 | ~7 weeks | pickaxe
`pause_download` / `resume_download` were no-ops (DR-168) | v0.0.1 | v0.5.3 | ~7.5 weeks | pickaxe
`.part` sidecar named by `with_extension`, so no cleanup path matched it (DR-169) | v0.0.1 | v0.5.3 | ~7.5 weeks | pickaxe
`Range` sent on every retry regardless of the response (DR-170) | v0.0.1 | v0.5.3 | ~7.5 weeks | pickaxe
`/Items/Latest` requested with the default `GroupItems=false` | v0.0.1 | v0.5.1 | ~7 weeks | pickaxe
`SubtitleStreamIndex` omitted from PlaybackInfo, letting the server burn in (DR-176) | v0.0.1 | v0.5.5 | ~8 weeks | pickaxe
No `PlaySessionId`, and one hardcoded `DeviceId`, on every stream URL (DR-177) | v0.0.1 | v0.5.5 | ~8 weeks | pickaxe
`download_item` never recorded `media_type`; NULL read as `'audio'` (DR-135) | v0.0.1 | v0.4.6 | ~7 weeks | pickaxe
`download_album` read its track list from the local cache (DR-173) | v0.0.1 | v0.5.5 | ~8 weeks | pickaxe

| Device profile carried no `MaxAudioChannels` (DR-141) | v0.0.1 | v0.4.6 | ~7 weeks | absence | | Streaming ceiling fixed at 20 Mbps with no way to lower it (UR-074) | v0.0.1 | v0.5.3 (as a feature) | ~7.5 weeks | pickaxe | | Hero banner auto-rotation never restarted after a manual swipe (DR-038) | v0.0.1 | v0.9.1 | ~8.5 weeks | pickaxe | | Audio-track change asked the player to select a track the transcode never carried (DR-258) | v0.0.1 | v0.11.1 | ~2 months | pickaxe | | Subtitle URL missing its `Stream.` route segment, so every fetch 404ed (DR-259) | v0.0.1 | v0.11.1 | ~2 months | pickaxe | | `timeupdate` gated on `!isPlaying`, so a paused activity froze the position (DR-265) | v0.0.1 | v0.11.5 | ~9 weeks | pickaxe |

### Why they took so long to surface

Four of these were latent until a later feature exercised them, which is why the fix lands so far from the cause:

- The `videoBitrate` casing was harmless while every download was `original`. It became visible only once a quality picker existed to select against — and then produced no error, just a full-size file, because Jellyfin discards an unbound query key silently.
- The unconditional `Range` header was inert for the same reason: `original` is the one rung served with a `Content-Length` and real byte-range support. It started corrupting files in v0.5.1, the moment the casing fix made transcoded downloads actually transcode. So the code dates to v0.0.1 and the corruption to v0.5.1 — a one-release window for the visible symptom.
- The missing `PlaySessionId` only bites when a stream is re-opened for the same item. Nothing re-opened one until quality switching, transcoded seek and audio-track switching existed.
- The omitted `SubtitleStreamIndex` only bites on sources whose own default subtitle track is image-based, since that is what forces the server from sidecar to burn-in.

Two were masked by soft failure: the asset protocol being disabled (DR-134) was hidden by the thumbnail cache falling back to the server copy whenever the server was reachable, and `AudioStreamIndex=0` was hidden by servers that silently correct an out-of-range index — which is exactly why it was reported as "some videos have no audio" rather than as a bug in the client.

## Introduced by a feature, fixed later

Defect | Present since | Fixed in | How dated
--- | --- | --- | ---
Native-path resume position never applied (both layers assumed the other seeked) | v0.0.9/v0.0.10 | v0.5.1 | feature (`PlayerAdapter` contract)
`get_downloaded_items` matched "this library exists" rather than constraining the item to it (DR-167) | v0.0.17 | v0.5.3 | feature (browsable downloaded library)
`SCOPE_ITEM_TYPES` — the frontend/backend boundary leak (DR-063) | v0.0.17 | v0.2.1 | pickaxe
`check:boundary` anchored to the query site, blind to a named const (DR-094) | v0.0.17 | v0.2.1 | feature (tripwire landed with the leak it missed)
Coverage gate divided by hardcoded denominators, reporting 158% (DR-093) | v0.0.1 | v0.2.1 | pickaxe
Tap deferral raced the WebView's synthesized click (DR-092 → DR-098) | v0.1.5 | v0.2.7 | feature (the deferral itself)
Transport for webview media decided from `el.paused` in the DOM (DR-097) | v0.0.9/v0.0.10 | v0.2.7 | feature (`Html5PlayerAdapter`)
`pick_current_episode` rung 3 returned the first gap, not the furthest watched | v0.3.0 | v0.5.1 | feature
`mirror_user_data` mirrored `is_favorite` alone and returned early (DR-155) | v0.4.0 | v0.5.1 | pickaxe
Stop-report path never fed the sync queue that existed for it (DR-154) | v0.4.6 | v0.5.1 | feature (queue + drain landed with no producer)
Background-audio base applied in two display-only places (DR-159) | v0.2.9 | v0.5.3 | pickaxe
Positions reported as 0 before the first tick, and always 0 for webview media (DR-178/179/180) | v0.5.3 | v0.5.5 | feature (DR-159's tick boundary)
Length-less handoff transcode left to the player's own load-error retry, which can only restart it (DR-203) | v0.0.16 | v0.8.2 | feature (the handoff's progressive-mp3 choice)
Recently Added trusted the server to group new tracks — `GroupItems=true` only groups a track whose parent chain resolves a `MusicAlbum`, and older servers ignore it | v0.5.1 | v0.11.4 | feature (the v0.5.1 fix for the same symptom)
PiP and the background-audio handoff both armable, decided by one `isInPictureInPictureMode` sample (DR-266) | v0.5.3 | v0.11.5 | feature (PiP on the HTML5 path, beside a toggle that had shipped in v0.0.16)

Three of these are worth separating out, because the defect is not a mistake in the code so much as plumbing that was built and never connected:

- `repository_get_next_up_episodes` accepted a `series_id` from the day it was written, and no caller passed one until v0.3.0.
- The sync queue and its drain were built, tested and running in v0.4.6 with neither of its two would-be producers ever called.
- Both halves of the watched-state backend existed with no caller before v0.5.3.

An automated check cannot see any of these — the code is present, tested and reachable in principle. Only tracing a requirement to a call site catches it.

## Short windows (one release or less)

Defect | Present since | Fixed in | Note
--- | --- | --- | ---
`experimentalNativeVideo` defaulted on, shipping audio with a blank screen (DR-161 → DR-172) | v0.5.3 | v0.5.4 | One release. The decode path was fine; the compositing step never ran.
Webview-shaped audio profile insufficient — server ignores a profile's audio codec (DR-149) | v0.4.7 | v0.4.8 | The v0.4.7 fix for DR-148 was necessary and not sufficient.
Android `versionCode` floor went stale (`minor*100` yielding less than the 5002 already in the field) | v0.5.0 | v0.5.3 | Caught before a broken APK shipped; no released build was un-installable.
Subtitle sidecar work reverted by a commit assembled from a stale tree | v0.5.5 | v0.5.5 | Never released broken — both commits are in v0.5.5.

## Fixed twice / never actually broken

- Autoplay time reset (v0.0.2). Two commit objects carry this identical change: `dcf08f30` (merged via Gitea PR #3, tagged v0.0.2) and `fa7cb6e9` (the local original). Both have the same parent `674c8e5c` and the same diff. A merge chain pulled `fa7cb6e9` and its follow-up `1e599627` into master's history during v0.5.5, so `git log v0.5.4..v0.5.5` lists an autoplay fix that changed no file in that release — `nextEpisodeService.ts` is byte-identical across the tag boundary. The fix shipped in v0.0.2 and has not regressed.

  This is the one case where reading the changelog off `git log` subjects would have produced a false entry, and it is a good argument for the project's practice of deriving release notes from TRACES rather than commit subjects.

## Recurring shapes

Four causes account for most of the table:

1. An omitted parameter is not a neutral default. `SubtitleStreamIndex`, `AudioStreamIndex`, `GroupItems` and `MaxAudioChannels` all had a server-side default that was actively wrong, and in three of the four the server's choice was more expensive than the one intended — burn-in forcing a full re-encode being the extreme case.
2. Silent binding failures. `videoBitRate` produced no error, no warning and a plausible-looking file. So did an unbound `Range`, and so did the coverage gate dividing by a stale denominator.
3. Two layers each assuming the other acts. Native resume (adapter recorded the position, backend never seeked), end-of-playback dispatch (two paths, one unreachable), and the surface/attach split in v0.5.0's native video.
4. A guard keyed on state that moves. The tap deferral keyed suppression on a timer handle the callback had already cleared; the HTML5 toggle keyed play-vs-pause on `el.paused`, which flips while buffering.

## Reproducing this

The pickaxe rows can be re-derived directly:

    git log --oneline --reverse -S'<defective token>' -- src-tauri/src   # introducing commit
    git tag --contains <sha> | sort -V | head -1                          # first release with it

Blaming the lines a fix removed (`git blame` at the fix's parent) is faster to run across many commits but was not used for the rows above: it reliably lands on whichever commit last touched the adjacent lines, which is usually not the commit that introduced the defect. It was used only to shortlist candidates.

# Docker & CI/CD Setup for JellyTau

This document explains how to use the Docker configuration and Gitea Act CI/CD pipeline for building and testing JellyTau.

## Overview

The setup includes:

- Dockerfile.builder: Pre-built image with all dependencies (push to your registry)
- Dockerfile: Multi-stage build for local testing and building
- docker-compose.yml: Orchestration for local development and testing
- .gitea/workflows/build-and-test.yml: Automated CI/CD pipeline using pre-built builder image

### Quick Start

For CI/CD (Gitea Actions):

1. Build and push builder image (see build-builder-image.md)
2. Push to master branch - workflow runs automatically
3. Check Actions tab for results and APK artifacts

For Local Testing:

    docker-compose run test       # Run tests
    docker-compose run android-build  # Build APK
    docker-compose run dev        # Interactive shell

## Docker Usage

### Prerequisites

- Docker Engine 20.10+
- Docker Compose 2.0+ (if using docker-compose)
- At least 10GB free disk space (for Android SDK and build artifacts)

### Building the Docker Image

    # Build the complete image
    docker build -t jellytau:latest .

    # Build specific target
    docker build -t jellytau:test --target test .
    docker build -t jellytau:android --target android-build .

### Using Docker Compose

#### Run Tests Only

    docker-compose run test

This will:

1. Install all dependencies
2. Run frontend tests (Vitest)
3. Run Rust backend tests
4. Report results

#### Build Android APK

    docker-compose run android-build

This will:

1. Run tests first (depends on test service)
2. If tests pass, build the Android APK
3. Output APK files to `src-tauri/gen/android/app/build/outputs/apk/`

#### Interactive Development

    docker-compose run dev

This starts an interactive shell with all development tools available. From here you can:

    bun install
    bun run build
    bun test
    bun run tauri android build --apk true

#### Run All Services in Sequence

    docker-compose up --abort-on-container-exit

### Extracting Build Artifacts

After a successful build, APK files are located in:

    src-tauri/gen/android/app/build/outputs/apk/

Copy to your host machine:

    docker cp jellytau-android-build:/app/src-tauri/gen/android/app/build/outputs/apk ./apk-output

## Gitea Act CI/CD Pipeline

The `.gitea/workflows/build-and-test.yml` workflow automates:

Single Job: Runs on every push to `master` and PRs

- Uses pre-built builder image (no setup time)
- Installs project dependencies
- Runs frontend tests (Vitest)
- Runs Rust backend tests
- Builds the frontend
- Builds the Android APK
- Uploads APK as artifact (30-day retention)

The workflow skips markdown files to avoid unnecessary builds.

### Workflow Triggers

The workflow runs on:

- Push to `master` or `main` branches
- Pull requests to `master` or `main` branches
- Can be extended with: `workflow_dispatch` for manual triggers

### Setting Up the Builder Image

Before using the CI/CD pipeline, you must build and push the builder image:

    # Build the image
    docker build -f Dockerfile.builder -t jellytau-builder:latest .

    # Tag for your registry
    docker tag jellytau-builder:latest gitea.tourolle.paris/dtourolle/jellytau-builder:latest

    # Push to registry
    docker push gitea.tourolle.paris/dtourolle/jellytau-builder:latest

See build-builder-image.md for detailed instructions.

### Setting Up Gitea Act

1. Ensure builder image is pushed (see above)

2. Push to Gitea repository: The workflow will automatically trigger on push to `master` or pull requests

3. View workflow runs in Gitea UI:

   - Navigate to your repository
   - Go to Actions tab
   - Click on workflow runs to see logs

4. Test locally (optional):

       # Install act if needed
       curl https://gitea.com/actions/setup-act/releases/download/v0.25.0/act-0.25.0-linux-x86_64.tar.gz | tar xz

       # Run locally (requires builder image to be available)
       ./act push --file .gitea/workflows/build-and-test.yml

### Customizing the Workflow

#### Modify Build Triggers

Edit `.gitea/workflows/build-and-test.yml` to change when builds run:

    on:
      push:
        branches:
          - master
          - develop  # Add more branches
        paths:
          - 'src/**'  # Only run if src/ changes
          - 'src-tauri/**'  # Only run if Rust code changes

#### Add Notifications

Add Slack, Discord, or email notifications on build completion:

    - name: Notify on success
      if: success()
      run: |
        curl -X POST https://slack-webhook-url...

#### Customize APK Upload

Modify artifact retention or add to cloud storage:

    - name: Upload APK to S3
      uses: actions/s3-sync@v1
      with:
        aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY }}
        aws_secret_access_key: ${{ secrets.AWS_SECRET_KEY }}
        aws_bucket: my-apk-bucket
        source_dir: src-tauri/gen/android/app/build/outputs/apk/

## Environment Setup in CI

### Secret Variables

To use secrets in the workflow, set them in Gitea:

1. Go to Repository Settings → Secrets
2. Add secrets like:
   - `AWS_ACCESS_KEY` for S3 uploads
   - `SLACK_WEBHOOK_URL` for notifications
   - `GITHUB_TOKEN` for releases (pre-configured)

## Troubleshooting

### Out of Memory During Build

Android builds are memory-intensive. If you get OOM errors:

    # Limit memory in docker-compose
    services:
      android-build:
        deploy:
          resources:
            limits:
              memory: 6G

Or increase Docker's memory allocation in Docker Desktop settings.

### Android SDK Download Timeout

If downloads timeout, increase timeout or download manually:

    # In container, with longer timeout
    timeout 600 sdkmanager --sdk_root=$ANDROID_HOME ...

### Rust Compilation Errors

Make sure Rust is updated:

    rustup update
    rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android

### Cache Issues

Clear Docker cache and rebuild:

    docker-compose down -v  # Remove volumes
    docker system prune      # Clean up dangling images
    docker-compose up --build

## Performance Tips

1. Cache Reuse: Both Docker and Gitea Act cache dependencies across runs
2. Parallel Steps: The workflow runs frontend and Rust tests in series; consider parallelizing for faster CI
3. Incremental Builds: Rust and Node caches persist between runs
4. Docker Buildkit: Enable for faster builds:

       DOCKER_BUILDKIT=1 docker build .

## Security Considerations

- Dockerfile uses `ubuntu:24.04` base image from official Docker Hub
- NDK is downloaded from official Google servers (verified via HTTPS)
- No credentials are stored in the Dockerfile
- Use Gitea Secrets for sensitive values (API keys, tokens, etc.)
- Lock dependency versions in `Cargo.toml` and `package.json`

## Next Steps

1. Test locally with `docker-compose up`
2. Push to your Gitea repository
3. Monitor workflow runs in the Actions tab
4. Configure secrets in repository settings for production builds
5. Set up artifact retention policies (currently 30 days)

## References

- Gitea Actions Documentation
- Docker Multi-stage Builds
- Android Build Tools
- Tauri Android Guide

# Building and Pushing the JellyTau Builder Image

This document explains how to create and push the pre-built builder Docker image to your registry for use in Gitea Act CI/CD.

## Prerequisites

- Docker installed and running
- Access to your Docker registry (e.g., `gitea.tourolle.paris`)
- Docker registry credentials configured (`docker login`)

## Building the Builder Image

### Step 1: Build the Image Locally

    # From the project root
    docker build -f Dockerfile.builder -t jellytau-builder:latest .

This creates a local image with:

- All system dependencies
- Rust with Android targets
- Android SDK and NDK
- Node.js and Bun
- All build tools pre-installed

### Step 2: Tag for Your Registry

Replace `gitea.tourolle.paris/dtourolle` with your actual registry path:

    docker tag jellytau-builder:latest gitea.tourolle.paris/dtourolle/jellytau-builder:latest

### Step 3: Login to Your Registry

If not already logged in:

    docker login gitea.tourolle.paris

### Step 4: Push to Registry

    docker push gitea.tourolle.paris/dtourolle/jellytau-builder:latest

## Complete One-Liner

    docker build -f Dockerfile.builder -t jellytau-builder:latest . && \
    docker tag jellytau-builder:latest gitea.tourolle.paris/dtourolle/jellytau-builder:latest && \
    docker push gitea.tourolle.paris/dtourolle/jellytau-builder:latest

## Verifying the Build

Check that the image was pushed successfully:

    # List images in your registry (depends on registry API support)
    docker search gitea.tourolle.paris/dtourolle/jellytau-builder

    # Or pull and test locally
    docker pull gitea.tourolle.paris/dtourolle/jellytau-builder:latest
    docker run -it gitea.tourolle.paris/dtourolle/jellytau-builder:latest bun --version

## Using in CI/CD

The workflow at `.gitea/workflows/build-and-test.yml` automatically uses:

    container:
      image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest

Once pushed, your CI/CD pipeline will use this pre-built image instead of installing everything during the build, saving significant time.

## Updating the Builder Image

When dependencies change (new Rust version, Android SDK update, etc.):

1. Update `Dockerfile.builder` with the new configuration
2. Rebuild and push with a new tag:

    docker build -f Dockerfile.builder -t jellytau-builder:v1.2.0 .
    docker tag jellytau-builder:v1.2.0 gitea.tourolle.paris/dtourolle/jellytau-builder:v1.2.0
    docker push gitea.tourolle.paris/dtourolle/jellytau-builder:v1.2.0

3. Update the workflow to use the new tag:

    container:
      image: gitea.tourolle.paris/dtourolle/jellytau-builder:v1.2.0

## Image Contents

The builder image includes:

- Base OS: Ubuntu 24.04
- Languages:
  - Rust (stable) with targets: aarch64-linux-android, armv7-linux-androideabi, x86_64-linux-android
  - Node.js 20.x
  - OpenJDK 17 (for Android)
- Tools:
  - Bun package manager
  - Android SDK 34
  - Android NDK 27.0.11902837
  - Build essentials (gcc, make, etc.)
  - Git, curl, wget
  - libssl, libclang development libraries
- Pre-configured:
  - Rust toolchain components (rustfmt, clippy)
  - Android SDK/NDK environment variables
  - All paths optimized for building

## Build Time

First build takes ~15-20 minutes depending on internet speed (downloads Android SDK/NDK). Subsequent builds are cached and take seconds.

## Storage

The built image is approximately 4-5 GB. Ensure your registry has sufficient storage.

## Troubleshooting

### "Image not found" in CI

- Verify the image name matches exactly in the workflow
- Check that the image was successfully pushed: `docker push` output should show successful layers
- Ensure Gitea has access to your registry (check network/firewall)

### Build fails with "command not found"

- The image may not have finished pushing. Wait a few moments and retry the CI job.
- Check that all layers were pushed successfully in the push output.

### Registry authentication in CI

If your registry requires credentials in CI:

1. Create a deploy token in your registry
2. Add to Gitea secrets as `REGISTRY_USERNAME` and `REGISTRY_TOKEN`
3. Use in workflow:

    - name: Login to Registry
      run: |
        docker login gitea.tourolle.paris -u ${{ secrets.REGISTRY_USERNAME }} -p ${{ secrets.REGISTRY_TOKEN }}

## References

- Docker Build Documentation
- Docker Push Documentation
- Dockerfile Reference

# Rust API Reference

The full backend API reference is generated by `cargo doc` (rustdoc).

👉 Open the Rust API Reference