Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 21m37s
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 2m55s
Traceability Validation / Check Requirement Traces (push) Successful in 23s
Build & Release / Run Tests (push) Successful in 15m34s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m26s
Build & Release / Build Linux (push) Successful in 20m58s
Build & Release / Build Windows (push) Successful in 16m10s
Build & Release / Build Android (push) Successful in 31m25s
Build & Release / Create Release (push) Successful in 1m5s
Verified each against the code before acting; four of the five findings held, one did not. DR-253 — a deferred seek outlived its file. `seek` holds a position while MPV has nothing loaded and `FileLoaded` applies it (DR-241), but neither `load` nor `stop` discarded it. Scrub near the end of a transcoded item — which re-opens the stream — then skip to the next item before the reload completes, and the old position lands on the new item. It starts wherever the previous one was scrubbed to, silently. Both lifecycle points clear it now. DR-254 — a per-playback quality ceiling outlived its playback. The override is process-wide and describes one playback: dropping to 720p for a struggling episode says nothing about the next. Every advance the frontend drives clears it through player_play_item, but the background audio-only advance loads the next episode in Rust and skipped all three clearing sites — so every later episode stayed capped, with nothing in the UI explaining why. DR-255 — `playable_url` was a byte-identical copy of `playback_url`, added for the cross-platform open path. The original is `#[cfg(target_os = "android")]`, so it does not exist in a Linux build and nothing warned. Two matches over MediaSource meant a new variant could be handled in one and forgotten in the other. The gate is gone and the copy with it. The fifth finding — that the comment on `video_audio_codecs` describes a renderer switch the code no longer has — does not hold. `get_player_status` hard-codes Android to Native, but `experimentalNativeVideo` is still live in VideoPlayer.svelte as a suppressor that can force HTML5 even when Rust says native. The switch exists, so the narrow codec list is still doing its job. Both correctness fixes are red-then-green. The tests are wiring assertions in the style of UT-218: what matters is the call site, and reaching these at runtime needs a live MPV handle or a repository, a server and a player. That technique now appears three times and is worth watching — it pins call sites, not behaviour. The review's sharpest point is one it raised as redundancy: MpvPlayer already handles DR-253 correctly, resetting deferred state on every open, and the old path had to be patched separately. That is the drift two parallel engines produce, and the argument for finishing DR-248/249 rather than leaving LegacyPlayer in place indefinitely. 795 Rust tests, 1088 frontend, every CI check green locally.
653 lines
21 KiB
Rust
653 lines
21 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
use std::path::PathBuf;
|
|
|
|
/// Context for the current queue - where did the queue items come from?
|
|
/// This is used for remote playback transfer to send album/playlist context.
|
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
|
#[serde(tag = "type", rename_all = "lowercase")]
|
|
pub enum QueueContext {
|
|
/// Playing from a specific album
|
|
Album {
|
|
album_id: String,
|
|
album_name: String,
|
|
},
|
|
/// Playing from a specific playlist
|
|
Playlist {
|
|
playlist_id: String,
|
|
playlist_name: String,
|
|
},
|
|
/// Custom queue (search results, manual queue, etc.)
|
|
/// Will create a temporary playlist on remote transfer
|
|
#[default]
|
|
Custom,
|
|
}
|
|
|
|
/// Represents a subtitle track
|
|
///
|
|
/// 🔴 **Do not add `#[serde(rename_all = "camelCase")]` here.** This is the one
|
|
/// struct in the player that deliberately keeps snake_case on the wire, because
|
|
/// the *same* serialization feeds two consumers that both spell `mime_type`:
|
|
///
|
|
/// * the JNI boundary — `player/android/mod.rs` serializes `MediaItem::subtitles`
|
|
/// with `serde_json` and hands the string to `JellyTauPlayer.loadWithMetadata`,
|
|
/// whose parser reads `url`, `language`, `label` and `optString("mime_type")`;
|
|
/// * the IPC boundary — `PlayItemRequest::subtitles` deserializes this same type
|
|
/// from the frontend, and the generated binding (`SubtitleTrack` in
|
|
/// `bindings.ts`) therefore also declares `mime_type`.
|
|
///
|
|
/// Renaming would not break the build and would not fail the IPC: Kotlin's
|
|
/// `optString` would just fall back to its default MIME type for every track, so
|
|
/// the failure would be silent. UT-146 asserts the serialized keys.
|
|
///
|
|
/// TRACES: UR-020 | IR-016, JA-008 | UT-146
|
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub struct SubtitleTrack {
|
|
/// Stream index in the media source
|
|
pub index: i32,
|
|
/// Subtitle URL
|
|
pub url: String,
|
|
/// Language code (e.g., "eng", "spa")
|
|
pub language: Option<String>,
|
|
/// Display title
|
|
pub label: Option<String>,
|
|
/// MIME type (e.g., "text/vtt", "application/x-subrip").
|
|
/// Snake_case on purpose — see the note on the struct.
|
|
pub mime_type: String,
|
|
}
|
|
|
|
/// Represents a media item that can be played
|
|
///
|
|
/// TRACES: UR-003, UR-004 | DR-002
|
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
#[specta(rename = "PlayerMediaItem")]
|
|
pub struct MediaItem {
|
|
/// Unique identifier
|
|
pub id: String,
|
|
/// Display title
|
|
pub title: String,
|
|
/// Name (alias for title - for frontend compatibility)
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub name: Option<String>,
|
|
/// Artist name(s) for audio
|
|
pub artist: Option<String>,
|
|
/// Album name for audio
|
|
pub album: Option<String>,
|
|
/// Album name (alias - for frontend compatibility)
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub album_name: Option<String>,
|
|
/// Album ID (Jellyfin ID) for remote transfer context
|
|
#[serde(default)]
|
|
pub album_id: Option<String>,
|
|
/// Artist items with IDs for clickable links
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub artist_items: Option<Vec<crate::repository::types::ArtistItem>>,
|
|
/// Artists as array of strings (fallback when artist_items not available)
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub artists: Option<Vec<String>>,
|
|
/// Primary image tag for artwork.
|
|
///
|
|
/// Legacy Jellyfin name; being replaced by `image_id` (same value). Dual-carried
|
|
/// while the frontend migrates (docs/specs/frontend-domain-model.md).
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub primary_image_tag: Option<String>,
|
|
/// Neutral image identifier the frontend resolves to a URL — replaces
|
|
/// `primary_image_tag`.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub image_id: Option<String>,
|
|
/// Item type (Audio, Movie, Episode, etc.)
|
|
#[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
|
|
pub item_type: Option<String>,
|
|
/// Playlist ID (Jellyfin ID) for remote transfer context
|
|
#[serde(default)]
|
|
pub playlist_id: Option<String>,
|
|
/// Duration in seconds
|
|
pub duration: Option<f64>,
|
|
/// URL or path to artwork image
|
|
pub artwork_url: Option<String>,
|
|
/// Type of media
|
|
pub media_type: MediaType,
|
|
/// Source of the media
|
|
pub source: MediaSource,
|
|
/// Video codec (e.g., "h264", "hevc") for video media
|
|
#[serde(default)]
|
|
pub video_codec: Option<String>,
|
|
/// Whether the video requires server-side transcoding
|
|
#[serde(default)]
|
|
pub needs_transcoding: bool,
|
|
/// How this item's stream is fetched, as the backend decided it.
|
|
///
|
|
/// Carried on the queue item so a later seek/reload does not have to guess.
|
|
/// `None` for items queued by a path that never negotiated (audio tracks,
|
|
/// direct URLs) and for anything queued before this field existed, where the
|
|
/// caller falls back to `needs_transcoding` — every transcode this app
|
|
/// requests is HLS (DR-140), so that fallback is exact rather than a guess.
|
|
///
|
|
/// TRACES: UR-003, UR-004, UR-079 | DR-225, DR-230
|
|
#[serde(default)]
|
|
pub transport: Option<crate::repository::Transport>,
|
|
|
|
/// Video width in pixels
|
|
#[serde(default)]
|
|
pub video_width: Option<u32>,
|
|
/// Video height in pixels
|
|
#[serde(default)]
|
|
pub video_height: Option<u32>,
|
|
/// Available subtitle tracks
|
|
#[serde(default)]
|
|
pub subtitles: Vec<SubtitleTrack>,
|
|
/// Series ID (for TV show episodes) - used for series audio preferences
|
|
#[serde(default)]
|
|
pub series_id: Option<String>,
|
|
/// Server ID - used for series audio preferences
|
|
#[serde(default)]
|
|
pub server_id: Option<String>,
|
|
}
|
|
|
|
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum MediaType {
|
|
Audio,
|
|
Video,
|
|
}
|
|
|
|
/// TRACES: UR-002, UR-003, UR-004, UR-011 | DR-003
|
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
#[serde(tag = "type", rename_all = "lowercase")]
|
|
#[specta(rename = "PlayerMediaSource")]
|
|
pub enum MediaSource {
|
|
/// Streaming from Jellyfin server
|
|
Remote {
|
|
stream_url: String,
|
|
jellyfin_item_id: String,
|
|
},
|
|
/// Downloaded/cached locally
|
|
Local {
|
|
file_path: PathBuf,
|
|
/// Original Jellyfin ID for sync-back
|
|
jellyfin_item_id: Option<String>,
|
|
},
|
|
/// Direct URL (e.g., channel plugins)
|
|
DirectUrl { url: String },
|
|
}
|
|
|
|
impl MediaItem {
|
|
/// Get the Jellyfin item ID if available
|
|
pub fn jellyfin_id(&self) -> Option<&str> {
|
|
match &self.source {
|
|
MediaSource::Remote {
|
|
jellyfin_item_id, ..
|
|
} => Some(jellyfin_item_id),
|
|
MediaSource::Local {
|
|
jellyfin_item_id, ..
|
|
} => jellyfin_item_id.as_deref(),
|
|
MediaSource::DirectUrl { .. } => None,
|
|
}
|
|
}
|
|
|
|
/// The URL or path an engine should open.
|
|
///
|
|
/// Not gated to Android any more. It was, back when only ExoPlayer needed
|
|
/// direct URL access — and that gate is why a byte-identical copy was later
|
|
/// added for the cross-platform `MediaPlayer::open` path without anyone
|
|
/// noticing this existed: it is invisible in a Linux build, so nothing
|
|
/// warned. Two matches over `MediaSource` meant a new variant could be
|
|
/// handled in one and forgotten in the other, silently.
|
|
///
|
|
/// TRACES: UR-081 | DR-245, DR-255
|
|
pub fn playback_url(&self) -> String {
|
|
match &self.source {
|
|
MediaSource::Remote { stream_url, .. } => stream_url.clone(),
|
|
MediaSource::Local { file_path, .. } => file_path.to_string_lossy().to_string(),
|
|
MediaSource::DirectUrl { url } => url.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl MediaItem {
|
|
/// A minimal item for tests.
|
|
///
|
|
/// The struct has twenty-odd fields, almost none of which any given test
|
|
/// cares about, and repeating the literal per test is how a new field ends
|
|
/// up added in thirty places. Set what matters on the result.
|
|
///
|
|
/// TRACES: UR-081 | DR-243
|
|
#[cfg(any(test, feature = "conformance"))]
|
|
pub fn sample(id: &str, url: &str) -> Self {
|
|
Self {
|
|
transport: None,
|
|
id: id.to_string(),
|
|
title: id.to_string(),
|
|
name: None,
|
|
artist: None,
|
|
album: None,
|
|
album_name: None,
|
|
album_id: None,
|
|
artist_items: None,
|
|
artists: None,
|
|
primary_image_tag: None,
|
|
image_id: None,
|
|
item_type: None,
|
|
playlist_id: None,
|
|
duration: None,
|
|
artwork_url: None,
|
|
media_type: MediaType::Video,
|
|
source: MediaSource::DirectUrl {
|
|
url: url.to_string(),
|
|
},
|
|
video_codec: None,
|
|
needs_transcoding: false,
|
|
video_width: None,
|
|
video_height: None,
|
|
subtitles: vec![],
|
|
series_id: None,
|
|
server_id: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::path::PathBuf;
|
|
|
|
#[test]
|
|
fn test_queue_context_album() {
|
|
let context = QueueContext::Album {
|
|
album_id: "album-123".to_string(),
|
|
album_name: "Test Album".to_string(),
|
|
};
|
|
|
|
assert!(matches!(context, QueueContext::Album { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn test_queue_context_playlist() {
|
|
let context = QueueContext::Playlist {
|
|
playlist_id: "playlist-456".to_string(),
|
|
playlist_name: "Test Playlist".to_string(),
|
|
};
|
|
|
|
assert!(matches!(context, QueueContext::Playlist { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn test_queue_context_custom() {
|
|
let context = QueueContext::Custom;
|
|
assert!(matches!(context, QueueContext::Custom));
|
|
}
|
|
|
|
#[test]
|
|
fn test_queue_context_serialization() {
|
|
let context = QueueContext::Album {
|
|
album_id: "alb-001".to_string(),
|
|
album_name: "Album 001".to_string(),
|
|
};
|
|
|
|
let json = serde_json::to_string(&context);
|
|
assert!(json.is_ok());
|
|
let serialized = json.unwrap();
|
|
assert!(serialized.contains("album"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_queue_context_default() {
|
|
let context = QueueContext::default();
|
|
assert!(matches!(context, QueueContext::Custom));
|
|
}
|
|
|
|
#[test]
|
|
fn test_queue_context_clone() {
|
|
let context = QueueContext::Album {
|
|
album_id: "clone-alb".to_string(),
|
|
album_name: "Clone Album".to_string(),
|
|
};
|
|
|
|
let cloned = context.clone();
|
|
assert_eq!(context, cloned);
|
|
}
|
|
|
|
#[test]
|
|
fn test_subtitle_track_creation() {
|
|
let track = SubtitleTrack {
|
|
index: 0,
|
|
url: "https://example.com/subs.vtt".to_string(),
|
|
language: Some("eng".to_string()),
|
|
label: Some("English".to_string()),
|
|
mime_type: "text/vtt".to_string(),
|
|
};
|
|
|
|
assert_eq!(track.index, 0);
|
|
assert_eq!(track.language, Some("eng".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_subtitle_track_without_language() {
|
|
let track = SubtitleTrack {
|
|
index: 1,
|
|
url: "https://example.com/subs.srt".to_string(),
|
|
language: None,
|
|
label: Some("Subtitles".to_string()),
|
|
mime_type: "application/x-subrip".to_string(),
|
|
};
|
|
|
|
assert!(track.language.is_none());
|
|
assert!(track.label.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_subtitle_track_serialization() {
|
|
let track = SubtitleTrack {
|
|
index: 0,
|
|
url: "url.vtt".to_string(),
|
|
language: Some("eng".to_string()),
|
|
label: None,
|
|
mime_type: "text/vtt".to_string(),
|
|
};
|
|
|
|
let json = serde_json::to_string(&track);
|
|
assert!(json.is_ok());
|
|
let serialized = json.unwrap();
|
|
assert!(serialized.contains("0"));
|
|
assert!(serialized.contains("eng"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_media_type_audio() {
|
|
let media_type = MediaType::Audio;
|
|
let json = serde_json::to_string(&media_type).unwrap();
|
|
assert!(json.contains("audio"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_media_type_video() {
|
|
let media_type = MediaType::Video;
|
|
let json = serde_json::to_string(&media_type).unwrap();
|
|
assert!(json.contains("video"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_media_source_remote() {
|
|
let source = MediaSource::Remote {
|
|
stream_url: "https://server.com/video.mp4".to_string(),
|
|
jellyfin_item_id: "item-123".to_string(),
|
|
};
|
|
|
|
assert!(matches!(source, MediaSource::Remote { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn test_media_source_local() {
|
|
let source = MediaSource::Local {
|
|
file_path: PathBuf::from("/path/to/video.mp4"),
|
|
jellyfin_item_id: Some("item-456".to_string()),
|
|
};
|
|
|
|
assert!(matches!(source, MediaSource::Local { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn test_media_source_local_without_jellyfin_id() {
|
|
let source = MediaSource::Local {
|
|
file_path: PathBuf::from("/downloads/audio.mp3"),
|
|
jellyfin_item_id: None,
|
|
};
|
|
|
|
assert!(matches!(source, MediaSource::Local { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn test_media_source_direct_url() {
|
|
let source = MediaSource::DirectUrl {
|
|
url: "https://external.com/stream".to_string(),
|
|
};
|
|
|
|
assert!(matches!(source, MediaSource::DirectUrl { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn test_media_source_serialization() {
|
|
let source = MediaSource::DirectUrl {
|
|
url: "https://example.com/stream".to_string(),
|
|
};
|
|
|
|
let json = serde_json::to_string(&source);
|
|
assert!(json.is_ok());
|
|
let serialized = json.unwrap();
|
|
assert!(serialized.contains("directurl"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_media_item_creation_minimal() {
|
|
let item = MediaItem {
|
|
// Audio and direct-URL items never negotiate a transport.
|
|
transport: None,
|
|
id: "item-1".to_string(),
|
|
title: "Test Item".to_string(),
|
|
name: None,
|
|
artist: None,
|
|
album: None,
|
|
album_name: None,
|
|
album_id: None,
|
|
artist_items: None,
|
|
artists: None,
|
|
primary_image_tag: None,
|
|
image_id: None,
|
|
item_type: None,
|
|
playlist_id: None,
|
|
duration: None,
|
|
artwork_url: None,
|
|
media_type: MediaType::Video,
|
|
source: MediaSource::DirectUrl {
|
|
url: "https://example.com/video".to_string(),
|
|
},
|
|
video_codec: None,
|
|
needs_transcoding: false,
|
|
video_width: None,
|
|
video_height: None,
|
|
subtitles: vec![],
|
|
series_id: None,
|
|
server_id: None,
|
|
};
|
|
|
|
assert_eq!(item.id, "item-1");
|
|
assert_eq!(item.title, "Test Item");
|
|
assert!(!item.needs_transcoding);
|
|
}
|
|
|
|
#[test]
|
|
fn test_media_item_jellyfin_id() {
|
|
let item = MediaItem {
|
|
// Audio and direct-URL items never negotiate a transport.
|
|
transport: None,
|
|
id: "item-2".to_string(),
|
|
title: "Test".to_string(),
|
|
name: None,
|
|
artist: None,
|
|
album: None,
|
|
album_name: None,
|
|
album_id: None,
|
|
artist_items: None,
|
|
artists: None,
|
|
primary_image_tag: None,
|
|
image_id: None,
|
|
item_type: None,
|
|
playlist_id: None,
|
|
duration: None,
|
|
artwork_url: None,
|
|
media_type: MediaType::Audio,
|
|
source: MediaSource::Remote {
|
|
stream_url: "https://server/stream".to_string(),
|
|
jellyfin_item_id: "jf-id-123".to_string(),
|
|
},
|
|
video_codec: None,
|
|
needs_transcoding: false,
|
|
video_width: None,
|
|
video_height: None,
|
|
subtitles: vec![],
|
|
series_id: None,
|
|
server_id: None,
|
|
};
|
|
|
|
assert_eq!(item.jellyfin_id(), Some("jf-id-123"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_media_item_jellyfin_id_local() {
|
|
let item = MediaItem {
|
|
// Audio and direct-URL items never negotiate a transport.
|
|
transport: None,
|
|
id: "item-3".to_string(),
|
|
title: "Local".to_string(),
|
|
name: None,
|
|
artist: None,
|
|
album: None,
|
|
album_name: None,
|
|
album_id: None,
|
|
artist_items: None,
|
|
artists: None,
|
|
primary_image_tag: None,
|
|
image_id: None,
|
|
item_type: None,
|
|
playlist_id: None,
|
|
duration: None,
|
|
artwork_url: None,
|
|
media_type: MediaType::Video,
|
|
source: MediaSource::Local {
|
|
file_path: PathBuf::from("/local/video.mp4"),
|
|
jellyfin_item_id: Some("jf-local".to_string()),
|
|
},
|
|
video_codec: None,
|
|
needs_transcoding: false,
|
|
video_width: None,
|
|
video_height: None,
|
|
subtitles: vec![],
|
|
series_id: None,
|
|
server_id: None,
|
|
};
|
|
|
|
assert_eq!(item.jellyfin_id(), Some("jf-local"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_media_item_jellyfin_id_direct_url() {
|
|
let item = MediaItem {
|
|
// Audio and direct-URL items never negotiate a transport.
|
|
transport: None,
|
|
id: "item-4".to_string(),
|
|
title: "Direct".to_string(),
|
|
name: None,
|
|
artist: None,
|
|
album: None,
|
|
album_name: None,
|
|
album_id: None,
|
|
artist_items: None,
|
|
artists: None,
|
|
primary_image_tag: None,
|
|
image_id: None,
|
|
item_type: None,
|
|
playlist_id: None,
|
|
duration: None,
|
|
artwork_url: None,
|
|
media_type: MediaType::Video,
|
|
source: MediaSource::DirectUrl {
|
|
url: "https://external.com/media".to_string(),
|
|
},
|
|
video_codec: None,
|
|
needs_transcoding: false,
|
|
video_width: None,
|
|
video_height: None,
|
|
subtitles: vec![],
|
|
series_id: None,
|
|
server_id: None,
|
|
};
|
|
|
|
assert_eq!(item.jellyfin_id(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_media_item_with_subtitles() {
|
|
let sub = SubtitleTrack {
|
|
index: 0,
|
|
url: "subs.vtt".to_string(),
|
|
language: Some("eng".to_string()),
|
|
label: Some("English".to_string()),
|
|
mime_type: "text/vtt".to_string(),
|
|
};
|
|
|
|
let item = MediaItem {
|
|
// Audio and direct-URL items never negotiate a transport.
|
|
transport: None,
|
|
id: "item-subs".to_string(),
|
|
title: "With Subs".to_string(),
|
|
name: None,
|
|
artist: None,
|
|
album: None,
|
|
album_name: None,
|
|
album_id: None,
|
|
artist_items: None,
|
|
artists: None,
|
|
primary_image_tag: None,
|
|
image_id: None,
|
|
item_type: None,
|
|
playlist_id: None,
|
|
duration: None,
|
|
artwork_url: None,
|
|
media_type: MediaType::Video,
|
|
source: MediaSource::DirectUrl {
|
|
url: "video.mp4".to_string(),
|
|
},
|
|
video_codec: None,
|
|
needs_transcoding: false,
|
|
video_width: None,
|
|
video_height: None,
|
|
subtitles: vec![sub],
|
|
series_id: None,
|
|
server_id: None,
|
|
};
|
|
|
|
assert_eq!(item.subtitles.len(), 1);
|
|
assert_eq!(item.subtitles[0].language, Some("eng".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_media_item_serialization() {
|
|
let item = MediaItem {
|
|
// Audio and direct-URL items never negotiate a transport.
|
|
transport: None,
|
|
id: "serial-item".to_string(),
|
|
title: "Serial Test".to_string(),
|
|
name: Some("Name".to_string()),
|
|
artist: None,
|
|
album: None,
|
|
album_name: None,
|
|
album_id: None,
|
|
artist_items: None,
|
|
artists: None,
|
|
primary_image_tag: None,
|
|
image_id: None,
|
|
item_type: Some("Movie".to_string()),
|
|
playlist_id: None,
|
|
duration: Some(120.0),
|
|
artwork_url: None,
|
|
media_type: MediaType::Video,
|
|
source: MediaSource::DirectUrl {
|
|
url: "https://example.com/movie.mp4".to_string(),
|
|
},
|
|
video_codec: Some("h264".to_string()),
|
|
needs_transcoding: false,
|
|
video_width: Some(1920),
|
|
video_height: Some(1080),
|
|
subtitles: vec![],
|
|
series_id: None,
|
|
server_id: None,
|
|
};
|
|
|
|
let json = serde_json::to_string(&item);
|
|
assert!(json.is_ok());
|
|
let serialized = json.unwrap();
|
|
assert!(serialized.contains("serial-item"));
|
|
assert!(serialized.contains("Serial Test"));
|
|
}
|
|
}
|