Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 2 additions & 8 deletions docs/en/guide/m3u-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).

Expand All @@ -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
```

Expand Down
8 changes: 7 additions & 1 deletion docs/en/guide/web-player.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
10 changes: 2 additions & 8 deletions docs/guide/m3u-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ http://iptv.example.com/live/channel1.m3u8

## 线路标签

通过在 URL 末尾添加 `$标签` 后缀,可以为频道源指定显示标签(如清晰度)。`$标签` 必须位于整个 URL 的最末尾。
一行 `#EXTINF` 后面紧跟多行 URL,每行代表该频道的一条线路。通过在 URL 末尾添加 `$标签` 后缀,可以为每条线路指定显示标签(如清晰度)。`$标签` 必须位于整个 URL 的最末尾。

只有在支持标签和频道聚合的播放器中(例如 [内置 Web 播放器](./web-player.md))才有效果。

Expand All @@ -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/卫视/广东卫视/标清$标清
```

Expand Down
8 changes: 7 additions & 1 deletion docs/guide/web-player.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
183 changes: 183 additions & 0 deletions e2e/test_m3u.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
Loading
Loading