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)
This commit is contained in:
2025-10-02 11:39:56 -04:00
parent 36c0bc7dc7
commit 0d7361db4b
9 changed files with 492 additions and 215 deletions

23
src-tauri/Cargo.lock generated
View File

@@ -6,6 +6,8 @@ version = 4
name = "Shark"
version = "0.1.0"
dependencies = [
"id3",
"metaflac",
"serde",
"serde_json",
"tauri",
@@ -1964,6 +1966,17 @@ dependencies = [
"zerovec",
]
[[package]]
name = "id3"
version = "1.16.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aadb14a5ba1a0d58ecd4a29bfc9b8f1d119eee24aa01a62c1ec93eb9630a1d86"
dependencies = [
"bitflags 2.9.4",
"byteorder",
"flate2",
]
[[package]]
name = "ident_case"
version = "1.0.1"
@@ -2354,6 +2367,16 @@ dependencies = [
"autocfg",
]
[[package]]
name = "metaflac"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdf25a3451319c52a4a56d956475fbbb763bfb8420e2187d802485cb0fd8d965"
dependencies = [
"byteorder",
"hex",
]
[[package]]
name = "mime"
version = "0.3.17"

View File

@@ -27,4 +27,6 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri-plugin-http = { version = "2", features = ["unsafe-headers"] }
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
id3 = "1.16.3"
metaflac = "0.2.8"

View File

@@ -1,11 +1,29 @@
use tauri_plugin_sql::{Migration, MigrationKind};
mod tagger;
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
/// Tag an audio file with metadata, cover art, and lyrics
#[tauri::command]
fn tag_audio_file(
path: String,
metadata: tagger::TaggingMetadata,
cover_data: Option<Vec<u8>>,
embed_lyrics: bool,
) -> Result<(), String> {
tagger::tag_audio_file(
&path,
&metadata,
cover_data.as_deref(),
embed_lyrics,
)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let migrations = vec![
@@ -58,7 +76,7 @@ pub fn run() {
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.invoke_handler(tauri::generate_handler![greet])
.invoke_handler(tauri::generate_handler![greet, tag_audio_file])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

328
src-tauri/src/tagger.rs Normal file
View File

@@ -0,0 +1,328 @@
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"
}
}