Files
lidify/backend/src/services/enrichment.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

668 lines
22 KiB
TypeScript

/**
* Metadata Enrichment Service
*
* Enriches artist/album/track metadata using multiple sources:
* - MusicBrainz: MBIDs, release dates, track info
* - Last.fm: Genres, tags, similar artists, bio
* - Cover Art Archive: Album artwork
* - Discogs: Additional metadata (optional)
*
* Features:
* - Optional/opt-in (bandwidth intensive)
* - Rate limiting to respect API limits
* - Confidence scoring for matches
* - Manual override support
*/
import { logger } from "../utils/logger";
import { prisma } from "../utils/db";
import { lastFmService } from "./lastfm";
import { musicBrainzService } from "./musicbrainz";
import { imageProviderService } from "./imageProvider";
export interface EnrichmentSettings {
enabled: boolean;
autoEnrichOnScan: boolean;
sources: {
musicbrainz: boolean;
lastfm: boolean;
coverArtArchive: boolean;
};
rateLimit: {
maxRequestsPerMinute: number;
respectApiLimits: boolean;
};
overwriteExisting: boolean;
matchingConfidence: "strict" | "moderate" | "loose";
}
export interface EnrichmentResult {
success: boolean;
itemsProcessed: number;
itemsEnriched: number;
itemsFailed: number;
errors: Array<{ item: string; error: string }>;
}
export interface ArtistEnrichmentData {
mbid?: string;
bio?: string;
genres?: string[];
tags?: string[];
similarArtists?: string[];
heroUrl?: string;
formed?: number;
confidence: number;
}
export interface AlbumEnrichmentData {
rgMbid?: string;
releaseDate?: Date;
albumType?: string;
genres?: string[];
tags?: string[];
label?: string;
coverUrl?: string;
trackCount?: number;
confidence: number;
}
export interface TrackEnrichmentData {
mbid?: string;
duration?: number;
genres?: string[];
lyrics?: string;
confidence: number;
}
export class EnrichmentService {
private defaultSettings: EnrichmentSettings = {
enabled: false, // Opt-in by default
autoEnrichOnScan: false,
sources: {
musicbrainz: true,
lastfm: true,
coverArtArchive: true,
},
rateLimit: {
maxRequestsPerMinute: 30,
respectApiLimits: true,
},
overwriteExisting: false,
matchingConfidence: "moderate",
};
private requestQueue: Array<() => Promise<any>> = [];
private isProcessingQueue = false;
/**
* Get enrichment settings for a user
*/
async getSettings(userId: string): Promise<EnrichmentSettings> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { enrichmentSettings: true },
});
if (user?.enrichmentSettings) {
// enrichmentSettings is already a JSON object from Prisma
let userSettings: any;
if (typeof user.enrichmentSettings === "string") {
userSettings = JSON.parse(user.enrichmentSettings);
} else {
userSettings = user.enrichmentSettings;
}
// IMPORTANT: Always merge with defaults to ensure all fields exist
return {
...this.defaultSettings,
...userSettings,
sources: {
...this.defaultSettings.sources,
...(userSettings.sources || {}),
},
rateLimit: {
...this.defaultSettings.rateLimit,
...(userSettings.rateLimit || {}),
},
};
}
return this.defaultSettings;
}
/**
* Update enrichment settings for a user
*/
async updateSettings(
userId: string,
settings: Partial<EnrichmentSettings>
): Promise<EnrichmentSettings> {
const current = await this.getSettings(userId);
const updated = { ...current, ...settings };
await prisma.user.update({
where: { id: userId },
data: {
enrichmentSettings: JSON.stringify(updated) as any,
},
});
return updated;
}
/**
* Enrich a single artist with metadata from multiple sources
*/
async enrichArtist(
artistId: string,
settings?: EnrichmentSettings
): Promise<ArtistEnrichmentData | null> {
const config = settings || this.defaultSettings;
if (!config.enabled) {
return null;
}
const artist = await prisma.artist.findUnique({
where: { id: artistId },
select: { id: true, name: true, mbid: true },
});
if (!artist) {
throw new Error(`Artist ${artistId} not found`);
}
logger.debug(`Enriching artist: ${artist.name}`);
const enrichmentData: ArtistEnrichmentData = {
confidence: 0,
};
// Step 1: Get/verify MBID from MusicBrainz
if (
config.sources.musicbrainz &&
(!artist.mbid || artist.mbid.startsWith("temp-"))
) {
try {
const mbResults = await musicBrainzService.searchArtist(
artist.name,
1
);
if (mbResults.length > 0) {
enrichmentData.mbid = mbResults[0].id;
enrichmentData.confidence += 0.4;
logger.debug(` Found MBID: ${enrichmentData.mbid}`);
}
} catch (error) {
logger.error(` MusicBrainz lookup failed:`, error);
}
}
// Step 2: Get artist info from Last.fm
if (config.sources.lastfm) {
try {
const artistMbid = enrichmentData.mbid || artist.mbid;
const lastfmInfo = await lastFmService.getArtistInfo(
artist.name,
artistMbid && !artistMbid.startsWith("temp-")
? artistMbid
: undefined
);
if (lastfmInfo) {
enrichmentData.bio = lastfmInfo.bio?.summary;
enrichmentData.tags =
lastfmInfo.tags?.tag?.map((t: any) => t.name) || [];
enrichmentData.genres = enrichmentData.tags?.slice(0, 3); // Top 3 tags as genres
enrichmentData.confidence += 0.3;
logger.debug(
` Found Last.fm data: ${
enrichmentData.tags?.length || 0
} tags`
);
// Get similar artists
const similar = await lastFmService.getSimilarArtists(
artist.name,
"10"
);
enrichmentData.similarArtists = similar.map(
(a: any) => a.name
);
logger.debug(` Found ${similar.length} similar artists`);
}
} catch (error) {
logger.error(
` ✗ Last.fm lookup failed:`,
error instanceof Error ? error.message : error
);
}
}
// Step 3: Get artist image from multiple sources (Deezer → Fanart → MusicBrainz → Last.fm)
try {
const artistMbid = enrichmentData.mbid || artist.mbid;
const imageResult = await imageProviderService.getArtistImage(
artist.name,
artistMbid && !artistMbid.startsWith("temp-")
? artistMbid
: undefined
);
if (imageResult) {
enrichmentData.heroUrl = imageResult.url;
enrichmentData.confidence += 0.2;
logger.debug(` Found artist image from ${imageResult.source}`);
}
} catch (error) {
logger.error(
` ✗ Artist image lookup failed:`,
error instanceof Error ? error.message : error
);
}
logger.debug(
` Enrichment confidence: ${(
enrichmentData.confidence * 100
).toFixed(0)}%`
);
return enrichmentData;
}
/**
* Enrich a single album with metadata from multiple sources
*/
async enrichAlbum(
albumId: string,
settings?: EnrichmentSettings
): Promise<AlbumEnrichmentData | null> {
const config = settings || this.defaultSettings;
if (!config.enabled) {
return null;
}
const album = await prisma.album.findUnique({
where: { id: albumId },
include: {
artist: {
select: { name: true, mbid: true },
},
},
});
if (!album) {
throw new Error(`Album ${albumId} not found`);
}
logger.debug(
`[Enrichment] Processing album: ${album.artist.name} - ${album.title}`
);
const enrichmentData: AlbumEnrichmentData = {
confidence: 0,
};
// Step 1: Try to find MBID
if (config.sources.musicbrainz) {
try {
// If artist has MBID, search their discography
if (
album.artist.mbid &&
!album.artist.mbid.startsWith("temp-")
) {
const releaseGroups =
await musicBrainzService.getReleaseGroups(
album.artist.mbid,
["album", "ep"],
50
);
// Try to match by title
const match = releaseGroups.find(
(rg: any) =>
rg.title.toLowerCase() ===
album.title.toLowerCase() ||
rg.title.toLowerCase().replace(/[^a-z0-9]/g, "") ===
album.title
.toLowerCase()
.replace(/[^a-z0-9]/g, "")
);
if (match) {
enrichmentData.rgMbid = match.id;
enrichmentData.albumType = match["primary-type"];
enrichmentData.releaseDate = match["first-release-date"]
? new Date(match["first-release-date"])
: undefined;
enrichmentData.confidence += 0.5;
logger.debug(` Found MBID: ${enrichmentData.rgMbid}`);
// Try to get label info from first release
try {
const rgDetails =
await musicBrainzService.getReleaseGroup(
match.id
);
if (rgDetails?.releases?.[0]?.id) {
const releaseId = rgDetails.releases[0].id;
const releaseInfo =
await musicBrainzService.getRelease(
releaseId
);
if (
releaseInfo?.["label-info"]?.[0]?.label
?.name
) {
enrichmentData.label =
releaseInfo["label-info"][0].label.name;
logger.debug(
` Found label: ${enrichmentData.label}`
);
}
}
} catch (error) {
logger.debug(`Could not fetch label info`);
}
}
}
} catch (error) {
logger.error(` MusicBrainz lookup failed:`, error);
}
}
// Step 2: Get album info from Last.fm
if (config.sources.lastfm) {
try {
const lastfmInfo = await lastFmService.getAlbumInfo(
album.artist.name,
album.title
);
if (lastfmInfo) {
enrichmentData.tags =
lastfmInfo.tags?.tag?.map((t: any) => t.name) || [];
enrichmentData.genres = enrichmentData.tags?.slice(0, 3);
enrichmentData.trackCount =
lastfmInfo.tracks?.track?.length;
enrichmentData.confidence += 0.3;
logger.debug(
` Found Last.fm data: ${
enrichmentData.tags?.length || 0
} tags`
);
}
} catch (error) {
logger.error(` Last.fm lookup failed:`, error);
}
}
// Step 3: Get cover art from multiple sources (Deezer → MusicBrainz → Fanart)
try {
const coverResult = await imageProviderService.getAlbumCover(
album.artist.name,
album.title,
enrichmentData.rgMbid
);
if (coverResult) {
enrichmentData.coverUrl = coverResult.url;
enrichmentData.confidence += 0.2;
logger.debug(` Found cover art from ${coverResult.source}`);
}
} catch (error) {
logger.error(
` ✗ Cover art lookup failed:`,
error instanceof Error ? error.message : error
);
}
logger.debug(
` Enrichment confidence: ${(
enrichmentData.confidence * 100
).toFixed(0)}%`
);
return enrichmentData;
}
/**
* Apply enrichment data to an artist in the database
*/
async applyArtistEnrichment(
artistId: string,
data: ArtistEnrichmentData
): Promise<void> {
const updateData: any = {};
// Check if MBID is already in use by another artist
if (data.mbid) {
const existingArtist = await prisma.artist.findUnique({
where: { mbid: data.mbid },
select: { id: true, name: true },
});
if (existingArtist && existingArtist.id !== artistId) {
logger.debug(
`MBID ${data.mbid} already used by "${existingArtist.name}", skipping MBID update`
);
} else {
updateData.mbid = data.mbid;
}
}
if (data.bio) updateData.summary = data.bio;
if (data.heroUrl) updateData.heroUrl = data.heroUrl;
if (data.genres && data.genres.length > 0) {
updateData.genres = data.genres;
}
if (Object.keys(updateData).length > 0) {
await prisma.artist.update({
where: { id: artistId },
data: updateData,
});
logger.debug(
` Saved ${data.genres?.length || 0} genres for artist`
);
}
}
/**
* Apply enrichment data to an album in the database
*/
async applyAlbumEnrichment(
albumId: string,
data: AlbumEnrichmentData
): Promise<void> {
const updateData: any = {};
if (data.rgMbid) updateData.rgMbid = data.rgMbid;
if (data.coverUrl) updateData.coverUrl = data.coverUrl;
if (data.releaseDate) {
// Store original release date in dedicated field
updateData.originalYear = data.releaseDate.getFullYear();
// Also update year for backward compatibility (but originalYear takes precedence)
updateData.year = data.releaseDate.getFullYear();
}
if (data.label) updateData.label = data.label;
if (data.genres && data.genres.length > 0) {
updateData.genres = data.genres;
}
if (Object.keys(updateData).length > 0) {
await prisma.album.update({
where: { id: albumId },
data: updateData,
});
logger.debug(
` Saved album data: ${
data.genres?.length || 0
} genres, label: ${data.label || "none"}`
);
}
// Update OwnedAlbum table if MBID changed
if (data.rgMbid) {
const album = await prisma.album.findUnique({
where: { id: albumId },
select: { artistId: true },
});
if (album) {
await prisma.ownedAlbum.upsert({
where: {
artistId_rgMbid: {
artistId: album.artistId,
rgMbid: data.rgMbid,
},
},
create: {
artistId: album.artistId,
rgMbid: data.rgMbid,
source: "enrichment",
},
update: {},
});
}
}
}
/**
* Enrich entire library for a user
*/
async enrichLibrary(
userId: string,
onProgress?: (progress: {
current: number;
total: number;
item: string;
}) => void
): Promise<EnrichmentResult> {
const settings = await this.getSettings(userId);
if (!settings.enabled) {
throw new Error("Enrichment is not enabled for this user");
}
const result: EnrichmentResult = {
success: true,
itemsProcessed: 0,
itemsEnriched: 0,
itemsFailed: 0,
errors: [],
};
// Get all artists with their albums
const artists = await prisma.artist.findMany({
where: {
albums: {
some: {}, // Only artists with albums
},
},
select: {
id: true,
name: true,
albums: {
select: { id: true, title: true },
},
},
});
logger.debug(`Starting enrichment for ${artists.length} artists...`);
for (const artist of artists) {
try {
result.itemsProcessed++;
onProgress?.({
current: result.itemsProcessed,
total:
artists.length +
artists.reduce((sum, a) => sum + a.albums.length, 0),
item: `${artist.name}`,
});
// Enrich artist
const artistEnrichmentData = await this.enrichArtist(
artist.id,
settings
);
if (
artistEnrichmentData &&
artistEnrichmentData.confidence > 0.3
) {
await this.applyArtistEnrichment(
artist.id,
artistEnrichmentData
);
result.itemsEnriched++;
}
// Enrich all albums for this artist
for (const album of artist.albums) {
try {
result.itemsProcessed++;
onProgress?.({
current: result.itemsProcessed,
total:
artists.length +
artists.reduce(
(sum, a) => sum + a.albums.length,
0
),
item: `${artist.name} - ${album.title}`,
});
const albumEnrichmentData = await this.enrichAlbum(
album.id,
settings
);
if (
albumEnrichmentData &&
albumEnrichmentData.confidence > 0.3
) {
await this.applyAlbumEnrichment(
album.id,
albumEnrichmentData
);
result.itemsEnriched++;
}
// Rate limiting between albums
await new Promise((resolve) =>
setTimeout(resolve, 500)
);
} catch (error: any) {
result.itemsFailed++;
result.errors.push({
item: `${artist.name} - ${album.title}`,
error: error.message,
});
logger.error(
` ✗ Failed to enrich ${artist.name} - ${album.title}:`,
error
);
}
}
// Rate limiting between artists
await new Promise((resolve) => setTimeout(resolve, 1000));
} catch (error: any) {
result.itemsFailed++;
result.errors.push({
item: artist.name,
error: error.message,
});
logger.error(` Failed to enrich ${artist.name}:`, error);
}
}
logger.debug(
`Enrichment complete: ${result.itemsEnriched}/${result.itemsProcessed} items enriched`
);
return result;
}
}
export const enrichmentService = new EnrichmentService();