diff --git a/CHANGELOG.md b/CHANGELOG.md index 9aa200fa..2b69d621 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Track preview — Symphonia 0.6 format hints and fast stream start + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#1006](https://github.com/Psychotoxical/psysonic/pull/1006)** + +* Preview resolves container format from HTTP headers, Subsonic `suffix`, and magic-byte sniff so Symphonia 0.6 no longer fails with `.unknown` demuxer errors. +* Preview opens via ranged HTTP when the server supports byte ranges — audio starts after ~384 KiB buffered instead of waiting for a full-file download; buffered fallback uses the same probe seek-gate as main playback. +* Player bar cover guard while preview metadata loads; progress ring leaves the loading spinner once the engine emits `audio:preview-start`. + + + ## [1.47.0] > **🙏 Thank you to our amazing Discord community.** This release would not have been possible without your tireless support, quality checks, bug reports and all-round collaboration. Every report, every repro and every bit of feedback shaped what shipped here — thank you. Come join us: [discord.gg/AMnDRErm4u](https://discord.gg/AMnDRErm4u) diff --git a/src-tauri/crates/psysonic-audio/src/decode.rs b/src-tauri/crates/psysonic-audio/src/decode.rs index fccdadbc..c0c9950b 100644 --- a/src-tauri/crates/psysonic-audio/src/decode.rs +++ b/src-tauri/crates/psysonic-audio/src/decode.rs @@ -166,15 +166,24 @@ impl SizedDecoder { inner: Cursor::new(data), len: data_len, }; + // 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))); + let media: Box = match &probe_seek_gate { + Some(gate) => Box::new(ProbeSeekGate { + inner: Box::new(source), + seekable: gate.clone(), + }), + None => Box::new(source), + }; // Hi-Res: 4 MB read-ahead so Symphonia demuxes fewer Read calls for // high-bitrate files (88.2 kHz/24-bit FLAC ≈ 1800 kbps). // Standard: 512 KB is plenty for MP3/AAC — larger buffers waste allocation // and compete with the playback thread at track start. let buf_len = if hi_res { 4 * 1024 * 1024 } else { 512 * 1024 }; - let mss = MediaSourceStream::new( - Box::new(source) as Box, - MediaSourceStreamOptions { buffer_len: buf_len }, - ); + let mss = MediaSourceStream::new(media, MediaSourceStreamOptions { buffer_len: buf_len }); let mut hint = Hint::new(); if let Some(ext) = format_hint { @@ -200,6 +209,10 @@ impl SizedDecoder { } })?; + if let Some(gate) = &probe_seek_gate { + gate.store(true, Ordering::Relaxed); + } + let track = format .tracks() .iter() diff --git a/src-tauri/crates/psysonic-audio/src/preview.rs b/src-tauri/crates/psysonic-audio/src/preview.rs index d15ae5ac..08ce62f2 100644 --- a/src-tauri/crates/psysonic-audio/src/preview.rs +++ b/src-tauri/crates/psysonic-audio/src/preview.rs @@ -1,6 +1,6 @@ //! Short preview playback on a secondary sink (same output stream). -use std::sync::atomic::Ordering; -use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use rodio::Player; @@ -9,8 +9,17 @@ use tauri::{AppHandle, Emitter, State}; use super::decode::SizedDecoder; use super::engine::{audio_http_client, AudioEngine}; -use super::helpers::MASTER_HEADROOM; +use super::helpers::{ + content_type_to_hint, format_hint_from_content_disposition, normalize_stream_suffix_for_hint, + resolve_playback_format_hint, sniff_stream_format_extension, STREAM_FORMAT_SNIFF_PROBE_BYTES, + MASTER_HEADROOM, +}; +use super::play_input::url_format_hint; use super::sources::PriorityBoostSource; +use super::stream::{ + mp4_needs_tail_prefetch, ranged_download_task, wait_for_ranged_mp4_probe_ready, + RangedHttpSource, RangedMp4ProbeGate, +}; // ──────────────────────────────────────────────────────────────────────────── // Preview engine — secondary Sink on the same OutputStream, fed by Symphonia. @@ -90,26 +99,246 @@ pub(crate) fn preview_resume_main(state: &AudioEngine) { } } -/// Format hint inferred from a Subsonic stream URL. The frontend always passes -/// a `format=flac` query param for `.opus` files (server transcodes); for -/// everything else we guess from the URL's `format=` value or fall back to None. +/// `format=` query param on Subsonic stream URLs (transcode targets). pub(crate) fn preview_format_hint_from_url(url: &str) -> Option { url.split('?') .nth(1)? .split('&') .find_map(|kv| { let (k, v) = kv.split_once('=')?; - if k.eq_ignore_ascii_case("format") { Some(v.to_string()) } else { None } + if k.eq_ignore_ascii_case("format") { + Some(v.to_string()) + } else { + None + } }) } +/// Symphonia container hint for preview downloads — mirrors main playback: +/// Content-Type / Content-Disposition, URL tail, Subsonic suffix, magic-byte sniff. +pub(crate) fn resolve_preview_format_hint( + url: &str, + content_type: Option<&str>, + content_disposition: Option<&str>, + stream_suffix: Option<&str>, + bytes: &[u8], +) -> Option { + let media_hint = content_type + .and_then(content_type_to_hint) + .or_else(|| { + content_disposition.and_then(format_hint_from_content_disposition) + }); + let url_hint = preview_format_hint_from_url(url).or_else(|| url_format_hint(url)); + resolve_playback_format_hint( + url_hint.as_deref(), + stream_suffix, + media_hint.as_deref(), + Some(bytes), + ) +} + +fn preview_http_client(state: &AudioEngine) -> reqwest::Client { + reqwest::Client::builder() + .timeout(Duration::from_secs(300)) + .use_rustls_tls() + .user_agent(psysonic_core::user_agent::subsonic_wire_user_agent()) + .build() + .unwrap_or_else(|_| audio_http_client(state)) +} + +/// Open a preview decoder — ranged HTTP when the server supports it (starts +/// after ~384 KiB buffered), otherwise falls back to a full in-memory download. +async fn open_preview_decoder( + url: &str, + format_suffix: Option<&str>, + gen: u64, + state: &AudioEngine, + app: &AppHandle, +) -> Result, String> { + let preview_http = preview_http_client(state); + let response = preview_http + .get(url) + .send() + .await + .map_err(|e| format!("preview: connection failed: {e}"))? + .error_for_status() + .map_err(|e| format!("preview: HTTP {e}"))?; + + let mut stream_hint = content_type_to_hint( + response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + ) + .or_else(|| { + response + .headers() + .get(reqwest::header::CONTENT_DISPOSITION) + .and_then(|v| v.to_str().ok()) + .and_then(format_hint_from_content_disposition) + }) + .or_else(|| normalize_stream_suffix_for_hint(format_suffix)) + .or_else(|| preview_format_hint_from_url(url)) + .or_else(|| url_format_hint(url)); + + let supports_range = response + .headers() + .get(reqwest::header::ACCEPT_RANGES) + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.to_ascii_lowercase().contains("bytes")); + let total_size = response.content_length(); + + if stream_hint.is_none() && supports_range { + if let Some(total_u64) = total_size.filter(|&t| t > 0) { + let last = total_u64 + .saturating_sub(1) + .min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64); + if let Ok(pr) = preview_http + .get(url) + .header(reqwest::header::RANGE, format!("bytes=0-{last}")) + .send() + .await + { + let stat = pr.status(); + let ok = stat == reqwest::StatusCode::PARTIAL_CONTENT + || stat == reqwest::StatusCode::OK; + if ok { + if let Ok(bytes) = pr.bytes().await { + if !bytes.is_empty() { + stream_hint = sniff_stream_format_extension(&bytes).or(stream_hint); + } + } + } + } + } + } + + if let (true, Some(total), true) = (supports_range, total_size, stream_hint.is_some()) { + if state.preview_gen.load(Ordering::SeqCst) != gen { + return Ok(None); + } + let total_usize = total as usize; + crate::app_deprintln!( + "[preview] ranged open — total={} KB, hint={:?}", + total_usize / 1024, + stream_hint + ); + let buf = Arc::new(Mutex::new(vec![0u8; total_usize])); + let downloaded_to = Arc::new(AtomicUsize::new(0)); + let done = Arc::new(AtomicBool::new(false)); + let playback_armed = Arc::new(AtomicBool::new(false)); + let tail_ready = Arc::new(AtomicBool::new(false)); + let tail_filled_from = Arc::new(AtomicU64::new(0)); + let tail_prefetch = mp4_needs_tail_prefetch(&[], stream_hint.as_deref()); + let mp4_probe_gate = tail_prefetch.then(|| RangedMp4ProbeGate { + tail_ready: tail_ready.clone(), + buf: buf.clone(), + downloaded_to: downloaded_to.clone(), + gen_arc: state.preview_gen.clone(), + gen, + format_hint: stream_hint.clone(), + }); + tokio::spawn(ranged_download_task( + gen, + state.preview_gen.clone(), + preview_http, + app.clone(), + 0.0, + url.to_string(), + response, + buf.clone(), + downloaded_to.clone(), + done.clone(), + state.stream_completed_cache.clone(), + state.stream_completed_spill.clone(), + state.normalization_engine.clone(), + state.normalization_target_lufs.clone(), + state.loudness_pre_analysis_attenuation_db.clone(), + None, + None, + None, + playback_armed, + stream_hint.clone(), + tail_ready.clone(), + tail_filled_from.clone(), + )); + if let Some(ref gate) = mp4_probe_gate { + wait_for_ranged_mp4_probe_ready(gate).await?; + if state.preview_gen.load(Ordering::SeqCst) != gen { + return Ok(None); + } + } + let reader = RangedHttpSource { + buf, + downloaded_to, + tail_ready, + tail_filled_from, + total_size: total, + pos: 0, + done, + gen_arc: state.preview_gen.clone(), + gen, + }; + let hint = stream_hint.clone(); + let decoder = tokio::task::spawn_blocking(move || { + SizedDecoder::new_streaming(Box::new(reader), hint.as_deref(), "preview-stream") + }) + .await + .map_err(|e| format!("preview: decoder thread: {e}"))??; + return Ok(Some(decoder)); + } + + crate::app_deprintln!( + "[preview] buffered download — accept-ranges={}, content-length={:?}, hint={:?}", + supports_range, + total_size, + stream_hint + ); + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + let content_disposition = response + .headers() + .get(reqwest::header::CONTENT_DISPOSITION) + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + let bytes = response + .bytes() + .await + .map_err(|e| format!("preview: read body: {e}"))? + .to_vec(); + if state.preview_gen.load(Ordering::SeqCst) != gen { + return Ok(None); + } + let hint = resolve_preview_format_hint( + url, + content_type.as_deref(), + content_disposition.as_deref(), + format_suffix, + &bytes, + ); + let bytes_for_blocking = bytes; + let hint_for_blocking = hint.clone(); + let decoder = tokio::task::spawn_blocking(move || { + SizedDecoder::new(bytes_for_blocking, hint_for_blocking.as_deref(), false) + }) + .await + .map_err(|e| format!("preview: decoder thread: {e}"))??; + Ok(Some(decoder)) +} + #[tauri::command] +#[allow(clippy::too_many_arguments)] // Tauri IPC — args map 1:1 to the JS invoke payload. pub async fn audio_preview_play( id: String, url: String, start_sec: f64, duration_sec: f64, volume: f32, + format_suffix: Option, app: AppHandle, state: State<'_, AudioEngine>, ) -> Result<(), String> { @@ -134,48 +363,24 @@ pub async fn audio_preview_play( preview_pause_main(&state); } - // ── Download ───────────────────────────────────────────────────────────── - // Dedicated client with a generous timeout. The shared `audio_http_client` - // caps at 30 s, which aborts mid-download on multi-hundred-megabyte - // uncompressed files (e.g. 18-min Hi-Res WAV ~600 MB) — those need - // ~60–120 s on a typical home LAN. The watchdog (30 s wall-clock) still - // bounds how long the preview plays once the bytes are in memory, so a - // long download just means a longer "loading" spinner before audio starts. - let preview_http = reqwest::Client::builder() - .timeout(Duration::from_secs(300)) - .use_rustls_tls() - .user_agent(psysonic_core::user_agent::subsonic_wire_user_agent()) - .build() - .unwrap_or_else(|_| audio_http_client(&state)); - let bytes = preview_http - .get(&url) - .send() - .await - .map_err(|e| format!("preview: connection failed: {e}"))? - .error_for_status() - .map_err(|e| format!("preview: HTTP {e}"))? - .bytes() - .await - .map_err(|e| format!("preview: read body: {e}"))? - .to_vec(); + // ── Open decoder (ranged stream when possible) ─────────────────────────── + let decoder = match open_preview_decoder( + &url, + format_suffix.as_deref(), + gen, + &state, + &app, + ) + .await? + { + Some(d) => d, + None => return Ok(()), + }; if state.preview_gen.load(Ordering::SeqCst) != gen { - // A newer preview started while we were downloading — bail. return Ok(()); } - // ── Decode ─────────────────────────────────────────────────────────────── - let hint = preview_format_hint_from_url(&url); - let bytes_for_blocking = bytes; - let hint_for_blocking = hint.clone(); - let decoder = tokio::task::spawn_blocking(move || { - SizedDecoder::new(bytes_for_blocking, hint_for_blocking.as_deref(), false) - }) - .await - .map_err(|e| format!("preview: decoder thread: {e}"))??; - - if state.preview_gen.load(Ordering::SeqCst) != gen { return Ok(()); } - // ── Build source pipeline ──────────────────────────────────────────────── // Seek FIRST on the bare decoder, THEN cap with take_duration. Capping // before the seek made take_duration's wall-clock counter tick from @@ -271,6 +476,55 @@ pub async fn audio_preview_play( Ok(()) } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_preview_format_hint_sniffs_flac_from_bytes() { + let hint = resolve_preview_format_hint( + "https://host/rest/stream.view?id=1", + None, + None, + None, + b"fLaC\x00\x00\x00\x22", + ); + assert_eq!(hint.as_deref(), Some("flac")); + } + + #[test] + fn resolve_preview_format_hint_prefers_content_type_over_sniff() { + let hint = resolve_preview_format_hint( + "https://host/rest/stream.view?id=1", + Some("audio/mpeg"), + None, + None, + b"fLaC\x00\x00\x00\x22", + ); + assert_eq!(hint.as_deref(), Some("mp3")); + } + + #[test] + fn resolve_preview_format_hint_uses_subsonic_suffix() { + let hint = resolve_preview_format_hint( + "https://host/rest/stream.view?id=1", + None, + None, + Some("flac"), + &[0x00, 0x01, 0x02, 0x03], + ); + assert_eq!(hint.as_deref(), Some("flac")); + } + + #[test] + fn preview_format_hint_from_url_reads_format_query_param() { + assert_eq!( + preview_format_hint_from_url("https://h/stream.view?format=opus&id=x"), + Some("opus".into()) + ); + } +} + #[tauri::command] pub fn audio_preview_stop(app: AppHandle, state: State<'_, AudioEngine>) { preview_stop_inner(&app, &state, true); diff --git a/src/components/albumTrackList/TrackRow.tsx b/src/components/albumTrackList/TrackRow.tsx index 89675866..f9fa56a6 100644 --- a/src/components/albumTrackList/TrackRow.tsx +++ b/src/components/albumTrackList/TrackRow.tsx @@ -8,7 +8,7 @@ import type { Track } from '../../store/playerStoreTypes'; import { songToTrack } from '../../utils/playback/songToTrack'; import { useSelectionStore } from '../../store/selectionStore'; import { useThemeStore } from '../../store/themeStore'; -import { usePreviewStore } from '../../store/previewStore'; +import { previewInputFromSong, usePreviewStore } from '../../store/previewStore'; import StarRating from '../StarRating'; import { codecLabel, type ColKey } from '../../utils/componentHelpers/albumTrackListHelpers'; import { formatLongDuration } from '../../utils/format/formatDuration'; @@ -116,7 +116,7 @@ export const TrackRow = React.memo(function TrackRow({ className={`playlist-suggestion-preview-btn${isPreviewing ? ' is-previewing' : ''}${isPreviewAudioStarted ? ' audio-started' : ''}`} onClick={e => { e.stopPropagation(); - usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'albums'); + usePreviewStore.getState().startPreview(previewInputFromSong(song), 'albums'); }} data-tooltip={isPreviewing ? t('playlists.previewStop') : t('playlists.preview')} aria-label={isPreviewing ? t('playlists.previewStop') : t('playlists.preview')} diff --git a/src/components/artistDetail/ArtistDetailTopTracks.tsx b/src/components/artistDetail/ArtistDetailTopTracks.tsx index 9d0561e9..27c0f87f 100644 --- a/src/components/artistDetail/ArtistDetailTopTracks.tsx +++ b/src/components/artistDetail/ArtistDetailTopTracks.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { AudioLines, ChevronRight, Play, Square } from 'lucide-react'; import type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes'; import { usePlayerStore } from '../../store/playerStore'; -import { usePreviewStore } from '../../store/previewStore'; +import { previewInputFromSong, usePreviewStore } from '../../store/previewStore'; import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior'; import { songToTrack } from '../../utils/playback/songToTrack'; import { formatTrackTime } from '../../utils/format/formatDuration'; @@ -82,7 +82,7 @@ export default function ArtistDetailTopTracks({