diff --git a/docs/en/guide/m3u-integration.md b/docs/en/guide/m3u-integration.md index 4e17dbfd..84533a0f 100644 --- a/docs/en/guide/m3u-integration.md +++ b/docs/en/guide/m3u-integration.md @@ -188,7 +188,7 @@ 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)). @@ -197,24 +197,18 @@ This feature is only effective in players that support labels and channel aggreg ```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 ``` ### 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 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/en/guide/web-player.md b/docs/en/guide/web-player.md index 46b93a2f..8c7f717c 100644 --- a/docs/en/guide/web-player.md +++ b/docs/en/guide/web-player.md @@ -99,10 +99,16 @@ 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) +```m3u +#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..57ef3e16 100644 --- a/docs/guide/m3u-integration.md +++ b/docs/guide/m3u-integration.md @@ -188,7 +188,7 @@ http://iptv.example.com/live/channel1.m3u8 ## 线路标签 -通过在 URL 末尾添加 `$标签` 后缀,可以为频道源指定显示标签(如清晰度)。`$标签` 必须位于整个 URL 的最末尾。 +一行 `#EXTINF` 后面紧跟多行 URL,每行代表该频道的一条线路。通过在 URL 末尾添加 `$标签` 后缀,可以为每条线路指定显示标签(如清晰度)。`$标签` 必须位于整个 URL 的最末尾。 只有在支持标签和频道聚合的播放器中(例如 [内置 Web 播放器](./web-player.md))才有效果。 @@ -197,24 +197,18 @@ http://iptv.example.com/live/channel1.m3u8 ```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$标清 ``` ### 示例输出 -每个带 `$label` 的频道会生成独立的服务路径,`$label` 转换为 `/label` 子路径,同时 `$label` 保留在转换后 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/卫视/广东卫视/标清$标清 ``` diff --git a/docs/guide/web-player.md b/docs/guide/web-player.md index 742e6c44..77a1ec5e 100644 --- a/docs/guide/web-player.md +++ b/docs/guide/web-player.md @@ -99,10 +99,16 @@ LG webOS 智能电视同样支持通过内置浏览器使用播放器,并将 ## 频道聚合 -当 M3U 中存在多个**同组同名**的频道时,播放器会自动将它们聚合为一个频道的多个源,在频道列表中只显示一次。用户可以通过线路选择器切换不同线路(如不同清晰度): +在 M3U 中,一行 `#EXTINF` 后面紧跟多行 URL,即表示该频道拥有多条线路。播放器会将它们聚合为一个频道,在频道列表中只显示一次,用户可以通过线路选择器切换不同线路(如不同清晰度): ![频道源选择器](../images/channel-source-selector.png) +```m3u +#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] diff --git a/e2e/test_m3u.py b/e2e/test_m3u.py index 6c9894c0..13ae4b12 100644 --- a/e2e/test_m3u.py +++ b/e2e/test_m3u.py @@ -974,6 +974,189 @@ 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. 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_shape_preserved_and_every_url_rewritten(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 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") + # Input shape is kept: one EXTINF, then its URLs, then the next entry + assert lines[1:] == [ + extinf_lines[0], + url_lines[0], + url_lines[1], + 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) + 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") == 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() + + 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..c6f488a9 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,15 @@ 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 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]; @@ -1142,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 */ @@ -1176,6 +1201,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 +1247,18 @@ 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))) { + /* 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]; @@ -1266,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) && @@ -1302,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]; @@ -1361,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++; - in_entry = 0; + 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; 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); } }