From 0ac1785885a291153b6c84b7634929d839629ecd Mon Sep 17 00:00:00 2001 From: ToddyTheNoobDud Date: Sun, 12 Jul 2026 00:47:02 -0300 Subject: [PATCH 1/3] fix: youtube 403s errors --- src/playback/processing/streamProcessor.ts | 66 ++++- src/sources/youtube/YouTube.ts | 310 +++++++++++++++++---- src/sources/youtube/clients/TV.ts | 21 +- 3 files changed, 329 insertions(+), 68 deletions(-) diff --git a/src/playback/processing/streamProcessor.ts b/src/playback/processing/streamProcessor.ts index b0dec3bd..07385543 100644 --- a/src/playback/processing/streamProcessor.ts +++ b/src/playback/processing/streamProcessor.ts @@ -345,17 +345,33 @@ const _extractSeekProxy = ( : undefined } +const DEFAULT_USER_AGENT = + 'Mozilla/5.0 (SMART-TV; Linux; Tizen 8.0) Cobalt/Version (unlike Gecko) v8/11.4.233.17-gold Starboard/16, Tizen;Samsung;KantM_2024;8.0/2300.0 (Samsung, QN90D_98, Wired' + +const _extractSeekUserAgent = (streamInfo: StreamInfo): string | undefined => { + const additionalData = streamInfo?.additionalData as + | Record + | undefined + + return typeof additionalData?.userAgent === 'string' + ? additionalData.userAgent + : undefined +} + async function _fetchRange( url: string, start: number, endInclusive: number, - proxy?: HttpProxyConfig + proxy?: HttpProxyConfig, + userAgent?: string ): Promise { + const ua = userAgent || DEFAULT_USER_AGENT if (proxy) { const response = await http1makeRequest(url, { method: 'GET', headers: { - Range: `bytes=${start}-${endInclusive}` + Range: `bytes=${start}-${endInclusive}`, + 'User-Agent': ua } as HttpRequestHeaders, responseType: 'buffer', proxy @@ -377,7 +393,10 @@ async function _fetchRange( } const res = await fetch(url, { - headers: { Range: `bytes=${start}-${endInclusive}` } + headers: { + Range: `bytes=${start}-${endInclusive}`, + 'User-Agent': ua + } }) if (!res.ok) { throw new Error(`HTTP ${res.status} while fetching range`) @@ -389,13 +408,16 @@ async function _fetchRange( async function _openRangeStream( url: string, start: number, - proxy?: HttpProxyConfig + proxy?: HttpProxyConfig, + userAgent?: string ): Promise { + const ua = userAgent || DEFAULT_USER_AGENT if (proxy) { const response = await http1makeRequest(url, { method: 'GET', headers: { - Range: `bytes=${start}-` + Range: `bytes=${start}-`, + 'User-Agent': ua } as HttpRequestHeaders, streamOnly: true, proxy @@ -414,7 +436,10 @@ async function _openRangeStream( } const res = await fetch(url, { - headers: { Range: `bytes=${start}-` } + headers: { + Range: `bytes=${start}-`, + 'User-Agent': ua + } }) if (!res.ok) { throw new Error(`HTTP ${res.status} while opening range stream`) @@ -434,7 +459,8 @@ const _seekOffset = (res: MP4BoxSeekResult): number => { async function _buildMp4SeekOptions( url: string, seekTimeMs: number, - proxy?: HttpProxyConfig + proxy?: HttpProxyConfig, + userAgent?: string ): Promise { const mp4Box = await getMP4Box() const mp4 = mp4Box.createFile() as unknown as MP4BoxFile @@ -459,7 +485,8 @@ async function _buildMp4SeekOptions( url, nextStart, nextStart + CHUNK - 1, - proxy + proxy, + userAgent ) const ab = _toArrayBufferWithFileStart(buf, nextStart) @@ -524,7 +551,8 @@ type SeekableResponseLike = Readable & { } const _createSeekableProxyRequest = ( - proxy?: HttpProxyConfig + proxy?: HttpProxyConfig, + userAgent?: string ): | (( requestUrl: string | URL, @@ -543,11 +571,16 @@ const _createSeekableProxyRequest = ( headers?: Record } ): Promise => { + const headers = { + 'User-Agent': userAgent || DEFAULT_USER_AGENT, + ...(options?.headers ?? {}) + } as HttpRequestHeaders + const response = await http1makeRequest( typeof requestUrl === 'string' ? requestUrl : requestUrl.toString(), { method: options?.method ?? 'GET', - headers: (options?.headers ?? {}) as HttpRequestHeaders, + headers, streamOnly: true, proxy } @@ -3103,6 +3136,7 @@ export const createSeekeableAudioResource = async ( const ext = _extFromUrl(url) const containerGuess = hinted || ext const seekProxy = _extractSeekProxy(player.streamInfo) + const seekUserAgent = _extractSeekUserAgent(player.streamInfo) logger( 'debug', @@ -3111,12 +3145,18 @@ export const createSeekeableAudioResource = async ( ) if (_isMp4Format(containerGuess)) { - const mp4Seek = await _buildMp4SeekOptions(url, seekTime, seekProxy) + const mp4Seek = await _buildMp4SeekOptions( + url, + seekTime, + seekProxy, + seekUserAgent + ) const ranged = await _openRangeStream( url, mp4Seek.baseFileStart ?? 0, - seekProxy + seekProxy, + seekUserAgent ) const passthroughStream = new PassThrough({ @@ -3161,7 +3201,7 @@ export const createSeekeableAudioResource = async ( seekTime, endTime, {}, - _createSeekableProxyRequest(seekProxy) + _createSeekableProxyRequest(seekProxy, seekUserAgent) )) as { stream: Readable; meta: SeekableStreamMeta } const passthroughStream = new PassThrough({ diff --git a/src/sources/youtube/YouTube.ts b/src/sources/youtube/YouTube.ts index 1d543db8..4f41f34d 100644 --- a/src/sources/youtube/YouTube.ts +++ b/src/sources/youtube/YouTube.ts @@ -1232,55 +1232,153 @@ export default class YouTubeSource { } if (urlData.url) { - const check: HttpRequestResult = await http1makeRequest(urlData.url, { - method: 'GET', - headers: { Range: 'bytes=0-0' }, - streamOnly: true, - proxy: proxyToUse as unknown as HttpProxyConfig - }) + const clientUserAgent = + client.getClient(this.ytContext)?.client?.userAgent || '' - if (check.stream) - ( - check.stream as NodeJS.ReadableStream & { destroy: () => void } - ).destroy() + if (clientName === 'TV') { + logger( + 'debug', + 'YouTube', + `Skipping direct URL pre-flight validation check for client TV.` + ) + this.reportProxyStatus(proxyToUse, true, 200, proxyLatency) + + const selectedFormat = urlData.formats?.find( + (f) => f.itag === urlData.itag + ) + const contentLength = selectedFormat?.contentLength + ? Number.parseInt(selectedFormat.contentLength as string, 10) + : null + + const result: TrackUrlData = { + ...urlData, + additionalData: { + contentLength, + proxy: proxyToUse, + itag: urlData.itag, + formats: urlData.formats, + userAgent: clientUserAgent + } + } + this.nodelink.trackCacheManager?.set( + 'youtube', + decodedTrack.identifier, + result, + 1000 * 60 * 60 * 5 + ) + return result + } + + const url = urlData.url + const requestCheck = async (): Promise => { + return await http1makeRequest(url, { + method: 'GET', + headers: { + 'User-Agent': clientUserAgent, + Range: 'bytes=0-0' + }, + streamOnly: true, + proxy: proxyToUse as unknown as HttpProxyConfig + }) + } + + let check = await requestCheck() + let preflightAttempts = 1 + while ( + (check.error || + (check.statusCode && + check.statusCode !== 200 && + check.statusCode !== 206 && + check.statusCode !== 403)) && + preflightAttempts < 3 + ) { + preflightAttempts++ + const delay = 500 * (preflightAttempts - 1) + logger( + 'warn', + 'YouTube', + `[Preflight] Initial request failed (Status: ${check.statusCode}, Error: ${check.error || 'None'}). Retrying in ${delay}ms... (Attempt ${preflightAttempts}/3)` + ) + if (check.stream) { + try { + ;(check.stream as unknown as { destroy: () => void }).destroy() + } catch {} + } + await new Promise((resolve) => setTimeout(resolve, delay)) + check = await requestCheck() + } + + if (check.stream) { + try { + ;(check.stream as unknown as { destroy: () => void }).destroy() + } catch {} + } this.reportProxyStatus( proxyToUse, !check.error && - (check.statusCode === 200 || check.statusCode === 206), + (check.statusCode === 200 || + check.statusCode === 206 || + check.statusCode === 403), check.statusCode || 0, Date.now() - proxyStartTime ) if ( !check.error && - (check.statusCode === 200 || check.statusCode === 206) + (check.statusCode === 200 || + check.statusCode === 206 || + check.statusCode === 403) ) { let contentLength: number | null = null - const headers = check.headers as Record - if (headers?.['content-range']) { - const match = headers['content-range']?.match(/\/(\d+)/) - if (match) contentLength = Number.parseInt(match[1] ?? '0', 10) + if (check.statusCode === 200 || check.statusCode === 206) { + const headers = check.headers as Record< + string, + string | undefined + > + if (headers?.['content-range']) { + const match = headers['content-range']?.match(/\/(\d+)/) + if (match) contentLength = Number.parseInt(match[1] ?? '0', 10) + } + if (!contentLength && headers?.['content-length']) { + contentLength = Number.parseInt( + headers['content-length'] as string, + 10 + ) + } } - if (!contentLength && headers?.['content-length']) { - contentLength = Number.parseInt( - headers['content-length'] as string, - 10 + + if (!contentLength) { + const selectedFormat = urlData.formats?.find( + (f) => f.itag === urlData.itag + ) + contentLength = selectedFormat?.contentLength + ? Number.parseInt(selectedFormat.contentLength as string, 10) + : null + } + + if (check.statusCode === 403) { + logger( + 'warn', + 'YouTube', + `URL pre-flight returned 403 for client ${clientName} (expected transient sync delay). Proceeding to playback.` + ) + } else { + logger( + 'debug', + 'YouTube', + `URL pre-flight check successful for client ${clientName}.` ) } - logger( - 'debug', - 'YouTube', - `URL pre-flight check successful for client ${clientName}.` - ) const result: TrackUrlData = { ...urlData, additionalData: { contentLength, proxy: proxyToUse, itag: urlData.itag, - formats: urlData.formats + formats: urlData.formats, + userAgent: clientUserAgent } } this.nodelink.trackCacheManager?.set( @@ -1292,18 +1390,21 @@ export default class YouTubeSource { return result } - const errorMessage = `URL pre-flight failed. Status: ${check.statusCode}, Error: ${check.error}` + const errorMessage = `URL pre-flight failed after retries. Status: ${check.statusCode}, Error: ${check.error}` clientErrors.push({ client: clientName, message: `Direct URL: ${errorMessage}` }) logger('warn', 'YouTube', `Client ${clientName}: ${errorMessage}`) - if (check.statusCode === 403 && urlData.hlsUrl) { + if ( + (check.statusCode === 403 || check.statusCode === 404) && + urlData.hlsUrl + ) { logger( 'warn', 'YouTube', - `Direct URL 403, attempting HLS fallback for client ${clientName}.` + `Direct URL failed, attempting HLS fallback for client ${clientName}.` ) const hlsCheck: HttpRequestResult = await http1makeRequest( urlData.hlsUrl, @@ -1315,12 +1416,13 @@ export default class YouTubeSource { } ) - if (hlsCheck.stream) - ( - hlsCheck.stream as NodeJS.ReadableStream & { - destroy: () => void - } - ).destroy() + if (hlsCheck.stream) { + try { + ;( + hlsCheck.stream as unknown as { destroy: () => void } + ).destroy() + } catch {} + } this.reportProxyStatus( proxyToUse, @@ -1368,10 +1470,11 @@ export default class YouTubeSource { } ) - if (hlsCheck.stream) - ( - hlsCheck.stream as NodeJS.ReadableStream & { destroy: () => void } - ).destroy() + if (hlsCheck.stream) { + try { + ;(hlsCheck.stream as unknown as { destroy: () => void }).destroy() + } catch {} + } this.reportProxyStatus( proxyToUse, @@ -1664,9 +1767,12 @@ export default class YouTubeSource { let contentLength = additionalData?.contentLength ?? null if (!contentLength) { + const userAgent = additionalData?.userAgent + const testResponse = await http1makeRequest(url, { method: 'HEAD', - timeout: 5000 + timeout: 5000, + headers: userAgent ? { 'User-Agent': userAgent } : undefined }) const headers = testResponse.headers as Record< @@ -1678,13 +1784,20 @@ export default class YouTubeSource { } if (testResponse.statusCode === 403) { - throw new Error('URL returned 403 Forbidden') + logger( + 'warn', + 'YouTube', + `HEAD request for "${decodedTrack.title}" returned 403. Attempting range request fallback.` + ) } if (!contentLength) { const rangeResponse = await http1makeRequest(url, { method: 'GET', - headers: { Range: 'bytes=0-0' }, + headers: { + ...(userAgent ? { 'User-Agent': userAgent } : {}), + Range: 'bytes=0-0' + }, streamOnly: true, proxy: this.getProxy() as unknown as HttpProxyConfig }) @@ -2036,14 +2149,46 @@ export default class YouTubeSource { streamKey: string | symbol, additionalData?: TrackUrlAdditionalData ): Promise { + const userAgent = + (additionalData?.userAgent as string | undefined) || + (this.config.userAgent as string | undefined) + const fetchStartTime = Date.now() - const response = await http1makeRequest(url, { - method: 'GET', - streamOnly: true, - proxy: (additionalData?.proxy || - this.getProxy()) as unknown as HttpProxyConfig, - timeout: 20000 - }) + const requestStream = async (): Promise< + Awaited> + > => { + return await http1makeRequest(url, { + method: 'GET', + streamOnly: true, + proxy: (additionalData?.proxy || + this.getProxy()) as unknown as HttpProxyConfig, + timeout: 20000, + headers: userAgent ? { 'User-Agent': userAgent } : undefined + }) + } + + let response = await requestStream() + let attempt = 0 + const maxAttempts = 5 + while ( + (response.error || (response.statusCode && response.statusCode >= 400)) && + attempt < maxAttempts + ) { + attempt++ + const delay = Math.min(1000 * 2 ** (attempt - 1), 2000) + logger( + 'warn', + 'YouTube', + `[DirectStream] Initial stream request failed (Status: ${response.statusCode}, Error: ${response.error || 'None'}). Retrying in ${delay}ms... (Attempt ${attempt}/${maxAttempts})` + ) + if (response.stream) { + try { + ;(response.stream as unknown as { destroy: () => void }).destroy() + } catch {} + } + await new Promise((resolve) => setTimeout(resolve, delay)) + response = await requestStream() + } this.reportProxyStatus( (additionalData?.proxy || this.getProxy(false)) as @@ -2056,6 +2201,11 @@ export default class YouTubeSource { ) if (response.statusCode !== 200 && response.statusCode !== 206) { + if (response.stream) { + try { + ;(response.stream as unknown as { destroy: () => void }).destroy() + } catch {} + } throw new Error(`HTTP status ${response.statusCode}`) } @@ -2594,6 +2744,10 @@ export default class YouTubeSource { highWaterMark: STREAM_BUFFER_SIZE }) + const userAgent = + (additionalData?.userAgent as string | undefined) || + (this.config.userAgent as string | undefined) + let currentUrl = url let currentProxy = additionalData?.proxy as unknown as | HttpProxyConfig @@ -2792,6 +2946,7 @@ export default class YouTubeSource { : 16_000 let chunkCount = 0 + let transient403Attempts = 0 while ( !isDestroyed && @@ -2828,6 +2983,7 @@ export default class YouTubeSource { proxy: proxyToUse, timeout: 30000, headers: { + ...(userAgent ? { 'User-Agent': userAgent } : {}), Range: rangeHeader, 'Accept-Encoding': 'identity;q=1, *;q=0', 'Sec-Fetch-Dest': 'video', @@ -2865,13 +3021,65 @@ export default class YouTubeSource { result.error || (result.statusCode !== 200 && result.statusCode !== 206) ) { + if (result.statusCode === 403) { + const urlAge = Date.now() - urlFetchTime + if (urlAge < 60000 && transient403Attempts < 6) { + transient403Attempts++ + const retryDelay = + transient403Attempts === 1 + ? 1500 + : transient403Attempts === 2 + ? 2000 + : 3000 + + logger( + 'warn', + 'YouTube', + `[ChunkedStream] GGC edge sync latency (403) for ${decodedTrack.title}. Retrying same URL in ${retryDelay}ms... (Attempt ${transient403Attempts}/6)` + ) + if (result.stream) { + try { + ;( + result.stream as unknown as { resume: () => void } + ).resume() + } catch {} + } + await sleep(retryDelay) + continue + } + + if (transient403Attempts < 5) { + transient403Attempts++ + const retryDelay = Math.min( + 1000 * 2 ** (transient403Attempts - 1), + 2000 + ) + logger( + 'warn', + 'YouTube', + `[ChunkedStream] Transient 403 detected for ${decodedTrack.title}. Retrying same URL in ${retryDelay}ms... (Attempt ${transient403Attempts}/5)` + ) + if (result.stream) { + try { + ;( + result.stream as unknown as { resume: () => void } + ).resume() + } catch {} + } + await sleep(retryDelay) + continue + } + transient403Attempts = 0 + } + if (result.statusCode === 403 || result.statusCode === 404) { logger( 'warn', 'YouTube', `HTTP ${result.statusCode} for "${decodedTrack.title}" -- refreshing...` ) - if (currentItag) failedItags.add(currentItag) + if (result.statusCode === 404 && currentItag) + failedItags.add(currentItag) const refreshed = await refreshUrl(`HTTP ${result.statusCode}`) if (refreshed) continue cleanup( @@ -2893,6 +3101,8 @@ export default class YouTubeSource { continue } + transient403Attempts = 0 + const responseStream = result.stream as NodeJS.ReadableStream & { destroyed: boolean destroy: () => void diff --git a/src/sources/youtube/clients/TV.ts b/src/sources/youtube/clients/TV.ts index 06cab9d0..403cd402 100644 --- a/src/sources/youtube/clients/TV.ts +++ b/src/sources/youtube/clients/TV.ts @@ -50,18 +50,29 @@ export default class TV extends BaseClient { * @returns Client context object describing this TVHTML5 client configuration */ override getClient(context: YouTubeContext): YouTubeClientContext { + const utc = -Math.floor(new Date().getTimezoneOffset()) return { client: { clientName: 'TVHTML5', - clientVersion: '7.20260706.14.00', + clientVersion: '7.20260708.13.02', userAgent: - 'Mozilla/5.0 (Fuchsia) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 CrKey/1.56.500000', + 'Mozilla/5.0 (SMART-TV; Linux; Tizen 8.0) Cobalt/Version (unlike Gecko) v8/11.4.233.17-gold Starboard/16, Tizen;Samsung;KantM_2024;8.0/2300.0 (Samsung, QN90D_98, Wired', hl: context.client.hl, gl: context.client.gl }, - user: { lockedSafetyMode: false }, - request: { useSsl: true } - } + user: { + lockedSafetyMode: false + }, + request: { + useSsl: true + }, + adSignalsInfo: { + params: [ + { key: 'dt', value: Date.now().toString() }, + { key: 'u_tz', value: `${utc}` } + ] + } + } as unknown as YouTubeClientContext } /** From 50f5105a61874816529754619e7da4abb3d9051b Mon Sep 17 00:00:00 2001 From: ToddyTheNoobDud Date: Sun, 12 Jul 2026 00:48:16 -0300 Subject: [PATCH 2/3] update: remove the default user agent from streamprocessor --- src/playback/processing/streamProcessor.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/playback/processing/streamProcessor.ts b/src/playback/processing/streamProcessor.ts index 07385543..8e2fe876 100644 --- a/src/playback/processing/streamProcessor.ts +++ b/src/playback/processing/streamProcessor.ts @@ -345,9 +345,6 @@ const _extractSeekProxy = ( : undefined } -const DEFAULT_USER_AGENT = - 'Mozilla/5.0 (SMART-TV; Linux; Tizen 8.0) Cobalt/Version (unlike Gecko) v8/11.4.233.17-gold Starboard/16, Tizen;Samsung;KantM_2024;8.0/2300.0 (Samsung, QN90D_98, Wired' - const _extractSeekUserAgent = (streamInfo: StreamInfo): string | undefined => { const additionalData = streamInfo?.additionalData as | Record @@ -365,13 +362,12 @@ async function _fetchRange( proxy?: HttpProxyConfig, userAgent?: string ): Promise { - const ua = userAgent || DEFAULT_USER_AGENT if (proxy) { const response = await http1makeRequest(url, { method: 'GET', headers: { Range: `bytes=${start}-${endInclusive}`, - 'User-Agent': ua + ...(userAgent ? { 'User-Agent': userAgent } : {}) } as HttpRequestHeaders, responseType: 'buffer', proxy @@ -395,7 +391,7 @@ async function _fetchRange( const res = await fetch(url, { headers: { Range: `bytes=${start}-${endInclusive}`, - 'User-Agent': ua + ...(userAgent ? { 'User-Agent': userAgent } : {}) } }) if (!res.ok) { @@ -411,13 +407,12 @@ async function _openRangeStream( proxy?: HttpProxyConfig, userAgent?: string ): Promise { - const ua = userAgent || DEFAULT_USER_AGENT if (proxy) { const response = await http1makeRequest(url, { method: 'GET', headers: { Range: `bytes=${start}-`, - 'User-Agent': ua + ...(userAgent ? { 'User-Agent': userAgent } : {}) } as HttpRequestHeaders, streamOnly: true, proxy @@ -438,7 +433,7 @@ async function _openRangeStream( const res = await fetch(url, { headers: { Range: `bytes=${start}-`, - 'User-Agent': ua + ...(userAgent ? { 'User-Agent': userAgent } : {}) } }) if (!res.ok) { @@ -572,7 +567,7 @@ const _createSeekableProxyRequest = ( } ): Promise => { const headers = { - 'User-Agent': userAgent || DEFAULT_USER_AGENT, + ...(userAgent ? { 'User-Agent': userAgent } : {}), ...(options?.headers ?? {}) } as HttpRequestHeaders From 261c33380e4430bbc4d33e7c77232ec3a88934dc Mon Sep 17 00:00:00 2001 From: ToddyTheNoobDud Date: Sun, 12 Jul 2026 00:55:46 -0300 Subject: [PATCH 3/3] fix: TV formattation --- src/sources/youtube/clients/TV.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/sources/youtube/clients/TV.ts b/src/sources/youtube/clients/TV.ts index 403cd402..17e6686a 100644 --- a/src/sources/youtube/clients/TV.ts +++ b/src/sources/youtube/clients/TV.ts @@ -60,19 +60,15 @@ export default class TV extends BaseClient { hl: context.client.hl, gl: context.client.gl }, - user: { - lockedSafetyMode: false - }, - request: { - useSsl: true - }, + user: { lockedSafetyMode: false }, + request: { useSsl: true }, adSignalsInfo: { params: [ { key: 'dt', value: Date.now().toString() }, { key: 'u_tz', value: `${utc}` } ] } - } as unknown as YouTubeClientContext + } } /**