mirror of
https://github.com/markuryy/shark.git
synced 2025-12-13 12:01:01 +00:00
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:
@@ -102,10 +102,10 @@
|
||||
<td class="track-number">
|
||||
{track.metadata.trackNumber ?? i + 1}
|
||||
</td>
|
||||
<td>{track.metadata.title || track.filename}</td>
|
||||
<td>{track.metadata.title ?? '—'}</td>
|
||||
{#if showAlbumColumn}
|
||||
<td>{track.metadata.artist || '—'}</td>
|
||||
<td>{track.metadata.album || '—'}</td>
|
||||
<td>{track.metadata.artist ?? '—'}</td>
|
||||
<td>{track.metadata.album ?? '—'}</td>
|
||||
{/if}
|
||||
<td class="duration">
|
||||
{#if track.metadata.duration}
|
||||
|
||||
325
src/lib/components/DeezerCollectionView.svelte
Normal file
325
src/lib/components/DeezerCollectionView.svelte
Normal file
@@ -0,0 +1,325 @@
|
||||
<script lang="ts">
|
||||
import type { Track } from '$lib/types/track';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
metadata?: string;
|
||||
coverImageUrl?: string;
|
||||
tracks: Track[];
|
||||
trackExistsMap: Map<string, boolean>; // Map of track ID to existence
|
||||
selectedTrackIndex?: number | null;
|
||||
onTrackClick?: (index: number) => void;
|
||||
onDownloadTrack?: (index: number) => void;
|
||||
onDownloadPlaylist?: () => void;
|
||||
downloadingTrackIds?: Set<string>;
|
||||
}
|
||||
|
||||
let {
|
||||
title,
|
||||
subtitle,
|
||||
metadata,
|
||||
coverImageUrl,
|
||||
tracks,
|
||||
trackExistsMap,
|
||||
selectedTrackIndex = null,
|
||||
onTrackClick,
|
||||
onDownloadTrack,
|
||||
onDownloadPlaylist,
|
||||
downloadingTrackIds = new Set()
|
||||
}: Props = $props();
|
||||
|
||||
type ViewMode = 'tracks' | 'info';
|
||||
let viewMode = $state<ViewMode>('tracks');
|
||||
|
||||
function handleTrackClick(index: number) {
|
||||
if (onTrackClick) {
|
||||
onTrackClick(index);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownloadClick(index: number, event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
if (onDownloadTrack) {
|
||||
onDownloadTrack(index);
|
||||
}
|
||||
}
|
||||
|
||||
// Get track ID for existence checking
|
||||
function getTrackId(track: Track): string | undefined {
|
||||
// Assuming track has metadata with a Deezer ID stored
|
||||
return (track as any).deezerId?.toString();
|
||||
}
|
||||
|
||||
function isTrackInLibrary(track: Track): boolean {
|
||||
const trackId = getTrackId(track);
|
||||
if (!trackId) return false;
|
||||
return trackExistsMap.get(trackId) ?? false;
|
||||
}
|
||||
|
||||
function isTrackDownloading(track: Track): boolean {
|
||||
const trackId = getTrackId(track);
|
||||
if (!trackId) return false;
|
||||
return downloadingTrackIds.has(trackId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="collection-header">
|
||||
{#if coverImageUrl}
|
||||
<img
|
||||
src={coverImageUrl}
|
||||
alt="{title} cover"
|
||||
class="collection-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div class="collection-cover-placeholder"></div>
|
||||
{/if}
|
||||
<div class="collection-info">
|
||||
<h2>{title}</h2>
|
||||
{#if subtitle}
|
||||
<p class="collection-subtitle">{subtitle}</p>
|
||||
{/if}
|
||||
{#if metadata}
|
||||
<p class="collection-metadata">{metadata}</p>
|
||||
{/if}
|
||||
</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>
|
||||
<th style="width: 80px;">In Library</th>
|
||||
<th style="width: 100px;">Actions</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 ?? '—'}</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>
|
||||
<td class="in-library">
|
||||
{isTrackInLibrary(track) ? '✓' : '✗'}
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
onclick={(e) => handleDownloadClick(i, e)}
|
||||
disabled={isTrackDownloading(track)}
|
||||
class="download-btn"
|
||||
>
|
||||
{isTrackDownloading(track) ? 'Queued' : 'Download'}
|
||||
</button>
|
||||
</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>{title}</span>
|
||||
</div>
|
||||
{#if subtitle}
|
||||
<div class="field-row">
|
||||
<span class="field-label">Creator:</span>
|
||||
<span>{subtitle}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="field-row">
|
||||
<span class="field-label">Tracks:</span>
|
||||
<span>{tracks.length}</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset style="margin-top: 16px;">
|
||||
<legend>Actions</legend>
|
||||
<button onclick={onDownloadPlaylist}>
|
||||
Download Playlist
|
||||
</button>
|
||||
<p class="help-text">Download all tracks and save as m3u8 playlist</p>
|
||||
</fieldset>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.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;
|
||||
}
|
||||
|
||||
.in-library {
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.actions {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.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>
|
||||
Reference in New Issue
Block a user