feat(spotify): hook existing download queue

This commit is contained in:
2025-10-16 13:25:03 -04:00
parent df4967dd55
commit 651d87af4c
6 changed files with 829 additions and 2 deletions

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { Track } from '$lib/types/track'; import type { Track } from '$lib/types/track';
import PageDecoration from '$lib/components/PageDecoration.svelte'; import PageDecoration from '$lib/components/PageDecoration.svelte';
import { deezerAuth } from '$lib/stores/deezer';
interface Props { interface Props {
title: string; title: string;
@@ -8,8 +9,12 @@
metadata?: string; metadata?: string;
coverImageUrl?: string; coverImageUrl?: string;
tracks: Track[]; tracks: Track[];
trackExistsMap?: Map<string, boolean>;
selectedTrackIndex?: number | null; selectedTrackIndex?: number | null;
onTrackClick?: (index: number) => void; onTrackClick?: (index: number) => void;
onDownloadTrack?: (index: number) => void;
onDownloadPlaylist?: () => void;
downloadingTrackIds?: Set<string>;
} }
let { let {
@@ -18,8 +23,12 @@
metadata, metadata,
coverImageUrl, coverImageUrl,
tracks, tracks,
trackExistsMap = new Map(),
selectedTrackIndex = null, selectedTrackIndex = null,
onTrackClick onTrackClick,
onDownloadTrack,
onDownloadPlaylist,
downloadingTrackIds = new Set()
}: Props = $props(); }: Props = $props();
type ViewMode = 'tracks' | 'info'; type ViewMode = 'tracks' | 'info';
@@ -30,6 +39,30 @@
onTrackClick(index); onTrackClick(index);
} }
} }
function handleDownloadClick(index: number, event: MouseEvent) {
event.stopPropagation();
if (onDownloadTrack) {
onDownloadTrack(index);
}
}
// Get Spotify track ID for existence checking
function getSpotifyTrackId(track: Track): string | undefined {
return (track as any).spotifyId?.toString();
}
function isTrackInLibrary(track: Track): boolean {
const trackId = getSpotifyTrackId(track);
if (!trackId) return false;
return trackExistsMap.get(trackId) ?? false;
}
function isTrackDownloading(track: Track): boolean {
const trackId = getSpotifyTrackId(track);
if (!trackId) return false;
return downloadingTrackIds.has(trackId);
}
</script> </script>
<PageDecoration label="SPOTIFY PLAYLIST" /> <PageDecoration label="SPOTIFY PLAYLIST" />
@@ -85,6 +118,10 @@
<th>Artist</th> <th>Artist</th>
<th>Album</th> <th>Album</th>
<th>Duration</th> <th>Duration</th>
{#if $deezerAuth.loggedIn}
<th style="width: 80px;">In Library</th>
<th style="width: 100px;">Actions</th>
{/if}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -106,6 +143,20 @@
{/if} {/if}
</td> </td>
{#if $deezerAuth.loggedIn}
<td class="in-library">
{isTrackInLibrary(track) ? '✓' : '✗'}
</td>
<td class="actions">
<button
onclick={(e) => handleDownloadClick(i, e)}
disabled={isTrackDownloading(track)}
class="download-btn"
>
{isTrackDownloading(track) ? 'Queued' : 'Download'}
</button>
</td>
{/if}
</tr> </tr>
{/each} {/each}
</tbody> </tbody>
@@ -131,6 +182,22 @@
<span>{tracks.length}</span> <span>{tracks.length}</span>
</div> </div>
</fieldset> </fieldset>
{#if $deezerAuth.loggedIn}
<fieldset style="margin-top: 16px;">
<legend>Actions</legend>
<button onclick={onDownloadPlaylist}>
Download Playlist
</button>
<p class="help-text">Download all tracks via Deezer and save as m3u8 playlist</p>
</fieldset>
{:else}
<fieldset style="margin-top: 16px;">
<legend>Downloads</legend>
<p class="warning-text">Deezer login required to download Spotify tracks</p>
<p class="help-text">Sign in to Deezer in Services → Deezer to enable downloads</p>
</fieldset>
{/if}
</div> </div>
{/if} {/if}
</div> </div>
@@ -250,4 +317,31 @@
min-width: 120px; 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;
}
</style> </style>

View File

@@ -203,7 +203,7 @@ export class SpotifyAPI {
* Make an authenticated API call to Spotify * Make an authenticated API call to Spotify
* Automatically refreshes token if expired * Automatically refreshes token if expired
*/ */
private async apiCall<T>(endpoint: string, options: RequestInit = {}): Promise<T> { async apiCall<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
// Check if token needs refresh // Check if token needs refresh
if (isTokenExpired(this.expiresAt)) { if (isTokenExpired(this.expiresAt)) {
console.log('[Spotify] Token expired, refreshing...'); console.log('[Spotify] Token expired, refreshing...');

View File

@@ -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'
};
}
}

View File

@@ -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<any | null> {
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<any | null> {
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<DeezerMatchResult> {
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 };
}

View File

@@ -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
}
};
}

View File

@@ -14,6 +14,11 @@
} from '$lib/library/spotify-database'; } from '$lib/library/spotify-database';
import SpotifyCollectionView from '$lib/components/SpotifyCollectionView.svelte'; import SpotifyCollectionView from '$lib/components/SpotifyCollectionView.svelte';
import type { Track, AudioFormat } from '$lib/types/track'; 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 playlistId = $derived($page.params.id!);
let loading = $state(true); let loading = $state(true);
@@ -22,6 +27,8 @@
let playlistTracks = $state<SpotifyPlaylistTrack[]>([]); let playlistTracks = $state<SpotifyPlaylistTrack[]>([]);
let selectedTrackIndex = $state<number | null>(null); let selectedTrackIndex = $state<number | null>(null);
let coverImageUrl = $state<string | undefined>(undefined); let coverImageUrl = $state<string | undefined>(undefined);
let trackExistsMap = $state(new Map<string, boolean>());
let downloadingTrackIds = $state(new Set<string>());
// Convert Spotify tracks to Track type for CollectionView // Convert Spotify tracks to Track type for CollectionView
let tracks = $derived<Track[]>( let tracks = $derived<Track[]>(
@@ -29,6 +36,7 @@
path: '', path: '',
filename: '', filename: '',
format: 'unknown' as AudioFormat, format: 'unknown' as AudioFormat,
spotifyId: track.track_id, // Store Spotify ID for downloading
metadata: { metadata: {
title: track.name || 'Unknown Title', title: track.name || 'Unknown Title',
artist: track.artist_name || 'Unknown Artist', artist: track.artist_name || 'Unknown Artist',
@@ -200,6 +208,63 @@
function handleTrackClick(index: number) { function handleTrackClick(index: number) {
selectedTrackIndex = index; 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))
);
}
}
</script> </script>
{#if loading} {#if loading}
@@ -225,8 +290,12 @@
metadata="{playlist.track_count} tracks" metadata="{playlist.track_count} tracks"
{coverImageUrl} {coverImageUrl}
{tracks} {tracks}
{trackExistsMap}
{selectedTrackIndex} {selectedTrackIndex}
{downloadingTrackIds}
onTrackClick={handleTrackClick} onTrackClick={handleTrackClick}
onDownloadTrack={handleDownloadTrack}
onDownloadPlaylist={handleDownloadPlaylist}
/> />
</div> </div>
{/if} {/if}