Improve deterministic YouTube matching and playlist handling
This commit is contained in:
@@ -56,6 +56,27 @@ This project follows a practical chronological changelog rather than autogenerat
|
|||||||
- Added safer default pacing recommendations
|
- Added safer default pacing recommendations
|
||||||
- Improved deployment documentation
|
- Improved deployment documentation
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Spotify OAuth login flow
|
||||||
|
- Persistent Spotify user token handling
|
||||||
|
- Large playlist pagination support (>100 tracks)
|
||||||
|
- Deterministic YouTube candidate scoring
|
||||||
|
- Duration-aware YouTube matching
|
||||||
|
- Detailed YouTube candidate debug logging
|
||||||
|
- Improved Docker deployment guidance
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- YouTube pacing and throttling resistance
|
||||||
|
- Cover art embedding resilience
|
||||||
|
- Queue stability during large playlist imports
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Spotify playlist truncation at 100 tracks
|
||||||
|
- Incorrect live/acoustic YouTube selections
|
||||||
|
- Playlist retrieval failures on large public playlists
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 2.4.2
|
# 2.4.2
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ export class SpotifyApiService {
|
|||||||
|
|
||||||
async getTrackMetadata(
|
async getTrackMetadata(
|
||||||
spotifyUrl: string,
|
spotifyUrl: string,
|
||||||
): Promise<{ name: string; artist: string; image: string }> {
|
): Promise<{ name: string; artist: string; image: string; durationMs?: number }> {
|
||||||
try {
|
try {
|
||||||
this.logger.debug(`Getting track metadata for ${spotifyUrl}`);
|
this.logger.debug(`Getting track metadata for ${spotifyUrl}`);
|
||||||
const trackId = this.getTrackId(spotifyUrl);
|
const trackId = this.getTrackId(spotifyUrl);
|
||||||
@@ -253,6 +253,7 @@ export class SpotifyApiService {
|
|||||||
name: data.name,
|
name: data.name,
|
||||||
artist: data.artists.map((a) => a.name).join(', '),
|
artist: data.artists.map((a) => a.name).join(', '),
|
||||||
image: data.album.images[0]?.url || '',
|
image: data.album.images[0]?.url || '',
|
||||||
|
durationMs: data.duration_ms,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to get track metadata: ${error.message}`);
|
this.logger.error(`Failed to get track metadata: ${error.message}`);
|
||||||
@@ -370,6 +371,7 @@ export class SpotifyApiService {
|
|||||||
artists: any[];
|
artists: any[];
|
||||||
preview_url: any;
|
preview_url: any;
|
||||||
album: { images: any[] };
|
album: { images: any[] };
|
||||||
|
duration_ms?: number;
|
||||||
};
|
};
|
||||||
}) => {
|
}) => {
|
||||||
if (!item.item) return null;
|
if (!item.item) return null;
|
||||||
@@ -380,6 +382,7 @@ export class SpotifyApiService {
|
|||||||
artist: item.item.artists.map((a) => a.name).join(', '),
|
artist: item.item.artists.map((a) => a.name).join(', '),
|
||||||
previewUrl: item.item.preview_url,
|
previewUrl: item.item.preview_url,
|
||||||
coverUrl: item.item.album?.images?.[0]?.url || null,
|
coverUrl: item.item.album?.images?.[0]?.url || null,
|
||||||
|
durationMs: item.item.duration_ms,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ const ALLOWED_YOUTUBE_HOSTS = new Set([
|
|||||||
|
|
||||||
const BAD_MATCH_TERMS = [
|
const BAD_MATCH_TERMS = [
|
||||||
'live',
|
'live',
|
||||||
|
'ao vivo',
|
||||||
|
'acústico',
|
||||||
|
'acustico',
|
||||||
|
'acoustic',
|
||||||
|
'concert',
|
||||||
|
'festival',
|
||||||
|
'session',
|
||||||
'cover',
|
'cover',
|
||||||
'karaoke',
|
'karaoke',
|
||||||
'remix',
|
'remix',
|
||||||
@@ -34,6 +41,16 @@ const BAD_MATCH_TERMS = [
|
|||||||
'instrumental',
|
'instrumental',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const OFFICIALISH_TERMS = [
|
||||||
|
'official audio',
|
||||||
|
'official video',
|
||||||
|
'official music video',
|
||||||
|
'lyric video',
|
||||||
|
'lyrics',
|
||||||
|
'vevo',
|
||||||
|
'- topic',
|
||||||
|
];
|
||||||
|
|
||||||
export interface YoutubeMatch {
|
export interface YoutubeMatch {
|
||||||
url: string;
|
url: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -42,22 +59,30 @@ export interface YoutubeMatch {
|
|||||||
reason: string;
|
reason: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CandidateScore extends YoutubeMatch {
|
||||||
|
rejected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
@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 findBestYoutubeMatch(artist: string, name: string): Promise<YoutubeMatch> {
|
async findBestYoutubeMatch(
|
||||||
|
artist: string,
|
||||||
|
name: string,
|
||||||
|
durationMs?: number,
|
||||||
|
): 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 candidates = (result.videos || [])
|
const candidates = (result.videos || [])
|
||||||
.filter((video: any) => !!video?.url && !!video?.title)
|
.filter((video: any) => !!video?.url && !!video?.title)
|
||||||
.slice(0, 10)
|
.slice(0, 15)
|
||||||
.map((video: any) => this.scoreCandidate(video, artist, name))
|
.map((video: any) => this.scoreCandidate(video, artist, name, durationMs))
|
||||||
.filter((match: YoutubeMatch) => {
|
.filter((match: CandidateScore) => {
|
||||||
try {
|
try {
|
||||||
this.assertValidYoutubeUrl(match.url);
|
this.assertValidYoutubeUrl(match.url);
|
||||||
return true;
|
return true;
|
||||||
@@ -65,72 +90,160 @@ export class YoutubeService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.sort((a: YoutubeMatch, b: YoutubeMatch) => {
|
.sort((a, b) => b.score - a.score);
|
||||||
if (b.score !== a.score) {
|
|
||||||
return b.score - a.score;
|
|
||||||
}
|
|
||||||
return a.url.localeCompare(b.url);
|
|
||||||
});
|
|
||||||
|
|
||||||
const best = candidates[0];
|
this.logger.debug(
|
||||||
|
`YouTube candidates for "${query}": ` +
|
||||||
|
candidates
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(
|
||||||
|
(c) =>
|
||||||
|
`[${c.score}${c.rejected ? ' rejected' : ''}] ${c.title} by ${c.author} (${c.reason})`,
|
||||||
|
)
|
||||||
|
.join(' | '),
|
||||||
|
);
|
||||||
|
|
||||||
if (!best) {
|
const accepted = candidates.find((candidate) => !candidate.rejected);
|
||||||
throw new Error(`No YouTube result found for: ${query}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (best.score < 40) {
|
if (!accepted) {
|
||||||
throw new Error(
|
throw new Error(`No acceptable YouTube result found for: ${query}`);
|
||||||
`No confident YouTube match for "${query}". Best score: ${best.score} (${best.title})`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Selected YouTube match for "${query}": ${best.title} by ${best.author} (${best.score})`,
|
`Selected YouTube match for "${query}": ${accepted.title} by ${accepted.author} (${accepted.score})`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return best;
|
return accepted;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOnYoutubeOne(artist: string, name: string): Promise<string> {
|
private scoreCandidate(
|
||||||
return (await this.findBestYoutubeMatch(artist, name)).url;
|
video: any,
|
||||||
}
|
artist: string,
|
||||||
|
name: string,
|
||||||
private scoreCandidate(video: any, artist: string, name: string): YoutubeMatch {
|
durationMs?: number,
|
||||||
|
): CandidateScore {
|
||||||
const title = String(video.title || '');
|
const title = String(video.title || '');
|
||||||
const author = String(video.author?.name || video.author || '');
|
const author = String(video.author?.name || video.author || '');
|
||||||
const haystack = normalize(`${title} ${author}`);
|
const url = String(video.url || '');
|
||||||
const titleScore = tokenOverlap(name, title) * 55;
|
const seconds = this.getVideoSeconds(video);
|
||||||
const artistScore = tokenOverlap(artist, `${title} ${author}`) * 35;
|
|
||||||
|
|
||||||
let score = titleScore + artistScore;
|
const normalizedTitle = this.normalize(title);
|
||||||
const reasons = [
|
const normalizedAuthor = this.normalize(author);
|
||||||
`title=${Math.round(titleScore)}`,
|
const normalizedArtist = this.normalize(artist);
|
||||||
`artist=${Math.round(artistScore)}`,
|
const normalizedName = this.normalize(name);
|
||||||
];
|
|
||||||
|
|
||||||
for (const term of BAD_MATCH_TERMS) {
|
let score = 0;
|
||||||
if (haystack.includes(term)) {
|
const reasons: string[] = [];
|
||||||
score -= 15;
|
let rejected = false;
|
||||||
reasons.push(`penalty:${term}`);
|
|
||||||
|
if (normalizedTitle.includes(normalizedName)) {
|
||||||
|
score += 45;
|
||||||
|
reasons.push('title=45');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const artistPart of this.artistParts(normalizedArtist)) {
|
||||||
|
if (artistPart.length >= 3 && (normalizedTitle.includes(artistPart) || normalizedAuthor.includes(artistPart))) {
|
||||||
|
score += 20;
|
||||||
|
reasons.push(`artist=${artistPart}:20`);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (haystack.includes('official audio') || haystack.includes('topic')) {
|
const officialish = OFFICIALISH_TERMS.find((term) =>
|
||||||
score += 10;
|
`${normalizedTitle} ${normalizedAuthor}`.includes(this.normalize(term)),
|
||||||
reasons.push('bonus:officialish');
|
);
|
||||||
|
if (officialish) {
|
||||||
|
score += 12;
|
||||||
|
reasons.push(`officialish=${officialish}:12`);
|
||||||
}
|
}
|
||||||
|
|
||||||
score = Math.max(0, Math.min(100, Math.round(score)));
|
const badTerm = BAD_MATCH_TERMS.find((term) =>
|
||||||
|
normalizedTitle.includes(this.normalize(term)),
|
||||||
|
);
|
||||||
|
if (badTerm) {
|
||||||
|
score -= 25;
|
||||||
|
reasons.push(`bad=${badTerm}:-25`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (durationMs && seconds) {
|
||||||
|
const spotifySeconds = Math.round(durationMs / 1000);
|
||||||
|
const diff = Math.abs(seconds - spotifySeconds);
|
||||||
|
const ratio = diff / Math.max(spotifySeconds, 1);
|
||||||
|
|
||||||
|
if (diff <= 8 || ratio <= 0.05) {
|
||||||
|
score += 40;
|
||||||
|
reasons.push(`duration=excellent:${diff}s:+40`);
|
||||||
|
} else if (diff <= 15 || ratio <= 0.10) {
|
||||||
|
score += 30;
|
||||||
|
reasons.push(`duration=good:${diff}s:+30`);
|
||||||
|
} else if (diff <= 30 || ratio <= 0.15) {
|
||||||
|
score += 18;
|
||||||
|
reasons.push(`duration=ok:${diff}s:+18`);
|
||||||
|
} else if (diff <= 45 || ratio <= 0.25) {
|
||||||
|
score += 5;
|
||||||
|
reasons.push(`duration=weak:${diff}s:+5`);
|
||||||
|
} else if (diff > 60 && ratio > 0.40) {
|
||||||
|
score -= 80;
|
||||||
|
rejected = true;
|
||||||
|
reasons.push(`duration=reject:${diff}s:-80`);
|
||||||
|
} else {
|
||||||
|
score -= 35;
|
||||||
|
reasons.push(`duration=bad:${diff}s:-35`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
reasons.push('duration=unknown');
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url: String(video.url),
|
url,
|
||||||
title,
|
title,
|
||||||
author,
|
author,
|
||||||
score,
|
score,
|
||||||
reason: reasons.join(', '),
|
reason: reasons.join(', '),
|
||||||
|
rejected,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalize(value: string): string {
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/&/g, ' and ')
|
||||||
|
.replace(/[^a-z0-9]+/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private artistParts(normalizedArtist: string): string[] {
|
||||||
|
return normalizedArtist
|
||||||
|
.split(/\s+(and|feat|featuring|ft|x|,|with)\s+|,/)
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter((part) => part && !['and', 'feat', 'featuring', 'ft', 'x', 'with'].includes(part));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getVideoSeconds(video: any): number | undefined {
|
||||||
|
if (typeof video.seconds === 'number' && Number.isFinite(video.seconds)) {
|
||||||
|
return video.seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = String(video.timestamp || video.duration || '');
|
||||||
|
const parts = timestamp
|
||||||
|
.split(':')
|
||||||
|
.map((part) => Number(part))
|
||||||
|
.filter((part) => Number.isFinite(part));
|
||||||
|
|
||||||
|
if (parts.length === 2) {
|
||||||
|
return parts[0] * 60 + parts[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 3) {
|
||||||
|
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
assertValidYoutubeUrl(url: string): void {
|
assertValidYoutubeUrl(url: string): void {
|
||||||
let parsed: URL;
|
let parsed: URL;
|
||||||
|
|
||||||
@@ -232,38 +345,3 @@ 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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ export class TrackEntity {
|
|||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
coverUrl?: string; // Track-specific album art (overrides playlist coverUrl)
|
coverUrl?: string; // Track-specific album art (overrides playlist coverUrl)
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
durationMs?: number;
|
||||||
|
|
||||||
@Column({ default: Date.now() })
|
@Column({ default: Date.now() })
|
||||||
createdAt?: number;
|
createdAt?: number;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user