同步上游 zip:合并任务后台化 + 合并整剧可直接播放;保留 ultrafast+4线程 转码优化与 .gitignore 本地规则
This commit is contained in:
+24
-10
@@ -36,7 +36,7 @@ export function createDownloads(app) {
|
||||
label.appendChild(select);
|
||||
return label;
|
||||
}
|
||||
function buildGroups(){const byID=new Map();tasks.forEach(task=>{const id=task.dramaId||task.dramaTitle||task.id;let group=byID.get(id);if(!group){group={id,title:task.dramaTitle||'短剧',tasks:[],release:'unknown'};byID.set(id,group);}group.tasks.push(task);if(task.releaseStatus)group.release=task.releaseStatus;});return Array.from(byID.values());}
|
||||
function buildGroups(){const byID=new Map();tasks.forEach(task=>{const id=task.dramaId||task.dramaTitle||task.id;let group=byID.get(id);if(!group){group={id,title:task.dramaTitle||'短剧',tasks:[],release:'unknown'};byID.set(id,group);}group.tasks.push(task);if(task.releaseStatus)group.release=task.releaseStatus;});for(const [id,merge] of Object.entries(mergeStates)){if(merge.playbackTaskId&&!byID.has(id))byID.set(id,{id,title:merge.dramaTitle||'短剧',tasks:[],release:'unknown'});}return Array.from(byID.values());}
|
||||
|
||||
function groupMatches(group){const keyword=$('taskSearch').value.trim().toLowerCase();if(keyword&&!group.title.toLowerCase().includes(keyword))return false;const release=$('releaseStatus').value;if(release&&group.release!==release)return false;const status=$('taskStatus').value;const stats=groupStats(group);if(status==='completed')return stats.success===group.tasks.length;if(status==='unfinished')return stats.success!==group.tasks.length;if(status==='failed')return stats.failed>0;if(status==='running')return stats.running+stats.parsing+stats.queued>0;if(status==='paused')return stats.paused>0;if(status==='canceled')return stats.canceled>0;return true;}
|
||||
|
||||
@@ -103,6 +103,8 @@ function renderTasks(next) {
|
||||
|
||||
function renderGroup(group) {
|
||||
const stats = groupStats(group);
|
||||
const merge = mergeStates[group.id];
|
||||
if (!group.tasks.length && merge?.playbackTaskId) stats.percent = 100;
|
||||
const wrap = element('article', 'group' + (openGroups.has(group.id) ? ' open' : ''));
|
||||
wrap.dataset.dramaId = group.id;
|
||||
const head = element('div', 'group-head');
|
||||
@@ -112,14 +114,15 @@ function renderGroup(group) {
|
||||
checkbox.setAttribute('aria-label', '选择合集 ' + group.title);
|
||||
checkbox.dataset.focusKey = 'group-select-' + group.id;
|
||||
const selectedCount = group.tasks.filter(task => selectedTasks.has(task.id)).length;
|
||||
checkbox.checked = selectedCount === group.tasks.length;
|
||||
checkbox.checked = group.tasks.length > 0 && selectedCount === group.tasks.length;
|
||||
checkbox.disabled = !group.tasks.length;
|
||||
checkbox.indeterminate = selectedCount > 0 && !checkbox.checked;
|
||||
checkbox.addEventListener('change', () => selectGroup(group, checkbox.checked));
|
||||
const info = element('div');
|
||||
info.appendChild(element('h2', 'group-title', group.title));
|
||||
const statuses = [releaseText(group.release), '共 ' + group.tasks.length + ' 集', '完成 ' + stats.success];
|
||||
const statuses = group.tasks.length ? [releaseText(group.release), '共 ' + group.tasks.length + ' 集', '完成 ' + stats.success] : ['本地合并视频'];
|
||||
const qualities = [...new Set(group.tasks.map(task => number(task.downloadQuality)))];
|
||||
statuses.push('画质:' + (qualities.length === 1 ? qualityLabel(qualities[0]) : '按分集设置'));
|
||||
if (qualities.length) statuses.push('画质:' + (qualities.length === 1 ? qualityLabel(qualities[0]) : '按分集设置'));
|
||||
for (const [key, label] of [['running','下载中'],['queued','排队'],['parsing','解析中'],['paused','暂停'],['failed','失败'],['canceled','已取消']]) if (stats[key]) statuses.push(label + ' ' + stats[key]);
|
||||
const meta = element('p', 'small', statuses.join(' · '));
|
||||
meta.appendChild(element('span', 'group-watch-progress', historySuffix(group.id)));
|
||||
@@ -128,8 +131,7 @@ function renderGroup(group) {
|
||||
selection.appendChild(checkbox);
|
||||
heading.append(selection, info);
|
||||
head.append(heading, progressBar(stats.percent), element('p', 'small', stats.percent + '% · 已写入 ' + formatBytes(stats.bytes) + (stats.speed > 0 ? ' · ' + formatBytes(stats.speed) + '/s' : '')));
|
||||
const merge = mergeStates[group.id];
|
||||
if (merge) head.appendChild(element('p', merge.error ? 'error notice' : 'small notice', '合并:' + ({running:'进行中', success:'完成', failed:'失败'}[merge.status] || merge.status) + ' ' + (merge.progress || 0) + '%' + (merge.detail ? ' · ' + merge.detail : '') + (merge.error ? ' · ' + merge.error : '')));
|
||||
if (merge) head.appendChild(element('p', merge.error ? 'error notice' : 'small notice', '合并:' + ({queued:'排队中', running:'进行中', success:'完成', failed:'失败'}[merge.status] || merge.status) + ' ' + (merge.progress || 0) + '%' + (merge.detail ? ' · ' + merge.detail : '') + (merge.error ? ' · ' + merge.error : '')));
|
||||
const actions = element('div', 'group-actions');
|
||||
function action(label, key, callback, disabled = false, className = 'secondary') {
|
||||
const node = button(label, callback, disabled, className);
|
||||
@@ -137,21 +139,24 @@ function renderGroup(group) {
|
||||
return node;
|
||||
}
|
||||
const playable = group.tasks.filter(task => task.playable).sort((left, right) => number(left.index) - number(right.index))[0];
|
||||
actions.appendChild(action(watchLabel(group.id, '播放'), 'play', () => window.dramaPlayer.openCollection(playable.id, group.title, true), !playable, 'secondary collection-play-button'));
|
||||
if (merge?.playbackTaskId) actions.appendChild(action('播放全集(' + merge.startEpisode + '–' + merge.endEpisode + '集)', 'play-merged', () => window.dramaPlayer.openCollection(merge.playbackTaskId, group.title, true), false, 'collection-play-button merged-play-button'));
|
||||
if (group.tasks.length) actions.appendChild(action(merge?.playbackTaskId ? '分集播放' : watchLabel(group.id, '播放'), 'play', () => window.dramaPlayer.openCollection(playable.id, group.title, true), !playable, 'secondary collection-play-button'));
|
||||
const toggle = action(openGroups.has(group.id) ? '收起分集' : '查看分集', 'episodes', () => {
|
||||
if (openGroups.has(group.id)) openGroups.delete(group.id); else openGroups.add(group.id);
|
||||
renderTasks(tasks);
|
||||
});
|
||||
toggle.setAttribute('aria-expanded', String(openGroups.has(group.id)));
|
||||
toggle.disabled = !group.tasks.length;
|
||||
actions.appendChild(toggle);
|
||||
if (stats.running + stats.queued + stats.parsing) actions.appendChild(action('暂停', 'pause', () => taskAction('pause', [], [group.id])));
|
||||
if (stats.paused) actions.appendChild(action('继续', 'resume', () => taskAction('resume', [], [group.id])));
|
||||
if (stats.failed + stats.canceled) actions.appendChild(action('重试失败', 'retry', () => taskAction('retry', [], [group.id])));
|
||||
if (merge && ['queued', 'running'].includes(merge.status)) actions.appendChild(action('取消合并', 'cancel-merge', async () => {try {await post('/api/ui/merge/cancel', {dramaIds: [group.id]}); await pollTasks();} catch (error) {setMessage(error.message, true);}}));
|
||||
const menu = element('details', 'action-menu');
|
||||
const summary = element('summary', '', '更多操作');
|
||||
summary.dataset.focusKey = group.id + '-more';
|
||||
const popover = element('div', 'action-popover');
|
||||
popover.append(action('更新本剧', 'update', () => updateGroups([group.id])), action('合并已完成分集', 'merge', () => mergeGroups([group.id]), !stats.success), action('取消本剧下载', 'cancel', () => taskAction('cancel', [], [group.id]), !stats.running && !stats.queued && !stats.parsing && !stats.paused));
|
||||
popover.append(action('更新本剧', 'update', () => updateGroups([group.id])), action('合并已完成分集', 'merge', () => mergeGroups([group.id]), !stats.success || ['queued', 'running'].includes(merge?.status)), action('取消本剧下载', 'cancel', () => taskAction('cancel', [], [group.id]), !stats.running && !stats.queued && !stats.parsing && !stats.paused));
|
||||
menu.append(summary, popover);
|
||||
actions.appendChild(menu);
|
||||
head.appendChild(actions);
|
||||
@@ -203,7 +208,16 @@ async function taskAction(action,ids,dramaIds=[]){const count=dramaIds.length||i
|
||||
|
||||
async function clearTasks(){const ids=selectedTaskList().map(task=>task.id);if(!ids.length){setMessage('请先勾选需要清理的任务',true);return;}if(!confirm('清理勾选的 '+ids.length+' 个任务记录?进行中的任务会先停止;已下载视频不会删除。'))return;try{const result=await post('/api/ui/tasks/clear',{ids});ids.forEach(id=>selectedTasks.delete(id));renderTasks(result.data||[]);setMessage('已清理 '+(result.removed||0)+' 个任务'+(result.pending?',另有 '+result.pending+' 个正在停止后清理':''));}catch(error){$('taskError').textContent='清理失败:'+error.message;}}
|
||||
|
||||
async function mergeGroups(ids){if(!ids.length){setMessage('请先勾选下载合集',true);return;}const deleteEpisodes=$('deleteEpisodesAfterMerge').checked;if(deleteEpisodes&&!confirm('合并成功后删除 '+ids.length+' 个合集已合并的分集文件,确认继续?'))return;setMessage('正在合并 '+ids.length+' 部,下载区会显示进度');try{const result=await post('/api/ui/merge',{dramaIds:ids,deleteEpisodes});const items=result.data||[];const failed=items.filter(item=>!item.ok);setMessage('合并完成:成功 '+(items.length-failed.length)+',失败 '+failed.length,failed.length>0);await pollTasks();}catch(error){$('taskError').textContent='合并失败:'+error.message;}}
|
||||
async function mergeGroups(ids) {
|
||||
if (!ids.length) {setMessage('请先勾选下载合集', true); return;}
|
||||
const deleteEpisodes = $('deleteEpisodesAfterMerge').checked;
|
||||
if (deleteEpisodes && !confirm('合并成功后删除 ' + ids.length + ' 个合集已合并的分集文件,确认继续?')) return;
|
||||
try {
|
||||
const result = await post('/api/ui/merge', {dramaIds: ids, deleteEpisodes, async: true});
|
||||
setMessage('已提交后台合并 ' + result.accepted + ' 部,可关闭或刷新页面,进度在下载区查看');
|
||||
await pollTasks();
|
||||
} catch (error) {$('taskError').textContent = '提交合并失败:' + error.message;}
|
||||
}
|
||||
|
||||
|
||||
function renderActivity(all) {
|
||||
@@ -249,7 +263,7 @@ function toggleBatch() {
|
||||
|
||||
function schedule(restart = true) {
|
||||
if (app.viewer?.onlineOnly) return;
|
||||
const active = hasActiveTasks || ['idle', 'downloading', 'verifying'].includes(ffmpegStatus) || Object.values(mergeStates).some(state => state.status === 'running');
|
||||
const active = hasActiveTasks || ['idle', 'downloading', 'verifying'].includes(ffmpegStatus) || Object.values(mergeStates).some(state => ['queued', 'running'].includes(state.status));
|
||||
const delay = document.hidden ? 30000 : active || !$('downloadsPage').hidden ? 2000 : 15000;
|
||||
if (!restart && polling && pollTimer && delay === scheduledDelay) return;
|
||||
clearTimeout(pollTimer);
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
function progressText(id) {
|
||||
const entry = typeof id === 'object' ? id : get(id);
|
||||
if (!entry) return '';
|
||||
if (entry.taskId?.startsWith('merged:')) return entry.completed ? '全集已看完' : '全集看到 ' + clock(entry.position);
|
||||
if (entry.completed) return entry.index >= entry.total ? '已看至最新' : '第' + entry.episode + '集已看完';
|
||||
return '看到第' + entry.episode + '集 ' + clock(entry.position);
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@
|
||||
</div>
|
||||
<p class="small">Emby 探测或取流结束即释放会话名额。处理并发设为 0 可禁用该类任务;原始播放不占转码名额。</p>
|
||||
<p>允许尝试客户端直连的站源</p><div id="playbackDirectSources" class="playback-direct-sources"></div>
|
||||
<p class="small">默认关闭。检查通过后尝试直连,失败退回中转;已发出的源站链接可能在账号权限变更后继续有效。</p>
|
||||
<p id="playbackDirectHint" class="small">先开启“优先原始 MP4 / HLS 播放”,再选择直连站源。满足源站鉴权、编码、HTTPS 和跨域条件才会直连,失败退回中转。</p>
|
||||
<div class="toolbar"><button id="savePlaybackSettings" class="secondary">保存播放设置</button><span id="playbackSettingsStatus" class="small" role="status"></span></div>
|
||||
</details>
|
||||
<div class="small">保存后自动持久化。降低并发不终止正在下载的任务;代理变更用于后续连接,失败任务可重试。同一站源的剧库请求顺序执行,下载与剧库请求使用独立并发。</div>
|
||||
|
||||
@@ -3,6 +3,13 @@ import { $, api, post } from './ui-core.js';
|
||||
export function createPlaybackSettings() {
|
||||
let settings = null;
|
||||
const fields = ['maxSessions', 'maxMediaRequests', 'maxRemuxJobs', 'maxAudioTranscodes', 'maxVideoTranscodes', 'maxPrefetchJobs', 'cacheMB'];
|
||||
function updateDirect() {
|
||||
const enabled = $('originalPlaybackEnabled').checked;
|
||||
$('playbackDirectSources').querySelectorAll('input').forEach(input => {input.disabled = !enabled;});
|
||||
$('playbackDirectHint').textContent = enabled
|
||||
? '允许尝试直连,不保证每集生效。需要服务器解密、转码,或源站鉴权、HTTPS、跨域检查未通过时仍走中转;客户端连接失败也会自动回退。'
|
||||
: '原始 MP4 / HLS 播放已关闭,已勾选的直连站源也不会生效。先开启上方开关,再保存播放设置。';
|
||||
}
|
||||
function render(result) {
|
||||
settings = result.settings;
|
||||
$('originalPlaybackEnabled').checked = settings.enabled;
|
||||
@@ -18,6 +25,7 @@ export function createPlaybackSettings() {
|
||||
label.append(input, document.createTextNode(source.name));
|
||||
sources.appendChild(label);
|
||||
}
|
||||
updateDirect();
|
||||
}
|
||||
async function load() {
|
||||
try {render(await api('/api/ui/admin/playback'));}
|
||||
@@ -32,10 +40,10 @@ export function createPlaybackSettings() {
|
||||
directSources: [...$('playbackDirectSources').querySelectorAll('input:checked')].map(input => input.value)};
|
||||
for (const name of fields) value[name] = Number($('playback-' + name).value);
|
||||
render(await post('/api/ui/admin/playback', value));
|
||||
$('playbackSettingsStatus').textContent = '播放设置已保存,新任务按当前限制执行。';
|
||||
$('playbackSettingsStatus').textContent = '播放设置已保存,新任务按当前限制执行。' + (!value.enabled && value.directSources.length ? '原始播放已关闭,直连暂不生效。' : '');
|
||||
} catch (error) {$('playbackSettingsStatus').textContent = '保存失败:' + error.message;}
|
||||
finally {button.disabled = false;}
|
||||
}
|
||||
function init() {$('savePlaybackSettings').onclick = save; return load();}
|
||||
function init() {$('savePlaybackSettings').onclick = save; $('originalPlaybackEnabled').onchange = updateDirect; return load();}
|
||||
return {init};
|
||||
}
|
||||
|
||||
@@ -63,8 +63,8 @@
|
||||
panel.classList.toggle('playback-loading', Boolean(value.loading));
|
||||
panel.classList.toggle('playback-buffering', waiting);
|
||||
if (lastTitle !== value.title) {node('mobilePlayerTitle').textContent = node('mobileHeadingTitle').textContent = value.title || '正在加载'; lastTitle = value.title;}
|
||||
node('mobileEpisodeLabel').textContent = value.episode ? '第 ' + value.episode + ' 集' : '正在加载';
|
||||
node('mobileEpisodeSummary').textContent = [value.releaseStatus === 'finished' ? '已完结' : value.releaseStatus === 'ongoing' ? '连载中' : '', value.total ? '全 ' + value.total + ' 集' : '获取分集中'].filter(Boolean).join(' · ');
|
||||
node('mobileEpisodeLabel').textContent = value.episode ? (value.merged ? value.episode : '第 ' + value.episode + ' 集') : '正在加载';
|
||||
node('mobileEpisodeSummary').textContent = [value.releaseStatus === 'finished' ? '已完结' : value.releaseStatus === 'ongoing' ? '连载中' : '', value.merged ? '本地合并视频' : value.total ? '全 ' + value.total + ' 集' : '获取分集中'].filter(Boolean).join(' · ');
|
||||
node('mobileEpisodeVIP').hidden = !value.vip;
|
||||
node('mobileRateBtn').textContent = node('mobileLandscapeRateBtn').textContent = video.playbackRate === 1 ? '倍速' : (video.playbackRate || 1) + ' 倍';
|
||||
const selectedQuality = quality.selectedOptions[0]?.textContent || '自动';
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
const prefetchToggle = node('prefetchNextEpisode');
|
||||
const prefetchStatus = node('prefetchStatus');
|
||||
try {prefetchToggle.checked = localStorage.getItem('juku.playback.prefetchNext') !== 'false';} catch (_) {}
|
||||
const mobile = window.JukuPlayerMobile({panel, video, state: () => ({title: dramaName, episode: episodes[currentIndex - 1]?.episode, total: episodes.length, duration: playbackDuration, loading, releaseStatus, vip: episodes[currentIndex - 1]?.vip}), showEpisodes: showEpisodePanel, showPreferences: section => setPlayerPreferences(true, section)});
|
||||
const mobile = window.JukuPlayerMobile({panel, video, state: () => ({title: dramaName, merged: episodes[currentIndex - 1]?.merged, episode: episodes[currentIndex - 1]?.episode, total: episodes.length, duration: playbackDuration, loading, releaseStatus, vip: episodes[currentIndex - 1]?.vip}), showEpisodes: showEpisodePanel, showPreferences: section => setPlayerPreferences(true, section)});
|
||||
const controls = window.JukuPlayerControls({panel, video, mobile: () => mobile.active(), rotation: () => mobile.rotation(), canSwipe: () => panel.open && episodes.length > 0 && !panel.classList.contains('episodes-open') && node('playerPreferences').hidden, swipe: direction => {
|
||||
const next = currentIndex + direction;
|
||||
if (next < 1) return '已经是第一集';
|
||||
@@ -281,7 +281,7 @@
|
||||
button.setAttribute('aria-current', String(index === currentIndex));
|
||||
button.classList.toggle('watched', completedEpisodes.has(index));
|
||||
}
|
||||
node('playbackEpisodeCount').textContent = episodes.length ? '第 ' + (episodes[currentIndex - 1]?.episode || currentIndex || '—') + ' 集 / 共 ' + episodes.length + ' 集' : '正在获取分集';
|
||||
node('playbackEpisodeCount').textContent = episodes[currentIndex - 1]?.merged ? episodes[currentIndex - 1].title : episodes.length ? '第 ' + (episodes[currentIndex - 1]?.episode || currentIndex || '—') + ' 集 / 共 ' + episodes.length + ' 集' : '正在获取分集';
|
||||
node('jumpEpisode').max = episodes.length || 1;
|
||||
node('jumpEpisode').disabled = !episodes.length;
|
||||
node('jumpEpisodeBtn').disabled = !episodes.length;
|
||||
@@ -322,10 +322,10 @@
|
||||
episodeButtons = episodes.slice(episodePage * 30, episodePage * 30 + 30).map(episode => {
|
||||
const button = document.createElement('button');
|
||||
button.className = 'secondary';
|
||||
button.textContent = episode.episode + (episode.vip ? ' VIP' : '');
|
||||
button.textContent = episode.merged ? '全集' : episode.episode + (episode.vip ? ' VIP' : '');
|
||||
button.classList.toggle('vip-episode', Boolean(episode.vip));
|
||||
button.title = (episode.title || '第 ' + episode.episode + ' 集') + (episode.vip ? ' · VIP,仅试看' : '');
|
||||
button.setAttribute('aria-label', '播放第 ' + episode.episode + ' 集' + (episode.vip ? ',VIP,仅试看' : ''));
|
||||
button.setAttribute('aria-label', episode.merged ? '播放' + episode.title : '播放第 ' + episode.episode + ' 集' + (episode.vip ? ',VIP,仅试看' : ''));
|
||||
button.dataset.episodeIndex = episode.index;
|
||||
button.addEventListener('click', () => {playEpisode(episode.index); closeEpisodePanel();});
|
||||
episodeList.appendChild(button);
|
||||
@@ -515,8 +515,11 @@
|
||||
}
|
||||
|
||||
function updatePlaybackHint(source) {
|
||||
if (!collectionMode) {
|
||||
node('playbackHint').textContent = '直接观看,不加入下载任务。开启预缓存后,临近播完时提前准备下一集;关闭窗口即停止取流并释放缓存。';
|
||||
if (source === 'merged' || episodes[currentIndex - 1]?.merged) {
|
||||
node('playbackHint').textContent = '正在播放本地合并文件,支持拖动进度和继续观看。';
|
||||
} else if (!collectionMode) {
|
||||
const delivery = mediaPlan?.delivery === 'redirect' ? '当前为客户端直连,视频直接从源站获取。' : mediaPlan?.delivery === 'proxy' ? '当前由服务器中转视频。' : '';
|
||||
node('playbackHint').textContent = delivery + '直接观看,不加入下载任务。开启预缓存后,临近播完时提前准备下一集;关闭窗口即停止取流并释放缓存。';
|
||||
} else {
|
||||
const prefix = source === 'local' ? '本集播放本地已完成文件。' : source === 'online' ? '本集在线缓冲,同时使用原下载任务保存视频。' : '已完成分集优先播放本地,播到未完成分集时自动下载该集。';
|
||||
node('playbackHint').textContent = prefix + '只补下载播到的分集;关闭播放器不取消下载,可在下载合集中暂停或取消。';
|
||||
@@ -691,7 +694,7 @@
|
||||
let reader;
|
||||
try {
|
||||
if (collectionMode && preparedIndex !== index) {
|
||||
statusText.textContent = '正在检查本地分集并准备下载…';
|
||||
statusText.textContent = episodes[index - 1].merged ? '正在读取本地合并文件…' : '正在检查本地分集并准备下载…';
|
||||
const preparation = await requestJSON('/api/ui/playback/prepare', {session: currentSession, episode: index}, signal);
|
||||
if (signal.aborted) throw abortError();
|
||||
preparedIndex = index;
|
||||
|
||||
Reference in New Issue
Block a user