// ==UserScript== // @name Telegram 电报媒体下载器增强版 // @name:zh-CN Telegram 电报媒体下载器增强版 // @namespace https://github.com/Neet-Nestor/Telegram-Media-Downloader // @version 2.0.28 // @description Telegram 电报媒体下载器增强版:任务队列、并发分片、自动重试、点选批量下载、速度显示、历史记录、设置与桌面通知。 // @description:zh-CN Telegram 电报媒体下载器增强版:任务队列、并发分片、自动重试、点选批量下载、速度显示、历史记录、设置与桌面通知。 // @author Enhanced by Claude (based on Nestor Qin's work) // @license GNU GPLv3 // @match https://web.telegram.org/* // @match https://webk.telegram.org/* // @match https://webz.telegram.org/* // @icon https://img.icons8.com/color/452/telegram-app--v5.png // @grant unsafeWindow // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_listValues // @grant GM_notification // @grant GM_registerMenuCommand // @run-at document-idle // @downloadURL https://update.greasyfork.icu/scripts/576114/Telegram%20%E7%94%B5%E6%8A%A5%E5%AA%92%E4%BD%93%E4%B8%8B%E8%BD%BD%E5%99%A8%E5%A2%9E%E5%BC%BA%E7%89%88.user.js // @updateURL https://update.greasyfork.icu/scripts/576114/Telegram%20%E7%94%B5%E6%8A%A5%E5%AA%92%E4%BD%93%E4%B8%8B%E8%BD%BD%E5%99%A8%E5%A2%9E%E5%BC%BA%E7%89%88.meta.js // ==/UserScript== (function () { "use strict"; // ============================================================ // 0. 工具函数 & 存储适配(兼容非 GM 环境) // ============================================================ const GM = { get(key, def) { try { if (typeof GM_getValue === "function") return GM_getValue(key, def); } catch (_) {} const v = localStorage.getItem("tmd_" + key); if (v === null) return def; try { return JSON.parse(v); } catch { return def; } }, set(key, val) { try { if (typeof GM_setValue === "function") return GM_setValue(key, val); } catch (_) {} localStorage.setItem("tmd_" + key, JSON.stringify(val)); }, notify(opts) { try { if (typeof GM_notification === "function") return GM_notification(opts); } catch (_) {} if ("Notification" in window && Notification.permission === "granted") { new Notification(opts.title || "Telegram 电报媒体下载器增强版", { body: opts.text }); } }, menu(label, fn) { try { if (typeof GM_registerMenuCommand === "function") GM_registerMenuCommand(label, fn); } catch (_) {} }, }; const DEFAULTS = { chunkConcurrency: 4, maxChunkConcurrency: 18, maxRetries: 3, retryDelayMs: 1500, queueConcurrency: 5, nameWithDate: true, nameWithChannel: true, notifyOnComplete: true, historyDedup: false, panelCollapsed: false, rowSweepSelect: true, useFileSystemAccess: false, // false: 下载完成后弹一次浏览器保存框;true: 开始前选位置,流式写入磁盘 }; const settings = Object.assign({}, DEFAULTS, GM.get("settings", {})); const saveSettings = () => GM.set("settings", settings); const logger = { info: (m, tag) => console.log(`[TMD]${tag ? ` ${tag}:` : ""} ${m}`), warn: (m, tag) => console.warn(`[TMD]${tag ? ` ${tag}:` : ""} ${m}`), error: (m, tag) => console.error(`[TMD]${tag ? ` ${tag}:` : ""} ${m}`), }; const REFRESH_DELAY = 500; const DOWNLOAD_ICON = "\ue979"; const contentRangeRegex = /^bytes (\d+)-(\d+)\/(\d+)$/; const WIN = typeof unsafeWindow !== "undefined" ? unsafeWindow : window; const uid = () => (Math.random() + 1).toString(36).slice(2, 10) + "_" + Date.now().toString(36); const hashCode = (s) => { let h = 0; for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0; return (h >>> 0).toString(36); }; const formatBytes = (n) => { if (!n || n < 0) return "0 B"; const u = ["B", "KB", "MB", "GB", "TB"]; let i = 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } return `${n.toFixed(n >= 10 || i === 0 ? 0 : 1)} ${u[i]}`; }; const formatDuration = (s) => { if (!isFinite(s) || s < 0) return "--"; if (s < 60) return `${s | 0}s`; if (s < 3600) return `${(s / 60) | 0}m${(s % 60) | 0}s`; return `${(s / 3600) | 0}h${((s % 3600) / 60) | 0}m`; }; const sanitizeFilename = (name) => name.replace(/[\\/:*?"<>|]/g, "_").replace(/\s+/g, " ").slice(0, 180); const defaultExtForKind = (kind) => kind === "audio" ? "ogg" : kind === "image" ? "jpeg" : "mp4"; const normalizeOriginalFileName = (name, kind) => { if (!name || typeof name !== "string") return ""; let safe = name.trim().replace(/[?#].*$/, "").split(/[\\/]/).pop(); if (!safe || /^\(?unknown(?: file)?\)?$/i.test(safe)) return ""; safe = sanitizeFilename(safe).replace(/^\.+/, "").trim(); if (!safe) return ""; if (!/\.[A-Za-z0-9]{1,8}$/.test(safe)) safe += "." + defaultExtForKind(kind); return safe; }; const isDarkMode = () => { const html = document.querySelector("html"); return html.classList.contains("night") || html.classList.contains("theme-dark"); }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const videoSrcOf = (video) => video ? (video.currentSrc || video.src || video.querySelector("source")?.src || "") : ""; const pickWritableDirectory = async () => { const pickFn = WIN.showDirectoryPicker || window.showDirectoryPicker; if (!pickFn) throw new Error("当前浏览器不支持目录选择器(需要 Chrome / Edge / Safari 新版)"); const handle = await pickFn.call(WIN, { mode: "readwrite" }); if (handle.requestPermission) { const p = await handle.requestPermission({ mode: "readwrite" }); if (p !== "granted") throw new Error("写权限被拒绝(请重新选择目录并点「允许」)"); } const probe = "_tmd_probe_" + Date.now() + ".tmp"; const fh = await handle.getFileHandle(probe, { create: true }); const w = await fh.createWritable(); await w.write(new Blob(["ok"])); await w.close(); try { await handle.removeEntry(probe); } catch {} return handle; }; const makeDownloadButton = ({ className, html, onClick, ariaLabel }) => { const b = document.createElement("button"); b.className = className; b.type = "button"; b.title = "Download"; if (ariaLabel) b.setAttribute("aria-label", ariaLabel); b.innerHTML = html; if (onClick) b.onclick = onClick; return b; }; const hideNativeDownloadButtons = (root, isDownload) => { root.querySelectorAll("button:not(.tel-download)").forEach((btn) => { if (isDownload(btn) && btn.style.display !== "none") btn.style.display = "none"; }); }; const removeAll = (root, selector) => root && root.querySelectorAll(selector).forEach((el) => el.remove()); // ============================================================ // 1. 下载历史(用于去重) // ============================================================ const history = { _set: new Set(GM.get("history", [])), has(key) { return this._set.has(key); }, add(key) { this._set.add(key); if (this._set.size > 2000) { const arr = Array.from(this._set); this._set = new Set(arr.slice(-1500)); } GM.set("history", Array.from(this._set)); }, clear() { this._set.clear(); GM.set("history", []); }, }; // ============================================================ // 2. 下载任务 // ============================================================ const TaskState = { PENDING: "pending", RUNNING: "running", PAUSED: "paused", COMPLETED: "completed", FAILED: "failed", CANCELLED: "cancelled", }; class DownloadTask { constructor({ url, kind, fileName, context }) { this.id = uid(); this.url = url; this.kind = kind; // 'video' | 'audio' | 'image' this.context = context || {}; const originalName = normalizeOriginalFileName( fileName || this.context.originalName || this.context.fileName, this.kind ); this._preserveFileName = !!originalName; this.fileName = originalName || this._deriveFileName(); this.state = TaskState.PENDING; this.totalSize = 0; this.downloaded = 0; this.retries = 0; this.error = null; this.startedAt = 0; this.finishedAt = 0; this._blobs = []; // offset -> blob this._writable = null; this._aborter = new AbortController(); this._paused = false; this._samples = []; // [{t, bytes}] this._listeners = new Set(); } _deriveFileName() { const ext = defaultExtForKind(this.kind); let base = hashCode(this.url); try { const last = decodeURIComponent(this.url.split("/").pop()); const meta = JSON.parse(last); const originalName = normalizeOriginalFileName(meta.fileName, this.kind); if (originalName) { this._preserveFileName = true; return originalName; } } catch {} const parts = []; if (settings.nameWithDate) { const d = new Date(); const pad = (n) => String(n).padStart(2, "0"); parts.push(`${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`); } if (settings.nameWithChannel && this.context.channel) { parts.push(this.context.channel); } parts.push(base); return sanitizeFilename(parts.join("_")) + "." + ext; } on(fn) { this._listeners.add(fn); return () => this._listeners.delete(fn); } _emit() { this._listeners.forEach((fn) => { try { fn(this); } catch {} }); } get progress() { return this.totalSize ? Math.min(100, (this.downloaded * 100) / this.totalSize) : 0; } get speed() { const now = Date.now(); this._samples = this._samples.filter((s) => now - s.t < 4000); if (this._samples.length < 2) return 0; const first = this._samples[0]; const last = this._samples[this._samples.length - 1]; const dt = (last.t - first.t) / 1000; return dt > 0 ? (last.bytes - first.bytes) / dt : 0; } get eta() { const s = this.speed; if (!s || !this.totalSize) return Infinity; return Math.max(0, (this.totalSize - this.downloaded) / s); } pause() { if (this.state !== TaskState.RUNNING) return; this._paused = true; this.state = TaskState.PAUSED; this._emit(); } resume() { if (this.state !== TaskState.PAUSED) return; this._paused = false; this.state = TaskState.RUNNING; this._emit(); queue._kick(); } cancel() { if ([TaskState.COMPLETED, TaskState.CANCELLED].includes(this.state)) return; this.state = TaskState.CANCELLED; this._aborter.abort(); this._blobs = []; this._emit(); } _recordBytes(delta) { this.downloaded += delta; this._samples.push({ t: Date.now(), bytes: this.downloaded }); } async _fetchRange(startOffset, endOffset) { const rangeHdr = endOffset !== undefined && endOffset !== null ? `bytes=${startOffset}-${endOffset}` : `bytes=${startOffset}-`; const res = await fetch(this.url, { method: "GET", headers: { Range: rangeHdr }, signal: this._aborter.signal, }); if (![200, 206].includes(res.status)) { throw new Error(`HTTP ${res.status}`); } const mime = (res.headers.get("Content-Type") || "").split(";")[0]; if (this.kind === "video" && !mime.startsWith("video/") && !mime.startsWith("application/")) throw new Error("Unexpected MIME: " + mime); if (this.kind === "audio" && !mime.startsWith("audio/")) throw new Error("Unexpected MIME: " + mime); if (!this._preserveFileName && this.kind === "video" && mime.startsWith("video/")) { const ext = mime.split("/")[1] || "mp4"; this.fileName = this.fileName.replace(/\.[^.]+$/, "." + ext); } const rangeHeader = res.headers.get("Content-Range"); const match = rangeHeader && rangeHeader.match(contentRangeRegex); if (!match) { const blob = await res.blob(); this.totalSize = blob.size; return { blob, startOffset: 0, endOffset: blob.size - 1, totalSize: blob.size }; } const startO = parseInt(match[1]); const endO = parseInt(match[2]); const total = parseInt(match[3]); const blob = await res.blob(); return { blob, startOffset: startO, endOffset: endO, totalSize: total }; } async start() { // 防止 _kick 重入导致同一任务被启动多次。 if (this._starting) return; this._starting = true; try { this.state = TaskState.RUNNING; this.startedAt = this.startedAt || Date.now(); this._emit(); // 批量目录模式:把 writable 直接接到用户选的目录下的新文件 if (!this._writable && this.context && this.context.dirHandle) { try { const unique = await this._uniqueFileNameIn( this.context.dirHandle, this.fileName ); this.fileName = unique; const fh = await this.context.dirHandle.getFileHandle(unique, { create: true }); this._writable = await fh.createWritable(); } catch (err) { logger.warn("Batch dir write failed: " + err.message, this.fileName); this.error = "目录写入失败: " + err.message; this.state = TaskState.FAILED; this._emit(); return; } } if (this.kind === "image") { return this._simpleDownload(); } // 单文件可选 picker(仅无 dirHandle 时) if (settings.useFileSystemAccess && !this._writable) { const picked = await this._trySaveFilePicker(); if (picked === "cancelled") { this.state = TaskState.CANCELLED; this._emit(); return; } } try { await this._runStreamed(); this._finalize(); } catch (err) { if (this.state === TaskState.CANCELLED) return; if (err.name === "AbortError" && this._paused) return; // paused; queue will resume this.error = err.message || String(err); if (this.retries < settings.maxRetries) { this.retries++; logger.warn(`Retry ${this.retries}/${settings.maxRetries}: ${this.error}`, this.fileName); await sleep(settings.retryDelayMs * Math.pow(2, this.retries - 1)); this._aborter = new AbortController(); this._blobs = []; this.downloaded = 0; this._samples = []; if (this._writable) { try { await this._writable.close(); } catch {} this._writable = null; } // 释放锁让递归 start() 可重入(retry 是合法重入) this._starting = false; return this.start(); } this.state = TaskState.FAILED; this._emit(); if (settings.notifyOnComplete) { GM.notify({ title: "Download failed", text: this.fileName + "\n" + this.error, timeout: 6000 }); } } } finally { this._starting = false; } } async _simpleDownload() { const usingDirHandle = !!this._writable; // 已由 start() 基于 dirHandle 准备好 try { const res = await fetch(this.url, { signal: this._aborter.signal }); if (!res.ok) throw new Error("HTTP " + res.status); const blob = await res.blob(); this.totalSize = blob.size; this.downloaded = blob.size; this._samples.push({ t: Date.now(), bytes: 0 }); this._samples.push({ t: Date.now() + 1, bytes: blob.size }); if (this._writable) { await this._writable.write(blob); try { await this._writable.close(); } catch {} this._writable = null; } else { this._saveBlob(blob); } await this._finalize(); } catch (err) { if (this.state === TaskState.CANCELLED) return; if (usingDirHandle || (this.context && this.context.dirHandle)) { this.error = err.message || String(err); this.state = TaskState.FAILED; this._emit(); return; } // 单文件模式才允许降级到浏览器下载。 try { const a = document.createElement("a"); a.href = this.url; a.download = this.fileName; document.body.appendChild(a); a.click(); document.body.removeChild(a); this.totalSize = this.totalSize || 1; this.downloaded = this.totalSize; await this._finalize(); } catch (e2) { this.error = e2.message || String(e2); this.state = TaskState.FAILED; this._emit(); } } } // 在目录句柄下为 fileName 找一个不冲突的名字(首选原名,冲突则追加 _1 / _2 ...) // 策略:能 getFileHandle 到就是"存在"需换名;任何错误(NotFoundError / TypeError / 权限等)都当"不存在"直接用 async _uniqueFileNameIn(dirHandle, baseName) { const safe = sanitizeFilename(baseName); const dotIdx = safe.lastIndexOf("."); const stem = dotIdx > 0 ? safe.slice(0, dotIdx) : safe; const ext = dotIdx > 0 ? safe.slice(dotIdx) : ""; let candidate = safe; for (let i = 0; i < 10000; i++) { let exists = false; try { await dirHandle.getFileHandle(candidate); exists = true; } catch { exists = false; } if (!exists) return candidate; candidate = `${stem}_${i + 1}${ext}`; } return candidate; } async _trySaveFilePicker() { const supported = "showSaveFilePicker" in WIN && (() => { try { return WIN.self === WIN.top; } catch { return false; } })(); if (!supported) return "unsupported"; try { const handle = await WIN.showSaveFilePicker({ suggestedName: this.fileName }); this._writable = await handle.createWritable(); return "ok"; } catch (err) { if (err.name === "AbortError") return "cancelled"; logger.warn(err.message, this.fileName); return "error"; } } _baseChunkConcurrency() { return Math.max(1, Math.min(parseInt(settings.chunkConcurrency) || 1, 16)); } _effectiveChunkConcurrency() { const base = this._baseChunkConcurrency(); const maxBoost = Math.max(base, Math.min(parseInt(settings.maxChunkConcurrency) || 18, 32)); const queueSlots = Math.max(1, Math.min(parseInt(settings.queueConcurrency) || 1, 8)); let runningStreams = 1; try { runningStreams = Math.max(1, queue.streamRunning || queue.running || 1); } catch {} const totalBudget = Math.max(base * queueSlots, maxBoost); const dynamic = Math.ceil(totalBudget / runningStreams); const next = Math.max(base, Math.min(maxBoost, dynamic)); this._chunkConcurrency = next; return next; } async _runStreamed() { // Probe: first request learns total size and server chunk size const first = await this._fetchRange(0); this.totalSize = first.totalSize; await this._commitChunk(first.startOffset, first.blob); let offset = first.endOffset + 1; this._recordBytes(first.blob.size); this._emit(); if (offset >= this.totalSize) return; const serverChunkSize = first.blob.size || 1024 * 1024; const chunkSize = Math.max(serverChunkSize, 512 * 1024); this._chunkConcurrency = this._effectiveChunkConcurrency(); const preserveOrder = !!this._writable; // Helper: fetch the exact [start, end] range, possibly via multiple requests const fetchExactRange = async (start, end) => { const parts = []; let cur = start; while (cur <= end) { await this._pauseGate(); const p = await this._fetchRange(cur, end); parts.push(p.blob); cur = p.endOffset + 1; this._recordBytes(p.blob.size); this._emit(); } return parts.length === 1 ? parts[0] : new Blob(parts); }; // Build chunk plan [offset..totalSize-1] const chunks = []; for (let s = offset; s < this.totalSize; s += chunkSize) { chunks.push({ start: s, end: Math.min(s + chunkSize - 1, this.totalSize - 1) }); } if (preserveOrder) { // Fetch in parallel but commit in order to the writable stream const inflight = new Map(); // idx -> Promise let nextCommit = 0; let nextFetch = 0; const scheduleMore = () => { const targetConcurrency = this._effectiveChunkConcurrency(); while (inflight.size < targetConcurrency && nextFetch < chunks.length) { const idx = nextFetch++; const { start, end } = chunks[idx]; inflight.set(idx, fetchExactRange(start, end)); } }; scheduleMore(); while (nextCommit < chunks.length) { await this._pauseGate(); const blob = await inflight.get(nextCommit); inflight.delete(nextCommit); await this._commitChunk(chunks[nextCommit].start, blob); nextCommit++; scheduleMore(); } return; } // In-memory fully parallel const results = new Array(chunks.length); let cursor = 0; const worker = async () => { while (cursor < chunks.length) { const idx = cursor++; const { start, end } = chunks[idx]; results[idx] = await fetchExactRange(start, end); } }; await Promise.all(Array.from({ length: this._effectiveChunkConcurrency() }, worker)); for (const b of results) if (b) this._blobs.push(b); } async _pauseGate() { while (this._paused) { if (this.state === TaskState.CANCELLED) throw new Error("Cancelled"); await sleep(200); } } async _commitChunk(startOffset, blob) { if (this._writable) { await this._writable.write(blob); } else { this._blobs.push(blob); } } async _finalize() { if (this._writable) { try { await this._writable.close(); } catch (e) { logger.warn(e.message); } } else if (this._blobs.length && this.kind !== "image") { const mime = this.kind === "audio" ? "audio/ogg" : "video/mp4"; const merged = new Blob(this._blobs, { type: mime }); this._saveBlob(merged); } this._blobs = []; this.state = TaskState.COMPLETED; this.finishedAt = Date.now(); if (this.totalSize) this.downloaded = this.totalSize; this._emit(); if (settings.historyDedup) history.add(this.url); if (settings.notifyOnComplete) { GM.notify({ title: "Download complete", text: this.fileName, timeout: 4000 }); } } _saveBlob(blob) { // 硬保险:用户选了批量目录时,绝不降级为浏览器保存框(防止代码里任何路径漏过来) if (this.context && this.context.dirHandle) { logger.error( "[safety] _saveBlob called while a dirHandle is set — refusing to show browser save dialog.", this.fileName ); this.error = "目录写入路径失效,已阻止降级到浏览器保存框"; this.state = TaskState.FAILED; this._emit(); return; } const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = this.fileName; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 60_000); } } class RecordedDownloadTask { constructor({ fileName, kind, size, startedAt, finishedAt }) { this.id = uid(); this.url = "recorded:" + this.id; this.kind = kind || "image"; this.fileName = normalizeOriginalFileName(fileName, this.kind) || sanitizeFilename(fileName || ("saved_" + this.id)); this.state = TaskState.COMPLETED; this.totalSize = size || 0; this.downloaded = this.totalSize; this.retries = 0; this.error = null; this.startedAt = startedAt || Date.now(); this.finishedAt = finishedAt || Date.now(); this._listeners = new Set(); } get progress() { return 100; } get speed() { return 0; } get eta() { return 0; } on(fn) { this._listeners.add(fn); return () => this._listeners.delete(fn); } _emit() { this._listeners.forEach((fn) => { try { fn(this); } catch {} }); } pause() {} resume() {} start() {} cancel() { this.state = TaskState.CANCELLED; this._emit(); } } // ============================================================ // 3. 任务队列 // ============================================================ const queue = { tasks: new Map(), _onChange: new Set(), add(task) { if (settings.historyDedup && history.has(task.url)) { logger.info("Skipped (already downloaded)", task.fileName); return null; } this.tasks.set(task.id, task); task.on((t) => { this._fire(); if ([TaskState.COMPLETED, TaskState.FAILED, TaskState.CANCELLED, TaskState.PAUSED].includes(t.state)) { this._kick(); } }); this._fire(); this._kick(); return task; }, remove(id) { const t = this.tasks.get(id); if (t) t.cancel(); this.tasks.delete(id); this._fire(); }, clearCompleted() { for (const [id, t] of this.tasks) { if ([TaskState.COMPLETED, TaskState.CANCELLED, TaskState.FAILED].includes(t.state)) { this.tasks.delete(id); } } this._fire(); }, pauseAll() { this.tasks.forEach((t) => t.pause()); }, resumeAll() { this.tasks.forEach((t) => t.resume()); }, onChange(fn) { this._onChange.add(fn); return () => this._onChange.delete(fn); }, _fire() { this._onChange.forEach((fn) => { try { fn(); } catch {} }); }, get running() { return Array.from(this.tasks.values()).filter((t) => t.state === TaskState.RUNNING).length; }, get streamRunning() { return Array.from(this.tasks.values()).filter((t) => t.state === TaskState.RUNNING && t instanceof DownloadTask && t.kind !== "image" ).length; }, _kick() { const slots = settings.queueConcurrency - this.running; if (slots <= 0) return; const pending = Array.from(this.tasks.values()).filter( (t) => t.state === TaskState.PENDING ); const toStart = pending.slice(0, slots); // 同步先把选中的任务标记为 RUNNING,确保后续在同一事件循环里发生的 _kick // 不会把它们再次当作 PENDING 重新选中。再异步调用 start()。 for (const t of toStart) t.state = TaskState.RUNNING; for (const t of toStart) t.start(); }, }; const recordSavedFileInPanel = ({ fileName, kind = "image", size = 0, startedAt, finishedAt } = {}) => { const task = new RecordedDownloadTask({ fileName, kind, size, startedAt, finishedAt }); queue.add(task); return task; }; const batchPanelNotices = []; const refreshPanelSoon = () => { try { panel && panel.render && panel.render(); } catch {} }; const clearBatchPanelNotices = () => { if (!batchPanelNotices.length) return; batchPanelNotices.length = 0; refreshPanelSoon(); }; const createBatchPanelNotice = ({ total, dirName }) => { const notice = { id: uid(), state: "running", total: total || 0, ok: 0, bad: 0, dirName: dirName || "", startedAt: Date.now(), finishedAt: 0, }; batchPanelNotices.unshift(notice); if (batchPanelNotices.length > 5) batchPanelNotices.length = 5; refreshPanelSoon(); return notice; }; const updateBatchPanelNotice = (notice, patch = {}) => { if (!notice) return; Object.assign(notice, patch); refreshPanelSoon(); }; // ============================================================ // 4. 管理面板 UI // ============================================================ const panel = (() => { let root, listEl, toggleBtn, summaryEl; const build = () => { root = document.createElement("div"); root.id = "tmd-panel"; Object.assign(root.style, { position: "fixed", right: "12px", bottom: "12px", width: "360px", maxHeight: "70vh", display: "flex", flexDirection: "column", borderRadius: "10px", fontFamily: "system-ui, sans-serif", fontSize: "13px", boxShadow: "0 8px 30px rgba(0,0,0,0.35)", zIndex: location.pathname.startsWith("/k/") ? 4 : 1600, overflow: "hidden", transition: "transform 0.2s ease", }); applyTheme(); const header = document.createElement("div"); Object.assign(header.style, { padding: "8px 12px", display: "flex", alignItems: "center", justifyContent: "space-between", cursor: "move", fontWeight: 600, gap: "8px", }); const titleSpan = document.createElement("span"); titleSpan.textContent = "⬇ Telegram 电报媒体下载器增强版"; titleSpan.className = "tmd-hide-on-collapse"; titleSpan.style.whiteSpace = "nowrap"; header.appendChild(titleSpan); const actions = document.createElement("div"); actions.style.display = "flex"; actions.style.gap = "6px"; const mkBtn = (label, title, onClick) => { const b = document.createElement("button"); b.textContent = label; b.title = title; Object.assign(b.style, { background: "transparent", border: "1px solid currentColor", borderRadius: "4px", padding: "2px 6px", cursor: "pointer", color: "inherit", fontSize: "11px", }); b.onclick = onClick; return b; }; const hideOnCollapse = (el) => { el.classList.add("tmd-hide-on-collapse"); return el; }; // Header 只放图标按钮,保持单行紧凑 actions.appendChild(hideOnCollapse(mkBtn("⏸", "暂停全部", () => queue.pauseAll()))); actions.appendChild(hideOnCollapse(mkBtn("▶", "继续全部", () => queue.resumeAll()))); actions.appendChild(hideOnCollapse(mkBtn("🗑", "清空已完成和本批下载", () => { queue.clearCompleted(); clearBatchPanelNotices(); }))); actions.appendChild(hideOnCollapse(mkBtn("⚙", "设置", () => openSettings()))); toggleBtn = mkBtn(settings.panelCollapsed ? "▲" : "▼", "折叠/展开", () => { settings.panelCollapsed = !settings.panelCollapsed; saveSettings(); applyCollapsed(); }); actions.appendChild(toggleBtn); header.appendChild(actions); // 主操作工具栏(独立一行,两个按钮 flex:1 均分宽度,不会挤压 header) const toolbar = document.createElement("div"); toolbar.className = "tmd-hide-on-collapse"; Object.assign(toolbar.style, { padding: "0 10px 8px", display: "flex", gap: "8px", }); const mkMainBtn = (label, title, onClick, bg) => { const b = document.createElement("button"); b.textContent = label; b.title = title; Object.assign(b.style, { flex: "1", padding: "7px 10px", borderRadius: "6px", border: "none", background: bg, color: "#fff", fontWeight: "600", fontSize: "12px", cursor: "pointer", whiteSpace: "nowrap", fontFamily: "inherit", }); b.onclick = onClick; b.addEventListener("mouseenter", () => { b.style.filter = "brightness(1.1)"; }); b.addEventListener("mouseleave", () => { b.style.filter = ""; }); return b; }; toolbar.appendChild(mkMainBtn( "☑ 点选下载", "进入点选模式:在图片/视频上点圆圈挑一批,再一次性下载到目录", () => selectMode.enter(), "#2196f3" )); summaryEl = document.createElement("div"); Object.assign(summaryEl.style, { padding: "4px 12px", fontSize: "11px", opacity: 0.7, borderTop: "1px solid rgba(128,128,128,0.25)", }); listEl = document.createElement("div"); Object.assign(listEl.style, { overflow: "auto", flex: 1, padding: "4px 8px 8px", }); root.appendChild(header); root.appendChild(toolbar); root.appendChild(summaryEl); root.appendChild(listEl); document.body.appendChild(root); makeDraggable(root, header); applyCollapsed(); }; const applyCollapsed = () => { const collapsed = settings.panelCollapsed; listEl.style.display = collapsed ? "none" : "block"; summaryEl.style.display = collapsed ? "none" : "block"; root.querySelectorAll(".tmd-hide-on-collapse").forEach((el) => { el.style.display = collapsed ? "none" : ""; }); if (collapsed) { root.style.width = "auto"; root.style.minWidth = "auto"; // Update toggle button to show running task count as a subtle indicator const n = queue.running; toggleBtn.textContent = n > 0 ? `↓${n}` : "▲"; toggleBtn.title = `展开 (${queue.tasks.size} 个任务, ${n} 运行中)`; } else { root.style.width = "360px"; root.style.minWidth = ""; toggleBtn.textContent = "▼"; toggleBtn.title = "折叠"; } }; const applyTheme = () => { if (!root) return; const dark = isDarkMode(); root.style.background = dark ? "rgba(26,32,40,0.96)" : "rgba(255,255,255,0.97)"; root.style.color = dark ? "#eee" : "#222"; root.style.border = `1px solid ${dark ? "#333" : "#ddd"}`; }; const render = () => { if (!root) return; applyTheme(); const tasks = Array.from(queue.tasks.values()).sort((a, b) => b.id.localeCompare(a.id)); const running = tasks.filter((t) => t.state === TaskState.RUNNING).length; const totalSpeed = tasks.reduce( (acc, t) => acc + (t.state === TaskState.RUNNING ? t.speed : 0), 0 ); summaryEl.textContent = `任务: ${tasks.length} | 运行中: ${running} | 合计速度: ${formatBytes( totalSpeed )}/s`; if (settings.panelCollapsed) { toggleBtn.textContent = running > 0 ? `↓${running}` : "▲"; toggleBtn.title = `展开 (${tasks.length} 个任务, ${running} 运行中, ${formatBytes(totalSpeed)}/s)`; } listEl.innerHTML = ""; batchPanelNotices.forEach((notice) => listEl.appendChild(renderBatchNotice(notice))); if (tasks.length === 0 && batchPanelNotices.length === 0) { const empty = document.createElement("div"); empty.style.textAlign = "center"; empty.style.padding = "20px 8px"; empty.style.opacity = 0.5; empty.textContent = "暂无任务。打开媒体后点击下载按钮。"; listEl.appendChild(empty); return; } tasks.forEach((t) => listEl.appendChild(renderRow(t))); }; const renderBatchNotice = (notice) => { const ok = notice.ok || 0; const bad = notice.bad || 0; const done = ok + bad; const total = notice.total || 0; const pct = total ? Math.min(100, Math.round((done * 100) / total)) : 0; const isDone = notice.state === "done"; const isFailed = notice.state === "failed"; const color = isFailed ? "#ff9800" : isDone ? "#4caf50" : "#2196f3"; const title = isFailed ? "⚠ 本批下载完成但有失败" : isDone ? "✔ 本批下载完成" : "本批下载中"; const box = document.createElement("div"); Object.assign(box.style, { padding: "10px 12px", margin: "4px 0 8px", borderRadius: "8px", background: isDone && !isFailed ? "rgba(76,175,80,0.16)" : isFailed ? "rgba(255,152,0,0.16)" : "rgba(33,150,243,0.14)", border: `1px solid ${color}`, boxShadow: isDone ? `0 0 0 1px ${color}44, 0 8px 22px rgba(0,0,0,0.22)` : "", }); const head = document.createElement("div"); Object.assign(head.style, { display: "flex", justifyContent: "space-between", gap: "8px", alignItems: "center" }); const label = document.createElement("div"); label.textContent = title; Object.assign(label.style, { fontWeight: 700, color }); const close = document.createElement("button"); close.textContent = "×"; close.title = "移除此批次提示"; Object.assign(close.style, { cursor: "pointer", border: "none", background: "transparent", color: "inherit", fontSize: "18px", lineHeight: 1, }); close.onclick = () => { const idx = batchPanelNotices.findIndex((n) => n.id === notice.id); if (idx >= 0) batchPanelNotices.splice(idx, 1); render(); }; head.appendChild(label); head.appendChild(close); const bar = document.createElement("div"); Object.assign(bar.style, { height: "8px", marginTop: "8px", borderRadius: "999px", background: "rgba(128,128,128,0.25)", overflow: "hidden", }); const fill = document.createElement("div"); Object.assign(fill.style, { height: "100%", width: `${pct}%`, background: color, transition: "width 0.3s", }); bar.appendChild(fill); const meta = document.createElement("div"); Object.assign(meta.style, { marginTop: "7px", fontSize: "12px", opacity: 0.9, lineHeight: 1.45, }); meta.innerHTML = `${ok} / ${total} 已成功` + (bad ? `,失败 ${bad}` : "") + (notice.dirName ? `
→ ${notice.dirName}` : ""); box.appendChild(head); box.appendChild(bar); box.appendChild(meta); return box; }; const renderRow = (t) => { const row = document.createElement("div"); Object.assign(row.style, { padding: "6px 8px", margin: "4px 0", borderRadius: "6px", background: isDarkMode() ? "rgba(255,255,255,0.04)" : "rgba(0,0,0,0.04)", }); const head = document.createElement("div"); Object.assign(head.style, { display: "flex", justifyContent: "space-between", gap: "6px", }); const name = document.createElement("div"); name.textContent = t.fileName; name.title = t.fileName; Object.assign(name.style, { flex: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", fontWeight: 500, }); const ctrls = document.createElement("div"); ctrls.style.display = "flex"; ctrls.style.gap = "4px"; const mk = (lbl, title, fn, hidden = false) => { const b = document.createElement("button"); b.textContent = lbl; b.title = title; Object.assign(b.style, { cursor: "pointer", border: "none", background: "transparent", color: "inherit", fontSize: "13px", padding: "0 4px", display: hidden ? "none" : "", }); b.onclick = fn; return b; }; if (t.state === TaskState.RUNNING) ctrls.appendChild(mk("⏸", "暂停", () => t.pause())); if (t.state === TaskState.PAUSED) ctrls.appendChild(mk("▶", "继续", () => t.resume())); if (t.state === TaskState.FAILED) { ctrls.appendChild( mk("↻", "重试", () => { t.state = TaskState.PENDING; t.retries = 0; t.error = null; t._aborter = new AbortController(); queue._kick(); }) ); } ctrls.appendChild(mk("✕", "移除", () => queue.remove(t.id))); head.appendChild(name); head.appendChild(ctrls); const bar = document.createElement("div"); Object.assign(bar.style, { height: "6px", marginTop: "4px", borderRadius: "3px", background: "rgba(128,128,128,0.25)", overflow: "hidden", position: "relative", }); const fill = document.createElement("div"); const color = { [TaskState.COMPLETED]: "#4caf50", [TaskState.FAILED]: "#e53935", [TaskState.CANCELLED]: "#9e9e9e", [TaskState.PAUSED]: "#ff9800", [TaskState.RUNNING]: "#2196f3", [TaskState.PENDING]: "#607d8b", }[t.state]; Object.assign(fill.style, { height: "100%", width: t.state === TaskState.PENDING ? "4%" : t.progress + "%", background: color, transition: "width 0.3s", }); bar.appendChild(fill); const meta = document.createElement("div"); Object.assign(meta.style, { display: "flex", justifyContent: "space-between", fontSize: "11px", opacity: 0.75, marginTop: "3px", }); const left = document.createElement("span"); const right = document.createElement("span"); if (t.state === TaskState.RUNNING) { const chunks = t._chunkConcurrency ? ` · 分片 ${t._chunkConcurrency}` : ""; left.textContent = `${formatBytes(t.downloaded)}/${formatBytes(t.totalSize)} · ${formatBytes( t.speed )}/s${chunks}`; right.textContent = `ETA ${formatDuration(t.eta)} · ${t.progress.toFixed(0)}%`; } else if (t.state === TaskState.COMPLETED) { left.textContent = `✔ ${formatBytes(t.totalSize)}`; right.textContent = `用时 ${formatDuration((t.finishedAt - t.startedAt) / 1000)}`; } else if (t.state === TaskState.FAILED) { left.textContent = `✖ ${t.error || "失败"}`; right.textContent = `重试 ${t.retries}`; } else { left.textContent = t.state; right.textContent = `${t.progress.toFixed(0)}%`; } meta.appendChild(left); meta.appendChild(right); row.appendChild(head); row.appendChild(bar); row.appendChild(meta); return row; }; const makeDraggable = (el, handle) => { let sx, sy, ox, oy, dragging = false; handle.addEventListener("mousedown", (e) => { if (e.target.tagName === "BUTTON") return; dragging = true; sx = e.clientX; sy = e.clientY; const rect = el.getBoundingClientRect(); ox = rect.left; oy = rect.top; e.preventDefault(); }); window.addEventListener("mousemove", (e) => { if (!dragging) return; const nx = ox + (e.clientX - sx); const ny = oy + (e.clientY - sy); el.style.left = nx + "px"; el.style.top = ny + "px"; el.style.right = "auto"; el.style.bottom = "auto"; }); window.addEventListener("mouseup", () => (dragging = false)); }; const mount = () => { if (root) return; build(); queue.onChange(render); render(); setInterval(render, 700); }; return { mount, render }; })(); // ============================================================ // 5. 设置面板 // ============================================================ const openSettings = () => { const existing = document.getElementById("tmd-settings"); if (existing) existing.remove(); const modal = document.createElement("div"); modal.id = "tmd-settings"; Object.assign(modal.style, { position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)", zIndex: 99999, display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "system-ui, sans-serif", }); const dark = isDarkMode(); const card = document.createElement("div"); Object.assign(card.style, { background: dark ? "#1f272f" : "#fff", color: dark ? "#eee" : "#222", borderRadius: "10px", padding: "18px 20px", width: "380px", boxShadow: "0 16px 40px rgba(0,0,0,0.4)", }); card.innerHTML = `

