From 06ed7b8f8d44d920ea57288f79f044752fd5142d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 01:05:19 +0000 Subject: [PATCH 1/6] fix(m3u): keep extra URL lines under a single #EXTINF as channel sources A #EXTINF followed by several URL lines describes one channel with multiple sources. The converter only consumed the first URL and silently dropped the rest, so such channels lost all but one source in the transformed playlist. Keep the entry open after the first URL and re-emit the EXTINF line for each additional URL-looking line, so every source gets its own service path. Closes #740 Co-authored-by: Stackie Jia --- e2e/test_m3u.py | 113 ++++++++++++++++++++++++++++++++++++++++++++++++ src/m3u.c | 24 ++++++++-- 2 files changed, 134 insertions(+), 3 deletions(-) diff --git a/e2e/test_m3u.py b/e2e/test_m3u.py index 6c9894c0..f2afe124 100644 --- a/e2e/test_m3u.py +++ b/e2e/test_m3u.py @@ -974,6 +974,119 @@ def test_multi_label_urls_contain_labels(self, r2h_binary): r2h.stop() +class TestM3UMultiURLPerEXTINF: + """A single #EXTINF followed by several URL lines describes one channel + with multiple sources. Each URL must be kept and re-paired with the + EXTINF line so aggregating players see them as one channel.""" + + def test_each_url_line_emitted_with_extinf(self, r2h_binary): + port = find_free_port() + config = f"""\ +[global] +verbosity = 4 + +[bind] +* {port} + +[services] +#EXTM3U +#EXTINF:-1 group-title="Group 1",Channel 3 +rtp://239.0.0.3:1234$Line 1 +rtp://239.0.0.4:1234$Line 2 +#EXTINF:-1 group-title="Group 1",Channel 4 +rtp://239.0.0.5:1234 +""" + r2h = R2HProcess(r2h_binary, port, config_content=config) + try: + r2h.start() + status, _, body = http_get("127.0.0.1", port, "/playlist.m3u") + assert status == 200 + lines = [line for line in body.decode().splitlines() if line] + + extinf_lines = [line for line in lines if line.startswith("#EXTINF")] + url_lines = [line for line in lines if line.startswith("http")] + assert extinf_lines == [ + '#EXTINF:-1 group-title="Group 1",Channel 3', + '#EXTINF:-1 group-title="Group 1",Channel 3', + '#EXTINF:-1 group-title="Group 1",Channel 4', + ] + assert len(url_lines) == 3 + assert url_lines[0].endswith("/Channel%203/Line%201$Line 1") + assert url_lines[1].endswith("/Channel%203/Line%202$Line 2") + assert url_lines[2].endswith("/Channel%204") + # EXTINF/URL pairs must alternate so every URL keeps its metadata + assert lines[1:] == [ + extinf_lines[0], + url_lines[0], + extinf_lines[1], + url_lines[1], + extinf_lines[2], + url_lines[2], + ] + + for path in ("/Group%201/Channel%203/Line%201", "/Group%201/Channel%203/Line%202"): + head_status, _, _ = http_request("127.0.0.1", port, "HEAD", path) + assert head_status == 200, f"{path} should resolve to a service" + finally: + r2h.stop() + + def test_unlabeled_urls_get_unique_service_paths(self, r2h_binary): + port = find_free_port() + config = f"""\ +[global] +verbosity = 4 + +[bind] +* {port} + +[services] +#EXTM3U +#EXTINF:-1,News +rtp://239.0.0.1:1234 +rtp://239.0.0.2:1234 +""" + r2h = R2HProcess(r2h_binary, port, config_content=config) + try: + r2h.start() + status, _, body = http_get("127.0.0.1", port, "/playlist.m3u") + assert status == 200 + text = body.decode() + url_lines = [line for line in text.splitlines() if line.startswith("http")] + assert len(url_lines) == 2 + assert len(set(url_lines)) == 2, "each source needs its own service path" + assert text.count("#EXTINF:-1,News") == 2 + finally: + r2h.stop() + + def test_stray_text_after_url_is_not_treated_as_source(self, r2h_binary): + port = find_free_port() + config = f"""\ +[global] +verbosity = 4 + +[bind] +* {port} + +[services] +#EXTM3U +#EXTINF:-1,Solo +rtp://239.0.0.1:1234 +this is not a url +#EXTINF:-1,Next +rtp://239.0.0.2:1234 +""" + r2h = R2HProcess(r2h_binary, port, config_content=config) + try: + r2h.start() + status, _, body = http_get("127.0.0.1", port, "/playlist.m3u") + assert status == 200 + text = body.decode() + assert "this is not a url" not in text + assert text.count("#EXTINF") == 2 + finally: + r2h.stop() + + # --------------------------------------------------------------------------- # M3U-configured service + request query merge mechanism # --------------------------------------------------------------------------- diff --git a/src/m3u.c b/src/m3u.c index 27083b42..cc9f9aab 100644 --- a/src/m3u.c +++ b/src/m3u.c @@ -834,6 +834,16 @@ static int is_url_recognizable(const char *url) { return 0; } +/* Check whether a line looks like a stream URL (scheme://... or an absolute + * path). Used to accept additional URL lines that follow an #EXTINF entry + * whose first URL was already consumed, without picking up stray text. */ +static int m3u_line_looks_like_url(const char *line) { + if (line[0] == '/') { + return 1; + } + return strstr(line, "://") != NULL; +} + /* Update ETag for transformed M3U playlist */ static void update_m3u_etag(void) { MD5Context ctx; @@ -1085,6 +1095,11 @@ int m3u_parse_and_create_services(const char *content, const char *source_url) { const char *content_ptr = content; struct m3u_extinf current_extinf; int in_entry = 0; + /* Set once the current #EXTINF has consumed a URL line. Further URL lines + * under the same #EXTINF are treated as additional sources of that channel + * and each re-emits the EXTINF line, so downstream players that aggregate + * same-group same-name entries see them as one channel with multiple sources. */ + int entry_has_url = 0; int entry_count = 0; size_t line_len; char proxy_url[MAX_URL_LENGTH]; @@ -1176,6 +1191,7 @@ int m3u_parse_and_create_services(const char *content, const char *source_url) { if (extract_service_name(line, base_name, sizeof(base_name)) != 0) { logger(LOG_WARN, "Failed to extract service name from EXTINF line"); in_entry = 0; + entry_has_url = 0; continue; } @@ -1221,11 +1237,13 @@ int m3u_parse_and_create_services(const char *content, const char *source_url) { transformed_line[sizeof(transformed_line) - 1] = '\0'; in_entry = 1; + entry_has_url = 0; continue; } - /* Process URL line (follows EXTINF) */ - if (in_entry && line[0] != '#') { + /* Process URL line (follows EXTINF). After the first URL, only lines that + * look like URLs are accepted as additional sources of the same entry. */ + if (in_entry && line[0] != '#' && (!entry_has_url || m3u_line_looks_like_url(line))) { /* Extract $label suffix from URL end before any processing */ const char *url_label = http_find_url_label(line); char url_label_copy[MAX_SERVICE_NAME]; @@ -1379,7 +1397,7 @@ int m3u_parse_and_create_services(const char *content, const char *source_url) { append_to_transformed_m3u("\n", service_source); entry_count++; - in_entry = 0; + entry_has_url = 1; } } From f74ad94e3d78cf44b9dc35dd98c7f227c680ac42 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 01:05:19 +0000 Subject: [PATCH 2/6] fix(web-ui): aggregate consecutive URL lines under one #EXTINF into channel sources The player parser reset the pending EXTINF after the first URL line, so a second URL under the same #EXTINF was ignored instead of becoming another source of the channel. Co-authored-by: Stackie Jia --- web-ui/src/lib/m3u-parser.test.ts | 64 ++++++++++++++++++++++++++++++- web-ui/src/lib/m3u-parser.ts | 30 +++++++++------ 2 files changed, 82 insertions(+), 12 deletions(-) diff --git a/web-ui/src/lib/m3u-parser.test.ts b/web-ui/src/lib/m3u-parser.test.ts index 0df7188f..07850470 100644 --- a/web-ui/src/lib/m3u-parser.test.ts +++ b/web-ui/src/lib/m3u-parser.test.ts @@ -2,11 +2,73 @@ import { describe, expect, it } from "vitest"; import type { Source } from "../types/player"; import { buildCatchupUrl } from "./catchup-url"; import { planCatchupSegmentWindows } from "./catchup-windows"; -import { buildCatchupSegments } from "./m3u-parser"; +import { buildCatchupSegments, parseM3U } from "./m3u-parser"; const NOW = new Date("2026-09-02T12:00:00.000Z"); const HOURS = 60 * 60 * 1000; +describe("parseM3U", () => { + it("aggregates consecutive URL lines under one #EXTINF into a single channel with multiple sources", () => { + const { channels } = parseM3U(`#EXTM3U +#EXTINF:-1 group-title="Group 1",Channel 3 +https://example.com/stream3$Line 1 +https://example.com/stream3-alt$Line 2 +#EXTINF:-1 group-title="Group 1",Channel 4 +https://example.com/stream4 +`); + + expect(channels).toHaveLength(2); + expect(channels[0].name).toBe("Channel 3"); + expect(channels[0].groups).toEqual(["Group 1"]); + expect(channels[0].sources).toEqual([ + { url: "/stream3$Line 1", label: "Line 1", catchup: undefined, catchupSource: undefined }, + { url: "/stream3-alt$Line 2", label: "Line 2", catchup: undefined, catchupSource: undefined }, + ]); + expect(channels[1].name).toBe("Channel 4"); + expect(channels[1].sources.map((source) => source.url)).toEqual(["/stream4"]); + }); + + it("merges multi-URL entries with repeated #EXTINF entries of the same group and name", () => { + const { channels } = parseM3U(`#EXTM3U +#EXTINF:-1 group-title="Sat",GDTV +http://r2h.local/Sat/GDTV/UHD$UHD +http://r2h.local/Sat/GDTV/HD$HD +#EXTINF:-1 group-title="Sat",GDTV +http://r2h.local/Sat/GDTV/SD$SD +`); + + expect(channels).toHaveLength(1); + expect(channels[0].id).toBe("1"); + expect(channels[0].sources.map((source) => source.label)).toEqual(["UHD", "HD", "SD"]); + }); + + it("carries per-entry catchup settings to every source of a multi-URL entry", () => { + const { channels } = parseM3U(`#EXTM3U +#EXTINF:-1 catchup="default" catchup-source="http://cu.example/ch?playseek={utc:YmdHMS}-{utcend:YmdHMS}",News +http://live.example/news-a +http://live.example/news-b +`); + + expect(channels).toHaveLength(1); + for (const source of channels[0].sources) { + expect(source.catchup).toBe("default"); + expect(source.catchupSource).toBe("/ch?playseek={utc:YmdHMS}-{utcend:YmdHMS}"); + } + expect(channels[0].sources.map((source) => source.label)).toEqual([undefined, undefined]); + }); + + it("ignores URL lines that appear before any #EXTINF", () => { + const { channels } = parseM3U(`#EXTM3U +https://example.com/orphan +#EXTINF:-1,Only +https://example.com/only +`); + + expect(channels).toHaveLength(1); + expect(channels[0].sources.map((source) => source.url)).toEqual(["/only"]); + }); +}); + const source: Source = { url: "http://live.example/ch", catchup: "default", diff --git a/web-ui/src/lib/m3u-parser.ts b/web-ui/src/lib/m3u-parser.ts index c45a5952..e953b9eb 100644 --- a/web-ui/src/lib/m3u-parser.ts +++ b/web-ui/src/lib/m3u-parser.ts @@ -29,6 +29,9 @@ export function parseM3U(content: string): M3UMetadata { catchup?: string; catchupSource?: string; } | null = null; + // Channel created from the current #EXTINF; consecutive URL lines under the + // same #EXTINF are appended to it as additional sources. + let currentChannel: Channel | null = null; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); @@ -85,6 +88,7 @@ export function parseM3U(content: string): M3UMetadata { catchup: catchupMatch?.[1] || defaultCatchup, catchupSource: resolveCatchupSource(catchupSourceMatch?.[1] || defaultCatchupSource), }; + currentChannel = null; continue; } @@ -96,24 +100,28 @@ export function parseM3U(content: string): M3UMetadata { const urlWithoutLabel = labelMatch ? line.slice(0, line.lastIndexOf("$")) : line; const resolvedUrl = toPlaylistRelativePath(urlWithoutLabel) + (labelMatch ? line.slice(line.lastIndexOf("$")) : ""); + const source: Source = { + url: resolvedUrl, + catchup: currentExtinf.catchup, + catchupSource: currentExtinf.catchupSource, + label: sourceLabel, + }; + + if (currentChannel) { + currentChannel.sources.push(source); + continue; + } - channels.push({ + currentChannel = { id: `${channels.length + 1}`, name: currentExtinf.name, logo: currentExtinf.logo, groups: currentExtinf.groups, tvgId: currentExtinf.tvgId, tvgName: currentExtinf.tvgName, - sources: [ - { - url: resolvedUrl, - catchup: currentExtinf.catchup, - catchupSource: currentExtinf.catchupSource, - label: sourceLabel, - }, - ], - }); - currentExtinf = null; + sources: [source], + }; + channels.push(currentChannel); } } From a1e53e6f4e74578ec5eef38e1b00d49d0e2ac1f8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 01:05:19 +0000 Subject: [PATCH 3/6] docs(m3u): document multiple URL lines under one #EXTINF Co-authored-by: Stackie Jia --- docs/en/guide/m3u-integration.md | 11 ++++++++++- docs/en/guide/web-player.md | 15 +++++++++++++++ docs/guide/m3u-integration.md | 11 ++++++++++- docs/guide/web-player.md | 15 +++++++++++++++ 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/docs/en/guide/m3u-integration.md b/docs/en/guide/m3u-integration.md index 4e17dbfd..2f531ba8 100644 --- a/docs/en/guide/m3u-integration.md +++ b/docs/en/guide/m3u-integration.md @@ -203,9 +203,18 @@ rtp://239.253.64.200:5140/?fcc=10.255.75.73:15970$HD rtp://239.253.64.44:5140/?fcc=10.255.75.73:15970$SD ``` +You can also write a single `#EXTINF` line followed by multiple URL lines, where each line is one source of that channel. This is equivalent to repeating `#EXTINF` as shown above: + +```m3u +#EXTINF:-1 tvg-id="Guangdong TV" tvg-name="Guangdong TV" tvg-logo="https://example.com/logo/GuangdongTV.png" group-title="Satellite",Guangdong TV +rtp://239.253.64.96:5140/?fcc=10.255.75.73:15970$UHD +rtp://239.253.64.200:5140/?fcc=10.255.75.73:15970$HD +rtp://239.253.64.44:5140/?fcc=10.255.75.73:15970$SD +``` + ### Example Output -Each channel with a `$label` generates an independent service path, with `$label` converted to a `/label` subpath, and `$label` also preserved at the end of the converted URL: +Each channel with a `$label` generates an independent service path, with `$label` converted to a `/label` subpath, and `$label` also preserved at the end of the converted URL. Regardless of which input form is used, the converted M3U emits a separate `#EXTINF` + URL pair for every source: ```m3u #EXTINF:-1 tvg-id="Guangdong TV" tvg-name="Guangdong TV" tvg-logo="https://example.com/logo/GuangdongTV.png" group-title="Satellite",Guangdong TV diff --git a/docs/en/guide/web-player.md b/docs/en/guide/web-player.md index 46b93a2f..82290712 100644 --- a/docs/en/guide/web-player.md +++ b/docs/en/guide/web-player.md @@ -103,6 +103,21 @@ When multiple channels with the **same group and same name** exist in the M3U, t ![Channel Source Selector](../../images/channel-source-selector.png) +Both of the following M3U forms are aggregated into one channel with multiple sources: + +```m3u +# Form 1: repeat #EXTINF, one source per entry +#EXTINF:-1 group-title="Satellite",Guangdong TV +http://192.168.1.1:5140/rtp/239.253.64.96:5140$UHD +#EXTINF:-1 group-title="Satellite",Guangdong TV +http://192.168.1.1:5140/rtp/239.253.64.200:5140$HD + +# Form 2: one #EXTINF followed by multiple URL lines +#EXTINF:-1 group-title="Satellite",Guangdong TV +http://192.168.1.1:5140/rtp/239.253.64.96:5140$UHD +http://192.168.1.1:5140/rtp/239.253.64.200:5140$HD +``` + If a source URL has a `$label` suffix, the player extracts it as the source's display label (such as "UHD", "HD", "SD"). Sources without `$label` are displayed with sequential numbers (such as "Source 1", "Source 2"). > [!NOTE] diff --git a/docs/guide/m3u-integration.md b/docs/guide/m3u-integration.md index 8917ebf6..a081aa90 100644 --- a/docs/guide/m3u-integration.md +++ b/docs/guide/m3u-integration.md @@ -203,9 +203,18 @@ rtp://239.253.64.200:5140/?fcc=10.255.75.73:15970$高清 rtp://239.253.64.44:5140/?fcc=10.255.75.73:15970$标清 ``` +也可以只写一行 `#EXTINF`,后面紧跟多行 URL,每行代表该频道的一条线路,效果与上面重复 `#EXTINF` 的写法完全相同: + +```m3u +#EXTINF:-1 tvg-id="广东卫视" tvg-name="广东卫视" tvg-logo="https://example.com/logo/广东卫视.png" group-title="卫视",广东卫视 +rtp://239.253.64.96:5140/?fcc=10.255.75.73:15970$超高清 +rtp://239.253.64.200:5140/?fcc=10.255.75.73:15970$高清 +rtp://239.253.64.44:5140/?fcc=10.255.75.73:15970$标清 +``` + ### 示例输出 -每个带 `$label` 的频道会生成独立的服务路径,`$label` 转换为 `/label` 子路径,同时 `$label` 保留在转换后 URL 的末尾: +每个带 `$label` 的频道会生成独立的服务路径,`$label` 转换为 `/label` 子路径,同时 `$label` 保留在转换后 URL 的末尾。无论输入采用哪种写法,转换后的 M3U 都会为每条线路输出一组独立的 `#EXTINF` + URL: ```m3u #EXTINF:-1 tvg-id="广东卫视" tvg-name="广东卫视" tvg-logo="https://example.com/logo/广东卫视.png" group-title="卫视",广东卫视 diff --git a/docs/guide/web-player.md b/docs/guide/web-player.md index 742e6c44..21c15c56 100644 --- a/docs/guide/web-player.md +++ b/docs/guide/web-player.md @@ -103,6 +103,21 @@ LG webOS 智能电视同样支持通过内置浏览器使用播放器,并将 ![频道源选择器](../images/channel-source-selector.png) +以下两种 M3U 写法都会被聚合为一个频道的多条线路: + +```m3u +# 写法一:重复 #EXTINF,每行一个源 +#EXTINF:-1 group-title="卫视",广东卫视 +http://192.168.1.1:5140/rtp/239.253.64.96:5140$超高清 +#EXTINF:-1 group-title="卫视",广东卫视 +http://192.168.1.1:5140/rtp/239.253.64.200:5140$高清 + +# 写法二:一个 #EXTINF 后紧跟多行 URL +#EXTINF:-1 group-title="卫视",广东卫视 +http://192.168.1.1:5140/rtp/239.253.64.96:5140$超高清 +http://192.168.1.1:5140/rtp/239.253.64.200:5140$高清 +``` + 如果源 URL 带有 `$标签` 后缀,播放器会将其提取为源的显示标签(如「超高清」「高清」「标清」)。没有 `$标签` 的源则按序号显示(如「线路 1」「线路 2」)。 > [!NOTE] From 9f7c788065b835de09a460e355d045b29aff78ff Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 01:13:27 +0000 Subject: [PATCH 4/6] docs(m3u): make one #EXTINF with multiple URL lines the documented multi-source form Co-authored-by: Stackie Jia --- docs/en/guide/m3u-integration.md | 15 ++------------- docs/en/guide/web-player.md | 11 +---------- docs/guide/m3u-integration.md | 15 ++------------- docs/guide/web-player.md | 11 +---------- 4 files changed, 6 insertions(+), 46 deletions(-) diff --git a/docs/en/guide/m3u-integration.md b/docs/en/guide/m3u-integration.md index 2f531ba8..d49e6073 100644 --- a/docs/en/guide/m3u-integration.md +++ b/docs/en/guide/m3u-integration.md @@ -188,23 +188,12 @@ Unrecognizable URLs are preserved as-is without conversion. For example, if `cat ## Source Labels -By adding a `$label` suffix at the very end of a URL, you can specify a display label for the channel source (such as quality level). The `$label` must be at the absolute end of the entire URL. +A single `#EXTINF` line followed by multiple URL lines declares one source per line for that channel. By adding a `$label` suffix at the very end of a URL, you can specify a display label for each source (such as quality level). The `$label` must be at the absolute end of the entire URL. This feature is only effective in players that support labels and channel aggregation (such as the [built-in web player](/en/guide/web-player)). ### Example Input -```m3u -#EXTINF:-1 tvg-id="Guangdong TV" tvg-name="Guangdong TV" tvg-logo="https://example.com/logo/GuangdongTV.png" group-title="Satellite",Guangdong TV -rtp://239.253.64.96:5140/?fcc=10.255.75.73:15970$UHD -#EXTINF:-1 tvg-id="Guangdong TV" tvg-name="Guangdong TV" tvg-logo="https://example.com/logo/GuangdongTV.png" group-title="Satellite",Guangdong TV -rtp://239.253.64.200:5140/?fcc=10.255.75.73:15970$HD -#EXTINF:-1 tvg-id="Guangdong TV" tvg-name="Guangdong TV" tvg-logo="https://example.com/logo/GuangdongTV.png" group-title="Satellite",Guangdong TV -rtp://239.253.64.44:5140/?fcc=10.255.75.73:15970$SD -``` - -You can also write a single `#EXTINF` line followed by multiple URL lines, where each line is one source of that channel. This is equivalent to repeating `#EXTINF` as shown above: - ```m3u #EXTINF:-1 tvg-id="Guangdong TV" tvg-name="Guangdong TV" tvg-logo="https://example.com/logo/GuangdongTV.png" group-title="Satellite",Guangdong TV rtp://239.253.64.96:5140/?fcc=10.255.75.73:15970$UHD @@ -214,7 +203,7 @@ rtp://239.253.64.44:5140/?fcc=10.255.75.73:15970$SD ### Example Output -Each channel with a `$label` generates an independent service path, with `$label` converted to a `/label` subpath, and `$label` also preserved at the end of the converted URL. Regardless of which input form is used, the converted M3U emits a separate `#EXTINF` + URL pair for every source: +Each source generates an independent service path, with `$label` converted to a `/label` subpath, and `$label` also preserved at the end of the converted URL. For compatibility with third-party players, the converted M3U emits a separate `#EXTINF` + URL entry for every source: ```m3u #EXTINF:-1 tvg-id="Guangdong TV" tvg-name="Guangdong TV" tvg-logo="https://example.com/logo/GuangdongTV.png" group-title="Satellite",Guangdong TV diff --git a/docs/en/guide/web-player.md b/docs/en/guide/web-player.md index 82290712..8c7f717c 100644 --- a/docs/en/guide/web-player.md +++ b/docs/en/guide/web-player.md @@ -99,20 +99,11 @@ LG webOS smart TVs can also use the built-in browser to open the player and pin ## Channel Aggregation -When multiple channels with the **same group and same name** exist in the M3U, the player automatically aggregates them into multiple sources of one channel, displaying them only once in the channel list. Users can switch between different sources (such as different quality levels) using the source selector: +In the M3U, a single `#EXTINF` line followed by multiple URL lines declares a channel with multiple sources. The player aggregates them into one channel, displaying it only once in the channel list, and users can switch between sources (such as different quality levels) using the source selector: ![Channel Source Selector](../../images/channel-source-selector.png) -Both of the following M3U forms are aggregated into one channel with multiple sources: - ```m3u -# Form 1: repeat #EXTINF, one source per entry -#EXTINF:-1 group-title="Satellite",Guangdong TV -http://192.168.1.1:5140/rtp/239.253.64.96:5140$UHD -#EXTINF:-1 group-title="Satellite",Guangdong TV -http://192.168.1.1:5140/rtp/239.253.64.200:5140$HD - -# Form 2: one #EXTINF followed by multiple URL lines #EXTINF:-1 group-title="Satellite",Guangdong TV http://192.168.1.1:5140/rtp/239.253.64.96:5140$UHD http://192.168.1.1:5140/rtp/239.253.64.200:5140$HD diff --git a/docs/guide/m3u-integration.md b/docs/guide/m3u-integration.md index a081aa90..6a34dc2f 100644 --- a/docs/guide/m3u-integration.md +++ b/docs/guide/m3u-integration.md @@ -188,23 +188,12 @@ http://iptv.example.com/live/channel1.m3u8 ## 线路标签 -通过在 URL 末尾添加 `$标签` 后缀,可以为频道源指定显示标签(如清晰度)。`$标签` 必须位于整个 URL 的最末尾。 +一行 `#EXTINF` 后面紧跟多行 URL,每行代表该频道的一条线路。通过在 URL 末尾添加 `$标签` 后缀,可以为每条线路指定显示标签(如清晰度)。`$标签` 必须位于整个 URL 的最末尾。 只有在支持标签和频道聚合的播放器中(例如 [内置 Web 播放器](./web-player.md))才有效果。 ### 示例输入 -```m3u -#EXTINF:-1 tvg-id="广东卫视" tvg-name="广东卫视" tvg-logo="https://example.com/logo/广东卫视.png" group-title="卫视",广东卫视 -rtp://239.253.64.96:5140/?fcc=10.255.75.73:15970$超高清 -#EXTINF:-1 tvg-id="广东卫视" tvg-name="广东卫视" tvg-logo="https://example.com/logo/广东卫视.png" group-title="卫视",广东卫视 -rtp://239.253.64.200:5140/?fcc=10.255.75.73:15970$高清 -#EXTINF:-1 tvg-id="广东卫视" tvg-name="广东卫视" tvg-logo="https://example.com/logo/广东卫视.png" group-title="卫视",广东卫视 -rtp://239.253.64.44:5140/?fcc=10.255.75.73:15970$标清 -``` - -也可以只写一行 `#EXTINF`,后面紧跟多行 URL,每行代表该频道的一条线路,效果与上面重复 `#EXTINF` 的写法完全相同: - ```m3u #EXTINF:-1 tvg-id="广东卫视" tvg-name="广东卫视" tvg-logo="https://example.com/logo/广东卫视.png" group-title="卫视",广东卫视 rtp://239.253.64.96:5140/?fcc=10.255.75.73:15970$超高清 @@ -214,7 +203,7 @@ rtp://239.253.64.44:5140/?fcc=10.255.75.73:15970$标清 ### 示例输出 -每个带 `$label` 的频道会生成独立的服务路径,`$label` 转换为 `/label` 子路径,同时 `$label` 保留在转换后 URL 的末尾。无论输入采用哪种写法,转换后的 M3U 都会为每条线路输出一组独立的 `#EXTINF` + URL: +每条线路会生成独立的服务路径,`$label` 转换为 `/label` 子路径,同时 `$label` 保留在转换后 URL 的末尾。为兼容第三方播放器,转换后的 M3U 中每条线路都会输出为独立的 `#EXTINF` + URL 条目: ```m3u #EXTINF:-1 tvg-id="广东卫视" tvg-name="广东卫视" tvg-logo="https://example.com/logo/广东卫视.png" group-title="卫视",广东卫视 diff --git a/docs/guide/web-player.md b/docs/guide/web-player.md index 21c15c56..77a1ec5e 100644 --- a/docs/guide/web-player.md +++ b/docs/guide/web-player.md @@ -99,20 +99,11 @@ LG webOS 智能电视同样支持通过内置浏览器使用播放器,并将 ## 频道聚合 -当 M3U 中存在多个**同组同名**的频道时,播放器会自动将它们聚合为一个频道的多个源,在频道列表中只显示一次。用户可以通过线路选择器切换不同线路(如不同清晰度): +在 M3U 中,一行 `#EXTINF` 后面紧跟多行 URL,即表示该频道拥有多条线路。播放器会将它们聚合为一个频道,在频道列表中只显示一次,用户可以通过线路选择器切换不同线路(如不同清晰度): ![频道源选择器](../images/channel-source-selector.png) -以下两种 M3U 写法都会被聚合为一个频道的多条线路: - ```m3u -# 写法一:重复 #EXTINF,每行一个源 -#EXTINF:-1 group-title="卫视",广东卫视 -http://192.168.1.1:5140/rtp/239.253.64.96:5140$超高清 -#EXTINF:-1 group-title="卫视",广东卫视 -http://192.168.1.1:5140/rtp/239.253.64.200:5140$高清 - -# 写法二:一个 #EXTINF 后紧跟多行 URL #EXTINF:-1 group-title="卫视",广东卫视 http://192.168.1.1:5140/rtp/239.253.64.96:5140$超高清 http://192.168.1.1:5140/rtp/239.253.64.200:5140$高清 From 5558a845fcb667f3c4cb4928660c40885659a54b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 01:19:26 +0000 Subject: [PATCH 5/6] fix(m3u): preserve input shape for entries with multiple URL lines Write the EXTINF line once and list the rewritten URLs directly beneath it instead of re-emitting EXTINF per URL, so the transformed playlist mirrors the source playlist. The blank separator between entries is deferred until the next tag so single-URL output is unchanged. Co-authored-by: Stackie Jia --- e2e/test_m3u.py | 86 ++++++++++++++++++++++++++++++++++++++++++++----- src/m3u.c | 47 ++++++++++++++++++++------- 2 files changed, 114 insertions(+), 19 deletions(-) diff --git a/e2e/test_m3u.py b/e2e/test_m3u.py index f2afe124..13ae4b12 100644 --- a/e2e/test_m3u.py +++ b/e2e/test_m3u.py @@ -976,10 +976,11 @@ def test_multi_label_urls_contain_labels(self, r2h_binary): class TestM3UMultiURLPerEXTINF: """A single #EXTINF followed by several URL lines describes one channel - with multiple sources. Each URL must be kept and re-paired with the - EXTINF line so aggregating players see them as one channel.""" + with multiple sources. Every URL must get its own service, and the + transformed playlist must keep the same shape: one EXTINF line followed + by the rewritten URLs.""" - def test_each_url_line_emitted_with_extinf(self, r2h_binary): + def test_shape_preserved_and_every_url_rewritten(self, r2h_binary): port = find_free_port() config = f"""\ [global] @@ -1006,7 +1007,6 @@ def test_each_url_line_emitted_with_extinf(self, r2h_binary): extinf_lines = [line for line in lines if line.startswith("#EXTINF")] url_lines = [line for line in lines if line.startswith("http")] assert extinf_lines == [ - '#EXTINF:-1 group-title="Group 1",Channel 3', '#EXTINF:-1 group-title="Group 1",Channel 3', '#EXTINF:-1 group-title="Group 1",Channel 4', ] @@ -1014,15 +1014,17 @@ def test_each_url_line_emitted_with_extinf(self, r2h_binary): assert url_lines[0].endswith("/Channel%203/Line%201$Line 1") assert url_lines[1].endswith("/Channel%203/Line%202$Line 2") assert url_lines[2].endswith("/Channel%204") - # EXTINF/URL pairs must alternate so every URL keeps its metadata + # Input shape is kept: one EXTINF, then its URLs, then the next entry assert lines[1:] == [ extinf_lines[0], url_lines[0], - extinf_lines[1], url_lines[1], - extinf_lines[2], + extinf_lines[1], url_lines[2], ] + # The two entries are still separated by exactly one blank line + raw = body.decode() + assert f"{url_lines[0]}\n{url_lines[1]}\n\n{extinf_lines[1]}\n" in raw for path in ("/Group%201/Channel%203/Line%201", "/Group%201/Channel%203/Line%202"): head_status, _, _ = http_request("127.0.0.1", port, "HEAD", path) @@ -1054,7 +1056,75 @@ def test_unlabeled_urls_get_unique_service_paths(self, r2h_binary): url_lines = [line for line in text.splitlines() if line.startswith("http")] assert len(url_lines) == 2 assert len(set(url_lines)) == 2, "each source needs its own service path" - assert text.count("#EXTINF:-1,News") == 2 + assert text.count("#EXTINF:-1,News") == 1 + finally: + r2h.stop() + + def test_repeated_extinf_input_keeps_repeated_extinf_output(self, r2h_binary): + """The converter mirrors the input: repeated EXTINF stays repeated.""" + port = find_free_port() + config = f"""\ +[global] +verbosity = 4 + +[bind] +* {port} + +[services] +#EXTM3U +#EXTINF:-1,News +rtp://239.0.0.1:1234$HD +#EXTINF:-1,News +rtp://239.0.0.2:1234$SD +""" + r2h = R2HProcess(r2h_binary, port, config_content=config) + try: + r2h.start() + status, _, body = http_get("127.0.0.1", port, "/playlist.m3u") + assert status == 200 + lines = [line for line in body.decode().splitlines() if line] + assert lines[1:] == [ + "#EXTINF:-1,News", + lines[2], + "#EXTINF:-1,News", + lines[4], + ] + assert lines[2].endswith("/News/HD$HD") + assert lines[4].endswith("/News/SD$SD") + finally: + r2h.stop() + + def test_catchup_rewritten_once_for_multi_url_entry(self, r2h_binary): + """catchup-source lives on the single EXTINF line, so it is rewritten + once (against the first source) and both URLs follow it.""" + port = find_free_port() + config = f"""\ +[global] +verbosity = 4 + +[bind] +* {port} + +[services] +#EXTM3U +#EXTINF:-1 catchup="default" catchup-source="rtsp://10.0.0.50:554/playback?seek={{utc:YmdHMS}}-{{utcend:YmdHMS}}",Catchup Ch +rtp://239.0.0.1:1234$HD +rtp://239.0.0.2:1234$SD +""" + r2h = R2HProcess(r2h_binary, port, config_content=config) + try: + r2h.start() + status, _, body = http_get("127.0.0.1", port, "/playlist.m3u") + text = body.decode() + assert status == 200 + assert text.count("#EXTINF") == 1 + _, catchup_url = extract_catchup_source(text, "Catchup Ch") + assert "/Catchup%20Ch/HD/catchup" in catchup_url + url_lines = [line for line in text.splitlines() if line.startswith("http")] + assert [line.rsplit("/", 1)[-1] for line in url_lines] == ["HD$HD", "SD$SD"] + for path in ("/Catchup%20Ch/HD", "/Catchup%20Ch/SD"): + head_status, _, _ = http_request("127.0.0.1", port, "HEAD", path) + assert head_status == 200, f"{path} should resolve to a service" finally: r2h.stop() diff --git a/src/m3u.c b/src/m3u.c index cc9f9aab..c6f488a9 100644 --- a/src/m3u.c +++ b/src/m3u.c @@ -1096,10 +1096,14 @@ int m3u_parse_and_create_services(const char *content, const char *source_url) { struct m3u_extinf current_extinf; int in_entry = 0; /* Set once the current #EXTINF has consumed a URL line. Further URL lines - * under the same #EXTINF are treated as additional sources of that channel - * and each re-emits the EXTINF line, so downstream players that aggregate - * same-group same-name entries see them as one channel with multiple sources. */ + * under the same #EXTINF are additional sources of that channel: they get + * their own service but are written directly below the first URL, so the + * transformed playlist keeps the same shape as the input. */ int entry_has_url = 0; + /* Blank separator owed after the entry currently being written. It is + * flushed lazily (before the next tag or at end of input) so that extra URL + * lines of the same entry stay contiguous. */ + int pending_entry_gap = 0; int entry_count = 0; size_t line_len; char proxy_url[MAX_URL_LENGTH]; @@ -1157,6 +1161,12 @@ int m3u_parse_and_create_services(const char *content, const char *source_url) { continue; } + /* Any tag line ends the entry being written; emit the deferred gap */ + if (line[0] == '#' && pending_entry_gap) { + append_to_transformed_m3u("\n", service_source); + pending_entry_gap = 0; + } + /* Handle M3U header */ if (m3u_is_header(line)) { /* Extract EPG URL from header if present */ @@ -1244,6 +1254,11 @@ int m3u_parse_and_create_services(const char *content, const char *source_url) { /* Process URL line (follows EXTINF). After the first URL, only lines that * look like URLs are accepted as additional sources of the same entry. */ if (in_entry && line[0] != '#' && (!entry_has_url || m3u_line_looks_like_url(line))) { + /* The EXTINF line is written once, together with the first URL. It can + * carry only one catchup-source, so catchup services are created for the + * first URL only; additional URLs just contribute their own live service. */ + int first_url = !entry_has_url; + /* Extract $label suffix from URL end before any processing */ const char *url_label = http_find_url_label(line); char url_label_copy[MAX_SERVICE_NAME]; @@ -1284,7 +1299,7 @@ int m3u_parse_and_create_services(const char *content, const char *source_url) { http_strip_url_label(line_without_label); /* Create catchup service if present and URL is recognizable */ - if (current_extinf.has_catchup && strlen(current_extinf.catchup_source) > 0) { + if (first_url && current_extinf.has_catchup && strlen(current_extinf.catchup_source) > 0) { catchup_is_recognizable = is_url_recognizable(current_extinf.catchup_source); if (!catchup_is_recognizable && is_url_recognizable(line_without_label) && @@ -1320,7 +1335,9 @@ int m3u_parse_and_create_services(const char *content, const char *source_url) { } /* Now generate the transformed EXTINF line with unique names */ - if (unique_catchup_name && catchup_is_recognizable) { + if (!first_url) { + /* EXTINF already written with the first URL of this entry */ + } else if (unique_catchup_name && catchup_is_recognizable) { /* Replace catchup-source URL in EXTINF line */ char *catchup_query = extract_catchup_template_query(catchup_service_url); char catchup_proxy_url[MAX_URL_LENGTH]; @@ -1379,28 +1396,36 @@ int m3u_parse_and_create_services(const char *content, const char *source_url) { free(unique_service_name); } else { /* Failed to create service, preserve original EXTINF and URL */ - append_to_transformed_m3u(transformed_line, service_source); - append_to_transformed_m3u("\n", service_source); + if (first_url) { + append_to_transformed_m3u(transformed_line, service_source); + append_to_transformed_m3u("\n", service_source); + } append_to_transformed_m3u(line, service_source); append_to_transformed_m3u("\n", service_source); } } else { /* Unrecognizable URL: preserve original EXTINF and URL completely */ - append_to_transformed_m3u(transformed_line, service_source); - append_to_transformed_m3u("\n", service_source); + if (first_url) { + append_to_transformed_m3u(transformed_line, service_source); + append_to_transformed_m3u("\n", service_source); + } append_to_transformed_m3u(line, service_source); append_to_transformed_m3u("\n", service_source); logger(LOG_DEBUG, "Preserving unrecognizable URL: %s", line); } - /* Add blank line after each entry */ - append_to_transformed_m3u("\n", service_source); + /* Blank line after the entry is deferred: more URLs may follow */ + pending_entry_gap = 1; entry_count++; entry_has_url = 1; } } + if (pending_entry_gap) { + append_to_transformed_m3u("\n", service_source); + } + /* Mark the end of inline content if this was inline parsing */ if (service_source == SERVICE_SOURCE_INLINE) { m3u_cache.transformed_m3u_inline_end = m3u_cache.transformed_m3u_used; From 4341cb48967e1be671b869d04f898c5c47681225 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 01:19:26 +0000 Subject: [PATCH 6/6] docs(m3u): show converted playlist keeping one #EXTINF with multiple URLs Co-authored-by: Stackie Jia --- docs/en/guide/m3u-integration.md | 6 +----- docs/guide/m3u-integration.md | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/docs/en/guide/m3u-integration.md b/docs/en/guide/m3u-integration.md index d49e6073..84533a0f 100644 --- a/docs/en/guide/m3u-integration.md +++ b/docs/en/guide/m3u-integration.md @@ -203,16 +203,12 @@ rtp://239.253.64.44:5140/?fcc=10.255.75.73:15970$SD ### Example Output -Each source generates an independent service path, with `$label` converted to a `/label` subpath, and `$label` also preserved at the end of the converted URL. For compatibility with third-party players, the converted M3U emits a separate `#EXTINF` + URL entry for every source: +Each source generates an independent service path, with `$label` converted to a `/label` subpath, and `$label` also preserved at the end of the converted URL. The converted M3U keeps the same structure as the input: ```m3u #EXTINF:-1 tvg-id="Guangdong TV" tvg-name="Guangdong TV" tvg-logo="https://example.com/logo/GuangdongTV.png" group-title="Satellite",Guangdong TV http://192.168.1.1:5140/Satellite/Guangdong TV/UHD$UHD - -#EXTINF:-1 tvg-id="Guangdong TV" tvg-name="Guangdong TV" tvg-logo="https://example.com/logo/GuangdongTV.png" group-title="Satellite",Guangdong TV http://192.168.1.1:5140/Satellite/Guangdong TV/HD$HD - -#EXTINF:-1 tvg-id="Guangdong TV" tvg-name="Guangdong TV" tvg-logo="https://example.com/logo/GuangdongTV.png" group-title="Satellite",Guangdong TV http://192.168.1.1:5140/Satellite/Guangdong TV/SD$SD ``` diff --git a/docs/guide/m3u-integration.md b/docs/guide/m3u-integration.md index 6a34dc2f..57ef3e16 100644 --- a/docs/guide/m3u-integration.md +++ b/docs/guide/m3u-integration.md @@ -203,16 +203,12 @@ rtp://239.253.64.44:5140/?fcc=10.255.75.73:15970$标清 ### 示例输出 -每条线路会生成独立的服务路径,`$label` 转换为 `/label` 子路径,同时 `$label` 保留在转换后 URL 的末尾。为兼容第三方播放器,转换后的 M3U 中每条线路都会输出为独立的 `#EXTINF` + URL 条目: +每条线路会生成独立的服务路径,`$label` 转换为 `/label` 子路径,同时 `$label` 保留在转换后 URL 的末尾。转换后的 M3U 保持与输入相同的结构: ```m3u #EXTINF:-1 tvg-id="广东卫视" tvg-name="广东卫视" tvg-logo="https://example.com/logo/广东卫视.png" group-title="卫视",广东卫视 http://192.168.1.1:5140/卫视/广东卫视/超高清$超高清 - -#EXTINF:-1 tvg-id="广东卫视" tvg-name="广东卫视" tvg-logo="https://example.com/logo/广东卫视.png" group-title="卫视",广东卫视 http://192.168.1.1:5140/卫视/广东卫视/高清$高清 - -#EXTINF:-1 tvg-id="广东卫视" tvg-name="广东卫视" tvg-logo="https://example.com/logo/广东卫视.png" group-title="卫视",广东卫视 http://192.168.1.1:5140/卫视/广东卫视/标清$标清 ```