From 8e1ed10ba5d7f2f1a673612b20b2de71801f1b2a Mon Sep 17 00:00:00 2001 From: Stardream Date: Mon, 25 May 2026 18:22:46 +1000 Subject: [PATCH] feat: add HLS proxy modes and improve source editor - 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 --- README.md | 17 +- README.zh-CN.md | 17 +- public/admin.html | 386 +++++++++++++++++++++++++++++++++++++++++----- server.py | 121 ++++++++++++++- 4 files changed, 494 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index e5a9cef..09a1cf2 100644 --- a/README.md +++ b/README.md @@ -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/`) 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
@@ -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 | +
[![][back-to-top]](#readme-top) diff --git a/README.zh-CN.md b/README.zh-CN.md index 77a292f..7e55d65 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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/`),真实推流码不出现在公开地址中 - **VOD 点播 / 视频服务** - 带 HMAC 签名的 `/video/` URL,支持 HTTP Range 请求(可 seek);文件浏览器中可直接将视频文件或文件夹发布为归档直播 -- **HLS 代理** - 带签名验证的 `/proxy/hls/` 路由,解决跨域 HLS 播放问题 +- **HLS 代理模式** - 每个播放源可选择直连、完整代理或仅代理 Manifest,在源地址暴露、跨域兼容和服务器带宽之间自行取舍 - **API 密钥鉴权** - 在后台生成 Token,可通过 API 密钥对所有管理及统计接口进行程序化访问 -- **移动端适配** - 管理后台侧边栏、文件浏览器行、推流目录侧边栏均可在窄屏设备上自适应折叠 +- **移动端适配** - 管理后台侧边栏、视角编辑器、文件浏览器行、推流目录侧边栏均可在窄屏设备上自适应折叠
@@ -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 仍会出现在浏览器网络请求中 | +
[![][back-to-top]](#readme-top) diff --git a/public/admin.html b/public/admin.html index 1a44647..557d320 100644 --- a/public/admin.html +++ b/public/admin.html @@ -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; } @@ -995,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; } @@ -1238,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; } @@ -1964,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': '安全设置', @@ -2019,6 +2100,7 @@ '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"}', @@ -2036,7 +2118,6 @@ 'hint.tg_vars': '可用变量:{title}、{url}、{site_title}、{time}、{status}、{stream_id}、{public_id}、{link_name}、{source_url}  ·  支持 HTML', 'hint.obs_rtmp': '推流端使用 RTMP 推流到服务器,播放器使用 SRS 输出的 HLS 或 FLV 地址。', 'hint.obs_routes': '新增自定义推流码后,系统会生成不可反推的公开 HLS/FLV 地址。推流端仍使用真实推流码推流。', - 'hint.links': '填写格式:视角名称 | 类型 | 播放链接 | Key Override | ClearKey 信息', 'drm.config': 'DRM 播放授权', 'drm.none': '无 DRM', 'hint.footer_example': '示例:## 联系方式\n- [项目主页](https://example.com)', @@ -2111,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', @@ -2375,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', @@ -2430,6 +2521,7 @@ '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"}', @@ -2447,7 +2539,6 @@ 'hint.tg_vars': 'Variables: {title}, {url}, {site_title}, {time}, {status}, {stream_id}, {public_id}, {link_name}, {source_url}  ·  HTML supported', 'hint.obs_rtmp': 'The encoder pushes via RTMP to the server; the player uses HLS or FLV from SRS.', 'hint.obs_routes': 'Adding a custom stream key generates a public HLS/FLV URL that cannot be reverse-engineered.', - 'hint.links': 'Format: name | type | URL | Key Override | ClearKey', 'drm.config': 'DRM license', 'drm.none': 'No DRM', 'hint.footer_example': 'Example: ## Contact\n- [Project page](https://example.com)', @@ -2522,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', @@ -3890,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; @@ -3919,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); }); }); } @@ -4010,6 +4206,7 @@ return { name: row.querySelector('.l-name').value, type: row.querySelector('.l-type').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(), @@ -4044,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) @@ -4057,20 +4336,48 @@ div.className = 'link-row'; div.draggable = true; div.innerHTML = ` - - - -
- -
+ + + + + + + - -
- + `; els.linksContainer.appendChild(div); const drmList = div.querySelector('.link-drm-list'); @@ -4287,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; } @@ -4317,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(); @@ -4437,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(); @@ -4516,7 +4833,6 @@

视角配置

-

填写格式:视角名称 | 类型 | 播放链接 | Key Override | ClearKey 信息

diff --git a/server.py b/server.py index 6e455dd..6333b36 100644 --- a/server.py +++ b/server.py @@ -92,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) @@ -457,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"}) @@ -499,9 +522,10 @@ 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]] = [] @@ -513,9 +537,7 @@ def add_playback_urls(links: list[dict[str, object]]) -> list[dict[str, object]] drm_playback_type = str(config_item.get("playbackType") or "") if drm_playback_url: probe_item = {"url": drm_playback_url, "type": drm_playback_type} - parsed_drm_url = urlparse(drm_playback_url) - if is_hls_link(probe_item) and parsed_drm_url.scheme in ("http", "https"): - config_item["playback_url"] = hls_proxy_path(drm_playback_url) + 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) @@ -641,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( @@ -648,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, @@ -1635,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: @@ -1661,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 @@ -1677,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 @@ -3242,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"}) @@ -3278,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// + # 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()