feat(library): focused music/TV/movie landing screens + self-draining download queue
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 9m49s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 25s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 22m33s

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:
2026-06-24 20:44:17 +02:00
parent dcf08f30bc
commit 17a35573a0
33 changed files with 2045 additions and 188 deletions
+332 -46
View File
@@ -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
+74 -4
View File
@@ -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<String>,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, 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<SearchOptions>,
request_id: u32,
) -> Result<SearchResult, String> {
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
+5 -1
View File
@@ -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<tauri::Wry> {
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<tauri::Wry> {
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,
+183
View File
@@ -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<SearchOptions>,
) -> Result<SearchResult, RepoError> {
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<SearchOptions>,
) -> Result<SearchResult, RepoError> {
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<String, MediaItem> = HashMap::new();
let mut server_order: Vec<String> = 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<MediaItem> = Vec::new();
let mut seen: std::collections::HashSet<String> = 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<usize>,
) -> Result<Vec<MediaItem>, 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<Vec<Genre>, 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<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
unimplemented!()
}
async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
unimplemented!()
}
@@ -759,6 +863,14 @@ mod tests {
unimplemented!()
}
async fn get_rediscover_albums(
&self,
_parent_id: Option<&str>,
_limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
unimplemented!()
}
async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, 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");
}
}
+9
View File
@@ -79,6 +79,15 @@ pub trait MediaRepository: Send + Sync {
limit: Option<usize>,
) -> Result<Vec<MediaItem>, 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<usize>,
) -> Result<Vec<MediaItem>, RepoError>;
/// Get resume movies
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError>;
+11
View File
@@ -778,6 +778,17 @@ impl MediaRepository for OfflineRepository {
Ok(items)
}
async fn get_rediscover_albums(
&self,
_parent_id: Option<&str>,
_limit: Option<usize>,
) -> Result<Vec<MediaItem>, 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<usize>) -> Result<Vec<MediaItem>, RepoError> {
let limit_val = limit.unwrap_or(12);
+57 -2
View File
@@ -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<String> = 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<usize>,
) -> Result<Vec<MediaItem>, 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<usize>) -> Result<Vec<MediaItem>, RepoError> {
let limit_str = limit.unwrap_or(16);
let endpoint = format!(
@@ -811,14 +847,24 @@ impl MediaRepository for OnlineRepository {
options: Option<SearchOptions>,
) -> Result<SearchResult, RepoError> {
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::<Vec<_>>()
.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");
}
}
+13
View File
@@ -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;
"#;