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