songs.js 40 KB

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