mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +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.
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 { buildCoverArtUrlForServer } from '../../api/subsonicStreamUrl';
|
||||||
import type { CoverArtRef } from '../types';
|
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 large image — an https:// URL Discord's own servers can reach.
|
||||||
* 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
|
* Unlike every other cover fetch we must NOT use the connect URL: that prefers
|
||||||
* to the on-disk webp cache (what MPRIS uses) is meaningless to Discord and
|
* the LAN address (fast for the app itself), but Discord fetches the image
|
||||||
* silently falls back to the app icon — so we hand it the getCoverArt URL.
|
* 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> {
|
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