feat(qbzd): auto-advance queue + play-album endpoint

Auto-advance: background task monitors when track ends and
automatically downloads and plays the next track in the queue.
Polls every 500ms. Queue exhaustion logs and stops.

Play-album: POST /api/playback/play-album sets the queue with
all album tracks and starts playing from the specified index.

cc
This commit is contained in:
vicrodh committed 2026-04-09 13:30:15 -06:00
1 parent 3a01330b02
commit 30bc325f68
2 files changed
+151

No files matched your search

+82
View File
@@ -99,6 +99,88 @@ pub async fn play_track(
})))
}
/// Play an album by ID. Sets the queue with all album tracks and starts playing.
#[derive(Deserialize)]
pub struct PlayAlbumRequest {
pub album_id: String,
pub start_index: Option<usize>,
pub quality: Option<String>,
}
pub async fn play_album(
daemon: Arc<DaemonCore>,
Json(req): Json<PlayAlbumRequest>,
) -> Result<Json<serde_json::Value>, String> {
// Fetch album with tracks
let album = daemon.core.get_album(&req.album_id).await
.map_err(|e| format!("Album fetch failed: {}", e))?;
let album_tracks = album.tracks.as_ref()
.map(|tc| &tc.items)
.ok_or("Album has no tracks")?;
let tracks: Vec<qbz_models::QueueTrack> = album_tracks.iter().map(|track| {
qbz_models::QueueTrack {
id: track.id,
title: track.title.clone(),
artist: track.performer.as_ref().map(|p| p.name.clone()).unwrap_or_default(),
album: album.title.clone(),
duration_secs: track.duration as u64,
artwork_url: album.image.large.clone(),
hires: track.hires_streamable,
bit_depth: track.maximum_bit_depth,
sample_rate: track.maximum_sampling_rate,
is_local: false,
album_id: Some(album.id.clone()),
artist_id: Some(album.artist.id),
streamable: true,
source: Some("qobuz".to_string()),
parental_warning: track.parental_warning,
}
}).collect();
if tracks.is_empty() {
return Err("Album has no tracks".to_string());
}
let start = req.start_index.unwrap_or(0);
let first_track_id = tracks[start].id;
// Set queue
daemon.core.set_queue(tracks, Some(start)).await;
// Play the first track
let quality = match req.quality.as_deref() {
Some("Hi-Res+") | Some("UltraHiRes") => qbz_models::Quality::UltraHiRes,
Some("Hi-Res") | Some("HiRes") => qbz_models::Quality::HiRes,
_ => qbz_models::Quality::HiRes,
};
let stream_url = daemon.core.get_stream_url(first_track_id, quality).await
.map_err(|e| format!("Stream URL failed: {}", e))?;
let http = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| format!("HTTP error: {}", e))?;
let audio_data = http.get(&stream_url.url).send().await
.map_err(|e| format!("Download failed: {}", e))?
.bytes().await
.map_err(|e| format!("Read failed: {}", e))?
.to_vec();
daemon.core.player().play_data(audio_data, first_track_id)
.map_err(|e| format!("Player error: {}", e))?;
Ok(Json(serde_json::json!({
"playing": true,
"track_id": first_track_id,
"album": album.title,
"tracks_count": album_tracks.len(),
})))
}
pub async fn play(daemon: Arc<DaemonCore>) -> Result<&'static str, String> {
daemon.core.resume().map_err(|e| e.to_string())?;
Ok("ok")
+69
View File
@@ -126,6 +126,9 @@ pub async fn run(mut config: DaemonConfig) -> Result<(), String> {
// Start playback state polling loop (broadcasts to event bus + MPRIS)
spawn_playback_loop(daemon.core.clone(), daemon.event_bus.clone(), mpris_handle.clone());
// Start auto-advance: when a track ends, play the next one in the queue
spawn_auto_advance(daemon.clone());
// Spawn MPRIS metadata updater (listens to event bus for TrackStarted)
if let Some(ref mc) = mpris_handle {
spawn_mpris_metadata_updater(daemon.event_bus.subscribe(), mc.clone());
@@ -248,6 +251,71 @@ fn load_oauth_token() -> Option<String> {
None
}
/// Auto-advance: monitors playback and plays next track when current ends.
fn spawn_auto_advance(daemon: Arc<DaemonCore>) {
tokio::spawn(async move {
let mut last_track_id: u64 = 0;
let mut was_playing = false;
loop {
let player = daemon.core.player();
let state = &player.state;
let track_id = state.current_track_id();
let is_playing = state.is_playing();
let position = state.current_position();
let duration = state.duration();
// Detect track end: was playing, now stopped, and position >= duration
let track_ended = was_playing && !is_playing && track_id == 0 && last_track_id != 0;
// Also detect when position reaches duration while still "playing"
let position_ended = is_playing && duration > 0 && position >= duration && track_id != 0;
if track_ended || (last_track_id != 0 && track_id == 0 && was_playing) {
log::info!("[qbzd/auto-advance] Track {} ended, advancing...", last_track_id);
// Get next track from queue
if let Some(next_track) = daemon.core.next_track().await {
let next_id = next_track.id;
log::info!("[qbzd/auto-advance] Next: {} - {}", next_track.artist, next_track.title);
// Download and play
let quality = qbz_models::Quality::HiRes;
match daemon.core.get_stream_url(next_id, quality).await {
Ok(stream_url) => {
let http = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(10))
.build();
if let Ok(client) = http {
if let Ok(resp) = client.get(&stream_url.url).send().await {
if let Ok(bytes) = resp.bytes().await {
let data = bytes.to_vec();
log::info!("[qbzd/auto-advance] Downloaded {} bytes", data.len());
if let Err(e) = daemon.core.player().play_data(data, next_id) {
log::error!("[qbzd/auto-advance] Play failed: {}", e);
}
}
}
}
}
Err(e) => {
log::error!("[qbzd/auto-advance] Stream URL failed: {}", e);
}
}
} else {
log::info!("[qbzd/auto-advance] Queue empty, playback stopped");
}
}
last_track_id = track_id;
was_playing = is_playing;
// Poll rate: check every 500ms
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
});
}
/// Register the daemon as a `_qbz._tcp` mDNS service for LAN discovery.
fn register_mdns(config: &DaemonConfig) -> Result<mdns_sd::ServiceDaemon, String> {
let mdns = mdns_sd::ServiceDaemon::new()
@@ -440,6 +508,7 @@ fn build_router(daemon: Arc<DaemonCore>) -> axum::Router {
.route("/api/playback", get(with_daemon!(daemon, playback::get_playback)))
.route("/api/playback/play", post(with_daemon!(daemon, playback::play)))
.route("/api/playback/play-track", post(with_daemon!(daemon, playback::play_track, json)))
.route("/api/playback/play-album", post(with_daemon!(daemon, playback::play_album, json)))
.route("/api/playback/pause", post(with_daemon!(daemon, playback::pause)))
.route("/api/playback/stop", post(with_daemon!(daemon, playback::stop)))
.route("/api/playback/next", post(with_daemon!(daemon, playback::next)))