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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# <img src="./web-ui/public/assets/icon.png" width="24" height="24"> rtp2httpd - IPTV 流媒体转发服务器
# <img src="./web-ui/public/assets/icon-192.png" width="24" height="24"> rtp2httpd - IPTV 流媒体转发服务器

[>> 访问官方文档网站 <<](https://rtp2httpd.com)

Expand Down
2 changes: 1 addition & 1 deletion docs/en/guide/web-player.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ The player page path can be customized via the `player-page-path` configuration

The built-in web player supports PWA (Progressive Web App). You can add the player page to your device's home screen (including phones, tablets, computers, and LG webOS smart TVs) and launch it like a native app for a full-screen, immersive viewing experience.

After adding to the home screen, the desktop icon is named **IPTV** by default and uses the built-in app icon. The player retains local settings such as theme preferences.
After adding to the home screen, the desktop icon is named **R2H Player** by default and uses the built-in app icon. The player retains local settings such as theme preferences.

### iOS / iPadOS (Safari)

Expand Down
2 changes: 1 addition & 1 deletion docs/guide/web-player.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ http://192.168.1.1:5140/player

内置 Web 播放器支持 PWA(Progressive Web App)。你可以将播放器页面添加到设备主屏幕(包括手机、平板、电脑,以及 LG webOS 智能电视),像打开原生应用一样一键进入,获得全屏、沉浸式的观看体验。

添加到主屏幕后,桌面图标名称默认为 **IPTV**,并会使用内置的应用图标。播放器会保留主题等本地设置。
添加到主屏幕后,桌面图标名称默认为 **R2H 播放器**,并会使用内置的应用图标。播放器会保留主题等本地设置。

### iOS / iPadOS(Safari)

Expand Down
236 changes: 226 additions & 10 deletions e2e/test_pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@
playlist endpoint.
"""

import gzip
import json
import os
import signal
import struct
import time
from urllib.parse import quote

import pytest

Expand Down Expand Up @@ -44,6 +49,17 @@ def _wait_for_http_status(port: int, path: str, expected: int = 200, timeout: fl
raise AssertionError(f"{path} did not return {expected} before timeout; last status was {last_status}")


def _parse_manifest(body: bytes) -> dict:
return json.loads(body.decode("utf-8"))


def _png_dimensions(body: bytes, content_encoding: str) -> tuple[int, int]:
if content_encoding == "gzip":
body = gzip.decompress(body)
assert body.startswith(b"\x89PNG\r\n\x1a\n")
return struct.unpack(">II", body[16:24])


# ---------------------------------------------------------------------------
# Module-scoped shared rtp2httpd instance
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -105,8 +121,6 @@ def test_status_returns_html(self, basic_r2h):
def test_status_contains_info(self, basic_r2h):
"""Status page should contain recognizable content.
The embedded HTML may be gzip-compressed; request uncompressed."""
import gzip

_, hdrs, body = http_get(
"127.0.0.1",
basic_r2h.port,
Expand All @@ -119,6 +133,14 @@ def test_status_contains_info(self, basic_r2h):
text = body.decode("utf-8", errors="replace").lower()
assert "<html" in text or "<!doctype" in text

def test_status_contains_install_metadata(self, basic_r2h):
_, _, body = http_get("127.0.0.1", basic_r2h.port, "/status", headers={"Accept-Encoding": "identity"})
text = body.decode("utf-8")
assert 'rel="manifest" href="./status.webmanifest" crossorigin="use-credentials"' in text
assert 'rel="icon" type="image/png" sizes="192x192" href="./assets/icon-192.png"' in text
assert 'rel="apple-touch-icon" sizes="192x192" href="./assets/icon-192.png"' in text
assert 'name="apple-mobile-web-app-title" content="R2H 面板"' in text


# ---------------------------------------------------------------------------
# Player page
Expand All @@ -135,6 +157,110 @@ def test_player_returns_html(self, basic_r2h):
assert "html" in ct.lower()
assert len(body) > 100

def test_player_contains_install_metadata(self, basic_r2h):
_, _, body = http_get("127.0.0.1", basic_r2h.port, "/player", headers={"Accept-Encoding": "identity"})
text = body.decode("utf-8")
assert 'rel="manifest" href="./player.webmanifest" crossorigin="use-credentials"' in text
assert 'rel="icon" type="image/png" sizes="192x192" href="./assets/icon-192.png"' in text
assert 'rel="apple-touch-icon" sizes="192x192" href="./assets/icon-192.png"' in text
assert 'name="apple-mobile-web-app-title" content="R2H 播放器"' in text


# ---------------------------------------------------------------------------
# Web App Manifests
# ---------------------------------------------------------------------------


class TestWebAppManifests:
@pytest.mark.parametrize(
("manifest_path", "name", "short_name", "english_name", "english_short_name", "start_url"),
[
("/status.webmanifest", "rtp2httpd 面板", "R2H 面板", "rtp2httpd Status", "R2H Status", "/status"),
(
"/player.webmanifest",
"rtp2httpd 播放器",
"R2H 播放器",
"rtp2httpd Player",
"R2H Player",
"/player",
),
],
)
def test_default_manifest(
self,
basic_r2h,
manifest_path,
name,
short_name,
english_name,
english_short_name,
start_url,
):
status, hdrs, body = http_get("127.0.0.1", basic_r2h.port, manifest_path)
assert status == 200
assert "application/manifest+json" in get_header(hdrs, "Content-Type")
assert get_header(hdrs, "Cache-Control") == "no-cache"

manifest = _parse_manifest(body)
assert manifest["lang"] == "zh-Hans"
assert manifest["dir"] == "ltr"
assert manifest["name"] == name
assert manifest["short_name"] == short_name
assert manifest["name_localized"] == {"zh-Hans": name, "zh-Hant": name, "en": english_name}
assert manifest["short_name_localized"] == {
"zh-Hans": short_name,
"zh-Hant": short_name,
"en": english_short_name,
}
assert manifest["id"] == start_url
assert manifest["scope"] == start_url
assert manifest["start_url"] == start_url
assert manifest["display"] == "standalone"
expected_dark_color = "#090b1a" if manifest_path == "/status.webmanifest" else "#050b18"
assert manifest["theme_color"] == expected_dark_color
assert manifest["background_color"] == expected_dark_color
assert manifest["icons"] == [
{"src": "/assets/icon-192.png", "sizes": "192x192", "type": "image/png"},
{"src": "/assets/icon-512.png", "sizes": "512x512", "type": "image/png"},
]

@pytest.mark.parametrize(("path", "expected_size"), [("/assets/icon-192.png", 192), ("/assets/icon-512.png", 512)])
def test_manifest_icon_dimensions(self, basic_r2h, path, expected_size):
status, hdrs, body = http_get("127.0.0.1", basic_r2h.port, path)
assert status == 200
assert get_header(hdrs, "Content-Type") == "image/png"
assert _png_dimensions(body, get_header(hdrs, "Content-Encoding")) == (expected_size, expected_size)

def test_root_page_path_manifest(self, r2h_binary):
port = find_free_port()
config = f"""\
[global]
status-page-path = ////
player-page-path = //watch///
app-path-prefix = ///app///

[bind]
* {port}
"""
r2h = R2HProcess(r2h_binary, port, config_content=config)
try:
r2h.start()
status, _, body = http_get("127.0.0.1", port, "/app/status.webmanifest")
assert status == 200
manifest = _parse_manifest(body)
assert manifest["id"] == "/app/"
assert manifest["scope"] == "/app/"
assert manifest["start_url"] == "/app/"

status, _, body = http_get("127.0.0.1", port, "/app/player.webmanifest")
assert status == 200
manifest = _parse_manifest(body)
assert manifest["id"] == "/app/watch"
assert manifest["scope"] == "/app/watch"
assert manifest["start_url"] == "/app/watch"
finally:
r2h.stop()


# ---------------------------------------------------------------------------
# Root / and /playlist.m3u
Expand Down Expand Up @@ -218,8 +344,25 @@ def test_prefixed_player_html(self, prefixed_r2h):
assert "html" in get_header(hdrs, "Content-Type").lower()
assert len(body) > 100

@pytest.mark.parametrize(
("manifest_path", "start_url"),
[
(f"{APP_PREFIX}/status.webmanifest", f"{APP_PREFIX}/status"),
(f"{APP_PREFIX}/player.webmanifest", f"{APP_PREFIX}/player"),
],
)
def test_prefixed_manifest_paths(self, prefixed_r2h, manifest_path, start_url):
status, _, body = http_get("127.0.0.1", prefixed_r2h.port, manifest_path)
assert status == 200
manifest = _parse_manifest(body)
assert manifest["id"] == start_url
assert manifest["scope"] == start_url
assert manifest["start_url"] == start_url
assert manifest["icons"][0]["src"] == f"{APP_PREFIX}/assets/icon-192.png"
assert manifest["icons"][1]["src"] == f"{APP_PREFIX}/assets/icon-512.png"

def test_prefixed_static_asset(self, prefixed_r2h):
status, hdrs, body = http_get("127.0.0.1", prefixed_r2h.port, f"{APP_PREFIX}/assets/icon.png")
status, hdrs, body = http_get("127.0.0.1", prefixed_r2h.port, f"{APP_PREFIX}/assets/icon-192.png")
assert status == 200
assert "image/png" in get_header(hdrs, "Content-Type").lower()
assert len(body) > 0
Expand Down Expand Up @@ -254,7 +397,7 @@ def test_prefixed_status_sse(self, prefixed_r2h):
[
"/status",
"/player",
"/assets/icon.png",
"/assets/icon-192.png",
"/playlist.m3u",
"/app/rtp2httpd2/status",
],
Expand All @@ -265,30 +408,103 @@ def test_unprefixed_or_boundary_mismatch_routes_404(self, prefixed_r2h, path):

def test_token_cookie_path_uses_app_prefix(self, r2h_binary):
port = find_free_port()
token = "secret token;&$"
encoded_token = quote(token, safe="/")
config = f"""\
[global]
verbosity = 4
app-path-prefix = {APP_PREFIX}
r2h-token = secret-token
r2h-token = {token}

[bind]
* {port}
"""
r2h = R2HProcess(r2h_binary, port, config_content=config)
try:
r2h.start()
status, hdrs, _ = http_get("127.0.0.1", port, f"{APP_PREFIX}/status?r2h-token=secret-token")
status, _, _ = http_get("127.0.0.1", port, f"{APP_PREFIX}/status.webmanifest")
assert status == 401

status, hdrs, _ = http_get("127.0.0.1", port, f"{APP_PREFIX}/status?r2h-token={encoded_token}")
assert status == 200
set_cookie = get_header(hdrs, "Set-Cookie")
assert "r2h-token=secret-token" in set_cookie
assert f"r2h-token={encoded_token}" in set_cookie
assert f"Path={APP_PREFIX}" in set_cookie

status, _, _ = http_get(
cookie = f"r2h-token={encoded_token}"
status, _, body = http_get(
"127.0.0.1",
port,
f"{APP_PREFIX}/status.webmanifest",
headers={"Cookie": cookie},
)
assert status == 200
manifest = _parse_manifest(body)
assert manifest["id"] == f"{APP_PREFIX}/status"
assert manifest["scope"] == f"{APP_PREFIX}/status"
assert manifest["start_url"] == f"{APP_PREFIX}/status?r2h-token={encoded_token}"

status, hdrs, _ = http_get("127.0.0.1", port, manifest["start_url"])
assert status == 200
assert f"r2h-token={encoded_token}" in get_header(hdrs, "Set-Cookie")
finally:
r2h.stop()

def test_manifest_updates_after_config_reload(self, r2h_binary):
port = find_free_port()
old_config = f"""\
[global]
workers = 1
app-path-prefix = /old
status-page-path = /old-status
r2h-token = old-token

[bind]
* {port}
"""
new_config = f"""\
[global]
workers = 1
app-path-prefix = /new
status-page-path = /new-status
r2h-token = new-token

[bind]
* {port}
"""
r2h = R2HProcess(r2h_binary, port, config_content=old_config)
try:
r2h.start()
status, _, body = http_get(
"127.0.0.1",
port,
f"{APP_PREFIX}/status",
headers={"Cookie": "r2h-token=secret-token"},
"/old/status.webmanifest?r2h-token=old-token",
)
assert status == 200
assert _parse_manifest(body)["start_url"] == "/old/old-status?r2h-token=old-token"

assert r2h._config_path is not None
with open(r2h._config_path, "w") as config_file:
config_file.write(new_config)
assert r2h.process is not None
os.kill(r2h.process.pid, signal.SIGHUP)

deadline = time.monotonic() + 5
while True:
status, _, body = http_get(
"127.0.0.1",
port,
"/new/status.webmanifest?r2h-token=new-token",
)
if status == 200:
break
if time.monotonic() >= deadline:
raise AssertionError(f"reloaded manifest did not become available; last status was {status}")
time.sleep(0.05)

manifest = _parse_manifest(body)
assert manifest["id"] == "/new/new-status"
assert manifest["scope"] == "/new/new-status"
assert manifest["start_url"] == "/new/new-status?r2h-token=new-token"
finally:
r2h.stop()
2 changes: 1 addition & 1 deletion scripts/build-ikuai-ipkg.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ STAGING_ROOT="$SUPPORT_DIR/.staging"
STAGING_PACKAGE="$STAGING_ROOT/rtp2httpd"
DIST_DIR="$SUPPORT_DIR/dist"
MANIFEST="$PACKAGE_SRC/manifest.json"
ICON_SOURCE="$PROJECT_ROOT/web-ui/public/assets/icon.png"
ICON_SOURCE="$PROJECT_ROOT/web-ui/public/assets/icon-192.png"

VERSION=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"v\{0,1\}\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
VERSION=${1:-$VERSION}
Expand Down
14 changes: 14 additions & 0 deletions src/connection.c
Original file line number Diff line number Diff line change
Expand Up @@ -922,6 +922,20 @@ int connection_route_and_start(connection_t *c) {
c->should_set_r2h_cookie = (source == TOKEN_SOURCE_QUERY);
}

const char *status_manifest_route = "status.webmanifest";
size_t status_manifest_route_len = strlen(status_manifest_route);
if (status_manifest_route_len == path_len && strncmp(service_path, status_manifest_route, path_len) == 0) {
handle_web_app_manifest(c, false);
return 0;
}

const char *player_manifest_route = "player.webmanifest";
size_t player_manifest_route_len = strlen(player_manifest_route);
if (player_manifest_route_len == path_len && strncmp(service_path, player_manifest_route, path_len) == 0) {
handle_web_app_manifest(c, true);
return 0;
}

const char *status_route = config.status_page_route ? config.status_page_route : "status";
size_t status_route_len = strlen(status_route);
char status_sse_route[HTTP_URL_BUFFER_SIZE];
Expand Down
Loading
Loading