soundcloud-core 1.0.0
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.
- package/LICENSE +20 -0
- package/README.md +166 -0
- package/index.js +40 -0
- package/package.json +89 -0
- package/src/plugin/cdn.js +69 -0
- package/src/plugin/client-id.js +61 -0
- package/src/plugin/playlist.js +151 -0
- package/src/plugin/profile.js +127 -0
- package/src/plugin/search.js +74 -0
- package/src/plugin/track.js +102 -0
- package/src/plugin/user-agent.js +17 -0
- package/src/soundcloud.js +87 -0
- package/typings/index.d.ts +787 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright (c) 2026 BlazeInferno64 --> https://github.com/blazeinferno64
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a
|
|
4
|
+
copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to permit
|
|
8
|
+
persons to whom the Software is furnished to do so, subject to the
|
|
9
|
+
following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included
|
|
12
|
+
in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
15
|
+
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
|
|
17
|
+
NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
|
18
|
+
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
|
19
|
+
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
|
20
|
+
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
[](https://npmjs.com/package/soundcloud-core)
|
|
2
|
+
[](https://www.npmjs.com/package/soundcloud-core?activeTab=dependencies)
|
|
3
|
+
[](https://nodejs.org)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
|
|
6
|
+
[](https://npm-stat.com/charts.html?package=soundcloud-core)
|
|
7
|
+
[](https://packagephobia.com/result?p=soundcloud-core)
|
|
8
|
+
[](https://bundlephobia.com/package/soundcloud-core@latest)
|
|
9
|
+
|
|
10
|
+

|
|
11
|
+

|
|
12
|
+
[](https://gitpod.io/#https://github.com/blazeinferno64/soundcloud-core)
|
|
13
|
+
[](https://github.com/blazeinferno64)
|
|
14
|
+

|
|
15
|
+
|
|
16
|
+
# soundcloud-core
|
|
17
|
+
|
|
18
|
+
> Fast, Minimalist, Unofficial SoundCloud v2 API client wrapper for [Node.js](https://nodejs.org)
|
|
19
|
+
|
|
20
|
+
## Disclaimer & Terms of Use
|
|
21
|
+
|
|
22
|
+
This project is an independent, unofficial wrapper for the **SoundCloud v2 API**. It is **not** affiliated, endorsed, sponsored, or officially connected with SoundCloud Limited in any way.
|
|
23
|
+
|
|
24
|
+
The author and contributors do not encourage, condone, or support any misuse of the [SoundCloud API](https://developers.soundcloud.com/), scraping practices, or any actions that violate [SoundCloud's Terms of Service](https://soundcloud.com/terms-of-use), [API Terms of Use](https://developers.soundcloud.com/docs/api/terms-of-use), or platform policies. This library is provided for educational and experimental purposes only. Users are solely responsible for ensuring that their usage of this software complies with **SoundCloud's official terms and applicable laws**.
|
|
25
|
+
|
|
26
|
+
# Installation
|
|
27
|
+
|
|
28
|
+
To get started with `soundcloud-core`, simply run the following command in your terminal:
|
|
29
|
+
|
|
30
|
+
Using [npm](https://npmjs.com) installation command:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
$ npm i soundcloud-core
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Using [yarn](https://yarnpkg.com) installation command:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
$ yarn add soundcloud-core
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Using [bun](https://bun.sh) installation command:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
$ bun add soundcloud-core
|
|
46
|
+
```
|
|
47
|
+
# Info
|
|
48
|
+
|
|
49
|
+
New to Promises?
|
|
50
|
+
|
|
51
|
+
If you're not familiar with promises, check out the [MDN documentation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) to learn more.
|
|
52
|
+
|
|
53
|
+
## Built on Top of Node.js HTTP library
|
|
54
|
+
|
|
55
|
+
Under the hood, `soundcloud-core` uses the native [Fetch API](https://nodejs.org/learn/getting-started/fetch) provided by Node.js (undici), ensuring a seamless and efficient experience without any third party packages.
|
|
56
|
+
|
|
57
|
+
# Getting started
|
|
58
|
+
|
|
59
|
+
First, require/import this library to your project as follows:
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
const { SoundCloudClient } = require("soundcloud-core");
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
If it's an ES Module then import it to your project as follows:
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
import { SoundCloudClient } from "soundcloud-core";
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Then spin up a client and you're good to go:
|
|
72
|
+
|
|
73
|
+
```js
|
|
74
|
+
const client = new SoundCloudClient();
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
# Client Id
|
|
78
|
+
|
|
79
|
+
As per [SoundCloud's v2 API](https://developers.soundcloud.com/), no client id is strictly necessary to use this library - if you don't pass one in, `soundcloud-core` will quietly go fetch a fresh one for you the first time it actually needs it (by pulling it straight off [soundcloud.com](https://soundcloud.com)), and reuse it for the lifetime of the client.
|
|
80
|
+
|
|
81
|
+
That said, if you already have a `client_id` lying around (or you're hitting rate limits and want a bit more control over when/how it's fetched), you can just hand it to the constructor yourself:
|
|
82
|
+
|
|
83
|
+
```js
|
|
84
|
+
const client = new SoundCloudClient({
|
|
85
|
+
// Leave it empty, incase you don't have one!
|
|
86
|
+
clientId: "your_client_id"
|
|
87
|
+
});
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
You can also grab whatever `client_id` the client ends up using, in case you want to cache it or reuse it elsewhere:
|
|
91
|
+
|
|
92
|
+
```js
|
|
93
|
+
const clientId = await client.getClientId();
|
|
94
|
+
console.log(clientId);
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
# Usage
|
|
98
|
+
|
|
99
|
+
Every method on `SoundCloudClient` returns a promise, so `await` them (or `.then()` them) inside an `async` function.
|
|
100
|
+
|
|
101
|
+
## Fetching a track
|
|
102
|
+
|
|
103
|
+
```js
|
|
104
|
+
const client = new SoundCloudClient();
|
|
105
|
+
|
|
106
|
+
const track = await client.getMetaData({
|
|
107
|
+
url: "https://soundcloud.com/martingarrix/martin-garrix-animals-original" // Martin Garrix - Animals
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
console.log(track.title); // "Track Name"
|
|
111
|
+
console.log(track.streamUrl); // Playable HLS/progressive stream (Best for audio quality) URL (short-lived!)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Keep in mind `streamUrl` is short-lived and will expire after a while, so don't go caching it for later - fetch it fresh whenever you actually need to stream the track.
|
|
115
|
+
|
|
116
|
+
## Fetching a playlist
|
|
117
|
+
|
|
118
|
+
```js
|
|
119
|
+
const playlist = await client.getPlaylist({
|
|
120
|
+
// Place your playlist url here, for example I'am placing my playlist url here
|
|
121
|
+
url: "https://soundcloud.com/blazeinferno64/sets/only-house",
|
|
122
|
+
limit: 20 // grab up to 20 tracks, or pass "max" for the whole playlist
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
console.log(playlist.title, playlist.trackCount);
|
|
126
|
+
playlist.tracks.forEach(track => console.log(track.title));
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Fetching a user profile
|
|
130
|
+
|
|
131
|
+
```js
|
|
132
|
+
// A bare username works just as well as a full profile URL
|
|
133
|
+
const profile = await client.getProfile({
|
|
134
|
+
// Your username here, for example I'am placing my username here
|
|
135
|
+
username: "BlazeInferno64"
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
console.log(profile.username, profile.stats.followers);
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Searching for tracks
|
|
142
|
+
|
|
143
|
+
```js
|
|
144
|
+
const results = await client.search({
|
|
145
|
+
query: "Martin Garrix - Animals", // Any song you want to search
|
|
146
|
+
limit: 15
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
results.forEach(track => console.log(track.title, "-", track.artist.username));
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
# LICENSE
|
|
153
|
+
|
|
154
|
+
`soundcloud-core` is released under the MIT License.
|
|
155
|
+
|
|
156
|
+
View the full license terms <a href="https://github.com/BlazeInferno64/soundcloud-core/blob/main/LICENSE">here</a>.
|
|
157
|
+
|
|
158
|
+
# Bugs & Issues
|
|
159
|
+
|
|
160
|
+
Found a bug or want a new feature?
|
|
161
|
+
|
|
162
|
+
Report issues and request features on the [soundcloud-core issue tracker](https://github.com/blazeinferno64/soundcloud-core/issues).
|
|
163
|
+
|
|
164
|
+
`Thanks for reading!`
|
|
165
|
+
|
|
166
|
+
`Have a great day ahead :D`
|
package/index.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Copyright (c) 2026 BlazeInferno64 --> https://github.com/blazeinferno64.
|
|
2
|
+
//
|
|
3
|
+
// Author(s) -> BlazeInferno64
|
|
4
|
+
//
|
|
5
|
+
// Last updated: 10/09/2026
|
|
6
|
+
//
|
|
7
|
+
// Permission is hereby granted, free of charge, to any person obtaining a
|
|
8
|
+
// copy of this software and associated documentation files (the
|
|
9
|
+
// "Software"), to deal in the Software without restriction, including
|
|
10
|
+
// without limitation the rights to use, copy, modify, merge, publish,
|
|
11
|
+
// distribute, sublicense, and/or sell copies of the Software, and to permit
|
|
12
|
+
// persons to whom the Software is furnished to do so, subject to the
|
|
13
|
+
// following conditions:
|
|
14
|
+
//
|
|
15
|
+
// The above copyright notice and this permission notice shall be included
|
|
16
|
+
// in all copies or substantial portions of the Software.
|
|
17
|
+
//
|
|
18
|
+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
19
|
+
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
20
|
+
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
|
|
21
|
+
// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
|
22
|
+
// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
|
23
|
+
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
|
24
|
+
// USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
25
|
+
|
|
26
|
+
"use strict";
|
|
27
|
+
/**
|
|
28
|
+
* soundcloud-core is a Fast, Minimalist, Unofficial SoundCloud v2 API client wrapper for [Node.js](https://nodejs.org)
|
|
29
|
+
*
|
|
30
|
+
* SoundCloud API Requests done right!
|
|
31
|
+
*
|
|
32
|
+
* Learn more about it from [here](https://github.com/blazeinferno64/soundcloud-core)
|
|
33
|
+
* @example
|
|
34
|
+
* // Require it in your project by doing -
|
|
35
|
+
* const { SoundCloudClient } = require("soundcloud-core");
|
|
36
|
+
*
|
|
37
|
+
* // Or import it to your project if its an ES module by doing -
|
|
38
|
+
* import { SoundCloudClient } from "soundcloud-core";
|
|
39
|
+
*/
|
|
40
|
+
module.exports = require("./src/soundcloud-core");
|
package/package.json
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
{
|
|
2
|
+
"dependencies": {
|
|
3
|
+
"cheerio": "^1.2.0"
|
|
4
|
+
},
|
|
5
|
+
"name": "soundcloud-core",
|
|
6
|
+
"version": "1.0.0",
|
|
7
|
+
"description": "Fast, Minimalist, Unofficial SoundCloud v2 API client wrapper for Node.js",
|
|
8
|
+
"main": "index.js",
|
|
9
|
+
"devDependencies": {},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "echo \"All success!\" && exit 1"
|
|
12
|
+
},
|
|
13
|
+
"types": "typings/index.d.ts",
|
|
14
|
+
"contributors": [
|
|
15
|
+
"BlazeInferno64",
|
|
16
|
+
"blazeinferno"
|
|
17
|
+
],
|
|
18
|
+
"keywords": [
|
|
19
|
+
"blazed",
|
|
20
|
+
"blazed.js",
|
|
21
|
+
"HTTP-Client",
|
|
22
|
+
"blazed-http-client",
|
|
23
|
+
"nodejs",
|
|
24
|
+
"javascript",
|
|
25
|
+
"node-http",
|
|
26
|
+
"node-fetch",
|
|
27
|
+
"axios",
|
|
28
|
+
"gaxios",
|
|
29
|
+
"node",
|
|
30
|
+
"https",
|
|
31
|
+
"http",
|
|
32
|
+
"json",
|
|
33
|
+
"automatic-json-parser",
|
|
34
|
+
"json-server",
|
|
35
|
+
"http/2",
|
|
36
|
+
"ws",
|
|
37
|
+
"node-blazed.js",
|
|
38
|
+
"node-http/2",
|
|
39
|
+
"node-https",
|
|
40
|
+
"express",
|
|
41
|
+
"typescript",
|
|
42
|
+
"connect",
|
|
43
|
+
"got",
|
|
44
|
+
"git",
|
|
45
|
+
"htmx",
|
|
46
|
+
"www",
|
|
47
|
+
"npm",
|
|
48
|
+
"high-peformance",
|
|
49
|
+
"keep-Alive",
|
|
50
|
+
"apache",
|
|
51
|
+
"http/1.1",
|
|
52
|
+
"http/1",
|
|
53
|
+
"http/3",
|
|
54
|
+
"ai",
|
|
55
|
+
"openai",
|
|
56
|
+
"api",
|
|
57
|
+
"binary",
|
|
58
|
+
"microsoft",
|
|
59
|
+
"google",
|
|
60
|
+
"gemini",
|
|
61
|
+
"cloudflare",
|
|
62
|
+
"dns",
|
|
63
|
+
"dns-client",
|
|
64
|
+
"light-weight",
|
|
65
|
+
"fast-http-client",
|
|
66
|
+
"promise-based-http-client",
|
|
67
|
+
"eslint",
|
|
68
|
+
"openjs",
|
|
69
|
+
"regex",
|
|
70
|
+
"light-weight",
|
|
71
|
+
"babel__core",
|
|
72
|
+
"soundcloud",
|
|
73
|
+
"soundcloud-core",
|
|
74
|
+
"soundcloud.ts",
|
|
75
|
+
"soundcloud-core",
|
|
76
|
+
"youtube",
|
|
77
|
+
"ytdl-core",
|
|
78
|
+
"yt-music",
|
|
79
|
+
"yt"
|
|
80
|
+
],
|
|
81
|
+
"author": "BlazeInferno64",
|
|
82
|
+
"license": "MIT",
|
|
83
|
+
"homepage": "https://github.com/blazeinferno64/soundcloud-core#readme",
|
|
84
|
+
"repository": {
|
|
85
|
+
"type": "git",
|
|
86
|
+
"url": "git+https://github.com/blazeinferno64/soundcloud-core.git"
|
|
87
|
+
},
|
|
88
|
+
"type": "commonjs"
|
|
89
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Copyright (c) 2026 BlazeInferno64 --> https://github.com/blazeinferno64.
|
|
2
|
+
//
|
|
3
|
+
// Author(s) -> BlazeInferno64
|
|
4
|
+
//
|
|
5
|
+
// Last updated: 09/05/2026
|
|
6
|
+
|
|
7
|
+
const { ua } = require('./user-agent');
|
|
8
|
+
const { getFreshClientID } = require('./client-id');
|
|
9
|
+
|
|
10
|
+
//const cheerio = require("cheerio");
|
|
11
|
+
|
|
12
|
+
const getCDNUrl = async (track, clientID, userAgent) => {
|
|
13
|
+
try {
|
|
14
|
+
if (!track) throw new Error("Track ID is required to fetch the CDN URL.");
|
|
15
|
+
if (!clientID) clientID = await getFreshClientID(userAgent);
|
|
16
|
+
|
|
17
|
+
if (!track.media?.transcodings?.length) return null;
|
|
18
|
+
|
|
19
|
+
const transcodings = track.media.transcodings;
|
|
20
|
+
|
|
21
|
+
// TOP PRIORITY: High Quality AAC (HLS)
|
|
22
|
+
let transcoding = transcodings.find(t =>
|
|
23
|
+
t.format.protocol === 'hls' &&
|
|
24
|
+
t.preset.includes('aac')
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
// SECOND PRIORITY: Standard Progressive MP3
|
|
28
|
+
if (!transcoding) {
|
|
29
|
+
transcoding = transcodings.find(t =>
|
|
30
|
+
t.format.protocol === 'progressive' &&
|
|
31
|
+
t.format.mime_type.includes('audio/mpeg')
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// LAST RESORT: Anything that isn't Opus
|
|
36
|
+
if (!transcoding) {
|
|
37
|
+
transcoding = transcodings.find(t => !t.preset.includes('opus'));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!transcoding) return null;
|
|
41
|
+
|
|
42
|
+
// Construct the final URL with the client_id and optional track_authorization
|
|
43
|
+
let url = `${transcoding.url}?client_id=${clientID}`;
|
|
44
|
+
if (track.track_authorization) {
|
|
45
|
+
// Append the track_authorization parameter if it exists
|
|
46
|
+
url += `&track_authorization=${track.track_authorization}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Here we make the request to get the actual CDN URL to stream the track. We use the provided userAgent or default to the one from user-agent.js.
|
|
50
|
+
const response = await fetch(url, {
|
|
51
|
+
headers: {
|
|
52
|
+
'User-Agent': userAgent || ua,
|
|
53
|
+
'Accept': 'application/json' // Requesting JSON response
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const data = await response.json();
|
|
58
|
+
return data.url; // Return the actual CDN URL for streaming! If HLS, this will be a .m3u8 playlist URL; if progressive, it will be a direct .mp3 URL.
|
|
59
|
+
|
|
60
|
+
} catch (e) {
|
|
61
|
+
// If any error occurs during the process, we throw an error with a descriptive message.
|
|
62
|
+
throw new Error(`Cannot fetch CDN URL for track ${track}: ${e.message ? e.message : e}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Export the functions
|
|
67
|
+
module.exports = {
|
|
68
|
+
getCDNUrl
|
|
69
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Copyright (c) 2026 BlazeInferno64 --> https://github.com/blazeinferno64.
|
|
2
|
+
//
|
|
3
|
+
// Author(s) -> BlazeInferno64
|
|
4
|
+
//
|
|
5
|
+
// Last updated: 09/05/2026
|
|
6
|
+
|
|
7
|
+
"use strict";
|
|
8
|
+
|
|
9
|
+
const { ua } = require('./user-agent');
|
|
10
|
+
|
|
11
|
+
const cheerio = require("cheerio");
|
|
12
|
+
|
|
13
|
+
const getFreshClientID = async (userAgent) => {
|
|
14
|
+
try {
|
|
15
|
+
const response = await fetch('https://soundcloud.com', {
|
|
16
|
+
headers: {
|
|
17
|
+
'User-Agent': userAgent || ua,
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const html = await response.text();
|
|
22
|
+
const $ = cheerio.load(html);
|
|
23
|
+
|
|
24
|
+
// Find all script tags with src attributes that contain 'sndcdn.com/assets/'
|
|
25
|
+
const scriptUrls = [];
|
|
26
|
+
$('script[src]').each((i, el) => {
|
|
27
|
+
const src = $(el).attr('src');
|
|
28
|
+
// Search for the client_id pattern in the script content
|
|
29
|
+
if (src && src.includes('sndcdn.com/assets/')) {
|
|
30
|
+
scriptUrls.push(src);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Iterate through the script URLs in reverse order to find the latest one
|
|
35
|
+
for (const url of scriptUrls.reverse()) {
|
|
36
|
+
const jsRes = await fetch(url, {
|
|
37
|
+
headers: {
|
|
38
|
+
'User-Agent': userAgent || ua,
|
|
39
|
+
// intentionally not forcing Content-Type to match original behavior
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
const jsContent = await jsRes.text();
|
|
43
|
+
|
|
44
|
+
// Yeps! We found the client_id in the script content
|
|
45
|
+
const match = jsContent.match(/client_id[:=]\s*["']([a-zA-Z0-9]{32})["']/);
|
|
46
|
+
if (match && match[1]) {
|
|
47
|
+
// FINALLY! Return the client_id if found or else skip it
|
|
48
|
+
return match[1];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} catch (e) {
|
|
52
|
+
// Well, SoundCloud might have changed their structure or something went wrong, so we throw an error or have blocked the request.
|
|
53
|
+
// Either way, we can't get the client_id now :(
|
|
54
|
+
throw new Error(`Cannot fetch clientID from SoundCloud: ${e.message ? e.message : e}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
module.exports = {
|
|
60
|
+
getFreshClientID
|
|
61
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Copyright (c) 2026 BlazeInferno64 --> https://github.com/blazeinferno64.
|
|
2
|
+
//
|
|
3
|
+
// Author(s) -> BlazeInferno64
|
|
4
|
+
//
|
|
5
|
+
// Last updated: 09/07/2026
|
|
6
|
+
|
|
7
|
+
const { ua } = require('./user-agent');
|
|
8
|
+
const { getFreshClientID } = require('./client-id');
|
|
9
|
+
|
|
10
|
+
const CHUNK_SIZE = 50; // SoundCloud's batch tracks endpoint caps out around here - stay safely under it
|
|
11
|
+
|
|
12
|
+
// `limit` accepts a positive integer (how many tracks to include) or the string "max" (every track in the playlist).
|
|
13
|
+
// Returns Infinity for "max" so the slice() below just takes everything.
|
|
14
|
+
const resolveLimit = (limit) => {
|
|
15
|
+
if (limit === 'max') return Infinity;
|
|
16
|
+
if (typeof limit === 'number' && Number.isInteger(limit) && limit > 0) return limit;
|
|
17
|
+
throw new Error(`Invalid limit provided! Expected a positive integer or "max", got: ${JSON.stringify(limit)}`);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Turn a raw track (whether it came inline or from a batch fetch) into the same reduced shape.
|
|
21
|
+
// track === null covers the case where a stub id couldn't be resolved (deleted/private/geo-blocked).
|
|
22
|
+
const reduceTrack = (id, track) => {
|
|
23
|
+
if (!track) {
|
|
24
|
+
return { id, title: null, permalinkUrl: null, duration: null, genre: null, artworkUrl: null, artist: null };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
id: track.id,
|
|
29
|
+
title: track.title,
|
|
30
|
+
permalinkUrl: track.permalink_url,
|
|
31
|
+
duration: track.duration,
|
|
32
|
+
genre: track.genre,
|
|
33
|
+
artworkUrl: track.artwork_url
|
|
34
|
+
? track.artwork_url.replace("-large.jpg", "-t500x500.jpg")
|
|
35
|
+
: (track.user?.avatar_url?.replace("-large.jpg", "-t500x500.jpg") || null),
|
|
36
|
+
artist: track.user ? {
|
|
37
|
+
id: track.user.id,
|
|
38
|
+
username: track.user.username,
|
|
39
|
+
profileUrl: track.user.permalink_url
|
|
40
|
+
} : null
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Fetches full track data for a batch of stub ids, chunked to stay under SoundCloud's per-request limit.
|
|
45
|
+
// Returns a Map of id -> raw track so failed/missing ids can just fall back to null downstream.
|
|
46
|
+
const resolveStubTracks = async (ids, userAgent, clientID) => {
|
|
47
|
+
const resolved = new Map();
|
|
48
|
+
|
|
49
|
+
for (let i = 0; i < ids.length; i += CHUNK_SIZE) {
|
|
50
|
+
const chunk = ids.slice(i, i + CHUNK_SIZE);
|
|
51
|
+
const idsUrl = `https://api-v2.soundcloud.com/tracks?ids=${chunk.join(',')}&client_id=${clientID}`;
|
|
52
|
+
|
|
53
|
+
const response = await fetch(idsUrl, {
|
|
54
|
+
headers: {
|
|
55
|
+
'User-Agent': userAgent || ua,
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (!response.ok) continue; // Skip a bad chunk rather than failing the whole playlist over a few tracks
|
|
60
|
+
|
|
61
|
+
const chunkTracks = await response.json();
|
|
62
|
+
for (const track of chunkTracks) {
|
|
63
|
+
resolved.set(track.id, track);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return resolved;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const fetchPlaylist = async (playlistUrl, userAgent, clientID, limit = 10) => {
|
|
71
|
+
try {
|
|
72
|
+
if (!playlistUrl || typeof playlistUrl !== "string") {
|
|
73
|
+
throw new Error("Invalid playlist URL provided!");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const maxTracks = resolveLimit(limit);
|
|
77
|
+
|
|
78
|
+
if (!clientID) clientID = await getFreshClientID(userAgent);
|
|
79
|
+
|
|
80
|
+
const resolveUrl = `https://api-v2.soundcloud.com/resolve?url=${encodeURIComponent(playlistUrl)}&client_id=${clientID}`;
|
|
81
|
+
|
|
82
|
+
const response = await fetch(resolveUrl, {
|
|
83
|
+
headers: {
|
|
84
|
+
'User-Agent': userAgent || ua,
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (!response.ok) {
|
|
89
|
+
throw new Error(`Failed to resolve playlist URL. Status: ${response.status}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const playlist = await response.json();
|
|
93
|
+
|
|
94
|
+
// The resolve endpoint can also return tracks/users - playlists and "sets" both come back as kind "playlist".
|
|
95
|
+
if (!playlist || playlist.kind !== "playlist") {
|
|
96
|
+
throw new Error(`Provided URL did not resolve to a playlist (kind: ${playlist?.kind || "unknown"}).`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const rawTracks = (playlist.tracks || []).slice(0, maxTracks === Infinity ? undefined : maxTracks);
|
|
100
|
+
|
|
101
|
+
// SoundCloud only inlines full data for roughly the first 5 tracks - the rest come back as stubs (id only).
|
|
102
|
+
// Batch-fetch the missing ones so the playlist comes back fully populated, same as the web app does.
|
|
103
|
+
const stubIds = rawTracks.filter(t => !t.title).map(t => t.id);
|
|
104
|
+
const resolvedStubs = stubIds.length
|
|
105
|
+
? await resolveStubTracks(stubIds, userAgent, clientID)
|
|
106
|
+
: new Map();
|
|
107
|
+
|
|
108
|
+
const tracks = rawTracks.map(t =>
|
|
109
|
+
reduceTrack(t.id, t.title ? t : resolvedStubs.get(t.id))
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const playlistData = {
|
|
113
|
+
id: playlist.id,
|
|
114
|
+
title: playlist.title,
|
|
115
|
+
description: playlist.description,
|
|
116
|
+
permalinkUrl: playlist.permalink_url,
|
|
117
|
+
trackCount: playlist.track_count, // total tracks in the playlist - not affected by `limit`, see `tracks.length` for how many actually came back
|
|
118
|
+
duration: playlist.duration, // total duration in milliseconds
|
|
119
|
+
genre: playlist.genre,
|
|
120
|
+
createdAt: playlist.created_at,
|
|
121
|
+
|
|
122
|
+
// Curator metadata (the user who made the playlist)
|
|
123
|
+
curator: {
|
|
124
|
+
id: playlist.user.id,
|
|
125
|
+
username: playlist.user.username,
|
|
126
|
+
profileUrl: playlist.user.permalink_url,
|
|
127
|
+
avatarUrl: playlist.user.avatar_url?.replace("-large.jpg", "-t500x500.jpg")
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
artworkUrl: playlist.artwork_url
|
|
131
|
+
? playlist.artwork_url.replace("-large.jpg", "-t500x500.jpg")
|
|
132
|
+
: playlist.user.avatar_url?.replace("-large.jpg", "-t500x500.jpg"),
|
|
133
|
+
|
|
134
|
+
// Engagement stats
|
|
135
|
+
stats: {
|
|
136
|
+
likes: playlist.likes_count,
|
|
137
|
+
reposts: playlist.reposts_count
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
tracks
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
return playlistData;
|
|
144
|
+
} catch (e) {
|
|
145
|
+
throw new Error("Failed to fetch playlist!");
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = {
|
|
150
|
+
fetchPlaylist
|
|
151
|
+
}
|