From 7c99262a7977bd4210cc55e673554b7ffedaf8bc Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 13 Feb 2026 18:39:33 +0000 Subject: [PATCH 01/18] feat(discord): Initial implementation #468 --- package-lock.json | 282 +++++++++- package.json | 1 + src/backend/common/infrastructure/Atomic.ts | 9 +- .../infrastructure/config/client/clients.ts | 5 +- .../infrastructure/config/client/discord.ts | 25 + .../common/vendor/discord/DiscordWSClient.ts | 531 ++++++++++++++++++ src/backend/scrobblers/DiscordScrobbler.ts | 71 +++ src/backend/scrobblers/ScrobbleClients.ts | 7 + 8 files changed, 923 insertions(+), 8 deletions(-) create mode 100644 src/backend/common/infrastructure/config/client/discord.ts create mode 100644 src/backend/common/vendor/discord/DiscordWSClient.ts create mode 100644 src/backend/scrobblers/DiscordScrobbler.ts diff --git a/package-lock.json b/package-lock.json index 7479d399..3b96bdde 100644 --- a/package-lock.json +++ b/package-lock.json @@ -143,6 +143,7 @@ "@vitejs/plugin-react": "^4.2.1", "chai": "^4.3.6", "chai-as-promised": "^7.1.1", + "discord.js": "^14.25.1", "eslint": "^8.56.0", "eslint-plugin-prefer-arrow-functions": "^3.2.4", "git-cliff": "^2.12.0", @@ -1073,6 +1074,164 @@ "@dbus-types/dbus": "^0.0.4" } }, + "node_modules/@discordjs/builders": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.1.tgz", + "integrity": "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.33", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz", + "integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.1.1", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.3", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.16", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.3" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/undici": { + "version": "6.21.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", + "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@dmsnell/diff-match-patch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@dmsnell/diff-match-patch/-/diff-match-patch-1.1.0.tgz", @@ -2812,6 +2971,42 @@ "win32" ] }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@sec-ant/readable-stream": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", @@ -4193,6 +4388,17 @@ "vite": "^4.2.0 || ^5.0.0" } }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@yarnpkg/lockfile": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", @@ -5676,6 +5882,54 @@ "node": ">=8" } }, + "node_modules/discord-api-types": { + "version": "0.38.39", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.39.tgz", + "integrity": "sha512-XRdDQvZvID1XvcFftjSmd4dcmMi/RL/jSy5sduBDAvCGFcNFHThdIQXCEBDZFe52lCNEzuIL0QJoKYAmRmxLUA==", + "dev": true, + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/discord.js": { + "version": "14.25.1", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz", + "integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^1.13.0", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.0", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.3", + "discord-api-types": "^0.38.33", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.3" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/discord.js/node_modules/undici": { + "version": "6.21.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", + "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -8978,6 +9232,13 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "dev": true, + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -9045,6 +9306,13 @@ "lz-string": "bin/bin.js" } }, + "node_modules/magic-bytes.js": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", + "dev": true, + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -12723,6 +12991,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "dev": true, + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.0.tgz", @@ -13793,9 +14068,10 @@ "peer": true }, "node_modules/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", "engines": { "node": ">=10.0.0" }, diff --git a/package.json b/package.json index 58c0880f..a780dc2c 100644 --- a/package.json +++ b/package.json @@ -178,6 +178,7 @@ "@vitejs/plugin-react": "^4.2.1", "chai": "^4.3.6", "chai-as-promised": "^7.1.1", + "discord.js": "^14.25.1", "eslint": "^8.56.0", "eslint-plugin-prefer-arrow-functions": "^3.2.4", "git-cliff": "^2.12.0", diff --git a/src/backend/common/infrastructure/Atomic.ts b/src/backend/common/infrastructure/Atomic.ts index 79d34574..20cd3ac0 100644 --- a/src/backend/common/infrastructure/Atomic.ts +++ b/src/backend/common/infrastructure/Atomic.ts @@ -113,7 +113,8 @@ export type ClientType = | 'listenbrainz' | 'koito' | 'tealfm' - | 'rocksky'; + | 'rocksky' + | 'discord'; export const clientTypes: ClientType[] = [ 'maloja', 'lastfm', @@ -121,7 +122,8 @@ export const clientTypes: ClientType[] = [ 'listenbrainz', 'koito', 'tealfm', - 'rocksky' + 'rocksky', + 'discord' ]; export const clientInterfaces = [ @@ -132,7 +134,8 @@ export const clientInterfaces = [ 'ListenBrainzClientConfig', 'KoitoClientConfig', 'TealClientConfig', - 'RockSkyClientConfig' + 'RockSkyClientConfig', + 'DiscordClientConfig' ]; export const isClientType = (data: string): data is ClientType => { diff --git a/src/backend/common/infrastructure/config/client/clients.ts b/src/backend/common/infrastructure/config/client/clients.ts index 4dbcd1bb..fc569bb2 100644 --- a/src/backend/common/infrastructure/config/client/clients.ts +++ b/src/backend/common/infrastructure/config/client/clients.ts @@ -5,7 +5,8 @@ import { MalojaClientAIOConfig, MalojaClientConfig } from "./maloja.js"; import { TealClientAIOConfig, TealClientConfig } from "./tealfm.js"; import { RockSkyClientAIOConfig, RockSkyClientConfig } from "./rocksky.js"; import { LibrefmClientConfig, LibrefmClientAIOConfig } from "./librefm.js"; +import { DiscordClientAIOConfig, DiscordClientConfig } from "./discord.js"; -export type ClientConfig = MalojaClientConfig | LastfmClientConfig | LibrefmClientConfig | ListenBrainzClientConfig | KoitoClientConfig | TealClientConfig | RockSkyClientConfig; +export type ClientConfig = MalojaClientConfig | LastfmClientConfig | LibrefmClientConfig | ListenBrainzClientConfig | KoitoClientConfig | TealClientConfig | RockSkyClientConfig | DiscordClientConfig; -export type ClientAIOConfig = MalojaClientAIOConfig | LastfmClientAIOConfig | LibrefmClientAIOConfig | ListenBrainzClientAIOConfig | KoitoClientAIOConfig | TealClientAIOConfig | RockSkyClientAIOConfig; +export type ClientAIOConfig = MalojaClientAIOConfig | LastfmClientAIOConfig | LibrefmClientAIOConfig | ListenBrainzClientAIOConfig | KoitoClientAIOConfig | TealClientAIOConfig | RockSkyClientAIOConfig | DiscordClientAIOConfig; diff --git a/src/backend/common/infrastructure/config/client/discord.ts b/src/backend/common/infrastructure/config/client/discord.ts new file mode 100644 index 00000000..05d530bf --- /dev/null +++ b/src/backend/common/infrastructure/config/client/discord.ts @@ -0,0 +1,25 @@ +import { RequestRetryOptions } from "../common.js" +import { CommonClientConfig, CommonClientData } from "./index.js" + +export interface DiscordData { + //applicationId: string + token: string + artwork?: boolean | string[] +} + +export interface DiscordClientData extends DiscordData, CommonClientData {} + +export interface DiscordClientConfig extends CommonClientConfig { + /** + * Should always be `client` when using Koito as a client + * + * @default client + * @examples ["client"] + * */ + configureAs?: 'client' | 'source' + data: DiscordClientData +} + +export interface DiscordClientAIOConfig extends DiscordClientConfig { + type: 'discord' +} \ No newline at end of file diff --git a/src/backend/common/vendor/discord/DiscordWSClient.ts b/src/backend/common/vendor/discord/DiscordWSClient.ts new file mode 100644 index 00000000..1b5f97b0 --- /dev/null +++ b/src/backend/common/vendor/discord/DiscordWSClient.ts @@ -0,0 +1,531 @@ +import { childLogger } from "@foxxmd/logging"; +import { WS } from 'iso-websocket' +import { DiscordClientData } from "../../infrastructure/config/client/discord.js"; +import { _DataPayload, _NonDispatchPayload, APIUser, GatewayActivity, GatewayActivityUpdateData, GatewayDispatchEvents, GatewayHeartbeatRequest, GatewayHelloData, GatewayIdentify, GatewayIdentifyData, GatewayInvalidSessionData, GatewayOpcodes, GatewayPresenceUpdateData, GatewayReadyDispatchData, GatewayResumeData, GatewayUpdatePresence, PresenceUpdateStatus } from "discord.js"; +import { isDebugMode, removeUndefinedKeys, sleep } from "../../../utils.js"; +import pEvent from 'p-event'; +import { randomInt } from "crypto"; +import request from 'superagent'; +import AbstractApiClient from "../AbstractApiClient.js"; +import { AbstractApiOptions, asPlayerStateData, SourceData } from "../../infrastructure/Atomic.js"; +import { isPlayObject, PlayObject } from "../../../../core/Atomic.js"; +import dayjs from "dayjs"; +import { capitalize } from "../../../../core/StringUtils.js"; + +const ARTWORK_PLACEHOLDER = 'https://raw.githubusercontent.com/FoxxMD/multi-scrobbler/master/assets/icon128.png'; +const API_GATEWAY_ENDPOINT = 'https://discord.com/api/gateway'; + +/** + * Implementation largely based on + * + * https://github.com/n0thhhing/Discord-rich-presence + * https://github.com/logixism/navicord + * + * Existing implementations of Rich Presence all use the local RPC gateway from a running Discord app + * and the impl that actually uses the remote gateway, @discord/ws, is only built for bot use + * so we need to roll our own Gateway API interface https://docs.discord.com/developers/events/gateway + * + */ +export class DiscordWSClient extends AbstractApiClient { + + declare config: DiscordClientData; + + heartbeatInterval: NodeJS.Timeout + acknowledged: boolean = true; + + // https://docs.discord.com/developers/events/gateway#ready-event + // used for resuming session, if possible + session_id: string; + resume_gateway_url: string; + sequence: number; + + initialGatewayUrl?: string; + + user: APIUser; + + declare client: WS; + + canReconnect?: boolean = false; + ready: boolean = false; + + lastActiveStatus?: PresenceUpdateStatus = PresenceUpdateStatus.Offline; + + activityTimeout: NodeJS.Timeout; + + constructor(name: any, config: DiscordClientData, options: AbstractApiOptions) { + super('Discord', name, config, options); + this.logger = childLogger(options.logger, 'WS Gateway'); + } + + initClient = async (baseUrl?: string) => { + if (baseUrl === undefined && this.initialGatewayUrl === undefined) { + try { + await this.fetchGatewayUrl(); + baseUrl = this.initialGatewayUrl; + } catch (e) { + throw new Error('Could not get initial gateway url', { cause: e }); + } + } + + const gatewayUrl = `${baseUrl}?encoding=json&v=10`; + this.logger.debug(`Using Gateway URL ${gatewayUrl}`); + + this.client = new WS(gatewayUrl, { + automaticOpen: false, + retry: { + retries: 0 + } + }); + + this.client.addEventListener('retry', (e) => { + this.logger.verbose(`Retrying connection, attempt ${e.attempt}`); + }); + this.client.addEventListener('close', (e) => { + // should receive a close code https://docs.discord.com/developers/topics/opcodes-and-status-codes#gateway-gateway-close-event-codes + // which determines if reconnect is possible + this.logger.warn(`Connection was closed: ${e.code} => ${e.reason}`); + }); + this.client.addEventListener('open', (e) => { + this.logger.verbose(`Connection was established.`); + + if (this.canReconnect && this.session_id !== undefined) { + // using resume + this.handleResume(); + } else { + // initial identify + this.handleIdentify(); + } + }); + this.client.addEventListener('error', (e) => { + // may return a close code? + this.logger.error(new Error(`Error from Discord Gateway`, { cause: e.error })); + }); + + this.client.addEventListener('message', async (e) => { + try { + await this.handleMessage(JSON.parse(e.data)); + } catch (e) { + this.logger.error(e); + } + }); + } + + fetchGatewayUrl = async () => { + const resp = await request.get(API_GATEWAY_ENDPOINT); + this.initialGatewayUrl = resp.body.url; + this.logger.debug(`Got Initial Gateway Base: ${this.initialGatewayUrl}`); + } + + connect = async () => { + try { + this.client.open(); + const opened = await pEvent(this.client, 'open'); + return true; + } catch (e) { + this.client.close(); + throw new Error(`Could not connect to Discord Gateway`, { cause: e.error ?? e }); + } + } + + handleIdentify() { + const data: GatewayIdentify = { + op: GatewayOpcodes.Identify, + d: { + token: this.config.token, + intents: 0, + properties: { + os: "linux", + device: "Discord Client", + browser: "Discord Client" + } + } + }; + this.client.send(JSON.stringify(data)); + } + + async handleHello(data: GatewayHelloData) { + // jitter + // https://docs.discord.com/developers/events/gateway#heartbeat-interval + const sleepTime = randomInt(data.heartbeat_interval - 1); + this.logger.debug(`Heartbeat Interval: ${data.heartbeat_interval}ms (${Math.floor(data.heartbeat_interval / 1000)}s), waiting ${Math.floor(sleepTime / 1000)}s before sending first heartbeat.`); + await sleep(sleepTime); + this.sendHeartbeat(); + this.heartbeatInterval = setInterval(() => { + if (!this.acknowledged) { + // zombied! + return this.handleReconnect().then(() => null).catch((e) => this.logger.error(e)); + } + this.sendHeartbeat(); + }, data.heartbeat_interval); + } + + sendHeartbeat() { + + const heartbeatRequest: GatewayHeartbeatRequest = { + op: GatewayOpcodes.Heartbeat, + // @ts-expect-error + d: this.sequence ?? null + } + this.acknowledged = false; + if (!this.client.OPEN) { + this.logger.warn('Cannot send heartbeat because connection is closed.'); + return; + } + this.client.send(JSON.stringify(heartbeatRequest)); + if (isDebugMode()) { + this.logger.debug('Sent heartbeat'); + } + } + + /** + * https://docs.discord.com/developers/events/gateway#identifying + * https://docs.discord.com/developers/events/gateway#ready-event + * https://docs.discord.com/developers/events/gateway-events#ready */ + handleReady(data: GatewayReadyDispatchData) { + this.session_id = data.session_id; + this.resume_gateway_url = data.resume_gateway_url; + this.user = data.user; + this.canReconnect = true; + this.ready = true; + this.logger.verbose(`Gateway Connection READY for ${this.user.username}`); + } + + /** https://docs.discord.com/developers/events/gateway-events#invalid-session */ + handleInvalidSession(data: GatewayInvalidSessionData) { + this.canReconnect = data !== false; + return this.handleReconnect().then(() => null).catch((e) => this.logger.error(e)); + } + + async handleReconnect() { + await this.cleanupConnection(); + // maybe don't do this if we've failed N times + this.initClient(); + this.connect(); + } + + handleResume() { + const data: GatewayResumeData = { + token: this.config.token, + session_id: this.session_id, + seq: this.sequence + } + + this.client.send(JSON.stringify({ op: GatewayOpcodes.Resume, d: data })); + } + + async cleanupConnection() { + clearInterval(this.heartbeatInterval); + this.heartbeatInterval = undefined; + clearTimeout(this.activityTimeout); + this.activityTimeout = undefined; + + if (!this.client.CLOSED) { + this.client.close(); + // wait for close or just give it a few seconds + await Promise.race([ + pEvent(this.client, 'close'), + sleep(3000), + ]); + } + if (!this.canReconnect) { + this.session_id = undefined; + this.sequence = undefined; + this.resume_gateway_url = undefined; + this.user = undefined; + } + } + + handleUserSessionUpdates = (data: UserSession[]) => { + this.logger.debug('Recieved updated user sessions'); + const otherSessions = data.filter(x => x.session_id !== this.session_id); + if (otherSessions.length === 0) { + this.logger.debug('No other user sessions exist, marking our session presence as inactive'); + this.lastActiveStatus = PresenceUpdateStatus.Offline; + } + if (otherSessions.some(x => x.status === 'online')) { + this.lastActiveStatus = PresenceUpdateStatus.Online; + } else if (otherSessions.some(x => x.status === 'dnd')) { + this.lastActiveStatus = PresenceUpdateStatus.DoNotDisturb; + } else if (otherSessions.some(x => x.status === 'idle')) { + this.lastActiveStatus = PresenceUpdateStatus.Idle; + } else if (otherSessions.some(x => x.status === 'invisible')) { + this.lastActiveStatus = PresenceUpdateStatus.Invisible; + } else { + this.lastActiveStatus = PresenceUpdateStatus.Offline; + } + this.logger.debug(`Best status found: ${this.lastActiveStatus}`); + } + + async handleMessage(message: _DataPayload | _NonDispatchPayload) { + try { + const { op, s } = message; + if (s !== null && s !== undefined) { + this.sequence = s; + } + if (isDebugMode()) { + const friendlyOp = opcodeToFriendly(op); + let handleHint = `Got opcode ${op}${friendlyOp !== op ? ` (${friendlyOp})` : ''}`; + if (op === GatewayOpcodes.Dispatch) { + handleHint += ` w/ Dispatch Event ${message.t}`; + } + this.logger.debug(handleHint); + } + + switch (op) { + case GatewayOpcodes.Hello: + this.handleHello(message.d as GatewayHelloData).catch(e => this.logger.error(e)); + break; + case GatewayOpcodes.HeartbeatAck: + if (isDebugMode()) { + this.logger.debug("Heartbeat acknowledged"); + } + this.acknowledged = true; + break; + case GatewayOpcodes.Heartbeat: + if (isDebugMode()) { + this.logger.debug("Received Heartbeat"); + } + this.sendHeartbeat(); + break; + case GatewayOpcodes.Dispatch: + const { t } = message; + switch (t) { + case GatewayDispatchEvents.Ready: + this.handleReady(message.d as GatewayReadyDispatchData); + break; + // @ts-expect-error + case 'SESSIONS_REPLACE': + if (isDebugMode()) { + // @ts-expect-error + this.logger.debug({ data: message.d }, t); + } + break; + }; + break; + case GatewayOpcodes.InvalidSession: + this.handleInvalidSession(message.d as GatewayInvalidSessionData); + break; + case GatewayOpcodes.Reconnect: + await this.handleReconnect(); + break; + case GatewayOpcodes.Resume: + this.logger.debug({ data: message.d }, 'Resumed session'); + this.canReconnect = true; + this.ready = true; + break; + case GatewayOpcodes.Identify: + this.logger.debug({ data: message.d }, 'Recieved Identifiy opcode'); + break; + case GatewayOpcodes.PresenceUpdate: + this.logger.debug({ data: message.d }, 'Recieved Presence Update opcode'); + break; + default: + this.logger.debug(`Recieved unhandled opcode: ${op}`); + break; + } + } catch (error) { + throw new Error('Error handling gateway message', { cause: error }); + } + } + + playStateToActivity = (data: SourceData): GatewayActivity => { + const { activity, artUrl } = playStateToActivityData(data); + const { + artwork = false + } = this.config; + + let art = ARTWORK_PLACEHOLDER; + if (artUrl !== undefined && artwork !== false) { + if (Array.isArray(artwork)) { + const allowed = artwork.some(x => artUrl.toLocaleLowerCase().includes(x.toLocaleLowerCase())); + if (allowed) { + art = artUrl; + } + } else { + const u = new URL(artUrl); + // only allow secure protocol as this is likely to be a real domain that is public accessible + // IP domain usually uses http only + if (u.protocol === 'https://') { + art = artUrl; + } + } + } + // https://docs.discord.com/developers/events/gateway-events#activity-object-activity-assets + // https://docs.discord.com/developers/events/gateway-events#activity-object-activity-asset-image + activity.assets.large_image = art; + + return activity; + } + + sendActivity = async (data: SourceData) => { + + if ([PresenceUpdateStatus.Offline, PresenceUpdateStatus.Invisible].includes(this.lastActiveStatus)) { + this.logger.debug('Not updating presence because no user sessions have a visible status'); + return; + } + const [sendOk, reasons] = this.checkOkToSend(); + if (!sendOk) { + this.logger.warn(`Cannot send activity because client is ${reasons}`); + return; + } + const activity = this.playStateToActivity(data); + + let clearTime = dayjs().add(5, 'minutes'); + if (activity.timestamps.end !== undefined) { + clearTime = dayjs.unix(Math.floor(activity.timestamps.end as number / 1000)); + } + + const updateData = this.generatePresenceUpdate(); + updateData.activities.push(activity); + + const currentActivity: GatewayUpdatePresence = { + op: GatewayOpcodes.PresenceUpdate, + d: updateData + } + + this.client.send(JSON.stringify(currentActivity)); + + if (this.activityTimeout !== undefined) { + clearTimeout(this.activityTimeout); + } + this.activityTimeout = setTimeout(() => { + this.clearActivity(); + }, Math.abs(clearTime.diff(dayjs(), 'ms'))); + } + + clearActivity = () => { + if (this.activityTimeout !== undefined) { + clearTimeout(this.activityTimeout); + this.activityTimeout = undefined; + } + + const [sendOk, reasons] = this.checkOkToSend(); + if (!sendOk) { + this.logger.warn(`Cannot clear activity because client is ${reasons}`); + return; + } + + const clearedActivity: GatewayUpdatePresence = { + op: GatewayOpcodes.PresenceUpdate, + d: this.generatePresenceUpdate() + } + this.client.send(JSON.stringify(clearedActivity)); + + } + + generatePresenceUpdate = (): GatewayPresenceUpdateData => { + return { + since: null, + activities: [], + status: this.lastActiveStatus, + // TODO determine this? + afk: this.lastActiveStatus === PresenceUpdateStatus.Idle + } + } + + checkOkToSend = (): [boolean, string?] => { + if (this.ready && this.client.OPEN) { + return [true]; + } + const reasons = []; + if (!this.ready) { + reasons.push('not ready'); + } + if (!this.client.OPEN) { + reasons.push('socket not open'); + } + return [false, reasons.join(' and ')]; + } +} + +const opcodeToFriendly = (op: number) => { + switch (op) { + case GatewayOpcodes.Hello: + return 'Hello'; + case GatewayOpcodes.HeartbeatAck: + return 'HeartbeatAck' + case GatewayOpcodes.Heartbeat: + return 'Heartbeat'; + case GatewayOpcodes.Dispatch: + return 'Dispatch'; + case GatewayOpcodes.InvalidSession: + return 'InvalidSession'; + case GatewayOpcodes.Reconnect: + return 'Reconnect'; + case GatewayOpcodes.Resume: + return 'Resume'; + case GatewayOpcodes.Identify: + return 'Identify'; + case GatewayOpcodes.PresenceUpdate: + return 'PresenceUpdate' + default: + return op; + } +} + +interface UserSession { + status: 'online' | 'invisible' | 'dnd' | 'idle' + active?: boolean + session_id: string + activities: [] +} + +export const playStateToActivityData = (data: SourceData, opts: { useArt?: boolean } = {}): { activity: GatewayActivity, artUrl?: string } => { + // unix timestamps in milliseconds + let startTime: number, + endTime: number; + + let play: PlayObject; + if (isPlayObject(data)) { + play = data; + if (data.meta.trackProgressPosition !== undefined && play.data.duration !== undefined) { + startTime = dayjs().subtract(data.meta.trackProgressPosition, 's').unix() * 1000; + endTime = dayjs().add(data.data.duration - data.meta.trackProgressPosition, 's').unix() * 1000; + } else if (asPlayerStateData(data)) { + play = data.play; + if (data.position !== undefined && play.data.duration !== undefined) { + startTime = dayjs().subtract(data.position, 's').unix() * 1000; + endTime = dayjs().add(data.data.duration - data.position, 's').unix() * 1000; + } + } + } + + let activityName = 'Music'; + if (play.meta?.source !== undefined) { + activityName = capitalize(play.meta.source) + } + + // @ts-expect-error + const activity = removeUndefinedKeys({ + // https://docs.discord.com/developers/events/gateway-events#activity-object + type: 2, // Listening + // https://docs.discord.com/developers/events/gateway-events#activity-object + status_display_type: 1, // state + name: activityName, + details: play.data.track, + state: play.data.artists !== undefined && play.data.artists.length > 0 ? play.data.artists.join(' / ') : undefined, + // https://docs.discord.com/developers/events/gateway-events#activity-object-activity-assets + // https://docs.discord.com/developers/events/gateway-events#activity-object-activity-asset-image + assets: { + large_text: play.data.album + } + }); + if (endTime !== undefined && startTime !== undefined) { + activity.timestamps = { + start: startTime, + end: endTime + } + } + + const { + art: { + album, + track, + artist + } = {} + } = play.meta ?? {}; + + const artUrl = album ?? track ?? artist; + + return { activity, artUrl }; +} \ No newline at end of file diff --git a/src/backend/scrobblers/DiscordScrobbler.ts b/src/backend/scrobblers/DiscordScrobbler.ts new file mode 100644 index 00000000..a537fd33 --- /dev/null +++ b/src/backend/scrobblers/DiscordScrobbler.ts @@ -0,0 +1,71 @@ +import { Logger } from "@foxxmd/logging"; +import EventEmitter from "events"; +import { PlayObject } from "../../core/Atomic.js"; +import { FormatPlayObjectOptions } from "../common/infrastructure/Atomic.js"; +import { Notifiers } from "../notifier/Notifiers.js"; + +import AbstractScrobbleClient, { nowPlayingUpdateByPlayDuration } from "./AbstractScrobbleClient.js"; +import { DiscordClientConfig } from "../common/infrastructure/config/client/discord.js"; +import { DiscordWSClient } from "../common/vendor/discord/DiscordWSClient.js"; + +export default class DiscordScrobbler extends AbstractScrobbleClient { + + api: DiscordWSClient; + requiresAuth = true; + requiresAuthInteraction = false; + + declare config: DiscordClientConfig; + + constructor(name: any, config: DiscordClientConfig, options = {}, notifier: Notifiers, emitter: EventEmitter, logger: Logger) { + super('discord', name, config, notifier, emitter, logger); + this.api = new DiscordWSClient(name, { ...config.data, ...config.options }, { logger: this.logger }); + this.supportsNowPlaying = true; + this.nowPlayingMaxThreshold = nowPlayingUpdateByPlayDuration; + } + + formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => obj; + + protected async doBuildInitData(): Promise { + const { + data: { + token + } = {} + } = this.config; + if (token === undefined) { + throw new Error('Must provide a user token'); + } + await this.api.initClient(); + return true; + } + + doAuthentication = async () => { + + try { + return await this.api.connect(); + } catch (e) { + throw e; + } + } + + getScrobblesForRefresh = async (limit: number) => { + return []; + } + + alreadyScrobbled = async (playObj: PlayObject, log = false) => true + + public playToClientPayload(playObj: PlayObject): any { + return this.api.playStateToActivity(playObj); + } + + doScrobble = async (playObj: PlayObject) => { + return { play: playObj, payload: {} }; + } + + doPlayingNow = async (data: PlayObject) => { + try { + await this.api.sendActivity(data); + } catch (e) { + throw e; + } + } +} diff --git a/src/backend/scrobblers/ScrobbleClients.ts b/src/backend/scrobblers/ScrobbleClients.ts index ccf0068d..802d80c1 100644 --- a/src/backend/scrobblers/ScrobbleClients.ts +++ b/src/backend/scrobblers/ScrobbleClients.ts @@ -21,6 +21,7 @@ import { CommonClientOptions } from '../common/infrastructure/config/client/inde import { ExternalMetadataTerm, PlayTransformHooks } from '../common/infrastructure/Transform.js'; import { LibrefmClientConfig } from '../common/infrastructure/config/client/librefm.js'; import clone from 'clone'; +import { DiscordClientConfig } from '../common/infrastructure/config/client/discord.js'; type groupedNamedConfigs = {[key: string]: ParsedConfig[]}; @@ -108,6 +109,8 @@ export default class ScrobbleClients { return "TealClientConfig"; case 'rocksky': return "RockSkyClientConfig"; + case 'discord': + return 'DiscordClientConfig'; } } @@ -430,6 +433,10 @@ ${sources.join('\n')}`); const RockskyScrobbler = (await import('./RockskyScrobbler.js')).default; newClient = new RockskyScrobbler(name, {...clientConfig, data: {configDir: this.internalConfig.configDir, ...data} } as unknown as RockSkyClientConfig, {}, notifier, this.emitter, this.logger); break; + case 'discord': + const DiscordScrobbler = (await import('./DiscordScrobbler.js')).default; + newClient = new DiscordScrobbler(name, {...clientConfig, data: {configDir: this.internalConfig.configDir, ...data} } as unknown as DiscordClientConfig, {}, notifier, this.emitter, this.logger); + break; default: break; } -- 2.51.2 From 1b93512b0a0b90060d01e53c9079219d8eea2f1c Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 13 Feb 2026 19:47:05 +0000 Subject: [PATCH 02/18] feat(discord): artwork fallback and ENV config --- config/discord.json.example | 10 ++++++++++ .../infrastructure/config/client/discord.ts | 4 ++-- .../common/vendor/discord/DiscordWSClient.ts | 19 ++++++++++++++++--- src/backend/scrobblers/ScrobbleClients.ts | 18 ++++++++++++++++++ 4 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 config/discord.json.example diff --git a/config/discord.json.example b/config/discord.json.example new file mode 100644 index 00000000..6b10ac6c --- /dev/null +++ b/config/discord.json.example @@ -0,0 +1,10 @@ +[ + { + "name": "MS", + "enable": true, + "data": { + "token": "Ma7XBvzRERK3gQdkX4dkb3OQ.6aaGmJ.rvFiiVrzG4VXHHL3LyhZYhpWdG3Ph_if9aKz5E" + } + } + ] + \ No newline at end of file diff --git a/src/backend/common/infrastructure/config/client/discord.ts b/src/backend/common/infrastructure/config/client/discord.ts index 05d530bf..839fd95e 100644 --- a/src/backend/common/infrastructure/config/client/discord.ts +++ b/src/backend/common/infrastructure/config/client/discord.ts @@ -2,9 +2,9 @@ import { RequestRetryOptions } from "../common.js" import { CommonClientConfig, CommonClientData } from "./index.js" export interface DiscordData { - //applicationId: string token: string - artwork?: boolean | string[] + artwork?: boolean | string | string[] + artworkDefaultUrl?: string } export interface DiscordClientData extends DiscordData, CommonClientData {} diff --git a/src/backend/common/vendor/discord/DiscordWSClient.ts b/src/backend/common/vendor/discord/DiscordWSClient.ts index 1b5f97b0..52d6b94e 100644 --- a/src/backend/common/vendor/discord/DiscordWSClient.ts +++ b/src/backend/common/vendor/discord/DiscordWSClient.ts @@ -2,7 +2,7 @@ import { childLogger } from "@foxxmd/logging"; import { WS } from 'iso-websocket' import { DiscordClientData } from "../../infrastructure/config/client/discord.js"; import { _DataPayload, _NonDispatchPayload, APIUser, GatewayActivity, GatewayActivityUpdateData, GatewayDispatchEvents, GatewayHeartbeatRequest, GatewayHelloData, GatewayIdentify, GatewayIdentifyData, GatewayInvalidSessionData, GatewayOpcodes, GatewayPresenceUpdateData, GatewayReadyDispatchData, GatewayResumeData, GatewayUpdatePresence, PresenceUpdateStatus } from "discord.js"; -import { isDebugMode, removeUndefinedKeys, sleep } from "../../../utils.js"; +import { isDebugMode, parseBool, removeUndefinedKeys, sleep } from "../../../utils.js"; import pEvent from 'p-event'; import { randomInt } from "crypto"; import request from 'superagent'; @@ -11,6 +11,7 @@ import { AbstractApiOptions, asPlayerStateData, SourceData } from "../../infrast import { isPlayObject, PlayObject } from "../../../../core/Atomic.js"; import dayjs from "dayjs"; import { capitalize } from "../../../../core/StringUtils.js"; +import { parseArrayFromMaybeString } from "../../../utils/StringUtils.js"; const ARTWORK_PLACEHOLDER = 'https://raw.githubusercontent.com/FoxxMD/multi-scrobbler/master/assets/icon128.png'; const API_GATEWAY_ENDPOINT = 'https://discord.com/api/gateway'; @@ -52,9 +53,20 @@ export class DiscordWSClient extends AbstractApiClient { activityTimeout: NodeJS.Timeout; + artworkOpt: boolean | string[] = false; + constructor(name: any, config: DiscordClientData, options: AbstractApiOptions) { super('Discord', name, config, options); this.logger = childLogger(options.logger, 'WS Gateway'); + if(typeof this.config.artwork === 'boolean' || Array.isArray(this.config.artwork)) { + this.artworkOpt = this.config.artwork; + } else if(typeof this.config.artwork === 'string') { + if(['true','false'].includes(this.config.artwork.toLocaleLowerCase())) { + this.artworkOpt = parseBool(this.config.artwork) + } else { + this.artworkOpt = parseArrayFromMaybeString(this.config.artwork) + } + } } initClient = async (baseUrl?: string) => { @@ -330,11 +342,12 @@ export class DiscordWSClient extends AbstractApiClient { playStateToActivity = (data: SourceData): GatewayActivity => { const { activity, artUrl } = playStateToActivityData(data); + const artwork = this.artworkOpt; const { - artwork = false + artworkDefaultUrl = ARTWORK_PLACEHOLDER } = this.config; - let art = ARTWORK_PLACEHOLDER; + let art = artworkDefaultUrl; if (artUrl !== undefined && artwork !== false) { if (Array.isArray(artwork)) { const allowed = artwork.some(x => artUrl.toLocaleLowerCase().includes(x.toLocaleLowerCase())); diff --git a/src/backend/scrobblers/ScrobbleClients.ts b/src/backend/scrobblers/ScrobbleClients.ts index 802d80c1..134f584a 100644 --- a/src/backend/scrobblers/ScrobbleClients.ts +++ b/src/backend/scrobblers/ScrobbleClients.ts @@ -299,6 +299,24 @@ export default class ScrobbleClients { }) } break; + case 'discord': { + const discord = { + token: process.env.DISCORD_TOKEN, + artwork: process.env.DISCORD_ARTWORK, + artworkDefaultUrl: process.env.DISCORD_ARTWORK_DEFAULT_URL + } + if (!Object.values(discord).every(x => x === undefined)) { + configs.push({ + type: 'discord', + name: 'unnamed-discord', + source: 'ENV', + mode: 'single', + configureAs: 'client', + data: discord, + options: transformPresetEnv('DISCORD') + }) + } + } break; default: break; } -- 2.51.2 From 883797a51d0835f45bbce4af869b48ff947f1994 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 13 Feb 2026 19:47:18 +0000 Subject: [PATCH 03/18] docs(discord): Add discord docs --- .../docs/configuration/clients/discord.mdx | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 docsite/docs/configuration/clients/discord.mdx diff --git a/docsite/docs/configuration/clients/discord.mdx b/docsite/docs/configuration/clients/discord.mdx new file mode 100644 index 00000000..4ff9583d --- /dev/null +++ b/docsite/docs/configuration/clients/discord.mdx @@ -0,0 +1,75 @@ +--- +title: Discord +toc_min_heading_level: 2 +toc_max_heading_level: 5 +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; +import CodeBlock from '@theme/CodeBlock'; +import JsonConfig from '!!raw-loader!@site/../config/discord.json.example'; + +This scrobbler uses **[Now Playing](/configuration/clients#now-playing)** functionality to set your Discord [Rich Presence](https://docs.discord.com/developers/rich-presence/overview) to the music you are currently monitoring with multi-scrobbler. + +:::warning[Discord TOS Violation] + +This functionality requires you to use your own **User Token** in a way that is **against Discord's policies/terms of service.** If you use this scrobbler you do so **at your risk**. + +Refer to the **How MS Uses Discord API** section below to gauge whether this risk is acceptable to you. + +::: + + + +Multi-scrobbler does its upmost to implement, to-spec, [Gateway API communication](https://docs.discord.com/developers/events/gateway) and respect usage. It requires no [permissions](https://docs.discord.com/developers/events/gateway#gateway-intents) and reads no data other than monitoring what other user sessions are active. + +Aside from the on-paper violation of using a user token programmatically (on your behalf), there is no misuse of the API to achieve rich presence. + +Additionally, MS uses the state of non-MS sessions to determine if it should update presence at all. Essentially, it will not act on your behalf unless you are: + +* using an official client +* physically online +* not invisible + + + +## User Token + +You must provide a User Token for this scrobbler to work. + +No instructions will be provided for obtaining a User Token because it is against Discord's policies to use it. Instead, you can search online for "how to obtain discord user token". + +:::warning + +Treat your User Token like a login credential. Anyone who has your token can access Discord as you. + +When finding instructions for obtaining this token only run commands, or follow steps, that you can understand and trust. Do not run commands you do not understand. Never give your token to someone else. + +::: + +## Artwork + +For some Sources, MS parses album art and displays it in the dashboard. This image can be displayed on Discord alongside your listening status. + +However, Discord needs a *direct, internet-facing* URL to correctly display this image which may not be feasible, or desirable, based on each of your Sources. For example: + +* A Source may be accessible only on your LAN (Jellyfin at `http://192.168.0.101`) +* A Source may be internet-facing but you do not want to expose this information to discord/other users +* A Source may be internet-facing but requires authentication to view the image + +MS is conservative in this context by **disallowing** use of album art URLs **by default.** Additionally, it **always** disallows use of these URLs if they do not start with `https`. + +* To allow **any** `https` album art URLs set ENV `DISCORD_ARTWORK=true` +* To allow only `https` album art URLs from certain domains use a list of keywords like `DISCORD_ARTWORK=spotify,jellyfin` (or in file as `"artwork": ["spotify","jellyfin"]`) + +If listening activity does not have album art, or the URL is disallowed, MS will use a fallback image of the multi-scrobbler logo. You can customize this fallback image with ENV `DISCORD_ARTWORK_DEFAULT_URL` or in file config with `artworkDefaultUrl` + +## Configuration + + + | Environmental Variable | Required? | Default | Description | + | :---------------------------- | --------- | ------- | :--------------------------------------------------------------------------------------------------------- | + | `DISCORD_TOKEN` | Yes | | User Token acquired from an active Discord sessions | + | `DISCORD_ARTWORK` | No | | A boolean indicating if external artwork URLs should be used. Or a comma-separated list of allowed domains | + | `DISCORD_ARTWORK_DEFAULT_URL` | No | | A URL of an image to use as a fallback if the album art URL cannot be used | + \ No newline at end of file -- 2.51.2 From 1456070044f7b70f5d8fb7dfa65fac7b30605ef6 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 13 Feb 2026 20:42:23 +0000 Subject: [PATCH 04/18] fix(discord): Better error handling and reconnect logic --- .../common/vendor/discord/DiscordWSClient.ts | 85 +++++++++++++++---- src/backend/scrobblers/DiscordScrobbler.ts | 7 ++ 2 files changed, 76 insertions(+), 16 deletions(-) diff --git a/src/backend/common/vendor/discord/DiscordWSClient.ts b/src/backend/common/vendor/discord/DiscordWSClient.ts index 52d6b94e..2455eba5 100644 --- a/src/backend/common/vendor/discord/DiscordWSClient.ts +++ b/src/backend/common/vendor/discord/DiscordWSClient.ts @@ -1,9 +1,10 @@ import { childLogger } from "@foxxmd/logging"; import { WS } from 'iso-websocket' import { DiscordClientData } from "../../infrastructure/config/client/discord.js"; -import { _DataPayload, _NonDispatchPayload, APIUser, GatewayActivity, GatewayActivityUpdateData, GatewayDispatchEvents, GatewayHeartbeatRequest, GatewayHelloData, GatewayIdentify, GatewayIdentifyData, GatewayInvalidSessionData, GatewayOpcodes, GatewayPresenceUpdateData, GatewayReadyDispatchData, GatewayResumeData, GatewayUpdatePresence, PresenceUpdateStatus } from "discord.js"; +import { _DataPayload, _NonDispatchPayload, APIUser, GatewayActivity, GatewayActivityUpdateData, GatewayCloseCodes, GatewayDispatchEvents, GatewayHeartbeatRequest, GatewayHelloData, GatewayIdentify, GatewayIdentifyData, GatewayInvalidSessionData, GatewayOpcodes, GatewayPresenceUpdateData, GatewayReadyDispatchData, GatewayResumeData, GatewayUpdatePresence, PresenceUpdateStatus } from "discord.js"; import { isDebugMode, parseBool, removeUndefinedKeys, sleep } from "../../../utils.js"; import pEvent from 'p-event'; +import EventEmitter from "events"; import { randomInt } from "crypto"; import request from 'superagent'; import AbstractApiClient from "../AbstractApiClient.js"; @@ -48,6 +49,8 @@ export class DiscordWSClient extends AbstractApiClient { canReconnect?: boolean = false; ready: boolean = false; + authOK?: boolean + closeEvents: number = 0; lastActiveStatus?: PresenceUpdateStatus = PresenceUpdateStatus.Offline; @@ -55,9 +58,12 @@ export class DiscordWSClient extends AbstractApiClient { artworkOpt: boolean | string[] = false; + emitter: EventEmitter; + constructor(name: any, config: DiscordClientData, options: AbstractApiOptions) { super('Discord', name, config, options); this.logger = childLogger(options.logger, 'WS Gateway'); + this.emitter = new EventEmitter(); if(typeof this.config.artwork === 'boolean' || Array.isArray(this.config.artwork)) { this.artworkOpt = this.config.artwork; } else if(typeof this.config.artwork === 'string') { @@ -69,13 +75,20 @@ export class DiscordWSClient extends AbstractApiClient { } } - initClient = async (baseUrl?: string) => { - if (baseUrl === undefined && this.initialGatewayUrl === undefined) { - try { - await this.fetchGatewayUrl(); + initClient = async () => { + let baseUrl: string; + if(this.resume_gateway_url !== undefined) { + baseUrl = this.resume_gateway_url; + } else { + if (this.initialGatewayUrl === undefined) { + try { + await this.fetchGatewayUrl(); + baseUrl = this.initialGatewayUrl; + } catch (e) { + throw new Error('Could not get initial gateway url', { cause: e }); + } + } else { baseUrl = this.initialGatewayUrl; - } catch (e) { - throw new Error('Could not get initial gateway url', { cause: e }); } } @@ -92,10 +105,32 @@ export class DiscordWSClient extends AbstractApiClient { this.client.addEventListener('retry', (e) => { this.logger.verbose(`Retrying connection, attempt ${e.attempt}`); }); - this.client.addEventListener('close', (e) => { + this.client.addEventListener('close', async (e) => { // should receive a close code https://docs.discord.com/developers/topics/opcodes-and-status-codes#gateway-gateway-close-event-codes // which determines if reconnect is possible this.logger.warn(`Connection was closed: ${e.code} => ${e.reason}`); + if([ + GatewayCloseCodes.AuthenticationFailed, + GatewayCloseCodes.InvalidShard, + GatewayCloseCodes.ShardingRequired, + GatewayCloseCodes.InvalidAPIVersion, + GatewayCloseCodes.InvalidIntents, + GatewayCloseCodes.DisallowedIntents + ].includes(e.code)) { + this.canReconnect = false; + } + if(GatewayCloseCodes.AuthenticationFailed === e.code) { + await this.cleanupConnection(); + this.authOK = false; + this.emitter.emit('stopped', {authFailure: true}); + // don't attempt to reconnect, will always fail + } else if(this.closeEvents < 3) { + this.closeEvents++; + await this.handleReconnect(); + } else { + await this.cleanupConnection(); + this.emitter.emit('stopped', {authFailure: false}); + } }); this.client.addEventListener('open', (e) => { this.logger.verbose(`Connection was established.`); @@ -109,7 +144,6 @@ export class DiscordWSClient extends AbstractApiClient { } }); this.client.addEventListener('error', (e) => { - // may return a close code? this.logger.error(new Error(`Error from Discord Gateway`, { cause: e.error })); }); @@ -161,8 +195,18 @@ export class DiscordWSClient extends AbstractApiClient { const sleepTime = randomInt(data.heartbeat_interval - 1); this.logger.debug(`Heartbeat Interval: ${data.heartbeat_interval}ms (${Math.floor(data.heartbeat_interval / 1000)}s), waiting ${Math.floor(sleepTime / 1000)}s before sending first heartbeat.`); await sleep(sleepTime); + const [isOk] = this.checkOkToSend(); + if(!isOk) { + return; + } this.sendHeartbeat(); this.heartbeatInterval = setInterval(() => { + if(this.client.OPEN !== this.client.readyState) { + if(this.heartbeatInterval !== undefined) { + clearInterval(this.heartbeatInterval); + } + return; + } if (!this.acknowledged) { // zombied! return this.handleReconnect().then(() => null).catch((e) => this.logger.error(e)); @@ -179,8 +223,8 @@ export class DiscordWSClient extends AbstractApiClient { d: this.sequence ?? null } this.acknowledged = false; - if (!this.client.OPEN) { - this.logger.warn('Cannot send heartbeat because connection is closed.'); + if (this.client.OPEN !== this.client.readyState) { + this.logger.debug('Cannot send heartbeat because connection is closed.'); return; } this.client.send(JSON.stringify(heartbeatRequest)); @@ -199,6 +243,8 @@ export class DiscordWSClient extends AbstractApiClient { this.user = data.user; this.canReconnect = true; this.ready = true; + this.authOK = true; + this.closeEvents = 0; this.logger.verbose(`Gateway Connection READY for ${this.user.username}`); } @@ -231,7 +277,7 @@ export class DiscordWSClient extends AbstractApiClient { clearTimeout(this.activityTimeout); this.activityTimeout = undefined; - if (!this.client.CLOSED) { + if (this.client.CLOSED !== this.client.readyState) { this.client.close(); // wait for close or just give it a few seconds await Promise.race([ @@ -239,6 +285,7 @@ export class DiscordWSClient extends AbstractApiClient { sleep(3000), ]); } + this.ready = false; if (!this.canReconnect) { this.session_id = undefined; this.sequence = undefined; @@ -311,19 +358,25 @@ export class DiscordWSClient extends AbstractApiClient { // @ts-expect-error this.logger.debug({ data: message.d }, t); } + // @ts-expect-error + this.handleUserSessionUpdates(message.d as UserSession[]); break; }; break; case GatewayOpcodes.InvalidSession: + this.logger.debug('Recieved invalid session opcode'); this.handleInvalidSession(message.d as GatewayInvalidSessionData); break; case GatewayOpcodes.Reconnect: + this.logger.debug('Recieved reconnect opcode'); await this.handleReconnect(); break; case GatewayOpcodes.Resume: - this.logger.debug({ data: message.d }, 'Resumed session'); + this.logger.debug({ data: message.d }, 'Recieved Resumed session'); this.canReconnect = true; this.ready = true; + this.authOK = true; + this.closeEvents = 0; break; case GatewayOpcodes.Identify: this.logger.debug({ data: message.d }, 'Recieved Identifiy opcode'); @@ -437,15 +490,15 @@ export class DiscordWSClient extends AbstractApiClient { } checkOkToSend = (): [boolean, string?] => { - if (this.ready && this.client.OPEN) { + if (this.ready && this.client.OPEN === this.client.readyState) { return [true]; } const reasons = []; if (!this.ready) { reasons.push('not ready'); } - if (!this.client.OPEN) { - reasons.push('socket not open'); + if (this.client.OPEN !== this.client.readyState) { + reasons.push(`socket not open (${this.client.readyState})`); } return [false, reasons.join(' and ')]; } diff --git a/src/backend/scrobblers/DiscordScrobbler.ts b/src/backend/scrobblers/DiscordScrobbler.ts index a537fd33..cb8fcef3 100644 --- a/src/backend/scrobblers/DiscordScrobbler.ts +++ b/src/backend/scrobblers/DiscordScrobbler.ts @@ -19,6 +19,13 @@ export default class DiscordScrobbler extends AbstractScrobbleClient { constructor(name: any, config: DiscordClientConfig, options = {}, notifier: Notifiers, emitter: EventEmitter, logger: Logger) { super('discord', name, config, notifier, emitter, logger); this.api = new DiscordWSClient(name, { ...config.data, ...config.options }, { logger: this.logger }); + this.api.emitter.on('stopped', async (e) => { + if(e.authFailure) { + this.authFailure = true; + this.authed = false; + } + await this.tryStopScrobbling(); + }); this.supportsNowPlaying = true; this.nowPlayingMaxThreshold = nowPlayingUpdateByPlayDuration; } -- 2.51.2 From 3aada673d510b18db19807f362aabcccd6f8ce19 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 13 Feb 2026 22:00:40 +0000 Subject: [PATCH 05/18] feat(discord): Add album art functionality --- .../docs/configuration/clients/clients.mdx | 1 + .../docs/configuration/clients/discord.mdx | 41 +++++++-- docsite/docs/index.mdx | 1 + docsite/static/img/discord_presence.png | Bin 0 -> 9155 bytes .../infrastructure/config/client/discord.ts | 3 +- .../common/vendor/discord/DiscordWSClient.ts | 84 ++++++++++++++---- src/backend/scrobblers/DiscordScrobbler.ts | 4 +- 7 files changed, 107 insertions(+), 27 deletions(-) create mode 100644 docsite/static/img/discord_presence.png diff --git a/docsite/docs/configuration/clients/clients.mdx b/docsite/docs/configuration/clients/clients.mdx index 586f4eab..e7468a2d 100644 --- a/docsite/docs/configuration/clients/clients.mdx +++ b/docsite/docs/configuration/clients/clients.mdx @@ -12,6 +12,7 @@ A **Client** is an application that stores the historical information about what | Name | Now Playing | | :-------------------------------------------------- | :---------- | +| [Discord](/configuration/clients/discord) | ✅ | | [Last.fm](/configuration/clients/lastfm) | ✅ | | [Libre.fm](/configuration/clients/librefm) | ✅ | | [Listenbrainz](/configuration/clients/listenbrainz) | ✅ | diff --git a/docsite/docs/configuration/clients/discord.mdx b/docsite/docs/configuration/clients/discord.mdx index 4ff9583d..35432da0 100644 --- a/docsite/docs/configuration/clients/discord.mdx +++ b/docsite/docs/configuration/clients/discord.mdx @@ -11,6 +11,8 @@ import JsonConfig from '!!raw-loader!@site/../config/discord.json.example'; This scrobbler uses **[Now Playing](/configuration/clients#now-playing)** functionality to set your Discord [Rich Presence](https://docs.discord.com/developers/rich-presence/overview) to the music you are currently monitoring with multi-scrobbler. + + :::warning[Discord TOS Violation] This functionality requires you to use your own **User Token** in a way that is **against Discord's policies/terms of service.** If you use this scrobbler you do so **at your risk**. @@ -49,9 +51,28 @@ When finding instructions for obtaining this token only run commands, or follow ## Artwork -For some Sources, MS parses album art and displays it in the dashboard. This image can be displayed on Discord alongside your listening status. +For some Sources, MS parses album art and displays it in the dashboard. If a Discord **Application Id** is provided then this image can be displayed on Discord alongside your listening status. + +If no Application Id is provided Discord will always show a default image next to your status. + +
+ +Create An Application + +Go to the [Discord Developer Portal](https://discord.com/developers/applications) + +* Click on "New Application" and give it a name +* Copy the **Application Id** (Client Id) shown after creation -However, Discord needs a *direct, internet-facing* URL to correctly display this image which may not be feasible, or desirable, based on each of your Sources. For example: +
+ +Discord needs an **external, internet-facing** URL to correctly display this image which may not be feasible, or desirable, based on the Sources you are using. Multi-scrobbler disallows **all** external URLs by default. + +
+ +Using External URLs + +There are some scenarios where you may not want to use your artwork URLs, for example: * A Source may be accessible only on your LAN (Jellyfin at `http://192.168.0.101`) * A Source may be internet-facing but you do not want to expose this information to discord/other users @@ -62,14 +83,18 @@ MS is conservative in this context by **disallowing** use of album art URLs **by * To allow **any** `https` album art URLs set ENV `DISCORD_ARTWORK=true` * To allow only `https` album art URLs from certain domains use a list of keywords like `DISCORD_ARTWORK=spotify,jellyfin` (or in file as `"artwork": ["spotify","jellyfin"]`) -If listening activity does not have album art, or the URL is disallowed, MS will use a fallback image of the multi-scrobbler logo. You can customize this fallback image with ENV `DISCORD_ARTWORK_DEFAULT_URL` or in file config with `artworkDefaultUrl` +If a listening activity does not have album art, or the URL is disallowed, MS will use the [multi-scrobbler logo](https://github.com/FoxxMD/multi-scrobbler/blob/master/assets/icon.png) as a fallback image. + +You can customize this fallback image with ENV `DISCORD_ARTWORK_DEFAULT_URL` or in file config with `artworkDefaultUrl`. If you were prefer to use the discord default image set this value to `false`. + +
## Configuration - | Environmental Variable | Required? | Default | Description | - | :---------------------------- | --------- | ------- | :--------------------------------------------------------------------------------------------------------- | - | `DISCORD_TOKEN` | Yes | | User Token acquired from an active Discord sessions | - | `DISCORD_ARTWORK` | No | | A boolean indicating if external artwork URLs should be used. Or a comma-separated list of allowed domains | - | `DISCORD_ARTWORK_DEFAULT_URL` | No | | A URL of an image to use as a fallback if the album art URL cannot be used | + | Environmental Variable | Required? | Default | Description | + | :---------------------------- | --------- | ------- | :--------------------------------------------------------------------------------------------------------------- | + | `DISCORD_TOKEN` | Yes | | User Token acquired from an active Discord sessions | + | `DISCORD_ARTWORK` | No | | A boolean indicating if external artwork URLs should be used. Or a comma-separated list of allowed domains | + | `DISCORD_ARTWORK_DEFAULT_URL` | No | | A URL of an image to use as a fallback if the album art URL cannot be used. Or `false` to use discord's default. | \ No newline at end of file diff --git a/docsite/docs/index.mdx b/docsite/docs/index.mdx index 9ec147da..47b64640 100644 --- a/docsite/docs/index.mdx +++ b/docsite/docs/index.mdx @@ -42,6 +42,7 @@ A dockerized app that monitors your music listening activity from *everywhere* a * [Yamaha MusicCast](/configuration/sources/yamaha-musiccast) * [Youtube Music](/configuration/sources/youtube-music) * Supports scrobbling to many [**Clients**](/configuration/clients) + * [Discord](/configuration/clients/discord) (Now Playing) * [Koito](/configuration/clients/koito) * [Last.fm](/configuration/clients/lastfm) * [Libre.fm](/configuration/clients/librefm) diff --git a/docsite/static/img/discord_presence.png b/docsite/static/img/discord_presence.png new file mode 100644 index 0000000000000000000000000000000000000000..b88cacfee22aad5ab4eb04030cb31aa5a99b40af GIT binary patch literal 9155 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 { let baseUrl: string; - if(this.resume_gateway_url !== undefined) { + if (this.resume_gateway_url !== undefined) { baseUrl = this.resume_gateway_url; } else { if (this.initialGatewayUrl === undefined) { @@ -109,7 +120,7 @@ export class DiscordWSClient extends AbstractApiClient { // should receive a close code https://docs.discord.com/developers/topics/opcodes-and-status-codes#gateway-gateway-close-event-codes // which determines if reconnect is possible this.logger.warn(`Connection was closed: ${e.code} => ${e.reason}`); - if([ + if ([ GatewayCloseCodes.AuthenticationFailed, GatewayCloseCodes.InvalidShard, GatewayCloseCodes.ShardingRequired, @@ -119,17 +130,17 @@ export class DiscordWSClient extends AbstractApiClient { ].includes(e.code)) { this.canReconnect = false; } - if(GatewayCloseCodes.AuthenticationFailed === e.code) { + if (GatewayCloseCodes.AuthenticationFailed === e.code) { await this.cleanupConnection(); this.authOK = false; - this.emitter.emit('stopped', {authFailure: true}); + this.emitter.emit('stopped', { authFailure: true }); // don't attempt to reconnect, will always fail - } else if(this.closeEvents < 3) { + } else if (this.closeEvents < 3) { this.closeEvents++; await this.handleReconnect(); } else { await this.cleanupConnection(); - this.emitter.emit('stopped', {authFailure: false}); + this.emitter.emit('stopped', { authFailure: false }); } }); this.client.addEventListener('open', (e) => { @@ -196,13 +207,13 @@ export class DiscordWSClient extends AbstractApiClient { this.logger.debug(`Heartbeat Interval: ${data.heartbeat_interval}ms (${Math.floor(data.heartbeat_interval / 1000)}s), waiting ${Math.floor(sleepTime / 1000)}s before sending first heartbeat.`); await sleep(sleepTime); const [isOk] = this.checkOkToSend(); - if(!isOk) { + if (!isOk) { return; } this.sendHeartbeat(); this.heartbeatInterval = setInterval(() => { - if(this.client.OPEN !== this.client.readyState) { - if(this.heartbeatInterval !== undefined) { + if (this.client.OPEN !== this.client.readyState) { + if (this.heartbeatInterval !== undefined) { clearInterval(this.heartbeatInterval); } return; @@ -393,7 +404,7 @@ export class DiscordWSClient extends AbstractApiClient { } } - playStateToActivity = (data: SourceData): GatewayActivity => { + playStateToActivity = async (data: SourceData): Promise => { const { activity, artUrl } = playStateToActivityData(data); const artwork = this.artworkOpt; const { @@ -418,7 +429,12 @@ export class DiscordWSClient extends AbstractApiClient { } // https://docs.discord.com/developers/events/gateway-events#activity-object-activity-assets // https://docs.discord.com/developers/events/gateway-events#activity-object-activity-asset-image - activity.assets.large_image = art; + if(art !== false) { + const usedUrl = await this.getArtworkUrl(art); + if(usedUrl !== undefined) { + activity.assets.large_image = usedUrl; + } + } return activity; } @@ -434,7 +450,7 @@ export class DiscordWSClient extends AbstractApiClient { this.logger.warn(`Cannot send activity because client is ${reasons}`); return; } - const activity = this.playStateToActivity(data); + const activity = await this.playStateToActivity(data); let clearTime = dayjs().add(5, 'minutes'); if (activity.timestamps.end !== undefined) { @@ -502,6 +518,42 @@ export class DiscordWSClient extends AbstractApiClient { } return [false, reasons.join(' and ')]; } + + getArtworkUrl = async (artUrl: string): Promise => { + + const cachedUrl = await this.cache.cacheMetadata.get(artUrl); + if (cachedUrl !== undefined) { + return cachedUrl; + } + + if (this.config.applicationId === undefined || this.artFail) { + return; + } + + try { + const imgResp = await request.post(`https://discord.com/api/v10/applications/${this.config.applicationId}/external-assets`) + .set('Authorization', this.config.token) + .type('json') + .send({ "urls": [artUrl] }); + this.artFailCount = 0; + const proxied = `mp:${imgResp.body[0].external_asset_path}` + await this.cache.cacheMetadata.set(artUrl, proxied); + return proxied; + } catch (e) { + this.artFailCount++; + this.logger.warn(new Error('Failed to upload art url', { cause: e })); + if (isSuperAgentResponseError(e)) { + if (e.status === 401 || e.status === 403) { + this.artFail = true; + } + } else if (this.artFailCount > 3) { + this.logger.verbose('More than 3 consecutive failures to upload art...turning off to stop spamming bad requests'); + this.artFail = true; + } + return; + } + + } } const opcodeToFriendly = (op: number) => { diff --git a/src/backend/scrobblers/DiscordScrobbler.ts b/src/backend/scrobblers/DiscordScrobbler.ts index cb8fcef3..cf1ae537 100644 --- a/src/backend/scrobblers/DiscordScrobbler.ts +++ b/src/backend/scrobblers/DiscordScrobbler.ts @@ -6,7 +6,7 @@ import { Notifiers } from "../notifier/Notifiers.js"; import AbstractScrobbleClient, { nowPlayingUpdateByPlayDuration } from "./AbstractScrobbleClient.js"; import { DiscordClientConfig } from "../common/infrastructure/config/client/discord.js"; -import { DiscordWSClient } from "../common/vendor/discord/DiscordWSClient.js"; +import { DiscordWSClient, playStateToActivityData } from "../common/vendor/discord/DiscordWSClient.js"; export default class DiscordScrobbler extends AbstractScrobbleClient { @@ -61,7 +61,7 @@ export default class DiscordScrobbler extends AbstractScrobbleClient { alreadyScrobbled = async (playObj: PlayObject, log = false) => true public playToClientPayload(playObj: PlayObject): any { - return this.api.playStateToActivity(playObj); + return playStateToActivityData(playObj).activity; } doScrobble = async (playObj: PlayObject) => { -- 2.51.2 From f6d99215818b1c796d71b90cdf76eedc1b7c70b9 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 13 Feb 2026 22:08:46 +0000 Subject: [PATCH 06/18] type fixes --- src/backend/common/vendor/discord/DiscordWSClient.ts | 10 +--------- src/core/Atomic.ts | 2 +- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/backend/common/vendor/discord/DiscordWSClient.ts b/src/backend/common/vendor/discord/DiscordWSClient.ts index 46afb64a..9533dc44 100644 --- a/src/backend/common/vendor/discord/DiscordWSClient.ts +++ b/src/backend/common/vendor/discord/DiscordWSClient.ts @@ -635,15 +635,7 @@ export const playStateToActivityData = (data: SourceData, opts: { useArt?: boole } } - const { - art: { - album, - track, - artist - } = {} - } = play.meta ?? {}; - - const artUrl = album ?? track ?? artist; + const artUrl = play.meta?.art?.album ?? play.meta.art.track ?? play.meta.art.artist; return { activity, artUrl }; } \ No newline at end of file diff --git a/src/core/Atomic.ts b/src/core/Atomic.ts index c572f9cb..2dd3fa2d 100644 --- a/src/core/Atomic.ts +++ b/src/core/Atomic.ts @@ -54,7 +54,7 @@ export interface SourceStatusData { export interface ClientStatusData { status: string; - type: "maloja" | "lastfm" | "librefm" | "listenbrainz" | "koito" | "tealfm" | "rocksky"; + type: "maloja" | "lastfm" | "librefm" | "listenbrainz" | "koito" | "tealfm" | "rocksky" | "discord"; display: string; scrobbled: number; deadLetterScrobbles: number -- 2.51.2 From c57200132ec32421e3a374f88825998509516afc Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 13 Feb 2026 23:04:38 +0000 Subject: [PATCH 07/18] use discord.js deps in production build --- package-lock.json | 22 +--------------------- package.json | 2 +- 2 files changed, 2 insertions(+), 22 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3b96bdde..d915d1a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,6 +53,7 @@ "cors": "^2.8.5", "dayjs": "^1.10.4", "dbus-ts": "^0.0.7", + "discord.js": "^14.25.1", "dotenv": "^10.0.0", "express": "^4.17.1", "express-session": "^1.17.2", @@ -143,7 +144,6 @@ "@vitejs/plugin-react": "^4.2.1", "chai": "^4.3.6", "chai-as-promised": "^7.1.1", - "discord.js": "^14.25.1", "eslint": "^8.56.0", "eslint-plugin-prefer-arrow-functions": "^3.2.4", "git-cliff": "^2.12.0", @@ -1078,7 +1078,6 @@ "version": "1.13.1", "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.1.tgz", "integrity": "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@discordjs/formatters": "^0.6.2", @@ -1100,7 +1099,6 @@ "version": "1.5.3", "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=16.11.0" @@ -1110,7 +1108,6 @@ "version": "0.6.2", "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", - "dev": true, "license": "Apache-2.0", "dependencies": { "discord-api-types": "^0.38.33" @@ -1126,7 +1123,6 @@ "version": "2.6.0", "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz", "integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@discordjs/collection": "^2.1.1", @@ -1150,7 +1146,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=18" @@ -1163,7 +1158,6 @@ "version": "6.21.3", "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", - "dev": true, "license": "MIT", "engines": { "node": ">=18.17" @@ -1173,7 +1167,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", - "dev": true, "license": "Apache-2.0", "dependencies": { "discord-api-types": "^0.38.33" @@ -1189,7 +1182,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@discordjs/collection": "^2.1.0", @@ -1213,7 +1205,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=18" @@ -1226,7 +1217,6 @@ "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -2975,7 +2965,6 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=v14.0.0", @@ -2986,7 +2975,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", - "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -3000,7 +2988,6 @@ "version": "3.5.3", "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=v14.0.0", @@ -4392,7 +4379,6 @@ "version": "2.4.7", "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", - "dev": true, "license": "MIT", "engines": { "node": ">=v14.0.0", @@ -5886,7 +5872,6 @@ "version": "0.38.39", "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.39.tgz", "integrity": "sha512-XRdDQvZvID1XvcFftjSmd4dcmMi/RL/jSy5sduBDAvCGFcNFHThdIQXCEBDZFe52lCNEzuIL0QJoKYAmRmxLUA==", - "dev": true, "license": "MIT", "workspaces": [ "scripts/actions/documentation" @@ -5896,7 +5881,6 @@ "version": "14.25.1", "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz", "integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@discordjs/builders": "^1.13.0", @@ -5924,7 +5908,6 @@ "version": "6.21.3", "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", - "dev": true, "license": "MIT", "engines": { "node": ">=18.17" @@ -9236,7 +9219,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", - "dev": true, "license": "MIT" }, "node_modules/log-symbols": { @@ -9310,7 +9292,6 @@ "version": "1.13.0", "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", - "dev": true, "license": "MIT" }, "node_modules/magic-string": { @@ -12995,7 +12976,6 @@ "version": "6.0.4", "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", - "dev": true, "license": "MIT" }, "node_modules/tslib": { diff --git a/package.json b/package.json index a780dc2c..00b7c9a0 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "cors": "^2.8.5", "dayjs": "^1.10.4", "dbus-ts": "^0.0.7", + "discord.js": "^14.25.1", "dotenv": "^10.0.0", "express": "^4.17.1", "express-session": "^1.17.2", @@ -178,7 +179,6 @@ "@vitejs/plugin-react": "^4.2.1", "chai": "^4.3.6", "chai-as-promised": "^7.1.1", - "discord.js": "^14.25.1", "eslint": "^8.56.0", "eslint-plugin-prefer-arrow-functions": "^3.2.4", "git-cliff": "^2.12.0", -- 2.51.2 From b95b7e37813557b897f59f9d49cedc60a8d430d7 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Sat, 14 Feb 2026 00:12:16 +0000 Subject: [PATCH 08/18] fix(discord): Add missing applicationId env and docs --- config/discord.json.example | 3 ++- docsite/docs/configuration/clients/discord.mdx | 1 + src/backend/scrobblers/ScrobbleClients.ts | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/config/discord.json.example b/config/discord.json.example index 6b10ac6c..939a139c 100644 --- a/config/discord.json.example +++ b/config/discord.json.example @@ -3,7 +3,8 @@ "name": "MS", "enable": true, "data": { - "token": "Ma7XBvzRERK3gQdkX4dkb3OQ.6aaGmJ.rvFiiVrzG4VXHHL3LyhZYhpWdG3Ph_if9aKz5E" + "token": "Ma7XBvzRERK3gQdkX4dkb3OQ.6aaGmJ.rvFiiVrzG4VXHHL3LyhZYhpWdG3Ph_if9aKz5E", + "applicationId": "8190211179716453570" } } ] diff --git a/docsite/docs/configuration/clients/discord.mdx b/docsite/docs/configuration/clients/discord.mdx index 35432da0..ae4d8fca 100644 --- a/docsite/docs/configuration/clients/discord.mdx +++ b/docsite/docs/configuration/clients/discord.mdx @@ -95,6 +95,7 @@ You can customize this fallback image with ENV `DISCORD_ARTWORK_DEFAULT_URL` or | Environmental Variable | Required? | Default | Description | | :---------------------------- | --------- | ------- | :--------------------------------------------------------------------------------------------------------------- | | `DISCORD_TOKEN` | Yes | | User Token acquired from an active Discord sessions | + | `DISCORD_APPLICATION_ID` | Yes | | Application ID for user with album art | | `DISCORD_ARTWORK` | No | | A boolean indicating if external artwork URLs should be used. Or a comma-separated list of allowed domains | | `DISCORD_ARTWORK_DEFAULT_URL` | No | | A URL of an image to use as a fallback if the album art URL cannot be used. Or `false` to use discord's default. | \ No newline at end of file diff --git a/src/backend/scrobblers/ScrobbleClients.ts b/src/backend/scrobblers/ScrobbleClients.ts index 134f584a..e443ed5b 100644 --- a/src/backend/scrobblers/ScrobbleClients.ts +++ b/src/backend/scrobblers/ScrobbleClients.ts @@ -303,6 +303,7 @@ export default class ScrobbleClients { const discord = { token: process.env.DISCORD_TOKEN, artwork: process.env.DISCORD_ARTWORK, + applicationId: process.env.DISCORD_APPLICATION_ID, artworkDefaultUrl: process.env.DISCORD_ARTWORK_DEFAULT_URL } if (!Object.values(discord).every(x => x === undefined)) { -- 2.51.2 From 2993279d78618438600411c6b54a9396d2c0aec0 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Sun, 15 Feb 2026 18:36:27 +0000 Subject: [PATCH 09/18] feat(discord): Add platform specific now playing should update check --- .../common/vendor/discord/DiscordWSClient.ts | 5 ----- src/backend/scrobblers/AbstractScrobbleClient.ts | 7 ++++++- src/backend/scrobblers/DiscordScrobbler.ts | 14 ++++++++++++++ 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/backend/common/vendor/discord/DiscordWSClient.ts b/src/backend/common/vendor/discord/DiscordWSClient.ts index 9533dc44..bec3c22d 100644 --- a/src/backend/common/vendor/discord/DiscordWSClient.ts +++ b/src/backend/common/vendor/discord/DiscordWSClient.ts @@ -440,11 +440,6 @@ export class DiscordWSClient extends AbstractApiClient { } sendActivity = async (data: SourceData) => { - - if ([PresenceUpdateStatus.Offline, PresenceUpdateStatus.Invisible].includes(this.lastActiveStatus)) { - this.logger.debug('Not updating presence because no user sessions have a visible status'); - return; - } const [sendOk, reasons] = this.checkOkToSend(); if (!sendOk) { this.logger.warn(`Cannot send activity because client is ${reasons}`); diff --git a/src/backend/scrobblers/AbstractScrobbleClient.ts b/src/backend/scrobblers/AbstractScrobbleClient.ts index f6740b3e..1e555c83 100644 --- a/src/backend/scrobblers/AbstractScrobbleClient.ts +++ b/src/backend/scrobblers/AbstractScrobbleClient.ts @@ -1017,7 +1017,7 @@ ${closestMatch.breakdowns.join('\n')}`, {leaf: ['Dupe Check']}); if(play === undefined) { return; } - if(this.shouldUpdatePlayingNow(play)) { + if(this.shouldUpdatePlayingNow(play) && (await this.shouldUpdatePlayingNowPlatformSpecific(play))) { try { await this.doPlayingNow(play); this.npLogger.debug(`Now Playing updated.`); @@ -1065,6 +1065,11 @@ ${closestMatch.breakdowns.join('\n')}`, {leaf: ['Dupe Check']}); return false; } + /** If there are specific requirements for updating playing now based on the scrobbler platform they should implement this */ + protected shouldUpdatePlayingNowPlatformSpecific = async (data: PlayObject): Promise => { + return true; + } + protected doPlayingNow = (data: PlayObject): Promise => Promise.resolve(undefined) diff --git a/src/backend/scrobblers/DiscordScrobbler.ts b/src/backend/scrobblers/DiscordScrobbler.ts index cf1ae537..35e284aa 100644 --- a/src/backend/scrobblers/DiscordScrobbler.ts +++ b/src/backend/scrobblers/DiscordScrobbler.ts @@ -7,6 +7,7 @@ import { Notifiers } from "../notifier/Notifiers.js"; import AbstractScrobbleClient, { nowPlayingUpdateByPlayDuration } from "./AbstractScrobbleClient.js"; import { DiscordClientConfig } from "../common/infrastructure/config/client/discord.js"; import { DiscordWSClient, playStateToActivityData } from "../common/vendor/discord/DiscordWSClient.js"; +import { PresenceUpdateStatus } from "discord.js"; export default class DiscordScrobbler extends AbstractScrobbleClient { @@ -75,4 +76,17 @@ export default class DiscordScrobbler extends AbstractScrobbleClient { throw e; } } + + shouldUpdatePlayingNowPlatformSpecific = async (data: PlayObject) => { + if ([PresenceUpdateStatus.Offline, PresenceUpdateStatus.Invisible].includes(this.api.lastActiveStatus)) { + this.logger.debug('Not updating presence because no user sessions have a visible status'); + return false; + } + const [sendOk, reasons] = this.api.checkOkToSend(); + if (!sendOk) { + this.logger.warn(`Cannot update playing now because api client is ${reasons}`); + return false; + } + return true; + } } -- 2.51.2 From 17b11f19a3a320adb1321906495b3e3b539c1a79 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 16 Feb 2026 17:26:34 +0000 Subject: [PATCH 10/18] feat(nowplaying): Update now playing based on play presence AND player status --- src/backend/common/infrastructure/Atomic.ts | 1 + .../common/vendor/discord/DiscordWSClient.ts | 7 +- .../scrobblers/AbstractScrobbleClient.ts | 83 +++++++++++-------- src/backend/scrobblers/DiscordScrobbler.ts | 18 ++-- src/backend/scrobblers/KoitoScrobbler.ts | 8 +- src/backend/scrobblers/LastfmScrobbler.ts | 8 +- .../scrobblers/ListenbrainzScrobbler.ts | 8 +- src/backend/scrobblers/RockskyScrobbler.ts | 6 +- src/backend/scrobblers/ScrobbleClients.ts | 6 +- .../tests/scrobbler/scrobblers.test.ts | 64 +++++++------- src/backend/tests/utils/PlayTestUtils.ts | 48 ++++++++++- src/core/Atomic.ts | 9 +- 12 files changed, 169 insertions(+), 97 deletions(-) diff --git a/src/backend/common/infrastructure/Atomic.ts b/src/backend/common/infrastructure/Atomic.ts index 20cd3ac0..5b0c547f 100644 --- a/src/backend/common/infrastructure/Atomic.ts +++ b/src/backend/common/infrastructure/Atomic.ts @@ -233,6 +233,7 @@ export interface ProgressAwarePlayObject extends PlayObject { export type DeviceId = string; export type PlayUserId = string; export type PlayPlatformId = [DeviceId, PlayUserId]; +export type PlayPlatformIdStr = string; export type GroupedPlays = TupleMap; diff --git a/src/backend/common/vendor/discord/DiscordWSClient.ts b/src/backend/common/vendor/discord/DiscordWSClient.ts index bec3c22d..aea0d3e9 100644 --- a/src/backend/common/vendor/discord/DiscordWSClient.ts +++ b/src/backend/common/vendor/discord/DiscordWSClient.ts @@ -439,12 +439,17 @@ export class DiscordWSClient extends AbstractApiClient { return activity; } - sendActivity = async (data: SourceData) => { + sendActivity = async (data: SourceData | undefined) => { const [sendOk, reasons] = this.checkOkToSend(); if (!sendOk) { this.logger.warn(`Cannot send activity because client is ${reasons}`); return; } + if(data === undefined) { + this.clearActivity(); + return; + } + const activity = await this.playStateToActivity(data); let clearTime = dayjs().add(5, 'minutes'); diff --git a/src/backend/scrobblers/AbstractScrobbleClient.ts b/src/backend/scrobblers/AbstractScrobbleClient.ts index 1e555c83..6b5a5e7e 100644 --- a/src/backend/scrobblers/AbstractScrobbleClient.ts +++ b/src/backend/scrobblers/AbstractScrobbleClient.ts @@ -6,9 +6,10 @@ import { nanoid } from "nanoid"; import { MarkOptional } from "ts-essentials"; import { DeadLetterScrobble, + NowPlayingUpdateThreshold, PlayObject, PlayObjectLifecycleless, - QueuedScrobble, ScrobbleActionResult, ScrobblePayload, ScrobbleResponse, TA_DURING, + QueuedScrobble, ScrobbleActionResult, ScrobblePayload, ScrobbleResponse, SourcePlayerObj, TA_DURING, TA_FUZZY, TrackStringOptions } from "../../core/Atomic.js"; @@ -18,6 +19,7 @@ import { hasUpstreamError, UpstreamError } from "../common/errors/UpstreamError. import { ARTIST_WEIGHT, Authenticatable, + CALCULATED_PLAYER_STATUSES, ClientType, DEFAULT_RETRY_MULTIPLIER, DUP_SCORE_THRESHOLD, @@ -63,8 +65,9 @@ import { normalizeStr } from "../utils/StringUtils.js"; import prom, { Counter, Gauge } from 'prom-client'; import { ScrobbleSubmitError } from "../common/errors/MSErrors.js"; import {serializeError} from 'serialize-error'; +import { redactString } from "@foxxmd/redact-string"; -type PlatformMappedPlays = Map; +type PlatformMappedPlays = Map; type NowPlayingQueue = Map; export default abstract class AbstractScrobbleClient extends AbstractComponent implements Authenticatable { @@ -100,11 +103,11 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i supportsNowPlaying: boolean = false; nowPlayingEnabled: boolean; - nowPlayingFilter: (queue: NowPlayingQueue) => PlayObject | undefined; - nowPlayingMinThreshold: (play?: PlayObject) => number = (_) => 10; - nowPlayingMaxThreshold: (play?: PlayObject) => number = (_) => 30; + nowPlayingFilter: (queue: NowPlayingQueue) => SourcePlayerObj | undefined; + nowPlayingMinThreshold: NowPlayingUpdateThreshold = (_) => 10; + nowPlayingMaxThreshold: NowPlayingUpdateThreshold = (_) => 30; nowPlayingLastUpdated?: Dayjs; - nowPlayingLastPlay?: PlayObject; + nowPlayingLastPlay?: SourcePlayerObj; nowPlayingQueue: NowPlayingQueue = new Map(); nowPlayingTaskInterval: number = 5000; npLogger: Logger; @@ -292,7 +295,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i } } - this.nowPlayingFilter = (queue: NowPlayingQueue) => { + this.nowPlayingFilter = (queue: NowPlayingQueue): SourcePlayerObj => { if (queue.size === 0) { return undefined; } @@ -305,7 +308,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i // if only one player then return it const plays = Array.from(platformPlays); if (plays.length === 1) { - return plays[0][1].play; + return plays[0][1].player; } // else we need to sort players to determine which to report @@ -313,18 +316,17 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i // this way we aren't flip-flopping between multiple players for reporting now playing // (keeps reporting sticky based on first reported) if (this.nowPlayingLastPlay !== undefined) { - const lastNowPlayingPlat = genGroupIdStrFromPlay(this.nowPlayingLastPlay); for (const [platform, data] of plays) { - if (platform === lastNowPlayingPlat) { - return data.play; + if (platform === this.nowPlayingLastPlay.platformId) { + return data.player; } } } // otherwise sort platform alphabetically and take first plays.sort((a, b) => a[0].localeCompare(b[0])); - return plays[0][1].play; + return plays[0][1].player; } } } @@ -1001,38 +1003,42 @@ ${closestMatch.breakdowns.join('\n')}`, {leaf: ['Dupe Check']}); this.updateDeadLetterCache(); } - queuePlayingNow = (data: PlayObject, source: SourceIdentifier) => { + queuePlayingNow = (data: SourcePlayerObj, source: SourceIdentifier) => { const sourceId = `${source.name}-${source.type}`; if(isDebugMode()) { - this.npLogger.debug(`Queueing ${buildTrackString(data, {include: ['artist', 'track', 'platform']})} from ${sourceId}`); + let playHint = ''; + if(data.play !== undefined) { + playHint = ` with Play ${buildTrackString(data.play, {include: ['artist', 'track', 'platform']})}` + } + this.npLogger.debug(`Queueing Player ${redactString(data.platformId, 10)} ${data.status.calculated.toLocaleUpperCase()}${playHint} from ${sourceId}`); } const platformPlays = this.nowPlayingQueue.get(sourceId) ?? new Map(); - platformPlays.set(genGroupIdStrFromPlay(data), {play: data, source}); + platformPlays.set(data.platformId, {player: data, source}); this.nowPlayingQueue.set(sourceId, platformPlays); } processingPlayingNow = async (): Promise => { if(this.supportsNowPlaying && this.nowPlayingEnabled) { - const play = this.nowPlayingFilter(this.nowPlayingQueue); - if(play === undefined) { + const sourcePlayerData = this.nowPlayingFilter(this.nowPlayingQueue); + if(sourcePlayerData === undefined) { return; } - if(this.shouldUpdatePlayingNow(play) && (await this.shouldUpdatePlayingNowPlatformSpecific(play))) { + if(this.shouldUpdatePlayingNow(sourcePlayerData) && (await this.shouldUpdatePlayingNowPlatformSpecific(sourcePlayerData))) { try { - await this.doPlayingNow(play); + await this.doPlayingNow(sourcePlayerData); this.npLogger.debug(`Now Playing updated.`); - this.emitEvent('nowPlayingUpdated', play); + this.emitEvent('nowPlayingUpdated', sourcePlayerData); } catch (e) { this.npLogger.warn(new Error('Error occurred while trying to update upstream Client, will ignore', {cause: e})); } - this.nowPlayingLastPlay = play; + this.nowPlayingLastPlay = sourcePlayerData; this.nowPlayingLastUpdated = dayjs(); } this.nowPlayingQueue = new Map(); } } - shouldUpdatePlayingNow = (data: PlayObject): boolean => { + shouldUpdatePlayingNow = (data: SourcePlayerObj): boolean => { if(this.nowPlayingLastPlay === undefined || this.nowPlayingLastUpdated === undefined) { if(isDebugMode()) { this.npLogger.debug(`Now Playing has not yet been set! Should update`); @@ -1042,35 +1048,39 @@ ${closestMatch.breakdowns.join('\n')}`, {leaf: ['Dupe Check']}); const lastUpdateDiff = Math.abs(dayjs().diff(this.nowPlayingLastUpdated, 's')); + const removedPlay = this.nowPlayingLastPlay.play !== undefined && data.play === undefined; + + const playerStatusChanged = this.nowPlayingLastPlay.status.calculated !== data.status.calculated || this.nowPlayingLastPlay.status.reported !== data.status.reported; + // update if play *has* changed and time since last update is greater than min interval // this prevents spamming scrobbler API with updates if user is skipping tracks and source updates frequently - if(!playObjDataMatch(data, this.nowPlayingLastPlay) && this.nowPlayingMinThreshold(data) < lastUpdateDiff) { + if(this.nowPlayingMinThreshold(data.play) < lastUpdateDiff && (removedPlay || playerStatusChanged || !playObjDataMatch(data.play, this.nowPlayingLastPlay.play))) { if(isDebugMode()) { - this.npLogger.debug(`New Play differs from previous Now Playing and time since update ${lastUpdateDiff}s, greater than threshold ${this.nowPlayingMinThreshold(data)}. Should update`); + this.npLogger.debug(`New Play differs from previous Now Playing and time since update ${lastUpdateDiff}s, greater than threshold ${this.nowPlayingMinThreshold(data.play)}. Should update`); } return true; } // update if play *has not* changed but last update is greater than max interval // this keeps scrobbler Now Playing fresh ("active" indicator) in the event play is long - if(playObjDataMatch(data, this.nowPlayingLastPlay) && this.nowPlayingMaxThreshold(data) < lastUpdateDiff) { + if(this.nowPlayingMaxThreshold(data.play) < lastUpdateDiff && playObjDataMatch(data.play, this.nowPlayingLastPlay.play)) { if(isDebugMode()) { - this.npLogger.debug(`Now Playing last updated ${lastUpdateDiff}s ago, greater than threshold ${this.nowPlayingMaxThreshold(data)}s. Should update`); + this.npLogger.debug(`Now Playing last updated ${lastUpdateDiff}s ago, greater than threshold ${this.nowPlayingMaxThreshold(data.play)}s. Should update`); } return true; } if(isDebugMode()) { - this.npLogger.debug(`Now Playing ${playObjDataMatch(data, this.nowPlayingLastPlay) ? 'matches' : 'does not match'} and was last updated ${lastUpdateDiff}s ago (threshold ${this.nowPlayingMaxThreshold(data)}s), not updating`); + this.npLogger.debug(`Now Playing ${playObjDataMatch(data.play, this.nowPlayingLastPlay.play) ? 'matches' : 'does not match'} and was last updated ${lastUpdateDiff}s ago (threshold ${this.nowPlayingMaxThreshold(data.play)}s), not updating`); } return false; } - /** If there are specific requirements for updating playing now based on the scrobbler platform they should implement this */ - protected shouldUpdatePlayingNowPlatformSpecific = async (data: PlayObject): Promise => { - return true; + /** Implement this for specific requirements for updating playing now based on the scrobbler platform */ + protected shouldUpdatePlayingNowPlatformSpecific(data: SourcePlayerObj): Promise { + return shouldUpdatePlayingNowPlatformWhenPlayingOnly(data); } - protected doPlayingNow = (data: PlayObject): Promise => Promise.resolve(undefined) + protected doPlayingNow = (data: SourcePlayerObj): Promise => Promise.resolve(undefined) public emitEvent = (eventName: string, payload: object) => { @@ -1095,6 +1105,13 @@ ${closestMatch.breakdowns.join('\n')}`, {leaf: ['Dupe Check']}); } } -export const nowPlayingUpdateByPlayDuration = (play: PlayObject) => { - return (play.data.duration ?? 30) + 1; +export const nowPlayingUpdateByPlayDuration: NowPlayingUpdateThreshold = (play?: PlayObject) => { + if(play === undefined) { + 31; + } + return (play?.data?.duration ?? 30) + 1; +} + +export const shouldUpdatePlayingNowPlatformWhenPlayingOnly = async (data: SourcePlayerObj): Promise => { + return data.status.calculated === CALCULATED_PLAYER_STATUSES.playing; } \ No newline at end of file diff --git a/src/backend/scrobblers/DiscordScrobbler.ts b/src/backend/scrobblers/DiscordScrobbler.ts index 35e284aa..48d46c27 100644 --- a/src/backend/scrobblers/DiscordScrobbler.ts +++ b/src/backend/scrobblers/DiscordScrobbler.ts @@ -1,7 +1,7 @@ import { Logger } from "@foxxmd/logging"; import EventEmitter from "events"; -import { PlayObject } from "../../core/Atomic.js"; -import { FormatPlayObjectOptions } from "../common/infrastructure/Atomic.js"; +import { PlayObject, SourcePlayerObj } from "../../core/Atomic.js"; +import { CALCULATED_PLAYER_STATUSES, CalculatedPlayerStatus, FormatPlayObjectOptions, REPORTED_PLAYER_STATUSES, ReportedPlayerStatus } from "../common/infrastructure/Atomic.js"; import { Notifiers } from "../notifier/Notifiers.js"; import AbstractScrobbleClient, { nowPlayingUpdateByPlayDuration } from "./AbstractScrobbleClient.js"; @@ -29,6 +29,7 @@ export default class DiscordScrobbler extends AbstractScrobbleClient { }); this.supportsNowPlaying = true; this.nowPlayingMaxThreshold = nowPlayingUpdateByPlayDuration; + this.nowPlayingMinThreshold = (_) => 5; } formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => obj; @@ -69,15 +70,22 @@ export default class DiscordScrobbler extends AbstractScrobbleClient { return { play: playObj, payload: {} }; } - doPlayingNow = async (data: PlayObject) => { + doPlayingNow = async (data: SourcePlayerObj) => { try { - await this.api.sendActivity(data); + if([CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused].includes(data.status.calculated as ReportedPlayerStatus)) { + await this.api.sendActivity(undefined); + } else { + await this.api.sendActivity(data.play); + } } catch (e) { throw e; } } - shouldUpdatePlayingNowPlatformSpecific = async (data: PlayObject) => { + shouldUpdatePlayingNowPlatformSpecific = async (data: SourcePlayerObj) => { + if(data.status.reported === REPORTED_PLAYER_STATUSES.playing + || [CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused].includes(data.status.calculated as ReportedPlayerStatus) + || data.status.stale) if ([PresenceUpdateStatus.Offline, PresenceUpdateStatus.Invisible].includes(this.api.lastActiveStatus)) { this.logger.debug('Not updating presence because no user sessions have a visible status'); return false; diff --git a/src/backend/scrobblers/KoitoScrobbler.ts b/src/backend/scrobblers/KoitoScrobbler.ts index b96e3d2e..d85eed05 100644 --- a/src/backend/scrobblers/KoitoScrobbler.ts +++ b/src/backend/scrobblers/KoitoScrobbler.ts @@ -1,6 +1,6 @@ import { Logger } from "@foxxmd/logging"; import EventEmitter from "events"; -import { PlayObject } from "../../core/Atomic.js"; +import { PlayObject, SourcePlayerObj } from "../../core/Atomic.js"; import { buildTrackString, capitalize } from "../../core/StringUtils.js"; import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; import { UpstreamError } from "../common/errors/UpstreamError.js"; @@ -8,7 +8,7 @@ import { FormatPlayObjectOptions } from "../common/infrastructure/Atomic.js"; import { playToListenPayload } from "../common/vendor/ListenbrainzApiClient.js"; import { Notifiers } from "../notifier/Notifiers.js"; -import AbstractScrobbleClient from "./AbstractScrobbleClient.js"; +import AbstractScrobbleClient, { shouldUpdatePlayingNowPlatformWhenPlayingOnly } from "./AbstractScrobbleClient.js"; import { isDebugMode } from "../utils.js"; import { KoitoClientConfig } from "../common/infrastructure/config/client/koito.js"; import { KoitoApiClient, listenObjectResponseToPlay } from "../common/vendor/koito/KoitoApiClient.js"; @@ -95,9 +95,9 @@ export default class KoitoScrobbler extends AbstractScrobbleClient { } } - doPlayingNow = async (data: PlayObject) => { + doPlayingNow = async (data: SourcePlayerObj) => { try { - await this.api.submitListen(data, { listenType: 'playing_now'}); + await this.api.submitListen(data.play, { listenType: 'playing_now'}); } catch (e) { throw e; } diff --git a/src/backend/scrobblers/LastfmScrobbler.ts b/src/backend/scrobblers/LastfmScrobbler.ts index 710c3f03..6e4dfd4a 100644 --- a/src/backend/scrobblers/LastfmScrobbler.ts +++ b/src/backend/scrobblers/LastfmScrobbler.ts @@ -1,6 +1,6 @@ import { Logger } from "@foxxmd/logging"; import EventEmitter from "events"; -import { PlayObject } from "../../core/Atomic.js"; +import { PlayObject, SourcePlayerObj } from "../../core/Atomic.js"; import { buildTrackString, capitalize } from "../../core/StringUtils.js"; import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; import { UpstreamError } from "../common/errors/UpstreamError.js"; @@ -8,7 +8,7 @@ import { FormatPlayObjectOptions, InternalConfigOptional } from "../common/infra import { LastfmClientConfig } from "../common/infrastructure/config/client/lastfm.js"; import LastfmApiClient, { LastFMIgnoredScrobble, playToClientPayload, formatPlayObj, LASTFM_HOST, LASTFM_PATH } from "../common/vendor/LastfmApiClient.js"; import { Notifiers } from "../notifier/Notifiers.js"; -import AbstractScrobbleClient, { nowPlayingUpdateByPlayDuration } from "./AbstractScrobbleClient.js"; +import AbstractScrobbleClient, { nowPlayingUpdateByPlayDuration, shouldUpdatePlayingNowPlatformWhenPlayingOnly } from "./AbstractScrobbleClient.js"; import { findCauseByReference } from "../utils/ErrorUtils.js"; export default class LastfmScrobbler extends AbstractScrobbleClient { @@ -107,10 +107,10 @@ export default class LastfmScrobbler extends AbstractScrobbleClient { } } - doPlayingNow = async (data: PlayObject) => { + doPlayingNow = async (data: SourcePlayerObj) => { // last.fm shows Now Playing for the same time as the duration of the track being submitted try { - return this.api.playingNow(data); + return this.api.playingNow(data.play); } catch (e) { throw e; } diff --git a/src/backend/scrobblers/ListenbrainzScrobbler.ts b/src/backend/scrobblers/ListenbrainzScrobbler.ts index 136433c7..9a55198d 100644 --- a/src/backend/scrobblers/ListenbrainzScrobbler.ts +++ b/src/backend/scrobblers/ListenbrainzScrobbler.ts @@ -1,6 +1,6 @@ import { Logger } from "@foxxmd/logging"; import EventEmitter from "events"; -import { PlayObject } from "../../core/Atomic.js"; +import { PlayObject, SourcePlayerObj } from "../../core/Atomic.js"; import { buildTrackString, capitalize } from "../../core/StringUtils.js"; import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; import { hasUpstreamError, UpstreamError } from "../common/errors/UpstreamError.js"; @@ -10,7 +10,7 @@ import { ListenbrainzApiClient, playToListenPayload, playToSubmitPayload } from import { ListenPayload } from '../common/vendor/listenbrainz/interfaces.js'; import { Notifiers } from "../notifier/Notifiers.js"; -import AbstractScrobbleClient, { nowPlayingUpdateByPlayDuration } from "./AbstractScrobbleClient.js"; +import AbstractScrobbleClient, { nowPlayingUpdateByPlayDuration, shouldUpdatePlayingNowPlatformWhenPlayingOnly } from "./AbstractScrobbleClient.js"; import { isDebugMode } from "../utils.js"; export default class ListenbrainzScrobbler extends AbstractScrobbleClient { @@ -98,10 +98,10 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient { } } - doPlayingNow = async (data: PlayObject) => { + doPlayingNow = async (data: SourcePlayerObj) => { // listenbrainz shows Now Playing for the same time as the duration of the track being submitted try { - await this.api.submitListen(data, { listenType: 'playing_now'}); + await this.api.submitListen(data.play, { listenType: 'playing_now'}); } catch (e) { throw e; } diff --git a/src/backend/scrobblers/RockskyScrobbler.ts b/src/backend/scrobblers/RockskyScrobbler.ts index d95c086b..575788ba 100644 --- a/src/backend/scrobblers/RockskyScrobbler.ts +++ b/src/backend/scrobblers/RockskyScrobbler.ts @@ -1,6 +1,6 @@ import { Logger } from "@foxxmd/logging"; import EventEmitter from "events"; -import { PlayObject } from "../../core/Atomic.js"; +import { PlayObject, SourcePlayerObj } from "../../core/Atomic.js"; import { buildTrackString, capitalize } from "../../core/StringUtils.js"; import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; import { hasUpstreamError, UpstreamError } from "../common/errors/UpstreamError.js"; @@ -103,9 +103,9 @@ export default class RockskyScrobbler extends AbstractScrobbleClient { } } - doPlayingNow = async (data: PlayObject) => { + doPlayingNow = async (data: SourcePlayerObj) => { try { - await this.api.submitListen(data, { listenType: 'playing_now'}); + await this.api.submitListen(data.play, { listenType: 'playing_now'}); } catch (e) { throw e; } diff --git a/src/backend/scrobblers/ScrobbleClients.ts b/src/backend/scrobblers/ScrobbleClients.ts index e443ed5b..cb8007a0 100644 --- a/src/backend/scrobblers/ScrobbleClients.ts +++ b/src/backend/scrobblers/ScrobbleClients.ts @@ -50,9 +50,7 @@ export default class ScrobbleClients { this.sourceEmitter.on('playerUpdate', async (payload: { data: SourcePlayerObj & { options: { scrobbleTo: string[] } }} & SourceIdentifier) => { // agressively update Now Playing so scrobblers that display based on duration are mostly synced // but aggressively *stop* updating if state becomes stale/orphaned - if(payload.data.status.reported === REPORTED_PLAYER_STATUSES.playing && (!payload.data.status.stale && !payload.data.status.orphaned)) { - this.playingNow(payload.data.play, {...payload.data.options, scrobbleFrom: { type: payload.type, name: payload.name}}); - } + this.playingNow(payload.data, {...payload.data.options, scrobbleFrom: { type: payload.type, name: payload.name}}); }); this.sourceEmitter.on('discoveredToScrobble', async (payload: { data: (PlayObject | PlayObject[]), options: { forceRefresh?: boolean, checkTime?: Dayjs, scrobbleTo?: string[], scrobbleFrom?: string } }) => { @@ -468,7 +466,7 @@ ${sources.join('\n')}`); this.clients.push(newClient); } - playingNow = async (data: (PlayObject | PlayObject[]), options: {scrobbleTo: string[], scrobbleFrom: SourceIdentifier}) => { + playingNow = async (data: SourcePlayerObj, options: {scrobbleTo: string[], scrobbleFrom: SourceIdentifier}) => { const playObjs = Array.isArray(data) ? data : [data]; const { scrobbleTo = [], diff --git a/src/backend/tests/scrobbler/scrobblers.test.ts b/src/backend/tests/scrobbler/scrobblers.test.ts index 5dbab82f..e3c1de16 100644 --- a/src/backend/tests/scrobbler/scrobblers.test.ts +++ b/src/backend/tests/scrobbler/scrobblers.test.ts @@ -11,7 +11,7 @@ import { genGroupIdStr, sleep } from "../../utils.js"; import mixedDuration from '../plays/mixedDuration.json' with { type: 'json' }; import withDuration from '../plays/withDuration.json' with { type: 'json' }; import { MockNetworkError, withRequestInterception } from "../utils/networking.js"; -import { asPlays, generatePlay, generatePlayPlatformId, generatePlays, normalizePlays } from "../utils/PlayTestUtils.js"; +import { asPlays, generatePlay, generatePlayPlatformId, generatePlays, generateSourcePlayerObj, normalizePlays } from "../utils/PlayTestUtils.js"; import MockDate from 'mockdate'; import { NowPlayingScrobbler, TestAuthScrobbler, TestScrobbler } from "./TestScrobbler.js"; @@ -776,12 +776,12 @@ describe('Now Playing', function() { const pt = dayjs().subtract(15, 's'); - npScrobbler.queuePlayingNow(generatePlay({playDate: pt}, {deviceId: genGroupIdStr(secondPlatform)}), {type: 'spotify', name: 'test'}); - npScrobbler.queuePlayingNow(generatePlay({playDate: pt}, {deviceId: genGroupIdStr(firstPlatform)}), {type: 'spotify', name: 'test'}); + npScrobbler.queuePlayingNow(generateSourcePlayerObj({play: generatePlay({playDate: pt}, {deviceId: genGroupIdStr(secondPlatform)})}) , {type: 'spotify', name: 'test'}); + npScrobbler.queuePlayingNow(generateSourcePlayerObj({play: generatePlay({playDate: pt}, {deviceId: genGroupIdStr(firstPlatform)})}), {type: 'spotify', name: 'test'}); const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue); - expect(toReport.meta.deviceId).eq(genGroupIdStr(firstPlatform)); + expect(toReport.play.meta.deviceId).eq(genGroupIdStr(firstPlatform)); }); @@ -793,16 +793,16 @@ describe('Now Playing', function() { const firstPlatform: PlayPlatformId = ['aaa', 'NO_USER']; const secondPlatform: PlayPlatformId = ['bbbb', 'NO_USER']; - npScrobbler.nowPlayingLastPlay = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}); + npScrobbler.nowPlayingLastPlay = generateSourcePlayerObj({play: generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}); const pt = dayjs().subtract(15, 's'); - npScrobbler.queuePlayingNow(generatePlay({playDate: pt}, {deviceId: genGroupIdStr(secondPlatform)}), {type: 'spotify', name: 'test'}); - npScrobbler.queuePlayingNow(generatePlay({playDate: pt}, {deviceId: genGroupIdStr(firstPlatform)}), {type: 'spotify', name: 'test'}); + npScrobbler.queuePlayingNow(generateSourcePlayerObj({play:generatePlay({playDate: pt}, {deviceId: genGroupIdStr(secondPlatform)})}), {type: 'spotify', name: 'test'}); + npScrobbler.queuePlayingNow(generateSourcePlayerObj({play:generatePlay({playDate: pt}, {deviceId: genGroupIdStr(firstPlatform)})}), {type: 'spotify', name: 'test'}); const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue); - expect(toReport.meta.deviceId).eq(genGroupIdStr(firstPlatform)); + expect(toReport.play.meta.deviceId).eq(genGroupIdStr(firstPlatform)); }); @@ -816,17 +816,17 @@ describe('Now Playing', function() { const pt = dayjs().subtract(15, 's'); - const stickyNp = generatePlay({playDate: pt}, {deviceId: genGroupIdStr(secondPlatform)}); + const stickyNp = generateSourcePlayerObj({play: generatePlay({playDate: pt}, {deviceId: genGroupIdStr(secondPlatform)})}); npScrobbler.nowPlayingLastPlay = stickyNp - npScrobbler.queuePlayingNow(generatePlay({playDate: pt}, {deviceId: genGroupIdStr(firstPlatform)}), {type: 'spotify', name: 'test'}); + npScrobbler.queuePlayingNow(generateSourcePlayerObj({play:generatePlay({playDate: pt}, {deviceId: genGroupIdStr(firstPlatform)})}), {type: 'spotify', name: 'test'}); npScrobbler.queuePlayingNow(stickyNp, {type: 'spotify', name: 'test'}); const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue); - expect(toReport.meta.deviceId).eq(genGroupIdStr(secondPlatform)); + expect(toReport.play.meta.deviceId).eq(genGroupIdStr(secondPlatform)); }); @@ -835,15 +835,15 @@ describe('Now Playing', function() { const npScrobbler = new NowPlayingScrobbler(); await npScrobbler.initialize(); - const a = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}); - const b = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}); + const a = generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}); + const b = generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}); npScrobbler.queuePlayingNow(b, {type: 'jellyfin', name: 'btest'}) npScrobbler.queuePlayingNow(a, {type: 'subsonic', name: 'atest'}) const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue); - expect(toReport.meta.deviceId).eq(a.meta.deviceId); + expect(toReport.play.meta.deviceId).eq(a.play.meta.deviceId); }); @@ -852,15 +852,15 @@ describe('Now Playing', function() { const npScrobbler = new NowPlayingScrobbler({name: 'test', options: {nowPlaying: ['btest', 'atest']}}); await npScrobbler.initialize(); - const a = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}); - const b = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}); + const a = generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}); + const b = generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}); npScrobbler.queuePlayingNow(a, {type: 'subsonic', name: 'atest'}) npScrobbler.queuePlayingNow(b, {type: 'jellyfin', name: 'btest'}) const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue); - expect(toReport.meta.deviceId).eq(b.meta.deviceId); + expect(toReport.play.meta.deviceId).eq(b.play.meta.deviceId); }); @@ -869,7 +869,7 @@ describe('Now Playing', function() { const npScrobbler = new NowPlayingScrobbler({name: 'test', options: {nowPlaying: ['btest', 'atest']}}); await npScrobbler.initialize(); - const c = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}); + const c = generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}); npScrobbler.queuePlayingNow(c, {type: 'jellyfin', name: 'ctest'}) @@ -887,7 +887,7 @@ describe('Now Playing', function() { const npScrobbler = new NowPlayingScrobbler(); await npScrobbler.initialize(); - const res = npScrobbler.shouldUpdatePlayingNow(generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})); + const res = npScrobbler.shouldUpdatePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})})); expect(res).to.be.true; }); @@ -896,8 +896,8 @@ describe('Now Playing', function() { const npScrobbler = new NowPlayingScrobbler(); await npScrobbler.initialize(); - const lastUpdate = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}); - npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingMaxThreshold(lastUpdate) + 1, 's'); + const lastUpdate = generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}); + npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingMaxThreshold(lastUpdate.play) + 1, 's'); npScrobbler.nowPlayingLastPlay = lastUpdate; const res = npScrobbler.shouldUpdatePlayingNow(lastUpdate); @@ -909,8 +909,8 @@ describe('Now Playing', function() { const npScrobbler = new NowPlayingScrobbler(); await npScrobbler.initialize(); - const lastUpdate = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}); - npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingMaxThreshold(lastUpdate) - 1, 's'); + const lastUpdate = generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}); + npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingMaxThreshold(lastUpdate.play) - 1, 's'); npScrobbler.nowPlayingLastPlay = lastUpdate; const res = npScrobbler.shouldUpdatePlayingNow(lastUpdate); @@ -922,11 +922,11 @@ describe('Now Playing', function() { const npScrobbler = new NowPlayingScrobbler(); await npScrobbler.initialize(); - const lastUpdate = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}); - npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingMinThreshold(lastUpdate) + 1, 's'); + const lastUpdate = generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}); + npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingMinThreshold(lastUpdate.play) + 1, 's'); npScrobbler.nowPlayingLastPlay = lastUpdate; - const res = npScrobbler.shouldUpdatePlayingNow(generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})); + const res = npScrobbler.shouldUpdatePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})})); expect(res).to.be.true; }); @@ -935,11 +935,11 @@ describe('Now Playing', function() { const npScrobbler = new NowPlayingScrobbler(); await npScrobbler.initialize(); - const lastUpdate = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}); - npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingMinThreshold(lastUpdate) - 1, 's'); + const lastUpdate = generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}); + npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingMinThreshold(lastUpdate.play) - 1, 's'); npScrobbler.nowPlayingLastPlay = lastUpdate; - const res = npScrobbler.shouldUpdatePlayingNow(generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})); + const res = npScrobbler.shouldUpdatePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})})); expect(res).to.be.false; }); @@ -958,7 +958,7 @@ describe('Now Playing', function() { await npScrobbler.initialize(); npScrobbler.scheduler.startById('pn_task'); - npScrobbler.queuePlayingNow(generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}), {type: 'jellyfin', name: 'test'}); + npScrobbler.queuePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}), {type: 'jellyfin', name: 'test'}); const res = await Promise.race([pEvent(npScrobbler.emitter, 'nowPlayingUpdated'), sleep(12)]); @@ -974,7 +974,7 @@ describe('Now Playing', function() { const now = dayjs(); - npScrobbler.queuePlayingNow(generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}), {type: 'jellyfin', name: 'test'}); + npScrobbler.queuePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}), {type: 'jellyfin', name: 'test'}); const res = await Promise.race([pEvent(npScrobbler.emitter, 'nowPlayingUpdated'), sleep(12)]); @@ -982,7 +982,7 @@ describe('Now Playing', function() { MockDate.set(now.add(npScrobbler.nowPlayingMinThreshold() + 3, 's').toDate()); - npScrobbler.queuePlayingNow(generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}), {type: 'jellyfin', name: 'test'}); + npScrobbler.queuePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}), {type: 'jellyfin', name: 'test'}); const resUpdate = await Promise.race([pEvent(npScrobbler.emitter, 'nowPlayingUpdated'), sleep(12)]); diff --git a/src/backend/tests/utils/PlayTestUtils.ts b/src/backend/tests/utils/PlayTestUtils.ts index ffc0e221..aee77b9c 100644 --- a/src/backend/tests/utils/PlayTestUtils.ts +++ b/src/backend/tests/utils/PlayTestUtils.ts @@ -5,9 +5,9 @@ import isBetween from "dayjs/plugin/isBetween.js"; import relativeTime from "dayjs/plugin/relativeTime.js"; import timezone from "dayjs/plugin/timezone.js"; import utc from "dayjs/plugin/utc.js"; -import { FEAT, JOINERS, JOINERS_FINAL, JsonPlayObject, MissingMbidType, 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'; +import { FEAT, JOINERS, JOINERS_FINAL, JsonPlayObject, MissingMbidType, ObjectPlayData, PlayMeta, PlayObject, SourcePlayerObj } from "../../../core/Atomic.js"; +import { genGroupIdStr, getPlatformIdFromData, sortByNewestPlayDate } from "../../utils.js"; +import { CALCULATED_PLAYER_STATUSES, NO_DEVICE, NO_USER, PlayerStateDataMaybePlay, PlayPlatformId, REPORTED_PLAYER_STATUSES, ReportedPlayerStatus, SINGLE_USER_PLATFORM_ID, SourceIdentifier } from '../../common/infrastructure/Atomic.js'; import { arrayListAnd } from '../../../core/StringUtils.js'; import { findDelimiters } from '../../utils/StringUtils.js'; import { ListRecord, ScrobbleRecord } from '../../common/infrastructure/config/client/tealfm.js'; @@ -428,4 +428,44 @@ export const generateTealPlayRecord = (opts: { } return [rec, { did, tid }]; -} \ No newline at end of file +} + +export interface GenerateSourcePlayerObjOptions { + playOpts?: Parameters[0] + play?: PlayObject + playPlatform?: string, + sourcePlayOpts?: Partial +} + +export const generateSourcePlayerObj = (opts: GenerateSourcePlayerObjOptions): SourcePlayerObj => { + let play: PlayObject; + let platformId: string; + if(opts.play !== undefined) { + platformId = opts.play.meta.deviceId; + } else { + platformId = opts.playPlatform ?? genGroupIdStr(SINGLE_USER_PLATFORM_ID); + } + play = opts.play ?? generatePlay(opts.playOpts, {deviceId:platformId}); + + const { + status = {}, + play: oPlay, + platformId: oplat, + ...rest + } = opts.sourcePlayOpts ?? {}; + + return { + platformId, + play, + listenedDuration: 60, + playerLastUpdatedAt: dayjs().toISOString(), + status: { + reported: REPORTED_PLAYER_STATUSES.playing, + calculated: CALCULATED_PLAYER_STATUSES.playing, + stale: false, + orphaned: false, + ...status + }, + ...rest + } +} \ No newline at end of file diff --git a/src/core/Atomic.ts b/src/core/Atomic.ts index 2dd3fa2d..b7af1768 100644 --- a/src/core/Atomic.ts +++ b/src/core/Atomic.ts @@ -5,6 +5,7 @@ import { AdditionalTrackInfoResponse } from "../backend/common/vendor/listenbrai import { Delta } from 'jsondiffpatch'; import { MarkOptional } from "ts-essentials"; import { ErrorObject } from "serialize-error"; +import { PlayPlatformIdStr } from "../backend/common/infrastructure/Atomic.js"; export interface SourceStatusData { status: string; @@ -357,8 +358,8 @@ export interface LogOutputConfig { } export interface SourcePlayerObj { - platformId: string, - play: PlayObject, + platformId: PlayPlatformIdStr, + play?: PlayObject, playFirstSeenAt?: string, playLastUpdatedAt?: string, playerLastUpdatedAt: string @@ -374,7 +375,7 @@ export interface SourcePlayerObj { } export interface SourcePlayerJson extends Omit { - play: JsonPlayObject + play?: JsonPlayObject } export interface SourceScrobble { @@ -386,6 +387,8 @@ export interface QueuedScrobble extends SourceScrobble { id: string } +export type NowPlayingUpdateThreshold = (play?: PlayObject) => number; + export interface DeadLetterScrobble extends QueuedScrobble { id: string retries: number -- 2.51.2 From f5c8ce51090d5e44676616de9c8cba016814e845 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 16 Feb 2026 21:08:06 +0000 Subject: [PATCH 11/18] more now playing fixes and improvements --- .../scrobblers/AbstractScrobbleClient.ts | 15 +++++++++------ src/backend/scrobblers/ScrobbleClients.ts | 5 ++++- src/backend/utils.ts | 12 ++++++++++++ src/backend/utils/StringUtils.ts | 17 ++++++++++++++++- 4 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/backend/scrobblers/AbstractScrobbleClient.ts b/src/backend/scrobblers/AbstractScrobbleClient.ts index 6b5a5e7e..ba20bba5 100644 --- a/src/backend/scrobblers/AbstractScrobbleClient.ts +++ b/src/backend/scrobblers/AbstractScrobbleClient.ts @@ -70,6 +70,8 @@ import { redactString } from "@foxxmd/redact-string"; type PlatformMappedPlays = Map; type NowPlayingQueue = Map; +const platformTruncate = truncateStringToLength(10); + export default abstract class AbstractScrobbleClient extends AbstractComponent implements Authenticatable { name: string; @@ -1010,7 +1012,7 @@ ${closestMatch.breakdowns.join('\n')}`, {leaf: ['Dupe Check']}); if(data.play !== undefined) { playHint = ` with Play ${buildTrackString(data.play, {include: ['artist', 'track', 'platform']})}` } - this.npLogger.debug(`Queueing Player ${redactString(data.platformId, 10)} ${data.status.calculated.toLocaleUpperCase()}${playHint} from ${sourceId}`); + this.npLogger.debug(`Queueing Player ${platformTruncate(data.platformId)} ${data.status.calculated.toLocaleUpperCase()}${playHint} from ${sourceId}`); } const platformPlays = this.nowPlayingQueue.get(sourceId) ?? new Map(); platformPlays.set(data.platformId, {player: data, source}); @@ -1048,13 +1050,14 @@ ${closestMatch.breakdowns.join('\n')}`, {leaf: ['Dupe Check']}); const lastUpdateDiff = Math.abs(dayjs().diff(this.nowPlayingLastUpdated, 's')); - const removedPlay = this.nowPlayingLastPlay.play !== undefined && data.play === undefined; + const playExistingDiscrepancy = (this.nowPlayingLastPlay.play !== undefined && data.play === undefined) || (this.nowPlayingLastPlay === undefined && data.play !== undefined); + const bothPlaysExist = this.nowPlayingLastPlay.play !== undefined && data.play !== undefined; - const playerStatusChanged = this.nowPlayingLastPlay.status.calculated !== data.status.calculated || this.nowPlayingLastPlay.status.reported !== data.status.reported; + const playerStatusChanged = this.nowPlayingLastPlay.status.calculated !== data.status.calculated; // update if play *has* changed and time since last update is greater than min interval // this prevents spamming scrobbler API with updates if user is skipping tracks and source updates frequently - if(this.nowPlayingMinThreshold(data.play) < lastUpdateDiff && (removedPlay || playerStatusChanged || !playObjDataMatch(data.play, this.nowPlayingLastPlay.play))) { + if(this.nowPlayingMinThreshold(data.play) < lastUpdateDiff && (playExistingDiscrepancy || playerStatusChanged || (bothPlaysExist && !playObjDataMatch(data.play, this.nowPlayingLastPlay.play)))) { if(isDebugMode()) { this.npLogger.debug(`New Play differs from previous Now Playing and time since update ${lastUpdateDiff}s, greater than threshold ${this.nowPlayingMinThreshold(data.play)}. Should update`); } @@ -1062,7 +1065,7 @@ ${closestMatch.breakdowns.join('\n')}`, {leaf: ['Dupe Check']}); } // update if play *has not* changed but last update is greater than max interval // this keeps scrobbler Now Playing fresh ("active" indicator) in the event play is long - if(this.nowPlayingMaxThreshold(data.play) < lastUpdateDiff && playObjDataMatch(data.play, this.nowPlayingLastPlay.play)) { + if(this.nowPlayingMaxThreshold(data.play) < lastUpdateDiff && (bothPlaysExist && playObjDataMatch(data.play, this.nowPlayingLastPlay.play))) { if(isDebugMode()) { this.npLogger.debug(`Now Playing last updated ${lastUpdateDiff}s ago, greater than threshold ${this.nowPlayingMaxThreshold(data.play)}s. Should update`); } @@ -1070,7 +1073,7 @@ ${closestMatch.breakdowns.join('\n')}`, {leaf: ['Dupe Check']}); } if(isDebugMode()) { - this.npLogger.debug(`Now Playing ${playObjDataMatch(data.play, this.nowPlayingLastPlay.play) ? 'matches' : 'does not match'} and was last updated ${lastUpdateDiff}s ago (threshold ${this.nowPlayingMaxThreshold(data.play)}s), not updating`); + this.npLogger.debug(`Now Playing ${bothPlaysExist && playObjDataMatch(data.play, this.nowPlayingLastPlay.play) ? 'matches' : 'does not match'} and was last updated ${lastUpdateDiff}s ago (threshold ${this.nowPlayingMaxThreshold(data.play)}s), not updating`); } return false; } diff --git a/src/backend/scrobblers/ScrobbleClients.ts b/src/backend/scrobblers/ScrobbleClients.ts index cb8007a0..8d5e054b 100644 --- a/src/backend/scrobblers/ScrobbleClients.ts +++ b/src/backend/scrobblers/ScrobbleClients.ts @@ -302,7 +302,10 @@ export default class ScrobbleClients { token: process.env.DISCORD_TOKEN, artwork: process.env.DISCORD_ARTWORK, applicationId: process.env.DISCORD_APPLICATION_ID, - artworkDefaultUrl: process.env.DISCORD_ARTWORK_DEFAULT_URL + artworkDefaultUrl: process.env.DISCORD_ARTWORK_DEFAULT_URL, + statusOverrideAllow: process.env.DISCORD_STATUS_OVERRIDE_ALLOW, + activitiesOverrideAllow: process.env.DISCORD_ACTIVITIES_OVERRIDE_ALLOW, + applicationsOverrideDisallow: process.env.DISCORD_APPNAME_OVERRIDE_DISALLOW } if (!Object.values(discord).every(x => x === undefined)) { configs.push({ diff --git a/src/backend/utils.ts b/src/backend/utils.ts index e0793126..7f99402b 100644 --- a/src/backend/utils.ts +++ b/src/backend/utils.ts @@ -353,6 +353,18 @@ export function parseBool(value: any, prev: any = false): boolean { throw new Error(`'${value.toString()}' is not a boolean value.`); } +export function parseBoolStrict(value: string): boolean { + const strTrue = ['1', 'true', 'yes'].includes(value.toLocaleLowerCase().trim()); + if (strTrue) { + return strTrue; + } + const strFalse = ['0', 'false', 'no'].includes(value.toLocaleLowerCase().trim()); + if (strFalse) { + return false; + } + throw new Error(`'${value.toString()}' is not a strict boolean value.`); +} + export const genGroupIdStrFromPlay = (play: PlayObject) => { const groupId = genGroupId(play); return genGroupIdStr(groupId); diff --git a/src/backend/utils/StringUtils.ts b/src/backend/utils/StringUtils.ts index 2697898a..577ab881 100644 --- a/src/backend/utils/StringUtils.ts +++ b/src/backend/utils/StringUtils.ts @@ -2,7 +2,7 @@ import { strategies, stringSameness, StringSamenessResult } from "@foxxmd/string import { hasher } from 'node-object-hash'; import { PlayObject } from "../../core/Atomic.js"; import { asPlayerStateData, DELIMITERS, DELIMITERS_NO_AMP, PlayerStateDataMaybePlay } from "../common/infrastructure/Atomic.js"; -import { genGroupIdStr, getPlatformIdFromData, intersect, parseRegexSingleOrFail } from "../utils.js"; +import { genGroupIdStr, getPlatformIdFromData, intersect, parseBool, parseBoolStrict, parseRegexSingleOrFail } from "../utils.js"; import { buildTrackString } from "../../core/StringUtils.js"; const {levenStrategy, diceStrategy} = strategies; @@ -412,6 +412,21 @@ export const parseArrayFromMaybeString = (value: string | string[] = '', opts: A return arr; } +export const parseBoolOrArrayFromMaybeString = (value: string | string[] | boolean = '', opts: ArrParseOpts = {}): string[] | boolean => { + if (typeof value === 'boolean') { + return value; + } + if(Array.isArray(value)) { + return value; + } + try { + return parseBoolStrict(value); + } catch (e) { + // not a strict bool value + } + return parseArrayFromMaybeString(value, opts); +} + export const firstNonEmptyStr = (vals: unknown[]): string | undefined => { for(const val of vals) { if(val !== undefined && val !== null && typeof val !== 'object') { -- 2.51.2 From 53c8036e343e28fd6d3bbc35aaef4635c63c36f5 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 16 Feb 2026 21:08:23 +0000 Subject: [PATCH 12/18] feat(discord): Add more presence configuration options --- .../docs/configuration/clients/discord.mdx | 199 ++++++++++++++-- .../infrastructure/config/client/discord.ts | 15 +- .../common/vendor/discord/DiscordWSClient.ts | 216 ++++++++++++++++-- src/backend/scrobblers/DiscordScrobbler.ts | 52 +++-- 4 files changed, 424 insertions(+), 58 deletions(-) diff --git a/docsite/docs/configuration/clients/discord.mdx b/docsite/docs/configuration/clients/discord.mdx index ae4d8fca..017f5366 100644 --- a/docsite/docs/configuration/clients/discord.mdx +++ b/docsite/docs/configuration/clients/discord.mdx @@ -27,15 +27,27 @@ Multi-scrobbler does its upmost to implement, to-spec, [Gateway API communicatio Aside from the on-paper violation of using a user token programmatically (on your behalf), there is no misuse of the API to achieve rich presence. -Additionally, MS uses the state of non-MS sessions to determine if it should update presence at all. Essentially, it will not act on your behalf unless you are: +Additionally, MS uses the state of non-MS sessions to conservatively update presence. [By default it will not conflict](#configure-when-presence-is-used) with any other official presence activities and is only used when you are actually active on a device. -* using an official client -* physically online -* not invisible + + + + +Every time multi-scrobbler is started a discord session update needs to be triggered so that [MS can capture the signals it uses to determine if presence can be updated.](#configure-when-presence-is-used) This only needs to be *once*, after starting multi-scrobbler. But it does need to be *every time* multi-scrobbler is (re)started. Without these signals MS will not update presence. + +This session update can happen automatically after *some* time so you may not need to do anything. If you want to force the update then do one of the following: + +* Open discord on a device it was not recently active on +* Exit discord on an active device +* Change your status (online, idle, etc...) +* Set a custom status +* Cause a presence update with a different app (listening to..., playing..., etc.) -## User Token +## Required Setup + +### User Token You must provide a User Token for this scrobbler to work. @@ -49,11 +61,167 @@ When finding instructions for obtaining this token only run commands, or follow ::: -## Artwork +## Optional Setup + +### Configure When Presence Is Used + +MS uses several signals from your "real" Discord sessions to determine if it should update presence. The default configuration is **extremely** conservative to ensure that MS does not conflict with other apps using presence. Additionally, it only updates if a "real" user device is active on discord. + +:::tip[TLDR] + +Without any additional configuration, MS will only update presence if: + +* you have discord open on a real device +* your status is either: online, idle, or dnd (not invisible) +* no other apps are broadcasting presence (no other listening, playing, competing, etc... activities on your profile) + +::: + +#### Online Status + +Configure if MS is allowed to update presence based on your online status. If this setting is not defined MS will only update if your status is **online**, **idle**, or **dnd**. + +Allowed Values: `online` `idle` `dnd` `invisible` + +
+ +Example + +Only allow presence updating when all of your "real" sessions are either online or idle. + + +In File/AIO: + +```json +[ + { + "name": "MS", + "enable": true, + "data": { + "token": "Ma7XBvzRERK3gQdkX4dkb3OQ.6aaGmJ.rvFiiVrzG4VXHHL3LyhZYhpWdG3Ph_if9aKz5E", + "applicationId": "8190211179716453570", + "statusOverrideAllow": ["online", "idle"] + } + } + ] +``` + +In ENV: + +``` +DISCORD_STATUS_OVERRIDE_ALLOW=online,idle +``` + +
+ +#### Activity Type + +Configure the activity types of **other** activities that MS is allowed to broadcast at the same time as. If this setting is not defined MS will only update if no other activities or only `custom`. + +Note: Discord shows both `custom` and other non `custom` activities at the same time. All other activities are mutually exclusive. + +Allowed Values: + +* `playing` `streaming` `listening` `custom` `competing` +* `true` => allow presence during *any* +* `false` => allow presence during *none* + +
+ +Example + +You want to allow MS to update presence when you are normally playing any game but not when any other activity (streaming, listening, competing) is happening. + +In File/AIO: + +```json +[ + { + "name": "MS", + "enable": true, + "data": { + "token": "Ma7XBvzRERK3gQdkX4dkb3OQ.6aaGmJ.rvFiiVrzG4VXHHL3LyhZYhpWdG3Ph_if9aKz5E", + "applicationId": "8190211179716453570", + "activitiesOverrideAllow": ["playing", "custom"] + } + } + ] +``` + +In ENV: + +``` +DISCORD_ACTIVITIES_OVERRIDE_ALLOW=playing,custom +``` + +___ + +You **do not** want to allow MS to update presence if **any** other activity is occurring, including `custom`. + +```json +[ + { + "name": "MS", + "enable": true, + "data": { + "token": "Ma7XBvzRERK3gQdkX4dkb3OQ.6aaGmJ.rvFiiVrzG4VXHHL3LyhZYhpWdG3Ph_if9aKz5E", + "applicationId": "8190211179716453570", + "activitiesOverrideAllow": false + } + } + ] +``` + +In ENV: + +``` +DISCORD_ACTIVITIES_OVERRIDE_ALLOW=false +``` + +
+ +#### Activity Name + +Configure the names of **other** activities that MS is **not** allowed to broadcast at the same time as. + +It is not required to use [Activity Type](#activity-type) with this setting, but it is useful. + +
+ +Example + +You want to allow MS to update presence when you are normally playing any game but not when any other activity (streaming, listening, competing) is happening. You also do not want to allow MS to update presence if the game is fortnite. + +In File/AIO: + +```json +[ + { + "name": "MS", + "enable": true, + "data": { + "token": "Ma7XBvzRERK3gQdkX4dkb3OQ.6aaGmJ.rvFiiVrzG4VXHHL3LyhZYhpWdG3Ph_if9aKz5E", + "applicationId": "8190211179716453570", + "activitiesOverrideAllow": ["playing", "custom"], + "applicationsOverrideDisallow": ["fortnite"] + } + } + ] +``` + +In ENV: + +``` +DISCORD_ACTIVITIES_OVERRIDE_ALLOW=playing,custom +DISCORD_APPNAME_OVERRIDE_DISALLOW=fortnite +``` +
+ +### Artwork For some Sources, MS parses album art and displays it in the dashboard. If a Discord **Application Id** is provided then this image can be displayed on Discord alongside your listening status. -If no Application Id is provided Discord will always show a default image next to your status. +**If no Application Id is provided Discord will use its own default image next to your status.**
@@ -80,7 +248,7 @@ There are some scenarios where you may not want to use your artwork URLs, for ex MS is conservative in this context by **disallowing** use of album art URLs **by default.** Additionally, it **always** disallows use of these URLs if they do not start with `https`. -* To allow **any** `https` album art URLs set ENV `DISCORD_ARTWORK=true` +* To allow **any** `https` album art URLs set ENV `DISCORD_ARTWORK=true` (or in file as `"artwork": true`) * To allow only `https` album art URLs from certain domains use a list of keywords like `DISCORD_ARTWORK=spotify,jellyfin` (or in file as `"artwork": ["spotify","jellyfin"]`) If a listening activity does not have album art, or the URL is disallowed, MS will use the [multi-scrobbler logo](https://github.com/FoxxMD/multi-scrobbler/blob/master/assets/icon.png) as a fallback image. @@ -92,10 +260,13 @@ You can customize this fallback image with ENV `DISCORD_ARTWORK_DEFAULT_URL` or ## Configuration - | Environmental Variable | Required? | Default | Description | - | :---------------------------- | --------- | ------- | :--------------------------------------------------------------------------------------------------------------- | - | `DISCORD_TOKEN` | Yes | | User Token acquired from an active Discord sessions | - | `DISCORD_APPLICATION_ID` | Yes | | Application ID for user with album art | - | `DISCORD_ARTWORK` | No | | A boolean indicating if external artwork URLs should be used. Or a comma-separated list of allowed domains | - | `DISCORD_ARTWORK_DEFAULT_URL` | No | | A URL of an image to use as a fallback if the album art URL cannot be used. Or `false` to use discord's default. | + | Environmental Variable | Required? | Default | Description | + | :---------------------------------- | --------- | ----------------- | :--------------------------------------------------------------------------------------------------------------- | + | `DISCORD_TOKEN` | Yes | | User Token acquired from an active Discord sessions | + | `DISCORD_APPLICATION_ID` | No | | Application ID used to display album art | + | `DISCORD_ARTWORK` | No | | A boolean indicating if external artwork URLs should be used. Or a comma-separated list of allowed domains | + | `DISCORD_ARTWORK_DEFAULT_URL` | No | | A URL of an image to use as a fallback if the album art URL cannot be used. Or `false` to use discord's default. | + | `DISCORD_STATUS_OVERRIDE_ALLOW` | No | `online,idle,dnd` | A comma-separated list of statuses are allowed to have presence. | + | `DISCORD_ACTIVITIES_OVERRIDE_ALLOW` | No | `custom` | A comma-seperated list of *other* activity types MS can broadcast presence at the same time as. | + | `DISCORD_APPNAME_OVERRIDE_DISALLOW` | No | | A commera-seperated list of activity names MS is *not* allowed to broadcast presence at the same time as. | \ No newline at end of file diff --git a/src/backend/common/infrastructure/config/client/discord.ts b/src/backend/common/infrastructure/config/client/discord.ts index 8191a697..82aef02e 100644 --- a/src/backend/common/infrastructure/config/client/discord.ts +++ b/src/backend/common/infrastructure/config/client/discord.ts @@ -1,4 +1,3 @@ -import { RequestRetryOptions } from "../common.js" import { CommonClientConfig, CommonClientData } from "./index.js" export interface DiscordData { @@ -6,6 +5,9 @@ export interface DiscordData { applicationId?: string artwork?: boolean | string | string[] artworkDefaultUrl?: string | false + statusOverrideAllow?: string | StatusType[] + activitiesOverrideAllow?: boolean | string | ActivityType[] + applicationsOverrideDisallow?: string | string[] } export interface DiscordClientData extends DiscordData, CommonClientData {} @@ -23,4 +25,15 @@ export interface DiscordClientConfig extends CommonClientConfig { export interface DiscordClientAIOConfig extends DiscordClientConfig { type: 'discord' +} + +export type ActivityType = 'playing' | 'streaming' | 'listening' | 'watching' | 'custom' | 'competing'; +export const ActivityTypes: ActivityType[] = ['playing','streaming','listening','watching','custom','competing']; +export type StatusType = 'online' | 'idle' | 'dnd' | 'invisible'; + +export interface DiscordStrongData extends DiscordData { + artwork?: boolean | string[] + statusOverrideAllow?: StatusType[] + activitiesOverrideAllow?: ActivityType[] + applicationsOverrideDisallow?: string[] } \ No newline at end of file diff --git a/src/backend/common/vendor/discord/DiscordWSClient.ts b/src/backend/common/vendor/discord/DiscordWSClient.ts index aea0d3e9..8b54ebd5 100644 --- a/src/backend/common/vendor/discord/DiscordWSClient.ts +++ b/src/backend/common/vendor/discord/DiscordWSClient.ts @@ -1,7 +1,7 @@ import { childLogger } from "@foxxmd/logging"; import { WS } from 'iso-websocket' -import { DiscordClientData } from "../../infrastructure/config/client/discord.js"; -import { _DataPayload, _NonDispatchPayload, APIUser, GatewayActivity, GatewayActivityUpdateData, GatewayCloseCodes, GatewayDispatchEvents, GatewayHeartbeatRequest, GatewayHelloData, GatewayIdentify, GatewayIdentifyData, GatewayInvalidSessionData, GatewayOpcodes, GatewayPresenceUpdateData, GatewayReadyDispatchData, GatewayResumeData, GatewayUpdatePresence, PresenceUpdateStatus } from "discord.js"; +import { DiscordClientData, DiscordData, DiscordStrongData, StatusType, ActivityType as MSActivityType, ActivityTypes } from "../../infrastructure/config/client/discord.js"; +import { _DataPayload, _NonDispatchPayload, ActivityType, APIUser, GatewayActivity, GatewayActivityUpdateData, GatewayCloseCodes, GatewayDispatchEvents, GatewayHeartbeatRequest, GatewayHelloData, GatewayIdentify, GatewayIdentifyData, GatewayInvalidSessionData, GatewayOpcodes, GatewayPresenceUpdateData, GatewayReadyDispatchData, GatewayResumeData, GatewayUpdatePresence, PresenceUpdateStatus } from "discord.js"; import { isDebugMode, parseBool, removeUndefinedKeys, sleep } from "../../../utils.js"; import pEvent from 'p-event'; import EventEmitter from "events"; @@ -12,7 +12,7 @@ import { AbstractApiOptions, asPlayerStateData, SourceData } from "../../infrast import { isPlayObject, PlayObject } from "../../../../core/Atomic.js"; import dayjs from "dayjs"; import { capitalize } from "../../../../core/StringUtils.js"; -import { parseArrayFromMaybeString } from "../../../utils/StringUtils.js"; +import { parseArrayFromMaybeString, parseBoolOrArrayFromMaybeString } from "../../../utils/StringUtils.js"; import { getRoot } from "../../../ioc.js"; import { MSCache } from "../../Cache.js"; import { isSuperAgentResponseError } from "../../errors/ErrorUtils.js"; @@ -33,7 +33,7 @@ const API_GATEWAY_ENDPOINT = 'https://discord.com/api/gateway'; */ export class DiscordWSClient extends AbstractApiClient { - declare config: DiscordClientData; + declare config: DiscordStrongData; heartbeatInterval: NodeJS.Timeout acknowledged: boolean = true; @@ -56,33 +56,20 @@ export class DiscordWSClient extends AbstractApiClient { closeEvents: number = 0; lastActiveStatus?: PresenceUpdateStatus = PresenceUpdateStatus.Offline; + lastActivities: GatewayActivity[] = []; activityTimeout: NodeJS.Timeout; - artworkOpt: boolean | string[] = false; - emitter: EventEmitter; cache: MSCache; artFail: boolean = false; artFailCount = 0; - constructor(name: any, config: DiscordClientData, options: AbstractApiOptions) { + constructor(name: any, config: DiscordStrongData, options: AbstractApiOptions) { super('Discord', name, config, options); this.logger = childLogger(options.logger, 'WS Gateway'); this.emitter = new EventEmitter(); - if (typeof this.config.artwork === 'boolean' || Array.isArray(this.config.artwork)) { - this.artworkOpt = this.config.artwork; - } else if (typeof this.config.artwork === 'string') { - if (['true', 'false'].includes(this.config.artwork.toLocaleLowerCase())) { - this.artworkOpt = parseBool(this.config.artwork) - } else { - this.artworkOpt = parseArrayFromMaybeString(this.config.artwork) - } - } - if(this.config.artworkDefaultUrl !== undefined && typeof this.config.artworkDefaultUrl === 'string' && this.config.artworkDefaultUrl.toLocaleLowerCase().trim() === 'false') { - this.config.artworkDefaultUrl = false; - } this.cache = getRoot().items.cache(); } @@ -302,16 +289,34 @@ export class DiscordWSClient extends AbstractApiClient { this.sequence = undefined; this.resume_gateway_url = undefined; this.user = undefined; + this.lastActiveStatus = PresenceUpdateStatus.Offline; + this.lastActivities = []; } } handleUserSessionUpdates = (data: UserSession[]) => { this.logger.debug('Recieved updated user sessions'); - const otherSessions = data.filter(x => x.session_id !== this.session_id); - if (otherSessions.length === 0) { + if (data.filter(x => x.session_id !== this.session_id && x.session_id !== 'all').length === 0) { this.logger.debug('No other user sessions exist, marking our session presence as inactive'); this.lastActiveStatus = PresenceUpdateStatus.Offline; + this.lastActivities = []; + return; } + const otherSessions = data.filter(x => x.session_id !== this.session_id); + const sessionSummaries = otherSessions.map(x => { + let sessionId = `${x.session_id === 'all' ? '(All) | ' : ''}OS ${x.client_info.os} | Client ${x.client_info.client} | Status ${x.status} | Active ${x.active === true}`; + if(x.activities.length === 0) { + sessionId += " | 0 Activities" + } else { + const activitySummary = x.activities.map(x => x.type === 4 ? 'Custom Status' : `${activityIdToStr(x.type)} ${x.name}`).join(', '); + sessionId += ` | Activities => ${activitySummary}`; + }; + return sessionId; + }); + this.logger.debug(sessionSummaries.join('\n')); + + const last = this.lastActiveStatus; + if (otherSessions.some(x => x.status === 'online')) { this.lastActiveStatus = PresenceUpdateStatus.Online; } else if (otherSessions.some(x => x.status === 'dnd')) { @@ -324,6 +329,20 @@ export class DiscordWSClient extends AbstractApiClient { this.lastActiveStatus = PresenceUpdateStatus.Offline; } this.logger.debug(`Best status found: ${this.lastActiveStatus}`); + + this.lastActivities = otherSessions.filter(x => x.session_id !== 'all').map(x => x.activities).flat(1); + + const [allowed, reason] = this.presenceIsAllowed(); + if(!allowed) { + // if updated sessions now disallow updating presence + // and we have a current presence in our session + // then we need to remove it so it doesn't override anything + const ourSession = data.find(x => x.session_id === this.session_id); + if(ourSession !== undefined && ourSession.activities.length > 0) { + this.logger.debug(`Clearing our session presence, MS presence no longer allowed because ${reason}`); + this.clearActivity(); + } + } } async handleMessage(message: _DataPayload | _NonDispatchPayload) { @@ -367,7 +386,7 @@ export class DiscordWSClient extends AbstractApiClient { case 'SESSIONS_REPLACE': if (isDebugMode()) { // @ts-expect-error - this.logger.debug({ data: message.d }, t); + this.logger.debug(`${t} => ${JSON.stringify(message.d)}`); } // @ts-expect-error this.handleUserSessionUpdates(message.d as UserSession[]); @@ -406,7 +425,9 @@ export class DiscordWSClient extends AbstractApiClient { playStateToActivity = async (data: SourceData): Promise => { const { activity, artUrl } = playStateToActivityData(data); - const artwork = this.artworkOpt; + const { + artwork = false + } = this.config; const { artworkDefaultUrl = ARTWORK_PLACEHOLDER } = this.config; @@ -552,7 +573,43 @@ export class DiscordWSClient extends AbstractApiClient { } return; } + } + + presenceIsAllowedByStatus = (status?: PresenceUpdateStatus | StatusType): [boolean, string?] => { + if (!this.config.statusOverrideAllow.includes(status as StatusType ?? this.lastActiveStatus as StatusType)) { + return [false, `most active session has a disallowed status: ${status ?? this.lastActiveStatus}`]; + } + return [true]; + } + + presenceIsAllowedByActivity = (manualActivities?: GatewayActivity[]): [boolean, string?] => { + const activities = manualActivities ?? this.lastActivities; + if (activities.length !== 0) { + const disallowedActivityType = activities.find(x => !this.config.activitiesOverrideAllow.includes(activityIdToStr(x.type))); + if (disallowedActivityType !== undefined) { + return [false, `a session has an activity type MS is not allowed to override: ${activityIdToStr(disallowedActivityType.type)}`]; + } + const disallowedActivityName = activities.find(x => !this.config.applicationsOverrideDisallow.some(y => x.name.toLocaleLowerCase().includes(y.toLocaleLowerCase()))); + if (disallowedActivityType !== undefined) { + return [false, `a session has an activity name MS is not allowed to override: ${disallowedActivityName.name}`]; + } + } + + return [true]; + } + + presenceIsAllowed = (): [boolean, string?] => { + const [statusAllowed, statusReason] = this.presenceIsAllowedByStatus(); + if(!statusAllowed) { + return [statusAllowed, statusReason]; + } + + const [activityAllowed, activityReason] = this.presenceIsAllowedByActivity(); + if(!activityAllowed) { + return [activityAllowed, activityReason]; + } + return [true]; } } @@ -583,9 +640,21 @@ const opcodeToFriendly = (op: number) => { interface UserSession { status: 'online' | 'invisible' | 'dnd' | 'idle' + client_info: { + version: number + os: string + client: string + } + processed_at_timestamp?: number active?: boolean session_id: string - activities: [] + // activities: { + // state: string + // created_at: number + // type: ActivityType + // name: string + // }[] + activities: GatewayActivity[] } export const playStateToActivityData = (data: SourceData, opts: { useArt?: boolean } = {}): { activity: GatewayActivity, artUrl?: string } => { @@ -638,4 +707,103 @@ export const playStateToActivityData = (data: SourceData, opts: { useArt?: boole const artUrl = play.meta?.art?.album ?? play.meta.art.track ?? play.meta.art.artist; return { activity, artUrl }; +} + +export const statusStringToType = (str: string): StatusType => { + switch(str.trim().toLocaleLowerCase()) { + case 'online': + return PresenceUpdateStatus.Online; + case 'idle': + return PresenceUpdateStatus.Idle; + case 'dnd': + return PresenceUpdateStatus.DoNotDisturb; + case 'invisible': + return PresenceUpdateStatus.Invisible; + default: + throw new Error(`Not a valid status type. Must be one of: online | idle | dnd | invisible`); + } +} + +export const activityStringToType = (str: string): MSActivityType => { + switch(str.trim().toLocaleLowerCase()) { + case 'playing': + return 'playing'; + case 'streaming': + return 'streaming'; + case 'listening': + return 'listening'; + case 'watching': + return 'watching'; + case 'custom': + return 'custom'; + case 'competing': + return 'competing'; + default: + throw new Error(`Not a valid activity type. Must be one of: playing | streaming | listening | watching | custom | competing`); + } +} + +export const activityIdToStr = (id: number): MSActivityType => { + switch(id) { + case 0: + return 'playing'; + case 1: + return 'streaming'; + case 2: + return 'listening'; + case 3: + return 'watching'; + case 4: + return 'custom'; + case 5: + return 'competing'; + default: + throw new Error(`Not a valid activity type. Must be one of: playing | streaming | listening | watching | custom | competing`); + } +} + +export const configToStrong = (data: DiscordData): DiscordStrongData => { + const { + token, + applicationId, + artwork, + artworkDefaultUrl, + statusOverrideAllow = ['online','idle','dnd'], + activitiesOverrideAllow = ['custom'], + applicationsOverrideDisallow = [] + } = data; + + const strongConfig: DiscordStrongData = { + token, + applicationId, + applicationsOverrideDisallow: parseArrayFromMaybeString(applicationsOverrideDisallow) + } + + if (typeof artwork === 'boolean' || Array.isArray(artwork)) { + strongConfig.artwork = artwork; + } else if (typeof artwork === 'string') { + if (['true', 'false'].includes(artwork.toLocaleLowerCase())) { + strongConfig.artwork = parseBool(artwork) + } else { + strongConfig.artwork = parseArrayFromMaybeString(artwork) + } + } + + if(artworkDefaultUrl !== undefined && typeof artworkDefaultUrl === 'string' && artworkDefaultUrl.toLocaleLowerCase().trim() === 'false') { + strongConfig.artworkDefaultUrl = false; + } else { + strongConfig.artworkDefaultUrl = artworkDefaultUrl; + } + + const saRaw = parseArrayFromMaybeString(statusOverrideAllow); + strongConfig.statusOverrideAllow = saRaw.map(statusStringToType); + + const aaRaw = parseBoolOrArrayFromMaybeString(activitiesOverrideAllow); + if(typeof aaRaw === 'boolean') { + strongConfig.activitiesOverrideAllow = aaRaw ? ActivityTypes : []; + } else { + strongConfig.activitiesOverrideAllow = aaRaw.map(activityStringToType); + } + + return strongConfig; } \ No newline at end of file diff --git a/src/backend/scrobblers/DiscordScrobbler.ts b/src/backend/scrobblers/DiscordScrobbler.ts index 48d46c27..9f65aef3 100644 --- a/src/backend/scrobblers/DiscordScrobbler.ts +++ b/src/backend/scrobblers/DiscordScrobbler.ts @@ -1,13 +1,12 @@ import { Logger } from "@foxxmd/logging"; import EventEmitter from "events"; import { PlayObject, SourcePlayerObj } from "../../core/Atomic.js"; -import { CALCULATED_PLAYER_STATUSES, CalculatedPlayerStatus, FormatPlayObjectOptions, REPORTED_PLAYER_STATUSES, ReportedPlayerStatus } from "../common/infrastructure/Atomic.js"; +import { CALCULATED_PLAYER_STATUSES, FormatPlayObjectOptions, REPORTED_PLAYER_STATUSES, ReportedPlayerStatus } from "../common/infrastructure/Atomic.js"; import { Notifiers } from "../notifier/Notifiers.js"; import AbstractScrobbleClient, { nowPlayingUpdateByPlayDuration } from "./AbstractScrobbleClient.js"; -import { DiscordClientConfig } from "../common/infrastructure/config/client/discord.js"; -import { DiscordWSClient, playStateToActivityData } from "../common/vendor/discord/DiscordWSClient.js"; -import { PresenceUpdateStatus } from "discord.js"; +import { DiscordClientConfig, DiscordStrongData, StatusType } from "../common/infrastructure/config/client/discord.js"; +import { configToStrong, DiscordWSClient, playStateToActivityData } from "../common/vendor/discord/DiscordWSClient.js"; export default class DiscordScrobbler extends AbstractScrobbleClient { @@ -15,11 +14,12 @@ export default class DiscordScrobbler extends AbstractScrobbleClient { requiresAuth = true; requiresAuthInteraction = false; - declare config: DiscordClientConfig; + declare config: DiscordClientConfig & {data: DiscordStrongData }; constructor(name: any, config: DiscordClientConfig, options = {}, notifier: Notifiers, emitter: EventEmitter, logger: Logger) { - super('discord', name, config, notifier, emitter, logger); - this.api = new DiscordWSClient(name, { ...config.data, ...config.options }, { logger: this.logger }); + const strong = configToStrong(config.data); + super('discord', name, {...config, data: strong}, notifier, emitter, logger); + this.api = new DiscordWSClient(name, { ...strong, ...config.options }, { logger: this.logger }); this.api.emitter.on('stopped', async (e) => { if(e.authFailure) { this.authFailure = true; @@ -43,6 +43,15 @@ export default class DiscordScrobbler extends AbstractScrobbleClient { if (token === undefined) { throw new Error('Must provide a user token'); } + if(typeof this.config.data.artwork === 'boolean') { + this.logger.verbose(`Artwork: ${this.config.data.artwork ? 'Allow any with HTTPS' : 'Allow none'}`); + } else { + this.logger.verbose(`Artwork: Allow HTTPS with these domains: ${this.config.data.artwork.join(', ')}`); + } + this.logger.verbose(`Artwork Fallback Url: ${this.config.data.artworkDefaultUrl}`); + this.logger.verbose(`Allow override statuses: ${this.config.data.statusOverrideAllow.join(', ')}`); + this.logger.verbose(`Allow override activity types: ${this.config.data.activitiesOverrideAllow.join(', ')}`); + this.logger.verbose(`Disallow override activity names: ${this.config.data.applicationsOverrideDisallow.join(', ')}`); await this.api.initClient(); return true; } @@ -83,18 +92,23 @@ export default class DiscordScrobbler extends AbstractScrobbleClient { } shouldUpdatePlayingNowPlatformSpecific = async (data: SourcePlayerObj) => { - if(data.status.reported === REPORTED_PLAYER_STATUSES.playing - || [CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused].includes(data.status.calculated as ReportedPlayerStatus) - || data.status.stale) - if ([PresenceUpdateStatus.Offline, PresenceUpdateStatus.Invisible].includes(this.api.lastActiveStatus)) { - this.logger.debug('Not updating presence because no user sessions have a visible status'); - return false; - } - const [sendOk, reasons] = this.api.checkOkToSend(); - if (!sendOk) { - this.logger.warn(`Cannot update playing now because api client is ${reasons}`); - return false; + if ([CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused, CALCULATED_PLAYER_STATUSES.playing].includes(data.status.calculated as ReportedPlayerStatus) + || data.status.stale) { + + const [sendOk, reasons] = this.api.checkOkToSend(); + if (!sendOk) { + this.logger.warn(`Cannot update playing now because api client is ${reasons}`); + return false; + } + + const [allowed, reason] = this.api.presenceIsAllowed(); + if(!allowed) { + this.logger.debug(reason); + } + + return true; + } - return true; + return false; } } -- 2.51.2 From 61b82778221a389783d0ea3fbfbcdde957141633 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 17 Feb 2026 19:05:17 +0000 Subject: [PATCH 13/18] feat(discord): Add actionable links for public URLs and musicbrainz Add links to album art and/or small image if application id enabled --- assets/musicbrainz-logo-small.png | Bin 0 -> 4349 bytes .../common/vendor/ListenbrainzApiClient.ts | 17 ++++ .../common/vendor/discord/DiscordWSClient.ts | 75 ++++++++++++++++-- src/backend/sources/JellyfinApiSource.ts | 5 +- src/backend/sources/WebScrobblerSource.ts | 1 + 5 files changed, 89 insertions(+), 9 deletions(-) create mode 100644 assets/musicbrainz-logo-small.png diff --git a/assets/musicbrainz-logo-small.png b/assets/musicbrainz-logo-small.png new file mode 100644 index 0000000000000000000000000000000000000000..cf9efc395d1eea87feb23713deac99fe723b51bc GIT binary patch literal 4349 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 { return undefined; } +/** + * Returns a known music service based on the given URL + * @see https://listenbrainz.readthedocs.io/en/latest/users/json.html#payload-json-details + * */ +export const urlToMusicService = (url?: string): string | undefined => { + if(url === undefined) { + return undefined; + } + const lower = url.trim().toLocaleLowerCase(); + for(const [k, v] of Object.entries(musicServices)) { + if(url.includes(v)) { + return k; + } + } + return undefined; +} + export const playToSubmitPayload = (play: PlayObject, options: SubmitOptions = {}): SubmitPayload => { const { listenType = 'single'} = options; const listenPayload: SubmitPayload = {listen_type: listenType, payload: [playToListenPayload(play)]}; diff --git a/src/backend/common/vendor/discord/DiscordWSClient.ts b/src/backend/common/vendor/discord/DiscordWSClient.ts index 8b54ebd5..a641df13 100644 --- a/src/backend/common/vendor/discord/DiscordWSClient.ts +++ b/src/backend/common/vendor/discord/DiscordWSClient.ts @@ -1,7 +1,7 @@ import { childLogger } from "@foxxmd/logging"; import { WS } from 'iso-websocket' import { DiscordClientData, DiscordData, DiscordStrongData, StatusType, ActivityType as MSActivityType, ActivityTypes } from "../../infrastructure/config/client/discord.js"; -import { _DataPayload, _NonDispatchPayload, ActivityType, APIUser, GatewayActivity, GatewayActivityUpdateData, GatewayCloseCodes, GatewayDispatchEvents, GatewayHeartbeatRequest, GatewayHelloData, GatewayIdentify, GatewayIdentifyData, GatewayInvalidSessionData, GatewayOpcodes, GatewayPresenceUpdateData, GatewayReadyDispatchData, GatewayResumeData, GatewayUpdatePresence, PresenceUpdateStatus } from "discord.js"; +import { _DataPayload, _NonDispatchPayload, ActivityType, APIUser, GatewayActivity, GatewayActivityButton, GatewayActivityUpdateData, GatewayCloseCodes, GatewayDispatchEvents, GatewayHeartbeatRequest, GatewayHelloData, GatewayIdentify, GatewayIdentifyData, GatewayInvalidSessionData, GatewayOpcodes, GatewayPresenceUpdateData, GatewayReadyDispatchData, GatewayResumeData, GatewayUpdatePresence, PresenceUpdateStatus } from "discord.js"; import { isDebugMode, parseBool, removeUndefinedKeys, sleep } from "../../../utils.js"; import pEvent from 'p-event'; import EventEmitter from "events"; @@ -16,8 +16,10 @@ import { parseArrayFromMaybeString, parseBoolOrArrayFromMaybeString } from "../. import { getRoot } from "../../../ioc.js"; import { MSCache } from "../../Cache.js"; import { isSuperAgentResponseError } from "../../errors/ErrorUtils.js"; +import { urlToMusicService } from "../ListenbrainzApiClient.js"; const ARTWORK_PLACEHOLDER = 'https://raw.githubusercontent.com/FoxxMD/multi-scrobbler/master/assets/icon.png'; +const MB_ART = 'https://raw.githubusercontent.com/FoxxMD/multi-scrobbler/master/assets/musicbrainz-logo-small.png'; const API_GATEWAY_ENDPOINT = 'https://discord.com/api/gateway'; /** @@ -455,6 +457,12 @@ export class DiscordWSClient extends AbstractApiClient { if(usedUrl !== undefined) { activity.assets.large_image = usedUrl; } + if(activity.assets.small_text !== undefined) { + const smallArt = await this.getArtworkUrl(MB_ART); + if(smallArt !== undefined) { + activity.assets.small_image = smallArt; + } + } } return activity; @@ -473,9 +481,13 @@ export class DiscordWSClient extends AbstractApiClient { const activity = await this.playStateToActivity(data); - let clearTime = dayjs().add(5, 'minutes'); - if (activity.timestamps.end !== undefined) { + const play = isPlayObject(data) ? data : data.play; + + let clearTime = dayjs().add(260, 'seconds'); // funny number + if (activity.timestamps?.end !== undefined) { clearTime = dayjs.unix(Math.floor(activity.timestamps.end as number / 1000)); + } else if (play.data?.duration !== undefined) { + clearTime = dayjs().add(play.data.duration, 'seconds') } const updateData = this.generatePresenceUpdate(); @@ -677,10 +689,7 @@ export const playStateToActivityData = (data: SourceData, opts: { useArt?: boole } } - let activityName = 'Music'; - if (play.meta?.source !== undefined) { - activityName = capitalize(play.meta.source) - } + let activityName = capitalize(play.meta?.musicService ?? play.meta?.mediaPlayerName ?? play.meta?.source ?? 'music') // @ts-expect-error const activity = removeUndefinedKeys({ @@ -703,8 +712,58 @@ export const playStateToActivityData = (data: SourceData, opts: { useArt?: boole end: endTime } } + + //let buttons: GatewayActivityButton[] = []; + + const { + meta: { + url: { + web, + origin + } = {}, + }, + data: { + meta: { + brainz: { + recording + } = {} + } = {} + } = {} + } = play; + const url = origin ?? web; + if(url !== undefined) { + const knownService = urlToMusicService(url); + if(knownService !== undefined) { + activity.assets.large_url = url; + + // when including buttons discord accepts the presence update but does not actually use it + // I think buttons may now be limited to official RPC or restricted to preset actions via things like secrets or registering commands + // https://docs.discord.com/developers/developer-tools/game-sdk#activitysecrets-struct + + // buttons.push({ + // label: `Listen on ${capitalize(knownService)}`, + // url: web + // }); + } + } + if(recording !== undefined) { + const mb = `https://musicbrainz.org/recording/${recording}`; + if(activity.assets.large_url === undefined) { + activity.assets.large_url = mb; + } else { + activity.assets.small_url = mb; + activity.assets.small_text = 'Open On Musicbrainz'; + } + // buttons.push({ + // label: 'Open on Musicbrainz', + // url: `https://musicbrainz.org/recording/${recording}` + // }); + } + // if(buttons.length > 0) { + // activity.buttons = buttons; + // } - const artUrl = play.meta?.art?.album ?? play.meta.art.track ?? play.meta.art.artist; + const artUrl = play.meta?.art?.album ?? play.meta?.art?.track ?? play.meta?.art?.artist; return { activity, artUrl }; } diff --git a/src/backend/sources/JellyfinApiSource.ts b/src/backend/sources/JellyfinApiSource.ts index 84fa5a44..0afcce69 100644 --- a/src/backend/sources/JellyfinApiSource.ts +++ b/src/backend/sources/JellyfinApiSource.ts @@ -454,7 +454,10 @@ export default class JellyfinApiSource extends MemoryPositionalSource { meta.album = ProviderIds.MusicBrainzAlbum; } if(ProviderIds.MusicBrainzTrack !== undefined) { - meta.recording = ProviderIds.MusicBrainzTrack; + meta.track = ProviderIds.MusicBrainzTrack; + } + if(ProviderIds.MusicBrainzRecording !== undefined) { + meta.recording = ProviderIds.MusicBrainzRecording; } if(ProviderIds.MusicBrainzArtist !== undefined) { meta.artist = [ProviderIds.MusicBrainzArtist]; diff --git a/src/backend/sources/WebScrobblerSource.ts b/src/backend/sources/WebScrobblerSource.ts index e8698cca..a7aa84ee 100644 --- a/src/backend/sources/WebScrobblerSource.ts +++ b/src/backend/sources/WebScrobblerSource.ts @@ -146,6 +146,7 @@ export class WebScrobblerSource extends MemorySource { }, deviceId: `${connectorLabel}-${controllerTabId}`, musicService: connectorL, + source: 'WebScrobbler', scrobbleAllowed: isScrobblingAllowed, nowPlaying: options.nowPlaying ?? false } -- 2.51.2 From cfd458f463db6dc5f4a61a9d56118d06d33b81c4 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 17 Feb 2026 20:12:49 +0000 Subject: [PATCH 14/18] docs(discord): Improve artwork url guidance and application id --- .../docs/configuration/clients/discord.mdx | 81 ++++++++++++++---- docsite/static/img/discord_presence_art.png | Bin 0 -> 19926 bytes .../static/img/discord_presence_default.png | Bin 0 -> 7342 bytes .../common/vendor/discord/DiscordWSClient.ts | 3 + 4 files changed, 68 insertions(+), 16 deletions(-) create mode 100644 docsite/static/img/discord_presence_art.png create mode 100644 docsite/static/img/discord_presence_default.png diff --git a/docsite/docs/configuration/clients/discord.mdx b/docsite/docs/configuration/clients/discord.mdx index 017f5366..47417873 100644 --- a/docsite/docs/configuration/clients/discord.mdx +++ b/docsite/docs/configuration/clients/discord.mdx @@ -217,11 +217,9 @@ DISCORD_APPNAME_OVERRIDE_DISALLOW=fortnite ```
-### Artwork - -For some Sources, MS parses album art and displays it in the dashboard. If a Discord **Application Id** is provided then this image can be displayed on Discord alongside your listening status. +### Discord Application -**If no Application Id is provided Discord will use its own default image next to your status.** +Creating a [Discord App](https://docs.discord.com/developers/quick-start/overview-of-apps) enables you to use custom album cover art and secondary status images for more links. Creating an App is free and simple, it is recommended you do this to get the most functionality of our Rich Presence.
@@ -231,31 +229,82 @@ Go to the [Discord Developer Portal](https://discord.com/developers/applications * Click on "New Application" and give it a name * Copy the **Application Id** (Client Id) shown after creation +* Add this Id to your Multi-Scrobbler config + * Env Config => `DISCORD_APPLICATION_ID=12345678` + * File Config => `"applicationId": "12345678"`
+The created application is used to enable the features/config below: + +* [Artwork](#artwork) + +### Artwork + +For some Sources, MS parses album art and displays it in the dashboard. This image can be displayed on Discord alongside your listening status. + +:::note + +If no [**Application Id**](#discord-application) is configured the status art will **always** be the [Discord Default](./?artworkUrl=discord-default#artwork-url-types). + +::: + Discord needs an **external, internet-facing** URL to correctly display this image which may not be feasible, or desirable, based on the Sources you are using. Multi-scrobbler disallows **all** external URLs by default. -
+#### Artwork URL Types -Using External URLs + + + When [**Application Id**](#discord-application) is **not** configured Discord will show its own default image. -There are some scenarios where you may not want to use your artwork URLs, for example: + + + + When [**Application Id**](#discord-application) **is** configured and any of these conditions is true: -* A Source may be accessible only on your LAN (Jellyfin at `http://192.168.0.101`) -* A Source may be internet-facing but you do not want to expose this information to discord/other users -* A Source may be internet-facing but requires authentication to view the image + * Play does not contain artwork information (no artwork shown in dashboard) + * or artwork url is not a known music service domain and... + * `artwork` is set to false or **not defined** + * or `artwork` is a list of custom domains keywords and external artwork url does not contain any of these keywords + * or external artwork url is `http` -MS is conservative in this context by **disallowing** use of album art URLs **by default.** Additionally, it **always** disallows use of these URLs if they do not start with `https`. + The status artwork will use a default URL instead of your artwork URL. This is to preserve *your* privacy because Discord does not host status images, it only links to them and the URL is visible to other users. + + Without any other configuration, the default URL is for the the MS logo: -* To allow **any** `https` album art URLs set ENV `DISCORD_ARTWORK=true` (or in file as `"artwork": true`) -* To allow only `https` album art URLs from certain domains use a list of keywords like `DISCORD_ARTWORK=spotify,jellyfin` (or in file as `"artwork": ["spotify","jellyfin"]`) + -If a listening activity does not have album art, or the URL is disallowed, MS will use the [multi-scrobbler logo](https://github.com/FoxxMD/multi-scrobbler/blob/master/assets/icon.png) as a fallback image. + This default image can be customized: -You can customize this fallback image with ENV `DISCORD_ARTWORK_DEFAULT_URL` or in file config with `artworkDefaultUrl`. If you were prefer to use the discord default image set this value to `false`. + * ENV Config => `DISCORD_ARTWORK_DEFAULT_URL=https://cooldomain.com/art.png` + * File Config => `artworkDefaultUrl` + + + When [**Application Id**](#discord-application) **is** configured MS can use external URLS for artwork. -
+ External URLs that are not from known music services are **disabled by default.** This is to preserve *your* privacy because Discord does not host status images, it only links to them and the URL is visible to other users. + +
+ + Why Do I Care? + + There are some scenarios where you may not want your artwork URLs to be visible, for example: + + * A Source may be accessible only on your LAN (Jellyfin at `http://192.168.0.101`) + * A Source may be internet-facing but you do not want to expose this information to discord/other users + * A Source may be internet-facing but requires authentication to view the image +
+ + Regardless of the config below MS will **never** use an external URL if it it not `https`. + + MS will use your artwork URLs when: + + * `artwork` (`DISCORD_ARTWORK`) is `true` + * `artwork` is a list of custom domains keywords and external artwork url contains any of these keywords like `DISCORD_ARTWORK=mycdn,jellyfin` (or in file as `"artwork": ["mycdn","jellyfin"]`) + + + + ## Configuration diff --git a/docsite/static/img/discord_presence_art.png b/docsite/static/img/discord_presence_art.png new file mode 100644 index 0000000000000000000000000000000000000000..61049259bc7d329fd17da1764faaa652576d5577 GIT binary patch literal 19926 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 Date: Tue, 17 Feb 2026 20:13:11 +0000 Subject: [PATCH 15/18] fix: Fix config validation error source --- src/backend/utils/ValidationUtils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/backend/utils/ValidationUtils.ts b/src/backend/utils/ValidationUtils.ts index b46f2379..cb482ba3 100644 --- a/src/backend/utils/ValidationUtils.ts +++ b/src/backend/utils/ValidationUtils.ts @@ -39,8 +39,8 @@ export const validateJson = async (type: string, config: object, schemaIdenti return config as unknown as T; } else { const schemaErrors = ['Json config was not valid. Please use schema to check validity.']; - if (Array.isArray(ajv.errors)) { - for (const err of ajv.errors) { + if (Array.isArray(validate.errors)) { + for (const err of validate.errors) { const parts = [ `At: ${err.instancePath}`, ]; -- 2.51.2 From ea5b66cdaeed80826bd04a6d267225ce4f0d51a5 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 17 Feb 2026 20:16:50 +0000 Subject: [PATCH 16/18] fix(discord): Fix ajv not liking defaultUrl string | false --- src/backend/common/infrastructure/config/client/discord.ts | 3 ++- src/backend/common/vendor/discord/DiscordWSClient.ts | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/backend/common/infrastructure/config/client/discord.ts b/src/backend/common/infrastructure/config/client/discord.ts index 82aef02e..32d769eb 100644 --- a/src/backend/common/infrastructure/config/client/discord.ts +++ b/src/backend/common/infrastructure/config/client/discord.ts @@ -4,7 +4,7 @@ export interface DiscordData { token: string applicationId?: string artwork?: boolean | string | string[] - artworkDefaultUrl?: string | false + artworkDefaultUrl?: string | boolean statusOverrideAllow?: string | StatusType[] activitiesOverrideAllow?: boolean | string | ActivityType[] applicationsOverrideDisallow?: string | string[] @@ -33,6 +33,7 @@ export type StatusType = 'online' | 'idle' | 'dnd' | 'invisible'; export interface DiscordStrongData extends DiscordData { artwork?: boolean | string[] + artworkDefaultUrl?: string | false statusOverrideAllow?: StatusType[] activitiesOverrideAllow?: ActivityType[] applicationsOverrideDisallow?: string[] diff --git a/src/backend/common/vendor/discord/DiscordWSClient.ts b/src/backend/common/vendor/discord/DiscordWSClient.ts index 1e4525cd..6e7004b5 100644 --- a/src/backend/common/vendor/discord/DiscordWSClient.ts +++ b/src/backend/common/vendor/discord/DiscordWSClient.ts @@ -17,6 +17,7 @@ import { getRoot } from "../../../ioc.js"; import { MSCache } from "../../Cache.js"; import { isSuperAgentResponseError } from "../../errors/ErrorUtils.js"; import { urlToMusicService } from "../ListenbrainzApiClient.js"; +import { fa } from "@faker-js/faker"; const ARTWORK_PLACEHOLDER = 'https://raw.githubusercontent.com/FoxxMD/multi-scrobbler/master/assets/icon.png'; const MB_ART = 'https://raw.githubusercontent.com/FoxxMD/multi-scrobbler/master/assets/musicbrainz-logo-small.png'; @@ -853,6 +854,8 @@ export const configToStrong = (data: DiscordData): DiscordStrongData => { if(artworkDefaultUrl !== undefined && typeof artworkDefaultUrl === 'string' && artworkDefaultUrl.toLocaleLowerCase().trim() === 'false') { strongConfig.artworkDefaultUrl = false; + } else if (typeof artworkDefaultUrl === 'boolean') { + strongConfig.artworkDefaultUrl = artworkDefaultUrl ? ARTWORK_PLACEHOLDER : false; } else { strongConfig.artworkDefaultUrl = artworkDefaultUrl; } -- 2.51.2 From 2a2fb9ad552c1fa9fc9d5a7224976545a1aaa528 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 17 Feb 2026 21:05:26 +0000 Subject: [PATCH 17/18] refactor(discord): Update links and art * Use pocike icon for placeholder art * Use MS logo for small art with link to docs * Use web link as track url, if available * Use MB link as track or artist url --- assets/README.md | 1 + assets/default-artwork.png | Bin 0 -> 1485 bytes .../docs/configuration/clients/discord.mdx | 6 ++-- docsite/static/img/discord_presence_art.png | Bin 19926 -> 18475 bytes ...g => discord_presence_discord_default.png} | Bin .../img/discord_presence_ms_default.png | Bin 0 -> 9407 bytes .../common/vendor/discord/DiscordWSClient.ts | 31 +++++++++--------- 7 files changed, 19 insertions(+), 19 deletions(-) create mode 100644 assets/README.md create mode 100644 assets/default-artwork.png rename docsite/static/img/{discord_presence_default.png => discord_presence_discord_default.png} (100%) create mode 100644 docsite/static/img/discord_presence_ms_default.png diff --git a/assets/README.md b/assets/README.md new file mode 100644 index 00000000..080fffbc --- /dev/null +++ b/assets/README.md @@ -0,0 +1 @@ +* Attribution for [`default-artwork.png`](/assets/default-artwork.png): https://www.flaticon.com/free-icon/music_15795471 \ No newline at end of file diff --git a/assets/default-artwork.png b/assets/default-artwork.png new file mode 100644 index 0000000000000000000000000000000000000000..f9eeec725b467003f22f62c88994505915e06224 GIT binary patch literal 1485 zcmeAS@N?(olHy`uVBq!ia0y~yU}ykg4mJh`hQoG=rx_R+I14-?iy0XF_JS~DaaM*1 z0|SF(iEBhjaDG}zd16s2LwR|*US?i)adKios$PCk`s{Z$QVa~NQJyZ2Ar*7p-u2Fq z4V5|maee02C2DM4>uU2tL zgJavYMJ|dZOnLQRc}@S@1Aol2Mel58l)BDgzK`Ko)Vc_V*}{LEq>Sa658M%ZeQl$q z?!Ik2H)OMvII}E2aKF2uFzfnCyO&Z=lQ~}K#_r`SooaKBJ?De2Uf^8LHD7gu#2QQw zXzn|zx+{Y*;$?Kiqz`e+H!i?Sag)s_#3t}CNbD#W={yurRoOL791)p?Lo3M%^ ze~5lma@|SB3MmWr@87mxSj-;5|7X^l?VU?jUT50I-0!}oUZjFuyiHc{dFQ&7(G86* zpWk?y_?!6h9=Pc8nMZw=`mAMa4pvJTB$i`GKUV&oogvC#ZytWPVg0f=-k!DW+kfbX z-R;o#W{mJZtAF>{g4l*aE*z-TO4x#}rhT{XFT)2Vch&}-U%hGNuG6**^$+en{j9cu zU%ct1*h9O3mvs-iO4gb>uiM?gd8c*S=f>X+dqwo^4*2i5z2GbJb%y)PGB#)y-C^Qk zEN7BG|0w-C({v_oC$lRlJ6RvF---X2*e{iQV4}iZi3ykW9+*F1`y&>grm>vChNULH zRQTExp@zE$qC?FS1N9gyR=r|db4rmR{^7jG-mFC&2hOdM-k{UYP!m?lJng9f!`z*# z1Naz!?0vxbxXdKNJ78PXrnk@tb!%>~Z;g#fy(|hr&f0akW{?!-_Z#Ty{J*gBv6nft2P>YXeM3EB_8U+!lrXIXbs!;*CY{||-;xfS=)9_)13Dy6-gfniSf zZi^?MuN3IL`^tK|!IH5mYs(!bhIhR3)eX~^{j(Kb-RR$-elf7#y@-cl$3D3Od%Y^O z3v3P6KdBaHnBCCd;NPenY;r?n=U$zfU3m$ouShWRF|`-eo`CT*dfw7xx4g5r)Uapi@i+^R{r$T>E$*>nWxMzu#Ovydb}E6NAh*T^&yuaMJ%? z%k3>B|4S@mE9;Lt+jqE_GVXaDz2xfVhT}Ee-XXUO^e272q{UF(u)S7&f(BdR``QoJ z-q{?udtkbv+N1=xhT8?(uYSL#$8fyib;EZjAGLdIjoF+PHP=qgJYe5gdO-QW`GsoJ zBxf?j9jlc%_HRO~b#3yA_ygPrvJXT(2uony!199g<^+a!Jog13Fue17WzAV|K)sBI zVe;i;+OGFkbUU8C%(_OWV$X*y8<{THyl&XcE%)E5RC&_9pgZxuzMTE9aUnV}UMNz$ z!1`#nedq6(qfUnJ7hUAPzN0UAj$7}R$MeFt zMPDlv2!Gx6(=iTpD|@A%xatExVy=k`3{`!Q`xypsAe#|K&o z${P$G$uJ$>!MrEGV0mx*>Rfjh7Z;aJ-_6D2wrN;O6lF0mFfe$!`njxgN@xNA`v;9| literal 0 HcmV?d00001 diff --git a/docsite/docs/configuration/clients/discord.mdx b/docsite/docs/configuration/clients/discord.mdx index 47417873..a3dc539f 100644 --- a/docsite/docs/configuration/clients/discord.mdx +++ b/docsite/docs/configuration/clients/discord.mdx @@ -11,7 +11,7 @@ import JsonConfig from '!!raw-loader!@site/../config/discord.json.example'; This scrobbler uses **[Now Playing](/configuration/clients#now-playing)** functionality to set your Discord [Rich Presence](https://docs.discord.com/developers/rich-presence/overview) to the music you are currently monitoring with multi-scrobbler. - + :::warning[Discord TOS Violation] @@ -257,7 +257,7 @@ Discord needs an **external, internet-facing** URL to correctly display this ima When [**Application Id**](#discord-application) is **not** configured Discord will show its own default image. - + When [**Application Id**](#discord-application) **is** configured and any of these conditions is true: @@ -272,7 +272,7 @@ Discord needs an **external, internet-facing** URL to correctly display this ima Without any other configuration, the default URL is for the the MS logo: - + This default image can be customized: diff --git a/docsite/static/img/discord_presence_art.png b/docsite/static/img/discord_presence_art.png index 61049259bc7d329fd17da1764faaa652576d5577..3a2fcfa52d6e651dc119d33885919ff347a32c66 100644 GIT binary patch literal 18475 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 literal 19926 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 cV);CLv+xz{VlZK*zQ`IKVdw=~l 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 D0|7Q# literal 0 HcmV?d00001 diff --git a/src/backend/common/vendor/discord/DiscordWSClient.ts b/src/backend/common/vendor/discord/DiscordWSClient.ts index 6e7004b5..7a27b1bd 100644 --- a/src/backend/common/vendor/discord/DiscordWSClient.ts +++ b/src/backend/common/vendor/discord/DiscordWSClient.ts @@ -19,8 +19,8 @@ import { isSuperAgentResponseError } from "../../errors/ErrorUtils.js"; import { urlToMusicService } from "../ListenbrainzApiClient.js"; import { fa } from "@faker-js/faker"; -const ARTWORK_PLACEHOLDER = 'https://raw.githubusercontent.com/FoxxMD/multi-scrobbler/master/assets/icon.png'; -const MB_ART = 'https://raw.githubusercontent.com/FoxxMD/multi-scrobbler/master/assets/musicbrainz-logo-small.png'; +const ARTWORK_PLACEHOLDER = 'https://raw.githubusercontent.com/FoxxMD/multi-scrobbler/master/assets/default-artwork.png'; +const MS_ART = 'https://raw.githubusercontent.com/FoxxMD/multi-scrobbler/master/assets/icon.png'; const API_GATEWAY_ENDPOINT = 'https://discord.com/api/gateway'; /** @@ -432,7 +432,8 @@ export class DiscordWSClient extends AbstractApiClient { artwork = false } = this.config; const { - artworkDefaultUrl = ARTWORK_PLACEHOLDER + artworkDefaultUrl = ARTWORK_PLACEHOLDER, + applicationId } = this.config; let art = artworkDefaultUrl; @@ -458,15 +459,14 @@ export class DiscordWSClient extends AbstractApiClient { if(usedUrl !== undefined) { activity.assets.large_image = usedUrl; } - if(activity.assets.small_text !== undefined) { - const smallArt = await this.getArtworkUrl(MB_ART); - if(smallArt !== undefined) { + } + if(art !== MS_ART && applicationId !== undefined) { + const smallArt = await this.getArtworkUrl(MS_ART); + if(smallArt !== undefined) { activity.assets.small_image = smallArt; - } else { - delete activity.assets.small_text; - delete activity.assets.small_url; - } - } + activity.assets.small_text = 'Via Multi-Scrobbler' + activity.assets.small_url = 'https://multi-scrobbler.app' + } } return activity; @@ -738,7 +738,7 @@ export const playStateToActivityData = (data: SourceData, opts: { useArt?: boole if(url !== undefined) { const knownService = urlToMusicService(url); if(knownService !== undefined) { - activity.assets.large_url = url; + activity.details_url = url; // when including buttons discord accepts the presence update but does not actually use it // I think buttons may now be limited to official RPC or restricted to preset actions via things like secrets or registering commands @@ -752,11 +752,10 @@ export const playStateToActivityData = (data: SourceData, opts: { useArt?: boole } if(recording !== undefined) { const mb = `https://musicbrainz.org/recording/${recording}`; - if(activity.assets.large_url === undefined) { - activity.assets.large_url = mb; + if(activity.details_url === undefined) { + activity.details_url = mb; } else { - activity.assets.small_url = mb; - activity.assets.small_text = 'Open On Musicbrainz'; + activity.state_url = mb; } // buttons.push({ // label: 'Open on Musicbrainz', -- 2.51.2 From bb77c1c0bbbfa19528b71603364655e68101eeea Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 17 Feb 2026 21:28:53 +0000 Subject: [PATCH 18/18] feat(discord): Use artwork url if its from a known domain --- .../docs/configuration/clients/discord.mdx | 4 +-- .../common/vendor/discord/DiscordWSClient.ts | 29 +++++++++++-------- src/backend/utils/RequestUtils.ts | 10 +++++++ src/core/Atomic.ts | 17 ++++++++++- 4 files changed, 44 insertions(+), 16 deletions(-) diff --git a/docsite/docs/configuration/clients/discord.mdx b/docsite/docs/configuration/clients/discord.mdx index a3dc539f..e1d201e9 100644 --- a/docsite/docs/configuration/clients/discord.mdx +++ b/docsite/docs/configuration/clients/discord.mdx @@ -249,13 +249,11 @@ If no [**Application Id**](#discord-application) is configured the status art wi ::: -Discord needs an **external, internet-facing** URL to correctly display this image which may not be feasible, or desirable, based on the Sources you are using. Multi-scrobbler disallows **all** external URLs by default. - #### Artwork URL Types - When [**Application Id**](#discord-application) is **not** configured Discord will show its own default image. + When [**Application Id**](#discord-application) is **not** configured Discord will show its own default image. No custom artwork can be used in this configuration. diff --git a/src/backend/common/vendor/discord/DiscordWSClient.ts b/src/backend/common/vendor/discord/DiscordWSClient.ts index 7a27b1bd..42badf7c 100644 --- a/src/backend/common/vendor/discord/DiscordWSClient.ts +++ b/src/backend/common/vendor/discord/DiscordWSClient.ts @@ -18,6 +18,7 @@ import { MSCache } from "../../Cache.js"; import { isSuperAgentResponseError } from "../../errors/ErrorUtils.js"; import { urlToMusicService } from "../ListenbrainzApiClient.js"; import { fa } from "@faker-js/faker"; +import { urlContainsKnownMediaDomain } from "../../../utils/RequestUtils.js"; const ARTWORK_PLACEHOLDER = 'https://raw.githubusercontent.com/FoxxMD/multi-scrobbler/master/assets/default-artwork.png'; const MS_ART = 'https://raw.githubusercontent.com/FoxxMD/multi-scrobbler/master/assets/icon.png'; @@ -437,18 +438,22 @@ export class DiscordWSClient extends AbstractApiClient { } = this.config; let art = artworkDefaultUrl; - if (artUrl !== undefined && artwork !== false) { - if (Array.isArray(artwork)) { - const allowed = artwork.some(x => artUrl.toLocaleLowerCase().includes(x.toLocaleLowerCase())); - if (allowed) { - art = artUrl; - } - } else { - const u = new URL(artUrl); - // only allow secure protocol as this is likely to be a real domain that is public accessible - // IP domain usually uses http only - if (u.protocol === 'https://') { - art = artUrl; + if(artUrl !== undefined) { + if(urlContainsKnownMediaDomain(artUrl)) { + art = artUrl; + } else if (artwork !== false) { + if (Array.isArray(artwork)) { + const allowed = artwork.some(x => artUrl.toLocaleLowerCase().includes(x.toLocaleLowerCase())); + if (allowed) { + art = artUrl; + } + } else { + const u = new URL(artUrl); + // only allow secure protocol as this is likely to be a real domain that is public accessible + // IP domain usually uses http only + if (u.protocol === 'https://') { + art = artUrl; + } } } } diff --git a/src/backend/utils/RequestUtils.ts b/src/backend/utils/RequestUtils.ts index 6a93eb61..f43d0ee4 100644 --- a/src/backend/utils/RequestUtils.ts +++ b/src/backend/utils/RequestUtils.ts @@ -1,5 +1,6 @@ import { Files, File } from "formidable"; import VolatileFile from "formidable/VolatileFile.js"; +import { KNOWN_MEDIA_PROVIDER_URLS } from "../../core/Atomic.js"; // typings from Formidable are all nuts. // VolatileFile is missing buffer and also does not extend File even though it should @@ -69,3 +70,12 @@ const isVolatileFile = (val: unknown): val is File => { export const getFileIdentifier = (f: File): string => { return f.originalFilename === null ? f.newFilename : f.originalFilename; } + +export const urlContainsDomains = (url: string | URL, domains: string[]): boolean => { + const u = typeof url === 'string' ? url : url.hostname; + return domains.some(x => u.includes(x)); +} + +export const urlContainsKnownMediaDomain = (url: string | URL): boolean => { + return urlContainsDomains(url, KNOWN_MEDIA_PROVIDER_URLS); +} \ No newline at end of file diff --git a/src/core/Atomic.ts b/src/core/Atomic.ts index b7af1768..2c23c265 100644 --- a/src/core/Atomic.ts +++ b/src/core/Atomic.ts @@ -531,4 +531,19 @@ export interface TransformResult { type: string, name: string, play: PlayData -} \ No newline at end of file +} + +export const KNOWN_MEDIA_PROVIDER_URLS = [ +'spotify.com', +'bandcamp.com', +'youtube.com', +'deezer.com', +'tidal.com', +'apple.com', +'archive.org', +'soundcloud.com', +'jamendo.com', +'play.google.com', +'listenbrainz.org', +'musicbrainz.org' +]; \ No newline at end of file -- 2.51.2