diff --git a/src/lib/services/deezer.ts b/src/lib/services/deezer.ts index 5502e5c..f0b7e32 100644 --- a/src/lib/services/deezer.ts +++ b/src/lib/services/deezer.ts @@ -317,6 +317,16 @@ export class DeezerAPI { } } + // Get album data + async getAlbumData(albumId: string): Promise { + return this.apiCall('album.getData', { alb_id: albumId }); + } + + // Get track lyrics + async getLyrics(trackId: string): Promise { + return this.apiCall('song.getLyrics', { sng_id: trackId }); + } + // Get track download URL async getTrackDownloadUrl(trackToken: string, format: string, licenseToken: string, retryCount: number = 0): Promise { console.log('[DEBUG] Getting track download URL...', { trackToken, format, licenseToken }); diff --git a/src/lib/services/deezer/addToQueue.ts b/src/lib/services/deezer/addToQueue.ts index 52d7131..fabb12c 100644 --- a/src/lib/services/deezer/addToQueue.ts +++ b/src/lib/services/deezer/addToQueue.ts @@ -5,6 +5,7 @@ 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 @@ -19,7 +20,33 @@ export async function addDeezerTrackToQueue(trackId: string): Promise { 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 = { id: trackInfo.SNG_ID, title: trackInfo.SNG_TITLE, @@ -36,10 +63,19 @@ export async function addDeezerTrackToQueue(trackId: string): Promise { explicit: trackInfo.EXPLICIT_LYRICS === 1, md5Origin: trackInfo.MD5_ORIGIN, 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({ source: 'deezer', type: 'track', diff --git a/src/lib/services/deezer/downloader.ts b/src/lib/services/deezer/downloader.ts index 0ef038a..1cb29f2 100644 --- a/src/lib/services/deezer/downloader.ts +++ b/src/lib/services/deezer/downloader.ts @@ -6,6 +6,10 @@ 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 { downloadCover, saveCoverToAlbumFolder } from './imageDownload'; +import { settings } from '$lib/stores/settings'; +import { get } from 'svelte/store'; import type { DeezerTrack } from '$lib/types/deezer'; export interface DownloadProgress { @@ -88,9 +92,36 @@ export async function downloadTrack( decryptedData = encryptedData; } - // Write to temp file - console.log('Writing to temp file...'); - await writeFile(paths.tempPath, decryptedData); + // Get user settings + const appSettings = get(settings); + + // 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 const finalPath = `${paths.filepath}/${paths.filename}`; @@ -104,6 +135,27 @@ export async function downloadTrack( 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!'); return finalPath; diff --git a/src/lib/services/deezer/imageDownload.ts b/src/lib/services/deezer/imageDownload.ts new file mode 100644 index 0000000..15ec288 --- /dev/null +++ b/src/lib/services/deezer/imageDownload.ts @@ -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 { + 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 { + 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; + } +} diff --git a/src/lib/services/deezer/queueManager.ts b/src/lib/services/deezer/queueManager.ts index 1174de1..69b255d 100644 --- a/src/lib/services/deezer/queueManager.ts +++ b/src/lib/services/deezer/queueManager.ts @@ -19,6 +19,7 @@ import type { DeezerTrack } from '$lib/types/deezer'; export class DeezerQueueManager { private isProcessing = false; private abortController: AbortController | null = null; + private albumCoverCache: Map = new Map(); /** * Start processing the queue @@ -33,6 +34,9 @@ export class DeezerQueueManager { this.abortController = new AbortController(); console.log('[DeezerQueueManager] Starting queue processor'); + // Clear any stale currentJob from previous session + await setCurrentJob(null); + try { await this.processQueue(); } catch (error) { @@ -57,16 +61,19 @@ export class DeezerQueueManager { /** * Main queue processing loop + * Runs continuously while the app is open, waiting for new items */ private async processQueue(): Promise { + console.log('[DeezerQueueManager] Queue processor started'); + while (this.isProcessing) { const queueState = get(downloadQueue); const nextItem = getNextQueuedItem(queueState); if (!nextItem) { - // No more items to process - console.log('[DeezerQueueManager] Queue empty, stopping'); - break; + // No items to process - wait and check again + await new Promise(resolve => setTimeout(resolve, 500)); + continue; } console.log(`[DeezerQueueManager] Processing item: ${nextItem.title}`); @@ -99,6 +106,8 @@ export class DeezerQueueManager { // Clear current job await setCurrentJob(null); } + + console.log('[DeezerQueueManager] Queue processor stopped'); } /** diff --git a/src/lib/services/deezer/tagger.ts b/src/lib/services/deezer/tagger.ts new file mode 100644 index 0000000..5b3bd3f --- /dev/null +++ b/src/lib/services/deezer/tagger.ts @@ -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 { + 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 || ''; +} diff --git a/src/lib/stores/settings.ts b/src/lib/stores/settings.ts index 869abb7..e6a1f35 100644 --- a/src/lib/stores/settings.ts +++ b/src/lib/stores/settings.ts @@ -9,6 +9,12 @@ export interface AppSettings { deezerConcurrency: number; deezerFormat: 'FLAC' | 'MP3_320' | 'MP3_128'; deezerOverwrite: boolean; + // Metadata & artwork settings + embedCoverArt: boolean; + saveCoverToFolder: boolean; + embedLyrics: boolean; + saveLrcFile: boolean; + coverImageQuality: number; } // Initialize the store with settings.json @@ -20,7 +26,12 @@ const defaultSettings: AppSettings = { playlistsFolder: null, deezerConcurrency: 1, deezerFormat: 'FLAC', - deezerOverwrite: false + deezerOverwrite: false, + embedCoverArt: true, + saveCoverToFolder: true, + embedLyrics: true, + saveLrcFile: true, + coverImageQuality: 90 }; // Create a writable store for reactive UI updates @@ -33,13 +44,23 @@ export async function loadSettings(): Promise { const deezerConcurrency = await store.get('deezerConcurrency'); const deezerFormat = await store.get<'FLAC' | 'MP3_320' | 'MP3_128'>('deezerFormat'); const deezerOverwrite = await store.get('deezerOverwrite'); + const embedCoverArt = await store.get('embedCoverArt'); + const saveCoverToFolder = await store.get('saveCoverToFolder'); + const embedLyrics = await store.get('embedLyrics'); + const saveLrcFile = await store.get('saveLrcFile'); + const coverImageQuality = await store.get('coverImageQuality'); settings.set({ musicFolder: musicFolder ?? null, playlistsFolder: playlistsFolder ?? null, deezerConcurrency: deezerConcurrency ?? 1, 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 { })); } +// Save embed cover art setting +export async function setEmbedCoverArt(value: boolean): Promise { + 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 { + 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 { + 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 { + 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 { + await store.set('coverImageQuality', value); + await store.save(); + + settings.update(s => ({ + ...s, + coverImageQuality: value + })); +} + // Initialize settings on app start loadSettings(); diff --git a/src/lib/types/deezer.ts b/src/lib/types/deezer.ts index e5e5a78..03b86c7 100644 --- a/src/lib/types/deezer.ts +++ b/src/lib/types/deezer.ts @@ -76,6 +76,22 @@ export interface DeezerTrack { releaseDate?: string; genre?: string[]; 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 diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 797cd4a..7bc8162 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -6,6 +6,7 @@ import { settings, loadSettings } from '$lib/stores/settings'; import { scanPlaylists, type Playlist } from '$lib/library/scanner'; import { downloadQueue } from '$lib/stores/downloadQueue'; + import { deezerQueueManager } from '$lib/services/deezer/queueManager'; let { children } = $props(); @@ -22,6 +23,9 @@ onMount(async () => { await loadSettings(); await loadPlaylists(); + + // Start background queue processor + deezerQueueManager.start(); }); async function loadPlaylists() { diff --git a/src/routes/downloads/+page.svelte b/src/routes/downloads/+page.svelte index 296c661..f8af7a6 100644 --- a/src/routes/downloads/+page.svelte +++ b/src/routes/downloads/+page.svelte @@ -1,7 +1,6 @@