Skip to content

Commit 34db2fb

Browse files
committed
fix(radio): a playlist that only points at the stream is followed (#368)
A TuneIn station whose url is a `.m3u` never played: the file holds one line naming the real stream, and the proxy handed that list straight to ffmpeg. Its only m3u reader is the hls demuxer, which wants a manifest — `#EXTM3U` plus an `#EXT-X-` tag — so a pointer came back as "Invalid data found when processing input". Entering such a url by hand failed for the same reason: nothing in the chain ever followed a pointer, it only rewrote one. So tell the two apart. A real manifest still goes to the client with its segments proxied; a pointer is followed here, up to three hops, and what it names is streamed instead. A `.pls` lists mirrors, so a dead first entry is tried past rather than reported as a dead station. Parsing knows both flavours: in a `.pls` only `File<N>=` counts, since `[playlist]` and `NumberOfEntries=2` resolve into perfectly fetchable relative urls, and candidates carrying markup or whitespace are dropped — a cdn that answers a dead url with an html page under `application/x-mpegURL` is not a playlist worth chasing. Verified on the station from the issue: it plays, and the icy titles arrive with it.
1 parent 4c22705 commit 34db2fb

3 files changed

Lines changed: 438 additions & 82 deletions

File tree

src/adapters/http/streams/audioProxyHandler.ts

Lines changed: 216 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,65 @@ import {
1212
} from '@/shared/urlProxy';
1313

1414
const 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. */
1620
const 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*#EXTM3U/.test(body)) {
30+
return false;
31+
}
32+
return /#EXT-X-(?:STREAM-INF|TARGETDURATION|MEDIA-SEQUENCE)[:\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) => /^\[playlist\]$/i.test(line) || /^File\d+\s*=/i.test(line));
45+
const candidates = isPls
46+
? lines
47+
.map((line) => /^File(\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. */
1975
function 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+
46108
export 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 = /^File(\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

Comments
 (0)