From 1ec492725c23742e1b8abb27c7b03e3f5761632b Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 6 Jan 2026 18:27:49 +0000 Subject: [PATCH] feat(musicbrainz): Implement field scoring --- .../configuration/transforms/musicbrainz.mdx | 60 ++++++++- .../transforms/MusicbrainzTransformer.ts | 82 +++++++++--- .../scrobblers/AbstractScrobbleClient.ts | 29 +---- .../tests/musicbrainz/musicbrainz.test.ts | 33 +++++ src/backend/utils/PlayComparisonUtils.ts | 122 +++++++++++++++++- src/backend/utils/StringUtils.ts | 29 ++++- 6 files changed, 301 insertions(+), 54 deletions(-) diff --git a/docsite/docs/configuration/transforms/musicbrainz.mdx b/docsite/docs/configuration/transforms/musicbrainz.mdx index 849b5e27..c74749a9 100644 --- a/docsite/docs/configuration/transforms/musicbrainz.mdx +++ b/docsite/docs/configuration/transforms/musicbrainz.mdx @@ -620,14 +620,17 @@ You may want to filter the majority of your matches by releases but allow matchi ### Sorting -Each [Release Attribute](#release-attribute) has one additional property that can be used to **rank** releases based on the order of the values you give it. This is the `priority` property. -*After* matches have been [filtered](#filtering), the remaining matches will have their releases sorted. Release with an attribute that does not match are **not** removed, but they are sorted lower than releases that do match. +*After* matches have been [filtered](#filtering), the remaining matches *and their releases** can be sorted. Matches/Releases with an attribute that does not match are **not** removed, but they are sorted lower than matches that do match. Sorting can be a good alternative to filtering: with filters there is a possibility your filters may eliminate all matches; if you want to ensure that **some** match will be used then sorting can ensure that the **best choice** out of those given will always be used, without accidentally ending up with **no choice**. Sorting can be used instead of filters, or in conjunction with filters, it's your choice. +#### Release Attributes {#sorting-releases} + +Each [Release Attribute](#release-attribute) has one additional property that can be used to **rank** releases based on the order of the values you give it. This is the `priority` property. + In configuration: ``` @@ -642,7 +645,58 @@ EX to prefer albums, then singles, over everything else "releaseGroupPrimaryTypePriority": ["album", "single"] ``` -If a rule is not present then multi-scrobbler defaults it to `true`. +#### Field Scoring + +Matches can additionally be sorted/ranked based on how similar their fields are to the original Scrobble. Use these properties for sorting when you want the final Musicbrainz match to be as closely matched to the original Scrobble, as opposed to what may be the most "correct" match based on the Musicbrainz score or suggested Release. + +
+ +Example Scenario + +You scrobble [this track from spotify](https://open.spotify.com/track/21trRQA61afzljSK9V0SHX), which has an English title, Album name, and Artist name. + +```json +{ + "track": "Price", + "artists": ["ATLUS Sound Team"], + "album": "PERSONA5 ORIGINAL SOUNDTRACK" +} +``` + +However, the [Recording found by Musicbrainz](https://beta.musicbrainz.org/search?query=isrc%3AJPK651601515&type=recording&limit=25&method=advanced) has multiple Releases (albums). The first suggested release is a *related* Album with similar name (`Persona 25th Anniversary Deluxe Vinyl Box Set`) but it's not the same as your album. Additionally, the artists for this album are in Japanese, rather than English. + +You have used `releaseStatusPriority` to prioritize `psuedo-release` which does work but there are still multiple releases chosen, and some are non-English (`『ペルソナ5』オリジナル・サウンドトラック`). + +Using `"albumWeight": 0.4` and `"artistWeight": 0.3` ensures that the releases that have an album name, and artist names, more similar to your original scrobble are ranked higher. Resulting in [the correct Release (`PERSONA5 ORIGINAL SOUNDTRACK`)](https://beta.musicbrainz.org/release/82de33b1-1cd6-4236-b116-561d0ecc8acf) being chosen as the final match. + +
+ +Add one or more of these weight properties to your [Stage Configuration](/configuration/transforms#configuring-stages) to enable Field Scoring: + +```json5 +// ... +"defaults": { + // ... + // enables album text similiarity scoring + "albumWeight": 0.33, + // enables title text similiarity scoring + "titleWeight": 0.33, + // enables artist text similiarity scoring + "artistWeight": 0.33 + } +``` + +While not enforced, it's a good idea to keep these weights under `1`. And if using more than one weight, they should be add up to `1`. + +
+ +How Scoring is Combined + +The score similarity from `albumWeight` will be added to the score accumulated by [Release Attributes](#sorting-releases) to affect the final ranking of each Release for a Match/Recording. + +In the final sorting of Recordings, title score + artist score is combined with the top ranked release album score. The top ranked score is then used to choose the matched Recording. + +
## Best Practices diff --git a/src/backend/common/transforms/MusicbrainzTransformer.ts b/src/backend/common/transforms/MusicbrainzTransformer.ts index 43073af2..1af96951 100644 --- a/src/backend/common/transforms/MusicbrainzTransformer.ts +++ b/src/backend/common/transforms/MusicbrainzTransformer.ts @@ -4,18 +4,19 @@ import { WebhookPayload } from "../infrastructure/config/health/webhooks.js"; import { ExternalMetadataTerm, PlayTransformMetadataStage } from "../infrastructure/Transform.js"; import AtomicPartsTransformer from "./AtomicPartsTransformer.js"; import { TransformerOptions } from "./AbstractTransformer.js"; -import { DELIMITERS, MUSICBRAINZ_URL, MusicbrainzApiConfigData } from "../infrastructure/Atomic.js"; +import { ARTIST_WEIGHT, DELIMITERS, MUSICBRAINZ_URL, MusicbrainzApiConfigData, TITLE_WEIGHT } from "../infrastructure/Atomic.js"; import { MaybeLogger } from "../logging.js"; import { childLogger, Logger } from "@foxxmd/logging"; import { MusicbrainzApiClient, MusicbrainzApiConfig, recordingToPlay, UsingTypes } from "../vendor/musicbrainz/MusicbrainzApiClient.js"; import { IRecordingList, IRecordingMatch, MusicBrainzApi } from "musicbrainz-api"; import { intersect, isDebugMode, missingMbidTypes, removeUndefinedKeys } from "../../utils.js"; import { SimpleError, SkipTransformStageError, StagePrerequisiteError } from "../errors/MSErrors.js"; -import { parseArrayFromMaybeString } from "../../utils/StringUtils.js"; +import { parseArrayFromMaybeString, scoreNormalizedStringsWeighted } from "../../utils/StringUtils.js"; import clone from "clone"; import { Cacheable } from "cacheable"; import { splitByFirstRegexFound } from "../../../core/StringUtils.js"; import { nativeParse } from "./NativeTransformer.js"; +import { comparePlayArtistsNormalized, scoreTrackWeightedAndNormalized } from "../../utils/PlayComparisonUtils.js"; export const asMissingMbid = (str: string): MissingMbidType => { const clean = str.trim().toLocaleLowerCase(); @@ -149,6 +150,10 @@ export interface MusicbrainzTransformerData { * */ releaseAllowEmpty?: boolean + + titleWeight?: number | true + artistWeight?: number | true + albumWeight?: number | true } export interface MusicbrainzTransformerDataStrong extends MusicbrainzTransformerData { @@ -166,6 +171,10 @@ export interface MusicbrainzTransformerDataStrong extends MusicbrainzTransformer releaseStatusDeny?: MBReleaseStatus[] releaseStatusPriority?: MBReleaseStatus[] searchOrder: SearchType[] + + titleWeight?: number + artistWeight?: number + albumWeight?: number } export interface MusicbrainzTransformerDataStage extends MusicbrainzTransformerDataStrong,PlayTransformMetadataStage { @@ -179,7 +188,7 @@ export type MusicbrainzBestMatch = {play: PlayObject, score: number}; export type MusicbrainzTransformerConfig = TransformerCommon & {options?: TransformOptions & {logUrl?: boolean}} -export type RecordingRankedMatched = IRecordingMatch & {rankScore?: number} +export type RecordingRankedMatched = IRecordingMatch & {rankScore?: number, artistScore?: number, titleScore?: number, albumScore?: number} export interface IRecordingMSList extends IRecordingList { recordings: RecordingRankedMatched[] @@ -212,6 +221,9 @@ export const parseStageConfig = (data: MusicbrainzTransformerData | undefined = fallbackFreeText, fallbackAlbumSearch, searchOrder = [], + titleWeight, + albumWeight, + artistWeight, ...rest } = data; @@ -235,6 +247,9 @@ export const parseStageConfig = (data: MusicbrainzTransformerData | undefined = releaseCountryDeny: releaseCountryDeny !== undefined ? parseArrayFromMaybeString(releaseCountryDeny, {lower: true}) : undefined, releaseCountryPriority: releaseCountryPriority !== undefined ? parseArrayFromMaybeString(releaseCountryPriority, {lower: true}) : undefined, searchOrder: ['isrc','basic'], + artistWeight: 0, + titleWeight: 0, + albumWeight: 0, ...rest, }; @@ -309,6 +324,20 @@ export const parseStageConfig = (data: MusicbrainzTransformerData | undefined = } } + 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; } @@ -581,7 +610,7 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer 0); } -export const rankReleasesByPriority = (list: IRecordingMatch[], stageConfig: MusicbrainzTransformerDataStage, logger: MaybeLogger = new MaybeLogger()) => { +export const rankReleasesByPriority = (list: IRecordingMatch[], stageConfig: MusicbrainzTransformerDataStage, play: PlayObject, logger: MaybeLogger = new MaybeLogger()): RecordingRankedMatched[] => { const { releaseStatusPriority = [], releaseGroupPrimaryTypePriority = [], releaseGroupSecondaryTypePriority = [], - releaseCountryPriority = [] + releaseCountryPriority = [], + albumWeight, + titleWeight, + artistWeight } = stageConfig; - if(releaseStatusPriority.length === 0 && releaseGroupPrimaryTypePriority.length === 0 && releaseGroupSecondaryTypePriority.length === 0 && releaseCountryPriority.length === 0) { - return list; - } - const cList = clone(list); + const cList = clone(list) as RecordingRankedMatched[]; const rankedList = cList.map((x) => { - return { - ...x, - releases: (x.releases ?? []).map((a) => { + 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 = releaseStatusPriority.findIndex(x => x === a.status.toLocaleLowerCase()) + 1; const grpPAScore = releaseGroupPrimaryTypePriority.findIndex(x => x === a["release-group"]?.["primary-type"]?.toLocaleLowerCase()) + 1; const grpSAScore = (a["release-group"]?.["secondary-types"] ?? []).reduce((acc: number, curr: MBReleaseGroupSecondaryType) => acc + releaseGroupSecondaryTypePriority.findIndex(x => x === (curr as MBReleaseGroupSecondaryType).toLocaleLowerCase()) + 1,0); const countryAScore = releaseCountryPriority.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, - rankedScore: statAScore + grpPAScore + grpSAScore + countryAScore + 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 } }); - for(const rec of rankedList) { - rec.releases.sort((a, b) => b.rankedScore - a.rankedScore); - } + rankedList.sort((a, b) => b.rankScore - a.rankScore) return rankedList; }; diff --git a/src/backend/scrobblers/AbstractScrobbleClient.ts b/src/backend/scrobblers/AbstractScrobbleClient.ts index 21ae6f1b..405ade46 100644 --- a/src/backend/scrobblers/AbstractScrobbleClient.ts +++ b/src/backend/scrobblers/AbstractScrobbleClient.ts @@ -44,7 +44,6 @@ import { sortByOldestPlayDate, } from "../utils.js"; import { messageWithCauses, messageWithCausesTruncatedDefault } from "../utils/ErrorUtils.js"; -import { compareScrobbleArtists, compareScrobbleTracks, normalizeStr } from "../utils/StringUtils.js"; import { comparePlayTemporally, hasAcceptableTemporalAccuracy, @@ -58,6 +57,7 @@ import { getRoot } from "../ioc.js"; import { rehydratePlay } from "../utils/CacheUtils.js"; import { findAsyncSequential, staggerMapper } from "../utils/AsyncUtils.js"; import pMap, { pMapIterable } from "p-map"; +import { comparePlayArtistsNormalized, comparePlayTracksNormalized } from "../utils/PlayComparisonUtils.js"; type PlatformMappedPlays = Map; type NowPlayingQueue = Map; @@ -487,29 +487,6 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i return [matchPlayDate, dtInvariantMatches]; } - protected compareExistingScrobbleTitle = (existing: PlayObject, candidate: PlayObject): number => { - const result = compareScrobbleTracks(existing, candidate); - return Math.min(result.highScore/100, 1); - } - - protected compareExistingScrobbleArtist = (existing: PlayObject, candidate: PlayObject): [number, number] => { - const { - data: { - artists: existingArtists = [], - } = {} - } = existing; - const { - data: { - artists: candidateArtists = [], - } = {} - } = candidate; - const normExisting = existingArtists.map(x => normalizeStr(x, {keepSingleWhitespace: true})); - const candidateExisting = candidateArtists.map(x => normalizeStr(x, {keepSingleWhitespace: true})); - - const wholeMatches = setIntersection(new Set(normExisting), new Set(candidateExisting)).size; - return [Math.min(compareScrobbleArtists(existing, candidate)/100, 1), wholeMatches] - } - existingScrobble = async (playObjPre: PlayObject) => { const playObj = await this.transformPlay(playObjPre, TRANSFORM_HOOK.candidate); @@ -581,9 +558,9 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i timeMatch = 0.6; } - const titleMatch = this.compareExistingScrobbleTitle(x, playObj); + const [titleMatch, titleResults] = comparePlayTracksNormalized(x, playObj); - const [artistMatch, wholeMatches] = this.compareExistingScrobbleArtist(x, playObj); + const [artistMatch, wholeMatches] = comparePlayArtistsNormalized(x, playObj); let artistScore = ARTIST_WEIGHT * artistMatch; const titleScore = TITLE_WEIGHT * titleMatch; diff --git a/src/backend/tests/musicbrainz/musicbrainz.test.ts b/src/backend/tests/musicbrainz/musicbrainz.test.ts index aada25ed..aebc8305 100644 --- a/src/backend/tests/musicbrainz/musicbrainz.test.ts +++ b/src/backend/tests/musicbrainz/musicbrainz.test.ts @@ -259,6 +259,39 @@ describe('Musicbrainz API', function () { expect(res.recordings).to.not.be.empty; }); + it('sorts by text weight', async function () { + + this.timeout(3500); + + const play: PlayObject = { + data: { + track: "Price", + artists: ["ATLUS Sound Team"], + album: "PERSONA5 ORIGINAL SOUNDTRACK", + isrc: 'JPK651601515' + }, + meta: {} + } + await mbTransformer.tryInitialize(); + + const res = await mbTransformer.getTransformerData(play, { + type: "musicbrainz", + searchWhenMissing: ["artists", "album", "title"], + searchOrder: ["isrc"], + }); + expect(res.recordings).to.exist; + expect(res.recordings).to.not.be.empty; + const chosenPlay = await mbTransformer.handlePostFetch(play,res, { + type: "musicbrainz", + searchWhenMissing: ["artists", "album", "title"], + searchOrder: DEFAULT_SEARCHTYPE_ORDER, + albumWeight: 0.4, + titleWeight: 0.3, + artistWeight: 0.3, + }); + expect(chosenPlay.data.meta.brainz.album).to.eq("82de33b1-1cd6-4236-b116-561d0ecc8acf") + }); + }); describe('Multiple Endpoints', function () { diff --git a/src/backend/utils/PlayComparisonUtils.ts b/src/backend/utils/PlayComparisonUtils.ts index 1c1c01d9..af733a74 100644 --- a/src/backend/utils/PlayComparisonUtils.ts +++ b/src/backend/utils/PlayComparisonUtils.ts @@ -1,8 +1,11 @@ import { getListDiff, ListDiff } from "@donedeal0/superdiff"; import { PlayObject, TA_CLOSE, TA_DEFAULT_ACCURACY, TA_EXACT, TemporalAccuracy } from "../../core/Atomic.js"; import { buildTrackString } from "../../core/StringUtils.js"; -import { playObjDataMatch } from "../utils.js"; +import { playObjDataMatch, setIntersection } from "../utils.js"; import { comparePlayTemporally, hasAcceptableTemporalAccuracy, TemporalPlayComparisonOptions } from "./TimeUtils.js"; +import { compareNormalizedStrings, compareScrobbleArtists, compareScrobbleTracks, compareTracks, normalizeStr, TrackSamenessResults } from "./StringUtils.js"; +import { ARTIST_WEIGHT, TITLE_WEIGHT } from "../common/infrastructure/Atomic.js"; +import { StringSamenessResult } from "@foxxmd/string-sameness"; export const metaInvariantTransform = (play: PlayObject): PlayObject => { @@ -242,4 +245,119 @@ export const humanReadableDiff = (aPlay: PlayObject[], bPlay: PlayObject[], resu export const genericSourcePlayMatch = (a: PlayObject, b: PlayObject, t?: TemporalAccuracy[], temporalOptions?: TemporalPlayComparisonOptions): boolean => playObjDataMatch(a, b) - && hasAcceptableTemporalAccuracy(comparePlayTemporally(a, b, temporalOptions).match, t); \ No newline at end of file + && hasAcceptableTemporalAccuracy(comparePlayTemporally(a, b, temporalOptions).match, t); + +export const comparePlayArtistsNormalized = (existing: PlayObject, candidate: PlayObject): [number, number] => { + const { + data: { + artists: existingArtists = [], + } = {} + } = existing; + const { + data: { + artists: candidateArtists = [], + } = {} + } = candidate; + const normExisting = existingArtists.map(x => normalizeStr(x, {keepSingleWhitespace: true})); + const candidateExisting = candidateArtists.map(x => normalizeStr(x, {keepSingleWhitespace: true})); + + const wholeMatches = setIntersection(new Set(normExisting), new Set(candidateExisting)).size; + return [Math.min(compareScrobbleArtists(existing, candidate)/100, 1), wholeMatches] +} + +export const comparePlayTracksNormalized = (existing: PlayObject, candidate: PlayObject): [number,TrackSamenessResults] => { + const [highest, results] = compareScrobbleTracks(existing, candidate); + return [Math.min(highest.highScore/100, 1), results]; +} + +export const scoreTrackWeightedAndNormalized = (ref: string, candidate: string, weight?: number, bonuses: {exact?: number, naive?: number} = {}): [number,TrackSamenessResults] => { + const { + exact, + naive + } = bonuses; + const [trackSameness, trackRes] = compareTracks(ref, candidate); + const trackHigh = Math.min(trackSameness.highScore/100, 1) + + let trackBonus = 0; + if(trackRes.exact) { + trackBonus = exact; + } else if(trackRes.naive.highScore > trackRes.cleaned.highScore) { + trackBonus = naive; + } + const trackScore = trackHigh * (weight + trackBonus); + + return [trackScore, trackRes]; +} + +export const comparePlayAlbumNormalized = (existing: PlayObject, candidate: PlayObject): [number,{result: StringSamenessResult, exact: boolean}] => { + const sameness = compareNormalizedStrings(existing.data.album ?? '', candidate.data.album ?? ''); + + const exact = existing.data.album === candidate.data.album; + + return [Math.min(sameness.highScore/100, 1), {result: sameness, exact}]; +} + +export interface SamenessScoreOptions { + weights?: { + track?: number + trackBonuses?: { + exact?: number + naive?: number + } + artist?: number + artistBonuses?: { + exact?: number + } + album?: number + albumBonuses?: { + exact?: number + naive?: number + } + } +} +export const scorePlaySameness = (ref: PlayObject, candidate: PlayObject, options: SamenessScoreOptions = {}) => { + + const { + weights: { + track: trackWeight = TITLE_WEIGHT, + trackBonuses: { + exact: tExact = 0.05, + naive: tNaive = 0.03, + } = {}, + artist: artistWeight = ARTIST_WEIGHT, + artistBonuses: { + exact: arExact = 0.05 + } = {}, + album: albumWeight = 0.3, + albumBonuses: { + exact: alExact = 0.05, + } = {} + } = {} + } = options; + + const [trackHigh, trackRes] = comparePlayTracksNormalized(ref, candidate); + const [artistHigh, artistRes] = comparePlayArtistsNormalized(ref, candidate); + const [albumHigh, albumRes] = comparePlayAlbumNormalized(ref, candidate); + + let trackBonus = 0; + if(trackRes.exact) { + trackBonus = tExact; + } else if(trackRes.naive.highScore > trackRes.cleaned.highScore) { + trackBonus = tNaive; + } + const trackScore = trackHigh * (trackWeight + trackBonus); + + let artistBonus = 0; + if(artistRes > 0) { + artistBonus = arExact; + } + const artistScore = artistHigh * (artistWeight + artistBonus); + + let albumBonus = 0; + if(albumRes.exact) { + albumBonus = alExact; + } + const albumScore = albumHigh * (albumWeight + albumBonus); + + return trackScore + artistScore + albumScore; +} \ No newline at end of file diff --git a/src/backend/utils/StringUtils.ts b/src/backend/utils/StringUtils.ts index 39ce01f7..2697898a 100644 --- a/src/backend/utils/StringUtils.ts +++ b/src/backend/utils/StringUtils.ts @@ -241,7 +241,12 @@ export const findDelimiters = (str: string, delimiters = DELIMITERS) => { return found; } -export const compareScrobbleTracks = (existing: PlayObject, candidate: PlayObject): StringSamenessResult => { +export interface TrackSamenessResults { + naive: StringSamenessResult, + cleaned: StringSamenessResult, + exact: boolean} + +export const compareScrobbleTracks = (existing: PlayObject, candidate: PlayObject): [StringSamenessResult, TrackSamenessResults] => { const { data: { track: existingTrack, @@ -254,6 +259,12 @@ export const compareScrobbleTracks = (existing: PlayObject, candidate: PlayObjec } } = candidate; + return compareTracks(existingTrack, candidateTrack); +} + +export const compareTracks = (existingTrack: string, candidateTrack: string): [StringSamenessResult, TrackSamenessResults] => { + const exact = existingTrack === candidateTrack; + // try to remove any joiners based on existing artists const existingCredits = parseTrackCredits(existingTrack); const existingPrimary = existingCredits !== undefined ? existingCredits.primaryComposite : existingTrack; @@ -265,10 +276,9 @@ export const compareScrobbleTracks = (existing: PlayObject, candidate: PlayObjec const creditsCleanedTrackSameness = compareNormalizedStrings(existingPrimary, candidatePrimary); const naiveTrackSameness = compareNormalizedStrings(existingTrack, candidateTrack); - if(creditsCleanedTrackSameness.highScore > naiveTrackSameness.highScore) { - return creditsCleanedTrackSameness; - } - return naiveTrackSameness; + const highest = creditsCleanedTrackSameness.highScore > naiveTrackSameness.highScore ? creditsCleanedTrackSameness : naiveTrackSameness; + + return [highest, {naive: naiveTrackSameness, cleaned: creditsCleanedTrackSameness, exact}]; } export const compareScrobbleArtists = (existing: PlayObject, candidate: PlayObject): number => { @@ -372,6 +382,15 @@ export const compareNormalizedStrings = (existing: string, candidate: string): S }) } +export const scoreNormalizedStringsWeighted = (reference: string, candidate: string, weight: number, exactBonus?: number): number => { + const sameness = compareNormalizedStrings(reference ?? '', candidate ?? ''); + const exact = reference === candidate; + + const normalScore = Math.min(sameness.highScore/100, 1); + + return normalScore * (weight + (exact ? exactBonus : 0)); +} + interface ArrParseOpts { lower?: boolean } -- 2.51.2