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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user