feat(repository): a route table and resolved server capabilities

Endpoints were 57 inline format! literals with their 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 would mean a conditional at every one of
them. They now live in repository/endpoints.rs, one function each, taking
&ServerCapabilities.

Two things fall out of the move:

  - A small Endpoint builder replaces the manual ?/& juggling, so a double or
    trailing separator is structurally impossible rather than something four
    assertions in a deleted test file used to watch for.
  - Both user-scoped route shapes (/Users/{uid}/Items and /Items?userId=) are
    built and tested, though nothing selects the second yet. The family still
    works on 12.0, so migrating is optional; having both means it is a one-line
    change if 13.0 removes them, as the newly written removal policy allows.

ServerCapabilities is resolved once per connection from the version the server
already reported at connect. The version-to-flags mapping lives in exactly one
function and nothing else in the crate compares a version number: a `version < N`
at the point of use re-derives a domain fact where it is consumed, is unreadable
by its second occurrence, and cannot express a backport.

An unrecognised version resolves forward to the newest known generation rather
than being refused, because refusing would make every release expire the moment
the server upgrades. Only a version below the floor is refused.

This commit also carries the two fixes that are NOT capability branches, because
they live in the same files:

  - Authorization replaces X-Emby-Authorization, and ApiKey replaces the api_key
    query parameter. Jellyfin 12.0 disables both legacy spellings by default and
    a migration flips them on upgraded servers too, so this is what actually
    breaks against 12.0. The header value this app already built was always the
    correct MediaBrowser scheme, and both new spellings are ungated on 10.11.x —
    so it is a rename, not a branch. The query-parameter spelling is load-bearing
    rather than cosmetic: stream URLs go to mpv, ExoPlayer and the webview's
    <video>, none of which can send a header.
  - A type-filtered listing now states Recursive explicitly. 12.0 defaults it to
    true for a library parent with IncludeItemTypes where 10.11 returned
    immediate children, so the identical request returned a different result set
    with nothing in the response to say which rule applied. The value sent is the
    one that shipped, so this is a compatibility fix and not a silent behaviour
    change.

A structural test refuses any deprecated auth spelling reaching a request
builder, verified to fail when one is reintroduced. Behaviour is otherwise
preserved: the four previous endpoint builders become test-only shims over the
new table, so the ~20 existing tests encoding DR-116/DR-212/DR-257 now exercise
the production path rather than being deleted.

TRACES: UR-085 | IR-035, DR-279, DR-280, DR-287, DR-288

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-08 20:09:34 +02:00
co-authored by Claude Opus 5
parent f0c33a52c2
commit 8027fd5fac
4 changed files with 1452 additions and 325 deletions
+858
View File
@@ -0,0 +1,858 @@
//! 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<Item = impl AsRef<str>>, separator: &str) -> String {
values
.into_iter()
.map(|v| urlencoding::encode(v.as_ref()).into_owned())
.collect::<Vec<_>>()
.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<usize>,
) -> 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<usize>,
) -> 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"));
}
}