Rockbox open source high quality audio player as a Music Player Daemon
mpris rockbox mpd libadwaita audio rust zig deno
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Save UPnP album art for remote tracks

Return album_art_uri from UPnP listings and add read_files_with_art so
directory scans can propagate art URLs. Extend save_audio_metadata to
accept an optional art URL and download/save the UPnP album art when no
embedded cover is present. Persist remote metadata concurrently
(semaphore-limited) and remove the blocking C/FFI probe.

+178 -54
+1 -1
crates/cli/src/lib.rs
··· 421 421 422 422 match rt.block_on(async { 423 423 let pool = create_connection_pool().await?; 424 - save_audio_metadata(pool, url).await 424 + save_audio_metadata(pool, url, None).await 425 425 }) { 426 426 Ok(()) => 0, 427 427 Err(e) => {
+30 -4
crates/graphql/src/lib.rs
··· 53 53 }) 54 54 } 55 55 56 - pub fn read_upnp_files(path: String) -> BoxFuture<'static, Result<Vec<String>, Error>> { 56 + /// Like `read_files` but also returns the `albumArtURI` for UPnP tracks. 57 + /// Local files always have `None` for the art URI. 58 + pub fn read_files_with_art( 59 + path: String, 60 + ) -> BoxFuture<'static, Result<Vec<(String, Option<String>)>, Error>> { 61 + Box::pin(async move { 62 + if path.starts_with("upnp://") { 63 + return read_upnp_entries(path).await; 64 + } 65 + let files = read_files(path).await?; 66 + Ok(files.into_iter().map(|f| (f, None)).collect()) 67 + }) 68 + } 69 + 70 + pub fn read_upnp_entries( 71 + path: String, 72 + ) -> BoxFuture<'static, Result<Vec<(String, Option<String>)>, Error>> { 57 73 Box::pin(async move { 58 74 use rockbox_upnp::control_point::{ 59 75 browse_content_directory, percent_decode, percent_encode, ··· 72 88 let ctrl_encoded = ctrl_encoded.to_string(); 73 89 let entries = browse_content_directory(&control_url, &object_id).await; 74 90 let mut result = Vec::new(); 91 + let mut futures = FuturesUnordered::new(); 75 92 for entry in entries { 76 93 if entry.is_container { 77 94 let sub_path = format!("upnp://{}/{}", ctrl_encoded, percent_encode(&entry.id)); 78 - let sub = read_upnp_files(sub_path).await?; 79 - result.extend(sub); 95 + futures.push(tokio::spawn(read_upnp_entries(sub_path))); 80 96 } else if let Some(uri) = entry.uri { 81 - result.push(uri); 97 + result.push((uri, entry.album_art_uri)); 82 98 } 83 99 } 100 + while let Some(Ok(sub)) = futures.next().await { 101 + result.extend(sub?); 102 + } 84 103 Ok(result) 85 104 }) 86 105 } 106 + 107 + pub fn read_upnp_files(path: String) -> BoxFuture<'static, Result<Vec<String>, Error>> { 108 + Box::pin(async move { 109 + let entries = read_upnp_entries(path).await?; 110 + Ok(entries.into_iter().map(|(uri, _)| uri).collect()) 111 + }) 112 + }
+74 -4
crates/library/src/audio_scan.rs
··· 14 14 use rockbox_sys as rb; 15 15 use rockbox_sys::types::mp3_entry::Mp3Entry; 16 16 use sqlx::{Pool, Sqlite}; 17 - use std::{io::Write, path::PathBuf, sync::Arc}; 17 + use std::{io::Write, path::PathBuf, sync::Arc, time::Duration}; 18 18 use tokio::{fs, sync::Semaphore}; 19 19 20 20 const AUDIO_EXTENSIONS: [&str; 18] = [ ··· 59 59 { 60 60 continue; 61 61 } 62 - save_audio_metadata(pool.clone(), path).await?; 62 + save_audio_metadata(pool.clone(), path, None).await?; 63 63 result.push(PathBuf::from(path)); 64 64 } 65 65 } ··· 86 86 }) 87 87 } 88 88 89 - pub async fn save_audio_metadata(pool: Pool<Sqlite>, path: &str) -> Result<(), Error> { 89 + pub async fn save_audio_metadata( 90 + pool: Pool<Sqlite>, 91 + path: &str, 92 + album_art_uri: Option<&str>, 93 + ) -> Result<(), Error> { 90 94 if !is_supported_audio_path(path) { 91 95 return Ok(()); 92 96 } ··· 121 125 let artist = track_artist(&entry); 122 126 let album_artist = track_album_artist(&entry, &artist); 123 127 let album = track_album(&entry, &title); 124 - let album_art = extract_and_save_album_cover_with_key(metadata_path, Some(&album))?; 128 + let mut album_art = extract_and_save_album_cover_with_key(metadata_path, Some(&album))?; 129 + 130 + // Fall back to the UPnP albumArtURI when the audio file has no embedded art. 131 + if album_art.is_none() { 132 + if let Some(art_url) = album_art_uri { 133 + album_art = download_and_save_album_art(art_url, &album).await?; 134 + } 135 + } 125 136 126 137 let title = match title.is_empty() { 127 138 true => "Unknown Title".to_string(), ··· 554 565 _ => None, 555 566 } 556 567 } 568 + 569 + /// Download album art from a URL (e.g. UPnP `albumArtURI`) and save it to the covers directory. 570 + /// Returns the filename (e.g. `"abc123.jpg"`) on success, or `None` if unavailable. 571 + async fn download_and_save_album_art( 572 + art_url: &str, 573 + album_key: &str, 574 + ) -> Result<Option<String>, Error> { 575 + let client = match reqwest::Client::builder() 576 + .timeout(Duration::from_secs(10)) 577 + .build() 578 + { 579 + Ok(c) => c, 580 + Err(_) => return Ok(None), 581 + }; 582 + 583 + let response = match client.get(art_url).send().await { 584 + Ok(r) if r.status().is_success() => r, 585 + Ok(r) => { 586 + tracing::debug!("album art download {} returned {}", art_url, r.status()); 587 + return Ok(None); 588 + } 589 + Err(e) => { 590 + tracing::debug!("album art download {} failed: {}", art_url, e); 591 + return Ok(None); 592 + } 593 + }; 594 + 595 + let ext = response 596 + .headers() 597 + .get(reqwest::header::CONTENT_TYPE) 598 + .and_then(|ct| ct.to_str().ok()) 599 + .map(|ct| { 600 + if ct.contains("png") { 601 + "png" 602 + } else if ct.contains("gif") { 603 + "gif" 604 + } else if ct.contains("bmp") { 605 + "bmp" 606 + } else { 607 + "jpg" 608 + } 609 + }) 610 + .unwrap_or("jpg"); 611 + 612 + let bytes = match response.bytes().await { 613 + Ok(b) if !b.is_empty() => b, 614 + _ => return Ok(None), 615 + }; 616 + 617 + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); 618 + let covers_path = format!("{}/.config/rockbox.org/covers", home); 619 + std::fs::create_dir_all(&covers_path)?; 620 + 621 + let album_md5 = md5::compute(album_key.as_bytes()); 622 + let filename = format!("{:x}.{}", album_md5, ext); 623 + std::fs::write(format!("{}/{}", covers_path, filename), &bytes)?; 624 + 625 + Ok(Some(filename)) 626 + }
+1 -1
crates/network/src/lib.rs
··· 36 36 copy(&mut content.as_ref(), &mut file)?; 37 37 38 38 let pool = create_connection_pool().await?; 39 - save_audio_metadata(pool, &file_path).await?; 39 + save_audio_metadata(pool, &file_path, None).await?; 40 40 41 41 Ok(file_path) 42 42 }
+65 -44
crates/server/src/handlers/playlists.rs
··· 1 - use std::{env, ffi::CString, sync::atomic::Ordering}; 1 + use std::{env, sync::atomic::Ordering, sync::Arc}; 2 + 3 + use futures_util::stream::{FuturesUnordered, StreamExt}; 4 + use tokio::sync::Semaphore; 2 5 3 6 use crate::http::{Context, Request, Response}; 4 7 use crate::{PLAYER_MUTEX, PLAYLIST_DIRTY}; 5 8 use anyhow::{anyhow, Error}; 6 9 use local_ip_addr::get_local_ip_address; 7 10 use rand::seq::SliceRandom; 8 - use rockbox_graphql::read_files; 11 + use rockbox_graphql::{read_files, read_files_with_art}; 12 + use rockbox_library::audio_scan::save_audio_metadata; 9 13 use rockbox_library::repo; 10 14 use rockbox_sys::{ 11 15 self as rb, ··· 15 19 use rockbox_traits::types::track::Track; 16 20 use rockbox_types::{DeleteTracks, InsertTracks, NewPlaylist, StatusCode}; 17 21 18 - unsafe extern "C" { 19 - fn save_remote_track_metadata(url: *const std::ffi::c_char) -> i32; 20 - } 21 - 22 22 fn trim_path(s: String) -> String { 23 23 let s = s.trim(); 24 24 s.split('#').next().unwrap_or(s).to_string() ··· 41 41 return Ok(()); 42 42 } 43 43 44 - persist_remote_track_metadata(_ctx, &new_playlist.tracks).await?; 44 + let tracks_with_art: Vec<(String, Option<String>)> = new_playlist 45 + .tracks 46 + .iter() 47 + .map(|t| (t.clone(), None)) 48 + .collect(); 49 + persist_remote_track_metadata(_ctx.pool.clone(), tracks_with_art).await; 45 50 46 51 let player_mutex = PLAYER_MUTEX.lock().unwrap(); 47 52 ··· 218 223 let mut tracklist: InsertTracks = serde_json::from_str(&req_body).unwrap(); 219 224 tracklist.tracks = tracklist.tracks.into_iter().map(trim_path).collect(); 220 225 226 + let mut tracks_with_art: Vec<(String, Option<String>)> = 227 + tracklist.tracks.iter().map(|t| (t.clone(), None)).collect(); 228 + 221 229 if let Some(dir) = &tracklist.directory { 222 - tracklist.tracks = read_files(dir.clone()).await?; 230 + let entries = read_files_with_art(dir.clone()).await?; 231 + tracklist.tracks = entries.iter().map(|(uri, _)| uri.clone()).collect(); 232 + tracks_with_art = entries; 223 233 } 224 234 225 235 if tracklist.tracks.is_empty() { ··· 227 237 return Ok(()); 228 238 } 229 239 230 - persist_remote_track_metadata(ctx, &tracklist.tracks).await?; 240 + persist_remote_track_metadata(ctx.pool.clone(), tracks_with_art).await; 231 241 232 242 let player_mutex = PLAYER_MUTEX.lock().unwrap(); 233 243 let amount = rb::playlist::amount(); ··· 320 330 Ok(()) 321 331 } 322 332 323 - async fn persist_remote_track_metadata(ctx: &Context, tracks: &[String]) -> Result<(), Error> { 324 - for track in tracks { 325 - if track.starts_with("http://") || track.starts_with("https://") { 326 - // Raw PCM streams served at /stream.wav have no embedded metadata and 327 - // probing them would block for ~47 s (8 MB at audio bitrate) then time out. 328 - if reqwest::Url::parse(track) 329 - .map(|u| u.path() == "/stream.wav") 330 - .unwrap_or(false) 331 - { 333 + async fn persist_remote_track_metadata( 334 + pool: sqlx::Pool<sqlx::Sqlite>, 335 + tracks: Vec<(String, Option<String>)>, 336 + ) { 337 + // Raw PCM streams served at /stream.wav have no embedded metadata and 338 + // probing them would block for ~47 s (8 MB at audio bitrate) then time out. 339 + let sem = Arc::new(Semaphore::new(8)); 340 + let mut futs: FuturesUnordered<tokio::task::JoinHandle<()>> = FuturesUnordered::new(); 341 + 342 + for (track, art_uri) in tracks { 343 + if !track.starts_with("http://") && !track.starts_with("https://") { 344 + continue; 345 + } 346 + if reqwest::Url::parse(&track) 347 + .map(|u| u.path() == "/stream.wav") 348 + .unwrap_or(false) 349 + { 350 + continue; 351 + } 352 + // Internal /tracks/{id} URLs are already in the library DB. 353 + match find_internal_track_by_pool(&pool, &track).await { 354 + Ok(Some(_)) => continue, 355 + Ok(None) => {} 356 + Err(e) => { 357 + tracing::warn!("metadata db check failed for {}: {}", track, e); 332 358 continue; 333 359 } 334 - if find_internal_track_by_url(ctx, track).await?.is_some() { 335 - continue; 360 + } 361 + let pool = pool.clone(); 362 + let sem = sem.clone(); 363 + futs.push(tokio::spawn(async move { 364 + let _permit = sem.acquire_owned().await.unwrap(); 365 + if let Err(e) = save_audio_metadata(pool, &track, art_uri.as_deref()).await { 366 + tracing::warn!("save_audio_metadata failed for {}: {}", track, e); 336 367 } 337 - let track = track.clone(); 338 - let track_for_worker = track.clone(); 339 - let status = tokio::task::spawn_blocking(move || -> Result<i32, Error> { 340 - let track_cstr = CString::new(track_for_worker.as_str())?; 341 - Ok(unsafe { save_remote_track_metadata(track_cstr.as_ptr()) }) 342 - }) 343 - .await??; 344 - if status != 0 { 345 - return Err(anyhow!("failed to save remote metadata for {}", track)); 346 - } 347 - } 368 + })); 348 369 } 349 370 350 - Ok(()) 371 + while futs.next().await.is_some() {} 351 372 } 352 373 353 374 async fn find_track_metadata( ··· 368 389 } 369 390 } 370 391 371 - if path.starts_with("http://") || path.starts_with("https://") { 372 - if metadata 373 - .as_ref() 374 - .map(|track| track.album_art.is_none()) 375 - .unwrap_or(true) 376 - && internal_track.is_none() 377 - { 378 - persist_remote_track_metadata(ctx, &[path.to_string()]).await?; 379 - metadata = repo::track::find_by_md5(ctx.pool.clone(), &hash).await?; 380 - } 381 - } 392 + // Metadata for remote tracks is probed in the background when tracks are 393 + // inserted (insert_tracks / create_playlist). Do not probe here — this 394 + // function is called once per track while holding PLAYER_MUTEX, so a 395 + // synchronous HTTP probe per track would block the entire server. 382 396 383 397 Ok(metadata) 384 398 } 385 399 386 400 async fn find_internal_track_by_url( 387 401 ctx: &Context, 402 + path: &str, 403 + ) -> Result<Option<rockbox_library::entity::track::Track>, Error> { 404 + find_internal_track_by_pool(&ctx.pool, path).await 405 + } 406 + 407 + async fn find_internal_track_by_pool( 408 + pool: &sqlx::Pool<sqlx::Sqlite>, 388 409 path: &str, 389 410 ) -> Result<Option<rockbox_library::entity::track::Track>, Error> { 390 411 let url = match reqwest::Url::parse(path) { ··· 408 429 return Ok(None); 409 430 } 410 431 411 - repo::track::find(ctx.pool.clone(), track_id) 432 + repo::track::find(pool.clone(), track_id) 412 433 .await 413 434 .map_err(Into::into) 414 435 }
+7
crates/upnp/src/control_point.rs
··· 17 17 pub title: String, 18 18 pub is_container: bool, 19 19 pub uri: Option<String>, 20 + pub album_art_uri: Option<String>, 20 21 } 21 22 22 23 pub async fn discover_media_servers() -> Vec<DiscoveredDevice> { ··· 238 239 } else { 239 240 extract_res_uri(element) 240 241 }; 242 + let album_art_uri = if is_container { 243 + None 244 + } else { 245 + extract_namespaced(element, "albumArtURI") 246 + }; 241 247 out.push(ContentEntry { 242 248 id, 243 249 title, 244 250 is_container, 245 251 uri, 252 + album_art_uri, 246 253 }); 247 254 pos = end_abs; 248 255 }