Skip to main content

jellytau_lib/repository/
endpoints.rs

1//! Every Jellyfin route the online repository speaks, in one place.
2//!
3//! Before this module the endpoints were 57 inline `format!` literals scattered
4//! through `online.rs`, query strings baked in at the point of use. That is
5//! workable against exactly one server, and hostile to anything else: a second
6//! route shape means a conditional at every one of them.
7//!
8//! Each function here takes `&ServerCapabilities` and returns a **path**
9//! (`/Users/…`), except the handful documented as returning an absolute URL
10//! because they are handed to a media player rather than to the JSON helpers.
11//!
12//! # Percent-encoding
13//!
14//! Values are encoded, syntax is not. A genre named `Drama & Romance` or a
15//! search for `a?b` must not split into another parameter. [`Endpoint::param`]
16//! encodes; [`Endpoint::raw_param`] does not and is for values this module
17//! itself composed (numbers, and lists whose separator is meaningful to
18//! Jellyfin — `IncludeItemTypes` splits on `,`, `Genres` on `|`, so the
19//! separator survives while each element is encoded).
20//!
21//! TRACES: UR-085 | DR-279
22
23use super::capabilities::ServerCapabilities;
24use super::types::{GetItemsOptions, SearchScope};
25
26/// A path plus query string, which knows whether it needs `?` or `&` next.
27///
28/// The manual separator juggling this replaces produced the double-ampersand and
29/// trailing-ampersand cases an earlier test file spent four assertions on.
30/// Making it structural is cheaper than testing for it.
31pub struct Endpoint {
32    buf: String,
33    has_query: bool,
34}
35
36impl Endpoint {
37    pub fn new(path: &str) -> Self {
38        // A caller may hand in a path that already carries a query.
39        let has_query = path.contains('?');
40        Self {
41            buf: path.to_string(),
42            has_query,
43        }
44    }
45
46    fn separator(&mut self) -> char {
47        if self.has_query {
48            '&'
49        } else {
50            self.has_query = true;
51            '?'
52        }
53    }
54
55    /// Append `key=value`, percent-encoding the value.
56    pub fn param(mut self, key: &str, value: &str) -> Self {
57        let sep = self.separator();
58        self.buf
59            .push_str(&format!("{}{}={}", sep, key, urlencoding::encode(value)));
60        self
61    }
62
63    /// Append `key=value` verbatim. Only for values this module composed.
64    pub fn raw_param(mut self, key: &str, value: &str) -> Self {
65        let sep = self.separator();
66        self.buf.push_str(&format!("{}{}={}", sep, key, value));
67        self
68    }
69
70    pub fn build(self) -> String {
71        self.buf
72    }
73}
74
75/// Encode each element of a list while keeping the separator Jellyfin splits on.
76fn encode_list(values: impl IntoIterator<Item = impl AsRef<str>>, separator: &str) -> String {
77    values
78        .into_iter()
79        .map(|v| urlencoding::encode(v.as_ref()).into_owned())
80        .collect::<Vec<_>>()
81        .join(separator)
82}
83
84/// The base for a user-scoped item query.
85///
86/// This is the one place the two route shapes differ, and the reason the route
87/// table exists at all. `user_scoped_item_routes` is `true` for every generation
88/// today — see the flag's own documentation for why flipping it needs a cited
89/// source rather than a guess (DR-282).
90fn user_items_root(caps: &ServerCapabilities, user_id: &str) -> Endpoint {
91    if caps.user_scoped_item_routes {
92        Endpoint::new(&format!("/Users/{}/Items", user_id))
93    } else {
94        Endpoint::new("/Items").param("userId", user_id)
95    }
96}
97
98/// The standard field set for a list view. `People` is deliberately absent — it
99/// is only wanted in the detail view, and it is not small.
100const LIST_FIELDS: &str = "BackdropImageTags,ParentBackdropImageTags,UserData";
101
102/// As [`LIST_FIELDS`], plus what the offline store needs to derive genre lists
103/// and per-genre counts from cached rows.
104const LIST_FIELDS_WITH_GENRES: &str =
105    "BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData";
106
107// ===== Libraries and items =====
108
109/// The user's library views.
110///
111/// TRACES: UR-007, UR-085 | JA-003, DR-279
112pub fn user_views(_caps: &ServerCapabilities, user_id: &str) -> String {
113    format!("/Users/{}/Views", user_id)
114}
115
116/// One item, in detail. `People`, `MediaStreams` and `MediaSources` are named
117/// here and nowhere else — the detail view is the only place they are wanted.
118///
119/// TRACES: UR-007, UR-085 | JA-005, DR-279
120pub fn item_detail(caps: &ServerCapabilities, user_id: &str, item_id: &str) -> String {
121    let base = if caps.user_scoped_item_routes {
122        Endpoint::new(&format!(
123            "/Users/{}/Items/{}",
124            user_id,
125            urlencoding::encode(item_id)
126        ))
127    } else {
128        Endpoint::new(&format!("/Items/{}", urlencoding::encode(item_id))).param("userId", user_id)
129    };
130    base.raw_param(
131        "Fields",
132        "BackdropImageTags,ParentBackdropImageTags,People,MediaStreams,MediaSources,PremiereDate,UserData",
133    )
134    .build()
135}
136
137/// A folder listing.
138///
139/// Every value is percent-encoded before it goes into the query string: these
140/// are values, not URL syntax, so a space or an `&` in one must not split it
141/// into another parameter.
142///
143/// TRACES: UR-007, UR-067, UR-085 | DR-116, DR-212, DR-279 | UT-104, UT-206
144pub fn get_items(
145    caps: &ServerCapabilities,
146    user_id: &str,
147    parent_id: &str,
148    options: Option<&GetItemsOptions>,
149) -> String {
150    let mut ep = user_items_root(caps, user_id).param("ParentId", parent_id);
151
152    if let Some(opts) = options {
153        if let Some(limit) = opts.limit {
154            ep = ep.raw_param("Limit", &limit.to_string());
155        }
156        if let Some(start_index) = opts.start_index {
157            ep = ep.raw_param("StartIndex", &start_index.to_string());
158        }
159        if let Some(types) = &opts.include_item_types {
160            // The comma is the list separator Jellyfin splits on, so encode
161            // each type rather than the joined string.
162            ep = ep.raw_param("IncludeItemTypes", &encode_list(types, ","));
163        }
164
165        // An explicit sort always wins; the container's default only fills the
166        // gap when the caller named none. A caller that names neither gets no
167        // SortBy at all, leaving the server's own order intact.
168        //
169        // TRACES: UR-007 | DR-257 | UT-229
170        let default_sort = super::types::default_listing_sort(opts.parent_kind);
171        let sort_by = opts
172            .sort_by
173            .as_deref()
174            .or(default_sort.map(|(field, _)| field));
175        let sort_order = opts
176            .sort_order
177            .as_deref()
178            .or(default_sort.map(|(_, order)| order));
179
180        if let Some(sort_by) = sort_by {
181            // SortBy is likewise comma-delimited ("ParentIndexNumber,IndexNumber,
182            // SortName"), so encode per field.
183            ep = ep.raw_param("SortBy", &encode_list(sort_by.split(','), ","));
184        }
185        if let Some(sort_order) = sort_order {
186            ep = ep.param("SortOrder", sort_order);
187        }
188        // Jellyfin 12.0 defaults `recursive` to true when the parent is a
189        // library folder and `IncludeItemTypes` is set, where 10.11 listed only
190        // immediate children — the same request, a different result set. State
191        // it explicitly whenever a type filter is present so both generations
192        // agree, and state the behaviour that shipped rather than adopting the
193        // new server-side default silently.
194        //
195        // TRACES: UR-085 | DR-288
196        let type_filtered = opts
197            .include_item_types
198            .as_ref()
199            .is_some_and(|types| !types.is_empty());
200        match (opts.recursive, type_filtered) {
201            (Some(recursive), _) => ep = ep.raw_param("Recursive", &recursive.to_string()),
202            (None, true) => ep = ep.raw_param("Recursive", "false"),
203            (None, false) => {}
204        }
205        if let Some(genres) = &opts.genres {
206            if !genres.is_empty() {
207                // Genre names may contain spaces or ampersands; `|` is the
208                // separator Jellyfin splits this one on.
209                ep = ep.raw_param("Genres", &encode_list(genres, "|"));
210            }
211        }
212        // TRACES: UR-067 | DR-116 | UT-104
213        if opts.favorites_only == Some(true) {
214            ep = ep.raw_param("Filters", "IsFavorite");
215        }
216    }
217
218    ep.raw_param("Fields", LIST_FIELDS_WITH_GENRES).build()
219}
220
221/// A "recently added" listing.
222///
223/// `GroupItems=true` is the load-bearing parameter: Jellyfin defaults it to
224/// `false`, which returns each newly-added *leaf* separately, so importing one
225/// 14-track album pushed 14 rows into "recently added" and buried everything
226/// else. With grouping on, the server collapses children into the container
227/// that was added — an album appears once, while movies (which have no such
228/// container) are unaffected.
229///
230/// TRACES: UR-024, UR-034, UR-085 | IR-024, JA-016, DR-279
231pub fn latest_items(
232    caps: &ServerCapabilities,
233    user_id: &str,
234    parent_id: &str,
235    limit: Option<usize>,
236) -> String {
237    let base = if caps.user_scoped_item_routes {
238        Endpoint::new(&format!("/Users/{}/Items/Latest", user_id))
239    } else {
240        Endpoint::new("/Items/Latest").param("userId", user_id)
241    };
242    base.param("ParentId", parent_id)
243        .raw_param("Limit", &limit.unwrap_or(16).to_string())
244        .raw_param("GroupItems", "true")
245        .raw_param("Fields", LIST_FIELDS)
246        .build()
247}
248
249/// The resume ("Continue Watching") listing.
250///
251/// TRACES: UR-019, UR-085 | JA-013, DR-279
252pub fn resume_items(
253    caps: &ServerCapabilities,
254    user_id: &str,
255    limit: usize,
256    include_item_types: Option<&str>,
257    parent_id: Option<&str>,
258) -> String {
259    let base = if caps.user_scoped_item_routes {
260        Endpoint::new(&format!("/Users/{}/Items/Resume", user_id))
261    } else {
262        Endpoint::new("/Items/Resume").param("userId", user_id)
263    };
264    let ep = base
265        .raw_param("Limit", &limit.to_string())
266        .raw_param("MediaTypes", "Video");
267    let ep = match include_item_types {
268        Some(types) => ep.raw_param("IncludeItemTypes", types),
269        None => ep,
270    };
271    let ep = ep.raw_param("Fields", LIST_FIELDS);
272    match parent_id {
273        Some(pid) => ep.param("ParentId", pid).build(),
274        None => ep.build(),
275    }
276}
277
278/// A Next Up listing.
279///
280/// `EnableResumable=false` is the point of this query: the server default is
281/// `true`, which makes a partially-watched episode its own series' "next up" —
282/// the very episode `/Items/Resume` returns — so Continue Watching and Next Up
283/// end up showing the same cards. Servers predating the parameter ignore it,
284/// which is why the frontend also drops in-progress entries (DR-197).
285///
286/// TRACES: UR-023, UR-059, UR-085 | DR-197, DR-279, JA-014, JA-036 | UT-190, UT-191
287pub fn next_up(
288    _caps: &ServerCapabilities,
289    user_id: &str,
290    series_id: Option<&str>,
291    limit: Option<usize>,
292) -> String {
293    let ep = Endpoint::new("/Shows/NextUp")
294        .param("UserId", user_id)
295        .raw_param("Limit", &limit.unwrap_or(16).to_string())
296        .raw_param("EnableResumable", "false")
297        .raw_param("Fields", LIST_FIELDS);
298
299    match series_id {
300        Some(sid) => ep.param("SeriesId", sid).build(),
301        None => ep.build(),
302    }
303}
304
305/// A favourites listing.
306///
307/// `scope` is expanded here — `SearchScope::All` yields `None`, and the
308/// `IncludeItemTypes` filter is then **omitted entirely** rather than sent as a
309/// union, which would silently drop every type nobody enumerated (see
310/// `SearchScope::item_types`).
311///
312/// TRACES: UR-067, UR-085 | DR-115, DR-279, JA-033 | UT-100
313pub fn favorites(
314    caps: &ServerCapabilities,
315    user_id: &str,
316    scope: SearchScope,
317    options: Option<&GetItemsOptions>,
318) -> String {
319    let mut ep = user_items_root(caps, user_id)
320        .raw_param("Filters", "IsFavorite")
321        .raw_param("Recursive", "true");
322
323    if let Some(types) = scope.item_types() {
324        ep = ep.raw_param("IncludeItemTypes", &types.join(","));
325    }
326
327    // Jellyfin has no "date favourited", so name order is the only stable sort
328    // available; callers may still override it.
329    let sort_by = options
330        .and_then(|o| o.sort_by.as_deref())
331        .unwrap_or("SortName");
332    let sort_order = options
333        .and_then(|o| o.sort_order.as_deref())
334        .unwrap_or("Ascending");
335    ep = ep
336        .raw_param("SortBy", sort_by)
337        .raw_param("SortOrder", sort_order);
338
339    if let Some(limit) = options.and_then(|o| o.limit) {
340        ep = ep.raw_param("Limit", &limit.to_string());
341    }
342    if let Some(start_index) = options.and_then(|o| o.start_index) {
343        ep = ep.raw_param("StartIndex", &start_index.to_string());
344    }
345
346    ep.raw_param("Fields", LIST_FIELDS_WITH_GENRES).build()
347}
348
349/// Items sorted by when they were last played, filtered to played ones.
350///
351/// TRACES: UR-034, UR-085 | DR-279
352pub fn played_items_by_date(
353    caps: &ServerCapabilities,
354    user_id: &str,
355    include_item_types: &str,
356    limit: usize,
357    sort_order: &str,
358    parent_id: Option<&str>,
359) -> String {
360    let ep = user_items_root(caps, user_id)
361        .raw_param("SortBy", "DatePlayed")
362        .raw_param("SortOrder", sort_order)
363        .raw_param("IncludeItemTypes", include_item_types)
364        .raw_param("Limit", &limit.to_string())
365        .raw_param("Recursive", "true")
366        .raw_param("Filters", "IsPlayed")
367        .raw_param("Fields", LIST_FIELDS);
368    match parent_id {
369        Some(pid) => ep.param("ParentId", pid).build(),
370        None => ep.build(),
371    }
372}
373
374/// Genres, with the item counts the frontend uses to pick a diverse subset.
375///
376/// TRACES: UR-085 | DR-279
377pub fn genres(
378    _caps: &ServerCapabilities,
379    user_id: &str,
380    include_item_types: &str,
381    parent_id: Option<&str>,
382) -> String {
383    let ep = Endpoint::new("/Genres")
384        .param("UserId", user_id)
385        .raw_param("IncludeItemTypes", include_item_types)
386        .raw_param("Recursive", "true")
387        .raw_param("Fields", "ItemCounts");
388    match parent_id {
389        Some(pid) => ep.param("ParentId", pid).build(),
390        None => ep.build(),
391    }
392}
393
394/// A search.
395///
396/// TRACES: UR-085 | DR-279
397pub fn search(
398    caps: &ServerCapabilities,
399    user_id: &str,
400    term: &str,
401    limit: usize,
402    include_item_types: Option<&[String]>,
403) -> String {
404    let ep = user_items_root(caps, user_id)
405        .param("SearchTerm", term)
406        .raw_param("Limit", &limit.to_string())
407        .raw_param("Recursive", "true");
408    match include_item_types {
409        Some(types) if !types.is_empty() => ep
410            .raw_param("IncludeItemTypes", &encode_list(types, ","))
411            .build(),
412        _ => ep.build(),
413    }
414}
415
416/// A person's filmography.
417///
418/// TRACES: UR-036, UR-085 | JA-031, DR-279
419pub fn items_by_person(
420    caps: &ServerCapabilities,
421    user_id: &str,
422    person_id: &str,
423    limit: usize,
424    include_item_types: Option<&[String]>,
425) -> String {
426    let ep = user_items_root(caps, user_id)
427        .param("PersonIds", person_id)
428        .raw_param("Limit", &limit.to_string())
429        .raw_param("Recursive", "true")
430        .raw_param("Fields", LIST_FIELDS);
431    match include_item_types {
432        Some(types) if !types.is_empty() => ep
433            .raw_param("IncludeItemTypes", &encode_list(types, ","))
434            .build(),
435        _ => ep.build(),
436    }
437}
438
439/// A person as an item.
440///
441/// Jellyfin serves people through the ordinary user-item endpoint rather than
442/// anything under `/Persons`; the cast entries on an item's `People` field carry
443/// the ids this is called with.
444///
445/// TRACES: UR-035, UR-036, UR-085 | IR-022, JA-030, DR-279
446pub fn person(caps: &ServerCapabilities, user_id: &str, person_id: &str) -> String {
447    if caps.user_scoped_item_routes {
448        format!(
449            "/Users/{}/Items/{}",
450            user_id,
451            urlencoding::encode(person_id)
452        )
453    } else {
454        Endpoint::new(&format!("/Items/{}", urlencoding::encode(person_id)))
455            .param("userId", user_id)
456            .build()
457    }
458}
459
460/// Items similar to one item.
461///
462/// TRACES: UR-085 | DR-279
463pub fn similar_items(
464    _caps: &ServerCapabilities,
465    item_id: &str,
466    user_id: &str,
467    limit: usize,
468) -> String {
469    Endpoint::new(&format!("/Items/{}/Similar", urlencoding::encode(item_id)))
470        .param("UserId", user_id)
471        .raw_param("Limit", &limit.to_string())
472        .raw_param("Fields", LIST_FIELDS)
473        .build()
474}
475
476// ===== User data mutations =====
477
478/// Favourite / un-favourite an item (POST to set, DELETE to clear).
479///
480/// TRACES: UR-067, UR-085 | JA-033, DR-279
481pub fn favorite_item(_caps: &ServerCapabilities, user_id: &str, item_id: &str) -> String {
482    format!(
483        "/Users/{}/FavoriteItems/{}",
484        user_id,
485        urlencoding::encode(item_id)
486    )
487}
488
489/// Mark played / clear watch history (POST to set, DELETE to clear).
490///
491/// TRACES: UR-025, UR-085 | JA-035, DR-279
492pub fn played_item(_caps: &ServerCapabilities, user_id: &str, item_id: &str) -> String {
493    format!(
494        "/Users/{}/PlayedItems/{}",
495        user_id,
496        urlencoding::encode(item_id)
497    )
498}
499
500// ===== Playback =====
501
502/// Playback negotiation for one item.
503///
504/// TRACES: UR-004, UR-085 | JA-021, DR-279
505pub fn playback_info(_caps: &ServerCapabilities, item_id: &str) -> String {
506    format!("/Items/{}/PlaybackInfo", urlencoding::encode(item_id))
507}
508
509/// Playback reporting.
510///
511/// TRACES: UR-020, UR-085 | JA-010, JA-011, JA-012, DR-279
512pub fn sessions_playing(_caps: &ServerCapabilities) -> &'static str {
513    "/Sessions/Playing"
514}
515pub fn sessions_playing_progress(_caps: &ServerCapabilities) -> &'static str {
516    "/Sessions/Playing/Progress"
517}
518pub fn sessions_playing_stopped(_caps: &ServerCapabilities) -> &'static str {
519    "/Sessions/Playing/Stopped"
520}
521
522/// Live TV channels.
523///
524/// TRACES: UR-085 | DR-279
525pub fn live_tv_channels(_caps: &ServerCapabilities, user_id: &str) -> String {
526    Endpoint::new("/LiveTv/Channels")
527        .param("UserId", user_id)
528        .raw_param("Fields", "PrimaryImageAspectRatio,Overview")
529        .raw_param("EnableImageTypes", "Primary")
530        .build()
531}
532
533/// Generic channels.
534///
535/// TRACES: UR-085 | DR-279
536pub fn channels(_caps: &ServerCapabilities, user_id: &str) -> String {
537    Endpoint::new("/Channels").param("UserId", user_id).build()
538}
539
540// ===== Playlists =====
541
542/// TRACES: UR-062, UR-085 | DR-279
543pub fn playlists(_caps: &ServerCapabilities) -> &'static str {
544    "/Playlists"
545}
546
547/// A playlist as an item — used for rename and delete, which are `/Items`
548/// operations rather than `/Playlists` ones.
549///
550/// TRACES: UR-062, UR-085 | DR-279
551pub fn playlist_as_item(_caps: &ServerCapabilities, playlist_id: &str) -> String {
552    format!("/Items/{}", urlencoding::encode(playlist_id))
553}
554
555/// TRACES: UR-062, UR-085 | DR-279
556pub fn playlist_items(_caps: &ServerCapabilities, playlist_id: &str, user_id: &str) -> String {
557    Endpoint::new(&format!(
558        "/Playlists/{}/Items",
559        urlencoding::encode(playlist_id)
560    ))
561    .param("UserId", user_id)
562    .raw_param(
563        "Fields",
564        "PrimaryImageTag,Artists,AlbumId,Album,AlbumArtist,RunTimeTicks,ArtistItems",
565    )
566    .raw_param("StartIndex", "0")
567    .raw_param("Limit", "10000")
568    .build()
569}
570
571/// TRACES: UR-062, UR-085 | DR-279
572pub fn playlist_items_add(_caps: &ServerCapabilities, playlist_id: &str, ids: &str) -> String {
573    Endpoint::new(&format!(
574        "/Playlists/{}/Items",
575        urlencoding::encode(playlist_id)
576    ))
577    .param("Ids", ids)
578    .build()
579}
580
581/// TRACES: UR-062, UR-085 | DR-279
582pub fn playlist_items_remove(
583    _caps: &ServerCapabilities,
584    playlist_id: &str,
585    entry_ids: &str,
586) -> String {
587    Endpoint::new(&format!(
588        "/Playlists/{}/Items",
589        urlencoding::encode(playlist_id)
590    ))
591    .param("EntryIds", entry_ids)
592    .build()
593}
594
595/// TRACES: UR-062, UR-085 | DR-279
596pub fn playlist_item_move(
597    _caps: &ServerCapabilities,
598    playlist_id: &str,
599    item_id: &str,
600    new_index: u32,
601) -> String {
602    format!(
603        "/Playlists/{}/Items/{}/Move/{}",
604        urlencoding::encode(playlist_id),
605        urlencoding::encode(item_id),
606        new_index
607    )
608}
609
610// ===== Plugin =====
611
612/// The JRay plugin's per-item context. Not core Jellyfin; absent servers 404 and
613/// the caller treats that as "no context", so it needs no capability flag.
614///
615/// TRACES: UR-085 | DR-279
616pub fn jray_context(_caps: &ServerCapabilities, item_id: &str, position_seconds: f64) -> String {
617    format!(
618        "/Plugins/JRay/Items/{}/jray?t={}",
619        urlencoding::encode(item_id),
620        position_seconds
621    )
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627
628    fn caps() -> ServerCapabilities {
629        ServerCapabilities::assumed()
630    }
631
632    /// The builder must never emit a double or trailing separator, and must use
633    /// `?` exactly once. This is structural now rather than asserted at every
634    /// call site.
635    ///
636    /// TRACES: UR-085 | DR-279
637    #[test]
638    fn query_separators_are_structural() {
639        let url = Endpoint::new("/Items")
640            .param("a", "1")
641            .param("b", "2")
642            .raw_param("c", "3")
643            .build();
644        assert_eq!(url, "/Items?a=1&b=2&c=3");
645        assert_eq!(url.matches('?').count(), 1);
646        assert!(!url.contains("&&"));
647        assert!(!url.ends_with('&'));
648
649        // A path that already carries a query continues it rather than
650        // starting a second one.
651        let continued = Endpoint::new("/Items?x=0").param("y", "1").build();
652        assert_eq!(continued, "/Items?x=0&y=1");
653        assert_eq!(continued.matches('?').count(), 1);
654
655        // No parameters at all means no `?`.
656        assert_eq!(Endpoint::new("/Items").build(), "/Items");
657    }
658
659    /// Values are encoded, list separators are not.
660    ///
661    /// TRACES: UR-007, UR-085 | DR-212, DR-279 | UT-206
662    #[test]
663    fn values_are_encoded_but_list_separators_survive() {
664        let url = Endpoint::new("/x").param("SearchTerm", "a?b&c d").build();
665        assert!(url.contains("SearchTerm=a%3Fb%26c%20d"), "{url}");
666
667        assert_eq!(
668            encode_list(["Drama & Romance", "Sci-Fi"], "|"),
669            "Drama%20%26%20Romance|Sci-Fi"
670        );
671        assert_eq!(encode_list(["Movie", "Series"], ","), "Movie,Series");
672    }
673
674    /// The user-scoped split is the reason this module exists. Both shapes must
675    /// be well-formed, and the default must be byte-identical to what shipped.
676    ///
677    /// TRACES: UR-085 | DR-279, DR-282
678    #[test]
679    fn both_user_scoped_route_shapes_are_well_formed() {
680        let legacy = caps();
681        assert!(legacy.user_scoped_item_routes, "the shipped default");
682        let url = get_items(&legacy, "u1", "lib-1", None);
683        assert!(url.starts_with("/Users/u1/Items?ParentId=lib-1"), "{url}");
684
685        let mut modern = caps();
686        modern.user_scoped_item_routes = false;
687        let url = get_items(&modern, "u1", "lib-1", None);
688        assert!(url.starts_with("/Items?userId=u1&ParentId=lib-1"), "{url}");
689        assert_eq!(url.matches('?').count(), 1, "{url}");
690        assert!(!url.contains("/Users/"), "{url}");
691    }
692
693    /// Every route must be well-formed under *both* shapes — a flipped flag
694    /// must not produce a malformed URL anywhere.
695    ///
696    /// TRACES: UR-085 | DR-279, DR-282
697    #[test]
698    fn no_route_is_malformed_under_either_shape() {
699        for user_scoped in [true, false] {
700            let mut c = caps();
701            c.user_scoped_item_routes = user_scoped;
702
703            let routes = vec![
704                user_views(&c, "u1"),
705                item_detail(&c, "u1", "i1"),
706                get_items(&c, "u1", "p1", None),
707                latest_items(&c, "u1", "p1", Some(8)),
708                resume_items(&c, "u1", 10, None, None),
709                resume_items(&c, "u1", 10, Some("Movie"), Some("lib-9")),
710                next_up(&c, "u1", Some("s1"), Some(5)),
711                favorites(&c, "u1", SearchScope::All, None),
712                played_items_by_date(&c, "u1", "Audio", 20, "Descending", None),
713                genres(&c, "u1", "MusicAlbum", Some("lib-1")),
714                search(&c, "u1", "query", 25, Some(&["Movie".to_string()])),
715                items_by_person(&c, "u1", "p9", 50, None),
716                person(&c, "u1", "p9"),
717                similar_items(&c, "i1", "u1", 12),
718                favorite_item(&c, "u1", "i1"),
719                played_item(&c, "u1", "i1"),
720                playback_info(&c, "i1"),
721                live_tv_channels(&c, "u1"),
722                channels(&c, "u1"),
723                playlist_as_item(&c, "pl1"),
724                playlist_items(&c, "pl1", "u1"),
725                playlist_items_add(&c, "pl1", "a,b"),
726                playlist_items_remove(&c, "pl1", "e1"),
727                playlist_item_move(&c, "pl1", "i1", 3u32),
728                jray_context(&c, "i1", 42.5),
729            ];
730
731            for route in routes {
732                assert!(route.starts_with('/'), "{route}");
733                assert!(!route.contains("&&"), "{route}");
734                assert!(!route.contains("?&"), "{route}");
735                assert!(!route.ends_with('&'), "{route}");
736                assert!(!route.ends_with('?'), "{route}");
737                assert!(
738                    route.matches('?').count() <= 1,
739                    "more than one query separator: {route}"
740                );
741            }
742        }
743    }
744
745    /// TRACES: UR-024, UR-034 | IR-024, JA-016
746    #[test]
747    fn latest_items_groups_children_into_containers() {
748        let url = latest_items(&caps(), "u1", "lib-1", Some(16));
749        assert!(url.contains("GroupItems=true"), "{url}");
750        assert!(url.contains("ParentId=lib-1"), "{url}");
751        assert!(url.contains("Limit=16"), "{url}");
752    }
753
754    /// TRACES: UR-059 | DR-197, JA-036 | UT-190, UT-191
755    #[test]
756    fn next_up_excludes_resumable_and_scopes_to_series() {
757        let url = next_up(&caps(), "u1", None, Some(12));
758        assert!(url.contains("EnableResumable=false"), "{url}");
759        assert!(url.contains("UserId=u1"), "{url}");
760        assert!(url.contains("Limit=12"), "{url}");
761        assert!(!url.contains("SeriesId"), "{url}");
762
763        let scoped = next_up(&caps(), "u1", Some("series-a"), None);
764        assert!(scoped.contains("SeriesId=series-a"), "{scoped}");
765        assert!(scoped.contains("Limit=16"), "default limit: {scoped}");
766    }
767
768    /// `All` must omit the type filter entirely rather than send a union, which
769    /// would silently drop every type nobody enumerated.
770    ///
771    /// TRACES: UR-067 | DR-115 | UT-100
772    #[test]
773    fn favorites_all_scope_omits_the_type_filter() {
774        let url = favorites(&caps(), "u1", SearchScope::All, None);
775        assert!(!url.contains("IncludeItemTypes"), "{url}");
776        assert!(url.contains("Filters=IsFavorite"), "{url}");
777    }
778
779    /// TRACES: UR-067 | DR-115 | UT-100
780    #[test]
781    fn favorites_honours_paging_and_sort() {
782        let url = favorites(
783            &caps(),
784            "u1",
785            SearchScope::All,
786            Some(&GetItemsOptions {
787                limit: Some(20),
788                start_index: Some(40),
789                sort_by: Some("Random".to_string()),
790                sort_order: Some("Descending".to_string()),
791                ..Default::default()
792            }),
793        );
794        assert!(url.contains("&Limit=20"), "{url}");
795        assert!(url.contains("&StartIndex=40"), "{url}");
796        assert!(url.contains("&SortBy=Random&SortOrder=Descending"), "{url}");
797    }
798
799    /// The detail view is the only caller that wants People/MediaStreams; a list
800    /// query must not drag them along.
801    ///
802    /// Jellyfin 12.0 changed `GetItems` to default `recursive` to **true** when
803    /// the parent is a library folder and `IncludeItemTypes` is set — so the
804    /// identical request returns a different result set on the two generations.
805    /// Sending an explicit value makes them agree, and `false` is what shipped.
806    ///
807    /// Source: `ItemsController.cs` in v12.0 — `if (folder is ICollectionFolder
808    /// && includeItemTypes.Length > 0) { recursive ??= true; }`
809    ///
810    /// TRACES: UR-085 | DR-288
811    #[test]
812    fn a_type_filtered_listing_always_states_recursive() {
813        let filtered = get_items(
814            &caps(),
815            "u1",
816            "lib-1",
817            Some(&GetItemsOptions {
818                include_item_types: Some(vec!["Movie".to_string()]),
819                ..Default::default()
820            }),
821        );
822        assert!(
823            filtered.contains("Recursive="),
824            "a type-filtered listing must state Recursive or 12.0 will infer a \
825             different one than 10.11: {filtered}"
826        );
827        assert!(
828            filtered.contains("Recursive=false"),
829            "and it must state the behaviour that shipped: {filtered}"
830        );
831
832        // An explicit choice by the caller still wins.
833        let explicit = get_items(
834            &caps(),
835            "u1",
836            "lib-1",
837            Some(&GetItemsOptions {
838                include_item_types: Some(vec!["Movie".to_string()]),
839                recursive: Some(true),
840                ..Default::default()
841            }),
842        );
843        assert!(explicit.contains("Recursive=true"), "{explicit}");
844        assert_eq!(explicit.matches("Recursive=").count(), 1, "{explicit}");
845
846        // No type filter, no inference to defend against, no parameter.
847        let plain = get_items(&caps(), "u1", "lib-1", None);
848        assert!(!plain.contains("Recursive="), "{plain}");
849    }
850
851    /// TRACES: UR-007 | DR-279
852    #[test]
853    fn only_the_detail_route_requests_the_heavy_fields() {
854        assert!(item_detail(&caps(), "u1", "i1").contains("People"));
855        assert!(!get_items(&caps(), "u1", "p1", None).contains("People"));
856        assert!(!latest_items(&caps(), "u1", "p1", None).contains("MediaStreams"));
857    }
858}