feat(library): focused music/TV/movie landing screens + self-draining download queue
Library screens:
- Add dedicated music, TV, and movie landing pages (hero banner +
horizontal carousels) backed by new music/tv/movies stores.
- Route tvshows libraries to /library/tv; surface rediscover ("haven't
listened to in a while") albums via a new repository method across
online/offline/hybrid repos plus the repository_get_rediscover_albums
command.
- Add an A-Z jump bar for long alphabetically-sorted lists, with grid
index anchors in LibraryGrid/LibraryListView/TrackList.
- Filter the "Podcasts" folder out of music library queries.
Downloads:
- Add a backend queue pump: enqueue_download / enqueue_video_downloads
persist the resolved stream URL + target dir on each row (migration
017), and the pump starts up to max_concurrent and drains the rest
automatically as slots free, instead of the frontend silently dropping
items past the concurrency limit. Album/series/season buttons now
enqueue rather than calling start_download directly.
Other fixes:
- Hybrid search now returns instant cache results and pushes the merged
cache+server union via a request-id-tagged search-event, so superseded
queries can't clobber fresher results.
- URL-encode SearchTerm / genres / item types in online repo requests.
- Android: pause on audio-becoming-noisy (headphone/BT disconnect).
This commit is contained in:
@@ -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<i64>,
|
||||
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<crate::storage::db_service::RusqliteService>,
|
||||
active_downloads: Arc<Mutex<std::collections::HashSet<i64>>>,
|
||||
) {
|
||||
use crate::download::events::DownloadEvent;
|
||||
use tauri::Emitter;
|
||||
|
||||
let max_concurrent = {
|
||||
let manager = app.state::<DownloadManagerWrapper>();
|
||||
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::<DownloadManagerWrapper>();
|
||||
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<Mutex<std::collections::HashSet<i64>>>,
|
||||
) {
|
||||
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<u64>| {
|
||||
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::<DatabaseWrapper>();
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user