Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/FoxxMD/multi-scrobbler. Scrobble plays from multiple sources to multiple clients docs.multi-scrobbler.app
deezer docker jellyfin koito lastfm listenbrainz maloja mopidy mpris music music-assistant plex scrobble self-hosted spotify subsonic tautulli youtube-music
Something went wrong. Try again.
47 kB · 1061 lines
TypeScript
at debugStartup
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061import { type ArtistCredit, asMBReleasePrimaryGroupType, asMBReleaseSecondaryGroupType, asMBReleaseStatus, DEFAULT_MISSING_TYPES, type LifecycleInput, type MBReleaseGroupPrimaryType, type MBReleaseGroupSecondaryType, type MBReleaseStatus, type MissingMbidType, type PlayObject, type TrackMeta, type TransformerCommon, type TransformOptions } from "../../../core/Atomic.ts";import { isWhenCondition, testWhenConditions } from "../../utils/PlayTransformUtils.ts";import type {WebhookPayload} from "../infrastructure/config/health/webhooks.ts";import type {ExternalMetadataTerm, PlayTransformMetadataStage} from "../../../core/Transform.ts";import AtomicPartsTransformer from "./AtomicPartsTransformer.ts";import type {TransformerOptions} from "./AbstractTransformer.ts";import { ARTIST_WEIGHT, type MusicbrainzApiConfigData, TITLE_WEIGHT } from "../infrastructure/Atomic.ts";import { DELIMITERS } from '../../../core/Atomic.ts';import { MaybeLogger } from '../MaybeLogger.ts';import { childLogger } from "@foxxmd/logging";import { MusicbrainzApiClient, recordingToPlay, type UsingTypes } from "../vendor/musicbrainz/MusicbrainzApiClient.ts";import type {IRecordingList, IRecordingMatch} from "musicbrainz-api";import { intersect, missingMbidTypes } from "../../utils.ts";import { removeUndefinedKeys } from '../../../core/DataUtils.ts';import { SimpleError, SkipTransformStageError, StagePrerequisiteError, StageTransformError } from "../errors/MSErrors.ts";import { parseArrayFromMaybeString, scoreNormalizedStringsWeighted } from "../../utils/StringUtils.ts";import clone from "clone";import type { Cacheable } from "cacheable";import { splitByFirstRegexFound } from "../../../core/StringUtils.ts";import { nativeParse } from "./NativeTransformer.ts";import { comparePlayArtistsNormalized, scoreTrackWeightedAndNormalized } from "../../utils/PlayComparisonUtils.ts";
export const asMissingMbid = (str: string): MissingMbidType => { const clean = str.trim().toLocaleLowerCase(); switch(clean) { case 'track': case 'title': return 'title'; case 'artist': case 'artists': return 'artists'; case 'album': return 'album'; case 'duration': return 'duration' } throw new Error(`MissingMbidType must be one of 'artist' or 'title' or 'album' or 'duration', given: ${clean}`);}
export const asSearchType = (str: string): SearchType => { const clean = str.trim().toLocaleLowerCase(); switch(clean) { case 'artist': case 'artists': return 'artist'; case 'album': return 'album'; case 'freetext': return 'freetext'; case 'basic': return 'basic'; case 'isrc': return 'isrc'; case 'mbidrecording': return 'mbidrecording'; case 'basicorid': case 'basicorids': return 'basicorids'; } throw new Error(`SearchType must be one of 'freetext' | 'album' | 'artist' | 'basic' | 'basicOrids' | 'isrc' | 'mbidRecording' -- given: ${clean}`);}
export type SearchType = 'freetext' | 'album' | 'artist' | 'basic' | 'basicorids' | 'isrc' | 'mbidrecording';
export const DEFAULT_SEARCHTYPE_ORDER: SearchType[] = ['isrc','basic'];
export interface MusicbrainzTransformerData { searchWhenMissing?: MissingMbidType[] forceSearch?: boolean score?: number fallbackArtistSearch?: ('naive' | 'native') fallbackFreeText?: boolean fallbackAlbumSearch?: boolean logPreMbid?: boolean searchOrder?: SearchType[] searchArtistMethod?: ('naive' | 'native')
/** Ignore album artist if it is "Various Artists" * * @default true */ ignoreVA?: boolean /** Allow only releases with release groups with these primary types * * @see https://wiki.musicbrainz.org/Release_Group/Type#Primary_types */ releaseGroupPrimaryTypeAllow?: string[] /** Filter out any releases with release groups with these primary types * * @see https://wiki.musicbrainz.org/Release_Group/Type#Primary_types */ releaseGroupPrimaryTypeDeny?: string[] /** Prioritise releases to use based on the order of these release group types * * @see https://wiki.musicbrainz.org/Release_Group/Type#Primary_types */ releaseGroupPrimaryTypePriority?: string[]
/** Allow only releases with release groups with these secondary types * * @see https://wiki.musicbrainz.org/Release_Group/Type#Secondary_types */ releaseGroupSecondaryTypeAllow?: string[] /** Filter out any releases with release groups with these secondary types * * @see https://wiki.musicbrainz.org/Release_Group/Type#Secondary_types */ releaseGroupSecondaryTypeDeny?: string[] /** Prioritise releases to use based on the order of these release group secondary types * * @see https://wiki.musicbrainz.org/Release_Group/Type#Secondary_types */ releaseGroupSecondaryTypePriority?: string[]
/** Allow only releases with these statuses * * @see https://wiki.musicbrainz.org/Release#Status */ releaseStatusAllow?: string[] /** Filter out any releases with these statuses * * @see https://wiki.musicbrainz.org/Release#Status */ releaseStatusDeny?: string[] /** Prioritise releases to used based on the order of these statuses * * @see https://wiki.musicbrainz.org/Release#Status */ releaseStatusPriority?: string[]
/** Allow only releases from these ISO2 countries * * @see https://beta.musicbrainz.org/doc/Release/Country */ releaseCountryAllow?: string[] /** Filter out any releases rom these ISO2 countries * * @see https://beta.musicbrainz.org/doc/Release/Country */ releaseCountryDeny?: string[] /** Prioritise releases to used based on the order of these ISO2 countries * * @see https://beta.musicbrainz.org/doc/Release/Country */ releaseCountryPriority?: string[]
/** Do not filter out a recording if it initially has no releases * * Use in conjunction with release filters by setting to `true` * to prevent recordings from being filtered out solely becauase they don't have any releases to begin with * */ releaseAllowEmpty?: boolean
titleWeight?: number | true artistWeight?: number | true albumWeight?: number | true}
export interface MusicbrainzTransformerDataStrong extends MusicbrainzTransformerData { searchWhenMissing: MissingMbidType[]
releaseGroupPrimaryTypeAllow?: MBReleaseGroupPrimaryType[] releaseGroupPrimaryTypeDeny?: MBReleaseGroupPrimaryType[] releaseGroupPrimaryTypePriority?: MBReleaseGroupPrimaryType[]
releaseGroupSecondaryTypeAllow?: MBReleaseGroupSecondaryType[] releaseGroupSecondaryTypeDeny?: MBReleaseGroupSecondaryType[] releaseGroupSecondaryTypePriority?: MBReleaseGroupSecondaryType[]
releaseStatusAllow?: MBReleaseStatus[] releaseStatusDeny?: MBReleaseStatus[] releaseStatusPriority?: MBReleaseStatus[]
titleWeight?: number artistWeight?: number albumWeight?: number}
export interface MusicbrainzTransformerDataStage extends MusicbrainzTransformerDataStrong,PlayTransformMetadataStage {}
export interface MusicbrainzTransformerDataConfig { apis: MusicbrainzApiConfigData[]}
export type MusicbrainzBestMatch = {play: PlayObject, score: number};
export type MusicbrainzTransformerConfig = TransformerCommon<MusicbrainzTransformerData, MusicbrainzTransformerDataConfig> & {options?: TransformOptions & {logUrl?: boolean}}
export type RecordingRankedMatched = IRecordingMatch & {rankScore?: number, artistScore?: number, titleScore?: number, albumScore?: number}
export interface IRecordingMSList extends IRecordingList { recordings: RecordingRankedMatched[] freeText?: boolean requestQuery: string requestQueries?: LifecycleInput[]}
export const parseStageConfig = (data: MusicbrainzTransformerData | undefined = {}, logger: MaybeLogger = new MaybeLogger()): MusicbrainzTransformerDataStrong => {
if (data === null || typeof data !== 'object') { throw new Error('Musicbrainz Transformer data should be an object or not defined.'); }
const { releaseGroupPrimaryTypeAllow, releaseGroupPrimaryTypeDeny, releaseGroupPrimaryTypePriority, releaseGroupSecondaryTypeAllow, releaseGroupSecondaryTypeDeny, releaseGroupSecondaryTypePriority, releaseStatusAllow, releaseStatusDeny, releaseStatusPriority, releaseCountryAllow, releaseCountryDeny, releaseCountryPriority,
searchWhenMissing, fallbackArtistSearch, searchArtistMethod, fallbackFreeText, fallbackAlbumSearch, searchOrder = [], titleWeight, albumWeight, artistWeight, ...rest } = data;
const config: MusicbrainzTransformerDataStrong = { searchWhenMissing: DEFAULT_MISSING_TYPES, score: 90,
releaseGroupPrimaryTypeAllow: releaseGroupPrimaryTypeAllow !== undefined ? parseArrayFromMaybeString(releaseGroupPrimaryTypeAllow, {lower: true}).map(asMBReleasePrimaryGroupType) : undefined, releaseGroupPrimaryTypeDeny: releaseGroupPrimaryTypeDeny !== undefined ?parseArrayFromMaybeString(releaseGroupPrimaryTypeDeny, {lower: true}).map(asMBReleasePrimaryGroupType) : undefined, releaseGroupPrimaryTypePriority: releaseGroupPrimaryTypePriority !== undefined ? parseArrayFromMaybeString(releaseGroupPrimaryTypePriority, {lower: true}).map(asMBReleasePrimaryGroupType) : undefined,
releaseGroupSecondaryTypeAllow: releaseGroupSecondaryTypeAllow !== undefined ? parseArrayFromMaybeString(releaseGroupSecondaryTypeAllow, {lower: true}).map(asMBReleaseSecondaryGroupType) : undefined, releaseGroupSecondaryTypeDeny: releaseGroupSecondaryTypeDeny !== undefined ? parseArrayFromMaybeString(releaseGroupSecondaryTypeDeny, {lower: true}).map(asMBReleaseSecondaryGroupType) : undefined, releaseGroupSecondaryTypePriority: releaseGroupSecondaryTypePriority !== undefined ? parseArrayFromMaybeString(releaseGroupSecondaryTypePriority, {lower: true}).map(asMBReleaseSecondaryGroupType) : undefined,
releaseStatusAllow: releaseStatusAllow !== undefined ? parseArrayFromMaybeString(releaseStatusAllow, {lower: true}).map(asMBReleaseStatus) : undefined, releaseStatusDeny: releaseStatusDeny !== undefined ? parseArrayFromMaybeString(releaseStatusDeny, {lower: true}).map(asMBReleaseStatus) : undefined, releaseStatusPriority: releaseStatusPriority !== undefined ? parseArrayFromMaybeString(releaseStatusPriority, {lower: true}).map(asMBReleaseStatus) : undefined,
releaseCountryAllow: releaseCountryAllow !== undefined ? parseArrayFromMaybeString(releaseCountryAllow, {lower: true}) : undefined, releaseCountryDeny: releaseCountryDeny !== undefined ? parseArrayFromMaybeString(releaseCountryDeny, {lower: true}) : undefined, releaseCountryPriority: releaseCountryPriority !== undefined ? parseArrayFromMaybeString(releaseCountryPriority, {lower: true}) : undefined, ...rest, };
if(searchWhenMissing !== undefined) { config.searchWhenMissing = searchWhenMissing.map(asMissingMbid); }
logger.debug(`Will search if missing: ${config.searchWhenMissing.join(', ')} | Match if (default) score is >= ${config.score}`);
let soSet = searchOrder.length > 0 ? new Set<SearchType>(searchOrder.map(asSearchType)) : new Set<SearchType>(); const depSearch = [];
// preserve order of search from before searchOrder // by adding fallback properties in same order they were in getTransformerData if(fallbackAlbumSearch === true) { soSet.add('album'); depSearch.push('fallbackAlbumSearch'); }
if(fallbackArtistSearch !== undefined) { const cleanFallback = fallbackArtistSearch; if(!['native','naive'].includes(cleanFallback)) { throw new Error(`fallbackArtistSearch must be one of 'native' or 'naive', given: ${cleanFallback}`); } config.searchArtistMethod = cleanFallback; depSearch.push('fallbackArtistSearch'); soSet.add('artist'); }
if(fallbackFreeText === true) { soSet.add('freetext'); depSearch.push('fallbackFreeText'); }
if(depSearch.length > 0) { logger.warn(`fallback search options are DEPRECATED and will be removed in a future release. Please switch to 'searchOrder'. See Release 0.11.0 for migrating. Deprecated options used: ${depSearch.join(',')}`); if(depSearch.includes('fallbackArtistSearch')){ logger.warn(`'fallbackArtistSearch' is DEPRECATED and will removed in a future release. Please switch to 'searchOrder' with 'artist', and 'searchArtistMethod' for naive/native. See Release 0.11.0 for migrating.`); } // preserve order of search from before searchOrder // where isrc/basic ran before fallbacks // -- only add here if we know any fallbacks were used soSet = new Set<SearchType>(['isrc','basic', ...soSet]); }
if(soSet.has('artist') && config.searchArtistMethod === undefined) { const cleanFallback = (searchArtistMethod ?? 'native'); if(!['native','naive'].includes(cleanFallback)) { throw new Error(`searchArtistMethod must be one of 'native' or 'naive', given: ${cleanFallback}`); } config.searchArtistMethod = cleanFallback; logger.debug(`Artist search using${searchArtistMethod === undefined ? ' default' : ''} '${config.searchArtistMethod}' method`); }
const so = Array.from(soSet); const soHint: string[] = []; for(const s of so) { if(s === 'artist') { soHint.push(`artist (${config.searchArtistMethod})`); } else { soHint.push(s); } } if(so.length > 0) { logger.debug(`Search Order => ${soHint.join(' | ')}`); config.searchOrder = so; } else { logger.debug(`Search Order => default (isrc, basic) or stage default`); }
for(const [k,v] of Object.entries(config)) { if(k.includes('release') && v !== undefined) { logger.debug(`${k}: ${Array.isArray(v) ? v.join(' | ') : v}`); } }
if(titleWeight !== undefined) { config.titleWeight = titleWeight === true ? TITLE_WEIGHT : titleWeight; } if(artistWeight !== undefined) { config.artistWeight = artistWeight === true ? ARTIST_WEIGHT : artistWeight; } if(albumWeight !== undefined) { config.albumWeight = albumWeight === true ? 0.3 : albumWeight; }
if(albumWeight !== undefined || titleWeight !== undefined || artistWeight !== undefined) { logger.debug(`Ranking matches based on scrobble text. Weights => Title ${config.titleWeight} | Artist ${config.artistWeight} | Album ${config.albumWeight}`); }
return config;}
export default class MusicbrainzTransformer extends AtomicPartsTransformer<ExternalMetadataTerm, PlayObject, MusicbrainzTransformerDataStage> {
declare config: MusicbrainzTransformerConfig;
protected defaults: MusicbrainzTransformerDataStrong;
protected api: MusicbrainzApiClient; protected clientCache?: Cacheable;
public constructor(config: MusicbrainzTransformerConfig, options: TransformerOptions & {clientCache?: Cacheable}) { super(config, options); this.clientCache = options.clientCache; this.staggerOpts = { initialInterval: 0, maxRandomStagger: 100 } }
protected async doBuildInitData(): Promise<true | string | undefined> { this.defaults = parseStageConfig(this.config.defaults, childLogger(this.logger, 'Defaults'));
this.api = new MusicbrainzApiClient(this.config.name, {apis: this.config.data.apis}, { logger: this.logger, cache: this.clientCache, logUrl: this.config.options?.logUrl });
return true; }
protected doParseConfig(data: MusicbrainzTransformerDataStage) { if (data.type !== 'musicbrainz') { throw new Error(`Musicbrainz Transformer is only usable with 'musicbrinz' type stages`); }
const stage: MusicbrainzTransformerDataStage = { ...data, ...parseStageConfig(data), type: 'musicbrainz' }
for (const k of ['artists', 'albumArtists', 'title', 'album', 'meta', 'duration']) { if (!(k in stage)) { stage[k] = true; continue; } if (Array.isArray(stage[k])) { throw new Error(`${k} must be a boolean or when object`); } if (typeof stage[k] === 'boolean') { continue; } if (typeof stage[k] === 'object' && !isWhenCondition(stage[k])) { throw new Error(`${k} is not a valid when object`); } } return stage; }
public async handlePreFetch(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage): Promise<void> { const { searchWhenMissing = this.defaults.searchWhenMissing, forceSearch = this.defaults.forceSearch ?? false, logPreMbid = this.defaults.logPreMbid ?? false } = stageConfig;
if(logPreMbid) { const a = play.data.meta?.brainz?.artist; const parts: string[] = [ `Recording ${play.data.meta?.brainz?.recording ?? '(None)'}`, `Release ${play.data.meta?.brainz?.album ?? '(None)'}`, `Artists ${a === undefined || a.length === 0 ? '(None)' : a.join(', ')}`, `ISRC ${play.data.isrc ?? '(None)'}` ]; this.logger.debug(`Original MBIDS => ${parts.join(' | ')}`); }
const missing = missingMbidTypes(play); if(intersect(searchWhenMissing, missing).length > 0) { this.logger.debug(`Desired MBIDs for ${searchWhenMissing.join(',')} and Play is missing: ${missing.join(', ')}`); } else if(forceSearch) { this.logger.debug(`All desired MBIDs (${searchWhenMissing.join(',')}) exist but forceSearch = true`); } else { throw new SkipTransformStageError(`No desired MBIDs (${searchWhenMissing.join(',')}) are missing`, {shortStack: true}); } }
public async getTransformerData(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage): Promise<IRecordingMSList> { const { // preserve order of search from before searchOrder searchOrder = this.defaults.searchOrder ?? ['isrc', 'basic'] } = stageConfig; let results: IRecordingMSList; const queries: LifecycleInput[] = [];
for(const searchType of searchOrder) { try { switch(searchType) { case 'isrc': results = await this.searchByIsrc(play, stageConfig); break; case 'album': results = await this.searchByAlbum(play, stageConfig); break; case 'artist': results = await this.searchByArtist(play, stageConfig); break; case 'basic': results = await this.searchByBasicFields(play, stageConfig); break; case 'freetext': results = await this.searchByFreeText(play, stageConfig); break; case 'basicorids': results = await this.searchByBasicFieldsOrMBIDs(play, stageConfig); break; case 'mbidrecording': results = await this.searchByRecordingMbid(play, stageConfig); break; } queries.push({type: `mbQuery-${searchType}${results.recordings.length === 0 ? '-empty' : ''}`, input: results.requestQuery}); if(results.recordings.length === 0) { this.logger.debug(`'${searchType}' search type returned no matches`); } else { break; } } catch (e) { if(e instanceof SearchPrerequisiteError) { queries.push({type: `mbQuery-${searchType}-prereqFailure`, input: `Search type ${searchType} did not meet prerequesites: ${e.message}`}); this.logger.debug(`Search type ${searchType} did not meet prerequesites: ${e.message}`); } else { // we should be catching any unrecoverable errors in api calls // so we should only get here if something truly bad has happened // and we probably don't want to try additional api calls throw new StageTransformError('Search Error', 'Unexpected error occurred while getting musicbrainz recordings', {cause: e, inputs: queries}); } } }
return {...results, requestQueries: queries}; }
public async searchByBasicFields(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage): Promise<IRecordingMSList> { this.logger.debug({labels: ['Basic Search']}, 'Searching by artist/album/track'); return await this.api.searchByRecording(play); }
public async searchByBasicFieldsOrMBIDs(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage): Promise<IRecordingMSList> { const using: UsingTypes[] = []; const { data: { meta: { brainz = {} } = {} } = {} } = play;
if(brainz.recording !== undefined) { using.push('mbidrecording'); } else if(brainz.track !== undefined) { using.push('mbidtrack'); } else { using.push('title'); } using.push(brainz.album !== undefined ? 'mbidrelease' : 'album'); using.push((brainz.artist ?? []).length > 0 ? 'mbidartist' : 'artist');
this.logger.debug({labels: ['Basic Or MBID Search']}, `Searching using ${using.join(', ')}}`); return await this.api.searchByRecording(play, {using}); }
public async searchByIsrc(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage): Promise<IRecordingMSList> { if(play.data.isrc !== undefined) { this.logger.debug({labels: ['ISRC Search']},'Searching with ISRC'); return await this.api.searchByRecording(play, {using: ['isrc']}); } throw new SearchPrerequisiteError('Play does not have ISRC'); }
public async searchByRecordingMbid(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage): Promise<IRecordingMSList> { if(play.data.meta?.brainz?.recording !== undefined) { this.logger.debug({labels: ['MBID Search']},'Searching with Recording MBID'); return await this.api.searchByRecording(play, {using: ['mbidrecording']}); } throw new SearchPrerequisiteError('Play does not have recording MBID'); }
public async searchByAlbum(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage): Promise<IRecordingMSList> { // possibly the artist is incorrect (may be combined as one string) // if we have an album we can likely still get a decent hit w/o using artist if(play.data.album !== undefined && play.data.artists !== undefined && play.data.artists.length > 0) { this.logger.debug({labels: ['Album Search']},'Searching with only track+album'); return await this.api.searchByRecording(play, {using: ['title','album']}); } if(play.data.album === undefined) { throw new SearchPrerequisiteError('Play does not have an album'); } if(play.data.artists === undefined) { throw new SearchPrerequisiteError('Play does not have any artists'); } if(play.data.artists.length === 1) { throw new SearchPrerequisiteError('Play only has one artist'); } }
public async searchByArtist(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage): Promise<IRecordingMSList> { const { searchArtistMethod = this.defaults.searchArtistMethod, } = stageConfig; // if only one artist // then its likely artist string is combined if(play.data.artists !== undefined && play.data.artists.length === 1) {
if(searchArtistMethod === 'naive') { // try a naive split using any common delimiter found and use the first value as artist // -- this will likely result in a less accurate match but at least it might find something // -- usually the "primary artist" is listed first in a combined artist string so cross your fingers this works const naiveSplit = splitByFirstRegexFound(play.data.artists[0], [play.data.artists[0]]).map(x => x.trim()); if(naiveSplit.length > 1) { this.logger.debug({labels: ['Parsed Artist Search']},'Searching with track + first value from artist string split'); return await this.api.searchByRecording({...play, data: { ...play.data, artists: [naiveSplit[0]] }}, {using: ['title','artist']}); } else { throw new SearchPrerequisiteError('Naive parsing did not produce multiple artists'); } } else if(searchArtistMethod === 'native') { // use MS native parsing to extract artists from artist string and title // -- cleaning title is aggressive but at this point MB has not found anything which means its likely not "proper" // IE "My Track (Cool Remix)" is a proper recording name but "My Track (feat. Someone)" is not bc MB would have removed the joiner from the name // so we do the same to hopefully get a match // // ...additionally, since MB hasn't found anything with single artist string its likely the artist name does not have a common delimiter as part of their proper name // IE "Crosby, Stills, Nash & Young" is a proper artist name with delimiters included but "My Artist & My Feat Artists" is not // so we split out all artists by all found delimiters const nativePlay = nativeParse(play, {titleClean: true, delimiters: DELIMITERS}); this.logger.debug({labels: ['Parsed Artist Search']},'Searching with aggressive native parsing'); return await this.api.searchByRecording(nativePlay, {using: ['title','artist']}); } }
if(play.data.artists === undefined) { throw new SearchPrerequisiteError('Play does not have any artists'); } if(play.data.artists.length > 1) { throw new SearchPrerequisiteError('Play has more than one artist already'); } }
public async searchByFreeText(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage): Promise<IRecordingMSList> { this.logger.debug({labels: ['Freetext Search']},'Trying freetext search'); const results = await this.api.searchByRecording(play, {freetext: true}) as IRecordingMSList; results.freeText = true; return results; }
public async handlePostFetch(play: PlayObject, transformData: IRecordingMSList, stageConfig: MusicbrainzTransformerDataStage): Promise<PlayObject> { // if all searches fail prereqs then no recording lists are assigned to results if(transformData.recordings === undefined) { throw new StagePrerequisiteError('All search prerequisites failed, Musicbrainz API could not be searched with the given searchOrder options', { shortStack: true, inputs: transformData.requestQueries }); } if(transformData.recordings.length === 0) { throw new StagePrerequisiteError('No matches returned from Musicbrainz API', {shortStack: true, inputs: transformData.requestQueries}); }
const { score = this.defaults.score ?? 90 } = stageConfig;
// if brainz meta contains track MBID then we should be able to get the exact release let explicitList: IRecordingMatch[]; let filtered: boolean; [explicitList, filtered] = filterByExplicitTrackMbid(transformData.recordings, play); if(filtered) { this.logger.debug(`Found exact release using track MBID`); return recordingToPlay(explicitList[0], {ignoreVA: stageConfig.ignoreVA}); } [explicitList, filtered] = filterByExplicitReleaseMbid(transformData.recordings, play); if(filtered) { this.logger.debug(`Found exact release using release MBID`); return recordingToPlay(explicitList[0], {ignoreVA: stageConfig.ignoreVA}); }
let filteredList: RecordingRankedMatched[] = transformData.recordings.filter(x => x.score >= score); if(filteredList.length === 0) { throw new StagePrerequisiteError(`All ${transformData.count} fetched matches had a score < ${score}, best match was ${transformData.recordings[0].score}`, {shortStack: true}); } const mergedConfig = Object.assign({}, removeUndefinedKeys({...this.defaults}), removeUndefinedKeys({...stageConfig})); filteredList = filterByValidReleaseStatus(filteredList, mergedConfig); filteredList = filterByValidReleaseGroupPrimary(filteredList, mergedConfig); filteredList = filterByValidReleaseGroupSecondary(filteredList, mergedConfig); filteredList = filterByValidReleaseCountry(filteredList, mergedConfig);
if(filteredList.length === 0) { throw new StagePrerequisiteError(`All ${transformData.count} recordings were filtered out by allow/deny release config`, {shortStack: true}); }
filteredList = rankReleasesByPriority(filteredList, mergedConfig, play);
this.logger.debug(`${filteredList.length} of ${transformData.count} were valid, filtered matches. Using match with best score of ${filteredList[0].score}`);
const recordingPlay = recordingToPlay(filteredList[0], {ignoreVA: stageConfig.ignoreVA}); recordingPlay.meta.lifecycleInputs = [...(recordingPlay.meta.lifecycleInputs ?? []), ...(transformData.requestQueries ?? []), {type: 'mbRecording', input: filteredList[0]}]; return recordingPlay; }
protected async handleTitle(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise<string | undefined> { if (parts === false) { return play.data.track; } if (typeof parts === 'object') { if (parts.when !== undefined) { if (!testWhenConditions(parts.when, play, { testMaybeRegex: this.regex.testMaybeRegex })) { this.logger.debug('When condition for track not met, returning original track'); return play.data.track; } } }
return transformData.data.track; } protected async handleArtists(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise<ArtistCredit[] | undefined> { if (parts === false) { return play.data.artists; } if (typeof parts === 'object') { if (parts.when !== undefined) { if (!testWhenConditions(parts.when, play, { testMaybeRegex: this.regex.testMaybeRegex })) { this.logger.debug('When condition for artists not met, returning original artists'); return play.data.artists; } } }
return transformData.data.artists; } protected async handleAlbumArtists(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise<ArtistCredit[] | undefined> { if (parts === false) { return play.data.albumArtists; } if (typeof parts === 'object') { if (parts.when !== undefined) { if (!testWhenConditions(parts.when, play, { testMaybeRegex: this.regex.testMaybeRegex })) { this.logger.debug('When condition for albumArtists not met, returning original artists'); return play.data.albumArtists; } } } return transformData.data.albumArtists; } protected async handleAlbum(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise<string | undefined> { if (parts === false) { return play.data.album; } if (typeof parts === 'object') { if (parts.when !== undefined) { if (!testWhenConditions(parts.when, play, { testMaybeRegex: this.regex.testMaybeRegex })) { this.logger.debug('When condition for album not met, returning original album'); return play.data.album; } } }
return transformData.data.album; } protected async handleDuration(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise<number | undefined> { if (parts === false || transformData.data.duration === undefined) { return play.data.duration; } if (typeof parts === 'object') { if (parts.when !== undefined) { if (!testWhenConditions(parts.when, play, { testMaybeRegex: this.regex.testMaybeRegex })) { this.logger.debug('When condition for duration not met, returning original duration'); return play.data.duration; } } }
return transformData.data.duration; }
protected async handleMeta(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise<TrackMeta | undefined> { if (parts === false) { return play.data.meta; } if (typeof parts === 'object') { if (parts.when !== undefined) { if (!testWhenConditions(parts.when, play, { testMaybeRegex: this.regex.testMaybeRegex })) { this.logger.debug('When condition for duration not met, returning original duration'); return play.data.meta; } } } return transformData.data.meta; }
public notify(payload: WebhookPayload): Promise<void> { return; }
}
export const filterByValidReleaseStatus = <T extends IRecordingMatch[]>(list: T, stageConfig: MusicbrainzTransformerDataStage, logger: MaybeLogger = new MaybeLogger()) => { const { releaseStatusAllow = [], releaseStatusDeny = [], releaseAllowEmpty } = stageConfig; if(releaseStatusAllow.length === 0 && releaseStatusDeny.length === 0) { return list; } const releaseFiltered = list.map(x => ({ ...x, releases: x.releases === undefined ? [] : x.releases.filter(y => { if(releaseStatusAllow.length > 0) { return releaseStatusAllow.includes(y.status?.toLocaleLowerCase() as MBReleaseStatus) } return !releaseStatusDeny.includes(y.status?.toLocaleLowerCase() as MBReleaseStatus) }) })); return releaseFiltered.filter(x => ( (list.find(y => y.id === x.id).releases ?? []).length === 0 && releaseAllowEmpty ) || x.releases.length > 0);}
export const filterByValidReleaseGroupPrimary = <T extends IRecordingMatch[]>(list: T, stageConfig: MusicbrainzTransformerDataStage, logger: MaybeLogger = new MaybeLogger()) => { const { releaseGroupPrimaryTypeAllow = [], releaseGroupPrimaryTypeDeny = [], releaseAllowEmpty } = stageConfig; if(releaseGroupPrimaryTypeAllow.length === 0 && releaseGroupPrimaryTypeAllow.length === 0) { return list; } const releaseFiltered = list.map(x => ({ ...x, releases: x.releases === undefined ? [] : x.releases.filter(y => { if(releaseGroupPrimaryTypeAllow.length > 0) { return releaseGroupPrimaryTypeAllow.includes(y["release-group"]?.["primary-type"]?.toLocaleLowerCase() as MBReleaseGroupPrimaryType) } return !releaseGroupPrimaryTypeDeny.includes(y["release-group"]?.["primary-type"]?.toLocaleLowerCase() as MBReleaseGroupPrimaryType) }) })); return releaseFiltered.filter(x => ( (list.find(y => y.id === x.id).releases ?? []).length === 0 && releaseAllowEmpty ) || x.releases.length > 0);}
export const filterByValidReleaseGroupSecondary = (list: IRecordingMatch[], stageConfig: MusicbrainzTransformerDataStage, logger: MaybeLogger = new MaybeLogger()) => { const { releaseGroupSecondaryTypeAllow = [], releaseGroupSecondaryTypeDeny = [], releaseAllowEmpty } = stageConfig; if(releaseGroupSecondaryTypeAllow.length === 0 && releaseGroupSecondaryTypeDeny.length === 0) { return list; } const releaseFiltered = list.map(x => ({ ...x, releases: x.releases === undefined ? [] : x.releases.filter(y => { if(releaseGroupSecondaryTypeAllow.length > 0) { return intersect(releaseGroupSecondaryTypeAllow, (y["release-group"]?.["secondary-types"] ?? []).map(x => x.toLocaleLowerCase()) as MBReleaseGroupSecondaryType[]).length > 0; } return intersect(releaseGroupSecondaryTypeDeny, (y["release-group"]?.["secondary-types"] ?? []).map(x => x.toLocaleLowerCase()) as MBReleaseGroupSecondaryType[]).length === 0; }) })); return releaseFiltered.filter(x => ( (list.find(y => y.id === x.id).releases ?? []).length === 0 && releaseAllowEmpty ) || x.releases.length > 0);}
export const filterByValidReleaseCountry = (list: IRecordingMatch[], stageConfig: MusicbrainzTransformerDataStage, logger: MaybeLogger = new MaybeLogger()) => { const { releaseCountryAllow = [], releaseCountryDeny = [], releaseAllowEmpty } = stageConfig; if(releaseCountryAllow.length === 0 && releaseCountryDeny.length === 0) { return list; } const releaseFiltered = list.map(x => ({ ...x, releases: x.releases === undefined ? [] : x.releases.filter(y => { if(releaseCountryAllow.length > 0) { return releaseCountryAllow.includes(y.country?.toLocaleLowerCase()) } return !releaseCountryDeny.includes(y.country?.toLocaleLowerCase()) }) })); return releaseFiltered.filter(x => ( (list.find(y => y.id === x.id).releases ?? []).length === 0 && releaseAllowEmpty ) || x.releases.length > 0);}
export const filterByExplicitTrackMbid = (list: IRecordingMatch[], play: PlayObject): [IRecordingMatch[], boolean] => { if (play.data.meta?.brainz?.track === undefined) { return [list, false]; } let recMatch: IRecordingMatch, releaseMatchId: string; for (const rec of list) { if (recMatch !== undefined) { break; } for (const rel of rec.releases) { if (rel.media.some(x => { if(x.tracks !== undefined) { return x.tracks.some(y => y.id === play.data.meta.brainz.track); } if(x.track !== undefined) { return x.track.some(y => y.id === play.data.meta.brainz.track); } return false; })) { releaseMatchId = rel.id; recMatch = rec; break; } } } if (recMatch !== undefined) { const r = structuredClone(recMatch); r.releases = r.releases.filter(x => x.id === releaseMatchId); return [[r], true]; } return [list, false];}
export const filterByExplicitReleaseMbid = (list: IRecordingMatch[], play: PlayObject): [IRecordingMatch[], boolean] => { if (play.data.meta?.brainz?.album === undefined) { return [list, false]; } let recMatch: IRecordingMatch, releaseMatchId: string; for (const rec of list) { if (recMatch !== undefined) { break; } for (const rel of rec.releases) { if(rel.id === play.data.meta.brainz.album) { releaseMatchId = rel.id; recMatch = rec; } } } if (recMatch !== undefined) { const r = structuredClone(recMatch); r.releases = r.releases.filter(x => x.id === releaseMatchId); return [[r], true]; } return [list, false];}
export const rankReleasesByPriority = (list: IRecordingMatch[], stageConfig: MusicbrainzTransformerDataStage, play: PlayObject, logger: MaybeLogger = new MaybeLogger()): RecordingRankedMatched[] => { const { releaseStatusPriority = [], releaseGroupPrimaryTypePriority = [], releaseGroupSecondaryTypePriority = [], releaseCountryPriority = [], albumWeight = 0, titleWeight = 0, artistWeight = 0 } = stageConfig;
// reverse order so that "highest" priority (first in user list) ends up with the highest index, that we use as score const groupPrimaryPriority = [...releaseGroupPrimaryTypePriority].reverse(), groupSecPriority = [...releaseGroupSecondaryTypePriority].reverse(), countryPriority = [...releaseCountryPriority].reverse(), statusPriority = [...releaseStatusPriority].reverse();
const cList = clone(list) as RecordingRankedMatched[]; const rankedList = cList.map((x) => { let artistScore = 0; if(artistWeight !== 0) { const artistRes = comparePlayArtistsNormalized(play, recordingToPlay(x)); artistScore = artistRes[0] * (artistWeight + (artistRes[1] > 0 ? 0.05 : 0)); } const releases = (x.releases ?? []).map((a) => { const statAScore = statusPriority.findIndex(x => a.status !== undefined && x === a.status.toLocaleLowerCase()) + 1; const grpPAScore = groupPrimaryPriority.findIndex(x => x === a["release-group"]?.["primary-type"]?.toLocaleLowerCase()) + 1; const grpSAScore = (a["release-group"]?.["secondary-types"] ?? []).reduce((acc: number, curr: MBReleaseGroupSecondaryType) => acc + groupSecPriority.findIndex(x => x === (curr as MBReleaseGroupSecondaryType).toLocaleLowerCase()) + 1,0); const countryAScore = countryPriority.findIndex(x => a.country === undefined ? false : x === a.country.toLocaleLowerCase()) + 1; const compareScore = scoreNormalizedStringsWeighted(play.data.album, a.title, albumWeight, albumWeight !== 0 ? 0.05 : 0); return { ...a, albumScore: statAScore + grpPAScore + grpSAScore + countryAScore + compareScore, albumCompareScore: compareScore }; }); releases.sort((a, b) => b.albumScore - a.albumScore); let albumScore = 0; if(releases.length > 0) { albumScore = releases[0].albumCompareScore; } const titleScore = titleWeight === 0 ? 0 : scoreTrackWeightedAndNormalized(play.data.track, x.title, titleWeight, {exact: 0.05, naive: 0.03})[0];
return { ...x, titleScore, artistScore, albumScore, rankScore: titleScore + artistScore + albumScore, releases } }); rankedList.sort((a, b) => b.rankScore - a.rankScore) return rankedList;};
export const DEFAULTS_SENSIBLE: MusicbrainzTransformerData = { // use official release over anything else "releaseStatusPriority": ["official"], // prefer album, then single, then ep "releaseGroupPrimaryTypePriority": ["album", "single", "ep"], // prefer worldwide release "releaseCountryPriority": ["XW"]}export const DEFAULTS_NATIVE: MusicbrainzTransformerData = { "searchArtistMethod": "native", "searchOrder": ["artist"]}
export const DEFAULTS_AGGRESSIVE: MusicbrainzTransformerData = { "searchOrder": ["freetext"]}
export const DEFAULTS_FIELDS_BIAS = { "titleWeight": 0.33, "albumWeight": 0.33, "artistWeight": 0.33}
export const DEFAULTS_PRESET: MusicbrainzTransformerData = { "searchOrder": ["isrc", "basic"]}
export const DEFAULTS_ID: MusicbrainzTransformerData = { "searchOrder": ["isrc", "mbidrecording", "basicorids", "basic"]};
const PRESETS: Record<string, MusicbrainzTransformerData> = { default: DEFAULTS_PRESET, sensible: DEFAULTS_SENSIBLE, native: DEFAULTS_NATIVE, aggressive: DEFAULTS_AGGRESSIVE, fields: DEFAULTS_FIELDS_BIAS, 'id': DEFAULTS_ID}
export const configFromEnv = (logger: MaybeLogger = new MaybeLogger()) => { const mbEnv = process.env.MB_PRESETS; const mbContact = process.env.MB_CONTACT; let mbConfig: MusicbrainzTransformerConfig; if (mbEnv !== undefined && mbEnv.trim() !== '') { if (mbContact === undefined || mbContact.trim() === '') { throw new SimpleError('Must provide a contact url/email for musicbrainz ENV present!'); } mbConfig = { type: 'musicbrainz', name: 'MSDefault', data: { apis: [ { contact: mbEnv } ] }, defaults: {
} } const presets = mbEnv.split(',').map(x => x.trim().toLocaleLowerCase()); const soSet = new Set<SearchType>(); for (const pName of presets) { const p = PRESETS[pName]; if(p === undefined) { logger.warn(`No preset with name '${p}'`); continue; } const { searchOrder = [], ...rest } = p; mbConfig.defaults = { ...mbConfig.defaults, ...rest, } for (const o of searchOrder) { soSet.add(o); } } if(soSet.size > 0) { mbConfig.defaults.searchOrder = Array.from(soSet); } logger.debug(`Using presets: ${presets.join(',')}`); }
return mbConfig;}
export class SearchPrerequisiteError extends SimpleError { name = 'Search Prerequistie Failure';}