playlists.js 39 KB

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