mirror of
https://github.com/markuryy/shark.git
synced 2025-12-12 19:51:01 +00:00
- 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)
329 lines
10 KiB
Rust
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"
|
|
}
|
|
}
|