feat(dz): add playlist download, existence check, and improved queue handling

Add ability to download entire playlists as M3U8 files, with UI
integration and per-track download actions. Implement track existence
checking to avoid duplicate downloads, respecting the overwrite setting.
Improve queue manager to sync downloaded tracks to the library
incrementally. Refactor playlist parsing and metadata reading to use the
Rust backend for better performance and accuracy. Update UI to reflect
track existence and download status in playlist views.

BREAKING CHANGE: Deezer playlist and track download logic now relies on
Rust backend for metadata and new existence checking; some APIs and
internal behaviors have changed.
This commit is contained in:
2025-10-02 19:26:12 -04:00
parent 40e72126aa
commit e1e7817c71
17 changed files with 1341 additions and 332 deletions

View File

@@ -11,6 +11,7 @@
let { children } = $props();
let playlists = $state<Playlist[]>([]);
let playlistsLoadTimestamp = $state<number>(0);
// Count active downloads (queued or downloading)
let activeDownloads = $derived(
@@ -20,12 +21,25 @@
}).length
);
onMount(async () => {
await loadSettings();
await loadPlaylists();
onMount(() => {
// Run async initialization
(async () => {
await loadSettings();
await loadPlaylists();
})();
// Start background queue processor
deezerQueueManager.start();
// Start playlist folder watcher (poll every 5 seconds)
const playlistWatchInterval = setInterval(async () => {
await checkPlaylistsUpdate();
}, 5000);
// Cleanup on unmount
return () => {
clearInterval(playlistWatchInterval);
};
});
async function loadPlaylists() {
@@ -35,10 +49,37 @@
try {
playlists = await scanPlaylists($settings.playlistsFolder);
playlistsLoadTimestamp = Date.now();
} catch (e) {
console.error('Error loading playlists:', e);
}
}
/**
* Check if playlists folder has been modified since last load
* If so, reload the playlists
*/
async function checkPlaylistsUpdate() {
if (!$settings.playlistsFolder) {
return;
}
try {
// Simple approach: just rescan periodically
// A more sophisticated approach would use fs watch APIs
const newPlaylists = await scanPlaylists($settings.playlistsFolder);
// Check if playlist count or names changed
if (newPlaylists.length !== playlists.length ||
newPlaylists.some((p, i) => p.name !== playlists[i]?.name)) {
console.log('[Sidebar] Playlists updated, refreshing...');
playlists = newPlaylists;
playlistsLoadTimestamp = Date.now();
}
} catch (e) {
// Silently fail - folder might not exist yet
}
}
</script>
<div class="app-container">
@@ -67,22 +108,22 @@
Services
</summary>
<div class="nav-submenu">
<a href="/services/spotify" class="nav-item nav-subitem">
<!-- <a href="/services/spotify" class="nav-item nav-subitem">
<img src="/icons/spotify.png" alt="" class="nav-icon" />
Spotify
</a>
</a> -->
<a href="/services/deezer" class="nav-item nav-subitem">
<img src="/icons/deezer.png" alt="" class="nav-icon" />
Deezer
</a>
<a href="/services/soulseek" class="nav-item nav-subitem">
<!-- <a href="/services/soulseek" class="nav-item nav-subitem">
<img src="/icons/soulseek.png" alt="" class="nav-icon" />
Soulseek
</a>
<a href="/services/musicbrainz" class="nav-item nav-subitem">
</a> -->
<!-- <a href="/services/musicbrainz" class="nav-item nav-subitem">
<img src="/icons/musicbrainz.svg" alt="" class="nav-icon" />
MusicBrainz
</a>
</a> -->
</div>
</details>
<details class="nav-collapsible" open>

View File

@@ -17,7 +17,15 @@
onMount(async () => {
await loadSettings();
await loadPlaylist();
});
// Reactive effect: reload playlist when name changes
$effect(() => {
// Track the dependency
playlistName;
// Load the playlist
loadPlaylist();
});
async function loadPlaylist() {

View File

@@ -3,6 +3,7 @@
import { page } from '$app/stores';
import { deezerAuth } from '$lib/stores/deezer';
import { deezerAPI } from '$lib/services/deezer';
import { settings } from '$lib/stores/settings';
import {
getCachedPlaylist,
getCachedPlaylistTracks,
@@ -10,24 +11,30 @@
upsertPlaylistTracks,
type DeezerPlaylistTrack
} from '$lib/library/deezer-database';
import { TrackExistenceCache } from '$lib/library/trackMatcher';
import { addDeezerTrackToQueue } from '$lib/services/deezer/addToQueue';
import { downloadDeezerPlaylist } from '$lib/services/deezer/playlistDownloader';
import DeezerCollectionView from '$lib/components/DeezerCollectionView.svelte';
import type { Track } from '$lib/types/track';
import type { DeezerTrack } from '$lib/types/deezer';
type ViewMode = 'tracks' | 'info';
let viewMode = $state<ViewMode>('tracks');
let playlistId = $derived($page.params.id ?? '');
let playlistTitle = $state('');
let playlistCreator = $state('');
let playlistTrackCount = $state(0);
let playlistPicture = $state<string | undefined>(undefined);
let tracks = $state<Track[]>([]);
let deezerTracks = $state<DeezerTrack[]>([]); // Store original Deezer tracks for downloading
let trackExistsMap = $state<Map<string, boolean>>(new Map());
let loading = $state(true);
let refreshing = $state(false);
let error = $state<string | null>(null);
let selectedTrackIndex = $state<number | null>(null);
let lastCached = $state<number | null>(null);
let downloadingTrackIds = $state<Set<string>>(new Set());
const isFavoriteTracks = $derived(playlistId === 'favorite-tracks');
const existenceCache = new TrackExistenceCache();
onMount(async () => {
await loadPlaylist();
@@ -45,7 +52,9 @@
const favTracks = await getCachedTracks();
playlistTrackCount = favTracks.length;
tracks = favTracks.map(convertDeezerTrackToTrack);
// Convert database tracks - we'll need to fetch full data for downloads
tracks = favTracks.map(convertFavTrackToTrack);
lastCached = favTracks[0]?.cached_at || null;
} else {
// Load regular playlist
@@ -70,9 +79,12 @@
if (cachedTracks.length === 0) {
await refreshPlaylistTracks();
} else {
tracks = cachedTracks.map(convertPlaylistTrackToTrack);
tracks = cachedTracks.map(convertDbTrackToTrack);
}
}
// Check track existence after loading tracks
await checkTrackExistence();
} catch (e) {
error = 'Error loading playlist: ' + (e instanceof Error ? e.message : String(e));
} finally {
@@ -80,6 +92,57 @@
}
}
/**
* Check which tracks exist in the local library
*/
async function checkTrackExistence() {
if (!$settings.musicFolder || tracks.length === 0) {
return;
}
try {
// Build minimal DeezerTrack objects from cached database data
// This is much faster than fetching from API
const deezerTracksData: DeezerTrack[] = tracks
.filter(track => (track as any).deezerId)
.map(track => {
const trackId = (track as any).deezerId;
return {
id: parseInt(trackId, 10),
title: track.metadata.title || 'Unknown',
artist: track.metadata.artist || 'Unknown',
artistId: 0, // Not needed for path generation
artists: [track.metadata.artist || 'Unknown'],
album: track.metadata.album || 'Unknown',
albumId: 0, // Not needed for path generation
albumArtist: track.metadata.artist || 'Unknown', // Use artist as album artist
albumArtistId: 0, // Not needed for path generation
trackNumber: track.metadata.trackNumber || 1,
discNumber: 1, // Assume single disc
duration: track.metadata.duration || 0,
explicit: false,
// These fields are only needed for downloading, not for existence checking
md5Origin: undefined,
mediaVersion: undefined,
trackToken: undefined
} as DeezerTrack;
});
deezerTracks = deezerTracksData;
// Check existence using cache
const existenceResults = await existenceCache.checkTracks(
deezerTracksData,
$settings.musicFolder,
$settings.deezerFormat
);
trackExistsMap = existenceResults;
} catch (err) {
console.error('Error checking track existence:', err);
}
}
async function refreshPlaylistTracks() {
if (!$deezerAuth.arl || refreshing) {
return;
@@ -101,9 +164,12 @@
// Reload from cache
const cachedTracks = await getCachedPlaylistTracks(playlistId);
tracks = cachedTracks.map(convertPlaylistTrackToTrack);
tracks = cachedTracks.map(convertDbTrackToTrack);
lastCached = Math.floor(Date.now() / 1000);
// Re-check track existence
await checkTrackExistence();
console.log('[Deezer Playlist] Refresh complete!');
} catch (e) {
console.error('Error refreshing playlist tracks:', e);
@@ -113,39 +179,108 @@
}
}
function convertPlaylistTrackToTrack(deezerTrack: DeezerPlaylistTrack): Track {
return {
path: '', // Deezer tracks don't have a local path
filename: deezerTrack.title,
/**
* Convert database playlist track to Track format with Deezer ID attached
*/
function convertDbTrackToTrack(dbTrack: DeezerPlaylistTrack): Track {
const track: Track = {
path: '',
filename: dbTrack.title,
format: 'unknown',
metadata: {
title: deezerTrack.title,
artist: deezerTrack.artist_name,
album: deezerTrack.album_title || undefined,
trackNumber: deezerTrack.track_number || undefined,
duration: deezerTrack.duration
title: dbTrack.title,
artist: dbTrack.artist_name,
album: dbTrack.album_title || undefined,
trackNumber: dbTrack.track_number || undefined,
duration: dbTrack.duration
}
};
// Attach Deezer ID for existence checking and downloading
(track as any).deezerId = dbTrack.track_id;
return track;
}
function convertDeezerTrackToTrack(deezerTrack: any): Track {
return {
/**
* Convert database favorite track to Track format with Deezer ID attached
*/
function convertFavTrackToTrack(dbTrack: import('$lib/library/deezer-database').DeezerTrack): Track {
const track: Track = {
path: '',
filename: deezerTrack.title,
filename: dbTrack.title,
format: 'unknown',
metadata: {
title: deezerTrack.title,
artist: deezerTrack.artist_name,
album: deezerTrack.album_title || undefined,
duration: deezerTrack.duration
title: dbTrack.title,
artist: dbTrack.artist_name,
album: dbTrack.album_title || undefined,
duration: dbTrack.duration
}
};
// Attach Deezer ID for existence checking and downloading
(track as any).deezerId = dbTrack.id;
return track;
}
function handleTrackClick(index: number) {
selectedTrackIndex = index;
}
async function handleDownloadTrack(index: number) {
const track = tracks[index];
const trackId = (track as any).deezerId;
if (!trackId) {
console.error('Track has no Deezer ID');
return;
}
try {
downloadingTrackIds.add(trackId);
downloadingTrackIds = downloadingTrackIds; // Trigger reactivity
await addDeezerTrackToQueue(trackId);
console.log(`Track "${track.metadata.title}" added to download queue`);
} catch (err) {
console.error('Error adding track to queue:', err);
} finally {
downloadingTrackIds.delete(trackId);
downloadingTrackIds = downloadingTrackIds; // Trigger reactivity
}
}
async function handleDownloadPlaylist() {
if (deezerTracks.length === 0) {
console.error('No tracks to download');
return;
}
if (!$settings.playlistsFolder) {
console.error('Playlists folder not configured');
return;
}
try {
console.log(`Downloading playlist "${playlistTitle}"...`);
const m3u8Path = await downloadDeezerPlaylist(
playlistTitle,
deezerTracks,
$settings.playlistsFolder,
$settings.musicFolder!
);
console.log(`Playlist saved to: ${m3u8Path}`);
// TODO: Show success notification
} catch (err) {
console.error('Error downloading playlist:', err);
// TODO: Show error notification
}
}
function formatTimestamp(timestamp: number | null): string {
if (!timestamp) return 'Never';
const date = new Date(timestamp * 1000);
@@ -159,116 +294,19 @@
{:else if error}
<p class="error" style="padding: 8px;">{error}</p>
{:else}
<!-- Header -->
<div class="collection-header">
{#if playlistPicture}
<img
src={playlistPicture}
alt="{playlistTitle} cover"
class="collection-cover"
/>
{:else}
<div class="collection-cover-placeholder"></div>
{/if}
<div class="collection-info">
<h2>{playlistTitle}</h2>
<p class="collection-subtitle">by {playlistCreator}</p>
<p class="collection-metadata">{playlistTrackCount} track{playlistTrackCount !== 1 ? 's' : ''}</p>
</div>
</div>
<section class="collection-content">
<!-- Tabs -->
<!--
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
Reason: 98.css library requires <menu role="tablist"> for proper tab styling.
-->
<menu role="tablist">
<li role="tab" aria-selected={viewMode === 'tracks'}>
<button onclick={() => viewMode = 'tracks'}>Tracks</button>
</li>
<li role="tab" aria-selected={viewMode === 'info'}>
<button onclick={() => viewMode = 'info'}>Info</button>
</li>
</menu>
<!-- Tab Content -->
<div class="window tab-content" role="tabpanel">
<div class="window-body">
{#if viewMode === 'tracks'}
<!-- Track Listing -->
<div class="sunken-panel table-container">
<table class="interactive">
<thead>
<tr>
<th style="width: 50px;">#</th>
<th>Title</th>
<th>Artist</th>
<th>Album</th>
<th>Duration</th>
</tr>
</thead>
<tbody>
{#each tracks as track, i}
<tr
class:highlighted={selectedTrackIndex === i}
onclick={() => handleTrackClick(i)}
>
<td class="track-number">
{track.metadata.trackNumber ?? i + 1}
</td>
<td>{track.metadata.title || track.filename}</td>
<td>{track.metadata.artist || '—'}</td>
<td>{track.metadata.album || '—'}</td>
<td class="duration">
{#if track.metadata.duration}
{Math.floor(track.metadata.duration / 60)}:{String(Math.floor(track.metadata.duration % 60)).padStart(2, '0')}
{:else}
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else if viewMode === 'info'}
<!-- Playlist Info -->
<div class="info-container">
<fieldset>
<legend>Playlist Information</legend>
<div class="field-row">
<span class="field-label">Title:</span>
<span>{playlistTitle}</span>
</div>
<div class="field-row">
<span class="field-label">Creator:</span>
<span>{playlistCreator}</span>
</div>
<div class="field-row">
<span class="field-label">Tracks:</span>
<span>{playlistTrackCount}</span>
</div>
<div class="field-row">
<span class="field-label">Last Updated:</span>
<span>{formatTimestamp(lastCached)}</span>
</div>
</fieldset>
{#if !isFavoriteTracks}
<fieldset style="margin-top: 16px;">
<legend>Actions</legend>
<button onclick={refreshPlaylistTracks} disabled={refreshing}>
{refreshing ? 'Refreshing...' : 'Refresh Playlist'}
</button>
<p class="help-text">Fetch the latest tracks from Deezer</p>
</fieldset>
{/if}
</div>
{/if}
</div>
</div>
</section>
<DeezerCollectionView
title={playlistTitle}
subtitle={playlistCreator}
metadata="{playlistTrackCount} track{playlistTrackCount !== 1 ? 's' : ''}"
coverImageUrl={playlistPicture}
{tracks}
{trackExistsMap}
{selectedTrackIndex}
onTrackClick={handleTrackClick}
onDownloadTrack={handleDownloadTrack}
onDownloadPlaylist={handleDownloadPlaylist}
{downloadingTrackIds}
/>
{/if}
</div>
@@ -282,122 +320,4 @@
.error {
color: #ff6b6b;
}
.collection-header {
display: flex;
gap: 16px;
padding: 8px;
margin-bottom: 6px;
flex-shrink: 0;
}
.collection-cover {
width: 152px;
height: 152px;
object-fit: cover;
image-rendering: auto;
flex-shrink: 0;
}
.collection-cover-placeholder {
width: 152px;
height: 152px;
background: linear-gradient(135deg, #c0c0c0 25%, #808080 25%, #808080 50%, #c0c0c0 50%, #c0c0c0 75%, #808080 75%);
background-size: 8px 8px;
flex-shrink: 0;
}
.collection-info {
display: flex;
flex-direction: column;
justify-content: center;
}
h2 {
margin: 0 0 4px 0;
font-size: 1.5em;
}
.collection-subtitle {
margin: 0 0 8px 0;
font-size: 1.1em;
opacity: 0.8;
}
.collection-metadata {
margin: 0;
opacity: 0.6;
font-size: 0.9em;
}
.collection-content {
margin: 0;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.tab-content {
margin-top: -2px;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.window-body {
padding: 0;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.table-container {
flex: 1;
overflow-y: auto;
min-height: 0;
}
table {
width: 100%;
}
th {
text-align: left;
}
.track-number {
text-align: center;
opacity: 0.6;
}
.duration {
font-family: monospace;
font-size: 0.9em;
text-align: center;
width: 80px;
}
.info-container {
padding: 16px;
}
.field-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.field-label {
font-weight: bold;
min-width: 120px;
}
.help-text {
margin: 8px 0 0 0;
font-size: 11px;
color: #808080;
}
</style>

View File

@@ -233,6 +233,7 @@
/>
<label for="deezer-overwrite">Overwrite existing files</label>
</div>
<small class="help-text">When disabled, tracks that already exist will not be added to the download queue</small>
</div>
<fieldset>