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
This commit is contained in:
@@ -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<GetItemsOptions>,
|
||||
) -> Result<SearchResult, RepoError> {
|
||||
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<Vec<Library>, 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<DownloadDiskUsage, RepoError> {
|
||||
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<SearchResult, RepoError> {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user