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.
59 kB · 1332 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332import chai, { assert, expect } from 'chai';import {spy} from 'sinon';import asPromised from 'chai-as-promised';import clone from 'clone';import dayjs from "dayjs";import { after, describe, it } from 'mocha';import { http, HttpResponse } from 'msw';import pEvent from 'p-event';import { INGRESS_QUEUE, type PlayObject, QUEUE_STATUS_FAILED, SOURCE_SOT } from "../../../core/Atomic.ts";import { sleep, sortByOldestPlayDate } from "../../utils.ts";import { genGroupIdStr } from '../../../core/PlayUtils.ts';import mixedDuration from '../plays/mixedDuration.json' with { type: 'json' };import withDuration from '../plays/withDuration.json' with { type: 'json' };import { MockNetworkError, withRequestInterception } from "../utils/networking.ts";import { generatePlay, generatePlayPlatformId, generatePlays, generateSourcePlayerObj, normalizePlays } from "../../../core/tests/utils/PlayTestUtils.ts";import MockDate from 'mockdate';
import { NowPlayingScrobbler, TestAuthScrobbler, TestScrobbler } from "./TestScrobbler.ts";import { type PaginatedTimeRangeOptions, REFRESH_STALE_DEFAULT } from '../../common/infrastructure/Atomic.ts';import type {PlayPlatformId} from '../../../core/Atomic.ts';import { shuffleArray } from '../../utils/DataUtils.ts';import { DEFAULT_CONSOLIDATE_DURATION, groupPlaysToTimeRanges } from '../../utils/ListenFetchUtils.ts';import { asPlay } from '../../../core/PlayMarshalUtils.ts';import { getRoot } from '../../ioc.ts';import { transientCache } from '../utils/TransientTestUtils.ts';import { generateArray } from '../../../core/DataUtils.ts';import type {RepositoryCreatePlayOpts} from '../../common/database/drizzle/repositories/PlayRepository.ts';import { fixtureCreatePlay } from '../utils/databaseFixtures.ts';import { isAbortError } from 'abort-controller-x';import { nameToCredit } from "../../../core/MusicMetadata.ts";import { namesToCredits } from "../../../core/MusicMetadata.ts";import ScrobbleClients from '../../scrobblers/ScrobbleClients.ts';import { WildcardEmitter } from '../../common/WildcardEmitter.ts';import type { CommonClientConfig } from '../../common/infrastructure/config/client/index.ts';import { loggerNoop } from '../../common/MaybeLogger.ts';import type { MSBackendEventMap } from '../../common/infrastructure/MSBackendEventMap.ts';
chai.use(asPromised);
const firstPlayDate = dayjs().subtract(1, 'hour');const olderFirstPlayDate = dayjs().subtract(4, 'hour');
const withDurPlays = withDuration.map(asPlay);// @ts-expect-error mixed duration is missing meta but not used for testsconst mixedDurPlays = mixedDuration.map(asPlay);const normalizedWithDur = normalizePlays(withDurPlays, {initialDate: firstPlayDate});const normalizedWithMixedDur = normalizePlays(mixedDurPlays, {initialDate: firstPlayDate});
const normalizedWithMixedDurOlder = normalizePlays(mixedDurPlays, {initialDate: olderFirstPlayDate});
const generateTestScrobbler = async (config?: CommonClientConfig) => { const testScrobbler = new TestScrobbler(config); testScrobbler.verboseOptions = { match: { onMatch: true, onNoMatch: true, confidenceBreakdown: true } }; await testScrobbler.initialize(); return testScrobbler;}
describe('Networking', function () {
describe('Authentication', function () { it('Should set as authenticated if doAuthentication does not throw and returns true', withRequestInterception( [ http.get('http://example.com', () => { // https://github.com/mswjs/msw/issues/1819#issuecomment-1789364174 // already using DOM though, not sure why it doesn't fix itself return new HttpResponse(null, {status: 200}); } ) ], async function() { const authScrobbler = new TestAuthScrobbler(); await authScrobbler.testAuth(); assert.isFalse(authScrobbler.authGated()); } ));
it('Should set as unauthenticated with possibility to retry if error is network related', withRequestInterception( [ http.get('http://example.com', () => { throw new MockNetworkError('EAI_AGAIN'); } ) ], async function() { const authScrobbler = new TestAuthScrobbler(); try { await authScrobbler.initialize(); } catch (e) { /* empty */ } assert.isTrue(authScrobbler.authGated()); assert.isFalse(authScrobbler.hasUnrecoverableAuthFailure()); } ));
it('Should set as unauthenticated with no possibility to retry if error is not network related', withRequestInterception( [ http.get('http://example.com', () => { return HttpResponse.json({error: 'Invalid API Key'}, {status: 401}); } ) ], async function() { const authScrobbler = new TestAuthScrobbler(); try { await authScrobbler.initialize(); } catch (e) { /* empty */ } assert.isTrue(authScrobbler.authGated()); assert.isTrue(authScrobbler.hasUnrecoverableAuthFailure()); } )); });});
describe('Detects duplicate and unique scrobbles from client recent history', function () {
describe('When scrobble is unique', function () {
it('It is not detected as duplicate when play date is newer than most recent', async function () {
await using testScrobbler = await generateTestScrobbler(); testScrobbler.testRecentScrobbles = normalizedWithMixedDur;
const newScrobble = generatePlay({ playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 1].data.playDate!.add(70, 'seconds') });
assert.isFalse((await testScrobbler.alreadyScrobbled(newScrobble))[0]); return; });
it('It is not detected as duplicate when play date is close to an existing scrobble', async function () {
await using testScrobbler = await generateTestScrobbler(); testScrobbler.testRecentScrobbles = normalizedWithMixedDur;
const newScrobble = generatePlay({ playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate!.add(3, 'seconds') });
assert.isFalse((await testScrobbler.alreadyScrobbled(newScrobble))[0]); });
it('It handles unique detection when no existing scrobble matches above a score of 0', async function () {
await using testScrobbler = await generateTestScrobbler(); testScrobbler.testRecentScrobbles = normalizedWithMixedDur;
const uniquePlay = generatePlay({ artists: namesToCredits(["2814"]), track: nameToCredit("新宿ゴールデン街"), duration: 130, playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate!.add(6, 'minutes') });
await assert.isFulfilled( testScrobbler.alreadyScrobbled(uniquePlay)) assert.isFalse((await testScrobbler.alreadyScrobbled(uniquePlay))[0]) }); });
describe('When scrobble track/artist/album matches existing but is a new scrobble', function () {
it('Is not detected as duplicate when artist is same, time is similar, but track is different', async function () {
await using testScrobbler = await generateTestScrobbler(); testScrobbler.testRecentScrobbles = normalizedWithMixedDur;
const diffPlay = clone(normalizedWithMixedDur[1]); diffPlay.data.playDate = diffPlay.data.playDate!.add(9, 's'); diffPlay.data.track = nameToCredit('A Totally Different Track')
assert.isFalse((await testScrobbler.alreadyScrobbled(diffPlay))[0]); });
it('Is not detected as duplicate when track is same, time is similar, but artist is different', async function () {
await using testScrobbler = await generateTestScrobbler(); testScrobbler.testRecentScrobbles = normalizedWithMixedDur;
const diffPlay = clone(normalizedWithMixedDur[1]); diffPlay.data.playDate = diffPlay.data.playDate!.add(9, 's'); diffPlay.data.artists = namesToCredits(['A Different Artist']);
assert.isFalse((await testScrobbler.alreadyScrobbled(diffPlay))[0]); });
it('Is not detected as duplicate when play date is different by more than 10 seconds (high granularity source)', async function () {
await using testScrobbler = await generateTestScrobbler(); testScrobbler.testRecentScrobbles = normalizedWithMixedDur;
const timeOffPos = clone(normalizedWithMixedDur[normalizedWithMixedDur.length - 1]); timeOffPos.data.playDate = timeOffPos.data.playDate!.add(11, 's');
const timeOffNeg = clone(normalizedWithMixedDur[normalizedWithMixedDur.length - 1]); timeOffNeg.data.playDate = timeOffNeg.data.playDate!.subtract(11, 's');
assert.isFalse((await testScrobbler.alreadyScrobbled(timeOffPos))[0]); assert.isFalse((await testScrobbler.alreadyScrobbled(timeOffNeg))[0]); });
it('Is not detected as duplicate when play date is different by more than 60 seconds (low granularity source)', async function () {
const recent = normalizePlays(mixedDurPlays, { initialDate: firstPlayDate, defaultMeta: {source: 'subsonic'} }); await using testScrobbler = await generateTestScrobbler(); testScrobbler.testRecentScrobbles = recent;
const timeOffPos = clone(recent[recent.length - 1]); timeOffPos.data.playDate = timeOffPos.data.playDate!.add(61, 's');
const timeOffNeg = clone(recent[recent.length - 1]); timeOffNeg.data.playDate = timeOffNeg.data.playDate!.subtract(61, 's');
assert.isFalse((await testScrobbler.alreadyScrobbled(timeOffPos))[0]); assert.isFalse((await testScrobbler.alreadyScrobbled(timeOffNeg))[0]); });
describe('When existing has duration', function () {
it('A track with continuity to the previous track is not detected as a duplicate', async function () {
await using testScrobbler = await generateTestScrobbler(); testScrobbler.testRecentScrobbles = normalizedWithDur;
const brickPt1 = normalizedWithDur.find(x => x.data.track!.name.includes('Another Brick')); const brickPt2 = clone(brickPt1); brickPt2!.data.track = nameToCredit('Another Brick in the Wall, Pt. 2'); brickPt2!.data.playDate = brickPt1!.data.playDate!.add(brickPt1!.data.duration! + 1, 'seconds'); assert.isFalse((await testScrobbler.alreadyScrobbled(brickPt2!))[0]);
const story1 = normalizedWithDur.find(x => x.data.track!.name.includes('Da Art of')); const story2 = clone(story1); story2!.data.track = nameToCredit(`Da Art of Storytellin' (Pt. 2)`); story2!.data.playDate = story2!.data.playDate!.add(story1!.data.duration! + 1, 'seconds');
assert.isFalse((await testScrobbler.alreadyScrobbled(story2!))[0]);
const ballad1 = normalizedWithDur.find(x => x.data.track!.name.includes('Ballade No. 1')); const ballad2 = clone(ballad1); ballad2!.data.track = nameToCredit(`Ballade No. 2 in G Minor, Op. 27`); ballad2!.data.playDate = ballad2!.data.playDate!.add(ballad1!.data.duration! + 1, 'seconds');
assert.isFalse((await testScrobbler.alreadyScrobbled(ballad2!))[0]); });
it('Is not detected as duplicate when play date matches fuzzy but play is marked as repeat', async function () {
const recent = normalizePlays(normalizedWithDur, { initialDate: firstPlayDate, defaultMeta: {source: 'jellyfin'} }); await using testScrobbler = await generateTestScrobbler(); testScrobbler.testRecentScrobbles = recent;
const repeatPlay = clone(recent[recent.length - 1]); repeatPlay.data.playDate = repeatPlay.data.playDate!.add(repeatPlay.data.duration! + 2, 's');
assert.isTrue((await testScrobbler.alreadyScrobbled(repeatPlay))[0]);
repeatPlay.data.repeat = true; assert.isFalse((await testScrobbler.alreadyScrobbled(repeatPlay))[0]); });
it('Is not detected as duplicate when play date matches fuzzy and play source SOT is history', async function () {
const play = generatePlay({ artists: namesToCredits(['Nejad']), track: nameToCredit('CODE'), album: undefined, playDate: dayjs().subtract(179, 's'), duration: 179 }); await using testScrobbler = await generateTestScrobbler(); testScrobbler.testRecentScrobbles = [play];
const newPlay = clone(play); newPlay.data.playDate = dayjs(); newPlay.meta.sourceSOT = SOURCE_SOT.HISTORY;
const res = await testScrobbler.existingPlay(newPlay, [play]); expect(res.match).is.false; }); });
});
describe('When scrobble is a duplicate (title/artists/album)', function () {
it('Is detected as duplicate when an exact match', async function () { await using testScrobbler = await generateTestScrobbler(); testScrobbler.testRecentScrobbles = normalizedWithMixedDur; assert.isTrue((await testScrobbler.alreadyScrobbled(normalizedWithMixedDur[normalizedWithMixedDur.length - 1]))[0]); });
it('Is detected as duplicate when artist/title differences are whitespace or case', async function () { await using testScrobbler = await generateTestScrobbler(); testScrobbler.testRecentScrobbles = normalizedWithMixedDur; const ref = normalizedWithMixedDur[3];
const diffPlay = clone(ref); diffPlay.data.playDate = diffPlay.data.playDate!.add(9, 's');
diffPlay.data.track = nameToCredit(ref.data.track!.name.toUpperCase()); assert.isTrue((await testScrobbler.alreadyScrobbled(diffPlay))[0]);
diffPlay.data.track = nameToCredit(` ${ref.data.track?.name} `); assert.isTrue((await testScrobbler.alreadyScrobbled(diffPlay))[0]);
diffPlay.data.track = nameToCredit(ref.data.track!.name.replaceAll(' ', ' ')); assert.isTrue((await testScrobbler.alreadyScrobbled(diffPlay))[0]);
diffPlay.data.artists = ref.data.artists!.map(x => ({...x, name: x.name.toUpperCase()})); assert.isTrue((await testScrobbler.alreadyScrobbled(diffPlay))[0]);
diffPlay.data.artists = ref.data.artists!.map(x => ({...x, name: x.name.replaceAll(' ', ' ')})); assert.isTrue((await testScrobbler.alreadyScrobbled(diffPlay))[0]); });
it('Is detected as duplicate when artist/title differences are from unicode normalization', async function () { await using testScrobbler = await generateTestScrobbler(); testScrobbler.testRecentScrobbles = normalizedWithMixedDur; const ref = normalizedWithMixedDur.find(x => x.data.track?.name === 'Jimbó');
const diffPlay = clone(ref); diffPlay!.data.playDate = diffPlay!.data.playDate!.add(9, 's'); diffPlay!.data.track = nameToCredit('Jimbo'); assert.isTrue((await testScrobbler.alreadyScrobbled(diffPlay!))[0]); });
it('Is detected as duplicate when play date is off by 10 seconds or less (high granularity source)', async function () {
await using testScrobbler = await generateTestScrobbler(); testScrobbler.testRecentScrobbles = normalizedWithMixedDur;
const timeOffPos = clone(normalizedWithMixedDur[normalizedWithMixedDur.length - 1]); timeOffPos.data.playDate = timeOffPos.data.playDate!.add(10, 's');
const timeOffNeg = clone(normalizedWithMixedDur[normalizedWithMixedDur.length - 1]); timeOffNeg.data.playDate = timeOffNeg.data.playDate!.subtract(10, 's');
assert.isTrue((await testScrobbler.alreadyScrobbled(timeOffPos))[0]); assert.isTrue((await testScrobbler.alreadyScrobbled(timeOffNeg))[0]);
// 10 seconds fuzzy diff inclusive const son = normalizedWithMixedDurOlder.find(x => x.data.track?.name === 'Sonora') son!.data.playDate = dayjs().subtract(1, 'hour').set('minute', 26).set('second', 20); son!.data.duration = 267; son!.data.listenedFor = undefined; testScrobbler.testRecentScrobbles = normalizedWithMixedDurOlder.concat(son!);
const offSon = clone(son); offSon!.data.playDate = dayjs().subtract(1, 'hour').set('minute', 30).set('second', 37); assert.isTrue((await testScrobbler.alreadyScrobbled(offSon!))[0]); });
it('Is detected as duplicate when play date is off by less than 60 seconds (low granularity source)', async function () {
const recent = normalizePlays(mixedDurPlays, { initialDate: firstPlayDate, defaultMeta: {source: 'subsonic'} }); await using testScrobbler = await generateTestScrobbler(); testScrobbler.testRecentScrobbles = recent;
const timeOffPos = clone(recent[recent.length - 1]); timeOffPos.data.playDate = timeOffPos.data.playDate!.add(59, 's');
const timeOffNeg = clone(recent[recent.length - 1]); timeOffNeg.data.playDate = timeOffNeg.data.playDate!.subtract(59, 's');
assert.isTrue((await testScrobbler.alreadyScrobbled(timeOffPos))[0]); assert.isTrue((await testScrobbler.alreadyScrobbled(timeOffNeg))[0]); });
it('Is detected as duplicate when title is exact, artist is similar, and time is similar', async function () { await using testScrobbler = await generateTestScrobbler(); testScrobbler.testRecentScrobbles = normalizedWithMixedDur; const ref = normalizedWithMixedDur[3];
const diffPlay = clone(ref); diffPlay.data.playDate = diffPlay.data.playDate!.add(3, 's'); diffPlay.data.artists = [diffPlay.data.artists![0]] assert.isTrue((await testScrobbler.alreadyScrobbled(diffPlay))[0]);
diffPlay.data.artists = [ref.data.artists![1]] assert.isTrue((await testScrobbler.alreadyScrobbled(diffPlay))[0]);
const son = normalizedWithMixedDur.find(x => x.data.track?.name === 'Sonora')
const sonDiffPlay = clone(son); sonDiffPlay!.data.playDate = sonDiffPlay!.data.playDate!.subtract(son!.data.duration! + 1, 's'); assert.isTrue((await testScrobbler.alreadyScrobbled(sonDiffPlay!))[0]); });
it('Is detected as duplicate when artists are included in joiner', async function () { const ref = normalizedWithMixedDurOlder.find(x => x.data.track?.name === 'Freeze Tag'); ref!.data.playDate = dayjs().subtract(1, 'hour').set('minute', 29).set('second', 26)
const spotifyPlay: PlayObject = { data: { artists: namesToCredits([ "Terrace Martin", "Robert Glasper", "9th Wonder", "Kamasi Washington", "Dinner Party", "Cordae", "Phoelix" ]), album: nameToCredit("Dinner Party: Dessert"), track: nameToCredit("Freeze Tag (feat. Cordae & Phoelix)"), "duration": 191.375, "playDate": dayjs().subtract(1, 'hour').set('minute', 29).set('second', 27) }, meta: { source: 'Spotify' } } await using testScrobbler = await generateTestScrobbler(); testScrobbler.testRecentScrobbles = normalizedWithMixedDurOlder.concat(ref!);
assert.isTrue((await testScrobbler.alreadyScrobbled(spotifyPlay))[0]); });
describe('When at least one play has duration', function () {
it('Is detected as duplicate when play date is close to the end of an existing scrobble', async function () {
await using testScrobbler = await generateTestScrobbler(); testScrobbler.testRecentScrobbles = normalizedWithDur;
const timeEnd = clone(normalizedWithDur[normalizedWithMixedDur.length - 2]); timeEnd.data.playDate = timeEnd.data.playDate!.add(timeEnd.data.duration!, 's');
assert.isTrue((await testScrobbler.alreadyScrobbled(timeEnd))[0]);
// only one has duration timeEnd.data.duration = undefined;
assert.isTrue((await testScrobbler.alreadyScrobbled(timeEnd))[0]);
// only one has duration timeEnd.data.duration = undefined;
assert.isTrue((await testScrobbler.alreadyScrobbled(timeEnd))[0]); });
}); });});
describe('Detects duplicate and unique scrobbles using actively tracked scrobbles', function() {
it('Detects a unique play', async function() { const newScrobble = generatePlay({ playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate!.add(3, 'seconds') });
await using testScrobbler = await generateTestScrobbler(); testScrobbler.testRecentScrobbles = normalizedWithMixedDur; const [matchedPlay, matchedData] = await testScrobbler.findExistingSubmittedPlayObj(newScrobble);
assert.isUndefined(matchedPlay); assert.isEmpty(matchedData); });
it('Detects an exact duplicate', async function() { const newScrobble = generatePlay({ playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate!.add(3, 'seconds') }); await using testScrobbler = await generateTestScrobbler(); await testScrobbler.playRepoTest.createPlays([{play: newScrobble, state: 'scrobbled', input: {}}]) testScrobbler.testRecentScrobbles = normalizedWithMixedDur; //testScrobbler.addScrobbledTrack(newScrobble, newScrobble);
const [matchedPlay, matchedData] = await testScrobbler.findExistingSubmittedPlayObj(newScrobble);
assert.isDefined(matchedPlay); assert.isNotEmpty(matchedData); });
it('Detects a duplicate with close time', async function() { const newScrobble = generatePlay({ playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate!.add(3, 'seconds') }); await using testScrobbler = await generateTestScrobbler(); //testScrobbler.testRecentScrobbles = normalizedWithMixedDur; await testScrobbler.playRepoTest.createPlays([{play: newScrobble, state: 'scrobbled', input: {}}]) //testScrobbler.addScrobbledTrack(newScrobble, newScrobble);
const dupScrobble = clone(newScrobble); dupScrobble.data.playDate = newScrobble.data.playDate!.add(2, 'seconds');
const [matchedPlay, matchedData] = await testScrobbler.findExistingSubmittedPlayObj(dupScrobble);
assert.isDefined(matchedPlay); assert.isNotEmpty(matchedData); });});
describe('Upstream Scrobbles', function() {
afterEach(function () { MockDate.reset(); const root = getRoot(); root.upsert({ cache: () => transientCache }); });
it('Calls timerange func to get SOT scrobbles when none exists', async function() { const existingPlays = normalizePlays(generatePlays(3), {initialDate: dayjs().subtract(1, 'hour')}); await using scrobbler = await generateTestScrobbler(); scrobbler.testRecentScrobbles = []; await scrobbler.initialize(); scrobbler.testRecentScrobbles = existingPlays;
const sp = spy(scrobbler, 'getScrobblesForTimeRange');
const play = generatePlay({playDate: dayjs().subtract(60, 's')}); await scrobbler.queuePlay(play); const emptied = pEvent(scrobbler.emitter, 'queueEmptied'); scrobbler.startScrobbling(new AbortController().signal).then(() => null); await emptied; scrobbler.tryStopScrobbling().then(() => null); expect(sp.called).is.true; return; });
it('Uses cached timerange for closely grouped scrobbles', async function() { const existingPlays = normalizePlays(generatePlays(3), {initialDate: dayjs().subtract(1, 'hour')}); await using scrobbler = await generateTestScrobbler(); scrobbler.testRecentScrobbles = []; await scrobbler.initialize(); scrobbler.testRecentScrobbles = existingPlays;
const sp = spy(scrobbler, 'getScrobblesForTimeRange');
const play1 = generatePlay({playDate: dayjs().subtract(3, 'm')}); const play2 = generatePlay({playDate: dayjs().subtract(1, 'm')}); await scrobbler.queuePlay([play1, play2]); const emptied = pEvent(scrobbler.emitter, 'queueEmptied'); scrobbler.startScrobbling(new AbortController().signal).then(() => null); await emptied; scrobbler.tryStopScrobbling().then(() => null); expect(sp.callCount).to.eq(1); return; });
it('Uses separate timerange calls when scrobbles are not closely grouped', async function() { const existingPlays = normalizePlays(generatePlays(3), {initialDate: dayjs().subtract(1, 'hour')}); await using scrobbler = await generateTestScrobbler(); scrobbler.testRecentScrobbles = []; await scrobbler.initialize(); scrobbler.testRecentScrobbles = existingPlays;
const sp = spy(scrobbler, 'getScrobblesForTimeRange');
const play1 = generatePlay({playDate: dayjs().subtract(3, 'm')}); const play2 = generatePlay({playDate: dayjs().subtract(1, 'm')}); const play3 = generatePlay({playDate: dayjs().subtract(DEFAULT_CONSOLIDATE_DURATION.add(4, 'm'))}); await scrobbler.queuePlay([play1, play2, play3]); const emptied = pEvent(scrobbler.emitter, 'queueEmptied'); scrobbler.startScrobbling(new AbortController().signal).then(() => null); await emptied; scrobbler.tryStopScrobbling().then(() => null); expect(sp.callCount).to.eq(2); return; });
it('Gets fresh timerange if TTL of staleAfter has passed', async function() { const existingPlays = normalizePlays(generatePlays(3), {initialDate: dayjs().subtract(1, 'hour')}); await using scrobbler = await generateTestScrobbler(); scrobbler.testRecentScrobbles = []; await scrobbler.initialize(); scrobbler.testRecentScrobbles = existingPlays;
const sp = spy(scrobbler, 'getScrobblesForTimeRange');
const play1 = generatePlay({playDate: dayjs().subtract(3, 'm')}); const play2 = generatePlay({playDate: dayjs().subtract(1, 'm')}); await scrobbler.queuePlay([play1]); const emptied = pEvent(scrobbler.emitter, 'queueEmptied'); scrobbler.startScrobbling(new AbortController().signal).then(() => null); await emptied; expect(sp.calledOnce).is.true;
MockDate.set(dayjs().add(REFRESH_STALE_DEFAULT + 1, 's').toDate()); const emptied2 = pEvent(scrobbler.emitter, 'queueEmptied'); await scrobbler.queuePlay([play2]); await emptied2; scrobbler.tryStopScrobbling().then(() => null); expect(sp.calledTwice).is.true; return; });
});
describe('Dead Scrobbles', function() {
it('Processes all dead scrobbles', async function () { await using testScrobbler = await generateTestScrobbler(); await testScrobbler.initialize(); testScrobbler.testRecentScrobbles = [];
const queuedPlays = await testScrobbler.playRepoTest.createPlays(generateArray<RepositoryCreatePlayOpts>(3, () => ({ ...fixtureCreatePlay(), state: 'failed', input: {} }) as RepositoryCreatePlayOpts));
await testScrobbler.queueRepoTest.createMany(queuedPlays.map(x => ({queueName: INGRESS_QUEUE, queueStatus: QUEUE_STATUS_FAILED, retries: 1, playId: x.id, componentId: testScrobbler.componentId})));
await testScrobbler.processDeadLetterQueue(undefined, undefined, true);
const updatedPlays = await testScrobbler.playRepoTest.findPlays({uid: queuedPlays.map(x => x.uid)}); for(const p of updatedPlays) { expect(p.state === 'scrobbled'); } });
});
const normalizedScrobbler = async () => { await using testScrobbler = await generateTestScrobbler(); await testScrobbler.initialize(); testScrobbler.testRecentScrobbles = normalizedWithMixedDur; testScrobbler.scrobbleSleep = 500; testScrobbler.scrobbleDelay = 0; //testScrobbler.lastScrobbleCheck = dayjs().subtract(60, 'seconds'); testScrobbler.config.options = {}; return testScrobbler;}
describe('Scrobble client uses transform plays correctly', function() {
// TODO need to find a better way to detect this // since we are now doing it in the processing loop instead of before queue // it('Transforms play before queue when preCompare is present', async function() { // testScrobbler.config.options = { // playTransform: { // preCompare: { // title: [ // 'cool' // ] // } // } // } // testScrobbler.buildTransformRules(); // const newScrobble = generatePlay({ // track: 'my cool track' // }); // testScrobbler.queuePlay(newScrobble, 'test'); // expect(testScrobbler.queuedScrobbles[0].play.data.track?.name).is.eq('my track'); // });
it('Transforms play on scrobble when postCompare is present', async function() { await using testScrobbler = await normalizedScrobbler(); testScrobbler.config.options = { playTransform: { postCompare: { type: 'user', title: [ 'cool' ] } } } await testScrobbler.buildTransformRules(); const newScrobble = generatePlay({ track: nameToCredit('my cool track') }); await testScrobbler.queuePlay(newScrobble); const queuedPlayedData = await testScrobbler.playRepoTest.getQueued(INGRESS_QUEUE); expect(queuedPlayedData.data[0].play.data.track?.name).is.eq('my cool track'); testScrobbler.scrobbleSleep = 100; testScrobbler.initScrobbleMonitoring().catch(console.error);
const e = (await pEvent(testScrobbler.emitter, 'scrobble')) as {data: {play: PlayObject }}; expect(e.data.play.data.track?.name).is.eq('my track'); });
it('Transforms candidate play on comparison', async function() { await using testScrobbler = await normalizedScrobbler(); testScrobbler.config.options = { playTransform: { compare: { candidate: { type: 'user', title: [ 'hugely cool and very different track' ] } } } } const newScrobble = generatePlay({ track: nameToCredit('my hugely cool and very different track title') });
testScrobbler.testRecentScrobbles = normalizePlays([newScrobble, ...withDurPlays], {initialDate: firstPlayDate}); await testScrobbler.buildTransformRules();
expect((await testScrobbler.alreadyScrobbled(newScrobble))[0]).is.false; });
it('Transforms existing play on comparison', async function() { await using testScrobbler = await normalizedScrobbler(); testScrobbler.config.options = { playTransform: { compare: { existing: { type: 'user', title: [ 'hugely cool and very different track' ] } } } } const newScrobble = generatePlay({ track: nameToCredit('my hugely cool and very different track title') });
testScrobbler.testRecentScrobbles = normalizePlays([newScrobble, ...withDurPlays], {initialDate: firstPlayDate}); await testScrobbler.buildTransformRules();
expect((await testScrobbler.alreadyScrobbled(newScrobble))[0]).is.false; });
afterEach(async function () { this.timeout(3500); //await testScrobbler.tryStopScrobbling() });
});
const normalizedMonitoringScrobbler = async () => { const testScrobbler = await generateTestScrobbler(); await testScrobbler.initialize(); testScrobbler.testRecentScrobbles = normalizedWithMixedDur; testScrobbler.scrobbleSleep = 100; testScrobbler.scrobbleDelay = 0; testScrobbler.initScrobbleMonitoring().catch((e) => { if(!isAbortError(e)) { console.error(e); } }); return testScrobbler;}
describe('Manages scrobble queue', function() {
it('Scrobbles a uniquely queued play', async function() { await using testScrobbler = await normalizedMonitoringScrobbler(); const newScrobble = generatePlay({ playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate!.add(3, 'seconds') }); await testScrobbler.queuePlay(newScrobble); const res = await Promise.race([pEvent(testScrobbler.emitter, 'scrobble'), sleep(3000)]);
assert.isDefined(res); assert.isDefined(res.data); });
it('Does not Scrobble a duplicate play queued after original is scrobbled', async function() { await using testScrobbler = await normalizedMonitoringScrobbler(); this.timeout(20000);
const newScrobble = generatePlay({ playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate!.add(3, 'seconds') }); const dupScrobble = clone(newScrobble); dupScrobble.data.playDate = newScrobble.data.playDate!.add(2, 'seconds');
await testScrobbler.queuePlay(newScrobble); const res = await Promise.race([pEvent(testScrobbler.emitter, 'scrobble'), sleep(1500)]);
assert.isDefined(res); assert.isDefined(res.data);
await testScrobbler.queuePlay(dupScrobble); const resDup = await Promise.race([pEvent(testScrobbler.emitter, 'scrobble'), sleep(1100)]);
assert.isUndefined(resDup); });
it('Does not Scrobble a duplicate play queued before original is scrobbled', async function() { await using testScrobbler = await normalizedMonitoringScrobbler(); this.timeout(3500);
const newScrobble = generatePlay({ playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate!.add(3, 'seconds') }); const dupScrobble = clone(newScrobble); dupScrobble.data.playDate = newScrobble.data.playDate!.add(2, 'seconds');
await testScrobbler.queuePlay(newScrobble); await testScrobbler.queuePlay(dupScrobble); const res = await Promise.race([pEvent(testScrobbler.emitter, 'scrobble'), sleep(1500)]);
assert.isDefined(res); assert.isDefined(res.data);
const resDup = await Promise.race([pEvent(testScrobbler.emitter, 'scrobble'), sleep(1100)]);
assert.isUndefined(resDup); });
it('Delays scrobbles when many are queued', async function () { await using testScrobbler = await normalizedMonitoringScrobbler(); this.timeout(3500);
const newScrobble1 = generatePlay({ playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate!.add(3, 'seconds') }); const newScrobble2 = generatePlay({ playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate!.add(80, 'seconds') }); const newScrobble3 = generatePlay({ playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate!.add(160, 'seconds') });
testScrobbler.scrobbleDelay = 600;
await testScrobbler.queuePlay([newScrobble1, newScrobble2, newScrobble3]); await pEvent(testScrobbler.emitter, 'scrobble'); const initial = dayjs(); await pEvent(testScrobbler.emitter, 'scrobble'); await pEvent(testScrobbler.emitter, 'scrobble');
const end = dayjs();
// roughly... assert.closeTo(end.diff(initial, 'ms'), 1200, 200); });
after(async function () { this.timeout(3500); });});
describe('Now Playing', function() {
describe('Filtering Aggregated Updates', function() {
it('When no Now Playing exists, chooses play based on sorted platform id', async function () {
await using npScrobbler = new NowPlayingScrobbler(); await npScrobbler.initialize();
const firstPlatform: PlayPlatformId = ['aaa', 'NO_USER']; const secondPlatform: PlayPlatformId = ['bbbb', 'NO_USER'];
const pt = dayjs().subtract(15, 's');
await npScrobbler.queuePlayingNow(generateSourcePlayerObj({play: generatePlay({playDate: pt}, {deviceId: genGroupIdStr(secondPlatform)})}) , {type: 'spotify', name: 'test'}); await npScrobbler.queuePlayingNow(generateSourcePlayerObj({play: generatePlay({playDate: pt}, {deviceId: genGroupIdStr(firstPlatform)})}), {type: 'spotify', name: 'test'});
const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);
expect(toReport!.player.play!.meta.deviceId).eq(genGroupIdStr(firstPlatform));
});
it('When Now Playing platform does not exist in queued plays, chooses play based on sorted platform id', async function () {
await using npScrobbler = new NowPlayingScrobbler(); await npScrobbler.initialize();
const firstPlatform: PlayPlatformId = ['aaa', 'NO_USER']; const secondPlatform: PlayPlatformId = ['bbbb', 'NO_USER'];
npScrobbler.nowPlayingLastPlay = generateSourcePlayerObj({play: generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})});
const pt = dayjs().subtract(15, 's');
await npScrobbler.queuePlayingNow(generateSourcePlayerObj({play:generatePlay({playDate: pt}, {deviceId: genGroupIdStr(secondPlatform)})}), {type: 'spotify', name: 'test'}); await npScrobbler.queuePlayingNow(generateSourcePlayerObj({play:generatePlay({playDate: pt}, {deviceId: genGroupIdStr(firstPlatform)})}), {type: 'spotify', name: 'test'});
const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);
expect(toReport!.player.play!.meta.deviceId).eq(genGroupIdStr(firstPlatform));
});
it('Chooses play based on existing Now Playing', async function () {
await using npScrobbler = new NowPlayingScrobbler(); await npScrobbler.initialize();
const firstPlatform: PlayPlatformId = ['aaa', 'NO_USER']; const secondPlatform: PlayPlatformId = ['bbbb', 'NO_USER'];
const pt = dayjs().subtract(15, 's');
const stickyNp = generateSourcePlayerObj({play: generatePlay({playDate: pt}, {deviceId: genGroupIdStr(secondPlatform)})});
npScrobbler.nowPlayingLastPlay = stickyNp
await npScrobbler.queuePlayingNow(generateSourcePlayerObj({play:generatePlay({playDate: pt}, {deviceId: genGroupIdStr(firstPlatform)})}), {type: 'spotify', name: 'test'}); await npScrobbler.queuePlayingNow(stickyNp, {type: 'spotify', name: 'test'});
const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);
expect(toReport!.player.play!.meta.deviceId).eq(genGroupIdStr(secondPlatform));
});
it('Sorts Sources alphabetically when using default Source sorting', async function () {
await using npScrobbler = new NowPlayingScrobbler(); await npScrobbler.initialize();
const a = generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}); const b = generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})});
await npScrobbler.queuePlayingNow(b, {type: 'jellyfin', name: 'btest'}) await npScrobbler.queuePlayingNow(a, {type: 'subsonic', name: 'atest'})
const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);
expect(toReport!.player.play!.meta.deviceId).eq(a.play!.meta.deviceId);
});
it('Sorts Sources based on user config', async function () {
await using npScrobbler = new NowPlayingScrobbler({name: 'test', options: {nowPlaying: ['btest', 'atest']}}); await npScrobbler.initialize();
const a = generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}); const b = generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})});
await npScrobbler.queuePlayingNow(a, {type: 'subsonic', name: 'atest'}) await npScrobbler.queuePlayingNow(b, {type: 'jellyfin', name: 'btest'})
const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);
expect(toReport!.player.play!.meta.deviceId).eq(b.play!.meta.deviceId);
});
it('Does not report if source is not in user config', async function () {
await using npScrobbler = new NowPlayingScrobbler({name: 'test', options: {nowPlaying: ['btest', 'atest']}}); await npScrobbler.initialize();
const c = generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})});
await npScrobbler.queuePlayingNow(c, {type: 'jellyfin', name: 'ctest'})
const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);
expect(toReport).to.be.undefined;
}); });
describe('Updating', function () {
it('Should update if no existing Now Playing', async function () {
await using npScrobbler = new NowPlayingScrobbler(); await npScrobbler.initialize();
const res = npScrobbler.shouldUpdatePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}))[0]; expect(res).to.be.true; });
it('Should update if previous Now Playing matches updated and last updated diff is greater than upper limit', async function () {
await using npScrobbler = new NowPlayingScrobbler(); await npScrobbler.initialize();
const lastUpdate = generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}); npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingMaxThreshold(lastUpdate.play) + 1, 's'); npScrobbler.nowPlayingLastPlay = lastUpdate;
const res = npScrobbler.shouldUpdatePlayingNow(lastUpdate)[0]; expect(res).to.be.true; });
it('Should NOT update if previous Now Playing matches updated and last updated diff is less than upper limit', async function () {
await using npScrobbler = new NowPlayingScrobbler(); await npScrobbler.initialize();
const lastUpdate = generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}); npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingMaxThreshold(lastUpdate.play) - 1, 's'); npScrobbler.nowPlayingLastPlay = lastUpdate;
const res = npScrobbler.shouldUpdatePlayingNow(lastUpdate)[0]; expect(res).to.be.false; });
it('Should update if previous Now Playing does NOT match updated and last updated diff is greater than lower limit', async function () {
await using npScrobbler = new NowPlayingScrobbler(); await npScrobbler.initialize();
const lastUpdate = generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}); npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingMinThreshold(lastUpdate.play) + 1, 's'); npScrobbler.nowPlayingLastPlay = lastUpdate;
const res = npScrobbler.shouldUpdatePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}))[0]; expect(res).to.be.true; });
it('Should NOT update if previous Now Playing does NOT match updated and last updated diff is less than than lower limit', async function () {
await using npScrobbler = new NowPlayingScrobbler(); await npScrobbler.initialize();
const lastUpdate = generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}); npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingMinThreshold(lastUpdate.play) - 1, 's'); npScrobbler.nowPlayingLastPlay = lastUpdate;
const res = npScrobbler.shouldUpdatePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}))[0]; expect(res).to.be.false; });
});
describe('Scheduling', function () {
this.afterEach(() => { MockDate.reset(); });
it('Should update when no existing Now Playing', async function () {
await using npScrobbler = new NowPlayingScrobbler(); npScrobbler.nowPlayingTaskInterval = 10; await npScrobbler.initialize(); npScrobbler.initTasks();
await npScrobbler.queuePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}), {type: 'jellyfin', name: 'test'});
const res = await Promise.race([pEvent(npScrobbler.emitter, 'nowPlayingUpdated'), sleep(12)]);
expect(res).is.not.undefined; });
it('Should update when updated does not match Now Playing', async function () {
await using npScrobbler = new NowPlayingScrobbler(); npScrobbler.nowPlayingTaskInterval = 10; await npScrobbler.initialize(); npScrobbler.initTasks();
const now = dayjs();
await npScrobbler.queuePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}), {type: 'jellyfin', name: 'test'});
const res = await Promise.race([pEvent(npScrobbler.emitter, 'nowPlayingUpdated'), sleep(20)]);
expect(res).is.not.undefined;
MockDate.set(now.add(npScrobbler.nowPlayingMinThreshold() + 3, 's').toDate());
await npScrobbler.queuePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}), {type: 'jellyfin', name: 'test'});
const resUpdate = await Promise.race([pEvent(npScrobbler.emitter, 'nowPlayingUpdated'), sleep(20)]);
expect(resUpdate).is.not.undefined; });
});
});
describe('Scrobble Temporal Grouping', function () {
it('Groups into separate groups when not within duration', function() { const plays1 = normalizePlays(generatePlays(3), {initialDate: dayjs().subtract(1, 'hour')}); plays1.sort(sortByOldestPlayDate); const oldest1 = plays1[0].data.playDate!.unix(); const newest1 = plays1[plays1.length - 1].data.playDate!.unix();
const plays2 = normalizePlays(generatePlays(3), {initialDate: dayjs().subtract(2, 'hour')}); plays2.sort(sortByOldestPlayDate); const oldest2 = plays2[0].data.playDate!.unix(); const newest2 = plays2[plays2.length - 1].data.playDate!.unix();
const plays = [...plays1, ...plays2]; shuffleArray(plays);
const ranges = groupPlaysToTimeRanges(plays, [], {consolidateDuration: dayjs.duration(1, 's')}); expect(ranges.length).eq(2); expect(ranges.some(x => x.from < oldest1 && x.to > newest1)).is.true; expect(ranges.some(x => x.from < oldest2 && x.to > newest2)).is.true; });
it('Groups into existing time range', function() { const plays1 = normalizePlays(generatePlays(3), {initialDate: dayjs().subtract(1, 'hour')}); plays1.sort(sortByOldestPlayDate); const oldest1 = plays1[0].data.playDate; const newest1 = plays1[plays1.length - 1].data.playDate;
const plays2 = normalizePlays(generatePlays(3), {initialDate: dayjs().subtract(2, 'hour')}); plays2.sort(sortByOldestPlayDate); const oldest2 = plays2[0].data.playDate; const newest2 = plays2[plays2.length - 1].data.playDate;
const plays = [...plays1, ...plays2]; shuffleArray(plays);
const existing: PaginatedTimeRangeOptions = {from: oldest1!.subtract(10, 's').unix(), to: newest1!.add(10, 's').unix()};
const ranges = groupPlaysToTimeRanges(plays, [existing], {consolidateDuration: dayjs.duration(1, 's')}); expect(ranges.length).eq(2); expect(ranges.some(x => x.from < oldest1!.unix() && x.to > newest1!.unix())).is.true; expect(ranges.some(x => x.from < oldest2!.unix() && x.to > newest2!.unix())).is.true; expect(ranges.some(x => x.to === existing.to && x.from && existing.from)); });
it('Consolidates time ranges', function() { const plays1 = normalizePlays(generatePlays(3), {initialDate: dayjs().subtract(1, 'hour')}); plays1.sort(sortByOldestPlayDate);
const plays2 = normalizePlays(generatePlays(3), {initialDate: dayjs().subtract(5, 'hour')}); plays2.sort(sortByOldestPlayDate);
const singlePlay1 = generatePlay({playDate: dayjs().subtract(9, 'h')}) const singlePlay2 = generatePlay({playDate: dayjs().subtract(9, 'h').subtract(20, 'm')}); const singlePlay3 = generatePlay({playDate: dayjs().subtract(9, 'h').subtract(40, 'm')});
const plays = [...plays1, ...plays2, singlePlay1, singlePlay2, singlePlay3]; shuffleArray(plays);
const ranges = groupPlaysToTimeRanges(plays, [], {consolidateDuration: dayjs.duration(1, 's')}); expect(ranges.length).eq(5);
const consolidatedRanges = groupPlaysToTimeRanges(plays, [], {consolidateDuration: DEFAULT_CONSOLIDATE_DURATION}); expect(consolidatedRanges.length).eq(3); });});
describe('Scrobble Clients Behavior', function() {
describe('Source filtering', function() {
let cEmitter: WildcardEmitter<MSBackendEventMap>, sEmitter: WildcardEmitter<MSBackendEventMap>, clients: ScrobbleClients;
beforeEach(function() { cEmitter = new WildcardEmitter<MSBackendEventMap>(); sEmitter = new WildcardEmitter<MSBackendEventMap>(); clients = new ScrobbleClients(cEmitter, sEmitter, { localUrl: new URL('http://example.com'), configDir: process.cwd(), version: 'test' }, loggerNoop); });
it('does not scrobble when both name and id do not match', async function() {
const testClient = await generateTestScrobbler({id: 'test', name: 'test'}); clients.clients.push(testClient);
sEmitter.emit('discoveredToScrobble', { type: 'jellyfin', from: 'source', name: 'test', data: { data: [generatePlay()], options: { scrobbleFrom: 'testSource', scrobbleTo: ['foo'], checkTime: dayjs() } } }); expect(clients.scrobbleToNamesWarnings).is.empty; await Promise.race([ pEvent(testClient.emitter, 'scrobbleQueued'), sleep(10) ]); const queued = await testClient.getQueued(INGRESS_QUEUE); expect(queued.data).is.empty; });
it('warns when scrobbleTo matches name but not id', async function() {
const testClient = await generateTestScrobbler({id: 'testid', name: 'test'}); clients.clients.push(testClient);
sEmitter.emit('discoveredToScrobble', { type: 'jellyfin', from: 'source', name: 'test', data: { data: [generatePlay()], options: { scrobbleFrom: 'testSource', scrobbleTo: ['test'], checkTime: dayjs() } } }); expect(clients.scrobbleToNamesWarnings).length.greaterThan(0); await Promise.race([ pEvent(testClient.emitter, 'scrobbleQueued'), sleep(100) ]) const queued = await testClient.getQueued(INGRESS_QUEUE); expect(queued.data).is.not.empty; });
it('scrobbles when both name and id match', async function() {
const testClient = await generateTestScrobbler({id: 'test', name: 'test'}); clients.clients.push(testClient);
sEmitter.emit('discoveredToScrobble', { type: 'jellyfin', from: 'source', name: 'test', data: { data: [generatePlay()], options: { scrobbleFrom: 'testSource', scrobbleTo: ['test'], checkTime: dayjs() } } }); expect(clients.scrobbleToNamesWarnings).is.empty; await Promise.race([ pEvent(testClient.emitter, 'scrobbleQueued'), sleep(50) ]) const queued = await testClient.getQueued(INGRESS_QUEUE); expect(queued.data).is.not.empty; });
it('scrobbles without warning when only id matches', async function() {
const testClient = await generateTestScrobbler({id: 'test foo', name: 'test'}); clients.clients.push(testClient);
sEmitter.emit('discoveredToScrobble', { type: 'jellyfin', from: 'source', name: 'test', data: { data: [generatePlay()], options: { scrobbleFrom: 'testSource', scrobbleTo: ['test foo'], checkTime: dayjs() } } }); expect(clients.scrobbleToNamesWarnings).is.empty; await Promise.race([ pEvent(testClient.emitter, 'scrobbleQueued'), sleep(50) ]) const queued = await testClient.getQueued(INGRESS_QUEUE); expect(queued.data).is.not.empty; });
it('scrobbles when scrobbleTo is empty', async function() {
const testClient = await generateTestScrobbler({id: 'test foo', name: 'test'}); clients.clients.push(testClient);
sEmitter.emit('discoveredToScrobble', { type: 'jellyfin', from: 'source', name: 'test', data: { data: [generatePlay()], options: { scrobbleFrom: 'testSource', scrobbleTo: [], checkTime: dayjs() } } }); expect(clients.scrobbleToNamesWarnings).is.empty; await Promise.race([ pEvent(testClient.emitter, 'scrobbleQueued'), sleep(50) ]) const queued = await testClient.getQueued(INGRESS_QUEUE); expect(queued.data).is.not.empty; });
it('scrobbleTo is case-insensitive and ignores whitespace', async function() {
const testClient = await generateTestScrobbler({id: 'test foo ', name: 'test'}); clients.clients.push(testClient);
sEmitter.emit('discoveredToScrobble', { type: 'jellyfin', from: 'source', name: 'test', data: { data: [generatePlay()], options: { scrobbleFrom: 'testSource', scrobbleTo: ['TesT foO'], checkTime: dayjs() } } }); expect(clients.scrobbleToNamesWarnings).is.empty; await Promise.race([ pEvent(testClient.emitter, 'scrobbleQueued'), sleep(50) ]) const queued = await testClient.getQueued(INGRESS_QUEUE); expect(queued.data).is.not.empty; });
});
});