songs.js 38 KB

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