您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Download SoundCloud single tracks or bulk-download playlists (sets) locally without external service.
当前为
// ==UserScript== // @name Local SoundCloud Downloader (single + playlist bulk) // @namespace https://drumkits4.me/ // @version 0.3.2 // @license MIT // @description Download SoundCloud single tracks or bulk-download playlists (sets) locally without external service. // @author 83 (modified maple3142) // @match https://soundcloud.com/* // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/ponyfill.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/StreamSaver.min.js // @grant none // @icon https://a-v2.sndcdn.com/assets/images/sc-icons/favicon-2cadd14bdb.ico // ==/UserScript== streamSaver.mitm = "https://maple3142.github.io/StreamSaver.js/mitm.html"; function hook(obj, name, callback, type) { const fn = obj[name]; obj[name] = function (...args) { if (type === "before") callback.apply(this, args); fn.apply(this, args); if (type === "after") callback.apply(this, args); }; return () => { obj[name] = fn; }; } const btn = { init() { this.el = document.createElement("button"); this.el.textContent = "Download"; this.el.classList.add( "sc-button", "sc-button-medium", "sc-button-icon", "sc-button-responsive", "sc-button-secondary", "sc-button-download" ); }, cb() { const par = document.querySelector(".sc-button-toolbar .sc-button-group"); if (par && this.el.parentElement !== par) par.insertAdjacentElement("beforeend", this.el); }, attach() { this.detach(); this.observer = new MutationObserver(this.cb.bind(this)); this.observer.observe(document.body, { childList: true, subtree: true }); this.cb(); }, detach() { if (this.observer) this.observer.disconnect(); }, }; btn.init(); async function getClientId() { return new Promise((resolve) => { const restore = hook( XMLHttpRequest.prototype, "open", async (method, url) => { try { const u = new URL(url, document.baseURI); const clientId = u.searchParams.get("client_id"); if (!clientId) return; console.log("got clientId", clientId); restore(); resolve(clientId); } catch (e) {} }, "after" ); }); } const clientIdPromise = getClientId(); let controller = null; function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } async function downloadStreamToFile(fetchUrl, filename) { try { const resp = await fetch(fetchUrl); if (!resp.ok) throw new Error(`fetch failed: ${resp.status}`); const contentLength = resp.headers.get("Content-Length"); const ws = streamSaver.createWriteStream(filename, { size: contentLength ? Number(contentLength) : undefined, }); const rs = resp.body; if (rs.pipeTo) { return rs.pipeTo(ws); } const reader = rs.getReader(); const writer = ws.getWriter(); const pump = () => reader .read() .then((res) => res.done ? writer.close() : writer.write(res.value).then(pump) ); return pump(); } catch (err) { console.error("downloadStreamToFile error for", filename, err); throw err; } } async function resolveTranscodingUrl(transcoding, clientId) { const fetchUrl = transcoding.url + `?client_id=${clientId}`; const res = await fetch(fetchUrl); if (!res.ok) throw new Error("failed to resolve transcoding url"); const json = await res.json(); return json.url; } async function fetchFullTrack(id, clientId) { const url = `https://api-v2.soundcloud.com/tracks/${id}?client_id=${clientId}`; const res = await fetch(url); if (!res.ok) throw new Error(`Failed to fetch track ${id}: ${res.status}`); return res.json(); } async function downloadTrackObject(track, clientId) { if (!track.media || !Array.isArray(track.media.transcodings)) { console.log(`Track ${track.id} missing media, refetching full object...`); track = await fetchFullTrack(track.id, clientId); } const progressive = track.media.transcodings.find( (t) => t.format && t.format.protocol === "progressive" ); if (!progressive) { console.warn("no progressive for", track.title || track.id); throw new Error("no progressive"); } const actualUrl = await resolveTranscodingUrl(progressive, clientId); const cleanTitle = (track.title || `track-${track.id}`).replace( /[\\/:"*?<>|]+/g, "" ); const filename = `${cleanTitle}.mp3`; await downloadStreamToFile(actualUrl, filename); } async function load(by) { btn.detach(); console.log("load by", by, location.href); if ( /^(\/(you|stations|discover|stream|upload|search|settings))/.test( location.pathname ) ) return; const clientId = await clientIdPromise; if (controller) { controller.abort(); controller = null; } controller = new AbortController(); const resolveUrl = `https://api-v2.soundcloud.com/resolve?url=${encodeURIComponent( location.href )}&client_id=${clientId}`; let result; try { result = await fetch(resolveUrl, { signal: controller.signal }).then((r) => r.json() ); } catch (err) { console.error("resolve fetch error", err); return; } console.log("result", result); if (result.kind === "track") { btn.el.textContent = "Download"; btn.el.onclick = async () => { try { await downloadTrackObject(result, clientId); console.log("track downloaded:", result.title); } catch (err) { console.error("single track download failed", err); alert("Download failed. See console for details."); } }; btn.attach(); console.log("attached single-track downloader"); return; } if (result.kind === "playlist") { btn.el.textContent = "Download All"; btn.el.onclick = async () => { if ( !confirm( `Download ${result.tracks.length} tracks from playlist "${ result.title || "playlist" }"?` ) ) return; btn.detach(); const failed = []; for (let i = 0; i < result.tracks.length; i++) { const track = result.tracks[i]; console.log( `(${i + 1}/${result.tracks.length}) start:`, track.title || track.id ); try { await downloadTrackObject(track, clientId); console.log(`(${i + 1}) downloaded:`, track.title || track.id); } catch (err) { console.warn( `(${i + 1}) skipped/failed:`, track.title || track.id, err ); failed.push({ index: i + 1, title: track.title || track.id, reason: err.message || String(err), }); } await sleep(800); } btn.attach(); if (failed.length === 0) { alert(`All ${result.tracks.length} tracks downloaded.`); } else { const skipped = failed .map((f) => `${f.index}: ${f.title} (${f.reason})`) .join("\n"); alert( `Some tracks failed or were skipped (${failed.length}). See console for details.\n\n${skipped}` ); console.warn("Failed downloads:", failed); } }; btn.attach(); console.log("attached playlist bulk downloader"); return; } console.log("not track or playlist; nothing attached"); } load("init"); hook(history, "pushState", () => load("pushState"), "after"); window.addEventListener("popstate", () => load("popstate"));
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址