[READ-ONLY] Mirror of https://github.com/FoxxMD/multi-scrobbler. Scrobble plays from multiple sources to multiple clients docs.multi-scrobbler.app
deezer docker jellyfin koito lastfm listenbrainz maloja mopidy mpris music music-assistant plex scrobble self-hosted spotify subsonic tautulli youtube-music
0

Configure Feed

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

Merge pull request #608 from FoxxMD/nowPlayingGranular

refactor: Now Playing rewrite to handle realtime client updates

authored by

Matt Foxx and committed by
GitHub
(May 30, 2026, 10:59 AM EDT) c26e6f77 e2b59900

+245 -107
+1 -5
.gitignore
··· 153 153 storybook-static 154 154 155 155 *.db 156 - *.db.* 157 - *.db-*lib 158 - *.db 159 - *.db.* 160 - *.db-*lib 156 + *.db*
+26 -8
src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts
··· 4 4 import AbstractApiClient from "../AbstractApiClient.js"; 5 5 import { Agent, ComAtprotoRepoCreateRecord, ComAtprotoRepoListRecords, ComAtprotoRepoPutRecord } from "@atproto/api"; 6 6 import { MSCache } from "../../Cache.js"; 7 - import { BrainzMeta, PlayObject, PlayObjectLifecycleless, ScrobbleActionResult, UnixTimestamp, MBID } from "../../../../core/Atomic.js"; 7 + import { BrainzMeta, PlayObject, PlayObjectLifecycleless, ScrobbleActionResult, UnixTimestamp, MBID, NowPlayingUpdateThreshold, SourcePlayerObj, Second } from "../../../../core/Atomic.js"; 8 8 import { musicServiceToCononical } from '../listenbrainz/lzUtils.js'; 9 9 import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; 10 10 import { RecordOptions } from "../../infrastructure/config/client/tealfm.js"; ··· 15 15 import { ScrobbleSubmitError } from "../../errors/MSErrors.js"; 16 16 import { UpstreamError } from "../../errors/UpstreamError.js"; 17 17 import { decodeTid, generateTID } from '@ewanc26/tid'; 18 + import { Duration } from "dayjs/plugin/duration.js"; 18 19 19 20 export abstract class AbstractBlueSkyApiClient extends AbstractApiClient implements PagelessTimeRangeListens { 20 21 ··· 141 142 ? { trackName: "", artists: [] } 142 143 : playToRecord(play); 143 144 144 - // default "fallback" value 145 - let expiry: Dayjs = dayjs().add(10, 'minute'); 146 - // 1min ago if paused -> try now + (duration - position) -> try now + duration -> fallback 145 + let expiry: Dayjs; 147 146 if(notPlaying) { 147 + // if clearing status we set expiration as one minute in the past 148 148 expiry = dayjs().subtract(1, 'minute'); 149 - } else if(position !== undefined && play.data.duration !== undefined) { 150 - expiry = dayjs().add(play.data.duration - position, 'second'); 151 - } else if(play.data.duration !== undefined) { 152 - expiry = dayjs().add(play.data.duration, 'second'); 149 + } else { 150 + expiry = dayjs().add(nowPlayingExpirationDuration({play, position})); 153 151 } 154 152 155 153 return { ··· 158 156 expiry: expiry.toISOString(), 159 157 item 160 158 }; 159 + } 160 + 161 + export const nowPlayingExpirationDuration = (data: Pick<SourcePlayerObj, 'play' | 'position'>): Duration => { 162 + let expiry: Dayjs = dayjs().add(10, 'minute'); 163 + 164 + const { 165 + position, 166 + play 167 + } = data; 168 + 169 + // if we have position and duration then expiration is set as calculated end of listening session 170 + if(position !== undefined && play?.data.duration !== undefined) { 171 + expiry = dayjs().add(play.data.duration - position, 'second'); 172 + } else if(play?.data.duration !== undefined) { 173 + // else if we have duration but not position then use track duration 174 + expiry = dayjs().add(play.data.duration, 'second'); 175 + } 176 + 177 + // otherwise use 10 minutes 178 + return dayjs.duration(expiry.diff(dayjs(), 'ms')); 161 179 } 162 180 163 181 export const listRecordToPlay = (listRecord: ListRecord<ScrobbleRecord>): PlayObject => {
+160 -35
src/backend/scrobblers/AbstractScrobbleClient.ts
··· 30 30 FormatPlayObjectOptions, 31 31 PaginatedTimeRangeOptions, 32 32 REFRESH_STALE_DEFAULT, 33 + ReportedPlayerStatus, 33 34 ScrobbledPlayObject, 34 35 SourceIdentifier, 35 36 TIME_WEIGHT, ··· 79 80 import { asPlay } from "../../core/PlayMarshalUtils.js"; 80 81 import { DrizzleQueueRepository } from "../common/database/drizzle/repositories/QueueRepository.js"; 81 82 import { GenericRepository } from "../common/database/drizzle/repositories/BaseRepository.js"; 83 + import assert from "node:assert"; 82 84 83 85 type PlatformMappedPlays = Map<string, {player: SourcePlayerObj, source: SourceIdentifier}>; 84 86 type NowPlayingQueue = Map<string, PlatformMappedPlays>; ··· 120 122 deadLetterQueued: number = 0; 121 123 122 124 supportsNowPlaying: boolean = false; 125 + nowPlayingIsRealtime: boolean = false; 123 126 nowPlayingInit: boolean = false; 124 127 nowPlayingEnabled: boolean; 125 128 nowPlayingFilter: (queue: NowPlayingQueue) => SourcePlayerObj | undefined; ··· 1529 1532 if(sourcePlayerData === undefined) { 1530 1533 return; 1531 1534 } 1532 - let shouldUpdate: boolean, 1533 - clientReason: string | undefined; 1534 - const [npUpdateTop, npUpdateTopReason] = this.shouldUpdatePlayingNowResult(sourcePlayerData); 1535 - shouldUpdate = npUpdateTop; 1536 - if(!npUpdateTop) { 1537 - this.npLogger.trace(`Not updating because ${npUpdateTopReason}`); 1538 - } else { 1535 + let [shouldUpdate, npUpdateTopReason] = this.shouldUpdatePlayingNow(sourcePlayerData); 1536 + let clientReason: string | undefined; 1537 + if(!shouldUpdate) { 1538 + this.npLogger.trace(`Not updating, ${npUpdateTopReason}`); 1539 + } 1540 + 1541 + if(shouldUpdate) { 1539 1542 const [clientUpdate, clientUpdateReason, level] = await this.shouldUpdatePlayingNowPlatformSpecific(sourcePlayerData); 1540 1543 clientReason = clientUpdateReason; 1541 1544 shouldUpdate = clientUpdate; ··· 1543 1546 this.npLogger[level ?? 'trace'](`Not updating, ${npUpdateTopReason} --BUT-- ${clientUpdateReason}`); 1544 1547 } 1545 1548 } 1549 + 1550 + // finally, do the update 1546 1551 if(shouldUpdate) { 1547 1552 this.npLogger.verbose(`Updating because ${npUpdateTopReason}${clientReason !== undefined ? ` --AND-- ${clientReason}` : ''}`); 1548 1553 try { ··· 1559 1564 } 1560 1565 } 1561 1566 1562 - shouldUpdatePlayingNowResult = (data: SourcePlayerObj): [boolean, string?] => { 1567 + nowPlayingHasDiscrepancy = (data: SourcePlayerObj): [boolean, string?] => { 1563 1568 if(this.nowPlayingLastPlay === undefined || this.nowPlayingLastUpdated === undefined) { 1564 1569 return [true, 'Now Playing has not yet been set']; 1565 1570 } 1566 1571 1567 - if(data.play?.data?.track === undefined) { 1568 - return [false, 'play is missing track information']; 1572 + const playExistingDiscrepancy = (this.nowPlayingLastPlay.play !== undefined && data.play === undefined) || (this.nowPlayingLastPlay === undefined && data.play !== undefined); 1573 + if(playExistingDiscrepancy) { 1574 + return [true, `previous update ${this.nowPlayingLastPlay.play !== undefined ? 'exists' : 'does not exist'} and current update ${data.play !== undefined ? 'exists' : 'does not exist'}`]; 1569 1575 } 1570 - if((data.play?.data?.artists ?? []).length === 0) { 1571 - return [false, 'play is missing artist information']; 1576 + 1577 + if(this.nowPlayingLastPlay.play === undefined && data.play === undefined) { 1578 + return [false, 'both previous and current update do not exist, nothing to update']; 1572 1579 } 1573 1580 1574 - const lastUpdateDiff = Math.abs(dayjs().diff(this.nowPlayingLastUpdated, 's')); 1581 + if(this.nowPlayingLastPlay.status.calculated !== data.status.calculated) { 1582 + return [true, 'player state has changed']; 1583 + } 1584 + 1585 + if(!playObjDataMatch(data.play, this.nowPlayingLastPlay.play)) { 1586 + return [true, 'previous update play data does not match current']; 1587 + } 1575 1588 1576 - const playExistingDiscrepancy = (this.nowPlayingLastPlay.play !== undefined && data.play === undefined) || (this.nowPlayingLastPlay === undefined && data.play !== undefined); 1577 - const bothPlaysExist = this.nowPlayingLastPlay.play !== undefined && data.play !== undefined; 1589 + return [false, 'previous update data matches current']; 1590 + } 1578 1591 1579 - const playerStatusChanged = this.nowPlayingLastPlay.status.calculated !== data.status.calculated; 1592 + protected nowPlayingThresholdsMet = (data: SourcePlayerObj) => { 1593 + const lastUpdateDiff = Math.abs(dayjs().diff(this.nowPlayingLastUpdated, 's')); 1594 + const minMet = this.nowPlayingMinThreshold(data.play) < lastUpdateDiff; 1595 + const minReason = `time since last update (${lastUpdateDiff}s) is ${minMet ? 'greater' : 'less'} than min threshold ${this.nowPlayingMinThreshold(data.play)}s`; 1580 1596 1581 - // update if play *has* changed and time since last update is greater than min interval 1582 - // this prevents spamming scrobbler API with updates if user is skipping tracks and source updates frequently 1583 - if(this.nowPlayingMinThreshold(data.play) < lastUpdateDiff && (playExistingDiscrepancy || playerStatusChanged || (bothPlaysExist && !playObjDataMatch(data.play, this.nowPlayingLastPlay.play)))) { 1584 - return [true, `New Play differs from previous Now Playing and time since update ${lastUpdateDiff}s, greater than threshold ${this.nowPlayingMinThreshold(data.play)}`]; 1585 - } 1586 - // update if play *has not* changed but last update is greater than max interval 1587 - // this keeps scrobbler Now Playing fresh ("active" indicator) in the event play is long 1588 - if(this.nowPlayingMaxThreshold(data.play) < lastUpdateDiff && (bothPlaysExist && playObjDataMatch(data.play, this.nowPlayingLastPlay.play))) { 1589 - return [true, `Now Playing last updated ${lastUpdateDiff}s ago, greater than threshold ${this.nowPlayingMaxThreshold(data.play)}s`]; 1590 - } 1597 + const maxMet = this.nowPlayingMaxThreshold(data.play) < lastUpdateDiff; 1598 + const maxReason = `time since last update (${lastUpdateDiff}s) is ${maxMet ? 'greater' : 'less'} than max threshold ${this.nowPlayingMaxThreshold(data.play)}s`; 1591 1599 1592 - return [false, `Now Playing ${bothPlaysExist && playObjDataMatch(data.play, this.nowPlayingLastPlay.play) ? 'matches' : 'does not match'} and was last updated ${lastUpdateDiff}s ago (threshold ${this.nowPlayingMaxThreshold(data.play)}s)`]; 1600 + return { 1601 + minMet, 1602 + minReason, 1603 + maxMet, 1604 + maxReason 1605 + } 1593 1606 } 1594 1607 1595 - shouldUpdatePlayingNow = (data: SourcePlayerObj): boolean => { 1596 - return this.shouldUpdatePlayingNowResult(data)[0]; 1608 + shouldUpdatePlayingNow = (sourcePlayerData: SourcePlayerObj): [boolean, string] => { 1609 + let shouldUpdate: boolean; 1610 + const thresholds = this.nowPlayingThresholdsMet(sourcePlayerData); 1611 + // first we check if there is an obvious discrepancy between last updated and current update data 1612 + // such as one missing, status change, no stored previous, etc... 1613 + const [npUpdateTop, npUpdateTopReason] = this.nowPlayingHasDiscrepancy(sourcePlayerData); 1614 + shouldUpdate = npUpdateTop; 1615 + if(!npUpdateTop) { 1616 + 1617 + if(npUpdateTopReason === 'previous update data matches current') { 1618 + if(thresholds.maxMet) { 1619 + return [true, `previous matches current update --AND-- ${thresholds.maxReason}`]; 1620 + } else { 1621 + return [false, `previous matches current update --BUT-- ${thresholds.maxReason}`]; 1622 + } 1623 + } 1624 + 1625 + return [false, npUpdateTopReason]; 1626 + } 1627 + 1628 + let validStatusReason: string; 1629 + if(shouldUpdate) { 1630 + // next we check if new player state is even valid to use for an update 1631 + const [statusValid, reason] = this.nowPlayingIsRealtime ? playerInValidNPUpdateState(sourcePlayerData) : playerInNPPlayingOnlyState(sourcePlayerData); 1632 + validStatusReason = reason; 1633 + shouldUpdate = statusValid; 1634 + if(!statusValid) { 1635 + return [false, `${npUpdateTopReason} --BUT-- ${validStatusReason}`]; 1636 + } 1637 + } 1638 + 1639 + if(shouldUpdate && this.nowPlayingLastPlay !== undefined) { 1640 + // at this point its possible we could update but we should respect minimum update intervals 1641 + // and triggering this early means less, deeper checks 1642 + const thresholds = this.nowPlayingThresholdsMet(sourcePlayerData); 1643 + if (!thresholds.minMet) { 1644 + shouldUpdate = false; 1645 + return [false, `${npUpdateTopReason} and ${validStatusReason} --BUT-- ${thresholds.minReason}`]; 1646 + } 1647 + else if ( 1648 + // status hasn't changed 1649 + this.nowPlayingLastPlay.status?.calculated === sourcePlayerData.status?.calculated 1650 + // and both plays are defined and have not changed 1651 + && (this.nowPlayingLastPlay.play !== undefined && sourcePlayerData.play !== undefined) 1652 + && playObjDataMatch(sourcePlayerData.play, this.nowPlayingLastPlay.play)) { 1653 + 1654 + // only update if we are passed max threshold 1655 + shouldUpdate = thresholds.maxMet; 1656 + if(!thresholds.maxMet) { 1657 + return [false, `${npUpdateTopReason} and ${validStatusReason} --BUT-- ${thresholds.maxReason}`]; 1658 + } 1659 + } 1660 + } 1661 + 1662 + if(shouldUpdate) { 1663 + // check for valid play data if the update should be for a playing track 1664 + if(playerInNPPlayingOnlyState(sourcePlayerData)) { 1665 + if(sourcePlayerData.play?.data?.track === undefined) { 1666 + shouldUpdate = false; 1667 + return [false, `${npUpdateTopReason} and ${validStatusReason} --BUT-- play is missing track information`]; 1668 + } 1669 + if((sourcePlayerData.play?.data?.artists ?? []).length === 0) { 1670 + shouldUpdate = false; 1671 + return [false, `${npUpdateTopReason} and ${validStatusReason} --BUT-- play is missing artist information`]; 1672 + } 1673 + } 1674 + } 1675 + 1676 + if(shouldUpdate && this.nowPlayingIsRealtime) { 1677 + // prevent multiple clearing updates 1678 + if(this.nowPlayingLastPlay !== undefined && shouldClearNPStatus(sourcePlayerData) && shouldClearNPStatus(this.nowPlayingLastPlay)) { 1679 + shouldUpdate = false; 1680 + return [false, `${npUpdateTopReason} and ${validStatusReason} --BUT-- last update already cleared now playing`]; 1681 + } 1682 + } 1683 + 1684 + return [true, `${npUpdateTopReason} and ${validStatusReason}`]; 1597 1685 } 1598 1686 1599 1687 /** Implement this for specific requirements for updating playing now based on the scrobbler platform */ 1600 - protected shouldUpdatePlayingNowPlatformSpecific(data: SourcePlayerObj): Promise<[boolean, string?, LogLevel?]> { 1601 - return shouldUpdatePlayingNowPlatformWhenPlayingOnly(data); 1688 + protected async shouldUpdatePlayingNowPlatformSpecific(data: SourcePlayerObj): Promise<[boolean, string?, LogLevel?]> { 1689 + return [true]; 1602 1690 } 1603 1691 1604 1692 protected doPlayingNow = (data: SourcePlayerObj): Promise<any> => Promise.resolve(undefined) ··· 1636 1724 return (play?.data?.duration ?? 30) + 1; 1637 1725 } 1638 1726 1639 - export const shouldUpdatePlayingNowPlatformWhenPlayingOnly = async (data: SourcePlayerObj): Promise<[boolean, string]> => { 1640 - if(data.status.calculated === CALCULATED_PLAYER_STATUSES.playing || (data.nowPlayingMode && !CALCULATED_PLAYER_STATUSES.stopped)) { 1641 - return [true, `calculated player status is ${data.status.calculated}`]; 1727 + export const shouldClearNPStatus = (data: SourcePlayerObj) => { 1728 + return [CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused].includes(data.status.calculated as ReportedPlayerStatus); 1729 + } 1730 + 1731 + export const playerInNPPlayingOnlyState = (data: SourcePlayerObj): [boolean, string] => { 1732 + // for lower-interval update clients (like listenbrainz, lastfm) IE not real-time 1733 + // we don't want to create updates for paused/stopped because the NP data for these services 1734 + // is only supposed to be updated intermittently 1735 + // 1736 + // so only allow an update if the player is actually playing 1737 + if(!data.nowPlayingMode) { 1738 + if(data.status.calculated === CALCULATED_PLAYER_STATUSES.playing) { 1739 + return [true, `calculated player status is ${data.status.calculated}`]; 1740 + } 1741 + return [false, `calculated player status is ${data.status.calculated} but must be playing`]; 1642 1742 } 1643 - return [false, `calculated player status is ${data.status.calculated} but must be played/stopped`]; 1743 + return npPlayerInValidNPUpdateState(data); 1744 + } 1745 + 1746 + export const playerInValidNPUpdateState = (data: SourcePlayerObj): [boolean, string] => { 1747 + // if the source player is not a "Now Playing" type (lz, endpoint Source, etc...) 1748 + // then we only want to allow an update if the player state is a known "good" type IE don't allow on unknown 1749 + if(!data.nowPlayingMode) { 1750 + if([CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused, CALCULATED_PLAYER_STATUSES.playing].includes(data.status.calculated as ReportedPlayerStatus)) { 1751 + return [true, `player in valid update state: '${data.status.calculated }'`]; 1752 + } 1753 + return [false,`player is not in state: stopped | paused | playing => Found '${data.status.calculated }'`]; 1754 + } 1755 + 1756 + return npPlayerInValidNPUpdateState(data); 1757 + } 1758 + 1759 + export const npPlayerInValidNPUpdateState = (data: SourcePlayerObj): [boolean, string] => { 1760 + assert(data.nowPlayingMode === true, 'data is not in nowPlayingMode'); 1761 + 1762 + // if the source player *is* a "Now Playing" type 1763 + // then we allow update on anything that isn't explicitly stopped 1764 + // since these sources have limited reporting capability for calculating a valid state 1765 + if(CALCULATED_PLAYER_STATUSES.stopped !== data.status.calculated as ReportedPlayerStatus) { 1766 + return [true, `NP player in valid update state: '${data.status.calculated }'`]; 1767 + } 1768 + return [false, `NP player is is invalid update state: stopped`]; 1644 1769 }
+14 -27
src/backend/scrobblers/DiscordScrobbler.ts
··· 4 4 import { CALCULATED_PLAYER_STATUSES, FormatPlayObjectOptions, REPORTED_PLAYER_STATUSES, ReportedPlayerStatus, SINGLE_USER_PLATFORM_ID_STR, TimeRangeListensFetcher } from "../common/infrastructure/Atomic.js"; 5 5 import { Notifiers } from "../notifier/Notifiers.js"; 6 6 7 - import AbstractScrobbleClient, { nowPlayingUpdateByPlayDuration } from "./AbstractScrobbleClient.js"; 7 + import AbstractScrobbleClient, { nowPlayingUpdateByPlayDuration, shouldClearNPStatus } from "./AbstractScrobbleClient.js"; 8 8 import { DiscordClientConfig, DiscordStrongData } from "../common/infrastructure/config/client/discord.js"; 9 9 import { DiscordWSClient } from "../common/vendor/discord/DiscordWSClient.js"; 10 10 import { configToStrong } from "../common/vendor/discord/DiscordUtils.js"; ··· 18 18 api: DiscordWSClient | DiscordIPCClient; 19 19 requiresAuth = true; 20 20 requiresAuthInteraction = false; 21 + override nowPlayingIsRealtime: boolean = true; 21 22 apiMode!: 'ws' | 'ipc'; 22 23 23 24 declare config: DiscordClientConfig & {data: DiscordStrongData }; ··· 146 147 147 148 doPlayingNow = async (data: SourcePlayerObj) => { 148 149 try { 149 - if([CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused].includes(data.status.calculated as ReportedPlayerStatus)) { 150 + if(shouldClearNPStatus(data)) { 150 151 await this.api.sendActivity(undefined); 151 152 } else { 152 153 await this.api.sendActivity(data); ··· 157 158 } 158 159 159 160 shouldUpdatePlayingNowPlatformSpecific = async (data: SourcePlayerObj): Promise<[boolean, string?, LogLevel?]> => { 160 - if ([CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused, CALCULATED_PLAYER_STATUSES.playing].includes(data.status.calculated as ReportedPlayerStatus) 161 - || (data.nowPlayingMode && !CALCULATED_PLAYER_STATUSES.stopped) 162 - || data.status.stale) { 163 - 164 - const [sendOk, reasons, level = 'warn'] = await this.api.checkOkToSend(); 165 - if (!sendOk) { 166 - return [false, `Cannot update playing now because api client is ${reasons}`, level as LogLevel]; 167 - } 168 - 169 - if(this.api instanceof DiscordWSClient) { 170 - const [allowed, reason] = this.api.presenceIsAllowed(); 171 - if(!allowed) { 172 - this.npLogger.debug(reason); 173 - return [false, reason]; 174 - } 175 - } 161 + const [sendOk, reasons, level = 'warn'] = await this.api.checkOkToSend(); 162 + if (!sendOk) { 163 + return [false, `Cannot update playing now because api client is ${reasons}`, level as LogLevel]; 164 + } 176 165 177 - return [true]; 178 - } else { 179 - if(!data.nowPlayingMode && ![CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused, CALCULATED_PLAYER_STATUSES.playing].includes(data.status.calculated as ReportedPlayerStatus)) { 180 - return [false,`player is not in state: stopped | paused | playing => Found '${data.status.calculated }'`]; 181 - } else if(data.nowPlayingMode && CALCULATED_PLAYER_STATUSES.stopped) { 182 - this.npLogger.trace(`Will not update because now playing player is stopped => Found ${data.status.calculated}`); 183 - return [false,`playing player is stopped => Found ${data.status.calculated}` ] 184 - } else { 185 - return [false, 'player is in an unexpected state for discord usage'] 166 + if(this.api instanceof DiscordWSClient) { 167 + const [allowed, reason] = this.api.presenceIsAllowed(); 168 + if(!allowed) { 169 + this.npLogger.debug(reason); 170 + return [false, reason]; 186 171 } 187 172 } 173 + 174 + return [true]; 188 175 } 189 176 }
+1 -1
src/backend/scrobblers/LastfmScrobbler.ts
··· 8 8 import { LastfmClientConfig } from "../common/infrastructure/config/client/lastfm.js"; 9 9 import LastfmApiClient, { LastFMIgnoredScrobble, playToClientPayload, formatPlayObj } from "../common/vendor/LastfmApiClient.js"; 10 10 import { Notifiers } from "../notifier/Notifiers.js"; 11 - import AbstractScrobbleClient, { nowPlayingUpdateByPlayDuration, shouldUpdatePlayingNowPlatformWhenPlayingOnly } from "./AbstractScrobbleClient.js"; 11 + import AbstractScrobbleClient, { nowPlayingUpdateByPlayDuration, playerInNPPlayingOnlyState } from "./AbstractScrobbleClient.js"; 12 12 import { findCauseByReference } from "../utils/ErrorUtils.js"; 13 13 import { createGetScrobblesForTimeRangeFunc } from "../utils/ListenFetchUtils.js"; 14 14
+1 -1
src/backend/scrobblers/ListenbrainzScrobbler.ts
··· 12 12 import { ListenPayload } from '../common/vendor/listenbrainz/interfaces.js'; 13 13 import { Notifiers } from "../notifier/Notifiers.js"; 14 14 15 - import AbstractScrobbleClient, { nowPlayingUpdateByPlayDuration, shouldUpdatePlayingNowPlatformWhenPlayingOnly } from "./AbstractScrobbleClient.js"; 15 + import AbstractScrobbleClient, { nowPlayingUpdateByPlayDuration, playerInNPPlayingOnlyState } from "./AbstractScrobbleClient.js"; 16 16 import { isDebugMode } from "../utils.js"; 17 17 import { createGetScrobblesForTimeRangeFunc } from "../utils/ListenFetchUtils.js"; 18 18
+33 -24
src/backend/scrobblers/TealfmScrobbler.ts
··· 7 7 import { playToListenPayload } from '../common/vendor/listenbrainz/lzUtils.js'; 8 8 import { Notifiers } from "../notifier/Notifiers.js"; 9 9 10 - import AbstractScrobbleClient from "./AbstractScrobbleClient.js"; 10 + import AbstractScrobbleClient, { nowPlayingUpdateByPlayDuration, shouldClearNPStatus } from "./AbstractScrobbleClient.js"; 11 11 import { TealClientConfig } from "../common/infrastructure/config/client/tealfm.js"; 12 12 import { BlueSkyAppApiClient } from "../common/vendor/bluesky/BlueSkyAppApiClient.js"; 13 13 import { BlueSkyOauthApiClient } from "../common/vendor/bluesky/BlueSkyOauthApiClient.js"; 14 - import { AbstractBlueSkyApiClient, listRecordToPlay, playToRecord, playToStatusRecord, recordToPlay } from "../common/vendor/bluesky/AbstractBlueSkyApiClient.js"; 14 + import { AbstractBlueSkyApiClient, listRecordToPlay, nowPlayingExpirationDuration, playToRecord, playToStatusRecord, recordToPlay } from "../common/vendor/bluesky/AbstractBlueSkyApiClient.js"; 15 + import dayjs, { Dayjs } from "dayjs"; 16 + import { durationToHuman } from "../utils.js"; 15 17 16 18 export default class TealScrobbler extends AbstractScrobbleClient { 17 19 18 20 requiresAuth = true; 19 21 requiresAuthInteraction = false; 22 + override nowPlayingIsRealtime: boolean = true; 23 + protected lastExpirationDate: Dayjs; 20 24 21 25 declare config: TealClientConfig; 22 26 ··· 35 39 } else { 36 40 throw new Error(`Must define either 'baseUri' or 'appPassword' in configuration!`); 37 41 } 42 + this.nowPlayingMaxThreshold = nowPlayingUpdateByPlayDuration; 43 + this.nowPlayingMinThreshold = (_) => 20; 38 44 } 39 45 40 46 formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => recordToPlay(obj); ··· 123 129 } 124 130 125 131 doPlayingNow = async (data: SourcePlayerObj) => { 126 - const notPlaying = [CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused].includes(data.status.calculated as ReportedPlayerStatus); 132 + 133 + const isClearing = shouldClearNPStatus(data); 134 + 135 + // we can avoid additional calls to PDS for clearing a status if the status is about to expire, or is already expired. 136 + // this will usually happen if a player stops playing the last track in a queue 137 + // -- worth doing since PDS calls have a daily rate limit 138 + if(isClearing && (this.statusExpiresSoon() || this.statusAlreadyExpired())) { 139 + this.npLogger.debug(`Not calling status record update because status is about to expire (or has already), expiring ${durationToHuman(dayjs.duration(dayjs().diff(this.lastExpirationDate)))}`); 140 + return; 141 + } 142 + 127 143 try { 128 - await this.client.updateStatusRecord(playToStatusRecord(data.play, notPlaying, data.position)); 144 + await this.client.updateStatusRecord(playToStatusRecord(data.play, isClearing, data.position)); 145 + if(!isClearing) { 146 + this.lastExpirationDate = dayjs().add(nowPlayingExpirationDuration(data)); 147 + } 129 148 } catch (e) { 130 149 throw e; 131 150 } 132 151 } 133 152 134 - wasLastStatusCleared = () => { 135 - return this.nowPlayingLastPlay !== undefined 136 - && [CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused].includes(this.nowPlayingLastPlay.status.calculated as ReportedPlayerStatus) 153 + protected statusExpiresSoon = () => { 154 + if(this.lastExpirationDate === undefined) { 155 + return false; 156 + } 157 + // may want to make this configurable in the future? 158 + return Math.abs(dayjs().diff(this.lastExpirationDate, 's')) < 15; 137 159 } 138 - 139 - shouldUpdatePlayingNowPlatformSpecific = async (data: SourcePlayerObj): Promise<[boolean, string?, LogLevel?]> => { 140 - if ([CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused].includes(data.status.calculated as ReportedPlayerStatus) && !this.wasLastStatusCleared() 141 - || [CALCULATED_PLAYER_STATUSES.playing].includes(data.status.calculated as ReportedPlayerStatus) 142 - || (data.nowPlayingMode && !CALCULATED_PLAYER_STATUSES.stopped)) { 143 - return [true]; 144 - } else { 145 - if(!data.nowPlayingMode && ![CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused, CALCULATED_PLAYER_STATUSES.playing].includes(data.status.calculated as ReportedPlayerStatus)) { 146 - return [false,`player is not in state: stopped | paused | playing => Found '${data.status.calculated }'`]; 147 - } else if (this.wasLastStatusCleared()) { 148 - return [false, 'teal.fm status has already been set to expired']; 149 - } else if (data.nowPlayingMode && CALCULATED_PLAYER_STATUSES.stopped) { 150 - this.npLogger.trace(`Will not update because now playing player is stopped => Found ${data.status.calculated}`); 151 - return [false,`playing player is stopped => Found ${data.status.calculated}` ] 152 - } else { 153 - return [false, 'player is in an unexpected state for teal.fm usage'] 154 - } 160 + protected statusAlreadyExpired = () => { 161 + if(this.lastExpirationDate === undefined) { 162 + return false; 155 163 } 164 + return dayjs().isAfter(this.lastExpirationDate); 156 165 } 157 166 } 158 167
+4 -1
src/backend/sources/PlayerState/AbstractPlayerState.ts
··· 302 302 playDateCompleted: completed ? dayjs() : undefined, 303 303 repeat: this.isRepeatPlay 304 304 }, 305 - meta: this.currentPlay.meta 305 + meta: { 306 + ...this.currentPlay.meta, 307 + trackProgressPosition: this.getPosition() ?? this.currentPlay.meta.trackProgressPosition 308 + } 306 309 } 307 310 } 308 311 return undefined;
+5 -5
src/backend/tests/scrobbler/scrobblers.test.ts
··· 970 970 await using npScrobbler = new NowPlayingScrobbler(); 971 971 await npScrobbler.initialize(); 972 972 973 - const res = npScrobbler.shouldUpdatePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})})); 973 + const res = npScrobbler.shouldUpdatePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}))[0]; 974 974 expect(res).to.be.true; 975 975 }); 976 976 ··· 983 983 npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingMaxThreshold(lastUpdate.play) + 1, 's'); 984 984 npScrobbler.nowPlayingLastPlay = lastUpdate; 985 985 986 - const res = npScrobbler.shouldUpdatePlayingNow(lastUpdate); 986 + const res = npScrobbler.shouldUpdatePlayingNow(lastUpdate)[0]; 987 987 expect(res).to.be.true; 988 988 }); 989 989 ··· 996 996 npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingMaxThreshold(lastUpdate.play) - 1, 's'); 997 997 npScrobbler.nowPlayingLastPlay = lastUpdate; 998 998 999 - const res = npScrobbler.shouldUpdatePlayingNow(lastUpdate); 999 + const res = npScrobbler.shouldUpdatePlayingNow(lastUpdate)[0]; 1000 1000 expect(res).to.be.false; 1001 1001 }); 1002 1002 ··· 1009 1009 npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingMinThreshold(lastUpdate.play) + 1, 's'); 1010 1010 npScrobbler.nowPlayingLastPlay = lastUpdate; 1011 1011 1012 - const res = npScrobbler.shouldUpdatePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})})); 1012 + const res = npScrobbler.shouldUpdatePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}))[0]; 1013 1013 expect(res).to.be.true; 1014 1014 }); 1015 1015 ··· 1022 1022 npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingMinThreshold(lastUpdate.play) - 1, 's'); 1023 1023 npScrobbler.nowPlayingLastPlay = lastUpdate; 1024 1024 1025 - const res = npScrobbler.shouldUpdatePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})})); 1025 + const res = npScrobbler.shouldUpdatePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}))[0]; 1026 1026 expect(res).to.be.false; 1027 1027 }); 1028 1028