import type {Logger} from "@foxxmd/logging"; import dayjs, { type Dayjs } from "dayjs"; import type { EventEmitter } from "events"; import { AsyncTask, SimpleIntervalJob, ToadScheduler } from "toad-scheduler"; import { PARSED_FROM, type PlayObject, SOURCE_SOT, type SOURCE_SOT_TYPES, type SourcePlayerJson, type SourcePlayerObj } from "../../core/Atomic.ts"; import { buildTrackString } from "../../core/StringUtils.ts"; import { asPlayerStateDataMaybePlay, type InternalConfig, type PlayerStateDataMaybePlay, type ProgressAwarePlayObject} from "../common/infrastructure/Atomic.ts"; import { CALCULATED_PLAYER_STATUSES } from '../../core/Atomic.ts'; import type {PlayPlatformId} from '../../core/Atomic.ts'; import type {SourceConfig} from '../common/infrastructure/config/source/sources.ts'; import type {SourceType} from "../../core/Atomic.ts"; import type {PollingOptions} from "../common/infrastructure/config/common.ts"; import { getPlatformIdFromData, isDebugMode, playObjDataMatch, thresholdResultSummary, } from "../utils.ts"; import { genGroupIdStr } from '../../core/PlayUtils.ts'; import { formatNumber } from '../../core/DataUtils.ts'; import { timePassesScrobbleThreshold } from "../utils/TimeUtils.ts"; import { timeToHumanTimestamp } from "../../core/TimeUtils.ts"; import { PromisePool } from "@supercharge/promise-pool"; import AbstractSource from "./AbstractSource.ts"; import type { AbstractPlayerState} from "./PlayerState/AbstractPlayerState.ts"; import { createPlayerOptions, type PlayerStateOptions } from "./PlayerState/AbstractPlayerState.ts"; import { GenericPlayerState } from "./PlayerState/GenericPlayerState.ts"; import { hashObject } from "../utils/StringUtils.ts"; import type {ComponentSourceApiJson} from "../../core/Api.ts"; const EXPECTED_NON_DISCOVERED_REASON = 'not added because an identical play with the same timestamp was already discovered.'; export default class MemorySource extends AbstractSource { playerSourceOfTruth: SOURCE_SOT_TYPES = SOURCE_SOT.PLAYER; /* * MemorySource uses its own state to maintain a list of recently played tracks and determine if a track is valid. * This is necessary for any source that * * doesn't have its own source of truth for "recently played" or * * that does not return "started at" and "duration" timestamps for recent plays or * * where these timestamps don't have enough granularity (IE second accuracy) * such as subsonic and jellyfin */ players: Map = new Map(); playerState: Map = new Map(); playerCleanupDiscoveryAttempt: Map = new Map(); deceasedPlayers: Map = new Map(); scheduler: ToadScheduler = new ToadScheduler(); protected isPositional: boolean = false; constructor(type: SourceType, name: string, config: SourceConfig, internal: InternalConfig, emitter: EventEmitter) { super(type, name, config, internal, emitter); // player cleanup on *schedule* is needed when the Source is non-polling (ingress) // because if the source stops sending updates then processRecentPlays() was never called so we never remove old players this.scheduler.addSimpleIntervalJob(new SimpleIntervalJob({ seconds: 10 }, new AsyncTask('Player Cleanup', (): Promise => { if (this.canPoll) { return Promise.resolve(); } return PromisePool .withConcurrency(1) .for(this.players.keys()) .process(async (key) => { await this.cleanupPlayer(key); }); }))); } [Symbol.dispose]() { this.scheduler.stop(); for(const job of this.scheduler.getAllJobs()) { this.scheduler.removeById(job.id); } for(const p of this.players.keys()) { this.deletePlayer(p); } } async [Symbol.asyncDispose]() { await super[Symbol.asyncDispose](); this.scheduler.stop(); for(const job of this.scheduler.getAllJobs()) { this.scheduler.removeById(job.id); } this[Symbol.dispose](); } cleanupPlayer = async (key: string): Promise => { const player = this.players.get(key); if(player === undefined) { this.logger.warn({labels: 'Player Cleanup'},`No Player with ID ${key} exists! Cannot cleanup.`); return; } let discoveredCleanupPlay: PlayObject | undefined; let label = 'Player Cleanup', deletePlayer = false; // no communication from the source was received for this player const isStale = player.checkStale(); if (isStale && player.checkOrphaned() && player.isDead()) { deletePlayer = true; label = 'Dead Player Cleanup'; } else if (isStale) { label = 'Stale Player Cleanup'; const state = player.getApiState(); const stateHash = hashObject(state); if(stateHash !== this.playerState.get(key)) { this.playerState.set(key, stateHash); this.emitEvent('playerUpdate', { ...state, options: { wasMonitored: this.isMonitoring(), scrobbleTo: this.clients } }); } if(this.config.options?.logPlayerState === true || isDebugMode()) { player.logSummary(); } } else { // player is not stale this.playerCleanupDiscoveryAttempt.delete(key); return; } // player was stale or orphaned/dead // if we haven't already tried to discover any in-progress plays then do it now (and only once) if(!this.playerCleanupDiscoveryAttempt.has(key)) { this.playerCleanupDiscoveryAttempt.set(key, true); // get play as completed const cleanupPlay = player.getPlayedObject(true); let discoverablePlay: boolean; if(cleanupPlay !== undefined) { const [discoverable, discoverableReason] = await this.isListenedPlayDiscoverable(cleanupPlay); discoverablePlay = discoverable; if(this.playerSourceOfTruth === SOURCE_SOT.PLAYER) { player.logger.verbose({labels: label}, discoverableReason); } if(discoverable) { discoveredCleanupPlay = cleanupPlay; // we are discovering/scrobbling play // and since player is now stale we should treat this "session" as ended // -- so if user resumes a stale play later its a new session (new real time period of them listening) // basically this is the same as if the player was orphaned and removed // // so we remove listen ranges so the old accumulated listen time can't be used for the "new" listening session player.listenRanges = []; player.currentListenRange = undefined; } } } if(deletePlayer) { this.deletePlayer(player.platformIdStr, `Removed after being orphaned for ${timeToHumanTimestamp(dayjs.duration(player.stateIntervalOptions.orphanedInterval, 'seconds'))}`); } return discoveredCleanupPlay; } playersToObject = (): Record => { if(this.players.size === 0) { return {}; } const record: Record = {}; for(const [k,v] of this.players.entries()) { record[k] = v.getApiState(); } return record; } public getApiData(): ComponentSourceApiJson { return { ...super.getApiData(), sot: this.playerSourceOfTruth, players: this.playersToObject() as unknown as Record } } 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, { ...createPlayerOptions(this.config.data as Partial, this.playerSourceOfTruth, this.logger), ...opts })); this.playerState.set(idStr, ''); } hasPlayer = (data: string | PlayerStateDataMaybePlay): boolean => { let id: string; if(typeof data === 'string') { id = data; } else { id = genGroupIdStr(getPlatformIdFromData(data)); } return this.players.has(id); } isZombiePlayer = (id: string, lastUpdated: Dayjs): boolean => { return this.deceasedPlayers.has(id) && this.deceasedPlayers.get(id).isSame(lastUpdated); } genPlayerId = (data: PlayObject | PlayerStateDataMaybePlay): string => { return genGroupIdStr(getPlatformIdFromData(data)); } deletePlayer = (id: string, reason?: string) => { if(!this.players.has(id)) { return; } if(reason !== undefined) { this.players.get(id)?.logger.debug(reason); } using player = this.players.get(id); this.deceasedPlayers.set(id, player.stateLastUpdatedAt); player[Symbol.dispose](); this.players.delete(id); this.playerState.delete(id); this.emitEvent('playerDelete', {platformId: id}); } pickPlatformSession = (sessions: (PlayObject | PlayerStateDataMaybePlay)[], player: AbstractPlayerState): PlayObject | PlayerStateDataMaybePlay => { if(sessions.length > 1) { player.logger.debug(`More than one data/state found in incoming data, will only use first found.`); } return sessions[0]; } processRecentPlays = async (datas: (PlayObject | PlayerStateDataMaybePlay)[], reportedTS?: Dayjs) => { const { options: { scrobbleThresholds = {} } } = this.config; const newStatefulPlays: PlayObject[] = []; // create any new players from incoming data for (const data of datas) { const id = getPlatformIdFromData(data); const idStr = this.genPlayerId(data); if (!this.players.has(idStr)) { if(asPlayerStateDataMaybePlay(data) && data.stateUpdatedAt !== undefined) { if(this.isZombiePlayer(idStr, data.stateUpdatedAt)) { this.logger.trace(`Not creating player for ${idStr} because last state update timestamp has not changed since it was deleted.`); continue; } else { // cleaning up in case it already existed but has new timestamp this.deceasedPlayers.delete(idStr); } } this.setNewPlayer(idStr, this.logger, id); if(!this.multiPlatform && this.players.size > 1) { // new platform should have old platform data transferred const [id,firstPlayer] = Array.from(this.players.entries())[0]; const newPlayer = this.players.get(idStr); firstPlayer.transferToNewPlayer(newPlayer); this.deletePlayer(id, 'Removed due to player transfer'); } } } for (const [key, player] of this.players.entries()) { let incomingData: PlayObject | PlayerStateDataMaybePlay; // 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); }); let hasFreshState = false; if (relevantDatas.length > 0) { hasFreshState = true; this.lastActivityAt = dayjs(); incomingData = this.pickPlatformSession(relevantDatas, player); if(asPlayerStateDataMaybePlay(incomingData) && incomingData.stateUpdatedAt !== undefined && player.stateLastUpdatedAt.isSame(incomingData.stateUpdatedAt)) { hasFreshState = false; player.logger.trace('Skipping update because it has the same timestamp as the previous update'); } } // we've received some form of communication from the source for this player if (hasFreshState) { this.lastActivityAt = dayjs(); // reset any player cleanup state since we got fresh data this.playerCleanupDiscoveryAttempt.delete(key); incomingData = this.pickPlatformSession(relevantDatas, player); let playerState: PlayerStateDataMaybePlay; if(asPlayerStateDataMaybePlay(incomingData)) { playerState = incomingData; } 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, reportedTS); const candidate = prevPlay !== undefined ? prevPlay : currPlay; const playChanged = prevPlay !== undefined; // wait to discover play until it is stale or current play has changed // so that our discovered track has an accurate "listenedFor" count if (candidate !== undefined && (playChanged || player.isUpdateStale())) { const [discoverable, discoverableReason] = await this.isListenedPlayDiscoverable(candidate); if(discoverable) { if(this.playerSourceOfTruth === SOURCE_SOT.PLAYER) { player.logger.verbose(discoverableReason); } newStatefulPlays.push(candidate) } else if(playChanged && this.playerSourceOfTruth === SOURCE_SOT.PLAYER) { player.logger.verbose(discoverableReason); } } if(this.config.options?.logPlayerState === true || isDebugMode()) { player.logSummary(); } const apiState = player.getApiState(); this.playerState.set(key, hashObject(apiState)) this.emitEvent('playerUpdate', { ...apiState, options: { wasMonitored: this.isMonitoring(), scrobbleTo: this.clients } }); } else { const playFromCleanup = await this.cleanupPlayer(key); if(playFromCleanup !== undefined) { newStatefulPlays.push(playFromCleanup); } } } if(this.playerSourceOfTruth === SOURCE_SOT.PLAYER) { if(this.players.size > 0) { this.setStatus(`Monitoring ${this.players.size} active Players`); } else { this.setStatus(`Monitoring for new Players`); } } return newStatefulPlays.map((x) => ({...x, meta: {...x.meta, parsedFrom: PARSED_FROM.player}})); } protected isListenedPlayDiscoverable = async (candidate: PlayObject): Promise<[boolean, string]> => { const { options: { scrobbleThresholds = {} } } = this.config; const stPrefix = `${buildTrackString(candidate, {include: ['trackId', 'artist', 'track']})}`; const thresholdResults = timePassesScrobbleThreshold(scrobbleThresholds, candidate.data.listenedFor, candidate.data.duration); if (thresholdResults.passes) { const matchingRecent = await this.existingDiscovered(candidate); //sRecentlyPlayed.find(x => playObjDataMatch(x, candidate)); if (matchingRecent.match === false) { return [true,`${stPrefix} added after ${thresholdResultSummary(thresholdResults)} and not matching any prior plays`]; } else { const {data: {playDate, duration}} = candidate; const {closestMatchedPlay: {data: {playDate: rplayDate}} = {}} = matchingRecent; if (!playDate.isSame(rplayDate)) { if (duration !== undefined) { if (playDate.isAfter(rplayDate.add(duration, 's'))) { return [true,`${stPrefix} added after ${thresholdResultSummary(thresholdResults)} and having a different timestamp than a prior play`]; } return [false, `${stPrefix} ${EXPECTED_NON_DISCOVERED_REASON}`] } else { const discoveredPlays = await this.getRecentlyDiscoveredPlays(); if (discoveredPlays.length === 0 || !playObjDataMatch(discoveredPlays[0], candidate)) { // if most recent stateful play is not this track we'll add it return [true,`${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.`]; } return [false, `${stPrefix} not added because it matched the last discovered play and could not determine time frame of play`]; } } else { return [false, `${stPrefix} ${EXPECTED_NON_DISCOVERED_REASON}`]; } } } return [false,`${stPrefix} not added because ${thresholdResultSummary(thresholdResults)}.`]; } recentlyPlayedTrackIsValid = (playObj: any) => playObj.data.playDate.isBefore(dayjs().subtract(30, 's')) protected getInterval(log?: boolean): number { /** * If any player is progressing, reports position, and play has duration * then we can modify polling interval so that we check source data just before track is supposed to end * which will give us more accurate data on when player moves to the next play = better duration reporting to scrobble clients * -- additionally, will have better confidence for fudging 100% duration played * */ let interval = super.getInterval(); if(this.players.size === 0) { return interval; } let logDecrease: undefined | string; for(const player of this.players.values()) { if(player.calculatedStatus === CALCULATED_PLAYER_STATUSES.playing) { const pos = player.getPosition(); if(pos !== undefined && this.isPositional && player.currentPlay !== undefined && player.currentPlay.data?.duration !== undefined) { const { data: { duration } = {} } = player.currentPlay; const remaining = duration - pos; if(remaining < interval + 2) { // interval should be at least 1 second so we don't spam sources when polling interval = Math.max(1, remaining - 2); logDecrease = `Temporarily decreasing polling interval to ${formatNumber(interval)}s due to Player ${player.platformIdStr} reporting track duration remaining (${formatNumber(remaining)}s) less than normal interval (${formatNumber(super.getInterval())}s)`; } } } } if(logDecrease !== undefined && log) { this.logger.debug(logDecrease); } return interval; } public async destroy() { this.scheduler.stop(); await super.destroy(); } } const sortByPlayDate = (a: ProgressAwarePlayObject, b: ProgressAwarePlayObject): number => { throw new Error("Function not implemented."); };