feat(search): answer search from a local index; tier downloads by lifetime

Search's instant leg read only downloaded items, so with no downloads it
returned nothing and every keystroke fell through to a full Recursive=true
server query. It now reads the whole synced catalog through the same
availability CTE get_items uses, gated on the same include_catalog_browse
flag so search and browse cannot diverge. (UR-065, DR-108)

Also fixes three defects found while confirming that:

- items_fts grew by a full duplicate index every catalog pass. INSERT OR
  REPLACE fires no AFTER DELETE trigger without recursive_triggers, so the
  old index row was orphaned, and a TEXT PRIMARY KEY meant the replacement
  took a fresh rowid and inserted a second entry. Now a real upsert, with
  migration 021 rebuilding existing indexes. (DR-110)
- DELETE FROM items existed nowhere, so server-side deletions never
  propagated. Adds a post-crawl mark-and-sweep, scoped to crawled types,
  skipping downloaded items, and refusing to run after a partial crawl
  because items.parent_id cascades. (DR-110)
- The index omitted MusicArtist, Playlist and People, which search groups
  results by. Adds them plus people_fts (migration 022). (DR-111)

Re-indexing moves from a frontend startup call to a Rust background task
with a 6h TTL, so a long session no longer searches a stale catalog and a
restart no longer forces a crawl regardless of freshness. (DR-109, IR-030)

Downloads gain a lifetime tier. Eviction selected every completed row by
age with no download_source filter, so hitting the storage limit deleted
the oldest download -- typically one saved deliberately for offline -- to
make room for a precached track. It now reclaims only 'auto' rows, and
expired ones are reclaimed first, before live cache is evicted.
(DR-126, DR-127)

Downloaded video and audio-only handoffs now play from disk instead of
streaming; the video path had never consulted downloads at all. No
transcode is involved: MPV runs video=no and ExoPlayer has no surface for
an Audio item. (DR-123 in part, DR-128)

FTS queries are built as quoted phrases so apostrophes, hyphens and
slashes are data rather than operator syntax, and the item-type filter is
bound rather than interpolated.

Specs: docs/specs/catalog-index-search.md,
docs/specs/read-through-media-cache.md

