1use super::capabilities::ServerCapabilities;
24use super::types::{GetItemsOptions, SearchScope};
25
26pub struct Endpoint {
32 buf: String,
33 has_query: bool,
34}
35
36impl Endpoint {
37 pub fn new(path: &str) -> Self {
38 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 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 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
75fn 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
84fn 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
98const LIST_FIELDS: &str = "BackdropImageTags,ParentBackdropImageTags,UserData";
101
102const LIST_FIELDS_WITH_GENRES: &str =
105 "BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData";
106
107pub fn user_views(_caps: &ServerCapabilities, user_id: &str) -> String {
113 format!("/Users/{}/Views", user_id)
114}
115
116pub 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
137pub 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 ep = ep.raw_param("IncludeItemTypes", &encode_list(types, ","));
163 }
164
165 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 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 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 ep = ep.raw_param("Genres", &encode_list(genres, "|"));
210 }
211 }
212 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
221pub 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
249pub 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
278pub 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
305pub 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 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
349pub 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
374pub 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
394pub 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
416pub 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
439pub 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
460pub 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
476pub 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
489pub 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
500pub fn playback_info(_caps: &ServerCapabilities, item_id: &str) -> String {
506 format!("/Items/{}/PlaybackInfo", urlencoding::encode(item_id))
507}
508
509pub 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
522pub 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
533pub fn channels(_caps: &ServerCapabilities, user_id: &str) -> String {
537 Endpoint::new("/Channels").param("UserId", user_id).build()
538}
539
540pub fn playlists(_caps: &ServerCapabilities) -> &'static str {
544 "/Playlists"
545}
546
547pub fn playlist_as_item(_caps: &ServerCapabilities, playlist_id: &str) -> String {
552 format!("/Items/{}", urlencoding::encode(playlist_id))
553}
554
555pub 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
571pub 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
581pub 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
595pub 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
610pub 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 #[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 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 assert_eq!(Endpoint::new("/Items").build(), "/Items");
657 }
658
659 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 let plain = get_items(&caps(), "u1", "lib-1", None);
848 assert!(!plain.contains("Recursive="), "{plain}");
849 }
850
851 #[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}