feat: DRM playback with Widevine and FairPlay via Shaka Player
Build and Push Docker Image / build (push) Successful in 14s
Build and Push Docker Image / build (push) Successful in 14s
This commit is contained in:
@@ -5,4 +5,7 @@ __pycache__/
|
|||||||
.env
|
.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
CHANGELOG.md
|
CHANGELOG.md
|
||||||
|
CODEX_CHANGELOG.md
|
||||||
|
CODEX_TODO.md
|
||||||
|
CODEX_REVIEW.md
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
|||||||
+313
-3
@@ -8,6 +8,7 @@
|
|||||||
<script src="/vendor/hls.min.js"></script>
|
<script src="/vendor/hls.min.js"></script>
|
||||||
<script src="/vendor/flv.min.js"></script>
|
<script src="/vendor/flv.min.js"></script>
|
||||||
<script src="/vendor/dash.all.min.js"></script>
|
<script src="/vendor/dash.all.min.js"></script>
|
||||||
|
<script src="/vendor/shaka-player.compiled.js"></script>
|
||||||
<script src="/vendor/artplayer.js"></script>
|
<script src="/vendor/artplayer.js"></script>
|
||||||
<script src="/vendor/artplayer-plugin-hls-control.js"></script>
|
<script src="/vendor/artplayer-plugin-hls-control.js"></script>
|
||||||
<style>
|
<style>
|
||||||
@@ -181,6 +182,11 @@
|
|||||||
flv_unsupported: '当前浏览器不支持 FLV 播放',
|
flv_unsupported: '当前浏览器不支持 FLV 播放',
|
||||||
hls_unsupported: '当前浏览器不支持 HLS 播放',
|
hls_unsupported: '当前浏览器不支持 HLS 播放',
|
||||||
dash_unavailable: 'DASH 播放组件未加载',
|
dash_unavailable: 'DASH 播放组件未加载',
|
||||||
|
drm_unavailable: '当前浏览器不支持此 DRM 播放',
|
||||||
|
drm_license_missing:'未配置 DRM License URL',
|
||||||
|
drm_certificate_missing:'未配置 FairPlay Certificate URL',
|
||||||
|
drm_playback_error:'DRM 授权播放失败',
|
||||||
|
drm_android_webview_unsupported:'当前内置浏览器不支持此 DRM 播放,请使用系统浏览器打开',
|
||||||
quality: '画质',
|
quality: '画质',
|
||||||
// API error codes from server
|
// API error codes from server
|
||||||
'err.stream_not_found': '直播不存在',
|
'err.stream_not_found': '直播不存在',
|
||||||
@@ -209,6 +215,11 @@
|
|||||||
flv_unsupported: 'FLV playback is not supported in this browser',
|
flv_unsupported: 'FLV playback is not supported in this browser',
|
||||||
hls_unsupported: 'HLS playback is not supported in this browser',
|
hls_unsupported: 'HLS playback is not supported in this browser',
|
||||||
dash_unavailable: 'DASH player component not loaded',
|
dash_unavailable: 'DASH player component not loaded',
|
||||||
|
drm_unavailable: 'This browser does not support the configured DRM playback',
|
||||||
|
drm_license_missing:'DRM License URL is not configured',
|
||||||
|
drm_certificate_missing:'FairPlay Certificate URL is not configured',
|
||||||
|
drm_playback_error:'DRM license playback failed',
|
||||||
|
drm_android_webview_unsupported:'This in-app browser does not support this DRM playback. Open it in your system browser.',
|
||||||
quality: 'Quality',
|
quality: 'Quality',
|
||||||
// API error codes from server
|
// API error codes from server
|
||||||
'err.stream_not_found': 'Stream not found',
|
'err.stream_not_found': 'Stream not found',
|
||||||
@@ -222,7 +233,7 @@
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const PLAYER_LANG = (localStorage.getItem('lang_pref') || 'zh') === 'en' ? 'en' : 'zh';
|
const PLAYER_LANG = (localStorage.getItem('lang_pref') || 'zh') === 'en' ? 'en' : 'zh';
|
||||||
const t = key => (PLAYER_I18N[PLAYER_LANG] || PLAYER_I18N.zh)[key] || key;
|
const t = key => (PLAYER_I18N[PLAYER_LANG] || PLAYER_I18N.zh)[key] || PLAYER_I18N.en[key] || key;
|
||||||
|
|
||||||
// Initialise static text in the HTML that cannot use data-i18n.
|
// Initialise static text in the HTML that cannot use data-i18n.
|
||||||
document.getElementById('msg').textContent = t('loading');
|
document.getElementById('msg').textContent = t('loading');
|
||||||
@@ -324,6 +335,196 @@
|
|||||||
return Object.keys(clearkeys).length ? { 'org.w3.clearkey': { clearkeys } } : null;
|
return Object.keys(clearkeys).length ? { 'org.w3.clearkey': { clearkeys } } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseHeaderConfig(input) {
|
||||||
|
const text = String(input || '').trim();
|
||||||
|
if (!text) return {};
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
return Object.entries(parsed).reduce((acc, [key, value]) => {
|
||||||
|
if (key && value !== undefined && value !== null) acc[key] = String(value);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
return text.split(/\n+/).reduce((acc, line) => {
|
||||||
|
const idx = line.indexOf(':');
|
||||||
|
if (idx > 0) {
|
||||||
|
const key = line.slice(0, idx).trim();
|
||||||
|
const value = line.slice(idx + 1).trim();
|
||||||
|
if (key && value) acc[key] = value;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFairPlayContentId(initData) {
|
||||||
|
const text = shaka.util.StringUtils.fromBytesAutoDetect(initData);
|
||||||
|
return shaka.util.FairPlayUtils.defaultGetContentId(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformFairPlayInitData(initData, initDataType, drmInfo) {
|
||||||
|
if (initDataType !== 'skd') return initData;
|
||||||
|
const contentId = getFairPlayContentId(initData);
|
||||||
|
return shaka.util.FairPlayUtils.initDataTransform(initData, contentId, drmInfo.serverCertificate);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDrmConfigs(link) {
|
||||||
|
const configs = Array.isArray(link?.drmConfigs) ? link.drmConfigs : Array.isArray(link?.drm_configs) ? link.drm_configs : [];
|
||||||
|
const normalized = configs.map(config => ({
|
||||||
|
drmType: String(config?.drmType || config?.drm_type || '').toLowerCase(),
|
||||||
|
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()
|
||||||
|
})).filter(config => (config.drmType === 'widevine' || config.drmType === 'fairplay') && config.licenseUrl);
|
||||||
|
if (normalized.length) return normalized;
|
||||||
|
const legacyType = String(link?.drmType || link?.drm_type || '').toLowerCase();
|
||||||
|
const legacyLicense = String(link?.licenseUrl || link?.license_url || '').trim();
|
||||||
|
if ((legacyType === 'widevine' || legacyType === 'fairplay') && legacyLicense) {
|
||||||
|
return [{
|
||||||
|
drmType: legacyType,
|
||||||
|
licenseUrl: legacyLicense,
|
||||||
|
certificateUrl: String(link?.certificateUrl || link?.certificate_url || '').trim(),
|
||||||
|
licenseHeaders: String(link?.licenseHeaders || link?.license_headers || '').trim(),
|
||||||
|
pssh: String(link?.pssh || '').trim()
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function browserPrefersFairPlay() {
|
||||||
|
const ua = navigator.userAgent || '';
|
||||||
|
const isSafari = /Safari/i.test(ua) && !/(Chrome|Chromium|CriOS|FxiOS|Edg|OPR|OPiOS)/i.test(ua);
|
||||||
|
return !!window.WebKitMediaKeys || isSafari;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAndroidRestrictedWebView() {
|
||||||
|
const ua = navigator.userAgent || '';
|
||||||
|
if (!/Android/i.test(ua)) return false;
|
||||||
|
return /Telegram/i.test(ua) || /; wv\)/i.test(ua) || /\bwv\b/i.test(ua);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectDrmConfig(link) {
|
||||||
|
const configs = getDrmConfigs(link);
|
||||||
|
if (!configs.length) return null;
|
||||||
|
if (browserPrefersFairPlay()) {
|
||||||
|
return configs.find(config => config.drmType === 'fairplay' && config.certificateUrl) || configs.find(config => config.drmType === 'fairplay') || null;
|
||||||
|
}
|
||||||
|
return configs.find(config => config.drmType === 'widevine') || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkUsesWidevine(link) {
|
||||||
|
return selectDrmConfig(link)?.drmType === 'widevine';
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkUsesFairPlay(link) {
|
||||||
|
return selectDrmConfig(link)?.drmType === 'fairplay';
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkUsesShakaDrm(link) {
|
||||||
|
return !!selectDrmConfig(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkHasDrmConfigs(link) {
|
||||||
|
return getDrmConfigs(link).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatShakaError(error) {
|
||||||
|
if (!error) return t('drm_playback_error');
|
||||||
|
if (error.code === 6001 && isAndroidRestrictedWebView()) {
|
||||||
|
return t('drm_android_webview_unsupported');
|
||||||
|
}
|
||||||
|
const parts = [];
|
||||||
|
if (error.code !== undefined) parts.push(`code ${error.code}`);
|
||||||
|
if (error.category !== undefined) parts.push(`category ${error.category}`);
|
||||||
|
if (error.severity !== undefined) parts.push(`severity ${error.severity}`);
|
||||||
|
return parts.length ? `${t('drm_playback_error')} (${parts.join(', ')})` : t('drm_playback_error');
|
||||||
|
}
|
||||||
|
|
||||||
|
function installLiveDurationGuard(art) {
|
||||||
|
const root = art?.template?.$player;
|
||||||
|
if (!root) return () => {};
|
||||||
|
const fix = () => {
|
||||||
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||||
|
const nodes = [];
|
||||||
|
while (walker.nextNode()) nodes.push(walker.currentNode);
|
||||||
|
nodes.forEach(node => {
|
||||||
|
const text = node.nodeValue || '';
|
||||||
|
if (!text.includes(' / ')) return;
|
||||||
|
const fixed = text.replace(/\s+\/\s+\d{4,}:\d{2}:\d{2}/g, '');
|
||||||
|
if (fixed !== text) node.nodeValue = fixed;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const timer = window.setInterval(fix, 500);
|
||||||
|
fix();
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function installShakaQualityControl(art, player) {
|
||||||
|
const update = () => {
|
||||||
|
const tracks = player.getVariantTracks()
|
||||||
|
.filter(track => track.videoId !== null && track.videoId !== undefined)
|
||||||
|
.sort((a, b) => (b.height || 0) - (a.height || 0) || (b.bandwidth || 0) - (a.bandwidth || 0));
|
||||||
|
const unique = [];
|
||||||
|
const seen = new Set();
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
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 selector = unique.map(track => ({
|
||||||
|
html: track.height ? `${track.height}P` : `${Math.round((track.bandwidth || 0) / 1000)}K`,
|
||||||
|
value: track.id,
|
||||||
|
default: !!track.active
|
||||||
|
}));
|
||||||
|
selector.push({
|
||||||
|
html: autoLabel,
|
||||||
|
value: 'auto',
|
||||||
|
default: !active
|
||||||
|
});
|
||||||
|
const onSelect = item => {
|
||||||
|
if (item.value === 'auto') {
|
||||||
|
player.configure({ abr: { enabled: true } });
|
||||||
|
} else {
|
||||||
|
const selected = player.getVariantTracks().find(track => String(track.id) === String(item.value));
|
||||||
|
if (selected) {
|
||||||
|
player.configure({ abr: { enabled: false } });
|
||||||
|
player.selectVariantTrack(selected, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.setTimeout(update, 150);
|
||||||
|
return item.html;
|
||||||
|
};
|
||||||
|
art.setting.update({
|
||||||
|
name: 'shaka-quality',
|
||||||
|
html: t('quality'),
|
||||||
|
tooltip: selectedLabel,
|
||||||
|
width: 200,
|
||||||
|
selector,
|
||||||
|
onSelect
|
||||||
|
});
|
||||||
|
art.controls.update({
|
||||||
|
name: 'shaka-quality',
|
||||||
|
position: 'right',
|
||||||
|
index: 11,
|
||||||
|
html: selectedLabel,
|
||||||
|
style: { padding: '0 10px', marginRight: '10px' },
|
||||||
|
selector,
|
||||||
|
onSelect
|
||||||
|
});
|
||||||
|
};
|
||||||
|
player.addEventListener('variantchanged', update);
|
||||||
|
window.setTimeout(update, 0);
|
||||||
|
window.setTimeout(update, 800);
|
||||||
|
return update;
|
||||||
|
}
|
||||||
|
|
||||||
function getLinkType(link) {
|
function getLinkType(link) {
|
||||||
if (link.type) return link.type;
|
if (link.type) return link.type;
|
||||||
const path = (link.url || '').split('?')[0].toLowerCase();
|
const path = (link.url || '').split('?')[0].toLowerCase();
|
||||||
@@ -604,6 +805,7 @@
|
|||||||
url: getPlaybackUrl(l),
|
url: getPlaybackUrl(l),
|
||||||
type: getLinkType(l)
|
type: getLinkType(l)
|
||||||
}));
|
}));
|
||||||
|
const initialLink = activeLinks[0] || null;
|
||||||
|
|
||||||
playerInstance = new Artplayer({
|
playerInstance = new Artplayer({
|
||||||
container: '.artplayer-app',
|
container: '.artplayer-app',
|
||||||
@@ -612,6 +814,7 @@
|
|||||||
quality: quality,
|
quality: quality,
|
||||||
title: data.eventName,
|
title: data.eventName,
|
||||||
autoplay: true,
|
autoplay: true,
|
||||||
|
isLive: linkUsesShakaDrm(initialLink),
|
||||||
volume: 0.5,
|
volume: 0.5,
|
||||||
autoSize: true,
|
autoSize: true,
|
||||||
fullscreen: true,
|
fullscreen: true,
|
||||||
@@ -631,9 +834,116 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
m3u8: function (video, url, art) {
|
m3u8: function (video, url, art) {
|
||||||
if (Hls.isSupported()) {
|
const currentLink = activeLinks.find(l => l.url === url || getPlaybackUrl(l) === url);
|
||||||
|
if (linkHasDrmConfigs(currentLink) && !selectDrmConfig(currentLink)) {
|
||||||
|
art.notice.show = t('drm_unavailable');
|
||||||
|
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 licenseUrl = String(selectedDrm?.licenseUrl || '').trim();
|
||||||
|
if (!licenseUrl) {
|
||||||
|
art.notice.show = t('drm_license_missing');
|
||||||
|
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 advanced = {};
|
||||||
|
if (fairplay) {
|
||||||
|
servers[fairplayLegacyKeySystem] = licenseUrl;
|
||||||
|
advanced[keySystem] = {
|
||||||
|
serverCertificateUri: certificateUrl,
|
||||||
|
audioRobustness: '',
|
||||||
|
videoRobustness: ''
|
||||||
|
};
|
||||||
|
advanced[fairplayLegacyKeySystem] = {
|
||||||
|
serverCertificateUri: certificateUrl,
|
||||||
|
audioRobustness: '',
|
||||||
|
videoRobustness: ''
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
advanced[keySystem] = {
|
||||||
|
audioRobustness: '',
|
||||||
|
videoRobustness: '',
|
||||||
|
persistentStateRequired: false,
|
||||||
|
distinctiveIdentifierRequired: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const drmConfig = {
|
||||||
|
preferredKeySystems: fairplay ? [keySystem, fairplayLegacyKeySystem] : [keySystem],
|
||||||
|
servers,
|
||||||
|
advanced
|
||||||
|
};
|
||||||
|
if (fairplay && !certificateUrl) {
|
||||||
|
art.notice.show = t('drm_certificate_missing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
player.configure({
|
||||||
|
drm: {
|
||||||
|
...drmConfig,
|
||||||
|
...(fairplay ? { initDataTransform: transformFairPlayInitData } : {})
|
||||||
|
},
|
||||||
|
...(fairplay ? { streaming: { useNativeHlsForFairPlay: false } } : {})
|
||||||
|
});
|
||||||
|
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 (Object.keys(headers).length) {
|
||||||
|
Object.assign(request.headers, headers);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (fairplay) {
|
||||||
|
player.getNetworkingEngine().registerResponseFilter((type, response) => {
|
||||||
|
shaka.util.FairPlayUtils.commonFairPlayResponse(type, response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
player.addEventListener('error', event => {
|
||||||
|
console.error('Shaka DRM Error:', event.detail);
|
||||||
|
art.notice.show = formatShakaError(event.detail);
|
||||||
|
});
|
||||||
|
player.attach(video).then(() => player.load(url)).then(() => {
|
||||||
|
installShakaQualityControl(art, player);
|
||||||
|
}).catch(error => {
|
||||||
|
console.error('Shaka Load Error:', error);
|
||||||
|
art.notice.show = formatShakaError(error);
|
||||||
|
});
|
||||||
|
art.shaka = player;
|
||||||
|
art.on('destroy', () => {
|
||||||
|
if (art.streamhallDurationGuard) art.streamhallDurationGuard();
|
||||||
|
player.destroy().catch(() => {});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Hls.isSupported()) {
|
||||||
|
if (art.shaka) {
|
||||||
|
art.shaka.destroy().catch(() => {});
|
||||||
|
art.shaka = null;
|
||||||
|
}
|
||||||
|
if (art.streamhallDurationGuard) {
|
||||||
|
art.streamhallDurationGuard();
|
||||||
|
art.streamhallDurationGuard = null;
|
||||||
|
}
|
||||||
if (art.hls) art.hls.destroy();
|
if (art.hls) art.hls.destroy();
|
||||||
const currentLink = activeLinks.find(l => l.url === url || getPlaybackUrl(l) === url);
|
|
||||||
const keyOverride = currentLink ? parseHlsKeyOverride(currentLink.key) : null;
|
const keyOverride = currentLink ? parseHlsKeyOverride(currentLink.key) : null;
|
||||||
|
|
||||||
class CustomLoader extends Hls.DefaultConfig.loader {
|
class CustomLoader extends Hls.DefaultConfig.loader {
|
||||||
|
|||||||
+1454
File diff suppressed because it is too large
Load Diff
@@ -514,7 +514,48 @@ def admin_session_not_before() -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def normalize_links(raw: object) -> list[dict[str, str]]:
|
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 []
|
||||||
|
normalized: list[dict[str, str]] = []
|
||||||
|
|
||||||
|
for config in configs:
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
continue
|
||||||
|
drm_type = str(config.get("drmType", config.get("drm_type", ""))).strip().lower()
|
||||||
|
if drm_type not in ("widevine", "fairplay"):
|
||||||
|
continue
|
||||||
|
license_url = str(config.get("licenseUrl", config.get("license_url", ""))).strip()
|
||||||
|
if not license_url:
|
||||||
|
continue
|
||||||
|
normalized.append(
|
||||||
|
{
|
||||||
|
"drmType": drm_type,
|
||||||
|
"licenseUrl": license_url,
|
||||||
|
"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(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
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:
|
||||||
|
has_legacy = any(config["drmType"] == legacy_type for config in normalized)
|
||||||
|
if not has_legacy:
|
||||||
|
normalized.append(
|
||||||
|
{
|
||||||
|
"drmType": legacy_type,
|
||||||
|
"licenseUrl": legacy_license,
|
||||||
|
"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(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_links(raw: object) -> list[dict[str, object]]:
|
||||||
links = raw if isinstance(raw, list) else []
|
links = raw if isinstance(raw, list) else []
|
||||||
normalized = []
|
normalized = []
|
||||||
for item in links:
|
for item in links:
|
||||||
@@ -527,6 +568,8 @@ def normalize_links(raw: object) -> list[dict[str, str]]:
|
|||||||
link_type = str(item.get("type", "")).strip().lower()
|
link_type = str(item.get("type", "")).strip().lower()
|
||||||
if link_type not in ("", "m3u8", "flv", "dash"):
|
if link_type not in ("", "m3u8", "flv", "dash"):
|
||||||
link_type = ""
|
link_type = ""
|
||||||
|
drm_configs = normalize_drm_configs(item)
|
||||||
|
first_drm = drm_configs[0] if drm_configs else {}
|
||||||
normalized.append(
|
normalized.append(
|
||||||
{
|
{
|
||||||
"name": name,
|
"name": name,
|
||||||
@@ -534,6 +577,12 @@ def normalize_links(raw: object) -> list[dict[str, str]]:
|
|||||||
"url": url,
|
"url": url,
|
||||||
"key": str(item.get("key", "")).strip(),
|
"key": str(item.get("key", "")).strip(),
|
||||||
"clearkey": str(item.get("clearkey", "")).strip(),
|
"clearkey": str(item.get("clearkey", "")).strip(),
|
||||||
|
"drmConfigs": drm_configs,
|
||||||
|
"drmType": str(first_drm.get("drmType", "")),
|
||||||
|
"licenseUrl": str(first_drm.get("licenseUrl", "")),
|
||||||
|
"certificateUrl": str(first_drm.get("certificateUrl", "")),
|
||||||
|
"licenseHeaders": str(first_drm.get("licenseHeaders", "")),
|
||||||
|
"pssh": str(first_drm.get("pssh", "")),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return normalized
|
return normalized
|
||||||
@@ -627,6 +676,23 @@ def stream_probe_response(valid: bool, status_code: int | None = None) -> dict[s
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def stream_probe_drm_response(
|
||||||
|
valid: bool,
|
||||||
|
status_code: int | None,
|
||||||
|
drm_types: set[str],
|
||||||
|
configured_types: set[str],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
missing = bool(drm_types) and not bool(drm_types & configured_types)
|
||||||
|
return {
|
||||||
|
"valid": valid and not missing,
|
||||||
|
"code": "drm_config_missing" if missing else ("detected" if valid else "no_info"),
|
||||||
|
"status_code": status_code,
|
||||||
|
"drm_detected": bool(drm_types),
|
||||||
|
"drm_types": sorted(drm_types),
|
||||||
|
"drm_configured": sorted(configured_types),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def decode_probe_text(data: bytes) -> str:
|
def decode_probe_text(data: bytes) -> str:
|
||||||
for encoding in ("utf-8-sig", "utf-8", "latin-1"):
|
for encoding in ("utf-8-sig", "utf-8", "latin-1"):
|
||||||
try:
|
try:
|
||||||
@@ -636,6 +702,33 @@ def decode_probe_text(data: bytes) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def hls_manifest_drm_types(text: str) -> set[str]:
|
||||||
|
lower = text.lower()
|
||||||
|
types: set[str] = set()
|
||||||
|
if "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed" in lower or "com.widevine" in lower:
|
||||||
|
types.add("widevine")
|
||||||
|
if "com.apple.streamingkeydelivery" in lower or "skd://" in lower:
|
||||||
|
types.add("fairplay")
|
||||||
|
return types
|
||||||
|
|
||||||
|
|
||||||
|
def drm_config_types(configs: object) -> set[str]:
|
||||||
|
normalized: set[str] = set()
|
||||||
|
if not isinstance(configs, list):
|
||||||
|
return normalized
|
||||||
|
for config in configs:
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
continue
|
||||||
|
drm_type = str(config.get("drmType", config.get("drm_type", ""))).strip().lower()
|
||||||
|
license_url = str(config.get("licenseUrl", config.get("license_url", ""))).strip()
|
||||||
|
certificate_url = str(config.get("certificateUrl", config.get("certificate_url", ""))).strip()
|
||||||
|
if drm_type == "widevine" and license_url:
|
||||||
|
normalized.add("widevine")
|
||||||
|
if drm_type == "fairplay" and license_url and certificate_url:
|
||||||
|
normalized.add("fairplay")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
def resolve_video_file_path(url_path: str) -> str | None:
|
def resolve_video_file_path(url_path: str) -> str | None:
|
||||||
"""Decode a /video/<token>/<encoded> path and return the absolute filepath, or None if invalid/missing."""
|
"""Decode a /video/<token>/<encoded> path and return the absolute filepath, or None if invalid/missing."""
|
||||||
parts = url_path.strip("/").split("/", 2)
|
parts = url_path.strip("/").split("/", 2)
|
||||||
@@ -670,7 +763,11 @@ def resolve_video_file_path(url_path: str) -> str | None:
|
|||||||
return filepath if os.path.isfile(filepath) else None
|
return filepath if os.path.isfile(filepath) else None
|
||||||
|
|
||||||
|
|
||||||
def probe_stream_url(raw_url: object, type_hint: object = "") -> dict[str, object]:
|
def probe_stream_url(
|
||||||
|
raw_url: object,
|
||||||
|
type_hint: object = "",
|
||||||
|
drm_configs: object | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
url = str(raw_url or "").strip()
|
url = str(raw_url or "").strip()
|
||||||
if not url:
|
if not url:
|
||||||
return stream_probe_response(False)
|
return stream_probe_response(False)
|
||||||
@@ -715,7 +812,9 @@ def probe_stream_url(raw_url: object, type_hint: object = "") -> dict[str, objec
|
|||||||
marker in text
|
marker in text
|
||||||
for marker in ("#EXTINF", "#EXT-X-STREAM-INF", "#EXT-X-MEDIA-SEQUENCE", "#EXT-X-PART")
|
for marker in ("#EXTINF", "#EXT-X-STREAM-INF", "#EXT-X-MEDIA-SEQUENCE", "#EXT-X-PART")
|
||||||
)
|
)
|
||||||
return stream_probe_response(has_playlist and has_live_media, status_code)
|
drm_types = hls_manifest_drm_types(text)
|
||||||
|
configured_types = drm_config_types(drm_configs)
|
||||||
|
return stream_probe_drm_response(has_playlist and has_live_media, status_code, drm_types, configured_types)
|
||||||
|
|
||||||
if is_dash or "dash+xml" in content_type:
|
if is_dash or "dash+xml" in content_type:
|
||||||
return stream_probe_response(b"<MPD" in data[:2048], status_code)
|
return stream_probe_response(b"<MPD" in data[:2048], status_code)
|
||||||
@@ -979,7 +1078,7 @@ def probe_stream_links(row: dict[str, object]) -> dict[str, object]:
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
links = []
|
links = []
|
||||||
for index, link in enumerate(links):
|
for index, link in enumerate(links):
|
||||||
result = probe_stream_url(link["url"], link.get("type", ""))
|
result = probe_stream_url(link["url"], link.get("type", ""), link.get("drmConfigs", []))
|
||||||
if result["valid"]:
|
if result["valid"]:
|
||||||
return {
|
return {
|
||||||
**result,
|
**result,
|
||||||
@@ -1152,7 +1251,7 @@ def rewrite_external_hls_manifest(manifest: str, base_url: str) -> str:
|
|||||||
# attributes such as EXT-X-KEY) to route through the signed /proxy/hls/
|
# attributes such as EXT-X-KEY) to route through the signed /proxy/hls/
|
||||||
# endpoint, enabling cross-origin playback and key override in the player.
|
# endpoint, enabling cross-origin playback and key override in the player.
|
||||||
def proxied_uri(value: str) -> str:
|
def proxied_uri(value: str) -> str:
|
||||||
if not value or value.startswith("data:"):
|
if not value or value.startswith(("data:", "skd:")):
|
||||||
return value
|
return value
|
||||||
return hls_proxy_path(urljoin(base_url, value))
|
return hls_proxy_path(urljoin(base_url, value))
|
||||||
|
|
||||||
@@ -1878,7 +1977,7 @@ class StreamHallHandler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
def api_check_stream_url(self) -> None:
|
def api_check_stream_url(self) -> None:
|
||||||
body = self.read_json()
|
body = self.read_json()
|
||||||
result = probe_stream_url(body.get("url", ""), body.get("type", ""))
|
result = probe_stream_url(body.get("url", ""), body.get("type", ""), body.get("drmConfigs", body.get("drm_configs", [])))
|
||||||
self.send_json({"status": "success", "data": result})
|
self.send_json({"status": "success", "data": result})
|
||||||
|
|
||||||
def api_check_stream(self) -> None:
|
def api_check_stream(self) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user