Files
shark/src-tauri/src/tagger.rs
Markury 0d7361db4b refactor: migrate audio metadata tagging from ts to rust
- Add id3 and metaflac crates for native audio tagging
- Create tagger.rs with separate tag_mp3() and tag_flac() functions
- Implement tag_audio_file Tauri command for unified tagging interface
- Support full metadata: title, artist, album, track#, ISRC, BPM, lyrics, cover art
- Create TypeScript wrapper (tagger.ts) for calling Rust backend
- Update downloader.ts to use Rust tagging for both MP3 and FLAC
- Remove browser-id3-writer dependency (no browser FLAC support)
- Inline lyrics parsing in addToQueue.ts (no longer needed in tagger)
2025-10-02 11:39:56 -04:00

329 lines
10 KiB
Rust

use id3::{Tag as ID3Tag, TagLike, Version, frame::{Picture, PictureType}};
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<String>,
pub artist: Option<String>,
pub artists: Option<Vec<String>>,
pub album: Option<String>,
pub album_artist: Option<String>,
pub track_number: Option<u32>,
pub disc_number: Option<u32>,
pub year: Option<i32>,
pub genre: Option<Vec<String>>,
pub duration: Option<u32>, // in seconds
pub bpm: Option<u32>,
pub isrc: Option<String>,
pub label: Option<String>,
pub barcode: Option<String>,
pub copyright: Option<String>,
pub explicit: Option<bool>,
pub replay_gain: Option<String>,
pub source_id: Option<String>,
pub lyrics_unsync: Option<String>,
}
/// 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::{ExtendedText, Content};
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::{ExtendedText, Content};
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::{ExtendedText, Content};
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::{ExtendedText, Content};
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::{Lyrics, Content};
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"
}
}