playlists.js 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332
  1. import async from "async";
  2. import CoreClass from "../core";
  3. let PlaylistsModule;
  4. let StationsModule;
  5. let SongsModule;
  6. let CacheModule;
  7. let DBModule;
  8. let UtilsModule;
  9. let MediaModule;
  10. let WSModule;
  11. class _PlaylistsModule extends CoreClass {
  12. // eslint-disable-next-line require-jsdoc
  13. constructor() {
  14. super("playlists");
  15. PlaylistsModule = this;
  16. }
  17. /**
  18. * Initialises the playlists module
  19. *
  20. * @returns {Promise} - returns promise (reject, resolve)
  21. */
  22. async initialize() {
  23. this.setStage(1);
  24. StationsModule = this.moduleManager.modules.stations;
  25. CacheModule = this.moduleManager.modules.cache;
  26. DBModule = this.moduleManager.modules.db;
  27. UtilsModule = this.moduleManager.modules.utils;
  28. SongsModule = this.moduleManager.modules.songs;
  29. MediaModule = this.moduleManager.modules.media;
  30. WSModule = this.moduleManager.modules.ws;
  31. this.playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" });
  32. this.playlistSchemaCache = await CacheModule.runJob("GET_SCHEMA", { schemaName: "playlist" });
  33. CacheModule.runJob("SUB", {
  34. channel: "playlist.updated",
  35. cb: async data => {
  36. PlaylistsModule.playlistModel.findOne(
  37. { _id: data.playlistId },
  38. ["_id", "displayName", "type", "privacy", "songs", "createdBy", "createdAt", "createdFor"],
  39. (err, playlist) => {
  40. const newPlaylist = {
  41. ...playlist._doc,
  42. songsCount: playlist.songs.length,
  43. songsLength: playlist.songs.reduce(
  44. (previous, current) => ({
  45. duration: previous.duration + current.duration
  46. }),
  47. { duration: 0 }
  48. ).duration
  49. };
  50. delete newPlaylist.songs;
  51. WSModule.runJob("EMIT_TO_ROOM", {
  52. room: "admin.playlists",
  53. args: ["event:admin.playlist.updated", { data: { playlist: newPlaylist } }]
  54. });
  55. }
  56. );
  57. }
  58. });
  59. this.setStage(2);
  60. return new Promise((resolve, reject) => {
  61. async.waterfall(
  62. [
  63. next => {
  64. this.setStage(3);
  65. CacheModule.runJob("HGETALL", { table: "playlists" })
  66. .then(playlists => {
  67. next(null, playlists);
  68. })
  69. .catch(next);
  70. },
  71. (playlists, next) => {
  72. this.setStage(4);
  73. if (!playlists) return next();
  74. const playlistIds = Object.keys(playlists);
  75. return async.each(
  76. playlistIds,
  77. (playlistId, next) => {
  78. PlaylistsModule.playlistModel.findOne({ _id: playlistId }, (err, playlist) => {
  79. if (err) next(err);
  80. else if (!playlist) {
  81. CacheModule.runJob("HDEL", {
  82. table: "playlists",
  83. key: playlistId
  84. })
  85. .then(() => next())
  86. .catch(next);
  87. } else next();
  88. });
  89. },
  90. next
  91. );
  92. },
  93. next => {
  94. this.setStage(5);
  95. PlaylistsModule.playlistModel.find({}, next);
  96. },
  97. (playlists, next) => {
  98. this.setStage(6);
  99. async.each(
  100. playlists,
  101. (playlist, cb) => {
  102. CacheModule.runJob("HSET", {
  103. table: "playlists",
  104. key: playlist._id,
  105. value: PlaylistsModule.playlistSchemaCache(playlist)
  106. })
  107. .then(() => cb())
  108. .catch(next);
  109. },
  110. next
  111. );
  112. }
  113. ],
  114. async err => {
  115. if (err) {
  116. const formattedErr = await UtilsModule.runJob("GET_ERROR", {
  117. error: err
  118. });
  119. reject(new Error(formattedErr));
  120. } else {
  121. resolve();
  122. }
  123. }
  124. );
  125. });
  126. }
  127. /**
  128. * Returns a list of playlists that include a specific song
  129. *
  130. * @param {object} payload - object that contains the payload
  131. * @param {string} payload.songId - the song id
  132. * @param {string} payload.includeSongs - include the songs
  133. * @returns {Promise} - returns promise (reject, resolve)
  134. */
  135. GET_PLAYLISTS_WITH_SONG(payload) {
  136. return new Promise((resolve, reject) => {
  137. const includeObject = payload.includeSongs ? null : { songs: false };
  138. PlaylistsModule.playlistModel.find({ "songs._id": payload.songId }, includeObject, (err, playlists) => {
  139. if (err) reject(err);
  140. else resolve({ playlists });
  141. });
  142. });
  143. }
  144. /**
  145. * Creates a playlist owned by a user
  146. *
  147. * @param {object} payload - object that contains the payload
  148. * @param {string} payload.userId - the id of the user to create the playlist for
  149. * @param {string} payload.displayName - the display name of the playlist
  150. * @param {string} payload.type - the type of the playlist
  151. * @returns {Promise} - returns promise (reject, resolve)
  152. */
  153. CREATE_USER_PLAYLIST(payload) {
  154. return new Promise((resolve, reject) => {
  155. PlaylistsModule.playlistModel.create(
  156. {
  157. displayName: payload.displayName,
  158. songs: [],
  159. createdBy: payload.userId,
  160. createdAt: Date.now(),
  161. createdFor: null,
  162. type: payload.type
  163. },
  164. (err, playlist) => {
  165. if (err) return reject(new Error(err));
  166. return resolve(playlist._id);
  167. }
  168. );
  169. });
  170. }
  171. /**
  172. * Creates a playlist that contains all songs of a specific genre
  173. *
  174. * @param {object} payload - object that contains the payload
  175. * @param {string} payload.genre - the genre
  176. * @returns {Promise} - returns promise (reject, resolve)
  177. */
  178. CREATE_GENRE_PLAYLIST(payload) {
  179. return new Promise((resolve, reject) => {
  180. PlaylistsModule.runJob("GET_GENRE_PLAYLIST", { genre: payload.genre.toLowerCase() }, this)
  181. .then(() => {
  182. reject(new Error("Playlist already exists"));
  183. })
  184. .catch(err => {
  185. if (err.message === "Playlist not found") {
  186. PlaylistsModule.playlistModel.create(
  187. {
  188. displayName: `Genre - ${payload.genre}`,
  189. songs: [],
  190. createdBy: "Musare",
  191. createdFor: `${payload.genre.toLowerCase()}`,
  192. createdAt: Date.now(),
  193. type: "genre"
  194. },
  195. (err, playlist) => {
  196. if (err) return reject(new Error(err));
  197. return resolve(playlist._id);
  198. }
  199. );
  200. } else reject(new Error(err));
  201. });
  202. });
  203. }
  204. /**
  205. * Gets all genre playlists
  206. *
  207. * @param {object} payload - object that contains the payload
  208. * @param {string} payload.includeSongs - include the songs
  209. * @returns {Promise} - returns promise (reject, resolve)
  210. */
  211. GET_ALL_GENRE_PLAYLISTS(payload) {
  212. return new Promise((resolve, reject) => {
  213. const includeObject = payload.includeSongs ? null : { songs: false };
  214. PlaylistsModule.playlistModel.find({ type: "genre" }, includeObject, (err, playlists) => {
  215. if (err) reject(new Error(err));
  216. else resolve({ playlists });
  217. });
  218. });
  219. }
  220. /**
  221. * Gets all station playlists
  222. *
  223. * @param {object} payload - object that contains the payload
  224. * @param {string} payload.includeSongs - include the songs
  225. * @returns {Promise} - returns promise (reject, resolve)
  226. */
  227. GET_ALL_STATION_PLAYLISTS(payload) {
  228. return new Promise((resolve, reject) => {
  229. const includeObject = payload.includeSongs ? null : { songs: false };
  230. PlaylistsModule.playlistModel.find({ type: "station" }, includeObject, (err, playlists) => {
  231. if (err) reject(new Error(err));
  232. else resolve({ playlists });
  233. });
  234. });
  235. }
  236. /**
  237. * Gets a genre playlist
  238. *
  239. * @param {object} payload - object that contains the payload
  240. * @param {string} payload.genre - the genre
  241. * @param {string} payload.includeSongs - include the songs
  242. * @returns {Promise} - returns promise (reject, resolve)
  243. */
  244. GET_GENRE_PLAYLIST(payload) {
  245. return new Promise((resolve, reject) => {
  246. const includeObject = payload.includeSongs ? null : { songs: false };
  247. PlaylistsModule.playlistModel.findOne(
  248. { type: "genre", createdFor: payload.genre },
  249. includeObject,
  250. (err, playlist) => {
  251. if (err) reject(new Error(err));
  252. else if (!playlist) reject(new Error("Playlist not found"));
  253. else resolve({ playlist });
  254. }
  255. );
  256. });
  257. }
  258. /**
  259. * Gets all missing genre playlists
  260. *
  261. * @returns {Promise} - returns promise (reject, resolve)
  262. */
  263. GET_MISSING_GENRE_PLAYLISTS() {
  264. return new Promise((resolve, reject) => {
  265. SongsModule.runJob("GET_ALL_GENRES", {}, this)
  266. .then(response => {
  267. const { genres } = response;
  268. const missingGenres = [];
  269. async.eachLimit(
  270. genres,
  271. 1,
  272. (genre, next) => {
  273. PlaylistsModule.runJob(
  274. "GET_GENRE_PLAYLIST",
  275. { genre: genre.toLowerCase(), includeSongs: false },
  276. this
  277. )
  278. .then(() => {
  279. next();
  280. })
  281. .catch(err => {
  282. if (err.message === "Playlist not found") {
  283. missingGenres.push(genre);
  284. next();
  285. } else next(err);
  286. });
  287. },
  288. err => {
  289. if (err) reject(err);
  290. else resolve({ genres: missingGenres });
  291. }
  292. );
  293. })
  294. .catch(err => {
  295. reject(err);
  296. });
  297. });
  298. }
  299. /**
  300. * Creates all missing genre playlists
  301. *
  302. * @returns {Promise} - returns promise (reject, resolve)
  303. */
  304. CREATE_MISSING_GENRE_PLAYLISTS() {
  305. return new Promise((resolve, reject) => {
  306. PlaylistsModule.runJob("GET_MISSING_GENRE_PLAYLISTS", {}, this)
  307. .then(response => {
  308. const { genres } = response;
  309. async.eachLimit(
  310. genres,
  311. 1,
  312. (genre, next) => {
  313. PlaylistsModule.runJob("CREATE_GENRE_PLAYLIST", { genre }, this)
  314. .then(() => {
  315. next();
  316. })
  317. .catch(err => {
  318. next(err);
  319. });
  320. },
  321. err => {
  322. if (err) reject(err);
  323. else resolve();
  324. }
  325. );
  326. })
  327. .catch(err => {
  328. reject(err);
  329. });
  330. });
  331. }
  332. /**
  333. * Gets a station playlist
  334. *
  335. * @param {object} payload - object that contains the payload
  336. * @param {string} payload.staationId - the station id
  337. * @param {string} payload.includeSongs - include the songs
  338. * @returns {Promise} - returns promise (reject, resolve)
  339. */
  340. GET_STATION_PLAYLIST(payload) {
  341. return new Promise((resolve, reject) => {
  342. const includeObject = payload.includeSongs ? null : { songs: false };
  343. PlaylistsModule.playlistModel.findOne(
  344. { type: "station", createdFor: payload.stationId },
  345. includeObject,
  346. (err, playlist) => {
  347. if (err) reject(new Error(err));
  348. else if (!playlist) reject(new Error("Playlist not found"));
  349. else resolve({ playlist });
  350. }
  351. );
  352. });
  353. }
  354. /**
  355. * Adds a song to a playlist
  356. *
  357. * @param {object} payload - object that contains the payload
  358. * @param {string} payload.playlistId - the playlist id
  359. * @param {string} payload.youtubeId - the youtube id
  360. * @returns {Promise} - returns promise (reject, resolve)
  361. */
  362. ADD_SONG_TO_PLAYLIST(payload) {
  363. return new Promise((resolve, reject) => {
  364. const { playlistId, youtubeId } = payload;
  365. async.waterfall(
  366. [
  367. next => {
  368. PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
  369. .then(playlist => {
  370. next(null, playlist);
  371. })
  372. .catch(next);
  373. },
  374. (playlist, next) => {
  375. if (!playlist) return next("Playlist not found.");
  376. if (playlist.songs.find(song => song.youtubeId === youtubeId))
  377. return next("That song is already in the playlist.");
  378. return next();
  379. },
  380. next => {
  381. MediaModule.runJob("GET_MEDIA", { youtubeId }, this)
  382. .then(response => {
  383. const { song } = response;
  384. const { _id, title, artists, thumbnail, duration, verified } = song;
  385. next(null, {
  386. _id,
  387. youtubeId,
  388. title,
  389. artists,
  390. thumbnail,
  391. duration,
  392. verified
  393. });
  394. })
  395. .catch(next);
  396. },
  397. (newSong, next) => {
  398. PlaylistsModule.playlistModel.updateOne(
  399. { _id: playlistId },
  400. { $push: { songs: newSong } },
  401. { runValidators: true },
  402. err => {
  403. if (err) return next(err);
  404. return PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
  405. .then(playlist => next(null, playlist, newSong))
  406. .catch(next);
  407. }
  408. );
  409. },
  410. (playlist, newSong, next) => {
  411. StationsModule.runJob("GET_STATIONS_THAT_AUTOFILL_OR_BLACKLIST_PLAYLIST", { playlistId })
  412. .then(response => {
  413. async.each(
  414. response.stationIds,
  415. (stationId, next) => {
  416. PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId })
  417. .then()
  418. .catch();
  419. next();
  420. },
  421. err => {
  422. if (err) next(err);
  423. else next(null, playlist, newSong);
  424. }
  425. );
  426. })
  427. .catch(next);
  428. },
  429. (playlist, newSong, next) => {
  430. if (playlist.type === "user-liked" || playlist.type === "user-disliked") {
  431. MediaModule.runJob("RECALCULATE_RATINGS", {
  432. youtubeId: newSong.youtubeId
  433. })
  434. .then(ratings => next(null, playlist, newSong, ratings))
  435. .catch(next);
  436. } else {
  437. next(null, playlist, newSong, null);
  438. }
  439. }
  440. ],
  441. (err, playlist, song, ratings) => {
  442. if (err) reject(err);
  443. else resolve({ playlist, song, ratings });
  444. }
  445. );
  446. });
  447. }
  448. /**
  449. * Remove from playlist
  450. *
  451. * @param {object} payload - object that contains the payload
  452. * @param {string} payload.playlistId - the playlist id
  453. * @param {string} payload.youtubeId - the youtube id
  454. * @returns {Promise} - returns a promise (resolve, reject)
  455. */
  456. REMOVE_FROM_PLAYLIST(payload) {
  457. return new Promise((resolve, reject) => {
  458. const { playlistId, youtubeId } = payload;
  459. async.waterfall(
  460. [
  461. next => {
  462. PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
  463. .then(playlist => {
  464. next(null, playlist);
  465. })
  466. .catch(next);
  467. },
  468. (playlist, next) => {
  469. if (!playlist) return next("Playlist not found.");
  470. if (!playlist.songs.find(song => song.youtubeId === youtubeId))
  471. return next("That song is not currently in the playlist.");
  472. return PlaylistsModule.playlistModel.updateOne(
  473. { _id: playlistId },
  474. { $pull: { songs: { youtubeId } } },
  475. next
  476. );
  477. },
  478. (res, next) => {
  479. PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
  480. .then(playlist => next(null, playlist))
  481. .catch(next);
  482. },
  483. (playlist, next) => {
  484. StationsModule.runJob("GET_STATIONS_THAT_AUTOFILL_OR_BLACKLIST_PLAYLIST", { playlistId })
  485. .then(response => {
  486. async.each(
  487. response.stationIds,
  488. (stationId, next) => {
  489. PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId })
  490. .then()
  491. .catch();
  492. next();
  493. },
  494. err => {
  495. if (err) next(err);
  496. else next(null, playlist);
  497. }
  498. );
  499. })
  500. .catch(next);
  501. },
  502. (playlist, next) => {
  503. if (playlist.type === "user-liked" || playlist.type === "user-disliked") {
  504. MediaModule.runJob("RECALCULATE_RATINGS", { youtubeId })
  505. .then(ratings => next(null, playlist, ratings))
  506. .catch(next);
  507. } else next(null, playlist, null);
  508. },
  509. (playlist, ratings, next) =>
  510. CacheModule.runJob(
  511. "PUB",
  512. {
  513. channel: "playlist.updated",
  514. value: { playlistId }
  515. },
  516. this
  517. )
  518. .then(() => next(null, playlist, ratings))
  519. .catch(next)
  520. ],
  521. (err, playlist, ratings) => {
  522. if (err) reject(err);
  523. else resolve({ playlist, ratings });
  524. }
  525. );
  526. });
  527. }
  528. /**
  529. * Deletes a song from a playlist based on the youtube id
  530. *
  531. * @param {object} payload - object that contains the payload
  532. * @param {string} payload.playlistId - the playlist id
  533. * @param {string} payload.youtubeId - the youtube id
  534. * @returns {Promise} - returns promise (reject, resolve)
  535. */
  536. DELETE_SONG_FROM_PLAYLIST_BY_YOUTUBE_ID(payload) {
  537. return new Promise((resolve, reject) => {
  538. PlaylistsModule.playlistModel.updateOne(
  539. { _id: payload.playlistId },
  540. { $pull: { songs: { youtubeId: payload.youtubeId } } },
  541. err => {
  542. if (err) reject(new Error(err));
  543. else {
  544. PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId: payload.playlistId }, this)
  545. .then(() => resolve())
  546. .catch(err => {
  547. reject(new Error(err));
  548. });
  549. }
  550. }
  551. );
  552. });
  553. }
  554. /**
  555. * Fills a genre playlist with songs
  556. *
  557. * @param {object} payload - object that contains the payload
  558. * @param {string} payload.genre - the genre
  559. * @param {string} payload.createPlaylist - create playlist if it doesn't exist, default false
  560. * @returns {Promise} - returns promise (reject, resolve)
  561. */
  562. AUTOFILL_GENRE_PLAYLIST(payload) {
  563. return new Promise((resolve, reject) => {
  564. async.waterfall(
  565. [
  566. next => {
  567. PlaylistsModule.runJob(
  568. "GET_GENRE_PLAYLIST",
  569. { genre: payload.genre.toLowerCase(), includeSongs: true },
  570. this
  571. )
  572. .then(response => {
  573. next(null, response.playlist._id);
  574. })
  575. .catch(err => {
  576. if (err.message === "Playlist not found") {
  577. if (payload.createPlaylist)
  578. PlaylistsModule.runJob("CREATE_GENRE_PLAYLIST", { genre: payload.genre }, this)
  579. .then(playlistId => {
  580. next(null, playlistId);
  581. })
  582. .catch(err => {
  583. next(err);
  584. });
  585. } else next(err);
  586. });
  587. },
  588. (playlistId, next) => {
  589. SongsModule.runJob("GET_ALL_SONGS_WITH_GENRE", { genre: payload.genre }, this)
  590. .then(response => {
  591. next(null, playlistId, response.songs);
  592. })
  593. .catch(err => {
  594. console.log(err);
  595. next(err);
  596. });
  597. },
  598. (playlistId, _songs, next) => {
  599. const songs = _songs.map(song => {
  600. const { _id, youtubeId, title, artists, thumbnail, duration, verified } = song;
  601. return {
  602. _id,
  603. youtubeId,
  604. title,
  605. artists,
  606. thumbnail,
  607. duration,
  608. verified
  609. };
  610. });
  611. PlaylistsModule.playlistModel.updateOne({ _id: playlistId }, { $set: { songs } }, err => {
  612. next(err, playlistId);
  613. });
  614. },
  615. (playlistId, next) => {
  616. PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
  617. .then(() => {
  618. next(null, playlistId);
  619. })
  620. .catch(next);
  621. },
  622. (playlistId, next) => {
  623. StationsModule.runJob("GET_STATIONS_THAT_AUTOFILL_OR_BLACKLIST_PLAYLIST", { playlistId }, this)
  624. .then(response => {
  625. async.eachLimit(
  626. response.stationIds,
  627. 1,
  628. (stationId, next) => {
  629. PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }, this)
  630. .then(() => {
  631. next();
  632. })
  633. .catch(err => {
  634. next(err);
  635. });
  636. },
  637. err => {
  638. if (err) next(err);
  639. else next();
  640. }
  641. );
  642. })
  643. .catch(err => {
  644. next(err);
  645. });
  646. }
  647. ],
  648. err => {
  649. if (err && err !== true) return reject(new Error(err));
  650. return resolve({});
  651. }
  652. );
  653. });
  654. }
  655. /**
  656. * Gets orphaned genre playlists
  657. *
  658. * @returns {Promise} - returns promise (reject, resolve)
  659. */
  660. GET_ORPHANED_GENRE_PLAYLISTS() {
  661. return new Promise((resolve, reject) => {
  662. PlaylistsModule.playlistModel.find({ type: "genre" }, { songs: false }, (err, playlists) => {
  663. if (err) reject(new Error(err));
  664. else {
  665. const orphanedPlaylists = [];
  666. async.eachLimit(
  667. playlists,
  668. 1,
  669. (playlist, next) => {
  670. SongsModule.runJob("GET_ALL_SONGS_WITH_GENRE", { genre: playlist.createdFor }, this)
  671. .then(response => {
  672. if (response.songs.length === 0) {
  673. StationsModule.runJob(
  674. "GET_STATIONS_THAT_AUTOFILL_OR_BLACKLIST_PLAYLIST",
  675. { playlistId: playlist._id },
  676. this
  677. )
  678. .then(response => {
  679. if (response.stationIds.length === 0) orphanedPlaylists.push(playlist);
  680. next();
  681. })
  682. .catch(next);
  683. } else next();
  684. })
  685. .catch(next);
  686. },
  687. err => {
  688. if (err) reject(new Error(err));
  689. else resolve({ playlists: orphanedPlaylists });
  690. }
  691. );
  692. }
  693. });
  694. });
  695. }
  696. /**
  697. * Deletes all orphaned genre playlists
  698. *
  699. * @returns {Promise} - returns promise (reject, resolve)
  700. */
  701. DELETE_ORPHANED_GENRE_PLAYLISTS() {
  702. return new Promise((resolve, reject) => {
  703. PlaylistsModule.runJob("GET_ORPHANED_GENRE_PLAYLISTS", {}, this)
  704. .then(response => {
  705. async.eachLimit(
  706. response.playlists,
  707. 1,
  708. (playlist, next) => {
  709. this.publishProgress({ status: "update", message: `Deleting "${playlist._id}"` });
  710. PlaylistsModule.runJob("DELETE_PLAYLIST", { playlistId: playlist._id }, this)
  711. .then(() => {
  712. this.log("INFO", "Deleting orphaned genre playlist");
  713. next();
  714. })
  715. .catch(err => {
  716. next(err);
  717. });
  718. },
  719. err => {
  720. if (err) reject(new Error(err));
  721. else resolve({});
  722. }
  723. );
  724. })
  725. .catch(err => {
  726. reject(new Error(err));
  727. });
  728. });
  729. }
  730. /**
  731. * Gets a orphaned station playlists
  732. *
  733. * @returns {Promise} - returns promise (reject, resolve)
  734. */
  735. GET_ORPHANED_STATION_PLAYLISTS() {
  736. return new Promise((resolve, reject) => {
  737. PlaylistsModule.playlistModel.find({ type: "station" }, { songs: false }, (err, playlists) => {
  738. if (err) reject(new Error(err));
  739. else {
  740. const orphanedPlaylists = [];
  741. async.eachLimit(
  742. playlists,
  743. 1,
  744. (playlist, next) => {
  745. StationsModule.runJob("GET_STATION", { stationId: playlist.createdFor }, this)
  746. .then(station => {
  747. if (station.playlist !== playlist._id.toString()) {
  748. orphanedPlaylists.push(playlist);
  749. }
  750. next();
  751. })
  752. .catch(err => {
  753. if (err.message === "Station not found") {
  754. orphanedPlaylists.push(playlist);
  755. next();
  756. } else next(err);
  757. });
  758. },
  759. err => {
  760. if (err) reject(new Error(err));
  761. else resolve({ playlists: orphanedPlaylists });
  762. }
  763. );
  764. }
  765. });
  766. });
  767. }
  768. /**
  769. * Deletes all orphaned station playlists
  770. *
  771. * @returns {Promise} - returns promise (reject, resolve)
  772. */
  773. DELETE_ORPHANED_STATION_PLAYLISTS() {
  774. return new Promise((resolve, reject) => {
  775. PlaylistsModule.runJob("GET_ORPHANED_STATION_PLAYLISTS", {}, this)
  776. .then(response => {
  777. async.eachLimit(
  778. response.playlists,
  779. 1,
  780. (playlist, next) => {
  781. this.publishProgress({ status: "update", message: `Deleting "${playlist._id}"` });
  782. PlaylistsModule.runJob("DELETE_PLAYLIST", { playlistId: playlist._id }, this)
  783. .then(() => {
  784. this.log("INFO", "Deleting orphaned station playlist");
  785. next();
  786. })
  787. .catch(err => {
  788. next(err);
  789. });
  790. },
  791. err => {
  792. if (err) reject(new Error(err));
  793. else resolve({});
  794. }
  795. );
  796. })
  797. .catch(err => {
  798. reject(new Error(err));
  799. });
  800. });
  801. }
  802. /**
  803. * Fills a station playlist with songs
  804. *
  805. * @param {object} payload - object that contains the payload
  806. * @param {string} payload.stationId - the station id
  807. * @returns {Promise} - returns promise (reject, resolve)
  808. */
  809. AUTOFILL_STATION_PLAYLIST(payload) {
  810. return new Promise((resolve, reject) => {
  811. let originalPlaylist = null;
  812. async.waterfall(
  813. [
  814. next => {
  815. if (!payload.stationId) next("Please specify a station id");
  816. else next();
  817. },
  818. next => {
  819. StationsModule.runJob("GET_STATION", { stationId: payload.stationId }, this)
  820. .then(station => {
  821. next(null, station);
  822. })
  823. .catch(next);
  824. },
  825. (station, next) => {
  826. PlaylistsModule.runJob("GET_PLAYLIST", { playlistId: station.playlist }, this)
  827. .then(playlist => {
  828. originalPlaylist = playlist;
  829. next(null, station);
  830. })
  831. .catch(err => {
  832. next(err);
  833. });
  834. },
  835. (station, next) => {
  836. const playlists = [];
  837. async.eachLimit(
  838. station.autofill.playlists,
  839. 1,
  840. (playlistId, next) => {
  841. PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
  842. .then(playlist => {
  843. playlists.push(playlist);
  844. next();
  845. })
  846. .catch(next);
  847. },
  848. err => {
  849. next(err, station, playlists);
  850. }
  851. );
  852. },
  853. (station, playlists, next) => {
  854. const blacklist = [];
  855. async.eachLimit(
  856. station.blacklist,
  857. 1,
  858. (playlistId, next) => {
  859. PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
  860. .then(playlist => {
  861. blacklist.push(playlist);
  862. next();
  863. })
  864. .catch(next);
  865. },
  866. err => {
  867. next(err, station, playlists, blacklist);
  868. }
  869. );
  870. },
  871. (station, playlists, blacklist, next) => {
  872. const blacklistedSongs = blacklist
  873. .flatMap(blacklistedPlaylist => blacklistedPlaylist.songs)
  874. .reduce(
  875. (items, item) =>
  876. items.find(x => x.youtubeId === item.youtubeId) ? [...items] : [...items, item],
  877. []
  878. );
  879. const includedSongs = playlists
  880. .flatMap(playlist => playlist.songs)
  881. .reduce(
  882. (songs, song) =>
  883. songs.find(x => x.youtubeId === song.youtubeId) ? [...songs] : [...songs, song],
  884. []
  885. )
  886. .filter(song => !blacklistedSongs.find(x => x.youtubeId === song.youtubeId));
  887. next(null, station, includedSongs);
  888. },
  889. (station, includedSongs, next) => {
  890. PlaylistsModule.playlistModel.updateOne(
  891. { _id: station.playlist },
  892. { $set: { songs: includedSongs } },
  893. err => {
  894. next(err, includedSongs);
  895. }
  896. );
  897. },
  898. (includedSongs, next) => {
  899. PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId: originalPlaylist._id }, this)
  900. .then(() => {
  901. next(null, includedSongs);
  902. })
  903. .catch(next);
  904. },
  905. (includedSongs, next) => {
  906. if (originalPlaylist.songs.length === 0 && includedSongs.length > 0)
  907. StationsModule.runJob("SKIP_STATION", {
  908. stationId: payload.stationId,
  909. natural: false,
  910. skipReason: "other"
  911. });
  912. next();
  913. }
  914. ],
  915. err => {
  916. if (err && err !== true) return reject(new Error(err));
  917. return resolve({});
  918. }
  919. );
  920. });
  921. }
  922. /**
  923. * Gets a playlist by id from the cache or Mongo, and if it isn't in the cache yet, adds it the cache
  924. *
  925. * @param {object} payload - object that contains the payload
  926. * @param {string} payload.playlistId - the id of the playlist we are trying to get
  927. * @returns {Promise} - returns promise (reject, resolve)
  928. */
  929. GET_PLAYLIST(payload) {
  930. return new Promise((resolve, reject) => {
  931. async.waterfall(
  932. [
  933. next => {
  934. CacheModule.runJob(
  935. "HGET",
  936. {
  937. table: "playlists",
  938. key: payload.playlistId
  939. },
  940. this
  941. )
  942. .then(playlist => next(null, playlist))
  943. .catch(next);
  944. },
  945. (playlist, next) => {
  946. if (playlist)
  947. PlaylistsModule.playlistModel.exists({ _id: payload.playlistId }, (err, exists) => {
  948. if (err) next(err);
  949. else if (exists) next(null, playlist);
  950. else {
  951. CacheModule.runJob(
  952. "HDEL",
  953. {
  954. table: "playlists",
  955. key: payload.playlistId
  956. },
  957. this
  958. )
  959. .then(() => next())
  960. .catch(next);
  961. }
  962. });
  963. else PlaylistsModule.playlistModel.findOne({ _id: payload.playlistId }, next);
  964. },
  965. (playlist, next) => {
  966. if (playlist) {
  967. CacheModule.runJob(
  968. "HSET",
  969. {
  970. table: "playlists",
  971. key: payload.playlistId,
  972. value: playlist
  973. },
  974. this
  975. )
  976. .then(playlist => {
  977. next(null, playlist);
  978. })
  979. .catch(next);
  980. } else next("Playlist not found");
  981. }
  982. ],
  983. (err, playlist) => {
  984. if (err && err !== true) return reject(new Error(err));
  985. return resolve(playlist);
  986. }
  987. );
  988. });
  989. }
  990. /**
  991. * Gets a playlist from id from Mongo and updates the cache with it
  992. *
  993. * @param {object} payload - object that contains the payload
  994. * @param {string} payload.playlistId - the id of the playlist we are trying to update
  995. * @returns {Promise} - returns promise (reject, resolve)
  996. */
  997. UPDATE_PLAYLIST(payload) {
  998. return new Promise((resolve, reject) => {
  999. async.waterfall(
  1000. [
  1001. next => {
  1002. PlaylistsModule.playlistModel.findOne({ _id: payload.playlistId }, next);
  1003. },
  1004. (playlist, next) => {
  1005. if (!playlist) {
  1006. CacheModule.runJob("HDEL", {
  1007. table: "playlists",
  1008. key: payload.playlistId
  1009. });
  1010. return next("Playlist not found");
  1011. }
  1012. return CacheModule.runJob(
  1013. "HSET",
  1014. {
  1015. table: "playlists",
  1016. key: payload.playlistId,
  1017. value: playlist
  1018. },
  1019. this
  1020. )
  1021. .then(playlist => {
  1022. next(null, playlist);
  1023. })
  1024. .catch(next);
  1025. }
  1026. ],
  1027. (err, playlist) => {
  1028. if (err && err !== true) return reject(new Error(err));
  1029. return resolve(playlist);
  1030. }
  1031. );
  1032. });
  1033. }
  1034. /**
  1035. * Deletes playlist from id from Mongo and cache
  1036. *
  1037. * @param {object} payload - object that contains the payload
  1038. * @param {string} payload.playlistId - the id of the playlist we are trying to delete
  1039. * @returns {Promise} - returns promise (reject, resolve)
  1040. */
  1041. DELETE_PLAYLIST(payload) {
  1042. return new Promise((resolve, reject) => {
  1043. async.waterfall(
  1044. [
  1045. next => {
  1046. PlaylistsModule.playlistModel.deleteOne({ _id: payload.playlistId }, next);
  1047. },
  1048. (res, next) => {
  1049. CacheModule.runJob(
  1050. "HDEL",
  1051. {
  1052. table: "playlists",
  1053. key: payload.playlistId
  1054. },
  1055. this
  1056. )
  1057. .then(() => next())
  1058. .catch(next);
  1059. },
  1060. next => {
  1061. StationsModule.runJob(
  1062. "REMOVE_AUTOFILLED_OR_BLACKLISTED_PLAYLIST_FROM_STATIONS",
  1063. { playlistId: payload.playlistId },
  1064. this
  1065. )
  1066. .then(() => {
  1067. next();
  1068. })
  1069. .catch(err => next(err));
  1070. }
  1071. ],
  1072. err => {
  1073. if (err && err !== true) return reject(new Error(err));
  1074. return resolve();
  1075. }
  1076. );
  1077. });
  1078. }
  1079. /**
  1080. * Searches through playlists
  1081. *
  1082. * @param {object} payload - object that contains the payload
  1083. * @param {string} payload.query - the query
  1084. * @param {string} payload.includePrivate - include private playlists
  1085. * @param {string} payload.includeStation - include station playlists
  1086. * @param {string} payload.includeUser - include user playlists
  1087. * @param {string} payload.includeGenre - include genre playlists
  1088. * @param {string} payload.includeAdmin - include admin playlists
  1089. * @param {string} payload.includeOwn - include own user playlists
  1090. * @param {string} payload.userId - the user id of the person requesting
  1091. * @param {string} payload.includeSongs - include songs
  1092. * @param {string} payload.page - page (default 1)
  1093. * @returns {Promise} - returns promise (reject, resolve)
  1094. */
  1095. SEARCH(payload) {
  1096. return new Promise((resolve, reject) => {
  1097. async.waterfall(
  1098. [
  1099. next => {
  1100. const types = [];
  1101. if (payload.includeStation) types.push("station");
  1102. if (payload.includeUser) types.push("user");
  1103. if (payload.includeGenre) types.push("genre");
  1104. if (payload.includeAdmin) types.push("admin");
  1105. if (types.length === 0 && !payload.includeOwn) return next("No types have been included.");
  1106. const privacies = ["public"];
  1107. if (payload.includePrivate) privacies.push("private");
  1108. const includeObject = payload.includeSongs ? null : { songs: false };
  1109. const filterArray = [
  1110. {
  1111. displayName: new RegExp(`${payload.query}`, "i"),
  1112. privacy: { $in: privacies },
  1113. type: { $in: types }
  1114. }
  1115. ];
  1116. if (payload.includeOwn && payload.userId)
  1117. filterArray.push({
  1118. displayName: new RegExp(`${payload.query}`, "i"),
  1119. type: "user",
  1120. createdBy: payload.userId
  1121. });
  1122. return next(null, filterArray, includeObject);
  1123. },
  1124. (filterArray, includeObject, next) => {
  1125. const page = payload.page ? payload.page : 1;
  1126. const pageSize = 15;
  1127. const skipAmount = pageSize * (page - 1);
  1128. PlaylistsModule.playlistModel.find({ $or: filterArray }).count((err, count) => {
  1129. if (err) next(err);
  1130. else {
  1131. PlaylistsModule.playlistModel
  1132. .find({ $or: filterArray }, includeObject)
  1133. .skip(skipAmount)
  1134. .limit(pageSize)
  1135. .exec((err, playlists) => {
  1136. if (err) next(err);
  1137. else {
  1138. next(null, {
  1139. playlists,
  1140. page,
  1141. pageSize,
  1142. skipAmount,
  1143. count
  1144. });
  1145. }
  1146. });
  1147. }
  1148. });
  1149. },
  1150. (data, next) => {
  1151. if (data.playlists.length > 0) next(null, data);
  1152. else next("No playlists found");
  1153. }
  1154. ],
  1155. (err, data) => {
  1156. if (err && err !== true) return reject(new Error(err));
  1157. return resolve(data);
  1158. }
  1159. );
  1160. });
  1161. }
  1162. /**
  1163. * Clears and refills a station playlist
  1164. *
  1165. * @param {object} payload - object that contains the payload
  1166. * @param {string} payload.playlistId - the id of the playlist we are trying to clear and refill
  1167. * @returns {Promise} - returns promise (reject, resolve)
  1168. */
  1169. CLEAR_AND_REFILL_STATION_PLAYLIST(payload) {
  1170. return new Promise((resolve, reject) => {
  1171. const { playlistId } = payload;
  1172. async.waterfall(
  1173. [
  1174. next => {
  1175. PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
  1176. .then(playlist => {
  1177. next(null, playlist);
  1178. })
  1179. .catch(err => {
  1180. next(err);
  1181. });
  1182. },
  1183. (playlist, next) => {
  1184. if (playlist.type !== "station") next("This playlist is not a station playlist.");
  1185. else next(null, playlist.createdFor);
  1186. },
  1187. (stationId, next) => {
  1188. PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }, this)
  1189. .then(() => {
  1190. next();
  1191. })
  1192. .catch(err => {
  1193. next(err);
  1194. });
  1195. }
  1196. ],
  1197. err => {
  1198. if (err && err !== true) return reject(new Error(err));
  1199. return resolve();
  1200. }
  1201. );
  1202. });
  1203. }
  1204. /**
  1205. * Clears and refills a genre playlist
  1206. *
  1207. * @param {object} payload - object that contains the payload
  1208. * @param {string} payload.playlistId - the id of the playlist we are trying to clear and refill
  1209. * @returns {Promise} - returns promise (reject, resolve)
  1210. */
  1211. CLEAR_AND_REFILL_GENRE_PLAYLIST(payload) {
  1212. return new Promise((resolve, reject) => {
  1213. const { playlistId } = payload;
  1214. async.waterfall(
  1215. [
  1216. next => {
  1217. PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
  1218. .then(playlist => {
  1219. next(null, playlist);
  1220. })
  1221. .catch(err => {
  1222. next(err);
  1223. });
  1224. },
  1225. (playlist, next) => {
  1226. if (playlist.type !== "genre") next("This playlist is not a genre playlist.");
  1227. else next(null, playlist.createdFor);
  1228. },
  1229. (genre, next) => {
  1230. PlaylistsModule.runJob("AUTOFILL_GENRE_PLAYLIST", { genre, createPlaylist: true }, this)
  1231. .then(() => {
  1232. next();
  1233. })
  1234. .catch(err => {
  1235. next(err);
  1236. });
  1237. }
  1238. ],
  1239. err => {
  1240. if (err && err !== true) return reject(new Error(err));
  1241. return resolve();
  1242. }
  1243. );
  1244. });
  1245. }
  1246. }
  1247. export default new _PlaylistsModule();