mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
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:
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 =>
|
||||
|
||||
@@ -295,8 +295,9 @@ export const settings = {
|
||||
linuxWaylandTextRenderGpu: 'Приоритет на GPU',
|
||||
linuxWaylandTextRenderMinimal: 'Минимално (изглаждане по подразбиране от CSS)',
|
||||
discordCoverTitle: 'Източник на корица',
|
||||
discordCoverDesc: 'Откъде идва обложката на албума в профила ти в Discord.',
|
||||
discordCoverDesc: 'Откъде идва обложката на албума в профила ти в Discord. Обложките от сървъра използват публична връзка към изображение без данни за достъп — всеки, който вижда профила ти, може да разбере публичния адрес на сървъра ти. Изисква публично достъпен сървър.',
|
||||
discordCoverNone: 'Няма (само икона на приложението)',
|
||||
discordCoverServer: 'Сървър (чрез информация за албума)',
|
||||
discordCoverApple: 'Apple Music',
|
||||
discordOptions: 'Разширени настройки за Discord',
|
||||
discordTemplates: 'Персонализирани текстови шаблони',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -295,8 +295,9 @@ export const settings = {
|
||||
linuxWaylandTextRenderGpu: 'GPU 優先',
|
||||
linuxWaylandTextRenderMinimal: '最小 (既定 CSS スムージング)',
|
||||
discordCoverTitle: 'カバーアートのソース',
|
||||
discordCoverDesc: 'Discord プロフィールに表示するアルバムアートの取得元です。',
|
||||
discordCoverDesc: 'Discord プロフィールに表示するアルバムアートの取得元です。サーバーのカバーは、認証情報を含まない公開画像リンクを使用します — プロフィールを見た人にサーバーの公開アドレスが分かる場合があります。公開到達可能なサーバーが必要です。',
|
||||
discordCoverNone: 'なし (アプリアイコンのみ)',
|
||||
discordCoverServer: 'サーバー (アルバム情報経由)',
|
||||
discordCoverApple: 'Apple Music',
|
||||
discordOptions: 'Discord 詳細オプション',
|
||||
discordTemplates: 'カスタムテキストテンプレート',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'Пользовательские шаблоны текста',
|
||||
|
||||
@@ -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: '自定义文本模板',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 } = {};
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user