cc8d0f6969
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
2450 lines
91 KiB
TypeScript
2450 lines
91 KiB
TypeScript
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();
|