3 Commits

Author SHA1 Message Date
Stardream c4c3e6f445 fix: instant cookie probe, default Auto quality, consistent control order
Build and Push Docker Image / build (push) Successful in 17s
- Trigger a stream probe immediately when the upstream cookie field changes,
  instead of waiting for the next monitor cycle
- Remove the probeActive guard in checkLinkRow so a cookie-triggered probe is no
  longer dropped when it lands while the URL probe is still in flight (the
  probeToken check already handles superseded results)
- Default the HLS resolution menu to its "Auto" (ABR) entry rather than landing
  on a fixed (lowest) rung; start hls.js in auto level mode
- Keep source (视角) selector on the left and resolution on the right in both
  live and archive players, which previously rendered in opposite order
2026-06-02 00:59:55 +10:00
Stardream 42ce5d2684 docs: document upstream cookie, HLS proxy timeout, and TG notification options
Build and Push Docker Image / build (push) Successful in 18s
2026-05-31 01:30:14 +10:00
Stardream 6d39c512d7 feat: upstream cookie proxy, HLS connection pool, multi-link TG notifications
Build and Push Docker Image / build (push) Successful in 15s
- Add upstream Cookie support for HLS full-proxy mode (CloudFront signed cookies
  stored server-side as opaque tokens; never exposed in proxy URLs)
- Add HTTP connection pool for HLS proxy upstream requests to avoid per-request
  TLS handshake overhead; introduce HLS_PROXY_TIMEOUT separate from probe timeout
- Add per-link TG start notification with 30s merge window: each newly-live link
  fires independently, links that come online within the window are merged into
  one message with names joined by ' & '
- Fix TG reconnect grace period (TG_RECONNECT_GRACE_SECS=60): suppress both
  stop and start notifications for brief RTMP disconnects
- Fix stream probe to check all links for TG-enabled streams; non-TG streams
  still stop at first valid link to avoid unnecessary probes
