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
|
||||
|
||||
@@ -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<GetItemsOptions>,
|
||||
) -> Result<SearchResult, RepoError> {
|
||||
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::<Vec<_>>()
|
||||
.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<CachedItem> = 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<Vec<Library>, 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<String>>(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<DownloadDiskUsage, RepoError> {
|
||||
// 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<i64>>(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<RusqliteService>,
|
||||
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<RusqliteService>, 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<RusqliteService>, 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<RusqliteService>) -> 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();
|
||||
|
||||
@@ -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<String, i64>,
|
||||
/// 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<String, bool>,
|
||||
/// 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")]
|
||||
|
||||
Reference in New Issue
Block a user