musicbrainz-api 0.7.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/.idea/$PRODUCT_WORKSPACE_FILE$ +1 -1
  2. package/.idea/misc.xml +6 -0
  3. package/.idea/modules.xml +7 -7
  4. package/.idea/vcs.xml +5 -5
  5. package/README.md +482 -290
  6. package/lib/digest-auth.d.ts +21 -21
  7. package/lib/digest-auth.js +82 -87
  8. package/lib/musicbrainz-api.d.ts +293 -156
  9. package/lib/musicbrainz-api.js +541 -390
  10. package/lib/musicbrainz.types.d.ts +585 -379
  11. package/lib/musicbrainz.types.js +16 -16
  12. package/lib/rate-limiter.d.ts +8 -8
  13. package/lib/rate-limiter.js +31 -31
  14. package/lib/xml/xml-isrc-list.d.ts +17 -17
  15. package/lib/xml/xml-isrc-list.js +22 -22
  16. package/lib/xml/xml-isrc.d.ts +10 -10
  17. package/lib/xml/xml-isrc.js +17 -17
  18. package/lib/xml/xml-metadata.d.ts +6 -6
  19. package/lib/xml/xml-metadata.js +29 -29
  20. package/lib/xml/xml-recording.d.ts +24 -24
  21. package/lib/xml/xml-recording.js +20 -20
  22. package/package.json +104 -98
  23. package/.idea/checkstyle-idea.xml +0 -16
  24. package/.idea/codeStyles/Project.xml +0 -38
  25. package/.idea/codeStyles/codeStyleConfig.xml +0 -5
  26. package/.idea/inspectionProfiles/Project_Default.xml +0 -6
  27. package/.idea/shelf/Uncommitted_changes_before_Update_at_6-1-2022_11_38_[Default_Changelist]/shelved.patch +0 -58
  28. package/.idea/shelf/Uncommitted_changes_before_Update_at_6-1-2022_11_38__Default_Changelist_.xml +0 -4
  29. package/.idea/shelf/Uncommitted_changes_before_rebase_[Default_Changelist]/shelved.patch +0 -738
  30. package/.idea/shelf/Uncommitted_changes_before_rebase_[Default_Changelist]1/shelved.patch +0 -0
  31. package/.idea/shelf/Uncommitted_changes_before_rebase__Default_Changelist_.xml +0 -4
  32. package/.idea/workspace.xml +0 -722
  33. package/etc/config.js +0 -32
  34. package/yarn-error.log +0 -3608
