feat(discord): restore server cover source via public album-info URLs (#1299)

* feat(discord): allow the server cover source in the store again

DiscordCoverSource gains 'server' back. The rehydrate migration that
forced any persisted 'server' preference to 'none' is dropped — that
coercion only made sense while the source didn't exist.

* feat(discord): resolve server covers via credential-free album-info URLs

Add a resolver that fetches cover art for the Discord 'server' source
through the standard Subsonic getAlbumInfo2 endpoint, never through an
authenticated getCoverArt URL (that was the leak fixed in #1246).

- sanitizeDiscordCoverUrl rejects anything unfit to publish: non-https,
  embedded userinfo, auth-shaped query params (u/t/s/apiKey/jwt/token/...),
  LAN/loopback hosts
- resolveServerCoverForDiscord takes only an album id and a share-base
  string, never a server profile or credentials, and session-caches
  results (including negative ones) per album
- rewrites a LAN-scoped album-info response to the profile's public
  share address, keeping path + query, so the app being connected over
  LAN doesn't hide an otherwise-public /share/img/<jwt> link
- discordPresence.ts wires the 'server' branch in with a staleness
  guard so a slow resolve can't overwrite a newer track's presence

* feat(discord): add the server option back to the cover-source picker

Three-way segmented control again: none / server / apple. The cover
description now discloses that server covers reveal the server's
public address to anyone viewing the presence (no credentials).

Restores discordCoverServer in all 14 locales — the Italian locale
already carried an unused copy of the key from before it existed at
removal time, now wired up instead of left dead.

* fix(discord): reject non-publishable image URLs before they reach Discord

is_publishable_image_url is a backstop applied to every artwork_url
(cover_art_url param and the iTunes result alike) right before it can
become a large_image asset: https only, no userinfo, no auth-shaped
query params. This is the layer a future frontend refactor can't
silently bypass — the failure mode that turned the original
credential-free server-cover design (#462) into the leak fixed in
#1246 was exactly that kind of regression, on the frontend only.

* docs(changelog): note Discord server cover source restoration (#1299)

* fix(discord): close the LAN-host gap in the Rust publish backstop

is_publishable_image_url checked scheme/userinfo/credential params but
not host locality, despite its doc comment claiming parity with the TS
sanitizer's isLanUrl check — a self-review found the gap. Reuses
psysonic-core::log_sanitize's existing is_lan_host (now pub) instead
of a second hand-written LAN-detection copy.

* fix(discord): don't silently resurrect a pre-#1246 'server' preference

The rehydrate migration that forced any persisted 'server' cover
source to 'none' was dropped in the initial revival commit, which
meant a user skipping straight from a pre-#1246 build to this one
would have gotten it silently reactivated without ever seeing the new
opt-in disclosure. Restored as a one-time, sentinel-gated migration:
it still catches that stale value exactly once, but never coerces a
deliberate post-revival choice back.

* fix(discord): harden the server-cover sync against races and cross-server tracks

- Staleness guard on the async resolve now also rechecks
  discordRichPresence/discordCoverSource, not just the track id, so a
  slow resolve can't revive presence after it was disabled or the
  cover source was switched away from 'server' while in flight.
- Skip server-cover resolution (fall back to the app icon) when the
  playing track isn't from the active server — getAlbumInfo2 always
  queries the active server, so a mixed-server queue could otherwise
  ask the wrong server for an album id.
- The change-detection gate now also reacts to the active server
  profile's share-base changing (e.g. adding a public address), not
  only to track/play-state/cover-source/template changes.
- Server profile/shareBase is now computed once per sync and reused,
  instead of a second useAuthStore.getState() call inside the branch.

* fix(discord): preserve reverse-proxy paths and TTL the server-cover cache

- rewriteOriginToShareBase only swapped protocol/hostname/port, so a
  server reachable behind a reverse-proxy subpath (shareBase carrying
  a path prefix) lost that prefix and produced a 404ing URL. The
  prefix is now preserved.
- serverCoverCache entries (including negative ones) no longer live
  forever — a 1h TTL, matching the existing Rust iTunes-artwork cache,
  means a transient resolve failure doesn't hide an album's cover for
  the rest of the session.
This commit is contained in:
Psychotoxical
2026-07-14 13:52:05 +02:00
committed by GitHub
parent 0331ac173d
commit a451509d94
25 changed files with 507 additions and 34 deletions
+6
View File
@@ -113,6 +113,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Browse and queue track rows can show the track's **album** cover (per-disc art when the album has distinct disc covers). Covers load through the standard cover cache pipeline — library resolve, viewport ensure, Rust resize to disk tiers — not a separate warm path.
* **Settings → Appearance** adds separate toggles for queue vs browse tracklists. Favorites, playlist, and album-detail track grids gain a flex-resize handle on the title column when covers are shown.
### Discord — server cover art source, without the credential leak
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1299](https://github.com/Psychotoxical/psysonic/pull/1299)**
* **Settings → Integrations → Discord → Cover art source** gets a **Server** option back, alongside None and Apple Music. It resolves artwork through the standard Subsonic `getAlbumInfo2` endpoint's public image link — never the authenticated cover URL that leaked login credentials in #1246. Needs a publicly reachable server; anyone viewing your Discord profile can see that server's public address, but nothing else.
## Changed
@@ -293,7 +293,10 @@ fn is_lan_ipv6(host: &str) -> bool {
false
}
fn is_lan_host(host: &str) -> bool {
/// Public: reused by other crates (e.g. `psysonic-integration`'s Discord
/// publish gate) wherever "is this host LAN/loopback, not safe to expose
/// externally" needs the same answer this log-redaction module already uses.
pub fn is_lan_host(host: &str) -> bool {
let stripped = host.trim().trim_matches(|c| c == '[' || c == ']');
let lower = stripped.to_ascii_lowercase();
if lower.is_empty() || lower == "localhost" || lower.ends_with(".local") {
@@ -19,6 +19,42 @@ use std::time::Instant;
const DISCORD_APP_ID: &str = "1489544859718258779";
/// Query-param keys that carry a replayable auth secret. Checked
/// case-insensitively; Subsonic's own keys (`u`/`t`/`s`) are lower-case but
/// the defensive variants guard against other backends / auth schemes.
const CREDENTIAL_PARAM_KEYS: &[&str] = &["u", "t", "s", "p", "apikey", "jwt", "token", "auth"];
/// Backstop gate: true when `url` is safe to publish to Discord as a
/// `large_image`. Discord's external image proxy re-exposes the source URL
/// to anyone viewing the presence, so this must reject anything credentialed
/// or LAN-scoped before it ever reaches `Assets::large_image` — regardless of
/// which frontend code path produced the URL (mirrors the sanitizer in
/// `src/cover/integrations/discord.ts`, but this is the layer a frontend
/// regression cannot bypass). The LAN/loopback check reuses
/// `psysonic_core::log_sanitize::is_lan_host`, the same host classification
/// already relied on for local-log redaction, rather than a second
/// hand-written copy.
fn is_publishable_image_url(url: &str) -> bool {
let Ok(parsed) = url::Url::parse(url) else {
return false;
};
if parsed.scheme() != "https" {
return false;
}
if !parsed.username().is_empty() || parsed.password().is_some() {
return false;
}
if psysonic_core::log_sanitize::is_lan_host(parsed.host_str().unwrap_or("")) {
return false;
}
for (key, _) in parsed.query_pairs() {
if CREDENTIAL_PARAM_KEYS.contains(&key.to_lowercase().as_str()) {
return false;
}
}
true
}
/// Cache entry for iTunes artwork lookup (avoids repeated API calls for same album).
pub struct ArtworkCacheEntry {
pub url: String,
@@ -368,6 +404,17 @@ pub async fn discord_update_presence(
None
};
// Backstop: reject any URL that isn't safe to publish, no matter which
// path above produced it. Falls back to the app icon on rejection.
let artwork_url = artwork_url.filter(|url| {
let ok = is_publishable_image_url(url);
if !ok {
#[cfg(debug_assertions)]
crate::app_eprintln!("[discord] rejected non-publishable artwork_url");
}
ok
});
let mut guard = state.client.lock().unwrap();
// (Re)connect lazily — handles the case where Discord starts after the app.
@@ -610,6 +657,68 @@ mod tests {
assert_eq!(f.details, "Queen Bohemian Rhapsody");
}
// ── is_publishable_image_url ─────────────────────────────────────────────
#[test]
fn publishable_url_accepts_public_share_image_link() {
assert!(is_publishable_image_url(
"https://music.example.com/share/img/eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjEifQ.abc?size=600"
));
}
#[test]
fn publishable_url_accepts_itunes_artwork_link() {
assert!(is_publishable_image_url(
"https://is1-ssl.mzstatic.com/image/thumb/Music/600x600bb.jpg"
));
}
#[test]
fn publishable_url_rejects_credentialed_subsonic_cover_url() {
assert!(!is_publishable_image_url(
"https://music.example.com/rest/getCoverArt.view?id=al-1&u=alice&t=deadbeef&s=abc123"
));
}
#[test]
fn publishable_url_rejects_credentialed_url_regardless_of_key_case() {
assert!(!is_publishable_image_url(
"https://music.example.com/rest/getCoverArt.view?id=al-1&U=alice&T=deadbeef&S=abc123"
));
}
#[test]
fn publishable_url_rejects_non_https_scheme() {
assert!(!is_publishable_image_url(
"http://music.example.com/share/img/eyJhbGciOiJIUzI1NiJ9.abc"
));
}
#[test]
fn publishable_url_rejects_embedded_userinfo() {
assert!(!is_publishable_image_url(
"https://alice:secret@music.example.com/share/img/eyJhbGciOiJIUzI1NiJ9.abc"
));
}
#[test]
fn publishable_url_rejects_malformed_url() {
assert!(!is_publishable_image_url("not a url"));
}
#[test]
fn publishable_url_rejects_lan_host() {
assert!(!is_publishable_image_url(
"https://192.168.1.5/share/img/eyJhbGciOiJIUzI1NiJ9.abc"
));
}
#[test]
fn publishable_url_rejects_loopback_and_local_hosts() {
assert!(!is_publishable_image_url("https://localhost/share/img/abc"));
assert!(!is_publishable_image_url("https://music.local/share/img/abc"));
}
// ── compute_discord_start_timestamp ──────────────────────────────────────
#[test]
+147
View File
@@ -0,0 +1,147 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('@/lib/api/subsonicAlbumInfo', () => ({
getAlbumInfo2: vi.fn(),
}));
import { getAlbumInfo2 } from '@/lib/api/subsonicAlbumInfo';
import { sanitizeDiscordCoverUrl, resolveServerCoverForDiscord } from './discord';
const mockedGetAlbumInfo2 = vi.mocked(getAlbumInfo2);
describe('sanitizeDiscordCoverUrl', () => {
it('accepts a plain public https url', () => {
expect(sanitizeDiscordCoverUrl('https://music.example.com/share/img/eyJhbGciOiJIUzI1NiJ9.abc?size=1200'))
.toBe('https://music.example.com/share/img/eyJhbGciOiJIUzI1NiJ9.abc?size=1200');
});
it('rejects null / undefined / empty input', () => {
expect(sanitizeDiscordCoverUrl(null)).toBeNull();
expect(sanitizeDiscordCoverUrl(undefined)).toBeNull();
expect(sanitizeDiscordCoverUrl('')).toBeNull();
});
it('rejects malformed URLs', () => {
expect(sanitizeDiscordCoverUrl('not a url')).toBeNull();
});
it('rejects non-https schemes', () => {
expect(sanitizeDiscordCoverUrl('http://music.example.com/share/img/abc')).toBeNull();
});
it('rejects URLs carrying embedded userinfo', () => {
expect(sanitizeDiscordCoverUrl('https://alice:secret@music.example.com/share/img/abc')).toBeNull();
});
it('rejects a credentialed Subsonic getCoverArt URL (the original leak, PR #1246)', () => {
expect(
sanitizeDiscordCoverUrl(
'https://music.example.com/rest/getCoverArt.view?id=al-1&u=alice&t=deadbeef&s=abc123',
),
).toBeNull();
});
it('rejects credentialed query params regardless of key case', () => {
expect(
sanitizeDiscordCoverUrl('https://music.example.com/rest/getCoverArt.view?id=al-1&U=alice&T=deadbeef&S=abc123'),
).toBeNull();
});
it('rejects an apiKey-style credential param', () => {
expect(sanitizeDiscordCoverUrl('https://music.example.com/img/al-1?apiKey=secret')).toBeNull();
});
it('rejects LAN / loopback hosts', () => {
expect(sanitizeDiscordCoverUrl('https://192.168.1.5/share/img/abc')).toBeNull();
expect(sanitizeDiscordCoverUrl('https://localhost/share/img/abc')).toBeNull();
});
});
describe('resolveServerCoverForDiscord', () => {
beforeEach(() => {
mockedGetAlbumInfo2.mockReset();
});
it('prefers largeImageUrl, falling back to medium then small', async () => {
mockedGetAlbumInfo2.mockResolvedValueOnce({
largeImageUrl: 'https://music.example.com/img/large.jpg',
mediumImageUrl: 'https://music.example.com/img/medium.jpg',
});
expect(await resolveServerCoverForDiscord('al-large', null)).toBe('https://music.example.com/img/large.jpg');
mockedGetAlbumInfo2.mockResolvedValueOnce({
mediumImageUrl: 'https://music.example.com/img/medium.jpg',
});
expect(await resolveServerCoverForDiscord('al-medium', null)).toBe('https://music.example.com/img/medium.jpg');
mockedGetAlbumInfo2.mockResolvedValueOnce({
smallImageUrl: 'https://music.example.com/img/small.jpg',
});
expect(await resolveServerCoverForDiscord('al-small', null)).toBe('https://music.example.com/img/small.jpg');
});
it('returns null and caches the negative result when getAlbumInfo2 has no images', async () => {
mockedGetAlbumInfo2.mockResolvedValue(null);
expect(await resolveServerCoverForDiscord('al-none', null)).toBeNull();
expect(await resolveServerCoverForDiscord('al-none', null)).toBeNull();
// Second call for the same albumId must hit the cache, not the API again.
expect(mockedGetAlbumInfo2).toHaveBeenCalledTimes(1);
});
it('caches a successful resolution — subsequent calls skip the API', async () => {
mockedGetAlbumInfo2.mockResolvedValue({ largeImageUrl: 'https://music.example.com/img/cached.jpg' });
await resolveServerCoverForDiscord('al-cache', null);
await resolveServerCoverForDiscord('al-cache', null);
expect(mockedGetAlbumInfo2).toHaveBeenCalledTimes(1);
});
it('rewrites a LAN-scoped response origin to the public share base, keeping path + query', async () => {
mockedGetAlbumInfo2.mockResolvedValueOnce({
largeImageUrl: 'https://192.168.1.5:4533/share/img/eyJhbGciOiJIUzI1NiJ9.abc?size=1200',
});
const result = await resolveServerCoverForDiscord('al-lan', 'https://music.example.com');
expect(result).toBe('https://music.example.com/share/img/eyJhbGciOiJIUzI1NiJ9.abc?size=1200');
});
it('never returns a URL carrying credentials, even if the server response had one', async () => {
mockedGetAlbumInfo2.mockResolvedValueOnce({
largeImageUrl: 'https://music.example.com/rest/getCoverArt.view?id=al-1&u=alice&t=deadbeef&s=abc123',
});
expect(await resolveServerCoverForDiscord('al-credentialed', null)).toBeNull();
});
it('returns null without calling the API when there is no shareBase and the response is empty', async () => {
mockedGetAlbumInfo2.mockResolvedValueOnce({});
expect(await resolveServerCoverForDiscord('al-empty', null)).toBeNull();
});
it('preserves a reverse-proxy path prefix from shareBase when rewriting origin', async () => {
mockedGetAlbumInfo2.mockResolvedValueOnce({
largeImageUrl: 'https://192.168.1.5:4533/share/img/eyJhbGciOiJIUzI1NiJ9.abc?size=1200',
});
const result = await resolveServerCoverForDiscord('al-proxy', 'https://music.example.com/nav');
expect(result).toBe('https://music.example.com/nav/share/img/eyJhbGciOiJIUzI1NiJ9.abc?size=1200');
});
it('re-fetches after the cache TTL expires, including for a cached negative result', async () => {
vi.useFakeTimers();
try {
mockedGetAlbumInfo2.mockResolvedValueOnce(null);
expect(await resolveServerCoverForDiscord('al-ttl', null)).toBeNull();
expect(mockedGetAlbumInfo2).toHaveBeenCalledTimes(1);
// Still within the TTL window — cache hit, no second call.
await vi.advanceTimersByTimeAsync(59 * 60 * 1000);
expect(await resolveServerCoverForDiscord('al-ttl', null)).toBeNull();
expect(mockedGetAlbumInfo2).toHaveBeenCalledTimes(1);
// Past the TTL — must re-fetch instead of trusting the stale negative result.
await vi.advanceTimersByTimeAsync(2 * 60 * 1000);
mockedGetAlbumInfo2.mockResolvedValueOnce({ largeImageUrl: 'https://music.example.com/img/fresh.jpg' });
expect(await resolveServerCoverForDiscord('al-ttl', null)).toBe('https://music.example.com/img/fresh.jpg');
expect(mockedGetAlbumInfo2).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
});
+110
View File
@@ -0,0 +1,110 @@
import { getAlbumInfo2 } from '@/lib/api/subsonicAlbumInfo';
import { isLanUrl } from '@/lib/server/serverEndpoint';
/**
* Query-param keys that carry a replayable Subsonic (or generic API) secret.
* Any URL carrying one of these must never be published to Discord — its
* external image proxy exposes the full source URL to anyone viewing the
* presence. Checked case-insensitively; Subsonic's own keys (`u`/`t`/`s`) are
* lower-case but the defensive variants guard against other backends.
*/
const CREDENTIAL_PARAM_KEYS = new Set(['u', 't', 's', 'p', 'apikey', 'jwt', 'token', 'auth']);
/**
* Gate every URL before it may become a Discord `large_image`. Discord's
* external image proxy re-publishes the source URL to anyone who can view
* the presence, so this rejects anything that isn't safe to publish:
* https only, no embedded userinfo, no auth-shaped query params, no
* LAN/loopback host (dead weight for Discord, reveals network topology).
*/
export function sanitizeDiscordCoverUrl(raw: string | null | undefined): string | null {
if (!raw) return null;
let url: URL;
try {
url = new URL(raw);
} catch {
return null;
}
if (url.protocol !== 'https:') return null;
if (url.username || url.password) return null;
for (const key of url.searchParams.keys()) {
if (CREDENTIAL_PARAM_KEYS.has(key.toLowerCase())) return null;
}
if (isLanUrl(url.origin)) return null;
return url.toString();
}
/**
* Swap `raw`'s origin for `shareBase`'s, keeping query untouched and
* preserving both URLs' paths. Navidrome's `getAlbumInfo2` derives the image
* host from the request that reached it — when the app is connected over the
* LAN address, the returned URL is LAN-scoped even though the server also
* has a public address configured. The `/share/img/<jwt>` path itself is
* host-independent, so pointing it at the profile's public share address
* makes it reachable for Discord — but `shareBase` may itself carry a path
* prefix (a server reachable behind a reverse proxy at e.g.
* `https://host/nav`), which must be kept, not dropped, or the rewritten URL
* 404s against the actual public endpoint.
*/
function rewriteOriginToShareBase(raw: string, shareBase: string): string {
try {
const url = new URL(raw);
const share = new URL(shareBase);
if (url.origin === share.origin) return raw;
url.protocol = share.protocol;
url.hostname = share.hostname;
url.port = share.port;
const sharePrefix = share.pathname.replace(/\/$/, '');
if (sharePrefix && !url.pathname.startsWith(sharePrefix)) {
url.pathname = `${sharePrefix}${url.pathname}`;
}
return url.toString();
} catch {
return raw;
}
}
interface ServerCoverCacheEntry {
url: string | null;
fetchedAt: number;
}
/**
* Session cache: `"<shareBase>|<albumId>"` -> resolved (already sanitized)
* cover URL, or `null` for a miss/failure. Negative results are cached too —
* at most one `getAlbumInfo2` call per album per server per TTL window.
* TTL (not "forever") so a transient failure (server briefly unreachable,
* timeout) doesn't hide an album's cover for the rest of the session —
* mirrors the Rust-side iTunes artwork cache TTL for the same reason.
*/
const SERVER_COVER_CACHE_TTL_MS = 60 * 60 * 1000;
const serverCoverCache = new Map<string, ServerCoverCacheEntry>();
/**
* Resolve a credential-free Discord cover URL for `albumId` via the
* standard Subsonic `getAlbumInfo2` endpoint. Deliberately takes only an
* album id and a share-base string — never a server profile/credentials —
* so this resolver has no way to construct an authenticated URL, unlike the
* removed `coverArtUrlForDiscord` that leaked `u`/`t`/`s` (see PR #1246).
*/
export async function resolveServerCoverForDiscord(
albumId: string,
shareBase: string | null,
): Promise<string | null> {
const cacheKey = `${shareBase ?? ''}|${albumId}`;
const cached = serverCoverCache.get(cacheKey);
if (cached && Date.now() - cached.fetchedAt < SERVER_COVER_CACHE_TTL_MS) {
return cached.url;
}
let result: string | null = null;
const info = await getAlbumInfo2(albumId);
const raw = info?.largeImageUrl || info?.mediumImageUrl || info?.smallImageUrl;
if (raw) {
const rewritten = shareBase ? rewriteOriginToShareBase(raw, shareBase) : raw;
result = sanitizeDiscordCoverUrl(rewritten);
}
serverCoverCache.set(cacheKey, { url: result, fetchedAt: Date.now() });
return result;
}
@@ -3,6 +3,9 @@ import { commands } from '@/generated/bindings';
import { useAuthStore } from '@/store/authStore';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { getPlaybackProgressSnapshot } from '@/features/playback/store/playbackProgress';
import { resolveServerCoverForDiscord } from '@/cover/integrations/discord';
import { serverShareBaseUrl } from '@/lib/server/serverEndpoint';
import { playbackServerDiffersFromActive } from '@/features/playback/utils/playback/playbackServer';
/**
* Discord Rich Presence sync. Updates on track change or play/pause toggle —
@@ -17,6 +20,7 @@ export function setupDiscordPresence(): () => void {
let discordPrevTemplateLargeText: string | null = null;
let discordPrevTemplateName: string | null = null;
let discordPrevCoverSource: string | null = null;
let discordPrevShareBase: string | null = null;
function syncDiscord() {
const { currentTrack, isPlaying } = usePlayerStore.getState();
@@ -28,6 +32,8 @@ export function setupDiscordPresence(): () => void {
discordTemplateState,
discordTemplateLargeText,
discordTemplateName,
servers,
activeServerId,
} = useAuthStore.getState();
if (!discordRichPresence || !currentTrack) {
@@ -35,6 +41,7 @@ export function setupDiscordPresence(): () => void {
discordPrevTrackId = null;
discordPrevIsPlaying = null;
discordPrevCoverSource = null;
discordPrevShareBase = null;
discordPrevTemplateDetails = null;
discordPrevTemplateState = null;
discordPrevTemplateLargeText = null;
@@ -44,18 +51,28 @@ export function setupDiscordPresence(): () => void {
return;
}
// Computed unconditionally (cheap: one array find + a URL normalize) so a
// profile edit (fixing a LAN-only address to a public one, say) is caught
// by shareBaseChanged below even when track/play-state/cover-source/
// templates are all unchanged — the 'server' branch further down needs
// this value regardless, so there is no second `getState()` read for it.
const profile = servers.find(s => s.id === activeServerId);
const shareBase = profile ? serverShareBaseUrl(profile) : null;
const trackChanged = currentTrack.id !== discordPrevTrackId;
const playingChanged = isPlaying !== discordPrevIsPlaying;
const coverSourceChanged = discordCoverSource !== discordPrevCoverSource;
const shareBaseChanged = discordCoverSource === 'server' && shareBase !== discordPrevShareBase;
const detailsTemplateChanged = discordTemplateDetails !== discordPrevTemplateDetails;
const stateTemplateChanged = discordTemplateState !== discordPrevTemplateState;
const largeTextTemplateChanged = discordTemplateLargeText !== discordPrevTemplateLargeText;
const nameTemplateChanged = discordTemplateName !== discordPrevTemplateName;
if (!trackChanged && !playingChanged && !coverSourceChanged && !detailsTemplateChanged && !stateTemplateChanged && !largeTextTemplateChanged && !nameTemplateChanged) return;
if (!trackChanged && !playingChanged && !coverSourceChanged && !shareBaseChanged && !detailsTemplateChanged && !stateTemplateChanged && !largeTextTemplateChanged && !nameTemplateChanged) return;
discordPrevTrackId = currentTrack.id;
discordPrevIsPlaying = isPlaying;
discordPrevCoverSource = discordCoverSource;
discordPrevShareBase = shareBase;
discordPrevTemplateDetails = discordTemplateDetails;
discordPrevTemplateState = discordTemplateState;
discordPrevTemplateLargeText = discordTemplateLargeText;
@@ -77,12 +94,33 @@ export function setupDiscordPresence(): () => void {
}).catch(() => {});
};
// Cover art is resolved Rust-side: 'apple' triggers the iTunes lookup via
// the fetchItunesCovers flag above; 'none' shows just the app icon. The
// frontend never builds a cover URL for Discord — the removed 'server'
// source leaked the authenticated Subsonic getCoverArt URL (u/t/s) through
// Discord's public external image proxy.
sendPresence(null);
// 'apple' is resolved Rust-side via the fetchItunesCovers flag above.
// 'none' shows just the app icon. 'server' resolves here via the
// credential-blind getAlbumInfo2 resolver (cover/integrations/discord.ts)
// — it never sees server auth, unlike the removed builder that leaked the
// authenticated Subsonic getCoverArt URL (u/t/s) through Discord's public
// external image proxy (PR #1246). The Rust command re-validates whatever
// URL arrives here before it ever reaches Discord (defense in depth).
//
// getAlbumInfo2 always queries the *active* server (subsonicClient's api()
// has no per-call server override), so a mixed-server queue whose playing
// track isn't from the active server would otherwise ask the wrong server
// for that album id. Skip the server lookup — and fall back to the app
// icon — for that case rather than risk a wrong or 404ing cover.
if (discordCoverSource === 'server' && currentTrack.albumId && !playbackServerDiffersFromActive()) {
const trackId = currentTrack.id;
void resolveServerCoverForDiscord(currentTrack.albumId, shareBase).then(url => {
// Staleness guard: the resolve is async — drop it if playback moved on,
// Rich Presence got disabled, or the cover source changed away from
// 'server' while the request was in flight.
const latest = useAuthStore.getState();
if (usePlayerStore.getState().currentTrack?.id !== trackId) return;
if (!latest.discordRichPresence || latest.discordCoverSource !== 'server') return;
sendPresence(url);
});
} else {
sendPresence(null);
}
}
const unsubDiscordPlayer = usePlayerStore.subscribe(syncDiscord);
@@ -26,6 +26,7 @@ export function IntegrationsTab() {
];
const discordCoverOptions: SegmentedOption<DiscordCoverSource>[] = [
{ id: 'none', label: t('settings.discordCoverNone') },
{ id: 'server', label: t('settings.discordCoverServer') },
{ id: 'apple', label: t('settings.discordCoverApple') },
];
const backdropSourceLabel = (s: BackdropSource): string =>
+2 -1
View File
@@ -295,8 +295,9 @@ export const settings = {
linuxWaylandTextRenderGpu: 'Приоритет на GPU',
linuxWaylandTextRenderMinimal: 'Минимално (изглаждане по подразбиране от CSS)',
discordCoverTitle: 'Източник на корица',
discordCoverDesc: 'Откъде идва обложката на албума в профила ти в Discord.',
discordCoverDesc: 'Откъде идва обложката на албума в профила ти в Discord. Обложките от сървъра използват публична връзка към изображение без данни за достъп — всеки, който вижда профила ти, може да разбере публичния адрес на сървъра ти. Изисква публично достъпен сървър.',
discordCoverNone: 'Няма (само икона на приложението)',
discordCoverServer: 'Сървър (чрез информация за албума)',
discordCoverApple: 'Apple Music',
discordOptions: 'Разширени настройки за Discord',
discordTemplates: 'Персонализирани текстови шаблони',
+2 -1
View File
@@ -292,8 +292,9 @@ export const settings = {
linuxWaylandTextRenderGpu: 'GPU zuerst',
linuxWaylandTextRenderMinimal: 'Minimal (Standard-CSS-Glättung)',
discordCoverTitle: 'Cover-Quelle',
discordCoverDesc: 'Woher das Cover auf deinem Discord-Profil stammt.',
discordCoverDesc: 'Woher das Cover auf deinem Discord-Profil stammt. Server-Cover nutzen einen öffentlichen, credential-freien Bildlink wer dein Profil sieht, kann die öffentliche Adresse deines Servers erkennen. Erfordert einen öffentlich erreichbaren Server.',
discordCoverNone: 'Keine (nur App-Symbol)',
discordCoverServer: 'Server (über Album-Info)',
discordCoverApple: 'Apple Music',
discordOptions: 'Erweiterte Discord-Optionen',
discordTemplates: 'Benutzerdefinierte Textvorlagen',
+2 -1
View File
@@ -295,8 +295,9 @@ export const settings = {
linuxWaylandTextRenderGpu: 'GPU-first',
linuxWaylandTextRenderMinimal: 'Minimal (default CSS smoothing)',
discordCoverTitle: 'Cover art source',
discordCoverDesc: 'Where the album artwork on your Discord profile comes from.',
discordCoverDesc: 'Where the album artwork on your Discord profile comes from. Server covers use a public, credential-free image link — anyone viewing your profile may see your server\'s public address. Needs a publicly reachable server.',
discordCoverNone: 'None (app icon only)',
discordCoverServer: 'Server (via album info)',
discordCoverApple: 'Apple Music',
discordOptions: 'Advanced Discord options',
discordTemplates: 'Custom text templates',
+2 -1
View File
@@ -291,8 +291,9 @@ export const settings = {
linuxWaylandTextRenderGpu: 'Prioridad GPU',
linuxWaylandTextRenderMinimal: 'Mínimo (suavizado CSS por defecto)',
discordCoverTitle: 'Fuente de la carátula',
discordCoverDesc: 'De dónde proviene la carátula mostrada en tu perfil de Discord.',
discordCoverDesc: 'De dónde proviene la carátula mostrada en tu perfil de Discord. Las carátulas del servidor usan un enlace de imagen público, sin credenciales — cualquiera que vea tu perfil puede ver la dirección pública de tu servidor. Requiere un servidor accesible públicamente.',
discordCoverNone: 'Ninguna (solo icono de la app)',
discordCoverServer: 'Servidor (vía info del álbum)',
discordCoverApple: 'Apple Music',
discordOptions: 'Opciones avanzadas de Discord',
discordTemplates: 'Plantillas de texto personalizadas',
+2 -1
View File
@@ -279,8 +279,9 @@ export const settings = {
discordRichPresenceDesc: 'Affiche le titre en cours de lecture sur votre profil Discord. Discord doit être ouvert.',
discordRichPresenceNotice: 'Attention : il s\'agit de la Discord Rich Presence intégrée à Psysonic. Si vous préférez utiliser le plugin officiel Discord Rich Presence de Navidrome, laissez cette fonction désactivée et activez plutôt « Afficher dans la fenêtre live » plus bas sur cette page.',
discordCoverTitle: 'Source de la pochette',
discordCoverDesc: 'D\'où provient la pochette affichée sur votre profil Discord.',
discordCoverDesc: 'D\'où provient la pochette affichée sur votre profil Discord. Les pochettes du serveur utilisent un lien d\'image public, sans identifiants — quiconque voit votre profil peut voir l\'adresse publique de votre serveur. Nécessite un serveur accessible publiquement.',
discordCoverNone: 'Aucune (icône de l\'app uniquement)',
discordCoverServer: 'Serveur (via infos album)',
discordCoverApple: 'Apple Music',
discordOptions: 'Options Discord avancées',
discordTemplates: 'Modèles de texte personnalisés',
+2 -1
View File
@@ -295,8 +295,9 @@ export const settings = {
linuxWaylandTextRenderGpu: 'GPU-elsődleges',
linuxWaylandTextRenderMinimal: 'Minimál (alapértelmezett CSS-simítás)',
discordCoverTitle: 'Borító forrása',
discordCoverDesc: 'Honnan származik az albumborító a Discord-profilodon.',
discordCoverDesc: 'Honnan származik az albumborító a Discord-profilodon. A szerveres borítók egy nyilvános, hitelesítő adatok nélküli képhivatkozást használnak — aki látja a profilodat, megtudhatja a szervered nyilvános címét. Nyilvánosan elérhető szervert igényel.',
discordCoverNone: 'Nincs (csak alkalmazásikon)',
discordCoverServer: 'Szerver (albuminfó alapján)',
discordCoverApple: 'Apple Music',
discordOptions: 'Speciális Discord-beállítások',
discordTemplates: 'Egyéni szövegsablonok',
+1 -1
View File
@@ -295,7 +295,7 @@ export const settings = {
linuxWaylandTextRenderGpu: 'Priorità GPU',
linuxWaylandTextRenderMinimal: 'Minimo (levigatura CSS predefinita)',
discordCoverTitle: 'Fonte della copertina',
discordCoverDesc: 'Da dove proviene la copertina dell\'album mostrata sul tuo profilo Discord.',
discordCoverDesc: 'Da dove proviene la copertina dell\'album mostrata sul tuo profilo Discord. Le copertine del server usano un link immagine pubblico e privo di credenziali — chi visualizza il tuo profilo può vedere l\'indirizzo pubblico del tuo server. Richiede un server raggiungibile pubblicamente.',
discordCoverNone: 'Nessuna (solo icona dell\'app)',
discordCoverServer: 'Server (tramite info album)',
discordCoverApple: 'Apple Music',
+2 -1
View File
@@ -295,8 +295,9 @@ export const settings = {
linuxWaylandTextRenderGpu: 'GPU 優先',
linuxWaylandTextRenderMinimal: '最小 (既定 CSS スムージング)',
discordCoverTitle: 'カバーアートのソース',
discordCoverDesc: 'Discord プロフィールに表示するアルバムアートの取得元です。',
discordCoverDesc: 'Discord プロフィールに表示するアルバムアートの取得元です。サーバーのカバーは、認証情報を含まない公開画像リンクを使用します — プロフィールを見た人にサーバーの公開アドレスが分かる場合があります。公開到達可能なサーバーが必要です。',
discordCoverNone: 'なし (アプリアイコンのみ)',
discordCoverServer: 'サーバー (アルバム情報経由)',
discordCoverApple: 'Apple Music',
discordOptions: 'Discord 詳細オプション',
discordTemplates: 'カスタムテキストテンプレート',
+2 -1
View File
@@ -278,8 +278,9 @@ export const settings = {
discordRichPresenceDesc: 'Vis sporet som spilles i din Discord-profil. Krever at Discord kjører.',
discordRichPresenceNotice: 'Merk: dette er den innebygde Discord Rich Presence i Psysonic. Vil du heller bruke det offisielle Navidrome Discord Rich Presence-tillegget, la denne funksjonen være av og aktiver i stedet «Vis i "Nå spiller"» lenger ned på denne siden.',
discordCoverTitle: 'Omslagskilde',
discordCoverDesc: 'Hvor albumomslaget på Discord-profilen din kommer fra.',
discordCoverDesc: 'Hvor albumomslaget på Discord-profilen din kommer fra. Serveromslag bruker en offentlig bildelenke uten legitimasjon — alle som ser profilen din, kan se serverens offentlige adresse. Krever en offentlig tilgjengelig server.',
discordCoverNone: 'Ingen (kun app-ikon)',
discordCoverServer: 'Server (via albuminfo)',
discordCoverApple: 'Apple Music',
discordOptions: 'Avanserte Discord-alternativer',
discordTemplates: 'Egendefinerte tekstmaler',
+2 -1
View File
@@ -279,8 +279,9 @@ export const settings = {
discordRichPresenceDesc: 'Toont het huidige nummer op je Discord-profiel. Discord moet daarvoor geopend zijn.',
discordRichPresenceNotice: 'Let op: dit is de in Psysonic ingebouwde Discord Rich Presence. Wil je liever de officiële Navidrome Discord Rich Presence-plug-in gebruiken, laat deze functie dan uitgeschakeld en schakel in plaats daarvan verderop op deze pagina „Weergeven in live-venster" in.',
discordCoverTitle: 'Bron albumhoes',
discordCoverDesc: 'Waar de albumhoes op je Discord-profiel vandaan komt.',
discordCoverDesc: 'Waar de albumhoes op je Discord-profiel vandaan komt. Serverhoezen gebruiken een openbare, credential-vrije afbeeldingslink — iedereen die je profiel bekijkt, kan het openbare adres van je server zien. Vereist een openbaar bereikbare server.',
discordCoverNone: 'Geen (alleen app-icoon)',
discordCoverServer: 'Server (via albuminfo)',
discordCoverApple: 'Apple Music',
discordOptions: 'Geavanceerde Discord-opties',
discordTemplates: 'Aangepaste tekstsjablonen',
+2 -1
View File
@@ -295,8 +295,9 @@ export const settings = {
linuxWaylandTextRenderGpu: 'Z priorytetem GPU',
linuxWaylandTextRenderMinimal: 'Minimalne (domyślne wygładzanie CSS)',
discordCoverTitle: 'Źródło okładki',
discordCoverDesc: 'Skąd pochodzi okładka albumu wyświetlana na Twoim profilu Discord.',
discordCoverDesc: 'Skąd pochodzi okładka albumu wyświetlana na Twoim profilu Discord. Okładki z serwera korzystają z publicznego linku do obrazu bez danych uwierzytelniających — każdy, kto zobaczy Twój profil, może poznać publiczny adres Twojego serwera. Wymaga publicznie dostępnego serwera.',
discordCoverNone: 'Żadna (tylko ikona aplikacji)',
discordCoverServer: 'Serwer (przez info albumu)',
discordCoverApple: 'Apple Music',
discordOptions: 'Zaawansowane ustawienia Discord',
discordTemplates: 'Niestandardowe szablony tekstowe',
+2 -1
View File
@@ -294,8 +294,9 @@ export const settings = {
linuxWaylandTextRenderGpu: 'Prioritate GPU',
linuxWaylandTextRenderMinimal: 'Minim (netezire CSS implicită)',
discordCoverTitle: 'Sursa coperții',
discordCoverDesc: 'De unde provine coperta afișată pe profilul tău Discord.',
discordCoverDesc: 'De unde provine coperta afișată pe profilul tău Discord. Copertele de pe server folosesc un link de imagine public, fără date de autentificare — oricine îți vede profilul poate afla adresa publică a serverului tău. Necesită un server accesibil public.',
discordCoverNone: 'Niciuna (doar iconița aplicației)',
discordCoverServer: 'Server (prin informații album)',
discordCoverApple: 'Apple Music',
discordOptions: 'Opțiuni avansate Discord',
discordTemplates: 'Șablon text personalizat',
+2 -1
View File
@@ -298,8 +298,9 @@ export const settings = {
'Показывать текущий трек в профиле и статусе Discord. Нужен запущенный клиент Discord.',
discordRichPresenceNotice: 'Внимание: это встроенная Discord Rich Presence в Psysonic. Если вы хотите использовать официальный плагин Discord Rich Presence для Navidrome, оставьте эту функцию выключенной и вместо этого включите «Показывать в „Сейчас играет"» ниже на этой странице.',
discordCoverTitle: 'Источник обложки',
discordCoverDesc: 'Откуда берётся обложка, показываемая в вашем профиле Discord.',
discordCoverDesc: 'Откуда берётся обложка, показываемая в вашем профиле Discord. Обложки с сервера используют публичную ссылку на изображение без учётных данных — любой, кто видит ваш профиль, может узнать публичный адрес вашего сервера. Требуется публично доступный сервер.',
discordCoverNone: 'Нет (только иконка приложения)',
discordCoverServer: 'Сервер (через информацию об альбоме)',
discordCoverApple: 'Apple Music',
discordOptions: 'Расширенные параметры Discord',
discordTemplates: 'Пользовательские шаблоны текста',
+2 -1
View File
@@ -278,8 +278,9 @@ export const settings = {
discordRichPresenceDesc: '在 Discord 个人资料上显示当前播放的曲目。需要 Discord 处于运行状态。',
discordRichPresenceNotice: '注意:这是 Psysonic 内置的 Discord Rich Presence。如果你想使用官方的 Navidrome Discord Rich Presence 插件,请保持此功能关闭,并改为启用本页下方的"在实时窗口中显示"。',
discordCoverTitle: '封面来源',
discordCoverDesc: '你的 Discord 资料中显示的专辑封面来源。',
discordCoverDesc: '你的 Discord 资料中显示的专辑封面来源。服务器封面使用公开的、不含凭据的图片链接——任何查看你资料的人都可能看到你服务器的公开地址。需要可公开访问的服务器。',
discordCoverNone: '无(仅显示应用图标)',
discordCoverServer: '服务器(通过专辑信息)',
discordCoverApple: 'Apple Music',
discordOptions: 'Discord 高级选项',
discordTemplates: '自定义文本模板',
+2 -2
View File
@@ -214,8 +214,8 @@ describe('replay-gain related setters (write through to player store)', () => {
});
describe('discord cover source setters', () => {
it('setDiscordCoverSource accepts none / apple', () => {
for (const src of ['none', 'apple'] as const) {
it('setDiscordCoverSource accepts none / server / apple', () => {
for (const src of ['none', 'server', 'apple'] as const) {
useAuthStore.getState().setDiscordCoverSource(src);
expect(useAuthStore.getState().discordCoverSource).toBe(src);
}
+37
View File
@@ -86,3 +86,40 @@ describe('computeAuthStoreRehydration — lyrics', () => {
expect(patch.startMinimizedToTray).toBe(false);
});
});
describe('computeAuthStoreRehydration — discordCoverSource server-revival (PR #1299)', () => {
const SENTINEL_KEY = 'psysonic-discord-server-cover-revival-v1';
beforeEach(() => {
resetAuthStore();
localStorage.clear();
});
it('coerces a stale pre-#1246 "server" value to "none" exactly once', () => {
const base = useAuthStore.getState();
const patch = computeAuthStoreRehydration({ ...base, discordCoverSource: 'server' } as AuthState);
expect(patch.discordCoverSource).toBe('none');
expect(localStorage.getItem(SENTINEL_KEY)).toBe('1');
});
it('does not coerce "server" once the sentinel is already set (post-revival user choice)', () => {
localStorage.setItem(SENTINEL_KEY, '1');
const base = useAuthStore.getState();
const patch = computeAuthStoreRehydration({ ...base, discordCoverSource: 'server' } as AuthState);
expect(patch.discordCoverSource).toBeUndefined();
});
it('sets the sentinel on first rehydrate even when the value is not "server"', () => {
const base = useAuthStore.getState();
computeAuthStoreRehydration({ ...base, discordCoverSource: 'none' } as AuthState);
expect(localStorage.getItem(SENTINEL_KEY)).toBe('1');
});
it('does not touch "apple" or "none"', () => {
const base = useAuthStore.getState();
for (const source of ['apple', 'none'] as const) {
const patch = computeAuthStoreRehydration({ ...base, discordCoverSource: source } as AuthState);
expect(patch.discordCoverSource).toBeUndefined();
}
});
});
+18 -9
View File
@@ -175,15 +175,24 @@ export function computeAuthStoreRehydration(state: AuthState): Partial<AuthState
if (legacyAppleCovers === true && (!state.discordCoverSource || state.discordCoverSource === 'none')) {
discordCoverSourceMigrated = { discordCoverSource: 'apple' };
}
// The 'server' cover source was removed: it built an authenticated Subsonic
// getCoverArt URL (carrying u/t/s) and handed it to Discord, whose external
// image proxy exposes the full URL to anyone viewing the presence. Migrate any
// persisted 'server' → 'none' (no external request, no cover) — the least
// surprising landing that doesn't silently start hitting a third party.
if ((state as { discordCoverSource?: unknown }).discordCoverSource === 'server') {
discordCoverSourceMigrated = { discordCoverSource: 'none' };
}
// One-time: the 'server' cover source was removed in PR #1246 (it leaked
// authenticated Subsonic credentials) and reinstated in PR #1299 via a
// credential-free implementation. A value that predates the reinstatement
// must not be silently honored — a user who skipped every build between
// those two PRs would otherwise have their pre-#1246 'server' preference
// resurrected on first launch, without ever seeing the new opt-in
// disclosure copy. Runs exactly once (guarded by a sentinel, same pattern
// as the maxCacheMb migration below) so re-selecting 'server' afterward is
// never coerced back.
const discordServerCoverRevivalMigrationKey = 'psysonic-discord-server-cover-revival-v1';
try {
if (!localStorage.getItem(discordServerCoverRevivalMigrationKey)) {
if ((state as { discordCoverSource?: unknown }).discordCoverSource === 'server') {
discordCoverSourceMigrated = { discordCoverSource: 'none' };
}
localStorage.setItem(discordServerCoverRevivalMigrationKey, '1');
}
} catch { /* ignore */ }
// One-time: legacy unified `maxCacheMb` cap removed from Settings (offline + IDB covers).
const maxCacheMbMigrationKey = 'psysonic-max-cache-mb-removed-v1';
let maxCacheMbMigrated: { maxCacheMb?: number } = {};
+1 -1
View File
@@ -88,7 +88,7 @@ export type LoggingMode = 'off' | 'normal' | 'debug';
*/
export type ClockFormat = 'auto' | '24h' | '12h';
export type NormalizationEngine = 'off' | 'replaygain' | 'loudness';
export type DiscordCoverSource = 'none' | 'apple';
export type DiscordCoverSource = 'none' | 'server' | 'apple';
/** Wayland + WebKit text/GPU profile (Settings → System, Linux only when available). */
export type LinuxWaylandTextRenderProfile = 'balanced' | 'sharp' | 'gpu' | 'minimal';