fix(discord): use the public server address for Rich Presence cover art

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.
This commit is contained in:
Psychotoxical
2026-06-16 16:08:57 +02:00
parent 8bfde08199
commit 5f15784b7d
2 changed files with 97 additions and 8 deletions
+48
View File
@@ -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');
});
});
+49 -8
View File
@@ -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<string | null> {
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;
}