The hybrid favourites read went straight to the online repository on a cache miss and dropped the result on the floor. Every other read path persists what it fetches, so this one made the favourites page re-query the server on every visit — and left the offline mirror (DR-114) empty on a fresh install, since this is the path that fills it. It now goes through get_favorites_server_only, which saves through on the way back. The command had a matching hole: with nothing cached it returned the empty result, painting "Nothing favourited yet" at a viewer whose favourites were simply marked on another client. It now asks the repository for a real answer instead of an empty state it would correct a round trip later. TRACES: UR-067 | DR-115
404 lines
23 KiB
Markdown
404 lines
23 KiB
Markdown
# 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>,
|
|
```
|
|
|
|
`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<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:
|
|
|
|
```rust
|
|
/// TRACES: UR-067 | DR-115 | JA-033
|
|
async fn get_favorites(
|
|
&self,
|
|
scope: SearchScope,
|
|
options: Option<GetItemsOptions>,
|
|
) -> Result<SearchResult, RepoError>;
|
|
```
|
|
|
|
```rust
|
|
#[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"`):
|
|
|
|
```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<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](../../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.
|
|
|
|
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](../../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.
|