Files
jellytau/docs/specs/favorites-browsing.md
T
dtourolle 62873cab3d 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.
2026-08-04 17:35:17 +02:00

22 KiB

Spec: Favourites — marking, browsing, and sync

Status: Implemented Requirements: UR-067, UR-068, UR-069 → DR-113 … DR-120; JA-033, JA-034 (allocated in requirements.md; tests UT-099 … UT-107). Note: UR-066/DR-112/IR-031 were claimed by the concurrent safe-area work while this spec was being written, so the ids here start one higher than first drafted. Existing: UR-017 → DR-021, JA-017, JA-018 (the toggle itself, already built). UX spec: ux-flows.md §3.2 (full-player favourite), §5.2 (album detail favourite), §5B.3 (movie detail hero: Play / Download / Favorite) — all three already specify favourite affordances that do not exist in the build. This spec closes those, and adds a new §5C for the Favourites browse surface. Supersedes / revises: nothing.

Summary

JellyTau can favourite an item but can never show you what you favourited. The heart is mounted in exactly one place (the mini player), no query anywhere asks Jellyfin or the local database for favourites, and favourites marked on any other client are invisible here. This spec adds the read side (a Favourites page, home carousels, an in-library filter), puts the heart on detail pages and media cards, teaches the backend to ingest server-side favourite state, and drains favourite toggles made while offline.

Background: what exists today

Verified in code, 2026-08-04. The write path is real and mostly correct; the read path does not exist at all.

  1. Toggling works, from one place only. FavoriteButton.svelte is mounted solely in MiniPlayer.svelte:381. Nothing else in src/ renders it — so the only favouritable item in the app is the one currently playing.

  2. The toggle's plumbing is sound. favorites.ts writes local first (storage_toggle_favorite, storage/mod.rs:908 — sets user_data.is_favorite + pending_sync = 1), then POST/DELETEs /Users/{uid}/FavoriteItems/{id} (online.rs:1652) only when connected. Leave this design intact.

  3. MediaItem.user_data is always None from the server. JellyfinItem has no UserData field, and to_media_item hardcodes user_data: None with the comment "User data not included in basic item responses". The only populated user_data in the app comes from series_progress.rs and the local read in offline.rs:115. Nothing ingests server favourite state, which is why the mini player has to fetch storageGetPlaybackProgress per track to colour one heart (MiniPlayer.svelte:75-93).

  4. No favourites query exists. GetItemsOptions has no favourites field; Filters=IsFavorite appears nowhere; no SQL selects is_favorite = 1; there is no /library/favorites route and no favourites carousel in home.ts.

  5. Offline favourites are silently lossy. Offline mark_favorite / unmark_favorite are no-ops (offline.rs:1620), so an offline toggle survives only as a local row with pending_sync = 1 — and nothing ever drains that flag. syncService.queueFavorite (syncService.ts:91) exists with no callers.

Motivation

Favouriting is a promise: the app takes the input and shows a "Added to favorites" toast, then discards it as far as the user can tell. Three of the UX flows already specify favourite buttons that were never built, and the one that was built (mini player) writes to a store nothing reads. Either the feature gets its read side or the heart should be removed — this spec takes the first option.

Layer assignment

Logic / responsibility Layer Why it belongs there
Favourites scope → set of Jellyfin item types Rust Domain taxonomy. Changes when Jellyfin adds/renames a type, never when the UI is redesigned. Reuses the canonical SearchScope::item_types() (types.rs:324) — the exact leak class of scoped-search-boundary.md.
Cross-library favourites query (Filters=IsFavorite, Recursive, paging, sort field) Rust Query shaping against the Jellyfin API is domain logic; the endpoint's contract changes with the server, not the UI.
Offline favourites SQL (join user_data, downloaded/catalog gating) Rust Storage + domain. Must obey the existing catalog-browse gate (DR-080) which the frontend cannot see.
Deserialising Jellyfin UserData into MediaItem.user_data Rust Provider payload mapping.
Mirroring server favourite state into the local user_data table Rust Cache/sync policy.
Conflict rule: a local row with pending_sync = 1 beats the server value Rust Business rule about which write wins; nothing to do with rendering.
Draining pending favourite toggles on reconnect Rust Sync policy, and it must run whether or not any view is mounted — a frontend-driven drain dies with the component. Consistent with reachability from real traffic (DR-055).
Which surfaces show favourites, tab order, row placement on home Frontend Pure presentation; changes only if the UI is redesigned.
Heart placement, animation, toast, haptics, empty-state copy Frontend Presentation.
In-session optimistic heart state shared across views Frontend View state, not persisted truth; the durable write already goes to Rust.

