2 Commits

Author SHA1 Message Date
Stardream 8e1ed10ba5 feat: add HLS proxy modes and improve source editor
Build and Push Docker Image / build (push) Successful in 10s
- per-source HLS proxy mode added with auto, direct, full proxy, and manifest-only options
- manifest-only proxy route rewrites playlist media URLs to upstream absolute URLs
- HLS proxy routes reject private and local network targets
- source editor now shows aligned per-field labels instead of the old format hint
- source proxy mode is saved with each playback source and applied to DRM-specific playback URLs
- mobile source editor adds compact ordering controls for long view-angle forms
- mobile drag handling improved for stream rows and source rows
- README and README.zh-CN document HLS proxy mode tradeoffs
2026-05-25 18:22:46 +10:00
Stardream 8f63f20fdf feat: expand DRM playback and discovery support
Build and Push Docker Image / build (push) Successful in 13s
- native FairPlay HLS playback added for iOS/Safari with same-origin license proxy
- Widevine license proxy added to avoid browser-side CORS license failures
- DRM-specific playback URLs added for per-browser MPD/HLS variants
- admin DRM auto-discovery added for DASH Widevine and HLS FairPlay manifests
- DRM stream probing now checks DRM-specific playback URLs
- Shaka quality selector deduplicates audio variants while preserving same-resolution bitrates
- DRM proxy source and config index matching stabilized after source reordering
- DRM manifest discovery rejects private, loopback, link-local, and redirect targets
- Android Telegram WebView Widevine handling now probes key-system capability before blocking
2026-05-25 02:19:30 +10:00
5 changed files with 1625 additions and 114 deletions
+14 -3
View File
@@ -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
View File
@@ -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)
+614 -51
View File
@@ -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} &nbsp;·&nbsp; 支持 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} &nbsp;·&nbsp; 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 || '',
const readDrmConfig = (drmRow) => {
const drmType = drmRow.querySelector('.l-drm-type')?.value || '';
const config = {
drmType,
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);
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>
<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>
<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));
});
row.querySelector('.remove-drm-config-btn')?.addEventListener('click', () => {
row.remove();
input.addEventListener('input', () => {
syncMainPlaybackState();
refreshProbe();
});
input.addEventListener('blur', () => {
syncMainPlaybackState();
scheduleLinkRowCheck(div, 0);
});
});
row.querySelector('.l-drm-playback-type')?.addEventListener('change', refreshProbe);
row.querySelector('.remove-drm-config-btn')?.addEventListener('click', () => {
row.remove();
syncMainPlaybackState();
refreshProbe();
});
return row;
};
const fillInputIfAvailable = (input, value, overwrite = false) => {
if (!input || !value) return;
if (overwrite || !input.value.trim()) input.value = value;
};
const applyDiscoveredDrm = (items, sourceUrl = '', sourceType = '', preferredRow = null) => {
const supported = (Array.isArray(items) ? items : [])
.filter(item => item && (item.drmType === 'widevine' || item.drmType === 'fairplay'));
if (!supported.length) return { applied: 0, partial: false };
const mainUrl = div.querySelector('.l-url')?.value.trim() || '';
const mainType = div.querySelector('.l-type')?.value || '';
let applied = 0;
let partial = false;
supported.forEach(item => {
partial = partial || !!item.partial || !item.licenseUrl || (item.drmType === 'fairplay' && !item.certificateUrl);
let target = null;
if (preferredRow?.isConnected) {
const preferredType = preferredRow.querySelector('.l-drm-type')?.value || '';
if (!preferredType || preferredType === item.drmType) target = preferredRow;
}
if (!target) target = Array.from(drmList.querySelectorAll('.drm-config-row')).find(row => {
const rowType = row.querySelector('.l-drm-type')?.value || '';
return rowType === item.drmType;
});
if (!target) target = addDrmConfigUI({ drmType: item.drmType });
const typeSelect = target.querySelector('.l-drm-type');
if (typeSelect) {
typeSelect.value = item.drmType;
typeSelect.dispatchEvent(new Event('change'));
}
fillInputIfAvailable(target.querySelector('.l-license-url'), item.licenseUrl || '', true);
fillInputIfAvailable(target.querySelector('.l-certificate-url'), item.certificateUrl || '', true);
fillInputIfAvailable(target.querySelector('.l-pssh'), item.pssh || '', true);
const itemPlaybackUrl = item.playbackUrl || sourceUrl || '';
if (itemPlaybackUrl && itemPlaybackUrl !== mainUrl) {
fillInputIfAvailable(target.querySelector('.l-drm-playback-url'), itemPlaybackUrl, true);
}
const itemPlaybackType = item.playbackType || sourceType || '';
const playbackTypeSelect = target.querySelector('.l-drm-playback-type');
if (playbackTypeSelect && itemPlaybackType && itemPlaybackUrl !== mainUrl) {
playbackTypeSelect.value = itemPlaybackType;
} else if (playbackTypeSelect && mainType && itemPlaybackUrl === mainUrl) {
playbackTypeSelect.value = '';
}
applied += 1;
});
syncMainPlaybackState();
scheduleLinkRowCheck(div, 0);
return { applied, partial };
};
const discoverDrmForRow = async () => {
const btn = div.querySelector('.discover-drm-btn');
const drmTargets = getRowDrmPlaybackTargets(div);
const mainTarget = {
url: div.querySelector('.l-url')?.value.trim() || '',
type: div.querySelector('.l-type')?.value || '',
drmRow: null
};
const targets = drmTargets.length ? drmTargets : (mainTarget.url ? [mainTarget] : []);
if (!targets.length) {
showToast(t('probe.no_info'), 'error');
return;
}
const original = btn?.textContent || '';
if (btn) {
btn.disabled = true;
btn.textContent = '...';
}
let total = 0;
let partial = false;
const failedMessages = [];
try {
for (const target of targets) {
try {
const res = await apiCall('discover_drm', {
url: target.url,
type: inferLinkType(target.url, target.type)
});
const result = applyDiscoveredDrm(res.data?.drmConfigs || [], target.url, res.data?.type || target.type, target.row || target.drmRow);
total += result.applied || 0;
partial = partial || !!result.partial;
} catch (e) {
const url = target.url || '';
const shortUrl = url.length > 72 ? `${url.slice(0, 69)}...` : url;
failedMessages.push(`${shortUrl}: ${e.message || e}`);
}
}
div.querySelector('.link-drm-config')?.setAttribute('open', '');
if (total && failedMessages.length) {
showToast(t('msg.drm_partial_failed').replace('{count}', failedMessages.length), 'info');
} else if (total) {
showToast(partial ? t('msg.drm_partial') : t('msg.drm_discovered'), partial ? 'info' : 'success');
} else if (failedMessages.length) {
showToast(`${t('msg.drm_discover_failed')}: ${failedMessages[0]}`, 'error');
} else {
showToast(t('msg.drm_not_found'), 'error');
}
} catch (e) {
showToast(e.message, 'error');
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = original || t('btn.discover_drm');
}
}
};
if (normalizedDrmConfigs.length) {
normalizedDrmConfigs.forEach(config => addDrmConfigUI(config));
} else {
addDrmConfigUI();
}
syncMainPlaybackState();
div.querySelector('.discover-drm-btn')?.addEventListener('click', discoverDrmForRow);
div.querySelector('.add-drm-config-btn')?.addEventListener('click', () => {
div.querySelector('.link-drm-config')?.setAttribute('open', '');
addDrmConfigUI();
@@ -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
View File
@@ -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);
}
+611 -4
View File
@@ -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()