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.
|
||||
|
||||
@@ -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/<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:
|
||||
|
||||
```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 `<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](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.
|
||||
|
||||
@@ -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:<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](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
|
||||
|
||||
@@ -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 `<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.
|
||||
|
||||
```mermaid
|
||||
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).
|
||||
|
||||
```mermaid
|
||||
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](02-svelte-frontend.md#safe-area-insets)).
|
||||
|
||||
## Android MediaSession & Remote Volume Control
|
||||
|
||||
**Location**: `JellyTauPlaybackService.kt`
|
||||
|
||||
@@ -139,9 +139,56 @@ flowchart TB
|
||||
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](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.rs`
|
||||
**Location**: `src-tauri/src/commands/download/` — `mod.rs` (the commands below), `pinning.rs`, `smart_cache.rs`
|
||||
|
||||
| Command | Parameters | Description |
|
||||
|---------|------------|-------------|
|
||||
@@ -152,6 +199,11 @@ flowchart TB
|
||||
| `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
|
||||
|
||||
|
||||
@@ -122,6 +122,41 @@ reports `NETWORK_NO_SOURCE` (which is exactly how DR-134's failure presented).
|
||||
| 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
|
||||
|
||||
+29
-17
@@ -95,15 +95,15 @@ Each major subsystem is documented in its own file in this directory:
|
||||
|
||||
| Document | Contents |
|
||||
|----------|----------|
|
||||
| [01 - Rust Backend](01-rust-backend.md) | Media session state machine, player state machine, playback mode, media items, queue manager, favorites, player backend trait, player controller, playlist system, Tauri commands |
|
||||
| [02 - Svelte Frontend](02-svelte-frontend.md) | 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 |
|
||||
| [03 - Data Flow](03-data-flow.md) | Repository query flow (cache-first), playback initiation, playback mode transfer, queue navigation, volume control |
|
||||
| [01 - Rust Backend](01-rust-backend.md) | 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](02-svelte-frontend.md) | 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](03-data-flow.md) | Repository query flow (cache-first), locally-indexed search, playback initiation, playback mode transfer, queue navigation, volume control |
|
||||
| [04 - Type Sync & Threading](04-type-sync-and-threading.md) | Rust/TypeScript type synchronization, Tauri v2 IPC parameter naming convention, thread safety patterns |
|
||||
| [05 - Platform Backends](05-platform-backends.md) | Player events system, MpvBackend (Linux), ExoPlayerBackend (Android), MediaSession & remote volume, album art caching, backend initialization |
|
||||
| [06 - Downloads & Offline](06-downloads-and-offline.md) | Download manager, download worker, smart caching engine, download/offline commands, player integration, frontend store, UI components |
|
||||
| [05 - Platform Backends](05-platform-backends.md) | 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](06-downloads-and-offline.md) | 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](07-connectivity.md) | HTTP client with retry logic, connectivity monitor, network resilience architecture |
|
||||
| [08 - Database Design](08-database-design.md) | 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](09-security.md) | Authentication token storage, secure storage module, network security, local data protection |
|
||||
| [09 - Security](09-security.md) | Authentication token storage, secure storage module, network security, webview CSP + asset-protocol scope, **path confinement and input binding**, local data protection |
|
||||
|
||||
---
|
||||
|
||||
@@ -112,17 +112,24 @@ Each major subsystem is documented in its own file in this directory:
|
||||
```
|
||||
src-tauri/src/
|
||||
├── lib.rs # Tauri app setup, state initialization
|
||||
├── commands/ # Tauri command handlers (90+ commands)
|
||||
├── commands/ # Tauri command handlers (~245 #[tauri::command] fns)
|
||||
│ ├── mod.rs # Command exports
|
||||
│ ├── player.rs # 16 player commands
|
||||
│ ├── repository.rs # 27 repository commands
|
||||
│ ├── playlist.rs # 7 playlist commands
|
||||
│ ├── playback_mode.rs # 5 playback mode commands
|
||||
│ ├── connectivity.rs # 7 connectivity commands
|
||||
│ ├── storage.rs # Storage & database commands
|
||||
│ ├── download.rs # 7 download commands
|
||||
│ ├── offline.rs # 3 offline commands
|
||||
│ └── sync.rs # Sync queue commands
|
||||
│ ├── 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.
|
||||
@@ -207,4 +214,9 @@ src/lib/
|
||||
|
||||
The frontend is genuinely UI-heavy; business decisions live in Rust, but the UI owns layout, navigation, and interaction state.
|
||||
|
||||
**Total Commands:** 90+ Tauri commands across 14 command modules
|
||||
**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`.
|
||||
|
||||
Reference in New Issue
Block a user