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:
@@ -195,6 +195,56 @@ pub async fn repository_get_item(
|
|||||||
.map_err(|e| format!("{:?}", e))
|
.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<Vec<Library>, 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<GetItemsOptions>,
|
||||||
|
) -> Result<SearchResult, String> {
|
||||||
|
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<DownloadDiskUsage, String> {
|
||||||
|
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`
|
/// 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
|
/// (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.
|
/// has no data for the item, so the caller can render nothing without error.
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
// @req: IR-013 - SQLite integration for local database
|
// @req: IR-013 - SQLite integration for local database
|
||||||
// @req: DR-012 - Local database for media metadata cache
|
// @req: DR-012 - Local database for media metadata cache
|
||||||
// @req: DR-013 - Repository pattern for online/offline data access
|
// @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)]
|
#[cfg(test)]
|
||||||
use crate::utils::lock::MutexSafe;
|
use crate::utils::lock::MutexSafe;
|
||||||
@@ -131,6 +133,36 @@ impl HybridRepository {
|
|||||||
Ok(result.items)
|
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).
|
/// Search only the live Jellyfin server (full library).
|
||||||
pub async fn search_server_only(
|
pub async fn search_server_only(
|
||||||
&self,
|
&self,
|
||||||
@@ -316,6 +348,26 @@ impl MediaRepository for HybridRepository {
|
|||||||
.cache_with_timeout(async move { offline.get_items(&parent_id, opts_clone).await })
|
.cache_with_timeout(async move { offline.get_items(&parent_id, opts_clone).await })
|
||||||
.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
|
// Cache hit: return immediately, update cache in background
|
||||||
if let Ok(data) = &cache_result {
|
if let Ok(data) = &cache_result {
|
||||||
if data.has_content() {
|
if data.has_content() {
|
||||||
@@ -1457,6 +1509,81 @@ mod tests {
|
|||||||
|
|
||||||
Ok(result)
|
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
|
/// Test cache miss saves server data to cache for next time
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
// Offline repository - queries SQLite database for cached data
|
// 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::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
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
|
/// the full greyed-out catalog. See `set_include_catalog_browse` and the
|
||||||
/// `showServerCatalog` UI flag. Fixes the bug where offline library pages showed
|
/// `showServerCatalog` UI flag. Fixes the bug where offline library pages showed
|
||||||
/// every server item regardless of the toggle.
|
/// every server item regardless of the toggle.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-052 | DR-078
|
||||||
static INCLUDE_CATALOG_BROWSE: AtomicBool = AtomicBool::new(true);
|
static INCLUDE_CATALOG_BROWSE: AtomicBool = AtomicBool::new(true);
|
||||||
|
|
||||||
/// Set whether offline `get_items` includes non-downloaded (synced-only) catalog
|
/// 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);
|
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)
|
INCLUDE_CATALOG_BROWSE.load(Ordering::Relaxed)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,6 +522,266 @@ impl OfflineRepository {
|
|||||||
Ok(saved)
|
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
|
/// Cache playlist items from server into local database
|
||||||
/// Called by HybridRepository after fetching from online
|
/// Called by HybridRepository after fetching from online
|
||||||
pub async fn save_playlist_items_to_cache(
|
pub async fn save_playlist_items_to_cache(
|
||||||
@@ -1785,7 +2058,8 @@ mod tests {
|
|||||||
CREATE TABLE downloads (
|
CREATE TABLE downloads (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
item_id TEXT NOT NULL,
|
item_id TEXT NOT NULL,
|
||||||
status TEXT NOT NULL
|
status TEXT NOT NULL,
|
||||||
|
file_size INTEGER
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE libraries (
|
CREATE TABLE libraries (
|
||||||
@@ -2052,6 +2326,8 @@ mod tests {
|
|||||||
/// downloaded media — not the whole synced catalog. With it on, the full
|
/// downloaded media — not the whole synced catalog. With it on, the full
|
||||||
/// (synced-but-not-downloaded) catalog is revealed. Fixes the bug where
|
/// (synced-but-not-downloaded) catalog is revealed. Fixes the bug where
|
||||||
/// offline library pages showed every server item regardless of the toggle.
|
/// offline library pages showed every server item regardless of the toggle.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-052 | DR-078 | UT-067
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_get_items_toggle_gates_synced_catalog() {
|
async fn test_get_items_toggle_gates_synced_catalog() {
|
||||||
use crate::storage::db_service::DatabaseService;
|
use crate::storage::db_service::DatabaseService;
|
||||||
@@ -2289,6 +2565,189 @@ mod tests {
|
|||||||
repo.save_to_cache("library-1", &items).await.unwrap();
|
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]
|
#[tokio::test]
|
||||||
async fn test_playlist_create_empty() {
|
async fn test_playlist_create_empty() {
|
||||||
let db_service = create_test_db();
|
let db_service = create_test_db();
|
||||||
|
|||||||
@@ -212,6 +212,28 @@ pub struct SearchResult {
|
|||||||
pub total_record_count: usize,
|
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
|
/// Options for querying items
|
||||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|||||||
@@ -784,6 +784,19 @@ async deleteAllDownloads(userId: string) : Promise<number> {
|
|||||||
async deleteAlbumDownloads(albumId: string, userId: string) : Promise<number> {
|
async deleteAlbumDownloads(albumId: string, userId: string) : Promise<number> {
|
||||||
return await TAURI_INVOKE("delete_album_downloads", { albumId, userId });
|
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<number> {
|
||||||
|
return await TAURI_INVOKE("delete_downloads_under", { itemId, userId });
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Clear all stale pending/failed/paused downloads
|
* Clear all stale pending/failed/paused downloads
|
||||||
*/
|
*/
|
||||||
@@ -915,6 +928,29 @@ async updateSmartCacheConfig(config: CacheConfig) : Promise<null> {
|
|||||||
async getSmartCacheConfig() : Promise<CacheConfig> {
|
async getSmartCacheConfig() : Promise<CacheConfig> {
|
||||||
return await TAURI_INVOKE("get_smart_cache_config");
|
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<null> {
|
||||||
|
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<boolean> {
|
||||||
|
return await TAURI_INVOKE("get_downloads_allowed");
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Get album recommendations based on play history
|
* 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<MediaItem> {
|
async repositoryGetItem(handle: string, itemId: string) : Promise<MediaItem> {
|
||||||
return await TAURI_INVOKE("repository_get_item", { handle, itemId });
|
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<Library[]> {
|
||||||
|
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<SearchResult> {
|
||||||
|
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<DownloadDiskUsage> {
|
||||||
|
return await TAURI_INVOKE("repository_get_download_disk_usage", { handle });
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Query the optional JRay plugin for the actors on screen at time `t`
|
* 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
|
* (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
|
* Whether we're currently checking connectivity
|
||||||
*/
|
*/
|
||||||
isChecking: boolean }
|
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
|
* Information about a download
|
||||||
*/
|
*/
|
||||||
@@ -1757,6 +1848,45 @@ export type MediaType = "audio" | "video"
|
|||||||
* Converts from both local MediaItem and remote NowPlayingItem
|
* 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 }
|
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 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 }
|
export type OfflineItem = { id: string; name: string; itemType: string; albumId: string | null; albumName: string | null; artists: string | null; runtimeTicks: number | null; primaryImageTag: string | null }
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -337,6 +337,46 @@ describe("RepositoryClient", () => {
|
|||||||
requestId: 0,
|
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", () => {
|
describe("Playback Methods", () => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// NO direct HTTP calls - everything routes through Rust backend
|
// NO direct HTTP calls - everything routes through Rust backend
|
||||||
|
|
||||||
import { commands } from "./bindings";
|
import { commands } from "./bindings";
|
||||||
import type { JRayActor } from "./bindings";
|
import type { JRayActor, DownloadDiskUsage } from "./bindings";
|
||||||
import type { QualityPreset } from "./quality-presets";
|
import type { QualityPreset } from "./quality-presets";
|
||||||
import type {
|
import type {
|
||||||
Library,
|
Library,
|
||||||
@@ -91,6 +91,31 @@ export class RepositoryClient {
|
|||||||
return commands.repositoryGetItem(this.ensureHandle(), itemId);
|
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<Library[]> {
|
||||||
|
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<SearchResult> {
|
||||||
|
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<DownloadDiskUsage> {
|
||||||
|
return commands.repositoryGetDownloadDiskUsage(this.ensureHandle());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Query the optional JRay plugin for the actors on screen at time `t`
|
* 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
|
* (seconds) in an item. Resolves to an empty array when JRay isn't installed
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
<!--
|
||||||
|
Downloaded browse surface: the library, filtered to what's on the device.
|
||||||
|
|
||||||
|
Reuses the library's own grid/cards. The top level lists only libraries with
|
||||||
|
downloaded content; drilling into a library shows its downloaded items in the
|
||||||
|
same grid used online. Clicking a leaf/detail item navigates to the shared
|
||||||
|
`/library/[id]` detail page, where Play uses the local file. Per-item and
|
||||||
|
device disk usage ride along via the size labels and the top bar.
|
||||||
|
|
||||||
|
TRACES: UR-055, UR-056 | DR-081, DR-082, DR-083, DR-085
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import type { Library, MediaItem } from "$lib/api/types";
|
||||||
|
import LibraryGrid from "$lib/components/library/LibraryGrid.svelte";
|
||||||
|
import { formatBytes } from "$lib/utils/formatBytes";
|
||||||
|
import {
|
||||||
|
downloadedCatalog,
|
||||||
|
downloadedLibraries,
|
||||||
|
downloadedDeviceTotal,
|
||||||
|
downloadedItemCount,
|
||||||
|
} from "$lib/services/downloadedCatalog";
|
||||||
|
|
||||||
|
// Drill state: null = library list; otherwise the library we're inside.
|
||||||
|
let currentLibrary = $state<Library | null>(null);
|
||||||
|
let items = $state<MediaItem[]>([]);
|
||||||
|
let loadingItems = $state(false);
|
||||||
|
let loadError = $state<string | null>(null);
|
||||||
|
|
||||||
|
const loading = $derived($downloadedCatalog.loading);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void downloadedCatalog.refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openLibrary(library: Library) {
|
||||||
|
currentLibrary = library;
|
||||||
|
loadingItems = true;
|
||||||
|
loadError = null;
|
||||||
|
try {
|
||||||
|
items = await downloadedCatalog.loadItems(library.id);
|
||||||
|
} catch (err) {
|
||||||
|
loadError = err instanceof Error ? err.message : "Failed to load downloads";
|
||||||
|
items = [];
|
||||||
|
} finally {
|
||||||
|
loadingItems = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function backToLibraries() {
|
||||||
|
currentLibrary = null;
|
||||||
|
items = [];
|
||||||
|
loadError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Containers (album/season/series/box set) drill via the shared detail page,
|
||||||
|
// which is offline-aware; leaves open their detail/play surface there too.
|
||||||
|
function onItemClick(item: MediaItem | Library) {
|
||||||
|
if ("collectionType" in item) {
|
||||||
|
// A Library (top level) — drill in place.
|
||||||
|
void openLibrary(item as Library);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
goto(`/library/${item.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A size label for a card, if we have a byte figure for it.
|
||||||
|
function sizeLabelFor(item: MediaItem | Library): string | undefined {
|
||||||
|
const bytes = $downloadedCatalog.sizes[item.id];
|
||||||
|
return bytes && bytes > 0 ? formatBytes(bytes) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove a downloaded item/container, stating the reclaim amount first.
|
||||||
|
async function removeItem(item: MediaItem | Library) {
|
||||||
|
if (!("type" in item)) return;
|
||||||
|
const bytes = $downloadedCatalog.sizes[item.id] ?? 0;
|
||||||
|
const freed = bytes > 0 ? ` This frees ${formatBytes(bytes)}.` : "";
|
||||||
|
if (!confirm(`Remove “${item.name}” from this device?${freed}`)) return;
|
||||||
|
try {
|
||||||
|
await downloadedCatalog.remove(item.id);
|
||||||
|
// Reload the current library so removed items (and now-empty containers)
|
||||||
|
// drop out of the browse.
|
||||||
|
if (currentLibrary) {
|
||||||
|
items = await downloadedCatalog.loadItems(currentLibrary.id);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
loadError = err instanceof Error ? err.message : "Failed to remove download";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full vs partial container badge (leaves get no container badge here).
|
||||||
|
function downloadedBadgeFor(item: MediaItem | Library): "full" | "partial" | undefined {
|
||||||
|
if (!("type" in item)) return undefined;
|
||||||
|
const isContainer = ["MusicAlbum", "Series", "Season", "BoxSet"].includes(item.type);
|
||||||
|
if (!isContainer) return undefined;
|
||||||
|
return $downloadedCatalog.partialContainers[item.id] ? "partial" : "full";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-5">
|
||||||
|
<!-- Device total: the headline figure, reconciles with the listed sum. -->
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between rounded-lg border border-gray-700 bg-[var(--color-surface)] px-4 py-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<svg class="h-5 w-5 text-[var(--color-jellyfin)]" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.8">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 7v10a2 2 0 002 2h12a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H6a2 2 0 00-2 2z" />
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm text-gray-200">
|
||||||
|
<span class="font-semibold text-white">{formatBytes($downloadedDeviceTotal)}</span>
|
||||||
|
on device
|
||||||
|
<span class="text-gray-500">·</span>
|
||||||
|
{$downloadedItemCount}
|
||||||
|
{$downloadedItemCount === 1 ? "item" : "items"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if currentLibrary}
|
||||||
|
<!-- Inside a library: breadcrumb back to the library list. -->
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<button
|
||||||
|
onclick={backToLibraries}
|
||||||
|
class="text-gray-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Downloaded
|
||||||
|
</button>
|
||||||
|
<span class="text-gray-600">/</span>
|
||||||
|
<span class="text-white font-medium">{currentLibrary.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loadError}
|
||||||
|
<p class="text-sm text-red-400">{loadError}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<LibraryGrid
|
||||||
|
items={items.map((i) => i)}
|
||||||
|
loading={loadingItems}
|
||||||
|
showViewToggle={true}
|
||||||
|
musicContent={currentLibrary.collectionType === "music"}
|
||||||
|
{sizeLabelFor}
|
||||||
|
{downloadedBadgeFor}
|
||||||
|
onItemRemove={removeItem}
|
||||||
|
{onItemClick}
|
||||||
|
/>
|
||||||
|
{#if !loadingItems && items.length === 0 && !loadError}
|
||||||
|
<p class="text-center py-8 text-gray-500 text-sm">Nothing downloaded in this library.</p>
|
||||||
|
{/if}
|
||||||
|
{:else if loading}
|
||||||
|
<p class="text-center py-12 text-gray-400">Loading your downloads…</p>
|
||||||
|
{:else if $downloadedLibraries.length === 0}
|
||||||
|
<!-- Empty Downloaded state: authoritative "nothing downloaded", not a server miss. -->
|
||||||
|
<div class="rounded-lg border border-gray-700 bg-[var(--color-surface)] p-10 text-center">
|
||||||
|
<svg class="mx-auto mb-4 h-14 w-14 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.4">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4m-9 7h10" />
|
||||||
|
</svg>
|
||||||
|
<p class="text-lg font-medium text-gray-300">Nothing downloaded yet</p>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">
|
||||||
|
Browse your library and tap download to save media for offline.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onclick={() => goto("/library")}
|
||||||
|
class="mt-5 rounded-lg bg-[var(--color-jellyfin)] px-4 py-2 text-sm font-medium text-white hover:opacity-90 transition"
|
||||||
|
>
|
||||||
|
Go to library
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Library list — only libraries with downloaded content. -->
|
||||||
|
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||||
|
{#each $downloadedLibraries as lib (lib.id)}
|
||||||
|
<button
|
||||||
|
onclick={() => openLibrary(lib)}
|
||||||
|
class="group/card flex flex-col text-left transition-transform duration-200 hover:scale-105"
|
||||||
|
>
|
||||||
|
<div class="relative aspect-video w-full overflow-hidden rounded-lg bg-[var(--color-surface)] shadow-md flex items-center justify-center">
|
||||||
|
<svg class="h-10 w-10 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.4">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-7l-2-2H5a2 2 0 00-2 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 truncate text-sm font-medium text-white group-hover/card:text-[var(--color-jellyfin)] transition-colors">
|
||||||
|
{lib.name}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
<!-- TRACES: UR-029, UR-051 | DR-069, DR-070 -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { MediaItem, Library } from "$lib/api/types";
|
import type { MediaItem, Library } from "$lib/api/types";
|
||||||
import MediaCard from "./MediaCard.svelte";
|
import MediaCard from "./MediaCard.svelte";
|
||||||
@@ -9,12 +10,20 @@
|
|||||||
title?: string;
|
title?: string;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
showViewToggle?: boolean;
|
showViewToggle?: boolean;
|
||||||
forceGrid?: boolean;
|
|
||||||
musicContent?: boolean;
|
musicContent?: boolean;
|
||||||
onItemClick?: (item: MediaItem | Library) => void;
|
onItemClick?: (item: MediaItem | Library) => void;
|
||||||
|
/**
|
||||||
|
* Optional per-item secondary label (e.g. on-disk size for the Downloaded
|
||||||
|
* surface), forwarded to each card. TRACES: UR-056 | DR-085
|
||||||
|
*/
|
||||||
|
sizeLabelFor?: (item: MediaItem | Library) => string | undefined;
|
||||||
|
/** Optional per-item container download badge for the Downloaded surface. */
|
||||||
|
downloadedBadgeFor?: (item: MediaItem | Library) => "full" | "partial" | undefined;
|
||||||
|
/** Optional per-item remove-from-device handler for the Downloaded surface. */
|
||||||
|
onItemRemove?: (item: MediaItem | Library) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { items, title, loading = false, showViewToggle = true, forceGrid = false, musicContent = false, onItemClick }: Props = $props();
|
let { items, title, loading = false, showViewToggle = true, musicContent = false, onItemClick, sizeLabelFor, downloadedBadgeFor, onItemRemove }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@@ -65,7 +74,7 @@
|
|||||||
<div class="text-center py-12 text-gray-400">
|
<div class="text-center py-12 text-gray-400">
|
||||||
<p>No items found</p>
|
<p>No items found</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if !forceGrid && $viewMode === "list"}
|
{:else if $viewMode === "list"}
|
||||||
<LibraryListView {items} showProgress={true} onItemClick={onItemClick} />
|
<LibraryListView {items} showProgress={true} onItemClick={onItemClick} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||||
@@ -74,6 +83,9 @@
|
|||||||
<MediaCard
|
<MediaCard
|
||||||
{item}
|
{item}
|
||||||
showProgress={true}
|
showProgress={true}
|
||||||
|
sizeLabel={sizeLabelFor?.(item)}
|
||||||
|
downloadedBadge={downloadedBadgeFor?.(item)}
|
||||||
|
onRemove={onItemRemove ? () => onItemRemove(item) : undefined}
|
||||||
onclick={() => onItemClick?.(item)}
|
onclick={() => onItemClick?.(item)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
<!-- TRACES: UR-051, UR-052 | DR-068, DR-078 -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { MediaItem, Library } from "$lib/api/types";
|
import type { MediaItem, Library } from "$lib/api/types";
|
||||||
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||||
@@ -12,10 +13,26 @@
|
|||||||
size?: "small" | "medium" | "large";
|
size?: "small" | "medium" | "large";
|
||||||
showProgress?: boolean;
|
showProgress?: boolean;
|
||||||
showDownloadStatus?: boolean;
|
showDownloadStatus?: boolean;
|
||||||
|
/**
|
||||||
|
* Secondary on-disk size label (e.g. "1.2 GB"), shown under the subtitle.
|
||||||
|
* Used by the Downloaded browse surface. TRACES: UR-056 | DR-085
|
||||||
|
*/
|
||||||
|
sizeLabel?: string;
|
||||||
|
/**
|
||||||
|
* "full" | "partial" — badges a downloaded container on the artwork so a
|
||||||
|
* fully-downloaded item reads differently from a partially-downloaded one.
|
||||||
|
* TRACES: UR-055 | DR-083
|
||||||
|
*/
|
||||||
|
downloadedBadge?: "full" | "partial";
|
||||||
|
/**
|
||||||
|
* When set, a hover/focus "remove from device" control appears on the card
|
||||||
|
* (Downloaded surface only). TRACES: UR-055, UR-056 | DR-083
|
||||||
|
*/
|
||||||
|
onRemove?: () => void;
|
||||||
onclick?: () => void;
|
onclick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { item, size = "medium", showProgress = false, showDownloadStatus = true, onclick }: Props = $props();
|
let { item, size = "medium", showProgress = false, showDownloadStatus = true, sizeLabel, downloadedBadge, onRemove, onclick }: Props = $props();
|
||||||
|
|
||||||
// Check if this item is downloaded
|
// Check if this item is downloaded
|
||||||
const downloadInfo = $derived(
|
const downloadInfo = $derived(
|
||||||
@@ -219,6 +236,43 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Remove-from-device control (Downloaded surface), shown on hover/focus -->
|
||||||
|
{#if onRemove}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={(e) => { e.stopPropagation(); onRemove?.(); }}
|
||||||
|
class="absolute top-2 left-2 w-7 h-7 rounded-full bg-black/70 hover:bg-red-600 text-white flex items-center justify-center opacity-0 group-hover/card:opacity-100 focus:opacity-100 transition-opacity shadow-lg"
|
||||||
|
title="Remove from device"
|
||||||
|
aria-label="Remove {item.name} from device"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 7h12M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2m-7 0v12a1 1 0 001 1h6a1 1 0 001-1V7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Container downloaded badge (Downloaded surface): full vs partial -->
|
||||||
|
{#if downloadedBadge}
|
||||||
|
<div
|
||||||
|
class="absolute bottom-2 right-2"
|
||||||
|
title={downloadedBadge === "full" ? "Fully downloaded" : "Partially downloaded"}
|
||||||
|
>
|
||||||
|
{#if downloadedBadge === "full"}
|
||||||
|
<div class="w-6 h-6 rounded-full bg-green-600 flex items-center justify-center shadow-lg">
|
||||||
|
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="w-6 h-6 rounded-full bg-amber-500 flex items-center justify-center shadow-lg" aria-label="Partially downloaded">
|
||||||
|
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Server-only: queue-for-download control (kept at full opacity over the
|
<!-- Server-only: queue-for-download control (kept at full opacity over the
|
||||||
greyed artwork). Queued items show a "queued" badge instead. -->
|
greyed artwork). Queued items show a "queued" badge instead. -->
|
||||||
{#if isServerOnly}
|
{#if isServerOnly}
|
||||||
@@ -261,5 +315,8 @@
|
|||||||
{#if subtitle()}
|
{#if subtitle()}
|
||||||
<p class="text-xs text-gray-400 truncate">{subtitle()}</p>
|
<p class="text-xs text-gray-400 truncate">{subtitle()}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if sizeLabel}
|
||||||
|
<p class="text-xs text-gray-500 truncate">{sizeLabel}</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</svelte:element>
|
</svelte:element>
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// Downloaded catalog service — the "Downloaded" browse surface's data source.
|
||||||
|
//
|
||||||
|
// This is the offline library, filtered to what's on the device. It reads the
|
||||||
|
// dedicated offline-only browse path on the repository (never the hybrid merge),
|
||||||
|
// so results are authoritative regardless of connectivity: an empty result means
|
||||||
|
// "nothing downloaded here", never "server unreachable". See the spec
|
||||||
|
// docs/specs/downloads-as-offline-library.md and ux-flows §7.2–7.3.
|
||||||
|
//
|
||||||
|
// It also owns disk-usage: a per-item/container byte map plus the device total,
|
||||||
|
// aggregated from `downloads.file_size` by the backend.
|
||||||
|
//
|
||||||
|
// TRACES: UR-055, UR-056 | DR-082, DR-083, DR-085
|
||||||
|
|
||||||
|
import { writable, derived, get } from "svelte/store";
|
||||||
|
import type { Library, MediaItem, GetItemsOptions } from "$lib/api/types";
|
||||||
|
import type { DownloadDiskUsage } from "$lib/api/bindings";
|
||||||
|
import { commands } from "$lib/api/bindings";
|
||||||
|
import { auth } from "$lib/stores/auth";
|
||||||
|
|
||||||
|
interface DownloadedCatalogState {
|
||||||
|
libraries: Library[];
|
||||||
|
/** item id → bytes on disk (leaf's own size, or a container subtotal). */
|
||||||
|
sizes: Record<string, number>;
|
||||||
|
/** container id → true when only partially downloaded. */
|
||||||
|
partialContainers: Record<string, boolean>;
|
||||||
|
deviceTotalBytes: number;
|
||||||
|
itemCount: number;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initial: DownloadedCatalogState = {
|
||||||
|
libraries: [],
|
||||||
|
sizes: {},
|
||||||
|
partialContainers: {},
|
||||||
|
deviceTotalBytes: 0,
|
||||||
|
itemCount: 0,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function createDownloadedCatalogStore() {
|
||||||
|
const { subscribe, update, set } = writable<DownloadedCatalogState>(initial);
|
||||||
|
|
||||||
|
function repo() {
|
||||||
|
return auth.getRepository();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load the downloaded-library list and disk-usage totals for the top bar. */
|
||||||
|
async function refresh(): Promise<void> {
|
||||||
|
update((s) => ({ ...s, loading: true, error: null }));
|
||||||
|
try {
|
||||||
|
const [libraries, usage] = await Promise.all([
|
||||||
|
repo().getDownloadedLibraries(),
|
||||||
|
repo().getDownloadDiskUsage() as Promise<DownloadDiskUsage>,
|
||||||
|
]);
|
||||||
|
// The wire type is Partial<{ [k]: number }>; normalise to a dense record.
|
||||||
|
const sizes: Record<string, number> = {};
|
||||||
|
for (const [k, v] of Object.entries(usage.sizes)) {
|
||||||
|
if (typeof v === "number") sizes[k] = v;
|
||||||
|
}
|
||||||
|
const partialContainers: Record<string, boolean> = {};
|
||||||
|
for (const [k, v] of Object.entries(usage.partialContainers)) {
|
||||||
|
if (v) partialContainers[k] = true;
|
||||||
|
}
|
||||||
|
update((s) => ({
|
||||||
|
...s,
|
||||||
|
libraries,
|
||||||
|
sizes,
|
||||||
|
partialContainers,
|
||||||
|
deviceTotalBytes: usage.deviceTotalBytes,
|
||||||
|
itemCount: usage.itemCount,
|
||||||
|
loading: false,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to load downloads";
|
||||||
|
update((s) => ({ ...s, loading: false, error: message }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Downloaded-only items under a container (library, album, season, series). */
|
||||||
|
async function loadItems(parentId: string, options?: GetItemsOptions): Promise<MediaItem[]> {
|
||||||
|
const result = await repo().getDownloadedItems(parentId, options);
|
||||||
|
return result.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bytes on disk for an item id (leaf's own, or a container subtotal), or 0. */
|
||||||
|
function sizeOf(itemId: string): number {
|
||||||
|
return get({ subscribe }).sizes[itemId] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove every completed download at or under a container/leaf, then refresh
|
||||||
|
* so the browse and totals update. Returns the number of downloads removed.
|
||||||
|
* TRACES: UR-055, UR-056 | DR-083
|
||||||
|
*/
|
||||||
|
async function remove(itemId: string): Promise<number> {
|
||||||
|
const userId = auth.getUserId();
|
||||||
|
if (!userId) throw new Error("Not signed in");
|
||||||
|
const removed = await commands.deleteDownloadsUnder(itemId, userId);
|
||||||
|
await refresh();
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
set(initial);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { subscribe, refresh, loadItems, sizeOf, remove, reset };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const downloadedCatalog = createDownloadedCatalogStore();
|
||||||
|
|
||||||
|
export const downloadedLibraries = derived(downloadedCatalog, ($c) => $c.libraries);
|
||||||
|
export const downloadedDeviceTotal = derived(downloadedCatalog, ($c) => $c.deviceTotalBytes);
|
||||||
|
export const downloadedItemCount = derived(downloadedCatalog, ($c) => $c.itemCount);
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { formatBytes } from "./formatBytes";
|
||||||
|
|
||||||
|
// TRACES: UR-056 | DR-085 | UT-050
|
||||||
|
describe("formatBytes", () => {
|
||||||
|
it("renders zero and non-positive as '0 B'", () => {
|
||||||
|
expect(formatBytes(0)).toBe("0 B");
|
||||||
|
expect(formatBytes(-1)).toBe("0 B");
|
||||||
|
expect(formatBytes(NaN)).toBe("0 B");
|
||||||
|
expect(formatBytes(Infinity)).toBe("0 B");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders whole bytes below 1 KB", () => {
|
||||||
|
expect(formatBytes(1)).toBe("1 B");
|
||||||
|
expect(formatBytes(999)).toBe("999 B");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("crosses to KB at 1000 bytes (decimal units)", () => {
|
||||||
|
expect(formatBytes(1000)).toBe("1 KB");
|
||||||
|
expect(formatBytes(1500)).toBe("1.5 KB");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 2-3 significant figures", () => {
|
||||||
|
expect(formatBytes(340_000_000)).toBe("340 MB");
|
||||||
|
expect(formatBytes(1_200_000_000)).toBe("1.2 GB");
|
||||||
|
expect(formatBytes(48_000_000)).toBe("48 MB");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims trailing zeros", () => {
|
||||||
|
expect(formatBytes(2_000_000_000)).toBe("2 GB");
|
||||||
|
expect(formatBytes(10_000_000)).toBe("10 MB");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scales into large units", () => {
|
||||||
|
expect(formatBytes(3_400_000_000)).toBe("3.4 GB");
|
||||||
|
expect(formatBytes(1_000_000_000_000)).toBe("1 TB");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses no decimals at or above 100 of a unit", () => {
|
||||||
|
// 123.4 MB → "123 MB" (2-3 sig figs, whole number band)
|
||||||
|
expect(formatBytes(123_400_000)).toBe("123 MB");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Shared byte-size formatter for the Downloads surface.
|
||||||
|
//
|
||||||
|
// One formatter, used everywhere disk usage is shown (cards, detail pages, the
|
||||||
|
// device total, and the remove-reclaim prompt) so units are consistent. We use
|
||||||
|
// DECIMAL units (1 GB = 1000 MB), matching how phone storage screens and file
|
||||||
|
// browsers present sizes, and show 2–3 significant figures.
|
||||||
|
//
|
||||||
|
// TRACES: UR-056 | DR-085
|
||||||
|
|
||||||
|
const UNITS = ["B", "KB", "MB", "GB", "TB", "PB"] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a byte count as a human-readable size string (decimal units).
|
||||||
|
*
|
||||||
|
* Examples: 0 → "0 B", 340_000_000 → "340 MB", 1_200_000_000 → "1.2 GB".
|
||||||
|
*
|
||||||
|
* - Bytes render as whole numbers (no "0.5 B").
|
||||||
|
* - KB and above show enough decimals for 2–3 significant figures: values
|
||||||
|
* ≥ 100 render with no decimals, ≥ 10 with one, otherwise two.
|
||||||
|
* - Negative / non-finite inputs are treated as 0 (sizes are never negative).
|
||||||
|
*/
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
||||||
|
|
||||||
|
let value = bytes;
|
||||||
|
let unitIndex = 0;
|
||||||
|
while (value >= 1000 && unitIndex < UNITS.length - 1) {
|
||||||
|
value /= 1000;
|
||||||
|
unitIndex += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bytes are always whole; larger units get 2–3 significant figures.
|
||||||
|
let formatted: string;
|
||||||
|
if (unitIndex === 0) {
|
||||||
|
formatted = Math.round(value).toString();
|
||||||
|
} else if (value >= 100) {
|
||||||
|
formatted = Math.round(value).toString();
|
||||||
|
} else if (value >= 10) {
|
||||||
|
formatted = value.toFixed(1);
|
||||||
|
} else {
|
||||||
|
formatted = value.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim trailing zeros ("1.20" → "1.2", "1.00" → "1") for a cleaner label.
|
||||||
|
if (formatted.includes(".")) {
|
||||||
|
formatted = formatted.replace(/\.?0+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${formatted} ${UNITS[unitIndex]}`;
|
||||||
|
}
|
||||||
@@ -1,18 +1,37 @@
|
|||||||
|
<!--
|
||||||
|
Downloads surface (UR-055): two views under /downloads.
|
||||||
|
|
||||||
|
- Downloaded (default): the library filtered to what's on the device, using the
|
||||||
|
same browse grids/cards/detail pages as online. Backed by the offline-only
|
||||||
|
repository path (never merges server results).
|
||||||
|
- Transfers: the in-flight transfer rows (downloading / queued / paused /
|
||||||
|
failed / waiting-for-WiFi) with per-row controls. Completed transfers fall
|
||||||
|
off this view — they appear in Downloaded.
|
||||||
|
|
||||||
|
Initiating downloads stays on item/album/series detail pages (§7.1); this page
|
||||||
|
manages and browses only.
|
||||||
|
|
||||||
|
TRACES: UR-055, UR-056 | DR-081, DR-082, DR-083, DR-084, DR-085
|
||||||
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { commands } from "$lib/api/bindings";
|
import { downloads, activeDownloads, pendingDownloads, failedDownloads, waitingForNetwork } from "$lib/stores/downloads";
|
||||||
import { downloads, activeDownloads, completedDownloads, pendingDownloads, failedDownloads } from "$lib/stores/downloads";
|
import { areDownloadsAllowed } from "$lib/services/networkType";
|
||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
import DownloadItem from "$lib/components/downloads/DownloadItem.svelte";
|
import DownloadItem from "$lib/components/downloads/DownloadItem.svelte";
|
||||||
import StorageManagement from "$lib/components/downloads/StorageManagement.svelte";
|
import DownloadedBrowse from "$lib/components/downloads/DownloadedBrowse.svelte";
|
||||||
|
|
||||||
type TabType = "active" | "completed";
|
type ViewType = "downloaded" | "transfers";
|
||||||
let activeTab = $state<TabType>("active");
|
let view = $state<ViewType>("downloaded");
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await loadDownloads();
|
await loadDownloads();
|
||||||
|
// Seed the WiFi-gate state on entry: the 'waitingForNetwork' event only
|
||||||
|
// fires when the pump runs, so a queue parked before this page opened would
|
||||||
|
// otherwise show no explanation.
|
||||||
|
waitingForNetwork.set(!(await areDownloadsAllowed()));
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadDownloads() {
|
async function loadDownloads() {
|
||||||
@@ -29,8 +48,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeDownloadsList = $derived($activeDownloads.concat($pendingDownloads));
|
// Transfers = everything still in flight or waiting. Completed rows are
|
||||||
const completedDownloadsList = $derived($completedDownloads.concat($failedDownloads));
|
// deliberately excluded — they live in Downloaded, not here.
|
||||||
|
const transfers = $derived(
|
||||||
|
$activeDownloads.concat($pendingDownloads).concat($failedDownloads)
|
||||||
|
);
|
||||||
|
|
||||||
async function pauseAll() {
|
async function pauseAll() {
|
||||||
for (const download of $activeDownloads) {
|
for (const download of $activeDownloads) {
|
||||||
@@ -42,15 +64,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Refresh to update UI with new states
|
|
||||||
const userId = $auth.user?.id;
|
const userId = $auth.user?.id;
|
||||||
if (userId) {
|
if (userId) await downloads.refresh(userId);
|
||||||
await downloads.refresh(userId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resumeAll() {
|
async function resumeAll() {
|
||||||
for (const download of activeDownloadsList) {
|
for (const download of transfers) {
|
||||||
if (download.status === "paused" || download.status === "failed") {
|
if (download.status === "paused" || download.status === "failed") {
|
||||||
try {
|
try {
|
||||||
await downloads.resume(download.id);
|
await downloads.resume(download.id);
|
||||||
@@ -59,42 +78,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Refresh to update UI with new states
|
|
||||||
const userId = $auth.user?.id;
|
const userId = $auth.user?.id;
|
||||||
if (userId) {
|
if (userId) await downloads.refresh(userId);
|
||||||
await downloads.refresh(userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function clearCompleted() {
|
|
||||||
for (const download of $completedDownloads) {
|
|
||||||
try {
|
|
||||||
await downloads.delete(download.id);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to delete download ${download.id}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Refresh to update UI
|
|
||||||
const userId = $auth.user?.id;
|
|
||||||
if (userId) {
|
|
||||||
await downloads.refresh(userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function clearStale() {
|
|
||||||
try {
|
|
||||||
const userId = $auth.user?.id;
|
|
||||||
if (userId) {
|
|
||||||
await commands.clearStaleDownloads(userId);
|
|
||||||
await downloads.refresh(userId);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to clear stale downloads:", error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="max-w-4xl mx-auto space-y-6 p-6">
|
<div class="max-w-5xl mx-auto space-y-6 p-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<button
|
<button
|
||||||
@@ -103,93 +92,87 @@
|
|||||||
title="Back to library"
|
title="Back to library"
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||||
<path
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M15 19l-7-7 7-7"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-white mb-2">Downloads</h1>
|
<h1 class="text-3xl font-bold text-white">Downloads</h1>
|
||||||
<p class="text-gray-400">Manage your offline media downloads</p>
|
<p class="text-gray-400">Your offline library and active transfers</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onclick={loadDownloads}
|
|
||||||
class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-white transition-colors"
|
|
||||||
title="Refresh downloads"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Storage Management -->
|
<!-- View switch: Downloaded (default) / Transfers -->
|
||||||
<StorageManagement />
|
|
||||||
|
|
||||||
<!-- Tabs -->
|
|
||||||
<div class="flex gap-4 border-b border-gray-700">
|
<div class="flex gap-4 border-b border-gray-700">
|
||||||
<button
|
<button
|
||||||
onclick={() => (activeTab = "active")}
|
onclick={() => (view = "downloaded")}
|
||||||
class="pb-3 px-1 font-medium transition-colors relative {activeTab === 'active'
|
class="pb-3 px-1 font-medium transition-colors relative {view === 'downloaded'
|
||||||
? 'text-[var(--color-jellyfin)]'
|
? 'text-[var(--color-jellyfin)]'
|
||||||
: 'text-gray-400 hover:text-white'}"
|
: 'text-gray-400 hover:text-white'}"
|
||||||
>
|
>
|
||||||
Active
|
Downloaded
|
||||||
{#if activeDownloadsList.length > 0}
|
{#if view === "downloaded"}
|
||||||
<span class="ml-2 px-2 py-0.5 text-xs rounded-full bg-blue-500/20 text-blue-400">
|
|
||||||
{activeDownloadsList.length}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{#if activeTab === "active"}
|
|
||||||
<div class="absolute bottom-0 left-0 right-0 h-0.5 bg-[var(--color-jellyfin)]"></div>
|
<div class="absolute bottom-0 left-0 right-0 h-0.5 bg-[var(--color-jellyfin)]"></div>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => (activeTab = "completed")}
|
onclick={() => (view = "transfers")}
|
||||||
class="pb-3 px-1 font-medium transition-colors relative {activeTab === 'completed'
|
class="pb-3 px-1 font-medium transition-colors relative {view === 'transfers'
|
||||||
? 'text-[var(--color-jellyfin)]'
|
? 'text-[var(--color-jellyfin)]'
|
||||||
: 'text-gray-400 hover:text-white'}"
|
: 'text-gray-400 hover:text-white'}"
|
||||||
>
|
>
|
||||||
Completed
|
Transfers
|
||||||
{#if completedDownloadsList.length > 0}
|
<!-- Draw attention only while transfers are active. -->
|
||||||
<span class="ml-2 px-2 py-0.5 text-xs rounded-full bg-green-500/20 text-green-400">
|
{#if transfers.length > 0}
|
||||||
{completedDownloadsList.length}
|
<span class="ml-2 px-2 py-0.5 text-xs rounded-full bg-blue-500/20 text-blue-400">
|
||||||
|
{transfers.length}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if activeTab === "completed"}
|
{#if view === "transfers"}
|
||||||
<div class="absolute bottom-0 left-0 right-0 h-0.5 bg-[var(--color-jellyfin)]"></div>
|
<div class="absolute bottom-0 left-0 right-0 h-0.5 bg-[var(--color-jellyfin)]"></div>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Color coding legend -->
|
{#if view === "downloaded"}
|
||||||
<div class="bg-[var(--color-surface)] rounded-lg p-3 border border-gray-700">
|
<DownloadedBrowse />
|
||||||
<div class="flex items-center gap-6 text-xs text-gray-400">
|
{:else}
|
||||||
<div class="flex items-center gap-2">
|
<!-- WiFi-only gate notice (UR-053): explains an otherwise stuck-looking queue -->
|
||||||
<div class="w-1 h-4 rounded bg-green-500/50"></div>
|
{#if $waitingForNetwork && transfers.length > 0}
|
||||||
<span><span class="text-green-400 font-medium">Green</span> = Downloads you chose</span>
|
<div class="flex items-start gap-3 rounded-lg border border-amber-700 bg-amber-900/20 p-4" role="status">
|
||||||
</div>
|
<svg class="mt-0.5 h-5 w-5 shrink-0 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||||
<div class="flex items-center gap-2">
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 18h.01M8.111 15.111a5.5 5.5 0 017.778 0M4.929 11.929a10 10 0 0114.142 0M12 21h.01" />
|
||||||
<div class="w-1 h-4 rounded bg-blue-500/50"></div>
|
</svg>
|
||||||
<span><span class="text-blue-400 font-medium">Blue</span> = Auto-cached content</span>
|
<div>
|
||||||
</div>
|
<p class="font-medium text-amber-200">Waiting for WiFi</p>
|
||||||
|
<p class="mt-1 text-sm text-amber-200/80">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="text-center py-12 text-gray-400">
|
<div class="text-center py-12 text-gray-400"><p>Loading transfers…</p></div>
|
||||||
<p>Loading downloads...</p>
|
{:else if transfers.length === 0}
|
||||||
|
<div class="rounded-lg border border-gray-700 bg-[var(--color-surface)] p-10 text-center">
|
||||||
|
<svg class="mx-auto mb-4 h-14 w-14 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.4">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
<p class="text-lg font-medium text-gray-300">Nothing downloading</p>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">
|
||||||
|
Browse your library and tap download to save media for offline.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onclick={() => goto("/library")}
|
||||||
|
class="mt-5 rounded-lg bg-[var(--color-jellyfin)] px-4 py-2 text-sm font-medium text-white hover:opacity-90 transition"
|
||||||
|
>
|
||||||
|
Go to library
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Bulk Actions -->
|
|
||||||
{#if activeTab === "active" && activeDownloadsList.length > 0}
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button
|
<button
|
||||||
onclick={pauseAll}
|
onclick={pauseAll}
|
||||||
@@ -203,121 +186,12 @@
|
|||||||
>
|
>
|
||||||
Resume All
|
Resume All
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onclick={clearStale}
|
|
||||||
class="px-4 py-2 bg-yellow-500/20 text-yellow-400 rounded-lg font-medium hover:bg-yellow-500/30 transition-colors text-sm"
|
|
||||||
title="Remove all pending, paused, and failed downloads"
|
|
||||||
>
|
|
||||||
Clear Stale
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{:else if activeTab === "completed" && completedDownloadsList.length > 0}
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<button
|
|
||||||
onclick={clearCompleted}
|
|
||||||
class="px-4 py-2 bg-red-500/20 text-red-400 rounded-lg font-medium hover:bg-red-500/30 transition-colors text-sm"
|
|
||||||
>
|
|
||||||
Clear Completed
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={async () => {
|
|
||||||
const userId = $auth.user?.id;
|
|
||||||
if (userId) {
|
|
||||||
if (confirm('Delete ALL downloads (including completed)? This cannot be undone.')) {
|
|
||||||
await commands.deleteAllDownloads(userId);
|
|
||||||
await downloads.refresh(userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
class="px-4 py-2 bg-red-600/30 text-red-300 rounded-lg font-medium hover:bg-red-600/40 transition-colors text-sm border border-red-500/50"
|
|
||||||
title="Delete all downloads and files"
|
|
||||||
>
|
|
||||||
Delete All Content
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Downloads List -->
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{#if activeTab === "active"}
|
{#each transfers as download (download.id)}
|
||||||
{#if activeDownloadsList.length === 0}
|
|
||||||
<div class="bg-[var(--color-surface)] rounded-lg p-12 text-center">
|
|
||||||
<svg
|
|
||||||
class="w-16 h-16 mx-auto text-gray-600 mb-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="1.5"
|
|
||||||
d="M12 4v12m0 0l-4-4m4 4l4-4m-9 7h10"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<p class="text-gray-400 text-lg font-medium">No active downloads</p>
|
|
||||||
<p class="text-gray-500 text-sm mt-2">
|
|
||||||
Downloads you start will appear here
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
{#each activeDownloadsList as download (download.id)}
|
|
||||||
<DownloadItem {download} />
|
<DownloadItem {download} />
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
{#if completedDownloadsList.length === 0}
|
|
||||||
<div class="bg-[var(--color-surface)] rounded-lg p-12 text-center">
|
|
||||||
<svg
|
|
||||||
class="w-16 h-16 mx-auto text-gray-600 mb-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="1.5"
|
|
||||||
d="M5 13l4 4L19 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<p class="text-gray-400 text-lg font-medium">No completed downloads</p>
|
|
||||||
<p class="text-gray-500 text-sm mt-2">
|
|
||||||
Finished downloads will appear here
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
{#each completedDownloadsList as download (download.id)}
|
|
||||||
<DownloadItem {download} />
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Info Box -->
|
|
||||||
{#if activeDownloadsList.length === 0 && completedDownloadsList.length === 0}
|
|
||||||
<div class="bg-blue-900/20 border border-blue-800 rounded-lg p-4">
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<svg
|
|
||||||
class="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<div class="text-sm text-blue-300">
|
|
||||||
<p class="font-semibold mb-1">Getting started with downloads:</p>
|
|
||||||
<ul class="list-disc list-inside space-y-1 text-blue-200">
|
|
||||||
<li>Look for the download icon next to tracks, albums, and playlists</li>
|
|
||||||
<li>Downloaded media is available for offline playback</li>
|
|
||||||
<li>Configure download settings in the Settings page</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user