diff --git a/src-tauri/crates/psysonic-audio/src/decode.rs b/src-tauri/crates/psysonic-audio/src/decode.rs index c0c9950b..fe4165fd 100644 --- a/src-tauri/crates/psysonic-audio/src/decode.rs +++ b/src-tauri/crates/psysonic-audio/src/decode.rs @@ -169,8 +169,14 @@ impl SizedDecoder { // Symphonia 0.6 scans trailing metadata on seekable sources — hide // seekability during probe (same as `new_streaming`) so preview does not // read the entire in-memory file before the first sample. - let probe_seek_gate = (!crate::stream::container_hint_is_mp4(format_hint)) - .then(|| Arc::new(AtomicBool::new(false))); + // + // Exception: Ogg (Vorbis/Opus/…) must stay seekable through the probe, + // otherwise its demuxer never records `phys_byte_range_end` and the first + // seek panics (see `container_hint_is_ogg`). This source is fully + // in-memory, so the trailing-metadata scan it re-enables is free. + let gate_needed = !crate::stream::container_hint_is_mp4(format_hint) + && !crate::stream::container_hint_is_ogg(format_hint); + let probe_seek_gate = gate_needed.then(|| Arc::new(AtomicBool::new(false))); let media: Box = match &probe_seek_gate { Some(gate) => Box::new(ProbeSeekGate { inner: Box::new(source), @@ -315,19 +321,33 @@ impl SizedDecoder { /// Build a decoder from any `MediaSource` (e.g. track-stream or radio). /// Uses `enable_gapless: false` — live streams are not seekable; gapless /// trimming requires seeking to read the LAME/iTunSMPB end-padding info. + /// `source_random_access`: the underlying source can cheaply seek to EOF + /// (e.g. a local file), so the probe-time trailing-metadata / stream-end scan + /// is not a full download. Progressive sources (ranged HTTP) pass `false`. pub(crate) fn new_streaming( media: Box, format_hint: Option<&str>, source_tag: &str, + source_random_access: bool, ) -> Result { // For non-MP4 progressive streams, hide seekability during the probe so // Symphonia 0.6 skips its trailing-metadata scan (which would seek to EOF // and block until the whole file is downloaded). Re-enabled right after. // MP4 keeps seekability (its demuxer needs it to find `moov`; tail is // prefetched separately). + // + // Ogg also keeps seekability through the probe, but only on random-access + // sources: its demuxer records `phys_byte_range_end` during the probe and + // panics on the first seek otherwise (see `container_hint_is_ogg`). On a + // local file the stream-end scan is cheap; on a progressive ranged stream + // it would force a full download, so there we keep the gate and accept + // that seeking is a no-op (the panic itself is contained in `try_seek`). let stream_len = media.byte_len(); - let probe_seek_gate = (!crate::stream::container_hint_is_mp4(format_hint)) - .then(|| Arc::new(AtomicBool::new(false))); + let ogg_needs_seekable_probe = + source_random_access && crate::stream::container_hint_is_ogg(format_hint); + let gate_needed = !crate::stream::container_hint_is_mp4(format_hint) + && !ogg_needs_seekable_probe; + let probe_seek_gate = gate_needed.then(|| Arc::new(AtomicBool::new(false))); let media: Box = match &probe_seek_gate { Some(gate) => Box::new(ProbeSeekGate { inner: media, seekable: gate.clone() }), None => media, @@ -588,20 +608,36 @@ impl Source for SizedDecoder { let to_skip = self.current_frame_offset % self.channels().get() as usize; - let seek_res = self - .format - .seek(SeekMode::Accurate, SeekTo::Time { time, track_id: None }) - .map_err(|e| rodio::source::SeekError::Other( - std::sync::Arc::new(std::io::Error::other(e.to_string())) - ))?; + // symphonia 0.6's OGG demuxer can `panic!` (e.g. `Option::unwrap()` on + // `None` in `OggReader::do_seek`) on some streams instead of returning + // an `Err`. `try_seek` runs on rodio's cpal output thread, so an escaping + // panic poisons the engine mutexes and then aborts the whole process at + // the non-unwinding cpal FFI boundary (the "crash on Stop" is a downstream + // symptom of that poison). Contain the unwind here — including the packet + // reads in `refine_position`, which can hit the same broken demuxer state — + // and surface it as a recoverable `SeekError` so the engine stays alive + // (the seek becomes a no-op rather than killing playback). + let seek_outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let seek_res = self + .format + .seek(SeekMode::Accurate, SeekTo::Time { time, track_id: None }) + .map_err(|e| e.to_string())?; + self.refine_position(seek_res)?; + Ok::<(), String>(()) + })); - self.refine_position(seek_res) - .map_err(|e| rodio::source::SeekError::Other( - std::sync::Arc::new(std::io::Error::other(e)) - ))?; - - self.current_frame_offset += to_skip; - Ok(()) + match seek_outcome { + Ok(Ok(())) => { + self.current_frame_offset += to_skip; + Ok(()) + } + Ok(Err(e)) => Err(rodio::source::SeekError::Other(std::sync::Arc::new( + std::io::Error::other(e), + ))), + Err(_panic) => Err(rodio::source::SeekError::Other(std::sync::Arc::new( + std::io::Error::other("seek panicked inside the demuxer (contained)"), + ))), + } } } @@ -1019,8 +1055,9 @@ mod tests { #[test] fn new_streaming_constructs_from_synthetic_wav() { let wav = synthetic_wav_bytes(0.5); - let decoder = SizedDecoder::new_streaming(seekable_source(wav), Some("wav"), "test-stream") - .expect("streaming WAV decode setup"); + let decoder = + SizedDecoder::new_streaming(seekable_source(wav), Some("wav"), "test-stream", true) + .expect("streaming WAV decode setup"); assert_eq!(decoder.spec.rate(), 44_100); assert_eq!(decoder.spec.channels().count(), 1); // Live streams report no total duration. @@ -1033,6 +1070,7 @@ mod tests { seekable_source(vec![0x00u8; 64]), None, "test-stream", + true, ); assert!(result.is_err()); } diff --git a/src-tauri/crates/psysonic-audio/src/device_resume.rs b/src-tauri/crates/psysonic-audio/src/device_resume.rs index 1dd8e7b0..c4fd7833 100644 --- a/src-tauri/crates/psysonic-audio/src/device_resume.rs +++ b/src-tauri/crates/psysonic-audio/src/device_resume.rs @@ -87,6 +87,7 @@ pub(crate) async fn try_resume_after_device_change( reader: Box::new(LocalFileSource { file, len }), format_hint: url_format_hint(url), tag: "LocalFile[device-resume]", + random_access: true, mp4_probe_gate: None, } } diff --git a/src-tauri/crates/psysonic-audio/src/play_input.rs b/src-tauri/crates/psysonic-audio/src/play_input.rs index 4ed0382d..03f7f3f5 100644 --- a/src-tauri/crates/psysonic-audio/src/play_input.rs +++ b/src-tauri/crates/psysonic-audio/src/play_input.rs @@ -40,6 +40,9 @@ pub(crate) enum PlayInput { reader: Box, format_hint: Option, tag: &'static str, + /// Source can cheaply seek to EOF (local file). Drives whether Ogg keeps + /// seekability through the probe so its seek path does not panic. + random_access: bool, /// When set, Symphonia probe waits for moov (tail or fast-start prefix). mp4_probe_gate: Option, }, @@ -201,6 +204,7 @@ fn open_local_file_input( reader: Box::new(reader), format_hint: local_hint, tag: "local-file", + random_access: true, mp4_probe_gate: None, }) } @@ -345,6 +349,7 @@ async fn open_ranged_or_streaming_input( reader: Box::new(reader), format_hint: stream_hint, tag: "ranged-stream", + random_access: false, mp4_probe_gate, })); } diff --git a/src-tauri/crates/psysonic-audio/src/preview.rs b/src-tauri/crates/psysonic-audio/src/preview.rs index 610c1ffd..aad71ab1 100644 --- a/src-tauri/crates/psysonic-audio/src/preview.rs +++ b/src-tauri/crates/psysonic-audio/src/preview.rs @@ -282,7 +282,7 @@ async fn open_preview_decoder( }; let hint = stream_hint.clone(); let decoder = tokio::task::spawn_blocking(move || { - SizedDecoder::new_streaming(Box::new(reader), hint.as_deref(), "preview-stream") + SizedDecoder::new_streaming(Box::new(reader), hint.as_deref(), "preview-stream", false) }) .await .map_err(|e| format!("preview: decoder thread: {e}"))??; diff --git a/src-tauri/crates/psysonic-audio/src/radio_commands.rs b/src-tauri/crates/psysonic-audio/src/radio_commands.rs index d84cf0fd..6fb4c3df 100644 --- a/src-tauri/crates/psysonic-audio/src/radio_commands.rs +++ b/src-tauri/crates/psysonic-audio/src/radio_commands.rs @@ -124,7 +124,7 @@ pub async fn audio_play_radio( let hint_clone = fmt_hint.clone(); let decoder = tokio::task::spawn_blocking(move || { - SizedDecoder::new_streaming(Box::new(reader), hint_clone.as_deref(), "radio") + SizedDecoder::new_streaming(Box::new(reader), hint_clone.as_deref(), "radio", false) }) .await .map_err(|e| e.to_string())??; diff --git a/src-tauri/crates/psysonic-audio/src/source_build.rs b/src-tauri/crates/psysonic-audio/src/source_build.rs index 0f6d7f0a..c5a89ed2 100644 --- a/src-tauri/crates/psysonic-audio/src/source_build.rs +++ b/src-tauri/crates/psysonic-audio/src/source_build.rs @@ -345,6 +345,7 @@ async fn build_source_from_play_input( reader, format_hint: media_hint, tag, + random_access, mp4_probe_gate, } => { if let Some(gate) = mp4_probe_gate.as_ref() { @@ -354,7 +355,7 @@ async fn build_source_from_play_input( } } let decoder = tokio::task::spawn_blocking(move || { - SizedDecoder::new_streaming(reader, media_hint.as_deref(), tag) + SizedDecoder::new_streaming(reader, media_hint.as_deref(), tag, random_access) }) .await .map_err(|e| e.to_string())??; @@ -375,7 +376,12 @@ async fn build_source_from_play_input( PlayInput::Streaming { reader, format_hint: stream_hint } => { is_seekable = false; let decoder = tokio::task::spawn_blocking(move || { - SizedDecoder::new_streaming(Box::new(reader), stream_hint.as_deref(), "track-stream") + SizedDecoder::new_streaming( + Box::new(reader), + stream_hint.as_deref(), + "track-stream", + false, + ) }) .await .map_err(|e| e.to_string())??; diff --git a/src-tauri/crates/psysonic-audio/src/stream/mod.rs b/src-tauri/crates/psysonic-audio/src/stream/mod.rs index 412aee5c..8085c29b 100644 --- a/src-tauri/crates/psysonic-audio/src/stream/mod.rs +++ b/src-tauri/crates/psysonic-audio/src/stream/mod.rs @@ -21,6 +21,23 @@ pub(crate) use mp4::{ container_hint_is_mp4, isobmff_buffer_looks_complete, log_isobmff_buffer_diagnostic, mp4_needs_tail_prefetch, mp4_suspect_zero_holes, }; + +/// True when the container hint denotes an Ogg-encapsulated stream (Vorbis, +/// Opus, Speex, FLAC-in-Ogg). +/// +/// symphonia 0.6's Ogg demuxer records the physical stream's byte range at +/// construction time, but only when the source reports `is_seekable()` *during +/// the probe*. If seekability is hidden then (see `ProbeSeekGate`), +/// `phys_byte_range_end` stays `None` and the first real seek panics with +/// `Option::unwrap()` on `None` (`demuxer.rs:180`). Sources that can cheaply +/// seek to EOF must therefore stay seekable through the probe for Ogg. +pub(crate) fn container_hint_is_ogg(hint: Option<&str>) -> bool { + let Some(h) = hint else { return false }; + matches!( + h.to_ascii_lowercase().as_str(), + "ogg" | "oga" | "ogx" | "opus" | "spx" + ) +} pub(crate) use local_file::LocalFileSource; pub(crate) use radio::{RadioLiveState, RadioSharedFlags, radio_download_task}; pub(crate) use ranged_http::{RangedHttpSource, ranged_download_task};