# Spec: Downloads as a browsable offline library **Status:** Draft — ready to implement **Scope:** Frontend-heavy; one new repository-client browse path. Minimal Rust. **Requirements:** UR-055 → DR-081, DR-082, DR-083, DR-084; UR-056 → DR-085 (see [requirements.md](../requirements.md)). **UX spec:** [ux-flows.md §7.2–7.7](../ux-flows.md). ## Summary Replace the flat Active/Completed download list with two views under `/downloads`: 1. **Downloaded** (default) — the library, filtered to what's on the device, using the *same* browse screens as online (grids, cards, detail pages). 2. **Transfers** — the existing progress-row list, demoted to a secondary tab, showing only in-flight transfers. Plus per-item disk usage (UR-056) shown in familiar units on cards, detail pages, a device total, and the remove confirmation. ## Motivation A user who downloaded three seasons and two albums sees ~70 individual transfer rows today, with no grouping and no reuse of the library UI. "What do I have offline" and "what is downloading" are different questions crammed into one flat list. Browsing offline should feel exactly like browsing online. ## Background: verified current state 1. **The offline repository is already a browsable tree.** [offline.rs](../../src-tauri/src/repository/offline.rs) — `get_items` returns downloaded items **plus** containers (MusicAlbum, Series, Season) that have at least one downloaded child. `get_libraries`, `get_item`, and `search` all filter to downloaded content via CTEs. This is the data source for Downloaded; **do not build a new query layer.** 2. **The client cannot reach it independently.** [repository-client.ts](../../src/lib/api/repository-client.ts) `getItems` → `repositoryGetItems` always goes through the **hybrid** repository ([hybrid.rs](../../src-tauri/src/repository/hybrid.rs)), which merges cache and server. There is no "offline only" browse path exposed. This is the one real backend gap (DR-082). 3. **Downloads page is a flat two-tab list.** [downloads/+page.svelte](../../src/routes/downloads/+page.svelte) — Active / Completed tabs, one `DownloadItem` row per transfer, no browsing. 4. **Library browse components are reusable as-is.** `LibraryGrid`, `MediaCard`, the `/library/[id]` detail page (§5A/§5B) render whatever items they are given. Downloaded browse is those components with an offline-scoped source. 5. **A related fallthrough bug is already tracked** (DR-080, another session): `HybridRepository::get_items` treats an empty offline result as a cache miss and falls through to the server. The offline-only browse path (DR-082) must **not** share that behaviour — an empty result there is authoritative "nothing downloaded here." 6. **Concurrency, the 3-download cap, and the auto-pump are backend concerns.** Do not surface them as manual controls; do not loop `startDownload` from the frontend (see [CLAUDE.md](../../CLAUDE.md) gotchas). ## Design ### View split (DR-081) `/downloads` renders a **Downloaded** / **Transfers** switch. Downloaded is the default. Transfers shows a count/badge only while transfers are active. Initiating downloads stays on item/album/series detail pages (§7.1) — this page does not start downloads. ### Offline-scoped browse source (DR-082, DR-083) Add an explicit offline-only browse path so Downloaded never merges server results and never depends on reachability. Two viable shapes — pick per the codebase, do not do both: - **(a)** A dedicated command (e.g. `repository_get_downloaded_items` / `_libraries`) that calls the offline repository directly, with a matching client method; or - **(b)** An explicit `offlineOnly`/scope flag on the existing get-items path that bypasses the hybrid merge and the empty→fallthrough behaviour. Either way: an empty result is authoritative (do **not** reuse the DR-080 fallthrough), and the path is available while the server is reachable (a user online still wants to browse their downloads). Downloaded then reuses `LibraryGrid` / `MediaCard` / the detail page against this source. Omit libraries and containers with no downloaded content. Badge partially- vs fully-downloaded containers. Play uses the local file; remove is available at item / album / season / series level and removes a container from the browse when its last downloaded child goes. ### Transfers view (DR-084) The existing list, filtered to in-flight rows only: downloading (with progress), queued, paused, failed, waiting-for-WiFi (the DR-074 state from the other session). Controls: Pause / Resume / Cancel / Retry. Completed transfers leave this view — they appear in Downloaded. Empty state points at the library. ### Disk usage (DR-085, UR-056) - **Source the bytes from the download manager** — it writes the files and can stat them. Aggregate to album/season/series subtotals and a device total. This is display + aggregation, **not** new tracking. - **Format once, consistently.** One shared formatter, human units, 2–3 significant figures (`1.2 GB`, `340 MB`). Binary vs decimal — pick one and use it everywhere. - **Surface it in familiar places:** a secondary size label on the card and detail page; a device total at the top of Downloaded (`3.4 GB · 12 items`) that reconciles with the listed sum; a reclaim figure in the remove confirmation ("frees 1.2 GB"). No separate "storage report" screen. - Sort/filter by size is a nice-to-have, not required for v1. ## Out of scope - Changing download initiation, the 3-concurrent cap, or the auto-pump. - The catalog-browse / show-server-catalog toggle (UR-052, another session) — that governs the *online offline-fallback* library; this is the dedicated Downloads surface. They should be consistent but are separate work. - Fixing the DR-080 hybrid fallthrough bug (owned elsewhere) — just don't depend on that behaviour here. ## Acceptance criteria - [ ] `/downloads` opens on Downloaded and can switch to Transfers. - [ ] Downloaded lists only libraries/containers with downloaded content, using the same grids/cards/detail pages as online browsing. - [ ] Browsing Downloaded never shows non-downloaded server items, online or off. - [ ] An empty Downloaded result reads as "nothing downloaded," never falls through to the server. - [ ] Play from Downloaded plays the local file. - [ ] Remove works at item/album/season/series level and updates the browse. - [ ] Transfers shows only in-flight rows with working controls; finished transfers move to Downloaded. - [ ] Each downloaded item/container shows its on-disk size; a device total is shown and reconciles with the sum; remove states the reclaim amount. - [ ] `bun run check`, `bun run test`, and (if Rust touched) `cargo test` + `cargo clippy` pass. ## Testing - Repository client: the offline-only browse path returns downloaded content and its containers, and an empty result does **not** trigger server fallthrough. - Downloaded view: libraries/containers with no downloads are omitted; partial/full container badging. - Transfers: only in-flight statuses render; a completed transfer disappears. - Size formatter: rounding and unit thresholds; subtotal aggregation; device total reconciles with listed items. - If a Rust command is added, add the tauri IPC param-naming coverage per [CLAUDE.md](../../CLAUDE.md) (camelCase rule). New requirement-implementing code needs `TRACES:` comments. Suggested tags: view split `UR-055 | DR-081`; offline browse path `UR-055 | DR-082, DR-083`; Transfers `UR-055 | DR-084`; size display `UR-056 | DR-085`. ## Notes for the implementer - Read [ux-flows.md §7.2–7.7](../ux-flows.md) first — behavioural spec; this is the implementation plan. - The offline repository already does the hard part. The main work is a clean offline-only client path and reusing the library components — resist rebuilding browse UI. - Another session is active in downloads/offline/connectivity code (DR-074, DR-078–080). Coordinate on [downloads/+page.svelte](../../src/routes/downloads/+page.svelte) and the repository layer; check `git diff` before repairing unexpected changes.