[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 actionable links for public URLs and musicbrainz

Add links to album art and/or small image if application id enabled

FoxxMD (Feb 17, 2026, 7:05 PM UTC) 61b82778 53c8036e

+89 -9
assets/musicbrainz-logo-small.png

This is a binary file and will not be displayed.

+17
src/backend/common/vendor/ListenbrainzApiClient.ts
··· 819 819 return undefined; 820 820 } 821 821 822 + /** 823 + * Returns a known music service based on the given URL 824 + * @see https://listenbrainz.readthedocs.io/en/latest/users/json.html#payload-json-details 825 + * */ 826 + export const urlToMusicService = (url?: string): string | undefined => { 827 + if(url === undefined) { 828 + return undefined; 829 + } 830 + const lower = url.trim().toLocaleLowerCase(); 831 + for(const [k, v] of Object.entries(musicServices)) { 832 + if(url.includes(v)) { 833 + return k; 834 + } 835 + } 836 + return undefined; 837 + } 838 + 822 839 export const playToSubmitPayload = (play: PlayObject, options: SubmitOptions = {}): SubmitPayload => { 823 840 const { listenType = 'single'} = options; 824 841 const listenPayload: SubmitPayload = {listen_type: listenType, payload: [playToListenPayload(play)]};
+67 -8
src/backend/common/vendor/discord/DiscordWSClient.ts
··· 1 1 import { childLogger } from "@foxxmd/logging"; 2 2 import { WS } from 'iso-websocket' 3 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"; 4 + import { _DataPayload, _NonDispatchPayload, ActivityType, APIUser, GatewayActivity, GatewayActivityButton, 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"; ··· 16 16 import { getRoot } from "../../../ioc.js"; 17 17 import { MSCache } from "../../Cache.js"; 18 18 import { isSuperAgentResponseError } from "../../errors/ErrorUtils.js"; 19 + import { urlToMusicService } from "../ListenbrainzApiClient.js"; 19 20 20 21 const ARTWORK_PLACEHOLDER = 'https://raw.githubusercontent.com/FoxxMD/multi-scrobbler/master/assets/icon.png'; 22 + const MB_ART = 'https://raw.githubusercontent.com/FoxxMD/multi-scrobbler/master/assets/musicbrainz-logo-small.png'; 21 23 const API_GATEWAY_ENDPOINT = 'https://discord.com/api/gateway'; 22 24 23 25 /** ··· 455 457 if(usedUrl !== undefined) { 456 458 activity.assets.large_image = usedUrl; 457 459 } 460 + if(activity.assets.small_text !== undefined) { 461 + const smallArt = await this.getArtworkUrl(MB_ART); 462 + if(smallArt !== undefined) { 463 + activity.assets.small_image = smallArt; 464 + } 465 + } 458 466 } 459 467 460 468 return activity; ··· 473 481 474 482 const activity = await this.playStateToActivity(data); 475 483 476 - let clearTime = dayjs().add(5, 'minutes'); 477 - if (activity.timestamps.end !== undefined) { 484 + const play = isPlayObject(data) ? data : data.play; 485 + 486 + let clearTime = dayjs().add(260, 'seconds'); // funny number 487 + if (activity.timestamps?.end !== undefined) { 478 488 clearTime = dayjs.unix(Math.floor(activity.timestamps.end as number / 1000)); 489 + } else if (play.data?.duration !== undefined) { 490 + clearTime = dayjs().add(play.data.duration, 'seconds') 479 491 } 480 492 481 493 const updateData = this.generatePresenceUpdate(); ··· 677 689 } 678 690 } 679 691 680 - let activityName = 'Music'; 681 - if (play.meta?.source !== undefined) { 682 - activityName = capitalize(play.meta.source) 683 - } 692 + let activityName = capitalize(play.meta?.musicService ?? play.meta?.mediaPlayerName ?? play.meta?.source ?? 'music') 684 693 685 694 // @ts-expect-error 686 695 const activity = removeUndefinedKeys<GatewayActivity>({ ··· 703 712 end: endTime 704 713 } 705 714 } 715 + 716 + //let buttons: GatewayActivityButton[] = []; 706 717 707 - const artUrl = play.meta?.art?.album ?? play.meta.art.track ?? play.meta.art.artist; 718 + const { 719 + meta: { 720 + url: { 721 + web, 722 + origin 723 + } = {}, 724 + }, 725 + data: { 726 + meta: { 727 + brainz: { 728 + recording 729 + } = {} 730 + } = {} 731 + } = {} 732 + } = play; 733 + const url = origin ?? web; 734 + if(url !== undefined) { 735 + const knownService = urlToMusicService(url); 736 + if(knownService !== undefined) { 737 + activity.assets.large_url = url; 738 + 739 + // when including buttons discord accepts the presence update but does not actually use it 740 + // I think buttons may now be limited to official RPC or restricted to preset actions via things like secrets or registering commands 741 + // https://docs.discord.com/developers/developer-tools/game-sdk#activitysecrets-struct 742 + 743 + // buttons.push({ 744 + // label: `Listen on ${capitalize(knownService)}`, 745 + // url: web 746 + // }); 747 + } 748 + } 749 + if(recording !== undefined) { 750 + const mb = `https://musicbrainz.org/recording/${recording}`; 751 + if(activity.assets.large_url === undefined) { 752 + activity.assets.large_url = mb; 753 + } else { 754 + activity.assets.small_url = mb; 755 + activity.assets.small_text = 'Open On Musicbrainz'; 756 + } 757 + // buttons.push({ 758 + // label: 'Open on Musicbrainz', 759 + // url: `https://musicbrainz.org/recording/${recording}` 760 + // }); 761 + } 762 + // if(buttons.length > 0) { 763 + // activity.buttons = buttons; 764 + // } 765 + 766 + const artUrl = play.meta?.art?.album ?? play.meta?.art?.track ?? play.meta?.art?.artist; 708 767 709 768 return { activity, artUrl }; 710 769 }
+4 -1
src/backend/sources/JellyfinApiSource.ts
··· 454 454 meta.album = ProviderIds.MusicBrainzAlbum; 455 455 } 456 456 if(ProviderIds.MusicBrainzTrack !== undefined) { 457 - meta.recording = ProviderIds.MusicBrainzTrack; 457 + meta.track = ProviderIds.MusicBrainzTrack; 458 + } 459 + if(ProviderIds.MusicBrainzRecording !== undefined) { 460 + meta.recording = ProviderIds.MusicBrainzRecording; 458 461 } 459 462 if(ProviderIds.MusicBrainzArtist !== undefined) { 460 463 meta.artist = [ProviderIds.MusicBrainzArtist];
+1
src/backend/sources/WebScrobblerSource.ts
··· 146 146 }, 147 147 deviceId: `${connectorLabel}-${controllerTabId}`, 148 148 musicService: connectorL, 149 + source: 'WebScrobbler', 149 150 scrobbleAllowed: isScrobblingAllowed, 150 151 nowPlaying: options.nowPlaying ?? false 151 152 }