feat(dl): add metadata, lyrics, and cover art tagging

Introduce metadata handling for online downloads:
- Embed cover art and lyrics (synced/unsynced) into MP3 files
- Save cover art to album folders and .lrc lyric files as sidecars
- Fetch and parse album/track metadata and lyrics from Deezer API
- Add user settings for artwork and lyrics embedding, LRC export, and cover quality
- Refactor queue manager to run continuously in background
This commit is contained in:
2025-10-02 10:57:27 -04:00
parent d1edc8b7f7
commit 36c0bc7dc7
11 changed files with 568 additions and 15 deletions

View File

@@ -317,6 +317,16 @@ export class DeezerAPI {
} }
} }
// Get album data
async getAlbumData(albumId: string): Promise<any> {
return this.apiCall('album.getData', { alb_id: albumId });
}
// Get track lyrics
async getLyrics(trackId: string): Promise<any> {
return this.apiCall('song.getLyrics', { sng_id: trackId });
}
// Get track download URL // Get track download URL
async getTrackDownloadUrl(trackToken: string, format: string, licenseToken: string, retryCount: number = 0): Promise<string | null> { async getTrackDownloadUrl(trackToken: string, format: string, licenseToken: string, retryCount: number = 0): Promise<string | null> {
console.log('[DEBUG] Getting track download URL...', { trackToken, format, licenseToken }); console.log('[DEBUG] Getting track download URL...', { trackToken, format, licenseToken });

View File

@@ -5,6 +5,7 @@
import { deezerAPI } from '$lib/services/deezer'; import { deezerAPI } from '$lib/services/deezer';
import { addToQueue } from '$lib/stores/downloadQueue'; import { addToQueue } from '$lib/stores/downloadQueue';
import { parseLyricsToLRC, parseLyricsToSYLT, parseLyricsText } from './tagger';
/** /**
* Fetch track metadata and add to download queue * Fetch track metadata and add to download queue
@@ -19,7 +20,33 @@ export async function addDeezerTrackToQueue(trackId: string): Promise<void> {
throw new Error('Track not found or invalid track ID'); throw new Error('Track not found or invalid track ID');
} }
// Build track object // Fetch album data for cover art URLs
let albumData = null;
try {
albumData = await deezerAPI.getAlbumData(trackInfo.ALB_ID.toString());
} catch (error) {
console.warn('[AddToQueue] Could not fetch album data:', error);
}
// Fetch lyrics
let lyricsData = null;
try {
lyricsData = await deezerAPI.getLyrics(trackInfo.SNG_ID.toString());
} catch (error) {
console.warn('[AddToQueue] Could not fetch lyrics:', error);
}
// Parse lyrics if available
let lyrics = undefined;
if (lyricsData) {
lyrics = {
sync: parseLyricsToLRC(lyricsData),
unsync: parseLyricsText(lyricsData),
syncID3: parseLyricsToSYLT(lyricsData)
};
}
// Build track object with enhanced metadata
const track = { const track = {
id: trackInfo.SNG_ID, id: trackInfo.SNG_ID,
title: trackInfo.SNG_TITLE, title: trackInfo.SNG_TITLE,
@@ -36,10 +63,19 @@ export async function addDeezerTrackToQueue(trackId: string): Promise<void> {
explicit: trackInfo.EXPLICIT_LYRICS === 1, explicit: trackInfo.EXPLICIT_LYRICS === 1,
md5Origin: trackInfo.MD5_ORIGIN, md5Origin: trackInfo.MD5_ORIGIN,
mediaVersion: trackInfo.MEDIA_VERSION, mediaVersion: trackInfo.MEDIA_VERSION,
trackToken: trackInfo.TRACK_TOKEN trackToken: trackInfo.TRACK_TOKEN,
// Enhanced metadata
lyrics,
albumCoverUrl: albumData?.ALB_PICTURE ? `https://e-cdns-images.dzcdn.net/images/cover/${albumData.ALB_PICTURE}/500x500-000000-80-0-0.jpg` : undefined,
albumCoverXlUrl: albumData?.ALB_PICTURE ? `https://e-cdns-images.dzcdn.net/images/cover/${albumData.ALB_PICTURE}/1000x1000-000000-80-0-0.jpg` : undefined,
label: albumData?.LABEL_NAME,
barcode: albumData?.UPC,
releaseDate: trackInfo.PHYSICAL_RELEASE_DATE,
genre: trackInfo.GENRE ? [trackInfo.GENRE] : undefined,
copyright: trackInfo.COPYRIGHT
}; };
// Add to queue // Add to queue (queue manager runs continuously in background)
await addToQueue({ await addToQueue({
source: 'deezer', source: 'deezer',
type: 'track', type: 'track',

View File

@@ -6,6 +6,10 @@ import { fetch } from '@tauri-apps/plugin-http';
import { writeFile, mkdir, remove, rename, exists } from '@tauri-apps/plugin-fs'; import { writeFile, mkdir, remove, rename, exists } from '@tauri-apps/plugin-fs';
import { generateBlowfishKey, decryptChunk } from './crypto'; import { generateBlowfishKey, decryptChunk } from './crypto';
import { generateTrackPath } from './paths'; import { generateTrackPath } from './paths';
import { tagMP3 } from './tagger';
import { downloadCover, saveCoverToAlbumFolder } from './imageDownload';
import { settings } from '$lib/stores/settings';
import { get } from 'svelte/store';
import type { DeezerTrack } from '$lib/types/deezer'; import type { DeezerTrack } from '$lib/types/deezer';
export interface DownloadProgress { export interface DownloadProgress {
@@ -88,9 +92,36 @@ export async function downloadTrack(
decryptedData = encryptedData; decryptedData = encryptedData;
} }
// Write to temp file // Get user settings
console.log('Writing to temp file...'); const appSettings = get(settings);
await writeFile(paths.tempPath, decryptedData);
// Download cover art if enabled
let coverData: Uint8Array | undefined;
if ((appSettings.embedCoverArt || appSettings.saveCoverToFolder) && track.albumCoverUrl) {
try {
console.log('Downloading cover art...');
coverData = await downloadCover(track.albumCoverUrl);
} catch (error) {
console.warn('Failed to download cover art:', error);
}
}
// 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);
// Move to final location // Move to final location
const finalPath = `${paths.filepath}/${paths.filename}`; const finalPath = `${paths.filepath}/${paths.filename}`;
@@ -104,6 +135,27 @@ export async function downloadTrack(
await rename(paths.tempPath, finalPath); await rename(paths.tempPath, finalPath);
// Save LRC sidecar file if enabled
if (appSettings.saveLrcFile && track.lyrics?.sync) {
try {
const lrcPath = finalPath.replace(/\.[^.]+$/, '.lrc');
console.log('Saving LRC file to:', lrcPath);
await writeFile(lrcPath, new TextEncoder().encode(track.lyrics.sync));
} catch (error) {
console.warn('Failed to save LRC file:', error);
}
}
// Save cover art to album folder if enabled
if (appSettings.saveCoverToFolder && coverData) {
try {
console.log('Saving cover art to album folder...');
await saveCoverToAlbumFolder(coverData, paths.filepath, 'cover');
} catch (error) {
console.warn('Failed to save cover art to folder:', error);
}
}
console.log('Download complete!'); console.log('Download complete!');
return finalPath; return finalPath;

View File

@@ -0,0 +1,67 @@
/**
* Deezer cover art downloader
* Downloads and caches album cover images
*/
import { fetch } from '@tauri-apps/plugin-http';
import { writeFile, exists } from '@tauri-apps/plugin-fs';
/**
* Download cover art from URL
* @param url - Cover art URL from Deezer
* @returns Uint8Array of image data
*/
export async function downloadCover(url: string): Promise<Uint8Array> {
console.log('[ImageDownload] Downloading cover from:', url);
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36'
},
connectTimeout: 30000
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const arrayBuffer = await response.arrayBuffer();
const imageData = new Uint8Array(arrayBuffer);
console.log('[ImageDownload] Downloaded', imageData.length, 'bytes');
return imageData;
} catch (error) {
console.error('[ImageDownload] Error downloading cover:', error);
throw error;
}
}
/**
* Save cover art to album folder
* @param coverData - Image data as Uint8Array
* @param albumPath - Path to album folder
* @param filename - Filename without extension (default: 'cover')
*/
export async function saveCoverToAlbumFolder(
coverData: Uint8Array,
albumPath: string,
filename: string = 'cover'
): Promise<void> {
const coverPath = `${albumPath}/${filename}.jpg`;
// Check if already exists
if (await exists(coverPath)) {
console.log('[ImageDownload] Cover already exists, skipping:', coverPath);
return;
}
try {
await writeFile(coverPath, coverData);
console.log('[ImageDownload] Saved cover to:', coverPath);
} catch (error) {
console.error('[ImageDownload] Error saving cover:', error);
throw error;
}
}

