diff --git a/src/backend/scrobblers/AbstractScrobbleClient.ts b/src/backend/scrobblers/AbstractScrobbleClient.ts index 48069552..519acb70 100644 --- a/src/backend/scrobblers/AbstractScrobbleClient.ts +++ b/src/backend/scrobblers/AbstractScrobbleClient.ts @@ -265,7 +265,8 @@ export default abstract class AbstractScrobbleClient implements Authenticatable } protected compareExistingScrobbleTitle = (existing: PlayObject, candidate: PlayObject): number => { - return Math.min(compareScrobbleTracks(existing, candidate)/100, 1); + const result = compareScrobbleTracks(existing, candidate); + return Math.min(result.highScore/100, 1); } protected compareExistingScrobbleArtist = (existing: PlayObject, candidate: PlayObject): [number, number] => { diff --git a/src/backend/tests/plays/mixedDuration.json b/src/backend/tests/plays/mixedDuration.json index 881cb5c4..0e03affd 100644 --- a/src/backend/tests/plays/mixedDuration.json +++ b/src/backend/tests/plays/mixedDuration.json @@ -133,5 +133,24 @@ "meta": { "source": "Maloja" } + }, + { + "data": { + "artists": [ + "9th Wonder", + "Cordae", + "Dinner Party", + "Kamasi Washington", + "Phoelix", + "Robert Glasper", + "Terrace Martin" + ], + "track": "Freeze Tag", + "album": "Dinner Party: Dessert", + "playDate": "2024-01-01T16:29:26.000Z" + }, + "meta": { + "source": "Maloja" + } } ] diff --git a/src/backend/tests/scrobbler/scrobblers.test.ts b/src/backend/tests/scrobbler/scrobblers.test.ts index 166b7acb..6d7e2dbc 100644 --- a/src/backend/tests/scrobbler/scrobblers.test.ts +++ b/src/backend/tests/scrobbler/scrobblers.test.ts @@ -12,6 +12,7 @@ import {asPlays, generatePlay, normalizePlays} from "../utils/PlayTestUtils"; import dayjs from "dayjs"; import {sleep} from "../../utils"; import {MockNetworkError, withRequestInterception} from "../utils/networking"; +import {PlayObject} from "../../../core/Atomic"; const firstPlayDate = dayjs().subtract(1, 'hour'); const olderFirstPlayDate = dayjs().subtract(4, 'hour'); @@ -302,6 +303,36 @@ describe('Detects duplicate and unique scrobbles from client recent history', fu assert.isTrue(await testScrobbler.alreadyScrobbled(sonDiffPlay)); }); + it('Is detected as duplicate when artists are included in joiner', async function () { + const ref = normalizedWithMixedDurOlder.find(x => x.data.track === 'Freeze Tag'); + ref.data.playDate = dayjs().subtract(1, 'hour').set('minute', 29).set('second', 26) + + const spotifyPlay: PlayObject = { + data: { + artists: [ + "Terrace Martin", + "Robert Glasper", + "9th Wonder", + "Kamasi Washington", + "Dinner Party", + "Cordae", + "Phoelix" + ], + album: "Dinner Party: Dessert", + track: "Freeze Tag (feat. Cordae & Phoelix)", + "duration": 191.375, + "playDate": dayjs().subtract(1, 'hour').set('minute', 29).set('second', 27) + }, + meta: { + source: 'Spotify' + } + } + + testScrobbler.recentScrobbles = normalizedWithMixedDurOlder.concat(ref); + + assert.isTrue(await testScrobbler.alreadyScrobbled(spotifyPlay)); + }); + describe('When at least one play has duration', function () { it('Is detected as duplicate when play date is close to the end of an existing scrobble', async function () { diff --git a/src/backend/utils/StringUtils.ts b/src/backend/utils/StringUtils.ts index 295ee2f6..8451635d 100644 --- a/src/backend/utils/StringUtils.ts +++ b/src/backend/utils/StringUtils.ts @@ -135,7 +135,7 @@ export const findDelimiters = (str: string) => { return found; } -export const compareScrobbleTracks = (existing: PlayObject, candidate: PlayObject): number => { +export const compareScrobbleTracks = (existing: PlayObject, candidate: PlayObject): StringSamenessResult => { const { data: { track: existingTrack, @@ -148,7 +148,21 @@ export const compareScrobbleTracks = (existing: PlayObject, candidate: PlayObjec } } = candidate; - return compareNormalizedStrings(existingTrack, candidateTrack).highScore; + // try to remove any joiners based on existing artists + const existingCredits = parseTrackCredits(existingTrack); + const existingPrimary = existingCredits !== undefined ? existingCredits.primary : existingTrack; + + const candidateCredits = parseTrackCredits(candidateTrack); + const candidatePrimary = candidateCredits !== undefined ? candidateCredits.primary : candidateTrack; + + // take whichever score is higher + const creditsCleanedTrackSameness = compareNormalizedStrings(existingPrimary, candidatePrimary); + const naiveTrackSameness = compareNormalizedStrings(existingTrack, candidateTrack); + + if(creditsCleanedTrackSameness.highScore > naiveTrackSameness.highScore) { + return creditsCleanedTrackSameness; + } + return naiveTrackSameness; } export const compareScrobbleArtists = (existing: PlayObject, candidate: PlayObject): number => {