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:
124
rust/src/lib.rs
124
rust/src/lib.rs
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user