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;
|
||||
@@ -2,6 +2,7 @@ mod auth;
|
||||
mod commands;
|
||||
mod connectivity;
|
||||
mod credentials;
|
||||
mod domain;
|
||||
mod download;
|
||||
mod jellyfin;
|
||||
mod playback_mode;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user