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:
3
bun.lock
3
bun.lock
@@ -13,7 +13,6 @@
|
||||
"@tauri-apps/plugin-sql": "^2.3.0",
|
||||
"@tauri-apps/plugin-store": "~2",
|
||||
"blowfish-node": "^1.1.4",
|
||||
"browser-id3-writer": "^6.3.1",
|
||||
"music-metadata": "^11.9.0",
|
||||
"uuid": "^13.0.0",
|
||||
},
|
||||
@@ -208,8 +207,6 @@
|
||||
|
||||
"blowfish-node": ["blowfish-node@1.1.4", "", {}, "sha512-Iahpxc/cutT0M0tgwV5goklB+EzDuiYLgwJg050AmUG2jSIOpViWMLdnRgBxzZuNfswAgHSUiIdvmNdgL2v6DA=="],
|
||||
|
||||
"browser-id3-writer": ["browser-id3-writer@6.3.1", "", {}, "sha512-sRA4Uq9Q3NsmXiVpLvIDxzomtgCdbw6SY85A6fw7dUQGRVoOBg1/buFv6spPhYiSo6FlVtN5OJQTvvhbmfx9rQ=="],
|
||||
|
||||
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
"@tauri-apps/plugin-sql": "^2.3.0",
|
||||
"@tauri-apps/plugin-store": "~2",
|
||||
"blowfish-node": "^1.1.4",
|
||||
"browser-id3-writer": "^6.3.1",
|
||||
"music-metadata": "^11.9.0",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
import { deezerAPI } from '$lib/services/deezer';
|
||||
import { addToQueue } from '$lib/stores/downloadQueue';
|
||||
import { parseLyricsToLRC, parseLyricsToSYLT, parseLyricsText } from './tagger';
|
||||
|
||||
/**
|
||||
* Fetch track metadata and add to download queue
|
||||
@@ -39,10 +38,20 @@ export async function addDeezerTrackToQueue(trackId: string): Promise<void> {
|
||||
// Parse lyrics if available
|
||||
let lyrics = undefined;
|
||||
if (lyricsData) {
|
||||
// Parse LRC format (synced lyrics)
|
||||
let syncLrc = '';
|
||||
if (lyricsData.LYRICS_SYNC_JSON) {
|
||||
for (const line of lyricsData.LYRICS_SYNC_JSON) {
|
||||
const text = line.line || '';
|
||||
const timestamp = line.lrc_timestamp || '[00:00.00]';
|
||||
syncLrc += `${timestamp}${text}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
lyrics = {
|
||||
sync: parseLyricsToLRC(lyricsData),
|
||||
unsync: parseLyricsText(lyricsData),
|
||||
syncID3: parseLyricsToSYLT(lyricsData)
|
||||
sync: syncLrc || undefined,
|
||||
unsync: lyricsData.LYRICS_TEXT || undefined,
|
||||
syncID3: undefined // No longer needed, handled by Rust tagger
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { fetch } from '@tauri-apps/plugin-http';
|
||||
import { writeFile, mkdir, remove, rename, exists } from '@tauri-apps/plugin-fs';
|
||||
import { generateBlowfishKey, decryptChunk } from './crypto';
|
||||
import { generateTrackPath } from './paths';
|
||||
import { tagMP3 } from './tagger';
|
||||
import { tagAudioFile } from './tagger';
|
||||
import { downloadCover, saveCoverToAlbumFolder } from './imageDownload';
|
||||
import { settings } from '$lib/stores/settings';
|
||||
import { get } from 'svelte/store';
|
||||
@@ -106,22 +106,9 @@ export async function downloadTrack(
|
||||
}
|
||||
}
|
||||
|
||||
// Apply tags (currently MP3 only)
|
||||
let finalData = decryptedData;
|
||||
if (format === 'MP3_320' || format === 'MP3_128') {
|
||||
console.log('Tagging MP3 file...');
|
||||
finalData = await tagMP3(
|
||||
decryptedData,
|
||||
track,
|
||||
appSettings.embedCoverArt ? coverData : undefined,
|
||||
appSettings.embedLyrics
|
||||
);
|
||||
}
|
||||
// TODO: Add FLAC tagging when library is ready
|
||||
|
||||
// Write tagged file to temp
|
||||
console.log('Writing tagged file to temp...');
|
||||
await writeFile(paths.tempPath, finalData);
|
||||
// Write untagged file to temp first
|
||||
console.log('Writing untagged file to temp...');
|
||||
await writeFile(paths.tempPath, decryptedData);
|
||||
|
||||
// Move to final location
|
||||
const finalPath = `${paths.filepath}/${paths.filename}`;
|
||||
@@ -135,6 +122,21 @@ export async function downloadTrack(
|
||||
|
||||
await rename(paths.tempPath, finalPath);
|
||||
|
||||
// Apply tags (works for both MP3 and FLAC)
|
||||
console.log('Tagging audio file...');
|
||||
try {
|
||||
await tagAudioFile(
|
||||
finalPath,
|
||||
track,
|
||||
appSettings.embedCoverArt ? coverData : undefined,
|
||||
appSettings.embedLyrics
|
||||
);
|
||||
console.log('Tagging complete!');
|
||||
} catch (error) {
|
||||
console.error('Failed to tag audio file:', error);
|
||||
// Non-fatal error - file is still downloaded, just not tagged
|
||||
}
|
||||
|
||||
// Save LRC sidecar file if enabled
|
||||
if (appSettings.saveLrcFile && track.lyrics?.sync) {
|
||||
try {
|
||||
|
||||
@@ -1,207 +1,106 @@
|
||||
/**
|
||||
* Audio file tagging module
|
||||
* Embeds metadata, lyrics, and cover art into audio files
|
||||
* Audio file tagging interface
|
||||
* Handles metadata tagging for MP3 and FLAC files via Rust backend
|
||||
*/
|
||||
|
||||
import { ID3Writer } from 'browser-id3-writer';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { DeezerTrack } from '$lib/types/deezer';
|
||||
|
||||
/**
|
||||
* Tag MP3 file with metadata, lyrics, and cover art
|
||||
* @param audioData - Decrypted audio data
|
||||
* Metadata structure that matches Rust TaggingMetadata
|
||||
*/
|
||||
export interface TaggingMetadata {
|
||||
title?: string;
|
||||
artist?: string;
|
||||
artists?: string[];
|
||||
album?: string;
|
||||
albumArtist?: string;
|
||||
trackNumber?: number;
|
||||
discNumber?: number;
|
||||
year?: number;
|
||||
genre?: string[];
|
||||
duration?: number;
|
||||
bpm?: number;
|
||||
isrc?: string;
|
||||
label?: string;
|
||||
barcode?: string;
|
||||
copyright?: string;
|
||||
explicit?: boolean;
|
||||
replayGain?: string;
|
||||
sourceId?: string;
|
||||
lyricsUnsync?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert DeezerTrack to TaggingMetadata for Rust
|
||||
*/
|
||||
export function convertToTaggingMetadata(track: DeezerTrack): TaggingMetadata {
|
||||
const metadata: TaggingMetadata = {
|
||||
title: track.title,
|
||||
artist: track.artist,
|
||||
artists: track.artists,
|
||||
album: track.album,
|
||||
albumArtist: track.albumArtist,
|
||||
trackNumber: track.trackNumber,
|
||||
discNumber: track.discNumber,
|
||||
isrc: track.isrc,
|
||||
bpm: track.bpm,
|
||||
copyright: track.copyright,
|
||||
explicit: track.explicit,
|
||||
replayGain: track.replayGain,
|
||||
label: track.label,
|
||||
barcode: track.barcode,
|
||||
sourceId: track.id.toString(),
|
||||
duration: track.duration,
|
||||
};
|
||||
|
||||
// Extract year from releaseDate (format: YYYY-MM-DD)
|
||||
if (track.releaseDate) {
|
||||
const year = parseInt(track.releaseDate.split('-')[0]);
|
||||
if (!isNaN(year)) {
|
||||
metadata.year = year;
|
||||
}
|
||||
}
|
||||
|
||||
// Genre
|
||||
if (track.genre && track.genre.length > 0) {
|
||||
metadata.genre = track.genre;
|
||||
}
|
||||
|
||||
// Lyrics (unsynced text)
|
||||
if (track.lyrics?.unsync) {
|
||||
metadata.lyricsUnsync = track.lyrics.unsync;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag an audio file with metadata, cover art, and lyrics
|
||||
* @param filePath - Path to the audio file
|
||||
* @param track - Track metadata
|
||||
* @param coverData - Optional cover art image data
|
||||
* @param embedLyrics - Whether to embed lyrics
|
||||
* @returns Tagged audio data as Uint8Array
|
||||
*/
|
||||
export async function tagMP3(
|
||||
audioData: Uint8Array,
|
||||
export async function tagAudioFile(
|
||||
filePath: string,
|
||||
track: DeezerTrack,
|
||||
coverData?: Uint8Array,
|
||||
embedLyrics: boolean = true
|
||||
): Promise<Uint8Array> {
|
||||
const writer = new ID3Writer(audioData.buffer);
|
||||
): Promise<void> {
|
||||
const metadata = convertToTaggingMetadata(track);
|
||||
|
||||
// Basic tags
|
||||
if (track.title) {
|
||||
writer.setFrame('TIT2', track.title);
|
||||
}
|
||||
// Convert Uint8Array to regular array for JSON serialization
|
||||
const coverArray = coverData ? Array.from(coverData) : undefined;
|
||||
|
||||
if (track.artists && track.artists.length > 0) {
|
||||
writer.setFrame('TPE1', track.artists);
|
||||
}
|
||||
|
||||
if (track.album) {
|
||||
writer.setFrame('TALB', track.album);
|
||||
}
|
||||
|
||||
if (track.albumArtist) {
|
||||
writer.setFrame('TPE2', track.albumArtist);
|
||||
}
|
||||
|
||||
// Track and disc numbers
|
||||
if (track.trackNumber) {
|
||||
writer.setFrame('TRCK', track.trackNumber.toString());
|
||||
}
|
||||
|
||||
if (track.discNumber) {
|
||||
writer.setFrame('TPOS', track.discNumber.toString());
|
||||
}
|
||||
|
||||
// Additional metadata
|
||||
if (track.genre && track.genre.length > 0) {
|
||||
writer.setFrame('TCON', track.genre);
|
||||
}
|
||||
|
||||
if (track.releaseDate) {
|
||||
const year = track.releaseDate.split('-')[0];
|
||||
if (year) {
|
||||
writer.setFrame('TYER', parseInt(year));
|
||||
}
|
||||
}
|
||||
|
||||
if (track.duration) {
|
||||
writer.setFrame('TLEN', track.duration * 1000);
|
||||
}
|
||||
|
||||
if (track.bpm) {
|
||||
writer.setFrame('TBPM', track.bpm);
|
||||
}
|
||||
|
||||
if (track.label) {
|
||||
writer.setFrame('TPUB', track.label);
|
||||
}
|
||||
|
||||
if (track.isrc) {
|
||||
writer.setFrame('TSRC', track.isrc);
|
||||
}
|
||||
|
||||
if (track.barcode) {
|
||||
writer.setFrame('TXXX', {
|
||||
description: 'BARCODE',
|
||||
value: track.barcode
|
||||
try {
|
||||
await invoke('tag_audio_file', {
|
||||
path: filePath,
|
||||
metadata,
|
||||
coverData: coverArray,
|
||||
embedLyrics,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to tag audio file: ${error}`);
|
||||
}
|
||||
|
||||
if (track.explicit !== undefined) {
|
||||
writer.setFrame('TXXX', {
|
||||
description: 'ITUNESADVISORY',
|
||||
value: track.explicit ? '1' : '0'
|
||||
});
|
||||
}
|
||||
|
||||
if (track.replayGain) {
|
||||
writer.setFrame('TXXX', {
|
||||
description: 'REPLAYGAIN_TRACK_GAIN',
|
||||
value: track.replayGain
|
||||
});
|
||||
}
|
||||
|
||||
if (track.copyright) {
|
||||
writer.setFrame('TCOP', track.copyright);
|
||||
}
|
||||
|
||||
// Source tags
|
||||
writer.setFrame('TXXX', {
|
||||
description: 'SOURCE',
|
||||
value: 'Deezer'
|
||||
});
|
||||
|
||||
writer.setFrame('TXXX', {
|
||||
description: 'SOURCEID',
|
||||
value: track.id.toString()
|
||||
});
|
||||
|
||||
// Lyrics
|
||||
if (embedLyrics && track.lyrics) {
|
||||
// Unsynced lyrics (USLT frame)
|
||||
if (track.lyrics.unsync) {
|
||||
writer.setFrame('USLT', {
|
||||
description: '',
|
||||
lyrics: track.lyrics.unsync,
|
||||
language: 'eng'
|
||||
});
|
||||
}
|
||||
|
||||
// Synced lyrics (SYLT frame)
|
||||
if (track.lyrics.syncID3 && track.lyrics.syncID3.length > 0) {
|
||||
writer.setFrame('SYLT', {
|
||||
type: 1,
|
||||
text: track.lyrics.syncID3,
|
||||
timestampFormat: 2
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Cover art (APIC frame)
|
||||
if (coverData && coverData.length > 0) {
|
||||
writer.setFrame('APIC', {
|
||||
type: 3,
|
||||
data: coverData.buffer,
|
||||
description: 'cover'
|
||||
});
|
||||
}
|
||||
|
||||
const taggedBuffer = writer.addTag();
|
||||
return new Uint8Array(taggedBuffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Deezer lyrics to LRC format
|
||||
* @param lyricsData - Lyrics data from Deezer API
|
||||
* @returns LRC formatted string
|
||||
*/
|
||||
export function parseLyricsToLRC(lyricsData: any): string {
|
||||
if (!lyricsData || !lyricsData.LYRICS_SYNC_JSON) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const syncLyricsJson = lyricsData.LYRICS_SYNC_JSON;
|
||||
let lrc = '';
|
||||
|
||||
for (const line of syncLyricsJson) {
|
||||
const text = line.line || '';
|
||||
const timestamp = line.lrc_timestamp || '[00:00.00]';
|
||||
lrc += `${timestamp}${text}\n`;
|
||||
}
|
||||
|
||||
return lrc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Deezer lyrics to ID3 SYLT format
|
||||
* @param lyricsData - Lyrics data from Deezer API
|
||||
* @returns Array of [text, milliseconds] tuples
|
||||
*/
|
||||
export function parseLyricsToSYLT(lyricsData: any): Array<[string, number]> {
|
||||
if (!lyricsData || !lyricsData.LYRICS_SYNC_JSON) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const syncLyricsJson = lyricsData.LYRICS_SYNC_JSON;
|
||||
const sylt: Array<[string, number]> = [];
|
||||
|
||||
for (const line of syncLyricsJson) {
|
||||
const text = line.line || '';
|
||||
const milliseconds = parseInt(line.milliseconds || '0');
|
||||
|
||||
if (text || milliseconds > 0) {
|
||||
sylt.push([text, milliseconds]);
|
||||
}
|
||||
}
|
||||
|
||||
return sylt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plain text lyrics
|
||||
* @param lyricsData - Lyrics data from Deezer API
|
||||
* @returns Plain text lyrics
|
||||
*/
|
||||
export function parseLyricsText(lyricsData: any): string {
|
||||
if (!lyricsData) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return lyricsData.LYRICS_TEXT || '';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user