bfe22745bc
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>
195 lines
7.0 KiB
TypeScript
195 lines
7.0 KiB
TypeScript
// 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 [];
|
||
}
|
||
}
|
||
}
|