use id3::{ frame::{Picture, PictureType}, Tag as ID3Tag, TagLike, Version, }; use metaflac::Tag as FlacTag; use serde::{Deserialize, Serialize}; use std::path::Path; /// Metadata structure for audio file tagging /// Matches DeezerTrack interface from TypeScript #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TaggingMetadata { pub title: Option, pub artist: Option, pub artists: Option>, pub album: Option, pub album_artist: Option, pub track_number: Option, pub disc_number: Option, pub year: Option, pub genre: Option>, pub duration: Option, // in seconds pub bpm: Option, pub isrc: Option, pub label: Option, pub barcode: Option, pub copyright: Option, pub explicit: Option, pub replay_gain: Option, pub source_id: Option, pub lyrics_unsync: Option, } /// Tag an audio file (MP3 or FLAC) with metadata pub fn tag_audio_file( path: &str, metadata: &TaggingMetadata, cover_data: Option<&[u8]>, embed_lyrics: bool, ) -> Result<(), String> { let path_obj = Path::new(path); // Determine file type by extension let extension = path_obj .extension() .and_then(|e| e.to_str()) .map(|e| e.to_lowercase()) .ok_or_else(|| "File has no extension".to_string())?; match extension.as_str() { "mp3" => tag_mp3(path, metadata, cover_data, embed_lyrics), "flac" => tag_flac(path, metadata, cover_data, embed_lyrics), _ => Err(format!("Unsupported file format: {}", extension)), } } /// Tag MP3 file using id3 crate fn tag_mp3( path: &str, metadata: &TaggingMetadata, cover_data: Option<&[u8]>, embed_lyrics: bool, ) -> Result<(), String> { // Read or create tag let mut tag = ID3Tag::read_from_path(path).unwrap_or_else(|_| ID3Tag::new()); // Basic metadata if let Some(ref title) = metadata.title { tag.set_title(title); } if let Some(ref artists) = metadata.artists { if !artists.is_empty() { tag.set_artist(artists.join(", ")); } } else if let Some(ref artist) = metadata.artist { tag.set_artist(artist); } if let Some(ref album) = metadata.album { tag.set_album(album); } if let Some(ref album_artist) = metadata.album_artist { tag.set_album_artist(album_artist); } // Track and disc numbers if let Some(track_number) = metadata.track_number { tag.set_track(track_number); } if let Some(disc_number) = metadata.disc_number { tag.set_disc(disc_number); } // Year if let Some(year) = metadata.year { tag.set_year(year); } // Genre if let Some(ref genres) = metadata.genre { if let Some(first_genre) = genres.first() { tag.set_genre(first_genre); } } // Duration (milliseconds) if let Some(duration) = metadata.duration { tag.set_duration(duration * 1000); } // Additional metadata as text frames if let Some(ref isrc) = metadata.isrc { tag.add_frame(id3::frame::Frame::text("TSRC", isrc.clone())); } if let Some(ref label) = metadata.label { tag.add_frame(id3::frame::Frame::text("TPUB", label.clone())); } if let Some(bpm) = metadata.bpm { tag.add_frame(id3::frame::Frame::text("TBPM", bpm.to_string())); } if let Some(ref copyright) = metadata.copyright { tag.add_frame(id3::frame::Frame::text("TCOP", copyright.clone())); } // Custom text frames (TXXX) if let Some(ref barcode) = metadata.barcode { use id3::frame::{Content, ExtendedText}; let ext_text = ExtendedText { description: "BARCODE".to_string(), value: barcode.clone(), }; tag.add_frame(id3::frame::Frame::with_content( "TXXX", Content::ExtendedText(ext_text), )); } if let Some(explicit) = metadata.explicit { use id3::frame::{Content, ExtendedText}; let ext_text = ExtendedText { description: "ITUNESADVISORY".to_string(), value: if explicit { "1" } else { "0" }.to_string(), }; tag.add_frame(id3::frame::Frame::with_content( "TXXX", Content::ExtendedText(ext_text), )); } if let Some(ref replay_gain) = metadata.replay_gain { use id3::frame::{Content, ExtendedText}; let ext_text = ExtendedText { description: "REPLAYGAIN_TRACK_GAIN".to_string(), value: replay_gain.clone(), }; tag.add_frame(id3::frame::Frame::with_content( "TXXX", Content::ExtendedText(ext_text), )); } if let Some(ref source_id) = metadata.source_id { use id3::frame::{Content, ExtendedText}; let source_text = ExtendedText { description: "SOURCE".to_string(), value: "Deezer".to_string(), }; tag.add_frame(id3::frame::Frame::with_content( "TXXX", Content::ExtendedText(source_text), )); let sourceid_text = ExtendedText { description: "SOURCEID".to_string(), value: source_id.clone(), }; tag.add_frame(id3::frame::Frame::with_content( "TXXX", Content::ExtendedText(sourceid_text), )); } // Lyrics (USLT frame) if embed_lyrics { if let Some(ref lyrics) = metadata.lyrics_unsync { use id3::frame::{Content, Lyrics}; let lyrics_frame = Lyrics { lang: "eng".to_string(), description: String::new(), text: lyrics.clone(), }; tag.add_frame(id3::frame::Frame::with_content( "USLT", Content::Lyrics(lyrics_frame), )); } } // Cover art (APIC frame) if let Some(cover_bytes) = cover_data { if !cover_bytes.is_empty() { let mime_type = detect_mime_type_str(cover_bytes); let picture = Picture { mime_type: mime_type.to_string(), picture_type: PictureType::CoverFront, description: "Cover".to_string(), data: cover_bytes.to_vec(), }; tag.add_frame(id3::frame::Frame::with_content( "APIC", id3::frame::Content::Picture(picture), )); } } // Write tag tag.write_to_path(path, Version::Id3v24) .map_err(|e| format!("Failed to write MP3 tags: {}", e)) } /// Tag FLAC file using metaflac crate fn tag_flac( path: &str, metadata: &TaggingMetadata, cover_data: Option<&[u8]>, embed_lyrics: bool, ) -> Result<(), String> { let mut tag = FlacTag::read_from_path(path).map_err(|e| format!("Failed to read FLAC file: {}", e))?; // Remove all existing vorbis comments to start fresh let vorbis = tag.vorbis_comments_mut(); vorbis.comments.clear(); // Basic metadata if let Some(ref title) = metadata.title { tag.set_vorbis("TITLE", vec![title.clone()]); } if let Some(ref artists) = metadata.artists { if !artists.is_empty() { // FLAC supports multiple ARTIST tags tag.set_vorbis("ARTIST", artists.clone()); } } else if let Some(ref artist) = metadata.artist { tag.set_vorbis("ARTIST", vec![artist.clone()]); } if let Some(ref album) = metadata.album { tag.set_vorbis("ALBUM", vec![album.clone()]); } if let Some(ref album_artist) = metadata.album_artist { tag.set_vorbis("ALBUMARTIST", vec![album_artist.clone()]); } // Track and disc numbers if let Some(track_number) = metadata.track_number { tag.set_vorbis("TRACKNUMBER", vec![track_number.to_string()]); } if let Some(disc_number) = metadata.disc_number { tag.set_vorbis("DISCNUMBER", vec![disc_number.to_string()]); } // Year/Date if let Some(year) = metadata.year { tag.set_vorbis("DATE", vec![year.to_string()]); } // Genre if let Some(ref genres) = metadata.genre { tag.set_vorbis("GENRE", genres.clone()); } // Additional metadata if let Some(ref isrc) = metadata.isrc { tag.set_vorbis("ISRC", vec![isrc.clone()]); } if let Some(ref label) = metadata.label { tag.set_vorbis("PUBLISHER", vec![label.clone()]); } if let Some(bpm) = metadata.bpm { tag.set_vorbis("BPM", vec![bpm.to_string()]); } if let Some(ref copyright) = metadata.copyright { tag.set_vorbis("COPYRIGHT", vec![copyright.clone()]); } if let Some(ref barcode) = metadata.barcode { tag.set_vorbis("BARCODE", vec![barcode.clone()]); } if let Some(explicit) = metadata.explicit { tag.set_vorbis( "ITUNESADVISORY", vec![if explicit { "1" } else { "0" }.to_string()], ); } if let Some(ref replay_gain) = metadata.replay_gain { tag.set_vorbis("REPLAYGAIN_TRACK_GAIN", vec![replay_gain.clone()]); } if let Some(ref source_id) = metadata.source_id { tag.set_vorbis("SOURCE", vec!["Deezer".to_string()]); tag.set_vorbis("SOURCEID", vec![source_id.clone()]); } // Lyrics if embed_lyrics { if let Some(ref lyrics) = metadata.lyrics_unsync { tag.set_vorbis("LYRICS", vec![lyrics.clone()]); } } // Cover art if let Some(cover_bytes) = cover_data { if !cover_bytes.is_empty() { let mime_type = detect_mime_type_str(cover_bytes); tag.remove_picture_type(metaflac::block::PictureType::CoverFront); tag.add_picture( mime_type, metaflac::block::PictureType::CoverFront, cover_bytes.to_vec(), ); } } // Write tag tag.save() .map_err(|e| format!("Failed to write FLAC tags: {}", e)) } /// Detect MIME type from image data header fn detect_mime_type_str(data: &[u8]) -> &'static str { if data.len() < 4 { return "image/jpeg"; } if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF { "image/jpeg" } else if data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 { "image/png" } else { "image/jpeg" } }