diff --git a/media_downloader.py b/media_downloader.py index 785e162..5eb1977 100644 --- a/media_downloader.py +++ b/media_downloader.py @@ -18,6 +18,7 @@ from module.download_stat import ( update_download_status, update_task_progress, reset_task_progress, + snapshot_current_chat, increment_task_stat, get_task_progress, is_message_skipped, @@ -602,6 +603,8 @@ async def download_chat_task( node: TaskNode, ): """Download all task""" + # 切到下一个频道前,先把上一个频道的最终进度快照进 _completed_chats + snapshot_current_chat() # Reset and update task progress reset_task_progress() diff --git a/module/download_stat.py b/module/download_stat.py index 19b9961..072a80c 100644 --- a/module/download_stat.py +++ b/module/download_stat.py @@ -43,6 +43,12 @@ _task_progress: dict = { "last_update": 0, } +# 多频道任务队列与已完成快照(独立于 _task_progress,不被单频道 reset 影响) +# _task_queue: 任务开始时由 API 设置,[{chat_id, chat_title}, ...] +# _completed_chats: 每切换一个频道前把上一个的最终进度快照到这里 +_task_queue: list = [] +_completed_chats: list = [] + def get_download_result() -> dict: """get global download result""" @@ -62,7 +68,7 @@ def get_download_state() -> DownloadState: def get_task_progress() -> dict: """get task progress with auto-detection of checking state""" progress = _task_progress.copy() - + # Auto-detect if still checking based on last_update time # If last update was within 3 seconds, consider it still active if progress["current_chat"] and progress["last_update"] > 0: @@ -73,10 +79,57 @@ def get_task_progress() -> dict: progress["is_checking"] = True elif progress["skipped_files"] > 0 or progress["checked_messages"] > 0: progress["is_checking"] = False - + + # 附加多频道任务总览,供前端渲染队列卡片 + progress["task_queue"] = list(_task_queue) + progress["completed_chats"] = list(_completed_chats) return progress +def snapshot_current_chat(): + """把当前 _task_progress 的核心字段快照进 _completed_chats。current_chat 为空时 no-op。 + 在多频道串行下载时,每个 download_chat_task 开始前调用,保留上一个频道的最终进度。 + """ + global _completed_chats + chat_id = _task_progress.get("current_chat", "") + if not chat_id: + return + qual = _task_progress.get("qualified_files", 0) or 0 + est = _task_progress.get("estimated_total", 0) or 0 + existing = _task_progress.get("existing_skipped", 0) or 0 + raw_total = est or qual + real_total = max(0, raw_total - existing) + _completed_chats.append({ + "chat_id": chat_id, + "chat_title": _task_progress.get("current_chat_title", "") or chat_id, + "done": _task_progress.get("completed_files", 0) or 0, + "total": real_total, + "skip": _task_progress.get("skipped_files", 0) or 0, + "existing_skip": existing, + "failed": _task_progress.get("failed_files", 0) or 0, + }) + + +def set_task_queue(items: list): + """设置本次任务的完整频道队列。items 每项 {chat_id, chat_title}。""" + global _task_queue + _task_queue = [] + for it in (items or []): + cid = str(it.get("chat_id", "") or "").strip() + if not cid: + continue + _task_queue.append({ + "chat_id": cid, + "chat_title": it.get("chat_title", "") or cid, + }) + + +def clear_completed_chats(): + """新任务启动时清空已完成列表。""" + global _completed_chats + _completed_chats = [] + + def update_task_progress( current_chat: str = None, current_chat_title: str = None, diff --git a/module/templates/index.html b/module/templates/index.html index 7b40b79..a9c19bc 100644 --- a/module/templates/index.html +++ b/module/templates/index.html @@ -195,17 +195,42 @@ .card-hd-badge { font-size: 11px; font-weight: 400; color: var(--muted); } .card-bd { padding: 12px 14px; } - /* 任务横幅 */ + /* 任务队列总览 */ .task-banner { display: none; padding: 10px 14px; border-radius: 8px; background: var(--surface); border: 1px solid var(--border); } - .task-banner.show { display: flex; align-items: center; justify-content: space-between; gap: 12px; } - .task-banner-name { font-weight: 600; color: var(--accent); font-size: 13px; } - .task-banner-filter { font-size: 11px; color: var(--muted); margin-top: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 480px; } - .task-banner-state { font-size: 12px; color: var(--muted); margin-top: 2px; } - .task-banner-left { min-width: 0; flex: 1; } + .task-banner.show { display: block; } + .qbanner-head { + display: flex; align-items: center; justify-content: space-between; + font-size: 12px; color: var(--muted); margin-bottom: 8px; + } + .qbanner-title { font-weight: 600; color: var(--text); font-size: 13px; } + .qbanner-summary { font-size: 11px; } + + /* 队列卡片(已完成 / 当前 / 排队中) */ + .qcard { + display: flex; align-items: flex-start; gap: 10px; + padding: 8px 10px; border-radius: 6px; + margin-bottom: 6px; + border: 1px solid transparent; + } + .qcard:last-child { margin-bottom: 0; } + .qcard-icon { font-size: 14px; line-height: 1.5; flex-shrink: 0; } + .qcard-main { flex: 1; min-width: 0; } + .qcard-name { font-weight: 600; font-size: 13px; color: var(--text); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .qcard-state { font-size: 11px; color: var(--muted); margin-top: 2px; } + .qcard-prog { + height: 4px; background: rgba(255,255,255,.12); + border-radius: 2px; overflow: hidden; margin-top: 6px; + } + .qcard-prog-fill { height: 100%; width: 0%; background: #4caf50; transition: width .3s; } + .qcard-active { background: rgba(74,144,226,.08); border-color: rgba(74,144,226,.25); } + .qcard-active .qcard-name { color: var(--accent); } + .qcard-done { background: rgba(76,175,80,.07); opacity: .85; } + .qcard-pending { background: rgba(128,128,128,.06); opacity: .6; } /* 历史频道卡片 */ .hist-head { @@ -687,9 +712,6 @@ /* 下载项操作按钮加大便于点按 */ .dl-btn { width: 28px; height: 28px; font-size: 13px; } - /* 任务横幅过滤表达式不限宽 */ - .task-banner-filter { max-width: none; } - /* 列表高度减小 */ #dl-list, #done-list { height: 220px; } @@ -980,23 +1002,13 @@
- +
-
- - - - - +
+ 任务队列 +
-
当前任务
+
@@ -1126,13 +1138,6 @@ return parts.join(' · '); } - function updateBannerFilter() { - const el = document.getElementById('banner-filter'); - const txt = parseFilterDisplay(currentFilter); - if (txt) { el.textContent = txt; el.style.display = ''; } - else { el.textContent = ''; el.style.display = 'none'; } - } - function loadConfig() { fetch('/api/get_config').then(r => r.json()).then(d => { const p = d.save_path || '默认路径'; @@ -1140,7 +1145,6 @@ el.textContent = p; el.title = p; document.getElementById('path-input').value = d.save_path || ''; currentFilter = d.download_filter || ''; - updateBannerFilter(); }).catch(() => {}); } @@ -1448,6 +1452,121 @@ }).catch(() => {}).finally(() => { btn.disabled = false; }); } + // ── 任务队列总览渲染 ── + function renderTaskQueue(p) { + const banner = document.getElementById('task-banner'); + const list = document.getElementById('qbanner-list'); + const summary = document.getElementById('qbanner-summary'); + + const queue = Array.isArray(p.task_queue) ? p.task_queue : []; + const completed = Array.isArray(p.completed_chats) ? p.completed_chats : []; + const currentChat = p.current_chat || ''; + + // 若没有队列也没有当前任务,隐藏整个 banner + if (!queue.length && !currentChat) { + banner.classList.remove('show'); + list.innerHTML = ''; + summary.textContent = ''; + return; + } + banner.classList.add('show'); + + // completed 按 chat_id 建索引 + const completedMap = {}; + completed.forEach(c => { completedMap[c.chat_id] = c; }); + + // 兜底:队列为空但有 current(多频道队列元信息还没写入时),用 current 顶一下 + const effectiveQueue = queue.length + ? queue + : [{ chat_id: currentChat, chat_title: p.current_chat_title || currentChat }]; + + const paused = dlState === 'continue'; + let doneCount = 0, runningCount = 0; + + list.innerHTML = effectiveQueue.map(q => { + if (completedMap[q.chat_id]) { + doneCount++; + return renderDoneCard(q, completedMap[q.chat_id]); + } else if (q.chat_id === currentChat) { + runningCount++; + return renderActiveCard(q, p, paused); + } else { + return renderPendingCard(q); + } + }).join(''); + + summary.textContent = `${doneCount}/${effectiveQueue.length} 完成`; + } + + function renderDoneCard(q, c) { + const title = escapeHtml(q.chat_title || q.chat_id); + const skipStr = (c.existing_skip && c.existing_skip > 0) + ? `跳过${c.skip||0},其中本次跳过${c.existing_skip}` + : `跳过${c.skip||0}`; + const totalStr = (c.total && c.total > 0) + ? `${c.done||0} / ${c.total} 完成` + : `${c.done||0} 个完成`; + return ` +
+
+
+
${title}
+
${totalStr},${skipStr}
+
+
`; + } + + function renderActiveCard(q, p, paused) { + const title = escapeHtml(q.chat_title || p.current_chat_title || q.chat_id); + const dl = p.downloading_files||0, done = p.completed_files||0, skip = p.skipped_files||0; + const qual = p.qualified_files||0, est = p.estimated_total||0; + const existingSkip = p.existing_skipped || 0; + const rawTotal = est || qual; + const realTotal = Math.max(0, rawTotal - existingSkip); + const doneStr = rawTotal ? `${done} / ${realTotal}` : `${done}`; + const skipStr = existingSkip > 0 ? `跳过${skip},其中本次跳过${existingSkip}` : `跳过${skip}`; + + let st = ''; + if (p.is_checking && paused) st = `⏸ 已暂停 · 扫描中… (${skipStr})`; + else if (p.is_checking && dl>0) st = `🔍 扫描+下载中 (${dl}个,${doneStr},${skipStr})`; + else if (p.is_checking) st = `🔍 扫描中… (${doneStr},${skipStr})`; + else if (paused) st = `⏸ 已暂停 (${doneStr},${skipStr})`; + else if (dl>0) st = `🚀 下载中 (${dl}个,${doneStr},${skipStr})`; + else if (done>0||skip>0) st = `✅ 完成 (${doneStr},${skipStr})`; + else st = '⏳ 等待开始'; + + let progBar = ''; + if (realTotal > 0) { + const pct = Math.min(100, Math.round(done * 100 / realTotal)); + const suffix = (!est && p.is_checking) ? '(扫描中…)' : ''; + progBar = ` +
+
${done} / ${realTotal}${suffix}
`; + } + + return ` +
+
🚀
+
+
${title}
+
${st}
+ ${progBar} +
+
`; + } + + function renderPendingCard(q) { + const title = escapeHtml(q.chat_title || q.chat_id); + return ` +
+
+
+
${title}
+
排队中…
+
+
`; + } + // ── 轮询 ── function poll() { fetch('/get_download_status').then(r => r.json()).then(d => { @@ -1457,46 +1576,7 @@ fetch('/api/task_progress').then(r => r.json()).then(p => { document.getElementById('h-skip').textContent = p.skipped_files || 0; - const banner = document.getElementById('task-banner'); - const paused = dlState === 'continue'; - if (p.current_chat) { - banner.classList.add('show'); - const title = p.current_chat_title || p.current_chat; - document.getElementById('banner-chat').textContent = title; - const dl = p.downloading_files||0, done = p.completed_files||0, skip = p.skipped_files||0; - const qual = p.qualified_files||0, est = p.estimated_total||0; - const existingSkip = p.existing_skipped || 0; - // 分母优先用缓存值;没有就用当次遍历实时累加的 qualified。再扣除"本次任务已跳过",得到真正要下载的数量 - const rawTotal = est || qual; - const realTotal = Math.max(0, rawTotal - existingSkip); - const doneStr = rawTotal ? `${done} / ${realTotal}` : `${done}`; - // 跳过文案:有本次跳过时追加说明 - const skipStr = existingSkip > 0 ? `跳过${skip},其中本次跳过${existingSkip}` : `跳过${skip}`; - let st = ''; - if (p.is_checking && paused) st = `⏸ 已暂停 · 扫描中… (${skipStr})`; - else if (p.is_checking && dl>0) st = `🔍 扫描+下载中 (${dl}个,${doneStr},${skipStr})`; - else if (p.is_checking) st = `🔍 扫描中… (${doneStr},${skipStr})`; - else if (paused) st = `⏸ 已暂停 (${doneStr},${skipStr})`; - else if (dl>0) st = `🚀 下载中 (${dl}个,${doneStr},${skipStr})`; - else if (done>0||skip>0) st = `✅ 完成 (${doneStr},${skipStr})`; - else st = '⏳ 等待开始'; - document.getElementById('banner-state').textContent = st; - - // 进度条:有总数才显示;扫描中且无缓存时给"扫描中…"后缀 - const prog = document.getElementById('banner-progress'); - if (realTotal > 0) { - prog.style.display = 'flex'; - const pct = Math.min(100, Math.round(done * 100 / realTotal)); - document.getElementById('tbp-fill').style.width = pct + '%'; - const suffix = (!est && p.is_checking) ? '(扫描中…)' : ''; - document.getElementById('tbp-text').textContent = `${done} / ${realTotal}${suffix}`; - } else { - prog.style.display = 'none'; - } - } else { - banner.classList.remove('show'); - document.getElementById('banner-progress').style.display = 'none'; - } + renderTaskQueue(p); }).catch(() => {}); fetch('/get_download_list?already_down=false').then(r => r.json()).then(data => { diff --git a/module/web.py b/module/web.py index e8218e7..a85f37a 100644 --- a/module/web.py +++ b/module/web.py @@ -19,6 +19,8 @@ import utils from module.app import Application, ChatDownloadConfig, TaskNode from module.download_stat import ( DownloadState, + clear_completed_chats, + set_task_queue, get_download_result, get_download_state, get_total_download_speed, @@ -840,12 +842,16 @@ def api_save_and_restart(): # Add to channel history _add_to_channel_history(chat_id, chat_title, chat_type) - + + # 重置队列总览元信息(单频道场景队列就一项) + clear_completed_chats() + set_task_queue([{"chat_id": chat_id, "chat_title": chat_title or chat_id}]) + # Trigger restart if _app: _app.restart_program = True logger.info(f"Restart flag set, new chat_id: {chat_id} ({chat_title})") - + return jsonify({ "success": True, "message": f"配置已保存,正在重启下载 {chat_title or chat_id}..." @@ -897,6 +903,13 @@ def api_save_and_restart_multi(): for it in normalized: _add_to_channel_history(it["chat_id"], it["chat_title"], it["chat_type"]) + # 重置并设置本次任务的完整队列,供前端渲染"任务队列"卡片 + clear_completed_chats() + set_task_queue([ + {"chat_id": it["chat_id"], "chat_title": it["chat_title"] or it["chat_id"]} + for it in normalized + ]) + # 触发重启 if _app: _app.restart_program = True