From a845ac10cea4dff51d87571999d94730d1e92068 Mon Sep 17 00:00:00 2001 From: Exerra Date: Wed, 15 Jul 2026 02:44:41 +0300 Subject: [PATCH] feat(applemusic): implement top-rebound recovery for duplicate plays Added logic to detect and recover 'top-rebound' history patterns where Apple Music deduplicates re-listened tracks by bumping them to the top. This ensures intermediate tracks are captured and re-listened top tracks are processed correctly. Includes a new configuration option `recoverUnchangedTopHistory` to toggle this behavior. --- config/applemusic.json.example | 3 +- .../docs/configuration/sources/applemusic.mdx | 12 +++ .../config/source/applemusic.ts | 12 +++ src/backend/sources/AppleMusicSource.ts | 76 ++++++++++++++++++- src/backend/sources/ScrobbleSources.ts | 8 +- 5 files changed, 107 insertions(+), 4 deletions(-) diff --git a/config/applemusic.json.example b/config/applemusic.json.example index 33193712..65e08524 100644 --- a/config/applemusic.json.example +++ b/config/applemusic.json.example @@ -21,7 +21,8 @@ //"interval": 60 }, "options": { - "logDiff": true + "logDiff": true, + "recoverUnchangedTopHistory": true } } ] diff --git a/docsite/docs/configuration/sources/applemusic.mdx b/docsite/docs/configuration/sources/applemusic.mdx index 71e3095e..07b4ed92 100644 --- a/docsite/docs/configuration/sources/applemusic.mdx +++ b/docsite/docs/configuration/sources/applemusic.mdx @@ -108,6 +108,17 @@ The Apple Music API **does not provide timestamps** for when tracks were played. *For the most accurate scrobble timestamps, it is highly recommended to keep the polling interval (`APPLEMUSIC_INTERVAL`) low (the default is 60 seconds).* +**Duplicate Play Recovery** + +The Apple Music history API strictly deduplicates tracks. Instead of showing the same song multiple times in a row, it simply bumps the re-played track back to the #1 spot and removes the older entry. + +For example, if you listen to **Song A** → **Song B**, your history is `[Song B, Song A]`. If you then listen to **Song A** again, the API returns `[Song A, Song B]` (Song A was bumped to the top). + +If Multi-Scrobbler polled when the top track was Song A, and polls again after it's bumped back to the top, the top track appears functionally unchanged. This usually confuses standard scrobblers into ignoring the update entirely. By default, MS correctly detects this "top-rebound" pattern, extracts the intermediate tracks that were squeezed in (Song B), and scrobbles Song A again as a re-listen. + +*Note: Because of this strict deduplication, **"pure loops"** (listening to Song A continuously on repeat without playing anything else in between) cannot be tracked. The history list never changes shape, so MS has no way of knowing it was played again.* + +If you ever experience false positives (tracks being scrobbled that you didn't actually re-listen to), you can disable this recovery behavior with `"recoverUnchangedTopHistory": false` in your config options or by setting the `APPLEMUSIC_RECOVER_UNCHANGED_TOP_HISTORY=false` env var. ## Configuration Reference @@ -122,5 +133,6 @@ The Apple Music API **does not provide timestamps** for when tracks were played. | `APPLEMUSIC_TOKEN` | No | | The authentication JWT extracted from the browser. | | `APPLEMUSIC_INTERVAL` | No | `60` | Polling interval in seconds. | | `APPLEMUSIC_HEADERS` | No | | JSON string of custom headers for API requests, e.g. `{"Origin":"https://music.apple.com"}` | + | `APPLEMUSIC_RECOVER_UNCHANGED_TOP_HISTORY` | No | `true` | Apple Music deduplicates its history by bumping re-played tracks to the top. If you listen to Song A → Song B → Song A, the history changes from `[A]` to `[A, B]` (bumping A). MS detects this "top-rebound" to recover the intermediate play (B) and the re-listen (A). Disable this only if you see incorrect duplicate scrobbles. | | `APPLEMUSIC_NAME` | No | | A vanity name different than the ID. | \ No newline at end of file diff --git a/src/backend/common/infrastructure/config/source/applemusic.ts b/src/backend/common/infrastructure/config/source/applemusic.ts index 333dcb65..55f2d318 100644 --- a/src/backend/common/infrastructure/config/source/applemusic.ts +++ b/src/backend/common/infrastructure/config/source/applemusic.ts @@ -25,6 +25,18 @@ export interface AppleMusicSourceConfig extends CommonSourceConfig { options?: CommonSourceOptions & { logAuth?: boolean logDiff?: boolean + /** + * Fixes a quirk where Apple Music's history API hides duplicate plays. + * If you listen to A → B → A, the API returns [A, B, X, Y ...] instead of [A, B, A, X, Y ...]. + * This can cause MS to skip the second A play. + * + * When enabled (default), MS detects this pattern, keeps the interim tracks (B), + * and re-scrobbles A as a re-listen. Disable only if you notice false positives. + * + * @default true + * @examples [true, false] + */ + recoverUnchangedTopHistory?: boolean } } diff --git a/src/backend/sources/AppleMusicSource.ts b/src/backend/sources/AppleMusicSource.ts index e829f8b6..6e298333 100644 --- a/src/backend/sources/AppleMusicSource.ts +++ b/src/backend/sources/AppleMusicSource.ts @@ -17,10 +17,12 @@ import { playsAreSortConsistent } from "../utils/PlayComparisonUtils.ts"; +export type AppleMusicHistoryDiffType = 'bump' | 'added' | 'top-rebound'; + export interface HistoryConsistencyResult { plays: PlayObject[]; consistent: boolean; - diffType?: 'bump' | 'added'; + diffType?: AppleMusicHistoryDiffType; diffResults?: PlayOrderConsistencyResults; reason?: string; } @@ -165,8 +167,13 @@ export default class AppleMusicSource extends AbstractSource { return {plays: [], consistent: true}; } + const recovered = this.tryRecoverUnchangedTopHistory(plays); + if(recovered !== undefined) { + return recovered; + } + let diffResults: PlayOrderConsistencyResults; - let diffType: 'bump' | 'added' | undefined; + let diffType: AppleMusicHistoryDiffType | undefined; diffResults = playsAreBumpedOnly(this.recentlyPlayed, plays); if(diffResults[0] === true) { diffType = 'bump'; @@ -191,6 +198,71 @@ export default class AppleMusicSource extends AbstractSource { } } } + + + private tryRecoverUnchangedTopHistory = (plays: PlayObject[]): HistoryConsistencyResult | undefined => { + if(this.config.options?.recoverUnchangedTopHistory === false) { + return undefined; + } + if(this.recentlyPlayed.length === 0 || plays.length === 0) { + return undefined; + } + + if(!playsAreSortConsistent([this.recentlyPlayed[0]], [plays[0]])) { + return undefined; + } + + const prevTail = this.recentlyPlayed.slice(1); + const nextTail = plays.slice(1); + + if(playsAreSortConsistent(prevTail, nextTail)) { + return undefined; + } + + let tailDiff: PlayOrderConsistencyResults = playsAreBumpedOnly(prevTail, nextTail); + let tailKind: 'bump' | 'added'; + + if(tailDiff[0] === true) { + if(tailDiff[2] !== 'prepend') { + return undefined; + } + tailKind = 'bump'; + } else { + tailDiff = playsAreAddedOnly(prevTail, nextTail); + if(tailDiff[0] !== true || tailDiff[2] !== 'prepend') { + return undefined; + } + const revertedToRecent = this.recentChangedHistoryResponses.findIndex(x => playsAreSortConsistent(x.plays, plays)); + if(revertedToRecent !== -1) { + return { + plays: [], + consistent: false, + diffType: 'top-rebound', + diffResults: tailDiff, + reason: `Apple Music History has exact order as another recent response during top-rebound recovery. Resetting history to current list and NOT ADDING tracks.`, + }; + } + tailKind = 'added'; + } + + const interimNewestFirst = tailDiff[1] ?? []; + if(interimNewestFirst.length === 0) { + return undefined; + } + + const recoveredPlays = [...interimNewestFirst].reverse().concat(plays[0]); + + this.logger.verbose( + `Recovered ${interimNewestFirst.length} interim play(s) under unchanged top track (tail ${tailKind}-prepend) and re-scrobbling top as a re-listen (top-rebound).` + ); + + return { + plays: recoveredPlays, + consistent: true, + diffType: 'top-rebound', + diffResults: tailDiff, + }; + } // Apple Music does not provide timestamps for recently played tracks, so hacky method private applyCalculatedTimestamps(plays: PlayObject[]): PlayObject[] { diff --git a/src/backend/sources/ScrobbleSources.ts b/src/backend/sources/ScrobbleSources.ts index 94fea038..4387ba08 100644 --- a/src/backend/sources/ScrobbleSources.ts +++ b/src/backend/sources/ScrobbleSources.ts @@ -857,6 +857,10 @@ export default class ScrobbleSources { key, headers, }, false); + const recoverEnv = process.env.APPLEMUSIC_RECOVER_UNCHANGED_TOP_HISTORY; + const recoverUnchangedTopHistory = recoverEnv !== undefined && recoverEnv.trim() !== '' + ? parseBool(recoverEnv) + : undefined; const p = getCommonComponentEnvConfig('APPLEMUSIC'); if (nonEmptyObj(data) || nonEmptyObj(p)) { configs.push({ @@ -867,7 +871,9 @@ export default class ScrobbleSources { configureAs: defaultConfigureAs, data: data, ...p, - options: transformPresetEnv('APPLEMUSIC') + options: transformPresetEnv('APPLEMUSIC', { + recoverUnchangedTopHistory, + } as AppleMusicSourceConfig['options']) }); } } break; -- 2.51.2