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

@@ -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=="],

View File

@@ -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
View File

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

View File

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

View File

@@ -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
View 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"
}
}

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
});
}
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
try {
await invoke('tag_audio_file', {
path: filePath,
metadata,
coverData: coverArray,
embedLyrics,
});
} catch (error) {
throw new Error(`Failed to tag audio file: ${error}`);
}
}
// 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 || '';
}