@@ -1,391 +1,542 @@
1
- "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
5
- }) : (function(o, m, k, k2) {
6
- if (k2 === undefined) k2 = k;
7
- o[k2] = m[k];
8
- }));
9
- var __exportStar = (this && this.__exportStar) || function(m, exports) {
10
- for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
11
- };
12
- Object.defineProperty(exports, "__esModule", { value: true });
13
- exports.makeAndQueryString = exports.MusicBrainzApi = exports.XmlRecording = exports.XmlIsrcList = exports.XmlIsrc = exports.XmlMetadata = void 0;
14
- const assert = require("assert");
15
- const http_status_codes_1 = require("http-status-codes");
16
- const Url = require("url");
17
- const Debug = require("debug");
18
- var xml_metadata_1 = require("./xml/xml-metadata");
19
- Object.defineProperty(exports, "XmlMetadata", { enumerable: true, get: function () { return xml_metadata_1.XmlMetadata; } });
20
- var xml_isrc_1 = require("./xml/xml-isrc");
21
- Object.defineProperty(exports, "XmlIsrc", { enumerable: true, get: function () { return xml_isrc_1.XmlIsrc; } });
22
- var xml_isrc_list_1 = require("./xml/xml-isrc-list");
23
- Object.defineProperty(exports, "XmlIsrcList", { enumerable: true, get: function () { return xml_isrc_list_1.XmlIsrcList; } });
24
- var xml_recording_1 = require("./xml/xml-recording");
25
- Object.defineProperty(exports, "XmlRecording", { enumerable: true, get: function () { return xml_recording_1.XmlRecording; } });
26
- const digest_auth_1 = require("./digest-auth");
27
- const rate_limiter_1 = require("./rate-limiter");
28
- const mb = require("./musicbrainz.types");
29
- const got_1 = require("got");
30
- const tough = require("tough-cookie");
31
- __exportStar(require("./musicbrainz.types"), exports);
32
- const util_1 = require("util");
33
- const retries = 3;
34
- const debug = Debug('musicbrainz-api');
35
- class MusicBrainzApi {
36
- constructor(_config) {
37
- this.config = {
38
- baseUrl: 'https://musicbrainz.org'
39
- };
40
- Object.assign(this.config, _config);
41
- const cookieJar = new tough.CookieJar();
42
- this.getCookies = (0, util_1.promisify)(cookieJar.getCookies.bind(cookieJar));
43
- this.options = {
44
- prefixUrl: this.config.baseUrl,
45
- timeout: 20 * 1000,
46
- headers: {
47
- 'User-Agent': `${this.config.appName}/${this.config.appVersion} ( ${this.config.appContactInfo} )`
48
- },
49
- cookieJar
50
- };
51
- this.rateLimiter = new rate_limiter_1.RateLimiter(60, 50);
52
- }
53
- static escapeText(text) {
54
- let str = '';
55
- for (const chr of text) {
56
- // Escaping Special Characters: + - && || ! ( ) { } [ ] ^ " ~ * ? : \ /
57
- // ToDo: && ||
58
- switch (chr) {
59
- case '+':
60
- case '-':
61
- case '!':
62
- case '(':
63
- case ')':
64
- case '{':
65
- case '}':
66
- case '[':
67
- case ']':
68
- case '^':
69
- case '"':
70
- case '~':
71
- case '*':
72
- case '?':
73
- case ':':
74
- case '\\':
75
- case '/':
76
- str += '\\';
77
- }
78
- str += chr;
79
- }
80
- return str;
81
- }
82
- static fetchCsrf(html) {
83
- return {
84
- sessionKey: MusicBrainzApi.fetchValue(html, 'csrf_session_key'),
85
- token: MusicBrainzApi.fetchValue(html, 'csrf_token')
86
- };
87
- }
88
- static fetchValue(html, key) {
89
- let pos = html.indexOf(`name="${key}"`);
90
- if (pos >= 0) {
91
- pos = html.indexOf('value="', pos + key.length + 7);
92
- if (pos >= 0) {
93
- pos += 7;
94
- const endValuePos = html.indexOf('"', pos);
95
- const value = html.substring(pos, endValuePos);
96
- return value;
97
- }
98
- }
99
- }
100
- async restGet(relUrl, query = {}, attempt = 1) {
101
- query.fmt = 'json';
102
- let response;
103
- await this.rateLimiter.limit();
104
- do {
105
- response = await got_1.default.get('ws/2' + relUrl, Object.assign({ searchParams: query, responseType: 'json' }, this.options));
106
- if (response.statusCode !== 503)
107
- break;
108
- debug('Rate limiter kicked in, slowing down...');
109
- await rate_limiter_1.RateLimiter.sleep(500);
110
- } while (true);
111
- switch (response.statusCode) {
112
- case http_status_codes_1.StatusCodes.OK:
113
- return response.body;
114
- case http_status_codes_1.StatusCodes.BAD_REQUEST:
115
- case http_status_codes_1.StatusCodes.NOT_FOUND:
116
- throw new Error(`Got response status ${response.statusCode}: ${(0, http_status_codes_1.getReasonPhrase)(response.status)}`);
117
- case http_status_codes_1.StatusCodes.SERVICE_UNAVAILABLE: // 503
118
- default:
119
- const msg = `Got response status ${response.statusCode} on attempt #${attempt} (${(0, http_status_codes_1.getReasonPhrase)(response.status)})`;
120
- debug(msg);
121
- if (attempt < retries) {
122
- return this.restGet(relUrl, query, attempt + 1);
123
- }
124
- else
125
- throw new Error(msg);
126
- }
127
- }
128
- // -----------------------------------------------------------------------------------------------------------------
129
- // Lookup functions
130
- // -----------------------------------------------------------------------------------------------------------------
131
- /**
132
- * Generic lookup function
133
- * @param entity
134
- * @param mbid
135
- * @param inc
136
- */
137
- getEntity(entity, mbid, inc = []) {
138
- return this.restGet(`/${entity}/${mbid}`, { inc: inc.join(' ') });
139
- }
140
- /**
141
- * Lookup area
142
- * @param areaId Area MBID
143
- * @param inc Sub-queries
144
- */
145
- getArea(areaId, inc = []) {
146
- return this.getEntity('area', areaId, inc);
147
- }
148
- /**
149
- * Lookup artist
150
- * @param artistId Artist MBID
151
- * @param inc Sub-queries
152
- */
153
- getArtist(artistId, inc = []) {
154
- return this.getEntity('artist', artistId, inc);
155
- }
156
- /**
157
- * Lookup release
158
- * @param releaseId Release MBID
159
- * @param inc Include: artist-credits, labels, recordings, release-groups, media, discids, isrcs (with recordings)
160
- * ToDo: ['recordings', 'artists', 'artist-credits', 'isrcs', 'url-rels', 'release-groups']
161
- */
162
- getRelease(releaseId, inc = []) {
163
- return this.getEntity('release', releaseId, inc);
164
- }
165
- /**
166
- * Lookup release-group
167
- * @param releaseGroupId Release-group MBID
168
- * @param inc Include: ToDo
169
- */
170
- getReleaseGroup(releaseGroupId, inc = []) {
171
- return this.getEntity('release-group', releaseGroupId, inc);
172
- }
173
- /**
174
- * Lookup work
175
- * @param workId Work MBID
176
- */
177
- getWork(workId) {
178
- return this.getEntity('work', workId);
179
- }
180
- /**
181
- * Lookup label
182
- * @param labelId Label MBID
183
- */
184
- getLabel(labelId) {
185
- return this.getEntity('label', labelId);
186
- }
187
- /**
188
- * Lookup recording
189
- * @param recordingId Label MBID
190
- * @param inc Include: artist-credits, isrcs
191
- */
192
- getRecording(recordingId, inc = []) {
193
- return this.getEntity('recording', recordingId, inc);
194
- }
195
- async postRecording(xmlMetadata) {
196
- return this.post('recording', xmlMetadata);
197
- }
198
- async post(entity, xmlMetadata) {
199
- if (!this.config.appName || !this.config.appVersion) {
200
- throw new Error(`XML-Post requires the appName & appVersion to be defined`);
201
- }
202
- const clientId = `${this.config.appName.replace(/-/g, '.')}-${this.config.appVersion}`;
203
- const path = `ws/2/${entity}/`;
204
- // Get digest challenge
205
- let digest = null;
206
- let n = 1;
207
- const postData = xmlMetadata.toXml();
208
- do {
209
- await this.rateLimiter.limit();
210
- const response = await got_1.default.post(path, Object.assign({ searchParams: { client: clientId }, headers: {
211
- authorization: digest,
212
- 'Content-Type': 'application/xml'
213
- }, body: postData, throwHttpErrors: false }, this.options));
214
- if (response.statusCode === http_status_codes_1.StatusCodes.UNAUTHORIZED) {
215
- // Respond to digest challenge
216
- const auth = new digest_auth_1.DigestAuth(this.config.botAccount);
217
- const relPath = Url.parse(response.requestUrl).path; // Ensure path is relative
218
- digest = auth.digest(response.request.method, relPath, response.headers['www-authenticate']);
219
- ++n;
220
- }
221
- else {
222
- break;
223
- }
224
- } while (n++ < 5);
225
- }
226
- async login() {
227
- assert.ok(this.config.botAccount.username, 'bot username should be set');
228
- assert.ok(this.config.botAccount.password, 'bot password should be set');
229
- if (this.session && this.session.loggedIn) {
230
- for (const cookie of await this.getCookies(this.options.prefixUrl)) {
231
- if (cookie.key === 'remember_login') {
232
- return true;
233
- }
234
- }
235
- }
236
- this.session = await this.getSession(this.config.baseUrl);
237
- const redirectUri = '/success';
238
- const formData = {
239
- username: this.config.botAccount.username,
240
- password: this.config.botAccount.password,
241
- csrf_session_key: this.session.csrf.sessionKey,
242
- csrf_token: this.session.csrf.token,
243
- remember_me: 1
244
- };
245
- const response = await got_1.default.post('login', Object.assign({ followRedirect: false, searchParams: {
246
- returnto: redirectUri
247
- }, form: formData }, this.options));
248
- const success = response.statusCode === http_status_codes_1.StatusCodes.MOVED_TEMPORARILY && response.headers.location === redirectUri;
249
- if (success) {
250
- this.session.loggedIn = true;
251
- }
252
- return success;
253
- }
254
- /**
255
- * Logout
256
- */
257
- async logout() {
258
- const redirectUri = '/success';
259
- const response = await got_1.default.get('logout', Object.assign({ followRedirect: false, searchParams: {
260
- returnto: redirectUri
261
- } }, this.options));
262
- const success = response.statusCode === http_status_codes_1.StatusCodes.MOVED_TEMPORARILY && response.headers.location === redirectUri;
263
- if (success) {
264
- this.session.loggedIn = true;
265
- }
266
- return success;
267
- }
268
- /**
269
- * Submit entity
270
- * @param entity Entity type e.g. 'recording'
271
- * @param mbid
272
- * @param formData
273
- */
274
- async editEntity(entity, mbid, formData) {
275
- await this.rateLimiter.limit();
276
- this.session = await this.getSession(this.config.baseUrl);
277
- formData.csrf_session_key = this.session.csrf.sessionKey;
278
- formData.csrf_token = this.session.csrf.token;
279
- formData.username = this.config.botAccount.username;
280
- formData.password = this.config.botAccount.password;
281
- formData.remember_me = 1;
282
- const response = await got_1.default.post(`${entity}/${mbid}/edit`, Object.assign({ form: formData, followRedirect: false }, this.options));
283
- if (response.statusCode === http_status_codes_1.StatusCodes.OK)
284
- throw new Error(`Failed to submit form data`);
285
- if (response.statusCode === http_status_codes_1.StatusCodes.MOVED_TEMPORARILY)
286
- return;
287
- throw new Error(`Unexpected status code: ${response.statusCode}`);
288
- }
289
- /**
290
- * Set URL to recording
291
- * @param recording Recording to update
292
- * @param url2add URL to add to the recording
293
- * @param editNote Edit note
294
- */
295
- async addUrlToRecording(recording, url2add, editNote = '') {
296
- const formData = {};
297
- formData['edit-recording.name'] = recording.title; // Required
298
- formData['edit-recording.comment'] = recording.disambiguation;
299
- formData['edit-recording.make_votable'] = true;
300
- formData['edit-recording.url.0.link_type_id'] = url2add.linkTypeId;
301
- formData['edit-recording.url.0.text'] = url2add.text;
302
- for (const i in recording.isrcs) {
303
- formData[`edit-recording.isrcs.${i}`] = recording.isrcs[i];
304
- }
305
- formData['edit-recording.edit_note'] = editNote;
306
- return this.editEntity('recording', recording.id, formData);
307
- }
308
- /**
309
- * Add ISRC to recording
310
- * @param recording Recording to update
311
- * @param isrc ISRC code to add
312
- * @param editNote Edit note
313
- */
314
- async addIsrc(recording, isrc, editNote = '') {
315
- const formData = {};
316
- formData[`edit-recording.name`] = recording.title; // Required
317
- if (!recording.isrcs) {
318
- throw new Error('You must retrieve recording with existing ISRC values');
319
- }
320
- if (recording.isrcs.indexOf(isrc) === -1) {
321
- recording.isrcs.push(isrc);
322
- for (const i in recording.isrcs) {
323
- formData[`edit-recording.isrcs.${i}`] = recording.isrcs[i];
324
- }
325
- return this.editEntity('recording', recording.id, formData);
326
- }
327
- }
328
- // -----------------------------------------------------------------------------------------------------------------
329
- // Query functions
330
- // -----------------------------------------------------------------------------------------------------------------
331
- /**
332
- * Search an entity using a search query
333
- * @param query e.g.: '" artist: Madonna, track: Like a virgin"' or object with search terms: {artist: Madonna}
334
- * @param entity e.g. 'recording'
335
- * @param query Arguments
336
- */
337
- search(entity, query) {
338
- const urlQuery = Object.assign({}, query);
339
- if (typeof query.query === 'object') {
340
- urlQuery.query = makeAndQueryString(query.query);
341
- }
342
- if (Array.isArray(query.inc)) {
343
- urlQuery.inc = urlQuery.inc.join(' ');
344
- }
345
- return this.restGet('/' + entity + '/', urlQuery);
346
- }
347
- // -----------------------------------------------------------------------------------------------------------------
348
- // Helper functions
349
- // -----------------------------------------------------------------------------------------------------------------
350
- /**
351
- * Add Spotify-ID to MusicBrainz recording.
352
- * This function will automatically lookup the recording title, which is required to submit the recording URL
353
- * @param recording MBID of the recording
354
- * @param spotifyId Spotify ID
355
- * @param editNote Comment to add.
356
- */
357
- addSpotifyIdToRecording(recording, spotifyId, editNote) {
358
- assert.strictEqual(spotifyId.length, 22);
359
- return this.addUrlToRecording(recording, {
360
- linkTypeId: mb.LinkType.stream_for_free,
361
- text: 'https://open.spotify.com/track/' + spotifyId
362
- }, editNote);
363
- }
364
- searchArea(query) {
365
- return this.search('area', query);
366
- }
367
- searchArtist(query) {
368
- return this.search('artist', query);
369
- }
370
- searchRelease(query) {
371
- return this.search('release', query);
372
- }
373
- searchReleaseGroup(query) {
374
- return this.search('release-group', query);
375
- }
376
- searchUrl(query) {
377
- return this.search('url', query);
378
- }
379
- async getSession(url) {
380
- const response = await got_1.default.get('login', Object.assign({ followRedirect: false, responseType: 'text' }, this.options));
381
- return {
382
- csrf: MusicBrainzApi.fetchCsrf(response.body)
383
- };
384
- }
385
- }
386
- exports.MusicBrainzApi = MusicBrainzApi;
387
- function makeAndQueryString(keyValuePairs) {
388
- return Object.keys(keyValuePairs).map(key => `${key}:"${keyValuePairs[key]}"`).join(' AND ');
389
- }
390
- exports.makeAndQueryString = makeAndQueryString;
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.makeAndQueryString = exports.MusicBrainzApi = exports.XmlRecording = exports.XmlIsrcList = exports.XmlIsrc = exports.XmlMetadata = void 0;
18
+ const assert = require("assert");
19
+ const http_status_codes_1 = require("http-status-codes");
20
+ const Url = require("url");
21
+ const Debug = require("debug");
22
+ var xml_metadata_1 = require("./xml/xml-metadata");
23
+ Object.defineProperty(exports, "XmlMetadata", { enumerable: true, get: function () { return xml_metadata_1.XmlMetadata; } });
24
+ var xml_isrc_1 = require("./xml/xml-isrc");
25
+ Object.defineProperty(exports, "XmlIsrc", { enumerable: true, get: function () { return xml_isrc_1.XmlIsrc; } });
26
+ var xml_isrc_list_1 = require("./xml/xml-isrc-list");
27
+ Object.defineProperty(exports, "XmlIsrcList", { enumerable: true, get: function () { return xml_isrc_list_1.XmlIsrcList; } });
28
+ var xml_recording_1 = require("./xml/xml-recording");
29
+ Object.defineProperty(exports, "XmlRecording", { enumerable: true, get: function () { return xml_recording_1.XmlRecording; } });
30
+ const digest_auth_1 = require("./digest-auth");
31
+ const rate_limiter_1 = require("./rate-limiter");
32
+ const mb = require("./musicbrainz.types");
33
+ const got_1 = require("got");
34
+ const tough = require("tough-cookie");
35
+ __exportStar(require("./musicbrainz.types"), exports);
36
+ const util_1 = require("util");
37
+ const retries = 3;
38
+ const debug = Debug('musicbrainz-api');
39
+ class MusicBrainzApi {
40
+ constructor(_config) {
41
+ this.config = {
42
+ baseUrl: 'https://musicbrainz.org'
43
+ };
44
+ Object.assign(this.config, _config);
45
+ const cookieJar = new tough.CookieJar();
46
+ this.getCookies = (0, util_1.promisify)(cookieJar.getCookies.bind(cookieJar));
47
+ this.options = {
48
+ prefixUrl: this.config.baseUrl,
49
+ timeout: 20 * 1000,
50
+ headers: {
51
+ 'User-Agent': `${this.config.appName}/${this.config.appVersion} ( ${this.config.appContactInfo} )`
52
+ },
53
+ cookieJar
54
+ };
55
+ this.rateLimiter = new rate_limiter_1.RateLimiter(60, 50);
56
+ }
57
+ static escapeText(text) {
58
+ let str = '';
59
+ for (const chr of text) {
60
+ // Escaping Special Characters: + - && || ! ( ) { } [ ] ^ " ~ * ? : \ /
61
+ // ToDo: && ||
62
+ switch (chr) {
63
+ case '+':
64
+ case '-':
65
+ case '!':
66
+ case '(':
67
+ case ')':
68
+ case '{':
69
+ case '}':
70
+ case '[':
71
+ case ']':
72
+ case '^':
73
+ case '"':
74
+ case '~':
75
+ case '*':
76
+ case '?':
77
+ case ':':
78
+ case '\\':
79
+ case '/':
80
+ str += '\\';
81
+ }
82
+ str += chr;
83
+ }
84
+ return str;
85
+ }
86
+ static fetchCsrf(html) {
87
+ return {
88
+ sessionKey: MusicBrainzApi.fetchValue(html, 'csrf_session_key'),
89
+ token: MusicBrainzApi.fetchValue(html, 'csrf_token')
90
+ };
91
+ }
92
+ static fetchValue(html, key) {
93
+ let pos = html.indexOf(`name="${key}"`);
94
+ if (pos >= 0) {
95
+ pos = html.indexOf('value="', pos + key.length + 7);
96
+ if (pos >= 0) {
97
+ pos += 7;
98
+ const endValuePos = html.indexOf('"', pos);
99
+ const value = html.substring(pos, endValuePos);
100
+ return value;
101
+ }
102
+ }
103
+ }
104
+ async restGet(relUrl, query = {}, attempt = 1) {
105
+ query.fmt = 'json';
106
+ let response;
107
+ await this.rateLimiter.limit();
108
+ do {
109
+ response = await got_1.default.get('ws/2' + relUrl, Object.assign({ searchParams: query, responseType: 'json' }, this.options));
110
+ if (response.statusCode !== 503)
111
+ break;
112
+ debug('Rate limiter kicked in, slowing down...');
113
+ await rate_limiter_1.RateLimiter.sleep(500);
114
+ } while (true);
115
+ switch (response.statusCode) {
116
+ case http_status_codes_1.StatusCodes.OK:
117
+ return response.body;
118
+ case http_status_codes_1.StatusCodes.BAD_REQUEST:
119
+ case http_status_codes_1.StatusCodes.NOT_FOUND:
120
+ throw new Error(`Got response status ${response.statusCode}: ${(0, http_status_codes_1.getReasonPhrase)(response.status)}`);
121
+ case http_status_codes_1.StatusCodes.SERVICE_UNAVAILABLE: // 503
122
+ default:
123
+ const msg = `Got response status ${response.statusCode} on attempt #${attempt} (${(0, http_status_codes_1.getReasonPhrase)(response.status)})`;
124
+ debug(msg);
125
+ if (attempt < retries) {
126
+ return this.restGet(relUrl, query, attempt + 1);
127
+ }
128
+ else
129
+ throw new Error(msg);
130
+ }
131
+ }
132
+ // -----------------------------------------------------------------------------------------------------------------
133
+ // Lookup functions
134
+ // -----------------------------------------------------------------------------------------------------------------
135
+ /**
136
+ * Generic lookup function
137
+ * @param entity
138
+ * @param mbid
139
+ * @param inc
140
+ */
141
+ lookupEntity(entity, mbid, inc = []) {
142
+ return this.restGet(`/${entity}/${mbid}`, { inc: inc.join(' ') });
143
+ }
144
+ /**
145
+ * Lookup area
146
+ * @param areaId Area MBID
147
+ * @param inc Sub-queries
148
+ */
149
+ lookupArea(areaId, inc = []) {
150
+ return this.lookupEntity('area', areaId, inc);
151
+ }
152
+ /**
153
+ * Lookup artist
154
+ * @param artistId Artist MBID
155
+ * @param inc Sub-queries
156
+ */
157
+ lookupArtist(artistId, inc = []) {
158
+ return this.lookupEntity('artist', artistId, inc);
159
+ }
160
+ /**
161
+ * Lookup collection
162
+ * @param collectionId Collection MBID
163
+ * @param inc List of additional information to be included about the entity. Any of the entities directly linked to the entity can be included.
164
+ */
165
+ lookupCollection(collectionId, inc = []) {
166
+ return this.lookupEntity('collection', collectionId, inc);
167
+ }
168
+ /**
169
+ * Lookup instrument
170
+ * @param artistId Instrument MBID
171
+ * @param inc Sub-queries
172
+ */
173
+ lookupInstrument(instrumentId, inc = []) {
174
+ return this.lookupEntity('instrument', instrumentId, inc);
175
+ }
176
+ /**
177
+ * Lookup label
178
+ * @param labelId Area MBID
179
+ * @param inc Sub-queries
180
+ */
181
+ lookupLabel(labelId, inc = []) {
182
+ return this.lookupEntity('label', labelId, inc);
183
+ }
184
+ /**
185
+ * Lookup place
186
+ * @param placeId Area MBID
187
+ * @param inc Sub-queries
188
+ */
189
+ lookupPlace(placeId, inc = []) {
190
+ return this.lookupEntity('place', placeId, inc);
191
+ }
192
+ /**
193
+ * Lookup release
194
+ * @param releaseId Release MBID
195
+ * @param inc Include: artist-credits, labels, recordings, release-groups, media, discids, isrcs (with recordings)
196
+ * ToDo: ['recordings', 'artists', 'artist-credits', 'isrcs', 'url-rels', 'release-groups']
197
+ */
198
+ lookupRelease(releaseId, inc = []) {
199
+ return this.lookupEntity('release', releaseId, inc);
200
+ }
201
+ /**
202
+ * Lookup release-group
203
+ * @param releaseGroupId Release-group MBID
204
+ * @param inc Include: ToDo
205
+ */
206
+ lookupReleaseGroup(releaseGroupId, inc = []) {
207
+ return this.lookupEntity('release-group', releaseGroupId, inc);
208
+ }
209
+ /**
210
+ * Lookup recording
211
+ * @param recordingId Label MBID
212
+ * @param inc Include: artist-credits, isrcs
213
+ */
214
+ lookupRecording(recordingId, inc = []) {
215
+ return this.lookupEntity('recording', recordingId, inc);
216
+ }
217
+ /**
218
+ * Lookup work
219
+ * @param workId Work MBID
220
+ */
221
+ lookupWork(workId, inc = []) {
222
+ return this.lookupEntity('work', workId, inc);
223
+ }
224
+ /**
225
+ * Lookup URL
226
+ * @param urlId URL MBID
227
+ */
228
+ lookupUrl(urlId, inc = []) {
229
+ return this.lookupEntity('url', urlId, inc);
230
+ }
231
+ /**
232
+ * Lookup Event
233
+ * @param eventId Event MBID
234
+ * @param eventIncludes List of sub-queries to enable
235
+ */
236
+ lookupEvent(eventId, eventIncludes = []) {
237
+ return this.lookupEntity('event', eventId, eventIncludes);
238
+ }
239
+ // -----------------------------------------------------------------------------------------------------------------
240
+ // Browse functions
241
+ // -----------------------------------------------------------------------------------------------------------------
242
+ // https://wiki.musicbrainz.org/MusicBrainz_API#Browse
243
+ // https://wiki.musicbrainz.org/MusicBrainz_API#Linked_entities
244
+ // For example: http://musicbrainz.org/ws/2/release?label=47e718e1-7ee4-460c-b1cc-1192a841c6e5&offset=12&limit=2
245
+ /**
246
+ * Generic browse function
247
+ * https://wiki.musicbrainz.org/Development/JSON_Web_Service#Browse_Requests
248
+ * @param entity MusicBrainz entity
249
+ * @param query Query, like: {<entity>: <MBID:}
250
+ */
251
+ browseEntity(entity, query) {
252
+ return this.restGet(`/${entity}`, query);
253
+ }
254
+ /**
255
+ * Browse areas
256
+ * @param query Query, like: {<entity>: <MBID:}
257
+ */
258
+ browseAreas(query) {
259
+ return this.browseEntity('area', query);
260
+ }
261
+ /**
262
+ * Browse artists
263
+ * @param query Query, like: {<entity>: <MBID:}
264
+ */
265
+ browseArtists(query) {
266
+ return this.browseEntity('artist', query);
267
+ }
268
+ /**
269
+ * Browse collections
270
+ * @param query Query, like: {<entity>: <MBID:}
271
+ */
272
+ browseCollections(query) {
273
+ return this.browseEntity('collection', query);
274
+ }
275
+ /**
276
+ * Browse events
277
+ * @param query Query, like: {<entity>: <MBID:}
278
+ */
279
+ browseEvents(query) {
280
+ return this.browseEntity('event', query);
281
+ }
282
+ /**
283
+ * Browse instruments
284
+ * @param query Query, like: {<entity>: <MBID:}
285
+ */
286
+ browseInstruments(query) {
287
+ return this.browseEntity('instrument', query);
288
+ }
289
+ /**
290
+ * Browse labels
291
+ * @param query Query, like: {<entity>: <MBID:}
292
+ */
293
+ browseLabels(query) {
294
+ return this.browseEntity('label', query);
295
+ }
296
+ /**
297
+ * Browse places
298
+ * @param query Query, like: {<entity>: <MBID:}
299
+ */
300
+ browsePlaces(query) {
301
+ return this.browseEntity('place', query);
302
+ }
303
+ /**
304
+ * Browse recordings
305
+ * @param query Query, like: {<entity>: <MBID:}
306
+ */
307
+ browseRecordings(query) {
308
+ return this.browseEntity('recording', query);
309
+ }
310
+ /**
311
+ * Browse releases
312
+ * @param query Query, like: {<entity>: <MBID:}
313
+ */
314
+ browseReleases(query) {
315
+ return this.browseEntity('release', query);
316
+ }
317
+ /**
318
+ * Browse release-groups
319
+ * @param query Query, like: {<entity>: <MBID:}
320
+ */
321
+ browseReleaseGroups(query) {
322
+ return this.browseEntity('release-group', query);
323
+ }
324
+ /**
325
+ * Browse series
326
+ * @param query Query, like: {<entity>: <MBID:}
327
+ */
328
+ browseSeries(query) {
329
+ return this.browseEntity('series', query);
330
+ }
331
+ /**
332
+ * Browse works
333
+ * @param query Query, like: {<entity>: <MBID:}
334
+ */
335
+ browseWorks(query) {
336
+ return this.browseEntity('work', query);
337
+ }
338
+ /**
339
+ * Browse URLs
340
+ * @param query Query, like: {<entity>: <MBID:}
341
+ */
342
+ browseUrls(query) {
343
+ return this.browseEntity('url', query);
344
+ }
345
+ // ---------------------------------------------------------------------------
346
+ async postRecording(xmlMetadata) {
347
+ return this.post('recording', xmlMetadata);
348
+ }
349
+ async post(entity, xmlMetadata) {
350
+ if (!this.config.appName || !this.config.appVersion) {
351
+ throw new Error(`XML-Post requires the appName & appVersion to be defined`);
352
+ }
353
+ const clientId = `${this.config.appName.replace(/-/g, '.')}-${this.config.appVersion}`;
354
+ const path = `ws/2/${entity}/`;
355
+ // Get digest challenge
356
+ let digest = null;
357
+ let n = 1;
358
+ const postData = xmlMetadata.toXml();
359
+ do {
360
+ await this.rateLimiter.limit();
361
+ const response = await got_1.default.post(path, Object.assign({ searchParams: { client: clientId }, headers: {
362
+ authorization: digest,
363
+ 'Content-Type': 'application/xml'
364
+ }, body: postData, throwHttpErrors: false }, this.options));
365
+ if (response.statusCode === http_status_codes_1.StatusCodes.UNAUTHORIZED) {
366
+ // Respond to digest challenge
367
+ const auth = new digest_auth_1.DigestAuth(this.config.botAccount);
368
+ const relPath = Url.parse(response.requestUrl).path; // Ensure path is relative
369
+ digest = auth.digest(response.request.method, relPath, response.headers['www-authenticate']);
370
+ ++n;
371
+ }
372
+ else {
373
+ break;
374
+ }
375
+ } while (n++ < 5);
376
+ }
377
+ async login() {
378
+ assert.ok(this.config.botAccount.username, 'bot username should be set');
379
+ assert.ok(this.config.botAccount.password, 'bot password should be set');
380
+ if (this.session && this.session.loggedIn) {
381
+ for (const cookie of await this.getCookies(this.options.prefixUrl)) {
382
+ if (cookie.key === 'remember_login') {
383
+ return true;
384
+ }
385
+ }
386
+ }
387
+ this.session = await this.getSession(this.config.baseUrl);
388
+ const redirectUri = '/success';
389
+ const formData = {
390
+ username: this.config.botAccount.username,
391
+ password: this.config.botAccount.password,
392
+ csrf_session_key: this.session.csrf.sessionKey,
393
+ csrf_token: this.session.csrf.token,
394
+ remember_me: 1
395
+ };
396
+ const response = await got_1.default.post('login', Object.assign({ followRedirect: false, searchParams: {
397
+ returnto: redirectUri
398
+ }, form: formData }, this.options));
399
+ const success = response.statusCode === http_status_codes_1.StatusCodes.MOVED_TEMPORARILY && response.headers.location === redirectUri;
400
+ if (success) {
401
+ this.session.loggedIn = true;
402
+ }
403
+ return success;
404
+ }
405
+ /**
406
+ * Logout
407
+ */
408
+ async logout() {
409
+ const redirectUri = '/success';
410
+ const response = await got_1.default.get('logout', Object.assign({ followRedirect: false, searchParams: {
411
+ returnto: redirectUri
412
+ } }, this.options));
413
+ const success = response.statusCode === http_status_codes_1.StatusCodes.MOVED_TEMPORARILY && response.headers.location === redirectUri;
414
+ if (success) {
415
+ this.session.loggedIn = true;
416
+ }
417
+ return success;
418
+ }
419
+ /**
420
+ * Submit entity
421
+ * @param entity Entity type e.g. 'recording'
422
+ * @param mbid
423
+ * @param formData
424
+ */
425
+ async editEntity(entity, mbid, formData) {
426
+ await this.rateLimiter.limit();
427
+ this.session = await this.getSession(this.config.baseUrl);
428
+ formData.csrf_session_key = this.session.csrf.sessionKey;
429
+ formData.csrf_token = this.session.csrf.token;
430
+ formData.username = this.config.botAccount.username;
431
+ formData.password = this.config.botAccount.password;
432
+ formData.remember_me = 1;
433
+ const response = await got_1.default.post(`${entity}/${mbid}/edit`, Object.assign({ form: formData, followRedirect: false }, this.options));
434
+ if (response.statusCode === http_status_codes_1.StatusCodes.OK)
435
+ throw new Error(`Failed to submit form data`);
436
+ if (response.statusCode === http_status_codes_1.StatusCodes.MOVED_TEMPORARILY)
437
+ return;
438
+ throw new Error(`Unexpected status code: ${response.statusCode}`);
439
+ }
440
+ /**
441
+ * Set URL to recording
442
+ * @param recording Recording to update
443
+ * @param url2add URL to add to the recording
444
+ * @param editNote Edit note
445
+ */
446
+ async addUrlToRecording(recording, url2add, editNote = '') {
447
+ const formData = {};
448
+ formData['edit-recording.name'] = recording.title; // Required
449
+ formData['edit-recording.comment'] = recording.disambiguation;
450
+ formData['edit-recording.make_votable'] = true;
451
+ formData['edit-recording.url.0.link_type_id'] = url2add.linkTypeId;
452
+ formData['edit-recording.url.0.text'] = url2add.text;
453
+ for (const i in recording.isrcs) {
454
+ formData[`edit-recording.isrcs.${i}`] = recording.isrcs[i];
455
+ }
456
+ formData['edit-recording.edit_note'] = editNote;
457
+ return this.editEntity('recording', recording.id, formData);
458
+ }
459
+ /**
460
+ * Add ISRC to recording
461
+ * @param recording Recording to update
462
+ * @param isrc ISRC code to add
463
+ * @param editNote Edit note
464
+ */
465
+ async addIsrc(recording, isrc, editNote = '') {
466
+ const formData = {};
467
+ formData[`edit-recording.name`] = recording.title; // Required
468
+ if (!recording.isrcs) {
469
+ throw new Error('You must retrieve recording with existing ISRC values');
470
+ }
471
+ if (recording.isrcs.indexOf(isrc) === -1) {
472
+ recording.isrcs.push(isrc);
473
+ for (const i in recording.isrcs) {
474
+ formData[`edit-recording.isrcs.${i}`] = recording.isrcs[i];
475
+ }
476
+ return this.editEntity('recording', recording.id, formData);
477
+ }
478
+ }
479
+ // -----------------------------------------------------------------------------------------------------------------
480
+ // Query functions
481
+ // -----------------------------------------------------------------------------------------------------------------
482
+ /**
483
+ * Search an entity using a search query
484
+ * @param query e.g.: '" artist: Madonna, track: Like a virgin"' or object with search terms: {artist: Madonna}
485
+ * @param entity e.g. 'recording'
486
+ * @param query Arguments
487
+ */
488
+ search(entity, query) {
489
+ const urlQuery = Object.assign({}, query);
490
+ if (typeof query.query === 'object') {
491
+ urlQuery.query = makeAndQueryString(query.query);
492
+ }
493
+ if (Array.isArray(query.inc)) {
494
+ urlQuery.inc = urlQuery.inc.join(' ');
495
+ }
496
+ return this.restGet('/' + entity + '/', urlQuery);
497
+ }
498
+ // -----------------------------------------------------------------------------------------------------------------
499
+ // Helper functions
500
+ // -----------------------------------------------------------------------------------------------------------------
501
+ /**
502
+ * Add Spotify-ID to MusicBrainz recording.
503
+ * This function will automatically lookup the recording title, which is required to submit the recording URL
504
+ * @param recording MBID of the recording
505
+ * @param spotifyId Spotify ID
506
+ * @param editNote Comment to add.
507
+ */
508
+ addSpotifyIdToRecording(recording, spotifyId, editNote) {
509
+ assert.strictEqual(spotifyId.length, 22);
510
+ return this.addUrlToRecording(recording, {
511
+ linkTypeId: mb.LinkType.stream_for_free,
512
+ text: 'https://open.spotify.com/track/' + spotifyId
513
+ }, editNote);
514
+ }
515
+ searchArea(query) {
516
+ return this.search('area', query);
517
+ }
518
+ searchArtist(query) {
519
+ return this.search('artist', query);
520
+ }
521
+ searchRelease(query) {
522
+ return this.search('release', query);
523
+ }
524
+ searchReleaseGroup(query) {
525
+ return this.search('release-group', query);
526
+ }
527
+ searchUrl(query) {
528
+ return this.search('url', query);
529
+ }
530
+ async getSession(url) {
531
+ const response = await got_1.default.get('login', Object.assign({ followRedirect: false, responseType: 'text' }, this.options));
532
+ return {
533
+ csrf: MusicBrainzApi.fetchCsrf(response.body)
534
+ };
535
+ }
536
+ }
537
+ exports.MusicBrainzApi = MusicBrainzApi;
538
+ function makeAndQueryString(keyValuePairs) {
539
+ return Object.keys(keyValuePairs).map(key => `${key}:"${keyValuePairs[key]}"`).join(' AND ');
540
+ }
541
+ exports.makeAndQueryString = makeAndQueryString;
391
542
  //# sourceMappingURL=musicbrainz-api.js.map