diff --git a/src/backend/sources/EndpointLastfmSource.ts b/src/backend/sources/EndpointLastfmSource.ts index 72444e3d..340ba711 100644 --- a/src/backend/sources/EndpointLastfmSource.ts +++ b/src/backend/sources/EndpointLastfmSource.ts @@ -7,6 +7,7 @@ import { InternalConfig, NO_USER, PlayerStateData, + PlayPlatformId, REPORTED_PLAYER_STATUSES, ReportedPlayerStatus } from "../common/infrastructure/Atomic.js"; @@ -15,6 +16,9 @@ import MemorySource from "./MemorySource.js"; import { LastFMEndpointSourceConfig } from "../common/infrastructure/config/source/endpointlfm.js"; import { LastfmTrackUpdateRequest, NowPlayingPayload, TrackScrobblePayload } from "lastfm-node-client"; import { scrobblePayloadToPlay } from "../common/vendor/LastfmApiClient.js"; +import { Logger } from "@foxxmd/logging"; +import { PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js"; +import { NowPlayingPlayerState } from "./PlayerState/NowPlayingPlayerState.js"; const noSlugMatch = new RegExp(/(?:\/api\/lastfm\/?)$|(?:\/1\/?|\/2.0\/?)$/i); const slugMatch = new RegExp(/\/api\/lastfm\/([^\/]+)$/i); @@ -77,6 +81,8 @@ export class EndpointLastfmSource extends MemorySource { } } } + + getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new NowPlayingPlayerState(logger, id, opts); } export const playStateFromRequest = (obj: LastfmTrackUpdateRequest): PlayerStateData => { diff --git a/src/backend/sources/EndpointListenbrainzSource.ts b/src/backend/sources/EndpointListenbrainzSource.ts index abb7e236..606f244a 100644 --- a/src/backend/sources/EndpointListenbrainzSource.ts +++ b/src/backend/sources/EndpointListenbrainzSource.ts @@ -7,6 +7,7 @@ import { InternalConfig, NO_USER, PlayerStateData, + PlayPlatformId, REPORTED_PLAYER_STATUSES, ReportedPlayerStatus } from "../common/infrastructure/Atomic.js"; @@ -16,6 +17,9 @@ import { SubmitPayload } from '../common/vendor/listenbrainz/interfaces.js'; import { ListenPayload } from '../common/vendor/listenbrainz/interfaces.js'; import { parseRegexSingleOrFail } from "../utils.js"; import MemorySource from "./MemorySource.js"; +import { NowPlayingPlayerState } from "./PlayerState/NowPlayingPlayerState.js"; +import { Logger } from "@foxxmd/logging"; +import { PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js"; const noSlugMatch = new RegExp(/(?:\/api\/listenbrainz\/?)$|(?:\/1\/?|\/1\/submit-listens\/?)$/i); const slugMatch = new RegExp(/\/api\/listenbrainz\/([^\/]+)$/i); @@ -94,6 +98,8 @@ export class EndpointListenbrainzSource extends MemorySource { } } } + + getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new NowPlayingPlayerState(logger, id, opts); } export const playStateFromRequest = (obj: SubmitPayload): PlayerStateData => { diff --git a/src/backend/sources/LastfmSource.ts b/src/backend/sources/LastfmSource.ts index 5aa2a3ac..b3fc46d0 100644 --- a/src/backend/sources/LastfmSource.ts +++ b/src/backend/sources/LastfmSource.ts @@ -4,12 +4,15 @@ import { TrackObject, UserGetRecentTracksResponse } from "lastfm-node-client"; import request from "superagent"; import { PlayObject, SOURCE_SOT } from "../../core/Atomic.js"; import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; -import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js"; +import { FormatPlayObjectOptions, InternalConfig, PlayPlatformId } from "../common/infrastructure/Atomic.js"; import { LastfmSourceConfig } from "../common/infrastructure/config/source/lastfm.js"; import LastfmApiClient from "../common/vendor/LastfmApiClient.js"; import { sortByOldestPlayDate } from "../utils.js"; import { RecentlyPlayedOptions } from "./AbstractSource.js"; import MemorySource from "./MemorySource.js"; +import { Logger } from "@foxxmd/logging"; +import { PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js"; +import { NowPlayingPlayerState } from "./PlayerState/NowPlayingPlayerState.js"; export default class LastfmSource extends MemorySource { @@ -149,4 +152,6 @@ export default class LastfmSource extends MemorySource { } protected getBackloggedPlays = async (options: RecentlyPlayedOptions = {}) => await this.getRecentlyPlayed({formatted: true, ...options}) + + getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new NowPlayingPlayerState(logger, id, opts); } diff --git a/src/backend/sources/ListenbrainzSource.ts b/src/backend/sources/ListenbrainzSource.ts index f46c70df..68def67f 100644 --- a/src/backend/sources/ListenbrainzSource.ts +++ b/src/backend/sources/ListenbrainzSource.ts @@ -2,12 +2,15 @@ import EventEmitter from "events"; import request from "superagent"; import { PlayObject, SOURCE_SOT } from "../../core/Atomic.js"; import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; -import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js"; +import { FormatPlayObjectOptions, InternalConfig, PlayPlatformId } from "../common/infrastructure/Atomic.js"; import { ListenBrainzSourceConfig } from "../common/infrastructure/config/source/listenbrainz.js"; import { ListenbrainzApiClient } from "../common/vendor/ListenbrainzApiClient.js"; import { RecentlyPlayedOptions } from "./AbstractSource.js"; import MemorySource from "./MemorySource.js"; import { isPortReachableConnect } from "../utils/NetworkUtils.js"; +import { Logger } from "@foxxmd/logging"; +import { PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js"; +import { NowPlayingPlayerState } from "./PlayerState/NowPlayingPlayerState.js"; export default class ListenbrainzSource extends MemorySource { @@ -89,4 +92,6 @@ export default class ListenbrainzSource extends MemorySource { } protected getBackloggedPlays = async (options: RecentlyPlayedOptions = {}) => await this.getRecentlyPlayed({formatted: true, ...options}) + + getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new NowPlayingPlayerState(logger, id, opts); } diff --git a/src/backend/sources/PlayerState/AbstractPlayerState.ts b/src/backend/sources/PlayerState/AbstractPlayerState.ts index 017a3571..bfcb4e25 100644 --- a/src/backend/sources/PlayerState/AbstractPlayerState.ts +++ b/src/backend/sources/PlayerState/AbstractPlayerState.ts @@ -98,6 +98,14 @@ export abstract class AbstractPlayerState { protected abstract newListenProgress(data?: Partial): 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); } @@ -108,7 +116,7 @@ export abstract class AbstractPlayerState { isUpdateStale(reportedTS?: Dayjs) { if (this.currentPlay !== undefined) { - return Math.abs((reportedTS ?? dayjs()).diff(this.playLastUpdatedAt, 'seconds')) > this.stateIntervalOptions.staleInterval; + return Math.abs((reportedTS ?? dayjs()).diff(this.playLastUpdatedAt, 'seconds')) > this.getStaleInterval(); } return false; } @@ -117,7 +125,7 @@ export abstract class AbstractPlayerState { 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.stateIntervalOptions.staleInterval}s)`); + 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(); } @@ -125,18 +133,18 @@ export abstract class AbstractPlayerState { } isOrphaned() { - return dayjs().diff(this.stateLastUpdatedAt, 'seconds') >= this.stateIntervalOptions.orphanedInterval; + return dayjs().diff(this.stateLastUpdatedAt, 'seconds') >= this.getOrphanedInterval(); } isDead() { - return dayjs().diff(this.stateLastUpdatedAt, 'seconds') >= this.stateIntervalOptions.orphanedInterval * 2; + 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.stateIntervalOptions.orphanedInterval}s)`); + 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; } diff --git a/src/backend/sources/PlayerState/NowPlayingPlayerState.ts b/src/backend/sources/PlayerState/NowPlayingPlayerState.ts new file mode 100644 index 00000000..1874f811 --- /dev/null +++ b/src/backend/sources/PlayerState/NowPlayingPlayerState.ts @@ -0,0 +1,26 @@ +import { GenericPlayerState } from "./GenericPlayerState.js"; + +/** + * This Player is only used for displaying data reported to EndpointListenbrainzSource, and parsed from ListenbrainzSource, as playing_now + * and the behvior for Listenbrainz server is to display playing now info with a timeout equal to duraion of the submitted track + * https://github.com/FoxxMD/multi-scrobbler/discussions/338 + * + * We'll use duration as a generic timeout for any Source that *only* parses Now Playing data for Player + */ +export class NowPlayingPlayerState extends GenericPlayerState { + + protected getStaleInterval() { + if(this.currentPlay !== undefined && this.currentPlay.data.duration !== undefined) { + return this.currentPlay.data.duration; + } + return super.getStaleInterval(); + } + + protected getOrphanedInterval() { + if(this.currentPlay !== undefined && this.currentPlay.data.duration !== undefined) { + return this.currentPlay.data.duration; + } + return super.getOrphanedInterval(); + } + +} \ No newline at end of file -- 2.51.2 From 34be8add143d59987c1472fd991e10f599020172 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 26 Aug 2025 19:45:48 +0000 Subject: [PATCH 2/6] docs: Remove koito from listenbrainz example Not really needed since koito has its own docs --- config/listenbrainz.json.example | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/config/listenbrainz.json.example b/config/listenbrainz.json.example index 92f4e2a9..fe8b8bb3 100644 --- a/config/listenbrainz.json.example +++ b/config/listenbrainz.json.example @@ -16,15 +16,5 @@ "token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b", "username": "FoxxMD" } - }, - { - "name": "koitoClient", - "enable": true, - "configureAs": "client", - "data": { - "token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b", - "username": "FoxxMD", - "url": "http://192.168.0.100:4110/apis/listenbrainz/1" - } - }, + } ] -- 2.51.2 From eba9bc1b6b619d4c89b4eeca6b3eb8defc4e0d0c Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 26 Aug 2025 20:01:03 +0000 Subject: [PATCH 3/6] feat(webscrobbler): Use Now Playing Player --- src/backend/sources/WebScrobblerSource.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/backend/sources/WebScrobblerSource.ts b/src/backend/sources/WebScrobblerSource.ts index 5b1382ba..efff92ca 100644 --- a/src/backend/sources/WebScrobblerSource.ts +++ b/src/backend/sources/WebScrobblerSource.ts @@ -6,6 +6,7 @@ import { InternalConfig, NO_USER, PlayerStateData, + PlayPlatformId, REPORTED_PLAYER_STATUSES, ReportedPlayerStatus, } from "../common/infrastructure/Atomic.js"; @@ -18,6 +19,9 @@ import { import { joinedUrl } from "../utils/NetworkUtils.js"; import MemorySource from "./MemorySource.js"; +import { Logger } from "@foxxmd/logging"; +import { PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js"; +import { NowPlayingPlayerState } from "./PlayerState/NowPlayingPlayerState.js"; export class WebScrobblerSource extends MemorySource { @@ -185,4 +189,6 @@ export class WebScrobblerSource extends MemorySource { } } } + + getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new NowPlayingPlayerState(logger, id, opts); } -- 2.51.2 From a01c8e3f178810778922bfb98645325f42f1be61 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 26 Aug 2025 20:22:12 +0000 Subject: [PATCH 4/6] feat(ui): Add Now Playing behavior for ui Player * show "Now Playing" instead of Unknown/Playing * Don't show empty listened duration/percent --- .../PlayerState/NowPlayingPlayerState.ts | 7 +++++++ src/client/components/player/Player.tsx | 20 +++++++++++-------- src/core/Atomic.ts | 1 + 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/backend/sources/PlayerState/NowPlayingPlayerState.ts b/src/backend/sources/PlayerState/NowPlayingPlayerState.ts index 1874f811..60ac6120 100644 --- a/src/backend/sources/PlayerState/NowPlayingPlayerState.ts +++ b/src/backend/sources/PlayerState/NowPlayingPlayerState.ts @@ -1,3 +1,4 @@ +import { SourcePlayerObj } from "../../../core/Atomic.js"; import { GenericPlayerState } from "./GenericPlayerState.js"; /** @@ -23,4 +24,10 @@ export class NowPlayingPlayerState extends GenericPlayerState { return super.getOrphanedInterval(); } + public getApiState(): SourcePlayerObj { + const data = super.getApiState(); + data.nowPlayingMode = true; + return data; + } + } \ No newline at end of file diff --git a/src/client/components/player/Player.tsx b/src/client/components/player/Player.tsx index f32de113..73cf96e5 100644 --- a/src/client/components/player/Player.tsx +++ b/src/client/components/player/Player.tsx @@ -42,6 +42,7 @@ art = {}, } = {}, play, listenedDuration = 0, + nowPlayingMode = false, status: { calculated = '???', reported, @@ -51,14 +52,17 @@ art = {}, } = data; let durPer = null; - if(duration !== undefined && duration !== null && duration !== 0) { - if(listenedDuration === 0 || listenedDuration === null) { - durPer = ' (0%)'; - } else { - durPer = ` (${((listenedDuration/duration) * 100).toFixed(0)}%)`; + if(!nowPlayingMode) { + if(duration !== undefined && duration !== null && duration !== 0) { + if(listenedDuration === 0 || listenedDuration === null) { + durPer = ' (0%)'; + } else { + durPer = ` (${((listenedDuration/duration) * 100).toFixed(0)}%)`; + } } } + const [viewMode, setViewMode] = useState('player'); const toggleViewMode = useCallback(() => { @@ -94,10 +98,10 @@ art = {},

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

- +
-

Status: {capitalize(calculated)}

-

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

+

Status: {['unknown','playing'].includes(calculated) && nowPlayingMode ? 'Now Playing' : capitalize(calculated)}

+

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

diff --git a/src/core/Atomic.ts b/src/core/Atomic.ts index 0cda60b3..4abee445 100644 --- a/src/core/Atomic.ts +++ b/src/core/Atomic.ts @@ -257,6 +257,7 @@ export interface SourcePlayerObj { playerLastUpdatedAt: string position?: Second listenedDuration: Second + nowPlayingMode?: boolean status: { reported: string calculated: string -- 2.51.2 From aca23ed90d5ee309dd62fa28127aa01fa704e8f9 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 26 Aug 2025 20:26:32 +0000 Subject: [PATCH 5/6] test: Update lz expected config count --- src/backend/tests/config/config.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/backend/tests/config/config.test.ts b/src/backend/tests/config/config.test.ts index 9d020185..2799ce9a 100644 --- a/src/backend/tests/config/config.test.ts +++ b/src/backend/tests/config/config.test.ts @@ -92,8 +92,7 @@ describe('Sample Configs', function () { await copyFile(samplePath(componentType), `${componentType}.json`); const clients = new ScrobbleClients(emitter, new EventEmitter, new URL('http://example.com'), process.cwd(), loggerTest); await clients.buildClientsFromConfig(new Notifiers(new EventEmitter, new EventEmitter, new EventEmitter, loggerTest)); - const expectedCount = componentType === 'listenbrainz' ? 2 : 1; - expect(clients.clients).length(expectedCount); + expect(clients.clients).length(1); }); } }); -- 2.51.2 From 87d05b7c75a9a0940af3a599bc94c2959ee46553 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Wed, 27 Aug 2025 12:26:46 +0000 Subject: [PATCH 6/6] fix(listenbrainz): Fix missing album/release_name for listenbrainz endpoint #342 --- src/backend/common/vendor/ListenbrainzApiClient.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/backend/common/vendor/ListenbrainzApiClient.ts b/src/backend/common/vendor/ListenbrainzApiClient.ts index 944b179b..50db9504 100644 --- a/src/backend/common/vendor/ListenbrainzApiClient.ts +++ b/src/backend/common/vendor/ListenbrainzApiClient.ts @@ -254,6 +254,7 @@ export class ListenbrainzApiClient extends AbstractApiClient { track_metadata: { artist_name, track_name, + release_name, additional_info: { duration, track_mbid, @@ -269,6 +270,7 @@ export class ListenbrainzApiClient extends AbstractApiClient { playDate: typeof listened_at === 'number' ? dayjs.unix(listened_at) : dayjs(listened_at), track: track_name, artists: [artist_name], + album: release_name, duration, meta: { brainz: {