feat(search): answer search from a local index; tier downloads by lifetime

Search's instant leg read only downloaded items, so with no downloads it
returned nothing and every keystroke fell through to a full Recursive=true
server query. It now reads the whole synced catalog through the same
availability CTE get_items uses, gated on the same include_catalog_browse
flag so search and browse cannot diverge. (UR-065, DR-108)

Also fixes three defects found while confirming that:

- items_fts grew by a full duplicate index every catalog pass. INSERT OR
  REPLACE fires no AFTER DELETE trigger without recursive_triggers, so the
  old index row was orphaned, and a TEXT PRIMARY KEY meant the replacement
  took a fresh rowid and inserted a second entry. Now a real upsert, with
  migration 021 rebuilding existing indexes. (DR-110)
- DELETE FROM items existed nowhere, so server-side deletions never
  propagated. Adds a post-crawl mark-and-sweep, scoped to crawled types,
  skipping downloaded items, and refusing to run after a partial crawl
  because items.parent_id cascades. (DR-110)
- The index omitted MusicArtist, Playlist and People, which search groups
  results by. Adds them plus people_fts (migration 022). (DR-111)

Re-indexing moves from a frontend startup call to a Rust background task
with a 6h TTL, so a long session no longer searches a stale catalog and a
restart no longer forces a crawl regardless of freshness. (DR-109, IR-030)

Downloads gain a lifetime tier. Eviction selected every completed row by
age with no download_source filter, so hitting the storage limit deleted
the oldest download -- typically one saved deliberately for offline -- to
make room for a precached track. It now reclaims only 'auto' rows, and
expired ones are reclaimed first, before live cache is evicted.
(DR-126, DR-127)

Downloaded video and audio-only handoffs now play from disk instead of
streaming; the video path had never consulted downloads at all. No
transcode is involved: MPV runs video=no and ExoPlayer has no surface for
an Audio item. (DR-123 in part, DR-128)

FTS queries are built as quoted phrases so apostrophes, hyphens and
slashes are data rather than operator syntax, and the item-type filter is
bound rather than interpolated.

Specs: docs/specs/catalog-index-search.md,
docs/specs/read-through-media-cache.md

