diff --git a/src/backend/common/infrastructure/Atomic.ts b/src/backend/common/infrastructure/Atomic.ts index e4b42b23..27390737 100644 --- a/src/backend/common/infrastructure/Atomic.ts +++ b/src/backend/common/infrastructure/Atomic.ts @@ -140,6 +140,11 @@ export const DEFAULT_CLOSE_POSITION_ABSOLUTE = 12; export const DEFAULT_CLOSE_POSITION_PERCENT = 0.15; export const DEFAULT_DURATION_REPEAT_ABSOLUTE = 120; export const DEFAULT_DURATION_REPEAT_PERCENT = 0.50; +/** Last known Position within X seconds or Y% of the end of a Play is considered to have reached the end of the Play */ +export const DEFAULT_COMPLETED_POSITION_ABSOLUTE = 10; +export const DEFAULT_COMPLETED_POSITION_PERCENT = 0.10; +/** A completed Play must come back to a Position within the first Y% of the Play to be considered restarted */ +export const DEFAULT_RESTART_POSITION_PERCENT = 0.50; export interface ScrobbleThresholdResult { passes: boolean duration: { diff --git a/src/backend/sources/PlayerState/AbstractPlayerState.ts b/src/backend/sources/PlayerState/AbstractPlayerState.ts index f467efad..fbe893c6 100644 --- a/src/backend/sources/PlayerState/AbstractPlayerState.ts +++ b/src/backend/sources/PlayerState/AbstractPlayerState.ts @@ -4,6 +4,9 @@ import { type PlayObject, type PlayProgress, type Second, SOURCE_SOT, type SOURC import { buildTrackString } from "../../../core/StringUtils.ts"; import { asPlayerStateData, + DEFAULT_COMPLETED_POSITION_ABSOLUTE, + DEFAULT_COMPLETED_POSITION_PERCENT, + DEFAULT_RESTART_POSITION_PERCENT, type PlayerStateData, type PlayerStateDataMaybePlay, } from "../../common/infrastructure/Atomic.ts"; @@ -377,13 +380,30 @@ export abstract class AbstractPlayerState { const repeatHint = `New Position (${position})`; const trackDur = currentPlay.data.duration; + const playerDur = this.getListenDuration(); + const [repeatDurationOk, repeatDurationHint] = repeatDurationPlayed(currentPlay, playerDur, {hintPrefix: false}); + + // Play was completed and then restarted + // + // the new position may NOT be close to start of Play if the Source was not updated/polled until well after the Play restarted + // so instead of proximity to start we require the new position only be in the first half of the Play + // + // doing this, and requiring a good chunk of the Play was listened to, prevents cutting off a Play + // when a user reaches the end and seeks back to re-listen to a later section + const completedLastPos = this.currentListenRange?.getPosition() ?? (this.listenRanges.length > 0 ? this.listenRanges[this.listenRanges.length - 1].getPosition() : undefined); + if (trackDur !== undefined && trackDur > 0 && completedLastPos !== undefined && repeatDurationOk && position < completedLastPos && position <= trackDur * DEFAULT_RESTART_POSITION_PERCENT) { + const [completed, completedHint] = closeToPlayEnd(currentPlay, completedLastPos, {absolute: DEFAULT_COMPLETED_POSITION_ABSOLUTE, percent: DEFAULT_COMPLETED_POSITION_PERCENT, hintPrefix: false}); + if (completed) { + this.logger.verbose(`${repeatHint} is within first ${formatNumber(DEFAULT_RESTART_POSITION_PERCENT * 100, {toFixed: 0})}% of track and last position (${completedLastPos}s) ${completedHint} and listened ${repeatDurationHint}`); + return true; + } + } + // new position is close to start of Play const [closeStart, closeStartHint] = closeToPlayStart(currentPlay, position, {hintPrefix: false}); hints.push(closeStartHint); if (closeStart) { - const playerDur = this.getListenDuration(); - const [repeatDurationOk, repeatDurationHint] = repeatDurationPlayed(currentPlay, playerDur, {hintPrefix: false}); // user has played at least 2 minutes or 50% of track // and the current (new) listen range was close to the start diff --git a/src/backend/tests/player/player.test.ts b/src/backend/tests/player/player.test.ts index 8490f143..cc7ebe30 100644 --- a/src/backend/tests/player/player.test.ts +++ b/src/backend/tests/player/player.test.ts @@ -447,6 +447,117 @@ describe('Player listen ranges', function () { 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); + }); + } + }); }); }); });