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
+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,