diff --git a/.gitea/workflows/traceability-check.yml b/.gitea/workflows/traceability-check.yml index 52043f0d..ed5f6d7b 100644 --- a/.gitea/workflows/traceability-check.yml +++ b/.gitea/workflows/traceability-check.yml @@ -46,7 +46,7 @@ jobs: # hardcode them here. This step previously divided by frozen literals # (UR/39, IR/24, DR/48, JA/3, total 114) while the file had grown to # 211 requirements, so it reported 158% coverage and the threshold - # below could never trip. See docs/specs/traceability-gate-repair.md. + # below could never trip. See docs/traceability-ci.md. TOTAL_TRACES=$(jq '.totalTraces' traces-report.json) COVERED=$(jq '.coverage.covered' traces-report.json) TOTAL_REQS=$(jq '.coverage.total' traces-report.json) diff --git a/docs-site/SUMMARY.md b/docs-site/SUMMARY.md index 855a495e..c8733acd 100644 --- a/docs-site/SUMMARY.md +++ b/docs-site/SUMMARY.md @@ -26,47 +26,20 @@ - [UX Flows](ux-flows.md) -# Specs — Writing One +# Specs — Pending Work +- [Specs Index](specs/README.md) - [Spec Template](specs/SPEC-TEMPLATE.md) - [Spec Review Checklist](specs/SPEC-REVIEW-CHECKLIST.md) - -# Specs — Playback & Player - - [Playback Backend Unification](specs/playback-backend-unification.md) - [Player Facade Enforcement](specs/player-facade-enforcement.md) -- [Playback Documentation Corrections](specs/playback-docs-corrections.md) -- [Video Background Audio](specs/video-background-audio.md) -- [Android Native Video Spike](specs/android-native-video-spike.md) -- [Android Audio Settings Parity](specs/android-audio-settings-parity.md) -- [Audio Equalizer](specs/audio-equalizer.md) - [Windows Native Audio Backend](specs/windows-native-audio-backend.md) - [libmpv2 Migration](specs/libmpv2-migration.md) -- [Streaming Bitrate Cap](specs/streaming-bitrate-cap.md) - [Read-Through Media Cache](specs/read-through-media-cache.md) - -# Specs — Library & Browsing - - [Scoped Search](specs/scoped-search.md) - [Scoped Search Boundary](specs/scoped-search-boundary.md) - [Scoped Search Boundary — Implementation](specs/scoped-search-boundary-implementation.md) -- [Locally-Indexed Search](specs/catalog-index-search.md) -- [Favourites Browsing](specs/favorites-browsing.md) -- [Library Mosaic](specs/library-mosaic.md) -- [Series Current-Episode Navigation](specs/series-current-episode-navigation.md) -- [Account Menu](specs/account-menu.md) - [Frontend Domain Model](specs/frontend-domain-model.md) - -# Specs — Downloads & Offline - -- [Downloads as an Offline Library](specs/downloads-as-offline-library.md) -- [Offline Downloaded-Only Filter](specs/offline-downloaded-only-filter.md) - -# Specs — Tooling & Build - -- [Traceability Gate Repair](specs/traceability-gate-repair.md) -- [Boundary Tripwire Hardening](specs/boundary-tripwire-hardening.md) -- [Requirement-Coverage Script Removal](specs/req-coverage-script-removal.md) - [Build Provenance](specs/build-provenance.md) # Build & Release diff --git a/docs/architecture/01-rust-backend.md b/docs/architecture/01-rust-backend.md index 9044af49..8486aeb0 100644 --- a/docs/architecture/01-rust-backend.md +++ b/docs/architecture/01-rust-backend.md @@ -376,57 +376,77 @@ flowchart TB ## Favorites System **Location**: -- Service: `src/lib/services/favorites.ts` -- Component: `src/lib/components/FavoriteButton.svelte` -- Backend: `src-tauri/src/commands/storage.rs` +- 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` -The favorites system implements optimistic updates with server synchronization: +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: ```mermaid flowchart TB UI[FavoriteButton] -->|Click| Service[toggleFavorite] - Service -->|1. Optimistic| LocalDB[(SQLite user_data)] - Service -->|2. Sync| JellyfinAPI[Jellyfin API] - Service -->|3. Mark Synced| LocalDB - - JellyfinAPI -->|POST| MarkFav["/Users/{id}/FavoriteItems/{itemId}"] - JellyfinAPI -->|DELETE| UnmarkFav["/Users/{id}/FavoriteItems/{itemId}"] - - LocalDB -->|is_favorite
pending_sync| UserData[user_data table] + Service -->|"1. Optimistic"| LocalDB[("SQLite user_data
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
(background task)"] -->|"pending_sync = 1"| Repo ``` -**Flow**: -1. User clicks heart button in UI (MiniPlayer, AudioPlayer, or detail pages) -2. `toggleFavorite()` service function handles the logic: - - Updates local SQLite database immediately (optimistic update) - - Attempts to sync with Jellyfin server - - Marks as synced if successful, otherwise leaves `pending_sync = 1` -3. UI reflects the change immediately without waiting for server response +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](#background-workers). -**Components**: +### Browsing -- **FavoriteButton.svelte**: Reusable heart button component - - Configurable size (sm/md/lg) - - Red when favorited, gray when not - - Loading state during toggle - - Bindable `isFavorite` prop for two-way binding +`get_favorites(scope, options)` answers "what did this user favourite", across +libraries, with the **scope owned by Rust** — the frontend sends a +[`SearchScope`](#search-scope-and-the-taxonomy-boundary) variant and never names +an item type. `HybridRepository` splits it the same way it splits every query: -- **Integration Points**: - - MiniPlayer: Shows favorite button for audio tracks (hidden on small screens) - - Full AudioPlayer: Shows favorite button (planned) - - Album/Artist detail pages: Shows favorite button (planned) +| 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 | -**Database Schema**: -- `user_data.is_favorite`: Boolean flag (stored as INTEGER 0/1) -- `user_data.pending_sync`: Indicates if local changes need syncing +`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. -**Tauri Commands**: -- `storage_toggle_favorite`: Updates favorite status in local database -- `storage_mark_synced`: Clears pending_sync flag after successful sync +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. -**API Methods**: -- `LibraryApi.markFavorite(itemId)`: POST to Jellyfin -- `LibraryApi.unmarkFavorite(itemId)`: DELETE from Jellyfin +**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 @@ -569,3 +589,116 @@ async fn move_playlist_item(&self, playlist_id: &str, item_id: &str, new_index: | `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](../../CLAUDE.md) and +[scoped-search-boundary.md](../specs/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: + +```rust +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> { … } + + /// The scope a library of this Jellyfin `CollectionType` belongs to. + pub fn for_collection_type(collection_type: &str) -> Option { … } +} +``` + +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](../specs/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>` 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. + +The frontend names a variant and nothing else; the labels the picker shows are +served over IPC by `player_get_streaming_qualities`. + +## 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. diff --git a/docs/architecture/02-svelte-frontend.md b/docs/architecture/02-svelte-frontend.md index a423eb54..539253bb 100644 --- a/docs/architecture/02-svelte-frontend.md +++ b/docs/architecture/02-svelte-frontend.md @@ -538,6 +538,14 @@ sequenceDiagram ## 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](../../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 @@ -657,3 +665,180 @@ The playlist UI provides full CRUD operations for Jellyfin playlists with offlin 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](01-rust-backend.md#search-scope-and-the-taxonomy-boundary)). +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/` 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: + +```css +--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. + +## 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 ``, 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](05-platform-backends.md#native-video-compositing-android) +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. diff --git a/docs/architecture/03-data-flow.md b/docs/architecture/03-data-flow.md index 4e07663c..140099cd 100644 --- a/docs/architecture/03-data-flow.md +++ b/docs/architecture/03-data-flow.md @@ -49,6 +49,61 @@ sequenceDiagram - 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](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. +## 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. + +```mermaid +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:
scheduled crawl keeps the index fresh,
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](01-rust-backend.md#search-scope-and-the-taxonomy-boundary)). +- **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](01-rust-backend.md#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 ```mermaid diff --git a/docs/architecture/05-platform-backends.md b/docs/architecture/05-platform-backends.md index 6429b1da..bcee2598 100644 --- a/docs/architecture/05-platform-backends.md +++ b/docs/architecture/05-platform-backends.md @@ -247,6 +247,184 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO } ``` +### 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](../requirements.md#platform-playback-backend-parity-linux-vs-android), +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: + +```rust +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 `