Files
muzick/workers/src/integrations/deezer.client.ts
T
kami bfe22745bc feat(discovery): acquire recommendations that keep their names
Acquisition ran yt-dlp without --embed-metadata, so every download
arrived untagged. The scanner then stored the video id as the title and
"Unknown Artist" as the artist, the vetted-candidate tag check rejected
the mismatch, and all 18 acquired tracks were hidden and retired.

- Pass --embed-metadata so downloads carry real tags.
- Let a scan take fallback title/artist from the candidate, for sources
  that still ship untagged files.
- Install Deno alongside yt-dlp: YouTube guards some formats with a JS
  challenge yt-dlp must execute, and no other runtime is enabled.
- Dedupe candidates by artist and title. The (source, external_id) key
  misses the same song reaching us under two Deezer release ids.

Also carries the in-flight discovery work this builds on: the
Recommendations page replacing Discover, the discovery source service,
and the acquisition spec tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 18:43:58 +04:00

195 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Deezer API client (read-only).
//
// Deezer provides a free, no-auth search API with high-quality cover art
// (up to 1000×1000 via the `cover_xl` field). Coverage is very good for
// European and mainstream releases.
//
// Rate limit: Deezer doesn't document a hard limit for the public search API,
// but ~50 req/min is a safe polite default, enforced via the http base.
//
// Best-effort: on any error, methods log a warning and return null rather than
// throwing, so enrichment never breaks the worker.
import { buildUserAgent, requestJson } from './http.js';
/** A normalised Deezer album search result (only the fields we read). */
export interface DeezerAlbum {
artistName: string;
title: string;
/** 1000×1000 cover URL. */
coverXl: string;
/** 500×500 cover URL (fallback). */
coverBig: string;
}
/** An album in an artist's discography (only the fields we read). */
export interface DeezerAlbumRelease {
id: number;
title: string;
/** ISO date, YYYY-MM-DD. */
releaseDate: string;
/** 'album' | 'single' | 'ep' | 'compilation' as reported by Deezer. */
recordType: string;
}
interface DeezerSearchResult {
artist?: { name?: string };
title?: string;
cover_xl?: string;
cover_big?: string;
}
interface DeezerSearchResponse {
data?: DeezerSearchResult[];
}
/** Lowercase and strip diacritics, so a query for "Bjork" matches "Björk". */
const foldName = (name: string) =>
name.trim().toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
export class DeezerClient {
private readonly baseUrl = 'https://api.deezer.com';
private readonly userAgent: string;
private readonly minIntervalMs: number;
constructor(contact = '', minIntervalMs = 1200) {
this.userAgent = buildUserAgent(contact);
this.minIntervalMs = minIntervalMs;
}
/**
* Search the Deezer catalog for an album by artist + title and return the
* best match, or null if no match / error.
*
* Deezer's search `q` supports the fielded syntax `artist:"…" album:"…"`
* which gives precise matches.
*/
async searchAlbum(artist: string, album: string): Promise<DeezerAlbum | null> {
if (artist.trim() === '' || album.trim() === '') return null;
const q = `artist:"${artist}" album:"${album}"`;
const qs = new URLSearchParams({ q });
const url = `${this.baseUrl}/search/album?${qs.toString()}`;
try {
const data = await requestJson<DeezerSearchResponse>(url, {
userAgent: this.userAgent,
minIntervalMs: this.minIntervalMs,
});
const results = data.data ?? [];
if (results.length === 0) return null;
const best = results[0];
const coverXl = best.cover_xl ?? best.cover_big ?? null;
const coverBig = best.cover_big ?? best.cover_xl ?? null;
if (!coverXl) return null;
return {
artistName: best.artist?.name ?? artist,
title: best.title ?? album,
coverXl,
coverBig: coverBig ?? coverXl,
};
} catch (err) {
console.warn('[Deezer] searchAlbum failed:', (err as Error).message);
return null;
}
}
/**
* Artist photo (1000×1000 `picture_xl`) by name, or null.
*
* ponytail: exact case-insensitive name match only. Deezer's artist search is
* fuzzy and happily returns a tribute band for a near miss, so a wrong photo
* is worse than the placeholder. Loosen it only if real artists get skipped.
*/
async searchArtistImage(artist: string): Promise<string | null> {
if (artist.trim() === '') return null;
const qs = new URLSearchParams({ q: artist });
try {
const data = await requestJson<{ data?: { name?: string; picture_xl?: string; picture_big?: string }[] }>(
`${this.baseUrl}/search/artist?${qs.toString()}`,
{ userAgent: this.userAgent, minIntervalMs: this.minIntervalMs }
);
const want = foldName(artist);
for (const a of data.data ?? []) {
if (foldName(a.name ?? '') !== want) continue;
// Deezer serves a blank grey tile for artists it has no photo for: the
// path is either empty or the md5 of the empty string. Both are useless,
// and the same name can appear twice with only the second one real.
const url = a.picture_xl ?? a.picture_big ?? '';
if (url && !url.includes('d41d8cd98f00b204e9800998ecf8427e') && !url.includes('/artist//')) return url;
}
return null;
} catch (err) {
console.warn('[Deezer] searchArtistImage failed:', (err as Error).message);
return null;
}
}
/** Resolve an artist name to a Deezer artist id via exact fold-matched search. */
private async resolveArtistId(artist: string): Promise<number | null> {
if (artist.trim() === '') return null;
const qs = new URLSearchParams({ q: artist });
try {
const data = await requestJson<{ data?: { id?: number; name?: string }[] }>(
`${this.baseUrl}/search/artist?${qs.toString()}`,
{ userAgent: this.userAgent, minIntervalMs: this.minIntervalMs }
);
const want = foldName(artist);
for (const a of data.data ?? []) {
if (foldName(a.name ?? '') === want && typeof a.id === 'number') return a.id;
}
return null;
} catch (err) {
console.warn('[Deezer] resolveArtistId failed:', (err as Error).message);
return null;
}
}
/**
* Albums by `artist` released on/after `sinceIso` (YYYY-MM-DD), newest first.
*
* ponytail: reads only the first page (25 albums). Deezer returns albums
* newest-first, so a release-date cutoff never needs page two unless an
* artist dropped 25 albums inside the window. Paginate if that ever happens.
*/
async getArtistAlbumsSince(artist: string, sinceIso: string): Promise<DeezerAlbumRelease[]> {
const artistId = await this.resolveArtistId(artist);
if (artistId === null) return [];
try {
const data = await requestJson<{
data?: { id?: number; title?: string; release_date?: string; record_type?: string }[];
}>(`${this.baseUrl}/artist/${artistId}/albums?limit=25`, {
userAgent: this.userAgent,
minIntervalMs: this.minIntervalMs,
});
return (data.data ?? [])
.filter((a) => typeof a.id === 'number' && a.title && a.release_date && a.release_date >= sinceIso)
.map((a) => ({
id: a.id as number,
title: a.title as string,
releaseDate: a.release_date as string,
recordType: a.record_type ?? 'album',
}));
} catch (err) {
console.warn('[Deezer] getArtistAlbumsSince failed:', (err as Error).message);
return [];
}
}
/** Track titles on a Deezer album, in tracklist order. */
async getAlbumTracks(albumId: number): Promise<string[]> {
try {
const data = await requestJson<{ data?: { title?: string }[] }>(
`${this.baseUrl}/album/${albumId}/tracks?limit=50`,
{ userAgent: this.userAgent, minIntervalMs: this.minIntervalMs }
);
return (data.data ?? []).map((t) => t.title ?? '').filter((t) => t !== '');
} catch (err) {
console.warn('[Deezer] getAlbumTracks failed:', (err as Error).message);
return [];
}
}
}