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

Configure Feed

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

feat(discord): Add more presence configuration options

FoxxMD (Feb 16, 2026, 9:08 PM UTC) 53c8036e f5c8ce51

+424 -58
+185 -14
docsite/docs/configuration/clients/discord.mdx
··· 27 27 28 28 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. 29 29 30 - 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: 30 + 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. 31 + 32 + </DetailsAdmo> 33 + 34 + <DetailsAdmo type="note" summary="REQUIRED Interaction after Starting Multi-Scrobbler"> 35 + 36 + 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. 37 + 38 + 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: 31 39 32 - * using an official client 33 - * physically online 34 - * not invisible 40 + * Open discord on a device it was not recently active on 41 + * Exit discord on an active device 42 + * Change your status (online, idle, etc...) 43 + * Set a custom status 44 + * Cause a presence update with a different app (listening to..., playing..., etc.) 35 45 36 46 </DetailsAdmo> 37 47 38 - ## User Token 48 + ## Required Setup 49 + 50 + ### User Token 39 51 40 52 You must provide a User Token for this scrobbler to work. 41 53 ··· 49 61 50 62 ::: 51 63 52 - ## Artwork 64 + ## Optional Setup 65 + 66 + ### Configure When Presence Is Used 67 + 68 + 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. 69 + 70 + :::tip[TLDR] 71 + 72 + Without any additional configuration, MS will only update presence if: 73 + 74 + * you have discord open on a real device 75 + * your status is either: online, idle, or dnd (not invisible) 76 + * no other apps are broadcasting presence (no other listening, playing, competing, etc... activities on your profile) 77 + 78 + ::: 79 + 80 + #### Online Status 81 + 82 + 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**. 83 + 84 + Allowed Values: `online` `idle` `dnd` `invisible` 85 + 86 + <details> 87 + 88 + <summary>Example</summary> 89 + 90 + Only allow presence updating when all of your "real" sessions are either online or idle. 91 + 92 + 93 + In File/AIO: 94 + 95 + ```json 96 + [ 97 + { 98 + "name": "MS", 99 + "enable": true, 100 + "data": { 101 + "token": "Ma7XBvzRERK3gQdkX4dkb3OQ.6aaGmJ.rvFiiVrzG4VXHHL3LyhZYhpWdG3Ph_if9aKz5E", 102 + "applicationId": "8190211179716453570", 103 + "statusOverrideAllow": ["online", "idle"] 104 + } 105 + } 106 + ] 107 + ``` 108 + 109 + In ENV: 110 + 111 + ``` 112 + DISCORD_STATUS_OVERRIDE_ALLOW=online,idle 113 + ``` 114 + 115 + </details> 116 + 117 + #### Activity Type 118 + 119 + 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`. 120 + 121 + Note: Discord shows both `custom` and other non `custom` activities at the same time. All other activities are mutually exclusive. 122 + 123 + Allowed Values: 124 + 125 + * `playing` `streaming` `listening` `custom` `competing` 126 + * `true` => allow presence during *any* 127 + * `false` => allow presence during *none* 128 + 129 + <details> 130 + 131 + <summary>Example</summary> 132 + 133 + 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. 134 + 135 + In File/AIO: 136 + 137 + ```json 138 + [ 139 + { 140 + "name": "MS", 141 + "enable": true, 142 + "data": { 143 + "token": "Ma7XBvzRERK3gQdkX4dkb3OQ.6aaGmJ.rvFiiVrzG4VXHHL3LyhZYhpWdG3Ph_if9aKz5E", 144 + "applicationId": "8190211179716453570", 145 + "activitiesOverrideAllow": ["playing", "custom"] 146 + } 147 + } 148 + ] 149 + ``` 150 + 151 + In ENV: 152 + 153 + ``` 154 + DISCORD_ACTIVITIES_OVERRIDE_ALLOW=playing,custom 155 + ``` 156 + 157 + ___ 158 + 159 + You **do not** want to allow MS to update presence if **any** other activity is occurring, including `custom`. 160 + 161 + ```json 162 + [ 163 + { 164 + "name": "MS", 165 + "enable": true, 166 + "data": { 167 + "token": "Ma7XBvzRERK3gQdkX4dkb3OQ.6aaGmJ.rvFiiVrzG4VXHHL3LyhZYhpWdG3Ph_if9aKz5E", 168 + "applicationId": "8190211179716453570", 169 + "activitiesOverrideAllow": false 170 + } 171 + } 172 + ] 173 + ``` 174 + 175 + In ENV: 176 + 177 + ``` 178 + DISCORD_ACTIVITIES_OVERRIDE_ALLOW=false 179 + ``` 180 + 181 + </details> 182 + 183 + #### Activity Name 184 + 185 + Configure the names of **other** activities that MS is **not** allowed to broadcast at the same time as. 186 + 187 + It is not required to use [Activity Type](#activity-type) with this setting, but it is useful. 188 + 189 + <details> 190 + 191 + <summary>Example</summary> 192 + 193 + 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. 194 + 195 + In File/AIO: 196 + 197 + ```json 198 + [ 199 + { 200 + "name": "MS", 201 + "enable": true, 202 + "data": { 203 + "token": "Ma7XBvzRERK3gQdkX4dkb3OQ.6aaGmJ.rvFiiVrzG4VXHHL3LyhZYhpWdG3Ph_if9aKz5E", 204 + "applicationId": "8190211179716453570", 205 + "activitiesOverrideAllow": ["playing", "custom"], 206 + "applicationsOverrideDisallow": ["fortnite"] 207 + } 208 + } 209 + ] 210 + ``` 211 + 212 + In ENV: 213 + 214 + ``` 215 + DISCORD_ACTIVITIES_OVERRIDE_ALLOW=playing,custom 216 + DISCORD_APPNAME_OVERRIDE_DISALLOW=fortnite 217 + ``` 218 + </details> 219 + 220 + ### Artwork 53 221 54 222 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. 55 223 56 - If no Application Id is provided Discord will always show a default image next to your status. 224 + **If no Application Id is provided Discord will use its own default image next to your status.** 57 225 58 226 <details> 59 227 ··· 80 248 81 249 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`. 82 250 83 - * To allow **any** `https` album art URLs set ENV `DISCORD_ARTWORK=true` 251 + * To allow **any** `https` album art URLs set ENV `DISCORD_ARTWORK=true` (or in file as `"artwork": true`) 84 252 * 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"]`) 85 253 86 254 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 260 ## Configuration 93 261 94 262 <Config config="DiscordClientConfig" fileContent={JsonConfig} client name="discord"> 95 - | Environmental Variable | Required? | Default | Description | 96 - | :---------------------------- | --------- | ------- | :--------------------------------------------------------------------------------------------------------------- | 97 - | `DISCORD_TOKEN` | Yes | | User Token acquired from an active Discord sessions | 98 - | `DISCORD_APPLICATION_ID` | Yes | | Application ID for user with album art | 99 - | `DISCORD_ARTWORK` | No | | A boolean indicating if external artwork URLs should be used. Or a comma-separated list of allowed domains | 100 - | `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. | 263 + | Environmental Variable | Required? | Default | Description | 264 + | :---------------------------------- | --------- | ----------------- | :--------------------------------------------------------------------------------------------------------------- | 265 + | `DISCORD_TOKEN` | Yes | | User Token acquired from an active Discord sessions | 266 + | `DISCORD_APPLICATION_ID` | No | | Application ID used to display album art | 267 + | `DISCORD_ARTWORK` | No | | A boolean indicating if external artwork URLs should be used. Or a comma-separated list of allowed domains | 268 + | `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. | 269 + | `DISCORD_STATUS_OVERRIDE_ALLOW` | No | `online,idle,dnd` | A comma-separated list of statuses are allowed to have presence. | 270 + | `DISCORD_ACTIVITIES_OVERRIDE_ALLOW` | No | `custom` | A comma-seperated list of *other* activity types MS can broadcast presence at the same time as. | 271 + | `DISCORD_APPNAME_OVERRIDE_DISALLOW` | No | | A commera-seperated list of activity names MS is *not* allowed to broadcast presence at the same time as. | 101 272 </Config>
+14 -1
src/backend/common/infrastructure/config/client/discord.ts
··· 1 - import { RequestRetryOptions } from "../common.js" 2 1 import { CommonClientConfig, CommonClientData } from "./index.js" 3 2 4 3 export interface DiscordData { ··· 6 5 applicationId?: string 7 6 artwork?: boolean | string | string[] 8 7 artworkDefaultUrl?: string | false 8 + statusOverrideAllow?: string | StatusType[] 9 + activitiesOverrideAllow?: boolean | string | ActivityType[] 10 + applicationsOverrideDisallow?: string | string[] 9 11 } 10 12 11 13 export interface DiscordClientData extends DiscordData, CommonClientData {} ··· 23 25 24 26 export interface DiscordClientAIOConfig extends DiscordClientConfig { 25 27 type: 'discord' 28 + } 29 + 30 + export type ActivityType = 'playing' | 'streaming' | 'listening' | 'watching' | 'custom' | 'competing'; 31 + export const ActivityTypes: ActivityType[] = ['playing','streaming','listening','watching','custom','competing']; 32 + export type StatusType = 'online' | 'idle' | 'dnd' | 'invisible'; 33 + 34 + export interface DiscordStrongData extends DiscordData { 35 + artwork?: boolean | string[] 36 + statusOverrideAllow?: StatusType[] 37 + activitiesOverrideAllow?: ActivityType[] 38 + applicationsOverrideDisallow?: string[] 26 39 }
+192 -24
src/backend/common/vendor/discord/DiscordWSClient.ts
··· 1 1 import { childLogger } from "@foxxmd/logging"; 2 2 import { WS } from 'iso-websocket' 3 - import { DiscordClientData } from "../../infrastructure/config/client/discord.js"; 4 - import { _DataPayload, _NonDispatchPayload, APIUser, GatewayActivity, GatewayActivityUpdateData, GatewayCloseCodes, GatewayDispatchEvents, GatewayHeartbeatRequest, GatewayHelloData, GatewayIdentify, GatewayIdentifyData, GatewayInvalidSessionData, GatewayOpcodes, GatewayPresenceUpdateData, GatewayReadyDispatchData, GatewayResumeData, GatewayUpdatePresence, PresenceUpdateStatus } from "discord.js"; 3 + import { DiscordClientData, DiscordData, DiscordStrongData, StatusType, ActivityType as MSActivityType, ActivityTypes } from "../../infrastructure/config/client/discord.js"; 4 + import { _DataPayload, _NonDispatchPayload, ActivityType, APIUser, GatewayActivity, GatewayActivityUpdateData, GatewayCloseCodes, GatewayDispatchEvents, GatewayHeartbeatRequest, GatewayHelloData, GatewayIdentify, GatewayIdentifyData, GatewayInvalidSessionData, GatewayOpcodes, GatewayPresenceUpdateData, GatewayReadyDispatchData, GatewayResumeData, GatewayUpdatePresence, PresenceUpdateStatus } from "discord.js"; 5 5 import { isDebugMode, parseBool, removeUndefinedKeys, sleep } from "../../../utils.js"; 6 6 import pEvent from 'p-event'; 7 7 import EventEmitter from "events"; ··· 12 12 import { isPlayObject, PlayObject } from "../../../../core/Atomic.js"; 13 13 import dayjs from "dayjs"; 14 14 import { capitalize } from "../../../../core/StringUtils.js"; 15 - import { parseArrayFromMaybeString } from "../../../utils/StringUtils.js"; 15 + import { parseArrayFromMaybeString, parseBoolOrArrayFromMaybeString } from "../../../utils/StringUtils.js"; 16 16 import { getRoot } from "../../../ioc.js"; 17 17 import { MSCache } from "../../Cache.js"; 18 18 import { isSuperAgentResponseError } from "../../errors/ErrorUtils.js"; ··· 33 33 */ 34 34 export class DiscordWSClient extends AbstractApiClient { 35 35 36 - declare config: DiscordClientData; 36 + declare config: DiscordStrongData; 37 37 38 38 heartbeatInterval: NodeJS.Timeout 39 39 acknowledged: boolean = true; ··· 56 56 closeEvents: number = 0; 57 57 58 58 lastActiveStatus?: PresenceUpdateStatus = PresenceUpdateStatus.Offline; 59 + lastActivities: GatewayActivity[] = []; 59 60 60 61 activityTimeout: NodeJS.Timeout; 61 - 62 - artworkOpt: boolean | string[] = false; 63 62 64 63 emitter: EventEmitter; 65 64 ··· 67 66 artFail: boolean = false; 68 67 artFailCount = 0; 69 68 70 - constructor(name: any, config: DiscordClientData, options: AbstractApiOptions) { 69 + constructor(name: any, config: DiscordStrongData, options: AbstractApiOptions) { 71 70 super('Discord', name, config, options); 72 71 this.logger = childLogger(options.logger, 'WS Gateway'); 73 72 this.emitter = new EventEmitter(); 74 - if (typeof this.config.artwork === 'boolean' || Array.isArray(this.config.artwork)) { 75 - this.artworkOpt = this.config.artwork; 76 - } else if (typeof this.config.artwork === 'string') { 77 - if (['true', 'false'].includes(this.config.artwork.toLocaleLowerCase())) { 78 - this.artworkOpt = parseBool(this.config.artwork) 79 - } else { 80 - this.artworkOpt = parseArrayFromMaybeString(this.config.artwork) 81 - } 82 - } 83 - if(this.config.artworkDefaultUrl !== undefined && typeof this.config.artworkDefaultUrl === 'string' && this.config.artworkDefaultUrl.toLocaleLowerCase().trim() === 'false') { 84 - this.config.artworkDefaultUrl = false; 85 - } 86 73 this.cache = getRoot().items.cache(); 87 74 } 88 75 ··· 302 289 this.sequence = undefined; 303 290 this.resume_gateway_url = undefined; 304 291 this.user = undefined; 292 + this.lastActiveStatus = PresenceUpdateStatus.Offline; 293 + this.lastActivities = []; 305 294 } 306 295 } 307 296 308 297 handleUserSessionUpdates = (data: UserSession[]) => { 309 298 this.logger.debug('Recieved updated user sessions'); 310 - const otherSessions = data.filter(x => x.session_id !== this.session_id); 311 - if (otherSessions.length === 0) { 299 + if (data.filter(x => x.session_id !== this.session_id && x.session_id !== 'all').length === 0) { 312 300 this.logger.debug('No other user sessions exist, marking our session presence as inactive'); 313 301 this.lastActiveStatus = PresenceUpdateStatus.Offline; 302 + this.lastActivities = []; 303 + return; 314 304 } 305 + const otherSessions = data.filter(x => x.session_id !== this.session_id); 306 + const sessionSummaries = otherSessions.map(x => { 307 + let sessionId = `${x.session_id === 'all' ? '(All) | ' : ''}OS ${x.client_info.os} | Client ${x.client_info.client} | Status ${x.status} | Active ${x.active === true}`; 308 + if(x.activities.length === 0) { 309 + sessionId += " | 0 Activities" 310 + } else { 311 + const activitySummary = x.activities.map(x => x.type === 4 ? 'Custom Status' : `${activityIdToStr(x.type)} ${x.name}`).join(', '); 312 + sessionId += ` | Activities => ${activitySummary}`; 313 + }; 314 + return sessionId; 315 + }); 316 + this.logger.debug(sessionSummaries.join('\n')); 317 + 318 + const last = this.lastActiveStatus; 319 + 315 320 if (otherSessions.some(x => x.status === 'online')) { 316 321 this.lastActiveStatus = PresenceUpdateStatus.Online; 317 322 } else if (otherSessions.some(x => x.status === 'dnd')) { ··· 324 329 this.lastActiveStatus = PresenceUpdateStatus.Offline; 325 330 } 326 331 this.logger.debug(`Best status found: ${this.lastActiveStatus}`); 332 + 333 + this.lastActivities = otherSessions.filter(x => x.session_id !== 'all').map(x => x.activities).flat(1); 334 + 335 + const [allowed, reason] = this.presenceIsAllowed(); 336 + if(!allowed) { 337 + // if updated sessions now disallow updating presence 338 + // and we have a current presence in our session 339 + // then we need to remove it so it doesn't override anything 340 + const ourSession = data.find(x => x.session_id === this.session_id); 341 + if(ourSession !== undefined && ourSession.activities.length > 0) { 342 + this.logger.debug(`Clearing our session presence, MS presence no longer allowed because ${reason}`); 343 + this.clearActivity(); 344 + } 345 + } 327 346 } 328 347 329 348 async handleMessage(message: _DataPayload<GatewayDispatchEvents> | _NonDispatchPayload) { ··· 367 386 case 'SESSIONS_REPLACE': 368 387 if (isDebugMode()) { 369 388 // @ts-expect-error 370 - this.logger.debug({ data: message.d }, t); 389 + this.logger.debug(`${t} => ${JSON.stringify(message.d)}`); 371 390 } 372 391 // @ts-expect-error 373 392 this.handleUserSessionUpdates(message.d as UserSession[]); ··· 406 425 407 426 playStateToActivity = async (data: SourceData): Promise<GatewayActivity> => { 408 427 const { activity, artUrl } = playStateToActivityData(data); 409 - const artwork = this.artworkOpt; 428 + const { 429 + artwork = false 430 + } = this.config; 410 431 const { 411 432 artworkDefaultUrl = ARTWORK_PLACEHOLDER 412 433 } = this.config; ··· 552 573 } 553 574 return; 554 575 } 576 + } 555 577 578 + presenceIsAllowedByStatus = (status?: PresenceUpdateStatus | StatusType): [boolean, string?] => { 579 + if (!this.config.statusOverrideAllow.includes(status as StatusType ?? this.lastActiveStatus as StatusType)) { 580 + return [false, `most active session has a disallowed status: ${status ?? this.lastActiveStatus}`]; 581 + } 582 + return [true]; 583 + } 584 + 585 + presenceIsAllowedByActivity = (manualActivities?: GatewayActivity[]): [boolean, string?] => { 586 + const activities = manualActivities ?? this.lastActivities; 587 + if (activities.length !== 0) { 588 + const disallowedActivityType = activities.find(x => !this.config.activitiesOverrideAllow.includes(activityIdToStr(x.type))); 589 + if (disallowedActivityType !== undefined) { 590 + return [false, `a session has an activity type MS is not allowed to override: ${activityIdToStr(disallowedActivityType.type)}`]; 591 + } 592 + const disallowedActivityName = activities.find(x => !this.config.applicationsOverrideDisallow.some(y => x.name.toLocaleLowerCase().includes(y.toLocaleLowerCase()))); 593 + if (disallowedActivityType !== undefined) { 594 + return [false, `a session has an activity name MS is not allowed to override: ${disallowedActivityName.name}`]; 595 + } 596 + } 597 + 598 + return [true]; 599 + } 600 + 601 + presenceIsAllowed = (): [boolean, string?] => { 602 + const [statusAllowed, statusReason] = this.presenceIsAllowedByStatus(); 603 + if(!statusAllowed) { 604 + return [statusAllowed, statusReason]; 605 + } 606 + 607 + const [activityAllowed, activityReason] = this.presenceIsAllowedByActivity(); 608 + if(!activityAllowed) { 609 + return [activityAllowed, activityReason]; 610 + } 611 + 612 + return [true]; 556 613 } 557 614 } 558 615 ··· 583 640 584 641 interface UserSession { 585 642 status: 'online' | 'invisible' | 'dnd' | 'idle' 643 + client_info: { 644 + version: number 645 + os: string 646 + client: string 647 + } 648 + processed_at_timestamp?: number 586 649 active?: boolean 587 650 session_id: string 588 - activities: [] 651 + // activities: { 652 + // state: string 653 + // created_at: number 654 + // type: ActivityType 655 + // name: string 656 + // }[] 657 + activities: GatewayActivity[] 589 658 } 590 659 591 660 export const playStateToActivityData = (data: SourceData, opts: { useArt?: boolean } = {}): { activity: GatewayActivity, artUrl?: string } => { ··· 638 707 const artUrl = play.meta?.art?.album ?? play.meta.art.track ?? play.meta.art.artist; 639 708 640 709 return { activity, artUrl }; 710 + } 711 + 712 + export const statusStringToType = (str: string): StatusType => { 713 + switch(str.trim().toLocaleLowerCase()) { 714 + case 'online': 715 + return PresenceUpdateStatus.Online; 716 + case 'idle': 717 + return PresenceUpdateStatus.Idle; 718 + case 'dnd': 719 + return PresenceUpdateStatus.DoNotDisturb; 720 + case 'invisible': 721 + return PresenceUpdateStatus.Invisible; 722 + default: 723 + throw new Error(`Not a valid status type. Must be one of: online | idle | dnd | invisible`); 724 + } 725 + } 726 + 727 + export const activityStringToType = (str: string): MSActivityType => { 728 + switch(str.trim().toLocaleLowerCase()) { 729 + case 'playing': 730 + return 'playing'; 731 + case 'streaming': 732 + return 'streaming'; 733 + case 'listening': 734 + return 'listening'; 735 + case 'watching': 736 + return 'watching'; 737 + case 'custom': 738 + return 'custom'; 739 + case 'competing': 740 + return 'competing'; 741 + default: 742 + throw new Error(`Not a valid activity type. Must be one of: playing | streaming | listening | watching | custom | competing`); 743 + } 744 + } 745 + 746 + export const activityIdToStr = (id: number): MSActivityType => { 747 + switch(id) { 748 + case 0: 749 + return 'playing'; 750 + case 1: 751 + return 'streaming'; 752 + case 2: 753 + return 'listening'; 754 + case 3: 755 + return 'watching'; 756 + case 4: 757 + return 'custom'; 758 + case 5: 759 + return 'competing'; 760 + default: 761 + throw new Error(`Not a valid activity type. Must be one of: playing | streaming | listening | watching | custom | competing`); 762 + } 763 + } 764 + 765 + export const configToStrong = (data: DiscordData): DiscordStrongData => { 766 + const { 767 + token, 768 + applicationId, 769 + artwork, 770 + artworkDefaultUrl, 771 + statusOverrideAllow = ['online','idle','dnd'], 772 + activitiesOverrideAllow = ['custom'], 773 + applicationsOverrideDisallow = [] 774 + } = data; 775 + 776 + const strongConfig: DiscordStrongData = { 777 + token, 778 + applicationId, 779 + applicationsOverrideDisallow: parseArrayFromMaybeString(applicationsOverrideDisallow) 780 + } 781 + 782 + if (typeof artwork === 'boolean' || Array.isArray(artwork)) { 783 + strongConfig.artwork = artwork; 784 + } else if (typeof artwork === 'string') { 785 + if (['true', 'false'].includes(artwork.toLocaleLowerCase())) { 786 + strongConfig.artwork = parseBool(artwork) 787 + } else { 788 + strongConfig.artwork = parseArrayFromMaybeString(artwork) 789 + } 790 + } 791 + 792 + if(artworkDefaultUrl !== undefined && typeof artworkDefaultUrl === 'string' && artworkDefaultUrl.toLocaleLowerCase().trim() === 'false') { 793 + strongConfig.artworkDefaultUrl = false; 794 + } else { 795 + strongConfig.artworkDefaultUrl = artworkDefaultUrl; 796 + } 797 + 798 + const saRaw = parseArrayFromMaybeString(statusOverrideAllow); 799 + strongConfig.statusOverrideAllow = saRaw.map(statusStringToType); 800 + 801 + const aaRaw = parseBoolOrArrayFromMaybeString(activitiesOverrideAllow); 802 + if(typeof aaRaw === 'boolean') { 803 + strongConfig.activitiesOverrideAllow = aaRaw ? ActivityTypes : []; 804 + } else { 805 + strongConfig.activitiesOverrideAllow = aaRaw.map(activityStringToType); 806 + } 807 + 808 + return strongConfig; 641 809 }
+33 -19
src/backend/scrobblers/DiscordScrobbler.ts
··· 1 1 import { Logger } from "@foxxmd/logging"; 2 2 import EventEmitter from "events"; 3 3 import { PlayObject, SourcePlayerObj } from "../../core/Atomic.js"; 4 - import { CALCULATED_PLAYER_STATUSES, CalculatedPlayerStatus, FormatPlayObjectOptions, REPORTED_PLAYER_STATUSES, ReportedPlayerStatus } from "../common/infrastructure/Atomic.js"; 4 + import { CALCULATED_PLAYER_STATUSES, FormatPlayObjectOptions, REPORTED_PLAYER_STATUSES, ReportedPlayerStatus } from "../common/infrastructure/Atomic.js"; 5 5 import { Notifiers } from "../notifier/Notifiers.js"; 6 6 7 7 import AbstractScrobbleClient, { nowPlayingUpdateByPlayDuration } from "./AbstractScrobbleClient.js"; 8 - import { DiscordClientConfig } from "../common/infrastructure/config/client/discord.js"; 9 - import { DiscordWSClient, playStateToActivityData } from "../common/vendor/discord/DiscordWSClient.js"; 10 - import { PresenceUpdateStatus } from "discord.js"; 8 + import { DiscordClientConfig, DiscordStrongData, StatusType } from "../common/infrastructure/config/client/discord.js"; 9 + import { configToStrong, DiscordWSClient, playStateToActivityData } from "../common/vendor/discord/DiscordWSClient.js"; 11 10 12 11 export default class DiscordScrobbler extends AbstractScrobbleClient { 13 12 ··· 15 14 requiresAuth = true; 16 15 requiresAuthInteraction = false; 17 16 18 - declare config: DiscordClientConfig; 17 + declare config: DiscordClientConfig & {data: DiscordStrongData }; 19 18 20 19 constructor(name: any, config: DiscordClientConfig, options = {}, notifier: Notifiers, emitter: EventEmitter, logger: Logger) { 21 - super('discord', name, config, notifier, emitter, logger); 22 - this.api = new DiscordWSClient(name, { ...config.data, ...config.options }, { logger: this.logger }); 20 + const strong = configToStrong(config.data); 21 + super('discord', name, {...config, data: strong}, notifier, emitter, logger); 22 + this.api = new DiscordWSClient(name, { ...strong, ...config.options }, { logger: this.logger }); 23 23 this.api.emitter.on('stopped', async (e) => { 24 24 if(e.authFailure) { 25 25 this.authFailure = true; ··· 43 43 if (token === undefined) { 44 44 throw new Error('Must provide a user token'); 45 45 } 46 + if(typeof this.config.data.artwork === 'boolean') { 47 + this.logger.verbose(`Artwork: ${this.config.data.artwork ? 'Allow any with HTTPS' : 'Allow none'}`); 48 + } else { 49 + this.logger.verbose(`Artwork: Allow HTTPS with these domains: ${this.config.data.artwork.join(', ')}`); 50 + } 51 + this.logger.verbose(`Artwork Fallback Url: ${this.config.data.artworkDefaultUrl}`); 52 + this.logger.verbose(`Allow override statuses: ${this.config.data.statusOverrideAllow.join(', ')}`); 53 + this.logger.verbose(`Allow override activity types: ${this.config.data.activitiesOverrideAllow.join(', ')}`); 54 + this.logger.verbose(`Disallow override activity names: ${this.config.data.applicationsOverrideDisallow.join(', ')}`); 46 55 await this.api.initClient(); 47 56 return true; 48 57 } ··· 83 92 } 84 93 85 94 shouldUpdatePlayingNowPlatformSpecific = async (data: SourcePlayerObj) => { 86 - if(data.status.reported === REPORTED_PLAYER_STATUSES.playing 87 - || [CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused].includes(data.status.calculated as ReportedPlayerStatus) 88 - || data.status.stale) 89 - if ([PresenceUpdateStatus.Offline, PresenceUpdateStatus.Invisible].includes(this.api.lastActiveStatus)) { 90 - this.logger.debug('Not updating presence because no user sessions have a visible status'); 91 - return false; 92 - } 93 - const [sendOk, reasons] = this.api.checkOkToSend(); 94 - if (!sendOk) { 95 - this.logger.warn(`Cannot update playing now because api client is ${reasons}`); 96 - return false; 95 + if ([CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused, CALCULATED_PLAYER_STATUSES.playing].includes(data.status.calculated as ReportedPlayerStatus) 96 + || data.status.stale) { 97 + 98 + const [sendOk, reasons] = this.api.checkOkToSend(); 99 + if (!sendOk) { 100 + this.logger.warn(`Cannot update playing now because api client is ${reasons}`); 101 + return false; 102 + } 103 + 104 + const [allowed, reason] = this.api.presenceIsAllowed(); 105 + if(!allowed) { 106 + this.logger.debug(reason); 107 + } 108 + 109 + return true; 110 + 97 111 } 98 - return true; 112 + return false; 99 113 } 100 114 }