- Filter high-frequency HTTP access log entries (HLS segments, heartbeat, etc.)
- Add json-file logging driver config to docker-compose for reliable log access
2026-05-31 01:16:48 +10:00
6 changed files with 527 additions and 112 deletions
+7 -2
View File
@@ -31,10 +31,10 @@
- **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 with per-source labels and proxy mode selection - **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 modes** - Per-source direct, full proxy, or manifest-only proxy modes for balancing source URL exposure, CORS compatibility, and server bandwidth - **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, source editor, 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
@@ -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` |
@@ -194,6 +197,8 @@ Each stream source can choose how external HLS URLs are exposed to viewers:
| `Full proxy` | Manifest, segments, maps, and keys are routed through `/proxy/hls/` | StreamHall carries all HLS media traffic | | `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 | | `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)
+7 -2
View File
@@ -31,10 +31,10 @@
- **播放器** - 基于 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 代理模式** - 每个播放源可选择直连、完整代理或仅代理 Manifest,在源地址暴露、跨域兼容和服务器带宽之间自行取舍 - **HLS 代理模式** - 每个播放源可选择直连、完整代理或仅代理 Manifest,在源地址暴露、跨域兼容和服务器带宽之间自行取舍;完整代理模式支持上游 Cookie 转发,可对接依赖 Cookie 鉴权的 CDN(如 CloudFront 签名 Cookie),Cookie 仅存于服务端、不会暴露在播放地址中
- **API 密钥鉴权** - 在后台生成 Token,可通过 API 密钥对所有管理及统计接口进行程序化访问 - **API 密钥鉴权** - 在后台生成 Token,可通过 API 密钥对所有管理及统计接口进行程序化访问
- **移动端适配** - 管理后台侧边栏、视角编辑器、文件浏览器行、推流目录侧边栏均可在窄屏设备上自适应折叠 - **移动端适配** - 管理后台侧边栏、视角编辑器、文件浏览器行、推流目录侧边栏均可在窄屏设备上自适应折叠
@@ -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` |
@@ -194,6 +197,8 @@ volumes:
| `完整代理` | manifest、分片、map、key 都通过 `/proxy/hls/` | StreamHall 承担全部 HLS 媒体流量 | | `完整代理` | manifest、分片、map、key 都通过 `/proxy/hls/` | StreamHall 承担全部 HLS 媒体流量 |
| `仅 Manifest` | 只有播放列表经过 StreamHall;分片、key、map 改写为源站绝对地址 | StreamHall 带宽较低;最终媒体 URL 仍会出现在浏览器网络请求中 | | `仅 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)
+5
View File
@@ -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
+86 -16
View File
@@ -452,7 +452,8 @@
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;
@@ -460,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;
@@ -473,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;
} }
@@ -487,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;
@@ -604,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);
@@ -614,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));
@@ -1047,6 +1081,7 @@
} }
.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;
} }
@@ -2042,6 +2077,7 @@
'form.link_name': '视角名称', 'form.link_name': '视角名称',
'form.link_type': '类型', 'form.link_type': '类型',
'form.proxy_mode': '代理模式', 'form.proxy_mode': '代理模式',
'form.upstream_cookie': '上游 Cookie',
'form.link_url': '播放链接', 'form.link_url': '播放链接',
'form.key_override':'Key Override', 'form.key_override':'Key Override',
'form.clearkey': 'ClearKey 信息', 'form.clearkey': 'ClearKey 信息',
@@ -2101,6 +2137,7 @@
'ph.link_name': '视角名', 'ph.link_name': '视角名',
'ph.link_url': '链接 (m3u8/flv/mpd)', 'ph.link_url': '链接 (m3u8/flv/mpd)',
'ph.proxy_mode': '代理模式', '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"}',
@@ -2156,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 配置',
@@ -2463,6 +2501,7 @@
'form.link_name': 'Name', 'form.link_name': 'Name',
'form.link_type': 'Type', 'form.link_type': 'Type',
'form.proxy_mode': 'Proxy mode', 'form.proxy_mode': 'Proxy mode',
'form.upstream_cookie': 'Upstream Cookie',
'form.link_url': 'Playback URL', 'form.link_url': 'Playback URL',
'form.key_override':'Key Override', 'form.key_override':'Key Override',
'form.clearkey': 'ClearKey', 'form.clearkey': 'ClearKey',
@@ -2522,6 +2561,7 @@
'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.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"}',
@@ -2577,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',
@@ -3101,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) {
@@ -3123,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) {
@@ -3156,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')))
); );
}; };
@@ -3172,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'));
} }
@@ -3194,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'));
} }
@@ -4207,6 +4268,7 @@
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', 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(),
@@ -4323,7 +4385,7 @@
handle.addEventListener('pointercancel', finish); handle.addEventListener('pointercancel', finish);
}; };
const addLinkUI = (name = 'Default', url = '', key = '', clearkey = '', type = '', drmType = '', licenseUrl = '', licenseHeaders = '', pssh = '', certificateUrl = '', drmConfigs = [], proxyMode = 'auto') => { 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 normalizedProxyMode = ['auto', 'direct', 'full', 'manifest'].includes(String(proxyMode || '').toLowerCase()) ? String(proxyMode || '').toLowerCase() : 'auto';
@@ -4378,6 +4440,12 @@
<div class="link-field-label">${t('form.clearkey')}</div> <div class="link-field-label">${t('form.clearkey')}</div>
<textarea class="l-clearkey" rows="2" placeholder="${t('ph.clearkey')}">${escapeHtml(clearkey)}</textarea> <textarea class="l-clearkey" rows="2" placeholder="${t('ph.clearkey')}">${escapeHtml(clearkey)}</textarea>
</div> </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>
@@ -4595,6 +4663,8 @@
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-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-up')?.addEventListener('click', () => moveLinkRowStep(div, -1));
div.querySelector('.link-move-down')?.addEventListener('click', () => moveLinkRowStep(div, 1)); div.querySelector('.link-move-down')?.addEventListener('click', () => moveLinkRowStep(div, 1));
@@ -4754,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 || [], l.proxyMode || l.proxy_mode || 'auto')); 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();
+26
View File
@@ -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);
} }
}); });
+396 -92
View File
@@ -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,6 +96,18 @@ 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" HLS_MANIFEST_PROXY_PREFIX = "/proxy/hls-manifest"
VIEWER_TOKEN_TTL = 300 # seconds VIEWER_TOKEN_TTL = 300 # seconds
@@ -336,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 '[]'")
@@ -425,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}")
@@ -453,8 +568,13 @@ 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}"
@@ -477,7 +597,8 @@ def playback_url_for_mode(url: str, link: dict[str, object], proxy_mode: str) ->
return url return url
if mode == "manifest": if mode == "manifest":
return hls_manifest_proxy_path(url) return hls_manifest_proxy_path(url)
return hls_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"})
@@ -540,6 +661,8 @@ def add_playback_urls(links: list[dict[str, object]]) -> list[dict[str, object]]
config_item["playback_url"] = playback_url_for_mode(drm_playback_url, probe_item, proxy_mode) config_item["playback_url"] = playback_url_for_mode(drm_playback_url, probe_item, proxy_mode)
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
@@ -672,6 +795,7 @@ def normalize_links(raw: object) -> list[dict[str, object]]:
"type": link_type, "type": link_type,
"url": url, "url": url,
"proxyMode": proxy_mode, "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,
@@ -1145,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:
@@ -1166,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"
@@ -1468,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)
@@ -1518,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,
@@ -1525,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:
@@ -1586,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}")
@@ -1637,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():
@@ -1686,10 +1923,15 @@ def monitor_streams_loop() -> None:
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)
@@ -1697,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)
@@ -2503,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:
@@ -2578,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:
@@ -3240,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)
@@ -3274,14 +3533,28 @@ 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) self.send_error(HTTPStatus.NOT_FOUND)
return return
token, encoded_url = parts[2], parts[3]
try: try:
target_url = decode_proxy_target(encoded_url) target_url = decode_proxy_target(encoded_url)
except (ValueError, UnicodeDecodeError): except (ValueError, UnicodeDecodeError):
@@ -3291,49 +3564,80 @@ class StreamHallHandler(BaseHTTPRequestHandler):
if parsed.scheme not in ("http", "https"): if parsed.scheme not in ("http", "https"):
self.send_error(HTTPStatus.BAD_REQUEST) self.send_error(HTTPStatus.BAD_REQUEST)
return return
if not hmac.compare_digest(token, hls_proxy_url_token(target_url)): if not hmac.compare_digest(token, hls_proxy_url_token(target_url, cookie_ref)):
self.send_error(HTTPStatus.FORBIDDEN) self.send_error(HTTPStatus.FORBIDDEN)
return 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: try:
reject_private_http_url(target_url) reject_private_http_url(target_url)
except AppError: except AppError:
self.send_error(HTTPStatus.FORBIDDEN) self.send_error(HTTPStatus.FORBIDDEN)
return return
upstream_headers: dict[str, str] = {"User-Agent": "StreamHall/1.0"}
if upstream_cookie:
upstream_headers["Cookie"] = upstream_cookie
try: try:
req = Request(target_url, headers={"User-Agent": "StreamHall/1.0"}) resp, conn, _scheme, _host, _port = _upstream_fetch(target_url, upstream_headers, HLS_PROXY_TIMEOUT)
with urlopen(req, timeout=STREAM_PROBE_TIMEOUT if parsed.path.lower().endswith(".m3u8") else 20) as resp: except (http.client.HTTPException, OSError, TimeoutError):
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()
if is_manifest:
body = resp.read(2 * 1024 * 1024)
content = rewrite_external_hls_manifest(decode_probe_text(body), target_url).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:
return
while True:
chunk = resp.read(65536)
if not chunk:
break
self.wfile.write(chunk)
except HTTPError as exc:
self.send_error(HTTPStatus(exc.code) if exc.code in HTTPStatus._value2member_map_ else HTTPStatus.BAD_GATEWAY)
except (URLError, TimeoutError, OSError):
self.send_error(HTTPStatus.BAD_GATEWAY) 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: def proxy_hls_manifest_route(self, request_path: str, send_body: bool = True) -> None:
# URL format: /proxy/hls-manifest/<token>/<base64-encoded-url> # URL format: /proxy/hls-manifest/<token>/<base64-encoded-url>