diff --git a/.github/ISSUE_TEMPLATE/01-bug-report.yml b/.github/ISSUE_TEMPLATE/01-bug-report.yml index 53b39109..55534778 100644 --- a/.github/ISSUE_TEMPLATE/01-bug-report.yml +++ b/.github/ISSUE_TEMPLATE/01-bug-report.yml @@ -50,9 +50,17 @@ body: attributes: label: Logs description: | - Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. - If possible reproduce the issue with [**Debug Mode** enabled](https://foxxmd.github.io/multi-scrobbler/docs/configuration#debug-mode) + Please copy and paste any/all relevant from the DEBUG level log output. + Even if your issue does not seem to be shown in the logs you should still add as much detail as possible, a _lack_ of issues in logs may be a good hint during debugging. + Your logs will be automatically formatted into code, do not need use backticks. + If possible reproduce the issue with [**Debug Mode** enabled](https://foxxmd.github.io/multi-scrobbler/docs/configuration#debug-mode). render: shell + placeholder: | + [2025-02-14 12:47:57.170 -0500] VERBOSE: [Init] Config Dir ENV: undefined -> Resolved: CWD/config + [2025-02-14 12:47:57.236 -0500] INFO : [Init] Debug Mode: NO + [2025-02-14 12:47:57.258 -0500] INFO : [Init] Version: master + [2025-02-14 12:47:57.259 -0500] INFO : [Init] Generating schema definitions... + [2025-02-14 12:47:59.148 -0500] DEBUG : [App] [Scrobblers] [Maloja - default] Found 100 recent scrobbles - type: textarea attributes: label: Additional Context -- 2.51.2 From 2f9a45b1e461f15d425fc56dec3456b389b7e8cc Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Wed, 12 Mar 2025 19:16:32 +0000 Subject: [PATCH 02/28] fix: Filter components by name and check for undefined Fixes #273 --- src/backend/scrobblers/ScrobbleClients.ts | 7 +++++-- src/backend/sources/ScrobbleSources.ts | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/backend/scrobblers/ScrobbleClients.ts b/src/backend/scrobblers/ScrobbleClients.ts index f6bb7898..1b51f236 100644 --- a/src/backend/scrobblers/ScrobbleClients.ts +++ b/src/backend/scrobblers/ScrobbleClients.ts @@ -55,7 +55,7 @@ export default class ScrobbleClients { getByType = (type: any) => this.clients.filter(x => x.type === type) async getStatusSummary(type?: string, name?: string): Promise<[boolean, string[]]> { - let clients: AbstractScrobbleClient[]; + let clients: AbstractScrobbleClient[] = []; const messages: string[] = []; let clientsReady = true; @@ -63,7 +63,10 @@ export default class ScrobbleClients { if(type !== undefined) { clients = this.getByType(type); } else if(name !== undefined) { - clients = [this.getByName(name)]; + const clientByName = this.getByName(name); + if(clientByName !== undefined) { + clients = [clientByName]; + } } else { clients = this.clients; } diff --git a/src/backend/sources/ScrobbleSources.ts b/src/backend/sources/ScrobbleSources.ts index 6dfc1d45..3bc2a44c 100644 --- a/src/backend/sources/ScrobbleSources.ts +++ b/src/backend/sources/ScrobbleSources.ts @@ -86,14 +86,17 @@ export default class ScrobbleSources { getByNameAndType = (name: string, type: SourceType) => this.sources.find(x => x.name === name && x.type === type) async getStatusSummary(type?: string, name?: string): Promise<[boolean, string[]]> { - let sources: AbstractSource[] + let sources: AbstractSource[] = []; let sourcesReady = true; const messages: string[] = []; if(type !== undefined) { sources = this.getByType(type); } else if(name !== undefined) { - sources = [this.getByName(name)]; + const sourceByName = this.getByName(name); + if(sourceByName !== undefined) { + sources = [sourceByName]; + } } else { sources = this.sources; } -- 2.51.2 From ec9bea70fd98790742bab6ffb5693c72f8d78c1f Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 13 Mar 2025 10:37:53 -0400 Subject: [PATCH 03/28] chore: Bump YTM dependency version --- package-lock.json | 16 ++++++++-------- package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 59c57139..4608f624 100644 --- a/package-lock.json +++ b/package-lock.json @@ -82,7 +82,7 @@ "vite-express": "^0.16.0", "vlc-client": "^1.1.1", "xml2js": "0.6.1", - "youtubei.js": "^12.0.0" + "youtubei.js": "^13.1.0" }, "devDependencies": { "@dbus-types/notifications": "^0.0.5", @@ -6810,9 +6810,9 @@ } }, "node_modules/jintr": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jintr/-/jintr-3.1.0.tgz", - "integrity": "sha512-azhCHApkRfBH8INpiUCwKBYaNCdB5G+x3NApsI2MxQXSlgFAx7rap3YwE3JAkN08GO8f3ilZsGB0Yvc+412ntQ==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/jintr/-/jintr-3.2.1.tgz", + "integrity": "sha512-yjKUBuwTTg4nc4izMysxuIk0BKh45hnbc1KnXE6LxagIGZn5od+I2elpuRY9IIm3EiKiUZxhxV89a0iX+xoEZg==", "funding": [ "https://github.com/sponsors/LuanRT" ], @@ -11922,15 +11922,15 @@ } }, "node_modules/youtubei.js": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/youtubei.js/-/youtubei.js-12.0.0.tgz", - "integrity": "sha512-pGmVb1I9b2gseqmuMx+BCajzVUi04+r+8zxj4Fk/iQaGQGvBCbY87Tu9mdvEgIQYTkkb4Fza7GZGrH9AjYNbrw==", + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/youtubei.js/-/youtubei.js-13.1.0.tgz", + "integrity": "sha512-uL4TyojAYET0c5NGFD7+ScCod/k8Pc/B+D5tLrunFcz1GaBjRMOGRPcNGaRmnhwisegU7ibtw0iUxCN+BZ0ang==", "funding": [ "https://github.com/sponsors/LuanRT" ], "dependencies": { "@bufbuild/protobuf": "^2.0.0", - "jintr": "^3.1.0", + "jintr": "^3.2.1", "tslib": "^2.5.0", "undici": "^5.19.1" } diff --git a/package.json b/package.json index 89110cb3..148d36ea 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,7 @@ "vite-express": "^0.16.0", "vlc-client": "^1.1.1", "xml2js": "0.6.1", - "youtubei.js": "^12.0.0" + "youtubei.js": "^13.1.0" }, "devDependencies": { "@dbus-types/notifications": "^0.0.5", -- 2.51.2 From 5927450840dc91a19ef410bd61bce350c2a6fbf6 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 13 Mar 2025 10:40:47 -0400 Subject: [PATCH 04/28] fix(ytm): Recover artist/album from YTM when not surfaced by youtube.js Use flex_columns for parsing raw data when musicshelf when artist/album does not have an endpoint LuanRT/YouTube.js#381 Fixes #280 --- src/backend/sources/YTMusicSource.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/backend/sources/YTMusicSource.ts b/src/backend/sources/YTMusicSource.ts index 67143563..78031c36 100644 --- a/src/backend/sources/YTMusicSource.ts +++ b/src/backend/sources/YTMusicSource.ts @@ -22,6 +22,7 @@ import { joinedUrl } from "../utils/NetworkUtils.js"; import { FixedSizeList } from "fixed-size-list"; import { todayAwareFormat } from "../utils/TimeUtils.js"; import { RestType } from "ts-json-schema-generator"; +import { parseArtistCredits, parseCredits } from "../utils/StringUtils.js"; export interface HistoryIngressResult { plays: PlayObject[], @@ -348,6 +349,25 @@ Redirect URI : ${this.redirectUri}`); } else if(authorData !== undefined) { artists = authorData.map(x => x.name) as string[]; } + if(artists.length === 0 && obj.flex_columns.at(1)?.title?.text !== undefined) { + // if YTM doesn't have an endpoint (page) for an artist (combined) then YouTube.js doesn't include it + // in the music shelf object created from parsing data + // BUT YouTube.js does expose the raw data so we can try to recover artists from plain text + // https://github.com/LuanRT/YouTube.js/issues/381 + const credits = parseArtistCredits(obj.flex_columns.at(1)?.title?.text); + if(credits !== undefined) { + // try to be clever + artists.push(credits.primary); + if(credits.secondary !== undefined) { + const nonEmptyArtists = credits.secondary.filter(x => x !== undefined && x !== null && x.trim() !== ''); + if(nonEmptyArtists.length > 0) { + artists = [...artists, ...nonEmptyArtists]; + } + } + } else { + artists = [obj.flex_columns.at(1)?.title?.text]; + } + } let albumArtists: string[] = []; if(artistsData !== undefined && authorData !== undefined) { @@ -356,6 +376,9 @@ Redirect URI : ${this.redirectUri}`); if(albumData !== undefined) { album = albumData.name; } + if(album === undefined && obj.flex_columns.at(2)?.title?.text !== undefined) { + album = obj.flex_columns.at(2)?.title?.text; + } if(dur!== undefined) { const durObj = dayjs.duration(dur.seconds, 's') duration = durObj.asSeconds(); -- 2.51.2 From 1a62edab37476a4a2054ac173234e77db47d84db Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 21 Jan 2025 16:45:50 +0000 Subject: [PATCH 05/28] feat(source): Add stale/orphaned intervals as user configurable --- .../common/infrastructure/config/common.ts | 27 +++++++++++++++ src/backend/sources/MemorySource.ts | 2 +- .../PlayerState/AbstractPlayerState.ts | 34 ++++++++++++++----- 3 files changed, 53 insertions(+), 10 deletions(-) diff --git a/src/backend/common/infrastructure/config/common.ts b/src/backend/common/infrastructure/config/common.ts index 4cdb7f3f..e24407cc 100644 --- a/src/backend/common/infrastructure/config/common.ts +++ b/src/backend/common/infrastructure/config/common.ts @@ -48,5 +48,32 @@ export interface PollingOptions { * @examples [30] * */ maxInterval?: number + + /** + * Number of seconds after which A Player is considered Stale + * + * When Polling the source does not recieve data about a specific Player after X seconds it becomes Stale. When the Player becomes Stale: + * + * * The current listening session is ended. If the Player becomes active again a new listening session is started (Player will miss `interval` seconds of listening) + * * If the player has an existing session w/ track then MS attempts to scrobble it + * + * This option DOES NOT need to be set. It is automatically calculated as (`interval` * 3) when not defined. + */ + staleAfter?: number + + /** + * Number of seconds after which A Player is considered Orphaned + * + * When Polling the source does not recieve data about a specific Player after X seconds it becomes Orphaned. When the Player becomes Orphaned: + * + * * The current Player session is ended and the Player is removed from MS + * * MS attempts to scrobble, if the Player has an existing session w/ track + * + * A Player should become Orphaned EQUAL TO OR AFTER it becomes Stale. + * + * * This option DOES NOT need to be set. It is automatically calculated as (`interval` * 5) when not defined. + * * If it is set it must be equal to or larger than `staleAfter` or (`interval * 3`) + */ + orphanedAfter?: number } diff --git a/src/backend/sources/MemorySource.ts b/src/backend/sources/MemorySource.ts index e63985cc..0358a07b 100644 --- a/src/backend/sources/MemorySource.ts +++ b/src/backend/sources/MemorySource.ts @@ -141,7 +141,7 @@ export default class MemorySource extends AbstractSource { setNewPlayer = (idStr: string, logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions = {}) => { this.players.set(idStr, this.getNewPlayer(this.logger, id, { - ...createPlayerOptions(this.config.data as Partial), + ...createPlayerOptions(this.config.data as Partial, this.playerSourceOfTruth, this.logger), ...opts })); this.playerState.set(idStr, ''); diff --git a/src/backend/sources/PlayerState/AbstractPlayerState.ts b/src/backend/sources/PlayerState/AbstractPlayerState.ts index e3c13dfd..f3823148 100644 --- a/src/backend/sources/PlayerState/AbstractPlayerState.ts +++ b/src/backend/sources/PlayerState/AbstractPlayerState.ts @@ -30,22 +30,38 @@ export interface PlayerStateOptions extends PlayerStateIntervals { export const DefaultPlayerStateOptions: PlayerStateOptions = {}; -export const createPlayerOptions = (pollingOpts?: Partial, sot: SOURCE_SOT_TYPES = SOURCE_SOT.PLAYER): PlayerStateOptions => { +export const createPlayerOptions = (pollingOpts?: Partial, sot: SOURCE_SOT_TYPES = SOURCE_SOT.PLAYER, logger?: Logger): PlayerStateOptions => { const { interval = 30, maxInterval = 60, + staleAfter, + orphanedAfter } = pollingOpts || {}; - if(sot === SOURCE_SOT.PLAYER) { - return { - staleInterval: interval * 3, - orphanedInterval: interval * 5 - } - } + + 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: interval, - orphanedInterval: maxInterval + staleInterval: sa, + orphanedInterval: oa } } -- 2.51.2 From 47541a449b39fa023fed69e59c76878dd405d904 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 21 Jan 2025 17:07:05 +0000 Subject: [PATCH 06/28] feat: Use human-friendly timestamp for stale/ophan interva logging --- src/backend/sources/JellyfinSource.ts | 2 +- src/backend/sources/MemorySource.ts | 4 +- .../PlayerState/AbstractPlayerState.ts | 6 +-- src/backend/utils.ts | 44 +-------------- src/backend/utils/TimeUtils.ts | 54 ++++++++++++++++++- 5 files changed, 60 insertions(+), 50 deletions(-) diff --git a/src/backend/sources/JellyfinSource.ts b/src/backend/sources/JellyfinSource.ts index 711a5d77..dc4d1a57 100644 --- a/src/backend/sources/JellyfinSource.ts +++ b/src/backend/sources/JellyfinSource.ts @@ -14,9 +14,9 @@ import { doubleReturnNewline, isDebugMode, parseBool, - parseDurationFromTimestamp, playObjDataMatch, } from "../utils.js"; +import { parseDurationFromTimestamp } from '../utils/TimeUtils.js'; import { comparePlayTemporally, temporalAccuracyIsAtLeast, diff --git a/src/backend/sources/MemorySource.ts b/src/backend/sources/MemorySource.ts index 0358a07b..a1ce369d 100644 --- a/src/backend/sources/MemorySource.ts +++ b/src/backend/sources/MemorySource.ts @@ -27,7 +27,7 @@ import { playObjDataMatch, thresholdResultSummary, } from "../utils.js"; -import { timePassesScrobbleThreshold } from "../utils/TimeUtils.js"; +import { timePassesScrobbleThreshold, timeToHumanTimestamp } from "../utils/TimeUtils.js"; import AbstractSource from "./AbstractSource.js"; import { AbstractPlayerState, createPlayerOptions, PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js"; import { GenericPlayerState } from "./PlayerState/GenericPlayerState.js"; @@ -120,7 +120,7 @@ export default class MemorySource extends AbstractSource { } } if(deletePlayer) { - this.deletePlayer(player.platformIdStr, `Removed after being orphaned for ${dayjs.duration(player.stateIntervalOptions.orphanedInterval, 'seconds').asMinutes()} minutes`); + this.deletePlayer(player.platformIdStr, `Removed after being orphaned for ${timeToHumanTimestamp(dayjs.duration(player.stateIntervalOptions.orphanedInterval, 'seconds'))}`); } return discoveredCleanupPlay; diff --git a/src/backend/sources/PlayerState/AbstractPlayerState.ts b/src/backend/sources/PlayerState/AbstractPlayerState.ts index f3823148..02a0ad4f 100644 --- a/src/backend/sources/PlayerState/AbstractPlayerState.ts +++ b/src/backend/sources/PlayerState/AbstractPlayerState.ts @@ -16,7 +16,7 @@ import { PollingOptions } from "../../common/infrastructure/config/common.js"; import { formatNumber, genGroupIdStr, playObjDataMatch, progressBar } from "../../utils.js"; import { ListenProgress } from "./ListenProgress.js"; import { ListenRange, ListenRangePositional } from "./ListenRange.js"; -import { todayAwareFormat } from "../../utils/TimeUtils.js"; +import { timeToHumanTimestamp, todayAwareFormat } from "../../utils/TimeUtils.js"; export interface PlayerStateIntervals { staleInterval?: number @@ -113,7 +113,7 @@ export abstract class AbstractPlayerState { const isStale = this.isUpdateStale(); 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 ${Math.abs(dayjs().diff(this.playLastUpdatedAt, 'seconds'))} seconds`); + this.logger.debug(`Stale after no Play updates for ${timeToHumanTimestamp(Math.abs(dayjs().diff(this.playLastUpdatedAt, 'ms')))} (staleAfter ${this.stateIntervalOptions.staleInterval}s)`); // end current listening sessions this.currentListenSessionEnd(); } @@ -132,7 +132,7 @@ export abstract class AbstractPlayerState { 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`); + 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)`); } return isOrphaned; } diff --git a/src/backend/utils.ts b/src/backend/utils.ts index 2b0cf807..b17591c3 100644 --- a/src/backend/utils.ts +++ b/src/backend/utils.ts @@ -230,7 +230,7 @@ export const parseRetryAfterSecsFromObj = (err: any) => { // } const { response: { - // @ts-ignore + // @ts-expect-error headers, // returned in superagent error } = {}, retryAfter: ra // possible custom property we have set @@ -302,48 +302,6 @@ export const removeUndefinedKeys = >(obj: T): T | return newObj; } -export const parseDurationFromTimestamp = (timestamp: any) => { - if (timestamp === null || timestamp === undefined) { - return undefined; - } - if (!(typeof timestamp === 'string')) { - throw new Error('Timestamp must be a string'); - } - if (timestamp.trim() === '') { - return undefined; - } - const parsedRuntime = timestamp.split(':'); - let hours = '0', - minutes = '0', - seconds = '0', - milli = '0'; - - switch (parsedRuntime.length) { - case 3: - hours = parsedRuntime[0]; - minutes = parsedRuntime[1]; - seconds = parsedRuntime[2]; - break; - case 2: - minutes = parsedRuntime[0]; - seconds = parsedRuntime[1]; - break; - case 1: - seconds = parsedRuntime[0]; - } - const splitSec = seconds.split('.'); - if (splitSec.length > 1) { - seconds = splitSec[0]; - milli = splitSec[1]; - } - return dayjs.duration({ - hours: Number.parseInt(hours), - minutes: Number.parseInt(minutes), - seconds: Number.parseInt(seconds), - milliseconds: Number.parseInt(milli) - }); -} - export const remoteHostIdentifiers = (req: Request): RemoteIdentityParts => { const remote = req.connection.remoteAddress; const proxyRemote = Array.isArray(req.headers["x-forwarded-for"]) ? req.headers["x-forwarded-for"][0] : req.headers["x-forwarded-for"]; diff --git a/src/backend/utils/TimeUtils.ts b/src/backend/utils/TimeUtils.ts index ab9328b2..ef8d4fb1 100644 --- a/src/backend/utils/TimeUtils.ts +++ b/src/backend/utils/TimeUtils.ts @@ -245,4 +245,56 @@ export const todayAwareFormat = (date: Dayjs, opts: {fullFormat?: string, todayF todayFormat = 'HH:mm:ssZ' } = opts; return date.format(date.isToday() ? todayFormat : fullFormat); -} +}; +export const parseDurationFromTimestamp = (timestamp: any) => { + if (timestamp === null || timestamp === undefined) { + return undefined; + } + if (!(typeof timestamp === 'string')) { + throw new Error('Timestamp must be a string'); + } + if (timestamp.trim() === '') { + return undefined; + } + const parsedRuntime = timestamp.split(':'); + let hours = '0', minutes = '0', seconds = '0', milli = '0'; + + switch (parsedRuntime.length) { + case 3: + hours = parsedRuntime[0]; + minutes = parsedRuntime[1]; + seconds = parsedRuntime[2]; + break; + case 2: + minutes = parsedRuntime[0]; + seconds = parsedRuntime[1]; + break; + case 1: + seconds = parsedRuntime[0]; + } + const splitSec = seconds.split('.'); + if (splitSec.length > 1) { + seconds = splitSec[0]; + milli = splitSec[1]; + } + return dayjs.duration({ + hours: Number.parseInt(hours), + minutes: Number.parseInt(minutes), + seconds: Number.parseInt(seconds), + milliseconds: Number.parseInt(milli) + }); +}; + +export type Milliseconds = number; + +export const timeToHumanTimestamp = (val: ReturnType | Milliseconds): string => { + const ms = dayjs.isDuration(val) ? val.asMilliseconds() : val; + + // less than one hour + if(ms < 3600000) { + // EX 14:07 + return new Date(ms).toISOString().substring(14, 19) + } + // EX 01:15:45 + return new Date(ms).toISOString().substring(11, 19); +} \ No newline at end of file -- 2.51.2 From 84f353220d19686d8e1565ed74f36d43b0d7e708 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 21 Jan 2025 17:27:10 +0000 Subject: [PATCH 07/28] feat(source): Clear listening range on stale to prevent erroneous scrobble on resume Clearing pre-stale listening range prevents any future resume (player becomes non-stale) from using old listening range to scrobble. Represents a physical "break" between listening sessions for the same song --- src/backend/sources/MemorySource.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/backend/sources/MemorySource.ts b/src/backend/sources/MemorySource.ts index a1ce369d..30573328 100644 --- a/src/backend/sources/MemorySource.ts +++ b/src/backend/sources/MemorySource.ts @@ -116,6 +116,14 @@ export default class MemorySource extends AbstractSource { } 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; } } } -- 2.51.2 From 818f67d6daf5c0d8678a33238c01c1b2f706b1de Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 21 Jan 2025 17:50:43 +0000 Subject: [PATCH 08/28] feat(source): Add relative time since last activity to logging Makes logs easier to read when looking for stale source/player --- src/backend/sources/AbstractSource.ts | 10 ++++++---- src/backend/utils/TimeUtils.ts | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/backend/sources/AbstractSource.ts b/src/backend/sources/AbstractSource.ts index 4ad38e31..72005dba 100644 --- a/src/backend/sources/AbstractSource.ts +++ b/src/backend/sources/AbstractSource.ts @@ -33,7 +33,7 @@ import { sortByNewestPlayDate, sortByOldestPlayDate, } from "../utils.js"; -import { comparePlayTemporally, temporalAccuracyIsAtLeast, todayAwareFormat } from "../utils/TimeUtils.js"; +import { comparePlayTemporally, temporalAccuracyIsAtLeast, timeToHumanTimestamp, todayAwareFormat } from "../utils/TimeUtils.js"; import { getRoot } from '../ioc.js'; import { componentFileLogger } from '../common/logging.js'; import { WebhookPayload } from '../common/infrastructure/config/health/webhooks.js'; @@ -440,6 +440,8 @@ export default abstract class AbstractSource extends AbstractComponent implement const activeThreshold = this.lastActivityAt.add(checkActiveFor, 's'); const inactiveFor = dayjs.duration(Math.abs(activeThreshold.diff(dayjs(), 'millisecond'))).humanize(false); + const relativeActivity = dayjs.duration(this.lastActivityAt.diff(dayjs(), 'ms')); + const humanRelativeActivity = relativeActivity.asSeconds() > -3 ? '' : ` (${timeToHumanTimestamp(relativeActivity)} ago)`; let friendlyInterval = ''; const friendlyLastFormat = todayAwareFormat(this.lastActivityAt); if (activeThreshold.isBefore(dayjs())) { @@ -452,12 +454,12 @@ export default abstract class AbstractSource extends AbstractComponent implement sleepTime = interval + backoff; } if(isDebugMode()) { - debugMsgs.push(`Last activity ${friendlyLastFormat} is ${inactiveFor} outside of polling period (last activity + ${checkActiveFor}s)`); + debugMsgs.push(`Last activity ${friendlyLastFormat}${humanRelativeActivity} is ${inactiveFor} outside of polling period (last activity + ${checkActiveFor}s)`); } else { - debugMsgs.push(`Last activity was at ${friendlyLastFormat}`); + debugMsgs.push(`Last activity was at ${friendlyLastFormat}${humanRelativeActivity}`); } } else { - debugMsgs.push(`Last activity was at ${friendlyLastFormat}`); + debugMsgs.push(`Last activity was at ${friendlyLastFormat}${humanRelativeActivity}`); friendlyInterval = `${formatNumber(sleepTime)}s`; } debugMsgs.push(`Next check in ${friendlyInterval}`); diff --git a/src/backend/utils/TimeUtils.ts b/src/backend/utils/TimeUtils.ts index ef8d4fb1..82431c48 100644 --- a/src/backend/utils/TimeUtils.ts +++ b/src/backend/utils/TimeUtils.ts @@ -288,7 +288,7 @@ export const parseDurationFromTimestamp = (timestamp: any) => { export type Milliseconds = number; export const timeToHumanTimestamp = (val: ReturnType | Milliseconds): string => { - const ms = dayjs.isDuration(val) ? val.asMilliseconds() : val; + const ms = dayjs.isDuration(val) ? Math.abs(val.asMilliseconds()) : val; // less than one hour if(ms < 3600000) { -- 2.51.2 From f11a2b5442780441bbae3c0163083b4a325eb216 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 31 Jan 2025 18:37:02 +0000 Subject: [PATCH 09/28] fix: Use last known position for final range when cleaning up stale RT player Prevents listen duration from being reported as overdrift RT position due to lack of player updates --- .../PlayerState/AbstractPlayerState.ts | 14 +++++----- .../PlayerState/PositionalPlayerState.ts | 12 +++++++++ src/backend/tests/player/player.test.ts | 27 +++++++++++++++++-- 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/src/backend/sources/PlayerState/AbstractPlayerState.ts b/src/backend/sources/PlayerState/AbstractPlayerState.ts index 02a0ad4f..35cbfc8e 100644 --- a/src/backend/sources/PlayerState/AbstractPlayerState.ts +++ b/src/backend/sources/PlayerState/AbstractPlayerState.ts @@ -102,18 +102,18 @@ export abstract class AbstractPlayerState { return this.platformId[0] === candidateId[0] && this.platformId[1] === candidateId[1]; } - isUpdateStale() { + isUpdateStale(reportedTS?: Dayjs) { if (this.currentPlay !== undefined) { - return Math.abs(dayjs().diff(this.playLastUpdatedAt, 'seconds')) > this.stateIntervalOptions.staleInterval; + return Math.abs((reportedTS ?? dayjs()).diff(this.playLastUpdatedAt, 'seconds')) > this.stateIntervalOptions.staleInterval; } return false; } - checkStale() { - const isStale = this.isUpdateStale(); + 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(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.stateIntervalOptions.staleInterval}s)`); // end current listening sessions this.currentListenSessionEnd(); } @@ -170,7 +170,7 @@ export abstract class AbstractPlayerState { protected setPlay(state: PlayerStateData, reportedTS?: Dayjs): [PlayObject, PlayObject?] { const {play, status, sessionId} = state; - this.playLastUpdatedAt = dayjs(); + this.playLastUpdatedAt = reportedTS ?? dayjs(); if (status !== undefined) { this.reportedStatus = status; } @@ -344,7 +344,7 @@ export abstract class AbstractPlayerState { const {play, position} = state; this.currentPlay = play; - this.playFirstSeenAt = dayjs(); + this.playFirstSeenAt = reportedTS ?? dayjs(); this.listenRanges = []; this.currentListenRange = undefined; diff --git a/src/backend/sources/PlayerState/PositionalPlayerState.ts b/src/backend/sources/PlayerState/PositionalPlayerState.ts index a716dd53..17967e3c 100644 --- a/src/backend/sources/PlayerState/PositionalPlayerState.ts +++ b/src/backend/sources/PlayerState/PositionalPlayerState.ts @@ -94,9 +94,21 @@ export class PositionalPlayerState extends AbstractPlayerState { // likely the track was listened to until it ended // but polling interval or network delays caused MS to not get data on the very end // also...within 3 seconds of ending is close enough to call this complete IMO + // + // -- STALE is included in this because there are some Sources (jellyfin or plex with 3rd party apps?) + // that will created a new "player" if the queue is cleared or stopping the player, + // from the user's perspective its all the same but then MS sees it as different + // so the player will eventually be pruned but we want to treat the play "going stale" it as if it finished to account for this behavior + this.logger.debug(`Listen duration was within ${this.gracefulEndBuffer}s of Play duration, bumping duration to 100% ${this.calculatedStatus === CALCULATED_PLAYER_STATUSES.stale ? 'because stale player probably finished Play before going dark.' : ' because we probably just missed Source reporting 100% before changing Play.'}`) finalPosition = duration; //this.currentListenRange.end.position = duration; + } else if(this.calculatedStatus === CALCULATED_PLAYER_STATUSES.stale && this.currentListenRange.isOverDrifted(this.currentListenRange.end.position)) { + // if player uses realtime but source went stale and WAS NOT close to the end its likely the RT is way overdrifted + // in which case we definitely do want to use RT as final position + // so use last known position before stale instead + this.logger.debug(`Player became Stale and realtime position overdrifted before session ended! Using last known position instead of RT position so listen duration stays accurate.`); + finalPosition = this.currentListenRange.end.position; } } this.currentListenRange.finalize(finalPosition); diff --git a/src/backend/tests/player/player.test.ts b/src/backend/tests/player/player.test.ts index b8fe204b..6f44c7c6 100644 --- a/src/backend/tests/player/player.test.ts +++ b/src/backend/tests/player/player.test.ts @@ -1,5 +1,5 @@ import { loggerTest } from "@foxxmd/logging"; -import { assert } from 'chai'; +import { assert, expect } from 'chai'; import clone from "clone"; import dayjs, { Dayjs } from "dayjs"; import { describe, it } from 'mocha'; @@ -26,7 +26,7 @@ const testState = (data: Omit): PlayerSt class TestPositionalPlayerState extends PositionalPlayerState { protected newListenRange(start?: ListenProgressPositional, end?: ListenProgressPositional, options: object = {}): ListenRangePositional { - const range = super.newListenRange(start, end, {rtImmediate: false, ...options}); + const range = super.newListenRange(start, end, {allowedDrift: this.allowedDrift, rtImmediate: false, rtTruth: this.rtTruth, ...options}); return range; } public testSessionRepeat(position: number, reportedTS?: Dayjs) { @@ -151,6 +151,29 @@ describe('Player status', function () { assert.equal(CALCULATED_PLAYER_STATUSES.paused, player.calculatedStatus); }); + it('Uses last known position for final range when cleaning up stale player', function () { + const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER], {staleInterval: 20, rtTruth: true}); + + const positioned = clone(newPlay); + positioned.meta.trackProgressPosition = 3; + + player.update(testState({play: positioned, position: 3})); + + player.currentListenRange.rtPlayer.setPosition(13000); + player.update(testState({play: positioned, position: 13}), dayjs().add(10, 'seconds')); + + player.currentListenRange.rtPlayer.setPosition(23000); + player.update(testState({play: positioned, position: 23}), dayjs().add(20, 'seconds')); + + const staleDate = dayjs().add(41, 'seconds') + player.currentListenRange.rtPlayer.setPosition(44000); + expect(player.currentListenRange.isOverDrifted(23)).to.be.true; + + expect(player.checkStale(staleDate)).to.be.true; + expect(player.listenRanges[player.listenRanges.length - 1].end.position).to.eq(23); + expect(player.getListenDuration()).to.eq(20); + }); + // TODO playback position reported and conflicts with player reported status }); }); -- 2.51.2 From 38ea3115f671e60aaf7a3eefa152a9920d5d8765 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 31 Jan 2025 20:26:21 +0000 Subject: [PATCH 10/28] test: Add tests for scrobble behavior when cleaning up players --- package-lock.json | 7 + package.json | 1 + src/backend/tests/source/TestSource.ts | 10 + src/backend/tests/source/source.test.ts | 243 ++++++++++++++++++++++- src/backend/tests/utils/PlayTestUtils.ts | 16 ++ 5 files changed, 275 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4608f624..f5fbe3e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -121,6 +121,7 @@ "eslint": "^8.56.0", "eslint-plugin-prefer-arrow-functions": "^3.2.4", "mocha": "^10.3.0", + "mockdate": "^3.0.5", "msw": "^2.1.2", "nodemon": "^3.0.3", "ts-essentials": "^9.1.2", @@ -7393,6 +7394,12 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/mockdate": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/mockdate/-/mockdate-3.0.5.tgz", + "integrity": "sha512-iniQP4rj1FhBdBYS/+eQv7j1tadJ9lJtdzgOpvsOHng/GbcDh2Fhdeq+ZRldrPYdXvCyfFUmFeEwEGXZB5I/AQ==", + "dev": true + }, "node_modules/mopidy": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/mopidy/-/mopidy-1.3.0.tgz", diff --git a/package.json b/package.json index 148d36ea..796c661f 100644 --- a/package.json +++ b/package.json @@ -153,6 +153,7 @@ "eslint": "^8.56.0", "eslint-plugin-prefer-arrow-functions": "^3.2.4", "mocha": "^10.3.0", + "mockdate": "^3.0.5", "msw": "^2.1.2", "nodemon": "^3.0.3", "ts-essentials": "^9.1.2", diff --git a/src/backend/tests/source/TestSource.ts b/src/backend/tests/source/TestSource.ts index 3631b2d0..b11a742c 100644 --- a/src/backend/tests/source/TestSource.ts +++ b/src/backend/tests/source/TestSource.ts @@ -1,8 +1,18 @@ import { PlayObject } from "../../../core/Atomic.js"; import AbstractSource from "../../sources/AbstractSource.js"; +import { MemoryPositionalSource } from "../../sources/MemoryPositionalSource.js"; +import MemorySource from "../../sources/MemorySource.js"; export class TestSource extends AbstractSource { handle(plays: PlayObject[]) { this.scrobble(plays); } } + +export class TestMemorySource extends MemoryPositionalSource { + +} + +export class TestMemoryPositionalSource extends MemoryPositionalSource { + +} \ No newline at end of file diff --git a/src/backend/tests/source/source.test.ts b/src/backend/tests/source/source.test.ts index f2ae8d84..67a3ee58 100644 --- a/src/backend/tests/source/source.test.ts +++ b/src/backend/tests/source/source.test.ts @@ -6,10 +6,15 @@ import { after, before, describe, it } from 'mocha'; import pEvent from "p-event"; import clone from 'clone'; import { PlayObject } from "../../../core/Atomic.js"; -import { generatePlay } from "../utils/PlayTestUtils.js"; -import { TestSource } from "./TestSource.js"; +import { generatePlay, generatePlayerStateData } from "../utils/PlayTestUtils.js"; +import { TestMemoryPositionalSource, TestMemorySource, TestSource } from "./TestSource.js"; import spotifyPayload from '../plays/spotifyCurrentPlaybackState.json'; import SpotifySource from "../../sources/SpotifySource.js"; +import MockDate from 'mockdate'; +import dayjs, { Dayjs } from "dayjs"; +import { REPORTED_PLAYER_STATUSES } from "../../common/infrastructure/Atomic.js"; +import { SourceConfig } from "../../common/infrastructure/config/source/sources.js"; +import MemorySource from "../../sources/MemorySource.js"; chai.use(asPromised); @@ -20,6 +25,20 @@ const generateSource = () => { } let source: TestSource = generateSource(); +const generateMemorySource = (config: SourceConfig = {}) => { + const s = new TestMemorySource('spotify', 'test', config, {localUrl: new URL('https://example.com'), configDir: 'fake', logger: loggerTest, version: 'test'}, emitter); + s.buildTransformRules(); + s.scheduler.stop(); + return s; +} + +const generateMemoryPositionalSource = (config: SourceConfig = {}) => { + const s = new TestMemoryPositionalSource('spotify', 'test', config, {localUrl: new URL('https://example.com'), configDir: 'fake', logger: loggerTest, version: 'test'}, emitter); + s.buildTransformRules(); + s.scheduler.stop(); + return s; +} + describe('Sources use transform plays correctly', function () { beforeEach(function() { @@ -159,4 +178,224 @@ describe('Sources correctly parse incoming payloads', function () { expect(identicalArtistsPlay.data.artists).eql(['Dubmood', 'MASTER BOOT RECORD']); expect(identicalArtistsPlay.data.albumArtists).to.be.empty; }); +}); + +describe('Player Cleanup', function () { + + this.afterEach(() => { + MockDate.reset(); + }); + + const cleanedUpDuration = (generateSource: (config: SourceConfig) => MemorySource) => { + const source = generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}}); + const initialDate = dayjs(); + const initialState = generatePlayerStateData({position: 0, playData: {duration: 50}, timestamp: initialDate, status: REPORTED_PLAYER_STATUSES.playing}); + expect(source.processRecentPlays([initialState]).length).to.be.eq(0); + + let position = 0; + let timeSince = 0; + + // simulate polling playing source for 30 seconds, 10 second interval + for(let i = 0; i < 3; i++) { + position += 10; + timeSince += 10; + MockDate.set(initialDate.add(position, 'seconds').toDate()); + const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing}); + expect(source.processRecentPlays([advancedState]).length).to.be.eq(0); + } + + // simulate polling another 20 seconds without any updates from the Source + for(let i = 0; i < 2; i++) { + timeSince += 10; + MockDate.set(initialDate.add(timeSince, 'seconds').toDate()); + expect(source.processRecentPlays([]).length).to.be.eq(0); + } + + MockDate.set(initialDate.add(timeSince + 2, 'seconds').toDate()); + const discoveredPlays = source.processRecentPlays([]); + // cleanup should discover stale play + expect(discoveredPlays.length).to.be.eq(1); + expect(discoveredPlays[0].data.listenedFor).closeTo(30, 2); + } + + it('Discovers cleaned up Play with correct duration (Non Positional Source)', function () { + cleanedUpDuration(generateMemorySource); + }); + + it('Discovers cleaned up Play with correct duration (Positional Source)', function () { + cleanedUpDuration(generateMemoryPositionalSource); + }); + + const noScrobbleRediscoveryOnActive = (generateSource: (config: SourceConfig) => MemorySource) => { + + const source = generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}}); + const initialDate = dayjs(); + const initialState = generatePlayerStateData({position: 0, playData: {duration: 50}, timestamp: initialDate, status: REPORTED_PLAYER_STATUSES.playing}); + expect(source.processRecentPlays([initialState]).length).to.be.eq(0); + + let position = 0; + let timeSince = 0; + + // simulate polling playing source for 30 seconds, 10 second interval + for(let i = 0; i < 3; i++) { + position += 10; + timeSince += 10; + MockDate.set(initialDate.add(position, 'seconds').toDate()); + const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing}); + expect(source.processRecentPlays([advancedState]).length).to.be.eq(0); + } + + // simulate polling another 20 seconds without any updates from the Source + for(let i = 0; i < 2; i++) { + timeSince += 10; + MockDate.set(initialDate.add(timeSince, 'seconds').toDate()); + expect(source.processRecentPlays([]).length).to.be.eq(0); + } + + timeSince += 2; + + MockDate.set(initialDate.add(timeSince, 'seconds').toDate()); + const discoveredPlays = source.processRecentPlays([]); + // cleanup should discover stale play + expect(discoveredPlays.length).to.be.eq(1); + expect(discoveredPlays[0].data.listenedFor).closeTo(30, 2); + + timeSince += 10; + + position -= 9; + // simulate polling another 20 seconds with active source again + for(let i = 0; i < 2; i++) { + timeSince += 10; + MockDate.set(initialDate.add(timeSince, 'seconds').toDate()); + const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing}); + expect(source.processRecentPlays([advancedState]).length).to.be.eq(0); + } + + timeSince += 10; + MockDate.set(initialDate.add(timeSince, 'seconds').toDate()); + // new Play + const advancedState = generatePlayerStateData({timestamp: dayjs(), position: 0, status: REPORTED_PLAYER_STATUSES.playing}); + // should not return play because it has only been played for ~20 seconds, less than 50% of duration + const plays = source.processRecentPlays([advancedState]) + expect(plays.length).to.be.eq(0); + } + + + it('Does not discover same Play after becoming active again (Non Positional Source)', function () { + noScrobbleRediscoveryOnActive(generateMemorySource); + }); + + it('Does not discover same Play after becoming active again (Positional Source)', function () { + noScrobbleRediscoveryOnActive(generateMemoryPositionalSource); + }); + + const noScrobbleStale = (generateSource: (config: SourceConfig) => MemorySource) => { + + const source = generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}}); + const initialDate = dayjs(); + + // if player incorrectly counted stale time then 30s of actual play + 20s of stale time > scrobble threshold of 50% of 90s + const initialState = generatePlayerStateData({position: 0, playData: {duration: 90}, timestamp: initialDate, status: REPORTED_PLAYER_STATUSES.playing}); + expect(source.processRecentPlays([initialState]).length).to.be.eq(0); + + let position = 0; + let timeSince = 0; + + // simulate polling playing source for 30 seconds, 10 second interval + for(let i = 0; i < 3; i++) { + position += 10; + timeSince += 10; + MockDate.set(initialDate.add(position, 'seconds').toDate()); + const advancedState = generatePlayerStateData({play: initialState.play, timestamp: initialDate, position, status: REPORTED_PLAYER_STATUSES.playing}); + expect(source.processRecentPlays([advancedState]).length).to.be.eq(0); + } + + // simulate polling another 20 seconds without any updates from the Source + for(let i = 0; i < 2; i++) { + timeSince += 10; + MockDate.set(initialDate.add(timeSince, 'seconds').toDate()); + expect(source.processRecentPlays([]).length).to.be.eq(0); + } + + MockDate.set(initialDate.add(timeSince + 2, 'seconds').toDate()); + const discoveredPlays = source.processRecentPlays([]); + // cleanup should not discover stale play + expect(discoveredPlays.length).to.be.eq(0); + + } + + it('Does not discover cleaned up Play that did not meet threshold (Non Positional Source)', function () { + noScrobbleStale(generateMemorySource); + }); + + it('Does not discover cleaned up Play that did not meet threshold (Positional Source)', function () { + noScrobbleStale(generateMemoryPositionalSource); + }); + + const scrobbleRediscoveryOnActive = (generateSource: (config: SourceConfig) => MemorySource) => { + + const source = generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}}); + const initialDate = dayjs(); + + // if player incorrectly counted stale time then 30s of actual play + 20s of stale time > scrobble threshold of 50% of 90s + const initialState = generatePlayerStateData({position: 0, playData: {duration: 90}, timestamp: initialDate, status: REPORTED_PLAYER_STATUSES.playing}); + expect(source.processRecentPlays([initialState]).length).to.be.eq(0); + + let position = 0; + let timeSince = 0; + + // simulate polling playing source for 30 seconds, 10 second interval + for(let i = 0; i < 3; i++) { + position += 10; + timeSince += 10; + MockDate.set(initialDate.add(position, 'seconds').toDate()); + const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing}); + expect(source.processRecentPlays([advancedState]).length).to.be.eq(0); + } + + // simulate polling another 20 seconds without any updates from the Source + for(let i = 0; i < 2; i++) { + timeSince += 10; + MockDate.set(initialDate.add(timeSince, 'seconds').toDate()); + expect(source.processRecentPlays([]).length).to.be.eq(0); + } + + timeSince += 2; + + MockDate.set(initialDate.add(timeSince, 'seconds').toDate()); + const discoveredPlays = source.processRecentPlays([]); + // cleanup should not discover stale play + expect(discoveredPlays.length).to.be.eq(0); + + // so that loop starts 1 second after "paused" position + position -= 9; + + // simulate ~50 seconds of listening (enough for scrobble) + MockDate.set(initialDate.add(timeSince, 'seconds').toDate()); + for(let i = 0; i < 5; i++) { + position += 10; + timeSince += 10; + MockDate.set(initialDate.add(position, 'seconds').toDate()); + const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing}); + expect(source.processRecentPlays([advancedState]).length).to.be.eq(0); + } + + timeSince += 10; + MockDate.set(initialDate.add(position, 'seconds').toDate()); + // new Play + const advancedState = generatePlayerStateData({timestamp: dayjs(), position: 0, status: REPORTED_PLAYER_STATUSES.playing}); + // should return discovered play with ~90 seconds of duration + const plays = source.processRecentPlays([advancedState]) + expect(plays.length).to.be.eq(1); + expect(plays[0].data.duration).to.be.closeTo(90, 2); + + } + + it('Does discover Play after becoming active again (Non Positional Source)', function () { + scrobbleRediscoveryOnActive(generateMemorySource); + }); + + it('Does discover Play after becoming active again (Positional Source)', function () { + scrobbleRediscoveryOnActive(generateMemoryPositionalSource); + }); }); \ No newline at end of file diff --git a/src/backend/tests/utils/PlayTestUtils.ts b/src/backend/tests/utils/PlayTestUtils.ts index 8b971b0c..cf9f4eea 100644 --- a/src/backend/tests/utils/PlayTestUtils.ts +++ b/src/backend/tests/utils/PlayTestUtils.ts @@ -7,6 +7,7 @@ import timezone from "dayjs/plugin/timezone.js"; import utc from "dayjs/plugin/utc.js"; import { JsonPlayObject, ObjectPlayData, PlayMeta, PlayObject } from "../../../core/Atomic.js"; import { sortByNewestPlayDate } from "../../utils.js"; +import { NO_DEVICE, NO_USER, PlayerStateDataMaybePlay, PlayPlatformId, ReportedPlayerStatus } from '../../common/infrastructure/Atomic.js'; dayjs.extend(utc) dayjs.extend(isBetween); @@ -134,6 +135,21 @@ export const normalizePlays = (plays: PlayObject[], return normalizedPlays; } +export const generatePlayerStateData = (options: Omit & {playData?: ObjectPlayData, playMeta?: PlayMeta, platformId?: PlayPlatformId} = {}): PlayerStateDataMaybePlay => { + let play: PlayObject = options.play ?? generatePlay(options.playData, options.playMeta); + if(options.position !== undefined) { + play.meta.trackProgressPosition = options.position; + } + return { + platformId: options.platformId ?? [NO_DEVICE, NO_USER], + sessionId: options.sessionId, + play, + status: options.status, + position: options.position, + timestamp: options.timestamp ?? dayjs() + } +} + export const generatePlay = (data: ObjectPlayData = {}, meta: PlayMeta = {}): PlayObject => { return { data: { -- 2.51.2 From 47eead2a189fa607be0036074a579a145d2d0f16 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 31 Jan 2025 20:26:47 +0000 Subject: [PATCH 11/28] feat: Use timestamp for position MS change on RT player Makes testing easier --- .../sources/PlayerState/RealtimePlayer.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/backend/sources/PlayerState/RealtimePlayer.ts b/src/backend/sources/PlayerState/RealtimePlayer.ts index 717ef4ac..35cfffb5 100644 --- a/src/backend/sources/PlayerState/RealtimePlayer.ts +++ b/src/backend/sources/PlayerState/RealtimePlayer.ts @@ -1,4 +1,5 @@ import { childLogger, Logger } from "@foxxmd/logging"; +import dayjs, { Dayjs } from "dayjs"; import { SimpleIntervalJob, Task, ToadScheduler } from "toad-scheduler"; const RT_TICK = 500; @@ -9,13 +10,21 @@ export abstract class RealtimePlayer { scheduler: ToadScheduler = new ToadScheduler(); protected position: number = 0; + private clockTS: Dayjs = dayjs(); protected constructor(/* logger: Logger */) { //this.logger = childLogger(logger, `RT`); const job = new SimpleIntervalJob({ milliseconds: RT_TICK, runImmediately: true - }, new Task('updatePos', () => this.position += RT_TICK), { id: 'rt' }); + }, new Task('updatePos', () => { + // in production RT_TICK and the diff between now and clockTS should always be the same + // but in order to mock for testing (where we manipulate Date now()) the source of truth + // needs to come from TS rather than simple TICK increase + this.setPosition() + //this.position += Math.abs(dayjs().diff(this.clockTS, 'ms')); // RT_TICK + + }), { id: 'rt' }); this.scheduler.addSimpleIntervalJob(job); this.scheduler.stop(); this.position = 0; @@ -25,6 +34,7 @@ export abstract class RealtimePlayer { if (position !== undefined) { this.position = position; } + this.clockTS = dayjs(); this.scheduler.startById('rt'); } @@ -45,8 +55,12 @@ export abstract class RealtimePlayer { return !asSeconds ? this.position : this.position / 1000; } - public setPosition(time: number) { + public setPosition(time?: number) { + if(time === undefined) { + this.position += Math.abs(dayjs().diff(this.clockTS, 'ms')); + } this.position = time; + this.clockTS = dayjs(); } } -- 2.51.2 From 2b890d47048a4cde4800e598cd1f30b3c2151a74 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 31 Jan 2025 20:27:34 +0000 Subject: [PATCH 12/28] refactor: Pass reportedTS for testing purposes --- src/backend/sources/MemorySource.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/backend/sources/MemorySource.ts b/src/backend/sources/MemorySource.ts index 30573328..eaa7f674 100644 --- a/src/backend/sources/MemorySource.ts +++ b/src/backend/sources/MemorySource.ts @@ -1,5 +1,5 @@ import { Logger } from "@foxxmd/logging"; -import dayjs from "dayjs"; +import dayjs, { Dayjs } from "dayjs"; import { EventEmitter } from "events"; import objectHash from 'object-hash'; import { SimpleIntervalJob, Task, ToadScheduler } from "toad-scheduler"; @@ -184,7 +184,7 @@ export default class MemorySource extends AbstractSource { return sessions[0]; } - processRecentPlays = (datas: (PlayObject | PlayerStateDataMaybePlay)[]) => { + processRecentPlays = (datas: (PlayObject | PlayerStateDataMaybePlay)[], reportedTS?: Dayjs) => { const { options: { @@ -239,7 +239,7 @@ export default class MemorySource extends AbstractSource { playerState.position = playerState.play.meta?.trackProgressPosition; } - const [currPlay, prevPlay] = player.update(playerState); + const [currPlay, prevPlay] = player.update(playerState, reportedTS); const candidate = prevPlay !== undefined ? prevPlay : currPlay; const playChanged = prevPlay !== undefined; -- 2.51.2 From d3cd07c5c3d2c381a33821a43039b09f9f2e278f Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 13 Mar 2025 16:20:37 +0000 Subject: [PATCH 13/28] feat(listenbrainz): Implement MVP LZ endpoint source --- config/endpointlz.json.example | 9 + src/backend/common/infrastructure/Atomic.ts | 2 + .../config/source/endpointlz.ts | 32 +++ .../infrastructure/config/source/sources.ts | 3 + .../common/vendor/ListenbrainzApiClient.ts | 39 +++- src/backend/server/api.ts | 2 + .../server/endpointListenbrainzRoutes.ts | 54 +++++ .../sources/EndpointListenbrainzSource.ts | 191 ++++++++++++++++++ src/backend/sources/ScrobbleSources.ts | 25 +++ .../ingressNotifiers/LZEndpointNotifier.ts | 46 +++++ 10 files changed, 402 insertions(+), 1 deletion(-) create mode 100644 config/endpointlz.json.example create mode 100644 src/backend/common/infrastructure/config/source/endpointlz.ts create mode 100644 src/backend/server/endpointListenbrainzRoutes.ts create mode 100644 src/backend/sources/EndpointListenbrainzSource.ts create mode 100644 src/backend/sources/ingressNotifiers/LZEndpointNotifier.ts diff --git a/config/endpointlz.json.example b/config/endpointlz.json.example new file mode 100644 index 00000000..0c8139b1 --- /dev/null +++ b/config/endpointlz.json.example @@ -0,0 +1,9 @@ +[ + { + "name": "myLz", + "enable": true, + "data": { + "token": "myToken" + } + } +] diff --git a/src/backend/common/infrastructure/Atomic.ts b/src/backend/common/infrastructure/Atomic.ts index e87d6882..dab935f1 100644 --- a/src/backend/common/infrastructure/Atomic.ts +++ b/src/backend/common/infrastructure/Atomic.ts @@ -15,6 +15,7 @@ export type SourceType = | 'jellyfin' | 'lastfm' | 'deezer' + | 'endpointlz' | 'ytmusic' | 'mpris' | 'mopidy' @@ -35,6 +36,7 @@ export const sourceTypes: SourceType[] = [ 'jellyfin', 'lastfm', 'deezer', + 'endpointlz', 'ytmusic', 'mpris', 'mopidy', diff --git a/src/backend/common/infrastructure/config/source/endpointlz.ts b/src/backend/common/infrastructure/config/source/endpointlz.ts new file mode 100644 index 00000000..db8d5459 --- /dev/null +++ b/src/backend/common/infrastructure/config/source/endpointlz.ts @@ -0,0 +1,32 @@ +import { CommonSourceConfig, CommonSourceData } from "./index.js"; + +export interface ListenbrainzEndpointData extends CommonSourceData { + /** + * The URL ending that should be used to identify scrobbles for this source + * + * If you are using multiple Listenbrainz endpoint sources (scrobbles for many users) you can use a slug to match Sources with individual users/origins + * + * Example: + * + * * slug: 'usera' => API URL: http://localhost:9078/api/listenbrainz/usera + * * slug: 'originb' => API URL: http://localhost:9078/api/listenbrainz/originb + * + * If no slug is found from an extension's incoming webhook event the first Listenbrainz source without a slug will be used + * */ + slug?: string | null + + /** + * If an LZ submission request contains this token in the Authorization Header it will be used to match the submission with this Source + * + * See: https://listenbrainz.readthedocs.io/en/latest/users/api/index.html#add-the-user-token-to-your-requests + * */ + token?: string | null +} + +export interface ListenbrainzEndpointSourceConfig extends CommonSourceConfig { + data?: ListenbrainzEndpointData +} + +export interface ListenbrainzEndpointSourceAIOConfig extends ListenbrainzEndpointSourceConfig { + type: 'endpointlz' +} diff --git a/src/backend/common/infrastructure/config/source/sources.ts b/src/backend/common/infrastructure/config/source/sources.ts index 77cfbbd9..8adbfd44 100644 --- a/src/backend/common/infrastructure/config/source/sources.ts +++ b/src/backend/common/infrastructure/config/source/sources.ts @@ -1,4 +1,5 @@ import { ChromecastSourceAIOConfig, ChromecastSourceConfig } from "./chromecast.js"; +import { ListenbrainzEndpointSourceAIOConfig, ListenbrainzEndpointSourceConfig } from "./endpointlz.js"; import { DeezerSourceAIOConfig, DeezerSourceConfig } from "./deezer.js"; import { JellyApiSourceAIOConfig, JellyApiSourceConfig, JellySourceAIOConfig, JellySourceConfig } from "./jellyfin.js"; import { JRiverSourceAIOConfig, JRiverSourceConfig } from "./jriver.js"; @@ -24,6 +25,7 @@ export type SourceConfig = | PlexApiSourceConfig | TautulliSourceConfig | DeezerSourceConfig + | ListenbrainzEndpointSourceConfig | SubSonicSourceConfig | JellySourceConfig | JellyApiSourceConfig @@ -46,6 +48,7 @@ export type SourceAIOConfig = | PlexApiSourceAIOConfig | TautulliSourceAIOConfig | DeezerSourceAIOConfig + | ListenbrainzEndpointSourceAIOConfig | SubsonicSourceAIOConfig | JellySourceAIOConfig | JellyApiSourceAIOConfig diff --git a/src/backend/common/vendor/ListenbrainzApiClient.ts b/src/backend/common/vendor/ListenbrainzApiClient.ts index 99d7eec3..5540f238 100644 --- a/src/backend/common/vendor/ListenbrainzApiClient.ts +++ b/src/backend/common/vendor/ListenbrainzApiClient.ts @@ -81,7 +81,7 @@ export interface ListenPayload { } export interface SubmitPayload { - listen_type: 'single', + listen_type: 'single' | 'playing_now', payload: [ListenPayload] } @@ -300,6 +300,43 @@ export class ListenbrainzApiClient extends AbstractApiClient { } } + static listenPayloadToPlay(payload: ListenPayload, nowPlaying: boolean = false): PlayObject { + const { + listened_at = dayjs().unix(), + track_metadata: { + artist_name, + track_name, + additional_info: { + duration, + track_mbid, + artist_mbids, + release_mbid, + release_group_mbid + } = {} + } = {}, + } = payload; + + return { + data: { + playDate: typeof listened_at === 'number' ? dayjs.unix(listened_at) : dayjs(listened_at), + track: track_name, + artists: [artist_name], + duration, + meta: { + brainz: { + artist: artist_mbids !== undefined ? artist_mbids : undefined, + album: release_mbid, + albumArtist: release_group_mbid, + track: track_mbid + } + } + }, + meta: { + nowPlaying, + } + } + } + static listenResponseToPlay(listen: ListenResponse): PlayObject { const { listened_at, diff --git a/src/backend/server/api.ts b/src/backend/server/api.ts index 8596dafb..3bae9b49 100644 --- a/src/backend/server/api.ts +++ b/src/backend/server/api.ts @@ -24,6 +24,7 @@ import { parseBool, sortByNewestPlayDate } from "../utils.js"; import { setupAuthRoutes } from "./auth.js"; import { setupDeezerRoutes } from "./deezerRoutes.js"; import { setupJellyfinRoutes } from "./jellyfinRoutes.js"; +import {setupLZEndpointRoutes} from "./endpointListenbrainzRoutes.js"; import { makeClientCheckMiddle, makeSourceCheckMiddle } from "./middleware.js"; import { setupPlexRoutes } from "./plexRoutes.js"; import { setupTautulliRoutes } from "./tautulliRoutes.js"; @@ -152,6 +153,7 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream: setupJellyfinRoutes(app, logger, scrobbleSources); setupDeezerRoutes(app, logger, scrobbleSources); setupWebscrobblerRoutes(app, logger, scrobbleSources); + setupLZEndpointRoutes(app, logger, scrobbleSources); setupAuthRoutes(app, logger, sourceRequiredMiddle, clientRequiredMiddle, scrobbleSources, scrobbleClients); app.putAsync('/api/webscrobbler', bodyParser.json({type: ['text/*', 'application/json']}), async (req, res) => { diff --git a/src/backend/server/endpointListenbrainzRoutes.ts b/src/backend/server/endpointListenbrainzRoutes.ts new file mode 100644 index 00000000..f82513a6 --- /dev/null +++ b/src/backend/server/endpointListenbrainzRoutes.ts @@ -0,0 +1,54 @@ +/* eslint-disable prefer-arrow-functions/prefer-arrow-functions */ +import { ExpressWithAsync } from "@awaitjs/express"; +import { childLogger, Logger } from "@foxxmd/logging"; +import bodyParser from "body-parser"; +import { EndpointListenbrainzSource, playStateFromRequest } from "../sources/EndpointListenbrainzSource.js"; +import { LZEndpointNotifier } from "../sources/ingressNotifiers/LZEndpointNotifier.js"; +import ScrobbleSources from "../sources/ScrobbleSources.js"; +import { nonEmptyBody } from "./middleware.js"; + +export const setupLZEndpointRoutes = (app: ExpressWithAsync, parentLogger: Logger, scrobbleSources: ScrobbleSources) => { + + const logger = childLogger(parentLogger, ['Ingress', 'Listenbrainz']); + + const lzJsonParser = bodyParser.json({ + type: ['text/*', 'application/json'], + }); + const nonEmptyCheck = nonEmptyBody(logger, 'LZ Endpoint'); + + const webhookIngress = new LZEndpointNotifier(logger); + app.useAsync(/\/api\/listenbrainz.*/, + async function (req, res, next) { + // track request before parsing body to ensure we at least log that something is happening + // (in the event body parsing does not work or request is not POST/PATCH) + webhookIngress.trackIngress(req, true); + if (req.method !== 'POST') { + return res.sendStatus(405); + } + next(); + }, + lzJsonParser, nonEmptyCheck, async function (req, res) { + webhookIngress.trackIngress(req, false); + + res.sendStatus(200); + + + const sources = scrobbleSources.getByType('endpointlz') as EndpointListenbrainzSource[]; + if (sources.length === 0) { + logger.warn('Received Listenbrainz endpoint payload but no Listenbrainz endpoint sources are configured'); + } + + const validSources = sources.filter(x => x.matchRequest(req)); + if (validSources.length === 0) { + const [slug, token] = EndpointListenbrainzSource.parseDisplayIdentifiersFromRequest(req); + logger.warn(`No Listenbrainz endpoint config matched => Slug: ${slug} | Token: ${token}`); + } + + const playerState = playStateFromRequest(req.body); + + for (const source of validSources) { + await source.handle(playerState); + } + }); +} + diff --git a/src/backend/sources/EndpointListenbrainzSource.ts b/src/backend/sources/EndpointListenbrainzSource.ts new file mode 100644 index 00000000..9acf42d1 --- /dev/null +++ b/src/backend/sources/EndpointListenbrainzSource.ts @@ -0,0 +1,191 @@ +import dayjs from "dayjs"; +import EventEmitter from "events"; +import { PlayObject, SOURCE_SOT } from "../../core/Atomic.js"; +import { + ExpressRequest, + FormatPlayObjectOptions, + InternalConfig, + NO_USER, + PlayerStateData, + REPORTED_PLAYER_STATUSES, + ReportedPlayerStatus +} from "../common/infrastructure/Atomic.js"; +import { ListenbrainzEndpointSourceConfig } from "../common/infrastructure/config/source/endpointlz.js"; +import { ListenbrainzApiClient, ListenPayload, SubmitPayload } from "../common/vendor/ListenbrainzApiClient.js"; +import { parseRegexSingleOrFail } from "../utils.js"; +import MemorySource from "./MemorySource.js"; + +const noSlugMatch = new RegExp(/\/api\/listenbrainz(?:\/?|\/1\/?|\/1\/submit-listens\/?)$/i); +const slugMatch = new RegExp(/\/api\/listenbrainz\/([^\/]+)(?:\/?|\/1\/?|\/1\/submit-listens\/?)$/i); + +export const authHeaderRegex = new RegExp(/Token (.+)$/i); + +export class EndpointListenbrainzSource extends MemorySource { + + declare config: ListenbrainzEndpointSourceConfig; + + constructor(name: any, config: ListenbrainzEndpointSourceConfig, internal: InternalConfig, emitter: EventEmitter) { + super('endpointlz', name, config, internal, emitter); + this.multiPlatform = true; + this.playerSourceOfTruth = SOURCE_SOT.HISTORY; + + const { + data = {}, + data: { + slug, + } = {} + } = this.config; + this.config.data = { + token: undefined, + ...data, + slug: slug === null ? undefined : slug, + }; + } + + static parseSlugFromString(path: string): string | false | undefined { + const noSlug = parseRegexSingleOrFail(noSlugMatch, path); + if (noSlug !== undefined) { + return undefined; + } + const slugResult = parseRegexSingleOrFail(slugMatch, path); + if (slugResult !== undefined) { + return slugResult.groups[0]; + } + return false; + } + + static parseSlugFromRequest(req: ExpressRequest): string | false | undefined { + return EndpointListenbrainzSource.parseSlugFromString(req.baseUrl); + } + + static parseTokenFromString(str: string): string | undefined { + const tokenMatch = parseRegexSingleOrFail(authHeaderRegex, str); + if(tokenMatch !== undefined) { + return tokenMatch.groups[0]; + } + return undefined; + } + + static parseTokenFromRequest(req: ExpressRequest): string | false | undefined { + const auth = req.header('Authorization'); + if(typeof auth === 'string' && auth !== '') { + const matchedToken = EndpointListenbrainzSource.parseTokenFromString(auth); + if(matchedToken === undefined) { + return false; + } + return matchedToken; + } + return undefined; + } + + static parseIdentifiersFromRequest(req: ExpressRequest): [string | false | undefined, false | string | undefined] { + const slug = EndpointListenbrainzSource.parseSlugFromRequest(req); + const token = EndpointListenbrainzSource.parseTokenFromRequest(req); + + return [slug, token]; + } + + static parseDisplayIdentifiersFromRequest(req: ExpressRequest): [string, string] { + const [slug, token] = EndpointListenbrainzSource.parseIdentifiersFromRequest(req); + let slugStr = '(no slug)'; + if (slug === false) { + slugStr = '(invalid slug)'; + } else if (slug !== undefined) { + slugStr = slug; + } + + let tokenStr = '(no token)'; + if (token === false) { + tokenStr = '(invalid token)'; + } else if (token !== undefined) { + tokenStr = `${token.substring(0,3)}****` + } + return [slugStr, tokenStr]; + } + + matchRequest(req: ExpressRequest): boolean { + let matchesToken = this.config.data.token === undefined; + const reqToken = EndpointListenbrainzSource.parseTokenFromRequest(req); + if (reqToken === false) { + return false; + } + matchesToken = this.config.data.token === undefined && reqToken === undefined || + (reqToken !== undefined && this.config.data.token !== undefined + && this.config.data.token.toLowerCase().trim() === reqToken.toLowerCase().trim()); + + if (!matchesToken) { + return false; + } + + let matchesPath = false; + const slug = EndpointListenbrainzSource.parseSlugFromRequest(req); + if (slug === false) { + return false; + } else { + matchesPath = (this.config.data.slug === undefined && slug === undefined) || (slug !== undefined && this.config.data.slug !== undefined && this.config.data.slug.toLowerCase().trim() === slug.toLocaleLowerCase().trim()); + } + + return matchesToken && matchesPath; + } + + static listenTypeAsPlayerStatus(event: string): ReportedPlayerStatus { + switch (event) { + case 'single': + case 'playing_now': + return REPORTED_PLAYER_STATUSES.playing; + default: + return REPORTED_PLAYER_STATUSES.unknown; + } + } + + static formatPlayObj(obj: ListenPayload, options: FormatPlayObjectOptions & { + nowPlaying?: boolean + } = {}): PlayObject { + return ListenbrainzApiClient.listenPayloadToPlay(obj, options.nowPlaying); + } + + getRecentlyPlayed = async (options = {}) => { + return this.getFlatRecentlyDiscoveredPlays(); + } + + isValidScrobble = (playObj: PlayObject) => { + return true; + } + + handle = async (stateData: PlayerStateData) => { + + this.processRecentPlays([stateData]); + + if (stateData.play.meta.nowPlaying === false && this.isValidScrobble(stateData.play)) { + const discovered = this.discover([stateData.play]); + if (discovered.length > 0) { + this.scrobble(discovered); + } + } + } +} + +export const playStateFromRequest = (obj: SubmitPayload): PlayerStateData => { + const { + listen_type, + payload, + } = obj; + + const play = ListenbrainzApiClient.listenPayloadToPlay(payload[0], listen_type === 'playing_now'); + return { + platformId: [play.meta.deviceId, NO_USER], + play, + status: listenTypeAsPlayerStatus(listen_type), + timestamp: dayjs() + } +} + +export const listenTypeAsPlayerStatus = (event: string): ReportedPlayerStatus => { + switch (event) { + case 'single': + case 'playing_now': + return REPORTED_PLAYER_STATUSES.playing; + default: + return REPORTED_PLAYER_STATUSES.unknown; + } +} \ No newline at end of file diff --git a/src/backend/sources/ScrobbleSources.ts b/src/backend/sources/ScrobbleSources.ts index 3bc2a44c..58affd01 100644 --- a/src/backend/sources/ScrobbleSources.ts +++ b/src/backend/sources/ScrobbleSources.ts @@ -5,6 +5,7 @@ import { ConfigMeta, InternalConfig, isSourceType, SourceType, sourceTypes } fro import { AIOConfig, SourceDefaults } from "../common/infrastructure/config/aioConfig.js"; import { ChromecastSourceConfig } from "../common/infrastructure/config/source/chromecast.js"; import { DeezerData, DeezerSourceConfig } from "../common/infrastructure/config/source/deezer.js"; +import { ListenbrainzEndpointSourceConfig, ListenbrainzEndpointData } from "../common/infrastructure/config/source/endpointlz.js"; import { JellyApiData, JellyApiSourceConfig, @@ -33,6 +34,7 @@ import { validateJson } from "../utils/ValidationUtils.js"; import AbstractSource from "./AbstractSource.js"; import { ChromecastSource } from "./ChromecastSource.js"; import DeezerSource from "./DeezerSource.js"; +import { EndpointListenbrainzSource } from "./EndpointListenbrainzSource.js"; import JellyfinApiSource from "./JellyfinApiSource.js"; import JellyfinSource from "./JellyfinSource.js"; import { JRiverSource } from "./JRiverSource.js"; @@ -130,6 +132,9 @@ export default class ScrobbleSources { case 'deezer': this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("DeezerSourceConfig"); break; + case 'endpointlz': + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("ListenbrainzEndpointSourceConfig"); + break; case 'subsonic': this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("SubSonicSourceConfig"); break; @@ -377,6 +382,23 @@ export default class ScrobbleSources { // sane default for lastfm is that user want to scrobble TO it, not FROM it -- this is also existing behavior defaultConfigureAs = 'client'; break; + case 'endpointlz': + const lzShouldUse = parseBool(process.env.LZENDPOINT_ENABLE); + const lze = { + slug: process.env.LZE_SLUG, + token: process.env.LZE_TOKEN + } + if (!Object.values(lze).every(x => x === undefined) || lzShouldUse) { + configs.push({ + type: 'endpointlz', + name: 'unnamed', + source: 'ENV', + mode: 'single', + configureAs: defaultConfigureAs, + data: lze as ListenbrainzEndpointData + }); + } + break; case 'jriver': const jr = { url: process.env.JRIVER_URL, @@ -663,6 +685,9 @@ export default class ScrobbleSources { case 'listenbrainz': newSource = await new ListenbrainzSource(name, compositeConfig as ListenBrainzSourceConfig, this.internalConfig, this.emitter); break; + case 'endpointlz': + newSource = await new EndpointListenbrainzSource(name, compositeConfig as ListenbrainzEndpointSourceConfig, this.internalConfig, this.emitter); + break; case 'jriver': newSource = await new JRiverSource(name, compositeConfig as JRiverSourceConfig, this.internalConfig, this.emitter); break; diff --git a/src/backend/sources/ingressNotifiers/LZEndpointNotifier.ts b/src/backend/sources/ingressNotifiers/LZEndpointNotifier.ts new file mode 100644 index 00000000..b99bfec3 --- /dev/null +++ b/src/backend/sources/ingressNotifiers/LZEndpointNotifier.ts @@ -0,0 +1,46 @@ +import { Logger } from "@foxxmd/logging"; +import { Request } from "express"; +import { EndpointListenbrainzSource } from "../EndpointListenbrainzSource.js"; +import { IngressNotifier } from "./IngressNotifier.js"; + +export class LZEndpointNotifier extends IngressNotifier { + + constructor(logger: Logger) { + super('Listenbrainz Endpoint', logger); + } + + seenSlugs: Record = {}; + notifyBySource(req: Request, isRaw: boolean): [boolean, (string | undefined)] { + + if(!isRaw) { + + const [slug, token] = EndpointListenbrainzSource.parseIdentifiersFromRequest(req); + if(slug === false) { + return [false, `Request URL was not a valid: ${req.baseUrl}`]; + } + const slugStr = slug ?? '(no slug)'; + + if(token === false) { + return [false, `Request URL was valid and 'Authorization' header was present but invalid. Authorization header should be 'Token tokenValue' but was '${req.header('Authorization')}'`]; + } + const tokenStr = token ?? '(no token)'; + const redactedToken = token !== undefined ? `${(token as string).substring(0,3)}****` : '(no token)'; + + const identifier = `${slugStr}-${tokenStr}`; + + if(this.seenSlugs[identifier] === undefined) { + this.seenSlugs[identifier] = true; + return [true, `Received a well formed request to endpoint with -- Slug: ${slugStr} | Token: ${redactedToken} -- for the first time.`]; + } + } + + return [true, undefined]; + } + + notifyByRequest(req: Request, isRaw: boolean): string | undefined { + if(req.method !== 'POST') { + return `Expected POST request (submit-listen payload) but received ${req.method}`; + } + return; + } +} -- 2.51.2 From e74ba52c478d44ee9e865257fa7ff2844ffcb9dd Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 13 Mar 2025 16:53:25 +0000 Subject: [PATCH 14/28] fix(listenbrainz): Improved slug and normal lz endpoint detection --- .../server/endpointListenbrainzRoutes.ts | 6 +- .../sources/EndpointListenbrainzSource.ts | 138 ++++++++---------- .../ingressNotifiers/LZEndpointNotifier.ts | 6 +- 3 files changed, 69 insertions(+), 81 deletions(-) diff --git a/src/backend/server/endpointListenbrainzRoutes.ts b/src/backend/server/endpointListenbrainzRoutes.ts index f82513a6..0f4b6052 100644 --- a/src/backend/server/endpointListenbrainzRoutes.ts +++ b/src/backend/server/endpointListenbrainzRoutes.ts @@ -2,7 +2,7 @@ import { ExpressWithAsync } from "@awaitjs/express"; import { childLogger, Logger } from "@foxxmd/logging"; import bodyParser from "body-parser"; -import { EndpointListenbrainzSource, playStateFromRequest } from "../sources/EndpointListenbrainzSource.js"; +import { EndpointListenbrainzSource, playStateFromRequest, parseDisplayIdentifiersFromRequest } from "../sources/EndpointListenbrainzSource.js"; import { LZEndpointNotifier } from "../sources/ingressNotifiers/LZEndpointNotifier.js"; import ScrobbleSources from "../sources/ScrobbleSources.js"; import { nonEmptyBody } from "./middleware.js"; @@ -17,7 +17,7 @@ export const setupLZEndpointRoutes = (app: ExpressWithAsync, parentLogger: Logge const nonEmptyCheck = nonEmptyBody(logger, 'LZ Endpoint'); const webhookIngress = new LZEndpointNotifier(logger); - app.useAsync(/\/api\/listenbrainz.*/, + app.useAsync(/(\/api\/listenbrainz.*)|(\/1\/submit-listens)/, async function (req, res, next) { // track request before parsing body to ensure we at least log that something is happening // (in the event body parsing does not work or request is not POST/PATCH) @@ -40,7 +40,7 @@ export const setupLZEndpointRoutes = (app: ExpressWithAsync, parentLogger: Logge const validSources = sources.filter(x => x.matchRequest(req)); if (validSources.length === 0) { - const [slug, token] = EndpointListenbrainzSource.parseDisplayIdentifiersFromRequest(req); + const [slug, token] = parseDisplayIdentifiersFromRequest(req); logger.warn(`No Listenbrainz endpoint config matched => Slug: ${slug} | Token: ${token}`); } diff --git a/src/backend/sources/EndpointListenbrainzSource.ts b/src/backend/sources/EndpointListenbrainzSource.ts index 9acf42d1..b2a20d78 100644 --- a/src/backend/sources/EndpointListenbrainzSource.ts +++ b/src/backend/sources/EndpointListenbrainzSource.ts @@ -15,8 +15,8 @@ import { ListenbrainzApiClient, ListenPayload, SubmitPayload } from "../common/v import { parseRegexSingleOrFail } from "../utils.js"; import MemorySource from "./MemorySource.js"; -const noSlugMatch = new RegExp(/\/api\/listenbrainz(?:\/?|\/1\/?|\/1\/submit-listens\/?)$/i); -const slugMatch = new RegExp(/\/api\/listenbrainz\/([^\/]+)(?:\/?|\/1\/?|\/1\/submit-listens\/?)$/i); +const noSlugMatch = new RegExp(/(?:\/api\/listenbrainz\/?)$|(?:\/1\/?|\/1\/submit-listens\/?)$/i); +const slugMatch = new RegExp(/\/api\/listenbrainz\/([^\/]+)$/i); export const authHeaderRegex = new RegExp(/Token (.+)$/i); @@ -42,70 +42,9 @@ export class EndpointListenbrainzSource extends MemorySource { }; } - static parseSlugFromString(path: string): string | false | undefined { - const noSlug = parseRegexSingleOrFail(noSlugMatch, path); - if (noSlug !== undefined) { - return undefined; - } - const slugResult = parseRegexSingleOrFail(slugMatch, path); - if (slugResult !== undefined) { - return slugResult.groups[0]; - } - return false; - } - - static parseSlugFromRequest(req: ExpressRequest): string | false | undefined { - return EndpointListenbrainzSource.parseSlugFromString(req.baseUrl); - } - - static parseTokenFromString(str: string): string | undefined { - const tokenMatch = parseRegexSingleOrFail(authHeaderRegex, str); - if(tokenMatch !== undefined) { - return tokenMatch.groups[0]; - } - return undefined; - } - - static parseTokenFromRequest(req: ExpressRequest): string | false | undefined { - const auth = req.header('Authorization'); - if(typeof auth === 'string' && auth !== '') { - const matchedToken = EndpointListenbrainzSource.parseTokenFromString(auth); - if(matchedToken === undefined) { - return false; - } - return matchedToken; - } - return undefined; - } - - static parseIdentifiersFromRequest(req: ExpressRequest): [string | false | undefined, false | string | undefined] { - const slug = EndpointListenbrainzSource.parseSlugFromRequest(req); - const token = EndpointListenbrainzSource.parseTokenFromRequest(req); - - return [slug, token]; - } - - static parseDisplayIdentifiersFromRequest(req: ExpressRequest): [string, string] { - const [slug, token] = EndpointListenbrainzSource.parseIdentifiersFromRequest(req); - let slugStr = '(no slug)'; - if (slug === false) { - slugStr = '(invalid slug)'; - } else if (slug !== undefined) { - slugStr = slug; - } - - let tokenStr = '(no token)'; - if (token === false) { - tokenStr = '(invalid token)'; - } else if (token !== undefined) { - tokenStr = `${token.substring(0,3)}****` - } - return [slugStr, tokenStr]; - } - matchRequest(req: ExpressRequest): boolean { let matchesToken = this.config.data.token === undefined; - const reqToken = EndpointListenbrainzSource.parseTokenFromRequest(req); + const reqToken = parseTokenFromRequest(req); if (reqToken === false) { return false; } @@ -118,7 +57,7 @@ export class EndpointListenbrainzSource extends MemorySource { } let matchesPath = false; - const slug = EndpointListenbrainzSource.parseSlugFromRequest(req); + const slug = parseSlugFromRequest(req); if (slug === false) { return false; } else { @@ -128,16 +67,6 @@ export class EndpointListenbrainzSource extends MemorySource { return matchesToken && matchesPath; } - static listenTypeAsPlayerStatus(event: string): ReportedPlayerStatus { - switch (event) { - case 'single': - case 'playing_now': - return REPORTED_PLAYER_STATUSES.playing; - default: - return REPORTED_PLAYER_STATUSES.unknown; - } - } - static formatPlayObj(obj: ListenPayload, options: FormatPlayObjectOptions & { nowPlaying?: boolean } = {}): PlayObject { @@ -188,4 +117,63 @@ export const listenTypeAsPlayerStatus = (event: string): ReportedPlayerStatus => default: return REPORTED_PLAYER_STATUSES.unknown; } +} + +export const parseTokenFromString = (str: string): string | undefined => { + const tokenMatch = parseRegexSingleOrFail(authHeaderRegex, str); + if(tokenMatch !== undefined) { + return tokenMatch.groups[0]; + } + return undefined; +} + +export const parseTokenFromRequest = (req: ExpressRequest): string | false | undefined => { + const auth = req.header('Authorization'); + if(typeof auth === 'string' && auth !== '') { + const matchedToken = parseTokenFromString(auth); + if(matchedToken === undefined) { + return false; + } + return matchedToken; + } + return undefined; +} + +export const parseSlugFromString = (path: string): string | false | undefined => { + const noSlug = parseRegexSingleOrFail(noSlugMatch, path); + if (noSlug !== undefined) { + return undefined; + } + const slugResult = parseRegexSingleOrFail(slugMatch, path); + if (slugResult !== undefined) { + return slugResult.groups[0]; + } + return false; +} + +export const parseSlugFromRequest = (req: ExpressRequest): string | false | undefined => parseSlugFromString(req.baseUrl); + +export const parseIdentifiersFromRequest = (req: ExpressRequest): [string | false | undefined, false | string | undefined] => { + const slug = parseSlugFromRequest(req); + const token = parseTokenFromRequest(req); + + return [slug, token]; +} + +export const parseDisplayIdentifiersFromRequest = (req: ExpressRequest): [string, string] => { + const [slug, token] = parseIdentifiersFromRequest(req); + let slugStr = '(no slug)'; + if (slug === false) { + slugStr = '(invalid slug)'; + } else if (slug !== undefined) { + slugStr = slug; + } + + let tokenStr = '(no token)'; + if (token === false) { + tokenStr = '(invalid token)'; + } else if (token !== undefined) { + tokenStr = `${token.substring(0,3)}****` + } + return [slugStr, tokenStr]; } \ No newline at end of file diff --git a/src/backend/sources/ingressNotifiers/LZEndpointNotifier.ts b/src/backend/sources/ingressNotifiers/LZEndpointNotifier.ts index b99bfec3..0bd8e95c 100644 --- a/src/backend/sources/ingressNotifiers/LZEndpointNotifier.ts +++ b/src/backend/sources/ingressNotifiers/LZEndpointNotifier.ts @@ -1,6 +1,6 @@ import { Logger } from "@foxxmd/logging"; import { Request } from "express"; -import { EndpointListenbrainzSource } from "../EndpointListenbrainzSource.js"; +import { EndpointListenbrainzSource, parseIdentifiersFromRequest } from "../EndpointListenbrainzSource.js"; import { IngressNotifier } from "./IngressNotifier.js"; export class LZEndpointNotifier extends IngressNotifier { @@ -14,9 +14,9 @@ export class LZEndpointNotifier extends IngressNotifier { if(!isRaw) { - const [slug, token] = EndpointListenbrainzSource.parseIdentifiersFromRequest(req); + const [slug, token] = parseIdentifiersFromRequest(req); if(slug === false) { - return [false, `Request URL was not a valid: ${req.baseUrl}`]; + return [false, `Request URL was not valid: ${req.baseUrl}`]; } const slugStr = slug ?? '(no slug)'; -- 2.51.2 From 3680ac8af1693d4afab7f26b73ad04c0b81541cc Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 13 Mar 2025 18:44:08 +0000 Subject: [PATCH 15/28] feat(lastm): Implement MVP Lastfm endpoint source --- config/endpointlfm.json.example | 9 ++ src/backend/common/infrastructure/Atomic.ts | 2 + .../config/source/endpointlfm.ts | 25 ++++ .../infrastructure/config/source/sources.ts | 3 + .../typings/lastfm-node-client.d.ts | 8 ++ src/backend/common/vendor/LastfmApiClient.ts | 62 ++++++++- src/backend/server/api.ts | 2 + src/backend/server/endpointLastfmRoutes.ts | 60 +++++++++ src/backend/sources/EndpointLastfmSource.ts | 122 ++++++++++++++++++ .../sources/EndpointListenbrainzSource.ts | 2 +- src/backend/sources/ScrobbleSources.ts | 24 ++++ .../ingressNotifiers/LFMEndpointNotifier.ts | 50 +++++++ 12 files changed, 367 insertions(+), 2 deletions(-) create mode 100644 config/endpointlfm.json.example create mode 100644 src/backend/common/infrastructure/config/source/endpointlfm.ts create mode 100644 src/backend/server/endpointLastfmRoutes.ts create mode 100644 src/backend/sources/EndpointLastfmSource.ts create mode 100644 src/backend/sources/ingressNotifiers/LFMEndpointNotifier.ts diff --git a/config/endpointlfm.json.example b/config/endpointlfm.json.example new file mode 100644 index 00000000..bdb20351 --- /dev/null +++ b/config/endpointlfm.json.example @@ -0,0 +1,9 @@ +[ + { + "name": "myLfm", + "enable": true, + "data": { + "slug": "mySlug" + } + } +] diff --git a/src/backend/common/infrastructure/Atomic.ts b/src/backend/common/infrastructure/Atomic.ts index dab935f1..a18f38c5 100644 --- a/src/backend/common/infrastructure/Atomic.ts +++ b/src/backend/common/infrastructure/Atomic.ts @@ -16,6 +16,7 @@ export type SourceType = | 'lastfm' | 'deezer' | 'endpointlz' + | 'endpointlfm' | 'ytmusic' | 'mpris' | 'mopidy' @@ -37,6 +38,7 @@ export const sourceTypes: SourceType[] = [ 'lastfm', 'deezer', 'endpointlz', + 'endpointlfm', 'ytmusic', 'mpris', 'mopidy', diff --git a/src/backend/common/infrastructure/config/source/endpointlfm.ts b/src/backend/common/infrastructure/config/source/endpointlfm.ts new file mode 100644 index 00000000..80abc281 --- /dev/null +++ b/src/backend/common/infrastructure/config/source/endpointlfm.ts @@ -0,0 +1,25 @@ +import { CommonSourceConfig, CommonSourceData } from "./index.js"; + +export interface LastFMEndpointData extends CommonSourceData { + /** + * The URL ending that should be used to identify scrobbles for this source + * + * If you are using multiple Last.fm endpoint sources (scrobbles for many users) you can use a slug to match Sources with individual users/origins + * + * Example: + * + * * slug: 'usera' => API URL: http://localhost:9078/api/lastfm/usera + * * slug: 'originb' => API URL: http://localhost:9078/api/lastfm/originb + * + * If no slug is found from an extension's incoming webhook event the first Last.fm source without a slug will be used + * */ + slug?: string | null +} + +export interface LastFMEndpointSourceConfig extends CommonSourceConfig { + data?: LastFMEndpointData +} + +export interface LastFMEndpointSourceAIOConfig extends LastFMEndpointSourceConfig { + type: 'endpointlfm' +} diff --git a/src/backend/common/infrastructure/config/source/sources.ts b/src/backend/common/infrastructure/config/source/sources.ts index 8adbfd44..d4212b07 100644 --- a/src/backend/common/infrastructure/config/source/sources.ts +++ b/src/backend/common/infrastructure/config/source/sources.ts @@ -1,5 +1,6 @@ import { ChromecastSourceAIOConfig, ChromecastSourceConfig } from "./chromecast.js"; import { ListenbrainzEndpointSourceAIOConfig, ListenbrainzEndpointSourceConfig } from "./endpointlz.js"; +import { LastFMEndpointSourceAIOConfig, LastFMEndpointSourceConfig } from "./endpointlfm.js"; import { DeezerSourceAIOConfig, DeezerSourceConfig } from "./deezer.js"; import { JellyApiSourceAIOConfig, JellyApiSourceConfig, JellySourceAIOConfig, JellySourceConfig } from "./jellyfin.js"; import { JRiverSourceAIOConfig, JRiverSourceConfig } from "./jriver.js"; @@ -26,6 +27,7 @@ export type SourceConfig = | TautulliSourceConfig | DeezerSourceConfig | ListenbrainzEndpointSourceConfig + | LastFMEndpointSourceConfig | SubSonicSourceConfig | JellySourceConfig | JellyApiSourceConfig @@ -49,6 +51,7 @@ export type SourceAIOConfig = | TautulliSourceAIOConfig | DeezerSourceAIOConfig | ListenbrainzEndpointSourceAIOConfig + | LastFMEndpointSourceAIOConfig | SubsonicSourceAIOConfig | JellySourceAIOConfig | JellyApiSourceAIOConfig diff --git a/src/backend/common/infrastructure/typings/lastfm-node-client.d.ts b/src/backend/common/infrastructure/typings/lastfm-node-client.d.ts index 8ee66f5c..8026e1eb 100644 --- a/src/backend/common/infrastructure/typings/lastfm-node-client.d.ts +++ b/src/backend/common/infrastructure/typings/lastfm-node-client.d.ts @@ -30,6 +30,10 @@ declare module 'lastfm-node-client' { mbid?: string } + export interface TrackScrobbleRequest extends TrackScrobblePayload { + method: 'track.scrobble' + } + export interface TrackScrobbleResponse { scrobbles: { '@attr': { @@ -52,6 +56,10 @@ declare module 'lastfm-node-client' { export type NowPlayingPayload = Omit + export type NowPlayingRequest = NowPlayingPayload & { method: 'track.updateNowPlaying' }; + + export type LastfmTrackUpdateRequest = TrackScrobbleRequest | NowPlayingRequest; + export interface NowPlayingResponse { nowplaying: { ignoredMessage: { diff --git a/src/backend/common/vendor/LastfmApiClient.ts b/src/backend/common/vendor/LastfmApiClient.ts index 1fca76f5..445db4c8 100644 --- a/src/backend/common/vendor/LastfmApiClient.ts +++ b/src/backend/common/vendor/LastfmApiClient.ts @@ -1,6 +1,8 @@ -import dayjs from "dayjs"; +import dayjs, { Dayjs } from "dayjs"; import LastFm, { AuthGetSessionResponse, + LastfmTrackUpdateRequest, + NowPlayingPayload, NowPlayingResponse, TrackObject, TrackScrobblePayload, @@ -16,6 +18,7 @@ import { UpstreamError } from "../errors/UpstreamError.js"; import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../infrastructure/Atomic.js"; import { LastfmData } from "../infrastructure/config/client/lastfm.js"; import AbstractApiClient from "./AbstractApiClient.js"; +import { parseArtistCredits } from "../../utils/StringUtils.js"; const badErrors = [ 'api key suspended', @@ -275,3 +278,60 @@ export default class LastfmApiClient extends AbstractApiClient { } } } + +export const scrobblePayloadToPlay = (obj: LastfmTrackUpdateRequest): PlayObject => { + const { + artist, + track, + duration, + album, + albumArtist, + } = obj; + + let ts: Dayjs | undefined; + if('timestamp' in obj) { + ts = dayjs.unix(obj.timestamp); + } + const mbid = 'mbid' in obj ? obj.mbid : undefined; + + + let artists: string[] = []; + + const credits = parseArtistCredits(artist); + if(credits !== undefined) { + artists.push(credits.primary); + if(credits.secondary !== undefined) { + const nonEmptyArtists = credits.secondary.filter(x => x !== undefined && x !== null && x.trim() !== ''); + if(nonEmptyArtists.length > 0) { + artists = [...artists, ...nonEmptyArtists]; + } + } + } else { + artists = [artist]; + } + + const play: PlayObject = { + data: { + track, + album: nonEmptyStringOrDefault(album), + albumArtists: nonEmptyStringOrDefault(albumArtist) !== undefined ? [albumArtist] : undefined, + duration: typeof duration === 'string' ? parseInt(duration, 10) : duration, + playDate: ts, + artists + }, + meta: { + source: 'lastfm', + nowPlaying: obj.method === 'track.updateNowPlaying' + } + }; + + if(nonEmptyStringOrDefault(mbid) !== undefined) { + play.data.meta = { + brainz: { + track: mbid + } + }; + } + + return play; +} \ No newline at end of file diff --git a/src/backend/server/api.ts b/src/backend/server/api.ts index 3bae9b49..625f7c7a 100644 --- a/src/backend/server/api.ts +++ b/src/backend/server/api.ts @@ -25,6 +25,7 @@ import { setupAuthRoutes } from "./auth.js"; import { setupDeezerRoutes } from "./deezerRoutes.js"; import { setupJellyfinRoutes } from "./jellyfinRoutes.js"; import {setupLZEndpointRoutes} from "./endpointListenbrainzRoutes.js"; +import {setupLastfmEndpointRoutes} from "./endpointLastfmRoutes.js"; import { makeClientCheckMiddle, makeSourceCheckMiddle } from "./middleware.js"; import { setupPlexRoutes } from "./plexRoutes.js"; import { setupTautulliRoutes } from "./tautulliRoutes.js"; @@ -154,6 +155,7 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream: setupDeezerRoutes(app, logger, scrobbleSources); setupWebscrobblerRoutes(app, logger, scrobbleSources); setupLZEndpointRoutes(app, logger, scrobbleSources); + setupLastfmEndpointRoutes(app, logger, scrobbleSources); setupAuthRoutes(app, logger, sourceRequiredMiddle, clientRequiredMiddle, scrobbleSources, scrobbleClients); app.putAsync('/api/webscrobbler', bodyParser.json({type: ['text/*', 'application/json']}), async (req, res) => { diff --git a/src/backend/server/endpointLastfmRoutes.ts b/src/backend/server/endpointLastfmRoutes.ts new file mode 100644 index 00000000..23a3bd79 --- /dev/null +++ b/src/backend/server/endpointLastfmRoutes.ts @@ -0,0 +1,60 @@ +/* eslint-disable prefer-arrow-functions/prefer-arrow-functions */ +import { ExpressWithAsync } from "@awaitjs/express"; +import { childLogger, Logger } from "@foxxmd/logging"; +import bodyParser from "body-parser"; +import ScrobbleSources from "../sources/ScrobbleSources.js"; +import { nonEmptyBody } from "./middleware.js"; +import { LFMEndpointNotifier } from "../sources/ingressNotifiers/LFMEndpointNotifier.js"; +import { EndpointLastfmSource, playStateFromRequest, parseDisplayIdentifiersFromRequest } from "../sources/EndpointLastfmSource.js"; +import { LastfmTrackUpdateRequest } from "lastfm-node-client"; + +export const setupLastfmEndpointRoutes = (app: ExpressWithAsync, parentLogger: Logger, scrobbleSources: ScrobbleSources) => { + + const logger = childLogger(parentLogger, ['Ingress', 'LFM']); + + const nonEmptyCheck = nonEmptyBody(logger, 'LFM Endpoint'); + + const webhookIngress = new LFMEndpointNotifier(logger); + app.useAsync(/(\/api\/lastfm.*)|(\/2.0\/?)$/, + async function (req, res, next) { + // track request before parsing body to ensure we at least log that something is happening + // (in the event body parsing does not work or request is not POST/PATCH) + webhookIngress.trackIngress(req, true); + if (req.method !== 'POST') { + return res.sendStatus(405); + } + next(); + }, + bodyParser.urlencoded({ extended: true }), + nonEmptyCheck, async function (req, res) { + webhookIngress.trackIngress(req, false); + + const sources = scrobbleSources.getByType('endpointlfm') as EndpointLastfmSource[]; + if (sources.length === 0) { + logger.warn('Received Lastfm endpoint payload but no Lastfm endpoint sources are configured'); + } + + const validSources = sources.filter(x => x.matchRequest(req)); + if (validSources.length === 0) { + const [slug] = parseDisplayIdentifiersFromRequest(req); + logger.warn(`No Lastfm endpoint config matched => Slug: ${slug}`); + } + + if(!('method' in req.body)) { + return res.status(400).json({error: `Missing 'method' param`}); + } + const method = (req.body as LastfmTrackUpdateRequest).method; + if(!['track.updateNowPlaying','track.scrobble'].includes(method)) { + return res.status(400).json({error: `Unexpected 'method' param value '${method}', expected either 'track.updateNowPlaying' or 'track.scrobble'`}); + } + + res.sendStatus(200); + + const playerState = playStateFromRequest(req.body); + + for (const source of validSources) { + await source.handle(playerState); + } + }); +} + diff --git a/src/backend/sources/EndpointLastfmSource.ts b/src/backend/sources/EndpointLastfmSource.ts new file mode 100644 index 00000000..72444e3d --- /dev/null +++ b/src/backend/sources/EndpointLastfmSource.ts @@ -0,0 +1,122 @@ +import dayjs from "dayjs"; +import EventEmitter from "events"; +import { PlayObject, SOURCE_SOT } from "../../core/Atomic.js"; +import { + ExpressRequest, + FormatPlayObjectOptions, + InternalConfig, + NO_USER, + PlayerStateData, + REPORTED_PLAYER_STATUSES, + ReportedPlayerStatus +} from "../common/infrastructure/Atomic.js"; +import { parseRegexSingleOrFail } from "../utils.js"; +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"; + +const noSlugMatch = new RegExp(/(?:\/api\/lastfm\/?)$|(?:\/1\/?|\/2.0\/?)$/i); +const slugMatch = new RegExp(/\/api\/lastfm\/([^\/]+)$/i); + +export const authHeaderRegex = new RegExp(/Token (.+)$/i); + +export class EndpointLastfmSource extends MemorySource { + + declare config: LastFMEndpointSourceConfig; + + constructor(name: any, config: LastFMEndpointSourceConfig, internal: InternalConfig, emitter: EventEmitter) { + super('endpointlfm', name, config, internal, emitter); + this.multiPlatform = false; + this.playerSourceOfTruth = SOURCE_SOT.HISTORY; + + const { + data = {}, + data: { + slug, + } = {} + } = this.config; + this.config.data = { + ...data, + slug: slug === null ? undefined : slug, + }; + } + + matchRequest(req: ExpressRequest): boolean { + let matchesPath = false; + const slug = parseSlugFromRequest(req); + if (slug === false) { + return false; + } else { + matchesPath = (this.config.data.slug === undefined && slug === undefined) || (slug !== undefined && this.config.data.slug !== undefined && this.config.data.slug.toLowerCase().trim() === slug.toLocaleLowerCase().trim()); + } + + return matchesPath; + } + + static formatPlayObj(obj: LastfmTrackUpdateRequest, options: FormatPlayObjectOptions = {}): PlayObject { + return scrobblePayloadToPlay(obj); + } + + getRecentlyPlayed = async (options = {}) => { + return this.getFlatRecentlyDiscoveredPlays(); + } + + isValidScrobble = (playObj: PlayObject) => { + return true; + } + + handle = async (stateData: PlayerStateData) => { + + this.processRecentPlays([stateData]); + + if (stateData.play.meta.nowPlaying === false && this.isValidScrobble(stateData.play)) { + const discovered = this.discover([stateData.play]); + if (discovered.length > 0) { + this.scrobble(discovered); + } + } + } +} + +export const playStateFromRequest = (obj: LastfmTrackUpdateRequest): PlayerStateData => { + + const play = scrobblePayloadToPlay(obj); + return { + platformId: [play.meta.deviceId, NO_USER], + play, + status: obj.method === 'track.updateNowPlaying' ? REPORTED_PLAYER_STATUSES.playing : REPORTED_PLAYER_STATUSES.unknown, + timestamp: dayjs() + } +} + +export const parseSlugFromString = (path: string): string | false | undefined => { + const noSlug = parseRegexSingleOrFail(noSlugMatch, path); + if (noSlug !== undefined) { + return undefined; + } + const slugResult = parseRegexSingleOrFail(slugMatch, path); + if (slugResult !== undefined) { + return slugResult.groups[0]; + } + return false; +} + +export const parseSlugFromRequest = (req: ExpressRequest): string | false | undefined => parseSlugFromString(req.baseUrl); + +export const parseIdentifiersFromRequest = (req: ExpressRequest): [string | false | undefined] => { + const slug = parseSlugFromRequest(req); + + return [slug]; +} + +export const parseDisplayIdentifiersFromRequest = (req: ExpressRequest): [string] => { + const [slug] = parseIdentifiersFromRequest(req); + let slugStr = '(no slug)'; + if (slug === false) { + slugStr = '(invalid slug)'; + } else if (slug !== undefined) { + slugStr = slug; + } + return [slugStr]; +} \ No newline at end of file diff --git a/src/backend/sources/EndpointListenbrainzSource.ts b/src/backend/sources/EndpointListenbrainzSource.ts index b2a20d78..9bf8c47a 100644 --- a/src/backend/sources/EndpointListenbrainzSource.ts +++ b/src/backend/sources/EndpointListenbrainzSource.ts @@ -26,7 +26,7 @@ export class EndpointListenbrainzSource extends MemorySource { constructor(name: any, config: ListenbrainzEndpointSourceConfig, internal: InternalConfig, emitter: EventEmitter) { super('endpointlz', name, config, internal, emitter); - this.multiPlatform = true; + this.multiPlatform = false; this.playerSourceOfTruth = SOURCE_SOT.HISTORY; const { diff --git a/src/backend/sources/ScrobbleSources.ts b/src/backend/sources/ScrobbleSources.ts index 58affd01..76e3751c 100644 --- a/src/backend/sources/ScrobbleSources.ts +++ b/src/backend/sources/ScrobbleSources.ts @@ -6,6 +6,7 @@ import { AIOConfig, SourceDefaults } from "../common/infrastructure/config/aioCo import { ChromecastSourceConfig } from "../common/infrastructure/config/source/chromecast.js"; import { DeezerData, DeezerSourceConfig } from "../common/infrastructure/config/source/deezer.js"; import { ListenbrainzEndpointSourceConfig, ListenbrainzEndpointData } from "../common/infrastructure/config/source/endpointlz.js"; +import { LastFMEndpointSourceConfig, LastFMEndpointData } from "../common/infrastructure/config/source/endpointlfm.js"; import { JellyApiData, JellyApiSourceConfig, @@ -35,6 +36,7 @@ import AbstractSource from "./AbstractSource.js"; import { ChromecastSource } from "./ChromecastSource.js"; import DeezerSource from "./DeezerSource.js"; import { EndpointListenbrainzSource } from "./EndpointListenbrainzSource.js"; +import { EndpointLastfmSource } from "./EndpointLastfmSource.js"; import JellyfinApiSource from "./JellyfinApiSource.js"; import JellyfinSource from "./JellyfinSource.js"; import { JRiverSource } from "./JRiverSource.js"; @@ -135,6 +137,9 @@ export default class ScrobbleSources { case 'endpointlz': this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("ListenbrainzEndpointSourceConfig"); break; + case 'endpointlfm': + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("LastFMEndpointSourceConfig"); + break; case 'subsonic': this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("SubSonicSourceConfig"); break; @@ -399,6 +404,22 @@ export default class ScrobbleSources { }); } break; + case 'endpointlfm': + const lfmShouldUse = parseBool(process.env.LFMENDPOINT_ENABLE); + const lfme = { + slug: process.env.LFM_SLUG, + } + if (!Object.values(lfme).every(x => x === undefined) || lfmShouldUse) { + configs.push({ + type: 'endpointlfm', + name: 'unnamed', + source: 'ENV', + mode: 'single', + configureAs: defaultConfigureAs, + data: lfme as LastFMEndpointData + }); + } + break; case 'jriver': const jr = { url: process.env.JRIVER_URL, @@ -688,6 +709,9 @@ export default class ScrobbleSources { case 'endpointlz': newSource = await new EndpointListenbrainzSource(name, compositeConfig as ListenbrainzEndpointSourceConfig, this.internalConfig, this.emitter); break; + case 'endpointlfm': + newSource = await new EndpointLastfmSource(name, compositeConfig as LastFMEndpointSourceConfig, this.internalConfig, this.emitter); + break; case 'jriver': newSource = await new JRiverSource(name, compositeConfig as JRiverSourceConfig, this.internalConfig, this.emitter); break; diff --git a/src/backend/sources/ingressNotifiers/LFMEndpointNotifier.ts b/src/backend/sources/ingressNotifiers/LFMEndpointNotifier.ts new file mode 100644 index 00000000..c1e3269b --- /dev/null +++ b/src/backend/sources/ingressNotifiers/LFMEndpointNotifier.ts @@ -0,0 +1,50 @@ +import { Logger } from "@foxxmd/logging"; +import { Request } from "express"; +import { parseIdentifiersFromRequest } from "../EndpointLastfmSource.js"; +import { IngressNotifier } from "./IngressNotifier.js"; +import { LastfmTrackUpdateRequest } from "lastfm-node-client"; + +export class LFMEndpointNotifier extends IngressNotifier { + + constructor(logger: Logger) { + super('Lastfm Endpoint', logger); + } + + seenSlugs: Record = {}; + notifyBySource(req: Request, isRaw: boolean): [boolean, (string | undefined)] { + + if(!isRaw) { + + const [slug] = parseIdentifiersFromRequest(req); + if(slug === false) { + return [false, `Request URL was not valid: ${req.baseUrl}`]; + } + const slugStr = slug ?? '(no slug)'; + + const identifier = `${slugStr}`; + + if(this.seenSlugs[identifier] === undefined) { + this.seenSlugs[identifier] = true; + return [true, `Received a request to endpoint with -- Slug: ${slugStr} -- for the first time.`]; + } + } + + return [true, undefined]; + } + + notifyByRequest(req: Request, isRaw: boolean): string | undefined { + if(req.method !== 'POST') { + return `Expected POST request (track.scrobble payload) but received ${req.method}`; + } + if(!isRaw) { + if(!('method' in req.body)) { + return `Body is missing 'method' param` + } + const method = (req.body as LastfmTrackUpdateRequest).method; + if(!['track.updateNowPlaying','track.scrobble'].includes(method)) { + return `Unexpected 'method' param value '${method}', expected either 'track.updateNowPlaying' or 'track.scrobble'` + } + } + return; + } +} -- 2.51.2 From f68a9493ce80a037d4a0006e96f957d40c55ce17 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 14 Mar 2025 08:16:56 -0400 Subject: [PATCH 16/28] fix(listenbrainz): add dummy validate-token endpoint --- src/backend/server/endpointListenbrainzRoutes.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/backend/server/endpointListenbrainzRoutes.ts b/src/backend/server/endpointListenbrainzRoutes.ts index 0f4b6052..43d7d020 100644 --- a/src/backend/server/endpointListenbrainzRoutes.ts +++ b/src/backend/server/endpointListenbrainzRoutes.ts @@ -50,5 +50,14 @@ export const setupLZEndpointRoutes = (app: ExpressWithAsync, parentLogger: Logge await source.handle(playerState); } }); + app.getAsync('/1/validate-token', async function (req, res) { + //https://listenbrainz.readthedocs.io/en/latest/users/api/core.html#get--1-validate-token + return res.status(200).json({ + code: 200, + message: "Token valid.", + valid: true, + user_name: "Multi-Scrobbler" + }) + }); } -- 2.51.2 From 479af1cd5ef1645a3325031dfdbc08a5a2d3a26f Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 14 Mar 2025 16:29:48 +0000 Subject: [PATCH 17/28] docs: Add Listenbrainz Endpoint source docs --- README.md | 1 + docsite/docs/configuration/configuration.mdx | 70 ++++++++++++++++++++ docsite/src/pages/index.mdx | 1 + 3 files changed, 72 insertions(+) diff --git a/README.md b/README.md index 3e115050..87003081 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c * [Youtube Music](https://foxxmd.github.io/multi-scrobbler/docs/configuration#youtube-music) * [Last.fm](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm-source) * [ListenBrainz](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz-source) + * [ListenBrainz (Endpoint)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz-endpoint) * [~~Deezer~~](https://foxxmd.github.io/multi-scrobbler/docs/configuration#deezer) * [MPRIS (Linux Desktop)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpris) * [Mopidy](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mopidy) diff --git a/docsite/docs/configuration/configuration.mdx b/docsite/docs/configuration/configuration.mdx index bfc3fdbb..51e4f407 100644 --- a/docsite/docs/configuration/configuration.mdx +++ b/docsite/docs/configuration/configuration.mdx @@ -17,6 +17,7 @@ import JriverfinConfig from '!!raw-loader!../../../config/jriver.json.example'; import KodiConfig from '!!raw-loader!../../../config/kodi.json.example'; import LastfmConfig from '!!raw-loader!../../../config/lastfm.json.example'; import ListenbrainzConfig from '!!raw-loader!../../../config/listenbrainz.json.example'; +import ListenbrainzEndpointConfig from '!!raw-loader!../../../config/endpointlz.json.example'; import MalojaConfig from '!!raw-loader!../../../config/maloja.json.example'; import MopidyConfig from '!!raw-loader!../../../config/mopidy.json.example'; import MprisConfig from '!!raw-loader!../../../config/mpris.json.example'; @@ -640,6 +641,8 @@ See the [Last.fm (Client)](#lastfm) setup for registration instructions. ### [Listenbrainz (Source)](https://listenbrainz.org) +This Source monitors a Listenbrainz account's scrobble history and then re-scrobbles discovered tracks to configured clients. + You will need to run your own Listenbrainz server or have an account [on the official instance](https://listenbrainz.org/login/) On your [profile page](https://listenbrainz.org/profile/) find your **User Token** to use in the configuration. @@ -679,6 +682,73 @@ On your [profile page](https://listenbrainz.org/profile/) find your **User Token +### [Listenbrainz (Endpoint)](https://listenbrainz.org) + +This Source enables multi-scrobbler to accept scrobbles from outside applications as if it was a Listenbrainz server. An example of this would be configuring [pano-scrobbler](https://github.com/kawaiiDango/pano-scrobbler) with a custom Listenbrainz URL to point to multi-scrobbler. + +##### URL + +If a **slug** is **not** provided in configuration then multi-scrobbler will accept Listenbrainz scrobbles at + +``` +http://localhost:9078/1/submit-listens +``` + +which is the "standard" Listenbrainz server path for scrobbling. + +Use a slug only if you need to setup multiple Listenbrainz Endpoint sources and cannot use different tokens. + +If a slug is used then the URL will be: + +``` +http://localhost:9078/api/listenbrainz/mySlug +``` + +:::note + Some Listenbrainz applications may require custom Listenbrainz URLs to be a real domain (`example.com`) and/or use SSL (`https://`). In this case you should setup multi-scrobbler behind a reverse proxy to support this functionality. It is out of the scope of this project to do this solely within multi-scrobbler. +::: + +##### Token + +Most Listenbrainz clients require a token (Authentication Token) to be provided during setup. This value can be anything you want, just make sure to use the same value for `token` in your multi-scrobbler configuration for the endpoint. + + +#### Configuration + + + + | Environmental Variable | Required? | Default | Description | + | :--------------------- | :-------- | ------- | ----------------------------------------------------------------------------------------------------------------- | + | `LZENDPOINT_ENABLE` | No | | Use LZ Endpoint as a Source without any other configuration. Only required if slug/token are not provided as ENVs | + | `LZE_TOKEN` | No | | LZ "Authentication Token" you provided to the scrobbling application | + | `LZE_SLUG` | No | | (Optional) The URL suffix to use for accepting LZ scrobbles | + + +
+ Change `configureAs` to `source` + + Example + + {ListenbrainzEndpointConfig} + +
+ + or +
+ +
+ Change `configureAs` to `source` + + Example + + + +
+ + or +
+
+ ### [~~Deezer~~](https://deezer.com/) :::warning diff --git a/docsite/src/pages/index.mdx b/docsite/src/pages/index.mdx index 08d11c2b..2e6e9602 100644 --- a/docsite/src/pages/index.mdx +++ b/docsite/src/pages/index.mdx @@ -21,6 +21,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c * [Youtube Music](docs/configuration#youtube-music) * [Last.fm](docs/configuration#lastfm-source) * [ListenBrainz](docs/configuration#listenbrainz-source) + * [ListenBrainz (Endpoint)](docs/configuration#listenbrainz-endpoint) * [~~Deezer~~](docs/configuration#deezer) * [MPRIS (Linux Desktop)](docs/configuration#mpris) * [Mopidy](docs/configuration#mopidy) -- 2.51.2 From 210fa35cb60e28eac3737d7f4f9da789d0647746 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 14 Mar 2025 16:34:43 +0000 Subject: [PATCH 18/28] docs: Add Last.fm Endpoint source docs --- README.md | 1 + docsite/docs/configuration/configuration.mdx | 58 ++++++++++++++++++++ docsite/src/pages/index.mdx | 1 + 3 files changed, 60 insertions(+) diff --git a/README.md b/README.md index 87003081..34288f9e 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c * [WebScrobbler](https://foxxmd.github.io/multi-scrobbler/docs/configuration#webscrobbler) * [Youtube Music](https://foxxmd.github.io/multi-scrobbler/docs/configuration#youtube-music) * [Last.fm](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm-source) + * [Last.fm (Endpoint)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm-endpoint) * [ListenBrainz](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz-source) * [ListenBrainz (Endpoint)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz-endpoint) * [~~Deezer~~](https://foxxmd.github.io/multi-scrobbler/docs/configuration#deezer) diff --git a/docsite/docs/configuration/configuration.mdx b/docsite/docs/configuration/configuration.mdx index 51e4f407..5e2d3f23 100644 --- a/docsite/docs/configuration/configuration.mdx +++ b/docsite/docs/configuration/configuration.mdx @@ -16,6 +16,7 @@ import JellyfinConfig from '!!raw-loader!../../../config/jellyfin.json.example'; import JriverfinConfig from '!!raw-loader!../../../config/jriver.json.example'; import KodiConfig from '!!raw-loader!../../../config/kodi.json.example'; import LastfmConfig from '!!raw-loader!../../../config/lastfm.json.example'; +import LastfmEndpointConfig from '!!raw-loader!../../../config/endpointlfm.json.example'; import ListenbrainzConfig from '!!raw-loader!../../../config/listenbrainz.json.example'; import ListenbrainzEndpointConfig from '!!raw-loader!../../../config/endpointlz.json.example'; import MalojaConfig from '!!raw-loader!../../../config/maloja.json.example'; @@ -639,6 +640,63 @@ See the [Last.fm (Client)](#lastfm) setup for registration instructions. +### [Last.fm (Endpoint)](https://www.last.fm) + +This Source enables multi-scrobbler to accept scrobbles from outside applications as if it was a Last.fm server. + +##### URL + +If a **slug** is **not** provided in configuration then multi-scrobbler will accept Last.fm scrobbles at + +``` +http://localhost:9078/2.0/ +``` + +which is the "standard" Last.fm server path for scrobbling. + +Use a slug only if you need to setup multiple Last.fm Endpoint sources and cannot use different tokens. + +If a slug is used then the URL will be: + +``` +http://localhost:9078/api/lastfm/mySlug +``` + +#### Configuration + + + + | Environmental Variable | Required? | Default | Description | + | :--------------------- | :-------- | ------- | ------------------------------------------------------------------------------------------------------------------ | + | `LFMENDPOINT_ENABLE` | No | | Use LFM Endpoint as a Source without any other configuration. Only required if slug/token are not provided as ENVs | + | `LFM_SLUG` | No | | (Optional) The URL suffix to use for accepting LFM scrobbles | + + +
+ Change `configureAs` to `source` + + Example + + {LastfmEndpointConfig} + +
+ + or +
+ +
+ Change `configureAs` to `source` + + Example + + + +
+ + or +
+
+ ### [Listenbrainz (Source)](https://listenbrainz.org) This Source monitors a Listenbrainz account's scrobble history and then re-scrobbles discovered tracks to configured clients. diff --git a/docsite/src/pages/index.mdx b/docsite/src/pages/index.mdx index 2e6e9602..e557c7d7 100644 --- a/docsite/src/pages/index.mdx +++ b/docsite/src/pages/index.mdx @@ -20,6 +20,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c * [WebScrobbler](docs/configuration#webscrobbler) * [Youtube Music](docs/configuration#youtube-music) * [Last.fm](docs/configuration#lastfm-source) + * [Last.fm (Endpoint)](docs/configuration#lastfm-endpoint) * [ListenBrainz](docs/configuration#listenbrainz-source) * [ListenBrainz (Endpoint)](docs/configuration#listenbrainz-endpoint) * [~~Deezer~~](docs/configuration#deezer) -- 2.51.2 From 190431b9d4311f02c781096310ff18728bf93f00 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Wed, 12 Mar 2025 17:07:44 +0000 Subject: [PATCH 19/28] feat(musiccast): Implement MusicCast Source --- config/musiccast.json.example | 9 ++ src/backend/common/infrastructure/Atomic.ts | 2 + .../infrastructure/config/source/musiccast.ts | 87 ++++++++++ .../infrastructure/config/source/sources.ts | 3 + src/backend/sources/MusicCastSource.ts | 148 ++++++++++++++++++ src/backend/sources/ScrobbleSources.ts | 23 +++ src/backend/utils/NetworkUtils.ts | 10 +- 7 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 config/musiccast.json.example create mode 100644 src/backend/common/infrastructure/config/source/musiccast.ts create mode 100644 src/backend/sources/MusicCastSource.ts diff --git a/config/musiccast.json.example b/config/musiccast.json.example new file mode 100644 index 00000000..a741c347 --- /dev/null +++ b/config/musiccast.json.example @@ -0,0 +1,9 @@ +[ + { + "name": "myYamaha", + "enable": true, + "data": { + "url": "192.168.0.101" + } + } +] diff --git a/src/backend/common/infrastructure/Atomic.ts b/src/backend/common/infrastructure/Atomic.ts index a18f38c5..910e1e47 100644 --- a/src/backend/common/infrastructure/Atomic.ts +++ b/src/backend/common/infrastructure/Atomic.ts @@ -20,6 +20,7 @@ export type SourceType = | 'ytmusic' | 'mpris' | 'mopidy' + | 'musiccast' | 'listenbrainz' | 'jriver' | 'kodi' @@ -42,6 +43,7 @@ export const sourceTypes: SourceType[] = [ 'ytmusic', 'mpris', 'mopidy', + 'musiccast', 'listenbrainz', 'jriver', 'kodi', diff --git a/src/backend/common/infrastructure/config/source/musiccast.ts b/src/backend/common/infrastructure/config/source/musiccast.ts new file mode 100644 index 00000000..5b4a0904 --- /dev/null +++ b/src/backend/common/infrastructure/config/source/musiccast.ts @@ -0,0 +1,87 @@ +import { REPORTED_PLAYER_STATUSES, ReportedPlayerStatus } from "../../Atomic.js"; +import { CommonSourceConfig, CommonSourceData } from "./index.js"; + +export type PlaybackStatus = 'play' | 'stop' | 'pause' | 'fast_reverse' | 'fast_forward' + +export interface MusicCastResponse { + response_code: number +} + +export interface DeviceInfoResponse extends MusicCastResponse { + model_name: string + device_id: string + system_version: number + api_version: number +} + +export interface DeviceStatusResponse extends MusicCastResponse { + power: 'on' | 'standby' +} + +/** use with /netusb/getPlayInfo or /cd/getPlayInfo */ +export interface PlayInfoCDResponse extends MusicCastResponse { + device_status: 'open' | 'close' | 'ready' | 'not_ready' + playback: 'play' | 'stop' | 'pause' | 'fast_reverse' | 'fast_forward' + /** in seconds */ + play_time: number + /** in seconds */ + total_time: number + artist: string + album: string + track: string +} + +export interface PlayInfoNetResponse extends PlayInfoCDResponse { + input: string +} + +const MusicCastResponseCodes = new Map([ + [0, 'Success'], + [1, 'Initializing'], + [2, 'Internal Error'], + [3, 'Invalid Request'], + [4, 'Invalid Parameter'], + [5, 'Guarded (Unable to setup in current status)'], + [6, 'Time out'], + [100, 'Access Error'], + [101, 'Other Error'], + [107, 'Service Maintenance'], + [109, 'License Error'], + [110, 'Read Only Mode'], + [112, 'Access Denied'], + [115, 'Simultaneous logins has reached the upper limit'], + [200, 'Linking in progress'], + [201, 'Unlinking in progress'] +]); + +export const playbackToReportedStatus = (pb: PlaybackStatus): ReportedPlayerStatus => { + switch(pb) { + case 'play': + case 'fast_forward': + case 'fast_reverse': + return REPORTED_PLAYER_STATUSES.playing; + case 'pause': + return REPORTED_PLAYER_STATUSES.paused; + case 'stop': + return REPORTED_PLAYER_STATUSES.stopped; + default: + return REPORTED_PLAYER_STATUSES.unknown; + } +} + +export interface MusicCastData extends CommonSourceData { + /** + * The host or URL of the YamahaExtendedControl endpoint to use + * + * @examples [["192.168.0.101","http://192.168.0.101/YamahaExtendedControl"]] + * */ + url: string +} + +export interface MusicCastSourceConfig extends CommonSourceConfig { + data: MusicCastData +} + +export interface MusicCastSourceAIOConfig extends MusicCastSourceConfig { + type: 'musiccast' +} diff --git a/src/backend/common/infrastructure/config/source/sources.ts b/src/backend/common/infrastructure/config/source/sources.ts index d4212b07..40646548 100644 --- a/src/backend/common/infrastructure/config/source/sources.ts +++ b/src/backend/common/infrastructure/config/source/sources.ts @@ -11,6 +11,7 @@ import { MopidySourceAIOConfig, MopidySourceConfig } from "./mopidy.js"; import { MPDSourceAIOConfig, MPDSourceConfig } from "./mpd.js"; import { MPRISSourceAIOConfig, MPRISSourceConfig } from "./mpris.js"; import { MusikcubeSourceAIOConfig, MusikcubeSourceConfig } from "./musikcube.js"; +import { MusicCastSourceConfig, MusicCastSourceAIOConfig } from "./musiccast.js"; import { PlexSourceAIOConfig, PlexSourceConfig, PlexApiSourceConfig, PlexApiSourceAIOConfig } from "./plex.js"; import { SpotifySourceAIOConfig, SpotifySourceConfig } from "./spotify.js"; import { SubsonicSourceAIOConfig, SubSonicSourceConfig } from "./subsonic.js"; @@ -41,6 +42,7 @@ export type SourceConfig = | WebScrobblerSourceConfig | ChromecastSourceConfig | MusikcubeSourceConfig + | MusicCastSourceConfig | MPDSourceConfig | VLCSourceConfig; @@ -65,5 +67,6 @@ export type SourceAIOConfig = | WebScrobblerSourceAIOConfig | ChromecastSourceAIOConfig | MusikcubeSourceAIOConfig + | MusicCastSourceAIOConfig | MPDSourceAIOConfig | VLCSourceAIOConfig; diff --git a/src/backend/sources/MusicCastSource.ts b/src/backend/sources/MusicCastSource.ts new file mode 100644 index 00000000..c25bf720 --- /dev/null +++ b/src/backend/sources/MusicCastSource.ts @@ -0,0 +1,148 @@ +import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; +import { RecentlyPlayedOptions } from "./AbstractSource.js"; +import { EventEmitter } from "events"; +import { PlayObject, URLData } from "../../core/Atomic.js"; +import { + FormatPlayObjectOptions, + InternalConfig, + PlayerStateData, + SINGLE_USER_PLATFORM_ID, +} from "../common/infrastructure/Atomic.js"; +import { isPortReachable, joinedUrl, normalizeWebAddress } from "../utils/NetworkUtils.js"; +import { DeviceInfoResponse, DeviceStatusResponse, MusicCastSourceConfig, playbackToReportedStatus, PlayInfoCDResponse, PlayInfoNetResponse } from "../common/infrastructure/config/source/musiccast.js"; +import request, { Request, Response } from 'superagent'; + + +export class MusicCastSource extends MemoryPositionalSource { + + declare config: MusicCastSourceConfig; + + urlData!: URLData; + + + constructor(name: any, config: MusicCastSourceConfig, internal: InternalConfig, emitter: EventEmitter) { + const { + data = {} + } = config; + const { + ...rest + } = data; + super('musiccast', name, { ...config, data: { ...rest } }, internal, emitter); + + this.requiresAuth = false; + this.canPoll = true; + } + + protected async doBuildInitData(): Promise { + const { + data: { + url + } = {} + } = this.config; + if (url === null || url === undefined || url === '') { + throw new Error('url must be defined'); + } + this.urlData = normalizeWebAddress(url, { defaultPath: '/YamahaExtendedControl/v1' }); + const normal = this.urlData.normal; + this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${normal}'`) + return true; + } + + + protected async doCheckConnection(): Promise { + try { + await isPortReachable(this.urlData.port, { host: this.urlData.url.hostname }); + this.logger.verbose(`${this.urlData.url.hostname}:${this.urlData.port} is reachable.`); + + const resp = await request.get(joinedUrl(this.urlData.url, 'system/getDeviceInfo').toString()) + if (resp.body !== undefined && typeof resp.body === 'object') { + const deviceInfo = resp.body as DeviceInfoResponse; + this.logger.info(`Found ${deviceInfo.model_name} (${deviceInfo.device_id}) using API v${deviceInfo.api_version}`); + } else { + this.logger.warn('Could not get device info! Ignoring but probably not good...'); + } + return true; + } catch (e) { + const hint = e.error?.cause?.message ?? undefined; + throw new Error(`Could not connect to MusicCast server${hint !== undefined ? ` (${hint})` : ''}`, { cause: e.error ?? e }); + } + } + + getAnyPlayInfo = async (): Promise => { + try { + const cdResp = await request.get(joinedUrl(this.urlData.url, '/cd/getPlayInfo').toString()); + if (cdResp.body !== undefined && typeof cdResp.body === 'object') { + return cdResp.body as PlayInfoCDResponse; + } + } catch (e) { + this.logger.warn(new Error('Not OK response from cd getPlayInfo but will continue', {cause: e})); + } + + try { + const netResp = await request.get(joinedUrl(this.urlData.url, '/netusb/getPlayInfo').toString()); + if (netResp.body !== undefined && typeof netResp.body === 'object') { + return netResp.body as PlayInfoNetResponse + } + } catch (e) { + this.logger.warn(new Error('Not OK response from netusb getPlayInfo but will continue', {cause: e})); + } + + return undefined; + } + + getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { + + const statusResp = await request.get(joinedUrl(this.urlData.url, 'main/getStatus').toString()); + if (statusResp.body == undefined || typeof statusResp.body !== 'object') { + this.logger.error({ getStatusResponse: statusResp }); + throw new Error('Could not determine status of MusicCast device'); + } + if ((statusResp.body as DeviceStatusResponse).power !== 'on') { + this.logger.debug('MusicCast device is offline'); + return this.processRecentPlays([]); + } + + const playInfo = await this.getAnyPlayInfo(); + if(playInfo === undefined) { + return this.processRecentPlays([]); + } + + const play = formatPlayObj(playInfo); + + + const playerState: PlayerStateData = { + platformId: SINGLE_USER_PLATFORM_ID, + status: playbackToReportedStatus(playInfo.playback), + play, + position: play.meta.trackProgressPosition + } + + return this.processRecentPlays([playerState]); + } +} + +const formatPlayObj = (obj: PlayInfoCDResponse | PlayInfoNetResponse, options: FormatPlayObjectOptions = {}): PlayObject => { + + const { + play_time, + total_time, + artist, + album, + track, + device_status, + playback + } = obj; + + return { + data: { + artists: artist !== undefined && artist !== '' ? [artist] : [], + album: album !== '' ? album : undefined, + track, + duration: total_time + }, + meta: { + trackProgressPosition: play_time, + deviceId: 'input' in obj ? obj.input : 'cd' + } + } +} \ No newline at end of file diff --git a/src/backend/sources/ScrobbleSources.ts b/src/backend/sources/ScrobbleSources.ts index 76e3751c..39f63e3c 100644 --- a/src/backend/sources/ScrobbleSources.ts +++ b/src/backend/sources/ScrobbleSources.ts @@ -18,6 +18,7 @@ import { KodiData, KodiSourceConfig } from "../common/infrastructure/config/sour import { LastfmSourceConfig } from "../common/infrastructure/config/source/lastfm.js"; import { ListenBrainzSourceConfig } from "../common/infrastructure/config/source/listenbrainz.js"; import { MopidySourceConfig } from "../common/infrastructure/config/source/mopidy.js"; +import { MusicCastData, MusicCastSourceConfig } from "../common/infrastructure/config/source/musiccast.js"; import { MPDSourceConfig } from "../common/infrastructure/config/source/mpd.js"; import { MPRISData, MPRISSourceConfig } from "../common/infrastructure/config/source/mpris.js"; import { MusikcubeData, MusikcubeSourceConfig } from "../common/infrastructure/config/source/musikcube.js"; @@ -47,6 +48,7 @@ import { MopidySource } from "./MopidySource.js"; import { MPDSource } from "./MPDSource.js"; import { MPRISSource } from "./MPRISSource.js"; import { MusikcubeSource } from "./MusikcubeSource.js"; +import { MusicCastSource } from "./MusicCastSource.js"; import PlexSource from "./PlexSource.js"; import SpotifySource from "./SpotifySource.js"; import { SubsonicSource } from "./SubsonicSource.js"; @@ -176,6 +178,9 @@ export default class ScrobbleSources { case 'musikcube': this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("MusikcubeSourceConfig"); break; + case 'musiccast': + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("MusicCastSourceConfig"); + break; case 'mpd': this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("MPDSourceConfig"); break; @@ -498,6 +503,21 @@ export default class ScrobbleSources { }); } break; + case 'musiccast': + const musecase = { + url: process.env.MCAST_URL, + } + if (!Object.values(musecase).every(x => x === undefined)) { + configs.push({ + type: 'musiccast', + name: 'unnamed', + source: 'ENV', + mode: 'single', + configureAs: defaultConfigureAs, + data: musecase as MusicCastData + }); + } + break; case 'musikcube': const mc = { url: process.env.MC_URL, @@ -727,6 +747,9 @@ export default class ScrobbleSources { case 'musikcube': newSource = await new MusikcubeSource(name, compositeConfig as MusikcubeSourceConfig, this.internalConfig, this.emitter); break; + case 'musiccast': + newSource = await new MusicCastSource(name, compositeConfig as MusicCastSourceConfig, this.internalConfig, this.emitter); + break; case 'mpd': newSource = await new MPDSource(name, compositeConfig as MPDSourceConfig, this.internalConfig, this.emitter); break; diff --git a/src/backend/utils/NetworkUtils.ts b/src/backend/utils/NetworkUtils.ts index 0d60b1b1..ff83306f 100644 --- a/src/backend/utils/NetworkUtils.ts +++ b/src/backend/utils/NetworkUtils.ts @@ -49,13 +49,15 @@ export const isPortReachable = async (port: number, opts: PortReachableOpts) => const QUOTES_UNWRAP_REGEX: RegExp = new RegExp(/^"(.*)"$/); -export const normalizeWebAddress = (val: string): URLData => { +export const normalizeWebAddress = (val: string, options: {defaultPath?: string} = {}): URLData => { let cleanUserUrl = val.trim(); const results = parseRegexSingle(QUOTES_UNWRAP_REGEX, val); if (results !== undefined && results.groups && results.groups.length > 0) { cleanUserUrl = results.groups[0]; } + const {defaultPath} = options; + let normal = normalizeUrl(cleanUserUrl, {removeTrailingSlash: true}); const u = new URL(normal); let port: number; @@ -72,6 +74,12 @@ export const normalizeWebAddress = (val: string): URLData => { normal = normal.replace('http:', 'https:'); } } + + if(u.pathname === '/' && defaultPath !== undefined) { + u.pathname = defaultPath; + normal = normalizeUrl(u.toString()); + } + return { url: u, normal, -- 2.51.2 From b5ead397924cc95e084dfc35c59a22359bbd026f Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Wed, 12 Mar 2025 17:23:15 +0000 Subject: [PATCH 20/28] feat(musiccast): Switch play info fetch order net/usb is more likely to be used since it has more sources. Likely using it will reduce required fetches in most usage scenarios. --- src/backend/sources/MusicCastSource.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/backend/sources/MusicCastSource.ts b/src/backend/sources/MusicCastSource.ts index c25bf720..e9e9376b 100644 --- a/src/backend/sources/MusicCastSource.ts +++ b/src/backend/sources/MusicCastSource.ts @@ -69,14 +69,6 @@ export class MusicCastSource extends MemoryPositionalSource { } getAnyPlayInfo = async (): Promise => { - try { - const cdResp = await request.get(joinedUrl(this.urlData.url, '/cd/getPlayInfo').toString()); - if (cdResp.body !== undefined && typeof cdResp.body === 'object') { - return cdResp.body as PlayInfoCDResponse; - } - } catch (e) { - this.logger.warn(new Error('Not OK response from cd getPlayInfo but will continue', {cause: e})); - } try { const netResp = await request.get(joinedUrl(this.urlData.url, '/netusb/getPlayInfo').toString()); @@ -87,6 +79,15 @@ export class MusicCastSource extends MemoryPositionalSource { this.logger.warn(new Error('Not OK response from netusb getPlayInfo but will continue', {cause: e})); } + try { + const cdResp = await request.get(joinedUrl(this.urlData.url, '/cd/getPlayInfo').toString()); + if (cdResp.body !== undefined && typeof cdResp.body === 'object') { + return cdResp.body as PlayInfoCDResponse; + } + } catch (e) { + this.logger.warn(new Error('Not OK response from cd getPlayInfo but will continue', {cause: e})); + } + return undefined; } -- 2.51.2 From 4fee231a7bff70c0f1fa741fb4d9923262fb2d3f Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Wed, 12 Mar 2025 19:02:43 +0000 Subject: [PATCH 21/28] test(musiccast): Add tests for basic connectivity --- package-lock.json | 9 + package.json | 1 + .../infrastructure/config/source/musiccast.ts | 2 +- src/backend/sources/MusicCastSource.ts | 18 +- src/backend/tests/musiccast/musiccast.test.ts | 229 ++++++++++++++++++ src/backend/utils/NetworkUtils.ts | 38 +++ 6 files changed, 291 insertions(+), 6 deletions(-) create mode 100644 src/backend/tests/musiccast/musiccast.test.ts diff --git a/package-lock.json b/package-lock.json index f5fbe3e0..82d964f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@foxxmd/logging": "^0.2.2", "@foxxmd/regex-buddy-core": "^0.1.2", "@foxxmd/string-sameness": "^0.4.0", + "@gr2m/net-interceptor": "^1.0.0", "@jellyfin/sdk": "^0.11.0", "@kenyip/backoff-strategies": "^1.0.4", "@lukehagar/plexjs": "^0.32.1", @@ -1136,6 +1137,14 @@ "npm": ">=9.3.0" } }, + "node_modules/@gr2m/net-interceptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@gr2m/net-interceptor/-/net-interceptor-1.0.0.tgz", + "integrity": "sha512-qLTp3npmB5SDiEIh1Jg1v8a09dlnfYV1kcSyLmsyucvZBT89nM+6L1QdlrCC/W1cTvdTpvI+Vleit94UVX7/Cg==", + "engines": { + "node": ">= 14" + } + }, "node_modules/@homebridge/long": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/@homebridge/long/-/long-5.2.1.tgz", diff --git a/package.json b/package.json index 796c661f..e241a8a0 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "@foxxmd/logging": "^0.2.2", "@foxxmd/regex-buddy-core": "^0.1.2", "@foxxmd/string-sameness": "^0.4.0", + "@gr2m/net-interceptor": "^1.0.0", "@jellyfin/sdk": "^0.11.0", "@kenyip/backoff-strategies": "^1.0.4", "@lukehagar/plexjs": "^0.32.1", diff --git a/src/backend/common/infrastructure/config/source/musiccast.ts b/src/backend/common/infrastructure/config/source/musiccast.ts index 5b4a0904..23e0f868 100644 --- a/src/backend/common/infrastructure/config/source/musiccast.ts +++ b/src/backend/common/infrastructure/config/source/musiccast.ts @@ -35,7 +35,7 @@ export interface PlayInfoNetResponse extends PlayInfoCDResponse { input: string } -const MusicCastResponseCodes = new Map([ +export const MusicCastResponseCodes = new Map([ [0, 'Success'], [1, 'Initializing'], [2, 'Internal Error'], diff --git a/src/backend/sources/MusicCastSource.ts b/src/backend/sources/MusicCastSource.ts index e9e9376b..5ff1cbc1 100644 --- a/src/backend/sources/MusicCastSource.ts +++ b/src/backend/sources/MusicCastSource.ts @@ -8,8 +8,8 @@ import { PlayerStateData, SINGLE_USER_PLATFORM_ID, } from "../common/infrastructure/Atomic.js"; -import { isPortReachable, joinedUrl, normalizeWebAddress } from "../utils/NetworkUtils.js"; -import { DeviceInfoResponse, DeviceStatusResponse, MusicCastSourceConfig, playbackToReportedStatus, PlayInfoCDResponse, PlayInfoNetResponse } from "../common/infrastructure/config/source/musiccast.js"; +import { isPortReachable, isPortReachableConnect, joinedUrl, normalizeWebAddress } from "../utils/NetworkUtils.js"; +import { DeviceInfoResponse, DeviceStatusResponse, MusicCastResponseCodes, MusicCastSourceConfig, playbackToReportedStatus, PlayInfoCDResponse, PlayInfoNetResponse } from "../common/infrastructure/config/source/musiccast.js"; import request, { Request, Response } from 'superagent'; @@ -51,7 +51,7 @@ export class MusicCastSource extends MemoryPositionalSource { protected async doCheckConnection(): Promise { try { - await isPortReachable(this.urlData.port, { host: this.urlData.url.hostname }); + await isPortReachableConnect(1130, { host: this.urlData.url.hostname }); this.logger.verbose(`${this.urlData.url.hostname}:${this.urlData.port} is reachable.`); const resp = await request.get(joinedUrl(this.urlData.url, 'system/getDeviceInfo').toString()) @@ -73,7 +73,11 @@ export class MusicCastSource extends MemoryPositionalSource { try { const netResp = await request.get(joinedUrl(this.urlData.url, '/netusb/getPlayInfo').toString()); if (netResp.body !== undefined && typeof netResp.body === 'object') { - return netResp.body as PlayInfoNetResponse + const resp = netResp.body as PlayInfoNetResponse + if(resp.response_code !== 0) { + throw new Error(`netusb source is unexpected status: ${resp.response_code} (${MusicCastResponseCodes.get(resp.response_code) ?? 'Unknown'})`); + } + return resp; } } catch (e) { this.logger.warn(new Error('Not OK response from netusb getPlayInfo but will continue', {cause: e})); @@ -82,7 +86,11 @@ export class MusicCastSource extends MemoryPositionalSource { try { const cdResp = await request.get(joinedUrl(this.urlData.url, '/cd/getPlayInfo').toString()); if (cdResp.body !== undefined && typeof cdResp.body === 'object') { - return cdResp.body as PlayInfoCDResponse; + const resp = cdResp.body as PlayInfoCDResponse; + if(resp.response_code !== 0) { + throw new Error(`cd source is unexpected status: ${resp.response_code} (${MusicCastResponseCodes.get(resp.response_code) ?? 'Unknown'})`); + } + return resp; } } catch (e) { this.logger.warn(new Error('Not OK response from cd getPlayInfo but will continue', {cause: e})); diff --git a/src/backend/tests/musiccast/musiccast.test.ts b/src/backend/tests/musiccast/musiccast.test.ts new file mode 100644 index 00000000..9ca984aa --- /dev/null +++ b/src/backend/tests/musiccast/musiccast.test.ts @@ -0,0 +1,229 @@ +import { loggerTest, loggerDebug } from "@foxxmd/logging"; +import { assert, expect } from 'chai'; +import EventEmitter from "events"; +import dayjs from "dayjs"; +import { describe, it, before, after } from 'mocha'; +import { http, HttpResponse } from "msw"; +import { withRequestInterception } from "../utils/networking.js"; +import { MusicCastData } from "../../common/infrastructure/config/source/musiccast.js"; +import { MusicCastSource } from "../../sources/MusicCastSource.js"; +import netInterceptor from "@gr2m/net-interceptor"; +import { REPORTED_PLAYER_STATUSES } from "../../common/infrastructure/Atomic.js"; + +const TEST_IP = '192.168.10.101'; + +const createSource = (data: MusicCastData = { url: TEST_IP }): MusicCastSource => { + const source = new MusicCastSource('Test', { + data, + options: {} + }, { localUrl: new URL('http://test'), configDir: 'test', logger: loggerTest, version: 'test' }, new EventEmitter()); + return source; +} + +describe('MusicCast Startup', function () { + + after(() => { + netInterceptor.stop(); + }); + it('tests for device info correctly', withRequestInterception( + [ + http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/system/getDeviceInfo`, () => { + return HttpResponse.json({ + model_name: 'test', + device_id: 'testid', + system_version: '1234', + version: '1test', + response_code: 0 + }, { status: 200 }); + }) + ], + async function () { + const source = createSource(); + + await source.buildInitData(); + + netInterceptor.start(); + + netInterceptor.on("connection", (socket) => { + socket.write("Hello there."); + netInterceptor.stop(); + }); + await source.checkConnection(); + expect(source.connectionOK).to.be.true; + } + )); +}); + +describe('MusicCast State Handling', function() { + + it('Handles standby mode', withRequestInterception( + [ + http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/main/getStatus`, () => { + return HttpResponse.json({ + power: 'standby', + response_code: 0 + }, { status: 200 }); + }) + ], + async function () { + const source = createSource(); + + await source.buildInitData(); + source.connectionOK = true; + await source.getRecentlyPlayed(); + expect(source.players.size).to.eq(0); + } + )); + + it('Handles valid netusb device playing state', withRequestInterception( + [ + http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/main/getStatus`, () => { + return HttpResponse.json({ + power: 'on', + response_code: 0 + }, { status: 200 }); + }), + http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/netusb/getPlayInfo`, () => { + return HttpResponse.json({ + device_status: 'ready', + playback: 'play', + play_time: 10, + total_time: 60, + artist: 'Test Artist', + album: 'Test Album', + track: 'Cool Track', + input: 'av1', + response_code: 0 + }, { status: 200 }); + }), + ], + async function () { + const source = createSource(); + + await source.buildInitData(); + source.connectionOK = true; + await source.getRecentlyPlayed(); + expect(source.players.size).to.eq(1); + const playerState = source.players.get(source.players.keys().next().value).getApiState(); + expect(playerState.play.data.album).to.eq('Test Album'); + expect(playerState.play.data.track).to.eq('Cool Track'); + expect(playerState.play.data.duration).to.eq(60); + expect(playerState.play.meta.trackProgressPosition).to.eq(10); + expect(playerState.play.meta.deviceId).to.eq('av1'); + expect(playerState.status.reported).to.eq(REPORTED_PLAYER_STATUSES.playing); + } + )); + + it('Handles valid cd device playing state', withRequestInterception( + [ + http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/main/getStatus`, () => { + return HttpResponse.json({ + power: 'on', + response_code: 0 + }, { status: 200 }); + }), + http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/netusb/getPlayInfo`, () => { + return HttpResponse.json({ + response_code: 100 + }, { status: 200 }); + }), + http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/cd/getPlayInfo`, () => { + return HttpResponse.json({ + device_status: 'ready', + playback: 'play', + play_time: 10, + total_time: 60, + artist: 'Test Artist', + album: 'Test Album', + track: 'Cool Track', + response_code: 0 + }, { status: 200 }); + }), + ], + async function () { + const source = createSource(); + + await source.buildInitData(); + source.connectionOK = true; + await source.getRecentlyPlayed(); + expect(source.players.size).to.eq(1); + const playerState = source.players.get(source.players.keys().next().value).getApiState(); + expect(playerState.play.data.album).to.eq('Test Album'); + expect(playerState.play.data.track).to.eq('Cool Track'); + expect(playerState.play.data.duration).to.eq(60); + expect(playerState.play.meta.trackProgressPosition).to.eq(10); + expect(playerState.status.reported).to.eq(REPORTED_PLAYER_STATUSES.playing); + } + )); + + it('Handles non 200 status from getPlayInfo', withRequestInterception( + [ + http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/main/getStatus`, () => { + return HttpResponse.json({ + power: 'on', + response_code: 0 + }, { status: 200 }); + }), + http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/netusb/getPlayInfo`, () => { + return HttpResponse.json({ + response_code: 100 + }, { status: 500 }); + }), + http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/cd/getPlayInfo`, () => { + return HttpResponse.json({ + device_status: 'ready', + playback: 'play', + play_time: 10, + total_time: 60, + artist: 'Test Artist', + album: 'Test Album', + track: 'Cool Track', + response_code: 0 + }, { status: 200 }); + }), + ], + async function () { + const source = createSource(); + + await source.buildInitData(); + source.connectionOK = true; + await source.getRecentlyPlayed(); + expect(source.players.size).to.eq(1); + } + )); + + it('Handles stopped player', withRequestInterception( + [ + http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/main/getStatus`, () => { + return HttpResponse.json({ + power: 'on', + response_code: 0 + }, { status: 200 }); + }), + http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/netusb/getPlayInfo`, () => { + return HttpResponse.json({ + device_status: 'ready', + playback: 'stop', + play_time: 10, + total_time: 60, + artist: 'Test Artist', + album: 'Test Album', + track: 'Cool Track', + input: 'av1', + response_code: 0 + }, { status: 200 }); + }), + ], + async function () { + const source = createSource(); + + await source.buildInitData(); + source.connectionOK = true; + await source.getRecentlyPlayed(); + expect(source.players.size).to.eq(1); + const playerState = source.players.get(source.players.keys().next().value).getApiState(); + expect(playerState.status.reported).to.eq(REPORTED_PLAYER_STATUSES.stopped); + } + )); + +}); \ No newline at end of file diff --git a/src/backend/utils/NetworkUtils.ts b/src/backend/utils/NetworkUtils.ts index ff83306f..6d247216 100644 --- a/src/backend/utils/NetworkUtils.ts +++ b/src/backend/utils/NetworkUtils.ts @@ -47,6 +47,44 @@ export const isPortReachable = async (port: number, opts: PortReachableOpts) => } } +/** Test if a host:port is reachable via TCP + * + * Need to use net.connect instead of new.Socket() because the popular mocking libraries don't mock Socket + * + * https://github.com/gr2m/node-net-interceptor/issues/2 + * https://github.com/moll/node-mitm/issues/42 + * + */ +export const isPortReachableConnect = async (port: number, opts: PortReachableOpts) => { + const {host, timeout = 1000} = opts; + + const promise = new Promise(((resolve, reject) => { + const client = net.connect({ + timeout, + port, + host + }, () => { + client.end(); + resolve(true); + }); + + client.on('error', (err) => { + client.destroy(); + reject(err); + }); + client.on('timeout', () => { + reject(new Error(`Connection timed out after ${timeout}ms`)); + }); + })); + + try { + await promise; + return true; + } catch (e) { + throw e; + } +} + const QUOTES_UNWRAP_REGEX: RegExp = new RegExp(/^"(.*)"$/); export const normalizeWebAddress = (val: string, options: {defaultPath?: string} = {}): URLData => { -- 2.51.2 From 1dd3185715bfb234e0fe9af2d3b7ac52b785ed1d Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 14 Mar 2025 15:55:35 +0000 Subject: [PATCH 22/28] fix: Better handling of 0 duration tracks * Check for zero duration before using progress bar in debug logging * Adds tests for scrobble thresholds to make sure 0 duration is handled --- .../PlayerState/AbstractPlayerState.ts | 4 +- src/backend/tests/player/player.test.ts | 16 +++++++- src/backend/tests/source/source.test.ts | 38 +++++++++++++++++++ 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/backend/sources/PlayerState/AbstractPlayerState.ts b/src/backend/sources/PlayerState/AbstractPlayerState.ts index 35cbfc8e..a981ccce 100644 --- a/src/backend/sources/PlayerState/AbstractPlayerState.ts +++ b/src/backend/sources/PlayerState/AbstractPlayerState.ts @@ -367,11 +367,11 @@ export abstract class AbstractPlayerState { } 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) { + 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 | `; } let listenedPercent = ''; - if (this.currentPlay !== undefined && this.currentPlay.data.duration !== undefined) { + 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 diff --git a/src/backend/tests/player/player.test.ts b/src/backend/tests/player/player.test.ts index 6f44c7c6..ad3dcdd3 100644 --- a/src/backend/tests/player/player.test.ts +++ b/src/backend/tests/player/player.test.ts @@ -230,7 +230,7 @@ describe('Player listen ranges', function () { describe('When source does provide playback position', function () { - it('Duration is position based', function () { + it('Listened duration is position based', function () { const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]); const positioned = clone(newPlay); @@ -243,6 +243,20 @@ describe('Player listen ranges', function () { assert.equal(player.getListenDuration(), 7); }); + it('Listened duration is track duration invariant', function () { + const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]); + + const positioned = clone(newPlay); + positioned.data.duration = 0; + + player.update(testState({play: positioned, position: 3})); + + player.currentListenRange.rtPlayer.setPosition(10000); + player.update(testState({play: positioned, position: 10}), dayjs().add(10, 'seconds')); + + assert.equal(player.getListenDuration(), 7); + }); + it('Range ends if position over drifts', function () { const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]); diff --git a/src/backend/tests/source/source.test.ts b/src/backend/tests/source/source.test.ts index 67a3ee58..e944a64e 100644 --- a/src/backend/tests/source/source.test.ts +++ b/src/backend/tests/source/source.test.ts @@ -15,6 +15,8 @@ import dayjs, { Dayjs } from "dayjs"; import { REPORTED_PLAYER_STATUSES } from "../../common/infrastructure/Atomic.js"; import { SourceConfig } from "../../common/infrastructure/config/source/sources.js"; import MemorySource from "../../sources/MemorySource.js"; +import { timePassesScrobbleThreshold } from "../../utils/TimeUtils.js"; +import { DEFAULT_SCROBBLE_DURATION_THRESHOLD, DEFAULT_SCROBBLE_PERCENT_THRESHOLD } from "../../common/infrastructure/Atomic.js"; chai.use(asPromised); @@ -398,4 +400,40 @@ describe('Player Cleanup', function () { it('Does discover Play after becoming active again (Positional Source)', function () { scrobbleRediscoveryOnActive(generateMemoryPositionalSource); }); +}); + +describe('Scrobble Threshold Checks', function() { + + it('uses defaults when no user-configured thresholds are passed', function() { + const results = timePassesScrobbleThreshold({}, 1, 1); + expect(results.duration.threshold).to.eq(DEFAULT_SCROBBLE_DURATION_THRESHOLD); + expect(results.percent.threshold).to.eq(DEFAULT_SCROBBLE_PERCENT_THRESHOLD); + }); + + it('uses user-configured thresholds when passed', function() { + const results = timePassesScrobbleThreshold({ + duration: 20, + percent: 15 + }, 1, 1); + expect(results.duration.threshold).to.eq(20); + expect(results.percent.threshold).to.eq(15); + }); + + it('passes when duration is above threshold', function() { + const results = timePassesScrobbleThreshold({}, DEFAULT_SCROBBLE_DURATION_THRESHOLD + 1); + expect(results.duration.passes).is.true; + expect(results.passes).is.true; + }); + + it('passes when percent is above threshold', function() { + const results = timePassesScrobbleThreshold({}, 30, 50); + expect(results.percent.passes).is.true; + expect(results.passes).is.true; + }); + + it('handles zero duration', function() { + const results = timePassesScrobbleThreshold({}, DEFAULT_SCROBBLE_DURATION_THRESHOLD + 1, 0); + expect(results.duration.passes).is.true; + expect(results.passes).is.true; + }); }); \ No newline at end of file -- 2.51.2 From e4e1797ac578cab4fd17eed7f5d3a4df306f4091 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 14 Mar 2025 17:35:15 +0000 Subject: [PATCH 23/28] fix(musiccast): Remove hardocded port for debugging --- src/backend/sources/MusicCastSource.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/sources/MusicCastSource.ts b/src/backend/sources/MusicCastSource.ts index 5ff1cbc1..b3d87eb3 100644 --- a/src/backend/sources/MusicCastSource.ts +++ b/src/backend/sources/MusicCastSource.ts @@ -51,7 +51,7 @@ export class MusicCastSource extends MemoryPositionalSource { protected async doCheckConnection(): Promise { try { - await isPortReachableConnect(1130, { host: this.urlData.url.hostname }); + await isPortReachableConnect(this.urlData.port, { host: this.urlData.url.hostname }); this.logger.verbose(`${this.urlData.url.hostname}:${this.urlData.port} is reachable.`); const resp = await request.get(joinedUrl(this.urlData.url, 'system/getDeviceInfo').toString()) -- 2.51.2 From 230ddf4a48c6828e2d8c1667bf9c473e2beca31e Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 17 Mar 2025 14:27:35 +0000 Subject: [PATCH 24/28] fix(source): Do not calculate polling interval decrease if duration does not exist --- src/backend/sources/MemorySource.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/sources/MemorySource.ts b/src/backend/sources/MemorySource.ts index eaa7f674..84c8a8f4 100644 --- a/src/backend/sources/MemorySource.ts +++ b/src/backend/sources/MemorySource.ts @@ -331,7 +331,7 @@ export default class MemorySource extends AbstractSource { for(const player of this.players.values()) { if(player.calculatedStatus === CALCULATED_PLAYER_STATUSES.playing) { const pos = player.getPosition(); - if(pos !== undefined && player.currentPlay !== undefined) { + if(pos !== undefined && player.currentPlay !== undefined && player.currentPlay.data?.duration !== undefined) { const { data: { duration -- 2.51.2 From 6a71d4a495231cdeb8ab04ccf94dfb2781d78b99 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 17 Mar 2025 14:29:18 +0000 Subject: [PATCH 25/28] fix(musiccast): Treat 0 total_total as undefined Prevents some positional player logic from running on a functionally-useless value --- src/backend/sources/MusicCastSource.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/backend/sources/MusicCastSource.ts b/src/backend/sources/MusicCastSource.ts index b3d87eb3..d96787b9 100644 --- a/src/backend/sources/MusicCastSource.ts +++ b/src/backend/sources/MusicCastSource.ts @@ -147,7 +147,8 @@ const formatPlayObj = (obj: PlayInfoCDResponse | PlayInfoNetResponse, options: F artists: artist !== undefined && artist !== '' ? [artist] : [], album: album !== '' ? album : undefined, track, - duration: total_time + // we should treat 0 time as the same as not being provided + duration: total_time === 0 ? undefined : total_time }, meta: { trackProgressPosition: play_time, -- 2.51.2 From 0dca49ab4364962eec68e577880508f1ec802dab Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 17 Mar 2025 14:46:21 +0000 Subject: [PATCH 26/28] fix(player): Check for 0 duration before finalizing listen session with duration-based calculation --- .../PlayerState/PositionalPlayerState.ts | 2 +- src/backend/tests/player/player.test.ts | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/backend/sources/PlayerState/PositionalPlayerState.ts b/src/backend/sources/PlayerState/PositionalPlayerState.ts index 17967e3c..25159df1 100644 --- a/src/backend/sources/PlayerState/PositionalPlayerState.ts +++ b/src/backend/sources/PlayerState/PositionalPlayerState.ts @@ -90,7 +90,7 @@ export class PositionalPlayerState extends AbstractPlayerState { duration, } = {} } = this.currentPlay; - if(duration !== undefined && (duration - this.currentListenRange.end.position) < this.gracefulEndBuffer) { + if(duration !== undefined && duration !== 0 && (duration - this.currentListenRange.end.position) < this.gracefulEndBuffer) { // likely the track was listened to until it ended // but polling interval or network delays caused MS to not get data on the very end // also...within 3 seconds of ending is close enough to call this complete IMO diff --git a/src/backend/tests/player/player.test.ts b/src/backend/tests/player/player.test.ts index ad3dcdd3..609cad21 100644 --- a/src/backend/tests/player/player.test.ts +++ b/src/backend/tests/player/player.test.ts @@ -257,6 +257,29 @@ describe('Player listen ranges', function () { assert.equal(player.getListenDuration(), 7); }); + it('Listened for is track duration invariant', function () { + const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]); + + const positioned = clone(newPlay); + positioned.data.duration = 0; + + player.update(testState({play: positioned, position: 3, status: REPORTED_PLAYER_STATUSES.playing})); + + player.currentListenRange.rtPlayer.setPosition(10000); + player.update(testState({play: positioned, position: 10, status: REPORTED_PLAYER_STATUSES.playing}), dayjs().add(10, 'seconds')); + + player.currentListenRange.rtPlayer.setPosition(20000); + player.update(testState({play: positioned, position: 20, status: REPORTED_PLAYER_STATUSES.playing}), dayjs().add(20, 'seconds')); + + const otherPlay = clone(positioned); + otherPlay.data.track = "A New Track"; + player.currentListenRange.rtPlayer.setPosition(30000); + const [currPlay, prevPlay] = player.update(testState({play: otherPlay, position: 2, status: REPORTED_PLAYER_STATUSES.playing}), dayjs().add(30, 'seconds')); + + assert.isDefined(prevPlay); + assert.equal(prevPlay.data.listenedFor, 17); + }); + it('Range ends if position over drifts', function () { const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]); -- 2.51.2 From 2adb3bd052b0d1a97c00e62b2a258e7f6e6eaaa1 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 17 Mar 2025 21:02:18 +0000 Subject: [PATCH 27/28] docs(musiccast): Add docs for Yahama MusicCast --- README.md | 1 + docsite/docs/configuration/configuration.mdx | 74 +++++++++++++++++++- docsite/src/pages/index.mdx | 1 + 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 34288f9e..88b03231 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c * [Musikcube](https://foxxmd.github.io/multi-scrobbler/docs/configuration#muikcube) * [MPD (Music Player Daemon)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpd-music-player-daemon) * [VLC](https://foxxmd.github.io/multi-scrobbler/docs/configuration#vlc) + * [Yamaha MusicCast](https://foxxmd.github.io/multi-scrobbler/docs/configuration#yamaha-musiccast) * Supports scrobbling to many **Clients** * [Maloja](https://foxxmd.github.io/multi-scrobbler/docs/configuration#maloja) * [Last.fm](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm) diff --git a/docsite/docs/configuration/configuration.mdx b/docsite/docs/configuration/configuration.mdx index 5e2d3f23..a7e7a8be 100644 --- a/docsite/docs/configuration/configuration.mdx +++ b/docsite/docs/configuration/configuration.mdx @@ -23,6 +23,7 @@ import MalojaConfig from '!!raw-loader!../../../config/maloja.json.example'; import MopidyConfig from '!!raw-loader!../../../config/mopidy.json.example'; import MprisConfig from '!!raw-loader!../../../config/mpris.json.example'; import MusikcubeConfig from '!!raw-loader!../../../config/musikcube.json.example'; +import MusiccastConfig from '!!raw-loader!../../../config/musiccast.json.example'; import MPDConfig from '!!raw-loader!../../../config/mpd.json.example'; import PlexConfig from '!!raw-loader!../../../config/plex.json.example'; import PlexWebhookConfig from '!!raw-loader!../../../config/plex.webhook.json.example'; @@ -1857,8 +1858,6 @@ If the file being played in VLC does not have ID3 tags/metadata information mult To extract this information use [named capture groups](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Named_capturing_group) in your expression matching the field that should be extracted: `artist` `title` and/or `album`. MS accepts multiple expressions, the first one that matches a filename will be used. - - Example usage in [a file-based config:](./?configType=file#webscrobbler-config) ```json5 title="config/vlc.json" {10-15} @@ -1901,6 +1900,77 @@ If you find that VLC is incorrectly reporting track information (in its interfac ] ``` +### [Yamaha MusicCast](https://usa.yamaha.com/products/contents/audio_visual/musiccast/index.html) + +Monitor Musiccast device/receivers for music played on Network/USB/CD inputs. + +##### URL + +If only a domain/IP:PORT is provided to multi-scrobbler it will automatically append the default Musiccast API path. If your Musiccast device is located behind a custom URL (or reverse proxy) you should explicitly provide the base path to use. + +EX + + | `url` | Transformed URL | + | --------------------------------------- | :---------------------------------------------- | + | `http://192.168.0.101` | `http://192.168.0.101/YamahaExtendedControl/v1` | + | `http://mydomain.com/reverseProxy/cast` | `http://mydomain.com/reverseProxy/cast` | + +##### Scrobble Threshold + +The data source of the music being played on the Musiccast device may not report a track duration to multi-scrobbler. In its default configuration this means mutli-scrobbler won't consider a track scrobblable until it has been played for 4 minutes (or 50% but that is impossible to determine). You should configure multi-scrobbler to scrobble after a more reasonable amount of time by using `scrobbleThresholds` in the [Musiccast Source file-based config](./?configType=file#yamaha-musiccast): + +```json5 +[ + { + "name": "myYamaha", + "enable": true, + "data": { + "url": "192.168.0.101" + }, + "options": { + "scrobbleThresholds": { // scrobble track if... + "duration": 90, // listened to for 90 seconds OR + "percent": 50 // listened to 50% of the track + } + } + } +] +``` + +#### Configuration + + + + | Environmental Variable | Required? | Default | Description | + | :--------------------- | :-------- | ------- | :----------------------- | + | `MCAST_URL` | Yes | | The Musiccast device URL | + + +
+ Change `configureAs` to `source` + + Example + + {MusiccastConfig} + +
+ + or +
+ +
+ Change `configureAs` to `source` + + Example + + + +
+ + or +
+
+ ## Client Configurations ### [Maloja](https://github.com/krateng/maloja) diff --git a/docsite/src/pages/index.mdx b/docsite/src/pages/index.mdx index e557c7d7..32a05348 100644 --- a/docsite/src/pages/index.mdx +++ b/docsite/src/pages/index.mdx @@ -32,6 +32,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c * [Musikcube](docs/configuration#musikcube) * [MPD (Music Player Daemon)](docs/configuration#mpd-music-player-daemon) * [VLC](docs/configuration#vlc) + * [Yamaha MusicCast](docs/configuration#yamaha-musiccast) * Supports scrobbling to many **Clients** * [Maloja](docs/configuration#maloja) * [Last.fm](docs/configuration#lastfm) -- 2.51.2 From e0060fe3e5347a3813a5655bf79636f98dcd47fd Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 18 Mar 2025 17:28:14 +0000 Subject: [PATCH 28/28] feat: Add ENVs for default scrobble threshold for duration/percent * SOURCE_SCROBBLE_DURATION env sets default duration * SOURCE_SCROBBLE_PERCENT env sets default percent --- src/backend/sources/ScrobbleSources.ts | 44 ++++++++++++++++++++- src/backend/tests/config/config.test.ts | 52 ++++++++++++++++++++++++- src/backend/utils.ts | 1 + 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/src/backend/sources/ScrobbleSources.ts b/src/backend/sources/ScrobbleSources.ts index 39f63e3c..4431dfd5 100644 --- a/src/backend/sources/ScrobbleSources.ts +++ b/src/backend/sources/ScrobbleSources.ts @@ -59,6 +59,7 @@ import YTMusicSource from "./YTMusicSource.js"; import { Definition } from 'ts-json-schema-generator'; import { getTypeSchemaFromConfigGenerator } from '../utils/SchemaUtils.js'; import PlexApiSource from './PlexApiSource.js'; +import { nonEmptyStringOrDefault } from '../../core/StringUtils.js'; type groupedNamedConfigs = {[key: string]: ParsedConfig[]}; @@ -192,6 +193,45 @@ export default class ScrobbleSources { return this.schemaDefinitions[type]; } + buildSourceDefaults = (fileDefaults: SourceDefaults = {}): SourceDefaults => { + const scrobbleDurationEnv = process.env.SOURCE_SCROBBLE_DURATION; + const scrobblePercentEnv = process.env.SOURCE_SCROBBLE_PERCENT; + + const buildDefaults = {...fileDefaults}; + + if(nonEmptyStringOrDefault(scrobbleDurationEnv) !== undefined || nonEmptyStringOrDefault(scrobblePercentEnv) !== undefined) { + const { + scrobbleThresholds: { + duration, + percent + } = {}, + scrobbleThresholds = {} + } = fileDefaults; + buildDefaults.scrobbleThresholds = {...scrobbleThresholds}; + + if(duration === undefined && nonEmptyStringOrDefault(scrobbleDurationEnv) !== undefined) { + const envDur = Number.parseInt(scrobbleDurationEnv); + if(Number.isNaN(envDur)) { + this.logger.warn(`Ignoring value '${scrobbleDurationEnv}' for env SOURCE_SCROBBLE_DURATION because it is not a number`); + } else { + buildDefaults.scrobbleThresholds.duration = envDur; + this.logger.verbose(`Set default scrobble threshold duration to '${scrobbleDurationEnv}' based on env SOURCE_SCROBBLE_DURATION`); + } + } + if(percent === undefined && nonEmptyStringOrDefault(scrobblePercentEnv) !== undefined) { + const envPercent = Number.parseInt(scrobblePercentEnv); + if(Number.isNaN(envPercent)) { + this.logger.warn(`Ignoring value '${scrobblePercentEnv}' for env SOURCE_SCROBBLE_PERCENT because it is not a number`); + } else { + buildDefaults.scrobbleThresholds.percent = envPercent; + this.logger.verbose(`Set default scrobble threshold percent to '${scrobblePercentEnv}' based on env SOURCE_SCROBBLE_PERCENT`); + } + } + } + + return buildDefaults; + } + buildSourcesFromConfig = async (additionalConfigs: ParsedConfig[] = []) => { const configs: ParsedConfig[] = additionalConfigs; @@ -211,7 +251,7 @@ export default class ScrobbleSources { sources: mainConfigSourcesConfigs = [], sourceDefaults: sd = {}, } = aioConfig; - sourceDefaults = sd; + sourceDefaults = this.buildSourceDefaults(sd); for (const [index, c] of mainConfigSourcesConfigs.entries()) { const {name = 'unnamed'} = c; if(!isSourceType(c.type.toLocaleLowerCase())) { @@ -239,6 +279,8 @@ export default class ScrobbleSources { configureAs: 'source' // override user value }); } + } else { + sourceDefaults = this.buildSourceDefaults(); } for (const sourceType of sourceTypes) { diff --git a/src/backend/tests/config/config.test.ts b/src/backend/tests/config/config.test.ts index b013c1b6..2799ce9a 100644 --- a/src/backend/tests/config/config.test.ts +++ b/src/backend/tests/config/config.test.ts @@ -1,4 +1,4 @@ -import { after, before, describe, it } from 'mocha'; +import { after, before, afterEach, describe, it } from 'mocha'; import chai, { assert, expect } from 'chai'; import asPromised from 'chai-as-promised'; import withLocalTmpDir from 'with-local-tmp-dir'; @@ -11,6 +11,7 @@ import EventEmitter from "events"; import {loggerTest, loggerDebug} from '@foxxmd/logging'; import { clientTypes, SourceType, sourceTypes } from '../../common/infrastructure/Atomic.js'; import { Notifiers } from '../../notifier/Notifiers.js'; +import { difference, intersect } from '../../utils.js'; chai.use(asPromised); @@ -97,3 +98,52 @@ describe('Sample Configs', function () { }); }); }); + +describe('Global ENVs with Config', function () { + + let baseEnvKeys: string[] = []; + + before(function() { + baseEnvKeys = Array.from(Object.keys(process.env)); + }); + + afterEach(function() { + const modified = difference(Array.from(Object.keys(process.env)), baseEnvKeys); + for(const key of modified) { + delete process.env[key]; + } + }); + + it('Parses default scrobble duration', async function () { + process.env.SOURCE_SCROBBLE_DURATION = '20'; + process.env.MPRIS_ENABLE = 'true'; + + const emitter = new EventEmitter(); + const sources = new ScrobbleSources(emitter, { + localUrl: new URL('http://example.com'), + configDir: process.cwd(), + version: 'test' + }, loggerTest); + await sources.buildSourcesFromConfig(); + + expect(sources.sources).length(1); + expect(sources.sources[0].config?.options?.scrobbleThresholds?.duration).to.eq(20); + }); + + it('Parses default scrobble precentage', async function () { + process.env.SOURCE_SCROBBLE_PERCENT = '20'; + process.env.MPRIS_ENABLE = 'true'; + + const emitter = new EventEmitter(); + const sources = new ScrobbleSources(emitter, { + localUrl: new URL('http://example.com'), + configDir: process.cwd(), + version: 'test' + }, loggerTest); + await sources.buildSourcesFromConfig(); + + expect(sources.sources).length(1); + expect(sources.sources[0].config?.options?.scrobbleThresholds?.percent).to.eq(20); + }); + +}); \ No newline at end of file diff --git a/src/backend/utils.ts b/src/backend/utils.ts index b17591c3..9e95d41b 100644 --- a/src/backend/utils.ts +++ b/src/backend/utils.ts @@ -483,6 +483,7 @@ export const intersect = (a: Array, b: Array) => { return Array.from(intersection); } +/** Return an array of elements from array a (first arg) that are not in array b (second arg) */ export const difference = (a: Array, b: Array) => { const setA = new Set(a); const setB = new Set(b);