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>
This commit is contained in:
@@ -22,6 +22,16 @@ export interface DeezerAlbum {
|
||||
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;
|
||||
@@ -116,4 +126,69 @@ export class DeezerClient {
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user