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.
29 kB · 563 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564import { 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); });
// 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); }); } }); }); });});