Add deterministic YouTube source scoring

This commit is contained in:
Tor Wingalen
2026-05-14 18:00:53 +02:00
parent d1b040a8b1
commit 4bb61f5ebe
2 changed files with 138 additions and 8 deletions
+129 -6
View File
@@ -21,26 +21,114 @@ const ALLOWED_YOUTUBE_HOSTS = new Set([
'youtu.be', 'youtu.be',
]); ]);
const BAD_MATCH_TERMS = [
'live',
'cover',
'karaoke',
'remix',
'sped up',
'slowed',
'nightcore',
'reaction',
'tutorial',
'instrumental',
];
export interface YoutubeMatch {
url: string;
title: string;
author: string;
score: number;
reason: string;
}
@Injectable() @Injectable()
export class YoutubeService { export class YoutubeService {
private readonly logger = new Logger(TrackService.name); private readonly logger = new Logger(TrackService.name);
constructor(private readonly configService: ConfigService) {} constructor(private readonly configService: ConfigService) {}
async findOnYoutubeOne(artist: string, name: string): Promise<string> { async findBestYoutubeMatch(artist: string, name: string): Promise<YoutubeMatch> {
const query = `${artist} - ${name}`; const query = `${artist} - ${name}`;
this.logger.debug(`Searching ${query} on YT`); this.logger.debug(`Searching ${query} on YT`);
const result = await yts(query); const result = await yts(query);
const firstVideo = result.videos?.[0]; const candidates = (result.videos || [])
.filter((video: any) => !!video?.url && !!video?.title)
.slice(0, 10)
.map((video: any) => this.scoreCandidate(video, artist, name))
.filter((match: YoutubeMatch) => {
try {
this.assertValidYoutubeUrl(match.url);
return true;
} catch {
return false;
}
})
.sort((a: YoutubeMatch, b: YoutubeMatch) => {
if (b.score !== a.score) {
return b.score - a.score;
}
return a.url.localeCompare(b.url);
});
if (!firstVideo?.url) { const best = candidates[0];
if (!best) {
throw new Error(`No YouTube result found for: ${query}`); throw new Error(`No YouTube result found for: ${query}`);
} }
this.assertValidYoutubeUrl(firstVideo.url); if (best.score < 40) {
this.logger.debug(`Found ${query} on ${firstVideo.url}`); throw new Error(
return firstVideo.url; `No confident YouTube match for "${query}". Best score: ${best.score} (${best.title})`,
);
}
this.logger.debug(
`Selected YouTube match for "${query}": ${best.title} by ${best.author} (${best.score})`,
);
return best;
}
async findOnYoutubeOne(artist: string, name: string): Promise<string> {
return (await this.findBestYoutubeMatch(artist, name)).url;
}
private scoreCandidate(video: any, artist: string, name: string): YoutubeMatch {
const title = String(video.title || '');
const author = String(video.author?.name || video.author || '');
const haystack = normalize(`${title} ${author}`);
const titleScore = tokenOverlap(name, title) * 55;
const artistScore = tokenOverlap(artist, `${title} ${author}`) * 35;
let score = titleScore + artistScore;
const reasons = [
`title=${Math.round(titleScore)}`,
`artist=${Math.round(artistScore)}`,
];
for (const term of BAD_MATCH_TERMS) {
if (haystack.includes(term)) {
score -= 15;
reasons.push(`penalty:${term}`);
}
}
if (haystack.includes('official audio') || haystack.includes('topic')) {
score += 10;
reasons.push('bonus:officialish');
}
score = Math.max(0, Math.min(100, Math.round(score)));
return {
url: String(video.url),
title,
author,
score,
reason: reasons.join(', '),
};
} }
assertValidYoutubeUrl(url: string): void { assertValidYoutubeUrl(url: string): void {
@@ -144,3 +232,38 @@ export class YoutubeService {
); );
} }
} }
function normalize(value: string): string {
return value
.toLowerCase()
.replace(/&/g, ' and ')
.replace(/[^a-z0-9åäöüéèàáíóúñ]+/gi, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function tokens(value: string): Set<string> {
return new Set(
normalize(value)
.split(' ')
.filter((token) => token.length > 1),
);
}
function tokenOverlap(needle: string, haystack: string): number {
const needleTokens = tokens(needle);
const haystackTokens = tokens(haystack);
if (needleTokens.size === 0) {
return 0;
}
let matches = 0;
for (const token of needleTokens) {
if (haystackTokens.has(token)) {
matches++;
}
}
return matches / needleTokens.size;
}
+9 -2
View File
@@ -102,11 +102,18 @@ export class TrackService {
}); });
let updatedTrack: TrackEntity; let updatedTrack: TrackEntity;
try { try {
const youtubeUrl = await this.youtubeService.findOnYoutubeOne( const youtubeMatch = await this.youtubeService.findBestYoutubeMatch(
track.artist, track.artist,
track.name, track.name,
); );
updatedTrack = { ...track, youtubeUrl, status: TrackStatusEnum.Queued }; updatedTrack = {
...track,
youtubeUrl: youtubeMatch.url,
status: TrackStatusEnum.Queued,
};
this.logger.debug(
`YouTube match for track ${track.id}: ${youtubeMatch.title} by ${youtubeMatch.author} score=${youtubeMatch.score} reason=${youtubeMatch.reason}`,
);
} catch (err) { } catch (err) {
this.logger.error(err); this.logger.error(err);
updatedTrack = { updatedTrack = {