docs: fold shipped specs into the architecture docs and delete them

A spec was a promise; sixteen of them had become descriptions of code that
already shipped, sitting beside four that describe work still outstanding, with
nothing in the file telling the two apart. Half the statuses were also wrong —
audio-equalizer read "Accepted" with the EQ live on both platforms, the native
video spec said the flag stays off after the default was flipped on.

The shipped designs move into docs/architecture, which is the maintained
description of the build, and the spec files go. Git history keeps the
originals; what a future change still needs is carried across:

- 01-rust-backend: favourites rewritten (the old section named a file that no
  longer exists and called shipped buttons "planned"), domain vocabulary owned
  by Rust (SearchScope, exclusions, the bitrate ladder), background workers
- 02-svelte-frontend: app shell and chrome, library mosaic, series/episode
  navigation, downloaded browse, safe-area insets, native-video store, logging
- 03-data-flow: locally-indexed search
- 05-platform-backends: audio settings on ExoPlayer, the equalizer's band
  vocabulary, native video compositing, the background-audio handoff
- 06-downloads-and-offline: one storage model, offline catalog visibility
- 09-security: path confinement and input binding

docs/specs/README.md now says what the directory is for and where each shipped
design went. Deferred work the specs recorded is kept beside the code it
concerns rather than lost: season-bounded autoplay, the two dead search
commands, why indexing is a full crawl.

requirements.md had fourteen stale statuses — Android audio parity still read
"Linux only", DR-150 still said the native-video default was off, DR-190 was
Proposed after DR-196 implemented it, and five tooling requirements were
Proposed after landing. Three unbuilt specs suggested requirement ids that have
since been allocated to other work; each now carries a warning.
This commit is contained in:
2026-08-21 18:15:58 +02:00
parent 8f5c9023d0
commit 32043a2152
48 changed files with 890 additions and 3686 deletions
+171 -38
View File
@@ -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<br/>pending_sync| UserData[user_data table]
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
```
**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<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](../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<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.
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.