From 9682d4f8f928174079a7a0fb74916b831292b900 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 24 Oct 2024 14:53:30 +0000 Subject: [PATCH 1/3] refactor(player): Cleaner player update signature --- src/backend/common/infrastructure/Atomic.ts | 10 +- src/backend/sources/MemorySource.ts | 9 +- .../PlayerState/AbstractPlayerState.ts | 6 +- .../PlayerState/JellyfinPlayerState.ts | 10 +- src/backend/tests/player/player.test.ts | 142 +++++++++--------- src/core/Atomic.ts | 4 + 6 files changed, 98 insertions(+), 83 deletions(-) diff --git a/src/backend/common/infrastructure/Atomic.ts b/src/backend/common/infrastructure/Atomic.ts index 9af851fc..11b28b64 100644 --- a/src/backend/common/infrastructure/Atomic.ts +++ b/src/backend/common/infrastructure/Atomic.ts @@ -4,7 +4,7 @@ import { Dayjs } from "dayjs"; import { Request, Response } from "express"; import { NextFunction, ParamsDictionary, Query } from "express-serve-static-core"; import { FixedSizeList } from 'fixed-size-list'; -import { PlayMeta, PlayObject } from "../../../core/Atomic.js"; +import { isPlayObject, PlayMeta, PlayObject } from "../../../core/Atomic.js"; import TupleMap from "../TupleMap.js"; export type SourceType = @@ -122,13 +122,9 @@ export interface PlayerStateDataMaybePlay { timestamp?: Dayjs } -export const asPlayerStateData = (obj: object): obj is PlayerStateData => { - return 'platformId' in obj && 'play' in obj; -} +export const asPlayerStateData = (obj: object): obj is PlayerStateData => asPlayerStateDataMaybePlay(obj) && 'play' in obj && isPlayObject(obj.play) -export const asPlayerStateDataMaybePlay = (obj: object): obj is PlayerStateDataMaybePlay => { - return 'platformId' in obj; -} +export const asPlayerStateDataMaybePlay = (obj: object): obj is PlayerStateDataMaybePlay => 'platformId' in obj export interface FormatPlayObjectOptions { newFromSource?: boolean diff --git a/src/backend/sources/MemorySource.ts b/src/backend/sources/MemorySource.ts index 0b52b063..7a6043b6 100644 --- a/src/backend/sources/MemorySource.ts +++ b/src/backend/sources/MemorySource.ts @@ -167,7 +167,14 @@ export default class MemorySource extends AbstractSource { } incomingData = relevantDatas[0]; - const [currPlay, prevPlay] = asPlayerStateDataMaybePlay(incomingData) ? player.setState(incomingData.status, incomingData.play) : player.setState(undefined, incomingData); + let playerState: PlayerStateDataMaybePlay; + if(asPlayerStateDataMaybePlay(incomingData)) { + playerState = incomingData; + } else { + playerState = {play: incomingData, platformId: getPlatformIdFromData(incomingData)}; + } + + const [currPlay, prevPlay] = player.update(playerState); const candidate = prevPlay !== undefined ? prevPlay : currPlay; const playChanged = prevPlay !== undefined; diff --git a/src/backend/sources/PlayerState/AbstractPlayerState.ts b/src/backend/sources/PlayerState/AbstractPlayerState.ts index 77d528bf..eb8a8324 100644 --- a/src/backend/sources/PlayerState/AbstractPlayerState.ts +++ b/src/backend/sources/PlayerState/AbstractPlayerState.ts @@ -5,6 +5,7 @@ import { buildTrackString } from "../../../core/StringUtils.js"; import { CALCULATED_PLAYER_STATUSES, CalculatedPlayerStatus, + PlayerStateDataMaybePlay, PlayPlatformId, REPORTED_PLAYER_STATUSES, ReportedPlayerStatus, @@ -121,8 +122,11 @@ export abstract class AbstractPlayerState { return status !== 'paused' && status !== 'stopped'; } - setState(status?: ReportedPlayerStatus, play?: PlayObject, reportedTS?: Dayjs) { + update(state: PlayerStateDataMaybePlay, reportedTS?: Dayjs) { this.stateLastUpdatedAt = dayjs(); + + const {play, status} = state; + if (play !== undefined) { return this.setPlay(play, status, reportedTS); } else if (status !== undefined) { diff --git a/src/backend/sources/PlayerState/JellyfinPlayerState.ts b/src/backend/sources/PlayerState/JellyfinPlayerState.ts index 764608b2..4003c84f 100644 --- a/src/backend/sources/PlayerState/JellyfinPlayerState.ts +++ b/src/backend/sources/PlayerState/JellyfinPlayerState.ts @@ -1,6 +1,6 @@ import { Logger } from "@foxxmd/logging"; import { PlayObject } from "../../../core/Atomic.js"; -import { PlayPlatformId, ReportedPlayerStatus } from "../../common/infrastructure/Atomic.js"; +import { PlayerStateDataMaybePlay, PlayPlatformId, ReportedPlayerStatus } from "../../common/infrastructure/Atomic.js"; import { PlayerStateOptions } from "./AbstractPlayerState.js"; import { GenericPlayerState } from "./GenericPlayerState.js"; @@ -9,11 +9,11 @@ export class JellyfinPlayerState extends GenericPlayerState { super(logger, platformId, opts); } - setState(status?: ReportedPlayerStatus, play?: PlayObject) { - let stat: ReportedPlayerStatus = status; - if(status === undefined && play.meta?.event === 'PlaybackProgress') { + update(state: PlayerStateDataMaybePlay) { + let stat: ReportedPlayerStatus = state.status; + if(stat === undefined && state.play?.meta?.event === 'PlaybackProgress') { stat = 'playing'; } - return super.setState(stat, play); + return super.update({...state, status: stat}); } } diff --git a/src/backend/tests/player/player.test.ts b/src/backend/tests/player/player.test.ts index 6584256d..4e0bc110 100644 --- a/src/backend/tests/player/player.test.ts +++ b/src/backend/tests/player/player.test.ts @@ -7,7 +7,9 @@ import { CALCULATED_PLAYER_STATUSES, NO_DEVICE, NO_USER, - REPORTED_PLAYER_STATUSES + PlayerStateDataMaybePlay, + REPORTED_PLAYER_STATUSES, + SINGLE_USER_PLATFORM_ID } from "../../common/infrastructure/Atomic.js"; import { GenericPlayerState } from "../../sources/PlayerState/GenericPlayerState.js"; import { playObjDataMatch } from "../../utils.js"; @@ -17,6 +19,8 @@ const logger = loggerTest; const newPlay = generatePlay({duration: 300}); +const testState = (data: Omit): PlayerStateDataMaybePlay => ({...data, platformId: SINGLE_USER_PLATFORM_ID}); + describe('Basic player state', function () { it('Creates new play state when new', function () { @@ -25,7 +29,7 @@ describe('Basic player state', function () { assert.isUndefined(player.currentListenRange); assert.isUndefined(player.currentPlay); - player.setState(undefined, newPlay); + player.update(testState({play: newPlay})); assert.isDefined(player.currentListenRange); assert.isDefined(player.currentPlay); @@ -37,7 +41,7 @@ describe('Basic player state', function () { assert.isUndefined(player.currentListenRange); assert.isUndefined(player.currentPlay); - player.setState(undefined, newPlay); + player.update(testState({play: newPlay})); assert.isDefined(player.currentListenRange); assert.isDefined(player.currentPlay); @@ -47,12 +51,12 @@ describe('Basic player state', function () { 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.setState(undefined, newPlay); + 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.setState(undefined, nextPlay); + const [returnedPlay, prevPlay] = player.update(testState({play: nextPlay})); assert.isTrue(playObjDataMatch(prevPlay, newPlay)); assert.isTrue(playObjDataMatch(player.currentPlay, nextPlay)); @@ -64,10 +68,10 @@ 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.setState(undefined, newPlay); + player.update(testState({play: newPlay})); assert.equal(CALCULATED_PLAYER_STATUSES.unknown, player.calculatedStatus); - player.setState(undefined, newPlay, dayjs().add(10, 'seconds')); + player.update(testState({play: newPlay}), dayjs().add(10, 'seconds')); assert.equal(CALCULATED_PLAYER_STATUSES.playing, player.calculatedStatus); }); @@ -76,8 +80,8 @@ describe('Player status', function () { it('Calculated state is playing when source reports playing', function () { const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]); - player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay); - player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(10, 'seconds')); + 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); }); @@ -85,18 +89,18 @@ describe('Player status', function () { it('Calculated state is paused when source reports paused', function () { const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]); - player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay); - player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(10, 'seconds')); - player.setState(REPORTED_PLAYER_STATUSES.paused, newPlay, dayjs().add(20, 'seconds')); + 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.setState(REPORTED_PLAYER_STATUSES.playing, newPlay); - player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(10, 'seconds')); - player.setState(REPORTED_PLAYER_STATUSES.stopped, newPlay, dayjs().add(20, 'seconds')); + 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); }); @@ -110,10 +114,10 @@ describe('Player status', function () { const positioned = clone(newPlay); positioned.meta.trackProgressPosition = 3; - player.setState(undefined, positioned); + player.update(testState({play: positioned})); positioned.meta.trackProgressPosition = 13; - player.setState(undefined, positioned, dayjs().add(10, 'seconds')); + player.update(testState({play: positioned}), dayjs().add(10, 'seconds')); assert.equal(CALCULATED_PLAYER_STATUSES.playing, player.calculatedStatus); }); @@ -124,12 +128,12 @@ describe('Player status', function () { const positioned = clone(newPlay); positioned.meta.trackProgressPosition = 3; - player.setState(undefined, positioned); + player.update(testState({play: positioned})); positioned.meta.trackProgressPosition = 13; - player.setState(undefined, positioned, dayjs().add(10, 'seconds')); + player.update(testState({play: positioned}), dayjs().add(10, 'seconds')); - player.setState(undefined, positioned, dayjs().add(20, 'seconds')); + player.update(testState({play: positioned}), dayjs().add(20, 'seconds')); assert.equal(CALCULATED_PLAYER_STATUSES.paused, player.calculatedStatus); }); @@ -144,17 +148,17 @@ describe('Player listen ranges', function () { it('Duration is timestamp based for unknown/playing reported players', function () { const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]); - player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay); - player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(10, 'seconds')); - player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(20, 'seconds')); + 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.setState(undefined, newPlay); - uplayer.setState(undefined, newPlay, dayjs().add(10, 'seconds')); - uplayer.setState(undefined, newPlay, dayjs().add(20, 'seconds')); + 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); }); @@ -162,11 +166,11 @@ describe('Player listen ranges', function () { it('Range ends if player reports paused', function () { const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]); - player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay); - player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(10, 'seconds')); - player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(20, 'seconds')); - player.setState(REPORTED_PLAYER_STATUSES.paused, newPlay, dayjs().add(30, 'seconds')); - player.setState(REPORTED_PLAYER_STATUSES.paused, newPlay, dayjs().add(40, 'seconds')); + 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); }); @@ -174,15 +178,15 @@ describe('Player listen ranges', function () { it('Listen duration continues when player resumes', function () { const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]); - player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay); - player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(10, 'seconds')); - player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(20, 'seconds')); - player.setState(REPORTED_PLAYER_STATUSES.paused, newPlay, dayjs().add(30, 'seconds')); - player.setState(REPORTED_PLAYER_STATUSES.paused, newPlay, dayjs().add(40, 'seconds')); + 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.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(50, 'seconds')); - player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(60, 'seconds')); + 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); }); @@ -195,10 +199,10 @@ describe('Player listen ranges', function () { const positioned = clone(newPlay); positioned.meta.trackProgressPosition = 3; - player.setState(undefined, positioned); + player.update(testState({play: positioned})); positioned.meta.trackProgressPosition = 10; - player.setState(undefined, positioned, dayjs().add(10, 'seconds')); + player.update(testState({play: positioned}), dayjs().add(10, 'seconds')); assert.equal(player.getListenDuration(), 7); }); @@ -208,10 +212,10 @@ describe('Player listen ranges', function () { const positioned = clone(newPlay); positioned.meta.trackProgressPosition = 3; - player.setState(undefined, positioned); + player.update(testState({play: positioned})); positioned.meta.trackProgressPosition = 3; - player.setState(undefined, positioned, dayjs().add(10, 'seconds')); + player.update(testState({play: positioned}), dayjs().add(10, 'seconds')); assert.equal(player.getListenDuration(), 0); }); @@ -221,18 +225,18 @@ describe('Player listen ranges', function () { const positioned = clone(newPlay); positioned.meta.trackProgressPosition = 3; - player.setState(undefined, positioned); + player.update(testState({play: positioned})); positioned.meta.trackProgressPosition = 7; - player.setState(undefined, positioned, dayjs().add(10, 'seconds')); + player.update(testState({play: positioned}), dayjs().add(10, 'seconds')); - player.setState(undefined, positioned, dayjs().add(20, 'seconds')); + player.update(testState({play: positioned}), dayjs().add(20, 'seconds')); positioned.meta.trackProgressPosition = 17; - player.setState(undefined, positioned, dayjs().add(30, 'seconds')); + player.update(testState({play: positioned}), dayjs().add(30, 'seconds')); positioned.meta.trackProgressPosition = 27; - player.setState(undefined, positioned, dayjs().add(40, 'seconds')); + player.update(testState({play: positioned}), dayjs().add(40, 'seconds')); assert.equal(player.getListenDuration(), 24); }); @@ -244,18 +248,18 @@ describe('Player listen ranges', function () { const positioned = clone(newPlay); positioned.meta.trackProgressPosition = 3; - player.setState(undefined, positioned); + player.update(testState({play: positioned})); positioned.meta.trackProgressPosition = 13; - player.setState(undefined, positioned, dayjs().add(10, 'seconds')); + player.update(testState({play: positioned}), dayjs().add(10, 'seconds')); positioned.meta.trackProgressPosition = 30; - player.setState(undefined, positioned, dayjs().add(20, 'seconds')); + player.update(testState({play: positioned}), dayjs().add(20, 'seconds')); assert.equal(player.currentListenRange.start.timestamp, player.currentListenRange.end.timestamp); positioned.meta.trackProgressPosition = 40; - player.setState(undefined, positioned, dayjs().add(30, 'seconds')); + player.update(testState({play: positioned}), dayjs().add(30, 'seconds')); assert.equal(player.getListenDuration(), 20); }); @@ -265,18 +269,18 @@ describe('Player listen ranges', function () { const positioned = clone(newPlay); positioned.meta.trackProgressPosition = 30; - player.setState(undefined, positioned); + player.update(testState({play: positioned})); positioned.meta.trackProgressPosition = 40; - player.setState(undefined, positioned, dayjs().add(10, 'seconds')); + player.update(testState({play: positioned}), dayjs().add(10, 'seconds')); positioned.meta.trackProgressPosition = 20; - player.setState(undefined, positioned, dayjs().add(20, 'seconds')); + player.update(testState({play: positioned}), dayjs().add(20, 'seconds')); assert.equal(player.currentListenRange.start.timestamp, player.currentListenRange.end.timestamp); positioned.meta.trackProgressPosition = 30; - player.setState(undefined, positioned, dayjs().add(30, 'seconds')); + player.update(testState({play: positioned}), dayjs().add(30, 'seconds')); assert.equal(player.getListenDuration(), 20); }); @@ -290,16 +294,16 @@ describe('Player listen ranges', function () { const positioned = clone(newPlay); positioned.data.duration = 70; positioned.meta.trackProgressPosition = 45; - player.setState(undefined, positioned); + player.update(testState({play: positioned})); positioned.meta.trackProgressPosition = 55; - player.setState(undefined, positioned, dayjs().add(10, 'seconds')); + player.update(testState({play: positioned}), dayjs().add(10, 'seconds')); positioned.meta.trackProgressPosition = 65; - player.setState(undefined, positioned, dayjs().add(20, 'seconds')); + player.update(testState({play: positioned}), dayjs().add(20, 'seconds')); positioned.meta.trackProgressPosition = 5; - const [curr, prevPlay] = player.setState(undefined, positioned, dayjs().add(30, 'seconds')); + const [curr, prevPlay] = player.update(testState({play: positioned}), dayjs().add(30, 'seconds')); assert.isDefined(prevPlay); assert.equal(player.getListenDuration(), 0); @@ -311,16 +315,16 @@ describe('Player listen ranges', function () { const positioned = clone(newPlay); positioned.data.duration = 300; positioned.meta.trackProgressPosition = 351; - player.setState(undefined, positioned); + player.update(testState({play: positioned})); positioned.meta.trackProgressPosition = 361; - player.setState(undefined, positioned, dayjs().add(10, 'seconds')); + player.update(testState({play: positioned}), dayjs().add(10, 'seconds')); positioned.meta.trackProgressPosition = 371; - player.setState(undefined, positioned, dayjs().add(20, 'seconds')); + player.update(testState({play: positioned}), dayjs().add(20, 'seconds')); positioned.meta.trackProgressPosition = 20; - const [curr, prevPlay] = player.setState(undefined, positioned, dayjs().add(30, 'seconds')); + const [curr, prevPlay] = player.update(testState({play: positioned}), dayjs().add(30, 'seconds')); assert.isDefined(prevPlay); assert.equal(player.getListenDuration(), 0); @@ -332,22 +336,22 @@ describe('Player listen ranges', function () { const positioned = clone(newPlay); positioned.data.duration = 70; positioned.meta.trackProgressPosition = 0; - player.setState(undefined, positioned); + player.update(testState({play: positioned})); positioned.meta.trackProgressPosition = 10; - player.setState(undefined, positioned, dayjs().add(10, 'seconds')); + player.update(testState({play: positioned}), dayjs().add(10, 'seconds')); positioned.meta.trackProgressPosition = 20; - player.setState(undefined, positioned, dayjs().add(20, 'seconds')); + player.update(testState({play: positioned}), dayjs().add(20, 'seconds')); positioned.meta.trackProgressPosition = 30; - player.setState(undefined, positioned, dayjs().add(30, 'seconds')); + player.update(testState({play: positioned}), dayjs().add(30, 'seconds')); positioned.meta.trackProgressPosition = 40; - player.setState(undefined, positioned, dayjs().add(40, 'seconds')); + player.update(testState({play: positioned}), dayjs().add(40, 'seconds')); positioned.meta.trackProgressPosition = 2; - const [curr, prevPlay] = player.setState(undefined, positioned, dayjs().add(50, 'seconds')); + const [curr, prevPlay] = player.update(testState({play: positioned}), dayjs().add(50, 'seconds')); assert.isDefined(prevPlay); assert.equal(player.getListenDuration(), 0); diff --git a/src/core/Atomic.ts b/src/core/Atomic.ts index f3380d6c..5bd37f01 100644 --- a/src/core/Atomic.ts +++ b/src/core/Atomic.ts @@ -168,6 +168,10 @@ export interface AmbPlayObject { meta: PlayMeta } +export const isPlayObject = (obj: object): obj is PlayObject => { + return 'data' in obj && typeof obj.data === 'object' && 'meta' in obj && typeof obj.meta === 'object'; +} + export interface PlayObject extends AmbPlayObject { data: ObjectPlayData, } From 0c9b4961371b3d2b2f6e4a6d3011617be6f0f618 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 24 Oct 2024 20:37:41 +0000 Subject: [PATCH 2/3] feat(player): Implement real-time positional state tracking * Introduce positional and non-positional data structures * Refactor state player into types of each * Refactor listen ranges and progress into types of each * Implment (internal) real-time player and base positional player seeked/repeat on real-time drift instead of just reported position * Refactor tests to use (emulated) real-time components --- src/backend/sources/ChromecastSource.ts | 4 +- src/backend/sources/JRiverSource.ts | 4 +- src/backend/sources/JellyfinApiSource.ts | 7 +- src/backend/sources/KodiSource.ts | 4 +- src/backend/sources/MPDSource.ts | 4 +- src/backend/sources/MemoryPositionalSource.ts | 9 + src/backend/sources/MemorySource.ts | 5 +- src/backend/sources/MopidySource.ts | 4 +- src/backend/sources/MusikcubeSource.ts | 4 +- .../PlayerState/AbstractPlayerState.ts | 139 +++++-------- .../sources/PlayerState/GenericPlayerState.ts | 33 ++- .../PlayerState/JellyfinPlayerState.ts | 3 +- .../sources/PlayerState/ListenProgress.ts | 43 +++- .../sources/PlayerState/ListenRange.ts | 171 +++++++++++++--- .../sources/PlayerState/PlexPlayerState.ts | 7 +- .../PlayerState/PositionalPlayerState.ts | 114 +++++++++++ .../sources/PlayerState/RealtimePlayer.ts | 23 ++- .../PlayerState/RealtimePlayerState.ts | 44 ++++ src/backend/sources/PlexApiSource.ts | 4 +- src/backend/sources/SpotifySource.ts | 4 +- src/backend/sources/VLCSource.ts | 4 +- src/backend/tests/player/player.test.ts | 188 +++++++++--------- src/core/Atomic.ts | 4 + 23 files changed, 560 insertions(+), 266 deletions(-) create mode 100644 src/backend/sources/MemoryPositionalSource.ts create mode 100644 src/backend/sources/PlayerState/PositionalPlayerState.ts create mode 100644 src/backend/sources/PlayerState/RealtimePlayerState.ts diff --git a/src/backend/sources/ChromecastSource.ts b/src/backend/sources/ChromecastSource.ts index ad0cd367..db47a8f2 100644 --- a/src/backend/sources/ChromecastSource.ts +++ b/src/backend/sources/ChromecastSource.ts @@ -34,7 +34,7 @@ import { difference, genGroupIdStr, parseBool } from "../utils.js"; import { findCauseByReference } from "../utils/ErrorUtils.js"; import { discoveryAvahi, discoveryNative } from "../utils/MDNSUtils.js"; import { RecentlyPlayedOptions } from "./AbstractSource.js"; -import MemorySource from "./MemorySource.js"; +import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; interface ChromecastDeviceInfo { mdns: MdnsDeviceInfo @@ -46,7 +46,7 @@ interface ChromecastDeviceInfo { applications: Map } -export class ChromecastSource extends MemorySource { +export class ChromecastSource extends MemoryPositionalSource { declare config: ChromecastSourceConfig; diff --git a/src/backend/sources/JRiverSource.ts b/src/backend/sources/JRiverSource.ts index 43df3f2d..3bb5e5ec 100644 --- a/src/backend/sources/JRiverSource.ts +++ b/src/backend/sources/JRiverSource.ts @@ -7,9 +7,9 @@ import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructur import { JRiverSourceConfig } from "../common/infrastructure/config/source/jriver.js"; import { Info, JRiverApiClient, PLAYER_STATE } from "../common/vendor/JRiverApiClient.js"; import { RecentlyPlayedOptions } from "./AbstractSource.js"; -import MemorySource from "./MemorySource.js"; +import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; -export class JRiverSource extends MemorySource { +export class JRiverSource extends MemoryPositionalSource { declare config: JRiverSourceConfig; url: URL; diff --git a/src/backend/sources/JellyfinApiSource.ts b/src/backend/sources/JellyfinApiSource.ts index e09f38dd..c60dde83 100644 --- a/src/backend/sources/JellyfinApiSource.ts +++ b/src/backend/sources/JellyfinApiSource.ts @@ -52,13 +52,11 @@ import { import { JellyApiSourceConfig } from "../common/infrastructure/config/source/jellyfin.js"; import { combinePartsToString, genGroupIdStr, getPlatformIdFromData, joinedUrl, parseBool, } from "../utils.js"; import { parseArrayFromMaybeString } from "../utils/StringUtils.js"; -import MemorySource from "./MemorySource.js"; -import { PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js"; -import { JellyfinPlayerState } from "./PlayerState/JellyfinPlayerState.js"; +import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; const shortDeviceId = truncateStringToLength(10, ''); -export default class JellyfinApiSource extends MemorySource { +export default class JellyfinApiSource extends MemoryPositionalSource { users: string[] = []; client: Jellyfin @@ -476,7 +474,6 @@ export default class JellyfinApiSource extends MemorySource { } } - getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new JellyfinPlayerState(logger, id, opts) } /** diff --git a/src/backend/sources/KodiSource.ts b/src/backend/sources/KodiSource.ts index 33e29c1e..b266f372 100644 --- a/src/backend/sources/KodiSource.ts +++ b/src/backend/sources/KodiSource.ts @@ -4,9 +4,9 @@ import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructur import { KodiSourceConfig } from "../common/infrastructure/config/source/kodi.js"; import { KodiApiClient } from "../common/vendor/KodiApiClient.js"; import { RecentlyPlayedOptions } from "./AbstractSource.js"; -import MemorySource from "./MemorySource.js"; +import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; -export class KodiSource extends MemorySource { +export class KodiSource extends MemoryPositionalSource { declare config: KodiSourceConfig; client: KodiApiClient; diff --git a/src/backend/sources/MPDSource.ts b/src/backend/sources/MPDSource.ts index 9329c5fb..37e822b3 100644 --- a/src/backend/sources/MPDSource.ts +++ b/src/backend/sources/MPDSource.ts @@ -19,7 +19,7 @@ import { } from "../common/infrastructure/config/source/mpd.js"; import { isPortReachable } from "../utils/NetworkUtils.js"; import { RecentlyPlayedOptions } from "./AbstractSource.js"; -import MemorySource from "./MemorySource.js"; +import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; const mpdClient = mpdapiNS.default; @@ -29,7 +29,7 @@ const CLIENT_PLAYER_STATE: Record = { 'stop': REPORTED_PLAYER_STATUSES.stopped, } -export class MPDSource extends MemorySource { +export class MPDSource extends MemoryPositionalSource { declare config: MPDSourceConfig; host?: string diff --git a/src/backend/sources/MemoryPositionalSource.ts b/src/backend/sources/MemoryPositionalSource.ts new file mode 100644 index 00000000..c6e8d2d7 --- /dev/null +++ b/src/backend/sources/MemoryPositionalSource.ts @@ -0,0 +1,9 @@ +import { Logger } from "@foxxmd/logging"; +import { PlayPlatformId } from "../common/infrastructure/Atomic.js"; +import MemorySource from "./MemorySource.js"; +import { PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js"; +import { PositionalPlayerState } from "./PlayerState/PositionalPlayerState.js"; + +export class MemoryPositionalSource extends MemorySource { + getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new PositionalPlayerState(logger, id, opts) +} \ No newline at end of file diff --git a/src/backend/sources/MemorySource.ts b/src/backend/sources/MemorySource.ts index 7a6043b6..ead84e03 100644 --- a/src/backend/sources/MemorySource.ts +++ b/src/backend/sources/MemorySource.ts @@ -90,7 +90,7 @@ export default class MemorySource extends AbstractSource { return record; } - getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new GenericPlayerState(logger, id, opts) + getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions): AbstractPlayerState => new GenericPlayerState(logger, id, opts) setNewPlayer = (idStr: string, logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions = {}) => { this.players.set(idStr, this.getNewPlayer(this.logger, id, { @@ -173,6 +173,9 @@ export default class MemorySource extends AbstractSource { } else { playerState = {play: incomingData, platformId: getPlatformIdFromData(incomingData)}; } + if(playerState.position === undefined && playerState.play !== undefined && playerState.play.meta.trackProgressPosition !== undefined) { + playerState.position = playerState.play.meta?.trackProgressPosition; + } const [currPlay, prevPlay] = player.update(playerState); const candidate = prevPlay !== undefined ? prevPlay : currPlay; diff --git a/src/backend/sources/MopidySource.ts b/src/backend/sources/MopidySource.ts index 30b8ed08..70916719 100644 --- a/src/backend/sources/MopidySource.ts +++ b/src/backend/sources/MopidySource.ts @@ -15,9 +15,9 @@ import { } from "../common/infrastructure/Atomic.js"; import { MopidySourceConfig } from "../common/infrastructure/config/source/mopidy.js"; import { RecentlyPlayedOptions } from "./AbstractSource.js"; -import MemorySource from "./MemorySource.js"; +import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; -export class MopidySource extends MemorySource { +export class MopidySource extends MemoryPositionalSource { declare config: MopidySourceConfig; albumBlacklist: string[] = []; diff --git a/src/backend/sources/MusikcubeSource.ts b/src/backend/sources/MusikcubeSource.ts index bc8094a5..d39bdc75 100644 --- a/src/backend/sources/MusikcubeSource.ts +++ b/src/backend/sources/MusikcubeSource.ts @@ -22,7 +22,7 @@ import { } from "../common/infrastructure/config/source/musikcube.js"; import { sleep } from "../utils.js"; import { RecentlyPlayedOptions } from "./AbstractSource.js"; -import MemorySource from "./MemorySource.js"; +import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; const CLIENT_STATE = { 0: 'connecting', @@ -31,7 +31,7 @@ const CLIENT_STATE = { 3: 'closed' } -export class MusikcubeSource extends MemorySource { +export class MusikcubeSource extends MemoryPositionalSource { declare config: MusikcubeSourceConfig; url: URL; diff --git a/src/backend/sources/PlayerState/AbstractPlayerState.ts b/src/backend/sources/PlayerState/AbstractPlayerState.ts index eb8a8324..895b795b 100644 --- a/src/backend/sources/PlayerState/AbstractPlayerState.ts +++ b/src/backend/sources/PlayerState/AbstractPlayerState.ts @@ -1,10 +1,12 @@ import { childLogger, Logger } from "@foxxmd/logging"; import dayjs, { Dayjs } from "dayjs"; -import { PlayObject, Second, SOURCE_SOT, SOURCE_SOT_TYPES, SourcePlayerObj } from "../../../core/Atomic.js"; +import { PlayObject, PlayProgress, Second, SOURCE_SOT, SOURCE_SOT_TYPES, SourcePlayerObj } from "../../../core/Atomic.js"; import { buildTrackString } from "../../../core/StringUtils.js"; import { + asPlayerStateData, CALCULATED_PLAYER_STATUSES, CalculatedPlayerStatus, + PlayerStateData, PlayerStateDataMaybePlay, PlayPlatformId, REPORTED_PLAYER_STATUSES, @@ -13,7 +15,7 @@ import { import { PollingOptions } from "../../common/infrastructure/config/common.js"; import { formatNumber, genGroupIdStr, playObjDataMatch, progressBar } from "../../utils.js"; import { ListenProgress } from "./ListenProgress.js"; -import { ListenRange } from "./ListenRange.js"; +import { ListenRange, ListenRangePositional } from "./ListenRange.js"; export interface PlayerStateIntervals { staleInterval?: number @@ -21,6 +23,8 @@ export interface PlayerStateIntervals { } export interface PlayerStateOptions extends PlayerStateIntervals { + allowedDrift?: number + rtTruth?: boolean } export const DefaultPlayerStateOptions: PlayerStateOptions = {}; @@ -58,8 +62,6 @@ export abstract class AbstractPlayerState { createdAt: Dayjs = dayjs(); stateLastUpdatedAt: Dayjs = dayjs(); - protected allowedDrift?: number; - protected constructor(logger: Logger, platformId: PlayPlatformId, opts: PlayerStateOptions = DefaultPlayerStateOptions) { this.platformId = platformId; this.logger = childLogger(logger, `Player ${this.platformIdStr}`); @@ -71,6 +73,9 @@ export abstract class AbstractPlayerState { this.stateIntervalOptions = {staleInterval, orphanedInterval: orphanedInterval}; } + protected abstract newListenProgress(data?: Partial): ListenProgress; + protected abstract newListenRange(start?: ListenProgress, end?: ListenProgress, options?: object): ListenRange; + get platformIdStr() { return genGroupIdStr(this.platformId); } @@ -126,10 +131,12 @@ export abstract class AbstractPlayerState { this.stateLastUpdatedAt = dayjs(); const {play, status} = state; - - if (play !== undefined) { - return this.setPlay(play, status, reportedTS); - } else if (status !== undefined) { + + if (asPlayerStateData(state)) { + return this.setPlay(state, reportedTS); + } + + if (status !== undefined) { if (status === 'stopped' && this.reportedStatus !== 'stopped' && this.currentPlay !== undefined) { this.stopPlayer(); const play = this.getPlayedObject(true); @@ -143,19 +150,19 @@ export abstract class AbstractPlayerState { return []; } - protected setPlay(play: PlayObject, status?: ReportedPlayerStatus, reportedTS?: Dayjs): [PlayObject, PlayObject?] { + protected setPlay(state: PlayerStateData, reportedTS?: Dayjs): [PlayObject, PlayObject?] { + const {play, status} = state; this.playLastUpdatedAt = dayjs(); if (status !== undefined) { this.reportedStatus = status; } if (this.currentPlay !== undefined) { - const currentPlayMatches = playObjDataMatch(this.currentPlay, play); - if (!currentPlayMatches) { // TODO check new play date and listen range to see if they intersect + if (!this.incomingPlayMatchesExisting(play)) { // TODO check new play date and listen range to see if they intersect this.logger.debug(`Incoming play state (${buildTrackString(play, {include: ['trackId', 'artist', 'track']})}) does not match existing state, removing existing: ${buildTrackString(this.currentPlay, {include: ['trackId', 'artist', 'track']})}`) this.currentListenSessionEnd(); const played = this.getPlayedObject(true); - this.setCurrentPlay(play, {reportedTS}); + this.setCurrentPlay(state, {reportedTS}); if (this.calculatedStatus !== CALCULATED_PLAYER_STATUSES.playing) { this.calculatedStatus = CALCULATED_PLAYER_STATUSES.unknown; } @@ -163,27 +170,30 @@ export abstract class AbstractPlayerState { } else if (status !== undefined && !AbstractPlayerState.isProgressStatus(status)) { this.currentListenSessionEnd(); this.calculatedStatus = this.reportedStatus; - } else if (this.isSessionRepeat(play.meta.trackProgressPosition, reportedTS)) { + } else if (this.isSessionRepeat(state.position, reportedTS)) { // if we detect the track has been restarted end listen session and treat as a new play this.currentListenSessionEnd(); const played = this.getPlayedObject(true); play.data.playDate = dayjs(); - this.setCurrentPlay(play, {reportedTS}); + this.setCurrentPlay(state, {reportedTS}); return [this.getPlayedObject(), played]; } else { if(this.currentListenRange !== undefined) { - const [isSeeked, seekedPos] = this.currentListenRange.seeked(play.meta.trackProgressPosition, reportedTS); + const [isSeeked, seekedPos] = this.currentListenRange.seeked(state.position, reportedTS); if (isSeeked !== false) { - this.logger.debug(`Detected player was seeked ${seekedPos.toFixed(2)}s, starting new listen range`); + this.logger.verbose(`Detected player was seeked ${(seekedPos / 1000).toFixed(2)}s, starting new listen range`); + if(state.position !== undefined && (this.currentListenRange as ListenRangePositional).end.position === state.position) { + this.calculatedStatus = CALCULATED_PLAYER_STATUSES.paused; + } // if player has been seeked start a new listen range so our numbers don't get all screwy this.currentListenSessionEnd(); } } - this.currentListenSessionContinue(play.meta.trackProgressPosition, reportedTS); + this.currentListenSessionContinue(state.position, reportedTS); } } else { - this.setCurrentPlay(play); + this.setCurrentPlay(state); this.calculatedStatus = CALCULATED_PLAYER_STATUSES.unknown; } @@ -194,6 +204,8 @@ export abstract class AbstractPlayerState { return [this.getPlayedObject(), undefined]; } + protected incomingPlayMatchesExisting(play: PlayObject): boolean { return playObjDataMatch(this.currentPlay, play); } + protected clearPlayer() { this.currentPlay = undefined; this.playLastUpdatedAt = undefined; @@ -244,65 +256,9 @@ export abstract class AbstractPlayerState { return listenDur; } - protected currentListenSessionContinue(position?: number, timestamp?: Dayjs) { - const now = dayjs(); - if (this.currentListenRange === undefined) { - this.logger.debug('Started new Player listen range.'); - let usedPosition = position; - if(this.calculatedStatus === CALCULATED_PLAYER_STATUSES.playing && position !== undefined && position <= 3) { - // likely the player has moved to a new track from a previous track (still calculated as playing) - // and polling/network delays means we did not catch absolute beginning of track - usedPosition = 1; - } - this.currentListenRange = new ListenRange(new ListenProgress(timestamp, usedPosition), undefined, this.allowedDrift); - } else { - const oldEndProgress = this.currentListenRange.end; - const newEndProgress = new ListenProgress(timestamp, position); - if (position !== undefined && oldEndProgress !== undefined) { - if (!this.isSessionStillPlaying(position) && !['paused', 'stopped'].includes(this.calculatedStatus)) { - this.calculatedStatus = this.reportedStatus === 'stopped' ? CALCULATED_PLAYER_STATUSES.stopped : CALCULATED_PLAYER_STATUSES.paused; - if (this.reportedStatus !== this.calculatedStatus) { - this.logger.debug(`Reported status '${this.reportedStatus}' but track position has not progressed between two updates. Calculated player status is now ${this.calculatedStatus}`); - } else { - this.logger.debug(`Player position is equal between current -> last update. Updated calculated status to ${this.calculatedStatus}`); - } - } else if (position !== oldEndProgress.position && this.calculatedStatus !== 'playing') { - this.calculatedStatus = CALCULATED_PLAYER_STATUSES.playing; - if (this.reportedStatus !== this.calculatedStatus) { - this.logger.debug(`Reported status '${this.reportedStatus}' but track position has progressed between two updates. Calculated player status is now ${this.calculatedStatus}`); - } else { - this.logger.debug(`Player position changed between current -> last update. Updated calculated status to ${this.calculatedStatus}`); - } - } - } else { - this.calculatedStatus = CALCULATED_PLAYER_STATUSES.playing; - } - this.currentListenRange.setRangeEnd(newEndProgress); - } - } + protected abstract currentListenSessionContinue(position?: number | undefined, timestamp?: Dayjs); - protected abstract isSessionStillPlaying(position: number): boolean; - - protected currentListenSessionEnd() { - if (this.currentListenRange !== undefined && this.currentListenRange.getDuration() !== 0) { - this.logger.debug('Ended current Player listen range.') - if(this.calculatedStatus === CALCULATED_PLAYER_STATUSES.playing && this.currentListenRange.isPositional() && !this.currentListenRange.isInitial()) { - const { - data: { - duration, - } = {} - } = this.currentPlay; - if(duration !== undefined && (duration - this.currentListenRange.end.position) < 3) { - // likely the track was listened to until it ended - // but polling interval or network delays caused MS to not get data on the very end - // also...within 3 seconds of ending is close enough to call this complete IMO - this.currentListenRange.end.position = duration; - } - } - this.listenRanges.push(this.currentListenRange); - } - this.currentListenRange = undefined; - } + protected abstract currentListenSessionEnd(); protected isSessionRepeat(position?: number, reportedTS?: Dayjs) { if(this.currentListenRange === undefined) { @@ -335,11 +291,11 @@ export abstract class AbstractPlayerState { repeatHint = `${repeatHint} and listened to more than 50% (${formatNumber((playerDur/trackDur)*100)}%).` } if (closeDurNum || closeDurPer) { - this.logger.debug(repeatHint); + this.logger.verbose(repeatHint); return true; } - if (trackDur !== undefined) { - const lastPos = this.currentListenRange.end.position; + if (trackDur !== undefined && this.currentListenRange.getPosition() !== undefined) { + const lastPos = this.currentListenRange.getPosition(); // or last position is within 10 seconds (or 10%) of end of track const nearEndNum = (trackDur - lastPos < 12); if(nearEndNum) { @@ -350,7 +306,7 @@ export abstract class AbstractPlayerState { repeatHint = `${repeatHint} and previous position was within 15% of track end (${formatNumber((lastPos/trackDur)*100)}%)`; } if(nearEndNum || nearEndPos) { - this.logger.debug(repeatHint); + this.logger.verbose(repeatHint); return true; } } @@ -358,7 +314,7 @@ export abstract class AbstractPlayerState { return false; } - protected setCurrentPlay(play: PlayObject, options?: CurrentPlayOptions) { + protected setCurrentPlay(state: PlayerStateData, options?: CurrentPlayOptions) { const { status, @@ -366,6 +322,8 @@ export abstract class AbstractPlayerState { listenSessionManaged = true } = options || {}; + const {play, position} = state; + this.currentPlay = play; this.playFirstSeenAt = dayjs(); this.listenRanges = []; @@ -378,7 +336,7 @@ export abstract class AbstractPlayerState { } if (listenSessionManaged && !['stopped'].includes(this.reportedStatus)) { - this.currentListenSessionContinue(play.meta.trackProgressPosition, reportedTS); + this.currentListenSessionContinue(position, reportedTS); } } @@ -390,7 +348,7 @@ export abstract class AbstractPlayerState { } parts.push(`Reported: ${this.reportedStatus.toUpperCase()} | Calculated: ${this.calculatedStatus.toUpperCase()} | Stale: ${this.isUpdateStale() ? 'Yes' : 'No'} | Orphaned: ${this.isOrphaned() ? 'Yes' : 'No'} | Last Update: ${this.stateLastUpdatedAt.toISOString()}`); let progress = ''; - if (this.currentListenRange !== undefined && this.currentListenRange.end.position !== undefined && this.currentPlay.data.duration !== undefined) { + if (this.currentListenRange !== undefined && this.currentListenRange instanceof ListenRangePositional && this.currentPlay.data.duration !== undefined) { progress = `${progressBar(this.currentListenRange.end.position / this.currentPlay.data.duration, 1, 15)} ${formatNumber(this.currentListenRange.end.position, {toFixed: 0})}/${formatNumber(this.currentPlay.data.duration, {toFixed: 0})}s | `; } let listenedPercent = ''; @@ -408,20 +366,17 @@ export abstract class AbstractPlayerState { this.logger.debug(this.textSummary()); } - public getPosition(): Second { + public getPosition(): Second | undefined { if(this.calculatedStatus === 'stopped') { return undefined; } - let lastRange: ListenRange | undefined; if(this.currentListenRange !== undefined) { - lastRange = this.currentListenRange; - } else if(this.listenRanges.length > 0) { - lastRange = this.listenRanges[this.listenRanges.length - 1]; + return this.currentListenRange.getPosition(); } - if(lastRange === undefined || lastRange.end === undefined || lastRange.end.position === undefined) { - return undefined; + if(this.listenRanges.length > 0) { + return this.listenRanges[this.listenRanges.length - 1].getPosition(); } - return lastRange.end.position; + return undefined; } public getApiState(): SourcePlayerObj { @@ -446,7 +401,7 @@ export abstract class AbstractPlayerState { this.logger.debug(`Transferring state to new Player (${newPlayer.platformIdStr})`); newPlayer.calculatedStatus = this.calculatedStatus; if(this.currentPlay !== undefined) { - newPlayer.setCurrentPlay(this.currentPlay, {status: this.reportedStatus, listenSessionManaged: false}); + newPlayer.setCurrentPlay({play: this.currentPlay, platformId: this.platformId}, {status: this.reportedStatus, listenSessionManaged: false}); } newPlayer.currentListenRange = this.currentListenRange; newPlayer.listenRanges = this.listenRanges; diff --git a/src/backend/sources/PlayerState/GenericPlayerState.ts b/src/backend/sources/PlayerState/GenericPlayerState.ts index 265077db..e5fffd0e 100644 --- a/src/backend/sources/PlayerState/GenericPlayerState.ts +++ b/src/backend/sources/PlayerState/GenericPlayerState.ts @@ -1,13 +1,40 @@ import { Logger } from "@foxxmd/logging"; -import { PlayPlatformId } from "../../common/infrastructure/Atomic.js"; +import { CALCULATED_PLAYER_STATUSES, PlayPlatformId } from "../../common/infrastructure/Atomic.js"; import { AbstractPlayerState, PlayerStateOptions } from "./AbstractPlayerState.js"; +import { PlayProgress } from "../../../core/Atomic.js"; +import { ListenProgress, ListenProgressTS } from "./ListenProgress.js"; +import { ListenRange, ListenRangeTS } from "./ListenRange.js"; +import { Dayjs } from "dayjs"; export class GenericPlayerState extends AbstractPlayerState { + protected newListenProgress(data?: Partial): ListenProgress { + return new ListenProgressTS(data); + } + + protected newListenRange(start?: ListenProgress, end?: ListenProgress, options?: object): ListenRange { + return new ListenRangeTS(start, end); + } + constructor(logger: Logger, platformId: PlayPlatformId, opts?: PlayerStateOptions) { super(logger, platformId, opts); } + + protected currentListenSessionContinue(position?: number, timestamp?: Dayjs) { + if (this.currentListenRange === undefined) { + this.logger.debug('Started new Player listen range.'); + this.currentListenRange = this.newListenRange(this.newListenProgress({timestamp})); + } else { + this.calculatedStatus = CALCULATED_PLAYER_STATUSES.playing; + this.currentListenRange.setRangeEnd(this.newListenProgress({timestamp})); + } + } - protected isSessionStillPlaying(position: number): boolean { - return position !== this.currentListenRange.end.position; + protected currentListenSessionEnd() { + if (this.currentListenRange !== undefined && this.currentListenRange.getDuration() !== 0) { + this.logger.debug('Ended current Player listen range.') + this.currentListenRange.finalize(); + this.listenRanges.push(this.currentListenRange); + } + this.currentListenRange = undefined; } } diff --git a/src/backend/sources/PlayerState/JellyfinPlayerState.ts b/src/backend/sources/PlayerState/JellyfinPlayerState.ts index 4003c84f..425dea39 100644 --- a/src/backend/sources/PlayerState/JellyfinPlayerState.ts +++ b/src/backend/sources/PlayerState/JellyfinPlayerState.ts @@ -3,8 +3,9 @@ import { PlayObject } from "../../../core/Atomic.js"; import { PlayerStateDataMaybePlay, PlayPlatformId, ReportedPlayerStatus } from "../../common/infrastructure/Atomic.js"; import { PlayerStateOptions } from "./AbstractPlayerState.js"; import { GenericPlayerState } from "./GenericPlayerState.js"; +import { PositionalPlayerState } from "./PositionalPlayerState.js"; -export class JellyfinPlayerState extends GenericPlayerState { +export class JellyfinPlayerState extends PositionalPlayerState { constructor(logger: Logger, platformId: PlayPlatformId, opts?: PlayerStateOptions) { super(logger, platformId, opts); } diff --git a/src/backend/sources/PlayerState/ListenProgress.ts b/src/backend/sources/PlayerState/ListenProgress.ts index 2d66e58e..9b2612c9 100644 --- a/src/backend/sources/PlayerState/ListenProgress.ts +++ b/src/backend/sources/PlayerState/ListenProgress.ts @@ -1,32 +1,53 @@ import dayjs, { Dayjs } from "dayjs"; -import { PlayProgress, Second } from "../../../core/Atomic.js"; +import { PlayProgress, PlayProgressPositional, Second } from "../../../core/Atomic.js"; -export class ListenProgress implements PlayProgress { +export class ListenProgressTS implements PlayProgress { public timestamp: Dayjs; - public position?: Second; public positionPercent?: number; - constructor(timestamp?: Dayjs, position?: number, positionPercent?: number) { + constructor(data: Partial = {}) { + const {timestamp, positionPercent} = data; this.timestamp = timestamp ?? dayjs(); - this.position = position; this.positionPercent = positionPercent; } - getDuration(end: ListenProgress): Second { - if (this.position !== undefined && end.position !== undefined) { - return end.position - this.position; - } else { - return end.timestamp.diff(this.timestamp, 'seconds'); + getDuration(end: ListenProgressTS): Second { + return end.timestamp.diff(this.timestamp, 'seconds'); + } + + toJSON() { + return { + timestamp: this.timestamp.toISOString(), + position: undefined, + positionPercent: this.positionPercent } } +} + +export class ListenProgressPositional extends ListenProgressTS implements PlayProgressPositional { + public position: Second; + + constructor(data: PlayProgressPositional) { + super(data); + const {timestamp, position} = data; + this.timestamp = timestamp ?? dayjs(); + this.position = position; + } + + getDuration(end: ListenProgressPositional): Second { + return end.position - this.position; + } + toJSON() { return { timestamp: this.timestamp.toISOString(), position: this.position, - positionPercent: this.positionPercent + positionPercent: undefined } } } + +export type ListenProgress = ListenProgressTS | ListenProgressPositional; \ No newline at end of file diff --git a/src/backend/sources/PlayerState/ListenRange.ts b/src/backend/sources/PlayerState/ListenRange.ts index 64d51d19..7bf1c44a 100644 --- a/src/backend/sources/PlayerState/ListenRange.ts +++ b/src/backend/sources/PlayerState/ListenRange.ts @@ -1,71 +1,182 @@ import dayjs, { Dayjs } from "dayjs"; -import { ListenRangeData, Second } from "../../../core/Atomic.js"; -import { ListenProgress } from "./ListenProgress.js"; - -export class ListenRange implements ListenRangeData { +import { ListenRangeData, Millisecond, PlayProgress, PlayProgressPositional, Second } from "../../../core/Atomic.js"; +import { ListenProgress, ListenProgressPositional, ListenProgressTS } from "./ListenProgress.js"; +import { GenericRealtimePlayer, RealtimePlayer } from "./RealtimePlayer.js"; +export abstract class ListenRange { public start: ListenProgress; public end: ListenProgress; - protected allowedDrift: number; - constructor(start?: ListenProgress, end?: ListenProgress, allowedDrift: number = 2500) { - const s = start ?? new ListenProgress(); + constructor(start?: ListenProgress, end?: ListenProgress) { + const s = start ?? new ListenProgressTS(); const e = end ?? s; this.start = s; this.end = e; - this.allowedDrift = allowedDrift; } + public abstract isPositional(): boolean; + public abstract isInitial(): boolean; + public abstract seeked(position?: number, reportedTS?: Dayjs): [boolean, Second?]; + public abstract setRangeStart(data: ListenProgress | Partial); + public abstract setRangeEnd(data: ListenProgress | Partial); + public abstract getDuration(): Second; + public abstract getPosition(): Second | undefined; + public abstract finalize(position?: number); + public abstract toJSON(); +} + +export class ListenRangeTS extends ListenRange implements ListenRangeData { + + declare public start: ListenProgressTS; + declare public end: ListenProgressTS; + isPositional() { - return this.start.position !== undefined && this.end.position !== undefined; + return false; } isInitial() { - if (this.isPositional()) { - return this.start.position === this.end.position; - } return this.start.timestamp.isSame(this.end.timestamp); } seeked(position?: number, reportedTS: Dayjs = dayjs()): [boolean, Second?] { - if (position === undefined || this.isInitial() || !this.isPositional()) { - return [false]; + return [false]; + } + + setRangeStart(data: ListenProgress | Partial) { + if (data instanceof ListenProgressTS) { + this.start = data; + } else { + const d = data || {}; + this.start = new ListenProgressTS(d) + } + } + + setRangeEnd(data: ListenProgress | Partial) { + if (data instanceof ListenProgressTS) { + this.end = data; + } else { + const d = data || {}; + this.end = new ListenProgressTS(d) + } + } + + getDuration(): Second { + return this.start.getDuration(this.end); + } + + public getPosition(): Second { + return undefined; + } + + public finalize(position?: number) { + } + + toJSON() { + return [this.start, this.end]; + } +} + +export class ListenRangePositional extends ListenRange { + + declare public start: ListenProgressPositional + declare public end: ListenProgressPositional; + public rtPlayer: RealtimePlayer; + protected finalized: boolean; + rtTruth: boolean; + + protected allowedDrift: number; + + constructor(start?: ListenProgressPositional, end?: ListenProgressPositional, options: {rtTruth?: boolean, allowedDrift?: number, rtImmediate?: boolean} = {}) { + super(start, end); + const { allowedDrift = 2000, rtTruth = false, rtImmediate = true } = options; + this.allowedDrift = allowedDrift; + this.rtTruth = rtTruth; + this.rtPlayer = new GenericRealtimePlayer(); + this.rtPlayer.setPosition(start.position * 1000); + if(rtImmediate) { + this.rtPlayer.play(); } + this.finalized = false; + } + + isPositional(): boolean { + return true; + } + + isInitial() { + return this.start.position === this.end.position; + } + + seeked(position: Second, reportedTS: Dayjs = dayjs()): [boolean, Millisecond?] { // if (new) position is earlier than last stored position then the user has seeked backwards on the player if (position < this.end.position) { - return [true, position - this.end.position]; + return [true, (position - this.end.position) * 1000]; } // if (new) position is more than a reasonable number of ms ahead of real time than they have seeked forwards on the player - const realTimeDiff = Math.max(0, reportedTS.diff(this.end.timestamp, 'ms')); // 0 max used so TS from testing doesn't cause "backward" diff - const positionDiff = (position - this.end.position) * 1000; + //const realTimeDiff = Math.max(0, reportedTS.diff(this.end.timestamp, 'ms')); // 0 max used so TS from testing doesn't cause "backward" diff + //const positionDiff = (position - this.end.position) * 1000; // if user is more than 2.5 seconds ahead of real time - if (positionDiff - realTimeDiff > this.allowedDrift) { - return [true, position - this.end.position]; + if (this.isOverDrifted(position)) { + return [true, this.getDrift(position)]; } return [false]; } - setRangeStart(data: ListenProgress | { position?: number, timestamp?: Dayjs, positionPercent?: number }) { - if (data instanceof ListenProgress) { + setRangeStart(data: ListenProgressPositional | PlayProgressPositional) { + if (data instanceof ListenProgressPositional) { this.start = data; } else { - const d = data || {}; - this.start = new ListenProgress(d.timestamp, d.position, d.positionPercent) + //const d = data || {}; + this.start = new ListenProgressPositional(data) } + this.rtPlayer.stop(); + this.rtPlayer.play(this.start.position); } - setRangeEnd(data: ListenProgress | { position?: number, timestamp?: Dayjs, positionPercent?: number }) { - if (data instanceof ListenProgress) { - this.end = data; - } else { - const d = data || {}; - this.end = new ListenProgress(d.timestamp, d.position, d.positionPercent) + getDrift(position?: Second): Millisecond { + return ((position ?? this.end.position) * 1000) - this.rtPlayer.getPosition(); + } + + isOverDrifted(position: Second): boolean { + return Math.abs(this.getDrift((position ?? this.end.position))) > this.allowedDrift; + } + + setRangeEnd(data: ListenProgressPositional | PlayProgressPositional/* , force?: boolean */) { + const endProgress = data instanceof ListenProgressPositional ? data : new ListenProgressPositional(data) + // if(this.rtTruth) { + // if(!this.isOverDrifted(endProgress.position) && !force) { + // endProgress.position = this.rtPlayer.getPosition(); + // } else { + // // if we've drifted too far sync RT to reported position + // this.rtPlayer.setPosition(endProgress.position); + // } + // } + this.end = endProgress; + } + + finalize(position?: number) { + this.rtPlayer.pause(); + this.finalized = true; + let finalPosition = position; + if(finalPosition === undefined && this.rtTruth) { + finalPosition = this.rtPlayer.getPosition(true); + } + + if(finalPosition !== undefined) { + this.end.position = finalPosition; } } + public getPosition(): Second | undefined { + if(this.rtTruth && !this.finalized) { + this.rtPlayer.getPosition(); + } + return this.end.position; + } + getDuration(): Second { return this.start.getDuration(this.end); } @@ -73,4 +184,4 @@ export class ListenRange implements ListenRangeData { toJSON() { return [this.start, this.end]; } -} +} \ No newline at end of file diff --git a/src/backend/sources/PlayerState/PlexPlayerState.ts b/src/backend/sources/PlayerState/PlexPlayerState.ts index 497784d2..655768a8 100644 --- a/src/backend/sources/PlayerState/PlexPlayerState.ts +++ b/src/backend/sources/PlayerState/PlexPlayerState.ts @@ -2,11 +2,12 @@ import { Logger } from "@foxxmd/logging"; import { PlayPlatformId, REPORTED_PLAYER_STATUSES } from "../../common/infrastructure/Atomic.js"; import { AbstractPlayerState, PlayerStateOptions } from "./AbstractPlayerState.js"; import { GenericPlayerState } from "./GenericPlayerState.js"; +import { PositionalPlayerState } from "./PositionalPlayerState.js"; -export class PlexPlayerState extends GenericPlayerState { +export class PlexPlayerState extends PositionalPlayerState { constructor(logger: Logger, platformId: PlayPlatformId, opts?: PlayerStateOptions) { - super(logger, platformId, opts); - this.allowedDrift = 17000; + super(logger, platformId, {allowedDrift: 17000, rtTruth: true, ...(opts || {})}); + } protected isSessionStillPlaying(position: number): boolean { diff --git a/src/backend/sources/PlayerState/PositionalPlayerState.ts b/src/backend/sources/PlayerState/PositionalPlayerState.ts new file mode 100644 index 00000000..a091d498 --- /dev/null +++ b/src/backend/sources/PlayerState/PositionalPlayerState.ts @@ -0,0 +1,114 @@ +import { Logger } from "@foxxmd/logging"; +import { CALCULATED_PLAYER_STATUSES, PlayPlatformId, REPORTED_PLAYER_STATUSES } from "../../common/infrastructure/Atomic.js"; +import { AbstractPlayerState, PlayerStateOptions } from "./AbstractPlayerState.js"; +import { GenericPlayerState } from "./GenericPlayerState.js"; +import { GenericRealtimePlayer, RealtimePlayer } from "./RealtimePlayer.js"; +import { PlayProgress, PlayProgressPositional, Second } from "../../../core/Atomic.js"; +import { Dayjs } from "dayjs"; +import { ListenProgress, ListenProgressPositional } from "./ListenProgress.js"; +import { ListenRange, ListenRangePositional } from "./ListenRange.js"; + +export class PositionalPlayerState extends AbstractPlayerState { + + protected allowedDrift: number; + protected rtTruth: boolean; + + declare currentListenRange?: ListenRangePositional; + declare listenRanges: ListenRangePositional[]; + + constructor(logger: Logger, platformId: PlayPlatformId, opts?: PlayerStateOptions) { + super(logger, platformId, opts); + const { + allowedDrift = 3000, + rtTruth = false, + } = opts || {}; + this.allowedDrift = allowedDrift; + this.rtTruth = rtTruth; + } + + protected newListenProgress(data?: PlayProgressPositional): ListenProgressPositional { + return new ListenProgressPositional(data); + } + protected newListenRange(start?: ListenProgressPositional, end?: ListenProgressPositional, options: object = {}): ListenRangePositional { + return new ListenRangePositional(start, end, {allowedDrift: this.allowedDrift, rtTruth: this.rtTruth, ...options}); + } + + protected isSessionStillPlaying(position: number): boolean { + //return this.reportedStatus === REPORTED_PLAYER_STATUSES.playing; + if(!this.currentListenRange.isOverDrifted(position)) { + return true; + } + return position !== this.currentListenRange.end.position; + } + + protected currentListenSessionContinue(position: number, timestamp?: Dayjs) { + if (this.currentListenRange === undefined) { + this.logger.debug('Started new Player listen range.'); + let usedPosition = position; + if (this.calculatedStatus === CALCULATED_PLAYER_STATUSES.playing && position !== undefined && position <= 3) { + // likely the player has moved to a new track from a previous track (still calculated as playing) + // and polling/network delays means we did not catch absolute beginning of track + usedPosition = 1; + } + this.currentListenRange = this.newListenRange(this.newListenProgress({ timestamp, position: usedPosition }), undefined); + } else { + const oldEndProgress = this.currentListenRange.end; + const newEndProgress = this.newListenProgress({ timestamp, position }); + + if (!this.isSessionStillPlaying(position) && !['paused', 'stopped'].includes(this.calculatedStatus)) { + + this.calculatedStatus = this.reportedStatus === 'stopped' ? CALCULATED_PLAYER_STATUSES.stopped : CALCULATED_PLAYER_STATUSES.paused; + + if (this.reportedStatus !== this.calculatedStatus) { + this.logger.debug(`Reported status '${this.reportedStatus}' but track position has not progressed between two updates. Calculated player status is now ${this.calculatedStatus}`); + } else { + this.logger.debug(`Player position is equal between current -> last update. Updated calculated status to ${this.calculatedStatus}`); + } + } else if (position !== oldEndProgress.position && this.calculatedStatus !== 'playing') { + + this.calculatedStatus = CALCULATED_PLAYER_STATUSES.playing; + + if (this.reportedStatus !== this.calculatedStatus) { + this.logger.debug(`Reported status '${this.reportedStatus}' but track position has progressed between two updates. Calculated player status is now ${this.calculatedStatus}`); + } else { + this.logger.debug(`Player position changed between current -> last update. Updated calculated status to ${this.calculatedStatus}`); + } + } + + this.currentListenRange.setRangeEnd(newEndProgress); + } + } + + protected currentListenSessionEnd() { + if (this.currentListenRange !== undefined && this.currentListenRange.getDuration() !== 0) { + this.logger.debug('Ended current Player listen range.') + let finalPosition: number; + if(this.calculatedStatus === CALCULATED_PLAYER_STATUSES.playing && !this.currentListenRange.isInitial()) { + const { + data: { + duration, + } = {} + } = this.currentPlay; + if(duration !== undefined && (duration - this.currentListenRange.end.position) < 3) { + // likely the track was listened to until it ended + // but polling interval or network delays caused MS to not get data on the very end + // also...within 3 seconds of ending is close enough to call this complete IMO + finalPosition = duration; + //this.currentListenRange.end.position = duration; + + } + } + this.currentListenRange.finalize(finalPosition); + this.listenRanges.push(this.currentListenRange); + } + this.currentListenRange = undefined; + } + + public getPosition(): Second | undefined { + if(this.calculatedStatus !== 'stopped' && this.currentListenRange !== undefined && this.rtTruth) { + return this.currentListenRange.rtPlayer.getPosition(true); + } + return super.getPosition(); + } + +} diff --git a/src/backend/sources/PlayerState/RealtimePlayer.ts b/src/backend/sources/PlayerState/RealtimePlayer.ts index f4b30d37..717ef4ac 100644 --- a/src/backend/sources/PlayerState/RealtimePlayer.ts +++ b/src/backend/sources/PlayerState/RealtimePlayer.ts @@ -3,21 +3,22 @@ import { SimpleIntervalJob, Task, ToadScheduler } from "toad-scheduler"; const RT_TICK = 500; -abstract class RealtimePlayer { +export abstract class RealtimePlayer { - logger: Logger; + //logger: Logger; scheduler: ToadScheduler = new ToadScheduler(); protected position: number = 0; - protected constructor(logger: Logger) { - this.logger = childLogger(logger, `RT`); + protected constructor(/* logger: Logger */) { + //this.logger = childLogger(logger, `RT`); const job = new SimpleIntervalJob({ milliseconds: RT_TICK, runImmediately: true }, new Task('updatePos', () => this.position += RT_TICK), { id: 'rt' }); this.scheduler.addSimpleIntervalJob(job); this.scheduler.stop(); + this.position = 0; } public play(position?: number) { @@ -40,9 +41,17 @@ abstract class RealtimePlayer { this.position = position; } - public getPosition() { - return this.position; + public getPosition(asSeconds: boolean = false) { + return !asSeconds ? this.position : this.position / 1000; + } + + public setPosition(time: number) { + this.position = time; } } -export class GenericRealtimePlayer extends RealtimePlayer {} \ No newline at end of file +export class GenericRealtimePlayer extends RealtimePlayer { + constructor(/* logger: Logger */) { + super(); + } +} \ No newline at end of file diff --git a/src/backend/sources/PlayerState/RealtimePlayerState.ts b/src/backend/sources/PlayerState/RealtimePlayerState.ts new file mode 100644 index 00000000..ddcc1001 --- /dev/null +++ b/src/backend/sources/PlayerState/RealtimePlayerState.ts @@ -0,0 +1,44 @@ +import { Logger } from "@foxxmd/logging"; +import { PlayPlatformId, REPORTED_PLAYER_STATUSES } from "../../common/infrastructure/Atomic.js"; +import { AbstractPlayerState, PlayerStateOptions } from "./AbstractPlayerState.js"; +import { GenericPlayerState } from "./GenericPlayerState.js"; +import { GenericRealtimePlayer, RealtimePlayer } from "./RealtimePlayer.js"; +import { Second } from "../../../core/Atomic.js"; +import { Dayjs } from "dayjs"; + +// export class RealtimePlayerState extends GenericPlayerState { + +// rtPlayer: RealtimePlayer; +// //allowedDrift: number; + +// constructor(logger: Logger, platformId: PlayPlatformId, opts?: PlayerStateOptions) { +// super(logger, platformId, opts); +// this.rtPlayer = new GenericRealtimePlayer(logger); +// const { +// allowedDrift = 3000 +// } = opts || {}; +// this.allowedDrift = 3000; +// } + +// protected isSessionStillPlaying(position: number): boolean { +// return this.reportedStatus === REPORTED_PLAYER_STATUSES.playing; +// } + +// public getPosition(): Second | undefined { +// if(this.calculatedStatus === 'stopped') { +// return undefined; +// } +// return this.rtPlayer.getPosition(); +// } + +// protected currentListenSessionEnd() { +// super.currentListenSessionEnd(); +// this.rtPlayer.pause(); +// } +// protected currentListenSessionContinue(position?: number, timestamp?: Dayjs) { +// const rt = this.rtPlayer.getPosition(true); +// if(Math.abs(position - rt) > this.allowedDrift) { +// this.logger.debug(`Reported position (${position}s) has drifted from real-time (${rt}s) more than allowed (${this.allowedDrift}ms)`); +// } +// } +// } diff --git a/src/backend/sources/PlexApiSource.ts b/src/backend/sources/PlexApiSource.ts index ab33fec4..5774605c 100644 --- a/src/backend/sources/PlexApiSource.ts +++ b/src/backend/sources/PlexApiSource.ts @@ -11,7 +11,6 @@ import { } from "../common/infrastructure/Atomic.js"; import { combinePartsToString, genGroupIdStr, getFirstNonEmptyString, getPlatformIdFromData, joinedUrl, parseBool, } from "../utils.js"; import { parseArrayFromMaybeString } from "../utils/StringUtils.js"; -import MemorySource from "./MemorySource.js"; import { GetSessionsMetadata } from "@lukehagar/plexjs/sdk/models/operations/getsessions.js"; import { PlexAPI } from "@lukehagar/plexjs"; import { @@ -26,12 +25,13 @@ import { Readable } from 'node:stream'; import { PlexPlayerState } from './PlayerState/PlexPlayerState.js'; import { PlayerStateOptions } from './PlayerState/AbstractPlayerState.js'; import { Logger } from '@foxxmd/logging'; +import { MemoryPositionalSource } from './MemoryPositionalSource.js'; const shortDeviceId = truncateStringToLength(10, ''); const THUMB_REGEX = new RegExp(/\/library\/metadata\/(?\d+)\/thumb\/\d+/) -export default class PlexApiSource extends MemorySource { +export default class PlexApiSource extends MemoryPositionalSource { users: string[] = []; plexApi: PlexAPI; diff --git a/src/backend/sources/SpotifySource.ts b/src/backend/sources/SpotifySource.ts index 614ba555..066213a8 100644 --- a/src/backend/sources/SpotifySource.ts +++ b/src/backend/sources/SpotifySource.ts @@ -28,20 +28,20 @@ import { } from "../utils.js"; import { findCauseByFunc } from "../utils/ErrorUtils.js"; import { RecentlyPlayedOptions } from "./AbstractSource.js"; -import MemorySource from "./MemorySource.js"; import AlbumObjectSimplified = SpotifyApi.AlbumObjectSimplified; import ArtistObjectSimplified = SpotifyApi.ArtistObjectSimplified; import CurrentlyPlayingObject = SpotifyApi.CurrentlyPlayingObject; import PlayHistoryObject = SpotifyApi.PlayHistoryObject; import TrackObjectFull = SpotifyApi.TrackObjectFull; import UserDevice = SpotifyApi.UserDevice; +import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; const scopes = ['user-read-recently-played', 'user-read-currently-playing', 'user-read-playback-state', 'user-read-playback-position']; const state = 'random'; const shortDeviceId = truncateStringToLength(10, ''); -export default class SpotifySource extends MemorySource { +export default class SpotifySource extends MemoryPositionalSource { spotifyApi: SpotifyWebApi; workingCredsPath: string; diff --git a/src/backend/sources/VLCSource.ts b/src/backend/sources/VLCSource.ts index 6d394039..2a701424 100644 --- a/src/backend/sources/VLCSource.ts +++ b/src/backend/sources/VLCSource.ts @@ -15,7 +15,7 @@ import { VlcAudioMeta, VLCSourceConfig, PlayerState } from "../common/infrastruc import { isPortReachable } from "../utils/NetworkUtils.js"; import { firstNonEmptyStr } from "../utils/StringUtils.js"; import { RecentlyPlayedOptions } from "./AbstractSource.js"; -import MemorySource from "./MemorySource.js"; +import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; const CLIENT_PLAYER_STATE: Record = { 'playing': REPORTED_PLAYER_STATUSES.playing, @@ -23,7 +23,7 @@ const CLIENT_PLAYER_STATE: Record = { 'stopped': REPORTED_PLAYER_STATUSES.stopped, } -export class VLCSource extends MemorySource { +export class VLCSource extends MemoryPositionalSource { declare config: VLCSourceConfig; host?: string diff --git a/src/backend/tests/player/player.test.ts b/src/backend/tests/player/player.test.ts index 4e0bc110..b8fe204b 100644 --- a/src/backend/tests/player/player.test.ts +++ b/src/backend/tests/player/player.test.ts @@ -1,7 +1,7 @@ import { loggerTest } from "@foxxmd/logging"; import { assert } from 'chai'; import clone from "clone"; -import dayjs from "dayjs"; +import dayjs, { Dayjs } from "dayjs"; import { describe, it } from 'mocha'; import { CALCULATED_PLAYER_STATUSES, @@ -14,6 +14,9 @@ import { import { GenericPlayerState } from "../../sources/PlayerState/GenericPlayerState.js"; import { playObjDataMatch } from "../../utils.js"; import { generatePlay } from "../utils/PlayTestUtils.js"; +import { PositionalPlayerState } from "../../sources/PlayerState/PositionalPlayerState.js"; +import { ListenProgressPositional } from "../../sources/PlayerState/ListenProgress.js"; +import { ListenRangePositional } from "../../sources/PlayerState/ListenRange.js"; const logger = loggerTest; @@ -21,6 +24,16 @@ const newPlay = generatePlay({duration: 300}); const testState = (data: Omit): 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, {rtImmediate: false, ...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 () { @@ -109,31 +122,31 @@ describe('Player status', function () { describe('When source provides playback position', function () { it('Calculated state is playing when position moves forward', function () { - const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]); + const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]); const positioned = clone(newPlay); - positioned.meta.trackProgressPosition = 3; - player.update(testState({play: positioned})); + player.update(testState({play: positioned, position: 3})); - positioned.meta.trackProgressPosition = 13; - player.update(testState({play: positioned}), dayjs().add(10, 'seconds')); + 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', function () { - const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]); + 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})); + player.update(testState({play: positioned, position: 3})); - positioned.meta.trackProgressPosition = 13; - player.update(testState({play: positioned}), dayjs().add(10, 'seconds')); + player.currentListenRange.rtPlayer.setPosition(13000); + player.update(testState({play: positioned, position: 13}), dayjs().add(10, 'seconds')); - player.update(testState({play: positioned}), dayjs().add(20, '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); }); @@ -195,163 +208,148 @@ describe('Player listen ranges', function () { describe('When source does provide playback position', function () { it('Duration is position based', function () { - const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]); + const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]); const positioned = clone(newPlay); - positioned.meta.trackProgressPosition = 3; - player.update(testState({play: positioned})); - positioned.meta.trackProgressPosition = 10; - player.update(testState({play: positioned}), dayjs().add(10, 'seconds')); + 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('Range ends if position does not move', function () { - const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]); + it('Range ends if position over drifts', function () { + const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]); const positioned = clone(newPlay); - positioned.meta.trackProgressPosition = 3; - player.update(testState({play: positioned})); + player.update(testState({play: positioned, position: 3})); - positioned.meta.trackProgressPosition = 3; - player.update(testState({play: positioned}), dayjs().add(10, 'seconds')); + 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 GenericPlayerState(logger, [NO_DEVICE, NO_USER]); + const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]); const positioned = clone(newPlay); - positioned.meta.trackProgressPosition = 3; - player.update(testState({play: positioned})); + player.update(testState({play: positioned, position: 3})); - positioned.meta.trackProgressPosition = 7; - player.update(testState({play: positioned}), dayjs().add(10, 'seconds')); + player.currentListenRange.rtPlayer.setPosition(7000); + player.update(testState({play: positioned, position: 7}), dayjs().add(4, 'seconds')); - player.update(testState({play: positioned}), dayjs().add(20, 'seconds')); - positioned.meta.trackProgressPosition = 17; - player.update(testState({play: positioned}), dayjs().add(30, 'seconds')); + player.currentListenRange.rtPlayer.setPosition(23000); + player.update(testState({play: positioned, position: 23}), dayjs().add(20, 'seconds')); - positioned.meta.trackProgressPosition = 27; - player.update(testState({play: positioned}), dayjs().add(40, 'seconds')); + player.currentListenRange.rtPlayer.setPosition(33000); + player.update(testState({play: positioned, position: 33}), dayjs().add(30, 'seconds')); - assert.equal(player.getListenDuration(), 24); + 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 GenericPlayerState(logger, [NO_DEVICE, NO_USER]); + const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]); const positioned = clone(newPlay); - positioned.meta.trackProgressPosition = 3; - player.update(testState({play: positioned})); + player.update(testState({play: positioned, position: 3})); positioned.meta.trackProgressPosition = 13; - player.update(testState({play: positioned}), dayjs().add(10, 'seconds')); - - positioned.meta.trackProgressPosition = 30; - player.update(testState({play: positioned}), dayjs().add(20, 'seconds')); - - assert.equal(player.currentListenRange.start.timestamp, player.currentListenRange.end.timestamp); + player.currentListenRange.rtPlayer.setPosition(13000); + player.update(testState({play: positioned, position: 13}), dayjs().add(10, 'seconds')); - positioned.meta.trackProgressPosition = 40; - player.update(testState({play: positioned}), dayjs().add(30, 'seconds')); - - assert.equal(player.getListenDuration(), 20); + 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', function () { - const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]); + 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); - positioned.meta.trackProgressPosition = 30; - player.update(testState({play: positioned})); - - positioned.meta.trackProgressPosition = 40; - player.update(testState({play: positioned}), dayjs().add(10, 'seconds')); - - positioned.meta.trackProgressPosition = 20; - player.update(testState({play: positioned}), dayjs().add(20, 'seconds')); + player.update(testState({play: positioned, position: 3})); - assert.equal(player.currentListenRange.start.timestamp, player.currentListenRange.end.timestamp); + player.currentListenRange.rtPlayer.setPosition(13000); + player.update(testState({play: positioned, position: 13}), dayjs().add(10, 'seconds')); - positioned.meta.trackProgressPosition = 30; - player.update(testState({play: positioned}), dayjs().add(30, 'seconds')); - - assert.equal(player.getListenDuration(), 20); + 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 GenericPlayerState(logger, [NO_DEVICE, NO_USER]); + const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]); const positioned = clone(newPlay); positioned.data.duration = 70; - positioned.meta.trackProgressPosition = 45; - player.update(testState({play: positioned})); + player.update(testState({play: positioned, position: 45})); - positioned.meta.trackProgressPosition = 55; - player.update(testState({play: positioned}), dayjs().add(10, 'seconds')); + player.currentListenRange.rtPlayer.setPosition(65000); + player.update(testState({play: positioned, position: 65}), dayjs().add(20, 'seconds')); - positioned.meta.trackProgressPosition = 65; - player.update(testState({play: positioned}), dayjs().add(20, 'seconds')); + const isRepeat = player.testSessionRepeat(5, dayjs().add(20, 'seconds')); + assert.isTrue(isRepeat); - positioned.meta.trackProgressPosition = 5; - const [curr, prevPlay] = player.update(testState({play: positioned}), dayjs().add(30, 'seconds')); + player.currentListenRange.rtPlayer.setPosition(67000); + const [curr, prevPlay] = player.update(testState({play: positioned, position: 5}), dayjs().add(22, 'seconds')); assert.isDefined(prevPlay); 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 GenericPlayerState(logger, [NO_DEVICE, NO_USER]); + const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]); const positioned = clone(newPlay); positioned.data.duration = 300; - positioned.meta.trackProgressPosition = 351; - player.update(testState({play: positioned})); - positioned.meta.trackProgressPosition = 361; - player.update(testState({play: positioned}), dayjs().add(10, 'seconds')); + player.update(testState({play: positioned, position: 351})); + + player.currentListenRange.rtPlayer.setPosition(361000); + player.update(testState({play: positioned, position: 361}), dayjs().add(10, 'seconds')); - positioned.meta.trackProgressPosition = 371; - player.update(testState({play: positioned}), dayjs().add(20, 'seconds')); + player.currentListenRange.rtPlayer.setPosition(371000); + player.update(testState({play: positioned, position: 371}), dayjs().add(20, 'seconds')); - positioned.meta.trackProgressPosition = 20; - const [curr, prevPlay] = player.update(testState({play: positioned}), dayjs().add(30, '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.isDefined(prevPlay); assert.equal(player.getListenDuration(), 0); }); - it('Detects repeat when player is seeked to start and a heft chunk of the track has already been played', function () { - const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]); + 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; - positioned.meta.trackProgressPosition = 0; - player.update(testState({play: positioned})); - - positioned.meta.trackProgressPosition = 10; - player.update(testState({play: positioned}), dayjs().add(10, 'seconds')); - positioned.meta.trackProgressPosition = 20; - player.update(testState({play: positioned}), dayjs().add(20, 'seconds')); + player.update(testState({play: positioned, position: 0})); - positioned.meta.trackProgressPosition = 30; - player.update(testState({play: positioned}), dayjs().add(30, 'seconds')); + player.currentListenRange.rtPlayer.setPosition(40000); + player.update(testState({play: positioned, position: 40}), dayjs().add(40, 'seconds')); - positioned.meta.trackProgressPosition = 40; - player.update(testState({play: positioned}), dayjs().add(40, 'seconds')); + const isRepeat = player.testSessionRepeat(2, dayjs().add(50, 'seconds')); + assert.isTrue(isRepeat); positioned.meta.trackProgressPosition = 2; - const [curr, prevPlay] = player.update(testState({play: positioned}), dayjs().add(50, 'seconds')); + player.currentListenRange.rtPlayer.setPosition(50000); + const [curr, prevPlay] = player.update(testState({play: positioned, position: 2}), dayjs().add(50, 'seconds')); assert.isDefined(prevPlay); assert.equal(player.getListenDuration(), 0); diff --git a/src/core/Atomic.ts b/src/core/Atomic.ts index 5bd37f01..ae1f46ff 100644 --- a/src/core/Atomic.ts +++ b/src/core/Atomic.ts @@ -54,6 +54,10 @@ export interface PlayProgress { positionPercent?: number } +export interface PlayProgressPositional extends PlayProgress { + position: number +} + export interface ListenRangeData { start: ListenProgress end: ListenProgress From c498bde1fd5ee2f3afd302b97dec5ce4461de587 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 25 Oct 2024 15:57:30 +0000 Subject: [PATCH 3/3] feat(ui): Show indeterminate state for non-positional players --- src/client/components/player/Player.tsx | 2 +- .../components/player/PlayerTimestamp.tsx | 34 +++---------------- src/client/components/player/timestamp.scss | 19 +++++++++++ 3 files changed, 24 insertions(+), 31 deletions(-) diff --git a/src/client/components/player/Player.tsx b/src/client/components/player/Player.tsx index d82aeb7c..1a21141d 100644 --- a/src/client/components/player/Player.tsx +++ b/src/client/components/player/Player.tsx @@ -94,7 +94,7 @@ art = {},

{calculated !== 'stopped' ? artists.join(' / ') : '-'}

- +

Status: {capitalize(calculated)}

Listened: {calculated !== 'stopped' ? `${listenedDuration.toFixed(0)}s` : '-'}{durPer}

diff --git a/src/client/components/player/PlayerTimestamp.tsx b/src/client/components/player/PlayerTimestamp.tsx index ec229cfc..362f853a 100644 --- a/src/client/components/player/PlayerTimestamp.tsx +++ b/src/client/components/player/PlayerTimestamp.tsx @@ -4,6 +4,7 @@ import './timestamp.scss'; export interface TimestampProps { current: number duration: number + indeterminate?: boolean } const convertTime = (rawTime: number) => { @@ -19,11 +20,11 @@ const convertTime = (rawTime: number) => { const Timestamp = (props: TimestampProps) => { return(
-
- {convertTime(Math.floor(props.current))} +
+ {props.indeterminate ? '-' : convertTime(Math.floor(props.current))}
-
+
{convertTime(Math.floor(props.duration) - Math.floor(props.current))} @@ -31,32 +32,5 @@ const Timestamp = (props: TimestampProps) => {
); } -/*export class TimestampC extends React.Component { - convertTime(time) { - let mins = Math.floor(time / 60); - let seconds = time - (mins * 60); - if (seconds < 10) { - seconds = "0" + seconds; - } - time = mins + ":" + seconds; - return time; - } - - render() { - return( -
-
- {this.convertTime(this.props.current)} -
-
-
-
-
- {this.convertTime(this.props.duration - this.props.current)} -
-
- ); - } -}*/ export default Timestamp; diff --git a/src/client/components/player/timestamp.scss b/src/client/components/player/timestamp.scss index 923fbcb5..708a3f7d 100644 --- a/src/client/components/player/timestamp.scss +++ b/src/client/components/player/timestamp.scss @@ -34,6 +34,13 @@ $primary: #556a77; bottom: 0; background: $primary; } + + > div.indeterminate { + background-color: #ECEFF1; + animation: indeterminateAnimation 3s infinite linear; + transform-origin: 0% 50%; + background: $primary; + } } &__current { @@ -44,3 +51,15 @@ $primary: #556a77; right: 0; } } + +@keyframes indeterminateAnimation { + 0% { + transform: translateX(0) scaleX(0); + } + 50% { + transform: translateX(0) scaleX(0.5); + } + 100% { + transform: translateX(100%) scaleX(0.5); + } +} \ No newline at end of file