From 2a4afefe64bda527fc8a8b7696b742a2f02384b3 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 16 Jul 2026 16:41:36 +0000 Subject: [PATCH] feat(applemusic): Make album normalization user configurable and add guidance on usage --- config/applemusic.json.example | 3 +- .../docs/configuration/sources/applemusic.mdx | 107 +++++++++++++++--- .../config/source/applemusic.ts | 13 +++ src/backend/sources/AppleMusicSource.ts | 14 ++- src/backend/sources/ScrobbleSources.ts | 11 +- .../tests/applemusic/applemusic.test.ts | 10 +- 6 files changed, 129 insertions(+), 29 deletions(-) diff --git a/config/applemusic.json.example b/config/applemusic.json.example index 2ed1a0f7..33b21cc4 100644 --- a/config/applemusic.json.example +++ b/config/applemusic.json.example @@ -22,7 +22,8 @@ }, "options": { "logDiff": true, - "recoverUnchangedTopHistory": true + "recoverUnchangedTopHistory": true, + "normalizeAlbum": true } } ] diff --git a/docsite/docs/configuration/sources/applemusic.mdx b/docsite/docs/configuration/sources/applemusic.mdx index 165af538..e3758c13 100644 --- a/docsite/docs/configuration/sources/applemusic.mdx +++ b/docsite/docs/configuration/sources/applemusic.mdx @@ -161,21 +161,102 @@ If you ever experience false positives (tracks being scrobbled that you didn't a #### Album name normalization -The Apple Music API appends ` - EP` or ` - Single` to the album name for EPs and singles. Multi-Scrobbler strips this out so that the album name matches other sources. +The Apple Music API appends ` - EP` or ` - Single` to the album name for EPs and singles. These suffixes are *not* part of the official names of the album and can cause issues when trying to scrobble or match metadata. + +By default, Multi-Scrobbler strips these suffixes out so only the official name is used. + +If you do not want this behavior it can be disabled with ENV `APPLEMUSIC_NORMALIZE_ALBUM=false` or File/AIO option `"normalizeAlbum": false`. + + + +If you still want this stripping functionality but with more control over how it is applied you can use a [User Stage](/configuration/transforms/user). + +Here is an example of creating a User Stage to only strip suffixes for a specific scrobble client: + +
+ +Example + +```json5 title="config.json" +{ + // ... + "transformers": [ + { + "type": "user", + "name": "AppleAlbumStip", + "defaults": { + "album": [ + "/ - (EP|Single)$/i" + ] + } + } + ] +} +``` + +```json5 title="applemusic.json" +{ + { + "id": "myAppleMusic", + "name": "My Apple Music", + "data": { + // ... + }, + // highlight-start + "options": { + "normalizeAlbum": false + } + // highlight-end + } +} +``` + +```json5 title="lastfm.json" +[ + { + "name": "Foxx LFM Client", + "id": "myLastFmClient", + "enable": true, + "configureAs": "client", + "data": { + "apiKey": "a89cba1569901a0671d5a9875fed4be1", + "secret": "ec42e09d5ae0ee0f0816ca151008412a", + "redirectUri": "http://localhost:9078/lastfm/callback" + }, + // highlight-start + "options": { + "playTransform": { + "preCompare": [ + { + "type": "user", + "name": "AppleAlbumStip", + } + ] + } + } + // highlight-end + } +] +``` + +
+ +
## Configuration Reference - | Environment Variable | Required? | Default | Description | - | ----------------------------- | --------- | ------- | ---------------------------------------------------- | - | `APPLEMUSIC_ID` | Yes | | A unique ID for this source. | - | `APPLEMUSIC_MEDIA_USER_TOKEN` | Yes | | The media-user-token extracted from the browser. | - | `APPLEMUSIC_KEY_ID` | No | | Key ID from your MusicKit key. | - | `APPLEMUSIC_TEAM_ID` | No | | Team ID from your Apple Developer account. | - | `APPLEMUSIC_KEY_P8` | No | | The contents of your MusicKit `.p8` private key file.| - | `APPLEMUSIC_TOKEN` | No | | The authentication JWT extracted from the browser. | - | `APPLEMUSIC_INTERVAL` | No | `60` | Polling interval in seconds. | - | `APPLEMUSIC_ORIGIN_HEADER` | No | | Origin header for API requests, e.g. `https://music.apple.com`. Required when using a browser token. | - | `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. | + | Environment Variable | Required? | Default | Description | + | ------------------------------------------ | --------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | `APPLEMUSIC_ID` | Yes | | A unique ID for this source. | + | `APPLEMUSIC_MEDIA_USER_TOKEN` | Yes | | The media-user-token extracted from the browser. | + | `APPLEMUSIC_KEY_ID` | No | | Key ID from your MusicKit key. | + | `APPLEMUSIC_TEAM_ID` | No | | Team ID from your Apple Developer account. | + | `APPLEMUSIC_KEY_P8` | No | | The contents of your MusicKit `.p8` private key file. | + | `APPLEMUSIC_TOKEN` | No | | The authentication JWT extracted from the browser. | + | `APPLEMUSIC_INTERVAL` | No | `60` | Polling interval in seconds. | + | `APPLEMUSIC_ORIGIN_HEADER` | No | | Origin header for API requests, e.g. `https://music.apple.com`. Required when using a browser token. | + | `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_NORMALIZE_ALBUM` | No | `true` | Strips extraneous `- EP` and `- Single` suffixes from album names | + | `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 29643e30..b5acba9e 100644 --- a/src/backend/common/infrastructure/config/source/applemusic.ts +++ b/src/backend/common/infrastructure/config/source/applemusic.ts @@ -37,6 +37,19 @@ export interface AppleMusicSourceConfig extends CommonSourceConfig { * @examples [true, false] */ recoverUnchangedTopHistory?: boolean + /** + * Removes extraneous suffixes from album data + * + * Apple Music add ' - EP' and ' - Single' to album names for EP's and singles, respectively. + * These suffixes are not part of the official names for the album and can cause issues in scrobble services + * or when matching metadata (musicbrainz). + * + * When this option is true (default), Multi-scrobbler automatically removes these suffixes. + * + * @default true + * @examples [true, false] + */ + normalizeAlbum?: boolean } } diff --git a/src/backend/sources/AppleMusicSource.ts b/src/backend/sources/AppleMusicSource.ts index 7db38ebb..36099ce3 100644 --- a/src/backend/sources/AppleMusicSource.ts +++ b/src/backend/sources/AppleMusicSource.ts @@ -115,13 +115,15 @@ export default class AppleMusicSource extends AbstractSource { return `Apple Music API is reachable (status ${status})`; } - static formatPlayObj(track: Song, options: {newFromSource?: boolean} = {}): PlayObject { - const {newFromSource = false} = options; + static formatPlayObj(track: Song, options: {newFromSource?: boolean, normalizeAlbum?: boolean} = {}): PlayObject { + const {newFromSource = false, normalizeAlbum = true} = options; - // Apple Music appends " - EP" or " - Single" to the album name for EPs and singles - // We strip this out so it matches other sources let albumName = track.albumName - albumName = albumName.replace(/ - (EP|Single)$/i, ''); + if(normalizeAlbum) { + // Apple Music appends " - EP" or " - Single" to the album name for EPs and singles + // We strip this out so it matches other sources + albumName = albumName.replace(/ - (EP|Single)$/i, ''); + } const play: PlayObjectMinimal = { data: { @@ -164,7 +166,7 @@ export default class AppleMusicSource extends AbstractSource { if (!result.data) { return []; } - return (result.data as Song[]).map(track => AppleMusicSource.formatPlayObj(track)); + return (result.data as Song[]).map(track => AppleMusicSource.formatPlayObj(track, {normalizeAlbum: this.config?.options?.normalizeAlbum})); } getIncomingHistoryConsistencyResult = (plays: PlayObject[]): HistoryConsistencyResult => { diff --git a/src/backend/sources/ScrobbleSources.ts b/src/backend/sources/ScrobbleSources.ts index 7cc56704..fbe32458 100644 --- a/src/backend/sources/ScrobbleSources.ts +++ b/src/backend/sources/ScrobbleSources.ts @@ -34,7 +34,7 @@ import type {YandexMusicBridgeData, YandexMusicBridgeSourceConfig} from "../comm import type {SonosData, SonosSourceConfig} from "../common/infrastructure/config/source/sonos.ts"; import type {AppleMusicSourceConfig} from "../common/infrastructure/config/source/applemusic.ts"; import type { WildcardEmitter } from "../common/WildcardEmitter.ts"; -import { nonEmptyObj, parseBool } from "../utils.ts"; +import { nonEmptyObj, parseBool, parseBoolStrict } from "../utils.ts"; import { removeUndefinedKeys } from '../../core/DataUtils.ts'; import { getCommonComponentEnvConfig, readJson } from '../utils/DataUtils.ts'; import { validateJson } from "../utils/ValidationUtils.ts"; @@ -848,10 +848,10 @@ export default class ScrobbleSources { key, origin: nonEmptyStringOrDefault(process.env.APPLEMUSIC_ORIGIN_HEADER, undefined), }, false); - const recoverEnv = process.env.APPLEMUSIC_RECOVER_UNCHANGED_TOP_HISTORY; - const recoverUnchangedTopHistory = recoverEnv !== undefined && recoverEnv.trim() !== '' - ? parseBool(recoverEnv) - : undefined; + const recoverEnv = nonEmptyStringOrDefault(process.env.APPLEMUSIC_RECOVER_UNCHANGED_TOP_HISTORY); + const recoverUnchangedTopHistory = recoverEnv !== undefined ? parseBoolStrict(recoverEnv) : undefined; + const albumNormalizeEnv = nonEmptyStringOrDefault(process.env.APPLEMUSIC_NORMALIZE_ALBUM); + const normalizeAlbumName = albumNormalizeEnv !== undefined ? parseBoolStrict(albumNormalizeEnv) : undefined; const p = getCommonComponentEnvConfig('APPLEMUSIC'); if (nonEmptyObj(data) || nonEmptyObj(p)) { configs.push({ @@ -864,6 +864,7 @@ export default class ScrobbleSources { ...p, options: transformPresetEnv('APPLEMUSIC', { recoverUnchangedTopHistory, + normalizeAlbumName } as AppleMusicSourceConfig['options']) }); } diff --git a/src/backend/tests/applemusic/applemusic.test.ts b/src/backend/tests/applemusic/applemusic.test.ts index 4679f065..86ab67d2 100644 --- a/src/backend/tests/applemusic/applemusic.test.ts +++ b/src/backend/tests/applemusic/applemusic.test.ts @@ -287,11 +287,13 @@ describe('Apple Music - Format Play Object', function () { durationInMillis: 200000 } as any; + const formatOptions = {normalizeAlbum: true}; + // Run them through the formatter - const playEP = AppleMusicSource.formatPlayObj(trackEP); - const playSingle = AppleMusicSource.formatPlayObj(trackSingle); - const playNormal = AppleMusicSource.formatPlayObj(trackNormal); - const playLowercase = AppleMusicSource.formatPlayObj(trackLowercase); + const playEP = AppleMusicSource.formatPlayObj(trackEP, formatOptions); + const playSingle = AppleMusicSource.formatPlayObj(trackSingle, formatOptions); + const playNormal = AppleMusicSource.formatPlayObj(trackNormal, formatOptions); + const playLowercase = AppleMusicSource.formatPlayObj(trackLowercase, formatOptions); // Assert the suffixes are removed expect(playEP.data.album).to.equal('ALBUM A'); -- 2.51.2