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.
22 kB · 452 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453import { loggerTest } from "@foxxmd/logging";import { assert, expect } from 'chai';import clone from "clone";import dayjs, { type Dayjs } from "dayjs";import { describe, it } from 'mocha';import { type PlayerStateDataMaybePlay} from "../../common/infrastructure/Atomic.ts";import { SINGLE_USER_PLATFORM_ID } from '../../../core/Atomic.ts';import { NO_USER } from '../../../core/Atomic.ts';import { NO_DEVICE } from '../../../core/Atomic.ts';import { CALCULATED_PLAYER_STATUSES } from '../../../core/Atomic.ts';import { REPORTED_PLAYER_STATUSES } from '../../../core/Atomic.ts';import { GenericPlayerState } from "../../sources/PlayerState/GenericPlayerState.ts";import { playObjDataMatch } from "../../utils.ts";import { generatePlay } from "../../../core/tests/utils/PlayTestUtils.ts";import { PositionalPlayerState } from "../../sources/PlayerState/PositionalPlayerState.ts";import type { ListenProgressPositional } from "../../sources/PlayerState/ListenProgress.ts";import type { ListenRangePositional } from "../../sources/PlayerState/ListenRange.ts";
const logger = loggerTest;
const newPlay = generatePlay({duration: 300});
const testState = (data: Omit<PlayerStateDataMaybePlay, 'platformId'>): PlayerStateDataMaybePlay => ({...data, platformId: SINGLE_USER_PLATFORM_ID});
class TestPositionalPlayerState extends PositionalPlayerState { protected newListenRange(start?: ListenProgressPositional, end?: ListenProgressPositional, options: object = {}): ListenRangePositional { const range = super.newListenRange(start, end, {allowedDrift: this.allowedDrift, rtImmediate: false, rtTruth: this.rtTruth, ...options}); return range; } public testSessionRepeat(position: number, reportedTS?: Dayjs) { return this.isSessionRepeat(position, reportedTS); }}
describe('Basic player state', function () {
it('Creates new play state when new', function () { const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
assert.isUndefined(player.currentListenRange); assert.isUndefined(player.currentPlay);
player.update(testState({play: newPlay}));
assert.isDefined(player.currentListenRange); assert.isDefined(player.currentPlay); });
it('Creates new play state in unknown status', function () { const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
assert.isUndefined(player.currentListenRange); assert.isUndefined(player.currentPlay);
player.update(testState({play: newPlay}));
assert.isDefined(player.currentListenRange); assert.isDefined(player.currentPlay); assert.equal(CALCULATED_PLAYER_STATUSES.unknown, player.calculatedStatus); });
it('Creates new play state when incoming play is not the same as stored play', function () { const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
player.update(testState({play: newPlay}));
assert.isTrue(playObjDataMatch(player.currentPlay, newPlay));
const nextPlay = generatePlay({playDate: newPlay.data.playDate.add(2, 'seconds')}); const [returnedPlay, prevPlay] = player.update(testState({play: nextPlay}));
assert.isTrue(playObjDataMatch(prevPlay, newPlay)); assert.isTrue(playObjDataMatch(player.currentPlay, nextPlay)); });});
describe('Player status', function () {
it('New player transitions from unknown to playing on n+1 states', function () { const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
player.update(testState({play: newPlay})); assert.equal(CALCULATED_PLAYER_STATUSES.unknown, player.calculatedStatus);
player.update(testState({play: newPlay}), dayjs().add(10, 'seconds')); assert.equal(CALCULATED_PLAYER_STATUSES.playing, player.calculatedStatus); });
describe('When source provides reported status', function () {
it('Calculated state is playing when source reports playing', function () { const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.playing})); player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.playing}), dayjs().add(10, 'seconds')); assert.equal(CALCULATED_PLAYER_STATUSES.playing, player.calculatedStatus); });
it('Calculated state is paused when source reports paused', function () { const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.playing})); player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.playing}), dayjs().add(10, 'seconds')); player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.paused}), dayjs().add(20, 'seconds')); assert.equal(CALCULATED_PLAYER_STATUSES.paused, player.calculatedStatus); });
it('Calculated state is stopped when source reports stopped', function () { const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.playing})); player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.playing}), dayjs().add(10, 'seconds')); player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.stopped}), dayjs().add(20, 'seconds')); assert.equal(CALCULATED_PLAYER_STATUSES.stopped, player.calculatedStatus); });
});
describe('When source provides playback position', function () {
it('Calculated state is playing when position moves forward', function () { const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]);
const positioned = clone(newPlay);
player.update(testState({play: positioned, position: 3}));
player.currentListenRange.rtPlayer.setPosition(13000); player.update(testState({play: positioned, position: 13}), dayjs().add(10, 'seconds'));
assert.equal(CALCULATED_PLAYER_STATUSES.playing, player.calculatedStatus); });
it('Calculated state is paused when position does not change and rt overdrifts', function () { const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]);
const positioned = clone(newPlay); positioned.meta.trackProgressPosition = 3;
player.update(testState({play: positioned, position: 3}));
player.currentListenRange.rtPlayer.setPosition(13000); player.update(testState({play: positioned, position: 13}), dayjs().add(10, 'seconds'));
player.currentListenRange.rtPlayer.setPosition(23000); player.update(testState({play: positioned, position: 13}), dayjs().add(20, 'seconds'));
assert.equal(CALCULATED_PLAYER_STATUSES.paused, player.calculatedStatus); });
it('Uses last known position for final range when cleaning up stale player', function () { const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER], {staleInterval: 20, rtTruth: true});
const positioned = clone(newPlay); positioned.meta.trackProgressPosition = 3;
player.update(testState({play: positioned, position: 3}));
player.currentListenRange.rtPlayer.setPosition(13000); player.update(testState({play: positioned, position: 13}), dayjs().add(10, 'seconds'));
player.currentListenRange.rtPlayer.setPosition(23000); player.update(testState({play: positioned, position: 23}), dayjs().add(20, 'seconds'));
const staleDate = dayjs().add(41, 'seconds') player.currentListenRange.rtPlayer.setPosition(44000); expect(player.currentListenRange.isOverDrifted(23)).to.be.true;
expect(player.checkStale(staleDate)).to.be.true; expect(player.listenRanges[player.listenRanges.length - 1].end.position).to.eq(23); expect(player.getListenDuration()).to.eq(20); });
// TODO playback position reported and conflicts with player reported status });});
describe('Player listen ranges', function () { describe('When source does not provide playback position', function () {
it('Duration is timestamp based for unknown/playing reported players', function () { const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.playing})); player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.playing}), dayjs().add(10, 'seconds')); player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.playing}), dayjs().add(20, 'seconds'));
assert.equal(player.getListenDuration(), 20);
const uplayer = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
uplayer.update(testState({play: newPlay})); uplayer.update(testState({play: newPlay}), dayjs().add(10, 'seconds')); uplayer.update(testState({play: newPlay}), dayjs().add(20, 'seconds'));
assert.equal(uplayer.getListenDuration(), 20); });
it('Range ends if player reports paused', function () { const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.playing})); player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.playing}), dayjs().add(10, 'seconds')); player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.playing}), dayjs().add(20, 'seconds')); player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.paused}), dayjs().add(30, 'seconds')); player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.paused}), dayjs().add(40, 'seconds'));
assert.equal(player.getListenDuration(), 20); });
it('Listen duration continues when player resumes', function () { const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.playing})); player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.playing}), dayjs().add(10, 'seconds')); player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.playing}), dayjs().add(20, 'seconds')); player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.paused}), dayjs().add(30, 'seconds')); player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.paused}), dayjs().add(40, 'seconds')); // For TS-only players the player must see two consecutive playing states to count the duration between them // so it does NOT count above paused ^^ to below playing -- only playing-to-playing player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.playing}), dayjs().add(50, 'seconds')); player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.playing}), dayjs().add(60, 'seconds'));
assert.equal(player.getListenDuration(), 30); }); });
describe('When source does provide playback position', function () {
it('Listened duration is position based', function () { const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]);
const positioned = clone(newPlay);
player.update(testState({play: positioned, position: 3}));
player.currentListenRange.rtPlayer.setPosition(10000); player.update(testState({play: positioned, position: 10}), dayjs().add(10, 'seconds'));
assert.equal(player.getListenDuration(), 7); });
it('Listened duration is track duration invariant', function () { const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]);
const positioned = clone(newPlay); positioned.data.duration = 0;
player.update(testState({play: positioned, position: 3}));
player.currentListenRange.rtPlayer.setPosition(10000); player.update(testState({play: positioned, position: 10}), dayjs().add(10, 'seconds'));
assert.equal(player.getListenDuration(), 7); });
it('Listened for is track duration invariant', function () { const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]);
const positioned = clone(newPlay); positioned.data.duration = 0;
player.update(testState({play: positioned, position: 3, status: REPORTED_PLAYER_STATUSES.playing}));
player.currentListenRange.rtPlayer.setPosition(10000); player.update(testState({play: positioned, position: 10, status: REPORTED_PLAYER_STATUSES.playing}), dayjs().add(10, 'seconds'));
player.currentListenRange.rtPlayer.setPosition(20000); player.update(testState({play: positioned, position: 20, status: REPORTED_PLAYER_STATUSES.playing}), dayjs().add(20, 'seconds'));
const otherPlay = clone(positioned); otherPlay.data.track = "A New Track"; player.currentListenRange.rtPlayer.setPosition(30000); const [currPlay, prevPlay] = player.update(testState({play: otherPlay, position: 2, status: REPORTED_PLAYER_STATUSES.playing}), dayjs().add(30, 'seconds'));
assert.isDefined(prevPlay); assert.equal(prevPlay.data.listenedFor, 17); });
it('Range ends if position over drifts', function () { const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]);
const positioned = clone(newPlay); player.update(testState({play: positioned, position: 3}));
player.currentListenRange.rtPlayer.setPosition(10000); player.update(testState({play: positioned, position: 3}), dayjs().add(10, 'seconds'));
assert.equal(player.getListenDuration(), 0); });
it('Range continues when position continues moving forward', function () { const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]);
const positioned = clone(newPlay); player.update(testState({play: positioned, position: 3}));
player.currentListenRange.rtPlayer.setPosition(7000); player.update(testState({play: positioned, position: 7}), dayjs().add(4, 'seconds'));
player.currentListenRange.rtPlayer.setPosition(23000); player.update(testState({play: positioned, position: 23}), dayjs().add(20, 'seconds'));
player.currentListenRange.rtPlayer.setPosition(33000); player.update(testState({play: positioned, position: 33}), dayjs().add(30, 'seconds'));
player.currentListenRange.rtPlayer.setPosition(43000); player.update(testState({play: positioned, position: 43}), dayjs().add(40, 'seconds'));
assert.equal(player.getListenDuration(), 40); });
describe('Detects seeking', function () {
it('Detects seeking forward', function () { const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]);
const positioned = clone(newPlay); player.update(testState({play: positioned, position: 3}));
positioned.meta.trackProgressPosition = 13; player.currentListenRange.rtPlayer.setPosition(13000); player.update(testState({play: positioned, position: 13}), dayjs().add(10, 'seconds'));
player.currentListenRange.rtPlayer.setPosition(17000); const [isSeeked, time] = player.currentListenRange.seeked(24, dayjs().add(17, 'seconds')) assert.isTrue(isSeeked); assert.equal(time, 7000) });
it('Detects seeking backwards when position is before last reported position', function () { const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]);
const positioned = clone(newPlay); player.update(testState({play: positioned, position: 3}));
player.currentListenRange.rtPlayer.setPosition(13000); player.update(testState({play: positioned, position: 13}), dayjs().add(10, 'seconds'));
player.currentListenRange.rtPlayer.setPosition(17000); const [isSeeked, time] = player.currentListenRange.seeked(10, dayjs().add(17, 'seconds')) assert.isTrue(isSeeked); assert.equal(time, -3000) }); });
describe('Detects repeating', function () { it('Detects repeat when player was within 12 seconds of ending and seeked back to within 12 seconds of start', function () { const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]);
const positioned = clone(newPlay); positioned.data.duration = 70; player.update(testState({play: positioned, position: 45}));
player.currentListenRange.rtPlayer.setPosition(65000); player.update(testState({play: positioned, position: 65}), dayjs().add(20, 'seconds'));
const isRepeat = player.testSessionRepeat(5, dayjs().add(20, 'seconds')); assert.isTrue(isRepeat);
player.currentListenRange.rtPlayer.setPosition(67000); const [curr, prevPlay] = player.update(testState({play: positioned, position: 5}), dayjs().add(22, 'seconds'));
assert.isDefined(prevPlay); assert.isTrue(curr.data.repeat) assert.equal(player.getListenDuration(), 0); });
it('Detects repeat when player was within 15% of ending and seeked back to within 15% of start', function () { const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]);
const positioned = clone(newPlay); positioned.data.duration = 300;
player.update(testState({play: positioned, position: 351}));
player.currentListenRange.rtPlayer.setPosition(361000); player.update(testState({play: positioned, position: 361}), dayjs().add(10, 'seconds'));
player.currentListenRange.rtPlayer.setPosition(371000); player.update(testState({play: positioned, position: 371}), dayjs().add(20, 'seconds'));
const isRepeat = player.testSessionRepeat(20, dayjs().add(30, 'seconds')); assert.isTrue(isRepeat);
player.currentListenRange.rtPlayer.setPosition(381000); const [curr, prevPlay] = player.update(testState({play: positioned, position: 20}), dayjs().add(30, 'seconds'));
assert.isTrue(curr.data.repeat); assert.isDefined(prevPlay); assert.equal(player.getListenDuration(), 0); });
it('Detects repeat when player is seeked to start and a hefty chunk of the track has already been played', function () { const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]);
const positioned = clone(newPlay); positioned.data.duration = 70;
player.update(testState({play: positioned, position: 0}));
player.currentListenRange.rtPlayer.setPosition(40000); player.update(testState({play: positioned, position: 40}), dayjs().add(40, 'seconds'));
const isRepeat = player.testSessionRepeat(2, dayjs().add(50, 'seconds')); assert.isTrue(isRepeat);
positioned.meta.trackProgressPosition = 2; player.currentListenRange.rtPlayer.setPosition(50000); const [curr, prevPlay] = player.update(testState({play: positioned, position: 2}), dayjs().add(50, 'seconds'));
assert.isTrue(curr.data.repeat) assert.isDefined(prevPlay); assert.equal(player.getListenDuration(), 0); });
it('Resets repeat status when updated with non-matching play', function () { const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]);
const positioned = clone(newPlay); positioned.data.duration = 70;
player.update(testState({play: positioned, position: 0}));
player.currentListenRange.rtPlayer.setPosition(40000); player.update(testState({play: positioned, position: 40}), dayjs().add(40, 'seconds'));
const isRepeat = player.testSessionRepeat(2, dayjs().add(50, 'seconds')); assert.isTrue(isRepeat);
positioned.meta.trackProgressPosition = 2; player.currentListenRange.rtPlayer.setPosition(50000); const [curr, prevPlay] = player.update(testState({play: positioned, position: 2}), dayjs().add(50, 'seconds'));
assert.isTrue(curr.data.repeat) assert.isDefined(prevPlay); assert.equal(player.getListenDuration(), 0);
player.currentListenRange.rtPlayer.setPosition(55000); const [currNew, prevPlayRepeat] = player.update(testState({play: generatePlay(), position: 1}), dayjs().add(55, 'seconds'));
assert.isDefined(prevPlayRepeat) assert.isTrue(prevPlayRepeat.data.repeat); assert.isDefined(currNew); assert.isFalse(currNew.data.repeat); }); }); });});