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
+12 -4
View File
@@ -18,6 +18,13 @@ export interface ScanContext {
sourceType?: 'MANUAL' | 'RECOMMENDATION';
probationStatus?: 'probation' | 'retained' | 'retired';
candidateId?: string;
/**
* Names to use when the file carries no title/artist tags. An acquired
* download often has none, and the filename ("VTKqlmCpTmQ.mp3") plus
* "Unknown Artist" is worse than the vetted candidate's own names.
*/
fallbackTitle?: string;
fallbackArtist?: string;
}
export interface ScanResult {
@@ -31,7 +38,7 @@ export interface ScanResult {
* in the title ("Song (feat. X)") are folded in either way. First artist is the
* main artist; the rest are featured. See ./utils/artist-names for the rules.
*/
function parseArtistsFromMetadata(common: any): { main: string; featured: string[] } {
function parseArtistsFromMetadata(common: any, fallbackArtist = 'Unknown Artist'): { main: string; featured: string[] } {
const rawTitle = common.title || '';
// Structured array present: still split each entry (tags sometimes put a whole
@@ -52,7 +59,7 @@ function parseArtistsFromMetadata(common: any): { main: string; featured: string
}
}
return parseArtists(common.artist || 'Unknown Artist', rawTitle);
return parseArtists(common.artist || fallbackArtist, rawTitle);
}
function hashFile(filePath: string): Promise<string> {
@@ -136,8 +143,9 @@ export class ScannerService {
const { common, format } = metadata;
// 1. Ensure Artist(s) exist.
const trackTitle = common.title || path.basename(filePath);
const { main: mainArtistRaw, featured: featuredArtistNames } = parseArtistsFromMetadata(common);
const trackTitle = common.title || context.fallbackTitle || path.basename(filePath);
const { main: mainArtistRaw, featured: featuredArtistNames } =
parseArtistsFromMetadata(common, context.fallbackArtist || 'Unknown Artist');
const { id: artistId, name: resolvedArtist } = await this.resolveOrCreateArtist(mainArtistRaw);