From f25deba824c57d99e1ec522a2782d2a91078435f Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Thu, 23 Jul 2026 20:02:55 +0200 Subject: [PATCH] feat(downloads): browsable downloaded library with on-disk usage Replace the flat download list with a Downloaded browse surface that reuses the online grids/cards/detail pages, filtered to on-device media, plus a demoted Transfers tab. Add repository browse commands (getDownloadedLibraries/Items, disk usage) with offline/hybrid implementations, a downloadedCatalog service, formatBytes helper, and per-item/device disk-usage labels on cards and grids. Regenerated bindings. Also carries the inseparable UR-052 offline-filter hunks in offline.rs/hybrid.rs. TRACES: UR-055 | DR-081, DR-082, DR-083, DR-084; UR-056 | DR-085 --- src-tauri/src/commands/repository.rs | 50 ++ src-tauri/src/repository/hybrid.rs | 127 +++++ src-tauri/src/repository/offline.rs | 463 +++++++++++++++++- src-tauri/src/repository/types.rs | 22 + src/lib/api/bindings.ts | 130 +++++ src/lib/api/repository-client.test.ts | 40 ++ src/lib/api/repository-client.ts | 27 +- .../downloads/DownloadedBrowse.svelte | 189 +++++++ src/lib/components/library/LibraryGrid.svelte | 18 +- src/lib/components/library/MediaCard.svelte | 59 ++- src/lib/services/downloadedCatalog.ts | 116 +++++ src/lib/utils/formatBytes.test.ts | 43 ++ src/lib/utils/formatBytes.ts | 50 ++ src/routes/downloads/+page.svelte | 308 ++++-------- 14 files changed, 1418 insertions(+), 224 deletions(-) create mode 100644 src/lib/components/downloads/DownloadedBrowse.svelte create mode 100644 src/lib/services/downloadedCatalog.ts create mode 100644 src/lib/utils/formatBytes.test.ts create mode 100644 src/lib/utils/formatBytes.ts diff --git a/src-tauri/src/commands/repository.rs b/src-tauri/src/commands/repository.rs index 9de68161..63d11cbd 100644 --- a/src-tauri/src/commands/repository.rs +++ b/src-tauri/src/commands/repository.rs @@ -195,6 +195,56 @@ pub async fn repository_get_item( .map_err(|e| format!("{:?}", e)) } +/// Downloaded-only browse: libraries that contain downloaded content. +/// +/// Backs the Downloads "Downloaded" surface. Never merges server results and is +/// authoritative — an empty list means nothing is downloaded. +/// +/// TRACES: UR-055 | DR-082 +#[tauri::command] +#[specta::specta] +pub async fn repository_get_downloaded_libraries( + manager: State<'_, RepositoryManagerWrapper>, + handle: String, +) -> Result, String> { + let repo = manager.0.get(&handle).ok_or("Repository not found")?; + repo.get_downloaded_libraries() + .await + .map_err(|e| format!("{:?}", e)) +} + +/// Downloaded-only browse: items under a container that are on the device. +/// +/// TRACES: UR-055 | DR-082, DR-083 +#[tauri::command] +#[specta::specta] +pub async fn repository_get_downloaded_items( + manager: State<'_, RepositoryManagerWrapper>, + handle: String, + parent_id: String, + options: Option, +) -> Result { + let repo = manager.0.get(&handle).ok_or("Repository not found")?; + repo.get_downloaded_items(&parent_id, options) + .await + .map_err(|e| format!("{:?}", e)) +} + +/// On-disk usage of downloaded content (device total, per-item/container bytes). +/// +/// TRACES: UR-056 | DR-085 +#[tauri::command] +#[specta::specta] +pub async fn repository_get_download_disk_usage( + manager: State<'_, RepositoryManagerWrapper>, + handle: String, +) -> Result { + let repo = manager.0.get(&handle).ok_or("Repository not found")?; + repo.get_download_disk_usage() + .await + .map_err(|e| format!("{:?}", e)) +} + /// Query the optional JRay plugin for the actors on screen at time `t` /// (seconds) in an item. Returns an empty list when JRay isn't installed or /// has no data for the item, so the caller can render nothing without error. diff --git a/src-tauri/src/repository/hybrid.rs b/src-tauri/src/repository/hybrid.rs index a31d9e42..2dc8bae8 100644 --- a/src-tauri/src/repository/hybrid.rs +++ b/src-tauri/src/repository/hybrid.rs @@ -4,6 +4,8 @@ // @req: IR-013 - SQLite integration for local database // @req: DR-012 - Local database for media metadata cache // @req: DR-013 - Repository pattern for online/offline data access +// +// TRACES: UR-002, UR-052 | IR-013 | DR-012, DR-013, DR-080 #[cfg(test)] use crate::utils::lock::MutexSafe; @@ -131,6 +133,36 @@ impl HybridRepository { Ok(result.items) } + /// Browse downloaded content only — the dedicated Downloads surface. + /// + /// Bypasses the cache/server merge entirely and reads the offline repository + /// directly, so an empty result is authoritative ("nothing downloaded here") + /// and never falls through to the server (DR-080). Available online too — a + /// user who is reachable still wants to browse what's on the device. + /// + /// TRACES: UR-055 | DR-082, DR-083 + pub async fn get_downloaded_items( + &self, + parent_id: &str, + options: Option, + ) -> Result { + self.offline.get_downloaded_items(parent_id, options).await + } + + /// Libraries that contain downloaded content (offline-only, authoritative). + /// + /// TRACES: UR-055 | DR-082 + pub async fn get_downloaded_libraries(&self) -> Result, RepoError> { + self.offline.get_downloaded_libraries().await + } + + /// On-disk usage of downloaded content, for the disk-usage display. + /// + /// TRACES: UR-056 | DR-085 + pub async fn get_download_disk_usage(&self) -> Result { + self.offline.get_download_disk_usage().await + } + /// Search only the live Jellyfin server (full library). pub async fn search_server_only( &self, @@ -316,6 +348,26 @@ impl MediaRepository for HybridRepository { .cache_with_timeout(async move { offline.get_items(&parent_id, opts_clone).await }) .await; + // Downloads-only gate: when the "Show all server media" toggle is off + // (offline), an empty offline result is authoritative — the user asked + // for downloaded media only and this library has none. Return it as-is + // rather than falling through to the server, which would re-pad the page + // with the full catalog and re-defeat the filter (DR-080). When the flag + // is on (the default, and always so while reachable) behaviour below is + // unchanged, including the background cache refresh on a hit. + if !crate::repository::offline::include_catalog_browse() { + if let Ok(data) = &cache_result { + debug!( + "[HybridRepo] Downloads-only gate: returning offline result ({} items) as authoritative for parent {}", + data.items.len(), + &parent_id_for_save[..8.min(parent_id_for_save.len())] + ); + // Abort the in-flight server request; we won't use it. + server_handle.abort(); + return Ok(data.clone()); + } + } + // Cache hit: return immediately, update cache in background if let Ok(data) = &cache_result { if data.has_content() { @@ -1457,6 +1509,81 @@ mod tests { Ok(result) } + + /// Test version mirroring the real `HybridRepository::get_items` + /// downloads-only gate: when `include_catalog_browse()` is false, the + /// offline result is authoritative and the server is NOT queried, even + /// when the cache is empty. Otherwise falls through to the normal + /// cache-first logic in `get_items`. + async fn get_items_gated(&self, parent_id: &str) -> Result { + if !crate::repository::offline::include_catalog_browse() { + let items = self.offline.get_items(parent_id, None).await?; + // Authoritative: return as-is, never touch the server. + return Ok(items); + } + self.get_items(parent_id).await + } + } + + /// Serialize tests that mutate the process-global INCLUDE_CATALOG_BROWSE + /// flag, and always restore it to the default (true) afterwards. + static GATE_TEST_LOCK: Mutex<()> = Mutex::new(()); + + /// UT-070: with the downloads-only gate off, an empty offline result is + /// returned as-is and the server is NOT queried. + /// + /// @req-test: UR-052 - Offline "downloaded only" filtering + /// @req-test: DR-080 - Empty offline result is authoritative when gate off + #[tokio::test] + async fn test_get_items_gate_off_empty_does_not_query_server() { + let _guard = GATE_TEST_LOCK.lock_safe(); + crate::repository::offline::set_include_catalog_browse(false); + + // Server has items, cache is empty. Gate off ⇒ the server must be ignored. + let repo = TestHybridRepo::new(vec![ + create_test_item("s-1", "Server 1"), + create_test_item("s-2", "Server 2"), + ]); + + let result = repo.get_items_gated("parent-123").await.unwrap(); + + assert_eq!( + result.items.len(), + 0, + "empty offline result is authoritative when the gate is off" + ); + assert_eq!( + repo.online.get_query_count(), + 0, + "server must NOT be queried when the gate is off" + ); + + crate::repository::offline::set_include_catalog_browse(true); + } + + /// Guard the online path: with the gate ON and an empty cache, get_items + /// still falls through to the server (unchanged behaviour). + /// + /// @req-test: UR-052 - Offline "downloaded only" filtering + /// @req-test: DR-080 - Gate on ⇒ empty cache still queries the server + #[tokio::test] + async fn test_get_items_gate_on_empty_falls_through_to_server() { + let _guard = GATE_TEST_LOCK.lock_safe(); + crate::repository::offline::set_include_catalog_browse(true); + + let repo = TestHybridRepo::new(vec![ + create_test_item("s-1", "Server 1"), + create_test_item("s-2", "Server 2"), + ]); + + let result = repo.get_items_gated("parent-123").await.unwrap(); + + assert_eq!(result.items.len(), 2, "server result used on empty cache"); + assert_eq!( + repo.online.get_query_count(), + 1, + "server IS queried when the gate is on and the cache is empty" + ); } /// Test cache miss saves server data to cache for next time diff --git a/src-tauri/src/repository/offline.rs b/src-tauri/src/repository/offline.rs index 4fc57e47..63e3c626 100644 --- a/src-tauri/src/repository/offline.rs +++ b/src-tauri/src/repository/offline.rs @@ -1,4 +1,6 @@ // Offline repository - queries SQLite database for cached data +// +// TRACES: UR-002, UR-052 | DR-012, DR-013, DR-078 use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -18,6 +20,8 @@ use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteSer /// the full greyed-out catalog. See `set_include_catalog_browse` and the /// `showServerCatalog` UI flag. Fixes the bug where offline library pages showed /// every server item regardless of the toggle. +/// +/// TRACES: UR-052 | DR-078 static INCLUDE_CATALOG_BROWSE: AtomicBool = AtomicBool::new(true); /// Set whether offline `get_items` includes non-downloaded (synced-only) catalog @@ -28,7 +32,16 @@ pub fn set_include_catalog_browse(include: bool) { INCLUDE_CATALOG_BROWSE.store(include, Ordering::Relaxed); } -fn include_catalog_browse() -> bool { +/// Whether offline `get_items` currently includes the synced-but-not-downloaded +/// catalog (the greyed-out browse view). Mirrors `set_include_catalog_browse`. +/// +/// Exposed so the hybrid repo can tell "cache is cold, ask the server" from +/// "user asked for downloads only and there are none here": when this is false, +/// an empty offline `get_items` is authoritative and must not fall through to +/// the server. See hybrid.rs `get_items`. +/// +/// TRACES: UR-052 | DR-078, DR-080 +pub fn include_catalog_browse() -> bool { INCLUDE_CATALOG_BROWSE.load(Ordering::Relaxed) } @@ -509,6 +522,266 @@ impl OfflineRepository { Ok(saved) } + /// SQL fragment: the set of item ids that are "on the device" — playable + /// items with a completed download, plus containers (album/series/season) + /// that have at least one downloaded child. This is the `get_items` CTE with + /// the synced-but-not-downloaded catalog branch deliberately excluded, so it + /// is authoritative regardless of the process-wide catalog-browse flag. + /// + /// TRACES: UR-055 | DR-082, DR-083 + const DOWNLOADED_ITEMS_CTE: &'static str = " + WITH downloaded_items AS ( + SELECT DISTINCT i.id + FROM items i + INNER JOIN downloads d ON i.id = d.item_id + WHERE d.status = 'completed' + AND i.item_type IN ('Audio', 'Movie', 'Episode') + + UNION + + SELECT DISTINCT i.id + FROM items i + INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id) + INNER JOIN downloads d ON children.id = d.item_id + WHERE d.status = 'completed' + AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder') + )"; + + /// Downloaded-only browse: items under `parent_id` that are on the device. + /// + /// Unlike [`MediaRepository::get_items`], this never includes the + /// synced-but-not-downloaded catalog and never consults the process-wide + /// `INCLUDE_CATALOG_BROWSE` flag — it is the dedicated Downloads surface. + /// An empty result is authoritative ("nothing downloaded here"), so the + /// hybrid repo must call this directly rather than racing the server. + /// + /// TRACES: UR-055 | DR-082, DR-083 + pub async fn get_downloaded_items( + &self, + parent_id: &str, + options: Option, + ) -> Result { + let opts = options.unwrap_or_default(); + let limit = opts.limit.unwrap_or(10000); + let start_index = opts.start_index.unwrap_or(0); + + let type_filter = if let Some(include_item_types) = &opts.include_item_types { + if !include_item_types.is_empty() { + let types = include_item_types + .iter() + .map(|t| format!("'{}'", t.replace('\'', "''"))) + .collect::>() + .join(","); + format!(" AND i.item_type IN ({})", types) + } else { + String::new() + } + } else { + String::new() + }; + + let sql = format!( + "{cte} + SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres, + i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, + i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, + i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, + i.parent_index_number, i.is_folder, i.premiere_date + FROM items i + INNER JOIN downloaded_items di ON i.id = di.id + WHERE i.server_id = ? + AND ( + i.parent_id = ? OR i.album_id = ? OR i.season_id = ? OR i.series_id = ? + OR EXISTS ( + SELECT 1 FROM libraries l + WHERE l.id = ? AND l.server_id = i.server_id + ) + ){type_filter} + ORDER BY i.sort_name ASC, i.name ASC + LIMIT {limit} OFFSET {start_index}", + cte = Self::DOWNLOADED_ITEMS_CTE, + ); + + let query = Query::with_params( + sql, + vec![ + QueryParam::String(self.server_id.clone()), + QueryParam::String(parent_id.to_string()), + QueryParam::String(parent_id.to_string()), + QueryParam::String(parent_id.to_string()), + QueryParam::String(parent_id.to_string()), + QueryParam::String(parent_id.to_string()), + ], + ); + + let cached_items: Vec = self + .db_service + .query_many(query, row_to_cached_item) + .await + .map_err(|e| RepoError::Database { message: e })?; + + let mut items = Vec::new(); + for cached in cached_items { + let user_data = self.get_user_data(&cached.id).await; + items.push(Self::cached_item_to_media_item(cached, user_data)); + } + + let total_record_count = items.len(); + Ok(SearchResult { + items, + total_record_count, + }) + } + + /// Libraries that contain at least one downloaded item. Libraries with + /// nothing on the device are omitted, so the Downloaded surface only lists + /// libraries the user actually has offline content in. + /// + /// TRACES: UR-055 | DR-082 + pub async fn get_downloaded_libraries(&self) -> Result, RepoError> { + // A downloaded item links back to its library only indirectly (the + // cache leaves library_id NULL — see [[offline-libraries-never-cached]]). + // We match a library by collection_type ↔ item_type instead: any + // completed download of a given media kind qualifies that library. + let query = Query::with_params( + &format!( + "{cte} + SELECT l.id, l.name, l.collection_type, l.image_tag + FROM libraries l + WHERE l.server_id = ? + AND EXISTS ( + SELECT 1 FROM items i + INNER JOIN downloaded_items di ON i.id = di.id + WHERE i.server_id = l.server_id + AND ( + (l.collection_type = 'music' AND i.item_type IN ('MusicAlbum', 'MusicArtist', 'Audio')) + OR (l.collection_type = 'movies' AND i.item_type = 'Movie') + OR (l.collection_type = 'tvshows' AND i.item_type IN ('Series', 'Season', 'Episode')) + OR (l.collection_type NOT IN ('music', 'movies', 'tvshows')) + ) + ) + ORDER BY l.sort_order ASC, l.name ASC", + cte = Self::DOWNLOADED_ITEMS_CTE, + ), + vec![QueryParam::String(self.server_id.clone())], + ); + + self.db_service + .query_many(query, |row| { + Ok(Library { + id: row.get(0)?, + name: row.get(1)?, + collection_type: row + .get::<_, Option>(2)? + .unwrap_or_else(|| "unknown".to_string()), + image_tag: row.get(3)?, + }) + }) + .await + .map_err(|e| RepoError::Database { message: e }) + } + + /// On-disk bytes for downloaded content, for the disk-usage display. + /// + /// Returns one entry per *container or leaf* that appears in the Downloaded + /// browse: a leaf's own `file_size`, a container's summed downloaded + /// descendants — plus the device total and item (leaf) count. This is pure + /// aggregation over `downloads.file_size`, not new tracking. + /// + /// TRACES: UR-056 | DR-085 + pub async fn get_download_disk_usage(&self) -> Result { + // Per-leaf sizes (completed playable downloads only). + let leaf_query = Query::with_params( + "SELECT d.item_id, COALESCE(d.file_size, 0) + FROM downloads d + INNER JOIN items i ON i.id = d.item_id + WHERE d.status = 'completed' + AND i.server_id = ? + AND i.item_type IN ('Audio', 'Movie', 'Episode')", + vec![QueryParam::String(self.server_id.clone())], + ); + let leaves: Vec<(String, i64)> = self + .db_service + .query_many(leaf_query, |row| Ok((row.get(0)?, row.get(1)?))) + .await + .map_err(|e| RepoError::Database { message: e })?; + + // Container subtotals: sum each container's downloaded descendants. + let container_query = Query::with_params( + "SELECT c.id, COALESCE(SUM(d.file_size), 0) + FROM items c + INNER JOIN items children + ON (children.parent_id = c.id OR children.album_id = c.id OR children.season_id = c.id OR children.series_id = c.id) + INNER JOIN downloads d ON children.id = d.item_id + WHERE d.status = 'completed' + AND c.server_id = ? + AND c.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder') + GROUP BY c.id", + vec![QueryParam::String(self.server_id.clone())], + ); + let containers: Vec<(String, i64)> = self + .db_service + .query_many(container_query, |row| Ok((row.get(0)?, row.get(1)?))) + .await + .map_err(|e| RepoError::Database { message: e })?; + + // Partiality per container: a container is "partial" when it has cached + // descendants that are NOT downloaded. We compare downloaded-descendant + // count against total-cached-descendant count (the offline cache holds + // the synced full catalog, so this is meaningful). + let partial_query = Query::with_params( + "SELECT c.id, + COUNT(children.id) AS total_children, + SUM(CASE WHEN d.status = 'completed' THEN 1 ELSE 0 END) AS downloaded_children + FROM items c + INNER JOIN items children + ON (children.parent_id = c.id OR children.album_id = c.id OR children.season_id = c.id OR children.series_id = c.id) + LEFT JOIN downloads d ON children.id = d.item_id AND d.status = 'completed' + WHERE c.server_id = ? + AND c.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder') + AND children.item_type IN ('Audio', 'Movie', 'Episode', 'Season') + GROUP BY c.id", + vec![QueryParam::String(self.server_id.clone())], + ); + let partial_rows: Vec<(String, i64, i64)> = self + .db_service + .query_many(partial_query, |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get::<_, Option>(2)?.unwrap_or(0), + )) + }) + .await + .map_err(|e| RepoError::Database { message: e })?; + + let mut partial_containers = std::collections::HashMap::new(); + for (id, total, downloaded) in partial_rows { + // Only record containers that actually have a download (they appear + // in the browse); mark partial when some cached child is missing. + if downloaded > 0 && downloaded < total { + partial_containers.insert(id, true); + } + } + + let item_count = leaves.len() as u32; + let device_total_bytes: i64 = leaves.iter().map(|(_, b)| *b).sum(); + + let mut sizes = std::collections::HashMap::new(); + for (id, bytes) in leaves.into_iter().chain(containers.into_iter()) { + // A container id can never collide with a leaf id, so a plain insert + // is fine; use entry to be defensive against duplicate rows. + *sizes.entry(id).or_insert(0) += bytes; + } + + Ok(DownloadDiskUsage { + sizes, + partial_containers, + device_total_bytes, + item_count, + }) + } + /// Cache playlist items from server into local database /// Called by HybridRepository after fetching from online pub async fn save_playlist_items_to_cache( @@ -1785,7 +2058,8 @@ mod tests { CREATE TABLE downloads ( id INTEGER PRIMARY KEY AUTOINCREMENT, item_id TEXT NOT NULL, - status TEXT NOT NULL + status TEXT NOT NULL, + file_size INTEGER ); CREATE TABLE libraries ( @@ -2052,6 +2326,8 @@ mod tests { /// downloaded media — not the whole synced catalog. With it on, the full /// (synced-but-not-downloaded) catalog is revealed. Fixes the bug where /// offline library pages showed every server item regardless of the toggle. + /// + /// TRACES: UR-052 | DR-078 | UT-067 #[tokio::test] async fn test_get_items_toggle_gates_synced_catalog() { use crate::storage::db_service::DatabaseService; @@ -2289,6 +2565,189 @@ mod tests { repo.save_to_cache("library-1", &items).await.unwrap(); } + /// Insert a fully-formed item row of a given type (bypasses save_to_cache's + /// stub-parent machinery so containers/leaves can be linked precisely). + async fn insert_item( + db: &Arc, + id: &str, + item_type: &str, + album_id: Option<&str>, + series_id: Option<&str>, + season_id: Option<&str>, + ) { + db.execute(Query::with_params( + "INSERT INTO items (id, server_id, name, item_type, album_id, series_id, season_id, synced_at) + VALUES (?1, 'test-server', ?2, ?3, ?4, ?5, ?6, '2024-01-01')", + vec![ + QueryParam::String(id.to_string()), + QueryParam::String(format!("Name {id}")), + QueryParam::String(item_type.to_string()), + album_id.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null), + series_id.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null), + season_id.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null), + ], + )) + .await + .unwrap(); + } + + async fn seed_completed_download(db: &Arc, item_id: &str, file_size: i64) { + db.execute(Query::with_params( + "INSERT INTO downloads (item_id, status, file_size) VALUES (?1, 'completed', ?2)", + vec![ + QueryParam::String(item_id.to_string()), + QueryParam::Int64(file_size), + ], + )) + .await + .unwrap(); + } + + async fn seed_library(db: &Arc, id: &str, collection_type: &str) { + db.execute(Query::with_params( + "INSERT INTO libraries (id, server_id, name, collection_type, sort_order) + VALUES (?1, 'test-server', ?2, ?3, 0)", + vec![ + QueryParam::String(id.to_string()), + QueryParam::String(format!("Lib {id}")), + QueryParam::String(collection_type.to_string()), + ], + )) + .await + .unwrap(); + } + + fn make_repo(db: &Arc) -> OfflineRepository { + OfflineRepository::new( + db.clone(), + "test-server".to_string(), + "test-user".to_string(), + ) + } + + /// UT: downloaded-only browse returns a downloaded leaf AND its container, + /// filtered to the requested album parent. A non-downloaded sibling is omitted. + /// + /// TRACES: UR-055 | DR-082, DR-083 | UT-046 + #[tokio::test] + async fn test_get_downloaded_items_returns_leaf_and_container() { + let db = create_test_db(); + insert_item(&db, "album-1", "MusicAlbum", None, None, None).await; + insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await; + insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await; + // track-1 downloaded; track-2 is NOT downloaded. + seed_completed_download(&db, "track-1", 1000).await; + + let repo = make_repo(&db); + + // Browsing the album shows only the downloaded track. + let in_album = repo.get_downloaded_items("album-1", None).await.unwrap(); + let ids: Vec<&str> = in_album.items.iter().map(|i| i.id.as_str()).collect(); + assert_eq!(ids, vec!["track-1"], "only the downloaded track is listed"); + } + + /// UT: an empty downloaded-only browse is authoritative — no rows, no error, + /// regardless of the catalog-browse flag (which the DR-080 fallthrough uses). + /// + /// TRACES: UR-055 | DR-082 | UT-047 + #[tokio::test] + async fn test_get_downloaded_items_empty_is_authoritative() { + let db = create_test_db(); + insert_item(&db, "album-1", "MusicAlbum", None, None, None).await; + insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await; + // Nothing downloaded, and the catalog-browse flag is ON (online default). + set_include_catalog_browse(true); + let repo = make_repo(&db); + + let result = repo.get_downloaded_items("album-1", None).await.unwrap(); + assert!( + result.items.is_empty(), + "empty downloaded browse returns no items even with catalog-browse on" + ); + } + + /// UT: only libraries with downloaded content are listed; an empty one is omitted. + /// + /// TRACES: UR-055 | DR-082 | UT-048 + #[tokio::test] + async fn test_get_downloaded_libraries_omits_empty() { + let db = create_test_db(); + seed_library(&db, "music-lib", "music").await; + seed_library(&db, "movie-lib", "movies").await; + insert_item(&db, "track-1", "Audio", None, None, None).await; + seed_completed_download(&db, "track-1", 500).await; + + let repo = make_repo(&db); + let libs = repo.get_downloaded_libraries().await.unwrap(); + let ids: Vec<&str> = libs.iter().map(|l| l.id.as_str()).collect(); + assert_eq!( + ids, + vec!["music-lib"], + "movie library with no downloads omitted" + ); + } + + /// UT: disk usage reports a leaf's own size, a container's summed descendants, + /// and reconciles the device total with the sum of leaves. + /// + /// TRACES: UR-056 | DR-085 | UT-049 + #[tokio::test] + async fn test_download_disk_usage_aggregates_containers() { + let db = create_test_db(); + insert_item(&db, "album-1", "MusicAlbum", None, None, None).await; + insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await; + insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await; + seed_completed_download(&db, "track-1", 1000).await; + seed_completed_download(&db, "track-2", 2000).await; + + let repo = make_repo(&db); + let usage = repo.get_download_disk_usage().await.unwrap(); + + assert_eq!(usage.item_count, 2, "two leaf downloads"); + assert_eq!( + usage.device_total_bytes, 3000, + "device total is the leaf sum" + ); + assert_eq!(usage.sizes.get("track-1"), Some(&1000)); + assert_eq!( + usage.sizes.get("album-1"), + Some(&3000), + "container = sum of children" + ); + // Both children downloaded ⇒ album is NOT partial. + assert_eq!( + usage.partial_containers.get("album-1"), + None, + "fully downloaded album is not partial" + ); + // Device total reconciles with the sum of the listed leaves. + let leaf_sum: i64 = ["track-1", "track-2"] + .iter() + .map(|id| usage.sizes[*id]) + .sum(); + assert_eq!(leaf_sum, usage.device_total_bytes); + } + + /// UT: a container with a downloaded child AND a non-downloaded cached child + /// is flagged partial. TRACES: UR-055 | DR-083 | UT-051 + #[tokio::test] + async fn test_download_disk_usage_flags_partial_container() { + let db = create_test_db(); + insert_item(&db, "album-1", "MusicAlbum", None, None, None).await; + insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await; + insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await; + // Only track-1 downloaded; track-2 is cached but not downloaded. + seed_completed_download(&db, "track-1", 1000).await; + + let repo = make_repo(&db); + let usage = repo.get_download_disk_usage().await.unwrap(); + assert_eq!( + usage.partial_containers.get("album-1"), + Some(&true), + "album with a missing child is partial" + ); + } + #[tokio::test] async fn test_playlist_create_empty() { let db_service = create_test_db(); diff --git a/src-tauri/src/repository/types.rs b/src-tauri/src/repository/types.rs index bb419a99..29e1c533 100644 --- a/src-tauri/src/repository/types.rs +++ b/src-tauri/src/repository/types.rs @@ -212,6 +212,28 @@ pub struct SearchResult { pub total_record_count: usize, } +/// On-disk usage of downloaded content, for the Downloads surface. +/// +/// `sizes` maps an item id (leaf *or* container) to its bytes on disk: a leaf's +/// own file size, a container's summed downloaded descendants. `device_total_bytes` +/// and `item_count` are the headline figures for the Downloaded surface top bar. +/// +/// TRACES: UR-056 | DR-085 +#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DownloadDiskUsage { + /// item id → bytes on disk (leaf's own size, or a container's subtotal). + pub sizes: std::collections::HashMap, + /// Container id → true when it is only *partially* downloaded (has cached + /// children that are not downloaded). Absent/false ⇒ fully downloaded. Lets + /// the Downloaded surface badge partial vs. full containers. + pub partial_containers: std::collections::HashMap, + /// Sum of all downloaded leaf sizes — the device total. + pub device_total_bytes: i64, + /// Number of downloaded leaf items (not containers). + pub item_count: u32, +} + /// Options for querying items #[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] diff --git a/src/lib/api/bindings.ts b/src/lib/api/bindings.ts index 35771767..caef4d2b 100644 --- a/src/lib/api/bindings.ts +++ b/src/lib/api/bindings.ts @@ -784,6 +784,19 @@ async deleteAllDownloads(userId: string) : Promise { async deleteAlbumDownloads(albumId: string, userId: string) : Promise { return await TAURI_INVOKE("delete_album_downloads", { albumId, userId }); }, +/** + * Remove every completed download at or under a container item. + * + * Works at any level of the Downloaded browse: a leaf (removes just that + * download), an album/season/series (removes all downloaded descendants linked + * via album_id/season_id/series_id/parent_id). Deletes the DB rows and the + * on-disk files. Returns the number of downloads removed. Idempotent. + * + * TRACES: UR-055 | DR-083 + */ +async deleteDownloadsUnder(itemId: string, userId: string) : Promise { + return await TAURI_INVOKE("delete_downloads_under", { itemId, userId }); +}, /** * Clear all stale pending/failed/paused downloads */ @@ -915,6 +928,29 @@ async updateSmartCacheConfig(config: CacheConfig) : Promise { async getSmartCacheConfig() : Promise { return await TAURI_INVOKE("get_smart_cache_config"); }, +/** + * Report the device's current network transport (Android → Rust). + * + * The frontend calls this on startup and whenever the native network callback + * fires. Updating to an acceptable network re-pumps the download queue, so a + * queue parked on "waiting for WiFi" drains itself without user action. + * + * TRACES: UR-053 | DR-074 + */ +async setNetworkState(network: NetworkStateWrapperArg) : Promise { + return await TAURI_INVOKE("set_network_state", { network }); +}, +/** + * Whether downloads are currently permitted by the WiFi-only gate. + * + * The downloads UI uses this to render "Waiting for WiFi" on pending rows + * rather than leaving them looking silently stuck. + * + * TRACES: UR-053 | DR-074 + */ +async getDownloadsAllowed() : Promise { + return await TAURI_INVOKE("get_downloads_allowed"); +}, /** * Get album recommendations based on play history */ @@ -1166,6 +1202,33 @@ async repositoryGetItems(handle: string, parentId: string, options: GetItemsOpti async repositoryGetItem(handle: string, itemId: string) : Promise { return await TAURI_INVOKE("repository_get_item", { handle, itemId }); }, +/** + * Downloaded-only browse: libraries that contain downloaded content. + * + * Backs the Downloads "Downloaded" surface. Never merges server results and is + * authoritative — an empty list means nothing is downloaded. + * + * TRACES: UR-055 | DR-082 + */ +async repositoryGetDownloadedLibraries(handle: string) : Promise { + return await TAURI_INVOKE("repository_get_downloaded_libraries", { handle }); +}, +/** + * Downloaded-only browse: items under a container that are on the device. + * + * TRACES: UR-055 | DR-082, DR-083 + */ +async repositoryGetDownloadedItems(handle: string, parentId: string, options: GetItemsOptions | null) : Promise { + return await TAURI_INVOKE("repository_get_downloaded_items", { handle, parentId, options }); +}, +/** + * On-disk usage of downloaded content (device total, per-item/container bytes). + * + * TRACES: UR-056 | DR-085 + */ +async repositoryGetDownloadDiskUsage(handle: string) : Promise { + return await TAURI_INVOKE("repository_get_download_disk_usage", { handle }); +}, /** * Query the optional JRay plugin for the actors on screen at time `t` * (seconds) in an item. Returns an empty list when JRay isn't installed or @@ -1626,6 +1689,34 @@ connectionError: string | null; * Whether we're currently checking connectivity */ isChecking: boolean } +/** + * On-disk usage of downloaded content, for the Downloads surface. + * + * `sizes` maps an item id (leaf *or* container) to its bytes on disk: a leaf's + * own file size, a container's summed downloaded descendants. `device_total_bytes` + * and `item_count` are the headline figures for the Downloaded surface top bar. + * + * TRACES: UR-056 | DR-085 + */ +export type DownloadDiskUsage = { +/** + * item id → bytes on disk (leaf's own size, or a container's subtotal). + */ +sizes: Partial<{ [key in string]: number }>; +/** + * Container id → true when it is only *partially* downloaded (has cached + * children that are not downloaded). Absent/false ⇒ fully downloaded. Lets + * the Downloaded surface badge partial vs. full containers. + */ +partialContainers: Partial<{ [key in string]: boolean }>; +/** + * Sum of all downloaded leaf sizes — the device total. + */ +deviceTotalBytes: number; +/** + * Number of downloaded leaf items (not containers). + */ +itemCount: number } /** * Information about a download */ @@ -1757,6 +1848,45 @@ export type MediaType = "audio" | "video" * Converts from both local MediaItem and remote NowPlayingItem */ export type MergedMediaItem = { id: string; title: string; artist: string | null; album: string | null; albumId: string | null; duration: number | null; primaryImageTag: string | null; mediaType: string } +/** + * Argument struct for [`set_network_state`]. + * + * TRACES: UR-053 | DR-074 + */ +export type NetworkStateWrapperArg = { networkType: NetworkType; unmetered: boolean } +/** + * Kind of network transport currently active. + * + * Mirrors the string constants in `NetworkTypeMonitor.kt`; the two must stay + * in sync (the serde rename below is what the frontend sends). + * + * TRACES: UR-053 | DR-074 + */ +export type NetworkType = +/** + * No active network. + */ +"none" | +/** + * WiFi (may still be metered — check `unmetered`). + */ +"wifi" | +/** + * Wired ethernet, typical on Android TV and desktop. + */ +"ethernet" | +/** + * Mobile data — never acceptable when wifi-only is enabled. + */ +"cellular" | +/** + * Some other transport (VPN over unknown carrier, Bluetooth tethering, …). + */ +"other" | +/** + * Could not determine the transport. + */ +"unknown" export type NowPlayingItem = { id: string | null; name: string | null; runTimeTicks: number | null; album: string | null; albumId: string | null; albumArtist: string | null; artists: string[] | null; imageTags: Partial<{ [key in string]: string }> | null; primaryImageTag: string | null; albumPrimaryImageTag: string | null; Type: string | null } export type OfflineItem = { id: string; name: string; itemType: string; albumId: string | null; albumName: string | null; artists: string | null; runtimeTicks: number | null; primaryImageTag: string | null } /** diff --git a/src/lib/api/repository-client.test.ts b/src/lib/api/repository-client.test.ts index 45c6c547..a63dc352 100644 --- a/src/lib/api/repository-client.test.ts +++ b/src/lib/api/repository-client.test.ts @@ -337,6 +337,46 @@ describe("RepositoryClient", () => { requestId: 0, }); }); + + // Downloaded-only browse path (UR-055 | DR-082) — verifies command names and + // camelCase params per the Tauri v2 rule (CLAUDE.md). + it("should get downloaded libraries from backend", async () => { + const mockLibraries = [{ id: "lib1", name: "Music", collectionType: "music" }]; + (invoke as any).mockResolvedValueOnce(mockLibraries); + + const libraries = await client.getDownloadedLibraries(); + + expect(libraries).toEqual(mockLibraries); + expect(invoke).toHaveBeenCalledWith("repository_get_downloaded_libraries", { + handle: "test-handle-123", + }); + }); + + it("should get downloaded items with camelCase params", async () => { + const mockResult = { items: [{ id: "t1", name: "Track", type: "Audio" }], totalRecordCount: 1 }; + (invoke as any).mockResolvedValueOnce(mockResult); + + const result = await client.getDownloadedItems("album1", { limit: 50 }); + + expect(result).toEqual(mockResult); + expect(invoke).toHaveBeenCalledWith("repository_get_downloaded_items", { + handle: "test-handle-123", + parentId: "album1", + options: { limit: 50 }, + }); + }); + + it("should get download disk usage from backend", async () => { + const mockUsage = { sizes: { t1: 1000 }, partialContainers: {}, deviceTotalBytes: 1000, itemCount: 1 }; + (invoke as any).mockResolvedValueOnce(mockUsage); + + const usage = await client.getDownloadDiskUsage(); + + expect(usage).toEqual(mockUsage); + expect(invoke).toHaveBeenCalledWith("repository_get_download_disk_usage", { + handle: "test-handle-123", + }); + }); }); describe("Playback Methods", () => { diff --git a/src/lib/api/repository-client.ts b/src/lib/api/repository-client.ts index 985c92f5..13461454 100644 --- a/src/lib/api/repository-client.ts +++ b/src/lib/api/repository-client.ts @@ -3,7 +3,7 @@ // NO direct HTTP calls - everything routes through Rust backend import { commands } from "./bindings"; -import type { JRayActor } from "./bindings"; +import type { JRayActor, DownloadDiskUsage } from "./bindings"; import type { QualityPreset } from "./quality-presets"; import type { Library, @@ -91,6 +91,31 @@ export class RepositoryClient { return commands.repositoryGetItem(this.ensureHandle(), itemId); } + /** + * Downloaded-only browse: libraries that contain downloaded content. + * Never merges server results; an empty list is authoritative. + * TRACES: UR-055 | DR-082 + */ + async getDownloadedLibraries(): Promise { + return commands.repositoryGetDownloadedLibraries(this.ensureHandle()); + } + + /** + * Downloaded-only browse: items under a container that are on the device. + * TRACES: UR-055 | DR-082, DR-083 + */ + async getDownloadedItems(parentId: string, options?: GetItemsOptions): Promise { + return commands.repositoryGetDownloadedItems(this.ensureHandle(), parentId, options ?? null); + } + + /** + * On-disk usage of downloaded content (device total + per-item/container bytes). + * TRACES: UR-056 | DR-085 + */ + async getDownloadDiskUsage(): Promise { + return commands.repositoryGetDownloadDiskUsage(this.ensureHandle()); + } + /** * Query the optional JRay plugin for the actors on screen at time `t` * (seconds) in an item. Resolves to an empty array when JRay isn't installed diff --git a/src/lib/components/downloads/DownloadedBrowse.svelte b/src/lib/components/downloads/DownloadedBrowse.svelte new file mode 100644 index 00000000..d378add7 --- /dev/null +++ b/src/lib/components/downloads/DownloadedBrowse.svelte @@ -0,0 +1,189 @@ + + + +
+ +
+
+ + + +

