[HTML] 纯文本查看 复制代码
// ==UserScript==
// @name 喜马拉雅静默下载器 Pro
// @namespace https://xmly.local/
// @version 6.4.0
// @description 勾选批量下载 · 单集下载 · MP3 64k / M4A 48k · 免签名接口 · 零依赖单文件
// @author XX (rewritten)
// @match *://www.ximalaya.com/*
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @grant unsafeWindow
// @connect ximalaya.com
// @connect xmcdn.com
// @connect *
// @run-at document-start
// @noframes
// @license MIT
// ==/UserScript==
(function () {
'use strict';
var W = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window;
var STORE_KEY = 'xmly_dl_pro_v6';
var CONCURRENCY = 3;
var PAGE_SIZE = 30;
var QUALITY = 1;
var MAX_DL_RETRY = 2;
var MAX_INFO_RETRY = 4;
var AES_KEY_HEX = 'aaad3e4fd540b0f79dca95606e72bf93';
var CFG = loadCfg();
var STATE = {
running: false,
mode: 'all', // all | rest | sel,决定进度显示在哪个按钮上
done: 0,
total: 0,
fail: [],
tracks: [],
rowState: {}, // trackId -> idle | loading | done | error
selected: {} // trackId -> true,待下载队列
};
var dirHandle = null;
var els = {};
var lastHref = location.href;
function loadCfg() {
var c = GM_getValue(STORE_KEY);
if (!c || typeof c !== 'object') c = {};
c.top = typeof c.top === 'number' ? c.top : 20;
c.left = (typeof c.left === 'number') ? c.left : null;
// 默认 MP3:实测同一集 M4A 只有 49.1kbps(URL 里是 aacv2-48K),
// MP3 是 64.4kbps,MP3 比特率更高、信息量更大。可切回 M4A 省体积。
c.format = c.format === 'm4a' ? 'm4a' : 'mp3';
return c;
}
function saveCfg() { GM_setValue(STORE_KEY, CFG); }
function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
function addStyle(css) {
if (typeof GM_addStyle === 'function') { GM_addStyle(css); return; }
var s = document.createElement('style');
s.textContent = css;
(document.head || document.documentElement).appendChild(s);
}
/* ======================= 网络 ======================= */
function req(url, opts) {
opts = opts || {};
// 显式传 headers 就用传的(传 {} = 完全不带额外头);
// 不传才加 Referer。音频 CDN 不能带 Referer + credentials,
// 否则 CORS 下会 ERR_FAILED(Access-Control-Allow-Origin: * 不允许带凭据)。
var headers = (opts.headers !== undefined)
? opts.headers
: { 'Referer': 'https://www.ximalaya.com/' };
return new Promise(function (resolve, reject) {
try {
GM_xmlhttpRequest({
method: 'GET',
url: url,
responseType: opts.binary ? 'arraybuffer' : 'text',
timeout: opts.timeout || 20000,
headers: headers,
onload: function (r) {
if (r.status >= 200 && r.status < 400) resolve(r);
else reject(new Error('HTTP ' + r.status));
},
onerror: function () { reject(new Error('网络错误')); },
ontimeout: function () { reject(new Error('请求超时')); },
onabort: function () { reject(new Error('已取消')); }
});
} catch (e) { reject(e); }
});
}
function toJson(text) {
try { return JSON.parse(text); } catch (e) { return null; }
}
/* ======================= 页面识别 ======================= */
function pageInfo() {
var m = location.pathname.match(/^\/album\/(\d+)/);
if (m) return { type: 'album', id: m[1] };
m = location.pathname.match(/\/(?:sound|track)\/(\d+)/);
if (m) return { type: 'track', id: m[1] };
return null;
}
/* ======================= 曲目列表 ======================= */
function normTrack(t, i) {
var id = String(t.trackId || t.id || t.soundId || '');
var title = String(t.title || t.trackTitle || t.soundTitle || '').trim();
return {
id: id,
title: title || ('audio_' + id),
index: typeof t.index === 'number' ? t.index : (i + 1),
isPaid: !!(t.isPaid || t.isVipFirst)
};
}
async function fetchTrackPage(albumId, pageNum) {
var url = 'https://www.ximalaya.com/revision/album/getTracksList?albumId=' + albumId +
'&pageNum=' + pageNum + '&pageSize=' + PAGE_SIZE;
var r = await req(url, { timeout: 20000 });
var j = toJson(r.responseText);
if (!j || j.ret !== 200) {
if (j && j.ret === 401) throw new Error('需要登录(401)');
throw new Error('列表接口异常:' + (j ? (j.msg || j.ret) : '无响应'));
}
var d = j.data || {};
var arr = d.tracks || d.list || d.trackList || [];
return {
tracks: arr.map(normTrack).filter(function (x) { return x.id; }),
total: d.trackTotalCount || 0
};
}
async function loadAllTracks(albumId, onProgress) {
var all = [];
var seen = Object.create(null);
var total = 0;
for (var p = 1; p <= 200; p++) {
var page = await fetchTrackPage(albumId, p);
if (page.total) total = page.total;
if (!page.tracks.length) break;
var added = 0;
page.tracks.forEach(function (t) {
if (seen[t.id]) return;
seen[t.id] = 1;
all.push(t);
added++;
});
if (onProgress) onProgress(all.length, total);
if (!added) break;
await sleep(200);
}
all.sort(function (a, b) { return (a.index || 0) - (b.index || 0); });
return { tracks: all, total: total || all.length };
}
async function fetchAlbumTitle(albumId) {
try {
var r = await req('https://www.ximalaya.com/revision/album?albumId=' + albumId, { timeout: 15000 });
var j = toJson(r.responseText);
return (j && j.data && j.data.mainInfo && j.data.mainInfo.albumTitle) || '';
} catch (e) { return ''; }
}
/* ============================================== */
var AES = (function () {
function gmul(a, b) {
var p = 0;
for (var i = 0; i < 8; i++) {
if (b & 1) p ^= a;
var hi = a & 0x80;
a = (a << 1) & 0xff;
if (hi) a ^= 0x1b;
b >>= 1;
}
return p & 0xff;
}
var SBOX = new Uint8Array(256), INV = new Uint8Array(256);
for (var i = 0; i < 256; i++) {
var inv = 0;
if (i) { for (var j = 1; j < 256; j++) { if (gmul(i, j) === 1) { inv = j; break; } } }
var s = inv, x = inv;
for (var k = 0; k < 4; k++) { x = ((x << 1) | (x >> 7)) & 0xff; s ^= x; }
s ^= 0x63;
SBOX[i] = s; INV[s] = i;
}
var M9 = new Uint8Array(256), M11 = new Uint8Array(256),
M13 = new Uint8Array(256), M14 = new Uint8Array(256);
for (var m = 0; m < 256; m++) {
M9[m] = gmul(m, 9); M11[m] = gmul(m, 11);
M13[m] = gmul(m, 13); M14[m] = gmul(m, 14);
}
function expand(key) {
var w = new Uint8Array(176);
w.set(key);
var rcon = 1;
for (var i2 = 16; i2 < 176; i2 += 4) {
var t = w.slice(i2 - 4, i2);
if (i2 % 16 === 0) {
t = new Uint8Array([SBOX[t[1]] ^ rcon, SBOX[t[2]], SBOX[t[3]], SBOX[t[0]]]);
rcon = gmul(rcon, 2);
}
for (var j2 = 0; j2 < 4; j2++) w[i2 + j2] = w[i2 - 16 + j2] ^ t[j2];
}
return w;
}
function ark(s, w, r) { for (var i3 = 0; i3 < 16; i3++) s[i3] ^= w[r * 16 + i3]; }
function invSub(s) { for (var i4 = 0; i4 < 16; i4++) s[i4] = INV[s[i4]]; }
function invShift(s) {
var t = s.slice();
s[1] = t[13]; s[5] = t[1]; s[9] = t[5]; s[13] = t[9];
s[2] = t[10]; s[6] = t[14]; s[10] = t[2]; s[14] = t[6];
s[3] = t[7]; s[7] = t[11]; s[11] = t[15]; s[15] = t[3];
}
function invMix(s) {
for (var c = 0; c < 4; c++) {
var b = c * 4;
var a0 = s[b], a1 = s[b + 1], a2 = s[b + 2], a3 = s[b + 3];
s[b] = M14[a0] ^ M11[a1] ^ M13[a2] ^ M9[a3];
s[b + 1] = M9[a0] ^ M14[a1] ^ M11[a2] ^ M13[a3];
s[b + 2] = M13[a0] ^ M9[a1] ^ M14[a2] ^ M11[a3];
s[b + 3] = M11[a0] ^ M13[a1] ^ M9[a2] ^ M14[a3];
}
}
function hex(h) {
var o = new Uint8Array(h.length / 2);
for (var i5 = 0; i5 < o.length; i5++) o[i5] = parseInt(h.substr(i5 * 2, 2), 16);
return o;
}
function decrypt(ct, keyHex) {
var rk = expand(hex(keyHex));
var out = new Uint8Array(ct.length);
var s = new Uint8Array(16);
for (var off = 0; off < ct.length; off += 16) {
s.set(ct.subarray(off, off + 16));
ark(s, rk, 10);
for (var r = 9; r >= 1; r--) { invShift(s); invSub(s); ark(s, rk, r); invMix(s); }
invShift(s); invSub(s); ark(s, rk, 0);
out.set(s, off);
}
var pad = out[out.length - 1];
if (!(pad > 0 && pad <= 16)) pad = 0;
return out.subarray(0, out.length - pad);
}
return { decrypt: decrypt };
})();
function b64urlToBytes(s) {
var b = String(s).replace(/-/g, '+').replace(/_/g, '/');
while (b.length % 4) b += '=';
var bin = atob(b);
var out = new Uint8Array(bin.length);
for (var i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function decryptPlay(enc) {
if (!enc) return '';
try {
var bytes = AES.decrypt(b64urlToBytes(enc), AES_KEY_HEX);
var txt = new TextDecoder('utf-8').decode(bytes);
return /^https?:\/\//i.test(txt) ? txt : '';
} catch (e) { return ''; }
}
/* ======================= 音频地址 ======================= */
// 按用户选的格式挑一条:m4a / mp3 / best
function pickByFormat(list, fmt) {
var sorted = list.slice().sort(function (a, b) {
return (b.qualityLevel || 0) - (a.qualityLevel || 0);
});
if (fmt === 'mp3') {
for (var i = 0; i < sorted.length; i++) if (/MP3/i.test(sorted[i].type || '')) return sorted[i];
} else if (fmt === 'm4a') {
for (var j = 0; j < sorted.length; j++) if (/M4A/i.test(sorted[j].type || '')) return sorted[j];
}
return sorted[0];
}
async function resolveOnce(trackId) {
var url = 'https://www.ximalaya.com/mobile-playpage/track/v3/baseInfo/' + Date.now() +
'?device=web&trackId=' + encodeURIComponent(trackId) + '&trackQualityLevel=' + QUALITY;
var r = await req(url, { timeout: 12000 });
var j = toJson(r.responseText);
if (!j) throw new Error('返回非 JSON');
if (j.ret) throw new Error(j.msg || ('接口码 ' + j.ret));
var info = j.trackInfo || {};
var list = info.playUrlList || [];
if (!list.length) throw new Error('无播放地址(无权限或已下架)');
var order = (CFG.format === 'm4a') ? ['m4a', 'mp3'] : ['mp3', 'm4a'];
for (var k = 0; k < order.length; k++) {
var it = pickByFormat(list, order[k]);
if (!it) continue;
var plain = /^https?:\/\//i.test(it.url) ? it.url : decryptPlay(it.url);
if (!plain) continue;
// 付费专辑未购买 / VIP 不覆盖时,服务端只给 _preview_ 试听片段
if (/_preview_/i.test(plain)) {
throw new Error('仅试听片段(该专辑需购买或开通 VIP)');
}
return { url: plain, quality: it.qualityLevel || 0, type: it.type || '' };
}
throw new Error('地址解密失败');
}
async function resolveAudio(trackId) {
var last = null;
for (var i = 0; i <= MAX_INFO_RETRY; i++) {
try { return await resolveOnce(trackId); }
catch (e) {
last = e;
if (i < MAX_INFO_RETRY) await sleep(500 + Math.random() * 300);
}
}
throw last || new Error('解析失败');
}
/* ======================= 落盘 ======================= */
function extOf(url) {
var p = String(url).split('#')[0].split('?')[0];
var m = p.match(/\.(mp3|m4a|aac|wav|flac|ogg|mp4)$/i);
return m ? m[1].toLowerCase() : 'm4a';
}
function safeName(s) {
var t = String(s || '')
.replace(/[\\/:*?"<>|\r\n\t]/g, ' ')
.replace(/\s+/g, ' ')
.replace(/^[.\s]+/, '')
.replace(/[.\s]+$/, '')
.trim()
.slice(0, 80);
return t || 'audio';
}
function pad3(n) { n = Number(n) || 0; return ('000' + n).slice(-3); }
function supportsDirPicker() {
return typeof (W.showDirectoryPicker || window.showDirectoryPicker) === 'function';
}
async function pickDirectory() {
if (!supportsDirPicker()) { setMsg('当前浏览器不支持选文件夹,将走普通下载', 'warning'); return false; }
try {
dirHandle = await (W.showDirectoryPicker || window.showDirectoryPicker).call(W, { id: 'xmly-dl', mode: 'readwrite' });
setDirLabel();
setMsg('已选择文件夹:' + dirHandle.name, 'success');
return true;
} catch (e) { return false; }
}
async function ensureDirPermission() {
if (!dirHandle) return false;
try {
if (typeof dirHandle.queryPermission !== 'function') return true;
var p = await dirHandle.queryPermission({ mode: 'readwrite' });
if (p === 'granted') return true;
p = await dirHandle.requestPermission({ mode: 'readwrite' });
return p === 'granted';
} catch (e) { return false; }
}
async function writeToDir(name, buf) {
if (!dirHandle) return false;
if (!(await ensureDirPermission())) return false;
try {
var fh = await dirHandle.getFileHandle(name, { create: true });
var w = await fh.createWritable();
await w.write(buf);
await w.close();
return true;
} catch (e) { return false; }
}
function saveByBlob(buf, name) {
var blob = new Blob([buf], { type: 'application/octet-stream' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = name;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 60000);
}
/* ======================= 下载 ======================= */
async function downloadOne(track, seq) {
var audio = await resolveAudio(track.id);
var name = pad3(seq || track.index || 0) + '_' + safeName(track.title) + '.' + extOf(audio.url);
var r = await req(audio.url, { binary: true, timeout: 300000, headers: {} });
var buf = r && r.response;
if (!buf || buf.byteLength === 0) throw new Error('音频为空');
if (dirHandle && await writeToDir(name, buf)) return name;
saveByBlob(buf, name);
return name;
}
async function withRetry(track, seq) {
var last = null;
for (var i = 0; i <= MAX_DL_RETRY; i++) {
try { return await downloadOne(track, seq); }
catch (e) {
last = e;
if (i < MAX_DL_RETRY) await sleep(900 + Math.random() * 800);
}
}
throw last || new Error('未知错误');
}
async function pool(list, limit, worker) {
var idx = 0;
var size = Math.max(1, Math.min(limit, list.length));
var runners = [];
for (var k = 0; k < size; k++) {
runners.push((async function () {
while (true) {
var i = idx++;
if (i >= list.length) return;
await worker(list[i], i);
}
})());
}
await Promise.all(runners);
}
// 下载单条,同步更新列表行状态
async function runOne(track, seq) {
setRow(track.id, 'loading');
try {
var name = await withRetry(track, seq);
setRow(track.id, 'done');
return { ok: true, name: name };
} catch (e) {
setRow(track.id, 'error');
var msg = (e && e.message) || String(e);
STATE.fail.push('第' + seq + '集 ' + track.title + '|' + msg);
return { ok: false, err: msg };
}
}
/* ======================= 主流程 ======================= */
// 下载勾选的(待下载队列)
async function downloadSelected() {
if (STATE.running) return;
var list = selectedTracks();
if (!list.length) { setMsg('还没勾选任何集数,点左边方框选一下', 'warning'); return; }
STATE.running = true;
STATE.mode = 'sel';
STATE.done = 0;
STATE.total = list.length;
STATE.fail = [];
setBusy(true);
setMsg('下载选中的 ' + list.length + ' 集…', 'loading');
render();
await pool(list, CONCURRENCY, async function (track) {
var seq = STATE.tracks.indexOf(track) + 1;
await runOne(track, seq);
STATE.done++;
render();
});
STATE.running = false;
setBusy(false);
finishMsg();
}
// 只下没下过的(含失败重试)
async function downloadRest() {
if (STATE.running) return;
var tracks = STATE.tracks.filter(function (t) {
return STATE.rowState[t.id] !== 'done';
});
if (!tracks.length) { setMsg('都下完了', 'success'); return; }
STATE.running = true;
STATE.mode = 'rest';
STATE.done = 0;
STATE.total = tracks.length;
STATE.fail = [];
setBusy(true);
setMsg('补下 ' + tracks.length + ' 集…', 'loading');
await pool(tracks, CONCURRENCY, async function (track) {
var seq = STATE.tracks.indexOf(track) + 1;
await runOne(track, seq);
STATE.done++;
render();
});
STATE.running = false;
setBusy(false);
finishMsg();
}
async function downloadSingle(track) {
if (STATE.rowState[track.id] === 'loading') return;
var before = STATE.rowState[track.id];
if (before === 'done') {
if (!confirm('这一集已经下过了,要再下一次吗?')) return;
}
setMsg('正在下载:' + track.title.slice(0, 22) + '…', 'loading');
var seq = STATE.tracks.indexOf(track) + 1;
var r = await runOne(track, seq || track.index || 1);
if (r.ok) setMsg('✅ 已保存:' + r.name, 'success');
else setMsg('❌ 失败:' + r.err, 'error');
}
function finishMsg() {
var ok = STATE.total - STATE.fail.length;
if (STATE.fail.length) {
setMsg('完成 ' + ok + '/' + STATE.total + ',失败 ' + STATE.fail.length + ' 条(明细见控制台)', 'warning');
console.warn('[喜马拉雅下载器] 失败清单:\n' + STATE.fail.join('\n'));
} else {
setMsg('🎉 全部完成!共 ' + STATE.total + ' 集', 'success');
}
STATE.fail = [];
}
async function loadList() {
var pi = pageInfo();
if (!pi) { setMsg('当前页面不是专辑或单集页', 'error'); return; }
if (STATE.running) return;
setList('正在读取列表…');
var tracks = [], total = 0, title = '';
try {
if (pi.type === 'album') {
title = await fetchAlbumTitle(pi.id);
var res = await loadAllTracks(pi.id, function (got, all) {
setList('读取列表 ' + got + (all ? '/' + all : '') + ' 集…');
});
tracks = res.tracks;
total = res.total;
setTitle(title || ('专辑_' + pi.id), tracks.length, total);
} else {
var h = document.querySelector('h1');
var t = h ? (h.getAttribute('title') || h.textContent || '').trim() : '';
tracks = [{ id: pi.id, title: t || ('audio_' + pi.id), index: 1, isPaid: false }];
total = 1;
setTitle(t || '单集', 1, 1);
}
} catch (e) {
var em = (e && e.message) || String(e);
setMsg('读取失败:' + em, 'error');
setList('读取失败:' + em);
console.error('[喜马拉雅下载器] 读列表失败', e);
return;
}
STATE.tracks = tracks;
STATE.rowState = {};
STATE.selected = {};
setMsg('', '');
if (!tracks.length) { setMsg('没找到可下载的集数', 'error'); return; }
renderList();
}
/* ======================= UI ======================= */
var CSS = "" +
"#xmly-dl{position:fixed;z-index:2147483000;width:340px;border-radius:16px;" +
"background:rgba(255,255,255,.97);backdrop-filter:blur(20px) saturate(180%);" +
"-webkit-backdrop-filter:blur(20px) saturate(180%);" +
"box-shadow:0 10px 38px rgba(0,0,0,.16),0 0 0 1px rgba(0,0,0,.05);" +
"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'PingFang SC','Microsoft YaHei',sans-serif;" +
"overflow:hidden;user-select:none;}" +
".xm-hd{display:flex;align-items:center;gap:8px;padding:12px 14px 4px;cursor:grab;}" +
".xm-hd:active{cursor:grabbing;}" +
".xm-ic{font-size:18px;line-height:1;}" +
".xm-tt{font-size:14px;font-weight:700;color:#1a1a1a;flex:1;}" +
".xm-x{width:20px;height:20px;line-height:18px;text-align:center;border-radius:50%;background:rgba(0,0,0,.06);" +
"color:#888;font-size:14px;cursor:pointer;font-weight:400;}" +
".xm-x:hover{background:rgba(0,0,0,.12);color:#333;}" +
".xm-sub{margin:0 14px 8px;font-size:11px;color:#8a8a8a;line-height:1.4;" +
"white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}" +
".xm-tool{display:flex;gap:6px;margin:0 14px 8px;align-items:stretch;}" +
".xm-tool select{flex:0 0 92px;height:32px;padding:0 20px 0 9px;border-radius:8px;border:1px solid #E6E6E6;" +
"background:#FAFAFA;font-size:11.5px;color:#444;outline:none;cursor:pointer;" +
"font-family:inherit;-webkit-appearance:none;appearance:none;" +
"background-image:linear-gradient(45deg,transparent 50%,#999 50%),linear-gradient(135deg,#999 50%,transparent 50%);" +
"background-position:calc(100% - 12px) 14px,calc(100% - 8px) 14px;background-size:4px 4px;background-repeat:no-repeat;}" +
".xm-tool select:focus{border-color:#FC6D26;}" +
".xm-btn{display:flex;align-items:center;justify-content:center;gap:3px;border:none;border-radius:8px;" +
"height:32px;font-size:12px;font-weight:600;cursor:pointer;font-family:inherit;" +
"white-space:nowrap;flex-shrink:0;transition:background .2s;overflow:hidden;}" +
".xm-sel{flex:1;min-width:0;background:linear-gradient(135deg,#FC6D26,#F4511E);color:#fff;" +
"box-shadow:0 3px 10px rgba(244,81,30,.26);}" +
".xm-sel:hover{background:linear-gradient(135deg,#E64A19,#D84315);}" +
".xm-sel[disabled]{background:#F0F0F0;color:#B5B5B5;box-shadow:none;cursor:default;}" +
".xm-all{flex:0 0 54px;background:rgba(0,0,0,.05);color:#5a5a5a;font-weight:500;}" +
".xm-all:hover{background:rgba(0,0,0,.09);}" +
".xm-rest{flex:0 0 44px;background:rgba(0,0,0,.05);color:#5a5a5a;font-weight:500;}" +
".xm-rest:hover{background:rgba(0,0,0,.09);}" +
".xm-list{margin:0 14px 8px;max-height:236px;overflow-y:auto;overflow-x:hidden;" +
"border-radius:10px;background:#FAFAFA;padding:4px;}" +
".xm-list::-webkit-scrollbar{width:6px;}" +
".xm-list::-webkit-scrollbar-thumb{background:#D8D8D8;border-radius:3px;}" +
".xm-row{display:flex;align-items:center;gap:5px;padding:5px 6px;border-radius:7px;cursor:pointer;}" +
".xm-row:hover{background:rgba(0,0,0,.035);}" +
".xm-row.sel{background:rgba(244,81,30,.07);}" +
".xm-row.sel .n{color:#C2410C;font-weight:500;}" +
".xm-row .c{flex:0 0 13px;width:13px;height:13px;margin:0;padding:0;accent-color:#F4511E;cursor:pointer;}" +
".xm-row .i{flex:0 0 26px;font-size:10px;color:#B0B0B0;font-variant-numeric:tabular-nums;}" +
".xm-row .n{flex:1;font-size:11.5px;color:#333;white-space:nowrap;overflow:hidden;" +
"text-overflow:ellipsis;line-height:1.5;}" +
".xm-row.error .n{color:#C62828;}" +
".xm-row.done .n{color:#2E7D32;}" +
".xm-row .d{flex:0 0 42px;height:22px;border:none;border-radius:6px;background:#FFF;color:#F4511E;" +
"font-size:11px;font-weight:600;cursor:pointer;box-shadow:0 0 0 1px rgba(244,81,30,.28);font-family:inherit;}" +
".xm-row .d:hover{background:#F4511E;color:#fff;}" +
".xm-row .d[disabled]{background:#F2F2F2;color:#BBB;box-shadow:none;cursor:default;}" +
".xm-row.done .d{background:#E8F5E9;color:#2E7D32;box-shadow:none;}" +
".xm-row.error .d{background:#FFEBEE;color:#C62828;box-shadow:none;}" +
".xm-empty{padding:14px 0;text-align:center;font-size:11px;color:#AAA;}" +
".xm-barwrap{display:none;margin:0 14px 8px;height:4px;border-radius:3px;background:#F0F0F0;overflow:hidden;}" +
".xm-bar{height:100%;width:0;border-radius:3px;background:linear-gradient(90deg,#FC6D26,#FFA726);transition:width .25s;}" +
".xm-dir{display:flex;align-items:center;justify-content:center;width:calc(100% - 28px);margin:0 14px 8px;" +
"padding:7px 0;border:none;border-radius:8px;background:rgba(0,0,0,.04);color:#666;font-size:11.5px;" +
"cursor:pointer;font-family:inherit;}" +
".xm-dir:hover{background:rgba(0,0,0,.08);}" +
".xm-msg{display:none;margin:0 14px 12px;padding:8px 10px;border-radius:9px;font-size:11.5px;" +
"font-weight:500;text-align:center;line-height:1.5;word-break:break-all;}" +
".xm-msg.success{display:block;background:#E8F5E9;color:#2E7D32;}" +
".xm-msg.warning{display:block;background:#FFF3E0;color:#E65100;}" +
".xm-msg.error{display:block;background:#FFEBEE;color:#C62828;}" +
".xm-msg.loading{display:block;background:#E3F2FD;color:#1565C0;}";
function buildUI() {
if (document.getElementById('xmly-dl')) return;
var d = document.createElement('div');
d.id = 'xmly-dl';
d.style.top = (CFG.top || 20) + 'px';
if (CFG.left == null) { d.style.right = '20px'; } else { d.style.left = CFG.left + 'px'; }
d.innerHTML =
'<div class="xm-hd">' +
'<span class="xm-ic">🎵</span>' +
'<span class="xm-tt">喜马拉雅下载器</span>' +
'<span class="xm-x" id="xm-min" title="收起/展开">–</span>' +
'</div>' +
'<div class="xm-sub" id="xm-sub">正在识别页面…</div>' +
'<div id="xm-body">' +
'<div class="xm-tool">' +
'<select id="xm-fmt" title="下载格式">' +
'<option value="mp3">MP3 64k</option>' +
'<option value="m4a">M4A 48k</option>' +
'</select>' +
'<button class="xm-btn xm-all" id="xm-all">全选</button>' +
'<button class="xm-btn xm-rest" id="xm-rest" title="只下载还没下过的">补缺</button>' +
'<button class="xm-btn xm-sel" id="xm-sel" title="下载勾选的集数">下载选中</button>' +
'</div>' +
'<div class="xm-barwrap" id="xm-barwrap"><div class="xm-bar" id="xm-bar"></div></div>' +
'<div class="xm-list" id="xm-list"><div class="xm-empty">正在读取列表…</div></div>' +
'<button class="xm-dir" id="xm-dir">📁 选择保存文件夹</button>' +
'<div class="xm-msg" id="xm-msg"></div>' +
'</div>';
document.body.appendChild(d);
els.root = d;
els.sub = d.querySelector('#xm-sub');
els.body = d.querySelector('#xm-body');
els.fmt = d.querySelector('#xm-fmt');
els.sel = d.querySelector('#xm-sel');
els.all = d.querySelector('#xm-all');
els.rest = d.querySelector('#xm-rest');
els.barwrap = d.querySelector('#xm-barwrap');
els.bar = d.querySelector('#xm-bar');
els.list = d.querySelector('#xm-list');
els.dir = d.querySelector('#xm-dir');
els.msg = d.querySelector('#xm-msg');
els.min = d.querySelector('#xm-min');
els.fmt.value = CFG.format;
els.fmt.addEventListener('change', function () {
CFG.format = els.fmt.value;
saveCfg();
setMsg('格式已切换为 ' + (els.fmt.options[els.fmt.selectedIndex].text), 'success');
});
els.sel.addEventListener('click', function () { downloadSelected(); });
els.all.addEventListener('click', function () { toggleSelectAll(); });
els.rest.addEventListener('click', function () { downloadRest(); });
els.dir.addEventListener('click', function () { pickDirectory(); });
els.min.addEventListener('click', function () {
var hidden = els.body.style.display === 'none';
els.body.style.display = hidden ? 'block' : 'none';
els.min.textContent = hidden ? '–' : '+';
});
enableDrag(d);
setDirLabel();
loadList();
}
function enableDrag(el) {
var handle = el.querySelector('.xm-hd');
handle.addEventListener('mousedown', function (e) {
if (e.button !== 0 || e.target.id === 'xm-min') return;
e.preventDefault();
var rect = el.getBoundingClientRect();
var dx = e.clientX - rect.left;
var dy = e.clientY - rect.top;
el.style.left = rect.left + 'px';
el.style.top = rect.top + 'px';
el.style.right = 'auto';
function onMove(ev) {
var x = Math.max(0, Math.min(window.innerWidth - el.offsetWidth, ev.clientX - dx));
var y = Math.max(0, Math.min(window.innerHeight - el.offsetHeight, ev.clientY - dy));
el.style.left = x + 'px';
el.style.top = y + 'px';
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
CFG.left = parseInt(el.style.left, 10) || 0;
CFG.top = parseInt(el.style.top, 10) || 0;
saveCfg();
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
}
function renderList() {
if (!els.list) return;
if (!STATE.tracks.length) {
els.list.innerHTML = '<div class="xm-empty">没有曲目</div>';
updateSelBtn();
return;
}
var frag = document.createDocumentFragment();
STATE.tracks.forEach(function (t, i) {
var row = document.createElement('div');
row.className = 'xm-row' + (STATE.selected[t.id] ? ' sel' : '');
row.dataset.id = t.id;
row.innerHTML =
'<input type="checkbox" class="c"' + (STATE.selected[t.id] ? ' checked' : '') + '>' +
'<span class="i">' + pad3(i + 1) + '</span>' +
'<span class="n" title="' + escapeAttr(t.title) + '">' + escapeHtml(t.title) + '</span>' +
'<button class="d">下载</button>';
var cb = row.querySelector('.c');
// 点整行(除了「下载」按钮)都能切换勾选
row.addEventListener('click', function (e) {
if (e.target.closest('.d')) return;
if (e.target === cb) { setSelected(t.id, cb.checked); return; }
cb.checked = !cb.checked;
setSelected(t.id, cb.checked);
});
cb.addEventListener('change', function () { setSelected(t.id, cb.checked); });
row.querySelector('.d').addEventListener('click', function () { downloadSingle(t); });
frag.appendChild(row);
});
els.list.innerHTML = '';
els.list.appendChild(frag);
updateSelBtn();
}
function setSelected(trackId, on) {
if (on) STATE.selected[trackId] = true;
else delete STATE.selected[trackId];
var row = els.list && els.list.querySelector('.xm-row[data-id="' + trackId + '"]');
if (row) {
row.classList.toggle('sel', !!on);
var cb = row.querySelector('.c');
if (cb) cb.checked = !!on;
}
updateSelBtn();
}
function selectedTracks() {
return STATE.tracks.filter(function (t) { return STATE.selected[t.id]; });
}
function updateSelBtn() {
if (!els.sel) return;
var n = selectedTracks().length;
var total = STATE.tracks.length;
els.sel.textContent = n ? ('下载选中 ' + n) : '下载选中';
els.sel.disabled = (n === 0) || STATE.running;
if (els.all) {
els.all.textContent = (total > 0 && n === total) ? '取消全选' : '全选';
els.all.disabled = STATE.running || total === 0;
}
}
function toggleSelectAll() {
if (STATE.running || !STATE.tracks.length) return;
var allOn = selectedTracks().length === STATE.tracks.length;
STATE.selected = {};
if (!allOn) STATE.tracks.forEach(function (t) { STATE.selected[t.id] = true; });
// 同步 DOM
els.list.querySelectorAll('.xm-row').forEach(function (row) {
var on = !!STATE.selected[row.dataset.id];
row.classList.toggle('sel', on);
var cb = row.querySelector('.c');
if (cb) cb.checked = on;
});
updateSelBtn();
setMsg(allOn ? '已取消全选' : ('已全选 ' + STATE.tracks.length + ' 集'), 'success');
}
function setRow(trackId, state) {
STATE.rowState[trackId] = state;
var row = els.list && els.list.querySelector('.xm-row[data-id="' + trackId + '"]');
if (!row) return;
row.classList.remove('loading', 'done', 'error');
if (state !== 'idle') row.classList.add(state);
var btn = row.querySelector('.d');
if (!btn) return;
if (state === 'loading') { btn.disabled = true; btn.textContent = '…'; }
else if (state === 'done') { btn.disabled = false; btn.textContent = '✓'; }
else if (state === 'error') { btn.disabled = false; btn.textContent = '重试'; }
else { btn.disabled = false; btn.textContent = '下载'; }
}
function setList(html) {
if (els.list) els.list.innerHTML = '<div class="xm-empty">' + escapeHtml(html) + '</div>';
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
});
}
function escapeAttr(s) { return escapeHtml(s).replace(/"/g, '"'); }
function setMsg(text, type) {
if (!els.msg) return;
els.msg.className = 'xm-msg' + (text ? ' ' + (type || 'success') : '');
els.msg.textContent = text || '';
}
function setBusy(busy) {
if (!els.sel) return;
els.sel.disabled = busy || selectedTracks().length === 0;
els.rest.disabled = busy;
if (els.all) els.all.disabled = busy || !STATE.tracks.length;
if (!busy) {
els.rest.textContent = '补缺';
updateSelBtn();
}
els.barwrap.style.display = busy ? 'block' : 'none';
if (!busy) els.bar.style.width = '0%';
}
function render() {
var pct = STATE.total ? Math.round(STATE.done / STATE.total * 100) : 0;
if (els.bar) els.bar.style.width = pct + '%';
if (!STATE.running) return;
var txt = STATE.done + '/' + STATE.total;
if (STATE.mode === 'rest' && els.rest) els.rest.textContent = txt;
else if (els.sel) { els.sel.textContent = txt; els.sel.disabled = true; }
}
function setDirLabel() {
if (!els.dir) return;
els.dir.textContent = dirHandle ? ('📁 ' + dirHandle.name) : '📁 选择保存文件夹';
}
function setTitle(title, got, total) {
if (!els.sub) return;
els.sub.textContent = title + ' · 共 ' + total + ' 集' + (got < total ? '(已读 ' + got + ')' : '');
els.sub.title = title;
}
setInterval(function () {
if (location.href === lastHref) return;
lastHref = location.href;
if (STATE.running) return;
STATE.tracks = [];
STATE.rowState = {};
STATE.selected = {};
setMsg('', '');
setList('页面已切换,正在读取…');
loadList();
}, 900);
addStyle(CSS);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', buildUI, { once: true });
} else {
buildUI();
}
})();