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.
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.
-
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. -
The toggle's plumbing is sound. favorites.ts writes local first (
storage_toggle_favorite, storage/mod.rs:908 — setsuser_data.is_favorite+pending_sync = 1), then POST/DELETEs/Users/{uid}/FavoriteItems/{id}(online.rs:1652) only when connected. Leave this design intact. -
MediaItem.user_datais alwaysNonefrom the server.JellyfinItemhas noUserDatafield, andto_media_itemhardcodesuser_data: Nonewith the comment "User data not included in basic item responses". The only populateduser_datain 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 fetchstorageGetPlaybackProgressper track to colour one heart (MiniPlayer.svelte:75-93). -
No favourites query exists.
GetItemsOptionshas no favourites field;Filters=IsFavoriteappears nowhere; no SQL selectsis_favorite = 1; there is no/library/favoritesroute and no favourites carousel in home.ts. -
Offline favourites are silently lossy. Offline
mark_favorite/unmark_favoriteare no-ops (offline.rs:1620), so an offline toggle survives only as a local row withpending_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=IsFavoritewhen true. - offline
get_items: addINNER JOIN user_data ud ON ud.item_id = i.id AND ud.user_id = ? AND ud.is_favorite = 1, composed with the existingavailable_itemsCTE 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=…fromscope.item_types()(omit entirely onNone, per that function's contract) + the standardFields=. - offline:
items ⨝ user_data (is_favorite = 1), type filter from the samescope.item_types(), honouringinclude_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 aSearchScopevalue, 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 carousels — favoriteMovies, 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.MediaCardartwork overlay (top-right). Suppressed onisServerOnlycards, 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/favoriteslists 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 sendsSearchScopeonly. bun run checkandbun run testpass.cargo fmtclean,cargo clippyclean,bun run test:rustpasses.bun run check:boundarypasses (necessary, not sufficient — see CLAUDE.md).- New requirement-implementing code carries
// TRACES:comments. bindings.tsregenerated 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
UserDatafrom item responses.
Implementation notes (as built)
Two things landed differently from the design above, both forced by where the
AppHandle lives:
- The
favorites-changedevent is emitted from the command layer, not the repository.HybridRepositoryhas noAppHandle— the same reasonsearch-eventis emitted fromrepository_search.repository_get_favoritestherefore does the two-phase read itself (cache leg returned, server leg spawned) and diffs the two id sets viachanged_favorite_ids, which is extracted and unit-tested (UT-107) rather than buried in the spawn. - The drain hooks the existing
connectivity:reconnectedevent viaapp.listenincommands/favorites.rs, rather than reaching intoConnectivityMonitor(which knows nothing about repositories). It drains through a narrowFavoriteSinktrait so it can be tested against a recording double instead of a forty-methodMediaRepositorymock.
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 diffbefore "repairing" unexpected changes. - Do not try to reuse
get_itemswith an emptyParentIdfor cross-library favourites; that endpoint is built as?ParentId={}(online.rs:731) and an empty value is not a reliable "all libraries" request. Useget_favorites. SearchScopeis reused rather than a newFavoritesScopeso 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::AllreturnsNonefromitem_types()on purpose; callers must omitIncludeItemTypesentirely 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.tsafter the Rust types change; never hand-edit it.