media.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516
  1. import async from "async";
  2. import CoreClass from "../core";
  3. let MediaModule;
  4. let CacheModule;
  5. let DBModule;
  6. let UtilsModule;
  7. let YouTubeModule;
  8. let SongsModule;
  9. let WSModule;
  10. class _MediaModule extends CoreClass {
  11. // eslint-disable-next-line require-jsdoc
  12. constructor() {
  13. super("media");
  14. MediaModule = this;
  15. }
  16. /**
  17. * Initialises the media module
  18. *
  19. * @returns {Promise} - returns promise (reject, resolve)
  20. */
  21. async initialize() {
  22. this.setStage(1);
  23. CacheModule = this.moduleManager.modules.cache;
  24. DBModule = this.moduleManager.modules.db;
  25. UtilsModule = this.moduleManager.modules.utils;
  26. YouTubeModule = this.moduleManager.modules.youtube;
  27. SongsModule = this.moduleManager.modules.songs;
  28. WSModule = this.moduleManager.modules.ws;
  29. this.RatingsModel = await DBModule.runJob("GET_MODEL", { modelName: "ratings" });
  30. this.RatingsSchemaCache = await CacheModule.runJob("GET_SCHEMA", { schemaName: "ratings" });
  31. this.ImportJobModel = await DBModule.runJob("GET_MODEL", { modelName: "importJob" });
  32. this.setStage(2);
  33. return new Promise((resolve, reject) => {
  34. CacheModule.runJob("SUB", {
  35. channel: "importJob.updated",
  36. cb: importJob => {
  37. WSModule.runJob("EMIT_TO_ROOM", {
  38. room: "admin.import",
  39. args: ["event:admin.importJob.updated", { data: { importJob } }]
  40. });
  41. }
  42. });
  43. CacheModule.runJob("SUB", {
  44. channel: "importJob.removed",
  45. cb: jobId => {
  46. WSModule.runJob("EMIT_TO_ROOM", {
  47. room: "admin.import",
  48. args: ["event:admin.importJob.removed", { data: { jobId } }]
  49. });
  50. }
  51. });
  52. async.waterfall(
  53. [
  54. next => {
  55. this.setStage(2);
  56. CacheModule.runJob("HGETALL", { table: "ratings" })
  57. .then(ratings => {
  58. next(null, ratings);
  59. })
  60. .catch(next);
  61. },
  62. (ratings, next) => {
  63. this.setStage(3);
  64. if (!ratings) return next();
  65. const mediaSources = Object.keys(ratings);
  66. return async.each(
  67. mediaSources,
  68. (mediaSource, next) => {
  69. MediaModule.RatingsModel.findOne({ mediaSource }, (err, rating) => {
  70. if (err) next(err);
  71. else if (!rating)
  72. CacheModule.runJob("HDEL", {
  73. table: "ratings",
  74. key: mediaSource
  75. })
  76. .then(() => next())
  77. .catch(next);
  78. else next();
  79. });
  80. },
  81. next
  82. );
  83. },
  84. next => {
  85. this.setStage(4);
  86. MediaModule.RatingsModel.find({}, next);
  87. },
  88. (ratings, next) => {
  89. this.setStage(5);
  90. async.each(
  91. ratings,
  92. (rating, next) => {
  93. CacheModule.runJob("HSET", {
  94. table: "ratings",
  95. key: rating.mediaSource,
  96. value: MediaModule.RatingsSchemaCache(rating)
  97. })
  98. .then(() => next())
  99. .catch(next);
  100. },
  101. next
  102. );
  103. }
  104. ],
  105. async err => {
  106. if (err) {
  107. console.log(345345, err);
  108. err = await UtilsModule.runJob("GET_ERROR", { error: err });
  109. reject(new Error(err));
  110. } else resolve();
  111. }
  112. );
  113. });
  114. }
  115. /**
  116. * Recalculates dislikes and likes
  117. *
  118. * @param {object} payload - returns an object containing the payload
  119. * @param {string} payload.mediaSource - the media source
  120. * @returns {Promise} - returns a promise (resolve, reject)
  121. */
  122. async RECALCULATE_RATINGS(payload) {
  123. const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
  124. return new Promise((resolve, reject) => {
  125. async.waterfall(
  126. [
  127. next => {
  128. playlistModel.countDocuments(
  129. { songs: { $elemMatch: { mediaSource: payload.mediaSource } }, type: "user-liked" },
  130. (err, likes) => {
  131. if (err) return next(err);
  132. return next(null, likes);
  133. }
  134. );
  135. },
  136. (likes, next) => {
  137. playlistModel.countDocuments(
  138. { songs: { $elemMatch: { mediaSource: payload.mediaSource } }, type: "user-disliked" },
  139. (err, dislikes) => {
  140. if (err) return next(err);
  141. return next(err, { likes, dislikes });
  142. }
  143. );
  144. },
  145. ({ likes, dislikes }, next) => {
  146. MediaModule.RatingsModel.findOneAndUpdate(
  147. { mediaSource: payload.mediaSource },
  148. {
  149. $set: {
  150. likes,
  151. dislikes
  152. }
  153. },
  154. { new: true, upsert: true },
  155. next
  156. );
  157. },
  158. (ratings, next) => {
  159. CacheModule.runJob(
  160. "HSET",
  161. {
  162. table: "ratings",
  163. key: payload.mediaSource,
  164. value: ratings
  165. },
  166. this
  167. )
  168. .then(ratings => next(null, ratings))
  169. .catch(next);
  170. }
  171. ],
  172. (err, { likes, dislikes }) => {
  173. if (err) return reject(new Error(err));
  174. return resolve({ likes, dislikes });
  175. }
  176. );
  177. });
  178. }
  179. /**
  180. * Recalculates all dislikes and likes
  181. *
  182. * @returns {Promise} - returns a promise (resolve, reject)
  183. */
  184. RECALCULATE_ALL_RATINGS() {
  185. return new Promise((resolve, reject) => {
  186. async.waterfall(
  187. [
  188. next => {
  189. SongsModule.SongModel.find({}, { mediaSource: true }, next);
  190. },
  191. (songs, next) => {
  192. // TODO support spotify
  193. YouTubeModule.youtubeVideoModel.find({}, { youtubeId: true }, (err, videos) => {
  194. if (err) next(err);
  195. else
  196. next(null, [
  197. ...songs.map(song => song.mediaSource),
  198. ...videos.map(video => `youtube:${video.youtubeId}`)
  199. ]);
  200. });
  201. },
  202. (mediaSources, next) => {
  203. async.eachLimit(
  204. mediaSources,
  205. 2,
  206. (mediaSource, next) => {
  207. this.publishProgress({
  208. status: "update",
  209. message: `Recalculating ratings for ${mediaSource}`
  210. });
  211. MediaModule.runJob("RECALCULATE_RATINGS", { mediaSource }, this)
  212. .then(() => {
  213. next();
  214. })
  215. .catch(err => {
  216. next(err);
  217. });
  218. },
  219. err => {
  220. next(err);
  221. }
  222. );
  223. }
  224. ],
  225. err => {
  226. if (err) return reject(new Error(err));
  227. return resolve();
  228. }
  229. );
  230. });
  231. }
  232. /**
  233. * Gets ratings by id from the cache or Mongo, and if it isn't in the cache yet, adds it the cache
  234. *
  235. * @param {object} payload - object containing the payload
  236. * @param {string} payload.mediaSource - the media source
  237. * @param {string} payload.createMissing - whether to create missing ratings
  238. * @returns {Promise} - returns a promise (resolve, reject)
  239. */
  240. GET_RATINGS(payload) {
  241. return new Promise((resolve, reject) => {
  242. async.waterfall(
  243. [
  244. next =>
  245. CacheModule.runJob("HGET", { table: "ratings", key: payload.mediaSource }, this)
  246. .then(ratings => next(null, ratings))
  247. .catch(next),
  248. (ratings, next) => {
  249. if (ratings) return next(true, ratings);
  250. return MediaModule.RatingsModel.findOne({ mediaSource: payload.mediaSource }, next);
  251. },
  252. (ratings, next) => {
  253. if (ratings)
  254. return CacheModule.runJob(
  255. "HSET",
  256. {
  257. table: "ratings",
  258. key: payload.mediaSource,
  259. value: ratings
  260. },
  261. this
  262. ).then(ratings => next(true, ratings));
  263. if (!payload.createMissing) return next("Ratings not found.");
  264. return MediaModule.runJob("RECALCULATE_RATINGS", { mediaSource: payload.mediaSource }, this)
  265. .then(() => next())
  266. .catch(next);
  267. },
  268. next =>
  269. MediaModule.runJob("GET_RATINGS", { mediaSource: payload.mediaSource }, this)
  270. .then(res => next(null, res.ratings))
  271. .catch(next)
  272. ],
  273. (err, ratings) => {
  274. if (err && err !== true) return reject(new Error(err));
  275. return resolve({ ratings });
  276. }
  277. );
  278. });
  279. }
  280. /**
  281. * Remove ratings by id from the cache and Mongo
  282. *
  283. * @param {object} payload - object containing the payload
  284. * @param {string} payload.mediaSources - the media source
  285. * @returns {Promise} - returns a promise (resolve, reject)
  286. */
  287. REMOVE_RATINGS(payload) {
  288. return new Promise((resolve, reject) => {
  289. let { mediaSources } = payload;
  290. if (!Array.isArray(mediaSources)) mediaSources = [mediaSources];
  291. async.eachLimit(
  292. mediaSources,
  293. 1,
  294. (mediaSource, next) => {
  295. async.waterfall(
  296. [
  297. next => {
  298. MediaModule.RatingsModel.deleteOne({ mediaSource }, err => {
  299. if (err) next(err);
  300. else next();
  301. });
  302. },
  303. next => {
  304. CacheModule.runJob("HDEL", { table: "ratings", key: mediaSource }, this)
  305. .then(() => {
  306. next();
  307. })
  308. .catch(next);
  309. }
  310. ],
  311. next
  312. );
  313. },
  314. err => {
  315. if (err && err !== true) return reject(new Error(err));
  316. return resolve();
  317. }
  318. );
  319. });
  320. }
  321. /**
  322. * Get song or youtube video by mediaSource
  323. *
  324. * @param {object} payload - an object containing the payload
  325. * @param {string} payload.mediaSource - the media source of the song/video
  326. * @param {string} payload.userId - the user id
  327. * @returns {Promise} - returns a promise (resolve, reject)
  328. */
  329. GET_MEDIA(payload) {
  330. return new Promise((resolve, reject) => {
  331. async.waterfall(
  332. [
  333. next => {
  334. SongsModule.SongModel.findOne({ mediaSource: payload.mediaSource }, next);
  335. },
  336. (song, next) => {
  337. if (song && song.duration > 0) return next(true, song);
  338. if (payload.mediaSource.startsWith("youtube:")) {
  339. const youtubeId = payload.mediaSource.split(":")[1];
  340. return YouTubeModule.runJob(
  341. "GET_VIDEO",
  342. { identifier: youtubeId, createMissing: true },
  343. this
  344. )
  345. .then(response => {
  346. const { youtubeId, title, author, duration } = response.video;
  347. next(null, song, {
  348. mediaSource: `youtube:${youtubeId}`,
  349. title,
  350. artists: [author],
  351. duration
  352. });
  353. })
  354. .catch(next);
  355. }
  356. // TODO handle Spotify here
  357. return next("Invalid media source provided.");
  358. },
  359. (song, youtubeVideo, next) => {
  360. if (song && song.duration <= 0) {
  361. song.duration = youtubeVideo.duration;
  362. song.save({ validateBeforeSave: true }, err => {
  363. if (err) next(err, song);
  364. next(null, song);
  365. });
  366. } else {
  367. next(null, {
  368. ...youtubeVideo,
  369. skipDuration: 0,
  370. requestedBy: payload.userId,
  371. requestedAt: Date.now(),
  372. verified: false
  373. });
  374. }
  375. }
  376. ],
  377. (err, song) => {
  378. if (err && err !== true) return reject(new Error(err));
  379. return resolve({ song });
  380. }
  381. );
  382. });
  383. }
  384. /**
  385. * Remove import job by id from Mongo
  386. *
  387. * @param {object} payload - object containing the payload
  388. * @param {string} payload.jobIds - the job ids
  389. * @returns {Promise} - returns a promise (resolve, reject)
  390. */
  391. UPDATE_IMPORT_JOBS(payload) {
  392. return new Promise((resolve, reject) => {
  393. let { jobIds } = payload;
  394. if (!Array.isArray(jobIds)) jobIds = [jobIds];
  395. async.waterfall(
  396. [
  397. next => {
  398. MediaModule.ImportJobModel.find({ _id: { $in: jobIds } }, next);
  399. },
  400. (importJobs, next) => {
  401. async.eachLimit(
  402. importJobs,
  403. 1,
  404. (importJob, next) => {
  405. CacheModule.runJob("PUB", {
  406. channel: "importJob.updated",
  407. value: importJob
  408. })
  409. .then(() => next())
  410. .catch(next);
  411. },
  412. err => {
  413. if (err) next(err);
  414. else next(null, importJobs);
  415. }
  416. );
  417. }
  418. ],
  419. (err, importJobs) => {
  420. if (err && err !== true) return reject(new Error(err));
  421. return resolve({ importJobs });
  422. }
  423. );
  424. });
  425. }
  426. /**
  427. * Remove import job by id from Mongo
  428. *
  429. * @param {object} payload - object containing the payload
  430. * @param {string} payload.jobIds - the job ids
  431. * @returns {Promise} - returns a promise (resolve, reject)
  432. */
  433. REMOVE_IMPORT_JOBS(payload) {
  434. return new Promise((resolve, reject) => {
  435. let { jobIds } = payload;
  436. if (!Array.isArray(jobIds)) jobIds = [jobIds];
  437. async.waterfall(
  438. [
  439. next => {
  440. MediaModule.ImportJobModel.deleteMany({ _id: { $in: jobIds } }, err => {
  441. if (err) next(err);
  442. else next();
  443. });
  444. },
  445. next => {
  446. async.eachLimit(
  447. jobIds,
  448. 1,
  449. (jobId, next) => {
  450. CacheModule.runJob("PUB", {
  451. channel: "importJob.removed",
  452. value: jobId
  453. })
  454. .then(() => next())
  455. .catch(next);
  456. },
  457. next
  458. );
  459. }
  460. ],
  461. err => {
  462. if (err && err !== true) return reject(new Error(err));
  463. return resolve();
  464. }
  465. );
  466. });
  467. }
  468. }
  469. export default new _MediaModule();