同步上游 zip:合并任务后台化 + 合并整剧可直接播放;保留 ultrafast+4线程 转码优化与 .gitignore 本地规则

This commit is contained in:
yvan
2026-09-18 10:16:15 +08:00
parent 80485e9dba
commit b96363d787
21 changed files with 1173 additions and 172 deletions
+24 -10
View File
@@ -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);
+1
View File
@@ -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);
}
+1 -1
View File
@@ -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>
+10 -2
View File
@@ -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};
}
+2 -2
View File
@@ -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 || '自动';
+10 -7
View File
@@ -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;