diff --git a/scripts/sync-android-sources.sh b/scripts/sync-android-sources.sh index c1f3d4a..9a32be2 100755 --- a/scripts/sync-android-sources.sh +++ b/scripts/sync-android-sources.sh @@ -70,6 +70,20 @@ if [ -d "$RES_SRC" ]; then # render well, and our adaptive-icon xml no longer references it, so a stray # ic_launcher_monochrome.png would just be dead weight. rm -f "$RES_DST"/mipmap-*/ic_launcher_monochrome.png + + # `tauri android init` also emits the Android Studio DEFAULT adaptive icon + # as API-qualified VECTOR drawables: + # drawable/ic_launcher_background.xml (solid #3DDC84 green) + # drawable-v24/ic_launcher_foreground.xml (the Android robot) + # Because drawable-v24 is a more specific match than our unqualified + # mipmap-*/ic_launcher_*.png, on API 24+ the vector WINS and the app ships + # the green square robot instead of our jellyfish. Remove them so the + # adaptive-icon xml resolves @mipmap/ic_launcher_{background,foreground} + # to the real committed PNGs. + rm -f "$RES_DST"/drawable/ic_launcher_background.xml \ + "$RES_DST"/drawable-v24/ic_launcher_foreground.xml \ + "$RES_DST"/drawable*/ic_launcher_foreground.xml \ + "$RES_DST"/drawable*/ic_launcher_background.xml fi echo "✓ Android sources synced successfully" diff --git a/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlaybackService.kt b/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlaybackService.kt index 38de5fc..b3dcc53 100644 --- a/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlaybackService.kt +++ b/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlaybackService.kt @@ -295,6 +295,14 @@ class JellyTauPlaybackService : MediaSessionService() { // Update MediaSession playback state session.setPlaybackState(buildPlaybackState(isPlaying, position)) + // While casting, re-assert the remote volume provider. Metadata pushes + // arrive on the session poller thread and can race with (or arrive + // before) enableRemoteVolume(); this keeps the session routed to the + // remote (absolute) volume slider instead of the local media stream. + if (isRemoteVolumeEnabled) { + volumeProvider?.let { session.setPlaybackToRemote(it) } + } + // Update the notification updateNotification(title, artist, isPlaying) } @@ -326,8 +334,17 @@ class JellyTauPlaybackService : MediaSessionService() { * * The reported playback speed is 1.0 while playing and 0.0 while paused so * Android does not extrapolate the position past a paused track. + * + * While remote volume control is enabled (casting), the state is forced to + * STATE_PLAYING regardless of [isPlaying]. Android only surfaces the remote + * (absolute) volume slider for a session that is actively playing; if a + * periodic metadata/position push reports paused (e.g. before the remote + * session has actually started), reporting STATE_PAUSED here makes the + * system tear down the remote slider set up by setPlaybackToRemote() and + * fall back to the local media-stream volume. */ private fun buildPlaybackState(isPlaying: Boolean, position: Long): PlaybackStateCompat { + val playing = isPlaying || isRemoteVolumeEnabled return PlaybackStateCompat.Builder() .setActions( PlaybackStateCompat.ACTION_PLAY or @@ -338,9 +355,9 @@ class JellyTauPlaybackService : MediaSessionService() { PlaybackStateCompat.ACTION_SEEK_TO ) .setState( - if (isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED, + if (playing) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED, position, - if (isPlaying) 1.0f else 0.0f + if (playing) 1.0f else 0.0f ) .build() } diff --git a/src-tauri/src/commands/catalog.rs b/src-tauri/src/commands/catalog.rs new file mode 100644 index 0000000..a349d2c --- /dev/null +++ b/src-tauri/src/commands/catalog.rs @@ -0,0 +1,440 @@ +//! Tauri commands for the offline "browse & queue" feature. +//! +//! Two backend pieces support browsing the full server catalog while offline +//! and queueing downloads that fire on reconnect: +//! +//! - [`sync_full_catalog`] walks every library while online and persists all +//! items to the offline cache so the whole catalog is browsable (greyed out) +//! offline. It reuses [`HybridRepository::cache_items_from_server`], which in +//! turn reuses `OfflineRepository::save_to_cache` (sets `synced_at`, which is +//! what `get_items` branch 3 serves offline). +//! - [`resume_queued_downloads`] resolves and pumps the `pending` download rows +//! that were queued offline (they have `stream_url IS NULL`), mirroring the +//! heal-and-pump pattern in `player_preload_upcoming`. + +use std::sync::Arc; + +use log::{info, warn}; +use tauri::State; + +use crate::commands::repository::RepositoryManagerWrapper; +use crate::commands::download::{pump_download_queue, DownloadManagerWrapper}; +use crate::commands::storage::DatabaseWrapper; +use crate::repository::types::GetItemsOptions; +use crate::storage::db_service::{DatabaseService, Query, QueryParam}; + +/// app_settings key holding the RFC-3339 timestamp of the last successful +/// full-catalog sync. +const LAST_CATALOG_SYNC_KEY: &str = "last_catalog_sync"; + +/// Item types worth caching for offline browsing: containers the library +/// landing pages render plus the playable leaves users queue for download. +const CATALOG_ITEM_TYPES: &[&str] = &[ + "MusicAlbum", + "Movie", + "Series", + "Season", + "Episode", + "Audio", + "BoxSet", +]; + +#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CatalogSyncResult { + /// Total items persisted to the offline cache across all libraries. + pub items_cached: usize, + /// Libraries that failed to sync (e.g. server hiccup); best-effort. + pub libraries_failed: usize, +} + +#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CatalogSyncStatus { + /// RFC-3339 timestamp of the last successful sync, if any. + pub last_synced_at: Option, +} + +/// Walk every library on the server and persist all items to the offline cache +/// so the full catalog is browsable offline (greyed out when not downloaded). +/// +/// Best-effort: a library that fails to fetch is counted and skipped rather than +/// aborting the whole sync. Runs libraries sequentially to avoid hammering the +/// server. Uses `Recursive=true` so a single request per library returns the +/// containers and their playable children. +#[tauri::command] +#[specta::specta] +pub async fn sync_full_catalog( + repository: State<'_, RepositoryManagerWrapper>, + db: State<'_, DatabaseWrapper>, + handle: String, +) -> Result { + use crate::repository::MediaRepository; + + let repo = repository.0.get(&handle).ok_or("Repository not found")?; + + let db_service = { + let database = db.0.lock().map_err(|e| e.to_string())?; + Arc::new(database.service()) + }; + + let libraries = repo.get_libraries().await.map_err(|e| e.to_string())?; + info!("[Catalog] Full sync starting across {} libraries", libraries.len()); + + let include_types: Vec = CATALOG_ITEM_TYPES.iter().map(|s| s.to_string()).collect(); + + let mut items_cached = 0usize; + let mut libraries_failed = 0usize; + + for library in &libraries { + let opts = GetItemsOptions { + recursive: Some(true), + include_item_types: Some(include_types.clone()), + limit: Some(100_000), + ..Default::default() + }; + + match repo.cache_items_from_server(&library.id, Some(opts)).await { + Ok(items) => { + info!( + "[Catalog] Cached {} items from library '{}'", + items.len(), + library.name + ); + items_cached += items.len(); + } + Err(e) => { + warn!("[Catalog] Failed to sync library '{}': {:?}", library.name, e); + libraries_failed += 1; + } + } + } + + // Record the sync time so callers can skip re-syncing too eagerly. + let now = chrono::Utc::now().to_rfc3339(); + let upsert = Query::with_params( + "INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP", + vec![ + QueryParam::String(LAST_CATALOG_SYNC_KEY.to_string()), + QueryParam::String(now), + ], + ); + if let Err(e) = db_service.execute(upsert).await { + warn!("[Catalog] Failed to persist last-sync timestamp: {}", e); + } + + info!( + "[Catalog] Full sync complete: {} items cached, {} libraries failed", + items_cached, libraries_failed + ); + + Ok(CatalogSyncResult { + items_cached, + libraries_failed, + }) +} + +/// Report the last-synced timestamp so the UI can show a hint / decide whether +/// to trigger a fresh sync. +#[tauri::command] +#[specta::specta] +pub async fn catalog_sync_status( + db: State<'_, DatabaseWrapper>, +) -> Result { + let db_service = { + let database = db.0.lock().map_err(|e| e.to_string())?; + Arc::new(database.service()) + }; + + let query = Query::with_params( + "SELECT value FROM app_settings WHERE key = ?", + vec![QueryParam::String(LAST_CATALOG_SYNC_KEY.to_string())], + ); + let last_synced_at: Option = db_service + .query_optional(query, |row| row.get(0)) + .await + .map_err(|e| e.to_string())?; + + Ok(CatalogSyncStatus { last_synced_at }) +} + +#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResumeQueuedResult { + /// Rows whose stream URL was resolved and are now pump-eligible. + pub resolved: usize, + /// Rows that couldn't be resolved (item metadata / URL lookup failed). + pub failed: usize, +} + +/// Core of [`resume_queued_downloads`], factored out for testing: select every +/// `pending`/`stream_url IS NULL` row, resolve each via `resolve` (returning +/// `None` leaves the row pending), and heal the row so the pump can start it. +/// The `resolve` closure receives `(item_id, media_type, quality_preset)`. +pub(crate) async fn resolve_pending_download_urls( + db_service: &Arc, + target_dir: &str, + resolve: F, +) -> Result +where + F: Fn(String, String, String) -> Fut, + Fut: std::future::Future>, +{ + let rows_query = Query::new( + "SELECT id, item_id, COALESCE(media_type, 'audio'), COALESCE(quality_preset, 'original') + FROM downloads + WHERE status = 'pending' AND stream_url IS NULL", + ); + let rows: Vec<(i64, String, String, String)> = db_service + .query_many(rows_query, |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) + }) + .await + .map_err(|e| e.to_string())?; + + if rows.is_empty() { + return Ok(ResumeQueuedResult { resolved: 0, failed: 0 }); + } + + info!("[Catalog] Resolving {} offline-queued downloads on reconnect", rows.len()); + + let mut resolved = 0usize; + let mut failed = 0usize; + + for (download_id, item_id, media_type, quality) in rows { + let stream_url = match resolve(item_id.clone(), media_type, quality).await { + Some(url) => url, + None => { + failed += 1; + continue; + } + }; + + // Heal the row so the pump can start it. Guard on stream_url IS NULL so a + // concurrent resolver doesn't clobber an already-started row. + let update = Query::with_params( + "UPDATE downloads SET stream_url = ?, target_dir = ? + WHERE id = ? AND status = 'pending' AND stream_url IS NULL", + vec![ + QueryParam::String(stream_url), + QueryParam::String(target_dir.to_string()), + QueryParam::Int64(download_id), + ], + ); + match db_service.execute(update).await { + Ok(n) if n > 0 => resolved += 1, + Ok(_) => {} // already resolved by someone else; not a failure + Err(e) => { + warn!("[Catalog] Failed to persist URL for download {}: {}", download_id, e); + failed += 1; + } + } + } + + Ok(ResumeQueuedResult { resolved, failed }) +} + +/// Resolve the stream URL for every download row that was queued while offline +/// (`status = 'pending' AND stream_url IS NULL`), then pump the queue so they +/// start. Call this on reconnect. +/// +/// Audio rows resolve via `get_audio_stream_url`; video rows (media_type = +/// 'video') via the pure `get_video_download_url` builder using the row's stored +/// `quality_preset` — mirroring `enqueue_video_downloads`. Rows whose URL can't +/// be resolved are left pending (they retry on the next reconnect). +#[tauri::command] +#[specta::specta] +pub async fn resume_queued_downloads( + repository: State<'_, RepositoryManagerWrapper>, + db: State<'_, DatabaseWrapper>, + download_manager: State<'_, DownloadManagerWrapper>, + app: tauri::AppHandle, + handle: String, +) -> Result { + use crate::repository::MediaRepository; + + use crate::repository::HybridRepository; + + let repo = repository.0.get(&handle).ok_or("Repository not found")?; + + // The pump needs a target_dir; use the same storage root the other download + // paths use (the database's parent directory — see `storage_get_path`). + let (db_service, target_dir) = { + let database = db.0.lock().map_err(|e| e.to_string())?; + let target_dir = database + .path() + .parent() + .ok_or_else(|| "Database path has no parent directory".to_string())? + .to_string_lossy() + .to_string(); + (Arc::new(database.service()), target_dir) + }; + + // Resolve each row's URL against the (now reachable) repository. + let repo_for_resolve = Arc::clone(&repo); + let outcome = resolve_pending_download_urls( + &db_service, + &target_dir, + move |item_id: String, media_type: String, quality: String| { + let repo = Arc::clone(&repo_for_resolve); + async move { + if media_type == "video" { + Some(::get_video_download_url( + repo.as_ref(), + &item_id, + &quality, + None, + )) + } else { + match repo.get_audio_stream_url(&item_id).await { + Ok(url) => Some(url), + Err(e) => { + warn!("[Catalog] Failed to resolve audio URL for {}: {:?}", item_id, e); + None + } + } + } + } + }, + ) + .await + .map_err(|e| e.to_string())?; + + let ResumeQueuedResult { resolved, failed } = outcome; + + // Kick the pump so the newly-resolved rows actually start. + if resolved > 0 { + let active_downloads = { + let manager = download_manager.0.lock().map_err(|e| e.to_string())?; + manager.get_active_downloads() + }; + pump_download_queue(app, db_service, active_downloads).await; + } + + info!("[Catalog] Resume complete: {} resolved, {} failed", resolved, failed); + + Ok(ResumeQueuedResult { resolved, failed }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::db_service::RusqliteService; + use rusqlite::Connection; + use std::sync::Mutex; + + fn test_db() -> Arc { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + r#" + CREATE TABLE downloads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + item_id TEXT NOT NULL, + status TEXT NOT NULL, + stream_url TEXT, + target_dir TEXT, + media_type TEXT, + quality_preset TEXT + ); + "#, + ) + .unwrap(); + Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn)))) + } + + async fn insert_download( + db: &Arc, + item_id: &str, + status: &str, + stream_url: Option<&str>, + media_type: Option<&str>, + ) { + let q = Query::with_params( + "INSERT INTO downloads (item_id, status, stream_url, media_type) VALUES (?, ?, ?, ?)", + vec![ + QueryParam::String(item_id.to_string()), + QueryParam::String(status.to_string()), + stream_url.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null), + media_type.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null), + ], + ); + db.execute(q).await.unwrap(); + } + + async fn get_row(db: &Arc, item_id: &str) -> (String, Option, Option) { + let q = Query::with_params( + "SELECT status, stream_url, target_dir FROM downloads WHERE item_id = ?", + vec![QueryParam::String(item_id.to_string())], + ); + db.query_one(q, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))) + .await + .unwrap() + } + + #[tokio::test] + async fn resolves_offline_queued_row_and_leaves_resolved_rows_untouched() { + let db = test_db(); + // A row queued offline: pending with no URL yet. + insert_download(&db, "queued-1", "pending", None, None).await; + // An already-resolved pending row: must NOT be touched. + insert_download(&db, "already", "pending", Some("http://existing/url"), None).await; + // A completed row: irrelevant. + insert_download(&db, "done", "completed", Some("http://done/url"), None).await; + + let out = resolve_pending_download_urls(&db, "/data/downloads", |item_id, _mt, _q| async move { + Some(format!("http://resolved/{item_id}")) + }) + .await + .unwrap(); + + assert_eq!(out.resolved, 1); + assert_eq!(out.failed, 0); + + // The offline-queued row now has a URL + target dir and stays pending. + let (status, url, target) = get_row(&db, "queued-1").await; + assert_eq!(status, "pending"); + assert_eq!(url.as_deref(), Some("http://resolved/queued-1")); + assert_eq!(target.as_deref(), Some("/data/downloads")); + + // The already-resolved row is unchanged (not re-resolved). + let (_s, url2, _t) = get_row(&db, "already").await; + assert_eq!(url2.as_deref(), Some("http://existing/url")); + } + + #[tokio::test] + async fn counts_unresolvable_rows_as_failed_and_leaves_them_pending() { + let db = test_db(); + insert_download(&db, "bad", "pending", None, None).await; + + // Resolver returns None (e.g. server lookup failed). + let out = resolve_pending_download_urls(&db, "/data", |_id, _mt, _q| async move { None }) + .await + .unwrap(); + + assert_eq!(out.resolved, 0); + assert_eq!(out.failed, 1); + + // Still pending with no URL, so a later reconnect can retry it. + let (status, url, _t) = get_row(&db, "bad").await; + assert_eq!(status, "pending"); + assert_eq!(url, None); + } + + #[tokio::test] + async fn video_rows_use_media_type_in_resolver() { + let db = test_db(); + insert_download(&db, "vid-1", "pending", None, Some("video")).await; + + let out = resolve_pending_download_urls(&db, "/data", |item_id, media_type, _q| async move { + assert_eq!(media_type, "video"); + Some(format!("http://transcode/{item_id}")) + }) + .await + .unwrap(); + + assert_eq!(out.resolved, 1); + let (_s, url, _t) = get_row(&db, "vid-1").await; + assert_eq!(url.as_deref(), Some("http://transcode/vid-1")); + } +} diff --git a/src-tauri/src/commands/download/mod.rs b/src-tauri/src/commands/download/mod.rs index 5bf3cbb..75512d9 100644 --- a/src-tauri/src/commands/download/mod.rs +++ b/src-tauri/src/commands/download/mod.rs @@ -1268,13 +1268,50 @@ fn spawn_download_worker( debug!(" Unregistered download {}. Active downloads: {}", download_id, active.len()); } + // The pump runs downloads in the background, so the terminal status MUST + // be persisted to the DB here — the frontend event handler only writes it + // when that download happens to be loaded in its store, which is not the + // case for auto-pumped rows (or any completion while the downloads page is + // closed). `check_for_local_download` filters on status = 'completed', so a + // missed write leaves finished files unrecognized: albums never show as + // downloaded and playback never switches from the (expiring) stream to the + // local file, cutting tracks off mid-play. + let db_service = { + let db = app.state::(); + let database = match db.0.lock() { + Ok(d) => d, + Err(e) => { + error!("[pump] Failed to lock database after download {}: {}", download_id, e); + return; + } + }; + Arc::new(database.service()) + }; + match result { Ok(res) => { info!("Download completed successfully: {} bytes", res.bytes_downloaded); + let file_path = target_path.to_string_lossy().to_string(); + + let update = Query::with_params( + "UPDATE downloads SET status = 'completed', progress = 1.0, \ + bytes_downloaded = ?, file_size = ?, file_path = ?, \ + completed_at = CURRENT_TIMESTAMP WHERE id = ?", + vec![ + QueryParam::Int64(res.bytes_downloaded as i64), + QueryParam::Int64(res.bytes_downloaded as i64), + QueryParam::String(file_path.clone()), + QueryParam::Int64(download_id), + ], + ); + if let Err(e) = db_service.execute(update).await { + error!("[pump] Failed to persist completed status for download {}: {}", download_id, e); + } + let completed_event = DownloadEvent::Completed { download_id, item_id, - file_path: target_path.to_string_lossy().to_string(), + file_path, }; match app.emit("download-event", completed_event) { Ok(_) => debug!(" Completed event emitted successfully"), @@ -1283,6 +1320,18 @@ fn spawn_download_worker( } Err(e) => { error!("Download failed: {:?}", e); + + let update = Query::with_params( + "UPDATE downloads SET status = 'failed', error_message = ? WHERE id = ?", + vec![ + QueryParam::String(e.to_string()), + QueryParam::Int64(download_id), + ], + ); + if let Err(db_err) = db_service.execute(update).await { + error!("[pump] Failed to persist failed status for download {}: {}", download_id, db_err); + } + let failed_event = DownloadEvent::Failed { download_id, item_id, @@ -1296,17 +1345,6 @@ fn spawn_download_worker( } // A slot just freed — start the next pending download (if any). - let db_service = { - let db = app.state::(); - let database = match db.0.lock() { - Ok(d) => d, - Err(e) => { - error!("[pump] Failed to lock database after download {}: {}", download_id, e); - return; - } - }; - Arc::new(database.service()) - }; pump_download_queue(app.clone(), db_service, active_downloads).await; }); } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 86524b9..97c7332 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -2,6 +2,7 @@ // TRACES: UR-002, UR-003, UR-004, UR-005, UR-009, UR-011, UR-012, UR-017, UR-019, UR-025 | // DR-015, DR-017, DR-021, DR-028 pub mod auth; +pub mod catalog; pub mod connectivity; pub mod conversions; pub mod device; @@ -17,6 +18,7 @@ pub mod storage; pub mod sync; pub use auth::*; +pub use catalog::*; pub use connectivity::*; pub use conversions::*; pub use device::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4a64f71..6d37cae 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -23,6 +23,7 @@ use log::{error, info}; use log::warn; use commands::{ + sync_full_catalog, catalog_sync_status, resume_queued_downloads, cancel_download, clear_stale_downloads, delete_album_downloads, delete_all_downloads, delete_download, download_album, download_item, download_item_and_start, download_video, download_series, download_season, get_download_storage_stats, get_downloads, get_download_manager_stats, set_max_concurrent_downloads, @@ -585,6 +586,9 @@ fn specta_builder() -> Builder { start_download, enqueue_download, enqueue_video_downloads, + sync_full_catalog, + catalog_sync_status, + resume_queued_downloads, get_download_manager_stats, set_max_concurrent_downloads, get_smart_cache_stats, diff --git a/src-tauri/src/repository/hybrid.rs b/src-tauri/src/repository/hybrid.rs index 36bff86..4d7f6da 100644 --- a/src-tauri/src/repository/hybrid.rs +++ b/src-tauri/src/repository/hybrid.rs @@ -79,6 +79,26 @@ impl HybridRepository { .await } + /// Fetch a folder's items from the live server and persist them to the + /// offline cache synchronously (unlike `get_items`, which saves in a + /// fire-and-forget background task after a 100ms cache race). + /// + /// Used by the full-catalog pre-sync (`sync_full_catalog`) to deterministically + /// walk every library while online so the whole catalog is browsable — greyed + /// out — offline. Returns the items fetched so the caller can recurse into + /// containers. Server-only: errors if unreachable. + pub async fn cache_items_from_server( + &self, + parent_id: &str, + options: Option, + ) -> Result, RepoError> { + let result = self.online.get_items(parent_id, options).await?; + if !result.items.is_empty() { + self.offline.save_to_cache(parent_id, &result.items).await?; + } + Ok(result.items) + } + /// Search only the live Jellyfin server (full library). pub async fn search_server_only( &self, diff --git a/src/lib/api/bindings.ts b/src/lib/api/bindings.ts index 145fe33..0902583 100644 --- a/src/lib/api/bindings.ts +++ b/src/lib/api/bindings.ts @@ -806,6 +806,38 @@ async enqueueDownload(downloadId: number, streamUrl: string, targetDir: string) async enqueueVideoDownloads(handle: string, downloadIds: number[], targetDir: string) : Promise { return await TAURI_INVOKE("enqueue_video_downloads", { handle, downloadIds, targetDir }); }, +/** + * Walk every library on the server and persist all items to the offline cache + * so the full catalog is browsable offline (greyed out when not downloaded). + * + * Best-effort: a library that fails to fetch is counted and skipped rather than + * aborting the whole sync. Runs libraries sequentially to avoid hammering the + * server. Uses `Recursive=true` so a single request per library returns the + * containers and their playable children. + */ +async syncFullCatalog(handle: string) : Promise { + return await TAURI_INVOKE("sync_full_catalog", { handle }); +}, +/** + * Report the last-synced timestamp so the UI can show a hint / decide whether + * to trigger a fresh sync. + */ +async catalogSyncStatus() : Promise { + return await TAURI_INVOKE("catalog_sync_status"); +}, +/** + * Resolve the stream URL for every download row that was queued while offline + * (`status = 'pending' AND stream_url IS NULL`), then pump the queue so they + * start. Call this on reconnect. + * + * Audio rows resolve via `get_audio_stream_url`; video rows (media_type = + * 'video') via the pure `get_video_download_url` builder using the row's stored + * `quality_preset` — mirroring `enqueue_video_downloads`. Rows whose URL can't + * be resolved are left pending (they retry on the next reconnect). + */ +async resumeQueuedDownloads(handle: string) : Promise { + return await TAURI_INVOKE("resume_queued_downloads", { handle }); +}, /** * Get download manager statistics */ @@ -1505,6 +1537,20 @@ export type CachedLibrary = { id: string; serverId: string; name: string; collec * Cached person info returned to frontend */ export type CachedPerson = { id: string; serverId: string; name: string; overview: string | null; primaryImageTag: string | null; premiereDate: string | null; endDate: string | null } +export type CatalogSyncResult = { +/** + * Total items persisted to the offline cache across all libraries. + */ +itemsCached: number; +/** + * Libraries that failed to sync (e.g. server hiccup); best-effort. + */ +librariesFailed: number } +export type CatalogSyncStatus = { +/** + * RFC-3339 timestamp of the last successful sync, if any. + */ +lastSyncedAt: string | null } /** * Connectivity status */ @@ -2066,6 +2112,15 @@ export type RemoteSessionStatus = { position: number; duration: number | null; i * TRACES: UR-005 | DR-005 */ export type RepeatMode = "off" | "all" | "one" +export type ResumeQueuedResult = { +/** + * Rows whose stream URL was resolved and are now pump-eligible. + */ +resolved: number; +/** + * Rows that couldn't be resolved (item metadata / URL lookup failed). + */ +failed: number } /** * Options for search queries */ diff --git a/src/lib/components/library/MediaCard.serverOnly.test.ts b/src/lib/components/library/MediaCard.serverOnly.test.ts new file mode 100644 index 0000000..193cb8f --- /dev/null +++ b/src/lib/components/library/MediaCard.serverOnly.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/svelte"; + +// Controllable stores for the offline "server only" branch. Declared via +// vi.hoisted so they exist when the hoisted vi.mock factories run. A tiny +// writable shim avoids importing svelte inside the hoisted block. +const h = vi.hoisted(() => { + function shim(initial: T) { + let value = initial; + const subs = new Set<(v: T) => void>(); + return { + set(v: T) { + value = v; + subs.forEach((fn) => fn(value)); + }, + subscribe(fn: (v: T) => void) { + subs.add(fn); + fn(value); + return () => subs.delete(fn); + }, + }; + } + return { + isConnectedStore: shim(true), + showServerCatalogStore: shim(false), + downloadsStore: shim({ downloads: {} as Record }), + downloadItem: vi.fn(async () => 1), + getUserId: vi.fn(() => "user-1"), + }; +}); + +const { isConnectedStore, showServerCatalogStore, downloadsStore, downloadItem, getUserId } = h; + +vi.mock("$lib/stores/connectivity", () => ({ + isConnected: { subscribe: h.isConnectedStore.subscribe }, +})); + +vi.mock("$lib/services/offlineCatalog", () => ({ + showServerCatalog: { subscribe: h.showServerCatalogStore.subscribe }, +})); + +vi.mock("$lib/stores/downloads", () => ({ + downloads: { subscribe: h.downloadsStore.subscribe, downloadItem: h.downloadItem }, +})); + +vi.mock("$lib/stores/auth", () => ({ + auth: { getUserId: h.getUserId }, +})); + +// CachedImage does async repo/image work irrelevant to these tests. +vi.mock("$lib/components/common/CachedImage.svelte", async () => ({ + default: (await import("./__mocks__/StubImage.svelte")).default, +})); + +import MediaCard from "./MediaCard.svelte"; + +const track = { + id: "track-1", + name: "Some Song", + type: "Audio" as const, + serverId: "server-1", + artists: ["Artist A"], + albumName: "Album X", +}; + +describe("MediaCard server-only (offline browse & queue)", () => { + beforeEach(() => { + vi.clearAllMocks(); + isConnectedStore.set(true); + showServerCatalogStore.set(false); + downloadsStore.set({ downloads: {} }); + }); + + it("shows no queue button while online", () => { + render(MediaCard, { props: { item: track } }); + expect(screen.queryByLabelText(/Queue download/i)).toBeNull(); + }); + + it("shows no queue button when offline but the reveal toggle is off", () => { + isConnectedStore.set(false); + render(MediaCard, { props: { item: track } }); + expect(screen.queryByLabelText(/Queue download/i)).toBeNull(); + }); + + it("reveals a queue button when offline and the reveal toggle is on", () => { + isConnectedStore.set(false); + showServerCatalogStore.set(true); + render(MediaCard, { props: { item: track } }); + expect(screen.getByLabelText(/Queue download for Some Song/i)).toBeTruthy(); + }); + + it("queues the item for download with its metadata on click", async () => { + isConnectedStore.set(false); + showServerCatalogStore.set(true); + render(MediaCard, { props: { item: track } }); + + await fireEvent.click(screen.getByLabelText(/Queue download for Some Song/i)); + + expect(downloadItem).toHaveBeenCalledTimes(1); + const args = downloadItem.mock.calls[0] as unknown as any[]; + expect(args[0]).toBe("track-1"); // itemId + expect(args[1]).toBe("user-1"); // userId + expect(args[5]).toBe("Some Song"); // itemName + expect(args[6]).toBe("Artist A"); // artistName + expect(args[7]).toBe("Album X"); // albumName + }); + + it("shows a Queued badge (not the button) for a pending download", () => { + isConnectedStore.set(false); + showServerCatalogStore.set(true); + downloadsStore.set({ + downloads: { "track-1": { itemId: "track-1", status: "pending", progress: 0 } }, + }); + render(MediaCard, { props: { item: track } }); + + expect(screen.getByText(/Queued/i)).toBeTruthy(); + expect(screen.queryByLabelText(/Queue download/i)).toBeNull(); + }); + + it("does not grey out a completed download", () => { + isConnectedStore.set(false); + showServerCatalogStore.set(true); + downloadsStore.set({ + downloads: { "track-1": { itemId: "track-1", status: "completed", progress: 1 } }, + }); + render(MediaCard, { props: { item: track } }); + + expect(screen.queryByLabelText(/Queue download/i)).toBeNull(); + }); +}); diff --git a/src/lib/components/library/MediaCard.svelte b/src/lib/components/library/MediaCard.svelte index 36bc4a2..aa0a518 100644 --- a/src/lib/components/library/MediaCard.svelte +++ b/src/lib/components/library/MediaCard.svelte @@ -2,6 +2,9 @@ import type { MediaItem, Library } from "$lib/api/types"; import { truncateMiddle } from "$lib/utils/truncateMiddle"; import { downloads } from "$lib/stores/downloads"; + import { isConnected } from "$lib/stores/connectivity"; + import { showServerCatalog } from "$lib/services/offlineCatalog"; + import { auth } from "$lib/stores/auth"; import CachedImage from "$lib/components/common/CachedImage.svelte"; interface Props { @@ -25,6 +28,55 @@ ); const downloadProgress = $derived(downloadInfo?.progress || 0); + // A media item (not a library) is "server only" when it exists in the cache + // but isn't downloaded/downloading — and we're offline with the reveal toggle + // on. Such cards render greyed out with a "queue for download" button and are + // inert to tap (nothing to play offline). + const isMediaItem = $derived("type" in item); + const isQueued = $derived(downloadInfo?.status === "pending"); + // Actively transferring (as opposed to merely queued/pending for reconnect). + const isActivelyDownloading = $derived(downloadInfo?.status === "downloading"); + // "Server only" = offline, reveal on, and not already downloaded or actively + // transferring. A `pending` (queued-for-reconnect) item stays server-only so + // it can show the Queued badge in place of the queue button. + const isServerOnly = $derived( + isMediaItem && !$isConnected && $showServerCatalog && !isDownloaded && !isActivelyDownloading + ); + + let queueError = $state(null); + + // Queue this item for download on next reconnect. Offline, this just persists + // a `pending` downloads row (no stream_url); the reconnect handler resolves + // the URL and the pump starts it. See offlineCatalog service. + async function queueForDownload(e: Event) { + e.stopPropagation(); + if (!isMediaItem) return; + const media = item as MediaItem; + const userId = auth.getUserId(); + if (!userId) { + queueError = "Not signed in"; + return; + } + try { + queueError = null; + // Derive a sensible on-disk path; the backend heals stream_url on reconnect. + const filePath = `downloads/${media.id}`; + await downloads.downloadItem( + media.id, + userId, + filePath, + undefined, + undefined, + media.name, + media.artists?.join(", ") ?? undefined, + media.albumName ?? undefined + ); + } catch (err) { + console.error("[MediaCard] Failed to queue download:", err); + queueError = "Failed to queue"; + } + } + const sizeClasses = { small: "w-24", medium: "w-36", @@ -76,10 +128,12 @@ }); - + {/if} + + {#if queueError} +
+ {queueError} +
+ {/if} + {/if} -
+

