Files
shark/src/lib/services/spotify/playlistDownloader.ts
2026-03-19 11:37:27 -04:00

250 lines
8.5 KiB
TypeScript

/**
* 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
}
};
}