@zibot/scdl 0.0.4 → 0.0.6

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 (5) hide show
  1. package/.prettierrc +21 -0
  2. package/README.md +272 -55
  3. package/index.d.ts +10 -5
  4. package/index.js +219 -82
  5. package/package.json +32 -35
package/.prettierrc ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "arrowParens": "always",
3
+ "bracketSameLine": true,
4
+ "bracketSpacing": true,
5
+ "semi": true,
6
+ "experimentalTernaries": true,
7
+ "singleQuote": false,
8
+ "jsxSingleQuote": true,
9
+ "quoteProps": "as-needed",
10
+ "trailingComma": "all",
11
+ "singleAttributePerLine": true,
12
+ "htmlWhitespaceSensitivity": "css",
13
+ "vueIndentScriptAndStyle": false,
14
+ "proseWrap": "always",
15
+ "insertPragma": false,
16
+ "printWidth": 130,
17
+ "requirePragma": false,
18
+ "tabWidth": 2,
19
+ "useTabs": true,
20
+ "embeddedLanguageFormatting": "auto"
21
+ }
package/README.md CHANGED
@@ -1,118 +1,335 @@
1
1
  # @zibot/scdl
2
2
 
3
- @zibot/scdl một module JavaScript hỗ trợ tải, tìm kiếm, quản dữ liệu từ SoundCloud thông qua API. Module này cung cấp các
4
- phương thức tiện lợi để xử lý track, playlist, và tải nội dung.
3
+ A tiny, promise-based Node.js client for searching SoundCloud, fetching track/playlist details, and downloading tracks as readable streams.
5
4
 
6
- ## Cài đặt
5
+ * **Search** tracks, playlists, and users
6
+ * **Inspect** rich metadata for tracks and playlists
7
+ * **Download** a track stream (high/low quality)
8
+ * **Discover** related tracks by URL or ID
7
9
 
8
- Bạn thể cài đặt module này bằng npm hoặc yarn:
10
+ > ⚠️ Respect SoundCloud’s Terms of Service and creator rights. This library is for personal/educational use; don’t redistribute copyrighted content without permission.
9
11
 
10
- ```bash
11
- npm install @zibot/scdl
12
- ```
12
+ ---
13
13
 
14
- hoặc
14
+ ## Installation
15
15
 
16
16
  ```bash
17
+ npm i @zibot/scdl
18
+ # or
17
19
  yarn add @zibot/scdl
20
+ # or
21
+ pnpm add @zibot/scdl
18
22
  ```
19
23
 
20
- ## Sử dụng
24
+ **Runtime:** Node.js 18+ is recommended (built-in `fetch`/WHATWG streams). Works with ESM or CommonJS.
25
+
26
+ ---
27
+
28
+ ## Quick Start
29
+
30
+ ### ESM
31
+
32
+ ```ts
33
+ import SoundCloud from "@zibot/scdl";
34
+
35
+ const sc = new SoundCloud({ init: true }); // auto-initialize clientId
36
+
37
+ (async () => {
38
+ // Search tracks
39
+ const results = await sc.searchTracks({ query: "lofi hip hop", limit: 5, type: "tracks" });
40
+ console.log(results);
41
+
42
+ // Track details
43
+ const track = await sc.getTrackDetails("https://soundcloud.com/user/track-slug");
44
+ console.log(track.title, track.user.username);
45
+
46
+ // Download a track (Readable stream)
47
+ const stream = await sc.downloadTrack("https://soundcloud.com/user/track-slug", { quality: "high" });
48
+ stream.pipe(process.stdout); // or pipe to fs.createWriteStream("track.mp3")
49
+
50
+ // Related tracks
51
+ const related = await sc.getRelatedTracks(track.id, { limit: 10 });
52
+ console.log(related.map(t => t.title));
53
+ })();
54
+ ```
21
55
 
22
- ### Khởi tạo
56
+ ### CommonJS
23
57
 
24
- Đầu tiên, bạn cần khởi tạo lớp `SoundCloud` để sử dụng các chức năng của module:
58
+ ```js
59
+ const SoundCloud = require("@zibot/scdl");
60
+ const fs = require("node:fs");
25
61
 
26
- ```javascript
27
- const { SoundCloud } = require("@zibot/scdl");
62
+ const sc = new SoundCloud({ init: true });
28
63
 
