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

@@ -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" />

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">
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>

View File

@@ -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>