diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index dffaee34..0ff7533a 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "name": "vuio", "displayName": "VuIO Media Server", "description": "Browse, search and cast your VuIO media library — and control the TVs and speakers on your network — from Claude.", - "version": "0.0.47", + "version": "0.0.48", "author": { "name": "vyrti", "url": "https://github.com/vuiodev" @@ -29,4 +29,4 @@ "source": "./claude/plugin" } ] -} +} \ No newline at end of file diff --git a/claude/mcpb/manifest.json b/claude/mcpb/manifest.json index ed38fa38..88b6f5d8 100644 --- a/claude/mcpb/manifest.json +++ b/claude/mcpb/manifest.json @@ -2,7 +2,7 @@ "manifest_version": "0.3", "name": "vuio", "display_name": "VuIO Media Server", - "version": "0.0.47", + "version": "0.0.48", "description": "Browse, search and cast your VuIO media library from Claude.", "long_description": "Connects Claude to a VuIO media server on your network. Search the library, browse folders, build playlists, and cast to DLNA, Chromecast and AirPlay devices.\n\nThis bundle runs `vuio mcp`, which bridges Claude's stdio connection to a VuIO server that is already running. It does not start a server or open the library database itself — point it at the machine that does.", "author": { @@ -17,7 +17,14 @@ "documentation": "https://github.com/vuiodev/vuio#ai-agent--mcp-integration", "support": "https://github.com/vuiodev/vuio/issues", "license": "MIT OR Apache-2.0", - "keywords": ["dlna", "upnp", "chromecast", "airplay", "media-server", "casting"], + "keywords": [ + "dlna", + "upnp", + "chromecast", + "airplay", + "media-server", + "casting" + ], "server": { "type": "binary", "entry_point": "bin/vuio", @@ -55,6 +62,10 @@ "tools_generated": true, "compatibility": { "claude_desktop": ">=1.0.0", - "platforms": ["darwin", "win32", "linux"] + "platforms": [ + "darwin", + "win32", + "linux" + ] } -} +} \ No newline at end of file diff --git a/claude/plugin/.claude-plugin/plugin.json b/claude/plugin/.claude-plugin/plugin.json index b2566f05..0cc19fa8 100644 --- a/claude/plugin/.claude-plugin/plugin.json +++ b/claude/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "vuio", "displayName": "VuIO Media Server", - "version": "0.0.47", + "version": "0.0.48", "description": "Browse, search and cast your VuIO media library — and control the TVs and speakers on your network — from Claude.", "author": { "name": "vyrti", @@ -10,8 +10,15 @@ "homepage": "https://github.com/vuiodev/vuio", "repository": "https://github.com/vuiodev/vuio", "license": "MIT OR Apache-2.0", - "keywords": ["dlna", "upnp", "chromecast", "airplay", "media-server", "casting"], + "keywords": [ + "dlna", + "upnp", + "chromecast", + "airplay", + "media-server", + "casting" + ], "skills": "./skills/", "commands": "./commands/", "mcpServers": "./.mcp.json" -} +} \ No newline at end of file diff --git a/crates/vuio-bench/Cargo.toml b/crates/vuio-bench/Cargo.toml index 9f75a18c..76aee59c 100644 --- a/crates/vuio-bench/Cargo.toml +++ b/crates/vuio-bench/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vuio-bench" -version = "0.0.47" +version = "0.0.48" edition = "2021" authors = ["vyrti"] description = "Generates large VuIO libraries for performance work. Not published." @@ -37,7 +37,7 @@ tokio = { version = "1.53", features = ["rt-multi-thread", "macros"] } # because it opens every internal module and carries no stability promise. This # crate is `publish = false` and exists only to drive the database from the # inside, which is the same category as core's own dev-dependency on itself. -vuio-core = { path = "../vuio-core", version = "0.0.47", features = ["unstable-internals", "transcode-aac", "transcode-ac3", "transcode-dts"] } +vuio-core = { path = "../vuio-core", version = "0.0.48", features = ["unstable-internals", "transcode-aac", "transcode-ac3", "transcode-dts"] } vuio-codec-ac3 = { path = "../vuio-codec-ac3" } oxideav-dts = { path = "../vendor/oxideav-dts" } oxideav-core = { path = "../vendor/oxideav-core" } diff --git a/crates/vuio-cli/Cargo.toml b/crates/vuio-cli/Cargo.toml index e75b723b..a33522bd 100644 --- a/crates/vuio-cli/Cargo.toml +++ b/crates/vuio-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vuio-cli" -version = "0.0.47" +version = "0.0.48" edition = "2021" authors = ["vyrti"] description = "VuIO media server command-line application" @@ -41,5 +41,5 @@ serde_json = "1.0" tokio = { version = "1.53", features = ["rt-multi-thread", "macros", "signal", "io-std", "io-util"] } tracing = "0.1" uuid = { version = "1.24", features = ["v4"] } -vuio-core = { path = "../vuio-core", version = "0.0.47" } +vuio-core = { path = "../vuio-core", version = "0.0.48" } diff --git a/crates/vuio-core/Cargo.toml b/crates/vuio-core/Cargo.toml index 88158be6..88880197 100644 --- a/crates/vuio-core/Cargo.toml +++ b/crates/vuio-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vuio-core" -version = "0.0.47" +version = "0.0.48" edition = "2021" rust-version = "1.95" authors = ["vyrti"] @@ -168,7 +168,7 @@ sysinfo = { version = "0.39", default-features = false, features = ["system", "d socket2 = { version = "0.6", features = ["all"] } mdns-sd = { version = "0.21", default-features = false, features = ["async"] } vuio-cast = { path = "../vuio-cast", version = "0.0.4", default-features = false, optional = true } -vuio-web = { path = "../vuio-web", version = "0.0.47", optional = true } +vuio-web = { path = "../vuio-web", version = "0.0.48", optional = true } jwalk = "0.9" tokio-stream = "0.1" hap-crypto = { version = "1.4", optional = true } diff --git a/crates/vuio-core/src/media/remux/mkv_demuxer.rs b/crates/vuio-core/src/media/remux/mkv_demuxer.rs index 2a1be1dc..965bca62 100644 --- a/crates/vuio-core/src/media/remux/mkv_demuxer.rs +++ b/crates/vuio-core/src/media/remux/mkv_demuxer.rs @@ -145,6 +145,379 @@ pub struct FileInfo { pub duration_secs: Option, } +/// A byte-level patch presented only to the browser MKV demuxer. +/// +/// The file on disk is never changed. The patch is overlaid while Symphonia is +/// reading this one stream. +#[cfg(feature = "casting")] +#[derive(Debug)] +struct BrowserMkvPatch { + offset: u64, + bytes: Vec, +} + +/// A seekable view of an MKV that hides its `Chapters` element from Symphonia. +/// +/// Chapters are unrelated to HLS remuxing, but Symphonia rejects an otherwise +/// playable MKV when an `EditionEntry` omits `EditionUID`. ffmpeg, browsers and +/// televisions all tolerate that common muxer output. Replacing only the +/// top-level `Chapters` header with an equal-length EBML `Void` header lets the +/// browser reader ignore that optional metadata without shifting a single file +/// offset. The ordinary [`MkvDemuxer::inspect`] path still reads the original +/// bytes, so television remuxing/transcoding is not routed through this view. +#[cfg(feature = "casting")] +struct BrowserMkvSource { + file: std::fs::File, + len: u64, + pos: u64, + patches: Vec, +} + +#[cfg(feature = "casting")] +impl BrowserMkvSource { + fn open(path: &Path) -> Result { + use std::io::{Seek, SeekFrom}; + + let mut file = std::fs::File::open(path) + .with_context(|| format!("Failed to open MKV file: {}", path.display()))?; + let len = file.metadata()?.len(); + let patches = browser_chapter_patches(&mut file, len)?; + file.seek(SeekFrom::Start(0))?; + Ok(Self { + file, + len, + pos: 0, + patches, + }) + } +} + +#[cfg(feature = "casting")] +impl std::io::Read for BrowserMkvSource { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let read = std::io::Read::read(&mut self.file, buf)?; + let start = self.pos; + let end = start.saturating_add(read as u64); + + for patch in &self.patches { + let patch_end = patch.offset.saturating_add(patch.bytes.len() as u64); + let overlap_start = start.max(patch.offset); + let overlap_end = end.min(patch_end); + if overlap_start < overlap_end { + let dst = (overlap_start - start) as usize; + let src = (overlap_start - patch.offset) as usize; + let len = (overlap_end - overlap_start) as usize; + buf[dst..dst + len].copy_from_slice(&patch.bytes[src..src + len]); + } + } + + self.pos = end; + Ok(read) + } +} + +#[cfg(feature = "casting")] +impl std::io::Seek for BrowserMkvSource { + fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result { + self.pos = std::io::Seek::seek(&mut self.file, pos)?; + Ok(self.pos) + } +} + +#[cfg(feature = "casting")] +impl symphonia::core::io::MediaSource for BrowserMkvSource { + fn is_seekable(&self) -> bool { + true + } + + fn byte_len(&self) -> Option { + Some(self.len) + } +} + +#[cfg(feature = "casting")] +#[derive(Debug)] +struct EbmlSpan { + header_start: u64, + body_start: u64, + end: u64, +} + +#[cfg(feature = "casting")] +const SEGMENT_ID: u32 = 0x1853_8067; +#[cfg(feature = "casting")] +const SEEK_HEAD_ID: u32 = 0x114D_9B74; +#[cfg(feature = "casting")] +const SEEK_ID: u32 = 0x4DBB; +#[cfg(feature = "casting")] +const SEEK_TARGET_ID: u32 = 0x53AB; +#[cfg(feature = "casting")] +const SEEK_POSITION_ID: u32 = 0x53AC; +#[cfg(feature = "casting")] +const CHAPTERS_ID: u32 = 0x1043_A770; +#[cfg(feature = "casting")] +const CLUSTER_ID: u32 = 0x1F43_B675; + +/// Locate the chapter headers Symphonia will visit while constructing its MKV +/// reader. The first cluster ends its linear header scan; chapter positions in +/// `SeekHead` are followed separately, so those are included too. +#[cfg(feature = "casting")] +fn browser_chapter_patches( + file: &mut std::fs::File, + file_len: u64, +) -> Result> { + use std::io::{Seek, SeekFrom}; + + file.seek(SeekFrom::Start(0))?; + let segment = find_ebml_segment(file, file_len)?; + file.seek(SeekFrom::Start(segment.body_start))?; + + let mut patches = Vec::new(); + for _ in 0..100_000 { + let Some((id, span)) = read_ebml_header(file, segment.end)? else { + break; + }; + let resume = span.end; + match id { + CHAPTERS_ID => add_chapter_patch(&mut patches, &span)?, + SEEK_HEAD_ID => { + for offset in chapter_offsets_from_seek_head(file, &span, segment.body_start)? { + file.seek(SeekFrom::Start(offset))?; + if let Some((CHAPTERS_ID, chapter)) = read_ebml_header(file, file_len)? { + add_chapter_patch(&mut patches, &chapter)?; + } + } + } + CLUSTER_ID => break, + _ => {} + } + file.seek(SeekFrom::Start(resume))?; + } + + patches.sort_by_key(|patch| patch.offset); + patches.dedup_by_key(|patch| patch.offset); + Ok(patches) +} + +#[cfg(feature = "casting")] +fn find_ebml_segment(file: &mut std::fs::File, file_len: u64) -> Result { + use std::io::{Seek, SeekFrom}; + + file.seek(SeekFrom::Start(0))?; + for _ in 0..16 { + let Some((id, span)) = read_ebml_header(file, file_len)? else { + break; + }; + if id == SEGMENT_ID { + return Ok(span); + } + // Ordinarily this is the EBML header. Being tolerant of other leading + // elements costs nothing and keeps this compatibility reader narrow. + file.seek(SeekFrom::Start(span.end))?; + } + anyhow::bail!("no Matroska Segment element") +} + +/// Read one EBML variable-length integer. IDs retain their length marker; +/// sizes do not. +#[cfg(feature = "casting")] +fn read_ebml_vint( + file: &mut std::fs::File, + keep_marker: bool, +) -> Result> { + use std::io::Read; + + let mut first = [0u8; 1]; + match file.read_exact(&mut first) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None), + Err(err) => return Err(err.into()), + } + let lead = first[0]; + if lead == 0 { + return Ok(None); + } + let width = lead.leading_zeros() + 1; + if width > 8 || (keep_marker && width > 4) { + return Ok(None); + } + let value_bits = if width == 8 { 0 } else { 0xFFu8 >> width }; + let mut value = if keep_marker { + u64::from(lead) + } else { + u64::from(lead & value_bits) + }; + for _ in 1..width { + let mut next = [0u8; 1]; + file.read_exact(&mut next)?; + value = (value << 8) | u64::from(next[0]); + } + Ok(Some((value, width))) +} + +#[cfg(feature = "casting")] +fn read_ebml_header( + file: &mut std::fs::File, + limit: u64, +) -> Result> { + use std::io::Seek; + + let header_start = file.stream_position()?; + if header_start >= limit { + return Ok(None); + } + let Some((id, _)) = read_ebml_vint(file, true)? else { + return Ok(None); + }; + let Some((size, width)) = read_ebml_vint(file, false)? else { + return Ok(None); + }; + let body_start = file.stream_position()?; + let unknown = size == (1u64 << (7 * width)) - 1; + let end = if unknown { + limit + } else { + body_start.saturating_add(size).min(limit) + }; + Ok(Some(( + u32::try_from(id).unwrap_or(0), + EbmlSpan { + header_start, + body_start, + end, + }, + ))) +} + +#[cfg(feature = "casting")] +fn add_chapter_patch(patches: &mut Vec, span: &EbmlSpan) -> Result<()> { + let original_header_len = span.body_start.saturating_sub(span.header_start) as usize; + let body_len = span.end.saturating_sub(span.body_start); + let size_width = original_header_len.saturating_sub(1).min(8); + if size_width == 0 { + anyhow::bail!("invalid Matroska Chapters header") + } + let absorbed = original_header_len.saturating_sub(1 + size_width) as u64; + let size = encode_ebml_size(body_len.saturating_add(absorbed), size_width) + .ok_or_else(|| anyhow::anyhow!("Matroska Chapters element is too large to void"))?; + let mut bytes = Vec::with_capacity(1 + size.len()); + bytes.push(0xEC); // EBML Void + bytes.extend_from_slice(&size); + patches.push(BrowserMkvPatch { + offset: span.header_start, + bytes, + }); + Ok(()) +} + +#[cfg(feature = "casting")] +fn encode_ebml_size(value: u64, width: usize) -> Option> { + if !(1..=8).contains(&width) { + return None; + } + let limit = 1u64.checked_shl((7 * width) as u32)?; + // The all-ones value is reserved for an unknown-length element. + if value >= limit - 1 { + return None; + } + let encoded = value | (1u64 << (7 * width)); + Some(encoded.to_be_bytes()[8 - width..].to_vec()) +} + +#[cfg(feature = "casting")] +fn chapter_offsets_from_seek_head( + file: &mut std::fs::File, + span: &EbmlSpan, + segment_start: u64, +) -> Result> { + use std::io::{Read, Seek, SeekFrom}; + + const MAX_SEEK_HEAD_BYTES: u64 = 1024 * 1024; + let len = span.end.saturating_sub(span.body_start); + if len > MAX_SEEK_HEAD_BYTES { + return Ok(Vec::new()); + } + let mut bytes = vec![0u8; len as usize]; + file.seek(SeekFrom::Start(span.body_start))?; + file.read_exact(&mut bytes)?; + + let mut offsets = Vec::new(); + let mut entries = EbmlCursor::new(&bytes); + while let Some((id, body)) = entries.read_element() { + if id != SEEK_ID { + continue; + } + let mut target = None; + let mut position = None; + let mut fields = EbmlCursor::new(body); + while let Some((field, value)) = fields.read_element() { + match field { + SEEK_TARGET_ID => target = u32::try_from(ebml_uint(value)).ok(), + SEEK_POSITION_ID => position = Some(ebml_uint(value)), + _ => {} + } + } + if target == Some(CHAPTERS_ID) { + if let Some(position) = position { + offsets.push(segment_start.saturating_add(position)); + } + } + } + Ok(offsets) +} + +#[cfg(feature = "casting")] +fn ebml_uint(bytes: &[u8]) -> u64 { + bytes + .iter() + .take(8) + .fold(0u64, |value, byte| (value << 8) | u64::from(*byte)) +} + +#[cfg(feature = "casting")] +struct EbmlCursor<'a> { + bytes: &'a [u8], + at: usize, +} + +#[cfg(feature = "casting")] +impl<'a> EbmlCursor<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { bytes, at: 0 } + } + + fn read_vint(&mut self, keep_marker: bool) -> Option { + let lead = *self.bytes.get(self.at)?; + if lead == 0 { + return None; + } + let width = lead.leading_zeros() as usize + 1; + if width > 8 || (keep_marker && width > 4) { + return None; + } + let value_bits = if width == 8 { 0 } else { 0xFFu8 >> width }; + let mut value = if keep_marker { + u64::from(lead) + } else { + u64::from(lead & value_bits) + }; + for offset in 1..width { + value = (value << 8) | u64::from(*self.bytes.get(self.at + offset)?); + } + self.at += width; + Some(value) + } + + fn read_element(&mut self) -> Option<(u32, &'a [u8])> { + let id = u32::try_from(self.read_vint(true)?).ok()?; + let size = usize::try_from(self.read_vint(false)?).ok()?; + let end = self.at.checked_add(size)?; + let body = self.bytes.get(self.at..end)?; + self.at = end; + Some((id, body)) + } +} + /// The first video track this remuxer can pass through into browser-playable fMP4. pub fn browser_video_track(tracks: &[TrackInfo]) -> Option<&TrackInfo> { tracks.iter().find(|t| { @@ -180,6 +553,32 @@ impl MkvDemuxer { /// info such as duration. #[cfg(feature = "casting")] pub fn inspect(path: &Path) -> Result { + use symphonia::core::io::MediaSourceStream; + + let file = std::fs::File::open(path) + .with_context(|| format!("Failed to open MKV file: {}", path.display()))?; + let stream = MediaSourceStream::new(Box::new(file), Default::default()); + Self::inspect_stream(path, stream) + } + + /// Inspect an MKV for browser HLS playback while ignoring chapter metadata. + /// + /// This is deliberately separate from [`Self::inspect`]: televisions keep + /// using the ordinary, unmodified source view. + #[cfg(feature = "casting")] + pub fn inspect_for_browser(path: &Path) -> Result { + use symphonia::core::io::MediaSourceStream; + + let source = BrowserMkvSource::open(path)?; + let stream = MediaSourceStream::new(Box::new(source), Default::default()); + Self::inspect_stream(path, stream) + } + + #[cfg(feature = "casting")] + fn inspect_stream( + path: &Path, + stream: symphonia::core::io::MediaSourceStream<'_>, + ) -> Result { use symphonia::core::codecs::audio::well_known::{ CODEC_ID_AAC, CODEC_ID_AC3, CODEC_ID_DCA, CODEC_ID_EAC3, CODEC_ID_TRUEHD, }; @@ -189,14 +588,9 @@ impl MkvDemuxer { use symphonia::core::codecs::video::well_known::{CODEC_ID_H264, CODEC_ID_HEVC}; use symphonia::core::formats::probe::Hint; use symphonia::core::formats::{FormatOptions, TrackFlags}; - use symphonia::core::io::MediaSourceStream; use symphonia::core::meta::MetadataOptions; use symphonia::core::units::Timestamp; - let file = std::fs::File::open(path) - .with_context(|| format!("Failed to open MKV file: {}", path.display()))?; - let stream = MediaSourceStream::new(Box::new(file), Default::default()); - let mut hint = Hint::new(); if let Some(ext) = path.extension().and_then(|e| e.to_str()) { hint.with_extension(ext); @@ -314,6 +708,11 @@ impl MkvDemuxer { Err(anyhow::anyhow!("Casting/Symphonia feature not enabled")) } + #[cfg(not(feature = "casting"))] + pub fn inspect_for_browser(_path: &Path) -> Result { + Err(anyhow::anyhow!("Casting/Symphonia feature not enabled")) + } + /// Legacy convenience wrapper — returns just the track list. pub fn inspect_tracks(path: &Path) -> Result> { Self::inspect(path).map(|fi| fi.tracks) @@ -340,6 +739,58 @@ impl MkvDemuxer { output_timescale: u32, start_secs: f64, target_duration_secs: f64, + ) -> Result> { + use symphonia::core::io::MediaSourceStream; + + let file = std::fs::File::open(path)?; + let stream = MediaSourceStream::new(Box::new(file), Default::default()); + Self::extract_track_packets_from_stream( + path, + stream, + target_track_id, + codec, + output_timescale, + start_secs, + target_duration_secs, + ) + } + + /// Browser-only packet extraction that ignores Matroska chapter metadata. + /// The television path continues to call [`Self::extract_track_packets`]. + #[cfg(feature = "casting")] + pub fn extract_track_packets_for_browser( + path: &Path, + target_track_id: u32, + codec: TrackCodec, + output_timescale: u32, + start_secs: f64, + target_duration_secs: f64, + ) -> Result> { + use symphonia::core::io::MediaSourceStream; + + let source = BrowserMkvSource::open(path)?; + let stream = MediaSourceStream::new(Box::new(source), Default::default()); + Self::extract_track_packets_from_stream( + path, + stream, + target_track_id, + codec, + output_timescale, + start_secs, + target_duration_secs, + ) + } + + #[cfg(feature = "casting")] + #[allow(clippy::too_many_arguments)] + fn extract_track_packets_from_stream( + path: &Path, + stream: symphonia::core::io::MediaSourceStream<'_>, + target_track_id: u32, + codec: TrackCodec, + output_timescale: u32, + start_secs: f64, + target_duration_secs: f64, ) -> Result> { // A safety valve, not a tuning knob: guards against runaway loops if a track's // packets never accumulate to `target_duration_secs` (e.g. a corrupt duration). @@ -350,13 +801,9 @@ impl MkvDemuxer { const SEEK_BACKOFF_SECS: f64 = 0.001; use symphonia::core::formats::probe::Hint; use symphonia::core::formats::{FormatOptions, SeekMode, SeekTo}; - use symphonia::core::io::MediaSourceStream; use symphonia::core::meta::MetadataOptions; use symphonia::core::units::Time; - let file = std::fs::File::open(path)?; - let stream = MediaSourceStream::new(Box::new(file), Default::default()); - let mut hint = Hint::new(); if let Some(ext) = path.extension().and_then(|e| e.to_str()) { hint.with_extension(ext); @@ -507,6 +954,18 @@ impl MkvDemuxer { ) -> Result> { Err(anyhow::anyhow!("Casting/Symphonia feature not enabled")) } + + #[cfg(not(feature = "casting"))] + pub fn extract_track_packets_for_browser( + _path: &Path, + _target_track_id: u32, + _codec: TrackCodec, + _output_timescale: u32, + _start_secs: f64, + _target_duration_secs: f64, + ) -> Result> { + Err(anyhow::anyhow!("Casting/Symphonia feature not enabled")) + } } /// Fill in each packet's decode timestamp for a run of packets that are in decode order diff --git a/crates/vuio-core/src/web/remux_streaming.rs b/crates/vuio-core/src/web/remux_streaming.rs index 71de8f6f..c9c7d592 100644 --- a/crates/vuio-core/src/web/remux_streaming.rs +++ b/crates/vuio-core/src/web/remux_streaming.rs @@ -145,9 +145,15 @@ async fn load_file_info( })? .ok_or(AppError::NotFound)?; - let info = MkvDemuxer::inspect(&file_info.path).unwrap_or_else(|_| FileInfo { - tracks: Vec::new(), - duration_secs: None, + let info = MkvDemuxer::inspect_for_browser(&file_info.path).unwrap_or_else(|err| { + error!( + "Failed to inspect MKV file {} for browser playback: {err:#}", + file_info.path.display() + ); + FileInfo { + tracks: Vec::new(), + duration_secs: None, + } }); Ok((file_info.path, info)) @@ -313,7 +319,7 @@ fn build_segment_bytes( ); } - let mut packets = MkvDemuxer::extract_track_packets( + let mut packets = MkvDemuxer::extract_track_packets_for_browser( path, track.id, track.codec_kind, @@ -391,7 +397,7 @@ fn build_reencoded_segment( let request_from = (from - LONGEST_SOURCE_FRAME).max(0); let request_len = (from + len as i64 - request_from).max(0); - let packets = MkvDemuxer::extract_track_packets( + let packets = MkvDemuxer::extract_track_packets_for_browser( path, track.id, track.codec_kind, @@ -578,4 +584,47 @@ mod tests { vec![0.0, 4.0, 8.0, 12.0, 16.0, 20.0, 24.0, 28.0, 32.0, 36.0, 40.0, 44.0, 48.0, 52.0, 60.0] ); } + + #[test] + #[ignore = "diagnostic helper for a locally generated Matroska fixture"] + fn write_local_hls_fixture_segments() { + let path = std::path::Path::new("/tmp/mkv-playback-fixture.mkv"); + let info = MkvDemuxer::inspect_for_browser(path).expect("inspect local MKV fixture"); + let track = browser_video_track(&info.tracks) + .expect("a browser-compatible video track") + .clone(); + let layout = segmentation(path, &info); + + std::fs::write( + "/tmp/mkv-playback-playlist.m3u8", + HlsGenerator::build_media_playlist(&layout.boundaries), + ) + .expect("write playlist"); + + let mut init = Fmp4Writer::build_ftyp(); + init.extend_from_slice(&Fmp4Writer::build_moov(&track)); + std::fs::write("/tmp/mkv-playback-init.mp4", init).expect("write init segment"); + + for seq in 0..layout.boundaries.len().saturating_sub(1) as u32 { + let (start, end) = layout.range(seq).expect("segment range"); + let packets = MkvDemuxer::extract_track_packets_for_browser( + path, + track.id, + track.codec_kind, + Fmp4Writer::timescale_for(&track), + start, + end - start, + ) + .expect("extract packets"); + eprintln!( + "segment {seq}: {start:.3}..{end:.3}, {} packets", + packets.len() + ); + std::fs::write( + format!("/tmp/mkv-playback-segment-{seq}.m4s"), + build_segment_bytes(path, &track, seq, start, end), + ) + .expect("write media segment"); + } + } } diff --git a/crates/vuio-core/tests/common/mod.rs b/crates/vuio-core/tests/common/mod.rs index d7aa3027..b6c436f1 100644 --- a/crates/vuio-core/tests/common/mod.rs +++ b/crates/vuio-core/tests/common/mod.rs @@ -72,9 +72,22 @@ pub enum TrackKind { /// top-level elements at the first cluster, so a `Cues` element it was not told /// about in advance is one it never reads. pub fn build_mkv(tracks: &[Track], duration_ms: f64) -> Vec { + build_mkv_inner(tracks, duration_ms, false) +} + +/// The same film with an `EditionEntry` that omits its nominally mandatory +/// `EditionUID`, matching Matroska emitted by ffmpeg in the wild. Players ignore +/// the incomplete chapter metadata; this fixture makes sure browser remuxing +/// does too. +pub fn build_mkv_with_invalid_chapters(tracks: &[Track], duration_ms: f64) -> Vec { + build_mkv_inner(tracks, duration_ms, true) +} + +fn build_mkv_inner(tracks: &[Track], duration_ms: f64, invalid_chapters: bool) -> Vec { const ID_SEEK_HEAD: u32 = 0x114D9B74; const ID_INFO: u32 = 0x1549A966; const ID_TRACKS: u32 = 0x1654AE6B; + const ID_CHAPTERS: u32 = 0x1043A770; const ID_CLUSTER: u32 = 0x1F43B675; const ID_CUES: u32 = 0x1C53BB6B; @@ -128,6 +141,16 @@ pub fn build_mkv(tracks: &[Track], duration_ms: f64) -> Vec { } let tracks_el = master(ID_TRACKS, &entries); + // ffmpeg has emitted EditionEntry without EditionUID. Symphonia treats + // that optional metadata defect as fatal for the entire container, while + // players simply ignore the chapters. + let chapters = if invalid_chapters { + let edition = master(0x45B9, &uint_el(0x45DB, 1)); // EditionFlagDefault only. + master(ID_CHAPTERS, &edition) + } else { + Vec::new() + }; + // --- Clusters, one per second of content --- // Every sample from every track, in presentation order, so the interleaving // is what a real muxer would produce and a demuxer walking forward sees both @@ -143,8 +166,14 @@ pub fn build_mkv(tracks: &[Track], duration_ms: f64) -> Vec { let mut clusters = Vec::new(); // (cluster timestamp in ms, byte offset from the start of the segment's data) let mut cue_points: Vec<(u64, u64)> = Vec::new(); - let seek_head_len = seek_head_placeholder_len(); - let mut cursor = seek_head_len + info.len() as u64 + tracks_el.len() as u64; + let seek_entries: Vec<(u32, u64)> = if invalid_chapters { + vec![(ID_INFO, 0), (ID_TRACKS, 0), (ID_CHAPTERS, 0), (ID_CUES, 0)] + } else { + vec![(ID_INFO, 0), (ID_TRACKS, 0), (ID_CUES, 0)] + }; + let seek_head_len = seek_head(&seek_entries).len() as u64; + let mut cursor = + seek_head_len + info.len() as u64 + tracks_el.len() as u64 + chapters.len() as u64; let mut index = 0usize; while index < all.len() { @@ -188,11 +217,18 @@ pub fn build_mkv(tracks: &[Track], duration_ms: f64) -> Vec { let cues = master(ID_CUES, &cues_body); // --- SeekHead, now that the positions it points at are known --- - let seek_head = seek_head(&[ + let mut seek_entries = vec![ (ID_INFO, seek_head_len), (ID_TRACKS, seek_head_len + info.len() as u64), - (ID_CUES, cues_pos), - ]); + ]; + if invalid_chapters { + seek_entries.push(( + ID_CHAPTERS, + seek_head_len + info.len() as u64 + tracks_el.len() as u64, + )); + } + seek_entries.push((ID_CUES, cues_pos)); + let seek_head = seek_head(&seek_entries); assert_eq!( seek_head.len() as u64, seek_head_len, @@ -204,6 +240,7 @@ pub fn build_mkv(tracks: &[Track], duration_ms: f64) -> Vec { segment_body.extend(seek_head); segment_body.extend(info); segment_body.extend(tracks_el); + segment_body.extend(chapters); segment_body.extend(clusters); segment_body.extend(cues); @@ -242,10 +279,6 @@ fn seek_head(entries: &[(u32, u64)]) -> Vec { master(0x114D9B74, &body) } -fn seek_head_placeholder_len() -> u64 { - seek_head(&[(0x1549A966, 0), (0x1654AE6B, 0), (0x1C53BB6B, 0)]).len() as u64 -} - /// The bytes of an EBML element ID, written verbatim as the class ID they are. fn id_bytes(id: u32) -> Vec { let bytes = id.to_be_bytes(); diff --git a/crates/vuio-core/tests/film_transcode_tests.rs b/crates/vuio-core/tests/film_transcode_tests.rs index 6791b43c..ce48102f 100644 --- a/crates/vuio-core/tests/film_transcode_tests.rs +++ b/crates/vuio-core/tests/film_transcode_tests.rs @@ -9,7 +9,7 @@ mod common; -use common::{build_mkv, video_sample, Track, TrackKind, AVCC}; +use common::{build_mkv, build_mkv_with_invalid_chapters, video_sample, Track, TrackKind, AVCC}; use std::sync::Arc; use tower::ServiceExt; use vuio_core::database::MediaRepository; @@ -25,6 +25,10 @@ const AC3_FRAME_MS: f64 = AC3_FRAME_SAMPLES as f64 / 48.0; /// Build a film: `AC3` looped to fill `seconds`, beside a 25 fps video track /// with a keyframe every second. pub fn film(seconds: f64) -> Vec { + film_container(seconds, false) +} + +fn film_container(seconds: f64, invalid_chapters: bool) -> Vec { let frames: Vec<&[u8]> = AC3 .as_chunks::() .0 @@ -52,37 +56,39 @@ pub fn film(seconds: f64) -> Vec { }) .collect(); - build_mkv( - &[ - Track { - number: 1, - codec_id: "V_MPEG4/ISO/AVC", - codec_private: AVCC.to_vec(), - kind: TrackKind::Video { - width: 640, - height: 360, - }, - samples: video_samples, - all_keyframes: false, - is_default: true, - language: None, + let tracks = [ + Track { + number: 1, + codec_id: "V_MPEG4/ISO/AVC", + codec_private: AVCC.to_vec(), + kind: TrackKind::Video { + width: 640, + height: 360, }, - Track { - number: 2, - codec_id: "A_AC3", - codec_private: Vec::new(), - kind: TrackKind::Audio { - sample_rate: 48_000.0, - channels: 2, - }, - samples: audio_samples, - all_keyframes: true, - is_default: true, - language: Some("eng"), + samples: video_samples, + all_keyframes: false, + is_default: true, + language: None, + }, + Track { + number: 2, + codec_id: "A_AC3", + codec_private: Vec::new(), + kind: TrackKind::Audio { + sample_rate: 48_000.0, + channels: 2, }, - ], - seconds * 1000.0, - ) + samples: audio_samples, + all_keyframes: true, + is_default: true, + language: Some("eng"), + }, + ]; + if invalid_chapters { + build_mkv_with_invalid_chapters(&tracks, seconds * 1000.0) + } else { + build_mkv(&tracks, seconds * 1000.0) + } } #[test] @@ -125,6 +131,29 @@ fn the_fixture_really_is_a_matroska_file_with_an_ac3_track() { assert_eq!(audio.sample_rate, Some(48_000)); } +#[test] +fn the_browser_ignores_invalid_chapter_metadata_but_the_tv_reader_is_unchanged() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("Film-with-invalid-chapters.mkv"); + std::fs::write(&path, film_container(6.0, true)).unwrap(); + + let ordinary_error = vuio_core::media::remux::MkvDemuxer::inspect(&path) + .expect_err("the ordinary Symphonia reader remains strict"); + assert!(format!("{ordinary_error:#}").contains("missing edition uid")); + + use vuio_core::media::remux::{TrackCodec, TrackKind as K}; + let info = vuio_core::media::remux::MkvDemuxer::inspect_for_browser(&path) + .expect("the browser reader ignores chapters it does not use"); + assert!(info + .tracks + .iter() + .any(|track| track.track_kind == K::Video && track.codec_kind == TrackCodec::Avc)); + assert!(info + .tracks + .iter() + .any(|track| track.track_kind == K::Audio && track.codec_kind == TrackCodec::Ac3)); +} + // ── Step 1: what the scanner records ────────────────────────────────────── use axum::{ @@ -411,6 +440,63 @@ async fn the_master_playlist_offers_the_films_ac3_track_as_a_rendition() { ); } +/// DTS follows the same browser path as Dolby: decode the Matroska track and +/// expose AAC-LC to HLS/MSE. Keep this separate from the AC-3 fixture so a DTS +/// decoder regression cannot hide behind the shared playlist assertions. +#[cfg(feature = "transcode-dts")] +#[tokio::test] +async fn the_browser_hls_path_decodes_dts_to_aac() { + let (_temp, state, id) = scanned_dts_film(8.0).await; + let (status, _, body) = get( + &state, + &format!("/media/{id}/hls/master.m3u8"), + Method::GET, + None, + ) + .await; + let playlist = String::from_utf8(body).expect("the master playlist is text"); + assert_eq!(status, StatusCode::OK); + assert!(playlist.contains("audio/0/index.m3u8"), "{playlist}"); + assert!(playlist.contains("mp4a.40.2"), "{playlist}"); + + let (status, _, init) = get( + &state, + &format!("/media/{id}/hls/audio/0/init.mp4"), + Method::GET, + None, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert!( + find_box(&init, "mp4a").is_some(), + "DTS must be restated as AAC" + ); + + let (status, _, segment) = get( + &state, + &format!("/media/{id}/hls/audio/0/segment/0"), + Method::GET, + None, + ) + .await; + assert_eq!(status, StatusCode::OK); + let trun = find_box(&segment, "trun").expect("an AAC sample run"); + let samples = u32::from_be_bytes(trun[4..8].try_into().unwrap()); + assert!( + samples > 150, + "four seconds of DTS decoded to only {samples} AAC frames" + ); + let mdat = boxes(&segment) + .into_iter() + .find(|(name, _)| name == "mdat") + .expect("an mdat") + .1; + assert!( + !mdat.is_empty(), + "the decoded DTS rendition is silent/empty" + ); +} + #[tokio::test] async fn the_audio_init_segment_describes_aac_not_the_source_codec() { let (_temp, state, id) = scanned_film(8.0).await; diff --git a/crates/vuio-web/Cargo.toml b/crates/vuio-web/Cargo.toml index f7ff45cf..9a9fdfd7 100644 --- a/crates/vuio-web/Cargo.toml +++ b/crates/vuio-web/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vuio-web" -version = "0.0.47" +version = "0.0.48" edition = "2021" rust-version = "1.95" authors = ["vyrti"] diff --git a/docs/api.md b/docs/api.md index c7acabde..77796503 100644 --- a/docs/api.md +++ b/docs/api.md @@ -98,7 +98,7 @@ configuration file actually writes. Backs the dashboard's Admin tab. "read_only_reason": null, "auth_enabled": false, "is_docker": false, - "version": "0.0.47", + "version": "0.0.48", // Where the server is actually accepting, which is what every advertised URL uses. "bound_addr": "0.0.0.0:8080", "desired_addr": null, diff --git a/docs/install.md b/docs/install.md index d19461e4..30cebdd0 100644 --- a/docs/install.md +++ b/docs/install.md @@ -242,7 +242,7 @@ Deploy VuIO to a Kubernetes cluster using the official Helm chart from GHCR. For ```bash # Install directly from GitHub Container Registry -helm install vuio oci://ghcr.io/vuiodev/charts/vuio --version 0.0.47 +helm install vuio oci://ghcr.io/vuiodev/charts/vuio --version 0.0.48 ``` Or install from local source: diff --git a/docs/kubernetes.md b/docs/kubernetes.md index a1e4119f..d5da293c 100644 --- a/docs/kubernetes.md +++ b/docs/kubernetes.md @@ -11,7 +11,7 @@ VuIO provides an official Helm 3 chart to deploy the media server directly onto Install the chart directly from GitHub Container Registry without cloning the repository: ```bash -helm install vuio oci://ghcr.io/vuiodev/charts/vuio --version 0.0.47 +helm install vuio oci://ghcr.io/vuiodev/charts/vuio --version 0.0.48 ``` ### Local Installation diff --git a/packaging/docker/builddocker.sh b/packaging/docker/builddocker.sh index 6480cb98..71815c27 100755 --- a/packaging/docker/builddocker.sh +++ b/packaging/docker/builddocker.sh @@ -1,6 +1,6 @@ export GITHUB_ORG="vuiodev" export IMAGE_NAME="vuio" -export VERSION_TAG="v0.0.47" +export VERSION_TAG="v0.0.48" docker login ghcr.io diff --git a/packaging/linux/generate-repo.sh b/packaging/linux/generate-repo.sh index 8e742347..aafe0cf8 100755 --- a/packaging/linux/generate-repo.sh +++ b/packaging/linux/generate-repo.sh @@ -250,7 +250,7 @@ for arch in x86_64 aarch64; do zstd -d "$pkg" -o pkgtemp/pkg.tar --quiet 2>/dev/null && \ tar -xf pkgtemp/pkg.tar -C pkgtemp .PKGINFO 2>/dev/null || true pname="vuio" - pver="0.0.47-1" + pver="0.0.48-1" pdesc="Cross-platform DLNA media server" purl="https://github.com/vuiodev/vuio" psize="15000000"