From 5f15784b7de63334a2250b0c6fffc1a1b47824d1 Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:08:57 +0200 Subject: [PATCH] fix(discord): use the public server address for Rich Presence cover art MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Discord cover URL was built via the connect endpoint, which prefers the LAN address — but Discord fetches the image from its own servers, so a LAN address is unreachable and the cover falls back to the app icon. This is a dual-address regression: before a second (public) address could be added, the only configured URL was the public one. Build the Discord large-image URL via serverShareBaseUrl (public preferred, like share links / Orbit invites) instead of the connect URL. Adds a test. --- src/cover/integrations/discord.test.ts | 48 ++++++++++++++++++++++ src/cover/integrations/discord.ts | 57 ++++++++++++++++++++++---- 2 files changed, 97 insertions(+), 8 deletions(-) create mode 100644 src/cover/integrations/discord.test.ts diff --git a/src/cover/integrations/discord.test.ts b/src/cover/integrations/discord.test.ts new file mode 100644 index 00000000..0a70e9e3 --- /dev/null +++ b/src/cover/integrations/discord.test.ts @@ -0,0 +1,48 @@ +/** + * coverArtUrlForDiscord — Discord fetches the large image from its own servers, + * so the URL must use the public address, not the LAN-preferred connect URL + * (regression from the dual-address feature). + */ +import { beforeEach, describe, expect, it } from 'vitest'; +import { resetAllStores } from '@/test/helpers/storeReset'; +import { makeServer } from '@/test/helpers/factories'; +import { useAuthStore } from '@/store/authStore'; +import { coverArtUrlForDiscord } from './discord'; +import type { CoverArtRef } from '../types'; + +function refForServer(serverId: string, url: string): CoverArtRef { + return { + cacheKind: 'album', + cacheEntityId: 'al-1', + fetchCoverArtId: 'al-1', + serverScope: { kind: 'server', serverId, url, username: 'tester', password: 'pw' }, + }; +} + +beforeEach(() => { + resetAllStores(); +}); + +describe('coverArtUrlForDiscord', () => { + it('uses the public address on a dual-address profile, not the LAN one', async () => { + const server = makeServer({ + url: 'http://192.168.1.50:4533', + alternateUrl: 'https://music.example.com', + }); + useAuthStore.setState({ servers: [server], activeServerId: server.id } as never); + + const url = await coverArtUrlForDiscord(refForServer(server.id, server.url)); + + expect(url).toContain('music.example.com'); + expect(url).not.toContain('192.168.1.50'); + }); + + it('returns the single configured address when there is no alternate', async () => { + const server = makeServer({ url: 'https://music.example.com', alternateUrl: undefined }); + useAuthStore.setState({ servers: [server], activeServerId: server.id } as never); + + const url = await coverArtUrlForDiscord(refForServer(server.id, server.url)); + + expect(url).toContain('music.example.com'); + }); +}); diff --git a/src/cover/integrations/discord.ts b/src/cover/integrations/discord.ts index 52ef7d59..909a86c1 100644 --- a/src/cover/integrations/discord.ts +++ b/src/cover/integrations/discord.ts @@ -1,13 +1,54 @@ -import { buildCoverArtFetchUrl } from '../fetchUrl'; -import type { CoverArtRef } from '../types'; +import { buildCoverArtUrlForServer } from '../../api/subsonicStreamUrl'; +import { serverShareBaseUrl } from '../../utils/server/serverEndpoint'; +import { getPlaybackServerId } from '../../utils/playback/playbackServer'; +import { useAuthStore } from '../../store/authStore'; +import type { CoverArtRef, CoverServerScope } from '../types'; + +/** The saved profile id that a cover scope resolves to (active/playback/server). */ +function serverIdForScope(scope: CoverServerScope): string | null { + if (scope.kind === 'server') return scope.serverId; + if (scope.kind === 'playback') { + return getPlaybackServerId() ?? useAuthStore.getState().activeServerId ?? null; + } + return useAuthStore.getState().activeServerId ?? null; +} /** - * Discord large image — always the HTTPS fetch URL, never a local cache path. - * Discord Rich Presence images are fetched by Discord's own servers, so the - * large_image must be a key or an https:// URL they can reach. A `file://` path - * to the on-disk webp cache (what MPRIS uses) is meaningless to Discord and - * silently falls back to the app icon — so we hand it the getCoverArt URL. + * Discord large image — an https:// URL Discord's own servers can reach. + * + * Unlike every other cover fetch we must NOT use the connect URL: that prefers + * the LAN address (fast for the app itself), but Discord fetches the image + * remotely, so a `http://192.168.x.x` address is unreachable and falls back to + * the app icon. Discord is an external consumer just like a share link, so use + * `serverShareBaseUrl` (public address preferred when both are set). */ export async function coverArtUrlForDiscord(ref: CoverArtRef): Promise { - return buildCoverArtFetchUrl(ref, 800) || null; + const { serverScope, fetchCoverArtId } = ref; + const serverId = serverIdForScope(serverScope); + const profile = serverId + ? useAuthStore.getState().servers.find(s => s.id === serverId) + : undefined; + + if (profile) { + return buildCoverArtUrlForServer( + serverShareBaseUrl(profile), + profile.username, + profile.password, + fetchCoverArtId, + 800, + ) || null; + } + + // Server scope carries its own URL/creds even when not a saved profile. + if (serverScope.kind === 'server') { + return buildCoverArtUrlForServer( + serverShareBaseUrl({ url: serverScope.url }), + serverScope.username, + serverScope.password, + fetchCoverArtId, + 800, + ) || null; + } + + return null; }