29
64
  (async () => {
30
- const scdl = new SoundCloud({ init: true }); // Tự động lấy client ID
31
- await scdl.init(); // Đảm bảo client ID được khởi tạo
65
+ const stream = await sc.downloadTrack("https://soundcloud.com/user/track-slug");
66
+ stream.pipe(fs.createWriteStream("track.mp3"));
32
67
  })();
33
68
  ```
34
69
 
35
70
  ---
36
71
 
37
- ### Các phương thức
72
+ ## Initialization
38
73
 
39
- #### **`searchTracks(options)`**
74
+ The client can retrieve a valid `clientId` automatically.
40
75
 
41
- Tìm kiếm các track trên SoundCloud.
76
+ ```ts
77
+ // Option A: auto-init (recommended)
78
+ const sc = new SoundCloud({ init: true });
42
79
 
43
- ```javascript
44
- const tracks = await scdl.searchTracks({ query: "chill", limit: 5 });
45
- console.log(tracks);
80
+ // Option B: manual init
81
+ const sc = new SoundCloud();
82
+ await sc.init(); // retrieves clientId
46
83
  ```
47
84
 
48
- **Tham số:**
85
+ > You usually only need to call `init()` once per process. If you get authentication errors later, re-calling `init()` may refresh the client ID.
86
+
87
+ ---
88
+
89
+ ## API
90
+
91
+ ### `new SoundCloud(options?)`
49
92
 
50
- - `query` (bắt buộc): Từ khóa để tìm kiếm.
51
- - `limit` (mặc định: `20`): Số lượng track tối đa.
52
- - `offset` (mặc định: `0`): Vị trí bắt đầu.
93
+ Create a client.
94
+
95
+ * `options.init?: boolean` if `true`, calls `init()` internally.
96
+
97
+ **Properties**
98
+
99
+ * `clientId: string | null` – resolved after `init()`
100
+ * `apiBaseUrl: string` – internal base URL used for API calls
101
+
102
+ ---
103
+
104
+ ### `init(): Promise<void>`
105
+
106
+ Initialize the client (retrieve `clientId`). Call this if you didn’t pass `{ init: true }`.
53
107
 
54
108
  ---
55
109
 
56
- #### **`getTrack(url)`**
110
+ ### `searchTracks(options: SearchOptions): Promise<(Track | Playlist | User)[]>`
111
+
112
+ Search SoundCloud.
113
+
114
+ **Parameters – `SearchOptions`**
115
+
116
+ * `query: string` – search text
117
+ * `limit?: number` – default depends on endpoint (commonly 10–20)
118
+ * `offset?: number` – for pagination
119
+ * `type?: "all" | "tracks" | "playlists" | "users"` – filter result kinds (default `"all"`)
57
120
 
58
- Lấy thông tin chi tiết về một track.
121
+ **Usage**
59
122
 
60
- ```javascript
61
- const track = await scdl.getTrack("https://soundcloud.com/user/song");
62
- console.log(track);
123
+ ```ts
124
+ // Top tracks
125
+ const tracks = await sc.searchTracks({ query: "ambient study", type: "tracks", limit: 10 });
126
+
127
+ // Mixed kinds (tracks/playlists/users)
128
+ const mixed = await sc.searchTracks({ query: "chill", type: "all", limit: 5, offset: 5 });
63
129
  ```
64
130
 
65
- **Tham số:**
131
+ ---
132
+
133
+ ### `getTrackDetails(url: string): Promise<Track>`
134
+
135
+ Get rich metadata for a single track by its public URL.
136
+
137
+ ```ts
138
+ const t = await sc.getTrackDetails("https://soundcloud.com/artist/track");
139
+ console.log({
140
+ id: t.id,
141
+ title: t.title,
142
+ by: t.user.username,
143
+ streamables: t.media.transcodings.length,
144
+ });
145
+ ```
66
146
 
67
- - `url` (bắt buộc): URL của track.
147
+ **`Track`**
148
+
149
+ ```ts
150
+ interface Track {
151
+ id: number;
152
+ title: string;
153
+ url: string;
154
+ user: { id: number; username: string };
155
+ media: {
156
+ transcodings: {
157
+ url: string;
158
+ format: { protocol: string; mime_type: string };
159
+ }[];
160
+ };
161
+ }
162
+ ```
68
163
 
69
164
  ---
70
165
 
71
- #### **`getPlaylist(url)`**
166
+ ### `getPlaylistDetails(url: string): Promise<Playlist>`
72
167
 
73
- Lấy thông tin chi tiết về một playlist.
168
+ Fetch playlist metadata and contained tracks.
74
169
 
75
- ```javascript
76
- const playlist = await scdl.getPlaylist("https://soundcloud.com/user/playlist");
77
- console.log(playlist);
170
+ ```ts
171
+ const pl = await sc.getPlaylistDetails("https://soundcloud.com/artist/sets/playlist-slug");
172
+ console.log(pl.title, pl.tracks.length);
78
173
  ```
