import { 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 => ({...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); }); }); }); });