domain: neutral StreamKind for media streams (phase 4d)

Add StreamKind enum (audio/video/subtitle/other) to the domain module with
a total stream_kind_from_jellyfin mapper. MediaStream gains a kind field
(dual-carry), populated at the mapping seam. Frontend VideoPlayer track/
subtitle selection and the channel-video check now use stream.kind instead
of the Jellyfin stream.type string.

Rust 456 (+ stream_kinds_map test), frontend 644, check clean.
This commit is contained in:
2026-07-23 22:11:31 +02:00
parent ec8a7610f5
commit 1968c06172
8 changed files with 68 additions and 9 deletions
+24 -1
View File
@@ -6,7 +6,18 @@
//! //!
//! Spec: docs/specs/frontend-domain-model.md //! 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. /// Jellyfin ticks per second (10 million). A tick is 100 ns.
/// The frontend must never see ticks — this is where they die. /// 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] #[test]
fn unknown_type_never_panics_and_falls_back() { fn unknown_type_never_panics_and_falls_back() {
// The whole point: garbage in, safe kind out, no panic. // The whole point: garbage in, safe kind out, no panic.
+13
View File
@@ -57,6 +57,19 @@ pub enum MediaKind {
Other, 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 { impl MediaKind {
/// True for kinds that are containers/collections rather than playable leaves. /// True for kinds that are containers/collections rather than playable leaves.
/// Presentation-neutral helper the backend can use for e.g. drill-vs-play. /// Presentation-neutral helper the backend can use for e.g. drill-vs-play.
+2 -2
View File
@@ -6,5 +6,5 @@
pub mod from_jellyfin; pub mod from_jellyfin;
pub mod media; pub mod media;
pub use from_jellyfin::{kind_from_jellyfin, ticks_to_ms}; pub use from_jellyfin::{kind_from_jellyfin, stream_kind_from_jellyfin, ticks_to_ms};
pub use media::MediaKind; pub use media::{MediaKind, StreamKind};
+1
View File
@@ -658,6 +658,7 @@ impl JellyfinItem {
streams streams
.into_iter() .into_iter()
.map(|s| crate::repository::types::MediaStream { .map(|s| crate::repository::types::MediaStream {
kind: crate::domain::stream_kind_from_jellyfin(&s.stream_type),
stream_type: s.stream_type, stream_type: s.stream_type,
codec: s.codec, codec: s.codec,
language: s.language, language: s.language,
+5
View File
@@ -205,8 +205,13 @@ pub struct MediaItem {
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)] #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct MediaStream { pub struct MediaStream {
/// Legacy Jellyfin stream type string ("Audio"/"Video"/"Subtitle"). Being
/// replaced by `kind`; dual-carried while the frontend migrates.
#[serde(rename = "type")] #[serde(rename = "type")]
pub stream_type: String, 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")] #[serde(skip_serializing_if = "Option::is_none")]
pub codec: Option<String>, pub codec: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
+19 -1
View File
@@ -1912,7 +1912,16 @@ export type MediaSource = { id: string; name: string; container?: string | null;
/** /**
* Media stream information (audio, video, subtitle tracks) * 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" export type MediaType = "audio" | "video"
/** /**
* Lightweight media item for merged playback state * 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 * Storage statistics for downloads
*/ */
export type StorageStats = { total_bytes: number; total_items: number; albums: AlbumStorageInfo[] } 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 * Represents a subtitle track
*/ */
+3 -3
View File
@@ -180,7 +180,7 @@
console.log("[VideoPlayer] No media or mediaStreams available"); console.log("[VideoPlayer] No media or mediaStreams available");
return []; 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); console.log("[VideoPlayer] Found audio tracks:", tracks.length, tracks);
return tracks; return tracks;
}); });
@@ -243,7 +243,7 @@
console.log("[VideoPlayer] No media or mediaStreams available for subtitles"); console.log("[VideoPlayer] No media or mediaStreams available for subtitles");
return []; 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); console.log("[VideoPlayer] Found subtitle tracks:", tracks.length, tracks);
return tracks; return tracks;
}); });
@@ -520,7 +520,7 @@
// Build subtitle tracks for native player // Build subtitle tracks for native player
const subtitleTracks = []; const subtitleTracks = [];
if (media.mediaStreams && mediaSourceId) { 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) { for (const sub of subtitles) {
try { try {
const url = await getSubtitleUrl(sub.index); const url = await getSubtitleUrl(sub.index);
+1 -2
View File
@@ -111,8 +111,7 @@
function isVideoChannelItem(item: MediaItem): boolean { function isVideoChannelItem(item: MediaItem): boolean {
return ( return (
item.kind === "channelItem" && item.kind === "channelItem" &&
// stream.type is Jellyfin stream vocabulary (migrated in a later phase). (item.mediaStreams?.some((s) => s.kind === "video") ?? false)
(item.mediaStreams?.some((s) => s.type === "Video") ?? false)
); );
} }