musicbrainz-api
Version:
MusicBrainz API client for reading and submitting metadata
230 lines • 8.98 kB
JavaScript
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