5 Commits

Author SHA1 Message Date
Stardream c4c3e6f445 fix: instant cookie probe, default Auto quality, consistent control order
Build and Push Docker Image / build (push) Successful in 17s
- Trigger a stream probe immediately when the upstream cookie field changes,
  instead of waiting for the next monitor cycle
- Remove the probeActive guard in checkLinkRow so a cookie-triggered probe is no
  longer dropped when it lands while the URL probe is still in flight (the
  probeToken check already handles superseded results)
- Default the HLS resolution menu to its "Auto" (ABR) entry rather than landing
  on a fixed (lowest) rung; start hls.js in auto level mode
- Keep source (视角) selector on the left and resolution on the right in both
  live and archive players, which previously rendered in opposite order
2026-06-02 00:59:55 +10:00
Stardream 42ce5d2684 docs: document upstream cookie, HLS proxy timeout, and TG notification options
Build and Push Docker Image / build (push) Successful in 18s
2026-05-31 01:30:14 +10:00
Stardream 6d39c512d7 feat: upstream cookie proxy, HLS connection pool, multi-link TG notifications
Build and Push Docker Image / build (push) Successful in 15s
- Add upstream Cookie support for HLS full-proxy mode (CloudFront signed cookies
  stored server-side as opaque tokens; never exposed in proxy URLs)
- Add HTTP connection pool for HLS proxy upstream requests to avoid per-request
  TLS handshake overhead; introduce HLS_PROXY_TIMEOUT separate from probe timeout
- Add per-link TG start notification with 30s merge window: each newly-live link
  fires independently, links that come online within the window are merged into
  one message with names joined by ' & '
- Fix TG reconnect grace period (TG_RECONNECT_GRACE_SECS=60): suppress both
  stop and start notifications for brief RTMP disconnects
- Fix stream probe to check all links for TG-enabled streams; non-TG streams
  still stop at first valid link to avoid unnecessary probes
