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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user