diff --git a/src/backend/src/shared/youtube.service.ts b/src/backend/src/shared/youtube.service.ts index e62ee5a..80199ca 100644 --- a/src/backend/src/shared/youtube.service.ts +++ b/src/backend/src/shared/youtube.service.ts @@ -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 { + async findBestYoutubeMatch(artist: string, name: string): Promise { 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 { + 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 { + 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; +} diff --git a/src/backend/src/track/track.service.ts b/src/backend/src/track/track.service.ts index 8780989..26c9636 100644 --- a/src/backend/src/track/track.service.ts +++ b/src/backend/src/track/track.service.ts @@ -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 = {