@zibot/scdl 0.0.4 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/index.d.ts +10 -5
  2. package/index.js +218 -82
  3. package/package.json +1 -1
package/index.d.ts CHANGED
@@ -1,18 +1,18 @@
1
1
  import { Readable } from "stream";
2
2
 
3
3
  // Types
4
- interface SearchOptions {
4
+ export interface SearchOptions {
5
5
  query: string;
6
6
  limit?: number;
7
7
  offset?: number;
8
8
  type?: "all" | "tracks" | "playlists" | "users";
9
9
  }
10
10
 
11
- interface DownloadOptions {
11
+ export interface DownloadOptions {
12
12
  quality?: "high" | "low";
13
13
  }
14
14
 
15
- interface Track {
15
+ export interface Track {
16
16
  id: number;
17
17
  title: string;
18
18
  url: string;
@@ -25,13 +25,13 @@ interface Track {
25
25
  };
26
26
  }
27
27
 
28
- interface Playlist {
28
+ export interface Playlist {
29
29
  id: number;
30
30
  title: string;
31
31
  tracks: Track[];
32
32
  }
33
33
 
34
- interface User {
34
+ export interface User {
35
35
  id: number;
36
36
  username: string;
37
37
  followers_count: number;
@@ -68,6 +68,11 @@ declare class SoundCloud {
68
68
  * Download a track as a stream.
69
69
  */
70
70
  downloadTrack(url: string, options?: DownloadOptions): Promise<Readable>;
71
+
72
+ /**
73
+ * Get related tracks for a given track (by URL or ID).
74
+ */
75
+ getRelatedTracks(track: string | number, opts?: { limit?: number; offset?: number }): Promise<Track[]>;
71
76
  }
72
77
 
73
78
  export = SoundCloud;
package/index.js CHANGED
@@ -1,153 +1,289 @@
1
+ "use strict";
2
+
1
3
  const axios = require("axios");
2
4
  const m3u8stream = require("m3u8stream");
3
5
 
4
6
  class SoundCloud {
7
+ /**
8
+ * @param {Object} options
9
+ * @param {boolean} [options.autoInit=true] - tự động lấy clientId
10
+ * @param {string} [options.apiBaseUrl="https://api-v2.soundcloud.com"]
11
+ * @param {number} [options.timeout=12_000]
12
+ * @param {(id:string)=>void} [options.onClientId] - callback khi lấy được clientId (để cache ngoài)
13
+ * @param {string} [options.clientId] - nếu bạn đã có sẵn clientId hợp lệ
14
+ */
5
15
  constructor(options = {}) {
6
- const defaultOptions = { init: true, apiBaseUrl: "https://api-v2.soundcloud.com" };
7
- options = { ...defaultOptions, ...options };
8
- this.clientId = null;
9
- this.apiBaseUrl = options.apiBaseUrl;
10
- if (options.init) this.init();
16
+ const defaultOptions = {
17
+ autoInit: true,
18
+ apiBaseUrl: "https://api-v2.soundcloud.com",
19
+ timeout: 12_000,
20
+ onClientId: null,
21
+ clientId: null,
22
+ };
23
+ this.opts = { ...defaultOptions, ...options };
24
+ this.apiBaseUrl = this.opts.apiBaseUrl;
25
+ this.clientId = this.opts.clientId || null;
26
+
27
+ this.http = axios.create({
28
+ timeout: this.opts.timeout,
29
+ headers: {
30
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36",
31
+ Accept: "application/json, text/javascript, */*; q=0.01",
32
+ Referer: "https://soundcloud.com/",
33
+ },
34
+ });
35
+
36
+ this._initPromise = null;
37
+ if (this.opts.autoInit && !this.clientId) {
38
+ this._initPromise = this.init();
39
+ }
11
40
  }
12
41
 
13
- // Auto-fetch Client ID
14
- async init() {
15
- const clientIdRegex = /client_id=(:?[\w\d]{32})/;
16
- const soundCloudDom = (await axios.get("https://soundcloud.com")).data;
17
- const scriptUrls = (soundCloudDom.match(/<script crossorigin src="(.*?)"><\/script>/g) || [])
18
- .map((tag) => tag.match(/src="(.*?)"/)?.[1])
19
- .filter(Boolean);
20
-
21
- for (const url of scriptUrls) {
22
- const response = await axios.get(url);
23
- const match = response.data.match(clientIdRegex);
24
- if (match) {
25
- this.clientId = match[1];
26
- return;
42
+ async ensureReady() {
43
+ if (this.clientId) return;
44
+ if (!this._initPromise) this._initPromise = this.init();
45
+ await this._initPromise;
46
+ }
47
+
48
+ async _getJson(url, { retries = 3, retryOn = [429, 500, 502, 503, 504] } = {}) {
49
+ let lastErr;
50
+ for (let attempt = 0; attempt <= retries; attempt++) {
51
+ try {
52
+ const { data } = await this.http.get(url);
53
+ return data;
54
+ } catch (err) {
55
+ lastErr = err;
56
+ const status = err?.response?.status;
57
+ const shouldRetry = retryOn.includes(status) || err.code === "ECONNABORTED";
58
+ if (!shouldRetry || attempt === retries) break;
59
+ const delay = 300 * 2 ** attempt + Math.floor(Math.random() * 150);
60
+ await new Promise((r) => setTimeout(r, delay));
27
61
  }
28
62
  }
63
+ throw lastErr;
64
+ }
65
+
66
+ async init() {
67
+ if (this.clientId) return this.clientId;
68
+
69
+ const regexes = [
70
+ /client_id=([a-zA-Z0-9]{32})/g, // client_id=XXXXXXXX...
71
+ /"client_id"\s*:\s*"([a-zA-Z0-9]{32})"/g, // "client_id":"XXXXXXXX..."
72
+ ];
73
+
74
+ const homeHtml = await this._getJson("https://soundcloud.com").catch(() => null);
75
+ const scriptUrls =
76
+ (typeof homeHtml === "string"
77
+ ? (homeHtml.match(/<script[^>]+src="([^"]+)"/g) || []).map((t) => t.match(/src="([^"]+)"/)?.[1]).filter(Boolean)
78
+ : []) || [];
79
+
80
+ const candidates = [
81
+ ...scriptUrls.filter((u) => /sndcdn\.com|soundcloud\.com/.test(u)),
82
+ "https://a-v2.sndcdn.com/assets/1-ff6b3.js",
83
+ "https://a-v2.sndcdn.com/assets/2-ff6b3.js",
84
+ ];
85
+
86
+ for (const url of candidates) {
87
+ try {
88
+ const res = await this.http.get(url, { responseType: "text" });
89
+ const text = res.data || "";
90
+ for (const re of regexes) {
91
+ const m = re.exec(text);
92
+ if (m && m[1]) {
93
+ this.clientId = m[1];
94
+ if (typeof this.opts.onClientId === "function") this.opts.onClientId(this.clientId);
95
+ return this.clientId;
96
+ }
97
+ }
98
+ } catch {}
99
+ }
29
100
 
30
- throw new Error("Failed to fetch client ID");
101
+ throw new Error("Không thể lấy client_id từ SoundCloud");
31
102
  }
32
103
 
33
- // Search SoundCloud
34
104
  async searchTracks({ query, limit = 30, offset = 0, type = "all" }) {
105
+ await this.ensureReady();
35
106
  const path = type === "all" ? "" : `/${type}`;
36
- const url = `${this.apiBaseUrl}/search${path}?q=${encodeURIComponent(
37
- query,
38
- )}&limit=${limit}&offset=${offset}&access=playable&client_id=${this.clientId}`;
107
+ const url =
108
+ `${this.apiBaseUrl}/search${path}` +
109
+ `?q=${encodeURIComponent(query)}` +
110
+ `&limit=${limit}&offset=${offset}` +
111
+ `&access=playable&client_id=${this.clientId}`;
39
112
  try {
40
- const { data } = await axios.get(url);
41
-
42
- if (!data || !data?.collection?.length) {
43
- return [];
44
- }
45
-
46
- return data.collection.filter((track) => {
47
- if (!track.permalink_url || !track.title || !track.duration) return false;
48
- return true;
49
- });
50
- } catch (error) {
51
- console.error("Search error:", error.message || error);
113
+ const data = await this._getJson(url);
114
+ const collection = Array.isArray(data?.collection) ? data.collection : [];
115
+ return collection.filter((t) => t?.permalink_url && t?.title && t?.duration);
116
+ } catch (e) {
52
117
  throw new Error("Search failed");
53
118
  }
54
119
  }
55
120
 
56
- // Get track details
57
121
  async getTrackDetails(trackUrl) {
58
- try {
59
- return await this.fetchItem(trackUrl);
60
- } catch (error) {
61
- throw new Error("Invalid track URL");
62
- }
122
+ await this.ensureReady();
123
+ const item = await this.fetchItem(trackUrl);
124
+ if (item?.kind !== "track") throw new Error("Invalid track URL");
125
+ return item;
63
126
  }
64
127
 
65
- // Get playlist details
66
128
  async getPlaylistDetails(playlistUrl) {
67
- try {
68
- const playlist = await this.fetchItem(playlistUrl);
69
- const { tracks } = playlist;
129
+ await this.ensureReady();
130
+ const playlist = await this.fetchItem(playlistUrl);
131
+ if (playlist?.kind !== "playlist") throw new Error("Invalid playlist URL");
70
132
 
71
- const loadedTracks = tracks.filter((track) => track.title);
72
- const unloadedTrackIds = tracks.filter((track) => !track.title).map((track) => track.id);
133
+ const tracks = Array.isArray(playlist.tracks) ? playlist.tracks : [];
134
+ const loaded = tracks.filter((t) => t?.title);
135
+ const unloadedIds = tracks.filter((t) => !t?.title && t?.id).map((t) => t.id);
73
136
 
74
- if (unloadedTrackIds.length > 0) {
75
- const moreTracks = await this.fetchTracksByIds(unloadedTrackIds);
76
- playlist.tracks = loadedTracks.concat(moreTracks);
77
- }
78
-
79
- return playlist;
80
- } catch (error) {
81
- throw new Error("Invalid playlist URL");
137
+ if (unloadedIds.length) {
138
+ const more = await this.fetchTracksByIds(unloadedIds);
139
+ playlist.tracks = loaded.concat(more);
140
+ } else {
141
+ playlist.tracks = loaded;
82
142
  }
143
+ return playlist;
83
144
  }
84
145
 
85
- // Download track stream
86
146
  async downloadTrack(trackUrl, options = {}) {
147
+ await this.ensureReady();
87
148
  try {
88
149
  const track = await this.getTrackDetails(trackUrl);
89
- const transcoding = track?.media?.transcodings?.find((t) => t.format.protocol === "hls");
90
150
 
91
- if (!transcoding) throw new Error("No valid HLS stream found");
151
+ if (track?.policy === "BLOCK" || track?.state === "blocked") {
152
+ throw new Error("Track bị chặn (policy/geo).");
153
+ }
154
+ if (track?.has_downloads === false && track?.streamable === false) {
155
+ throw new Error("Track không cho phép stream.");
156
+ }
92
157
 
93
- const m3u8Url = await this.getStreamUrl(transcoding.url);
94
- return m3u8stream(m3u8Url, options);
158
+ const transcoding = this._pickBestTranscoding(track);
159
+ if (!transcoding) throw new Error("Không tìm thấy stream phù hợp.");
160
+
161
+ const streamUrl = await this.getStreamUrl(transcoding.url);
162
+ if (transcoding.format?.protocol === "hls") {
163
+ return m3u8stream(streamUrl, {
164
+ requestOptions: {
165
+ headers: { "User-Agent": this.http.defaults.headers["User-Agent"] },
166
+ },
167
+ ...options,
168
+ });
169
+ } else {
170
+ const res = await this.http.get(streamUrl, { responseType: "stream" });
171
+ return res.data;
172
+ }
95
173
  } catch (e) {
96
- console.error("Failed to download track");
174
+ console.error("Failed to download track:", e?.message || e);
97
175
  return null;
98
176
  }
99
177
  }
100
178
 
101
- // Fetch single item (track/playlist/user)
102
179
  async fetchItem(itemUrl) {
103
- const url = `${this.apiBaseUrl}/resolve?url=${itemUrl}&client_id=${this.clientId}`;
180
+ await this.ensureReady();
181
+ const url = `${this.apiBaseUrl}/resolve?url=${encodeURIComponent(itemUrl)}&client_id=${this.clientId}`;
104
182
  try {
105
- const { data } = await axios.get(url);
106
- return data;
107
- } catch (error) {
183
+ return await this._getJson(url);
184
+ } catch (e) {
108
185
  throw new Error("Failed to fetch item details");
109
186
  }
110
187
  }
111
188
 
112
- // Fetch multiple tracks by their IDs
113
189
  async fetchTracksByIds(trackIds) {
114
- const chunkSize = 50; // Adjust chunk size as needed based on API limits
190
+ await this.ensureReady();
191
+ const ids = Array.from(new Set(trackIds.filter(Boolean)));
192
+ if (!ids.length) return [];
193
+ const chunkSize = 50;
115
194
  const chunks = [];
116
- for (let i = 0; i < trackIds.length; i += chunkSize) {
117
- chunks.push(trackIds.slice(i, i + chunkSize));
118
- }
195
+ for (let i = 0; i < ids.length; i += chunkSize) chunks.push(ids.slice(i, i + chunkSize));
119
196
 
120
197
  try {
121
198
  const results = await Promise.all(
122
199
  chunks.map(async (chunk) => {
123
- const ids = chunk.join(",");
124
- const url = `${this.apiBaseUrl}/tracks?ids=${ids}&client_id=${this.clientId}`;
125
- const { data } = await axios.get(url);
126
- return data;
200
+ const url = `${this.apiBaseUrl}/tracks?ids=${chunk.join(",")}&client_id=${this.clientId}`;
201
+ return await this._getJson(url);
127
202
  }),
128
203
  );
129
-
130
- // Combine results from all chunks
131
204
  return results.flat();
132
205
  } catch (error) {
133
206
  console.error("Failed to fetch tracks by IDs:", {
134
207
  clientId: this.clientId,
135
- error: error.response?.data || error.message,
208
+ status: error?.response?.status,
209
+ error: error?.response?.data || error?.message,
136
210
  });
137
211
  throw new Error("Failed to fetch tracks by IDs");
138
212
  }
139
213
  }
140
214
 
141
- // Get HLS stream URL
142
215
  async getStreamUrl(transcodingUrl) {
143
- const url = `${transcodingUrl}?client_id=${this.clientId}`;
216
+ await this.ensureReady();
217
+ const url = `${transcodingUrl}${transcodingUrl.includes("?") ? "&" : "?"}client_id=${this.clientId}`;
144
218
  try {
145
- const { data } = await axios.get(url);
219
+ const data = await this._getJson(url);
220
+ if (!data?.url) throw new Error("No stream URL in response");
146
221
  return data.url;
147
222
  } catch (error) {
223
+ if (error?.response?.status === 401 || error?.response?.status === 403) {
224
+ this.clientId = null;
225
+ this._initPromise = this.init();
226
+ await this._initPromise;
227
+ const retryUrl = `${transcodingUrl}${transcodingUrl.includes("?") ? "&" : "?"}client_id=${this.clientId}`;
228
+ const data = await this._getJson(retryUrl);
229
+ if (!data?.url) throw new Error("No stream URL in response (after refresh)");
230
+ return data.url;
231
+ }
148
232
  throw new Error("Failed to fetch stream URL");
149
233
  }
150
234
  }
235
+
236
+ /** pick transcoding: HLS (opus > mp3), fallback progressive mp3 */
237
+ _pickBestTranscoding(track) {
238
+ const list = Array.isArray(track?.media?.transcodings) ? track.media.transcodings : [];
239
+ if (!list.length) return null;
240
+
241
+ const score = (t) => {
242
+ const proto = t?.format?.protocol;
243
+ const mime = t?.format?.mime_type || "";
244
+ if (proto === "hls" && mime.includes("opus")) return 100;
245
+ if (proto === "hls" && mime.includes("mpeg")) return 90;
246
+ if (proto === "progressive" && mime.includes("mpeg")) return 70;
247
+ return 10;
248
+ };
249
+
250
+ return [...list].sort((a, b) => score(b) - score(a))[0];
251
+ }
252
+ async _resolveTrackId(input) {
253
+ await this.ensureReady();
254
+ if (!input) throw new Error("Missing track identifier");
255
+ if (typeof input === "number" || /^[0-9]+$/.test(String(input))) {
256
+ return Number(input);
257
+ }
258
+ const item = await this.fetchItem(input);
259
+ if (item?.kind !== "track" || !item?.id) {
260
+ throw new Error("Cannot resolve track ID from input");
261
+ }
262
+ return item.id;
263
+ }
264
+
265
+ /**
266
+ * Lấy danh sách related tracks cho một track (URL hoặc ID)
267
+ * @param {string|number} track - track URL hoặc track ID
268
+ * @param {object} opts
269
+ * @param {number} [opts.limit=20]
270
+ * @param {number} [opts.offset=0]
271
+ * @returns {Promise<Array>} danh sách track tương tự
272
+ */
273
+ async getRelatedTracks(track, { limit = 20, offset = 0 } = {}) {
274
+ await this.ensureReady();
275
+ const id = await this._resolveTrackId(track);
276
+
277
+ const url = `${this.apiBaseUrl}/tracks/${id}/related` + `?limit=${limit}&offset=${offset}&client_id=${this.clientId}`;
278
+
279
+ try {
280
+ const data = await this._getJson(url);
281
+ const collection = Array.isArray(data?.collection) ? data.collection : Array.isArray(data) ? data : [];
282
+ return collection.filter((t) => t?.permalink_url && t?.title && t?.duration);
283
+ } catch (e) {
284
+ return [];
285
+ }
286
+ }
151
287
  }
152
288
 
153
289
  module.exports = SoundCloud;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zibot/scdl",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "Soucloud download",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",