+ {formatBytes($downloadedDeviceTotal)} + on device + · + {$downloadedItemCount} + {$downloadedItemCount === 1 ? "item" : "items"} +

+
+
+ + {#if currentLibrary} + +
+ + / + {currentLibrary.name} +
+ + {#if loadError} +

{loadError}

+ {/if} + + i)} + loading={loadingItems} + showViewToggle={true} + musicContent={currentLibrary.collectionType === "music"} + {sizeLabelFor} + {downloadedBadgeFor} + onItemRemove={removeItem} + {onItemClick} + /> + {#if !loadingItems && items.length === 0 && !loadError} +

Nothing downloaded in this library.

+ {/if} + {:else if loading} +

Loading your downloads…

+ {:else if $downloadedLibraries.length === 0} + +
+ + + +

Nothing downloaded yet

+

+ Browse your library and tap download to save media for offline. +

+ +
+ {:else} + +
+ {#each $downloadedLibraries as lib (lib.id)} + + {/each} +
+ {/if} +
diff --git a/src/lib/components/library/LibraryGrid.svelte b/src/lib/components/library/LibraryGrid.svelte index 20981b1c..011337bb 100644 --- a/src/lib/components/library/LibraryGrid.svelte +++ b/src/lib/components/library/LibraryGrid.svelte @@ -1,3 +1,4 @@ +
@@ -65,7 +74,7 @@

No items found

- {:else if !forceGrid && $viewMode === "list"} + {:else if $viewMode === "list"} {:else}
@@ -74,6 +83,9 @@ onItemRemove(item) : undefined} onclick={() => onItemClick?.(item)} />
diff --git a/src/lib/components/library/MediaCard.svelte b/src/lib/components/library/MediaCard.svelte index aa0a5182..39b06731 100644 --- a/src/lib/components/library/MediaCard.svelte +++ b/src/lib/components/library/MediaCard.svelte @@ -1,3 +1,4 @@ + -
+
-

Downloads

-

Manage your offline media downloads

+

Downloads

+

Your offline library and active transfers

-
- - - - +
- -
-
-
-
- Green = Downloads you chose -
-
-
- Blue = Auto-cached content -
-
-
- - {#if loading} -
-

Loading downloads...

-
+ {#if view === "downloaded"} + {:else} - - {#if activeTab === "active" && activeDownloadsList.length > 0} + + {#if $waitingForNetwork && transfers.length > 0} +
+ + + +
+

Waiting for WiFi

+

+ Downloads are paused because “WiFi Only” is enabled and this device is + on a metered or cellular connection. They'll resume automatically on + an unmetered network. +

+
+
+ {/if} + + {#if loading} +

Loading transfers…

+ {:else if transfers.length === 0} +
+ + + +

Nothing downloading

+

+ Browse your library and tap download to save media for offline. +

+ +
+ {:else}
-
- {:else if activeTab === "completed" && completedDownloadsList.length > 0} -
- - -
- {/if} - -
- {#if activeTab === "active"} - {#if activeDownloadsList.length === 0} -
- - - -

No active downloads

-

- Downloads you start will appear here -

-
- {:else} - {#each activeDownloadsList as download (download.id)} - - {/each} - {/if} - {:else} - {#if completedDownloadsList.length === 0} -
- - - -

No completed downloads

-

- Finished downloads will appear here -

-
- {:else} - {#each completedDownloadsList as download (download.id)} - - {/each} - {/if} - {/if} -
- - - {#if activeDownloadsList.length === 0 && completedDownloadsList.length === 0} -
-
- - - -
-

Getting started with downloads:

-
    -
  • Look for the download icon next to tracks, albums, and playlists
  • -
  • Downloaded media is available for offline playback
  • -
  • Configure download settings in the Settings page
  • -
-
-
+
+ {#each transfers as download (download.id)} + + {/each}
{/if} {/if}