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

423 lines
12 KiB
TypeScript

/**
* Image Provider Service
*
* Tries multiple sources for high-quality artist/album artwork:
* 1. Deezer (most reliable, high quality)
* 2. Fanart.tv (excellent quality, requires API key)
* 3. MusicBrainz Cover Art Archive (good quality)
* 4. Last.fm (fallback, often missing)
*/
import { logger } from "../utils/logger";
import axios from "axios";
export interface ImageSearchOptions {
preferredSize?: "small" | "medium" | "large" | "extralarge" | "mega";
timeout?: number;
}
export interface ImageResult {
url: string;
source: "deezer" | "fanart" | "musicbrainz" | "lastfm" | "spotify";
size?: string;
}
export class ImageProviderService {
private readonly FANART_API_KEY = process.env.FANART_API_KEY;
private readonly DEEZER_API_URL = "https://api.deezer.com";
private readonly FANART_API_URL = "https://webservice.fanart.tv/v3";
/**
* Get artist image from multiple sources with fallback chain
*/
async getArtistImage(
artistName: string,
mbid?: string,
options: ImageSearchOptions = {}
): Promise<ImageResult | null> {
const { timeout = 5000 } = options;
logger.debug(`[IMAGE] Searching for artist image: ${artistName}`);
// Try Deezer first (most reliable)
try {
const deezerImage = await this.getArtistImageFromDeezer(
artistName,
timeout
);
if (deezerImage) {
logger.debug(` Found image from Deezer`);
return deezerImage;
}
} catch (error) {
logger.debug(
` Deezer failed: ${
error instanceof Error ? error.message : "Unknown error"
}`
);
}
// Try Fanart.tv if we have API key and MBID
if (this.FANART_API_KEY && mbid) {
try {
const fanartImage = await this.getArtistImageFromFanart(
mbid,
timeout
);
if (fanartImage) {
logger.debug(` Found image from Fanart.tv`);
return fanartImage;
}
} catch (error) {
logger.debug(
`Fanart.tv failed: ${
error instanceof Error ? error.message : "Unknown error"
}`
);
}
}
// Try MusicBrainz/Cover Art Archive if we have MBID
if (mbid) {
try {
const mbImage = await this.getArtistImageFromMusicBrainz(
mbid,
timeout
);
if (mbImage) {
logger.debug(` Found image from MusicBrainz`);
return mbImage;
}
} catch (error) {
logger.debug(
`MusicBrainz failed: ${
error instanceof Error ? error.message : "Unknown error"
}`
);
}
}
logger.debug(` No artist image found from any source`);
return null;
}
/**
* Get album cover from multiple sources with fallback chain
*/
async getAlbumCover(
artistName: string,
albumTitle: string,
rgMbid?: string,
options: ImageSearchOptions = {}
): Promise<ImageResult | null> {
const { timeout = 5000 } = options;
logger.debug(
`[IMAGE] Searching for album cover: ${artistName} - ${albumTitle}`
);
// Try Deezer first (most reliable)
try {
const deezerCover = await this.getAlbumCoverFromDeezer(
artistName,
albumTitle,
timeout
);
if (deezerCover) {
logger.debug(` Found cover from Deezer`);
return deezerCover;
}
} catch (error) {
logger.debug(
` Deezer failed: ${
error instanceof Error ? error.message : "Unknown error"
}`
);
}
// Try MusicBrainz Cover Art Archive if we have MBID
if (rgMbid) {
try {
const mbCover = await this.getAlbumCoverFromMusicBrainz(
rgMbid,
timeout
);
if (mbCover) {
logger.debug(` Found cover from MusicBrainz`);
return mbCover;
}
} catch (error) {
logger.debug(
`MusicBrainz failed: ${
error instanceof Error ? error.message : "Unknown error"
}`
);
}
}
// Try Fanart.tv if we have API key and MBID
if (this.FANART_API_KEY && rgMbid) {
try {
const fanartCover = await this.getAlbumCoverFromFanart(
rgMbid,
timeout
);
if (fanartCover) {
logger.debug(` Found cover from Fanart.tv`);
return fanartCover;
}
} catch (error) {
logger.debug(
`Fanart.tv failed: ${
error instanceof Error ? error.message : "Unknown error"
}`
);
}
}
logger.debug(` No album cover found from any source`);
return null;
}
/**
* Search Deezer for artist image
*/
private async getArtistImageFromDeezer(
artistName: string,
timeout: number
): Promise<ImageResult | null> {
const response = await axios.get(
`${this.DEEZER_API_URL}/search/artist`,
{
params: { q: artistName, limit: 1 },
timeout,
}
);
if (response.data.data && response.data.data.length > 0) {
const artist = response.data.data[0];
// Deezer provides: picture, picture_small, picture_medium, picture_big, picture_xl
const imageUrl =
artist.picture_xl || artist.picture_big || artist.picture;
if (imageUrl) {
return {
url: imageUrl,
source: "deezer",
size: "xl",
};
}
}
return null;
}
/**
* Search Deezer for album cover
*/
private async getAlbumCoverFromDeezer(
artistName: string,
albumTitle: string,
timeout: number
): Promise<ImageResult | null> {
const response = await axios.get(
`${this.DEEZER_API_URL}/search/album`,
{
params: {
q: `artist:"${artistName}" album:"${albumTitle}"`,
limit: 5,
},
timeout,
}
);
if (response.data.data && response.data.data.length > 0) {
// Try to find exact match first
let album = response.data.data.find(
(a: any) =>
a.title.toLowerCase() === albumTitle.toLowerCase() &&
a.artist.name.toLowerCase() === artistName.toLowerCase()
);
// Fall back to first result
if (!album) {
album = response.data.data[0];
}
// Deezer provides: cover, cover_small, cover_medium, cover_big, cover_xl
const coverUrl = album.cover_xl || album.cover_big || album.cover;
if (coverUrl) {
return {
url: coverUrl,
source: "deezer",
size: "xl",
};
}
}
return null;
}
/**
* Get artist image from Fanart.tv
*/
private async getArtistImageFromFanart(
mbid: string,
timeout: number
): Promise<ImageResult | null> {
if (!this.FANART_API_KEY) {
return null;
}
const response = await axios.get(
`${this.FANART_API_URL}/music/${mbid}`,
{
params: { api_key: this.FANART_API_KEY },
timeout,
}
);
// Fanart.tv provides multiple image types, prefer artistthumb
const images =
response.data.artistthumb ||
response.data.musicbanner ||
response.data.hdmusiclogo;
if (images && images.length > 0) {
return {
url: images[0].url,
source: "fanart",
};
}
return null;
}
/**
* Get album cover from Fanart.tv
*/
private async getAlbumCoverFromFanart(
rgMbid: string,
timeout: number
): Promise<ImageResult | null> {
if (!this.FANART_API_KEY) {
return null;
}
const response = await axios.get(
`${this.FANART_API_URL}/music/albums/${rgMbid}`,
{
params: { api_key: this.FANART_API_KEY },
timeout,
}
);
// Prefer albumcover, fall back to cdart
const covers =
response.data.albums?.[rgMbid]?.albumcover ||
response.data.albums?.[rgMbid]?.cdart;
if (covers && covers.length > 0) {
return {
url: covers[0].url,
source: "fanart",
};
}
return null;
}
/**
* Get artist image from MusicBrainz (via relationships)
*/
private async getArtistImageFromMusicBrainz(
mbid: string,
timeout: number
): Promise<ImageResult | null> {
// MusicBrainz doesn't have direct artist images, but we can check for image relationships
// This is a placeholder - in practice, we'd need to parse relationships
return null;
}
/**
* Get album cover from MusicBrainz Cover Art Archive
*/
private async getAlbumCoverFromMusicBrainz(
rgMbid: string,
timeout: number
): Promise<ImageResult | null> {
try {
const response = await axios.get(
`https://coverartarchive.org/release-group/${rgMbid}`,
{
timeout,
validateStatus: (status) => status === 200,
}
);
if (response.data.images && response.data.images.length > 0) {
// Find front cover
const frontCover =
response.data.images.find(
(img: any) => img.front === true
) || response.data.images[0];
return {
url: frontCover.image,
source: "musicbrainz",
};
}
} catch (error) {
// 404 is expected if no cover art exists
if (axios.isAxiosError(error) && error.response?.status === 404) {
return null;
}
throw error;
}
return null;
}
/**
* Get artist image from Last.fm (fallback only - often unreliable)
*/
async getArtistImageFromLastFm(
artistName: string,
mbid?: string
): Promise<ImageResult | null> {
try {
const { lastFmService } = await import("./lastfm");
const artistInfo = await lastFmService.getArtistInfo(
artistName,
mbid
);
if (artistInfo?.image) {
const megaImage = artistInfo.image.find(
(img: any) => img.size === "mega"
);
const largeImage = artistInfo.image.find(
(img: any) => img.size === "extralarge"
);
const image = megaImage || largeImage;
if (image?.["#text"]) {
return {
url: image["#text"],
source: "lastfm",
size: image.size,
};
}
}
} catch (error) {
logger.debug(
`Last.fm failed: ${
error instanceof Error ? error.message : "Unknown error"
}`
);
}
return null;
}
}
export const imageProviderService = new ImageProviderService();