import async from "async"; import mongoose from "mongoose"; import CoreClass from "../core"; let SongsModule; let CacheModule; let DBModule; let UtilsModule; let YouTubeModule; let StationsModule; let PlaylistsModule; let MediaModule; let WSModule; class _SongsModule extends CoreClass { // eslint-disable-next-line require-jsdoc constructor() { super("songs"); SongsModule = this; } /** * Initialises the songs module * * @returns {Promise} - returns promise (reject, resolve) */ async initialize() { this.setStage(1); CacheModule = this.moduleManager.modules.cache; DBModule = this.moduleManager.modules.db; UtilsModule = this.moduleManager.modules.utils; YouTubeModule = this.moduleManager.modules.youtube; StationsModule = this.moduleManager.modules.stations; PlaylistsModule = this.moduleManager.modules.playlists; MediaModule = this.moduleManager.modules.media; WSModule = this.moduleManager.modules.ws; this.SongModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }); this.SongSchemaCache = await CacheModule.runJob("GET_SCHEMA", { schemaName: "song" }); this.setStage(2); return new Promise((resolve, reject) => { CacheModule.runJob("SUB", { channel: "song.created", cb: async data => WSModule.runJob("EMIT_TO_ROOMS", { rooms: ["import-album", `edit-song.${data.song._id}`, "edit-songs"], args: ["event:admin.song.created", { data }] }) }); async.waterfall( [ next => { this.setStage(2); CacheModule.runJob("HGETALL", { table: "songs" }) .then(songs => { next(null, songs); }) .catch(next); }, (songs, next) => { this.setStage(3); if (!songs) return next(); const youtubeIds = Object.keys(songs); return async.each( youtubeIds, (youtubeId, next) => { SongsModule.SongModel.findOne({ youtubeId }, (err, song) => { if (err) next(err); else if (!song) CacheModule.runJob("HDEL", { table: "songs", key: youtubeId }) .then(() => next()) .catch(next); else next(); }); }, next ); }, next => { this.setStage(4); SongsModule.SongModel.find({}, next); }, (songs, next) => { this.setStage(5); async.each( songs, (song, next) => { CacheModule.runJob("HSET", { table: "songs", key: song.youtubeId, value: SongsModule.SongSchemaCache(song) }) .then(() => next()) .catch(next); }, next ); } ], async err => { if (err) { err = await UtilsModule.runJob("GET_ERROR", { error: err }); reject(new Error(err)); } else resolve(); } ); }); } /** * Gets a song by id from the cache or Mongo, and if it isn't in the cache yet, adds it the cache * * @param {object} payload - object containing the payload * @param {string} payload.songId - the id of the song we are trying to get * @returns {Promise} - returns a promise (resolve, reject) */ GET_SONG(payload) { return new Promise((resolve, reject) => { async.waterfall( [ next => { if (!mongoose.Types.ObjectId.isValid(payload.songId)) return next("songId is not a valid ObjectId."); return CacheModule.runJob("HGET", { table: "songs", key: payload.songId }, this) .then(song => next(null, song)) .catch(next); }, (song, next) => { if (song) return next(true, song); return SongsModule.SongModel.findOne({ _id: payload.songId }, next); }, (song, next) => { if (song) { CacheModule.runJob( "HSET", { table: "songs", key: payload.songId, value: song }, this ).then(song => next(null, song)); } else next("Song not found."); } ], (err, song) => { if (err && err !== true) return reject(new Error(err)); return resolve({ song }); } ); }); } /** * Gets songs by id from Mongo * * @param {object} payload - object containing the payload * @param {string} payload.youtubeIds - the youtube ids of the songs we are trying to get * @returns {Promise} - returns a promise (resolve, reject) */ GET_SONGS(payload) { return new Promise((resolve, reject) => { async.waterfall( [ next => SongsModule.SongModel.find({ youtubeId: { $in: payload.youtubeIds } }, next), (songs, next) => { const youtubeIds = payload.youtubeIds.filter( youtubeId => !songs.find(song => song.youtubeId === youtubeId) ); return YouTubeModule.youtubeVideoModel.find( { youtubeId: { $in: youtubeIds } }, (err, videos) => { if (err) next(err); else { const youtubeVideos = videos.map(video => { const { youtubeId, title, author, duration, thumbnail } = video; return { youtubeId, title, artists: [author], genres: [], tags: [], duration, skipDuration: 0, thumbnail: thumbnail || `https://img.youtube.com/vi/${youtubeId}/mqdefault.jpg`, requestedBy: null, requestedAt: Date.now(), verified: false, youtubeVideoId: video._id }; }); next(null, [...songs, ...youtubeVideos]); } } ); } ], (err, songs) => { if (err && err !== true) return reject(new Error(err)); return resolve({ songs }); } ); }); } /** * Create song * * @param {object} payload - an object containing the payload * @param {string} payload.song - the song object * @param {string} payload.userId - the user id of the person requesting the song * @returns {Promise} - returns a promise (resolve, reject) */ CREATE_SONG(payload) { return new Promise((resolve, reject) => { async.waterfall( [ next => { DBModule.runJob("GET_MODEL", { modelName: "user" }, this) .then(UserModel => { UserModel.findOne( { _id: payload.userId }, { "preferences.anonymousSongRequests": 1 }, next ); }) .catch(next); }, (user, next) => { const song = new SongsModule.SongModel({ ...payload.song, requestedBy: user.preferences.anonymousSongRequests ? null : payload.userId, requestedAt: Date.now() }); if (song.verified) { song.verifiedBy = payload.userId; song.verifiedAt = Date.now(); } song.save({ validateBeforeSave: true }, err => { if (err) return next(err, song); return next(null, song); }); }, (song, next) => { SongsModule.runJob("UPDATE_SONG", { songId: song._id }); return next(null, song); }, (song, next) => { MediaModule.runJob("RECALCULATE_RATINGS", { youtubeId: song.youtubeId }, this) .then(() => next(null, song)) .catch(next); }, (song, next) => { CacheModule.runJob("PUB", { channel: "song.created", value: { song } }) .then(() => next(null, song)) .catch(next); } ], (err, song) => { if (err && err !== true) return reject(new Error(err)); return resolve({ song }); } ); }); } /** * Gets a song from id from Mongo and updates the cache with it * * @param {object} payload - an object containing the payload * @param {string} payload.songId - the id of the song we are trying to update * @param {string} payload.oldStatus - old status of song being updated (optional) * @returns {Promise} - returns a promise (resolve, reject) */ async UPDATE_SONG(payload) { const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this); const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this); return new Promise((resolve, reject) => { async.waterfall( [ next => { SongsModule.SongModel.findOne({ _id: payload.songId }, next); }, (song, next) => { if (!song) { CacheModule.runJob("HDEL", { table: "songs", key: payload.songId }); return next("Song not found."); } return CacheModule.runJob( "HSET", { table: "songs", key: payload.songId, value: song }, this ) .then(() => { const { _id, youtubeId, title, artists, thumbnail, duration, skipDuration, verified } = song; next(null, { _id, youtubeId, title, artists, thumbnail, duration, skipDuration, verified }); }) .catch(next); }, (song, next) => { playlistModel.updateMany({ "songs._id": song._id }, { $set: { "songs.$": song } }, err => { if (err) next(err); else next(null, song); }); }, (song, next) => { playlistModel.updateMany( { "songs.youtubeId": song.youtubeId }, { $set: { "songs.$": song } }, err => { if (err) next(err); else next(null, song); } ); }, (song, next) => { playlistModel.find({ "songs._id": song._id }, (err, playlists) => { if (err) next(err); else { async.eachLimit( playlists, 1, (playlist, next) => { PlaylistsModule.runJob( "UPDATE_PLAYLIST", { playlistId: playlist._id }, this ) .then(() => { next(); }) .catch(err => { next(err); }); }, err => { if (err) next(err); else next(null, song); } ); } }); }, (song, next) => { stationModel.updateMany({ "queue._id": song._id }, { $set: { "queue.$": song } }, err => { if (err) next(err); else next(null, song); }); }, (song, next) => { stationModel.updateMany( { "queue.youtubeId": song.youtubeId }, { $set: { "queue.$": song } }, err => { if (err) next(err); else next(null, song); } ); }, (song, next) => { stationModel.find({ "queue._id": song._id }, (err, stations) => { if (err) next(err); else { async.eachLimit( stations, 1, (station, next) => { StationsModule.runJob("UPDATE_STATION", { stationId: station._id }, this) .then(() => { next(); }) .catch(err => { next(err); }); }, err => { if (err) next(err); else next(null, song); } ); } }); }, (song, next) => { async.eachLimit( song.genres, 1, (genre, next) => { PlaylistsModule.runJob( "AUTOFILL_GENRE_PLAYLIST", { genre, createPlaylist: song.verified }, this ) .then(() => { next(); }) .catch(err => next(err)); }, err => { next(err, song); } ); } ], (err, song) => { if (err && err !== true) return reject(new Error(err)); if (!payload.oldStatus) payload.oldStatus = null; CacheModule.runJob("PUB", { channel: "song.updated", value: { songId: song._id, oldStatus: payload.oldStatus } }); return resolve(song); } ); }); } /** * Gets multiple songs from id from Mongo and updates the cache with it * * @param {object} payload - an object containing the payload * @param {Array} payload.songIds - the ids of the songs we are trying to update * @param {string} payload.oldStatus - old status of song being updated (optional) * @returns {Promise} - returns a promise (resolve, reject) */ async UPDATE_SONGS(payload) { const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this); const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this); return new Promise((resolve, reject) => { async.waterfall( [ // Get songs from Mongo next => { const { songIds } = payload; this.publishProgress({ status: "update", message: `Updating songs (stage 1)` }); SongsModule.SongModel.find({ _id: songIds }, next); }, // Any songs that were not in Mongo, remove from cache, if they're in the cache (songs, next) => { const { songIds } = payload; this.publishProgress({ status: "update", message: `Updating songs (stage 2)` }); async.eachLimit( songIds, 1, (songId, next) => { if (songs.findIndex(song => song._id.toString() === songId) === -1) { // NOTE: could be made lower priority CacheModule.runJob("HDEL", { table: "songs", key: songId }); next(); } else next(); }, () => { next(null, songs); } ); }, // Adds/updates all songs in the cache (songs, next) => { this.publishProgress({ status: "update", message: `Updating songs (stage 3)` }); async.eachLimit( songs, 1, (song, next) => { CacheModule.runJob( "HSET", { table: "songs", key: song._id, value: song }, this ) .then(() => { next(); }) .catch(next); }, () => { next(null, songs); } ); }, // Updates all playlists that the songs are in by setting the new trimmed song (songs, next) => { this.publishProgress({ status: "update", message: `Updating songs (stage 4)` }); const trimmedSongs = songs.map(song => { const { _id, youtubeId, title, artists, thumbnail, duration, verified } = song; return { _id, youtubeId, title, artists, thumbnail, duration, verified }; }); const playlistsToUpdate = new Set(); async.eachLimit( trimmedSongs, 1, (trimmedSong, next) => { async.waterfall( [ next => { playlistModel.updateMany( { "songs._id": trimmedSong._id }, { $set: { "songs.$": trimmedSong } }, next ); }, (res, next) => { playlistModel.find({ "songs._id": trimmedSong._id }, next); }, (playlists, next) => { playlists.forEach(playlist => { playlistsToUpdate.add(playlist._id.toString()); }); next(); } ], next ); }, err => { next(err, songs, playlistsToUpdate); } ); }, // Updates all playlists that the songs are in (songs, playlistsToUpdate, next) => { this.publishProgress({ status: "update", message: `Updating songs (stage 5)` }); async.eachLimit( playlistsToUpdate, 1, (playlistId, next) => { PlaylistsModule.runJob( "UPDATE_PLAYLIST", { playlistId }, this ) .then(() => { next(); }) .catch(err => { next(err); }); }, err => { next(err, songs); } ); }, // Updates all station queues that the songs are in by setting the new trimmed song (songs, next) => { this.publishProgress({ status: "update", message: `Updating songs (stage 6)` }); const stationsToUpdate = new Set(); async.eachLimit( songs, 1, (song, next) => { async.waterfall( [ next => { const { youtubeId, title, artists, thumbnail, duration, verified } = song; stationModel.updateMany( { "queue._id": song._id }, { $set: { "queue.$.youtubeId": youtubeId, "queue.$.title": title, "queue.$.artists": artists, "queue.$.thumbnail": thumbnail, "queue.$.duration": duration, "queue.$.verified": verified } }, next ); }, (res, next) => { stationModel.find({ "queue._id": song._id }, next); }, (stations, next) => { stations.forEach(station => { stationsToUpdate.add(station._id.toString()); }); next(); } ], next ); }, err => { next(err, songs, stationsToUpdate); } ); }, // Updates all playlists that the songs are in (songs, stationsToUpdate, next) => { this.publishProgress({ status: "update", message: `Updating songs (stage 7)` }); async.eachLimit( stationsToUpdate, 1, (stationId, next) => { StationsModule.runJob( "UPDATE_STATION", { stationId }, this ) .then(() => { next(); }) .catch(err => { next(err); }); }, err => { next(err, songs); } ); }, // Autofill the genre playlists of all genres of all songs (songs, next) => { this.publishProgress({ status: "update", message: `Updating songs (stage 8)` }); const genresToAutofill = new Set(); songs.forEach(song => { if (song.verified) song.genres.forEach(genre => { genresToAutofill.add(genre); }); }); async.eachLimit( genresToAutofill, 1, (genre, next) => { PlaylistsModule.runJob("AUTOFILL_GENRE_PLAYLIST", { genre, createPlaylist: true }, this) .then(() => { next(); }) .catch(err => next(err)); }, err => { next(err, songs); } ); }, // Send event that the song was updated (songs, next) => { this.publishProgress({ status: "update", message: `Updating songs (stage 9)` }); async.eachLimit( songs, 1, (song, next) => { CacheModule.runJob("PUB", { channel: "song.updated", value: { songId: song._id, oldStatus: null } }); next(); }, () => { next(); } ); } ], err => { if (err && err !== true) return reject(new Error(err)); return resolve(); } ); }); } /** * Updates all songs * * @returns {Promise} - returns a promise (resolve, reject) */ UPDATE_ALL_SONGS() { return new Promise((resolve, reject) => { async.waterfall( [ next => { SongsModule.SongModel.find({}, next); }, (songs, next) => { let index = 0; const { length } = songs; async.eachLimit( songs, 2, (song, next) => { index += 1; console.log(`Updating song #${index} out of ${length}: ${song._id}`); this.publishProgress({ status: "update", message: `Updating song "${song._id}"` }); SongsModule.runJob("UPDATE_SONG", { songId: song._id }, this) .then(() => { next(); }) .catch(err => { next(err); }); }, err => { next(err); } ); } ], err => { if (err && err !== true) return reject(new Error(err)); return resolve(); } ); }); } // /** // * Deletes song from id from Mongo and cache // * // * @param {object} payload - returns an object containing the payload // * @param {string} payload.songId - the song id of the song we are trying to delete // * @returns {Promise} - returns a promise (resolve, reject) // */ // DELETE_SONG(payload) { // return new Promise((resolve, reject) => // async.waterfall( // [ // next => { // SongsModule.SongModel.deleteOne({ _id: payload.songId }, next); // }, // next => { // CacheModule.runJob( // "HDEL", // { // table: "songs", // key: payload.songId // }, // this // ) // .then(() => next()) // .catch(next); // }, // next => { // this.log("INFO", `Going to update playlists and stations now for deleted song ${payload.songId}`); // DBModule.runJob("GET_MODEL", { modelName: "playlist" }).then(playlistModel => { // playlistModel.find({ "songs._id": song._id }, (err, playlists) => { // if (err) this.log("ERROR", err); // else { // playlistModel.updateMany( // { "songs._id": payload.songId }, // { $pull: { "songs.$._id": payload.songId} }, // err => { // if (err) this.log("ERROR", err); // else { // playlists.forEach(playlist => { // PlaylistsModule.runJob("UPDATE_PLAYLIST", { // playlistId: playlist._id // }); // }); // } // } // ); // } // }); // }); // DBModule.runJob("GET_MODEL", { modelName: "station" }).then(stationModel => { // stationModel.find({ "queue._id": payload.songId }, (err, stations) => { // stationModel.updateMany( // { "queue._id": payload.songId }, // { // $pull: { "queue._id": } // }, // err => { // if (err) this.log("ERROR", err); // else { // stations.forEach(station => { // StationsModule.runJob("UPDATE_STATION", { stationId: station._id }); // }); // } // } // ); // }); // }); // } // ], // err => { // if (err && err !== true) return reject(new Error(err)); // return resolve(); // } // ) // ); // } /** * Searches through songs * * @param {object} payload - object that contains the payload * @param {string} payload.query - the query * @param {string} payload.includeUnverified - include unverified songs * @param {string} payload.includeVerified - include verified songs * @param {string} payload.trimmed - include trimmed songs * @param {string} payload.page - page (default 1) * @returns {Promise} - returns promise (reject, resolve) */ SEARCH(payload) { return new Promise((resolve, reject) => { async.waterfall( [ next => { const isVerified = []; if (payload.includeUnverified) isVerified.push(false); if (payload.includeVerified) isVerified.push(true); if (isVerified.length === 0) return next("No verified status has been included."); let { query } = payload; const isRegex = query.length > 2 && query.indexOf("/") === 0 && query.lastIndexOf("/") === query.length - 1; if (isRegex) query = query.slice(1, query.length - 1); else query = query.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&"); const filterArray = [ { title: new RegExp(`${query}`, "i"), verified: { $in: isVerified } }, { artists: new RegExp(`${query}`, "i"), verified: { $in: isVerified } } ]; return next(null, filterArray); }, (filterArray, next) => { const page = payload.page ? payload.page : 1; const pageSize = 15; const skipAmount = pageSize * (page - 1); SongsModule.SongModel.find({ $or: filterArray }).count((err, count) => { if (err) next(err); else { SongsModule.SongModel.find({ $or: filterArray }) .skip(skipAmount) .limit(pageSize) .exec((err, songs) => { if (err) next(err); else { next(null, { songs, page, pageSize, skipAmount, count }); } }); } }); }, (data, next) => { if (data.songs.length === 0) next("No songs found"); else if (payload.trimmed) { next(null, { songs: data.songs.map(song => { const { _id, youtubeId, title, artists, thumbnail, duration, verified } = song; return { _id, youtubeId, title, artists, thumbnail, duration, verified }; }), ...data }); } else next(null, data); } ], (err, data) => { if (err && err !== true) return reject(new Error(err)); return resolve(data); } ); }); } /** * Gets an array of all genres * * @returns {Promise} - returns a promise (resolve, reject) */ GET_ALL_GENRES() { return new Promise((resolve, reject) => { async.waterfall( [ next => { SongsModule.SongModel.find({ verified: true }, { genres: 1, _id: false }, next); }, (songs, next) => { let allGenres = []; songs.forEach(song => { allGenres = allGenres.concat(song.genres); }); const lowerCaseGenres = allGenres.map(genre => genre.toLowerCase()); const uniqueGenres = lowerCaseGenres.filter( (value, index, self) => self.indexOf(value) === index ); next(null, uniqueGenres); } ], (err, genres) => { if (err && err !== true) return reject(new Error(err)); return resolve({ genres }); } ); }); } /** * Gets an array of all songs with a specific genre * * @param {object} payload - returns an object containing the payload * @param {string} payload.genre - the genre * @returns {Promise} - returns a promise (resolve, reject) */ GET_ALL_SONGS_WITH_GENRE(payload) { return new Promise((resolve, reject) => { async.waterfall( [ next => { SongsModule.SongModel.find( { verified: true, genres: { $regex: new RegExp(`^${payload.genre.toLowerCase()}$`, "i") } }, next ); } ], (err, songs) => { if (err && err !== true) return reject(new Error(err)); return resolve({ songs }); } ); }); } // runjob songs GET_ORPHANED_PLAYLIST_SONGS {} /** * Gets a orphaned playlist songs * * @returns {Promise} - returns promise (reject, resolve) */ GET_ORPHANED_PLAYLIST_SONGS() { return new Promise((resolve, reject) => { DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this).then(playlistModel => { playlistModel.find({}, (err, playlists) => { if (err) reject(new Error(err)); else { SongsModule.SongModel.find({}, { _id: true, youtubeId: true }, (err, songs) => { if (err) reject(new Error(err)); else { const songIds = songs.map(song => song._id.toString()); const orphanedYoutubeIds = new Set(); async.eachLimit( playlists, 1, (playlist, next) => { playlist.songs.forEach(song => { if ( (!song._id || songIds.indexOf(song._id.toString() === -1)) && !orphanedYoutubeIds.has(song.youtubeId) ) { orphanedYoutubeIds.add(song.youtubeId); } }); next(); }, () => { resolve({ youtubeIds: Array.from(orphanedYoutubeIds) }); } ); } }); } }); }); }); } /** * Requests all orphaned playlist songs, adding them to the database * * @returns {Promise} - returns promise (reject, resolve) */ REQUEST_ORPHANED_PLAYLIST_SONGS() { return new Promise((resolve, reject) => { DBModule.runJob("GET_MODEL", { modelName: "playlist" }) .then(playlistModel => { SongsModule.runJob("GET_ORPHANED_PLAYLIST_SONGS", {}, this).then(response => { const { youtubeIds } = response; const playlistsToUpdate = new Set(); async.eachLimit( youtubeIds, 1, (youtubeId, next) => { async.waterfall( [ next => { this.publishProgress({ status: "update", message: `Requesting "${youtubeId}"` }); console.log( youtubeId, `this is song ${youtubeIds.indexOf(youtubeId) + 1}/${youtubeIds.length}` ); setTimeout(next, 150); }, next => { MediaModule.runJob("GET_MEDIA", { youtubeId }, this) .then(res => next(null, res.song)) .catch(next); }, (song, next) => { const { _id, title, artists, thumbnail, duration, verified } = song; const trimmedSong = { _id, youtubeId, title, artists, thumbnail, duration, verified }; playlistModel.updateMany( { "songs.youtubeId": song.youtubeId }, { $set: { "songs.$": trimmedSong } }, err => { next(err, song); } ); }, (song, next) => { playlistModel.find({ "songs._id": song._id }, next); }, (playlists, next) => { playlists.forEach(playlist => { playlistsToUpdate.add(playlist._id.toString()); }); next(); } ], next ); }, err => { if (err) reject(err); else { async.eachLimit( Array.from(playlistsToUpdate), 1, (playlistId, next) => { PlaylistsModule.runJob( "UPDATE_PLAYLIST", { playlistId }, this ) .then(() => { next(); }) .catch(next); }, err => { if (err) reject(err); else resolve(); } ); } } ); }); }) .catch(reject); }); } /** * Gets a list of all genres * * @returns {Promise} - returns promise (reject, resolve) */ GET_GENRES() { return new Promise((resolve, reject) => { async.waterfall( [ next => { SongsModule.SongModel.distinct("genres", next); } ], (err, genres) => { if (err) reject(err); resolve({ genres }); } ); }); } /** * Gets a list of all artists * * @returns {Promise} - returns promise (reject, resolve) */ GET_ARTISTS() { return new Promise((resolve, reject) => { async.waterfall( [ next => { SongsModule.SongModel.distinct("artists", next); } ], (err, artists) => { if (err) reject(err); resolve({ artists }); } ); }); } /** * Gets a list of all tags * * @returns {Promise} - returns promise (reject, resolve) */ GET_TAGS() { return new Promise((resolve, reject) => { async.waterfall( [ next => { SongsModule.SongModel.distinct("tags", next); } ], (err, tags) => { if (err) reject(err); resolve({ tags }); } ); }); } } export default new _SongsModule();