Something went wrong. Try again.
[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
Something went wrong. Try again.
13 kB · 306 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306import { 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(); await 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?.name).to.equal(interimPlay.data.track?.name); expect(reboundResult.plays[1].data.track?.name).to.equal(plays[0].data.track?.name); });
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); });});
describe('Apple Music - Format Play Object', function () { it(`Strips " - EP" and " - Single" suffixes from album names`, function () { // Mock Apple Music API Song objects const trackEP = { id: '1', type: 'songs', name: 'SONG A', artistName: 'ARIST A', albumName: 'ALBUM A - EP', durationInMillis: 200000 } as any; // Cast as any to bypass TS complaining about missing API fields (artworks, etc.)
const trackSingle = { id: '2', type: 'songs', name: 'SONG B', artistName: 'ARIST B', albumName: 'ALBUM B - Single', durationInMillis: 200000 } as any;
const trackNormal = { id: '3', type: 'songs', name: 'SONG C', artistName: 'ARIST C', albumName: 'ALBUM C - The 2nd Album', durationInMillis: 200000 } as any;
const trackLowercase = { id: '4', type: 'songs', name: 'song d', artistName: 'artist d', albumName: 'album d - ep', durationInMillis: 200000 } as any;
const formatOptions = {normalizeAlbum: true};
// Run them through the formatter const playEP = AppleMusicSource.formatPlayObj(trackEP, formatOptions); const playSingle = AppleMusicSource.formatPlayObj(trackSingle, formatOptions); const playNormal = AppleMusicSource.formatPlayObj(trackNormal, formatOptions); const playLowercase = AppleMusicSource.formatPlayObj(trackLowercase, formatOptions);
// Assert the suffixes are removed expect(playEP.data.album?.name).to.equal('ALBUM A'); expect(playSingle.data.album?.name).to.equal('ALBUM B'); expect(playLowercase.data.album?.name).to.equal('album d');
// Assert normal album names are untouched expect(playNormal.data.album?.name).to.equal(trackNormal.albumName); });});