mirror of
https://github.com/markuryy/shark.git
synced 2025-12-13 12:01:01 +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:
@@ -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