Skip to main content

#
jellytau_lib/domain/
from_jellyfin.rs

    1//! Jellyfin → domain translation.
    2//!
    3//! The ONLY place Jellyfin's vocabulary touches the domain model. Adding a
    4//! second provider later means a sibling `from_<provider>.rs`; the domain types
    5//! and every consumer stay untouched.
    6//!
    7//! Spec: docs/specs/frontend-domain-model.md
    8
    9use super::media::{MediaKind, StreamKind};
    10
    11/// Classify a Jellyfin media-stream `Type` string into a neutral [`StreamKind`].
    12/// Total and panic-free.
    13pub fn stream_kind_from_jellyfin(stream_type: &str) -> StreamKind {
    14    match stream_type {
    15        "Audio" => StreamKind::Audio,
    16        "Video" => StreamKind::Video,
    17        "Subtitle" => StreamKind::Subtitle,
    18        _ => StreamKind::Other,
    19    }
    20}
    21
    22/// Jellyfin ticks per second (10 million). A tick is 100 ns.
    23/// The frontend must never see ticks — this is where they die.
    24const TICKS_PER_MILLISECOND: i64 = 10_000;
    25
    26/// Convert a Jellyfin `RunTimeTicks` value to milliseconds.
    27///
    28/// Domain durations are milliseconds; ticks are a Jellyfin unit and stop here.
    29pub fn ticks_to_ms(ticks: i64) -> i64 {
    30    ticks / TICKS_PER_MILLISECOND
    31}
    32
    33/// Classify a Jellyfin `Type` string into a neutral [`MediaKind`].
    34///
    35/// **Total and panic-free**: any unrecognised string maps to [`MediaKind::Other`]
    36/// rather than failing. `is_folder` disambiguates the one Jellyfin type
    37/// (`ChannelFolderItem`) whose kind depends on whether it is a container.
    38///
    39/// The recognised set is every `item_type` the frontend audit found in use
    40/// (docs/specs/frontend-domain-model.md), plus the common cast/crew person
    41/// subtypes Jellyfin returns in `People[].Type`.
    42pub fn kind_from_jellyfin(item_type: &str, is_folder: bool) -> MediaKind {
    43    match item_type {
    44        // Music
    45        "Audio" | "MusicVideo" => MediaKind::Track,
    46        "MusicAlbum" => MediaKind::Album,
    47        "MusicArtist" | "AlbumArtist" => MediaKind::Artist,
    48        "Playlist" => MediaKind::Playlist,
    49
    50        // Video
    51        "Movie" => MediaKind::Movie,
    52        "Series" => MediaKind::Series,
    53        "Season" => MediaKind::Season,
    54        "Episode" => MediaKind::Episode,
    55        // A bare video leaf with no richer classification.
    56        "Video" => MediaKind::Movie,
    57
    58        // Cast / crew — Jellyfin uses both a "Person" item type and role-typed
    59        // people (Actor/Director/Writer/Composer/…) in People[].Type.
    60        "Person" | "Actor" | "Director" | "Writer" | "Composer" | "GuestStar" | "Producer" => {
    61            MediaKind::Person
    62        }
    63
    64        // A live TV channel: playable, but a non-seekable live stream.
    65        "TvChannel" | "LiveTvChannel" => MediaKind::LiveChannel,
    66        // A bare channel is a container the user drills into.
    67        "Channel" => MediaKind::Channel,
    68
    69        // Containers
    70        "Folder" | "CollectionFolder" | "UserView" | "BoxSet" => MediaKind::Folder,
    71        // ChannelFolderItem is a container when it is a folder, else a playable
    72        // channel leaf (distinct kind so the UI can route it to playback).
    73        "ChannelFolderItem" => {
    74            if is_folder {
    75                MediaKind::ChannelFolder
    76            } else {
    77                MediaKind::ChannelItem
    78            }
    79        }
    80
    81        // Unknown → safe sink. Never panics.
    82        _ => {
    83            if is_folder {
    84                MediaKind::Folder
    85            } else {
    86                MediaKind::Other
    87            }
    88        }
    89    }
    90}
    91
    92#[cfg(test)]
    93mod tests {
    94    use super::*;
    95
    96    #[test]
    97    fn ticks_convert_to_milliseconds() {
    98        // 1 second = 10,000,000 ticks = 1000 ms
    99        assert_eq!(ticks_to_ms(10_000_000), 1000);
    100        // 90.5 s
    101        assert_eq!(ticks_to_ms(905_000_000), 90_500);
    102        assert_eq!(ticks_to_ms(0), 0);
    103        // Sub-millisecond truncates toward zero, not panics.
    104        assert_eq!(ticks_to_ms(9_999), 0);
    105    }
    106
    107    #[test]
    108    fn music_types_map() {
    109        assert_eq!(kind_from_jellyfin("Audio", false), MediaKind::Track);
    110        assert_eq!(kind_from_jellyfin("MusicAlbum", true), MediaKind::Album);
    111        assert_eq!(kind_from_jellyfin("MusicArtist", true), MediaKind::Artist);
    112        assert_eq!(kind_from_jellyfin("Playlist", true), MediaKind::Playlist);
    113    }
    114
    115    #[test]
    116    fn video_types_map() {
    117        assert_eq!(kind_from_jellyfin("Movie", false), MediaKind::Movie);
    118        assert_eq!(kind_from_jellyfin("Series", true), MediaKind::Series);
    119        assert_eq!(kind_from_jellyfin("Season", true), MediaKind::Season);
    120        assert_eq!(kind_from_jellyfin("Episode", false), MediaKind::Episode);
    121        assert_eq!(kind_from_jellyfin("Video", false), MediaKind::Movie);
    122    }
    123
    124    #[test]
    125    fn person_and_role_types_map_to_person() {
    126        for t in ["Person", "Actor", "Director", "Writer", "Composer"] {
    127            assert_eq!(kind_from_jellyfin(t, false), MediaKind::Person, "{t}");
    128        }
    129    }
    130
    131    #[test]
    132    fn channel_and_container_types_map() {
    133        assert_eq!(
    134            kind_from_jellyfin("TvChannel", false),
    135            MediaKind::LiveChannel
    136        );
    137        assert_eq!(kind_from_jellyfin("Channel", false), MediaKind::Channel);
    138        assert_eq!(
    139            kind_from_jellyfin("CollectionFolder", true),
    140            MediaKind::Folder
    141        );
    142        assert_eq!(kind_from_jellyfin("BoxSet", true), MediaKind::Folder);
    143    }
    144
    145    /// A channel container is not an ordinary folder. Jellyfin gives both the
    146    /// same item type, but only the channel one holds plugin content whose
    147    /// natural order is by release date — a podcast, for instance. Collapsing
    148    /// it into `Folder` left the repository with no way to tell the two apart,
    149    /// so every podcast listed alphabetically.
    150    ///
    151    /// TRACES: UR-007 | DR-257 | UT-230
    152    #[test]
    153    fn channel_folder_item_disambiguates_on_is_folder() {
    154        assert_eq!(
    155            kind_from_jellyfin("ChannelFolderItem", true),
    156            MediaKind::ChannelFolder
    157        );
    158        assert_eq!(
    159            kind_from_jellyfin("ChannelFolderItem", false),
    160            MediaKind::ChannelItem
    161        );
    162    }
    163
    164    #[test]
    165    fn stream_kinds_map() {
    166        assert_eq!(stream_kind_from_jellyfin("Audio"), StreamKind::Audio);
    167        assert_eq!(stream_kind_from_jellyfin("Video"), StreamKind::Video);
    168        assert_eq!(stream_kind_from_jellyfin("Subtitle"), StreamKind::Subtitle);
    169        assert_eq!(
    170            stream_kind_from_jellyfin("EmbeddedImage"),
    171            StreamKind::Other
    172        );
    173        assert_eq!(stream_kind_from_jellyfin(""), StreamKind::Other);
    174    }
    175
    176    #[test]
    177    fn unknown_type_never_panics_and_falls_back() {
    178        // The whole point: garbage in, safe kind out, no panic.
    179        assert_eq!(kind_from_jellyfin("Epis0de", false), MediaKind::Other);
    180        assert_eq!(kind_from_jellyfin("", false), MediaKind::Other);
    181        assert_eq!(
    182            kind_from_jellyfin("SomeFutureType", true),
    183            MediaKind::Folder
    184        );
    185        assert_eq!(kind_from_jellyfin("🎵unicode", false), MediaKind::Other);
    186    }
    187}