From 651d87af4cf2c4bd1de9e28597d59ba9ce7922a4 Mon Sep 17 00:00:00 2001 From: Markury Date: Thu, 16 Oct 2025 13:25:03 -0400 Subject: [PATCH] feat(spotify): hook existing download queue --- .../components/SpotifyCollectionView.svelte | 96 +++++++- src/lib/services/spotify.ts | 2 +- src/lib/services/spotify/addToQueue.ts | 216 ++++++++++++++++ src/lib/services/spotify/converter.ts | 215 ++++++++++++++++ .../services/spotify/playlistDownloader.ts | 233 ++++++++++++++++++ .../spotify/playlists/[id]/+page.svelte | 69 ++++++ 6 files changed, 829 insertions(+), 2 deletions(-) create mode 100644 src/lib/services/spotify/addToQueue.ts create mode 100644 src/lib/services/spotify/converter.ts create mode 100644 src/lib/services/spotify/playlistDownloader.ts diff --git a/src/lib/components/SpotifyCollectionView.svelte b/src/lib/components/SpotifyCollectionView.svelte index ac5a2cd..cd39241 100644 --- a/src/lib/components/SpotifyCollectionView.svelte +++ b/src/lib/components/SpotifyCollectionView.svelte @@ -1,6 +1,7 @@ @@ -85,6 +118,10 @@ Artist Album Duration + {#if $deezerAuth.loggedIn} + In Library + Actions + {/if} @@ -106,6 +143,20 @@ — {/if} + {#if $deezerAuth.loggedIn} + + {isTrackInLibrary(track) ? '✓' : '✗'} + + + + + {/if} {/each} @@ -131,6 +182,22 @@ {tracks.length} + + {#if $deezerAuth.loggedIn} +
+ Actions + +

Download all tracks via Deezer and save as m3u8 playlist

+
+ {:else} +
+ Downloads +

Deezer login required to download Spotify tracks

+

Sign in to Deezer in Services → Deezer to enable downloads

+
+ {/if} {/if} @@ -250,4 +317,31 @@ min-width: 120px; } + .help-text { + margin: 8px 0 0 0; + font-size: 11px; + color: #808080; + } + + .warning-text { + margin: 0 0 8px 0; + font-size: 12px; + color: #c00; + } + + .in-library { + text-align: center; + font-weight: bold; + font-size: 1.2em; + } + + .actions { + text-align: center; + } + + .download-btn { + padding: 2px 8px; + font-size: 11px; + } + diff --git a/src/lib/services/spotify.ts b/src/lib/services/spotify.ts index 1f917bf..92c4912 100644 --- a/src/lib/services/spotify.ts +++ b/src/lib/services/spotify.ts @@ -203,7 +203,7 @@ export class SpotifyAPI { * Make an authenticated API call to Spotify * Automatically refreshes token if expired */ - private async apiCall(endpoint: string, options: RequestInit = {}): Promise { + async apiCall(endpoint: string, options: RequestInit = {}): Promise { // Check if token needs refresh if (isTokenExpired(this.expiresAt)) { console.log('[Spotify] Token expired, refreshing...'); diff --git a/src/lib/services/spotify/addToQueue.ts b/src/lib/services/spotify/addToQueue.ts new file mode 100644 index 0000000..6cf54b4 --- /dev/null +++ b/src/lib/services/spotify/addToQueue.ts @@ -0,0 +1,216 @@ +/** + * Utility to add a Spotify track to the download queue by converting it to Deezer + * Uses ISRC matching to find the equivalent Deezer track + */ + +import { deezerAPI } from '../deezer'; +import { addToQueue } from '$lib/stores/downloadQueue'; +import { settings } from '$lib/stores/settings'; +import { deezerAuth } from '$lib/stores/deezer'; +import { trackExists } from '../deezer/downloader'; +import { setInfo, setWarning, setError } from '$lib/stores/status'; +import { get } from 'svelte/store'; +import { convertSpotifyTrackToDeezer, type SpotifyTrackInput } from './converter'; +import type { DeezerTrack } from '$lib/types/deezer'; + +export interface SpotifyTrackData { + id: string; + name: string; + artist_name: string; + album_name: string; + duration_ms: number; + isrc?: string | null; +} + +/** + * Add a Spotify track to the download queue by converting it to Deezer + * @param spotifyTrack - Spotify track data (from cache or API) + * @returns Result object with success status and details + */ +export async function addSpotifyTrackToQueue( + spotifyTrack: SpotifyTrackData +): Promise<{ + success: boolean; + deezerId?: string; + matchMethod?: string; + reason?: string; +}> { + // Ensure Deezer authentication + const authState = get(deezerAuth); + if (!authState.loggedIn || !authState.arl) { + setError('Deezer login required for downloads'); + return { + success: false, + reason: 'deezer_auth_required' + }; + } + + deezerAPI.setArl(authState.arl); + + try { + // Convert Spotify track to Deezer + console.log(`[AddSpotifyToQueue] Converting: ${spotifyTrack.name} by ${spotifyTrack.artist_name}`); + + const conversionInput: SpotifyTrackInput = { + id: spotifyTrack.id, + name: spotifyTrack.name, + artists: [spotifyTrack.artist_name], + album: spotifyTrack.album_name, + duration_ms: spotifyTrack.duration_ms, + isrc: spotifyTrack.isrc + }; + + const conversionResult = await convertSpotifyTrackToDeezer(conversionInput); + + if (!conversionResult.success || !conversionResult.deezerTrack) { + const errorMsg = `Could not find "${spotifyTrack.name}" on Deezer`; + console.warn(`[AddSpotifyToQueue] ${errorMsg}`); + setWarning(errorMsg); + return { + success: false, + reason: conversionResult.error || 'conversion_failed' + }; + } + + const deezerPublicTrack = conversionResult.deezerTrack; + const deezerTrackId = deezerPublicTrack.id.toString(); + + console.log( + `[AddSpotifyToQueue] Matched to Deezer track: ${deezerTrackId} via ${conversionResult.matchMethod}` + ); + + // Fetch full track data from Deezer GW API + const deezerFullTrack = await deezerAPI.getTrack(deezerTrackId); + + if (!deezerFullTrack || !deezerFullTrack.SNG_ID) { + const errorMsg = 'Failed to fetch full Deezer track data'; + console.error(`[AddSpotifyToQueue] ${errorMsg}`); + setError(errorMsg); + return { + success: false, + reason: 'deezer_fetch_failed' + }; + } + + // Fetch album data for cover art + let albumData = null; + try { + albumData = await deezerAPI.getAlbumData(deezerFullTrack.ALB_ID.toString()); + } catch (error) { + console.warn('[AddSpotifyToQueue] Could not fetch album data:', error); + } + + // Fetch lyrics + let lyricsData = null; + try { + lyricsData = await deezerAPI.getLyrics(deezerFullTrack.SNG_ID.toString()); + } catch (error) { + console.warn('[AddSpotifyToQueue] Could not fetch lyrics:', error); + } + + // Parse lyrics if available + let lyrics = undefined; + if (lyricsData) { + let syncLrc = ''; + if (lyricsData.LYRICS_SYNC_JSON) { + for (const line of lyricsData.LYRICS_SYNC_JSON) { + const text = line.line || ''; + const timestamp = line.lrc_timestamp || '[00:00.00]'; + syncLrc += `${timestamp}${text}\n`; + } + } + + lyrics = { + sync: syncLrc || undefined, + unsync: lyricsData.LYRICS_TEXT || undefined, + syncID3: undefined + }; + } + + // Build full DeezerTrack object + const deezerTrack: DeezerTrack = { + id: parseInt(deezerFullTrack.SNG_ID, 10), + title: deezerFullTrack.SNG_TITLE, + artist: deezerFullTrack.ART_NAME, + artistId: parseInt(deezerFullTrack.ART_ID, 10), + artists: [deezerFullTrack.ART_NAME], + album: deezerFullTrack.ALB_TITLE, + albumId: parseInt(deezerFullTrack.ALB_ID, 10), + albumArtist: deezerFullTrack.ART_NAME, + albumArtistId: parseInt(deezerFullTrack.ART_ID, 10), + trackNumber: + typeof deezerFullTrack.TRACK_NUMBER === 'number' + ? deezerFullTrack.TRACK_NUMBER + : parseInt(deezerFullTrack.TRACK_NUMBER, 10), + discNumber: + typeof deezerFullTrack.DISK_NUMBER === 'number' + ? deezerFullTrack.DISK_NUMBER + : parseInt(deezerFullTrack.DISK_NUMBER, 10), + duration: + typeof deezerFullTrack.DURATION === 'number' + ? deezerFullTrack.DURATION + : parseInt(deezerFullTrack.DURATION, 10), + explicit: deezerFullTrack.EXPLICIT_LYRICS === 1, + md5Origin: deezerFullTrack.MD5_ORIGIN, + mediaVersion: deezerFullTrack.MEDIA_VERSION, + trackToken: deezerFullTrack.TRACK_TOKEN, + // Enhanced metadata + lyrics, + albumCoverUrl: albumData?.ALB_PICTURE + ? `https://e-cdns-images.dzcdn.net/images/cover/${albumData.ALB_PICTURE}/500x500-000000-80-0-0.jpg` + : undefined, + albumCoverXlUrl: albumData?.ALB_PICTURE + ? `https://e-cdns-images.dzcdn.net/images/cover/${albumData.ALB_PICTURE}/1000x1000-000000-80-0-0.jpg` + : undefined, + label: albumData?.LABEL_NAME, + barcode: albumData?.UPC, + releaseDate: deezerFullTrack.PHYSICAL_RELEASE_DATE, + genre: deezerFullTrack.GENRE ? [deezerFullTrack.GENRE] : undefined, + copyright: deezerFullTrack.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(deezerTrack, appSettings.musicFolder, appSettings.deezerFormat); + + if (exists) { + console.log(`[AddSpotifyToQueue] Skipping "${deezerTrack.title}" - already exists`); + setWarning(`Skipped: ${deezerTrack.title} (already exists)`); + return { + success: false, + deezerId: deezerTrackId, + matchMethod: conversionResult.matchMethod, + reason: 'already_exists' + }; + } + } + + // Add to queue + await addToQueue({ + source: 'deezer', + type: 'track', + title: deezerTrack.title, + artist: deezerTrack.artist, + totalTracks: 1, + downloadObject: deezerTrack + }); + + setInfo(`Queued: ${deezerTrack.title}`); + + return { + success: true, + deezerId: deezerTrackId, + matchMethod: conversionResult.matchMethod + }; + } catch (error) { + const errorMsg = `Error adding track to queue: ${error instanceof Error ? error.message : 'Unknown error'}`; + console.error('[AddSpotifyToQueue]', errorMsg); + setError(errorMsg); + return { + success: false, + reason: 'queue_error' + }; + } +} diff --git a/src/lib/services/spotify/converter.ts b/src/lib/services/spotify/converter.ts new file mode 100644 index 0000000..b472d85 --- /dev/null +++ b/src/lib/services/spotify/converter.ts @@ -0,0 +1,215 @@ +/** + * Spotify to Deezer track conversion utilities + * Matches Spotify tracks to Deezer tracks using ISRC codes (primary) or metadata search (fallback) + */ + +import { fetch } from '@tauri-apps/plugin-http'; +import { deezerAPI } from '../deezer'; + +export interface SpotifyTrackInput { + id: string; + name: string; + artists: string[]; + album: string; + duration_ms: number; + isrc?: string | null; +} + +export interface DeezerMatchResult { + success: boolean; + deezerTrack?: any; + matchMethod?: 'isrc' | 'metadata' | 'none'; + error?: string; +} + +/** + * Search Deezer for a track by ISRC code + * This is the primary and most reliable matching method + */ +export async function searchDeezerByISRC(isrc: string): Promise { + if (!isrc || isrc.trim().length === 0) { + return null; + } + + try { + console.log(`[Converter] Searching Deezer by ISRC: ${isrc}`); + + const url = `https://api.deezer.com/2.0/track/isrc:${encodeURIComponent(isrc)}`; + const response = await fetch(url, { + method: 'GET', + headers: { + 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36', + 'Accept': 'application/json' + }, + connectTimeout: 30000 + }); + + if (!response.ok) { + console.warn(`[Converter] ISRC search failed with status: ${response.status}`); + return null; + } + + const result = await response.json(); + + // Check if we got an error response + if (result.error) { + console.warn(`[Converter] ISRC search returned error:`, result.error); + return null; + } + + // Valid track found + if (result.id) { + console.log(`[Converter] Found Deezer track by ISRC: ${result.id} - ${result.title}`); + return result; + } + + return null; + } catch (error) { + console.error('[Converter] Error searching by ISRC:', error); + return null; + } +} + +/** + * Search Deezer for a track by metadata (title + artist) + * Used as fallback when ISRC is not available or doesn't match + */ +export async function searchDeezerByMetadata( + title: string, + artist: string, + durationMs?: number +): Promise { + try { + // Build search query: "artist title" + const query = `${artist} ${title}`.trim(); + console.log(`[Converter] Searching Deezer by metadata: "${query}"`); + + const searchResults = await deezerAPI.searchTracks(query, 10); + + if (!searchResults.data || searchResults.data.length === 0) { + console.warn(`[Converter] No results found for: "${query}"`); + return null; + } + + // Try to find best match + // Priority: exact title match, then duration match (±2 seconds) + const durationSec = durationMs ? Math.floor(durationMs / 1000) : undefined; + + for (const track of searchResults.data) { + // Check title similarity (case-insensitive) + const titleMatch = track.title.toLowerCase() === title.toLowerCase(); + + // Check duration if available (within 2 seconds tolerance) + const durationMatch = !durationSec || Math.abs(track.duration - durationSec) <= 2; + + if (titleMatch && durationMatch) { + console.log(`[Converter] Found exact match by metadata: ${track.id} - ${track.title}`); + return track; + } + } + + // If no exact match, return first result as best guess + const firstResult = searchResults.data[0]; + console.log(`[Converter] Using first result as best match: ${firstResult.id} - ${firstResult.title}`); + return firstResult; + } catch (error) { + console.error('[Converter] Error searching by metadata:', error); + return null; + } +} + +/** + * Convert a Spotify track to Deezer track ID + * Uses ISRC matching first, falls back to metadata search + */ +export async function convertSpotifyTrackToDeezer( + spotifyTrack: SpotifyTrackInput +): Promise { + console.log(`[Converter] Converting Spotify track: ${spotifyTrack.name} by ${spotifyTrack.artists.join(', ')}`); + + // Try ISRC matching first (most reliable) + if (spotifyTrack.isrc) { + const deezerTrack = await searchDeezerByISRC(spotifyTrack.isrc); + if (deezerTrack) { + return { + success: true, + deezerTrack, + matchMethod: 'isrc' + }; + } + console.log(`[Converter] ISRC match failed for: ${spotifyTrack.isrc}`); + } + + // Fallback to metadata search + const artist = spotifyTrack.artists[0] || 'Unknown'; + const deezerTrack = await searchDeezerByMetadata( + spotifyTrack.name, + artist, + spotifyTrack.duration_ms + ); + + if (deezerTrack) { + return { + success: true, + deezerTrack, + matchMethod: 'metadata' + }; + } + + // No match found + console.warn(`[Converter] Could not find Deezer match for: ${spotifyTrack.name} by ${artist}`); + return { + success: false, + matchMethod: 'none', + error: 'No match found on Deezer' + }; +} + +/** + * Convert multiple Spotify tracks to Deezer track IDs + * Returns both successful conversions and failed tracks + */ +export async function convertSpotifyTracksBatch( + spotifyTracks: SpotifyTrackInput[] +): Promise<{ + conversions: Array<{ spotifyId: string; deezerId: string; matchMethod: string }>; + failures: Array<{ spotifyId: string; name: string; artist: string; error: string }>; +}> { + const conversions: Array<{ spotifyId: string; deezerId: string; matchMethod: string }> = []; + const failures: Array<{ spotifyId: string; name: string; artist: string; error: string }> = []; + + console.log(`[Converter] Converting ${spotifyTracks.length} Spotify tracks to Deezer...`); + + for (const track of spotifyTracks) { + try { + const result = await convertSpotifyTrackToDeezer(track); + + if (result.success && result.deezerTrack) { + conversions.push({ + spotifyId: track.id, + deezerId: result.deezerTrack.id.toString(), + matchMethod: result.matchMethod || 'unknown' + }); + } else { + failures.push({ + spotifyId: track.id, + name: track.name, + artist: track.artists[0] || 'Unknown', + error: result.error || 'Unknown error' + }); + } + } catch (error) { + console.error(`[Converter] Error converting track ${track.name}:`, error); + failures.push({ + spotifyId: track.id, + name: track.name, + artist: track.artists[0] || 'Unknown', + error: error instanceof Error ? error.message : 'Conversion error' + }); + } + } + + console.log(`[Converter] Conversion complete: ${conversions.length} successful, ${failures.length} failed`); + + return { conversions, failures }; +} diff --git a/src/lib/services/spotify/playlistDownloader.ts b/src/lib/services/spotify/playlistDownloader.ts new file mode 100644 index 0000000..0ad9827 --- /dev/null +++ b/src/lib/services/spotify/playlistDownloader.ts @@ -0,0 +1,233 @@ +/** + * Download Spotify playlist - converts tracks to Deezer via ISRC, adds to queue, and creates m3u8 file + */ + +import { addToQueue } from '$lib/stores/downloadQueue'; +import { trackExists } from '$lib/services/deezer/downloader'; +import { writeM3U8, makeRelativePath, type M3U8Track } from '$lib/library/m3u8'; +import { generateTrackPath } from '$lib/services/deezer/paths'; +import { settings } from '$lib/stores/settings'; +import { deezerAuth } from '$lib/stores/deezer'; +import { deezerAPI } from '$lib/services/deezer'; +import { setInfo, setSuccess, setWarning } from '$lib/stores/status'; +import { get } from 'svelte/store'; +import { mkdir } from '@tauri-apps/plugin-fs'; +import { convertSpotifyTrackToDeezer, type SpotifyTrackInput } from './converter'; +import type { DeezerTrack } from '$lib/types/deezer'; + +export interface SpotifyPlaylistTrack { + id: number | string; + track_id: string; + name: string; + artist_name: string; + album_name: string; + duration_ms: number; + isrc?: string | null; +} + +/** + * Download a Spotify playlist by converting tracks to Deezer equivalents + * - Converts all tracks via ISRC matching + * - Adds converted tracks to the download queue (respects overwrite setting) + * - Creates an m3u8 playlist file with relative paths + * + * @param playlistName - Name of the playlist + * @param spotifyTracks - Array of Spotify track objects + * @param playlistsFolder - Path to playlists folder + * @param musicFolder - Path to music folder + * @returns Object with m3u8 path and statistics + */ +export async function downloadSpotifyPlaylist( + playlistName: string, + spotifyTracks: SpotifyPlaylistTrack[], + playlistsFolder: string, + musicFolder: string +): Promise<{ + m3u8Path: string; + stats: { + total: number; + queued: number; + skipped: number; + failed: number; + }; +}> { + const appSettings = get(settings); + const authState = get(deezerAuth); + + // Ensure Deezer is authenticated + if (!authState.loggedIn || !authState.arl) { + throw new Error('Deezer authentication required for downloads'); + } + + deezerAPI.setArl(authState.arl); + + console.log(`[SpotifyPlaylistDownloader] Starting download for playlist: ${playlistName}`); + console.log(`[SpotifyPlaylistDownloader] Tracks: ${spotifyTracks.length}`); + + // Ensure playlists folder exists + try { + await mkdir(playlistsFolder, { recursive: true }); + } catch (error) { + // Folder might already exist + } + + // Track statistics + let queuedCount = 0; + let skippedCount = 0; + let failedCount = 0; + + // Track successful conversions for m3u8 generation + const successfulTracks: Array<{ + deezerTrack: DeezerTrack; + spotifyTrack: SpotifyPlaylistTrack; + }> = []; + + // Convert and queue each track + for (const spotifyTrack of spotifyTracks) { + try { + // Convert Spotify track to Deezer + const conversionInput: SpotifyTrackInput = { + id: spotifyTrack.track_id, + name: spotifyTrack.name, + artists: [spotifyTrack.artist_name], + album: spotifyTrack.album_name, + duration_ms: spotifyTrack.duration_ms, + isrc: spotifyTrack.isrc + }; + + const conversionResult = await convertSpotifyTrackToDeezer(conversionInput); + + if (!conversionResult.success || !conversionResult.deezerTrack) { + console.warn( + `[SpotifyPlaylistDownloader] Failed to convert: ${spotifyTrack.name} - ${conversionResult.error}` + ); + failedCount++; + continue; + } + + const deezerPublicTrack = conversionResult.deezerTrack; + + // Fetch full track data from Deezer GW API (needed for download) + const deezerTrackId = deezerPublicTrack.id.toString(); + const deezerFullTrack = await deezerAPI.getTrack(deezerTrackId); + + if (!deezerFullTrack || !deezerFullTrack.SNG_ID) { + console.warn(`[SpotifyPlaylistDownloader] Could not fetch full Deezer track data for ID: ${deezerTrackId}`); + failedCount++; + continue; + } + + // Build DeezerTrack object + const deezerTrack: DeezerTrack = { + id: parseInt(deezerFullTrack.SNG_ID, 10), + title: deezerFullTrack.SNG_TITLE, + artist: deezerFullTrack.ART_NAME, + artistId: parseInt(deezerFullTrack.ART_ID, 10), + artists: [deezerFullTrack.ART_NAME], + album: deezerFullTrack.ALB_TITLE, + albumId: parseInt(deezerFullTrack.ALB_ID, 10), + albumArtist: deezerFullTrack.ART_NAME, + albumArtistId: parseInt(deezerFullTrack.ART_ID, 10), + trackNumber: + typeof deezerFullTrack.TRACK_NUMBER === 'number' + ? deezerFullTrack.TRACK_NUMBER + : parseInt(deezerFullTrack.TRACK_NUMBER, 10), + discNumber: + typeof deezerFullTrack.DISK_NUMBER === 'number' + ? deezerFullTrack.DISK_NUMBER + : parseInt(deezerFullTrack.DISK_NUMBER, 10), + duration: + typeof deezerFullTrack.DURATION === 'number' + ? deezerFullTrack.DURATION + : parseInt(deezerFullTrack.DURATION, 10), + explicit: deezerFullTrack.EXPLICIT_LYRICS === 1, + md5Origin: deezerFullTrack.MD5_ORIGIN, + mediaVersion: deezerFullTrack.MEDIA_VERSION, + trackToken: deezerFullTrack.TRACK_TOKEN + }; + + // Check if track already exists (if overwrite is disabled) + if (!appSettings.deezerOverwrite && appSettings.musicFolder) { + const exists = await trackExists(deezerTrack, appSettings.musicFolder, appSettings.deezerFormat); + if (exists) { + console.log(`[SpotifyPlaylistDownloader] Skipping "${deezerTrack.title}" - already exists`); + skippedCount++; + // Still add to successful tracks for m3u8 generation + successfulTracks.push({ deezerTrack, spotifyTrack }); + continue; + } + } + + // Queue track for download + await addToQueue({ + source: 'deezer', + type: 'track', + title: deezerTrack.title, + artist: deezerTrack.artist, + totalTracks: 1, + downloadObject: deezerTrack + }); + + queuedCount++; + successfulTracks.push({ deezerTrack, spotifyTrack }); + + console.log( + `[SpotifyPlaylistDownloader] Queued: ${deezerTrack.title} (matched via ${conversionResult.matchMethod})` + ); + } catch (error) { + console.error(`[SpotifyPlaylistDownloader] Error processing track ${spotifyTrack.name}:`, error); + failedCount++; + } + } + + console.log( + `[SpotifyPlaylistDownloader] Queued ${queuedCount} tracks, skipped ${skippedCount}, failed ${failedCount}` + ); + + // Show queue status message + if (queuedCount > 0) { + const parts = [`Queued ${queuedCount} track${queuedCount !== 1 ? 's' : ''}`]; + if (skippedCount > 0) parts.push(`${skippedCount} skipped`); + if (failedCount > 0) parts.push(`${failedCount} not found`); + setInfo(parts.join(', ')); + } else if (skippedCount > 0) { + setWarning(`All ${skippedCount} tracks already exist`); + } else if (failedCount > 0) { + setWarning(`Could not find ${failedCount} tracks on Deezer`); + } + + // Generate m3u8 file using Deezer track paths + const m3u8Tracks: M3U8Track[] = successfulTracks.map(({ deezerTrack, spotifyTrack }) => { + // Generate expected path for this Deezer track + const paths = generateTrackPath(deezerTrack, musicFolder, appSettings.deezerFormat, false); + const absolutePath = `${paths.filepath}/${paths.filename}`; + + // Convert to relative path from playlists folder + const relativePath = makeRelativePath(absolutePath, 'Music'); + + return { + duration: deezerTrack.duration, + artist: deezerTrack.artist, + title: deezerTrack.title, + path: relativePath + }; + }); + + // Write m3u8 file + const m3u8Path = await writeM3U8(playlistName, m3u8Tracks, playlistsFolder); + + console.log(`[SpotifyPlaylistDownloader] Playlist saved to: ${m3u8Path}`); + + // Show success message for playlist creation + setSuccess(`Playlist created: ${playlistName} (${successfulTracks.length} tracks)`); + + return { + m3u8Path, + stats: { + total: spotifyTracks.length, + queued: queuedCount, + skipped: skippedCount, + failed: failedCount + } + }; +} diff --git a/src/routes/services/spotify/playlists/[id]/+page.svelte b/src/routes/services/spotify/playlists/[id]/+page.svelte index d3a16e1..f503d1a 100644 --- a/src/routes/services/spotify/playlists/[id]/+page.svelte +++ b/src/routes/services/spotify/playlists/[id]/+page.svelte @@ -14,6 +14,11 @@ } from '$lib/library/spotify-database'; import SpotifyCollectionView from '$lib/components/SpotifyCollectionView.svelte'; import type { Track, AudioFormat } from '$lib/types/track'; + import { addSpotifyTrackToQueue } from '$lib/services/spotify/addToQueue'; + import { downloadSpotifyPlaylist } from '$lib/services/spotify/playlistDownloader'; + import { settings } from '$lib/stores/settings'; + import { deezerAuth } from '$lib/stores/deezer'; + import { setError } from '$lib/stores/status'; let playlistId = $derived($page.params.id!); let loading = $state(true); @@ -22,6 +27,8 @@ let playlistTracks = $state([]); let selectedTrackIndex = $state(null); let coverImageUrl = $state(undefined); + let trackExistsMap = $state(new Map()); + let downloadingTrackIds = $state(new Set()); // Convert Spotify tracks to Track type for CollectionView let tracks = $derived( @@ -29,6 +36,7 @@ path: '', filename: '', format: 'unknown' as AudioFormat, + spotifyId: track.track_id, // Store Spotify ID for downloading metadata: { title: track.name || 'Unknown Title', artist: track.artist_name || 'Unknown Artist', @@ -200,6 +208,63 @@ function handleTrackClick(index: number) { selectedTrackIndex = index; } + + async function handleDownloadTrack(index: number) { + if (!$deezerAuth.loggedIn) { + setError('Deezer login required for downloads'); + return; + } + + const spotifyTrack = playlistTracks[index]; + if (!spotifyTrack) return; + + // Mark as downloading + downloadingTrackIds = new Set(downloadingTrackIds).add(spotifyTrack.track_id); + + try { + await addSpotifyTrackToQueue({ + id: spotifyTrack.track_id, + name: spotifyTrack.name, + artist_name: spotifyTrack.artist_name, + album_name: spotifyTrack.album_name, + duration_ms: spotifyTrack.duration_ms, + isrc: spotifyTrack.isrc + }); + } catch (error) { + console.error('Error downloading track:', error); + } finally { + // Remove from downloading set + const newSet = new Set(downloadingTrackIds); + newSet.delete(spotifyTrack.track_id); + downloadingTrackIds = newSet; + } + } + + async function handleDownloadPlaylist() { + if (!$deezerAuth.loggedIn) { + setError('Deezer login required for downloads'); + return; + } + + if (!playlist || !$settings.musicFolder || !$settings.playlistsFolder) { + setError('Please configure music and playlists folders in settings'); + return; + } + + try { + await downloadSpotifyPlaylist( + playlist.name, + playlistTracks, + $settings.playlistsFolder, + $settings.musicFolder + ); + } catch (error) { + console.error('Error downloading playlist:', error); + setError( + 'Error downloading playlist: ' + (error instanceof Error ? error.message : String(error)) + ); + } + } {#if loading} @@ -225,8 +290,12 @@ metadata="{playlist.track_count} tracks" {coverImageUrl} {tracks} + {trackExistsMap} {selectedTrackIndex} + {downloadingTrackIds} onTrackClick={handleTrackClick} + onDownloadTrack={handleDownloadTrack} + onDownloadPlaylist={handleDownloadPlaylist} /> {/if}