fix: Improve detection of repeat plays when listen range is close to end of a position player

* Use additional hueristic for determining repeat: use last range ended + seek in first 50% of track
* Define a clearer definition for "last position at the end" to be 10s or 10% of duration
* Guard against track short seek behavior (listening to same part of a song over and over again) by checking duration played

#691
This commit is contained in:
FoxxMD 2026-10-01 11:04:51 -04:00
parent 948e52b33e
commit 98fe0d09d7
3 changed files with 138 additions and 2 deletions

View file

@ -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: {

View file

@ -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

View file

@ -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);
});
}
});
});
});
});