feat(spotify): caching for Spotify to Deezer conversions to reduce API calls

This commit is contained in:
Markury
2026-03-18 11:08:08 -04:00
parent a846080677
commit 2c471370e4
3 changed files with 185 additions and 94 deletions

View File

@@ -299,7 +299,8 @@ pub fn run() {
kind: MigrationKind::Up, kind: MigrationKind::Up,
}]; }];
let spotify_migrations = vec![Migration { let spotify_migrations = vec![
Migration {
version: 1, version: 1,
description: "create_spotify_cache_tables", description: "create_spotify_cache_tables",
sql: " sql: "
@@ -365,7 +366,24 @@ pub fn run() {
CREATE INDEX IF NOT EXISTS idx_spotify_playlist_tracks_isrc ON spotify_playlist_tracks(isrc); CREATE INDEX IF NOT EXISTS idx_spotify_playlist_tracks_isrc ON spotify_playlist_tracks(isrc);
", ",
kind: MigrationKind::Up, kind: MigrationKind::Up,
}]; },
Migration {
version: 2,
description: "create_spotify_deezer_conversion_cache",
sql: "
CREATE TABLE IF NOT EXISTS spotify_deezer_conversions (
spotify_track_id TEXT PRIMARY KEY,
deezer_track_id INTEGER NOT NULL,
deezer_track_json TEXT NOT NULL,
match_method TEXT NOT NULL,
cached_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_conversions_deezer_id ON spotify_deezer_conversions(deezer_track_id);
",
kind: MigrationKind::Up,
},
];
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_oauth::init()) .plugin(tauri_plugin_oauth::init())

View File

@@ -318,6 +318,63 @@ export async function upsertPlaylistTracks(playlistId: string, tracks: any[]): P
} }
} }
/**
* Cached Spotify→Deezer conversion result
*/
export interface CachedConversion {
spotify_track_id: string;
deezer_track_id: number;
deezer_track_json: string;
match_method: string;
cached_at: number;
}
/**
* Get cached Spotify→Deezer conversions for a batch of track IDs
*/
export async function getCachedConversions(
spotifyTrackIds: string[]
): Promise<Map<string, CachedConversion>> {
if (spotifyTrackIds.length === 0) return new Map();
const database = await initSpotifyDatabase();
const map = new Map<string, CachedConversion>();
// Query in batches of 100 to avoid SQLite variable limits
for (let i = 0; i < spotifyTrackIds.length; i += 100) {
const batch = spotifyTrackIds.slice(i, i + 100);
const placeholders = batch.map((_, idx) => `$${idx + 1}`).join(',');
const rows = await database.select<CachedConversion[]>(
`SELECT * FROM spotify_deezer_conversions WHERE spotify_track_id IN (${placeholders})`,
batch
);
for (const row of rows) {
map.set(row.spotify_track_id, row);
}
}
return map;
}
/**
* Cache a Spotify→Deezer conversion result
*/
export async function cacheConversion(
spotifyTrackId: string,
deezerTrackId: number,
deezerTrackJson: string,
matchMethod: string
): Promise<void> {
const database = await initSpotifyDatabase();
const now = Math.floor(Date.now() / 1000);
await database.execute(
`INSERT OR REPLACE INTO spotify_deezer_conversions (spotify_track_id, deezer_track_id, deezer_track_json, match_method, cached_at)
VALUES ($1, $2, $3, $4, $5)`,
[spotifyTrackId, deezerTrackId, deezerTrackJson, matchMethod, now]
);
}
/** /**
* Clear all Spotify cache * Clear all Spotify cache
*/ */

View File

