Add StreamKind enum (audio/video/subtitle/other) to the domain module with a total stream_kind_from_jellyfin mapper. MediaStream gains a kind field (dual-carry), populated at the mapping seam. Frontend VideoPlayer track/ subtitle selection and the channel-video check now use stream.kind instead of the Jellyfin stream.type string. Rust 456 (+ stream_kinds_map test), frontend 644, check clean.
181 lines
6.4 KiB
Rust
181 lines
6.4 KiB
Rust
//! Jellyfin → domain translation.
|
|
//!
|
|
//! The ONLY place Jellyfin's vocabulary touches the domain model. Adding a
|
|
//! second provider later means a sibling `from_<provider>.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);
|
|
}
|
|
}
|