UNPKG

@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
// @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;