musicbrainz.js 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  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. class RateLimitter {
  6. /**
  7. * Constructor
  8. * @param {number} timeBetween - The time between each allowed MusicBrainz request
  9. */
  10. constructor(timeBetween) {
  11. this.dateStarted = Date.now();
  12. this.timeBetween = timeBetween;
  13. }
  14. /**
  15. * Returns a promise that resolves whenever the ratelimit of a MusicBrainz request is done
  16. * @returns {Promise} - promise that gets resolved when the rate limit allows it
  17. */
  18. continue() {
  19. return new Promise(resolve => {
  20. if (Date.now() - this.dateStarted >= this.timeBetween) resolve();
  21. else setTimeout(resolve, this.dateStarted + this.timeBetween - Date.now());
  22. });
  23. }
  24. /**
  25. * Restart the rate limit timer
  26. */
  27. restart() {
  28. this.dateStarted = Date.now();
  29. }
  30. }
  31. let MusicBrainzModule;
  32. let DBModule;
  33. let CacheModule;
  34. class _MusicBrainzModule extends CoreClass {
  35. // eslint-disable-next-line require-jsdoc
  36. constructor() {
  37. super("musicbrainz", {
  38. concurrency: 1
  39. });
  40. MusicBrainzModule = this;
  41. }
  42. /**
  43. * Initialises the MusicBrainz module
  44. * @returns {Promise} - returns promise (reject, resolve)
  45. */
  46. async initialize() {
  47. DBModule = this.moduleManager.modules.db;
  48. CacheModule = this.moduleManager.modules.cache;
  49. AppModule = this.moduleManager.modules.app;
  50. this.genericApiRequestModel = this.GenericApiRequestModel = await DBModule.runJob("GET_MODEL", {
  51. modelName: "genericApiRequest"
  52. });
  53. this.rateLimiter = new RateLimitter(1100);
  54. this.requestTimeout = 5000;
  55. this.axios = axios.create();
  56. const { app } = await AppModule.runJob("GET_APP", {});
  57. // Proxy with caching for coverartarchive.org
  58. app.get(
  59. [
  60. "/caa/release/:mbid",
  61. "/caa/release/:mbid/:type",
  62. "/caa/release-group/:mbid",
  63. "/caa/release-group/:mbid/:type"
  64. ],
  65. async (req, res) => {
  66. // TODO add config option to proxy or redirect these requests
  67. // Remove /caa/ from the path
  68. const path = req.path.substring(5);
  69. console.log(`Request for ${path}`);
  70. const cachedResponse = await CacheModule.runJob("HGET", {
  71. table: MUSICBRAINZ_CAA_CACHED_RESPONSE_REDIS_TABLE,
  72. key: path
  73. });
  74. if (cachedResponse) {
  75. const { contentType, data, status } = cachedResponse;
  76. if (status === "404") {
  77. console.log(contentType, data, status);
  78. }
  79. res.set("Content-Type", contentType);
  80. res.status(status);
  81. res.send(Buffer.from(data, "base64"));
  82. return;
  83. }
  84. let CAARes;
  85. try {
  86. CAARes = await this.axios.get(`https://coverartarchive.org/${path}`, {
  87. responseType: "arraybuffer"
  88. });
  89. } catch (err) {
  90. if (err.response) CAARes = err.response;
  91. else {
  92. console.log(`Non-normal error when requesting coverartarchive.org: ${err.message}`);
  93. return;
  94. }
  95. }
  96. const contentType = CAARes.headers["content-type"];
  97. const data = Buffer.from(CAARes.data, "binary").toString("base64");
  98. const { status } = CAARes;
  99. await CacheModule.runJob("HSET", {
  100. table: MUSICBRAINZ_CAA_CACHED_RESPONSE_REDIS_TABLE,
  101. key: path,
  102. value: JSON.stringify({
  103. contentType,
  104. data,
  105. status
  106. })
  107. });
  108. res.set("Content-Type", contentType);
  109. res.status(status);
  110. res.send(Buffer.from(data, "base64"));
  111. }
  112. );
  113. }
  114. /**
  115. * Perform MusicBrainz API call
  116. * @param {object} payload - object that contains the payload
  117. * @param {string} payload.url - request url
  118. * @param {object} payload.params - request parameters
  119. * @returns {Promise} - returns promise (reject, resolve)
  120. */
  121. async API_CALL(payload) {
  122. const { url, params } = payload;
  123. let genericApiRequest = await MusicBrainzModule.GenericApiRequestModel.findOne({
  124. url,
  125. params
  126. });
  127. if (genericApiRequest) {
  128. if (genericApiRequest._doc.responseData.error) throw new Error(genericApiRequest._doc.responseData.error);
  129. return genericApiRequest._doc.responseData;
  130. }
  131. await MusicBrainzModule.rateLimiter.continue();
  132. MusicBrainzModule.rateLimiter.restart();
  133. const responseData = await new Promise((resolve, reject) => {
  134. console.log(
  135. "INFO",
  136. `Making MusicBrainz API request to ${url} with ${new URLSearchParams(params).toString()}`
  137. );
  138. MusicBrainzModule.axios
  139. .get(url, {
  140. params,
  141. headers: {
  142. "User-Agent": `Musare/${MUSARE_VERSION} ( https://github.com/Musare/Musare )` // TODO set this in accordance to https://musicbrainz.org/doc/MusicBrainz_API/Rate_Limiting
  143. },
  144. timeout: MusicBrainzModule.requestTimeout
  145. })
  146. .then(({ data: responseData }) => {
  147. resolve(responseData);
  148. })
  149. .catch(err => {
  150. if (err.response.status === 404) {
  151. resolve(err.response.data);
  152. } else reject(err);
  153. });
  154. });
  155. if (responseData.error && responseData.error !== "Not Found") throw new Error(responseData.error);
  156. genericApiRequest = new MusicBrainzModule.GenericApiRequestModel({
  157. url,
  158. params,
  159. responseData,
  160. date: Date.now()
  161. });
  162. genericApiRequest.save();
  163. if (responseData.error) throw new Error(responseData.error);
  164. return responseData;
  165. }
  166. /**
  167. * Gets a list of recordings, releases and release groups for a given artist
  168. * @param {object} payload - object that contains the payload
  169. * @param {string} payload.artistId - MusicBrainz artist id
  170. * @returns {Promise} - returns promise (reject, resolve)
  171. */
  172. async GET_RECORDINGS_RELEASES_RELEASE_GROUPS(payload) {
  173. const { artistId } = payload;
  174. // 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
  175. const existingResponse = await CacheModule.runJob(
  176. "HGET",
  177. {
  178. table: "musicbrainzRecordingsReleasesReleaseGroups",
  179. key: artistId
  180. },
  181. this
  182. );
  183. if (existingResponse) return existingResponse;
  184. const fetchDate = new Date();
  185. let maxReleases = 0;
  186. let releases = [];
  187. do {
  188. const offset = releases.length;
  189. // eslint-disable-next-line no-await-in-loop
  190. const response = await MusicBrainzModule.runJob(
  191. "GET_RECORDINGS_RELEASES_RELEASE_GROUPS_PAGE",
  192. { artistId, offset },
  193. this
  194. );
  195. maxReleases = response["release-count"];
  196. releases = [...releases, ...response.releases];
  197. if (response.releases.length === 0) break;
  198. } while (maxReleases >= releases.length);
  199. const response = {
  200. releases,
  201. fetchDate
  202. };
  203. await CacheModule.runJob(
  204. "HSET",
  205. {
  206. table: "musicbrainzRecordingsReleasesReleaseGroups",
  207. key: artistId,
  208. value: JSON.stringify(response)
  209. },
  210. this
  211. );
  212. return response;
  213. }
  214. /**
  215. * Gets a list of recordings, releases and release groups for a given artist, for a given page
  216. * @param {object} payload - object that contains the payload
  217. * @param {string} payload.artistId - MusicBrainz artist id
  218. * @param {string} payload.offset - offset by how much
  219. * @returns {Promise} - returns promise (reject, resolve)
  220. */
  221. async GET_RECORDINGS_RELEASES_RELEASE_GROUPS_PAGE(payload) {
  222. const { artistId, offset } = payload;
  223. const response = await MusicBrainzModule.runJob(
  224. "API_CALL",
  225. {
  226. url: `https://musicbrainz.org/ws/2/release`,
  227. params: {
  228. fmt: "json",
  229. artist: artistId,
  230. inc: "release-groups+recordings",
  231. limit: 100,
  232. offset
  233. }
  234. },
  235. this
  236. );
  237. return response;
  238. }
  239. /**
  240. * Searches for MusicBrainz artists
  241. * @param {object} payload - object that contains the payload
  242. * @param {string} payload.query - the artist query
  243. * @returns {Promise} - returns promise (reject, resolve)
  244. */
  245. async SEARCH_MUSICBRAINZ_ARTISTS(payload) {
  246. const { query } = payload;
  247. // TODO support offset
  248. const response = await MusicBrainzModule.runJob(
  249. "API_CALL",
  250. {
  251. url: `https://musicbrainz.org/ws/2/artist`,
  252. params: {
  253. fmt: "json",
  254. query,
  255. limit: 100,
  256. offset: 0
  257. }
  258. },
  259. this
  260. );
  261. return {
  262. musicbrainzArtists: response.artists
  263. };
  264. }
  265. }
  266. export default new _MusicBrainzModule();