5 Commits

Author SHA1 Message Date
Stardream 8f63f20fdf feat: expand DRM playback and discovery support
Build and Push Docker Image / build (push) Successful in 13s
- native FairPlay HLS playback added for iOS/Safari with same-origin license proxy
- Widevine license proxy added to avoid browser-side CORS license failures
- DRM-specific playback URLs added for per-browser MPD/HLS variants
- admin DRM auto-discovery added for DASH Widevine and HLS FairPlay manifests
- DRM stream probing now checks DRM-specific playback URLs
- Shaka quality selector deduplicates audio variants while preserving same-resolution bitrates
- DRM proxy source and config index matching stabilized after source reordering
- DRM manifest discovery rejects private, loopback, link-local, and redirect targets
- Android Telegram WebView Widevine handling now probes key-system capability before blocking
2026-05-25 02:19:30 +10:00
Stardream 2b0aaf3a5d fix: security hardening and code review improvements
Build and Push Docker Image / build (push) Successful in 12s
- hls proxy now signs full URL instead of hostname only (SSRF)
- viewer token required to start session (prevents stats forgery and password bypass)
- weak SECRET_KEY placeholder replaced with REPLACE_ME, startup warning added
- api key auth drops query-string fallback, Bearer header only
- hls variant playlist DRM detection fetches first variant
- dash manifest DRM detection added
- android webview detection restricted to Telegram only
- privacy comment removed from ip hash (ip is stored in plaintext)
- csv export adds range filter (today/7d/30d/all), defaults to 30d
- analytics export button integrated as dropdown with range selection
- docker-compose placeholder defaults updated
- readme and readme.zh-cn updated for v1.2.0 changes
2026-05-24 01:40:03 +10:00
Stardream 601eb0247f feat: DRM playback with Widevine and FairPlay via Shaka Player
Build and Push Docker Image / build (push) Successful in 14s
2026-05-24 00:18:52 +10:00
Stardream 34de0bdef4 feat: mobile responsive layout for admin panel and file browser 2026-05-24 00:18:37 +10:00
Stardream 90fe42a81a fix: load-more button animates with card list on tab switch
Build and Push Docker Image / build (push) Successful in 23s
Replace display:none toggle with opacity/transform transition so the
button fades and slides in sync with #stream-list.is-switching (0.18s ease).
Tab switch now hides the button at the same time as the list.
2026-05-23 00:02:37 +10:00
9 changed files with 3469 additions and 100 deletions
+3
View File
@@ -5,4 +5,7 @@ __pycache__/
.env .env
.DS_Store .DS_Store
CHANGELOG.md CHANGELOG.md
CODEX_CHANGELOG.md
CODEX_TODO.md
CODEX_REVIEW.md
AGENTS.md AGENTS.md
+5 -4
View File
@@ -28,7 +28,7 @@
## ✨ Features ## ✨ Features
- **Public stream list** - Live and archive tabs, password-protected streams, custom site branding, bilingual UI (Chinese / English) with per-language site description - **Public stream list** - Live and archive tabs, password-protected streams, custom site branding, bilingual UI (Chinese / English) with per-language site description
- **Player** - HLS, FLV, MPEG-DASH playback via ArtPlayer; AES-128 key override and DASH ClearKey support - **Player** - HLS, FLV, MPEG-DASH playback via ArtPlayer; AES-128 key override and DASH ClearKey support; Widevine and FairPlay DRM playback via Shaka Player (multi-DRM configs per source, Android Telegram WebView detection)
- **Admin panel** - Add, edit, reorder, enable/disable streams; manage sources; drag-and-drop ordering - **Admin panel** - Add, edit, reorder, enable/disable streams; manage sources; drag-and-drop ordering
- **Viewer analytics** - Session tracking, unique visitors, peak concurrent viewers, average watch duration, device / browser / OS / geography breakdown, real-time dashboard, CSV export - **Viewer analytics** - Session tracking, unique visitors, peak concurrent viewers, average watch duration, device / browser / OS / geography breakdown, real-time dashboard, CSV export
- **Telegram notifications** - Per-stream push messages on stream start and stop - **Telegram notifications** - Per-stream push messages on stream start and stop
@@ -36,6 +36,7 @@
- **VOD / file serving** - Signed `/video/` URLs with HTTP Range support (seek-capable); publish any local video file or folder as an archive stream directly from the file browser - **VOD / file serving** - Signed `/video/` URLs with HTTP Range support (seek-capable); publish any local video file or folder as an archive stream directly from the file browser
- **HLS proxy** - Signed `/proxy/hls/` routes for cross-origin HLS playback - **HLS proxy** - Signed `/proxy/hls/` routes for cross-origin HLS playback
- **API key auth** - Generate per-key tokens in the admin panel for programmatic access to all admin and analytics endpoints - **API key auth** - Generate per-key tokens in the admin panel for programmatic access to all admin and analytics endpoints
- **Mobile responsive** - Admin panel sidebar, file browser rows, and push directory sidebar all collapse gracefully on narrow screens
<div align="right"> <div align="right">
@@ -142,7 +143,7 @@ Set these environment variables in `docker-compose.yml`:
| Variable | Default | Required | Description | | Variable | Default | Required | Description |
|---|---|---|---| |---|---|---|---|
| `SECRET_KEY` | `change-this-secret` | **Yes** | HMAC signing key for sessions and stream routes | | `SECRET_KEY` | `REPLACE_ME` | **Yes** | HMAC signing key for sessions and stream routes. Generate with `openssl rand -hex 32` |
| `DATABASE_URL` | see compose file | **Yes** | PostgreSQL connection string | | `DATABASE_URL` | see compose file | **Yes** | PostgreSQL connection string |
| `POSTGRES_PASSWORD` | see compose file | **Yes** | PostgreSQL password | | `POSTGRES_PASSWORD` | see compose file | **Yes** | PostgreSQL password |
| `TZ` | `UTC` | No | Container timezone, e.g. `Asia/Shanghai` | | `TZ` | `UTC` | No | Container timezone, e.g. `Asia/Shanghai` |
@@ -234,7 +235,7 @@ The hidden public HLS URL (`/h/<slug>/...`) routes through nginx on port `8889`,
### Authentication ### Authentication
- **Browser session** - cookie set by `POST /api?action=login` - **Browser session** - cookie set by `POST /api?action=login`
- **API key** - send `Authorization: Bearer <token>` header, or append `?api_key=<token>` to any admin endpoint. Keys are managed in the admin panel under **Site Settings → API Keys**. - **API key** - send `Authorization: Bearer <token>` header. Keys are managed in the admin panel under **Site Settings → API Keys**.
### Public Endpoints ### Public Endpoints
@@ -280,7 +281,7 @@ The hidden public HLS URL (`/h/<slug>/...`) routes through nginx on port `8889`,
| `GET` | `/api?action=stats_geo` | Country-level visitor counts | | `GET` | `/api?action=stats_geo` | Country-level visitor counts |
| `GET` | `/api?action=stats_stream_detail&id=<id>` | Single-stream detail | | `GET` | `/api?action=stats_stream_detail&id=<id>` | Single-stream detail |
| `GET` | `/api?action=stats_sessions_page&id=<id>` | Paginated session list | | `GET` | `/api?action=stats_sessions_page&id=<id>` | Paginated session list |
| `GET` | `/api?action=stats_export_csv` | Export sessions as CSV | | `GET` | `/api?action=stats_export_csv&range=<range>` | Export sessions as CSV (`range`: `today`, `7d`, `30d` (default), `all`) |
<div align="right"> <div align="right">
+5 -4
View File
@@ -28,7 +28,7 @@
## ✨ 功能特性 ## ✨ 功能特性
- **公开直播列表** - 直播 / 存档双 Tab,支持密码保护、自定义站点品牌,内置中英双语界面(含分语言站点简介) - **公开直播列表** - 直播 / 存档双 Tab,支持密码保护、自定义站点品牌,内置中英双语界面(含分语言站点简介)
- **播放器** - 基于 ArtPlayer,支持 HLS、FLV、MPEG-DASH 播放;支持 AES-128 密钥覆盖及 DASH ClearKey - **播放器** - 基于 ArtPlayer,支持 HLS、FLV、MPEG-DASH 播放;支持 AES-128 密钥覆盖及 DASH ClearKey;通过 Shaka Player 支持 Widevine 和 FairPlay DRM 播放(每路播放源可独立配置多 DRM 方案,内置 Android Telegram WebView 检测)
- **管理后台** - 直播的增删改查、启用/禁用、拖拽排序;多播放源管理 - **管理后台** - 直播的增删改查、启用/禁用、拖拽排序;多播放源管理
- **观看统计** - 会话追踪、独立访客数、峰值并发、平均时长、设备 / 浏览器 / 操作系统 / 地理分布实时看板,支持 CSV 导出 - **观看统计** - 会话追踪、独立访客数、峰值并发、平均时长、设备 / 浏览器 / 操作系统 / 地理分布实时看板,支持 CSV 导出
- **Telegram 推送** - 可按直播单独配置,开播 / 关播自动发送通知 - **Telegram 推送** - 可按直播单独配置,开播 / 关播自动发送通知
@@ -36,6 +36,7 @@
- **VOD 点播 / 视频服务** - 带 HMAC 签名的 `/video/` URL,支持 HTTP Range 请求(可 seek);文件浏览器中可直接将视频文件或文件夹发布为归档直播 - **VOD 点播 / 视频服务** - 带 HMAC 签名的 `/video/` URL,支持 HTTP Range 请求(可 seek);文件浏览器中可直接将视频文件或文件夹发布为归档直播
- **HLS 代理** - 带签名验证的 `/proxy/hls/` 路由,解决跨域 HLS 播放问题 - **HLS 代理** - 带签名验证的 `/proxy/hls/` 路由,解决跨域 HLS 播放问题
- **API 密钥鉴权** - 在后台生成 Token,可通过 API 密钥对所有管理及统计接口进行程序化访问 - **API 密钥鉴权** - 在后台生成 Token,可通过 API 密钥对所有管理及统计接口进行程序化访问
- **移动端适配** - 管理后台侧边栏、文件浏览器行、推流目录侧边栏均可在窄屏设备上自适应折叠
<div align="right"> <div align="right">
@@ -142,7 +143,7 @@ python server.py
| 变量 | 默认值 | 是否必填 | 说明 | | 变量 | 默认值 | 是否必填 | 说明 |
|---|---|---|---| |---|---|---|---|
| `SECRET_KEY` | `change-this-secret` | **必填** | 会话与推流路由的 HMAC 签名密钥 | | `SECRET_KEY` | `REPLACE_ME` | **必填** | 会话与推流路由的 HMAC 签名密钥。可用 `openssl rand -hex 32` 生成 |
| `DATABASE_URL` | 见 compose 文件 | **必填** | PostgreSQL 连接字符串 | | `DATABASE_URL` | 见 compose 文件 | **必填** | PostgreSQL 连接字符串 |
| `POSTGRES_PASSWORD` | 见 compose 文件 | **必填** | PostgreSQL 数据库密码 | | `POSTGRES_PASSWORD` | 见 compose 文件 | **必填** | PostgreSQL 数据库密码 |
| `TZ` | `UTC` | 否 | 容器时区,如 `Asia/Shanghai` | | `TZ` | `UTC` | 否 | 容器时区,如 `Asia/Shanghai` |
@@ -234,7 +235,7 @@ RTMP 服务器: rtmp://HOST:1935/live
### 鉴权方式 ### 鉴权方式
- **浏览器会话** - 通过 `POST /api?action=login` 登录后设置的 Cookie - **浏览器会话** - 通过 `POST /api?action=login` 登录后设置的 Cookie
- **API 密钥** - 在请求头携带 `Authorization: Bearer <token>`,或在 URL 追加 `?api_key=<token>`。密钥在后台**网站设置 → API 密钥**处管理。 - **API 密钥** - 在请求头携带 `Authorization: Bearer <token>`。密钥在后台**网站设置 → API 密钥**处管理。
### 公开接口 ### 公开接口
@@ -280,7 +281,7 @@ RTMP 服务器: rtmp://HOST:1935/live
| `GET` | `/api?action=stats_geo` | 地理分布(国家级) | | `GET` | `/api?action=stats_geo` | 地理分布(国家级) |
| `GET` | `/api?action=stats_stream_detail&id=<id>` | 单场直播详情 | | `GET` | `/api?action=stats_stream_detail&id=<id>` | 单场直播详情 |
| `GET` | `/api?action=stats_sessions_page&id=<id>` | 分页会话列表 | | `GET` | `/api?action=stats_sessions_page&id=<id>` | 分页会话列表 |
| `GET` | `/api?action=stats_export_csv` | 导出会话 CSV | | `GET` | `/api?action=stats_export_csv&range=<range>` | 导出会话 CSV`range``today``7d``30d`(默认)、`all` |
<div align="right"> <div align="right">
+4 -3
View File
@@ -10,8 +10,9 @@ services:
ports: ports:
- "8085:8080" - "8085:8080"
environment: environment:
SECRET_KEY: "change-this-secret" # Generate with: openssl rand -hex 32
DATABASE_URL: "postgresql://streamhall:streamhall_pg_password@postgres:5432/streamhall" SECRET_KEY: "REPLACE_ME"
DATABASE_URL: "postgresql://streamhall:REPLACE_DB_PASSWORD@postgres:5432/streamhall"
TZ: "UTC" TZ: "UTC"
RTMP_HOST: "srs" RTMP_HOST: "srs"
# Comma-separated list of video directories (optional label:path format) # Comma-separated list of video directories (optional label:path format)
@@ -29,7 +30,7 @@ services:
environment: environment:
POSTGRES_DB: "streamhall" POSTGRES_DB: "streamhall"
POSTGRES_USER: "streamhall" POSTGRES_USER: "streamhall"
POSTGRES_PASSWORD: "streamhall_pg_password" POSTGRES_PASSWORD: "REPLACE_DB_PASSWORD"
TZ: "UTC" TZ: "UTC"
volumes: volumes:
- ./postgres-data:/var/lib/postgresql/data - ./postgres-data:/var/lib/postgresql/data
+630 -39
View File
@@ -219,6 +219,10 @@
padding: 10px; padding: 10px;
} }
#admin-menu-toggle { display: none; }
#push-sidebar-toggle { display: none; }
#push-browser-layout { align-items: flex-start; }
.admin-menu-btn { .admin-menu-btn {
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 6px; border-radius: 6px;
@@ -364,6 +368,15 @@
outline: none; outline: none;
} }
select {
appearance: none;
padding-right: 34px !important;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E") !important;
background-repeat: no-repeat !important;
background-position: right 11px center !important;
background-size: 16px 16px !important;
}
textarea { textarea {
resize: vertical; resize: vertical;
} }
@@ -376,6 +389,116 @@
margin-bottom: 10px; margin-bottom: 10px;
} }
.link-remove-btn {
grid-column: -1;
grid-row: 1 / -1;
align-self: center;
min-height: 42px;
}
.link-row .link-drm-config {
grid-column: 2 / -2;
border: 1px solid var(--line);
border-radius: 7px;
background: rgba(100, 116, 139, 0.06);
overflow: hidden;
}
.link-row .link-drm-config > summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 9px 11px;
color: var(--muted);
font-size: 0.78rem;
font-weight: 900;
cursor: pointer;
list-style: none;
}
.link-row .link-drm-config > summary::-webkit-details-marker {
display: none;
}
.link-row .link-drm-config > summary::after {
content: "DRM";
border-radius: 999px;
padding: 2px 7px;
background: var(--line);
color: var(--text);
font-size: 0.68rem;
letter-spacing: 0.04em;
}
.link-row .link-drm-grid {
display: grid;
grid-template-columns: 0.7fr 1.5fr;
gap: 8px;
padding: 0 11px 11px;
}
.link-row .link-drm-grid textarea {
min-height: 68px;
}
.link-row .link-drm-wide {
grid-column: 1 / -1;
}
.link-row.has-drm-playback-url .l-url,
.link-row.has-drm-playback-url .l-type {
opacity: 0.55;
cursor: not-allowed;
background-color: rgba(100, 116, 139, 0.12);
}
.link-drm-list {
display: grid;
gap: 8px;
padding: 0 11px 11px;
}
.drm-config-row {
display: grid;
grid-template-columns: 0.7fr 1.4fr 1.2fr auto;
gap: 8px;
padding: 9px;
border: 1px solid var(--line);
border-radius: 6px;
background: rgba(255, 255, 255, 0.035);
}
.drm-config-row textarea {
min-height: 58px;
}
.drm-config-row .l-drm-type {
padding-right: 34px !important;
}
.drm-remove-btn {
align-self: start;
width: 42px;
height: 42px;
min-width: 42px;
padding: 0;
line-height: 1;
}
.link-drm-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
padding: 0 11px 11px;
}
.drm-discover-btn {
padding: 6px 10px;
font-size: .82em;
white-space: nowrap;
}
.nav-link-row { .nav-link-row {
display: grid; display: grid;
grid-template-columns: minmax(120px, 0.7fr) minmax(180px, 1.5fr) auto; grid-template-columns: minmax(120px, 0.7fr) minmax(180px, 1.5fr) auto;
@@ -867,22 +990,89 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.link-row .link-drm-config,
.link-row .link-drm-wide {
grid-column: 1 / -1;
}
.link-row .link-drm-grid {
grid-template-columns: 1fr;
}
.drm-config-row {
grid-template-columns: 1fr;
}
.admin-layout { .admin-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
#admin-menu-toggle {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 10px 13px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--panel);
color: var(--fg);
font-size: inherit;
cursor: pointer;
text-align: left;
}
#admin-menu-toggle .group-arrow {
transition: transform 0.2s ease;
}
.admin-menu.mobile-open #admin-menu-toggle .group-arrow {
transform: rotate(180deg);
}
.admin-menu { .admin-menu {
position: static; position: relative;
display: grid; display: flex;
grid-template-columns: repeat(5, minmax(118px, 1fr)); flex-direction: column;
overflow-x: auto; gap: 6px;
margin-bottom: 16px;
overflow: visible;
}
.admin-menu > .admin-menu-btn,
.admin-menu > .admin-menu-group {
display: none;
}
.admin-menu.mobile-open #admin-menu-toggle {
margin-bottom: 4px;
}
.admin-menu.mobile-open > .admin-menu-btn,
.admin-menu.mobile-open > .admin-menu-group {
display: flex;
width: 100%;
} }
.admin-menu-btn { #push-browser-layout { flex-direction: column; align-items: stretch; }
text-align: center; #push-sidebar-wrap { width: 100%; }
#push-sidebar-toggle {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 10px 13px;
margin-bottom: 0;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--panel);
color: var(--fg);
font-size: inherit;
cursor: pointer;
text-align: left;
}
#push-sidebar-toggle .group-arrow { transition: transform 0.2s ease; }
#push-sidebar-wrap.sidebar-open #push-sidebar-toggle .group-arrow { transform: rotate(180deg); }
#push-sidebar { display: none !important; }
#push-sidebar-wrap.sidebar-open #push-sidebar {
display: flex !important;
width: 100% !important;
margin-top: 6px;
} }
.admin-menu-group-btn { justify-content:center; }
.admin-sub-btn { text-align:center; padding-left:13px; }
.stream-row { .stream-row {
align-items: flex-start; align-items: flex-start;
@@ -969,6 +1159,8 @@
.dash-range-btn { border:1px solid var(--line); border-radius:5px; padding:4px 10px; color:var(--muted); background:transparent; font-size:.78rem; font-weight:800; cursor:pointer; box-shadow:none; transform:none !important; } .dash-range-btn { border:1px solid var(--line); border-radius:5px; padding:4px 10px; color:var(--muted); background:transparent; font-size:.78rem; font-weight:800; cursor:pointer; box-shadow:none; transform:none !important; }
.dash-range-btn:hover { color:var(--blue); border-color:rgba(78,126,232,.3); background:rgba(78,126,232,.06); filter:none; } .dash-range-btn:hover { color:var(--blue); border-color:rgba(78,126,232,.3); background:rgba(78,126,232,.06); filter:none; }
.dash-range-btn.active { color:#fff; background:var(--blue); border-color:transparent; } .dash-range-btn.active { color:#fff; background:var(--blue); border-color:transparent; }
.dash-export-range-item { display:block; width:100%; text-align:left; padding:7px 14px; background:none; border:none; color:var(--text); cursor:pointer; font-size:.84em; box-shadow:none; transform:none !important; }
.dash-export-range-item:hover { background:rgba(78,126,232,.1); filter:none; }
.dash-bar-chart { display:flex; align-items:flex-end; gap:2px; height:110px; padding-bottom:3px; } .dash-bar-chart { display:flex; align-items:flex-end; gap:2px; height:110px; padding-bottom:3px; }
.dash-bar-col-wrap { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:flex-end; height:100%; position:relative; } .dash-bar-col-wrap { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:flex-end; height:100%; position:relative; }
.dash-bar-col { width:100%; min-height:2px; border-radius:3px 3px 0 0; background:var(--blue); opacity:.75; transition:height .4s cubic-bezier(.22,1,.36,1); cursor:default; } .dash-bar-col { width:100%; min-height:2px; border-radius:3px 3px 0 0; background:var(--blue); opacity:.75; transition:height .4s cubic-bezier(.22,1,.36,1); cursor:default; }
@@ -1008,6 +1200,11 @@
.modal-card-wide { width:min(100%,clamp(640px,80vw,1080px)); max-height:90vh; overflow-y:auto; padding:26px; } .modal-card-wide { width:min(100%,clamp(640px,80vw,1080px)); max-height:90vh; overflow-y:auto; padding:26px; }
.stream-stat-summary-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:10px; margin:14px 0 18px; } .stream-stat-summary-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:10px; margin:14px 0 18px; }
@media(max-width:600px) { .stream-stat-summary-grid { grid-template-columns:repeat(2,1fr); } } @media(max-width:600px) { .stream-stat-summary-grid { grid-template-columns:repeat(2,1fr); } }
.fb-row { min-width:0; }
@media(max-width:600px) {
.fb-row { flex-wrap:wrap; }
.fb-actions { width:100%; justify-content:flex-end; margin-top:4px; }
}
.stream-stat-mini-card { border:1px solid var(--line); border-radius:7px; padding:12px 14px; } .stream-stat-mini-card { border:1px solid var(--line); border-radius:7px; padding:12px 14px; }
.stream-stat-mini-lbl { font-size:.72rem; font-weight:800; color:var(--muted); text-transform:uppercase; } .stream-stat-mini-lbl { font-size:.72rem; font-weight:800; color:var(--muted); text-transform:uppercase; }
.stream-stat-mini-val { font-size:1.55rem; font-weight:900; margin:3px 0 1px; } .stream-stat-mini-val { font-size:1.55rem; font-weight:900; margin:3px 0 1px; }
@@ -1112,6 +1309,10 @@
<div class="admin-layout"> <div class="admin-layout">
<aside class="admin-menu" aria-label="Admin sidebar menu"> <aside class="admin-menu" aria-label="Admin sidebar menu">
<button type="button" id="admin-menu-toggle" aria-label="Toggle menu">
<span id="admin-menu-toggle-label">&#9776;</span>
<span class="group-arrow" style="margin-left:auto;">&#9662;</span>
</button>
<button type="button" class="admin-menu-btn active" data-admin-view-target="dashboard" data-i18n="menu.dashboard">数据看板</button> <button type="button" class="admin-menu-btn active" data-admin-view-target="dashboard" data-i18n="menu.dashboard">数据看板</button>
<div class="admin-menu-group open" id="records-group"> <div class="admin-menu-group open" id="records-group">
<button type="button" class="admin-menu-group-btn" id="records-group-toggle"><span data-i18n="menu.streams">直播列表</span> <span class="group-arrow"></span></button> <button type="button" class="admin-menu-group-btn" id="records-group-toggle"><span data-i18n="menu.streams">直播列表</span> <span class="group-arrow"></span></button>
@@ -1421,7 +1622,17 @@
<h2 style="margin-top:0;margin-bottom:18px;" data-i18n="section.analytics">数据看板</h2> <h2 style="margin-top:0;margin-bottom:18px;" data-i18n="section.analytics">数据看板</h2>
<div class="dash-toolbar"> <div class="dash-toolbar">
<span id="dash-live-indicator" class="live-badge" data-state="connecting">连接中...</span> <span id="dash-live-indicator" class="live-badge" data-state="connecting">连接中...</span>
<button type="button" id="dash-export-btn" class="btn-success" data-i18n="dash.export">⬇ 导出 CSV</button> <div style="position:relative;display:inline-block;">
<button type="button" id="dash-export-btn" class="btn-success" style="display:flex;align-items:center;gap:5px;">
<span data-i18n="dash.export">导出 CSV</span><span style="font-size:.9em;opacity:.75;"></span>
</button>
<div id="dash-export-menu" style="display:none;position:absolute;right:0;top:calc(100% + 4px);background:var(--panel);border:1px solid var(--line);border-radius:8px;overflow:hidden;z-index:999;min-width:108px;box-shadow:0 4px 16px rgba(0,0,0,.18);">
<button type="button" class="dash-export-range-item" data-range="today" data-i18n="range.today">今日</button>
<button type="button" class="dash-export-range-item" data-range="7d" data-i18n="range.7d">近 7 天</button>
<button type="button" class="dash-export-range-item" data-range="30d" data-i18n="range.30d">近 30 天</button>
<button type="button" class="dash-export-range-item" data-range="all" data-i18n="range.all">全部</button>
</div>
</div>
</div> </div>
<div class="dash-card-grid"> <div class="dash-card-grid">
<div class="dash-card dash-accent-mint"> <div class="dash-card dash-accent-mint">
@@ -1543,8 +1754,11 @@
</div> </div>
<button type="button" id="upload-video-btn" class="btn-primary" data-i18n="btn.upload_video">上传视频</button> <button type="button" id="upload-video-btn" class="btn-primary" data-i18n="btn.upload_video">上传视频</button>
</div> </div>
<div style="display:flex;gap:16px;align-items:flex-start;"> <div id="push-browser-layout" style="display:flex;gap:16px;">
<div id="push-sidebar-wrap" style="flex-shrink:0;">
<button type="button" id="push-sidebar-toggle"><span data-i18n="push.dir_label">目录</span><span class="group-arrow" style="margin-left:auto;">&#9662;</span></button>
<div id="push-sidebar" style="width:140px;flex-shrink:0;display:flex;flex-direction:column;gap:4px;"></div> <div id="push-sidebar" style="width:140px;flex-shrink:0;display:flex;flex-direction:column;gap:4px;"></div>
</div>
<div style="flex:1;min-width:0;"> <div style="flex:1;min-width:0;">
<div id="push-breadcrumb" style="margin-bottom:10px;font-size:.85em;color:var(--muted);"></div> <div id="push-breadcrumb" style="margin-bottom:10px;font-size:.85em;color:var(--muted);"></div>
<div style="position:relative;margin-bottom:10px;"> <div style="position:relative;margin-bottom:10px;">
@@ -1620,7 +1834,7 @@
'dash.total_sub': '所有观看会话', 'dash.total_sub': '所有观看会话',
'dash.unique': '独立访客', 'dash.unique': '独立访客',
'dash.unique_sub': '去重 Visitor ID', 'dash.unique_sub': '去重 Visitor ID',
'dash.export': '导出 CSV', 'dash.export': '导出 CSV',
'dash.exporting': '导出中...', 'dash.exporting': '导出中...',
'dash.stream_detail': '直播统计明细', 'dash.stream_detail': '直播统计明细',
'dash.per_page': '每页', 'dash.per_page': '每页',
@@ -1781,6 +1995,7 @@
'btn.add_stream': '+ 添加', 'btn.add_stream': '+ 添加',
'btn.reset_filter': '清空', 'btn.reset_filter': '清空',
'btn.add_link': '+ 添加视角', 'btn.add_link': '+ 添加视角',
'btn.discover_drm': '自动识别 DRM',
'btn.save': '保 存', 'btn.save': '保 存',
'btn.cancel': '取 消', 'btn.cancel': '取 消',
'btn.delete': '删除', 'btn.delete': '删除',
@@ -1804,8 +2019,16 @@
'ph.nav_url': '#stream-list 或 https://example.com', 'ph.nav_url': '#stream-list 或 https://example.com',
'ph.link_name': '视角名', 'ph.link_name': '视角名',
'ph.link_url': '链接 (m3u8/flv/mpd)', 'ph.link_url': '链接 (m3u8/flv/mpd)',
'ph.main_url_disabled':'已使用 DRM 专用播放链接',
'ph.key_aes': 'AES-128 Key Hex,可多行: main-video=hex', 'ph.key_aes': 'AES-128 Key Hex,可多行: main-video=hex',
'ph.clearkey': 'ClearKey 信息,如 {"kid":"key"}', 'ph.clearkey': 'ClearKey 信息,如 {"kid":"key"}',
'ph.license_url': 'DRM License URL',
'ph.license_url_widevine':'Widevine License URL',
'ph.license_url_fairplay':'FairPlay License URL',
'ph.certificate_url':'FairPlay Certificate URL',
'ph.drm_playback_url':'DRM 专用播放链接 (可选)',
'ph.license_headers':'License Headers JSON 或 Header: Value 多行',
'ph.pssh': 'PSSH,可选,用于记录或诊断',
// hints // hints
'hint.nav_links': '留空则不显示菜单链接。支持站内锚点如 #stream-list,或完整 http/https 链接。', 'hint.nav_links': '留空则不显示菜单链接。支持站内锚点如 #stream-list,或完整 http/https 链接。',
'hint.pw_change': '修改后台登录密码后,当前会话会自动退出。', 'hint.pw_change': '修改后台登录密码后,当前会话会自动退出。',
@@ -1814,6 +2037,8 @@
'hint.obs_rtmp': '推流端使用 RTMP 推流到服务器,播放器使用 SRS 输出的 HLS 或 FLV 地址。', 'hint.obs_rtmp': '推流端使用 RTMP 推流到服务器,播放器使用 SRS 输出的 HLS 或 FLV 地址。',
'hint.obs_routes': '新增自定义推流码后,系统会生成不可反推的公开 HLS/FLV 地址。推流端仍使用真实推流码推流。', 'hint.obs_routes': '新增自定义推流码后,系统会生成不可反推的公开 HLS/FLV 地址。推流端仍使用真实推流码推流。',
'hint.links': '填写格式:视角名称 | 类型 | 播放链接 | Key Override | ClearKey 信息', 'hint.links': '填写格式:视角名称 | 类型 | 播放链接 | Key Override | ClearKey 信息',
'drm.config': 'DRM 播放授权',
'drm.none': '无 DRM',
'hint.footer_example': '示例:## 联系方式\n- [项目主页](https://example.com)', 'hint.footer_example': '示例:## 联系方式\n- [项目主页](https://example.com)',
// TG // TG
'tg.live_section': '直播', 'tg.live_label': 'LIVE', 'tg.live_section': '直播', 'tg.live_label': 'LIVE',
@@ -1852,6 +2077,7 @@
'probe.detected': '已检测到推流信息', 'probe.detected': '已检测到推流信息',
'probe.waiting': '等待自动检测...', 'probe.waiting': '等待自动检测...',
'probe.closed': '直播已关闭', 'probe.closed': '直播已关闭',
'probe.drm_config_missing':'检测到 DRM 流,但缺少匹配的 DRM 配置',
// status messages // status messages
'msg.site_saved': '网站设置已保存', 'msg.site_saved': '网站设置已保存',
'msg.site_err': '加载网站设置失败', 'msg.site_err': '加载网站设置失败',
@@ -1868,6 +2094,14 @@
'msg.copied': '已复制', 'msg.copied': '已复制',
'msg.no_key': '请先填写推流码', 'msg.no_key': '请先填写推流码',
'msg.sort_err': '排序保存失败', 'msg.sort_err': '排序保存失败',
'msg.drm_discovered':'已识别并填入 DRM 配置',
'msg.drm_partial': '已部分识别 DRM 配置,请补全必填项',
'msg.drm_not_found': '未在播放链接中识别到可用 DRM 配置',
'msg.drm_partial_failed': '部分 DRM 已识别,另有 {count} 个链接识别失败',
'msg.drm_discover_failed': 'DRM 自动识别失败',
'msg.drm_type_required': '请选择 DRM Method,或清空该 DRM 行',
'msg.drm_license_required': 'DRM License URL 为必填项',
'msg.drm_cert_required': 'FairPlay Certificate URL 为必填项',
// confirm/alert // confirm/alert
'confirm.delete': '确定删除?', 'confirm.delete': '确定删除?',
'confirm.delete_route': '确定删除这个推流码映射?', 'confirm.delete_route': '确定删除这个推流码映射?',
@@ -1935,6 +2169,7 @@
'push.copy_hls': '复制播放地址', 'push.copy_hls': '复制播放地址',
'push.add_stream': '添加直播', 'push.add_stream': '添加直播',
'push.copied': '已复制', 'push.copied': '已复制',
'push.dir_label': '目录',
'push.dir_empty': '此目录为空', 'push.dir_empty': '此目录为空',
'push.publish_archive': '发布归档', 'push.publish_archive': '发布归档',
'push.folder': '文件夹', 'push.folder': '文件夹',
@@ -2010,7 +2245,7 @@
'dash.total_sub': 'All sessions', 'dash.total_sub': 'All sessions',
'dash.unique': 'Unique Visitors', 'dash.unique': 'Unique Visitors',
'dash.unique_sub': 'Deduped visitor IDs', 'dash.unique_sub': 'Deduped visitor IDs',
'dash.export': 'Export CSV', 'dash.export': 'Export CSV',
'dash.exporting': 'Exporting...', 'dash.exporting': 'Exporting...',
'dash.stream_detail': 'Stream Statistics', 'dash.stream_detail': 'Stream Statistics',
'dash.per_page': 'Per page', 'dash.per_page': 'Per page',
@@ -2171,6 +2406,7 @@
'btn.add_stream': '+ Add', 'btn.add_stream': '+ Add',
'btn.reset_filter': 'Clear', 'btn.reset_filter': 'Clear',
'btn.add_link': '+ Add Source', 'btn.add_link': '+ Add Source',
'btn.discover_drm': 'Discover DRM',
'btn.save': 'Save', 'btn.save': 'Save',
'btn.cancel': 'Cancel', 'btn.cancel': 'Cancel',
'btn.delete': 'Delete', 'btn.delete': 'Delete',
@@ -2194,8 +2430,16 @@
'ph.nav_url': '#stream-list or https://example.com', 'ph.nav_url': '#stream-list or https://example.com',
'ph.link_name': 'Source name', 'ph.link_name': 'Source name',
'ph.link_url': 'URL (m3u8/flv/mpd)', 'ph.link_url': 'URL (m3u8/flv/mpd)',
'ph.main_url_disabled':'Using DRM-specific playback URL',
'ph.key_aes': 'AES-128 Key Hex, multi-line: main-video=hex', 'ph.key_aes': 'AES-128 Key Hex, multi-line: main-video=hex',
'ph.clearkey': 'ClearKey JSON, e.g. {"kid":"key"}', 'ph.clearkey': 'ClearKey JSON, e.g. {"kid":"key"}',
'ph.license_url': 'DRM License URL',
'ph.license_url_widevine':'Widevine License URL',
'ph.license_url_fairplay':'FairPlay License URL',
'ph.certificate_url':'FairPlay Certificate URL',
'ph.drm_playback_url':'DRM-specific playback URL (optional)',
'ph.license_headers':'License Headers JSON or Header: Value lines',
'ph.pssh': 'PSSH, optional note for diagnostics',
// hints // hints
'hint.nav_links': 'Leave empty to hide nav links. Supports anchors like #stream-list or full URLs.', 'hint.nav_links': 'Leave empty to hide nav links. Supports anchors like #stream-list or full URLs.',
'hint.pw_change': 'Changing the admin password will log you out of the current session.', 'hint.pw_change': 'Changing the admin password will log you out of the current session.',
@@ -2204,6 +2448,8 @@
'hint.obs_rtmp': 'The encoder pushes via RTMP to the server; the player uses HLS or FLV from SRS.', 'hint.obs_rtmp': 'The encoder pushes via RTMP to the server; the player uses HLS or FLV from SRS.',
'hint.obs_routes': 'Adding a custom stream key generates a public HLS/FLV URL that cannot be reverse-engineered.', 'hint.obs_routes': 'Adding a custom stream key generates a public HLS/FLV URL that cannot be reverse-engineered.',
'hint.links': 'Format: name | type | URL | Key Override | ClearKey', 'hint.links': 'Format: name | type | URL | Key Override | ClearKey',
'drm.config': 'DRM license',
'drm.none': 'No DRM',
'hint.footer_example': 'Example: ## Contact\n- [Project page](https://example.com)', 'hint.footer_example': 'Example: ## Contact\n- [Project page](https://example.com)',
// TG // TG
'tg.live_section': 'Live', 'tg.live_label': '直播', 'tg.live_section': 'Live', 'tg.live_label': '直播',
@@ -2242,6 +2488,7 @@
'probe.detected': 'Stream detected', 'probe.detected': 'Stream detected',
'probe.waiting': 'Waiting for detection...', 'probe.waiting': 'Waiting for detection...',
'probe.closed': 'Stream disabled', 'probe.closed': 'Stream disabled',
'probe.drm_config_missing':'DRM stream detected, but matching DRM config is missing',
// status messages // status messages
'msg.site_saved': 'Settings saved', 'msg.site_saved': 'Settings saved',
'msg.site_err': 'Failed to load settings', 'msg.site_err': 'Failed to load settings',
@@ -2258,6 +2505,14 @@
'msg.copied': 'Copied', 'msg.copied': 'Copied',
'msg.no_key': 'Please fill in stream key first', 'msg.no_key': 'Please fill in stream key first',
'msg.sort_err': 'Failed to save order', 'msg.sort_err': 'Failed to save order',
'msg.drm_discovered':'DRM config detected and filled',
'msg.drm_partial': 'DRM config partially detected, please complete required fields',
'msg.drm_not_found': 'No supported DRM config was detected in this playback URL',
'msg.drm_partial_failed': 'Some DRM configs were detected, but {count} URL(s) failed',
'msg.drm_discover_failed': 'DRM discovery failed',
'msg.drm_type_required': 'Select a DRM method, or clear this DRM row',
'msg.drm_license_required': 'DRM License URL is required',
'msg.drm_cert_required': 'FairPlay Certificate URL is required',
// confirm/alert // confirm/alert
'confirm.delete': 'Confirm delete?', 'confirm.delete': 'Confirm delete?',
'confirm.delete_route': 'Delete this stream key mapping?', 'confirm.delete_route': 'Delete this stream key mapping?',
@@ -2325,6 +2580,7 @@
'push.copy_hls': 'Copy HLS URL', 'push.copy_hls': 'Copy HLS URL',
'push.add_stream': 'Add Stream', 'push.add_stream': 'Add Stream',
'push.copied': 'Copied', 'push.copied': 'Copied',
'push.dir_label': 'Dirs',
'push.dir_empty': 'Directory is empty', 'push.dir_empty': 'Directory is empty',
'push.publish_archive': 'Publish to Archive', 'push.publish_archive': 'Publish to Archive',
'push.folder': 'Folder', 'push.folder': 'Folder',
@@ -2510,6 +2766,15 @@
if (btn.dataset.adminViewTarget) switchAdminView(btn.dataset.adminViewTarget); if (btn.dataset.adminViewTarget) switchAdminView(btn.dataset.adminViewTarget);
}); });
}); });
const _adminMenuEl = document.querySelector('.admin-menu');
document.getElementById('admin-menu-toggle')?.addEventListener('click', () => {
_adminMenuEl?.classList.toggle('mobile-open');
});
document.querySelectorAll('.admin-menu-btn, .admin-sub-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (window.innerWidth <= 900) _adminMenuEl?.classList.remove('mobile-open');
});
});
document.getElementById('records-group-toggle')?.addEventListener('click', () => { document.getElementById('records-group-toggle')?.addEventListener('click', () => {
document.getElementById('records-group')?.classList.toggle('open'); document.getElementById('records-group')?.classList.toggle('open');
}); });
@@ -2702,35 +2967,73 @@
if (state !== 'is-checking') el.dataset.probeComplete = '1'; if (state !== 'is-checking') el.dataset.probeComplete = '1';
}; };
const probeUrl = async (url, type = '') => { const readDrmConfig = (drmRow) => {
const drmType = drmRow.querySelector('.l-drm-type')?.value || '';
const config = {
drmType,
licenseUrl: drmRow.querySelector('.l-license-url')?.value.trim() || '',
licenseHeaders: drmRow.querySelector('.l-license-headers')?.value.trim() || '',
playbackUrl: drmRow.querySelector('.l-drm-playback-url')?.value.trim() || '',
playbackType: drmRow.querySelector('.l-drm-playback-type')?.value || ''
};
if (drmType === 'fairplay') {
config.certificateUrl = drmRow.querySelector('.l-certificate-url')?.value.trim() || '';
}
if (drmType === 'widevine') {
config.pssh = drmRow.querySelector('.l-pssh')?.value.trim() || '';
}
return config;
};
const getRowDrmConfigs = (row) => Array.from(row.querySelectorAll('.drm-config-row'))
.map(readDrmConfig)
.filter(config => config.drmType && config.licenseUrl);
const getRowDrmPlaybackTargets = (row) => Array.from(row.querySelectorAll('.drm-config-row')).map(drmRow => ({
row: drmRow,
url: drmRow.querySelector('.l-drm-playback-url')?.value.trim() || '',
type: drmRow.querySelector('.l-drm-playback-type')?.value || '',
drmType: drmRow.querySelector('.l-drm-type')?.value || ''
})).filter(item => item.url);
const getRowProbeTarget = (row) => {
const drmPlayback = getRowDrmPlaybackTargets(row)[0];
if (drmPlayback) return { url: drmPlayback.url, type: drmPlayback.type || '', drmRow: drmPlayback.row };
return {
url: row.querySelector('.l-url')?.value.trim() || '',
type: row.querySelector('.l-type')?.value || '',
drmRow: null
};
};
const probeUrl = async (url, type = '', drmConfigs = []) => {
const res = await apiCall('check_stream_url', { const res = await apiCall('check_stream_url', {
url, url,
type: inferLinkType(url, type) type: inferLinkType(url, type),
drmConfigs
}); });
return res.data || { valid: false, message: t('probe.no_info') }; return res.data || { valid: false, message: t('probe.no_info') };
}; };
const checkLinkRow = async (row, silent = false) => { const checkLinkRow = async (row, silent = false) => {
if (!row || !row.isConnected || row.dataset.probeActive === '1') return; if (!row || !row.isConnected || row.dataset.probeActive === '1') return;
const input = row.querySelector('.l-url');
const statusEl = row.querySelector('.stream-check-status'); const statusEl = row.querySelector('.stream-check-status');
const url = input?.value.trim() || ''; const { url, type } = getRowProbeTarget(row);
if (!url) { if (!url) {
setProbeStatus(statusEl, '', ''); setProbeStatus(statusEl, '', '');
return; return;
} }
const type = row.querySelector('.l-type')?.value || '';
const token = `${Date.now()}-${Math.random()}`; const token = `${Date.now()}-${Math.random()}`;
row.dataset.probeToken = token; row.dataset.probeToken = token;
row.dataset.probeActive = '1'; row.dataset.probeActive = '1';
if (!silent) setProbeStatus(statusEl, 'is-checking', t('probe.detecting')); if (!silent) setProbeStatus(statusEl, 'is-checking', t('probe.detecting'));
try { try {
const result = await probeUrl(url, type); const result = await probeUrl(url, type, getRowDrmConfigs(row));
if (!row.isConnected || row.dataset.probeToken !== token) return; if (!row.isConnected || row.dataset.probeToken !== token) return;
setProbeStatus( setProbeStatus(
statusEl, statusEl,
result.valid ? 'is-online' : 'is-offline', result.valid ? 'is-online' : 'is-offline',
result.valid ? t('probe.detected') : t('probe.no_info') result.valid ? t('probe.detected') : (t('probe.' + result.code) || t('probe.no_info'))
); );
} catch (e) { } catch (e) {
if (row.isConnected && row.dataset.probeToken === token) { if (row.isConnected && row.dataset.probeToken === token) {
@@ -2743,8 +3046,8 @@
const scheduleLinkRowCheck = (row, delay = 700) => { const scheduleLinkRowCheck = (row, delay = 700) => {
window.clearTimeout(linkProbeTimers.get(row)); window.clearTimeout(linkProbeTimers.get(row));
const input = row.querySelector('.l-url'); const target = getRowProbeTarget(row);
if (!input?.value.trim()) { if (!target.url) {
setProbeStatus(row.querySelector('.stream-check-status'), '', ''); setProbeStatus(row.querySelector('.stream-check-status'), '', '');
return; return;
} }
@@ -2754,7 +3057,7 @@
const checkFormLinks = () => { const checkFormLinks = () => {
Array.from(els.linksContainer.children).forEach(row => { Array.from(els.linksContainer.children).forEach(row => {
if (row.querySelector('.l-url')?.value.trim()) checkLinkRow(row, true); if (getRowProbeTarget(row).url) checkLinkRow(row, true);
}); });
}; };
@@ -3655,15 +3958,71 @@
}); });
}, true); }, true);
const validateDrmRequiredFields = () => {
for (const linkRow of Array.from(els.linksContainer.children)) {
for (const drmRow of Array.from(linkRow.querySelectorAll('.drm-config-row'))) {
const drmType = drmRow.querySelector('.l-drm-type')?.value || '';
const licenseInput = drmRow.querySelector('.l-license-url');
const certificateInput = drmRow.querySelector('.l-certificate-url');
const values = [
drmType,
licenseInput?.value.trim() || '',
certificateInput?.value.trim() || '',
drmRow.querySelector('.l-license-headers')?.value.trim() || '',
drmRow.querySelector('.l-pssh')?.value.trim() || '',
drmRow.querySelector('.l-drm-playback-url')?.value.trim() || '',
drmRow.querySelector('.l-drm-playback-type')?.value || ''
];
if (!values.some(Boolean)) continue;
const sourceName = linkRow.querySelector('.l-name')?.value.trim() || 'Default';
const fail = (message, input = null) => ({
message: `${sourceName}: ${message}`,
input: input || drmRow.querySelector('.l-drm-type'),
details: linkRow.querySelector('.link-drm-config')
});
if (!drmType) return fail(t('msg.drm_type_required') || 'Please select a DRM method');
if ((drmType === 'widevine' || drmType === 'fairplay') && !licenseInput?.value.trim()) {
return fail(t('msg.drm_license_required') || 'DRM License URL is required', licenseInput);
}
if (drmType === 'fairplay' && !certificateInput?.value.trim()) {
return fail(t('msg.drm_cert_required') || 'FairPlay Certificate URL is required', certificateInput);
}
}
}
return null;
};
els.form.addEventListener('submit', async (e) => { els.form.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const links = Array.from(els.linksContainer.children).map(row => ({ const drmInvalid = validateDrmRequiredFields();
if (drmInvalid) {
drmInvalid.details?.setAttribute('open', '');
drmInvalid.input?.focus();
showToast(drmInvalid.message, 'error');
return;
}
const links = Array.from(els.linksContainer.children).map(row => {
const drmConfigs = Array.from(row.querySelectorAll('.drm-config-row'))
.map(readDrmConfig)
.filter(config => config.drmType && config.licenseUrl);
const firstDrm = drmConfigs[0] || {};
const fallbackUrl = row.querySelector('.l-url').value.trim() || firstDrm.playbackUrl || '';
return {
name: row.querySelector('.l-name').value, name: row.querySelector('.l-name').value,
type: row.querySelector('.l-type').value, type: row.querySelector('.l-type').value,
url: row.querySelector('.l-url').value, url: fallbackUrl,
key: row.querySelector('.l-key').value.trim(), key: row.querySelector('.l-key').value.trim(),
clearkey: row.querySelector('.l-clearkey').value.trim() clearkey: row.querySelector('.l-clearkey').value.trim(),
})).filter(l => l.name && l.url); drmConfigs,
drmType: firstDrm.drmType || '',
licenseUrl: firstDrm.licenseUrl || '',
certificateUrl: firstDrm.certificateUrl || '',
licenseHeaders: firstDrm.licenseHeaders || '',
pssh: firstDrm.pssh || '',
playbackUrl: firstDrm.playbackUrl || '',
playbackType: firstDrm.playbackType || ''
};
}).filter(l => l.name && l.url);
const payload = { const payload = {
id: els.idInput.value, id: els.idInput.value,
@@ -3686,7 +4045,14 @@
let _linkDragSrc = null; let _linkDragSrc = null;
let _linkDragFromHandle = false; let _linkDragFromHandle = false;
const addLinkUI = (name = 'Default', url = '', key = '', clearkey = '', type = '') => { const addLinkUI = (name = 'Default', url = '', key = '', clearkey = '', type = '', drmType = '', licenseUrl = '', licenseHeaders = '', pssh = '', certificateUrl = '', drmConfigs = []) => {
const rawDrmType = String(drmType || '').toLowerCase();
const normalizedDrmType = rawDrmType === 'widevine' || rawDrmType === 'fairplay' ? rawDrmType : '';
const normalizedDrmConfigs = Array.isArray(drmConfigs) && drmConfigs.length
? drmConfigs
: (normalizedDrmType || licenseUrl || certificateUrl || licenseHeaders || pssh)
? [{ drmType: normalizedDrmType, licenseUrl, certificateUrl, licenseHeaders, pssh }]
: [];
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'link-row'; div.className = 'link-row';
div.draggable = true; div.draggable = true;
@@ -3705,9 +4071,219 @@
</div> </div>
<textarea class="l-key" rows="2" placeholder="${t('ph.key_aes')}">${escapeHtml(key)}</textarea> <textarea class="l-key" rows="2" placeholder="${t('ph.key_aes')}">${escapeHtml(key)}</textarea>
<textarea class="l-clearkey" rows="2" placeholder="${t('ph.clearkey')}">${escapeHtml(clearkey)}</textarea> <textarea class="l-clearkey" rows="2" placeholder="${t('ph.clearkey')}">${escapeHtml(clearkey)}</textarea>
<button type="button" class="btn-danger" onclick="this.parentElement.remove()">×</button> <details class="link-drm-config" ${normalizedDrmConfigs.length ? 'open' : ''}>
<summary>${t('drm.config')}</summary>
<div class="link-drm-list"></div>
<div class="link-drm-actions">
<button type="button" class="btn-secondary discover-drm-btn drm-discover-btn">${t('btn.discover_drm')}</button>
<button type="button" class="btn-secondary add-drm-config-btn" style="padding:6px 10px;font-size:.82em;">+ DRM</button>
</div>
</details>
<button type="button" class="btn-danger link-remove-btn" onclick="this.parentElement.remove()">×</button>
`; `;
els.linksContainer.appendChild(div); els.linksContainer.appendChild(div);
const drmList = div.querySelector('.link-drm-list');
const syncMainPlaybackState = () => {
const hasDrmPlayback = Array.from(div.querySelectorAll('.l-drm-playback-url')).some(input => input.value.trim());
const mainUrl = div.querySelector('.l-url');
const mainType = div.querySelector('.l-type');
if (mainUrl) {
mainUrl.disabled = hasDrmPlayback;
mainUrl.placeholder = hasDrmPlayback ? t('ph.main_url_disabled') : t('ph.link_url');
}
if (mainType) mainType.disabled = hasDrmPlayback;
div.classList.toggle('has-drm-playback-url', hasDrmPlayback);
};
const addDrmConfigUI = (config = {}) => {
const drmTypeValue = String(config.drmType || config.drm_type || '').toLowerCase();
const selectedType = drmTypeValue === 'fairplay' || drmTypeValue === 'widevine' ? drmTypeValue : '';
const row = document.createElement('div');
row.className = 'drm-config-row';
row.innerHTML = `
<select class="l-drm-type">
<option value="" ${selectedType === '' ? 'selected' : ''}>${t('drm.none')}</option>
<option value="widevine" ${selectedType === 'widevine' ? 'selected' : ''}>Widevine</option>
<option value="fairplay" ${selectedType === 'fairplay' ? 'selected' : ''}>FairPlay</option>
</select>
<input class="l-drm-playback-url drm-field-playback" placeholder="${t('ph.drm_playback_url')}" value="${escapeAttr(config.playbackUrl || config.playback_url || '')}">
<select class="l-drm-playback-type drm-field-playback">
<option value="" ${(config.playbackType || config.playback_type || '') === '' ? 'selected' : ''}>${t('type.auto')}</option>
<option value="m3u8" ${(config.playbackType || config.playback_type || '') === 'm3u8' ? 'selected' : ''}>HLS</option>
<option value="dash" ${(config.playbackType || config.playback_type || '') === 'dash' ? 'selected' : ''}>DASH</option>
<option value="flv" ${(config.playbackType || config.playback_type || '') === 'flv' ? 'selected' : ''}>FLV</option>
</select>
<button type="button" class="btn-danger remove-drm-config-btn drm-remove-btn">x</button>
<input class="l-license-url link-drm-wide drm-field-enabled" placeholder="${t('ph.license_url')}" value="${escapeAttr(config.licenseUrl || config.license_url || '')}">
<input class="l-certificate-url link-drm-wide drm-field-fairplay" placeholder="${t('ph.certificate_url')}" value="${escapeAttr(config.certificateUrl || config.certificate_url || '')}">
<textarea class="l-license-headers link-drm-wide drm-field-enabled" rows="2" placeholder="${t('ph.license_headers')}">${escapeHtml(config.licenseHeaders || config.license_headers || '')}</textarea>
<textarea class="l-pssh link-drm-wide drm-field-widevine" rows="2" placeholder="${t('ph.pssh')}">${escapeHtml(config.pssh || '')}</textarea>
`;
drmList.appendChild(row);
const drmTypeSelect = row.querySelector('.l-drm-type');
const licenseInput = row.querySelector('.l-license-url');
const syncDrmPlaceholders = () => {
const type = drmTypeSelect?.value || '';
if (licenseInput) {
licenseInput.placeholder = type === 'fairplay'
? t('ph.license_url_fairplay')
: type === 'widevine'
? t('ph.license_url_widevine')
: t('ph.license_url');
}
row.querySelectorAll('.drm-field-enabled').forEach(el => {
el.style.display = type ? '' : 'none';
});
row.querySelectorAll('.drm-field-playback').forEach(el => {
el.style.display = '';
});
row.querySelectorAll('.drm-field-fairplay').forEach(el => {
el.style.display = type === 'fairplay' ? '' : 'none';
});
row.querySelectorAll('.drm-field-widevine').forEach(el => {
el.style.display = type === 'widevine' ? '' : 'none';
});
};
syncDrmPlaceholders();
const refreshProbe = () => scheduleLinkRowCheck(div);
drmTypeSelect?.addEventListener('change', () => {
syncDrmPlaceholders();
refreshProbe();
});
row.querySelectorAll('input, textarea').forEach(input => {
input.addEventListener('input', () => {
syncMainPlaybackState();
refreshProbe();
});
input.addEventListener('blur', () => {
syncMainPlaybackState();
scheduleLinkRowCheck(div, 0);
});
});
row.querySelector('.l-drm-playback-type')?.addEventListener('change', refreshProbe);
row.querySelector('.remove-drm-config-btn')?.addEventListener('click', () => {
row.remove();
syncMainPlaybackState();
refreshProbe();
});
return row;
};
const fillInputIfAvailable = (input, value, overwrite = false) => {
if (!input || !value) return;
if (overwrite || !input.value.trim()) input.value = value;
};
const applyDiscoveredDrm = (items, sourceUrl = '', sourceType = '', preferredRow = null) => {
const supported = (Array.isArray(items) ? items : [])
.filter(item => item && (item.drmType === 'widevine' || item.drmType === 'fairplay'));
if (!supported.length) return { applied: 0, partial: false };
const mainUrl = div.querySelector('.l-url')?.value.trim() || '';
const mainType = div.querySelector('.l-type')?.value || '';
let applied = 0;
let partial = false;
supported.forEach(item => {
partial = partial || !!item.partial || !item.licenseUrl || (item.drmType === 'fairplay' && !item.certificateUrl);
let target = null;
if (preferredRow?.isConnected) {
const preferredType = preferredRow.querySelector('.l-drm-type')?.value || '';
if (!preferredType || preferredType === item.drmType) target = preferredRow;
}
if (!target) target = Array.from(drmList.querySelectorAll('.drm-config-row')).find(row => {
const rowType = row.querySelector('.l-drm-type')?.value || '';
return rowType === item.drmType;
});
if (!target) target = addDrmConfigUI({ drmType: item.drmType });
const typeSelect = target.querySelector('.l-drm-type');
if (typeSelect) {
typeSelect.value = item.drmType;
typeSelect.dispatchEvent(new Event('change'));
}
fillInputIfAvailable(target.querySelector('.l-license-url'), item.licenseUrl || '', true);
fillInputIfAvailable(target.querySelector('.l-certificate-url'), item.certificateUrl || '', true);
fillInputIfAvailable(target.querySelector('.l-pssh'), item.pssh || '', true);
const itemPlaybackUrl = item.playbackUrl || sourceUrl || '';
if (itemPlaybackUrl && itemPlaybackUrl !== mainUrl) {
fillInputIfAvailable(target.querySelector('.l-drm-playback-url'), itemPlaybackUrl, true);
}
const itemPlaybackType = item.playbackType || sourceType || '';
const playbackTypeSelect = target.querySelector('.l-drm-playback-type');
if (playbackTypeSelect && itemPlaybackType && itemPlaybackUrl !== mainUrl) {
playbackTypeSelect.value = itemPlaybackType;
} else if (playbackTypeSelect && mainType && itemPlaybackUrl === mainUrl) {
playbackTypeSelect.value = '';
}
applied += 1;
});
syncMainPlaybackState();
scheduleLinkRowCheck(div, 0);
return { applied, partial };
};
const discoverDrmForRow = async () => {
const btn = div.querySelector('.discover-drm-btn');
const drmTargets = getRowDrmPlaybackTargets(div);
const mainTarget = {
url: div.querySelector('.l-url')?.value.trim() || '',
type: div.querySelector('.l-type')?.value || '',
drmRow: null
};
const targets = drmTargets.length ? drmTargets : (mainTarget.url ? [mainTarget] : []);
if (!targets.length) {
showToast(t('probe.no_info'), 'error');
return;
}
const original = btn?.textContent || '';
if (btn) {
btn.disabled = true;
btn.textContent = '...';
}
let total = 0;
let partial = false;
const failedMessages = [];
try {
for (const target of targets) {
try {
const res = await apiCall('discover_drm', {
url: target.url,
type: inferLinkType(target.url, target.type)
});
const result = applyDiscoveredDrm(res.data?.drmConfigs || [], target.url, res.data?.type || target.type, target.row || target.drmRow);
total += result.applied || 0;
partial = partial || !!result.partial;
} catch (e) {
const url = target.url || '';
const shortUrl = url.length > 72 ? `${url.slice(0, 69)}...` : url;
failedMessages.push(`${shortUrl}: ${e.message || e}`);
}
}
div.querySelector('.link-drm-config')?.setAttribute('open', '');
if (total && failedMessages.length) {
showToast(t('msg.drm_partial_failed').replace('{count}', failedMessages.length), 'info');
} else if (total) {
showToast(partial ? t('msg.drm_partial') : t('msg.drm_discovered'), partial ? 'info' : 'success');
} else if (failedMessages.length) {
showToast(`${t('msg.drm_discover_failed')}: ${failedMessages[0]}`, 'error');
} else {
showToast(t('msg.drm_not_found'), 'error');
}
} catch (e) {
showToast(e.message, 'error');
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = original || t('btn.discover_drm');
}
}
};
if (normalizedDrmConfigs.length) {
normalizedDrmConfigs.forEach(config => addDrmConfigUI(config));
} else {
addDrmConfigUI();
}
syncMainPlaybackState();
div.querySelector('.discover-drm-btn')?.addEventListener('click', discoverDrmForRow);
div.querySelector('.add-drm-config-btn')?.addEventListener('click', () => {
div.querySelector('.link-drm-config')?.setAttribute('open', '');
addDrmConfigUI();
scheduleLinkRowCheck(div);
});
div.querySelector('.l-url').addEventListener('input', () => scheduleLinkRowCheck(div)); div.querySelector('.l-url').addEventListener('input', () => scheduleLinkRowCheck(div));
div.querySelector('.l-url').addEventListener('blur', () => scheduleLinkRowCheck(div, 0)); div.querySelector('.l-url').addEventListener('blur', () => scheduleLinkRowCheck(div, 0));
div.querySelector('.l-type').addEventListener('change', () => scheduleLinkRowCheck(div, 0)); div.querySelector('.l-type').addEventListener('change', () => scheduleLinkRowCheck(div, 0));
@@ -3861,7 +4437,7 @@
els.cancelBtn.classList.remove('hidden'); els.cancelBtn.classList.remove('hidden');
els.linksContainer.innerHTML = ''; els.linksContainer.innerHTML = '';
const links = JSON.parse(stream.links_json || '[]'); const links = JSON.parse(stream.links_json || '[]');
links.forEach(l => addLinkUI(l.name, l.url, l.key, l.clearkey, l.type)); links.forEach(l => addLinkUI(l.name, l.url, l.key, l.clearkey, l.type, l.drmType || l.drm_type || '', l.licenseUrl || l.license_url || '', l.licenseHeaders || l.license_headers || '', l.pssh || '', l.certificateUrl || l.certificate_url || '', l.drmConfigs || l.drm_configs || []));
if (links.length === 0) addLinkUI(); if (links.length === 0) addLinkUI();
} else { } else {
resetForm(); resetForm();
@@ -4713,18 +5289,29 @@
}); });
// Dashboard export CSV // Dashboard export CSV
document.getElementById('dash-export-btn')?.addEventListener('click', async () => { const exportMenu = document.getElementById('dash-export-menu');
document.getElementById('dash-export-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
exportMenu.style.display = exportMenu.style.display === 'none' ? 'block' : 'none';
});
document.addEventListener('click', () => { if (exportMenu) exportMenu.style.display = 'none'; });
exportMenu?.addEventListener('click', async (e) => {
const item = e.target.closest('.dash-export-range-item');
if (!item) return;
exportMenu.style.display = 'none';
const range = item.dataset.range || '30d';
const btn = document.getElementById('dash-export-btn'); const btn = document.getElementById('dash-export-btn');
btn.disabled = true; btn.textContent = t('dash.exporting'); const label = btn.querySelector('[data-i18n="dash.export"]');
btn.disabled = true; if (label) label.textContent = t('dash.exporting');
try { try {
const res = await fetch(`/api?action=stats_export_csv&_t=${Date.now()}`); const res = await fetch(`/api?action=stats_export_csv&range=${range}&_t=${Date.now()}`);
if (!res.ok) throw new Error('Export failed'); if (!res.ok) throw new Error('Export failed');
const blob = await res.blob(), url = URL.createObjectURL(blob); const blob = await res.blob(), url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; const a = document.createElement('a'); a.href = url;
a.download = `viewer_stats_${new Date().toISOString().slice(0,10)}.csv`; a.download = `viewer_stats_${range}_${new Date().toISOString().slice(0,10)}.csv`;
a.click(); URL.revokeObjectURL(url); a.click(); URL.revokeObjectURL(url);
} catch (e) { showToast(e.message, 'error'); } } catch (e) { showToast(e.message, 'error'); }
finally { btn.disabled = false; btn.textContent = t('dash.export'); } finally { btn.disabled = false; if (label) label.textContent = t('dash.export'); }
}); });
// Modal range tabs // Modal range tabs
@@ -5181,6 +5768,7 @@
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
_highlightSidebarBtn(sidebar, root.index); _highlightSidebarBtn(sidebar, root.index);
browseDir(root.index, ''); browseDir(root.index, '');
if (window.innerWidth <= 900) document.getElementById('push-sidebar-wrap')?.classList.remove('sidebar-open');
}); });
sidebar.appendChild(btn); sidebar.appendChild(btn);
}); });
@@ -5273,7 +5861,7 @@
(j.push_rel_path === childPath || j.push_rel_path.startsWith(childPath + '/'))) (j.push_rel_path === childPath || j.push_rel_path.startsWith(childPath + '/')))
: false; : false;
const folderBtns = (entry.has_playable || entry.has_video) const folderBtns = (entry.has_playable || entry.has_video)
? `<div style="display:flex;gap:6px;flex-shrink:0;" onclick="event.stopPropagation()"> ? `<div class="fb-actions" style="display:flex;gap:6px;" onclick="event.stopPropagation()">
${entry.has_playable ? `<div style="display:flex;gap:0;"><button class="btn-secondary push-folder-publish-btn" data-dir-index="${dirIndex}" data-folder-rel-path="${_esc(childPath)}" data-folder-name="${_esc(entry.name)}" style="padding:4px 5px;font-size:.80em;border-radius:6px 0 0 6px;">${_esc(t('push.publish_archive'))}</button><button class="btn-secondary push-folder-publish-existing-btn" data-dir-index="${dirIndex}" data-folder-rel-path="${_esc(childPath)}" data-folder-name="${_esc(entry.name)}" style="padding:4px 3px;font-size:.80em;border-left:1px solid rgba(255,255,255,.15);border-radius:0 6px 6px 0;">&#9662;</button></div>` : ''} ${entry.has_playable ? `<div style="display:flex;gap:0;"><button class="btn-secondary push-folder-publish-btn" data-dir-index="${dirIndex}" data-folder-rel-path="${_esc(childPath)}" data-folder-name="${_esc(entry.name)}" style="padding:4px 5px;font-size:.80em;border-radius:6px 0 0 6px;">${_esc(t('push.publish_archive'))}</button><button class="btn-secondary push-folder-publish-existing-btn" data-dir-index="${dirIndex}" data-folder-rel-path="${_esc(childPath)}" data-folder-name="${_esc(entry.name)}" style="padding:4px 3px;font-size:.80em;border-left:1px solid rgba(255,255,255,.15);border-radius:0 6px 6px 0;">&#9662;</button></div>` : ''}
${entry.has_video ${entry.has_video
? (_folderHasJob ? (_folderHasJob
@@ -5281,9 +5869,9 @@
: `<button class="btn-primary push-folder-push-btn" data-dir-index="${dirIndex}" data-folder-rel-path="${_esc(childPath)}" data-folder-name="${_esc(entry.name)}" style="padding:4px 5px;font-size:.80em;">${_esc(t('push.start'))}</button>`) : `<button class="btn-primary push-folder-push-btn" data-dir-index="${dirIndex}" data-folder-rel-path="${_esc(childPath)}" data-folder-name="${_esc(entry.name)}" style="padding:4px 5px;font-size:.80em;">${_esc(t('push.start'))}</button>`)
: ''} : ''}
</div>` : ''; </div>` : '';
html += `<div class="push-dir-row" data-idx="${dirIndex}" data-path="${_esc(childPath)}" style="display:flex;align-items:center;gap:10px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:10px 14px;cursor:pointer;"> html += `<div class="push-dir-row fb-row" data-idx="${dirIndex}" data-path="${_esc(childPath)}" style="display:flex;align-items:center;gap:10px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:10px 14px;cursor:pointer;">
<span style="font-size:1.1em;">&#128193;</span> <span style="font-size:1.1em;">&#128193;</span>
<div style="font-weight:600;font-size:.9em;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${_esc(entry.name)}</div> <div style="font-weight:600;font-size:.9em;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${_esc(entry.name)}</div>
${folderBtns} ${folderBtns}
</div>`; </div>`;
} else { } else {
@@ -5294,13 +5882,13 @@
const _tagHtml = _ext const _tagHtml = _ext
? `<span style="display:inline-block;padding:1px 6px;border-radius:3px;font-size:.7em;font-weight:700;letter-spacing:.04em;background:${_tagBg};color:#fff;">${_esc(_ext)}</span>` ? `<span style="display:inline-block;padding:1px 6px;border-radius:3px;font-size:.7em;font-weight:700;letter-spacing:.04em;background:${_tagBg};color:#fff;">${_esc(_ext)}</span>`
: ''; : '';
html += `<div style="display:flex;align-items:center;gap:10px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:10px 14px;"> html += `<div class="fb-row" style="display:flex;align-items:center;gap:10px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:10px 14px;">
<span style="font-size:1.1em;">&#127916;</span> <span style="font-size:1.1em;">&#127916;</span>
<div style="flex:1;min-width:0;"> <div style="flex:1;min-width:0;">
<div style="font-weight:600;font-size:.9em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" title="${_esc(entry.name)}">${_esc(_stem)}</div> <div style="font-weight:600;font-size:.9em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" title="${_esc(entry.name)}">${_esc(_stem)}</div>
<div style="font-size:.8em;color:var(--muted);margin-top:2px;display:flex;align-items:center;gap:5px;">${_esc(fmtBytes(entry.size || 0))}${_tagHtml}</div> <div style="font-size:.8em;color:var(--muted);margin-top:2px;display:flex;align-items:center;gap:5px;">${_esc(fmtBytes(entry.size || 0))}${_tagHtml}</div>
</div> </div>
<div style="display:flex;gap:6px;flex-shrink:0;"> <div class="fb-actions" style="display:flex;gap:6px;">
${entry.video_url ? `<div style="display:flex;gap:0;"><button class="btn-secondary push-publish-btn" data-video-url="${_esc(entry.video_url)}" data-filename="${_esc(entry.name)}" style="padding:4px 5px;font-size:.80em;border-radius:6px 0 0 6px;">${_esc(t('push.publish_archive'))}</button><button class="btn-secondary push-publish-existing-btn" data-video-url="${_esc(entry.video_url)}" data-filename="${_esc(entry.name)}" style="padding:4px 3px;font-size:.80em;border-left:1px solid rgba(255,255,255,.15);border-radius:0 6px 6px 0;">&#9662;</button></div>` : ''} ${entry.video_url ? `<div style="display:flex;gap:0;"><button class="btn-secondary push-publish-btn" data-video-url="${_esc(entry.video_url)}" data-filename="${_esc(entry.name)}" style="padding:4px 5px;font-size:.80em;border-radius:6px 0 0 6px;">${_esc(t('push.publish_archive'))}</button><button class="btn-secondary push-publish-existing-btn" data-video-url="${_esc(entry.video_url)}" data-filename="${_esc(entry.name)}" style="padding:4px 3px;font-size:.80em;border-left:1px solid rgba(255,255,255,.15);border-radius:0 6px 6px 0;">&#9662;</button></div>` : ''}
${(() => { const _fj = _jobMap[`v|${dirIndex}|${relPath}|${entry.name}`] ?? null; ${(() => { const _fj = _jobMap[`v|${dirIndex}|${relPath}|${entry.name}`] ?? null;
return _fj return _fj
@@ -5540,6 +6128,9 @@
document.querySelector('[data-admin-view-target="local"]') document.querySelector('[data-admin-view-target="local"]')
?.addEventListener('click', openPushView); ?.addEventListener('click', openPushView);
document.addEventListener('admin:enter-local', openPushView); document.addEventListener('admin:enter-local', openPushView);
document.getElementById('push-sidebar-toggle')?.addEventListener('click', () => {
document.getElementById('push-sidebar-wrap')?.classList.toggle('sidebar-open');
});
document.querySelectorAll('.admin-menu-btn:not([data-admin-view-target="local"]), .admin-sub-btn') document.querySelectorAll('.admin-menu-btn:not([data-admin-view-target="local"]), .admin-sub-btn')
.forEach(btn => btn.addEventListener('click', stopJobPolling)); .forEach(btn => btn.addEventListener('click', stopJobPolling));
+11 -4
View File
@@ -677,6 +677,12 @@
#sh-load-more-wrap { #sh-load-more-wrap {
text-align: center; text-align: center;
padding: 16px 0 24px; padding: 16px 0 24px;
transition: opacity 0.18s ease, transform 0.18s ease;
}
#sh-load-more-wrap.lm-hidden {
opacity: 0;
transform: translateY(6px);
pointer-events: none;
} }
#sh-load-more-btn { #sh-load-more-btn {
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -737,7 +743,7 @@
<div id="loading-indicator" class="loader"></div> <div id="loading-indicator" class="loader"></div>
<p id="error-message" class="message hidden"></p> <p id="error-message" class="message hidden"></p>
<div id="stream-list"></div> <div id="stream-list"></div>
<div id="sh-load-more-wrap" style="display:none;"> <div id="sh-load-more-wrap" class="lm-hidden">
<button id="sh-load-more-btn"></button> <button id="sh-load-more-btn"></button>
</div> </div>
</main> </main>
@@ -1097,7 +1103,7 @@
if (!streams.length) { if (!streams.length) {
errorMessage.textContent = emptyText(activeStreamLabel); errorMessage.textContent = emptyText(activeStreamLabel);
errorMessage.classList.remove('hidden'); errorMessage.classList.remove('hidden');
document.getElementById('sh-load-more-wrap').style.display = 'none'; document.getElementById('sh-load-more-wrap')?.classList.add('lm-hidden');
return; return;
} }
if (_visibleCount === 0) _visibleCount = _calcBatch(); if (_visibleCount === 0) _visibleCount = _calcBatch();
@@ -1125,9 +1131,9 @@
if (lmWrap && lmBtn) { if (lmWrap && lmBtn) {
if (streams.length > _visibleCount) { if (streams.length > _visibleCount) {
lmBtn.textContent = t('list.load_more'); lmBtn.textContent = t('list.load_more');
lmWrap.style.display = ''; lmWrap.classList.remove('lm-hidden');
} else { } else {
lmWrap.style.display = 'none'; lmWrap.classList.add('lm-hidden');
} }
} }
}; };
@@ -1139,6 +1145,7 @@
activeStreamLabel = nextLabel; activeStreamLabel = nextLabel;
localStorage.setItem('home_stream_label', activeStreamLabel); localStorage.setItem('home_stream_label', activeStreamLabel);
streamList.classList.add('is-switching'); streamList.classList.add('is-switching');
document.getElementById('sh-load-more-wrap')?.classList.add('lm-hidden');
window.setTimeout(() => { window.setTimeout(() => {
_visibleCount = 0; _visibleCount = 0;
renderStreams(); renderStreams();
+642 -9
View File
@@ -8,6 +8,7 @@
<script src="/vendor/hls.min.js"></script> <script src="/vendor/hls.min.js"></script>
<script src="/vendor/flv.min.js"></script> <script src="/vendor/flv.min.js"></script>
<script src="/vendor/dash.all.min.js"></script> <script src="/vendor/dash.all.min.js"></script>
<script src="/vendor/shaka-player.compiled.js"></script>
<script src="/vendor/artplayer.js"></script> <script src="/vendor/artplayer.js"></script>
<script src="/vendor/artplayer-plugin-hls-control.js"></script> <script src="/vendor/artplayer-plugin-hls-control.js"></script>
<style> <style>
@@ -181,6 +182,11 @@
flv_unsupported: '当前浏览器不支持 FLV 播放', flv_unsupported: '当前浏览器不支持 FLV 播放',
hls_unsupported: '当前浏览器不支持 HLS 播放', hls_unsupported: '当前浏览器不支持 HLS 播放',
dash_unavailable: 'DASH 播放组件未加载', dash_unavailable: 'DASH 播放组件未加载',
drm_unavailable: '当前浏览器不支持此 DRM 播放',
drm_license_missing:'未配置 DRM License URL',
drm_certificate_missing:'未配置 FairPlay Certificate URL',
drm_playback_error:'DRM 授权播放失败',
drm_android_webview_unsupported:'当前内置浏览器不支持此 DRM 播放,请使用系统浏览器打开',
quality: '画质', quality: '画质',
// API error codes from server // API error codes from server
'err.stream_not_found': '直播不存在', 'err.stream_not_found': '直播不存在',
@@ -209,6 +215,11 @@
flv_unsupported: 'FLV playback is not supported in this browser', flv_unsupported: 'FLV playback is not supported in this browser',
hls_unsupported: 'HLS playback is not supported in this browser', hls_unsupported: 'HLS playback is not supported in this browser',
dash_unavailable: 'DASH player component not loaded', dash_unavailable: 'DASH player component not loaded',
drm_unavailable: 'This browser does not support the configured DRM playback',
drm_license_missing:'DRM License URL is not configured',
drm_certificate_missing:'FairPlay Certificate URL is not configured',
drm_playback_error:'DRM license playback failed',
drm_android_webview_unsupported:'This in-app browser does not support this DRM playback. Open it in your system browser.',
quality: 'Quality', quality: 'Quality',
// API error codes from server // API error codes from server
'err.stream_not_found': 'Stream not found', 'err.stream_not_found': 'Stream not found',
@@ -222,7 +233,7 @@
}, },
}; };
const PLAYER_LANG = (localStorage.getItem('lang_pref') || 'zh') === 'en' ? 'en' : 'zh'; const PLAYER_LANG = (localStorage.getItem('lang_pref') || 'zh') === 'en' ? 'en' : 'zh';
const t = key => (PLAYER_I18N[PLAYER_LANG] || PLAYER_I18N.zh)[key] || key; const t = key => (PLAYER_I18N[PLAYER_LANG] || PLAYER_I18N.zh)[key] || PLAYER_I18N.en[key] || key;
// Initialise static text in the HTML that cannot use data-i18n. // Initialise static text in the HTML that cannot use data-i18n.
document.getElementById('msg').textContent = t('loading'); document.getElementById('msg').textContent = t('loading');
@@ -324,7 +335,467 @@
return Object.keys(clearkeys).length ? { 'org.w3.clearkey': { clearkeys } } : null; return Object.keys(clearkeys).length ? { 'org.w3.clearkey': { clearkeys } } : null;
} }
function parseHeaderConfig(input) {
const text = String(input || '').trim();
if (!text) return {};
try {
const parsed = JSON.parse(text);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return Object.entries(parsed).reduce((acc, [key, value]) => {
if (key && value !== undefined && value !== null) acc[key] = String(value);
return acc;
}, {});
}
} catch (e) {}
return text.split(/\n+/).reduce((acc, line) => {
const idx = line.indexOf(':');
if (idx > 0) {
const key = line.slice(0, idx).trim();
const value = line.slice(idx + 1).trim();
if (key && value) acc[key] = value;
}
return acc;
}, {});
}
function getFairPlayContentId(initData) {
const text = shaka.util.StringUtils.fromBytesAutoDetect(initData);
return shaka.util.FairPlayUtils.defaultGetContentId(text);
}
function transformFairPlayInitData(initData, initDataType, drmInfo) {
if (initDataType !== 'skd') return initData;
const contentId = getFairPlayContentId(initData);
return shaka.util.FairPlayUtils.initDataTransform(initData, contentId, drmInfo.serverCertificate);
}
function fairPlayStringToUtf16Buffer(text) {
const buffer = new ArrayBuffer(text.length * 2);
const view = new Uint16Array(buffer);
for (let i = 0; i < text.length; i++) view[i] = text.charCodeAt(i);
return new Uint8Array(buffer);
}
function concatFairPlayInitData(initData, contentId, certificate) {
const init = new Uint8Array(initData);
const id = fairPlayStringToUtf16Buffer(contentId);
const cert = new Uint8Array(certificate);
const result = new Uint8Array(init.byteLength + 4 + id.byteLength + 4 + cert.byteLength);
const view = new DataView(result.buffer);
let offset = 0;
result.set(init, offset);
offset += init.byteLength;
view.setUint32(offset, id.byteLength, true);
offset += 4;
result.set(id, offset);
offset += id.byteLength;
view.setUint32(offset, cert.byteLength, true);
offset += 4;
result.set(cert, offset);
return result;
}
function getLegacyFairPlayContentId(initData) {
const text = shaka.util.StringUtils.fromBytesAutoDetect(initData);
const raw = shaka.util.FairPlayUtils.defaultGetContentId(text) || text.replace(/^skd:\/\//i, '');
const query = raw.includes('?') ? raw.split('?').pop() : raw;
const params = new URLSearchParams(query.replace(/^.*?assetId=/, 'assetId='));
return params.get('assetId') || raw;
}
function transformLegacyFairPlayInitData(initData, certificate) {
const contentId = getLegacyFairPlayContentId(initData);
return concatFairPlayInitData(initData, contentId, certificate);
}
function normalizeFairPlayLicenseResponse(buffer, contentType = '') {
const data = new Uint8Array(buffer);
const type = String(contentType || '').toLowerCase();
const looksTextWrapped = type.includes('json') || type.includes('xml') || type.includes('text');
if (!looksTextWrapped || !window.shaka?.util?.FairPlayUtils || !window.shaka?.net?.NetworkingEngine) {
return data;
}
try {
const response = { data, headers: { 'content-type': contentType } };
shaka.util.FairPlayUtils.commonFairPlayResponse(shaka.net.NetworkingEngine.RequestType.LICENSE, response);
return response.data;
} catch (error) {
console.warn('Native FairPlay response unwrap skipped:', error);
return data;
}
}
function setupNativeFairPlayHls(video, url, drm, art, options = {}) {
if (!window.WebKitMediaKeys || !WebKitMediaKeys.isTypeSupported('com.apple.fps.1_0', 'video/mp4')) {
return null;
}
const licenseUrl = String(drm?.licenseUrl || '').trim();
const certificateUrl = String(drm?.certificateUrl || '').trim();
const headers = parseHeaderConfig(drm?.licenseHeaders || '');
const keySystem = 'com.apple.fps.1_0';
const sessions = [];
const disposers = [];
let disposed = false;
let loaded = false;
const showNativeError = (stage, error) => {
const detail = error?.message || error?.name || error?.type || error?.code || String(error || 'unknown');
console.error(`Native FairPlay Error [${stage}]:`, error);
art.notice.show = `${t('drm_playback_error')} (${stage}: ${detail})`;
};
const certificatePromise = fetch(certificateUrl, { cache: 'force-cache' }).then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.arrayBuffer();
}).then(buffer => {
console.info('Native FairPlay certificate loaded:', certificateUrl, buffer.byteLength);
return new Uint8Array(buffer);
}).catch(error => {
showNativeError('certificate', error);
throw error;
});
const clearWaiting = () => {
loaded = true;
};
const handleNeedKey = event => {
console.info('Native FairPlay needkey:', event);
certificatePromise.then(certificate => {
if (disposed) return;
try {
const initData = event.initData || event.webkitInitData;
if (!initData || !initData.byteLength) throw new Error('missing initData');
const transformed = transformLegacyFairPlayInitData(initData, certificate);
console.info('Native FairPlay initData:', shaka.util.StringUtils.fromBytesAutoDetect(initData), 'contentId=', getLegacyFairPlayContentId(initData), initData.byteLength, transformed.byteLength);
let session = null;
try {
session = video.webkitKeys.createSession('video/mp4', transformed);
} catch (error) {
showNativeError('create-session', error);
return;
}
if (!session) {
showNativeError('create-session', new Error('empty session'));
return;
}
sessions.push(session);
const handleMessage = messageEvent => {
const message = messageEvent.message || messageEvent.webkitMessage;
if (!message || !message.byteLength) {
showNativeError('license-message', new Error('missing SPC message'));
return;
}
const licenseEndpoint = options.licenseProxyUrl || licenseUrl;
console.info('Native FairPlay license request:', licenseEndpoint, message.byteLength);
const requestHeaders = options.licenseProxyUrl
? { 'Content-Type': 'application/octet-stream', 'X-StreamHall-Viewer-Token': options.viewerToken || '' }
: { 'Content-Type': 'application/octet-stream', ...headers };
fetch(licenseEndpoint, {
method: 'POST',
headers: requestHeaders,
body: message
}).then(async response => {
const contentType = response.headers.get('content-type') || '';
console.info('Native FairPlay license status:', response.status, contentType);
const buffer = await response.arrayBuffer();
if (!response.ok) {
const text = new TextDecoder().decode(buffer.slice(0, 2048));
console.warn('Native FairPlay license error body:', text);
throw `HTTP ${response.status}: ${text}`;
}
return { buffer, contentType };
}).then(({ buffer, contentType }) => {
if (disposed) return;
console.info('Native FairPlay license response:', buffer.byteLength);
session.update(normalizeFairPlayLicenseResponse(buffer, contentType));
}).catch(error => showNativeError('license', error));
};
const handleKeyError = errorEvent => {
const code = session.error ? `${session.error.code || ''} ${session.error.systemCode || ''}`.trim() : '';
showNativeError('key-session', code ? new Error(code) : errorEvent);
};
session.addEventListener('webkitkeymessage', handleMessage);
session.addEventListener('webkitkeyerror', handleKeyError);
disposers.push(() => {
session.removeEventListener('webkitkeymessage', handleMessage);
session.removeEventListener('webkitkeyerror', handleKeyError);
});
} catch (error) {
showNativeError('needkey', error);
}
}).catch(error => showNativeError('certificate', error));
};
const handleVideoError = () => showNativeError('media', video.error || new Error('Native FairPlay media error'));
try {
video.webkitSetMediaKeys(new WebKitMediaKeys(keySystem));
} catch (error) {
showNativeError('mediakeys', error);
return null;
}
video.addEventListener('webkitneedkey', handleNeedKey);
video.addEventListener('loadeddata', clearWaiting);
video.addEventListener('playing', clearWaiting);
video.addEventListener('error', handleVideoError);
disposers.push(() => {
video.removeEventListener('webkitneedkey', handleNeedKey);
video.removeEventListener('loadeddata', clearWaiting);
video.removeEventListener('playing', clearWaiting);
video.removeEventListener('error', handleVideoError);
});
const timeout = window.setTimeout(() => {
if (!disposed && !loaded && video.readyState < 2) {
art.notice.show = `${t('drm_playback_error')} (native FairPlay timeout)`;
}
}, 15000);
video.src = url;
video.load();
return () => {
disposed = true;
window.clearTimeout(timeout);
disposers.splice(0).forEach(dispose => dispose());
try { video.removeAttribute('src'); video.load(); } catch (e) {}
};
}
function getDrmConfigs(link) {
const configs = Array.isArray(link?.drmConfigs) ? link.drmConfigs : Array.isArray(link?.drm_configs) ? link.drm_configs : [];
const normalized = configs.map(config => ({
drmType: String(config?.drmType || config?.drm_type || '').toLowerCase(),
licenseUrl: String(config?.licenseUrl || config?.license_url || '').trim(),
certificateUrl: String(config?.certificateUrl || config?.certificate_url || '').trim(),
licenseHeaders: String(config?.licenseHeaders || config?.license_headers || '').trim(),
pssh: String(config?.pssh || '').trim(),
playbackUrl: String(config?.playbackUrl || config?.playback_url || '').trim(),
playbackType: String(config?.playbackType || config?.playback_type || '').trim().toLowerCase(),
playback_url: String(config?.playback_url || '').trim()
})).filter(config => (config.drmType === 'widevine' || config.drmType === 'fairplay') && config.licenseUrl);
if (normalized.length) return normalized;
const legacyType = String(link?.drmType || link?.drm_type || '').toLowerCase();
const legacyLicense = String(link?.licenseUrl || link?.license_url || '').trim();
if ((legacyType === 'widevine' || legacyType === 'fairplay') && legacyLicense) {
return [{
drmType: legacyType,
licenseUrl: legacyLicense,
certificateUrl: String(link?.certificateUrl || link?.certificate_url || '').trim(),
licenseHeaders: String(link?.licenseHeaders || link?.license_headers || '').trim(),
pssh: String(link?.pssh || '').trim(),
playbackUrl: String(link?.playbackUrl || link?.playback_url || '').trim(),
playbackType: String(link?.playbackType || link?.playback_type || '').trim().toLowerCase(),
playback_url: String(link?.playback_url || '').trim()
}];
}
return [];
}
function isAppleWebKitFairPlayCapable() {
const ua = navigator.userAgent || '';
const isiOSWebKit = /iPad|iPhone|iPod/i.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
const isDesktopSafari = /Safari/i.test(ua) && !/(Chrome|Chromium|CriOS|FxiOS|Edg|OPR|OPiOS)/i.test(ua);
return !!window.WebKitMediaKeys || isiOSWebKit || isDesktopSafari;
}
function browserPrefersFairPlay() {
return isAppleWebKitFairPlayCapable();
}
function isAndroidRestrictedWebView() {
const ua = navigator.userAgent || '';
if (!/Android/i.test(ua)) return false;
return /Telegram/i.test(ua);
}
async function canUseWidevineKeySystem() {
if (!navigator.requestMediaKeySystemAccess) return false;
try {
await navigator.requestMediaKeySystemAccess('com.widevine.alpha', [{
initDataTypes: ['cenc'],
audioCapabilities: [{ contentType: 'audio/mp4; codecs="mp4a.40.2"' }],
videoCapabilities: [{ contentType: 'video/mp4; codecs="avc1.42E01E"' }],
distinctiveIdentifier: 'optional',
persistentState: 'optional',
sessionTypes: ['temporary']
}]);
return true;
} catch (error) {
return false;
}
}
function selectDrmConfig(link) {
const configs = getDrmConfigs(link);
if (!configs.length) return null;
if (browserPrefersFairPlay()) {
return configs.find(config => config.drmType === 'fairplay' && config.certificateUrl) || configs.find(config => config.drmType === 'fairplay') || null;
}
return configs.find(config => config.drmType === 'widevine') || null;
}
function linkUsesWidevine(link) {
return selectDrmConfig(link)?.drmType === 'widevine';
}
function linkUsesFairPlay(link) {
return selectDrmConfig(link)?.drmType === 'fairplay';
}
function linkUsesShakaDrm(link) {
return !!selectDrmConfig(link);
}
function safeDestroyHls(art) {
try {
const hls = art?.hls;
if (hls) hls.destroy();
} catch (error) {
if (!String(error?.message || error).includes('Cannot find instance of HLS')) {
console.warn('HLS cleanup skipped:', error);
}
}
}
function linkHasDrmConfigs(link) {
return getDrmConfigs(link).length > 0;
}
function drmConfigMatchScore(config, selected) {
if (!selected || config.drmType !== selected.drmType) return -1;
let score = 1;
const fields = ['licenseUrl', 'certificateUrl', 'playbackUrl', 'playbackType', 'pssh', 'licenseHeaders'];
for (const field of fields) {
const left = String(config[field] || '').trim();
const right = String(selected[field] || '').trim();
if (left && right && left !== right) return -1;
if (left === right) score += 1;
}
return score;
}
function getDrmConfigIndex(link, selected) {
const configs = getDrmConfigs(link);
let bestIndex = -1;
let bestScore = -1;
configs.forEach((config, index) => {
const score = drmConfigMatchScore(config, selected);
if (score > bestScore) {
bestScore = score;
bestIndex = index;
}
});
return bestIndex;
}
function formatShakaError(error) {
if (!error) return t('drm_playback_error');
if (error.code === 6001 && isAndroidRestrictedWebView()) {
return t('drm_android_webview_unsupported');
}
const parts = [];
if (error.code !== undefined) parts.push(`code ${error.code}`);
if (error.category !== undefined) parts.push(`category ${error.category}`);
if (error.severity !== undefined) parts.push(`severity ${error.severity}`);
return parts.length ? `${t('drm_playback_error')} (${parts.join(', ')})` : t('drm_playback_error');
}
function installLiveDurationGuard(art) {
const root = art?.template?.$player;
if (!root) return () => {};
const fix = () => {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
nodes.forEach(node => {
const text = node.nodeValue || '';
if (!text.includes(' / ')) return;
const fixed = text.replace(/\s+\/\s+\d{4,}:\d{2}:\d{2}/g, '');
if (fixed !== text) node.nodeValue = fixed;
});
};
const timer = window.setInterval(fix, 500);
fix();
return () => window.clearInterval(timer);
}
function installShakaQualityControl(art, player) {
const update = () => {
const tracks = player.getVariantTracks()
.filter(track => track.videoId !== null && track.videoId !== undefined && (track.height || track.bandwidth))
.sort((a, b) => (b.height || 0) - (a.height || 0) || (b.bandwidth || 0) - (a.bandwidth || 0));
const byVideoTrack = new Map();
tracks.forEach(track => {
const key = track.videoId != null ? `video-${track.videoId}` : `${track.width || 0}x${track.height || 0}-${track.codecs || ''}-${Math.round((track.bandwidth || 0) / 250000)}`;
const current = byVideoTrack.get(key);
if (!current || track.active || (!current.active && (track.bandwidth || 0) > (current.bandwidth || 0))) {
byVideoTrack.set(key, track);
}
});
const unique = Array.from(byVideoTrack.values())
.sort((a, b) => (b.height || 0) - (a.height || 0) || (b.bandwidth || 0) - (a.bandwidth || 0));
if (!unique.length) return;
const active = unique.find(track => track.active) || tracks.find(track => track.active);
const autoLabel = 'Auto';
const heightCounts = unique.reduce((acc, track) => {
const key = track.height || 0;
acc.set(key, (acc.get(key) || 0) + 1);
return acc;
}, new Map());
const qualityLabel = track => {
if (!track) return autoLabel;
const kbps = Math.round((track.videoBandwidth || track.bandwidth || 0) / 1000);
if (!track.height) return `${kbps}K`;
if ((heightCounts.get(track.height) || 0) <= 1) return `${track.height}P`;
return kbps >= 1000 ? `${track.height}P (${(kbps / 1000).toFixed(1)}M)` : `${track.height}P (${kbps}K)`;
};
const selectedLabel = active ? qualityLabel(active) : autoLabel;
const selector = unique.map(track => ({
html: qualityLabel(track),
value: track.id,
default: !!track.active
}));
selector.push({
html: autoLabel,
value: 'auto',
default: !active
});
const onSelect = item => {
if (item.value === 'auto') {
player.configure({ abr: { enabled: true } });
} else {
const selected = player.getVariantTracks().find(track => String(track.id) === String(item.value));
if (selected) {
player.configure({ abr: { enabled: false } });
player.selectVariantTrack(selected, true);
}
}
window.setTimeout(update, 150);
return item.html;
};
art.setting.update({
name: 'shaka-quality',
html: t('quality'),
tooltip: selectedLabel,
width: 200,
selector,
onSelect
});
art.controls.update({
name: 'shaka-quality',
position: 'right',
index: 11,
html: selectedLabel,
style: { padding: '0 10px', marginRight: '10px' },
selector,
onSelect
});
};
player.addEventListener('variantchanged', update);
window.setTimeout(update, 0);
window.setTimeout(update, 800);
return update;
}
function getLinkType(link) { function getLinkType(link) {
if (linkUsesShakaDrm(link)) return 'm3u8';
const selectedDrm = selectDrmConfig(link);
if (selectedDrm?.playbackUrl) {
if (selectedDrm.playbackType) return selectedDrm.playbackType;
const drmPath = selectedDrm.playbackUrl.split('?')[0].toLowerCase();
if (drmPath.endsWith('.mpd')) return 'dash';
if (drmPath.endsWith('.m3u8')) return 'm3u8';
if (drmPath.endsWith('.flv')) return 'flv';
}
if (link.type) return link.type; if (link.type) return link.type;
const path = (link.url || '').split('?')[0].toLowerCase(); const path = (link.url || '').split('?')[0].toLowerCase();
if (path.endsWith('.mpd')) return 'dash'; if (path.endsWith('.mpd')) return 'dash';
@@ -334,6 +805,8 @@
} }
function getPlaybackUrl(link) { function getPlaybackUrl(link) {
const selectedDrm = selectDrmConfig(link);
if (selectedDrm?.playbackUrl) return selectedDrm.playback_url || selectedDrm.playbackUrl;
return link.playback_url || link.url; return link.playback_url || link.url;
} }
@@ -354,6 +827,7 @@
let viewerHeartbeatTimer = null; let viewerHeartbeatTimer = null;
let viewerSessionId = ''; let viewerSessionId = '';
let viewerVisitorId = localStorage.getItem('streamhall_visitor_id') || ''; let viewerVisitorId = localStorage.getItem('streamhall_visitor_id') || '';
let viewerToken = '';
let playerInstance = null; let playerInstance = null;
let playbackMisses = 0; let playbackMisses = 0;
if (!viewerVisitorId) { if (!viewerVisitorId) {
@@ -431,6 +905,7 @@
const data = await postViewerEvent('viewer_start', { const data = await postViewerEvent('viewer_start', {
id: streamId, id: streamId,
visitorId: viewerVisitorId, visitorId: viewerVisitorId,
viewerToken,
referer: document.referrer || '', referer: document.referrer || '',
state: viewerState() state: viewerState()
}); });
@@ -538,6 +1013,7 @@
els.pwdModal.classList.remove('hidden'); els.pwdModal.classList.remove('hidden');
document.getElementById('pwd-in').focus(); document.getElementById('pwd-in').focus();
} else { } else {
viewerToken = data.viewerToken || '';
startViewerStats(); startViewerStats();
waitForLiveAndStart(data); waitForLiveAndStart(data);
} }
@@ -553,6 +1029,7 @@
applyPlayerTitle(data); applyPlayerTitle(data);
applySiteIcon(data.siteIconUrl || ''); applySiteIcon(data.siteIconUrl || '');
els.pwdModal.classList.add('hidden'); els.pwdModal.classList.add('hidden');
viewerToken = data.viewerToken || '';
startViewerStats(); startViewerStats();
waitForLiveAndStart(data, pwd); waitForLiveAndStart(data, pwd);
} catch (err) { } catch (err) {
@@ -592,7 +1069,7 @@
return; return;
} }
const activeLinks = [...links]; const activeLinks = links.map((link, index) => ({ ...link, _sourceIndex: index }));
const preferredIndex = activeLinks.findIndex(link => link.url === preferredUrl || getPlaybackUrl(link) === preferredUrl); const preferredIndex = activeLinks.findIndex(link => link.url === preferredUrl || getPlaybackUrl(link) === preferredUrl);
if (preferredIndex > 0) { if (preferredIndex > 0) {
activeLinks.unshift(activeLinks.splice(preferredIndex, 1)[0]); activeLinks.unshift(activeLinks.splice(preferredIndex, 1)[0]);
@@ -604,6 +1081,13 @@
url: getPlaybackUrl(l), url: getPlaybackUrl(l),
type: getLinkType(l) type: getLinkType(l)
})); }));
const initialLink = activeLinks[0] || null;
const initialUsesDrm = linkUsesShakaDrm(initialLink);
const hlsControlPlugins = initialUsesDrm ? [] : [
artplayerPluginHlsControl({
quality: { control: true, setting: true, getName: (l) => l.height + 'P', title: t('quality') }
})
];
playerInstance = new Artplayer({ playerInstance = new Artplayer({
container: '.artplayer-app', container: '.artplayer-app',
@@ -612,6 +1096,7 @@
quality: quality, quality: quality,
title: data.eventName, title: data.eventName,
autoplay: true, autoplay: true,
isLive: data.streamLabel === 'LIVE' && !initialUsesDrm,
volume: 0.5, volume: 0.5,
autoSize: true, autoSize: true,
fullscreen: true, fullscreen: true,
@@ -631,9 +1116,161 @@
} }
}, },
m3u8: function (video, url, art) { m3u8: function (video, url, art) {
if (Hls.isSupported()) {
if (art.hls) art.hls.destroy();
const currentLink = activeLinks.find(l => l.url === url || getPlaybackUrl(l) === url); const currentLink = activeLinks.find(l => l.url === url || getPlaybackUrl(l) === url);
if (linkHasDrmConfigs(currentLink) && !selectDrmConfig(currentLink)) {
art.notice.show = t('drm_unavailable');
return;
}
if (linkUsesShakaDrm(currentLink)) {
const selectedDrm = selectDrmConfig(currentLink);
const fairplay = selectedDrm?.drmType === 'fairplay';
const certificateUrl = String(selectedDrm?.certificateUrl || '').trim();
const licenseUrl = String(selectedDrm?.licenseUrl || '').trim();
if (!licenseUrl) {
art.notice.show = t('drm_license_missing');
return;
}
if (fairplay && !certificateUrl) {
art.notice.show = t('drm_certificate_missing');
return;
}
if (selectedDrm?.drmType === 'widevine' && isAndroidRestrictedWebView()) {
canUseWidevineKeySystem().then(supported => {
if (!supported) art.notice.show = t('drm_android_webview_unsupported');
});
}
if (art.nativeFairPlayCleanup) {
art.nativeFairPlayCleanup();
art.nativeFairPlayCleanup = null;
}
safeDestroyHls(art);
const linkIndex = Number.isInteger(currentLink?._sourceIndex) ? currentLink._sourceIndex : activeLinks.indexOf(currentLink);
const drmIndex = getDrmConfigIndex(currentLink, selectedDrm);
if (fairplay && isAppleWebKitFairPlayCapable() && window.WebKitMediaKeys) {
if (art.shaka) {
art.shaka.destroy().catch(() => {});
art.shaka = null;
}
if (art.streamhallDurationGuard) art.streamhallDurationGuard();
art.streamhallDurationGuard = installLiveDurationGuard(art);
const licenseProxyUrl = linkIndex >= 0 && drmIndex >= 0
? `/api?action=fairplay_license&id=${encodeURIComponent(streamId)}&link=${encodeURIComponent(linkIndex)}&drm=${encodeURIComponent(drmIndex)}`
: '';
const cleanup = setupNativeFairPlayHls(video, url, selectedDrm, art, { licenseProxyUrl, viewerToken });
if (!cleanup) {
art.notice.show = t('drm_unavailable');
return;
}
art.nativeFairPlayCleanup = cleanup;
art.on('destroy', () => {
if (art.streamhallDurationGuard) art.streamhallDurationGuard();
if (art.nativeFairPlayCleanup) {
art.nativeFairPlayCleanup();
art.nativeFairPlayCleanup = null;
}
});
return;
}
if (window.shaka?.polyfill) shaka.polyfill.installAll();
if (!window.shaka || !shaka.Player || !shaka.Player.isBrowserSupported()) {
art.notice.show = t('drm_unavailable');
return;
}
if (art.shaka) art.shaka.destroy().catch(() => {});
if (art.streamhallDurationGuard) art.streamhallDurationGuard();
art.streamhallDurationGuard = installLiveDurationGuard(art);
const player = new shaka.Player();
const headers = parseHeaderConfig(selectedDrm?.licenseHeaders || '');
const keySystem = fairplay ? 'com.apple.fps' : 'com.widevine.alpha';
const fairplayLegacyKeySystem = 'com.apple.fps.1_0';
const widevineProxyUrl = !fairplay && linkIndex >= 0 && drmIndex >= 0
? `/api?action=widevine_license&id=${encodeURIComponent(streamId)}&link=${encodeURIComponent(linkIndex)}&drm=${encodeURIComponent(drmIndex)}&vt=${encodeURIComponent(viewerToken || '')}`
: '';
const shakaLicenseUrl = widevineProxyUrl || licenseUrl;
const servers = { [keySystem]: shakaLicenseUrl };
const advanced = {};
if (fairplay) {
servers[fairplayLegacyKeySystem] = shakaLicenseUrl;
advanced[keySystem] = {
serverCertificateUri: certificateUrl,
audioRobustness: '',
videoRobustness: ''
};
advanced[fairplayLegacyKeySystem] = {
serverCertificateUri: certificateUrl,
audioRobustness: '',
videoRobustness: ''
};
} else {
advanced[keySystem] = {
audioRobustness: '',
videoRobustness: '',
persistentStateRequired: false,
distinctiveIdentifierRequired: false
};
}
const drmConfig = {
preferredKeySystems: fairplay ? [keySystem, fairplayLegacyKeySystem] : [keySystem],
servers,
advanced
};
player.configure({
drm: {
...drmConfig,
...(fairplay ? { initDataTransform: transformFairPlayInitData } : {})
},
...(fairplay ? { streaming: { useNativeHlsForFairPlay: isAppleWebKitFairPlayCapable() } } : {})
});
player.getNetworkingEngine().registerRequestFilter((type, request) => {
if (type !== shaka.net.NetworkingEngine.RequestType.LICENSE) return;
if (fairplay) {
request.headers['Content-Type'] = request.headers['Content-Type'] || 'application/octet-stream';
}
if (widevineProxyUrl) {
request.headers['X-StreamHall-Viewer-Token'] = viewerToken || '';
}
if (Object.keys(headers).length) {
Object.assign(request.headers, headers);
}
});
if (fairplay) {
player.getNetworkingEngine().registerResponseFilter((type, response) => {
shaka.util.FairPlayUtils.commonFairPlayResponse(type, response);
});
}
player.addEventListener('error', event => {
console.error('Shaka DRM Error:', event.detail);
art.notice.show = formatShakaError(event.detail);
});
player.attach(video).then(() => player.load(url)).then(() => {
installShakaQualityControl(art, player);
}).catch(error => {
console.error('Shaka Load Error:', error);
art.notice.show = formatShakaError(error);
});
art.shaka = player;
art.on('destroy', () => {
if (art.streamhallDurationGuard) art.streamhallDurationGuard();
player.destroy().catch(() => {});
});
return;
}
if (Hls.isSupported()) {
if (art.nativeFairPlayCleanup) {
art.nativeFairPlayCleanup();
art.nativeFairPlayCleanup = null;
}
if (art.shaka) {
art.shaka.destroy().catch(() => {});
art.shaka = null;
}
if (art.streamhallDurationGuard) {
art.streamhallDurationGuard();
art.streamhallDurationGuard = null;
}
safeDestroyHls(art);
const keyOverride = currentLink ? parseHlsKeyOverride(currentLink.key) : null; const keyOverride = currentLink ? parseHlsKeyOverride(currentLink.key) : null;
class CustomLoader extends Hls.DefaultConfig.loader { class CustomLoader extends Hls.DefaultConfig.loader {
@@ -692,11 +1329,7 @@
art.on('destroy', () => dash.destroy()); art.on('destroy', () => dash.destroy());
} }
}, },
plugins: [ plugins: hlsControlPlugins
artplayerPluginHlsControl({
quality: { control: true, setting: true, getName: (l) => l.height + 'P', title: t('quality') }
})
]
}); });
startPlaybackMonitor(data, password); startPlaybackMonitor(data, password);
} }
+1455
View File
File diff suppressed because it is too large Load Diff
+698 -21
View File
@@ -7,6 +7,7 @@ import csv
import hashlib import hashlib
import hmac import hmac
import io import io
import ipaddress
import json import json
import mimetypes import mimetypes
import os import os
@@ -17,13 +18,14 @@ import tempfile
import threading import threading
import time import time
import uuid import uuid
import xml.etree.ElementTree as ET
from http import HTTPStatus from http import HTTPStatus
from http.cookies import SimpleCookie from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.parse import parse_qs, quote, urlencode, urljoin, urlparse from urllib.parse import parse_qs, quote, urlencode, urljoin, urlparse
from urllib.request import Request, urlopen from urllib.request import HTTPRedirectHandler, Request, build_opener, urlopen
try: try:
import psycopg import psycopg
@@ -90,6 +92,7 @@ VIDEO_EXTS = frozenset({".mp4", ".mkv", ".avi", ".flv", ".ts", ".mov", ".wmv", "
active_pushes: dict[str, dict] = {} active_pushes: dict[str, dict] = {}
_pushes_lock = threading.Lock() _pushes_lock = threading.Lock()
HLS_PROXY_PREFIX = "/proxy/hls" HLS_PROXY_PREFIX = "/proxy/hls"
VIEWER_TOKEN_TTL = 300 # seconds
HLS_URI_RE = re.compile(r'URI="([^"]+)"') # matches URI="..." attributes in HLS tag lines (e.g. EXT-X-KEY) HLS_URI_RE = re.compile(r'URI="([^"]+)"') # matches URI="..." attributes in HLS tag lines (e.g. EXT-X-KEY)
DEFAULT_SITE_SETTINGS = { DEFAULT_SITE_SETTINGS = {
@@ -421,14 +424,37 @@ def decode_proxy_target(value: str) -> str:
return base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8") return base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8")
def hls_proxy_host_token(host: str) -> str: def hls_proxy_url_token(url: str) -> str:
return sign(f"hls-proxy-host:{host.lower()}") return sign(f"hls-proxy-url:{url}")
def make_viewer_token(stream_id: int) -> str:
expiry = now() + VIEWER_TOKEN_TTL
payload = f"{stream_id}:{expiry}"
encoded = base64.urlsafe_b64encode(payload.encode()).decode().rstrip("=")
return f"{encoded}.{sign(f'viewer-token:{payload}')}"
def verify_viewer_token(token: str, stream_id: int) -> bool:
if not token or "." not in token:
return False
encoded, sig = token.rsplit(".", 1)
try:
padded = encoded + "=" * (-len(encoded) % 4)
payload = base64.urlsafe_b64decode(padded.encode()).decode()
tid_str, expiry_str = payload.split(":", 1)
except Exception:
return False
if int(tid_str) != stream_id:
return False
if now() > int(expiry_str):
return False
return hmac.compare_digest(sig, sign(f"viewer-token:{payload}"))
def hls_proxy_path(url: str) -> str: def hls_proxy_path(url: str) -> str:
host = urlparse(url).netloc
encoded = encode_proxy_target(url) encoded = encode_proxy_target(url)
return f"{HLS_PROXY_PREFIX}/{hls_proxy_host_token(host)}/{encoded}" return f"{HLS_PROXY_PREFIX}/{hls_proxy_url_token(url)}/{encoded}"
PLAYABLE_VIDEO_EXTS = frozenset({".mp4", ".mkv", ".mov", ".webm", ".m4v"}) PLAYABLE_VIDEO_EXTS = frozenset({".mp4", ".mkv", ".mov", ".webm", ".m4v"})
@@ -476,6 +502,22 @@ def add_playback_urls(links: list[dict[str, object]]) -> list[dict[str, object]]
parsed = urlparse(raw_url) parsed = urlparse(raw_url)
if is_hls_link(item) and parsed.scheme in ("http", "https"): if is_hls_link(item) and parsed.scheme in ("http", "https"):
item["playback_url"] = hls_proxy_path(raw_url) item["playback_url"] = hls_proxy_path(raw_url)
drm_configs = item.get("drmConfigs", [])
if isinstance(drm_configs, list):
prepared_configs: list[dict[str, object]] = []
for config in drm_configs:
if not isinstance(config, dict):
continue
config_item = dict(config)
drm_playback_url = str(config_item.get("playbackUrl") or "")
drm_playback_type = str(config_item.get("playbackType") or "")
if drm_playback_url:
probe_item = {"url": drm_playback_url, "type": drm_playback_type}
parsed_drm_url = urlparse(drm_playback_url)
if is_hls_link(probe_item) and parsed_drm_url.scheme in ("http", "https"):
config_item["playback_url"] = hls_proxy_path(drm_playback_url)
prepared_configs.append(config_item)
item["drmConfigs"] = prepared_configs
prepared.append(item) prepared.append(item)
return prepared return prepared
@@ -514,7 +556,79 @@ def admin_session_not_before() -> int:
return 0 return 0
def normalize_links(raw: object) -> list[dict[str, str]]: def parse_header_config(text: object) -> dict[str, str]:
raw = str(text or "").strip()
if not raw:
return {}
try:
parsed = json.loads(raw)
if isinstance(parsed, dict):
return {str(k): str(v) for k, v in parsed.items() if k and v is not None}
except json.JSONDecodeError:
pass
headers: dict[str, str] = {}
for line in raw.splitlines():
if ":" not in line:
continue
key, value = line.split(":", 1)
key = key.strip()
value = value.strip()
if key and value:
headers[key] = value
return headers
def normalize_drm_configs(item: dict[str, object]) -> list[dict[str, str]]:
raw_configs = item.get("drmConfigs", item.get("drm_configs", []))
configs = raw_configs if isinstance(raw_configs, list) else []
normalized: list[dict[str, str]] = []
for config in configs:
if not isinstance(config, dict):
continue
drm_type = str(config.get("drmType", config.get("drm_type", ""))).strip().lower()
if drm_type not in ("widevine", "fairplay"):
continue
license_url = str(config.get("licenseUrl", config.get("license_url", ""))).strip()
if not license_url:
continue
playback_type = str(config.get("playbackType", config.get("playback_type", ""))).strip().lower()
if playback_type not in ("", "m3u8", "flv", "dash"):
playback_type = ""
normalized.append(
{
"drmType": drm_type,
"licenseUrl": license_url,
"certificateUrl": str(config.get("certificateUrl", config.get("certificate_url", ""))).strip(),
"licenseHeaders": str(config.get("licenseHeaders", config.get("license_headers", ""))).strip(),
"pssh": str(config.get("pssh", "")).strip(),
"playbackUrl": str(config.get("playbackUrl", config.get("playback_url", ""))).strip(),
"playbackType": playback_type,
}
)
legacy_type = str(item.get("drmType", item.get("drm_type", ""))).strip().lower()
legacy_license = str(item.get("licenseUrl", item.get("license_url", ""))).strip()
if legacy_type in ("widevine", "fairplay") and legacy_license:
legacy_playback_type = str(item.get("playbackType", item.get("playback_type", ""))).strip().lower()
if legacy_playback_type not in ("", "m3u8", "flv", "dash"):
legacy_playback_type = ""
has_legacy = any(config["drmType"] == legacy_type for config in normalized)
if not has_legacy:
normalized.append(
{
"drmType": legacy_type,
"licenseUrl": legacy_license,
"certificateUrl": str(item.get("certificateUrl", item.get("certificate_url", ""))).strip(),
"licenseHeaders": str(item.get("licenseHeaders", item.get("license_headers", ""))).strip(),
"pssh": str(item.get("pssh", "")).strip(),
"playbackUrl": str(item.get("playbackUrl", item.get("playback_url", ""))).strip(),
"playbackType": legacy_playback_type,
}
)
return normalized
def normalize_links(raw: object) -> list[dict[str, object]]:
links = raw if isinstance(raw, list) else [] links = raw if isinstance(raw, list) else []
normalized = [] normalized = []
for item in links: for item in links:
@@ -527,6 +641,8 @@ def normalize_links(raw: object) -> list[dict[str, str]]:
link_type = str(item.get("type", "")).strip().lower() link_type = str(item.get("type", "")).strip().lower()
if link_type not in ("", "m3u8", "flv", "dash"): if link_type not in ("", "m3u8", "flv", "dash"):
link_type = "" link_type = ""
drm_configs = normalize_drm_configs(item)
first_drm = drm_configs[0] if drm_configs else {}
normalized.append( normalized.append(
{ {
"name": name, "name": name,
@@ -534,6 +650,14 @@ def normalize_links(raw: object) -> list[dict[str, str]]:
"url": url, "url": url,
"key": str(item.get("key", "")).strip(), "key": str(item.get("key", "")).strip(),
"clearkey": str(item.get("clearkey", "")).strip(), "clearkey": str(item.get("clearkey", "")).strip(),
"drmConfigs": drm_configs,
"drmType": str(first_drm.get("drmType", "")),
"licenseUrl": str(first_drm.get("licenseUrl", "")),
"certificateUrl": str(first_drm.get("certificateUrl", "")),
"licenseHeaders": str(first_drm.get("licenseHeaders", "")),
"pssh": str(first_drm.get("pssh", "")),
"playbackUrl": str(first_drm.get("playbackUrl", "")),
"playbackType": str(first_drm.get("playbackType", "")),
} }
) )
return normalized return normalized
@@ -627,6 +751,23 @@ def stream_probe_response(valid: bool, status_code: int | None = None) -> dict[s
} }
def stream_probe_drm_response(
valid: bool,
status_code: int | None,
drm_types: set[str],
configured_types: set[str],
) -> dict[str, object]:
missing = bool(drm_types) and not bool(drm_types & configured_types)
return {
"valid": valid and not missing,
"code": "drm_config_missing" if missing else ("detected" if valid else "no_info"),
"status_code": status_code,
"drm_detected": bool(drm_types),
"drm_types": sorted(drm_types),
"drm_configured": sorted(configured_types),
}
def decode_probe_text(data: bytes) -> str: def decode_probe_text(data: bytes) -> str:
for encoding in ("utf-8-sig", "utf-8", "latin-1"): for encoding in ("utf-8-sig", "utf-8", "latin-1"):
try: try:
@@ -636,6 +777,312 @@ def decode_probe_text(data: bytes) -> str:
return "" return ""
def hls_manifest_drm_types(text: str) -> set[str]:
lower = text.lower()
types: set[str] = set()
if "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed" in lower or "com.widevine" in lower:
types.add("widevine")
if "com.apple.streamingkeydelivery" in lower or "skd://" in lower:
types.add("fairplay")
return types
def hls_first_variant_url(text: str, base_url: str) -> str | None:
lines = text.splitlines()
for i, line in enumerate(lines):
if line.strip().startswith("#EXT-X-STREAM-INF"):
for j in range(i + 1, len(lines)):
candidate = lines[j].strip()
if candidate and not candidate.startswith("#"):
return urljoin(base_url, candidate)
return None
def dash_manifest_drm_types(text: str) -> set[str]:
lower = text.lower()
types: set[str] = set()
if "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed" in lower or "com.widevine" in lower:
types.add("widevine")
return types
class NoRedirectHandler(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
_NO_REDIRECT_OPENER = build_opener(NoRedirectHandler)
def reject_private_http_url(url: str) -> None:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.hostname:
raise AppError("invalid_request", "invalid manifest url")
hostname = parsed.hostname.strip().lower().rstrip(".")
if hostname in {"localhost", "ip6-localhost", "ip6-loopback"}:
raise AppError("invalid_request", "private manifest host is not allowed")
try:
candidates = [ipaddress.ip_address(hostname.strip("[]"))]
except ValueError:
try:
infos = socket.getaddrinfo(hostname, parsed.port or (443 if parsed.scheme == "https" else 80), type=socket.SOCK_STREAM)
except socket.gaierror as exc:
raise AppError("upstream_request_failed", f"DNS lookup failed: {hostname}") from exc
candidates = []
for info in infos:
sockaddr = info[4]
if not sockaddr:
continue
try:
candidates.append(ipaddress.ip_address(sockaddr[0]))
except ValueError:
continue
if not candidates:
raise AppError("upstream_request_failed", f"DNS lookup returned no usable address: {hostname}")
for address in candidates:
if (
address.is_private
or address.is_loopback
or address.is_link_local
or address.is_multicast
or address.is_reserved
or address.is_unspecified
):
raise AppError("invalid_request", f"private manifest address is not allowed: {address}")
def fetch_manifest_text(url: str, *, limit: int = 512 * 1024) -> tuple[str, int | None, str]:
headers = {
"User-Agent": "StreamHall/1.0",
"Accept": "application/dash+xml, application/vnd.apple.mpegurl, application/x-mpegURL, text/plain;q=0.8, */*;q=0.6",
}
current_url = url
for _ in range(4):
reject_private_http_url(current_url)
request = Request(current_url, headers=headers)
try:
with _NO_REDIRECT_OPENER.open(request, timeout=STREAM_PROBE_TIMEOUT) as resp:
final_url = resp.geturl() or current_url
reject_private_http_url(final_url)
status_code = int(getattr(resp, "status", resp.getcode()) or 0)
content_type = resp.headers.get("Content-Type", "")
data = resp.read(limit)
return decode_probe_text(data), status_code, content_type
except HTTPError as exc:
if exc.code in (301, 302, 303, 307, 308):
location = exc.headers.get("Location", "").strip()
if not location:
raise
current_url = urljoin(current_url, location)
continue
raise
raise AppError("upstream_request_failed", "too many manifest redirects")
def hls_attribute_value(line: str, name: str) -> str:
match = re.search(rf'(?:^|,){re.escape(name)}=("([^"]*)"|[^,]*)', line, re.IGNORECASE)
if not match:
return ""
value = match.group(2) if match.group(2) is not None else match.group(1)
return value.strip().strip('"')
def first_url_matching(text: str, pattern: str) -> str:
match = re.search(pattern, text, re.IGNORECASE)
return match.group(0) if match else ""
def brightcove_account_from_url(url: str) -> str:
parsed = urlparse(url)
patterns = (
r"/manifest/v1/hls/v\d+/fairplay/(\d+)/",
r"/manifest/v1/dash/[^/]+/[^/]+/(\d+)/",
r"/license/v1/[^/]+/[^/]+/(\d+)(?:/|$)",
r"/license/v1/fairplay_app_cert/(\d+)(?:/|$)",
)
for pattern in patterns:
match = re.search(pattern, parsed.path)
if match:
return match.group(1)
return ""
def discover_dash_drm(url: str, text: str) -> list[dict[str, str]]:
discovered: list[dict[str, str]] = []
try:
root = ET.fromstring(text)
except ET.ParseError:
return discovered
def local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1].lower()
content_nodes = [node for node in root.iter() if local_name(node.tag) == "contentprotection"]
for node in content_nodes:
scheme = str(node.attrib.get("schemeIdUri", "")).lower()
if "edef8ba9-79d6-4ace-a3c8-27dcd51d21ed" not in scheme and "widevine" not in scheme:
continue
license_url = ""
for key, value in node.attrib.items():
if key.rsplit("}", 1)[-1] == "licenseAcquisitionUrl":
license_url = str(value).strip()
break
if not license_url:
license_url = first_url_matching(ET.tostring(node, encoding="unicode"), r"https?://[^\s\"'<>]+/license/v1/cenc/widevine/[^\s\"'<>]+")
pssh = ""
for child in node.iter():
if local_name(child.tag) == "pssh" and child.text:
pssh = child.text.strip()
break
discovered.append(
{
"drmType": "widevine",
"licenseUrl": license_url,
"certificateUrl": "",
"pssh": pssh,
"playbackUrl": url,
"playbackType": "dash",
}
)
return [item for item in discovered if item.get("licenseUrl")]
def discover_hls_drm(url: str, text: str, *, include_variants: bool = True) -> list[dict[str, str]]:
discovered: list[dict[str, str]] = []
seen: set[tuple[str, str, str]] = set()
def add_item(item: dict[str, str]) -> None:
key = (item.get("drmType", ""), item.get("licenseUrl", ""), item.get("certificateUrl", ""))
if key in seen:
return
seen.add(key)
discovered.append(item)
cert_url = first_url_matching(text, r"https?://[^\s\"'<>]+/license/v1/fairplay_app_cert/[^\s\"'<>]+")
account_id = brightcove_account_from_url(url)
if not cert_url and account_id and "fairplay" in urlparse(url).path.lower():
cert_url = f"https://manifest.prod.boltdns.net/license/v1/fairplay_app_cert/{account_id}"
direct_license = first_url_matching(text, r"https?://[^\s\"'<>]+/license/v1/fairplay/[^\s\"'<>]+")
if direct_license or cert_url:
add_item(
{
"drmType": "fairplay",
"licenseUrl": direct_license,
"certificateUrl": cert_url,
"pssh": "",
"playbackUrl": url,
"playbackType": "m3u8",
"partial": not bool(direct_license),
}
)
for line in text.splitlines():
line = line.strip()
if not line.upper().startswith("#EXT-X-KEY"):
continue
keyformat = hls_attribute_value(line, "KEYFORMAT").lower()
uri = hls_attribute_value(line, "URI")
if "com.apple.streamingkeydelivery" in keyformat or uri.lower().startswith("skd://"):
license_url = uri if uri.lower().startswith(("http://", "https://")) else direct_license
add_item(
{
"drmType": "fairplay",
"licenseUrl": license_url,
"certificateUrl": cert_url,
"pssh": "",
"playbackUrl": url,
"playbackType": "m3u8",
"partial": not bool(license_url),
}
)
if include_variants:
for variant_url in hls_variant_urls(text, url)[:5]:
try:
variant_text, _, _ = fetch_manifest_text(variant_url)
except Exception:
continue
for item in discover_hls_drm(url, variant_text, include_variants=False):
add_item(item)
return [item for item in discovered if item.get("licenseUrl") or item.get("certificateUrl")]
def hls_variant_urls(text: str, base_url: str) -> list[str]:
urls: list[str] = []
lines = text.splitlines()
for i, line in enumerate(lines):
if line.strip().startswith("#EXT-X-STREAM-INF"):
for j in range(i + 1, len(lines)):
candidate = lines[j].strip()
if candidate and not candidate.startswith("#"):
urls.append(urljoin(base_url, candidate))
break
return urls
def discover_drm_from_url(raw_url: object, type_hint: object = "") -> dict[str, object]:
url = str(raw_url or "").strip()
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
raise AppError("invalid_request", "invalid playback url")
hint = str(type_hint or "").strip().lower()
path = parsed.path.lower()
is_hls = hint == "m3u8" or path.endswith(".m3u8")
is_dash = hint == "dash" or path.endswith(".mpd")
try:
text, status_code, content_type = fetch_manifest_text(url)
except HTTPError as exc:
raise AppError("upstream_http_error", f"HTTP {exc.code}") from exc
except (TimeoutError, URLError, OSError) as exc:
raise AppError("upstream_request_failed", str(exc)) from exc
lowered_type = content_type.lower()
if not is_hls and ("mpegurl" in lowered_type or "m3u8" in lowered_type or text.lstrip().startswith("#EXTM3U")):
is_hls = True
if not is_dash and ("dash+xml" in lowered_type or "<MPD" in text[:2048]):
is_dash = True
discovered: list[dict[str, str]] = []
if is_dash:
discovered.extend(discover_dash_drm(url, text))
if is_hls:
discovered.extend(discover_hls_drm(url, text))
unsupported: list[str] = []
lower = text.lower()
if "9a04f079-9840-4286-ab92-e65be0885f95" in lower or "/playready/" in lower:
unsupported.append("playready")
return {
"url": url,
"type": "dash" if is_dash else ("m3u8" if is_hls else ""),
"status_code": status_code,
"content_type": content_type,
"drmConfigs": discovered,
"unsupported": sorted(set(unsupported)),
}
def drm_config_types(configs: object) -> set[str]:
normalized: set[str] = set()
if not isinstance(configs, list):
return normalized
for config in configs:
if not isinstance(config, dict):
continue
drm_type = str(config.get("drmType", config.get("drm_type", ""))).strip().lower()
license_url = str(config.get("licenseUrl", config.get("license_url", ""))).strip()
certificate_url = str(config.get("certificateUrl", config.get("certificate_url", ""))).strip()
if drm_type == "widevine" and license_url:
normalized.add("widevine")
if drm_type == "fairplay" and license_url and certificate_url:
normalized.add("fairplay")
return normalized
def resolve_video_file_path(url_path: str) -> str | None: def resolve_video_file_path(url_path: str) -> str | None:
"""Decode a /video/<token>/<encoded> path and return the absolute filepath, or None if invalid/missing.""" """Decode a /video/<token>/<encoded> path and return the absolute filepath, or None if invalid/missing."""
parts = url_path.strip("/").split("/", 2) parts = url_path.strip("/").split("/", 2)
@@ -670,7 +1117,11 @@ def resolve_video_file_path(url_path: str) -> str | None:
return filepath if os.path.isfile(filepath) else None return filepath if os.path.isfile(filepath) else None
def probe_stream_url(raw_url: object, type_hint: object = "") -> dict[str, object]: def probe_stream_url(
raw_url: object,
type_hint: object = "",
drm_configs: object | None = None,
) -> dict[str, object]:
url = str(raw_url or "").strip() url = str(raw_url or "").strip()
if not url: if not url:
return stream_probe_response(False) return stream_probe_response(False)
@@ -715,10 +1166,25 @@ def probe_stream_url(raw_url: object, type_hint: object = "") -> dict[str, objec
marker in text marker in text
for marker in ("#EXTINF", "#EXT-X-STREAM-INF", "#EXT-X-MEDIA-SEQUENCE", "#EXT-X-PART") for marker in ("#EXTINF", "#EXT-X-STREAM-INF", "#EXT-X-MEDIA-SEQUENCE", "#EXT-X-PART")
) )
return stream_probe_response(has_playlist and has_live_media, status_code) drm_types = hls_manifest_drm_types(text)
if "#EXT-X-STREAM-INF" in text:
variant_url = hls_first_variant_url(text, url)
if variant_url:
try:
with urlopen(Request(variant_url, headers=headers), timeout=STREAM_PROBE_TIMEOUT) as vresp:
vdata = vresp.read(65536)
drm_types |= hls_manifest_drm_types(decode_probe_text(vdata))
except Exception:
pass
configured_types = drm_config_types(drm_configs)
return stream_probe_drm_response(has_playlist and has_live_media, status_code, drm_types, configured_types)
if is_dash or "dash+xml" in content_type: if is_dash or "dash+xml" in content_type:
return stream_probe_response(b"<MPD" in data[:2048], status_code) text = decode_probe_text(data)
valid = "<MPD" in text[:2048]
drm_types = dash_manifest_drm_types(text)
configured_types = drm_config_types(drm_configs)
return stream_probe_drm_response(valid, status_code, drm_types, configured_types)
if is_flv or "flv" in content_type: if is_flv or "flv" in content_type:
return stream_probe_response(data.startswith(b"FLV") or len(data) > 0, status_code) return stream_probe_response(data.startswith(b"FLV") or len(data) > 0, status_code)
@@ -817,8 +1283,6 @@ def verify_admin_password(password: str) -> bool:
def client_ip_hash(headers: object) -> str: def client_ip_hash(headers: object) -> str:
# Hash the client IP with HMAC-SHA256 so unique visitors can be counted
# without storing the raw IP address in the database.
raw = headers.get("CF-Connecting-IP") or headers.get("X-Forwarded-For") or headers.get("X-Real-IP") or "" raw = headers.get("CF-Connecting-IP") or headers.get("X-Forwarded-For") or headers.get("X-Real-IP") or ""
ip = str(raw).split(",", 1)[0].strip() ip = str(raw).split(",", 1)[0].strip()
if not ip: if not ip:
@@ -967,9 +1431,11 @@ def player_data(row: dict[str, object], settings: dict[str, str] | None = None)
site = settings or site_settings() site = settings or site_settings()
return { return {
"eventName": row["event_name"], "eventName": row["event_name"],
"streamLabel": normalize_stream_label(row["stream_label"]),
"siteTitle": site["site_title"], "siteTitle": site["site_title"],
"siteIconUrl": site.get("site_icon_url", ""), "siteIconUrl": site.get("site_icon_url", ""),
"links": add_playback_urls(normalize_links(links)), "links": add_playback_urls(normalize_links(links)),
"viewerToken": make_viewer_token(int(row["id"])),
} }
@@ -979,7 +1445,7 @@ def probe_stream_links(row: dict[str, object]) -> dict[str, object]:
except json.JSONDecodeError: except json.JSONDecodeError:
links = [] links = []
for index, link in enumerate(links): for index, link in enumerate(links):
result = probe_stream_url(link["url"], link.get("type", "")) result = probe_stream_url(link["url"], link.get("type", ""), link.get("drmConfigs", []))
if result["valid"]: if result["valid"]:
return { return {
**result, **result,
@@ -1152,7 +1618,7 @@ def rewrite_external_hls_manifest(manifest: str, base_url: str) -> str:
# attributes such as EXT-X-KEY) to route through the signed /proxy/hls/ # attributes such as EXT-X-KEY) to route through the signed /proxy/hls/
# endpoint, enabling cross-origin playback and key override in the player. # endpoint, enabling cross-origin playback and key override in the player.
def proxied_uri(value: str) -> str: def proxied_uri(value: str) -> str:
if not value or value.startswith("data:"): if not value or value.startswith(("data:", "skd:")):
return value return value
return hls_proxy_path(urljoin(base_url, value)) return hls_proxy_path(urljoin(base_url, value))
@@ -1234,13 +1700,10 @@ class StreamHallHandler(BaseHTTPRequestHandler):
return self._verify_api_key() return self._verify_api_key()
def _verify_api_key(self) -> bool: def _verify_api_key(self) -> bool:
token = None
auth = self.headers.get("Authorization", "") auth = self.headers.get("Authorization", "")
if auth.startswith("Bearer "): if not auth.startswith("Bearer "):
return False
token = auth[7:].strip() token = auth[7:].strip()
if not token:
qs = parse_qs(urlparse(self.path).query)
token = qs.get("api_key", [""])[0]
if not token: if not token:
return False return False
token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest() token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest()
@@ -1307,6 +1770,10 @@ class StreamHallHandler(BaseHTTPRequestHandler):
self.api_verify_password() self.api_verify_password()
elif action == "check_player_stream": elif action == "check_player_stream":
self.api_check_player_stream() self.api_check_player_stream()
elif action == "fairplay_license":
self.api_fairplay_license(parse_qs(parsed.query))
elif action == "widevine_license":
self.api_widevine_license(parse_qs(parsed.query))
elif action == "viewer_start": elif action == "viewer_start":
self.api_viewer_start() self.api_viewer_start()
elif action == "viewer_heartbeat": elif action == "viewer_heartbeat":
@@ -1361,6 +1828,9 @@ class StreamHallHandler(BaseHTTPRequestHandler):
elif action == "check_stream_url": elif action == "check_stream_url":
self.require_admin() self.require_admin()
self.api_check_stream_url() self.api_check_stream_url()
elif action == "discover_drm":
self.require_admin()
self.api_discover_drm()
elif action == "check_stream": elif action == "check_stream":
self.require_admin() self.require_admin()
self.api_check_stream() self.api_check_stream()
@@ -1545,8 +2015,184 @@ class StreamHallHandler(BaseHTTPRequestHandler):
raise AppError("auth_incorrect_password") raise AppError("auth_incorrect_password")
self.send_json({"status": "success", "data": self.check_stream_row(row)}) self.send_json({"status": "success", "data": self.check_stream_row(row)})
def api_fairplay_license(self, query: dict[str, list[str]]) -> None:
stream_ref = query.get("id", [""])[0]
viewer_token = self.headers.get("X-StreamHall-Viewer-Token", "").strip()
try:
link_index = int(query.get("link", ["-1"])[0])
drm_index = int(query.get("drm", ["-1"])[0])
variant_index = int(query.get("variant", ["-1"])[0])
except ValueError:
raise AppError("invalid_request")
if link_index < 0 or drm_index < 0:
raise AppError("invalid_request")
length = int(self.headers.get("Content-Length", "0") or "0")
if length <= 0 or length > 1024 * 1024:
raise AppError("invalid_request")
spc_body = self.rfile.read(length)
with db() as conn:
row = find_stream(conn, stream_ref)
if not row or int(row["is_enabled"] or 0) != 1:
raise AppError("stream_not_found_or_disabled")
if not verify_viewer_token(viewer_token, int(row["id"])):
raise AppError("auth_required")
try:
links = normalize_links(json.loads(row["links_json"] or "[]"))
except json.JSONDecodeError:
links = []
if link_index >= len(links):
raise AppError("invalid_request")
link_for_drm = links[link_index]
variants = link_for_drm.get("variants", [])
if variant_index >= 0:
if not isinstance(variants, list) or variant_index >= len(variants):
raise AppError("invalid_request")
variant = variants[variant_index]
if not isinstance(variant, dict):
raise AppError("invalid_request")
link_for_drm = variant
drm_configs = link_for_drm.get("drmConfigs", [])
if not isinstance(drm_configs, list) or drm_index >= len(drm_configs):
raise AppError("invalid_request")
drm_config = drm_configs[drm_index]
if not isinstance(drm_config, dict):
raise AppError("invalid_request")
if str(drm_config.get("drmType", "")).lower() != "fairplay":
raise AppError("invalid_request")
license_url = str(drm_config.get("licenseUrl", "")).strip()
parsed = urlparse(license_url)
if parsed.scheme not in ("http", "https"):
raise AppError("invalid_request")
request_headers = {
"User-Agent": self.headers.get("User-Agent", "StreamHall/1.0"),
"Content-Type": "application/json",
"Accept": "application/octet-stream, application/json;q=0.9, */*;q=0.8",
**parse_header_config(drm_config.get("licenseHeaders", "")),
}
upstream_payload: dict[str, str] = {
"server_playback_context": base64.b64encode(spc_body).decode("ascii")
}
upstream_body = json.dumps(upstream_payload).encode("utf-8")
try:
req = Request(license_url, data=upstream_body, headers=request_headers, method="POST")
with urlopen(req, timeout=20) as resp:
content = resp.read(2 * 1024 * 1024)
content_type = resp.headers.get("Content-Type") or "application/octet-stream"
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(content)))
self.send_header("Cache-Control", "no-cache")
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("X-Content-Type-Options", "nosniff")
self.end_headers()
self.wfile.write(content)
except HTTPError as exc:
error_body = exc.read(4096) if hasattr(exc, "read") else b""
status = HTTPStatus(exc.code) if exc.code in HTTPStatus._value2member_map_ else HTTPStatus.BAD_GATEWAY
body = error_body or f"upstream HTTP {exc.code}".encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", exc.headers.get("Content-Type") or "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-cache")
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("X-StreamHall-Upstream-Status", str(exc.code))
self.end_headers()
self.wfile.write(body)
except (URLError, TimeoutError, OSError) as exc:
body = f"upstream request failed: {exc}".encode("utf-8")
self.send_response(HTTPStatus.BAD_GATEWAY)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-cache")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(body)
def api_widevine_license(self, query: dict[str, list[str]]) -> None:
stream_ref = query.get("id", [""])[0]
viewer_token = (self.headers.get("X-StreamHall-Viewer-Token", "") or query.get("vt", [""])[0]).strip()
try:
link_index = int(query.get("link", ["-1"])[0])
drm_index = int(query.get("drm", ["-1"])[0])
except ValueError:
raise AppError("invalid_request", "invalid link or drm index")
if link_index < 0 or drm_index < 0:
raise AppError("invalid_request", "missing link or drm index")
length = int(self.headers.get("Content-Length", "0") or "0")
if length <= 0 or length > 1024 * 1024:
raise AppError("invalid_request", f"invalid challenge length: {length}")
challenge_body = self.rfile.read(length)
with db() as conn:
row = find_stream(conn, stream_ref)
if not row or int(row["is_enabled"] or 0) != 1:
raise AppError("stream_not_found_or_disabled")
if not verify_viewer_token(viewer_token, int(row["id"])):
raise AppError("auth_required", "missing or invalid viewer token")
try:
links = normalize_links(json.loads(row["links_json"] or "[]"))
except json.JSONDecodeError:
links = []
if link_index >= len(links):
raise AppError("invalid_request", f"link index out of range: {link_index}/{len(links)}")
drm_configs = links[link_index].get("drmConfigs", [])
if not isinstance(drm_configs, list) or drm_index >= len(drm_configs):
raise AppError("invalid_request", f"drm index out of range: {drm_index}")
drm_config = drm_configs[drm_index]
if not isinstance(drm_config, dict):
raise AppError("invalid_request", "invalid drm config")
if str(drm_config.get("drmType", "")).lower() != "widevine":
raise AppError("invalid_request", f"selected drm is not widevine: {drm_config.get('drmType', '')}")
license_url = str(drm_config.get("licenseUrl", "")).strip()
parsed = urlparse(license_url)
if parsed.scheme not in ("http", "https"):
raise AppError("invalid_request", "invalid widevine license url")
request_headers = {
"User-Agent": self.headers.get("User-Agent", "StreamHall/1.0"),
"Content-Type": self.headers.get("Content-Type", "application/octet-stream"),
"Accept": "application/octet-stream, application/json;q=0.9, */*;q=0.8",
**parse_header_config(drm_config.get("licenseHeaders", "")),
}
for blocked in ("host", "content-length", "connection", "origin", "referer", "cookie"):
request_headers.pop(blocked, None)
request_headers.pop(blocked.title(), None)
try:
req = Request(license_url, data=challenge_body, headers=request_headers, method="POST")
with urlopen(req, timeout=20) as resp:
content = resp.read(2 * 1024 * 1024)
content_type = resp.headers.get("Content-Type") or "application/octet-stream"
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(content)))
self.send_header("Cache-Control", "no-cache")
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("X-Content-Type-Options", "nosniff")
self.end_headers()
self.wfile.write(content)
except HTTPError as exc:
error_body = exc.read(4096) if hasattr(exc, "read") else b""
status = HTTPStatus(exc.code) if exc.code in HTTPStatus._value2member_map_ else HTTPStatus.BAD_GATEWAY
body = error_body or f"upstream HTTP {exc.code}".encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", exc.headers.get("Content-Type") or "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-cache")
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("X-StreamHall-Upstream-Status", str(exc.code))
self.end_headers()
self.wfile.write(body)
except (URLError, TimeoutError, OSError) as exc:
body = f"upstream request failed: {exc}".encode("utf-8")
self.send_response(HTTPStatus.BAD_GATEWAY)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-cache")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(body)
def api_viewer_start(self) -> None: def api_viewer_start(self) -> None:
body = self.read_json() body = self.read_json()
viewer_token = str(body.get("viewerToken", "")).strip()
visitor_id = str(body.get("visitorId", "")).strip()[:120] or secrets.token_urlsafe(18) visitor_id = str(body.get("visitorId", "")).strip()[:120] or secrets.token_urlsafe(18)
stream_ref = body.get("id", "") stream_ref = body.get("id", "")
referer = str(body.get("referer", self.headers.get("Referer", ""))).strip()[:500] referer = str(body.get("referer", self.headers.get("Referer", ""))).strip()[:500]
@@ -1554,6 +2200,8 @@ class StreamHallHandler(BaseHTTPRequestHandler):
row = find_stream(conn, stream_ref) row = find_stream(conn, stream_ref)
if not row or int(row["is_enabled"] or 0) != 1: if not row or int(row["is_enabled"] or 0) != 1:
raise AppError("stream_not_found_or_disabled") raise AppError("stream_not_found_or_disabled")
if not verify_viewer_token(viewer_token, int(row["id"])):
raise AppError("auth_required")
session_id = secrets.token_urlsafe(18) session_id = secrets.token_urlsafe(18)
timestamp = now() timestamp = now()
conn.execute( conn.execute(
@@ -1878,7 +2526,12 @@ class StreamHallHandler(BaseHTTPRequestHandler):
def api_check_stream_url(self) -> None: def api_check_stream_url(self) -> None:
body = self.read_json() body = self.read_json()
result = probe_stream_url(body.get("url", ""), body.get("type", "")) result = probe_stream_url(body.get("url", ""), body.get("type", ""), body.get("drmConfigs", body.get("drm_configs", [])))
self.send_json({"status": "success", "data": result})
def api_discover_drm(self) -> None:
body = self.read_json()
result = discover_drm_from_url(body.get("url", ""), body.get("type", ""))
self.send_json({"status": "success", "data": result}) self.send_json({"status": "success", "data": result})
def api_check_stream(self) -> None: def api_check_stream(self) -> None:
@@ -2065,7 +2718,26 @@ class StreamHallHandler(BaseHTTPRequestHandler):
}}) }})
def api_stats_export_csv(self) -> None: def api_stats_export_csv(self) -> None:
qs = parse_qs(urlparse(self.path).query)
range_param = qs.get("range", ["30d"])[0]
since_map = {"today": 86400, "7d": 7 * 86400, "30d": 30 * 86400}
since = now() - since_map[range_param] if range_param in since_map else 0
with db() as conn: with db() as conn:
if since:
rows = conn.execute(
"""
SELECT vs.session_id, vs.visitor_id, s.event_name,
vs.device_type, vs.referer,
vs.started_at, vs.ended_at, vs.last_seen_at,
vs.is_active, vs.play_state
FROM viewer_sessions vs
LEFT JOIN streams s ON vs.stream_id = s.id
WHERE vs.started_at >= ?
ORDER BY vs.started_at DESC
""",
(since,),
).fetchall()
else:
rows = conn.execute( rows = conn.execute(
""" """
SELECT vs.session_id, vs.visitor_id, s.event_name, SELECT vs.session_id, vs.visitor_id, s.event_name,
@@ -2551,7 +3223,7 @@ class StreamHallHandler(BaseHTTPRequestHandler):
def proxy_hls_route(self, request_path: str, send_body: bool = True) -> None: def proxy_hls_route(self, request_path: str, send_body: bool = True) -> None:
# URL format: /proxy/hls/<token>/<base64-encoded-url> # URL format: /proxy/hls/<token>/<base64-encoded-url>
# The token is an HMAC of the target hostname; it ensures that only URLs # The token is an HMAC of the full target URL; it ensures that only URLs
# generated by hls_proxy_path() can be proxied, preventing open-proxy abuse. # generated by hls_proxy_path() can be proxied, preventing open-proxy abuse.
parts = request_path.strip("/").split("/") parts = request_path.strip("/").split("/")
if len(parts) != 4 or parts[0] != "proxy" or parts[1] != "hls": if len(parts) != 4 or parts[0] != "proxy" or parts[1] != "hls":
@@ -2567,7 +3239,7 @@ class StreamHallHandler(BaseHTTPRequestHandler):
if parsed.scheme not in ("http", "https"): if parsed.scheme not in ("http", "https"):
self.send_error(HTTPStatus.BAD_REQUEST) self.send_error(HTTPStatus.BAD_REQUEST)
return return
if not hmac.compare_digest(token, hls_proxy_host_token(parsed.netloc)): if not hmac.compare_digest(token, hls_proxy_url_token(target_url)):
self.send_error(HTTPStatus.FORBIDDEN) self.send_error(HTTPStatus.FORBIDDEN)
return return
@@ -3103,7 +3775,12 @@ def resume_push_jobs() -> None:
pass pass
_WEAK_KEYS = {b"change-this-secret", b"REPLACE_ME"}
def main() -> None: def main() -> None:
if SECRET_KEY in _WEAK_KEYS:
print("WARNING: SECRET_KEY is set to a known default value. "
"Generate a secure key with: openssl rand -hex 32", flush=True)
init_db() init_db()
threading.Thread(target=resume_push_jobs, daemon=True).start() threading.Thread(target=resume_push_jobs, daemon=True).start()
threading.Thread(target=monitor_streams_loop, daemon=True).start() threading.Thread(target=monitor_streams_loop, daemon=True).start()