import { loggerTest } from "@foxxmd/logging"; import chai, { expect } from 'chai'; import asPromised from 'chai-as-promised'; import { describe, it } from 'mocha'; import pEvent from "p-event"; import clone from 'clone'; import type {PlayObject} from "../../../core/Atomic.ts"; import { generatePlay, generatePlayerStateData, generatePlays, normalizePlays } from "../../../core/tests/utils/PlayTestUtils.ts"; import { TestMemoryPositionalSource, TestMemorySource, TestSource } from "./TestSource.ts"; import spotifyPayload from '../plays/spotifyCurrentPlaybackState.json' with { type: "json" }; import SpotifySource from "../../sources/SpotifySource.ts"; import MockDate from 'mockdate'; import dayjs from "dayjs"; import { REPORTED_PLAYER_STATUSES } from '../../../core/Atomic.ts'; import type {SourceConfig} from "../../common/infrastructure/config/source/sources.ts"; import type MemorySource from "../../sources/MemorySource.ts"; import { RT_TICK_DEFAULT, setRtTick } from "../../sources/PlayerState/RealtimePlayer.ts"; import { sleep } from "../../utils.ts"; import DeezerInternalSource from "../../sources/DeezerInternalSource.ts"; import type {DeezerInternalSourceOptions} from "../../common/infrastructure/config/source/deezer.ts"; import { artistCreditsToNames } from "../../../core/StringUtils.ts"; import type { MarkOptional } from "ts-essentials"; import { WildcardEmitter } from "../../common/WildcardEmitter.ts"; import type { MSBackendEventMap } from "../../common/infrastructure/MSBackendEventMap.ts"; chai.use(asPromised); const emitter = new WildcardEmitter(); const generateSource = async () => { const source = new TestSource('spotify', 'test-basic', {id: `test-${Date.now()}`}, {localUrl: new URL('https://example.com'), configDir: 'fake', logger: loggerTest, version: 'test'}, emitter); await source.initialize(); await source.initTasks(); return source; } const generateMemorySource = async (config: MarkOptional = {}) => { const s = new TestMemorySource('spotify', 'test-memory', {id: `test-${Date.now()}`, ...config}, {localUrl: new URL('https://example.com'), configDir: 'fake', logger: loggerTest, version: 'test'}, emitter); await s.initialize(); // s.buildTransformRules(); s.scheduler.stop(); return s; } const generateMemoryPositionalSource = async (config: MarkOptional = {}) => { const s = new TestMemoryPositionalSource('spotify', 'test-positional', {id: `test-${Date.now()}`, ...config}, {localUrl: new URL('https://example.com'), configDir: 'fake', logger: loggerTest, version: 'test'}, emitter); await s.initialize(); //s.buildTransformRules(); s.scheduler.stop(); return s; } describe('Sources use transform plays correctly', function () { it('Transforms play on preCompare', async function() { await using source = await generateSource(); source.config.options = { playTransform: { preCompare: { type: 'user', title: [ { search: 'cool', replace: 'fun' } ] } } }; await source.buildTransformRules(); const newScrobble = generatePlay({ track: 'my cool track' }); await source.queuePlay([newScrobble]); await sleep(3); const discovered = await source.getRecentlyDiscoveredPlays(); expect(discovered.length).eq(1); expect(discovered[0].data.track).is.eq('my fun track'); }); it('Transforms play on postCompare', async function() { await using source = await generateSource(); source.config.options = { playTransform: { postCompare: { type: 'user', title: [ { search: 'cool', replace: 'fun' } ] } } }; await source.buildTransformRules(); const newScrobble = generatePlay({ track: 'my cool track' }); const pAwaiter = pEvent(source.emitter, 'discoveredToScrobble') as Promise; const promiseRes = await Promise.all([ source.queuePlay([newScrobble]), sleep(3), pAwaiter ]); //await source.queuePlay([newScrobble]); //await sleep(3); const discovered = await source.getRecentlyDiscoveredPlays(); expect(discovered.length).eq(1); expect(discovered[0].data.track).is.eq('my cool track'); //const pAwaiter = pEvent(source.emitter, 'discoveredToScrobble') as Promise; //source.handle(discovered); const e = promiseRes[2]; expect(e).is.not.undefined; const res: PlayObject[] = !Array.isArray(e.data.data) ? [e.data.data] : e.data.data; expect(res.length).is.eq(1); expect(res[0].data.track).is.eq('my fun track'); }); // TODO need to rework these // it('Transforms play existing comparison', async function() { // await using source = await generateSource(); // source.config.options = { // playTransform: { // compare: { // existing: { // type: 'user', // title: [ // { // search: 'hugely cool and very different track', // replace: 'fun' // } // ] // } // } // } // }; // source.buildTransformRules(); // const newScrobble = generatePlay({ // track: 'my hugely cool and very different track title', // }); // const discovered = await source.discover([newScrobble]) // expect(discovered.length).eq(1); // expect(discovered[0].data.track).is.eq('my hugely cool and very different track title'); // expect((await source.discover([newScrobble])).length).is.eq(1); // }); // it('Transforms play candidate comparison', async function() { // await using source = await generateSource(); // source.config.options = { // playTransform: { // compare: { // candidate: { // type: 'user', // title: [ // { // search: 'hugely cool and very different track', // replace: 'fun' // } // ] // } // } // } // }; // source.buildTransformRules(); // const newScrobble = generatePlay({ // track: 'my hugely cool and very different track title', // }); // const discovered = await source.discover([newScrobble]) // expect(discovered.length).eq(1); // expect(discovered[0].data.track).is.eq('my hugely cool and very different track title'); // expect((await source.discover([newScrobble])).length).is.eq(1); // }); }) describe('Sources correctly parse incoming payloads', function () { it('Spotify parses payload with no album artists correctly', function() { const noAAPayload = clone(spotifyPayload) noAAPayload.item.album.artists = []; const play = SpotifySource.formatPlayObj(noAAPayload as SpotifyApi.CurrentPlaybackResponse); expect(play.data.track).eq('The Sandpits Of Zonhoven'); expect(play.data.album).eq('Bloodbags And Downtube Shifters'); expect(artistCreditsToNames(play.data.artists)).eql(['Dubmood', 'MASTER BOOT RECORD']); expect(play.data.albumArtists).to.be.empty; }); it('Spotify parses payload with different album artists correctly', function() { const play = SpotifySource.formatPlayObj(spotifyPayload as SpotifyApi.CurrentPlaybackResponse); expect(play.data.track).eq('The Sandpits Of Zonhoven'); expect(play.data.album).eq('Bloodbags And Downtube Shifters'); expect(artistCreditsToNames(play.data.artists)).eql(['Dubmood', 'MASTER BOOT RECORD']); expect(artistCreditsToNames(play.data.albumArtists)).eql(['Dubmood']); }); it('Spotify parses payload with identical album artists correctly', function() { const identicalArtistsPayload = clone(spotifyPayload) identicalArtistsPayload.item.album.artists = identicalArtistsPayload.item.artists; const identicalArtistsPlay = SpotifySource.formatPlayObj(identicalArtistsPayload as SpotifyApi.CurrentPlaybackResponse); expect(identicalArtistsPlay.data.track).eq('The Sandpits Of Zonhoven'); expect(identicalArtistsPlay.data.album).eq('Bloodbags And Downtube Shifters'); expect(artistCreditsToNames(identicalArtistsPlay.data.artists)).eql(['Dubmood', 'MASTER BOOT RECORD']); expect(artistCreditsToNames(identicalArtistsPlay.data.albumArtists)).to.be.empty; }); }); describe('Player Cleanup', function () { this.afterEach(() => { MockDate.reset(); setRtTick(RT_TICK_DEFAULT); }); this.beforeEach(() => { setRtTick(1); }); const cleanedUpDuration = async (generateSource: (config: MarkOptional) => Promise) => { await using source = await generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}}); const initialDate = dayjs(); const initialState = generatePlayerStateData({position: 0, playData: {duration: 50}, stateUpdatedAt: initialDate, status: REPORTED_PLAYER_STATUSES.playing}); expect((await source.processRecentPlays([initialState])).length).to.be.eq(0); let position = 0; let timeSince = 0; // simulate polling playing source for 30 seconds, 10 second interval for(let i = 0; i < 3; i++) { position += 10; timeSince += 10; MockDate.set(initialDate.add(position, 'seconds').toDate()); await sleep(1); const advancedState = generatePlayerStateData({play: initialState.play, stateUpdatedAt: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing}); expect((await source.processRecentPlays([advancedState])).length).to.be.eq(0); } // simulate polling another 20 seconds without any updates from the Source for(let i = 0; i < 2; i++) { timeSince += 10; MockDate.set(initialDate.add(timeSince, 'seconds').toDate()); await sleep(1); expect((await source.processRecentPlays([])).length).to.be.eq(0); } MockDate.set(initialDate.add(timeSince + 2, 'seconds').toDate()); await sleep(1); const discoveredPlays = await source.processRecentPlays([]); // cleanup should discover stale play expect(discoveredPlays.length).to.be.eq(1); expect(discoveredPlays[0].data.listenedFor).closeTo(30, 2); } it('Discovers cleaned up Play with correct duration (Non Positional Source)', async function () { await cleanedUpDuration(generateMemorySource); }); it('Discovers cleaned up Play with correct duration (Positional Source)', async function () { await cleanedUpDuration(generateMemoryPositionalSource); }); const noScrobbleRediscoveryOnActive = async (generateSource: (config: MarkOptional) => Promise) => { await using source = await generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}}); const initialDate = dayjs(); const initialState = generatePlayerStateData({position: 0, playData: {duration: 50}, stateUpdatedAt: initialDate, status: REPORTED_PLAYER_STATUSES.playing}); expect((await source.processRecentPlays([initialState])).length).to.be.eq(0); let position = 0; let timeSince = 0; // simulate polling playing source for 30 seconds, 10 second interval for(let i = 0; i < 3; i++) { position += 10; timeSince += 10; MockDate.set(initialDate.add(position, 'seconds').toDate()); await sleep(1); const advancedState = generatePlayerStateData({play: initialState.play, stateUpdatedAt: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing}); expect((await source.processRecentPlays([advancedState])).length).to.be.eq(0); } // simulate polling another 20 seconds without any updates from the Source for(let i = 0; i < 2; i++) { timeSince += 10; MockDate.set(initialDate.add(timeSince, 'seconds').toDate()); await sleep(1); expect((await source.processRecentPlays([])).length).to.be.eq(0); } timeSince += 2; MockDate.set(initialDate.add(timeSince, 'seconds').toDate()); await sleep(1); const discoveredPlays = await source.processRecentPlays([]); // cleanup should discover stale play expect(discoveredPlays.length).to.be.eq(1); expect(discoveredPlays[0].data.listenedFor).closeTo(30, 2); timeSince += 10; position -= 9; // simulate polling another 20 seconds with active source again for(let i = 0; i < 2; i++) { timeSince += 10; MockDate.set(initialDate.add(timeSince, 'seconds').toDate()); await sleep(1); const advancedState = generatePlayerStateData({play: initialState.play, stateUpdatedAt: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing}); expect((await source.processRecentPlays([advancedState])).length).to.be.eq(0); } timeSince += 10; MockDate.set(initialDate.add(timeSince, 'seconds').toDate()); await sleep(1); // new Play const advancedState = generatePlayerStateData({stateUpdatedAt: dayjs(), position: 0, status: REPORTED_PLAYER_STATUSES.playing}); // should not return play because it has only been played for ~20 seconds, less than 50% of duration const plays = await source.processRecentPlays([advancedState]) expect(plays.length).to.be.eq(0); } it('Does not discover same Play after becoming active again (Non Positional Source)', async function () { await noScrobbleRediscoveryOnActive(generateMemorySource); }); it('Does not discover same Play after becoming active again (Positional Source)', async function () { await noScrobbleRediscoveryOnActive(generateMemoryPositionalSource); }); const noScrobbleStale = async (generateSource: (config: MarkOptional) => Promise) => { await using source = await generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}}); const initialDate = dayjs(); // if player incorrectly counted stale time then 30s of actual play + 20s of stale time > scrobble threshold of 50% of 90s const initialState = generatePlayerStateData({position: 0, playData: {duration: 90}, stateUpdatedAt: initialDate, status: REPORTED_PLAYER_STATUSES.playing}); expect((await source.processRecentPlays([initialState])).length).to.be.eq(0); let position = 0; let timeSince = 0; // simulate polling playing source for 30 seconds, 10 second interval for(let i = 0; i < 3; i++) { position += 10; timeSince += 10; MockDate.set(initialDate.add(position, 'seconds').toDate()); await sleep(1); const advancedState = generatePlayerStateData({play: initialState.play, stateUpdatedAt: initialDate, position, status: REPORTED_PLAYER_STATUSES.playing}); expect((await source.processRecentPlays([advancedState])).length).to.be.eq(0); } // simulate polling another 20 seconds without any updates from the Source for(let i = 0; i < 2; i++) { timeSince += 10; MockDate.set(initialDate.add(timeSince, 'seconds').toDate()); await sleep(1); expect((await source.processRecentPlays([])).length).to.be.eq(0); } MockDate.set(initialDate.add(timeSince + 2, 'seconds').toDate()); const discoveredPlays = await source.processRecentPlays([]); // cleanup should not discover stale play expect(discoveredPlays.length).to.be.eq(0); } it('Does not discover cleaned up Play that did not meet threshold (Non Positional Source)', async function () { await noScrobbleStale(generateMemorySource); }); it('Does not discover cleaned up Play that did not meet threshold (Positional Source)', async function () { await noScrobbleStale(generateMemoryPositionalSource); }); const scrobbleRediscoveryOnActive = async (generateSource: (config: MarkOptional) => Promise) => { await using source = await generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}}); const initialDate = dayjs(); // if player incorrectly counted stale time then 30s of actual play + 20s of stale time > scrobble threshold of 50% of 90s const initialState = generatePlayerStateData({position: 0, playData: {duration: 90}, stateUpdatedAt: initialDate, status: REPORTED_PLAYER_STATUSES.playing}); expect((await source.processRecentPlays([initialState])).length).to.be.eq(0); let position = 0; let timeSince = 0; // simulate polling playing source for 30 seconds, 10 second interval for(let i = 0; i < 3; i++) { position += 10; timeSince += 10; MockDate.set(initialDate.add(position, 'seconds').toDate()); await sleep(1); const advancedState = generatePlayerStateData({play: initialState.play, stateUpdatedAt: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing}); expect((await source.processRecentPlays([advancedState])).length).to.be.eq(0); } // simulate polling another 20 seconds without any updates from the Source for(let i = 0; i < 2; i++) { timeSince += 10; MockDate.set(initialDate.add(timeSince, 'seconds').toDate()); await sleep(1); expect((await source.processRecentPlays([])).length).to.be.eq(0); } timeSince += 2; MockDate.set(initialDate.add(timeSince, 'seconds').toDate()); await sleep(1); const discoveredPlays = await source.processRecentPlays([]); // cleanup should not discover stale play expect(discoveredPlays.length).to.be.eq(0); // so that loop starts 1 second after "paused" position position -= 9; // simulate ~50 seconds of listening (enough for scrobble) MockDate.set(initialDate.add(timeSince, 'seconds').toDate()); for(let i = 0; i < 5; i++) { position += 10; timeSince += 10; MockDate.set(initialDate.add(timeSince, 'seconds').toDate()); await sleep(1); const advancedState = generatePlayerStateData({play: initialState.play, stateUpdatedAt: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing}); expect((await source.processRecentPlays([advancedState])).length).to.be.eq(0); } timeSince += 10; MockDate.set(initialDate.add(timeSince, 'seconds').toDate()); await sleep(1); // new Play const advancedState = generatePlayerStateData({stateUpdatedAt: dayjs(), position: 0, status: REPORTED_PLAYER_STATUSES.playing}); // should return discovered play with ~90 seconds of duration const plays = await source.processRecentPlays([advancedState]) expect(plays.length).to.be.eq(1); expect(plays[0].data.duration).to.be.closeTo(90, 2); } it('Does discover Play after becoming active again (Non Positional Source)', async function () { await scrobbleRediscoveryOnActive(generateMemorySource); }); it('Does discover Play after becoming active again (Positional Source)', async function () { await scrobbleRediscoveryOnActive(generateMemoryPositionalSource); }); }); class DeezerTestSource extends DeezerInternalSource { protected async doCheckConnection(): Promise { return; } doAuthentication = async () => { return true; } } const generateDeezerSource = async (options: DeezerInternalSourceOptions = {}) => { const source = new DeezerTestSource('test', {id: `test-${Date.now()}`,data: {arl: 'test'}, options}, {localUrl: new URL('https://example.com'), configDir: 'fake', logger: loggerTest, version: 'test'}, emitter); source.queueIdleMs = 2; source.queueConcurrency = 1; source.stopPollingWaitInterval = 10; await source.initialize(); await source.startDiscoveryQueue(); return source; } const firstPlayDate = dayjs().subtract(1, 'hour'); const normalizedPlays = normalizePlays(generatePlays(6), {initialDate: firstPlayDate}); const lastPlay = normalizedPlays[normalizePlays.length - 1]; describe('Deezer Internal Source', function() { describe('When fuzzyDiscoveryIgnore is not defined or false', function () { it('discovers fuzzy play', async function() { const interimPlay = generatePlay({playDate: lastPlay.data.playDate.add(15, 's'), duration: 80}); const targetPlay = normalizedPlays[normalizedPlays.length - 2] const fuzzyPlay = clone(targetPlay); fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's'); await using source = await generateDeezerSource(); const queued = await source.queuePlay([...normalizedPlays, interimPlay]); expect(queued).length(normalizedPlays.length + 1); await Promise.race([pEvent(source.emitter, 'queueEmptied'), pEvent(source.emitter, 'discoveryQueueError')]); expect(await source.getRecentlyDiscoveredPlays()).length(normalizedPlays.length + 1); await source.queuePlay([fuzzyPlay]); await Promise.race([pEvent(source.emitter, 'queueEmptied'), pEvent(source.emitter, 'discoveryQueueError')]); expect(await source.getRecentlyDiscoveredPlays()).length(normalizedPlays.length + 2); }); }); describe('When fuzzyDiscoveryIgnore is true', function () { it('does not discover fuzzy play with interim plays', async function() { const interimPlay = generatePlay({playDate: lastPlay.data.playDate.add(15, 's'), duration: 80}); const targetPlay = normalizedPlays[normalizedPlays.length - 2] const fuzzyPlay = clone(targetPlay); fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's'); await using source = await generateDeezerSource({fuzzyDiscoveryIgnore: true}); const queued = await source.queuePlay([...normalizedPlays, interimPlay]); expect(queued).length(normalizedPlays.length + 1); await Promise.race([pEvent(source.emitter, 'queueEmptied'), pEvent(source.emitter, 'discoveryQueueError')]); await source.queuePlay([fuzzyPlay]); await Promise.race([pEvent(source.emitter, 'queueEmptied'), pEvent(source.emitter, 'discoveryQueueError')]); expect(await source.getRecentlyDiscoveredPlays()).length(normalizedPlays.length + 1); }); it('discovers fuzzy play when it is the last play ', async function() { const targetPlay = normalizedPlays[normalizedPlays.length - 1] const fuzzyPlay = clone(targetPlay); fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's'); await using source = await generateDeezerSource({fuzzyDiscoveryIgnore: true}); const queued = await source.queuePlay(normalizedPlays); expect(queued).length(normalizedPlays.length); await source.queuePlay([fuzzyPlay]); await Promise.race([pEvent(source.emitter, 'queueEmptied'), pEvent(source.emitter, 'discoveryQueueError')]); expect(await source.getRecentlyDiscoveredPlays()).length(normalizedPlays.length + 1); }); it('discovers fuzzy play when it is played consecutively', async function() { const targetPlay = normalizedPlays[normalizedPlays.length - 1] const fuzzyPlay = clone(targetPlay); fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's'); const morePlays = normalizePlays([...normalizedPlays, fuzzyPlay, ...generatePlays(2)], {initialDate: firstPlayDate}); await using source = await generateDeezerSource({fuzzyDiscoveryIgnore: false}); const queued = await source.queuePlay(morePlays); expect(queued).length(morePlays.length); await Promise.race([pEvent(source.emitter, 'queueEmptied'), pEvent(source.emitter, 'discoveryQueueError')]); expect(await source.getRecentlyDiscoveredPlays()).length(morePlays.length); }); }); describe('When fuzzyDiscoveryIgnore is aggressive', function () { it('does not discover fuzzy play with interim plays', async function() { const interimPlay = generatePlay({playDate: lastPlay.data.playDate.add(15, 's'), duration: 80}); const targetPlay = normalizedPlays[normalizedPlays.length - 2] const fuzzyPlay = clone(targetPlay); fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's'); await using source = await generateDeezerSource({fuzzyDiscoveryIgnore: 'aggressive'}); const queued = await source.queuePlay([...normalizedPlays, interimPlay]); expect(queued).length(normalizedPlays.length + 1); await Promise.race([pEvent(source.emitter, 'queueEmptied'), pEvent(source.emitter, 'discoveryQueueError')]); await source.queuePlay([fuzzyPlay]); await Promise.race([pEvent(source.emitter, 'queueEmptied'), pEvent(source.emitter, 'discoveryQueueError')]); expect(await source.getRecentlyDiscoveredPlays()).length(normalizedPlays.length + 1); }); it('does not discover play found during duration of previous', async function() { const interimPlay = generatePlay({playDate: lastPlay.data.playDate.add(15, 's'), duration: 80}); const targetPlay = normalizedPlays[normalizedPlays.length - 2] const duringPlay = clone(targetPlay); duringPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration * 0.5, 's'); await using source = await generateDeezerSource({fuzzyDiscoveryIgnore: 'aggressive'}); //await source.discover([...normalizedPlays, interimPlay]); await source.queuePlay([...normalizedPlays, interimPlay]); await Promise.race([pEvent(source.emitter, 'queueEmptied'), pEvent(source.emitter, 'discoveryQueueError')]); await source.queuePlay([duringPlay]); await Promise.race([pEvent(source.emitter, 'queueEmptied'), pEvent(source.emitter, 'discoveryQueueError')]); expect(await source.getRecentlyDiscoveredPlays()).length(normalizedPlays.length + 1) }); it('does not discover fuzzy play with delay of up to 40 seconds', async function() { const interimPlay = generatePlay({playDate: lastPlay.data.playDate.add(15, 's'), duration: 80}); const targetPlay = normalizedPlays[normalizedPlays.length - 2] const fuzzyPlay = clone(targetPlay); fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration + 39, 's'); await using source = await generateDeezerSource({fuzzyDiscoveryIgnore: 'aggressive'}); await source.queuePlay([...normalizedPlays, interimPlay]); await Promise.race([pEvent(source.emitter, 'queueEmptied'), pEvent(source.emitter, 'discoveryQueueError')]); await source.queuePlay([fuzzyPlay]); await Promise.race([pEvent(source.emitter, 'queueEmptied'), pEvent(source.emitter, 'discoveryQueueError')]); expect(await source.getRecentlyDiscoveredPlays()).length(normalizedPlays.length + 1) }); it('it does not discover fuzzy play when it is the last play ', async function() { const targetPlay = normalizedPlays[normalizedPlays.length - 1] const fuzzyPlay = clone(targetPlay); fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's'); await using source = await generateDeezerSource({fuzzyDiscoveryIgnore: 'aggressive'}); await source.queuePlay(normalizedPlays); await Promise.race([pEvent(source.emitter, 'queueEmptied'), pEvent(source.emitter, 'discoveryQueueError')]); await source.queuePlay([fuzzyPlay]); await Promise.race([pEvent(source.emitter, 'queueEmptied'), pEvent(source.emitter, 'discoveryQueueError')]); expect(await source.getRecentlyDiscoveredPlays()).length(normalizedPlays.length); }); it('does not discover fuzzy play when it is played consecutively', async function() { const targetPlay = normalizedPlays[normalizedPlays.length - 1] const fuzzyPlay = clone(targetPlay); fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's'); const morePlays = normalizePlays([...normalizedPlays, fuzzyPlay, ...generatePlays(2)], {initialDate: firstPlayDate}); await using source = await generateDeezerSource({fuzzyDiscoveryIgnore: 'aggressive'}); await source.queuePlay(morePlays); await Promise.race([pEvent(source.emitter, 'queueEmptied'), pEvent(source.emitter, 'discoveryQueueError')]); expect(await source.getRecentlyDiscoveredPlays()).length(morePlays.length - 1); }); }); });