@@ -12,9 +12,65 @@ import {
1212} from '@/shared/urlProxy' ;
1313
1414const MAX_PLAYLIST_BYTES = 1024 * 1024 ;
15+ /** A pointer playlist may name another one; stop before a cycle turns into a crawl. */
16+ const MAX_PLAYLIST_HOPS = 3 ;
17+ /** A `.pls` lists mirrors, so a dead first entry is not a dead station — try a few. */
18+ const MAX_PLAYLIST_ENTRIES = 3 ;
1519/** Window size for hosts that refuse open-ended ranges; big enough to keep ahead of playback. */
1620const RANGE_CHUNK_BYTES = 4 * 1024 * 1024 ;
1721
22+ /**
23+ * True only for a playlist ffmpeg can open itself.
24+ *
25+ * Its hls demuxer probes for `#EXTM3U` *and* one of the tags that make the file a
26+ * manifest rather than a pointer — that pair is the whole test, mirrored here.
27+ */
28+ function isHlsManifest ( body : string ) : boolean {
29+ if ( ! / ^ \s * # E X T M 3 U / . test ( body ) ) {
30+ return false ;
31+ }
32+ return / # E X T - X - (?: S T R E A M - I N F | T A R G E T D U R A T I O N | M E D I A - S E Q U E N C E ) [: \s] / i. test ( body ) ;
33+ }
34+
35+ /**
36+ * The urls a pointer playlist names, in mirror order.
37+ *
38+ * The two flavours have to be told apart first: in a pls every other line (`[playlist]`,
39+ * `NumberOfEntries=2`) is bookkeeping that resolves into a perfectly valid relative url
40+ * and would be tried as a mirror, while in an m3u a bare line *is* the entry.
41+ */
42+ function playlistEntries ( body : string , baseUrl : string ) : string [ ] {
43+ const lines = body . split ( / \r ? \n / ) . map ( ( line ) => line . trim ( ) ) . filter ( Boolean ) ;
44+ const isPls = lines . some ( ( line ) => / ^ \[ p l a y l i s t \] $ / i. test ( line ) || / ^ F i l e \d + \s * = / i. test ( line ) ) ;
45+ const candidates = isPls
46+ ? lines
47+ . map ( ( line ) => / ^ F i l e ( \d + ) \s * = \s * ( .+ ) $ / i. exec ( line ) )
48+ . filter ( ( match ) : match is RegExpExecArray => match !== null )
49+ . sort ( ( a , b ) => Number ( a [ 1 ] ) - Number ( b [ 1 ] ) )
50+ . map ( ( match ) => ( match [ 2 ] ?? '' ) . trim ( ) )
51+ : lines . filter ( ( line ) => ! line . startsWith ( '#' ) ) ;
52+
53+ const entries : string [ ] = [ ] ;
54+ for ( const candidate of candidates ) {
55+ // Anything with markup or whitespace in it is not an entry: a host that answers a
56+ // dead url with an html page under an `audio/x-mpegurl` header gets read as a
57+ // playlist, and `<!doctype html>` resolves against the base into a perfectly
58+ // fetchable url. Chasing those is how one junk page becomes several requests.
59+ if ( ! candidate || / [ < > " \s ] / . test ( candidate ) ) {
60+ continue ;
61+ }
62+ try {
63+ const absolute = new URL ( candidate , baseUrl ) ;
64+ if ( absolute . protocol === 'http:' || absolute . protocol === 'https:' ) {
65+ entries . push ( absolute . toString ( ) ) ;
66+ }
67+ } catch {
68+ // Not a url — a stray line or a local file path we cannot reach anyway.
69+ }
70+ }
71+ return entries ;
72+ }
73+
1874/** True for `bytes=N-` and for no Range at all — the shapes googlevideo answers with 403. */
1975function isUnboundedRange ( range : string | undefined ) : boolean {
2076 if ( ! range ) {
@@ -43,6 +99,12 @@ function parseContentRange(value: string | null): { end: number; total: number |
4399 return { end, total : total !== null && Number . isFinite ( total ) ? total : null } ;
44100}
45101
102+ interface UpstreamFetchOptions {
103+ upstreamHeaders : Record < string , string > ;
104+ wantsRest : boolean ;
105+ restStart : number ;
106+ }
107+
46108export class AudioProxyHandler {
47109 private readonly log = createLogger ( 'Http' , 'AudioProxy' ) ;
48110
@@ -65,8 +127,8 @@ export class AudioProxyHandler {
65127 }
66128
67129 const url = new URL ( req . url ?? '/' , 'http://localhost' ) ;
68- const target = url . searchParams . get ( 'u' ) ?? '' ;
69- if ( ! target ) {
130+ const requested = url . searchParams . get ( 'u' ) ?? '' ;
131+ if ( ! requested ) {
70132 res . writeHead ( 400 , { 'Content-Type' : 'application/json' } ) ;
71133 res . end ( JSON . stringify ( { error : 'missing-target' } ) ) ;
72134 return ;
@@ -83,66 +145,43 @@ export class AudioProxyHandler {
83145 // also counts against that video's request budget, which throttles quickly.
84146 const wantsRest = isUnboundedRange ( upstreamHeaders . Range ) ;
85147 const restStart = wantsRest ? parseRangeStart ( upstreamHeaders . Range ) : 0 ;
86- const firstAttemptHeaders = wantsRest
87- ? { ...upstreamHeaders , Range : `bytes=${ restStart } -${ restStart + RANGE_CHUNK_BYTES - 1 } ` }
88- : upstreamHeaders ;
148+ const fetchOpts = { upstreamHeaders, wantsRest, restStart } ;
89149
90150 let upstream : Response ;
91151 try {
92- upstream = await fetch ( target , {
93- headers : firstAttemptHeaders ,
94- redirect : 'follow' ,
95- } ) ;
96- if ( wantsRest ) {
97- this . log . debug ( 'proxy windowed first attempt' , {
98- clientRange : upstreamHeaders . Range ?? '(none)' ,
99- sentRange : firstAttemptHeaders . Range ,
100- status : upstream . status ,
101- sentHeaders : Object . keys ( firstAttemptHeaders ) . join ( ',' ) ,
102- } ) ;
103- }
104- // A host that dislikes the window (or ignores ranges entirely, like a radio
105- // stream) gets asked again exactly the way the client asked — no regression for
106- // everything that was already working. Cancel the refused body first: dropping a
107- // Response without reading it leaves the connection held open.
108- //
109- // An `icy-metaint` on the answer says the same thing from the other side: this is
110- // a live radio stream carrying metadata blocks at fixed offsets into its body. Not
111- // every one of them ignores ranges — an nginx-fronted Shoutcast serves the window
112- // happily, 206 and a fabricated gigabyte of Content-Length — but those offsets only
113- // hold within one unbroken body, so windowing it is never right regardless.
114- if ( wantsRest && ( ! upstream . ok || upstream . headers . has ( 'icy-metaint' ) ) ) {
115- await bestEffort ( ( ) => upstream . body ?. cancel ( ) ?? Promise . resolve ( ) , {
116- fallback : undefined ,
117- onError : 'debug' ,
118- log : this . log ,
119- label : 'discarding refused window' ,
120- context : { target } ,
121- } ) ;
122- upstream = await fetch ( target , { headers : upstreamHeaders , redirect : 'follow' } ) ;
123- }
152+ upstream = await this . fetchUpstream ( requested , fetchOpts ) ;
124153 } catch ( error ) {
125154 this . log . warn ( 'proxy fetch failed' , {
126- target,
155+ target : requested ,
127156 message : error instanceof Error ? error . message : String ( error ) ,
128157 } ) ;
129158 res . writeHead ( 502 , { 'Content-Type' : 'application/json' } ) ;
130159 res . end ( JSON . stringify ( { error : 'proxy-fetch-failed' } ) ) ;
131160 return ;
132161 }
133162
163+ // A `.m3u`/`.pls` that only points at the real stream is not something ffmpeg can
164+ // open — its one m3u demuxer is the HLS one, and that needs a manifest, not a list
165+ // of urls (issue #368). Handing such a pointer straight on, right for HLS where
166+ // ffmpeg takes over from here, leaves it with a text file where it wanted audio. So
167+ // follow the pointer ourselves and stream what it names.
168+ const resolved = await this . followPointerPlaylists ( res , upstream , requested , {
169+ ...fetchOpts ,
170+ extraHeaders,
171+ } ) ;
172+ if ( ! resolved ) {
173+ return ;
174+ }
175+ upstream = resolved . response ;
176+ const target = resolved . target ;
177+
134178 const contentType = upstream . headers . get ( 'content-type' ) ?? 'application/octet-stream' ;
135179 const contentLength = upstream . headers . get ( 'content-length' ) ;
136180 const acceptRanges = upstream . headers . get ( 'accept-ranges' ) ;
137181 const contentRange = upstream . headers . get ( 'content-range' ) ;
138182 const icyMetaInt = upstream . headers . get ( 'icy-metaint' ) ;
139183 const zoneId = this . resolveZoneId ( req ) ;
140184
141- if ( upstream . ok && this . isPlaylistResponse ( contentType , upstream . url ) ) {
142- await this . respondPlaylist ( res , upstream , contentType , extraHeaders ) ;
143- return ;
144- }
145-
146185 // The window came back: hand the client one continuous body built from this window
147186 // and the ones after it. Only when the host actually honoured the range (a 206 with
148187 // a total) — a 200 means it ignored the range and is already streaming the lot.
@@ -229,19 +268,144 @@ export class AudioProxyHandler {
229268 return headers ;
230269 }
231270
232- private async respondPlaylist (
271+ /**
272+ * One upstream request, with the windowing dance a range-hostile host needs.
273+ *
274+ * Throws whatever `fetch` throws; the caller decides what a dead host means.
275+ */
276+ private async fetchUpstream ( target : string , opts : UpstreamFetchOptions ) : Promise < Response > {
277+ const { upstreamHeaders, wantsRest, restStart } = opts ;
278+ const firstAttemptHeaders = wantsRest
279+ ? { ...upstreamHeaders , Range : `bytes=${ restStart } -${ restStart + RANGE_CHUNK_BYTES - 1 } ` }
280+ : upstreamHeaders ;
281+
282+ let upstream = await fetch ( target , {
283+ headers : firstAttemptHeaders ,
284+ redirect : 'follow' ,
285+ } ) ;
286+ if ( wantsRest ) {
287+ this . log . debug ( 'proxy windowed first attempt' , {
288+ clientRange : upstreamHeaders . Range ?? '(none)' ,
289+ sentRange : firstAttemptHeaders . Range ,
290+ status : upstream . status ,
291+ sentHeaders : Object . keys ( firstAttemptHeaders ) . join ( ',' ) ,
292+ } ) ;
293+ }
294+ // A host that dislikes the window (or ignores ranges entirely, like a radio
295+ // stream) gets asked again exactly the way the client asked — no regression for
296+ // everything that was already working. Cancel the refused body first: dropping a
297+ // Response without reading it leaves the connection held open.
298+ //
299+ // An `icy-metaint` on the answer says the same thing from the other side: this is
300+ // a live radio stream carrying metadata blocks at fixed offsets into its body. Not
301+ // every one of them ignores ranges — an nginx-fronted Shoutcast serves the window
302+ // happily, 206 and a fabricated gigabyte of Content-Length — but those offsets only
303+ // hold within one unbroken body, so windowing it is never right regardless.
304+ if ( wantsRest && ( ! upstream . ok || upstream . headers . has ( 'icy-metaint' ) ) ) {
305+ await this . discardBody ( upstream , 'discarding refused window' , target ) ;
306+ upstream = await fetch ( target , { headers : upstreamHeaders , redirect : 'follow' } ) ;
307+ }
308+ return upstream ;
309+ }
310+
311+ /**
312+ * Walk `.m3u`/`.pls` pointers until an actual audio response is in hand.
313+ *
314+ * Returns null when the answer is already on the wire: a real HLS manifest, rewritten
315+ * and served for ffmpeg's hls demuxer to take from here, or a pointer leading nowhere.
316+ */
317+ private async followPointerPlaylists (
318+ res : ServerResponse ,
319+ first : Response ,
320+ firstTarget : string ,
321+ opts : UpstreamFetchOptions & { extraHeaders ?: Record < string , string > } ,
322+ ) : Promise < { response : Response ; target : string } | null > {
323+ let upstream = first ;
324+ let target = firstTarget ;
325+
326+ for ( let hop = 0 ; ; hop ++ ) {
327+ const contentType = upstream . headers . get ( 'content-type' ) ?? 'application/octet-stream' ;
328+ if ( ! upstream . ok || ! this . isPlaylistResponse ( contentType , upstream . url ) ) {
329+ return { response : upstream , target } ;
330+ }
331+ if ( hop >= MAX_PLAYLIST_HOPS ) {
332+ this . log . warn ( 'playlist keeps pointing at playlists' , { target } ) ;
333+ await this . discardBody ( upstream , 'abandoning playlist chain' , target ) ;
334+ res . writeHead ( 502 , { 'Content-Type' : 'application/json' } ) ;
335+ res . end ( JSON . stringify ( { error : 'playlist-too-deep' } ) ) ;
336+ return null ;
337+ }
338+
339+ const text = await this . readTextResponse ( upstream ) ;
340+ if ( text == null ) {
341+ res . writeHead ( upstream . status || 502 , { 'Content-Type' : 'application/json' } ) ;
342+ res . end ( JSON . stringify ( { error : 'playlist-read-failed' } ) ) ;
343+ return null ;
344+ }
345+ if ( isHlsManifest ( text ) ) {
346+ this . respondPlaylist ( res , text , upstream , contentType , opts . extraHeaders ) ;
347+ return null ;
348+ }
349+
350+ const next = await this . fetchFirstReachableEntry ( text , upstream . url , opts ) ;
351+ if ( ! next ) {
352+ this . log . warn ( 'playlist names no reachable stream' , { target } ) ;
353+ res . writeHead ( 502 , { 'Content-Type' : 'application/json' } ) ;
354+ res . end ( JSON . stringify ( { error : 'playlist-unplayable' } ) ) ;
355+ return null ;
356+ }
357+ this . log . debug ( 'followed pointer playlist' , { from : target , to : next . target } ) ;
358+ upstream = next . response ;
359+ target = next . target ;
360+ }
361+ }
362+
363+ /** The first entry of a pointer playlist that answers — the rest are its mirrors. */
364+ private async fetchFirstReachableEntry (
365+ body : string ,
366+ baseUrl : string ,
367+ opts : UpstreamFetchOptions ,
368+ ) : Promise < { response : Response ; target : string } | null > {
369+ const entries = playlistEntries ( body , baseUrl ) . slice ( 0 , MAX_PLAYLIST_ENTRIES ) ;
370+ for ( const entry of entries ) {
371+ let response : Response ;
372+ try {
373+ response = await this . fetchUpstream ( entry , opts ) ;
374+ } catch ( error ) {
375+ this . log . debug ( 'playlist entry unreachable' , {
376+ entry,
377+ message : error instanceof Error ? error . message : String ( error ) ,
378+ } ) ;
379+ continue ;
380+ }
381+ if ( response . ok ) {
382+ return { response, target : entry } ;
383+ }
384+ this . log . debug ( 'playlist entry refused' , { entry, status : response . status } ) ;
385+ await this . discardBody ( response , 'discarding refused playlist entry' , entry ) ;
386+ }
387+ return null ;
388+ }
389+
390+ /** Drop a response we will not read: leaving the body open holds the connection. */
391+ private async discardBody ( response : Response , label : string , target : string ) : Promise < void > {
392+ await bestEffort ( ( ) => response . body ?. cancel ( ) ?? Promise . resolve ( ) , {
393+ fallback : undefined ,
394+ onError : 'debug' ,
395+ log : this . log ,
396+ label,
397+ context : { target } ,
398+ } ) ;
399+ }
400+
401+ private respondPlaylist (
233402 res : ServerResponse ,
403+ body : string ,
234404 upstream : Response ,
235405 contentType : string ,
236406 extraHeaders ?: Record < string , string > ,
237- ) : Promise < void > {
238- const text = await this . readTextResponse ( upstream ) ;
239- if ( text == null ) {
240- res . writeHead ( upstream . status || 502 , { 'Content-Type' : 'application/json' } ) ;
241- res . end ( JSON . stringify ( { error : 'playlist-read-failed' } ) ) ;
242- return ;
243- }
244- const rewritten = this . rewritePlaylist ( text , upstream . url , extraHeaders ) ;
407+ ) : void {
408+ const rewritten = this . rewriteM3u ( body , upstream . url , extraHeaders ) ;
245409 res . writeHead ( upstream . status || 200 , {
246410 'Content-Type' : contentType ,
247411 'Cache-Control' : 'no-cache' ,
@@ -405,18 +569,6 @@ export class AudioProxyHandler {
405569 return text ;
406570 }
407571
408- private rewritePlaylist (
409- body : string ,
410- baseUrl : string ,
411- headers ?: Record < string , string > ,
412- ) : string {
413- const lower = baseUrl . toLowerCase ( ) ;
414- if ( lower . endsWith ( '.pls' ) || body . includes ( 'File1=' ) ) {
415- return this . rewritePls ( body , baseUrl , headers ) ;
416- }
417- return this . rewriteM3u ( body , baseUrl , headers ) ;
418- }
419-
420572 private rewriteM3u (
421573 body : string ,
422574 baseUrl : string ,
@@ -436,24 +588,6 @@ export class AudioProxyHandler {
436588 return proxied . join ( '\n' ) ;
437589 }
438590
439- private rewritePls (
440- body : string ,
441- baseUrl : string ,
442- headers ?: Record < string , string > ,
443- ) : string {
444- const lines = body . split ( / \r ? \n / ) ;
445- const proxied = lines . map ( ( line ) => {
446- const match = / ^ F i l e ( \d + ) = ( .+ ) $ / i. exec ( line . trim ( ) ) ;
447- if ( ! match ) {
448- return line ;
449- }
450- const url = ( match [ 2 ] ?? '' ) . trim ( ) ;
451- const wrapped = this . wrapProxyUrl ( url , baseUrl , headers ) ;
452- return `File${ match [ 1 ] } =${ wrapped } ` ;
453- } ) ;
454- return proxied . join ( '\n' ) ;
455- }
456-
457591 private rewriteHlsUriLine (
458592 line : string ,
459593 baseUrl : string ,
0 commit comments