diff --git a/src-tauri/src/domain/from_jellyfin.rs b/src-tauri/src/domain/from_jellyfin.rs index 37b53ab2..42e3f028 100644 --- a/src-tauri/src/domain/from_jellyfin.rs +++ b/src-tauri/src/domain/from_jellyfin.rs @@ -6,7 +6,18 @@ //! //! Spec: docs/specs/frontend-domain-model.md -use super::media::MediaKind; +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. @@ -143,6 +154,18 @@ mod tests { ); } + #[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. diff --git a/src-tauri/src/domain/media.rs b/src-tauri/src/domain/media.rs index 235b46d3..17fbc9c2 100644 --- a/src-tauri/src/domain/media.rs +++ b/src-tauri/src/domain/media.rs @@ -57,6 +57,19 @@ pub enum MediaKind { Other, } +/// The kind of a media stream within an item (audio track, video track, +/// subtitle, …) — provider-neutral, replacing the stringly Jellyfin stream type. +#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum StreamKind { + Audio, + Video, + Subtitle, + /// Any stream kind we do not model explicitly (e.g. embedded image, data). + #[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. diff --git a/src-tauri/src/domain/mod.rs b/src-tauri/src/domain/mod.rs index 90b35f92..8cbeea61 100644 --- a/src-tauri/src/domain/mod.rs +++ b/src-tauri/src/domain/mod.rs @@ -6,5 +6,5 @@ pub mod from_jellyfin; pub mod media; -pub use from_jellyfin::{kind_from_jellyfin, ticks_to_ms}; -pub use media::MediaKind; +pub use from_jellyfin::{kind_from_jellyfin, stream_kind_from_jellyfin, ticks_to_ms}; +pub use media::{MediaKind, StreamKind}; diff --git a/src-tauri/src/repository/online.rs b/src-tauri/src/repository/online.rs index d07a6a90..c2800968 100644 --- a/src-tauri/src/repository/online.rs +++ b/src-tauri/src/repository/online.rs @@ -658,6 +658,7 @@ impl JellyfinItem { streams .into_iter() .map(|s| crate::repository::types::MediaStream { + kind: crate::domain::stream_kind_from_jellyfin(&s.stream_type), stream_type: s.stream_type, codec: s.codec, language: s.language, diff --git a/src-tauri/src/repository/types.rs b/src-tauri/src/repository/types.rs index 668c0432..61d5c0ec 100644 --- a/src-tauri/src/repository/types.rs +++ b/src-tauri/src/repository/types.rs @@ -205,8 +205,13 @@ pub struct MediaItem { #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MediaStream { + /// Legacy Jellyfin stream type string ("Audio"/"Video"/"Subtitle"). Being + /// replaced by `kind`; dual-carried while the frontend migrates. #[serde(rename = "type")] pub stream_type: String, + /// Provider-neutral stream classification — replaces `stream_type`. + #[serde(default)] + pub kind: crate::domain::StreamKind, #[serde(skip_serializing_if = "Option::is_none")] pub codec: Option, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/src/lib/api/bindings.ts b/src/lib/api/bindings.ts index c8890a02..52d55f0e 100644 --- a/src/lib/api/bindings.ts +++ b/src/lib/api/bindings.ts @@ -1912,7 +1912,16 @@ export type MediaSource = { id: string; name: string; container?: string | null; /** * Media stream information (audio, video, subtitle tracks) */ -export type MediaStream = { type: string; codec?: string | null; language?: string | null; displayTitle?: string | null; index: number; isDefault: boolean; isForced: boolean } +export type MediaStream = { +/** + * Legacy Jellyfin stream type string ("Audio"/"Video"/"Subtitle"). Being + * replaced by `kind`; dual-carried while the frontend migrates. + */ +type: string; +/** + * Provider-neutral stream classification — replaces `stream_type`. + */ +kind?: StreamKind; codec?: string | null; language?: string | null; displayTitle?: string | null; index: number; isDefault: boolean; isForced: boolean } export type MediaType = "audio" | "video" /** * Lightweight media item for merged playback state @@ -2516,6 +2525,15 @@ export type SmartCacheStats = { total_size: number; storage_limit: number; avail * Storage statistics for downloads */ export type StorageStats = { total_bytes: number; total_items: number; albums: AlbumStorageInfo[] } +/** + * The kind of a media stream within an item (audio track, video track, + * subtitle, …) — provider-neutral, replacing the stringly Jellyfin stream type. + */ +export type StreamKind = "audio" | "video" | "subtitle" | +/** + * Any stream kind we do not model explicitly (e.g. embedded image, data). + */ +"other" /** * Represents a subtitle track */ diff --git a/src/lib/components/player/VideoPlayer.svelte b/src/lib/components/player/VideoPlayer.svelte index 11f7e2b0..f726c086 100644 --- a/src/lib/components/player/VideoPlayer.svelte +++ b/src/lib/components/player/VideoPlayer.svelte @@ -180,7 +180,7 @@ console.log("[VideoPlayer] No media or mediaStreams available"); return []; } - const tracks = media.mediaStreams.filter(stream => stream.type === "Audio"); + const tracks = media.mediaStreams.filter(stream => stream.kind === "audio"); console.log("[VideoPlayer] Found audio tracks:", tracks.length, tracks); return tracks; }); @@ -243,7 +243,7 @@ console.log("[VideoPlayer] No media or mediaStreams available for subtitles"); return []; } - const tracks = media.mediaStreams.filter(stream => stream.type === "Subtitle"); + const tracks = media.mediaStreams.filter(stream => stream.kind === "subtitle"); console.log("[VideoPlayer] Found subtitle tracks:", tracks.length, tracks); return tracks; }); @@ -520,7 +520,7 @@ // Build subtitle tracks for native player const subtitleTracks = []; if (media.mediaStreams && mediaSourceId) { - const subtitles = media.mediaStreams.filter(s => s.type === "Subtitle"); + const subtitles = media.mediaStreams.filter(s => s.kind === "subtitle"); for (const sub of subtitles) { try { const url = await getSubtitleUrl(sub.index); diff --git a/src/routes/player/[id]/+page.svelte b/src/routes/player/[id]/+page.svelte index 4b982fe8..709731c4 100644 --- a/src/routes/player/[id]/+page.svelte +++ b/src/routes/player/[id]/+page.svelte @@ -111,8 +111,7 @@ function isVideoChannelItem(item: MediaItem): boolean { return ( item.kind === "channelItem" && - // stream.type is Jellyfin stream vocabulary (migrated in a later phase). - (item.mediaStreams?.some((s) => s.type === "Video") ?? false) + (item.mediaStreams?.some((s) => s.kind === "video") ?? false) ); }