/* ============================================================================
* 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 += '
粘贴链接,点「开始解析」即可。解析后选择清晰度与格式并下载。
🔒 链接与历史仅保存在本机浏览器,不会上传服务器。
暂无历史记录。解析过的链接会仅保存在本机浏览器。
'; return; } box.innerHTML = list.map(function(h,i){ return '