[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
0

Configure Feed

Select the types of activity you want to include in your feed.

refactor: Move Play position closeness and duration comparisons to own functions

* Hint for logs returns for all conditions
* Add tests

FoxxMD (Jul 17, 2025, 3:08 PM UTC) 60371f52 ce39065c

+225 -71
+4
src/backend/common/infrastructure/Atomic.ts
··· 211 211 212 212 export const DEFAULT_RETRY_MULTIPLIER: number = 1.5; 213 213 214 + export const DEFAULT_CLOSE_POSITION_ABSOLUTE = 12; 215 + export const DEFAULT_CLOSE_POSITION_PERCENT = 0.15; 216 + export const DEFAULT_DURATION_REPEAT_ABSOLUTE = 120; 217 + export const DEFAULT_DURATION_REPEAT_PERCENT = 0.50; 214 218 export interface ScrobbleThresholdResult { 215 219 passes: boolean 216 220 duration: {
+34 -35
src/backend/sources/PlayerState/AbstractPlayerState.ts
··· 16 16 import { formatNumber, genGroupIdStr, playObjDataMatch, progressBar } from "../../utils.js"; 17 17 import { ListenProgress } from "./ListenProgress.js"; 18 18 import { ListenRange, ListenRangePositional } from "./ListenRange.js"; 19 - import { timeToHumanTimestamp, todayAwareFormat } from "../../utils/TimeUtils.js"; 19 + import { closeToPlayEnd, closeToPlayStart, repeatDurationPlayed, timeToHumanTimestamp, todayAwareFormat } from "../../utils/TimeUtils.js"; 20 20 21 21 export interface PlayerStateIntervals { 22 22 staleInterval?: number ··· 80 80 listenRanges: ListenRange[] = []; 81 81 createdAt: Dayjs = dayjs(); 82 82 stateLastUpdatedAt: Dayjs = dayjs(); 83 + 84 + lastPlay?: PlayObject 83 85 84 86 protected constructor(logger: Logger, platformId: PlayPlatformId, opts: PlayerStateOptions = DefaultPlayerStateOptions) { 85 87 this.platformId = platformId; ··· 183 185 this.currentListenSessionEnd(); 184 186 const played = this.getPlayedObject(true); 185 187 this.isRepeatPlay = false; 188 + this.lastPlay = played; 186 189 this.setCurrentPlay(state, {reportedTS}); 187 190 if (this.calculatedStatus !== CALCULATED_PLAYER_STATUSES.playing) { 188 191 this.calculatedStatus = CALCULATED_PLAYER_STATUSES.unknown; ··· 216 219 } 217 220 } else { 218 221 this.isRepeatPlay = false; 222 + 219 223 this.setCurrentPlay(state); 220 224 this.calculatedStatus = CALCULATED_PLAYER_STATUSES.unknown; 221 225 } ··· 230 234 protected incomingPlayMatchesExisting(play: PlayObject): boolean { return playObjDataMatch(this.currentPlay, play); } 231 235 232 236 protected clearPlayer() { 237 + this.lastPlay = this.currentPlay; 233 238 this.currentPlay = undefined; 234 239 this.playLastUpdatedAt = undefined; 235 240 this.playFirstSeenAt = undefined; ··· 285 290 286 291 protected abstract currentListenSessionEnd(); 287 292 293 + /** Check if new Player Position was seeked to a Position that indicates user is repeating the track 294 + * 295 + * True if: 296 + * * New position is close to start of Play and... 297 + * * Listened duraton is more than 2 minutes/50% of Play OR... 298 + * * Previous Position was close to end of Play 299 + */ 288 300 protected isSessionRepeat(position?: number, reportedTS?: Dayjs) { 289 301 if(this.currentListenRange === undefined) { 290 302 return false; ··· 293 305 if (isSeeked === false || seekPos > 0) { 294 306 return false; 295 307 } 308 + 309 + const hints: string[] = []; 310 + 296 311 let repeatHint = `New Position (${position})`; 297 312 const trackDur = this.currentPlay.data.duration; 298 - // user is within 10 seconds or 10% of start of track 299 - const closeStartNum = position <= 12; 300 - if(closeStartNum) { 301 - repeatHint = `${repeatHint} is within 12 seconds of track start`; 302 - } 303 - const closeStartPer = (trackDur !== undefined && ((position / trackDur) <= 0.15)); 304 - if(!closeStartNum && closeStartPer) { 305 - repeatHint = `${repeatHint} is within 15% of track start (${formatNumber((position/trackDur)*100)}%).`; 306 - } 307 - if (closeStartNum || closeStartPer) { 313 + 314 + // new position is close to start of Play 315 + const [closeStart, closeStartHint] = closeToPlayStart(this.currentPlay, position, {hintPrefix: false}); 316 + hints.push(closeStartHint); 317 + 318 + if (closeStart) { 319 + const playerDur = this.getListenDuration(); 320 + const [repeatDurationOk, repeatDurationHint] = repeatDurationPlayed(this.currentPlay, playerDur, {hintPrefix: false}); 321 + 308 322 // user has played at least 2 minutes or 50% of track 309 - const playerDur = this.getListenDuration(); 310 - const closeDurNum = playerDur >= 120; 311 - if(closeDurNum) { 312 - repeatHint = `${repeatHint} and listened to more than 120s (${playerDur}s)` 313 - } 314 - const closeDurPer = (trackDur !== undefined && (playerDur / trackDur) >= 0.5); 315 - if(!closeDurNum && closeDurPer) { 316 - repeatHint = `${repeatHint} and listened to more than 50% (${formatNumber((playerDur/trackDur)*100)}%).` 317 - } 318 - if (closeDurNum || closeDurPer) { 319 - this.logger.verbose(repeatHint); 323 + if (repeatDurationOk) { 324 + this.logger.verbose(`${repeatHint} ${[closeStartHint, repeatDurationHint].join(' and ')}`); 320 325 return true; 321 326 } 322 - if (trackDur !== undefined && this.currentListenRange.getPosition() !== undefined) { 323 - const lastPos = this.currentListenRange.getPosition(); 324 - // or last position is within 10 seconds (or 10%) of end of track 325 - const nearEndNum = (trackDur - lastPos < 12); 326 - if(nearEndNum) { 327 - repeatHint = `${repeatHint} and previous position was within 12 seconds of track end.`; 328 - } 329 - const nearEndPos = ((lastPos / trackDur) > 0.85); 330 - if(!nearEndNum && nearEndPos) { 331 - repeatHint = `${repeatHint} and previous position was within 15% of track end (${formatNumber((lastPos/trackDur)*100)}%)`; 332 - } 333 - if(nearEndNum || nearEndPos) { 334 - this.logger.verbose(repeatHint); 327 + 328 + const lastPos = this.currentListenRange.getPosition(); 329 + if (trackDur !== undefined && lastPos !== undefined) { 330 + const [nearEnd, nearEndHint] = closeToPlayEnd(this.currentPlay, lastPos, {hintPrefix: false}); 331 + // last position is close to end of Play 332 + if(nearEnd) { 333 + this.logger.verbose(`${repeatHint} ${[closeStartHint, nearEndHint].join(' and ')}`); 335 334 return true; 336 335 } 337 336 }
-36
src/backend/tests/source/source.test.ts
··· 425 425 }); 426 426 }); 427 427 428 - describe('Scrobble Threshold Checks', function() { 429 - 430 - it('uses defaults when no user-configured thresholds are passed', function() { 431 - const results = timePassesScrobbleThreshold({}, 1, 1); 432 - expect(results.duration.threshold).to.eq(DEFAULT_SCROBBLE_DURATION_THRESHOLD); 433 - expect(results.percent.threshold).to.eq(DEFAULT_SCROBBLE_PERCENT_THRESHOLD); 434 - }); 435 - 436 - it('uses user-configured thresholds when passed', function() { 437 - const results = timePassesScrobbleThreshold({ 438 - duration: 20, 439 - percent: 15 440 - }, 1, 1); 441 - expect(results.duration.threshold).to.eq(20); 442 - expect(results.percent.threshold).to.eq(15); 443 - }); 444 - 445 - it('passes when duration is above threshold', function() { 446 - const results = timePassesScrobbleThreshold({}, DEFAULT_SCROBBLE_DURATION_THRESHOLD + 1); 447 - expect(results.duration.passes).is.true; 448 - expect(results.passes).is.true; 449 - }); 450 - 451 - it('passes when percent is above threshold', function() { 452 - const results = timePassesScrobbleThreshold({}, 30, 50); 453 - expect(results.percent.passes).is.true; 454 - expect(results.passes).is.true; 455 - }); 456 - 457 - it('handles zero duration', function() { 458 - const results = timePassesScrobbleThreshold({}, DEFAULT_SCROBBLE_DURATION_THRESHOLD + 1, 0); 459 - expect(results.duration.passes).is.true; 460 - expect(results.passes).is.true; 461 - }); 462 - }); 463 - 464 428 const generateDeezerSource = (options: DeezerInternalSourceOptions = {}) => { 465 429 return new DeezerInternalSource('test', {data: {arl: 'test'}, options}, {localUrl: new URL('https://example.com'), configDir: 'fake', logger: loggerTest, version: 'test'}, emitter); 466 430 }
+105
src/backend/tests/utilitiesTests/time.test.ts
··· 1 + import { assert, expect } from 'chai'; 2 + import { describe, it } from 'mocha'; 3 + import { closeToPlayEnd, closeToPlayStart, repeatDurationPlayed, timePassesScrobbleThreshold } from '../../utils/TimeUtils.js'; 4 + import { DEFAULT_CLOSE_POSITION_ABSOLUTE, DEFAULT_CLOSE_POSITION_PERCENT, DEFAULT_DURATION_REPEAT_ABSOLUTE, DEFAULT_DURATION_REPEAT_PERCENT, DEFAULT_SCROBBLE_DURATION_THRESHOLD, DEFAULT_SCROBBLE_PERCENT_THRESHOLD } from '../../common/infrastructure/Atomic.js'; 5 + import { generatePlay } from '../utils/PlayTestUtils.js'; 6 + 7 + describe('Play Position', function() { 8 + 9 + describe('Close To Start', function() { 10 + 11 + it(`Is close when within ${DEFAULT_CLOSE_POSITION_ABSOLUTE} seconds`, function() { 12 + expect(closeToPlayStart(generatePlay(), 10)[0]).is.true; 13 + }); 14 + it(`Is not close when later than ${DEFAULT_CLOSE_POSITION_ABSOLUTE} seconds`, function() { 15 + expect(closeToPlayStart(generatePlay({duration: 60}), 13)[0]).is.false; 16 + }); 17 + it(`Is close when within ${DEFAULT_CLOSE_POSITION_PERCENT} percent`, function() { 18 + expect(closeToPlayStart(generatePlay({duration: 300}), 40)[0]).is.true; 19 + }); 20 + it(`Is not close when greater than ${DEFAULT_CLOSE_POSITION_PERCENT} percent`, function() { 21 + expect(closeToPlayStart(generatePlay({duration: 60}), 15)[0]).is.false; 22 + }); 23 + it(`Is close when within ${DEFAULT_CLOSE_POSITION_ABSOLUTE} seconds and no duration`, function() { 24 + expect(closeToPlayStart(generatePlay({duration: undefined}), 10)[0]).is.true; 25 + }); 26 + 27 + }); 28 + 29 + describe('Close To End', function() { 30 + 31 + it(`Is not close when no duration`, function() { 32 + expect(closeToPlayEnd(generatePlay({duration: undefined}), 50)[0]).is.false; 33 + }); 34 + it(`Is close when within ${DEFAULT_CLOSE_POSITION_ABSOLUTE} seconds`, function() { 35 + expect(closeToPlayEnd(generatePlay({duration: 60}), 50)[0]).is.true; 36 + }); 37 + it(`Is not close when earlier than ${DEFAULT_CLOSE_POSITION_ABSOLUTE} seconds`, function() { 38 + expect(closeToPlayEnd(generatePlay({duration: 60}), 45)[0]).is.false; 39 + }); 40 + it(`Is close when within ${DEFAULT_CLOSE_POSITION_PERCENT} percent`, function() { 41 + expect(closeToPlayEnd(generatePlay({duration: 300}), 261)[0]).is.true; 42 + }); 43 + it(`Is not close when less than ${DEFAULT_CLOSE_POSITION_PERCENT} percent`, function() { 44 + expect(closeToPlayEnd(generatePlay({duration: 300}), 240)[0]).is.false; 45 + }); 46 + 47 + }); 48 + 49 + }); 50 + 51 + describe('Play Repeat Duration', function() { 52 + 53 + it(`Is valid for repeat when more than ${DEFAULT_DURATION_REPEAT_ABSOLUTE} seconds`, function() { 54 + expect(repeatDurationPlayed(generatePlay({duration: 300}), DEFAULT_DURATION_REPEAT_ABSOLUTE + 1)[0]).is.true; 55 + }); 56 + it(`Is not valid for repeat when less than ${DEFAULT_DURATION_REPEAT_ABSOLUTE} seconds`, function() { 57 + expect(repeatDurationPlayed(generatePlay({duration: 300}), 10)[0]).is.false; 58 + }); 59 + it(`Is valid for repeat when more than ${DEFAULT_DURATION_REPEAT_PERCENT} percent`, function() { 60 + expect(repeatDurationPlayed(generatePlay({duration: 60}), 40)[0]).is.true; 61 + }); 62 + it(`Is not valid for repeat when less than ${DEFAULT_DURATION_REPEAT_PERCENT} percent`, function() { 63 + expect(repeatDurationPlayed(generatePlay({duration: 60}), 15)[0]).is.false; 64 + }); 65 + it(`Is valid for repeat when more than ${DEFAULT_DURATION_REPEAT_ABSOLUTE} seconds and no duration`, function() { 66 + expect(repeatDurationPlayed(generatePlay({duration: undefined}), DEFAULT_DURATION_REPEAT_ABSOLUTE + 1)[0]).is.true; 67 + }); 68 + 69 + }); 70 + 71 + describe('Scrobble Threshold Checks', function() { 72 + 73 + it('uses defaults when no user-configured thresholds are passed', function() { 74 + const results = timePassesScrobbleThreshold({}, 1, 1); 75 + expect(results.duration.threshold).to.eq(DEFAULT_SCROBBLE_DURATION_THRESHOLD); 76 + expect(results.percent.threshold).to.eq(DEFAULT_SCROBBLE_PERCENT_THRESHOLD); 77 + }); 78 + 79 + it('uses user-configured thresholds when passed', function() { 80 + const results = timePassesScrobbleThreshold({ 81 + duration: 20, 82 + percent: 15 83 + }, 1, 1); 84 + expect(results.duration.threshold).to.eq(20); 85 + expect(results.percent.threshold).to.eq(15); 86 + }); 87 + 88 + it('passes when duration is above threshold', function() { 89 + const results = timePassesScrobbleThreshold({}, DEFAULT_SCROBBLE_DURATION_THRESHOLD + 1); 90 + expect(results.duration.passes).is.true; 91 + expect(results.passes).is.true; 92 + }); 93 + 94 + it('passes when percent is above threshold', function() { 95 + const results = timePassesScrobbleThreshold({}, 30, 50); 96 + expect(results.percent.passes).is.true; 97 + expect(results.passes).is.true; 98 + }); 99 + 100 + it('handles zero duration', function() { 101 + const results = timePassesScrobbleThreshold({}, DEFAULT_SCROBBLE_DURATION_THRESHOLD + 1, 0); 102 + expect(results.duration.passes).is.true; 103 + expect(results.passes).is.true; 104 + }); 105 + });
+82
src/backend/utils/TimeUtils.ts
··· 17 17 } from "../../core/Atomic.js"; 18 18 import { capitalize } from "../../core/StringUtils.js"; 19 19 import { 20 + DEFAULT_CLOSE_POSITION_ABSOLUTE, 21 + DEFAULT_CLOSE_POSITION_PERCENT, 22 + DEFAULT_DURATION_REPEAT_ABSOLUTE, 23 + DEFAULT_DURATION_REPEAT_PERCENT, 20 24 DEFAULT_SCROBBLE_DURATION_THRESHOLD, 21 25 DEFAULT_SCROBBLE_PERCENT_THRESHOLD, 22 26 lowGranularitySources, ··· 329 333 } 330 334 // EX 01:15:45 331 335 return new Date(ms).toISOString().substring(11, 19); 336 + } 337 + 338 + /** Is Position earlier than X seconds or Y% percent of the start of a Play? */ 339 + export const closeToPlayStart = (play: PlayObject, position: number, thresholds: {absolute?: number, percent?: number, hintPrefix?: boolean} = {}): [boolean, string] => { 340 + const { 341 + absolute = DEFAULT_CLOSE_POSITION_ABSOLUTE, 342 + percent = DEFAULT_CLOSE_POSITION_PERCENT, 343 + hintPrefix = true 344 + } = thresholds; 345 + 346 + let hintStart = hintPrefix ? `Position (${position})` : ''; 347 + const trackDur = play.data.duration; 348 + const closeStartNum = position <= absolute; 349 + const hints: string[] = []; 350 + hints.push(`${closeStartNum ? 'is' : 'is not'} within ${absolute}s of track start`); 351 + 352 + let closeStartPer = false; 353 + if(trackDur !== undefined) { 354 + const positionPercent = (position / trackDur); 355 + closeStartPer = (positionPercent <= percent); 356 + if(!closeStartNum) { 357 + hints.push(`${closeStartPer ? 'is' : 'is not'} within ${formatNumber(percent * 100, {toFixed: 0})}% of track start (${formatNumber(positionPercent*100)}%)`); 358 + } 359 + } 360 + 361 + return [closeStartNum || closeStartPer, `${hintStart} ${hints.join(' and ')}.`]; 362 + } 363 + 364 + /** Is Position closer than X seconds or Y% percent of the end of a Play? */ 365 + export const closeToPlayEnd = (play: PlayObject, position: number, thresholds: {absolute?: number, percent?: number, hintPrefix?: boolean} = {}): [boolean, string] => { 366 + const { 367 + absolute = DEFAULT_CLOSE_POSITION_ABSOLUTE, 368 + percent = DEFAULT_CLOSE_POSITION_PERCENT, 369 + hintPrefix = true 370 + } = thresholds; 371 + 372 + let hintStart = hintPrefix ? `Position (${position})` : ''; 373 + const trackDur = play.data.duration; 374 + 375 + if(trackDur === undefined) { 376 + return [false, `Cannot determine how close Position ${position} is to end of track because no duration data is available.`]; 377 + } 378 + 379 + const nearEndNum = trackDur - position <= absolute; 380 + const hints: string[] = []; 381 + hints.push(`${nearEndNum ? 'is' : 'is not'} within ${absolute}s of track end`); 382 + const positionPercent = 1 - (position / trackDur); 383 + const nearEndPer = (positionPercent < percent); 384 + if(!nearEndNum) { 385 + hints.push(`${nearEndPer ? 'is' : 'is not'} within ${formatNumber(percent * 100, {toFixed: 0})}% of track end (${formatNumber(positionPercent*100)}%)`); 386 + } 387 + return [nearEndNum || nearEndPer, `${hintStart} ${hints.join(' and ')}`]; 388 + } 389 + 390 + /** Has more than X seconds or Y% percent of Play duration been played? */ 391 + export const repeatDurationPlayed = (play: PlayObject, duration: number, thresholds: {absolute?: number, percent?: number, hintPrefix?: boolean} = {}): [boolean, string] => { 392 + const { 393 + absolute = DEFAULT_DURATION_REPEAT_ABSOLUTE, 394 + percent = DEFAULT_DURATION_REPEAT_PERCENT, 395 + hintPrefix = true 396 + } = thresholds; 397 + 398 + let hintStart = hintPrefix ? `Duration listened (${duration}s)` : ''; 399 + const trackDur = play.data.duration; 400 + const absPlayed = duration >= absolute; 401 + const hints: string[] = []; 402 + hints.push(`${absPlayed ? 'is' : 'is not'} more than ${absolute}s`); 403 + 404 + let majorityDurationPercent = false; 405 + if(trackDur !== undefined) { 406 + const durationPercent = (duration / trackDur); 407 + majorityDurationPercent = (durationPercent >= percent); 408 + if(!absPlayed) { 409 + hints.push(`${majorityDurationPercent ? 'is' : 'is not'} more than ${formatNumber(percent * 100, {toFixed: 0})}% of track duration (${formatNumber(durationPercent*100)}%)`); 410 + } 411 + } 412 + 413 + return [absPlayed || majorityDurationPercent, `${hintStart} ${hints.join(' and ')}.`]; 332 414 }