Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f63f20fdf | |||
| 2b0aaf3a5d | |||
| 601eb0247f | |||
| 34de0bdef4 | |||
| 90fe42a81a |
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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">☰</span>
|
||||||
|
<span class="group-arrow" style="margin-left:auto;">▾</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;">▾</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;">▾</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;">▾</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;">📁</span>
|
<span style="font-size:1.1em;">📁</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;">🎬</span>
|
<span style="font-size:1.1em;">🎬</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;">▾</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;">▾</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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user