feat: qbz-1 streaming, gapless prefetch, accurate scrobbling, Range-seek

Port proven playback architecture from qbqt fork:
- Bounded VecDeque buffer with condvar backpressure (4MB cap)
- decrypt_and_extract_frames for clean FLAC frame extraction from ISOBMFF
- Cancel+restart seeking with sub-segment sample skipping
- start_prefetch / QueueNext for gapless transitions with pre-started downloads
- track_transitioned signaling for scrobbler during gapless playback
- Range-request HTTP seeking for non-segmented (MP3) tracks
- OnceLock HTTP client singleton with cancel-aware chunked downloads
- Accumulated listening time scrobbling (prevents false scrobbles from seeking)
- Array-format Last.fm scrobble params (artist[0], track[0], etc.)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
joren
2026-03-30 20:12:23 +02:00
parent 6c8d032ce9
commit 70810bd4b1
16 changed files with 1539 additions and 382 deletions

View File

@@ -81,6 +81,9 @@ struct PrefetchedTrack {
track_id: i64,
track: api::models::TrackDto,
url: String,
n_segments: u32,
encryption_key: Option<String>,
prefetch_data: Option<player::decoder::PrefetchData>,
}
struct BackendInner {
@@ -476,8 +479,9 @@ pub unsafe extern "C" fn qobuz_backend_play_track(
}
};
let (track, url) = if let Some(pf) = cached {
(pf.track, pf.url)
// Extract prefetch_data to embed directly into TrackInfo
let (track, url, n_segments, encryption_key, prefetch_data) = if let Some(pf) = cached {
(pf.track, pf.url, pf.n_segments, pf.encryption_key, pf.prefetch_data)
} else {
// Fetch track metadata
let track = match client.lock().await.get_track(track_id).await {
@@ -489,11 +493,24 @@ pub unsafe extern "C" fn qobuz_backend_play_track(
Ok(u) => u,
Err(e) => { call_cb(cb, ud, EV_TRACK_URL_ERR, &err_json(&e.to_string())); return; }
};
let url = match url_dto.url {
Some(u) => u,
None => { call_cb(cb, ud, EV_TRACK_URL_ERR, &err_json("no stream URL")); return; }
let encryption_key = url_dto.key.clone();
// Prefer segmented url_template (reliable CDN path), fall back to plain url
eprintln!("[lib] url_dto: url={:?}, url_template={:?}, n_segments={:?}, mime={:?}, key_present={}",
url_dto.url.as_deref().map(|u| &u[..u.len().min(60)]),
url_dto.url_template.as_deref().map(|u| &u[..u.len().min(60)]),
url_dto.n_segments,
url_dto.mime_type,
encryption_key.is_some());
let (url, n_segments) = if let (Some(tmpl), Some(n)) = (url_dto.url_template, url_dto.n_segments) {
(tmpl, n)
} else if let Some(u) = url_dto.url {
(u, 0u32)
} else {
call_cb(cb, ud, EV_TRACK_URL_ERR, &err_json("no stream URL"));
return;
};
(track, url)
eprintln!("[lib] resolved: n_segments={n_segments}, url_prefix={}", &url[..url.len().min(80)]);
(track, url, n_segments, encryption_key, None)
};
// 2. Notify track change
@@ -513,7 +530,7 @@ pub unsafe extern "C" fn qobuz_backend_play_track(
if let Some(dur) = track.duration {
status.duration_secs.store(dur as u64, std::sync::atomic::Ordering::Relaxed);
}
let _ = cmd_tx.send(player::PlayerCommand::Play(player::TrackInfo { track, url, replaygain_db }));
let _ = cmd_tx.send(player::PlayerCommand::Play(player::TrackInfo { track, url, n_segments, encryption_key, replaygain_db, prefetch_data }));
// 5. State notification
call_cb(cb, ud, EV_STATE_CHANGED, r#"{"state":"playing"}"#);
@@ -582,6 +599,28 @@ pub unsafe extern "C" fn qobuz_backend_take_track_finished(ptr: *mut Backend) ->
if finished { 1 } else { 0 }
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_take_track_transitioned(ptr: *mut Backend) -> c_int {
let inner = &(*ptr).0;
let transitioned = inner
.player
.status
.track_transitioned
.swap(false, std::sync::atomic::Ordering::SeqCst);
if transitioned {
// Emit track changed so the Qt UI and Scrobbler automatically pick up the new song
if let Some(track) = inner.player.status.current_track.lock().unwrap().as_ref() {
if let Ok(j) = serde_json::to_string(track) {
call_cb(inner.cb, inner.ud, EV_TRACK_CHANGED, &j);
}
}
1
} else {
0
}
}
// ---------- ReplayGain / Gapless ----------
#[no_mangle]
@@ -600,10 +639,11 @@ pub unsafe extern "C" fn qobuz_backend_prefetch_track(
track_id: i64,
format_id: i32,
) {
let inner = &(*ptr).0;
let inner = &(*ptr).0;
let client = inner.client.clone();
let prefetch = inner.prefetch.clone();
let format = Format::from_id(format_id);
let cmd_tx = inner.player.cmd_tx.clone();
let rg_enabled = inner.replaygain_enabled.clone();
spawn(inner, async move {
let track = match client.lock().await.get_track(track_id).await {
@@ -614,11 +654,41 @@ pub unsafe extern "C" fn qobuz_backend_prefetch_track(
Ok(u) => u,
Err(_) => return,
};
let url = match url_dto.url {
Some(u) => u,
None => return,
let encryption_key = url_dto.key.clone();
let (url, n_segments) = if let (Some(tmpl), Some(n)) = (url_dto.url_template, url_dto.n_segments) {
(tmpl, n)
} else if let Some(u) = url_dto.url {
(u, 0u32)
} else {
return;
};
*prefetch.lock().await = Some(PrefetchedTrack { track_id, track, url });
// KICKSTART DOWNLOADING IMMEDIATELY
let prefetch_data = if n_segments > 0 {
Some(player::decoder::start_prefetch(
url.clone(),
n_segments,
encryption_key.as_deref(),
1,
))
} else {
None
};
let replaygain_db = if rg_enabled.load(std::sync::atomic::Ordering::Relaxed) {
track.audio_info.as_ref().and_then(|ai| ai.replaygain_track_gain)
} else {
None
};
let _ = cmd_tx.send(player::PlayerCommand::QueueNext(player::TrackInfo {
track,
url,
n_segments,
encryption_key,
replaygain_db,
prefetch_data,
}));
});
}
@@ -717,34 +787,6 @@ pub unsafe extern "C" fn qobuz_backend_get_user(ptr: *mut Backend) {
});
}
// ---------- Visualizer PCM access ----------
/// Read up to `max_samples` f32 PCM values into `buf`.
/// Returns the number of samples actually read.
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_viz_read(
ptr: *mut Backend,
buf: *mut f32,
max_samples: u32,
) -> u32 {
let consumer = &(*ptr).0.player.status.viz_consumer;
let Ok(mut lock) = consumer.try_lock() else { return 0 };
let slice = std::slice::from_raw_parts_mut(buf, max_samples as usize);
rb::RbConsumer::read(&mut *lock, slice).unwrap_or(0) as u32
}
/// Returns current sample rate of the audio stream (0 if idle).
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_viz_sample_rate(ptr: *const Backend) -> u32 {
(*ptr).0.player.status.viz_sample_rate.load(std::sync::atomic::Ordering::Relaxed)
}
/// Returns current channel count (0 if idle).
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_viz_channels(ptr: *const Backend) -> u32 {
(*ptr).0.player.status.viz_channels.load(std::sync::atomic::Ordering::Relaxed)
}
// ---------- Playlist management ----------
pub const EV_PLAYLIST_CREATED: c_int = 20;