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}
+
+ {:else}
+
+ {/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