[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
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(musicbrainz): Implement field scoring

FoxxMD (Jan 7, 2026, 10:49 PM UTC) 1ec49272 03d9dc36

+301 -54
+57 -3
docsite/docs/configuration/transforms/musicbrainz.mdx
··· 620 620 621 621 ### Sorting 622 622 623 - 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. 624 623 625 - *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. 624 + *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. 626 625 627 626 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**. 628 627 629 628 Sorting can be used instead of filters, or in conjunction with filters, it's your choice. 629 + 630 + #### Release Attributes {#sorting-releases} 631 + 632 + 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. 630 633 631 634 In configuration: 632 635 ··· 642 645 "releaseGroupPrimaryTypePriority": ["album", "single"] 643 646 ``` 644 647 645 - If a rule is not present then multi-scrobbler defaults it to `true`. 648 + #### Field Scoring 649 + 650 + 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. 651 + 652 + <details> 653 + 654 + <summary>Example Scenario</summary> 655 + 656 + You scrobble [this track from spotify](https://open.spotify.com/track/21trRQA61afzljSK9V0SHX), which has an English title, Album name, and Artist name. 657 + 658 + ```json 659 + { 660 + "track": "Price", 661 + "artists": ["ATLUS Sound Team"], 662 + "album": "PERSONA5 ORIGINAL SOUNDTRACK" 663 + } 664 + ``` 665 + 666 + 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. 667 + 668 + You have used `releaseStatusPriority` to prioritize `psuedo-release` which does work but there are still multiple releases chosen, and some are non-English (`『ペルソナ5』オリジナル・サウンドトラック`). 669 + 670 + 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. 671 + 672 + </details> 673 + 674 + Add one or more of these weight properties to your [Stage Configuration](/configuration/transforms#configuring-stages) to enable Field Scoring: 675 + 676 + ```json5 677 + // ... 678 + "defaults": { 679 + // ... 680 + // enables album text similiarity scoring 681 + "albumWeight": 0.33, 682 + // enables title text similiarity scoring 683 + "titleWeight": 0.33, 684 + // enables artist text similiarity scoring 685 + "artistWeight": 0.33 686 + } 687 + ``` 688 + 689 + 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`. 690 + 691 + <details> 692 + 693 + <summary>How Scoring is Combined</summary> 694 + 695 + 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. 696 + 697 + 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. 698 + 699 + </details> 646 700 647 701 ## Best Practices 648 702
+64 -18
src/backend/common/transforms/MusicbrainzTransformer.ts
··· 4 4 import { ExternalMetadataTerm, PlayTransformMetadataStage } from "../infrastructure/Transform.js"; 5 5 import AtomicPartsTransformer from "./AtomicPartsTransformer.js"; 6 6 import { TransformerOptions } from "./AbstractTransformer.js"; 7 - import { DELIMITERS, MUSICBRAINZ_URL, MusicbrainzApiConfigData } from "../infrastructure/Atomic.js"; 7 + import { ARTIST_WEIGHT, DELIMITERS, MUSICBRAINZ_URL, MusicbrainzApiConfigData, TITLE_WEIGHT } from "../infrastructure/Atomic.js"; 8 8 import { MaybeLogger } from "../logging.js"; 9 9 import { childLogger, Logger } from "@foxxmd/logging"; 10 10 import { MusicbrainzApiClient, MusicbrainzApiConfig, recordingToPlay, UsingTypes } from "../vendor/musicbrainz/MusicbrainzApiClient.js"; 11 11 import { IRecordingList, IRecordingMatch, MusicBrainzApi } from "musicbrainz-api"; 12 12 import { intersect, isDebugMode, missingMbidTypes, removeUndefinedKeys } from "../../utils.js"; 13 13 import { SimpleError, SkipTransformStageError, StagePrerequisiteError } from "../errors/MSErrors.js"; 14 - import { parseArrayFromMaybeString } from "../../utils/StringUtils.js"; 14 + import { parseArrayFromMaybeString, scoreNormalizedStringsWeighted } from "../../utils/StringUtils.js"; 15 15 import clone from "clone"; 16 16 import { Cacheable } from "cacheable"; 17 17 import { splitByFirstRegexFound } from "../../../core/StringUtils.js"; 18 18 import { nativeParse } from "./NativeTransformer.js"; 19 + import { comparePlayArtistsNormalized, scoreTrackWeightedAndNormalized } from "../../utils/PlayComparisonUtils.js"; 19 20 20 21 export const asMissingMbid = (str: string): MissingMbidType => { 21 22 const clean = str.trim().toLocaleLowerCase(); ··· 149 150 * 150 151 */ 151 152 releaseAllowEmpty?: boolean 153 + 154 + titleWeight?: number | true 155 + artistWeight?: number | true 156 + albumWeight?: number | true 152 157 } 153 158 154 159 export interface MusicbrainzTransformerDataStrong extends MusicbrainzTransformerData { ··· 166 171 releaseStatusDeny?: MBReleaseStatus[] 167 172 releaseStatusPriority?: MBReleaseStatus[] 168 173 searchOrder: SearchType[] 174 + 175 + titleWeight?: number 176 + artistWeight?: number 177 + albumWeight?: number 169 178 } 170 179 171 180 export interface MusicbrainzTransformerDataStage extends MusicbrainzTransformerDataStrong,PlayTransformMetadataStage { ··· 179 188 180 189 export type MusicbrainzTransformerConfig = TransformerCommon<MusicbrainzTransformerData, MusicbrainzTransformerDataConfig> & {options?: TransformOptions & {logUrl?: boolean}} 181 190 182 - export type RecordingRankedMatched = IRecordingMatch & {rankScore?: number} 191 + export type RecordingRankedMatched = IRecordingMatch & {rankScore?: number, artistScore?: number, titleScore?: number, albumScore?: number} 183 192 184 193 export interface IRecordingMSList extends IRecordingList { 185 194 recordings: RecordingRankedMatched[] ··· 212 221 fallbackFreeText, 213 222 fallbackAlbumSearch, 214 223 searchOrder = [], 224 + titleWeight, 225 + albumWeight, 226 + artistWeight, 215 227 ...rest 216 228 } = data; 217 229 ··· 235 247 releaseCountryDeny: releaseCountryDeny !== undefined ? parseArrayFromMaybeString(releaseCountryDeny, {lower: true}) : undefined, 236 248 releaseCountryPriority: releaseCountryPriority !== undefined ? parseArrayFromMaybeString(releaseCountryPriority, {lower: true}) : undefined, 237 249 searchOrder: ['isrc','basic'], 250 + artistWeight: 0, 251 + titleWeight: 0, 252 + albumWeight: 0, 238 253 239 254 ...rest, 240 255 }; ··· 309 324 } 310 325 } 311 326 327 + if(titleWeight !== undefined) { 328 + config.titleWeight = titleWeight === true ? TITLE_WEIGHT : titleWeight; 329 + } 330 + if(artistWeight !== undefined) { 331 + config.artistWeight = artistWeight === true ? ARTIST_WEIGHT : artistWeight; 332 + } 333 + if(albumWeight !== undefined) { 334 + config.albumWeight = albumWeight === true ? 0.3 : albumWeight; 335 + } 336 + 337 + if(albumWeight !== undefined || titleWeight !== undefined || artistWeight !== undefined) { 338 + logger.debug(`Ranking matches based on scrobble text. Weights => Title ${config.titleWeight} | Artist ${config.artistWeight} | Album ${config.albumWeight}`); 339 + } 340 + 312 341 return config; 313 342 } 314 343 ··· 581 610 throw new StagePrerequisiteError(`All ${transformData.count} recordings were filtered out by allow/deny release config`, {shortStack: true}); 582 611 } 583 612 584 - filteredList = rankReleasesByPriority(filteredList, mergedConfig); 613 + filteredList = rankReleasesByPriority(filteredList, mergedConfig, play); 585 614 586 615 this.logger.debug(`${filteredList.length} of ${transformData.count} were valid, filtered matches. Using match with best score of ${filteredList[0].score}`); 587 616 ··· 792 821 || x.releases.length > 0); 793 822 } 794 823 795 - export const rankReleasesByPriority = (list: IRecordingMatch[], stageConfig: MusicbrainzTransformerDataStage, logger: MaybeLogger = new MaybeLogger()) => { 824 + export const rankReleasesByPriority = (list: IRecordingMatch[], stageConfig: MusicbrainzTransformerDataStage, play: PlayObject, logger: MaybeLogger = new MaybeLogger()): RecordingRankedMatched[] => { 796 825 const { 797 826 releaseStatusPriority = [], 798 827 releaseGroupPrimaryTypePriority = [], 799 828 releaseGroupSecondaryTypePriority = [], 800 - releaseCountryPriority = [] 829 + releaseCountryPriority = [], 830 + albumWeight, 831 + titleWeight, 832 + artistWeight 801 833 } = stageConfig; 802 - if(releaseStatusPriority.length === 0 && releaseGroupPrimaryTypePriority.length === 0 && releaseGroupSecondaryTypePriority.length === 0 && releaseCountryPriority.length === 0) { 803 - return list; 804 - } 805 834 806 - const cList = clone(list); 835 + const cList = clone(list) as RecordingRankedMatched[]; 807 836 const rankedList = cList.map((x) => { 808 - return { 809 - ...x, 810 - releases: (x.releases ?? []).map((a) => { 837 + let artistScore = 0; 838 + if(artistWeight !== 0) { 839 + const artistRes = comparePlayArtistsNormalized(play, recordingToPlay(x)); 840 + artistScore = artistRes[0] * (artistWeight + (artistRes[1] > 0 ? 0.05 : 0)); 841 + } 842 + const releases = (x.releases ?? []).map((a) => { 811 843 const statAScore = releaseStatusPriority.findIndex(x => x === a.status.toLocaleLowerCase()) + 1; 812 844 const grpPAScore = releaseGroupPrimaryTypePriority.findIndex(x => x === a["release-group"]?.["primary-type"]?.toLocaleLowerCase()) + 1; 813 845 const grpSAScore = (a["release-group"]?.["secondary-types"] ?? []).reduce((acc: number, curr: MBReleaseGroupSecondaryType) => acc + releaseGroupSecondaryTypePriority.findIndex(x => x === (curr as MBReleaseGroupSecondaryType).toLocaleLowerCase()) + 1,0); 814 846 const countryAScore = releaseCountryPriority.findIndex(x => a.country === undefined ? false : x === a.country.toLocaleLowerCase()) + 1; 847 + const compareScore = scoreNormalizedStringsWeighted(play.data.album, a.title, albumWeight, albumWeight !== 0 ? 0.05 : 0); 815 848 return { 816 849 ...a, 817 - rankedScore: statAScore + grpPAScore + grpSAScore + countryAScore 850 + albumScore: statAScore + grpPAScore + grpSAScore + countryAScore + compareScore, 851 + albumCompareScore: compareScore 818 852 }; 819 - }) 853 + }); 854 + releases.sort((a, b) => b.albumScore - a.albumScore); 855 + let albumScore = 0; 856 + if(releases.length > 0) { 857 + albumScore = releases[0].albumCompareScore; 858 + } 859 + const titleScore = titleWeight === 0 ? 0 : scoreTrackWeightedAndNormalized(play.data.track, x.title, titleWeight, {exact: 0.05, naive: 0.03})[0]; 860 + 861 + return { 862 + ...x, 863 + titleScore, 864 + artistScore, 865 + albumScore, 866 + rankScore: titleScore + artistScore + albumScore, 867 + releases 820 868 } 821 869 }); 822 - for(const rec of rankedList) { 823 - rec.releases.sort((a, b) => b.rankedScore - a.rankedScore); 824 - } 870 + rankedList.sort((a, b) => b.rankScore - a.rankScore) 825 871 return rankedList; 826 872 }; 827 873
+3 -26
src/backend/scrobblers/AbstractScrobbleClient.ts
··· 44 44 sortByOldestPlayDate, 45 45 } from "../utils.js"; 46 46 import { messageWithCauses, messageWithCausesTruncatedDefault } from "../utils/ErrorUtils.js"; 47 - import { compareScrobbleArtists, compareScrobbleTracks, normalizeStr } from "../utils/StringUtils.js"; 48 47 import { 49 48 comparePlayTemporally, 50 49 hasAcceptableTemporalAccuracy, ··· 58 57 import { rehydratePlay } from "../utils/CacheUtils.js"; 59 58 import { findAsyncSequential, staggerMapper } from "../utils/AsyncUtils.js"; 60 59 import pMap, { pMapIterable } from "p-map"; 60 + import { comparePlayArtistsNormalized, comparePlayTracksNormalized } from "../utils/PlayComparisonUtils.js"; 61 61 62 62 type PlatformMappedPlays = Map<string, {play: PlayObject, source: SourceIdentifier}>; 63 63 type NowPlayingQueue = Map<string, PlatformMappedPlays>; ··· 487 487 return [matchPlayDate, dtInvariantMatches]; 488 488 } 489 489 490 - protected compareExistingScrobbleTitle = (existing: PlayObject, candidate: PlayObject): number => { 491 - const result = compareScrobbleTracks(existing, candidate); 492 - return Math.min(result.highScore/100, 1); 493 - } 494 - 495 - protected compareExistingScrobbleArtist = (existing: PlayObject, candidate: PlayObject): [number, number] => { 496 - const { 497 - data: { 498 - artists: existingArtists = [], 499 - } = {} 500 - } = existing; 501 - const { 502 - data: { 503 - artists: candidateArtists = [], 504 - } = {} 505 - } = candidate; 506 - const normExisting = existingArtists.map(x => normalizeStr(x, {keepSingleWhitespace: true})); 507 - const candidateExisting = candidateArtists.map(x => normalizeStr(x, {keepSingleWhitespace: true})); 508 - 509 - const wholeMatches = setIntersection(new Set(normExisting), new Set(candidateExisting)).size; 510 - return [Math.min(compareScrobbleArtists(existing, candidate)/100, 1), wholeMatches] 511 - } 512 - 513 490 existingScrobble = async (playObjPre: PlayObject) => { 514 491 515 492 const playObj = await this.transformPlay(playObjPre, TRANSFORM_HOOK.candidate); ··· 581 558 timeMatch = 0.6; 582 559 } 583 560 584 - const titleMatch = this.compareExistingScrobbleTitle(x, playObj); 561 + const [titleMatch, titleResults] = comparePlayTracksNormalized(x, playObj); 585 562 586 - const [artistMatch, wholeMatches] = this.compareExistingScrobbleArtist(x, playObj); 563 + const [artistMatch, wholeMatches] = comparePlayArtistsNormalized(x, playObj); 587 564 588 565 let artistScore = ARTIST_WEIGHT * artistMatch; 589 566 const titleScore = TITLE_WEIGHT * titleMatch;
+33
src/backend/tests/musicbrainz/musicbrainz.test.ts
··· 259 259 expect(res.recordings).to.not.be.empty; 260 260 }); 261 261 262 + it('sorts by text weight', async function () { 263 + 264 + this.timeout(3500); 265 + 266 + const play: PlayObject = { 267 + data: { 268 + track: "Price", 269 + artists: ["ATLUS Sound Team"], 270 + album: "PERSONA5 ORIGINAL SOUNDTRACK", 271 + isrc: 'JPK651601515' 272 + }, 273 + meta: {} 274 + } 275 + await mbTransformer.tryInitialize(); 276 + 277 + const res = await mbTransformer.getTransformerData(play, { 278 + type: "musicbrainz", 279 + searchWhenMissing: ["artists", "album", "title"], 280 + searchOrder: ["isrc"], 281 + }); 282 + expect(res.recordings).to.exist; 283 + expect(res.recordings).to.not.be.empty; 284 + const chosenPlay = await mbTransformer.handlePostFetch(play,res, { 285 + type: "musicbrainz", 286 + searchWhenMissing: ["artists", "album", "title"], 287 + searchOrder: DEFAULT_SEARCHTYPE_ORDER, 288 + albumWeight: 0.4, 289 + titleWeight: 0.3, 290 + artistWeight: 0.3, 291 + }); 292 + expect(chosenPlay.data.meta.brainz.album).to.eq("82de33b1-1cd6-4236-b116-561d0ecc8acf") 293 + }); 294 + 262 295 }); 263 296 264 297 describe('Multiple Endpoints', function () {
+120 -2
src/backend/utils/PlayComparisonUtils.ts
··· 1 1 import { getListDiff, ListDiff } from "@donedeal0/superdiff"; 2 2 import { PlayObject, TA_CLOSE, TA_DEFAULT_ACCURACY, TA_EXACT, TemporalAccuracy } from "../../core/Atomic.js"; 3 3 import { buildTrackString } from "../../core/StringUtils.js"; 4 - import { playObjDataMatch } from "../utils.js"; 4 + import { playObjDataMatch, setIntersection } from "../utils.js"; 5 5 import { comparePlayTemporally, hasAcceptableTemporalAccuracy, TemporalPlayComparisonOptions } from "./TimeUtils.js"; 6 + import { compareNormalizedStrings, compareScrobbleArtists, compareScrobbleTracks, compareTracks, normalizeStr, TrackSamenessResults } from "./StringUtils.js"; 7 + import { ARTIST_WEIGHT, TITLE_WEIGHT } from "../common/infrastructure/Atomic.js"; 8 + import { StringSamenessResult } from "@foxxmd/string-sameness"; 6 9 7 10 8 11 export const metaInvariantTransform = (play: PlayObject): PlayObject => { ··· 242 245 243 246 export const genericSourcePlayMatch = (a: PlayObject, b: PlayObject, t?: TemporalAccuracy[], temporalOptions?: TemporalPlayComparisonOptions): boolean => 244 247 playObjDataMatch(a, b) 245 - && hasAcceptableTemporalAccuracy(comparePlayTemporally(a, b, temporalOptions).match, t); 248 + && hasAcceptableTemporalAccuracy(comparePlayTemporally(a, b, temporalOptions).match, t); 249 + 250 + export const comparePlayArtistsNormalized = (existing: PlayObject, candidate: PlayObject): [number, number] => { 251 + const { 252 + data: { 253 + artists: existingArtists = [], 254 + } = {} 255 + } = existing; 256 + const { 257 + data: { 258 + artists: candidateArtists = [], 259 + } = {} 260 + } = candidate; 261 + const normExisting = existingArtists.map(x => normalizeStr(x, {keepSingleWhitespace: true})); 262 + const candidateExisting = candidateArtists.map(x => normalizeStr(x, {keepSingleWhitespace: true})); 263 + 264 + const wholeMatches = setIntersection(new Set(normExisting), new Set(candidateExisting)).size; 265 + return [Math.min(compareScrobbleArtists(existing, candidate)/100, 1), wholeMatches] 266 + } 267 + 268 + export const comparePlayTracksNormalized = (existing: PlayObject, candidate: PlayObject): [number,TrackSamenessResults] => { 269 + const [highest, results] = compareScrobbleTracks(existing, candidate); 270 + return [Math.min(highest.highScore/100, 1), results]; 271 + } 272 + 273 + export const scoreTrackWeightedAndNormalized = (ref: string, candidate: string, weight?: number, bonuses: {exact?: number, naive?: number} = {}): [number,TrackSamenessResults] => { 274 + const { 275 + exact, 276 + naive 277 + } = bonuses; 278 + const [trackSameness, trackRes] = compareTracks(ref, candidate); 279 + const trackHigh = Math.min(trackSameness.highScore/100, 1) 280 + 281 + let trackBonus = 0; 282 + if(trackRes.exact) { 283 + trackBonus = exact; 284 + } else if(trackRes.naive.highScore > trackRes.cleaned.highScore) { 285 + trackBonus = naive; 286 + } 287 + const trackScore = trackHigh * (weight + trackBonus); 288 + 289 + return [trackScore, trackRes]; 290 + } 291 + 292 + export const comparePlayAlbumNormalized = (existing: PlayObject, candidate: PlayObject): [number,{result: StringSamenessResult, exact: boolean}] => { 293 + const sameness = compareNormalizedStrings(existing.data.album ?? '', candidate.data.album ?? ''); 294 + 295 + const exact = existing.data.album === candidate.data.album; 296 + 297 + return [Math.min(sameness.highScore/100, 1), {result: sameness, exact}]; 298 + } 299 + 300 + export interface SamenessScoreOptions { 301 + weights?: { 302 + track?: number 303 + trackBonuses?: { 304 + exact?: number 305 + naive?: number 306 + } 307 + artist?: number 308 + artistBonuses?: { 309 + exact?: number 310 + } 311 + album?: number 312 + albumBonuses?: { 313 + exact?: number 314 + naive?: number 315 + } 316 + } 317 + } 318 + export const scorePlaySameness = (ref: PlayObject, candidate: PlayObject, options: SamenessScoreOptions = {}) => { 319 + 320 + const { 321 + weights: { 322 + track: trackWeight = TITLE_WEIGHT, 323 + trackBonuses: { 324 + exact: tExact = 0.05, 325 + naive: tNaive = 0.03, 326 + } = {}, 327 + artist: artistWeight = ARTIST_WEIGHT, 328 + artistBonuses: { 329 + exact: arExact = 0.05 330 + } = {}, 331 + album: albumWeight = 0.3, 332 + albumBonuses: { 333 + exact: alExact = 0.05, 334 + } = {} 335 + } = {} 336 + } = options; 337 + 338 + const [trackHigh, trackRes] = comparePlayTracksNormalized(ref, candidate); 339 + const [artistHigh, artistRes] = comparePlayArtistsNormalized(ref, candidate); 340 + const [albumHigh, albumRes] = comparePlayAlbumNormalized(ref, candidate); 341 + 342 + let trackBonus = 0; 343 + if(trackRes.exact) { 344 + trackBonus = tExact; 345 + } else if(trackRes.naive.highScore > trackRes.cleaned.highScore) { 346 + trackBonus = tNaive; 347 + } 348 + const trackScore = trackHigh * (trackWeight + trackBonus); 349 + 350 + let artistBonus = 0; 351 + if(artistRes > 0) { 352 + artistBonus = arExact; 353 + } 354 + const artistScore = artistHigh * (artistWeight + artistBonus); 355 + 356 + let albumBonus = 0; 357 + if(albumRes.exact) { 358 + albumBonus = alExact; 359 + } 360 + const albumScore = albumHigh * (albumWeight + albumBonus); 361 + 362 + return trackScore + artistScore + albumScore; 363 + }
+24 -5
src/backend/utils/StringUtils.ts
··· 241 241 return found; 242 242 } 243 243 244 - export const compareScrobbleTracks = (existing: PlayObject, candidate: PlayObject): StringSamenessResult => { 244 + export interface TrackSamenessResults { 245 + naive: StringSamenessResult, 246 + cleaned: StringSamenessResult, 247 + exact: boolean} 248 + 249 + export const compareScrobbleTracks = (existing: PlayObject, candidate: PlayObject): [StringSamenessResult, TrackSamenessResults] => { 245 250 const { 246 251 data: { 247 252 track: existingTrack, ··· 254 259 } 255 260 } = candidate; 256 261 262 + return compareTracks(existingTrack, candidateTrack); 263 + } 264 + 265 + export const compareTracks = (existingTrack: string, candidateTrack: string): [StringSamenessResult, TrackSamenessResults] => { 266 + const exact = existingTrack === candidateTrack; 267 + 257 268 // try to remove any joiners based on existing artists 258 269 const existingCredits = parseTrackCredits(existingTrack); 259 270 const existingPrimary = existingCredits !== undefined ? existingCredits.primaryComposite : existingTrack; ··· 265 276 const creditsCleanedTrackSameness = compareNormalizedStrings(existingPrimary, candidatePrimary); 266 277 const naiveTrackSameness = compareNormalizedStrings(existingTrack, candidateTrack); 267 278 268 - if(creditsCleanedTrackSameness.highScore > naiveTrackSameness.highScore) { 269 - return creditsCleanedTrackSameness; 270 - } 271 - return naiveTrackSameness; 279 + const highest = creditsCleanedTrackSameness.highScore > naiveTrackSameness.highScore ? creditsCleanedTrackSameness : naiveTrackSameness; 280 + 281 + return [highest, {naive: naiveTrackSameness, cleaned: creditsCleanedTrackSameness, exact}]; 272 282 } 273 283 274 284 export const compareScrobbleArtists = (existing: PlayObject, candidate: PlayObject): number => { ··· 370 380 transforms: [], 371 381 strategies: [levenStrategy, diceStrategy] 372 382 }) 383 + } 384 + 385 + export const scoreNormalizedStringsWeighted = (reference: string, candidate: string, weight: number, exactBonus?: number): number => { 386 + const sameness = compareNormalizedStrings(reference ?? '', candidate ?? ''); 387 + const exact = reference === candidate; 388 + 389 + const normalScore = Math.min(sameness.highScore/100, 1); 390 + 391 + return normalScore * (weight + (exact ? exactBonus : 0)); 373 392 } 374 393 375 394 interface ArrParseOpts {