import { reconcileChildren, withFocus, readPreference, savePreference } from './ui-core.js'; import { $, element, empty, button, setMessage, api, post, valueText, firstNonEmpty, dramaTitle, watchLabel, historySuffix, normalizeSource, sourceKey, sourceLabel, categoryName, episodeCount, coverURL, tagsText, dramaSearchText, rebuildOptions, number, formatBytes, formatTime, statusText, releaseText, phaseText, progressText, progressBar, episodeLabel, groupStats } from './ui-core.js'; export function createDownloads(app) { let tasks = [], visibleGroups = [], mergeStates = {}, taskRequest = false, ffmpegStatus = ''; const selectedTasks = new Set(), openGroups = new Set(), groupsEl = $('groups'); let pollTimer, pollPromise, batch = false, taskSignature = '', ffmpegSignature = ''; let hasActiveTasks = false, polling = false, scheduledDelay = 0; const qualityOptions = [0, 2160, 1440, 1080, 720, 540, 480, 360]; const storedQuality = number(readPreference('downloadQuality', 0)); let downloadQuality = qualityOptions.includes(storedQuality) ? storedQuality : 0; const qualityLabel = value => value ? value + 'p 优先' : '最高可用'; function setupQualitySelect(select) { select.dataset.downloadQuality = ''; for (const quality of qualityOptions) { const option = element('option', '', qualityLabel(quality)); option.value = String(quality); select.appendChild(option); } select.value = String(downloadQuality); select.title = '用于新加入的下载任务;缺少所选档位时取不高于该档位的最高画质,全部更高时取最低可用画质。保留源文件,不压缩。'; select.addEventListener('change', () => { downloadQuality = number(select.value); savePreference('downloadQuality', downloadQuality); document.querySelectorAll('[data-download-quality]').forEach(node => {node.value = String(downloadQuality);}); }); } function qualityControl() { const label = element('label', 'download-quality', '下载画质'); const select = element('select'); select.setAttribute('aria-label', '本次下载清晰度'); select.dataset.focusKey = 'detail-download-quality'; setupQualitySelect(select); 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;});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;} function selectGroup(group, checked){group.tasks.forEach(task=>{if(checked)selectedTasks.add(task.id);else selectedTasks.delete(task.id);});renderTasks(tasks);} function selectedTaskList(){return tasks.filter(task=>selectedTasks.has(task.id));} function selectedGroupIDs(){return Array.from(new Set(selectedTaskList().map(task=>task.dramaId)));} function updateTaskSelection(){const visible=new Set(visibleGroups.flatMap(group=>group.tasks.map(task=>task.id)));const hidden=selectedTaskList().filter(task=>!visible.has(task.id)).length;$('taskSelection').textContent='已选 '+selectedTasks.size+' 集 / '+selectedGroupIDs().length+' 部'+(hidden?'(隐藏 '+hidden+' 集)':'');for(const id of ['pauseSelectedBtn','resumeSelectedBtn','cancelSelectedBtn','retrySelectedBtn','updateSelectedBtn','mergeSelectedBtn','clearTasksBtn'])$(id).disabled=selectedTasks.size===0;} function renderTasks(next) { if (app.viewer?.onlineOnly) return; if (tasks !== next) taskSignature = ''; tasks = next; if (selectedTasks.size) { const existing = new Set(tasks.map(task => task.id)); selectedTasks.forEach(id => {if (!existing.has(id)) selectedTasks.delete(id);}); } let active = 0, completed = 0, written = 0; for (const task of tasks) { if (['running', 'queued', 'parsing'].includes(task.status)) active++; if (task.status === 'success') completed++; written += number(task.downloadedBytes); } const setText = (node, value) => {if (node.textContent !== String(value)) node.textContent = value;}; setText($('downloadActiveCount'), active); setText($('downloadCompleteCount'), completed); setText($('downloadWrittenBytes'), formatBytes(written)); document.querySelectorAll('[data-download-count]').forEach(node => {setText(node, active); if (node.hidden !== !active) node.hidden = !active;}); hasActiveTasks = active > 0; const showActivity = !$('activityPanel').hidden && !$('libraryPage').hidden; if ($('downloadsPage').hidden && !showActivity) return; const all = buildGroups(); if (!$('downloadsPage').hidden) { visibleGroups = all.filter(groupMatches); setText($('taskCount'), all.length ? visibleGroups.length + ' 部' : ''); const previous = new Map(Array.from(groupsEl.querySelectorAll('.group'), node => [node.dataset.dramaId, node])); withFocus(groupsEl, () => { const nodes = []; for (const group of visibleGroups) { const old = previous.get(group.id); const key = JSON.stringify([group, mergeStates[group.id], openGroups.has(group.id), batch, group.tasks.map(task => selectedTasks.has(task.id)), historySuffix(group.id)]); if (old?.renderKey === key) {nodes.push(old); continue;} const menuOpen = old?.querySelector('.action-menu')?.open; const node = renderGroup(group); node.renderKey = key; if (menuOpen) node.querySelector('.action-menu').open = true; nodes.push(node); } if (!visibleGroups.length) { const empty = element('div', 'empty'); empty.append(element('strong', '', tasks.length ? '没有匹配的下载合集' : '还没有下载任务'), element('p', '', tasks.length ? '试试其他筛选条件。' : '在剧集详情加入下载,也可以在剧库批量选择。')); if (!tasks.length) empty.appendChild(button('去剧库看看', () => window.JukuDialogs.navigate('library'), false, 'secondary')); nodes.push(empty); } reconcileChildren(groupsEl, nodes); }); updateTaskSelection(); } if (showActivity) renderActivity(all); } 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'); const heading = element('div', 'group-heading'); const checkbox = element('input'); checkbox.type = 'checkbox'; 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 = 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 = group.tasks.length ? [releaseText(group.release), '共 ' + group.tasks.length + ' 集', '完成 ' + stats.success] : ['本地合并视频']; const qualities = [...new Set(group.tasks.map(task => number(task.downloadQuality)))]; 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))); info.appendChild(meta); const selection = element('label', 'selection-target'); 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' : ''))); 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); node.dataset.focusKey = group.id + '-' + key; return node; } const playable = group.tasks.filter(task => task.playable).sort((left, right) => number(left.index) - number(right.index))[0]; 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 || ['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); const list = element('div', 'episode-list'); if (openGroups.has(group.id)) for (const task of group.tasks) { const episode = renderEpisode(task); episode.querySelectorAll('button,input').forEach((control, index) => control.dataset.focusKey = 'task-' + task.id + '-' + index); list.appendChild(episode); } wrap.append(head, list); return wrap; } function renderEpisode(task){ const row=element('div','episode');const checkbox=element('input');checkbox.type='checkbox';checkbox.checked=selectedTasks.has(task.id);checkbox.setAttribute('aria-label','选择 '+episodeLabel(task));checkbox.addEventListener('change',()=>{if(checkbox.checked)selectedTasks.add(task.id);else selectedTasks.delete(task.id);renderTasks(tasks);});const selection=element('label','selection-target');selection.appendChild(checkbox);row.appendChild(selection);const content=element('div','episode-main');const heading=element('div','episode-heading');heading.appendChild(element('span','pill '+task.status,phaseText(task)));heading.appendChild(element('strong','',task.status==='parsing'||task.title==='章节获取失败'?task.title:episodeLabel(task)));content.appendChild(heading);content.appendChild(element('div','small episode-path',task.path||''));if(task.error)content.appendChild(element('div','error',task.error));content.appendChild(progressBar(task.progress));content.appendChild(element('div','small',progressText(task)+' · 已写入 '+formatBytes(task.downloadedBytes)+(number(task.totalBytes)?' / '+formatBytes(task.totalBytes):'')+' · '+(number(task.speedBytesPerSecond)>0?formatBytes(task.speedBytesPerSecond)+'/s':'速度 —')));content.appendChild(element('div','small','已用 '+formatTime(task.elapsedSeconds)+' · 剩余 '+formatTime(task.remainingSeconds)+' · 尝试 '+(task.attempt||0))); const actions=element('div','episode-actions');if(task.playable)actions.appendChild(button('播放',()=>window.dramaPlayer.openCollection(task.id,task.dramaTitle),false,'secondary episode-play-button'));if(['running','queued','parsing'].includes(task.status))actions.appendChild(button('暂停',()=>taskAction('pause',[task.id]),task.pauseRequested));if(task.status==='paused')actions.appendChild(button('继续',()=>taskAction('resume',[task.id])));if(['running','queued','parsing','paused'].includes(task.status))actions.appendChild(button('取消',()=>taskAction('cancel',[task.id]),task.cancelRequested));if(['failed','canceled'].includes(task.status))actions.appendChild(button('重试',()=>taskAction('retry',[task.id])));content.appendChild(actions);row.appendChild(content);return row; } async function retryFFmpeg(){try{await post('/api/ui/ffmpeg',{});setMessage('已重新检查 FFmpeg,准备进度会自动更新');await pollTasks();}catch(error){setMessage(error.message,true);}} function renderFFmpeg(state){if(!state)return;const retryCovers=ffmpegStatus&&ffmpegStatus!=='ready'&&state.status==='ready';ffmpegStatus=state.status;let text=state.detail||'首次使用时自动准备 FFmpeg';if(state.status==='downloading'&&number(state.totalBytes)>0)text+=' · '+formatBytes(state.downloadedBytes)+' / '+formatBytes(state.totalBytes);if(state.error)text+=':'+state.error;for(const id of ['ffmpegNotice','ffmpegSettingsStatus']){const target=$(id);empty(target);target.appendChild(element('span',state.status==='failed'?'error':'',text));if(state.status==='failed'&&app.viewer?.account?.admin)target.appendChild(button('重新检查 FFmpeg',retryFFmpeg));if(id==='ffmpegNotice')target.hidden=!['downloading','verifying','failed'].includes(state.status);}if(retryCovers)app.library.retryCovers();window.dramaPlayer?.updateDependency(state,text);} async function pollTasks() { if (app.viewer?.onlineOnly) return; if (taskRequest) return pollPromise; taskRequest = true; pollPromise = (async () => { try { const result = await api('/api/ui/tasks'); const signature = JSON.stringify([result.data || [], result.merges || {}]); if (signature !== taskSignature) { mergeStates = result.merges || {}; renderTasks(result.data || []); taskSignature = signature; } const nextFFmpeg = JSON.stringify(result.ffmpeg || null); if (nextFFmpeg !== ffmpegSignature) {ffmpegSignature = nextFFmpeg; renderFFmpeg(result.ffmpeg);} $('taskError').textContent = ''; } catch (error) { $('taskError').textContent = '任务刷新失败:' + error.message; } finally {taskRequest = false; schedule();} })(); return pollPromise; } async function updateGroups(ids){if(!ids.length){setMessage('请先勾选下载合集',true);return;}try{await post('/api/ui/update',{ids});setMessage('已提交 '+ids.length+' 部更新检查,已有文件保留,只补充新增或缺失分集');await pollTasks();}catch(error){$('taskError').textContent='更新失败:'+error.message;}} async function taskAction(action,ids,dramaIds=[]){const count=dramaIds.length||ids.length;const unit=dramaIds.length?' 部合集':' 个任务';if(!count){setMessage('所选任务中没有可执行此操作的项',true);return;}if(action==='cancel'&&!confirm('取消所选 '+count+unit+'?已完成文件保留,可稍后重试。'))return;try{const result=await post('/api/ui/tasks/'+action,dramaIds.length?{dramaIds}:{ids});$('taskError').textContent='';if(result.data)renderTasks(result.data);setMessage('已提交'+({pause:'暂停',resume:'继续',cancel:'取消',retry:'重试'}[action]||action)+':'+count+unit);await pollTasks();}catch(error){$('taskError').textContent='操作失败:'+error.message;}} 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; 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) { const ordered = all.slice().sort((left, right) => { const a = groupStats(left), b = groupStats(right); return Boolean(b.running + b.queued + b.parsing) - Boolean(a.running + a.queued + a.parsing); }); const fragment = document.createDocumentFragment(); for (const group of ordered.slice(0, 3)) { const stats = groupStats(group), row = element('div', 'activity-item'); row.append(element('h3', '', group.title), element('p', 'small', stats.success + ' / ' + group.tasks.length + ' 集已完成'), progressBar(stats.percent)); fragment.appendChild(row); } if (!ordered.length) fragment.appendChild(element('p', 'small', '暂无下载任务,可在剧集卡片上直接加入下载。')); $('activityTasks').replaceChildren(fragment); } async function enqueue(ids) { if (app.viewer?.onlineOnly) {setMessage('当前账号仅可在线观看', true); return false;} if (!ids.length) return false; try { const quality = downloadQuality; await post('/api/ui/download', {ids, quality}); setMessage('已加入下载队列 · ' + ids.length + ' 部 · ' + qualityLabel(quality), false, {label: '查看下载', run: () => window.JukuDialogs.navigate('downloads')}); await pollTasks(); return true; } catch (error) {setMessage('加入下载失败:' + error.message, true); return false;} } function persistFilters() { savePreference('downloadFilters', {search: $('taskSearch').value, status: $('taskStatus').value, release: $('releaseStatus').value}); } function toggleBatch() { batch = !batch; if (!batch) selectedTasks.clear(); document.body.classList.toggle('download-batch', batch); $('downloadBatchControls').hidden = !batch; $('downloadBatchBtn').setAttribute('aria-pressed', String(batch)); $('downloadBatchBtn').textContent = batch ? '退出批量' : '批量管理'; renderTasks(tasks); } function schedule(restart = true) { if (app.viewer?.onlineOnly) return; 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); pollTimer = 0; if (!polling) return; scheduledDelay = delay; pollTimer = setTimeout(() => {pollTimer = 0; pollTasks();}, delay); } function init() { if (app.viewer?.onlineOnly) return; document.querySelectorAll('[data-download-quality]').forEach(setupQualitySelect); const stored = readPreference('downloadFilters', {}), saved = stored && typeof stored === 'object' ? stored : {}; $('taskSearch').value = typeof saved.search === 'string' ? saved.search.slice(0, 80) : ''; for (const [id, value] of [['taskStatus', saved.status], ['releaseStatus', saved.release]]) { $(id).value = Array.from($(id).options).some(option => option.value === value) ? value : ''; } for (const id of ['taskStatus', 'releaseStatus']) $(id).addEventListener('change', () => {persistFilters(); renderTasks(tasks);}); $('taskSearch').addEventListener('input', () => {persistFilters(); renderTasks(tasks);}); $('downloadBatchBtn').addEventListener('click', toggleBatch); $('selectGroupsBtn').addEventListener('click', () => {visibleGroups.forEach(group => group.tasks.forEach(task => selectedTasks.add(task.id))); renderTasks(tasks);}); $('invertGroupsBtn').addEventListener('click', () => {visibleGroups.forEach(group => {const checked = !group.tasks.every(task => selectedTasks.has(task.id)); group.tasks.forEach(task => checked ? selectedTasks.add(task.id) : selectedTasks.delete(task.id));}); renderTasks(tasks);}); $('deselectGroupsBtn').addEventListener('click', () => {selectedTasks.clear(); renderTasks(tasks);}); for (const [id, action, statuses] of [['pauseSelectedBtn','pause',['running','queued','parsing']],['resumeSelectedBtn','resume',['paused']],['cancelSelectedBtn','cancel',['running','queued','parsing','paused']],['retrySelectedBtn','retry',['failed','canceled']]]) $(id).addEventListener('click', () => taskAction(action, selectedTaskList().filter(task => statuses.includes(task.status)).map(task => task.id))); $('updateSelectedBtn').addEventListener('click', () => updateGroups(selectedGroupIDs())); $('mergeSelectedBtn').addEventListener('click', () => mergeGroups(selectedGroupIDs())); $('clearTasksBtn').addEventListener('click', clearTasks); window.addEventListener('downloadsChanged', pollTasks); document.addEventListener('visibilitychange', () => {if (!document.hidden) pollTasks(); schedule();}); window.addEventListener('pagehide', () => {polling = false; clearTimeout(pollTimer);}); window.addEventListener('pageshow', event => {polling = true; if (event.persisted) pollTasks(); else schedule();}); polling = true; return pollTasks(); } return {init, refresh: pollTasks, render: () => {renderTasks(tasks); schedule(false);}, enqueue, qualityControl, quality: () => downloadQuality, all: () => tasks}; }