···11+import { describe, it } from 'mocha';
22+import { loggerTest } from "@foxxmd/logging";
33+import chai, { expect } from 'chai';
44+import asPromised from 'chai-as-promised';
55+import clone from "clone";
66+import AppleMusicSource from "../../sources/AppleMusicSource.ts";
77+import EventEmitter from "events";
88+import { generatePlay, generatePlays } from '../../../core/tests/utils/PlayTestUtils.ts';
99+import type { AppleMusicSourceConfig } from '../../common/infrastructure/config/source/applemusic.ts';
1010+import { sleep } from '../../utils.ts';
1111+import dayjs from 'dayjs';
1212+1313+chai.use(asPromised);
1414+1515+const createAppleMusicSource = async (opts?: {
1616+ config?: any
1717+ emitter?: EventEmitter
1818+}) => {
1919+ const {
2020+ config = {
2121+ data: {
2222+ token: 'mock-token',
2323+ mediaUserToken: 'mock-media',
2424+ },
2525+ options: {
2626+ logDiff: true
2727+ }
2828+ },
2929+ emitter = new EventEmitter
3030+ } = opts || {};
3131+3232+ const source = new AppleMusicSource('test', config as AppleMusicSourceConfig, { localUrl: new URL('https://example.com'), configDir: 'fake', logger: loggerTest, version: 'test' }, emitter);
3333+3434+ await source.buildDatabase();
3535+ source.buildTransformRules();
3636+3737+ return source;
3838+}
3939+4040+describe('Apple Music - History Consistency & Deduplication', function () {
4141+4242+ it(`Adds new, prepended track`, async function () {
4343+ const source = await createAppleMusicSource();
4444+ const plays = generatePlays(20, {playDate: dayjs().subtract(10, 'minutes')}, { comment: 'Initial' });
4545+4646+ // emulating init, get history to use as base truth without discovering tracks
4747+ expect(source.parseRecentAgainstResponse(plays).plays).length(20);
4848+4949+ source.polling = true;
5050+5151+ // first true poll emulating no new tracks played (should not add new tracks from base truth)
5252+ expect(source.parseRecentAgainstResponse(plays).plays).length(0);
5353+5454+ // add new track played
5555+ const prependedPlays = [generatePlay({}, { comment: 'New Track' }), ...plays.slice(0, 19)];
5656+ const prependResult = source.parseRecentAgainstResponse(prependedPlays);
5757+5858+ expect(prependResult.plays).length(1);
5959+ expect(prependResult).to.deep.include({consistent: true, diffType: 'added'});
6060+ expect(prependResult.diffResults![2]).eq('prepend');
6161+ });
6262+6363+ it(`Recovers top-rebound duplicate play (interrupted loop)`, async function () {
6464+ const source = await createAppleMusicSource();
6565+6666+ // E.g., [Song A, Song C, Song X...]
6767+ const plays = generatePlays(20, {duration: 180});
6868+6969+ expect(source.parseRecentAgainstResponse(plays).plays).length(20);
7070+ source.polling = true;
7171+7272+ // User plays Song B (interim track), then plays Song A again.
7373+ // Apple Music deduplicates Song A by bumping it to the top.
7474+ // Expected Apple Music API output: [Song A, Song B, Song C, Song X...]
7575+ const interimPlay = generatePlay({duration: 200}, { comment: 'Interim Song B' });
7676+ const topReboundPlays = [plays[0], interimPlay, ...plays.slice(1, 19)];
7777+7878+ const reboundResult = source.parseRecentAgainstResponse(topReboundPlays);
7979+8080+ expect(reboundResult.consistent).to.be.true;
8181+ expect(reboundResult.diffType).to.equal('top-rebound');
8282+8383+ // It should recover BOTH the interim track and the re-listen of the top track
8484+ expect(reboundResult.plays).to.have.length(2);
8585+8686+ // Oldest first, newest last
8787+ expect(reboundResult.plays[0].data.track).to.equal(interimPlay.data.track);
8888+ expect(reboundResult.plays[1].data.track).to.equal(plays[0].data.track);
8989+ });
9090+9191+ it(`Ignores top-rebound when recoverUnchangedTopHistory is false`, async function () {
9292+ // Pass the disabled config option into the constructor
9393+ const source = await createAppleMusicSource({
9494+ config: {
9595+ data: {
9696+ token: 'mock-token',
9797+ mediaUserToken: 'mock-media',
9898+ },
9999+ options: {
100100+ logDiff: true,
101101+ recoverUnchangedTopHistory: false // <--- Key difference
102102+ }
103103+ }
104104+ });
105105+106106+ const plays = generatePlays(20);
107107+108108+ source.parseRecentAgainstResponse(plays);
109109+ source.polling = true;
110110+111111+ // Simulate top-rebound deduplication
112112+ const interimPlay = generatePlay({}, { comment: 'Interim Song B' });
113113+ const topReboundPlays = [plays[0], interimPlay, ...plays.slice(1, 19)];
114114+115115+ const result = source.parseRecentAgainstResponse(topReboundPlays);
116116+117117+ // Because the flag is disabled, it should fail temporal consistency and reset the list
118118+ expect(result.consistent).to.be.false;
119119+ expect(result.plays).to.have.length(0);
120120+ expect(result.reason).includes('temporally inconsistent order');
121121+ });
122122+123123+ it(`Adds bumped, prepended track (Standard bump without top-rebound)`, async function () {
124124+ const source = await createAppleMusicSource();
125125+ const plays = generatePlays(20);
126126+127127+ source.parseRecentAgainstResponse(plays);
128128+ source.polling = true;
129129+130130+ // Move a track from index 6 to index 0 (Apple Music bumped it)
131131+ const bumpedList = [...plays.map(x => clone(x))];
132132+ const bumped = bumpedList[6];
133133+ bumpedList.splice(6, 1);
134134+ bumpedList.unshift(bumped);
135135+136136+ // Slice to 20 to mimic API limit
137137+ const limitedBumpedList = bumpedList.slice(0, 20);
138138+139139+ const bumpedResults = source.parseRecentAgainstResponse(limitedBumpedList);
140140+141141+ expect(bumpedResults.plays).length(1);
142142+ expect(bumpedResults).to.deep.include({consistent: true, diffType: 'bump'});
143143+ expect(bumpedResults.diffResults![2]).eq('prepend');
144144+ });
145145+146146+ it(`Does not add appended track`, async function () {
147147+ const source = await createAppleMusicSource();
148148+ const plays = generatePlays(20);
149149+150150+ // Initialize
151151+ source.parseRecentAgainstResponse(plays);
152152+ source.polling = true;
153153+154154+ // Track is erroneously added to end of history (temporally inconsistent)
155155+ const appendPlays = [...plays.slice(1), generatePlay({}, { comment: 'Appended Track' })];
156156+ const appendedResult = source.parseRecentAgainstResponse(appendPlays);
157157+158158+ expect(appendedResult.plays).length(0);
159159+ expect(appendedResult).to.deep.include({consistent: false, diffType: 'added'});
160160+ expect(appendedResult.diffResults![2]).eq('append');
161161+162162+ // Assert that it threw the specific error reason for an append instead of a prepend
163163+ expect(appendedResult.reason).to.include('New tracks were added to Apple Music history in an unexpected way (append)');
164164+ });
165165+166166+ it(`Detects outdated recent history when order was previously seen`, async function () {
167167+ this.timeout(3700);
168168+ const source = await createAppleMusicSource();
169169+ const plays = generatePlays(20);
170170+171171+ source.parseRecentAgainstResponse(plays);
172172+ source.polling = true;
173173+174174+ // Add new track played
175175+ const newPlay = generatePlay({}, { comment: 'New Track' });
176176+ const prependedPlays = [newPlay, ...plays.slice(0, 19)];
177177+ expect(source.parseRecentAgainstResponse(prependedPlays).plays).length(1);
178178+179179+ await sleep(50);
180180+181181+ // Apple Music returns outdated history (the original list)
182182+ // Should be detected as append since the last track reappears
183183+ const badAppend = source.parseRecentAgainstResponse(plays);
184184+ expect(badAppend).to.deep.include({consistent: false, diffType: 'added', plays: []});
185185+ expect(badAppend.diffResults![2]).eq('append');
186186+187187+ await sleep(10);
188188+189189+ // Continued outdated history
190190+ expect(source.parseRecentAgainstResponse(plays)).to.deep.include({consistent: true, plays: []});
191191+192192+ await sleep(10);
193193+194194+ // Correct, current history is finally returned correctly again
195195+ const recentHistoryResult = source.parseRecentAgainstResponse(prependedPlays);
196196+ expect(recentHistoryResult).to.deep.include({consistent: false, plays: []});
197197+198198+ // Should detect that we have seen this exact history state recently and NOT add the tracks again
199199+ expect(recentHistoryResult.reason).includes('Apple Music History has exact order as another recent response');
200200+ });
201201+});
202202+203203+describe('Apple Music - Timestamp Estimation', function () {
204204+205205+ it(`Applies calculated timestamps based on track durations`, async function () {
206206+ const source = await createAppleMusicSource();
207207+208208+ // 1. Setup a base history (polling = false triggers initialization bypass)
209209+ const initialPlays = [generatePlay({duration: 100})];
210210+ source.parseRecentAgainstResponse(initialPlays);
211211+212212+ // 2. Turn on polling to trigger standard new track discovery
213213+ source.polling = true;
214214+215215+ // Tracks played while polling:
216216+ const oldest = generatePlay({duration: 100});
217217+ const middle = generatePlay({duration: 200});
218218+ const newest = generatePlay({duration: 300});
219219+220220+ // Apple Music API returns newest first, so we prepend them
221221+ const newHistory = [newest, middle, oldest, ...initialPlays];
222222+223223+ // 3. Process the new history
224224+ const results = source.parseRecentAgainstResponse(newHistory);
225225+226226+ // Resulting plays array is returned oldest-to-newest
227227+ expect(results.plays.length).to.equal(3);
228228+229229+ const oldestPlayTime = results.plays[0].data.playDate;
230230+ const middlePlayTime = results.plays[1].data.playDate;
231231+ const newestPlayTime = results.plays[2].data.playDate;
232232+233233+ // Since it calculates backward:
234234+ // Newest play should be ~now
235235+ // Middle should be (now - newest.duration) = (now - 300s)
236236+ // Oldest should be (now - newest.duration - middle.duration) = (now - 500s)
237237+238238+ expect(newestPlayTime!.isAfter(middlePlayTime!)).to.be.true;
239239+ expect(middlePlayTime!.isAfter(oldestPlayTime!)).to.be.true;
240240+241241+ // Verify the gaps match the track durations
242242+ const diffNewToMiddle = newestPlayTime!.diff(middlePlayTime!, 'seconds');
243243+ expect(diffNewToMiddle).to.be.closeTo(300, 1);
244244+245245+ const diffMiddleToOldest = middlePlayTime!.diff(oldestPlayTime!, 'seconds');
246246+ expect(diffMiddleToOldest).to.be.closeTo(200, 1);
247247+ });
248248+});