# 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](../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](../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](../../src/lib/components/FavoriteButton.svelte) is mounted solely in [MiniPlayer.svelte:381](../../src/lib/components/player/MiniPlayer.svelte#L381). 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](../../src/lib/services/favorites.ts) writes local first (`storage_toggle_favorite`, [storage/mod.rs:908](../../src-tauri/src/commands/storage/mod.rs#L908) — sets `user_data.is_favorite` + `pending_sync = 1`), then POST/DELETEs `/Users/{uid}/FavoriteItems/{id}` ([online.rs:1652](../../src-tauri/src/repository/online.rs#L1652)) 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`](../../src-tauri/src/repository/online.rs#L656) with the comment *"User data not included in basic item responses"*. The only populated `user_data` in the app comes from [series_progress.rs](../../src-tauri/src/repository/series_progress.rs#L244) and the local read in [offline.rs:115](../../src-tauri/src/repository/offline.rs#L115). **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](../../src/lib/components/player/MiniPlayer.svelte#L75-L93)). 4. **No favourites query exists.** [`GetItemsOptions`](../../src-tauri/src/repository/types.rs#L278) 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](../../src/lib/stores/home.ts). 5. **Offline favourites are silently lossy.** Offline `mark_favorite` / `unmark_favorite` are no-ops ([offline.rs:1620](../../src-tauri/src/repository/offline.rs#L1620)), 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](../../src/lib/services/syncService.ts#L91)) 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](../../src-tauri/src/repository/types.rs#L324)) — the exact leak class of [scoped-search-boundary.md](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`: ```rust // in JellyfinItem #[serde(alias = "UserData")] pub user_data: Option, ``` `JellyfinUserData` deserialises `IsFavorite`, `Played`, `PlaybackPositionTicks`, `PlayCount`, `LastPlayedDate` into the existing [`UserData`](../../src-tauri/src/repository/types.rs#L44) 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`: ```ts 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](../../src-tauri/src/repository/offline.rs) — every server result that gets cached (including via [`cache_items_from_server`](../../src-tauri/src/repository/hybrid.rs#L124) and the background cache refresh) passes through it. For each item carrying `user_data.is_favorite`, upsert: ```sql 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`: ```rust #[serde(skip_serializing_if = "Option::is_none")] pub favorites_only: Option, ``` - 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: ```rust /// TRACES: UR-067 | DR-115 | JA-033 async fn get_favorites( &self, scope: SearchScope, options: Option, ) -> Result; ``` ```rust #[tauri::command] #[specta::specta] pub async fn repository_get_favorites( manager: State<'_, RepositoryManagerWrapper>, handle: String, scope: SearchScope, options: Option, ) -> Result ``` 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"`): ```ts 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](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](../../src/routes/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 carousels** — `favoriteMovies`, `favoriteShows`, `favoriteMusic` added to [home.ts](../../src/lib/stores/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](../../src/lib/components/library/GenericMediaListPage.svelte) 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](../../src/routes/library/%5Bid%5D/+page.svelte#L528-L553)) — 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](../../src/lib/components/library/MediaCard.svelte#L60-L70)) — the heart is its own button and stops propagation. **Shared optimistic state** — a small `favorites` store (`Map` 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](../../src/lib/utils/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](../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` 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](../../src-tauri/src/repository/online.rs#L731)) 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](../../src-tauri/src/repository/types.rs#L316)). - 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.