@zibot/scdl
Version:
Soucloud download
341 lines (295 loc) • 10.9 kB
JavaScript
;
const axios = require("axios");
const m3u8stream = require("m3u8stream");
class SoundCloud {
/**
* @param {Object} options
* @param {boolean} [options.autoInit=true]
* @param {string} [options.apiBaseUrl="https://api-v2.soundcloud.com"]
* @param {number} [options.timeout=12_000]
* @param {(id:string)=>void} [options.onClientId]
* @param {string} [options.clientId]
*/
constructor(options = {}) {
const defaultOptions = {
autoInit: true,
apiBaseUrl: "https://api-v2.soundcloud.com",
timeout: 12_000,
onClientId: null,
clientId: null,
};
this.opts = { ...defaultOptions, ...options };
this.apiBaseUrl = this.opts.apiBaseUrl;
this.clientId = this.opts.clientId || null;
this.appVersion = null; // Will be fetched during init
this.http = axios.create({
timeout: this.opts.timeout,
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36",
Accept: "application/json, text/javascript, */*; q=0.01",
Referer: "https://soundcloud.com/",
},
});
this._initPromise = null;
if (this.opts.autoInit && !this.clientId) {
this._initPromise = this.init();
}
}
async ensureReady() {
if (this.clientId && this.appVersion) return;
if (!this._initPromise) this._initPromise = this.init();
await this._initPromise;
}
async _getJson(url, { retries = 3, retryOn = [429, 500, 502, 503, 504] } = {}) {
const separator = url.includes("?") ? "&" : "?";
const finalUrl = this.appVersion ? `${url}${separator}app_version=${this.appVersion}` : url;
let lastErr;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const { data } = await this.http.get(finalUrl);
return data;
} catch (err) {
lastErr = err;
const status = err?.response?.status;
const shouldRetry = retryOn.includes(status) || err.code === "ECONNABORTED";
if (!shouldRetry || attempt === retries) break;
const delay = 300 * 2 ** attempt + Math.floor(Math.random() * 150);
await new Promise((r) => setTimeout(r, delay));
}
}
throw lastErr;
}
async init() {
if (this.clientId && this.appVersion) return this.clientId;
const clientRegexes = [
/client_id=([a-zA-Z0-9]{32})/g,
/client_id:"([a-zA-Z0-9]{32})"/,
/"client_id"\s*:\s*"([a-zA-Z0-9]{32})"/g,
];
const versionRegex = /"app_version"\s*:\s*"([^"]+)"/;
const homeHtml = await this.http
.get("https://soundcloud.com")
.then((r) => r.data)
.catch(() => null);
// Attempt to extract app_version from home HTML first
if (homeHtml && versionRegex.test(homeHtml)) {
this.appVersion = homeHtml.match(versionRegex)[1];
}
const scriptUrls =
(typeof homeHtml === "string" ?
(homeHtml.match(/<script[^>]+src="([^"]+)"/g) || []).map((t) => t.match(/src="([^"]+)"/)?.[1]).filter(Boolean)
: []) || [];
const candidates = [
...scriptUrls.filter((u) => /sndcdn\.com|soundcloud\.com/.test(u)),
"https://a-v2.sndcdn.com/assets/1-ff6b3.js",
];
for (const url of candidates) {
try {
const res = await this.http.get(url, { responseType: "text" });
const text = res.data || "";
if (!this.appVersion && versionRegex.test(text)) {
this.appVersion = text.match(versionRegex)[1];
}
for (const re of clientRegexes) {
const m = re.exec(text);
if (m && m[1]) {
this.clientId = m[1];
if (typeof this.opts.onClientId === "function") this.opts.onClientId(this.clientId);
}
}
if (this.clientId && this.appVersion) break;
} catch {}
}
// Fallback app_version if not found
if (!this.appVersion) this.appVersion = Math.floor(Date.now() / 1000).toString();
if (!this.clientId) throw new Error("Không thể lấy client_id từ SoundCloud");
return this.clientId;
}
async search({ query, limit = 30, offset = 0, type = "all" }) {
await this.ensureReady();
const path = type === "all" ? "" : `/${type}`;
const url =
`${this.apiBaseUrl}/search${path}` +
`?q=${encodeURIComponent(query)}` +
`&limit=${limit}&offset=${offset}` +
`&access=playable&client_id=${this.clientId}`;
try {
const data = await this._getJson(url);
const collection = Array.isArray(data?.collection) ? data.collection : [];
return collection.filter((t) => t?.permalink_url && t?.title && t?.duration);
} catch (e) {
throw new Error("Search failed");
}
}
async getTrackDetails(trackUrl) {
await this.ensureReady();
const item = await this.fetchItem(trackUrl);
if (item?.kind !== "track") throw new Error("Invalid track URL");
return item;
}
async getPlaylistDetails(playlistUrl) {
await this.ensureReady();
const playlist = await this.fetchItem(playlistUrl);
if (playlist?.kind !== "playlist") throw new Error("Invalid playlist URL");
const tracks = Array.isArray(playlist.tracks) ? playlist.tracks : [];
const loaded = tracks.filter((t) => t?.title);
const unloadedIds = tracks.filter((t) => !t?.title && t?.id).map((t) => t.id);
if (unloadedIds.length) {
const more = await this.fetchTracksByIds(unloadedIds);
playlist.tracks = loaded.concat(more);
} else {
playlist.tracks = loaded;
}
return playlist;
}
async downloadTrack(trackOrPlaylistUrl, options = {}) {
await this.ensureReady();
try {
let item = await this.fetchItem(trackOrPlaylistUrl);
let track;
if (item.kind === "playlist") {
console.log(`[SoundCloud] Đã nhận diện link Set/Playlist: ${item.title}`);
if (!item.tracks || item.tracks.length === 0) {
throw new Error("Playlist này không có bài hát nào.");
}
track = item.tracks[0];
if (!track.media) {
track = await this.getTrackDetails(track.permalink_url || track.id);
}
} else if (item.kind === "track") {
track = item;
} else {
throw new Error("URL không phải là bài hát hoặc playlist hợp lệ.");
}
if (track?.policy === "BLOCK" || track?.state === "blocked") {
throw new Error(`Bài hát "${track.title}" bị chặn.`);
}
const transcodings = this._getSortedTranscodings(track);
if (!transcodings.length) throw new Error("Không tìm thấy stream phù hợp cho bài này.");
for (const transcoding of transcodings) {
try {
const streamUrl = await this.getStreamUrl(transcoding.url);
if (transcoding.format?.protocol === "hls") {
return m3u8stream(streamUrl, {
requestOptions: {
headers: {
"User-Agent": this.http.defaults.headers["User-Agent"],
Referer: "https://soundcloud.com/",
},
},
...options,
});
} else {
const res = await this.http.get(streamUrl, { responseType: "stream" });
return res.data;
}
} catch (err) {
continue; // Thử định dạng tiếp theo nếu định dạng này lỗi
}
}
throw new Error("Không thể khởi tạo luồng tải cho tất cả định dạng.");
} catch (e) {
console.error("Failed to download:", e?.message || e);
return null;
}
}
async fetchItem(itemUrl) {
await this.ensureReady();
const url = `${this.apiBaseUrl}/resolve?url=${encodeURIComponent(itemUrl)}&client_id=${this.clientId}`;
try {
return await this._getJson(url);
} catch (e) {
throw new Error("Failed to fetch item details");
}
}
async fetchTracksByIds(trackIds) {
await this.ensureReady();
const ids = Array.from(new Set(trackIds.filter(Boolean)));
if (!ids.length) return [];
const chunkSize = 50;
const chunks = [];
for (let i = 0; i < ids.length; i += chunkSize) chunks.push(ids.slice(i, i + chunkSize));
try {
const results = await Promise.all(
chunks.map(async (chunk) => {
const url = `${this.apiBaseUrl}/tracks?ids=${chunk.join(",")}&client_id=${this.clientId}`;
return await this._getJson(url);
}),
);
return results.flat();
} catch (error) {
throw new Error("Failed to fetch tracks by IDs");
}
}
async getStreamUrl(transcodingUrl) {
await this.ensureReady();
const url = `${transcodingUrl}${transcodingUrl.includes("?") ? "&" : "?"}client_id=${this.clientId}`;
try {
const data = await this._getJson(url);
if (!data?.url) throw new Error("No stream URL in response");
return data.url;
} catch (error) {
if (error?.response?.status === 401 || error?.response?.status === 403) {
this.clientId = null;
this._initPromise = this.init();
await this._initPromise;
const retryUrl = `${transcodingUrl}${transcodingUrl.includes("?") ? "&" : "?"}client_id=${this.clientId}`;
const data = await this._getJson(retryUrl);
if (!data?.url) throw new Error("No stream URL in response (after refresh)");
return data.url;
}
throw error;
}
}
_getSortedTranscodings(track) {
const list = Array.isArray(track?.media?.transcodings) ? track.media.transcodings : [];
const score = (t) => {
let s = 0;
const proto = t?.format?.protocol;
const mime = t?.format?.mime_type || "";
const isLegacy = t?.is_legacy_transcoding;
// Priority 1: Modern transcodings (aac_160k, etc.)
if (isLegacy === false) s += 1000;
// Priority 2: Protocol (HLS generally preferred for performance)
if (proto === "hls") s += 100;
else if (proto === "progressive") s += 50;
// Priority 3: Codec quality
if (mime.includes("opus")) s += 30;
if (mime.includes("mp4") || mime.includes("aac")) s += 25;
if (mime.includes("mpeg")) s += 10;
return s;
};
return [...list].sort((a, b) => score(b) - score(a));
}
_pickBestTranscoding(track) {
return this._getSortedTranscodings(track)[0] || null;
}
async _resolveTrackId(input) {
await this.ensureReady();
if (!input) throw new Error("Missing track identifier");
if (typeof input === "number" || /^[0-9]+$/.test(String(input))) {
return Number(input);
}
const item = await this.fetchItem(input);
if (item?.kind !== "track" || !item?.id) {
throw new Error("Cannot resolve track ID from input");
}
return item.id;
}
async getRelatedTracks(track, { limit = 20, offset = 0 } = {}) {
await this.ensureReady();
const id = await this._resolveTrackId(track);
const url = `${this.apiBaseUrl}/tracks/${id}/related` + `?limit=${limit}&offset=${offset}&client_id=${this.clientId}`;
try {
const data = await this._getJson(url);
const collection =
Array.isArray(data?.collection) ? data.collection
: Array.isArray(data) ? data
: [];
return collection.filter((t) => t?.permalink_url && t?.title && t?.duration);
} catch (e) {
return [];
}
}
}
module.exports = SoundCloud;