- Filter high-frequency HTTP access log entries (HLS segments, heartbeat, etc.)
- Add json-file logging driver config to docker-compose for reliable log access
2026-05-31 01:16:48 +10:00
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
6 changed files with 2135 additions and 209 deletions
+20 -4
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
- **Telegram notifications** - Per-stream push messages on stream start and stop; each source going live fires its own notification, simultaneous go-lives within a configurable window are merged into one message, and brief RTMP reconnects within a grace period are suppressed to avoid spurious stop/start pairs
- **Stream push** - Local file browser with per-file and per-folder RTMP push management; multi-file folder push with independent stream keys; inline push status and detail modal; remote RTMP push config for external encoders; hidden HLS route proxy (`/h/<slug>`) so real stream keys are never exposed publicly
- **VOD / file serving** - Signed `/video/` URLs with HTTP Range support (seek-capable); publish any local video file or folder as an archive stream directly from the file browser
- **HLS proxy** - Signed `/proxy/hls/` routes for cross-origin HLS playback
- **HLS proxy modes** - Per-source direct, full proxy, or manifest-only proxy modes for balancing source URL exposure, CORS compatibility, and server bandwidth; full proxy supports upstream cookie forwarding for cookie-authenticated CDNs (e.g. CloudFront signed cookies), with the cookie stored server-side and never exposed in playback URLs
- **API key auth** - Generate per-key tokens in the admin panel for programmatic access to all admin and analytics endpoints
- **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">
@@ -149,8 +149,11 @@ Set these environment variables in `docker-compose.yml`:
| `TZ` | `UTC` | No | Container timezone, e.g. `Asia/Shanghai` |
| `SRS_HTTP_ORIGIN` | `http://srs:8080` | No | SRS HTTP playback base URL |
| `STREAM_PROBE_TIMEOUT` | `4` | No | Seconds before aborting a stream URL probe |
| `HLS_PROXY_TIMEOUT` | `15` | No | Seconds before aborting an upstream HLS manifest/segment proxy request |
| `STREAM_MONITOR_INTERVAL` | `10` | No | Seconds between stream liveness checks |
| `TELEGRAM_TIMEOUT` | `6` | No | Seconds before aborting a Telegram API call |
| `TG_RECONNECT_GRACE_SECS` | `60` | No | Grace period before sending a stop notification; absorbs brief RTMP reconnects (`0` disables) |
| `TG_START_MERGE_SECS` | `30` | No | Window for merging simultaneous link-online events into one start notification (`0` disables) |
| `RTMP_HOST` | `srs` | No | Hostname of the SRS container used for local push jobs |
| `VIDEOS_DIRS` | *(unset)* | No | Comma-separated list of directories exposed in the file browser. Optionally prefix each path with a label: `label:/app/path`. Multiple entries: `movies:/app/movies,shows:/app/shows` |
@@ -183,6 +186,19 @@ volumes:
- /your/media/path:/app/media/external
```
**HLS proxy modes**
Each stream source can choose how external HLS URLs are exposed to viewers:
| Mode | Behavior | Bandwidth impact |
|---|---|---|
| `Auto` | Backward-compatible default; external HLS uses the full proxy | StreamHall carries manifest and segment traffic |
| `Direct` | Player uses the source URL directly | Viewer traffic goes to the source server |
| `Full proxy` | Manifest, segments, maps, and keys are routed through `/proxy/hls/` | StreamHall carries all HLS media traffic |
| `Manifest only` | Only the playlist uses StreamHall; segment/key/map URLs are absolute source URLs | Low StreamHall bandwidth; final media URLs remain visible in browser network tools |
In **Full proxy** mode, a source can also set an **upstream cookie** for CDNs that require cookie-based authentication (e.g. CloudFront signed cookies). StreamHall forwards the cookie on every manifest and segment request. The cookie is stored server-side and referenced via a signed opaque token in proxy URLs, so it is never derivable from a playback or segment URL. Upstream proxy requests reuse pooled HTTP connections to avoid per-request TLS handshake overhead.
<div align="right">
[![][back-to-top]](#readme-top)
+20 -4
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 推送** - 可按直播单独配置,开播 / 关播自动发送通知
- **Telegram 推送** - 可按直播单独配置,开播 / 关播自动发送通知;多视角直播下每个视角上线都会独立推送,时间窗口内同时开播的视角会合并为一条消息,RTMP 短暂断线重连在宽限期内不会误触发关播 / 开播通知
- **推流配置** - 内置文件浏览器,支持单文件和文件夹 RTMP 推流管理;文件夹可同时向多个推流码批量推送独立任务;推流状态内联显示于文件行,详情弹窗提供实时时长、复制地址和停止操作;同时支持远端编码器 RTMP 推流配置;隐藏 HLS 路由代理(`/h/<slug>`),真实推流码不出现在公开地址中
- **VOD 点播 / 视频服务** - 带 HMAC 签名的 `/video/` URL,支持 HTTP Range 请求(可 seek);文件浏览器中可直接将视频文件或文件夹发布为归档直播
- **HLS 代理** - 带签名验证的 `/proxy/hls/` 路由,解决跨域 HLS 播放问题
- **HLS 代理模式** - 每个播放源可选择直连、完整代理或仅代理 Manifest,在源地址暴露、跨域兼容和服务器带宽之间自行取舍;完整代理模式支持上游 Cookie 转发,可对接依赖 Cookie 鉴权的 CDN(如 CloudFront 签名 Cookie),Cookie 仅存于服务端、不会暴露在播放地址中
- **API 密钥鉴权** - 在后台生成 Token,可通过 API 密钥对所有管理及统计接口进行程序化访问
- **移动端适配** - 管理后台侧边栏、文件浏览器行、推流目录侧边栏均可在窄屏设备上自适应折叠
- **移动端适配** - 管理后台侧边栏、视角编辑器、文件浏览器行、推流目录侧边栏均可在窄屏设备上自适应折叠
<div align="right">
@@ -149,8 +149,11 @@ python server.py
| `TZ` | `UTC` | 否 | 容器时区,如 `Asia/Shanghai` |
| `SRS_HTTP_ORIGIN` | `http://srs:8080` | 否 | SRS HTTP 播放基础地址 |
| `STREAM_PROBE_TIMEOUT` | `4` | 否 | 流地址探测超时秒数 |
| `HLS_PROXY_TIMEOUT` | `15` | 否 | 上游 HLS manifest / 分片代理请求超时秒数 |
| `STREAM_MONITOR_INTERVAL` | `10` | 否 | 流存活检测间隔秒数 |
| `TELEGRAM_TIMEOUT` | `6` | 否 | Telegram API 请求超时秒数 |
| `TG_RECONNECT_GRACE_SECS` | `60` | 否 | 发送关播通知前的宽限期,用于吸收短暂 RTMP 重连(`0` 关闭) |
| `TG_START_MERGE_SECS` | `30` | 否 | 合并同时上线视角为一条开播通知的时间窗口(`0` 关闭) |
| `RTMP_HOST` | `srs` | 否 | 本地推流任务使用的 SRS 容器主机名 |
| `VIDEOS_DIRS` | *(未设置)* | 否 | 文件浏览器暴露的目录,逗号分隔。可为每个路径加标签前缀:`label:/app/path`。多个示例:`movies:/app/movies,shows:/app/shows` |
@@ -183,6 +186,19 @@ 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 仍会出现在浏览器网络请求中 |
**完整代理**模式下,播放源还可以设置**上游 Cookie**,用于对接依赖 Cookie 鉴权的 CDN(如 CloudFront 签名 Cookie)。StreamHall 会在每次 manifest 和分片请求时附带该 Cookie。Cookie 仅存于服务端,并以签名后的不可逆 token 形式嵌入代理地址,因此无法从播放或分片 URL 中还原出来。上游代理请求会复用连接池中的持久 HTTP 连接,避免每次请求都重新进行 TLS 握手。
<div align="right">
[![][back-to-top]](#readme-top)
+5
View File
@@ -22,6 +22,11 @@ services:
- ./videos:/app/videos
# Mount additional directories as needed:
# - /local/path:/app/media/label
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
postgres:
image: postgres:16-alpine
+710 -77
View File
File diff suppressed because it is too large Load Diff
+386 -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 {
@@ -979,10 +1302,14 @@
const hls = new Hls({
enableSoftwareAES: !!keyOverride,
loader: keyOverride ? CustomLoader : Hls.DefaultConfig.loader,
startLevel: -1,
debug: false
});
hls.loadSource(url);
hls.attachMedia(video);
// Keep ABR / auto quality active from the start so playback isn't
// pinned to the lowest rung when the source (re)loads.
hls.on(Hls.Events.MANIFEST_PARSED, () => { hls.currentLevel = -1; });
art.hls = hls;
art.on('destroy', () => hls.destroy());
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
@@ -1006,11 +1333,29 @@
art.on('destroy', () => dash.destroy());
}
},
plugins: [
artplayerPluginHlsControl({
quality: { control: true, setting: true, getName: (l) => l.height + 'P', title: t('quality') }
})
]
plugins: hlsControlPlugins
});
// The hls-control plugin labels its quality menu from hls.currentLevel, which
// is whatever rung hls.js happens to be on when the player becomes ready
// (often the lowest). Re-assert auto mode and refresh the menu so the default
// selection shows "Auto" rather than the lowest resolution.
playerInstance.on('ready', () => {
const root = playerInstance.template?.$player;
// Default the resolution menu to its "Auto" (ABR) entry instead of a fixed
// rung. The plugin tags each entry with data-value; Auto is value -1. Clicking
// it runs the plugin's own onSelect, which puts hls.js in auto mode and marks
// Auto as the selected item in both the control bar and the settings panel.
const autoItem = root?.querySelector('.art-control-hls-quality .art-selector-item[data-value="-1"]');
if (autoItem) autoItem.click();
// The native source (视角) control and the plugin resolution control get
// inserted in opposite DOM order under live vs archive mode, so their
// left/right positions flip. Force the archive layout in both: source
// selector on the left, resolution on the right.
const sourceCtrl = root?.querySelector('.art-control-quality');
const resCtrl = root?.querySelector('.art-control-hls-quality');
if (sourceCtrl && resCtrl && sourceCtrl.parentNode === resCtrl.parentNode) {
resCtrl.parentNode.insertBefore(sourceCtrl, resCtrl);
}
});
startPlaybackMonitor(data, password);
}
+994 -83
View File
File diff suppressed because it is too large Load Diff