From 078dcf51be31b963496de46485ac69e6f13fd6bc Mon Sep 17 00:00:00 2001 From: Niko Huuskonen Date: Fri, 28 Aug 2026 02:41:39 +0300 Subject: [PATCH 1/3] feat: make discord rpc fields customizable This adds customization options for various fields in discord RPC as well as option to use custom app id Signed-off-by: Niko Huuskonen --- src/main/discord.ts | 81 ++++++------- src/main/mainWindow.ts | 22 +--- src/renderer/amethyst.ts | 92 ++++++++++++--- src/renderer/locales/en-US.json | 15 +++ .../logic/MediaSource/JellyfinMediaSource.ts | 8 +- .../logic/MediaSource/MediaSourceManager.ts | 4 +- .../logic/MediaSource/SubsonicMediaSource.ts | 11 +- src/renderer/logic/MediaSource/index.ts | 1 + src/renderer/logic/settings.ts | 17 ++- src/renderer/logic/track.ts | 46 ++++++-- .../views/Settings/IntegrationSettings.vue | 111 ++++++++++++++++++ .../views/Settings/MediaSourceSettings.vue | 10 ++ src/shared/types.ts | 29 +++++ 13 files changed, 359 insertions(+), 88 deletions(-) diff --git a/src/main/discord.ts b/src/main/discord.ts index 506c84c1..3ba8b3d1 100644 --- a/src/main/discord.ts +++ b/src/main/discord.ts @@ -1,36 +1,26 @@ import { ActivityType, Client } from "@nyabsi/minimal-discord-rpc"; +import type { IRichPresenceInfo } from "@shared/types.js"; -import { APP_VERSION } from "./mainWindow.js"; - -const DISCORD_CLIENT_ID = "976036303156162570"; - -export type FormatIcons = "aiff" | "flac" | "mpeg" | "ogg" | "wave"; - -export interface IRichPresenceInfo { - title: string; - album: string; - timestamps: { - start: number; - end: number; - }; - coverUrl: string; - containerFormat: FormatIcons; - pauseStatus: string; -} +export const DEFAULT_DISCORD_CLIENT_ID = "976036303156162570"; export class Discord { - private readonly client: Client; + private client: Client; - private readonly connected: Promise; + private connected: Promise; private destroyed: boolean; - constructor() { - this.client = new Client({ clientId: DISCORD_CLIENT_ID }); + private clientId: string; - this.connected = this.connect(); + constructor(clientId: string = DEFAULT_DISCORD_CLIENT_ID) { + this.clientId = clientId || DEFAULT_DISCORD_CLIENT_ID; + this.client = new Client({ clientId: this.clientId }); this.destroyed = false; + this.bindEvents(); + this.connected = this.connect(); + } + private bindEvents(): void { this.client.on("ready", () => { // Do something when ready }); @@ -45,6 +35,22 @@ export class Discord { }); } + /** + * Swaps the RPC client over to a different Discord application (client) id, e.g. when the + * user configures a custom one in settings. No-op if the id hasn't actually changed. + */ + public setClientId(clientId: string): void { + const id = clientId || DEFAULT_DISCORD_CLIENT_ID; + if (id === this.clientId) return; + + this.clientId = id; + this.client.destroy().catch(() => {}); + this.client = new Client({ clientId: id }); + this.destroyed = false; + this.bindEvents(); + this.connected = this.connect(); + } + public clearRichPresence(): void { this.connected.then((check) => { if (check && !this.destroyed) { @@ -58,29 +64,18 @@ export class Discord { if (check && !this.destroyed) { this.client.setActivity({ type: ActivityType.Listening, - details: info.title.toString(), - state: info.album.toString(), - timestamps: info.pauseStatus == "yes" - ? { - start: info.timestamps.end, - end: info.timestamps.end, - } - : { - start: info.timestamps.start, - end: info.timestamps.end, - }, + details: info.details || undefined, + state: info.state || undefined, + timestamps: info.timestamps, assets: { - large_image: info.coverUrl !== "" ? `${info.coverUrl}` : "audio_file", - large_text: info.pauseStatus == "yes" ? `Paused - ${info.containerFormat?.toUpperCase() || "Unknown Format"}` : info.containerFormat?.toUpperCase() || "Unknown Format", - small_image: "logo", - small_text: `Amethyst ${APP_VERSION}\n`, + large_image: info.largeImageKey || "audio_file", + large_text: info.largeImageText || undefined, + small_image: info.smallImageKey || undefined, + small_text: info.smallImageText || undefined, }, - buttons: [ - { - label: "Find Song", - url: `https://www.youtube.com/results?search_query=${encodeURIComponent(info.title.toString())}`, - }, - ], + buttons: info.buttonEnabled && info.buttonUrl + ? [{ label: info.buttonLabel, url: info.buttonUrl }] + : undefined, }); } }); diff --git a/src/main/mainWindow.ts b/src/main/mainWindow.ts index eb0979ae..50acb669 100644 --- a/src/main/mainWindow.ts +++ b/src/main/mainWindow.ts @@ -1,3 +1,4 @@ +import type { IRichPresenceInfo } from "@shared/types.js"; import chalk from "chalk"; import type { FSWatcher } from "chokidar"; import chokidar from "chokidar"; @@ -12,7 +13,6 @@ import os from "os"; import path from "path"; import { ALLOWED_AUDIO_EXTENSIONS } from "../shared/constants.js"; -import type { FormatIcons, IRichPresenceInfo } from "./discord.js"; import { Discord } from "./discord.js"; import { getWindow, IS_DEV, store } from "./main.js"; import { __dirname } from "./utility.js"; @@ -327,24 +327,14 @@ export class MainWindow { }; }, - "update-rich-presence": (_: Event, [args]: string[]) => { - const [title, album, start, end, cover, format, paused] = args; - - const info: IRichPresenceInfo = { - title: title, - album: album, - timestamps: { - start: parseInt(start), - end: (parseInt(start) + parseInt(end) * 1000), - }, - coverUrl: cover, - containerFormat: format as FormatIcons, - pauseStatus: paused, - }; - + "update-rich-presence": (_: Event, [info]: [IRichPresenceInfo]) => { this.discord.updateCurrentSong(info); }, + "set-discord-client-id": (_: Event, [clientId]: [string]) => { + this.discord.setClientId(clientId); + }, + "set-vsync": (_: Event, [useVsync]: string[]) => { store.set("useVsync", useVsync); console.log(`Set store 'frameRateLimit' to ${useVsync}`); diff --git a/src/renderer/amethyst.ts b/src/renderer/amethyst.ts index 6696e990..0c133e79 100644 --- a/src/renderer/amethyst.ts +++ b/src/renderer/amethyst.ts @@ -3,6 +3,7 @@ import { StatusBar } from "@capacitor/status-bar"; import { NavigationBar } from "@hugotomazi/capacitor-navigation-bar"; import messages from "@intlify/unplugin-vue-i18n/messages"; import { ALLOWED_AUDIO_EXTENSIONS } from "@shared/constants.js"; +import type { DiscordRpcField, IRichPresenceInfo } from "@shared/types.js"; import { useLocalStorage } from "@vueuse/core"; import type { OpenDialogReturnValue, SaveDialogReturnValue } from "electron"; import { ref, watch } from "vue"; @@ -453,24 +454,83 @@ export class Amethyst extends AmethystBackend { let seekDuringPause: boolean = false; let trackNameBeforePause: string; + const findMediaSourceForTrack = (track: Track) => { + return this.mediaSourceManager.mediaSources.value.find((source) => { + if (source.type !== track.sourceType) return false; + if (track.sourceType === MediaSourceType.LocalFolder) return track.path.startsWith(source.path); + return track.credentials?.url === source.path; + }); + }; + + // Resolves which image key to send Discord: the track's own source-provided art if + // Discord-wide cover art is on and that specific media source allows sharing its art, + // otherwise falls back to a public MusicBrainz lookup, otherwise the static fallback asset. + const resolveCoverArtKey = async (track: Track): Promise => { + const discord = this.state.settings.integrations.discord; + if (!discord.showCoverArt) return "audio_file"; + + const source = findMediaSourceForTrack(track); + const sourceAllowsCoverArt = source?.sendCoverArtToDiscord ?? true; + + if (sourceAllowsCoverArt) { + await track.fetchAlbumCoverUrl(); + if (track.coverUrl) return track.coverUrl; + } + + const musicBrainzCoverUrl = await track.fetchMusicBrainzCoverUrl(); + return musicBrainzCoverUrl || "audio_file"; + }; + + const resolveField = (field: DiscordRpcField, track: Track): string => { + const title = track.getTitleFormatted(); + const artist = track.getArtistsFormatted() || "Unknown Artist"; + const album = track.getAlbum() || "Unknown Album"; + const format = track.getContainer()?.toUpperCase() || "Unknown Format"; + + switch (field) { + case "Title": return title; + case "Artist": return artist; + case "Album": return album; + case "Artist - Album": return `${artist} - ${album}`; + case "Title - Artist": return `${title} - ${artist}`; + case "Format": return format; + case "App Info": return `Amethyst ${this.VERSION}`; + case "None": + default: return ""; + } + }; + const clearRichPresence = () => { richPresenceTimer && clearInterval(richPresenceTimer); window.electron.ipcRenderer.invoke("clear-rich-presence"); }; - const updateRichPresence = async (track: Track) => { + const updateRichPresence = async (track: Track, coverArtKey: string) => { const sendData = () => { - const args = [ - track.getArtists() && track.getTitle() ? `${track.getTitle()}` : track.getFilename(), - `${track.getArtists()} - ${track.getAlbum()}`, - start.toString(), - (track.getDurationSeconds() as number).toString(), - track.sourceType != MediaSourceType.Subsonic && track.coverUrl, - track.getContainer()?.toLowerCase() || "unknown format", - isPaused ? "yes" : "no", - ]; - window.electron.ipcRenderer.invoke("update-rich-presence", [args]); - if (this.IS_DEV) console.log(`%c[⚐ Discord RPC]%c Updated RPC status`, "background-color: #7289da; color: black; font-weight: bold;", "color: #ffffff;", args); + const discord = this.state.settings.integrations.discord; + + let largeImageText = resolveField(discord.fields.largeImageText, track); + if (isPaused) largeImageText = largeImageText ? `Paused - ${largeImageText}` : "Paused"; + + const trackEnd = start + (track.getDurationSeconds() as number) * 1000; + + const info: IRichPresenceInfo = { + details: resolveField(discord.fields.details, track), + state: resolveField(discord.fields.state, track), + largeImageKey: coverArtKey, + largeImageText, + smallImageKey: "logo", + smallImageText: resolveField(discord.fields.smallImageText, track), + timestamps: isPaused + ? { start: trackEnd, end: trackEnd } + : { start, end: trackEnd }, + buttonEnabled: discord.showFindSongButton, + buttonLabel: "Find Song", + buttonUrl: `https://www.youtube.com/results?search_query=${encodeURIComponent(track.getTitleFormatted())}`, + }; + + window.electron.ipcRenderer.invoke("update-rich-presence", [info]); + if (this.IS_DEV) console.log(`%c[⚐ Discord RPC]%c Updated RPC status`, "background-color: #7289da; color: black; font-weight: bold;", "color: #ffffff;", info); }; richPresenceTimer && clearInterval(richPresenceTimer); @@ -481,11 +541,15 @@ export class Amethyst extends AmethystBackend { const updateWithCurrentTrack = async () => { if (this.getCurrentPlatform() == "mobile") return; const currentTrack = this.player.getCurrentTrack(); - await currentTrack?.fetchAlbumCoverUrl(); if (!currentTrack) return; - await updateRichPresence(currentTrack); + const coverArtKey = await resolveCoverArtKey(currentTrack); + await updateRichPresence(currentTrack, coverArtKey); }; + watch(() => this.state.settings.integrations.discord.clientId, (clientId) => { + window.electron.ipcRenderer.invoke("set-discord-client-id", [clientId]); + }, { immediate: true }); + if (this.state.settings.integrations.discord.enabled) { this.player.on("player:trackChange", async (track) => { if (isPaused && trackNameBeforePause == track.getTitleFormatted()) { diff --git a/src/renderer/locales/en-US.json b/src/renderer/locales/en-US.json index f2c35257..70e5ed4d 100644 --- a/src/renderer/locales/en-US.json +++ b/src/renderer/locales/en-US.json @@ -98,6 +98,7 @@ "settings.subsonic.password": "Password", "settings.media_source_type.local_folder": "Local folder", "settings.media_sources.tracks": "Tracks", + "settings.media_sources.send_cover_art_to_discord": "Cover art in Discord", "settings.media_sources.connected": "Connected", "settings.media_sources.disconnected": "Disconnected", "settings.media_source_type.subsonic": "Subsonic server", @@ -183,6 +184,20 @@ "settings.appearance.debug_stats.title": "Debug statistics", "settings.appearance.debug_stats.description": "Show numbers such as samples, FPS, processor usage and more at the top bar", "settings.discord_rpc.description": "Discord Rich Presence allows users to share their current activity on Amethyst with Discord. When enabled, the RPC feature updates the user's Discord status to show the version of Amethyst, currently playing song or playlist, along with the artist and album", + "settings.discord_rpc.client_id.title": "Application ID", + "settings.discord_rpc.client_id.description": "Use a custom Discord application ID instead of Amethyst's default one. Leave empty to use the default. Note that a custom application needs its own 'logo' and 'audio_file' assets uploaded in the Discord Developer Portal or the images won't show up", + "settings.discord_rpc.find_song_button.title": "Find song button", + "settings.discord_rpc.find_song_button.description": "Show a button on your Discord profile that lets others search for the current song on YouTube", + "settings.discord_rpc.cover_art.title": "Cover art", + "settings.discord_rpc.cover_art.description": "Show album cover art in your Discord status. Whether a specific media source's own art is used can be configured per source in Media Sources - sources that opt out still fall back to MusicBrainz's public cover art archive when a match is found", + "settings.discord_rpc.fields.details.title": "Details", + "settings.discord_rpc.fields.details.description": "The top line of text shown on your Discord status", + "settings.discord_rpc.fields.state.title": "State", + "settings.discord_rpc.fields.state.description": "The second line of text shown on your Discord status", + "settings.discord_rpc.fields.large_image_text.title": "Cover art tooltip", + "settings.discord_rpc.fields.large_image_text.description": "The text shown when hovering over the cover art", + "settings.discord_rpc.fields.small_image_text.title": "Logo tooltip", + "settings.discord_rpc.fields.small_image_text.description": "The text shown when hovering over the small Amethyst logo", "settings.lastfm.description": "Last.fm integration allows users to scrobble their music listening history to Last.fm. When enabled, Amethyst will automatically send the currently playing track information to Last.fm, allowing users to keep track of their listening habits and discover new music based on their preferences", "settings.lastfm.username.title": "Username", "settings.lastfm.username.description": "Your Last.fm username", diff --git a/src/renderer/logic/MediaSource/JellyfinMediaSource.ts b/src/renderer/logic/MediaSource/JellyfinMediaSource.ts index a7ce025b..5f786266 100644 --- a/src/renderer/logic/MediaSource/JellyfinMediaSource.ts +++ b/src/renderer/logic/MediaSource/JellyfinMediaSource.ts @@ -84,11 +84,12 @@ export class JellyfinMediaSource extends MediaSource { public serverInformation: JellyfinPublicSystemInfo | undefined; - public constructor(protected amethyst: Amethyst, public url: string, public username: string, public password: string, scrobble = true) { + public constructor(protected amethyst: Amethyst, public url: string, public username: string, public password: string, scrobble = true, sendCoverArtToDiscord = true) { super(amethyst, url); this.type = MediaSourceType.Jellyfin; this.name = this.url; this.isScrobblingEnabled = ref(scrobble); + this.sendCoverArtToDiscord = ref(sendCoverArtToDiscord); // Persist toggling the switch in Settings back into the saved source entry watch(this.isScrobblingEnabled, (enabled) => { @@ -96,6 +97,11 @@ export class JellyfinMediaSource extends MediaSource { if (saved) saved.scrobble = enabled; }); + watch(this.sendCoverArtToDiscord, (enabled) => { + const saved = this.amethyst.state.settings.mediaSources.saveMediaSources.find((s) => s.type == MediaSourceType.Jellyfin && s.url == this.url); + if (saved) saved.sendCoverArtToDiscord = enabled; + }); + this.setupScrobbling(); this.initialize(); } diff --git a/src/renderer/logic/MediaSource/MediaSourceManager.ts b/src/renderer/logic/MediaSource/MediaSourceManager.ts index 3ee1963a..7236de37 100644 --- a/src/renderer/logic/MediaSource/MediaSourceManager.ts +++ b/src/renderer/logic/MediaSource/MediaSourceManager.ts @@ -18,12 +18,12 @@ export class MediaSourceManager { if (savedSource.type == MediaSourceType.Subsonic) { // @ts-ignore - this.mediaSources.value.push(new SubsonicMediaSource(this.amethyst, savedSource.url, savedSource.username, savedSource.password)); + this.mediaSources.value.push(new SubsonicMediaSource(this.amethyst, savedSource.url, savedSource.username, savedSource.password, savedSource.sendCoverArtToDiscord ?? true)); } if (savedSource.type == MediaSourceType.Jellyfin) { // @ts-ignore - this.mediaSources.value.push(new JellyfinMediaSource(this.amethyst, savedSource.url, savedSource.username, savedSource.password, savedSource.scrobble ?? true)); + this.mediaSources.value.push(new JellyfinMediaSource(this.amethyst, savedSource.url, savedSource.username, savedSource.password, savedSource.scrobble ?? true, savedSource.sendCoverArtToDiscord ?? true)); } }); } diff --git a/src/renderer/logic/MediaSource/SubsonicMediaSource.ts b/src/renderer/logic/MediaSource/SubsonicMediaSource.ts index db3d2d13..94441578 100644 --- a/src/renderer/logic/MediaSource/SubsonicMediaSource.ts +++ b/src/renderer/logic/MediaSource/SubsonicMediaSource.ts @@ -1,5 +1,5 @@ import SubsonicAPI, { Child, SubsonicBaseResponse } from "subsonic-api"; -import { Ref, ref } from "vue"; +import { Ref, ref, watch } from "vue"; import { Amethyst } from "@/amethyst.js"; import { MediaSource, MediaSourceType } from "@/logic//MediaSource/index.js"; @@ -16,10 +16,11 @@ export class SubsonicMediaSource extends MediaSource { private shouldStopSync = false; public serverInformation: SubsonicBaseResponse | undefined; - public constructor(protected amethyst: Amethyst, public url: string, public username: string, public password: string) { + public constructor(protected amethyst: Amethyst, public url: string, public username: string, public password: string, sendCoverArtToDiscord = true) { super(amethyst, url); this.type = MediaSourceType.Subsonic; this.name = this.url; + this.sendCoverArtToDiscord = ref(sendCoverArtToDiscord); this.api = new SubsonicAPI({ url: this.url, auth: { @@ -28,6 +29,12 @@ export class SubsonicMediaSource extends MediaSource { }, }); + // Persist toggling the switch in Settings back into the saved source entry + watch(this.sendCoverArtToDiscord, (enabled) => { + const saved = this.amethyst.state.settings.mediaSources.saveMediaSources.find((s) => s.type == MediaSourceType.Subsonic && s.url == this.url); + if (saved) saved.sendCoverArtToDiscord = enabled; + }); + this.initialize(); } diff --git a/src/renderer/logic/MediaSource/index.ts b/src/renderer/logic/MediaSource/index.ts index c7aee37f..c78396da 100644 --- a/src/renderer/logic/MediaSource/index.ts +++ b/src/renderer/logic/MediaSource/index.ts @@ -17,6 +17,7 @@ export class MediaSource { public totalTracks: Ref = ref(0); public totalBytes: Ref = ref(0); public name: string = "generic"; + public sendCoverArtToDiscord: Ref = ref(false); public constructor(protected amethyst: Amethyst, public path: string) { diff --git a/src/renderer/logic/settings.ts b/src/renderer/logic/settings.ts index ed6b8686..073521b9 100644 --- a/src/renderer/logic/settings.ts +++ b/src/renderer/logic/settings.ts @@ -1,6 +1,17 @@ +import type { DiscordRpcField } from "@shared/types.js"; + import { AmethystPlatforms } from "@/amethyst.js"; import type { MediaSourceType } from "@/logic/MediaSource/index.js"; +export const DEFAULT_DISCORD_CLIENT_ID = "976036303156162570"; + +export const DEFAULT_DISCORD_RPC_FIELDS: Record<"details" | "state" | "largeImageText" | "smallImageText", DiscordRpcField> = { + details: "Title", + state: "Artist - Album", + largeImageText: "Format", + smallImageText: "App Info", +}; + export const VALID_SAMPLE_RATES = [ 4000, 8000, @@ -161,6 +172,10 @@ const DEFAULT_INTEGRATION_SETTINGS = { integrations: { discord: { enabled: true, + clientId: DEFAULT_DISCORD_CLIENT_ID, + showCoverArt: true, + showFindSongButton: true, + fields: { ...DEFAULT_DISCORD_RPC_FIELDS }, }, lastFm: { enabled: false, @@ -174,7 +189,7 @@ const DEFAULT_INTEGRATION_SETTINGS = { const DEFAULT_MEDIA_SOURCE_SETTINGS = { mediaSources: { - saveMediaSources: [{}] as { type: MediaSourceType; path?: string; uuid?: string; url?: string; username?: string; password?: string; scrobble?: boolean }[], + saveMediaSources: [{}] as { type: MediaSourceType; path?: string; uuid?: string; url?: string; username?: string; password?: string; scrobble?: boolean; sendCoverArtToDiscord?: boolean }[], }, }; diff --git a/src/renderer/logic/track.ts b/src/renderer/logic/track.ts index f2d3e38a..75c14f03 100644 --- a/src/renderer/logic/track.ts +++ b/src/renderer/logic/track.ts @@ -281,16 +281,18 @@ export class Track { return this.cover.data; }; - public fetchAlbumCoverUrl = async () => { - if (this.coverUrl !== "") { - return; - } - + /** + * Looks up a public cover art URL for this track via MusicBrainz/the Cover Art Archive. + * Doesn't touch `coverUrl` - purely resolves and returns, so callers decide what to do with it. + */ + private async resolveMusicBrainzCoverUrl(): Promise { let muid = this.metadata.data?.common.musicbrainz_albumid ?? ""; if (!muid) { - let artist = this.metadata.data?.common.artist ?? ""; - let album = this.metadata.data?.common.album ?? this.metadata.data?.common.title ?? ""; + // Prefer the track's own fields (set directly by remote sources like Subsonic/Jellyfin, + // which never populate `metadata`) over raw tag metadata, falling back between the two. + let artist = this.getArtistsFormatted() ?? this.metadata.data?.common.artist ?? ""; + let album = this.getAlbum() ?? this.metadata.data?.common.album ?? this.getTitle() ?? this.metadata.data?.common.title ?? ""; // If missing artist or album, try parsing filename if (!artist || !album) { @@ -315,7 +317,7 @@ export class Track { query: queryString, }); - if (!result.count) return; + if (!result.count) return ""; const [topResult] = result.releases; muid = topResult.id; @@ -323,11 +325,37 @@ export class Track { try { const response = await fetch(`https://coverartarchive.org/release/${muid}/front-500`); - if (response.ok) this.coverUrl = response.url; + if (response.ok) return response.url; } catch {} + + return ""; + } + + public fetchAlbumCoverUrl = async () => { + if (this.coverUrl !== "") return; + this.coverUrl = await this.resolveMusicBrainzCoverUrl(); }; + private musicBrainzCoverUrl: string = ""; + private musicBrainzCoverUrlPromise?: Promise; + + /** + * Always resolves a MusicBrainz cover art URL for this track, regardless of whether + * `coverUrl` is already set from elsewhere (e.g. a Subsonic/Jellyfin server's own art). + * Used as a public fallback when a track's own source-provided art shouldn't be used. + */ + public fetchMusicBrainzCoverUrl(): Promise { + if (this.musicBrainzCoverUrl) return Promise.resolve(this.musicBrainzCoverUrl); + if (!this.musicBrainzCoverUrlPromise) { + this.musicBrainzCoverUrlPromise = this.resolveMusicBrainzCoverUrl().then((url) => { + this.musicBrainzCoverUrl = url; + return url; + }); + } + return this.musicBrainzCoverUrlPromise; + } + /** * Fetches all async data concurrently */ diff --git a/src/renderer/views/Settings/IntegrationSettings.vue b/src/renderer/views/Settings/IntegrationSettings.vue index bccbf699..5516edae 100644 --- a/src/renderer/views/Settings/IntegrationSettings.vue +++ b/src/renderer/views/Settings/IntegrationSettings.vue @@ -1,9 +1,17 @@