musicbrainz.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. import axios from "axios";
  2. import CoreClass from "../core";
  3. import { MUSARE_VERSION } from "..";
  4. export const MUSICBRAINZ_CAA_CACHED_RESPONSE_REDIS_TABLE = "musicbrainzCAACachedResponse";
  5. export const MUSICBRAINZ_RECORDINGS_RELEASES_RELEASE_GROUPS_REDIS_TABLE = "musicbrainzRecordingsReleasesReleaseGroups";
  6. class RateLimitter {
  7. /**
  8. * Constructor
  9. * @param {number} timeBetween - The time between each allowed MusicBrainz request
  10. */
  11. constructor(timeBetween) {
  12. this.dateStarted = Date.now();
  13. this.timeBetween = timeBetween;
  14. }
  15. /**
  16. * Returns a promise that resolves whenever the ratelimit of a MusicBrainz request is done
  17. * @returns {Promise} - promise that gets resolved when the rate limit allows it
  18. */
  19. continue() {
  20. return new Promise(resolve => {
  21. if (Date.now() - this.dateStarted >= this.timeBetween) resolve();
  22. else setTimeout(resolve, this.dateStarted + this.timeBetween - Date.now());
  23. });
  24. }
  25. /**
  26. * Restart the rate limit timer
  27. */
  28. restart() {
  29. this.dateStarted = Date.now();
  30. }
  31. }
  32. let MusicBrainzModule;
  33. let DBModule;
  34. let CacheModule;
  35. let AppModule;
  36. class _MusicBrainzModule extends CoreClass {
  37. // eslint-disable-next-line require-jsdoc
  38. constructor() {
  39. super("musicbrainz", {
  40. concurrency: 1
  41. });
  42. MusicBrainzModule = this;
  43. }
  44. /**
  45. * Initialises the MusicBrainz module
  46. * @returns {Promise} - returns promise (reject, resolve)
  47. */
  48. async initialize() {
  49. DBModule = this.moduleManager.modules.db;
  50. CacheModule = this.moduleManager.modules.cache;
  51. AppModule = this.moduleManager.modules.app;
  52. this.genericApiRequestModel = this.GenericApiRequestModel = await DBModule.runJob("GET_MODEL", {
  53. modelName: "genericApiRequest"
  54. });
  55. this.rateLimiter = new RateLimitter(1100);
  56. this.requestTimeout = 5000;
  57. this.axios = axios.create();
  58. const { app } = await AppModule.runJob("GET_APP", {});
  59. // Proxy with caching for coverartarchive.org
  60. app.get(
  61. [
  62. "/caa/release/:mbid",
  63. "/caa/release/:mbid/:type",
  64. "/caa/release-group/:mbid",
  65. "/caa/release-group/:mbid/:type"
  66. ],
  67. async (req, res) => {
  68. // TODO add config option to proxy or redirect these requests
  69. // Remove /caa/ from the path
  70. const path = req.path.substring(5);
  71. console.log(`Request for ${path}`);
  72. const cachedResponse = await CacheModule.runJob("HGET", {
  73. table: MUSICBRAINZ_CAA_CACHED_RESPONSE_REDIS_TABLE,
  74. key: path
  75. });
  76. if (cachedResponse) {
  77. const { contentType, data, status } = cachedResponse;
  78. if (status === "404") {
  79. console.log(contentType, data, status);
  80. }
  81. res.set("Content-Type", contentType);
  82. res.status(status);
  83. res.send(Buffer.from(data, "base64"));
  84. return;
  85. }
  86. let CAARes;
  87. try {
  88. CAARes = await this.axios.get(`https://coverartarchive.org/${path}`, {
  89. responseType: "arraybuffer"
  90. });
  91. } catch (err) {
  92. if (err.response) CAARes = err.response;
  93. else {
  94. console.log(`Non-normal error when requesting coverartarchive.org: ${err.message}`);
  95. return;
  96. }
  97. }
  98. const contentType = CAARes.headers["content-type"];
  99. const data = Buffer.from(CAARes.data, "binary").toString("base64");
  100. const { status } = CAARes;
  101. await CacheModule.runJob("HSET", {
  102. table: MUSICBRAINZ_CAA_CACHED_RESPONSE_REDIS_TABLE,
  103. key: path,
  104. value: JSON.stringify({
  105. contentType,
  106. data,
  107. status
  108. })
  109. });
  110. res.set("Content-Type", contentType);
  111. res.status(status);
  112. res.send(Buffer.from(data, "base64"));
  113. }
  114. );
  115. }
  116. /**
  117. * Perform MusicBrainz API call
  118. * @param {object} payload - object that contains the payload
  119. * @param {string} payload.url - request url
  120. * @param {object} payload.params - request parameters
  121. * @returns {Promise} - returns promise (reject, resolve)
  122. */
  123. async API_CALL(payload) {
  124. const { url, params } = payload;
  125. let genericApiRequest = await MusicBrainzModule.GenericApiRequestModel.findOne({
  126. url,
  127. params
  128. });
  129. if (genericApiRequest) {
  130. if (genericApiRequest._doc.responseData.error) throw new Error(genericApiRequest._doc.responseData.error);
  131. return genericApiRequest._doc.responseData;
  132. }
  133. await MusicBrainzModule.rateLimiter.continue();
  134. MusicBrainzModule.rateLimiter.restart();
  135. const responseData = await new Promise((resolve, reject) => {
  136. console.log(
  137. "INFO",
  138. `Making MusicBrainz API request to ${url} with ${new URLSearchParams(params).toString()}`
  139. );
  140. MusicBrainzModule.axios
  141. .get(url, {
  142. params,
  143. headers: {
  144. "User-Agent": `Musare/${MUSARE_VERSION} ( https://github.com/Musare/Musare )` // TODO set this in accordance to https://musicbrainz.org/doc/MusicBrainz_API/Rate_Limiting
  145. },
  146. timeout: MusicBrainzModule.requestTimeout
  147. })
  148. .then(({ data: responseData }) => {
  149. resolve(responseData);
  150. })
  151. .catch(err => {
  152. if (err.response.status === 404) {
  153. resolve(err.response.data);
  154. } else reject(err);
  155. });
  156. });
  157. if (responseData.error && responseData.error !== "Not Found") throw new Error(responseData.error);
  158. genericApiRequest = new MusicBrainzModule.GenericApiRequestModel({
  159. url,
  160. params,
  161. responseData,
  162. date: Date.now()
  163. });
  164. genericApiRequest.save();
  165. if (responseData.error) throw new Error(responseData.error);
  166. return responseData;
  167. }
  168. /**
  169. * Gets a list of recordings, releases and release groups for a given artist
  170. * @param {object} payload - object that contains the payload
  171. * @param {string} payload.artistId - MusicBrainz artist id
  172. * @returns {Promise} - returns promise (reject, resolve)
  173. */
  174. async GET_RECORDINGS_RELEASES_RELEASE_GROUPS(payload) {
  175. const { artistId } = payload;
  176. // TODO this job caches a response to prevent overloading the API, but doesn't do anything to prevent the same job for the same artistId from running more than once at the same time
  177. const existingResponse = await CacheModule.runJob(
  178. "HGET",
  179. {
  180. table: MUSICBRAINZ_RECORDINGS_RELEASES_RELEASE_GROUPS_REDIS_TABLE,
  181. key: artistId
  182. },
  183. this
  184. );
  185. if (existingResponse) return existingResponse;
  186. const fetchDate = new Date();
  187. let maxArtistReleases = 0;
  188. let artistReleases = [];
  189. do {
  190. const offset = artistReleases.length;
  191. // eslint-disable-next-line no-await-in-loop
  192. const response = await MusicBrainzModule.runJob(
  193. "GET_RECORDINGS_RELEASES_RELEASE_GROUPS_PAGE",
  194. { artistId, offset },
  195. this
  196. );
  197. maxArtistReleases = response["release-count"];
  198. artistReleases = [...artistReleases, ...response.releases];
  199. if (response.releases.length === 0) break;
  200. } while (maxArtistReleases >= artistReleases.length);
  201. let maxTrackArtistReleases = 0;
  202. let trackArtistReleases = [];
  203. do {
  204. const offset = trackArtistReleases.length;
  205. // eslint-disable-next-line no-await-in-loop
  206. const response = await MusicBrainzModule.runJob(
  207. "GET_RECORDINGS_RELEASES_RELEASE_GROUPS_PAGE_WITH_TRACK_ARTIST",
  208. { artistId, offset },
  209. this
  210. );
  211. maxTrackArtistReleases = response["release-count"];
  212. trackArtistReleases = [...trackArtistReleases, ...response.releases];
  213. if (response.releases.length === 0) break;
  214. } while (maxTrackArtistReleases >= trackArtistReleases.length);
  215. let maxRecordings = 0;
  216. let recordings = [];
  217. do {
  218. const offset = recordings.length;
  219. // eslint-disable-next-line no-await-in-loop
  220. const response = await MusicBrainzModule.runJob("GET_RECORDINGS_PAGE", { artistId, offset }, this);
  221. maxRecordings = response["recording-count"];
  222. recordings = [...recordings, ...response.recordings];
  223. if (response.recordings.length === 0) break;
  224. } while (maxRecordings >= recordings.length);
  225. const response = {
  226. artistReleases,
  227. trackArtistReleases,
  228. recordings,
  229. fetchDate
  230. };
  231. await CacheModule.runJob(
  232. "HSET",
  233. {
  234. table: MUSICBRAINZ_RECORDINGS_RELEASES_RELEASE_GROUPS_REDIS_TABLE,
  235. key: artistId,
  236. value: JSON.stringify(response)
  237. },
  238. this
  239. );
  240. return response;
  241. }
  242. /**
  243. * Gets a list of recordings, releases and release groups for a given artist, for a given page
  244. * @param {object} payload - object that contains the payload
  245. * @param {string} payload.artistId - MusicBrainz artist id
  246. * @param {string} payload.offset - offset by how much
  247. * @returns {Promise} - returns promise (reject, resolve)
  248. */
  249. async GET_RECORDINGS_RELEASES_RELEASE_GROUPS_PAGE(payload) {
  250. const { artistId, offset } = payload;
  251. const response = await MusicBrainzModule.runJob(
  252. "API_CALL",
  253. {
  254. url: `https://musicbrainz.org/ws/2/release`,
  255. params: {
  256. fmt: "json",
  257. artist: artistId,
  258. inc: [
  259. "url-rels",
  260. "release-groups",
  261. "release-group-level-rels",
  262. "url-rels",
  263. "recordings",
  264. "recording-level-rels",
  265. "work-rels",
  266. "url-rels"
  267. ].join("+"),
  268. limit: 100,
  269. offset
  270. }
  271. },
  272. this
  273. );
  274. return response;
  275. }
  276. /**
  277. * Gets a list of recordings, releases and release groups for a given track artist, for a given page
  278. * @param {object} payload - object that contains the payload
  279. * @param {string} payload.artistId - MusicBrainz artist id
  280. * @param {string} payload.offset - offset by how much
  281. * @returns {Promise} - returns promise (reject, resolve)
  282. */
  283. async GET_RECORDINGS_RELEASES_RELEASE_GROUPS_PAGE_WITH_TRACK_ARTIST(payload) {
  284. const { artistId, offset } = payload;
  285. const response = await MusicBrainzModule.runJob(
  286. "API_CALL",
  287. {
  288. url: `https://musicbrainz.org/ws/2/release`,
  289. params: {
  290. fmt: "json",
  291. track_artist: artistId,
  292. inc: [
  293. "url-rels",
  294. "release-groups",
  295. "release-group-level-rels",
  296. "url-rels",
  297. "recordings"
  298. // "recording-level-rels",
  299. // "work-rels",
  300. // "url-rels",
  301. // "artist-credits"
  302. ].join("+"),
  303. limit: 100,
  304. offset
  305. }
  306. },
  307. this
  308. );
  309. return response;
  310. }
  311. /**
  312. * Gets a list of recordings for an artist, for a given page
  313. * @param {object} payload - object that contains the payload
  314. * @param {string} payload.artistId - MusicBrainz artist id
  315. * @param {string} payload.offset - offset by how much
  316. * @returns {Promise} - returns promise (reject, resolve)
  317. */
  318. async GET_RECORDINGS_PAGE(payload) {
  319. const { artistId, offset } = payload;
  320. const response = await MusicBrainzModule.runJob(
  321. "API_CALL",
  322. {
  323. url: `https://musicbrainz.org/ws/2/recording`,
  324. params: {
  325. fmt: "json",
  326. artist: artistId,
  327. inc: ["artist-credits", "isrcs", "work-rels", "url-rels"].join("+"),
  328. limit: 100,
  329. offset
  330. }
  331. },
  332. this
  333. );
  334. return response;
  335. }
  336. /**
  337. * Searches for MusicBrainz artists
  338. * @param {object} payload - object that contains the payload
  339. * @param {string} payload.query - the artist query
  340. * @returns {Promise} - returns promise (reject, resolve)
  341. */
  342. async SEARCH_MUSICBRAINZ_ARTISTS(payload) {
  343. const { query } = payload;
  344. // TODO support offset
  345. const response = await MusicBrainzModule.runJob(
  346. "API_CALL",
  347. {
  348. url: `https://musicbrainz.org/ws/2/artist`,
  349. params: {
  350. fmt: "json",
  351. query,
  352. limit: 100,
  353. offset: 0
  354. }
  355. },
  356. this
  357. );
  358. return {
  359. musicbrainzArtists: response.artists
  360. };
  361. }
  362. }
  363. export default new _MusicBrainzModule();