Files
jellytau/docs/specs/favorites-browsing.md
T
dtourolle 46a5219f8e docs: repair broken relative links
- traces-quick-ref.md: the four "where to find requirements" links pointed
  at README.md, but those anchors (#1-user-requirements and friends) live
  in requirements.md; the "See Also" links were written as if the file sat
  at the repo root (docs/traceability.md from inside docs/); and the
  extraction-script link needed ../ to reach scripts/README.md.
- release-checklist.md: the release-notes template linked ../../CHANGELOG.md
  (one level too deep) and ../../issues + ../../discussions, which are
  GitHub relative-URL idioms. The canonical remote is Gitea, whose release
  bodies render the template outside any repo path, so these are now
  absolute gitea.tourolle.paris URLs. Gitea has no discussions, so that
  link is dropped rather than pointed somewhere it does not exist.
- specs/favorites-browsing.md: linked the deleted
  src/lib/utils/tauriIntegration.test.ts.
2026-08-20 19:30:48 +02:00

23 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 the IPC param-naming suite under src/lib/utils/ (tauriIntegration.test.ts no longer exists — see the current camelCase guards in src/lib/stores/): 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.

  3. The command falls back to HybridRepository::get_favorites when nothing is cached. The two-phase read alone paints "Nothing favourited yet" on a fresh install and corrects it a server round trip later, which is a wrong answer shown to the user. An empty cache leg therefore defers to the repository's own cache-first-then-server read. That read was also fixed to save through on a server hit — without it the page re-queried the server on every visit and the DR-114 mirror was never filled by this path.

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.