mirror of
https://github.com/markuryy/shark.git
synced 2025-12-12 11:41:02 +00:00
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:
@@ -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
|
||||
async getTrackDownloadUrl(trackToken: string, format: string, licenseToken: string, retryCount: number = 0): Promise<string | null> {
|
||||
console.log('[DEBUG] Getting track download URL...', { trackToken, format, licenseToken });
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
67
src/lib/services/deezer/imageDownload.ts
Normal file
67
src/lib/services/deezer/imageDownload.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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<string, Uint8Array> = 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<void> {
|
||||
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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
207
src/lib/services/deezer/tagger.ts
Normal file
207
src/lib/services/deezer/tagger.ts
Normal 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 || '';
|
||||
}
|
||||
@@ -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<void> {
|
||||
const deezerConcurrency = await store.get<number>('deezerConcurrency');
|
||||
const deezerFormat = await store.get<'FLAC' | 'MP3_320' | 'MP3_128'>('deezerFormat');
|
||||
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({
|
||||
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<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
|
||||
loadSettings();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { downloadQueue, clearCompleted, removeFromQueue, type QueueItem } from '$lib/stores/downloadQueue';
|
||||
import { deezerQueueManager } from '$lib/services/deezer/queueManager';
|
||||
|
||||
let queueItems = $state<QueueItem[]>([]);
|
||||
|
||||
@@ -15,9 +14,6 @@
|
||||
.map(id => state.queue[id])
|
||||
.filter(item => item !== undefined);
|
||||
});
|
||||
|
||||
// Start queue processor
|
||||
deezerQueueManager.start();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
setDeezerConcurrency,
|
||||
setDeezerFormat,
|
||||
setDeezerOverwrite,
|
||||
setEmbedCoverArt,
|
||||
setSaveCoverToFolder,
|
||||
setEmbedLyrics,
|
||||
setSaveLrcFile,
|
||||
setCoverImageQuality,
|
||||
loadSettings
|
||||
} from '$lib/stores/settings';
|
||||
import { clearLibrary as clearLibraryDb } from '$lib/library/database';
|
||||
@@ -17,6 +22,11 @@
|
||||
let currentDeezerConcurrency = $state<number>(1);
|
||||
let currentDeezerFormat = $state<'FLAC' | 'MP3_320' | 'MP3_128'>('FLAC');
|
||||
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');
|
||||
|
||||
onMount(async () => {
|
||||
@@ -26,6 +36,11 @@
|
||||
currentDeezerConcurrency = $settings.deezerConcurrency;
|
||||
currentDeezerFormat = $settings.deezerFormat;
|
||||
currentDeezerOverwrite = $settings.deezerOverwrite;
|
||||
currentEmbedCoverArt = $settings.embedCoverArt;
|
||||
currentSaveCoverToFolder = $settings.saveCoverToFolder;
|
||||
currentEmbedLyrics = $settings.embedLyrics;
|
||||
currentSaveLrcFile = $settings.saveLrcFile;
|
||||
currentCoverImageQuality = $settings.coverImageQuality;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
@@ -34,6 +49,11 @@
|
||||
currentDeezerConcurrency = $settings.deezerConcurrency;
|
||||
currentDeezerFormat = $settings.deezerFormat;
|
||||
currentDeezerOverwrite = $settings.deezerOverwrite;
|
||||
currentEmbedCoverArt = $settings.embedCoverArt;
|
||||
currentSaveCoverToFolder = $settings.saveCoverToFolder;
|
||||
currentEmbedLyrics = $settings.embedLyrics;
|
||||
currentSaveLrcFile = $settings.saveLrcFile;
|
||||
currentCoverImageQuality = $settings.coverImageQuality;
|
||||
});
|
||||
|
||||
async function selectMusicFolder() {
|
||||
@@ -196,6 +216,66 @@
|
||||
<label for="deezer-overwrite">Overwrite existing files</label>
|
||||
</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>
|
||||
{:else if activeTab === 'advanced'}
|
||||
<section class="tab-content">
|
||||
|
||||
Reference in New Issue
Block a user