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 || '',
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 @@
<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));
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();
+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);
}