diff --git a/src/federation/audio.rs b/src/federation/audio.rs index f7421e5..0ee1a85 100644 --- a/src/federation/audio.rs +++ b/src/federation/audio.rs @@ -39,6 +39,34 @@ struct AudioResponseHeader { total_size: u64, #[serde(default)] offset: u64, + /// Full track metadata from the owner's database — richer and more + /// authoritative than whatever tags the file itself carries. Absent + /// when the peer predates the field (the header is extensible). + #[serde(default, skip_serializing_if = "Option::is_none")] + metadata: Option, +} + +/// Track metadata exchanged alongside the audio bytes. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TrackMetadata { + #[serde(default)] + pub title: String, + #[serde(default)] + pub artists: Vec, + #[serde(default)] + pub featured_artists: Vec, + #[serde(default)] + pub album_artists: Vec, + #[serde(default)] + pub release_title: String, + #[serde(default)] + pub release_type: Option, + #[serde(default)] + pub year: Option, + #[serde(default)] + pub track_number: Option, + #[serde(default)] + pub disc_number: Option, } pub fn hex_encode(bytes: &[u8]) -> String { @@ -122,15 +150,16 @@ async fn write_line(writer: &mut W, value: &impl Seria // --------------------------------------------------------------------------- /// Downloads a whole track from `owner` into `dir/.`; returns -/// the file path and the mime type the peer reported. An already complete -/// cached file is reused. +/// the file path, the mime type and the track metadata the peer reported. +/// An already complete cached file is reused (the metadata still comes +/// fresh from the header). pub async fn download_track( service: &MusicDhtService, owner: EndpointId, item_id_hex: &str, dir: &Path, stem: &str, -) -> Result<(PathBuf, String)> { +) -> Result<(PathBuf, String, Option)> { let mut stream = service .open_stream(owner, AUDIO_ALPN) .await @@ -160,7 +189,7 @@ pub async fn download_track( && header.total_size > 0 { // Already fully downloaded earlier; no need to fetch again. - return Ok((path, header.mime_type)); + return Ok((path, header.mime_type, header.metadata)); } let temp_path = dir.join(format!(".{stem}.{extension}.part")); @@ -182,7 +211,7 @@ pub async fn download_track( ); } tokio::fs::rename(&temp_path, &path).await?; - Ok((path, header.mime_type)) + Ok((path, header.mime_type, header.metadata)) } // --------------------------------------------------------------------------- @@ -205,19 +234,42 @@ pub fn resolve_local_track_id( Ok(None) } -fn resolve_local_file( +/// Resolves the item to (file path, full metadata from the database). +fn resolve_for_serving( library: &Library, own: EndpointId, item_id: ItemId, -) -> Result> { - let export = library.federation_export()?; - for track in export.tracks { - let derived = ItemId::derive(&own, ItemKind::Track, &format!("track:{}", track.id)); - if derived == item_id { - return Ok(Some(track.file_path)); - } - } - Ok(None) +) -> Result> { + let Some(track_id) = resolve_local_track_id(library, own, item_id)? else { + return Ok(None); + }; + let Some(track) = library.tracks_by_ids(&[track_id])?.into_iter().next() else { + return Ok(None); + }; + // Release type and album artists live on the release row. + let (release_type, album_artists) = match library.release(track.release_id) { + Ok(detail) => ( + Some(detail.release_type), + detail.artists.iter().map(|a| a.name.clone()).collect(), + ), + Err(_) => (None, Vec::new()), + }; + let metadata = TrackMetadata { + title: track.title.clone(), + artists: track.artists.iter().map(|a| a.name.clone()).collect(), + featured_artists: track + .featured_artists + .iter() + .map(|a| a.name.clone()) + .collect(), + album_artists, + release_title: track.release_title.clone(), + release_type, + year: track.release_year, + track_number: track.track_number, + disc_number: track.disc_number, + }; + Ok(Some((track.file_path, metadata))) } /// Runs the accept loop of the audio protocol until the acceptor closes. @@ -247,10 +299,10 @@ async fn serve_one(mut stream: ByteStream, library: Arc, own: EndpointI let resolved = match hex_decode_item_id(&request.item_id) { Some(item_id) => { let library = Arc::clone(&library); - match tokio::task::spawn_blocking(move || resolve_local_file(&library, own, item_id)) + match tokio::task::spawn_blocking(move || resolve_for_serving(&library, own, item_id)) .await { - Ok(Ok(Some(path))) => Ok(path), + Ok(Ok(Some(found))) => Ok(found), Ok(Ok(None)) => Err("track not found in the library".to_string()), Ok(Err(err)) => Err(format!("library lookup failed: {err:#}")), Err(err) => Err(format!("lookup task failed: {err}")), @@ -258,8 +310,8 @@ async fn serve_one(mut stream: ByteStream, library: Arc, own: EndpointI } None => Err("malformed item_id".to_string()), }; - let file_path = match resolved { - Ok(path) => path, + let (file_path, metadata) = match resolved { + Ok(found) => found, Err(message) => return refuse(stream, message).await, }; @@ -281,6 +333,7 @@ async fn serve_one(mut stream: ByteStream, library: Arc, own: EndpointI mime_type: guess_mime(&path).to_string(), total_size, offset, + metadata: Some(metadata), }, ) .await?; @@ -302,6 +355,7 @@ async fn refuse(mut stream: ByteStream, message: String) -> Result<()> { mime_type: String::new(), total_size: 0, offset: 0, + metadata: None, }, ) .await?; diff --git a/src/federation/mod.rs b/src/federation/mod.rs index 5cf8d6f..b73eb77 100644 --- a/src/federation/mod.rs +++ b/src/federation/mod.rs @@ -30,7 +30,7 @@ use serde::{Deserialize, Serialize}; use crate::library::Library; use crate::library::models::{ArtistRef, TrackItem}; -pub use audio::AUDIO_ALPN; +pub use audio::{AUDIO_ALPN, TrackMetadata}; /// How often the published library is re-synchronized with the local index. const SYNC_INTERVAL: Duration = Duration::from_secs(60); @@ -481,15 +481,21 @@ impl Federation { let dir = if save { &self.media_dir } else { &self.cache_dir }; tokio::fs::create_dir_all(dir).await?; - let (path, mime) = + let (path, mime, metadata) = audio::download_track(&service, owner, &fed.item_id, dir, &download_stem(fed)).await?; tracing::info!(path = %path.display(), %mime, "federated track downloaded"); if save { let library = Arc::clone(&self.library); let import_path = path.clone(); + let import_metadata = metadata.clone(); let imported = tokio::task::spawn_blocking(move || -> Result> { - let import = crate::library::import::read_file(&import_path)?; + let mut import = crate::library::import::read_file(&import_path)?; + // The owner's database is more authoritative than whatever + // tags the file happens to carry (often none at all). + if let Some(meta) = &import_metadata { + apply_remote_metadata(&mut import, meta); + } let (track_id, _) = crate::library::import::upsert_track(&library, &import)?; Ok(library.tracks_by_ids(&[track_id])?.into_iter().next()) }) @@ -509,12 +515,48 @@ impl Federation { } Ok(FedPlayable { - track: ephemeral_track(fed, &path), + track: ephemeral_track(fed, metadata.as_ref(), &path), imported: false, }) } } +/// Overlays the peer-supplied metadata onto tag-derived import data. Every +/// non-empty peer field wins; file tags only fill the gaps. +fn apply_remote_metadata(import: &mut crate::library::import::TrackImport, meta: &TrackMetadata) { + let title = meta.title.trim(); + if !title.is_empty() { + import.title = title.to_string(); + } + if !meta.artists.is_empty() { + import.artists = meta.artists.clone(); + } + if !meta.featured_artists.is_empty() { + import.featured_artists = meta.featured_artists.clone(); + } + if !meta.album_artists.is_empty() { + import.album_artists = meta.album_artists.clone(); + } else if !meta.artists.is_empty() { + import.album_artists = meta.artists.clone(); + } + let release_title = meta.release_title.trim(); + if !release_title.is_empty() { + import.release_title = release_title.to_string(); + } + if meta.release_type.is_some() { + import.release_type = meta.release_type.clone(); + } + if meta.year.is_some() { + import.year = meta.year; + } + if meta.track_number.is_some() { + import.track_number = meta.track_number; + } + if meta.disc_number.is_some() { + import.disc_number = meta.disc_number; + } +} + async fn stop_running(running: Option) { let Some(running) = running else { return }; for task in &running.tasks { @@ -594,27 +636,47 @@ fn download_stem(fed: &FedTrack) -> String { } /// A playable TrackItem for a downloaded-but-not-imported federated track. -fn ephemeral_track(fed: &FedTrack, path: &std::path::Path) -> TrackItem { +fn ephemeral_track( + fed: &FedTrack, + metadata: Option<&TrackMetadata>, + path: &std::path::Path, +) -> TrackItem { let id = NEXT_EPHEMERAL_ID.fetch_sub(1, Ordering::Relaxed); let file_size = std::fs::metadata(path).map(|m| m.len() as i64).ok(); - TrackItem { - id, - title: fed.title.clone(), - track_number: None, - disc_number: None, - duration_seconds: fed.duration_seconds.unwrap_or(0) as f64, - artists: fed - .artist_names + let refs = |names: &[String]| -> Vec { + names .iter() .map(|name| ArtistRef { id: -1, name: name.clone(), }) - .collect(), - featured_artists: Vec::new(), + .collect() + }; + let title = metadata + .map(|m| m.title.trim()) + .filter(|t| !t.is_empty()) + .unwrap_or(&fed.title) + .to_string(); + let artists = match metadata { + Some(meta) if !meta.artists.is_empty() => refs(&meta.artists), + _ => refs(&fed.artist_names), + }; + let release_title = metadata + .map(|m| m.release_title.trim()) + .filter(|t| !t.is_empty()) + .map(|t| t.to_string()) + .unwrap_or_else(|| format!("federation · {}", fed.owner_short())); + TrackItem { + id, + title, + track_number: metadata.and_then(|m| m.track_number), + disc_number: metadata.and_then(|m| m.disc_number), + duration_seconds: fed.duration_seconds.unwrap_or(0) as f64, + artists, + featured_artists: metadata.map(|m| refs(&m.featured_artists)).unwrap_or_default(), release_id: -1, - release_title: format!("federation · {}", fed.owner_short()), - release_year: fed.year, + release_title, + release_year: metadata.and_then(|m| m.year).or(fed.year), file_path: path.to_string_lossy().into_owned(), cover_path: None, audio_format: path diff --git a/src/library/import.rs b/src/library/import.rs index b798f2c..74a055f 100644 --- a/src/library/import.rs +++ b/src/library/import.rs @@ -24,6 +24,9 @@ pub struct TrackImport { pub featured_artists: Vec, pub album_artists: Vec, pub release_title: String, + /// Release type ("album", "single", ...) when known from a richer + /// source than file tags (e.g. federation metadata); None = "album". + pub release_type: Option, pub year: Option, pub track_number: Option, pub disc_number: Option, @@ -186,6 +189,7 @@ pub fn read_file(path: &Path) -> Result { featured_artists: featured, album_artists, release_title: album.unwrap_or_else(|| "Unknown Album".to_string()), + release_type: None, year, track_number, disc_number, @@ -243,8 +247,12 @@ pub fn upsert_track(library: &Library, import: &TrackImport) -> Result<(i64, boo } None => { tx.execute( - "INSERT INTO releases (title, release_type, year) VALUES (?1, 'album', ?2)", - params![import.release_title, import.year], + "INSERT INTO releases (title, release_type, year) VALUES (?1, ?2, ?3)", + params![ + import.release_title, + import.release_type.as_deref().unwrap_or("album"), + import.year, + ], )?; let id = tx.last_insert_rowid(); for (position, name) in import.album_artists.iter().enumerate() { diff --git a/src/library/mod.rs b/src/library/mod.rs index 9d36ae6..238dac7 100644 --- a/src/library/mod.rs +++ b/src/library/mod.rs @@ -130,7 +130,6 @@ pub struct ExportTrack { pub title: String, pub year: Option, pub duration_seconds: f64, - pub file_path: String, pub artist_names: Vec, } @@ -434,7 +433,7 @@ impl Library { track_artists.entry(id).or_default().push(name); } let mut statement = conn.prepare( - "SELECT t.id, t.title, r.year, t.duration_seconds, t.file_path + "SELECT t.id, t.title, r.year, t.duration_seconds FROM tracks t JOIN releases r ON r.id = t.release_id", )?; let tracks = statement @@ -444,7 +443,6 @@ impl Library { title: row.get(1)?, year: row.get(2)?, duration_seconds: row.get(3)?, - file_path: row.get(4)?, artist_names: Vec::new(), }) })? @@ -883,6 +881,7 @@ mod tests { fn add_track(lib: &Library, title: &str, artist: &str, album: &str) -> i64 { let import = import::TrackImport { + release_type: None, file_path: format!("/music/{artist}/{album}/{title}.mp3"), title: title.to_string(), artists: vec![artist.to_string()],