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}