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); }); // https://github.com/FoxxMD/multi-scrobbler/issues/691 // Based on listen ranges from the issue: track (194.307s) is played from `startPosition` to `lastPosition`, optionally Source then reports paused (closing the listen range), // and the next update arrives `resumeAfter` seconds later with the player at `resumePosition` // // `paused: false` skips the paused update so the listen range is still open when `resumePosition` is seen const playThenResume = (resumePosition: number, {startPosition = 28.351, lastPosition = 193.5, paused = true, resumeAfter = 31}: {startPosition?: number, lastPosition?: number, paused?: boolean, resumeAfter?: number} = {}) => { const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]); const start = dayjs(); const positioned = clone(newPlay); positioned.data.duration = 194.307; player.update(testState({play: positioned, position: startPosition, status: REPORTED_PLAYER_STATUSES.playing}), start); for(const position of [startPosition + ((lastPosition - startPosition) / 2), lastPosition]) { player.currentListenRange!.rtPlayer.setPosition(position * 1000); player.update(testState({play: positioned, position, status: REPORTED_PLAYER_STATUSES.playing}), start.add(position - startPosition, 'seconds')); } const lastUpdate = start.add((lastPosition - startPosition) + 1, 'seconds'); if(paused) { // Source reports paused => listen range is closed player.update(testState({play: positioned, position: lastPosition, status: REPORTED_PLAYER_STATUSES.paused}), lastUpdate); assert.isUndefined(player.currentListenRange); assert.equal(player.listenRanges.length, 1); } return player.update(testState({play: positioned, position: resumePosition, status: REPORTED_PLAYER_STATUSES.playing}), lastUpdate.add(resumeAfter, 'seconds')); } it('Detects repeat when player paused at end of track and next update is within 15% of start', function () { // control: 28.351s is 14.6% of track, taken from the one repeat that *was* detected in the issue const [curr, prevPlay] = playThenResume(28.351); assert.isDefined(prevPlay); assert.isTrue(curr!.data.repeat); }); describe('When Play was completed and then restarted', function () { // listen range is closed (Source reported paused at end of track) or still open (repeat is seen as a backwards seek) for(const paused of [true, false]) { const rangeHint = paused ? 'listen range closed' : 'listen range open'; it(`Detects repeat when next update is a polling interval later, past 15% of start (${rangeHint})`, function () { // 29.297s is 15.08% of track, taken from the repeat that was *not* detected in the issue const [curr, prevPlay] = playThenResume(29.297, {paused}); assert.isDefined(prevPlay, 'Repeat was not detected, listen range was appended to existing Play'); assert.equal(prevPlay.data.listenedFor, 194.307 - 28.351); assert.isTrue(curr!.data.repeat); assert.isAtMost(curr!.data.listenedFor!, 194.307); }); it(`Detects repeat regardless of time passed since end of track (${rangeHint})`, function () { // user manually seeked back right after track ended, or left the player paused for a long time before restarting for(const resumeAfter of [2, 300]) { const [curr, prevPlay] = playThenResume(29.297, {paused, resumeAfter}); assert.isDefined(prevPlay, `Repeat was not detected after ${resumeAfter}s`); assert.isTrue(curr!.data.repeat); } }); it(`Detects repeat when last position was within 10% of end of track (${rangeHint})`, function () { // Sources with infrequent position updates may never report a position closer to the end than this // 176s is 18.3s (9.4%) from end of track const [curr, prevPlay] = playThenResume(29.297, {paused, lastPosition: 176}); assert.isDefined(prevPlay, 'Repeat was not detected, listen range was appended to existing Play'); assert.isTrue(curr!.data.repeat); }); it(`Detects repeat when restarted position is anywhere in first half of track (${rangeHint})`, function () { const [curr, prevPlay] = playThenResume(95, {paused}); assert.isDefined(prevPlay, 'Repeat was not detected, listen range was appended to existing Play'); assert.isTrue(curr!.data.repeat); }); it(`Does not detect repeat when last position was not within 10% of end of track (${rangeHint})`, function () { // 170s is 24.3s (12.5%) from end of track so user seeked back mid-track const [curr, prevPlay] = playThenResume(29.297, {paused, lastPosition: 170}); assert.isUndefined(prevPlay); assert.isFalse(curr!.data.repeat); assert.equal(curr!.data.listenRanges!.length, 2); }); it(`Does not detect repeat when player is seeked back to second half of track (${rangeHint})`, function () { // user reached the end and seeked back to listen to a later section again for(const resumePosition of [98, 150, 185]) { const [curr, prevPlay] = playThenResume(resumePosition, {paused, resumeAfter: 2}); assert.isUndefined(prevPlay, `Repeat was detected when seeked to ${resumePosition}`); assert.isFalse(curr!.data.repeat); assert.equal(curr!.data.listenRanges!.length, 2); } }); it(`Does not detect repeat when only a small part of track was listened to (${rangeHint})`, function () { // only the last 23.5s (12%) was listened to before player went back, Play was not actually completed const [curr, prevPlay] = playThenResume(29.297, {paused, startPosition: 170}); assert.isUndefined(prevPlay); assert.isFalse(curr!.data.repeat); assert.equal(curr!.data.listenRanges!.length, 2); }); } }); }); }); });