@@ -1806,6 +1993,12 @@
'ph.link_url': '链接 (m3u8/flv/mpd)',
'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.license_headers':'License Headers JSON 或 Header: Value 多行',
+ 'ph.pssh': 'PSSH,可选,用于记录或诊断',
// hints
'hint.nav_links': '留空则不显示菜单链接。支持站内锚点如 #stream-list,或完整 http/https 链接。',
'hint.pw_change': '修改后台登录密码后,当前会话会自动退出。',
@@ -1814,6 +2007,8 @@
'hint.obs_rtmp': '推流端使用 RTMP 推流到服务器,播放器使用 SRS 输出的 HLS 或 FLV 地址。',
'hint.obs_routes': '新增自定义推流码后,系统会生成不可反推的公开 HLS/FLV 地址。推流端仍使用真实推流码推流。',
'hint.links': '填写格式:视角名称 | 类型 | 播放链接 | Key Override | ClearKey 信息',
+ 'drm.config': 'DRM 播放授权',
+ 'drm.none': '无 DRM',
'hint.footer_example': '示例:## 联系方式\n- [项目主页](https://example.com)',
// TG
'tg.live_section': '直播', 'tg.live_label': 'LIVE',
@@ -1852,6 +2047,7 @@
'probe.detected': '已检测到推流信息',
'probe.waiting': '等待自动检测...',
'probe.closed': '直播已关闭',
+ 'probe.drm_config_missing':'检测到 DRM 流,但缺少匹配的 DRM 配置',
// status messages
'msg.site_saved': '网站设置已保存',
'msg.site_err': '加载网站设置失败',
@@ -1935,6 +2131,7 @@
'push.copy_hls': '复制播放地址',
'push.add_stream': '添加直播',
'push.copied': '已复制',
+ 'push.dir_label': '目录',
'push.dir_empty': '此目录为空',
'push.publish_archive': '发布归档',
'push.folder': '文件夹',
@@ -2196,6 +2393,12 @@
'ph.link_url': 'URL (m3u8/flv/mpd)',
'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.license_headers':'License Headers JSON or Header: Value lines',
+ 'ph.pssh': 'PSSH, optional note for diagnostics',
// hints
'hint.nav_links': 'Leave empty to hide nav links. Supports anchors like #stream-list or full URLs.',
'hint.pw_change': 'Changing the admin password will log you out of the current session.',
@@ -2204,6 +2407,8 @@
'hint.obs_rtmp': 'The encoder pushes via RTMP to the server; the player uses HLS or FLV from SRS.',
'hint.obs_routes': 'Adding a custom stream key generates a public HLS/FLV URL that cannot be reverse-engineered.',
'hint.links': 'Format: name | type | URL | Key Override | ClearKey',
+ 'drm.config': 'DRM license',
+ 'drm.none': 'No DRM',
'hint.footer_example': 'Example: ## Contact\n- [Project page](https://example.com)',
// TG
'tg.live_section': 'Live', 'tg.live_label': '直播',
@@ -2242,6 +2447,7 @@
'probe.detected': 'Stream detected',
'probe.waiting': 'Waiting for detection...',
'probe.closed': 'Stream disabled',
+ 'probe.drm_config_missing':'DRM stream detected, but matching DRM config is missing',
// status messages
'msg.site_saved': 'Settings saved',
'msg.site_err': 'Failed to load settings',
@@ -2325,6 +2531,7 @@
'push.copy_hls': 'Copy HLS URL',
'push.add_stream': 'Add Stream',
'push.copied': 'Copied',
+ 'push.dir_label': 'Dirs',
'push.dir_empty': 'Directory is empty',
'push.publish_archive': 'Publish to Archive',
'push.folder': 'Folder',
@@ -2510,6 +2717,15 @@
if (btn.dataset.adminViewTarget) switchAdminView(btn.dataset.adminViewTarget);
});
});
+ const _adminMenuEl = document.querySelector('.admin-menu');
+ document.getElementById('admin-menu-toggle')?.addEventListener('click', () => {
+ _adminMenuEl?.classList.toggle('mobile-open');
+ });
+ document.querySelectorAll('.admin-menu-btn, .admin-sub-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ if (window.innerWidth <= 900) _adminMenuEl?.classList.remove('mobile-open');
+ });
+ });
document.getElementById('records-group-toggle')?.addEventListener('click', () => {
document.getElementById('records-group')?.classList.toggle('open');
});
@@ -2702,10 +2918,19 @@
if (state !== 'is-checking') el.dataset.probeComplete = '1';
};
- const probeUrl = async (url, type = '') => {
+ 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 probeUrl = async (url, type = '', drmConfigs = []) => {
const res = await apiCall('check_stream_url', {
url,
- type: inferLinkType(url, type)
+ type: inferLinkType(url, type),
+ drmConfigs
});
return res.data || { valid: false, message: t('probe.no_info') };
};
@@ -2725,12 +2950,12 @@
row.dataset.probeActive = '1';
if (!silent) setProbeStatus(statusEl, 'is-checking', t('probe.detecting'));
try {
- const result = await probeUrl(url, type);
+ const result = await probeUrl(url, type, getRowDrmConfigs(row));
if (!row.isConnected || row.dataset.probeToken !== token) return;
setProbeStatus(
statusEl,
result.valid ? 'is-online' : 'is-offline',
- result.valid ? t('probe.detected') : t('probe.no_info')
+ result.valid ? t('probe.detected') : (t('probe.' + result.code) || t('probe.no_info'))
);
} catch (e) {
if (row.isConnected && row.dataset.probeToken === token) {
@@ -3657,13 +3882,29 @@
els.form.addEventListener('submit', async (e) => {
e.preventDefault();
- const links = Array.from(els.linksContainer.children).map(row => ({
- name: row.querySelector('.l-name').value,
- type: row.querySelector('.l-type').value,
- url: row.querySelector('.l-url').value,
- key: row.querySelector('.l-key').value.trim(),
- clearkey: row.querySelector('.l-clearkey').value.trim()
- })).filter(l => l.name && l.url);
+ 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 firstDrm = drmConfigs[0] || {};
+ return {
+ name: row.querySelector('.l-name').value,
+ type: row.querySelector('.l-type').value,
+ url: row.querySelector('.l-url').value,
+ key: row.querySelector('.l-key').value.trim(),
+ clearkey: row.querySelector('.l-clearkey').value.trim(),
+ drmConfigs,
+ drmType: firstDrm.drmType || '',
+ licenseUrl: firstDrm.licenseUrl || '',
+ certificateUrl: firstDrm.certificateUrl || '',
+ licenseHeaders: firstDrm.licenseHeaders || '',
+ pssh: firstDrm.pssh || ''
+ };
+ }).filter(l => l.name && l.url);
const payload = {
id: els.idInput.value,
@@ -3686,7 +3927,14 @@
let _linkDragSrc = null;
let _linkDragFromHandle = false;
- const addLinkUI = (name = 'Default', url = '', key = '', clearkey = '', type = '') => {
+ const addLinkUI = (name = 'Default', url = '', key = '', clearkey = '', type = '', drmType = '', licenseUrl = '', licenseHeaders = '', pssh = '', certificateUrl = '', drmConfigs = []) => {
+ const rawDrmType = String(drmType || '').toLowerCase();
+ const normalizedDrmType = rawDrmType === 'widevine' || rawDrmType === 'fairplay' ? rawDrmType : '';
+ const normalizedDrmConfigs = Array.isArray(drmConfigs) && drmConfigs.length
+ ? drmConfigs
+ : (normalizedDrmType || licenseUrl || certificateUrl || licenseHeaders || pssh)
+ ? [{ drmType: normalizedDrmType, licenseUrl, certificateUrl, licenseHeaders, pssh }]
+ : [];
const div = document.createElement('div');
div.className = 'link-row';
div.draggable = true;
@@ -3705,9 +3953,77 @@
-
+
+ ${t('drm.config')}
+
+
+
+
+
+
`;
els.linksContainer.appendChild(div);
+ const drmList = div.querySelector('.link-drm-list');
+ const addDrmConfigUI = (config = {}) => {
+ const drmTypeValue = String(config.drmType || config.drm_type || '').toLowerCase();
+ const selectedType = drmTypeValue === 'fairplay' || drmTypeValue === 'widevine' ? drmTypeValue : '';
+ const row = document.createElement('div');
+ row.className = 'drm-config-row';
+ row.innerHTML = `
+
+
+
+
+
+
+ `;
+ drmList.appendChild(row);
+ const drmTypeSelect = row.querySelector('.l-drm-type');
+ const licenseInput = row.querySelector('.l-license-url');
+ const syncDrmPlaceholders = () => {
+ const type = drmTypeSelect?.value || '';
+ if (licenseInput) {
+ licenseInput.placeholder = type === 'fairplay'
+ ? t('ph.license_url_fairplay')
+ : type === 'widevine'
+ ? t('ph.license_url_widevine')
+ : t('ph.license_url');
+ }
+ row.querySelectorAll('.drm-field-enabled').forEach(el => {
+ el.style.display = type ? '' : 'none';
+ });
+ row.querySelectorAll('.drm-field-fairplay').forEach(el => {
+ el.style.display = type === 'fairplay' ? '' : 'none';
+ });
+ row.querySelectorAll('.drm-field-widevine').forEach(el => {
+ el.style.display = type === 'widevine' ? '' : 'none';
+ });
+ };
+ syncDrmPlaceholders();
+ const refreshProbe = () => scheduleLinkRowCheck(div);
+ drmTypeSelect?.addEventListener('change', () => {
+ syncDrmPlaceholders();
+ refreshProbe();
+ });
+ row.querySelectorAll('input, textarea').forEach(input => {
+ input.addEventListener('input', refreshProbe);
+ input.addEventListener('blur', () => scheduleLinkRowCheck(div, 0));
+ });
+ row.querySelector('.remove-drm-config-btn')?.addEventListener('click', () => {
+ row.remove();
+ refreshProbe();
+ });
+ };
+ normalizedDrmConfigs.forEach(config => addDrmConfigUI(config));
+ div.querySelector('.add-drm-config-btn')?.addEventListener('click', () => {
+ div.querySelector('.link-drm-config')?.setAttribute('open', '');
+ addDrmConfigUI();
+ scheduleLinkRowCheck(div);
+ });
div.querySelector('.l-url').addEventListener('input', () => scheduleLinkRowCheck(div));
div.querySelector('.l-url').addEventListener('blur', () => scheduleLinkRowCheck(div, 0));
div.querySelector('.l-type').addEventListener('change', () => scheduleLinkRowCheck(div, 0));
@@ -3861,7 +4177,7 @@
els.cancelBtn.classList.remove('hidden');
els.linksContainer.innerHTML = '';
const links = JSON.parse(stream.links_json || '[]');
- links.forEach(l => addLinkUI(l.name, l.url, l.key, l.clearkey, l.type));
+ links.forEach(l => addLinkUI(l.name, l.url, l.key, l.clearkey, l.type, l.drmType || l.drm_type || '', l.licenseUrl || l.license_url || '', l.licenseHeaders || l.license_headers || '', l.pssh || '', l.certificateUrl || l.certificate_url || '', l.drmConfigs || l.drm_configs || []));
if (links.length === 0) addLinkUI();
} else {
resetForm();
@@ -5181,6 +5497,7 @@
btn.addEventListener('click', () => {
_highlightSidebarBtn(sidebar, root.index);
browseDir(root.index, '');
+ if (window.innerWidth <= 900) document.getElementById('push-sidebar-wrap')?.classList.remove('sidebar-open');
});
sidebar.appendChild(btn);
});
@@ -5273,7 +5590,7 @@
(j.push_rel_path === childPath || j.push_rel_path.startsWith(childPath + '/')))
: false;
const folderBtns = (entry.has_playable || entry.has_video)
- ? `
+ ? `
${entry.has_playable ? `
` : ''}
${entry.has_video
? (_folderHasJob
@@ -5281,9 +5598,9 @@
: `
`)
: ''}
` : '';
- html += `
+ html += `
📁
-
${_esc(entry.name)}
+
${_esc(entry.name)}
${folderBtns}
`;
} else {
@@ -5294,13 +5611,13 @@
const _tagHtml = _ext
? `
${_esc(_ext)}`
: '';
- html += `
+ html += `
🎬
${_esc(_stem)}
${_esc(fmtBytes(entry.size || 0))}${_tagHtml}
-
+
${entry.video_url ? `
` : ''}
${(() => { const _fj = _jobMap[`v|${dirIndex}|${relPath}|${entry.name}`] ?? null;
return _fj
@@ -5540,6 +5857,9 @@
document.querySelector('[data-admin-view-target="local"]')
?.addEventListener('click', openPushView);
document.addEventListener('admin:enter-local', openPushView);
+ document.getElementById('push-sidebar-toggle')?.addEventListener('click', () => {
+ document.getElementById('push-sidebar-wrap')?.classList.toggle('sidebar-open');
+ });
document.querySelectorAll('.admin-menu-btn:not([data-admin-view-target="local"]), .admin-sub-btn')
.forEach(btn => btn.addEventListener('click', stopJobPolling));