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:
2 files changed
+151
No files matched your search
@@ -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")
|
||||
|
||||
@@ -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)))
|
||||
|
||||
Reference in new issue
Block a user