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
This commit is contained in:
Stardream
2026-05-25 02:19:30 +10:00
parent 2b0aaf3a5d
commit 8f63f20fdf
3 changed files with 1134 additions and 70 deletions
+275 -28
View File
@@ -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 || '',
const readDrmConfig = (drmRow) => {
const drmType = drmRow.querySelector('.l-drm-type')?.value || '';
const config = {
drmType,
licenseUrl: drmRow.querySelector('.l-license-url')?.value.trim() || '',
certificateUrl: drmRow.querySelector('.l-certificate-url')?.value.trim() || '',
licenseHeaders: drmRow.querySelector('.l-license-headers')?.value.trim() || '',
pssh: drmRow.querySelector('.l-pssh')?.value.trim() || ''
})).filter(config => config.drmType && config.licenseUrl);
playbackUrl: drmRow.querySelector('.l-drm-playback-url')?.value.trim() || '',
playbackType: drmRow.querySelector('.l-drm-playback-type')?.value || ''
};
if (drmType === 'fairplay') {
config.certificateUrl = drmRow.querySelector('.l-certificate-url')?.value.trim() || '';
}
if (drmType === 'widevine') {
config.pssh = drmRow.querySelector('.l-pssh')?.value.trim() || '';
}
return config;
};
const getRowDrmConfigs = (row) => Array.from(row.querySelectorAll('.drm-config-row'))
.map(readDrmConfig)
.filter(config => config.drmType && config.licenseUrl);
const getRowDrmPlaybackTargets = (row) => Array.from(row.querySelectorAll('.drm-config-row')).map(drmRow => ({
row: drmRow,
url: drmRow.querySelector('.l-drm-playback-url')?.value.trim() || '',
type: drmRow.querySelector('.l-drm-playback-type')?.value || '',
drmType: drmRow.querySelector('.l-drm-type')?.value || ''
})).filter(item => item.url);
const getRowProbeTarget = (row) => {
const drmPlayback = getRowDrmPlaybackTargets(row)[0];
if (drmPlayback) return { url: drmPlayback.url, type: drmPlayback.type || '', drmRow: drmPlayback.row };
return {
url: row.querySelector('.l-url')?.value.trim() || '',
type: row.querySelector('.l-type')?.value || '',
drmRow: null
};
};
const probeUrl = async (url, type = '', drmConfigs = []) => {
const res = await apiCall('check_stream_url', {
@@ -2950,14 +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 @@
<summary>${t('drm.config')}</summary>
<div class="link-drm-list"></div>
<div class="link-drm-actions">
<button type="button" class="btn-secondary discover-drm-btn drm-discover-btn">${t('btn.discover_drm')}</button>
<button type="button" class="btn-secondary add-drm-config-btn" style="padding:6px 10px;font-size:.82em;">+ DRM</button>
</div>
</details>
@@ -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 @@
<option value="widevine" ${selectedType === 'widevine' ? 'selected' : ''}>Widevine</option>
<option value="fairplay" ${selectedType === 'fairplay' ? 'selected' : ''}>FairPlay</option>
</select>
<input class="l-license-url drm-field-enabled" placeholder="${t('ph.license_url')}" value="${escapeAttr(config.licenseUrl || config.license_url || '')}">
<input class="l-certificate-url drm-field-fairplay" placeholder="${t('ph.certificate_url')}" value="${escapeAttr(config.certificateUrl || config.certificate_url || '')}">
<button type="button" class="btn-danger remove-drm-config-btn drm-remove-btn">×</button>
<input class="l-drm-playback-url drm-field-playback" placeholder="${t('ph.drm_playback_url')}" value="${escapeAttr(config.playbackUrl || config.playback_url || '')}">
<select class="l-drm-playback-type drm-field-playback">
<option value="" ${(config.playbackType || config.playback_type || '') === '' ? 'selected' : ''}>${t('type.auto')}</option>
<option value="m3u8" ${(config.playbackType || config.playback_type || '') === 'm3u8' ? 'selected' : ''}>HLS</option>
<option value="dash" ${(config.playbackType || config.playback_type || '') === 'dash' ? 'selected' : ''}>DASH</option>
<option value="flv" ${(config.playbackType || config.playback_type || '') === 'flv' ? 'selected' : ''}>FLV</option>
</select>
<button type="button" class="btn-danger remove-drm-config-btn drm-remove-btn">x</button>
<input class="l-license-url link-drm-wide drm-field-enabled" placeholder="${t('ph.license_url')}" value="${escapeAttr(config.licenseUrl || config.license_url || '')}">
<input class="l-certificate-url link-drm-wide drm-field-fairplay" placeholder="${t('ph.certificate_url')}" value="${escapeAttr(config.certificateUrl || config.certificate_url || '')}">
<textarea class="l-license-headers link-drm-wide drm-field-enabled" rows="2" placeholder="${t('ph.license_headers')}">${escapeHtml(config.licenseHeaders || config.license_headers || '')}</textarea>
<textarea class="l-pssh link-drm-wide drm-field-widevine" rows="2" placeholder="${t('ph.pssh')}">${escapeHtml(config.pssh || '')}</textarea>
`;
@@ -4009,6 +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));
});
row.querySelector('.remove-drm-config-btn')?.addEventListener('click', () => {
row.remove();
input.addEventListener('input', () => {
syncMainPlaybackState();
refreshProbe();
});
input.addEventListener('blur', () => {
syncMainPlaybackState();
scheduleLinkRowCheck(div, 0);
});
});
row.querySelector('.l-drm-playback-type')?.addEventListener('change', refreshProbe);
row.querySelector('.remove-drm-config-btn')?.addEventListener('click', () => {
row.remove();
syncMainPlaybackState();
refreshProbe();
});
return row;
};
const fillInputIfAvailable = (input, value, overwrite = false) => {
if (!input || !value) return;
if (overwrite || !input.value.trim()) input.value = value;
};
const applyDiscoveredDrm = (items, sourceUrl = '', sourceType = '', preferredRow = null) => {
const supported = (Array.isArray(items) ? items : [])
.filter(item => item && (item.drmType === 'widevine' || item.drmType === 'fairplay'));
if (!supported.length) return { applied: 0, partial: false };
const mainUrl = div.querySelector('.l-url')?.value.trim() || '';
const mainType = div.querySelector('.l-type')?.value || '';
let applied = 0;
let partial = false;
supported.forEach(item => {
partial = partial || !!item.partial || !item.licenseUrl || (item.drmType === 'fairplay' && !item.certificateUrl);
let target = null;
if (preferredRow?.isConnected) {
const preferredType = preferredRow.querySelector('.l-drm-type')?.value || '';
if (!preferredType || preferredType === item.drmType) target = preferredRow;
}
if (!target) target = Array.from(drmList.querySelectorAll('.drm-config-row')).find(row => {
const rowType = row.querySelector('.l-drm-type')?.value || '';
return rowType === item.drmType;
});
if (!target) target = addDrmConfigUI({ drmType: item.drmType });
const typeSelect = target.querySelector('.l-drm-type');
if (typeSelect) {
typeSelect.value = item.drmType;
typeSelect.dispatchEvent(new Event('change'));
}
fillInputIfAvailable(target.querySelector('.l-license-url'), item.licenseUrl || '', true);
fillInputIfAvailable(target.querySelector('.l-certificate-url'), item.certificateUrl || '', true);
fillInputIfAvailable(target.querySelector('.l-pssh'), item.pssh || '', true);
const itemPlaybackUrl = item.playbackUrl || sourceUrl || '';
if (itemPlaybackUrl && itemPlaybackUrl !== mainUrl) {
fillInputIfAvailable(target.querySelector('.l-drm-playback-url'), itemPlaybackUrl, true);
}
const itemPlaybackType = item.playbackType || sourceType || '';
const playbackTypeSelect = target.querySelector('.l-drm-playback-type');
if (playbackTypeSelect && itemPlaybackType && itemPlaybackUrl !== mainUrl) {
playbackTypeSelect.value = itemPlaybackType;
} else if (playbackTypeSelect && mainType && itemPlaybackUrl === mainUrl) {
playbackTypeSelect.value = '';
}
applied += 1;
});
syncMainPlaybackState();
scheduleLinkRowCheck(div, 0);
return { applied, partial };
};
const discoverDrmForRow = async () => {
const btn = div.querySelector('.discover-drm-btn');
const drmTargets = getRowDrmPlaybackTargets(div);
const mainTarget = {
url: div.querySelector('.l-url')?.value.trim() || '',
type: div.querySelector('.l-type')?.value || '',
drmRow: null
};
const targets = drmTargets.length ? drmTargets : (mainTarget.url ? [mainTarget] : []);
if (!targets.length) {
showToast(t('probe.no_info'), 'error');
return;
}
const original = btn?.textContent || '';
if (btn) {
btn.disabled = true;
btn.textContent = '...';
}
let total = 0;
let partial = false;
const failedMessages = [];
try {
for (const target of targets) {
try {
const res = await apiCall('discover_drm', {
url: target.url,
type: inferLinkType(target.url, target.type)
});
const result = applyDiscoveredDrm(res.data?.drmConfigs || [], target.url, res.data?.type || target.type, target.row || target.drmRow);
total += result.applied || 0;
partial = partial || !!result.partial;
} catch (e) {
const url = target.url || '';
const shortUrl = url.length > 72 ? `${url.slice(0, 69)}...` : url;
failedMessages.push(`${shortUrl}: ${e.message || e}`);
}
}
div.querySelector('.link-drm-config')?.setAttribute('open', '');
if (total && failedMessages.length) {
showToast(t('msg.drm_partial_failed').replace('{count}', failedMessages.length), 'info');
} else if (total) {
showToast(partial ? t('msg.drm_partial') : t('msg.drm_discovered'), partial ? 'info' : 'success');
} else if (failedMessages.length) {
showToast(`${t('msg.drm_discover_failed')}: ${failedMessages[0]}`, 'error');
} else {
showToast(t('msg.drm_not_found'), 'error');
}
} catch (e) {
showToast(e.message, 'error');
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = original || t('btn.discover_drm');
}
}
};
if (normalizedDrmConfigs.length) {
normalizedDrmConfigs.forEach(config => addDrmConfigUI(config));
} else {
addDrmConfigUI();
}
syncMainPlaybackState();
div.querySelector('.discover-drm-btn')?.addEventListener('click', discoverDrmForRow);
div.querySelector('.add-drm-config-btn')?.addEventListener('click', () => {
div.querySelector('.link-drm-config')?.setAttribute('open', '');
addDrmConfigUI();
+360 -41
View File
@@ -368,6 +368,189 @@
const contentId = getFairPlayContentId(initData);
return shaka.util.FairPlayUtils.initDataTransform(initData, contentId, drmInfo.serverCertificate);
}
function fairPlayStringToUtf16Buffer(text) {
const buffer = new ArrayBuffer(text.length * 2);
const view = new Uint16Array(buffer);
for (let i = 0; i < text.length; i++) view[i] = text.charCodeAt(i);
return new Uint8Array(buffer);
}
function concatFairPlayInitData(initData, contentId, certificate) {
const init = new Uint8Array(initData);
const id = fairPlayStringToUtf16Buffer(contentId);
const cert = new Uint8Array(certificate);
const result = new Uint8Array(init.byteLength + 4 + id.byteLength + 4 + cert.byteLength);
const view = new DataView(result.buffer);
let offset = 0;
result.set(init, offset);
offset += init.byteLength;
view.setUint32(offset, id.byteLength, true);
offset += 4;
result.set(id, offset);
offset += id.byteLength;
view.setUint32(offset, cert.byteLength, true);
offset += 4;
result.set(cert, offset);
return result;
}
function getLegacyFairPlayContentId(initData) {
const text = shaka.util.StringUtils.fromBytesAutoDetect(initData);
const raw = shaka.util.FairPlayUtils.defaultGetContentId(text) || text.replace(/^skd:\/\//i, '');
const query = raw.includes('?') ? raw.split('?').pop() : raw;
const params = new URLSearchParams(query.replace(/^.*?assetId=/, 'assetId='));
return params.get('assetId') || raw;
}
function transformLegacyFairPlayInitData(initData, certificate) {
const contentId = getLegacyFairPlayContentId(initData);
return concatFairPlayInitData(initData, contentId, certificate);
}
function normalizeFairPlayLicenseResponse(buffer, contentType = '') {
const data = new Uint8Array(buffer);
const type = String(contentType || '').toLowerCase();
const looksTextWrapped = type.includes('json') || type.includes('xml') || type.includes('text');
if (!looksTextWrapped || !window.shaka?.util?.FairPlayUtils || !window.shaka?.net?.NetworkingEngine) {
return data;
}
try {
const response = { data, headers: { 'content-type': contentType } };
shaka.util.FairPlayUtils.commonFairPlayResponse(shaka.net.NetworkingEngine.RequestType.LICENSE, response);
return response.data;
} catch (error) {
console.warn('Native FairPlay response unwrap skipped:', error);
return data;
}
}
function setupNativeFairPlayHls(video, url, drm, art, options = {}) {
if (!window.WebKitMediaKeys || !WebKitMediaKeys.isTypeSupported('com.apple.fps.1_0', 'video/mp4')) {
return null;
}
const licenseUrl = String(drm?.licenseUrl || '').trim();
const certificateUrl = String(drm?.certificateUrl || '').trim();
const headers = parseHeaderConfig(drm?.licenseHeaders || '');
const keySystem = 'com.apple.fps.1_0';
const sessions = [];
const disposers = [];
let disposed = false;
let loaded = false;
const showNativeError = (stage, error) => {
const detail = error?.message || error?.name || error?.type || error?.code || String(error || 'unknown');
console.error(`Native FairPlay Error [${stage}]:`, error);
art.notice.show = `${t('drm_playback_error')} (${stage}: ${detail})`;
};
const certificatePromise = fetch(certificateUrl, { cache: 'force-cache' }).then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.arrayBuffer();
}).then(buffer => {
console.info('Native FairPlay certificate loaded:', certificateUrl, buffer.byteLength);
return new Uint8Array(buffer);
}).catch(error => {
showNativeError('certificate', error);
throw error;
});
const clearWaiting = () => {
loaded = true;
};
const handleNeedKey = event => {
console.info('Native FairPlay needkey:', event);
certificatePromise.then(certificate => {
if (disposed) return;
try {
const initData = event.initData || event.webkitInitData;
if (!initData || !initData.byteLength) throw new Error('missing initData');
const transformed = transformLegacyFairPlayInitData(initData, certificate);
console.info('Native FairPlay initData:', shaka.util.StringUtils.fromBytesAutoDetect(initData), 'contentId=', getLegacyFairPlayContentId(initData), initData.byteLength, transformed.byteLength);
let session = null;
try {
session = video.webkitKeys.createSession('video/mp4', transformed);
} catch (error) {
showNativeError('create-session', error);
return;
}
if (!session) {
showNativeError('create-session', new Error('empty session'));
return;
}
sessions.push(session);
const handleMessage = messageEvent => {
const message = messageEvent.message || messageEvent.webkitMessage;
if (!message || !message.byteLength) {
showNativeError('license-message', new Error('missing SPC message'));
return;
}
const licenseEndpoint = options.licenseProxyUrl || licenseUrl;
console.info('Native FairPlay license request:', licenseEndpoint, message.byteLength);
const requestHeaders = options.licenseProxyUrl
? { 'Content-Type': 'application/octet-stream', 'X-StreamHall-Viewer-Token': options.viewerToken || '' }
: { 'Content-Type': 'application/octet-stream', ...headers };
fetch(licenseEndpoint, {
method: 'POST',
headers: requestHeaders,
body: message
}).then(async response => {
const contentType = response.headers.get('content-type') || '';
console.info('Native FairPlay license status:', response.status, contentType);
const buffer = await response.arrayBuffer();
if (!response.ok) {
const text = new TextDecoder().decode(buffer.slice(0, 2048));
console.warn('Native FairPlay license error body:', text);
throw `HTTP ${response.status}: ${text}`;
}
return { buffer, contentType };
}).then(({ buffer, contentType }) => {
if (disposed) return;
console.info('Native FairPlay license response:', buffer.byteLength);
session.update(normalizeFairPlayLicenseResponse(buffer, contentType));
}).catch(error => showNativeError('license', error));
};
const handleKeyError = errorEvent => {
const code = session.error ? `${session.error.code || ''} ${session.error.systemCode || ''}`.trim() : '';
showNativeError('key-session', code ? new Error(code) : errorEvent);
};
session.addEventListener('webkitkeymessage', handleMessage);
session.addEventListener('webkitkeyerror', handleKeyError);
disposers.push(() => {
session.removeEventListener('webkitkeymessage', handleMessage);
session.removeEventListener('webkitkeyerror', handleKeyError);
});
} catch (error) {
showNativeError('needkey', error);
}
}).catch(error => showNativeError('certificate', error));
};
const handleVideoError = () => showNativeError('media', video.error || new Error('Native FairPlay media error'));
try {
video.webkitSetMediaKeys(new WebKitMediaKeys(keySystem));
} catch (error) {
showNativeError('mediakeys', error);
return null;
}
video.addEventListener('webkitneedkey', handleNeedKey);
video.addEventListener('loadeddata', clearWaiting);
video.addEventListener('playing', clearWaiting);
video.addEventListener('error', handleVideoError);
disposers.push(() => {
video.removeEventListener('webkitneedkey', handleNeedKey);
video.removeEventListener('loadeddata', clearWaiting);
video.removeEventListener('playing', clearWaiting);
video.removeEventListener('error', handleVideoError);
});
const timeout = window.setTimeout(() => {
if (!disposed && !loaded && video.readyState < 2) {
art.notice.show = `${t('drm_playback_error')} (native FairPlay timeout)`;
}
}, 15000);
video.src = url;
video.load();
return () => {
disposed = true;
window.clearTimeout(timeout);
disposers.splice(0).forEach(dispose => dispose());
try { video.removeAttribute('src'); video.load(); } catch (e) {}
};
}
function getDrmConfigs(link) {
const configs = Array.isArray(link?.drmConfigs) ? link.drmConfigs : Array.isArray(link?.drm_configs) ? link.drm_configs : [];
@@ -376,7 +559,10 @@
licenseUrl: String(config?.licenseUrl || config?.license_url || '').trim(),
certificateUrl: String(config?.certificateUrl || config?.certificate_url || '').trim(),
licenseHeaders: String(config?.licenseHeaders || config?.license_headers || '').trim(),
pssh: String(config?.pssh || '').trim()
pssh: String(config?.pssh || '').trim(),
playbackUrl: String(config?.playbackUrl || config?.playback_url || '').trim(),
playbackType: String(config?.playbackType || config?.playback_type || '').trim().toLowerCase(),
playback_url: String(config?.playback_url || '').trim()
})).filter(config => (config.drmType === 'widevine' || config.drmType === 'fairplay') && config.licenseUrl);
if (normalized.length) return normalized;
const legacyType = String(link?.drmType || link?.drm_type || '').toLowerCase();
@@ -387,16 +573,24 @@
licenseUrl: legacyLicense,
certificateUrl: String(link?.certificateUrl || link?.certificate_url || '').trim(),
licenseHeaders: String(link?.licenseHeaders || link?.license_headers || '').trim(),
pssh: String(link?.pssh || '').trim()
pssh: String(link?.pssh || '').trim(),
playbackUrl: String(link?.playbackUrl || link?.playback_url || '').trim(),
playbackType: String(link?.playbackType || link?.playback_type || '').trim().toLowerCase(),
playback_url: String(link?.playback_url || '').trim()
}];
}
return [];
}
function browserPrefersFairPlay() {
function isAppleWebKitFairPlayCapable() {
const ua = navigator.userAgent || '';
const isSafari = /Safari/i.test(ua) && !/(Chrome|Chromium|CriOS|FxiOS|Edg|OPR|OPiOS)/i.test(ua);
return !!window.WebKitMediaKeys || isSafari;
const isiOSWebKit = /iPad|iPhone|iPod/i.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
const isDesktopSafari = /Safari/i.test(ua) && !/(Chrome|Chromium|CriOS|FxiOS|Edg|OPR|OPiOS)/i.test(ua);
return !!window.WebKitMediaKeys || isiOSWebKit || isDesktopSafari;
}
function browserPrefersFairPlay() {
return isAppleWebKitFairPlayCapable();
}
function isAndroidRestrictedWebView() {
@@ -405,6 +599,23 @@
return /Telegram/i.test(ua);
}
async function canUseWidevineKeySystem() {
if (!navigator.requestMediaKeySystemAccess) return false;
try {
await navigator.requestMediaKeySystemAccess('com.widevine.alpha', [{
initDataTypes: ['cenc'],
audioCapabilities: [{ contentType: 'audio/mp4; codecs="mp4a.40.2"' }],
videoCapabilities: [{ contentType: 'video/mp4; codecs="avc1.42E01E"' }],
distinctiveIdentifier: 'optional',
persistentState: 'optional',
sessionTypes: ['temporary']
}]);
return true;
} catch (error) {
return false;
}
}
function selectDrmConfig(link) {
const configs = getDrmConfigs(link);
if (!configs.length) return null;
@@ -426,9 +637,45 @@
return !!selectDrmConfig(link);
}
function safeDestroyHls(art) {
try {
const hls = art?.hls;
if (hls) hls.destroy();
} catch (error) {
if (!String(error?.message || error).includes('Cannot find instance of HLS')) {
console.warn('HLS cleanup skipped:', error);
}
}
}
function linkHasDrmConfigs(link) {
return getDrmConfigs(link).length > 0;
}
function drmConfigMatchScore(config, selected) {
if (!selected || config.drmType !== selected.drmType) return -1;
let score = 1;
const fields = ['licenseUrl', 'certificateUrl', 'playbackUrl', 'playbackType', 'pssh', 'licenseHeaders'];
for (const field of fields) {
const left = String(config[field] || '').trim();
const right = String(selected[field] || '').trim();
if (left && right && left !== right) return -1;
if (left === right) score += 1;
}
return score;
}
function getDrmConfigIndex(link, selected) {
const configs = getDrmConfigs(link);
let bestIndex = -1;
let bestScore = -1;
configs.forEach((config, index) => {
const score = drmConfigMatchScore(config, selected);
if (score > bestScore) {
bestScore = score;
bestIndex = index;
}
});
return bestIndex;
}
function formatShakaError(error) {
if (!error) return t('drm_playback_error');
@@ -464,22 +711,36 @@
function installShakaQualityControl(art, player) {
const update = () => {
const tracks = player.getVariantTracks()
.filter(track => track.videoId !== null && track.videoId !== undefined)
.filter(track => track.videoId !== null && track.videoId !== undefined && (track.height || track.bandwidth))
.sort((a, b) => (b.height || 0) - (a.height || 0) || (b.bandwidth || 0) - (a.bandwidth || 0));
const unique = [];
const seen = new Set();
const byVideoTrack = new Map();
tracks.forEach(track => {
const key = `${track.height || 0}-${Math.round((track.bandwidth || 0) / 1000)}`;
if (seen.has(key)) return;
seen.add(key);
unique.push(track);
const key = track.videoId != null ? `video-${track.videoId}` : `${track.width || 0}x${track.height || 0}-${track.codecs || ''}-${Math.round((track.bandwidth || 0) / 250000)}`;
const current = byVideoTrack.get(key);
if (!current || track.active || (!current.active && (track.bandwidth || 0) > (current.bandwidth || 0))) {
byVideoTrack.set(key, track);
}
});
const unique = Array.from(byVideoTrack.values())
.sort((a, b) => (b.height || 0) - (a.height || 0) || (b.bandwidth || 0) - (a.bandwidth || 0));
if (!unique.length) return;
const active = unique.find(track => track.active) || tracks.find(track => track.active);
const autoLabel = 'Auto';
const selectedLabel = active?.height ? `${active.height}P` : autoLabel;
const heightCounts = unique.reduce((acc, track) => {
const key = track.height || 0;
acc.set(key, (acc.get(key) || 0) + 1);
return acc;
}, new Map());
const qualityLabel = track => {
if (!track) return autoLabel;
const kbps = Math.round((track.videoBandwidth || track.bandwidth || 0) / 1000);
if (!track.height) return `${kbps}K`;
if ((heightCounts.get(track.height) || 0) <= 1) return `${track.height}P`;
return kbps >= 1000 ? `${track.height}P (${(kbps / 1000).toFixed(1)}M)` : `${track.height}P (${kbps}K)`;
};
const selectedLabel = active ? qualityLabel(active) : autoLabel;
const selector = unique.map(track => ({
html: track.height ? `${track.height}P` : `${Math.round((track.bandwidth || 0) / 1000)}K`,
html: qualityLabel(track),
value: track.id,
default: !!track.active
}));
@@ -526,6 +787,15 @@
}
function getLinkType(link) {
if (linkUsesShakaDrm(link)) return 'm3u8';
const selectedDrm = selectDrmConfig(link);
if (selectedDrm?.playbackUrl) {
if (selectedDrm.playbackType) return selectedDrm.playbackType;
const drmPath = selectedDrm.playbackUrl.split('?')[0].toLowerCase();
if (drmPath.endsWith('.mpd')) return 'dash';
if (drmPath.endsWith('.m3u8')) return 'm3u8';
if (drmPath.endsWith('.flv')) return 'flv';
}
if (link.type) return link.type;
const path = (link.url || '').split('?')[0].toLowerCase();
if (path.endsWith('.mpd')) return 'dash';
@@ -535,6 +805,8 @@
}
function getPlaybackUrl(link) {
const selectedDrm = selectDrmConfig(link);
if (selectedDrm?.playbackUrl) return selectedDrm.playback_url || selectedDrm.playbackUrl;
return link.playback_url || link.url;
}
@@ -797,7 +1069,7 @@
return;
}
const activeLinks = [...links];
const activeLinks = links.map((link, index) => ({ ...link, _sourceIndex: index }));
const preferredIndex = activeLinks.findIndex(link => link.url === preferredUrl || getPlaybackUrl(link) === preferredUrl);
if (preferredIndex > 0) {
activeLinks.unshift(activeLinks.splice(preferredIndex, 1)[0]);
@@ -810,6 +1082,12 @@
type: getLinkType(l)
}));
const initialLink = activeLinks[0] || null;
const initialUsesDrm = linkUsesShakaDrm(initialLink);
const hlsControlPlugins = initialUsesDrm ? [] : [
artplayerPluginHlsControl({
quality: { control: true, setting: true, getName: (l) => l.height + 'P', title: t('quality') }
})
];
playerInstance = new Artplayer({
container: '.artplayer-app',
@@ -818,7 +1096,7 @@
quality: quality,
title: data.eventName,
autoplay: true,
isLive: data.streamLabel === 'LIVE',
isLive: data.streamLabel === 'LIVE' && !initialUsesDrm,
volume: 0.5,
autoSize: true,
fullscreen: true,
@@ -844,35 +1122,76 @@
return;
}
if (linkUsesShakaDrm(currentLink)) {
if (art.hls) art.hls.destroy();
if (window.shaka?.polyfill) shaka.polyfill.installAll();
if (!window.shaka || !shaka.Player || !shaka.Player.isBrowserSupported()) {
art.notice.show = t('drm_unavailable');
return;
}
const selectedDrm = selectDrmConfig(currentLink);
if (selectedDrm?.drmType === 'widevine' && isAndroidRestrictedWebView()) {
art.notice.show = t('drm_android_webview_unsupported');
return;
}
const fairplay = selectedDrm?.drmType === 'fairplay';
const certificateUrl = String(selectedDrm?.certificateUrl || '').trim();
const licenseUrl = String(selectedDrm?.licenseUrl || '').trim();
if (!licenseUrl) {
art.notice.show = t('drm_license_missing');
return;
}
if (fairplay && !certificateUrl) {
art.notice.show = t('drm_certificate_missing');
return;
}
if (selectedDrm?.drmType === 'widevine' && isAndroidRestrictedWebView()) {
canUseWidevineKeySystem().then(supported => {
if (!supported) art.notice.show = t('drm_android_webview_unsupported');
});
}
if (art.nativeFairPlayCleanup) {
art.nativeFairPlayCleanup();
art.nativeFairPlayCleanup = null;
}
safeDestroyHls(art);
const linkIndex = Number.isInteger(currentLink?._sourceIndex) ? currentLink._sourceIndex : activeLinks.indexOf(currentLink);
const drmIndex = getDrmConfigIndex(currentLink, selectedDrm);
if (fairplay && isAppleWebKitFairPlayCapable() && window.WebKitMediaKeys) {
if (art.shaka) {
art.shaka.destroy().catch(() => {});
art.shaka = null;
}
if (art.streamhallDurationGuard) art.streamhallDurationGuard();
art.streamhallDurationGuard = installLiveDurationGuard(art);
const licenseProxyUrl = linkIndex >= 0 && drmIndex >= 0
? `/api?action=fairplay_license&id=${encodeURIComponent(streamId)}&link=${encodeURIComponent(linkIndex)}&drm=${encodeURIComponent(drmIndex)}`
: '';
const cleanup = setupNativeFairPlayHls(video, url, selectedDrm, art, { licenseProxyUrl, viewerToken });
if (!cleanup) {
art.notice.show = t('drm_unavailable');
return;
}
art.nativeFairPlayCleanup = cleanup;
art.on('destroy', () => {
if (art.streamhallDurationGuard) art.streamhallDurationGuard();
if (art.nativeFairPlayCleanup) {
art.nativeFairPlayCleanup();
art.nativeFairPlayCleanup = null;
}
});
return;
}
if (window.shaka?.polyfill) shaka.polyfill.installAll();
if (!window.shaka || !shaka.Player || !shaka.Player.isBrowserSupported()) {
art.notice.show = t('drm_unavailable');
return;
}
if (art.shaka) art.shaka.destroy().catch(() => {});
if (art.streamhallDurationGuard) art.streamhallDurationGuard();
art.streamhallDurationGuard = installLiveDurationGuard(art);
const player = new shaka.Player();
const headers = parseHeaderConfig(selectedDrm?.licenseHeaders || '');
const fairplay = selectedDrm?.drmType === 'fairplay';
const keySystem = fairplay ? 'com.apple.fps' : 'com.widevine.alpha';
const fairplayLegacyKeySystem = 'com.apple.fps.1_0';
const certificateUrl = String(selectedDrm?.certificateUrl || '').trim();
const servers = { [keySystem]: licenseUrl };
const widevineProxyUrl = !fairplay && linkIndex >= 0 && drmIndex >= 0
? `/api?action=widevine_license&id=${encodeURIComponent(streamId)}&link=${encodeURIComponent(linkIndex)}&drm=${encodeURIComponent(drmIndex)}&vt=${encodeURIComponent(viewerToken || '')}`
: '';
const shakaLicenseUrl = widevineProxyUrl || licenseUrl;
const servers = { [keySystem]: shakaLicenseUrl };
const advanced = {};
if (fairplay) {
servers[fairplayLegacyKeySystem] = licenseUrl;
servers[fairplayLegacyKeySystem] = shakaLicenseUrl;
advanced[keySystem] = {
serverCertificateUri: certificateUrl,
audioRobustness: '',
@@ -896,22 +1215,22 @@
servers,
advanced
};
if (fairplay && !certificateUrl) {
art.notice.show = t('drm_certificate_missing');
return;
}
player.configure({
drm: {
...drmConfig,
...(fairplay ? { initDataTransform: transformFairPlayInitData } : {})
},
...(fairplay ? { streaming: { useNativeHlsForFairPlay: false } } : {})
...(fairplay ? { streaming: { useNativeHlsForFairPlay: isAppleWebKitFairPlayCapable() } } : {})
});
player.getNetworkingEngine().registerRequestFilter((type, request) => {
if (type !== shaka.net.NetworkingEngine.RequestType.LICENSE) return;
if (fairplay) {
request.headers['Content-Type'] = request.headers['Content-Type'] || 'application/octet-stream';
}
if (widevineProxyUrl) {
request.headers['X-StreamHall-Viewer-Token'] = viewerToken || '';
}
if (Object.keys(headers).length) {
Object.assign(request.headers, headers);
}
@@ -939,6 +1258,10 @@
return;
}
if (Hls.isSupported()) {
if (art.nativeFairPlayCleanup) {
art.nativeFairPlayCleanup();
art.nativeFairPlayCleanup = null;
}
if (art.shaka) {
art.shaka.destroy().catch(() => {});
art.shaka = null;
@@ -947,7 +1270,7 @@
art.streamhallDurationGuard();
art.streamhallDurationGuard = null;
}
if (art.hls) art.hls.destroy();
safeDestroyHls(art);
const keyOverride = currentLink ? parseHlsKeyOverride(currentLink.key) : null;
class CustomLoader extends Hls.DefaultConfig.loader {
@@ -1006,11 +1329,7 @@
art.on('destroy', () => dash.destroy());
}
},
plugins: [
artplayerPluginHlsControl({
quality: { control: true, setting: true, getName: (l) => l.height + 'P', title: t('quality') }
})
]
plugins: hlsControlPlugins
});
startPlaybackMonitor(data, password);
}
+499 -1
View File
@@ -7,6 +7,7 @@ import csv
import hashlib
import hmac
import io
import ipaddress
import json
import mimetypes
import os
@@ -17,13 +18,14 @@ import tempfile
import threading
import time
import uuid
import xml.etree.ElementTree as ET
from http import HTTPStatus
from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import parse_qs, quote, urlencode, urljoin, urlparse
from urllib.request import Request, urlopen
from urllib.request import HTTPRedirectHandler, Request, build_opener, urlopen
try:
import psycopg
@@ -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 "<MPD" in text[:2048]):
is_dash = True
discovered: list[dict[str, str]] = []
if is_dash:
discovered.extend(discover_dash_drm(url, text))
if is_hls:
discovered.extend(discover_hls_drm(url, text))
unsupported: list[str] = []
lower = text.lower()
if "9a04f079-9840-4286-ab92-e65be0885f95" in lower or "/playready/" in lower:
unsupported.append("playready")
return {
"url": url,
"type": "dash" if is_dash else ("m3u8" if is_hls else ""),
"status_code": status_code,
"content_type": content_type,
"drmConfigs": discovered,
"unsupported": sorted(set(unsupported)),
}
def drm_config_types(configs: object) -> set[str]:
normalized: set[str] = set()
if not isinstance(configs, list):
@@ -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)