mirror of
https://github.com/markuryy/shark.git
synced 2025-12-12 11:41:02 +00:00
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:
23
src-tauri/Cargo.lock
generated
23
src-tauri/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
328
src-tauri/src/tagger.rs
Normal 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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user