[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(applemusic): Make album normalization user configurable and add guidance on usage

FoxxMD (Jul 16, 2026, 4:41 PM UTC) 2a4afefe b12153fb

+129 -29
+2 -1
config/applemusic.json.example
··· 22 22 }, 23 23 "options": { 24 24 "logDiff": true, 25 - "recoverUnchangedTopHistory": true 25 + "recoverUnchangedTopHistory": true, 26 + "normalizeAlbum": true 26 27 } 27 28 } 28 29 ]
+94 -13
docsite/docs/configuration/sources/applemusic.mdx
··· 161 161 162 162 #### Album name normalization 163 163 164 - 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. 164 + 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. 165 + 166 + By default, Multi-Scrobbler strips these suffixes out so only the official name is used. 167 + 168 + If you do not want this behavior it can be disabled with ENV `APPLEMUSIC_NORMALIZE_ALBUM=false` or File/AIO option `"normalizeAlbum": false`. 169 + 170 + <DetailsAdmo type="tip" summary="Stripping Suffix with User Stage"> 171 + 172 + 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). 173 + 174 + Here is an example of creating a User Stage to only strip suffixes for a specific scrobble client: 175 + 176 + <details> 177 + 178 + <summary>Example</summary> 179 + 180 + ```json5 title="config.json" 181 + { 182 + // ... 183 + "transformers": [ 184 + { 185 + "type": "user", 186 + "name": "AppleAlbumStip", 187 + "defaults": { 188 + "album": [ 189 + "/ - (EP|Single)$/i" 190 + ] 191 + } 192 + } 193 + ] 194 + } 195 + ``` 196 + 197 + ```json5 title="applemusic.json" 198 + { 199 + { 200 + "id": "myAppleMusic", 201 + "name": "My Apple Music", 202 + "data": { 203 + // ... 204 + }, 205 + // highlight-start 206 + "options": { 207 + "normalizeAlbum": false 208 + } 209 + // highlight-end 210 + } 211 + } 212 + ``` 213 + 214 + ```json5 title="lastfm.json" 215 + [ 216 + { 217 + "name": "Foxx LFM Client", 218 + "id": "myLastFmClient", 219 + "enable": true, 220 + "configureAs": "client", 221 + "data": { 222 + "apiKey": "a89cba1569901a0671d5a9875fed4be1", 223 + "secret": "ec42e09d5ae0ee0f0816ca151008412a", 224 + "redirectUri": "http://localhost:9078/lastfm/callback" 225 + }, 226 + // highlight-start 227 + "options": { 228 + "playTransform": { 229 + "preCompare": [ 230 + { 231 + "type": "user", 232 + "name": "AppleAlbumStip", 233 + } 234 + ] 235 + } 236 + } 237 + // highlight-end 238 + } 239 + ] 240 + ``` 241 + 242 + </details> 243 + 244 + </DetailsAdmo> 165 245 166 246 ## Configuration Reference 167 247 168 248 <Config config="AppleMusicSourceConfig" fileContent={AppleMusicConfig} name="applemusic"> 169 - | Environment Variable | Required? | Default | Description | 170 - | ----------------------------- | --------- | ------- | ---------------------------------------------------- | 171 - | `APPLEMUSIC_ID` | Yes | | A unique ID for this source. | 172 - | `APPLEMUSIC_MEDIA_USER_TOKEN` | Yes | | The media-user-token extracted from the browser. | 173 - | `APPLEMUSIC_KEY_ID` | No | | Key ID from your MusicKit key. | 174 - | `APPLEMUSIC_TEAM_ID` | No | | Team ID from your Apple Developer account. | 175 - | `APPLEMUSIC_KEY_P8` | No | | The contents of your MusicKit `.p8` private key file.| 176 - | `APPLEMUSIC_TOKEN` | No | | The authentication JWT extracted from the browser. | 177 - | `APPLEMUSIC_INTERVAL` | No | `60` | Polling interval in seconds. | 178 - | `APPLEMUSIC_ORIGIN_HEADER` | No | | Origin header for API requests, e.g. `https://music.apple.com`. Required when using a browser token. | 179 - | `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. | 180 - | `APPLEMUSIC_NAME` | No | | A vanity name different than the ID. | 249 + | Environment Variable | Required? | Default | Description | 250 + | ------------------------------------------ | --------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 251 + | `APPLEMUSIC_ID` | Yes | | A unique ID for this source. | 252 + | `APPLEMUSIC_MEDIA_USER_TOKEN` | Yes | | The media-user-token extracted from the browser. | 253 + | `APPLEMUSIC_KEY_ID` | No | | Key ID from your MusicKit key. | 254 + | `APPLEMUSIC_TEAM_ID` | No | | Team ID from your Apple Developer account. | 255 + | `APPLEMUSIC_KEY_P8` | No | | The contents of your MusicKit `.p8` private key file. | 256 + | `APPLEMUSIC_TOKEN` | No | | The authentication JWT extracted from the browser. | 257 + | `APPLEMUSIC_INTERVAL` | No | `60` | Polling interval in seconds. | 258 + | `APPLEMUSIC_ORIGIN_HEADER` | No | | Origin header for API requests, e.g. `https://music.apple.com`. Required when using a browser token. | 259 + | `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. | 260 + | `APPLEMUSIC_NORMALIZE_ALBUM` | No | `true` | Strips extraneous `- EP` and `- Single` suffixes from album names | 261 + | `APPLEMUSIC_NAME` | No | | A vanity name different than the ID. | 181 262 </Config>
+13
src/backend/common/infrastructure/config/source/applemusic.ts
··· 37 37 * @examples [true, false] 38 38 */ 39 39 recoverUnchangedTopHistory?: boolean 40 + /** 41 + * Removes extraneous suffixes from album data 42 + * 43 + * Apple Music add ' - EP' and ' - Single' to album names for EP's and singles, respectively. 44 + * These suffixes are not part of the official names for the album and can cause issues in scrobble services 45 + * or when matching metadata (musicbrainz). 46 + * 47 + * When this option is true (default), Multi-scrobbler automatically removes these suffixes. 48 + * 49 + * @default true 50 + * @examples [true, false] 51 + */ 52 + normalizeAlbum?: boolean 40 53 } 41 54 } 42 55
+8 -6
src/backend/sources/AppleMusicSource.ts
··· 115 115 return `Apple Music API is reachable (status ${status})`; 116 116 } 117 117 118 - static formatPlayObj(track: Song, options: {newFromSource?: boolean} = {}): PlayObject { 119 - const {newFromSource = false} = options; 118 + static formatPlayObj(track: Song, options: {newFromSource?: boolean, normalizeAlbum?: boolean} = {}): PlayObject { 119 + const {newFromSource = false, normalizeAlbum = true} = options; 120 120 121 - // Apple Music appends " - EP" or " - Single" to the album name for EPs and singles 122 - // We strip this out so it matches other sources 123 121 let albumName = track.albumName 124 - albumName = albumName.replace(/ - (EP|Single)$/i, ''); 122 + if(normalizeAlbum) { 123 + // Apple Music appends " - EP" or " - Single" to the album name for EPs and singles 124 + // We strip this out so it matches other sources 125 + albumName = albumName.replace(/ - (EP|Single)$/i, ''); 126 + } 125 127 126 128 const play: PlayObjectMinimal = { 127 129 data: { ··· 164 166 if (!result.data) { 165 167 return []; 166 168 } 167 - return (result.data as Song[]).map(track => AppleMusicSource.formatPlayObj(track)); 169 + return (result.data as Song[]).map(track => AppleMusicSource.formatPlayObj(track, {normalizeAlbum: this.config?.options?.normalizeAlbum})); 168 170 } 169 171 170 172 getIncomingHistoryConsistencyResult = (plays: PlayObject[]): HistoryConsistencyResult => {
+6 -5
src/backend/sources/ScrobbleSources.ts
··· 34 34 import type {SonosData, SonosSourceConfig} from "../common/infrastructure/config/source/sonos.ts"; 35 35 import type {AppleMusicSourceConfig} from "../common/infrastructure/config/source/applemusic.ts"; 36 36 import type { WildcardEmitter } from "../common/WildcardEmitter.ts"; 37 - import { nonEmptyObj, parseBool } from "../utils.ts"; 37 + import { nonEmptyObj, parseBool, parseBoolStrict } from "../utils.ts"; 38 38 import { removeUndefinedKeys } from '../../core/DataUtils.ts'; 39 39 import { getCommonComponentEnvConfig, readJson } from '../utils/DataUtils.ts'; 40 40 import { validateJson } from "../utils/ValidationUtils.ts"; ··· 848 848 key, 849 849 origin: nonEmptyStringOrDefault(process.env.APPLEMUSIC_ORIGIN_HEADER, undefined), 850 850 }, false); 851 - const recoverEnv = process.env.APPLEMUSIC_RECOVER_UNCHANGED_TOP_HISTORY; 852 - const recoverUnchangedTopHistory = recoverEnv !== undefined && recoverEnv.trim() !== '' 853 - ? parseBool(recoverEnv) 854 - : undefined; 851 + const recoverEnv = nonEmptyStringOrDefault(process.env.APPLEMUSIC_RECOVER_UNCHANGED_TOP_HISTORY); 852 + const recoverUnchangedTopHistory = recoverEnv !== undefined ? parseBoolStrict(recoverEnv) : undefined; 853 + const albumNormalizeEnv = nonEmptyStringOrDefault(process.env.APPLEMUSIC_NORMALIZE_ALBUM); 854 + const normalizeAlbumName = albumNormalizeEnv !== undefined ? parseBoolStrict(albumNormalizeEnv) : undefined; 855 855 const p = getCommonComponentEnvConfig('APPLEMUSIC'); 856 856 if (nonEmptyObj(data) || nonEmptyObj(p)) { 857 857 configs.push({ ··· 864 864 ...p, 865 865 options: transformPresetEnv('APPLEMUSIC', { 866 866 recoverUnchangedTopHistory, 867 + normalizeAlbumName 867 868 } as AppleMusicSourceConfig['options']) 868 869 }); 869 870 }
+6 -4
src/backend/tests/applemusic/applemusic.test.ts
··· 287 287 durationInMillis: 200000 288 288 } as any; 289 289 290 + const formatOptions = {normalizeAlbum: true}; 291 + 290 292 // Run them through the formatter 291 - const playEP = AppleMusicSource.formatPlayObj(trackEP); 292 - const playSingle = AppleMusicSource.formatPlayObj(trackSingle); 293 - const playNormal = AppleMusicSource.formatPlayObj(trackNormal); 294 - const playLowercase = AppleMusicSource.formatPlayObj(trackLowercase); 293 + const playEP = AppleMusicSource.formatPlayObj(trackEP, formatOptions); 294 + const playSingle = AppleMusicSource.formatPlayObj(trackSingle, formatOptions); 295 + const playNormal = AppleMusicSource.formatPlayObj(trackNormal, formatOptions); 296 + const playLowercase = AppleMusicSource.formatPlayObj(trackLowercase, formatOptions); 295 297 296 298 // Assert the suffixes are removed 297 299 expect(playEP.data.album).to.equal('ALBUM A');