UNPKG

@nasheedstation/soundcloud-downloader

Version:

Download Soundcloud audio with Node.js, this is a forked version, the original script was written by @zackradisic

342 lines (294 loc) 11.9 kB
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) 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> { if (!this._clientID) { await this.setClientID() } return this._clientID } /** @internal */ async setClientID (clientID?: string): Promise<string> { if (!clientID) { if (!this._clientID) { if (this.saveClientID) { const filename = path.resolve(__dirname, this._filePath ? this._filePath : '../client_id.json') const c = await this._getClientIDFromFile(filename) if (!c) { 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) }) } else { this._clientID = c } } else { this._clientID = await sckey.fetchKey() } } return this._clientID } this._clientID = clientID 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