@@ -1,5 +1,9 @@
/** /**
* Download Spotify playlist - converts tracks to Deezer via ISRC, adds to queue, and creates m3u8 file * 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 { addToQueue } from '$lib/stores/downloadQueue';
@@ -13,6 +17,7 @@ import { setInfo, setSuccess, setWarning } from '$lib/stores/status';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { mkdir } from '@tauri-apps/plugin-fs'; import { mkdir } from '@tauri-apps/plugin-fs';
import { convertSpotifyTrackToDeezer, type SpotifyTrackInput } from './converter'; import { convertSpotifyTrackToDeezer, type SpotifyTrackInput } from './converter';
import { getCachedConversions, cacheConversion } from '$lib/library/spotify-database';
import type { DeezerTrack } from '$lib/types/deezer'; import type { DeezerTrack } from '$lib/types/deezer';
export interface SpotifyPlaylistTrack { export interface SpotifyPlaylistTrack {
@@ -26,16 +31,8 @@ export interface SpotifyPlaylistTrack {
} }
/** /**
* Download a Spotify playlist by converting tracks to Deezer equivalents * Download a Spotify playlist by converting tracks to Deezer equivalents.
* - Converts all tracks via ISRC matching * Cached conversions are reused — only uncached tracks hit the Deezer API.
* - 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( export async function downloadSpotifyPlaylist(
playlistName: string, playlistName: string,
@@ -49,12 +46,12 @@ export async function downloadSpotifyPlaylist(
queued: number; queued: number;
skipped: number; skipped: number;
failed: number; failed: number;
cached: number;
}; };
}> { }> {
const appSettings = get(settings); const appSettings = get(settings);
const authState = get(deezerAuth); const authState = get(deezerAuth);
// Ensure Deezer is authenticated
if (!authState.loggedIn || !authState.arl) { if (!authState.loggedIn || !authState.arl) {
throw new Error('Deezer authentication required for downloads'); throw new Error('Deezer authentication required for downloads');
} }
@@ -64,28 +61,43 @@ export async function downloadSpotifyPlaylist(
console.log(`[SpotifyPlaylistDownloader] Starting download for playlist: ${playlistName}`); console.log(`[SpotifyPlaylistDownloader] Starting download for playlist: ${playlistName}`);
console.log(`[SpotifyPlaylistDownloader] Tracks: ${spotifyTracks.length}`); console.log(`[SpotifyPlaylistDownloader] Tracks: ${spotifyTracks.length}`);
// Ensure playlists folder exists
try { try {
await mkdir(playlistsFolder, { recursive: true }); await mkdir(playlistsFolder, { recursive: true });
} catch (error) { } catch (error) {
// Folder might already exist // Folder might already exist
} }
// Track statistics // --- 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 queuedCount = 0;
let skippedCount = 0; let skippedCount = 0;
let failedCount = 0; let failedCount = 0;
// Track successful conversions for m3u8 generation
const successfulTracks: Array<{ const successfulTracks: Array<{
deezerTrack: DeezerTrack; deezerTrack: DeezerTrack;
spotifyTrack: SpotifyPlaylistTrack; spotifyTrack: SpotifyPlaylistTrack;
}> = []; }> = [];
// Convert and queue each track
for (const spotifyTrack of spotifyTracks) { for (const spotifyTrack of spotifyTracks) {
try { try {
// Convert Spotify track to Deezer 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 = { const conversionInput: SpotifyTrackInput = {
id: spotifyTrack.track_id, id: spotifyTrack.track_id,
name: spotifyTrack.name, name: spotifyTrack.name,
@@ -107,18 +119,19 @@ export async function downloadSpotifyPlaylist(
const deezerPublicTrack = conversionResult.deezerTrack; const deezerPublicTrack = conversionResult.deezerTrack;
// Fetch full track data from Deezer GW API (needed for download) // Fetch full track data from GW API
const deezerTrackId = deezerPublicTrack.id.toString(); const deezerTrackId = deezerPublicTrack.id.toString();
const deezerFullTrack = await deezerAPI.getTrack(deezerTrackId); const deezerFullTrack = await deezerAPI.getTrack(deezerTrackId);
if (!deezerFullTrack || !deezerFullTrack.SNG_ID) { if (!deezerFullTrack || !deezerFullTrack.SNG_ID) {
console.warn(`[SpotifyPlaylistDownloader] Could not fetch full Deezer track data for ID: ${deezerTrackId}`); console.warn(
`[SpotifyPlaylistDownloader] Could not fetch full Deezer track data for ID: ${deezerTrackId}`
);
failedCount++; failedCount++;
continue; continue;
} }
// Build DeezerTrack object deezerTrack = {
const deezerTrack: DeezerTrack = {
id: parseInt(deezerFullTrack.SNG_ID, 10), id: parseInt(deezerFullTrack.SNG_ID, 10),
title: deezerFullTrack.SNG_TITLE, title: deezerFullTrack.SNG_TITLE,
artist: deezerFullTrack.ART_NAME, artist: deezerFullTrack.ART_NAME,
@@ -146,13 +159,24 @@ export async function downloadSpotifyPlaylist(
trackToken: deezerFullTrack.TRACK_TOKEN trackToken: deezerFullTrack.TRACK_TOKEN
}; };
// Check if track already exists (if overwrite is disabled) // 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) { if (!appSettings.deezerOverwrite && appSettings.musicFolder) {
const exists = await trackExists(deezerTrack, appSettings.musicFolder, appSettings.deezerFormat); const exists = await trackExists(deezerTrack, appSettings.musicFolder, appSettings.deezerFormat);
if (exists) { if (exists) {
console.log(`[SpotifyPlaylistDownloader] Skipping "${deezerTrack.title}" - already exists`);
skippedCount++; skippedCount++;
// Still add to successful tracks for m3u8 generation
successfulTracks.push({ deezerTrack, spotifyTrack }); successfulTracks.push({ deezerTrack, spotifyTrack });
continue; continue;
} }
@@ -170,10 +194,6 @@ export async function downloadSpotifyPlaylist(
queuedCount++; queuedCount++;
successfulTracks.push({ deezerTrack, spotifyTrack }); successfulTracks.push({ deezerTrack, spotifyTrack });
console.log(
`[SpotifyPlaylistDownloader] Queued: ${deezerTrack.title} (matched via ${conversionResult.matchMethod})`
);
} catch (error) { } catch (error) {
console.error(`[SpotifyPlaylistDownloader] Error processing track ${spotifyTrack.name}:`, error); console.error(`[SpotifyPlaylistDownloader] Error processing track ${spotifyTrack.name}:`, error);
failedCount++; failedCount++;
@@ -181,7 +201,7 @@ export async function downloadSpotifyPlaylist(
} }
console.log( console.log(
`[SpotifyPlaylistDownloader] Queued ${queuedCount} tracks, skipped ${skippedCount}, failed ${failedCount}` `[SpotifyPlaylistDownloader] Queued ${queuedCount}, skipped ${skippedCount}, failed ${failedCount}, cached ${cachedCount}`
); );
// Show queue status message // Show queue status message
@@ -189,6 +209,7 @@ export async function downloadSpotifyPlaylist(
const parts = [`Queued ${queuedCount} track${queuedCount !== 1 ? 's' : ''}`]; const parts = [`Queued ${queuedCount} track${queuedCount !== 1 ? 's' : ''}`];
if (skippedCount > 0) parts.push(`${skippedCount} skipped`); if (skippedCount > 0) parts.push(`${skippedCount} skipped`);
if (failedCount > 0) parts.push(`${failedCount} not found`); if (failedCount > 0) parts.push(`${failedCount} not found`);
if (cachedCount > 0) parts.push(`${cachedCount} from cache`);
setInfo(parts.join(', ')); setInfo(parts.join(', '));
} else if (skippedCount > 0) { } else if (skippedCount > 0) {
setWarning(`All ${skippedCount} tracks already exist`); setWarning(`All ${skippedCount} tracks already exist`);
@@ -196,13 +217,10 @@ export async function downloadSpotifyPlaylist(
setWarning(`Could not find ${failedCount} tracks on Deezer`); setWarning(`Could not find ${failedCount} tracks on Deezer`);
} }
// Generate m3u8 file using Deezer track paths // Generate m3u8 file
const m3u8Tracks: M3U8Track[] = successfulTracks.map(({ deezerTrack, spotifyTrack }) => { const m3u8Tracks: M3U8Track[] = successfulTracks.map(({ deezerTrack }) => {
// Generate expected path for this Deezer track
const paths = generateTrackPath(deezerTrack, musicFolder, appSettings.deezerFormat, false); const paths = generateTrackPath(deezerTrack, musicFolder, appSettings.deezerFormat, false);
const absolutePath = `${paths.filepath}/${paths.filename}`; const absolutePath = `${paths.filepath}/${paths.filename}`;
// Convert to relative path from playlists folder
const relativePath = makeRelativePath(absolutePath, 'Music'); const relativePath = makeRelativePath(absolutePath, 'Music');
return { return {
@@ -213,12 +231,9 @@ export async function downloadSpotifyPlaylist(
}; };
}); });
// Write m3u8 file
const m3u8Path = await writeM3U8(playlistName, m3u8Tracks, playlistsFolder); const m3u8Path = await writeM3U8(playlistName, m3u8Tracks, playlistsFolder);
console.log(`[SpotifyPlaylistDownloader] Playlist saved to: ${m3u8Path}`); console.log(`[SpotifyPlaylistDownloader] Playlist saved to: ${m3u8Path}`);
// Show success message for playlist creation
setSuccess(`Playlist created: ${playlistName} (${successfulTracks.length} tracks)`); setSuccess(`Playlist created: ${playlistName} (${successfulTracks.length} tracks)`);
return { return {
@@ -227,7 +242,8 @@ export async function downloadSpotifyPlaylist(
total: spotifyTracks.length, total: spotifyTracks.length,
queued: queuedCount, queued: queuedCount,
skipped: skippedCount, skipped: skippedCount,
failed: failedCount failed: failedCount,
cached: cachedCount
} }
}; };
} }