Files
jellytau/docs/specs/offline-downloaded-only-filter.md
T
dtourolle 8b028b6b60 docs: specs, requirements, ux-flows and traceability for new features
Add specs for the account menu, downloads-as-offline-library, offline
downloaded-only filter, and scoped search (+ boundary revision). Add the
new UR/DR entries to requirements.md, update ux-flows, and regenerate the
traceability matrix.

TRACES: UR-049, UR-050, UR-052, UR-053, UR-054, UR-055, UR-056
2026-07-23 20:04:35 +02:00

11 KiB

Spec: Offline "downloaded only" filtering (issue #10)

Status: Implemented Scope: Frontend (connectivity store) + Rust (hybrid repository). No new commands, no schema changes, no UI additions. Requirements: UR-052 → DR-078, DR-079, DR-080 (see requirements.md). Tracking: issue #10 — "when offline the filter to show only downloaded media does not work."

Summary

Offline, a library page is supposed to show only media on the device, with a "Show all server media" toggle that additionally reveals the cached server catalog greyed out (queueable for download on reconnect). In practice the toggle does not gate the listing — every server item still appears. This spec fixes that with two independent changes; either one alone leaves the bug visible.

Background: what already exists

Verified in code. The feature is built and mostly correct — this is a two-point repair, not new infrastructure. Do not rebuild the toggle, the command, or the SQL gate.

  1. The SQL gate works and is unit-tested. offline.rsget_items appends the synced-catalog UNION branch only when include_catalog_browse() is true; with it false, only downloaded/local rows return. Guarded by test_get_items_toggle_gates_synced_catalog (UT-067). Do not touch the query.

  2. The toggle → backend path is wired. The showServerCatalog store and the set_show_server_catalog command (catalog.rs) drive the process-wide INCLUDE_CATALOG_BROWSE flag. pushCatalogVisibility in offlineCatalog.ts computes include = connected || showCatalog and pushes it on every change.

  3. Home-screen queries are already downloads-only. get_latest_items, get_resume_items, get_recently_played_audio, get_resume_movies all INNER JOIN downloads ... status = 'completed'. They are unaffected — leave them.

  4. MediaCard already greys and queues. MediaCard.svelteisServerOnly renders the greyed, inert card with a queue button; the queued row heals its stream_url on reconnect via the offlineCatalog service. Leave it.

The two defects

Defect A — offline is never actually entered (DR-079)

pushCatalogVisibility keys off isConnected, but connectivity.ts derives:

isConnected = isOnline && isServerReachable   // isOnline = navigator.onLine

navigator.onLine is documented in that same file as advisory only — the Rust ConnectivityMonitor is the source of truth (principle: reachability from real traffic, DR-055). When the server is unreachable but the device link is up (server down, wrong LAN, VPN dropped), isOnline stays true, so isConnected stays true, so include stays true, so the backend keeps returning the full catalog. The user is "offline" in every meaningful sense but the toggle never gets a chance to gate anything.

This is the primary cause: it explains why the filter looks dead rather than merely inverted — the gate never closes.

Defect B — an intentionally empty result falls through to the server (DR-080)

With the gate off and nothing downloaded in a library, offline get_items correctly returns few or zero rows. But hybrid.rs treats a cache result as a hit only if data.has_content(). An empty offline result is indistinguishable from a cache miss, so HybridRepository::get_items (and parallel_race, used by ~10 other reads) falls through to the server and returns the full server list — re-defeating the filter even after Defect A is fixed.

Design

Fix A: isConnected follows backend reachability alone (DR-079)

In connectivity.ts, redefine the derived store:

export const isConnected = derived(
  connectivity,
  ($c) => $c.isServerReachable
);

navigator.onLine stays wired to what it is good for — a trigger for an immediate recheck (online/offline listeners already call checkServerReachable()); it must no longer be a term in the offline decision. Leave isOnline on the state object and the listeners intact.

Consider whether the optimistic isServerReachable: true startup default (connectivity.ts) should hold until the first real check resolves. Keep it — flipping the app to "offline" on launch is a worse regression than a brief full-catalog flash before the first probe. Note the choice in a comment.

Blast radius — this is the reason this is a spec, not a patch. isConnected is consumed beyond this feature (banners, MediaCard, mini-player gating, anything importing it). Enumerate consumers first:

grep -rn "isConnected" src/ | grep -v node_modules

For each, confirm "server unreachable" (not "device link down") is the correct trigger. It almost always is — that is the whole point of the reachability model — but verify rather than assume, and call out anything that genuinely wanted the device link in the PR description.

Fix B: an empty offline result is authoritative when the gate is off (DR-080)

The backend must distinguish "cache is cold, go ask the server" from "user asked for downloads only and there are none here." The gate flag already encodes intent — reuse it.

Add a getter beside the existing setter in offline.rs:

pub fn include_catalog_browse() -> bool { /* pub, already exists privately */ }

In hybrid.rs get_items: when !include_catalog_browse(), treat the offline result as authoritative and return it as-is even when empty — do not spawn/await the server fallback for this call. When the flag is on (online fast-path, or offline with the toggle on), behaviour is unchanged: empty cache still falls through to the server.

Keep it surgical:

  • Scope the change to get_items. The gate is a get_items concept; do not thread it into parallel_race or the other readers, which have no catalog gate and legitimately want the server on an empty cache.
  • Preserve the online path exactly: with the flag on (its default, and always so while reachable) the method behaves as it does today, including the background cache refresh on a hit.
  • The flag is process-global Relaxed; it is set from the frontend before the query. That ordering already holds for the SQL gate — no new synchronization.

Why both

Fix A closes the gate; Fix B stops the hybrid from re-opening it. A alone: with downloads present the list still gets padded by the server fallback whenever a library's cache is thin. B alone: the gate never closes because isConnected never goes false on a live link. Ship them together.

Out of scope

  • The SQL gate, the toggle, the command, INCLUDE_CATALOG_BROWSE — all correct.
  • MediaCard greying / queue-on-reconnect — correct.
  • Home-screen and resume queries — already downloads-only.
  • The Rust ConnectivityMonitor reachability logic itself — unchanged; this spec only stops the frontend from diluting its verdict with navigator.onLine.
  • Any new IPC command, DB column, or settings entry.
  • Making the "Show all server media" toggle reachable from Settings (that is a UX-placement question, tracked separately under UR-051's toggle note).

Acceptance criteria

  • [~] With the server unreachable on a live device link, a library page lists only downloaded media when the toggle is off (IT-016 — pending e2e; unit coverage via UT-069 + gate tests).
  • Turning the toggle on reveals the greyed-out cached catalog; turning it off hides it again — without leaving/re-entering the page (SQL gate + toggle wiring unchanged; UT-068 confirms the flag is pushed on toggle change).
  • A library with downloads and a thin cache does not get padded with non-downloaded server items when offline with the toggle off (Defect B — UT-070: gate off + empty offline result returned as-is, server not queried).
  • isConnected is false whenever the server is unreachable, regardless of navigator.onLine; true for a reachable server even if the browser reports offline (UT-069).
  • Every existing isConnected consumer still behaves correctly (banner in +layout.svelte, MediaCard, favorites.ts server-write skip — all want "server unreachable", which is the new semantics; CastButton's local isConnected is unrelated). Full frontend suite (616 tests) green.
  • Online behaviour is unchanged: with the flag on (its default, always so while reachable) get_items keeps the offline fast-path and background refresh (UT-067 + gate-on fall-through test).
  • [~] A download queued from a greyed offline card resolves and starts on reconnect (IT-017 — regression check, no code change; offlineCatalog resume path untouched).
  • bun run check, bun run test, and bun run test:rust pass; cd src-tauri && cargo fmt && cargo clippy clean (no new warnings in the touched files).

Testing

Rust (offline.rs / hybrid.rs test modules):

  • UT-070 — hybrid get_items with the gate off returns an empty offline result as-is and does not query the server. Assert via a mock online repo whose get_items bumps a call counter that must stay at zero.
  • Gate on + empty cache still falls through to the server (guard the online path).
  • UT-067 (test_get_items_toggle_gates_synced_catalog) must still pass untouched.

Frontend (vitest, src/lib/**/*.test.ts):

  • UT-069isConnected follows isServerReachable alone: false when unreachable with navigator.onLine === true; true when reachable with navigator.onLine === false.
  • UT-068pushCatalogVisibility resolves serverReachable || showCatalog and pushes to the backend on a change of either input (extend the existing offlineCatalog tests).

Integration (IT-016, IT-017) are documented as pending in requirements.md; wire them if the e2e harness can simulate an unreachable-server-on-live-link state, otherwise leave them pending with a note.

New/changed requirement code keeps its TRACES: comments — see CLAUDE.md. The affected files already carry tags: connectivity.ts (… | DR-079), hybrid.rs (… | DR-080), offline.rs (… | DR-078). Update the getter's tag when you expose it.

Notes for the implementer

  • Read docs/architecture/07-connectivity.md before Fix A — it is the canonical statement of the reachability model this fix restores fidelity to.
  • Fix B relies on the frontend having pushed the flag before the query runs; that ordering already holds for the SQL gate today. No new locking.
  • Another session is active in this repo (WiFi-only downloads, account menu landed alongside this work). Check git diff before "repairing" unexpected changes, and expect requirement IDs around UR-052 / DR-078 to be adjacent to other new rows.