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
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.
+13
View File
@@ -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.
+2 -2
View File
@@ -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};
+1
View File
@@ -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,
+5
View File
@@ -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<String>,
#[serde(skip_serializing_if = "Option::is_none")]