# 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](../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.rs](../../src-tauri/src/repository/offline.rs) — `get_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](../../src-tauri/src/commands/catalog.rs)) drive the process-wide `INCLUDE_CATALOG_BROWSE` flag. `pushCatalogVisibility` in [offlineCatalog.ts](../../src/lib/services/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.svelte](../../src/lib/components/library/MediaCard.svelte) — `isServerOnly` 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](../../src/lib/stores/connectivity.ts) derives: ```ts 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](../../src-tauri/src/repository/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](../../src/lib/stores/connectivity.ts), redefine the derived store: ```ts 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](../../src/lib/stores/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](../../src-tauri/src/repository/offline.rs): ```rust pub fn include_catalog_browse() -> bool { /* pub, already exists privately */ } ``` In [hybrid.rs](../../src-tauri/src/repository/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). - [x] 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). - [x] 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). - [x] `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). - [x] 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. - [x] 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). - [x] `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](../../src-tauri/src/repository/offline.rs) / [hybrid.rs](../../src-tauri/src/repository/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-069** — `isConnected` follows `isServerReachable` alone: false when unreachable with `navigator.onLine === true`; true when reachable with `navigator.onLine === false`. - **UT-068** — `pushCatalogVisibility` 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](../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](../../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](../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.