Add deterministic YouTube source scoring
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user