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}