Merge remote-tracking branch 'origin/main' into fix/album-sort-feat-artists

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Psychotoxical
2026-07-14 18:54:59 +02:00
44 changed files with 882 additions and 63 deletions
+48
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
@@ -157,6 +163,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Fixed
### Tray — release build compile after #1296
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1298](https://github.com/Psychotoxical/psysonic/pull/1298)**
* Fixes `E0308` in release `on_second_instance`: second-launch tray restore again uses inline resume/show/unminimize/focus so it stays generic over `tauri::Runtime` (Nix/release builds no longer fail on `restore_main_window(&WebviewWindow<R>)`).
### Tray — sidebar missing after cold start minimized to tray
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1296](https://github.com/Psychotoxical/psysonic/pull/1296)**
* With **Start minimized to tray** enabled, opening the main window from the tray could leave the left sidebar menu invisible until a full app restart (seen on Linux tiling WMs such as Hyprland). The sidebar no longer uses a slide-in entrance animation that starts at `opacity: 0`; cold-start tray hide restores the original pause-and-hide path from PR #1271, and tray show again resumes rendering before `show()` without an extra `unminimize()` that could pop the window on tiling WMs.
### Playlist and radio custom covers blank
**By [@cucadmuh](https://github.com/cucadmuh), reported by VirtualWolf, PR [#1295](https://github.com/Psychotoxical/psysonic/pull/1295)**
* Custom playlist and internet radio covers uploaded in Navidrome stayed blank in Psysonic (cards and detail headers) while album and track art worked. The cover resolver rewrote Navidrome's `pl-*` and `ra-*` getCoverArt ids into invalid `al-pl-*_0` / `al-ra-*_0` forms; fetch-only prefixes are now preserved in TS and Rust.
### Album detail — no duplicate cover thumbs in tracklist
**By [@cucadmuh](https://github.com/cucadmuh), reported by mikmik on the Psysonic Discord, PR [#1291](https://github.com/Psychotoxical/psysonic/pull/1291)**
* The album detail tracklist no longer shows a small album cover on every row when **Settings → Appearance → Track lists** is enabled — the page already displays the album art above the list, so the per-row thumbs from #1280 duplicated the same image on every line (desktop and mobile).
### Per-track covers when playing from a playlist
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1218](https://github.com/Psychotoxical/psysonic/pull/1218)**, reported by The Cup Slammer on Discord
@@ -333,6 +363,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Sorting albums by artist ordered them by the track artist while showing the album artist. On a release with featured guests the two differ, so it was filed under a name that isn't on screen — the album dropped out of its artist's run of years, sometimes behind a different artist entirely. Album sorting now follows the artist the row actually shows.
### Themes — album rails no longer cut off card shadows
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by Asra on the Psysonic Discord, PR [#1300](https://github.com/Psychotoxical/psysonic/pull/1300)**
* Horizontal album rails clipped an outer card shadow at the edges, which only themes that use a real drop shadow ran into. Working around it meant overriding the rail's `overflow`, and that disabled the rail's `<` / `>` scroll arrows. Rails now reserve room for the shadow inside the rail itself, so the arrows keep working; a theme that needs more room can raise `--rail-shadow-room` instead of touching `overflow`.
### Accessibility — modal dialogs announce their title
**By [@AliMahmoudDev](https://github.com/AliMahmoudDev), PR [#1301](https://github.com/Psychotoxical/psysonic/pull/1301)**
* Modal dialogs carried no accessible name, so a screen reader announced them without saying which dialog had opened. The dialog is now linked to its title, and each instance gets its own id so several open dialogs cannot be confused for one another.
### Contributors — theme credits could show an outdated name
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1302](https://github.com/Psychotoxical/psysonic/pull/1302)**
* **Settings → System → Contributors** read the theme list from a cache that is only refreshed every 12 hours, so a theme author whose name had since been corrected in the store kept showing under the old one, with no way to refresh from that screen. The list now shows the cached names instantly and quietly corrects itself from the store in the background.
## [1.49.0] - 2026-06-29
@@ -20,6 +20,7 @@ use std::path::{Path, PathBuf};
pub const LAYOUT_STAMP: &str = "canonical-segment-v5";
/// True for ids that are only valid as `getCoverArt` targets, not library entity keys.
/// Prefixes mirror Navidrome `model.Kind` (`pl`, `ra`, `dc`, `mf`, …) — not bare album hashes.
pub fn is_fetch_only_cover_id(id: &str) -> bool {
let id = id.trim();
id.starts_with("mf-")
@@ -119,7 +120,7 @@ pub fn resolve_album_cover(
fetch_cover_art_id: fetch.to_string(),
});
}
let fetch_id = if !distinct_disc_covers && fetch == album {
let fetch_id = if !distinct_disc_covers && fetch == album && !is_fetch_only_cover_id(fetch) {
format!("al-{album}_0")
} else {
fetch.to_string()
@@ -298,6 +299,28 @@ mod tests {
assert_eq!(e.fetch_cover_art_id, "al-2lsdR1ogDKiFcAD6Pcvk4f_0");
}
#[test]
fn resolve_album_playlist_cover_keeps_pl_prefix() {
let e = resolve_album_cover("pl-abc123", Some("pl-abc123"), false).unwrap();
assert_eq!(e.cache_entity_id, "pl-abc123");
assert_eq!(e.fetch_cover_art_id, "pl-abc123");
}
#[test]
fn resolve_album_playlist_cover_keeps_navidrome_pl_suffix() {
let id = "pl-18690de0-151b-4d86-81cb-f418a907315a_0";
let e = resolve_album_cover(id, Some(id), false).unwrap();
assert_eq!(e.cache_entity_id, id);
assert_eq!(e.fetch_cover_art_id, id);
}
#[test]
fn resolve_album_radio_cover_keeps_ra_prefix() {
let e = resolve_album_cover("ra-rd-1_0", Some("ra-rd-1_0"), false).unwrap();
assert_eq!(e.cache_entity_id, "ra-rd-1_0");
assert_eq!(e.fetch_cover_art_id, "ra-rd-1_0");
}
fn test_server_dir(label: &str) -> std::path::PathBuf {
let base = std::env::temp_dir().join(format!("psysonic-cover-layout-{label}"));
let _ = std::fs::remove_dir_all(&base);
@@ -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]
+6 -2
View File
@@ -169,8 +169,12 @@ document.documentElement.style.removeProperty('--psy-anim-speed');
})();
"#;
/// Show the main window after startup splash paint, or pause rendering when the
/// user chose "start minimized to tray" (flag set in `startup-splash-preflight.js`).
/// Show the main window after startup splash paint, or pause rendering and hide
/// when the user chose "start minimized to tray" (flag set in
/// `startup-splash-preflight.js`).
///
/// The shell no longer uses entrance animations that start at `opacity: 0`, so
/// pausing CSS here is safe on WebKitGTK tiling WMs (sidebar fix in #1296).
pub(crate) fn eval_startup_main_window_visibility(window: &tauri::WebviewWindow) {
let js = format!(
"(function () {{
+8
View File
@@ -204,6 +204,7 @@ const CONTRIBUTOR_ENTRIES = [
'Internet Radio — Web Audio EQ on HTML5 streams; presets apply without reconnect (PR #1284)',
'Player bar — hideable stop button, optional album line, drag-reorderable action buttons (request: mikmik on Psysonic Discord, PR #1287)',
'Persistent shuffle mode — queue-reordering shuffle with restore, survives restart, in sync with other devices and Orbit (request: mikmik on Psysonic Discord, PR #1288)',
'Playlist and radio custom covers — preserve Navidrome pl-/ra-* getCoverArt ids (fixes blank uploaded covers; PR #1295)',
],
},
{
@@ -479,6 +480,13 @@ const CONTRIBUTOR_ENTRIES = [
'Sync: form POST for large play queues to avoid HTTP 414 behind reverse proxies (PR #1262)',
],
},
{
github: 'AliMahmoudDev',
since: '1.50.0',
contributions: [
'Accessibility: modal dialogs announce their title to screen readers (aria-labelledby) (PR #1301)',
],
},
] as const;
// PR number of a contributor's first listed contribution, used as the
+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;
}
+41
View File
@@ -1,12 +1,14 @@
import { describe, expect, it } from 'vitest';
import {
albumHasDistinctDiscCovers,
isFetchOnlyCoverId,
normalizeAlbumLibraryEntry,
resolveAlbumCoverEntry,
resolveArtistCoverEntry,
resolveSongFetchCoverArtId,
resolveTrackCoverEntry,
} from './resolveEntry';
import { albumCoverRef } from './ref';
describe('resolveAlbumCoverEntry', () => {
it('uses bare Navidrome album id on disk', () => {
@@ -27,6 +29,45 @@ describe('resolveAlbumCoverEntry', () => {
'al-2lsdR1ogDKiFcAD6Pcvk4f_0',
);
});
it('keeps pl-* playlist cover ids for getCoverArt (no al- prefix)', () => {
const e = resolveAlbumCoverEntry('pl-abc123', 'pl-abc123');
expect(e?.cacheEntityId).toBe('pl-abc123');
expect(e?.fetchCoverArtId).toBe('pl-abc123');
});
it('keeps Navidrome pl-{uuid}_0 playlist coverArt from Subsonic API', () => {
const id = 'pl-18690de0-151b-4d86-81cb-f418a907315a_0';
const e = resolveAlbumCoverEntry(id, id);
expect(e?.fetchCoverArtId).toBe(id);
});
it('keeps ra-* internet radio cover ids (no al- prefix)', () => {
const e = resolveAlbumCoverEntry('ra-rd-1_0', 'ra-rd-1_0');
expect(e?.fetchCoverArtId).toBe('ra-rd-1_0');
});
});
describe('isFetchOnlyCoverId', () => {
it('matches Navidrome getCoverArt-only prefixes', () => {
expect(isFetchOnlyCoverId('pl-abc')).toBe(true);
expect(isFetchOnlyCoverId('ra-rd-1_0')).toBe(true);
expect(isFetchOnlyCoverId('mf-track')).toBe(true);
expect(isFetchOnlyCoverId('dc-album:2')).toBe(true);
});
it('does not match bare album hashes', () => {
expect(isFetchOnlyCoverId('2lsdR1ogDKiFcAD6Pcvk4f')).toBe(false);
expect(isFetchOnlyCoverId('al-2lsd_0')).toBe(false);
});
});
describe('albumCoverRef fetch-only ids', () => {
it('preserves pl-* for playlist hero/card covers', () => {
const id = 'pl-18690de0-151b-4d86-81cb-f418a907315a_0';
const ref = albumCoverRef(id, id);
expect(ref.fetchCoverArtId).toBe(id);
});
});
describe('resolveArtistCoverEntry', () => {
+17 -1
View File
@@ -22,6 +22,21 @@ export type CoverArtResolvableSong = Pick<SubsonicSong, 'id' | 'coverArt'> & {
albumId?: string | null;
};
/**
* True for ids that are only valid as `getCoverArt` targets, not library entity keys.
* Keep in sync with Rust `psysonic_core::cover_cache_layout::is_fetch_only_cover_id`.
*/
export function isFetchOnlyCoverId(id: string): boolean {
const trimmed = id.trim();
return (
trimmed.startsWith('mf-')
|| trimmed.startsWith('tr-')
|| trimmed.startsWith('pl-')
|| trimmed.startsWith('dc-')
|| trimmed.startsWith('ra-')
);
}
/** Navidrome `getCoverArt` id for a song row (ignores echo of track id with no art). */
export function resolveSongFetchCoverArtId(song: CoverArtResolvableSong): string | undefined {
const albumId = song.albumId?.trim();
@@ -92,7 +107,8 @@ export function resolveAlbumCoverEntry(
return { cacheKind: 'album', cacheEntityId: album, fetchCoverArtId: fetch };
}
// Bare album ids need `al-<albumId>_0` on Navidrome when no mf id is available.
if (!distinctDiscCovers && fetch === album) {
// Playlist / radio / other fetch-only ids must keep their native prefix (e.g. `pl-*`).
if (!distinctDiscCovers && fetch === album && !isFetchOnlyCoverId(fetch)) {
fetch = `al-${album}_0`;
}
const cacheEntityId =
@@ -4,8 +4,6 @@ import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import type { Track } from '@/lib/media/trackTypes';
import { songToTrack } from '@/lib/media/songToTrack';
import { formatLongDuration } from '@/lib/format/formatDuration';
import { OptionalBrowseTrackRowCoverThumb } from '@/cover/TrackRowCoverThumb';
import { useTrackListCoverArtEnabled } from '@/cover/useTrackListCoverArtSettings';
interface Props {
discNums: number[];
@@ -43,8 +41,6 @@ export function AlbumTrackListMobile({
onPlaySong,
onContextMenu,
}: Props) {
const showCovers = useTrackListCoverArtEnabled('pages');
return (
<div className="tracklist-mobile">
{discNums.map(discNum => (
@@ -62,7 +58,7 @@ export function AlbumTrackListMobile({
return (
<div
key={song.id}
className={`tracklist-mobile-row${showCovers ? ' tracklist-mobile-row--with-cover' : ''}${isActive ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
className={`tracklist-mobile-row${isActive ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
onClick={() => onPlaySong(song)}
onContextMenu={e => {
e.preventDefault();
@@ -78,7 +74,6 @@ export function AlbumTrackListMobile({
) : (
<span className="tracklist-mobile-num">{song.track ?? ''}</span>
)}
<OptionalBrowseTrackRowCoverThumb song={song} size="dense" />
<span className="tracklist-mobile-title">{song.title}</span>
</div>
<span className="tracklist-mobile-duration">{formatLongDuration(song.duration)}</span>
@@ -16,8 +16,6 @@ import { formatLastSeen } from '@/lib/format/userMgmtHelpers';
import i18n from '@/lib/i18n';
import { offlineActionPolicy, type OfflineActionPolicy } from '@/features/offline';
import { resolveTrackArtistRefs } from '@/features/playback/utils/playback/trackArtistRefs';
import { OptionalBrowseTrackRowCoverThumb } from '@/cover/TrackRowCoverThumb';
import { useTrackListCoverArtEnabled } from '@/cover/useTrackListCoverArtSettings';
type ContextMenuFn = (
x: number,
@@ -78,7 +76,6 @@ export const TrackRow = React.memo(function TrackRow({
const { t } = useTranslation();
const navigate = useNavigate();
const showBitrate = useThemeStore(s => s.showBitrate);
const showCovers = useTrackListCoverArtEnabled('pages');
const isSelected = useSelectionStore(s => s.selectedIds.has(song.id));
const isActive = currentTrackId === song.id;
const isPreviewing = usePreviewStore(s => s.previewingId === song.id);
@@ -109,9 +106,6 @@ export const TrackRow = React.memo(function TrackRow({
case 'title':
return (
<div key="title" className="track-info track-info-suggestion">
{showCovers ? (
<OptionalBrowseTrackRowCoverThumb song={song} size="dense" className="song-list-row-cover-thumb" />
) : null}
<button
type="button"
className="playlist-suggestion-play-btn"
@@ -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 PlaylistCardMainCover({ coverArt, alt }: { coverArt: string; alt
coverArt={coverArt}
displayCssPx={PLAYLIST_MAIN_COVER_CSS_PX}
surface="dense"
libraryResolve={false}
alt={alt}
className="album-card-cover-img"
/>
@@ -84,6 +84,7 @@ export default function PlaylistEditModal({
coverArt={customCoverId}
displayCssPx={PLAYLIST_MAIN_COVER_CSS_PX}
surface="dense"
libraryResolve={false}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
@@ -95,6 +95,7 @@ export default function PlaylistHero({
coverArt={customCoverId}
displayCssPx={PLAYLIST_MAIN_COVER_CSS_PX}
surface="dense"
libraryResolve={false}
alt=""
className="playlist-cover-grid"
style={{ objectFit: 'cover', display: 'block' }}
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import type { CoverArtId, CoverArtRef } from '@/cover/types';
import { albumCoverRef } from '@/cover/ref';
import { coverPrefetchRegister } from '@/cover/prefetchRegistry';
import { resolveAlbumCoverRefFromLibrary } from '@/cover/resolveEntryLibrary';
import { useCoverArt } from '@/cover/useCoverArt';
@@ -18,6 +19,11 @@ async function playlistCoverRefFromLibrary(
coverId: string,
songs: SubsonicSong[],
): Promise<CoverArtRef> {
const trimmed = coverId.trim();
// Custom playlist / radio covers only — not track mf-* ids used in quad collages.
if (trimmed.startsWith('pl-') || trimmed.startsWith('ra-')) {
return albumCoverRef(trimmed, trimmed);
}
const song = songs.find(s => s.coverArt === coverId || s.albumId === coverId);
if (song?.albumId) {
return resolveAlbumCoverRefFromLibrary(song.albumId, coverId);
@@ -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 =>
@@ -21,7 +21,7 @@ import { SettingsToggle } from '@/features/settings/components/SettingsToggle';
import { SettingsSubCard, SettingsField } from '@/features/settings/components/SettingsSubCard';
import { BackupSection } from '@/features/settings/components/BackupSection';
import { CONTRIBUTORS, MAINTAINERS, themeContributorsFromRegistry, type ThemeContributor } from '@/config/settingsCredits';
import { fetchRegistry } from '@/lib/themes/themeRegistry';
import { revalidateRegistry } from '@/lib/themes/themeRegistry';
export function SystemTab() {
const { t } = useTranslation();
@@ -37,15 +37,16 @@ export function SystemTab() {
.catch(() => {});
}, []);
// Community theme authors come from the store registry (cached, offline-safe).
// On a first run with no cached registry this simply stays empty.
// Community theme authors come from the store registry. Stale-while-revalidate:
// the cached copy paints immediately (offline-safe), then a background refresh
// corrects it. Reading the plain TTL cache would leave a corrected author
// mis-credited for up to 12 hours, and Credits has no refresh control of its
// own. On a first run with no cached registry this simply stays empty.
useEffect(() => {
let cancelled = false;
fetchRegistry()
.then(({ registry }) => {
if (!cancelled) setThemeContributors(themeContributorsFromRegistry(registry.themes));
})
.catch(() => {});
void revalidateRegistry(registry => {
if (!cancelled) setThemeContributors(themeContributorsFromRegistry(registry.themes));
});
return () => { cancelled = true; };
}, []);
+1 -1
View File
@@ -206,7 +206,7 @@ export default function Sidebar({
return (
<>
<aside className={`sidebar animate-slide-in ${isCollapsed ? 'collapsed' : ''}`}>
<aside className={`sidebar ${isCollapsed ? 'collapsed' : ''}`}>
<div className="sidebar-brand" aria-hidden>
{isCollapsed
? <PSmallLogo style={{ height: '32px', width: 'auto' }} />
+53 -1
View File
@@ -3,7 +3,7 @@
* stale-on-error fallback, and malformed-cache tolerance.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fetchRegistry, getCachedRegistry } from '@/lib/themes/themeRegistry';
import { fetchRegistry, getCachedRegistry, revalidateRegistry } from '@/lib/themes/themeRegistry';
const CACHE_KEY = 'psysonic_theme_registry_cache';
const NOW = 1_000_000_000;
@@ -95,3 +95,55 @@ describe('fetchRegistry', () => {
expect(calls[0]).not.toContain('jsdelivr');
});
});
describe('revalidateRegistry', () => {
it('serves the cache first, then the fresh copy — the TTL must not hide an update', async () => {
// The exact case this exists for: a cache well inside the TTL, holding a
// registry that has since been corrected upstream. `fetchRegistry` alone
// would return the stale copy and never hit the network.
writeCache(NOW, 'cached');
vi.stubGlobal('fetch', vi.fn(async () => okRes(reg('corrected'))));
const seen: string[] = [];
await revalidateRegistry(r => seen.push(r.generatedAt));
expect(seen).toEqual(['cached', 'corrected']);
});
it('emits once when the fresh copy matches the cache — no pointless re-render', async () => {
writeCache(NOW, 'same');
vi.stubGlobal('fetch', vi.fn(async () => okRes(reg('same'))));
const seen: string[] = [];
await revalidateRegistry(r => seen.push(r.generatedAt));
expect(seen).toEqual(['same']);
});
it('emits once with the network copy when there is no cache', async () => {
vi.stubGlobal('fetch', vi.fn(async () => okRes(reg('first'))));
const seen: string[] = [];
await revalidateRegistry(r => seen.push(r.generatedAt));
expect(seen).toEqual(['first']);
});
it('keeps the cached copy when the network fails, and does not emit it twice', async () => {
writeCache(NOW, 'cached');
vi.stubGlobal('fetch', vi.fn(async () => failRes()));
const seen: string[] = [];
await revalidateRegistry(r => seen.push(r.generatedAt));
expect(seen).toEqual(['cached']);
});
it('never rejects and emits nothing when there is no cache and no network', async () => {
vi.stubGlobal('fetch', vi.fn(async () => failRes()));
const seen: string[] = [];
await expect(revalidateRegistry(r => seen.push(r.generatedAt))).resolves.toBeUndefined();
expect(seen).toEqual([]);
});
});
+31
View File
@@ -110,6 +110,37 @@ export async function fetchRegistry(opts?: { force?: boolean }): Promise<FetchRe
throw new Error('registry fetch failed');
}
/**
* Stale-while-revalidate read, for surfaces that must not show outdated data but
* must not block on the network either. Hands the cached copy over immediately
* (if there is one), then force-fetches and hands over the fresh copy when it
* actually differs.
*
* The TTL path is deliberately bypassed: a copy up to {@link TTL_MS} old is fine
* for *browsing* the store, but it is not fine for the Credits list, which
* attributes work to a person a theme author whose handle was corrected
* upstream would otherwise stay mis-credited for up to 12 hours, with no way to
* refresh from that screen.
*
* `onRegistry` runs at most twice (cached, then fresh) and never with the same
* registry twice. Never rejects: with no cache and no network the caller simply
* keeps whatever it had.
*/
export async function revalidateRegistry(
onRegistry: (registry: Registry) => void,
): Promise<void> {
const cached = getCachedRegistry();
if (cached) onRegistry(cached);
try {
const { registry, stale } = await fetchRegistry({ force: true });
// `stale` means the network failed and we were handed the cache back — the
// caller already has it.
if (!stale && registry.generatedAt !== cached?.generatedAt) onRegistry(registry);
} catch {
// No cache and no network. Nothing to show, nothing to update.
}
}
/**
* Fetch a single theme's CSS text from GitHub raw (repo-relative path). Raw is
* used rather than a mutable CDN edge so an install or update always gets the
+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';
@@ -51,7 +51,15 @@
display: flex;
gap: var(--space-4);
overflow-x: auto;
padding-bottom: var(--space-4);
/* The rail scrolls, so it clips: `overflow-x: auto` makes `overflow-y` compute
to `auto` too. An outer card shadow would be cut off at the edges. Padding
gives it room to paint *inside* the clip box; the matching negative margin
keeps the rail's content box exactly where it was, so nothing shifts.
Without this, a theme with a drop shadow is pushed into overriding `overflow`
which removes the scroll container the nav arrows drive, disabling them. */
padding: var(--rail-shadow-room) var(--rail-shadow-room)
max(var(--space-4), var(--rail-shadow-room));
margin: calc(-1 * var(--rail-shadow-room)) calc(-1 * var(--rail-shadow-room)) 0;
scroll-snap-type: none;
scroll-behavior: smooth;
/* Hide scrollbar for Webkit */
@@ -35,5 +35,13 @@
/* Layout */
--sidebar-width: 220px;
--player-height: 88px;
/* Room reserved inside a horizontal card rail's clip box so an outer card
shadow has somewhere to paint. A rail scrolls, so it must clip (its
`overflow-x: auto` makes `overflow-y` compute to `auto`), and a drop shadow
would otherwise be cut off at the edges. Raise this in a theme that uses a
larger shadow or glow never override `overflow` on the rail itself, which
would remove its scrollability and disable its nav arrows. */
--rail-shadow-room: 8px;
}
+111
View File
@@ -0,0 +1,111 @@
import { describe, it, expect, vi } from 'vitest';
import { screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import Modal from '@/ui/Modal';
describe('Modal', () => {
const defaultProps = {
open: true,
onClose: vi.fn(),
title: 'Test Modal',
children: <p>Modal body content</p>,
};
it('renders nothing when open is false', () => {
// Query the document, not the render container: Modal portals into
// document.body, so the container is empty whether it is open or not.
renderWithProviders(<Modal {...defaultProps} open={false} />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('renders the dialog when open is true', () => {
renderWithProviders(<Modal {...defaultProps} />);
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('renders the title text', () => {
renderWithProviders(<Modal {...defaultProps} />);
expect(screen.getByText('Test Modal')).toBeInTheDocument();
});
it('renders children content', () => {
renderWithProviders(<Modal {...defaultProps} />);
expect(screen.getByText('Modal body content')).toBeInTheDocument();
});
it('has aria-modal="true" on the dialog', () => {
renderWithProviders(<Modal {...defaultProps} />);
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true');
});
it('links the dialog to its title via aria-labelledby', () => {
renderWithProviders(<Modal {...defaultProps} />);
const dialog = screen.getByRole('dialog');
const labelledBy = dialog.getAttribute('aria-labelledby');
expect(labelledBy).toBeTruthy();
const titleEl = document.getElementById(labelledBy!);
expect(titleEl).toBeTruthy();
expect(titleEl!.textContent).toContain('Test Modal');
});
it('uses a unique id per instance so multiple modals do not clash', () => {
renderWithProviders(
<>
<Modal {...defaultProps} title="First" />
<Modal {...defaultProps} title="Second" />
</>,
);
const dialogs = screen.getAllByRole('dialog');
const id1 = dialogs[0].getAttribute('aria-labelledby');
const id2 = dialogs[1].getAttribute('aria-labelledby');
expect(id1).not.toBe(id2);
});
it('calls onClose when Escape is pressed', () => {
const onClose = vi.fn();
renderWithProviders(<Modal {...defaultProps} onClose={onClose} />);
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).toHaveBeenCalledOnce();
});
it('calls onClose when the backdrop is clicked', async () => {
const onClose = vi.fn();
renderWithProviders(<Modal {...defaultProps} onClose={onClose} />);
await userEvent.click(screen.getByRole('dialog').parentElement!);
expect(onClose).toHaveBeenCalledOnce();
});
it('does not call onClose when the dialog content is clicked', async () => {
const onClose = vi.fn();
renderWithProviders(<Modal {...defaultProps} onClose={onClose} />);
await userEvent.click(screen.getByRole('dialog'));
expect(onClose).not.toHaveBeenCalled();
});
it('calls onClose when the close button is clicked', async () => {
const onClose = vi.fn();
renderWithProviders(<Modal {...defaultProps} onClose={onClose} />);
await userEvent.click(screen.getByRole('button', { name: /close/i }));
expect(onClose).toHaveBeenCalledOnce();
});
it('hides the close button when hideClose is true', () => {
renderWithProviders(<Modal {...defaultProps} hideClose />);
expect(screen.queryByRole('button', { name: /close/i })).not.toBeInTheDocument();
});
it('renders the subtitle when provided', () => {
renderWithProviders(<Modal {...defaultProps} subtitle="v1.2.3" />);
expect(screen.getByText('v1.2.3')).toBeInTheDocument();
});
it('renders the footer when provided', () => {
renderWithProviders(
<Modal {...defaultProps} footer={<button type="button">Save</button>} />,
);
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
});
});
+5 -2
View File
@@ -1,4 +1,4 @@
import { type ReactNode, useEffect } from 'react';
import { type ReactNode, useEffect, useId } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
@@ -31,6 +31,8 @@ interface ModalProps {
export default function Modal({
open, onClose, title, subtitle, icon, footer, size = 'md', hideClose, closeLabel, children,
}: ModalProps) {
const titleId = useId();
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
@@ -48,12 +50,13 @@ export default function Modal({
className={`ui-modal ui-modal--${size}`}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
onClick={e => e.stopPropagation()}
>
<div className="ui-modal-header">
{icon && <span className="ui-modal-icon">{icon}</span>}
<div className="ui-modal-titles">
<span className="ui-modal-title">{title}</span>
<span className="ui-modal-title" id={titleId}>{title}</span>
{subtitle != null && <span className="ui-modal-subtitle">{subtitle}</span>}
</div>
{!hideClose && (