79
174
 
80
- **Tham số:**
175
+ **`Playlist`**
81
176
 
82
- - `url` (bắt buộc): URL của playlist.
177
+ ```ts
178
+ interface Playlist {
179
+ id: number;
180
+ title: string;
181
+ tracks: Track[];
182
+ }
183
+ ```
83
184
 
84
185
  ---
85
186
 
86
- #### **`downloadTrack(url, options)`**
187
+ ### `downloadTrack(url: string, options?: DownloadOptions): Promise<Readable>`
188
+
189
+ Download a track as a Node `Readable` stream.
190
+
191
+ **Parameters – `DownloadOptions`**
87
192
 
88
- Tải một track từ SoundCloud.
193
+ * `quality?: "high" | "low"` – choose available transcoding (default implementation prefers higher quality when available)
89
194
 
90
- ```javascript
91
- const stream = await scdl.downloadTrack("https://soundcloud.com/user/song");
92
- stream.pipe(fs.createWriteStream("track.mp3"));
195
+ **Examples**
196
+
197
+ ```ts
198
+ import fs from "node:fs";
199
+
200
+ const read = await sc.downloadTrack("https://soundcloud.com/user/track", { quality: "high" });
201
+ await new Promise((resolve, reject) => {
202
+ read.pipe(fs.createWriteStream("track.mp3"))
203
+ .on("finish", resolve)
204
+ .on("error", reject);
205
+ });
206
+ ```
207
+
208
+ Pipe to any writable destination (stdout, HTTP response, cloud storage SDKs, etc.).
209
+
210
+ ---
211
+
212
+ ### `getRelatedTracks(track: string | number, opts?: { limit?: number; offset?: number }): Promise<Track[]>`
213
+
214
+ Fetch tracks related to a given track by **URL** or **numeric ID**.
215
+
216
+ ```ts
217
+ // by URL
218
+ const relByUrl = await sc.getRelatedTracks("https://soundcloud.com/artist/track", { limit: 6 });
219
+
220
+ // by ID
221
+ const base = await sc.getTrackDetails("https://soundcloud.com/artist/track");
222
+ const relById = await sc.getRelatedTracks(base.id, { limit: 6 });
93
223
  ```
94
224
 
95
- **Tham số:**
225
+ ---
226
+
227
+ ## Types
228
+
229
+ ```ts
230
+ export interface SearchOptions {
231
+ query: string;
232
+ limit?: number;
233
+ offset?: number;
234
+ type?: "all" | "tracks" | "playlists" | "users";
235
+ }
236
+
237
+ export interface DownloadOptions {
238
+ quality?: "high" | "low";
239
+ }
240
+
241
+ export interface User {
242
+ id: number;
243
+ username: string;
244
+ followers_count: number;
245
+ track_count: number;
246
+ }
247
+ ```
96
248
 
97
- - `url` (bắt buộc): URL của track.
98
- - `options` (tùy chọn): Cấu hình tải xuống.
249
+ These types are exported for TypeScript consumers.
99
250
 
100
251
  ---
101
252
 
102
- ### Lưu ý
253
+ ## Examples
254
+
255
+ ### Save the highest-quality stream to disk
256
+
257
+ ```ts
258
+ import fs from "node:fs";
259
+ import SoundCloud from "@zibot/scdl";
260
+
261
+ const sc = new SoundCloud({ init: true });
262
+
263
+ async function save(url: string, file: string) {
264
+ const stream = await sc.downloadTrack(url, { quality: "high" });
265
+ await new Promise<void>((resolve, reject) => {
266
+ stream.pipe(fs.createWriteStream(file))
267
+ .on("finish", resolve)
268
+ .on("error", reject);
269
+ });
270
+ }
271
+
272
+ save("https://soundcloud.com/user/track", "output.mp3");
273
+ ```
274
+
275
+ ### Basic search → pick first result → download
276
+
277
+ ```ts
278
+ const [first] = await sc.searchTracks({ query: "deep house 2024", type: "tracks", limit: 1 });
279
+ if (first && "url" in first) {
280
+ const s = await sc.downloadTrack(first.url);
281
+ s.pipe(process.stdout);
282
+ }
283
+ ```
284
+
285
+ ### Paginate results
286
+
287
+ ```ts
288
+ const page1 = await sc.searchTracks({ query: "vaporwave", type: "tracks", limit: 20, offset: 0 });
289
+ const page2 = await sc.searchTracks({ query: "vaporwave", type: "tracks", limit: 20, offset: 20 });
290
+ ```
291
+
292
+ ---
293
+
294
+ ## Error Handling & Tips
295
+
296
+ * **Initialization:** If a method throws due to missing/expired `clientId`, call `await sc.init()` and retry.
297
+ * **Quality selection:** Not all tracks expose multiple transcodings; the library will fall back when needed.
298
+ * **Rate limits / 429:** Back off and retry with an exponential strategy.
299
+ * **Private/geo-restricted tracks:** Details/downloads may be unavailable.
300
+ * **Networking:** Wrap downloads with proper error and close handlers to avoid dangling file descriptors.
301
+
302
+ ---
303
+
304
+ ## FAQ
305
+
306
+ **Q: Can I use this in the browser?**
307
+ This package targets Node.js (it returns Node `Readable`). Browser use is not supported.
308
+
309
+ **Q: What audio format do I get?**
310
+ Whatever the selected transcoding provides (commonly progressive MP3 or HLS AAC). You may need to remux/encode if you require a specific container/codec.
311
+
312
+ **Q: Do I need my own client ID?**
313
+ The client auto-discovers a valid `clientId`. If discovery fails due to upstream changes, update the package to the latest version.
314
+
315
+ ---
316
+
317
+ ## Contributing
318
+
319
+ PRs and issues are welcome! Please include:
103
320
 
104
- - Trước khi sử dụng các phương thức như `searchTracks`, `getTrack`, hoặc `downloadTrack`, cần đảm bảo rằng phương thức `init()` đã
105
- hoàn thành để lấy `clientId`.
106
- - Nếu `clientId` không được khởi tạo, các phương thức này sẽ ném lỗi.
321
+ * A clear description of the change
322
+ * Repro steps (for bugs)
323
+ * Tests where possible
107
324
 
108
325
  ---
109
326
 
110
- ## Đóng góp
327
+ ## License
111
328
 
112
- Nếu bạn muốn đóng góp cho dự án này, vui lòng tạo một pull request hoặc mở một issue trên GitHub.
329
+ MIT © Zibot
113
330
 
114
331
  ---
115
332
 
116
- ## Giấy phép
333
+ ## Disclaimer
117
334
 
118
- Dự án này được cấp phép theo giấy phép MIT. Xem file [LICENSE](LICENSE) để biết thêm chi tiết.
335
+ This project is not affiliated with SoundCloud. Use responsibly and comply with all applicable laws and SoundCloud’s Terms of Service.
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,290 @@
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:"([a-zA-Z0-9]{32})"/, // "client_id":"XXXXXXXX..."
72
+ /"client_id"\s*:\s*"([a-zA-Z0-9]{32})"/g, // "client_id":"XXXXXXXX..."
73
+ ];
74
+
75
+ const homeHtml = await this._getJson("https://soundcloud.com").catch(() => null);
76
+ const scriptUrls =
77
+ (typeof homeHtml === "string"
78
+ ? (homeHtml.match(/<script[^>]+src="([^"]+)"/g) || []).map((t) => t.match(/src="([^"]+)"/)?.[1]).filter(Boolean)
79
+ : []) || [];
80
+
81
+ const candidates = [
82
+ ...scriptUrls.filter((u) => /sndcdn\.com|soundcloud\.com/.test(u)),
83
+ "https://a-v2.sndcdn.com/assets/1-ff6b3.js",
84
+ "https://a-v2.sndcdn.com/assets/2-ff6b3.js",
85
+ ];
86
+
87
+ for (const url of candidates) {
88
+ try {
89
+ const res = await this.http.get(url, { responseType: "text" });
90
+ const text = res.data || "";
91
+ for (const re of regexes) {
92
+ const m = re.exec(text);
93
+ if (m && m[1]) {
94
+ this.clientId = m[1];
95
+ if (typeof this.opts.onClientId === "function") this.opts.onClientId(this.clientId);
96
+ return this.clientId;
97
+ }
98
+ }
99
+ } catch {}
100
+ }
29
101
 
30
- throw new Error("Failed to fetch client ID");
102
+ throw new Error("Không thể lấy client_id từ SoundCloud");
31
103
  }
32
104
 
33
- // Search SoundCloud
34
105
  async searchTracks({ query, limit = 30, offset = 0, type = "all" }) {
106
+ await this.ensureReady();
35
107
  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}`;
