//! Jellyfin → domain translation. //! //! The ONLY place Jellyfin's vocabulary touches the domain model. Adding a //! second provider later means a sibling `from_.rs`; the domain types //! and every consumer stay untouched. //! //! Spec: docs/specs/frontend-domain-model.md use super::media::{MediaKind, StreamKind}; /// Classify a Jellyfin media-stream `Type` string into a neutral [`StreamKind`]. /// Total and panic-free. pub fn stream_kind_from_jellyfin(stream_type: &str) -> StreamKind { match stream_type { "Audio" => StreamKind::Audio, "Video" => StreamKind::Video, "Subtitle" => StreamKind::Subtitle, _ => StreamKind::Other, } } /// Jellyfin ticks per second (10 million). A tick is 100 ns. /// The frontend must never see ticks — this is where they die. const TICKS_PER_MILLISECOND: i64 = 10_000; /// Convert a Jellyfin `RunTimeTicks` value to milliseconds. /// /// Domain durations are milliseconds; ticks are a Jellyfin unit and stop here. pub fn ticks_to_ms(ticks: i64) -> i64 { ticks / TICKS_PER_MILLISECOND } /// Classify a Jellyfin `Type` string into a neutral [`MediaKind`]. /// /// **Total and panic-free**: any unrecognised string maps to [`MediaKind::Other`] /// rather than failing. `is_folder` disambiguates the one Jellyfin type /// (`ChannelFolderItem`) whose kind depends on whether it is a container. /// /// The recognised set is every `item_type` the frontend audit found in use /// (docs/specs/frontend-domain-model.md), plus the common cast/crew person /// subtypes Jellyfin returns in `People[].Type`. pub fn kind_from_jellyfin(item_type: &str, is_folder: bool) -> MediaKind { match item_type { // Music "Audio" | "MusicVideo" => MediaKind::Track, "MusicAlbum" => MediaKind::Album, "MusicArtist" | "AlbumArtist" => MediaKind::Artist, "Playlist" => MediaKind::Playlist, // Video "Movie" => MediaKind::Movie, "Series" => MediaKind::Series, "Season" => MediaKind::Season, "Episode" => MediaKind::Episode, // A bare video leaf with no richer classification. "Video" => MediaKind::Movie, // Cast / crew — Jellyfin uses both a "Person" item type and role-typed // people (Actor/Director/Writer/Composer/…) in People[].Type. "Person" | "Actor" | "Director" | "Writer" | "Composer" | "GuestStar" | "Producer" => { MediaKind::Person } // A live TV channel: playable, but a non-seekable live stream. "TvChannel" | "LiveTvChannel" => MediaKind::LiveChannel, // A bare channel is a container the user drills into. "Channel" => MediaKind::Channel, // Containers "Folder" | "CollectionFolder" | "UserView" | "BoxSet" => MediaKind::Folder, // ChannelFolderItem is a container when it is a folder, else a playable // channel leaf (distinct kind so the UI can route it to playback). "ChannelFolderItem" => { if is_folder { MediaKind::Folder } else { MediaKind::ChannelItem } } // Unknown → safe sink. Never panics. _ => { if is_folder { MediaKind::Folder } else { MediaKind::Other } } } } #[cfg(test)] mod tests { use super::*; #[test] fn ticks_convert_to_milliseconds() { // 1 second = 10,000,000 ticks = 1000 ms assert_eq!(ticks_to_ms(10_000_000), 1000); // 90.5 s assert_eq!(ticks_to_ms(905_000_000), 90_500); assert_eq!(ticks_to_ms(0), 0); // Sub-millisecond truncates toward zero, not panics. assert_eq!(ticks_to_ms(9_999), 0); } #[test] fn music_types_map() { assert_eq!(kind_from_jellyfin("Audio", false), MediaKind::Track); assert_eq!(kind_from_jellyfin("MusicAlbum", true), MediaKind::Album); assert_eq!(kind_from_jellyfin("MusicArtist", true), MediaKind::Artist); assert_eq!(kind_from_jellyfin("Playlist", true), MediaKind::Playlist); } #[test] fn video_types_map() { assert_eq!(kind_from_jellyfin("Movie", false), MediaKind::Movie); assert_eq!(kind_from_jellyfin("Series", true), MediaKind::Series); assert_eq!(kind_from_jellyfin("Season", true), MediaKind::Season); assert_eq!(kind_from_jellyfin("Episode", false), MediaKind::Episode); assert_eq!(kind_from_jellyfin("Video", false), MediaKind::Movie); } #[test] fn person_and_role_types_map_to_person() { for t in ["Person", "Actor", "Director", "Writer", "Composer"] { assert_eq!(kind_from_jellyfin(t, false), MediaKind::Person, "{t}"); } } #[test] fn channel_and_container_types_map() { assert_eq!( kind_from_jellyfin("TvChannel", false), MediaKind::LiveChannel ); assert_eq!(kind_from_jellyfin("Channel", false), MediaKind::Channel); assert_eq!( kind_from_jellyfin("CollectionFolder", true), MediaKind::Folder ); assert_eq!(kind_from_jellyfin("BoxSet", true), MediaKind::Folder); } #[test] fn channel_folder_item_disambiguates_on_is_folder() { assert_eq!( kind_from_jellyfin("ChannelFolderItem", true), MediaKind::Folder ); assert_eq!( kind_from_jellyfin("ChannelFolderItem", false), MediaKind::ChannelItem ); } #[test] fn stream_kinds_map() { assert_eq!(stream_kind_from_jellyfin("Audio"), StreamKind::Audio); assert_eq!(stream_kind_from_jellyfin("Video"), StreamKind::Video); assert_eq!(stream_kind_from_jellyfin("Subtitle"), StreamKind::Subtitle); assert_eq!( stream_kind_from_jellyfin("EmbeddedImage"), StreamKind::Other ); assert_eq!(stream_kind_from_jellyfin(""), StreamKind::Other); } #[test] fn unknown_type_never_panics_and_falls_back() { // The whole point: garbage in, safe kind out, no panic. assert_eq!(kind_from_jellyfin("Epis0de", false), MediaKind::Other); assert_eq!(kind_from_jellyfin("", false), MediaKind::Other); assert_eq!( kind_from_jellyfin("SomeFutureType", true), MediaKind::Folder ); assert_eq!(kind_from_jellyfin("🎵unicode", false), MediaKind::Other); } }