{truncateMiddle(item.name, 40)}

@@ -173,4 +262,4 @@

{subtitle()}

{/if}
- + diff --git a/src/lib/components/library/__mocks__/StubImage.svelte b/src/lib/components/library/__mocks__/StubImage.svelte new file mode 100644 index 0000000..5a1ad3f --- /dev/null +++ b/src/lib/components/library/__mocks__/StubImage.svelte @@ -0,0 +1,7 @@ + + +
diff --git a/src/lib/services/offlineCatalog.ts b/src/lib/services/offlineCatalog.ts new file mode 100644 index 0000000..ea6d6ef --- /dev/null +++ b/src/lib/services/offlineCatalog.ts @@ -0,0 +1,102 @@ +// Offline catalog service - "browse & queue" for offline mode. +// +// Two responsibilities: +// 1. Full-catalog pre-sync: while online, walk every library and persist all +// items to the offline cache so the whole catalog is browsable (greyed out) +// offline. Backed by the `syncFullCatalog` Rust command. +// 2. Resume-on-reconnect: when the server becomes reachable again, resolve the +// stream URLs of downloads that were queued while offline and let the pump +// start them. Backed by `resumeQueuedDownloads`. +// +// It also owns the `showServerCatalog` UI flag (the offline banner toggle that +// reveals greyed-out, non-downloaded server media). +// +// TRACES: UR-002 + +import { writable, type Writable } from "svelte/store"; +import { commands } from "$lib/api/bindings"; +import { auth } from "$lib/stores/auth"; + +/** + * When true (and offline), library grids reveal greyed-out versions of media + * that exists on the server but isn't downloaded, each with a button to queue + * a download for the next reconnect. Toggled from the offline banner. + */ +export const showServerCatalog: Writable = writable(false); + +/** Last time a full catalog sync completed, for a UI hint. */ +export const lastCatalogSync: Writable = writable(null); + +// Guard against overlapping syncs (they can be slow on large libraries). +let syncInProgress = false; + +function currentHandle(): string | null { + try { + return auth.getRepository().getHandle(); + } catch { + return null; // not connected yet + } +} + +/** + * Walk every library and cache the full catalog. Best-effort and non-blocking: + * safe to call on startup (while online) and on reconnect. No-ops if not + * connected or a sync is already running. + */ +export async function syncCatalog(): Promise { + if (syncInProgress) return; + const handle = currentHandle(); + if (!handle) return; + + syncInProgress = true; + try { + const result = await commands.syncFullCatalog(handle); + console.info( + `[OfflineCatalog] Synced ${result.itemsCached} items (${result.librariesFailed} libraries failed)` + ); + await refreshSyncStatus(); + } catch (err) { + console.warn("[OfflineCatalog] Full catalog sync failed:", err); + } finally { + syncInProgress = false; + } +} + +/** + * Resolve URLs for downloads queued while offline and pump them. Call on + * reconnect. No-ops if not connected. + */ +export async function resumeQueued(): Promise { + const handle = currentHandle(); + if (!handle) return; + try { + const result = await commands.resumeQueuedDownloads(handle); + if (result.resolved > 0 || result.failed > 0) { + console.info( + `[OfflineCatalog] Resumed queued downloads: ${result.resolved} resolved, ${result.failed} failed` + ); + } + } catch (err) { + console.warn("[OfflineCatalog] Failed to resume queued downloads:", err); + } +} + +/** Refresh the last-synced timestamp from the backend. */ +export async function refreshSyncStatus(): Promise { + try { + const status = await commands.catalogSyncStatus(); + lastCatalogSync.set(status.lastSyncedAt ?? null); + } catch (err) { + console.debug("[OfflineCatalog] Failed to fetch sync status:", err); + } +} + +/** + * Called when the server becomes reachable again: resume queued downloads first + * (fast, user-visible), then refresh the catalog in the background. + */ +export async function onReconnected(): Promise { + await resumeQueued(); + // Fire-and-forget: don't block reconnection handling on a potentially long walk. + void syncCatalog(); +} diff --git a/src/lib/stores/auth.ts b/src/lib/stores/auth.ts index 900be52..87da2d2 100644 --- a/src/lib/stores/auth.ts +++ b/src/lib/stores/auth.ts @@ -201,6 +201,11 @@ function createAuthStore() { onServerReconnected: () => { // Retry session verification when server becomes reachable retryVerification(); + // Resume downloads queued while offline, then refresh the catalog. + // Lazy import to avoid an auth <-> offlineCatalog import cycle. + import("$lib/services/offlineCatalog") + .then((m) => m.onReconnected()) + .catch((err) => console.warn("[Auth] Catalog reconnect failed:", err)); }, }).catch((error) => { console.error("[Auth] Failed to start connectivity monitoring:", error); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 9d57b99..2299594 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,5 +1,6 @@