108
+ const url =
109
+ `${this.apiBaseUrl}/search${path}` +
110
+ `?q=${encodeURIComponent(query)}` +
111
+ `&limit=${limit}&offset=${offset}` +
112
+ `&access=playable&client_id=${this.clientId}`;
39
113
  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);
114
+ const data = await this._getJson(url);
115
+ const collection = Array.isArray(data?.collection) ? data.collection : [];
116
+ return collection.filter((t) => t?.permalink_url && t?.title && t?.duration);
117
+ } catch (e) {
52
118
  throw new Error("Search failed");
53
119
  }
54
120
  }
55
121
 
56
- // Get track details
57
122
  async getTrackDetails(trackUrl) {
58
- try {
59
- return await this.fetchItem(trackUrl);
60
- } catch (error) {
61
- throw new Error("Invalid track URL");
62
- }
123
+ await this.ensureReady();
124
+ const item = await this.fetchItem(trackUrl);
125
+ if (item?.kind !== "track") throw new Error("Invalid track URL");
126
+ return item;
63
127
  }
64
128
 
65
- // Get playlist details
66
129
  async getPlaylistDetails(playlistUrl) {
67
- try {
68
- const playlist = await this.fetchItem(playlistUrl);
69
- const { tracks } = playlist;
130
+ await this.ensureReady();
131
+ const playlist = await this.fetchItem(playlistUrl);
132
+ if (playlist?.kind !== "playlist") throw new Error("Invalid playlist URL");
70
133
 
71
- const loadedTracks = tracks.filter((track) => track.title);
72
- const unloadedTrackIds = tracks.filter((track) => !track.title).map((track) => track.id);
134
+ const tracks = Array.isArray(playlist.tracks) ? playlist.tracks : [];
135
+ const loaded = tracks.filter((t) => t?.title);
136
+ const unloadedIds = tracks.filter((t) => !t?.title && t?.id).map((t) => t.id);
73
137
 
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");
138
+ if (unloadedIds.length) {
139
+ const more = await this.fetchTracksByIds(unloadedIds);
140
+ playlist.tracks = loaded.concat(more);
141
+ } else {
142
+ playlist.tracks = loaded;
82
143
  }
144
+ return playlist;
83
145
  }
84
146
 
85
- // Download track stream
86
147
  async downloadTrack(trackUrl, options = {}) {
148
+ await this.ensureReady();
87
149
  try {
88
150
  const track = await this.getTrackDetails(trackUrl);
89
- const transcoding = track?.media?.transcodings?.find((t) => t.format.protocol === "hls");
90
151
 
91
- if (!transcoding) throw new Error("No valid HLS stream found");
152
+ if (track?.policy === "BLOCK" || track?.state === "blocked") {
153
+ throw new Error("Track bị chặn (policy/geo).");
154
+ }
155
+ if (track?.has_downloads === false && track?.streamable === false) {
156
+ throw new Error("Track không cho phép stream.");
157
+ }
92
158
 
93
- const m3u8Url = await this.getStreamUrl(transcoding.url);
94
- return m3u8stream(m3u8Url, options);
159
+ const transcoding = this._pickBestTranscoding(track);
160
+ if (!transcoding) throw new Error("Không tìm thấy stream phù hợp.");
161
+
162
+ const streamUrl = await this.getStreamUrl(transcoding.url);
163
+ if (transcoding.format?.protocol === "hls") {
164
+ return m3u8stream(streamUrl, {
165
+ requestOptions: {
166
+ headers: { "User-Agent": this.http.defaults.headers["User-Agent"] },
167
+ },
168
+ ...options,
169
+ });
170
+ } else {
171
+ const res = await this.http.get(streamUrl, { responseType: "stream" });
172
+ return res.data;
173
+ }
95
174
  } catch (e) {
96
- console.error("Failed to download track");
175
+ console.error("Failed to download track:", e?.message || e);
97
176
  return null;
98
177
  }
99
178
  }
100
179
 
101
- // Fetch single item (track/playlist/user)
102
180
  async fetchItem(itemUrl) {
103
- const url = `${this.apiBaseUrl}/resolve?url=${itemUrl}&client_id=${this.clientId}`;
181
+ await this.ensureReady();
182
+ const url = `${this.apiBaseUrl}/resolve?url=${encodeURIComponent(itemUrl)}&client_id=${this.clientId}`;
104
183
  try {
105
- const { data } = await axios.get(url);
106
- return data;
107
- } catch (error) {
184
+ return await this._getJson(url);
185
+ } catch (e) {
108
186
  throw new Error("Failed to fetch item details");
109
187
  }
110
188
  }
111
189
 
112
- // Fetch multiple tracks by their IDs
113
190
  async fetchTracksByIds(trackIds) {
114
- const chunkSize = 50; // Adjust chunk size as needed based on API limits
191
+ await this.ensureReady();
192
+ const ids = Array.from(new Set(trackIds.filter(Boolean)));
193
+ if (!ids.length) return [];
194
+ const chunkSize = 50;
115
195
  const chunks = [];
116
- for (let i = 0; i < trackIds.length; i += chunkSize) {
117
- chunks.push(trackIds.slice(i, i + chunkSize));
118
- }
196
+ for (let i = 0; i < ids.length; i += chunkSize) chunks.push(ids.slice(i, i + chunkSize));
119
197
 
120
198
  try {
121
199
  const results = await Promise.all(
122
200
  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;
201
+ const url = `${this.apiBaseUrl}/tracks?ids=${chunk.join(",")}&client_id=${this.clientId}`;
202
+ return await this._getJson(url);
127
203
  }),
128
204
  );
129
-
130
- // Combine results from all chunks
131
205
  return results.flat();
132
206
  } catch (error) {
133
207
  console.error("Failed to fetch tracks by IDs:", {
134
208
  clientId: this.clientId,
135
- error: error.response?.data || error.message,
209
+ status: error?.response?.status,
210
+ error: error?.response?.data || error?.message,
136
211
  });
137
212
  throw new Error("Failed to fetch tracks by IDs");
138
213
  }
