mirror of
https://github.com/markuryy/shark.git
synced 2025-12-12 19:51:01 +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
|
// 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 });
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
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 {
|
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');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
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;
|
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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user