diff --git a/src/backend/tests/applemusic/applemusic.test.ts b/src/backend/tests/applemusic/applemusic.test.ts new file mode 100644 index 00000000..43d19e33 --- /dev/null +++ b/src/backend/tests/applemusic/applemusic.test.ts @@ -0,0 +1,248 @@ +import { describe, it } from 'mocha'; +import { loggerTest } from "@foxxmd/logging"; +import chai, { expect } from 'chai'; +import asPromised from 'chai-as-promised'; +import clone from "clone"; +import AppleMusicSource from "../../sources/AppleMusicSource.ts"; +import EventEmitter from "events"; +import { generatePlay, generatePlays } from '../../../core/tests/utils/PlayTestUtils.ts'; +import type { AppleMusicSourceConfig } from '../../common/infrastructure/config/source/applemusic.ts'; +import { sleep } from '../../utils.ts'; +import dayjs from 'dayjs'; + +chai.use(asPromised); + +const createAppleMusicSource = async (opts?: { + config?: any + emitter?: EventEmitter +}) => { + const { + config = { + data: { + token: 'mock-token', + mediaUserToken: 'mock-media', + }, + options: { + logDiff: true + } + }, + emitter = new EventEmitter + } = opts || {}; + + const source = new AppleMusicSource('test', config as AppleMusicSourceConfig, { localUrl: new URL('https://example.com'), configDir: 'fake', logger: loggerTest, version: 'test' }, emitter); + + await source.buildDatabase(); + source.buildTransformRules(); + + return source; +} + +describe('Apple Music - History Consistency & Deduplication', function () { + + it(`Adds new, prepended track`, async function () { + const source = await createAppleMusicSource(); + const plays = generatePlays(20, {playDate: dayjs().subtract(10, 'minutes')}, { comment: 'Initial' }); + + // emulating init, get history to use as base truth without discovering tracks + expect(source.parseRecentAgainstResponse(plays).plays).length(20); + + source.polling = true; + + // first true poll emulating no new tracks played (should not add new tracks from base truth) + expect(source.parseRecentAgainstResponse(plays).plays).length(0); + + // add new track played + const prependedPlays = [generatePlay({}, { comment: 'New Track' }), ...plays.slice(0, 19)]; + const prependResult = source.parseRecentAgainstResponse(prependedPlays); + + expect(prependResult.plays).length(1); + expect(prependResult).to.deep.include({consistent: true, diffType: 'added'}); + expect(prependResult.diffResults![2]).eq('prepend'); + }); + + it(`Recovers top-rebound duplicate play (interrupted loop)`, async function () { + const source = await createAppleMusicSource(); + + // E.g., [Song A, Song C, Song X...] + const plays = generatePlays(20, {duration: 180}); + + expect(source.parseRecentAgainstResponse(plays).plays).length(20); + source.polling = true; + + // User plays Song B (interim track), then plays Song A again. + // Apple Music deduplicates Song A by bumping it to the top. + // Expected Apple Music API output: [Song A, Song B, Song C, Song X...] + const interimPlay = generatePlay({duration: 200}, { comment: 'Interim Song B' }); + const topReboundPlays = [plays[0], interimPlay, ...plays.slice(1, 19)]; + + const reboundResult = source.parseRecentAgainstResponse(topReboundPlays); + + expect(reboundResult.consistent).to.be.true; + expect(reboundResult.diffType).to.equal('top-rebound'); + + // It should recover BOTH the interim track and the re-listen of the top track + expect(reboundResult.plays).to.have.length(2); + + // Oldest first, newest last + expect(reboundResult.plays[0].data.track).to.equal(interimPlay.data.track); + expect(reboundResult.plays[1].data.track).to.equal(plays[0].data.track); + }); + + it(`Ignores top-rebound when recoverUnchangedTopHistory is false`, async function () { + // Pass the disabled config option into the constructor + const source = await createAppleMusicSource({ + config: { + data: { + token: 'mock-token', + mediaUserToken: 'mock-media', + }, + options: { + logDiff: true, + recoverUnchangedTopHistory: false // <--- Key difference + } + } + }); + + const plays = generatePlays(20); + + source.parseRecentAgainstResponse(plays); + source.polling = true; + + // Simulate top-rebound deduplication + const interimPlay = generatePlay({}, { comment: 'Interim Song B' }); + const topReboundPlays = [plays[0], interimPlay, ...plays.slice(1, 19)]; + + const result = source.parseRecentAgainstResponse(topReboundPlays); + + // Because the flag is disabled, it should fail temporal consistency and reset the list + expect(result.consistent).to.be.false; + expect(result.plays).to.have.length(0); + expect(result.reason).includes('temporally inconsistent order'); + }); + + it(`Adds bumped, prepended track (Standard bump without top-rebound)`, async function () { + const source = await createAppleMusicSource(); + const plays = generatePlays(20); + + source.parseRecentAgainstResponse(plays); + source.polling = true; + + // Move a track from index 6 to index 0 (Apple Music bumped it) + const bumpedList = [...plays.map(x => clone(x))]; + const bumped = bumpedList[6]; + bumpedList.splice(6, 1); + bumpedList.unshift(bumped); + + // Slice to 20 to mimic API limit + const limitedBumpedList = bumpedList.slice(0, 20); + + const bumpedResults = source.parseRecentAgainstResponse(limitedBumpedList); + + expect(bumpedResults.plays).length(1); + expect(bumpedResults).to.deep.include({consistent: true, diffType: 'bump'}); + expect(bumpedResults.diffResults![2]).eq('prepend'); + }); + + it(`Does not add appended track`, async function () { + const source = await createAppleMusicSource(); + const plays = generatePlays(20); + + // Initialize + source.parseRecentAgainstResponse(plays); + source.polling = true; + + // Track is erroneously added to end of history (temporally inconsistent) + const appendPlays = [...plays.slice(1), generatePlay({}, { comment: 'Appended Track' })]; + const appendedResult = source.parseRecentAgainstResponse(appendPlays); + + expect(appendedResult.plays).length(0); + expect(appendedResult).to.deep.include({consistent: false, diffType: 'added'}); + expect(appendedResult.diffResults![2]).eq('append'); + + // Assert that it threw the specific error reason for an append instead of a prepend + expect(appendedResult.reason).to.include('New tracks were added to Apple Music history in an unexpected way (append)'); + }); + + it(`Detects outdated recent history when order was previously seen`, async function () { + this.timeout(3700); + const source = await createAppleMusicSource(); + const plays = generatePlays(20); + + source.parseRecentAgainstResponse(plays); + source.polling = true; + + // Add new track played + const newPlay = generatePlay({}, { comment: 'New Track' }); + const prependedPlays = [newPlay, ...plays.slice(0, 19)]; + expect(source.parseRecentAgainstResponse(prependedPlays).plays).length(1); + + await sleep(50); + + // Apple Music returns outdated history (the original list) + // Should be detected as append since the last track reappears + const badAppend = source.parseRecentAgainstResponse(plays); + expect(badAppend).to.deep.include({consistent: false, diffType: 'added', plays: []}); + expect(badAppend.diffResults![2]).eq('append'); + + await sleep(10); + + // Continued outdated history + expect(source.parseRecentAgainstResponse(plays)).to.deep.include({consistent: true, plays: []}); + + await sleep(10); + + // Correct, current history is finally returned correctly again + const recentHistoryResult = source.parseRecentAgainstResponse(prependedPlays); + expect(recentHistoryResult).to.deep.include({consistent: false, plays: []}); + + // Should detect that we have seen this exact history state recently and NOT add the tracks again + expect(recentHistoryResult.reason).includes('Apple Music History has exact order as another recent response'); + }); +}); + +describe('Apple Music - Timestamp Estimation', function () { + + it(`Applies calculated timestamps based on track durations`, async function () { + const source = await createAppleMusicSource(); + + // 1. Setup a base history (polling = false triggers initialization bypass) + const initialPlays = [generatePlay({duration: 100})]; + source.parseRecentAgainstResponse(initialPlays); + + // 2. Turn on polling to trigger standard new track discovery + source.polling = true; + + // Tracks played while polling: + const oldest = generatePlay({duration: 100}); + const middle = generatePlay({duration: 200}); + const newest = generatePlay({duration: 300}); + + // Apple Music API returns newest first, so we prepend them + const newHistory = [newest, middle, oldest, ...initialPlays]; + + // 3. Process the new history + const results = source.parseRecentAgainstResponse(newHistory); + + // Resulting plays array is returned oldest-to-newest + expect(results.plays.length).to.equal(3); + + const oldestPlayTime = results.plays[0].data.playDate; + const middlePlayTime = results.plays[1].data.playDate; + const newestPlayTime = results.plays[2].data.playDate; + + // Since it calculates backward: + // Newest play should be ~now + // Middle should be (now - newest.duration) = (now - 300s) + // Oldest should be (now - newest.duration - middle.duration) = (now - 500s) + + expect(newestPlayTime!.isAfter(middlePlayTime!)).to.be.true; + expect(middlePlayTime!.isAfter(oldestPlayTime!)).to.be.true; + + // Verify the gaps match the track durations + const diffNewToMiddle = newestPlayTime!.diff(middlePlayTime!, 'seconds'); + expect(diffNewToMiddle).to.be.closeTo(300, 1); + + const diffMiddleToOldest = middlePlayTime!.diff(oldestPlayTime!, 'seconds'); + expect(diffMiddleToOldest).to.be.closeTo(200, 1); + }); +}); \ No newline at end of file