From 5df6dfeec09d54195ce44b92638ed87e5c44a434 Mon Sep 17 00:00:00 2001 From: Jordan Williams Date: Fri, 10 Oct 2025 15:22:27 -0500 Subject: [PATCH] Add MusicBrainz provider --- client/store/scanners.js | 6 +- server/finders/BookFinder.js | 48 +- server/providers/MusicBrainz.js | 978 +++++++++++++++++++++++++++++++- 3 files changed, 996 insertions(+), 36 deletions(-) diff --git a/client/store/scanners.js b/client/store/scanners.js index 2d3d465ca..b49ea2561 100644 --- a/client/store/scanners.js +++ b/client/store/scanners.js @@ -55,6 +55,10 @@ export const state = () => ({ { text: 'FantLab.ru', value: 'fantlab' + }, + { + text: 'MusicBrainz', + value: 'musicbrainz' } ], podcastProviders: [ @@ -123,4 +127,4 @@ export const mutations = { // Podcast providers not supported yet } } -} \ No newline at end of file +} diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index a6a6b07e6..d4d40b61e 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -9,11 +9,13 @@ const CustomProviderAdapter = require('../providers/CustomProviderAdapter') const Logger = require('../Logger') const { levenshteinDistance, levenshteinSimilarity, escapeRegExp, isValidASIN } = require('../utils/index') const htmlSanitizer = require('../utils/htmlSanitizer') +const MusicBrainz = require('../providers/MusicBrainz') class BookFinder { #providerResponseTimeout = 10000 constructor() { + this.musicbrainz = new MusicBrainz() this.openLibrary = new OpenLibrary() this.googleBooks = new GoogleBooks() this.audible = new Audible() @@ -23,11 +25,12 @@ class BookFinder { this.audiobookCovers = new AudiobookCovers() this.customProviderAdapter = new CustomProviderAdapter() - this.providers = ['google', 'itunes', 'openlibrary', 'fantlab', 'audiobookcovers', 'audible', 'audible.ca', 'audible.uk', 'audible.au', 'audible.fr', 'audible.de', 'audible.jp', 'audible.it', 'audible.in', 'audible.es'] + this.providers = ['google', 'itunes', 'musicbrainz', 'openlibrary', 'fantlab', 'audiobookcovers', 'audible', 'audible.ca', 'audible.uk', 'audible.au', 'audible.fr', 'audible.de', 'audible.jp', 'audible.it', 'audible.in', 'audible.es'] this.verbose = false } + // todo Not sure what this is for, but MusicBrainz can also be used to perform ISBN lookups. async findByISBN(isbn) { var book = await this.openLibrary.isbnLookup(isbn) if (book.errorCode) { @@ -194,6 +197,22 @@ class BookFinder { return books } + /** + * + * @param {string} title + * @param {string} author + * @param {string} asin + * @param {string} isbn + * @returns {Promise} + */ + async getMusicBrainzResults(title, author, asin, isbn) { + const books = await this.musicbrainz.search(title, author, asin, isbn) + // todo Sort by closest score and then duration? + if (this.verbose) Logger.debug(`MusicBrainz Book Search Results: ${books.length || 0}`) + if (!books) return [] + return books + } + /** * * @param {string} title @@ -389,7 +408,7 @@ class BookFinder { let actualTitleQuery = title let actualAuthorQuery = author - books = await this.runSearch(actualTitleQuery, actualAuthorQuery, provider, asin, maxTitleDistance, maxAuthorDistance) + books = await this.runSearch(actualTitleQuery, actualAuthorQuery, provider, asin, isbn, maxTitleDistance, maxAuthorDistance) if (!books.length && maxFuzzySearches > 0) { // Normalize title and author @@ -416,7 +435,7 @@ class BookFinder { if (++numFuzzySearches > maxFuzzySearches) break loop_author actualTitleQuery = titleCandidate actualAuthorQuery = authorCandidate - books = await this.runSearch(actualTitleQuery, actualAuthorQuery, provider, asin, maxTitleDistance, maxAuthorDistance) + books = await this.runSearch(actualTitleQuery, actualAuthorQuery, provider, asin, isbn, maxTitleDistance, maxAuthorDistance) if (books.length) break loop_author } } @@ -431,13 +450,17 @@ class BookFinder { book.matchConfidence = this.calculateMatchConfidence(book, libraryItemDurationMinutes, actualTitleQuery, actualAuthorQuery, isTitleAsin) }) - if (isAudibleProvider && libraryItemDurationMinutes) { + if ((isAudibleProvider || provider === "musicbrainz") && libraryItemDurationMinutes) { books.sort((a, b) => { - const aDuration = a.duration || Number.POSITIVE_INFINITY - const bDuration = b.duration || Number.POSITIVE_INFINITY - const aDurationDiff = Math.abs(aDuration - libraryItemDurationMinutes) - const bDurationDiff = Math.abs(bDuration - libraryItemDurationMinutes) - return aDurationDiff - bDurationDiff + if (a.matchConfidence == b.matchConfidence) { + const aDuration = a.duration || Number.POSITIVE_INFINITY + const bDuration = b.duration || Number.POSITIVE_INFINITY + const aDurationDiff = Math.abs(aDuration - libraryItemDurationMinutes) + const bDurationDiff = Math.abs(bDuration - libraryItemDurationMinutes) + return aDurationDiff - bDurationDiff + } else { + return b.matchConfidence - a.matchConfidence + } }) } } @@ -564,12 +587,13 @@ class BookFinder { * @param {string} title * @param {string} author * @param {string} provider - * @param {string} asin only used for audible providers + * @param {string} asin only used for Audible and MusicBrainz providers + * @param {string} isbn * @param {number} maxTitleDistance only used for openlibrary provider * @param {number} maxAuthorDistance only used for openlibrary provider * @returns {Promise} */ - async runSearch(title, author, provider, asin, maxTitleDistance, maxAuthorDistance) { + async runSearch(title, author, provider, asin, isbn, maxTitleDistance, maxAuthorDistance) { Logger.debug(`Book Search: title: "${title}", author: "${author || ''}", provider: ${provider}`) let books = [] @@ -586,6 +610,8 @@ class BookFinder { books = await this.getFantLabResults(title, author) } else if (provider === 'audiobookcovers') { books = await this.getAudiobookCoversResults(title) + } else if (provider === 'musicbrainz') { + books = await this.getMusicBrainzResults(title, author, asin, isbn) } else { books = await this.getGoogleBooksResults(title, author) } diff --git a/server/providers/MusicBrainz.js b/server/providers/MusicBrainz.js index 6ae245968..616fccaf8 100644 --- a/server/providers/MusicBrainz.js +++ b/server/providers/MusicBrainz.js @@ -1,51 +1,981 @@ -const axios = require('axios') +const axios = require('axios').default +const path = require('path') const packageJson = require('../../package.json') const Logger = require('../Logger') -const { isNullOrNaN } = require('../utils/index') +const Throttle = require('p-throttle') class MusicBrainz { - constructor() { } + #responseTimeout = 10000 + static _instance = null + + constructor() { + // ensures MusicBrainz class is a singleton + if (MusicBrainz._instance) { + return MusicBrainz._instance + } + + // Rate limit is 1 request per second for the same source IP address. + // @see https://musicbrainz.org/doc/MusicBrainz_API/Rate_Limiting + this.limiter = Throttle({ + limit: 1, + strict: true, + interval: 150, + }) + + MusicBrainz._instance = this + } + + get baseUrl() { + return 'https://musicbrainz.org/ws/2' + } get userAgentString() { return `audiobookshelf/${packageJson.version} (https://audiobookshelf.org)` } - // https://musicbrainz.org/doc/MusicBrainz_API/Search - searchTrack(options) { - let luceneParts = [] - if (options.artist) { - luceneParts.push(`artist:${options.artist}`) + /** + * Escape special Lucene search characters. + * @param {string} input + * @returns {string} + */ + escapeLuceneCharacters(input) { + if (typeof input === 'string' || input instanceof String) { + const specialLuceneCharacters = ['\\', '+', '-', '&&', '||', '!', '(', ')', '{', '}', '[', ']', '^', '"', '~', '*', '?', ':', '/'] + return specialLuceneCharacters.reduce((accumulator, character) => { + return accumulator.replaceAll(character, '\\' + character) + }, input) } - if (options.isrc) { - luceneParts.push(`isrc:${options.isrc}`) + } + + /** + * Attempt to parse the authors and narrators from the MusicBrainz artist credit. + * @param {Object[]} artistCredit The MusicBrainz artist credit + * @returns {Object[], Object[]} Parsed authors and narrators + */ + parseArtistCredit(artistCredit) { + // Up until the first join phrase which contains letter, assume each artist is an author, otherwise ignore the artist. + // The artist where the join phrase contains the first letter should be assumed to be an author. + // After a join phrase containing any of 'read', 'narrat', or 'perform' is reached, assume each artist is a narrator until the first join phrase which contains letter. + // The artist where the join phrase contains the first letter should be assumed to be a narrator. + let type = 'author' + let authors = [] + let narrators = [] + var regExp = /[a-zA-Z]/g + artistCredit.forEach((credit) => { + if (type === 'author') { + authors.push(credit.name) + } else if (type === 'narrator') { + narrators.push(credit.name) + } + if (credit?.joinphrase) { + if (credit.joinphrase === ' and ' || credit.joinphrase === ', and ') { + // nothing changes + } else if (credit.joinphrase.includes('read') || credit.joinphrase.includes('narrat') || credit.joinphrase.includes('perform')) { + type = 'narrator' + } else if (regExp.test(credit.joinphrase)) { + type = 'unknown' + } + } + }) + return { + authors: authors, + narrators: narrators } - if (options.title) { - luceneParts.push(`recording:${options.title}`) + } + + /** + * Parse an Audible ASIN from an Audible URL. + * @param {string} urlString + * @returns {string} + */ + parseAudibleAsinFromUrl(urlString) { + const url = new URL(urlString) + if (url?.hostname?.startsWith('www.audible.')) { + return path.parse(url.pathname).base.toUpperCase() } - if (options.album) { - luceneParts.push(`release:${options.album}`) + } + + /** + * Parse Audible ASIN from MusicBrainz relations. + * @param {Object[]} relations + * @returns {string} + */ + parseAudibleAsin(relations) { + if (!relations) { + return null } - if (!luceneParts.length) { - Logger.error(`[MusicBrainz] Invalid search options - must have at least one of artist, isrc, title, album`) + const asins = relations + .filter((relation) => { + return relation['target-type'] === 'url' && relation.type.startsWith('purchase') + }) + .map((relation) => { + return this.parseAudibleAsinFromUrl(relation.url.resource) + }) + .filter((asin) => { + return asin !== undefined + }) + if (asins.length === 0) { + return null + } + if (asins.length > 1) { + Logger.error(`[MusicBrainz] Parsed multiple Audible ASINs: ${JSON.stringify(asins)}. Ignoring all but the first.`) + } + return asins[0] + } + + /** + * Parse series from MusicBrainz relations. + * @param {Object[]} relations + * @returns {Object[]} + */ + parseSeries(relations) { + return relations + .filter((relation) => { + return relation['target-type'] === 'series' + }) + .map((relation) => { + return { + sequence: relation['ordering-key'], + series: relation.series.name, + type: relation.series.type + } + }) + .sort((a, b) => { + if (a.series < b.series) { + return 1 + } + if (a.series > b.series) { + return -1 + } + if (a.type > b.type) { + return 0 + } + // Order of precedence is release, release group, then work + if (a.type === 'Release series') { + return 1 + } + if (b.type === 'Release series') { + return -1 + } + if (a.type === 'Release group series') { + return 1 + } + if (b.type === 'Release group series') { + return -1 + } + if (a.type === 'Work series') { + return 1 + } + if (b.type === 'Work series') { + return -1 + } + return a.sequence - b.sequence + }) + .reduce((accumulator, s) => { + if (!accumulator.some((s2) => { return s2.series === s.series })) { + return accumulator.concat([{ + series: s.series, + sequence: s.sequence + }]) + } + return accumulator + }, []) + .sort((a, b) => { + if ('sequence' in a && 'sequence' in b) { + return b.sequence - a.sequence + } else if ('sequence' in a) { + return a.sequence + } else if ('sequence' in b) { + return b.sequence + } + return 0 + }) + } + + /** + * Parse all relationships from a MusicBrainz release. + * @param {Object[]} relations + * @returns {Object[]} + */ + parseRelations(release) { + return [].concat(release?.['release-group']?.relations) + .concat(release.relations) + .concat( + release.media.reduce((mediumAccumulator, medium) => { + return mediumAccumulator.concat( + medium.tracks.reduce((trackAccumulator, track) => { + return trackAccumulator.concat( + track.recording.relations.concat(track.recording.relations.reduce((recordingAccumulator, recording_relation) => { + if (recording_relation['target-type'] === 'work') { + return recordingAccumulator.concat(recording_relation.work.relations) + } + return recordingAccumulator + }, [])) + ) + }, []) + ) + }, []) + ) + .filter((element) => element !== undefined) + .reduce((accumulator, relation) => { + const found = accumulator.find((item) => { + const keys = ['name', 'target-credit', 'target-type', 'type', 'direction', 'attributes', 'artist', 'work', 'label', 'url'] + for (let i = 0; i < keys.length; i++) { + const key = keys.at(i) + if (item?.[key] !== relation?.[key]) { + return false + } + } + return true + }) + if (found) { + return accumulator + } + return accumulator.concat([relation]) + }, []) + } + + /** + * Parse genres and tags from MusicBrainz. + * @param {Object[]} allTags Tags from the release, release group, works, recordings, and series + * @returns {Object[]} + */ + parseTags(allTags) { + const specialPurposeTags = ['abridged', 'accompanying documents', 'audiobook', 'chapters', 'explicit', 'unabridged'] + const ignoredTags = ['abridged', 'audiobook', 'chapters', 'explicit', 'unabridged'] + let sortTags = (a, b) => { + if (!a?.name || !b?.name || !'count' in a || !'count' in b) { + return -1 + } + if (a.count === b.count) { + return a.name.toString().localeCompare(b.name) + } + return b.count - a.count + } + + const sortedTags = allTags.sort(sortTags) + function onlyUnique(value, index, array) { + return array.indexOf(value) === index + } + const genres = sortedTags + .filter((tag) => { + return !specialPurposeTags.includes(tag.name) + }) + .map((tag) => { + return tag.name + }) + .filter(onlyUnique) + const tags = sortedTags + .filter((tag) => { + return specialPurposeTags.includes(tag.name) && !ignoredTags.includes(tag.name) + }) + .map((tag) => { + return tag.name + }) + .filter(onlyUnique) + const hasAbridgedTag = sortedTags.some((tag) => { + return tag.name === 'abridged' + }) + const hasUnabridgedTag = sortedTags.some((tag) => { + return tag.name === 'unabridged' + }) + const explicit = sortedTags.some((tag) => { + return tag.name === 'explicit' + }) + let abridged = hasAbridgedTag && !hasUnabridgedTag + if (hasAbridgedTag && hasUnabridgedTag) { + Logger.error('[MusicBrainz] Both abridged and unabridged tags are present') + const abridgedCount = sortedTags.find((tag) => { + return tag.name === 'abridged' + }).count + const unabridgedCount = sortedTags.find((tag) => { + return tag.name === 'unabridged' + }).count + if (abridgedCount === unabridgedCount) { + Logger.error(`[MusicBrainz] Both abridged and unabridged tags are present and they both have same count ${abridgedCount}`) + abridged = null + } else { + abridged = abridgedCount > unabridgedCount + } + } + + return { + abridged: abridged, + explicit: explicit, + genres: genres, + tags: tags + } + } + + /** + * Look up all of the details of a MusicBrainz series. + * @param {string} id MusicBrainz Series id + * @param {number} [timeout] response timeout in ms + * @returns {Promise} + */ + async getSeries(id, timeout = this.#responseTimeout) { + if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout + const includes = { + inc: ['tags'].join('+') + } + const config = { + headers: { + 'User-Agent': this.userAgentString, + Accept: 'application/json' + }, + timeout + } + Logger.debug(`[MusicBrainz] Series search id: ${id}`) + try { + const response = await this._processRequest(this.limiter(() => axios.get(`${this.baseUrl}/series/${id}/`, { params: includes }, config))) + Logger.debug(`[MusicBrainz] Series got: ${JSON.stringify(response.data)}`) + if (!response?.data) return null + return response.data + } catch (error) { + Logger.error('[MusicBrainz] Error getting series', error.message) + return null + } + } + + /** + * Look up all of the details of a MusicBrainz work. + * @param {string} id MusicBrainz Work id + * @param {number} [timeout] response timeout in ms + * @returns {Promise} + */ + async getWork(id, timeout = this.#responseTimeout) { + if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout + const includes = { + inc: ['tags'].join('+') + } + const config = { + headers: { + 'User-Agent': this.userAgentString, + Accept: 'application/json' + }, + timeout + } + try { + const response = await this._processRequest(this.limiter(() => axios.get(`${this.baseUrl}/work/${id}/`, { params: includes }, config))) + Logger.debug(`[MusicBrainz] getWork: ${id}`) + if (!response?.data) return null + return response.data + } catch (error) { + Logger.error('[MusicBrainz] Error getting work', error.message) + return null + } + } + + /** + * Look up the essential details for a release using as few API calls and resources as possible. + * This is useful when displaying matches for each release in the UI. + * @param {string} id MusicBrainz Release id + * @param {number} [timeout] response timeout in ms + * @returns {Promise} + */ + async getReleaseMinimal(id, timeout = this.#responseTimeout) { + if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout + const includes = { + inc: [ + 'artist-credits', + 'media', + 'recordings', + 'work-rels', + 'series-rels', + 'artist-rels', + 'recording-level-rels', + 'work-level-rels', + // Non-essential for the Match list. + 'labels', + 'label-rels', + 'release-group-rels', + 'release-group-level-rels', + 'tags', + 'release-groups', + 'url-rels', + ].join('+') + } + const config = { + headers: { + 'User-Agent': this.userAgentString, + Accept: 'application/json' + }, + timeout + } + try { + const response = await this._processRequest(this.limiter(() => axios.get(`${this.baseUrl}/release/${id}/`, { params: includes }, config))) + if (!response?.data) return null + const release = response.data + const allRelations = this.parseRelations(response.data) + const releaseTags = release.tags.map((tag) => { + tag.type = 'release' + return tag + }) + const releaseGroupTags = release['release-group'].tags.map((tag) => { + tag.type = 'release group' + return tag + }) + const recordingTags = release.media + .reduce((mediumAccumulator, medium) => { + return mediumAccumulator.concat( + medium.tracks.reduce((trackAccumulator, track) => { + return trackAccumulator.concat( + track.recording.tags.map((tag) => { + tag.type = 'recording' + return tag + }) + ) + }, []) + ) + }, []) + .reduce((accumulator, tag) => { + const found = accumulator.find((item) => { return item.name === tag.name }) + if (found) { + return accumulator + } + return accumulator.concat([tag]) + }, []) + const allTags = releaseTags.concat(releaseGroupTags).concat(recordingTags) + const parsedTags = this.parseTags(allTags) + const series = this.parseSeries(allRelations) + const duration = Math.floor( + response.data.media.reduce((mediumAccumulator, medium) => { + return ( + mediumAccumulator + + medium.tracks.reduce((trackAccumulator, track) => { + return trackAccumulator + track.length + }, 0) + ) + }, 0) / 60_000 + ) + const artistCreditArtists = response.data['artist-credit'].map((artistCredit) => { + return { + id: artistCredit.artist.id, + name: artistCredit.name + } + }) + const authors = allRelations + .filter((relation) => { + return relation['target-type'] === 'artist' && relation.type === 'writer' + }) + .map((relation) => { + return relation.artist + }) + .reduce((accumulator, author) => { + const found = accumulator.find((item) => { return item.name === author.name }) + if (found) { + return accumulator + } + return accumulator.concat([author]) + }, []) + .map((author) => { + const credit = artistCreditArtists.find((artistCredit) => { + return artistCredit.id === author.id + }) + if ('name' in credit && credit.name.length > 0) { + return credit.name + } + if ('target-credit' in author && author['target-credit'].length > 0) { + return author['target-credit'] + } + return author.name + }) + const narrators = allRelations + .filter((relation) => { + return relation?.['target-type'] === 'artist' && relation?.type === 'vocal' && relation?.attributes?.includes('spoken vocals') && relation?.artist?.id !== '125ec42a-7229-4250-afc5-e057484327fe' // unknown + }) + .map((relation) => { + return relation?.artist + }) + .reduce((accumulator, narrator) => { + const found = accumulator.find((item) => { return item.id === narrator.id }); + if (found) { + return accumulator + } + return accumulator.concat([narrator]) + }, []) + .map((narrator) => { + const credit = artistCreditArtists.find((artistCredit) => { + return artistCredit.id === narrator.id + }) + if (credit?.name && credit.name.length > 0) { + return credit.name + } + if (narrator?.['target-credit'] && narrator['target-credit'].length > 0) { + return narrator['target-credit'] + } + return narrator.name + }) + function onlyUnique(value, index, array) { + return array.indexOf(value) === index + } + return { + authors: authors, + duration: duration, + narrators: narrators, + series: series, + // Fields that are not essential for being displayed in the Match list. + // todo Remove these from here and only get them for a selected match. + abridged: parsedTags?.abridged, + audibleAsin: this.parseAudibleAsin(response.data?.relations), + explicit: parsedTags?.explicit, + genres: parsedTags?.genres, + publishers: release['label-info'].map((label) => { + return label?.label?.name + }) + .concat( + allRelations.filter((relation) => { + return relation?.['target-type'] === 'label' && relation?.type === 'published' + }).map((relation) => { + if (relation.label?.['target-credit']) { + return relation.label['target-credit'] + } + return relation.label.name + }) + ) + .filter((element) => element !== undefined) + .filter(onlyUnique) + .join(', '), + tags: parsedTags?.tags, + } + } catch (error) { + Logger.error('[MusicBrainz] Error getting release', error.message) + return null + } + } + + /** + * Look up all of the details of a MusicBrainz release. + * @param {string} id MusicBrainz Release id + * @param {number} [timeout] response timeout in ms + * @returns {Promise} + */ + async getReleaseFull(id, timeout = this.#responseTimeout) { + if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout + const includes = { + inc: [ + 'artist-credits', + 'labels', + 'media', + 'recordings', + 'release-groups', + 'tags', + 'release-group-rels', + 'work-rels', + 'series-rels', + 'artist-rels', + 'label-rels', + 'recording-level-rels', + 'release-group-level-rels', + 'work-level-rels', + 'url-rels' // for Audible ASIN + ].join('+') + } + const config = { + headers: { + 'User-Agent': this.userAgentString, + Accept: 'application/json' + }, + timeout + } + function onlyUnique(value, index, array) { + return array.indexOf(value) === index + } + try { + Logger.debug(`[MusicBrainz] getRelease: Here!`) + const response = await this._processRequest(this.limiter(() => axios.get(`${this.baseUrl}/release/${id}/`, { params: includes }, config))) + if (!response?.data) return null + Logger.debug(`[MusicBrainz] getRelease response: ${JSON.stringify(response.data.tags)}`) + Logger.debug(`[MusicBrainz] getRelease response2: ${JSON.stringify(response.data['release-group'].tags)}`) + const release = response.data + const allRelations = this.parseRelations(response.data) + const workTags = (await Promise.all( + allRelations + .filter((relation) => { + return relation['target-type'] === 'work' + }) + .map((relation) => { + return relation.work.id + }) + .reduce((accumulator, work) => { + const found = accumulator.find((id) => { return id === work }) + if (found) { + return accumulator + } + return accumulator.concat([work]) + }, []) + .map(async (work) => { + Logger.debug(`[MusicBrainz] work: ${JSON.stringify(work)}`) + return await this.getWork(work, timeout) + }) + )).reduce((accumulator, work) => { + return accumulator.concat( + work.tags.map((tag) => { + tag.type = 'work' + return tag + }) + ) + }, []) + const seriesTags = (await Promise.all( + allRelations + .filter((relation) => { + return relation['target-type'] === 'series' + }) + .map((relation) => { + return relation.series.id + }) + .filter(onlyUnique) + .map(async (series) => { + return await this.getSeries(series, timeout) + }) + ) + ).reduce((accumulator, series) => { + return accumulator.concat( + series.tags.map((tag) => { + tag.type = series.type + return tag + }) + ) + }, []) + const releaseTags = release.tags.map((tag) => { + tag.type = 'release' + return tag + }) + const releaseGroupTags = release['release-group'].tags.map((tag) => { + tag.type = 'release group' + return tag + }) + const recordingTags = release.media + .reduce((mediumAccumulator, medium) => { + return mediumAccumulator.concat( + medium.tracks.reduce((trackAccumulator, track) => { + return trackAccumulator.concat( + track.recording.tags.map((tag) => { + tag.type = 'recording' + return tag + }) + ) + }, []) + ) + }, []) + .reduce((accumulator, tag) => { + const found = accumulator.find((item) => { return item.name === tag.name }) + if (found) { + return accumulator + } + return accumulator.concat([tag]) + }, []) + const allTags = workTags.concat(seriesTags).concat(releaseTags).concat(releaseGroupTags).concat(recordingTags) + const parsedTags = this.parseTags(allTags) + const series = this.parseSeries(allRelations) + const duration = Math.floor( + response.data.media.reduce((mediumAccumulator, medium) => { + return ( + mediumAccumulator + + medium.tracks.reduce((trackAccumulator, track) => { + return trackAccumulator + track.length + }, 0) + ) + }, 0) / 60_000 + ) + const artistCreditArtists = response.data['artist-credit'].map((artistCredit) => { + return { + id: artistCredit.artist.id, + name: artistCredit.name + } + }) + const authors = allRelations + .filter((relation) => { + return relation['target-type'] === 'artist' && relation.type === 'writer' + }) + .map((relation) => { + return relation.artist + }) + .reduce((accumulator, author) => { + const found = accumulator.find((item) => { return item.name === author.name }) + if (found) { + return accumulator + } + return accumulator.concat([author]) + }, []) + .map((author) => { + const credit = artistCreditArtists.find((artistCredit) => { + return artistCredit.id === author.id + }) + if ('name' in credit && credit.name.length > 0) { + return credit.name + } + if ('target-credit' in author && author['target-credit'].length > 0) { + return author['target-credit'] + } + return author.name + }) + const narrators = allRelations + .filter((relation) => { + return relation?.['target-type'] === 'artist' && relation?.type === 'vocal' && relation?.attributes?.includes('spoken vocals') && relation?.artist?.id !== '125ec42a-7229-4250-afc5-e057484327fe' // unknown + }) + .map((relation) => { + return relation?.artist + }) + .reduce((accumulator, narrator) => { + const found = accumulator.find((item) => { return item.id === narrator.id }); + if (found) { + return accumulator + } + return accumulator.concat([narrator]) + }, []) + .map((narrator) => { + const credit = artistCreditArtists.find((artistCredit) => { + return artistCredit.id === narrator.id + }) + if (credit?.name && credit.name.length > 0) { + return credit.name + } + if (narrator?.['target-credit'] && narrator['target-credit'].length > 0) { + return narrator['target-credit'] + } + return narrator.name + }) + return { + abridged: parsedTags?.abridged, + audibleAsin: this.parseAudibleAsin(response.data?.relations), + authors: authors, + chapters: this.parseChapters(response.data), + duration: duration, + explicit: parsedTags?.explicit, + genres: parsedTags?.genres, + narrators: narrators, + publishers: release['label-info'].map((label) => { + return label?.label?.name + }) + .concat( + allRelations.filter((relation) => { + return relation?.['target-type'] === 'label' && relation?.type === 'published' + }).map((relation) => { + if (relation.label?.['target-credit']) { + return relation.label['target-credit'] + } + return relation.label.name + }) + ) + .filter((element) => element !== undefined) + .filter(onlyUnique) + .join(', '), + series: series, + tags: parsedTags?.tags, + } + } catch (error) { + Logger.error('[MusicBrainz] Error getting release', error.message) + return null + } + } + + /** + * Calculates start offsets of chapters given their durations. + * @param {[Int]} + * @returns {[Int]} + */ + lengthsToStartOffsets(lengths) { + return lengths.map((_length, index) => { + if (index === 0) { + return 0 + } + return lengths.slice(0, index).reduce((accumulator, currentValue) => accumulator + currentValue, 0) + }) + } + + /** + * Parse chapters from a MusicBrainz Release's track list into the format used by Audiobookshelf. + * @param {Object[]} release A MusicBrainz Release + * @returns {Object[]} + */ + parseChapters(release) { + const chapters = release.media + .reduce((mediumAccumulator, medium) => { + return mediumAccumulator.concat(medium.tracks) + }, []) + .map((track) => { + return { + title: track.title, + lengthMs: track.length + } + }) + const startOffsets = this.lengthsToStartOffsets( + chapters.map((chapter) => { + return chapter.lengthMs + }) + ) + return chapters.map((chapter, index) => { + return chapter.startOffsetMs = startOffsets[index] + }) + } + + /** + * Get chapters from the track list of a MusicBrainz release. + * @param {string} id MusicBrainz Release ID + * @param {number} [timeout] response timeout in ms + * @returns {Promise} + */ + async getChaptersByMusicBrainzReleaseId(id, timeout = this.#responseTimeout) { + if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout + const includes = { + inc: [ + 'recordings' + ].join('+') + } + const config = { + headers: { + 'User-Agent': this.userAgentString, + Accept: 'application/json' + }, + timeout + } + return this._processRequest(this.limiter(() => axios.get(`${this.baseUrl}/release/${id}/`, { params: includes }, config))).then((response) => { + if (!response?.data) return null + return this.parseChapters(response.data) + }).catch((error) => { + Logger.error(`[MusicBrainz] error retrieving chapters`, error) return [] + }) + } + + /** + * Search MusicBrainz for a matching release. + * @param {string} title + * @param {string} author + * @param {string} asin The Amazon ASIN, which is different from the Audible ASIN + * @param {string} isbn + * @param {number} [timeout] response timeout in ms + * @returns {Promise} + */ + async search(title, author, asin, isbn, timeout = this.#responseTimeout) { + if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout + let luceneParts = ['primarytype:Other', '(secondarytype:Audiobook OR secondarytype:"Audio drama" OR secondarytype:Spokenword)'] + if (title && title.trim().length !== 0) { + luceneParts.push(`(release:"${this.escapeLuceneCharacters(title.trim())}" OR alias:"${this.escapeLuceneCharacters(title.trim())}")`) + } + if (author && author.length !== 0) { + let authors = author.split(',') + if (authors.length !== 0) { + authors.forEach((a) => { + luceneParts.push(`artistname:"${this.escapeLuceneCharacters(a.trim())}"`) + }) + } + } + if (isbn) { + luceneParts.push(`barcode:${this.escapeLuceneCharacters(isbn)}`) + } + if (asin) { + luceneParts.push(`asin:${this.escapeLuceneCharacters(asin)}`) } const query = { query: luceneParts.join(' AND '), - limit: isNullOrNaN(options.limit) ? 15 : Number(options.limit), + limit: 10, fmt: 'json' } const config = { headers: { - 'User-Agent': this.userAgentString - } + 'User-Agent': this.userAgentString, + Accept: 'application/json' + }, + timeout } - return axios.get('https://musicbrainz.org/ws/2/recording', { params: query }, config).then((response) => { - return response.data.recordings || [] - }).catch((error) => { + + Logger.debug(`[MusicBrainz] Search query: ${JSON.stringify(query)}`) + + try { + let response = await this._processRequest(this.limiter(() => axios.get(`${this.baseUrl}/release/`, { params: query }, config))) + if (!response?.data?.releases || !Array.isArray(response.data.releases)) return null + return Promise.all( + response.data['releases'].map(async (release) => { + const releaseDetails = await this.getReleaseMinimal(release.id, timeout) + Logger.debug(`[MusicBrainz] Release Details: ${JSON.stringify(releaseDetails)}`) + function onlyUnique(value, index, array) { + return array.indexOf(value) === index + } + let authors = [] + let narrators = [] + if ('authors' in releaseDetails && releaseDetails.authors !== undefined && releaseDetails.authors.length > 0) { + authors = releaseDetails.authors + } + if ('narrators' in releaseDetails && releaseDetails.narrators !== undefined && releaseDetails.narrators.length > 0) { + narrators = releaseDetails.narrators + } + if (authors.length === 0 || narrators.length === 0) { + const authorsAndNarratorsFromArtistCreditOnly = this.parseArtistCredit(release['artist-credit']) + if (authors.length === 0) { + authors = authorsAndNarratorsFromArtistCreditOnly.authors + } + if (narrators.length === 0) { + narrators = authorsAndNarratorsFromArtistCreditOnly.narrators + } + } + let payload = { + title: release?.title, + isbn: release?.barcode, + chapters: releaseDetails?.chapters, + // todo Display the disambiguation comment in the Match list. + disambiguation: release?.disambiguation, + explicit: releaseDetails?.explicit, + publisher: releaseDetails?.publishers, + publishedYear: release.date ? release.date.split('-')[0] : null, + region: release?.country, + musicBrainzReleaseId: release?.id, + matchConfidence: release?.score / 100.0, + format: release['media'] + .map((medium) => { + return medium?.format + }) + .filter((element) => element !== undefined) + .filter(onlyUnique) + .join(', '), + author: authors.join(', '), + narrator: narrators.join(', '), + language: release?.['text-representation']?.language, + cover: `https://coverartarchive.org/release/${release.id}/front`, + trackCount: release?.['track-count'], + amazonAsin: release?.asin, + asin: releaseDetails?.audibleAsin, + abridged: releaseDetails?.abridged, + genres: releaseDetails?.genres, + tags: releaseDetails?.tags, + series: releaseDetails?.series, + duration: releaseDetails?.duration + } + // Remove undefined values + for (const key in payload) { + if (payload[key] === undefined) { + delete payload[key] + } + } + return payload + }) + ) + } catch (error) { Logger.error(`[MusicBrainz] search request error`, error) return [] - }) + } + } + + /** + * Internal method to process requests and retry if rate limit is exceeded. + */ + async _processRequest(request) { + try { + return await request() + } catch (error) { + if (error.response?.status === 503) { + const retryAfter = parseInt(error.response.headers?.['retry-after'], 10) || 5 + + Logger.warn(`[MusicBrainz] Rate limit exceeded. Retrying in ${retryAfter} seconds.`) + await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000)) + + return this._processRequest(request) + } + + throw error + } } } -module.exports = MusicBrainz \ No newline at end of file +module.exports = MusicBrainz