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:
kami
2026-08-08 18:43:58 +04:00
parent d371bd97f3
commit bfe22745bc
28 changed files with 1241 additions and 221 deletions
+75
View File
@@ -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 [];
}
}
}