diff --git a/CHANGELOG.md b/CHANGELOG.md index 73584166..a9c17170 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src-tauri/crates/psysonic-core/src/log_sanitize.rs b/src-tauri/crates/psysonic-core/src/log_sanitize.rs index c8ad5bc0..82bc69c0 100644 --- a/src-tauri/crates/psysonic-core/src/log_sanitize.rs +++ b/src-tauri/crates/psysonic-core/src/log_sanitize.rs @@ -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") { diff --git a/src-tauri/crates/psysonic-integration/src/discord.rs b/src-tauri/crates/psysonic-integration/src/discord.rs index ee2b8578..1a58f826 100644 --- a/src-tauri/crates/psysonic-integration/src/discord.rs +++ b/src-tauri/crates/psysonic-integration/src/discord.rs @@ -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] diff --git a/src/cover/integrations/discord.test.ts b/src/cover/integrations/discord.test.ts new file mode 100644 index 00000000..47993efb --- /dev/null +++ b/src/cover/integrations/discord.test.ts @@ -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(); + } + }); +}); diff --git a/src/cover/integrations/discord.ts b/src/cover/integrations/discord.ts new file mode 100644 index 00000000..8f4ef693 --- /dev/null +++ b/src/cover/integrations/discord.ts @@ -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/` 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: `"|"` -> 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(); + +/** + * 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 { + 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; +} diff --git a/src/features/playback/store/audioListenerSetup/discordPresence.ts b/src/features/playback/store/audioListenerSetup/discordPresence.ts index 118dba6d..649ea746 100644 --- a/src/features/playback/store/audioListenerSetup/discordPresence.ts +++ b/src/features/playback/store/audioListenerSetup/discordPresence.ts @@ -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); diff --git a/src/features/settings/components/IntegrationsTab.tsx b/src/features/settings/components/IntegrationsTab.tsx index 6b42b877..7d8d137f 100644 --- a/src/features/settings/components/IntegrationsTab.tsx +++ b/src/features/settings/components/IntegrationsTab.tsx @@ -26,6 +26,7 @@ export function IntegrationsTab() { ]; const discordCoverOptions: SegmentedOption[] = [ { id: 'none', label: t('settings.discordCoverNone') }, + { id: 'server', label: t('settings.discordCoverServer') }, { id: 'apple', label: t('settings.discordCoverApple') }, ]; const backdropSourceLabel = (s: BackdropSource): string => diff --git a/src/locales/bg/settings.ts b/src/locales/bg/settings.ts index ad6d9ac5..f92789e5 100644 --- a/src/locales/bg/settings.ts +++ b/src/locales/bg/settings.ts @@ -295,8 +295,9 @@ export const settings = { linuxWaylandTextRenderGpu: 'Приоритет на GPU', linuxWaylandTextRenderMinimal: 'Минимално (изглаждане по подразбиране от CSS)', discordCoverTitle: 'Източник на корица', - discordCoverDesc: 'Откъде идва обложката на албума в профила ти в Discord.', + discordCoverDesc: 'Откъде идва обложката на албума в профила ти в Discord. Обложките от сървъра използват публична връзка към изображение без данни за достъп — всеки, който вижда профила ти, може да разбере публичния адрес на сървъра ти. Изисква публично достъпен сървър.', discordCoverNone: 'Няма (само икона на приложението)', + discordCoverServer: 'Сървър (чрез информация за албума)', discordCoverApple: 'Apple Music', discordOptions: 'Разширени настройки за Discord', discordTemplates: 'Персонализирани текстови шаблони', diff --git a/src/locales/de/settings.ts b/src/locales/de/settings.ts index 5e0d8130..0d172ef0 100644 --- a/src/locales/de/settings.ts +++ b/src/locales/de/settings.ts @@ -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', diff --git a/src/locales/en/settings.ts b/src/locales/en/settings.ts index e6caca5a..aa0ec94c 100644 --- a/src/locales/en/settings.ts +++ b/src/locales/en/settings.ts @@ -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', diff --git a/src/locales/es/settings.ts b/src/locales/es/settings.ts index 671823ee..46c062dd 100644 --- a/src/locales/es/settings.ts +++ b/src/locales/es/settings.ts @@ -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', diff --git a/src/locales/fr/settings.ts b/src/locales/fr/settings.ts index 02006d52..f791597f 100644 --- a/src/locales/fr/settings.ts +++ b/src/locales/fr/settings.ts @@ -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', diff --git a/src/locales/hu/settings.ts b/src/locales/hu/settings.ts index 882fd599..9ecabc2f 100644 --- a/src/locales/hu/settings.ts +++ b/src/locales/hu/settings.ts @@ -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', diff --git a/src/locales/it/settings.ts b/src/locales/it/settings.ts index 8501e437..ecbaf723 100644 --- a/src/locales/it/settings.ts +++ b/src/locales/it/settings.ts @@ -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', diff --git a/src/locales/ja/settings.ts b/src/locales/ja/settings.ts index e314e494..aa3dd868 100644 --- a/src/locales/ja/settings.ts +++ b/src/locales/ja/settings.ts @@ -295,8 +295,9 @@ export const settings = { linuxWaylandTextRenderGpu: 'GPU 優先', linuxWaylandTextRenderMinimal: '最小 (既定 CSS スムージング)', discordCoverTitle: 'カバーアートのソース', - discordCoverDesc: 'Discord プロフィールに表示するアルバムアートの取得元です。', + discordCoverDesc: 'Discord プロフィールに表示するアルバムアートの取得元です。サーバーのカバーは、認証情報を含まない公開画像リンクを使用します — プロフィールを見た人にサーバーの公開アドレスが分かる場合があります。公開到達可能なサーバーが必要です。', discordCoverNone: 'なし (アプリアイコンのみ)', + discordCoverServer: 'サーバー (アルバム情報経由)', discordCoverApple: 'Apple Music', discordOptions: 'Discord 詳細オプション', discordTemplates: 'カスタムテキストテンプレート', diff --git a/src/locales/nb/settings.ts b/src/locales/nb/settings.ts index e882ad62..6e9e13ef 100644 --- a/src/locales/nb/settings.ts +++ b/src/locales/nb/settings.ts @@ -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', diff --git a/src/locales/nl/settings.ts b/src/locales/nl/settings.ts index 9cf10157..8f3d23a6 100644 --- a/src/locales/nl/settings.ts +++ b/src/locales/nl/settings.ts @@ -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', diff --git a/src/locales/pl/settings.ts b/src/locales/pl/settings.ts index abcda822..5d0e193f 100644 --- a/src/locales/pl/settings.ts +++ b/src/locales/pl/settings.ts @@ -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', diff --git a/src/locales/ro/settings.ts b/src/locales/ro/settings.ts index 64af7917..0cabadeb 100644 --- a/src/locales/ro/settings.ts +++ b/src/locales/ro/settings.ts @@ -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', diff --git a/src/locales/ru/settings.ts b/src/locales/ru/settings.ts index faffc6d0..5e8bdea7 100644 --- a/src/locales/ru/settings.ts +++ b/src/locales/ru/settings.ts @@ -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: 'Пользовательские шаблоны текста', diff --git a/src/locales/zh/settings.ts b/src/locales/zh/settings.ts index 75919992..62142606 100644 --- a/src/locales/zh/settings.ts +++ b/src/locales/zh/settings.ts @@ -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: '自定义文本模板', diff --git a/src/store/authStore.settings.test.ts b/src/store/authStore.settings.test.ts index 5f981135..8d960bc5 100644 --- a/src/store/authStore.settings.test.ts +++ b/src/store/authStore.settings.test.ts @@ -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); } diff --git a/src/store/authStoreRehydrate.test.ts b/src/store/authStoreRehydrate.test.ts index 6f17f511..0b7f873f 100644 --- a/src/store/authStoreRehydrate.test.ts +++ b/src/store/authStoreRehydrate.test.ts @@ -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(); + } + }); +}); diff --git a/src/store/authStoreRehydrate.ts b/src/store/authStoreRehydrate.ts index fc22a0ba..b10c2a38 100644 --- a/src/store/authStoreRehydrate.ts +++ b/src/store/authStoreRehydrate.ts @@ -175,15 +175,24 @@ export function computeAuthStoreRehydration(state: AuthState): Partial