123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696 |
- import mongoose from "mongoose";
- import async from "async";
- import config from "config";
- import sckey from "soundcloud-key-fetch";
- import * as rax from "retry-axios";
- import axios from "axios";
- import CoreClass from "../core";
- let SoundCloudModule;
- let DBModule;
- let CacheModule;
- let MediaModule;
- const soundcloudTrackObjectToMusareTrackObject = soundcloudTrackObject => {
- const {
- id,
- title,
- artwork_url: artworkUrl,
- created_at: createdAt,
- duration,
- genre,
- kind,
- license,
- likes_count: likesCount,
- playback_count: playbackCount,
- public: _public,
- tag_list: tagList,
- user_id: userId,
- user,
- track_format: trackFormat,
- permalink,
- monetization_model: monetizationModel,
- policy,
- streamable,
- sharing,
- state,
- embeddable_by: embeddableBy
- } = soundcloudTrackObject;
- return {
- trackId: id,
- title,
- artworkUrl,
- soundcloudCreatedAt: new Date(createdAt),
- duration: duration / 1000,
- genre,
- kind,
- license,
- likesCount,
- playbackCount,
- public: _public,
- tagList,
- userId,
- username: user.username,
- userPermalink: user.permalink,
- trackFormat,
- permalink,
- monetizationModel,
- policy,
- streamable,
- sharing,
- state,
- embeddableBy
- };
- };
- class RateLimitter {
- /**
- * Constructor
- * @param {number} timeBetween - The time between each allowed YouTube request
- */
- constructor(timeBetween) {
- this.dateStarted = Date.now();
- this.timeBetween = timeBetween;
- }
- /**
- * Returns a promise that resolves whenever the ratelimit of a YouTube request is done
- * @returns {Promise} - promise that gets resolved when the rate limit allows it
- */
- continue() {
- return new Promise(resolve => {
- if (Date.now() - this.dateStarted >= this.timeBetween) resolve();
- else setTimeout(resolve, this.dateStarted + this.timeBetween - Date.now());
- });
- }
- /**
- * Restart the rate limit timer
- */
- restart() {
- this.dateStarted = Date.now();
- }
- }
- class _SoundCloudModule extends CoreClass {
- // eslint-disable-next-line require-jsdoc
- constructor() {
- super("soundcloud");
- SoundCloudModule = this;
- }
- /**
- * Initialises the soundcloud module
- * @returns {Promise} - returns promise (reject, resolve)
- */
- async initialize() {
- DBModule = this.moduleManager.modules.db;
- CacheModule = this.moduleManager.modules.cache;
- MediaModule = this.moduleManager.modules.media;
- this.soundcloudTrackModel = this.SoundCloudTrackModel = await DBModule.runJob("GET_MODEL", {
- modelName: "soundcloudTrack"
- });
- return new Promise((resolve, reject) => {
- this.rateLimiter = new RateLimitter(config.get("apis.soundcloud.rateLimit"));
- this.requestTimeout = config.get("apis.soundcloud.requestTimeout");
- this.axios = axios.create();
- this.axios.defaults.raxConfig = {
- instance: this.axios,
- retry: config.get("apis.soundcloud.retryAmount"),
- noResponseRetries: config.get("apis.soundcloud.retryAmount")
- };
- rax.attach(this.axios);
- this.apiKey = null;
- SoundCloudModule.runJob("TEST_SOUNDCLOUD_API_KEY", {}, null, -1)
- .then(result => {
- if (result) {
- resolve();
- return;
- }
- SoundCloudModule.runJob("GENERATE_SOUNDCLOUD_API_KEY", {}, null, -1)
- .then(() => {
- resolve();
- })
- .catch(reject);
- })
- .catch(reject);
- });
- }
- /**
- * Generates/fetches a new SoundCloud API key
- * @returns {Promise} - returns promise (reject, resolve)
- */
- GENERATE_SOUNDCLOUD_API_KEY() {
- return new Promise((resolve, reject) => {
- this.log("INFO", "Fetching new SoundCloud API key.");
- sckey
- .fetchKey()
- .then(soundcloudApiKey => {
- if (!soundcloudApiKey) {
- this.log("ERROR", "Couldn't fetch new SoundCloud API key.");
- reject(new Error("Couldn't fetch SoundCloud key."));
- return;
- }
- SoundCloudModule.soundcloudApiKey = soundcloudApiKey;
- CacheModule.runJob("SET", { key: "soundcloudApiKey", value: soundcloudApiKey }, this)
- .then(() => {
- SoundCloudModule.runJob("TEST_SOUNDCLOUD_API_KEY", {}, this).then(result => {
- if (!result) {
- this.log("ERROR", "Fetched SoundCloud API key is invalid.");
- reject(new Error("SoundCloud key isn't valid."));
- } else {
- this.log("INFO", "Fetched new valid SoundCloud API key.");
- resolve();
- }
- });
- })
- .catch(err => {
- this.log("ERROR", `Couldn't set new SoundCloud API key in cache. Error: ${err.message}`);
- reject(err);
- });
- })
- .catch(err => {
- this.log("ERROR", `Couldn't fetch new SoundCloud API key. Error: ${err.message}`);
- reject(new Error("Couldn't fetch SoundCloud key."));
- });
- });
- }
- /**
- * Tests the stored SoundCloud API key
- * @returns {Promise} - returns promise (reject, resolve)
- */
- TEST_SOUNDCLOUD_API_KEY() {
- return new Promise((resolve, reject) => {
- this.log("INFO", "Testing SoundCloud API key.");
- CacheModule.runJob("GET", { key: "soundcloudApiKey" }, this).then(soundcloudApiKey => {
- if (!soundcloudApiKey) {
- this.log("ERROR", "No SoundCloud API key found in cache.");
- resolve(false);
- return;
- }
- SoundCloudModule.soundcloudApiKey = soundcloudApiKey;
- sckey
- .testKey(soundcloudApiKey)
- .then(res => {
- this.log("INFO", `Tested SoundCloud API key. Result: ${res}`);
- resolve(res);
- })
- .catch(err => {
- this.log("ERROR", `Testing SoundCloud API key error: ${err.message}`);
- reject(err);
- });
- });
- });
- }
- /**
- * Perform SoundCloud API get track request
- * @param {object} payload - object that contains the payload
- * @param {string} payload.trackId - the SoundCloud track id to get
- * @returns {Promise} - returns promise (reject, resolve)
- */
- API_GET_TRACK(payload) {
- return new Promise((resolve, reject) => {
- const { trackId } = payload;
- SoundCloudModule.runJob(
- "API_CALL",
- {
- url: `https://api-v2.soundcloud.com/tracks/${trackId}`
- },
- this
- )
- .then(response => {
- resolve(response);
- })
- .catch(err => {
- reject(err);
- });
- });
- }
- /**
- * Perform SoundCloud API call
- * @param {object} payload - object that contains the payload
- * @param {string} payload.url - request url
- * @param {object} payload.params - request parameters
- * @returns {Promise} - returns promise (reject, resolve)
- */
- API_CALL(payload) {
- return new Promise((resolve, reject) => {
- const { url } = payload;
- const { soundcloudApiKey } = SoundCloudModule;
- const params = {
- client_id: soundcloudApiKey
- };
- SoundCloudModule.axios
- .get(url, {
- params,
- timeout: SoundCloudModule.requestTimeout
- })
- .then(response => {
- if (response.data.error) {
- reject(new Error(response.data.error));
- } else {
- resolve({ response });
- }
- })
- .catch(err => {
- reject(err);
- });
- // }
- });
- }
- /**
- * Create SoundCloud track
- * @param {object} payload - an object containing the payload
- * @param {object} payload.soundcloudTrack - the soundcloudTrack object
- * @returns {Promise} - returns a promise (resolve, reject)
- */
- CREATE_TRACK(payload) {
- return new Promise((resolve, reject) => {
- async.waterfall(
- [
- next => {
- const { soundcloudTrack } = payload;
- if (typeof soundcloudTrack !== "object") next("Invalid soundcloudTrack type");
- else {
- SoundCloudModule.soundcloudTrackModel.insertMany(soundcloudTrack, next);
- }
- },
- (soundcloudTracks, next) => {
- const mediaSources = soundcloudTracks.map(
- soundcloudTrack => `soundcloud:${soundcloudTrack.trackId}`
- );
- async.eachLimit(
- mediaSources,
- 2,
- (mediaSource, next) => {
- MediaModule.runJob("RECALCULATE_RATINGS", { mediaSource }, this)
- .then(() => next())
- .catch(next);
- },
- err => {
- if (err) next(err);
- else next(null, soundcloudTracks);
- }
- );
- }
- ],
- (err, soundcloudTracks) => {
- if (err) reject(new Error(err));
- else resolve({ soundcloudTracks });
- }
- );
- });
- }
- /**
- * Get SoundCloud track
- * @param {object} payload - an object containing the payload
- * @param {string} payload.identifier - the soundcloud track ObjectId or track id
- * @param {boolean} payload.createMissing - attempt to fetch and create track if not in db
- * @returns {Promise} - returns a promise (resolve, reject)
- */
- GET_TRACK(payload) {
- return new Promise((resolve, reject) => {
- async.waterfall(
- [
- next => {
- const query = mongoose.isObjectIdOrHexString(payload.identifier)
- ? { _id: payload.identifier }
- : { trackId: payload.identifier };
- return SoundCloudModule.soundcloudTrackModel.findOne(query, next);
- },
- (track, next) => {
- if (track) return next(null, track, false);
- if (mongoose.isObjectIdOrHexString(payload.identifier) || !payload.createMissing)
- return next("SoundCloud track not found.");
- return SoundCloudModule.runJob("API_GET_TRACK", { trackId: payload.identifier }, this)
- .then(({ response }) => {
- const { data } = response;
- if (!data || !data.id)
- return next("The specified track does not exist or cannot be publicly accessed.");
- const soundcloudTrack = soundcloudTrackObjectToMusareTrackObject(data);
- return next(null, false, soundcloudTrack);
- })
- .catch(next);
- },
- (track, soundcloudTrack, next) => {
- if (track) return next(null, track, true);
- return SoundCloudModule.runJob("CREATE_TRACK", { soundcloudTrack }, this)
- .then(res => {
- if (res.soundcloudTracks.length === 1) next(null, res.soundcloudTracks[0], false);
- else next("SoundCloud track not found.");
- })
- .catch(next);
- }
- ],
- (err, track, existing) => {
- if (err) reject(new Error(err));
- else if (track.policy === "SNIP") reject(new Error("Track is premium-only."));
- else resolve({ track, existing });
- }
- );
- });
- }
- /**
- * Tries to get a SoundCloud track from a URL
- * @param {object} payload - object that contains the payload
- * @param {string} payload.identifier - the SoundCloud track URL
- * @returns {Promise} - returns promise (reject, resolve)
- */
- GET_TRACK_FROM_URL(payload) {
- return new Promise((resolve, reject) => {
- const scRegex =
- /soundcloud\.com\/(?<userPermalink>[A-Za-z0-9]+([-_][A-Za-z0-9]+)*)\/(?<permalink>[A-Za-z0-9]+(?:[-_][A-Za-z0-9]+)*)/;
- async.waterfall(
- [
- next => {
- const match = scRegex.exec(payload.identifier);
- if (!match || !match.groups) {
- next("Invalid SoundCloud URL.");
- return;
- }
- const { userPermalink, permalink } = match.groups;
- SoundCloudModule.soundcloudTrackModel.findOne({ userPermalink, permalink }, next);
- },
- (_dbTrack, next) => {
- if (_dbTrack) {
- next(null, _dbTrack, true);
- return;
- }
- SoundCloudModule.runJob("API_RESOLVE", { url: payload.identifier }, this)
- .then(({ response }) => {
- const { data } = response;
- if (!data || !data.id) {
- next("The provided URL does not exist or cannot be accessed.");
- return;
- }
- if (data.kind !== "track") {
- next(`Invalid URL provided. Kind got: ${data.kind}.`);
- return;
- }
- // TODO get more data here
- const { id: trackId } = data;
- SoundCloudModule.soundcloudTrackModel.findOne({ trackId }, (err, dbTrack) => {
- if (err) next(err);
- else if (dbTrack) {
- next(null, dbTrack, true);
- } else {
- const soundcloudTrack = soundcloudTrackObjectToMusareTrackObject(data);
- SoundCloudModule.runJob("CREATE_TRACK", { soundcloudTrack }, this)
- .then(res => {
- if (res.soundcloudTracks.length === 1)
- next(null, res.soundcloudTracks[0], false);
- else next("SoundCloud track not found.");
- })
- .catch(next);
- }
- });
- })
- .catch(next);
- }
- ],
- (err, track, existing) => {
- if (err) reject(new Error(err));
- else if (track.policy === "SNIP") reject(new Error("Track is premium-only."));
- else resolve({ track, existing });
- }
- );
- });
- }
- /**
- * Returns an array of songs taken from a SoundCloud playlist
- * @param {object} payload - object that contains the payload
- * @param {string} payload.url - the url of the SoundCloud playlist
- * @returns {Promise} - returns promise (reject, resolve)
- */
- GET_PLAYLIST(payload) {
- return new Promise((resolve, reject) => {
- async.waterfall(
- [
- next => {
- SoundCloudModule.runJob("API_RESOLVE", { url: payload.url }, this)
- .then(async ({ response }) => {
- const { data } = response;
- if (!data || !data.id)
- return next("The provided URL does not exist or cannot be accessed.");
- let tracks;
- if (data.kind === "user")
- tracks = (
- await SoundCloudModule.runJob(
- "GET_ARTIST_TRACKS",
- {
- artistId: data.id
- },
- this
- )
- ).tracks;
- else if (data.kind !== "playlist" && data.kind !== "system-playlist")
- return next(`Invalid URL provided. Kind got: ${data.kind}.`);
- else tracks = data.tracks;
- const soundcloudTrackIds = tracks.map(track => track.id);
- return next(null, soundcloudTrackIds);
- })
- .catch(next);
- }
- ],
- (err, soundcloudTrackIds) => {
- if (err && err !== true) {
- SoundCloudModule.log(
- "ERROR",
- "GET_PLAYLIST",
- "Some error has occurred.",
- typeof err === "string" ? err : err.message
- );
- reject(new Error(typeof err === "string" ? err : err.message));
- } else {
- resolve({ songs: soundcloudTrackIds });
- }
- }
- );
- // kind;
- });
- }
- /**
- * Returns an array of songs taken from a SoundCloud artist
- * @param {object} payload - object that contains the payload
- * @param {string} payload.artistId - the id of the SoundCloud artist
- * @returns {Promise} - returns promise (reject, resolve)
- */
- GET_ARTIST_TRACKS(payload) {
- return new Promise((resolve, reject) => {
- async.waterfall(
- [
- next => {
- let first = true;
- let nextHref = null;
- let tracks = [];
- async.whilst(
- next => {
- if (nextHref || first) next(null, true);
- else next(null, false);
- },
- next => {
- let job;
- if (first) {
- job = SoundCloudModule.runJob(
- "API_GET_ARTIST_TRACKS",
- { artistId: payload.artistId },
- this
- );
- first = false;
- } else job = SoundCloudModule.runJob("API_GET_ARTIST_TRACKS", { nextHref }, this);
- job.then(({ response }) => {
- const { data } = response;
- const { collection, next_href: _nextHref } = data;
- nextHref = _nextHref;
- tracks = tracks.concat(collection);
- setTimeout(() => {
- next();
- }, 500);
- }).catch(err => {
- next(err);
- });
- },
- err => {
- if (err) return next(err);
- return next(null, tracks);
- }
- );
- }
- ],
- (err, tracks) => {
- if (err && err !== true) {
- SoundCloudModule.log(
- "ERROR",
- "GET_ARTIST_TRACKS",
- "Some error has occurred.",
- typeof err === "string" ? err : err.message
- );
- reject(new Error(typeof err === "string" ? err : err.message));
- } else {
- resolve({ tracks });
- }
- }
- );
- });
- }
- /**
- * Get Soundcloud artists
- * @param {object} payload - an object containing the payload
- * @param {Array} payload.userPermalinks - an array of Soundcloud user permalinks
- * @returns {Promise} - returns a promise (resolve, reject)
- */
- async GET_ARTISTS_FROM_PERMALINKS(payload) {
- const getArtists = async userPermalinks => {
- const jobsToRun = [];
- userPermalinks.forEach(userPermalink => {
- const url = `https://soundcloud.com/${userPermalink}`;
- jobsToRun.push(SoundCloudModule.runJob("API_RESOLVE", { url }, this));
- });
- const jobResponses = await Promise.all(jobsToRun);
- return jobResponses
- .map(jobResponse => jobResponse.response.data)
- .map(artist => ({
- artistId: artist.id,
- username: artist.username,
- avatarUrl: artist.avatar_url,
- permalink: artist.permalink,
- rawData: artist
- }));
- };
- const { userPermalinks } = payload;
- const existingArtists = [];
- const existingUserPermalinks = existingArtists.map(existingArtists => existingArtists.userPermalink);
- // const existingArtistsObjectIds = existingArtists.map(existingArtists => existingArtists._id.toString());
- if (userPermalinks.length === existingArtists.length) return { artists: existingArtists };
- const missingUserPermalinks = userPermalinks.filter(
- userPermalink => existingUserPermalinks.indexOf(userPermalink) === -1
- );
- if (missingUserPermalinks.length === 0) return { videos: existingArtists };
- const newArtists = await getArtists(missingUserPermalinks);
- // await SoundcloudModule.soundcloudArtistsModel.insertMany(newArtists);
- return { artists: existingArtists.concat(newArtists) };
- }
- /**
- * @param {object} payload - object that contains the payload
- * @param {string} payload.url - the url of the SoundCloud resource
- * @returns {Promise} - returns a promise (resolve, reject)
- */
- API_RESOLVE(payload) {
- return new Promise((resolve, reject) => {
- const { url } = payload;
- SoundCloudModule.runJob(
- "API_CALL",
- {
- url: `https://api-v2.soundcloud.com/resolve?url=${encodeURIComponent(url)}`
- },
- this
- )
- .then(response => {
- resolve(response);
- })
- .catch(err => {
- reject(err);
- });
- });
- }
- /**
- * Calls the API_CALL with the proper URL to get artist/user tracks
- * @param {object} payload - object that contains the payload
- * @param {string} payload.artistId - the id of the SoundCloud artist
- * @param {string} payload.nextHref - the next url to call
- * @returns {Promise} - returns a promise (resolve, reject)
- */
- API_GET_ARTIST_TRACKS(payload) {
- return new Promise((resolve, reject) => {
- const { artistId, nextHref } = payload;
- SoundCloudModule.runJob(
- "API_CALL",
- {
- url: artistId
- ? `https://api-v2.soundcloud.com/users/${artistId}/tracks?access=playable&limit=50`
- : nextHref
- },
- this
- )
- .then(response => {
- resolve(response);
- })
- .catch(err => {
- reject(err);
- });
- });
- }
- }
- export default new _SoundCloudModule();
|