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

Merge pull request #636 from Exerra/feat/apple-music

feat: Apple Music source

authored by

Matt Foxx and committed by
GitHub
(Jul 16, 2026, 3:45 PM EDT) f3d7d59a 1dba73cc

+1114 -177
+5 -1
.gitignore
··· 155 155 *.db 156 156 *.db* 157 157 158 - **/lexicons/**/*.json 158 + **/lexicons/**/*.json 159 + 160 + bun.lock 161 + .DS_Store 162 + **/.DS_Store
+1
README.md
··· 12 12 A dockerized app that monitors your music listening activity from *everywhere* and scrobbles it *anywhere*. 13 13 14 14 * Supports scrobbling from many [**Sources**](https://docs.multi-scrobbler.app/configuration/sources) 15 + * [Apple Music](https://docs.multi-scrobbler.app/configuration/sources/applemusic/?configType=file) 15 16 * [Azuracast](https://docs.multi-scrobbler.app/configuration/sources/azuracast) 16 17 * [Deezer](https://docs.multi-scrobbler.app/configuration/sources/deezer) 17 18 * [Google Cast (Chromecast)](https://docs.multi-scrobbler.app/configuration/sources/google-cast)
+29
config/applemusic.json.example
··· 1 + [ 2 + { 3 + "id": "myAppleMusic", 4 + "name": "My Apple Music", 5 + "enable": true, 6 + "clients": [], 7 + "data": { 8 + // either key or token needs to be provided 9 + "key": { 10 + "id": "2HPSNJZ88N", 11 + "teamId": "SN6YASW8G4", 12 + "p8": "-----BEGIN PRIVATE KEY-----\nMIGTAgEAMBMGByqGSM49AgEG...-----END PRIVATE KEY-----" 13 + }, 14 + "token": "your-jwt-token-here", 15 + "mediaUserToken": "your-media-user-token-here", 16 + 17 + // optional: Origin header for API requests (required when using a browser token) 18 + "origin": "https://music.apple.com", 19 + 20 + // optional: polling interval in seconds (default 60) 21 + "interval": 60 22 + }, 23 + "options": { 24 + "logDiff": true, 25 + "recoverUnchangedTopHistory": true, 26 + "normalizeAlbum": true 27 + } 28 + } 29 + ]
docsite/docs/configuration/applemusic_chrome_devtools.png

This is a binary file and will not be displayed.

docsite/docs/configuration/applemusic_safari_devtools.png

This is a binary file and will not be displayed.

docsite/docs/configuration/applemusic_safari_settings.png

This is a binary file and will not be displayed.

+262
docsite/docs/configuration/sources/applemusic.mdx
··· 1 + --- 2 + title: Apple Music 3 + toc_min_heading_level: 2 4 + toc_max_heading_level: 5 5 + --- 6 + 7 + import Tabs from '@theme/Tabs'; 8 + import TabItem from '@theme/TabItem'; 9 + import CodeBlock from '@theme/CodeBlock'; 10 + import AppleMusicConfig from '!!raw-loader!@site/../config/applemusic.json.example'; 11 + 12 + This Source **monitors your Apple Music listening history** via the official Apple Music API and scrobbles new activity to your configured [Clients](/configuration/clients). 13 + 14 + Because of how the Apple Music API works, Multi-Scrobbler (MS) requires two different tokens to function: a **Media User Token** (identifies your personal account) and an **Authentication Token** (authorizes API access). 15 + 16 + ### 1. Getting a Media User Token 17 + 18 + The `mediaUserToken` is required to access your recently played history. You can extract it directly from the Apple Music web app: 19 + 20 + 1. Visit [music.apple.com](https://music.apple.com) in your browser and log in. 21 + 2. Open your browser's Developer Tools (usually `F12` or <kbd>⌘</kbd> + <kbd>⌥</kbd> + <kbd>I</kbd>). 22 + 3. Navigate to the **Application** tab (Chrome/Edge) or **Storage** tab (Safari/Firefox). 23 + 4. Expand **Cookies** in the left sidebar and select `https://music.apple.com`. 24 + 5. Find the cookie named `media-user-token` and copy its value (it typically starts with `0.`). 25 + 26 + :::caution[Token Expiry] 27 + The `mediaUserToken` will eventually expire. If Multi-Scrobbler begins throwing authentication errors in the logs, simply repeat these steps to obtain and configure a fresh token. 28 + ::: 29 + 30 + ### 2. Authentication 31 + 32 + To authorize API access, Multi-Scrobbler requires an Authentication Token (JWT). Because the official Apple Music API is designed for developers, there are two ways to provide this token depending on your situation: 33 + 34 + * **Browser Token (Free & Most Common):** Best for 99% of users. You can easily extract a temporary token from the Apple Music web player without needing an Apple Developer account. 35 + * **MusicKit Key (Requires Paid Apple Developer Account):** If you happen to be enrolled in the paid Apple Developer Program ($99/year), you can provide a private key to automatically generate permanent tokens. 36 + 37 + Choose your preferred method below: 38 + 39 + <Tabs groupId="appleMusicAuth" queryString> 40 + <TabItem value="token" label="Browser Token (Free)"> 41 + 42 + Since most users don't have a paid developer account, extracting the token from the web player is the standard approach. 43 + 44 + Behind the scenes, the Apple Music website makes a lot of background requests, which can make finding the right token confusing. To filter out the noise and grab the exact token we need, follow the steps for your browser below: 45 + 46 + <details> 47 + <summary>Chrome, Edge & Firefox</summary> 48 + 49 + 1. Visit [music.apple.com](https://music.apple.com) and log in. 50 + 2. Open Developer Tools (`F12` or `Ctrl+Shift+I` / <kbd>⌘</kbd> + <kbd>⌥</kbd> + <kbd>I</kbd>) and navigate to the **Network** tab. 51 + 3. In the filter box at the top left, paste exactly: `https://amp-api.music.apple.com/v1/me/account` (**Label 1**). 52 + 4. Click the **Fetch/XHR** filter button to hide irrelevant requests (**Label 2**). 53 + 5. Reload the page (**Label 3**). 54 + 6. Under the "Name" column, click the request starting with `account?meta=` (**Label 4**). 55 + 7. In the panel that opens, scroll down to the **Request Headers** section. 56 + 8. Find the `authorization` header. Copy the long string of text **after** the `Bearer ` prefix (**Label 5**). 57 + 58 + ![Chrome Network Tab Instructions](../applemusic_chrome_devtools.png) 59 + 60 + </details> 61 + 62 + <details> 63 + <summary>Safari</summary> 64 + 65 + Before you can extract the token in Safari, you must enable developer tools. 66 + 67 + {/* This weird formatting is because ⌘ is usually incredibly tiny */} 68 + 1. Open Safari Settings (<kbd>⌘</kbd> + <kbd>,</kbd>), navigate to the **Advanced** tab, and check **"Show features for web developers"** at the very bottom. 69 + 70 + ![Safari Advanced Settings](../applemusic_safari_settings.png) 71 + 72 + 2. Visit [music.apple.com](https://music.apple.com) and log in. 73 + 3. Open the Web Inspector (<kbd>⌘</kbd>+<kbd>⌥</kbd>+<kbd>I</kbd> or Develop > Show Web Inspector) and navigate to the **Network** tab. 74 + 4. In the filter bar on the left side of the inspector, paste exactly: `https://amp-api.music.apple.com/v1/me/account` (**Label 1**). 75 + 5. Reload the page (**Label 2**). 76 + 6. Click the `account` request that appears in the Name list (**Label 3**). 77 + 7. In the details sidebar, look under the **Request** section for the `Authorization` header. Copy the long string of text **after** the `Bearer ` prefix (**Label 4**). 78 + 79 + ![Safari Network Tab Instructions](../applemusic_safari_devtools.png) 80 + 81 + </details> 82 + 83 + <br /> 84 + 85 + Once you have your token, add it to your configuration file along with the origin header: 86 + 87 + ```json title="applemusic.json" 88 + { 89 + "data": { 90 + "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsIm...", 91 + "mediaUserToken": "your-media-user-token-here", 92 + "origin": "https://music.apple.com" 93 + } 94 + } 95 + ``` 96 + 97 + :::info[Origin Header Required] 98 + When using a JWT extracted from the browser, Apple requires the request to match the domain it was issued to. You **must** include the `origin` field in your config as shown above. 99 + ::: 100 + 101 + *Note: Browser-generated JWTs are valid for a maximum of 35 days and must be updated manually when they expire.* 102 + 103 + </TabItem> 104 + <TabItem value="key" label="MusicKit Key (Paid Account)"> 105 + 106 + :::warning[Apple Developer Account Required] 107 + To generate JWTs automatically, you must be enrolled in the paid [Apple Developer Program](https://developer.apple.com/programs/) ($99/year) and create a MusicKit key. If you don't have an account, use the **Browser Token (Free)** tab instead. 108 + ::: 109 + 110 + To generate JWTs automatically and avoid manual token refreshes, you need an Apple Music API key: 111 + 112 + 1. Go to the [Apple Developer portal](https://developer.apple.com/account/resources/authkeys/list) and create a **MusicKit** key. 113 + 2. Download the `.p8` file — this is your private key. 114 + 3. Note your **Key ID** and **Team ID** (found in your Apple Developer account Membership details). 115 + 116 + Add these details to your config: 117 + 118 + ```json title="applemusic.json" 119 + { 120 + "data": { 121 + "key": { 122 + "id": "2HPSNJZ88N", 123 + "teamId": "SN6YASW8G4", 124 + "p8": "-----BEGIN PRIVATE KEY-----\nMIGTAgEAMBMGByqGSM49AgEG...-----END PRIVATE KEY-----" 125 + }, 126 + "mediaUserToken": "your-media-user-token-here" 127 + } 128 + } 129 + ``` 130 + 131 + :::tip 132 + When pasting the contents of your `.p8` file into JSON, make sure to replace physical line breaks with `\n` so it remains a valid, single-line JSON string. 133 + ::: 134 + 135 + </TabItem> 136 + </Tabs> 137 + 138 + --- 139 + 140 + ### How Multi-Scrobbler handles Apple Music quirks 141 + 142 + #### Timestamp Estimation 143 + 144 + The Apple Music API **does not provide timestamps** for when tracks were played. Multi-Scrobbler estimates play times by taking the current time and subtracting track durations backwards: 145 + * The most recent track is assumed to have finished playing **now**. 146 + * Each older track is estimated to have played `duration` seconds before the previous one. 147 + 148 + *For the most accurate scrobble timestamps, it is highly recommended to keep the polling interval (`APPLEMUSIC_INTERVAL`) low (the default is 60 seconds).* 149 + 150 + #### Duplicate Play Recovery 151 + 152 + 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. 153 + 154 + 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). 155 + 156 + 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. 157 + 158 + *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.* 159 + 160 + 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. 161 + 162 + #### Album name normalization 163 + 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> 245 + 246 + ## Configuration Reference 247 + 248 + <Config config="AppleMusicSourceConfig" fileContent={AppleMusicConfig} name="applemusic"> 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. | 262 + </Config>
+1
docsite/docs/configuration/sources/sources.mdx
··· 15 15 16 16 | Name | Networking | Scrobble SOT | [Multi Device/User](#multi-deviceuser) | [Scrobble Destination](#limiting-scrobble-destination) | [Thresholds](#scrobble-thresholds) | [Should Scrobble](#should-scrobble-behavior) | 17 17 | :---------------------------------------------------------------------- | :------------------------------------------------------- | :-------------------------------------------------- | :------------------------------------- | :----------------------------------------------------- | :--------------------------------- | :------------------------------------------- | 18 + | [Apple Music](/configuration/sources/applemusic) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ❌ | 18 19 | [Azuracast](/configuration/sources/azuracast) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ❌ | ✅ | ✅ | ✅ | 19 20 | [Deezer](/configuration/sources/deezer) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ✅ | ✅ | ❌ | ❌ | 20 21 | [Google Cast (Chromecast)](/configuration/sources/google-cast) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ✅ | ✅ | ✅ | ❌ |
+1
docsite/docs/index.mdx
··· 13 13 A dockerized app that monitors your music listening activity from *everywhere* and scrobbles it *anywhere*. 14 14 15 15 * Supports monitoring activity from many [**Sources**](/configuration/sources) 16 + * [Apple Music](/configuration/sources/applemusic) 16 17 * [Azuracast](/configuration/sources/azuracast) 17 18 * [Deezer](/configuration/sources/deezer) 18 19 * [Google Cast (Chromecast)](/configuration/sources/google-cast)
+22 -139
package-lock.json
··· 79 79 "musicbrainz-api": "^0.27.0", 80 80 "nanoid": "^3.3.1", 81 81 "neotraverse": "^0.6.18", 82 + "node-musickit-api": "^3.1.0-next.5", 82 83 "node-object-hash": "^3.1.1", 83 84 "normalize-url": "^8.0.1", 84 85 "ntfy": "^1.15.2", ··· 2658 2659 "arm64" 2659 2660 ], 2660 2661 "dev": true, 2661 - "libc": [ 2662 - "glibc" 2663 - ], 2664 2662 "license": "MIT", 2665 2663 "optional": true, 2666 2664 "os": [ ··· 2678 2676 "arm64" 2679 2677 ], 2680 2678 "dev": true, 2681 - "libc": [ 2682 - "musl" 2683 - ], 2684 2679 "license": "MIT", 2685 2680 "optional": true, 2686 2681 "os": [ ··· 2698 2693 "ppc64" 2699 2694 ], 2700 2695 "dev": true, 2701 - "libc": [ 2702 - "glibc" 2703 - ], 2704 2696 "license": "MIT", 2705 2697 "optional": true, 2706 2698 "os": [ ··· 2718 2710 "riscv64" 2719 2711 ], 2720 2712 "dev": true, 2721 - "libc": [ 2722 - "glibc" 2723 - ], 2724 2713 "license": "MIT", 2725 2714 "optional": true, 2726 2715 "os": [ ··· 2738 2727 "riscv64" 2739 2728 ], 2740 2729 "dev": true, 2741 - "libc": [ 2742 - "musl" 2743 - ], 2744 2730 "license": "MIT", 2745 2731 "optional": true, 2746 2732 "os": [ ··· 2758 2744 "s390x" 2759 2745 ], 2760 2746 "dev": true, 2761 - "libc": [ 2762 - "glibc" 2763 - ], 2764 2747 "license": "MIT", 2765 2748 "optional": true, 2766 2749 "os": [ ··· 2778 2761 "x64" 2779 2762 ], 2780 2763 "dev": true, 2781 - "libc": [ 2782 - "glibc" 2783 - ], 2784 2764 "license": "MIT", 2785 2765 "optional": true, 2786 2766 "os": [ ··· 2798 2778 "x64" 2799 2779 ], 2800 2780 "dev": true, 2801 - "libc": [ 2802 - "musl" 2803 - ], 2804 2781 "license": "MIT", 2805 2782 "optional": true, 2806 2783 "os": [ ··· 3013 2990 "arm64" 3014 2991 ], 3015 2992 "dev": true, 3016 - "libc": [ 3017 - "glibc" 3018 - ], 3019 2993 "license": "MIT", 3020 2994 "optional": true, 3021 2995 "os": [ ··· 3030 3004 "arm64" 3031 3005 ], 3032 3006 "dev": true, 3033 - "libc": [ 3034 - "musl" 3035 - ], 3036 3007 "license": "MIT", 3037 3008 "optional": true, 3038 3009 "os": [ ··· 3047 3018 "ppc64" 3048 3019 ], 3049 3020 "dev": true, 3050 - "libc": [ 3051 - "glibc" 3052 - ], 3053 3021 "license": "MIT", 3054 3022 "optional": true, 3055 3023 "os": [ ··· 3064 3032 "riscv64" 3065 3033 ], 3066 3034 "dev": true, 3067 - "libc": [ 3068 - "glibc" 3069 - ], 3070 3035 "license": "MIT", 3071 3036 "optional": true, 3072 3037 "os": [ ··· 3081 3046 "riscv64" 3082 3047 ], 3083 3048 "dev": true, 3084 - "libc": [ 3085 - "musl" 3086 - ], 3087 3049 "license": "MIT", 3088 3050 "optional": true, 3089 3051 "os": [ ··· 3098 3060 "s390x" 3099 3061 ], 3100 3062 "dev": true, 3101 - "libc": [ 3102 - "glibc" 3103 - ], 3104 3063 "license": "MIT", 3105 3064 "optional": true, 3106 3065 "os": [ ··· 3115 3074 "x64" 3116 3075 ], 3117 3076 "dev": true, 3118 - "libc": [ 3119 - "glibc" 3120 - ], 3121 3077 "license": "MIT", 3122 3078 "optional": true, 3123 3079 "os": [ ··· 3132 3088 "x64" 3133 3089 ], 3134 3090 "dev": true, 3135 - "libc": [ 3136 - "musl" 3137 - ], 3138 3091 "license": "MIT", 3139 3092 "optional": true, 3140 3093 "os": [ ··· 3370 3323 "arm" 3371 3324 ], 3372 3325 "dev": true, 3373 - "libc": [ 3374 - "glibc" 3375 - ], 3376 3326 "license": "MIT", 3377 3327 "optional": true, 3378 3328 "os": [ ··· 3394 3344 "arm" 3395 3345 ], 3396 3346 "dev": true, 3397 - "libc": [ 3398 - "musl" 3399 - ], 3400 3347 "license": "MIT", 3401 3348 "optional": true, 3402 3349 "os": [ ··· 3418 3365 "arm64" 3419 3366 ], 3420 3367 "dev": true, 3421 - "libc": [ 3422 - "glibc" 3423 - ], 3424 3368 "license": "MIT", 3425 3369 "optional": true, 3426 3370 "os": [ ··· 3442 3386 "arm64" 3443 3387 ], 3444 3388 "dev": true, 3445 - "libc": [ 3446 - "musl" 3447 - ], 3448 3389 "license": "MIT", 3449 3390 "optional": true, 3450 3391 "os": [ ··· 3466 3407 "x64" 3467 3408 ], 3468 3409 "dev": true, 3469 - "libc": [ 3470 - "glibc" 3471 - ], 3472 3410 "license": "MIT", 3473 3411 "optional": true, 3474 3412 "os": [ ··· 3490 3428 "x64" 3491 3429 ], 3492 3430 "dev": true, 3493 - "libc": [ 3494 - "musl" 3495 - ], 3496 3431 "license": "MIT", 3497 3432 "optional": true, 3498 3433 "os": [ ··· 3963 3898 "arm64" 3964 3899 ], 3965 3900 "dev": true, 3966 - "libc": [ 3967 - "glibc" 3968 - ], 3969 3901 "license": "MIT", 3970 3902 "optional": true, 3971 3903 "os": [ ··· 3983 3915 "arm64" 3984 3916 ], 3985 3917 "dev": true, 3986 - "libc": [ 3987 - "musl" 3988 - ], 3989 3918 "license": "MIT", 3990 3919 "optional": true, 3991 3920 "os": [ ··· 4003 3932 "ppc64" 4004 3933 ], 4005 3934 "dev": true, 4006 - "libc": [ 4007 - "glibc" 4008 - ], 4009 3935 "license": "MIT", 4010 3936 "optional": true, 4011 3937 "os": [ ··· 4023 3949 "s390x" 4024 3950 ], 4025 3951 "dev": true, 4026 - "libc": [ 4027 - "glibc" 4028 - ], 4029 3952 "license": "MIT", 4030 3953 "optional": true, 4031 3954 "os": [ ··· 4043 3966 "x64" 4044 3967 ], 4045 3968 "dev": true, 4046 - "libc": [ 4047 - "glibc" 4048 - ], 4049 3969 "license": "MIT", 4050 3970 "optional": true, 4051 3971 "os": [ ··· 4063 3983 "x64" 4064 3984 ], 4065 3985 "dev": true, 4066 - "libc": [ 4067 - "musl" 4068 - ], 4069 3986 "license": "MIT", 4070 3987 "optional": true, 4071 3988 "os": [ ··· 5199 5116 "arm64" 5200 5117 ], 5201 5118 "dev": true, 5202 - "libc": [ 5203 - "glibc" 5204 - ], 5205 5119 "license": "MIT", 5206 5120 "optional": true, 5207 5121 "os": [ ··· 5219 5133 "arm64" 5220 5134 ], 5221 5135 "dev": true, 5222 - "libc": [ 5223 - "musl" 5224 - ], 5225 5136 "license": "MIT", 5226 5137 "optional": true, 5227 5138 "os": [ ··· 5239 5150 "x64" 5240 5151 ], 5241 5152 "dev": true, 5242 - "libc": [ 5243 - "glibc" 5244 - ], 5245 5153 "license": "MIT", 5246 5154 "optional": true, 5247 5155 "os": [ ··· 5259 5167 "x64" 5260 5168 ], 5261 5169 "dev": true, 5262 - "libc": [ 5263 - "musl" 5264 - ], 5265 5170 "license": "MIT", 5266 5171 "optional": true, 5267 5172 "os": [ ··· 6253 6158 "arm64" 6254 6159 ], 6255 6160 "dev": true, 6256 - "libc": [ 6257 - "glibc" 6258 - ], 6259 6161 "license": "MIT", 6260 6162 "optional": true, 6261 6163 "os": [ ··· 6270 6172 "arm64" 6271 6173 ], 6272 6174 "dev": true, 6273 - "libc": [ 6274 - "musl" 6275 - ], 6276 6175 "license": "MIT", 6277 6176 "optional": true, 6278 6177 "os": [ ··· 6287 6186 "loong64" 6288 6187 ], 6289 6188 "dev": true, 6290 - "libc": [ 6291 - "glibc" 6292 - ], 6293 6189 "license": "MIT", 6294 6190 "optional": true, 6295 6191 "os": [ ··· 6304 6200 "loong64" 6305 6201 ], 6306 6202 "dev": true, 6307 - "libc": [ 6308 - "musl" 6309 - ], 6310 6203 "license": "MIT", 6311 6204 "optional": true, 6312 6205 "os": [ ··· 6321 6214 "ppc64" 6322 6215 ], 6323 6216 "dev": true, 6324 - "libc": [ 6325 - "glibc" 6326 - ], 6327 6217 "license": "MIT", 6328 6218 "optional": true, 6329 6219 "os": [ ··· 6338 6228 "riscv64" 6339 6229 ], 6340 6230 "dev": true, 6341 - "libc": [ 6342 - "glibc" 6343 - ], 6344 6231 "license": "MIT", 6345 6232 "optional": true, 6346 6233 "os": [ ··· 6355 6242 "riscv64" 6356 6243 ], 6357 6244 "dev": true, 6358 - "libc": [ 6359 - "musl" 6360 - ], 6361 6245 "license": "MIT", 6362 6246 "optional": true, 6363 6247 "os": [ ··· 6372 6256 "s390x" 6373 6257 ], 6374 6258 "dev": true, 6375 - "libc": [ 6376 - "glibc" 6377 - ], 6378 6259 "license": "MIT", 6379 6260 "optional": true, 6380 6261 "os": [ ··· 6389 6270 "x64" 6390 6271 ], 6391 6272 "dev": true, 6392 - "libc": [ 6393 - "glibc" 6394 - ], 6395 6273 "license": "MIT", 6396 6274 "optional": true, 6397 6275 "os": [ ··· 6406 6284 "x64" 6407 6285 ], 6408 6286 "dev": true, 6409 - "libc": [ 6410 - "musl" 6411 - ], 6412 6287 "license": "MIT", 6413 6288 "optional": true, 6414 6289 "os": [ ··· 13386 13261 "jiti": "lib/jiti-cli.mjs" 13387 13262 } 13388 13263 }, 13264 + "node_modules/jose": { 13265 + "version": "6.2.3", 13266 + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", 13267 + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", 13268 + "license": "MIT", 13269 + "funding": { 13270 + "url": "https://github.com/sponsors/panva" 13271 + } 13272 + }, 13389 13273 "node_modules/joycon": { 13390 13274 "version": "3.1.1", 13391 13275 "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", ··· 13851 13735 "arm64" 13852 13736 ], 13853 13737 "dev": true, 13854 - "libc": [ 13855 - "glibc" 13856 - ], 13857 13738 "license": "MPL-2.0", 13858 13739 "optional": true, 13859 13740 "os": [ ··· 13875 13756 "arm64" 13876 13757 ], 13877 13758 "dev": true, 13878 - "libc": [ 13879 - "musl" 13880 - ], 13881 13759 "license": "MPL-2.0", 13882 13760 "optional": true, 13883 13761 "os": [ ··· 13899 13777 "x64" 13900 13778 ], 13901 13779 "dev": true, 13902 - "libc": [ 13903 - "glibc" 13904 - ], 13905 13780 "license": "MPL-2.0", 13906 13781 "optional": true, 13907 13782 "os": [ ··· 13923 13798 "x64" 13924 13799 ], 13925 13800 "dev": true, 13926 - "libc": [ 13927 - "musl" 13928 - ], 13929 13801 "license": "MPL-2.0", 13930 13802 "optional": true, 13931 13803 "os": [ ··· 15012 14884 "encoding": { 15013 14885 "optional": true 15014 14886 } 14887 + } 14888 + }, 14889 + "node_modules/node-musickit-api": { 14890 + "version": "3.1.0-next.5", 14891 + "resolved": "https://registry.npmjs.org/node-musickit-api/-/node-musickit-api-3.1.0-next.5.tgz", 14892 + "integrity": "sha512-JdqoIj8VjpnpIVeOHY+clBU8xPKJH2fjwBQOWdOGScfgIBvbCDoQf66P+5pFIwUrirB0RcUV+ipENgW0NpHvCQ==", 14893 + "license": "MIT", 14894 + "dependencies": { 14895 + "jose": "^6.2.3" 14896 + }, 14897 + "peerDependencies": { 14898 + "typescript": ">=5" 15015 14899 } 15016 14900 }, 15017 14901 "node_modules/node-object-hash": { ··· 18993 18877 "version": "6.0.3", 18994 18878 "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", 18995 18879 "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", 18996 - "devOptional": true, 18997 18880 "license": "Apache-2.0", 18998 18881 "bin": { 18999 18882 "tsc": "bin/tsc",
+1
package.json
··· 119 119 "musicbrainz-api": "^0.27.0", 120 120 "nanoid": "^3.3.1", 121 121 "neotraverse": "^0.6.18", 122 + "node-musickit-api": "^3.1.0-next.5", 122 123 "node-object-hash": "^3.1.1", 123 124 "normalize-url": "^8.0.1", 124 125 "ntfy": "^1.15.2",
+1 -1
src/backend/common/infrastructure/Atomic.ts
··· 15 15 levelLabel: string 16 16 } 17 17 18 - export const lowGranularitySources: SourceType[] = ['subsonic', 'ytmusic']; 18 + export const lowGranularitySources: SourceType[] = ['subsonic', 'ytmusic', 'applemusic']; 19 19 20 20 export interface ComponentIdentifier { 21 21 type: SourceType | ClientType
+58
src/backend/common/infrastructure/config/source/applemusic.ts
··· 1 + import type {PollingOptions} from "../common.ts"; 2 + import type {CommonSourceConfig, CommonSourceData, CommonSourceOptions} from "./index.ts"; 3 + 4 + export interface AppleMusicKey { 5 + id: string 6 + teamId: string 7 + p8: string 8 + } 9 + 10 + export interface AppleMusicData extends CommonSourceData, PollingOptions { 11 + key?: AppleMusicKey 12 + token?: string 13 + mediaUserToken?: string 14 + /** 15 + * Origin header to include in every Apple Music API request. 16 + * Required when using a browser token (not a MusicKit key). 17 + * 18 + * @example "https://music.apple.com" 19 + */ 20 + origin?: string 21 + } 22 + 23 + export interface AppleMusicSourceConfig extends CommonSourceConfig { 24 + data?: AppleMusicData 25 + options?: CommonSourceOptions & { 26 + logAuth?: boolean 27 + logDiff?: boolean 28 + /** 29 + * Fixes a quirk where Apple Music's history API hides duplicate plays. 30 + * If you listen to A → B → A, the API returns [A, B, X, Y ...] instead of [A, B, A, X, Y ...]. 31 + * This can cause MS to skip the second A play. 32 + * 33 + * When enabled (default), MS detects this pattern, keeps the interim tracks (B), 34 + * and re-scrobbles A as a re-listen. Disable only if you notice false positives. 35 + * 36 + * @default true 37 + * @examples [true, false] 38 + */ 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 53 + } 54 + } 55 + 56 + export interface AppleMusicSourceAIOConfig extends AppleMusicSourceConfig { 57 + type: 'applemusic' 58 + }
+8 -3
src/backend/common/infrastructure/config/source/sources.ts
··· 27 27 import type {RockskySourceAIOConfig, RockskySourceConfig} from "./rocksky.ts"; 28 28 import type {LibrefmSouceAIOConfig, LibrefmSourceConfig} from "./librefm.ts"; 29 29 import type {SonosSourceAIOConfig, SonosSourceConfig} from "./sonos.ts"; 30 + import type {AppleMusicSourceAIOConfig, AppleMusicSourceConfig} from "./applemusic.ts"; 30 31 31 32 32 33 export type SourceConfig = ··· 58 59 | KoitoSourceConfig 59 60 | TealSourceConfig 60 61 | RockskySourceConfig 61 - | SonosSourceConfig; 62 + | SonosSourceConfig 63 + | AppleMusicSourceConfig; 62 64 63 65 export type SourceAIOConfig = 64 66 SpotifySourceAIOConfig ··· 89 91 | KoitoSourceAIOConfig 90 92 | TealSourceAIOConfig 91 93 | RockskySourceAIOConfig 92 - | SonosSourceAIOConfig; 94 + | SonosSourceAIOConfig 95 + | AppleMusicSourceAIOConfig; 93 96 94 97 /** Used for docusaurus schemas 95 98 * We need to show "array of" for each type of config when looking at File Config ··· 128 131 export type TealSourceConfigs = TealSourceConfig[]; 129 132 export type RockskySourceConfigs = RockskySourceConfig[]; 130 133 export type SonosSourceConfigs = SonosSourceConfig[]; 134 + export type AppleMusicSourceConfigs = AppleMusicSourceConfig[]; 131 135 132 136 133 137 export const atomicSourceInterfaces = [ ··· 159 163 'KoitoSourceConfig', 160 164 'TealSourceConfig', 161 165 'RockskySourceConfig', 162 - 'SonosSourceConfig' 166 + 'SonosSourceConfig', 167 + 'AppleMusicSourceConfig' 163 168 ]; 164 169 165 170 export const sourceInterfaces = [
+368
src/backend/sources/AppleMusicSource.ts
··· 1 + import dayjs, { type Dayjs } from "dayjs"; 2 + import type EventEmitter from "events"; 3 + import { MusicKit, type MeHistoryRecentlyPlayedTracksProps, type Song } from "node-musickit-api"; 4 + import type { PlayObject, PlayObjectMinimal } from "../../core/Atomic.ts"; 5 + import type { InternalConfig } from "../common/infrastructure/Atomic.ts"; 6 + import type { AppleMusicSourceConfig } from "../common/infrastructure/config/source/applemusic.ts"; 7 + import AbstractSource, { type RecentlyPlayedOptions } from "./AbstractSource.ts"; 8 + import { artistNamesToCredits } from "../../core/StringUtils.ts"; 9 + import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts"; 10 + import { 11 + getPlaysDiff, 12 + humanReadableDiff, 13 + type PlayOrderChangeType, 14 + type PlayOrderConsistencyResults, 15 + playsAreAddedOnly, 16 + playsAreBumpedOnly, 17 + playsAreSortConsistent 18 + } from "../utils/PlayComparisonUtils.ts"; 19 + 20 + export type AppleMusicHistoryDiffType = 'bump' | 'added' | 'top-rebound'; 21 + 22 + export interface HistoryConsistencyResult { 23 + plays: PlayObject[]; 24 + consistent: boolean; 25 + diffType?: AppleMusicHistoryDiffType; 26 + diffResults?: PlayOrderConsistencyResults<PlayOrderChangeType>; 27 + reason?: string; 28 + } 29 + 30 + export default class AppleMusicSource extends AbstractSource { 31 + 32 + private readonly MAX_HISTORY_KEPT = 3; 33 + private readonly POLL_TRACK_LIMIT = 20; 34 + private readonly UPSTREAM_TRACK_LIMIT = 30; 35 + 36 + requiresAuth = true; 37 + requiresAuthInteraction = false; 38 + 39 + declare config: AppleMusicSourceConfig 40 + 41 + recentlyPlayed: PlayObject[] = []; 42 + musicKit!: MusicKit; 43 + 44 + recentChangedHistoryResponses: {ts: Dayjs, plays: PlayObject[]}[] = []; 45 + 46 + constructor(name: string, config: AppleMusicSourceConfig, internal: InternalConfig, emitter: EventEmitter) { 47 + super('applemusic', name, config, internal, emitter); 48 + this.canPoll = true; 49 + this.canBacklog = false; 50 + this.supportsUpstreamRecentlyPlayed = true; 51 + } 52 + 53 + protected async doBuildInitData(): Promise<true | string | undefined> { 54 + const { 55 + key, 56 + token, 57 + mediaUserToken, 58 + origin, 59 + } = this.config.data || {}; 60 + 61 + if (!key && !token) { 62 + throw new Error('Either "key" or "token" config property is required for Apple Music authentication'); 63 + } 64 + if (!mediaUserToken) { 65 + throw new Error('"mediaUserToken" config property is required for user-specific endpoints like recently played.'); 66 + } 67 + 68 + if (key) { 69 + this.musicKit = new MusicKit({ key, mediaUserToken }); 70 + } else { 71 + this.musicKit = new MusicKit({ 72 + key: { id: '__token__', teamId: '__token__', p8: '__token__' }, 73 + mediaUserToken, 74 + }); 75 + this.musicKit.token = token!; 76 + } 77 + 78 + if (origin) { 79 + this.musicKit.headers = { Origin: origin }; 80 + } 81 + 82 + return true; 83 + } 84 + 85 + doAuthentication = async (): Promise<boolean> => { 86 + try { 87 + if (this.config.data?.key) { 88 + await this.musicKit.auth(); 89 + } 90 + 91 + const status = await this.musicKit.testAuth(); 92 + 93 + if (status !== 200) { 94 + throw new Error(`Apple Music test auth returned status code ${status}`); 95 + } 96 + 97 + return true; 98 + } catch (e) { 99 + this.logger.error(new Error('Apple Music authentication failed', {cause: e})); 100 + throw e; 101 + } 102 + } 103 + 104 + protected async doCheckConnection(): Promise<true | string | undefined> { 105 + if (this.config.data?.key) { 106 + await this.musicKit.auth(); 107 + } 108 + 109 + const status = await this.musicKit.testAuth(); 110 + 111 + if (status !== 200) { 112 + throw new Error(`Apple Music connection check failed with status ${status}`); 113 + } 114 + 115 + return `Apple Music API is reachable (status ${status})`; 116 + } 117 + 118 + static formatPlayObj(track: Song, options: {newFromSource?: boolean, normalizeAlbum?: boolean} = {}): PlayObject { 119 + const {newFromSource = false, normalizeAlbum = true} = options; 120 + 121 + let albumName = track.albumName 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 + } 127 + 128 + const play: PlayObjectMinimal = { 129 + data: { 130 + artists: artistNamesToCredits([track.artistName]), 131 + album: albumName, 132 + track: track.name, 133 + duration: track.durationInMillis ? Math.round(track.durationInMillis / 1000) : undefined, 134 + isrc: track.isrc 135 + }, 136 + meta: { 137 + source: 'AppleMusic', 138 + musicService: 'Apple Music', 139 + trackId: track.id, 140 + newFromSource, 141 + url: { 142 + web: track.url 143 + }, 144 + } 145 + } 146 + return baseFormatPlayObj(track, play); 147 + } 148 + 149 + recentlyPlayedTrackIsValid = (playObj: PlayObject) => playObj.meta.newFromSource 150 + 151 + getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => { 152 + return this.getTracks(this.UPSTREAM_TRACK_LIMIT); 153 + } 154 + 155 + getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { 156 + const plays = await this.getTracks(this.POLL_TRACK_LIMIT); 157 + return this.parseRecentAgainstResponse(plays).plays; 158 + } 159 + 160 + private getTracks = async (limit: number): Promise<PlayObject[]> => { 161 + const clampedLimit = Math.max(1, Math.min(this.UPSTREAM_TRACK_LIMIT, Math.round(limit))); 162 + const result = await this.musicKit.me.history.getRecentlyPlayedTracks({ limit: clampedLimit as MeHistoryRecentlyPlayedTracksProps["limit"], types: ["songs"] }); 163 + if (result.error) { 164 + throw new Error(result.error); 165 + } 166 + if (!result.data) { 167 + return []; 168 + } 169 + return (result.data as Song[]).map(track => AppleMusicSource.formatPlayObj(track, {normalizeAlbum: this.config?.options?.normalizeAlbum})); 170 + } 171 + 172 + getIncomingHistoryConsistencyResult = (plays: PlayObject[]): HistoryConsistencyResult => { 173 + const results: HistoryConsistencyResult = { 174 + plays: [], 175 + consistent: true 176 + } 177 + 178 + if(playsAreSortConsistent(this.recentlyPlayed, plays)) { 179 + return {plays: [], consistent: true}; 180 + } 181 + 182 + const recovered = this.tryRecoverUnchangedTopHistory(plays); 183 + if(recovered !== undefined) { 184 + return recovered; 185 + } 186 + 187 + let diffResults: PlayOrderConsistencyResults<PlayOrderChangeType>; 188 + let diffType: AppleMusicHistoryDiffType | undefined; 189 + diffResults = playsAreBumpedOnly(this.recentlyPlayed, plays); 190 + if(diffResults[0] === true) { 191 + diffType = 'bump'; 192 + if(diffResults[2] !== 'prepend') { 193 + return {...results, consistent: false, reason: `Previously seen Apple Music history was bumped in an unexpected way (${diffResults[2]}), resetting history to new list`, diffType, diffResults}; 194 + } 195 + return {...results, plays: [...diffResults[1]!].reverse(), diffType, diffResults}; 196 + } else { 197 + diffResults = playsAreAddedOnly(this.recentlyPlayed, plays); 198 + if(diffResults[0] === true) { 199 + diffType = 'added'; 200 + if(diffResults[2] !== 'prepend') { 201 + return {...results, consistent: false, reason: `New tracks were added to Apple Music history in an unexpected way (${diffResults[2]}), resetting watched history to new list`, diffType, diffResults}; 202 + } 203 + const revertedToRecent = this.recentChangedHistoryResponses.findIndex(x => playsAreSortConsistent(x.plays, plays)); 204 + if(revertedToRecent !== -1) { 205 + return {...results, consistent: false, reason: `Apple Music History has exact order as another recent response (${revertedToRecent + 1} ago) which means last history (n - 1) was probably out of date. Resetting history to current list and NOT ADDING new tracks since we probably already discovered them earlier.`, diffType, diffResults}; 206 + } 207 + return {...results, plays: [...diffResults[1]!].reverse(), diffType, diffResults}; 208 + } else { 209 + return {...results, consistent: false, reason: 'Apple Music History returned temporally inconsistent order, resetting history to new list.'}; 210 + } 211 + } 212 + } 213 + 214 + 215 + private tryRecoverUnchangedTopHistory = (plays: PlayObject[]): HistoryConsistencyResult | undefined => { 216 + if(this.config.options?.recoverUnchangedTopHistory === false) { 217 + return undefined; 218 + } 219 + if(this.recentlyPlayed.length === 0 || plays.length === 0) { 220 + return undefined; 221 + } 222 + 223 + if(!playsAreSortConsistent([this.recentlyPlayed[0]], [plays[0]])) { 224 + return undefined; 225 + } 226 + 227 + const prevTail = this.recentlyPlayed.slice(1); 228 + const nextTail = plays.slice(1); 229 + 230 + if(playsAreSortConsistent(prevTail, nextTail)) { 231 + return undefined; 232 + } 233 + 234 + let tailDiff: PlayOrderConsistencyResults<PlayOrderChangeType> = playsAreBumpedOnly(prevTail, nextTail); 235 + let tailKind: 'bump' | 'added'; 236 + 237 + if(tailDiff[0] === true) { 238 + if(tailDiff[2] !== 'prepend') { 239 + return undefined; 240 + } 241 + tailKind = 'bump'; 242 + } else { 243 + tailDiff = playsAreAddedOnly(prevTail, nextTail); 244 + if(tailDiff[0] !== true || tailDiff[2] !== 'prepend') { 245 + return undefined; 246 + } 247 + const revertedToRecent = this.recentChangedHistoryResponses.findIndex(x => playsAreSortConsistent(x.plays, plays)); 248 + if(revertedToRecent !== -1) { 249 + return { 250 + plays: [], 251 + consistent: false, 252 + diffType: 'top-rebound', 253 + diffResults: tailDiff, 254 + reason: `Apple Music History has exact order as another recent response during top-rebound recovery. Resetting history to current list and NOT ADDING tracks.`, 255 + }; 256 + } 257 + tailKind = 'added'; 258 + } 259 + 260 + const interimNewestFirst = tailDiff[1] ?? []; 261 + if(interimNewestFirst.length === 0) { 262 + return undefined; 263 + } 264 + 265 + const recoveredPlays = [...interimNewestFirst].reverse().concat(plays[0]); 266 + 267 + this.logger.verbose( 268 + `Recovered ${interimNewestFirst.length} interim play(s) under unchanged top track (tail ${tailKind}-prepend) and re-scrobbling top as a re-listen (top-rebound).` 269 + ); 270 + 271 + return { 272 + plays: recoveredPlays, 273 + consistent: true, 274 + diffType: 'top-rebound', 275 + diffResults: tailDiff, 276 + }; 277 + } 278 + 279 + // Apple Music does not provide timestamps for recently played tracks, so hacky method 280 + private applyCalculatedTimestamps(plays: PlayObject[]): PlayObject[] { 281 + let durSinceNow = 0; 282 + const now = dayjs(); 283 + 284 + return plays.reduceRight((acc, curr) => { 285 + const durDatedPlay = { 286 + data: { 287 + ...curr.data, 288 + playDate: durSinceNow === 0 ? now : now.subtract(durSinceNow, 'seconds'), 289 + }, 290 + meta: { ...curr.meta, newFromSource: true } 291 + } 292 + durSinceNow += curr.data.duration ?? 1; 293 + return [durDatedPlay, ...acc]; 294 + }, [] as PlayObject[]); 295 + } 296 + 297 + parseRecentAgainstResponse = (responsePlays: PlayObject[]): HistoryConsistencyResult => { 298 + 299 + let results: {plays: PlayObject[], consistent: boolean} = { 300 + plays: [], 301 + consistent: true 302 + } 303 + 304 + const plays = responsePlays.slice(0, this.POLL_TRACK_LIMIT); 305 + if(this.polling === false) { 306 + results.plays = plays; 307 + results.plays = results.plays.map((x, index) => ({ 308 + data: { 309 + ...x.data, 310 + playDate: dayjs().startOf('minute').add(index + 1, 's') 311 + }, 312 + meta: { 313 + ...x.meta, 314 + newFromSource: true 315 + } 316 + })); 317 + } else { 318 + 319 + const cResults = this.getIncomingHistoryConsistencyResult(plays); 320 + 321 + const { 322 + reason, 323 + plays: newPlays, 324 + consistent, 325 + diffResults, 326 + diffType 327 + } = cResults; 328 + 329 + results = cResults; 330 + 331 + if(!consistent || (newPlays.length > 0 && this.config.options?.logDiff === true)) { 332 + const playsDiff = getPlaysDiff(this.recentlyPlayed, plays) 333 + const humanDiff = humanReadableDiff(this.recentlyPlayed, plays, playsDiff); 334 + const diffMsg = `Changes from last seen list detected as ${diffType ?? 'unknown'} type:\n${humanDiff}`; 335 + if(reason !== undefined) { 336 + this.logger.warn(reason); 337 + this.logger.warn(diffMsg); 338 + } else { 339 + this.logger.verbose(diffMsg); 340 + } 341 + } 342 + 343 + results.plays = this.applyCalculatedTimestamps(results.plays); 344 + } 345 + 346 + this.recentlyPlayed = plays; 347 + 348 + if(results.plays.length > 0) { 349 + this.recentChangedHistoryResponses = [{plays, ts: dayjs()}, ...this.recentChangedHistoryResponses.slice(0, this.MAX_HISTORY_KEPT)] 350 + } 351 + 352 + return results; 353 + } 354 + 355 + onPollPostAuthCheck = async () => { 356 + if(!this.polling) { 357 + this.logger.verbose('Hydrating initial recently played tracks for reference.'); 358 + const referencePlays = await this.getRecentlyPlayed(); 359 + const reversedPlays = [...referencePlays]; 360 + reversedPlays.reverse(); 361 + 362 + for(const refPlay of reversedPlays) { 363 + await this.addPlayToDiscovered(refPlay); 364 + } 365 + } 366 + return true; 367 + } 368 + }
+46 -1
src/backend/sources/ScrobbleSources.ts
··· 32 32 import type {YTMusicData, YTMusicSourceConfig} from "../common/infrastructure/config/source/ytmusic.ts"; 33 33 import type {YandexMusicBridgeData, YandexMusicBridgeSourceConfig} from "../common/infrastructure/config/source/ymbridge.ts"; 34 34 import type {SonosData, SonosSourceConfig} from "../common/infrastructure/config/source/sonos.ts"; 35 + import type {AppleMusicSourceConfig} from "../common/infrastructure/config/source/applemusic.ts"; 35 36 import type { WildcardEmitter } from "../common/WildcardEmitter.ts"; 36 - import { nonEmptyObj, parseBool } from "../utils.ts"; 37 + import { nonEmptyObj, parseBool, parseBoolStrict } from "../utils.ts"; 37 38 import { removeUndefinedKeys } from '../../core/DataUtils.ts'; 38 39 import { getCommonComponentEnvConfig, readJson } from '../utils/DataUtils.ts'; 39 40 import { validateJson } from "../utils/ValidationUtils.ts"; ··· 170 171 return "RockskySourceConfig"; 171 172 case 'sonos': 172 173 return 'SonosSourceConfig'; 174 + case 'applemusic': 175 + return 'AppleMusicSourceConfig'; 173 176 } 174 177 } 175 178 ··· 829 832 }); 830 833 } 831 834 } break; 835 + case 'applemusic': { 836 + const key = (() => { 837 + const id = process.env.APPLEMUSIC_KEY_ID; 838 + const teamId = process.env.APPLEMUSIC_TEAM_ID; 839 + const p8 = process.env.APPLEMUSIC_KEY_P8; 840 + if (id !== undefined && teamId !== undefined && p8 !== undefined) { 841 + return { id, teamId, p8 }; 842 + } 843 + return undefined; 844 + })(); 845 + const data = removeUndefinedKeys({ 846 + mediaUserToken: process.env.APPLEMUSIC_MEDIA_USER_TOKEN, 847 + token: process.env.APPLEMUSIC_TOKEN, 848 + key, 849 + origin: nonEmptyStringOrDefault(process.env.APPLEMUSIC_ORIGIN_HEADER, undefined), 850 + }, false); 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 + const p = getCommonComponentEnvConfig('APPLEMUSIC'); 856 + if (nonEmptyObj(data) || nonEmptyObj(p)) { 857 + configs.push({ 858 + type: 'applemusic', 859 + name: 'unnamed', 860 + source: 'ENV', 861 + mode: 'single', 862 + configureAs: defaultConfigureAs, 863 + data: data, 864 + ...p, 865 + options: transformPresetEnv('APPLEMUSIC', { 866 + recoverUnchangedTopHistory, 867 + normalizeAlbumName 868 + } as AppleMusicSourceConfig['options']) 869 + }); 870 + } 871 + } break; 832 872 case 'sonos': { 833 873 const data: SonosData = removeUndefinedKeys<SonosData>({ 834 874 host: process.env.SONOS_HOST, ··· 1135 1175 case 'sonos': { 1136 1176 const {SonosSource} = (await import('./SonosSource.ts')); 1137 1177 newSource = await new SonosSource(name, compositeConfig as SonosSourceConfig, this.internalConfig, this.emitter); 1178 + break; 1179 + } 1180 + case 'applemusic': { 1181 + const AppleMusicSource = (await import('./AppleMusicSource.ts')).default; 1182 + newSource = await new AppleMusicSource(name, compositeConfig as AppleMusicSourceConfig, this.internalConfig, this.emitter); 1138 1183 break; 1139 1184 } 1140 1185 default:
+306
src/backend/tests/applemusic/applemusic.test.ts
··· 1 + import { describe, it } from 'mocha'; 2 + import { loggerTest } from "@foxxmd/logging"; 3 + import chai, { expect } from 'chai'; 4 + import asPromised from 'chai-as-promised'; 5 + import clone from "clone"; 6 + import AppleMusicSource from "../../sources/AppleMusicSource.ts"; 7 + import EventEmitter from "events"; 8 + import { generatePlay, generatePlays } from '../../../core/tests/utils/PlayTestUtils.ts'; 9 + import type { AppleMusicSourceConfig } from '../../common/infrastructure/config/source/applemusic.ts'; 10 + import { sleep } from '../../utils.ts'; 11 + import dayjs from 'dayjs'; 12 + 13 + chai.use(asPromised); 14 + 15 + const createAppleMusicSource = async (opts?: { 16 + config?: any 17 + emitter?: EventEmitter 18 + }) => { 19 + const { 20 + config = { 21 + data: { 22 + token: 'mock-token', 23 + mediaUserToken: 'mock-media', 24 + }, 25 + options: { 26 + logDiff: true 27 + } 28 + }, 29 + emitter = new EventEmitter 30 + } = opts || {}; 31 + 32 + const source = new AppleMusicSource('test', config as AppleMusicSourceConfig, { localUrl: new URL('https://example.com'), configDir: 'fake', logger: loggerTest, version: 'test' }, emitter); 33 + 34 + await source.buildDatabase(); 35 + source.buildTransformRules(); 36 + 37 + return source; 38 + } 39 + 40 + describe('Apple Music - History Consistency & Deduplication', function () { 41 + 42 + it(`Adds new, prepended track`, async function () { 43 + const source = await createAppleMusicSource(); 44 + const plays = generatePlays(20, {playDate: dayjs().subtract(10, 'minutes')}, { comment: 'Initial' }); 45 + 46 + // emulating init, get history to use as base truth without discovering tracks 47 + expect(source.parseRecentAgainstResponse(plays).plays).length(20); 48 + 49 + source.polling = true; 50 + 51 + // first true poll emulating no new tracks played (should not add new tracks from base truth) 52 + expect(source.parseRecentAgainstResponse(plays).plays).length(0); 53 + 54 + // add new track played 55 + const prependedPlays = [generatePlay({}, { comment: 'New Track' }), ...plays.slice(0, 19)]; 56 + const prependResult = source.parseRecentAgainstResponse(prependedPlays); 57 + 58 + expect(prependResult.plays).length(1); 59 + expect(prependResult).to.deep.include({consistent: true, diffType: 'added'}); 60 + expect(prependResult.diffResults![2]).eq('prepend'); 61 + }); 62 + 63 + it(`Recovers top-rebound duplicate play (interrupted loop)`, async function () { 64 + const source = await createAppleMusicSource(); 65 + 66 + // E.g., [Song A, Song C, Song X...] 67 + const plays = generatePlays(20, {duration: 180}); 68 + 69 + expect(source.parseRecentAgainstResponse(plays).plays).length(20); 70 + source.polling = true; 71 + 72 + // User plays Song B (interim track), then plays Song A again. 73 + // Apple Music deduplicates Song A by bumping it to the top. 74 + // Expected Apple Music API output: [Song A, Song B, Song C, Song X...] 75 + const interimPlay = generatePlay({duration: 200}, { comment: 'Interim Song B' }); 76 + const topReboundPlays = [plays[0], interimPlay, ...plays.slice(1, 19)]; 77 + 78 + const reboundResult = source.parseRecentAgainstResponse(topReboundPlays); 79 + 80 + expect(reboundResult.consistent).to.be.true; 81 + expect(reboundResult.diffType).to.equal('top-rebound'); 82 + 83 + // It should recover BOTH the interim track and the re-listen of the top track 84 + expect(reboundResult.plays).to.have.length(2); 85 + 86 + // Oldest first, newest last 87 + expect(reboundResult.plays[0].data.track).to.equal(interimPlay.data.track); 88 + expect(reboundResult.plays[1].data.track).to.equal(plays[0].data.track); 89 + }); 90 + 91 + it(`Ignores top-rebound when recoverUnchangedTopHistory is false`, async function () { 92 + // Pass the disabled config option into the constructor 93 + const source = await createAppleMusicSource({ 94 + config: { 95 + data: { 96 + token: 'mock-token', 97 + mediaUserToken: 'mock-media', 98 + }, 99 + options: { 100 + logDiff: true, 101 + recoverUnchangedTopHistory: false // <--- Key difference 102 + } 103 + } 104 + }); 105 + 106 + const plays = generatePlays(20); 107 + 108 + source.parseRecentAgainstResponse(plays); 109 + source.polling = true; 110 + 111 + // Simulate top-rebound deduplication 112 + const interimPlay = generatePlay({}, { comment: 'Interim Song B' }); 113 + const topReboundPlays = [plays[0], interimPlay, ...plays.slice(1, 19)]; 114 + 115 + const result = source.parseRecentAgainstResponse(topReboundPlays); 116 + 117 + // Because the flag is disabled, it should fail temporal consistency and reset the list 118 + expect(result.consistent).to.be.false; 119 + expect(result.plays).to.have.length(0); 120 + expect(result.reason).includes('temporally inconsistent order'); 121 + }); 122 + 123 + it(`Adds bumped, prepended track (Standard bump without top-rebound)`, async function () { 124 + const source = await createAppleMusicSource(); 125 + const plays = generatePlays(20); 126 + 127 + source.parseRecentAgainstResponse(plays); 128 + source.polling = true; 129 + 130 + // Move a track from index 6 to index 0 (Apple Music bumped it) 131 + const bumpedList = [...plays.map(x => clone(x))]; 132 + const bumped = bumpedList[6]; 133 + bumpedList.splice(6, 1); 134 + bumpedList.unshift(bumped); 135 + 136 + // Slice to 20 to mimic API limit 137 + const limitedBumpedList = bumpedList.slice(0, 20); 138 + 139 + const bumpedResults = source.parseRecentAgainstResponse(limitedBumpedList); 140 + 141 + expect(bumpedResults.plays).length(1); 142 + expect(bumpedResults).to.deep.include({consistent: true, diffType: 'bump'}); 143 + expect(bumpedResults.diffResults![2]).eq('prepend'); 144 + }); 145 + 146 + it(`Does not add appended track`, async function () { 147 + const source = await createAppleMusicSource(); 148 + const plays = generatePlays(20); 149 + 150 + // Initialize 151 + source.parseRecentAgainstResponse(plays); 152 + source.polling = true; 153 + 154 + // Track is erroneously added to end of history (temporally inconsistent) 155 + const appendPlays = [...plays.slice(1), generatePlay({}, { comment: 'Appended Track' })]; 156 + const appendedResult = source.parseRecentAgainstResponse(appendPlays); 157 + 158 + expect(appendedResult.plays).length(0); 159 + expect(appendedResult).to.deep.include({consistent: false, diffType: 'added'}); 160 + expect(appendedResult.diffResults![2]).eq('append'); 161 + 162 + // Assert that it threw the specific error reason for an append instead of a prepend 163 + expect(appendedResult.reason).to.include('New tracks were added to Apple Music history in an unexpected way (append)'); 164 + }); 165 + 166 + it(`Detects outdated recent history when order was previously seen`, async function () { 167 + this.timeout(3700); 168 + const source = await createAppleMusicSource(); 169 + const plays = generatePlays(20); 170 + 171 + source.parseRecentAgainstResponse(plays); 172 + source.polling = true; 173 + 174 + // Add new track played 175 + const newPlay = generatePlay({}, { comment: 'New Track' }); 176 + const prependedPlays = [newPlay, ...plays.slice(0, 19)]; 177 + expect(source.parseRecentAgainstResponse(prependedPlays).plays).length(1); 178 + 179 + await sleep(50); 180 + 181 + // Apple Music returns outdated history (the original list) 182 + // Should be detected as append since the last track reappears 183 + const badAppend = source.parseRecentAgainstResponse(plays); 184 + expect(badAppend).to.deep.include({consistent: false, diffType: 'added', plays: []}); 185 + expect(badAppend.diffResults![2]).eq('append'); 186 + 187 + await sleep(10); 188 + 189 + // Continued outdated history 190 + expect(source.parseRecentAgainstResponse(plays)).to.deep.include({consistent: true, plays: []}); 191 + 192 + await sleep(10); 193 + 194 + // Correct, current history is finally returned correctly again 195 + const recentHistoryResult = source.parseRecentAgainstResponse(prependedPlays); 196 + expect(recentHistoryResult).to.deep.include({consistent: false, plays: []}); 197 + 198 + // Should detect that we have seen this exact history state recently and NOT add the tracks again 199 + expect(recentHistoryResult.reason).includes('Apple Music History has exact order as another recent response'); 200 + }); 201 + }); 202 + 203 + describe('Apple Music - Timestamp Estimation', function () { 204 + 205 + it(`Applies calculated timestamps based on track durations`, async function () { 206 + const source = await createAppleMusicSource(); 207 + 208 + // 1. Setup a base history (polling = false triggers initialization bypass) 209 + const initialPlays = [generatePlay({duration: 100})]; 210 + source.parseRecentAgainstResponse(initialPlays); 211 + 212 + // 2. Turn on polling to trigger standard new track discovery 213 + source.polling = true; 214 + 215 + // Tracks played while polling: 216 + const oldest = generatePlay({duration: 100}); 217 + const middle = generatePlay({duration: 200}); 218 + const newest = generatePlay({duration: 300}); 219 + 220 + // Apple Music API returns newest first, so we prepend them 221 + const newHistory = [newest, middle, oldest, ...initialPlays]; 222 + 223 + // 3. Process the new history 224 + const results = source.parseRecentAgainstResponse(newHistory); 225 + 226 + // Resulting plays array is returned oldest-to-newest 227 + expect(results.plays.length).to.equal(3); 228 + 229 + const oldestPlayTime = results.plays[0].data.playDate; 230 + const middlePlayTime = results.plays[1].data.playDate; 231 + const newestPlayTime = results.plays[2].data.playDate; 232 + 233 + // Since it calculates backward: 234 + // Newest play should be ~now 235 + // Middle should be (now - newest.duration) = (now - 300s) 236 + // Oldest should be (now - newest.duration - middle.duration) = (now - 500s) 237 + 238 + expect(newestPlayTime!.isAfter(middlePlayTime!)).to.be.true; 239 + expect(middlePlayTime!.isAfter(oldestPlayTime!)).to.be.true; 240 + 241 + // Verify the gaps match the track durations 242 + const diffNewToMiddle = newestPlayTime!.diff(middlePlayTime!, 'seconds'); 243 + expect(diffNewToMiddle).to.be.closeTo(300, 1); 244 + 245 + const diffMiddleToOldest = middlePlayTime!.diff(oldestPlayTime!, 'seconds'); 246 + expect(diffMiddleToOldest).to.be.closeTo(200, 1); 247 + }); 248 + }); 249 + 250 + describe('Apple Music - Format Play Object', function () { 251 + 252 + it(`Strips " - EP" and " - Single" suffixes from album names`, function () { 253 + // Mock Apple Music API Song objects 254 + const trackEP = { 255 + id: '1', 256 + type: 'songs', 257 + name: 'SONG A', 258 + artistName: 'ARIST A', 259 + albumName: 'ALBUM A - EP', 260 + durationInMillis: 200000 261 + } as any; // Cast as any to bypass TS complaining about missing API fields (artworks, etc.) 262 + 263 + const trackSingle = { 264 + id: '2', 265 + type: 'songs', 266 + name: 'SONG B', 267 + artistName: 'ARIST B', 268 + albumName: 'ALBUM B - Single', 269 + durationInMillis: 200000 270 + } as any; 271 + 272 + const trackNormal = { 273 + id: '3', 274 + type: 'songs', 275 + name: 'SONG C', 276 + artistName: 'ARIST C', 277 + albumName: 'ALBUM C - The 2nd Album', 278 + durationInMillis: 200000 279 + } as any; 280 + 281 + const trackLowercase = { 282 + id: '4', 283 + type: 'songs', 284 + name: 'song d', 285 + artistName: 'artist d', 286 + albumName: 'album d - ep', 287 + durationInMillis: 200000 288 + } as any; 289 + 290 + const formatOptions = {normalizeAlbum: true}; 291 + 292 + // Run them through the formatter 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); 297 + 298 + // Assert the suffixes are removed 299 + expect(playEP.data.album).to.equal('ALBUM A'); 300 + expect(playSingle.data.album).to.equal('ALBUM B'); 301 + expect(playLowercase.data.album).to.equal('album d'); 302 + 303 + // Assert normal album names are untouched 304 + expect(playNormal.data.album).to.equal(trackNormal.albumName); 305 + }); 306 + });
+5 -32
src/core/Atomic.ts
··· 17 17 export const isComponentType = (type: string): type is ComponentType => isComponentTypeClient(type) || isComponentTypeSource(type); 18 18 export interface SourceStatusData { 19 19 status: string; 20 - type: 'spotify' 21 - | 'plex' 22 - | 'tautulli' 23 - | 'subsonic' 24 - | 'jellyfin' 25 - | 'lastfm' 26 - | 'librefm' 27 - | 'deezer' 28 - | 'endpointlz' 29 - | 'endpointlfm' 30 - | 'ytmusic' 31 - | 'ymbridge' 32 - | 'mpris' 33 - | 'mopidy' 34 - | 'musiccast' 35 - | 'listenbrainz' 36 - | 'jriver' 37 - | 'kodi' 38 - | 'webscrobbler' 39 - | 'chromecast' 40 - | 'maloja' 41 - | 'musikcube' 42 - | 'mpd' 43 - | 'vlc' 44 - | 'icecast' 45 - | 'azuracast' 46 - | 'koito' 47 - | 'tealfm' 48 - | 'rocksky' 49 - | 'sonos'; 20 + type: SourceType; 50 21 display: string; 51 22 tracksDiscovered: number; 52 23 name: string; ··· 746 717 'koito' | 747 718 'tealfm' | 748 719 'rocksky' | 749 - 'sonos'; 720 + 'sonos' | 721 + 'applemusic'; 750 722 export const sourceTypes: SourceType[] = [ 751 723 'spotify', 752 724 'plex', ··· 776 748 'koito', 777 749 'tealfm', 778 750 'rocksky', 779 - 'sonos' 751 + 'sonos', 752 + 'applemusic' 780 753 ]; 781 754 export const isSourceType = (data: string): data is SourceType => { 782 755 return sourceTypes.includes(data as SourceType);