139
214
  }
140
215
 
141
- // Get HLS stream URL
142
216
  async getStreamUrl(transcodingUrl) {
143
- const url = `${transcodingUrl}?client_id=${this.clientId}`;
217
+ await this.ensureReady();
218
+ const url = `${transcodingUrl}${transcodingUrl.includes("?") ? "&" : "?"}client_id=${this.clientId}`;
144
219
  try {
145
- const { data } = await axios.get(url);
220
+ const data = await this._getJson(url);
221
+ if (!data?.url) throw new Error("No stream URL in response");
146
222
  return data.url;
147
223
  } catch (error) {
224
+ if (error?.response?.status === 401 || error?.response?.status === 403) {
225
+ this.clientId = null;
226
+ this._initPromise = this.init();
227
+ await this._initPromise;
228
+ const retryUrl = `${transcodingUrl}${transcodingUrl.includes("?") ? "&" : "?"}client_id=${this.clientId}`;
229
+ const data = await this._getJson(retryUrl);
230
+ if (!data?.url) throw new Error("No stream URL in response (after refresh)");
231
+ return data.url;
232
+ }
148
233
  throw new Error("Failed to fetch stream URL");
149
234
  }
150
235
  }
236
+
237
+ /** pick transcoding: HLS (opus > mp3), fallback progressive mp3 */
238
+ _pickBestTranscoding(track) {
239
+ const list = Array.isArray(track?.media?.transcodings) ? track.media.transcodings : [];
240
+ if (!list.length) return null;
241
+
242
+ const score = (t) => {
243
+ const proto = t?.format?.protocol;
244
+ const mime = t?.format?.mime_type || "";
245
+ if (proto === "hls" && mime.includes("opus")) return 100;
246
+ if (proto === "hls" && mime.includes("mpeg")) return 90;
247
+ if (proto === "progressive" && mime.includes("mpeg")) return 70;
248
+ return 10;
249
+ };
250
+
251
+ return [...list].sort((a, b) => score(b) - score(a))[0];
252
+ }
253
+ async _resolveTrackId(input) {
254
+ await this.ensureReady();
255
+ if (!input) throw new Error("Missing track identifier");
256
+ if (typeof input === "number" || /^[0-9]+$/.test(String(input))) {
257
+ return Number(input);
258
+ }
259
+ const item = await this.fetchItem(input);
260
+ if (item?.kind !== "track" || !item?.id) {
261
+ throw new Error("Cannot resolve track ID from input");
262
+ }
263
+ return item.id;
264
+ }
265
+
266
+ /**
267
+ * Lấy danh sách related tracks cho một track (URL hoặc ID)
268
+ * @param {string|number} track - track URL hoặc track ID
269
+ * @param {object} opts
270
+ * @param {number} [opts.limit=20]
271
+ * @param {number} [opts.offset=0]
272
+ * @returns {Promise<Array>} danh sách track tương tự
273
+ */
274
+ async getRelatedTracks(track, { limit = 20, offset = 0 } = {}) {
275
+ await this.ensureReady();
276
+ const id = await this._resolveTrackId(track);
277
+
278
+ const url = `${this.apiBaseUrl}/tracks/${id}/related` + `?limit=${limit}&offset=${offset}&client_id=${this.clientId}`;
279
+
280
+ try {
281
+ const data = await this._getJson(url);
282
+ const collection = Array.isArray(data?.collection) ? data.collection : Array.isArray(data) ? data : [];
283
+ return collection.filter((t) => t?.permalink_url && t?.title && t?.duration);
284
+ } catch (e) {
285
+ return [];
286
+ }
287
+ }
151
288
  }
