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)