Includes concurrently-developed favourites browsing and background-audio
stream-end handling; the two workstreams share offline.rs, lib.rs and
online.rs, so no subset of files builds independently.
This commit is contained in:
2026-08-04 17:35:17 +02:00
parent c55ff45692
commit 62873cab3d
52 changed files with 6110 additions and 191 deletions
+109
View File
@@ -41,12 +41,34 @@ impl HybridRepository {
}
}
/// The signed-in user this repository acts for.
///
/// TRACES: UR-069 | DR-120
pub fn user_id(&self) -> &str {
self.online.user_id()
}
/// Download raw bytes from a URL using the shared authenticated HTTP client.
/// Delegates to online repository for connection reuse and proper auth.
pub async fn download_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
self.online.download_bytes(url).await
}
/// Remove catalog entries the server no longer has. Cache-only, so it goes
/// straight to the offline repository. Callers must only invoke this after a
/// crawl in which every library succeeded — see
/// `OfflineRepository::prune_stale_catalog` for why a partial crawl must not
/// sweep.
///
/// TRACES: UR-065 | DR-110
pub async fn prune_stale_catalog(
&self,
cutoff: &str,
item_types: &[String],
) -> Result<usize, RepoError> {
self.offline.prune_stale_catalog(cutoff, item_types).await
}
/// Query the JRay plugin for actors on screen at time `t`. Online-only
/// (the plugin lives on the Jellyfin server); empty when JRay isn't present.
pub async fn get_jray_actors(
@@ -113,6 +135,41 @@ impl HybridRepository {
.await
}
/// Favourites held locally, without touching the server. Backs the instant
/// leg of the two-phase favourites read in the command layer.
///
/// TRACES: UR-067 | DR-115
pub async fn get_favorites_cache_only(
&self,
scope: SearchScope,
options: Option<GetItemsOptions>,
) -> Result<SearchResult, RepoError> {
let offline = Arc::clone(&self.offline);
self.cache_with_timeout(async move { offline.get_favorites(scope, options).await })
.await
}
/// Favourites straight from the server, persisted to the cache on the way
/// through — which is also what mirrors their favourite flags into
/// `user_data` (DR-114), so the next offline read agrees with the server.
///
/// TRACES: UR-067 | DR-115
pub async fn get_favorites_server_only(
&self,
scope: SearchScope,
options: Option<GetItemsOptions>,
) -> Result<SearchResult, RepoError> {
let result = self.online.get_favorites(scope, options).await?;
if !result.items.is_empty() {
// Favourites span libraries, so there is no single parent to file
// them under; the parent id is only used for stub rows.
if let Err(e) = self.offline.save_to_cache("favorites", &result.items).await {
debug!("[HybridRepo] Failed to cache favourites: {:?}", e);
}
}
Ok(result)
}
/// Fetch a folder's items from the live server and persist them to the
/// offline cache synchronously (unlike `get_items`, which saves in a
/// fire-and-forget background task after a 100ms cache race).
@@ -790,6 +847,42 @@ impl MediaRepository for HybridRepository {
self.parallel_race(cache_future, server_future).await
}
/// TRACES: UR-067 | DR-115
async fn get_favorites(
&self,
scope: SearchScope,
options: Option<GetItemsOptions>,
) -> Result<SearchResult, RepoError> {
let offline = Arc::clone(&self.offline);
let online = Arc::clone(&self.online);
let opts_clone = options.clone();
let cache_result = self
.cache_with_timeout(async move { offline.get_favorites(scope, opts_clone).await })
.await;
// Downloads-only gate: with "Show all server media" off, an empty local
// result means "nothing favourited is on this device" and is
// authoritative. Falling through to the server here would re-pad the
// page with the full favourited catalog and defeat the filter (DR-080).
if !crate::repository::offline::include_catalog_browse() {
if let Ok(data) = &cache_result {
return Ok(data.clone());
}
}
if let Ok(data) = &cache_result {
if data.has_content() {
return Ok(data.clone());
}
}
match online.get_favorites(scope, options).await {
Ok(data) => Ok(data),
Err(e) => cache_result.or(Err(e)),
}
}
async fn get_similar_items(
&self,
item_id: &str,
@@ -1133,6 +1226,14 @@ mod tests {
unimplemented!()
}
async fn get_favorites(
&self,
_scope: SearchScope,
_options: Option<GetItemsOptions>,
) -> Result<SearchResult, RepoError> {
unimplemented!()
}
async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
unimplemented!()
}
@@ -1395,6 +1496,14 @@ mod tests {
unimplemented!()
}
async fn get_favorites(
&self,
_scope: SearchScope,
_options: Option<GetItemsOptions>,
) -> Result<SearchResult, RepoError> {
unimplemented!()
}
async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
unimplemented!()
}
+14
View File
@@ -212,6 +212,20 @@ pub trait MediaRepository: Send + Sync {
/// Unmark item as favorite
async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError>;
/// Everything the viewer has favourited, across every library.
///
/// Separate from `get_items` because favourites span libraries and
/// `get_items` is `ParentId`-shaped. `scope` is the opaque enum the
/// frontend sends; this layer expands it to item types (DR-063) so no
/// Jellyfin taxonomy is needed on the other side of the IPC boundary.
///
/// TRACES: UR-067 | DR-115, JA-033 | UT-100, UT-101
async fn get_favorites(
&self,
scope: SearchScope,
options: Option<GetItemsOptions>,
) -> Result<SearchResult, RepoError>;
/// Erase the viewer's watch history for an item: clear its played flag and
/// its resume position. On a container (series, season) this applies to
/// everything inside it, so a series is returned to "never watched" and
File diff suppressed because it is too large Load Diff
+307 -48
View File
@@ -48,6 +48,12 @@ pub struct OnlineRepository {
}
impl OnlineRepository {
/// The signed-in user these requests are made as. Needed by the favourites
/// drain, which reads this user's queued rows. TRACES: UR-069 | DR-120
pub fn user_id(&self) -> &str {
&self.user_id
}
pub fn new(
http_client: Arc<HttpClient>,
server_url: String,
@@ -560,6 +566,138 @@ struct JellyfinItem {
media_streams: Option<Vec<JellyfinMediaStream>>,
media_sources: Option<Vec<JellyfinMediaSource>>,
people: Option<Vec<crate::repository::types::Person>>,
user_data: Option<JellyfinUserData>,
}
/// Per-user state Jellyfin attaches to an item (favourite, played, resume).
///
/// Returned on every `/Users/{uid}/Items*` response; we additionally name
/// `UserData` in the `Fields=` list so the shape is explicit rather than
/// dependent on the server's default field set.
///
/// TRACES: UR-069 | DR-113, JA-034 | UT-099
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "PascalCase")]
struct JellyfinUserData {
playback_position_ticks: Option<i64>,
#[serde(rename = "Played")]
is_played: Option<bool>,
is_favorite: Option<bool>,
play_count: Option<i32>,
last_played_date: Option<String>,
}
impl From<JellyfinUserData> for UserData {
fn from(jf: JellyfinUserData) -> Self {
UserData {
playback_position_ticks: jf.playback_position_ticks,
playback_position_ms: jf.playback_position_ticks.map(crate::domain::ticks_to_ms),
is_played: jf.is_played,
is_favorite: jf.is_favorite,
play_count: jf.play_count,
last_played_date: jf.last_played_date,
playback_context_type: None,
playback_context_id: None,
}
}
}
/// Build the Jellyfin endpoint for a folder listing.
///
/// Extracted from `get_items` so the query it produces — in particular the
/// favourites filter — can be asserted without standing up an HTTP server.
///
/// TRACES: UR-007, UR-067 | DR-116 | UT-104
fn build_get_items_endpoint(
user_id: &str,
parent_id: &str,
options: Option<&GetItemsOptions>,
) -> String {
let mut endpoint = format!("/Users/{}/Items?ParentId={}", user_id, parent_id);
if let Some(opts) = options {
if let Some(limit) = opts.limit {
endpoint.push_str(&format!("&Limit={}", limit));
}
if let Some(start_index) = opts.start_index {
endpoint.push_str(&format!("&StartIndex={}", start_index));
}
if let Some(types) = &opts.include_item_types {
endpoint.push_str(&format!("&IncludeItemTypes={}", types.join(",")));
}
if let Some(sort_by) = &opts.sort_by {
endpoint.push_str(&format!("&SortBy={}", sort_by));
}
if let Some(sort_order) = &opts.sort_order {
endpoint.push_str(&format!("&SortOrder={}", sort_order));
}
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("|")));
}
}
// TRACES: UR-067 | DR-116 | UT-104
if opts.favorites_only == Some(true) {
endpoint.push_str("&Filters=IsFavorite");
}
}
// Request image fields for list views (People only needed in get_item
// detail view). Genres is needed so cached items carry their genres,
// which lets the offline store derive genre lists + per-genre counts.
endpoint
.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData");
endpoint
}
/// Build the Jellyfin endpoint for a favourites listing.
///
/// Pulled out of `get_favorites` so the query can be asserted without an HTTP
/// server. `scope` is expanded here — `SearchScope::All` yields `None`, and the
/// `IncludeItemTypes` filter is then **omitted entirely** rather than sent as a
/// union, which would silently drop every type nobody enumerated (see
/// `SearchScope::item_types`).
///
/// TRACES: UR-067 | DR-115, JA-033 | UT-100
fn build_favorites_endpoint(
user_id: &str,
scope: SearchScope,
options: Option<&GetItemsOptions>,
) -> String {
let mut endpoint = format!("/Users/{}/Items?Filters=IsFavorite&Recursive=true", user_id);
if let Some(types) = scope.item_types() {
endpoint.push_str(&format!("&IncludeItemTypes={}", types.join(",")));
}
// Jellyfin has no "date favourited", so name order is the only stable sort
// available; callers may still override it.
let sort_by = options
.and_then(|o| o.sort_by.as_deref())
.unwrap_or("SortName");
let sort_order = options
.and_then(|o| o.sort_order.as_deref())
.unwrap_or("Ascending");
endpoint.push_str(&format!("&SortBy={}&SortOrder={}", sort_by, sort_order));
if let Some(limit) = options.and_then(|o| o.limit) {
endpoint.push_str(&format!("&Limit={}", limit));
}
if let Some(start_index) = options.and_then(|o| o.start_index) {
endpoint.push_str(&format!("&StartIndex={}", start_index));
}
endpoint
.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData");
endpoint
}
// ImageTags from Jellyfin API - can be a HashMap with various image type keys
@@ -653,7 +791,9 @@ impl JellyfinItem {
series_name: self.series_name,
season_id: self.season_id,
season_name: self.season_name,
user_data: None, // User data not included in basic item responses
// Favourite/played/resume state as the server sees it. TRACES:
// UR-069 | DR-113, JA-034
user_data: self.user_data.map(UserData::from),
media_streams: self.media_streams.map(|streams| {
streams
.into_iter()
@@ -728,43 +868,7 @@ impl MediaRepository for OnlineRepository {
parent_id: &str,
options: Option<GetItemsOptions>,
) -> Result<SearchResult, RepoError> {
let mut endpoint = format!("/Users/{}/Items?ParentId={}", self.user_id, parent_id);
if let Some(opts) = options {
if let Some(limit) = opts.limit {
endpoint.push_str(&format!("&Limit={}", limit));
}
if let Some(start_index) = opts.start_index {
endpoint.push_str(&format!("&StartIndex={}", start_index));
}
if let Some(types) = opts.include_item_types {
endpoint.push_str(&format!("&IncludeItemTypes={}", types.join(",")));
}
if let Some(sort_by) = opts.sort_by {
endpoint.push_str(&format!("&SortBy={}", sort_by));
}
if let Some(sort_order) = opts.sort_order {
endpoint.push_str(&format!("&SortOrder={}", sort_order));
}
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). Genres is needed so cached items carry their genres,
// which lets the offline store derive genre lists + per-genre counts.
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate");
let endpoint = build_get_items_endpoint(&self.user_id, parent_id, options.as_ref());
let response: ItemsResponse = self.get_json(&endpoint).await?;
@@ -779,7 +883,7 @@ impl MediaRepository for OnlineRepository {
}
async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
let endpoint = format!("/Users/{}/Items/{}?Fields=BackdropImageTags,ParentBackdropImageTags,People,MediaStreams,MediaSources,PremiereDate", self.user_id, item_id);
let endpoint = format!("/Users/{}/Items/{}?Fields=BackdropImageTags,ParentBackdropImageTags,People,MediaStreams,MediaSources,PremiereDate,UserData", self.user_id, item_id);
let item: JellyfinItem = self.get_json(&endpoint).await?;
let media_item = item.to_media_item(self.user_id.clone());
@@ -794,7 +898,7 @@ impl MediaRepository for OnlineRepository {
) -> Result<Vec<MediaItem>, RepoError> {
let limit_str = limit.unwrap_or(16);
let endpoint = format!(
"/Users/{}/Items/Latest?ParentId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags",
"/Users/{}/Items/Latest?ParentId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
self.user_id, parent_id, limit_str
);
@@ -812,7 +916,7 @@ impl MediaRepository for OnlineRepository {
) -> Result<Vec<MediaItem>, RepoError> {
let limit_str = limit.unwrap_or(16);
let mut endpoint = format!(
"/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&Fields=BackdropImageTags,ParentBackdropImageTags",
"/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
self.user_id, limit_str
);
@@ -835,7 +939,7 @@ impl MediaRepository for OnlineRepository {
) -> Result<Vec<MediaItem>, RepoError> {
let limit_str = limit.unwrap_or(16);
let mut endpoint = format!(
"/Shows/NextUp?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags",
"/Shows/NextUp?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
self.user_id, limit_str
);
@@ -859,7 +963,7 @@ impl MediaRepository for OnlineRepository {
// Fetch more items to account for grouping reducing the count
let fetch_limit = limit_val * 3;
let endpoint = format!(
"/Users/{}/Items?SortBy=DatePlayed&SortOrder=Descending&IncludeItemTypes=Audio&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags",
"/Users/{}/Items?SortBy=DatePlayed&SortOrder=Descending&IncludeItemTypes=Audio&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
self.user_id, fetch_limit
);
@@ -993,7 +1097,7 @@ impl MediaRepository for OnlineRepository {
// 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",
"/Users/{}/Items?SortBy=DatePlayed&SortOrder=Ascending&IncludeItemTypes=MusicAlbum&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
self.user_id, limit_val
);
@@ -1012,7 +1116,7 @@ impl MediaRepository for OnlineRepository {
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
let limit_str = limit.unwrap_or(16);
let endpoint = format!(
"/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&IncludeItemTypes=Movie&Fields=BackdropImageTags,ParentBackdropImageTags",
"/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&IncludeItemTypes=Movie&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
self.user_id, limit_str
);
@@ -1113,7 +1217,9 @@ impl MediaRepository for OnlineRepository {
// Request image fields for list views (plus Genres so cached items
// carry genres for offline genre lists/counts).
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate");
endpoint.push_str(
"&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData",
);
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(SearchResult {
@@ -1654,6 +1760,25 @@ impl MediaRepository for OnlineRepository {
self.post_json(&endpoint, &serde_json::json!({})).await
}
/// TRACES: UR-067 | DR-115, JA-033 | UT-100
async fn get_favorites(
&self,
scope: SearchScope,
options: Option<GetItemsOptions>,
) -> Result<SearchResult, RepoError> {
let endpoint = build_favorites_endpoint(&self.user_id, scope, options.as_ref());
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(SearchResult {
items: response
.items
.into_iter()
.map(|item| item.to_media_item(self.user_id.clone()))
.collect(),
total_record_count: response.total_record_count,
})
}
async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
let endpoint = format!("/Users/{}/FavoriteItems/{}", self.user_id, item_id);
let url = format!("{}{}", self.server_url, endpoint);
@@ -1747,7 +1872,7 @@ impl MediaRepository for OnlineRepository {
let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(100);
let mut endpoint = format!(
"/Users/{}/Items?PersonIds={}&Limit={}&Recursive=true&Fields=BackdropImageTags,ParentBackdropImageTags",
"/Users/{}/Items?PersonIds={}&Limit={}&Recursive=true&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
self.user_id, person_id, limit
);
@@ -1781,7 +1906,7 @@ impl MediaRepository for OnlineRepository {
// Try the /Similar endpoint which works for most items
let endpoint = format!(
"/Items/{}/Similar?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags",
"/Items/{}/Similar?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
item_id, self.user_id, limit_str
);
@@ -2369,6 +2494,140 @@ mod tests {
);
}
/// UT-100 — the favourites endpoint asks the server for favourites, scoped.
///
/// TRACES: UR-067 | DR-115, JA-033 | UT-100
#[test]
fn test_build_favorites_endpoint_scopes_and_filters() {
let movies = build_favorites_endpoint("u1", SearchScope::Movies, None);
assert!(movies.starts_with("/Users/u1/Items?Filters=IsFavorite&Recursive=true"));
assert!(movies.contains("&IncludeItemTypes=Movie"));
// Jellyfin has no favourite timestamp, so name order is the default.
assert!(movies.contains("&SortBy=SortName&SortOrder=Ascending"));
// Hearts must render on the returned cards.
assert!(movies.contains("UserData"));
// Tv covers both the show and any individually favourited episode.
let tv = build_favorites_endpoint("u1", SearchScope::Tv, None);
assert!(tv.contains("&IncludeItemTypes=Series,Episode"));
let music = build_favorites_endpoint("u1", SearchScope::Music, None);
assert!(music.contains("&IncludeItemTypes=MusicAlbum,MusicArtist,Audio,Playlist"));
}
/// `All` must omit the type filter entirely rather than send a union, which
/// would silently drop every type nobody enumerated.
///
/// TRACES: UR-067 | DR-115 | UT-100
#[test]
fn test_build_favorites_endpoint_all_scope_omits_type_filter() {
let all = build_favorites_endpoint("u1", SearchScope::All, None);
assert!(!all.contains("IncludeItemTypes"));
}
/// Paging and an explicit sort still reach the server.
///
/// TRACES: UR-067 | DR-115 | UT-100
#[test]
fn test_build_favorites_endpoint_honours_paging_and_sort() {
let endpoint = build_favorites_endpoint(
"u1",
SearchScope::All,
Some(&GetItemsOptions {
limit: Some(20),
start_index: Some(40),
sort_by: Some("Random".to_string()),
sort_order: Some("Descending".to_string()),
..Default::default()
}),
);
assert!(endpoint.contains("&Limit=20"));
assert!(endpoint.contains("&StartIndex=40"));
assert!(endpoint.contains("&SortBy=Random&SortOrder=Descending"));
}
/// UT-104 — the in-library favourites toggle reaches the server as
/// `Filters=IsFavorite`, and is absent unless asked for.
///
/// TRACES: UR-067 | DR-116 | UT-104
#[test]
fn test_get_items_endpoint_applies_favorites_only() {
let plain = build_get_items_endpoint("u1", "lib-1", None);
assert!(!plain.contains("Filters=IsFavorite"));
let filtered = build_get_items_endpoint(
"u1",
"lib-1",
Some(&GetItemsOptions {
favorites_only: Some(true),
include_item_types: Some(vec!["Movie".to_string()]),
..Default::default()
}),
);
assert!(filtered.contains("&Filters=IsFavorite"));
// Composes with the filters already there rather than replacing them.
assert!(filtered.contains("&IncludeItemTypes=Movie"));
assert!(filtered.contains("ParentId=lib-1"));
// Explicitly false is not a request to filter.
let off = build_get_items_endpoint(
"u1",
"lib-1",
Some(&GetItemsOptions {
favorites_only: Some(false),
..Default::default()
}),
);
assert!(!off.contains("Filters=IsFavorite"));
}
/// UT-099 — a Jellyfin item's `UserData` reaches `MediaItem.user_data`.
///
/// Before DR-113 this mapping was hardcoded to `None`, so nothing outside
/// the mini player could know an item was favourited.
///
/// TRACES: UR-069 | DR-113, JA-034 | UT-099
#[test]
fn test_jellyfin_item_maps_user_data_favorite() {
let json = r#"{
"Id": "movie123",
"Name": "Test Movie",
"Type": "Movie",
"UserData": {
"PlaybackPositionTicks": 6000000000,
"Played": false,
"IsFavorite": true,
"PlayCount": 2,
"LastPlayedDate": "2026-08-01T12:00:00Z"
}
}"#;
let item: JellyfinItem = serde_json::from_str(json).expect("item should deserialize");
let media = item.to_media_item("server1".to_string());
let user_data = media.user_data.expect("user data should be mapped");
assert_eq!(user_data.is_favorite, Some(true));
assert_eq!(user_data.is_played, Some(false));
assert_eq!(user_data.play_count, Some(2));
assert_eq!(user_data.playback_position_ticks, Some(6_000_000_000));
// Ticks are converted for the frontend, which never divides them itself.
assert_eq!(user_data.playback_position_ms, Some(600_000));
}
/// An item without `UserData` still maps — the field is optional, and every
/// non-user-scoped endpoint omits it.
///
/// TRACES: UR-069 | DR-113 | UT-099
#[test]
fn test_jellyfin_item_without_user_data_maps_to_none() {
let json = r#"{"Id": "x", "Name": "No User Data", "Type": "Movie"}"#;
let item: JellyfinItem = serde_json::from_str(json).expect("item should deserialize");
let media = item.to_media_item("server1".to_string());
assert!(media.user_data.is_none());
}
#[test]
fn test_jellyfin_item_deserialize_with_artist_items() {
// Test that ArtistItems with PascalCase fields deserialize correctly
+6
View File
@@ -292,6 +292,12 @@ pub struct GetItemsOptions {
pub fields: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub genres: Option<Vec<String>>,
/// Restrict the listing to favourited items. Backs the per-library
/// favourites toggle; composes with every other filter here.
///
/// TRACES: UR-067 | DR-116 | UT-104
#[serde(skip_serializing_if = "Option::is_none")]
pub favorites_only: Option<bool>,
}
/// An opaque search scope the frontend selects; Rust owns what it *means*.