diff --git a/src/common/infrastructure/Atomic.ts b/src/common/infrastructure/Atomic.ts index ec852264..fb2a3e1e 100644 --- a/src/common/infrastructure/Atomic.ts +++ b/src/common/infrastructure/Atomic.ts @@ -3,6 +3,7 @@ import {FixedSizeList} from 'fixed-size-list'; import {MESSAGE} from 'triple-beam'; import {Logger} from '@foxxmd/winston'; import TupleMap from "../TupleMap.js"; +import is from "@sindresorhus/is"; export type SourceType = 'spotify' | 'plex' | 'tautulli' | 'subsonic' | 'jellyfin' | 'lastfm' | 'deezer' | 'ytmusic' | 'mpris' | 'mopidy' | 'listenbrainz' | 'jriver' | 'kodi'; export const sourceTypes: SourceType[] = ['spotify', 'plex', 'tautulli', 'subsonic', 'jellyfin', 'lastfm', 'deezer', 'ytmusic', 'mpris', 'mopidy', 'listenbrainz', 'jriver', 'kodi']; @@ -39,13 +40,39 @@ export const REPORTED_PLAYER_STATUSES = { unknown: 'unknown' as ReportedPlayerStatus } +export type CalculatedPlayerStatus = ReportedPlayerStatus | 'stale' | 'orphaned'; +export const CALCULATED_PLAYER_STATUSES = { + ...REPORTED_PLAYER_STATUSES, + stale: 'stale' as CalculatedPlayerStatus, + orphaned: 'orphaned' as CalculatedPlayerStatus, +} + export interface ConfigMeta { source: string mode?: string configureAs: string } -export type ListenRange = [Dayjs, Dayjs] +export type SourceData = (PlayObject | PlayerStateData); + +export interface PlayerStateData { + platformId: PlayPlatformId + play: PlayObject + status?: ReportedPlayerStatus + position?: number + timestamp?: Dayjs +} + +export const asPlayerStateData = (obj: object): obj is PlayerStateData => { + return 'platformId' in obj && 'play' in obj; +} + +export interface PlayProgress { + timestamp: Dayjs + position?: number + positionPercent?: number +} +export type ListenRange = [PlayProgress, PlayProgress] export interface TrackData { artists?: string[] @@ -247,3 +274,15 @@ export interface RegExResult { export interface NamedGroup { [name: string]: any } + +export interface numberFormatOptions { + toFixed: number, + defaultVal?: any, + prefix?: string, + suffix?: string, + round?: { + type?: string, + enable: boolean, + indicate?: boolean, + } +} diff --git a/src/sources/AbstractSource.ts b/src/sources/AbstractSource.ts index 9881ba1a..6629ff23 100644 --- a/src/sources/AbstractSource.ts +++ b/src/sources/AbstractSource.ts @@ -2,7 +2,7 @@ import dayjs, {Dayjs} from "dayjs"; import { buildTrackString, capitalize, closePlayDate, genGroupId, - genGroupIdStr, mergeArr, + genGroupIdStrFromPlay, mergeArr, playObjDataMatch, pollingBackoff, sleep, sortByNewestPlayDate, sortByOldestPlayDate diff --git a/src/sources/JRiverSource.ts b/src/sources/JRiverSource.ts index 2141ae73..17889adf 100644 --- a/src/sources/JRiverSource.ts +++ b/src/sources/JRiverSource.ts @@ -140,7 +140,7 @@ export class JRiverSource extends MemorySource { } } - return this.processRecentPlays(play); + return this.processRecentPlaysNew(play); } } diff --git a/src/sources/JellyfinSource.ts b/src/sources/JellyfinSource.ts index a853c3ad..409d2c35 100644 --- a/src/sources/JellyfinSource.ts +++ b/src/sources/JellyfinSource.ts @@ -8,8 +8,11 @@ import { truncateStringToLength } from "../utils.js"; import {JellySourceConfig} from "../common/infrastructure/config/source/jellyfin.js"; -import {FormatPlayObjectOptions, InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js"; +import {FormatPlayObjectOptions, InternalConfig, PlayObject, PlayPlatformId} from "../common/infrastructure/Atomic.js"; import EventEmitter from "events"; +import {PlayerStateOptions} from "./PlayerState/AbstractPlayerState.js"; +import {Logger} from "@foxxmd/winston"; +import {JellyfinPlayerState} from "./PlayerState/JellyfinPlayerState.js"; const shortDeviceId = truncateStringToLength(10, ''); @@ -306,7 +309,7 @@ export default class JellyfinSource extends MemorySource { scrobbleOpts.checkAll = true; } else { - newPlays = this.processRecentPlays([playObj]); + newPlays = this.processRecentPlaysNew([playObj]); } if(newPlays.length > 0) { @@ -318,4 +321,8 @@ export default class JellyfinSource extends MemorySource { } } } + + getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => { + return new JellyfinPlayerState(logger, id, opts); + } } diff --git a/src/sources/KodiSource.ts b/src/sources/KodiSource.ts index 90abf381..e972ac89 100644 --- a/src/sources/KodiSource.ts +++ b/src/sources/KodiSource.ts @@ -69,7 +69,7 @@ export class KodiSource extends MemorySource { let play = await this.client.getRecentlyPlayed(options); - return this.processRecentPlays(play); + return this.processRecentPlaysNew(play); } } diff --git a/src/sources/MPRISSource.ts b/src/sources/MPRISSource.ts index 01b7d13c..4129c4f0 100644 --- a/src/sources/MPRISSource.ts +++ b/src/sources/MPRISSource.ts @@ -214,7 +214,7 @@ export class MPRISSource extends MemorySource { if(options.display === true) { return deduped; } - return this.processRecentPlays(deduped); + return this.processRecentPlaysNew(deduped); } } diff --git a/src/sources/MemorySource.ts b/src/sources/MemorySource.ts index 6ef77892..a2f9451e 100644 --- a/src/sources/MemorySource.ts +++ b/src/sources/MemorySource.ts @@ -5,17 +5,27 @@ import { buildTrackString, toProgressAwarePlayObject, getProgress, - genGroupIdStr, playPassesScrobbleThreshold, timePassesScrobbleThreshold, thresholdResultSummary, genGroupId + genGroupIdStrFromPlay, + playPassesScrobbleThreshold, + timePassesScrobbleThreshold, + thresholdResultSummary, + genGroupId, + genGroupIdStr, getPlatformIdFromData } from "../utils.js"; import dayjs from "dayjs"; import { + asPlayerStateData, DeviceId, - GroupedPlays, - PlayObject, PlayUserId, + GroupedPlays, PlayerStateData, + PlayObject, PlayPlatformId, PlayUserId, ProgressAwarePlayObject, ScrobbleThresholdResult } from "../common/infrastructure/Atomic.js"; import TupleMap from "../common/TupleMap.js"; +import {AbstractPlayerState, PlayerStateOptions} from "./PlayerState/AbstractPlayerState.js"; +import {GenericPlayerState} from "./PlayerState/GenericPlayerState.js"; +import {Logger} from "@foxxmd/winston"; + export default class MemorySource extends AbstractSource { /* * MemorySource uses its own state to maintain a list of recently played tracks and determine if a track is valid. @@ -42,11 +52,111 @@ export default class MemorySource extends AbstractSource { * */ candidateRecentlyPlayed: GroupedPlays = new TupleMap + players: Map = new Map(); + getFlatCandidateRecentlyPlayed = (): PlayObject[] => { // TODO sort? return Array.from(this.candidateRecentlyPlayed.values()).flat(); } + getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => { + return new GenericPlayerState(logger, id, opts); + } + + processRecentPlaysNew = (datas: (PlayObject | PlayerStateData)[]) => { + + const { + data: { + scrobbleThresholds = {} + } = {} + } = this.config; + + const newStatefulPlays: PlayObject[] = []; + + // create any new players from incoming data + //const incomingPlatformIds: PlayPlatformId[] = []; + for (const data of datas) { + const id = getPlatformIdFromData(data); + const idStr = genGroupIdStr(id); + if (!this.players.has(idStr)) { + //incomingPlatformIds.push(id); + this.players.set(idStr, this.getNewPlayer(this.logger, id, { + staleInterval: (this.config.data.interval ?? 30) * 3, + orphanedInterval: (this.config.data.maxInterval ?? 60) * 5 + })); + } + } + + const deadPlatformIds: string[] = []; + + for (const [key, player] of this.players.entries()) { + + let incomingData: PlayObject | PlayerStateData; + // get all incoming datas relevant for each player (this should only be one) + const relevantDatas = datas.filter(x => { + const id = getPlatformIdFromData(x); + return player.platformEquals(id); + }); + + // we've received some form of communication from the source for this player + if (relevantDatas.length > 0) { + this.lastActivityAt = dayjs(); + + if (relevantDatas.length > 1) { + this.logger.warn(`More than one data/state for Player ${player.platformIdStr} found in incoming data, will only use first found.`); + } + incomingData = relevantDatas[0]; + + const [currPlay, prevPlay] = asPlayerStateData(incomingData) ? player.setState(incomingData.status, incomingData.play) : player.setState(undefined, incomingData); + const candidate = prevPlay !== undefined ? prevPlay : currPlay; + + if (candidate !== undefined) { + const thresholdResults = timePassesScrobbleThreshold(scrobbleThresholds, candidate.data.listenedFor, candidate.data.duration); + + if (thresholdResults.passes) { + const matchingRecent = this.existingDiscovered(candidate); //sRecentlyPlayed.find(x => playObjDataMatch(x, candidate)); + let stPrefix = `${buildTrackString(candidate, {include: ['trackId', 'artist', 'track']})}`; + if (matchingRecent === undefined) { + player.logger.debug(`${stPrefix} added after ${thresholdResultSummary(thresholdResults)} and not matching any prior plays`); + newStatefulPlays.push(candidate); + } else { + const {data: {playDate, duration}} = candidate; + const {data: {playDate: rplayDate}} = matchingRecent; + if (!playDate.isSame(rplayDate)) { + if (duration !== undefined) { + if (playDate.isAfter(rplayDate.add(duration, 's'))) { + player.logger.debug(`${stPrefix} added after ${thresholdResultSummary(thresholdResults)} and having a different timestamp than a prior play`); + newStatefulPlays.push(candidate); + } + } else { + const discoveredPlays = this.getRecentlyDiscoveredPlaysByPlatform(genGroupId(candidate)); + if (discoveredPlays.length === 0 || !playObjDataMatch(discoveredPlays[0], candidate)) { + // if most recent stateful play is not this track we'll add it + player.logger.debug(`${stPrefix} added after ${thresholdResultSummary(thresholdResults)}. Matched other recent play but could not determine time frame due to missing duration. Allowed due to not being last played track.`); + newStatefulPlays.push(candidate); + } + } + } + } + } + } + } else { + // no communication from the source was received for this player + player.checkStale(); + if (player.checkOrphaned() && player.isDead()) { + player.logger.debug(`Removed after being orphaned for ${dayjs.duration(player.stateIntervalOptions.orphanedInterval, 'seconds').asMinutes()} minutes`); + deadPlatformIds.push(player.platformIdStr); + } + } + player.logSummary(); + } + for (const deadId of deadPlatformIds) { + this.players.delete(deadId); + } + + return newStatefulPlays; + } + processRecentPlays = (plays: PlayObject[], useExistingPlayDate = false) => { const { @@ -61,9 +171,10 @@ export default class MemorySource extends AbstractSource { // -- otherwise, for sources like Spotify that accurately report when track started to play, we can use existing dates const flatLockedPlays = useExistingPlayDate ? plays : plays.map((p: any) => { - const {data: {playDate, ...restData}, ...rest} = p; - return {data: {...restData, playDate: dayjs()}, ...rest}; + const {data: {playDate, ...restData}, ...rest} = p; + return {data: {...restData, playDate: dayjs()}, ...rest}; }); + // group by device-user const groupedLockedPlays = flatLockedPlays.reduce((acc: GroupedPlays, curr: ProgressAwarePlayObject) => { const id = genGroupId(curr); @@ -71,17 +182,17 @@ export default class MemorySource extends AbstractSource { return acc; }, new Map()); - for(const [groupId, lockedPlays] of groupedLockedPlays.entries()) { + for (const [groupId, lockedPlays] of groupedLockedPlays.entries()) { const groupIdStr = `${groupId[0]}-${groupId[1]}`; let cRecentlyPlayed = this.candidateRecentlyPlayed.get(groupId) ?? []; // if no candidates exist new plays are new candidates - if(cRecentlyPlayed.length === 0) { + if (cRecentlyPlayed.length === 0) { this.logger.debug(`[Platform ${groupIdStr}] No prior candidate recent plays!`) // update activity date here so that polling interval decreases *before* we get a new valid play // so that we don't miss a play due to long polling interval this.lastActivityAt = dayjs(); const progressAware: ProgressAwarePlayObject[] = []; - for(const p of lockedPlays) { + for (const p of lockedPlays) { progressAware.push(toProgressAwarePlayObject(p)); this.logger.debug(`[Platform ${groupIdStr}] Adding new locked play: ${buildTrackString(p, {include: ['trackId', 'artist', 'track']})}`); } @@ -90,12 +201,12 @@ export default class MemorySource extends AbstractSource { // otherwise determine new tracks (not found in prior candidates) const newTracks = lockedPlays.filter((x: any) => cRecentlyPlayed.every(y => !playObjDataMatch(y, x))); const newProgressAwareTracks: ProgressAwarePlayObject[] = []; - if(newTracks.length > 0) { + if (newTracks.length > 0) { // update activity date here so that polling interval decreases *before* we get a new valid play // so that we don't miss a play due to long polling interval this.lastActivityAt = dayjs(); this.logger.debug(`[Platform ${groupIdStr}] New plays found that do not match existing candidates.`) - for(const p of newTracks) { + for (const p of newTracks) { this.logger.debug(`[Platform ${groupIdStr}] Adding new locked play: ${buildTrackString(p, {include: ['trackId', 'artist', 'track']})}`); newProgressAwareTracks.push(toProgressAwarePlayObject(p)); } @@ -103,7 +214,7 @@ export default class MemorySource extends AbstractSource { // filter prior candidates based on new recently played cRecentlyPlayed = cRecentlyPlayed.filter(x => { const candidateMatchedLocked = lockedPlays.some((y: any) => playObjDataMatch(x, y)); - if(!candidateMatchedLocked) { + if (!candidateMatchedLocked) { this.logger.debug(`[Platform ${groupIdStr}] Existing candidate not found in locked plays will be removed: ${buildTrackString(x, {include: ['trackId', 'artist', 'track']})}`); } return candidateMatchedLocked; @@ -119,7 +230,7 @@ export default class MemorySource extends AbstractSource { // now we check if all candidates pass tests for having been tracked long enough: // * Has been tracked for at least [duration] seconds or [percentage] of track duration // * If it has playback position data then it must also have progressed at least [duration] seconds or [percentage] of track duration progress since our initial tracking data - for(const candidate of cRecentlyPlayed) { + for (const candidate of cRecentlyPlayed) { let thresholdResults: ScrobbleThresholdResult; thresholdResults = playPassesScrobbleThreshold(candidate, scrobbleThresholds); const {passes: firstSeenValid} = thresholdResults; @@ -138,28 +249,28 @@ export default class MemorySource extends AbstractSource { } } - if(firstSeenValid && progressValid) { + if (firstSeenValid && progressValid) { // a prior candidate has been playing for more than 30 seconds and passed progress test, time to check statefuls const matchingRecent = this.existingDiscovered(candidate); //sRecentlyPlayed.find(x => playObjDataMatch(x, candidate)); let stPrefix = `[Platform ${groupId}] (Stateful Play) ${buildTrackString(candidate, {include: ['trackId', 'artist', 'track']})}`; - if(matchingRecent === undefined) { + if (matchingRecent === undefined) { this.logger.debug(`${stPrefix} added after ${thresholdResultSummary(thresholdResults)} and not matching any prior plays`); newStatefulPlays.push(candidate); //sRecentlyPlayed.push(candidate); } else { - const {data: { playDate, duration }} = candidate; - const {data: { playDate: rplayDate }} = matchingRecent; - if(!playDate.isSame(rplayDate)) { - if(duration !== undefined) { - if(playDate.isAfter(rplayDate.add(duration, 's'))) { + const {data: {playDate, duration}} = candidate; + const {data: {playDate: rplayDate}} = matchingRecent; + if (!playDate.isSame(rplayDate)) { + if (duration !== undefined) { + if (playDate.isAfter(rplayDate.add(duration, 's'))) { this.logger.debug(`${stPrefix} added after ${thresholdResultSummary(thresholdResults)} and having a different timestamp than a prior play`); newStatefulPlays.push(candidate); //sRecentlyPlayed.push(candidate); } } else { const discoveredPlays = this.getRecentlyDiscoveredPlaysByPlatform(genGroupId(candidate)); - if(discoveredPlays.length === 0 || !playObjDataMatch(discoveredPlays[0], candidate)) { + if (discoveredPlays.length === 0 || !playObjDataMatch(discoveredPlays[0], candidate)) { // if most recent stateful play is not this track we'll add it this.logger.debug(`${stPrefix} added after ${thresholdResultSummary(thresholdResults)}. Matched other recent play but could not determine time frame due to missing duration. Allowed due to not being last played track.`); newStatefulPlays.push(candidate); @@ -183,6 +294,7 @@ export default class MemorySource extends AbstractSource { return playObj.data.playDate.isBefore(dayjs().subtract(30, 's')); } } + function sortByPlayDate(a: ProgressAwarePlayObject, b: ProgressAwarePlayObject): number { throw new Error("Function not implemented."); } diff --git a/src/sources/MopidySource.ts b/src/sources/MopidySource.ts index 97779a09..34e8a813 100644 --- a/src/sources/MopidySource.ts +++ b/src/sources/MopidySource.ts @@ -1,6 +1,12 @@ import MemorySource from "./MemorySource.js"; import {MopidySourceConfig} from "../common/infrastructure/config/source/mopidy.js"; -import {FormatPlayObjectOptions, InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js"; +import { + FormatPlayObjectOptions, + InternalConfig, + PlayerStateData, + PlayObject, + SINGLE_USER_PLATFORM_ID +} from "../common/infrastructure/Atomic.js"; import dayjs from "dayjs"; import Mopidy, {models} from "mopidy"; import {URL} from "url"; @@ -177,6 +183,7 @@ export class MopidySource extends MemorySource { return []; } + const state = await this.client.playback.getState(); const currTrack = await this.client.playback.getCurrentTrack(); const playback = await this.client.playback.getTimePosition(); @@ -198,7 +205,13 @@ export class MopidySource extends MemorySource { } } - return this.processRecentPlays(play === undefined ? [] : [play]); + const playerState: PlayerStateData = { + platformId: SINGLE_USER_PLATFORM_ID, + status: state, + play + } + + return this.processRecentPlaysNew([playerState]); } } diff --git a/src/sources/PlayerState/AbstractPlayerState.ts b/src/sources/PlayerState/AbstractPlayerState.ts index 03ae8979..28d2443c 100644 --- a/src/sources/PlayerState/AbstractPlayerState.ts +++ b/src/sources/PlayerState/AbstractPlayerState.ts @@ -1,4 +1,6 @@ import { + CALCULATED_PLAYER_STATUSES, + CalculatedPlayerStatus, ListenRange, PlayData, PlayObject, PlayPlatformId, @@ -6,45 +8,158 @@ import { ReportedPlayerStatus } from "../../common/infrastructure/Atomic.js"; import dayjs, {Dayjs} from "dayjs"; -import {playObjDataMatch} from "../../utils.js"; +import {buildTrackString, formatNumber, genGroupIdStr, playObjDataMatch, progressBar} from "../../utils.js"; +import {Logger} from "@foxxmd/winston"; +import {ListenProgress} from "./ListenProgress.js"; + +export interface PlayerStateIntervals { + staleInterval?: number + orphanedInterval?: number +} + +export interface PlayerStateOptions extends PlayerStateIntervals { +} export abstract class AbstractPlayerState { + logger: Logger; reportedStatus: ReportedPlayerStatus = REPORTED_PLAYER_STATUSES.unknown + calculatedStatus: CalculatedPlayerStatus = CALCULATED_PLAYER_STATUSES.unknown platformId: PlayPlatformId + stateIntervalOptions: Required; currentPlay?: PlayObject playFirstSeenAt?: Dayjs - currentListenRange?: ListenRange - listenRanges: ListenRange[] = []; + playLastUpdatedAt?: Dayjs + currentListenRange?: [ListenProgress, ListenProgress] + listenRanges: [ListenProgress, ListenProgress][] = []; + createdAt: Dayjs = dayjs(); + stateLastUpdatedAt: Dayjs = dayjs(); - protected constructor(platformId: PlayPlatformId, initialPlay?: PlayObject, status?: ReportedPlayerStatus) { + protected constructor(logger: Logger, platformId: PlayPlatformId, opts?: PlayerStateOptions) { this.platformId = platformId; - if(initialPlay !== undefined) { - this.setPlay(initialPlay, status); - } + this.logger = logger.child({labels: [`Player ${this.platformIdStr}`]}); + + const { + staleInterval = 120, + orphanedInterval = 300, + } = opts || {}; + this.stateIntervalOptions = {staleInterval, orphanedInterval: orphanedInterval}; + } + + get platformIdStr() { + return genGroupIdStr(this.platformId); } platformEquals(candidateId: PlayPlatformId) { - return this.platformId[0] === candidateId[0] && this.platformId[1] === candidateId[0]; + return this.platformId[0] === candidateId[0] && this.platformId[1] === candidateId[1]; + } + + isUpdateStale() { + if (this.currentPlay !== undefined) { + return Math.abs(dayjs().diff(this.playLastUpdatedAt, 'seconds')) > this.stateIntervalOptions.staleInterval; + } + return false; + } + + checkStale() { + const isStale = this.isUpdateStale(); + if (isStale && this.calculatedStatus !== CALCULATED_PLAYER_STATUSES.stale) { + this.calculatedStatus = CALCULATED_PLAYER_STATUSES.stale; + this.logger.debug(`Stale after no Play updates for ${Math.abs(dayjs().diff(this.playLastUpdatedAt, 'seconds'))} seconds`); + // end current listening sessions + this.currentListenSessionEnd(); + } + return isStale; } - // TODO track player position from PlayObject against listen session + isOrphaned() { + return dayjs().diff(this.stateLastUpdatedAt, 'seconds') >= this.stateIntervalOptions.orphanedInterval; + } + + isDead() { + return dayjs().diff(this.stateLastUpdatedAt, 'minutes') >= this.stateIntervalOptions.orphanedInterval * 2; + } + + checkOrphaned() { + const isOrphaned = this.isOrphaned(); + if (isOrphaned && this.calculatedStatus !== CALCULATED_PLAYER_STATUSES.orphaned) { + this.calculatedStatus = CALCULATED_PLAYER_STATUSES.orphaned; + this.logger.debug(`Orphaned after no player updates for ${Math.abs(dayjs().diff(this.stateLastUpdatedAt, 'minutes'))} minutes`); + } + return isOrphaned; + } + + isProgressing() { + return AbstractPlayerState.isProgressStatus(this.reportedStatus); + } + + static isProgressStatus(status: ReportedPlayerStatus) { + return status !== 'paused' && status !== 'stopped'; + } + + setState(status?: ReportedPlayerStatus, play?: PlayObject) { + this.stateLastUpdatedAt = dayjs(); + if (play !== undefined) { + return this.setPlay(play, status); + } else if (status !== undefined) { + if (status === 'stopped' && this.reportedStatus !== 'stopped' && this.currentPlay !== undefined) { + this.stopPlayer(); + const play = this.getPlayedObject(); + this.clearPlayer(); + return [play, play]; + } + this.reportedStatus = status; + } else if (this.reportedStatus === undefined) { + this.reportedStatus = REPORTED_PLAYER_STATUSES.unknown; + } + return []; + } + + setPlay(play: PlayObject, status?: ReportedPlayerStatus): [PlayObject, PlayObject?] { + this.playLastUpdatedAt = dayjs(); + if (status !== undefined) { + this.reportedStatus = status; + } - setPlay(play: PlayObject, status: ReportedPlayerStatus = 'playing'): [PlayObject, PlayObject?] { if (this.currentPlay !== undefined) { if (!playObjDataMatch(this.currentPlay, play)/* || (true !== false)*/) { // TODO check new play date and listen range to see if they intersect + this.logger.debug(`Incoming play state does not match existing state, removing existing: ${buildTrackString(this.currentPlay, {include: ['trackId', 'artist', 'track']})}`) this.currentListenSessionEnd(); const played = this.getPlayedObject(); this.setCurrentPlay(play); + if (this.calculatedStatus !== CALCULATED_PLAYER_STATUSES.playing) { + this.calculatedStatus = CALCULATED_PLAYER_STATUSES.unknown; + } return [this.getPlayedObject(), played]; - } else if (status === 'playing') { - this.currentListenSessionContinue(); - } else { + } else if (status !== undefined && !AbstractPlayerState.isProgressStatus(status)) { this.currentListenSessionEnd(); + this.calculatedStatus = this.reportedStatus; + } else { + this.currentListenSessionContinue(play.meta.trackProgressPosition); } } else { this.setCurrentPlay(play); - return [this.getPlayedObject(), undefined]; + this.calculatedStatus = CALCULATED_PLAYER_STATUSES.unknown; } + + if (this.reportedStatus === undefined) { + this.reportedStatus = REPORTED_PLAYER_STATUSES.unknown; + } + + return [this.getPlayedObject(), undefined]; + } + + clearPlayer() { + this.currentPlay = undefined; + this.playLastUpdatedAt = undefined; + this.playFirstSeenAt = undefined; + this.listenRanges = []; + this.currentListenRange = undefined; + } + + stopPlayer() { + this.reportedStatus = 'stopped'; + this.playLastUpdatedAt = dayjs(); + this.currentListenSessionEnd(); } getPlayedObject(): PlayObject { @@ -61,37 +176,95 @@ export abstract class AbstractPlayerState { getListenDuration() { let listenDur: number = 0; - for (const [start, end] of this.listenRanges) { - const dur = start.diff(end, 'seconds'); - listenDur += dur; + let ranges = [...this.listenRanges]; + if (this.currentListenRange !== undefined) { + ranges.push(this.currentListenRange); + } + for (const [start, end] of ranges) { + listenDur += start.getDuration(end); } return listenDur; } - currentListenSessionContinue() { + currentListenSessionContinue(position?: number) { const now = dayjs(); if (this.currentListenRange === undefined) { - this.currentListenRange = [now, now]; + this.logger.debug('Started new Player listen range.'); + this.currentListenRange = [new ListenProgress(now, position), new ListenProgress(now, position)]; } else { - this.currentListenRange = [this.currentListenRange[0], now]; + const newEndProgress = new ListenProgress(now, position); + if (position !== undefined && this.currentListenRange[1].position !== undefined) { + const oldEndProgress = this.currentListenRange[1]; + if (position === oldEndProgress.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.verbose(`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.verbose(`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 = [this.currentListenRange[0], newEndProgress]; } } currentListenSessionEnd() { - if (this.currentListenRange !== undefined && this.currentListenRange[0].unix() !== this.currentListenRange[1].unix()) { + if (this.currentListenRange !== undefined && this.currentListenRange[0].getDuration(this.currentListenRange[1]) !== 0) { + this.logger.debug('Ended current Player listen range.') this.listenRanges.push(this.currentListenRange); } this.currentListenRange = undefined; } - setCurrentPlay(play: PlayObject, status: ReportedPlayerStatus = 'playing') { + setCurrentPlay(play: PlayObject, status?: ReportedPlayerStatus) { this.currentPlay = play; this.playFirstSeenAt = dayjs(); - this.reportedStatus = status; this.listenRanges = []; this.currentListenRange = undefined; - if (status === 'playing') { - this.currentListenSessionContinue(); + + this.logger.debug(`New Play: ${buildTrackString(play, {include: ['trackId', 'artist', 'track']})}`); + + if (status !== undefined) { + this.reportedStatus = status; + } + + if (!['stopped'].includes(this.reportedStatus)) { + this.currentListenSessionContinue(play.meta.trackProgressPosition); + } + } + + textSummary() { + let parts = ['']; + let play: string; + if (this.currentPlay !== undefined) { + parts.push(`${buildTrackString(this.currentPlay, {include: ['trackId', 'artist', 'track']})} @ ${this.playFirstSeenAt.toISOString()}`); + } + 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[1].position !== undefined && this.currentPlay.data.duration !== undefined) { + progress = `${progressBar(this.currentListenRange[1].position / this.currentPlay.data.duration, 1, 15)} ${formatNumber(this.currentListenRange[1].position, {toFixed: 0})}/${formatNumber(this.currentPlay.data.duration, {toFixed: 0})}s | `; } + let listenedPercent = ''; + if (this.currentPlay !== undefined && this.currentPlay.data.duration !== undefined) { + listenedPercent = formatNumber((this.getListenDuration() / this.currentPlay.data.duration) * 100, { + suffix: '%', + toFixed: 0 + }) + } + parts.push(`${progress}Listened For: ${formatNumber(this.getListenDuration(), {toFixed: 0})}s ${listenedPercent}`); + return parts.join('\n'); + } + + logSummary() { + this.logger.debug(this.textSummary()); } } diff --git a/src/sources/PlayerState/GenericPlayerState.ts b/src/sources/PlayerState/GenericPlayerState.ts new file mode 100644 index 00000000..8102951b --- /dev/null +++ b/src/sources/PlayerState/GenericPlayerState.ts @@ -0,0 +1,9 @@ +import {AbstractPlayerState, PlayerStateOptions} from "./AbstractPlayerState.js"; +import {Logger} from "@foxxmd/winston"; +import {PlayPlatformId} from "../../common/infrastructure/Atomic.js"; + +export class GenericPlayerState extends AbstractPlayerState { + constructor(logger: Logger, platformId: PlayPlatformId, opts?: PlayerStateOptions) { + super(logger, platformId, opts); + } +} diff --git a/src/sources/PlayerState/JellyfinPlayerState.ts b/src/sources/PlayerState/JellyfinPlayerState.ts new file mode 100644 index 00000000..3e9978ac --- /dev/null +++ b/src/sources/PlayerState/JellyfinPlayerState.ts @@ -0,0 +1,18 @@ +import {GenericPlayerState} from "./GenericPlayerState.js"; +import {Logger} from "@foxxmd/winston"; +import {PlayObject, PlayPlatformId, ReportedPlayerStatus} from "../../common/infrastructure/Atomic.js"; +import {PlayerStateOptions} from "./AbstractPlayerState.js"; + +export class JellyfinPlayerState extends GenericPlayerState { + constructor(logger: Logger, platformId: PlayPlatformId, opts?: PlayerStateOptions) { + super(logger, platformId, opts); + } + + setState(status?: ReportedPlayerStatus, play?: PlayObject) { + let stat: ReportedPlayerStatus = status; + if(status === undefined && play.meta?.event === 'PlaybackProgress') { + stat = 'playing'; + } + return super.setState(stat, play); + } +} diff --git a/src/sources/PlayerState/ListenProgress.ts b/src/sources/PlayerState/ListenProgress.ts new file mode 100644 index 00000000..ead3da93 --- /dev/null +++ b/src/sources/PlayerState/ListenProgress.ts @@ -0,0 +1,20 @@ +import dayjs, {Dayjs} from "dayjs"; +import {PlayProgress} from "../../common/infrastructure/Atomic.js"; + +export class ListenProgress implements PlayProgress { + + constructor( + public timestamp: Dayjs = dayjs(), + public position?: number, + public positionPercent?: number + ) { + } + + getDuration(end: ListenProgress) { + if (this.position !== undefined && end.position !== undefined) { + return end.position - this.position; + } else { + return end.timestamp.diff(this.timestamp, 'seconds'); + } + } +} diff --git a/src/sources/SpotifySource.ts b/src/sources/SpotifySource.ts index c141aaaf..a7b30783 100644 --- a/src/sources/SpotifySource.ts +++ b/src/sources/SpotifySource.ts @@ -7,7 +7,13 @@ import { import SpotifyWebApi from "spotify-web-api-node"; import AbstractSource, {RecentlyPlayedOptions} from "./AbstractSource.js"; import {SpotifySourceConfig} from "../common/infrastructure/config/source/spotify.js"; -import {FormatPlayObjectOptions, InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js"; +import { + FormatPlayObjectOptions, + InternalConfig, + NO_USER, + PlayerStateData, + PlayObject, ReportedPlayerStatus, SourceData +} from "../common/infrastructure/Atomic.js"; import PlayHistoryObject = SpotifyApi.PlayHistoryObject; import EventEmitter from "events"; import CurrentlyPlayingObject = SpotifyApi.CurrentlyPlayingObject; @@ -39,12 +45,12 @@ export default class SpotifySource extends MemorySource { super('spotify', name, config, internal, emitter); const { data: { - interval = 30, + interval = 15, } = {} } = config; - if (interval < 15) { - this.logger.warn('Interval should be 15 seconds or above...😬'); + if (interval < 5) { + this.logger.warn('Interval should be 5 seconds or above...😬 preferably 15'); } this.config.data.interval = interval; @@ -251,14 +257,14 @@ export default class SpotifySource extends MemorySource { } getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { - const plays: PlayObject[] = []; + const plays: SourceData[] = []; if(this.canGetState) { const state = await this.getCurrentPlaybackState(); - if(state.play !== undefined) { + if(state.playerState !== undefined) { if(state.device.is_private_session) { - this.logger.debug(`Will not track play on Device ${state.device.name} because it is a private session: ${buildTrackString(state.play)}`); + this.logger.debug(`Will not track play on Device ${state.device.name} because it is a private session: ${buildTrackString(state.playerState.play)}`); } else { - plays.push(state.play); + plays.push(state.playerState); } } } else { @@ -267,7 +273,7 @@ export default class SpotifySource extends MemorySource { plays.push(currPlay); } } - return this.processRecentPlays(plays, true); + return this.processRecentPlaysNew(plays); } getPlayHistory = async (options: RecentlyPlayedOptions = {}) => { @@ -290,18 +296,39 @@ export default class SpotifySource extends MemorySource { return undefined; } - getCurrentPlaybackState = async (logError = true): Promise<{device?: UserDevice, play?: PlayObject}> => { + getCurrentPlaybackState = async (logError = true): Promise<{device?: UserDevice, playerState?: PlayerStateData}> => { try { const funcState = (api: SpotifyWebApi) => api.getMyCurrentPlaybackState(); const res = await this.callApi>(funcState); - const {body: { - device, - item - } = {}} = res; - return { - device: device === null ? undefined : device, - play: item !== null && item !== undefined ? SpotifySource.formatPlayObj(res.body, {newFromSource: true}) : undefined + const { + body: { + device, + item, + is_playing, + timestamp, + progress_ms, + } = {} + } = res; + if(device !== undefined) { + let status: ReportedPlayerStatus = 'stopped'; + if(is_playing) { + status = 'playing'; + } else if(item !== null && item !== undefined) { + status = 'paused'; + } + return { + device, + playerState: { + platformId: [combinePartsToString([shortDeviceId(device.id), device.name]), NO_USER], + status, + play: item !== null && item !== undefined ? SpotifySource.formatPlayObj(res.body, {newFromSource: true}) : undefined, + timestamp: dayjs(timestamp), + position: progress_ms !== null && progress_ms !== undefined ? progress_ms / 1000 : undefined, + } + } } + + return {}; } catch (e) { if(logError) { this.logger.error(`Error occurred while trying to retrieve current playback state: ${e.message}`); diff --git a/src/sources/SubsonicSource.ts b/src/sources/SubsonicSource.ts index aedb1775..ac48d816 100644 --- a/src/sources/SubsonicSource.ts +++ b/src/sources/SubsonicSource.ts @@ -193,6 +193,6 @@ export class SubsonicSource extends MemorySource { } = resp; // sometimes subsonic sources will return the same track as being played twice on the same player, need to remove this so we don't duplicate plays const deduped = removeDuplicates(entry.map(SubsonicSource.formatPlayObj)); - return this.processRecentPlays(deduped); + return this.processRecentPlaysNew(deduped); } } diff --git a/src/utils.ts b/src/utils.ts index 97036953..4cd9add2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -6,8 +6,9 @@ import JSON5 from 'json5'; import { TimeoutError, WebapiError } from "spotify-web-api-node/src/response-error.js"; import Ajv, {Schema} from 'ajv'; import { + asPlayerStateData, DEFAULT_SCROBBLE_DURATION_THRESHOLD, - lowGranularitySources, NO_DEVICE, NO_USER, + lowGranularitySources, NO_DEVICE, NO_USER, numberFormatOptions, PlayerStateData, PlayObject, PlayPlatformId, ProgressAwarePlayObject, RegExResult, RemoteIdentityParts, ScrobbleThresholdResult, TrackStringOptions @@ -650,12 +651,22 @@ export function parseBool(value: any, prev: any = false): boolean { throw new Error('Not a boolean value.'); } -export const genGroupIdStr = (play: PlayObject) => { +export const genGroupIdStrFromPlay = (play: PlayObject) => { const groupId = genGroupId(play); - return `${groupId[0]}-${groupId[1]}`; + return genGroupIdStr(groupId); }; +export const genGroupIdStr = (id: PlayPlatformId) => { + return `${id[0]}-${id[1]}`; +} export const genGroupId = (play: PlayObject): PlayPlatformId => [play.meta.deviceId ?? NO_DEVICE, play.meta.user ?? NO_USER]; +export const getPlatformIdFromData = (data: PlayObject | PlayerStateData) => { + if(asPlayerStateData(data)) { + return data.platformId; + } + return genGroupId(data); +} + export const fileOrDirectoryIsWriteable = (location: string) => { const pathInfo = pathUtil.parse(location); const isDir = pathInfo.ext === ''; @@ -802,3 +813,58 @@ export const intersect = (a: Array, b: Array) => { const intersection = new Set([...setA].filter(x => setB.has(x))); return Array.from(intersection); } + +/** + * https://github.com/Mw3y/Text-ProgressBar/blob/master/ProgressBar.js + * */ +export const progressBar = (value: number, maxValue: number, size: number) => { + const percentage = value / maxValue; // Calculate the percentage of the bar + const progress = Math.round((size * percentage)); // Calculate the number of square caracters to fill the progress side. + const emptyProgress = size - progress; // Calculate the number of dash caracters to fill the empty progress side. + + const progressText = '▇'.repeat(progress); // Repeat is creating a string with progress * caracters in it + const emptyProgressText = '—'.repeat(emptyProgress); // Repeat is creating a string with empty progress * caracters in it + const percentageText = Math.round(percentage * 100) + '%'; // Displaying the percentage of the bar + + const bar = `[${progressText}${emptyProgressText}]${percentageText}`; + return bar; +}; + +export const formatNumber = (val: number | string, options?: numberFormatOptions) => { + const { + toFixed = 2, + defaultVal = null, + prefix = '', + suffix = '', + round, + } = options || {}; + let parsedVal = typeof val === 'number' ? val : Number.parseFloat(val); + if (Number.isNaN(parsedVal)) { + return defaultVal; + } + if(!Number.isFinite(val)) { + return 'Infinite'; + } + let prefixStr = prefix; + const {enable = false, indicate = true, type = 'round'} = round || {}; + if (enable && !Number.isInteger(parsedVal)) { + switch (type) { + case 'round': + parsedVal = Math.round(parsedVal); + break; + case 'ceil': + parsedVal = Math.ceil(parsedVal); + break; + case 'floor': + parsedVal = Math.floor(parsedVal); + } + if (indicate) { + prefixStr = `~${prefix}`; + } + } + const localeString = parsedVal.toLocaleString(undefined, { + minimumFractionDigits: toFixed, + maximumFractionDigits: toFixed, + }); + return `${prefixStr}${localeString}${suffix}`; +};