diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f554d32..ca471e0 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2028,6 +2028,7 @@ dependencies = [ "tokio", "tokio-rusqlite", "tokio-util", + "urlencoding", "uuid", ] @@ -4959,6 +4960,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "urlpattern" version = "0.3.0" @@ -5281,7 +5288,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e833cee..1391c14 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -33,6 +33,7 @@ rand = "0.8" tokio = { version = "1", features = ["sync", "rt-multi-thread", "time", "fs", "io-util", "macros"] } tokio-util = "0.7" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] } +urlencoding = "2" futures-util = "0.3" async-trait = "0.1" diff --git a/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlayer.kt b/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlayer.kt index f7c76c8..9852686 100644 --- a/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlayer.kt +++ b/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlayer.kt @@ -171,6 +171,12 @@ class JellyTauPlayer(private val appContext: Context) { // Create ExoPlayer with audio focus handling exoPlayer = ExoPlayer.Builder(appContext) .setAudioAttributes(audioAttributes, /* handleAudioFocus= */ true) + // Pause when the audio output is removed (wired headphones unplugged or + // Bluetooth device disconnected). ExoPlayer listens for the system + // ACTION_AUDIO_BECOMING_NOISY broadcast, which fires for both cases. + // The resulting pause flows through onIsPlayingChanged, keeping Rust and + // the lockscreen notification in sync automatically. + .setHandleAudioBecomingNoisy(true) .build() // Set up player listener diff --git a/src-tauri/src/commands/download/mod.rs b/src-tauri/src/commands/download/mod.rs index 38f15cf..5684d8b 100644 --- a/src-tauri/src/commands/download/mod.rs +++ b/src-tauri/src/commands/download/mod.rs @@ -2,8 +2,9 @@ #[cfg(test)] use crate::utils::lock::MutexSafe; +use std::path::PathBuf; use std::sync::{Arc, Mutex}; -use tauri::State; +use tauri::{Manager, State}; use log::{debug, error, info, warn}; use crate::download::{DownloadInfo, DownloadManager}; @@ -818,9 +819,7 @@ pub async fn start_download( stream_url: String, target_dir: String, ) -> Result<(), String> { - use crate::download::{DownloadTask, DownloadWorker}; use crate::download::events::DownloadEvent; - use std::path::PathBuf; use tauri::Emitter; debug!("start_download called for download_id: {}", download_id); @@ -906,16 +905,27 @@ pub async fn start_download( } }; - // Update status to downloading and save file_size if we got it + // Update status to downloading and save file_size if we got it. + // Also persist the resolved stream URL + target dir so the queue pump can + // restart/resume this download by itself if needed. let update_query = if let Some(size) = file_size_from_server { Query::with_params( - "UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP, file_size = ? WHERE id = ?", - vec![QueryParam::Int64(size), QueryParam::Int64(download_id)], + "UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP, file_size = ?, stream_url = ?, target_dir = ? WHERE id = ?", + vec![ + QueryParam::Int64(size), + QueryParam::String(stream_url.clone()), + QueryParam::String(target_dir.clone()), + QueryParam::Int64(download_id), + ], ) } else { Query::with_params( - "UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP WHERE id = ?", - vec![QueryParam::Int64(download_id)], + "UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP, stream_url = ?, target_dir = ? WHERE id = ?", + vec![ + QueryParam::String(stream_url.clone()), + QueryParam::String(target_dir.clone()), + QueryParam::Int64(download_id), + ], ) }; @@ -936,29 +946,304 @@ pub async fn start_download( // Build target path let target_path = PathBuf::from(&target_dir).join(&file_path); - // Create download task - let task = DownloadTask { - url: stream_url, - target_path: target_path.clone(), - }; - - // Start download in background - let app_clone = app.clone(); - let item_id_clone = item_id.clone(); - // Get a clone of the active downloads Arc for unregistering later let active_downloads = { let manager = download_manager.0.lock().map_err(|e| e.to_string())?; manager.get_active_downloads() }; + // Run the worker in the background; on completion/failure it frees the slot + // and pumps the next pending download. + spawn_download_worker( + app.clone(), + download_id, + item_id, + stream_url, + target_path, + active_downloads, + ); + + Ok(()) +} + +/// Enqueue a download with its resolved stream URL, then let the queue pump +/// start it (or a higher-priority pending item) when a slot is free. +/// +/// Unlike [`start_download`], this never errors when the concurrency limit is +/// reached: the URL is persisted on the row and the pump will pick it up once a +/// slot frees. This is the path bulk operations (album/series/season) use so +/// every queued item eventually downloads without the frontend re-issuing it. +#[tauri::command] +#[specta::specta] +pub async fn enqueue_download( + db: State<'_, DatabaseWrapper>, + download_manager: State<'_, DownloadManagerWrapper>, + app: tauri::AppHandle, + download_id: i64, + stream_url: String, + target_dir: String, +) -> Result<(), String> { + let db_service = { + let database = db.0.lock().map_err(|e| e.to_string())?; + Arc::new(database.service()) + }; + + // Persist the resolved URL/dir and mark the row pending so the pump can + // start it. We don't flip to 'downloading' here — the pump owns that. + let update_query = Query::with_params( + "UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?", + vec![ + QueryParam::String(stream_url), + QueryParam::String(target_dir), + QueryParam::Int64(download_id), + ], + ); + db_service.execute(update_query).await.map_err(|e| e.to_string())?; + + // Kick the pump: it will start as many pending downloads as there are slots. + 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; + + Ok(()) +} + +/// Enqueue a batch of already-queued video downloads, resolving each one's +/// transcode URL from the repository using the `quality_preset` stored on the +/// row. Then let the pump start them subject to the concurrency limit. +/// +/// This is the bulk video path (series/season): `download_series`/ +/// `download_season` insert the rows, then this resolves URLs and enqueues them +/// so they actually start. Resolving server-side avoids round-tripping every +/// episode URL through the frontend. +#[tauri::command] +#[specta::specta] +pub async fn enqueue_video_downloads( + db: State<'_, DatabaseWrapper>, + download_manager: State<'_, DownloadManagerWrapper>, + repository: State<'_, crate::commands::repository::RepositoryManagerWrapper>, + app: tauri::AppHandle, + handle: String, + download_ids: Vec, + target_dir: String, +) -> Result<(), String> { + 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()) + }; + + for download_id in download_ids { + // Read the item + quality preset for this queued download. + let info_query = Query::with_params( + "SELECT item_id, COALESCE(quality_preset, 'original') FROM downloads WHERE id = ?", + vec![QueryParam::Int64(download_id)], + ); + let (item_id, quality): (String, String) = match db_service + .query_one(info_query, |row| Ok((row.get(0)?, row.get(1)?))) + .await + { + Ok(row) => row, + Err(e) => { + warn!("[enqueue_video] Skipping download {}: {}", download_id, e); + continue; + } + }; + + // Build the transcode URL (pure URL builder, no server round-trip). + let stream_url = repo.as_ref().get_video_download_url(&item_id, &quality, None); + + let update_query = Query::with_params( + "UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?", + vec![ + QueryParam::String(stream_url), + QueryParam::String(target_dir.clone()), + QueryParam::Int64(download_id), + ], + ); + if let Err(e) = db_service.execute(update_query).await { + warn!("[enqueue_video] Failed to persist URL for download {}: {}", download_id, e); + } + } + + // Pump once: starts up to max_concurrent, the rest drain as slots free. + 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; + + Ok(()) +} + +/// Start as many pending downloads as there are free concurrency slots. +/// +/// Picks the highest-priority `pending` rows that have a persisted `stream_url` +/// (FIFO within a priority), registers each, flips it to `downloading`, and +/// spawns a worker. Each spawned worker calls this again on completion/failure, +/// so the queue drains itself without any frontend involvement. +async fn pump_download_queue( + app: tauri::AppHandle, + db_service: Arc, + active_downloads: Arc>>, +) { + use crate::download::events::DownloadEvent; + use tauri::Emitter; + + let max_concurrent = { + let manager = app.state::(); + let manager = match manager.0.lock() { + Ok(m) => m, + Err(e) => { + error!("[pump] Failed to lock download manager: {}", e); + return; + } + }; + manager.max_concurrent() + }; + + loop { + // How many slots are free right now? + let free_slots = { + let active = match active_downloads.lock() { + Ok(a) => a, + Err(e) => { + error!("[pump] Failed to lock active downloads: {}", e); + return; + } + }; + max_concurrent.saturating_sub(active.len()) + }; + if free_slots == 0 { + return; + } + + // Find the next pending, startable download (has a stream URL). Exclude + // anything already registered as active to avoid double-starting. + let next_query = Query::with_params( + "SELECT id, item_id, file_path, stream_url, target_dir + FROM downloads + WHERE status = 'pending' + AND stream_url IS NOT NULL + AND target_dir IS NOT NULL + ORDER BY priority DESC, queued_at ASC", + vec![], + ); + + let candidates: Vec<(i64, String, String, String, String)> = match db_service + .query_many(next_query, |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)) + }) + .await + { + Ok(rows) => rows, + Err(e) => { + error!("[pump] Failed to query pending downloads: {}", e); + return; + } + }; + + // Pick the first candidate not already active. + let next = candidates.into_iter().find(|(id, _, _, _, _)| { + active_downloads + .lock() + .map(|active| !active.contains(id)) + .unwrap_or(false) + }); + + let (download_id, item_id, file_path, stream_url, target_dir) = match next { + Some(n) => n, + None => return, // Nothing pending to start + }; + + // Register the slot. If registration fails (race: another pump filled + // the last slot), stop — we'll be re-pumped when a slot frees. + { + let manager = app.state::(); + let manager = match manager.0.lock() { + Ok(m) => m, + Err(e) => { + error!("[pump] Failed to lock download manager: {}", e); + return; + } + }; + if !manager.register_download(download_id) { + return; + } + info!( + "[pump] Download {} started. Active downloads: {}/{}", + download_id, + manager.active_count(), + manager.max_concurrent() + ); + } + + // Mark as downloading and stamp started_at. + let update_query = Query::with_params( + "UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP WHERE id = ?", + vec![QueryParam::Int64(download_id)], + ); + if let Err(e) = db_service.execute(update_query).await { + error!("[pump] Failed to mark download {} downloading: {}", download_id, e); + if let Ok(mut a) = active_downloads.lock() { + a.remove(&download_id); + } + continue; + } + + // Emit started event so the UI flips the row. + let _ = app.emit( + "download-event", + DownloadEvent::Started { + download_id, + item_id: item_id.clone(), + }, + ); + + let target_path = PathBuf::from(&target_dir).join(&file_path); + spawn_download_worker( + app.clone(), + download_id, + item_id, + stream_url, + target_path, + active_downloads.clone(), + ); + } +} + +/// Spawn the background worker for one download. On completion or failure it +/// unregisters the slot, emits the terminal event, and pumps the queue so the +/// next pending download starts automatically. +fn spawn_download_worker( + app: tauri::AppHandle, + download_id: i64, + item_id: String, + stream_url: String, + target_path: std::path::PathBuf, + active_downloads: Arc>>, +) { + use crate::download::{DownloadTask, DownloadWorker}; + use crate::download::events::DownloadEvent; + use tauri::Emitter; + + let task = DownloadTask { + url: stream_url, + target_path: target_path.clone(), + }; + tauri::async_runtime::spawn(async move { debug!("Download task started for download_id: {}", download_id); let worker = DownloadWorker::new(); // Progress callback that emits events to the frontend - let progress_app = app_clone.clone(); - let progress_item_id = item_id_clone.clone(); + let progress_app = app.clone(); + let progress_item_id = item_id.clone(); let on_progress = move |bytes_downloaded: u64, total_bytes: Option| { let progress = total_bytes .filter(|&t| t > 0) @@ -975,54 +1260,55 @@ pub async fn start_download( let _ = progress_app.emit("download-event", event); }; - match worker.download(&task, on_progress).await { - Ok(result) => { - info!("Download completed successfully: {} bytes", result.bytes_downloaded); + let result = worker.download(&task, on_progress).await; - // Unregister from download manager - if let Ok(mut active) = active_downloads.lock() { - active.remove(&download_id); - debug!(" Unregistered download {}. Active downloads: {}", download_id, active.len()); - } + // Free the slot before pumping so the next download can take it. + if let Ok(mut active) = active_downloads.lock() { + active.remove(&download_id); + debug!(" Unregistered download {}. Active downloads: {}", download_id, active.len()); + } - // Emit completed event - the frontend will handle state updates + match result { + Ok(res) => { + info!("Download completed successfully: {} bytes", res.bytes_downloaded); let completed_event = DownloadEvent::Completed { download_id, - item_id: item_id_clone, + item_id, file_path: target_path.to_string_lossy().to_string(), }; - debug!("Emitting completed event: {:?}", completed_event); - debug!(" Serialized: {}", serde_json::to_string(&completed_event).unwrap_or_default()); - match app_clone.emit("download-event", completed_event) { + match app.emit("download-event", completed_event) { Ok(_) => debug!(" Completed event emitted successfully"), Err(e) => error!(" Completed event emit failed: {:?}", e), } } Err(e) => { error!("Download failed: {:?}", e); - - // Unregister from download manager - if let Ok(mut active) = active_downloads.lock() { - active.remove(&download_id); - debug!(" Unregistered failed download {}. Active downloads: {}", download_id, active.len()); - } - - // Emit failed event - the frontend will handle state updates let failed_event = DownloadEvent::Failed { download_id, - item_id: item_id_clone.clone(), + item_id, error: e.to_string(), }; - debug!("Emitting failed event: {:?}", failed_event); - match app_clone.emit("download-event", failed_event) { + match app.emit("download-event", failed_event) { Ok(_) => debug!(" Failed event emitted successfully"), Err(e) => error!(" Failed event emit failed: {:?}", e), } } } - }); - Ok(()) + // 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; + }); } /// Delete a completed download diff --git a/src-tauri/src/commands/repository.rs b/src-tauri/src/commands/repository.rs index 843bd96..8557c49 100644 --- a/src-tauri/src/commands/repository.rs +++ b/src-tauri/src/commands/repository.rs @@ -7,8 +7,9 @@ use crate::utils::lock::MutexSafe; use std::collections::HashMap; use std::sync::{Arc, Mutex}; -use log::{debug, error, info}; -use tauri::State; +use log::{debug, error, info, warn}; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter, State}; use uuid::Uuid; use crate::jellyfin::HttpClient; @@ -259,6 +260,21 @@ pub async fn repository_get_resume_movies( .map_err(|e| format!("{:?}", e)) } +/// Get albums the user hasn't listened to recently ("rediscover") +#[tauri::command] +#[specta::specta] +pub async fn repository_get_rediscover_albums( + manager: State<'_, RepositoryManagerWrapper>, + handle: String, + parent_id: Option, + limit: Option, +) -> Result, String> { + let repo = manager.0.get(&handle).ok_or("Repository not found")?; + repo.as_ref().get_rediscover_albums(parent_id.as_deref(), limit) + .await + .map_err(|e| format!("{:?}", e)) +} + /// Get genres for a library #[tauri::command] #[specta::specta] @@ -273,19 +289,73 @@ pub async fn repository_get_genres( .map_err(|e| format!("{:?}", e)) } +/// Tauri event name carrying the merged (cache + server) search results. +pub const SEARCH_EVENT_NAME: &str = "search-event"; + +/// Payload for the deferred, merged search results pushed to the frontend. +/// +/// `request_id` matches the value the frontend passed to `repository_search`, +/// letting it discard updates from queries that have since been superseded. +#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchUpdateEvent { + pub request_id: u32, + pub result: SearchResult, +} + /// Search for items #[tauri::command] #[specta::specta] pub async fn repository_search( + app: AppHandle, manager: State<'_, RepositoryManagerWrapper>, handle: String, query: String, options: Option, + request_id: u32, ) -> Result { let repo = manager.0.get(&handle).ok_or("Repository not found")?; - repo.as_ref().search(&query, options) + + // Phase 1: instant local results from the cache (downloaded content) so the + // UI can render immediately while the server is still being queried. + let cache_result = repo + .search_cache_only(&query, options.clone()) .await - .map_err(|e| format!("{:?}", e)) + .unwrap_or_else(|e| { + debug!("[Search] Cache search miss/timeout: {:?}", e); + SearchResult { + items: Vec::new(), + total_record_count: 0, + } + }); + + // Phase 2: query the live server in the background, merge with the cache, + // and push the union to the frontend via a `search-event`. Tagged with + // `request_id` so the frontend can discard results from superseded queries. + let repo_bg = repo.clone(); + let cache_for_merge = cache_result.clone(); + tauri::async_runtime::spawn(async move { + match repo_bg.search_server_only(&query, options).await { + Ok(server_result) => { + let merged = + HybridRepository::merge_search_results(cache_for_merge, server_result); + let event = SearchUpdateEvent { + request_id, + result: merged, + }; + if let Err(e) = app.emit(SEARCH_EVENT_NAME, &event) { + error!("[Search] Failed to emit search update: {}", e); + } + } + Err(e) => { + // Server failed — the cache results are already on screen, so + // just log. (Offline / unreachable server falls here.) + warn!("[Search] Server search failed, keeping cache results: {:?}", e); + } + } + }); + + Ok(cache_result) } /// Get playback info for an item diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b0015c1..f46329b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -28,7 +28,7 @@ use commands::{ get_download_storage_stats, get_downloads, get_download_manager_stats, set_max_concurrent_downloads, get_smart_cache_stats, update_smart_cache_config, get_smart_cache_config, get_album_recommendations, get_album_affinity_status, - mark_download_completed, mark_download_failed, start_download, + mark_download_completed, mark_download_failed, start_download, enqueue_download, enqueue_video_downloads, pin_item, unpin_item, is_item_pinned, offline_get_items, offline_is_available, offline_search, pause_download, resume_download, player_cycle_repeat, player_get_audio_settings, player_get_queue, player_get_status, @@ -96,6 +96,7 @@ use commands::{ repository_create, repository_destroy, repository_get_libraries, repository_get_items, repository_get_item, repository_get_latest_items, repository_get_resume_items, repository_get_next_up_episodes, repository_get_recently_played_audio, repository_get_resume_movies, + repository_get_rediscover_albums, repository_get_genres, repository_search, repository_get_playback_info, repository_get_video_stream_url, repository_get_audio_stream_url, repository_report_playback_start, repository_report_playback_progress, repository_report_playback_stopped, @@ -493,6 +494,8 @@ fn specta_builder() -> Builder { mark_download_completed, mark_download_failed, start_download, + enqueue_download, + enqueue_video_downloads, get_download_manager_stats, set_max_concurrent_downloads, get_smart_cache_stats, @@ -552,6 +555,7 @@ fn specta_builder() -> Builder { repository_get_next_up_episodes, repository_get_recently_played_audio, repository_get_resume_movies, + repository_get_rediscover_albums, repository_get_genres, repository_search, repository_get_playback_info, diff --git a/src-tauri/src/repository/hybrid.rs b/src-tauri/src/repository/hybrid.rs index 508ee1d..fd72aba 100644 --- a/src-tauri/src/repository/hybrid.rs +++ b/src-tauri/src/repository/hybrid.rs @@ -57,6 +57,81 @@ impl HybridRepository { self.online.get_video_stream_url(item_id, media_source_id, start_time_seconds, audio_stream_index).await } + /// Search only the local SQLite cache (downloaded content). + /// + /// Fast (100ms timeout) — used to render instant results before the server + /// responds. Returns an empty result rather than erroring on timeout so the + /// caller can still fall through to the server. + pub async fn search_cache_only( + &self, + query: &str, + options: Option, + ) -> Result { + let offline = Arc::clone(&self.offline); + let query = query.to_string(); + self.cache_with_timeout(async move { offline.search(&query, options).await }) + .await + } + + /// Search only the live Jellyfin server (full library). + pub async fn search_server_only( + &self, + query: &str, + options: Option, + ) -> Result { + self.online.search(query, options).await + } + + /// Merge cache and server search results into a single de-duplicated list. + /// + /// Ordering: local (cached/downloaded) items first, then server-only items + /// appended. On a duplicate `id`, the server's item wins (fresher, more + /// complete metadata) but keeps the local item's earlier position. + pub fn merge_search_results(cache: SearchResult, server: SearchResult) -> SearchResult { + use std::collections::HashMap; + + // Index server items by id so we can (a) override duplicates with the + // server's metadata and (b) know which server items are brand new. + let mut server_by_id: HashMap = HashMap::new(); + let mut server_order: Vec = Vec::with_capacity(server.items.len()); + for item in server.items { + if !server_by_id.contains_key(&item.id) { + server_order.push(item.id.clone()); + } + server_by_id.insert(item.id.clone(), item); + } + + let mut items: Vec = Vec::new(); + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + + // Local items first, in their original order. If the server also + // returned this item, take the server's copy (newer metadata). + for local in cache.items { + if !seen.insert(local.id.clone()) { + continue; + } + match server_by_id.remove(&local.id) { + Some(server_item) => items.push(server_item), + None => items.push(local), + } + } + + // Then append server-only items, preserving the server's order. + for id in server_order { + if let Some(server_item) = server_by_id.remove(&id) { + if seen.insert(id) { + items.push(server_item); + } + } + } + + let total_record_count = items.len(); + SearchResult { + items, + total_record_count, + } + } + /// Cache-first query: try cache, fall back to server on miss. /// /// 1. Check cache (100ms timeout applied by caller via cache_with_timeout) @@ -274,6 +349,27 @@ impl MediaRepository for HybridRepository { self.parallel_race(cache_future, server_future).await } + async fn get_rediscover_albums( + &self, + parent_id: Option<&str>, + limit: Option, + ) -> Result, RepoError> { + let offline = Arc::clone(&self.offline); + let online = Arc::clone(&self.online); + let parent_id_owned = parent_id.map(|s| s.to_string()); + let parent_id_clone = parent_id_owned.clone(); + + let cache_future = self.cache_with_timeout(async move { + offline.get_rediscover_albums(parent_id_owned.as_deref(), limit).await + }); + + let server_future = async move { + online.get_rediscover_albums(parent_id_clone.as_deref(), limit).await + }; + + self.parallel_race(cache_future, server_future).await + } + async fn get_genres(&self, parent_id: Option<&str>) -> Result, RepoError> { let offline = Arc::clone(&self.offline); let online = Arc::clone(&self.online); @@ -602,6 +698,14 @@ mod tests { unimplemented!() } + async fn get_rediscover_albums( + &self, + _parent_id: Option<&str>, + _limit: Option, + ) -> Result, RepoError> { + unimplemented!() + } + async fn get_genres(&self, _parent_id: Option<&str>) -> Result, RepoError> { unimplemented!() } @@ -759,6 +863,14 @@ mod tests { unimplemented!() } + async fn get_rediscover_albums( + &self, + _parent_id: Option<&str>, + _limit: Option, + ) -> Result, RepoError> { + unimplemented!() + } + async fn get_genres(&self, _parent_id: Option<&str>) -> Result, RepoError> { unimplemented!() } @@ -1058,4 +1170,75 @@ mod tests { }; assert!(result_with_items.has_content(), "Result with items should have content"); } + + #[test] + fn test_merge_search_local_first_then_server_appended() { + let cache = SearchResult { + items: vec![ + create_test_item("a", "Cached A"), + create_test_item("b", "Cached B"), + ], + total_record_count: 2, + }; + let server = SearchResult { + items: vec![ + create_test_item("c", "Server C"), + create_test_item("d", "Server D"), + ], + total_record_count: 2, + }; + + let merged = HybridRepository::merge_search_results(cache, server); + + // Local items first (in order), then server-only items appended. + let ids: Vec<&str> = merged.items.iter().map(|i| i.id.as_str()).collect(); + assert_eq!(ids, vec!["a", "b", "c", "d"]); + assert_eq!(merged.total_record_count, 4); + } + + #[test] + fn test_merge_search_dedupes_with_server_winning() { + // "b" appears in both. Server metadata should win, but the item keeps + // its earlier (local) position and is not duplicated. + let cache = SearchResult { + items: vec![ + create_test_item("a", "Cached A"), + create_test_item("b", "Cached B"), + ], + total_record_count: 2, + }; + let server = SearchResult { + items: vec![ + create_test_item("b", "Server B (fresher)"), + create_test_item("c", "Server C"), + ], + total_record_count: 2, + }; + + let merged = HybridRepository::merge_search_results(cache, server); + + let ids: Vec<&str> = merged.items.iter().map(|i| i.id.as_str()).collect(); + assert_eq!(ids, vec!["a", "b", "c"], "no duplicate, local position kept"); + + let b = merged.items.iter().find(|i| i.id == "b").unwrap(); + assert_eq!(b.name, "Server B (fresher)", "server metadata wins on conflict"); + assert_eq!(merged.total_record_count, 3); + } + + #[test] + fn test_merge_search_handles_empty_sides() { + let only_server = HybridRepository::merge_search_results( + SearchResult { items: vec![], total_record_count: 0 }, + SearchResult { items: vec![create_test_item("x", "X")], total_record_count: 1 }, + ); + assert_eq!(only_server.items.len(), 1); + assert_eq!(only_server.items[0].id, "x"); + + let only_cache = HybridRepository::merge_search_results( + SearchResult { items: vec![create_test_item("y", "Y")], total_record_count: 1 }, + SearchResult { items: vec![], total_record_count: 0 }, + ); + assert_eq!(only_cache.items.len(), 1); + assert_eq!(only_cache.items[0].id, "y"); + } } diff --git a/src-tauri/src/repository/mod.rs b/src-tauri/src/repository/mod.rs index 82e47e3..94ac332 100644 --- a/src-tauri/src/repository/mod.rs +++ b/src-tauri/src/repository/mod.rs @@ -79,6 +79,15 @@ pub trait MediaRepository: Send + Sync { limit: Option, ) -> Result, RepoError>; + /// Get albums the user has played, but not recently ("rediscover" / haven't + /// listened to in a while). Returns albums sorted by least-recently played + /// first, optionally restricted to a parent library. + async fn get_rediscover_albums( + &self, + parent_id: Option<&str>, + limit: Option, + ) -> Result, RepoError>; + /// Get resume movies async fn get_resume_movies(&self, limit: Option) -> Result, RepoError>; diff --git a/src-tauri/src/repository/offline.rs b/src-tauri/src/repository/offline.rs index d1637c6..b1d6594 100644 --- a/src-tauri/src/repository/offline.rs +++ b/src-tauri/src/repository/offline.rs @@ -778,6 +778,17 @@ impl MediaRepository for OfflineRepository { Ok(items) } + async fn get_rediscover_albums( + &self, + _parent_id: Option<&str>, + _limit: Option, + ) -> Result, RepoError> { + // "Rediscover" is a discovery feature over the full server library. + // Offline only holds downloaded items, so there is nothing meaningful + // to surface here; the hybrid repo serves this from the server instead. + Ok(Vec::new()) + } + async fn get_resume_movies(&self, limit: Option) -> Result, RepoError> { let limit_val = limit.unwrap_or(12); diff --git a/src-tauri/src/repository/online.rs b/src-tauri/src/repository/online.rs index 269d6f9..2bbe6e3 100644 --- a/src-tauri/src/repository/online.rs +++ b/src-tauri/src/repository/online.rs @@ -568,6 +568,16 @@ impl MediaRepository for OnlineRepository { if let Some(recursive) = opts.recursive { endpoint.push_str(&format!("&Recursive={}", recursive)); } + if let Some(genres) = opts.genres { + if !genres.is_empty() { + // Genre names may contain spaces/ampersands, so percent-encode each. + let encoded: Vec = genres + .iter() + .map(|g| urlencoding::encode(g).into_owned()) + .collect(); + endpoint.push_str(&format!("&Genres={}", encoded.join("|"))); + } + } } // Request image fields for list views (People only needed in get_item detail view) @@ -759,6 +769,32 @@ impl MediaRepository for OnlineRepository { Ok(final_result) } + async fn get_rediscover_albums( + &self, + parent_id: Option<&str>, + limit: Option, + ) -> Result, RepoError> { + let limit_val = limit.unwrap_or(12); + // Ask Jellyfin for played albums sorted by least-recently played first. + // Filters=IsPlayed keeps only albums the user has actually listened to, + // and SortBy=DatePlayed ascending surfaces the ones they've neglected. + let mut endpoint = format!( + "/Users/{}/Items?SortBy=DatePlayed&SortOrder=Ascending&IncludeItemTypes=MusicAlbum&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags", + self.user_id, limit_val + ); + + if let Some(pid) = parent_id { + endpoint.push_str(&format!("&ParentId={}", pid)); + } + + let response: ItemsResponse = self.get_json(&endpoint).await?; + Ok(response + .items + .into_iter() + .map(|item| item.to_media_item(self.user_id.clone())) + .collect()) + } + async fn get_resume_movies(&self, limit: Option) -> Result, RepoError> { let limit_str = limit.unwrap_or(16); let endpoint = format!( @@ -811,14 +847,24 @@ impl MediaRepository for OnlineRepository { options: Option, ) -> Result { let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(50); + // SearchTerm is arbitrary user input and must be percent-encoded so that + // spaces, ampersands, etc. don't corrupt the query string (a multi-word + // search like "Star Wars" would otherwise produce a malformed URL). let mut endpoint = format!( "/Users/{}/Items?SearchTerm={}&Limit={}&Recursive=true", - self.user_id, query, limit + self.user_id, + urlencoding::encode(query), + limit ); if let Some(opts) = options { if let Some(types) = opts.include_item_types { - endpoint.push_str(&format!("&IncludeItemTypes={}", types.join(","))); + let encoded_types = types + .iter() + .map(|t| urlencoding::encode(t).into_owned()) + .collect::>() + .join(","); + endpoint.push_str(&format!("&IncludeItemTypes={}", encoded_types)); } } @@ -1768,4 +1814,13 @@ mod tests { assert_eq!(response.items[0].id, "item1"); assert_eq!(response.items[1].id, "item2"); } + + #[test] + fn test_search_term_is_url_encoded() { + // A multi-word query (and one with a reserved character) must be + // percent-encoded before being placed in the SearchTerm query param, + // otherwise the request URL is malformed and search returns nothing. + assert_eq!(urlencoding::encode("Star Wars"), "Star%20Wars"); + assert_eq!(urlencoding::encode("Tom & Jerry"), "Tom%20%26%20Jerry"); + } } diff --git a/src-tauri/src/storage/schema.rs b/src-tauri/src/storage/schema.rs index 00a60bc..1e67db9 100644 --- a/src-tauri/src/storage/schema.rs +++ b/src-tauri/src/storage/schema.rs @@ -21,6 +21,7 @@ pub const MIGRATIONS: &[(&str, &str)] = &[ ("014_series_audio_preferences", MIGRATION_014), ("015_device_id", MIGRATION_015), ("016_autoplay_max_episodes", MIGRATION_016), + ("017_downloads_resume_url", MIGRATION_017), ]; /// Initial schema migration @@ -667,3 +668,15 @@ const MIGRATION_016: &str = r#" ALTER TABLE user_player_settings ADD COLUMN autoplay_max_episodes INTEGER DEFAULT 0; "#; + +/// Migration to persist the resolved stream URL and target directory on each +/// download row. This lets the backend queue pump start a pending download by +/// itself (replaying the stored URL) once a concurrency slot frees up, instead +/// of relying on the frontend to re-issue every queued item. +const MIGRATION_017: &str = r#" +-- Resolved download source URL and on-disk target directory, captured when the +-- download is enqueued. Nullable: pre-existing rows and rows enqueued without a +-- URL simply won't be auto-started by the pump. +ALTER TABLE downloads ADD COLUMN stream_url TEXT; +ALTER TABLE downloads ADD COLUMN target_dir TEXT; +"#; diff --git a/src/lib/api/bindings.ts b/src/lib/api/bindings.ts index c90060e..17a3457 100644 --- a/src/lib/api/bindings.ts +++ b/src/lib/api/bindings.ts @@ -726,6 +726,31 @@ async markDownloadFailed(downloadId: number, errorMessage: string) : Promise { return await TAURI_INVOKE("start_download", { downloadId, streamUrl, targetDir }); }, +/** + * Enqueue a download with its resolved stream URL, then let the queue pump + * start it (or a higher-priority pending item) when a slot is free. + * + * Unlike [`start_download`], this never errors when the concurrency limit is + * reached: the URL is persisted on the row and the pump will pick it up once a + * slot frees. This is the path bulk operations (album/series/season) use so + * every queued item eventually downloads without the frontend re-issuing it. + */ +async enqueueDownload(downloadId: number, streamUrl: string, targetDir: string) : Promise { + return await TAURI_INVOKE("enqueue_download", { downloadId, streamUrl, targetDir }); +}, +/** + * Enqueue a batch of already-queued video downloads, resolving each one's + * transcode URL from the repository using the `quality_preset` stored on the + * row. Then let the pump start them subject to the concurrency limit. + * + * This is the bulk video path (series/season): `download_series`/ + * `download_season` insert the rows, then this resolves URLs and enqueues them + * so they actually start. Resolving server-side avoids round-tripping every + * episode URL through the frontend. + */ +async enqueueVideoDownloads(handle: string, downloadIds: number[], targetDir: string) : Promise { + return await TAURI_INVOKE("enqueue_video_downloads", { handle, downloadIds, targetDir }); +}, /** * Get download manager statistics */ @@ -1037,6 +1062,12 @@ async repositoryGetRecentlyPlayedAudio(handle: string, limit: number | null) : P async repositoryGetResumeMovies(handle: string, limit: number | null) : Promise { return await TAURI_INVOKE("repository_get_resume_movies", { handle, limit }); }, +/** + * Get albums the user hasn't listened to recently ("rediscover") + */ +async repositoryGetRediscoverAlbums(handle: string, parentId: string | null, limit: number | null) : Promise { + return await TAURI_INVOKE("repository_get_rediscover_albums", { handle, parentId, limit }); +}, /** * Get genres for a library */ @@ -1046,8 +1077,8 @@ async repositoryGetGenres(handle: string, parentId: string | null) : Promise { - return await TAURI_INVOKE("repository_search", { handle, query, options }); +async repositorySearch(handle: string, query: string, options: SearchOptions | null, requestId: number) : Promise { + return await TAURI_INVOKE("repository_search", { handle, query, options, requestId }); }, /** * Get playback info for an item diff --git a/src/lib/api/repository-client.test.ts b/src/lib/api/repository-client.test.ts index 4fd3bf6..0599d85 100644 --- a/src/lib/api/repository-client.test.ts +++ b/src/lib/api/repository-client.test.ts @@ -334,6 +334,7 @@ describe("RepositoryClient", () => { includeItemTypes: ["Audio"], limit: 100, }, + requestId: 0, }); }); }); diff --git a/src/lib/api/repository-client.ts b/src/lib/api/repository-client.ts index 411f81a..61ef689 100644 --- a/src/lib/api/repository-client.ts +++ b/src/lib/api/repository-client.ts @@ -109,12 +109,17 @@ export class RepositoryClient { return commands.repositoryGetResumeMovies(this.ensureHandle(), limit ?? null); } + /** Albums the user has played but not listened to recently ("rediscover"). */ + async getRediscoverAlbums(parentId?: string, limit?: number): Promise { + return commands.repositoryGetRediscoverAlbums(this.ensureHandle(), parentId ?? null, limit ?? null); + } + async getGenres(parentId?: string): Promise { return commands.repositoryGetGenres(this.ensureHandle(), parentId ?? null); } - async search(query: string, options?: SearchOptions): Promise { - return commands.repositorySearch(this.ensureHandle(), query, options ?? null); + async search(query: string, options?: SearchOptions, requestId = 0): Promise { + return commands.repositorySearch(this.ensureHandle(), query, options ?? null, requestId); } // ===== Playback Methods (all via Rust) ===== diff --git a/src/lib/components/library/AlbumDownloadButton.svelte b/src/lib/components/library/AlbumDownloadButton.svelte index a6695d4..2185045 100644 --- a/src/lib/components/library/AlbumDownloadButton.svelte +++ b/src/lib/components/library/AlbumDownloadButton.svelte @@ -91,15 +91,18 @@ // Get target directory for downloads const targetDir = await commands.storageGetPath(); - // Start each queued track download + // Enqueue each track with its resolved stream URL. The backend queue + // pump starts up to max_concurrent at a time and advances through the + // rest automatically as slots free up — so we never hit (and silently + // drop) the concurrency limit the way startDownload did. for (let i = 0; i < tracks.length && i < downloadIds.length; i++) { try { const streamUrl = await repo.getAudioStreamUrl(tracks[i].id); if (streamUrl) { - await commands.startDownload(downloadIds[i], streamUrl, targetDir); + await commands.enqueueDownload(downloadIds[i], streamUrl, targetDir); } } catch (e) { - console.error(`Failed to start download for track ${tracks[i].id}:`, e); + console.error(`Failed to enqueue download for track ${tracks[i].id}:`, e); } } diff --git a/src/lib/components/library/AlphabetScrollBar.svelte b/src/lib/components/library/AlphabetScrollBar.svelte new file mode 100644 index 0000000..6c7e297 --- /dev/null +++ b/src/lib/components/library/AlphabetScrollBar.svelte @@ -0,0 +1,123 @@ + + + + + + diff --git a/src/lib/components/library/GenericMediaListPage.svelte b/src/lib/components/library/GenericMediaListPage.svelte index bbfa697..5e1960f 100644 --- a/src/lib/components/library/GenericMediaListPage.svelte +++ b/src/lib/components/library/GenericMediaListPage.svelte @@ -4,6 +4,8 @@ import { goto } from "$app/navigation"; import { currentLibrary } from "$lib/stores/library"; import { auth } from "$lib/stores/auth"; + import { shouldShowAudioMiniPlayer } from "$lib/stores/player"; + import { isAndroid } from "$lib/stores/appState"; import SearchBar from "$lib/components/common/SearchBar.svelte"; import SortButtonGroup from "$lib/components/common/SortButtonGroup.svelte"; import type { SortOption } from "$lib/components/common/SortButtonGroup.svelte"; @@ -13,6 +15,8 @@ import type { MediaItem, Library, ItemType } from "$lib/api/types"; import LibraryGrid from "./LibraryGrid.svelte"; import TrackList from "./TrackList.svelte"; + import AlphabetScrollBar from "./AlphabetScrollBar.svelte"; + import { excludePodcasts } from "$lib/utils/podcastFilter"; /** * Generic media list page supporting Albums, Artists, Playlists, and Tracks @@ -41,6 +45,7 @@ let items = $state([]); let loading = $state(true); + let gridWrapper = $state(null); let searchQuery = $state(""); let debouncedSearchQuery = $state(""); let sortBy = $state(""); @@ -74,12 +79,13 @@ const repo = auth.getRepository(); // Use backend search if search query is provided, otherwise use getItems with sort + // HACK: excludePodcasts drops the "Podcasts" folder stored in the music library. if (debouncedSearchQuery.trim()) { const result = await repo.search(debouncedSearchQuery, { includeItemTypes: [config.itemType], limit: 10000, }); - items = result.items; + items = excludePodcasts(result.items); } else { const result = await repo.getItems($currentLibrary.id, { includeItemTypes: [config.itemType], @@ -88,7 +94,7 @@ recursive: true, limit: 10000, }); - items = result.items; + items = excludePodcasts(result.items); } } catch (e) { console.error(`Failed to load ${config.itemType}:`, e); @@ -142,6 +148,54 @@ goto(`/library/${track.id}`); } } + + // ===== A-Z jump bar ===== + // Bucket a name to its index letter: A-Z, or "#" for digits/symbols/empty. + function letterFor(name: string): string { + const first = (name ?? "").trim().charAt(0).toUpperCase(); + return first >= "A" && first <= "Z" ? first : "#"; + } + + // Only meaningful when the list is sorted alphabetically and long enough to scroll. + const isAlphaSorted = $derived(sortBy === "SortName"); + const showAlphaBar = $derived( + isAlphaSorted && + !loading && + !debouncedSearchQuery.trim() && + items.length > 30 + ); + + const availableLetters = $derived.by(() => { + const set = new Set(); + if (showAlphaBar) { + for (const item of items) set.add(letterFor(item.name)); + } + return set; + }); + + // First item index for each letter, honouring current ascending/descending order. + const firstIndexForLetter = $derived.by(() => { + const map = new Map(); + items.forEach((item, index) => { + const letter = letterFor(item.name); + if (!map.has(letter)) map.set(letter, index); + }); + return map; + }); + + function jumpToLetter(letter: string) { + const index = firstIndexForLetter.get(letter); + if (index === undefined || !gridWrapper) return; + const target = gridWrapper.querySelector(`[data-grid-index="${index}"]`); + target?.scrollIntoView({ behavior: "smooth", block: "start" }); + } + + // Bottom space the layout's
reserves for the nav / mini-player bars. + // Mirrors src/routes/library/+layout.svelte so the A-Z strip ends just above + // whichever bars are visible. + const bottomGap = $derived( + $shouldShowAudioMiniPlayer ? ($isAndroid ? "11rem" : "7rem") : "5rem" + );
@@ -192,10 +246,19 @@

No {config.title.toLowerCase()} found

{:else} - {#if config.displayComponent === "grid"} - - {:else if config.displayComponent === "tracklist"} - - {/if} +
+
+ {#if config.displayComponent === "grid"} + + {:else if config.displayComponent === "tracklist"} + + {/if} +
+ {#if showAlphaBar} +
+ +
+ {/if} +
{/if} diff --git a/src/lib/components/library/LibraryGrid.svelte b/src/lib/components/library/LibraryGrid.svelte index 479fc8e..20981b1 100644 --- a/src/lib/components/library/LibraryGrid.svelte +++ b/src/lib/components/library/LibraryGrid.svelte @@ -69,12 +69,14 @@ {:else}
- {#each items as item (item.id)} - onItemClick?.(item)} - /> + {#each items as item, index (item.id)} +
+ onItemClick?.(item)} + /> +
{/each}
{/if} diff --git a/src/lib/components/library/LibraryListView.svelte b/src/lib/components/library/LibraryListView.svelte index 6c028a5..b4bd74a 100644 --- a/src/lib/components/library/LibraryListView.svelte +++ b/src/lib/components/library/LibraryListView.svelte @@ -68,6 +68,7 @@ + + + + {#if heroItems.length > 0} + + {/if} + + + {#if continueWatching.length > 0} + + {/if} + + + {#if recentlyAdded.length > 0} + goto("/library/movies/all")} + /> + {/if} + + + {#each genreRows as row (row.id)} + goto(`/library/movies/genres`)} + /> + {/each} + + {#if !hasContent} +

Nothing here yet. Add some movies to your library to fill this page.

+ {/if} + + +
+

Browse

+
+ {#each categories as category (category.id)} + + {/each} +
+
+ +{/if} diff --git a/src/routes/library/movies/all/+page.svelte b/src/routes/library/movies/all/+page.svelte new file mode 100644 index 0000000..7bcf199 --- /dev/null +++ b/src/routes/library/movies/all/+page.svelte @@ -0,0 +1,27 @@ + + + diff --git a/src/routes/library/music/+page.svelte b/src/routes/library/music/+page.svelte index 360be4c..44a7ddb 100644 --- a/src/routes/library/music/+page.svelte +++ b/src/routes/library/music/+page.svelte @@ -1,8 +1,15 @@ + -
- -
-
-

Music Library

-

Choose a category to browse

-
- +{#if isLoading} +
+
- - -
- {#each categories as category (category.id)} +{:else} +
+ +
+

Music

+
- -
- -
- -
- + + {#if heroItems.length > 0} + + {/if} + + + {#if recentlyPlayed.length > 0} + + {/if} + + + {#if playlists.length > 0} + goto("/library/music/playlists")} + /> + {/if} + + + {#if newlyAdded.length > 0} + goto("/library/music/albums")} + /> + {/if} + + + {#if rediscover.length > 0} + + {/if} + + {#if !hasContent} +

Nothing here yet. Start playing some music to fill this page.

+ {/if} + + +
+

Browse

+
+ {#each categories as category (category.id)} +
- - -
- Browse - - - -
-
- - {/each} +
+
{category.name}
+
{category.description}
+
+ + {/each} +
+
-
+{/if} diff --git a/src/routes/library/tv/+page.svelte b/src/routes/library/tv/+page.svelte new file mode 100644 index 0000000..a2ba87b --- /dev/null +++ b/src/routes/library/tv/+page.svelte @@ -0,0 +1,176 @@ + + + +{#if isLoading} +
+
+
+{:else} +
+ +
+

{$currentLibrary?.name ?? "TV Shows"}

+ +
+ + + {#if heroItems.length > 0} + + {/if} + + + {#if continueWatching.length > 0} + + {/if} + + + {#if nextUp.length > 0} + + {/if} + + + {#if recentlyAdded.length > 0} + goto("/library/tv/shows")} + /> + {/if} + + + {#each genreRows as row (row.id)} + goto(`/library/shows/genres`)} + /> + {/each} + + {#if !hasContent} +

Nothing here yet. Start watching something to fill this page.

+ {/if} + + +
+

Browse

+
+ {#each categories as category (category.id)} + + {/each} +
+
+
+{/if} diff --git a/src/routes/library/tv/shows/+page.svelte b/src/routes/library/tv/shows/+page.svelte new file mode 100644 index 0000000..e81a7b0 --- /dev/null +++ b/src/routes/library/tv/shows/+page.svelte @@ -0,0 +1,27 @@ + + +