@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
text/typescript
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