From 0f9b1f9477bd46f4bcb7e54e57302646fb4574c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tor=20Matz=20Andr=C3=A9n?= Date: Thu, 14 May 2026 16:15:20 +0200 Subject: [PATCH] Harden YouTube search and download handling --- src/backend/src/shared/youtube.service.ts | 93 ++++++++++++++----- .../src/track/track-download.processor.ts | 12 ++- src/backend/src/track/track.service.ts | 11 ++- 3 files changed, 89 insertions(+), 27 deletions(-) diff --git a/src/backend/src/shared/youtube.service.ts b/src/backend/src/shared/youtube.service.ts index 27bfc30..e62ee5a 100644 --- a/src/backend/src/shared/youtube.service.ts +++ b/src/backend/src/shared/youtube.service.ts @@ -13,6 +13,14 @@ const HEADERS = { 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', }; +const ALLOWED_YOUTUBE_HOSTS = new Set([ + 'youtube.com', + 'www.youtube.com', + 'm.youtube.com', + 'music.youtube.com', + 'youtu.be', +]); + @Injectable() export class YoutubeService { private readonly logger = new Logger(TrackService.name); @@ -20,10 +28,37 @@ export class YoutubeService { constructor(private readonly configService: ConfigService) {} async findOnYoutubeOne(artist: string, name: string): Promise { - this.logger.debug(`Searching ${artist} - ${name} on YT`); - const url = (await yts(`${artist} - ${name}`)).videos[0].url; - this.logger.debug(`Found ${artist} - ${name} on ${url}`); - return url; + const query = `${artist} - ${name}`; + this.logger.debug(`Searching ${query} on YT`); + + const result = await yts(query); + const firstVideo = result.videos?.[0]; + + if (!firstVideo?.url) { + throw new Error(`No YouTube result found for: ${query}`); + } + + this.assertValidYoutubeUrl(firstVideo.url); + this.logger.debug(`Found ${query} on ${firstVideo.url}`); + return firstVideo.url; + } + + assertValidYoutubeUrl(url: string): void { + let parsed: URL; + + try { + parsed = new URL(url); + } catch { + throw new Error('Invalid YouTube URL'); + } + + if (!['http:', 'https:'].includes(parsed.protocol)) { + throw new Error('Rejected non-HTTP YouTube URL'); + } + + if (!ALLOWED_YOUTUBE_HOSTS.has(parsed.hostname.toLowerCase())) { + throw new Error(`Rejected non-YouTube URL: ${parsed.hostname}`); + } } private getCookiesOptions(): { @@ -51,10 +86,13 @@ export class YoutubeService { this.logger.debug( `Downloading ${track.artist} - ${track.name} (${track.youtubeUrl}) from YT`, ); + if (!track.youtubeUrl) { - this.logger.error('youtubeUrl is null or undefined'); - throw Error('youtubeUrl is null or undefined'); + throw new Error('youtubeUrl is null or undefined'); } + + this.assertValidYoutubeUrl(track.youtubeUrl); + const ytdlp = new YtDlp(); await ytdlp.downloadAudio( track.youtubeUrl, @@ -67,6 +105,7 @@ export class YoutubeService { audioQuality: this.configService.get('QUALITY'), }, ); + this.logger.debug( `Downloaded ${track.artist} - ${track.name} to ${output}`, ); @@ -78,24 +117,30 @@ export class YoutubeService { title: string, artist: string, ): Promise { - if (coverUrl) { - const res = await fetch(coverUrl); - const arrayBuf = await res.arrayBuffer(); - const imageBuffer = Buffer.from(arrayBuf); - - NodeID3.write( - { - title, - artist, - APIC: { - mime: 'image/jpeg', - type: { id: 3, name: 'front cover' }, - description: 'cover', - imageBuffer, - }, - }, - folderName, - ); + if (!coverUrl) { + return; } + + const res = await fetch(coverUrl); + if (!res.ok) { + throw new Error(`Failed to fetch cover art: ${res.status}`); + } + + const arrayBuf = await res.arrayBuffer(); + const imageBuffer = Buffer.from(arrayBuf); + + NodeID3.write( + { + title, + artist, + APIC: { + mime: 'image/jpeg', + type: { id: 3, name: 'front cover' }, + description: 'cover', + imageBuffer, + }, + }, + folderName, + ); } } diff --git a/src/backend/src/track/track-download.processor.ts b/src/backend/src/track/track-download.processor.ts index 8cdf7b1..de1b7d9 100644 --- a/src/backend/src/track/track-download.processor.ts +++ b/src/backend/src/track/track-download.processor.ts @@ -3,6 +3,16 @@ import { Job } from 'bullmq'; import { TrackService } from './track.service'; import { TrackEntity } from './track.entity'; +function getDownloadsPerMinute(): number { + const parsed = Number(process.env.YT_DOWNLOADS_PER_MINUTE || 3); + + if (!Number.isFinite(parsed) || parsed <= 0) { + return 3; + } + + return Math.max(1, Math.min(30, Math.floor(parsed))); +} + @Processor('track-download-processor') export class TrackDownloadProcessor extends WorkerHost { constructor(private readonly trackService: TrackService) { @@ -10,7 +20,7 @@ export class TrackDownloadProcessor extends WorkerHost { } async process(job: Job): Promise { - const maxPerMinute = Number(process.env.YT_DOWNLOADS_PER_MINUTE || 3); + const maxPerMinute = getDownloadsPerMinute(); const sleepMs = Math.floor(60000 / maxPerMinute); await new Promise((res) => setTimeout(res, sleepMs)); await this.trackService.downloadFromYoutube(job.data); diff --git a/src/backend/src/track/track.service.ts b/src/backend/src/track/track.service.ts index 8a9b57f..3365625 100644 --- a/src/backend/src/track/track.service.ts +++ b/src/backend/src/track/track.service.ts @@ -99,17 +99,24 @@ export class TrackService { error: String(err), status: TrackStatusEnum.Error, }; + await this.update(track.id, updatedTrack); + return; } + + await this.update(track.id, updatedTrack); await this.trackDownloadQueue.add('', updatedTrack, { jobId: `id-${updatedTrack.id}`, }); - await this.update(track.id, updatedTrack); } async downloadFromYoutube(track: TrackEntity): Promise { - if (!(await this.get(track.id))) { + const dbTrack = await this.get(track.id); + if (!dbTrack) { return; } + + track = dbTrack; + if (!track.name || !track.artist || !track.playlist) { this.logger.error( `Track or playlist field is null or undefined: name=${track.name}, artist=${track.artist}, playlist=${track.playlist ? 'ok' : 'null'}`,