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

@@ -1,6 +1,7 @@
use tauri_plugin_sql::{Migration, MigrationKind}; use tauri_plugin_sql::{Migration, MigrationKind};
mod tagger; mod tagger;
mod metadata;
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command] #[tauri::command]
@@ -19,6 +20,12 @@ fn tag_audio_file(
tagger::tag_audio_file(&path, &metadata, cover_data.as_deref(), embed_lyrics) tagger::tag_audio_file(&path, &metadata, cover_data.as_deref(), embed_lyrics)
} }
/// Read metadata from an audio file (MP3 or FLAC)
#[tauri::command]
fn read_audio_metadata(path: String) -> Result<metadata::AudioMetadata, String> {
metadata::read_audio_metadata(&path)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
let library_migrations = vec![Migration { let library_migrations = vec![Migration {
@@ -136,7 +143,7 @@ pub fn run() {
.plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_fs::init())
.invoke_handler(tauri::generate_handler![greet, tag_audio_file]) .invoke_handler(tauri::generate_handler![greet, tag_audio_file, read_audio_metadata])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }

87
src-tauri/src/metadata.rs Normal file
View File

@@ -0,0 +1,87 @@
use metaflac::Tag as FlacTag;
use id3::{Tag as ID3Tag, TagLike};
use serde::{Deserialize, Serialize};
use std::path::Path;
/// Audio file metadata structure
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AudioMetadata {
pub title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub album_artist: Option<String>,
pub track_number: Option<u32>,
pub duration: Option<f64>, // in seconds
}
/// Read metadata from an audio file (MP3 or FLAC)
pub fn read_audio_metadata(path: &str) -> Result<AudioMetadata, String> {
let path_obj = Path::new(path);
// Check if file exists
if !path_obj.exists() {
return Err(format!("File not found: {}", path));
}
// Determine file type by extension
let extension = path_obj
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase())
.ok_or_else(|| "File has no extension".to_string())?;
match extension.as_str() {
"mp3" => read_mp3_metadata(path),
"flac" => read_flac_metadata(path),
_ => Err(format!("Unsupported file format: {}", extension)),
}
}
/// Read metadata from MP3 file
fn read_mp3_metadata(path: &str) -> Result<AudioMetadata, String> {
let tag = ID3Tag::read_from_path(path)
.map_err(|e| format!("Failed to read MP3 tags: {}", e))?;
Ok(AudioMetadata {
title: tag.title().map(|s| s.to_string()),
artist: tag.artist().map(|s| s.to_string()),
album: tag.album().map(|s| s.to_string()),
album_artist: tag.album_artist().map(|s| s.to_string()),
track_number: tag.track(),
duration: tag.duration().map(|d| d as f64 / 1000.0), // Convert ms to seconds
})
}
/// Read metadata from FLAC file
fn read_flac_metadata(path: &str) -> Result<AudioMetadata, String> {
let tag = FlacTag::read_from_path(path)
.map_err(|e| format!("Failed to read FLAC tags: {}", e))?;
// Helper to get first value from vorbis comment
let get_first = |key: &str| -> Option<String> {
tag.vorbis_comments()
.and_then(|vorbis| vorbis.get(key))
.and_then(|values| values.first().map(|s| s.to_string()))
};
// Parse track number
let track_number = get_first("TRACKNUMBER")
.and_then(|s| s.parse::<u32>().ok());
// Get duration from streaminfo block (in samples)
let duration = tag.get_streaminfo().map(|info| {
let samples = info.total_samples;
let sample_rate = info.sample_rate;
samples as f64 / sample_rate as f64
});
Ok(AudioMetadata {
title: get_first("TITLE"),
artist: get_first("ARTIST"),
album: get_first("ALBUM"),
album_artist: get_first("ALBUMARTIST"),
track_number,
duration,
})
}

View File

@@ -102,10 +102,10 @@
<td class="track-number"> <td class="track-number">
{track.metadata.trackNumber ?? i + 1} {track.metadata.trackNumber ?? i + 1}
</td> </td>
<td>{track.metadata.title || track.filename}</td> <td>{track.metadata.title ?? '—'}</td>
{#if showAlbumColumn} {#if showAlbumColumn}
<td>{track.metadata.artist || '—'}</td> <td>{track.metadata.artist ?? '—'}</td>
<td>{track.metadata.album || '—'}</td> <td>{track.metadata.album ?? '—'}</td>
{/if} {/if}
<td class="duration"> <td class="duration">
{#if track.metadata.duration} {#if track.metadata.duration}

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

View File

@@ -0,0 +1,203 @@
/**
* Incremental library sync
* Syncs a single artist or album folder to the database without full rescan
*/
import { readDir, exists } from '@tauri-apps/plugin-fs';
import { upsertArtist, upsertAlbum, getAlbumByPath } from './database';
/**
* Sync a single album folder to the database
* @param albumPath - Absolute path to album folder
* @param artistName - Name of the artist
* @param artistId - ID of the artist in database
* @returns True if album was synced successfully
*/
export async function syncAlbumFolder(
albumPath: string,
artistName: string,
artistId: number
): Promise<boolean> {
try {
// Check if album folder exists
if (!(await exists(albumPath))) {
console.warn(`[IncrementalSync] Album folder does not exist: ${albumPath}`);
return false;
}
// Get album name from path
const albumName = albumPath.split('/').pop() || '';
// Count tracks in album
const entries = await readDir(albumPath);
const audioExtensions = ['.flac', '.mp3', '.opus', '.ogg', '.m4a', '.wav', '.FLAC', '.MP3'];
let trackCount = 0;
for (const entry of entries) {
if (!entry.isDirectory) {
const hasAudioExt = audioExtensions.some(ext => entry.name.endsWith(ext));
if (hasAudioExt) {
trackCount++;
}
}
}
if (trackCount === 0) {
console.warn(`[IncrementalSync] No tracks found in album: ${albumPath}`);
return false;
}
// Find cover art
let coverPath: string | undefined;
const imageExtensions = ['.jpg', '.jpeg', '.png', '.JPG', '.JPEG', '.PNG'];
for (const entry of entries) {
if (!entry.isDirectory) {
const hasImageExt = imageExtensions.some(ext => entry.name.endsWith(ext));
if (hasImageExt) {
coverPath = `${albumPath}/${entry.name}`;
break;
}
}
}
// Upsert album
await upsertAlbum({
artist_id: artistId,
artist_name: artistName,
title: albumName,
path: albumPath,
cover_path: coverPath,
track_count: trackCount
});
console.log(`[IncrementalSync] Synced album: ${artistName} - ${albumName} (${trackCount} tracks)`);
return true;
} catch (error) {
console.error(`[IncrementalSync] Error syncing album ${albumPath}:`, error);
return false;
}
}
/**
* Sync a single artist folder to the database
* This will sync the artist and all their albums
* @param artistPath - Absolute path to artist folder
* @returns True if artist was synced successfully
*/
export async function syncArtistFolder(artistPath: string): Promise<boolean> {
try {
// Check if artist folder exists
if (!(await exists(artistPath))) {
console.warn(`[IncrementalSync] Artist folder does not exist: ${artistPath}`);
return false;
}
// Get artist name from path
const artistName = artistPath.split('/').pop() || '';
// Get all album folders
const entries = await readDir(artistPath);
const albumFolders = entries.filter(e => e.isDirectory);
if (albumFolders.length === 0) {
console.warn(`[IncrementalSync] No albums found for artist: ${artistName}`);
return false;
}
// Sync all albums and collect stats
let totalTracks = 0;
let albumCount = 0;
let primaryCover: string | undefined;
for (const albumEntry of albumFolders) {
const albumPath = `${artistPath}/${albumEntry.name}`;
// Count tracks
const albumEntries = await readDir(albumPath);
const audioExtensions = ['.flac', '.mp3', '.opus', '.ogg', '.m4a', '.wav', '.FLAC', '.MP3'];
let trackCount = 0;
for (const entry of albumEntries) {
if (!entry.isDirectory) {
const hasAudioExt = audioExtensions.some(ext => entry.name.endsWith(ext));
if (hasAudioExt) {
trackCount++;
}
}
}
if (trackCount > 0) {
albumCount++;
totalTracks += trackCount;
// Find cover for first album
if (!primaryCover) {
for (const entry of albumEntries) {
if (!entry.isDirectory) {
const imageExtensions = ['.jpg', '.jpeg', '.png', '.JPG', '.JPEG', '.PNG'];
const hasImageExt = imageExtensions.some(ext => entry.name.endsWith(ext));
if (hasImageExt) {
primaryCover = `${albumPath}/${entry.name}`;
break;
}
}
}
}
}
}
// Upsert artist
const artistId = await upsertArtist({
name: artistName,
path: artistPath,
album_count: albumCount,
track_count: totalTracks,
primary_cover_path: primaryCover
});
// Now sync all albums with the artist ID
for (const albumEntry of albumFolders) {
const albumPath = `${artistPath}/${albumEntry.name}`;
await syncAlbumFolder(albumPath, artistName, artistId);
}
console.log(`[IncrementalSync] Synced artist: ${artistName} (${albumCount} albums, ${totalTracks} tracks)`);
return true;
} catch (error) {
console.error(`[IncrementalSync] Error syncing artist ${artistPath}:`, error);
return false;
}
}
/**
* Sync multiple tracks by their parent album/artist folders
* Groups tracks by artist and syncs each artist once
* @param trackPaths - Array of absolute paths to track files
* @returns Number of artists synced
*/
export async function syncTrackPaths(trackPaths: string[]): Promise<number> {
// Group tracks by artist path
const artistPaths = new Set<string>();
for (const trackPath of trackPaths) {
// Extract artist path (two levels up from track)
// Format: /path/to/Music/Artist/Album/Track.flac
const parts = trackPath.split('/');
if (parts.length >= 3) {
const artistPath = parts.slice(0, -2).join('/');
artistPaths.add(artistPath);
}
}
// Sync each artist
let syncedCount = 0;
for (const artistPath of artistPaths) {
const success = await syncArtistFolder(artistPath);
if (success) {
syncedCount++;
}
}
console.log(`[IncrementalSync] Synced ${syncedCount} artists from ${trackPaths.length} track paths`);
return syncedCount;
}

89
src/lib/library/m3u8.ts Normal file
View File

@@ -0,0 +1,89 @@
import { writeFile } from '@tauri-apps/plugin-fs';
import { sanitizeFilename } from '$lib/services/deezer/paths';
export interface M3U8Track {
duration: number; // in seconds
artist: string;
title: string;
path: string; // relative path from playlist file (e.g., ../Music/Artist/Album/01 - Track.flac)
}
/**
* Write an M3U8 playlist file
* Format: Extended M3U format with EXTINF metadata
*
* @param playlistName - Name of the playlist (will be sanitized)
* @param tracks - Array of tracks to include
* @param playlistsFolder - Absolute path to playlists folder
* @returns Absolute path to created m3u8 file
*/
export async function writeM3U8(
playlistName: string,
tracks: M3U8Track[],
playlistsFolder: string
): Promise<string> {
// Sanitize playlist name for filename
const sanitizedName = sanitizeFilename(playlistName);
const playlistPath = `${playlistsFolder}/${sanitizedName}.m3u8`;
// Build m3u8 content
const lines: string[] = [
'#EXTM3U',
`#PLAYLIST:${playlistName}`,
'#EXTENC:UTF-8',
''
];
for (const track of tracks) {
// EXTINF format: #EXTINF:duration,artist - title
const durationSeconds = Math.round(track.duration);
const extinf = `#EXTINF:${durationSeconds},${track.artist} - ${track.title}`;
lines.push(extinf);
lines.push(track.path);
}
// Add trailing newline
lines.push('');
const content = lines.join('\n');
const encoder = new TextEncoder();
const data = encoder.encode(content);
await writeFile(playlistPath, data);
return playlistPath;
}
/**
* Convert absolute music file path to relative path from playlists folder
* Assumes music folder and playlists folder are siblings:
* /path/to/Music/Artist/Album/Track.flac
* /path/to/Playlists/playlist.m3u8
* Becomes: ../Music/Artist/Album/Track.flac
*
* @param absoluteMusicPath - Absolute path to music file
* @param musicFolderName - Name of music folder (default: 'Music')
* @returns Relative path from playlists folder
*/
export function makeRelativePath(
absoluteMusicPath: string,
musicFolderName: string = 'Music'
): string {
// Split path into parts
const parts = absoluteMusicPath.split('/');
// Find the music folder index
const musicIndex = parts.findIndex(part => part === musicFolderName);
if (musicIndex === -1) {
// Fallback: if music folder not found, use the path as-is
console.warn(`[M3U8] Could not find "${musicFolderName}" in path: ${absoluteMusicPath}`);
return absoluteMusicPath;
}
// Take everything from music folder onwards
const relativeParts = parts.slice(musicIndex);
// Prepend ../ to go up from playlists folder
return `../${relativeParts.join('/')}`;
}

View File

@@ -1,5 +1,5 @@
import { readTextFile, readFile, exists, readDir } from '@tauri-apps/plugin-fs'; import { readTextFile, exists, readDir } from '@tauri-apps/plugin-fs';
import { parseBuffer } from 'music-metadata'; import { invoke } from '@tauri-apps/api/core';
import type { Track, AudioFormat, PlaylistWithTracks, TrackMetadata } from '$lib/types/track'; import type { Track, AudioFormat, PlaylistWithTracks, TrackMetadata } from '$lib/types/track';
/** /**
@@ -25,34 +25,66 @@ function getAudioFormat(filename: string): AudioFormat {
} }
} }
export interface ParsedPlaylistTrack {
path: string;
extinfData?: {
duration: number;
artist?: string;
title?: string;
};
}
/** /**
* Parse M3U/M3U8 playlist file * Parse M3U/M3U8 playlist file
* Supports both basic M3U and extended M3U8 format * Supports both basic M3U and extended M3U8 format
* Returns tracks with optional EXTINF metadata
*/ */
export async function parsePlaylist(playlistPath: string): Promise<string[]> { export async function parsePlaylist(playlistPath: string): Promise<ParsedPlaylistTrack[]> {
try { try {
const content = await readTextFile(playlistPath); const content = await readTextFile(playlistPath);
const lines = content.split('\n').map(line => line.trim()); const lines = content.split('\n').map(line => line.trim());
const tracks: string[] = []; const tracks: ParsedPlaylistTrack[] = [];
let currentExtinf: { duration: number; artist?: string; title?: string } | undefined;
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
const line = lines[i]; const line = lines[i];
// Skip empty lines and comments (except #EXTINF which precedes track info) // Skip empty lines and non-EXTINF comments
if (!line || (line.startsWith('#') && !line.startsWith('#EXTINF'))) { if (!line || (line.startsWith('#') && !line.startsWith('#EXTINF'))) {
continue; continue;
} }
// If it's an EXTINF line, the next line should be the file path // Parse EXTINF line: #EXTINF:duration,artist - title
if (line.startsWith('#EXTINF')) { if (line.startsWith('#EXTINF')) {
i++; // Move to next line const match = line.match(/^#EXTINF:(\d+),(.+)$/);
if (i < lines.length && lines[i] && !lines[i].startsWith('#')) { if (match) {
tracks.push(lines[i]); const duration = parseInt(match[1], 10);
const info = match[2];
// Try to split by " - " to get artist and title
const dashIndex = info.indexOf(' - ');
if (dashIndex !== -1) {
currentExtinf = {
duration,
artist: info.substring(0, dashIndex).trim(),
title: info.substring(dashIndex + 3).trim()
};
} else {
// No artist, just title
currentExtinf = {
duration,
title: info.trim()
};
}
} }
} else if (!line.startsWith('#')) { } else if (!line.startsWith('#')) {
// Regular M3U format - just file paths // This is a file path
tracks.push(line); tracks.push({
path: line,
extinfData: currentExtinf
});
currentExtinf = undefined; // Reset for next track
} }
} }
@@ -109,41 +141,37 @@ async function findActualFilePath(basePath: string): Promise<string | null> {
} }
/** /**
* Read metadata from audio file * Read metadata from audio file using Rust backend
*/ */
async function readAudioMetadata(filePath: string, format: AudioFormat): Promise<TrackMetadata> { async function readAudioMetadata(filePath: string, format: AudioFormat): Promise<TrackMetadata> {
try { try {
// Read file as binary // Call Rust command to read metadata
const fileData = await readFile(filePath); const metadata = await invoke<{
title?: string;
artist?: string;
album?: string;
albumArtist?: string;
trackNumber?: number;
duration?: number;
}>('read_audio_metadata', { path: filePath });
// Get MIME type from format const result: TrackMetadata = {
const mimeMap: Record<AudioFormat, string> = { title: metadata.title,
'flac': 'audio/flac', artist: metadata.artist,
'mp3': 'audio/mpeg', album: metadata.album,
'opus': 'audio/opus', albumArtist: metadata.albumArtist,
'ogg': 'audio/ogg', trackNumber: metadata.trackNumber,
'm4a': 'audio/mp4', duration: metadata.duration,
'wav': 'audio/wav',
'unknown': 'audio/mpeg'
}; };
// Parse metadata from buffer // Log what we got
const metadata = await parseBuffer(fileData, mimeMap[format], { duration: true }); if (!result.title && !result.artist && !result.album) {
console.warn(`[Playlist] No metadata found in file: ${filePath}`);
}
return { return result;
title: metadata.common.title,
artist: metadata.common.artist,
album: metadata.common.album,
albumArtist: metadata.common.albumartist,
year: metadata.common.year,
trackNumber: metadata.common.track?.no ?? undefined,
genre: metadata.common.genre?.[0],
duration: metadata.format.duration,
bitrate: metadata.format.bitrate ? Math.round(metadata.format.bitrate / 1000) : undefined,
sampleRate: metadata.format.sampleRate
};
} catch (error) { } catch (error) {
console.error('Error reading audio metadata:', error); console.error(`[Playlist] Error reading metadata from ${filePath}:`, error);
return {}; return {};
} }
} }
@@ -190,11 +218,13 @@ export async function loadPlaylistTracks(
playlistName: string, playlistName: string,
baseFolder: string baseFolder: string
): Promise<PlaylistWithTracks> { ): Promise<PlaylistWithTracks> {
const trackPaths = await parsePlaylist(playlistPath); const parsedTracks = await parsePlaylist(playlistPath);
// Load tracks with metadata in parallel // Load tracks with metadata in parallel
const tracks: Track[] = await Promise.all( const tracks: Track[] = await Promise.all(
trackPaths.map(async (trackPath) => { parsedTracks.map(async (parsedTrack) => {
const trackPath = parsedTrack.path;
// Handle relative paths - resolve relative to playlist location or music folder // Handle relative paths - resolve relative to playlist location or music folder
let fullPath = trackPath.startsWith('/') || trackPath.includes(':\\') let fullPath = trackPath.startsWith('/') || trackPath.includes(':\\')
? trackPath // Absolute path ? trackPath // Absolute path
@@ -209,17 +239,29 @@ export async function loadPlaylistTracks(
const filename = trackPath.split('/').pop() || trackPath.split('\\').pop() || trackPath; const filename = trackPath.split('/').pop() || trackPath.split('\\').pop() || trackPath;
const format = getAudioFormat(filename); const format = getAudioFormat(filename);
// Read metadata from actual audio file if found // Start with EXTINF metadata if available
const metadata = actualPath let metadata: TrackMetadata = {};
? await readAudioMetadata(actualPath, format) if (parsedTrack.extinfData) {
: {}; metadata.title = parsedTrack.extinfData.title;
metadata.artist = parsedTrack.extinfData.artist;
metadata.duration = parsedTrack.extinfData.duration;
}
// Fallback to filename parsing if no metadata // Try to read metadata from actual audio file if found
if (!metadata.title) { // Only override EXTINF data if file has actual metadata values
const nameWithoutExt = filename.replace(/\.(flac|mp3|opus|ogg|m4a|wav)$/i, ''); if (actualPath) {
const parts = nameWithoutExt.split(' - '); const fileMetadata = await readAudioMetadata(actualPath, format);
metadata.title = parts.length > 1 ? parts[1] : nameWithoutExt; // Merge, but only override if file metadata has values
metadata.artist = parts.length > 1 ? parts[0] : undefined; if (fileMetadata.title) metadata.title = fileMetadata.title;
if (fileMetadata.artist) metadata.artist = fileMetadata.artist;
if (fileMetadata.album) metadata.album = fileMetadata.album;
if (fileMetadata.albumArtist) metadata.albumArtist = fileMetadata.albumArtist;
if (fileMetadata.duration !== undefined) metadata.duration = fileMetadata.duration;
if (fileMetadata.trackNumber) metadata.trackNumber = fileMetadata.trackNumber;
if (fileMetadata.year) metadata.year = fileMetadata.year;
if (fileMetadata.genre) metadata.genre = fileMetadata.genre;
if (fileMetadata.bitrate) metadata.bitrate = fileMetadata.bitrate;
if (fileMetadata.sampleRate) metadata.sampleRate = fileMetadata.sampleRate;
} }
return { return {

View File

@@ -0,0 +1,143 @@
import { exists } from '@tauri-apps/plugin-fs';
import { generateTrackPath } from '$lib/services/deezer/paths';
import type { DeezerTrack } from '$lib/types/deezer';
/**
* Check if a Deezer track exists in the local music library
* Uses the same path generation logic as the downloader
*
* @param track - Deezer track to check
* @param musicFolder - Path to music folder
* @param format - Download format ('FLAC', 'MP3_320', 'MP3_128')
* @returns True if track file exists
*/
export async function deezerTrackExists(
track: DeezerTrack,
musicFolder: string,
format: string
): Promise<boolean> {
try {
// Generate the expected path using downloader logic
const paths = generateTrackPath(track, musicFolder, format, false);
const fullPath = `${paths.filepath}/${paths.filename}`;
// Check if file exists
return await exists(fullPath);
} catch (error) {
console.error('[TrackMatcher] Error checking track existence:', error);
return false;
}
}
/**
* Check existence for multiple tracks in batch
* Uses caching to avoid repeated file system checks
*
* @param tracks - Array of Deezer tracks
* @param musicFolder - Path to music folder
* @param format - Download format
* @returns Map of track ID to existence status
*/
export async function batchCheckTracksExist(
tracks: DeezerTrack[],
musicFolder: string,
format: string
): Promise<Map<string, boolean>> {
const results = new Map<string, boolean>();
// Check all tracks in parallel
await Promise.all(
tracks.map(async (track) => {
const trackId = track.id.toString();
const exists = await deezerTrackExists(track, musicFolder, format);
results.set(trackId, exists);
})
);
return results;
}
/**
* Session-based cache for track existence checks
* Useful for avoiding repeated checks within a single view
*/
export class TrackExistenceCache {
private cache: Map<string, boolean> = new Map();
/**
* Check if track exists, using cache if available
*/
async checkTrack(
track: DeezerTrack,
musicFolder: string,
format: string
): Promise<boolean> {
const cacheKey = this.getCacheKey(track, musicFolder, format);
if (this.cache.has(cacheKey)) {
return this.cache.get(cacheKey)!;
}
const exists = await deezerTrackExists(track, musicFolder, format);
this.cache.set(cacheKey, exists);
return exists;
}
/**
* Check multiple tracks, using cache when available
*/
async checkTracks(
tracks: DeezerTrack[],
musicFolder: string,
format: string
): Promise<Map<string, boolean>> {
const results = new Map<string, boolean>();
// Separate cached and uncached tracks
const uncachedTracks: DeezerTrack[] = [];
for (const track of tracks) {
const cacheKey = this.getCacheKey(track, musicFolder, format);
if (this.cache.has(cacheKey)) {
results.set(track.id.toString(), this.cache.get(cacheKey)!);
} else {
uncachedTracks.push(track);
}
}
// Check uncached tracks in batch
if (uncachedTracks.length > 0) {
const uncachedResults = await batchCheckTracksExist(
uncachedTracks,
musicFolder,
format
);
// Add to cache and results
for (const [trackId, exists] of uncachedResults.entries()) {
const track = uncachedTracks.find(t => t.id.toString() === trackId);
if (track) {
const cacheKey = this.getCacheKey(track, musicFolder, format);
this.cache.set(cacheKey, exists);
}
results.set(trackId, exists);
}
}
return results;
}
/**
* Clear the cache (useful when filesystem changes)
*/
clear(): void {
this.cache.clear();
}
/**
* Generate cache key for a track
*/
private getCacheKey(track: DeezerTrack, musicFolder: string, format: string): string {
return `${musicFolder}:${format}:${track.id}`;
}
}

View File

@@ -412,7 +412,8 @@ export class DeezerAPI {
console.log('[DEBUG] Getting track download URL...', { trackToken, format, licenseToken }); console.log('[DEBUG] Getting track download URL...', { trackToken, format, licenseToken });
try { try {
const cookieHeader = this.getCookieHeader(); // media.deezer.com ONLY needs arl cookie, not sid or other cookies
const cookieHeader = this.arl ? `arl=${this.arl}` : '';
console.log('[DEBUG] Sending request to media.deezer.com with cookies:', cookieHeader); console.log('[DEBUG] Sending request to media.deezer.com with cookies:', cookieHeader);
const response = await fetch('https://media.deezer.com/v1/get_url', { const response = await fetch('https://media.deezer.com/v1/get_url', {

View File

@@ -5,13 +5,24 @@
import { deezerAPI } from '$lib/services/deezer'; import { deezerAPI } from '$lib/services/deezer';
import { addToQueue } from '$lib/stores/downloadQueue'; import { addToQueue } from '$lib/stores/downloadQueue';
import { settings } from '$lib/stores/settings';
import { deezerAuth } from '$lib/stores/deezer';
import { trackExists } from './downloader';
import { get } from 'svelte/store';
/** /**
* Fetch track metadata and add to download queue * Fetch track metadata and add to download queue
* Respects the overwrite setting - skips tracks that already exist if overwrite is false
* @param trackId - Deezer track ID * @param trackId - Deezer track ID
* @returns Promise that resolves when track is added to queue * @returns Promise that resolves when track is added to queue (or skipped message)
*/ */
export async function addDeezerTrackToQueue(trackId: string): Promise<void> { export async function addDeezerTrackToQueue(trackId: string): Promise<{ added: boolean; reason?: string }> {
// Ensure ARL is set for authentication
const authState = get(deezerAuth);
if (authState.arl) {
deezerAPI.setArl(authState.arl);
}
// Fetch full track data from GW API // Fetch full track data from GW API
const trackInfo = await deezerAPI.getTrack(trackId); const trackInfo = await deezerAPI.getTrack(trackId);
@@ -66,9 +77,9 @@ export async function addDeezerTrackToQueue(trackId: string): Promise<void> {
albumId: trackInfo.ALB_ID, albumId: trackInfo.ALB_ID,
albumArtist: trackInfo.ART_NAME, albumArtist: trackInfo.ART_NAME,
albumArtistId: trackInfo.ART_ID, albumArtistId: trackInfo.ART_ID,
trackNumber: trackInfo.TRACK_NUMBER || 1, trackNumber: typeof trackInfo.TRACK_NUMBER === 'number' ? trackInfo.TRACK_NUMBER : parseInt(trackInfo.TRACK_NUMBER, 10),
discNumber: trackInfo.DISK_NUMBER || 1, discNumber: typeof trackInfo.DISK_NUMBER === 'number' ? trackInfo.DISK_NUMBER : parseInt(trackInfo.DISK_NUMBER, 10),
duration: trackInfo.DURATION, duration: typeof trackInfo.DURATION === 'number' ? trackInfo.DURATION : parseInt(trackInfo.DURATION, 10),
explicit: trackInfo.EXPLICIT_LYRICS === 1, explicit: trackInfo.EXPLICIT_LYRICS === 1,
md5Origin: trackInfo.MD5_ORIGIN, md5Origin: trackInfo.MD5_ORIGIN,
mediaVersion: trackInfo.MEDIA_VERSION, mediaVersion: trackInfo.MEDIA_VERSION,
@@ -84,6 +95,18 @@ export async function addDeezerTrackToQueue(trackId: string): Promise<void> {
copyright: trackInfo.COPYRIGHT copyright: trackInfo.COPYRIGHT
}; };
// Check if we should skip this track (if it exists and overwrite is false)
const appSettings = get(settings);
if (!appSettings.deezerOverwrite && appSettings.musicFolder) {
const exists = await trackExists(track, appSettings.musicFolder, appSettings.deezerFormat);
if (exists) {
console.log(`[AddToQueue] Skipping "${track.title}" - already exists`);
return { added: false, reason: 'already_exists' };
}
}
// Add to queue (queue manager runs continuously in background) // Add to queue (queue manager runs continuously in background)
await addToQueue({ await addToQueue({
source: 'deezer', source: 'deezer',
@@ -93,4 +116,6 @@ export async function addDeezerTrackToQueue(trackId: string): Promise<void> {
totalTracks: 1, totalTracks: 1,
downloadObject: track downloadObject: track
}); });
return { added: true };
} }

View File

@@ -124,7 +124,6 @@ export async function downloadTrack(
// Apply tags (works for both MP3 and FLAC) // Apply tags (works for both MP3 and FLAC)
console.log('Tagging audio file...'); console.log('Tagging audio file...');
try {
await tagAudioFile( await tagAudioFile(
finalPath, finalPath,
track, track,
@@ -132,10 +131,6 @@ export async function downloadTrack(
appSettings.embedLyrics appSettings.embedLyrics
); );
console.log('Tagging complete!'); console.log('Tagging complete!');
} catch (error) {
console.error('Failed to tag audio file:', error);
// Non-fatal error - file is still downloaded, just not tagged
}
// Save LRC sidecar file if enabled // Save LRC sidecar file if enabled
if (appSettings.saveLrcFile && track.lyrics?.sync) { if (appSettings.saveLrcFile && track.lyrics?.sync) {

View File

@@ -0,0 +1,93 @@
/**
* Download Deezer playlist - adds tracks to queue and creates m3u8 file
*/
import { addDeezerTrackToQueue } from './addToQueue';
import { writeM3U8, makeRelativePath, type M3U8Track } from '$lib/library/m3u8';
import { generateTrackPath } from './paths';
import { settings } from '$lib/stores/settings';
import { get } from 'svelte/store';
import type { DeezerTrack } from '$lib/types/deezer';
import { mkdir } from '@tauri-apps/plugin-fs';
/**
* Download a Deezer playlist
* - Adds all tracks to the download queue (respects overwrite setting)
* - Creates an m3u8 playlist file with relative paths
*
* @param playlistName - Name of the playlist
* @param tracks - Array of DeezerTrack objects
* @param playlistsFolder - Path to playlists folder
* @param musicFolder - Path to music folder
* @returns Path to created m3u8 file
*/
export async function downloadDeezerPlaylist(
playlistName: string,
tracks: DeezerTrack[],
playlistsFolder: string,
musicFolder: string
): Promise<string> {
const appSettings = get(settings);
console.log(`[PlaylistDownloader] Starting download for playlist: ${playlistName}`);
console.log(`[PlaylistDownloader] Tracks: ${tracks.length}`);
// Ensure playlists folder exists
try {
await mkdir(playlistsFolder, { recursive: true });
} catch (error) {
// Folder might already exist
}
// Add all tracks to download queue
// Note: Tracks from cache don't have md5Origin/mediaVersion/trackToken needed for download
// So we need to call addDeezerTrackToQueue which fetches full data from API
// We add a small delay between requests to avoid rate limiting
let addedCount = 0;
let skippedCount = 0;
for (let i = 0; i < tracks.length; i++) {
const track = tracks[i];
try {
const result = await addDeezerTrackToQueue(track.id.toString());
if (result.added) {
addedCount++;
} else {
skippedCount++;
}
// Add delay between requests to avoid rate limiting (except after last track)
if (i < tracks.length - 1) {
await new Promise(resolve => setTimeout(resolve, 300));
}
} catch (error) {
console.error(`[PlaylistDownloader] Error adding track ${track.title}:`, error);
}
}
console.log(`[PlaylistDownloader] Added ${addedCount} tracks to queue, skipped ${skippedCount}`);
// Generate m3u8 file
const m3u8Tracks: M3U8Track[] = tracks.map(track => {
// Generate expected path for this track
const paths = generateTrackPath(track, musicFolder, appSettings.deezerFormat, false);
const absolutePath = `${paths.filepath}/${paths.filename}`;
// Convert to relative path from playlists folder
const relativePath = makeRelativePath(absolutePath, 'Music');
return {
duration: track.duration,
artist: track.artist,
title: track.title,
path: relativePath
};
});
// Write m3u8 file
const m3u8Path = await writeM3U8(playlistName, m3u8Tracks, playlistsFolder);
console.log(`[PlaylistDownloader] Playlist saved to: ${m3u8Path}`);
return m3u8Path;
}

View File

@@ -13,6 +13,8 @@ import {
type QueueItem type QueueItem
} from '$lib/stores/downloadQueue'; } from '$lib/stores/downloadQueue';
import { settings } from '$lib/stores/settings'; import { settings } from '$lib/stores/settings';
import { deezerAuth } from '$lib/stores/deezer';
import { syncTrackPaths } from '$lib/library/incrementalSync';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import type { DeezerTrack } from '$lib/types/deezer'; import type { DeezerTrack } from '$lib/types/deezer';
@@ -121,6 +123,13 @@ export class DeezerQueueManager {
throw new Error('Music folder not configured'); throw new Error('Music folder not configured');
} }
// Set ARL for authentication
const authState = get(deezerAuth);
if (!authState.arl) {
throw new Error('Deezer ARL not found - please log in');
}
deezerAPI.setArl(authState.arl);
// Get user data for license token // Get user data for license token
const userData = await deezerAPI.getUserData(); const userData = await deezerAPI.getUserData();
const licenseToken = userData.USER?.OPTIONS?.license_token; const licenseToken = userData.USER?.OPTIONS?.license_token;
@@ -169,6 +178,14 @@ export class DeezerQueueManager {
); );
console.log(`[DeezerQueueManager] Downloaded: ${filePath}`); console.log(`[DeezerQueueManager] Downloaded: ${filePath}`);
// Trigger incremental library sync for this track
try {
await syncTrackPaths([filePath]);
} catch (error) {
console.error('[DeezerQueueManager] Error syncing track to library:', error);
// Non-fatal - track is downloaded, just not in database yet
}
} }
/** /**
@@ -259,6 +276,18 @@ export class DeezerQueueManager {
await Promise.all(running); await Promise.all(running);
console.log(`[DeezerQueueManager] Collection complete: ${completedCount} succeeded, ${failedCount} failed`); console.log(`[DeezerQueueManager] Collection complete: ${completedCount} succeeded, ${failedCount} failed`);
// Trigger incremental library sync for all successfully downloaded tracks
if (completedCount > 0) {
try {
const successfulPaths = results.filter(r => typeof r === 'string') as string[];
await syncTrackPaths(successfulPaths);
console.log(`[DeezerQueueManager] Synced ${successfulPaths.length} tracks to library`);
} catch (error) {
console.error('[DeezerQueueManager] Error syncing collection to library:', error);
// Non-fatal - tracks are downloaded, just not in database yet
}
}
} }
} }

View File

@@ -11,6 +11,7 @@
let { children } = $props(); let { children } = $props();
let playlists = $state<Playlist[]>([]); let playlists = $state<Playlist[]>([]);
let playlistsLoadTimestamp = $state<number>(0);
// Count active downloads (queued or downloading) // Count active downloads (queued or downloading)
let activeDownloads = $derived( let activeDownloads = $derived(
@@ -20,12 +21,25 @@
}).length }).length
); );
onMount(async () => { onMount(() => {
// Run async initialization
(async () => {
await loadSettings(); await loadSettings();
await loadPlaylists(); await loadPlaylists();
})();
// Start background queue processor // Start background queue processor
deezerQueueManager.start(); 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() { async function loadPlaylists() {
@@ -35,10 +49,37 @@
try { try {
playlists = await scanPlaylists($settings.playlistsFolder); playlists = await scanPlaylists($settings.playlistsFolder);
playlistsLoadTimestamp = Date.now();
} catch (e) { } catch (e) {
console.error('Error loading playlists:', 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> </script>
<div class="app-container"> <div class="app-container">
@@ -67,22 +108,22 @@
Services Services
</summary> </summary>
<div class="nav-submenu"> <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" /> <img src="/icons/spotify.png" alt="" class="nav-icon" />
Spotify Spotify
</a> </a> -->
<a href="/services/deezer" class="nav-item nav-subitem"> <a href="/services/deezer" class="nav-item nav-subitem">
<img src="/icons/deezer.png" alt="" class="nav-icon" /> <img src="/icons/deezer.png" alt="" class="nav-icon" />
Deezer Deezer
</a> </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" /> <img src="/icons/soulseek.png" alt="" class="nav-icon" />
Soulseek Soulseek
</a> </a> -->
<a href="/services/musicbrainz" class="nav-item nav-subitem"> <!-- <a href="/services/musicbrainz" class="nav-item nav-subitem">
<img src="/icons/musicbrainz.svg" alt="" class="nav-icon" /> <img src="/icons/musicbrainz.svg" alt="" class="nav-icon" />
MusicBrainz MusicBrainz
</a> </a> -->
</div> </div>
</details> </details>
<details class="nav-collapsible" open> <details class="nav-collapsible" open>

View File

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

View File

@@ -3,6 +3,7 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { deezerAuth } from '$lib/stores/deezer'; import { deezerAuth } from '$lib/stores/deezer';
import { deezerAPI } from '$lib/services/deezer'; import { deezerAPI } from '$lib/services/deezer';
import { settings } from '$lib/stores/settings';
import { import {
getCachedPlaylist, getCachedPlaylist,
getCachedPlaylistTracks, getCachedPlaylistTracks,
@@ -10,24 +11,30 @@
upsertPlaylistTracks, upsertPlaylistTracks,
type DeezerPlaylistTrack type DeezerPlaylistTrack
} from '$lib/library/deezer-database'; } 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 { 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 playlistId = $derived($page.params.id ?? '');
let playlistTitle = $state(''); let playlistTitle = $state('');
let playlistCreator = $state(''); let playlistCreator = $state('');
let playlistTrackCount = $state(0); let playlistTrackCount = $state(0);
let playlistPicture = $state<string | undefined>(undefined); let playlistPicture = $state<string | undefined>(undefined);
let tracks = $state<Track[]>([]); 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 loading = $state(true);
let refreshing = $state(false); let refreshing = $state(false);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let selectedTrackIndex = $state<number | null>(null); let selectedTrackIndex = $state<number | null>(null);
let lastCached = $state<number | null>(null); let lastCached = $state<number | null>(null);
let downloadingTrackIds = $state<Set<string>>(new Set());
const isFavoriteTracks = $derived(playlistId === 'favorite-tracks'); const isFavoriteTracks = $derived(playlistId === 'favorite-tracks');
const existenceCache = new TrackExistenceCache();
onMount(async () => { onMount(async () => {
await loadPlaylist(); await loadPlaylist();
@@ -45,7 +52,9 @@
const favTracks = await getCachedTracks(); const favTracks = await getCachedTracks();
playlistTrackCount = favTracks.length; 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; lastCached = favTracks[0]?.cached_at || null;
} else { } else {
// Load regular playlist // Load regular playlist
@@ -70,9 +79,12 @@
if (cachedTracks.length === 0) { if (cachedTracks.length === 0) {
await refreshPlaylistTracks(); await refreshPlaylistTracks();
} else { } else {
tracks = cachedTracks.map(convertPlaylistTrackToTrack); tracks = cachedTracks.map(convertDbTrackToTrack);
} }
} }
// Check track existence after loading tracks
await checkTrackExistence();
} catch (e) { } catch (e) {
error = 'Error loading playlist: ' + (e instanceof Error ? e.message : String(e)); error = 'Error loading playlist: ' + (e instanceof Error ? e.message : String(e));
} finally { } 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() { async function refreshPlaylistTracks() {
if (!$deezerAuth.arl || refreshing) { if (!$deezerAuth.arl || refreshing) {
return; return;
@@ -101,9 +164,12 @@
// Reload from cache // Reload from cache
const cachedTracks = await getCachedPlaylistTracks(playlistId); const cachedTracks = await getCachedPlaylistTracks(playlistId);
tracks = cachedTracks.map(convertPlaylistTrackToTrack); tracks = cachedTracks.map(convertDbTrackToTrack);
lastCached = Math.floor(Date.now() / 1000); lastCached = Math.floor(Date.now() / 1000);
// Re-check track existence
await checkTrackExistence();
console.log('[Deezer Playlist] Refresh complete!'); console.log('[Deezer Playlist] Refresh complete!');
} catch (e) { } catch (e) {
console.error('Error refreshing playlist tracks:', e); console.error('Error refreshing playlist tracks:', e);
@@ -113,39 +179,108 @@
} }
} }
function convertPlaylistTrackToTrack(deezerTrack: DeezerPlaylistTrack): Track { /**
return { * Convert database playlist track to Track format with Deezer ID attached
path: '', // Deezer tracks don't have a local path */
filename: deezerTrack.title, function convertDbTrackToTrack(dbTrack: DeezerPlaylistTrack): Track {
const track: Track = {
path: '',
filename: dbTrack.title,
format: 'unknown', format: 'unknown',
metadata: { metadata: {
title: deezerTrack.title, title: dbTrack.title,
artist: deezerTrack.artist_name, artist: dbTrack.artist_name,
album: deezerTrack.album_title || undefined, album: dbTrack.album_title || undefined,
trackNumber: deezerTrack.track_number || undefined, trackNumber: dbTrack.track_number || undefined,
duration: deezerTrack.duration 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: '', path: '',
filename: deezerTrack.title, filename: dbTrack.title,
format: 'unknown', format: 'unknown',
metadata: { metadata: {
title: deezerTrack.title, title: dbTrack.title,
artist: deezerTrack.artist_name, artist: dbTrack.artist_name,
album: deezerTrack.album_title || undefined, album: dbTrack.album_title || undefined,
duration: deezerTrack.duration duration: dbTrack.duration
} }
}; };
// Attach Deezer ID for existence checking and downloading
(track as any).deezerId = dbTrack.id;
return track;
} }
function handleTrackClick(index: number) { function handleTrackClick(index: number) {
selectedTrackIndex = index; 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 { function formatTimestamp(timestamp: number | null): string {
if (!timestamp) return 'Never'; if (!timestamp) return 'Never';
const date = new Date(timestamp * 1000); const date = new Date(timestamp * 1000);
@@ -159,116 +294,19 @@
{:else if error} {:else if error}
<p class="error" style="padding: 8px;">{error}</p> <p class="error" style="padding: 8px;">{error}</p>
{:else} {:else}
<!-- Header --> <DeezerCollectionView
<div class="collection-header"> title={playlistTitle}
{#if playlistPicture} subtitle={playlistCreator}
<img metadata="{playlistTrackCount} track{playlistTrackCount !== 1 ? 's' : ''}"
src={playlistPicture} coverImageUrl={playlistPicture}
alt="{playlistTitle} cover" {tracks}
class="collection-cover" {trackExistsMap}
{selectedTrackIndex}
onTrackClick={handleTrackClick}
onDownloadTrack={handleDownloadTrack}
onDownloadPlaylist={handleDownloadPlaylist}
{downloadingTrackIds}
/> />
{: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>
{/if} {/if}
</div> </div>
@@ -282,122 +320,4 @@
.error { .error {
color: #ff6b6b; 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> </style>

View File

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