Local SoundCloud Downloader (single + playlist bulk)

Download SoundCloud single tracks or bulk-download playlists (sets) locally without external service.

当前为 2025-09-20 提交的版本,查看 最新版本

// ==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或关注我们的公众号极客氢云获取最新地址