UNPKG

musicbrainz-api

Version:

MusicBrainz API client for reading and submitting metadata

230 lines 8.98 kB
import { StatusCodes as HttpStatus } from 'http-status-codes'; import Debug from 'debug'; import { DigestAuth } from './digest-auth.js'; import { RateLimitThreshold } from 'rate-limit-threshold'; import * as mb from './musicbrainz.types.js'; import { HttpClient } from "./http-client.js"; export { XmlMetadata } from './xml/xml-metadata.js'; export { XmlIsrc } from './xml/xml-isrc.js'; export { XmlIsrcList } from './xml/xml-isrc-list.js'; export { XmlRecording } from './xml/xml-recording.js'; export * from './musicbrainz.types.js'; const debug = Debug('musicbrainz-api'); export class MusicBrainzApi { static fetchCsrf(html) { return { sessionKey: MusicBrainzApi.fetchValue(html, 'csrf_session_key'), token: MusicBrainzApi.fetchValue(html, 'csrf_token') }; } static fetchValue(html, key) { let pos = html.indexOf(`name="${key}"`); if (pos >= 0) { pos = html.indexOf('value="', pos + key.length + 7); if (pos >= 0) { pos += 7; const endValuePos = html.indexOf('"', pos); return html.substring(pos, endValuePos); } } } constructor(_config) { this.config = { ...{ baseUrl: 'https://musicbrainz.org' }, ..._config }; this.httpClient = this.initHttpClient(); const limits = this.config.rateLimit ?? [15, 18]; this.rateLimiter = new RateLimitThreshold(limits[0], limits[1]); } initHttpClient() { return new HttpClient({ baseUrl: this.config.baseUrl, timeout: 500, userAgent: `${this.config.appName}/${this.config.appVersion} ( ${this.config.appContactInfo} )` }); } async restGet(relUrl, query = {}) { query.fmt = 'json'; await this.applyRateLimiter(); const response = await this.httpClient.get(`/ws/2${relUrl}`, { query, retryLimit: 10 }); return response.json(); } lookup(entity, mbid, inc = []) { return this.restGet(`/${entity}/${mbid}`, { inc: inc.join(' ') }); } async lookupUrl(url, inc = []) { const result = await this.restGet('/url', { resource: url, inc: inc.join(' ') }); if (Array.isArray(url) && url.length <= 1) { return { 'url-count': 1, 'url-offset': 0, urls: [result], }; } return result; } browse(entity, query, inc) { query = query ? query : {}; if (inc) { // Serialize include parameter query.inc = inc.join(' '); } for (const pipedFilter of ['type', 'status']) { if (query[pipedFilter]) { // Serialize type parameter query[pipedFilter] = query[pipedFilter].join('|'); } } return this.restGet(`/${entity}`, query); } search(entity, query) { const urlQuery = { ...query }; if (typeof query.query === 'object') { urlQuery.query = makeAndQueryString(query.query); } if (Array.isArray(query.inc)) { urlQuery.inc = urlQuery.inc.join(' '); } return this.restGet(`/${entity}/`, urlQuery); } // --------------------------------------------------------------------------- async postRecording(xmlMetadata) { return this.post('recording', xmlMetadata); } async post(entity, xmlMetadata) { if (!this.config.appName || !this.config.appVersion) { throw new Error("XML-Post requires the appName & appVersion to be defined"); } const clientId = `${this.config.appName.replace(/-/g, '.')}-${this.config.appVersion}`; const path = `/ws/2/${entity}/`; // Get digest challenge let digest = ''; let n = 1; const postData = xmlMetadata.toXml(); do { await this.applyRateLimiter(); const response = await this.httpClient.post(path, { query: { client: clientId }, headers: { authorization: digest, 'Content-Type': 'application/xml' }, body: postData }); if (response.statusCode === HttpStatus.UNAUTHORIZED) { // Respond to digest challenge const auth = new DigestAuth(this.config.botAccount); const relPath = response.requestUrl.pathname; // Ensure path is relative digest = auth.digest(response.request.method, relPath, response.headers['www-authenticate']); ++n; } else { break; } } while (n++ < 5); } /** * Submit entity * @param entity Entity type e.g. 'recording' * @param mbid * @param formData */ async editEntity(entity, mbid, formData) { await this.applyRateLimiter(); this.session = await this.getSession(); formData.csrf_session_key = this.session.csrf.sessionKey; formData.csrf_token = this.session.csrf.token; formData.username = this.config.botAccount?.username; formData.password = this.config.botAccount?.password; formData.remember_me = 1; const response = await this.httpClient.postForm(`/${entity}/${mbid}/edit`, formData, { followRedirects: false }); if (response.status === HttpStatus.OK) throw new Error("Failed to submit form data"); if (response.status === HttpStatus.MOVED_TEMPORARILY) return; throw new Error(`Unexpected status code: ${response.status}`); } /** * Set URL to recording * @param recording Recording to update * @param url2add URL to add to the recording * @param editNote Edit note */ async addUrlToRecording(recording, url2add, editNote = '') { const formData = {}; formData['edit-recording.name'] = recording.title; // Required formData['edit-recording.comment'] = recording.disambiguation; formData['edit-recording.make_votable'] = true; formData['edit-recording.url.0.link_type_id'] = url2add.linkTypeId; formData['edit-recording.url.0.text'] = url2add.text; recording.isrcs?.forEach((isrcs, i) => { formData[`edit-recording.isrcs.${i}`] = isrcs; }); formData['edit-recording.edit_note'] = editNote; return this.editEntity('recording', recording.id, formData); } /** * Add ISRC to recording * @param recording Recording to update * @param isrc ISRC code to add */ async addIsrc(recording, isrc) { const formData = {}; formData["edit-recording.name"] = recording.title; // Required if (!recording.isrcs) { throw new Error('You must retrieve recording with existing ISRC values'); } if (recording.isrcs.indexOf(isrc) === -1) { recording.isrcs.push(isrc); for (const i in recording.isrcs) { formData[`edit-recording.isrcs.${i}`] = recording.isrcs[i]; } return this.editEntity('recording', recording.id, formData); } } // ----------------------------------------------------------------------------------------------------------------- // Helper functions // ----------------------------------------------------------------------------------------------------------------- /** * Add Spotify-ID to MusicBrainz recording. * This function will automatically lookup the recording title, which is required to submit the recording URL * @param recording MBID of the recording * @param spotifyId Spotify ID * @param editNote Comment to add. */ addSpotifyIdToRecording(recording, spotifyId, editNote) { if (spotifyId.length !== 22) { throw new Error('Invalid Spotify ID length'); } return this.addUrlToRecording(recording, { linkTypeId: mb.LinkType.stream_for_free, text: `https://open.spotify.com/track/${spotifyId}` }, editNote); } async getSession() { const response = await this.httpClient.get('login', { followRedirects: false }); return { csrf: MusicBrainzApi.fetchCsrf(await response.text()) }; } async applyRateLimiter() { if (!this.config.disableRateLimiting) { const delay = await this.rateLimiter.limit(); debug(`Client side rate limiter activated: cool down for ${Math.round(delay / 100) / 10} s...`); } } } export function makeAndQueryString(keyValuePairs) { return Object.keys(keyValuePairs).map(key => `${key}:"${keyValuePairs[key]}"`).join(' AND '); } //# sourceMappingURL=musicbrainz-api.js.map