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 }