// ==UserScript== // @name SoundCloud Audio Grabber // @name:zh-CN SoundCloud 音频抓取器 // @namespace sc-audio-grabber // @version 2.0.0 // @description Grab the original audio of SoundCloud tracks and sets. Direct MP3 when available, otherwise losslessly re-assemble HLS fMP4 segments into .m4a. Streams to disk, cancellable, retryable, SPA aware. // @description:zh-CN 一键抓取 SoundCloud 曲目/歌单的原始音频:有直链 MP3 就直接下载,没有就把 HLS 的 fMP4 分片(init.mp4 + dataNNN.m4s)无损拼装成 .m4a;支持边下边存盘、进度与速度显示、取消与重试,适配站内无刷新跳转。 // @author sc-audio-grabber // @license MIT // @icon https://a-v2.sndcdn.com/assets/images/sc-icons/favicon-2cadd14bdb.ico // @match https://soundcloud.com/* // @match https://m.soundcloud.com/* // @run-at document-idle // @grant none // @noframes // ==/UserScript== /* * SoundCloud Audio Grabber 2.0.0 (MIT) * * 工作流程 * 1. 从页面已发出的请求 / 内联脚本 / sndcdn 资源包里取 client_id,并缓存 6 小时 * 2. api-v2 /resolve 解析当前页面:单曲返回 track,歌单返回 playlist * 3. 挑转码:progressive(直链 mp3) 优先,否则 aac_160k / abr_sq 的 HLS * 4. HLS:解析 m3u8 -> init.mp4 + dataNNN.m4s,6 并发下载、按序写盘 * fMP4 分片按序拼接即为合法 m4a,无需转码 * 5. 落盘:支持 File System Access(边下边写、内存占用恒定),否则 Blob 下载 * * 2.0.0 * + 歌单 / 曲目集批量下载(目录选择器) * + 边下边存盘、取消下载、分段失败自动退避重试 * + Shadow DOM 面板,不受站点样式影响;可拖拽、记忆位置 * + 适配 SoundCloud 的 SPA 无刷新跳转 * + 中文 / 英文自动切换 */ (function () { 'use strict'; if (window.__SCGRAB_LOADED__) return; window.__SCGRAB_LOADED__ = true; /* ===================== 常量 / 文案 ===================== */ var VERSION = '2.0.0'; var API = 'https://api-v2.soundcloud.com'; var ZH = /^zh/i.test(navigator.language || navigator.userLanguage || ''); var STR = ZH ? { title: 'SoundCloud 抓取器', grab: '抓取本页音频', grabSet: '抓取整个歌单', working: '处理中…', cancel: '取消', ready: '就绪,点按钮开始', notTrack: '当前页面不是曲目页或歌单页', noCid: '拿不到 client_id,请先在页面上播放一次任意曲目', noTrans: '该曲目没有可用转码', blocked: '该曲目被版权限制,无法抓取', resolving: '解析页面…', track: '曲目', playlist: '歌单', transcoding: '转码', plan: '分片', seg: '分片', merging: '写入文件…', saved: '已保存', copied: '直链已复制到剪贴板', copy: '复制直链', streamOn: '直接存盘', streamHint: '大文件不占内存', conc: '并发', failed: '失败', aborted: '已取消', skipped: '跳过', speed: '速度', chooseDir: '选择保存文件夹…', tracks: '首', of: '/', noPicker: '浏览器不支持目录写入,改为逐首下载', doneAll: '全部完成', err_net: '网络错误', err_http: 'HTTP 错误', err_pick: '保存位置未选择,已回退到浏览器下载', hint: 'Alt+G 呼出面板' } : { title: 'SoundCloud Grabber', grab: 'Grab this audio', grabSet: 'Grab whole set', working: 'Working…', cancel: 'Cancel', ready: 'Ready — press the button', notTrack: 'This page is not a track or set page', noCid: 'Cannot get client_id — play any track once first', noTrans: 'No usable transcoding for this track', blocked: 'This track is blocked by rights holder', resolving: 'Resolving page…', track: 'Track', playlist: 'Set', transcoding: 'Transcoding', plan: 'Segments', seg: 'segment', merging: 'Writing file…', saved: 'Saved', copied: 'Direct link copied', copy: 'Copy direct link', streamOn: 'Stream to disk', streamHint: 'constant memory', conc: 'Parallel', failed: 'Failed', aborted: 'Cancelled', skipped: 'Skipped', speed: 'Speed', chooseDir: 'Choose save folder…', tracks: 'tracks', of: '/', noPicker: 'Directory writing unsupported — downloading one by one', doneAll: 'All done', err_net: 'Network error', err_http: 'HTTP error', err_pick: 'No location picked — falling back to browser download', hint: 'Alt+G for the panel' }; function t(k) { return STR[k] || k; } /* ===================== 设置 ===================== */ var LS = { get: function (k, d) { try { var v = localStorage.getItem('scgrab.' + k); return v === null ? d : JSON.parse(v); } catch (e) { return d; } }, set: function (k, v) { try { localStorage.setItem('scgrab.' + k, JSON.stringify(v)); } catch (e) {} } }; var cfg = { concurrency: LS.get('conc', 6), retries: 3, stream: LS.get('stream', true) }; /* ===================== 小工具 ===================== */ function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); } function fmtBytes(n) { if (!n || n < 0) return '0 B'; var u = ['B', 'KB', 'MB', 'GB'], i = 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } return (n >= 100 || i === 0 ? Math.round(n) : n.toFixed(1)) + ' ' + u[i]; } function absUrl(u, base) { try { return new URL(u, base).href; } catch (e) { return u; } } function safeName(s) { return String(s == null ? '' : s) .replace(/[\\/:*?"<>|\u0000-\u001f]+/g, '_') .replace(/\s+/g, ' ') .replace(/^[\s.]+|[\s.]+$/g, '') .slice(0, 120) || 'soundcloud'; } function abortError() { var e = new Error('aborted'); e.name = 'AbortError'; return e; } function isAbort(e) { return !!e && (e.name === 'AbortError' || e.code === 20); } /** 带指数退避的重试;AbortError 直接抛出 */ async function withRetry(fn, times, base) { var last; for (var i = 0; i <= times; i++) { try { return await fn(i); } catch (e) { last = e; if (isAbort(e)) throw e; if (i === times) break; await sleep((base || 400) * Math.pow(2, i)); } } throw last; } async function getJson(url, signal) { var r = await fetch(url, { signal: signal, credentials: 'include', headers: { accept: 'application/json' } }); if (!r.ok) { var e = new Error('HTTP ' + r.status); e.status = r.status; throw e; } return r.json(); } async function getBuf(url, signal) { var r = await fetch(url, { signal: signal, credentials: 'omit' }); if (!r.ok) { var e = new Error('HTTP ' + r.status); e.status = r.status; throw e; } return new Uint8Array(await r.arrayBuffer()); } async function getText(url, signal) { var r = await fetch(url, { signal: signal, credentials: 'omit' }); if (!r.ok) { var e = new Error('HTTP ' + r.status); e.status = r.status; throw e; } return r.text(); } /* ===================== client_id ===================== */ var CID_KEY = 'cid'; var ctlNoop = undefined; var CID_TTL = 6 * 3600 * 1000; async function cidValid(cid) { if (!cid || !/^[A-Za-z0-9]{32}$/.test(cid)) return false; try { var r = await fetch(API + '/tracks/13158665?client_id=' + cid, { credentials: 'include' }); return r.ok; } catch (e) { return false; } } async function getClientId(force) { if (!force) { var c = LS.get(CID_KEY, null); if (c && c.v && c.e > Date.now() && await cidValid(c.v)) return c.v; } var cands = []; function push(v) { if (v && cands.indexOf(v) === -1) cands.push(v); } // 1) 页面已经发出去的请求 try { performance.getEntriesByType('resource').forEach(function (e) { var m = /[?&]client_id=([A-Za-z0-9]{32})/.exec(e.name || ''); if (m) push(m[1]); }); } catch (e) {} // 2) 内联脚本 try { var html = document.documentElement.innerHTML; var mm = html.match(/client_id\s*[:=]\s*"([A-Za-z0-9]{32})"/); if (mm) push(mm[1]); } catch (e) {} for (var i = 0; i < cands.length; i++) { if (await cidValid(cands[i])) { remember(cands[i]); return cands[i]; } } if (cands.length) ui.log('client_id 候选失效 (' + cands.length + '),扫描资源包…'); // 3) 扫 sndcdn 资源包 var srcs = []; try { Array.prototype.forEach.call(document.querySelectorAll('script[src]'), function (s) { if (/sndcdn\.com/.test(s.src) && srcs.indexOf(s.src) === -1) srcs.push(s.src); }); } catch (e) {} if (!srcs.length) { // 页面还没挂载脚本:直接拉一份首页 HTML 找资源地址 try { var home = await getText('https://soundcloud.com/', ctlNoop); var mm2 = home.match(/https:\/\/a-v2\.sndcdn\.com\/assets\/[^"']+\.js/g) || []; mm2.forEach(function (u) { if (srcs.indexOf(u) === -1) srcs.push(u); }); } catch (e) {} } for (var j = 0; j < srcs.length; j++) { try { var txt = await getText(srcs[j]); var m = txt.match(/client_id\s*[:=]\s*"([A-Za-z0-9]{32})"/) || txt.match(/client_id=([A-Za-z0-9]{32})/); if (m && await cidValid(m[1])) { ui.log('client_id <- ' + String(srcs[j]).split('/').pop()); remember(m[1]); return m[1]; } } catch (e) {} } ui.log('client_id 获取失败:已检查 ' + cands.length + ' 个候选 / ' + srcs.length + ' 个资源包'); return null; } function remember(cid) { LS.set(CID_KEY, { v: cid, e: Date.now() + CID_TTL }); } /* ===================== 解析曲目 / 歌单 ===================== */ async function resolvePage(url, cid, signal) { return getJson(API + '/resolve?url=' + encodeURIComponent(url) + '&client_id=' + cid, signal); } async function fullTrack(tr, cid, signal) { if (tr && tr.media && tr.media.transcodings && tr.media.transcodings.length) return tr; if (!tr || !tr.id) return tr; return getJson(API + '/tracks/' + tr.id + '?client_id=' + cid, signal); } async function playlistTracks(pl, cid, signal) { var out = (pl.tracks || []).slice(); var want = pl.track_count || out.length; while (out.length < want) { var page = await getJson(API + '/playlists/' + pl.id + '/tracks?client_id=' + cid + '&limit=50&offset=' + out.length, signal); if (!page || !page.length) break; out = out.concat(page); } return out.filter(function (x) { return x && x.kind === 'track'; }); } function pickTranscoding(tr) { var list = (tr && tr.media && tr.media.transcodings) || []; var i; for (i = 0; i < list.length; i++) { if (list[i].format && list[i].format.protocol === 'progressive') { return { item: list[i], hls: false }; } } var order = ['aac_160k', 'abr_sq', 'aac_96k', 'mp3_128', 'mp3_standard']; for (var o = 0; o < order.length; o++) { for (i = 0; i < list.length; i++) { if (list[i].preset === order[o]) return { item: list[i], hls: true }; } } if (!list.length) return null; return { item: list[0], hls: !(list[0].format && list[0].format.protocol === 'progressive') }; } async function streamUrl(tr, cid, trackAuth, signal) { var u = tr.url + (tr.url.indexOf('?') > -1 ? '&' : '?') + 'client_id=' + cid; if (trackAuth) u += '&track_authorization=' + encodeURIComponent(trackAuth); var j = await getJson(u, signal); if (!j || !j.url) throw new Error('no stream url'); return j.url; } /* ===================== m3u8 ===================== */ function parseM3U8(text, base) { var lines = String(text).split(/\r?\n/).map(function (l) { return l.trim(); }).filter(Boolean); if (!lines.length || lines[0].indexOf('#EXTM3U') !== 0) throw new Error('bad m3u8'); var variants = [], init = null, segs = [], key = null, dur = 0; for (var i = 0; i < lines.length; i++) { var l = lines[i]; if (l.indexOf('#EXT-X-STREAM-INF') === 0) { var bw = /BANDWIDTH=(\d+)/.exec(l); var nxt = lines[i + 1]; if (nxt && nxt.charAt(0) !== '#') { variants.push({ bw: bw ? parseInt(bw[1], 10) : 0, url: absUrl(nxt, base) }); i++; } continue; } if (l.indexOf('#EXT-X-MAP') === 0) { var mu = /URI="([^"]+)"/.exec(l); if (mu) init = absUrl(mu[1], base); continue; } if (l.indexOf('#EXT-X-KEY') === 0) { var ku = /URI="([^"]+)"/.exec(l); if (ku) key = absUrl(ku[1], base); continue; } if (l.indexOf('#EXTINF') === 0) { var d = parseFloat(l.slice(8)); if (!isNaN(d)) dur += d; continue; } if (l.charAt(0) === '#') continue; segs.push(absUrl(l, base)); } return { variants: variants, init: init, segs: segs, key: key, duration: dur }; } async function hlsPlan(url, signal) { var p = parseM3U8(await getText(url, signal), url); if (p.variants.length) { p.variants.sort(function (a, b) { return b.bw - a.bw; }); var best = p.variants[0]; var p2 = parseM3U8(await getText(best.url, signal), best.url); p2.bandwidth = best.bw; if (!p2.segs.length) return p; return p2; } return p; } /* ===================== 输出端(内存 或 直接写盘) ===================== */ function canPickFile() { try { return !!(window.showSaveFilePicker && navigator.userActivation && navigator.userActivation.isActive !== false); } catch (e) { return false; } } function canPickDir() { try { return !!(window.showDirectoryPicker && navigator.userActivation && navigator.userActivation.isActive !== false); } catch (e) { return false; } } function memSink(name, mime) { var parts = [], size = 0; return { write: function (u8) { parts.push(u8); size += u8.byteLength; }, close: function () { var blob = new Blob(parts, { type: mime }); saveBlob(blob, name); parts = null; return size; }, abort: function () { parts = null; } }; } function makeFsSink(fileHandle, name, mime) { var w = null, size = 0; return { write: async function (u8) { if (!w) w = await fileHandle.createWritable(); size += u8.byteLength; await w.write(u8); }, close: async function () { if (w) await w.close(); return size; }, abort: async function () { try { if (w) await w.abort(); } catch (e) {} } }; } function saveBlob(blob, name) { var a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name; a.style.display = 'none'; a.rel = 'noopener'; document.body.appendChild(a); a.click(); setTimeout(function () { URL.revokeObjectURL(a.href); a.remove(); }, 120000); } async function openSink(name, mime, ext) { if (cfg.stream && canPickFile()) { try { var h = await window.showSaveFilePicker({ suggestedName: name, types: [{ description: 'Audio', accept: (function () { var o = {}; o[mime] = [ext]; return o; })() }] }); return makeFsSink(h, name, mime); } catch (e) { if (isAbort(e)) throw e; // 用户自己点了取消 if (e && (e.name === 'AbortError')) throw abortError(); // 无用户手势 / 浏览器不支持:静默回退到内存下载 console.debug('[SCGrab] picker unavailable:', e && e.name); } } return memSink(name, mime); } /* ===================== 下载 ===================== */ function abortable(signal) { return signal; } async function grabProgressive(url, sink, ctl, onProg) { var r = await fetch(url, { signal: ctl.signal, credentials: 'omit' }); if (!r.ok) { var e = new Error('HTTP ' + r.status); e.status = r.status; throw e; } var total = parseInt(r.headers.get('content-length') || '0', 10) || 0; var started = Date.now(), bytes = 0; if (!r.body || !r.body.getReader) { var buf = new Uint8Array(await r.arrayBuffer()); await sink.write(buf); onProg({ bytes: buf.byteLength, totalBytes: buf.byteLength, elapsed: Date.now() - started }); return; } var reader = r.body.getReader(); for (;;) { var s = await reader.read(); if (s.done) break; if (ctl.signal.aborted) { try { reader.cancel(); } catch (e2) {} throw abortError(); } await sink.write(s.value); bytes += s.value.byteLength; onProg({ bytes: bytes, totalBytes: total, elapsed: Date.now() - started }); } } async function grabHls(plan, sink, ctl, onProg) { var total = plan.segs.length; var started = Date.now(), bytes = 0, next = 0; if (plan.init) { var ib = await withRetry(function () { return getBuf(plan.init, ctl.signal); }, cfg.retries, 400); await sink.write(ib); bytes += ib.byteLength; } var ready = new Map(); var flushing = Promise.resolve(); var report = function () { onProg({ done: next, total: total, bytes: bytes, elapsed: Date.now() - started }); }; onProg({ done: 0, total: total, bytes: bytes, elapsed: 0 }); function flush() { flushing = flushing.then(async function () { while (ready.has(next)) { var b = ready.get(next); ready.delete(next); await sink.write(b); bytes += b.byteLength; next++; report(); } }); return flushing; } var cursor = 0; async function worker() { for (;;) { if (ctl.signal.aborted) throw abortError(); var i = cursor++; if (i >= total) return; var buf = await withRetry(function () { return getBuf(plan.segs[i], ctl.signal); }, cfg.retries, 400); ready.set(i, buf); await flush(); } } var n = Math.max(1, Math.min(cfg.concurrency, total)); var workers = []; for (var k = 0; k < n; k++) workers.push(worker()); await Promise.all(workers); await flush(); if (next !== total) throw new Error('incomplete: ' + next + '/' + total); report(); } /* ===================== 单曲任务 ===================== */ async function grabTrack(track, cid, ctl, opts) { opts = opts || {}; var tr = await fullTrack(track, cid, ctl.signal); var picked = pickTranscoding(tr); if (!picked) throw new Error(t('noTrans')); var url = await streamUrl(picked.item, cid, tr.track_authorization, ctl.signal); var name = safeName((tr.user && tr.user.username ? tr.user.username + ' - ' : '') + (tr.title || 'track')); var ext, mime; if (!picked.hls) { ext = /\.mp3(\?|$)/i.test(url) ? 'mp3' : (/\.m4a(\?|$)/i.test(url) ? 'm4a' : 'mp3'); mime = ext === 'mp3' ? 'audio/mpeg' : 'audio/mp4'; } else { ext = 'm4a'; mime = 'audio/mp4'; } var fileName = name + '.' + ext; // 先选保存位置(File System Access 需要用户手势,越早越好) var sink = opts.dir ? makeFsSink(await opts.dir.getFileHandle(fileName, { create: true }), fileName, mime) : await openSink(fileName, mime, ext); try { if (picked.hls) { var plan = await hlsPlan(url, ctl.signal); ui.log(t('transcoding') + ': ' + (picked.item.preset || '?') + ' · HLS ' + plan.segs.length + ' seg' + (plan.bandwidth ? ' · ' + Math.round(plan.bandwidth / 1000) + 'k' : '')); await grabHls(plan, sink, ctl, opts.onProg || function () {}); } else { ui.log(t('transcoding') + ': ' + (picked.item.preset || 'progressive') + ' · direct ' + ext); await grabProgressive(url, sink, ctl, opts.onProg || function () {}); } var size = await sink.close(); return { file: fileName, size: size, url: url }; } catch (e) { await sink.abort(); throw e; } } /* ===================== 界面 ===================== */ var ui = { host: null, root: null, expanded: false, el: {}, log: function (msg) { console.log('[SCGrab] ' + msg); if (!ui.el.log) return; var line = document.createElement('div'); line.textContent = msg; ui.el.log.appendChild(line); while (ui.el.log.childNodes.length > 40) ui.el.log.removeChild(ui.el.log.firstChild); ui.el.log.scrollTop = ui.el.log.scrollHeight; }, status: function (msg) { if (ui.el.status) ui.el.status.textContent = msg; }, progress: function (pct) { if (!ui.el.bar) return; ui.el.bar.style.width = Math.max(0, Math.min(100, pct)) + '%'; }, busy: function (on, label) { if (!ui.el.go) return; ui.el.go.disabled = !!on; ui.el.go.textContent = on ? (label || t('working')) : t('grab'); ui.el.cancel.style.display = on ? '' : 'none'; } }; function css() { return [ ':host{all:initial}', '*{box-sizing:border-box;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Microsoft YaHei",sans-serif}', '.wrap{position:fixed;z-index:2147483000;right:18px;bottom:96px;width:300px;color:#eee;', 'background:#141414;border:1px solid #2c2c2c;border-radius:12px;box-shadow:0 12px 36px rgba(0,0,0,.5);', 'overflow:hidden;font-size:12px;line-height:1.5;user-select:none}', '.wrap.min{width:auto;border-radius:24px}', '.head{display:flex;align-items:center;gap:8px;padding:9px 10px;background:linear-gradient(90deg,#ff5500,#ff7a1a);cursor:move}', '.head b{flex:1;font-size:12px;color:#fff;font-weight:700;letter-spacing:.2px}', '.head .v{color:rgba(255,255,255,.75);font-size:10px}', '.head i{color:#fff;cursor:pointer;font-style:normal;padding:0 3px;opacity:.9}', '.head i:hover{opacity:1}', '.body{padding:10px}', '.wrap.min .body{display:none}', '.meta{color:#9a9a9a;margin-bottom:9px;word-break:break-word;display:-webkit-box;-webkit-line-clamp:2;', '-webkit-box-orient:vertical;overflow:hidden;min-height:16px}', '.row{display:flex;gap:6px}', 'button{border:0;border-radius:7px;cursor:pointer;font-size:12px;font-family:inherit}', '.go{flex:1;padding:8px;background:#ff5500;color:#fff;font-weight:700}', '.go:disabled{background:#5a2a10;color:#c9a;cursor:default}', '.cancel{padding:8px 10px;background:#333;color:#eee;display:none}', '.track{height:4px;background:#2a2a2a;border-radius:3px;margin:9px 0 5px;overflow:hidden}', '.bar{height:100%;width:0;background:#ff5500;transition:width .18s}', '.status{color:#8fb6d6;min-height:16px;font-size:11px}', '.opts{display:flex;flex-direction:column;gap:6px;margin-top:9px;color:#8a8a8a;font-size:11px}', '.opts .r{display:flex;align-items:center;gap:6px;min-height:18px}', '.opts .grow{flex:1}', '.opts label{display:flex;align-items:center;gap:4px;cursor:pointer;white-space:nowrap}', '.opts select{background:#222;color:#ddd;border:1px solid #333;border-radius:4px;font-size:11px;padding:1px 2px;outline:none}', '.opts input{accent-color:#ff5500;margin:0}', '.opts .link{cursor:pointer;color:#8fb6d6;border-bottom:1px dashed #3d5a6b;padding-bottom:1px}', '.opts .link:hover{color:#cfe6f5}', '.opts .tag{color:#4a4a4a;font-size:10px}', '.log{margin-top:8px;max-height:78px;overflow:auto;color:#6f8fa3;font-size:11px;line-height:1.45;', 'font-family:ui-monospace,Consolas,monospace;word-break:break-all;user-select:text;', 'scrollbar-width:thin;scrollbar-color:#3a3a3a transparent}', '.log::-webkit-scrollbar{width:6px;height:6px}', '.log::-webkit-scrollbar-thumb{background:#3a3a3a;border-radius:3px}', '.log::-webkit-scrollbar-track{background:transparent}', '.pill{position:fixed;right:18px;bottom:96px;z-index:2147483000;padding:9px 14px;border-radius:22px;', 'background:#ff5500;color:#fff;font-weight:700;font-size:12px;cursor:pointer;border:0;', 'box-shadow:0 8px 24px rgba(0,0,0,.4)}', '.pill:hover{background:#ff6a1f}' ].join(''); } function buildUi() { if (ui.host) return; var host = document.createElement('div'); host.id = 'scgrab-host'; host.style.cssText = 'all:initial;position:fixed;z-index:2147483000;right:0;bottom:0;'; var root; try { root = host.attachShadow({ mode: 'open' }); } catch (e) { root = host; } ui.host = host; ui.root = root; var style = document.createElement('style'); style.textContent = css(); root.appendChild(style); var wrap = document.createElement('div'); wrap.className = 'wrap'; wrap.innerHTML = '
' + t('title') + 'v' + VERSION + '' + '–
' + '
' + '
' + t('ready') + '
' + '
' + '' + '' + '
' + '
' + '
' + '
' + '
' + '' + '' + '' + '
' + '
' + '' + t('copy') + '' + '' + 'MIT · Alt+G' + '
' + '
' + '
' + '
'; var pill = document.createElement('button'); pill.className = 'pill'; pill.textContent = '⬇ ' + (ZH ? '抓取音频' : 'Grab'); pill.style.display = 'none'; pill.setAttribute('data-pill', ''); root.appendChild(wrap); root.appendChild(pill); document.documentElement.appendChild(host); ui.el = { wrap: wrap, meta: wrap.querySelector('[data-meta]'), go: wrap.querySelector('[data-go]'), cancel: wrap.querySelector('[data-cancel]'), bar: wrap.querySelector('[data-bar]'), status: wrap.querySelector('[data-status]'), log: wrap.querySelector('[data-log]'), copy: wrap.querySelector('[data-copy]'), stream: wrap.querySelector('[data-stream]'), conc: wrap.querySelector('[data-conc]'), pill: pill }; restorePos(); ui.el.go.addEventListener('click', function () { start(); }); ui.el.cancel.addEventListener('click', function () { if (ui.ctl) ui.ctl.abort(); }); ui.el.stream.addEventListener('change', function () { cfg.stream = ui.el.stream.checked; LS.set('stream', cfg.stream); }); ui.el.conc.addEventListener('change', function () { cfg.concurrency = parseInt(ui.el.conc.value, 10) || 6; LS.set('conc', cfg.concurrency); }); ui.el.copy.addEventListener('click', function () { if (!ui.lastUrl) return ui.log(t('copy') + ': —'); navigator.clipboard.writeText(ui.lastUrl).then(function () { ui.log(t('copied')); }, function () { ui.log(ui.lastUrl); }); }); wrap.querySelector('[data-min]').addEventListener('click', function () { wrap.style.display = 'none'; pill.style.display = ''; }); pill.addEventListener('click', function () { wrap.style.display = ''; pill.style.display = 'none'; }); enableDrag(wrap.querySelector('.head'), wrap); ui.log(t('hint')); } function restorePos() { var p = LS.get('pos', null); if (!p) return; var w = ui.el.wrap; w.style.left = p.x + 'px'; w.style.top = p.y + 'px'; w.style.right = 'auto'; w.style.bottom = 'auto'; } function enableDrag(handle, el) { var sx = 0, sy = 0, ox = 0, oy = 0, dragging = false; handle.addEventListener('pointerdown', function (e) { if (e.target.tagName === 'I') return; dragging = true; var r = el.getBoundingClientRect(); sx = e.clientX; sy = e.clientY; ox = r.left; oy = r.top; handle.setPointerCapture(e.pointerId); e.preventDefault(); }); handle.addEventListener('pointermove', function (e) { if (!dragging) return; var x = Math.max(0, Math.min(window.innerWidth - 60, ox + e.clientX - sx)); var y = Math.max(0, Math.min(window.innerHeight - 40, oy + e.clientY - sy)); el.style.left = x + 'px'; el.style.top = y + 'px'; el.style.right = 'auto'; el.style.bottom = 'auto'; }); handle.addEventListener('pointerup', function () { if (!dragging) return; dragging = false; var r = el.getBoundingClientRect(); LS.set('pos', { x: Math.round(r.left), y: Math.round(r.top) }); }); } /* ===================== 主流程 ===================== */ var state = { busy: false }; function pageUrl() { return location.origin + location.pathname; } async function start() { if (state.busy) return; state.busy = true; var ctl = new AbortController(); ui.ctl = ctl; ui.busy(true, t('resolving')); ui.progress(0); ui.status(t('resolving')); var t0 = Date.now(); try { var cid = await getClientId(); if (!cid) throw new Error(t('noCid')); var obj = await resolvePage(pageUrl(), cid, ctl.signal); var tracks, isSet = false; if (obj.kind === 'playlist' || obj.kind === 'system-playlists' || obj.tracks) { isSet = true; ui.status(t('playlist') + ': ' + (obj.title || '') + ' · ' + (obj.track_count || 0) + ' ' + t('tracks')); tracks = await playlistTracks(obj, cid, ctl.signal); } else if (obj.kind === 'track') { tracks = [obj]; } else { throw new Error(t('notTrack')); } if (!tracks.length) throw new Error(t('notTrack')); // 歌单:先选目录(需要用户手势,趁早) var dir = null; if (isSet && cfg.stream && canPickDir()) { ui.status(t('chooseDir')); try { dir = await window.showDirectoryPicker({ mode: 'readwrite' }); } catch (e) { if (isAbort(e)) throw e; ui.log(t('noPicker')); } } var okCount = 0, failCount = 0; for (var i = 0; i < tracks.length; i++) { if (ctl.signal.aborted) throw abortError(); var tr = tracks[i]; var label = '(' + (i + 1) + '/' + tracks.length + ') ' + (tr.title || tr.id); ui.status(label); ui.busy(true, t('working')); ui.progress(0); ui.log(label); try { var res = await grabTrack(tr, cid, ctl, { dir: dir, onProg: function (p) { var pct = p.total ? (p.done / p.total * 100) : (p.totalBytes ? (p.bytes / p.totalBytes * 100) : 0); ui.progress(pct); var sp = p.elapsed > 600 ? p.bytes / (p.elapsed / 1000) : 0; ui.status(label + ' · ' + Math.round(pct) + '% · ' + fmtBytes(p.bytes) + (p.total ? ' · ' + p.done + '/' + p.total + ' ' + t('seg') : '') + (sp ? ' · ' + fmtBytes(sp) + '/s' : '')); } }); ui.lastUrl = res.url; okCount++; ui.progress(100); ui.log('✓ ' + res.file + ' ' + fmtBytes(res.size)); } catch (e) { if (isAbort(e)) throw e; failCount++; ui.log('✗ ' + label + ' — ' + (e.message || e)); } } ui.status(t('doneAll') + ' ✓ ' + okCount + (failCount ? ' ✗ ' + failCount : '') + ' (' + ((Date.now() - t0) / 1000).toFixed(1) + 's)'); } catch (e) { if (isAbort(e)) { ui.log(t('aborted')); ui.status(t('aborted')); } else { ui.log('✗ ' + (e.message || e)); ui.status('✗ ' + (e.message || e)); } } finally { state.busy = false; ui.ctl = null; ui.busy(false); ui.el.go.textContent = t('grab'); } } /* ===================== 启动 ===================== */ function mount() { buildUi(); refreshMeta(); // SPA 路由变化 var last = location.href; setInterval(function () { if (location.href === last) return; last = location.href; refreshMeta(); }, 800); document.addEventListener('keydown', function (e) { if (e.altKey && (e.key === 'g' || e.key === 'G')) { e.preventDefault(); if (!ui.el.wrap) return; ui.el.wrap.style.display = ''; ui.el.pill.style.display = 'none'; start(); } }, true); } function refreshMeta() { if (!ui.el.meta) return; var title = String(document.title || '') .replace(/\s*\|\s*(Listen online for free|Free Listening)[^|]*$/i, '') .replace(/^Stream\s+(.+?)\s+by\s+.+$/, '$1') .trim(); ui.el.meta.textContent = title || t('ready'); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', mount); } else { mount(); } // 控制台入口 window.scGrab = function () { if (!ui.el.wrap) mount(); else if (ui.el.wrap.style.display === 'none') { ui.el.wrap.style.display = ''; ui.el.pill.style.display = 'none'; } return start(); }; window.scGrabInfo = function () { return { version: VERSION, cfg: cfg, busy: state.busy }; }; })();