5 Commits

Author SHA1 Message Date
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
Stardream b39533c5b6 fix: push jobs lost on container recreation
Build and Push Docker Image / build (push) Successful in 15s
- resume_push_jobs() waits for SRS RTMP port before launching ffmpeg,
  preventing failed launches when the full stack restarts in parallel
- Run resume in a background thread so HTTP server starts immediately
- Push job records are now only deleted from push_jobs when the user
  explicitly stops a job or the file completes normally (exit 0, non-loop);
  unexpected exits (SRS going down, connection dropped) preserve the record
  so the job is resumed on the next container start
2026-05-22 23:02:34 +10:00
9 changed files with 2433 additions and 107 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
+381 -37
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,102 @@
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-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;
justify-content: flex-end;
padding: 0 11px 11px;
}
.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 +976,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 +1145,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 +1186,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 +1295,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 +1608,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 +1740,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" style="width:140px;flex-shrink:0;display:flex;flex-direction:column;gap:4px;"></div> <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>
<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 +1820,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': '每页',
@@ -1806,6 +2006,12 @@
'ph.link_url': '链接 (m3u8/flv/mpd)', 'ph.link_url': '链接 (m3u8/flv/mpd)',
'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.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 +2020,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 +2060,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': '加载网站设置失败',
@@ -1935,6 +2144,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 +2220,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',
@@ -2196,6 +2406,12 @@
'ph.link_url': 'URL (m3u8/flv/mpd)', 'ph.link_url': 'URL (m3u8/flv/mpd)',
'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.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 +2420,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 +2460,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',
@@ -2325,6 +2544,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 +2730,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,10 +2931,19 @@
if (state !== 'is-checking') el.dataset.probeComplete = '1'; if (state !== 'is-checking') el.dataset.probeComplete = '1';
}; };
const probeUrl = async (url, type = '') => { const getRowDrmConfigs = (row) => Array.from(row.querySelectorAll('.drm-config-row')).map(drmRow => ({
drmType: drmRow.querySelector('.l-drm-type')?.value || '',
licenseUrl: drmRow.querySelector('.l-license-url')?.value.trim() || '',
certificateUrl: drmRow.querySelector('.l-certificate-url')?.value.trim() || '',
licenseHeaders: drmRow.querySelector('.l-license-headers')?.value.trim() || '',
pssh: drmRow.querySelector('.l-pssh')?.value.trim() || ''
})).filter(config => config.drmType && config.licenseUrl);
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') };
}; };
@@ -2725,12 +2963,12 @@
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) {
@@ -3657,13 +3895,29 @@
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 links = Array.from(els.linksContainer.children).map(row => {
name: row.querySelector('.l-name').value, const drmConfigs = Array.from(row.querySelectorAll('.drm-config-row')).map(drmRow => ({
type: row.querySelector('.l-type').value, drmType: drmRow.querySelector('.l-drm-type')?.value || '',
url: row.querySelector('.l-url').value, licenseUrl: drmRow.querySelector('.l-license-url')?.value.trim() || '',
key: row.querySelector('.l-key').value.trim(), certificateUrl: drmRow.querySelector('.l-certificate-url')?.value.trim() || '',
clearkey: row.querySelector('.l-clearkey').value.trim() licenseHeaders: drmRow.querySelector('.l-license-headers')?.value.trim() || '',
})).filter(l => l.name && l.url); pssh: drmRow.querySelector('.l-pssh')?.value.trim() || ''
})).filter(config => config.drmType && config.licenseUrl);
const firstDrm = drmConfigs[0] || {};
return {
name: row.querySelector('.l-name').value,
type: row.querySelector('.l-type').value,
url: row.querySelector('.l-url').value,
key: row.querySelector('.l-key').value.trim(),
clearkey: row.querySelector('.l-clearkey').value.trim(),
drmConfigs,
drmType: firstDrm.drmType || '',
licenseUrl: firstDrm.licenseUrl || '',
certificateUrl: firstDrm.certificateUrl || '',
licenseHeaders: firstDrm.licenseHeaders || '',
pssh: firstDrm.pssh || ''
};
}).filter(l => l.name && l.url);
const payload = { const payload = {
id: els.idInput.value, id: els.idInput.value,
@@ -3686,7 +3940,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 +3966,77 @@
</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 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 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-license-url drm-field-enabled" placeholder="${t('ph.license_url')}" value="${escapeAttr(config.licenseUrl || config.license_url || '')}">
<input class="l-certificate-url drm-field-fairplay" placeholder="${t('ph.certificate_url')}" value="${escapeAttr(config.certificateUrl || config.certificate_url || '')}">
<button type="button" class="btn-danger remove-drm-config-btn drm-remove-btn">×</button>
<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-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', refreshProbe);
input.addEventListener('blur', () => scheduleLinkRowCheck(div, 0));
});
row.querySelector('.remove-drm-config-btn')?.addEventListener('click', () => {
row.remove();
refreshProbe();
});
};
normalizedDrmConfigs.forEach(config => addDrmConfigUI(config));
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 +4190,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 +5042,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 +5521,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 +5614,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 +5622,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 +5635,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 +5881,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();
+317 -3
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,6 +335,196 @@
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 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()
})).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()
}];
}
return [];
}
function browserPrefersFairPlay() {
const ua = navigator.userAgent || '';
const isSafari = /Safari/i.test(ua) && !/(Chrome|Chromium|CriOS|FxiOS|Edg|OPR|OPiOS)/i.test(ua);
return !!window.WebKitMediaKeys || isSafari;
}
function isAndroidRestrictedWebView() {
const ua = navigator.userAgent || '';
if (!/Android/i.test(ua)) return false;
return /Telegram/i.test(ua);
}
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 linkHasDrmConfigs(link) {
return getDrmConfigs(link).length > 0;
}
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)
.sort((a, b) => (b.height || 0) - (a.height || 0) || (b.bandwidth || 0) - (a.bandwidth || 0));
const unique = [];
const seen = new Set();
tracks.forEach(track => {
const key = `${track.height || 0}-${Math.round((track.bandwidth || 0) / 1000)}`;
if (seen.has(key)) return;
seen.add(key);
unique.push(track);
});
if (!unique.length) return;
const active = unique.find(track => track.active) || tracks.find(track => track.active);
const autoLabel = 'Auto';
const selectedLabel = active?.height ? `${active.height}P` : autoLabel;
const selector = unique.map(track => ({
html: track.height ? `${track.height}P` : `${Math.round((track.bandwidth || 0) / 1000)}K`,
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 (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();
@@ -354,6 +555,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 +633,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 +741,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 +757,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) {
@@ -604,6 +809,7 @@
url: getPlaybackUrl(l), url: getPlaybackUrl(l),
type: getLinkType(l) type: getLinkType(l)
})); }));
const initialLink = activeLinks[0] || null;
playerInstance = new Artplayer({ playerInstance = new Artplayer({
container: '.artplayer-app', container: '.artplayer-app',
@@ -612,6 +818,7 @@
quality: quality, quality: quality,
title: data.eventName, title: data.eventName,
autoplay: true, autoplay: true,
isLive: data.streamLabel === 'LIVE',
volume: 0.5, volume: 0.5,
autoSize: true, autoSize: true,
fullscreen: true, fullscreen: true,
@@ -631,9 +838,116 @@
} }
}, },
m3u8: function (video, url, art) { m3u8: function (video, url, art) {
if (Hls.isSupported()) { 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)) {
if (art.hls) art.hls.destroy();
if (window.shaka?.polyfill) shaka.polyfill.installAll();
if (!window.shaka || !shaka.Player || !shaka.Player.isBrowserSupported()) {
art.notice.show = t('drm_unavailable');
return;
}
const selectedDrm = selectDrmConfig(currentLink);
if (selectedDrm?.drmType === 'widevine' && isAndroidRestrictedWebView()) {
art.notice.show = t('drm_android_webview_unsupported');
return;
}
const licenseUrl = String(selectedDrm?.licenseUrl || '').trim();
if (!licenseUrl) {
art.notice.show = t('drm_license_missing');
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 fairplay = selectedDrm?.drmType === 'fairplay';
const keySystem = fairplay ? 'com.apple.fps' : 'com.widevine.alpha';
const fairplayLegacyKeySystem = 'com.apple.fps.1_0';
const certificateUrl = String(selectedDrm?.certificateUrl || '').trim();
const servers = { [keySystem]: licenseUrl };
const advanced = {};
if (fairplay) {
servers[fairplayLegacyKeySystem] = licenseUrl;
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
};
if (fairplay && !certificateUrl) {
art.notice.show = t('drm_certificate_missing');
return;
}
player.configure({
drm: {
...drmConfig,
...(fairplay ? { initDataTransform: transformFairPlayInitData } : {})
},
...(fairplay ? { streaming: { useNativeHlsForFairPlay: false } } : {})
});
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 (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.shaka) {
art.shaka.destroy().catch(() => {});
art.shaka = null;
}
if (art.streamhallDurationGuard) {
art.streamhallDurationGuard();
art.streamhallDurationGuard = null;
}
if (art.hls) art.hls.destroy(); if (art.hls) art.hls.destroy();
const currentLink = activeLinks.find(l => l.url === url || getPlaybackUrl(l) === url);
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 {
+1455
View File
File diff suppressed because it is too large Load Diff
+252 -52
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import base64 import base64
import socket
import csv import csv
import hashlib import hashlib
import hmac import hmac
@@ -89,6 +90,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 = {
@@ -420,14 +422,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"})
@@ -513,7 +538,48 @@ def admin_session_not_before() -> int:
return 0 return 0
def normalize_links(raw: object) -> list[dict[str, str]]: 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
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(),
}
)
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:
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(),
}
)
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:
@@ -526,6 +592,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,
@@ -533,6 +601,12 @@ 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", "")),
} }
) )
return normalized return normalized
@@ -626,6 +700,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:
@@ -635,6 +726,52 @@ 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
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)
@@ -669,7 +806,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)
@@ -714,10 +855,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)
@@ -816,8 +972,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:
@@ -966,9 +1120,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"])),
} }
@@ -978,7 +1134,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,
@@ -1151,7 +1307,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))
@@ -1233,13 +1389,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 "):
token = auth[7:].strip() return False
if not token: token = auth[7:].strip()
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()
@@ -1546,6 +1699,7 @@ class StreamHallHandler(BaseHTTPRequestHandler):
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]
@@ -1553,6 +1707,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(
@@ -1877,7 +2033,7 @@ 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}) self.send_json({"status": "success", "data": result})
def api_check_stream(self) -> None: def api_check_stream(self) -> None:
@@ -2064,18 +2220,37 @@ 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:
rows = conn.execute( if since:
""" rows = conn.execute(
SELECT vs.session_id, vs.visitor_id, s.event_name, """
vs.device_type, vs.referer, SELECT vs.session_id, vs.visitor_id, s.event_name,
vs.started_at, vs.ended_at, vs.last_seen_at, vs.device_type, vs.referer,
vs.is_active, vs.play_state vs.started_at, vs.ended_at, vs.last_seen_at,
FROM viewer_sessions vs vs.is_active, vs.play_state
LEFT JOIN streams s ON vs.stream_id = s.id FROM viewer_sessions vs
ORDER BY vs.started_at DESC LEFT JOIN streams s ON vs.stream_id = s.id
""" WHERE vs.started_at >= ?
).fetchall() ORDER BY vs.started_at DESC
""",
(since,),
).fetchall()
else:
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
ORDER BY vs.started_at DESC
"""
).fetchall()
buf = io.StringIO() buf = io.StringIO()
writer = csv.writer(buf) writer = csv.writer(buf)
writer.writerow(["session_id", "visitor_id", "event_name", "device_type", "referer", writer.writerow(["session_id", "visitor_id", "event_name", "device_type", "referer",
@@ -2550,7 +2725,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":
@@ -2566,7 +2741,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
@@ -2819,6 +2994,7 @@ class StreamHallHandler(BaseHTTPRequestHandler):
"dir_index": dir_index, "dir_index": dir_index,
"push_rel_path": rel_path, "push_rel_path": rel_path,
"is_folder": is_folder, "is_folder": is_folder,
"user_stopped": False,
} }
try: try:
with db() as conn: with db() as conn:
@@ -2829,22 +3005,24 @@ class StreamHallHandler(BaseHTTPRequestHandler):
) )
except Exception: except Exception:
pass pass
def _monitor(jid: str, stream_key: str = stream_key, created: bool = push_created_route, pfile: str | None = playlist_path) -> None: def _monitor(jid: str, stream_key: str = stream_key, created: bool = push_created_route, pfile: str | None = playlist_path, lp: bool = loop) -> None:
proc.wait() rc = proc.wait()
with _pushes_lock: with _pushes_lock:
active_pushes.pop(jid, None) job_meta = active_pushes.pop(jid, {})
user_stopped = job_meta.get("user_stopped", False)
if pfile: if pfile:
try: try:
os.unlink(pfile) os.unlink(pfile)
except OSError: except OSError:
pass pass
try: if user_stopped or (rc == 0 and not lp):
with db() as conn: try:
conn.execute("DELETE FROM push_jobs WHERE job_id = ?", (jid,)) with db() as conn:
if created: conn.execute("DELETE FROM push_jobs WHERE job_id = ?", (jid,))
conn.execute("DELETE FROM obs_stream_routes WHERE stream_key = ?", (stream_key,)) if created:
except Exception: conn.execute("DELETE FROM obs_stream_routes WHERE stream_key = ?", (stream_key,))
pass except Exception:
pass
threading.Thread(target=_monitor, args=(job_id,), daemon=True).start() threading.Thread(target=_monitor, args=(job_id,), daemon=True).start()
self.send_json({"status": "ok", "job_id": job_id, "hls_slug": slug}) self.send_json({"status": "ok", "job_id": job_id, "hls_slug": slug})
@@ -2855,6 +3033,7 @@ class StreamHallHandler(BaseHTTPRequestHandler):
job = active_pushes.get(job_id) job = active_pushes.get(job_id)
if not job: if not job:
raise AppError("push_not_found") raise AppError("push_not_found")
job["user_stopped"] = True
job["proc"].terminate() job["proc"].terminate()
self.send_json({"status": "ok"}) self.send_json({"status": "ok"})
@@ -2984,7 +3163,20 @@ def cleanup_stale_sessions_loop() -> None:
pass pass
def _wait_for_rtmp(host: str, port: int = 1935, timeout: float = 60.0) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
try:
with socket.create_connection((host, port), timeout=2):
return True
except OSError:
time.sleep(2)
return False
def resume_push_jobs() -> None: def resume_push_jobs() -> None:
if not _wait_for_rtmp(RTMP_HOST):
return
try: try:
with db() as conn: with db() as conn:
rows = conn.execute("SELECT * FROM push_jobs").fetchall() rows = conn.execute("SELECT * FROM push_jobs").fetchall()
@@ -3057,22 +3249,25 @@ def resume_push_jobs() -> None:
"dir_index": dir_index, "dir_index": dir_index,
"push_rel_path": rel_path, "push_rel_path": rel_path,
"is_folder": is_folder, "is_folder": is_folder,
"user_stopped": False,
} }
def _monitor(jid: str = job_id, sk: str = stream_key, pfile: str | None = playlist_path) -> None: def _monitor(jid: str = job_id, sk: str = stream_key, pfile: str | None = playlist_path, lp: bool = loop) -> None:
proc.wait() rc = proc.wait()
with _pushes_lock: with _pushes_lock:
active_pushes.pop(jid, None) job_meta = active_pushes.pop(jid, {})
user_stopped = job_meta.get("user_stopped", False)
if pfile: if pfile:
try: try:
os.unlink(pfile) os.unlink(pfile)
except OSError: except OSError:
pass pass
try: if user_stopped or (rc == 0 and not lp):
with db() as conn: try:
conn.execute("DELETE FROM push_jobs WHERE job_id = ?", (jid,)) with db() as conn:
conn.execute("DELETE FROM obs_stream_routes WHERE stream_key = ?", (sk,)) conn.execute("DELETE FROM push_jobs WHERE job_id = ?", (jid,))
except Exception: conn.execute("DELETE FROM obs_stream_routes WHERE stream_key = ?", (sk,))
pass except Exception:
pass
threading.Thread(target=_monitor, daemon=True).start() threading.Thread(target=_monitor, daemon=True).start()
except Exception: except Exception:
try: try:
@@ -3082,9 +3277,14 @@ 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()
resume_push_jobs() 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()
threading.Thread(target=cleanup_stale_sessions_loop, daemon=True).start() threading.Thread(target=cleanup_stale_sessions_loop, daemon=True).start()
host = os.getenv("HOST", "0.0.0.0") host = os.getenv("HOST", "0.0.0.0")