152
289
 
153
290
  module.exports = SoundCloud;
package/package.json CHANGED
@@ -1,35 +1,32 @@
1
- {
2
- "name": "@zibot/scdl",
3
- "version": "0.0.4",
4
- "description": "Soucloud download",
5
- "main": "index.js",
6
- "types": "index.d.ts",
7
- "scripts": {
8
- "test": "echo \"Error: no test specified\" && exit 1"
9
- },
10
- "repository": {
11
- "type": "git",
12
- "url": "git+https://github.com/zijipia/Zibot_Package.git"
13
- },
14
- "keywords": [
15
- "@zibot/scdl",
16
- "scdl"
17
- ],
18
- "author": "Ziji",
19
- "license": "ISC",
20
- "bugs": {
21
- "url": "https://github.com/zijipia/Zibot_Package/issues"
22
- },
23
- "publishConfig": {
24
- "access": "public"
25
- },
26
- "homepage": "https://github.com/zijipia/Zibot_Package#readme",
27
- "devDependencies": {
28
- "discord.js": "^14.16.3"
29
- },
30
- "dependencies": {
31
- "axios": "^1.7.7",
32
- "events": "^3.3.0",
33
- "m3u8stream": "^0.8.6"
34
- }
35
- }
1
+ {
2
+ "name": "@zibot/scdl",
3
+ "version": "0.0.6",
4
+ "description": "Soucloud download",
5
+ "main": "index.js",
6
+ "types": "index.d.ts",
7
+ "scripts": {
8
+ "test": "echo \"Error: no test specified\" && exit 1"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/ZiProject/scdl.git"
13
+ },
14
+ "keywords": [
15
+ "@zibot/scdl",
16
+ "scdl"
17
+ ],
18
+ "author": "Ziji",
19
+ "license": "ISC",
20
+ "bugs": {
21
+ "url": "https://github.com/ZiProject/scdl/issues"
22
+ },
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "homepage": "https://github.com/ZiProject/scdl#readme",
27
+ "dependencies": {
28
+ "axios": "^1.13.2",
29
+ "events": "^3.3.0",
30
+ "m3u8stream": "^0.8.6"
31
+ }
32
+ }