From e1e7817c71241eecf43d47f22700903cd576d222 Mon Sep 17 00:00:00 2001 From: Markury Date: Thu, 2 Oct 2025 19:26:12 -0400 Subject: [PATCH] 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. --- src-tauri/src/lib.rs | 9 +- src-tauri/src/metadata.rs | 87 ++++ src/lib/components/CollectionView.svelte | 6 +- .../components/DeezerCollectionView.svelte | 325 ++++++++++++++ src/lib/library/incrementalSync.ts | 203 +++++++++ src/lib/library/m3u8.ts | 89 ++++ src/lib/library/playlist.ts | 142 +++--- src/lib/library/trackMatcher.ts | 143 ++++++ src/lib/services/deezer.ts | 3 +- src/lib/services/deezer/addToQueue.ts | 35 +- src/lib/services/deezer/downloader.ts | 19 +- src/lib/services/deezer/playlistDownloader.ts | 93 ++++ src/lib/services/deezer/queueManager.ts | 29 ++ src/routes/+layout.svelte | 59 ++- src/routes/playlists/[name]/+page.svelte | 10 +- .../deezer/playlists/[id]/+page.svelte | 420 +++++++----------- src/routes/settings/+page.svelte | 1 + 17 files changed, 1341 insertions(+), 332 deletions(-) create mode 100644 src-tauri/src/metadata.rs create mode 100644 src/lib/components/DeezerCollectionView.svelte create mode 100644 src/lib/library/incrementalSync.ts create mode 100644 src/lib/library/m3u8.ts create mode 100644 src/lib/library/trackMatcher.ts create mode 100644 src/lib/services/deezer/playlistDownloader.ts diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 28659fd..630b6c1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ use tauri_plugin_sql::{Migration, MigrationKind}; mod tagger; +mod metadata; // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ #[tauri::command] @@ -19,6 +20,12 @@ fn tag_audio_file( 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::read_audio_metadata(&path) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let library_migrations = vec![Migration { @@ -136,7 +143,7 @@ pub fn run() { .plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_dialog::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!()) .expect("error while running tauri application"); } diff --git a/src-tauri/src/metadata.rs b/src-tauri/src/metadata.rs new file mode 100644 index 0000000..49f1ab2 --- /dev/null +++ b/src-tauri/src/metadata.rs @@ -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, + pub artist: Option, + pub album: Option, + pub album_artist: Option, + pub track_number: Option, + pub duration: Option, // in seconds +} + +/// Read metadata from an audio file (MP3 or FLAC) +pub fn read_audio_metadata(path: &str) -> Result { + 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 { + 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 { + 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 { + 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::().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, + }) +} diff --git a/src/lib/components/CollectionView.svelte b/src/lib/components/CollectionView.svelte index e0907d8..59e57f3 100644 --- a/src/lib/components/CollectionView.svelte +++ b/src/lib/components/CollectionView.svelte @@ -102,10 +102,10 @@ {track.metadata.trackNumber ?? i + 1} - {track.metadata.title || track.filename} + {track.metadata.title ?? '—'} {#if showAlbumColumn} - {track.metadata.artist || '—'} - {track.metadata.album || '—'} + {track.metadata.artist ?? '—'} + {track.metadata.album ?? '—'} {/if} {#if track.metadata.duration} diff --git a/src/lib/components/DeezerCollectionView.svelte b/src/lib/components/DeezerCollectionView.svelte new file mode 100644 index 0000000..69dfa0b --- /dev/null +++ b/src/lib/components/DeezerCollectionView.svelte @@ -0,0 +1,325 @@ + + + +
+ {#if coverImageUrl} + {title} cover + {:else} +
+ {/if} +
+

{title}

+ {#if subtitle} +

{subtitle}

+ {/if} + {#if metadata} + + {/if} +
+
+ +
+ + + +
  • + +
  • +
  • + +
  • +
    + + +
    +
    + {#if viewMode === 'tracks'} + +
    + + + + + + + + + + + + + + {#each tracks as track, i} + handleTrackClick(i)} + > + + + + + + + + + {/each} + +
    #TitleArtistAlbumDurationIn LibraryActions
    + {track.metadata.trackNumber ?? i + 1} + {track.metadata.title ?? '—'}{track.metadata.artist ?? '—'}{track.metadata.album ?? '—'} + {#if track.metadata.duration} + {Math.floor(track.metadata.duration / 60)}:{String(Math.floor(track.metadata.duration % 60)).padStart(2, '0')} + {:else} + — + {/if} + + {isTrackInLibrary(track) ? '✓' : '✗'} + + +
    +
    + {:else if viewMode === 'info'} + +
    +
    + Playlist Information +
    + Title: + {title} +
    + {#if subtitle} +
    + Creator: + {subtitle} +
    + {/if} +
    + Tracks: + {tracks.length} +
    +
    + +
    + Actions + +

    Download all tracks and save as m3u8 playlist

    +
    +
    + {/if} +
    +
    +
    + + diff --git a/src/lib/library/incrementalSync.ts b/src/lib/library/incrementalSync.ts new file mode 100644 index 0000000..190c49e --- /dev/null +++ b/src/lib/library/incrementalSync.ts @@ -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 { + 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 { + 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 { + // Group tracks by artist path + const artistPaths = new Set(); + + 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; +} diff --git a/src/lib/library/m3u8.ts b/src/lib/library/m3u8.ts new file mode 100644 index 0000000..c66a87f --- /dev/null +++ b/src/lib/library/m3u8.ts @@ -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 { + // 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('/')}`; +} diff --git a/src/lib/library/playlist.ts b/src/lib/library/playlist.ts index d2292e8..7ad45c1 100644 --- a/src/lib/library/playlist.ts +++ b/src/lib/library/playlist.ts @@ -1,5 +1,5 @@ -import { readTextFile, readFile, exists, readDir } from '@tauri-apps/plugin-fs'; -import { parseBuffer } from 'music-metadata'; +import { readTextFile, exists, readDir } from '@tauri-apps/plugin-fs'; +import { invoke } from '@tauri-apps/api/core'; 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 * Supports both basic M3U and extended M3U8 format + * Returns tracks with optional EXTINF metadata */ -export async function parsePlaylist(playlistPath: string): Promise { +export async function parsePlaylist(playlistPath: string): Promise { try { const content = await readTextFile(playlistPath); 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++) { 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'))) { 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')) { - i++; // Move to next line - if (i < lines.length && lines[i] && !lines[i].startsWith('#')) { - tracks.push(lines[i]); + const match = line.match(/^#EXTINF:(\d+),(.+)$/); + if (match) { + 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('#')) { - // Regular M3U format - just file paths - tracks.push(line); + // This is a file path + tracks.push({ + path: line, + extinfData: currentExtinf + }); + currentExtinf = undefined; // Reset for next track } } @@ -109,41 +141,37 @@ async function findActualFilePath(basePath: string): Promise { } /** - * Read metadata from audio file + * Read metadata from audio file using Rust backend */ async function readAudioMetadata(filePath: string, format: AudioFormat): Promise { try { - // Read file as binary - const fileData = await readFile(filePath); + // Call Rust command to read metadata + 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 mimeMap: Record = { - 'flac': 'audio/flac', - 'mp3': 'audio/mpeg', - 'opus': 'audio/opus', - 'ogg': 'audio/ogg', - 'm4a': 'audio/mp4', - 'wav': 'audio/wav', - 'unknown': 'audio/mpeg' + const result: TrackMetadata = { + title: metadata.title, + artist: metadata.artist, + album: metadata.album, + albumArtist: metadata.albumArtist, + trackNumber: metadata.trackNumber, + duration: metadata.duration, }; - // Parse metadata from buffer - const metadata = await parseBuffer(fileData, mimeMap[format], { duration: true }); + // Log what we got + if (!result.title && !result.artist && !result.album) { + console.warn(`[Playlist] No metadata found in file: ${filePath}`); + } - return { - 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 - }; + return result; } catch (error) { - console.error('Error reading audio metadata:', error); + console.error(`[Playlist] Error reading metadata from ${filePath}:`, error); return {}; } } @@ -190,11 +218,13 @@ export async function loadPlaylistTracks( playlistName: string, baseFolder: string ): Promise { - const trackPaths = await parsePlaylist(playlistPath); + const parsedTracks = await parsePlaylist(playlistPath); // Load tracks with metadata in parallel 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 let fullPath = trackPath.startsWith('/') || trackPath.includes(':\\') ? trackPath // Absolute path @@ -209,17 +239,29 @@ export async function loadPlaylistTracks( const filename = trackPath.split('/').pop() || trackPath.split('\\').pop() || trackPath; const format = getAudioFormat(filename); - // Read metadata from actual audio file if found - const metadata = actualPath - ? await readAudioMetadata(actualPath, format) - : {}; + // Start with EXTINF metadata if available + let metadata: TrackMetadata = {}; + 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 - if (!metadata.title) { - const nameWithoutExt = filename.replace(/\.(flac|mp3|opus|ogg|m4a|wav)$/i, ''); - const parts = nameWithoutExt.split(' - '); - metadata.title = parts.length > 1 ? parts[1] : nameWithoutExt; - metadata.artist = parts.length > 1 ? parts[0] : undefined; + // Try to read metadata from actual audio file if found + // Only override EXTINF data if file has actual metadata values + if (actualPath) { + const fileMetadata = await readAudioMetadata(actualPath, format); + // Merge, but only override if file metadata has values + 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 { diff --git a/src/lib/library/trackMatcher.ts b/src/lib/library/trackMatcher.ts new file mode 100644 index 0000000..71b6f81 --- /dev/null +++ b/src/lib/library/trackMatcher.ts @@ -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 { + 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> { + const results = new Map(); + + // 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 = new Map(); + + /** + * Check if track exists, using cache if available + */ + async checkTrack( + track: DeezerTrack, + musicFolder: string, + format: string + ): Promise { + 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> { + const results = new Map(); + + // 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}`; + } +} diff --git a/src/lib/services/deezer.ts b/src/lib/services/deezer.ts index d02be4a..e700d7d 100644 --- a/src/lib/services/deezer.ts +++ b/src/lib/services/deezer.ts @@ -412,7 +412,8 @@ export class DeezerAPI { console.log('[DEBUG] Getting track download URL...', { trackToken, format, licenseToken }); 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); const response = await fetch('https://media.deezer.com/v1/get_url', { diff --git a/src/lib/services/deezer/addToQueue.ts b/src/lib/services/deezer/addToQueue.ts index ab1bad4..61ddd2c 100644 --- a/src/lib/services/deezer/addToQueue.ts +++ b/src/lib/services/deezer/addToQueue.ts @@ -5,13 +5,24 @@ import { deezerAPI } from '$lib/services/deezer'; 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 + * Respects the overwrite setting - skips tracks that already exist if overwrite is false * @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 { +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 const trackInfo = await deezerAPI.getTrack(trackId); @@ -66,9 +77,9 @@ export async function addDeezerTrackToQueue(trackId: string): Promise { albumId: trackInfo.ALB_ID, albumArtist: trackInfo.ART_NAME, albumArtistId: trackInfo.ART_ID, - trackNumber: trackInfo.TRACK_NUMBER || 1, - discNumber: trackInfo.DISK_NUMBER || 1, - duration: trackInfo.DURATION, + trackNumber: typeof trackInfo.TRACK_NUMBER === 'number' ? trackInfo.TRACK_NUMBER : parseInt(trackInfo.TRACK_NUMBER, 10), + discNumber: typeof trackInfo.DISK_NUMBER === 'number' ? trackInfo.DISK_NUMBER : parseInt(trackInfo.DISK_NUMBER, 10), + duration: typeof trackInfo.DURATION === 'number' ? trackInfo.DURATION : parseInt(trackInfo.DURATION, 10), explicit: trackInfo.EXPLICIT_LYRICS === 1, md5Origin: trackInfo.MD5_ORIGIN, mediaVersion: trackInfo.MEDIA_VERSION, @@ -84,6 +95,18 @@ export async function addDeezerTrackToQueue(trackId: string): Promise { 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) await addToQueue({ source: 'deezer', @@ -93,4 +116,6 @@ export async function addDeezerTrackToQueue(trackId: string): Promise { totalTracks: 1, downloadObject: track }); + + return { added: true }; } diff --git a/src/lib/services/deezer/downloader.ts b/src/lib/services/deezer/downloader.ts index 74e93dc..80aa5dd 100644 --- a/src/lib/services/deezer/downloader.ts +++ b/src/lib/services/deezer/downloader.ts @@ -124,18 +124,13 @@ export async function downloadTrack( // Apply tags (works for both MP3 and FLAC) console.log('Tagging audio file...'); - try { - await tagAudioFile( - finalPath, - track, - appSettings.embedCoverArt ? coverData : undefined, - appSettings.embedLyrics - ); - console.log('Tagging complete!'); - } catch (error) { - console.error('Failed to tag audio file:', error); - // Non-fatal error - file is still downloaded, just not tagged - } + await tagAudioFile( + finalPath, + track, + appSettings.embedCoverArt ? coverData : undefined, + appSettings.embedLyrics + ); + console.log('Tagging complete!'); // Save LRC sidecar file if enabled if (appSettings.saveLrcFile && track.lyrics?.sync) { diff --git a/src/lib/services/deezer/playlistDownloader.ts b/src/lib/services/deezer/playlistDownloader.ts new file mode 100644 index 0000000..6823a97 --- /dev/null +++ b/src/lib/services/deezer/playlistDownloader.ts @@ -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 { + 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; +} diff --git a/src/lib/services/deezer/queueManager.ts b/src/lib/services/deezer/queueManager.ts index 69b255d..9da08f7 100644 --- a/src/lib/services/deezer/queueManager.ts +++ b/src/lib/services/deezer/queueManager.ts @@ -13,6 +13,8 @@ import { type QueueItem } from '$lib/stores/downloadQueue'; import { settings } from '$lib/stores/settings'; +import { deezerAuth } from '$lib/stores/deezer'; +import { syncTrackPaths } from '$lib/library/incrementalSync'; import { get } from 'svelte/store'; import type { DeezerTrack } from '$lib/types/deezer'; @@ -121,6 +123,13 @@ export class DeezerQueueManager { 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 const userData = await deezerAPI.getUserData(); const licenseToken = userData.USER?.OPTIONS?.license_token; @@ -169,6 +178,14 @@ export class DeezerQueueManager { ); 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); 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 + } + } } } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 7bc8162..162ff6f 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -11,6 +11,7 @@ let { children } = $props(); let playlists = $state([]); + let playlistsLoadTimestamp = $state(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 + } + }
    @@ -67,22 +108,22 @@ Services
    @@ -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; - } diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index b436de9..abe1171 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -233,6 +233,7 @@ /> + When disabled, tracks that already exist will not be added to the download queue