domain: introduce provider-neutral media model (phase 1)
Establish src-tauri/src/domain/ as the single source of truth for the media model, with all Jellyfin translation isolated in from_jellyfin.rs. Adds MediaKind enum and neutral duration_ms/image_id fields to MediaItem as additive, defaulted dual-carry alongside the legacy Jellyfin-named fields, so nothing breaks while the frontend migrates off them. - domain/media.rs: canonical MediaKind (closed enum, replaces stringly item_type), Default = Other so unknown/defaulted items are inert. - domain/from_jellyfin.rs: total, panic-free item_type -> MediaKind classification (all audited types + person subroles) and ticks->ms. - MediaItem gains kind/duration_ms/image_id, populated at both mapping seams (online to_media_item, offline cached_item_to_media_item) and the synthesized-album/person sites. - Regenerated bindings.ts: frontend now HAS the neutral model available. Phase 1 of docs/specs/frontend-domain-model.md. No frontend behaviour change yet; wire shape is a superset of before. Rust 456 tests, frontend 644 tests, check + check:boundary all green.
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
//! 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;
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
// Live TV / channels
|
||||
"TvChannel" | "LiveTvChannel" | "Channel" => MediaKind::Channel,
|
||||
|
||||
// Containers
|
||||
"Folder" | "CollectionFolder" | "UserView" | "BoxSet" => MediaKind::Folder,
|
||||
// ChannelFolderItem is a container when it is a folder, else a leaf we
|
||||
// do not model further.
|
||||
"ChannelFolderItem" => {
|
||||
if is_folder {
|
||||
MediaKind::Folder
|
||||
} else {
|
||||
MediaKind::Other
|
||||
}
|
||||
}
|
||||
|
||||
// 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::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::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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Canonical, provider-neutral media domain model.
|
||||
//!
|
||||
//! This is the *single source of truth* for what a media item is across the
|
||||
//! whole app. Rust (repositories, player, downloads) uses these types directly;
|
||||
//! the frontend consumes the tauri-specta-generated projection in
|
||||
//! `src/lib/api/bindings.ts`. There is no second hand-written copy in either
|
||||
//! language, so the model cannot drift.
|
||||
//!
|
||||
//! No provider (Jellyfin) vocabulary belongs in this file. Translation from a
|
||||
//! provider's wire shape lives beside it in `from_jellyfin.rs` and is the only
|
||||
//! place provider terms touch the domain type.
|
||||
//!
|
||||
//! Spec: docs/specs/frontend-domain-model.md
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The kind of a media item — provider-neutral classification.
|
||||
///
|
||||
/// Replaces the stringly-typed `item_type` that carried Jellyfin's vocabulary
|
||||
/// (`"Audio"`, `"MusicAlbum"`, …) across the boundary. A closed enum means a
|
||||
/// typo or an unhandled kind is a compile error on the frontend, not a silent
|
||||
/// runtime miss across ~127 comparison sites.
|
||||
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum MediaKind {
|
||||
// Music
|
||||
Track,
|
||||
Album,
|
||||
Artist,
|
||||
Playlist,
|
||||
// Video
|
||||
Movie,
|
||||
Series,
|
||||
Season,
|
||||
Episode,
|
||||
// Cast/crew
|
||||
Person,
|
||||
// Containers / live TV
|
||||
Channel,
|
||||
Folder,
|
||||
/// A kind we do not model explicitly. Reached only for provider item types
|
||||
/// that map to nothing meaningful; consumers treat it like an opaque
|
||||
/// container. The mapping must be *total* — it never panics — so this is the
|
||||
/// safe sink for unknown strings. Also the `Default`, so a defaulted
|
||||
/// `MediaItem` (see the dual-carry migration) is inert rather than a lie.
|
||||
#[default]
|
||||
Other,
|
||||
}
|
||||
|
||||
impl MediaKind {
|
||||
/// True for kinds that are containers/collections rather than playable leaves.
|
||||
/// Presentation-neutral helper the backend can use for e.g. drill-vs-play.
|
||||
// Consumed by later migration phases (drill-vs-play routing); kept now so the
|
||||
// domain surface is complete alongside the type it describes.
|
||||
#[allow(dead_code)]
|
||||
pub fn is_container(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
MediaKind::Album
|
||||
| MediaKind::Artist
|
||||
| MediaKind::Series
|
||||
| MediaKind::Season
|
||||
| MediaKind::Playlist
|
||||
| MediaKind::Channel
|
||||
| MediaKind::Folder
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! Canonical, provider-neutral domain model — the single source of truth for
|
||||
//! the app's core data shapes, shared with the frontend via generated bindings.
|
||||
//!
|
||||
//! Spec: docs/specs/frontend-domain-model.md
|
||||
|
||||
pub mod from_jellyfin;
|
||||
pub mod media;
|
||||
|
||||
pub use from_jellyfin::{kind_from_jellyfin, ticks_to_ms};
|
||||
pub use media::MediaKind;
|
||||
Reference in New Issue
Block a user