您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Download SoundCloud tracks individually or playlists as ZIP archives with cover art
// ==UserScript== // @name Local SoundCloud Downloader (single + playlist ZIP + cover art) // @namespace https://drumkits4.me/ // @version 0.6.0 // @license MIT // @description Download SoundCloud tracks individually or playlists as ZIP archives with cover art // @author 83 (modified maple3142, optimized with ZIP support) // @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 // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js // @grant none // @icon https://a-v2.sndcdn.com/assets/images/sc-icons/favicon-2cadd14bdb.ico // ==/UserScript== (function () { "use strict"; streamSaver.mitm = "https://maple3142.github.io/StreamSaver.js/mitm.html"; // Cache for storing resolved URLs and client ID const cache = { clientId: null, resolvedUrls: new Map(), trackCache: new Map(), }; // Constants for better maintainability const CONSTANTS = { DELAY_BETWEEN_DOWNLOADS: 500, CACHE_TTL: 30 * 60 * 1000, // 30 minutes MAX_RETRIES: 3, RETRY_DELAY: 1000, FILENAME_SANITIZE_REGEX: /[\\/:"*?<>|]+/g, IGNORED_PATHS: /^\/(?:you|stations|discover|stream|upload|search|settings)(?:\/|$)/, ZIP_COMPRESSION_LEVEL: 6, // 0-9, higher = better compression but slower }; function hook(obj, name, callback, type = "after") { const fn = obj[name]; if (!fn) return () => {}; obj[name] = function (...args) { const result = type === "before" ? callback.apply(this, args) : undefined; const fnResult = fn.apply(this, args); if (type === "after") callback.apply(this, args); return result !== undefined ? result : fnResult; }; return () => { obj[name] = fn; }; } function makeButton(label, variant = "secondary") { const el = document.createElement("button"); el.textContent = label; el.className = `sc-button sc-button-medium sc-button-icon sc-button-responsive sc-button-${variant} sc-button-download`; return el; } async function getClientId() { if (cache.clientId) return cache.clientId; return new Promise((resolve) => { const restore = hook( XMLHttpRequest.prototype, "open", (method, url) => { try { const u = new URL(url, document.baseURI); const clientId = u.searchParams.get("client_id"); if (clientId) { console.log("Client ID obtained:", clientId); cache.clientId = clientId; restore(); resolve(clientId); } } catch (e) { console.error("Error extracting client ID:", e); } }, "after" ); }); } const clientIdPromise = getClientId(); let controller = null; function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function sanitizeFilename(name) { return ( name.replace(CONSTANTS.FILENAME_SANITIZE_REGEX, "").trim() || "untitled" ); } async function fetchWithRetry( url, options = {}, retries = CONSTANTS.MAX_RETRIES ) { for (let i = 0; i < retries; i++) { try { const response = await fetch(url, options); if (response.ok) return response; if (response.status === 404) throw new Error("Resource not found"); if (i === retries - 1) throw new Error(`HTTP ${response.status}`); } catch (err) { if (i === retries - 1) throw err; console.warn(`Retry ${i + 1}/${retries} for ${url}`); await sleep(CONSTANTS.RETRY_DELAY * (i + 1)); } } } async function downloadStreamToFile(fetchUrl, filename) { try { const resp = await fetchWithRetry(fetchUrl); const contentLength = resp.headers.get("Content-Length"); const ws = streamSaver.createWriteStream(filename, { size: contentLength ? Number(contentLength) : undefined, }); const rs = resp.body; if (!rs) throw new Error("No response body"); if (rs.pipeTo) { return rs.pipeTo(ws); } // Fallback for browsers without pipeTo const reader = rs.getReader(); const writer = ws.getWriter(); try { while (true) { const { done, value } = await reader.read(); if (done) break; await writer.write(value); } await writer.close(); } catch (err) { writer.abort(); throw err; } } catch (err) { console.error(`Download failed for ${filename}:`, err); throw err; } } async function fetchAsArrayBuffer(url) { const response = await fetchWithRetry(url); return response.arrayBuffer(); } async function resolveTranscodingUrl(transcoding, clientId) { const cacheKey = `${transcoding.url}_${clientId}`; if (cache.resolvedUrls.has(cacheKey)) { const cached = cache.resolvedUrls.get(cacheKey); if (Date.now() - cached.timestamp < CONSTANTS.CACHE_TTL) { return cached.url; } cache.resolvedUrls.delete(cacheKey); } const fetchUrl = `${transcoding.url}?client_id=${clientId}`; const res = await fetchWithRetry(fetchUrl); const json = await res.json(); cache.resolvedUrls.set(cacheKey, { url: json.url, timestamp: Date.now(), }); return json.url; } async function fetchFullTrack(id, clientId) { if (cache.trackCache.has(id)) { const cached = cache.trackCache.get(id); if (Date.now() - cached.timestamp < CONSTANTS.CACHE_TTL) { return cached.data; } cache.trackCache.delete(id); } const url = `https://api-v2.soundcloud.com/tracks/${id}?client_id=${clientId}`; const res = await fetchWithRetry(url); const data = await res.json(); cache.trackCache.set(id, { data, timestamp: Date.now(), }); return data; } async function getTrackArrayBuffer(track, clientId) { // Ensure we have full track data with media if (!track.media?.transcodings?.length) { console.log(`Track ${track.id} incomplete, fetching full data...`); track = await fetchFullTrack(track.id, clientId); } // Prefer progressive download over HLS const progressive = track.media.transcodings.find( (t) => t.format?.protocol === "progressive" ); if (!progressive) { // Fallback to HLS if no progressive available const hls = track.media.transcodings.find( (t) => t.format?.protocol === "hls" ); if (!hls) { throw new Error("No suitable transcoding format available"); } console.warn( `Using HLS format for ${track.title} (progressive not available)` ); } const transcoding = progressive || track.media.transcodings[0]; const actualUrl = await resolveTranscodingUrl(transcoding, clientId); return fetchAsArrayBuffer(actualUrl); } async function downloadTrackObject(track, clientId) { const buffer = await getTrackArrayBuffer(track, clientId); const cleanTitle = sanitizeFilename(track.title || `track-${track.id}`); const filename = `${cleanTitle}.mp3`; // Convert ArrayBuffer to Blob for download const blob = new Blob([buffer], { type: "audio/mpeg" }); const ws = streamSaver.createWriteStream(filename, { size: blob.size, }); const readableStream = blob.stream(); await readableStream.pipeTo(ws); return filename; } async function getCoverArtBuffer(track) { let artUrl = null; // Priority 1: API artwork_url with highest quality if (track.artwork_url) { artUrl = track.artwork_url .replace("-large", "-t1080x1080") .replace("-small", "-t1080x1080") .replace("-badge", "-t1080x1080"); } // Priority 2: Check DOM for artwork if (!artUrl) { const artSelectors = [ ".sc-artwork[style*='background-image']", ".image__full[style*='background-image']", ".artwork[style*='background-image']", ]; for (const selector of artSelectors) { const artEl = document.querySelector(selector); if (artEl) { const bg = artEl.style.backgroundImage; const match = bg.match(/url\(["']?([^"')]+)["']?\)/); if (match) { artUrl = match[1]; break; } } } } if (!artUrl) return null; try { return await fetchAsArrayBuffer(artUrl); } catch (err) { console.warn("Could not fetch cover art:", err); return null; } } async function downloadCoverArt(track) { try { const buffer = await getCoverArtBuffer(track); if (!buffer) throw new Error("No cover art found"); const cleanTitle = sanitizeFilename(track.title || `cover-${track.id}`); const filename = `${cleanTitle}-cover.jpg`; const blob = new Blob([buffer], { type: "image/jpeg" }); const ws = streamSaver.createWriteStream(filename, { size: blob.size, }); const readableStream = blob.stream(); await readableStream.pipeTo(ws); console.log("Cover art downloaded:", filename); return filename; } catch (err) { console.error("Cover art download failed:", err); throw new Error(`Cover download failed: ${err.message}`); } } async function downloadPlaylistAsZip(result, clientId, progressCallback) { const zip = new JSZip(); const playlistName = sanitizeFilename( result.title || "SoundCloud_Playlist" ); const failed = []; const trackCount = result.tracks.length; // Try to add playlist cover art try { const coverBuffer = await getCoverArtBuffer(result); if (coverBuffer) { zip.file(`${playlistName}_cover.jpg`, coverBuffer); console.log("Added playlist cover to ZIP"); } } catch (err) { console.warn("Could not add playlist cover:", err); } // Create a folder for tracks const tracksFolder = zip.folder("tracks"); // Download all tracks for (let i = 0; i < trackCount; i++) { const track = result.tracks[i]; const trackTitle = track.title || `Track ${track.id}`; if (progressCallback) { progressCallback(i + 1, trackCount, trackTitle); } console.log(`[${i + 1}/${trackCount}] Downloading: ${trackTitle}`); try { const buffer = await getTrackArrayBuffer(track, clientId); const cleanTitle = sanitizeFilename(trackTitle); // Add track number prefix for proper ordering const paddedNumber = String(i + 1).padStart(3, "0"); const filename = `${paddedNumber}_${cleanTitle}.mp3`; tracksFolder.file(filename, buffer); console.log(`[${i + 1}/${trackCount}] ✓ Added to ZIP: ${filename}`); } catch (err) { console.warn(`[${i + 1}/${trackCount}] ✗ Failed: ${trackTitle}`, err); failed.push({ index: i + 1, title: trackTitle, reason: err.message || String(err), }); } // Small delay to avoid rate limiting if (i < trackCount - 1) { await sleep(CONSTANTS.DELAY_BETWEEN_DOWNLOADS); } } // Add a text file with track listing and any failures let infoContent = `${playlistName}\n${"=".repeat(playlistName.length)}\n\n`; infoContent += `Total Tracks: ${trackCount}\n`; infoContent += `Successfully Downloaded: ${trackCount - failed.length}\n`; infoContent += `Failed: ${failed.length}\n\n`; infoContent += "Track Listing:\n" + "-".repeat(30) + "\n"; result.tracks.forEach((track, i) => { const status = failed.some((f) => f.index === i + 1) ? " [FAILED]" : ""; infoContent += `${i + 1}. ${ track.title || `Track ${track.id}` }${status}\n`; }); if (failed.length > 0) { infoContent += "\n\nFailed Downloads:\n" + "-".repeat(30) + "\n"; failed.forEach((f) => { infoContent += `${f.index}. ${f.title}\n Reason: ${f.reason}\n\n`; }); } zip.file("playlist_info.txt", infoContent); // Generate and download the ZIP file console.log("Generating ZIP file..."); const blob = await zip.generateAsync( { type: "blob", compression: "DEFLATE", compressionOptions: { level: CONSTANTS.ZIP_COMPRESSION_LEVEL, }, }, (metadata) => { if (progressCallback && metadata.percent) { progressCallback( -1, -1, `Creating ZIP: ${Math.round(metadata.percent)}%` ); } } ); // Download the ZIP file const zipFilename = `${playlistName}.zip`; const ws = streamSaver.createWriteStream(zipFilename, { size: blob.size, }); const readableStream = blob.stream(); await readableStream.pipeTo(ws); return { filename: zipFilename, failed, totalSize: blob.size, successCount: trackCount - failed.length, }; } async function createDownloadUI(result, clientId, toolbar) { // Remove existing download buttons toolbar.querySelectorAll(".sc-button-download").forEach((b) => b.remove()); if (result.kind === "track") { const dlBtn = makeButton("Download"); const coverBtn = makeButton("Cover"); dlBtn.onclick = async () => { dlBtn.disabled = true; dlBtn.textContent = "Downloading..."; try { const filename = await downloadTrackObject(result, clientId); dlBtn.textContent = "✓ Downloaded"; setTimeout(() => { dlBtn.textContent = "Download"; dlBtn.disabled = false; }, 2000); } catch (err) { console.error("Track download failed:", err); alert(`Download failed: ${err.message}`); dlBtn.textContent = "Download"; dlBtn.disabled = false; } }; coverBtn.onclick = async () => { coverBtn.disabled = true; coverBtn.textContent = "Getting..."; try { await downloadCoverArt(result); coverBtn.textContent = "✓ Done"; setTimeout(() => { coverBtn.textContent = "Cover"; coverBtn.disabled = false; }, 2000); } catch (err) { alert(err.message); coverBtn.textContent = "Cover"; coverBtn.disabled = false; } }; toolbar.append(dlBtn, coverBtn); console.log("Single track downloader attached"); } else if (result.kind === "playlist") { const dlZipBtn = makeButton("Download ZIP"); const coverBtn = makeButton("Cover"); dlZipBtn.onclick = async () => { const trackCount = result.tracks.length; const playlistTitle = result.title || "playlist"; if ( !confirm( `Download ${trackCount} tracks from "${playlistTitle}" as a ZIP file?\n\n` + `This will bundle all tracks into a single compressed archive.` ) ) { return; } dlZipBtn.disabled = true; const originalText = dlZipBtn.textContent; const startTime = Date.now(); try { const zipResult = await downloadPlaylistAsZip( result, clientId, (current, total, status) => { if (current === -1) { // ZIP generation progress dlZipBtn.textContent = status; } else { // Track download progress dlZipBtn.textContent = `${current}/${total}...`; } } ); const elapsedTime = Math.round((Date.now() - startTime) / 1000); const sizeMB = (zipResult.totalSize / (1024 * 1024)).toFixed(2); if (zipResult.failed.length === 0) { alert( `✓ ZIP downloaded successfully!\n\n` + `File: ${zipResult.filename}\n` + `Tracks: ${zipResult.successCount}\n` + `Size: ${sizeMB} MB\n` + `Time: ${elapsedTime}s` ); } else { const failedDetails = zipResult.failed .slice(0, 3) .map((f) => `• Track ${f.index}: ${f.title}`) .join("\n"); const moreText = zipResult.failed.length > 3 ? `\n...and ${ zipResult.failed.length - 3 } more (see playlist_info.txt in ZIP)` : ""; alert( `ZIP downloaded with some failures\n\n` + `File: ${zipResult.filename}\n` + `Success: ${zipResult.successCount}/${trackCount} tracks\n` + `Failed: ${zipResult.failed.length}\n` + `Size: ${sizeMB} MB\n` + `Time: ${elapsedTime}s\n\n` + `Failed tracks:\n${failedDetails}${moreText}` ); console.table(zipResult.failed); } } catch (err) { console.error("ZIP creation failed:", err); alert(`ZIP download failed: ${err.message}`); } finally { dlZipBtn.textContent = originalText; dlZipBtn.disabled = false; } }; coverBtn.onclick = async () => { coverBtn.disabled = true; coverBtn.textContent = "Getting..."; try { await downloadCoverArt(result); coverBtn.textContent = "✓ Done"; setTimeout(() => { coverBtn.textContent = "Cover"; coverBtn.disabled = false; }, 2000); } catch (err) { alert(err.message); coverBtn.textContent = "Cover"; coverBtn.disabled = false; } }; toolbar.append(dlZipBtn, coverBtn); console.log(`Playlist ZIP downloader attached (${trackCount} tracks)`); } } async function load(trigger) { console.log(`Loading (triggered by: ${trigger})`, location.href); // Skip non-content pages if (CONSTANTS.IGNORED_PATHS.test(location.pathname)) { console.log("Skipping non-content page"); return; } try { const clientId = await clientIdPromise; // Cancel any pending operations if (controller) { controller.abort(); } controller = new AbortController(); const resolveUrl = `https://api-v2.soundcloud.com/resolve?url=${encodeURIComponent( location.href )}&client_id=${clientId}`; const result = await fetchWithRetry(resolveUrl, { signal: controller.signal, }).then((r) => r.json()); console.log("Resolved result:", result); // Wait for toolbar to be available let toolbar = document.querySelector( ".sc-button-toolbar .sc-button-group" ); if (!toolbar) { // Try waiting for toolbar to appear await sleep(1000); toolbar = document.querySelector(".sc-button-toolbar .sc-button-group"); } if (!toolbar) { console.warn("Toolbar not found, retrying in 2s..."); setTimeout(() => load("retry"), 2000); return; } await createDownloadUI(result, clientId, toolbar); } catch (err) { if (err.name === "AbortError") { console.log("Operation aborted"); } else { console.error("Load error:", err); } } } // Initialize on page load if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", () => load("init")); } else { load("init"); } // Listen for navigation changes hook(history, "pushState", () => load("pushState"), "after"); window.addEventListener("popstate", () => load("popstate")); // Cleanup on page unload window.addEventListener("beforeunload", () => { if (controller) controller.abort(); }); console.log("SoundCloud Downloader v0.6.0 (ZIP) initialized"); })();