mirror of
https://github.com/markuryy/shark.git
synced 2025-12-12 11:41:02 +00:00
feat(downloads): add download queue management and UI
This commit is contained in:
3
bun.lock
3
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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
257
src/lib/services/deezer/queueManager.ts
Normal file
257
src/lib/services/deezer/queueManager.ts
Normal 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();
|
||||
200
src/lib/stores/downloadQueue.ts
Normal file
200
src/lib/stores/downloadQueue.ts
Normal 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();
|
||||
@@ -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<AppSettings> = writable(defaultSettings);
|
||||
export async function loadSettings(): Promise<void> {
|
||||
const musicFolder = await store.get<string>('musicFolder');
|
||||
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({
|
||||
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
|
||||
export async function getMusicFolder(): Promise<string | null> {
|
||||
return await store.get<string>('musicFolder');
|
||||
return (await store.get<string>('musicFolder')) ?? null;
|
||||
}
|
||||
|
||||
// Get playlists folder setting
|
||||
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
|
||||
|
||||
@@ -44,6 +44,10 @@
|
||||
<img src="/icons/laptop.png" alt="" class="nav-icon" />
|
||||
Library
|
||||
</a>
|
||||
<a href="/downloads" class="nav-item">
|
||||
<img src="/icons/cd-audio.png" alt="" class="nav-icon" />
|
||||
Downloads
|
||||
</a>
|
||||
<details class="nav-collapsible">
|
||||
<summary class="nav-item">
|
||||
<img src="/icons/world-star.png" alt="" class="nav-icon" />
|
||||
|
||||
189
src/routes/downloads/+page.svelte
Normal file
189
src/routes/downloads/+page.svelte
Normal 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>
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { deezerAuth, loadDeezerAuth, saveArl, saveUser, clearDeezerAuth } from '$lib/stores/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';
|
||||
|
||||
let arlInput = $state('');
|
||||
@@ -12,13 +13,13 @@
|
||||
let testingAuth = $state(false);
|
||||
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 isFetchingTrack = $state(false);
|
||||
let isDownloading = $state(false);
|
||||
let isAddingToQueue = $state(false);
|
||||
let trackInfo = $state<any>(null);
|
||||
let downloadStatus = $state('');
|
||||
let downloadError = $state('');
|
||||
let queueStatus = $state('');
|
||||
let queueError = $state('');
|
||||
|
||||
onMount(async () => {
|
||||
await loadDeezerAuth();
|
||||
@@ -86,12 +87,12 @@
|
||||
|
||||
async function fetchTrackInfo() {
|
||||
if (!$deezerAuth.arl || !$deezerAuth.user) {
|
||||
downloadError = 'Not logged in';
|
||||
queueError = 'Not logged in';
|
||||
return;
|
||||
}
|
||||
|
||||
isFetchingTrack = true;
|
||||
downloadError = '';
|
||||
queueError = '';
|
||||
trackInfo = null;
|
||||
|
||||
try {
|
||||
@@ -106,42 +107,28 @@
|
||||
trackInfo = trackData;
|
||||
} catch (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 {
|
||||
isFetchingTrack = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadTrackNow() {
|
||||
async function addTrackToQueue() {
|
||||
if (!trackInfo) {
|
||||
downloadError = 'Please fetch track info first';
|
||||
queueError = 'Please fetch track info first';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$settings.musicFolder) {
|
||||
downloadError = 'Please set a music folder in Settings first';
|
||||
queueError = 'Please set a music folder in Settings first';
|
||||
return;
|
||||
}
|
||||
|
||||
isDownloading = true;
|
||||
downloadStatus = 'Getting download URL...';
|
||||
downloadError = '';
|
||||
isAddingToQueue = true;
|
||||
queueStatus = '';
|
||||
queueError = '';
|
||||
|
||||
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
|
||||
const track = {
|
||||
id: trackInfo.SNG_ID,
|
||||
@@ -151,7 +138,7 @@
|
||||
artists: [trackInfo.ART_NAME],
|
||||
album: trackInfo.ALB_TITLE,
|
||||
albumId: trackInfo.ALB_ID,
|
||||
albumArtist: trackInfo.ART_NAME, // Simplified for test
|
||||
albumArtist: trackInfo.ART_NAME,
|
||||
albumArtistId: trackInfo.ART_ID,
|
||||
trackNumber: trackInfo.TRACK_NUMBER || 1,
|
||||
discNumber: trackInfo.DISK_NUMBER || 1,
|
||||
@@ -162,23 +149,28 @@
|
||||
trackToken: trackInfo.TRACK_TOKEN
|
||||
};
|
||||
|
||||
// Download track
|
||||
const filePath = await downloadTrack(
|
||||
track,
|
||||
downloadURL,
|
||||
$settings.musicFolder,
|
||||
format
|
||||
);
|
||||
// Add to queue
|
||||
await addToQueue({
|
||||
source: 'deezer',
|
||||
type: 'track',
|
||||
title: track.title,
|
||||
artist: track.artist,
|
||||
totalTracks: 1,
|
||||
downloadObject: track
|
||||
});
|
||||
|
||||
downloadStatus = `✓ Downloaded successfully to: ${filePath}`;
|
||||
console.log('Download complete:', filePath);
|
||||
queueStatus = '✓ Added to download queue!';
|
||||
|
||||
// Navigate to downloads page after brief delay
|
||||
setTimeout(() => {
|
||||
goto('/downloads');
|
||||
}, 1000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Download error:', error);
|
||||
downloadError = error instanceof Error ? error.message : 'Download failed';
|
||||
downloadStatus = '';
|
||||
console.error('Queue error:', error);
|
||||
queueError = error instanceof Error ? error.message : 'Failed to add to queue';
|
||||
} finally {
|
||||
isDownloading = false;
|
||||
isAddingToQueue = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -285,13 +277,13 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Test Track Download -->
|
||||
<!-- Add Track to Queue -->
|
||||
<section class="window test-section">
|
||||
<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 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">
|
||||
<label for="track-id">Track ID (from Deezer URL)</label>
|
||||
@@ -300,7 +292,7 @@
|
||||
type="text"
|
||||
bind:value={trackIdInput}
|
||||
placeholder="e.g., 3135556"
|
||||
disabled={isFetchingTrack || isDownloading}
|
||||
disabled={isFetchingTrack || isAddingToQueue}
|
||||
/>
|
||||
<small class="help-text">Default: 3135556 (Daft Punk - One More Time)</small>
|
||||
</div>
|
||||
@@ -313,31 +305,31 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if downloadStatus}
|
||||
{#if queueStatus}
|
||||
<div class="success-message">
|
||||
{downloadStatus}
|
||||
{queueStatus}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if downloadError}
|
||||
{#if queueError}
|
||||
<div class="error-message">
|
||||
⚠ {downloadError}
|
||||
⚠ {queueError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="button-row">
|
||||
<button onclick={fetchTrackInfo} disabled={isFetchingTrack || isDownloading}>
|
||||
<button onclick={fetchTrackInfo} disabled={isFetchingTrack || isAddingToQueue}>
|
||||
{isFetchingTrack ? 'Fetching...' : 'Fetch Track Info'}
|
||||
</button>
|
||||
|
||||
<button onclick={downloadTrackNow} disabled={!trackInfo || isDownloading || !$settings.musicFolder}>
|
||||
{isDownloading ? 'Downloading...' : 'Download'}
|
||||
<button onclick={addTrackToQueue} disabled={!trackInfo || isAddingToQueue || !$settings.musicFolder}>
|
||||
{isAddingToQueue ? 'Adding...' : 'Add to Queue'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if !$settings.musicFolder}
|
||||
<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>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,37 @@
|
||||
<script lang="ts">
|
||||
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';
|
||||
|
||||
let currentMusicFolder = $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 () => {
|
||||
await loadSettings();
|
||||
currentMusicFolder = $settings.musicFolder;
|
||||
currentPlaylistsFolder = $settings.playlistsFolder;
|
||||
currentDeezerConcurrency = $settings.deezerConcurrency;
|
||||
currentDeezerFormat = $settings.deezerFormat;
|
||||
currentDeezerOverwrite = $settings.deezerOverwrite;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
currentMusicFolder = $settings.musicFolder;
|
||||
currentPlaylistsFolder = $settings.playlistsFolder;
|
||||
currentDeezerConcurrency = $settings.deezerConcurrency;
|
||||
currentDeezerFormat = $settings.deezerFormat;
|
||||
currentDeezerOverwrite = $settings.deezerOverwrite;
|
||||
});
|
||||
|
||||
async function selectMusicFolder() {
|
||||
@@ -88,6 +105,52 @@
|
||||
{/if}
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<style>
|
||||
@@ -141,4 +204,36 @@
|
||||
opacity: 0.7;
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user