mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
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.
(cherry picked from commit 5f15784b7d)
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user