/* ============================================================================ * dl-core.js — Ubui 「真独立子页」共享引擎 * ---------------------------------------------------------------------------- * 为什么存在:downloader.html 是「一个文件 + ?platform= 参数」的伪子页架构, * 40 个平台 × 13 种语言 = 520 个参数变体,对爬虫是重复内容。 * 真子页 = 静态 SEO 写在 HTML + 独立 UI + 共享本引擎。 * * 设计原则: * 1. 本引擎只做「单平台」最小可用闭环:解析 → 选清晰度 → 下载 → 状态回流。 * 2. 平台差异不写死在引擎里,通过 window.ubui_PLAT 配置 + 两个扩展钩子注入: * hooks.presets(it) → 返回平台专属清晰度按钮组(如 YouTube 4K 矩阵) * hooks.extraActions(it)→ 返回平台专属操作按钮(如 TikTok 封面 / 原声) * hooks.afterParse(it) → 解析完成后的额外处理(如 TikTok 自动切无水印) * 3. 与 downloader.html 使用同一套后端契约,行为一致。 * * 后端契约(与 downloader.html 完全一致): * POST /api/download/preview {url,cookie,mode,media} → {title,cover,logo,streams[],subtitles[],ratio,error} * POST /api/download/task {url,clarity,watermark,cookie,profile,audioFormat,mode,media} * → {id, queued} * GET /api/download/task/{id}/stream (EventSource 进度) * GET /api/download/task/{id}/file (下载文件) * GET /api/download/stream?url=..&download=1 (强制下载任意直链,如封面/原声) * POST /api/download/playlist {url,limit} (播放列表批量) * ========================================================================== */ (function () { 'use strict'; /* ---------------------------------------------------------------- 配置 */ const CFG = Object.assign({ key: '', // 平台 slug,如 'youtube' / 'tiktok' name: '视频', // 展示名 watermarkDefault: true, // 默认是否去水印(TikTok 无水印版为主推,置 false) audioBatchBtn: false, // 音频页置 true:队列工具条显示「全部只下音频」 emptyHtml: '' // 分类页可注入专属空状态文案 }, window.ubui_PLAT || {}); /* ------------------------------------------------- 平台识别 / logo 渲染 */ /* 这段原先内联在 downloader.html 里;真子页没有那份内联脚本,故下沉至此。 logos/logos-map.js 提供 window.ubui_LOGOS(本地 logo 表,零外部依赖)。 */ const PLAT = [ { re: /douyin\.com|v\.douyin\.com|iesdouyin/i, name: '抖音', color: '#fe2c55', ch: '抖', slug: 'douyin' }, { re: /xiaohongshu\.com|xhslink\.com/i, name: '小红书', color: '#ff2442', ch: '书', slug: 'xiaohongshu' }, { re: /kuaishou\.com|gifshow\.com|v\.kuaishou\.com/i, name: '快手', color: '#ff6600', ch: '快', slug: 'kuaishou' }, { re: /bilibili\.com|b23\.tv/i, name: 'B站', color: '#fb7299', ch: 'B', slug: 'bilibili' }, { re: /youtube\.com|youtu\.be/i, name: 'YouTube', color: '#ff0000', ch: 'Y', slug: 'youtube' }, { re: /tiktok\.com|vt\.tiktok\.com|vm\.tiktok\.com|m\.tiktok\.com/i, name: 'TikTok', color: '#010101', ch: 'T', slug: 'tiktok' }, { re: /weibo\.com|weibo\.cn|m\.weibo\.cn/i, name: '微博', color: '#e6162d', ch: '微', slug: 'sinaweibo' }, { re: /zhihu\.com/i, name: '知乎', color: '#0066ff', ch: '知', slug: 'zhihu' }, { re: /toutiao\.com/i, name: '头条', color: '#ed3a3a', ch: '头', slug: 'toutiao' }, { re: /twitter\.com|x\.com/i, name: 'X', color: '#1da1f2', ch: 'X', slug: 'x' }, { re: /instagram\.com/i, name: 'Instagram', color: '#c13584', ch: 'IG', slug: 'instagram' }, { re: /facebook\.com|fb\.watch/i, name: 'Facebook', color: '#1877F2', ch: 'f', slug: 'facebook' }, { re: /twitch\.tv/i, name: 'Twitch', color: '#9146FF', ch: 'Tw', slug: 'twitch' }, { re: /soundcloud\.com/i, name: 'SoundCloud', color: '#FF5500', ch: 'Sc', slug: 'soundcloud' }, { re: /vk\.com/i, name: 'VK', color: '#0077FF', ch: 'VK', slug: 'vk' }, { re: /\.(mp4|mov|webm|mkv|mp3|wav|m4a|png|jpe?g|webp|gif)$/i, name: '直链', color: '#10b981', ch: '⬇' } ]; function detectPlat(url) { url = String(url || ''); for (let i = 0; i < PLAT.length; i++) { if (PLAT[i].re.test(url)) return PLAT[i]; } return { name: '链接', color: '#6b7280', ch: '🔗' }; } function esc(s) { return (s == null ? '' : String(s)).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } /* 平台 logo 本地渲染:box=true 的彩色图用白底容器 */ function logoMark(p) { p = p || {}; const L = (window.ubui_LOGOS && window.ubui_LOGOS[p.k]) || null; const mono = esc(p.ch || (p.n || '?').slice(0, 1)); if (L) { if (L.box) return ''; return ''; } return '' + mono + ''; } /* 导航用:平台色 logo(放在白底导航条上可见)*/ function logoColor(p) { p = p || {}; const L = (window.ubui_LOGOS && window.ubui_LOGOS[p.k]) || null; const mono = esc(p.ch || (p.n || '?').slice(0, 1)); if (L) { const col = L.c || L.w; return ''; } return '' + mono + ''; } /* logo 加载失败 → 回退为平台色首字 */ document.addEventListener('error', function (e) { const t = e.target; if (t && t.tagName === 'IMG' && t.classList && t.classList.contains('b-logo')) { const s = document.createElement('span'); s.className = 'b-mono'; s.textContent = t.dataset.mono || '?'; if (t.dataset.color) s.style.color = t.dataset.color; t.replaceWith(s); } }, true); /* 暴露给子页内联脚本使用(页面若另有实现可自行覆盖) */ window.UbuiPlat = { PLAT: PLAT, detectPlat: detectPlat, logoMark: logoMark, logoColor: logoColor, esc: esc }; if (typeof window.detectPlat !== 'function') window.detectPlat = detectPlat; if (typeof window.logoMark !== 'function') window.logoMark = logoMark; function fmtSize(b) { if (b == null) return '—'; if (b < 1024) return b + ' B'; if (b < 1048576) return (b / 1024).toFixed(1) + ' KB'; return (b / 1048576).toFixed(1) + ' MB'; } function fmtSpeed(bps) { if (!bps || bps < 1) return ''; if (bps < 1048576) return (bps / 1024).toFixed(0) + ' KB/s'; return (bps / 1048576).toFixed(1) + ' MB/s'; } function fmtEta(s) { if (s == null || !isFinite(s) || s < 0) return ''; if (s < 60) return Math.ceil(s) + 's'; return Math.floor(s / 60) + 'm' + Math.ceil(s % 60) + 's'; } /* 时长 mm:ss / h:mm:ss(服务端本来就返回 duration,这里只是格式化) */ function fmtDuration(s) { if (s == null || !isFinite(s) || s <= 0) return ''; s = Math.round(s); const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), x = s % 60; const pad = n => (n < 10 ? '0' + n : '' + n); return h ? (h + ':' + pad(m) + ':' + pad(x)) : (m + ':' + pad(x)); } /* 音频体积粗估:按码率估算,MP3 128kbps 是最常见的音轨码率 */ function estAudioSize(durSec) { if (!durSec || !isFinite(durSec) || durSec <= 0) return ''; return fmtSize(Math.round(durSec * 128 * 1000 / 8)); } function statusText(s) { return { queued: '排队中', parsing: '解析中', ready: '已解析', downloading: '下载中', queued_dl: '下载排队中', done: '已完成', error: '失败', paused: '已暂停' }[s] || s; } /* 解析不到标题时的兜底显示:整条链接太长会把卡片撑爆, 截成「平台 · 末尾几段 ID」,比一长串 URL 像样得多。 */ function shortTitle(u) { try { const url = new URL(u); const tail = (url.pathname + url.search).replace(/^\//, '').replace(/\?.*$/, '').split('/').filter(Boolean).pop(); const host = (url.hostname || '').replace(/^www\./, ''); return tail ? (host + ' · ' + tail.slice(-24)) : host || '未命名内容'; } catch (e) { return String(u || '').slice(0, 40); } } /* --------------------------------------------------------------- * 错误分类器(C12):把引擎原文(多数是英文)翻译成人话 + 下一步动作 * 为什么需要:yt-dlp 抛的是 "Unable to extract video id"、 * "Sign in to confirm you're not a bot" 这类话,用户完全看不懂, * 也不知道下一步该干嘛。这里分七类,每类给一句能照做的建议。 * ------------------------------------------------------------- */ function classifyError(raw) { const e = String(raw || '').trim(); const has = (re) => re.test(e); if (has(/sign in to confirm|not a bot|confirm you.?re not a bot|login required|account.*cookie|cookies?.*(need|required)/i)) return { msg: '平台要求登录验证', tip: '装 Ubui 浏览器插件,由你本机已登录的浏览器发起请求', icon: 'fa-user-lock' }; if (has(/private|not available in your country|available in your country|geo.?restrict|blocked in|account is private|地区|私密/i)) return { msg: '内容有访问限制', tip: '作者设为私密/好友可见,或该内容在你所在地区不可用', icon: 'fa-lock' }; if (has(/video unavailable|has been removed|no longer available|deleted|404|不存在|已删除/i)) return { msg: '内容已失效', tip: '链接可能已删除,或复制时缺了后半段 —— 建议重新从平台分享一次', icon: 'fa-link-slash' }; if (has(/unsupported url|unsupported|incompatible|is not a valid url|no suitable extractor|unable to extract|cannot extract/i)) return { msg: '这条链接解析不了', tip: '确认复制的是网页链接而非 App 内口令;换平台或换一条试试', icon: 'fa-circle-question' }; if (has(/rate.?limit|too many requests|429|bot|blocked by/i)) return { msg: '请求太频繁被限流', tip: '稍等一分钟再试;批量请分批,别一次贴几十条', icon: 'fa-hourglass-half' }; if (has(/timed? ?out|ETIMEDOUT|ENOTFOUND|ECONNRESET|socket hang up|network|网络|fetch failed/i)) return { msg: '网络连接失败', tip: '检查本机网络后重试;频繁出现请重启隧道(CN 出口)', icon: 'fa-wifi' }; if (has(/ffmpeg|postprocessing|merge|convert/i)) return { msg: '音视频合并失败', tip: '通常是格式组合不受支持,换一种清晰度或格式试试', icon: 'fa-film' }; if (!e) return { msg: '解析失败', tip: '稍后重试;若一直失败请换一个链接', icon: 'fa-triangle-exclamation' }; /* 平台返回的中文原因通常已经很具体(如「该作品已被作者设置为私密」),别覆盖它 */ if (/[一-龥]/.test(e)) return { msg: e.slice(0, 46), tip: '', icon: 'fa-triangle-exclamation' }; // 有中文就直接用中文原文 if (/[一-龥]/.test(e) && e.length <= 40) return { msg: e, tip: '', icon: 'fa-triangle-exclamation' }; return { msg: '解析失败', tip: '引擎返回:' + e.slice(0, 60), icon: 'fa-triangle-exclamation' }; } function parseHeight(q) { q = String(q || ''); if (/4k/i.test(q)) return 2160; if (/2k/i.test(q)) return 1440; const m = q.match(/(\d{3,4})\s*p/i); return m ? parseInt(m[1], 10) : 0; } /* 从清晰度串取语言标记,用于矩阵去重(1080P / 1080P 60fps 视为同一档) */ function qualityKey(q) { q = String(q || ''); const m = q.match(/(\d{3,4})\s*p/i); if (m) return m[1] + 'P'; if (/4k/i.test(q)) return '4K'; if (/2k/i.test(q)) return '2K'; return q.trim().toUpperCase() || 'DEFAULT'; } /* --------------------------------------------------------------- toast */ /* toast.js 若已加载则复用(含其防刷屏),否则本地兜底一份 */ const _TT = (function () { if (window.TT && typeof window.TT.toast === 'function') { const _o = window.TT.toast.bind(window.TT); const _l = { m: '', t: 0, n: 0 }; window.TT.toast = function (msg, type) { const now = Date.now(); if (msg === _l.m && now - _l.t < 4000) { _l.n++; return _o((_l.n > 1 ? (msg + ' ×' + _l.n) : msg), type); } _l = { m: msg, t: now, n: 1 }; return _o(msg, type); }; return window.TT; } return { toast: function (msg, type) { let el = document.getElementById('yc-toast'); if (!el) { el = document.createElement('div'); el.id = 'yc-toast'; el.style.cssText = 'position:fixed;left:50%;bottom:32px;transform:translateX(-50%);z-index:9999;' + 'background:var(--surface,#161b22);color:var(--text,#e6edf3);border:1px solid var(--border,#2a323d);' + 'padding:10px 18px;border-radius:10px;font-size:13.5px;box-shadow:0 10px 30px -12px rgba(0,0,0,.6);max-width:90vw;'; document.body.appendChild(el); } el.textContent = String(msg).replace(/<[^>]+>/g, ''); el.style.borderColor = (type === 'error') ? '#ef4444' : (type === 'success' ? '#2dd4bf' : 'var(--border)'); clearTimeout(el._t); el._t = setTimeout(() => { if (el.parentNode) el.parentNode.removeChild(el); }, 2600); } }; })(); function toast(msg, type) { if (_TT && _TT.toast) _TT.toast(msg, type); } /* --------------------------------------------------------------- 状态 */ const items = []; let uid = 0; const MAX_CONC = 3; let active = 0; const pend = []; /* P0-31:下载并发上限(解析有并发控制,下载原本裸奔,同时点多会打满服务端触发限流/403) */ const MAX_DL_CONC = 2; let dlActive = 0; const dlPend = []; function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } /* P1-92:解析请求最小间隔(防前端狂点 / 脚本误触发打满后端,保护账号与服务端) */ const PARSE_MIN_GAP = 200; let _lastReqTs = 0; async function _rateGate() { const now = Date.now(); const gap = PARSE_MIN_GAP - (now - _lastReqTs); if (gap > 0) await sleep(gap); _lastReqTs = Date.now(); } function lastQuality() { try { return localStorage.getItem('ubui_last_quality') || ''; } catch (e) { return ''; } } /* P1-47:记忆上次选择的音频格式,默认复用 */ function lastAudioFmt() { try { return localStorage.getItem('ubui_last_audiofmt') || 'mp3'; } catch (e) { return 'mp3'; } } /* 文件名净化:封面/字幕/原声下载时拼进文件名,特殊字符会导致下载失败或乱码(P0-49) */ function sanitizeName(s) { return String(s == null ? '' : s).replace(/[\\/:*?"<>|\n\r\t]+/g, '_').replace(/\s+/g, ' ').trim().slice(0, 120) || 'download'; } /* P1-27:解析结果短时缓存,相同 URL 5 分钟内复用,省请求、提速、降低限流概率 */ const _parseCache = new Map(); const PARSE_CACHE_TTL = 5 * 60 * 1000; function cachePreview(url, pj) { try { _parseCache.set(url, { t: Date.now(), pj: pj }); } catch (e) { } } function cachedPreview(url) { const c = _parseCache.get(url); if (c && (Date.now() - c.t) < PARSE_CACHE_TTL) return c.pj; if (c) _parseCache.delete(url); return null; } /* P1-46:按平台智能默认去水印:TikTok 默认无水印(无水印版是主推),其余沿用页面默认 */ function wmDefaultFor(it) { const slug = (it && it.logo && it.logo.slug) || CFG.key || ''; if (slug === 'tiktok') return false; return CFG.watermarkDefault; } let els = {}; function cacheEls() { ['urlInput', 'parseBtn', 'clearInputBtn', 'queue', 'detectChip', 'cookieInput', 'advToggle', 'advBody', 'retryBtn', 'platBadge', 'hostNote'].forEach(id => { els[id] = document.getElementById(id); }); } /** 当前解析模式:单条全量(批量页用 list,本引擎不支持) */ const parseMode = 'single'; const mediaType = 'video'; function getCookie() { const c = els.cookieInput; return c ? c.value.trim() : ''; } /* ---------------------------------------------------- 解析(并发受控) */ function enqueue(url) { const it = { id: ++uid, url: url, status: 'parsing', progress: 0, loaded: 0, total: 0, logo: null }; it.logo = detectPlat(url); items.push(it); render(); pend.push(it); pump(); } function pump() { while (active < MAX_CONC && pend.length) { const it = pend.shift(); active++; startItem(it); } } function release() { if (active > 0) active--; pump(); } /* P0-44 / P1-27:把解析结果应用到一个条目(网络命中与缓存命中共用,避免重复代码) */ function applyPreview(it, pj) { it.logo = pj.logo || it.logo; it.title = pj.title || shortTitle(it.url); it.cover = pj.cover || pj.coverUrl || pj.poster || null; /* 服务端其实早就返回 duration(TikTok / yt-dlp / playlist 分支都有),只是前端一直没接。 接上后:结果卡显示时长,并可据此估算音频体积(C14 / D5 / D12 由此解锁)。 */ if (pj.duration != null && isFinite(pj.duration)) it.duration = Number(pj.duration); it.ratio = pj.ratio || null; it.engine = pj.engine || null; it.engines = pj.engines || (pj.engine ? [pj.engine] : []); it._streams = pj.streams || []; it._subs = pj.subtitles || []; it._pj = pj; if (!it._claritySel) it._claritySel = 'original'; /* P0-44:回填上次清晰度偏好(仅当该档位在当前平台可用,避免选到不存在的档) */ const lq = lastQuality(); if (lq && it._streams.some(s => String(s.id) === String(lq))) it._claritySel = lq; /* 上游限流提示:服务端探测到 IP 级软禁时会给 hint,别让用户只看到干巴巴的「解析失败」 */ if (pj.hint) it._upstreamHint = pj.hint; if (pj.error) { it.error = pj.error; it.status = 'error'; } else { it.status = 'ready'; } /* 平台扩展钩子:解析完成后由子页做额外处理 */ if (typeof hooks.afterParse === 'function') hooks.afterParse(it, pj); render(); } async function startItem(it, attempt, done) { attempt = attempt || 0; try { await _rateGate(); /* P1-27:命中短时缓存则直接复用,跳过网络请求 */ const hit = cachedPreview(it.url); if (hit) { applyPreview(it, hit); if (done) done(); return; } const r = await fetch('/api/download/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: it.url, cookie: getCookie(), mode: parseMode, media: mediaType }) }); const pj = await r.json(); cachePreview(it.url, pj); applyPreview(it, pj); if (done) done(); } catch (e) { /* P0-19:解析失败自动重试(指数退避,应对限流/抖动),仍失败才标红,降低「假失败」 */ if (attempt < 2) { await sleep(700 * (attempt + 1)); return startItem(it, attempt + 1, done); } it.status = 'error'; it.error = e.message || '网络错误'; render(); } if (done) done(); } /* ------------------------------------------------------------- 下载 */ async function runTask(it, clarity, profile, watermark, cookie, audioFormat, onEnd) { if (it._downloading) { if (onEnd) onEnd(); return; } it._downloading = true; it._dlQueued = false; it.status = 'downloading'; it.error = null; it.progress = 0; it.loaded = 0; it.total = 0; it.speed = 0; render(); let tj = null; let reconnect = 0; const openStream = function () { const es = new EventSource('/api/download/task/' + tj.id + '/stream'); it.es = es; es.onmessage = function (ev) { let d; try { d = JSON.parse(ev.data); } catch (e) { return; } if (d.type === 'progress') { it.progress = d.progress || 0; if (d.loaded != null) { computeSpeed(it, d.loaded); it.loaded = d.loaded; } if (d.total != null) it.total = d.total; render(); } else if (d.type === 'done') { it.status = 'done'; it.fileUrl = '/api/download/task/' + tj.id + '/file'; it.size = d.size || it.total || 0; saveHistory(it); it._downloading = false; render(); toast('下载完成', 'success'); es.close(); if (onEnd) onEnd(); } else if (d.type === 'error') { it.status = 'error'; it.error = d.error || '下载失败'; it._downloading = false; render(); toast('下载失败:' + (d.error || '未知'), 'error'); es.close(); if (onEnd) onEnd(); } }; /* P1-32:EventSource 断线自动重连(最多 2 次),仍失败才标错,避免误判 */ es.onerror = function () { if (!it._downloading) { es.close(); return; } if (reconnect < 2) { reconnect++; es.close(); setTimeout(openStream, 800); return; } it.status = 'error'; it.error = '连接中断,请点重试'; it._downloading = false; render(); toast('下载连接中断', 'error'); es.close(); if (onEnd) onEnd(); }; }; try { const tk = await fetch('/api/download/task', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: it.url, clarity: clarity, watermark: watermark, cookie: cookie, profile: profile || 'video', audioFormat: audioFormat || 'mp3', mode: parseMode, media: mediaType }) }); tj = await tk.json(); if (!tk.ok) { it.status = 'error'; it.error = tj.error || '创建任务失败'; it._downloading = false; render(); if (onEnd) onEnd(); return; } it.taskId = tj.id; openStream(); } catch (e) { it.status = 'error'; it.error = e.message || '网络错误'; it._downloading = false; render(); if (onEnd) onEnd(); } } /* P0-31:下载并发受控——解析走 pump/active,下载同样走 dlPump/dlActive,避免同时打满服务端 */ function dlPump() { while (dlActive < MAX_DL_CONC && dlPend.length) { const job = dlPend.shift(); dlActive++; runTask(job.it, job.clarity, job.profile, job.watermark, job.cookie, job.audioFormat, function () { dlActive--; dlPump(); }); } } function requestDownload(it, clarity, profile, watermark, cookie, audioFormat) { if (it._dlQueued || it._downloading) return; it._dlQueued = true; it.status = 'queued_dl'; render(); dlPend.push({ it: it, clarity: clarity, profile: profile, watermark: watermark, cookie: cookie, audioFormat: audioFormat }); dlPump(); } function computeSpeed(it, loaded) { const now = Date.now(); if (it._lastT && it._lastLoaded != null) { const dt = (now - it._lastT) / 1000, db = loaded - it._lastLoaded; if (dt > 0.2 && db > 0) { it.speed = db / dt; if (it.total && it.total > loaded) it.eta = (it.total - loaded) / it.speed; } } it._lastT = now; it._lastLoaded = loaded; } function currentStream(it) { const streams = it._streams || []; const v = it._claritySel || 'original'; return streams.find(s => String(s.id) === String(v)) || null; } function downloadVideo(id) { const it = items.find(x => x.id === id); if (!it) return; const sel = document.getElementById('pr-clarity-' + id); if (sel) { it._claritySel = sel.value; } const wmEl = document.getElementById('pr-wm-' + id); const watermark = wmEl ? wmEl.checked : wmDefaultFor(it); try { localStorage.setItem('ubui_last_quality', it._claritySel); } catch (e) { } requestDownload(it, it._claritySel, 'video', watermark, getCookie(), 'mp3'); toast('已加入下载:' + (it._claritySel === 'original' ? '原始(最大)' : it._claritySel), 'success'); } function downloadAudio(id) { const it = items.find(x => x.id === id); if (!it || it._audioBusy) return; const afEl = document.getElementById('pr-audiofmt-' + id); const audioFormat = afEl ? afEl.value : 'mp3'; try { localStorage.setItem('ubui_last_audiofmt', audioFormat); } catch (e) { } it._audioFmt = audioFormat; it._audioBusy = true; render(); const r = fetch('/api/download/task', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: it.url, clarity: 'original', watermark: false, cookie: getCookie(), profile: 'audio', audioFormat: audioFormat, mode: parseMode, media: 'audio' }) }); r.then(x => x.json()).then(tj => { const es = new EventSource('/api/download/task/' + tj.id + '/stream'); es.onmessage = function (ev) { let d; try { d = JSON.parse(ev.data); } catch (e) { return; } /* 竞态兜底(2026-10-18):连上时任务可能已终态(done 先于连接发完),按终态收口 */ if (d.type === 'init') { if (d.status === 'done') { d = { type: 'done', size: null }; } else if (d.status === 'error') { d = { type: 'error', error: '下载失败' }; } else { return; } } /* 竞态兜底(2026-10-18):连上时任务可能已终态(done 先于连接发完),按终态收口 */ if (d.type === 'init') { if (d.status === 'done') { d = { type: 'done', size: null }; } else if (d.status === 'error') { d = { type: 'error', error: '下载失败' }; } else { return; } } if (d.type === 'done') { it.audioFileUrl = '/api/download/task/' + tj.id + '/file'; it.audioName = (it.title || 'audio') + '.' + (it._audioFmt || 'mp3'); it._audioBusy = false; render(); toast('音频已就绪,点卡片「下载音频」', 'success'); es.close(); } else if (d.type === 'progress') { /* progress 量纲不统一:有的引擎给0-1 小数、有的直接给 0-100。 统一按「>1 视为已是百分比」处理,否则会出现 10000% 这种离谱数字。 */ if (d.percent != null) it._audioPct = d.percent; else if (d.progress != null) it._audioPct = d.progress > 1 ? Math.round(d.progress) : Math.round(d.progress * 100); else it._audioPct = 0; render(); } else if (d.type === 'error') { it._audioBusy = false; render(); toast('音频提取失败:' + (d.error || '未知'), 'error'); es.close(); } }; es.onerror = function () { if (it._audioBusy) { it._audioBusy = false; render(); } es.close(); }; }).catch(() => { it._audioBusy = false; render(); }); toast('音频提取中…', 'success'); } /* 任意直链强制下载(封面、原声等) */ function downloadDirect(url, filename) { if (!url) { toast('没有可用的直链', 'error'); return; } const fn = sanitizeName(filename); const href = '/api/download/stream?url=' + encodeURIComponent(url) + '&download=1' + '&name=' + encodeURIComponent(String(name || 'file')); const a = document.createElement('a'); a.href = href; a.download = fn; a.rel = 'noopener'; /* P0-85:iOS Safari 常忽略 download 属性,改新标签触发保存;其余走锚点 download */ const isIOS = /iP(hone|ad|od)/.test(navigator.userAgent) && !window.MSStream; if (isIOS) { a.target = '_blank'; window.open(href, '_blank'); } else { document.body.appendChild(a); a.click(); document.body.removeChild(a); } } /* P1-50:复制任意直链到剪贴板(带降级方案,无需每次都触发下载) */ function copyRaw(u) { u = decodeURIComponent(u || ''); const done = function () { toast('已复制直链', 'success'); }; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(u).then(done).catch(function () { fallbackCopy(u, done); }); } else { fallbackCopy(u, done); } } function fallbackCopy(text, done) { try { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; ta.style.top = '-1000px'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); } catch (e) { } document.body.removeChild(ta); done(); } catch (e) { toast('复制失败,请手动复制', 'error'); } } /** 设置某条任务的清晰度(平台矩阵按钮 / 下拉共用) */ function setClarity(id, streamId) { const it = items.find(x => x.id === id); if (!it) return; it._claritySel = streamId; render(); toast('已选择清晰度:' + (currentStream(it) ? (currentStream(it).quality || streamId) : streamId), 'success'); } /* P1-21:单条卡片移除(同步清理下载队列与并发计数,避免计数错乱) */ function removeItem(id) { const idx = items.findIndex(x => x.id === id); if (idx < 0) return; const it = items[idx]; if (it.es) { try { it.es.close(); } catch (e) { } } if (it._downloading && dlActive > 0) dlActive--; else { const pi = dlPend.findIndex(j => j.it === it); if (pi >= 0) dlPend.splice(pi, 1); } items.splice(idx, 1); render(); } /* P0-18:失败项重试——接上此前缓存却从未绑定的 retry 能力(死代码复活) */ function retryItem(id) { const it = items.find(x => x.id === id); if (!it) return; it.error = null; it._downloading = false; it._dlQueued = false; if (it.taskId) { /* 下载阶段失败:沿用上次清晰度重新入下载队列 */ const sel = document.getElementById('pr-clarity-' + id); const clarity = sel ? sel.value : (it._claritySel || 'original'); const wmEl = document.getElementById('pr-wm-' + id); const wm = wmEl ? wmEl.checked : wmDefaultFor(it); requestDownload(it, clarity, 'video', wm, getCookie(), 'mp3'); toast('已重新加入下载', 'success'); } else { /* 解析阶段失败:重新入解析并发泵(带自动重试,且不破坏 MAX_CONC 上限) */ it.status = 'parsing'; render(); pend.push(it); pump(); } } /* P1-39:一键重试全部失败项 */ function retryAllFailed() { items.filter(x => x.status === 'error').forEach(it => retryItem(it.id)); toast('已重试失败项', 'success'); } /* --------------------------------------------------------- 队列渲染 */ function metaText(it) { /* 音频提取中必须优先显示:解析阶段已带回视频体积,若放在 size/total 之后, 用户点「只下音频」后卡片会一直显示原体积,看起来像"点了没反应"。 */ if (it._audioBusy) return '保存中…' + (it._audioPct ? (' ' + Math.min(100, Math.max(0, it._audioPct)) + '%') : ''); if (it.status === 'queued_dl') return '排队中…'; if (it.size) return '✓ ' + fmtSize(it.size); if (it.total) return fmtSize(it.loaded) + ' / ' + fmtSize(it.total) + (it.speed ? ' · ' + fmtSpeed(it.speed) : '') + (it.eta ? ' · 剩 ' + fmtEta(it.eta) : ''); if (it.status === 'downloading') return (it.progress || 0) + '%' + (it.speed ? ' · ' + fmtSpeed(it.speed) : '') + (it.eta ? ' · 剩 ' + fmtEta(it.eta) : '') + ((it.total && it.total > 157286400) ? ' · 可关闭页面' : ''); if (it.status === 'parsing') return '解析中…'; if (it.status === 'error') return ''; /* 已就绪但没有体积信息时返回空串:挂一个「—」既没信息量又像出了错 */ return ''; } /** 清晰度下拉:original 在前 + 平台实际可用档位 */ function claritySelectHtml(it) { const streams = it._streams || []; if (!streams.length) return ''; const cur = it._claritySel || 'original'; const maxS = streams.reduce((a, s) => (parseHeight(s.quality) > parseHeight(a ? a.quality : '')) ? s : a, null); let opts = ''; streams.forEach(s => { opts += ''; }); return ''; } /** 平台预设按钮组(YouTube 4K 矩阵等):返回按钮 HTML */ function presetButtonsHtml(it) { if (typeof hooks.presets !== 'function') return ''; try { return hooks.presets(it) || ''; } catch (e) { return ''; } } function errHtml(it) { if (!it.error) return ''; const e = it.error; const loginish = /cookie|登录|403|412|forbidden|waf|sign|verify|challenge|需/i.test(e); const unsupported = /不支持|无法解析|no supported|unsupported|暂不支持|not support|未适配|没有可用|找不到(源|资源)/i.test(e); const rateLimit = /429|限流|rate.?limit|too many|频率|ratelimit|busy|繁忙|稍后|稍后再|请求过快|频繁/i.test(e); const timeoutErr = /timeout|超时|timed out|504|gateway|网关|无响应|响应超时/i.test(e); const regionErr = /地区|region|所在国家|not available in your|仅.*地区|海外|版权|地区限制|region.?locked|geo/i.test(e); let extra = ''; /* 错误统一在此分类:不管错误来自 preview / 下载 / 网络, 到用户眼前都是「一句人话 + 一个下一步」,而不是引擎英文原文。 */ if (it.status === 'error' && it.error) { const ce = classifyError(it.error); extra += '
' + esc(ce.msg) + '
' + (ce.tip ? '
' + esc(ce.tip) + '
' : '') + '
'; } /* 上游限流(IP 级软禁):这是「等一下就好」而不是「链接坏了」,必须说清楚 */ if (it._upstreamHint) extra += ' · ' + esc(it._upstreamHint) + ''; if (unsupported) extra += ' · 该平台网页端暂不支持 / 受限,可下载 Ubui 桌面版下载器 获取完整支持'; if (loginish && !getCookie()) extra += ' · 该内容需要登录才能解析:安装 Ubui 浏览器插件即可自动带上你本机登录态,无需手动填写任何 Cookie。'; if (rateLimit) extra += ' · 请求过于频繁,请稍候再试或降低并发。'; if (timeoutErr) extra += ' · 解析超时,可能是平台限流或网络抖动,点重试可自动退避。'; if (regionErr) extra += ' · 该内容可能存在地区 / 版权限制,可换网络或试 Ubui 桌面版。'; return '
解析/下载失败(点击展开原因)
' + esc(e) + extra + '
'; } function playlistDoneHtml(it) { const logo = logoMark(it.logo || { n: CFG.name, ch: '视' }); const color = (it.logo && it.logo.color) || '#6b7280'; return '
' + '
' + '' + logo + '' + '
' + esc(it.title || it.url) + '
' + '
已加入 播放列表任务进行中
' + '' + '
'; } function cardHtml(it) { if (it._playlistDone) return playlistDoneHtml(it); const logo = logoMark(it.logo || { n: CFG.name, ch: '视' }); const color = (it.logo && it.logo.color) || '#6b7280'; let html = '
'; /* 封面缩略图:音频页用户面对的常常是"歌单里 20 首同名歌", 没有缩略图根本认不出是哪一条;有图才有列表的辨识度。 走服务端 /api/download/cover 代理:平台图床常有防盗链与跨域限制, 直连 在浏览器侧经常加载失败(空灰块)。 无封面时用平台色块占位,且不再额外渲染同色 platform-badge —— 同一张卡上两个一模一样色块,是最容易被当成 bug 的视觉瑕疵。 */ if (it.cover) { html += '
'; } else { html += '
' + logo + '
'; } html += '
'; /* 排版核心(v56):一行三栏 —— 封面 | 信息 | 操作。 之前状态、时长、按钮全堆在左边一条线上,1024px 宽的卡片右侧 400px 全是死白, 视觉上像"没做完"。现在把操作区推到右侧并纵向居中,宽度真正被用起来。 */ html += '
'; html += '
'; html += '
' + esc(it.title || it.url) + '
'; if (it.duration) { const dur = fmtDuration(it.duration); const est = estAudioSize(it.duration); html += '
' + dur + (est ? (' · 音频约 ' + est) : '') + '
'; } const mt = metaText(it); html += '
' + statusText(it.status) + '' + (mt ? ' ' + mt + '' : '') + '
'; html += '
'; /* ---- 右栏:主操作 + 移除,纵向居中对齐信息栏 ---- */ html += '
'; if (it.status === 'ready' || it.status === 'downloading' || it.status === 'done') { const audioPage = CFG.key === 'audio'; /* 音频页的主操作就是「只下音频」,直接做成一排里的主动作,不放进下面的选项行 */ if (audioPage) { html += ''; } else { html += ''; } } html += ''; html += '
'; html += '
'; /* 进度条:downloader.html 早就有样式与更新逻辑(.qi-bar),但引擎从未创建这个元素, 导致「解析中 / 下载中 / 排队中」全都只有文字、没有可视反馈。这里补上渲染。 音频提取(_audioBusy)同样要有进度——它可能跑十几秒,没有条用户会以为卡死。 */ if (it._audioBusy) { html += '
'; } else if (it.status === 'downloading' || it.status === 'queued_dl' || it.status === 'parsing') { const pct = it.status === 'downloading' ? (it.progress || 0) : (it.status === 'parsing' ? (it._audioPct || it.progress || 0) : 0); html += '
' + '
'; } if (it.ratio) html += '
' + esc(it.ratio) + '
'; if (it.status === 'ready' || it.status === 'downloading' || it.status === 'done') { /* 音频页与视频页是两件事: 视频页 → 清晰度矩阵 + 去水印 + 字幕(原画视频) 音频页 → 只给「格式 + 提取音频」,出现「去水印 / 2160p」这种控件是错的。 */ const audioPage = CFG.key === 'audio'; if (!audioPage && (it._streams || []).length) { html += '
' + presetButtonsHtml(it) + claritySelectHtml(it) + '
'; } /* 通用操作:主操作一行,字幕/直链等次要操作收进「更多」里, 避免一条视频有几十种语言字幕时把卡片撑成几百像素高。 */ /* 底部行:格式等选项在左,「更多操作」固定在右下角,两者并排不各占一行。 */ html += '
'; html += '
'; if (!audioPage) { html += ''; } const af0 = lastAudioFmt(); html += ' 格式'; html += ''; /* 音频页的「只下音频」已提升为右栏主操作,这里不再重复 */ if (!audioPage) { html += ''; } if (it.cover && CFG.showCoverBtn) { html += ''; } /* 平台专属扩展操作 */ if (typeof hooks.extraActions === 'function') { try { html += (hooks.extraActions(it) || ''); } catch (e) { } } html += '
'; /* ---- 次要操作收进可折叠区:字幕、原画直链、复制直链 ---- */ const extras = []; if ((it._subs || []).length) { /* 字幕:平台给了哪些语言就下哪些,不排序、不改名、不翻译、不优选。 只做一件事:数量多时收进折叠区,别把卡片撑爆。 */ const subs = it._subs; const subBtn = (sub) => ''; if (subs.length <= 10) { extras.push(' 字幕 ' + subs.length + ' 种' + subs.map(subBtn).join('')); } else { /* 超过 10 种:折叠区里只平铺前 10 个,再给一个二级折叠装剩下的 */ extras.push(' 字幕 ' + subs.length + ' 种' + subs.slice(0, 10).map(subBtn).join('') + '
展开其余 ' + (subs.length - 10) + ' 种' + '
' + subs.slice(10).map(subBtn).join('') + '
'); } } const cs = currentStream(it); if (cs && cs.url) { extras.push(' 原画直链'); extras.push(''); } if (extras.length) { html += '
更多操作' + '
' + extras.join('') + '
'; } html += '
'; /* .qi-foot */ } if (it.status === 'error') { /* P0-18:失败项重试按钮(接上此前缓存却从未绑定的 retry 能力) */ html += '
'; } /* P1-24:解析 / 排队中骨架屏(shimmer 占位,比纯文字更有确定感) */ if (it.status === 'parsing' || it.status === 'queued' || it.status === 'queued_dl') { html += ''; } html += errHtml(it); html += '
'; /* .qi-body */ html += '
'; /* .queue-item */ return html; } let lastSig = ''; function render() { if (!els.queue) return; if (!items.length) { els.queue.innerHTML = (CFG.emptyHtml || '

粘贴链接,点「开始解析」即可。解析后选择清晰度与格式并下载。

🔒 链接与历史仅保存在本机浏览器,不会上传服务器。

'); lastSig = ''; return; } /* 轻量化:只在结构变化时重建 DOM,纯进度刷新走局部更新,避免闪烁 */ const sig = items.map(i => i.id + ':' + i.status).join('|'); if (sig !== lastSig) { els.queue.innerHTML = queueToolbar() + items.map(cardHtml).join(''); lastSig = sig; } else { items.forEach(it => { const el = els.queue.querySelector('.queue-item[data-id="' + it.id + '"]'); if (!el) return; const st = el.querySelector('.st'); if (st) { st.className = 'st st-' + it.status; st.textContent = statusText(it.status); } const mt = el.querySelector('.qi-meta'); if (mt) mt.textContent = metaText(it); const bar = el.querySelector('.qi-bar'); /* 音频提取与视频下载共用同一条进度条;都没有进度元素时(如已就绪态) 动态补一个,避免局部更新路径下进度条凭空消失。 */ let pr = el.querySelector('.qi-progress'); if ((it.status === 'downloading' || it.status === 'queued_dl' || it.status === 'parsing' || it._audioBusy) && !pr) { pr = document.createElement('div'); pr.className = 'qi-progress qi-pb-mini' + ((it.status === 'downloading') ? '' : ' indet'); pr.innerHTML = '
'; el.appendChild(pr); } if (bar) { const busyAudio = !!it._audioBusy; const busyVid = it.status === 'downloading'; if (busyAudio || busyVid) { bar.style.width = Math.min(100, Math.max(0, busyAudio ? (it._audioPct || 0) : (it.progress || 0))) + '%'; pr.classList.toggle('indet', !busyVid); } } }); } mobileProgress(); } /* ------------------------------------------------------------------ * G7 移动端底部汇总进度条 * 手机上队列可能很长,下载进度条在屏幕外根本看不到,用户只能干等。 * 这里把「正在下载 + 排队中」汇总成一条固定在底部的窄条;桌面端由 CSS 隐藏。 * ---------------------------------------------------------------- */ function mobileProgress() { let box = document.getElementById('ubuiMProg'); if (!box) { box = document.createElement('div'); box.id = 'ubuiMProg'; box.setAttribute('aria-live', 'polite'); box.innerHTML = '
'; document.body.appendChild(box); } const busy = items.filter(x => x.status === 'downloading' || x.status === 'queued_dl' || x.status === 'parsing' || x.status === 'queued' || x._audioBusy); if (!busy.length) { box.classList.remove('show'); return; } const dl = busy.filter(x => x.status === 'downloading' || x._audioBusy); let pct; if (dl.length) { /* 音频提取走 _audioPct(服务端 percent),视频下载走 progress */ const sum = dl.reduce((s, x) => s + (x.status === 'downloading' ? (x.progress || 0) : (x._audioPct || 0)), 0); pct = Math.round(sum / dl.length); } else { const done = items.filter(x => x.status === 'done').length; pct = Math.round((done / items.length) * 100); } const txt = box.querySelector('.ump-txt'); txt.innerHTML = dl.length ? ' 正在下载 ' + dl.length + ' 个 · ' + pct + '%' : ' 正在解析 ' + busy.length + ' 个'; box.querySelector('.ump-bar i').style.width = pct + '%'; box.classList.add('show'); } /* ----------------------------------------------------------- 历史记录 */ /* P1-22:队列工具条(完成计数 + 清空已完成 / 清空全部) */ function queueToolbar() { const doneCount = items.filter(x => x.status === 'done').length; const dlCount = items.filter(x => x.status === 'downloading' || x.status === 'queued_dl').length; const pqCount = items.filter(x => x.status === 'parsing' || x.status === 'queued').length; const readyCount = items.filter(x => x.status === 'ready').length; const audioBusy = items.filter(x => x._audioBusy).length; let cnt = '共 ' + items.length + ' 条'; if (pqCount) cnt += ' · 解析中 ' + pqCount; if (dlCount || audioBusy) cnt += ' · 下载中 ' + (dlCount + audioBusy); if (readyCount) cnt += ' · 待下载 ' + readyCount; if (doneCount) cnt += ' · 完成 ' + doneCount; return '
' + '' + cnt + '' + /* 批量保存:没有待下载项时置灰,避免用户点了才知道没东西可下 */ (CFG.audioBatchBtn ? '' : '') + '' + '' + '' + '
'; } /* 音频页专属:队列里所有已解析成功的条目统一提取音频(逐条排队,不并发轰炸服务端) */ function audioAll() { const ready = items.filter(x => x.status === 'ready'); if (!ready.length) { toast('队列里还没有解析成功的条目', 'warning'); return; } let n = 0; ready.forEach(function (it) { if (it._audioBusy) return; n++; it._audioBusy = true; render(); setTimeout(function () { try { downloadAudio(it.id); } catch (e) { it._audioBusy = false; } }, n * 420); }); toast('已排入 ' + n + ' 条音频提取', 'success'); } /* P1-22:清空已完成 / 清空全部(分区操作,避免误删进行中的任务) */ function clearDone() { for (let i = items.length - 1; i >= 0; i--) { const it = items[i]; if (it.status === 'done') { if (it.es) { try { it.es.close(); } catch (e) { } } items.splice(i, 1); } } render(); toast('已清空已完成', 'success'); } function clearAll() { items.forEach(it => { if (it.es) { try { it.es.close(); } catch (e) { } } }); items.length = 0; pend.length = 0; dlPend.length = 0; active = 0; dlActive = 0; render(); toast('已清空全部', 'success'); } function saveHistory(it) { if (!it.title) return; try { const KEY = 'ubui_dl_hist'; let h = []; try { h = JSON.parse(localStorage.getItem(KEY) || '[]'); } catch (e) { h = []; } h.unshift({ title: it.title, url: it.url, platform: (it.logo && it.logo.name) || CFG.name, ts: Date.now() }); h = h.slice(0, 50); localStorage.setItem(KEY, JSON.stringify(h)); } catch (e) { } } /* P1-11:平台能力提示,识别单链时顺带给出该平台能力预期 */ const PLAT_CAPS = { '抖音':'支持无水印 / 高清', 'TikTok':'支持无水印 / 多清晰度', 'YouTube':'支持 4K / 字幕 / 音频', 'B站':'支持高清 / 弹幕', '快手':'支持高清 / 无水印', '小红书':'支持图文 / 视频', '微博':'支持原图 / 视频', '知乎':'支持视频 / 图文', 'X':'支持高清 / 音频', 'Instagram':'支持高清 / 图文', '微信视频号':'免登录解析' }; /* ------------------------------------------------------- 识别 / 提示 */ function markUrlInvalid(input, on) { if (!input) return; if (on) { input.setAttribute('aria-invalid', 'true'); input.classList.add('url-invalid'); } else { input.removeAttribute('aria-invalid'); input.classList.remove('url-invalid'); } } function updateDetect() { const input = els.urlInput; if (!input) return; const v = input.value.trim(); const chip = els.detectChip; if (!chip) return; /* 提示条支持点击关闭(用户反馈:红色提示关不掉、一直挡着输入框) */ if (!chip._dismissBound) { chip._dismissBound = true; chip.addEventListener('click', function () { chip.classList.remove('show'); }); } if (!v) { chip.classList.remove('show', 'warn'); clearTimeout(chip._warnTimer); markUrlInvalid(input, false); return; } /* P1-13:只认 http(s) 链接,聊天正文 / 乱码不再被当链接报错 */ const links = extractUrls(v); if (!links.length) { clearTimeout(chip._warnTimer); chip.classList.add('warn'); chip.innerHTML = ' 没识别到链接,请粘贴 http(s) 开头的地址'; chip.classList.add('show'); markUrlInvalid(input, true); /* 5 秒后自动收起,不长期占据首屏 */ chip._warnTimer = setTimeout(function () { chip.classList.remove('show'); }, 5000); return; } clearTimeout(chip._warnTimer); chip.classList.remove('warn'); markUrlInvalid(input, false); if (links.length > 1) { chip.style.background = 'var(--brand)'; chip.innerHTML = ' 已识别 ' + links.length + ' 条链接,将逐条解析'; } else { const p = detectPlat(links[0]); chip.style.background = p.color; const _cap = (typeof PLAT_CAPS !== 'undefined' && PLAT_CAPS[p.name]) || ''; chip.innerHTML = logoMark(p) + ' ' + esc(p.name) + (_cap ? ' · ' + esc(_cap) : '') + ''; } chip.classList.add('show'); } /* ------------------------------------------------------------ 动作入口 */ /* P1-16:从整段文字里自动抽取 http(s) 链接,避免把聊天正文当链接报错 */ function extractUrls(text) { try { const m = String(text || '').match(/https?:\/\/[^\s,;;,。)+)\]]+/gi); return m ? m.map(function (x) { return x.trim(); }).filter(Boolean) : []; } catch (e) { return []; } } function add(urls) { const raw = String(urls || ''); let list = extractUrls(raw); if (!list.length) { list = raw.split(/[\n\r\t ,,;;]+/).map(function (s) { return s.trim(); }).filter(Boolean); } const seen = {}; const uniq = list.filter(function (u) { if (seen[u]) return false; seen[u] = 1; return true; }) /* P1-13:只收 http(s),过滤掉 javascript:/data: 等伪协议,避免误发请求 + 历史 XSS */ .filter(function (u) { return /^https?:\/\/[^\s]+$/i.test(u); }); if (!uniq.length) { toast('没有可用的 http(s) 链接,请检查后重试', 'error'); return false; } uniq.forEach(enqueue); if (uniq.length > 1) toast('已加入 ' + uniq.length + ' 个链接,逐个解析中', 'success'); return true; } function parseFromInput() { const input = els.urlInput; if (!input || !input.value.trim()) { toast('请先粘贴链接', 'error'); if (input) input.focus(); return; } const ok = add(input.value); if (ok) { input.value = ''; markUrlInvalid(input, false); if (els.clearInputBtn) els.clearInputBtn.style.display = 'none'; if (els.detectChip) els.detectChip.classList.remove('show'); } else { markUrlInvalid(input, true); } } function playistBatch() { const input = els.urlInput; if (!input || !input.value.trim()) { toast('请先粘贴播放列表链接', 'error'); return; } const it = { id: ++uid, url: input.value.trim(), status: 'parsing', progress: 0, loaded: 0, total: 0, logo: null }; it.logo = detectPlat(it.url); items.push(it); render(); fetch('/api/download/playlist', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: it.url, limit: 100 }) }).then(r => r.json()).then(pj => { const rows = (pj.items || pj.list || pj.videos || []); if (!rows.length) { it.status = 'error'; it.error = '未能获取播放列表条目'; render(); return; } /* 逐条入队,走与单条完全相同的解析 / 下载通道 */ rows.forEach(rw => { const u = (rw && (rw.url || rw.link || rw.webpage_url)) || ''; if (!u) return; const sub = { id: ++uid, url: u, status: 'parsing', progress: 0, loaded: 0, total: 0, logo: it.logo, title: (rw && (rw.title || rw.name)) || u }; items.push(sub); pend.push(sub); }); /* P1-20:播放列表父项「僵尸解析中」修复——拿到列表后把占位父项转为已完成态 */ it.status = 'done'; it.title = '播放列表 · 共 ' + rows.length + ' 条已加入队列'; it._playlistDone = true; it._streams = []; pump(); render(); toast('播放列表已获取 ' + (pend.length ? '正在' : '') + '解析,共 ' + rows.length + ' 条', 'success'); }).catch(e => { it.status = 'error'; it.error = e.message || '播放列表获取失败'; render(); }); } /* P2-84:下载历史面板(复用 saveHistory 已存的 localStorage 数据) */ function getHistory(){ try{ return JSON.parse(localStorage.getItem('ubui_dl_hist')||'[]'); }catch(e){ return []; } } function renderHistory(){ const box = document.getElementById('ycHistList'); if(!box) return; const list = getHistory(); if(!list.length){ box.innerHTML = '

暂无历史记录。解析过的链接会仅保存在本机浏览器。

'; return; } box.innerHTML = list.map(function(h,i){ return '
'+esc(h.title||h.url)+''+ ''+esc(h.platform||'')+' · '+new Date(h.ts).toLocaleDateString()+''+ '
'; }).join(''); } function openHistory(){ let ov = document.getElementById('ycHist'); if(!ov){ ov = document.createElement('div'); ov.id='ycHist'; ov.className='yc-overlay'; ov.innerHTML = ''; document.body.appendChild(ov); ov.addEventListener('click', function(e){ if(e.target===ov) UbuiCore.closeHistory(); }); } ov.classList.add('show'); renderHistory(); } function closeHistory(){ const ov=document.getElementById('ycHist'); if(ov) ov.classList.remove('show'); } function clearHistory(){ try{ localStorage.removeItem('ubui_dl_hist'); }catch(e){} renderHistory(); if(window.toast) toast('已清空历史','success'); } function rehist(i){ const list=getHistory(); const h=list[i]; if(h&&h.url){ add(h.url); closeHistory(); } } /* -------------------------------------------------------------- 导出 */ /* P1-10:一键粘贴并解析(监听到剪贴板含链接即填入,带降级) */ function setupPasteBtn() { const pb = document.getElementById('pasteBtn'); if (!pb || !els.urlInput) return; pb.addEventListener('click', async function () { try { const t = await navigator.clipboard.readText(); if (t && t.trim()) { els.urlInput.value = t.trim(); updateDetect(); els.urlInput.focus(); toast('已粘贴 ' + extractUrls(t).length + ' 条链接', 'success'); } } catch (e) { toast('粘贴失败,请长按输入框手动粘贴', 'error'); } }); } /* P1-09:拖拽粘贴区——把链接 / 文件拖到输入区即填入(仅拖拽时高亮,无常驻 UI) */ function setupDropzone() { if (!els.urlInput) return; const zone = els.urlInput.closest('.input-bar') || els.urlInput.parentElement || els.urlInput; zone.addEventListener('dragover', function (e) { if (!e.dataTransfer) return; e.preventDefault(); zone.classList.add('drag'); }); zone.addEventListener('dragleave', function (e) { if (e.relatedTarget) return; zone.classList.remove('drag'); }); zone.addEventListener('drop', function (e) { if (!e.dataTransfer) return; e.preventDefault(); zone.classList.remove('drag'); const t = e.dataTransfer.getData('text/uri-list') || e.dataTransfer.getData('text/plain') || ''; if (t && t.trim()) { els.urlInput.value = t.trim(); updateDetect(); els.urlInput.focus(); toast('已从拖拽内容填入链接', 'success'); } }); } /* P1-25:Cookie 高级区——本机记住上次粘贴的 Cookie,下次自动填入(不随历史外泄) */ const COOKIE_KEY = 'ubui_cookie'; function setupCookieRemember() { const ci = els.cookieInput; if (!ci) return; try { const saved = localStorage.getItem(COOKIE_KEY); if (saved) ci.value = saved; } catch (e) { } ci.addEventListener('input', function () { try { if (ci.value.trim()) localStorage.setItem(COOKIE_KEY, ci.value.trim()); else localStorage.removeItem(COOKIE_KEY); } catch (e) { } }); const clr = document.getElementById('cookieClear'); if (clr) clr.addEventListener('click', function () { ci.value = ''; try { localStorage.removeItem(COOKIE_KEY); } catch (e) { } toast('已清除已保存 Cookie', 'success'); }); } function boot() { cacheEls(); if (els.urlInput) { els.urlInput.addEventListener('input', updateDetect); els.urlInput.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); parseFromInput(); } }); } if (els.parseBtn) els.parseBtn.addEventListener('click', parseFromInput); /* 部分页面(首页内联引擎、11 个多语言 TikTok 页)的主按钮 id 是 addBtn, 引擎原本只绑 parseBtn → 这些页面点「开始解析」毫无反应(P0)。这里做兼容绑定。 */ var _addBtn = document.getElementById('addBtn'); if (_addBtn && _addBtn !== els.parseBtn) _addBtn.addEventListener('click', parseFromInput); /* 多链接实时计数:用户常一次贴一堆却以为只能解析一条。 数字 >1 时把提示条点出来,明确告知会逐条排队。 */ (function () { if (!els.urlInput) return; var bar = els.urlInput.closest('.input-bar') || els.urlInput.parentElement; if (!bar || document.getElementById('ubuiMultiHint')) return; var tip = document.createElement('div'); tip.id = 'ubuiMultiHint'; tip.className = 'ubui-multi-hint'; /* 插到 input-bar 之后(而非内部),否则会跟「开始解析」按钮挤在同一行溢出屏幕 */ if (bar.parentElement) bar.parentElement.insertBefore(tip, bar.nextSibling); else bar.appendChild(tip); var upd = function () { var n = extractUrls(els.urlInput.value).length; if (n > 1) { tip.innerHTML = ' 已识别 ' + n + ' 条链接,将逐条解析并排队'; tip.classList.add('show'); } else { tip.classList.remove('show'); } }; els.urlInput.addEventListener('input', upd); els.urlInput.addEventListener('paste', function () { setTimeout(upd, 0); }); upd(); })(); if (els.clearInputBtn) els.clearInputBtn.addEventListener('click', () => { const u = els.urlInput; if (!u) return; u.value = ''; if (els.detectChip) els.detectChip.classList.remove('show'); els.clearInputBtn.style.display = 'none'; u.focus(); }); /* 第七批:粘贴 / 拖拽 / Cookie 记忆(页面有对应元素才生效,缺省安全降级) */ setupPasteBtn(); setupDropzone(); setupCookieRemember(); render(); } /* 平台扩展钩子容器(子页可覆写) */ const hooks = { presets: null, extraActions: null, afterParse: null }; window.UbuiCore = { CFG: CFG, hooks: hooks, boot: boot, render: render, add: add, parse: parseFromInput, enqueue: enqueue, downloadVideo: downloadVideo, downloadAudio: downloadAudio, downloadDirect: downloadDirect, setClarity: setClarity, copyRaw: copyRaw, removeItem: removeItem, retryItem: retryItem, retryAllFailed: retryAllFailed, clearDone: clearDone, clearAll: clearAll, audioAll: audioAll, openHistory: openHistory, closeHistory: closeHistory, clearHistory: clearHistory, rehist: rehist, requestDownload: requestDownload, playistBatch: playistBatch, updateDetect: updateDetect, items: items, esc: esc, fmtSize: fmtSize, parseHeight: parseHeight, qualityKey: qualityKey, statusText: statusText, toast: toast, fmtDuration: fmtDuration, estAudioSize: estAudioSize, classifyError: classifyError, _internal: { startItem: startItem, runTask: runTask, currentStream: currentStream } }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot); } else { boot(); } })(); /* ===== 分类页 / 批量页共用:横幅当前项高亮 ===== * 分类页(video/audio/image/film/live/social + 12 语言版)与 batch.html 都引 dl-core.js, * 这里按当前页文件名点亮横幅对应项。下载页 downloader.html 有自己的内联 renderCatNav * (会做平台 key 反查,例如 ?platform=douyin 点亮「视频」),不走这条逻辑。 */ function setCatBarActive(){ const bar = document.querySelector('.catbar'); if (!bar) return; const links = bar.querySelectorAll('a[data-k]'); if (!links.length) return; const here = (location.pathname.split('/').pop() || 'downloader.html').toLowerCase(); for (let i = 0; i < links.length; i++){ const href = (links[i].getAttribute('href') || '').split('?')[0].split('/').pop().toLowerCase(); if (href && href === here) links[i].classList.add('on'); } } /* 右上角登录态:有 token + 用户 → 头像 + 用户名;否则 → 登录 / 注册 */ function renderCatAuth(){ const box=document.getElementById('cbAuth'); if(!box) return; let u=null; try{ u=JSON.parse(localStorage.getItem('ubui_user')||'null'); }catch(e){ u=null; } const token=localStorage.getItem('ubui_token'); if(u && token && (u.username||u.email)){ const name=String(u.username||u.email).replace(/[<>&"]/g,''); box.innerHTML='' +''+name.charAt(0).toUpperCase()+'' +''+name+''; }else{ box.innerHTML=''; } } /* ===== 导航里的语言切换器:点空白处 / Esc 收起
===== * 用
是为了不限死交互、不写一堆 JS;这里只补「点外面关掉」这一条。 * 注意别用 click 的 target.closest 直接判定 summary —— 点 summary 本身也算 inside。 */ function closeCatLang(e){ const opened = document.querySelectorAll('.cb-lang[open]'); if (!opened.length) return; const t = e && e.target; for (let i = 0; i < opened.length; i++){ if (!t || !t.closest || !t.closest('.cb-lang')) opened[i].open = false; } } function initCatBar(){ setCatBarActive(); renderCatAuth(); document.addEventListener('click', closeCatLang); document.addEventListener('keydown', function(e){ if (e.key === 'Escape') closeCatLang(null); }); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initCatBar); else initCatBar(); /* ===== 移动端汉堡菜单切换(dl-core 共享, 2026-10-12, 防重绑定) ===== */ (function(){ if(window.__ubuiBurgerDone) return; window.__ubuiBurgerDone=true; var burger=document.querySelector(".cb-burger"); var bar=document.getElementById("catBar"); if(!burger||!bar) return; function setOpen(open){ bar.classList.toggle("open",open); burger.setAttribute("aria-expanded",open?"true":"false"); burger.innerHTML=open?'':''; } burger.addEventListener("click",function(e){ e.stopPropagation(); setOpen(!bar.classList.contains("open")); }); bar.querySelectorAll(".cb-links a").forEach(function(a){ a.addEventListener("click",function(){ setOpen(false); }); }); document.addEventListener("click",function(e){ if(bar.classList.contains("open") && !bar.contains(e.target)) setOpen(false); }); })();