mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-10-19 15:26:33 +00:00
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:
parent
948e52b33e
commit
98fe0d09d7
3 changed files with 138 additions and 2 deletions
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue