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

test(applemusic): add integration tests for Apple Music module

Add new test suite for Apple Music backend logic

Exerra (Jul 15, 2026, 1:39 PM +0300) f84a0f19 a845ac10

+248
+248
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 + });