From 8f63f20fdfc269364acf0a29c8e30272df6167b5 Mon Sep 17 00:00:00 2001 From: Stardream Date: Mon, 25 May 2026 02:19:30 +1000 Subject: [PATCH] feat: expand DRM playback and discovery support - 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 --- public/admin.html | 303 ++++++++++++++++++++++++--- public/player.html | 401 ++++++++++++++++++++++++++++++++---- server.py | 500 ++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 1134 insertions(+), 70 deletions(-) diff --git a/public/admin.html b/public/admin.html index 806bb44..1a44647 100644 --- a/public/admin.html +++ b/public/admin.html @@ -446,6 +446,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 +488,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; @@ -1981,6 +1995,7 @@ 'btn.add_stream': '+ 添加', 'btn.reset_filter': '清空', 'btn.add_link': '+ 添加视角', + 'btn.discover_drm': '自动识别 DRM', 'btn.save': '保 存', 'btn.cancel': '取 消', 'btn.delete': '删除', @@ -2004,12 +2019,14 @@ 'ph.nav_url': '#stream-list 或 https://example.com', 'ph.link_name': '视角名', 'ph.link_url': '链接 (m3u8/flv/mpd)', + '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 @@ -2077,6 +2094,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': '确定删除这个推流码映射?', @@ -2381,6 +2406,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 +2430,14 @@ 'ph.nav_url': '#stream-list or https://example.com', 'ph.link_name': 'Source name', 'ph.link_url': 'URL (m3u8/flv/mpd)', + '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 @@ -2477,6 +2505,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?', @@ -2931,13 +2967,44 @@ if (state !== 'is-checking') el.dataset.probeComplete = '1'; }; - const getRowDrmConfigs = (row) => Array.from(row.querySelectorAll('.drm-config-row')).map(drmRow => ({ - drmType: drmRow.querySelector('.l-drm-type')?.value || '', - licenseUrl: drmRow.querySelector('.l-license-url')?.value.trim() || '', - certificateUrl: drmRow.querySelector('.l-certificate-url')?.value.trim() || '', - licenseHeaders: drmRow.querySelector('.l-license-headers')?.value.trim() || '', - pssh: drmRow.querySelector('.l-pssh')?.value.trim() || '' - })).filter(config => config.drmType && config.licenseUrl); + const readDrmConfig = (drmRow) => { + const drmType = drmRow.querySelector('.l-drm-type')?.value || ''; + const config = { + drmType, + licenseUrl: drmRow.querySelector('.l-license-url')?.value.trim() || '', + licenseHeaders: drmRow.querySelector('.l-license-headers')?.value.trim() || '', + playbackUrl: drmRow.querySelector('.l-drm-playback-url')?.value.trim() || '', + playbackType: drmRow.querySelector('.l-drm-playback-type')?.value || '' + }; + if (drmType === 'fairplay') { + config.certificateUrl = drmRow.querySelector('.l-certificate-url')?.value.trim() || ''; + } + if (drmType === 'widevine') { + config.pssh = drmRow.querySelector('.l-pssh')?.value.trim() || ''; + } + return config; + }; + + const getRowDrmConfigs = (row) => Array.from(row.querySelectorAll('.drm-config-row')) + .map(readDrmConfig) + .filter(config => config.drmType && config.licenseUrl); + + const getRowDrmPlaybackTargets = (row) => Array.from(row.querySelectorAll('.drm-config-row')).map(drmRow => ({ + row: drmRow, + url: drmRow.querySelector('.l-drm-playback-url')?.value.trim() || '', + type: drmRow.querySelector('.l-drm-playback-type')?.value || '', + drmType: drmRow.querySelector('.l-drm-type')?.value || '' + })).filter(item => item.url); + + const getRowProbeTarget = (row) => { + const drmPlayback = getRowDrmPlaybackTargets(row)[0]; + if (drmPlayback) return { url: drmPlayback.url, type: drmPlayback.type || '', drmRow: drmPlayback.row }; + return { + url: row.querySelector('.l-url')?.value.trim() || '', + type: row.querySelector('.l-type')?.value || '', + drmRow: null + }; + }; const probeUrl = async (url, type = '', drmConfigs = []) => { const res = await apiCall('check_stream_url', { @@ -2950,14 +3017,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 +3046,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 +3057,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); }); }; @@ -3893,21 +3958,59 @@ }); }, 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, + url: fallbackUrl, key: row.querySelector('.l-key').value.trim(), clearkey: row.querySelector('.l-clearkey').value.trim(), drmConfigs, @@ -3915,7 +4018,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); @@ -3970,6 +4075,7 @@ ${t('drm.config')} @@ -3977,6 +4083,17 @@ `; 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 +4105,16 @@ - - - + + + + + `; @@ -4009,6 +4133,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 +4150,135 @@ refreshProbe(); }); row.querySelectorAll('input, textarea').forEach(input => { - input.addEventListener('input', refreshProbe); - input.addEventListener('blur', () => scheduleLinkRowCheck(div, 0)); + input.addEventListener('input', () => { + syncMainPlaybackState(); + refreshProbe(); + }); + input.addEventListener('blur', () => { + syncMainPlaybackState(); + scheduleLinkRowCheck(div, 0); + }); }); + row.querySelector('.l-drm-playback-type')?.addEventListener('change', refreshProbe); row.querySelector('.remove-drm-config-btn')?.addEventListener('click', () => { row.remove(); + syncMainPlaybackState(); refreshProbe(); }); + return row; }; - normalizedDrmConfigs.forEach(config => addDrmConfigUI(config)); + const fillInputIfAvailable = (input, value, overwrite = false) => { + if (!input || !value) return; + if (overwrite || !input.value.trim()) input.value = value; + }; + const applyDiscoveredDrm = (items, sourceUrl = '', sourceType = '', preferredRow = null) => { + const supported = (Array.isArray(items) ? items : []) + .filter(item => item && (item.drmType === 'widevine' || item.drmType === 'fairplay')); + if (!supported.length) return { applied: 0, partial: false }; + const mainUrl = div.querySelector('.l-url')?.value.trim() || ''; + const mainType = div.querySelector('.l-type')?.value || ''; + let applied = 0; + let partial = false; + supported.forEach(item => { + partial = partial || !!item.partial || !item.licenseUrl || (item.drmType === 'fairplay' && !item.certificateUrl); + let target = null; + if (preferredRow?.isConnected) { + const preferredType = preferredRow.querySelector('.l-drm-type')?.value || ''; + if (!preferredType || preferredType === item.drmType) target = preferredRow; + } + if (!target) target = Array.from(drmList.querySelectorAll('.drm-config-row')).find(row => { + const rowType = row.querySelector('.l-drm-type')?.value || ''; + return rowType === item.drmType; + }); + if (!target) target = addDrmConfigUI({ drmType: item.drmType }); + const typeSelect = target.querySelector('.l-drm-type'); + if (typeSelect) { + typeSelect.value = item.drmType; + typeSelect.dispatchEvent(new Event('change')); + } + fillInputIfAvailable(target.querySelector('.l-license-url'), item.licenseUrl || '', true); + fillInputIfAvailable(target.querySelector('.l-certificate-url'), item.certificateUrl || '', true); + fillInputIfAvailable(target.querySelector('.l-pssh'), item.pssh || '', true); + const itemPlaybackUrl = item.playbackUrl || sourceUrl || ''; + if (itemPlaybackUrl && itemPlaybackUrl !== mainUrl) { + fillInputIfAvailable(target.querySelector('.l-drm-playback-url'), itemPlaybackUrl, true); + } + const itemPlaybackType = item.playbackType || sourceType || ''; + const playbackTypeSelect = target.querySelector('.l-drm-playback-type'); + if (playbackTypeSelect && itemPlaybackType && itemPlaybackUrl !== mainUrl) { + playbackTypeSelect.value = itemPlaybackType; + } else if (playbackTypeSelect && mainType && itemPlaybackUrl === mainUrl) { + playbackTypeSelect.value = ''; + } + applied += 1; + }); + syncMainPlaybackState(); + scheduleLinkRowCheck(div, 0); + return { applied, partial }; + }; + const discoverDrmForRow = async () => { + const btn = div.querySelector('.discover-drm-btn'); + const drmTargets = getRowDrmPlaybackTargets(div); + const mainTarget = { + url: div.querySelector('.l-url')?.value.trim() || '', + type: div.querySelector('.l-type')?.value || '', + drmRow: null + }; + const targets = drmTargets.length ? drmTargets : (mainTarget.url ? [mainTarget] : []); + if (!targets.length) { + showToast(t('probe.no_info'), 'error'); + return; + } + const original = btn?.textContent || ''; + if (btn) { + btn.disabled = true; + btn.textContent = '...'; + } + let total = 0; + let partial = false; + const failedMessages = []; + try { + for (const target of targets) { + try { + const res = await apiCall('discover_drm', { + url: target.url, + type: inferLinkType(target.url, target.type) + }); + const result = applyDiscoveredDrm(res.data?.drmConfigs || [], target.url, res.data?.type || target.type, target.row || target.drmRow); + total += result.applied || 0; + partial = partial || !!result.partial; + } catch (e) { + const url = target.url || ''; + const shortUrl = url.length > 72 ? `${url.slice(0, 69)}...` : url; + failedMessages.push(`${shortUrl}: ${e.message || e}`); + } + } + div.querySelector('.link-drm-config')?.setAttribute('open', ''); + if (total && failedMessages.length) { + showToast(t('msg.drm_partial_failed').replace('{count}', failedMessages.length), 'info'); + } else if (total) { + showToast(partial ? t('msg.drm_partial') : t('msg.drm_discovered'), partial ? 'info' : 'success'); + } else if (failedMessages.length) { + showToast(`${t('msg.drm_discover_failed')}: ${failedMessages[0]}`, 'error'); + } else { + showToast(t('msg.drm_not_found'), 'error'); + } + } catch (e) { + showToast(e.message, 'error'); + } finally { + if (btn) { + btn.disabled = false; + btn.textContent = original || t('btn.discover_drm'); + } + } + }; + if (normalizedDrmConfigs.length) { + normalizedDrmConfigs.forEach(config => addDrmConfigUI(config)); + } else { + addDrmConfigUI(); + } + syncMainPlaybackState(); + div.querySelector('.discover-drm-btn')?.addEventListener('click', discoverDrmForRow); div.querySelector('.add-drm-config-btn')?.addEventListener('click', () => { div.querySelector('.link-drm-config')?.setAttribute('open', ''); addDrmConfigUI(); diff --git a/public/player.html b/public/player.html index 3ec7191..20caf17 100644 --- a/public/player.html +++ b/public/player.html @@ -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); } diff --git a/server.py b/server.py index 0681581..6e455dd 100644 --- a/server.py +++ b/server.py @@ -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 @@ -500,6 +502,22 @@ def add_playback_urls(links: list[dict[str, object]]) -> list[dict[str, object]] parsed = urlparse(raw_url) if is_hls_link(item) and parsed.scheme in ("http", "https"): item["playback_url"] = hls_proxy_path(raw_url) + 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} + 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) + prepared_configs.append(config_item) + item["drmConfigs"] = prepared_configs prepared.append(item) return prepared @@ -538,6 +556,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 +591,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 +601,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 +621,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 @@ -607,6 +656,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 +806,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 " set[str]: normalized: set[str] = set() if not isinstance(configs, list): @@ -1459,6 +1770,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 +1828,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 +2015,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 +2529,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)