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
|
@@ -0,0 +1,127 @@
|
|
|
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
|
+
// Accepts either a bare username ("BlazeInferno64") or a full profile URL and normalizes it into
|
|
11
|
+
// something the resolve endpoint can work with either way.
|
|
12
|
+
const normalizeProfileUrl = (input) => {
|
|
13
|
+
if (/^https?:\/\//i.test(input)) return input;
|
|
14
|
+
return `https://soundcloud.com/${input.trim().toLowerCase()}`;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const fetchProfile = async (profileInput, userAgent, clientID) => {
|
|
18
|
+
try {
|
|
19
|
+
if (!profileInput || typeof profileInput !== "string") {
|
|
20
|
+
throw new Error("Invalid username or profile URL provided!");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!clientID) clientID = await getFreshClientID(userAgent);
|
|
24
|
+
|
|
25
|
+
const profileUrl = normalizeProfileUrl(profileInput);
|
|
26
|
+
const resolveUrl = `https://api-v2.soundcloud.com/resolve?url=${encodeURIComponent(profileUrl)}&client_id=${clientID}`;
|
|
27
|
+
|
|
28
|
+
const response = await fetch(resolveUrl, {
|
|
29
|
+
headers: {
|
|
30
|
+
'User-Agent': userAgent || ua,
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
if (!response.ok) {
|
|
35
|
+
throw new Error(`Failed to resolve profile. Status: ${response.status}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const user = await response.json();
|
|
39
|
+
|
|
40
|
+
//console.log("Resolved user data:", user); // Debugging log to inspect the resolved user object
|
|
41
|
+
|
|
42
|
+
// The resolve endpoint can also return tracks/playlists depending on what the URL points to -
|
|
43
|
+
// we only want an actual user profile here.
|
|
44
|
+
if (!user || user.kind !== "user") {
|
|
45
|
+
throw new Error(`Provided username/URL did not resolve to a profile (kind: ${user?.kind || "unknown"}).`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The banner image at the top of a profile page lives under visuals, not avatar_url - falls back
|
|
49
|
+
// to null since plenty of profiles don't have one set.
|
|
50
|
+
const headerUrl = user.visuals?.visuals?.[0]?.visual_url || null;
|
|
51
|
+
|
|
52
|
+
const profileData = {
|
|
53
|
+
id: user.id,
|
|
54
|
+
urn: user.urn || null, // internal stable identifier, e.g. "soundcloud:users:1197971329" - useful if you're caching/de-duping across requests
|
|
55
|
+
username: user.username,
|
|
56
|
+
firstName: user.first_name || null,
|
|
57
|
+
lastName: user.last_name || null,
|
|
58
|
+
fullName: user.full_name || null,
|
|
59
|
+
permalink: user.permalink || null, // short slug ("blazeinferno64"), as opposed to the full permalinkUrl below
|
|
60
|
+
permalinkUrl: user.permalink_url,
|
|
61
|
+
description: user.description || null,
|
|
62
|
+
|
|
63
|
+
kind: user.kind || null, // should always be "user" for a profile, but included for completeness
|
|
64
|
+
|
|
65
|
+
location: {
|
|
66
|
+
city: user.city || null,
|
|
67
|
+
country: user.country_code || null
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
avatarUrl: user.avatar_url?.replace("-large.jpg", "-t500x500.jpg"),
|
|
71
|
+
headerUrl,
|
|
72
|
+
|
|
73
|
+
// Pro/Pro Unlimited/verified badges - shown next to the username on the real profile page
|
|
74
|
+
badges: {
|
|
75
|
+
verified: !!user.badges?.verified,
|
|
76
|
+
pro: !!user.badges?.pro,
|
|
77
|
+
proUnlimited: !!user.badges?.pro_unlimited
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
subscriptions: {
|
|
81
|
+
creatorSubscriptions: user.creator_subscriptions.products || [], // array of subscription product IDs the user is subscribed to (e.g. "creator_subscription:premium")
|
|
82
|
+
// The `creator_subscription` field is a newer addition to SoundCloud's subscription model, allowing users to support specific creators directly. The `product.id` can be used to identify the specific subscription product.
|
|
83
|
+
creatorSubscription: user.creator_subscription.product?.id,
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
// External links from the profile's "Links" section (personal site, Instagram, Twitter/X, etc.)
|
|
87
|
+
webProfiles: (user.web_profiles || []).map(link => ({
|
|
88
|
+
network: link.network,
|
|
89
|
+
title: link.title || null,
|
|
90
|
+
url: link.url
|
|
91
|
+
})),
|
|
92
|
+
|
|
93
|
+
// A user's "station" is what SoundCloud autoplays after their tracks finish (their own radio mix) -
|
|
94
|
+
// both fields are internal identifiers, not directly a browsable page URL.
|
|
95
|
+
station: {
|
|
96
|
+
urn: user.station_urn || null,
|
|
97
|
+
permalink: user.station_permalink || null
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
// Stats you'd see laid out on the profile page itself
|
|
101
|
+
stats: {
|
|
102
|
+
followers: user.followers_count,
|
|
103
|
+
following: user.followings_count,
|
|
104
|
+
tracks: user.track_count,
|
|
105
|
+
playlists: user.playlist_count,
|
|
106
|
+
playlistLikes: user.playlist_likes_count,
|
|
107
|
+
likes: user.public_favorites_count ?? user.likes_count,
|
|
108
|
+
reposts: user.reposts_count,
|
|
109
|
+
comments: user.comments_count,
|
|
110
|
+
groups: user.groups_count,
|
|
111
|
+
dateOfBirth: user.date_of_birth || null,
|
|
112
|
+
// Note: SoundCloud doesn't provide a "total plays" stat for a user profile, only per-track plays.
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
createdAt: user.created_at,
|
|
116
|
+
lastModified: user.last_modified || null // last time this profile's data changed on SoundCloud's end
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
return profileData;
|
|
120
|
+
} catch (e) {
|
|
121
|
+
throw new Error("Failed to fetch profile!");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = {
|
|
126
|
+
fetchProfile
|
|
127
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
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 search = async (query, userAgent, clientID, options = {}) => {
|
|
11
|
+
try {
|
|
12
|
+
if (!query || typeof query !== "string") {
|
|
13
|
+
throw new Error("Invalid search query provided!");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (!clientID) clientID = await getFreshClientID(userAgent);
|
|
17
|
+
|
|
18
|
+
const limit = options.limit || 10; // How many results to return - defaults to 10, same as the web app's initial batch
|
|
19
|
+
|
|
20
|
+
const searchUrl = `https://api-v2.soundcloud.com/search/tracks?q=${encodeURIComponent(query)}&client_id=${clientID}&limit=${limit}`;
|
|
21
|
+
|
|
22
|
+
const response = await fetch(searchUrl, {
|
|
23
|
+
headers: {
|
|
24
|
+
'User-Agent': userAgent || ua,
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
throw new Error(`Failed to search tracks. Status: ${response.status}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const data = await response.json();
|
|
33
|
+
|
|
34
|
+
// The search endpoint wraps results in a "collection" array - bail out early if it's missing/empty
|
|
35
|
+
if (!data || !Array.isArray(data.collection)) {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Trim each track down to what you'd actually want from a search result (not the full track.js payload -
|
|
40
|
+
// no streamUrl/trackAuthorization here since these are just candidates, not a resolved track yet)
|
|
41
|
+
const results = data.collection.map(track => ({
|
|
42
|
+
id: track.id,
|
|
43
|
+
title: track.title,
|
|
44
|
+
permalinkUrl: track.permalink_url,
|
|
45
|
+
duration: track.duration, // in milliseconds
|
|
46
|
+
genre: track.genre,
|
|
47
|
+
|
|
48
|
+
artworkUrl: track.artwork_url
|
|
49
|
+
? track.artwork_url.replace("-large.jpg", "-t500x500.jpg")
|
|
50
|
+
: track.user?.avatar_url?.replace("-large.jpg", "-t500x500.jpg"),
|
|
51
|
+
|
|
52
|
+
artist: {
|
|
53
|
+
id: track.user?.id,
|
|
54
|
+
username: track.user?.username,
|
|
55
|
+
profileUrl: track.user?.permalink_url
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
stats: {
|
|
59
|
+
plays: track.playback_count,
|
|
60
|
+
likes: track.likes_count,
|
|
61
|
+
comments: track.comment_count,
|
|
62
|
+
reposts: track.reposts_count
|
|
63
|
+
}
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
return results;
|
|
67
|
+
} catch (e) {
|
|
68
|
+
throw new Error("Failed to search tracks!");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = {
|
|
73
|
+
search
|
|
74
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
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
|
+
const { getCDNUrl } = require('./cdn');
|
|
10
|
+
|
|
11
|
+
const cheerio = require("cheerio");
|
|
12
|
+
|
|
13
|
+
const fetchSong = async (songUrl, userAgent, clientID) => {
|
|
14
|
+
try {
|
|
15
|
+
if (!songUrl || typeof songUrl !== "string") {
|
|
16
|
+
throw new Error("Invalid song URL provided!");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const resolveUrl = `https://api-v2.soundcloud.com/resolve?url=${encodeURIComponent(songUrl)}&client_id=${clientID}`;
|
|
20
|
+
|
|
21
|
+
const response = await fetch(resolveUrl, {
|
|
22
|
+
headers: {
|
|
23
|
+
'User-Agent': userAgent || ua,
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
if (!response.ok) {
|
|
28
|
+
throw new Error(`Failed to resolve song URL. Status: ${response.status}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let track = await response.json();
|
|
32
|
+
|
|
33
|
+
// The resolve endpoint can also return sets/users/playlists - we only want single tracks here.
|
|
34
|
+
if (!track || track.kind !== "track") {
|
|
35
|
+
throw new Error(`Provided URL did not resolve to a single track (kind: ${track?.kind || "unknown"}).`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Some resolved tracks omit media/track_authorization - fetch the full track record.
|
|
39
|
+
if (!track.media || !track.track_authorization) {
|
|
40
|
+
const trackRes = await fetch(`https://api-v2.soundcloud.com/tracks/${track.id}?client_id=${clientID}`, {
|
|
41
|
+
headers: {
|
|
42
|
+
'User-Agent': userAgent || ua,
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
track = await trackRes.json();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const cdnUrl = await getCDNUrl(track, clientID, userAgent || ua);
|
|
49
|
+
|
|
50
|
+
if (!cdnUrl) {
|
|
51
|
+
throw new Error(`No playable stream found for track: ${track.title}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const songData = {
|
|
55
|
+
id: track.id,
|
|
56
|
+
title: track.title,
|
|
57
|
+
description: track.description,
|
|
58
|
+
duration: track.duration, // in milliseconds
|
|
59
|
+
genre: track.genre,
|
|
60
|
+
createdAt: track.created_at,
|
|
61
|
+
|
|
62
|
+
// Artist Metadata
|
|
63
|
+
artist: {
|
|
64
|
+
id: track.user.id,
|
|
65
|
+
username: track.user.username,
|
|
66
|
+
profileUrl: track.user.permalink_url,
|
|
67
|
+
avatarUrl: track.user.avatar_url?.replace("-large.jpg", "-t500x500.jpg")
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
// Visual Assets (Upgraded to high-res, falling back to avatar)
|
|
71
|
+
artworkUrl: track.artwork_url
|
|
72
|
+
? track.artwork_url.replace("-large.jpg", "-t500x500.jpg")
|
|
73
|
+
: track.user.avatar_url?.replace("-large.jpg", "-t500x500.jpg"),
|
|
74
|
+
|
|
75
|
+
// Album / Publisher Info
|
|
76
|
+
album: track.publisher_metadata?.album_title || null,
|
|
77
|
+
label: track.publisher_metadata?.label_name || null,
|
|
78
|
+
|
|
79
|
+
// Playback & CDN (The crucial part for your client)
|
|
80
|
+
streamUrl: cdnUrl, // The final resolved HLS or progressive MP3 link
|
|
81
|
+
trackAuthorization: track.track_authorization || null,
|
|
82
|
+
|
|
83
|
+
// Engagement Stats (What you see on the web page)
|
|
84
|
+
stats: {
|
|
85
|
+
plays: track.playback_count,
|
|
86
|
+
likes: track.likes_count,
|
|
87
|
+
reposts: track.reposts_count,
|
|
88
|
+
comments: track.comment_count
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return songData;
|
|
93
|
+
} catch (e) {
|
|
94
|
+
throw new Error("Failed to fetch song!");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
module.exports = {
|
|
100
|
+
fetchSong
|
|
101
|
+
}
|
|
102
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Copyright (c) 2026 BlazeInferno64 --> https://github.com/blazeinferno64.
|
|
2
|
+
//
|
|
3
|
+
// Author(s) -> BlazeInferno64
|
|
4
|
+
//
|
|
5
|
+
// Last updated: 09/05/2026
|
|
6
|
+
"use strict";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Keep a consistent user-agent for all requests to avoid detection and rate limiting.
|
|
10
|
+
* This is especially important for SoundCloud API requests.
|
|
11
|
+
*/
|
|
12
|
+
const ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36';
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
module.exports = {
|
|
16
|
+
ua
|
|
17
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Copyright (c) 2026 BlazeInferno64 --> https://github.com/blazeinferno64.
|
|
2
|
+
//
|
|
3
|
+
// Author(s) -> BlazeInferno64
|
|
4
|
+
//
|
|
5
|
+
// Last updated: 07/09/2026
|
|
6
|
+
"use strict";
|
|
7
|
+
|
|
8
|
+
const { ua } = require('./plugin/user-agent');
|
|
9
|
+
const { getFreshClientID } = require('./plugin/client-id');
|
|
10
|
+
const { fetchSong } = require('./plugin/track');
|
|
11
|
+
const { search } = require('./plugin/search');
|
|
12
|
+
const { fetchPlaylist } = require('./plugin/playlist');
|
|
13
|
+
const { fetchProfile } = require('./plugin/profile');
|
|
14
|
+
|
|
15
|
+
//const cheerio = require("cheerio");
|
|
16
|
+
|
|
17
|
+
class SoundCloudClient {
|
|
18
|
+
constructor(options = {}) {
|
|
19
|
+
this._useragent = options ? options.userAgent : ua; // Use the provided user agent or default to the one from user-agent.js
|
|
20
|
+
this._clientId = options ? options.clientId : getFreshClientID(this._useragent); // Fetch one if not provided!
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async getClientId() {
|
|
24
|
+
if (!this._clientId) {
|
|
25
|
+
this._clientId = await getFreshClientID(this._useragent);
|
|
26
|
+
}
|
|
27
|
+
return this._clientId;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async getMetaData(songOptions) {
|
|
31
|
+
const { url, userAgent } = songOptions;
|
|
32
|
+
const clientId = await this._clientId || await this.getClientId(this._useragent);
|
|
33
|
+
const finalUserAgent = userAgent || this._useragent;
|
|
34
|
+
|
|
35
|
+
// Validate inputs
|
|
36
|
+
if (!url) throw new Error("No song URL provided!");
|
|
37
|
+
if (!clientId) throw new Error("No client ID available! Please provide one or allow the client to fetch it.");
|
|
38
|
+
|
|
39
|
+
// Fetch the song metadata using the provided URL, user agent, and client ID
|
|
40
|
+
return await fetchSong(url, finalUserAgent, clientId);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async getPlaylist(playlistOptions) {
|
|
44
|
+
const { url, userAgent, limit = 10 } = playlistOptions;
|
|
45
|
+
const clientId = await this._clientId || await this.getClientId(this._useragent);
|
|
46
|
+
const finalUserAgent = userAgent || this._useragent;
|
|
47
|
+
|
|
48
|
+
// Validate inputs
|
|
49
|
+
if (!url) throw new Error("No playlist URL provided!");
|
|
50
|
+
if (!clientId) throw new Error("No client ID available! Please provide one or allow the client to fetch it.");
|
|
51
|
+
|
|
52
|
+
// Fetch the playlist metadata using the provided URL, user agent, and client ID.
|
|
53
|
+
// `limit` caps how many tracks come back - a positive integer, or "max" for the whole playlist.
|
|
54
|
+
return await fetchPlaylist(url, finalUserAgent, clientId, limit);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async getProfile(profileOptions) {
|
|
58
|
+
const { username, userAgent } = profileOptions;
|
|
59
|
+
const clientId = await this._clientId || await this.getClientId(this._useragent);
|
|
60
|
+
const finalUserAgent = userAgent || this._useragent;
|
|
61
|
+
|
|
62
|
+
// Validate inputs
|
|
63
|
+
if (!username) throw new Error("No username or profile URL provided!");
|
|
64
|
+
if (!clientId) throw new Error("No client ID available! Please provide one or allow the client to fetch it.");
|
|
65
|
+
|
|
66
|
+
// `username` accepts either a bare username ("BlazeInferno64") or a full profile URL
|
|
67
|
+
return await fetchProfile(username, finalUserAgent, clientId);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async search(searchOptions = {}) {
|
|
71
|
+
const { query, userAgent, ...options } = searchOptions;
|
|
72
|
+
const clientId = await this._clientId || await this.getClientId(this._useragent);
|
|
73
|
+
const finalUserAgent = userAgent || this._useragent;
|
|
74
|
+
|
|
75
|
+
// Validate inputs
|
|
76
|
+
if (!query) throw new Error("No search query provided!");
|
|
77
|
+
if (!clientId) throw new Error("No client ID available! Please provide one or allow the client to fetch it.");
|
|
78
|
+
|
|
79
|
+
// Search for tracks matching the query, using the provided user agent and client ID
|
|
80
|
+
return await search(query, finalUserAgent, clientId, options);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
module.exports = {
|
|
86
|
+
SoundCloudClient
|
|
87
|
+
}
|