Add deterministic YouTube source scoring
This commit is contained in:
@@ -21,26 +21,114 @@ const ALLOWED_YOUTUBE_HOSTS = new Set([
|
||||
'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()
|
||||
export class YoutubeService {
|
||||
private readonly logger = new Logger(TrackService.name);
|
||||
|
||||
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}`;
|
||||
this.logger.debug(`Searching ${query} on YT`);
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
this.assertValidYoutubeUrl(firstVideo.url);
|
||||
this.logger.debug(`Found ${query} on ${firstVideo.url}`);
|
||||
return firstVideo.url;
|
||||
if (best.score < 40) {
|
||||
throw new Error(
|
||||
`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 {
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -102,11 +102,18 @@ export class TrackService {
|
||||
});
|
||||
let updatedTrack: TrackEntity;
|
||||
try {
|
||||
const youtubeUrl = await this.youtubeService.findOnYoutubeOne(
|
||||
const youtubeMatch = await this.youtubeService.findBestYoutubeMatch(
|
||||
track.artist,
|
||||
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) {
|
||||
this.logger.error(err);
|
||||
updatedTrack = {
|
||||
|
||||
Reference in New Issue
Block a user