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

View File

@@ -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
};
}

View File

@@ -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 {

View File

@@ -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 || '';
}