Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e1ed10ba5 | |||
| 8f63f20fdf |
@@ -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
|
||||
- **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
|
||||
- **Telegram notifications** - Per-stream push messages on stream start and stop
|
||||
- **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
|
||||
- **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
|
||||
- **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">
|
||||
|
||||
@@ -183,6 +183,17 @@ volumes:
|
||||
- /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 |
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
+14
-3
@@ -29,14 +29,14 @@
|
||||
|
||||
- **公开直播列表** - 直播 / 存档双 Tab,支持密码保护、自定义站点品牌,内置中英双语界面(含分语言站点简介)
|
||||
- **播放器** - 基于 ArtPlayer,支持 HLS、FLV、MPEG-DASH 播放;支持 AES-128 密钥覆盖及 DASH ClearKey;通过 Shaka Player 支持 Widevine 和 FairPlay DRM 播放(每路播放源可独立配置多 DRM 方案,内置 Android Telegram WebView 检测)
|
||||
- **管理后台** - 直播的增删改查、启用/禁用、拖拽排序;多播放源管理
|
||||
- **管理后台** - 直播的增删改查、启用/禁用、拖拽排序;多播放源管理,支持逐字段标签和代理模式选择
|
||||
- **观看统计** - 会话追踪、独立访客数、峰值并发、平均时长、设备 / 浏览器 / 操作系统 / 地理分布实时看板,支持 CSV 导出
|
||||
- **Telegram 推送** - 可按直播单独配置,开播 / 关播自动发送通知
|
||||
- **推流配置** - 内置文件浏览器,支持单文件和文件夹 RTMP 推流管理;文件夹可同时向多个推流码批量推送独立任务;推流状态内联显示于文件行,详情弹窗提供实时时长、复制地址和停止操作;同时支持远端编码器 RTMP 推流配置;隐藏 HLS 路由代理(`/h/<slug>`),真实推流码不出现在公开地址中
|
||||
- **VOD 点播 / 视频服务** - 带 HMAC 签名的 `/video/` URL,支持 HTTP Range 请求(可 seek);文件浏览器中可直接将视频文件或文件夹发布为归档直播
|
||||
- **HLS 代理** - 带签名验证的 `/proxy/hls/` 路由,解决跨域 HLS 播放问题
|
||||
- **HLS 代理模式** - 每个播放源可选择直连、完整代理或仅代理 Manifest,在源地址暴露、跨域兼容和服务器带宽之间自行取舍
|
||||
- **API 密钥鉴权** - 在后台生成 Token,可通过 API 密钥对所有管理及统计接口进行程序化访问
|
||||
- **移动端适配** - 管理后台侧边栏、文件浏览器行、推流目录侧边栏均可在窄屏设备上自适应折叠
|
||||
- **移动端适配** - 管理后台侧边栏、视角编辑器、文件浏览器行、推流目录侧边栏均可在窄屏设备上自适应折叠
|
||||
|
||||
<div align="right">
|
||||
|
||||
@@ -183,6 +183,17 @@ volumes:
|
||||
- /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 仍会出现在浏览器网络请求中 |
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
+626
-63
@@ -383,16 +383,72 @@
|
||||
|
||||
.link-row {
|
||||
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;
|
||||
align-items: start;
|
||||
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 {
|
||||
grid-column: -1;
|
||||
grid-column: 8 / 9;
|
||||
grid-row: 1 / -1;
|
||||
align-self: center;
|
||||
width: 42px;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
@@ -446,6 +502,13 @@
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.link-row.has-drm-playback-url .l-url,
|
||||
.link-row.has-drm-playback-url .l-type {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
background-color: rgba(100, 116, 139, 0.12);
|
||||
}
|
||||
|
||||
.link-drm-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -481,10 +544,17 @@
|
||||
|
||||
.link-drm-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
padding: 0 11px 11px;
|
||||
}
|
||||
|
||||
.drm-discover-btn {
|
||||
padding: 6px 10px;
|
||||
font-size: .82em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-link-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 0.7fr) minmax(180px, 1.5fr) auto;
|
||||
@@ -981,6 +1051,21 @@
|
||||
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 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -1224,8 +1309,12 @@
|
||||
.drag-handle:active { cursor:grabbing; }
|
||||
.stream-row.drag-over { outline:2px solid var(--blue); outline-offset:-2px; border-radius:6px; }
|
||||
.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.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: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; }
|
||||
@@ -1950,6 +2039,12 @@
|
||||
'form.stream_name': '直播名称',
|
||||
'form.stream_pw': '访问密码 (留空则公开)',
|
||||
'form.stream_type': '直播类型',
|
||||
'form.link_name': '视角名称',
|
||||
'form.link_type': '类型',
|
||||
'form.proxy_mode': '代理模式',
|
||||
'form.link_url': '播放链接',
|
||||
'form.key_override':'Key Override',
|
||||
'form.clearkey': 'ClearKey 信息',
|
||||
'form.hide_home': '不在主页显示',
|
||||
// section headings
|
||||
'h3.security': '安全设置',
|
||||
@@ -1981,6 +2076,7 @@
|
||||
'btn.add_stream': '+ 添加',
|
||||
'btn.reset_filter': '清空',
|
||||
'btn.add_link': '+ 添加视角',
|
||||
'btn.discover_drm': '自动识别 DRM',
|
||||
'btn.save': '保 存',
|
||||
'btn.cancel': '取 消',
|
||||
'btn.delete': '删除',
|
||||
@@ -2004,12 +2100,15 @@
|
||||
'ph.nav_url': '#stream-list 或 https://example.com',
|
||||
'ph.link_name': '视角名',
|
||||
'ph.link_url': '链接 (m3u8/flv/mpd)',
|
||||
'ph.proxy_mode': '代理模式',
|
||||
'ph.main_url_disabled':'已使用 DRM 专用播放链接',
|
||||
'ph.key_aes': 'AES-128 Key Hex,可多行: main-video=hex',
|
||||
'ph.clearkey': 'ClearKey 信息,如 {"kid":"key"}',
|
||||
'ph.license_url': 'DRM License URL',
|
||||
'ph.license_url_widevine':'Widevine License URL',
|
||||
'ph.license_url_fairplay':'FairPlay License URL',
|
||||
'ph.certificate_url':'FairPlay Certificate URL',
|
||||
'ph.drm_playback_url':'DRM 专用播放链接 (可选)',
|
||||
'ph.license_headers':'License Headers JSON 或 Header: Value 多行',
|
||||
'ph.pssh': 'PSSH,可选,用于记录或诊断',
|
||||
// hints
|
||||
@@ -2019,7 +2118,6 @@
|
||||
'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_routes': '新增自定义推流码后,系统会生成不可反推的公开 HLS/FLV 地址。推流端仍使用真实推流码推流。',
|
||||
'hint.links': '填写格式:视角名称 | 类型 | 播放链接 | Key Override | ClearKey 信息',
|
||||
'drm.config': 'DRM 播放授权',
|
||||
'drm.none': '无 DRM',
|
||||
'hint.footer_example': '示例:## 联系方式\n- [项目主页](https://example.com)',
|
||||
@@ -2077,6 +2175,14 @@
|
||||
'msg.copied': '已复制',
|
||||
'msg.no_key': '请先填写推流码',
|
||||
'msg.sort_err': '排序保存失败',
|
||||
'msg.drm_discovered':'已识别并填入 DRM 配置',
|
||||
'msg.drm_partial': '已部分识别 DRM 配置,请补全必填项',
|
||||
'msg.drm_not_found': '未在播放链接中识别到可用 DRM 配置',
|
||||
'msg.drm_partial_failed': '部分 DRM 已识别,另有 {count} 个链接识别失败',
|
||||
'msg.drm_discover_failed': 'DRM 自动识别失败',
|
||||
'msg.drm_type_required': '请选择 DRM Method,或清空该 DRM 行',
|
||||
'msg.drm_license_required': 'DRM License URL 为必填项',
|
||||
'msg.drm_cert_required': 'FairPlay Certificate URL 为必填项',
|
||||
// confirm/alert
|
||||
'confirm.delete': '确定删除?',
|
||||
'confirm.delete_route': '确定删除这个推流码映射?',
|
||||
@@ -2086,6 +2192,10 @@
|
||||
'theme.to_light': '切换明亮模式',
|
||||
// type selector
|
||||
'type.auto': '自动',
|
||||
'proxy.auto': '自动',
|
||||
'proxy.direct': '直连',
|
||||
'proxy.full': '完整代理',
|
||||
'proxy.manifest': '仅 Manifest',
|
||||
// OBS link names
|
||||
'obs.local_hls': '本地 HLS',
|
||||
'obs.local_flv': '本地 FLV',
|
||||
@@ -2350,6 +2460,12 @@
|
||||
'form.stream_name': 'Stream Name',
|
||||
'form.stream_pw': 'Password (empty = public)',
|
||||
'form.stream_type': 'Stream Type',
|
||||
'form.link_name': 'Name',
|
||||
'form.link_type': 'Type',
|
||||
'form.proxy_mode': 'Proxy mode',
|
||||
'form.link_url': 'Playback URL',
|
||||
'form.key_override':'Key Override',
|
||||
'form.clearkey': 'ClearKey',
|
||||
'form.hide_home': 'Hide from homepage',
|
||||
// section headings
|
||||
'h3.security': 'Security',
|
||||
@@ -2381,6 +2497,7 @@
|
||||
'btn.add_stream': '+ Add',
|
||||
'btn.reset_filter': 'Clear',
|
||||
'btn.add_link': '+ Add Source',
|
||||
'btn.discover_drm': 'Discover DRM',
|
||||
'btn.save': 'Save',
|
||||
'btn.cancel': 'Cancel',
|
||||
'btn.delete': 'Delete',
|
||||
@@ -2404,12 +2521,15 @@
|
||||
'ph.nav_url': '#stream-list or https://example.com',
|
||||
'ph.link_name': 'Source name',
|
||||
'ph.link_url': 'URL (m3u8/flv/mpd)',
|
||||
'ph.proxy_mode': 'Proxy mode',
|
||||
'ph.main_url_disabled':'Using DRM-specific playback URL',
|
||||
'ph.key_aes': 'AES-128 Key Hex, multi-line: main-video=hex',
|
||||
'ph.clearkey': 'ClearKey JSON, e.g. {"kid":"key"}',
|
||||
'ph.license_url': 'DRM License URL',
|
||||
'ph.license_url_widevine':'Widevine License URL',
|
||||
'ph.license_url_fairplay':'FairPlay License URL',
|
||||
'ph.certificate_url':'FairPlay Certificate URL',
|
||||
'ph.drm_playback_url':'DRM-specific playback URL (optional)',
|
||||
'ph.license_headers':'License Headers JSON or Header: Value lines',
|
||||
'ph.pssh': 'PSSH, optional note for diagnostics',
|
||||
// hints
|
||||
@@ -2419,7 +2539,6 @@
|
||||
'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_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.none': 'No DRM',
|
||||
'hint.footer_example': 'Example: ## Contact\n- [Project page](https://example.com)',
|
||||
@@ -2477,6 +2596,14 @@
|
||||
'msg.copied': 'Copied',
|
||||
'msg.no_key': 'Please fill in stream key first',
|
||||
'msg.sort_err': 'Failed to save order',
|
||||
'msg.drm_discovered':'DRM config detected and filled',
|
||||
'msg.drm_partial': 'DRM config partially detected, please complete required fields',
|
||||
'msg.drm_not_found': 'No supported DRM config was detected in this playback URL',
|
||||
'msg.drm_partial_failed': 'Some DRM configs were detected, but {count} URL(s) failed',
|
||||
'msg.drm_discover_failed': 'DRM discovery failed',
|
||||
'msg.drm_type_required': 'Select a DRM method, or clear this DRM row',
|
||||
'msg.drm_license_required': 'DRM License URL is required',
|
||||
'msg.drm_cert_required': 'FairPlay Certificate URL is required',
|
||||
// confirm/alert
|
||||
'confirm.delete': 'Confirm delete?',
|
||||
'confirm.delete_route': 'Delete this stream key mapping?',
|
||||
@@ -2486,6 +2613,10 @@
|
||||
'theme.to_light': 'Switch to light mode',
|
||||
// type selector
|
||||
'type.auto': 'Auto',
|
||||
'proxy.auto': 'Auto',
|
||||
'proxy.direct': 'Direct',
|
||||
'proxy.full': 'Full proxy',
|
||||
'proxy.manifest': 'Manifest only',
|
||||
// OBS link names
|
||||
'obs.local_hls': 'Local HLS',
|
||||
'obs.local_flv': 'Local FLV',
|
||||
@@ -2931,13 +3062,44 @@
|
||||
if (state !== 'is-checking') el.dataset.probeComplete = '1';
|
||||
};
|
||||
|
||||
const getRowDrmConfigs = (row) => Array.from(row.querySelectorAll('.drm-config-row')).map(drmRow => ({
|
||||
drmType: drmRow.querySelector('.l-drm-type')?.value || '',
|
||||
licenseUrl: drmRow.querySelector('.l-license-url')?.value.trim() || '',
|
||||
certificateUrl: drmRow.querySelector('.l-certificate-url')?.value.trim() || '',
|
||||
licenseHeaders: drmRow.querySelector('.l-license-headers')?.value.trim() || '',
|
||||
pssh: drmRow.querySelector('.l-pssh')?.value.trim() || ''
|
||||
})).filter(config => config.drmType && config.licenseUrl);
|
||||
const readDrmConfig = (drmRow) => {
|
||||
const drmType = drmRow.querySelector('.l-drm-type')?.value || '';
|
||||
const config = {
|
||||
drmType,
|
||||
licenseUrl: drmRow.querySelector('.l-license-url')?.value.trim() || '',
|
||||
licenseHeaders: drmRow.querySelector('.l-license-headers')?.value.trim() || '',
|
||||
playbackUrl: drmRow.querySelector('.l-drm-playback-url')?.value.trim() || '',
|
||||
playbackType: drmRow.querySelector('.l-drm-playback-type')?.value || ''
|
||||
};
|
||||
if (drmType === 'fairplay') {
|
||||
config.certificateUrl = drmRow.querySelector('.l-certificate-url')?.value.trim() || '';
|
||||
}
|
||||
if (drmType === 'widevine') {
|
||||
config.pssh = drmRow.querySelector('.l-pssh')?.value.trim() || '';
|
||||
}
|
||||
return config;
|
||||
};
|
||||
|
||||
const getRowDrmConfigs = (row) => Array.from(row.querySelectorAll('.drm-config-row'))
|
||||
.map(readDrmConfig)
|
||||
.filter(config => config.drmType && config.licenseUrl);
|
||||
|
||||
const getRowDrmPlaybackTargets = (row) => Array.from(row.querySelectorAll('.drm-config-row')).map(drmRow => ({
|
||||
row: drmRow,
|
||||
url: drmRow.querySelector('.l-drm-playback-url')?.value.trim() || '',
|
||||
type: drmRow.querySelector('.l-drm-playback-type')?.value || '',
|
||||
drmType: drmRow.querySelector('.l-drm-type')?.value || ''
|
||||
})).filter(item => item.url);
|
||||
|
||||
const getRowProbeTarget = (row) => {
|
||||
const drmPlayback = getRowDrmPlaybackTargets(row)[0];
|
||||
if (drmPlayback) return { url: drmPlayback.url, type: drmPlayback.type || '', drmRow: drmPlayback.row };
|
||||
return {
|
||||
url: row.querySelector('.l-url')?.value.trim() || '',
|
||||
type: row.querySelector('.l-type')?.value || '',
|
||||
drmRow: null
|
||||
};
|
||||
};
|
||||
|
||||
const probeUrl = async (url, type = '', drmConfigs = []) => {
|
||||
const res = await apiCall('check_stream_url', {
|
||||
@@ -2950,14 +3112,12 @@
|
||||
|
||||
const checkLinkRow = async (row, silent = false) => {
|
||||
if (!row || !row.isConnected || row.dataset.probeActive === '1') return;
|
||||
const input = row.querySelector('.l-url');
|
||||
const statusEl = row.querySelector('.stream-check-status');
|
||||
const url = input?.value.trim() || '';
|
||||
const { url, type } = getRowProbeTarget(row);
|
||||
if (!url) {
|
||||
setProbeStatus(statusEl, '', '');
|
||||
return;
|
||||
}
|
||||
const type = row.querySelector('.l-type')?.value || '';
|
||||
const token = `${Date.now()}-${Math.random()}`;
|
||||
row.dataset.probeToken = token;
|
||||
row.dataset.probeActive = '1';
|
||||
@@ -2981,8 +3141,8 @@
|
||||
|
||||
const scheduleLinkRowCheck = (row, delay = 700) => {
|
||||
window.clearTimeout(linkProbeTimers.get(row));
|
||||
const input = row.querySelector('.l-url');
|
||||
if (!input?.value.trim()) {
|
||||
const target = getRowProbeTarget(row);
|
||||
if (!target.url) {
|
||||
setProbeStatus(row.querySelector('.stream-check-status'), '', '');
|
||||
return;
|
||||
}
|
||||
@@ -2992,7 +3152,7 @@
|
||||
|
||||
const checkFormLinks = () => {
|
||||
Array.from(els.linksContainer.children).forEach(row => {
|
||||
if (row.querySelector('.l-url')?.value.trim()) checkLinkRow(row, true);
|
||||
if (getRowProbeTarget(row).url) checkLinkRow(row, true);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -3825,8 +3985,121 @@
|
||||
function setupDragHandles() {
|
||||
let dragSrc = null;
|
||||
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 => {
|
||||
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 => {
|
||||
if (!_dragFromHandle) { e.preventDefault(); return; }
|
||||
dragSrc = row;
|
||||
@@ -3854,19 +4127,7 @@
|
||||
const dstIdx = [...els.list.children].indexOf(row);
|
||||
if (srcIdx < dstIdx) els.list.insertBefore(dragSrc, row.nextSibling);
|
||||
else els.list.insertBefore(dragSrc, row);
|
||||
const label = 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();
|
||||
}
|
||||
await saveOrder(row.dataset.label);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -3893,21 +4154,60 @@
|
||||
});
|
||||
}, true);
|
||||
|
||||
const validateDrmRequiredFields = () => {
|
||||
for (const linkRow of Array.from(els.linksContainer.children)) {
|
||||
for (const drmRow of Array.from(linkRow.querySelectorAll('.drm-config-row'))) {
|
||||
const drmType = drmRow.querySelector('.l-drm-type')?.value || '';
|
||||
const licenseInput = drmRow.querySelector('.l-license-url');
|
||||
const certificateInput = drmRow.querySelector('.l-certificate-url');
|
||||
const values = [
|
||||
drmType,
|
||||
licenseInput?.value.trim() || '',
|
||||
certificateInput?.value.trim() || '',
|
||||
drmRow.querySelector('.l-license-headers')?.value.trim() || '',
|
||||
drmRow.querySelector('.l-pssh')?.value.trim() || '',
|
||||
drmRow.querySelector('.l-drm-playback-url')?.value.trim() || '',
|
||||
drmRow.querySelector('.l-drm-playback-type')?.value || ''
|
||||
];
|
||||
if (!values.some(Boolean)) continue;
|
||||
const sourceName = linkRow.querySelector('.l-name')?.value.trim() || 'Default';
|
||||
const fail = (message, input = null) => ({
|
||||
message: `${sourceName}: ${message}`,
|
||||
input: input || drmRow.querySelector('.l-drm-type'),
|
||||
details: linkRow.querySelector('.link-drm-config')
|
||||
});
|
||||
if (!drmType) return fail(t('msg.drm_type_required') || 'Please select a DRM method');
|
||||
if ((drmType === 'widevine' || drmType === 'fairplay') && !licenseInput?.value.trim()) {
|
||||
return fail(t('msg.drm_license_required') || 'DRM License URL is required', licenseInput);
|
||||
}
|
||||
if (drmType === 'fairplay' && !certificateInput?.value.trim()) {
|
||||
return fail(t('msg.drm_cert_required') || 'FairPlay Certificate URL is required', certificateInput);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
els.form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const drmInvalid = validateDrmRequiredFields();
|
||||
if (drmInvalid) {
|
||||
drmInvalid.details?.setAttribute('open', '');
|
||||
drmInvalid.input?.focus();
|
||||
showToast(drmInvalid.message, 'error');
|
||||
return;
|
||||
}
|
||||
const links = Array.from(els.linksContainer.children).map(row => {
|
||||
const drmConfigs = Array.from(row.querySelectorAll('.drm-config-row')).map(drmRow => ({
|
||||
drmType: drmRow.querySelector('.l-drm-type')?.value || '',
|
||||
licenseUrl: drmRow.querySelector('.l-license-url')?.value.trim() || '',
|
||||
certificateUrl: drmRow.querySelector('.l-certificate-url')?.value.trim() || '',
|
||||
licenseHeaders: drmRow.querySelector('.l-license-headers')?.value.trim() || '',
|
||||
pssh: drmRow.querySelector('.l-pssh')?.value.trim() || ''
|
||||
})).filter(config => config.drmType && config.licenseUrl);
|
||||
const drmConfigs = Array.from(row.querySelectorAll('.drm-config-row'))
|
||||
.map(readDrmConfig)
|
||||
.filter(config => config.drmType && config.licenseUrl);
|
||||
const firstDrm = drmConfigs[0] || {};
|
||||
const fallbackUrl = row.querySelector('.l-url').value.trim() || firstDrm.playbackUrl || '';
|
||||
return {
|
||||
name: row.querySelector('.l-name').value,
|
||||
type: row.querySelector('.l-type').value,
|
||||
url: row.querySelector('.l-url').value,
|
||||
proxyMode: row.querySelector('.l-proxy-mode')?.value || 'auto',
|
||||
url: fallbackUrl,
|
||||
key: row.querySelector('.l-key').value.trim(),
|
||||
clearkey: row.querySelector('.l-clearkey').value.trim(),
|
||||
drmConfigs,
|
||||
@@ -3915,7 +4215,9 @@
|
||||
licenseUrl: firstDrm.licenseUrl || '',
|
||||
certificateUrl: firstDrm.certificateUrl || '',
|
||||
licenseHeaders: firstDrm.licenseHeaders || '',
|
||||
pssh: firstDrm.pssh || ''
|
||||
pssh: firstDrm.pssh || '',
|
||||
playbackUrl: firstDrm.playbackUrl || '',
|
||||
playbackType: firstDrm.playbackType || ''
|
||||
};
|
||||
}).filter(l => l.name && l.url);
|
||||
|
||||
@@ -3939,10 +4241,92 @@
|
||||
|
||||
let _linkDragSrc = null;
|
||||
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') => {
|
||||
const rawDrmType = String(drmType || '').toLowerCase();
|
||||
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
|
||||
? drmConfigs
|
||||
: (normalizedDrmType || licenseUrl || certificateUrl || licenseHeaders || pssh)
|
||||
@@ -3952,31 +4336,71 @@
|
||||
div.className = 'link-row';
|
||||
div.draggable = true;
|
||||
div.innerHTML = `
|
||||
<span class="drag-handle" draggable="false" style="margin-top:6px;">⠿</span>
|
||||
<input class="l-name" placeholder="${t('ph.link_name')}" value="${escapeAttr(name)}">
|
||||
<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 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 class="link-order-controls">
|
||||
<span class="drag-handle" draggable="false">⠿</span>
|
||||
<button type="button" class="btn-secondary link-move-btn link-move-up" title="Move up" aria-label="Move up">↑</button>
|
||||
<button type="button" class="btn-secondary link-move-btn link-move-down" title="Move down" aria-label="Move down">↓</button>
|
||||
</div>
|
||||
<div class="link-field">
|
||||
<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>
|
||||
<textarea class="l-key" rows="2" placeholder="${t('ph.key_aes')}">${escapeHtml(key)}</textarea>
|
||||
<textarea class="l-clearkey" rows="2" placeholder="${t('ph.clearkey')}">${escapeHtml(clearkey)}</textarea>
|
||||
<details class="link-drm-config" ${normalizedDrmConfigs.length ? 'open' : ''}>
|
||||
<summary>${t('drm.config')}</summary>
|
||||
<div class="link-drm-list"></div>
|
||||
<div class="link-drm-actions">
|
||||
<button type="button" class="btn-secondary discover-drm-btn drm-discover-btn">${t('btn.discover_drm')}</button>
|
||||
<button type="button" class="btn-secondary add-drm-config-btn" style="padding:6px 10px;font-size:.82em;">+ DRM</button>
|
||||
</div>
|
||||
</details>
|
||||
<button type="button" class="btn-danger link-remove-btn" onclick="this.parentElement.remove()">×</button>
|
||||
<button type="button" class="btn-danger link-remove-btn">×</button>
|
||||
`;
|
||||
els.linksContainer.appendChild(div);
|
||||
const drmList = div.querySelector('.link-drm-list');
|
||||
const syncMainPlaybackState = () => {
|
||||
const hasDrmPlayback = Array.from(div.querySelectorAll('.l-drm-playback-url')).some(input => input.value.trim());
|
||||
const mainUrl = div.querySelector('.l-url');
|
||||
const mainType = div.querySelector('.l-type');
|
||||
if (mainUrl) {
|
||||
mainUrl.disabled = hasDrmPlayback;
|
||||
mainUrl.placeholder = hasDrmPlayback ? t('ph.main_url_disabled') : t('ph.link_url');
|
||||
}
|
||||
if (mainType) mainType.disabled = hasDrmPlayback;
|
||||
div.classList.toggle('has-drm-playback-url', hasDrmPlayback);
|
||||
};
|
||||
const addDrmConfigUI = (config = {}) => {
|
||||
const drmTypeValue = String(config.drmType || config.drm_type || '').toLowerCase();
|
||||
const selectedType = drmTypeValue === 'fairplay' || drmTypeValue === 'widevine' ? drmTypeValue : '';
|
||||
@@ -3988,9 +4412,16 @@
|
||||
<option value="widevine" ${selectedType === 'widevine' ? 'selected' : ''}>Widevine</option>
|
||||
<option value="fairplay" ${selectedType === 'fairplay' ? 'selected' : ''}>FairPlay</option>
|
||||
</select>
|
||||
<input class="l-license-url drm-field-enabled" placeholder="${t('ph.license_url')}" value="${escapeAttr(config.licenseUrl || config.license_url || '')}">
|
||||
<input class="l-certificate-url drm-field-fairplay" placeholder="${t('ph.certificate_url')}" value="${escapeAttr(config.certificateUrl || config.certificate_url || '')}">
|
||||
<button type="button" class="btn-danger remove-drm-config-btn drm-remove-btn">×</button>
|
||||
<input class="l-drm-playback-url drm-field-playback" placeholder="${t('ph.drm_playback_url')}" value="${escapeAttr(config.playbackUrl || config.playback_url || '')}">
|
||||
<select class="l-drm-playback-type drm-field-playback">
|
||||
<option value="" ${(config.playbackType || config.playback_type || '') === '' ? 'selected' : ''}>${t('type.auto')}</option>
|
||||
<option value="m3u8" ${(config.playbackType || config.playback_type || '') === 'm3u8' ? 'selected' : ''}>HLS</option>
|
||||
<option value="dash" ${(config.playbackType || config.playback_type || '') === 'dash' ? 'selected' : ''}>DASH</option>
|
||||
<option value="flv" ${(config.playbackType || config.playback_type || '') === 'flv' ? 'selected' : ''}>FLV</option>
|
||||
</select>
|
||||
<button type="button" class="btn-danger remove-drm-config-btn drm-remove-btn">x</button>
|
||||
<input class="l-license-url link-drm-wide drm-field-enabled" placeholder="${t('ph.license_url')}" value="${escapeAttr(config.licenseUrl || config.license_url || '')}">
|
||||
<input class="l-certificate-url link-drm-wide drm-field-fairplay" placeholder="${t('ph.certificate_url')}" value="${escapeAttr(config.certificateUrl || config.certificate_url || '')}">
|
||||
<textarea class="l-license-headers link-drm-wide drm-field-enabled" rows="2" placeholder="${t('ph.license_headers')}">${escapeHtml(config.licenseHeaders || config.license_headers || '')}</textarea>
|
||||
<textarea class="l-pssh link-drm-wide drm-field-widevine" rows="2" placeholder="${t('ph.pssh')}">${escapeHtml(config.pssh || '')}</textarea>
|
||||
`;
|
||||
@@ -4009,6 +4440,9 @@
|
||||
row.querySelectorAll('.drm-field-enabled').forEach(el => {
|
||||
el.style.display = type ? '' : 'none';
|
||||
});
|
||||
row.querySelectorAll('.drm-field-playback').forEach(el => {
|
||||
el.style.display = '';
|
||||
});
|
||||
row.querySelectorAll('.drm-field-fairplay').forEach(el => {
|
||||
el.style.display = type === 'fairplay' ? '' : 'none';
|
||||
});
|
||||
@@ -4023,15 +4457,135 @@
|
||||
refreshProbe();
|
||||
});
|
||||
row.querySelectorAll('input, textarea').forEach(input => {
|
||||
input.addEventListener('input', refreshProbe);
|
||||
input.addEventListener('blur', () => scheduleLinkRowCheck(div, 0));
|
||||
input.addEventListener('input', () => {
|
||||
syncMainPlaybackState();
|
||||
refreshProbe();
|
||||
});
|
||||
input.addEventListener('blur', () => {
|
||||
syncMainPlaybackState();
|
||||
scheduleLinkRowCheck(div, 0);
|
||||
});
|
||||
});
|
||||
row.querySelector('.l-drm-playback-type')?.addEventListener('change', refreshProbe);
|
||||
row.querySelector('.remove-drm-config-btn')?.addEventListener('click', () => {
|
||||
row.remove();
|
||||
syncMainPlaybackState();
|
||||
refreshProbe();
|
||||
});
|
||||
return row;
|
||||
};
|
||||
normalizedDrmConfigs.forEach(config => addDrmConfigUI(config));
|
||||
const fillInputIfAvailable = (input, value, overwrite = false) => {
|
||||
if (!input || !value) return;
|
||||
if (overwrite || !input.value.trim()) input.value = value;
|
||||
};
|
||||
const applyDiscoveredDrm = (items, sourceUrl = '', sourceType = '', preferredRow = null) => {
|
||||
const supported = (Array.isArray(items) ? items : [])
|
||||
.filter(item => item && (item.drmType === 'widevine' || item.drmType === 'fairplay'));
|
||||
if (!supported.length) return { applied: 0, partial: false };
|
||||
const mainUrl = div.querySelector('.l-url')?.value.trim() || '';
|
||||
const mainType = div.querySelector('.l-type')?.value || '';
|
||||
let applied = 0;
|
||||
let partial = false;
|
||||
supported.forEach(item => {
|
||||
partial = partial || !!item.partial || !item.licenseUrl || (item.drmType === 'fairplay' && !item.certificateUrl);
|
||||
let target = null;
|
||||
if (preferredRow?.isConnected) {
|
||||
const preferredType = preferredRow.querySelector('.l-drm-type')?.value || '';
|
||||
if (!preferredType || preferredType === item.drmType) target = preferredRow;
|
||||
}
|
||||
if (!target) target = Array.from(drmList.querySelectorAll('.drm-config-row')).find(row => {
|
||||
const rowType = row.querySelector('.l-drm-type')?.value || '';
|
||||
return rowType === item.drmType;
|
||||
});
|
||||
if (!target) target = addDrmConfigUI({ drmType: item.drmType });
|
||||
const typeSelect = target.querySelector('.l-drm-type');
|
||||
if (typeSelect) {
|
||||
typeSelect.value = item.drmType;
|
||||
typeSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
fillInputIfAvailable(target.querySelector('.l-license-url'), item.licenseUrl || '', true);
|
||||
fillInputIfAvailable(target.querySelector('.l-certificate-url'), item.certificateUrl || '', true);
|
||||
fillInputIfAvailable(target.querySelector('.l-pssh'), item.pssh || '', true);
|
||||
const itemPlaybackUrl = item.playbackUrl || sourceUrl || '';
|
||||
if (itemPlaybackUrl && itemPlaybackUrl !== mainUrl) {
|
||||
fillInputIfAvailable(target.querySelector('.l-drm-playback-url'), itemPlaybackUrl, true);
|
||||
}
|
||||
const itemPlaybackType = item.playbackType || sourceType || '';
|
||||
const playbackTypeSelect = target.querySelector('.l-drm-playback-type');
|
||||
if (playbackTypeSelect && itemPlaybackType && itemPlaybackUrl !== mainUrl) {
|
||||
playbackTypeSelect.value = itemPlaybackType;
|
||||
} else if (playbackTypeSelect && mainType && itemPlaybackUrl === mainUrl) {
|
||||
playbackTypeSelect.value = '';
|
||||
}
|
||||
applied += 1;
|
||||
});
|
||||
syncMainPlaybackState();
|
||||
scheduleLinkRowCheck(div, 0);
|
||||
return { applied, partial };
|
||||
};
|
||||
const discoverDrmForRow = async () => {
|
||||
const btn = div.querySelector('.discover-drm-btn');
|
||||
const drmTargets = getRowDrmPlaybackTargets(div);
|
||||
const mainTarget = {
|
||||
url: div.querySelector('.l-url')?.value.trim() || '',
|
||||
type: div.querySelector('.l-type')?.value || '',
|
||||
drmRow: null
|
||||
};
|
||||
const targets = drmTargets.length ? drmTargets : (mainTarget.url ? [mainTarget] : []);
|
||||
if (!targets.length) {
|
||||
showToast(t('probe.no_info'), 'error');
|
||||
return;
|
||||
}
|
||||
const original = btn?.textContent || '';
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '...';
|
||||
}
|
||||
let total = 0;
|
||||
let partial = false;
|
||||
const failedMessages = [];
|
||||
try {
|
||||
for (const target of targets) {
|
||||
try {
|
||||
const res = await apiCall('discover_drm', {
|
||||
url: target.url,
|
||||
type: inferLinkType(target.url, target.type)
|
||||
});
|
||||
const result = applyDiscoveredDrm(res.data?.drmConfigs || [], target.url, res.data?.type || target.type, target.row || target.drmRow);
|
||||
total += result.applied || 0;
|
||||
partial = partial || !!result.partial;
|
||||
} catch (e) {
|
||||
const url = target.url || '';
|
||||
const shortUrl = url.length > 72 ? `${url.slice(0, 69)}...` : url;
|
||||
failedMessages.push(`${shortUrl}: ${e.message || e}`);
|
||||
}
|
||||
}
|
||||
div.querySelector('.link-drm-config')?.setAttribute('open', '');
|
||||
if (total && failedMessages.length) {
|
||||
showToast(t('msg.drm_partial_failed').replace('{count}', failedMessages.length), 'info');
|
||||
} else if (total) {
|
||||
showToast(partial ? t('msg.drm_partial') : t('msg.drm_discovered'), partial ? 'info' : 'success');
|
||||
} else if (failedMessages.length) {
|
||||
showToast(`${t('msg.drm_discover_failed')}: ${failedMessages[0]}`, 'error');
|
||||
} else {
|
||||
showToast(t('msg.drm_not_found'), 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
} finally {
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = original || t('btn.discover_drm');
|
||||
}
|
||||
}
|
||||
};
|
||||
if (normalizedDrmConfigs.length) {
|
||||
normalizedDrmConfigs.forEach(config => addDrmConfigUI(config));
|
||||
} else {
|
||||
addDrmConfigUI();
|
||||
}
|
||||
syncMainPlaybackState();
|
||||
div.querySelector('.discover-drm-btn')?.addEventListener('click', discoverDrmForRow);
|
||||
div.querySelector('.add-drm-config-btn')?.addEventListener('click', () => {
|
||||
div.querySelector('.link-drm-config')?.setAttribute('open', '');
|
||||
addDrmConfigUI();
|
||||
@@ -4040,7 +4594,15 @@
|
||||
div.querySelector('.l-url').addEventListener('input', () => scheduleLinkRowCheck(div));
|
||||
div.querySelector('.l-url').addEventListener('blur', () => scheduleLinkRowCheck(div, 0));
|
||||
div.querySelector('.l-type').addEventListener('change', () => scheduleLinkRowCheck(div, 0));
|
||||
div.querySelector('.l-proxy-mode').addEventListener('change', () => scheduleLinkRowCheck(div, 0));
|
||||
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.addEventListener('dragstart', e => {
|
||||
if (!_linkDragFromHandle) { e.preventDefault(); return; }
|
||||
@@ -4070,7 +4632,9 @@
|
||||
if (srcIdx < dstIdx) container.insertBefore(_linkDragSrc, div.nextSibling);
|
||||
else container.insertBefore(_linkDragSrc, div);
|
||||
div.classList.remove('drag-over');
|
||||
updateLinkMoveButtons();
|
||||
});
|
||||
updateLinkMoveButtons();
|
||||
};
|
||||
|
||||
document.getElementById('add-link-btn').onclick = () => addLinkUI();
|
||||
@@ -4190,7 +4754,7 @@
|
||||
els.cancelBtn.classList.remove('hidden');
|
||||
els.linksContainer.innerHTML = '';
|
||||
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'));
|
||||
if (links.length === 0) addLinkUI();
|
||||
} else {
|
||||
resetForm();
|
||||
@@ -4269,7 +4833,6 @@
|
||||
</div>
|
||||
<div style="margin-top: 22px;">
|
||||
<h3 data-i18n="h3.links">视角配置</h3>
|
||||
<p class="hint" data-i18n="hint.links">填写格式:视角名称 | 类型 | 播放链接 | Key Override | ClearKey 信息</p>
|
||||
<div id="links-container"></div>
|
||||
<button type="button" id="add-link-btn" class="btn-success" data-i18n="btn.add_link">+ 添加视角</button>
|
||||
</div>
|
||||
|
||||
+360
-41
@@ -368,6 +368,189 @@
|
||||
const contentId = getFairPlayContentId(initData);
|
||||
return shaka.util.FairPlayUtils.initDataTransform(initData, contentId, drmInfo.serverCertificate);
|
||||
}
|
||||
function fairPlayStringToUtf16Buffer(text) {
|
||||
const buffer = new ArrayBuffer(text.length * 2);
|
||||
const view = new Uint16Array(buffer);
|
||||
for (let i = 0; i < text.length; i++) view[i] = text.charCodeAt(i);
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
||||
function concatFairPlayInitData(initData, contentId, certificate) {
|
||||
const init = new Uint8Array(initData);
|
||||
const id = fairPlayStringToUtf16Buffer(contentId);
|
||||
const cert = new Uint8Array(certificate);
|
||||
const result = new Uint8Array(init.byteLength + 4 + id.byteLength + 4 + cert.byteLength);
|
||||
const view = new DataView(result.buffer);
|
||||
let offset = 0;
|
||||
result.set(init, offset);
|
||||
offset += init.byteLength;
|
||||
view.setUint32(offset, id.byteLength, true);
|
||||
offset += 4;
|
||||
result.set(id, offset);
|
||||
offset += id.byteLength;
|
||||
view.setUint32(offset, cert.byteLength, true);
|
||||
offset += 4;
|
||||
result.set(cert, offset);
|
||||
return result;
|
||||
}
|
||||
|
||||
function getLegacyFairPlayContentId(initData) {
|
||||
const text = shaka.util.StringUtils.fromBytesAutoDetect(initData);
|
||||
const raw = shaka.util.FairPlayUtils.defaultGetContentId(text) || text.replace(/^skd:\/\//i, '');
|
||||
const query = raw.includes('?') ? raw.split('?').pop() : raw;
|
||||
const params = new URLSearchParams(query.replace(/^.*?assetId=/, 'assetId='));
|
||||
return params.get('assetId') || raw;
|
||||
}
|
||||
|
||||
function transformLegacyFairPlayInitData(initData, certificate) {
|
||||
const contentId = getLegacyFairPlayContentId(initData);
|
||||
return concatFairPlayInitData(initData, contentId, certificate);
|
||||
}
|
||||
function normalizeFairPlayLicenseResponse(buffer, contentType = '') {
|
||||
const data = new Uint8Array(buffer);
|
||||
const type = String(contentType || '').toLowerCase();
|
||||
const looksTextWrapped = type.includes('json') || type.includes('xml') || type.includes('text');
|
||||
if (!looksTextWrapped || !window.shaka?.util?.FairPlayUtils || !window.shaka?.net?.NetworkingEngine) {
|
||||
return data;
|
||||
}
|
||||
try {
|
||||
const response = { data, headers: { 'content-type': contentType } };
|
||||
shaka.util.FairPlayUtils.commonFairPlayResponse(shaka.net.NetworkingEngine.RequestType.LICENSE, response);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.warn('Native FairPlay response unwrap skipped:', error);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
function setupNativeFairPlayHls(video, url, drm, art, options = {}) {
|
||||
if (!window.WebKitMediaKeys || !WebKitMediaKeys.isTypeSupported('com.apple.fps.1_0', 'video/mp4')) {
|
||||
return null;
|
||||
}
|
||||
const licenseUrl = String(drm?.licenseUrl || '').trim();
|
||||
const certificateUrl = String(drm?.certificateUrl || '').trim();
|
||||
const headers = parseHeaderConfig(drm?.licenseHeaders || '');
|
||||
const keySystem = 'com.apple.fps.1_0';
|
||||
const sessions = [];
|
||||
const disposers = [];
|
||||
let disposed = false;
|
||||
let loaded = false;
|
||||
const showNativeError = (stage, error) => {
|
||||
const detail = error?.message || error?.name || error?.type || error?.code || String(error || 'unknown');
|
||||
console.error(`Native FairPlay Error [${stage}]:`, error);
|
||||
art.notice.show = `${t('drm_playback_error')} (${stage}: ${detail})`;
|
||||
};
|
||||
const certificatePromise = fetch(certificateUrl, { cache: 'force-cache' }).then(response => {
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.arrayBuffer();
|
||||
}).then(buffer => {
|
||||
console.info('Native FairPlay certificate loaded:', certificateUrl, buffer.byteLength);
|
||||
return new Uint8Array(buffer);
|
||||
}).catch(error => {
|
||||
showNativeError('certificate', error);
|
||||
throw error;
|
||||
});
|
||||
const clearWaiting = () => {
|
||||
loaded = true;
|
||||
};
|
||||
const handleNeedKey = event => {
|
||||
console.info('Native FairPlay needkey:', event);
|
||||
certificatePromise.then(certificate => {
|
||||
if (disposed) return;
|
||||
try {
|
||||
const initData = event.initData || event.webkitInitData;
|
||||
if (!initData || !initData.byteLength) throw new Error('missing initData');
|
||||
const transformed = transformLegacyFairPlayInitData(initData, certificate);
|
||||
console.info('Native FairPlay initData:', shaka.util.StringUtils.fromBytesAutoDetect(initData), 'contentId=', getLegacyFairPlayContentId(initData), initData.byteLength, transformed.byteLength);
|
||||
let session = null;
|
||||
try {
|
||||
session = video.webkitKeys.createSession('video/mp4', transformed);
|
||||
} catch (error) {
|
||||
showNativeError('create-session', error);
|
||||
return;
|
||||
}
|
||||
if (!session) {
|
||||
showNativeError('create-session', new Error('empty session'));
|
||||
return;
|
||||
}
|
||||
sessions.push(session);
|
||||
const handleMessage = messageEvent => {
|
||||
const message = messageEvent.message || messageEvent.webkitMessage;
|
||||
if (!message || !message.byteLength) {
|
||||
showNativeError('license-message', new Error('missing SPC message'));
|
||||
return;
|
||||
}
|
||||
const licenseEndpoint = options.licenseProxyUrl || licenseUrl;
|
||||
console.info('Native FairPlay license request:', licenseEndpoint, message.byteLength);
|
||||
const requestHeaders = options.licenseProxyUrl
|
||||
? { 'Content-Type': 'application/octet-stream', 'X-StreamHall-Viewer-Token': options.viewerToken || '' }
|
||||
: { 'Content-Type': 'application/octet-stream', ...headers };
|
||||
fetch(licenseEndpoint, {
|
||||
method: 'POST',
|
||||
headers: requestHeaders,
|
||||
body: message
|
||||
}).then(async response => {
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
console.info('Native FairPlay license status:', response.status, contentType);
|
||||
const buffer = await response.arrayBuffer();
|
||||
if (!response.ok) {
|
||||
const text = new TextDecoder().decode(buffer.slice(0, 2048));
|
||||
console.warn('Native FairPlay license error body:', text);
|
||||
throw `HTTP ${response.status}: ${text}`;
|
||||
}
|
||||
return { buffer, contentType };
|
||||
}).then(({ buffer, contentType }) => {
|
||||
if (disposed) return;
|
||||
console.info('Native FairPlay license response:', buffer.byteLength);
|
||||
session.update(normalizeFairPlayLicenseResponse(buffer, contentType));
|
||||
}).catch(error => showNativeError('license', error));
|
||||
};
|
||||
const handleKeyError = errorEvent => {
|
||||
const code = session.error ? `${session.error.code || ''} ${session.error.systemCode || ''}`.trim() : '';
|
||||
showNativeError('key-session', code ? new Error(code) : errorEvent);
|
||||
};
|
||||
session.addEventListener('webkitkeymessage', handleMessage);
|
||||
session.addEventListener('webkitkeyerror', handleKeyError);
|
||||
disposers.push(() => {
|
||||
session.removeEventListener('webkitkeymessage', handleMessage);
|
||||
session.removeEventListener('webkitkeyerror', handleKeyError);
|
||||
});
|
||||
} catch (error) {
|
||||
showNativeError('needkey', error);
|
||||
}
|
||||
}).catch(error => showNativeError('certificate', error));
|
||||
};
|
||||
const handleVideoError = () => showNativeError('media', video.error || new Error('Native FairPlay media error'));
|
||||
try {
|
||||
video.webkitSetMediaKeys(new WebKitMediaKeys(keySystem));
|
||||
} catch (error) {
|
||||
showNativeError('mediakeys', error);
|
||||
return null;
|
||||
}
|
||||
video.addEventListener('webkitneedkey', handleNeedKey);
|
||||
video.addEventListener('loadeddata', clearWaiting);
|
||||
video.addEventListener('playing', clearWaiting);
|
||||
video.addEventListener('error', handleVideoError);
|
||||
disposers.push(() => {
|
||||
video.removeEventListener('webkitneedkey', handleNeedKey);
|
||||
video.removeEventListener('loadeddata', clearWaiting);
|
||||
video.removeEventListener('playing', clearWaiting);
|
||||
video.removeEventListener('error', handleVideoError);
|
||||
});
|
||||
const timeout = window.setTimeout(() => {
|
||||
if (!disposed && !loaded && video.readyState < 2) {
|
||||
art.notice.show = `${t('drm_playback_error')} (native FairPlay timeout)`;
|
||||
}
|
||||
}, 15000);
|
||||
video.src = url;
|
||||
video.load();
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.clearTimeout(timeout);
|
||||
disposers.splice(0).forEach(dispose => dispose());
|
||||
try { video.removeAttribute('src'); video.load(); } catch (e) {}
|
||||
};
|
||||
}
|
||||
|
||||
function getDrmConfigs(link) {
|
||||
const configs = Array.isArray(link?.drmConfigs) ? link.drmConfigs : Array.isArray(link?.drm_configs) ? link.drm_configs : [];
|
||||
@@ -376,7 +559,10 @@
|
||||
licenseUrl: String(config?.licenseUrl || config?.license_url || '').trim(),
|
||||
certificateUrl: String(config?.certificateUrl || config?.certificate_url || '').trim(),
|
||||
licenseHeaders: String(config?.licenseHeaders || config?.license_headers || '').trim(),
|
||||
pssh: String(config?.pssh || '').trim()
|
||||
pssh: String(config?.pssh || '').trim(),
|
||||
playbackUrl: String(config?.playbackUrl || config?.playback_url || '').trim(),
|
||||
playbackType: String(config?.playbackType || config?.playback_type || '').trim().toLowerCase(),
|
||||
playback_url: String(config?.playback_url || '').trim()
|
||||
})).filter(config => (config.drmType === 'widevine' || config.drmType === 'fairplay') && config.licenseUrl);
|
||||
if (normalized.length) return normalized;
|
||||
const legacyType = String(link?.drmType || link?.drm_type || '').toLowerCase();
|
||||
@@ -387,16 +573,24 @@
|
||||
licenseUrl: legacyLicense,
|
||||
certificateUrl: String(link?.certificateUrl || link?.certificate_url || '').trim(),
|
||||
licenseHeaders: String(link?.licenseHeaders || link?.license_headers || '').trim(),
|
||||
pssh: String(link?.pssh || '').trim()
|
||||
pssh: String(link?.pssh || '').trim(),
|
||||
playbackUrl: String(link?.playbackUrl || link?.playback_url || '').trim(),
|
||||
playbackType: String(link?.playbackType || link?.playback_type || '').trim().toLowerCase(),
|
||||
playback_url: String(link?.playback_url || '').trim()
|
||||
}];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function browserPrefersFairPlay() {
|
||||
function isAppleWebKitFairPlayCapable() {
|
||||
const ua = navigator.userAgent || '';
|
||||
const isSafari = /Safari/i.test(ua) && !/(Chrome|Chromium|CriOS|FxiOS|Edg|OPR|OPiOS)/i.test(ua);
|
||||
return !!window.WebKitMediaKeys || isSafari;
|
||||
const isiOSWebKit = /iPad|iPhone|iPod/i.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
const isDesktopSafari = /Safari/i.test(ua) && !/(Chrome|Chromium|CriOS|FxiOS|Edg|OPR|OPiOS)/i.test(ua);
|
||||
return !!window.WebKitMediaKeys || isiOSWebKit || isDesktopSafari;
|
||||
}
|
||||
|
||||
function browserPrefersFairPlay() {
|
||||
return isAppleWebKitFairPlayCapable();
|
||||
}
|
||||
|
||||
function isAndroidRestrictedWebView() {
|
||||
@@ -405,6 +599,23 @@
|
||||
return /Telegram/i.test(ua);
|
||||
}
|
||||
|
||||
async function canUseWidevineKeySystem() {
|
||||
if (!navigator.requestMediaKeySystemAccess) return false;
|
||||
try {
|
||||
await navigator.requestMediaKeySystemAccess('com.widevine.alpha', [{
|
||||
initDataTypes: ['cenc'],
|
||||
audioCapabilities: [{ contentType: 'audio/mp4; codecs="mp4a.40.2"' }],
|
||||
videoCapabilities: [{ contentType: 'video/mp4; codecs="avc1.42E01E"' }],
|
||||
distinctiveIdentifier: 'optional',
|
||||
persistentState: 'optional',
|
||||
sessionTypes: ['temporary']
|
||||
}]);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectDrmConfig(link) {
|
||||
const configs = getDrmConfigs(link);
|
||||
if (!configs.length) return null;
|
||||
@@ -426,9 +637,45 @@
|
||||
return !!selectDrmConfig(link);
|
||||
}
|
||||
|
||||
function safeDestroyHls(art) {
|
||||
try {
|
||||
const hls = art?.hls;
|
||||
if (hls) hls.destroy();
|
||||
} catch (error) {
|
||||
if (!String(error?.message || error).includes('Cannot find instance of HLS')) {
|
||||
console.warn('HLS cleanup skipped:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function linkHasDrmConfigs(link) {
|
||||
return getDrmConfigs(link).length > 0;
|
||||
}
|
||||
function drmConfigMatchScore(config, selected) {
|
||||
if (!selected || config.drmType !== selected.drmType) return -1;
|
||||
let score = 1;
|
||||
const fields = ['licenseUrl', 'certificateUrl', 'playbackUrl', 'playbackType', 'pssh', 'licenseHeaders'];
|
||||
for (const field of fields) {
|
||||
const left = String(config[field] || '').trim();
|
||||
const right = String(selected[field] || '').trim();
|
||||
if (left && right && left !== right) return -1;
|
||||
if (left === right) score += 1;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
function getDrmConfigIndex(link, selected) {
|
||||
const configs = getDrmConfigs(link);
|
||||
let bestIndex = -1;
|
||||
let bestScore = -1;
|
||||
configs.forEach((config, index) => {
|
||||
const score = drmConfigMatchScore(config, selected);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestIndex = index;
|
||||
}
|
||||
});
|
||||
return bestIndex;
|
||||
}
|
||||
|
||||
function formatShakaError(error) {
|
||||
if (!error) return t('drm_playback_error');
|
||||
@@ -464,22 +711,36 @@
|
||||
function installShakaQualityControl(art, player) {
|
||||
const update = () => {
|
||||
const tracks = player.getVariantTracks()
|
||||
.filter(track => track.videoId !== null && track.videoId !== undefined)
|
||||
.filter(track => track.videoId !== null && track.videoId !== undefined && (track.height || track.bandwidth))
|
||||
.sort((a, b) => (b.height || 0) - (a.height || 0) || (b.bandwidth || 0) - (a.bandwidth || 0));
|
||||
const unique = [];
|
||||
const seen = new Set();
|
||||
const byVideoTrack = new Map();
|
||||
tracks.forEach(track => {
|
||||
const key = `${track.height || 0}-${Math.round((track.bandwidth || 0) / 1000)}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
unique.push(track);
|
||||
const key = track.videoId != null ? `video-${track.videoId}` : `${track.width || 0}x${track.height || 0}-${track.codecs || ''}-${Math.round((track.bandwidth || 0) / 250000)}`;
|
||||
const current = byVideoTrack.get(key);
|
||||
if (!current || track.active || (!current.active && (track.bandwidth || 0) > (current.bandwidth || 0))) {
|
||||
byVideoTrack.set(key, track);
|
||||
}
|
||||
});
|
||||
const unique = Array.from(byVideoTrack.values())
|
||||
.sort((a, b) => (b.height || 0) - (a.height || 0) || (b.bandwidth || 0) - (a.bandwidth || 0));
|
||||
if (!unique.length) return;
|
||||
const active = unique.find(track => track.active) || tracks.find(track => track.active);
|
||||
const autoLabel = 'Auto';
|
||||
const selectedLabel = active?.height ? `${active.height}P` : autoLabel;
|
||||
const heightCounts = unique.reduce((acc, track) => {
|
||||
const key = track.height || 0;
|
||||
acc.set(key, (acc.get(key) || 0) + 1);
|
||||
return acc;
|
||||
}, new Map());
|
||||
const qualityLabel = track => {
|
||||
if (!track) return autoLabel;
|
||||
const kbps = Math.round((track.videoBandwidth || track.bandwidth || 0) / 1000);
|
||||
if (!track.height) return `${kbps}K`;
|
||||
if ((heightCounts.get(track.height) || 0) <= 1) return `${track.height}P`;
|
||||
return kbps >= 1000 ? `${track.height}P (${(kbps / 1000).toFixed(1)}M)` : `${track.height}P (${kbps}K)`;
|
||||
};
|
||||
const selectedLabel = active ? qualityLabel(active) : autoLabel;
|
||||
const selector = unique.map(track => ({
|
||||
html: track.height ? `${track.height}P` : `${Math.round((track.bandwidth || 0) / 1000)}K`,
|
||||
html: qualityLabel(track),
|
||||
value: track.id,
|
||||
default: !!track.active
|
||||
}));
|
||||
@@ -526,6 +787,15 @@
|
||||
}
|
||||
|
||||
function getLinkType(link) {
|
||||
if (linkUsesShakaDrm(link)) return 'm3u8';
|
||||
const selectedDrm = selectDrmConfig(link);
|
||||
if (selectedDrm?.playbackUrl) {
|
||||
if (selectedDrm.playbackType) return selectedDrm.playbackType;
|
||||
const drmPath = selectedDrm.playbackUrl.split('?')[0].toLowerCase();
|
||||
if (drmPath.endsWith('.mpd')) return 'dash';
|
||||
if (drmPath.endsWith('.m3u8')) return 'm3u8';
|
||||
if (drmPath.endsWith('.flv')) return 'flv';
|
||||
}
|
||||
if (link.type) return link.type;
|
||||
const path = (link.url || '').split('?')[0].toLowerCase();
|
||||
if (path.endsWith('.mpd')) return 'dash';
|
||||
@@ -535,6 +805,8 @@
|
||||
}
|
||||
|
||||
function getPlaybackUrl(link) {
|
||||
const selectedDrm = selectDrmConfig(link);
|
||||
if (selectedDrm?.playbackUrl) return selectedDrm.playback_url || selectedDrm.playbackUrl;
|
||||
return link.playback_url || link.url;
|
||||
}
|
||||
|
||||
@@ -797,7 +1069,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const activeLinks = [...links];
|
||||
const activeLinks = links.map((link, index) => ({ ...link, _sourceIndex: index }));
|
||||
const preferredIndex = activeLinks.findIndex(link => link.url === preferredUrl || getPlaybackUrl(link) === preferredUrl);
|
||||
if (preferredIndex > 0) {
|
||||
activeLinks.unshift(activeLinks.splice(preferredIndex, 1)[0]);
|
||||
@@ -810,6 +1082,12 @@
|
||||
type: getLinkType(l)
|
||||
}));
|
||||
const initialLink = activeLinks[0] || null;
|
||||
const initialUsesDrm = linkUsesShakaDrm(initialLink);
|
||||
const hlsControlPlugins = initialUsesDrm ? [] : [
|
||||
artplayerPluginHlsControl({
|
||||
quality: { control: true, setting: true, getName: (l) => l.height + 'P', title: t('quality') }
|
||||
})
|
||||
];
|
||||
|
||||
playerInstance = new Artplayer({
|
||||
container: '.artplayer-app',
|
||||
@@ -818,7 +1096,7 @@
|
||||
quality: quality,
|
||||
title: data.eventName,
|
||||
autoplay: true,
|
||||
isLive: data.streamLabel === 'LIVE',
|
||||
isLive: data.streamLabel === 'LIVE' && !initialUsesDrm,
|
||||
volume: 0.5,
|
||||
autoSize: true,
|
||||
fullscreen: true,
|
||||
@@ -844,35 +1122,76 @@
|
||||
return;
|
||||
}
|
||||
if (linkUsesShakaDrm(currentLink)) {
|
||||
if (art.hls) art.hls.destroy();
|
||||
if (window.shaka?.polyfill) shaka.polyfill.installAll();
|
||||
if (!window.shaka || !shaka.Player || !shaka.Player.isBrowserSupported()) {
|
||||
art.notice.show = t('drm_unavailable');
|
||||
return;
|
||||
}
|
||||
const selectedDrm = selectDrmConfig(currentLink);
|
||||
if (selectedDrm?.drmType === 'widevine' && isAndroidRestrictedWebView()) {
|
||||
art.notice.show = t('drm_android_webview_unsupported');
|
||||
return;
|
||||
}
|
||||
const fairplay = selectedDrm?.drmType === 'fairplay';
|
||||
const certificateUrl = String(selectedDrm?.certificateUrl || '').trim();
|
||||
const licenseUrl = String(selectedDrm?.licenseUrl || '').trim();
|
||||
if (!licenseUrl) {
|
||||
art.notice.show = t('drm_license_missing');
|
||||
return;
|
||||
}
|
||||
if (fairplay && !certificateUrl) {
|
||||
art.notice.show = t('drm_certificate_missing');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedDrm?.drmType === 'widevine' && isAndroidRestrictedWebView()) {
|
||||
canUseWidevineKeySystem().then(supported => {
|
||||
if (!supported) art.notice.show = t('drm_android_webview_unsupported');
|
||||
});
|
||||
}
|
||||
if (art.nativeFairPlayCleanup) {
|
||||
art.nativeFairPlayCleanup();
|
||||
art.nativeFairPlayCleanup = null;
|
||||
}
|
||||
safeDestroyHls(art);
|
||||
const linkIndex = Number.isInteger(currentLink?._sourceIndex) ? currentLink._sourceIndex : activeLinks.indexOf(currentLink);
|
||||
const drmIndex = getDrmConfigIndex(currentLink, selectedDrm);
|
||||
if (fairplay && isAppleWebKitFairPlayCapable() && window.WebKitMediaKeys) {
|
||||
if (art.shaka) {
|
||||
art.shaka.destroy().catch(() => {});
|
||||
art.shaka = null;
|
||||
}
|
||||
if (art.streamhallDurationGuard) art.streamhallDurationGuard();
|
||||
art.streamhallDurationGuard = installLiveDurationGuard(art);
|
||||
const licenseProxyUrl = linkIndex >= 0 && drmIndex >= 0
|
||||
? `/api?action=fairplay_license&id=${encodeURIComponent(streamId)}&link=${encodeURIComponent(linkIndex)}&drm=${encodeURIComponent(drmIndex)}`
|
||||
: '';
|
||||
const cleanup = setupNativeFairPlayHls(video, url, selectedDrm, art, { licenseProxyUrl, viewerToken });
|
||||
if (!cleanup) {
|
||||
art.notice.show = t('drm_unavailable');
|
||||
return;
|
||||
}
|
||||
art.nativeFairPlayCleanup = cleanup;
|
||||
art.on('destroy', () => {
|
||||
if (art.streamhallDurationGuard) art.streamhallDurationGuard();
|
||||
if (art.nativeFairPlayCleanup) {
|
||||
art.nativeFairPlayCleanup();
|
||||
art.nativeFairPlayCleanup = null;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (window.shaka?.polyfill) shaka.polyfill.installAll();
|
||||
if (!window.shaka || !shaka.Player || !shaka.Player.isBrowserSupported()) {
|
||||
art.notice.show = t('drm_unavailable');
|
||||
return;
|
||||
}
|
||||
if (art.shaka) art.shaka.destroy().catch(() => {});
|
||||
if (art.streamhallDurationGuard) art.streamhallDurationGuard();
|
||||
art.streamhallDurationGuard = installLiveDurationGuard(art);
|
||||
const player = new shaka.Player();
|
||||
const headers = parseHeaderConfig(selectedDrm?.licenseHeaders || '');
|
||||
const fairplay = selectedDrm?.drmType === 'fairplay';
|
||||
const keySystem = fairplay ? 'com.apple.fps' : 'com.widevine.alpha';
|
||||
const fairplayLegacyKeySystem = 'com.apple.fps.1_0';
|
||||
const certificateUrl = String(selectedDrm?.certificateUrl || '').trim();
|
||||
const servers = { [keySystem]: licenseUrl };
|
||||
const widevineProxyUrl = !fairplay && linkIndex >= 0 && drmIndex >= 0
|
||||
? `/api?action=widevine_license&id=${encodeURIComponent(streamId)}&link=${encodeURIComponent(linkIndex)}&drm=${encodeURIComponent(drmIndex)}&vt=${encodeURIComponent(viewerToken || '')}`
|
||||
: '';
|
||||
const shakaLicenseUrl = widevineProxyUrl || licenseUrl;
|
||||
const servers = { [keySystem]: shakaLicenseUrl };
|
||||
const advanced = {};
|
||||
if (fairplay) {
|
||||
servers[fairplayLegacyKeySystem] = licenseUrl;
|
||||
servers[fairplayLegacyKeySystem] = shakaLicenseUrl;
|
||||
advanced[keySystem] = {
|
||||
serverCertificateUri: certificateUrl,
|
||||
audioRobustness: '',
|
||||
@@ -896,22 +1215,22 @@
|
||||
servers,
|
||||
advanced
|
||||
};
|
||||
if (fairplay && !certificateUrl) {
|
||||
art.notice.show = t('drm_certificate_missing');
|
||||
return;
|
||||
}
|
||||
|
||||
player.configure({
|
||||
drm: {
|
||||
...drmConfig,
|
||||
...(fairplay ? { initDataTransform: transformFairPlayInitData } : {})
|
||||
},
|
||||
...(fairplay ? { streaming: { useNativeHlsForFairPlay: false } } : {})
|
||||
...(fairplay ? { streaming: { useNativeHlsForFairPlay: isAppleWebKitFairPlayCapable() } } : {})
|
||||
});
|
||||
player.getNetworkingEngine().registerRequestFilter((type, request) => {
|
||||
if (type !== shaka.net.NetworkingEngine.RequestType.LICENSE) return;
|
||||
if (fairplay) {
|
||||
request.headers['Content-Type'] = request.headers['Content-Type'] || 'application/octet-stream';
|
||||
}
|
||||
if (widevineProxyUrl) {
|
||||
request.headers['X-StreamHall-Viewer-Token'] = viewerToken || '';
|
||||
}
|
||||
if (Object.keys(headers).length) {
|
||||
Object.assign(request.headers, headers);
|
||||
}
|
||||
@@ -939,6 +1258,10 @@
|
||||
return;
|
||||
}
|
||||
if (Hls.isSupported()) {
|
||||
if (art.nativeFairPlayCleanup) {
|
||||
art.nativeFairPlayCleanup();
|
||||
art.nativeFairPlayCleanup = null;
|
||||
}
|
||||
if (art.shaka) {
|
||||
art.shaka.destroy().catch(() => {});
|
||||
art.shaka = null;
|
||||
@@ -947,7 +1270,7 @@
|
||||
art.streamhallDurationGuard();
|
||||
art.streamhallDurationGuard = null;
|
||||
}
|
||||
if (art.hls) art.hls.destroy();
|
||||
safeDestroyHls(art);
|
||||
const keyOverride = currentLink ? parseHlsKeyOverride(currentLink.key) : null;
|
||||
|
||||
class CustomLoader extends Hls.DefaultConfig.loader {
|
||||
@@ -1006,11 +1329,7 @@
|
||||
art.on('destroy', () => dash.destroy());
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
artplayerPluginHlsControl({
|
||||
quality: { control: true, setting: true, getName: (l) => l.height + 'P', title: t('quality') }
|
||||
})
|
||||
]
|
||||
plugins: hlsControlPlugins
|
||||
});
|
||||
startPlaybackMonitor(data, password);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import csv
|
||||
import hashlib
|
||||
import hmac
|
||||
import io
|
||||
import ipaddress
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
@@ -17,13 +18,14 @@ import tempfile
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
from http import HTTPStatus
|
||||
from http.cookies import SimpleCookie
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import parse_qs, quote, urlencode, urljoin, urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.request import HTTPRedirectHandler, Request, build_opener, urlopen
|
||||
|
||||
try:
|
||||
import psycopg
|
||||
@@ -90,6 +92,7 @@ VIDEO_EXTS = frozenset({".mp4", ".mkv", ".avi", ".flv", ".ts", ".mov", ".wmv", "
|
||||
active_pushes: dict[str, dict] = {}
|
||||
_pushes_lock = threading.Lock()
|
||||
HLS_PROXY_PREFIX = "/proxy/hls"
|
||||
HLS_MANIFEST_PROXY_PREFIX = "/proxy/hls-manifest"
|
||||
VIEWER_TOKEN_TTL = 300 # seconds
|
||||
HLS_URI_RE = re.compile(r'URI="([^"]+)"') # matches URI="..." attributes in HLS tag lines (e.g. EXT-X-KEY)
|
||||
|
||||
@@ -455,6 +458,28 @@ def hls_proxy_path(url: str) -> str:
|
||||
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)
|
||||
return hls_proxy_path(url)
|
||||
|
||||
|
||||
PLAYABLE_VIDEO_EXTS = frozenset({".mp4", ".mkv", ".mov", ".webm", ".m4v"})
|
||||
|
||||
|
||||
@@ -497,9 +522,24 @@ def add_playback_urls(links: list[dict[str, object]]) -> list[dict[str, object]]
|
||||
for link in links:
|
||||
item = dict(link)
|
||||
raw_url = str(item.get("url") or "")
|
||||
parsed = urlparse(raw_url)
|
||||
if is_hls_link(item) and parsed.scheme in ("http", "https"):
|
||||
item["playback_url"] = hls_proxy_path(raw_url)
|
||||
proxy_mode = normalize_proxy_mode(item.get("proxyMode", item.get("proxy_mode", "")))
|
||||
item["proxyMode"] = proxy_mode
|
||||
if raw_url:
|
||||
item["playback_url"] = playback_url_for_mode(raw_url, item, proxy_mode)
|
||||
drm_configs = item.get("drmConfigs", [])
|
||||
if isinstance(drm_configs, list):
|
||||
prepared_configs: list[dict[str, object]] = []
|
||||
for config in drm_configs:
|
||||
if not isinstance(config, dict):
|
||||
continue
|
||||
config_item = dict(config)
|
||||
drm_playback_url = str(config_item.get("playbackUrl") or "")
|
||||
drm_playback_type = str(config_item.get("playbackType") or "")
|
||||
if drm_playback_url:
|
||||
probe_item = {"url": drm_playback_url, "type": drm_playback_type}
|
||||
config_item["playback_url"] = playback_url_for_mode(drm_playback_url, probe_item, proxy_mode)
|
||||
prepared_configs.append(config_item)
|
||||
item["drmConfigs"] = prepared_configs
|
||||
prepared.append(item)
|
||||
return prepared
|
||||
|
||||
@@ -538,6 +578,27 @@ def admin_session_not_before() -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def parse_header_config(text: object) -> dict[str, str]:
|
||||
raw = str(text or "").strip()
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
if isinstance(parsed, dict):
|
||||
return {str(k): str(v) for k, v in parsed.items() if k and v is not None}
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
headers: dict[str, str] = {}
|
||||
for line in raw.splitlines():
|
||||
if ":" not in line:
|
||||
continue
|
||||
key, value = line.split(":", 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if key and value:
|
||||
headers[key] = value
|
||||
return headers
|
||||
|
||||
def normalize_drm_configs(item: dict[str, object]) -> list[dict[str, str]]:
|
||||
raw_configs = item.get("drmConfigs", item.get("drm_configs", []))
|
||||
configs = raw_configs if isinstance(raw_configs, list) else []
|
||||
@@ -552,6 +613,9 @@ def normalize_drm_configs(item: dict[str, object]) -> list[dict[str, str]]:
|
||||
license_url = str(config.get("licenseUrl", config.get("license_url", ""))).strip()
|
||||
if not license_url:
|
||||
continue
|
||||
playback_type = str(config.get("playbackType", config.get("playback_type", ""))).strip().lower()
|
||||
if playback_type not in ("", "m3u8", "flv", "dash"):
|
||||
playback_type = ""
|
||||
normalized.append(
|
||||
{
|
||||
"drmType": drm_type,
|
||||
@@ -559,12 +623,17 @@ def normalize_drm_configs(item: dict[str, object]) -> list[dict[str, str]]:
|
||||
"certificateUrl": str(config.get("certificateUrl", config.get("certificate_url", ""))).strip(),
|
||||
"licenseHeaders": str(config.get("licenseHeaders", config.get("license_headers", ""))).strip(),
|
||||
"pssh": str(config.get("pssh", "")).strip(),
|
||||
"playbackUrl": str(config.get("playbackUrl", config.get("playback_url", ""))).strip(),
|
||||
"playbackType": playback_type,
|
||||
}
|
||||
)
|
||||
|
||||
legacy_type = str(item.get("drmType", item.get("drm_type", ""))).strip().lower()
|
||||
legacy_license = str(item.get("licenseUrl", item.get("license_url", ""))).strip()
|
||||
if legacy_type in ("widevine", "fairplay") and legacy_license:
|
||||
legacy_playback_type = str(item.get("playbackType", item.get("playback_type", ""))).strip().lower()
|
||||
if legacy_playback_type not in ("", "m3u8", "flv", "dash"):
|
||||
legacy_playback_type = ""
|
||||
has_legacy = any(config["drmType"] == legacy_type for config in normalized)
|
||||
if not has_legacy:
|
||||
normalized.append(
|
||||
@@ -574,6 +643,8 @@ def normalize_drm_configs(item: dict[str, object]) -> list[dict[str, str]]:
|
||||
"certificateUrl": str(item.get("certificateUrl", item.get("certificate_url", ""))).strip(),
|
||||
"licenseHeaders": str(item.get("licenseHeaders", item.get("license_headers", ""))).strip(),
|
||||
"pssh": str(item.get("pssh", "")).strip(),
|
||||
"playbackUrl": str(item.get("playbackUrl", item.get("playback_url", ""))).strip(),
|
||||
"playbackType": legacy_playback_type,
|
||||
}
|
||||
)
|
||||
return normalized
|
||||
@@ -592,6 +663,7 @@ def normalize_links(raw: object) -> list[dict[str, object]]:
|
||||
link_type = str(item.get("type", "")).strip().lower()
|
||||
if link_type not in ("", "m3u8", "flv", "dash"):
|
||||
link_type = ""
|
||||
proxy_mode = normalize_proxy_mode(item.get("proxyMode", item.get("proxy_mode", "")))
|
||||
drm_configs = normalize_drm_configs(item)
|
||||
first_drm = drm_configs[0] if drm_configs else {}
|
||||
normalized.append(
|
||||
@@ -599,6 +671,7 @@ def normalize_links(raw: object) -> list[dict[str, object]]:
|
||||
"name": name,
|
||||
"type": link_type,
|
||||
"url": url,
|
||||
"proxyMode": proxy_mode,
|
||||
"key": str(item.get("key", "")).strip(),
|
||||
"clearkey": str(item.get("clearkey", "")).strip(),
|
||||
"drmConfigs": drm_configs,
|
||||
@@ -607,6 +680,8 @@ def normalize_links(raw: object) -> list[dict[str, object]]:
|
||||
"certificateUrl": str(first_drm.get("certificateUrl", "")),
|
||||
"licenseHeaders": str(first_drm.get("licenseHeaders", "")),
|
||||
"pssh": str(first_drm.get("pssh", "")),
|
||||
"playbackUrl": str(first_drm.get("playbackUrl", "")),
|
||||
"playbackType": str(first_drm.get("playbackType", "")),
|
||||
}
|
||||
)
|
||||
return normalized
|
||||
@@ -755,6 +830,266 @@ def dash_manifest_drm_types(text: str) -> set[str]:
|
||||
return types
|
||||
|
||||
|
||||
class NoRedirectHandler(HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
|
||||
_NO_REDIRECT_OPENER = build_opener(NoRedirectHandler)
|
||||
|
||||
|
||||
def reject_private_http_url(url: str) -> None:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
raise AppError("invalid_request", "invalid manifest url")
|
||||
hostname = parsed.hostname.strip().lower().rstrip(".")
|
||||
if hostname in {"localhost", "ip6-localhost", "ip6-loopback"}:
|
||||
raise AppError("invalid_request", "private manifest host is not allowed")
|
||||
try:
|
||||
candidates = [ipaddress.ip_address(hostname.strip("[]"))]
|
||||
except ValueError:
|
||||
try:
|
||||
infos = socket.getaddrinfo(hostname, parsed.port or (443 if parsed.scheme == "https" else 80), type=socket.SOCK_STREAM)
|
||||
except socket.gaierror as exc:
|
||||
raise AppError("upstream_request_failed", f"DNS lookup failed: {hostname}") from exc
|
||||
candidates = []
|
||||
for info in infos:
|
||||
sockaddr = info[4]
|
||||
if not sockaddr:
|
||||
continue
|
||||
try:
|
||||
candidates.append(ipaddress.ip_address(sockaddr[0]))
|
||||
except ValueError:
|
||||
continue
|
||||
if not candidates:
|
||||
raise AppError("upstream_request_failed", f"DNS lookup returned no usable address: {hostname}")
|
||||
for address in candidates:
|
||||
if (
|
||||
address.is_private
|
||||
or address.is_loopback
|
||||
or address.is_link_local
|
||||
or address.is_multicast
|
||||
or address.is_reserved
|
||||
or address.is_unspecified
|
||||
):
|
||||
raise AppError("invalid_request", f"private manifest address is not allowed: {address}")
|
||||
|
||||
|
||||
def fetch_manifest_text(url: str, *, limit: int = 512 * 1024) -> tuple[str, int | None, str]:
|
||||
headers = {
|
||||
"User-Agent": "StreamHall/1.0",
|
||||
"Accept": "application/dash+xml, application/vnd.apple.mpegurl, application/x-mpegURL, text/plain;q=0.8, */*;q=0.6",
|
||||
}
|
||||
current_url = url
|
||||
for _ in range(4):
|
||||
reject_private_http_url(current_url)
|
||||
request = Request(current_url, headers=headers)
|
||||
try:
|
||||
with _NO_REDIRECT_OPENER.open(request, timeout=STREAM_PROBE_TIMEOUT) as resp:
|
||||
final_url = resp.geturl() or current_url
|
||||
reject_private_http_url(final_url)
|
||||
status_code = int(getattr(resp, "status", resp.getcode()) or 0)
|
||||
content_type = resp.headers.get("Content-Type", "")
|
||||
data = resp.read(limit)
|
||||
return decode_probe_text(data), status_code, content_type
|
||||
except HTTPError as exc:
|
||||
if exc.code in (301, 302, 303, 307, 308):
|
||||
location = exc.headers.get("Location", "").strip()
|
||||
if not location:
|
||||
raise
|
||||
current_url = urljoin(current_url, location)
|
||||
continue
|
||||
raise
|
||||
raise AppError("upstream_request_failed", "too many manifest redirects")
|
||||
|
||||
|
||||
def hls_attribute_value(line: str, name: str) -> str:
|
||||
match = re.search(rf'(?:^|,){re.escape(name)}=("([^"]*)"|[^,]*)', line, re.IGNORECASE)
|
||||
if not match:
|
||||
return ""
|
||||
value = match.group(2) if match.group(2) is not None else match.group(1)
|
||||
return value.strip().strip('"')
|
||||
|
||||
|
||||
def first_url_matching(text: str, pattern: str) -> str:
|
||||
match = re.search(pattern, text, re.IGNORECASE)
|
||||
return match.group(0) if match else ""
|
||||
|
||||
|
||||
def brightcove_account_from_url(url: str) -> str:
|
||||
parsed = urlparse(url)
|
||||
patterns = (
|
||||
r"/manifest/v1/hls/v\d+/fairplay/(\d+)/",
|
||||
r"/manifest/v1/dash/[^/]+/[^/]+/(\d+)/",
|
||||
r"/license/v1/[^/]+/[^/]+/(\d+)(?:/|$)",
|
||||
r"/license/v1/fairplay_app_cert/(\d+)(?:/|$)",
|
||||
)
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, parsed.path)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return ""
|
||||
|
||||
|
||||
def discover_dash_drm(url: str, text: str) -> list[dict[str, str]]:
|
||||
discovered: list[dict[str, str]] = []
|
||||
try:
|
||||
root = ET.fromstring(text)
|
||||
except ET.ParseError:
|
||||
return discovered
|
||||
|
||||
def local_name(tag: str) -> str:
|
||||
return tag.rsplit("}", 1)[-1].lower()
|
||||
|
||||
content_nodes = [node for node in root.iter() if local_name(node.tag) == "contentprotection"]
|
||||
for node in content_nodes:
|
||||
scheme = str(node.attrib.get("schemeIdUri", "")).lower()
|
||||
if "edef8ba9-79d6-4ace-a3c8-27dcd51d21ed" not in scheme and "widevine" not in scheme:
|
||||
continue
|
||||
license_url = ""
|
||||
for key, value in node.attrib.items():
|
||||
if key.rsplit("}", 1)[-1] == "licenseAcquisitionUrl":
|
||||
license_url = str(value).strip()
|
||||
break
|
||||
if not license_url:
|
||||
license_url = first_url_matching(ET.tostring(node, encoding="unicode"), r"https?://[^\s\"'<>]+/license/v1/cenc/widevine/[^\s\"'<>]+")
|
||||
pssh = ""
|
||||
for child in node.iter():
|
||||
if local_name(child.tag) == "pssh" and child.text:
|
||||
pssh = child.text.strip()
|
||||
break
|
||||
discovered.append(
|
||||
{
|
||||
"drmType": "widevine",
|
||||
"licenseUrl": license_url,
|
||||
"certificateUrl": "",
|
||||
"pssh": pssh,
|
||||
"playbackUrl": url,
|
||||
"playbackType": "dash",
|
||||
}
|
||||
)
|
||||
return [item for item in discovered if item.get("licenseUrl")]
|
||||
|
||||
|
||||
def discover_hls_drm(url: str, text: str, *, include_variants: bool = True) -> list[dict[str, str]]:
|
||||
discovered: list[dict[str, str]] = []
|
||||
seen: set[tuple[str, str, str]] = set()
|
||||
|
||||
def add_item(item: dict[str, str]) -> None:
|
||||
key = (item.get("drmType", ""), item.get("licenseUrl", ""), item.get("certificateUrl", ""))
|
||||
if key in seen:
|
||||
return
|
||||
seen.add(key)
|
||||
discovered.append(item)
|
||||
|
||||
cert_url = first_url_matching(text, r"https?://[^\s\"'<>]+/license/v1/fairplay_app_cert/[^\s\"'<>]+")
|
||||
account_id = brightcove_account_from_url(url)
|
||||
if not cert_url and account_id and "fairplay" in urlparse(url).path.lower():
|
||||
cert_url = f"https://manifest.prod.boltdns.net/license/v1/fairplay_app_cert/{account_id}"
|
||||
|
||||
direct_license = first_url_matching(text, r"https?://[^\s\"'<>]+/license/v1/fairplay/[^\s\"'<>]+")
|
||||
if direct_license or cert_url:
|
||||
add_item(
|
||||
{
|
||||
"drmType": "fairplay",
|
||||
"licenseUrl": direct_license,
|
||||
"certificateUrl": cert_url,
|
||||
"pssh": "",
|
||||
"playbackUrl": url,
|
||||
"playbackType": "m3u8",
|
||||
"partial": not bool(direct_license),
|
||||
}
|
||||
)
|
||||
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line.upper().startswith("#EXT-X-KEY"):
|
||||
continue
|
||||
keyformat = hls_attribute_value(line, "KEYFORMAT").lower()
|
||||
uri = hls_attribute_value(line, "URI")
|
||||
if "com.apple.streamingkeydelivery" in keyformat or uri.lower().startswith("skd://"):
|
||||
license_url = uri if uri.lower().startswith(("http://", "https://")) else direct_license
|
||||
add_item(
|
||||
{
|
||||
"drmType": "fairplay",
|
||||
"licenseUrl": license_url,
|
||||
"certificateUrl": cert_url,
|
||||
"pssh": "",
|
||||
"playbackUrl": url,
|
||||
"playbackType": "m3u8",
|
||||
"partial": not bool(license_url),
|
||||
}
|
||||
)
|
||||
|
||||
if include_variants:
|
||||
for variant_url in hls_variant_urls(text, url)[:5]:
|
||||
try:
|
||||
variant_text, _, _ = fetch_manifest_text(variant_url)
|
||||
except Exception:
|
||||
continue
|
||||
for item in discover_hls_drm(url, variant_text, include_variants=False):
|
||||
add_item(item)
|
||||
|
||||
return [item for item in discovered if item.get("licenseUrl") or item.get("certificateUrl")]
|
||||
|
||||
|
||||
def hls_variant_urls(text: str, base_url: str) -> list[str]:
|
||||
urls: list[str] = []
|
||||
lines = text.splitlines()
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip().startswith("#EXT-X-STREAM-INF"):
|
||||
for j in range(i + 1, len(lines)):
|
||||
candidate = lines[j].strip()
|
||||
if candidate and not candidate.startswith("#"):
|
||||
urls.append(urljoin(base_url, candidate))
|
||||
break
|
||||
return urls
|
||||
|
||||
|
||||
def discover_drm_from_url(raw_url: object, type_hint: object = "") -> dict[str, object]:
|
||||
url = str(raw_url or "").strip()
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||
raise AppError("invalid_request", "invalid playback url")
|
||||
|
||||
hint = str(type_hint or "").strip().lower()
|
||||
path = parsed.path.lower()
|
||||
is_hls = hint == "m3u8" or path.endswith(".m3u8")
|
||||
is_dash = hint == "dash" or path.endswith(".mpd")
|
||||
try:
|
||||
text, status_code, content_type = fetch_manifest_text(url)
|
||||
except HTTPError as exc:
|
||||
raise AppError("upstream_http_error", f"HTTP {exc.code}") from exc
|
||||
except (TimeoutError, URLError, OSError) as exc:
|
||||
raise AppError("upstream_request_failed", str(exc)) from exc
|
||||
|
||||
lowered_type = content_type.lower()
|
||||
if not is_hls and ("mpegurl" in lowered_type or "m3u8" in lowered_type or text.lstrip().startswith("#EXTM3U")):
|
||||
is_hls = True
|
||||
if not is_dash and ("dash+xml" in lowered_type or "<MPD" in text[:2048]):
|
||||
is_dash = True
|
||||
|
||||
discovered: list[dict[str, str]] = []
|
||||
if is_dash:
|
||||
discovered.extend(discover_dash_drm(url, text))
|
||||
if is_hls:
|
||||
discovered.extend(discover_hls_drm(url, text))
|
||||
|
||||
unsupported: list[str] = []
|
||||
lower = text.lower()
|
||||
if "9a04f079-9840-4286-ab92-e65be0885f95" in lower or "/playready/" in lower:
|
||||
unsupported.append("playready")
|
||||
|
||||
return {
|
||||
"url": url,
|
||||
"type": "dash" if is_dash else ("m3u8" if is_hls else ""),
|
||||
"status_code": status_code,
|
||||
"content_type": content_type,
|
||||
"drmConfigs": discovered,
|
||||
"unsupported": sorted(set(unsupported)),
|
||||
}
|
||||
|
||||
|
||||
def drm_config_types(configs: object) -> set[str]:
|
||||
normalized: set[str] = set()
|
||||
if not isinstance(configs, list):
|
||||
@@ -1324,6 +1659,28 @@ def rewrite_external_hls_manifest(manifest: str, base_url: str) -> str:
|
||||
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:
|
||||
while True:
|
||||
try:
|
||||
@@ -1350,6 +1707,9 @@ class StreamHallHandler(BaseHTTPRequestHandler):
|
||||
if parsed.path.startswith("/h/"):
|
||||
self.proxy_obs_route(parsed.path, parsed.query, send_body=True)
|
||||
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}/"):
|
||||
self.proxy_hls_route(parsed.path, send_body=True)
|
||||
return
|
||||
@@ -1366,6 +1726,9 @@ class StreamHallHandler(BaseHTTPRequestHandler):
|
||||
if parsed.path.startswith("/h/"):
|
||||
self.proxy_obs_route(parsed.path, parsed.query, send_body=False)
|
||||
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}/"):
|
||||
self.proxy_hls_route(parsed.path, send_body=False)
|
||||
return
|
||||
@@ -1459,6 +1822,10 @@ class StreamHallHandler(BaseHTTPRequestHandler):
|
||||
self.api_verify_password()
|
||||
elif action == "check_player_stream":
|
||||
self.api_check_player_stream()
|
||||
elif action == "fairplay_license":
|
||||
self.api_fairplay_license(parse_qs(parsed.query))
|
||||
elif action == "widevine_license":
|
||||
self.api_widevine_license(parse_qs(parsed.query))
|
||||
elif action == "viewer_start":
|
||||
self.api_viewer_start()
|
||||
elif action == "viewer_heartbeat":
|
||||
@@ -1513,6 +1880,9 @@ class StreamHallHandler(BaseHTTPRequestHandler):
|
||||
elif action == "check_stream_url":
|
||||
self.require_admin()
|
||||
self.api_check_stream_url()
|
||||
elif action == "discover_drm":
|
||||
self.require_admin()
|
||||
self.api_discover_drm()
|
||||
elif action == "check_stream":
|
||||
self.require_admin()
|
||||
self.api_check_stream()
|
||||
@@ -1697,6 +2067,181 @@ class StreamHallHandler(BaseHTTPRequestHandler):
|
||||
raise AppError("auth_incorrect_password")
|
||||
self.send_json({"status": "success", "data": self.check_stream_row(row)})
|
||||
|
||||
def api_fairplay_license(self, query: dict[str, list[str]]) -> None:
|
||||
stream_ref = query.get("id", [""])[0]
|
||||
viewer_token = self.headers.get("X-StreamHall-Viewer-Token", "").strip()
|
||||
try:
|
||||
link_index = int(query.get("link", ["-1"])[0])
|
||||
drm_index = int(query.get("drm", ["-1"])[0])
|
||||
variant_index = int(query.get("variant", ["-1"])[0])
|
||||
except ValueError:
|
||||
raise AppError("invalid_request")
|
||||
if link_index < 0 or drm_index < 0:
|
||||
raise AppError("invalid_request")
|
||||
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
if length <= 0 or length > 1024 * 1024:
|
||||
raise AppError("invalid_request")
|
||||
spc_body = self.rfile.read(length)
|
||||
with db() as conn:
|
||||
row = find_stream(conn, stream_ref)
|
||||
if not row or int(row["is_enabled"] or 0) != 1:
|
||||
raise AppError("stream_not_found_or_disabled")
|
||||
if not verify_viewer_token(viewer_token, int(row["id"])):
|
||||
raise AppError("auth_required")
|
||||
try:
|
||||
links = normalize_links(json.loads(row["links_json"] or "[]"))
|
||||
except json.JSONDecodeError:
|
||||
links = []
|
||||
if link_index >= len(links):
|
||||
raise AppError("invalid_request")
|
||||
link_for_drm = links[link_index]
|
||||
variants = link_for_drm.get("variants", [])
|
||||
if variant_index >= 0:
|
||||
if not isinstance(variants, list) or variant_index >= len(variants):
|
||||
raise AppError("invalid_request")
|
||||
variant = variants[variant_index]
|
||||
if not isinstance(variant, dict):
|
||||
raise AppError("invalid_request")
|
||||
link_for_drm = variant
|
||||
drm_configs = link_for_drm.get("drmConfigs", [])
|
||||
if not isinstance(drm_configs, list) or drm_index >= len(drm_configs):
|
||||
raise AppError("invalid_request")
|
||||
drm_config = drm_configs[drm_index]
|
||||
if not isinstance(drm_config, dict):
|
||||
raise AppError("invalid_request")
|
||||
if str(drm_config.get("drmType", "")).lower() != "fairplay":
|
||||
raise AppError("invalid_request")
|
||||
license_url = str(drm_config.get("licenseUrl", "")).strip()
|
||||
parsed = urlparse(license_url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise AppError("invalid_request")
|
||||
request_headers = {
|
||||
"User-Agent": self.headers.get("User-Agent", "StreamHall/1.0"),
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/octet-stream, application/json;q=0.9, */*;q=0.8",
|
||||
**parse_header_config(drm_config.get("licenseHeaders", "")),
|
||||
}
|
||||
upstream_payload: dict[str, str] = {
|
||||
"server_playback_context": base64.b64encode(spc_body).decode("ascii")
|
||||
}
|
||||
upstream_body = json.dumps(upstream_payload).encode("utf-8")
|
||||
try:
|
||||
req = Request(license_url, data=upstream_body, headers=request_headers, method="POST")
|
||||
with urlopen(req, timeout=20) as resp:
|
||||
content = resp.read(2 * 1024 * 1024)
|
||||
content_type = resp.headers.get("Content-Type") or "application/octet-stream"
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(len(content)))
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.end_headers()
|
||||
self.wfile.write(content)
|
||||
except HTTPError as exc:
|
||||
error_body = exc.read(4096) if hasattr(exc, "read") else b""
|
||||
status = HTTPStatus(exc.code) if exc.code in HTTPStatus._value2member_map_ else HTTPStatus.BAD_GATEWAY
|
||||
body = error_body or f"upstream HTTP {exc.code}".encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", exc.headers.get("Content-Type") or "text/plain; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("X-StreamHall-Upstream-Status", str(exc.code))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
except (URLError, TimeoutError, OSError) as exc:
|
||||
body = f"upstream request failed: {exc}".encode("utf-8")
|
||||
self.send_response(HTTPStatus.BAD_GATEWAY)
|
||||
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def api_widevine_license(self, query: dict[str, list[str]]) -> None:
|
||||
stream_ref = query.get("id", [""])[0]
|
||||
viewer_token = (self.headers.get("X-StreamHall-Viewer-Token", "") or query.get("vt", [""])[0]).strip()
|
||||
try:
|
||||
link_index = int(query.get("link", ["-1"])[0])
|
||||
drm_index = int(query.get("drm", ["-1"])[0])
|
||||
except ValueError:
|
||||
raise AppError("invalid_request", "invalid link or drm index")
|
||||
if link_index < 0 or drm_index < 0:
|
||||
raise AppError("invalid_request", "missing link or drm index")
|
||||
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
if length <= 0 or length > 1024 * 1024:
|
||||
raise AppError("invalid_request", f"invalid challenge length: {length}")
|
||||
challenge_body = self.rfile.read(length)
|
||||
with db() as conn:
|
||||
row = find_stream(conn, stream_ref)
|
||||
if not row or int(row["is_enabled"] or 0) != 1:
|
||||
raise AppError("stream_not_found_or_disabled")
|
||||
if not verify_viewer_token(viewer_token, int(row["id"])):
|
||||
raise AppError("auth_required", "missing or invalid viewer token")
|
||||
try:
|
||||
links = normalize_links(json.loads(row["links_json"] or "[]"))
|
||||
except json.JSONDecodeError:
|
||||
links = []
|
||||
if link_index >= len(links):
|
||||
raise AppError("invalid_request", f"link index out of range: {link_index}/{len(links)}")
|
||||
drm_configs = links[link_index].get("drmConfigs", [])
|
||||
if not isinstance(drm_configs, list) or drm_index >= len(drm_configs):
|
||||
raise AppError("invalid_request", f"drm index out of range: {drm_index}")
|
||||
drm_config = drm_configs[drm_index]
|
||||
if not isinstance(drm_config, dict):
|
||||
raise AppError("invalid_request", "invalid drm config")
|
||||
if str(drm_config.get("drmType", "")).lower() != "widevine":
|
||||
raise AppError("invalid_request", f"selected drm is not widevine: {drm_config.get('drmType', '')}")
|
||||
license_url = str(drm_config.get("licenseUrl", "")).strip()
|
||||
parsed = urlparse(license_url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise AppError("invalid_request", "invalid widevine license url")
|
||||
request_headers = {
|
||||
"User-Agent": self.headers.get("User-Agent", "StreamHall/1.0"),
|
||||
"Content-Type": self.headers.get("Content-Type", "application/octet-stream"),
|
||||
"Accept": "application/octet-stream, application/json;q=0.9, */*;q=0.8",
|
||||
**parse_header_config(drm_config.get("licenseHeaders", "")),
|
||||
}
|
||||
for blocked in ("host", "content-length", "connection", "origin", "referer", "cookie"):
|
||||
request_headers.pop(blocked, None)
|
||||
request_headers.pop(blocked.title(), None)
|
||||
try:
|
||||
req = Request(license_url, data=challenge_body, headers=request_headers, method="POST")
|
||||
with urlopen(req, timeout=20) as resp:
|
||||
content = resp.read(2 * 1024 * 1024)
|
||||
content_type = resp.headers.get("Content-Type") or "application/octet-stream"
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(len(content)))
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.end_headers()
|
||||
self.wfile.write(content)
|
||||
except HTTPError as exc:
|
||||
error_body = exc.read(4096) if hasattr(exc, "read") else b""
|
||||
status = HTTPStatus(exc.code) if exc.code in HTTPStatus._value2member_map_ else HTTPStatus.BAD_GATEWAY
|
||||
body = error_body or f"upstream HTTP {exc.code}".encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", exc.headers.get("Content-Type") or "text/plain; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("X-StreamHall-Upstream-Status", str(exc.code))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
except (URLError, TimeoutError, OSError) as exc:
|
||||
body = f"upstream request failed: {exc}".encode("utf-8")
|
||||
self.send_response(HTTPStatus.BAD_GATEWAY)
|
||||
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def api_viewer_start(self) -> None:
|
||||
body = self.read_json()
|
||||
viewer_token = str(body.get("viewerToken", "")).strip()
|
||||
@@ -2036,6 +2581,11 @@ class StreamHallHandler(BaseHTTPRequestHandler):
|
||||
result = probe_stream_url(body.get("url", ""), body.get("type", ""), body.get("drmConfigs", body.get("drm_configs", [])))
|
||||
self.send_json({"status": "success", "data": result})
|
||||
|
||||
def api_discover_drm(self) -> None:
|
||||
body = self.read_json()
|
||||
result = discover_drm_from_url(body.get("url", ""), body.get("type", ""))
|
||||
self.send_json({"status": "success", "data": result})
|
||||
|
||||
def api_check_stream(self) -> None:
|
||||
body = self.read_json()
|
||||
stream_id = int(body.get("id", 0) or 0)
|
||||
@@ -2744,6 +3294,11 @@ class StreamHallHandler(BaseHTTPRequestHandler):
|
||||
if not hmac.compare_digest(token, hls_proxy_url_token(target_url)):
|
||||
self.send_error(HTTPStatus.FORBIDDEN)
|
||||
return
|
||||
try:
|
||||
reject_private_http_url(target_url)
|
||||
except AppError:
|
||||
self.send_error(HTTPStatus.FORBIDDEN)
|
||||
return
|
||||
|
||||
try:
|
||||
req = Request(target_url, headers={"User-Agent": "StreamHall/1.0"})
|
||||
@@ -2780,6 +3335,58 @@ class StreamHallHandler(BaseHTTPRequestHandler):
|
||||
except (URLError, TimeoutError, OSError):
|
||||
self.send_error(HTTPStatus.BAD_GATEWAY)
|
||||
|
||||
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)
|
||||
return
|
||||
token, encoded_url = parts[2], parts[3]
|
||||
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)):
|
||||
self.send_error(HTTPStatus.FORBIDDEN)
|
||||
return
|
||||
try:
|
||||
reject_private_http_url(target_url)
|
||||
except AppError:
|
||||
self.send_error(HTTPStatus.FORBIDDEN)
|
||||
return
|
||||
|
||||
try:
|
||||
req = Request(target_url, headers={"User-Agent": "StreamHall/1.0"})
|
||||
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"
|
||||
body = resp.read(2 * 1024 * 1024)
|
||||
text = decode_probe_text(body)
|
||||
is_manifest = parsed.path.lower().endswith(".m3u8") or "mpegurl" in content_type.lower() or text.lstrip().startswith("#EXTM3U")
|
||||
if not is_manifest:
|
||||
self.send_error(HTTPStatus.BAD_REQUEST)
|
||||
return
|
||||
content = rewrite_external_hls_manifest_direct(text, 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)
|
||||
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)
|
||||
|
||||
def api_list_videos(self) -> None:
|
||||
qs = parse_qs(urlparse(self.path).query)
|
||||
dir_index_str = (qs.get("dir_index", [None])[0] or "").strip()
|
||||
|
||||
Reference in New Issue
Block a user