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:
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user