Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/FoxxMD/multi-scrobbler. Scrobble plays from multiple sources to multiple clients docs.multi-scrobbler.app
deezer docker jellyfin koito lastfm listenbrainz maloja mopidy mpris music music-assistant plex scrobble self-hosted spotify subsonic tautulli youtube-music
Something went wrong. Try again.
24 kB · 522 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523import { childLogger, type Logger } from "@foxxmd/logging";import dayjs, { type Dayjs } from "dayjs";import { type PlayObject, type PlayProgress, type Second, SOURCE_SOT, type SOURCE_SOT_TYPES, type SourcePlayerObj } from "../../../core/Atomic.ts";import { buildTrackString } from "../../../core/StringUtils.ts";import { asPlayerStateData, type PlayerStateData, type PlayerStateDataMaybePlay,} from "../../common/infrastructure/Atomic.ts";import { CALCULATED_PLAYER_STATUSES } from '../../../core/Atomic.ts';import type {CalculatedPlayerStatus} from '../../../core/Atomic.ts';import { REPORTED_PLAYER_STATUSES } from '../../../core/Atomic.ts';import type {ReportedPlayerStatus} from '../../../core/Atomic.ts';import type {PlayPlatformId} from '../../../core/Atomic.ts';import type {PollingOptions} from "../../common/infrastructure/config/common.ts";import { playObjDataMatch, progressBar } from "../../utils.ts";import { genGroupIdStr } from '../../../core/PlayUtils.ts';import { formatNumber } from '../../../core/DataUtils.ts';import type {ListenProgress} from "./ListenProgress.ts";import type { ListenRange} from "./ListenRange.ts";import { ListenRangePositional } from "./ListenRange.ts";import { closeToPlayEnd, closeToPlayStart, repeatDurationPlayed } from "../../utils/TimeUtils.ts";import { timeToHumanTimestamp } from "../../../core/TimeUtils.ts";import { todayAwareFormat } from "../../../core/TimeUtils.ts";
export interface PlayerStateIntervals { staleInterval?: number orphanedInterval?: number}
export interface PlayerStateOptions extends PlayerStateIntervals { allowedDrift?: number rtTruth?: boolean}
export const DefaultPlayerStateOptions: PlayerStateOptions = {};
export const createPlayerOptions = (pollingOpts?: Partial<PollingOptions>, sot: SOURCE_SOT_TYPES = SOURCE_SOT.PLAYER, logger?: Logger): PlayerStateOptions => { const { interval = 30, maxInterval = 60, staleAfter, orphanedAfter } = pollingOpts || {};
let sa = staleAfter, oa = orphanedAfter;
// if this player is not the source of truth we don't care about waiting around to see if the state comes back // in fact, we probably want to get rid of it as fast as possible since its superficial and more of an ephemeral "Now Playing" status than something we are actually tracking const staleAfterDefault = sot === SOURCE_SOT.PLAYER ? interval * 3 : interval; const orphanedAfterDefault = sot === SOURCE_SOT.PLAYER ? interval * 5 : maxInterval;
if(sa === undefined) { sa = staleAfterDefault; } if(oa === undefined) { oa = orphanedAfterDefault; } if(oa < sa) { oa = sa; if(logger !== undefined) { logger.warn(`'orhanedAfter' (${oa}s) was less than 'staleAfter' (${sa}s) which is not allowed! 'orhanedAfter' has been set to equal 'staleAfter'`); } }
return { staleInterval: sa, orphanedInterval: oa }}
export abstract class AbstractPlayerState { logger: Logger; reportedStatus: ReportedPlayerStatus = REPORTED_PLAYER_STATUSES.unknown calculatedStatus: CalculatedPlayerStatus = CALCULATED_PLAYER_STATUSES.unknown platformId: PlayPlatformId sessionId?: string stateIntervalOptions: Required<PlayerStateIntervals>; currentPlay?: PlayObject playFirstSeenAt?: Dayjs playLastUpdatedAt?: Dayjs isRepeatPlay?: boolean = false; currentListenRange?: ListenRange listenRanges: ListenRange[] = []; createdAt: Dayjs = dayjs(); stateLastUpdatedAt: Dayjs = dayjs(1);
lastPlay?: PlayObject lastPlayUpdatedAt?: Dayjs
protected constructor(logger: Logger, platformId: PlayPlatformId, opts: PlayerStateOptions = DefaultPlayerStateOptions) { this.platformId = platformId; this.logger = childLogger(logger, `Player ${this.platformIdStr}`);
const { staleInterval = 120, orphanedInterval = 300, } = opts; this.stateIntervalOptions = {staleInterval, orphanedInterval: orphanedInterval}; }
[Symbol.dispose]() { if(this.currentListenRange !== undefined) { this.currentListenRange[Symbol.dispose](); } for(const lr of this.listenRanges) { lr[Symbol.dispose](); } }
protected abstract newListenProgress(data?: Partial<PlayProgress>): ListenProgress; protected abstract newListenRange(start?: ListenProgress, end?: ListenProgress, options?: object): ListenRange;
protected getStaleInterval(): number { return this.stateIntervalOptions.staleInterval; }
protected getOrphanedInterval(): number { return this.stateIntervalOptions.orphanedInterval; }
get platformIdStr() { return genGroupIdStr(this.platformId); }
platformEquals(candidateId: PlayPlatformId) { return this.platformId[0] === candidateId[0] && this.platformId[1] === candidateId[1]; }
isUpdateStale(reportedTS?: Dayjs) { if (this.currentPlay !== undefined) { return Math.abs((reportedTS ?? dayjs()).diff(this.playLastUpdatedAt, 'seconds')) > this.getStaleInterval(); } return false; }
checkStale(reportedTS?: Dayjs) { const isStale = this.isUpdateStale(reportedTS); if (isStale && ![CALCULATED_PLAYER_STATUSES.stale, CALCULATED_PLAYER_STATUSES.orphaned].includes(this.calculatedStatus)) { this.calculatedStatus = CALCULATED_PLAYER_STATUSES.stale; this.logger.debug(`Stale after no Play updates for ${timeToHumanTimestamp(Math.abs((reportedTS ?? dayjs()).diff(this.playLastUpdatedAt, 'ms')))} (staleAfter ${this.getStaleInterval()}s)`); // end current listening sessions this.currentListenSessionEnd(); } return isStale; }
isOrphaned() { return dayjs().diff(this.stateLastUpdatedAt, 'seconds') >= this.getOrphanedInterval(); }
isDead() { return dayjs().diff(this.stateLastUpdatedAt, 'seconds') >= this.getOrphanedInterval()* 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 ${timeToHumanTimestamp(Math.abs(dayjs().diff(this.stateLastUpdatedAt, 'ms')))} ${Math.abs(dayjs().diff(this.stateLastUpdatedAt, 'minutes'))} (orhanedAfter ${this.getOrphanedInterval()}s)`); } return isOrphaned; }
isProgressing() { return AbstractPlayerState.isProgressStatus(this.reportedStatus); }
static isProgressStatus(status: ReportedPlayerStatus) { return status !== 'paused' && status !== 'stopped'; }
update(state: PlayerStateDataMaybePlay, reportedTS?: Dayjs) { this.stateLastUpdatedAt = state.stateUpdatedAt ?? dayjs(); if(!this.stateLastUpdatedAt.isValid()) { this.stateLastUpdatedAt = dayjs(); }
const {play, status} = state;
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); this.clearPlayer(); return [play, play]; } this.reportedStatus = status; } else if (this.reportedStatus === undefined) { this.reportedStatus = REPORTED_PLAYER_STATUSES.unknown; } return []; }
protected setPlay(state: PlayerStateData, reportedTS?: Dayjs): [PlayObject, PlayObject?] { const {play, status, sessionId, playUpdatedAt} = state; this.playLastUpdatedAt = reportedTS ?? playUpdatedAt ?? dayjs(); if (status !== undefined) { this.reportedStatus = status; } this.sessionId = sessionId;
if (this.currentPlay !== undefined) { 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.isRepeatPlay = false; this.lastPlay = played; this.lastPlayUpdatedAt = playUpdatedAt ?? dayjs(); this.setCurrentPlay(state, {reportedTS}); if (this.calculatedStatus !== CALCULATED_PLAYER_STATUSES.playing) { this.calculatedStatus = CALCULATED_PLAYER_STATUSES.unknown; } return [this.getPlayedObject(), played]; } else if (status !== undefined && !AbstractPlayerState.isProgressStatus(status)) { this.currentListenSessionEnd(); this.calculatedStatus = this.reportedStatus; } else if (this.isSessionRepeat(state.position, reportedTS)) { // if we detect the track has been restarted end listen session and treat as a new play const sessionless = this.currentListenRange === undefined; this.currentListenSessionEnd(); const played = this.getPlayedObject(true); play.data.playDate = dayjs(); this.isRepeatPlay = true; if(sessionless) { // if repeat occurred due to previous closed session // then we need to force a new session or else we'll miss a listening interval this.currentListenSessionContinue(state.position, reportedTS); } this.logger.debug('New Play is a repeat'); this.setCurrentPlay(state, {reportedTS}); return [this.getPlayedObject(), played]; } else { if(this.currentListenRange !== undefined) { const [isSeeked, seekedPos] = this.currentListenRange.seeked(state.position, reportedTS); if (isSeeked !== false) { 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(state.position, reportedTS); } } else { this.isRepeatPlay = false; // compensate for Players that report as STOPPED between Plays // -- should we check for closeToPlayStart() as well? if(this.lastPlay !== undefined) { const lastPlayDiff = Math.abs(this.lastPlayUpdatedAt.diff(dayjs(), 's')); const shortDiff = lastPlayDiff < 20; const lastPlayMatch = playObjDataMatch(play, this.lastPlay); this.isRepeatPlay = shortDiff && lastPlayMatch; this.logger.debug(`Last Play ${shortDiff ? 'was' : 'was not'} within 20s of new Player session and ${lastPlayMatch ? 'does' : 'does not'} match new Play -- ${this.isRepeatPlay ? 'is' : 'is not'} a repeat Play`); } this.setCurrentPlay(state, {reportedTS}); this.calculatedStatus = CALCULATED_PLAYER_STATUSES.unknown; }
if (this.reportedStatus === undefined) { this.reportedStatus = REPORTED_PLAYER_STATUSES.unknown; }
return [this.getPlayedObject(), undefined]; }
protected incomingPlayMatchesExisting(play: PlayObject): boolean { return playObjDataMatch(this.currentPlay, play); }
protected clearPlayer() { this.lastPlay = this.currentPlay; this.lastPlayUpdatedAt = dayjs(); this.currentPlay = undefined; this.playLastUpdatedAt = undefined; this.playFirstSeenAt = undefined; this.listenRanges = []; this.currentListenRange = undefined; this.isRepeatPlay = false; }
protected stopPlayer() { this.reportedStatus = 'stopped'; this.calculatedStatus = 'stopped'; this.playLastUpdatedAt = dayjs(); this.currentListenSessionEnd(); }
public getPlayedObject(completed: boolean = false): PlayObject | undefined { if(this.currentPlay !== undefined) { const ranges = [...this.listenRanges]; if (this.currentListenRange !== undefined) { ranges.push(this.currentListenRange); } if(completed) { this.logger.debug('Generating play object with playDateCompleted'); } const { data, meta, ...rest } = this.currentPlay; return { data: { ...data, playDate: this.playFirstSeenAt, listenedFor: this.getListenDuration(), listenRanges: ranges.map(x => ({start: x.start, end: x.end})), playDateCompleted: completed ? dayjs() : undefined, repeat: this.isRepeatPlay }, meta: { ...meta, trackProgressPosition: this.getPosition() ?? meta.trackProgressPosition }, ...rest } } return undefined; }
public getListenDuration(): Second{ let listenDur: number = 0; const ranges = [...this.listenRanges]; if (this.currentListenRange !== undefined) { ranges.push(this.currentListenRange); } for (const range of ranges) { listenDur += range.getDuration(); } return listenDur; }
protected abstract currentListenSessionContinue(position?: number | undefined, timestamp?: Dayjs);
protected abstract currentListenSessionEnd();
/** Check if new Player Position was seeked to a Position that indicates user is repeating the track * * True if: * * New position was/is seeked and is now close to start of Play and... * * Listened duraton is more than 2 minutes/50% of Play OR... * * Previous Position was close to end of Play */ protected isSessionRepeat(position?: number, reportedTS?: Dayjs): boolean { if(this.currentListenRange !== undefined) { const [isSeeked, seekPos] = this.currentListenRange.seeked(position, reportedTS); if (isSeeked === false || seekPos > 0) { return false; } }
const hints: string[] = [];
const repeatHint = `New Position (${position})`; const trackDur = this.currentPlay.data.duration;
// new position is close to start of Play const [closeStart, closeStartHint] = closeToPlayStart(this.currentPlay, position, {hintPrefix: false}); hints.push(closeStartHint);
if (closeStart) { const playerDur = this.getListenDuration(); const [repeatDurationOk, repeatDurationHint] = repeatDurationPlayed(this.currentPlay, playerDur, {hintPrefix: false});
// user has played at least 2 minutes or 50% of track // and the current (new) listen range was close to the start // and current range was seeked if (this.currentListenRange !== undefined && repeatDurationOk) { this.logger.verbose(`${repeatHint} ${[closeStartHint, repeatDurationHint].join(' and ')}`); return true; }
// if duration was not satisfied *or* we don't have a current listen range // then we check last position with proximity to end of the track const lastPosCandidate: {pos: number, context: string}[] = []; const lastPos = this.currentListenRange?.getPosition(); if(lastPos !== undefined) { // current listen range was seeked, but not closed (yet), check last end position lastPosCandidate.push({pos: lastPos, context: 'current listen session'}); } if(lastPos === undefined && this.listenRanges.length > 0) { // may have just started a new listen session, or no listen session yet, // after being paused/~stopped (session was closed) at the end of a playlist/repeat // // if we have a previous range then try the last end position const last = this.listenRanges[this.listenRanges.length - 1].getPosition(); if(last !== undefined) { // no seek since session does not exist // but we have closed ranges so try latest end position lastPosCandidate.push({pos: last, context: 'last listen session'}); } } if (trackDur !== undefined && lastPosCandidate.length > 0) { for(const posData of lastPosCandidate) { const [nearEnd, nearEndHint] = closeToPlayEnd(this.currentPlay, posData.pos, {hintPrefix: false}); // last position is close to end of Play if(nearEnd) { this.logger.verbose(`${repeatHint} ${[closeStartHint, `${posData.context} (${posData.pos}s) ${nearEndHint}`].join(' and ')}`); return true; } } } } return false; }
protected setCurrentPlay(state: PlayerStateData, options?: CurrentPlayOptions) {
const { status, reportedTS, listenSessionManaged = true } = options || {};
const {play, position} = state;
this.currentPlay = play; this.playFirstSeenAt = reportedTS ?? dayjs(); this.listenRanges = []; this.currentListenRange = undefined;
this.logger.verbose(`New Play: ${buildTrackString(play, {include: ['trackId', 'artist', 'track', 'session']})}`);
if (status !== undefined) { this.reportedStatus = status; }
if (listenSessionManaged && !['stopped'].includes(this.reportedStatus)) { this.currentListenSessionContinue(position, reportedTS); } }
public textSummary() { const parts = ['']; let play: string; if (this.currentPlay !== undefined) { parts.push(`${buildTrackString(this.currentPlay, {include: ['trackId', 'artist', 'track', 'session']})} @ ${todayAwareFormat(this.playFirstSeenAt)}`); } parts.push(`Reported: ${this.reportedStatus.toUpperCase()} | Calculated: ${this.calculatedStatus.toUpperCase()} | Stale: ${this.isUpdateStale() ? 'Yes' : 'No'} | Orphaned: ${this.isOrphaned() ? 'Yes' : 'No'} | Player Updated At: ${todayAwareFormat(this.stateLastUpdatedAt)} | Play Updated At: ${this.playLastUpdatedAt === undefined ? 'N/A' : todayAwareFormat(this.playLastUpdatedAt)}`); let progress = ''; if (this.currentListenRange !== undefined && this.currentListenRange instanceof ListenRangePositional && this.currentPlay.data.duration !== undefined && this.currentPlay.data.duration !== 0) { 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 Reported | `; } let listenedPercent = ''; if (this.currentPlay !== undefined && this.currentPlay.data.duration !== undefined && this.currentPlay.data.duration !== 0) { listenedPercent = formatNumber((this.getListenDuration() / this.currentPlay.data.duration) * 100, { suffix: '%', toFixed: 0 }) } parts.push(`${progress}Listened For: ${formatNumber(this.getListenDuration(), {toFixed: 0})}s ${listenedPercent}`); if (this.currentListenRange !== undefined && this.currentListenRange instanceof ListenRangePositional && this.currentListenRange.rtTruth) { const rtProgress = `${progressBar((this.currentListenRange.rtPlayer.getPosition() / 1000) / this.currentPlay.data.duration, 1, 15)} ${formatNumber(this.currentListenRange.rtPlayer.getPosition() / 1000, {toFixed: 0})}/${formatNumber(this.currentPlay.data.duration, {toFixed: 0})}s`; parts.push(`${rtProgress} Realtime | Drifted ${formatNumber(Math.abs(this.currentListenRange.getDrift() / 1000), {toFixed: 1})}s (Max ${formatNumber(this.currentListenRange.getAllowedDrift() / 1000, {toFixed: 1})})`); } return parts.join('\n'); }
public logSummary() { this.logger.debug(this.textSummary()); }
public getPosition(): Second | undefined { if(this.calculatedStatus === 'stopped') { return undefined; } if(this.currentListenRange !== undefined) { return this.currentListenRange.getPosition(); } if(this.listenRanges.length > 0) { return this.listenRanges[this.listenRanges.length - 1].getPosition(); } return undefined; }
public getApiState(): SourcePlayerObj { return { platformId: this.platformIdStr, play: this.getPlayedObject(), playLastUpdatedAt: this.playLastUpdatedAt !== undefined ? this.playLastUpdatedAt.toISOString() : undefined, playFirstSeenAt: this.playFirstSeenAt !== undefined ? this.playFirstSeenAt.toISOString() : undefined, playerLastUpdatedAt: this.stateLastUpdatedAt.toISOString(), createdAt: dayjs().unix(), position: this.getPosition(), listenedDuration: this.getListenDuration(), status: { reported: this.reportedStatus, calculated: this.calculatedStatus, stale: this.isUpdateStale(), orphaned: this.isOrphaned() } } }
public transferToNewPlayer(newPlayer: AbstractPlayerState) { this.logger.debug(`Transferring state to new Player (${newPlayer.platformIdStr})`); newPlayer.calculatedStatus = this.calculatedStatus; if(this.currentPlay !== undefined) { newPlayer.setCurrentPlay({play: this.currentPlay, platformId: this.platformId}, {status: this.reportedStatus, listenSessionManaged: false}); } newPlayer.currentListenRange = this.currentListenRange; newPlayer.listenRanges = this.listenRanges; newPlayer.playFirstSeenAt = this.playFirstSeenAt; newPlayer.playLastUpdatedAt = this.playLastUpdatedAt; }}
export interface CurrentPlayOptions { status?: ReportedPlayerStatus, reportedTS?: Dayjs listenSessionManaged?: boolean}