# 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.
