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:
2026-07-23 20:53:47 +02:00
parent f89b241ad6
commit 55fa26377a
11 changed files with 448 additions and 27 deletions
+151
View File
@@ -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);
}
}
+68
View File
@@ -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
)
}
}
+10
View File
@@ -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;
+1
View File
@@ -2,6 +2,7 @@ mod auth;
mod commands;
mod connectivity;
mod credentials;
mod domain;
mod download;
mod jellyfin;
mod playback_mode;
+3
View File
@@ -2177,6 +2177,7 @@ mod tests {
id: id.to_string(),
name: format!("Episode {}", index),
item_type: "Episode".to_string(),
kind: crate::domain::MediaKind::Episode,
is_folder: false,
server_id: "server".to_string(),
parent_id: Some("season1".to_string()),
@@ -2184,11 +2185,13 @@ mod tests {
overview: None,
genres: None,
runtime_ticks: None,
duration_ms: None,
production_year: None,
premiere_date: None,
community_rating: None,
official_rating: None,
primary_image_tag: None,
image_id: None,
backdrop_image_tags: None,
parent_backdrop_image_tags: None,
album_id: None,
+3
View File
@@ -1422,6 +1422,7 @@ mod tests {
id: id.to_string(),
name: name.to_string(),
item_type: "Movie".to_string(),
kind: crate::domain::MediaKind::Movie,
is_folder: false,
server_id: "test-server".to_string(),
parent_id: Some("parent-123".to_string()),
@@ -1429,11 +1430,13 @@ mod tests {
overview: Some("Test overview".to_string()),
genres: Some(vec!["Action".to_string(), "Adventure".to_string()]),
runtime_ticks: Some(7200000000),
duration_ms: Some(720000),
production_year: Some(2024),
premiere_date: None,
community_rating: Some(8.5),
official_rating: Some("PG-13".to_string()),
primary_image_tag: Some("image-tag-123".to_string()),
image_id: Some("image-tag-123".to_string()),
backdrop_image_tags: Some(vec!["backdrop-1".to_string()]),
parent_backdrop_image_tags: None,
album_id: None,
+13 -2
View File
@@ -68,10 +68,13 @@ impl OfflineRepository {
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
.unwrap_or_default();
let kind = crate::domain::kind_from_jellyfin(&item.item_type, item.is_folder);
MediaItem {
id: item.id.clone(),
name: item.name,
item_type: item.item_type,
kind,
is_folder: item.is_folder,
server_id: item.server_id,
parent_id: item.parent_id,
@@ -82,11 +85,13 @@ impl OfflineRepository {
.as_ref()
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok()),
runtime_ticks: item.runtime_ticks,
duration_ms: item.runtime_ticks.map(crate::domain::ticks_to_ms),
production_year: item.production_year,
premiere_date: item.premiere_date,
community_rating: item.community_rating,
official_rating: item.official_rating,
primary_image_tag: item.primary_image_tag,
primary_image_tag: item.primary_image_tag.clone(),
image_id: item.primary_image_tag,
backdrop_image_tags: item.backdrop_image_tags,
parent_backdrop_image_tags: item.parent_backdrop_image_tags,
album_id: item.album_id,
@@ -1599,6 +1604,7 @@ impl MediaRepository for OfflineRepository {
id: person_data.0,
name: person_data.1,
item_type: "Person".to_string(),
kind: crate::domain::MediaKind::Person,
is_folder: false,
server_id: self.server_id.clone(),
parent_id: None,
@@ -1606,11 +1612,13 @@ impl MediaRepository for OfflineRepository {
overview: person_data.2,
genres: None,
runtime_ticks: None,
duration_ms: None,
production_year: None,
premiere_date: None,
community_rating: None,
official_rating: None,
primary_image_tag: person_data.3,
primary_image_tag: person_data.3.clone(),
image_id: person_data.3,
backdrop_image_tags: None,
parent_backdrop_image_tags: None,
album_id: None,
@@ -2100,6 +2108,7 @@ mod tests {
id: id.to_string(),
name: name.to_string(),
item_type: "Audio".to_string(),
kind: crate::domain::MediaKind::Track,
is_folder: false,
server_id: "test-server".to_string(),
parent_id: parent_id.map(|s| s.to_string()),
@@ -2107,11 +2116,13 @@ mod tests {
overview: None,
genres: None,
runtime_ticks: None,
duration_ms: None,
production_year: None,
premiere_date: None,
community_rating: None,
official_rating: None,
primary_image_tag: None,
image_id: None,
backdrop_image_tags: None,
parent_backdrop_image_tags: None,
album_id: None,
+9 -1
View File
@@ -619,10 +619,13 @@ impl JellyfinItem {
let primary_tag = self.image_tags.as_ref().and_then(|tags| tags.primary());
let backdrop_tags = self.backdrop_image_tags;
let kind = crate::domain::kind_from_jellyfin(&self.item_type, self.is_folder);
MediaItem {
id: self.id,
name: self.name,
item_type: self.item_type,
kind,
is_folder: self.is_folder,
server_id,
parent_id: self.parent_id,
@@ -634,7 +637,9 @@ impl JellyfinItem {
community_rating: self.community_rating,
official_rating: self.official_rating,
runtime_ticks: self.run_time_ticks,
primary_image_tag: primary_tag,
duration_ms: self.run_time_ticks.map(crate::domain::ticks_to_ms),
primary_image_tag: primary_tag.clone(),
image_id: primary_tag,
backdrop_image_tags: backdrop_tags,
parent_backdrop_image_tags: self.parent_backdrop_image_tags,
album_id: self.album_id,
@@ -923,6 +928,7 @@ impl MediaRepository for OnlineRepository {
.clone()
.unwrap_or_else(|| "Unknown Album".to_string()),
item_type: "MusicAlbum".to_string(),
kind: crate::domain::MediaKind::Album,
is_folder: true,
server_id: first_track.server_id.clone(),
parent_id: None,
@@ -934,7 +940,9 @@ impl MediaRepository for OnlineRepository {
community_rating: None,
official_rating: None,
runtime_ticks: None,
duration_ms: None,
primary_image_tag: first_track.primary_image_tag.clone(),
image_id: first_track.primary_image_tag.clone(),
backdrop_image_tags: None,
parent_backdrop_image_tags: None,
album_id: None,
+35 -1
View File
@@ -97,13 +97,24 @@ pub struct Person {
}
/// Media item
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct MediaItem {
pub id: String,
pub name: String,
/// Legacy Jellyfin item-type string (`"Audio"`, `"MusicAlbum"`, …).
///
/// Dual-carry migration (docs/specs/frontend-domain-model.md): `kind` below
/// is the neutral replacement. This field stays while the frontend migrates
/// off it, then is removed in a later phase. New Rust code should read
/// `kind`, not this.
#[serde(rename = "type")]
pub item_type: String,
/// Provider-neutral classification — the replacement for `item_type`.
/// Populated by the Jellyfin mapping; defaults to `Other` for the handful of
/// construction sites that have not been migrated yet.
#[serde(default)]
pub kind: crate::domain::MediaKind,
/// Whether this item is a folder/container (vs a playable leaf). Used to
/// decide whether a channel item drills into a list or plays directly.
#[serde(default)]
@@ -127,11 +138,25 @@ pub struct MediaItem {
pub community_rating: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub official_rating: Option<String>,
/// Legacy Jellyfin duration in ticks (100 ns units). Being replaced by
/// `duration_ms`; dual-carried while the frontend migrates
/// (docs/specs/frontend-domain-model.md). New code should read `duration_ms`.
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "runTimeTicks")]
pub runtime_ticks: Option<i64>,
/// Duration in milliseconds — the neutral replacement for `runtime_ticks`.
/// Ticks never reach the frontend; this does.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<i64>,
/// Legacy Jellyfin primary image tag. Being replaced by `image_id`;
/// dual-carried while the frontend migrates. New code should read `image_id`.
#[serde(skip_serializing_if = "Option::is_none")]
pub primary_image_tag: Option<String>,
/// Neutral image identifier the frontend resolves to a URL via the image
/// command — the replacement for `primary_image_tag`. Same value today
/// (Jellyfin's tag is the id); the rename removes the provider term.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub backdrop_image_tags: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -525,6 +550,7 @@ mod tests {
id: "1".to_string(),
name: "Test".to_string(),
item_type: "Audio".to_string(),
kind: crate::domain::MediaKind::Track,
is_folder: false,
server_id: "server1".to_string(),
parent_id: None,
@@ -536,7 +562,9 @@ mod tests {
community_rating: None,
official_rating: None,
runtime_ticks: None,
duration_ms: None,
primary_image_tag: None,
image_id: None,
backdrop_image_tags: None,
parent_backdrop_image_tags: None,
album_id: None,
@@ -675,6 +703,7 @@ mod tests {
id: "track1".to_string(),
name: "Test Track".to_string(),
item_type: "Audio".to_string(),
kind: crate::domain::MediaKind::Track,
is_folder: false,
server_id: "server1".to_string(),
parent_id: None,
@@ -686,7 +715,9 @@ mod tests {
community_rating: None,
official_rating: None,
runtime_ticks: None,
duration_ms: None,
primary_image_tag: None,
image_id: None,
backdrop_image_tags: None,
parent_backdrop_image_tags: None,
album_id: None,
@@ -736,6 +767,7 @@ mod tests {
id: "1".to_string(),
name: "Track".to_string(),
item_type: "Audio".to_string(),
kind: crate::domain::MediaKind::Track,
is_folder: false,
server_id: "s1".to_string(),
parent_id: None,
@@ -747,7 +779,9 @@ mod tests {
community_rating: None,
official_rating: None,
runtime_ticks: None,
duration_ms: None,
primary_image_tag: None,
image_id: None,
backdrop_image_tags: None,
parent_backdrop_image_tags: None,
album_id: None,