Skip to main content

jellytau_lib/repository/
types.rs

1use serde::{Deserialize, Serialize};
2
3/// Error types for repository operations
4#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
5#[serde(tag = "type", rename_all = "lowercase")]
6pub enum RepoError {
7    Network { message: String },
8    Authentication { message: String },
9    NotFound { message: String },
10    Server { message: String },
11    Database { message: String },
12    Offline,
13}
14
15impl std::fmt::Display for RepoError {
16    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17        match self {
18            RepoError::Network { message } => write!(f, "Network error: {}", message),
19            RepoError::Authentication { message } => write!(f, "Authentication error: {}", message),
20            RepoError::NotFound { message } => write!(f, "Not found: {}", message),
21            RepoError::Server { message } => write!(f, "Server error: {}", message),
22            RepoError::Database { message } => write!(f, "Database error: {}", message),
23            RepoError::Offline => write!(f, "Offline - no server connection"),
24        }
25    }
26}
27
28impl std::error::Error for RepoError {}
29
30/// Library (media collection)
31#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub struct Library {
34    pub id: String,
35    pub name: String,
36    pub collection_type: String,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub image_tag: Option<String>,
39    /// The favourites scope this library's contents fall under, or `None` for a
40    /// library kind favourites does not carve up (Live TV, channels, books…).
41    ///
42    /// Derived here rather than in the UI: which collection type maps to which
43    /// scope is Jellyfin vocabulary, and the frontend must not hold a
44    /// collection-type → category table any more than an item-type one. See
45    /// `SearchScope::for_collection_type`.
46    ///
47    /// TRACES: UR-075 | DR-175
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub favorites_scope: Option<SearchScope>,
50}
51
52impl Library {
53    /// Build a library, deriving everything that follows from its collection
54    /// type. Prefer this over the struct literal so a new derived field cannot
55    /// be forgotten at one of the construction sites.
56    pub fn new(
57        id: String,
58        name: String,
59        collection_type: String,
60        image_tag: Option<String>,
61    ) -> Self {
62        let favorites_scope = SearchScope::for_collection_type(&collection_type);
63        Self {
64            id,
65            name,
66            collection_type,
67            image_tag,
68            favorites_scope,
69        }
70    }
71}
72
73/// User-specific data for an item (playback state, favorites, etc.)
74#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
75#[serde(rename_all = "camelCase")]
76pub struct UserData {
77    /// Legacy Jellyfin resume position in ticks. Being replaced by
78    /// `playback_position_ms`; dual-carried while the frontend migrates
79    /// (docs/specs/frontend-domain-model.md). New code should read the ms field.
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub playback_position_ticks: Option<i64>,
82    /// Resume position in milliseconds — the neutral replacement for
83    /// `playback_position_ticks`. Populated from ticks by the mapping; the
84    /// frontend never divides ticks itself.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub playback_position_ms: Option<i64>,
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub is_played: Option<bool>,
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub is_favorite: Option<bool>,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub play_count: Option<i32>,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub last_played_date: Option<String>,
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub playback_context_type: Option<String>,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub playback_context_id: Option<String>,
99}
100
101/// Artist item with ID and name (for clickable artist links)
102#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
103#[serde(rename_all = "camelCase")]
104pub struct ArtistItem {
105    #[serde(alias = "Id")]
106    pub id: String,
107    #[serde(alias = "Name")]
108    pub name: String,
109}
110
111/// Person (cast/crew member) - for movies, series, and episodes
112#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
113#[serde(rename_all = "camelCase")]
114pub struct Person {
115    /// Deserializes from API's "Id" field (PascalCase), serializes as "id" (camelCase to frontend)
116    #[serde(alias = "Id")]
117    #[serde(default)]
118    pub id: String,
119    /// Deserializes from API's "Name" field (PascalCase), serializes as "name" (camelCase to frontend)
120    #[serde(alias = "Name")]
121    #[serde(default)]
122    pub name: String,
123    /// Person type from Jellyfin API (Actor, Director, Writer, etc.)
124    /// Deserializes from API's "Type" field (PascalCase), serializes as "type" (camelCase to frontend)
125    #[serde(rename = "type")]
126    #[serde(alias = "Type")]
127    #[serde(default)]
128    pub person_type: String,
129    /// Deserializes from API's "Role" field (PascalCase), serializes as "role" (camelCase to frontend)
130    #[serde(alias = "Role")]
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub role: Option<String>,
133    /// Deserializes from API's "PrimaryImageTag" field (PascalCase), serializes as "primaryImageTag" (camelCase to frontend)
134    #[serde(alias = "PrimaryImageTag")]
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub primary_image_tag: Option<String>,
137}
138
139/// Media item
140#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
141#[serde(rename_all = "camelCase")]
142pub struct MediaItem {
143    pub id: String,
144    pub name: String,
145    /// Legacy Jellyfin item-type string (`"Audio"`, `"MusicAlbum"`, …).
146    ///
147    /// Dual-carry migration (docs/specs/frontend-domain-model.md): `kind` below
148    /// is the neutral replacement. This field stays while the frontend migrates
149    /// off it, then is removed in a later phase. New Rust code should read
150    /// `kind`, not this.
151    #[serde(rename = "type")]
152    pub item_type: String,
153    /// Provider-neutral classification — the replacement for `item_type`.
154    /// Populated by the Jellyfin mapping; defaults to `Other` for the handful of
155    /// construction sites that have not been migrated yet.
156    #[serde(default)]
157    pub kind: crate::domain::MediaKind,
158    /// Whether this item is a folder/container (vs a playable leaf). Used to
159    /// decide whether a channel item drills into a list or plays directly.
160    #[serde(default)]
161    pub is_folder: bool,
162    pub server_id: String,
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub parent_id: Option<String>,
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub library_id: Option<String>,
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub overview: Option<String>,
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub genres: Option<Vec<String>>,
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub production_year: Option<i32>,
173    /// ISO-8601 release/air date (Jellyfin `PremiereDate`). Used to sort
174    /// podcast episodes by release date.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub premiere_date: Option<String>,
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub community_rating: Option<f64>,
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub official_rating: Option<String>,
181    /// Legacy Jellyfin duration in ticks (100 ns units). Being replaced by
182    /// `duration_ms`; dual-carried while the frontend migrates
183    /// (docs/specs/frontend-domain-model.md). New code should read `duration_ms`.
184    #[serde(skip_serializing_if = "Option::is_none")]
185    #[serde(rename = "runTimeTicks")]
186    pub runtime_ticks: Option<i64>,
187    /// Duration in milliseconds — the neutral replacement for `runtime_ticks`.
188    /// Ticks never reach the frontend; this does.
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub duration_ms: Option<i64>,
191    /// Legacy Jellyfin primary image tag. Being replaced by `image_id`;
192    /// dual-carried while the frontend migrates. New code should read `image_id`.
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub primary_image_tag: Option<String>,
195    /// Neutral image identifier the frontend resolves to a URL via the image
196    /// command — the replacement for `primary_image_tag`. Same value today
197    /// (Jellyfin's tag is the id); the rename removes the provider term.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub image_id: Option<String>,
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub backdrop_image_tags: Option<Vec<String>>,
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub parent_backdrop_image_tags: Option<Vec<String>>,
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub album_id: Option<String>,
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub album_name: Option<String>,
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub album_artist: Option<String>,
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub artists: Option<Vec<String>>,
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub artist_items: Option<Vec<ArtistItem>>,
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub index_number: Option<i32>,
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub parent_index_number: Option<i32>,
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub series_id: Option<String>,
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub series_name: Option<String>,
222    #[serde(skip_serializing_if = "Option::is_none")]
223    pub season_id: Option<String>,
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub season_name: Option<String>,
226    #[serde(skip_serializing_if = "Option::is_none")]
227    pub user_data: Option<UserData>,
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub media_streams: Option<Vec<MediaStream>>,
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub media_sources: Option<Vec<MediaSource>>,
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub people: Option<Vec<Person>>,
234}
235
236/// Media stream information (audio, video, subtitle tracks)
237#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
238#[serde(rename_all = "camelCase")]
239pub struct MediaStream {
240    /// Legacy Jellyfin stream type string ("Audio"/"Video"/"Subtitle"). Being
241    /// replaced by `kind`; dual-carried while the frontend migrates.
242    #[serde(rename = "type")]
243    pub stream_type: String,
244    /// Provider-neutral stream classification — replaces `stream_type`.
245    #[serde(default)]
246    pub kind: crate::domain::StreamKind,
247    #[serde(skip_serializing_if = "Option::is_none")]
248    pub codec: Option<String>,
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub language: Option<String>,
251    #[serde(skip_serializing_if = "Option::is_none")]
252    pub display_title: Option<String>,
253    pub index: i32,
254    pub is_default: bool,
255    pub is_forced: bool,
256    /// Whether this stream can reach the app as a sidecar it renders itself.
257    ///
258    /// `None` for anything that is not a subtitle — the question does not apply,
259    /// and `false` there would read like a verdict. For a subtitle it is the
260    /// difference between a track the app can draw and one only the server could
261    /// have shown, by burning it into the picture (DR-176) — which this app never
262    /// asks it to do. The vocabulary of *which formats those are* stays in Rust;
263    /// the frontend only reads the answer.
264    ///
265    /// TRACES: UR-020 | DR-176 | UT-168
266    #[serde(default)]
267    pub supports_external_delivery: Option<bool>,
268}
269
270/// Media source information
271#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
272#[serde(rename_all = "camelCase")]
273pub struct MediaSource {
274    pub id: String,
275    pub name: String,
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub container: Option<String>,
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub size: Option<i64>,
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub bitrate: Option<i32>,
282    pub supports_direct_play: bool,
283    pub supports_direct_stream: bool,
284    pub supports_transcoding: bool,
285    #[serde(skip_serializing_if = "Option::is_none")]
286    pub direct_stream_url: Option<String>,
287}
288
289/// Search result with pagination
290#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
291#[serde(rename_all = "camelCase")]
292pub struct SearchResult {
293    pub items: Vec<MediaItem>,
294    pub total_record_count: usize,
295}
296
297/// On-disk usage of downloaded content, for the Downloads surface.
298///
299/// `sizes` maps an item id (leaf *or* container) to its bytes on disk: a leaf's
300/// own file size, a container's summed downloaded descendants. `device_total_bytes`
301/// and `item_count` are the headline figures for the Downloaded surface top bar.
302///
303/// TRACES: UR-056 | DR-085
304#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
305#[serde(rename_all = "camelCase")]
306pub struct DownloadDiskUsage {
307    /// item id → bytes on disk (leaf's own size, or a container's subtotal).
308    pub sizes: std::collections::HashMap<String, i64>,
309    /// Container id → true when it is only *partially* downloaded (has cached
310    /// children that are not downloaded). Absent/false ⇒ fully downloaded. Lets
311    /// the Downloaded surface badge partial vs. full containers.
312    pub partial_containers: std::collections::HashMap<String, bool>,
313    /// Sum of all downloaded leaf sizes — the device total.
314    pub device_total_bytes: i64,
315    /// Number of downloaded leaf items (not containers).
316    pub item_count: u32,
317}
318
319/// Options for querying items
320#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
321#[serde(rename_all = "camelCase")]
322pub struct GetItemsOptions {
323    #[serde(skip_serializing_if = "Option::is_none")]
324    pub start_index: Option<usize>,
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub limit: Option<usize>,
327    #[serde(skip_serializing_if = "Option::is_none")]
328    pub sort_by: Option<String>,
329    #[serde(skip_serializing_if = "Option::is_none")]
330    pub sort_order: Option<String>,
331    #[serde(skip_serializing_if = "Option::is_none")]
332    pub include_item_types: Option<Vec<String>>,
333    #[serde(skip_serializing_if = "Option::is_none")]
334    pub recursive: Option<bool>,
335    #[serde(skip_serializing_if = "Option::is_none")]
336    pub fields: Option<Vec<String>>,
337    #[serde(skip_serializing_if = "Option::is_none")]
338    pub genres: Option<Vec<String>>,
339    /// Restrict the listing to favourited items. Backs the per-library
340    /// favourites toggle; composes with every other filter here.
341    ///
342    /// TRACES: UR-067 | DR-116 | UT-104
343    #[serde(skip_serializing_if = "Option::is_none")]
344    pub favorites_only: Option<bool>,
345    /// What the container being listed *is*, so the repository can pick the
346    /// order its children belong in when the caller names none. The frontend
347    /// sends the neutral kind it already holds; what that kind implies about
348    /// ordering is decided here, the same division as `SearchScope`.
349    ///
350    /// TRACES: UR-007 | DR-257
351    #[serde(skip_serializing_if = "Option::is_none")]
352    pub parent_kind: Option<crate::domain::MediaKind>,
353}
354
355/// The order a container's children take when the caller asked for none.
356///
357/// Ordering by *name* is right for a library, a series or an album, and wrong
358/// for a channel folder: plugin channels — a podcast feed, say — carry a
359/// release date and are read newest-first, and Jellypod additionally prefixes
360/// played episodes with "[Played]", so a name sort clumped every heard episode
361/// at the top of the list. Returns `None` when no container kind was given, so
362/// callers that deliberately rely on the server's own order keep it.
363///
364/// This mapping is domain vocabulary and lives here rather than in the
365/// frontend, for the reason in docs/specs/scoped-search-boundary.md.
366///
367/// TRACES: UR-007 | DR-257 | UT-229
368pub fn default_listing_sort(
369    parent_kind: Option<crate::domain::MediaKind>,
370) -> Option<(&'static str, &'static str)> {
371    match parent_kind? {
372        crate::domain::MediaKind::ChannelFolder => Some(("PremiereDate", "Descending")),
373        _ => Some(("SortName", "Ascending")),
374    }
375}
376
377/// An opaque search scope the frontend selects; Rust owns what it *means*.
378///
379/// The expansion table below is Jellyfin domain vocabulary: it changes when
380/// Jellyfin adds or renames an item type, never when the UI is redesigned. It
381/// previously lived in the frontend (`searchScope.ts`), which is the boundary
382/// leak documented in docs/specs/scoped-search-boundary.md. The frontend now
383/// sends the enum and never names an item type in connection with search.
384///
385/// TRACES: UR-049 | DR-063
386#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
387#[serde(rename_all = "camelCase")]
388pub enum SearchScope {
389    All,
390    Music,
391    Movies,
392    Tv,
393}
394
395impl SearchScope {
396    /// The Jellyfin item types this scope requests, or `None` for `All`.
397    ///
398    /// `All` returns `None` rather than the union of every listed type on
399    /// purpose: an explicit `includeItemTypes` list filters out anything not
400    /// named in it, so a union would silently drop People, folders and any type
401    /// nobody enumerated. Callers must omit the filter entirely on `None`.
402    ///
403    /// TRACES: UR-049 | DR-063
404    pub fn item_types(self) -> Option<Vec<String>> {
405        match self {
406            SearchScope::All => None,
407            SearchScope::Music => Some(
408                ["MusicAlbum", "MusicArtist", "Audio", "Playlist"]
409                    .into_iter()
410                    .map(String::from)
411                    .collect(),
412            ),
413            SearchScope::Movies => Some(vec!["Movie".to_string()]),
414            SearchScope::Tv => Some(
415                ["Series", "Episode"]
416                    .into_iter()
417                    .map(String::from)
418                    .collect(),
419            ),
420        }
421    }
422
423    /// The scope a library of this Jellyfin `CollectionType` belongs to, or
424    /// `None` when its contents are not something favourites are browsed by.
425    ///
426    /// Same reasoning as `item_types`: this table is Jellyfin vocabulary and
427    /// changes when Jellyfin renames a collection type, not when the library
428    /// page is redesigned — so it lives here rather than in the UI that renders
429    /// a per-library favourites tile.
430    ///
431    /// `All` is never returned: it is the *absence* of a category, offered
432    /// alongside the libraries rather than derived from one.
433    ///
434    /// TRACES: UR-075 | DR-175 | UT-161
435    pub fn for_collection_type(collection_type: &str) -> Option<SearchScope> {
436        match collection_type {
437            "movies" => Some(SearchScope::Movies),
438            "tvshows" => Some(SearchScope::Tv),
439            "music" => Some(SearchScope::Music),
440            _ => None,
441        }
442    }
443}
444
445/// Options for search queries
446#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
447#[serde(rename_all = "camelCase")]
448pub struct SearchOptions {
449    #[serde(skip_serializing_if = "Option::is_none")]
450    pub limit: Option<usize>,
451    #[serde(skip_serializing_if = "Option::is_none")]
452    pub include_item_types: Option<Vec<String>>,
453    #[serde(skip_serializing_if = "Option::is_none")]
454    pub search_term: Option<String>,
455    /// Opaque scope selected by the UI. When set it **wins** over
456    /// `include_item_types`, which remains for the non-search `get_items`
457    /// callers that legitimately request a single concrete type.
458    #[serde(skip_serializing_if = "Option::is_none")]
459    pub scope: Option<SearchScope>,
460}
461
462impl SearchOptions {
463    /// Expand `scope` into `include_item_types` in place.
464    ///
465    /// Call this once, in the search command, *before* dispatching to the
466    /// cache and server paths — both already honour `include_item_types`, and
467    /// resolving in one place keeps online and offline results identical.
468    ///
469    /// TRACES: UR-049 | DR-063
470    pub fn resolve_scope(&mut self) {
471        if let Some(scope) = self.scope {
472            // `All` yields None, which clears the filter — the correct
473            // behaviour, not an omission.
474            self.include_item_types = scope.item_types();
475        }
476    }
477}
478
479/// Playback information
480#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
481#[serde(rename_all = "camelCase")]
482pub struct PlaybackInfo {
483    pub media_source_id: String,
484    pub play_session_id: String,
485    pub stream_url: String,
486    pub direct_play: bool,
487    pub needs_transcoding: bool,
488}
489
490/// Live stream information returned from opening a Live TV / channel stream.
491///
492/// Unlike on-demand video, a live channel must be "opened" before it can be
493/// streamed; the server returns a transcoding URL (already absolute) plus a
494/// `live_stream_id` that can later be used to close the stream.
495#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
496#[serde(rename_all = "camelCase")]
497pub struct LiveStreamInfo {
498    pub stream_url: String,
499    pub play_session_id: Option<String>,
500    pub live_stream_id: Option<String>,
501    pub media_source_id: Option<String>,
502    /// How to open `stream_url`.
503    ///
504    /// A live channel is always an HLS transcode — the server has to repackage a
505    /// broadcast mux into something a browser can play, and there is no static
506    /// file to direct-play. Saying so here means the player page never has to
507    /// work it out from the URL, which is the whole of DR-225.
508    ///
509    /// TRACES: UR-079 | DR-225
510    pub transport: super::stream_selection::Transport,
511}
512
513/// Genre
514#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
515#[serde(rename_all = "camelCase")]
516pub struct Genre {
517    pub id: String,
518    pub name: String,
519    /// Number of albums tagged with this genre, when the backend can supply it
520    /// (online only). Lets the frontend rank/pick genres without probing each
521    /// one. `None` when unknown (e.g. offline).
522    pub album_count: Option<u32>,
523}
524
525/// Image type
526#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
527pub enum ImageType {
528    Primary,
529    Backdrop,
530    Banner,
531    Thumb,
532    Logo,
533}
534
535impl ImageType {
536    pub fn as_str(&self) -> &str {
537        match self {
538            ImageType::Primary => "Primary",
539            ImageType::Backdrop => "Backdrop",
540            ImageType::Banner => "Banner",
541            ImageType::Thumb => "Thumb",
542            ImageType::Logo => "Logo",
543        }
544    }
545}
546
547/// Image options
548#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
549#[serde(rename_all = "camelCase")]
550pub struct ImageOptions {
551    #[serde(skip_serializing_if = "Option::is_none")]
552    pub max_width: Option<u32>,
553    #[serde(skip_serializing_if = "Option::is_none")]
554    pub max_height: Option<u32>,
555    #[serde(skip_serializing_if = "Option::is_none")]
556    pub quality: Option<u32>,
557    #[serde(skip_serializing_if = "Option::is_none")]
558    pub tag: Option<String>,
559}
560
561/// Trait for checking if data has meaningful content
562pub trait MeaningfulContent {
563    fn has_content(&self) -> bool;
564}
565
566impl MeaningfulContent for Vec<Library> {
567    fn has_content(&self) -> bool {
568        !self.is_empty()
569    }
570}
571
572impl MeaningfulContent for Vec<MediaItem> {
573    fn has_content(&self) -> bool {
574        !self.is_empty()
575    }
576}
577
578impl MeaningfulContent for SearchResult {
579    fn has_content(&self) -> bool {
580        !self.items.is_empty()
581    }
582}
583
584impl MeaningfulContent for MediaItem {
585    fn has_content(&self) -> bool {
586        true // A single item always has content if it exists
587    }
588}
589
590impl MeaningfulContent for Vec<Genre> {
591    fn has_content(&self) -> bool {
592        !self.is_empty()
593    }
594}
595
596impl MeaningfulContent for PlaybackInfo {
597    fn has_content(&self) -> bool {
598        !self.stream_url.is_empty()
599    }
600}
601
602/// Playlist entry — wraps a MediaItem with the Jellyfin PlaylistItemId
603/// needed for remove/reorder operations (distinct from the media item's ID)
604///
605/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
606#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
607#[serde(rename_all = "camelCase")]
608pub struct PlaylistEntry {
609    /// The playlist-scoped entry ID (Jellyfin's PlaylistItemId)
610    pub playlist_item_id: String,
611    /// The underlying media item
612    #[serde(flatten)]
613    pub item: MediaItem,
614}
615
616/// Result of creating a playlist
617///
618/// @req: JA-019 - Get/create/update playlists
619#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
620#[serde(rename_all = "camelCase")]
621pub struct PlaylistCreatedResult {
622    pub id: String,
623}
624
625impl MeaningfulContent for Vec<PlaylistEntry> {
626    fn has_content(&self) -> bool {
627        !self.is_empty()
628    }
629}
630
631impl MeaningfulContent for PlaylistCreatedResult {
632    fn has_content(&self) -> bool {
633        !self.id.is_empty()
634    }
635}
636
637#[cfg(test)]
638mod search_scope_tests {
639    use super::*;
640
641    /// Music expands to the four Jellyfin types that make up the category.
642    ///
643    /// This table is the domain vocabulary that used to live in the frontend
644    /// (`searchScope.ts`'s `SCOPE_ITEM_TYPES`) — the boundary leak that
645    /// docs/specs/scoped-search-boundary.md was written about.
646    ///
647    /// @req-test: UT-089 - SearchScope expands to Jellyfin item types
648    #[test]
649    fn music_scope_expands_to_music_item_types() {
650        assert_eq!(
651            SearchScope::Music.item_types(),
652            Some(vec![
653                "MusicAlbum".to_string(),
654                "MusicArtist".to_string(),
655                "Audio".to_string(),
656                "Playlist".to_string(),
657            ])
658        );
659    }
660
661    /// @req-test: UT-089 - SearchScope expands to Jellyfin item types
662    #[test]
663    fn movies_scope_expands_to_movie_only() {
664        assert_eq!(
665            SearchScope::Movies.item_types(),
666            Some(vec!["Movie".to_string()])
667        );
668    }
669
670    /// @req-test: UT-089 - SearchScope expands to Jellyfin item types
671    #[test]
672    fn tv_scope_expands_to_series_and_episode() {
673        assert_eq!(
674            SearchScope::Tv.item_types(),
675            Some(vec!["Series".to_string(), "Episode".to_string()])
676        );
677    }
678
679    /// `All` must send NO filter — not the union of the other scopes.
680    ///
681    /// Sending a union would silently drop every type nobody enumerated
682    /// (Person, folders, …), which an explicit `includeItemTypes` list filters
683    /// out. This is why `item_types()` returns Option rather than Vec.
684    ///
685    /// @req-test: UT-090 - All scope sends no item-type filter
686    #[test]
687    fn all_scope_sends_no_filter() {
688        assert_eq!(SearchScope::All.item_types(), None);
689    }
690
691    /// Scope wins over an explicitly supplied include_item_types.
692    ///
693    /// @req-test: UT-091 - Scope takes precedence over include_item_types
694    #[test]
695    fn resolve_scope_overrides_include_item_types() {
696        let mut options = SearchOptions {
697            include_item_types: Some(vec!["Movie".to_string()]),
698            scope: Some(SearchScope::Music),
699            ..Default::default()
700        };
701        options.resolve_scope();
702
703        assert_eq!(
704            options.include_item_types,
705            Some(vec![
706                "MusicAlbum".to_string(),
707                "MusicArtist".to_string(),
708                "Audio".to_string(),
709                "Playlist".to_string(),
710            ])
711        );
712    }
713
714    /// `All` clears any include_item_types so no filter reaches the query.
715    ///
716    /// @req-test: UT-090 - All scope sends no item-type filter
717    #[test]
718    fn resolve_all_scope_clears_include_item_types() {
719        let mut options = SearchOptions {
720            include_item_types: Some(vec!["Movie".to_string()]),
721            scope: Some(SearchScope::All),
722            ..Default::default()
723        };
724        options.resolve_scope();
725
726        assert_eq!(options.include_item_types, None);
727    }
728
729    /// With no scope set, include_item_types passes through untouched — the
730    /// non-search `getItems` callers rely on this.
731    ///
732    /// @req-test: UT-091 - Scope takes precedence over include_item_types
733    #[test]
734    fn resolve_without_scope_preserves_include_item_types() {
735        let mut options = SearchOptions {
736            include_item_types: Some(vec!["MusicAlbum".to_string()]),
737            scope: None,
738            ..Default::default()
739        };
740        options.resolve_scope();
741
742        assert_eq!(
743            options.include_item_types,
744            Some(vec!["MusicAlbum".to_string()])
745        );
746    }
747
748    /// The frontend sends the enum as camelCase over IPC.
749    ///
750    /// @req-test: UT-089 - SearchScope expands to Jellyfin item types
751    #[test]
752    fn scope_deserializes_from_camel_case() {
753        let options: SearchOptions =
754            serde_json::from_str(r#"{"scope": "music", "limit": 10}"#).unwrap();
755        assert!(matches!(options.scope, Some(SearchScope::Music)));
756
757        let all: SearchOptions = serde_json::from_str(r#"{"scope": "all"}"#).unwrap();
758        assert!(matches!(all.scope, Some(SearchScope::All)));
759    }
760
761    /// TRACES: DR-175 | UT-161
762    #[test]
763    fn test_collection_type_maps_to_its_favorites_scope() {
764        assert_eq!(
765            SearchScope::for_collection_type("movies"),
766            Some(SearchScope::Movies)
767        );
768        assert_eq!(
769            SearchScope::for_collection_type("tvshows"),
770            Some(SearchScope::Tv)
771        );
772        assert_eq!(
773            SearchScope::for_collection_type("music"),
774            Some(SearchScope::Music)
775        );
776    }
777
778    /// A library kind favourites are not browsed by gets no tile at all, rather
779    /// than one that opens an unfiltered list. `All` is never derived from a
780    /// library — it is the cross-library entry offered beside them.
781    ///
782    /// TRACES: DR-175 | UT-161
783    #[test]
784    fn test_uncategorised_collection_types_have_no_favorites_scope() {
785        for collection_type in ["livetv", "channels", "boxsets", "books", "unknown", ""] {
786            assert_eq!(
787                SearchScope::for_collection_type(collection_type),
788                None,
789                "{collection_type} should not carry a favourites scope"
790            );
791        }
792    }
793
794    /// TRACES: DR-175 | UT-161
795    #[test]
796    fn test_library_carries_its_favorites_scope_to_the_frontend() {
797        let music = Library::new("1".into(), "Music".into(), "music".into(), None);
798        assert_eq!(music.favorites_scope, Some(SearchScope::Music));
799
800        let json = serde_json::to_value(&music).unwrap();
801        assert_eq!(json["favoritesScope"], "music");
802
803        // A library with no scope omits the field rather than sending null.
804        let livetv = Library::new("2".into(), "Live TV".into(), "livetv".into(), None);
805        let json = serde_json::to_value(&livetv).unwrap();
806        assert!(json.get("favoritesScope").is_none());
807    }
808}
809
810#[cfg(test)]
811mod tests {
812    use super::*;
813
814    #[test]
815    fn test_artist_item_deserialize_pascal_case() {
816        // Test that ArtistItem correctly deserializes PascalCase JSON from Jellyfin API
817        let json = r#"{"Id": "artist123", "Name": "Bob Dylan"}"#;
818        let result: Result<ArtistItem, _> = serde_json::from_str(json);
819
820        assert!(result.is_ok());
821        let artist = result.unwrap();
822        assert_eq!(artist.id, "artist123");
823        assert_eq!(artist.name, "Bob Dylan");
824    }
825
826    #[test]
827    fn test_artist_item_deserialize_array() {
828        // Test deserializing array of ArtistItems (common in API responses)
829        let json = r#"[
830            {"Id": "artist1", "Name": "Bob Dylan"},
831            {"Id": "artist2", "Name": "Johnny Cash"}
832        ]"#;
833        let result: Result<Vec<ArtistItem>, _> = serde_json::from_str(json);
834
835        assert!(result.is_ok());
836        let artists = result.unwrap();
837        assert_eq!(artists.len(), 2);
838        assert_eq!(artists[0].id, "artist1");
839        assert_eq!(artists[0].name, "Bob Dylan");
840        assert_eq!(artists[1].id, "artist2");
841        assert_eq!(artists[1].name, "Johnny Cash");
842    }
843
844    #[test]
845    fn test_artist_item_serialize() {
846        // ArtistItem serializes to camelCase for the frontend; it still accepts
847        // Jellyfin's PascalCase on deserialize via #[serde(alias = ...)].
848        let artist = ArtistItem {
849            id: "test-id".to_string(),
850            name: "Test Artist".to_string(),
851        };
852
853        let json = serde_json::to_string(&artist).expect("Failed to serialize");
854        assert!(json.contains(r#""id":"test-id""#));
855        assert!(json.contains(r#""name":"Test Artist""#));
856
857        let from_pascal: ArtistItem =
858            serde_json::from_str(r#"{"Id":"x","Name":"Y"}"#).expect("Failed to deserialize");
859        assert_eq!(from_pascal.id, "x");
860        assert_eq!(from_pascal.name, "Y");
861    }
862
863    #[test]
864    fn test_media_item_with_primary_image_tag() {
865        // Test that MediaItem correctly handles primary_image_tag
866        let json = r#"{
867            "id": "item123",
868            "name": "Test Item",
869            "type": "MusicAlbum",
870            "serverId": "server1",
871            "primaryImageTag": "tag123"
872        }"#;
873
874        let result: Result<MediaItem, _> = serde_json::from_str(json);
875        assert!(result.is_ok());
876
877        let item = result.unwrap();
878        assert_eq!(item.id, "item123");
879        assert_eq!(item.name, "Test Item");
880        assert_eq!(item.primary_image_tag, Some("tag123".to_string()));
881    }
882
883    #[test]
884    fn test_media_item_with_artists() {
885        // Test MediaItem with artists array
886        let json = r#"{
887            "id": "track1",
888            "name": "Test Track",
889            "type": "Audio",
890            "serverId": "server1",
891            "artists": ["Artist 1", "Artist 2"]
892        }"#;
893
894        let result: Result<MediaItem, _> = serde_json::from_str(json);
895        assert!(result.is_ok());
896
897        let item = result.unwrap();
898        let artists = item.artists.expect("Expected artists");
899        assert_eq!(artists.len(), 2);
900        assert_eq!(artists[0], "Artist 1");
901        assert_eq!(artists[1], "Artist 2");
902    }
903
904    #[test]
905    fn test_search_result_meaningful_content() {
906        // Test MeaningfulContent trait for SearchResult
907        let empty_result = SearchResult {
908            items: vec![],
909            total_record_count: 0,
910        };
911        assert!(!empty_result.has_content());
912
913        let non_empty_result = SearchResult {
914            items: vec![MediaItem {
915                id: "1".to_string(),
916                name: "Test".to_string(),
917                item_type: "Audio".to_string(),
918                kind: crate::domain::MediaKind::Track,
919                is_folder: false,
920                server_id: "server1".to_string(),
921                parent_id: None,
922                library_id: None,
923                overview: None,
924                genres: None,
925                production_year: None,
926                premiere_date: None,
927                community_rating: None,
928                official_rating: None,
929                runtime_ticks: None,
930                duration_ms: None,
931                primary_image_tag: None,
932                image_id: None,
933                backdrop_image_tags: None,
934                parent_backdrop_image_tags: None,
935                album_id: None,
936                album_name: None,
937                album_artist: None,
938                artists: None,
939                artist_items: None,
940                index_number: None,
941                parent_index_number: None,
942                series_id: None,
943                series_name: None,
944                season_id: None,
945                season_name: None,
946                user_data: None,
947                media_streams: None,
948                media_sources: None,
949                people: None,
950            }],
951            total_record_count: 1,
952        };
953        assert!(non_empty_result.has_content());
954    }
955
956    #[test]
957    fn test_person_deserialize_complete() {
958        // Test that Person deserializes correctly with all fields (PascalCase from Jellyfin API)
959        let json = r#"{
960            "Id": "person123",
961            "Name": "Tom Hanks",
962            "Type": "Actor",
963            "Role": "Lead Actor",
964            "PrimaryImageTag": "tag456"
965        }"#;
966
967        let result: Result<Person, _> = serde_json::from_str(json);
968        assert!(result.is_ok(), "Failed to deserialize: {:?}", result.err());
969
970        let person = result.unwrap();
971        assert_eq!(person.id, "person123");
972        assert_eq!(person.name, "Tom Hanks");
973        assert_eq!(person.person_type, "Actor");
974        assert_eq!(person.role, Some("Lead Actor".to_string()));
975        assert_eq!(person.primary_image_tag, Some("tag456".to_string()));
976
977        // Verify serialization uses camelCase for frontend
978        let serialized = serde_json::to_string(&person).expect("Failed to serialize");
979        assert!(
980            serialized.contains(r#""type":"Actor""#),
981            "Serialized form should use 'type' not 'Type'"
982        );
983        assert!(serialized.contains(r#""id":"person123""#));
984        assert!(serialized.contains(r#""primaryImageTag":"tag456""#));
985    }
986
987    #[test]
988    fn test_person_deserialize_minimal() {
989        // Test that Person deserializes with missing optional fields (uses defaults)
990        let json = r#"{
991            "Id": "person456",
992            "Name": "Meryl Streep",
993            "Type": "Actress"
994        }"#;
995
996        let result: Result<Person, _> = serde_json::from_str(json);
997        assert!(result.is_ok());
998
999        let person = result.unwrap();
1000        assert_eq!(person.id, "person456");
1001        assert_eq!(person.name, "Meryl Streep");
1002        assert_eq!(person.person_type, "Actress");
1003        assert_eq!(person.role, None);
1004        assert_eq!(person.primary_image_tag, None);
1005    }
1006
1007    #[test]
1008    fn test_person_array_deserialize() {
1009        // Test deserializing array of Person objects (common in Jellyfin API)
1010        let json = r#"[
1011            {"Id": "actor1", "Name": "Actor One", "Type": "Actor"},
1012            {"Id": "director1", "Name": "Director One", "Type": "Director", "Role": "Director"}
1013        ]"#;
1014
1015        let result: Result<Vec<Person>, _> = serde_json::from_str(json);
1016        assert!(result.is_ok());
1017
1018        let people = result.unwrap();
1019        assert_eq!(people.len(), 2);
1020        assert_eq!(people[0].name, "Actor One");
1021        assert_eq!(people[1].person_type, "Director");
1022        assert_eq!(people[1].role, Some("Director".to_string()));
1023    }
1024
1025    #[test]
1026    fn test_media_item_with_people() {
1027        // Test that MediaItem correctly deserializes with people array (from API in PascalCase)
1028        let json = r#"{
1029            "id": "movie1",
1030            "name": "Test Movie",
1031            "type": "Movie",
1032            "serverId": "server1",
1033            "people": [
1034                {"Id": "actor1", "Name": "John Doe", "Type": "Actor"},
1035                {"Id": "director1", "Name": "Jane Smith", "Type": "Director"}
1036            ]
1037        }"#;
1038
1039        let result: Result<MediaItem, _> = serde_json::from_str(json);
1040        assert!(result.is_ok());
1041
1042        let item = result.unwrap();
1043        let people = item.people.as_ref().expect("Expected people array");
1044        assert_eq!(people.len(), 2);
1045        assert_eq!(people[0].name, "John Doe");
1046        assert_eq!(people[0].person_type, "Actor");
1047        assert_eq!(people[1].person_type, "Director");
1048
1049        // Verify that when serialized to frontend, it uses camelCase
1050        let serialized = serde_json::to_string(&item).expect("Failed to serialize");
1051        let re_parsed: serde_json::Value =
1052            serde_json::from_str(&serialized).expect("Failed to parse serialized");
1053        let people_array = re_parsed["people"]
1054            .as_array()
1055            .expect("people should be array");
1056        assert!(
1057            people_array[0].get("type").is_some(),
1058            "Serialized person should have 'type' field"
1059        );
1060        assert_eq!(people_array[0]["type"].as_str().unwrap(), "Actor");
1061    }
1062
1063    #[test]
1064    fn test_playlist_entry_serialization() {
1065        let entry = PlaylistEntry {
1066            playlist_item_id: "entry-abc-123".to_string(),
1067            item: MediaItem {
1068                id: "track1".to_string(),
1069                name: "Test Track".to_string(),
1070                item_type: "Audio".to_string(),
1071                kind: crate::domain::MediaKind::Track,
1072                is_folder: false,
1073                server_id: "server1".to_string(),
1074                parent_id: None,
1075                library_id: None,
1076                overview: None,
1077                genres: None,
1078                production_year: None,
1079                premiere_date: None,
1080                community_rating: None,
1081                official_rating: None,
1082                runtime_ticks: None,
1083                duration_ms: None,
1084                primary_image_tag: None,
1085                image_id: None,
1086                backdrop_image_tags: None,
1087                parent_backdrop_image_tags: None,
1088                album_id: None,
1089                album_name: None,
1090                album_artist: None,
1091                artists: Some(vec!["Artist One".to_string()]),
1092                artist_items: None,
1093                index_number: None,
1094                parent_index_number: None,
1095                series_id: None,
1096                series_name: None,
1097                season_id: None,
1098                season_name: None,
1099                user_data: None,
1100                media_streams: None,
1101                media_sources: None,
1102                people: None,
1103            },
1104        };
1105
1106        let json = serde_json::to_string(&entry).expect("Failed to serialize");
1107        // playlistItemId is camelCase
1108        assert!(json.contains(r#""playlistItemId":"entry-abc-123""#));
1109        // Flattened MediaItem fields appear at top level
1110        assert!(json.contains(r#""id":"track1""#));
1111        assert!(json.contains(r#""name":"Test Track""#));
1112        assert!(json.contains(r#""type":"Audio""#));
1113    }
1114
1115    #[test]
1116    fn test_playlist_created_result_serialization() {
1117        let result = PlaylistCreatedResult {
1118            id: "playlist-new-123".to_string(),
1119        };
1120        let json = serde_json::to_string(&result).expect("Failed to serialize");
1121        assert!(json.contains(r#""id":"playlist-new-123""#));
1122    }
1123
1124    #[test]
1125    fn test_playlist_entry_meaningful_content() {
1126        let empty: Vec<PlaylistEntry> = vec![];
1127        assert!(!empty.has_content());
1128
1129        let non_empty = vec![PlaylistEntry {
1130            playlist_item_id: "e1".to_string(),
1131            item: MediaItem {
1132                id: "1".to_string(),
1133                name: "Track".to_string(),
1134                item_type: "Audio".to_string(),
1135                kind: crate::domain::MediaKind::Track,
1136                is_folder: false,
1137                server_id: "s1".to_string(),
1138                parent_id: None,
1139                library_id: None,
1140                overview: None,
1141                genres: None,
1142                production_year: None,
1143                premiere_date: None,
1144                community_rating: None,
1145                official_rating: None,
1146                runtime_ticks: None,
1147                duration_ms: None,
1148                primary_image_tag: None,
1149                image_id: None,
1150                backdrop_image_tags: None,
1151                parent_backdrop_image_tags: None,
1152                album_id: None,
1153                album_name: None,
1154                album_artist: None,
1155                artists: None,
1156                artist_items: None,
1157                index_number: None,
1158                parent_index_number: None,
1159                series_id: None,
1160                series_name: None,
1161                season_id: None,
1162                season_name: None,
1163                user_data: None,
1164                media_streams: None,
1165                media_sources: None,
1166                people: None,
1167            },
1168        }];
1169        assert!(non_empty.has_content());
1170    }
1171}