Includes concurrently-developed favourites browsing and background-audio
stream-end handling; the two workstreams share offline.rs, lib.rs and
online.rs, so no subset of files builds independently.
This commit is contained in:
2026-08-04 17:35:17 +02:00
parent c55ff45692
commit 62873cab3d
52 changed files with 6110 additions and 191 deletions
+126 -2
View File
@@ -634,7 +634,7 @@ episode strip.
│ │ S2E4 • 48m • ★8.1 │ │
│ │ Overview… │ │
│ │ ▓▓▓▓▓░░░░░ 32m left │ │
│ │ [▶ Play] │ │
│ │ [▶ Play] [⬇] [♡] │ │
│ └───────────────────────────────────────────┘ │
│ │
│ More Episodes │ ← 2. EPISODE STRIP
@@ -688,7 +688,7 @@ A movie has no continuation set, so cast follows the hero directly.
### 5B.4 Series detail — section order
```
Hero (poster, title, metadata, Resume SxEy / Download / Clear history)
Hero (poster, title, metadata, Resume SxEy / Download / Favorite / Clear history)
→ Crew links
→ Genre tags
→ Seasons (collapsible; only the current season expanded)
@@ -757,6 +757,130 @@ opt-in. Grids and other surfaces keep tap-to-open with no long-press.
---
## 5C. Favourites
Favouriting is a two-sided promise: the heart takes the input, and the app must
be able to give it back. This section covers both sides — where you can mark a
favourite, and where marked favourites resurface.
See [specs/favorites-browsing.md](specs/favorites-browsing.md) for the layer
assignment and wire shapes.
### 5C.1 The heart appears wherever an item does
A favourite is a property of an *item*, so the affordance follows the item
rather than living on one privileged screen. Any surface that shows a whole
item shows its heart.
| Surface | Heart position | Notes |
|---------|----------------|-------|
| Movie / Series detail hero | In the button row, after Play and Download | §5B.3, §5B.4 |
| Episode Focus View hero | Same row as Play / Download | §5B.2 |
| Album, Artist, Playlist detail | In the header button row | §5.2 |
| Media card (any grid or carousel) | Top-right overlay on the artwork | Hidden on server-only (greyed) cards |
| Mini player | Right of the track metadata | Existing behaviour, unchanged |
| Full player | Secondary controls row | §3.2 — **not yet built**, see §5C.5 |
Rules:
- **The heart never competes with the card.** On a media card it is its own
button and swallows the tap, so hearting an item never also opens or plays
it, and never triggers the §5B.5 long-press.
- **State is shown, not guessed.** A filled heart means the *server* considers
the item a favourite (or you just tapped it). An item favourited in Jellyfin
Web, on another device, or by another client renders filled here without
being touched in JellyTau.
- **Feedback is immediate.** The heart fills on tap and a toast confirms;
neither waits for the server round-trip.
### 5C.2 Three ways back to what you favourited
Favourites are not one destination — they are a lens, and the right surface
depends on whether the user is *browsing*, *deciding*, or *hunting*.
```mermaid
flowchart TB
User[User wants their favourites] --> How{Intent}
How -->|Passive: show me something| Home[Home carousels<br/>Favourite Movies / Shows / Music]
How -->|Deliberate: my whole collection| Page[Favourites page<br/>/library/favorites]
How -->|Narrowing: within this library| Filter[Favourites filter<br/>on a library page]
Home -->|See all| Page
Page --> Detail[Item detail page]
Filter --> Detail
```
**Home carousels.** Rows for favourite movies, shows and music sit below
*Recently Added*. A row with nothing in it **does not render** — a fresh install
shows no empty favourite rows. Each row ends with *See all*, landing on the
matching tab of the Favourites page.
**The Favourites page** (`/library/favorites`) is the complete collection,
scoped by tabs:
```
┌─────────────────────────────────────────────────┐
│ [←] Favourites │
│ ┌─────┬────────┬───────┬───────┐ │
│ │ All │ Movies │ Shows │ Music │ ← scope tabs │
│ └─────┴────────┴───────┴───────┘ │
│ │
│ ┌────┐┌────┐┌────┐┌────┐┌────┐ │
│ │ ♥ ││ ♥ ││ ♥ ││ ♥ ││ ♥ │ grid/list │
│ └────┘└────┘└────┘└────┘└────┘ per §5A │
└─────────────────────────────────────────────────┘
```
- Cards obey §5A in full — shape follows the media, so a mixed *All* tab reads
as posters, squares and thumbnails side by side rather than one forced shape.
- Reached from a card on the library overview (`/library`) and from *See all*
on any home favourites row.
- Sorted by name. Jellyfin does not record *when* an item was favourited, so
"recently favourited" is not offerable — see §5C.5.
- Empty state, per tab: *"Nothing favourited yet — tap the heart on anything
you like."*
**The in-library filter** is for narrowing where the user already is: a
favourites toggle in the header of the Movies, TV and Music browse pages,
filtering the current list in place. It is **session-scoped and not persisted**
a sticky filter that silently hides most of a library reads as data loss on the
next launch.
### 5C.3 Removing a favourite removes it everywhere, at once
Un-hearting an item on the Favourites page removes its card from the grid
immediately; the same item disappears from the home rows and shows an empty
heart on its detail page without a manual refresh. The reverse holds for
favouriting. There is no confirmation prompt — the action is one tap to undo.
### 5C.4 Offline
- **Marking works offline.** The heart fills, the toast confirms, and the change
is held locally.
- **It reaches the server on reconnect**, without the user returning to the
screen where they made it.
- **Browsing offline shows favourites among media on the device**, subject to
the same "Show all server media" gate as every other browse surface (§7.2) —
with the gate off, an empty Favourites tab means *nothing favourited is
downloaded*, and the page does not quietly fall back to the server catalog.
### 5C.5 Known deviations
- **The full player has no heart.** §3.2 and §3.3 list a Favorite button among
the full player's secondary controls; it was never built, and this pass does
not add it. The mini player heart above it is the only in-player affordance.
*(UR-067)*
- **No "recently favourited" sort.** Jellyfin's API does not expose a favourite
timestamp, so favourites can only be ordered by name. Recording the
timestamp locally at toggle time would order *this device's* favourites only,
which is worse than a consistent name sort.
- **Music is one tab, not three.** The Music scope mixes albums, artists and
tracks in a single grid rather than offering sub-tabs. Acceptable while
favourite counts are small; revisit if the tab becomes unscannable.
---
## 6. Search Flow
Search is **context-scoped**: what you are looking at when you start a search