diff --git a/bun.lock b/bun.lock index d07d03d..bbb56e6 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "blowfish-node": "^1.1.4", "browser-id3-writer": "^6.3.1", "music-metadata": "^11.9.0", + "uuid": "^13.0.0", }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.6", @@ -290,6 +291,8 @@ "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], + "uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], + "vite": ["vite@6.3.6", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA=="], "vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="], diff --git a/package.json b/package.json index e61600f..6d488c2 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "@tauri-apps/plugin-store": "~2", "blowfish-node": "^1.1.4", "browser-id3-writer": "^6.3.1", - "music-metadata": "^11.9.0" + "music-metadata": "^11.9.0", + "uuid": "^13.0.0" }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.6", diff --git a/src/lib/services/deezer/queueManager.ts b/src/lib/services/deezer/queueManager.ts new file mode 100644 index 0000000..1174de1 --- /dev/null +++ b/src/lib/services/deezer/queueManager.ts @@ -0,0 +1,257 @@ +/** + * Deezer Queue Manager + * Handles download queue processing with configurable concurrency + */ + +import { downloadTrack } from './downloader'; +import { deezerAPI } from '../deezer'; +import { + downloadQueue, + updateQueueItem, + setCurrentJob, + getNextQueuedItem, + type QueueItem +} from '$lib/stores/downloadQueue'; +import { settings } from '$lib/stores/settings'; +import { get } from 'svelte/store'; +import type { DeezerTrack } from '$lib/types/deezer'; + +export class DeezerQueueManager { + private isProcessing = false; + private abortController: AbortController | null = null; + + /** + * Start processing the queue + */ + async start(): Promise { + if (this.isProcessing) { + console.log('[DeezerQueueManager] Already processing queue'); + return; + } + + this.isProcessing = true; + this.abortController = new AbortController(); + console.log('[DeezerQueueManager] Starting queue processor'); + + try { + await this.processQueue(); + } catch (error) { + console.error('[DeezerQueueManager] Queue processing error:', error); + } finally { + this.isProcessing = false; + this.abortController = null; + } + } + + /** + * Stop processing the queue + */ + stop(): void { + console.log('[DeezerQueueManager] Stopping queue processor'); + this.isProcessing = false; + if (this.abortController) { + this.abortController.abort(); + this.abortController = null; + } + } + + /** + * Main queue processing loop + */ + private async processQueue(): Promise { + 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; + } + + console.log(`[DeezerQueueManager] Processing item: ${nextItem.title}`); + + // Set as current job + await setCurrentJob(nextItem.id); + await updateQueueItem(nextItem.id, { status: 'downloading' }); + + try { + // Process based on type + if (nextItem.type === 'track') { + await this.downloadSingleTrack(nextItem); + } else if (nextItem.type === 'album' || nextItem.type === 'playlist') { + await this.downloadCollection(nextItem); + } + + // Mark as completed + await updateQueueItem(nextItem.id, { + status: 'completed', + progress: 100 + }); + } catch (error) { + console.error(`[DeezerQueueManager] Error downloading ${nextItem.title}:`, error); + await updateQueueItem(nextItem.id, { + status: 'failed', + error: error instanceof Error ? error.message : 'Unknown error' + }); + } + + // Clear current job + await setCurrentJob(null); + } + } + + /** + * Download a single track + */ + private async downloadSingleTrack(item: QueueItem): Promise { + const track = item.downloadObject as DeezerTrack; + const appSettings = get(settings); + + if (!appSettings.musicFolder) { + throw new Error('Music folder not configured'); + } + + // Get user data for license token + const userData = await deezerAPI.getUserData(); + const licenseToken = userData.USER?.OPTIONS?.license_token; + + if (!licenseToken) { + throw new Error('License token not found'); + } + + // Get download URL + const downloadURL = await deezerAPI.getTrackDownloadUrl( + track.trackToken!, + appSettings.deezerFormat, + licenseToken + ); + + if (!downloadURL) { + throw new Error('Failed to get download URL'); + } + + // Update progress + await updateQueueItem(item.id, { + currentTrack: { + title: track.title, + artist: track.artist, + progress: 0 + } + }); + + // Download the track + const filePath = await downloadTrack( + track, + downloadURL, + appSettings.musicFolder, + appSettings.deezerFormat, + (progress) => { + // Update progress in queue + updateQueueItem(item.id, { + progress: progress.percentage, + currentTrack: { + title: track.title, + artist: track.artist, + progress: progress.percentage + } + }); + } + ); + + console.log(`[DeezerQueueManager] Downloaded: ${filePath}`); + } + + /** + * Download a collection (album/playlist) with concurrency control + */ + private async downloadCollection(item: QueueItem): Promise { + const tracks = item.downloadObject as DeezerTrack[]; + const appSettings = get(settings); + const concurrency = appSettings.deezerConcurrency; + + console.log(`[DeezerQueueManager] Downloading collection with concurrency: ${concurrency}`); + + const results: (string | Error)[] = []; + let completedCount = 0; + let failedCount = 0; + + // Simple concurrent queue implementation + const queue = [...tracks]; + const running: Promise[] = []; + + const downloadNext = async (): Promise => { + const track = queue.shift(); + if (!track) return; + + try { + await updateQueueItem(item.id, { + currentTrack: { + title: track.title, + artist: track.artist, + progress: 0 + } + }); + + const userData = await deezerAPI.getUserData(); + const licenseToken = userData.USER?.OPTIONS?.license_token; + + if (!licenseToken) { + throw new Error('License token not found'); + } + + const downloadURL = await deezerAPI.getTrackDownloadUrl( + track.trackToken!, + appSettings.deezerFormat, + licenseToken + ); + + if (!downloadURL) { + throw new Error('Failed to get download URL'); + } + + const filePath = await downloadTrack( + track, + downloadURL, + appSettings.musicFolder!, + appSettings.deezerFormat + ); + + results.push(filePath); + completedCount++; + } catch (error) { + console.error(`[DeezerQueueManager] Error downloading track ${track.title}:`, error); + results.push(error as Error); + failedCount++; + } + + // Update progress + const totalTracks = item.totalTracks; + const progress = ((completedCount + failedCount) / totalTracks) * 100; + + await updateQueueItem(item.id, { + progress, + completedTracks: completedCount, + failedTracks: failedCount + }); + + // Continue with next track + if (queue.length > 0) { + await downloadNext(); + } + }; + + // Start initial batch of concurrent downloads + for (let i = 0; i < Math.min(concurrency, tracks.length); i++) { + running.push(downloadNext()); + } + + // Wait for all downloads to complete + await Promise.all(running); + + console.log(`[DeezerQueueManager] Collection complete: ${completedCount} succeeded, ${failedCount} failed`); + } +} + +// Singleton instance +export const deezerQueueManager = new DeezerQueueManager(); diff --git a/src/lib/stores/downloadQueue.ts b/src/lib/stores/downloadQueue.ts new file mode 100644 index 0000000..5fbf710 --- /dev/null +++ b/src/lib/stores/downloadQueue.ts @@ -0,0 +1,200 @@ +import { LazyStore } from '@tauri-apps/plugin-store'; +import { writable, type Writable } from 'svelte/store'; +import { v4 as uuidv4 } from 'uuid'; + +// Queue item that supports multiple download sources +export interface QueueItem { + id: string; + source: 'deezer' | 'soulseek'; + type: 'track' | 'album' | 'playlist'; + title: string; + artist: string; + status: 'queued' | 'downloading' | 'completed' | 'failed' | 'paused'; + addedAt: number; + progress: number; // 0-100 + totalTracks: number; + completedTracks: number; + failedTracks: number; + currentTrack?: { + title: string; + artist: string; + progress: number; + }; + error?: string; + downloadObject: any; // Source-specific data (DeezerTrack, Album, etc.) +} + +// Queue state +export interface DownloadQueueState { + queueOrder: string[]; // Array of UUIDs in processing order + queue: Record; // Map of UUID → QueueItem + currentJob: string | null; // UUID of currently downloading item +} + +// Initialize the store +const store = new LazyStore('downloadQueue.json'); + +// Default state +const defaultState: DownloadQueueState = { + queueOrder: [], + queue: {}, + currentJob: null +}; + +// Create writable store for reactive UI +export const downloadQueue: Writable = writable(defaultState); + +// Load queue from disk +export async function loadDownloadQueue(): Promise { + const queueOrder = await store.get('queueOrder'); + const queue = await store.get>('queue'); + const currentJob = await store.get('currentJob'); + + downloadQueue.set({ + queueOrder: queueOrder ?? [], + queue: queue ?? {}, + currentJob: currentJob ?? null + }); +} + +// Save queue to disk +async function saveQueue(state: DownloadQueueState): Promise { + await store.set('queueOrder', state.queueOrder); + await store.set('queue', state.queue); + await store.set('currentJob', state.currentJob); + await store.save(); +} + +// Add item to queue +export async function addToQueue(item: Omit): Promise { + const id = uuidv4(); + + const queueItem: QueueItem = { + ...item, + id, + status: 'queued', + addedAt: Date.now(), + progress: 0, + completedTracks: 0, + failedTracks: 0 + }; + + downloadQueue.update(state => { + const newState = { + ...state, + queueOrder: [...state.queueOrder, id], + queue: { + ...state.queue, + [id]: queueItem + } + }; + + saveQueue(newState); + return newState; + }); + + return id; +} + +// Remove item from queue +export async function removeFromQueue(id: string): Promise { + downloadQueue.update(state => { + const newQueue = { ...state.queue }; + delete newQueue[id]; + + const newState = { + ...state, + queueOrder: state.queueOrder.filter(qid => qid !== id), + queue: newQueue, + currentJob: state.currentJob === id ? null : state.currentJob + }; + + saveQueue(newState); + return newState; + }); +} + +// Update queue item +export async function updateQueueItem(id: string, updates: Partial): Promise { + downloadQueue.update(state => { + if (!state.queue[id]) return state; + + const newState = { + ...state, + queue: { + ...state.queue, + [id]: { + ...state.queue[id], + ...updates + } + } + }; + + saveQueue(newState); + return newState; + }); +} + +// Set current job +export async function setCurrentJob(id: string | null): Promise { + downloadQueue.update(state => { + const newState = { + ...state, + currentJob: id + }; + + saveQueue(newState); + return newState; + }); +} + +// Clear completed downloads +export async function clearCompleted(): Promise { + downloadQueue.update(state => { + const newQueue: Record = {}; + const newOrder: string[] = []; + + for (const id of state.queueOrder) { + const item = state.queue[id]; + if (item && item.status !== 'completed') { + newQueue[id] = item; + newOrder.push(id); + } + } + + const newState = { + ...state, + queueOrder: newOrder, + queue: newQueue + }; + + saveQueue(newState); + return newState; + }); +} + +// Clear all downloads +export async function clearAll(): Promise { + const newState = defaultState; + downloadQueue.set(newState); + await saveQueue(newState); +} + +// Get next queued item +export function getNextQueuedItem(state: DownloadQueueState): QueueItem | null { + if (state.currentJob !== null) { + return null; // Already processing something + } + + for (const id of state.queueOrder) { + const item = state.queue[id]; + if (item && item.status === 'queued') { + return item; + } + } + + return null; +} + +// Initialize on module load +loadDownloadQueue(); diff --git a/src/lib/stores/settings.ts b/src/lib/stores/settings.ts index a3550c8..869abb7 100644 --- a/src/lib/stores/settings.ts +++ b/src/lib/stores/settings.ts @@ -5,6 +5,10 @@ import { writable, type Writable } from 'svelte/store'; export interface AppSettings { musicFolder: string | null; playlistsFolder: string | null; + // Deezer download settings + deezerConcurrency: number; + deezerFormat: 'FLAC' | 'MP3_320' | 'MP3_128'; + deezerOverwrite: boolean; } // Initialize the store with settings.json @@ -13,7 +17,10 @@ const store = new LazyStore('settings.json'); // Default settings const defaultSettings: AppSettings = { musicFolder: null, - playlistsFolder: null + playlistsFolder: null, + deezerConcurrency: 1, + deezerFormat: 'FLAC', + deezerOverwrite: false }; // Create a writable store for reactive UI updates @@ -23,10 +30,16 @@ export const settings: Writable = writable(defaultSettings); export async function loadSettings(): Promise { const musicFolder = await store.get('musicFolder'); const playlistsFolder = await store.get('playlistsFolder'); + const deezerConcurrency = await store.get('deezerConcurrency'); + const deezerFormat = await store.get<'FLAC' | 'MP3_320' | 'MP3_128'>('deezerFormat'); + const deezerOverwrite = await store.get('deezerOverwrite'); settings.set({ musicFolder: musicFolder ?? null, - playlistsFolder: playlistsFolder ?? null + playlistsFolder: playlistsFolder ?? null, + deezerConcurrency: deezerConcurrency ?? 1, + deezerFormat: deezerFormat ?? 'FLAC', + deezerOverwrite: deezerOverwrite ?? false }); } @@ -62,12 +75,45 @@ export async function setPlaylistsFolder(path: string | null): Promise { // Get music folder setting export async function getMusicFolder(): Promise { - return await store.get('musicFolder'); + return (await store.get('musicFolder')) ?? null; } // Get playlists folder setting export async function getPlaylistsFolder(): Promise { - return await store.get('playlistsFolder'); + return (await store.get('playlistsFolder')) ?? null; +} + +// Save Deezer concurrency setting +export async function setDeezerConcurrency(value: number): Promise { + await store.set('deezerConcurrency', value); + await store.save(); + + settings.update(s => ({ + ...s, + deezerConcurrency: value + })); +} + +// Save Deezer format setting +export async function setDeezerFormat(value: 'FLAC' | 'MP3_320' | 'MP3_128'): Promise { + await store.set('deezerFormat', value); + await store.save(); + + settings.update(s => ({ + ...s, + deezerFormat: value + })); +} + +// Save Deezer overwrite setting +export async function setDeezerOverwrite(value: boolean): Promise { + await store.set('deezerOverwrite', value); + await store.save(); + + settings.update(s => ({ + ...s, + deezerOverwrite: value + })); } // Initialize settings on app start diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 1c00b0b..2342d1c 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -44,6 +44,10 @@ Library + + + Downloads +