songs.js 34 KB

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