View File

@@ -19,6 +19,7 @@ import type { DeezerTrack } from '$lib/types/deezer';
export class DeezerQueueManager { export class DeezerQueueManager {
private isProcessing = false; private isProcessing = false;
private abortController: AbortController | null = null; private abortController: AbortController | null = null;
private albumCoverCache: Map<string, Uint8Array> = new Map();
/** /**
* Start processing the queue * Start processing the queue
@@ -33,6 +34,9 @@ export class DeezerQueueManager {
this.abortController = new AbortController(); this.abortController = new AbortController();
console.log('[DeezerQueueManager] Starting queue processor'); console.log('[DeezerQueueManager] Starting queue processor');
// Clear any stale currentJob from previous session
await setCurrentJob(null);
try { try {
await this.processQueue(); await this.processQueue();
} catch (error) { } catch (error) {
@@ -57,16 +61,19 @@ export class DeezerQueueManager {
/** /**
* Main queue processing loop * Main queue processing loop
* Runs continuously while the app is open, waiting for new items
*/ */
private async processQueue(): Promise<void> { private async processQueue(): Promise<void> {
console.log('[DeezerQueueManager] Queue processor started');
while (this.isProcessing) { while (this.isProcessing) {
const queueState = get(downloadQueue); const queueState = get(downloadQueue);
const nextItem = getNextQueuedItem(queueState); const nextItem = getNextQueuedItem(queueState);
if (!nextItem) { if (!nextItem) {
// No more items to process // No items to process - wait and check again
console.log('[DeezerQueueManager] Queue empty, stopping'); await new Promise(resolve => setTimeout(resolve, 500));
break; continue;
} }
console.log(`[DeezerQueueManager] Processing item: ${nextItem.title}`); console.log(`[DeezerQueueManager] Processing item: ${nextItem.title}`);
@@ -99,6 +106,8 @@ export class DeezerQueueManager {
// Clear current job // Clear current job
await setCurrentJob(null); await setCurrentJob(null);
} }
console.log('[DeezerQueueManager] Queue processor stopped');
} }
/** /**

View File

@@ -0,0 +1,207 @@
/**
* Audio file tagging module
* Embeds metadata, lyrics, and cover art into audio files
*/
import { ID3Writer } from 'browser-id3-writer';
import type { DeezerTrack } from '$lib/types/deezer';
/**
* Tag MP3 file with metadata, lyrics, and cover art
* @param audioData - Decrypted audio data
* @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,
track: DeezerTrack,
coverData?: Uint8Array,
embedLyrics: boolean = true
): Promise<Uint8Array> {
const writer = new ID3Writer(audioData.buffer);
// Basic tags
if (track.title) {
writer.setFrame('TIT2', track.title);
}
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
});
}
}
// 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 || '';
}

View File

@@ -9,6 +9,12 @@ export interface AppSettings {
deezerConcurrency: number; deezerConcurrency: number;
deezerFormat: 'FLAC' | 'MP3_320' | 'MP3_128'; deezerFormat: 'FLAC' | 'MP3_320' | 'MP3_128';
deezerOverwrite: boolean; deezerOverwrite: boolean;
// Metadata & artwork settings
embedCoverArt: boolean;
saveCoverToFolder: boolean;
embedLyrics: boolean;
saveLrcFile: boolean;
coverImageQuality: number;
} }
// Initialize the store with settings.json // Initialize the store with settings.json
@@ -20,7 +26,12 @@ const defaultSettings: AppSettings = {
playlistsFolder: null, playlistsFolder: null,
deezerConcurrency: 1, deezerConcurrency: 1,
deezerFormat: 'FLAC', deezerFormat: 'FLAC',
deezerOverwrite: false deezerOverwrite: false,
embedCoverArt: true,
saveCoverToFolder: true,
embedLyrics: true,
saveLrcFile: true,
coverImageQuality: 90
}; };
// Create a writable store for reactive UI updates // Create a writable store for reactive UI updates
@@ -33,13 +44,23 @@ export async function loadSettings(): Promise<void> {
const deezerConcurrency = await store.get<number>('deezerConcurrency'); const deezerConcurrency = await store.get<number>('deezerConcurrency');
const deezerFormat = await store.get<'FLAC' | 'MP3_320' | 'MP3_128'>('deezerFormat'); const deezerFormat = await store.get<'FLAC' | 'MP3_320' | 'MP3_128'>('deezerFormat');
const deezerOverwrite = await store.get<boolean>('deezerOverwrite'); const deezerOverwrite = await store.get<boolean>('deezerOverwrite');
const embedCoverArt = await store.get<boolean>('embedCoverArt');
const saveCoverToFolder = await store.get<boolean>('saveCoverToFolder');
const embedLyrics = await store.get<boolean>('embedLyrics');
const saveLrcFile = await store.get<boolean>('saveLrcFile');
const coverImageQuality = await store.get<number>('coverImageQuality');
settings.set({ settings.set({
musicFolder: musicFolder ?? null, musicFolder: musicFolder ?? null,
playlistsFolder: playlistsFolder ?? null, playlistsFolder: playlistsFolder ?? null,
deezerConcurrency: deezerConcurrency ?? 1, deezerConcurrency: deezerConcurrency ?? 1,
deezerFormat: deezerFormat ?? 'FLAC', deezerFormat: deezerFormat ?? 'FLAC',
deezerOverwrite: deezerOverwrite ?? false deezerOverwrite: deezerOverwrite ?? false,
embedCoverArt: embedCoverArt ?? true,
saveCoverToFolder: saveCoverToFolder ?? true,
embedLyrics: embedLyrics ?? true,
saveLrcFile: saveLrcFile ?? true,
coverImageQuality: coverImageQuality ?? 90
}); });
} }
@@ -116,5 +137,60 @@ export async function setDeezerOverwrite(value: boolean): Promise<void> {
})); }));
} }
// Save embed cover art setting
export async function setEmbedCoverArt(value: boolean): Promise<void> {
await store.set('embedCoverArt', value);
await store.save();
settings.update(s => ({
...s,
embedCoverArt: value
}));
}
// Save cover to folder setting
export async function setSaveCoverToFolder(value: boolean): Promise<void> {
await store.set('saveCoverToFolder', value);
await store.save();
settings.update(s => ({
...s,
saveCoverToFolder: value
}));
}
// Save embed lyrics setting
export async function setEmbedLyrics(value: boolean): Promise<void> {
await store.set('embedLyrics', value);
await store.save();
settings.update(s => ({
...s,
embedLyrics: value
}));
}
// Save LRC file setting
export async function setSaveLrcFile(value: boolean): Promise<void> {
await store.set('saveLrcFile', value);
await store.save();
settings.update(s => ({
...s,
saveLrcFile: value
}));
}
// Save cover image quality setting
export async function setCoverImageQuality(value: number): Promise<void> {
await store.set('coverImageQuality', value);
await store.save();
settings.update(s => ({
...s,
coverImageQuality: value
}));
}
// Initialize settings on app start // Initialize settings on app start
loadSettings(); loadSettings();

View File

@@ -76,6 +76,22 @@ export interface DeezerTrack {
releaseDate?: string; releaseDate?: string;
genre?: string[]; genre?: string[];
contributors?: DeezerContributor[]; contributors?: DeezerContributor[];
// Lyrics
lyrics?: {
sync?: string; // LRC format: [mm:ss.xx]line\n
unsync?: string; // Plain text
syncID3?: Array<[string, number]>; // [text, milliseconds] for ID3 SYLT frame
};
// Cover art URLs
albumCoverUrl?: string; // Standard size (500x500)
albumCoverXlUrl?: string; // XL size (1000x1000+)
// Additional tags
label?: string;
barcode?: string;
replayGain?: string;
} }
// Contributor information // Contributor information

View File

@@ -6,6 +6,7 @@
import { settings, loadSettings } from '$lib/stores/settings'; import { settings, loadSettings } from '$lib/stores/settings';
import { scanPlaylists, type Playlist } from '$lib/library/scanner'; import { scanPlaylists, type Playlist } from '$lib/library/scanner';
import { downloadQueue } from '$lib/stores/downloadQueue'; import { downloadQueue } from '$lib/stores/downloadQueue';
import { deezerQueueManager } from '$lib/services/deezer/queueManager';
let { children } = $props(); let { children } = $props();
@@ -22,6 +23,9 @@
onMount(async () => { onMount(async () => {
await loadSettings(); await loadSettings();
await loadPlaylists(); await loadPlaylists();
// Start background queue processor
deezerQueueManager.start();
}); });
async function loadPlaylists() { async function loadPlaylists() {

View File

@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { downloadQueue, clearCompleted, removeFromQueue, type QueueItem } from '$lib/stores/downloadQueue'; import { downloadQueue, clearCompleted, removeFromQueue, type QueueItem } from '$lib/stores/downloadQueue';
import { deezerQueueManager } from '$lib/services/deezer/queueManager';
let queueItems = $state<QueueItem[]>([]); let queueItems = $state<QueueItem[]>([]);
@@ -15,9 +14,6 @@
.map(id => state.queue[id]) .map(id => state.queue[id])
.filter(item => item !== undefined); .filter(item => item !== undefined);
}); });
// Start queue processor
deezerQueueManager.start();
}); });
onDestroy(() => { onDestroy(() => {

View File

@@ -7,6 +7,11 @@
setDeezerConcurrency, setDeezerConcurrency,
setDeezerFormat, setDeezerFormat,
setDeezerOverwrite, setDeezerOverwrite,
setEmbedCoverArt,
setSaveCoverToFolder,
setEmbedLyrics,
setSaveLrcFile,
setCoverImageQuality,
loadSettings loadSettings
} from '$lib/stores/settings'; } from '$lib/stores/settings';
import { clearLibrary as clearLibraryDb } from '$lib/library/database'; import { clearLibrary as clearLibraryDb } from '$lib/library/database';
@@ -17,6 +22,11 @@
let currentDeezerConcurrency = $state<number>(1); let currentDeezerConcurrency = $state<number>(1);
let currentDeezerFormat = $state<'FLAC' | 'MP3_320' | 'MP3_128'>('FLAC'); let currentDeezerFormat = $state<'FLAC' | 'MP3_320' | 'MP3_128'>('FLAC');
let currentDeezerOverwrite = $state<boolean>(false); let currentDeezerOverwrite = $state<boolean>(false);
let currentEmbedCoverArt = $state<boolean>(true);
let currentSaveCoverToFolder = $state<boolean>(true);
let currentEmbedLyrics = $state<boolean>(true);
let currentSaveLrcFile = $state<boolean>(true);
let currentCoverImageQuality = $state<number>(90);
let activeTab = $state<'library' | 'deezer' | 'advanced'>('library'); let activeTab = $state<'library' | 'deezer' | 'advanced'>('library');
onMount(async () => { onMount(async () => {
@@ -26,6 +36,11 @@
currentDeezerConcurrency = $settings.deezerConcurrency; currentDeezerConcurrency = $settings.deezerConcurrency;
currentDeezerFormat = $settings.deezerFormat; currentDeezerFormat = $settings.deezerFormat;
currentDeezerOverwrite = $settings.deezerOverwrite; currentDeezerOverwrite = $settings.deezerOverwrite;
currentEmbedCoverArt = $settings.embedCoverArt;
currentSaveCoverToFolder = $settings.saveCoverToFolder;
currentEmbedLyrics = $settings.embedLyrics;
currentSaveLrcFile = $settings.saveLrcFile;
currentCoverImageQuality = $settings.coverImageQuality;
}); });
$effect(() => { $effect(() => {
@@ -34,6 +49,11 @@
currentDeezerConcurrency = $settings.deezerConcurrency; currentDeezerConcurrency = $settings.deezerConcurrency;
currentDeezerFormat = $settings.deezerFormat; currentDeezerFormat = $settings.deezerFormat;
currentDeezerOverwrite = $settings.deezerOverwrite; currentDeezerOverwrite = $settings.deezerOverwrite;
currentEmbedCoverArt = $settings.embedCoverArt;
currentSaveCoverToFolder = $settings.saveCoverToFolder;
currentEmbedLyrics = $settings.embedLyrics;
currentSaveLrcFile = $settings.saveLrcFile;
currentCoverImageQuality = $settings.coverImageQuality;
}); });
async function selectMusicFolder() { async function selectMusicFolder() {
@@ -196,6 +216,66 @@
<label for="deezer-overwrite">Overwrite existing files</label> <label for="deezer-overwrite">Overwrite existing files</label>
</div> </div>
</div> </div>
<fieldset>
<legend>Metadata & Artwork</legend>
<div class="field-row">
<input
id="embed-cover"
type="checkbox"
bind:checked={currentEmbedCoverArt}
onchange={() => setEmbedCoverArt(currentEmbedCoverArt)}
/>
<label for="embed-cover">Embed cover art in files</label>
</div>
<div class="field-row">
<input
id="save-cover"
type="checkbox"
bind:checked={currentSaveCoverToFolder}
onchange={() => setSaveCoverToFolder(currentSaveCoverToFolder)}
/>
<label for="save-cover">Save cover art to album folder</label>
</div>
<div class="field-row">
<input
id="embed-lyrics"
type="checkbox"
bind:checked={currentEmbedLyrics}
onchange={() => setEmbedLyrics(currentEmbedLyrics)}
/>
<label for="embed-lyrics">Embed lyrics in files</label>
</div>
<div class="field-row">
<input
id="save-lrc"
type="checkbox"
bind:checked={currentSaveLrcFile}
onchange={() => setSaveLrcFile(currentSaveLrcFile)}
/>
<label for="save-lrc">Save .lrc lyric files (for Rockbox/FLAC)</label>
</div>
<div class="field-row-stacked">
<label for="cover-quality">Cover Image Quality</label>
<div class="slider-container">
<input
id="cover-quality"
type="range"
min="60"
max="100"
bind:value={currentCoverImageQuality}
onchange={() => setCoverImageQuality(currentCoverImageQuality)}
/>
<span class="slider-value">{currentCoverImageQuality}%</span>
</div>
<small class="help-text">JPEG quality for cover images (default: 90%)</small>
</div>
</fieldset>
</section> </section>
{:else if activeTab === 'advanced'} {:else if activeTab === 'advanced'}
<section class="tab-content"> <section class="tab-content">