//! Every Jellyfin route the online repository speaks, in one place. //! //! Before this module the endpoints were 57 inline `format!` literals scattered //! through `online.rs`, query strings baked in at the point of use. That is //! workable against exactly one server, and hostile to anything else: a second //! route shape means a conditional at every one of them. //! //! Each function here takes `&ServerCapabilities` and returns a **path** //! (`/Users/…`), except the handful documented as returning an absolute URL //! because they are handed to a media player rather than to the JSON helpers. //! //! # Percent-encoding //! //! Values are encoded, syntax is not. A genre named `Drama & Romance` or a //! search for `a?b` must not split into another parameter. [`Endpoint::param`] //! encodes; [`Endpoint::raw_param`] does not and is for values this module //! itself composed (numbers, and lists whose separator is meaningful to //! Jellyfin — `IncludeItemTypes` splits on `,`, `Genres` on `|`, so the //! separator survives while each element is encoded). //! //! TRACES: UR-085 | DR-279 use super::capabilities::ServerCapabilities; use super::types::{GetItemsOptions, SearchScope}; /// A path plus query string, which knows whether it needs `?` or `&` next. /// /// The manual separator juggling this replaces produced the double-ampersand and /// trailing-ampersand cases an earlier test file spent four assertions on. /// Making it structural is cheaper than testing for it. pub struct Endpoint { buf: String, has_query: bool, } impl Endpoint { pub fn new(path: &str) -> Self { // A caller may hand in a path that already carries a query. let has_query = path.contains('?'); Self { buf: path.to_string(), has_query, } } fn separator(&mut self) -> char { if self.has_query { '&' } else { self.has_query = true; '?' } } /// Append `key=value`, percent-encoding the value. pub fn param(mut self, key: &str, value: &str) -> Self { let sep = self.separator(); self.buf .push_str(&format!("{}{}={}", sep, key, urlencoding::encode(value))); self } /// Append `key=value` verbatim. Only for values this module composed. pub fn raw_param(mut self, key: &str, value: &str) -> Self { let sep = self.separator(); self.buf.push_str(&format!("{}{}={}", sep, key, value)); self } pub fn build(self) -> String { self.buf } } /// Encode each element of a list while keeping the separator Jellyfin splits on. fn encode_list(values: impl IntoIterator>, separator: &str) -> String { values .into_iter() .map(|v| urlencoding::encode(v.as_ref()).into_owned()) .collect::>() .join(separator) } /// The base for a user-scoped item query. /// /// This is the one place the two route shapes differ, and the reason the route /// table exists at all. `user_scoped_item_routes` is `true` for every generation /// today — see the flag's own documentation for why flipping it needs a cited /// source rather than a guess (DR-282). fn user_items_root(caps: &ServerCapabilities, user_id: &str) -> Endpoint { if caps.user_scoped_item_routes { Endpoint::new(&format!("/Users/{}/Items", user_id)) } else { Endpoint::new("/Items").param("userId", user_id) } } /// The standard field set for a list view. `People` is deliberately absent — it /// is only wanted in the detail view, and it is not small. const LIST_FIELDS: &str = "BackdropImageTags,ParentBackdropImageTags,UserData"; /// As [`LIST_FIELDS`], plus what the offline store needs to derive genre lists /// and per-genre counts from cached rows. const LIST_FIELDS_WITH_GENRES: &str = "BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData"; // ===== Libraries and items ===== /// The user's library views. /// /// TRACES: UR-007, UR-085 | JA-003, DR-279 pub fn user_views(_caps: &ServerCapabilities, user_id: &str) -> String { format!("/Users/{}/Views", user_id) } /// One item, in detail. `People`, `MediaStreams` and `MediaSources` are named /// here and nowhere else — the detail view is the only place they are wanted. /// /// TRACES: UR-007, UR-085 | JA-005, DR-279 pub fn item_detail(caps: &ServerCapabilities, user_id: &str, item_id: &str) -> String { let base = if caps.user_scoped_item_routes { Endpoint::new(&format!( "/Users/{}/Items/{}", user_id, urlencoding::encode(item_id) )) } else { Endpoint::new(&format!("/Items/{}", urlencoding::encode(item_id))).param("userId", user_id) }; base.raw_param( "Fields", "BackdropImageTags,ParentBackdropImageTags,People,MediaStreams,MediaSources,PremiereDate,UserData", ) .build() } /// A folder listing. /// /// Every value is percent-encoded before it goes into the query string: these /// are values, not URL syntax, so a space or an `&` in one must not split it /// into another parameter. /// /// TRACES: UR-007, UR-067, UR-085 | DR-116, DR-212, DR-279 | UT-104, UT-206 pub fn get_items( caps: &ServerCapabilities, user_id: &str, parent_id: &str, options: Option<&GetItemsOptions>, ) -> String { let mut ep = user_items_root(caps, user_id).param("ParentId", parent_id); if let Some(opts) = options { if let Some(limit) = opts.limit { ep = ep.raw_param("Limit", &limit.to_string()); } if let Some(start_index) = opts.start_index { ep = ep.raw_param("StartIndex", &start_index.to_string()); } if let Some(types) = &opts.include_item_types { // The comma is the list separator Jellyfin splits on, so encode // each type rather than the joined string. ep = ep.raw_param("IncludeItemTypes", &encode_list(types, ",")); } // An explicit sort always wins; the container's default only fills the // gap when the caller named none. A caller that names neither gets no // SortBy at all, leaving the server's own order intact. // // TRACES: UR-007 | DR-257 | UT-229 let default_sort = super::types::default_listing_sort(opts.parent_kind); let sort_by = opts .sort_by .as_deref() .or(default_sort.map(|(field, _)| field)); let sort_order = opts .sort_order .as_deref() .or(default_sort.map(|(_, order)| order)); if let Some(sort_by) = sort_by { // SortBy is likewise comma-delimited ("ParentIndexNumber,IndexNumber, // SortName"), so encode per field. ep = ep.raw_param("SortBy", &encode_list(sort_by.split(','), ",")); } if let Some(sort_order) = sort_order { ep = ep.param("SortOrder", sort_order); } // Jellyfin 12.0 defaults `recursive` to true when the parent is a // library folder and `IncludeItemTypes` is set, where 10.11 listed only // immediate children — the same request, a different result set. State // it explicitly whenever a type filter is present so both generations // agree, and state the behaviour that shipped rather than adopting the // new server-side default silently. // // TRACES: UR-085 | DR-288 let type_filtered = opts .include_item_types .as_ref() .is_some_and(|types| !types.is_empty()); match (opts.recursive, type_filtered) { (Some(recursive), _) => ep = ep.raw_param("Recursive", &recursive.to_string()), (None, true) => ep = ep.raw_param("Recursive", "false"), (None, false) => {} } if let Some(genres) = &opts.genres { if !genres.is_empty() { // Genre names may contain spaces or ampersands; `|` is the // separator Jellyfin splits this one on. ep = ep.raw_param("Genres", &encode_list(genres, "|")); } } // TRACES: UR-067 | DR-116 | UT-104 if opts.favorites_only == Some(true) { ep = ep.raw_param("Filters", "IsFavorite"); } } ep.raw_param("Fields", LIST_FIELDS_WITH_GENRES).build() } /// A "recently added" listing. /// /// `GroupItems=true` is the load-bearing parameter: Jellyfin defaults it to /// `false`, which returns each newly-added *leaf* separately, so importing one /// 14-track album pushed 14 rows into "recently added" and buried everything /// else. With grouping on, the server collapses children into the container /// that was added — an album appears once, while movies (which have no such /// container) are unaffected. /// /// TRACES: UR-024, UR-034, UR-085 | IR-024, JA-016, DR-279 pub fn latest_items( caps: &ServerCapabilities, user_id: &str, parent_id: &str, limit: Option, ) -> String { let base = if caps.user_scoped_item_routes { Endpoint::new(&format!("/Users/{}/Items/Latest", user_id)) } else { Endpoint::new("/Items/Latest").param("userId", user_id) }; base.param("ParentId", parent_id) .raw_param("Limit", &limit.unwrap_or(16).to_string()) .raw_param("GroupItems", "true") .raw_param("Fields", LIST_FIELDS) .build() } /// The resume ("Continue Watching") listing. /// /// TRACES: UR-019, UR-085 | JA-013, DR-279 pub fn resume_items( caps: &ServerCapabilities, user_id: &str, limit: usize, include_item_types: Option<&str>, parent_id: Option<&str>, ) -> String { let base = if caps.user_scoped_item_routes { Endpoint::new(&format!("/Users/{}/Items/Resume", user_id)) } else { Endpoint::new("/Items/Resume").param("userId", user_id) }; let ep = base .raw_param("Limit", &limit.to_string()) .raw_param("MediaTypes", "Video"); let ep = match include_item_types { Some(types) => ep.raw_param("IncludeItemTypes", types), None => ep, }; let ep = ep.raw_param("Fields", LIST_FIELDS); match parent_id { Some(pid) => ep.param("ParentId", pid).build(), None => ep.build(), } } /// A Next Up listing. /// /// `EnableResumable=false` is the point of this query: the server default is /// `true`, which makes a partially-watched episode its own series' "next up" — /// the very episode `/Items/Resume` returns — so Continue Watching and Next Up /// end up showing the same cards. Servers predating the parameter ignore it, /// which is why the frontend also drops in-progress entries (DR-197). /// /// TRACES: UR-023, UR-059, UR-085 | DR-197, DR-279, JA-014, JA-036 | UT-190, UT-191 pub fn next_up( _caps: &ServerCapabilities, user_id: &str, series_id: Option<&str>, limit: Option, ) -> String { let ep = Endpoint::new("/Shows/NextUp") .param("UserId", user_id) .raw_param("Limit", &limit.unwrap_or(16).to_string()) .raw_param("EnableResumable", "false") .raw_param("Fields", LIST_FIELDS); match series_id { Some(sid) => ep.param("SeriesId", sid).build(), None => ep.build(), } } /// A favourites listing. /// /// `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, UR-085 | DR-115, DR-279, JA-033 | UT-100 pub fn favorites( caps: &ServerCapabilities, user_id: &str, scope: SearchScope, options: Option<&GetItemsOptions>, ) -> String { let mut ep = user_items_root(caps, user_id) .raw_param("Filters", "IsFavorite") .raw_param("Recursive", "true"); if let Some(types) = scope.item_types() { ep = ep.raw_param("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"); ep = ep .raw_param("SortBy", sort_by) .raw_param("SortOrder", sort_order); if let Some(limit) = options.and_then(|o| o.limit) { ep = ep.raw_param("Limit", &limit.to_string()); } if let Some(start_index) = options.and_then(|o| o.start_index) { ep = ep.raw_param("StartIndex", &start_index.to_string()); } ep.raw_param("Fields", LIST_FIELDS_WITH_GENRES).build() } /// Items sorted by when they were last played, filtered to played ones. /// /// TRACES: UR-034, UR-085 | DR-279 pub fn played_items_by_date( caps: &ServerCapabilities, user_id: &str, include_item_types: &str, limit: usize, sort_order: &str, parent_id: Option<&str>, ) -> String { let ep = user_items_root(caps, user_id) .raw_param("SortBy", "DatePlayed") .raw_param("SortOrder", sort_order) .raw_param("IncludeItemTypes", include_item_types) .raw_param("Limit", &limit.to_string()) .raw_param("Recursive", "true") .raw_param("Filters", "IsPlayed") .raw_param("Fields", LIST_FIELDS); match parent_id { Some(pid) => ep.param("ParentId", pid).build(), None => ep.build(), } } /// Genres, with the item counts the frontend uses to pick a diverse subset. /// /// TRACES: UR-085 | DR-279 pub fn genres( _caps: &ServerCapabilities, user_id: &str, include_item_types: &str, parent_id: Option<&str>, ) -> String { let ep = Endpoint::new("/Genres") .param("UserId", user_id) .raw_param("IncludeItemTypes", include_item_types) .raw_param("Recursive", "true") .raw_param("Fields", "ItemCounts"); match parent_id { Some(pid) => ep.param("ParentId", pid).build(), None => ep.build(), } } /// A search. /// /// TRACES: UR-085 | DR-279 pub fn search( caps: &ServerCapabilities, user_id: &str, term: &str, limit: usize, include_item_types: Option<&[String]>, ) -> String { let ep = user_items_root(caps, user_id) .param("SearchTerm", term) .raw_param("Limit", &limit.to_string()) .raw_param("Recursive", "true"); match include_item_types { Some(types) if !types.is_empty() => ep .raw_param("IncludeItemTypes", &encode_list(types, ",")) .build(), _ => ep.build(), } } /// A person's filmography. /// /// TRACES: UR-036, UR-085 | JA-031, DR-279 pub fn items_by_person( caps: &ServerCapabilities, user_id: &str, person_id: &str, limit: usize, include_item_types: Option<&[String]>, ) -> String { let ep = user_items_root(caps, user_id) .param("PersonIds", person_id) .raw_param("Limit", &limit.to_string()) .raw_param("Recursive", "true") .raw_param("Fields", LIST_FIELDS); match include_item_types { Some(types) if !types.is_empty() => ep .raw_param("IncludeItemTypes", &encode_list(types, ",")) .build(), _ => ep.build(), } } /// A person as an item. /// /// Jellyfin serves people through the ordinary user-item endpoint rather than /// anything under `/Persons`; the cast entries on an item's `People` field carry /// the ids this is called with. /// /// TRACES: UR-035, UR-036, UR-085 | IR-022, JA-030, DR-279 pub fn person(caps: &ServerCapabilities, user_id: &str, person_id: &str) -> String { if caps.user_scoped_item_routes { format!( "/Users/{}/Items/{}", user_id, urlencoding::encode(person_id) ) } else { Endpoint::new(&format!("/Items/{}", urlencoding::encode(person_id))) .param("userId", user_id) .build() } } /// Items similar to one item. /// /// TRACES: UR-085 | DR-279 pub fn similar_items( _caps: &ServerCapabilities, item_id: &str, user_id: &str, limit: usize, ) -> String { Endpoint::new(&format!("/Items/{}/Similar", urlencoding::encode(item_id))) .param("UserId", user_id) .raw_param("Limit", &limit.to_string()) .raw_param("Fields", LIST_FIELDS) .build() } // ===== User data mutations ===== /// Favourite / un-favourite an item (POST to set, DELETE to clear). /// /// TRACES: UR-067, UR-085 | JA-033, DR-279 pub fn favorite_item(_caps: &ServerCapabilities, user_id: &str, item_id: &str) -> String { format!( "/Users/{}/FavoriteItems/{}", user_id, urlencoding::encode(item_id) ) } /// Mark played / clear watch history (POST to set, DELETE to clear). /// /// TRACES: UR-025, UR-085 | JA-035, DR-279 pub fn played_item(_caps: &ServerCapabilities, user_id: &str, item_id: &str) -> String { format!( "/Users/{}/PlayedItems/{}", user_id, urlencoding::encode(item_id) ) } // ===== Playback ===== /// Playback negotiation for one item. /// /// TRACES: UR-004, UR-085 | JA-021, DR-279 pub fn playback_info(_caps: &ServerCapabilities, item_id: &str) -> String { format!("/Items/{}/PlaybackInfo", urlencoding::encode(item_id)) } /// Playback reporting. /// /// TRACES: UR-020, UR-085 | JA-010, JA-011, JA-012, DR-279 pub fn sessions_playing(_caps: &ServerCapabilities) -> &'static str { "/Sessions/Playing" } pub fn sessions_playing_progress(_caps: &ServerCapabilities) -> &'static str { "/Sessions/Playing/Progress" } pub fn sessions_playing_stopped(_caps: &ServerCapabilities) -> &'static str { "/Sessions/Playing/Stopped" } /// Live TV channels. /// /// TRACES: UR-085 | DR-279 pub fn live_tv_channels(_caps: &ServerCapabilities, user_id: &str) -> String { Endpoint::new("/LiveTv/Channels") .param("UserId", user_id) .raw_param("Fields", "PrimaryImageAspectRatio,Overview") .raw_param("EnableImageTypes", "Primary") .build() } /// Generic channels. /// /// TRACES: UR-085 | DR-279 pub fn channels(_caps: &ServerCapabilities, user_id: &str) -> String { Endpoint::new("/Channels").param("UserId", user_id).build() } // ===== Playlists ===== /// TRACES: UR-062, UR-085 | DR-279 pub fn playlists(_caps: &ServerCapabilities) -> &'static str { "/Playlists" } /// A playlist as an item — used for rename and delete, which are `/Items` /// operations rather than `/Playlists` ones. /// /// TRACES: UR-062, UR-085 | DR-279 pub fn playlist_as_item(_caps: &ServerCapabilities, playlist_id: &str) -> String { format!("/Items/{}", urlencoding::encode(playlist_id)) } /// TRACES: UR-062, UR-085 | DR-279 pub fn playlist_items(_caps: &ServerCapabilities, playlist_id: &str, user_id: &str) -> String { Endpoint::new(&format!( "/Playlists/{}/Items", urlencoding::encode(playlist_id) )) .param("UserId", user_id) .raw_param( "Fields", "PrimaryImageTag,Artists,AlbumId,Album,AlbumArtist,RunTimeTicks,ArtistItems", ) .raw_param("StartIndex", "0") .raw_param("Limit", "10000") .build() } /// TRACES: UR-062, UR-085 | DR-279 pub fn playlist_items_add(_caps: &ServerCapabilities, playlist_id: &str, ids: &str) -> String { Endpoint::new(&format!( "/Playlists/{}/Items", urlencoding::encode(playlist_id) )) .param("Ids", ids) .build() } /// TRACES: UR-062, UR-085 | DR-279 pub fn playlist_items_remove( _caps: &ServerCapabilities, playlist_id: &str, entry_ids: &str, ) -> String { Endpoint::new(&format!( "/Playlists/{}/Items", urlencoding::encode(playlist_id) )) .param("EntryIds", entry_ids) .build() } /// TRACES: UR-062, UR-085 | DR-279 pub fn playlist_item_move( _caps: &ServerCapabilities, playlist_id: &str, item_id: &str, new_index: u32, ) -> String { format!( "/Playlists/{}/Items/{}/Move/{}", urlencoding::encode(playlist_id), urlencoding::encode(item_id), new_index ) } // ===== Plugin ===== /// The JRay plugin's per-item context. Not core Jellyfin; absent servers 404 and /// the caller treats that as "no context", so it needs no capability flag. /// /// TRACES: UR-085 | DR-279 pub fn jray_context(_caps: &ServerCapabilities, item_id: &str, position_seconds: f64) -> String { format!( "/Plugins/JRay/Items/{}/jray?t={}", urlencoding::encode(item_id), position_seconds ) } #[cfg(test)] mod tests { use super::*; fn caps() -> ServerCapabilities { ServerCapabilities::assumed() } /// The builder must never emit a double or trailing separator, and must use /// `?` exactly once. This is structural now rather than asserted at every /// call site. /// /// TRACES: UR-085 | DR-279 #[test] fn query_separators_are_structural() { let url = Endpoint::new("/Items") .param("a", "1") .param("b", "2") .raw_param("c", "3") .build(); assert_eq!(url, "/Items?a=1&b=2&c=3"); assert_eq!(url.matches('?').count(), 1); assert!(!url.contains("&&")); assert!(!url.ends_with('&')); // A path that already carries a query continues it rather than // starting a second one. let continued = Endpoint::new("/Items?x=0").param("y", "1").build(); assert_eq!(continued, "/Items?x=0&y=1"); assert_eq!(continued.matches('?').count(), 1); // No parameters at all means no `?`. assert_eq!(Endpoint::new("/Items").build(), "/Items"); } /// Values are encoded, list separators are not. /// /// TRACES: UR-007, UR-085 | DR-212, DR-279 | UT-206 #[test] fn values_are_encoded_but_list_separators_survive() { let url = Endpoint::new("/x").param("SearchTerm", "a?b&c d").build(); assert!(url.contains("SearchTerm=a%3Fb%26c%20d"), "{url}"); assert_eq!( encode_list(["Drama & Romance", "Sci-Fi"], "|"), "Drama%20%26%20Romance|Sci-Fi" ); assert_eq!(encode_list(["Movie", "Series"], ","), "Movie,Series"); } /// The user-scoped split is the reason this module exists. Both shapes must /// be well-formed, and the default must be byte-identical to what shipped. /// /// TRACES: UR-085 | DR-279, DR-282 #[test] fn both_user_scoped_route_shapes_are_well_formed() { let legacy = caps(); assert!(legacy.user_scoped_item_routes, "the shipped default"); let url = get_items(&legacy, "u1", "lib-1", None); assert!(url.starts_with("/Users/u1/Items?ParentId=lib-1"), "{url}"); let mut modern = caps(); modern.user_scoped_item_routes = false; let url = get_items(&modern, "u1", "lib-1", None); assert!(url.starts_with("/Items?userId=u1&ParentId=lib-1"), "{url}"); assert_eq!(url.matches('?').count(), 1, "{url}"); assert!(!url.contains("/Users/"), "{url}"); } /// Every route must be well-formed under *both* shapes — a flipped flag /// must not produce a malformed URL anywhere. /// /// TRACES: UR-085 | DR-279, DR-282 #[test] fn no_route_is_malformed_under_either_shape() { for user_scoped in [true, false] { let mut c = caps(); c.user_scoped_item_routes = user_scoped; let routes = vec![ user_views(&c, "u1"), item_detail(&c, "u1", "i1"), get_items(&c, "u1", "p1", None), latest_items(&c, "u1", "p1", Some(8)), resume_items(&c, "u1", 10, None, None), resume_items(&c, "u1", 10, Some("Movie"), Some("lib-9")), next_up(&c, "u1", Some("s1"), Some(5)), favorites(&c, "u1", SearchScope::All, None), played_items_by_date(&c, "u1", "Audio", 20, "Descending", None), genres(&c, "u1", "MusicAlbum", Some("lib-1")), search(&c, "u1", "query", 25, Some(&["Movie".to_string()])), items_by_person(&c, "u1", "p9", 50, None), person(&c, "u1", "p9"), similar_items(&c, "i1", "u1", 12), favorite_item(&c, "u1", "i1"), played_item(&c, "u1", "i1"), playback_info(&c, "i1"), live_tv_channels(&c, "u1"), channels(&c, "u1"), playlist_as_item(&c, "pl1"), playlist_items(&c, "pl1", "u1"), playlist_items_add(&c, "pl1", "a,b"), playlist_items_remove(&c, "pl1", "e1"), playlist_item_move(&c, "pl1", "i1", 3u32), jray_context(&c, "i1", 42.5), ]; for route in routes { assert!(route.starts_with('/'), "{route}"); assert!(!route.contains("&&"), "{route}"); assert!(!route.contains("?&"), "{route}"); assert!(!route.ends_with('&'), "{route}"); assert!(!route.ends_with('?'), "{route}"); assert!( route.matches('?').count() <= 1, "more than one query separator: {route}" ); } } } /// TRACES: UR-024, UR-034 | IR-024, JA-016 #[test] fn latest_items_groups_children_into_containers() { let url = latest_items(&caps(), "u1", "lib-1", Some(16)); assert!(url.contains("GroupItems=true"), "{url}"); assert!(url.contains("ParentId=lib-1"), "{url}"); assert!(url.contains("Limit=16"), "{url}"); } /// TRACES: UR-059 | DR-197, JA-036 | UT-190, UT-191 #[test] fn next_up_excludes_resumable_and_scopes_to_series() { let url = next_up(&caps(), "u1", None, Some(12)); assert!(url.contains("EnableResumable=false"), "{url}"); assert!(url.contains("UserId=u1"), "{url}"); assert!(url.contains("Limit=12"), "{url}"); assert!(!url.contains("SeriesId"), "{url}"); let scoped = next_up(&caps(), "u1", Some("series-a"), None); assert!(scoped.contains("SeriesId=series-a"), "{scoped}"); assert!(scoped.contains("Limit=16"), "default limit: {scoped}"); } /// `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 favorites_all_scope_omits_the_type_filter() { let url = favorites(&caps(), "u1", SearchScope::All, None); assert!(!url.contains("IncludeItemTypes"), "{url}"); assert!(url.contains("Filters=IsFavorite"), "{url}"); } /// TRACES: UR-067 | DR-115 | UT-100 #[test] fn favorites_honours_paging_and_sort() { let url = favorites( &caps(), "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!(url.contains("&Limit=20"), "{url}"); assert!(url.contains("&StartIndex=40"), "{url}"); assert!(url.contains("&SortBy=Random&SortOrder=Descending"), "{url}"); } /// The detail view is the only caller that wants People/MediaStreams; a list /// query must not drag them along. /// /// Jellyfin 12.0 changed `GetItems` to default `recursive` to **true** when /// the parent is a library folder and `IncludeItemTypes` is set — so the /// identical request returns a different result set on the two generations. /// Sending an explicit value makes them agree, and `false` is what shipped. /// /// Source: `ItemsController.cs` in v12.0 — `if (folder is ICollectionFolder /// && includeItemTypes.Length > 0) { recursive ??= true; }` /// /// TRACES: UR-085 | DR-288 #[test] fn a_type_filtered_listing_always_states_recursive() { let filtered = get_items( &caps(), "u1", "lib-1", Some(&GetItemsOptions { include_item_types: Some(vec!["Movie".to_string()]), ..Default::default() }), ); assert!( filtered.contains("Recursive="), "a type-filtered listing must state Recursive or 12.0 will infer a \ different one than 10.11: {filtered}" ); assert!( filtered.contains("Recursive=false"), "and it must state the behaviour that shipped: {filtered}" ); // An explicit choice by the caller still wins. let explicit = get_items( &caps(), "u1", "lib-1", Some(&GetItemsOptions { include_item_types: Some(vec!["Movie".to_string()]), recursive: Some(true), ..Default::default() }), ); assert!(explicit.contains("Recursive=true"), "{explicit}"); assert_eq!(explicit.matches("Recursive=").count(), 1, "{explicit}"); // No type filter, no inference to defend against, no parameter. let plain = get_items(&caps(), "u1", "lib-1", None); assert!(!plain.contains("Recursive="), "{plain}"); } /// TRACES: UR-007 | DR-279 #[test] fn only_the_detail_route_requests_the_heavy_fields() { assert!(item_detail(&caps(), "u1", "i1").contains("People")); assert!(!get_items(&caps(), "u1", "p1", None).contains("People")); assert!(!latest_items(&caps(), "u1", "p1", None).contains("MediaStreams")); } }