Borderline, and the tie-breaker used: which scopes appear as tabs (All / Movies / Shows / Music) is a presentation choice — the frontend picks which SearchScope values to offer. What each scope means is Rust's. The frontend sends the enum value and never names an item type in connection with favourites. Single-type pages (itemType: "Movie" on the Movies list page) stay as they are; this rule targets category taxonomy, not every mention of a type.

Design

1. Server user data reaches MediaItem (Rust, DR-113, JA-034)

Jellyfin returns UserData on /Users/{uid}/Items* responses. Add the field to JellyfinItem and map it in to_media_item, replacing the hardcoded None:

// in JellyfinItem
#[serde(alias = "UserData")]
pub user_data: Option<JellyfinUserData>,

JellyfinUserData deserialises IsFavorite, Played, PlaybackPositionTicks, PlayCount, LastPlayedDate into the existing UserData type (which already carries is_favorite and already serialises camelCase, so bindings.ts needs no new type — only regeneration). Add UserData to the Fields= list in get_items / get_item so the shape is explicit rather than relying on the default.

Wire shape, unchanged from today's UserData:

item.userData?.isFavorite // boolean | null | undefined

Delete the now-false // User data not included in basic item responses comment.

2. Local mirror of server favourites (Rust, DR-114)

Choke point: save_to_cache(parent_id, &items) in offline.rs — every server result that gets cached (including via cache_items_from_server and the background cache refresh) passes through it.

For each item carrying user_data.is_favorite, upsert:

INSERT INTO user_data (user_id, item_id, is_favorite, synced_at, pending_sync)
VALUES (?, ?, ?, ?, 0)
ON CONFLICT(user_id, item_id) DO UPDATE SET
  is_favorite = excluded.is_favorite,
  synced_at   = excluded.synced_at
WHERE user_data.pending_sync = 0;      -- local unsynced change wins

The WHERE on the conflict clause is the whole conflict rule: a toggle made offline is never overwritten by a stale server value before it has been pushed.

3. Favourites queries (Rust, DR-115, DR-116, JA-033)

(a) In-library filter — one new field on GetItemsOptions:

#[serde(skip_serializing_if = "Option::is_none")]
pub favorites_only: Option<bool>,
  • online get_items: append &Filters=IsFavorite when true.
  • offline get_items: add INNER JOIN user_data ud ON ud.item_id = i.id AND ud.user_id = ? AND ud.is_favorite = 1, composed with the existing available_items CTE so the downloads-only gate still applies.

