/** * Download Spotify playlist - converts tracks to Deezer via ISRC, adds to queue, and creates m3u8 file * * Uses a persistent SQLite cache for Spotify→Deezer conversions so that only * new/uncached tracks require API calls. For a 200-track playlist where 3 * tracks were added, this reduces API calls from ~400 to ~6. */ 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 { getCachedConversions, cacheConversion } from '$lib/library/spotify-database'; 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. * Cached conversions are reused — only uncached tracks hit the Deezer API. */ export async function downloadSpotifyPlaylist( playlistName: string, spotifyTracks: SpotifyPlaylistTrack[], playlistsFolder: string, musicFolder: string ): Promise<{ m3u8Path: string; stats: { total: number; queued: number; skipped: number; failed: number; cached: number; }; }> { const appSettings = get(settings); const authState = get(deezerAuth); 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}`); try { await mkdir(playlistsFolder, { recursive: true }); } catch (error) { // Folder might already exist } // --- Look up cached conversions in bulk --- const spotifyIds = spotifyTracks.map((t) => t.track_id); const cachedConversions = await getCachedConversions(spotifyIds); const cachedCount = cachedConversions.size; const uncachedCount = spotifyTracks.length - cachedCount; console.log( `[SpotifyPlaylistDownloader] Cache: ${cachedCount} cached, ${uncachedCount} need conversion` ); let queuedCount = 0; let skippedCount = 0; let failedCount = 0; const successfulTracks: Array<{ deezerTrack: DeezerTrack; spotifyTrack: SpotifyPlaylistTrack; }> = []; for (const spotifyTrack of spotifyTracks) { try { let deezerTrack: DeezerTrack; // --- Check cache first --- const cached = cachedConversions.get(spotifyTrack.track_id); if (cached) { // Use cached conversion — zero API calls deezerTrack = JSON.parse(cached.deezer_track_json) as DeezerTrack; } else { // Convert via API (ISRC → metadata fallback) 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 GW API 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; } 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 }; // Cache the conversion for future runs await cacheConversion( spotifyTrack.track_id, deezerTrack.id, JSON.stringify(deezerTrack), conversionResult.matchMethod || 'unknown' ); console.log( `[SpotifyPlaylistDownloader] Converted & cached: ${deezerTrack.title} (via ${conversionResult.matchMethod})` ); } // Check if track already exists locally if (!appSettings.deezerOverwrite && appSettings.musicFolder) { const exists = await trackExists(deezerTrack, appSettings.musicFolder, appSettings.deezerFormat); if (exists) { skippedCount++; 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 }); } catch (error) { console.error(`[SpotifyPlaylistDownloader] Error processing track ${spotifyTrack.name}:`, error); failedCount++; } } console.log( `[SpotifyPlaylistDownloader] Queued ${queuedCount}, skipped ${skippedCount}, failed ${failedCount}, cached ${cachedCount}` ); // 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`); if (cachedCount > 0) parts.push(`${cachedCount} from cache`); 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 const m3u8Tracks: M3U8Track[] = successfulTracks.map(({ deezerTrack }) => { const paths = generateTrackPath(deezerTrack, musicFolder, appSettings.deezerFormat, false); const absolutePath = `${paths.filepath}/${paths.filename}`; const relativePath = makeRelativePath(absolutePath, playlistsFolder); return { duration: deezerTrack.duration, artist: deezerTrack.artist, title: deezerTrack.title, path: relativePath }; }); const m3u8Path = await writeM3U8(playlistName, m3u8Tracks, playlistsFolder); console.log(`[SpotifyPlaylistDownloader] Playlist saved to: ${m3u8Path}`); setSuccess(`Playlist created: ${playlistName} (${successfulTracks.length} tracks)`); return { m3u8Path, stats: { total: spotifyTracks.length, queued: queuedCount, skipped: skippedCount, failed: failedCount, cached: cachedCount } }; }