feat(downloads): add download queue management and UI

This commit is contained in:
2025-10-01 09:48:03 -04:00
parent 759ebc71f6
commit ef4b85433c
9 changed files with 847 additions and 60 deletions

View File

@@ -14,6 +14,7 @@
"blowfish-node": "^1.1.4", "blowfish-node": "^1.1.4",
"browser-id3-writer": "^6.3.1", "browser-id3-writer": "^6.3.1",
"music-metadata": "^11.9.0", "music-metadata": "^11.9.0",
"uuid": "^13.0.0",
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-static": "^3.0.6", "@sveltejs/adapter-static": "^3.0.6",
@@ -290,6 +291,8 @@
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], "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=="], "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=="], "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=="],

View File

@@ -22,7 +22,8 @@
"@tauri-apps/plugin-store": "~2", "@tauri-apps/plugin-store": "~2",
"blowfish-node": "^1.1.4", "blowfish-node": "^1.1.4",
"browser-id3-writer": "^6.3.1", "browser-id3-writer": "^6.3.1",
"music-metadata": "^11.9.0" "music-metadata": "^11.9.0",
"uuid": "^13.0.0"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-static": "^3.0.6", "@sveltejs/adapter-static": "^3.0.6",

View File

@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void>[] = [];
const downloadNext = async (): Promise<void> => {
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();

View File

@@ -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<string, QueueItem>; // 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<DownloadQueueState> = writable(defaultState);
// Load queue from disk
export async function loadDownloadQueue(): Promise<void> {
const queueOrder = await store.get<string[]>('queueOrder');
const queue = await store.get<Record<string, QueueItem>>('queue');
const currentJob = await store.get<string>('currentJob');
downloadQueue.set({
queueOrder: queueOrder ?? [],
queue: queue ?? {},
currentJob: currentJob ?? null
});
}
// Save queue to disk
async function saveQueue(state: DownloadQueueState): Promise<void> {
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<QueueItem, 'id' | 'status' | 'addedAt' | 'progress' | 'completedTracks' | 'failedTracks'>): Promise<string> {
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<void> {
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<QueueItem>): Promise<void> {
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<void> {
downloadQueue.update(state => {
const newState = {
...state,
currentJob: id
};
saveQueue(newState);
return newState;
});
}
// Clear completed downloads
export async function clearCompleted(): Promise<void> {
downloadQueue.update(state => {
const newQueue: Record<string, QueueItem> = {};
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<void> {
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();

View File

@@ -5,6 +5,10 @@ import { writable, type Writable } from 'svelte/store';
export interface AppSettings { export interface AppSettings {
musicFolder: string | null; musicFolder: string | null;
playlistsFolder: string | null; playlistsFolder: string | null;
// Deezer download settings
deezerConcurrency: number;
deezerFormat: 'FLAC' | 'MP3_320' | 'MP3_128';
deezerOverwrite: boolean;
} }
// Initialize the store with settings.json // Initialize the store with settings.json
@@ -13,7 +17,10 @@ const store = new LazyStore('settings.json');
// Default settings // Default settings
const defaultSettings: AppSettings = { const defaultSettings: AppSettings = {
musicFolder: null, musicFolder: null,
playlistsFolder: null playlistsFolder: null,
deezerConcurrency: 1,
deezerFormat: 'FLAC',
deezerOverwrite: false
}; };
// Create a writable store for reactive UI updates // Create a writable store for reactive UI updates
@@ -23,10 +30,16 @@ export const settings: Writable<AppSettings> = writable(defaultSettings);
export async function loadSettings(): Promise<void> { export async function loadSettings(): Promise<void> {
const musicFolder = await store.get<string>('musicFolder'); const musicFolder = await store.get<string>('musicFolder');
const playlistsFolder = await store.get<string>('playlistsFolder'); const playlistsFolder = await store.get<string>('playlistsFolder');
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');
settings.set({ settings.set({
musicFolder: musicFolder ?? null, 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<void> {
// Get music folder setting // Get music folder setting
export async function getMusicFolder(): Promise<string | null> { export async function getMusicFolder(): Promise<string | null> {
return await store.get<string>('musicFolder'); return (await store.get<string>('musicFolder')) ?? null;
} }
// Get playlists folder setting // Get playlists folder setting
export async function getPlaylistsFolder(): Promise<string | null> { export async function getPlaylistsFolder(): Promise<string | null> {
return await store.get<string>('playlistsFolder'); return (await store.get<string>('playlistsFolder')) ?? null;
}
// Save Deezer concurrency setting
export async function setDeezerConcurrency(value: number): Promise<void> {
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<void> {
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<void> {
await store.set('deezerOverwrite', value);
await store.save();
settings.update(s => ({
...s,
deezerOverwrite: value
}));
} }
// Initialize settings on app start // Initialize settings on app start

View File

@@ -44,6 +44,10 @@
<img src="/icons/laptop.png" alt="" class="nav-icon" /> <img src="/icons/laptop.png" alt="" class="nav-icon" />
Library Library
</a> </a>
<a href="/downloads" class="nav-item">
<img src="/icons/cd-audio.png" alt="" class="nav-icon" />
Downloads
</a>
<details class="nav-collapsible"> <details class="nav-collapsible">
<summary class="nav-item"> <summary class="nav-item">
<img src="/icons/world-star.png" alt="" class="nav-icon" /> <img src="/icons/world-star.png" alt="" class="nav-icon" />

View File

@@ -0,0 +1,189 @@
<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[]>([]);
// Subscribe to queue changes
let unsubscribe: (() => void) | null = null;
onMount(() => {
unsubscribe = downloadQueue.subscribe(state => {
// Convert queue to array ordered by queueOrder
queueItems = state.queueOrder
.map(id => state.queue[id])
.filter(item => item !== undefined);
});
// Start queue processor
deezerQueueManager.start();
});
onDestroy(() => {
if (unsubscribe) {
unsubscribe();
}
});
async function handleClearCompleted() {
await clearCompleted();
}
async function handleRemove(id: string) {
await removeFromQueue(id);
}
function formatProgress(item: QueueItem): string {
if (item.status === 'completed') return 'Done';
if (item.status === 'queued' || item.status === 'paused') return 'Queued';
if (item.status === 'failed') return 'Failed';
return `${Math.round(item.progress)}%`;
}
function getProgressBarStyle(progress: number): string {
return `width: ${progress}%`;
}
</script>
<div class="downloads-page">
<div class="header">
<h2>Downloads</h2>
<div class="header-actions">
<button onclick={handleClearCompleted} disabled={queueItems.every(i => i.status !== 'completed')}>
Clear Completed
</button>
</div>
</div>
{#if queueItems.length === 0}
<div class="empty-state">
<p>No downloads in queue</p>
<p class="help-text">Add tracks, albums, or playlists from services to start downloading</p>
</div>
{:else}
<div class="sunken-panel" style="overflow: auto; flex: 1;">
<table class="interactive">
<thead>
<tr>
<th class="col-title">Title</th>
<th class="col-artist">Artist</th>
<th class="col-progress">Progress</th>
<th class="col-source">Source</th>
<th class="col-actions"></th>
</tr>
</thead>
<tbody>
{#each queueItems as item (item.id)}
<tr class={item.status === 'downloading' ? 'highlighted' : ''}>
<td class="col-title">{item.title}</td>
<td class="col-artist">{item.artist}</td>
<td class="col-progress">
{#if item.status === 'downloading'}
<div class="progress-indicator">
<span class="progress-indicator-bar" style={getProgressBarStyle(item.progress)}></span>
</div>
{:else}
{formatProgress(item)}
{/if}
</td>
<td class="col-source">{item.source}</td>
<td class="col-actions">
<button
class="remove-btn"
onclick={() => handleRemove(item.id)}
disabled={item.status === 'downloading'}
title="Remove from queue"
>
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<style>
.downloads-page {
height: 100%;
display: flex;
flex-direction: column;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
h2 {
margin: 0;
font-size: 1.4em;
}
.header-actions {
display: flex;
gap: 8px;
}
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: light-dark(#666, #999);
}
.empty-state p {
margin: 8px 0;
}
.help-text {
font-size: 0.9em;
opacity: 0.7;
}
table {
width: 100%;
table-layout: fixed;
}
.col-title {
width: 35%;
}
.col-artist {
width: 25%;
}
.col-progress {
width: 20%;
}
.col-source {
width: 10%;
}
.col-actions {
width: 10%;
text-align: center;
}
.remove-btn {
padding: 2px 6px;
font-size: 12px;
min-width: 24px;
height: 20px;
line-height: 1;
}
.remove-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>

View File

@@ -1,8 +1,9 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { deezerAuth, loadDeezerAuth, saveArl, saveUser, clearDeezerAuth } from '$lib/stores/deezer'; import { deezerAuth, loadDeezerAuth, saveArl, saveUser, clearDeezerAuth } from '$lib/stores/deezer';
import { deezerAPI } from '$lib/services/deezer'; import { deezerAPI } from '$lib/services/deezer';
import { downloadTrack } from '$lib/services/deezer/downloader'; import { addToQueue } from '$lib/stores/downloadQueue';
import { settings } from '$lib/stores/settings'; import { settings } from '$lib/stores/settings';
let arlInput = $state(''); let arlInput = $state('');
@@ -12,13 +13,13 @@
let testingAuth = $state(false); let testingAuth = $state(false);
let authTestResult = $state<string | null>(null); let authTestResult = $state<string | null>(null);
// Track download test // Track add to queue test
let trackIdInput = $state('3135556'); // Default: Daft Punk - One More Time let trackIdInput = $state('3135556'); // Default: Daft Punk - One More Time
let isFetchingTrack = $state(false); let isFetchingTrack = $state(false);
let isDownloading = $state(false); let isAddingToQueue = $state(false);
let trackInfo = $state<any>(null); let trackInfo = $state<any>(null);
let downloadStatus = $state(''); let queueStatus = $state('');
let downloadError = $state(''); let queueError = $state('');
onMount(async () => { onMount(async () => {
await loadDeezerAuth(); await loadDeezerAuth();
@@ -86,12 +87,12 @@
async function fetchTrackInfo() { async function fetchTrackInfo() {
if (!$deezerAuth.arl || !$deezerAuth.user) { if (!$deezerAuth.arl || !$deezerAuth.user) {
downloadError = 'Not logged in'; queueError = 'Not logged in';
return; return;
} }
isFetchingTrack = true; isFetchingTrack = true;
downloadError = ''; queueError = '';
trackInfo = null; trackInfo = null;
try { try {
@@ -106,42 +107,28 @@
trackInfo = trackData; trackInfo = trackData;
} catch (error) { } catch (error) {
console.error('Fetch error:', error); console.error('Fetch error:', error);
downloadError = error instanceof Error ? error.message : 'Failed to fetch track'; queueError = error instanceof Error ? error.message : 'Failed to fetch track';
} finally { } finally {
isFetchingTrack = false; isFetchingTrack = false;
} }
} }
async function downloadTrackNow() { async function addTrackToQueue() {
if (!trackInfo) { if (!trackInfo) {
downloadError = 'Please fetch track info first'; queueError = 'Please fetch track info first';
return; return;
} }
if (!$settings.musicFolder) { if (!$settings.musicFolder) {
downloadError = 'Please set a music folder in Settings first'; queueError = 'Please set a music folder in Settings first';
return; return;
} }
isDownloading = true; isAddingToQueue = true;
downloadStatus = 'Getting download URL...'; queueStatus = '';
downloadError = ''; queueError = '';
try { try {
const format = $deezerAuth.user!.can_stream_lossless ? 'FLAC' : 'MP3_320';
const downloadURL = await deezerAPI.getTrackDownloadUrl(
trackInfo.TRACK_TOKEN,
format,
$deezerAuth.user!.license_token!
);
if (!downloadURL) {
throw new Error('Could not get download URL');
}
downloadStatus = 'Downloading and decrypting...';
console.log('Download URL:', downloadURL);
// Build track object // Build track object
const track = { const track = {
id: trackInfo.SNG_ID, id: trackInfo.SNG_ID,
@@ -151,7 +138,7 @@
artists: [trackInfo.ART_NAME], artists: [trackInfo.ART_NAME],
album: trackInfo.ALB_TITLE, album: trackInfo.ALB_TITLE,
albumId: trackInfo.ALB_ID, albumId: trackInfo.ALB_ID,
albumArtist: trackInfo.ART_NAME, // Simplified for test albumArtist: trackInfo.ART_NAME,
albumArtistId: trackInfo.ART_ID, albumArtistId: trackInfo.ART_ID,
trackNumber: trackInfo.TRACK_NUMBER || 1, trackNumber: trackInfo.TRACK_NUMBER || 1,
discNumber: trackInfo.DISK_NUMBER || 1, discNumber: trackInfo.DISK_NUMBER || 1,
@@ -162,23 +149,28 @@
trackToken: trackInfo.TRACK_TOKEN trackToken: trackInfo.TRACK_TOKEN
}; };
// Download track // Add to queue
const filePath = await downloadTrack( await addToQueue({
track, source: 'deezer',
downloadURL, type: 'track',
$settings.musicFolder, title: track.title,
format artist: track.artist,
); totalTracks: 1,
downloadObject: track
});
downloadStatus = `✓ Downloaded successfully to: ${filePath}`; queueStatus = '✓ Added to download queue!';
console.log('Download complete:', filePath);
// Navigate to downloads page after brief delay
setTimeout(() => {
goto('/downloads');
}, 1000);
} catch (error) { } catch (error) {
console.error('Download error:', error); console.error('Queue error:', error);
downloadError = error instanceof Error ? error.message : 'Download failed'; queueError = error instanceof Error ? error.message : 'Failed to add to queue';
downloadStatus = '';
} finally { } finally {
isDownloading = false; isAddingToQueue = false;
} }
} }
</script> </script>
@@ -285,13 +277,13 @@
</div> </div>
</section> </section>
<!-- Test Track Download --> <!-- Add Track to Queue -->
<section class="window test-section"> <section class="window test-section">
<div class="title-bar"> <div class="title-bar">
<div class="title-bar-text">Test Track Download</div> <div class="title-bar-text">Add Track to Download Queue</div>
</div> </div>
<div class="window-body"> <div class="window-body">
<p>Download a test track to verify decryption is working:</p> <p>Add a track to the download queue:</p>
<div class="field-row-stacked"> <div class="field-row-stacked">
<label for="track-id">Track ID (from Deezer URL)</label> <label for="track-id">Track ID (from Deezer URL)</label>
@@ -300,7 +292,7 @@
type="text" type="text"
bind:value={trackIdInput} bind:value={trackIdInput}
placeholder="e.g., 3135556" placeholder="e.g., 3135556"
disabled={isFetchingTrack || isDownloading} disabled={isFetchingTrack || isAddingToQueue}
/> />
<small class="help-text">Default: 3135556 (Daft Punk - One More Time)</small> <small class="help-text">Default: 3135556 (Daft Punk - One More Time)</small>
</div> </div>
@@ -313,31 +305,31 @@
</div> </div>
{/if} {/if}
{#if downloadStatus} {#if queueStatus}
<div class="success-message"> <div class="success-message">
{downloadStatus} {queueStatus}
</div> </div>
{/if} {/if}
{#if downloadError} {#if queueError}
<div class="error-message"> <div class="error-message">
{downloadError} {queueError}
</div> </div>
{/if} {/if}
<div class="button-row"> <div class="button-row">
<button onclick={fetchTrackInfo} disabled={isFetchingTrack || isDownloading}> <button onclick={fetchTrackInfo} disabled={isFetchingTrack || isAddingToQueue}>
{isFetchingTrack ? 'Fetching...' : 'Fetch Track Info'} {isFetchingTrack ? 'Fetching...' : 'Fetch Track Info'}
</button> </button>
<button onclick={downloadTrackNow} disabled={!trackInfo || isDownloading || !$settings.musicFolder}> <button onclick={addTrackToQueue} disabled={!trackInfo || isAddingToQueue || !$settings.musicFolder}>
{isDownloading ? 'Downloading...' : 'Download'} {isAddingToQueue ? 'Adding...' : 'Add to Queue'}
</button> </button>
</div> </div>
{#if !$settings.musicFolder} {#if !$settings.musicFolder}
<p class="help-text" style="margin-top: 8px;"> <p class="help-text" style="margin-top: 8px;">
⚠ Please set a music folder in Settings before downloading. ⚠ Please set a music folder in Settings before adding to queue.
</p> </p>
{/if} {/if}
</div> </div>

View File

@@ -1,20 +1,37 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { settings, setMusicFolder, setPlaylistsFolder, loadSettings } from '$lib/stores/settings'; import {
settings,
setMusicFolder,
setPlaylistsFolder,
setDeezerConcurrency,
setDeezerFormat,
setDeezerOverwrite,
loadSettings
} from '$lib/stores/settings';
import { open } from '@tauri-apps/plugin-dialog'; import { open } from '@tauri-apps/plugin-dialog';
let currentMusicFolder = $state<string | null>(null); let currentMusicFolder = $state<string | null>(null);
let currentPlaylistsFolder = $state<string | null>(null); let currentPlaylistsFolder = $state<string | null>(null);
let currentDeezerConcurrency = $state<number>(1);
let currentDeezerFormat = $state<'FLAC' | 'MP3_320' | 'MP3_128'>('FLAC');
let currentDeezerOverwrite = $state<boolean>(false);
onMount(async () => { onMount(async () => {
await loadSettings(); await loadSettings();
currentMusicFolder = $settings.musicFolder; currentMusicFolder = $settings.musicFolder;
currentPlaylistsFolder = $settings.playlistsFolder; currentPlaylistsFolder = $settings.playlistsFolder;
currentDeezerConcurrency = $settings.deezerConcurrency;
currentDeezerFormat = $settings.deezerFormat;
currentDeezerOverwrite = $settings.deezerOverwrite;
}); });
$effect(() => { $effect(() => {
currentMusicFolder = $settings.musicFolder; currentMusicFolder = $settings.musicFolder;
currentPlaylistsFolder = $settings.playlistsFolder; currentPlaylistsFolder = $settings.playlistsFolder;
currentDeezerConcurrency = $settings.deezerConcurrency;
currentDeezerFormat = $settings.deezerFormat;
currentDeezerOverwrite = $settings.deezerOverwrite;
}); });
async function selectMusicFolder() { async function selectMusicFolder() {
@@ -88,6 +105,52 @@
{/if} {/if}
</div> </div>
</section> </section>
<section class="library-content">
<h3>Deezer Download Settings</h3>
<div class="field-row-stacked">
<label for="deezer-format">Audio Quality</label>
<select
id="deezer-format"
bind:value={currentDeezerFormat}
onchange={() => setDeezerFormat(currentDeezerFormat)}
>
<option value="FLAC">FLAC (Lossless)</option>
<option value="MP3_320">MP3 320kbps</option>
<option value="MP3_128">MP3 128kbps</option>
</select>
<small class="help-text">Select the audio quality for downloaded tracks</small>
</div>
<div class="field-row-stacked">
<label for="deezer-concurrency">Download Concurrency (tracks within album/playlist)</label>
<div class="slider-container">
<input
id="deezer-concurrency"
type="range"
min="1"
max="10"
bind:value={currentDeezerConcurrency}
onchange={() => setDeezerConcurrency(currentDeezerConcurrency)}
/>
<span class="slider-value">{currentDeezerConcurrency}</span>
</div>
<small class="help-text">
Number of tracks to download simultaneously within collections (default: 1)
</small>
</div>
<div class="field-row">
<input
id="deezer-overwrite"
type="checkbox"
bind:checked={currentDeezerOverwrite}
onchange={() => setDeezerOverwrite(currentDeezerOverwrite)}
/>
<label for="deezer-overwrite">Overwrite existing files</label>
</div>
</section>
</div> </div>
<style> <style>
@@ -141,4 +204,36 @@
opacity: 0.7; opacity: 0.7;
font-size: 0.9em; font-size: 0.9em;
} }
.field-row {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 12px;
}
.field-row label {
margin: 0;
}
select {
width: 100%;
padding: 4px;
}
.slider-container {
display: flex;
gap: 12px;
align-items: center;
}
.slider-container input[type="range"] {
flex: 1;
}
.slider-value {
min-width: 30px;
font-weight: bold;
text-align: center;
}
</style> </style>