@snwfdhmp/soundcloud-downloader
Version:
Download Soundcloud audio with Node.js, this is a forked version, the original script was written by @zackradisic and forked by @nasheedstation
450 lines (402 loc) • 12.9 kB
text/typescript
// @ts-ignore // i will not inspect further, please do it if you want to. PRs are welcome.
import sckey from "soundcloud-key-fetch";
import getInfo, {
getSetInfo,
Transcoding,
getTrackInfoByID,
TrackInfo,
User,
SetInfo,
} from "./info";
import filterMedia, { FilterPredicateObject } from "./filter-media";
import { download, fromMediaObj } from "./download";
import isValidURL, {
convertFirebaseURL,
isFirebaseURL,
isPersonalizedTrackURL,
isPlaylistURL,
stripMobilePrefix,
} from "./url";
import STREAMING_PROTOCOLS, { _PROTOCOLS } from "./protocols";
import FORMATS, { _FORMATS } from "./formats";
import { search, related, SearchOptions, RelatedResponse } from "./search";
import { downloadPlaylist } from "./download-playlist";
import m3u8stream from "m3u8stream";
import axios, { AxiosInstance } from "axios";
import * as path from "path";
import * as fs from "fs";
import { PaginatedQuery } from "./util";
import { GetLikesOptions, getLikes, Like } from "./likes";
import { getUser } from "./user";
/** @internal */
const downloadFormat = async (
url: string,
clientID: string,
format: FORMATS,
axiosInstance: AxiosInstance
): Promise<NodeJS.ReadableStream | m3u8stream.Stream> => {
const info = await getInfo(url, clientID, axiosInstance);
if (!info.media) {
throw new Error("Media is undefined");
}
const filtered = filterMedia(info.media.transcodings, { format: format });
if (filtered.length === 0)
throw new Error(`Could not find media with specified format: (${format})`);
return await fromMediaObj(filtered[0], clientID, axiosInstance);
};
interface ClientIDData {
clientID: string;
date: Date;
}
export interface SCDLOptions {
// Set a custom client ID to use
clientID?: string;
// Set to true to save client ID to file
saveClientID?: boolean;
// File path to save client ID, defaults to '../client_id.json"
filePath?: string;
// Custom axios instance to use
axiosInstance?: AxiosInstance;
// Whether or not to automatically convert mobile links to regular links, defaults to true
stripMobilePrefix?: boolean;
// Whether or not to automatically convert SoundCloud Firebase links copied from the mobile app
// (e.g. https://soundcloud.app.goo.gl/xxxxxxxxx), defaults to true.
convertFirebaseLinks?: boolean;
}
export class SCDL {
STREAMING_PROTOCOLS: { [key: string]: STREAMING_PROTOCOLS };
FORMATS: { [key: string]: FORMATS };
private _clientID?: string;
private _filePath?: string;
axios: AxiosInstance;
saveClientID = process.env.SAVE_CLIENT_ID
? process.env.SAVE_CLIENT_ID.toLowerCase() === "true"
: false;
stripMobilePrefix: boolean;
convertFirebaseLinks: boolean;
constructor(options?: SCDLOptions) {
if (!options) options = {};
if (options.saveClientID) {
this.saveClientID = options.saveClientID;
if (options.filePath) this._filePath = options.filePath;
} else {
if (options.clientID) {
this._clientID = options.clientID;
}
}
if (options.axiosInstance) {
this.setAxiosInstance(options.axiosInstance);
} else {
this.setAxiosInstance(axios);
}
if (!options.stripMobilePrefix) options.stripMobilePrefix = true;
if (!options.convertFirebaseLinks) options.convertFirebaseLinks = true;
this.stripMobilePrefix = options.stripMobilePrefix;
this.convertFirebaseLinks = options.convertFirebaseLinks;
}
/**
* Returns a media Transcoding that matches the given predicate object
* @param media - The Transcodings to filter
* @param predicateObj - The desired Transcoding object to match
* @returns An array of Transcodings that match the predicate object
*/
filterMedia(
media: Transcoding[],
predicateObj: FilterPredicateObject
): Transcoding[] {
return filterMedia(media, predicateObj);
}
/**
* Get the audio of a given track. It returns the first format found.
*
* @param url - The URL of the Soundcloud track
* @param useDirectLink - Whether or not to use the download link if the artist has set the track to be downloadable. This has erratic behaviour on some environments.
* @returns A ReadableStream containing the audio data
*/
async download(
url: string,
useDirectLink = true
): Promise<NodeJS.ReadableStream | m3u8stream.Stream> {
return download(
await this.prepareURL(url),
await this.getClientID(),
this.axios,
useDirectLink
);
}
/**
* Get the audio of a given track with the specified format
* @param url - The URL of the Soundcloud track
* @param format - The desired format
*/
async downloadFormat(
url: string,
format: FORMATS
): Promise<NodeJS.ReadableStream | m3u8stream.Stream> {
return downloadFormat(
await this.prepareURL(url),
await this.getClientID(),
format,
this.axios
);
}
/**
* Returns info about a given track.
* @param url - URL of the Soundcloud track
* @returns Info about the track
*/
async getInfo(url: string): Promise<TrackInfo> {
return getInfo(
await this.prepareURL(url),
await this.getClientID(),
this.axios
);
}
/**
* Returns info about the given track(s) specified by ID.
* @param ids - The ID(s) of the tracks
* @returns Info about the track
*/
async getTrackInfoByID(
ids: number[],
playlistID?: number,
playlistSecretToken?: string
): Promise<TrackInfo[]> {
return getTrackInfoByID(
await this.getClientID(),
this.axios,
ids,
playlistID,
playlistSecretToken
);
}
/**
* Returns info about the given set
* @param url - URL of the Soundcloud set
* @returns Info about the set
*/
async getSetInfo(url: string): Promise<SetInfo> {
return getSetInfo(
await this.prepareURL(url),
await this.getClientID(),
this.axios
);
}
/**
* Searches for tracks/playlists for the given query
* @param options - The search option
* @returns SearchResponse
*/
async search(
options: SearchOptions
): Promise<PaginatedQuery<User | SetInfo | TrackInfo>> {
return search(options, this.axios, await this.getClientID());
}
/**
* Finds related tracks to the given track specified by ID
* @param id - The ID of the track
* @param limit - The number of results to return
* @param offset - Used for pagination, set to 0 if you will not use this feature.
*/
async related(
id: number,
limit: number,
offset = 0
): Promise<RelatedResponse<TrackInfo>> {
return related(id, limit, offset, this.axios, await this.getClientID());
}
/**
* Returns the audio streams and titles of the tracks in the given playlist.
* @param url - The url of the playlist
*/
async downloadPlaylist(
url: string
): Promise<[NodeJS.ReadableStream[], String[]]> {
return downloadPlaylist(
await this.prepareURL(url),
await this.getClientID(),
this.axios
);
}
/**
* Returns track information for a user's likes
* @param options - Can either be the profile URL of the user, or their ID
* @returns - An array of tracks
*/
async getLikes(options: GetLikesOptions): Promise<PaginatedQuery<Like>> {
let id: number;
const clientID = await this.getClientID();
if (options.id) {
id = options.id;
} else if (options.profileUrl) {
const user = await getUser(
await this.prepareURL(options.profileUrl),
clientID,
this.axios
);
id = user.id;
} else if (options.nextHref) {
return await getLikes(options, clientID, this.axios);
} else {
throw new Error("options.id or options.profileURL must be provided.");
}
options.id = id;
return getLikes(options, clientID, this.axios);
}
/**
* Returns information about a user
* @param url - The profile URL of the user
*/
async getUser(url: string): Promise<User> {
return getUser(
await this.prepareURL(url),
await this.getClientID(),
this.axios
);
}
/**
* Sets the instance of Axios to use to make requests to SoundCloud API
* @param instance - An instance of Axios
*/
setAxiosInstance(instance: AxiosInstance): void {
this.axios = instance;
}
/**
* Returns whether or not the given URL is a valid Soundcloud URL
* @param url - URL of the Soundcloud track
*/
isValidUrl(url: string): boolean {
return isValidURL(url, this.convertFirebaseLinks, this.stripMobilePrefix);
}
/**
* Returns whether or not the given URL is a valid playlist SoundCloud URL
* @param url - The URL to check
*/
isPlaylistURL(url: string): boolean {
return isPlaylistURL(url);
}
/**
* Returns true if the given URL is a personalized track URL. (of the form https://soundcloud.com/discover/sets/personalized-tracks::user-sdlkfjsldfljs:847104873)
* @param url - The URL to check
*/
isPersonalizedTrackURL(url: string): boolean {
return isPersonalizedTrackURL(url);
}
/**
* Returns true if the given URL is a Firebase URL (of the form https://soundcloud.app.goo.gl/XXXXXXXX)
* @param url - The URL to check
*/
isFirebaseURL(url: string): boolean {
return isFirebaseURL(url);
}
async getClientID(): Promise<string> {
let clientId = this._clientID;
if (!clientId) {
clientId = await this.setClientID();
}
return clientId;
}
/** @internal */
async setClientID(clientID?: string): Promise<string> {
if (clientID) {
this._clientID = clientID;
return clientID;
}
if (!this._clientID) {
if (!this.saveClientID) {
this._clientID = await sckey.fetchKey();
} else {
const filename = path.resolve(
__dirname,
this._filePath ? this._filePath : "../client_id.json"
);
const c = await this._getClientIDFromFile(filename);
if (c) {
this._clientID = c;
} else {
this._clientID = await sckey.fetchKey();
const data = {
clientID: this._clientID,
date: new Date().toISOString(),
};
fs.writeFile(filename, JSON.stringify(data), {}, (err) => {
if (err) console.log("Failed to save client_id to file: " + err);
});
}
}
}
const clientId = this._clientID;
if (!clientId) {
throw new Error("Failed to get client ID");
}
return clientId;
}
/** @internal */
private async _getClientIDFromFile(filename: string): Promise<string> {
return new Promise((resolve, reject) => {
if (!fs.existsSync(filename)) return resolve("");
fs.readFile(
filename,
"utf8",
(err: NodeJS.ErrnoException, data: string) => {
if (err) return reject(err);
let c: ClientIDData;
try {
c = JSON.parse(data);
} catch (err) {
return reject(err);
}
if (!c.date && !c.clientID)
return reject(
new Error(
"Property 'data' or 'clientID' missing from client_id.json"
)
);
if (typeof c.clientID !== "string")
return reject(
new Error("Property 'clientID' is not a string in client_id.json")
);
if (typeof c.date !== "string")
return reject(
new Error("Property 'date' is not a string in client_id.json")
);
const d = new Date(c.date);
if (Number.isNaN(d.getDay()))
return reject(
new Error("Invalid date object from 'date' in client_id.json")
);
const dayMs = 60 * 60 * 24 * 1000;
if (new Date().getTime() - d.getTime() >= dayMs) {
// Older than a day, delete
fs.unlink(filename, (err) => {
if (err) console.log("Failed to delete client_id.json: " + err);
});
return resolve("");
} else {
return resolve(c.clientID);
}
}
);
});
}
/**
* Prepares the given URL by stripping its mobile prefix (if this.stripMobilePrefix is true)
* and converting it to a regular URL (if this.convertFireBaseLinks is true.)
* @param url
*/
async prepareURL(url: string): Promise<string> {
if (this.stripMobilePrefix) url = stripMobilePrefix(url);
if (this.convertFirebaseLinks) {
if (isFirebaseURL(url)) url = await convertFirebaseURL(url, this.axios);
}
return url;
}
}
// SCDL instance with default configutarion
const scdl = new SCDL();
// Creates an instance of SCDL with custom configuration
const create = (options: SCDLOptions): SCDL => new SCDL(options);
export { create };
scdl.STREAMING_PROTOCOLS = _PROTOCOLS;
scdl.FORMATS = _FORMATS;
export default scdl;