import * as assert from 'assert' ; import { StatusCodes as HttpStatus , getReasonPhrase } from 'http-status-codes' ; import * as Url from 'url' ; import * as Debug from 'debug' ; export { XmlMetadata } from './xml/xml-metadata' ; export { XmlIsrc } from './xml/xml-isrc' ; export { XmlIsrcList } from './xml/xml-isrc-list' ; export { XmlRecording } from './xml/xml-recording' ; import { XmlMetadata } from './xml/xml-metadata' ; import { DigestAuth } from './digest-auth' ; import { RateLimiter } from './rate-limiter' ; import * as mb from './musicbrainz.types' ; import got , { Options } from 'got' ; import * as tough from 'tough-cookie' ; export * from './musicbrainz.types' ; import { promisify } from 'util' ; const retries = 3 ; /**
 * https://musicbrainz.org/doc/Development/XML_Web_Service/Version_2#Subqueries
 */ export type Includes = 'artists' | 'releases' | 'recordings' | 'artist-credits' | 'isrcs' | 'url-rels' | 'release-groups' | 'aliases' | 'discids' | 'annotation' | 'media' | 'area-rels' | 'artist-rels' | 'event-rels' | 'instrument-rels' | 'label-rels' | 'place-rels' | 'recording-rels' | 'release-rels' | 'release-group-rels' | 'series-rels' | 'work-rels' ; const debug = Debug ( 'musicbrainz-api' ) ; export interface IFormData { [ key ] : string | number ; } export interface IMusicBrainzConfig { botAccount ? : { username : string ; password : string ; } ; baseUrl ? : string ; appName ? : string ; appVersion ? : string ; /**
     * HTTP Proxy
     */ proxy ? : string ; /**
     * User e-mail address or application URL
     */ appContactInfo ? : string ; } export interface ISessionInformation { csrf : { sessionKey : string ; token : string ; } ; loggedIn ? : boolean ; } export class MusicBrainzApi { private static escapeText ( text ) { let str = '' ; for ( const chr of text ) { switch ( chr ) { case '+' : case '-' : case '!' : case '(' : case ')' : case '{' : case '}' : case '[' : case ']' : case '^' : case '"' : case '~' : case '*' : case '?' : case ':' : case '\\' : case '/' : str += '\\' ; } str += chr ; } return str ; } public readonly config : IMusicBrainzConfig = { baseUrl : 'https://musicbrainz.org' } ; private rateLimiter : RateLimiter ; private options : Options ; private session : ISessionInformation ; public static fetchCsrf ( html ) { return { sessionKey : MusicBrainzApi . fetchValue ( html , 'csrf_session_key' ) , token : MusicBrainzApi . fetchValue ( html , 'csrf_token' ) } ; } private 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 ) ; const value = html . substring ( pos , endValuePos ) ; return value ; } } } private getCookies : ( currentUrl : string | URL ) => Promise < tough . Cookie [ ] > ; public constructor ( _config ? ) { Object . assign ( this . config , _config ) ; const cookieJar = new tough . CookieJar ( ) ; this . getCookies = promisify ( cookieJar . getCookies . bind ( cookieJar ) ) ; this . options = { prefixUrl : this . config . baseUrl , timeout : 20 * 1000 , headers : { 'User-Agent' : `${ this . config . appName }/${ this . config . appVersion } ( ${ this . config . appContactInfo } )` } , cookieJar } ; this . rateLimiter = new RateLimiter ( 60 , 50 ) ; } public async restGet < T > ( relUrl , query = { } , attempt = 1 ) { query . fmt = 'json' ; let response ; await this . rateLimiter . limit ( ) ; do { response = await got . get ( 'ws/2' + relUrl , { searchParams : query , responseType : 'json' , ... this . options } ) ; if ( response . statusCode !== 503 ) break ; debug ( 'Rate limiter kicked in, slowing down...' ) ; await RateLimiter . sleep ( 500 ) ; } while ( true ) ; switch ( response . statusCode ) { case HttpStatus . OK : return response . body ; case HttpStatus . BAD_REQUEST : case HttpStatus . NOT_FOUND : throw new Error ( `Got response status ${ response . statusCode }: ${ getReasonPhrase ( response . status ) }` ) ; case HttpStatus . SERVICE_UNAVAILABLE : default : const msg = `Got response status ${ response . statusCode } on attempt #${ attempt } (${ getReasonPhrase ( response . status ) })` ; debug ( msg ) ; if ( attempt < retries ) { return this . restGet < T > ( relUrl , query , attempt + 1 ) ; } else throw new Error ( msg ) ; } } param entity param mbid param inc public getEntity < T > ( entity , mbid , inc = [ ] ) { return this . restGet < T > ( `/${ entity }/${ mbid }` , { inc : inc . join ( ' ' ) } ) ; } param areaId param inc public getArea ( areaId , inc = [ ] ) { return this . getEntity < mb . IArea > ( 'area' , areaId , inc ) ; } param artistId param inc public getArtist ( artistId , inc = [ ] ) { return this . getEntity < mb . IArtist > ( 'artist' , artistId , inc ) ; } param releaseId param inc public getRelease ( releaseId , inc = [ ] ) { return this . getEntity < mb . IRelease > ( 'release' , releaseId , inc ) ; } param releaseGroupId param inc public getReleaseGroup ( releaseGroupId , inc = [ ] ) { return this . getEntity < mb . IReleaseGroup > ( 'release-group' , releaseGroupId , inc ) ; } param workId public getWork ( workId ) { return this . getEntity < mb . IWork > ( 'work' , workId ) ; } param labelId public getLabel ( labelId ) { return this . getEntity < mb . ILabel > ( 'label' , labelId ) ; } param recordingId param inc public getRecording ( recordingId , inc = [ ] ) { return this . getEntity < mb . IRecording > ( 'recording' , recordingId , inc ) ; } public async postRecording ( xmlMetadata ) { return this . post ( 'recording' , xmlMetadata ) ; } public 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 }/` ; let digest = null ; let n = 1 ; const postData = xmlMetadata . toXml ( ) ; do { await this . rateLimiter . limit ( ) ; const response = await got . post ( path , { searchParams : { client : clientId } , headers : { authorization : digest , 'Content-Type' : 'application/xml' } , body : postData , throwHttpErrors : false , ... this . options } ) ; if ( response . statusCode === HttpStatus . UNAUTHORIZED ) { const auth = new DigestAuth ( this . config . botAccount ) ; const relPath = Url . parse ( response . requestUrl ) . path ; digest = auth . digest ( response . request . method , relPath , response . headers [ 'www-authenticate' ] ) ; ++ n ; } else { break ; } } while ( n ++ < 5 ) ; } public async login ( ) { assert . ok ( this . config . botAccount . username , 'bot username should be set' ) ; assert . ok ( this . config . botAccount . password , 'bot password should be set' ) ; if ( this . session && this . session . loggedIn ) { for ( const cookie of await this . getCookies ( this . options . prefixUrl ) ) { if ( cookie . key === 'remember_login' ) { return true ; } } } this . session = await this . getSession ( this . config . baseUrl ) ; const redirectUri = '/success' ; const formData = { username : this . config . botAccount . username , password : this . config . botAccount . password , csrf_session_key : this . session . csrf . sessionKey , csrf_token : this . session . csrf . token , remember_me : 1 } ; const response = await got . post ( 'login' , { followRedirect : false , searchParams : { returnto : redirectUri } , form : formData , ... this . options } ) ; const success = response . statusCode === HttpStatus . MOVED_TEMPORARILY && response . headers . location === redirectUri ; if ( success ) { this . session . loggedIn = true ; } return success ; } /**
     * Logout
     */ public async logout ( ) { const redirectUri = '/success' ; const response = await got . get ( 'logout' , { followRedirect : false , searchParams : { returnto : redirectUri } , ... this . options } ) ; const success = response . statusCode === HttpStatus . MOVED_TEMPORARILY && response . headers . location === redirectUri ; if ( success ) { this . session . loggedIn = true ; } return success ; } param entity param mbid param formData public async editEntity ( entity , mbid , formData ) { await this . rateLimiter . limit ( ) ; this . session = await this . getSession ( this . config . baseUrl ) ; 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 got . post ( `${ entity }/${ mbid }/edit` , { form : formData , followRedirect : false , ... this . options } ) ; if ( response . statusCode === HttpStatus . OK ) throw new Error ( `Failed to submit form data` ) ; if ( response . statusCode === HttpStatus . MOVED_TEMPORARILY ) return ; throw new Error ( `Unexpected status code: ${ response . statusCode }` ) ; } param recording param url2add param editNote public async addUrlToRecording ( recording , url2add , editNote = '' ) { const formData = { } ; formData [ 'edit-recording.name' ] = recording . title ; 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 ; for ( const i in recording . isrcs ) { formData [ `edit-recording.isrcs.${ i }` ] = recording . isrcs [ i ] ; } formData [ 'edit-recording.edit_note' ] = editNote ; return this . editEntity ( 'recording' , recording . id , formData ) ; } param recording param isrc param editNote public async addIsrc ( recording , isrc , editNote = '' ) { const formData = { } ; formData [ `edit-recording.name` ] = recording . title ; 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 ) ; } } param query param entity param query public search < T extends mb . ISearchResult > ( 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 < T > ( '/' + entity + '/' , urlQuery ) ; } param recording param spotifyId param editNote public addSpotifyIdToRecording ( recording , spotifyId , editNote ) { assert . strictEqual ( spotifyId . length , 22 ) ; return this . addUrlToRecording ( recording , { linkTypeId : mb . LinkType . stream_for_free , text : 'https://open.spotify.com/track/' + spotifyId } , editNote ) ; } public searchArea ( query ) { return this . search < mb . IAreaList > ( 'area' , query ) ; } public searchArtist ( query ) { return this . search < mb . IArtistList > ( 'artist' , query ) ; } public searchRelease ( query ) { return this . search < mb . IReleaseList > ( 'release' , query ) ; } public searchReleaseGroup ( query ) { return this . search < mb . IReleaseGroupList > ( 'release-group' , query ) ; } public searchUrl ( query ) { return this . search < mb . IUrlList > ( 'url' , query ) ; } private async getSession ( url ) { const response = await got . get ( 'login' , { followRedirect : false , responseType : 'text' , ... this . options } ) ; return { csrf : MusicBrainzApi . fetchCsrf ( response . body ) } ; } } export function makeAndQueryString ( keyValuePairs ) { return Object . keys ( keyValuePairs ) . map ( key => `${ key }:"${ keyValuePairs [ key ] }"` ) . join ( ' AND ' ) ; }