diff --git a/src/app/popup.rs b/src/app/popup.rs index 88681c3..bee4da9 100644 --- a/src/app/popup.rs +++ b/src/app/popup.rs @@ -215,12 +215,14 @@ fn save_edit( if artists.is_empty() { return Err("at least one artist is required".to_string()); } + let cover_path = value("Cover path"); let edit = TrackEdit { title, artists, featured_artists: names("Featured"), track_number: number("Track #")?, disc_number: number("Disc #")?, + cover_path: (!cover_path.is_empty()).then_some(cover_path), }; library.update_track(id, &edit) } diff --git a/src/app/update.rs b/src/app/update.rs index db47d66..55f524e 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -422,6 +422,7 @@ fn track_edit_popup(track: &TrackItem) -> super::state::Popup { "Disc #", track.disc_number.map(|n| n.to_string()).unwrap_or_default(), ), + EditField::new("Cover path", track.cover_path.clone().unwrap_or_default()), ], focus: 0, error: None, diff --git a/src/federation/audio.rs b/src/federation/audio.rs index 683860c..3d8f365 100644 --- a/src/federation/audio.rs +++ b/src/federation/audio.rs @@ -56,6 +56,12 @@ struct AudioResponseHeader { cover_size: u64, #[serde(default)] cover_mime: String, + /// Size of the main artist's image segment, sent after the cover and + /// before the audio; 0 = none. Governed by the same `want_cover` flag. + #[serde(default)] + artist_image_size: u64, + #[serde(default)] + artist_image_mime: String, } /// Covers above this size are skipped rather than transferred. @@ -198,6 +204,8 @@ pub struct Downloaded { pub metadata: Option, /// Cover art (bytes, file extension) sent by the owner, if any. pub cover: Option<(Vec, &'static str)>, + /// The main artist's image (bytes, file extension), if any. + pub artist_image: Option<(Vec, &'static str)>, } /// Downloads a whole track (with metadata and cover art) from `owner` into @@ -233,24 +241,31 @@ pub async fn download_track( ); } - // The cover segment precedes the audio bytes and is read regardless of - // the cache state — it sits first in the stream. - let cover = if header.cover_size > 0 { + // The image segments precede the audio bytes and are read regardless of + // the cache state — they sit first in the stream. + let mut read_image = async |size: u64, mime: &str, what: &str| -> Result, &'static str)>> { + if size == 0 { + return Ok(None); + } anyhow::ensure!( - header.cover_size <= MAX_COVER_BYTES, - "cover of {} bytes exceeds the {MAX_COVER_BYTES} byte limit", - header.cover_size + size <= MAX_COVER_BYTES, + "{what} of {size} bytes exceeds the {MAX_COVER_BYTES} byte limit" ); - let mut bytes = vec![0u8; header.cover_size as usize]; + let mut bytes = vec![0u8; size as usize]; stream .recv .read_exact(&mut bytes) .await - .context("stream ended inside the cover segment")?; - Some((bytes, image_extension(&header.cover_mime))) - } else { - None + .with_context(|| format!("stream ended inside the {what} segment"))?; + Ok(Some((bytes, image_extension(mime)))) }; + let cover = read_image(header.cover_size, &header.cover_mime, "cover").await?; + let artist_image = read_image( + header.artist_image_size, + &header.artist_image_mime, + "artist image", + ) + .await?; let extension = extension_for_mime(&header.mime_type); let path = dir.join(format!("{stem}.{extension}")); @@ -264,6 +279,7 @@ pub async fn download_track( mime_type: header.mime_type, metadata: header.metadata, cover, + artist_image, }); } @@ -291,6 +307,7 @@ pub async fn download_track( mime_type: header.mime_type, metadata: header.metadata, cover, + artist_image, }) } @@ -319,6 +336,7 @@ struct Served { file_path: String, metadata: TrackMetadata, cover_path: Option, + artist_image_path: Option, } /// Resolves the item to the audio file, metadata and cover. @@ -356,8 +374,13 @@ fn resolve_for_serving( track_number: track.track_number, disc_number: track.disc_number, }; + let artist_image_path = track + .artists + .first() + .and_then(|artist| library.artist_image(artist.id).ok().flatten()); Ok(Some(Served { cover_path: track.cover_path.clone(), + artist_image_path, file_path: track.file_path, metadata, })) @@ -417,11 +440,14 @@ async fn serve_one(mut stream: ByteStream, library: Arc, own: EndpointI file.seek(std::io::SeekFrom::Start(offset)).await?; } - // Cover art rides between the header and the audio, when asked for. - let cover = if request.want_cover { - load_cover(served.cover_path.as_deref()).await + // Images ride between the header and the audio, when asked for. + let (cover, artist_image) = if request.want_cover { + ( + load_cover(served.cover_path.as_deref()).await, + load_cover(served.artist_image_path.as_deref()).await, + ) } else { - None + (None, None) }; write_line( &mut stream.send, @@ -437,12 +463,22 @@ async fn serve_one(mut stream: ByteStream, library: Arc, own: EndpointI .as_ref() .map(|(_, mime)| mime.to_string()) .unwrap_or_default(), + artist_image_size: artist_image + .as_ref() + .map_or(0, |(bytes, _)| bytes.len() as u64), + artist_image_mime: artist_image + .as_ref() + .map(|(_, mime)| mime.to_string()) + .unwrap_or_default(), }, ) .await?; if let Some((bytes, _)) = &cover { stream.send.write_all(bytes).await?; } + if let Some((bytes, _)) = &artist_image { + stream.send.write_all(bytes).await?; + } tokio::io::copy(&mut file, &mut stream.send).await?; stream.send.finish()?; // Wait until the peer read everything (or gave up) before dropping the @@ -475,6 +511,8 @@ async fn refuse(mut stream: ByteStream, message: String) -> Result<()> { metadata: None, cover_size: 0, cover_mime: String::new(), + artist_image_size: 0, + artist_image_mime: String::new(), }, ) .await?; diff --git a/src/federation/mod.rs b/src/federation/mod.rs index 74a5418..822d23f 100644 --- a/src/federation/mod.rs +++ b/src/federation/mod.rs @@ -495,6 +495,7 @@ impl Federation { let import_path = downloaded.path.clone(); let import_metadata = downloaded.metadata.clone(); let import_cover = downloaded.cover.clone(); + let artist_image = downloaded.artist_image.clone(); let imported = tokio::task::spawn_blocking(move || -> Result> { let mut import = crate::library::import::read_file(&import_path)?; // The owner's database is more authoritative than whatever @@ -508,6 +509,14 @@ impl Federation { import.cover = import_cover; } let (track_id, _) = crate::library::import::upsert_track(&library, &import)?; + // The owner's artist image fills the gap for a freshly + // created (or still image-less) main artist. + if let (Some((bytes, extension)), Some(artist_name)) = + (&artist_image, import.artists.first()) + && let Err(err) = save_artist_image(&library, artist_name, bytes, extension) + { + tracing::warn!(%err, "saving the artist image failed"); + } Ok(library.tracks_by_ids(&[track_id])?.into_iter().next()) }) .await?; @@ -550,6 +559,28 @@ impl Federation { } } +/// Writes a received artist image into the covers directory and attaches it +/// to the artist unless one is already set. +fn save_artist_image( + library: &Library, + artist_name: &str, + bytes: &[u8], + extension: &str, +) -> Result<()> { + let covers_dir = library.covers_dir(); + std::fs::create_dir_all(covers_dir)?; + let path = covers_dir.join(format!( + "artist-{}.{extension}", + sanitize_file_stem(artist_name) + )); + // Write only if the artist actually lacks an image, to avoid litter. + if library.artist_image_missing(artist_name)? { + std::fs::write(&path, bytes)?; + library.set_artist_image_if_missing(artist_name, &path.to_string_lossy())?; + } + Ok(()) +} + /// 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) { diff --git a/src/library/mod.rs b/src/library/mod.rs index 238dac7..6b60a78 100644 --- a/src/library/mod.rs +++ b/src/library/mod.rs @@ -663,6 +663,13 @@ impl Library { "UPDATE tracks SET title = ?2, track_number = ?3, disc_number = ?4 WHERE id = ?1", params![id, edit.title, edit.track_number, edit.disc_number], )?; + // The cover is a release attribute; editing it from a track updates + // the release cover (what every view shows for this track). + tx.execute( + "UPDATE releases SET cover_path = ?2 + WHERE id = (SELECT release_id FROM tracks WHERE id = ?1)", + params![id, edit.cover_path], + )?; tx.execute("DELETE FROM track_artists WHERE track_id = ?1", [id])?; link_track_artists(&tx, id, &edit.artists, "main")?; link_track_artists(&tx, id, &edit.featured_artists, "featured")?; @@ -712,6 +719,44 @@ impl Library { Ok(()) } + /// Image of one artist, for the federation metadata exchange. + pub fn artist_image(&self, artist_id: i64) -> Result> { + let conn = self.lock(); + Ok(conn + .query_row( + "SELECT image_path FROM artists WHERE id = ?1", + [artist_id], + |row| row.get(0), + ) + .optional()? + .flatten()) + } + + /// `true` when the artist exists and has no image yet. + pub fn artist_image_missing(&self, name: &str) -> Result { + let conn = self.lock(); + let missing: Option = conn + .query_row( + "SELECT image_path IS NULL FROM artists WHERE name = ?1 COLLATE NOCASE", + [name], + |row| row.get(0), + ) + .optional()?; + Ok(missing.unwrap_or(false)) + } + + /// Sets an artist's image (matched by name) unless one is already set. + /// Returns whether the image was applied. + pub fn set_artist_image_if_missing(&self, name: &str, image_path: &str) -> Result { + let conn = self.lock(); + let changed = conn.execute( + "UPDATE artists SET image_path = ?2 + WHERE name = ?1 COLLATE NOCASE AND image_path IS NULL", + params![name, image_path], + )?; + Ok(changed > 0) + } + pub fn delete_track(&self, id: i64) -> Result<()> { let mut conn = self.lock(); let tx = conn.transaction()?; @@ -976,6 +1021,7 @@ mod tests { featured_artists: vec!["Guest".into()], track_number: Some(2), disc_number: None, + cover_path: None, }, ) .unwrap(); diff --git a/src/library/models.rs b/src/library/models.rs index b74554e..62c88fc 100644 --- a/src/library/models.rs +++ b/src/library/models.rs @@ -162,6 +162,9 @@ pub struct TrackEdit { pub featured_artists: Vec, pub track_number: Option, pub disc_number: Option, + /// Cover image path; the cover lives on the track's release (the same + /// image every view shows for the track). None clears it. + pub cover_path: Option, } #[derive(Debug, Clone)]