Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c4c3e6f445 | |||
| 42ce5d2684 | |||
| 6d39c512d7 | |||
| 8e1ed10ba5 |
@@ -29,14 +29,14 @@
|
|||||||
|
|
||||||
- **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; Widevine and FairPlay DRM playback via Shaka Player (multi-DRM configs per source, Android Telegram WebView detection)
|
- **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 with per-source labels and proxy mode selection
|
||||||
- **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; each source going live fires its own notification, simultaneous go-lives within a configurable window are merged into one message, and brief RTMP reconnects within a grace period are suppressed to avoid spurious stop/start pairs
|
||||||
- **Stream push** - Local file browser with per-file and per-folder RTMP push management; multi-file folder push with independent stream keys; inline push status and detail modal; remote RTMP push config for external encoders; hidden HLS route proxy (`/h/<slug>`) so real stream keys are never exposed publicly
|
- **Stream push** - Local file browser with per-file and per-folder RTMP push management; multi-file folder push with independent stream keys; inline push status and detail modal; remote RTMP push config for external encoders; hidden HLS route proxy (`/h/<slug>`) so real stream keys are never exposed publicly
|
||||||
- **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 modes** - Per-source direct, full proxy, or manifest-only proxy modes for balancing source URL exposure, CORS compatibility, and server bandwidth; full proxy supports upstream cookie forwarding for cookie-authenticated CDNs (e.g. CloudFront signed cookies), with the cookie stored server-side and never exposed in playback URLs
|
||||||
- **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
|
- **Mobile responsive** - Admin panel sidebar, source editor, file browser rows, and push directory sidebar all collapse gracefully on narrow screens
|
||||||
|
|
||||||
<div align="right">
|
<div align="right">
|
||||||
|
|
||||||
@@ -149,8 +149,11 @@ Set these environment variables in `docker-compose.yml`:
|
|||||||
| `TZ` | `UTC` | No | Container timezone, e.g. `Asia/Shanghai` |
|
| `TZ` | `UTC` | No | Container timezone, e.g. `Asia/Shanghai` |
|
||||||
| `SRS_HTTP_ORIGIN` | `http://srs:8080` | No | SRS HTTP playback base URL |
|
| `SRS_HTTP_ORIGIN` | `http://srs:8080` | No | SRS HTTP playback base URL |
|
||||||
| `STREAM_PROBE_TIMEOUT` | `4` | No | Seconds before aborting a stream URL probe |
|
| `STREAM_PROBE_TIMEOUT` | `4` | No | Seconds before aborting a stream URL probe |
|
||||||
|
| `HLS_PROXY_TIMEOUT` | `15` | No | Seconds before aborting an upstream HLS manifest/segment proxy request |
|
||||||
| `STREAM_MONITOR_INTERVAL` | `10` | No | Seconds between stream liveness checks |
|
| `STREAM_MONITOR_INTERVAL` | `10` | No | Seconds between stream liveness checks |
|
||||||
| `TELEGRAM_TIMEOUT` | `6` | No | Seconds before aborting a Telegram API call |
|
| `TELEGRAM_TIMEOUT` | `6` | No | Seconds before aborting a Telegram API call |
|
||||||
|
| `TG_RECONNECT_GRACE_SECS` | `60` | No | Grace period before sending a stop notification; absorbs brief RTMP reconnects (`0` disables) |
|
||||||
|
| `TG_START_MERGE_SECS` | `30` | No | Window for merging simultaneous link-online events into one start notification (`0` disables) |
|
||||||
| `RTMP_HOST` | `srs` | No | Hostname of the SRS container used for local push jobs |
|
| `RTMP_HOST` | `srs` | No | Hostname of the SRS container used for local push jobs |
|
||||||
| `VIDEOS_DIRS` | *(unset)* | No | Comma-separated list of directories exposed in the file browser. Optionally prefix each path with a label: `label:/app/path`. Multiple entries: `movies:/app/movies,shows:/app/shows` |
|
| `VIDEOS_DIRS` | *(unset)* | No | Comma-separated list of directories exposed in the file browser. Optionally prefix each path with a label: `label:/app/path`. Multiple entries: `movies:/app/movies,shows:/app/shows` |
|
||||||
|
|
||||||
@@ -183,6 +186,19 @@ volumes:
|
|||||||
- /your/media/path:/app/media/external
|
- /your/media/path:/app/media/external
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**HLS proxy modes**
|
||||||
|
|
||||||
|
Each stream source can choose how external HLS URLs are exposed to viewers:
|
||||||
|
|
||||||
|
| Mode | Behavior | Bandwidth impact |
|
||||||
|
|---|---|---|
|
||||||
|
| `Auto` | Backward-compatible default; external HLS uses the full proxy | StreamHall carries manifest and segment traffic |
|
||||||
|
| `Direct` | Player uses the source URL directly | Viewer traffic goes to the source server |
|
||||||
|
| `Full proxy` | Manifest, segments, maps, and keys are routed through `/proxy/hls/` | StreamHall carries all HLS media traffic |
|
||||||
|
| `Manifest only` | Only the playlist uses StreamHall; segment/key/map URLs are absolute source URLs | Low StreamHall bandwidth; final media URLs remain visible in browser network tools |
|
||||||
|
|
||||||
|
In **Full proxy** mode, a source can also set an **upstream cookie** for CDNs that require cookie-based authentication (e.g. CloudFront signed cookies). StreamHall forwards the cookie on every manifest and segment request. The cookie is stored server-side and referenced via a signed opaque token in proxy URLs, so it is never derivable from a playback or segment URL. Upstream proxy requests reuse pooled HTTP connections to avoid per-request TLS handshake overhead.
|
||||||
|
|
||||||
<div align="right">
|
<div align="right">
|
||||||
|
|
||||||
[![][back-to-top]](#readme-top)
|
[![][back-to-top]](#readme-top)
|
||||||
|
|||||||
+20
-4
@@ -29,14 +29,14 @@
|
|||||||
|
|
||||||
- **公开直播列表** - 直播 / 存档双 Tab,支持密码保护、自定义站点品牌,内置中英双语界面(含分语言站点简介)
|
- **公开直播列表** - 直播 / 存档双 Tab,支持密码保护、自定义站点品牌,内置中英双语界面(含分语言站点简介)
|
||||||
- **播放器** - 基于 ArtPlayer,支持 HLS、FLV、MPEG-DASH 播放;支持 AES-128 密钥覆盖及 DASH ClearKey;通过 Shaka Player 支持 Widevine 和 FairPlay DRM 播放(每路播放源可独立配置多 DRM 方案,内置 Android Telegram WebView 检测)
|
- **播放器** - 基于 ArtPlayer,支持 HLS、FLV、MPEG-DASH 播放;支持 AES-128 密钥覆盖及 DASH ClearKey;通过 Shaka Player 支持 Widevine 和 FairPlay DRM 播放(每路播放源可独立配置多 DRM 方案,内置 Android Telegram WebView 检测)
|
||||||
- **管理后台** - 直播的增删改查、启用/禁用、拖拽排序;多播放源管理
|
- **管理后台** - 直播的增删改查、启用/禁用、拖拽排序;多播放源管理,支持逐字段标签和代理模式选择
|
||||||
- **观看统计** - 会话追踪、独立访客数、峰值并发、平均时长、设备 / 浏览器 / 操作系统 / 地理分布实时看板,支持 CSV 导出
|
- **观看统计** - 会话追踪、独立访客数、峰值并发、平均时长、设备 / 浏览器 / 操作系统 / 地理分布实时看板,支持 CSV 导出
|
||||||
- **Telegram 推送** - 可按直播单独配置,开播 / 关播自动发送通知
|
- **Telegram 推送** - 可按直播单独配置,开播 / 关播自动发送通知;多视角直播下每个视角上线都会独立推送,时间窗口内同时开播的视角会合并为一条消息,RTMP 短暂断线重连在宽限期内不会误触发关播 / 开播通知
|
||||||
- **推流配置** - 内置文件浏览器,支持单文件和文件夹 RTMP 推流管理;文件夹可同时向多个推流码批量推送独立任务;推流状态内联显示于文件行,详情弹窗提供实时时长、复制地址和停止操作;同时支持远端编码器 RTMP 推流配置;隐藏 HLS 路由代理(`/h/<slug>`),真实推流码不出现在公开地址中
|
- **推流配置** - 内置文件浏览器,支持单文件和文件夹 RTMP 推流管理;文件夹可同时向多个推流码批量推送独立任务;推流状态内联显示于文件行,详情弹窗提供实时时长、复制地址和停止操作;同时支持远端编码器 RTMP 推流配置;隐藏 HLS 路由代理(`/h/<slug>`),真实推流码不出现在公开地址中
|
||||||
- **VOD 点播 / 视频服务** - 带 HMAC 签名的 `/video/` URL,支持 HTTP Range 请求(可 seek);文件浏览器中可直接将视频文件或文件夹发布为归档直播
|
- **VOD 点播 / 视频服务** - 带 HMAC 签名的 `/video/` URL,支持 HTTP Range 请求(可 seek);文件浏览器中可直接将视频文件或文件夹发布为归档直播
|
||||||
- **HLS 代理** - 带签名验证的 `/proxy/hls/` 路由,解决跨域 HLS 播放问题
|
- **HLS 代理模式** - 每个播放源可选择直连、完整代理或仅代理 Manifest,在源地址暴露、跨域兼容和服务器带宽之间自行取舍;完整代理模式支持上游 Cookie 转发,可对接依赖 Cookie 鉴权的 CDN(如 CloudFront 签名 Cookie),Cookie 仅存于服务端、不会暴露在播放地址中
|
||||||
- **API 密钥鉴权** - 在后台生成 Token,可通过 API 密钥对所有管理及统计接口进行程序化访问
|
- **API 密钥鉴权** - 在后台生成 Token,可通过 API 密钥对所有管理及统计接口进行程序化访问
|
||||||
- **移动端适配** - 管理后台侧边栏、文件浏览器行、推流目录侧边栏均可在窄屏设备上自适应折叠
|
- **移动端适配** - 管理后台侧边栏、视角编辑器、文件浏览器行、推流目录侧边栏均可在窄屏设备上自适应折叠
|
||||||
|
|
||||||
<div align="right">
|
<div align="right">
|
||||||
|
|
||||||
@@ -149,8 +149,11 @@ python server.py
|
|||||||
| `TZ` | `UTC` | 否 | 容器时区,如 `Asia/Shanghai` |
|
| `TZ` | `UTC` | 否 | 容器时区,如 `Asia/Shanghai` |
|
||||||
| `SRS_HTTP_ORIGIN` | `http://srs:8080` | 否 | SRS HTTP 播放基础地址 |
|
| `SRS_HTTP_ORIGIN` | `http://srs:8080` | 否 | SRS HTTP 播放基础地址 |
|
||||||
| `STREAM_PROBE_TIMEOUT` | `4` | 否 | 流地址探测超时秒数 |
|
| `STREAM_PROBE_TIMEOUT` | `4` | 否 | 流地址探测超时秒数 |
|
||||||
|
| `HLS_PROXY_TIMEOUT` | `15` | 否 | 上游 HLS manifest / 分片代理请求超时秒数 |
|
||||||
| `STREAM_MONITOR_INTERVAL` | `10` | 否 | 流存活检测间隔秒数 |
|
| `STREAM_MONITOR_INTERVAL` | `10` | 否 | 流存活检测间隔秒数 |
|
||||||
| `TELEGRAM_TIMEOUT` | `6` | 否 | Telegram API 请求超时秒数 |
|
| `TELEGRAM_TIMEOUT` | `6` | 否 | Telegram API 请求超时秒数 |
|
||||||
|
| `TG_RECONNECT_GRACE_SECS` | `60` | 否 | 发送关播通知前的宽限期,用于吸收短暂 RTMP 重连(`0` 关闭) |
|
||||||
|
| `TG_START_MERGE_SECS` | `30` | 否 | 合并同时上线视角为一条开播通知的时间窗口(`0` 关闭) |
|
||||||
| `RTMP_HOST` | `srs` | 否 | 本地推流任务使用的 SRS 容器主机名 |
|
| `RTMP_HOST` | `srs` | 否 | 本地推流任务使用的 SRS 容器主机名 |
|
||||||
| `VIDEOS_DIRS` | *(未设置)* | 否 | 文件浏览器暴露的目录,逗号分隔。可为每个路径加标签前缀:`label:/app/path`。多个示例:`movies:/app/movies,shows:/app/shows` |
|
| `VIDEOS_DIRS` | *(未设置)* | 否 | 文件浏览器暴露的目录,逗号分隔。可为每个路径加标签前缀:`label:/app/path`。多个示例:`movies:/app/movies,shows:/app/shows` |
|
||||||
|
|
||||||
@@ -183,6 +186,19 @@ volumes:
|
|||||||
- /your/media/path:/app/media/external
|
- /your/media/path:/app/media/external
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**HLS 代理模式**
|
||||||
|
|
||||||
|
每个播放源都可以选择外部 HLS 链接如何暴露给观众:
|
||||||
|
|
||||||
|
| 模式 | 行为 | 带宽影响 |
|
||||||
|
|---|---|---|
|
||||||
|
| `自动` | 向后兼容默认行为;外部 HLS 使用完整代理 | StreamHall 承担 manifest 和分片流量 |
|
||||||
|
| `直连` | 播放器直接使用源站 URL | 观众流量走源服务器 |
|
||||||
|
| `完整代理` | manifest、分片、map、key 都通过 `/proxy/hls/` | StreamHall 承担全部 HLS 媒体流量 |
|
||||||
|
| `仅 Manifest` | 只有播放列表经过 StreamHall;分片、key、map 改写为源站绝对地址 | StreamHall 带宽较低;最终媒体 URL 仍会出现在浏览器网络请求中 |
|
||||||
|
|
||||||
|
**完整代理**模式下,播放源还可以设置**上游 Cookie**,用于对接依赖 Cookie 鉴权的 CDN(如 CloudFront 签名 Cookie)。StreamHall 会在每次 manifest 和分片请求时附带该 Cookie。Cookie 仅存于服务端,并以签名后的不可逆 token 形式嵌入代理地址,因此无法从播放或分片 URL 中还原出来。上游代理请求会复用连接池中的持久 HTTP 连接,避免每次请求都重新进行 TLS 握手。
|
||||||
|
|
||||||
<div align="right">
|
<div align="right">
|
||||||
|
|
||||||
[![][back-to-top]](#readme-top)
|
[![][back-to-top]](#readme-top)
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ services:
|
|||||||
- ./videos:/app/videos
|
- ./videos:/app/videos
|
||||||
# Mount additional directories as needed:
|
# Mount additional directories as needed:
|
||||||
# - /local/path:/app/media/label
|
# - /local/path:/app/media/label
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
|
|||||||
+435
-49
@@ -383,20 +383,77 @@
|
|||||||
|
|
||||||
.link-row {
|
.link-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 1.2fr 0.8fr 1.8fr 1.4fr 1.6fr auto;
|
grid-template-columns: 24px 1.15fr 0.75fr 0.95fr 1.8fr 1.35fr 1.55fr 42px;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.link-order-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding-top: 34px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-row .drag-handle {
|
||||||
|
width: 24px;
|
||||||
|
text-align: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-move-btn {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-field {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-field-label {
|
||||||
|
min-height: 1em;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-field > input,
|
||||||
|
.link-field > select,
|
||||||
|
.link-field > textarea,
|
||||||
|
.link-row .url-field,
|
||||||
|
.link-row .url-field input {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
.link-remove-btn {
|
.link-remove-btn {
|
||||||
grid-column: -1;
|
grid-column: 8 / 9;
|
||||||
grid-row: 1 / -1;
|
grid-row: 1 / -1;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
|
width: 42px;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-row .link-drm-config {
|
.link-row .link-drm-config,
|
||||||
|
.link-row .link-upstream-auth {
|
||||||
grid-column: 2 / -2;
|
grid-column: 2 / -2;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
@@ -404,7 +461,8 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-row .link-drm-config > summary {
|
.link-row .link-drm-config > summary,
|
||||||
|
.link-row .link-upstream-auth > summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -417,7 +475,8 @@
|
|||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-row .link-drm-config > summary::-webkit-details-marker {
|
.link-row .link-drm-config > summary::-webkit-details-marker,
|
||||||
|
.link-row .link-upstream-auth > summary::-webkit-details-marker {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,6 +490,26 @@
|
|||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.link-row .link-upstream-auth > summary::after {
|
||||||
|
content: "Cookie";
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
background: var(--line);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-row .link-upstream-auth > .upstream-auth-body {
|
||||||
|
padding: 0 11px 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-row .link-upstream-auth > .upstream-auth-body textarea {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
.link-row .link-drm-grid {
|
.link-row .link-drm-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 0.7fr 1.5fr;
|
grid-template-columns: 0.7fr 1.5fr;
|
||||||
@@ -548,6 +627,12 @@
|
|||||||
background: rgba(220, 38, 38, 0.08);
|
background: rgba(220, 38, 38, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stream-check-status.is-warning,
|
||||||
|
.stream-live-state.is-warning {
|
||||||
|
color: #b45309;
|
||||||
|
background: rgba(245, 158, 11, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
:root[data-theme="dark"] .stream-check-status.is-online,
|
:root[data-theme="dark"] .stream-check-status.is-online,
|
||||||
:root[data-theme="dark"] .stream-live-state.is-online {
|
:root[data-theme="dark"] .stream-live-state.is-online {
|
||||||
color: var(--mint);
|
color: var(--mint);
|
||||||
@@ -558,6 +643,11 @@
|
|||||||
color: #fb7185;
|
color: #fb7185;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] .stream-check-status.is-warning,
|
||||||
|
:root[data-theme="dark"] .stream-live-state.is-warning {
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
.obs-grid {
|
.obs-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -991,10 +1081,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.link-row .link-drm-config,
|
.link-row .link-drm-config,
|
||||||
|
.link-row .link-upstream-auth,
|
||||||
.link-row .link-drm-wide {
|
.link-row .link-drm-wide {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.link-order-controls {
|
||||||
|
padding-top: 0;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-move-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-remove-btn {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-row: auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.link-row .link-drm-grid {
|
.link-row .link-drm-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -1238,8 +1344,12 @@
|
|||||||
.drag-handle:active { cursor:grabbing; }
|
.drag-handle:active { cursor:grabbing; }
|
||||||
.stream-row.drag-over { outline:2px solid var(--blue); outline-offset:-2px; border-radius:6px; }
|
.stream-row.drag-over { outline:2px solid var(--blue); outline-offset:-2px; border-radius:6px; }
|
||||||
.stream-row.dragging { opacity:.35; }
|
.stream-row.dragging { opacity:.35; }
|
||||||
|
.stream-row.touch-dragging { opacity:.72; outline:2px solid var(--blue); outline-offset:-2px; border-radius:6px; }
|
||||||
|
.stream-row.touch-drag-over { outline:2px dashed var(--blue); outline-offset:-2px; border-radius:6px; }
|
||||||
.link-row.drag-over { outline:2px solid var(--blue); outline-offset:-2px; border-radius:6px; }
|
.link-row.drag-over { outline:2px solid var(--blue); outline-offset:-2px; border-radius:6px; }
|
||||||
.link-row.dragging { opacity:.35; }
|
.link-row.dragging { opacity:.35; }
|
||||||
|
.link-row.touch-dragging { opacity:.72; outline:2px solid var(--blue); outline-offset:-2px; border-radius:6px; }
|
||||||
|
.link-row.touch-drag-over { outline:2px dashed var(--blue); outline-offset:-2px; border-radius:6px; }
|
||||||
.modal-close-btn { background:none; border:none; color:var(--muted); font-size:1.1rem; cursor:pointer; padding:2px 6px; line-height:1; box-shadow:none; transform:none !important; }
|
.modal-close-btn { background:none; border:none; color:var(--muted); font-size:1.1rem; cursor:pointer; padding:2px 6px; line-height:1; box-shadow:none; transform:none !important; }
|
||||||
.modal-close-btn:hover { color:var(--text); filter:none; }
|
.modal-close-btn:hover { color:var(--text); filter:none; }
|
||||||
.geo-section { border:1px solid var(--line); border-radius:8px; background:var(--panel); backdrop-filter:blur(14px); padding:20px 22px; margin-top:20px; }
|
.geo-section { border:1px solid var(--line); border-radius:8px; background:var(--panel); backdrop-filter:blur(14px); padding:20px 22px; margin-top:20px; }
|
||||||
@@ -1964,6 +2074,13 @@
|
|||||||
'form.stream_name': '直播名称',
|
'form.stream_name': '直播名称',
|
||||||
'form.stream_pw': '访问密码 (留空则公开)',
|
'form.stream_pw': '访问密码 (留空则公开)',
|
||||||
'form.stream_type': '直播类型',
|
'form.stream_type': '直播类型',
|
||||||
|
'form.link_name': '视角名称',
|
||||||
|
'form.link_type': '类型',
|
||||||
|
'form.proxy_mode': '代理模式',
|
||||||
|
'form.upstream_cookie': '上游 Cookie',
|
||||||
|
'form.link_url': '播放链接',
|
||||||
|
'form.key_override':'Key Override',
|
||||||
|
'form.clearkey': 'ClearKey 信息',
|
||||||
'form.hide_home': '不在主页显示',
|
'form.hide_home': '不在主页显示',
|
||||||
// section headings
|
// section headings
|
||||||
'h3.security': '安全设置',
|
'h3.security': '安全设置',
|
||||||
@@ -2019,6 +2136,8 @@
|
|||||||
'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.proxy_mode': '代理模式',
|
||||||
|
'ph.upstream_cookie': '粘贴 Cookie 字符串,如 CloudFront-Key-Pair-Id=xxx; CloudFront-Policy=yyy; CloudFront-Signature=zzz',
|
||||||
'ph.main_url_disabled':'已使用 DRM 专用播放链接',
|
'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"}',
|
||||||
@@ -2036,7 +2155,6 @@
|
|||||||
'hint.tg_vars': '可用变量:{title}、{url}、{site_title}、{time}、{status}、{stream_id}、{public_id}、{link_name}、{source_url} · 支持 HTML',
|
'hint.tg_vars': '可用变量:{title}、{url}、{site_title}、{time}、{status}、{stream_id}、{public_id}、{link_name}、{source_url} · 支持 HTML',
|
||||||
'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 信息',
|
|
||||||
'drm.config': 'DRM 播放授权',
|
'drm.config': 'DRM 播放授权',
|
||||||
'drm.none': '无 DRM',
|
'drm.none': '无 DRM',
|
||||||
'hint.footer_example': '示例:## 联系方式\n- [项目主页](https://example.com)',
|
'hint.footer_example': '示例:## 联系方式\n- [项目主页](https://example.com)',
|
||||||
@@ -2075,6 +2193,7 @@
|
|||||||
'probe.no_info': '没有监测到推流信息',
|
'probe.no_info': '没有监测到推流信息',
|
||||||
'probe.detecting': '正在检测推流信息...',
|
'probe.detecting': '正在检测推流信息...',
|
||||||
'probe.detected': '已检测到推流信息',
|
'probe.detected': '已检测到推流信息',
|
||||||
|
'probe.cookie_proxy_mismatch': '已检测到推流信号,但当前代理模式不支持 Cookie 转发,观众将无法正常播放,请改为完整代理模式',
|
||||||
'probe.waiting': '等待自动检测...',
|
'probe.waiting': '等待自动检测...',
|
||||||
'probe.closed': '直播已关闭',
|
'probe.closed': '直播已关闭',
|
||||||
'probe.drm_config_missing':'检测到 DRM 流,但缺少匹配的 DRM 配置',
|
'probe.drm_config_missing':'检测到 DRM 流,但缺少匹配的 DRM 配置',
|
||||||
@@ -2111,6 +2230,10 @@
|
|||||||
'theme.to_light': '切换明亮模式',
|
'theme.to_light': '切换明亮模式',
|
||||||
// type selector
|
// type selector
|
||||||
'type.auto': '自动',
|
'type.auto': '自动',
|
||||||
|
'proxy.auto': '自动',
|
||||||
|
'proxy.direct': '直连',
|
||||||
|
'proxy.full': '完整代理',
|
||||||
|
'proxy.manifest': '仅 Manifest',
|
||||||
// OBS link names
|
// OBS link names
|
||||||
'obs.local_hls': '本地 HLS',
|
'obs.local_hls': '本地 HLS',
|
||||||
'obs.local_flv': '本地 FLV',
|
'obs.local_flv': '本地 FLV',
|
||||||
@@ -2375,6 +2498,13 @@
|
|||||||
'form.stream_name': 'Stream Name',
|
'form.stream_name': 'Stream Name',
|
||||||
'form.stream_pw': 'Password (empty = public)',
|
'form.stream_pw': 'Password (empty = public)',
|
||||||
'form.stream_type': 'Stream Type',
|
'form.stream_type': 'Stream Type',
|
||||||
|
'form.link_name': 'Name',
|
||||||
|
'form.link_type': 'Type',
|
||||||
|
'form.proxy_mode': 'Proxy mode',
|
||||||
|
'form.upstream_cookie': 'Upstream Cookie',
|
||||||
|
'form.link_url': 'Playback URL',
|
||||||
|
'form.key_override':'Key Override',
|
||||||
|
'form.clearkey': 'ClearKey',
|
||||||
'form.hide_home': 'Hide from homepage',
|
'form.hide_home': 'Hide from homepage',
|
||||||
// section headings
|
// section headings
|
||||||
'h3.security': 'Security',
|
'h3.security': 'Security',
|
||||||
@@ -2430,6 +2560,8 @@
|
|||||||
'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.proxy_mode': 'Proxy mode',
|
||||||
|
'ph.upstream_cookie': 'Paste cookie string, e.g. CloudFront-Key-Pair-Id=xxx; CloudFront-Policy=yyy; CloudFront-Signature=zzz',
|
||||||
'ph.main_url_disabled':'Using DRM-specific playback URL',
|
'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"}',
|
||||||
@@ -2447,7 +2579,6 @@
|
|||||||
'hint.tg_vars': 'Variables: {title}, {url}, {site_title}, {time}, {status}, {stream_id}, {public_id}, {link_name}, {source_url} · HTML supported',
|
'hint.tg_vars': 'Variables: {title}, {url}, {site_title}, {time}, {status}, {stream_id}, {public_id}, {link_name}, {source_url} · HTML supported',
|
||||||
'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',
|
|
||||||
'drm.config': 'DRM license',
|
'drm.config': 'DRM license',
|
||||||
'drm.none': 'No DRM',
|
'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)',
|
||||||
@@ -2486,6 +2617,7 @@
|
|||||||
'probe.no_info': 'No stream detected',
|
'probe.no_info': 'No stream detected',
|
||||||
'probe.detecting': 'Detecting stream...',
|
'probe.detecting': 'Detecting stream...',
|
||||||
'probe.detected': 'Stream detected',
|
'probe.detected': 'Stream detected',
|
||||||
|
'probe.cookie_proxy_mismatch': 'Stream signal detected, but the current proxy mode does not forward cookies — viewers will not be able to play. Switch to Full Proxy mode.',
|
||||||
'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',
|
'probe.drm_config_missing':'DRM stream detected, but matching DRM config is missing',
|
||||||
@@ -2522,6 +2654,10 @@
|
|||||||
'theme.to_light': 'Switch to light mode',
|
'theme.to_light': 'Switch to light mode',
|
||||||
// type selector
|
// type selector
|
||||||
'type.auto': 'Auto',
|
'type.auto': 'Auto',
|
||||||
|
'proxy.auto': 'Auto',
|
||||||
|
'proxy.direct': 'Direct',
|
||||||
|
'proxy.full': 'Full proxy',
|
||||||
|
'proxy.manifest': 'Manifest only',
|
||||||
// OBS link names
|
// OBS link names
|
||||||
'obs.local_hls': 'Local HLS',
|
'obs.local_hls': 'Local HLS',
|
||||||
'obs.local_flv': 'Local FLV',
|
'obs.local_flv': 'Local FLV',
|
||||||
@@ -3006,17 +3142,21 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const probeUrl = async (url, type = '', drmConfigs = []) => {
|
const probeUrl = async (url, type = '', drmConfigs = [], upstreamCookie = '') => {
|
||||||
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
|
drmConfigs,
|
||||||
|
upstreamCookie
|
||||||
});
|
});
|
||||||
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;
|
// Note: no probeActive guard here — a newer check (e.g. triggered by pasting a
|
||||||
|
// cookie while the URL probe is still in flight) must be allowed to start.
|
||||||
|
// The probeToken check below discards any stale/superseded result.
|
||||||
|
if (!row || !row.isConnected) return;
|
||||||
const statusEl = row.querySelector('.stream-check-status');
|
const statusEl = row.querySelector('.stream-check-status');
|
||||||
const { url, type } = getRowProbeTarget(row);
|
const { url, type } = getRowProbeTarget(row);
|
||||||
if (!url) {
|
if (!url) {
|
||||||
@@ -3028,12 +3168,15 @@
|
|||||||
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, getRowDrmConfigs(row));
|
const upstreamCookie = row.querySelector('.l-upstream-cookie')?.value.trim() || '';
|
||||||
|
const result = await probeUrl(url, type, getRowDrmConfigs(row), upstreamCookie);
|
||||||
if (!row.isConnected || row.dataset.probeToken !== token) return;
|
if (!row.isConnected || row.dataset.probeToken !== token) return;
|
||||||
|
const proxyMode = row.querySelector('.l-proxy-mode')?.value || 'auto';
|
||||||
|
const cookieMismatch = result.valid && upstreamCookie && (proxyMode === 'direct' || proxyMode === 'manifest');
|
||||||
setProbeStatus(
|
setProbeStatus(
|
||||||
statusEl,
|
statusEl,
|
||||||
result.valid ? 'is-online' : 'is-offline',
|
cookieMismatch ? 'is-warning' : (result.valid ? 'is-online' : 'is-offline'),
|
||||||
result.valid ? t('probe.detected') : (t('probe.' + result.code) || t('probe.no_info'))
|
cookieMismatch ? t('probe.cookie_proxy_mismatch') : (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) {
|
||||||
@@ -3061,11 +3204,24 @@
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyProbeResult = (streamId, result) => {
|
const hasCookieProxyMismatch = (stream) => {
|
||||||
|
if (!stream?.links_json) return false;
|
||||||
|
try {
|
||||||
|
const links = JSON.parse(stream.links_json);
|
||||||
|
return Array.isArray(links) && links.some(l => {
|
||||||
|
const cookie = (l.upstreamCookie || l.upstream_cookie || '').trim();
|
||||||
|
const mode = (l.proxyMode || l.proxy_mode || 'auto').toLowerCase();
|
||||||
|
return cookie && (mode === 'direct' || mode === 'manifest');
|
||||||
|
});
|
||||||
|
} catch { return false; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyProbeResult = (streamId, result, stream = null) => {
|
||||||
|
const mismatch = result.valid && stream && hasCookieProxyMismatch(stream);
|
||||||
setSavedProbeStatus(
|
setSavedProbeStatus(
|
||||||
streamId,
|
streamId,
|
||||||
result.valid ? 'is-online' : 'is-offline',
|
mismatch ? 'is-warning' : (result.valid ? 'is-online' : 'is-offline'),
|
||||||
result.valid ? t('probe.detected') : (t('probe.' + result.code) || t('probe.no_info'))
|
mismatch ? t('probe.cookie_proxy_mismatch') : (result.valid ? t('probe.detected') : (t('probe.' + result.code) || t('probe.no_info')))
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3077,7 +3233,7 @@
|
|||||||
if (showChecking) setSavedProbeStatus(stream.id, 'is-checking', t('probe.detecting'));
|
if (showChecking) setSavedProbeStatus(stream.id, 'is-checking', t('probe.detecting'));
|
||||||
try {
|
try {
|
||||||
const res = await apiCall('check_stream', { id: stream.id });
|
const res = await apiCall('check_stream', { id: stream.id });
|
||||||
applyProbeResult(stream.id, res.data || { valid: false });
|
applyProbeResult(stream.id, res.data || { valid: false }, stream);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setSavedProbeStatus(stream.id, 'is-offline', t('probe.no_info'));
|
setSavedProbeStatus(stream.id, 'is-offline', t('probe.no_info'));
|
||||||
}
|
}
|
||||||
@@ -3099,7 +3255,7 @@
|
|||||||
try {
|
try {
|
||||||
const res = await apiCall('check_stream', { id: stream.id });
|
const res = await apiCall('check_stream', { id: stream.id });
|
||||||
const result = res.data || { valid: false };
|
const result = res.data || { valid: false };
|
||||||
applyProbeResult(stream.id, result);
|
applyProbeResult(stream.id, result, stream);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setSavedProbeStatus(stream.id, 'is-offline', t('probe.no_info'));
|
setSavedProbeStatus(stream.id, 'is-offline', t('probe.no_info'));
|
||||||
}
|
}
|
||||||
@@ -3890,8 +4046,121 @@
|
|||||||
function setupDragHandles() {
|
function setupDragHandles() {
|
||||||
let dragSrc = null;
|
let dragSrc = null;
|
||||||
let _dragFromHandle = false;
|
let _dragFromHandle = false;
|
||||||
|
let touchDrag = null;
|
||||||
|
const clearTouchDragMarks = () => {
|
||||||
|
els.list.querySelectorAll('.stream-row').forEach(r => r.classList.remove('touch-dragging', 'touch-drag-over'));
|
||||||
|
};
|
||||||
|
const saveOrder = async (label) => {
|
||||||
|
const newIds = [...els.list.querySelectorAll(`.stream-row[data-label="${label}"]`)]
|
||||||
|
.map(r => Number(r.dataset.id));
|
||||||
|
try {
|
||||||
|
await apiCall('reorder_streams', { label, ids: newIds });
|
||||||
|
newIds.forEach((id, idx) => {
|
||||||
|
const s = allStreams.find(x => x.id === id);
|
||||||
|
if (s) s.sort_order = idx;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
showToast(t('msg.sort_err') + ':' + err.message, 'error');
|
||||||
|
renderList();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const moveRowByPoint = (draggedRow, clientY) => {
|
||||||
|
const label = draggedRow.dataset.label;
|
||||||
|
const rows = Array.from(els.list.querySelectorAll(`.stream-row[data-label="${label}"]`)).filter(row => row !== draggedRow);
|
||||||
|
let target = null;
|
||||||
|
for (const row of rows) {
|
||||||
|
const rect = row.getBoundingClientRect();
|
||||||
|
if (clientY < rect.top + rect.height / 2) {
|
||||||
|
target = row;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (target) els.list.insertBefore(draggedRow, target);
|
||||||
|
else if (rows.length) els.list.insertBefore(draggedRow, rows[rows.length - 1].nextSibling);
|
||||||
|
clearTouchDragMarks();
|
||||||
|
draggedRow.classList.add('touch-dragging');
|
||||||
|
if (target) target.classList.add('touch-drag-over');
|
||||||
|
};
|
||||||
els.list.querySelectorAll('.stream-row').forEach(row => {
|
els.list.querySelectorAll('.stream-row').forEach(row => {
|
||||||
row.querySelector('.drag-handle')?.addEventListener('mousedown', () => { _dragFromHandle = true; });
|
const handle = row.querySelector('.drag-handle');
|
||||||
|
handle?.addEventListener('mousedown', () => { _dragFromHandle = true; });
|
||||||
|
if (handle) {
|
||||||
|
handle.style.touchAction = 'none';
|
||||||
|
const beginTouchDrag = (pointerId = null) => {
|
||||||
|
touchDrag = { row, pointerId, label: row.dataset.label };
|
||||||
|
row.classList.add('touch-dragging');
|
||||||
|
};
|
||||||
|
const updateTouchDrag = (clientY) => {
|
||||||
|
if (!touchDrag || touchDrag.row !== row) return;
|
||||||
|
moveRowByPoint(row, clientY);
|
||||||
|
};
|
||||||
|
const finishTouchDragByLabel = async (label) => {
|
||||||
|
touchDrag = null;
|
||||||
|
clearTouchDragMarks();
|
||||||
|
await saveOrder(label);
|
||||||
|
};
|
||||||
|
const cancelTouchDrag = () => {
|
||||||
|
touchDrag = null;
|
||||||
|
clearTouchDragMarks();
|
||||||
|
renderList();
|
||||||
|
};
|
||||||
|
if (window.PointerEvent) {
|
||||||
|
handle.addEventListener('pointerdown', (event) => {
|
||||||
|
if (event.pointerType === 'mouse') return;
|
||||||
|
event.preventDefault();
|
||||||
|
beginTouchDrag(event.pointerId);
|
||||||
|
try { handle.setPointerCapture(event.pointerId); } catch (e) {}
|
||||||
|
const onMove = (moveEvent) => {
|
||||||
|
if (!touchDrag || touchDrag.pointerId !== moveEvent.pointerId || touchDrag.row !== row) return;
|
||||||
|
moveEvent.preventDefault();
|
||||||
|
updateTouchDrag(moveEvent.clientY);
|
||||||
|
};
|
||||||
|
const onUp = async (upEvent) => {
|
||||||
|
if (!touchDrag || touchDrag.pointerId !== upEvent.pointerId || touchDrag.row !== row) return;
|
||||||
|
const label = touchDrag.label;
|
||||||
|
document.removeEventListener('pointermove', onMove, true);
|
||||||
|
document.removeEventListener('pointerup', onUp, true);
|
||||||
|
document.removeEventListener('pointercancel', onCancel, true);
|
||||||
|
try { handle.releasePointerCapture(upEvent.pointerId); } catch (e) {}
|
||||||
|
await finishTouchDragByLabel(label);
|
||||||
|
};
|
||||||
|
const onCancel = (cancelEvent) => {
|
||||||
|
if (!touchDrag || touchDrag.pointerId !== cancelEvent.pointerId || touchDrag.row !== row) return;
|
||||||
|
document.removeEventListener('pointermove', onMove, true);
|
||||||
|
document.removeEventListener('pointerup', onUp, true);
|
||||||
|
document.removeEventListener('pointercancel', onCancel, true);
|
||||||
|
try { handle.releasePointerCapture(cancelEvent.pointerId); } catch (e) {}
|
||||||
|
cancelTouchDrag();
|
||||||
|
};
|
||||||
|
document.addEventListener('pointermove', onMove, true);
|
||||||
|
document.addEventListener('pointerup', onUp, true);
|
||||||
|
document.addEventListener('pointercancel', onCancel, true);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
handle.addEventListener('touchstart', (event) => {
|
||||||
|
const touch = event.touches[0];
|
||||||
|
if (!touch) return;
|
||||||
|
event.preventDefault();
|
||||||
|
beginTouchDrag();
|
||||||
|
}, { passive: false });
|
||||||
|
handle.addEventListener('touchmove', (event) => {
|
||||||
|
const touch = event.touches[0];
|
||||||
|
if (!touch || !touchDrag || touchDrag.row !== row) return;
|
||||||
|
event.preventDefault();
|
||||||
|
updateTouchDrag(touch.clientY);
|
||||||
|
}, { passive: false });
|
||||||
|
handle.addEventListener('touchend', async (event) => {
|
||||||
|
if (!touchDrag || touchDrag.row !== row) return;
|
||||||
|
event.preventDefault();
|
||||||
|
await finishTouchDragByLabel(touchDrag.label);
|
||||||
|
}, { passive: false });
|
||||||
|
handle.addEventListener('touchcancel', (event) => {
|
||||||
|
if (!touchDrag || touchDrag.row !== row) return;
|
||||||
|
event.preventDefault();
|
||||||
|
cancelTouchDrag();
|
||||||
|
}, { passive: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
row.addEventListener('dragstart', e => {
|
row.addEventListener('dragstart', e => {
|
||||||
if (!_dragFromHandle) { e.preventDefault(); return; }
|
if (!_dragFromHandle) { e.preventDefault(); return; }
|
||||||
dragSrc = row;
|
dragSrc = row;
|
||||||
@@ -3919,19 +4188,7 @@
|
|||||||
const dstIdx = [...els.list.children].indexOf(row);
|
const dstIdx = [...els.list.children].indexOf(row);
|
||||||
if (srcIdx < dstIdx) els.list.insertBefore(dragSrc, row.nextSibling);
|
if (srcIdx < dstIdx) els.list.insertBefore(dragSrc, row.nextSibling);
|
||||||
else els.list.insertBefore(dragSrc, row);
|
else els.list.insertBefore(dragSrc, row);
|
||||||
const label = row.dataset.label;
|
await saveOrder(row.dataset.label);
|
||||||
const newIds = [...els.list.querySelectorAll(`.stream-row[data-label="${label}"]`)]
|
|
||||||
.map(r => Number(r.dataset.id));
|
|
||||||
try {
|
|
||||||
await apiCall('reorder_streams', { label, ids: newIds });
|
|
||||||
newIds.forEach((id, idx) => {
|
|
||||||
const s = allStreams.find(x => x.id === id);
|
|
||||||
if (s) s.sort_order = idx;
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
showToast(t('msg.sort_err') + ':' + err.message, 'error');
|
|
||||||
renderList();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -4010,6 +4267,8 @@
|
|||||||
return {
|
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,
|
||||||
|
proxyMode: row.querySelector('.l-proxy-mode')?.value || 'auto',
|
||||||
|
upstreamCookie: row.querySelector('.l-upstream-cookie')?.value.trim() || '',
|
||||||
url: fallbackUrl,
|
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(),
|
||||||
@@ -4044,10 +4303,92 @@
|
|||||||
|
|
||||||
let _linkDragSrc = null;
|
let _linkDragSrc = null;
|
||||||
let _linkDragFromHandle = false;
|
let _linkDragFromHandle = false;
|
||||||
|
let _linkTouchDrag = null;
|
||||||
|
|
||||||
const addLinkUI = (name = 'Default', url = '', key = '', clearkey = '', type = '', drmType = '', licenseUrl = '', licenseHeaders = '', pssh = '', certificateUrl = '', drmConfigs = []) => {
|
const clearLinkTouchDragMarks = () => {
|
||||||
|
els.linksContainer.querySelectorAll('.link-row').forEach(row => {
|
||||||
|
row.classList.remove('touch-dragging', 'touch-drag-over');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateLinkMoveButtons = () => {
|
||||||
|
const rows = Array.from(els.linksContainer.querySelectorAll('.link-row'));
|
||||||
|
rows.forEach((row, index) => {
|
||||||
|
const up = row.querySelector('.link-move-up');
|
||||||
|
const down = row.querySelector('.link-move-down');
|
||||||
|
if (up) up.disabled = index === 0;
|
||||||
|
if (down) down.disabled = index === rows.length - 1;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveLinkRowStep = (row, direction) => {
|
||||||
|
if (!row || !row.classList.contains('link-row')) return;
|
||||||
|
if (direction < 0) {
|
||||||
|
const prev = row.previousElementSibling;
|
||||||
|
if (prev?.classList.contains('link-row')) {
|
||||||
|
els.linksContainer.insertBefore(row, prev);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const next = row.nextElementSibling;
|
||||||
|
if (next?.classList.contains('link-row')) {
|
||||||
|
els.linksContainer.insertBefore(row, next.nextElementSibling);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateLinkMoveButtons();
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveLinkRowByPoint = (draggedRow, clientY) => {
|
||||||
|
const rows = Array.from(els.linksContainer.querySelectorAll('.link-row')).filter(row => row !== draggedRow);
|
||||||
|
let target = null;
|
||||||
|
for (const row of rows) {
|
||||||
|
const rect = row.getBoundingClientRect();
|
||||||
|
if (clientY < rect.top + rect.height / 2) {
|
||||||
|
target = row;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (target) {
|
||||||
|
els.linksContainer.insertBefore(draggedRow, target);
|
||||||
|
} else {
|
||||||
|
els.linksContainer.appendChild(draggedRow);
|
||||||
|
}
|
||||||
|
clearLinkTouchDragMarks();
|
||||||
|
draggedRow.classList.add('touch-dragging');
|
||||||
|
const next = target || rows[rows.length - 1] || null;
|
||||||
|
if (next && next !== draggedRow) next.classList.add('touch-drag-over');
|
||||||
|
updateLinkMoveButtons();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setupLinkPointerDrag = (row) => {
|
||||||
|
const handle = row.querySelector('.drag-handle');
|
||||||
|
if (!handle || !window.PointerEvent) return;
|
||||||
|
handle.style.touchAction = 'none';
|
||||||
|
handle.addEventListener('pointerdown', (event) => {
|
||||||
|
if (event.pointerType === 'mouse') return;
|
||||||
|
event.preventDefault();
|
||||||
|
_linkTouchDrag = { row, pointerId: event.pointerId };
|
||||||
|
row.classList.add('touch-dragging');
|
||||||
|
try { handle.setPointerCapture(event.pointerId); } catch (e) {}
|
||||||
|
});
|
||||||
|
handle.addEventListener('pointermove', (event) => {
|
||||||
|
if (!_linkTouchDrag || _linkTouchDrag.pointerId !== event.pointerId || _linkTouchDrag.row !== row) return;
|
||||||
|
event.preventDefault();
|
||||||
|
moveLinkRowByPoint(row, event.clientY);
|
||||||
|
});
|
||||||
|
const finish = (event) => {
|
||||||
|
if (!_linkTouchDrag || _linkTouchDrag.pointerId !== event.pointerId || _linkTouchDrag.row !== row) return;
|
||||||
|
try { handle.releasePointerCapture(event.pointerId); } catch (e) {}
|
||||||
|
_linkTouchDrag = null;
|
||||||
|
clearLinkTouchDragMarks();
|
||||||
|
};
|
||||||
|
handle.addEventListener('pointerup', finish);
|
||||||
|
handle.addEventListener('pointercancel', finish);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addLinkUI = (name = 'Default', url = '', key = '', clearkey = '', type = '', drmType = '', licenseUrl = '', licenseHeaders = '', pssh = '', certificateUrl = '', drmConfigs = [], proxyMode = 'auto', upstreamCookie = '') => {
|
||||||
const rawDrmType = String(drmType || '').toLowerCase();
|
const rawDrmType = String(drmType || '').toLowerCase();
|
||||||
const normalizedDrmType = rawDrmType === 'widevine' || rawDrmType === 'fairplay' ? rawDrmType : '';
|
const normalizedDrmType = rawDrmType === 'widevine' || rawDrmType === 'fairplay' ? rawDrmType : '';
|
||||||
|
const normalizedProxyMode = ['auto', 'direct', 'full', 'manifest'].includes(String(proxyMode || '').toLowerCase()) ? String(proxyMode || '').toLowerCase() : 'auto';
|
||||||
const normalizedDrmConfigs = Array.isArray(drmConfigs) && drmConfigs.length
|
const normalizedDrmConfigs = Array.isArray(drmConfigs) && drmConfigs.length
|
||||||
? drmConfigs
|
? drmConfigs
|
||||||
: (normalizedDrmType || licenseUrl || certificateUrl || licenseHeaders || pssh)
|
: (normalizedDrmType || licenseUrl || certificateUrl || licenseHeaders || pssh)
|
||||||
@@ -4057,20 +4398,54 @@
|
|||||||
div.className = 'link-row';
|
div.className = 'link-row';
|
||||||
div.draggable = true;
|
div.draggable = true;
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<span class="drag-handle" draggable="false" style="margin-top:6px;">⠿</span>
|
<div class="link-order-controls">
|
||||||
<input class="l-name" placeholder="${t('ph.link_name')}" value="${escapeAttr(name)}">
|
<span class="drag-handle" draggable="false">⠿</span>
|
||||||
<select class="l-type">
|
<button type="button" class="btn-secondary link-move-btn link-move-up" title="Move up" aria-label="Move up">↑</button>
|
||||||
<option value="" ${type === '' ? 'selected' : ''}>${t('type.auto')}</option>
|
<button type="button" class="btn-secondary link-move-btn link-move-down" title="Move down" aria-label="Move down">↓</button>
|
||||||
<option value="m3u8" ${type === 'm3u8' ? 'selected' : ''}>HLS</option>
|
|
||||||
<option value="flv" ${type === 'flv' ? 'selected' : ''}>FLV</option>
|
|
||||||
<option value="dash" ${type === 'dash' ? 'selected' : ''}>DASH</option>
|
|
||||||
</select>
|
|
||||||
<div class="url-field">
|
|
||||||
<input class="l-url" placeholder="${t('ph.link_url')}" value="${escapeAttr(url)}">
|
|
||||||
<div class="stream-check-status" aria-live="polite"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<textarea class="l-key" rows="2" placeholder="${t('ph.key_aes')}">${escapeHtml(key)}</textarea>
|
<div class="link-field">
|
||||||
<textarea class="l-clearkey" rows="2" placeholder="${t('ph.clearkey')}">${escapeHtml(clearkey)}</textarea>
|
<div class="link-field-label">${t('form.link_name')}</div>
|
||||||
|
<input class="l-name" placeholder="${t('ph.link_name')}" value="${escapeAttr(name)}">
|
||||||
|
</div>
|
||||||
|
<div class="link-field">
|
||||||
|
<div class="link-field-label">${t('form.link_type')}</div>
|
||||||
|
<select class="l-type">
|
||||||
|
<option value="" ${type === '' ? 'selected' : ''}>${t('type.auto')}</option>
|
||||||
|
<option value="m3u8" ${type === 'm3u8' ? 'selected' : ''}>HLS</option>
|
||||||
|
<option value="flv" ${type === 'flv' ? 'selected' : ''}>FLV</option>
|
||||||
|
<option value="dash" ${type === 'dash' ? 'selected' : ''}>DASH</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="link-field">
|
||||||
|
<div class="link-field-label">${t('form.proxy_mode')}</div>
|
||||||
|
<select class="l-proxy-mode" title="${t('ph.proxy_mode')}">
|
||||||
|
<option value="auto" ${normalizedProxyMode === 'auto' ? 'selected' : ''}>${t('proxy.auto')}</option>
|
||||||
|
<option value="direct" ${normalizedProxyMode === 'direct' ? 'selected' : ''}>${t('proxy.direct')}</option>
|
||||||
|
<option value="full" ${normalizedProxyMode === 'full' ? 'selected' : ''}>${t('proxy.full')}</option>
|
||||||
|
<option value="manifest" ${normalizedProxyMode === 'manifest' ? 'selected' : ''}>${t('proxy.manifest')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="link-field">
|
||||||
|
<div class="link-field-label">${t('form.link_url')}</div>
|
||||||
|
<div class="url-field">
|
||||||
|
<input class="l-url" placeholder="${t('ph.link_url')}" value="${escapeAttr(url)}">
|
||||||
|
<div class="stream-check-status" aria-live="polite"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="link-field">
|
||||||
|
<div class="link-field-label">${t('form.key_override')}</div>
|
||||||
|
<textarea class="l-key" rows="2" placeholder="${t('ph.key_aes')}">${escapeHtml(key)}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="link-field">
|
||||||
|
<div class="link-field-label">${t('form.clearkey')}</div>
|
||||||
|
<textarea class="l-clearkey" rows="2" placeholder="${t('ph.clearkey')}">${escapeHtml(clearkey)}</textarea>
|
||||||
|
</div>
|
||||||
|
<details class="link-upstream-auth" ${upstreamCookie ? 'open' : ''}>
|
||||||
|
<summary>${t('form.upstream_cookie')}</summary>
|
||||||
|
<div class="upstream-auth-body">
|
||||||
|
<textarea class="l-upstream-cookie" rows="3" placeholder="${t('ph.upstream_cookie')}">${escapeHtml(upstreamCookie)}</textarea>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
<details class="link-drm-config" ${normalizedDrmConfigs.length ? 'open' : ''}>
|
<details class="link-drm-config" ${normalizedDrmConfigs.length ? 'open' : ''}>
|
||||||
<summary>${t('drm.config')}</summary>
|
<summary>${t('drm.config')}</summary>
|
||||||
<div class="link-drm-list"></div>
|
<div class="link-drm-list"></div>
|
||||||
@@ -4079,7 +4454,7 @@
|
|||||||
<button type="button" class="btn-secondary add-drm-config-btn" style="padding:6px 10px;font-size:.82em;">+ DRM</button>
|
<button type="button" class="btn-secondary add-drm-config-btn" style="padding:6px 10px;font-size:.82em;">+ DRM</button>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<button type="button" class="btn-danger link-remove-btn" onclick="this.parentElement.remove()">×</button>
|
<button type="button" class="btn-danger link-remove-btn">×</button>
|
||||||
`;
|
`;
|
||||||
els.linksContainer.appendChild(div);
|
els.linksContainer.appendChild(div);
|
||||||
const drmList = div.querySelector('.link-drm-list');
|
const drmList = div.querySelector('.link-drm-list');
|
||||||
@@ -4287,7 +4662,17 @@
|
|||||||
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));
|
||||||
|
div.querySelector('.l-proxy-mode').addEventListener('change', () => scheduleLinkRowCheck(div, 0));
|
||||||
|
div.querySelector('.l-upstream-cookie')?.addEventListener('input', () => scheduleLinkRowCheck(div));
|
||||||
|
div.querySelector('.l-upstream-cookie')?.addEventListener('blur', () => scheduleLinkRowCheck(div, 0));
|
||||||
if (url) scheduleLinkRowCheck(div, 250);
|
if (url) scheduleLinkRowCheck(div, 250);
|
||||||
|
div.querySelector('.link-move-up')?.addEventListener('click', () => moveLinkRowStep(div, -1));
|
||||||
|
div.querySelector('.link-move-down')?.addEventListener('click', () => moveLinkRowStep(div, 1));
|
||||||
|
div.querySelector('.link-remove-btn')?.addEventListener('click', () => {
|
||||||
|
div.remove();
|
||||||
|
updateLinkMoveButtons();
|
||||||
|
});
|
||||||
|
setupLinkPointerDrag(div);
|
||||||
div.querySelector('.drag-handle').addEventListener('mousedown', () => { _linkDragFromHandle = true; });
|
div.querySelector('.drag-handle').addEventListener('mousedown', () => { _linkDragFromHandle = true; });
|
||||||
div.addEventListener('dragstart', e => {
|
div.addEventListener('dragstart', e => {
|
||||||
if (!_linkDragFromHandle) { e.preventDefault(); return; }
|
if (!_linkDragFromHandle) { e.preventDefault(); return; }
|
||||||
@@ -4317,7 +4702,9 @@
|
|||||||
if (srcIdx < dstIdx) container.insertBefore(_linkDragSrc, div.nextSibling);
|
if (srcIdx < dstIdx) container.insertBefore(_linkDragSrc, div.nextSibling);
|
||||||
else container.insertBefore(_linkDragSrc, div);
|
else container.insertBefore(_linkDragSrc, div);
|
||||||
div.classList.remove('drag-over');
|
div.classList.remove('drag-over');
|
||||||
|
updateLinkMoveButtons();
|
||||||
});
|
});
|
||||||
|
updateLinkMoveButtons();
|
||||||
};
|
};
|
||||||
|
|
||||||
document.getElementById('add-link-btn').onclick = () => addLinkUI();
|
document.getElementById('add-link-btn').onclick = () => addLinkUI();
|
||||||
@@ -4437,7 +4824,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, 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 || []));
|
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 || [], l.proxyMode || l.proxy_mode || 'auto', l.upstreamCookie || l.upstream_cookie || ''));
|
||||||
if (links.length === 0) addLinkUI();
|
if (links.length === 0) addLinkUI();
|
||||||
} else {
|
} else {
|
||||||
resetForm();
|
resetForm();
|
||||||
@@ -4516,7 +4903,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<div style="margin-top: 22px;">
|
<div style="margin-top: 22px;">
|
||||||
<h3 data-i18n="h3.links">视角配置</h3>
|
<h3 data-i18n="h3.links">视角配置</h3>
|
||||||
<p class="hint" data-i18n="hint.links">填写格式:视角名称 | 类型 | 播放链接 | Key Override | ClearKey 信息</p>
|
|
||||||
<div id="links-container"></div>
|
<div id="links-container"></div>
|
||||||
<button type="button" id="add-link-btn" class="btn-success" data-i18n="btn.add_link">+ 添加视角</button>
|
<button type="button" id="add-link-btn" class="btn-success" data-i18n="btn.add_link">+ 添加视角</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1302,10 +1302,14 @@
|
|||||||
const hls = new Hls({
|
const hls = new Hls({
|
||||||
enableSoftwareAES: !!keyOverride,
|
enableSoftwareAES: !!keyOverride,
|
||||||
loader: keyOverride ? CustomLoader : Hls.DefaultConfig.loader,
|
loader: keyOverride ? CustomLoader : Hls.DefaultConfig.loader,
|
||||||
|
startLevel: -1,
|
||||||
debug: false
|
debug: false
|
||||||
});
|
});
|
||||||
hls.loadSource(url);
|
hls.loadSource(url);
|
||||||
hls.attachMedia(video);
|
hls.attachMedia(video);
|
||||||
|
// Keep ABR / auto quality active from the start so playback isn't
|
||||||
|
// pinned to the lowest rung when the source (re)loads.
|
||||||
|
hls.on(Hls.Events.MANIFEST_PARSED, () => { hls.currentLevel = -1; });
|
||||||
art.hls = hls;
|
art.hls = hls;
|
||||||
art.on('destroy', () => hls.destroy());
|
art.on('destroy', () => hls.destroy());
|
||||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
@@ -1331,6 +1335,28 @@
|
|||||||
},
|
},
|
||||||
plugins: hlsControlPlugins
|
plugins: hlsControlPlugins
|
||||||
});
|
});
|
||||||
|
// The hls-control plugin labels its quality menu from hls.currentLevel, which
|
||||||
|
// is whatever rung hls.js happens to be on when the player becomes ready
|
||||||
|
// (often the lowest). Re-assert auto mode and refresh the menu so the default
|
||||||
|
// selection shows "Auto" rather than the lowest resolution.
|
||||||
|
playerInstance.on('ready', () => {
|
||||||
|
const root = playerInstance.template?.$player;
|
||||||
|
// Default the resolution menu to its "Auto" (ABR) entry instead of a fixed
|
||||||
|
// rung. The plugin tags each entry with data-value; Auto is value -1. Clicking
|
||||||
|
// it runs the plugin's own onSelect, which puts hls.js in auto mode and marks
|
||||||
|
// Auto as the selected item in both the control bar and the settings panel.
|
||||||
|
const autoItem = root?.querySelector('.art-control-hls-quality .art-selector-item[data-value="-1"]');
|
||||||
|
if (autoItem) autoItem.click();
|
||||||
|
// The native source (视角) control and the plugin resolution control get
|
||||||
|
// inserted in opposite DOM order under live vs archive mode, so their
|
||||||
|
// left/right positions flip. Force the archive layout in both: source
|
||||||
|
// selector on the left, resolution on the right.
|
||||||
|
const sourceCtrl = root?.querySelector('.art-control-quality');
|
||||||
|
const resCtrl = root?.querySelector('.art-control-hls-quality');
|
||||||
|
if (sourceCtrl && resCtrl && sourceCtrl.parentNode === resCtrl.parentNode) {
|
||||||
|
resCtrl.parentNode.insertBefore(sourceCtrl, resCtrl);
|
||||||
|
}
|
||||||
|
});
|
||||||
startPlaybackMonitor(data, password);
|
startPlaybackMonitor(data, password);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import http.client
|
||||||
|
import queue as _queue_mod
|
||||||
import socket
|
import socket
|
||||||
import csv
|
import csv
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -61,8 +63,11 @@ PASSWORD_HASH_ITERATIONS = 240000
|
|||||||
INITIAL_ADMIN_PASSWORD_BYTES = 18
|
INITIAL_ADMIN_PASSWORD_BYTES = 18
|
||||||
PUBLIC_ID_BYTES = 9
|
PUBLIC_ID_BYTES = 9
|
||||||
STREAM_PROBE_TIMEOUT = float(os.getenv("STREAM_PROBE_TIMEOUT", "4"))
|
STREAM_PROBE_TIMEOUT = float(os.getenv("STREAM_PROBE_TIMEOUT", "4"))
|
||||||
|
HLS_PROXY_TIMEOUT = float(os.getenv("HLS_PROXY_TIMEOUT", "15"))
|
||||||
TELEGRAM_TIMEOUT = float(os.getenv("TELEGRAM_TIMEOUT", "6"))
|
TELEGRAM_TIMEOUT = float(os.getenv("TELEGRAM_TIMEOUT", "6"))
|
||||||
STREAM_MONITOR_INTERVAL = max(5, int(os.getenv("STREAM_MONITOR_INTERVAL", "10")))
|
STREAM_MONITOR_INTERVAL = max(5, int(os.getenv("STREAM_MONITOR_INTERVAL", "10")))
|
||||||
|
TG_RECONNECT_GRACE_SECS = max(0, int(os.getenv("TG_RECONNECT_GRACE_SECS", "60")))
|
||||||
|
TG_START_MERGE_SECS = max(0, int(os.getenv("TG_START_MERGE_SECS", "30")))
|
||||||
SRS_HTTP_ORIGIN = os.getenv("SRS_HTTP_ORIGIN", "http://srs:8080").rstrip("/")
|
SRS_HTTP_ORIGIN = os.getenv("SRS_HTTP_ORIGIN", "http://srs:8080").rstrip("/")
|
||||||
OBS_ROUTE_SLUG_LENGTH = max(12, int(os.getenv("OBS_ROUTE_SLUG_LENGTH", "22")))
|
OBS_ROUTE_SLUG_LENGTH = max(12, int(os.getenv("OBS_ROUTE_SLUG_LENGTH", "22")))
|
||||||
URL_PATH_SAFE = "/._~!$&'()*+,;=:@"
|
URL_PATH_SAFE = "/._~!$&'()*+,;=:@"
|
||||||
@@ -91,7 +96,20 @@ 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()
|
||||||
|
_pending_stop_timers: dict[int, threading.Timer] = {}
|
||||||
|
_pending_stop_lock = threading.Lock()
|
||||||
|
_pending_start_timers: dict[int, tuple[threading.Timer, list[str]]] = {}
|
||||||
|
_pending_start_lock = threading.Lock()
|
||||||
|
_upstream_pools: dict[tuple, _queue_mod.Queue] = {} # (scheme, host, port) -> Queue[conn]
|
||||||
|
_upstream_pools_lock = threading.Lock()
|
||||||
|
_UPSTREAM_POOL_SIZE = 4
|
||||||
|
|
||||||
|
_proxy_cookie_tokens: dict[str, str] = {} # token_id -> cookie string (server-side only)
|
||||||
|
_proxy_cookie_refs: dict[str, str] = {} # cookie -> cookie_ref (reverse map for stable proxy URLs)
|
||||||
|
_proxy_cookie_tokens_lock = threading.Lock()
|
||||||
|
_PROXY_COOKIE_TOKEN_MAX = 5000
|
||||||
HLS_PROXY_PREFIX = "/proxy/hls"
|
HLS_PROXY_PREFIX = "/proxy/hls"
|
||||||
|
HLS_MANIFEST_PROXY_PREFIX = "/proxy/hls-manifest"
|
||||||
VIEWER_TOKEN_TTL = 300 # seconds
|
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)
|
||||||
|
|
||||||
@@ -335,6 +353,7 @@ def init_stats_tables(conn) -> None:
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_viewer_events_stream_time ON viewer_events(stream_id, event_at)")
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_viewer_events_stream_time ON viewer_events(stream_id, event_at)")
|
||||||
|
conn.execute("ALTER TABLE stream_probe_states ADD COLUMN IF NOT EXISTS live_links_json TEXT NOT NULL DEFAULT '[]'")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -424,7 +443,104 @@ 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_url_token(url: str) -> str:
|
def _upstream_conn_borrow(scheme: str, host: str, port: int, timeout: float) -> http.client.HTTPConnection:
|
||||||
|
key = (scheme, host, port)
|
||||||
|
with _upstream_pools_lock:
|
||||||
|
if key not in _upstream_pools:
|
||||||
|
_upstream_pools[key] = _queue_mod.Queue(maxsize=_UPSTREAM_POOL_SIZE)
|
||||||
|
pool = _upstream_pools[key]
|
||||||
|
try:
|
||||||
|
conn = pool.get_nowait()
|
||||||
|
conn.timeout = timeout
|
||||||
|
return conn
|
||||||
|
except _queue_mod.Empty:
|
||||||
|
if scheme == "https":
|
||||||
|
return http.client.HTTPSConnection(host, port, timeout=timeout)
|
||||||
|
return http.client.HTTPConnection(host, port, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def _upstream_conn_return(scheme: str, host: str, port: int, conn: http.client.HTTPConnection) -> None:
|
||||||
|
key = (scheme, host, port)
|
||||||
|
with _upstream_pools_lock:
|
||||||
|
pool = _upstream_pools.get(key)
|
||||||
|
if pool is not None:
|
||||||
|
try:
|
||||||
|
pool.put_nowait(conn)
|
||||||
|
return
|
||||||
|
except _queue_mod.Full:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _upstream_fetch(
|
||||||
|
url: str,
|
||||||
|
headers: dict[str, str],
|
||||||
|
timeout: float,
|
||||||
|
) -> tuple[http.client.HTTPResponse, http.client.HTTPConnection, str, str, int]:
|
||||||
|
"""Fetch URL via a pooled persistent connection.
|
||||||
|
Returns (response, conn, scheme, host, port).
|
||||||
|
Caller must fully consume the response body, then call _upstream_conn_return().
|
||||||
|
On any error the connection is closed automatically."""
|
||||||
|
parsed = urlparse(url)
|
||||||
|
scheme = parsed.scheme.lower()
|
||||||
|
host = parsed.hostname or ""
|
||||||
|
port = int(parsed.port or (443 if scheme == "https" else 80))
|
||||||
|
path = parsed.path or "/"
|
||||||
|
if parsed.query:
|
||||||
|
path = f"{path}?{parsed.query}"
|
||||||
|
|
||||||
|
for attempt in range(2):
|
||||||
|
conn = _upstream_conn_borrow(scheme, host, port, timeout)
|
||||||
|
try:
|
||||||
|
conn.request("GET", path, headers=headers)
|
||||||
|
resp = conn.getresponse()
|
||||||
|
return resp, conn, scheme, host, port
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if attempt == 0:
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_proxy_cookie_token(cookie: str) -> str:
|
||||||
|
"""Return a stable signed opaque reference for this cookie, creating one if needed.
|
||||||
|
The same cookie string always returns the same ref so HLS segment proxy URLs remain
|
||||||
|
stable across manifest re-fetches, preventing spurious ABR resets."""
|
||||||
|
with _proxy_cookie_tokens_lock:
|
||||||
|
if cookie in _proxy_cookie_refs:
|
||||||
|
return _proxy_cookie_refs[cookie]
|
||||||
|
token_id = secrets.token_urlsafe(16)
|
||||||
|
if len(_proxy_cookie_tokens) >= _PROXY_COOKIE_TOKEN_MAX:
|
||||||
|
evicted_id = next(iter(_proxy_cookie_tokens))
|
||||||
|
evicted_cookie = _proxy_cookie_tokens.pop(evicted_id)
|
||||||
|
_proxy_cookie_refs.pop(evicted_cookie, None)
|
||||||
|
_proxy_cookie_tokens[token_id] = cookie
|
||||||
|
ref = f"{token_id}.{sign(f'proxy-cookie-token:{token_id}')}"
|
||||||
|
_proxy_cookie_refs[cookie] = ref
|
||||||
|
return ref
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_proxy_cookie_token(ref: str) -> str:
|
||||||
|
"""Verify ref signature and return the stored cookie, or '' if invalid/not found."""
|
||||||
|
if "." not in ref:
|
||||||
|
return ""
|
||||||
|
token_id, sig = ref.split(".", 1)
|
||||||
|
if not hmac.compare_digest(sig, sign(f"proxy-cookie-token:{token_id}")):
|
||||||
|
return ""
|
||||||
|
with _proxy_cookie_tokens_lock:
|
||||||
|
return _proxy_cookie_tokens.get(token_id, "")
|
||||||
|
|
||||||
|
|
||||||
|
def hls_proxy_url_token(url: str, cookie_ref: str = "") -> str:
|
||||||
|
# cookie_ref is an opaque token issued by _issue_proxy_cookie_token, not the raw cookie.
|
||||||
|
if cookie_ref:
|
||||||
|
return sign(f"hls-proxy-url:{url}:{cookie_ref}")
|
||||||
return sign(f"hls-proxy-url:{url}")
|
return sign(f"hls-proxy-url:{url}")
|
||||||
|
|
||||||
|
|
||||||
@@ -452,11 +568,39 @@ def verify_viewer_token(token: str, stream_id: int) -> bool:
|
|||||||
return hmac.compare_digest(sig, sign(f"viewer-token:{payload}"))
|
return hmac.compare_digest(sig, sign(f"viewer-token:{payload}"))
|
||||||
|
|
||||||
|
|
||||||
def hls_proxy_path(url: str) -> str:
|
def hls_proxy_path(url: str, upstream_cookie: str = "") -> str:
|
||||||
encoded = encode_proxy_target(url)
|
encoded = encode_proxy_target(url)
|
||||||
|
if upstream_cookie:
|
||||||
|
cookie_ref = _issue_proxy_cookie_token(upstream_cookie)
|
||||||
|
token = hls_proxy_url_token(url, cookie_ref)
|
||||||
|
encoded_ref = base64.urlsafe_b64encode(cookie_ref.encode("utf-8")).decode("ascii").rstrip("=")
|
||||||
|
return f"{HLS_PROXY_PREFIX}/{token}/{encoded}/{encoded_ref}"
|
||||||
return f"{HLS_PROXY_PREFIX}/{hls_proxy_url_token(url)}/{encoded}"
|
return f"{HLS_PROXY_PREFIX}/{hls_proxy_url_token(url)}/{encoded}"
|
||||||
|
|
||||||
|
|
||||||
|
def hls_manifest_proxy_path(url: str) -> str:
|
||||||
|
encoded = encode_proxy_target(url)
|
||||||
|
return f"{HLS_MANIFEST_PROXY_PREFIX}/{hls_proxy_url_token(url)}/{encoded}"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_proxy_mode(value: object) -> str:
|
||||||
|
mode = str(value or "").strip().lower()
|
||||||
|
return mode if mode in ("auto", "direct", "full", "manifest") else "auto"
|
||||||
|
|
||||||
|
|
||||||
|
def playback_url_for_mode(url: str, link: dict[str, object], proxy_mode: str) -> str:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if not (is_hls_link(link) and parsed.scheme in ("http", "https")):
|
||||||
|
return url
|
||||||
|
mode = normalize_proxy_mode(proxy_mode)
|
||||||
|
if mode == "direct":
|
||||||
|
return url
|
||||||
|
if mode == "manifest":
|
||||||
|
return hls_manifest_proxy_path(url)
|
||||||
|
upstream_cookie = str(link.get("upstreamCookie", link.get("upstream_cookie", ""))).strip()
|
||||||
|
return hls_proxy_path(url, upstream_cookie)
|
||||||
|
|
||||||
|
|
||||||
PLAYABLE_VIDEO_EXTS = frozenset({".mp4", ".mkv", ".mov", ".webm", ".m4v"})
|
PLAYABLE_VIDEO_EXTS = frozenset({".mp4", ".mkv", ".mov", ".webm", ".m4v"})
|
||||||
|
|
||||||
|
|
||||||
@@ -499,9 +643,10 @@ def add_playback_urls(links: list[dict[str, object]]) -> list[dict[str, object]]
|
|||||||
for link in links:
|
for link in links:
|
||||||
item = dict(link)
|
item = dict(link)
|
||||||
raw_url = str(item.get("url") or "")
|
raw_url = str(item.get("url") or "")
|
||||||
parsed = urlparse(raw_url)
|
proxy_mode = normalize_proxy_mode(item.get("proxyMode", item.get("proxy_mode", "")))
|
||||||
if is_hls_link(item) and parsed.scheme in ("http", "https"):
|
item["proxyMode"] = proxy_mode
|
||||||
item["playback_url"] = hls_proxy_path(raw_url)
|
if raw_url:
|
||||||
|
item["playback_url"] = playback_url_for_mode(raw_url, item, proxy_mode)
|
||||||
drm_configs = item.get("drmConfigs", [])
|
drm_configs = item.get("drmConfigs", [])
|
||||||
if isinstance(drm_configs, list):
|
if isinstance(drm_configs, list):
|
||||||
prepared_configs: list[dict[str, object]] = []
|
prepared_configs: list[dict[str, object]] = []
|
||||||
@@ -513,11 +658,11 @@ def add_playback_urls(links: list[dict[str, object]]) -> list[dict[str, object]]
|
|||||||
drm_playback_type = str(config_item.get("playbackType") or "")
|
drm_playback_type = str(config_item.get("playbackType") or "")
|
||||||
if drm_playback_url:
|
if drm_playback_url:
|
||||||
probe_item = {"url": drm_playback_url, "type": drm_playback_type}
|
probe_item = {"url": drm_playback_url, "type": drm_playback_type}
|
||||||
parsed_drm_url = urlparse(drm_playback_url)
|
config_item["playback_url"] = playback_url_for_mode(drm_playback_url, probe_item, proxy_mode)
|
||||||
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)
|
prepared_configs.append(config_item)
|
||||||
item["drmConfigs"] = prepared_configs
|
item["drmConfigs"] = prepared_configs
|
||||||
|
# Cookie is already embedded in playback_url; strip it from viewer-facing data.
|
||||||
|
item.pop("upstreamCookie", None)
|
||||||
prepared.append(item)
|
prepared.append(item)
|
||||||
return prepared
|
return prepared
|
||||||
|
|
||||||
@@ -641,6 +786,7 @@ def normalize_links(raw: object) -> list[dict[str, object]]:
|
|||||||
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 = ""
|
||||||
|
proxy_mode = normalize_proxy_mode(item.get("proxyMode", item.get("proxy_mode", "")))
|
||||||
drm_configs = normalize_drm_configs(item)
|
drm_configs = normalize_drm_configs(item)
|
||||||
first_drm = drm_configs[0] if drm_configs else {}
|
first_drm = drm_configs[0] if drm_configs else {}
|
||||||
normalized.append(
|
normalized.append(
|
||||||
@@ -648,6 +794,8 @@ def normalize_links(raw: object) -> list[dict[str, object]]:
|
|||||||
"name": name,
|
"name": name,
|
||||||
"type": link_type,
|
"type": link_type,
|
||||||
"url": url,
|
"url": url,
|
||||||
|
"proxyMode": proxy_mode,
|
||||||
|
"upstreamCookie": str(item.get("upstreamCookie", item.get("upstream_cookie", ""))).strip(),
|
||||||
"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,
|
"drmConfigs": drm_configs,
|
||||||
@@ -1121,6 +1269,7 @@ def probe_stream_url(
|
|||||||
raw_url: object,
|
raw_url: object,
|
||||||
type_hint: object = "",
|
type_hint: object = "",
|
||||||
drm_configs: object | None = None,
|
drm_configs: object | None = None,
|
||||||
|
upstream_cookie: str = "",
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
url = str(raw_url or "").strip()
|
url = str(raw_url or "").strip()
|
||||||
if not url:
|
if not url:
|
||||||
@@ -1142,6 +1291,8 @@ def probe_stream_url(
|
|||||||
"User-Agent": "StreamHall/1.0",
|
"User-Agent": "StreamHall/1.0",
|
||||||
"Accept": "*/*",
|
"Accept": "*/*",
|
||||||
}
|
}
|
||||||
|
if upstream_cookie:
|
||||||
|
headers["Cookie"] = upstream_cookie
|
||||||
if not is_hls and not is_dash:
|
if not is_hls and not is_dash:
|
||||||
headers["Range"] = "bytes=0-4095"
|
headers["Range"] = "bytes=0-4095"
|
||||||
|
|
||||||
@@ -1444,15 +1595,25 @@ def probe_stream_links(row: dict[str, object]) -> dict[str, object]:
|
|||||||
links = normalize_links(json.loads(row["links_json"] or "[]"))
|
links = normalize_links(json.loads(row["links_json"] or "[]"))
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
links = []
|
links = []
|
||||||
|
probe_all = int(row.get("tg_notify_enabled", 0) or 0) == 1
|
||||||
|
all_live_names: list[str] = []
|
||||||
|
first_result: dict[str, object] | None = None
|
||||||
for index, link in enumerate(links):
|
for index, link in enumerate(links):
|
||||||
result = probe_stream_url(link["url"], link.get("type", ""), link.get("drmConfigs", []))
|
result = probe_stream_url(link["url"], link.get("type", ""), link.get("drmConfigs", []), link.get("upstreamCookie", ""))
|
||||||
if result["valid"]:
|
if result["valid"]:
|
||||||
return {
|
all_live_names.append(link["name"])
|
||||||
**result,
|
if first_result is None:
|
||||||
"index": index,
|
first_result = {
|
||||||
"url": link["url"],
|
**result,
|
||||||
"link_name": link["name"],
|
"index": index,
|
||||||
}
|
"url": link["url"],
|
||||||
|
"link_name": link["name"],
|
||||||
|
}
|
||||||
|
if not probe_all:
|
||||||
|
break
|
||||||
|
if first_result is not None:
|
||||||
|
first_result["all_live_names"] = all_live_names
|
||||||
|
return first_result
|
||||||
return stream_probe_response(False)
|
return stream_probe_response(False)
|
||||||
|
|
||||||
|
|
||||||
@@ -1494,6 +1655,32 @@ def notification_context_for_row(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _send_tg_live_notification(
|
||||||
|
row: dict[str, object],
|
||||||
|
is_live: bool,
|
||||||
|
probe_result: dict[str, object],
|
||||||
|
headers: object | None = None,
|
||||||
|
) -> None:
|
||||||
|
if int(row["tg_notify_enabled"] or 0) != 1:
|
||||||
|
return
|
||||||
|
settings = telegram_settings()
|
||||||
|
label = normalize_stream_label(row.get("stream_label", "LIVE")).lower()
|
||||||
|
notify_key = f"telegram_{label}_notify_{'start' if is_live else 'stop'}"
|
||||||
|
template_key = f"telegram_{label}_{'start' if is_live else 'stop'}_template"
|
||||||
|
status = "start" if is_live else "stop"
|
||||||
|
if settings.get(notify_key, "0") != "1":
|
||||||
|
return
|
||||||
|
text = render_message_template(
|
||||||
|
settings.get(template_key, ""),
|
||||||
|
notification_context_for_row(row, probe_result, status, headers, settings),
|
||||||
|
)
|
||||||
|
stream_id = int(row["id"])
|
||||||
|
try:
|
||||||
|
send_telegram_message(settings, text)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"Telegram notification failed for stream {stream_id}: {exc}")
|
||||||
|
|
||||||
|
|
||||||
def maybe_notify_stream_transition(
|
def maybe_notify_stream_transition(
|
||||||
row: dict[str, object],
|
row: dict[str, object],
|
||||||
is_live: bool,
|
is_live: bool,
|
||||||
@@ -1501,50 +1688,110 @@ def maybe_notify_stream_transition(
|
|||||||
headers: object | None = None,
|
headers: object | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
stream_id = int(row["id"])
|
stream_id = int(row["id"])
|
||||||
|
current_live_names: list[str] = probe_result.get("all_live_names", []) if is_live else []
|
||||||
|
if is_live and not current_live_names and probe_result.get("link_name"):
|
||||||
|
current_live_names = [str(probe_result["link_name"])]
|
||||||
|
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
previous = conn.execute(
|
previous = conn.execute(
|
||||||
"SELECT is_live FROM stream_probe_states WHERE stream_id = ?", (stream_id,)
|
"SELECT is_live, live_links_json FROM stream_probe_states WHERE stream_id = ?", (stream_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
previous_live = None if previous is None else bool(previous["is_live"])
|
previous_is_live = None if previous is None else bool(previous["is_live"])
|
||||||
|
try:
|
||||||
|
previous_live_names: set[str] = set(json.loads((previous["live_links_json"] or "[]") if previous else "[]"))
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
previous_live_names = set()
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO stream_probe_states (stream_id, is_live, updated_at)
|
INSERT INTO stream_probe_states (stream_id, is_live, live_links_json, updated_at)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
ON CONFLICT(stream_id) DO UPDATE SET
|
ON CONFLICT(stream_id) DO UPDATE SET
|
||||||
is_live = excluded.is_live,
|
is_live = excluded.is_live,
|
||||||
|
live_links_json = excluded.live_links_json,
|
||||||
updated_at = excluded.updated_at
|
updated_at = excluded.updated_at
|
||||||
""",
|
""",
|
||||||
(stream_id, 1 if is_live else 0, now()),
|
(stream_id, 1 if is_live else 0, json.dumps(current_live_names), now()),
|
||||||
)
|
)
|
||||||
if previous_live == is_live:
|
|
||||||
return
|
if previous_is_live is None and not is_live:
|
||||||
if previous_live is None and not is_live:
|
|
||||||
return
|
return
|
||||||
if int(row["tg_notify_enabled"] or 0) != 1:
|
if int(row["tg_notify_enabled"] or 0) != 1:
|
||||||
return
|
return
|
||||||
|
|
||||||
settings = telegram_settings()
|
newly_live = [n for n in current_live_names if n not in previous_live_names]
|
||||||
label = normalize_stream_label(row.get("stream_label", "LIVE")).lower()
|
|
||||||
if is_live:
|
|
||||||
notify_key = f"telegram_{label}_notify_start"
|
|
||||||
template_key = f"telegram_{label}_start_template"
|
|
||||||
status = "start"
|
|
||||||
else:
|
|
||||||
notify_key = f"telegram_{label}_notify_stop"
|
|
||||||
template_key = f"telegram_{label}_stop_template"
|
|
||||||
status = "stop"
|
|
||||||
if settings.get(notify_key, "0") != "1":
|
|
||||||
return
|
|
||||||
template = settings.get(template_key, "")
|
|
||||||
|
|
||||||
text = render_message_template(
|
if is_live:
|
||||||
template,
|
with _pending_stop_lock:
|
||||||
notification_context_for_row(row, probe_result, status, headers, settings),
|
stop_timer = _pending_stop_timers.pop(stream_id, None)
|
||||||
)
|
if stop_timer is not None:
|
||||||
try:
|
stop_timer.cancel()
|
||||||
send_telegram_message(settings, text)
|
return
|
||||||
except Exception as exc:
|
if not newly_live:
|
||||||
print(f"Telegram notification failed for stream {stream_id}: {exc}")
|
return
|
||||||
|
# Schedule start notification with merge window
|
||||||
|
if TG_START_MERGE_SECS <= 0:
|
||||||
|
merged = dict(probe_result, link_name=" & ".join(newly_live))
|
||||||
|
_send_tg_live_notification(row, True, merged, headers)
|
||||||
|
return
|
||||||
|
|
||||||
|
with _pending_start_lock:
|
||||||
|
existing = _pending_start_timers.get(stream_id)
|
||||||
|
if existing:
|
||||||
|
existing[0].cancel()
|
||||||
|
all_names = list(dict.fromkeys(existing[1] + newly_live))
|
||||||
|
else:
|
||||||
|
all_names = list(newly_live)
|
||||||
|
|
||||||
|
def _fire_start() -> None:
|
||||||
|
with _pending_start_lock:
|
||||||
|
entry = _pending_start_timers.get(stream_id)
|
||||||
|
if entry is None or entry[0] is not _the_start_timer:
|
||||||
|
return
|
||||||
|
del _pending_start_timers[stream_id]
|
||||||
|
merged = dict(probe_result, link_name=" & ".join(all_names))
|
||||||
|
_send_tg_live_notification(row, True, merged, headers)
|
||||||
|
|
||||||
|
_the_start_timer = threading.Timer(TG_START_MERGE_SECS, _fire_start)
|
||||||
|
_the_start_timer.daemon = True
|
||||||
|
_pending_start_timers[stream_id] = (_the_start_timer, all_names)
|
||||||
|
_the_start_timer.start()
|
||||||
|
else:
|
||||||
|
with _pending_start_lock:
|
||||||
|
start_entry = _pending_start_timers.pop(stream_id, None)
|
||||||
|
if start_entry:
|
||||||
|
start_entry[0].cancel()
|
||||||
|
|
||||||
|
if not previous_is_live:
|
||||||
|
return
|
||||||
|
|
||||||
|
if TG_RECONNECT_GRACE_SECS <= 0:
|
||||||
|
_send_tg_live_notification(row, False, probe_result, headers)
|
||||||
|
return
|
||||||
|
|
||||||
|
def _delayed_stop() -> None:
|
||||||
|
with _pending_stop_lock:
|
||||||
|
if _pending_stop_timers.get(stream_id) is not _the_timer:
|
||||||
|
return
|
||||||
|
del _pending_stop_timers[stream_id]
|
||||||
|
try:
|
||||||
|
with db() as conn:
|
||||||
|
state = conn.execute(
|
||||||
|
"SELECT is_live FROM stream_probe_states WHERE stream_id = ?", (stream_id,)
|
||||||
|
).fetchone()
|
||||||
|
if state and bool(state["is_live"]):
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_send_tg_live_notification(row, False, probe_result, headers)
|
||||||
|
|
||||||
|
_the_timer = threading.Timer(TG_RECONNECT_GRACE_SECS, _delayed_stop)
|
||||||
|
_the_timer.daemon = True
|
||||||
|
with _pending_stop_lock:
|
||||||
|
old = _pending_stop_timers.pop(stream_id, None)
|
||||||
|
if old is not None:
|
||||||
|
old.cancel()
|
||||||
|
_pending_stop_timers[stream_id] = _the_timer
|
||||||
|
_the_timer.start()
|
||||||
|
|
||||||
|
|
||||||
def notify_current_live_if_needed(stream_id: int, headers: object | None = None) -> None:
|
def notify_current_live_if_needed(stream_id: int, headers: object | None = None) -> None:
|
||||||
@@ -1562,11 +1809,25 @@ def notify_current_live_if_needed(stream_id: int, headers: object | None = None)
|
|||||||
label = normalize_stream_label(row.get("stream_label", "LIVE")).lower()
|
label = normalize_stream_label(row.get("stream_label", "LIVE")).lower()
|
||||||
if settings.get(f"telegram_{label}_notify_start", "0") != "1":
|
if settings.get(f"telegram_{label}_notify_start", "0") != "1":
|
||||||
return
|
return
|
||||||
|
live_names = result.get("all_live_names", [result["link_name"]] if result.get("link_name") else [])
|
||||||
|
link_name = " & ".join(live_names) if live_names else result.get("link_name", "")
|
||||||
text = render_message_template(
|
text = render_message_template(
|
||||||
settings.get(f"telegram_{label}_start_template", ""),
|
settings.get(f"telegram_{label}_start_template", ""),
|
||||||
notification_context_for_row(row, result, "start", headers, settings),
|
notification_context_for_row(row, dict(result, link_name=link_name), "start", headers, settings),
|
||||||
)
|
)
|
||||||
|
# Cancel any start timer the monitor may have already scheduled,
|
||||||
|
# so it doesn't fire a merged notification that re-includes these links.
|
||||||
|
with _pending_start_lock:
|
||||||
|
entry = _pending_start_timers.pop(stream_id, None)
|
||||||
|
if entry:
|
||||||
|
entry[0].cancel()
|
||||||
send_telegram_message(settings, text)
|
send_telegram_message(settings, text)
|
||||||
|
# Mark these links as already-notified so the monitor won't re-send.
|
||||||
|
with db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE stream_probe_states SET live_links_json = ? WHERE stream_id = ?",
|
||||||
|
(json.dumps(live_names), stream_id),
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"Telegram current live notification failed for stream {stream_id}: {exc}")
|
print(f"Telegram current live notification failed for stream {stream_id}: {exc}")
|
||||||
|
|
||||||
@@ -1613,14 +1874,14 @@ def rewrite_hls_manifest(manifest: str, slug: str, stream_key: str) -> str:
|
|||||||
return "\n".join(rewritten) + ("\n" if manifest.endswith("\n") else "")
|
return "\n".join(rewritten) + ("\n" if manifest.endswith("\n") else "")
|
||||||
|
|
||||||
|
|
||||||
def rewrite_external_hls_manifest(manifest: str, base_url: str) -> str:
|
def rewrite_external_hls_manifest(manifest: str, base_url: str, upstream_cookie: str = "") -> str:
|
||||||
# Rewrite all URLs in an external HLS manifest (segment lines and URI="..."
|
# Rewrite all URLs in an external HLS manifest (segment lines and URI="..."
|
||||||
# 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:", "skd:")):
|
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), upstream_cookie)
|
||||||
|
|
||||||
rewritten: list[str] = []
|
rewritten: list[str] = []
|
||||||
for line in manifest.splitlines():
|
for line in manifest.splitlines():
|
||||||
@@ -1635,15 +1896,42 @@ def rewrite_external_hls_manifest(manifest: str, base_url: str) -> str:
|
|||||||
return "\n".join(rewritten) + ("\n" if manifest.endswith("\n") else "")
|
return "\n".join(rewritten) + ("\n" if manifest.endswith("\n") else "")
|
||||||
|
|
||||||
|
|
||||||
|
def rewrite_external_hls_manifest_direct(manifest: str, base_url: str) -> str:
|
||||||
|
# Manifest-only proxy: expose the manifest through StreamHall, but rewrite
|
||||||
|
# relative media/key/map URLs to absolute upstream URLs so segment traffic
|
||||||
|
# goes directly from the viewer to the source server.
|
||||||
|
def absolute_uri(value: str) -> str:
|
||||||
|
if not value or value.startswith(("data:", "skd:")):
|
||||||
|
return value
|
||||||
|
return urljoin(base_url, value)
|
||||||
|
|
||||||
|
rewritten: list[str] = []
|
||||||
|
for line in manifest.splitlines():
|
||||||
|
text = line.strip()
|
||||||
|
if not text:
|
||||||
|
rewritten.append(line)
|
||||||
|
continue
|
||||||
|
if text.startswith("#"):
|
||||||
|
rewritten.append(HLS_URI_RE.sub(lambda match: f'URI="{absolute_uri(match.group(1))}"', line))
|
||||||
|
continue
|
||||||
|
rewritten.append(absolute_uri(text))
|
||||||
|
return "\n".join(rewritten) + ("\n" if manifest.endswith("\n") else "")
|
||||||
|
|
||||||
|
|
||||||
def monitor_streams_loop() -> None:
|
def monitor_streams_loop() -> None:
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
rows = conn.execute("SELECT * FROM streams ORDER BY id DESC").fetchall()
|
rows = conn.execute("SELECT * FROM streams ORDER BY id DESC").fetchall()
|
||||||
for row in rows:
|
|
||||||
check_stream_row_live(row)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"Stream monitor failed: {exc}")
|
print(f"Stream monitor: failed to fetch streams: {exc}", flush=True)
|
||||||
|
time.sleep(STREAM_MONITOR_INTERVAL)
|
||||||
|
continue
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
check_stream_row_live(row)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"Stream monitor: error on stream {row.get('id')}: {exc}", flush=True)
|
||||||
time.sleep(STREAM_MONITOR_INTERVAL)
|
time.sleep(STREAM_MONITOR_INTERVAL)
|
||||||
|
|
||||||
|
|
||||||
@@ -1651,7 +1939,13 @@ class StreamHallHandler(BaseHTTPRequestHandler):
|
|||||||
server_version = "StreamHall/1.0"
|
server_version = "StreamHall/1.0"
|
||||||
|
|
||||||
def log_message(self, fmt: str, *args: object) -> None:
|
def log_message(self, fmt: str, *args: object) -> None:
|
||||||
print(f"{self.address_string()} - {fmt % args}")
|
raw_path = self.path if hasattr(self, "path") else ""
|
||||||
|
path = raw_path.split("?", 1)[0]
|
||||||
|
if path.startswith(("/h/", "/proxy/hls/", "/proxy/hls-manifest/")):
|
||||||
|
return
|
||||||
|
if any(k in raw_path for k in ("viewer_heartbeat", "check_player_stream", "check_stream", "stream_stats_summary")):
|
||||||
|
return
|
||||||
|
print(f"{self.address_string()} - {fmt % args}", flush=True)
|
||||||
|
|
||||||
def do_GET(self) -> None:
|
def do_GET(self) -> None:
|
||||||
parsed = urlparse(self.path)
|
parsed = urlparse(self.path)
|
||||||
@@ -1661,6 +1955,9 @@ class StreamHallHandler(BaseHTTPRequestHandler):
|
|||||||
if parsed.path.startswith("/h/"):
|
if parsed.path.startswith("/h/"):
|
||||||
self.proxy_obs_route(parsed.path, parsed.query, send_body=True)
|
self.proxy_obs_route(parsed.path, parsed.query, send_body=True)
|
||||||
return
|
return
|
||||||
|
if parsed.path.startswith(f"{HLS_MANIFEST_PROXY_PREFIX}/"):
|
||||||
|
self.proxy_hls_manifest_route(parsed.path, send_body=True)
|
||||||
|
return
|
||||||
if parsed.path.startswith(f"{HLS_PROXY_PREFIX}/"):
|
if parsed.path.startswith(f"{HLS_PROXY_PREFIX}/"):
|
||||||
self.proxy_hls_route(parsed.path, send_body=True)
|
self.proxy_hls_route(parsed.path, send_body=True)
|
||||||
return
|
return
|
||||||
@@ -1677,6 +1974,9 @@ class StreamHallHandler(BaseHTTPRequestHandler):
|
|||||||
if parsed.path.startswith("/h/"):
|
if parsed.path.startswith("/h/"):
|
||||||
self.proxy_obs_route(parsed.path, parsed.query, send_body=False)
|
self.proxy_obs_route(parsed.path, parsed.query, send_body=False)
|
||||||
return
|
return
|
||||||
|
if parsed.path.startswith(f"{HLS_MANIFEST_PROXY_PREFIX}/"):
|
||||||
|
self.proxy_hls_manifest_route(parsed.path, send_body=False)
|
||||||
|
return
|
||||||
if parsed.path.startswith(f"{HLS_PROXY_PREFIX}/"):
|
if parsed.path.startswith(f"{HLS_PROXY_PREFIX}/"):
|
||||||
self.proxy_hls_route(parsed.path, send_body=False)
|
self.proxy_hls_route(parsed.path, send_body=False)
|
||||||
return
|
return
|
||||||
@@ -2451,12 +2751,18 @@ class StreamHallHandler(BaseHTTPRequestHandler):
|
|||||||
if cur.rowcount == 0:
|
if cur.rowcount == 0:
|
||||||
raise AppError("stream_not_found")
|
raise AppError("stream_not_found")
|
||||||
if enabled and row:
|
if enabled and row:
|
||||||
headers = dict(self.headers)
|
# Reset live_links_json and cancel any pending start timer so the monitor's
|
||||||
threading.Thread(
|
# next cycle treats all live links as newly-live via the merge window.
|
||||||
target=notify_current_live_if_needed,
|
# Using a single notification path eliminates duplicate-send races.
|
||||||
args=(stream_id, headers),
|
with db() as conn:
|
||||||
daemon=True,
|
conn.execute(
|
||||||
).start()
|
"UPDATE stream_probe_states SET live_links_json = '[]' WHERE stream_id = ?",
|
||||||
|
(stream_id,),
|
||||||
|
)
|
||||||
|
with _pending_start_lock:
|
||||||
|
entry = _pending_start_timers.pop(stream_id, None)
|
||||||
|
if entry:
|
||||||
|
entry[0].cancel()
|
||||||
self.send_json({"status": "success", "enabled": enabled})
|
self.send_json({"status": "success", "enabled": enabled})
|
||||||
|
|
||||||
def api_reorder_streams(self) -> None:
|
def api_reorder_streams(self) -> None:
|
||||||
@@ -2526,7 +2832,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", ""), body.get("drmConfigs", body.get("drm_configs", [])))
|
result = probe_stream_url(
|
||||||
|
body.get("url", ""),
|
||||||
|
body.get("type", ""),
|
||||||
|
body.get("drmConfigs", body.get("drm_configs", [])),
|
||||||
|
str(body.get("upstreamCookie", body.get("upstream_cookie", "")) or "").strip(),
|
||||||
|
)
|
||||||
self.send_json({"status": "success", "data": result})
|
self.send_json({"status": "success", "data": result})
|
||||||
|
|
||||||
def api_discover_drm(self) -> None:
|
def api_discover_drm(self) -> None:
|
||||||
@@ -3188,7 +3499,7 @@ class StreamHallHandler(BaseHTTPRequestHandler):
|
|||||||
upstream_url = f"{SRS_HTTP_ORIGIN}{upstream_path}{query}"
|
upstream_url = f"{SRS_HTTP_ORIGIN}{upstream_path}{query}"
|
||||||
try:
|
try:
|
||||||
req = Request(upstream_url, headers={"User-Agent": "StreamHall/1.0"})
|
req = Request(upstream_url, headers={"User-Agent": "StreamHall/1.0"})
|
||||||
opener = urlopen(req, timeout=STREAM_PROBE_TIMEOUT) if rewrite_manifest else urlopen(req)
|
opener = urlopen(req, timeout=HLS_PROXY_TIMEOUT) if rewrite_manifest else urlopen(req)
|
||||||
with opener as resp:
|
with opener as resp:
|
||||||
if rewrite_manifest:
|
if rewrite_manifest:
|
||||||
body = resp.read(1024 * 1024)
|
body = resp.read(1024 * 1024)
|
||||||
@@ -3222,11 +3533,119 @@ class StreamHallHandler(BaseHTTPRequestHandler):
|
|||||||
self.send_error(HTTPStatus.BAD_GATEWAY)
|
self.send_error(HTTPStatus.BAD_GATEWAY)
|
||||||
|
|
||||||
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>[/<base64-encoded-cookie-ref>]
|
||||||
# The token is an HMAC of the full target URL; it ensures that only URLs
|
# When a cookie is needed, the last segment is a server-issued opaque token reference
|
||||||
# generated by hls_proxy_path() can be proxied, preventing open-proxy abuse.
|
# (token_id.hmac_sig encoded as base64). The actual cookie is stored server-side only
|
||||||
|
# and never appears in the URL, preventing extraction from browser network logs.
|
||||||
parts = request_path.strip("/").split("/")
|
parts = request_path.strip("/").split("/")
|
||||||
if len(parts) != 4 or parts[0] != "proxy" or parts[1] != "hls":
|
if parts[:2] != ["proxy", "hls"]:
|
||||||
|
self.send_error(HTTPStatus.NOT_FOUND)
|
||||||
|
return
|
||||||
|
if len(parts) == 4:
|
||||||
|
token, encoded_url = parts[2], parts[3]
|
||||||
|
cookie_ref = ""
|
||||||
|
elif len(parts) == 5:
|
||||||
|
token, encoded_url, encoded_ref = parts[2], parts[3], parts[4]
|
||||||
|
try:
|
||||||
|
padded = encoded_ref + "=" * (-len(encoded_ref) % 4)
|
||||||
|
cookie_ref = base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8")
|
||||||
|
except Exception:
|
||||||
|
self.send_error(HTTPStatus.BAD_REQUEST)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
self.send_error(HTTPStatus.NOT_FOUND)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
target_url = decode_proxy_target(encoded_url)
|
||||||
|
except (ValueError, UnicodeDecodeError):
|
||||||
|
self.send_error(HTTPStatus.BAD_REQUEST)
|
||||||
|
return
|
||||||
|
parsed = urlparse(target_url)
|
||||||
|
if parsed.scheme not in ("http", "https"):
|
||||||
|
self.send_error(HTTPStatus.BAD_REQUEST)
|
||||||
|
return
|
||||||
|
if not hmac.compare_digest(token, hls_proxy_url_token(target_url, cookie_ref)):
|
||||||
|
self.send_error(HTTPStatus.FORBIDDEN)
|
||||||
|
return
|
||||||
|
upstream_cookie = ""
|
||||||
|
if cookie_ref:
|
||||||
|
upstream_cookie = _resolve_proxy_cookie_token(cookie_ref)
|
||||||
|
if not upstream_cookie:
|
||||||
|
# Token not found — server may have restarted; player page needs reload.
|
||||||
|
self.send_error(HTTPStatus.FORBIDDEN)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
reject_private_http_url(target_url)
|
||||||
|
except AppError:
|
||||||
|
self.send_error(HTTPStatus.FORBIDDEN)
|
||||||
|
return
|
||||||
|
|
||||||
|
upstream_headers: dict[str, str] = {"User-Agent": "StreamHall/1.0"}
|
||||||
|
if upstream_cookie:
|
||||||
|
upstream_headers["Cookie"] = upstream_cookie
|
||||||
|
try:
|
||||||
|
resp, conn, _scheme, _host, _port = _upstream_fetch(target_url, upstream_headers, HLS_PROXY_TIMEOUT)
|
||||||
|
except (http.client.HTTPException, OSError, TimeoutError):
|
||||||
|
self.send_error(HTTPStatus.BAD_GATEWAY)
|
||||||
|
return
|
||||||
|
|
||||||
|
if resp.status >= 400:
|
||||||
|
resp.read()
|
||||||
|
_upstream_conn_return(_scheme, _host, _port, conn)
|
||||||
|
self.send_error(HTTPStatus(resp.status) if resp.status in HTTPStatus._value2member_map_ else HTTPStatus.BAD_GATEWAY)
|
||||||
|
return
|
||||||
|
|
||||||
|
content_type = resp.getheader("Content-Type") or mimetypes.guess_type(parsed.path)[0] or "application/octet-stream"
|
||||||
|
is_manifest = parsed.path.lower().endswith(".m3u8") or "mpegurl" in content_type.lower()
|
||||||
|
|
||||||
|
if is_manifest:
|
||||||
|
body = resp.read(2 * 1024 * 1024)
|
||||||
|
_upstream_conn_return(_scheme, _host, _port, conn)
|
||||||
|
content = rewrite_external_hls_manifest(decode_probe_text(body), target_url, upstream_cookie).encode("utf-8")
|
||||||
|
self.send_response(HTTPStatus.OK)
|
||||||
|
self.send_header("Content-Type", "application/vnd.apple.mpegurl; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(content)))
|
||||||
|
self.send_header("Cache-Control", "no-cache")
|
||||||
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
self.end_headers()
|
||||||
|
if send_body:
|
||||||
|
self.wfile.write(content)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.send_response(HTTPStatus.OK)
|
||||||
|
self.send_header("Content-Type", content_type)
|
||||||
|
self.send_header("Cache-Control", "no-cache")
|
||||||
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
self.end_headers()
|
||||||
|
if not send_body:
|
||||||
|
resp.read()
|
||||||
|
_upstream_conn_return(_scheme, _host, _port, conn)
|
||||||
|
return
|
||||||
|
conn_returned = False
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
chunk = resp.read(65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
self.wfile.write(chunk)
|
||||||
|
_upstream_conn_return(_scheme, _host, _port, conn)
|
||||||
|
conn_returned = True
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
if not conn_returned:
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def proxy_hls_manifest_route(self, request_path: str, send_body: bool = True) -> None:
|
||||||
|
# URL format: /proxy/hls-manifest/<token>/<base64-encoded-url>
|
||||||
|
# This route only proxies the playlist itself. Segment/key/map URLs are
|
||||||
|
# rewritten to absolute upstream URLs, so media bandwidth does not pass
|
||||||
|
# through StreamHall.
|
||||||
|
parts = request_path.strip("/").split("/")
|
||||||
|
if len(parts) != 4 or parts[0] != "proxy" or parts[1] != "hls-manifest":
|
||||||
self.send_error(HTTPStatus.NOT_FOUND)
|
self.send_error(HTTPStatus.NOT_FOUND)
|
||||||
return
|
return
|
||||||
token, encoded_url = parts[2], parts[3]
|
token, encoded_url = parts[2], parts[3]
|
||||||
@@ -3242,37 +3661,31 @@ class StreamHallHandler(BaseHTTPRequestHandler):
|
|||||||
if not hmac.compare_digest(token, hls_proxy_url_token(target_url)):
|
if not hmac.compare_digest(token, hls_proxy_url_token(target_url)):
|
||||||
self.send_error(HTTPStatus.FORBIDDEN)
|
self.send_error(HTTPStatus.FORBIDDEN)
|
||||||
return
|
return
|
||||||
|
try:
|
||||||
|
reject_private_http_url(target_url)
|
||||||
|
except AppError:
|
||||||
|
self.send_error(HTTPStatus.FORBIDDEN)
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
req = Request(target_url, headers={"User-Agent": "StreamHall/1.0"})
|
req = Request(target_url, headers={"User-Agent": "StreamHall/1.0"})
|
||||||
with urlopen(req, timeout=STREAM_PROBE_TIMEOUT if parsed.path.lower().endswith(".m3u8") else 20) as resp:
|
with urlopen(req, timeout=STREAM_PROBE_TIMEOUT) as resp:
|
||||||
content_type = resp.headers.get("Content-Type") or mimetypes.guess_type(parsed.path)[0] or "application/octet-stream"
|
content_type = resp.headers.get("Content-Type") or mimetypes.guess_type(parsed.path)[0] or "application/octet-stream"
|
||||||
is_manifest = parsed.path.lower().endswith(".m3u8") or "mpegurl" in content_type.lower()
|
body = resp.read(2 * 1024 * 1024)
|
||||||
if is_manifest:
|
text = decode_probe_text(body)
|
||||||
body = resp.read(2 * 1024 * 1024)
|
is_manifest = parsed.path.lower().endswith(".m3u8") or "mpegurl" in content_type.lower() or text.lstrip().startswith("#EXTM3U")
|
||||||
content = rewrite_external_hls_manifest(decode_probe_text(body), target_url).encode("utf-8")
|
if not is_manifest:
|
||||||
self.send_response(HTTPStatus.OK)
|
self.send_error(HTTPStatus.BAD_REQUEST)
|
||||||
self.send_header("Content-Type", "application/vnd.apple.mpegurl; charset=utf-8")
|
|
||||||
self.send_header("Content-Length", str(len(content)))
|
|
||||||
self.send_header("Cache-Control", "no-cache")
|
|
||||||
self.send_header("Access-Control-Allow-Origin", "*")
|
|
||||||
self.end_headers()
|
|
||||||
if send_body:
|
|
||||||
self.wfile.write(content)
|
|
||||||
return
|
return
|
||||||
|
content = rewrite_external_hls_manifest_direct(text, target_url).encode("utf-8")
|
||||||
self.send_response(HTTPStatus.OK)
|
self.send_response(HTTPStatus.OK)
|
||||||
self.send_header("Content-Type", content_type)
|
self.send_header("Content-Type", "application/vnd.apple.mpegurl; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(content)))
|
||||||
self.send_header("Cache-Control", "no-cache")
|
self.send_header("Cache-Control", "no-cache")
|
||||||
self.send_header("Access-Control-Allow-Origin", "*")
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
if not send_body:
|
if send_body:
|
||||||
return
|
self.wfile.write(content)
|
||||||
while True:
|
|
||||||
chunk = resp.read(65536)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
self.wfile.write(chunk)
|
|
||||||
except HTTPError as exc:
|
except HTTPError as exc:
|
||||||
self.send_error(HTTPStatus(exc.code) if exc.code in HTTPStatus._value2member_map_ else HTTPStatus.BAD_GATEWAY)
|
self.send_error(HTTPStatus(exc.code) if exc.code in HTTPStatus._value2member_map_ else HTTPStatus.BAD_GATEWAY)
|
||||||
except (URLError, TimeoutError, OSError):
|
except (URLError, TimeoutError, OSError):
|
||||||
|
|||||||
Reference in New Issue
Block a user