[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 album art functionality

FoxxMD (Feb 13, 2026, 10:00 PM UTC) 3aada673 14560700

+107 -27
+1
docsite/docs/configuration/clients/clients.mdx
··· 12 12 13 13 | Name | Now Playing | 14 14 | :-------------------------------------------------- | :---------- | 15 + | [Discord](/configuration/clients/discord) | ✅ | 15 16 | [Last.fm](/configuration/clients/lastfm) | ✅ | 16 17 | [Libre.fm](/configuration/clients/librefm) | ✅ | 17 18 | [Listenbrainz](/configuration/clients/listenbrainz) | ✅ |
+33 -8
docsite/docs/configuration/clients/discord.mdx
··· 11 11 12 12 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. 13 13 14 + <img src={require('/img/discord_presence.png').default} height="131"/> 15 + 14 16 :::warning[Discord TOS Violation] 15 17 16 18 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 51 50 52 ## Artwork 51 53 52 - For some Sources, MS parses album art and displays it in the dashboard. This image can be displayed on Discord alongside your listening status. 54 + 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 + 56 + If no Application Id is provided Discord will always show a default image next to your status. 57 + 58 + <details> 59 + 60 + <summary>Create An Application</summary> 61 + 62 + Go to the [Discord Developer Portal](https://discord.com/developers/applications) 63 + 64 + * Click on "New Application" and give it a name 65 + * Copy the **Application Id** (Client Id) shown after creation 66 + 67 + </details> 68 + 69 + 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. 70 + 71 + <details> 72 + 73 + <summary>Using External URLs</summary> 53 74 54 - 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: 75 + There are some scenarios where you may not want to use your artwork URLs, for example: 55 76 56 77 * A Source may be accessible only on your LAN (Jellyfin at `http://192.168.0.101`) 57 78 * A Source may be internet-facing but you do not want to expose this information to discord/other users ··· 62 83 * To allow **any** `https` album art URLs set ENV `DISCORD_ARTWORK=true` 63 84 * 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"]`) 64 85 65 - 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` 86 + 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. 87 + 88 + 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`. 89 + 90 + </details> 66 91 67 92 ## Configuration 68 93 69 94 <Config config="DiscordClientConfig" fileContent={JsonConfig} client name="discord"> 70 - | Environmental Variable | Required? | Default | Description | 71 - | :---------------------------- | --------- | ------- | :--------------------------------------------------------------------------------------------------------- | 72 - | `DISCORD_TOKEN` | Yes | | User Token acquired from an active Discord sessions | 73 - | `DISCORD_ARTWORK` | No | | A boolean indicating if external artwork URLs should be used. Or a comma-separated list of allowed domains | 74 - | `DISCORD_ARTWORK_DEFAULT_URL` | No | | A URL of an image to use as a fallback if the album art URL cannot be used | 95 + | Environmental Variable | Required? | Default | Description | 96 + | :---------------------------- | --------- | ------- | :--------------------------------------------------------------------------------------------------------------- | 97 + | `DISCORD_TOKEN` | Yes | | User Token acquired from an active Discord sessions | 98 + | `DISCORD_ARTWORK` | No | | A boolean indicating if external artwork URLs should be used. Or a comma-separated list of allowed domains | 99 + | `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. | 75 100 </Config>
+1
docsite/docs/index.mdx
··· 42 42 * [Yamaha MusicCast](/configuration/sources/yamaha-musiccast) 43 43 * [Youtube Music](/configuration/sources/youtube-music) 44 44 * Supports scrobbling to many [**Clients**](/configuration/clients) 45 + * [Discord](/configuration/clients/discord) (Now Playing) 45 46 * [Koito](/configuration/clients/koito) 46 47 * [Last.fm](/configuration/clients/lastfm) 47 48 * [Libre.fm](/configuration/clients/librefm)
docsite/static/img/discord_presence.png

This is a binary file and will not be displayed.

+2 -1
src/backend/common/infrastructure/config/client/discord.ts
··· 3 3 4 4 export interface DiscordData { 5 5 token: string 6 + applicationId?: string 6 7 artwork?: boolean | string | string[] 7 - artworkDefaultUrl?: string 8 + artworkDefaultUrl?: string | false 8 9 } 9 10 10 11 export interface DiscordClientData extends DiscordData, CommonClientData {}
+68 -16
src/backend/common/vendor/discord/DiscordWSClient.ts
··· 13 13 import dayjs from "dayjs"; 14 14 import { capitalize } from "../../../../core/StringUtils.js"; 15 15 import { parseArrayFromMaybeString } from "../../../utils/StringUtils.js"; 16 + import { getRoot } from "../../../ioc.js"; 17 + import { MSCache } from "../../Cache.js"; 18 + import { isSuperAgentResponseError } from "../../errors/ErrorUtils.js"; 16 19 17 - const ARTWORK_PLACEHOLDER = 'https://raw.githubusercontent.com/FoxxMD/multi-scrobbler/master/assets/icon128.png'; 20 + const ARTWORK_PLACEHOLDER = 'https://raw.githubusercontent.com/FoxxMD/multi-scrobbler/master/assets/icon.png'; 18 21 const API_GATEWAY_ENDPOINT = 'https://discord.com/api/gateway'; 19 22 20 23 /** ··· 60 63 61 64 emitter: EventEmitter; 62 65 66 + cache: MSCache; 67 + artFail: boolean = false; 68 + artFailCount = 0; 69 + 63 70 constructor(name: any, config: DiscordClientData, options: AbstractApiOptions) { 64 71 super('Discord', name, config, options); 65 72 this.logger = childLogger(options.logger, 'WS Gateway'); 66 73 this.emitter = new EventEmitter(); 67 - if(typeof this.config.artwork === 'boolean' || Array.isArray(this.config.artwork)) { 74 + if (typeof this.config.artwork === 'boolean' || Array.isArray(this.config.artwork)) { 68 75 this.artworkOpt = this.config.artwork; 69 - } else if(typeof this.config.artwork === 'string') { 70 - if(['true','false'].includes(this.config.artwork.toLocaleLowerCase())) { 76 + } else if (typeof this.config.artwork === 'string') { 77 + if (['true', 'false'].includes(this.config.artwork.toLocaleLowerCase())) { 71 78 this.artworkOpt = parseBool(this.config.artwork) 72 79 } else { 73 80 this.artworkOpt = parseArrayFromMaybeString(this.config.artwork) 74 81 } 75 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 + this.cache = getRoot().items.cache(); 76 87 } 77 88 78 89 initClient = async () => { 79 90 let baseUrl: string; 80 - if(this.resume_gateway_url !== undefined) { 91 + if (this.resume_gateway_url !== undefined) { 81 92 baseUrl = this.resume_gateway_url; 82 93 } else { 83 94 if (this.initialGatewayUrl === undefined) { ··· 109 120 // should receive a close code https://docs.discord.com/developers/topics/opcodes-and-status-codes#gateway-gateway-close-event-codes 110 121 // which determines if reconnect is possible 111 122 this.logger.warn(`Connection was closed: ${e.code} => ${e.reason}`); 112 - if([ 123 + if ([ 113 124 GatewayCloseCodes.AuthenticationFailed, 114 125 GatewayCloseCodes.InvalidShard, 115 126 GatewayCloseCodes.ShardingRequired, ··· 119 130 ].includes(e.code)) { 120 131 this.canReconnect = false; 121 132 } 122 - if(GatewayCloseCodes.AuthenticationFailed === e.code) { 133 + if (GatewayCloseCodes.AuthenticationFailed === e.code) { 123 134 await this.cleanupConnection(); 124 135 this.authOK = false; 125 - this.emitter.emit('stopped', {authFailure: true}); 136 + this.emitter.emit('stopped', { authFailure: true }); 126 137 // don't attempt to reconnect, will always fail 127 - } else if(this.closeEvents < 3) { 138 + } else if (this.closeEvents < 3) { 128 139 this.closeEvents++; 129 140 await this.handleReconnect(); 130 141 } else { 131 142 await this.cleanupConnection(); 132 - this.emitter.emit('stopped', {authFailure: false}); 143 + this.emitter.emit('stopped', { authFailure: false }); 133 144 } 134 145 }); 135 146 this.client.addEventListener('open', (e) => { ··· 196 207 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.`); 197 208 await sleep(sleepTime); 198 209 const [isOk] = this.checkOkToSend(); 199 - if(!isOk) { 210 + if (!isOk) { 200 211 return; 201 212 } 202 213 this.sendHeartbeat(); 203 214 this.heartbeatInterval = setInterval(() => { 204 - if(this.client.OPEN !== this.client.readyState) { 205 - if(this.heartbeatInterval !== undefined) { 215 + if (this.client.OPEN !== this.client.readyState) { 216 + if (this.heartbeatInterval !== undefined) { 206 217 clearInterval(this.heartbeatInterval); 207 218 } 208 219 return; ··· 393 404 } 394 405 } 395 406 396 - playStateToActivity = (data: SourceData): GatewayActivity => { 407 + playStateToActivity = async (data: SourceData): Promise<GatewayActivity> => { 397 408 const { activity, artUrl } = playStateToActivityData(data); 398 409 const artwork = this.artworkOpt; 399 410 const { ··· 418 429 } 419 430 // https://docs.discord.com/developers/events/gateway-events#activity-object-activity-assets 420 431 // https://docs.discord.com/developers/events/gateway-events#activity-object-activity-asset-image 421 - activity.assets.large_image = art; 432 + if(art !== false) { 433 + const usedUrl = await this.getArtworkUrl(art); 434 + if(usedUrl !== undefined) { 435 + activity.assets.large_image = usedUrl; 436 + } 437 + } 422 438 423 439 return activity; 424 440 } ··· 434 450 this.logger.warn(`Cannot send activity because client is ${reasons}`); 435 451 return; 436 452 } 437 - const activity = this.playStateToActivity(data); 453 + const activity = await this.playStateToActivity(data); 438 454 439 455 let clearTime = dayjs().add(5, 'minutes'); 440 456 if (activity.timestamps.end !== undefined) { ··· 501 517 reasons.push(`socket not open (${this.client.readyState})`); 502 518 } 503 519 return [false, reasons.join(' and ')]; 520 + } 521 + 522 + getArtworkUrl = async (artUrl: string): Promise<string | undefined> => { 523 + 524 + const cachedUrl = await this.cache.cacheMetadata.get<string>(artUrl); 525 + if (cachedUrl !== undefined) { 526 + return cachedUrl; 527 + } 528 + 529 + if (this.config.applicationId === undefined || this.artFail) { 530 + return; 531 + } 532 + 533 + try { 534 + const imgResp = await request.post(`https://discord.com/api/v10/applications/${this.config.applicationId}/external-assets`) 535 + .set('Authorization', this.config.token) 536 + .type('json') 537 + .send({ "urls": [artUrl] }); 538 + this.artFailCount = 0; 539 + const proxied = `mp:${imgResp.body[0].external_asset_path}` 540 + await this.cache.cacheMetadata.set(artUrl, proxied); 541 + return proxied; 542 + } catch (e) { 543 + this.artFailCount++; 544 + this.logger.warn(new Error('Failed to upload art url', { cause: e })); 545 + if (isSuperAgentResponseError(e)) { 546 + if (e.status === 401 || e.status === 403) { 547 + this.artFail = true; 548 + } 549 + } else if (this.artFailCount > 3) { 550 + this.logger.verbose('More than 3 consecutive failures to upload art...turning off to stop spamming bad requests'); 551 + this.artFail = true; 552 + } 553 + return; 554 + } 555 + 504 556 } 505 557 } 506 558
+2 -2
src/backend/scrobblers/DiscordScrobbler.ts
··· 6 6 7 7 import AbstractScrobbleClient, { nowPlayingUpdateByPlayDuration } from "./AbstractScrobbleClient.js"; 8 8 import { DiscordClientConfig } from "../common/infrastructure/config/client/discord.js"; 9 - import { DiscordWSClient } from "../common/vendor/discord/DiscordWSClient.js"; 9 + import { DiscordWSClient, playStateToActivityData } from "../common/vendor/discord/DiscordWSClient.js"; 10 10 11 11 export default class DiscordScrobbler extends AbstractScrobbleClient { 12 12 ··· 61 61 alreadyScrobbled = async (playObj: PlayObject, log = false) => true 62 62 63 63 public playToClientPayload(playObj: PlayObject): any { 64 - return this.api.playStateToActivity(playObj); 64 + return playStateToActivityData(playObj).activity; 65 65 } 66 66 67 67 doScrobble = async (playObj: PlayObject) => {