Files
lidify/backend/src/services/spotifyImport.ts
T
Your Name cc8d0f6969 Release v1.3.0: Multi-source downloads, audio analyzer resilience, mobile improvements
Major Features:
- Multi-source download system (Soulseek/Lidarr with fallback)
- Configurable enrichment speed control (1-5x)
- Mobile touch drag support for seek sliders
- iOS PWA media controls (Control Center, Lock Screen)
- Artist name alias resolution via Last.fm
- Circuit breaker pattern for audio analysis

Critical Fixes:
- Audio analyzer stability (non-ASCII, BrokenProcessPool, OOM)
- Discovery system race conditions and import failures
- Radio decade categorization using originalYear
- LastFM API response normalization
- Mood bucket infinite loop prevention

Security:
- Bull Board admin authentication
- Lidarr webhook signature verification
- JWT token expiration and refresh
- Encryption key validation on startup

Closes #2, #6, #9, #13, #21, #26, #31, #34, #35, #37, #40, #43
2026-01-06 20:07:33 -06:00

2450 lines
91 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { spotifyService, SpotifyTrack, SpotifyPlaylist } from "./spotify";
import { logger } from "../utils/logger";
import { musicBrainzService } from "./musicbrainz";
import { deezerService } from "./deezer";
import {
createPlaylistLogger,
logPlaylistEvent,
} from "../utils/playlistLogger";
import { notificationService } from "./notificationService";
import { getSystemSettings } from "../utils/systemSettings";
import { prisma } from "../utils/db";
import { redisClient } from "../utils/redis";
import PQueue from "p-queue";
import { acquisitionService } from "./acquisitionService";
import { extractPrimaryArtist } from "../utils/artistNormalization";
// Store loggers for each job
const jobLoggers = new Map<string, ReturnType<typeof createPlaylistLogger>>();
/**
* Spotify Import Service
*
* Handles matching Spotify tracks to local library and managing imports
*/
export interface MatchedTrack {
spotifyTrack: SpotifyTrack;
localTrack: {
id: string;
title: string;
albumId: string;
albumTitle: string;
artistName: string;
} | null;
matchType: "exact" | "fuzzy" | "none";
matchConfidence: number; // 0-100
}
export interface AlbumToDownload {
spotifyAlbumId: string;
albumName: string;
artistName: string;
artistMbid: string | null;
albumMbid: string | null;
coverUrl: string | null;
trackCount: number;
tracksNeeded: SpotifyTrack[];
}
export interface ImportPreview {
playlist: {
id: string;
name: string;
description: string | null;
owner: string;
imageUrl: string | null;
trackCount: number;
};
matchedTracks: MatchedTrack[];
albumsToDownload: AlbumToDownload[];
summary: {
total: number;
inLibrary: number;
downloadable: number;
notFound: number;
};
}
export interface ImportJob {
id: string;
userId: string;
spotifyPlaylistId: string;
playlistName: string;
status:
| "pending"
| "downloading"
| "scanning"
| "creating_playlist"
| "matching_tracks"
| "completed"
| "failed"
| "cancelled";
progress: number;
albumsTotal: number;
albumsCompleted: number;
tracksMatched: number;
tracksTotal: number;
tracksDownloadable: number; // Tracks from albums being downloaded
createdPlaylistId: string | null;
error: string | null;
createdAt: Date;
updatedAt: Date;
// Store the original track list so we can match after downloads
pendingTracks: Array<{
artist: string;
title: string;
album: string;
preMatchedTrackId: string | null; // Track ID if already matched in preview
}>;
}
// Redis key pattern for import jobs
const IMPORT_JOB_KEY = (id: string) => `import:job:${id}`;
const IMPORT_JOB_TTL = 24 * 60 * 60; // 24 hours
/**
* Save import job to both database and Redis cache for cross-process sharing
*/
async function saveImportJob(job: ImportJob): Promise<void> {
// Save to database for durability
await prisma.spotifyImportJob.upsert({
where: { id: job.id },
create: {
id: job.id,
userId: job.userId,
spotifyPlaylistId: job.spotifyPlaylistId,
playlistName: job.playlistName,
status: job.status,
progress: job.progress,
albumsTotal: job.albumsTotal,
albumsCompleted: job.albumsCompleted,
tracksMatched: job.tracksMatched,
tracksTotal: job.tracksTotal,
tracksDownloadable: job.tracksDownloadable,
createdPlaylistId: job.createdPlaylistId,
error: job.error,
pendingTracks: job.pendingTracks as any,
},
update: {
status: job.status,
progress: job.progress,
albumsCompleted: job.albumsCompleted,
tracksMatched: job.tracksMatched,
createdPlaylistId: job.createdPlaylistId,
error: job.error,
updatedAt: new Date(),
},
});
// Save to Redis for cross-process sharing
try {
await redisClient.setEx(
IMPORT_JOB_KEY(job.id),
IMPORT_JOB_TTL,
JSON.stringify(job)
);
} catch (error) {
logger?.warn(
`⚠️ Failed to cache import job ${job.id} in Redis:`,
error
);
// Continue - Redis is optional, DB is source of truth
}
}
/**
* Get import job from Redis cache or database
* Redis provides cross-process sharing between API and worker processes
*/
async function getImportJob(importJobId: string): Promise<ImportJob | null> {
// Try Redis cache first (shared across all processes)
try {
const cached = await redisClient.get(IMPORT_JOB_KEY(importJobId));
if (cached) {
return JSON.parse(cached);
}
} catch (error) {
logger?.warn(
`⚠️ Failed to read import job ${importJobId} from Redis:`,
error
);
// Fall through to DB
}
// Load from database as fallback
const dbJob = await prisma.spotifyImportJob.findUnique({
where: { id: importJobId },
});
if (!dbJob) return null;
// Convert database job to ImportJob format
const job: ImportJob = {
id: dbJob.id,
userId: dbJob.userId,
spotifyPlaylistId: dbJob.spotifyPlaylistId,
playlistName: dbJob.playlistName,
status: dbJob.status as ImportJob["status"],
progress: dbJob.progress,
albumsTotal: dbJob.albumsTotal,
albumsCompleted: dbJob.albumsCompleted,
tracksMatched: dbJob.tracksMatched,
tracksTotal: dbJob.tracksTotal,
tracksDownloadable: dbJob.tracksDownloadable,
createdPlaylistId: dbJob.createdPlaylistId,
error: dbJob.error,
createdAt: dbJob.createdAt,
updatedAt: dbJob.updatedAt,
pendingTracks: (dbJob.pendingTracks as any) || [],
};
// Populate Redis for next time
try {
await redisClient.setEx(
IMPORT_JOB_KEY(importJobId),
IMPORT_JOB_TTL,
JSON.stringify(job)
);
} catch (error) {
logger?.warn(
`⚠️ Failed to cache import job ${importJobId} in Redis:`,
error
);
// Continue - Redis is optional
}
return job;
}
/**
* Normalize a string for fuzzy matching
* Handles: special characters, punctuation, remaster suffixes, etc.
*/
function normalizeString(str: string): string {
return (
str
.toLowerCase()
// Normalize special characters (ö→o, é→e, etc.)
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
// Remove punctuation but keep spaces
.replace(/[^\w\s]/g, "")
.replace(/\s+/g, " ")
.trim()
);
}
/**
* Normalize apostrophes and quotes to ASCII versions
* Handles: ' ' ` ʼ → '
*/
function normalizeApostrophes(str: string): string {
return str
.replace(/[''`′ʼ]/g, "'") // Various apostrophe forms → ASCII apostrophe
.replace(/[""]/g, '"'); // Smart quotes → ASCII quotes
}
/**
* Strip remaster/version suffixes but KEEP punctuation
* "Ain't Gonna Rain Anymore - 2011 Remaster" → "Ain't Gonna Rain Anymore"
* Used for database searches where we need to match punctuation
*/
function stripTrackSuffix(str: string): string {
return (
normalizeApostrophes(str)
// Remove " - YEAR Remaster", " - Remastered YEAR", " - Radio Edit", etc.
// Note: remaster(ed)? matches "remaster" or "remastered"
.replace(
/\s*-\s*(\d{4}\s+)?(remaster(ed)?|deluxe|bonus|single|radio edit|remix|acoustic|live|mono|stereo|version|edition|mix)(\s+\d{4})?(\s+(version|edition|mix))?.*$/i,
""
)
// Remove " - YEAR" at end
.replace(/\s*-\s*\d{4}\s*$/, "")
// Remove "(Live at...)", "(Live from...)", "(Recorded at...)" parenthetical content
.replace(
/\s*\([^)]*(?:live at|live from|recorded at|performed at)[^)]*\)\s*/gi,
" "
)
// Remove parenthetical content like "(Remastered)" or "(2011 Remastered Version)"
.replace(/\s*\([^)]*remaster[^)]*\)\s*/gi, " ")
.replace(/\s*\([^)]*version[^)]*\)\s*/gi, " ")
.replace(/\s*\([^)]*edition[^)]*\)\s*/gi, " ")
// Remove general "(Live)" or "(Live 2021)" etc
.replace(/\s*\(\s*live\s*(\d{4})?\s*\)\s*/gi, " ")
// Remove bracketed content like "[Deluxe Edition]"
.replace(/\s*\[[^\]]*\]\s*/g, " ")
.replace(/\s+/g, " ")
.trim()
);
}
/**
* Normalize track title - removes remaster/version suffixes AND punctuation
* "Ain't Gonna Rain Anymore - 2011 Remaster" → "aint gonna rain anymore"
* Used for similarity comparisons
*/
function normalizeTrackTitle(str: string): string {
return normalizeString(stripTrackSuffix(str));
}
/**
* Calculate similarity between two strings (0-100)
*/
function stringSimilarity(a: string, b: string): number {
const s1 = normalizeString(a);
const s2 = normalizeString(b);
if (s1 === s2) return 100;
// Check if one contains the other
if (s1.includes(s2) || s2.includes(s1)) {
const longer = Math.max(s1.length, s2.length);
const shorter = Math.min(s1.length, s2.length);
return Math.round((shorter / longer) * 100);
}
// Simple word overlap similarity
const words1 = new Set(s1.split(" "));
const words2 = new Set(s2.split(" "));
const intersection = [...words1].filter((w) => words2.has(w)).length;
const union = new Set([...words1, ...words2]).size;
return Math.round((intersection / union) * 100);
}
class SpotifyImportService {
/**
* Match a Spotify track to the local library
*/
private async matchTrack(
spotifyTrack: SpotifyTrack
): Promise<MatchedTrack> {
const normalizedTitle = normalizeString(spotifyTrack.title);
const normalizedArtist = normalizeString(spotifyTrack.artist);
const normalizedAlbum = normalizeString(spotifyTrack.album);
// Extract primary artist for better matching (handles "Artist feat. Someone")
const primaryArtist = extractPrimaryArtist(spotifyTrack.artist);
const normalizedPrimaryArtist = normalizeString(primaryArtist);
// Strategy 1: Exact match by primary artist + album + title
let exactMatch = await prisma.track.findFirst({
where: {
album: {
artist: {
normalizedName: normalizedPrimaryArtist,
},
title: {
mode: "insensitive",
equals: spotifyTrack.album,
},
},
title: {
mode: "insensitive",
equals: spotifyTrack.title,
},
},
include: {
album: {
include: {
artist: true,
},
},
},
});
// Fallback: Try with full artist name if primary artist didn't match
if (!exactMatch && primaryArtist !== spotifyTrack.artist) {
exactMatch = await prisma.track.findFirst({
where: {
album: {
artist: {
normalizedName: normalizedArtist,
},
title: {
mode: "insensitive",
equals: spotifyTrack.album,
},
},
title: {
mode: "insensitive",
equals: spotifyTrack.title,
},
},
include: {
album: {
include: {
artist: true,
},
},
},
});
}
if (exactMatch) {
return {
spotifyTrack,
localTrack: {
id: exactMatch.id,
title: exactMatch.title,
albumId: exactMatch.albumId,
albumTitle: exactMatch.album.title,
artistName: exactMatch.album.artist.name,
},
matchType: "exact",
matchConfidence: 100,
};
}
// Strategy 2: Fuzzy match by primary artist + title (any album)
let fuzzyMatches = await prisma.track.findMany({
where: {
album: {
artist: {
normalizedName: {
contains: normalizedPrimaryArtist.split(" ")[0], // First word of primary artist
},
},
},
},
include: {
album: {
include: {
artist: true,
},
},
},
take: 50, // Limit for performance
});
// Fallback: Try with full artist name if primary artist didn't find matches
if (fuzzyMatches.length === 0 && primaryArtist !== spotifyTrack.artist) {
fuzzyMatches = await prisma.track.findMany({
where: {
album: {
artist: {
normalizedName: {
contains: normalizedArtist.split(" ")[0], // First word of full artist
},
},
},
},
include: {
album: {
include: {
artist: true,
},
},
},
take: 50,
});
}
let bestMatch: any = null;
let bestScore = 0;
for (const track of fuzzyMatches) {
// Use cleaned titles for comparison (strips "- 2011 Remaster", etc.)
const titleSim = stringSimilarity(
normalizeTrackTitle(spotifyTrack.title),
normalizeTrackTitle(track.title)
);
// Compare against primary artist for better matching
const artistSim = stringSimilarity(
primaryArtist,
track.album.artist.name
);
// Weight: title 60%, artist 40%
const score = titleSim * 0.6 + artistSim * 0.4;
if (score > bestScore && score >= 70) {
bestScore = score;
bestMatch = track;
}
}
if (bestMatch) {
return {
spotifyTrack,
localTrack: {
id: bestMatch!.id,
title: bestMatch!.title,
albumId: bestMatch!.albumId,
albumTitle: bestMatch!.album.title,
artistName: bestMatch!.album.artist.name,
},
matchType: "fuzzy",
matchConfidence: Math.round(bestScore),
};
}
return {
spotifyTrack,
localTrack: null,
matchType: "none",
matchConfidence: 0,
};
}
/**
* Look up album info from MusicBrainz for downloading
*/
private async findAlbumMbid(
artistName: string,
albumName: string
): Promise<{ artistMbid: string | null; albumMbid: string | null }> {
try {
// Search for artist first
const artists = await musicBrainzService.searchArtist(
artistName,
5
);
if (!artists || artists.length === 0) {
return { artistMbid: null, albumMbid: null };
}
// Find best matching artist
let bestArtist = artists[0];
for (const artist of artists) {
if (
normalizeString(artist.name) === normalizeString(artistName)
) {
bestArtist = artist;
break;
}
}
const artistMbid = bestArtist.id;
// Search for album by this artist
const releaseGroups = await musicBrainzService.getReleaseGroups(
artistMbid
);
for (const rg of releaseGroups || []) {
if (stringSimilarity(rg.title, albumName) >= 80) {
return { artistMbid, albumMbid: rg.id };
}
}
return { artistMbid, albumMbid: null };
} catch (error) {
logger?.error("MusicBrainz lookup error:", error);
return { artistMbid: null, albumMbid: null };
}
}
/**
* Shared preview generator for any source tracklist
*/
private async buildPreviewFromTracklist(
tracks: SpotifyTrack[],
playlistMeta: {
id: string;
name: string;
description: string | null;
owner: string;
imageUrl: string | null;
trackCount: number;
},
source: "Spotify" | "Deezer"
): Promise<ImportPreview> {
const logPrefix =
source === "Spotify" ? "[Spotify Import]" : "[Deezer Import]";
const matchedTracks: MatchedTrack[] = [];
const unmatchedByAlbum = new Map<string, SpotifyTrack[]>();
for (const track of tracks) {
const matched = await this.matchTrack(track);
matchedTracks.push(matched);
if (!matched.localTrack) {
const key = `${track.artist}|||${track.album}`;
const existing = unmatchedByAlbum.get(key) || [];
existing.push(track);
unmatchedByAlbum.set(key, existing);
}
}
const albumsToDownload: AlbumToDownload[] = [];
for (const [key, albumTracks] of unmatchedByAlbum.entries()) {
const [artistName, albumName] = key.split("|||");
let resolvedAlbumName = albumName;
let artistMbid: string | null = null;
let albumMbid: string | null = null;
logger?.debug(
`\n${logPrefix} ========================================`
);
logger?.debug(
`${logPrefix} Looking up: "${artistName}" - "${albumName}"`
);
if (albumName && albumName !== "Unknown Album") {
// Normalize album name to remove live/remaster suffixes
const normalizedAlbumName = stripTrackSuffix(albumName);
const wasNormalized = normalizedAlbumName !== albumName;
logger?.debug(
`${logPrefix} Searching for album "${albumName}" by ${artistName}...`
);
if (wasNormalized) {
logger?.debug(
`${logPrefix} → Normalized to: "${normalizedAlbumName}"`
);
}
const mbResult = await this.findAlbumMbid(
artistName,
normalizedAlbumName
);
artistMbid = mbResult.artistMbid;
albumMbid = mbResult.albumMbid;
if (albumMbid) {
logger?.debug(
`${logPrefix} ✓ Found album directly: "${albumName}" (MBID: ${albumMbid})`
);
}
}
if (!albumMbid) {
logger?.debug(
`${logPrefix} Album not found, trying track-based search...`
);
for (const track of albumTracks) {
// Normalize track title to remove live/remaster suffixes
const normalizedTrackTitle = stripTrackSuffix(track.title);
const wasNormalized = normalizedTrackTitle !== track.title;
logger?.debug(
`${logPrefix} Searching for track "${track.title}"...`
);
if (wasNormalized) {
logger?.debug(
`${logPrefix} → Normalized to: "${normalizedTrackTitle}"`
);
}
const recordingInfo =
await musicBrainzService.searchRecording(
normalizedTrackTitle,
artistName
);
if (recordingInfo) {
resolvedAlbumName = recordingInfo.albumName;
artistMbid = recordingInfo.artistMbid;
albumMbid = recordingInfo.albumMbid;
logger?.debug(
`${logPrefix} ✓ Found via track: "${resolvedAlbumName}" (MBID: ${albumMbid})`
);
break;
}
}
}
if (!albumMbid) {
logger?.debug(
`${logPrefix} ✗ Could not find album MBID for ${artistName} - "${resolvedAlbumName}"`
);
if (albumName === "Unknown Album") {
logger?.debug(
`${logPrefix} But can still download via Soulseek (track-based search)`
);
}
}
const albumToDownload: AlbumToDownload = {
spotifyAlbumId: albumTracks[0].albumId,
albumName: resolvedAlbumName,
artistName,
artistMbid,
albumMbid,
coverUrl: albumTracks[0].coverUrl,
trackCount: albumTracks.length,
tracksNeeded: albumTracks,
};
logger?.debug(`${logPrefix} Download strategy:`);
if (albumMbid) {
logger?.debug(` Will request album from Lidarr/Soulseek:`);
logger?.debug(
` Artist: "${artistName}" (MBID: ${artistMbid || "NONE"})`
);
logger?.debug(
` Album: "${resolvedAlbumName}" (MBID: ${albumMbid})`
);
} else {
// No MBID - will try Soulseek track-based search
logger?.debug(
` Will request individual tracks via Soulseek (no MBID):`
);
logger?.debug(` Artist: "${artistName}"`);
logger?.debug(
` Tracks: ${albumTracks
.map((t) => `"${t.title}"`)
.join(", ")}`
);
}
logger?.debug(
`${logPrefix} ========================================\n`
);
albumsToDownload.push(albumToDownload);
}
const inLibrary = matchedTracks.filter(
(m) => m.localTrack !== null
).length;
// All albums are now downloadable via Soulseek (either album-based with MBID or track-based without)
const downloadableAlbums = albumsToDownload;
// No albums are truly "not found" since Soulseek can search for any track
const notFoundAlbums: AlbumToDownload[] = [];
const downloadable = downloadableAlbums.reduce(
(sum, a) => sum + a.tracksNeeded.length,
0
);
const notFound = notFoundAlbums.reduce(
(sum, a) => sum + a.tracksNeeded.length,
0
);
return {
playlist: playlistMeta,
matchedTracks,
albumsToDownload,
summary: {
total: playlistMeta.trackCount,
inLibrary,
downloadable,
notFound,
},
};
}
/**
* Generate a preview of what will be imported
*/
async generatePreview(spotifyUrl: string): Promise<ImportPreview> {
// Clear any stale null cache entries before processing
// This ensures we retry previously failed lookups
await musicBrainzService.clearStaleRecordingCaches();
const playlist = await spotifyService.getPlaylist(spotifyUrl);
if (!playlist) {
throw new Error(
"Could not fetch playlist from Spotify. Make sure it's a valid public playlist URL."
);
}
return this.buildPreviewFromTracklist(
playlist.tracks,
{
id: playlist.id,
name: playlist.name,
description: playlist.description,
owner: playlist.owner,
imageUrl: playlist.imageUrl,
trackCount: playlist.trackCount,
},
"Spotify"
);
}
/**
* Generate a preview from a Deezer playlist
* Converts Deezer tracks to Spotify format and processes them
*/
async generatePreviewFromDeezer(
deezerPlaylist: any
): Promise<ImportPreview> {
// Clear any stale null cache entries before processing
await musicBrainzService.clearStaleRecordingCaches();
logger?.debug(
"[Deezer Debug] Sample track from Deezer:",
JSON.stringify(deezerPlaylist.tracks[0], null, 2)
);
const spotifyTracks: SpotifyTrack[] = deezerPlaylist.tracks.map(
(track: any, index: number) => ({
spotifyId: track.deezerId,
title: track.title,
artist: track.artist,
artistId: track.artistId || "",
album: track.album || "Unknown Album",
albumId: track.albumId || "",
isrc: null,
durationMs: track.durationMs,
trackNumber: track.trackNumber || index + 1,
previewUrl: track.previewUrl || null,
coverUrl: track.coverUrl || deezerPlaylist.imageUrl || null,
})
);
logger?.debug(
"[Deezer Debug] Sample converted track:",
JSON.stringify(spotifyTracks[0], null, 2)
);
return this.buildPreviewFromTracklist(
spotifyTracks,
{
id: deezerPlaylist.id,
name: deezerPlaylist.title,
description: deezerPlaylist.description || null,
owner: deezerPlaylist.creator || "Deezer",
imageUrl: deezerPlaylist.imageUrl || null,
trackCount: deezerPlaylist.trackCount || spotifyTracks.length,
},
"Deezer"
);
}
/**
* Start an import job
*/
async startImport(
userId: string,
spotifyPlaylistId: string,
playlistName: string,
albumMbidsToDownload: string[],
preview: ImportPreview
): Promise<ImportJob> {
// Validate userId to prevent NaN/invalid values from entering the system
if (!userId || typeof userId !== 'string' || userId === 'NaN' || userId === 'undefined' || userId === 'null') {
logger?.error(
`[Spotify Import] Invalid userId provided to startImport: ${JSON.stringify({
userId,
typeofUserId: typeof userId,
playlistName
})}`
);
throw new Error(`Invalid userId provided: ${userId}`);
}
const jobId = `import_${Date.now()}_${Math.random()
.toString(36)
.substring(7)}`;
// Create dedicated logger for this job
const jobLogger = createPlaylistLogger(jobId);
jobLoggers.set(jobId, jobLogger);
jobLogger.logJobStart(playlistName, preview.summary.total, userId);
jobLogger?.info(`Playlist ID: ${spotifyPlaylistId}`);
jobLogger?.info(`Albums to download: ${albumMbidsToDownload.length}`);
jobLogger?.info(`Tracks already in library: ${preview.summary.inLibrary}`);
// Calculate tracks that will come from downloads
const tracksFromDownloads = preview.albumsToDownload
.filter((a) => albumMbidsToDownload.includes(a.albumMbid!))
.reduce((sum, a) => sum + a.tracksNeeded.length, 0);
// Extract the track info we need to match after downloads
// Include ALL tracks, both matched and unmatched
// IMPORTANT: Store pre-matched track IDs so we don't have to re-search them!
// NOTE: `PlaylistPendingTrack.spotifyAlbum` should reflect Spotify's album name.
// Only fall back to a resolved album name when Spotify returns "Unknown Album".
const pendingTracks = preview.matchedTracks.map((m) => {
const spotifyAlbum = m.spotifyTrack.album;
const spotifyAlbumId = m.spotifyTrack.albumId;
const albumToDownload = spotifyAlbumId
? preview.albumsToDownload.find(
(a) => a.spotifyAlbumId === spotifyAlbumId
)
: undefined;
const albumForDisplay =
spotifyAlbum && spotifyAlbum !== "Unknown Album"
? spotifyAlbum
: albumToDownload?.albumName || spotifyAlbum;
return {
artist: m.spotifyTrack.artist,
title: m.spotifyTrack.title,
album: albumForDisplay,
preMatchedTrackId: m.localTrack?.id || null,
};
});
const job: ImportJob = {
id: jobId,
userId,
spotifyPlaylistId,
playlistName,
status: "pending",
progress: 0,
albumsTotal: albumMbidsToDownload.length,
albumsCompleted: 0,
tracksMatched: preview.summary.inLibrary,
tracksTotal: preview.summary.total,
tracksDownloadable: tracksFromDownloads,
createdPlaylistId: null,
error: null,
createdAt: new Date(),
updatedAt: new Date(),
pendingTracks,
};
// Save to database and memory cache
await saveImportJob(job);
// Start processing in background
this.processImport(job, albumMbidsToDownload, preview).catch(
async (error) => {
job.status = "failed";
job.error = error.message;
job.updatedAt = new Date();
await saveImportJob(job);
jobLogger?.logJobFailed(error.message);
}
);
return job;
}
/**
* Process the import (download albums, create playlist)
* Now uses AcquisitionService for unified download handling
*/
private async processImport(
job: ImportJob,
albumMbidsToDownload: string[],
preview: ImportPreview
): Promise<void> {
const logger = jobLoggers.get(job.id);
try {
// Phase 1: Download albums using AcquisitionService
if (albumMbidsToDownload.length > 0) {
job.status = "downloading";
job.updatedAt = new Date();
await saveImportJob(job);
logger?.logAlbumDownloadStart(albumMbidsToDownload.length);
logger?.debug(
`[Spotify Import] Processing ${albumMbidsToDownload.length} albums via AcquisitionService`
);
logger?.info(
`Processing ${albumMbidsToDownload.length} albums via AcquisitionService`
);
// Process albums in parallel with concurrency limit
const albumQueue = new PQueue({
concurrency: 4,
});
const albumPromises = albumMbidsToDownload.map(
(albumIdentifier) =>
albumQueue.add(async () => {
// albumIdentifier can be either albumMbid or spotifyAlbumId (for Unknown Album)
const album = preview.albumsToDownload.find(
(a) =>
a.albumMbid === albumIdentifier ||
a.spotifyAlbumId === albumIdentifier
);
if (!album) return;
try {
const isUnknownAlbum =
album.albumName === "Unknown Album" ||
!album.albumMbid;
logger?.info(
`Album start: ${album.artistName} - ${
album.albumName
}${
album.albumMbid
? ` [MBID: ${album.albumMbid}]`
: " [Unknown Album]"
} (tracksNeeded=${
album.tracksNeeded.length
})`
);
logger?.debug(
`[Spotify Import] Requesting: ${album.artistName} - ${album.albumName}`
);
// Validate userId before creating acquisition context
if (!job.userId || typeof job.userId !== 'string' || job.userId === 'NaN' || job.userId === 'undefined' || job.userId === 'null') {
logger?.error(
`[Spotify Import] Invalid userId in job: ${JSON.stringify({
jobId: job.id,
userId: job.userId,
typeofUserId: typeof job.userId
})}`
);
throw new Error(`Invalid userId in import job: ${job.userId}`);
}
// Acquisition context for tracking
const context = {
userId: job.userId,
spotifyImportJobId: job.id,
};
let result;
if (isUnknownAlbum) {
// Unknown Album: Use track-based acquisition
logger?.debug(
`[Spotify Import] Unknown Album detected - using track acquisition`
);
const trackRequests =
album.tracksNeeded.map((track) => ({
trackTitle: track.title,
artistName: track.artist,
albumTitle: album.albumName,
}));
const trackResults =
await acquisitionService.acquireTracks(
trackRequests,
context
);
// Check if at least 50% succeeded
const successCount = trackResults.filter(
(r) => r.success
).length;
const successThreshold = Math.ceil(
trackRequests.length * 0.5
);
result = {
success:
successCount >= successThreshold,
tracksDownloaded: successCount,
tracksTotal: trackRequests.length,
};
if (result.success) {
logger?.info(
`Unknown Album tracks success: ${album.artistName} - ${successCount}/${trackRequests.length} tracks`
);
}
} else {
// Regular album: Use album-based acquisition
result =
await acquisitionService.acquireAlbum(
{
albumTitle: album.albumName,
artistName: album.artistName,
mbid: album.albumMbid!,
requestedTracks: album.tracksNeeded.map(t => ({
title: t.title
})),
},
context
);
if (result.success) {
logger?.info(
`Album acquisition success: ${album.artistName} - ${album.albumName} via ${result.source}`
);
}
}
if (!result.success) {
const errorMsg =
result.error ||
"No download sources available";
logger?.debug(
`[Spotify Import] ✗ Failed: ${album.albumName} - ${errorMsg}`
);
logger?.logAlbumFailed(
album.albumName,
album.artistName,
errorMsg
);
}
job.albumsCompleted++;
job.progress = Math.round(
(job.albumsCompleted / job.albumsTotal) * 30
);
job.updatedAt = new Date();
await saveImportJob(job);
logger?.debug(
`Album done: ${album.artistName} - ${
album.albumName
} (success=${
result.success ? "yes" : "no"
})`
);
} catch (error: any) {
logger?.error(
`[Spotify Import] Failed: ${album.artistName} - ${album.albumName}: ${error.message}`
);
logger?.logAlbumFailed(
album.albumName,
album.artistName,
error.message
);
}
})
);
// Wait for all album acquisitions to complete
await Promise.all(albumPromises);
logger?.info(
`Initial acquisition phase finished for ${albumMbidsToDownload.length} album(s). Checking completion state...`
);
// Check if we can complete immediately
await this.checkImportCompletion(job.id);
// If still downloading, wait for completion
if (job.status === "downloading") {
logger?.debug(
`[Spotify Import] Job ${job.id}: Waiting for downloads to complete...`
);
logger?.info(`Waiting for downloads to complete...`);
}
return;
}
// No downloads needed - all tracks already in library
// Create playlist immediately
await this.buildPlaylist(job);
} catch (error: any) {
job.status = "failed";
job.error = error.message;
job.updatedAt = new Date();
throw error;
}
}
/**
* Check if all downloads for this import are complete (called by webhook handler)
*/
async checkImportCompletion(importJobId: string): Promise<void> {
logger?.debug(
`\n[Spotify Import] Checking completion for job ${importJobId}...`
);
const job = await getImportJob(importJobId);
if (!job) {
logger?.debug(` Job not found`);
return;
}
const jobLogger = jobLoggers.get(importJobId);
// Check download jobs for this import
// NOTE: Jobs are created with auto-generated CUIDs, not prefixed IDs
// The spotifyImportJobId is stored in metadata.spotifyImportJobId
const downloadJobs = await prisma.downloadJob.findMany({
where: {
metadata: {
path: ['spotifyImportJobId'],
equals: importJobId,
},
},
});
const total = downloadJobs.length;
const completed = downloadJobs.filter(
(j) => j.status === "completed"
).length;
const failed = downloadJobs.filter((j) => j.status === "failed").length;
const pending = total - completed - failed;
if (total === 0 && job.albumsTotal > 0) {
const message =
"No download jobs were created for this import. This usually means the import preview did not include the selected albums.";
logger?.debug(` ${message}`);
jobLogger?.warn(message);
job.status = "failed";
job.error = message;
job.updatedAt = new Date();
await saveImportJob(job);
return;
}
logger?.debug(
` Download status: ${completed}/${total} completed, ${failed} failed, ${pending} pending`
);
jobLogger?.logDownloadProgress(completed, failed, pending);
// Update progress
job.progress =
total > 0
? 30 + Math.round((completed / total) * 40) // 30-70% for downloads
: 30;
job.updatedAt = new Date();
if (pending > 0) {
// Check how long we've been waiting for these downloads
const oldestPending = downloadJobs
.filter(
(j) => j.status === "pending" || j.status === "processing"
)
.sort(
(a, b) => a.createdAt.getTime() - b.createdAt.getTime()
)[0];
const waitTimeMs = oldestPending
? Date.now() - oldestPending.createdAt.getTime()
: 0;
const waitTimeMins = Math.round(waitTimeMs / 60000);
// After 10 minutes of waiting, proceed anyway to avoid stuck jobs
if (waitTimeMs < 600000) {
// 10 minutes
logger?.debug(
` Still waiting for ${pending} downloads... (${waitTimeMins} min elapsed)`
);
jobLogger?.info(`Waiting for Soulseek downloads to complete...`);
await saveImportJob(job);
return;
}
logger?.debug(
` Timeout: ${pending} downloads still pending after ${waitTimeMins} minutes, proceeding anyway`
);
jobLogger?.warn(
`Download timeout: ${pending} pending after ${waitTimeMins}m, proceeding with available tracks`
);
// Mark stale pending jobs as failed
await prisma.downloadJob.updateMany({
where: {
metadata: {
path: ['spotifyImportJobId'],
equals: importJobId,
},
status: { in: ["pending", "processing"] },
},
data: {
status: "failed",
error: "Timed out waiting for download",
completedAt: new Date(),
},
});
}
// All downloads finished (completed or failed)
logger?.debug(` All downloads finished! Triggering library scan...`);
jobLogger?.info(
`All ${total} download jobs finished (${completed} completed, ${failed} failed)`
);
// Trigger library scan to import the new files
const { scanQueue } = await import("../workers/queues");
const scanJob = await scanQueue.add("scan", {
userId: job.userId,
source: "spotify-import",
spotifyImportJobId: importJobId,
});
jobLogger?.info(
`Queued library scan (bullJobId=${scanJob.id ?? "unknown"})`
);
job.status = "scanning";
job.progress = 75;
job.updatedAt = new Date();
await saveImportJob(job);
}
/**
* Build playlist after library scan completes (called by scan worker)
*/
async buildPlaylistAfterScan(importJobId: string): Promise<void> {
logger?.debug(
`\n[Spotify Import] Building playlist for job ${importJobId}...`
);
const job = await getImportJob(importJobId);
if (!job) {
logger?.debug(` Job not found`);
return;
}
await this.buildPlaylist(job);
}
/**
* Internal: Build the playlist with matched tracks
*/
private async buildPlaylist(job: ImportJob): Promise<void> {
const logger = jobLoggers.get(job.id);
job.status = "creating_playlist";
job.progress = 90;
job.updatedAt = new Date();
await saveImportJob(job);
logger?.logPlaylistCreationStart();
logger?.logTrackMatchingStart();
// Match all pending tracks against the library
const matchedTrackIds: string[] = [];
let trackIndex = 0;
for (const pendingTrack of job.pendingTracks) {
trackIndex++;
// FAST PATH: If already matched in preview, use that ID directly
// This ensures tracks found during preview are included in the final playlist
if (pendingTrack.preMatchedTrackId) {
// Verify the track still exists
const existingTrack = await prisma.track.findUnique({
where: { id: pendingTrack.preMatchedTrackId },
select: { id: true, title: true },
});
if (existingTrack) {
matchedTrackIds.push(existingTrack.id);
logger?.debug(
` ✓ Pre-matched: "${pendingTrack.title}" -> track ${existingTrack.id}`
);
logger?.logTrackMatch(
trackIndex,
job.tracksTotal,
pendingTrack.title,
pendingTrack.artist,
true,
existingTrack.id
);
continue;
}
}
const normalizedArtist = normalizeString(pendingTrack.artist);
// Get first word for fuzzy artist matching (handles "Nick Cave & The Bad Seeds" -> "nick")
const artistFirstWord = normalizedArtist.split(" ")[0];
// Strip suffix but keep punctuation for DB queries: "Ain't Gonna Rain Anymore - 2011 Remaster" -> "Ain't Gonna Rain Anymore"
const strippedTitle = stripTrackSuffix(pendingTrack.title);
// Also normalize apostrophes in the original title for searching
const normalizedTitle = normalizeApostrophes(pendingTrack.title);
// Fully normalized for similarity comparison: "aint gonna rain anymore"
const cleanedTitle = normalizeTrackTitle(pendingTrack.title);
logger?.log(
` Matching: "${pendingTrack.title}" by ${pendingTrack.artist}`
);
logger?.log(
` strippedTitle: "${strippedTitle}", artistFirstWord: "${artistFirstWord}"`
);
// Try multiple matching strategies
let localTrack = null;
// Strategy 1: Exact title match with fuzzy artist (contains first word)
localTrack = await prisma.track.findFirst({
where: {
title: {
equals: normalizedTitle,
mode: "insensitive",
},
album: {
artist: {
normalizedName: {
contains: artistFirstWord,
mode: "insensitive",
},
},
},
},
});
// Strategy 2: Stripped title match (removes remaster suffix but keeps punctuation)
// "Ain't Gonna Rain Anymore - 2011 Remaster" -> searches for "Ain't Gonna Rain Anymore"
if (!localTrack && strippedTitle !== normalizedTitle) {
logger?.log(
` Strategy 2: Searching for stripped title "${strippedTitle}"`
);
localTrack = await prisma.track.findFirst({
where: {
title: {
equals: strippedTitle,
mode: "insensitive",
},
album: {
artist: {
normalizedName: {
contains: artistFirstWord,
mode: "insensitive",
},
},
},
},
});
}
// Strategy 3: Case-insensitive CONTAINS search on title (handles slight variations)
// e.g., database has "Ain't" but Spotify has "Ain't" (different apostrophe after normalization still differs)
if (!localTrack && strippedTitle.length >= 5) {
// Search for tracks where title contains the first few words
const searchTerm = strippedTitle
.split(" ")
.slice(0, 4)
.join(" ");
logger?.log(
` Strategy 3: Contains search for "${searchTerm}"`
);
const candidates = await prisma.track.findMany({
where: {
title: {
contains: searchTerm,
mode: "insensitive",
},
album: {
artist: {
normalizedName: {
contains: artistFirstWord,
mode: "insensitive",
},
},
},
},
take: 10,
});
// Find best match using similarity OR containment
for (const candidate of candidates) {
const candidateNormalized = normalizeTrackTitle(
candidate.title
);
const sim = stringSimilarity(
cleanedTitle,
candidateNormalized
);
// Direct similarity match
if (sim >= 80) {
localTrack = candidate;
logger?.log(
` Found via contains+similarity (${sim.toFixed(
0
)}%)`
);
break;
}
// Containment match: "Sordid Affair" should match "Sordid Affair (Feat. Ryan James)"
// Check if one title contains the other (normalized)
const spotifyNorm = cleanedTitle.toLowerCase();
const libraryNorm = candidateNormalized.toLowerCase();
if (
libraryNorm.startsWith(spotifyNorm) ||
spotifyNorm.startsWith(libraryNorm)
) {
localTrack = candidate;
logger?.log(
` Found via containment match: "${cleanedTitle}" in "${candidateNormalized}"`
);
break;
}
}
}
// Strategy 3.5: Same as preview - fuzzy match on artist NAME using similarity
// This catches cases where normalizedName differs from what we expect
if (!localTrack) {
logger?.log(` Strategy 3.5: Fuzzy artist+title matching`);
const candidates = await prisma.track.findMany({
where: {
album: {
artist: {
normalizedName: {
contains: artistFirstWord,
mode: "insensitive",
},
},
},
},
include: { album: { include: { artist: true } } },
take: 50,
});
// Use same matching as preview: compare cleaned titles
for (const candidate of candidates) {
const titleSim = stringSimilarity(
cleanedTitle,
normalizeTrackTitle(candidate.title)
);
const artistSim = stringSimilarity(
pendingTrack.artist,
candidate.album.artist.name
);
const score = titleSim * 0.6 + artistSim * 0.4;
if (score >= 70) {
localTrack = candidate;
logger?.debug(
` (preview-style match: ${score.toFixed(0)}%)`
);
break;
}
}
}
// Strategy 4: StartsWith match with stripped title (for slight title variations)
if (!localTrack && strippedTitle.length > 10) {
logger?.log(` Strategy 4: StartsWith search`);
localTrack = await prisma.track.findFirst({
where: {
title: {
startsWith: strippedTitle.substring(
0,
Math.min(20, strippedTitle.length)
),
mode: "insensitive",
},
album: {
artist: {
normalizedName: {
contains: artistFirstWord,
mode: "insensitive",
},
},
},
},
});
// Verify match
if (localTrack) {
const dbTitleNormalized = normalizeTrackTitle(
localTrack.title
);
if (
stringSimilarity(cleanedTitle, dbTitleNormalized) < 70
) {
localTrack = null;
} else {
logger?.log(` Found via startsWith`);
}
}
}
// Strategy 5: Very fuzzy - search and score by similarity (last resort)
if (!localTrack) {
logger?.log(` Strategy 5: Fuzzy search (last resort)`);
// Get first few words for search
const searchWords = strippedTitle
.split(" ")
.slice(0, 3)
.join(" ");
if (searchWords.length >= 4) {
const candidates = await prisma.track.findMany({
where: {
title: {
contains: searchWords.split(" ")[0], // Just first word
mode: "insensitive",
},
album: {
artist: {
normalizedName: {
contains: artistFirstWord,
mode: "insensitive",
},
},
},
},
include: { album: { include: { artist: true } } },
take: 20,
});
// Find best match by similarity
let bestMatch = null;
let bestScore = 0;
for (const candidate of candidates) {
const titleScore = stringSimilarity(
cleanedTitle,
normalizeTrackTitle(candidate.title)
);
const artistScore = stringSimilarity(
normalizedArtist,
normalizeString(candidate.album.artist.name)
);
const combinedScore =
titleScore * 0.7 + artistScore * 0.3;
if (combinedScore > bestScore && combinedScore >= 65) {
bestScore = combinedScore;
bestMatch = candidate;
}
}
if (bestMatch) {
localTrack = bestMatch;
logger?.debug(
` (fuzzy match: score ${bestScore.toFixed(
0
)}% with "${bestMatch.title}" by ${
bestMatch.album.artist.name
})`
);
}
}
}
// Strategy 6: Title-only search (ignores artist entirely)
// This handles cases where file has wrong artist metadata (e.g., "Various Artists" compilations)
// Only used when title is distinctive enough (>10 chars) and no match found yet
if (!localTrack && cleanedTitle.length >= 10) {
logger?.log(
` Strategy 6: Title-only search (fallback for wrong artist metadata)`
);
// Search for tracks with very similar title, ignore artist completely
const titleSearchTerm = strippedTitle
.split(" ")
.slice(0, 4)
.join(" ");
const candidates = await prisma.track.findMany({
where: {
title: {
contains: titleSearchTerm,
mode: "insensitive",
},
},
include: { album: { include: { artist: true } } },
take: 50,
});
// Find a high-confidence title match (require 85%+ similarity on title alone)
let bestTitleMatch = null;
let bestTitleScore = 0;
for (const candidate of candidates) {
const titleScore = stringSimilarity(
cleanedTitle,
normalizeTrackTitle(candidate.title)
);
// Require very high title match since we're ignoring artist
if (titleScore > bestTitleScore && titleScore >= 85) {
bestTitleScore = titleScore;
bestTitleMatch = candidate;
}
}
if (bestTitleMatch) {
localTrack = bestTitleMatch;
logger?.log(
` Found via title-only match (${bestTitleScore.toFixed(
0
)}%): "${bestTitleMatch.title}" by ${
bestTitleMatch.album.artist.name
}`
);
logger?.debug(
` (title-only match: ${bestTitleScore.toFixed(
0
)}% - note: artist metadata mismatch, wanted "${
pendingTrack.artist
}" got "${bestTitleMatch.album.artist.name}")`
);
}
}
if (localTrack) {
matchedTrackIds.push(localTrack.id);
logger?.debug(
` ✓ Matched: "${pendingTrack.title}" -> track ${localTrack.id}`
);
logger?.logTrackMatch(
trackIndex,
job.tracksTotal,
pendingTrack.title,
pendingTrack.artist,
true,
localTrack.id
);
} else {
// Debug: Check if artist exists at all
const artistExists = await prisma.artist.findFirst({
where: {
normalizedName: {
contains: normalizedArtist.split(" ")[0],
mode: "insensitive",
},
},
select: { name: true, normalizedName: true },
});
if (artistExists) {
logger?.debug(
` ✗ No match: "${pendingTrack.title}" by ${pendingTrack.artist} (artist "${artistExists.name}" exists but track not found)`
);
} else {
logger?.debug(
` ✗ No match: "${pendingTrack.title}" by ${pendingTrack.artist} (artist not in library)`
);
}
logger?.logTrackMatch(
trackIndex,
job.tracksTotal,
pendingTrack.title,
pendingTrack.artist,
false
);
}
}
const uniqueTrackIds = Array.from(new Set(matchedTrackIds));
if (uniqueTrackIds.length < matchedTrackIds.length) {
const removed = matchedTrackIds.length - uniqueTrackIds.length;
logger?.debug(
` Removed ${removed} duplicate track references before playlist creation`
);
logger?.info(
`Removed ${removed} duplicate track references before playlist creation`
);
}
logger?.debug(
` Matched ${uniqueTrackIds.length}/${job.tracksTotal} tracks`
);
logger?.info(
`Matched tracks after scan: ${uniqueTrackIds.length}/${job.tracksTotal}`
);
// Create the playlist with Spotify metadata
const playlist = await prisma.playlist.create({
data: {
userId: job.userId,
name: job.playlistName,
isPublic: false,
spotifyPlaylistId: job.spotifyPlaylistId,
items:
uniqueTrackIds.length > 0
? {
create: uniqueTrackIds.map((trackId, index) => ({
trackId,
sort: index,
})),
}
: undefined,
},
});
// Save unmatched tracks as pending tracks for later auto-matching
const unmatchedTracks = job.pendingTracks.filter((_, index) => {
// We need to track which indices were matched
// Since matchedTrackIds doesn't preserve order, we need a different approach
return true; // We'll recalculate below
});
// Recalculate unmatched - tracks that weren't added to playlist
const matchedTitlesNormalized = new Set<string>();
for (const pendingTrack of job.pendingTracks) {
const normalizedArtist = normalizeString(pendingTrack.artist);
const strippedTitle = stripTrackSuffix(pendingTrack.title);
// Check if this track was matched by looking for it in the created items
const found = await prisma.track.findFirst({
where: {
id: { in: uniqueTrackIds },
title: {
contains: strippedTitle.split(" ")[0],
mode: "insensitive",
},
album: {
artist: {
normalizedName: {
contains: normalizedArtist.split(" ")[0],
mode: "insensitive",
},
},
},
},
});
if (found) {
matchedTitlesNormalized.add(
`${normalizedArtist}|${strippedTitle.toLowerCase()}`
);
}
}
// Save pending tracks that weren't matched
const pendingTracksToSave = job.pendingTracks
.map((track, index) => ({ ...track, originalIndex: index }))
.filter((track) => {
const normalizedArtist = normalizeString(track.artist);
const strippedTitle = stripTrackSuffix(
track.title
).toLowerCase();
return !matchedTitlesNormalized.has(
`${normalizedArtist}|${strippedTitle}`
);
});
if (pendingTracksToSave.length > 0) {
logger?.debug(
` Saving ${pendingTracksToSave.length} pending tracks for future auto-matching`
);
logger?.debug(
` Fetching Deezer preview URLs for pending tracks...`
);
logger?.info(
`Saving pending tracks: ${pendingTracksToSave.length}`
);
// Fetch Deezer previews in parallel for all pending tracks
const pendingTracksWithPreviews = await Promise.all(
pendingTracksToSave.map(async (track) => {
let deezerPreviewUrl: string | null = null;
try {
deezerPreviewUrl = await deezerService.getTrackPreview(
track.artist,
track.title
);
} catch (e) {
// Preview not critical, continue without it
}
return {
...track,
deezerPreviewUrl,
};
})
);
const previewsFound = pendingTracksWithPreviews.filter(
(t) => t.deezerPreviewUrl
).length;
logger?.debug(
` Found ${previewsFound}/${pendingTracksToSave.length} Deezer preview URLs`
);
logger?.info(
`Pending previews found: ${previewsFound}/${pendingTracksToSave.length}`
);
await prisma.playlistPendingTrack.createMany({
data: pendingTracksWithPreviews.map((track) => ({
playlistId: playlist.id,
spotifyArtist: track.artist,
spotifyTitle: track.title,
spotifyAlbum: track.album,
deezerPreviewUrl: track.deezerPreviewUrl,
sort: track.originalIndex,
})),
skipDuplicates: true,
});
}
job.createdPlaylistId = playlist.id;
job.tracksMatched = uniqueTrackIds.length;
job.status = "completed";
job.progress = 100;
job.updatedAt = new Date();
await saveImportJob(job);
logger?.debug(`[Spotify Import] Job ${job.id} completed:`);
logger?.debug(` Playlist created: ${playlist.id}`);
logger?.debug(
` Tracks matched: ${matchedTrackIds.length}/${job.tracksTotal}`
);
logger?.logPlaylistCreated(
playlist.id,
matchedTrackIds.length,
job.tracksTotal
);
logger?.logJobComplete(
matchedTrackIds.length,
job.tracksTotal,
playlist.id
);
// Send notification about import completion
try {
await notificationService.notifyImportComplete(
job.userId,
job.playlistName,
playlist.id,
matchedTrackIds.length,
job.tracksTotal
);
} catch (notifError) {
logger?.error(`Failed to send import notification: ${notifError}`);
}
}
/**
* Re-match pending tracks and add newly downloaded ones to the playlist
*/
async refreshJobMatches(
jobId: string
): Promise<{ added: number; total: number }> {
const logger = jobLoggers.get(jobId);
const job = await getImportJob(jobId);
if (!job) {
throw new Error("Import job not found");
}
if (!job.createdPlaylistId) {
throw new Error("No playlist created for this job");
}
let added = 0;
// Get existing tracks in playlist
const existingItems = await prisma.playlistItem.findMany({
where: { playlistId: job.createdPlaylistId },
select: { trackId: true },
});
const existingTrackIds = new Set(
existingItems.map((item) => item.trackId)
);
// Get next position
const maxPosition = existingItems.length;
let nextPosition = maxPosition;
// Try to match each pending track
for (const pendingTrack of job.pendingTracks) {
const normalizedArtist = normalizeString(pendingTrack.artist);
// Track model doesn't have normalizedTitle - use case-insensitive title matching
const localTrack = await prisma.track.findFirst({
where: {
title: {
equals: pendingTrack.title,
mode: "insensitive",
},
album: {
artist: {
normalizedName: normalizedArtist,
},
},
},
});
if (localTrack && !existingTrackIds.has(localTrack.id)) {
// Add to playlist
await prisma.playlistItem.create({
data: {
playlistId: job.createdPlaylistId,
trackId: localTrack.id,
sort: nextPosition++,
},
});
existingTrackIds.add(localTrack.id);
added++;
}
}
job.tracksMatched += added;
job.updatedAt = new Date();
logger?.debug(
`[Spotify Import] Refresh job ${jobId}: added ${added} newly downloaded tracks`
);
logger?.info(
`Refresh: added ${added} newly downloaded track(s), totalMatchedNow=${job.tracksMatched}`
);
return { added, total: job.tracksMatched };
}
/**
* Get import job status (public method for routes)
*/
async getJob(jobId: string): Promise<ImportJob | null> {
return await getImportJob(jobId);
}
/**
* Get all jobs for a user
*/
async getUserJobs(userId: string): Promise<ImportJob[]> {
// Get from database to include jobs across restarts
const dbJobs = await prisma.spotifyImportJob.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
});
return dbJobs
.map((dbJob) => ({
id: dbJob.id,
userId: dbJob.userId,
spotifyPlaylistId: dbJob.spotifyPlaylistId,
playlistName: dbJob.playlistName,
status: dbJob.status as ImportJob["status"],
progress: dbJob.progress,
albumsTotal: dbJob.albumsTotal,
albumsCompleted: dbJob.albumsCompleted,
tracksMatched: dbJob.tracksMatched,
tracksTotal: dbJob.tracksTotal,
tracksDownloadable: dbJob.tracksDownloadable,
createdPlaylistId: dbJob.createdPlaylistId,
error: dbJob.error,
createdAt: dbJob.createdAt,
updatedAt: dbJob.updatedAt,
pendingTracks: (dbJob.pendingTracks as any) || [],
}))
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
/**
* Cancel an import job without creating a playlist.
* All pending downloads are marked as failed and the job is marked as cancelled.
*/
async cancelJob(jobId: string): Promise<{
playlistCreated: boolean;
playlistId: string | null;
tracksMatched: number;
}> {
const job = await getImportJob(jobId);
if (!job) {
throw new Error("Import job not found");
}
const logger = jobLoggers.get(jobId);
logger?.debug(`[Spotify Import] Cancelling job ${jobId}...`);
logger?.info(`Job cancelled by user`);
// If already completed, cancelled, or failed, nothing to do
if (
job.status === "completed" ||
job.status === "failed" ||
job.status === "cancelled"
) {
return {
playlistCreated: !!job.createdPlaylistId,
playlistId: job.createdPlaylistId || null,
tracksMatched: job.tracksMatched,
};
}
// Mark any pending download jobs as cancelled
await prisma.downloadJob.updateMany({
where: {
metadata: {
path: ['spotifyImportJobId'],
equals: jobId,
},
status: { in: ["pending", "processing"] },
},
data: {
status: "failed",
error: "Import cancelled by user",
completedAt: new Date(),
},
});
// Mark job as cancelled - do NOT create a playlist
job.status = "cancelled";
job.updatedAt = new Date();
logger?.info(`Import cancelled by user - no playlist created`);
return {
playlistCreated: false,
playlistId: null,
tracksMatched: 0,
};
}
/**
* Reconcile pending tracks for ALL playlists after a library scan
* This checks if any previously unmatched tracks now have matches in the library
* and automatically adds them to their playlists
*/
async reconcilePendingTracks(): Promise<{
playlistsUpdated: number;
tracksAdded: number;
}> {
logger?.debug(
`\n[Spotify Import] Reconciling pending tracks across all playlists...`
);
// Get all pending tracks grouped by playlist
const allPendingTracks = await prisma.playlistPendingTrack.findMany({
include: {
playlist: {
select: {
id: true,
name: true,
userId: true,
},
},
},
orderBy: [{ playlistId: "asc" }, { sort: "asc" }],
});
if (allPendingTracks.length === 0) {
logger?.debug(` No pending tracks to reconcile`);
return { playlistsUpdated: 0, tracksAdded: 0 };
}
logger?.debug(
` Found ${allPendingTracks.length} pending tracks across playlists`
);
let totalTracksAdded = 0;
const playlistsWithAdditions = new Set<string>();
const matchedPendingTrackIds: string[] = [];
// Group by playlist for efficient processing
const tracksByPlaylist = new Map<string, typeof allPendingTracks>();
for (const pt of allPendingTracks) {
const existing = tracksByPlaylist.get(pt.playlistId) || [];
existing.push(pt);
tracksByPlaylist.set(pt.playlistId, existing);
}
for (const [playlistId, pendingTracks] of tracksByPlaylist) {
// Get current max sort position in playlist
const maxSortResult = await prisma.playlistItem.aggregate({
where: { playlistId },
_max: { sort: true },
});
let nextSort = (maxSortResult._max.sort ?? -1) + 1;
// Get existing track IDs in playlist to avoid duplicates
const existingItems = await prisma.playlistItem.findMany({
where: { playlistId },
select: { trackId: true },
});
const existingTrackIds = new Set(
existingItems.map((item) => item.trackId)
);
for (const pendingTrack of pendingTracks) {
const normalizedArtist = normalizeString(
pendingTrack.spotifyArtist
);
const artistFirstWord = normalizedArtist.split(" ")[0];
const strippedTitle = stripTrackSuffix(
pendingTrack.spotifyTitle
);
const cleanedTitle = normalizeTrackTitle(strippedTitle);
logger?.debug(
` Trying to match: "${pendingTrack.spotifyTitle}" by ${pendingTrack.spotifyArtist}`
);
logger?.debug(
` strippedTitle: "${strippedTitle}", artistFirstWord: "${artistFirstWord}"`
);
// Debug: Check what tracks exist for this artist
const artistTracks = await prisma.track.findMany({
where: {
album: {
artist: {
normalizedName: {
contains: artistFirstWord,
mode: "insensitive",
},
},
},
},
select: {
title: true,
album: {
select: {
artist: {
select: {
name: true,
normalizedName: true,
},
},
},
},
},
take: 5,
});
if (artistTracks.length > 0) {
logger?.debug(
` DEBUG: Found ${artistTracks.length}+ tracks for artist containing "${artistFirstWord}"`
);
artistTracks
.slice(0, 3)
.forEach((t) =>
logger?.debug(
` - "${t.title}" (artist: ${t.album.artist.name}, normalized: ${t.album.artist.normalizedName})`
)
);
} else {
logger?.debug(
` DEBUG: NO tracks found for artist containing "${artistFirstWord}"`
);
}
// Try to find a matching track (using same strategies as buildPlaylist)
// Strategy 1: Stripped title + fuzzy artist (contains first word)
let localTrack = await prisma.track.findFirst({
where: {
title: { equals: strippedTitle, mode: "insensitive" },
album: {
artist: {
normalizedName: {
contains: artistFirstWord,
mode: "insensitive",
},
},
},
},
select: { id: true, title: true },
});
logger?.debug(
` Strategy 1 result: ${
localTrack ? "FOUND" : "not found"
}`
);
// Strategy 2: Contains search on first few words + similarity
if (!localTrack && strippedTitle.length >= 5) {
const searchTerm = strippedTitle
.split(" ")
.slice(0, 4)
.join(" ");
logger?.debug(
` Strategy 2: Contains search for "${searchTerm}"`
);
const candidates = await prisma.track.findMany({
where: {
title: {
contains: searchTerm,
mode: "insensitive",
},
album: {
artist: {
normalizedName: {
contains: artistFirstWord,
mode: "insensitive",
},
},
},
},
include: { album: { include: { artist: true } } },
take: 10,
});
logger?.debug(
` Strategy 2: Found ${candidates.length} candidates`
);
for (const candidate of candidates) {
const candidateNormalized = normalizeTrackTitle(
candidate.title
);
const sim = stringSimilarity(
cleanedTitle,
candidateNormalized
);
logger?.debug(
` "${candidate.title}" by ${
candidate.album.artist.name
}: ${sim.toFixed(0)}%`
);
// Direct similarity match
if (sim >= 80) {
localTrack = {
id: candidate.id,
title: candidate.title,
};
break;
}
// Containment match: "Sordid Affair" should match "Sordid Affair (Feat. Ryan James)"
const spotifyNorm = cleanedTitle.toLowerCase();
const libraryNorm = candidateNormalized.toLowerCase();
if (
libraryNorm.startsWith(spotifyNorm) ||
spotifyNorm.startsWith(libraryNorm)
) {
logger?.debug(
` Found via containment: "${cleanedTitle}" starts "${candidateNormalized}"`
);
localTrack = {
id: candidate.id,
title: candidate.title,
};
break;
}
}
}
if (!localTrack)
logger?.debug(` Strategy 2 result: not found`);
// Strategy 3: Fuzzy match on title + artist similarity
if (!localTrack) {
const firstWord = strippedTitle.split(" ")[0];
logger?.debug(
` Strategy 3: Fuzzy search for title containing "${firstWord}" and artist containing "${artistFirstWord}"`
);
const candidates = await prisma.track.findMany({
where: {
title: { contains: firstWord, mode: "insensitive" },
album: {
artist: {
normalizedName: {
contains: artistFirstWord,
mode: "insensitive",
},
},
},
},
include: { album: { include: { artist: true } } },
take: 20,
});
logger?.debug(
` Strategy 3: Found ${candidates.length} candidates`
);
for (const candidate of candidates) {
const titleScore = stringSimilarity(
cleanedTitle,
normalizeTrackTitle(candidate.title)
);
const artistScore = stringSimilarity(
pendingTrack.spotifyArtist,
candidate.album.artist.name
);
const combinedScore =
titleScore * 0.6 + artistScore * 0.4;
logger?.debug(
` "${candidate.title}" by ${
candidate.album.artist.name
}: title=${titleScore.toFixed(
0
)}%, artist=${artistScore.toFixed(
0
)}%, combined=${combinedScore.toFixed(0)}%`
);
if (combinedScore >= 70) {
localTrack = {
id: candidate.id,
title: candidate.title,
};
break;
}
}
}
if (localTrack && !existingTrackIds.has(localTrack.id)) {
// Add to playlist
await prisma.playlistItem.create({
data: {
playlistId,
trackId: localTrack.id,
sort: nextSort++,
},
});
existingTrackIds.add(localTrack.id);
matchedPendingTrackIds.push(pendingTrack.id);
totalTracksAdded++;
playlistsWithAdditions.add(playlistId);
logger?.debug(
` ✓ Matched: "${pendingTrack.spotifyTitle}" by ${pendingTrack.spotifyArtist}`
);
}
}
}
// Delete the matched pending tracks
if (matchedPendingTrackIds.length > 0) {
await prisma.playlistPendingTrack.deleteMany({
where: { id: { in: matchedPendingTrackIds } },
});
}
// Send notifications for each playlist that was updated
if (playlistsWithAdditions.size > 0) {
const { notificationService } = await import(
"./notificationService"
);
for (const playlistId of playlistsWithAdditions) {
const playlist = await prisma.playlist.findUnique({
where: { id: playlistId },
select: { id: true, name: true, userId: true },
});
if (playlist) {
const tracksAddedToPlaylist = matchedPendingTrackIds.filter(
(id) =>
allPendingTracks.find(
(pt) =>
pt.id === id && pt.playlistId === playlistId
)
).length;
await notificationService.create({
userId: playlist.userId,
type: "playlist_ready",
title: "Playlist Updated",
message: `${tracksAddedToPlaylist} new track${
tracksAddedToPlaylist !== 1 ? "s" : ""
} added to "${playlist.name}"`,
metadata: {
playlistId: playlist.id,
tracksAdded: tracksAddedToPlaylist,
},
});
}
}
}
logger?.debug(
` Reconciliation complete: ${totalTracksAdded} tracks added to ${playlistsWithAdditions.size} playlists`
);
return {
playlistsUpdated: playlistsWithAdditions.size,
tracksAdded: totalTracksAdded,
};
}
/**
* Get pending tracks count for a playlist
*/
async getPendingTracksCount(playlistId: string): Promise<number> {
return prisma.playlistPendingTrack.count({
where: { playlistId },
});
}
/**
* Get pending tracks for a playlist
*/
async getPendingTracks(playlistId: string): Promise<
Array<{
id: string;
artist: string;
title: string;
album: string;
}>
> {
const tracks = await prisma.playlistPendingTrack.findMany({
where: { playlistId },
orderBy: { sort: "asc" },
});
return tracks.map((t) => ({
id: t.id,
artist: t.spotifyArtist,
title: t.spotifyTitle,
album: t.spotifyAlbum,
}));
}
}
export const spotifyImportService = new SpotifyImportService();