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