设置

`; const addNum = (label, key, min, max) => { const row = document.createElement("div"); row.style.margin = "8px 0"; row.innerHTML = ``; const input = row.querySelector("input"); input.addEventListener("change", () => { const v = Math.max(min, Math.min(max, parseInt(input.value) || min)); settings[key] = v; input.value = v; saveSettings(); }); card.appendChild(row); }; const addBool = (label, key) => { const row = document.createElement("div"); row.style.margin = "8px 0"; row.innerHTML = ``; const input = row.querySelector("input"); input.addEventListener("change", () => { settings[key] = input.checked; saveSettings(); }); card.appendChild(row); }; addNum("基础分片并发 (1-16)", "chunkConcurrency", 1, 16); addNum("动态分片上限 (4-32)", "maxChunkConcurrency", 4, 32); addNum("队列并发数 (1-6)", "queueConcurrency", 1, 6); addNum("最大重试次数 (0-10)", "maxRetries", 0, 10); addNum("重试间隔(ms)", "retryDelayMs", 100, 30000); addBool("文件名含日期", "nameWithDate"); addBool("文件名含频道名", "nameWithChannel"); addBool("完成时通知", "notifyOnComplete"); addBool("按 URL 去重 (历史)", "historyDedup"); addBool("开始前选保存位置 (大文件流式写盘)", "useFileSystemAccess"); const btnRow = document.createElement("div"); btnRow.style.display = "flex"; btnRow.style.justifyContent = "space-between"; btnRow.style.marginTop = "14px"; btnRow.innerHTML = ` `; card.appendChild(btnRow); btnRow.querySelector("#tmd-clear-hist").onclick = () => { history.clear(); alert("历史已清空"); }; btnRow.querySelector("#tmd-close").onclick = () => modal.remove(); modal.addEventListener("click", (e) => { if (e.target === modal) modal.remove(); }); modal.appendChild(card); document.body.appendChild(modal); }; // ============================================================ // 6. 入队便捷函数 // ============================================================ // Pause any currently playing