Listings matched children on four columns at once (parent_id, album_id,
season_id, series_id) because Jellyfin's ParentId is the storage parent,
not the logical one. The OR defeated the planner into a full scan, and it
was wrong: every episode carries its series id, so a series listed all its
episodes beside its seasons (on both the browse and Downloads surfaces).
- Migration 027 adds items.container_id, a VIRTUAL generated column
(episode -> season/series/parent, season -> series, track -> album,
else parent) indexed with (sort_name, name), so a listing is one
ordered index range and every write path is covered untouched.
- Containers never cached (an episode that arrived via Next Up) get
placeholders named from the child's own fields, in the migration and
on every cache write, so offline navigation stays series -> season.
- The six queries that built the set of every downloaded item in a CTE
(get_item, latest, recently played, search, favourites, by-person,
Downloads) now check availability per row with one shared predicate.
- PRAGMA optimize at open gives the planner statistics.
Benchmark (~110k items, desktop): series listing ~80 ms -> <1 ms;
migration 027 upgrades that database in ~0.1 s (0.2 s on the Fairphone).
Tests first: a series listing its episodes, and the Downloads series
drill, both failed before the change.
A series page took about a second to show its seasons on a phone, every
visit, although they were cached. Three things stacked up:
- One SQLite connection behind one mutex served the whole app, so every
read queued behind every write. The database now has one owner: a
writer thread for writes and a pool of read-only WAL connections for
reads. synchronous = NORMAL and a busy timeout on every connection.
- The listing query built the set of every available item in the
database before filtering to the parent (~80 ms on a desktop for a
100k-item cache), then fetched user data one row at a time. It now
checks availability per row, uses the hierarchy indexes (1.5 ms on
the same benchmark) and batches the user-data lookup.
- A cache read that missed the 100 ms fast path was set aside until the
server answered. It is now raced against the server; whichever answers
first with content wins.
On the Fairphone, Frasier's season and episode lists now come from
cache in 34-133 ms (was 600-1030 ms waiting on the server).
Fixes found on the way, each with a test that failed first:
- sync_queue_mutation could return another mutation's row id: the id
came from a second trip to the shared connection. insert() reads it in
the same job.
- save_to_cache switched foreign keys off on the shared connection
across its awaits, so concurrent writes ran unchecked. The toggle now
lives inside one writer job, and a page is one transaction instead of
one commit per row.
Also: thumbnail LRU touches no longer block the lookup; unused
tokio-rusqlite dropped. Design and invariants in
docs/architecture/08-database-design.md (Connection ownership, Listing
query shape) and 03-data-flow.md.
Migrations ran as bare `execute_batch` calls with the `_migrations` row
written afterwards. SQLite autocommits every statement, so a migration
that died partway — low disk, an OOM kill, the process dying mid-boot —
left its earlier statements applied and recorded nothing.
That is unrecoverable rather than merely untidy. `execute_batch` aborts
on the first error, so the retry on the next launch failed at statement 1
with "duplicate column name" and kept failing forever, and
`Database::open` turns a migration error into a `panic!` — the app never
started again and the only fix was clearing app data, losing downloads
and logins. Several migrations have exactly the shape that triggers it:
006 is three `ADD COLUMN`s, 003/005/024 are full table rebuilds.
Each migration now runs in one transaction with its `_migrations` row
committed inside it, so a migration is all-or-nothing and a retry is
always safe. Every migration is pure DDL/DML, which SQLite runs
transactionally; a `PRAGMA` or `VACUUM` added to one would not roll back.
`migrate()` delegates to a new `migrate_with()` so a test can inject a
deliberately-failing migration.
Hand video playback off to a native audio-only stream when the app is
backgrounded or locked, with no on-device video decode (UR-040). Adds
player_enter/exit_background_audio commands, an audio-only stream URL
for video items across the repository layer, and the frontend handoff
state machine wired into VideoPlayer. Includes accompanying
repository/offline/player refactoring and regenerates the traceability
matrix.
Workstream A — poison-tolerant locking:
- Add utils/lock.rs with MutexSafe/RwLockSafe extension traits that recover a
poisoned std::sync lock instead of panicking, plus unit tests.
- Replace all 153 .lock().unwrap() and 4 .read()/.write().unwrap() production
sites with _safe variants across 14 files, eliminating the player
crash-cascade class. Tokio async mutexes are unchanged.
Workstream B — graceful backend init:
- create_player_backend no longer panics when MPV/ExoPlayer fail to initialize;
it falls back to NullBackend and emits a backend-init-failed event so the UI
can show "playback unavailable" instead of the app crashing. Fatal DB-setup
panics are kept.
Workstream F — doc reconciliation:
- Rewrite software-architecture.md's inaccurate "thin UI / ~800 lines" claims to
reflect reality (~20.5k non-test frontend) and document the events+polling
hybrid plus the new locking/backend-init behavior.