Frontend sends { favoritesOnly: true } (camelCase — nested struct field, needs the existing #[serde(rename_all = "camelCase")] on GetItemsOptions, already present).

(b) Cross-library favourites — a new trait method, because favourites span libraries and get_items is ParentId-shaped:

/// TRACES: UR-067 | DR-115 | JA-033
async fn get_favorites(
    &self,
    scope: SearchScope,
    options: Option<GetItemsOptions>,
) -> Result<SearchResult, RepoError>;
#[tauri::command]
#[specta::specta]
pub async fn repository_get_favorites(
    manager: State<'_, RepositoryManagerWrapper>,
    handle: String,
    scope: SearchScope,
    options: Option<GetItemsOptions>,
) -> Result<SearchResult, String>

Frontend call (command name matches the Rust fn exactly; top-level params auto-camelCase; SearchScope is #[serde(rename_all = "camelCase")] so the wire values are "all" | "music" | "movies" | "tv"):

await commands.repositoryGetFavorites(handle, "movies", { limit: 100 });
  • online: /Users/{uid}/Items?Filters=IsFavorite&Recursive=true&SortBy=SortName&SortOrder=Ascending + &IncludeItemTypes=… from scope.item_types() (omit entirely on None, per that function's contract) + the standard Fields=.
  • offline: items ⨝ user_data (is_favorite = 1), type filter from the same scope.item_types(), honouring include_catalog_browse().
  • hybrid: same cache-first race as get_items, including the DR-080 rule — with the catalog-browse gate off, an empty offline result is authoritative and must not fall through to the server. Getting this wrong reproduces Defect B from offline-downloaded-only-filter.md.

The cache-first result arrives stale, and there is no second payload. On a cache hit, hybrid::get_items returns the local rows and refreshes the cache in a background task whose result the frontend never sees — fine for a library listing that changes daily, wrong for favourites, where the point is that another client just changed something. Favourites is the second read path (after search) that needs the deferred update, so the background refresh in get_favorites must emit the same favorites-changed event as §4 when the server's favourite set differs from what was returned:

favorites-changed  →  { itemIds: string[] }   // union of ids whose is_favorite flipped

Both producers (background refresh, reconnect drain) emit the identical payload, and the frontend has one handler that refreshes the favorites store. Without this, a favourite marked on another client appears in JellyTau only on the second visit to the page.

4. Draining offline toggles (Rust, DR-120)

On the offline→online transition already detected by ConnectivityMonitor, select user_data WHERE pending_sync = 1 AND is_favorite IS NOT NULL, POST or DELETE /Users/{uid}/FavoriteItems/{id} per row, then set pending_sync = 0 and synced_at. Failures leave the row pending for the next transition.

Emit a kebab-case event when anything changed, so open views refresh without polling:

favorites-changed  →  { itemIds: string[] }

syncService.queueFavorite is dead code once this lands — delete it or point it at the backend drain; do not leave two competing queues.

5. Frontend surfaces (DR-117, DR-118, DR-119)

Favourites page — new route /library/favorites:

  • Scope tabs All / Movies / Shows / Music via the existing LibraryViewTabs; each tab sends a SearchScope value, nothing more.
  • Renders through LibraryGrid + MediaCard (tracklist for Music→tracks if the tab is later split; not in this pass).
  • Entry points: a card on the library overview (library/+page.svelte) and "See all" on the home rows.
  • Empty state per tab: "Nothing favourited yet — tap the heart on anything you like."

Home carouselsfavoriteMovies, favoriteShows, favoriteMusic added to home.ts, each repositoryGetFavorites(scope, { limit: 20 }), rendered after Recently Added and only when non-empty (no empty rows on a fresh install).

In-library filter — a favourites toggle in the header of GenericMediaListPage and the Movies/TV landing pages, passing favoritesOnly: true into the existing repo.getItems(...) options. Session-scoped state; not persisted (a persisted filter that hides most of a library is a support call waiting to happen).

Hearts — mount FavoriteButton:

  • Movie / series detail hero button row, beside the download buttons (library/[id]/+page.svelte:528-553) — closes ux-flows §5B.3.
  • EpisodeFocusView, ArtistDetailView, PlaylistDetailView, album detail — closes ux-flows §5.2.
  • MediaCard artwork overlay (top-right). Suppressed on isServerOnly cards, and must not fight the existing long-press/scroll-guard handlers (MediaCard.svelte:60-70) — the heart is its own button and stops propagation.

Shared optimistic state — a small favorites store (Map<string, boolean> overlay + favorites.set(id, value)), so un-hearting an item on the Favourites page removes it from the grid and from any home row without a refetch, and a heart tapped on a card is reflected on the detail page. Resolution order:

favorites store override  ??  item.userData?.isFavorite  ??  false

toggleFavorite() updates the store alongside its existing local + server writes; the favorites-changed event refreshes it. This removes the mini player's per-track storageGetPlaybackProgress fetch once items carry userData.

6. Offline behaviour

Toggling offline keeps working exactly as now (local write + pending_sync), and now actually reaches the server on reconnect (§4). The Favourites page offline shows favourites among downloaded/cached items, subject to the existing catalog-browse gate. The offline repo's no-op mark_favorite/unmark_favorite stay no-ops — the local write plus the drain is the offline path.

Out of scope

  • Favouriting people, genres, or collections; favourite playlists are included only insofar as they fall under the Music scope.
  • Sorting by "date favourited" — Jellyfin does not expose it. Favourites sort by name.
  • A dedicated bottom-nav tab for favourites (reachable from library overview + home).
  • Building a playlist or download batch from favourites.
  • Reconciling favourites for items that no longer exist on the server.
  • Splitting the Music tab into albums/artists/tracks sub-tabs.

Acceptance criteria

  • Favouriting is possible from movie, series, episode, album, artist and playlist detail pages, and from media cards in any grid.
  • A favourite marked in another Jellyfin client shows a filled heart in JellyTau without toggling it here.
  • /library/favorites lists favourites across libraries, filtered by the All/Movies/Shows/Music tabs.
  • Home shows favourite rows for movies, shows and music, and shows no row when a category has none.
  • Movies/TV/Music list pages can be filtered to favourites only.
  • Un-hearting an item on one surface updates the others without a manual refresh.
  • A favourite toggled while offline reaches the server after reconnect (verified against a real server or a fake repository).
  • Offline, the Favourites page respects the "Show all server media" gate — with it off, an empty result stays empty and does not fall through to the server.
  • No item-type set appears in src/ in connection with favourites; the frontend sends SearchScope only.
  • bun run check and bun run test pass.
  • cargo fmt clean, cargo clippy clean, bun run test:rust passes.
  • bun run check:boundary passes (necessary, not sufficient — see CLAUDE.md).
  • New requirement-implementing code carries // TRACES: comments.
  • bindings.ts regenerated from Rust, not hand-edited.

Testing

🔴 §4 (the pending-sync drain) is a bug fix — failing test first. Write a test that toggles a favourite with the repository offline, transitions to online, and asserts the server call happened; watch it fail before writing the drain.

Rust (cd src-tauri && cargo test):

Test Covers
UT-099 A Jellyfin item JSON fixture with UserData.IsFavorite: true maps to MediaItem.user_data.is_favorite == Some(true)
UT-100 online::get_favorites builds an endpoint with Filters=IsFavorite, Recursive=true, and the scope's IncludeItemTypes; SearchScope::All omits the type filter entirely
UT-101 offline::get_favorites returns only is_favorite = 1 rows, respects the scope type filter, and returns nothing extra when the catalog-browse gate is off
UT-102 save_to_cache mirror does not overwrite a row with pending_sync = 1
UT-103 Drain pushes pending rows, clears pending_sync, sets synced_at, and leaves failed rows pending
UT-104 get_items with favorites_only: true filters both online (endpoint) and offline (SQL)
UT-107 The background refresh in hybrid::get_favorites emits favorites-changed with the flipped ids, and emits nothing when the server set matches the cache

Frontend (bun run test):

Test Covers
UT-105 favorites store override precedence: store value beats userData.isFavorite beats false
UT-106 Un-hearting removes the item from a favourites list view (pure logic extracted to a .ts module, per the TrackList/episodeStrip pattern)
IT-0xx repositoryGetFavorites param naming — add to tauriIntegration.test.ts: camelCase top-level params, scope serialised as "movies" etc.

Any component logic worth testing gets extracted into a plain .ts module first (favoritesView.ts), rather than tested through the component.

TRACES

Piece Tag
JellyfinUserData + to_media_item mapping // TRACES: UR-069 | DR-113, JA-034 | UT-099
save_to_cache user_data mirror // TRACES: UR-069 | DR-114 | UT-102
get_favorites (trait, online, offline, hybrid) + command // TRACES: UR-067 | DR-115, JA-033 | UT-100, UT-101
GetItemsOptions.favorites_only handling // TRACES: UR-067 | DR-116 | UT-104
/library/favorites route + tabs // TRACES: UR-067 | DR-117
Home favourite carousels // TRACES: UR-067 | DR-118
FavoriteButton mounts + favorites store // TRACES: UR-068 | DR-119 | UT-105, UT-106
Pending-favourite drain + favorites-changed // TRACES: UR-069 | DR-120 | UT-103

New requirement rows to add to requirements.md:

  • UR-067 — Browse favourited media across libraries (page, home rows, in-library filter).
  • UR-068 — Mark/unmark favourites from browse and detail surfaces, not only the player.
  • UR-069 — Favourite state stays consistent with the server in both directions.
  • DR-113 … DR-120 — as tabled above.
  • JA-033 — Query favourite items (Filters=IsFavorite).
  • JA-034 — Read UserData from item responses.

Implementation notes (as built)

Two things landed differently from the design above, both forced by where the AppHandle lives:

  1. The favorites-changed event is emitted from the command layer, not the repository. HybridRepository has no AppHandle — the same reason search-event is emitted from repository_search. repository_get_favorites therefore does the two-phase read itself (cache leg returned, server leg spawned) and diffs the two id sets via changed_favorite_ids, which is extracted and unit-tested (UT-107) rather than buried in the spawn.
  2. The drain hooks the existing connectivity:reconnected event via app.listen in commands/favorites.rs, rather than reaching into ConnectivityMonitor (which knows nothing about repositories). It drains through a narrow FavoriteSink trait so it can be tested against a recording double instead of a forty-method MediaRepository mock.

Also as built: DatabaseService is not object-safe (generic methods), so the drain takes Arc<RusqliteService> like the rest of the storage code, and get_items' endpoint construction was extracted to build_get_items_endpoint so the favorites_only filter could be asserted without an HTTP server.

Not built: the full-player heart. ux-flows §3.2 lists one among the full player's secondary controls and it remains unbuilt — recorded as a known deviation in ux-flows §5C.5 rather than silently dropped.

Notes for the implementer

  • A parallel Claude session may be active in this repo — git diff before "repairing" unexpected changes.
  • Do not try to reuse get_items with an empty ParentId for cross-library favourites; that endpoint is built as ?ParentId={} (online.rs:731) and an empty value is not a reliable "all libraries" request. Use get_favorites.
  • SearchScope is reused rather than a new FavoritesScope so there is one taxonomy expansion in the codebase, not two that can drift. If the name grates once favourites ship, rename the type across search + favourites in one commit — don't fork it.
  • SearchScope::All returns None from item_types() on purpose; callers must omit IncludeItemTypes entirely rather than sending a union (see the doc comment at types.rs:316).
  • Ship order that keeps each step demonstrable: §1+§2 (state becomes visible) → §5 hearts (marking becomes possible) → §3+§5 browse surfaces (finding becomes possible) → §4 drain.
  • Regenerate bindings.ts after the Rust types change; never hand-edit it.