mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-25 00:35:45 +00:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d4cb95f7f | |||
| 4e05d32b88 | |||
| e822fff39f | |||
| dc7addae03 | |||
| c74e8f1921 | |||
| 7dfe58dba6 | |||
| 6759ee40e2 | |||
| fa69d11885 | |||
| f9760fe8a8 | |||
| 4e876f6e12 | |||
| 51b12eeec0 | |||
| 8610b2230e | |||
| e548c47078 | |||
| fa390e9211 | |||
| 25b8b57328 | |||
| 599ac31306 | |||
| 16aee64d66 | |||
| fa1dc1a328 | |||
| 0df547e3be | |||
| 9509b78073 | |||
| 1b73e929f0 | |||
| 6d9018e6b6 | |||
| 0a52e875a2 | |||
| ab70cbd528 | |||
| 1e8db450c4 | |||
| a451509d94 | |||
| 0331ac173d | |||
| 2e023fb8d3 | |||
| 398adaf214 | |||
| fd19419ac2 | |||
| f5ddb28d05 | |||
| 163e0e46a8 | |||
| d0edd925e4 | |||
| dac7bf56d9 |
@@ -26,6 +26,33 @@ const POLL_MS = 30_000;
|
||||
const TIMEOUT_MS = 90 * 60 * 1000;
|
||||
const OK_CONCLUSIONS = new Set(['success', 'neutral', 'skipped']);
|
||||
|
||||
/**
|
||||
* GitHub API hiccups (5xx, secondary rate limits, dropped connections) must
|
||||
* not fail the gate — the answer is to poll again, not to go red while the
|
||||
* real jobs are green. 4xx config errors (401/404 …) still throw.
|
||||
*/
|
||||
export function isTransientApiError(err) {
|
||||
const status = typeof err?.status === 'number' ? err.status : 0;
|
||||
return status === 0 || status === 429 || status >= 500;
|
||||
}
|
||||
|
||||
export async function withTransientRetry(label, fn, core, attempts = 5, delayMs = POLL_MS) {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
if (!isTransientApiError(err) || attempt >= attempts) {
|
||||
throw err;
|
||||
}
|
||||
// err.message can be a whole HTML error page — log only the status.
|
||||
core.info(
|
||||
`${label}: transient API error (status=${err?.status ?? 'network'}), retry ${attempt}/${attempts - 1} in ${delayMs / 1000}s`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function pathTriggersFrontend(file) {
|
||||
return FRONTEND_PATH_RE.test(file);
|
||||
}
|
||||
@@ -150,7 +177,11 @@ export async function runCiOkAggregate(github, context, core) {
|
||||
const { owner, repo } = context.repo;
|
||||
const sha = resolveTargetSha(context);
|
||||
const excludeRunId = String(context.runId);
|
||||
const changedFiles = await listChangedFiles(github, context);
|
||||
const changedFiles = await withTransientRetry(
|
||||
'listChangedFiles',
|
||||
() => listChangedFiles(github, context),
|
||||
core,
|
||||
);
|
||||
const required = requiredJobNames(changedFiles);
|
||||
|
||||
core.info(`ci-ok @ ${sha}; ${changedFiles.length} changed file(s)`);
|
||||
@@ -162,12 +193,24 @@ export async function runCiOkAggregate(github, context, core) {
|
||||
|
||||
const deadline = Date.now() + TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const checksAll = await github.paginate(github.rest.checks.listForRef, {
|
||||
owner,
|
||||
repo,
|
||||
ref: sha,
|
||||
per_page: 100,
|
||||
});
|
||||
let checksAll;
|
||||
try {
|
||||
checksAll = await github.paginate(github.rest.checks.listForRef, {
|
||||
owner,
|
||||
repo,
|
||||
ref: sha,
|
||||
per_page: 100,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!isTransientApiError(err)) {
|
||||
throw err;
|
||||
}
|
||||
// Same as an inconclusive poll: wait out the hiccup, the 90-minute
|
||||
// deadline stays the backstop.
|
||||
core.info(`checks.listForRef: transient API error (status=${err?.status ?? 'network'}) — retrying next poll`);
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
|
||||
continue;
|
||||
}
|
||||
const newestByName = newestChecksByName(checksAll, excludeRunId);
|
||||
const { pending, failures, done } = evaluateRequiredJobs(required, newestByName);
|
||||
|
||||
|
||||
@@ -3,10 +3,12 @@ import test from 'node:test';
|
||||
|
||||
import {
|
||||
evaluateRequiredJobs,
|
||||
isTransientApiError,
|
||||
newestChecksByName,
|
||||
pathTriggersFrontend,
|
||||
pathTriggersRust,
|
||||
requiredJobNames,
|
||||
withTransientRetry,
|
||||
} from './ci-ok-aggregate.mjs';
|
||||
|
||||
test('pathTriggersFrontend matches frontend workflow paths', () => {
|
||||
@@ -52,3 +54,75 @@ test('evaluateRequiredJobs passes when all required jobs succeeded', () => {
|
||||
const result = evaluateRequiredJobs(['eslint', 'vitest run'], newestChecksByName(checks));
|
||||
assert.equal(result.done, true);
|
||||
});
|
||||
|
||||
test('isTransientApiError treats 5xx, 429 and network errors as transient', () => {
|
||||
assert.equal(isTransientApiError({ status: 503 }), true);
|
||||
assert.equal(isTransientApiError({ status: 500 }), true);
|
||||
assert.equal(isTransientApiError({ status: 429 }), true);
|
||||
assert.equal(isTransientApiError(new Error('socket hang up')), true);
|
||||
assert.equal(isTransientApiError({ status: 404 }), false);
|
||||
assert.equal(isTransientApiError({ status: 401 }), false);
|
||||
});
|
||||
|
||||
const silentCore = { info: () => {} };
|
||||
|
||||
test('withTransientRetry retries transient errors and returns the late success', async () => {
|
||||
let calls = 0;
|
||||
const result = await withTransientRetry(
|
||||
'test',
|
||||
async () => {
|
||||
calls += 1;
|
||||
if (calls < 3) {
|
||||
const err = new Error('unavailable');
|
||||
err.status = 503;
|
||||
throw err;
|
||||
}
|
||||
return 'ok';
|
||||
},
|
||||
silentCore,
|
||||
5,
|
||||
1,
|
||||
);
|
||||
assert.equal(result, 'ok');
|
||||
assert.equal(calls, 3);
|
||||
});
|
||||
|
||||
test('withTransientRetry rethrows non-transient errors immediately', async () => {
|
||||
let calls = 0;
|
||||
await assert.rejects(
|
||||
withTransientRetry(
|
||||
'test',
|
||||
async () => {
|
||||
calls += 1;
|
||||
const err = new Error('not found');
|
||||
err.status = 404;
|
||||
throw err;
|
||||
},
|
||||
silentCore,
|
||||
5,
|
||||
1,
|
||||
),
|
||||
/not found/,
|
||||
);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('withTransientRetry gives up after the attempt budget', async () => {
|
||||
let calls = 0;
|
||||
await assert.rejects(
|
||||
withTransientRetry(
|
||||
'test',
|
||||
async () => {
|
||||
calls += 1;
|
||||
const err = new Error('unavailable');
|
||||
err.status = 503;
|
||||
throw err;
|
||||
},
|
||||
silentCore,
|
||||
3,
|
||||
1,
|
||||
),
|
||||
/unavailable/,
|
||||
);
|
||||
assert.equal(calls, 3);
|
||||
});
|
||||
|
||||
+104
-43
@@ -23,7 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
**By [@ImAsra](https://github.com/ImAsra), PR [#1222](https://github.com/Psychotoxical/psysonic/pull/1222)**
|
||||
|
||||
* A dismissible banner inviting you to join the Psysonic community on Discord appears after 20 hours of accumulated app use. **Join** opens the invite; dismiss it for the session, or choose **Never show again** to hide it permanently.
|
||||
* A dismissible banner inviting you to join the Psysonic community on Discord appears after 20 hours of accumulated app use. **Join** opens the invite; dismiss it for the session, or choose **Never show again** to hide it permanently. The icon renders at a consistent size on every platform, including Windows.
|
||||
|
||||
### Bulgarian translation
|
||||
|
||||
@@ -37,6 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
* Toggle **Album artists** vs **Track artists** on the Artists page — album mode lists indexed album artists; track mode includes performers from the local artist index (featured/guest credits). Star filter works in both modes; the choice persists across app restarts like **Show artist images**.
|
||||
* Letter bucket filter (`A`–`Z`, `#`, `OTHER`) runs in local SQL instead of scanning catalog chunks client-side, so late-alphabet picks load promptly on large libraries.
|
||||
* Artist name search no longer depends on query letter case for Cyrillic (and other non-ASCII) names when the local library index is enabled.
|
||||
|
||||
### CLI — relative volume and quieter scripting output
|
||||
|
||||
@@ -58,6 +59,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
* The sidebar library picker now supports **multi-select with priority ordering**: browse, search, genre and album/artist detail views aggregate across the chosen libraries and de-duplicate shared items by priority. Built for large libraries — scoped SQL uses the hot `library_id` column with covering indexes and FTS-first matching.
|
||||
* Identity matching that powers cross-library de-duplication now normalises names per shipped locale (folds German ß, Norwegian æ, French œ, Romanian ș/ț, and Cyrillic ё/й); CJK titles are matched as-is.
|
||||
* The Genres page and album browse genre filter list the full catalog on large libraries when **All libraries** is selected.
|
||||
|
||||
### Theme contributors credited in Settings
|
||||
|
||||
@@ -65,6 +67,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
* **Settings → System → Contributors** now lists community theme authors in a **Themes** sub-section alongside the **App** contributors, pulled from the theme store so it stays current as new themes are published.
|
||||
* The theme card **What's new** now shows just the latest version's notes instead of the full version history.
|
||||
* Theme author names refresh quietly from the store in the background instead of staying stale for up to 12 hours.
|
||||
|
||||
### Fullscreen player — Minimal and Immersive styles
|
||||
|
||||
@@ -91,6 +94,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
* The **Server** lyrics source now highlights lyrics word by word, so karaoke sync no longer depends on the third-party YouLyPlus backend. Requires Navidrome 0.63 or newer and lyrics that carry word timing (TTML or Enhanced LRC); anything else keeps highlighting line by line.
|
||||
* **Settings → Lyrics → Lyrics Sources** spells out those requirements, and the block now follows the standard settings sub-card layout.
|
||||
* Embedded Enhanced LRC no longer prints raw word timing codes (`<00:12.34>`) in the lyric text — those codes drive word-by-word highlighting instead.
|
||||
* FLAC, Ogg Vorbis, Opus and Speex files that store synced lyrics in the `SYNCEDLYRICS` tag show embedded lyrics again, with that tag taking priority over the plain `LYRICS` tag.
|
||||
|
||||
### Start minimized to tray
|
||||
|
||||
@@ -98,6 +103,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
* New **Start Minimized to Tray** toggle under **Settings → System → Behavior**. When enabled, the next cold start keeps the main window hidden and Psysonic runs from the system tray until you show it from the tray icon.
|
||||
* Requires **Show Tray Icon** (turning this on enables the tray automatically; hiding the tray clears the setting). The choice applies on the next launch only — toggling it in Settings does not hide the window immediately.
|
||||
* Opening the main window from the tray after a cold start renders the sidebar and main content immediately — including on Linux tiling WMs such as Hyprland — instead of leaving the sidebar or Mainstage invisible or blank until a restart.
|
||||
|
||||
### Navidrome public share links — open and play without logging in
|
||||
|
||||
@@ -105,6 +111,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
* Paste or search a Navidrome **public share** URL (`/share/{id}`) to preview the shared track list in a modal, then play the full queue with no server account — direct stream and cover URLs are resolved anonymously from the share page.
|
||||
* Share playback uses a dedicated scope so an idle server play-queue pull cannot replace the share queue while you are also logged into Navidrome. Share sessions are not restored after an app restart — the server play queue applies as usual.
|
||||
* While a share queue is active, **Save Playlist** is hidden in the queue toolbar (share tracks cannot be saved to the server); **Load Playlist** stays available. The queue **Share** button copies the original Navidrome `/share/{id}` page URL.
|
||||
|
||||
### Track lists — optional album cover thumbnails
|
||||
|
||||
@@ -112,6 +119,31 @@ 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.
|
||||
* Album detail pages skip per-row cover thumbs when the album art is already shown above the list — no duplicate image on every line.
|
||||
|
||||
### 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, alongside **None** and **Apple Music**. It resolves artwork through the standard Subsonic `getAlbumInfo2` endpoint's public image link — never an authenticated cover URL that could expose your login credentials (reported by lavioso on Discord). Needs a publicly reachable server; anyone viewing your Discord profile can see that server's public address, but nothing else.
|
||||
|
||||
### Playlist cards — play and queue from the right-click menu
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1307](https://github.com/Psychotoxical/psysonic/pull/1307)**
|
||||
|
||||
* Right-clicking a playlist card now offers **Play next** and **Add to queue** alongside **Play now**, matching the album card. All three honour offline mode and the active multi-library filter.
|
||||
|
||||
### Playlists browse — scoped header search
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1308](https://github.com/Psychotoxical/psysonic/pull/1308)**
|
||||
|
||||
* The header search field on the Playlists page now filters the list by playlist name (same scoped badge pattern as Artists / Albums), including in folder view.
|
||||
|
||||
### Artist page — add the whole discography to the queue
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1321](https://github.com/Psychotoxical/psysonic/pull/1321)**
|
||||
|
||||
* A new queue button on the artist page appends the artist's entire discography to the current queue in one click, next to Play all and Shuffle — matching what album pages already offer.
|
||||
|
||||
|
||||
## Changed
|
||||
@@ -134,13 +166,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
* With **Remember EQ per device** enabled and **System Default** selected, the equalizer now keys profiles to the active OS default output and switches when that default changes externally (Windows sound settings, Stream Deck, etc.), instead of using one shared profile for all system-default outputs.
|
||||
* On Linux/PipeWire, the active default is resolved from WirePlumber (`wpctl`) first — including Hyprpanel, pavucontrol, and `wpctl set-default` — not cpal, which can keep a stale card name even after the default sink changes. When PipeWire has already moved the playback stream to the new default, the device watcher skips a redundant stream reopen (avoids a post-switch stutter).
|
||||
* **Windows:** release builds no longer freeze on the loading splash; audio output devices on Windows and macOS use stable backend IDs with clearer labels, duplicate friendly names are disambiguated, device-change detection works again, and legacy pinned device / per-device EQ keys stored as plain names are matched after upgrade.
|
||||
|
||||
### Queue toolbar — Navidrome public share sessions
|
||||
### Player bar — build your own
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1279](https://github.com/Psychotoxical/psysonic/pull/1279)**
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1287](https://github.com/Psychotoxical/psysonic/pull/1287)**
|
||||
|
||||
* While a Navidrome public share queue is active, **Save Playlist** is hidden in the queue toolbar (share tracks cannot be saved to the server). **Load Playlist** stays available.
|
||||
* The queue **Share** button copies the original Navidrome `/share/{id}` page URL instead of a Psysonic magic-string queue payload.
|
||||
* **Settings → Personalisation → Player bar** now also hides the **stop button** and shows the **album name** under the artist (off by default; clicking it opens the album). The right-hand buttons — star rating, favorite, love, playback speed, equalizer, mini player — can be **dragged into any order** you like.
|
||||
* The section is no longer behind **Advanced**.
|
||||
|
||||
### Shuffle
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1288](https://github.com/Psychotoxical/psysonic/pull/1288)**
|
||||
|
||||
* A **shuffle toggle** in the player bar, next to the transport controls. While on, the queue is shuffled from the current track onwards — the playing track stays put — and turning it off restores the original order. It survives a restart, and the shuffled order is what your other devices and Orbit guests see, so playback stays in step everywhere. Hide the button under **Settings → Personalisation → Player bar** if you don't want it.
|
||||
|
||||
## Fixed
|
||||
|
||||
@@ -178,18 +217,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
* Queue rows that were far from the currently playing track (e.g. after starting a large playlist from the middle, or scrolling the queue) no longer stay stuck on a "…" placeholder — the queue now loads track details for whatever you scroll to, in the desktop queue panel, the mobile queue drawer, and the fullscreen "up next" overlay.
|
||||
|
||||
### Artists browse — case-insensitive search for Cyrillic names
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1237](https://github.com/Psychotoxical/psysonic/pull/1237)**
|
||||
|
||||
* Artist name search on the Artists page no longer depends on query letter case for Cyrillic (and other non-ASCII) names when the local library index is enabled — matching uses the indexed `name_sort` key with Unicode case folding instead of SQLite `LIKE` on the display name alone.
|
||||
|
||||
### Genres — full catalog on large libraries with All libraries selected
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1242](https://github.com/Psychotoxical/psysonic/pull/1242)**
|
||||
|
||||
* The Genres page and album browse genre filter no longer miss genres on large libraries when **All libraries** is selected — both now use the indexed `track_genre` SQL aggregate instead of sampling the first page of albums (regression from multi-library scope routing in PR #1241).
|
||||
|
||||
### Offline browse — on-disk-only Artists, Albums, Tracks, and Genres
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1243](https://github.com/Psychotoxical/psysonic/pull/1243)**
|
||||
@@ -204,12 +231,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
* The year filter on All Albums no longer clamps on every keystroke while typing a four-digit year — drafts commit on blur, Enter, or outside click; incomplete input reverts to the last applied value. Wheel and spinner controls are unchanged.
|
||||
|
||||
### Discord — "Server" cover art no longer exposes your login
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1246](https://github.com/Psychotoxical/psysonic/pull/1246)**
|
||||
|
||||
* The **Settings → Integrations → Discord → Cover art source → Server** option sent your server's cover URL to Discord, which republishes external image links, so anyone viewing your Rich Presence could read your username and login token. That option has been removed — cover art now comes only from **None** (app icon) or **Apple Music**, neither of which carries your credentials. Any saved **Server** setting is switched to **None**. Reported by lavioso on Discord.
|
||||
|
||||
### Album detail — favorite heart and album-level stars
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), reported by HiveMind on the Psysonic Discord, PR [#1247](https://github.com/Psychotoxical/psysonic/pull/1247)**
|
||||
@@ -256,18 +277,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
* Pausing a large queue behind a reverse proxy (e.g. Nginx) could snap the player back to an earlier track — the save was one long URL that hit the HTTP 414 limit, failed silently, and idle auto-pull restored the stale server queue.
|
||||
* Servers advertising the OpenSubsonic `formPost` extension (Navidrome) now save via POST; others retry once as POST on 414. A failed save no longer lets auto-pull overwrite playback — it resumes only after a successful save.
|
||||
|
||||
### Lyrics — timing codes showing up in the text
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1266](https://github.com/Psychotoxical/psysonic/pull/1266)**
|
||||
|
||||
* Tracks whose embedded lyrics use Enhanced LRC displayed the raw word timing codes (`<00:12.34>`) inside each line. The codes are now read as word timing instead of printed, so those lyrics also highlight word by word.
|
||||
|
||||
### Lyrics — synced lyrics in FLAC and Ogg files
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1267](https://github.com/Psychotoxical/psysonic/pull/1267)**
|
||||
|
||||
* FLAC, Ogg Vorbis, Opus and Speex files that store their synced lyrics in the `SYNCEDLYRICS` tag showed no embedded lyrics at all. That tag is now read, and it takes priority over the plain `LYRICS` tag as intended.
|
||||
|
||||
### Servers — connecting to servers behind a header gate
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1273](https://github.com/Psychotoxical/psysonic/pull/1273)**
|
||||
@@ -281,14 +290,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
* Covers that hit the gate during the brief window before headers were registered got a `403`, which was treated as "cover missing" and written a 30-minute do-not-retry marker — so on a gated server most covers stayed blank long after the gate started answering. A gate-style `403`/`401` on cover art is now treated as a recoverable hiccup (retried) rather than a permanent miss, and reconnecting a gated server clears those stale markers and re-runs the cover fill so the artwork loads.
|
||||
* For a server with both a LAN and a public address, whichever answered first after launch stuck for the whole session: if the app started off the LAN it pinned to the public address and never switched back to LAN once you got home (the public address kept answering, so the LAN address was never re-checked). The reachability tick now re-checks the LAN address first with a single attempt, so returning to the LAN upgrades the connection back to local automatically, while staying remote costs just that one probe rather than the full retry wait. The LAN/public badge also refreshes immediately when you switch the active server, instead of staying on the previous server's classification until the next poll.
|
||||
|
||||
### Windows — startup hang and audio output after per-device EQ (#1274)
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1277](https://github.com/Psychotoxical/psysonic/pull/1277)**
|
||||
|
||||
* Windows release builds no longer freeze on the loading splash after the per-device EQ changes in #1274 — boot-time imports no longer pull lucide into the auth-store chunk (circular init left `createLucideIcon` undefined).
|
||||
* Restores the lightweight cpal default-output path on Windows so startup no longer blocks on full device enumeration.
|
||||
* Audio output devices on Windows and macOS now use stable backend IDs (`Wasapi:…`) with clearer labels; duplicate friendly names are disambiguated, device-change detection works again, and legacy pinned device / per-device EQ keys stored as plain names are matched after upgrade.
|
||||
|
||||
### Windows — MSI bundle on dev and RC versions
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1278](https://github.com/Psychotoxical/psysonic/pull/1278)**
|
||||
@@ -302,6 +303,66 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
* Internet Radio playback stayed on HTML5 after v1.32, but EQ changes only reached the Rust engine used for library tracks — toggling EQ or switching presets had no effect on a live station. Radio now routes through a Web Audio 10-band graph on the same `<audio>` element when EQ is enabled; preset and slider changes update filters in place without restarting the stream.
|
||||
|
||||
### Music Network — connect errors now name their cause
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1285](https://github.com/Psychotoxical/psysonic/pull/1285)**
|
||||
|
||||
* Connecting a scrobble service could fail with only "Network error — check your connection or URL", which covers everything from a DNS failure to a blocked host, an interrupted TLS handshake or a rejected request. The underlying error is now shown alongside it, so a failing connect can be told apart from a reachability problem on your machine or network.
|
||||
|
||||
### Windows — Subsonic client id no longer `psysonic/undefined`
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1290](https://github.com/Psychotoxical/psysonic/pull/1290)**
|
||||
|
||||
* Windows release builds could send `psysonic/undefined` as the Subsonic client id (visible in **Who is listening?**) when `package.json` version was read during a circular authStore boot-chunk init — prebuild now emits a leaf `SUBSONIC_CLIENT_ID` literal and the boot-chunk guard rejects unresolved client-id templates.
|
||||
|
||||
### Albums — "Artist / Year" sorting and albums with featured guests
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1292](https://github.com/Psychotoxical/psysonic/pull/1292)**
|
||||
|
||||
* 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.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
|
||||
### Themes — smooth UI with many themes installed
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by Asra on the Psysonic Discord, PR [#1315](https://github.com/Psychotoxical/psysonic/pull/1315)**
|
||||
|
||||
* With a large number of community themes installed, every hover or playback-state change made the browser re-evaluate the CSS of every installed theme, which could slow the UI to a crawl. Only the active theme (plus the scheduler's day and night picks) participates now; the others stay dormant until applied — switching themes is unaffected.
|
||||
|
||||
### Settings — cover art toggles translated
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1319](https://github.com/Psychotoxical/psysonic/pull/1319)**
|
||||
|
||||
* The queue cover-art setting and the track-list setting's title showed English text in every language except Russian — both are translated in all languages now, and the German description states more precisely which pages show the thumbnails.
|
||||
|
||||
### Square corners — player bar and list thumbnails included
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by JU3RG on the Psysonic Discord, PR [#1320](https://github.com/Psychotoxical/psysonic/pull/1320)**
|
||||
|
||||
* The Square Corners toggle left the player bar cover and the small cover thumbnails in list rows rounded (queue, playlists, favorites, search, Random Mix). They now go square with everything else; the floating player bar's circular cover stays round by design.
|
||||
|
||||
### Duplicate server session on Navidrome
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by TheHomeGuy on the Psysonic Discord, PR [#1322](https://github.com/Psychotoxical/psysonic/pull/1322)**
|
||||
|
||||
* Native requests carried a separate User-Agent from the in-app view, so the server listed the app as two logged-in players at once. They now share one identity and show as a single session.
|
||||
|
||||
|
||||
## [1.49.0] - 2026-06-29
|
||||
|
||||
|
||||
+27
-9
@@ -16,11 +16,13 @@ Within each section, order by **user impact** (most noticeable first) — not PR
|
||||
|
||||
- The sidebar library picker now supports **multi-select with priority ordering** — browse, search, genres, and album/artist detail views aggregate across the libraries you pick and de-duplicate shared items by priority.
|
||||
- Cross-library matching normalises names per locale (German ß, Norwegian æ, French œ, Romanian ș/ț, Cyrillic ё/й); CJK titles are matched as-is.
|
||||
- The Genres page and album browse genre filter list the full catalog on large libraries when **All libraries** is selected.
|
||||
|
||||
### Navidrome public share links — listen without logging in
|
||||
|
||||
- Paste or search a Navidrome **public share** URL (`/share/{id}`) to preview the shared track list, then play the full queue with no server account.
|
||||
- Share playback stays isolated from your logged-in Navidrome queue — idle server play-queue pull cannot replace a share session while you are connected elsewhere.
|
||||
- While a share queue is active, **Save Playlist** is hidden in the queue toolbar; **Share** copies the original Navidrome `/share/{id}` page URL.
|
||||
|
||||
### Fullscreen player — Minimal, Immersive, and Prism
|
||||
|
||||
@@ -30,20 +32,33 @@ Within each section, order by **user impact** (most noticeable first) — not PR
|
||||
### Lyrics that follow the singer, word by word
|
||||
|
||||
- The **Server** lyrics source now highlights lyrics word by word as a track plays, so karaoke sync no longer needs the third-party YouLyPlus backend. It requires Navidrome 0.63 or newer and lyrics that carry word timing (TTML or Enhanced LRC files) — everything else keeps highlighting line by line. The requirements are spelled out under **Settings → Lyrics → Lyrics Sources**.
|
||||
- Embedded Enhanced LRC no longer prints raw word timing codes in the lyric text; FLAC, Ogg Vorbis, Opus, and Speex files with synced lyrics in the `SYNCEDLYRICS` tag show embedded lyrics again.
|
||||
|
||||
### Player bar — build your own, plus shuffle
|
||||
|
||||
- **Settings → Personalisation → Player bar** now also hides the **stop button** and shows the **album name** under the artist (off by default; clicking it opens the album). Star rating, favourite, love, playback speed, equalizer, and mini player can be **dragged into any order** you like — the section is no longer behind **Advanced**.
|
||||
- A **shuffle toggle** in the player bar shuffles the queue from the current track onwards while keeping the playing track in place; turning shuffle off restores the original order. It survives restarts and keeps Orbit guests in sync with the host. Hide the button from **Settings → Personalisation → Player bar** if you prefer.
|
||||
|
||||
### Track lists — optional album cover thumbnails
|
||||
|
||||
- Browse and queue track rows can show each track's **album** cover (per-disc art when the album has distinct disc covers).
|
||||
- **Settings → Appearance** adds separate toggles for queue vs browse tracklists; playlist, Favorites, and album-detail grids gain a flex-resize handle on the title column when covers are shown.
|
||||
- Album detail pages skip per-row cover thumbs when the album art is already shown above the list.
|
||||
|
||||
### Discord — Server cover art without exposing your login
|
||||
|
||||
- **Settings → Integrations → Discord → Cover art source** includes a **Server** option alongside **None** and **Apple Music**. It resolves artwork through the server's public album image link — never an authenticated URL that could expose your login credentials. Requires a publicly reachable server.
|
||||
|
||||
### Artists browse — album artists or track credits
|
||||
|
||||
- Toggle **Album artists** vs **Track artists** on the Artists page — album mode lists indexed album artists; track mode includes featured and guest performers from the local artist index. The choice persists across restarts like **Show artist images**.
|
||||
- Artist name search no longer depends on query letter case for Cyrillic and other non-ASCII names when the local library index is enabled.
|
||||
|
||||
### Theme Store — what's new on each theme
|
||||
|
||||
- Each theme card has an expandable **What's new** with per-version release notes from the author — including non-visual fixes.
|
||||
- Installed themes with an available update now appear at the top of the store list so you do not have to hunt for them.
|
||||
- **Settings → System → Contributors** lists community theme authors in a **Themes** section alongside app contributors; author names refresh quietly from the store in the background.
|
||||
|
||||
### Italian and Bulgarian — now in your language
|
||||
|
||||
@@ -52,15 +67,15 @@ Within each section, order by **user impact** (most noticeable first) — not PR
|
||||
### Start minimized to tray
|
||||
|
||||
- New **Start Minimized to Tray** toggle under **Settings → System → Behavior** — the next cold start keeps the main window hidden and Psysonic runs from the system tray until you show it from the tray icon. Requires **Show Tray Icon**; applies on the next launch only.
|
||||
- Opening the main window from the tray after a cold start renders the sidebar and main content immediately — including on Linux tiling window managers — instead of leaving them invisible or blank until a restart.
|
||||
|
||||
### Square corners — a sharper, boxier look
|
||||
|
||||
- New **Square Corners** toggle under **Settings → Appearance → Visual Options** strips the rounded corners off cards and cover art across the app — handy when a theme's rounding does not suit your album covers. Off by default; buttons, inputs, and dialogs keep the theme's corners.
|
||||
- New **Square Corners** toggle under **Settings → Appearance → Visual Options → Display** strips the rounded corners off cards and cover art across the app — handy when a theme's rounding does not suit your album covers. Off by default; buttons, inputs, and dialogs keep the theme's corners.
|
||||
|
||||
## Improved
|
||||
|
||||
- With **Remember EQ per device** on and **System Default** selected, the equalizer now keys profiles to the active OS default output and switches when that default changes outside the app (Windows sound settings, Stream Deck, PipeWire / `wpctl`, and similar). **Linux:** when PipeWire has already moved the stream to the new default, the device watcher skips a redundant reopen to avoid a post-switch stutter.
|
||||
- While a Navidrome public share queue is active, **Save Playlist** is hidden in the queue toolbar (share tracks cannot be saved to the server); the queue **Share** button copies the original Navidrome `/share/{id}` page URL.
|
||||
- With **Remember EQ per device** on and **System Default** selected, the equalizer now keys profiles to the active OS default output and switches when that default changes outside the app (Windows sound settings, Stream Deck, PipeWire / `wpctl`, and similar). **Linux:** when PipeWire has already moved the stream to the new default, the device watcher skips a redundant reopen to avoid a post-switch stutter. **Windows:** release builds no longer freeze on the loading splash; audio output devices use stable backend IDs with clearer labels, and device-change detection works again after upgrade.
|
||||
|
||||
## Fixed
|
||||
|
||||
@@ -69,9 +84,7 @@ Within each section, order by **user impact** (most noticeable first) — not PR
|
||||
- Playing a song from a playlist no longer shows the track's own cover in Now Playing when the album page would show album art — Now Playing consistently uses the album cover.
|
||||
- ReplayGain applies when stream or queue metadata resolves late; gapless auto-advance no longer leaves the playbar on the previous track.
|
||||
- Pausing a large queue behind a reverse proxy (e.g. Nginx) no longer snaps playback back to an earlier track — Navidrome saves via POST when supported, and a failed save no longer lets idle auto-pull overwrite your queue.
|
||||
- Enhanced LRC no longer prints raw word timing codes (`<00:12.34>`) in the lyric text — those codes drive word-by-word highlighting instead.
|
||||
- FLAC, Ogg Vorbis, Opus, and Speex files that store synced lyrics in the `SYNCEDLYRICS` tag show embedded lyrics again, with that tag taking priority over plain `LYRICS`.
|
||||
- **Windows:** release builds no longer freeze on the loading splash after per-device EQ changes; audio output devices use stable backend IDs with clearer labels, and device-change detection works again.
|
||||
- Internet Radio equalizer presets and slider changes now apply to live stations — not just library tracks.
|
||||
|
||||
### Offline, Now Playing, and Navidrome
|
||||
|
||||
@@ -80,19 +93,24 @@ Within each section, order by **user impact** (most noticeable first) — not PR
|
||||
|
||||
### Themes and integrations
|
||||
|
||||
- **Discord:** the **Server** cover art source has been removed — it sent your server's cover URL to Discord, which could expose your login credentials in the republished link. Cover art now comes only from **None** (app icon) or **Apple Music**; any saved **Server** setting switches to **None**.
|
||||
- Connecting a scrobble service in **Settings → Integrations → Music Network** now shows the underlying error alongside the generic network message, so a bad URL or rejected token is easier to tell apart from a reachability problem on your machine.
|
||||
- Horizontal album rails in themes with drop shadows no longer clip card shadows at the edges — scroll arrows keep working without theme authors overriding rail overflow.
|
||||
|
||||
### Browse and library
|
||||
|
||||
- Adding tracks to a playlist no longer fails past ~341 songs — writes go to the server in batches, and large-playlist edits are faster.
|
||||
- Artist name search on the Artists page no longer depends on query letter case for Cyrillic and other non-ASCII names when the local library index is enabled.
|
||||
- The Genres page and album browse genre filter no longer miss genres on large libraries when **All libraries** is selected.
|
||||
- Queue rows far from the playing track no longer stay stuck on a "…" placeholder — the queue loads details for whatever you scroll to, in the desktop panel, mobile drawer, and fullscreen **Up next** overlay.
|
||||
- The year filter on **All Albums** no longer clamps on every keystroke while you type a four-digit year — it commits on blur, Enter, or outside click.
|
||||
- Starring an album on the detail page fills the heart immediately and keeps it filled after reload; album-level stars and ratings reconcile consistently across browse and Favorites.
|
||||
- Renamed artists no longer linger as ghost entries that open to "Artist not found"; album artist links and cover tiles in **Random Albums** stay consistent after resync.
|
||||
- Custom playlist and internet radio covers uploaded in Navidrome show again on cards and detail headers.
|
||||
- Sorting albums by artist now follows the name shown on each row — featured-guest releases no longer land under the wrong artist in **Artist / Year** order.
|
||||
|
||||
### Other
|
||||
|
||||
- Servers behind a custom HTTP header gate (Cloudflare Access, Pangolin, and similar) now work for the full app — add-server errors stay on the form with a clear reason, browse and detail views load natively behind the gate, streaming and covers carry the header reliably, and returning to a LAN address upgrades the connection automatically when both LAN and public endpoints are configured.
|
||||
- Modal dialogs now announce their title to screen readers when they open.
|
||||
- **Windows:** **Who is listening?** no longer shows `psysonic/undefined` as the client id.
|
||||
|
||||
|
||||
## [1.49.0]
|
||||
|
||||
Generated
+3
-3
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1783776592,
|
||||
"narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=",
|
||||
"lastModified": 1784356753,
|
||||
"narHash": "sha256-12KrbMiWLcf8m7pCvAtZh1ZrgF85ZXDXvfR/fWTKy84=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3",
|
||||
"rev": "61b7c44c4073f0b827768aff0049561b5110ea5a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"npmDepsHash": "sha256-jl/Hwd/yENyjggAMk3jkSlVp+DE764MiraFT7AwDgpA="
|
||||
"npmDepsHash": "sha256-ORdnzlm65b2HeaUrvZehq0jGDxbdchpCzRDPD0EFVh4="
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.50.0-rc.1",
|
||||
"version": "1.50.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "psysonic",
|
||||
"version": "1.50.0-rc.1",
|
||||
"version": "1.50.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||
"@fontsource-variable/figtree": "^5.2.10",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.50.0-rc.1",
|
||||
"version": "1.50.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"check:css-imports": "node scripts/check-css-import-graph.mjs",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* Show the native window after the inline startup splash has painted.
|
||||
* When starting minimized to tray, hide the main window as early as possible
|
||||
* (visible:false may still map briefly on some Linux WMs before this script).
|
||||
* __TAURI_INTERNALS__ may not exist yet when this script first runs.
|
||||
*/
|
||||
(function startupSplashReveal() {
|
||||
@@ -12,8 +14,22 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
function tryHideMainWindow() {
|
||||
var internals = window.__TAURI_INTERNALS__;
|
||||
if (!internals || typeof internals.invoke !== 'function') return false;
|
||||
internals.invoke('plugin:window|hide', { label: 'main' }).catch(function () {});
|
||||
return true;
|
||||
}
|
||||
|
||||
function reveal(attempt) {
|
||||
if (window.__psyStartMinimizedToTray) return;
|
||||
if (window.__psyStartMinimizedToTray) {
|
||||
if (tryHideMainWindow()) return;
|
||||
if (attempt >= MAX_ATTEMPTS) return;
|
||||
window.setTimeout(function () {
|
||||
reveal(attempt + 1);
|
||||
}, 50);
|
||||
return;
|
||||
}
|
||||
if (tryShowMainWindow()) return;
|
||||
if (attempt >= MAX_ATTEMPTS) return;
|
||||
window.setTimeout(function () {
|
||||
@@ -21,6 +37,18 @@
|
||||
}, 50);
|
||||
}
|
||||
|
||||
if (window.__psyStartMinimizedToTray) {
|
||||
// Mark this synchronously, before React mounts. This deliberately does
|
||||
// not set the CSS animation-pause attribute: entrance animations may
|
||||
// still mount while the native window is hidden.
|
||||
window.__psyHidden = true;
|
||||
try {
|
||||
sessionStorage.setItem('psy-startup-tray-handled', '1');
|
||||
} catch (_err) {}
|
||||
reveal(0);
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(function () {
|
||||
requestAnimationFrame(function () {
|
||||
reveal(0);
|
||||
|
||||
@@ -27,6 +27,12 @@ const LUCIDE_SIGNALS = [
|
||||
'("download"',
|
||||
];
|
||||
|
||||
/** Subsonic client id must be a compile-time literal, not a cyclic package.json import. */
|
||||
const CLIENT_ID_SIGNALS = [
|
||||
'psysonic/undefined',
|
||||
'psysonic/${',
|
||||
];
|
||||
|
||||
let files;
|
||||
try {
|
||||
files = readdirSync(DIST).filter(f => f.endsWith('.js'));
|
||||
@@ -45,6 +51,11 @@ for (const file of files) {
|
||||
violations.push({ file, signal });
|
||||
}
|
||||
}
|
||||
for (const signal of CLIENT_ID_SIGNALS) {
|
||||
if (text.includes(signal)) {
|
||||
violations.push({ file, signal: `client-id: ${signal}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
|
||||
@@ -43,3 +43,15 @@ export const CHANGELOG_RAW: string = ${JSON.stringify(changelogRaw)};
|
||||
|
||||
writeFileSync(join(outDir, 'releaseNotesBundle.ts'), ts, 'utf8');
|
||||
console.log(`wrote src/generated/releaseNotesBundle.ts (sliced for ${version})`);
|
||||
|
||||
// Leaf module for boot-critical client id — must not import package.json at runtime
|
||||
// in the authStore chunk (circular init → psysonic/undefined on Windows WebView2).
|
||||
const appVersionTs = `/** @generated — run: node scripts/generate-release-notes-bundle.mjs */
|
||||
export const APP_VERSION = ${JSON.stringify(version)};
|
||||
|
||||
/** Subsonic REST \`c\` param and OpenSubsonic client id (\`psysonic/<version>\`). */
|
||||
export const SUBSONIC_CLIENT_ID = ${JSON.stringify(`psysonic/${version}`)};
|
||||
`;
|
||||
|
||||
writeFileSync(join(outDir, 'appVersion.ts'), appVersionTs, 'utf8');
|
||||
console.log(`wrote src/generated/appVersion.ts (${version})`);
|
||||
|
||||
Generated
+7
-7
@@ -4120,7 +4120,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic"
|
||||
version = "1.50.0-rc.1"
|
||||
version = "1.50.0"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"dasp_sample",
|
||||
@@ -4176,7 +4176,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-analysis"
|
||||
version = "1.50.0-rc.1"
|
||||
version = "1.50.0"
|
||||
dependencies = [
|
||||
"ebur128",
|
||||
"futures-util",
|
||||
@@ -4196,7 +4196,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-audio"
|
||||
version = "1.50.0-rc.1"
|
||||
version = "1.50.0"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"dasp_sample",
|
||||
@@ -4227,7 +4227,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-core"
|
||||
version = "1.50.0-rc.1"
|
||||
version = "1.50.0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"reqwest",
|
||||
@@ -4239,7 +4239,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-integration"
|
||||
version = "1.50.0-rc.1"
|
||||
version = "1.50.0"
|
||||
dependencies = [
|
||||
"discord-rich-presence",
|
||||
"futures-util",
|
||||
@@ -4257,7 +4257,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-library"
|
||||
version = "1.50.0-rc.1"
|
||||
version = "1.50.0"
|
||||
dependencies = [
|
||||
"psysonic-core",
|
||||
"psysonic-integration",
|
||||
@@ -4273,7 +4273,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-syncfs"
|
||||
version = "1.50.0-rc.1"
|
||||
version = "1.50.0"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"id3",
|
||||
|
||||
@@ -3,7 +3,7 @@ members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "1.50.0-rc.1"
|
||||
version = "1.50.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.95"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -123,7 +123,10 @@ pub fn nd_http_client() -> reqwest::Client {
|
||||
// the WebKit-side Subsonic calls end up negotiating most of the time
|
||||
// on these setups.
|
||||
reqwest::Client::builder()
|
||||
.user_agent(format!("Psysonic/{} (Tauri)", env!("CARGO_PKG_VERSION")))
|
||||
// Shared wire UA (the main WebView's User-Agent once the frontend reports
|
||||
// it at startup) so Navidrome logs these native calls under the same
|
||||
// client as the WebView instead of a second `[Psysonic]` session.
|
||||
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
|
||||
.http1_only()
|
||||
.pool_max_idle_per_host(0)
|
||||
.max_tls_version(reqwest::tls::Version::TLS_1_2)
|
||||
|
||||
@@ -413,7 +413,10 @@ struct MusicFoldersWrapper {
|
||||
|
||||
fn default_http_client() -> reqwest::Client {
|
||||
reqwest::Client::builder()
|
||||
.user_agent(format!("Psysonic/{} (Tauri)", env!("CARGO_PKG_VERSION")))
|
||||
// Shared wire UA (aligned with the main WebView at startup) so native
|
||||
// Subsonic calls share the WebView's client identity on the server
|
||||
// instead of registering a separate `[Psysonic]` session.
|
||||
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new())
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use rusqlite::types::Value as SqlValue;
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::album_compilation_filter::sql_display_artist_from;
|
||||
use crate::browse_support::overlay_album_level_starred_at;
|
||||
use crate::dto::{
|
||||
ArtistCreditMode, LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse, LibraryAlbumDto,
|
||||
@@ -338,14 +339,19 @@ fn build_layer1_scope_album(
|
||||
None,
|
||||
applied,
|
||||
)?;
|
||||
let order = deduped_album_order_sql(&req.sort);
|
||||
// Two shapes, two order clauses: the `GROUP BY t.album_id` branches need the
|
||||
// aggregates inside the sort key, the outer dedup subquery projects plain
|
||||
// columns. Sharing one string silently mis-sorted the grouped branches.
|
||||
let grouped_order = grouped_album_order_sql(&req.sort);
|
||||
let deduped_order = deduped_album_order_sql(&req.sort);
|
||||
let fast_browse = scopes.len() > 1 && skip_totals && extra_where.trim().is_empty();
|
||||
scope_merge::list_albums_layer1_filtered(
|
||||
store,
|
||||
scopes,
|
||||
&extra_where,
|
||||
&extra_params,
|
||||
&order,
|
||||
&grouped_order,
|
||||
&deduped_order,
|
||||
limit,
|
||||
offset,
|
||||
skip_totals,
|
||||
@@ -766,16 +772,32 @@ fn multi_scope_track_filter_sql(
|
||||
Ok((w.where_sql(), w.params().to_vec()))
|
||||
}
|
||||
|
||||
/// Same sort, built directly against the dedup shape's projected columns. It used
|
||||
/// to be the grouped SQL with `MAX(t.x)` string-replaced into column names — that
|
||||
/// only held while every key was a bare aggregate, and would silently mangle the
|
||||
/// display-artist expression.
|
||||
pub(crate) fn deduped_album_order_sql(sort: &[LibrarySortClause]) -> String {
|
||||
album_order_from_track_groups(sort)
|
||||
.map(|s| {
|
||||
s.replace("MAX(t.album)", "album")
|
||||
.replace("MAX(t.artist)", "artist")
|
||||
.replace("MAX(t.year)", "year")
|
||||
})
|
||||
album_order_sql(sort, &AlbumOrderCols::deduped())
|
||||
.unwrap_or_else(|| "ORDER BY album COLLATE NOCASE ASC, album_id ASC".to_string())
|
||||
}
|
||||
|
||||
/// Same sort for a `GROUP BY t.album_id` shape, with the default fallback the
|
||||
/// scoped browse needs.
|
||||
///
|
||||
/// The deduped form must NOT be used on a grouped query. Its keys are bare
|
||||
/// names, and SQLite resolves a bare name inside an expression (our display-
|
||||
/// artist `CASE`) against the FROM tables, not against a `MAX(...) AS artist`
|
||||
/// result alias — aliases only substitute when the whole ORDER BY term is a
|
||||
/// plain identifier. On a grouped query the bare column is then read from an
|
||||
/// arbitrary row of the group, so an album whose tracks carry `album_artist`
|
||||
/// unevenly sorts under whichever row SQLite happened to pick. The grouped form
|
||||
/// puts the aggregates inside the `CASE`, so there is no bare column left to
|
||||
/// resolve.
|
||||
pub(crate) fn grouped_album_order_sql(sort: &[LibrarySortClause]) -> String {
|
||||
album_order_from_track_groups(sort)
|
||||
.unwrap_or_else(|| "ORDER BY MAX(t.album) COLLATE NOCASE ASC, t.album_id ASC".to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn deduped_artist_order_sql(sort: &[LibrarySortClause]) -> String {
|
||||
order_clause(sort, EntityKind::Artist)
|
||||
.map(|s| {
|
||||
@@ -2081,16 +2103,48 @@ pub(crate) fn order_clause(sort: &[LibrarySortClause], entity: EntityKind) -> Op
|
||||
}
|
||||
}
|
||||
|
||||
/// Sort for album rows aggregated from `track t` (`GROUP BY t.album_id`).
|
||||
/// Must not reference `album a` — that alias is absent in this query shape.
|
||||
pub(crate) fn album_order_from_track_groups(sort: &[LibrarySortClause]) -> Option<String> {
|
||||
/// Column expressions the album sort orders by, per query shape.
|
||||
///
|
||||
/// `artist` must be the **displayed** album artist, not the raw track artist:
|
||||
/// the row mappers derive it with `pick_album_group_artist` (album-artist first),
|
||||
/// so ordering by `MAX(t.artist)` sorted by something the user never sees — on a
|
||||
/// featured-guest album that is "X feat. Z" while the row reads "X", which tore
|
||||
/// such albums out of their artist's year run (#1217).
|
||||
struct AlbumOrderCols {
|
||||
name: &'static str,
|
||||
artist: String,
|
||||
year: &'static str,
|
||||
}
|
||||
|
||||
impl AlbumOrderCols {
|
||||
/// Rows aggregated from `track t` (`GROUP BY t.album_id`) — the expressions
|
||||
/// must be aggregates. Must not reference `album a`: absent in this shape.
|
||||
fn grouped() -> Self {
|
||||
Self {
|
||||
name: "MAX(t.album) COLLATE NOCASE",
|
||||
artist: sql_display_artist_from("MAX(t.artist)", "MAX(t.album_artist)"),
|
||||
year: "MAX(t.year)",
|
||||
}
|
||||
}
|
||||
|
||||
/// Multi-library dedup shape: the outer select projects plain columns.
|
||||
fn deduped() -> Self {
|
||||
Self {
|
||||
name: "album COLLATE NOCASE",
|
||||
artist: sql_display_artist_from("artist", "album_artist"),
|
||||
year: "year",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn album_order_sql(sort: &[LibrarySortClause], cols: &AlbumOrderCols) -> Option<String> {
|
||||
let mut keys: Vec<String> = Vec::new();
|
||||
for s in sort {
|
||||
let col = match s.field.as_str() {
|
||||
"name" => "MAX(t.album) COLLATE NOCASE",
|
||||
"artist" => "MAX(t.artist) COLLATE NOCASE",
|
||||
"year" => "MAX(t.year)",
|
||||
"random" => "RANDOM()",
|
||||
"name" => cols.name.to_string(),
|
||||
"artist" => format!("{} COLLATE NOCASE", cols.artist),
|
||||
"year" => cols.year.to_string(),
|
||||
"random" => "RANDOM()".to_string(),
|
||||
_ => continue,
|
||||
};
|
||||
let dir = match s.dir {
|
||||
@@ -2106,6 +2160,11 @@ pub(crate) fn album_order_from_track_groups(sort: &[LibrarySortClause]) -> Optio
|
||||
}
|
||||
}
|
||||
|
||||
/// Sort for album rows aggregated from `track t` (`GROUP BY t.album_id`).
|
||||
pub(crate) fn album_order_from_track_groups(sort: &[LibrarySortClause]) -> Option<String> {
|
||||
album_order_sql(sort, &AlbumOrderCols::grouped())
|
||||
}
|
||||
|
||||
/// Allowlist of sortable fields per entity → trusted column expression.
|
||||
/// Unknown sort fields are ignored (fall back to the default order).
|
||||
pub(crate) fn sort_column(field: &str, entity: EntityKind) -> Option<&'static str> {
|
||||
@@ -3345,6 +3404,192 @@ mod tests {
|
||||
assert_eq!(ids, vec!["t2", "t1"]);
|
||||
}
|
||||
|
||||
/// #1217: the album browse sorted by `MAX(t.artist)` — the raw *track* artist —
|
||||
/// while the row displays the *album* artist. On an album with featured guests
|
||||
/// the two differ ("Alpha feat. Zulu" vs "Alpha"), so the album sorted under a
|
||||
/// name nobody could see and fell out of its artist's year run, landing after a
|
||||
/// completely different artist.
|
||||
#[test]
|
||||
fn album_artist_year_sort_keeps_featured_guest_albums_with_their_artist() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
|
||||
// Same album artist "Alpha" throughout; only the middle album carries a
|
||||
// featured-guest track credit.
|
||||
let mut solo_early = track("s1", "t1", "One", "Alpha", "Early");
|
||||
solo_early.year = Some(2000);
|
||||
|
||||
let mut feat = track("s1", "t2", "Two", "Alpha feat. Zulu", "Featured");
|
||||
feat.album_artist = Some("Alpha".into());
|
||||
feat.year = Some(2001);
|
||||
|
||||
let mut solo_late = track("s1", "t3", "Three", "Alpha", "Late");
|
||||
solo_late.year = Some(2002);
|
||||
|
||||
// A second artist that sorts between "Alpha" and "Alpha feat. Zulu".
|
||||
let mut other = track("s1", "t4", "Four", "Alpha Beta", "Other");
|
||||
other.year = Some(1999);
|
||||
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[solo_early, feat, solo_late, other])
|
||||
.unwrap();
|
||||
|
||||
let mut r = req("s1", &[EntityKind::Album]);
|
||||
r.sort = vec![
|
||||
LibrarySortClause { field: "artist".into(), dir: SortDir::Asc },
|
||||
LibrarySortClause { field: "year".into(), dir: SortDir::Asc },
|
||||
];
|
||||
let resp = run_advanced_search(&store, &r).unwrap();
|
||||
let names: Vec<&str> = resp.albums.iter().map(|a| a.name.as_str()).collect();
|
||||
|
||||
// Alpha's three albums stay together in year order; the other artist follows.
|
||||
// Before the fix the featured album sorted last, behind "Alpha Beta".
|
||||
assert_eq!(names, vec!["Early", "Featured", "Late", "Other"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn album_sorts_order_by_the_displayed_artist_in_both_query_shapes() {
|
||||
let sort = vec![LibrarySortClause { field: "artist".into(), dir: SortDir::Asc }];
|
||||
|
||||
let grouped = album_order_from_track_groups(&sort).unwrap();
|
||||
assert!(grouped.contains("MAX(t.album_artist)"), "grouped: {grouped}");
|
||||
|
||||
let deduped = deduped_album_order_sql(&sort);
|
||||
assert!(deduped.contains("album_artist"), "deduped: {deduped}");
|
||||
// The dedup shape has no aggregates to reference.
|
||||
assert!(!deduped.contains("MAX("), "deduped: {deduped}");
|
||||
}
|
||||
|
||||
/// The `GROUP BY t.album_id` shapes must never receive a sort key that leaves a
|
||||
/// bare column behind.
|
||||
///
|
||||
/// SQLite substitutes a result alias into ORDER BY **only when the whole term is
|
||||
/// a plain identifier** — `ORDER BY artist COLLATE NOCASE` does bind to
|
||||
/// `MAX(t.artist) AS artist`, but the same name *inside* our display-artist
|
||||
/// `CASE` resolves against `track` instead, and a bare column in a grouped query
|
||||
/// is read from an arbitrary row of the group. Aliasing the select list (the
|
||||
/// first attempt at this) therefore does not fix the `CASE` form: the album's
|
||||
/// sort key silently depends on which row SQLite happens to pick.
|
||||
///
|
||||
/// This is the deterministic guard. The behavioural tests below can pass by luck
|
||||
/// when that arbitrary row happens to be a favourable one; this one cannot.
|
||||
#[test]
|
||||
fn grouped_album_order_key_carries_the_aggregates_and_leaves_no_bare_column() {
|
||||
let sort = vec![LibrarySortClause { field: "artist".into(), dir: SortDir::Asc }];
|
||||
let grouped = grouped_album_order_sql(&sort);
|
||||
|
||||
assert!(grouped.contains("MAX(t.album_artist)"), "grouped: {grouped}");
|
||||
assert!(grouped.contains("MAX(t.artist)"), "grouped: {grouped}");
|
||||
// The deduped form's bare names — the exact thing that must not reach a
|
||||
// grouped query.
|
||||
assert!(
|
||||
!grouped.contains("coalesce(album_artist"),
|
||||
"bare column left in a grouped sort key: {grouped}",
|
||||
);
|
||||
assert!(
|
||||
!grouped.contains("coalesce(artist"),
|
||||
"bare column left in a grouped sort key: {grouped}",
|
||||
);
|
||||
}
|
||||
|
||||
/// Same defect, other query path: with a library scope selected, the browse
|
||||
/// runs through `scope_merge`, whose `GROUP BY` shapes now get the grouped sort
|
||||
/// key (aggregates inside the CASE) while only the dedup subquery, which really
|
||||
/// does project plain columns, gets the deduped one.
|
||||
#[test]
|
||||
fn scoped_album_artist_year_sort_also_keeps_featured_guest_albums_in_place() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
|
||||
let mut solo_early =
|
||||
scoped_track("s1", "t1", "One", "Alpha", "Early", "al_early", "lib1", None, Some(2000), None);
|
||||
solo_early.album_artist = Some("Alpha".into());
|
||||
|
||||
let mut feat = scoped_track(
|
||||
"s1", "t2", "Two", "Alpha feat. Zulu", "Featured", "al_feat", "lib1", None, Some(2001), None,
|
||||
);
|
||||
feat.album_artist = Some("Alpha".into());
|
||||
|
||||
let mut solo_late =
|
||||
scoped_track("s1", "t3", "Three", "Alpha", "Late", "al_late", "lib1", None, Some(2002), None);
|
||||
solo_late.album_artist = Some("Alpha".into());
|
||||
|
||||
let mut other =
|
||||
scoped_track("s1", "t4", "Four", "Alpha Beta", "Other", "al_other", "lib1", None, Some(1999), None);
|
||||
other.album_artist = Some("Alpha Beta".into());
|
||||
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[solo_early, feat, solo_late, other])
|
||||
.unwrap();
|
||||
|
||||
let mut r = req("s1", &[EntityKind::Album]);
|
||||
r.library_scopes = Some(vec![scope_pair("s1", "lib1")]);
|
||||
r.sort = vec![
|
||||
LibrarySortClause { field: "artist".into(), dir: SortDir::Asc },
|
||||
LibrarySortClause { field: "year".into(), dir: SortDir::Asc },
|
||||
];
|
||||
let resp = run_advanced_search(&store, &r).unwrap();
|
||||
let names: Vec<&str> = resp.albums.iter().map(|a| a.name.as_str()).collect();
|
||||
|
||||
assert_eq!(names, vec!["Early", "Featured", "Late", "Other"]);
|
||||
}
|
||||
|
||||
/// The featured album carries **two** tracks and `album_artist` on only one
|
||||
/// of them — the shape the single-track tests above cannot reach.
|
||||
///
|
||||
/// With one row per group, a bare `artist` / `album_artist` in the ORDER BY
|
||||
/// is indistinguishable from the `MAX()` aggregate, so an ORDER BY that
|
||||
/// resolves those names to table columns still sorts correctly by accident.
|
||||
/// Two rows split them: `MAX(t.album_artist)` is "Alpha" (the display
|
||||
/// artist), while the group also holds a row whose `album_artist` is NULL
|
||||
/// and whose `artist` is the feat credit. An ORDER BY reading the bare
|
||||
/// column can pick that row and sort the album under "Alpha feat. Zulu",
|
||||
/// tearing it out of Alpha's year run — #1217 all over again, on the
|
||||
/// scoped path.
|
||||
#[test]
|
||||
fn scoped_album_artist_year_sort_handles_sparse_album_artist_within_an_album() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
|
||||
let mut solo_early =
|
||||
scoped_track("s1", "t1", "One", "Alpha", "Early", "al_early", "lib1", None, Some(2000), None);
|
||||
solo_early.album_artist = Some("Alpha".into());
|
||||
|
||||
// Same album, two tracks: the album-artist row first, the feat row second
|
||||
// (and without an album_artist at all).
|
||||
let mut feat_titled = scoped_track(
|
||||
"s1", "t2a", "Two", "Alpha", "Featured", "al_feat", "lib1", None, Some(2001), None,
|
||||
);
|
||||
feat_titled.album_artist = Some("Alpha".into());
|
||||
|
||||
let mut feat_guest = scoped_track(
|
||||
"s1", "t2b", "Three", "Alpha feat. Zulu", "Featured", "al_feat", "lib1", None, Some(2001), None,
|
||||
);
|
||||
feat_guest.album_artist = None;
|
||||
|
||||
let mut solo_late =
|
||||
scoped_track("s1", "t3", "Four", "Alpha", "Late", "al_late", "lib1", None, Some(2002), None);
|
||||
solo_late.album_artist = Some("Alpha".into());
|
||||
|
||||
let mut other =
|
||||
scoped_track("s1", "t4", "Five", "Alpha Beta", "Other", "al_other", "lib1", None, Some(1999), None);
|
||||
other.album_artist = Some("Alpha Beta".into());
|
||||
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[solo_early, feat_titled, feat_guest, solo_late, other])
|
||||
.unwrap();
|
||||
|
||||
let mut r = req("s1", &[EntityKind::Album]);
|
||||
r.library_scopes = Some(vec![scope_pair("s1", "lib1")]);
|
||||
r.sort = vec![
|
||||
LibrarySortClause { field: "artist".into(), dir: SortDir::Asc },
|
||||
LibrarySortClause { field: "year".into(), dir: SortDir::Asc },
|
||||
];
|
||||
let resp = run_advanced_search(&store, &r).unwrap();
|
||||
let names: Vec<&str> = resp.albums.iter().map(|a| a.name.as_str()).collect();
|
||||
|
||||
// "Featured" displays as Alpha, so it belongs inside Alpha's year run —
|
||||
// not after "Other" under the feat credit.
|
||||
assert_eq!(names, vec!["Early", "Featured", "Late", "Other"]);
|
||||
}
|
||||
|
||||
// ── multi-library scope (WO-4b) ─────────────────────────────────────
|
||||
|
||||
fn scope_pair(server: &str, lib: &str) -> crate::dto::LibraryScopePair {
|
||||
|
||||
@@ -49,15 +49,28 @@ pub fn various_artists_label(s: &str) -> bool {
|
||||
s.trim().to_ascii_lowercase().contains("various artists")
|
||||
}
|
||||
|
||||
/// SQL mirror of [`pick_album_group_artist`] over arbitrary column *expressions*
|
||||
/// rather than a table alias — the album browse groups by album and therefore has
|
||||
/// to feed aggregates (`MAX(t.artist)`, `MAX(t.album_artist)`), while the
|
||||
/// multi-library dedup path feeds projected columns (`artist`, `album_artist`).
|
||||
/// Single source of the rule; keep in sync with [`pick_album_group_artist`].
|
||||
pub fn sql_display_artist_from(track_artist: &str, album_artist: &str) -> String {
|
||||
format!(
|
||||
"CASE WHEN trim(coalesce({aa}, '')) != '' \
|
||||
THEN trim({aa}) \
|
||||
ELSE NULLIF(trim(coalesce({ta}, '')), '') END",
|
||||
aa = album_artist,
|
||||
ta = track_artist,
|
||||
)
|
||||
}
|
||||
|
||||
/// SQL mirror of [`pick_album_group_artist`] for track-grouped browse subqueries
|
||||
/// (`la`). Used where `ORDER BY` / `COALESCE(a.artist, …)` must stay in SQL;
|
||||
/// keep both implementations in sync.
|
||||
pub fn sql_track_group_display_artist(alias: &str) -> String {
|
||||
format!(
|
||||
"CASE WHEN trim(coalesce({a}.album_artist, '')) != '' \
|
||||
THEN trim({a}.album_artist) \
|
||||
ELSE NULLIF(trim(coalesce({a}.artist, '')), '') END",
|
||||
a = alias
|
||||
sql_display_artist_from(
|
||||
&format!("{alias}.artist"),
|
||||
&format!("{alias}.album_artist"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -161,4 +174,60 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Same parity, but for the **aggregate** form the grouped album browse sorts on.
|
||||
///
|
||||
/// `map_album_from_tracks` builds a row's display artist as
|
||||
/// `pick_album_group_artist(MAX(artist), MAX(album_artist))`, so the sort key
|
||||
/// must be `sql_display_artist_from("MAX(t.artist)", "MAX(t.album_artist)")` over
|
||||
/// the same aggregates — anything else sorts the album under a name the row does
|
||||
/// not show (#1217). The multi-row groups here are the point: a single row cannot
|
||||
/// tell an aggregate apart from a bare column.
|
||||
#[test]
|
||||
fn sql_display_artist_from_aggregates_matches_pick_album_group_artist() {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
conn.execute("CREATE TABLE t (artist TEXT, album_artist TEXT)", [])
|
||||
.unwrap();
|
||||
let sql = format!(
|
||||
"SELECT {} FROM t",
|
||||
sql_display_artist_from("MAX(t.artist)", "MAX(t.album_artist)"),
|
||||
);
|
||||
|
||||
// Each case is one album's worth of tracks — album_artist deliberately sparse.
|
||||
let groups: [&[(&str, Option<&str>)]; 5] = [
|
||||
// Featured guest on one track only; the album artist is what shows.
|
||||
&[("Alpha", Some("Alpha")), ("Alpha feat. Zulu", None)],
|
||||
// Album artist on the *second* track — MAX still has to find it.
|
||||
&[("Alpha feat. Zulu", None), ("Alpha", Some("Alpha"))],
|
||||
// No album artist anywhere: falls back to the track credit.
|
||||
&[("Alpha", None), ("Alpha feat. Zulu", None)],
|
||||
// Blank strings must not count as an album artist.
|
||||
&[("Alice", Some(" ")), ("Alice feat. Bob", Some(""))],
|
||||
// Compilation: every track carries the same album artist.
|
||||
&[("Alice", Some("Various Artists")), ("Bob", Some("Various Artists"))],
|
||||
];
|
||||
|
||||
for rows in groups {
|
||||
conn.execute("DELETE FROM t", []).unwrap();
|
||||
for (artist, album_artist) in rows {
|
||||
conn.execute(
|
||||
"INSERT INTO t (artist, album_artist) VALUES (?1, ?2)",
|
||||
rusqlite::params![artist, album_artist],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let sql_out: Option<String> = conn.query_row(&sql, [], |r| r.get(0)).unwrap();
|
||||
|
||||
// The Rust side of the same decision, over the same aggregates.
|
||||
let max_artist = rows.iter().map(|(a, _)| *a).max().map(str::to_string);
|
||||
let max_album_artist = rows
|
||||
.iter()
|
||||
.filter_map(|(_, aa)| *aa)
|
||||
.max()
|
||||
.map(str::to_string);
|
||||
let rust_out = pick_album_group_artist(max_artist, max_album_artist);
|
||||
|
||||
assert_eq!(sql_out, rust_out, "rows={rows:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,6 +256,8 @@ fn list_albums_by_genre_layer1_scope(
|
||||
WHERE tg.server_id = t.server_id AND tg.track_id = t.id \
|
||||
AND tg.genre = ? COLLATE NOCASE)";
|
||||
let extra_params = vec![SqlValue::Text(genre.to_string())];
|
||||
// Plain-identifier keys, so the same string is correct for both the grouped and
|
||||
// the dedup shape (SQLite resolves a bare ORDER BY name to the result alias).
|
||||
let order = genre_multi_scope_order_sql(&req.sort);
|
||||
let (albums, total_count) = scope_merge::list_albums_layer1_filtered(
|
||||
store,
|
||||
@@ -263,6 +265,7 @@ fn list_albums_by_genre_layer1_scope(
|
||||
extra_where,
|
||||
&extra_params,
|
||||
&order,
|
||||
&order,
|
||||
limit,
|
||||
offset,
|
||||
!req.include_total,
|
||||
|
||||
@@ -266,12 +266,17 @@ pub fn list_albums(
|
||||
let limit = clamp_limit(request.limit);
|
||||
let offset = clamp_offset(request.offset);
|
||||
if crate::dto::scoped_layer1_eligible(scopes) {
|
||||
// Plain-identifier keys (`ORDER BY artist COLLATE NOCASE`), which SQLite
|
||||
// resolves to the `MAX(...) AS x` aliases in the grouped shape and to the
|
||||
// projected columns in the dedup shape — correct either way, so one string
|
||||
// serves both.
|
||||
let (albums, _) = list_albums_layer1_filtered(
|
||||
store,
|
||||
scopes,
|
||||
"",
|
||||
&[],
|
||||
&order,
|
||||
&order,
|
||||
limit,
|
||||
offset,
|
||||
true,
|
||||
@@ -385,7 +390,14 @@ pub(crate) fn list_albums_layer1_filtered(
|
||||
scopes: &[LibraryScopePair],
|
||||
extra_where: &str,
|
||||
extra_params: &[SqlValue],
|
||||
order_sql: &str,
|
||||
// `GROUP BY t.album_id` shapes. A sort key that is a plain identifier may be
|
||||
// passed as-is (SQLite resolves it to the `MAX(...) AS x` result alias), but a
|
||||
// key that wraps the name in an expression — our display-artist `CASE` — must
|
||||
// carry the aggregates itself, or the name resolves to the table column and is
|
||||
// read from an arbitrary row of the group.
|
||||
grouped_order_sql: &str,
|
||||
// Dedup shape: the outer select projects plain columns, so plain names are right.
|
||||
deduped_order_sql: &str,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
skip_totals: bool,
|
||||
@@ -411,13 +423,19 @@ pub(crate) fn list_albums_layer1_filtered(
|
||||
params.extend_from_slice(extra_params);
|
||||
|
||||
let count_sql = format!("SELECT COUNT(DISTINCT t.album_id) FROM track t WHERE {where_sql}");
|
||||
// Grouped shape: the ORDER BY must carry the aggregates itself. Aliasing the
|
||||
// sort columns is not enough — SQLite substitutes a result alias only when the
|
||||
// whole ORDER BY term is a plain identifier, so a bare name inside the
|
||||
// display-artist CASE would resolve to the table column and be read from an
|
||||
// arbitrary row of the group.
|
||||
let sql = format!(
|
||||
"SELECT t.server_id, t.album_id, MAX(t.album), MAX(t.artist), MAX(t.artist_id), \
|
||||
MAX(t.album_artist), COUNT(*), SUM(t.duration_sec), MAX(t.year), MAX(t.genre), \
|
||||
"SELECT t.server_id, t.album_id, MAX(t.album) AS album, MAX(t.artist) AS artist, \
|
||||
MAX(t.artist_id), MAX(t.album_artist) AS album_artist, COUNT(*), \
|
||||
SUM(t.duration_sec), MAX(t.year) AS year, MAX(t.genre), \
|
||||
MAX(t.cover_art_id), MAX(t.starred_at), MAX(t.synced_at) \
|
||||
FROM track t WHERE {where_sql} \
|
||||
GROUP BY t.album_id \
|
||||
{order_sql} \
|
||||
{grouped_order_sql} \
|
||||
LIMIT ? OFFSET ?"
|
||||
);
|
||||
let total = if skip_totals {
|
||||
@@ -461,13 +479,15 @@ pub(crate) fn list_albums_layer1_filtered(
|
||||
params.push(SqlValue::Text(p.library_id.clone()));
|
||||
}
|
||||
let count_sql = format!("SELECT COUNT(DISTINCT t.album_id) FROM track t WHERE {where_sql}");
|
||||
// Grouped shape — same reasoning as the single-scope branch above.
|
||||
let sql = format!(
|
||||
"SELECT t.server_id, t.album_id, MAX(t.album), MAX(t.artist), MAX(t.artist_id), \
|
||||
MAX(t.album_artist), COUNT(*), SUM(t.duration_sec), MAX(t.year), MAX(t.genre), \
|
||||
"SELECT t.server_id, t.album_id, MAX(t.album) AS album, MAX(t.artist) AS artist, \
|
||||
MAX(t.artist_id), MAX(t.album_artist) AS album_artist, COUNT(*), \
|
||||
SUM(t.duration_sec), MAX(t.year) AS year, MAX(t.genre), \
|
||||
MAX(t.cover_art_id), MAX(t.starred_at), MAX(t.synced_at) \
|
||||
FROM track t WHERE {where_sql} \
|
||||
GROUP BY t.album_id \
|
||||
{order_sql} \
|
||||
{grouped_order_sql} \
|
||||
LIMIT ? OFFSET ?"
|
||||
);
|
||||
let total = if skip_totals {
|
||||
@@ -534,7 +554,7 @@ pub(crate) fn list_albums_layer1_filtered(
|
||||
MIN(_pick) AS _pick \
|
||||
FROM per_lib GROUP BY album_dedup \
|
||||
) \
|
||||
{order_sql} \
|
||||
{deduped_order_sql} \
|
||||
LIMIT ? OFFSET ?"
|
||||
),
|
||||
);
|
||||
@@ -2538,10 +2558,11 @@ mod tests {
|
||||
let order = "ORDER BY album COLLATE NOCASE ASC, album_id ASC".to_string();
|
||||
|
||||
let bench = |label: &str, scopes: &[LibraryScopePair]| {
|
||||
let _ = list_albums_layer1_filtered(&store, scopes, "", &[], &order, 100, 0, true, false);
|
||||
let _ =
|
||||
list_albums_layer1_filtered(&store, scopes, "", &[], &order, &order, 100, 0, true, false);
|
||||
let start = Instant::now();
|
||||
let (rows, _) = list_albums_layer1_filtered(
|
||||
&store, scopes, "", &[], &order, 100, 0, true, false,
|
||||
&store, scopes, "", &[], &order, &order, 100, 0, true, false,
|
||||
)
|
||||
.unwrap();
|
||||
println!(" {label}: {:?} ({} albums)", start.elapsed(), rows.len());
|
||||
|
||||
+186
-38
@@ -391,34 +391,204 @@ pub fn run() {
|
||||
let _ = window.set_title("Psysonic (Dev)");
|
||||
}
|
||||
|
||||
// ── Dev: `--theme-watch <theme.css>` live theme reload ─────────
|
||||
// Poll a local theme.css and push it into the running app on save,
|
||||
// so theme authors get a live loop without re-importing a zip. The
|
||||
// frontend (dev only) installs it under the id in its
|
||||
// `[data-theme='<id>']` selector and applies it. Dev-builds only.
|
||||
// ── Dev: `--theme-watch <theme.css | dir>` live theme reload ───
|
||||
// Poll a local theme.css — or every `themes/*/theme.css` in a
|
||||
// cloned themes-repo checkout — and push contents into the running
|
||||
// app, so theme authors get a live loop without re-importing zips.
|
||||
// The frontend (dev only, main window) installs each payload under
|
||||
// the id in its `[data-theme='<id>']` selector; a save applies
|
||||
// live, the directory startup sweep only installs so authors can
|
||||
// switch between the checkout's themes in the UI. Dev-builds only.
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if let Some(i) = args.iter().position(|a| a == "--theme-watch") {
|
||||
match args.get(i + 1).cloned() {
|
||||
Some(path) => {
|
||||
eprintln!("[theme-watch] watching {path}");
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::SystemTime;
|
||||
use tauri::Listener;
|
||||
|
||||
// Accept a repo root (has themes/), a themes/ dir,
|
||||
// a single theme folder, or a bare theme.css.
|
||||
enum WatchTarget {
|
||||
Dir(PathBuf),
|
||||
File(PathBuf),
|
||||
}
|
||||
let root = PathBuf::from(&path);
|
||||
let target = if root.is_dir() && !root.join("theme.css").is_file() {
|
||||
if root.join("themes").is_dir() {
|
||||
WatchTarget::Dir(root.join("themes"))
|
||||
} else {
|
||||
WatchTarget::Dir(root)
|
||||
}
|
||||
} else if root.is_dir() {
|
||||
WatchTarget::File(root.join("theme.css"))
|
||||
} else {
|
||||
WatchTarget::File(root)
|
||||
};
|
||||
match &target {
|
||||
WatchTarget::Dir(d) => {
|
||||
eprintln!("[theme-watch] watching {}/*/theme.css", d.display())
|
||||
}
|
||||
WatchTarget::File(f) => {
|
||||
if !f.is_file() {
|
||||
eprintln!(
|
||||
"[theme-watch] warning: {} does not exist — nothing will load until it appears",
|
||||
f.display()
|
||||
);
|
||||
}
|
||||
eprintln!("[theme-watch] watching {}", f.display());
|
||||
}
|
||||
}
|
||||
|
||||
// Per-file state: css + manifest mtimes (gate the
|
||||
// reads while both files are unchanged) and last
|
||||
// pushed payload (change detection + ready
|
||||
// re-send). Entries only update after a successful
|
||||
// emit, so a failed push retries next tick instead
|
||||
// of being marked seen.
|
||||
type Stamps = (Option<SystemTime>, Option<SystemTime>);
|
||||
type Seen = HashMap<PathBuf, (Stamps, serde_json::Value)>;
|
||||
let seen: Arc<Mutex<Seen>> = Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
// The frontend announces its listeners (on mount
|
||||
// and after every dev-server reload) via
|
||||
// `theme-watch:ready`; re-send everything already
|
||||
// loaded so no emit is lost to a webview that
|
||||
// wasn't listening yet. Directory mode re-seeds
|
||||
// install-only (the active theme id is persisted,
|
||||
// so its CSS comes back without stealing the
|
||||
// selection); single-file mode re-applies the one
|
||||
// authored theme. Change detection keeps running
|
||||
// untouched, so a save landing mid-reload still
|
||||
// arrives as an applying `css` event.
|
||||
let ready_event = if matches!(target, WatchTarget::Dir(_)) {
|
||||
"theme-watch:css-seed"
|
||||
} else {
|
||||
"theme-watch:css"
|
||||
};
|
||||
{
|
||||
let seen = Arc::clone(&seen);
|
||||
let handle = app.handle().clone();
|
||||
app.listen("theme-watch:ready", move |_| {
|
||||
let Ok(m) = seen.lock() else { return };
|
||||
for (_, payload) in m.values() {
|
||||
let _ = handle.emit(ready_event, payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let handle = app.handle().clone();
|
||||
std::thread::spawn(move || {
|
||||
let p = std::path::PathBuf::from(&path);
|
||||
let mut last_css = String::new();
|
||||
loop {
|
||||
if let Ok(css) = std::fs::read_to_string(&p) {
|
||||
if css != last_css {
|
||||
last_css = css.clone();
|
||||
let _ = handle.emit("theme-watch:css", css);
|
||||
std::thread::spawn(move || loop {
|
||||
let files: Vec<PathBuf> = match &target {
|
||||
// Re-scan each tick so theme folders added
|
||||
// while running are picked up live.
|
||||
WatchTarget::Dir(dir) => std::fs::read_dir(dir)
|
||||
.map(|rd| {
|
||||
rd.flatten()
|
||||
.map(|e| e.path().join("theme.css"))
|
||||
.filter(|p| p.is_file())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
WatchTarget::File(f) => vec![f.clone()],
|
||||
};
|
||||
for f in files {
|
||||
let css_mtime =
|
||||
std::fs::metadata(&f).and_then(|m| m.modified()).ok();
|
||||
// The sibling manifest is part of the
|
||||
// watched payload — a version bump alone
|
||||
// must reach the UI (tester report: it
|
||||
// didn't), so its stamp gates too.
|
||||
let manifest_path =
|
||||
f.parent().map(|d| d.join("manifest.json"));
|
||||
let (manifest_exists, manifest_mtime) = match manifest_path
|
||||
.as_ref()
|
||||
.map(std::fs::metadata)
|
||||
{
|
||||
Some(Ok(md)) => (true, md.modified().ok()),
|
||||
_ => (false, None),
|
||||
};
|
||||
let stamps = (css_mtime, manifest_mtime);
|
||||
let Ok(mut m) = seen.lock() else { continue };
|
||||
// mtime gate: skip the full reads while
|
||||
// both stamps are unchanged (a None stamp
|
||||
// never matches an existing file, so
|
||||
// filesystems without mtime fall back to
|
||||
// read + compare).
|
||||
if let Some(((prev_css, prev_manifest), _)) = m.get(&f) {
|
||||
let css_fresh =
|
||||
prev_css.is_some() && *prev_css == css_mtime;
|
||||
let manifest_fresh = *prev_manifest == manifest_mtime
|
||||
&& (manifest_mtime.is_some() || !manifest_exists);
|
||||
if css_fresh && manifest_fresh {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
let Ok(css) = std::fs::read_to_string(&f) else {
|
||||
continue;
|
||||
};
|
||||
// Ship the sibling manifest's metadata so
|
||||
// the frontend can show the theme's real
|
||||
// name/author/version instead of dev
|
||||
// placeholders (and the update badge
|
||||
// stays quiet).
|
||||
let manifest = manifest_path
|
||||
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||
.and_then(|s| {
|
||||
serde_json::from_str::<serde_json::Value>(&s).ok()
|
||||
});
|
||||
let meta = |k: &str| {
|
||||
manifest
|
||||
.as_ref()
|
||||
.and_then(|v| v.get(k))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
};
|
||||
let payload = serde_json::json!({
|
||||
"css": css,
|
||||
"name": meta("name"),
|
||||
"author": meta("author"),
|
||||
"version": meta("version"),
|
||||
"description": meta("description"),
|
||||
"mode": meta("mode"),
|
||||
});
|
||||
let event = match m.get_mut(&f) {
|
||||
// mtime-only touch — restamp quietly.
|
||||
Some(entry) if entry.1 == payload => {
|
||||
entry.0 = stamps;
|
||||
continue;
|
||||
}
|
||||
// Metadata-only change (version bump
|
||||
// etc.) refreshes the install without
|
||||
// stealing the active theme.
|
||||
Some(entry)
|
||||
if entry.1.get("css").and_then(|c| c.as_str())
|
||||
== Some(css.as_str()) =>
|
||||
{
|
||||
"theme-watch:css-seed"
|
||||
}
|
||||
// First sight in directory mode
|
||||
// installs without stealing the
|
||||
// active theme; a save applies.
|
||||
None if matches!(target, WatchTarget::Dir(_)) => {
|
||||
"theme-watch:css-seed"
|
||||
}
|
||||
_ => "theme-watch:css",
|
||||
};
|
||||
if handle.emit(event, &payload).is_ok() {
|
||||
m.insert(f, (stamps, payload));
|
||||
}
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
});
|
||||
}
|
||||
None => eprintln!("[theme-watch] usage: --theme-watch <path/to/theme.css>"),
|
||||
None => eprintln!(
|
||||
"[theme-watch] usage: --theme-watch <path/to/theme.css | path/to/themes-checkout>"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -906,28 +1076,6 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
})
|
||||
.on_page_load(|webview, payload| {
|
||||
if webview.label() != "main" {
|
||||
return;
|
||||
}
|
||||
|
||||
match payload.event() {
|
||||
tauri::webview::PageLoadEvent::Started => {
|
||||
let app = webview.app_handle().clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(48));
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
crate::lib_commands::ui::mini::eval_startup_main_window_visibility(&window);
|
||||
}
|
||||
});
|
||||
}
|
||||
tauri::webview::PageLoadEvent::Finished => {
|
||||
if let Some(window) = webview.app_handle().get_webview_window("main") {
|
||||
crate::lib_commands::ui::mini::eval_startup_main_window_visibility(&window);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
greet,
|
||||
theme_import::import_theme_zip,
|
||||
|
||||
@@ -169,42 +169,6 @@ 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`).
|
||||
pub(crate) fn eval_startup_main_window_visibility(window: &tauri::WebviewWindow) {
|
||||
let js = format!(
|
||||
"(function () {{
|
||||
try {{
|
||||
if (sessionStorage.getItem('psy-startup-tray-handled') === '1') return;
|
||||
}} catch (e) {{}}
|
||||
var deferToTray = !!window.__psyStartMinimizedToTray;
|
||||
if (!deferToTray) {{
|
||||
try {{
|
||||
var raw = localStorage.getItem('psysonic-auth');
|
||||
if (raw) {{
|
||||
var state = JSON.parse(raw).state;
|
||||
deferToTray = !!(state && state.startMinimizedToTray && state.showTrayIcon !== false);
|
||||
}}
|
||||
}} catch (e) {{}}
|
||||
}}
|
||||
var internals = window.__TAURI_INTERNALS__;
|
||||
if (deferToTray) {{
|
||||
{pause}
|
||||
try {{ sessionStorage.setItem('psy-startup-tray-handled', '1'); }} catch (e) {{}}
|
||||
if (internals && typeof internals.invoke === 'function') {{
|
||||
internals.invoke('plugin:window|hide', {{ label: 'main' }}).catch(function () {{}});
|
||||
}}
|
||||
return;
|
||||
}}
|
||||
if (internals && typeof internals.invoke === 'function') {{
|
||||
internals.invoke('plugin:window|show', {{ label: 'main' }}).catch(function () {{}});
|
||||
}}
|
||||
}})();",
|
||||
pause = PAUSE_RENDERING_JS.trim(),
|
||||
);
|
||||
let _ = window.eval(&js);
|
||||
}
|
||||
|
||||
/// Resume rendering and bring the main window to the foreground.
|
||||
pub(crate) fn restore_main_window(main: &tauri::WebviewWindow) -> Result<(), String> {
|
||||
main.eval(RESUME_RENDERING_JS).map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.50.0-rc.1",
|
||||
"version": "1.50.0",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -78,7 +78,7 @@
|
||||
"installMode": "currentUser"
|
||||
},
|
||||
"wix": {
|
||||
"version": "1.50.0.10001"
|
||||
"version": "1.50.0.65534"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+75
-18
@@ -4,17 +4,18 @@ import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { useLyricsStore } from './store/lyricsStore';
|
||||
import { useThemeStore } from './store/themeStore';
|
||||
import { useInstalledThemesStore } from './store/installedThemesStore';
|
||||
import { syncInjectedThemes } from '@/lib/themes/themeInjection';
|
||||
import { gateInjectedThemes, syncInjectedThemes } from '@/lib/themes/themeInjection';
|
||||
import { useThemeScheduler } from '@/app/hooks/useThemeScheduler';
|
||||
import { useFontStore } from './store/fontStore';
|
||||
import { getWindowKind } from './app/windowKind';
|
||||
import { showToast } from '@/lib/dom/toast';
|
||||
import MiniPlayerApp from './app/MiniPlayerApp';
|
||||
import MainApp from './app/MainApp';
|
||||
|
||||
export default function App() {
|
||||
// Re-subscribe so themeStore changes trigger a re-render (the value itself
|
||||
// is consumed via useThemeScheduler / data-theme attribute below).
|
||||
useThemeStore(s => s.theme);
|
||||
const theme = useThemeStore(s => s.theme);
|
||||
const themeDay = useThemeStore(s => s.themeDay);
|
||||
const themeNight = useThemeStore(s => s.themeNight);
|
||||
const effectiveTheme = useThemeScheduler();
|
||||
const font = useFontStore(s => s.font);
|
||||
const buttonSize = useThemeStore(s => s.buttonSize);
|
||||
@@ -31,28 +32,84 @@ export default function App() {
|
||||
// active community theme is painted without a network round-trip.
|
||||
useEffect(() => {
|
||||
syncInjectedThemes(installedThemes);
|
||||
}, [installedThemes]);
|
||||
// Only the active slots participate in style matching (inactive styles
|
||||
// get media="not all" — see gateInjectedThemes). Runs in the same effects
|
||||
// flush as the data-theme attribute below, so a switch paints with both
|
||||
// applied.
|
||||
gateInjectedThemes([theme, themeDay, themeNight, effectiveTheme]);
|
||||
}, [installedThemes, theme, themeDay, themeNight, effectiveTheme]);
|
||||
|
||||
// Dev only: `--theme-watch <theme.css>` (debug builds) pushes a local theme's
|
||||
// CSS in on every save. Install it under the id in its `[data-theme='<id>']`
|
||||
// selector and apply it — the syncInjectedThemes effect above re-injects, so
|
||||
// authoring is live without re-importing a zip. Never wired in production.
|
||||
// Dev only: `--theme-watch <theme.css | dir>` (debug builds) pushes local
|
||||
// theme CSS (+ sibling manifest.json metadata) in on every save. Each
|
||||
// payload is installed under the id in its `[data-theme='<id>']` selector —
|
||||
// the syncInjectedThemes effect above re-injects, so authoring is live
|
||||
// without re-importing a zip. `theme-watch:css` also applies (single file,
|
||||
// or a save in a watched directory); `theme-watch:css-seed` only installs
|
||||
// (directory startup sweep), so authors can switch between a themes-repo
|
||||
// checkout's themes in the UI. Both windows subscribe: dev-seeded themes
|
||||
// are session-only (excluded from persistence), so the mini player cannot
|
||||
// get them through the cross-window storage sync. Never wired in
|
||||
// production.
|
||||
useEffect(() => {
|
||||
if (!import.meta.env.DEV) return;
|
||||
let unlisten: (() => void) | undefined;
|
||||
void import('@tauri-apps/api/event').then(({ listen }) => {
|
||||
const sub = listen<string>('theme-watch:css', ({ payload }) => {
|
||||
const id = payload.match(/\[data-theme=['"]([^'"]+)['"]\]/)?.[1];
|
||||
const unlisteners: (() => void)[] = [];
|
||||
void import('@tauri-apps/api/event').then(({ listen, emit }) => {
|
||||
type WatchPayload = {
|
||||
css: string;
|
||||
name?: string | null;
|
||||
author?: string | null;
|
||||
version?: string | null;
|
||||
description?: string | null;
|
||||
mode?: string | null;
|
||||
};
|
||||
const install = (payload: WatchPayload, apply: boolean) => {
|
||||
const css = payload?.css;
|
||||
const id = css?.match(/\[data-theme=['"]([^'"]+)['"]\]/)?.[1];
|
||||
if (!id) return;
|
||||
// Manifest metadata wins, then a store-installed copy's, then dev
|
||||
// placeholders — watched themes keep their real identity, and only
|
||||
// the CSS is the live payload. Fresh seeds are marked dev
|
||||
// (session-only, never persisted); a store-installed theme being
|
||||
// watched keeps its persisted entry.
|
||||
const prev = useInstalledThemesStore.getState().getInstalled(id);
|
||||
useInstalledThemesStore.getState().install({
|
||||
id, name: id, author: 'dev', version: '0.0.0', description: '', mode: 'dark', css: payload, installedAt: Date.now(),
|
||||
id,
|
||||
name: payload.name ?? prev?.name ?? id,
|
||||
author: payload.author ?? prev?.author ?? 'dev',
|
||||
version: payload.version ?? prev?.version ?? '0.0.0',
|
||||
description: payload.description ?? prev?.description ?? '',
|
||||
mode: payload.mode === 'light' || payload.mode === 'dark'
|
||||
? payload.mode
|
||||
: prev?.mode ?? 'dark',
|
||||
tags: prev?.tags,
|
||||
css,
|
||||
installedAt: prev?.installedAt ?? Date.now(),
|
||||
dev: prev ? prev.dev ?? false : true,
|
||||
});
|
||||
useThemeStore.getState().setTheme(id);
|
||||
});
|
||||
if (apply) {
|
||||
useThemeStore.getState().setTheme(id);
|
||||
// Confirm the save reached the app — theme authors watch the app
|
||||
// window, not the terminal. Main window only: the mini player
|
||||
// subscribes too and would double the toast. Dev-only path, so the
|
||||
// string stays untranslated (same as the rest of theme-watch).
|
||||
if (getWindowKind() !== 'mini') {
|
||||
showToast(`Theme synced: ${payload.name ?? prev?.name ?? id}`, 2500, 'success');
|
||||
}
|
||||
}
|
||||
};
|
||||
const subs = [
|
||||
listen<WatchPayload>('theme-watch:css', ({ payload }) => install(payload, true)),
|
||||
listen<WatchPayload>('theme-watch:css-seed', ({ payload }) => install(payload, false)),
|
||||
];
|
||||
// Guard the mocked-in-tests case where listen() isn't a promise.
|
||||
if (sub && typeof sub.then === 'function') sub.then(u => { unlisten = u; });
|
||||
for (const sub of subs) {
|
||||
if (sub && typeof sub.then === 'function') void sub.then(u => { unlisteners.push(u); });
|
||||
}
|
||||
// Announce the attached listeners (again after every dev-server reload)
|
||||
// so the watcher (re-)sends current contents — no lost first emit.
|
||||
void emit('theme-watch:ready').catch(() => {});
|
||||
}).catch(() => {});
|
||||
return () => unlisten?.();
|
||||
return () => { unlisteners.forEach(u => u()); };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { setLoggingMode, setSubsonicWireUserAgent } from '@/lib/api/platformShel
|
||||
import { getWindowKind } from './windowKind';
|
||||
import { migrateThemeSelection } from '@/lib/themes/themeMigration';
|
||||
import { getScheduledTheme, useThemeStore } from '../store/themeStore';
|
||||
import { syncInjectedThemes } from '@/lib/themes/themeInjection';
|
||||
import { gateInjectedThemes, syncInjectedThemes } from '@/lib/themes/themeInjection';
|
||||
import { useInstalledThemesStore, type InstalledTheme } from '../store/installedThemesStore';
|
||||
|
||||
/** Sync backend HTTP User-Agent from the main webview once at startup. */
|
||||
@@ -69,6 +69,13 @@ export function applyThemeAtStartup(): void {
|
||||
const s = parsed.state;
|
||||
if (!s) return;
|
||||
syncInjectedThemes(readInstalledThemes());
|
||||
// Gate to the active slots right away so the first style recalcs don't
|
||||
// walk every installed theme's rules (App's effect re-gates after mount).
|
||||
gateInjectedThemes([
|
||||
String(s.theme ?? 'mocha'),
|
||||
String(s.themeDay ?? 'latte'),
|
||||
String(s.themeNight ?? 'mocha'),
|
||||
]);
|
||||
// First-frame best effort for the "follow system" mode: the Web media query
|
||||
// is sync here (the native Tauri theme resolves only after mount, when the
|
||||
// App effect re-applies the effective theme). Unreliable on Linux WebKitGTK,
|
||||
|
||||
@@ -200,8 +200,14 @@ const CONTRIBUTOR_ENTRIES = [
|
||||
'Queue toolbar — Navidrome public share: hide Save Playlist, copy original share URL (PR #1279)',
|
||||
'Windows startup hang after #1274 — boot barrel split, stable Wasapi device IDs, legacy EQ key match (PR #1277)',
|
||||
'Windows MSI bundle on dev/RC versions — numeric WiX mapping; album easter-egg import chunk (PR #1278)',
|
||||
'Windows release builds — Subsonic client id no longer psysonic/undefined in Who is listening (PR #1290)',
|
||||
'Track lists — optional album cover thumbnails via standard cover pipeline; queue rows use playback scope (PR #1280)',
|
||||
'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)',
|
||||
'Playlist cards — Play next and Add to queue from the right-click menu, matching album cards (PR #1307)',
|
||||
'Playlists browse — scoped header search by playlist name (PR #1308)',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -424,6 +430,7 @@ const CONTRIBUTOR_ENTRIES = [
|
||||
'Settings → System: community theme authors credited in a Themes sub-section; theme card What\'s new shows the latest version only (PR #1248)',
|
||||
'Fullscreen player style — selectable Minimal or Immersive view, with the artist backdrop, cover-derived accent, and rail/Apple lyrics (PR #1249)',
|
||||
'Word-by-word lyrics from the server via OpenSubsonic songLyrics v2, no third-party backend needed (PR #1265)',
|
||||
'Artist page — add the whole discography to the queue (PR #1321)',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -477,6 +484,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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useAlbumDetailBack } from '@/features/album';
|
||||
import {
|
||||
ArrowLeft, Camera, Check, HardDriveDownload, Heart,
|
||||
Loader2, Play, Radio, Share2, Shuffle, Users,
|
||||
ListPlus, Loader2, Play, Radio, Share2, Shuffle, Users,
|
||||
} from 'lucide-react';
|
||||
import type { SubsonicAlbum, SubsonicArtist, SubsonicArtistInfo } from '@/lib/api/subsonicTypes';
|
||||
import { useOfflineStore } from '@/features/offline';
|
||||
@@ -35,6 +35,7 @@ interface Props {
|
||||
toggleStar: () => Promise<void>;
|
||||
handlePlayAll: () => void;
|
||||
handleShuffle: () => void;
|
||||
handleEnqueueAll: () => void;
|
||||
handleStartRadio: () => void;
|
||||
handleShareArtist: () => void;
|
||||
handleImageUpload: (e: React.ChangeEvent<HTMLInputElement>) => Promise<void>;
|
||||
@@ -100,7 +101,7 @@ function ArtistHeaderBg({ url, position }: { url: string; position?: string }) {
|
||||
|
||||
export default function ArtistDetailHero({
|
||||
artist, id, albums, info, isStarred, artistEntityRating, handleArtistEntityRating,
|
||||
toggleStar, handlePlayAll, handleShuffle, handleStartRadio, handleShareArtist,
|
||||
toggleStar, handlePlayAll, handleShuffle, handleEnqueueAll, handleStartRadio, handleShareArtist,
|
||||
handleImageUpload, playAllLoading, radioLoading, uploading,
|
||||
openedLink, openLink,
|
||||
coverId, coverRef, coverRevision, headerCoverFailed, setHeaderCoverFailed,
|
||||
@@ -302,6 +303,15 @@ export default function ArtistDetailHero({
|
||||
{playAllLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Shuffle size={16} />}
|
||||
{!isMobile && <span className="compact-btn-label">{t('artistDetail.shuffle')}</span>}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={handleEnqueueAll}
|
||||
disabled={playAllLoading}
|
||||
aria-label={t('artistDetail.enqueue')}
|
||||
{...tooltipAttrs(t('artistDetail.enqueueTooltip'))}
|
||||
>
|
||||
<ListPlus size={16} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
import { useArtistDetailData } from '@/features/artist/hooks/useArtistDetailData';
|
||||
import { useArtistSimilarArtists } from '@/features/artist/hooks/useArtistSimilarArtists';
|
||||
import {
|
||||
runArtistDetailPlayAll, runArtistDetailPlayTopSong, runArtistDetailShuffle, runArtistDetailStartRadio,
|
||||
runArtistDetailPlayAll, runArtistDetailPlayTopSong, runArtistDetailShuffle,
|
||||
runArtistDetailStartRadio, runArtistDetailEnqueueAll,
|
||||
} from '@/features/artist/utils/runArtistDetailPlay';
|
||||
import { useOfflineBrowseContext } from '@/features/offline';
|
||||
import { offlineActionPolicy } from '@/features/offline';
|
||||
@@ -113,6 +114,9 @@ export default function ArtistDetail() {
|
||||
const handleShuffle = () => runArtistDetailShuffle({
|
||||
albums, serverId: activeServerId, setPlayAllLoading, playTrack,
|
||||
});
|
||||
const handleEnqueueAll = () => runArtistDetailEnqueueAll({
|
||||
albums, serverId: activeServerId, setPlayAllLoading, enqueue,
|
||||
});
|
||||
const handleStartRadio = () => {
|
||||
if (!artist) return;
|
||||
return runArtistDetailStartRadio({ artist, t, setRadioLoading, playTrack, enqueue });
|
||||
@@ -270,6 +274,7 @@ export default function ArtistDetail() {
|
||||
toggleStar={toggleStar}
|
||||
handlePlayAll={handlePlayAll}
|
||||
handleShuffle={handleShuffle}
|
||||
handleEnqueueAll={handleEnqueueAll}
|
||||
handleStartRadio={handleStartRadio}
|
||||
handleShareArtist={handleShareArtist}
|
||||
handleImageUpload={handleImageUpload}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
|
||||
import * as offlineMediaResolve from '@/features/offline';
|
||||
import { fetchArtistDetailTracks, runArtistDetailPlayTopSong } from '@/features/artist/utils/runArtistDetailPlay';
|
||||
import {
|
||||
fetchArtistDetailTracks, runArtistDetailPlayTopSong, runArtistDetailEnqueueAll,
|
||||
} from '@/features/artist/utils/runArtistDetailPlay';
|
||||
|
||||
vi.mock('@/features/offline', () => ({
|
||||
resolveAlbum: vi.fn(),
|
||||
@@ -99,3 +101,39 @@ describe('runArtistDetailPlayTopSong', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runArtistDetailEnqueueAll', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('enqueues the whole ordered discography', async () => {
|
||||
resolveAlbumMock
|
||||
.mockResolvedValueOnce({
|
||||
album: albums[1],
|
||||
songs: [{ id: 't1', title: 'One', artist: 'A', album: 'A', albumId: 'al-1', duration: 100, track: 1 }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
album: albums[0],
|
||||
songs: [{ id: 't2', title: 'Two', artist: 'A', album: 'B', albumId: 'al-2', duration: 100, track: 1 }],
|
||||
});
|
||||
const enqueue = vi.fn();
|
||||
const setPlayAllLoading = vi.fn();
|
||||
|
||||
await runArtistDetailEnqueueAll({ albums, serverId: 'srv-1', setPlayAllLoading, enqueue });
|
||||
|
||||
expect(enqueue).toHaveBeenCalledTimes(1);
|
||||
expect(enqueue.mock.calls[0][0].map((t: { id: string }) => t.id)).toEqual(['t1', 't2']);
|
||||
expect(setPlayAllLoading).toHaveBeenCalledWith(true);
|
||||
expect(setPlayAllLoading).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it('is a no-op with no albums', async () => {
|
||||
const enqueue = vi.fn();
|
||||
|
||||
await runArtistDetailEnqueueAll({ albums: [], serverId: 'srv-1', setPlayAllLoading: vi.fn(), enqueue });
|
||||
|
||||
expect(resolveAlbumMock).not.toHaveBeenCalled();
|
||||
expect(enqueue).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getSimilarSongs2, getTopSongs } from '@/lib/api/subsonicArtists';
|
||||
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import type { Track } from '@/lib/media/trackTypes';
|
||||
import { songToTrack } from '@/lib/media/songToTrack';
|
||||
import { runBulkPlayAll, runBulkShuffle } from '@/features/playback/utils/playback/runBulkPlay';
|
||||
import { runBulkPlayAll, runBulkShuffle, runBulkEnqueue } from '@/features/playback/utils/playback/runBulkPlay';
|
||||
import { resolveAlbum, resolveMediaServerId } from '@/features/offline';
|
||||
|
||||
/** Ordered artist discography tracks for play-all / shuffle (network or local bytes). */
|
||||
@@ -82,6 +82,24 @@ export async function runArtistDetailShuffle(deps: RunArtistDetailPlayDeps): Pro
|
||||
});
|
||||
}
|
||||
|
||||
export interface RunArtistDetailEnqueueDeps {
|
||||
albums: SubsonicAlbum[];
|
||||
serverId?: string | null;
|
||||
setPlayAllLoading: (v: boolean) => void;
|
||||
enqueue: (tracks: Track[]) => void;
|
||||
}
|
||||
|
||||
/** Append the whole artist discography to the play queue. */
|
||||
export async function runArtistDetailEnqueueAll(deps: RunArtistDetailEnqueueDeps): Promise<void> {
|
||||
const { albums, serverId, setPlayAllLoading, enqueue } = deps;
|
||||
if (albums.length === 0) return;
|
||||
await runBulkEnqueue({
|
||||
fetchTracks: () => fetchArtistDetailTracks(albums, serverId),
|
||||
setLoading: setPlayAllLoading,
|
||||
enqueue,
|
||||
});
|
||||
}
|
||||
|
||||
export interface RunArtistDetailStartRadioDeps {
|
||||
artist: SubsonicArtist;
|
||||
t: TFunction;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Play, ChevronRight, FolderTree, ListMusic, Trash2 } from 'lucide-react';
|
||||
import { Play, ChevronsRight, ChevronRight, FolderTree, ListMusic, ListPlus, Trash2 } from 'lucide-react';
|
||||
import type { SubsonicPlaylist } from '@/lib/api/subsonicTypes';
|
||||
import { usePlaylistStore } from '@/features/playlist';
|
||||
import { usePlaylistStore, resolvePlaylistTracks } from '@/features/playlist';
|
||||
import { MultiPlaylistToPlaylistSubmenu, SinglePlaylistToPlaylistSubmenu } from '@/features/contextMenu/components/PlaylistToPlaylistSubmenus';
|
||||
import MoveToFolderSubmenu from '@/features/contextMenu/components/MoveToFolderSubmenu';
|
||||
import type { ContextMenuItemsProps } from '@/features/contextMenu/components/contextMenuItemTypes';
|
||||
@@ -9,6 +9,7 @@ import type { ContextMenuItemsProps } from '@/features/contextMenu/components/co
|
||||
export default function PlaylistContextItems(props: ContextMenuItemsProps) {
|
||||
const {
|
||||
type, item, closeContextMenu,
|
||||
playTrack, playNext, enqueue,
|
||||
playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave,
|
||||
playlistSongIds, setPlaylistSongIds,
|
||||
handleAction,
|
||||
@@ -23,15 +24,26 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="context-menu-item" onClick={() => handleAction(async () => {
|
||||
const { playPlaylistById } = await import('@/features/playlist');
|
||||
try {
|
||||
await playPlaylistById(playlist.id);
|
||||
} catch {
|
||||
// Network/load failure — leave playback untouched rather than crash.
|
||||
}
|
||||
const tracks = await resolvePlaylistTracks(playlist.id);
|
||||
if (tracks.length === 0) return;
|
||||
playTrack(tracks[0], tracks);
|
||||
})}>
|
||||
<Play size={14} /> {t('contextMenu.playNow')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(async () => {
|
||||
const tracks = await resolvePlaylistTracks(playlist.id);
|
||||
if (tracks.length === 0) return;
|
||||
playNext(tracks);
|
||||
})}>
|
||||
<ChevronsRight size={14} /> {t('contextMenu.playNext')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(async () => {
|
||||
const tracks = await resolvePlaylistTracks(playlist.id);
|
||||
if (tracks.length === 0) return;
|
||||
enqueue(tracks);
|
||||
})}>
|
||||
<ListPlus size={14} /> {t('contextMenu.addToQueue')}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
{offlinePolicy.canAddToPlaylist && (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// The banner icon shipped with a `viewBox` but no width/height and no CSS rule
|
||||
// for its class, so it had no intrinsic size at all. WebKitGTK happened to
|
||||
// render it small; Chromium (WebView2, i.e. Windows) fell back to the 300x150
|
||||
// default replaced-element size and the icon swallowed the bar. Pin the explicit
|
||||
// dimensions — CSS alone cannot be asserted here, and the attributes are what
|
||||
// make the icon correct even before the stylesheet applies.
|
||||
|
||||
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import DiscordBanner from './DiscordBanner';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { resetAuthStore } from '@/test/helpers/storeReset';
|
||||
|
||||
const THRESHOLD_MS = 20 * 60 * 60 * 1000;
|
||||
|
||||
describe('DiscordBanner', () => {
|
||||
beforeEach(() => {
|
||||
resetAuthStore();
|
||||
useAuthStore.setState({ discordBannerAccumulatedUsageMs: THRESHOLD_MS });
|
||||
});
|
||||
afterEach(resetAuthStore);
|
||||
|
||||
it('sizes its icon explicitly instead of leaving it intrinsic', () => {
|
||||
const { container } = render(<DiscordBanner />);
|
||||
const icon = container.querySelector('.discord-banner-icon');
|
||||
|
||||
expect(icon).not.toBeNull();
|
||||
expect(icon?.getAttribute('width')).toBe('18');
|
||||
expect(icon?.getAttribute('height')).toBe('18');
|
||||
});
|
||||
|
||||
it('keeps the icon inside the banner row next to the message and join button', () => {
|
||||
const { container } = render(<DiscordBanner />);
|
||||
const left = container.querySelector('.discord-banner-left');
|
||||
|
||||
expect(left?.querySelector('.discord-banner-icon')).not.toBeNull();
|
||||
expect(left?.querySelector('.discord-banner-text')).not.toBeNull();
|
||||
expect(left?.querySelector('.discord-banner-join')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,17 @@ export default function DiscordBanner() {
|
||||
return (
|
||||
<div className='discord-banner' role='region' aria-label={t('discordBanner.ariaLabel')}>
|
||||
<div className='discord-banner-left'>
|
||||
<svg className='discord-banner-icon' viewBox='0 0 24 24' fill='currentColor' aria-hidden='true'>
|
||||
{/* Explicit width/height, not just a viewBox: an SVG with neither has no
|
||||
intrinsic size, and Chromium (WebView2) then falls back to the 300x150
|
||||
default replaced-element size. */}
|
||||
<svg
|
||||
className='discord-banner-icon'
|
||||
width='18'
|
||||
height='18'
|
||||
viewBox='0 0 24 24'
|
||||
fill='currentColor'
|
||||
aria-hidden='true'
|
||||
>
|
||||
<path d='M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z' />
|
||||
</svg>
|
||||
<span className='discord-banner-text'>{t('discordBanner.message')}</span>
|
||||
|
||||
@@ -345,7 +345,12 @@ export default function Home() {
|
||||
// sidebar) instead of leaving the user on nothing.
|
||||
const allSectionsHidden = homeSections.every(s => !s.visible);
|
||||
return (
|
||||
<div className={`animate-fade-in${homeLiteArtworkFx ? ' home-lite-artwork' : ''}${homeFlatArtworkClip ? ' home-flat-artwork-clip' : ''}`}>
|
||||
<div
|
||||
className={[
|
||||
homeLiteArtworkFx ? 'home-lite-artwork' : '',
|
||||
homeFlatArtworkClip ? 'home-flat-artwork-clip' : '',
|
||||
].filter(Boolean).join(' ') || undefined}
|
||||
>
|
||||
{!loading && !perfFlags.disableMainstageHero && isVisible('hero') && <Hero albums={heroAlbums} />}
|
||||
|
||||
<div className="content-body" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function PlayerBar() {
|
||||
const {
|
||||
currentTrack, currentRadio, isPlaying, volume,
|
||||
togglePlay, next, previous, setVolume,
|
||||
stop, toggleRepeat, repeatMode, toggleFullscreen,
|
||||
stop, toggleRepeat, repeatMode, toggleShuffleMode, shuffleMode, toggleFullscreen,
|
||||
networkLoved, toggleNetworkLove,
|
||||
starredOverrides,
|
||||
userRatingOverrides,
|
||||
@@ -68,6 +68,8 @@ export default function PlayerBar() {
|
||||
stop: s.stop,
|
||||
toggleRepeat: s.toggleRepeat,
|
||||
repeatMode: s.repeatMode,
|
||||
toggleShuffleMode: s.toggleShuffleMode,
|
||||
shuffleMode: s.shuffleMode,
|
||||
toggleFullscreen: s.toggleFullscreen,
|
||||
networkLoved: s.networkLoved,
|
||||
toggleNetworkLove: s.toggleNetworkLove,
|
||||
@@ -217,6 +219,8 @@ export default function PlayerBar() {
|
||||
next={next}
|
||||
toggleRepeat={toggleRepeat}
|
||||
repeatMode={repeatMode}
|
||||
toggleShuffleMode={toggleShuffleMode}
|
||||
shuffleMode={shuffleMode}
|
||||
playPauseBind={playPauseBind}
|
||||
scheduleRemaining={scheduleRemaining}
|
||||
transportAnchorRef={transportAnchorRef}
|
||||
|
||||
@@ -73,6 +73,11 @@ export function PlayerTrackInfo({
|
||||
const layoutItems = usePlayerBarLayoutStore(s => s.items);
|
||||
const isLayoutVisible = (id: PlayerBarLayoutItemId) =>
|
||||
layoutItems.find(i => i.id === id)?.visible !== false;
|
||||
const trackInfoMode = usePlayerBarLayoutStore(s => s.trackInfoMode);
|
||||
// Radio has no album, and a preview shows the previewed track's own meta.
|
||||
const albumLine = trackInfoMode === 'titleAlbum' && !isRadio && !showPreviewMeta
|
||||
? currentTrack?.album
|
||||
: undefined;
|
||||
const offlineBrowseActive = useOfflineBrowseContext().active;
|
||||
const playerPolicy = offlineActionPolicy('playerBar', offlineBrowseActive);
|
||||
|
||||
@@ -177,6 +182,14 @@ export function PlayerTrackInfo({
|
||||
onClick={() => !isRadio && !showPreviewMeta && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||
/>
|
||||
)}
|
||||
{albumLine && (
|
||||
<MarqueeText
|
||||
text={albumLine}
|
||||
className="player-track-album"
|
||||
style={{ cursor: currentTrack?.albumId ? 'pointer' : 'default' }}
|
||||
onClick={() => currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
|
||||
/>
|
||||
)}
|
||||
{currentTrack && !isRadio && !showPreviewMeta && isLayoutVisible('starRating') && playerPolicy.canRate && (
|
||||
<StarRating
|
||||
value={userRatingOverrides[currentTrack.id] ?? currentTrack.userRating ?? 0}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Blend, Moon, Pause, Play, Repeat, Repeat1, SkipBack, SkipForward, Square, Sunrise } from 'lucide-react';
|
||||
import { Blend, Moon, Pause, Play, Repeat, Repeat1, Shuffle, SkipBack, SkipForward, Square, Sunrise } from 'lucide-react';
|
||||
import { audioPreviewStop, audioPreviewStopSilent } from '@/lib/api/audio';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { PlayerState } from '@/features/playback/store/playerStoreTypes';
|
||||
@@ -8,6 +8,7 @@ import { usePreviewStore } from '@/features/playback/store/previewStore';
|
||||
import PlaybackScheduleBadge from '@/features/playback/components/PlaybackScheduleBadge';
|
||||
import { usePlaybackDelayPress } from '@/features/playback/hooks/usePlaybackDelayPress';
|
||||
import { usePlaybackScheduleRemaining } from '@/features/playback/utils/playbackScheduleFormat';
|
||||
import { usePlayerBarLayoutStore } from '@/features/playback/store/playerBarLayoutStore';
|
||||
|
||||
type RepeatMode = PlayerState['repeatMode'];
|
||||
type PlayPauseBind = ReturnType<typeof usePlaybackDelayPress>['playPauseBind'];
|
||||
@@ -22,6 +23,8 @@ interface Props {
|
||||
next: () => void;
|
||||
toggleRepeat: () => void;
|
||||
repeatMode: RepeatMode;
|
||||
toggleShuffleMode: () => void;
|
||||
shuffleMode: boolean;
|
||||
playPauseBind: PlayPauseBind;
|
||||
scheduleRemaining: ScheduleRemaining;
|
||||
transportAnchorRef: React.RefObject<HTMLDivElement | null>;
|
||||
@@ -31,29 +34,55 @@ interface Props {
|
||||
|
||||
export function PlayerTransportControls({
|
||||
isPlaying, isRadio, isPreviewing, stop, previous, next, toggleRepeat, repeatMode,
|
||||
toggleShuffleMode, shuffleMode,
|
||||
playPauseBind, scheduleRemaining, transportAnchorRef, playSlotRef, t,
|
||||
}: Props) {
|
||||
const autodjPhase = useAutodjTransitionUi(s => s.phase);
|
||||
const showAutodjTransition =
|
||||
isPlaying && !isPreviewing && scheduleRemaining == null && autodjPhase === 'mixing';
|
||||
// Hiding Stop leaves no dead end: while previewing, the primary button already
|
||||
// renders as a stop control and ends the preview.
|
||||
const showStop = usePlayerBarLayoutStore(
|
||||
s => s.items.find(i => i.id === 'stop')?.visible !== false,
|
||||
);
|
||||
const showShuffle = usePlayerBarLayoutStore(
|
||||
s => s.items.find(i => i.id === 'shuffle')?.visible !== false,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="player-buttons" ref={transportAnchorRef}>
|
||||
<button
|
||||
className="player-btn player-btn-sm"
|
||||
onClick={() => {
|
||||
if (isPreviewing) {
|
||||
usePreviewStore.setState({ previewingId: null, previewingTrack: null, elapsed: 0 });
|
||||
audioPreviewStopSilent().catch(() => {});
|
||||
} else {
|
||||
stop();
|
||||
}
|
||||
}}
|
||||
aria-label={isPreviewing ? t('playlists.previewStop') : t('player.stop')}
|
||||
data-tooltip={isPreviewing ? t('playlists.previewStop') : t('player.stop')}
|
||||
>
|
||||
<Square size={14} fill="currentColor" />
|
||||
</button>
|
||||
{showStop && (
|
||||
<button
|
||||
className="player-btn player-btn-sm"
|
||||
onClick={() => {
|
||||
if (isPreviewing) {
|
||||
usePreviewStore.setState({ previewingId: null, previewingTrack: null, elapsed: 0 });
|
||||
audioPreviewStopSilent().catch(() => {});
|
||||
} else {
|
||||
stop();
|
||||
}
|
||||
}}
|
||||
aria-label={isPreviewing ? t('playlists.previewStop') : t('player.stop')}
|
||||
data-tooltip={isPreviewing ? t('playlists.previewStop') : t('player.stop')}
|
||||
>
|
||||
<Square size={14} fill="currentColor" />
|
||||
</button>
|
||||
)}
|
||||
{showShuffle && (
|
||||
<button
|
||||
className="player-btn player-btn-sm"
|
||||
onClick={toggleShuffleMode}
|
||||
aria-label={t('player.shuffle')}
|
||||
aria-pressed={shuffleMode}
|
||||
data-tooltip={`${t('player.shuffle')}: ${shuffleMode ? t('player.shuffleOn') : t('player.shuffleOff')}`}
|
||||
disabled={isRadio}
|
||||
style={isRadio
|
||||
? { opacity: 0.3, pointerEvents: 'none' }
|
||||
: { color: shuffleMode ? 'var(--accent)' : undefined }}
|
||||
>
|
||||
<Shuffle size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="player-btn"
|
||||
onClick={() => previous()}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,35 +1,109 @@
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
import {
|
||||
DEFAULT_PLAYER_BAR_LAYOUT_ITEMS,
|
||||
DEFAULT_PLAYER_BAR_TRACK_INFO_MODE,
|
||||
PLAYER_BAR_LAYOUT_ZONES,
|
||||
usePlayerBarLayoutStore,
|
||||
type PlayerBarLayoutItemConfig,
|
||||
} from '@/features/playback/store/playerBarLayoutStore';
|
||||
|
||||
type State = ReturnType<typeof usePlayerBarLayoutStore.getState>;
|
||||
|
||||
/**
|
||||
* Drives the persist middleware's rehydrate hook against a stored snapshot.
|
||||
* That hook is the risky path: a layout persisted by an older version knows
|
||||
* nothing about items added later, and a corrupted entry must not be able to
|
||||
* drop a button or leave the track-info picker on an unrenderable value.
|
||||
*/
|
||||
function rehydrate(stored: Partial<Record<'items' | 'trackInfoMode', unknown>>): State {
|
||||
const state = { ...usePlayerBarLayoutStore.getState(), ...stored } as State;
|
||||
usePlayerBarLayoutStore.persist.getOptions().onRehydrateStorage?.(state)?.(state, undefined);
|
||||
return state;
|
||||
}
|
||||
|
||||
const ids = (items: PlayerBarLayoutItemConfig[]) => items.map(i => i.id);
|
||||
|
||||
describe('playerBarLayoutStore', () => {
|
||||
beforeEach(() => {
|
||||
usePlayerBarLayoutStore.getState().reset();
|
||||
});
|
||||
|
||||
it('starts with all six items visible in declared order', () => {
|
||||
it('starts with every item visible in declared order', () => {
|
||||
const items = usePlayerBarLayoutStore.getState().items;
|
||||
expect(items.map(i => i.id)).toEqual([
|
||||
'starRating', 'favorite', 'lastfmLove', 'playbackRate', 'equalizer', 'miniPlayer',
|
||||
expect(ids(items)).toEqual([
|
||||
'stop', 'shuffle', 'starRating', 'favorite', 'lastfmLove', 'playbackRate', 'equalizer', 'miniPlayer',
|
||||
]);
|
||||
expect(items.every(i => i.visible)).toBe(true);
|
||||
expect(usePlayerBarLayoutStore.getState().trackInfoMode).toBe(DEFAULT_PLAYER_BAR_TRACK_INFO_MODE);
|
||||
});
|
||||
|
||||
it('puts the playback controls in the transport zone and the rest in actions', () => {
|
||||
// Transport items sit among the fixed controls: visibility only, no reorder.
|
||||
expect(PLAYER_BAR_LAYOUT_ZONES.stop).toBe('transport');
|
||||
expect(PLAYER_BAR_LAYOUT_ZONES.shuffle).toBe('transport');
|
||||
expect(ids(DEFAULT_PLAYER_BAR_LAYOUT_ITEMS)
|
||||
.filter(id => id !== 'stop' && id !== 'shuffle')
|
||||
.every(id => PLAYER_BAR_LAYOUT_ZONES[id] === 'actions')).toBe(true);
|
||||
});
|
||||
|
||||
it('toggleItem flips the matching id without disturbing the others', () => {
|
||||
usePlayerBarLayoutStore.getState().toggleItem('equalizer');
|
||||
const items = usePlayerBarLayoutStore.getState().items;
|
||||
expect(items.find(i => i.id === 'equalizer')?.visible).toBe(false);
|
||||
expect(items.find(i => i.id === 'starRating')?.visible).toBe(true);
|
||||
expect(items.find(i => i.id === 'miniPlayer')?.visible).toBe(true);
|
||||
expect(items.filter(i => i.id !== 'equalizer').every(i => i.visible)).toBe(true);
|
||||
});
|
||||
|
||||
it('reset restores defaults after toggles', () => {
|
||||
const { toggleItem, reset } = usePlayerBarLayoutStore.getState();
|
||||
it('reset restores items and the track-info mode together', () => {
|
||||
const { toggleItem, setTrackInfoMode, reset } = usePlayerBarLayoutStore.getState();
|
||||
toggleItem('favorite');
|
||||
toggleItem('lastfmLove');
|
||||
toggleItem('stop');
|
||||
setTrackInfoMode('titleAlbum');
|
||||
reset();
|
||||
expect(usePlayerBarLayoutStore.getState().items).toEqual(DEFAULT_PLAYER_BAR_LAYOUT_ITEMS);
|
||||
const state = usePlayerBarLayoutStore.getState();
|
||||
expect(state.items).toEqual(DEFAULT_PLAYER_BAR_LAYOUT_ITEMS);
|
||||
expect(state.trackInfoMode).toBe(DEFAULT_PLAYER_BAR_TRACK_INFO_MODE);
|
||||
});
|
||||
|
||||
describe('rehydrate', () => {
|
||||
it('adds items the stored layout predates, without losing the stored choices', () => {
|
||||
// A layout persisted before 'stop' existed: it must come back, visible.
|
||||
const state = rehydrate({
|
||||
items: [
|
||||
{ id: 'starRating', visible: true },
|
||||
{ id: 'equalizer', visible: false },
|
||||
],
|
||||
});
|
||||
expect(ids(state.items)).toContain('stop');
|
||||
expect(state.items.find(i => i.id === 'stop')?.visible).toBe(true);
|
||||
expect(state.items.find(i => i.id === 'equalizer')?.visible).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the stored order rather than forcing the default one', () => {
|
||||
const state = rehydrate({
|
||||
items: [
|
||||
{ id: 'miniPlayer', visible: true },
|
||||
{ id: 'equalizer', visible: true },
|
||||
],
|
||||
});
|
||||
expect(ids(state.items).slice(0, 2)).toEqual(['miniPlayer', 'equalizer']);
|
||||
});
|
||||
|
||||
it('drops unknown and malformed entries', () => {
|
||||
const state = rehydrate({
|
||||
items: [{ id: 'ghostButton', visible: true }, null, { visible: true }],
|
||||
});
|
||||
expect(ids(state.items).sort()).toEqual(ids(DEFAULT_PLAYER_BAR_LAYOUT_ITEMS).sort());
|
||||
});
|
||||
|
||||
it('falls back to the default track-info mode on an unknown or missing value', () => {
|
||||
expect(rehydrate({ trackInfoMode: 'everything' }).trackInfoMode)
|
||||
.toBe(DEFAULT_PLAYER_BAR_TRACK_INFO_MODE);
|
||||
expect(rehydrate({ trackInfoMode: undefined }).trackInfoMode)
|
||||
.toBe(DEFAULT_PLAYER_BAR_TRACK_INFO_MODE);
|
||||
});
|
||||
|
||||
it('keeps a valid stored track-info mode', () => {
|
||||
expect(rehydrate({ trackInfoMode: 'titleAlbum' }).trackInfoMode).toBe('titleAlbum');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export type PlayerBarLayoutItemId =
|
||||
| 'stop'
|
||||
| 'shuffle'
|
||||
| 'starRating'
|
||||
| 'favorite'
|
||||
// 'lastfmLove' is the enrichment-primary love button. The id is kept (not
|
||||
@@ -12,12 +14,42 @@ export type PlayerBarLayoutItemId =
|
||||
| 'equalizer'
|
||||
| 'miniPlayer';
|
||||
|
||||
/**
|
||||
* Which cluster of the bar an item lives in.
|
||||
*
|
||||
* `transport` items sit among the fixed playback controls, so only their
|
||||
* visibility is configurable — their position is dictated by the transport row
|
||||
* (the play button is a centred, special-cased element; letting users shuffle
|
||||
* controls around it buys nothing and breaks the adaptive small-window layout).
|
||||
* `actions` items are a plain right-hand row, so they are both toggleable and
|
||||
* reorderable.
|
||||
*/
|
||||
export type PlayerBarLayoutZone = 'transport' | 'actions';
|
||||
|
||||
export const PLAYER_BAR_LAYOUT_ZONES: Record<PlayerBarLayoutItemId, PlayerBarLayoutZone> = {
|
||||
stop: 'transport',
|
||||
shuffle: 'transport',
|
||||
starRating: 'actions',
|
||||
favorite: 'actions',
|
||||
lastfmLove: 'actions',
|
||||
playbackRate: 'actions',
|
||||
equalizer: 'actions',
|
||||
miniPlayer: 'actions',
|
||||
};
|
||||
|
||||
/** What the track-info block shows under the title. */
|
||||
export type PlayerBarTrackInfoMode = 'title' | 'titleAlbum';
|
||||
|
||||
export const PLAYER_BAR_TRACK_INFO_MODES: PlayerBarTrackInfoMode[] = ['title', 'titleAlbum'];
|
||||
|
||||
export interface PlayerBarLayoutItemConfig {
|
||||
id: PlayerBarLayoutItemId;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_PLAYER_BAR_LAYOUT_ITEMS: PlayerBarLayoutItemConfig[] = [
|
||||
{ id: 'stop', visible: true },
|
||||
{ id: 'shuffle', visible: true },
|
||||
{ id: 'starRating', visible: true },
|
||||
{ id: 'favorite', visible: true },
|
||||
{ id: 'lastfmLove', visible: true },
|
||||
@@ -26,10 +58,14 @@ export const DEFAULT_PLAYER_BAR_LAYOUT_ITEMS: PlayerBarLayoutItemConfig[] = [
|
||||
{ id: 'miniPlayer', visible: true },
|
||||
];
|
||||
|
||||
export const DEFAULT_PLAYER_BAR_TRACK_INFO_MODE: PlayerBarTrackInfoMode = 'title';
|
||||
|
||||
interface PlayerBarLayoutStore {
|
||||
items: PlayerBarLayoutItemConfig[];
|
||||
trackInfoMode: PlayerBarTrackInfoMode;
|
||||
setItems: (items: PlayerBarLayoutItemConfig[]) => void;
|
||||
toggleItem: (id: PlayerBarLayoutItemId) => void;
|
||||
setTrackInfoMode: (mode: PlayerBarTrackInfoMode) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
@@ -37,6 +73,7 @@ export const usePlayerBarLayoutStore = create<PlayerBarLayoutStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
items: DEFAULT_PLAYER_BAR_LAYOUT_ITEMS,
|
||||
trackInfoMode: DEFAULT_PLAYER_BAR_TRACK_INFO_MODE,
|
||||
|
||||
setItems: (items) => set({ items }),
|
||||
|
||||
@@ -44,7 +81,12 @@ export const usePlayerBarLayoutStore = create<PlayerBarLayoutStore>()(
|
||||
items: s.items.map(it => it.id === id ? { ...it, visible: !it.visible } : it),
|
||||
})),
|
||||
|
||||
reset: () => set({ items: DEFAULT_PLAYER_BAR_LAYOUT_ITEMS }),
|
||||
setTrackInfoMode: (trackInfoMode) => set({ trackInfoMode }),
|
||||
|
||||
reset: () => set({
|
||||
items: DEFAULT_PLAYER_BAR_LAYOUT_ITEMS,
|
||||
trackInfoMode: DEFAULT_PLAYER_BAR_TRACK_INFO_MODE,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: 'psysonic_player_bar_layout',
|
||||
@@ -55,8 +97,14 @@ export const usePlayerBarLayoutStore = create<PlayerBarLayoutStore>()(
|
||||
.filter((i): i is PlayerBarLayoutItemConfig =>
|
||||
i != null && typeof i.id === 'string' && knownIds.has(i.id as PlayerBarLayoutItemId));
|
||||
const seen = new Set(safe.map(i => i.id));
|
||||
// Items added in a later version (e.g. 'stop') are absent from an older
|
||||
// stored layout — append them with their default so the button does not
|
||||
// silently vanish for existing users.
|
||||
const missing = DEFAULT_PLAYER_BAR_LAYOUT_ITEMS.filter(i => !seen.has(i.id));
|
||||
state.items = missing.length > 0 ? [...safe, ...missing] : safe;
|
||||
if (!PLAYER_BAR_TRACK_INFO_MODES.includes(state.trackInfoMode)) {
|
||||
state.trackInfoMode = DEFAULT_PLAYER_BAR_TRACK_INFO_MODE;
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -26,9 +26,15 @@ import { createScheduleActions } from '@/features/playback/store/scheduleActions
|
||||
import { createTransportLightActions } from '@/features/playback/store/transportLightActions';
|
||||
import { createUiStateActions } from '@/features/playback/store/uiStateActions';
|
||||
import { createUndoRedoActions } from '@/features/playback/store/undoRedoActions';
|
||||
import { setShuffleOriginalOrder } from '@/features/playback/store/shuffleModeActions';
|
||||
import { readShuffleModeSnapshot } from '@/features/playback/store/shuffleModeStorage';
|
||||
|
||||
const initialPlayerPrefs = readInitialPlayerPrefs();
|
||||
const initialNetworkLovedCache = readInitialNetworkLovedCache();
|
||||
// Shuffle survives a restart, so the pre-shuffle order has to come back with it
|
||||
// — otherwise turning shuffle off later could not restore the queue.
|
||||
const initialShuffleMode = readShuffleModeSnapshot();
|
||||
setShuffleOriginalOrder(initialShuffleMode.originalOrder);
|
||||
let playerPersistWritesEnabled = false;
|
||||
|
||||
export const usePlayerStore = create<PlayerState>()(
|
||||
@@ -74,6 +80,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
scheduledResumeAtMs: null,
|
||||
scheduledResumeStartMs: null,
|
||||
repeatMode: initialPlayerPrefs.repeatMode,
|
||||
shuffleMode: initialShuffleMode.enabled,
|
||||
contextMenu: { isOpen: false, x: 0, y: 0, item: null, type: null },
|
||||
songInfoModal: { isOpen: false, songId: null },
|
||||
|
||||
|
||||
@@ -120,6 +120,13 @@ export interface PlayerState {
|
||||
|
||||
repeatMode: 'off' | 'all' | 'one';
|
||||
toggleRepeat: () => void;
|
||||
/**
|
||||
* Persistent shuffle. Reorders the queue itself rather than keeping a hidden
|
||||
* play order, so every consumer of "the next item in the list" — gapless
|
||||
* chain, server play-queue, Orbit guests — stays correct.
|
||||
*/
|
||||
shuffleMode: boolean;
|
||||
toggleShuffleMode: () => void;
|
||||
|
||||
reorderQueue: (startIndex: number, endIndex: number) => void;
|
||||
removeTrack: (index: number) => void;
|
||||
|
||||
@@ -26,6 +26,13 @@ import {
|
||||
ensureQueueServerPinned,
|
||||
} from '@/features/playback/utils/playback/playbackServer';
|
||||
import { clearTimelineSessionHistory } from '@/features/playback/store/timelineSessionHistory';
|
||||
import {
|
||||
getShuffleOriginalOrder,
|
||||
restoreOriginalOrder,
|
||||
setShuffleOriginalOrder,
|
||||
shuffled,
|
||||
} from '@/features/playback/store/shuffleModeActions';
|
||||
import { persistShuffleModeSnapshot } from '@/features/playback/store/shuffleModeStorage';
|
||||
|
||||
type SetState = (
|
||||
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
|
||||
@@ -69,9 +76,52 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
|
||||
| 'reorderQueue'
|
||||
| 'shuffleQueue'
|
||||
| 'shuffleUpcomingQueue'
|
||||
| 'toggleShuffleMode'
|
||||
| 'removeTrack'
|
||||
> {
|
||||
return {
|
||||
/**
|
||||
* Persistent shuffle: reorders the queue itself and remembers the order it
|
||||
* came from, so switching it off restores that order. Rationale for
|
||||
* reordering rather than keeping a hidden play order: see shuffleModeActions.
|
||||
*/
|
||||
toggleShuffleMode: () => {
|
||||
const state = get();
|
||||
const { currentTrack, queueIndex } = state;
|
||||
const items = itemsOf(state);
|
||||
const enabling = !state.shuffleMode;
|
||||
|
||||
// The flag flips even on an empty queue — the user is setting a mode, and
|
||||
// it has to hold for whatever they play next.
|
||||
if (items.length === 0) {
|
||||
setShuffleOriginalOrder([]);
|
||||
persistShuffleModeSnapshot({ enabled: enabling, originalOrder: [] });
|
||||
set({ shuffleMode: enabling });
|
||||
return;
|
||||
}
|
||||
|
||||
pushQueueUndoFromGetter(get);
|
||||
|
||||
let result: QueueItemRef[];
|
||||
if (enabling) {
|
||||
setShuffleOriginalOrder(items.map(r => r.trackId));
|
||||
// Everything up to and including the current track stays put: the playing
|
||||
// track must not move, and already-played rows are history.
|
||||
result = [...items.slice(0, queueIndex + 1), ...shuffled(items.slice(queueIndex + 1))];
|
||||
} else {
|
||||
result = restoreOriginalOrder(items, getShuffleOriginalOrder());
|
||||
setShuffleOriginalOrder([]);
|
||||
}
|
||||
|
||||
persistShuffleModeSnapshot({ enabled: enabling, originalOrder: getShuffleOriginalOrder() });
|
||||
|
||||
const newIndex = currentTrack
|
||||
? Math.max(0, result.findIndex(r => r.trackId === currentTrack.id))
|
||||
: 0;
|
||||
set({ shuffleMode: enabling, queueItems: result, queueIndex: newIndex });
|
||||
syncUserQueueMutationToServer(result, currentTrack, get().currentTime);
|
||||
},
|
||||
|
||||
enqueue: (tracks, _orbitConfirmed = false, skipQueueUndo = false) => {
|
||||
if (!_orbitConfirmed && tracks.length > 1) {
|
||||
void orbitBulkGuard(tracks.length).then(ok => {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// Shuffle physically reorders the queue, so the risk is not "is it random" — it
|
||||
// is whether turning it off puts the queue back. These tests pin the restore:
|
||||
// the playing track never moves, duplicate track ids do not collapse, and rows
|
||||
// that appeared while shuffle was on (enqueued, radio top-up) are kept rather
|
||||
// than dropped on the floor.
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { QueueItemRef } from '@/lib/media/trackTypes';
|
||||
import { restoreOriginalOrder, setShuffleOriginalOrder } from './shuffleModeActions';
|
||||
import { createQueueMutationActions } from './queueMutationActions';
|
||||
|
||||
vi.mock('@/features/playback/store/queueSync', () => ({
|
||||
syncUserQueueMutationToServer: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/features/playback/store/queueUndo', () => ({
|
||||
pushQueueUndoFromGetter: vi.fn(),
|
||||
}));
|
||||
|
||||
const ref = (trackId: string): QueueItemRef => ({ serverId: 's1', trackId });
|
||||
const ids = (items: QueueItemRef[]) => items.map(i => i.trackId);
|
||||
|
||||
describe('restoreOriginalOrder', () => {
|
||||
it('puts a shuffled queue back into its original order', () => {
|
||||
const shuffledQueue = [ref('a'), ref('d'), ref('b'), ref('e'), ref('c')];
|
||||
const restored = restoreOriginalOrder(shuffledQueue, ['a', 'b', 'c', 'd', 'e']);
|
||||
expect(ids(restored)).toEqual(['a', 'b', 'c', 'd', 'e']);
|
||||
});
|
||||
|
||||
it('appends rows that joined the queue while shuffle was on', () => {
|
||||
// 'x' and 'y' were enqueued (or topped up by radio) after shuffle started,
|
||||
// so the remembered order knows nothing about them.
|
||||
const current = [ref('c'), ref('x'), ref('a'), ref('y'), ref('b')];
|
||||
const restored = restoreOriginalOrder(current, ['a', 'b', 'c']);
|
||||
expect(ids(restored)).toEqual(['a', 'b', 'c', 'x', 'y']);
|
||||
});
|
||||
|
||||
it('drops ids from the remembered order that are no longer in the queue', () => {
|
||||
// 'b' was removed by the user while shuffled.
|
||||
const restored = restoreOriginalOrder([ref('c'), ref('a')], ['a', 'b', 'c']);
|
||||
expect(ids(restored)).toEqual(['a', 'c']);
|
||||
});
|
||||
|
||||
it('keeps every copy of a duplicated track', () => {
|
||||
const current = [ref('b'), ref('a'), ref('a')];
|
||||
const restored = restoreOriginalOrder(current, ['a', 'b', 'a']);
|
||||
expect(ids(restored)).toEqual(['a', 'b', 'a']);
|
||||
expect(restored).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('never loses or duplicates a row', () => {
|
||||
const current = [ref('c'), ref('x'), ref('a'), ref('b')];
|
||||
const restored = restoreOriginalOrder(current, ['a', 'b', 'c', 'ghost']);
|
||||
expect(restored).toHaveLength(current.length);
|
||||
expect(new Set(restored).size).toBe(current.length);
|
||||
});
|
||||
|
||||
it('returns the queue unchanged when nothing was remembered', () => {
|
||||
const current = [ref('a'), ref('b')];
|
||||
expect(ids(restoreOriginalOrder(current, []))).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleShuffleMode', () => {
|
||||
beforeEach(() => {
|
||||
setShuffleOriginalOrder([]);
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
/** Minimal player-state stub: only what the action reads and writes. */
|
||||
function harness(queue: string[], queueIndex: number, shuffleMode = false) {
|
||||
let state = {
|
||||
queueItems: queue.map(ref),
|
||||
queueIndex,
|
||||
shuffleMode,
|
||||
currentTrack: queue[queueIndex] ? { id: queue[queueIndex] } : null,
|
||||
currentTime: 0,
|
||||
};
|
||||
const set = (partial: unknown) => {
|
||||
const patch = typeof partial === 'function'
|
||||
? (partial as (s: typeof state) => Partial<typeof state>)(state)
|
||||
: partial as Partial<typeof state>;
|
||||
state = { ...state, ...patch };
|
||||
};
|
||||
const get = () => state as never;
|
||||
const { toggleShuffleMode } = createQueueMutationActions(set as never, get);
|
||||
return { toggleShuffleMode, read: () => state };
|
||||
}
|
||||
|
||||
it('keeps the playing track in place and shuffles only what is ahead', () => {
|
||||
const random = vi.spyOn(Math, 'random').mockReturnValue(0); // deterministic
|
||||
const h = harness(['a', 'b', 'c', 'd', 'e'], 1);
|
||||
h.toggleShuffleMode();
|
||||
const s = h.read();
|
||||
|
||||
expect(s.shuffleMode).toBe(true);
|
||||
// Played rows and the current track are untouched...
|
||||
expect(ids(s.queueItems).slice(0, 2)).toEqual(['a', 'b']);
|
||||
// ...the index still points at the playing track...
|
||||
expect(s.queueItems[s.queueIndex].trackId).toBe('b');
|
||||
// ...and the rest is a permutation of what was ahead, nothing lost.
|
||||
expect(ids(s.queueItems).slice(2).sort()).toEqual(['c', 'd', 'e']);
|
||||
random.mockRestore();
|
||||
});
|
||||
|
||||
it('restores the original order when switched off, index following the track', () => {
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.99);
|
||||
const h = harness(['a', 'b', 'c', 'd', 'e'], 0);
|
||||
h.toggleShuffleMode();
|
||||
expect(h.read().shuffleMode).toBe(true);
|
||||
|
||||
h.toggleShuffleMode();
|
||||
const s = h.read();
|
||||
expect(s.shuffleMode).toBe(false);
|
||||
expect(ids(s.queueItems)).toEqual(['a', 'b', 'c', 'd', 'e']);
|
||||
expect(s.queueItems[s.queueIndex].trackId).toBe('a');
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('flips the mode on an empty queue without touching it', () => {
|
||||
const h = harness([], 0);
|
||||
h.toggleShuffleMode();
|
||||
expect(h.read().shuffleMode).toBe(true);
|
||||
expect(h.read().queueItems).toEqual([]);
|
||||
});
|
||||
|
||||
it('persists the flag and the original order so it survives a restart', () => {
|
||||
const h = harness(['a', 'b', 'c'], 0);
|
||||
h.toggleShuffleMode();
|
||||
const stored = JSON.parse(window.localStorage.getItem('psysonic_shuffle_mode') ?? '{}');
|
||||
expect(stored.enabled).toBe(true);
|
||||
expect(stored.originalOrder).toEqual(['a', 'b', 'c']);
|
||||
|
||||
h.toggleShuffleMode();
|
||||
expect(window.localStorage.getItem('psysonic_shuffle_mode')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Persistent shuffle mode — pure helpers and the remembered pre-shuffle order.
|
||||
*
|
||||
* Turning shuffle on physically reorders the queue (the tracks after the current
|
||||
* one) and remembers the order it came from; turning it off puts that order back.
|
||||
* The toggle itself lives with the other queue mutations in
|
||||
* `queueMutationActions` — it pushes an undo snapshot and syncs the queue to the
|
||||
* server like every other mutation, and those modules reach back into the player
|
||||
* store. This file stays free of that so it can be imported anywhere.
|
||||
*
|
||||
* The alternative design — leaving `queueItems` alone and only *playing* in a
|
||||
* hidden random order — was rejected deliberately: "what plays next" is derived
|
||||
* from the list order in four places (manual next, the gapless successor, the
|
||||
* chain preload, the crossfade/AutoDJ plan), the engine is handed the next track
|
||||
* ~30 s ahead with no way to take it back, and the server play-queue (and Orbit
|
||||
* guests) only ever see the list order.
|
||||
*/
|
||||
|
||||
import type { QueueItemRef } from '@/lib/media/trackTypes';
|
||||
|
||||
/** Module-level, like the other non-render queue state: the UI never reads it. */
|
||||
let originalOrder: string[] = [];
|
||||
|
||||
export function getShuffleOriginalOrder(): string[] {
|
||||
return originalOrder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the remembered order. Called when shuffle is switched on, cleared when it
|
||||
* is switched off, and seeded from storage on boot — shuffle survives a restart,
|
||||
* so the order it can be undone to has to survive with it.
|
||||
*/
|
||||
export function setShuffleOriginalOrder(order: string[]): void {
|
||||
originalOrder = order;
|
||||
}
|
||||
|
||||
export function shuffled<T>(items: T[]): T[] {
|
||||
const out = [...items];
|
||||
for (let i = out.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[out[i], out[j]] = [out[j], out[i]];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorders `items` back into `order`, matching by track id.
|
||||
*
|
||||
* Ids can repeat (the same track may sit in the queue twice), so each id consumes
|
||||
* one ref at a time rather than filtering. Refs the remembered order does not
|
||||
* know about — enqueued, radio-topped-up or auto-added while shuffle was on —
|
||||
* keep their relative order and go to the end: they were never part of the
|
||||
* original list, so there is no position to restore them to.
|
||||
*/
|
||||
export function restoreOriginalOrder(items: QueueItemRef[], order: string[]): QueueItemRef[] {
|
||||
const pools = new Map<string, QueueItemRef[]>();
|
||||
for (const ref of items) {
|
||||
const pool = pools.get(ref.trackId);
|
||||
if (pool) pool.push(ref);
|
||||
else pools.set(ref.trackId, [ref]);
|
||||
}
|
||||
|
||||
const restored: QueueItemRef[] = [];
|
||||
for (const trackId of order) {
|
||||
const ref = pools.get(trackId)?.shift();
|
||||
if (ref) restored.push(ref);
|
||||
}
|
||||
|
||||
const restoredSet = new Set(restored);
|
||||
return [...restored, ...items.filter(ref => !restoredSet.has(ref))];
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Persisted shuffle-mode state.
|
||||
*
|
||||
* Kept out of the main `psysonic-player` blob (which already carries the whole
|
||||
* queue and can hit the localStorage quota) — same reasoning as
|
||||
* `playerPrefsStorage` / `queueVisibilityStorage`.
|
||||
*
|
||||
* Shuffle physically reorders `queueItems`, so "next track" stays "the next one
|
||||
* in the list" for the gapless chain, the server play-queue and Orbit guests.
|
||||
* The price is that turning shuffle off has to put the queue back — hence the
|
||||
* original order, remembered as track ids and persisted alongside the flag so it
|
||||
* survives a restart while shuffle is still on.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'psysonic_shuffle_mode';
|
||||
|
||||
export interface ShuffleModeSnapshot {
|
||||
enabled: boolean;
|
||||
/** Track ids in their pre-shuffle order; empty when shuffle is off. */
|
||||
originalOrder: string[];
|
||||
}
|
||||
|
||||
const EMPTY: ShuffleModeSnapshot = { enabled: false, originalOrder: [] };
|
||||
|
||||
export function readShuffleModeSnapshot(): ShuffleModeSnapshot {
|
||||
if (typeof window === 'undefined') return EMPTY;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return EMPTY;
|
||||
const parsed = JSON.parse(raw) as Partial<ShuffleModeSnapshot>;
|
||||
const originalOrder = Array.isArray(parsed.originalOrder)
|
||||
? parsed.originalOrder.filter((id): id is string => typeof id === 'string')
|
||||
: [];
|
||||
const enabled = parsed.enabled === true;
|
||||
// A flag without an order cannot be un-shuffled, and an order without the
|
||||
// flag is dead weight — treat either half alone as "off".
|
||||
if (!enabled || originalOrder.length === 0) return EMPTY;
|
||||
return { enabled, originalOrder };
|
||||
} catch {
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
export function persistShuffleModeSnapshot(snapshot: ShuffleModeSnapshot): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
if (!snapshot.enabled) {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
|
||||
} catch {
|
||||
// best-effort — the in-memory order still works for this session
|
||||
}
|
||||
}
|
||||
@@ -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' }}
|
||||
|
||||
@@ -10,6 +10,8 @@ interface Props {
|
||||
playlists: SubsonicPlaylist[];
|
||||
renderCard: (pl: SubsonicPlaylist) => React.ReactNode;
|
||||
disableVirtualization: boolean;
|
||||
/** When true, omit folder sections that currently have no matching playlists. */
|
||||
hideEmptyFolders?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -18,17 +20,26 @@ interface Props {
|
||||
* virtualisation match the flat grid; only the grouping differs. Rendered only
|
||||
* when at least one folder exists (the page falls back to the plain grid).
|
||||
*/
|
||||
export default function PlaylistsFolderView({ serverId, playlists, renderCard, disableVirtualization }: Props) {
|
||||
export default function PlaylistsFolderView({
|
||||
serverId,
|
||||
playlists,
|
||||
renderCard,
|
||||
disableVirtualization,
|
||||
hideEmptyFolders = false,
|
||||
}: Props) {
|
||||
const bucket = usePlaylistFolderStore(s => s.byServer[serverId]) ?? EMPTY_SERVER_FOLDERS;
|
||||
const { isDragging } = useDragDrop();
|
||||
const grouped = groupPlaylistsByFolder(playlists, bucket.folders, bucket.assignments);
|
||||
// Keep the ungrouped section as a drop target during a drag even when empty,
|
||||
// so a playlist filed into a folder can always be dragged back out to root.
|
||||
const showUngrouped = grouped.ungrouped.length > 0 || isDragging;
|
||||
const folderSections = hideEmptyFolders && !isDragging
|
||||
? grouped.folders.filter(({ playlists: items }) => items.length > 0)
|
||||
: grouped.folders;
|
||||
|
||||
return (
|
||||
<div className="playlist-folder-view">
|
||||
{grouped.folders.map(({ folder, playlists: items }) => (
|
||||
{folderSections.map(({ folder, playlists: items }) => (
|
||||
<PlaylistFolderSection
|
||||
key={folder.id}
|
||||
serverId={serverId}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -29,8 +29,8 @@ export * from './utils/addTracksToPlaylistWithDedup';
|
||||
export * from './utils/playlistBulkPlayActions';
|
||||
export * from './utils/playlistDisplayedSongs';
|
||||
export * from './utils/playlistFolders';
|
||||
export * from './utils/playlistsBrowseSearch';
|
||||
export * from './utils/playlistsSmart';
|
||||
export * from './utils/playPlaylistById';
|
||||
export * from './utils/runPlaylistCsvImport';
|
||||
export * from './utils/runPlaylistLoad';
|
||||
export * from './utils/runPlaylistReorderDrop';
|
||||
@@ -38,6 +38,7 @@ export * from './utils/runPlaylistsActions';
|
||||
export * from './utils/runPlaylistSaveMeta';
|
||||
export * from './utils/runPlaylistsOpenSmartEditor';
|
||||
export * from './utils/runPlaylistsSaveSmart';
|
||||
export * from './utils/resolvePlaylistTracks';
|
||||
export * from './utils/runPlaylistZipDownload';
|
||||
export * from './utils/spotifyCsvImport';
|
||||
export * from './utils/spotifyCsvMatch';
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { resolveMediaServerId, resolvePlaylist } from '@/features/offline';
|
||||
import { resolvePlaylistTracks } from '@/features/playlist/utils/resolvePlaylistTracks';
|
||||
import { getGenres } from '@/lib/api/subsonicGenres';
|
||||
import { filterSongsToActiveLibrary } from '@/lib/api/subsonicLibrary';
|
||||
import type { SubsonicPlaylist, SubsonicGenre } from '@/lib/api/subsonicTypes';
|
||||
import { songToTrack } from '@/lib/media/songToTrack';
|
||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { usePlaylistStore } from '@/features/playlist/store/playlistStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRangeSelection } from '@/lib/hooks/useRangeSelection';
|
||||
import { useScopedBrowseSearchQuery } from '@/store/liveSearchScopeStore';
|
||||
import { filterPlaylistsByNameQuery } from '@/features/playlist/utils/playlistsBrowseSearch';
|
||||
|
||||
import {
|
||||
defaultSmartFilters,
|
||||
@@ -40,6 +40,12 @@ export default function Playlists() {
|
||||
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
|
||||
const removeId = usePlaylistStore((s) => s.removeId);
|
||||
const playlists = usePlaylistStore((s) => s.playlists);
|
||||
const playlistsSearchQuery = useScopedBrowseSearchQuery('playlists');
|
||||
const visiblePlaylists = useMemo(
|
||||
() => filterPlaylistsByNameQuery(playlists, playlistsSearchQuery),
|
||||
[playlists, playlistsSearchQuery],
|
||||
);
|
||||
const textSearchActive = playlistsSearchQuery.trim().length > 0;
|
||||
const fetchPlaylists = usePlaylistStore((s) => s.fetchPlaylists);
|
||||
const activeUsername = useAuthStore(s => s.getActiveServer()?.username ?? '');
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
@@ -73,12 +79,37 @@ export default function Playlists() {
|
||||
|
||||
// ── Multi-selection ──────────────────────────────────────────────────────
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(playlists);
|
||||
const {
|
||||
selectedIds,
|
||||
setSelectedIds,
|
||||
toggleSelect,
|
||||
clearSelection: resetSelection,
|
||||
} = useRangeSelection(visiblePlaylists);
|
||||
const isNavidromeServer = Boolean(
|
||||
activeServerId &&
|
||||
(subsonicIdentityByServer[activeServerId]?.type ?? '').toLowerCase() === 'navidrome',
|
||||
);
|
||||
|
||||
// Intersect with the visible list so header/bulk actions never count hidden ids
|
||||
// (even for the render before the prune effect below runs).
|
||||
const visibleSelectedIds = useMemo(() => {
|
||||
if (selectedIds.size === 0) return selectedIds;
|
||||
const visibleIds = new Set(visiblePlaylists.map(p => p.id));
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
for (const id of selectedIds) {
|
||||
if (visibleIds.has(id)) next.add(id);
|
||||
else changed = true;
|
||||
}
|
||||
return changed ? next : selectedIds;
|
||||
}, [selectedIds, visiblePlaylists]);
|
||||
|
||||
// Drop ids that the scoped search hid so range-select state stays coherent.
|
||||
useEffect(() => {
|
||||
if (visibleSelectedIds === selectedIds) return;
|
||||
setSelectedIds(visibleSelectedIds);
|
||||
}, [visibleSelectedIds, selectedIds, setSelectedIds]);
|
||||
|
||||
const toggleSelectionMode = () => {
|
||||
setSelectionMode(v => !v);
|
||||
resetSelection();
|
||||
@@ -89,7 +120,7 @@ export default function Playlists() {
|
||||
resetSelection();
|
||||
};
|
||||
|
||||
const selectedPlaylists = playlists.filter(p => selectedIds.has(p.id));
|
||||
const selectedPlaylists = visiblePlaylists.filter(p => visibleSelectedIds.has(p.id));
|
||||
const isPlaylistDeletable = useCallback((pl: SubsonicPlaylist) => {
|
||||
if (!pl.owner) return true;
|
||||
if (!activeUsername) return false;
|
||||
@@ -144,14 +175,7 @@ export default function Playlists() {
|
||||
if (playingId === pl.id) return;
|
||||
setPlayingId(pl.id);
|
||||
try {
|
||||
const serverId = resolveMediaServerId(activeServerId);
|
||||
if (!serverId) return;
|
||||
const data = await resolvePlaylist(serverId, pl.id);
|
||||
if (!data) return;
|
||||
const songs = offlineBrowseActive
|
||||
? data.songs
|
||||
: await filterSongsToActiveLibrary(data.songs);
|
||||
const tracks = songs.map(songToTrack);
|
||||
const tracks = await resolvePlaylistTracks(pl.id);
|
||||
if (tracks.length > 0) {
|
||||
touchPlaylist(pl.id);
|
||||
playTrack(tracks[0], tracks);
|
||||
@@ -165,7 +189,7 @@ export default function Playlists() {
|
||||
});
|
||||
|
||||
const handleDeleteSelected = () => runPlaylistDeleteSelected({
|
||||
selectedPlaylists, selectedIds, isPlaylistDeletable, removeId, clearSelection, t,
|
||||
selectedPlaylists, isPlaylistDeletable, removeId, clearSelection, t,
|
||||
});
|
||||
|
||||
const renderCard = (pl: SubsonicPlaylist) => (
|
||||
@@ -173,7 +197,7 @@ export default function Playlists() {
|
||||
pl={pl}
|
||||
selectionMode={selectionMode}
|
||||
draggable={showFolderView}
|
||||
selectedIds={selectedIds}
|
||||
selectedIds={visibleSelectedIds}
|
||||
selectedPlaylists={selectedPlaylists}
|
||||
toggleSelect={toggleSelect}
|
||||
isPlaylistDeletable={isPlaylistDeletable}
|
||||
@@ -246,7 +270,7 @@ export default function Playlists() {
|
||||
|
||||
<PlaylistsHeader
|
||||
selectionMode={selectionMode}
|
||||
selectedIds={selectedIds}
|
||||
selectedIds={visibleSelectedIds}
|
||||
selectedPlaylists={selectedPlaylists}
|
||||
isPlaylistDeletable={isPlaylistDeletable}
|
||||
toggleSelectionMode={toggleSelectionMode}
|
||||
@@ -283,6 +307,8 @@ export default function Playlists() {
|
||||
{/* ── Grid ── */}
|
||||
{playlists.length === 0 ? (
|
||||
<div className="empty-state">{t('playlists.empty')}</div>
|
||||
) : visiblePlaylists.length === 0 && textSearchActive ? (
|
||||
<div className="empty-state">{t('playlists.noMatchingSearch')}</div>
|
||||
) : (
|
||||
<>
|
||||
{showFolderView && (
|
||||
@@ -293,17 +319,18 @@ export default function Playlists() {
|
||||
{showFolderView && activeServerId ? (
|
||||
<PlaylistsFolderView
|
||||
serverId={activeServerId}
|
||||
playlists={playlists}
|
||||
playlists={visiblePlaylists}
|
||||
renderCard={renderCard}
|
||||
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||
hideEmptyFolders={textSearchActive}
|
||||
/>
|
||||
) : (
|
||||
<VirtualCardGrid
|
||||
items={playlists}
|
||||
items={visiblePlaylists}
|
||||
itemKey={(pl, _i) => pl.id}
|
||||
rowVariant="playlist"
|
||||
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||
layoutSignal={playlists.length}
|
||||
layoutSignal={visiblePlaylists.length}
|
||||
renderItem={renderCard}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { getPlaylist } from '@/lib/api/subsonicPlaylists';
|
||||
import { songToTrack } from '@/lib/media/songToTrack';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { playPlaylistAll } from '@/features/playlist/utils/playlistBulkPlayActions';
|
||||
|
||||
/**
|
||||
* Load a playlist's songs and start playback immediately ("Play Now").
|
||||
*
|
||||
* Used where only the playlist metadata is on hand — the playlist context menu
|
||||
* on the Playlists overview — so the tracks have to be fetched first. Once
|
||||
* loaded it defers to {@link playPlaylistAll}, the same action the playlist
|
||||
* detail "Play All" button uses, so playback behaviour stays in one place.
|
||||
*/
|
||||
export async function playPlaylistById(id: string): Promise<void> {
|
||||
const { songs } = await getPlaylist(id);
|
||||
const tracks = songs.map(songToTrack);
|
||||
const { playTrack, enqueue } = usePlayerStore.getState();
|
||||
playPlaylistAll({ songsLength: tracks.length, id, tracks, playTrack, enqueue });
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { SubsonicPlaylist } from '@/lib/api/subsonicTypes';
|
||||
import {
|
||||
filterPlaylistsByNameQuery,
|
||||
isPlaylistsBrowsePath,
|
||||
} from './playlistsBrowseSearch';
|
||||
|
||||
function pl(id: string, name: string): SubsonicPlaylist {
|
||||
return { id, name, songCount: 0, duration: 0, created: '', changed: '' };
|
||||
}
|
||||
|
||||
describe('isPlaylistsBrowsePath', () => {
|
||||
it('matches only the playlists list route', () => {
|
||||
expect(isPlaylistsBrowsePath('/playlists')).toBe(true);
|
||||
expect(isPlaylistsBrowsePath('/playlists/abc')).toBe(false);
|
||||
expect(isPlaylistsBrowsePath('/artists')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterPlaylistsByNameQuery', () => {
|
||||
const list = [pl('1', 'Road Trip'), pl('2', 'Focus Mix'), pl('3', 'road house')];
|
||||
|
||||
it('returns all playlists when query is empty', () => {
|
||||
expect(filterPlaylistsByNameQuery(list, ' ')).toEqual(list);
|
||||
});
|
||||
|
||||
it('filters by case-insensitive name substring', () => {
|
||||
expect(filterPlaylistsByNameQuery(list, 'road').map(p => p.id)).toEqual(['1', '3']);
|
||||
expect(filterPlaylistsByNameQuery(list, 'FOCUS').map(p => p.id)).toEqual(['2']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { SubsonicPlaylist } from '@/lib/api/subsonicTypes';
|
||||
|
||||
/** True when pathname is the Playlists browse route (`/playlists`). */
|
||||
export function isPlaylistsBrowsePath(pathname: string): boolean {
|
||||
return pathname === '/playlists';
|
||||
}
|
||||
|
||||
/** Scoped Playlists text search — playlist name only. */
|
||||
export function filterPlaylistsByNameQuery(
|
||||
playlists: SubsonicPlaylist[],
|
||||
query: string,
|
||||
): SubsonicPlaylist[] {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle) return playlists;
|
||||
return playlists.filter(p => (p.name ?? '').toLowerCase().includes(needle));
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { resolvePlaylistTracks } from '@/features/playlist/utils/resolvePlaylistTracks';
|
||||
|
||||
const offlineMock = vi.fn(() => false);
|
||||
const resolveServerMock = vi.fn((id: string | null | undefined) => id ?? undefined);
|
||||
const resolvePlaylistMock = vi.fn();
|
||||
const filterMock = vi.fn();
|
||||
let activeServerId: string | null = 'srv-1';
|
||||
|
||||
vi.mock('@/features/offline', () => ({
|
||||
isOfflineBrowseActive: () => offlineMock(),
|
||||
resolveMediaServerId: (id: string | null | undefined) => resolveServerMock(id),
|
||||
resolvePlaylist: (serverId: string, playlistId: string) => resolvePlaylistMock(serverId, playlistId),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api/subsonicLibrary', () => ({
|
||||
filterSongsToActiveLibrary: (songs: unknown) => filterMock(songs),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/media/songToTrack', () => ({
|
||||
songToTrack: (song: { id: string }) => ({ id: song.id, track: true }),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/authStore', () => ({
|
||||
useAuthStore: { getState: () => ({ activeServerId }) },
|
||||
}));
|
||||
|
||||
describe('resolvePlaylistTracks', () => {
|
||||
beforeEach(() => {
|
||||
offlineMock.mockReset().mockReturnValue(false);
|
||||
resolveServerMock.mockReset().mockImplementation((id: string | null | undefined) => id ?? undefined);
|
||||
resolvePlaylistMock.mockReset();
|
||||
filterMock.mockReset();
|
||||
activeServerId = 'srv-1';
|
||||
});
|
||||
|
||||
it('scopes to the active library when online', async () => {
|
||||
resolvePlaylistMock.mockResolvedValue({ playlist: { id: 'pl-1' }, songs: [{ id: 'a' }, { id: 'b' }, { id: 'c' }] });
|
||||
// Active-library scope hides b and c.
|
||||
filterMock.mockResolvedValue([{ id: 'a' }]);
|
||||
|
||||
const tracks = await resolvePlaylistTracks('pl-1');
|
||||
|
||||
expect(filterMock).toHaveBeenCalledOnce();
|
||||
expect(tracks).toEqual([{ id: 'a', track: true }]);
|
||||
});
|
||||
|
||||
it('uses the full offline list without library filtering', async () => {
|
||||
offlineMock.mockReturnValue(true);
|
||||
resolvePlaylistMock.mockResolvedValue({ playlist: { id: 'pl-1' }, songs: [{ id: 'a' }, { id: 'b' }] });
|
||||
|
||||
const tracks = await resolvePlaylistTracks('pl-1');
|
||||
|
||||
expect(filterMock).not.toHaveBeenCalled();
|
||||
expect(tracks).toEqual([{ id: 'a', track: true }, { id: 'b', track: true }]);
|
||||
});
|
||||
|
||||
it('returns [] when the active server cannot be resolved', async () => {
|
||||
activeServerId = null;
|
||||
resolveServerMock.mockReturnValue(undefined);
|
||||
|
||||
const tracks = await resolvePlaylistTracks('pl-1');
|
||||
|
||||
expect(tracks).toEqual([]);
|
||||
expect(resolvePlaylistMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns [] when the playlist cannot be resolved', async () => {
|
||||
resolvePlaylistMock.mockResolvedValue(null);
|
||||
|
||||
const tracks = await resolvePlaylistTracks('pl-1');
|
||||
|
||||
expect(tracks).toEqual([]);
|
||||
expect(filterMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows a rejecting library-scope filter to [] (no unhandled rejection)', async () => {
|
||||
resolvePlaylistMock.mockResolvedValue({ playlist: { id: 'pl-1' }, songs: [{ id: 'a' }] });
|
||||
filterMock.mockRejectedValue(new Error('network'));
|
||||
|
||||
await expect(resolvePlaylistTracks('pl-1')).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { isOfflineBrowseActive, resolveMediaServerId, resolvePlaylist } from '@/features/offline';
|
||||
import { filterSongsToActiveLibrary } from '@/lib/api/subsonicLibrary';
|
||||
import { songToTrack } from '@/lib/media/songToTrack';
|
||||
import type { Track } from '@/lib/media/trackTypes';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
/**
|
||||
* Resolve a playlist's playable tracks from its id alone — the same way the
|
||||
* Playlists overview "Play" button does: offline-browse aware via
|
||||
* {@link resolvePlaylist}, then scoped to the active library (#1241) when
|
||||
* online. Shared by the overview play control and the playlist context-menu
|
||||
* queue actions so those paths cannot drift.
|
||||
*
|
||||
* Best-effort: returns `[]` when the server is unknown or the playlist cannot
|
||||
* be resolved, so callers can treat empty as "nothing to enqueue".
|
||||
*/
|
||||
export async function resolvePlaylistTracks(playlistId: string): Promise<Track[]> {
|
||||
const serverId = resolveMediaServerId(useAuthStore.getState().activeServerId);
|
||||
if (!serverId) return [];
|
||||
try {
|
||||
const data = await resolvePlaylist(serverId, playlistId);
|
||||
if (!data) return [];
|
||||
// The library-scope filter fetches the album list over the network, so it
|
||||
// can reject; swallow to [] so context-menu callers (which run via a
|
||||
// no-catch handler) never leak an unhandled rejection.
|
||||
const songs = isOfflineBrowseActive()
|
||||
? data.songs
|
||||
: await filterSongsToActiveLibrary(data.songs);
|
||||
return songs.map(songToTrack);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,6 @@ export async function runPlaylistDelete(deps: RunPlaylistDeleteDeps): Promise<vo
|
||||
|
||||
export interface RunPlaylistDeleteSelectedDeps {
|
||||
selectedPlaylists: SubsonicPlaylist[];
|
||||
selectedIds: Set<string>;
|
||||
isPlaylistDeletable: (pl: SubsonicPlaylist) => boolean;
|
||||
removeId: (id: string) => void;
|
||||
clearSelection: () => void;
|
||||
@@ -50,26 +49,26 @@ export interface RunPlaylistDeleteSelectedDeps {
|
||||
}
|
||||
|
||||
export async function runPlaylistDeleteSelected(deps: RunPlaylistDeleteSelectedDeps): Promise<void> {
|
||||
const { selectedPlaylists, selectedIds, isPlaylistDeletable, removeId, clearSelection, t } = deps;
|
||||
const { selectedPlaylists, isPlaylistDeletable, removeId, clearSelection, t } = deps;
|
||||
const deletable = selectedPlaylists.filter(isPlaylistDeletable);
|
||||
if (deletable.length === 0) return;
|
||||
let deleted = 0;
|
||||
const removedIds = new Set<string>();
|
||||
for (const pl of deletable) {
|
||||
try {
|
||||
await deletePlaylist(pl.id);
|
||||
removeId(pl.id);
|
||||
deleted++;
|
||||
removedIds.add(pl.id);
|
||||
} catch {
|
||||
showToast(t('playlists.deleteFailed', { name: pl.name }), 3000, 'error');
|
||||
}
|
||||
}
|
||||
usePlaylistStore.setState((s) => ({
|
||||
playlists: s.playlists.filter((p) => !(selectedIds.has(p.id) && isPlaylistDeletable(p))),
|
||||
}));
|
||||
clearSelection();
|
||||
if (deleted > 0) {
|
||||
showToast(t('playlists.deleteSuccess', { count: deleted }), 3000, 'info');
|
||||
if (removedIds.size > 0) {
|
||||
usePlaylistStore.setState((s) => ({
|
||||
playlists: s.playlists.filter((p) => !removedIds.has(p.id)),
|
||||
}));
|
||||
showToast(t('playlists.deleteSuccess', { count: removedIds.size }), 3000, 'info');
|
||||
}
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
export interface RunPlaylistMergeSelectedDeps {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { isAlbumsBrowsePath, isNewReleasesBrowsePath } from '@/features/album';
|
||||
import { isTracksBrowsePath } from '@/store/advancedSearchSessionStore';
|
||||
import { isArtistsBrowsePath } from '@/features/artist';
|
||||
import { isComposersBrowsePath } from '@/features/composers';
|
||||
import { isPlaylistsBrowsePath } from '@/features/playlist';
|
||||
|
||||
export const SCOPE_NAV_ITEM: Record<LiveSearchScope, keyof typeof ALL_NAV_ITEMS> = {
|
||||
artists: 'artists',
|
||||
@@ -12,6 +13,7 @@ export const SCOPE_NAV_ITEM: Record<LiveSearchScope, keyof typeof ALL_NAV_ITEMS>
|
||||
newReleases: 'newReleases',
|
||||
tracks: 'tracks',
|
||||
composers: 'composers',
|
||||
playlists: 'playlists',
|
||||
};
|
||||
|
||||
/** Scope to restore when on a browse route but the badge was cleared (global search mode). */
|
||||
@@ -25,6 +27,7 @@ export function resolveLiveSearchScopeGhost(
|
||||
if (isNewReleasesBrowsePath(pathname)) return 'newReleases';
|
||||
if (isTracksBrowsePath(pathname)) return 'tracks';
|
||||
if (isComposersBrowsePath(pathname)) return 'composers';
|
||||
if (isPlaylistsBrowsePath(pathname)) return 'playlists';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -40,6 +43,8 @@ export function liveSearchScopePlaceholderKey(scope: LiveSearchScope | null): st
|
||||
return 'search.scopeTracksPlaceholder';
|
||||
case 'composers':
|
||||
return 'search.scopeComposersPlaceholder';
|
||||
case 'playlists':
|
||||
return 'search.scopePlaylistsPlaceholder';
|
||||
default:
|
||||
return 'search.placeholder';
|
||||
}
|
||||
@@ -62,6 +67,8 @@ export function liveSearchScopeBadgeTooltipKey(scope: LiveSearchScope): string {
|
||||
return 'search.scopeTracksBadgeTooltip';
|
||||
case 'composers':
|
||||
return 'search.scopeComposersBadgeTooltip';
|
||||
case 'playlists':
|
||||
return 'search.scopePlaylistsBadgeTooltip';
|
||||
default:
|
||||
return 'search.scopeArtistsBadgeTooltip';
|
||||
}
|
||||
@@ -79,6 +86,8 @@ export function liveSearchScopeGhostTooltipKey(scope: LiveSearchScope): string {
|
||||
return 'search.scopeTracksGhostTooltip';
|
||||
case 'composers':
|
||||
return 'search.scopeComposersGhostTooltip';
|
||||
case 'playlists':
|
||||
return 'search.scopePlaylistsGhostTooltip';
|
||||
default:
|
||||
return 'search.scopeArtistsGhostTooltip';
|
||||
}
|
||||
|
||||
@@ -41,6 +41,9 @@ describe('resolveLiveSearchScopeGhost', () => {
|
||||
expect(resolveLiveSearchScopeGhost('/tracks', 'tracks')).toBeNull();
|
||||
expect(resolveLiveSearchScopeGhost('/composers', null)).toBe('composers');
|
||||
expect(resolveLiveSearchScopeGhost('/composers', 'composers')).toBeNull();
|
||||
expect(resolveLiveSearchScopeGhost('/playlists', null)).toBe('playlists');
|
||||
expect(resolveLiveSearchScopeGhost('/playlists', 'playlists')).toBeNull();
|
||||
expect(resolveLiveSearchScopeGhost('/playlists/abc', null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -128,6 +131,7 @@ describe('liveSearchScopePlaceholderKey', () => {
|
||||
expect(liveSearchScopePlaceholderKey('newReleases')).toBe('search.scopeNewReleasesPlaceholder');
|
||||
expect(liveSearchScopePlaceholderKey('tracks')).toBe('search.scopeTracksPlaceholder');
|
||||
expect(liveSearchScopePlaceholderKey('composers')).toBe('search.scopeComposersPlaceholder');
|
||||
expect(liveSearchScopePlaceholderKey('playlists')).toBe('search.scopePlaylistsPlaceholder');
|
||||
expect(liveSearchScopePlaceholderKey(null)).toBe('search.placeholder');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,9 @@ describe('syncLiveSearchRouteScope', () => {
|
||||
|
||||
syncLiveSearchRouteScope('/composers');
|
||||
expect(useLiveSearchScopeStore.getState().scope).toBe('composers');
|
||||
|
||||
syncLiveSearchRouteScope('/playlists');
|
||||
expect(useLiveSearchScopeStore.getState().scope).toBe('playlists');
|
||||
});
|
||||
|
||||
it('clears scope and query when leaving browse routes', () => {
|
||||
@@ -27,6 +30,15 @@ describe('syncLiveSearchRouteScope', () => {
|
||||
expect(useLiveSearchScopeStore.getState().query).toBe('');
|
||||
});
|
||||
|
||||
it('clears scope when entering playlist detail', () => {
|
||||
useLiveSearchScopeStore.setState({ query: 'road', scope: 'playlists' });
|
||||
|
||||
syncLiveSearchRouteScope('/playlists/abc123');
|
||||
|
||||
expect(useLiveSearchScopeStore.getState().scope).toBeNull();
|
||||
expect(useLiveSearchScopeStore.getState().query).toBe('');
|
||||
});
|
||||
|
||||
it('clears query when leaving browse with scope already cleared (ghost mode)', () => {
|
||||
useLiveSearchScopeStore.setState({ query: 'beatles', scope: null });
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { isAlbumsBrowsePath, isNewReleasesBrowsePath } from '@/features/album';
|
||||
import { isArtistsBrowsePath } from '@/features/artist';
|
||||
import { isTracksBrowsePath } from '@/store/advancedSearchSessionStore';
|
||||
import { isComposersBrowsePath } from '@/features/composers';
|
||||
import { isPlaylistsBrowsePath } from '@/features/playlist';
|
||||
import { useLiveSearchScopeStore } from '@/store/liveSearchScopeStore';
|
||||
|
||||
/** Keep scope badge in sync with browse routes; clear field text when leaving browse. */
|
||||
@@ -20,6 +21,8 @@ export function syncLiveSearchRouteScope(pathname: string): void {
|
||||
store.setScope('tracks');
|
||||
} else if (isComposersBrowsePath(pathname)) {
|
||||
store.setScope('composers');
|
||||
} else if (isPlaylistsBrowsePath(pathname)) {
|
||||
store.setScope('playlists');
|
||||
} else {
|
||||
if (store.scope != null) store.clearScope();
|
||||
if (store.query !== '') store.setQuery('');
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -181,7 +181,6 @@ export function PersonalisationTab() {
|
||||
<SettingsSubSection
|
||||
title={t('settings.playerBarTitle')}
|
||||
icon={<Disc3 size={16} />}
|
||||
advanced
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import React from 'react';
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Gauge, Heart, PictureInPicture2, SlidersVertical, Star } from 'lucide-react';
|
||||
import { Gauge, GripVertical, Heart, PictureInPicture2, Shuffle, SlidersVertical, Square, Star } from 'lucide-react';
|
||||
import LastfmIcon from '@/ui/LastfmIcon';
|
||||
import {
|
||||
usePlayerBarLayoutStore,
|
||||
PLAYER_BAR_LAYOUT_ZONES,
|
||||
type PlayerBarLayoutItemConfig,
|
||||
type PlayerBarLayoutItemId,
|
||||
type PlayerBarTrackInfoMode,
|
||||
} from '@/features/playback/store/playerBarLayoutStore';
|
||||
import { useListReorderDnd } from '@/lib/hooks/useListReorderDnd';
|
||||
import { applyListReorderById, type ListReorderDropTarget } from '@/lib/util/listReorder';
|
||||
import { ReorderGripHandle } from '@/features/settings/components/ReorderGripHandle';
|
||||
import { SettingsSegmented } from '@/features/settings/components/SettingsSegmented';
|
||||
|
||||
const PLAYER_BAR_LAYOUT_LABEL_KEYS: Record<PlayerBarLayoutItemId, string> = {
|
||||
stop: 'settings.playerBarStop',
|
||||
shuffle: 'settings.playerBarShuffle',
|
||||
starRating: 'settings.playerBarStarRating',
|
||||
favorite: 'settings.playerBarFavorite',
|
||||
lastfmLove: 'settings.playerBarLastfmLove',
|
||||
@@ -17,6 +26,8 @@ const PLAYER_BAR_LAYOUT_LABEL_KEYS: Record<PlayerBarLayoutItemId, string> = {
|
||||
};
|
||||
|
||||
const PLAYER_BAR_LAYOUT_ICONS: Record<PlayerBarLayoutItemId, React.ReactNode> = {
|
||||
stop: <Square size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />,
|
||||
shuffle: <Shuffle size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />,
|
||||
starRating: <Star size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />,
|
||||
favorite: <Heart size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />,
|
||||
lastfmLove: (
|
||||
@@ -29,26 +40,90 @@ const PLAYER_BAR_LAYOUT_ICONS: Record<PlayerBarLayoutItemId, React.ReactNode> =
|
||||
miniPlayer: <PictureInPicture2 size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />,
|
||||
};
|
||||
|
||||
const REORDER_TYPE = 'player_bar_layout_reorder';
|
||||
|
||||
export function PlayerBarLayoutCustomizer() {
|
||||
const { t } = useTranslation();
|
||||
const items = usePlayerBarLayoutStore(s => s.items);
|
||||
const setItems = usePlayerBarLayoutStore(s => s.setItems);
|
||||
const toggleItem = usePlayerBarLayoutStore(s => s.toggleItem);
|
||||
const trackInfoMode = usePlayerBarLayoutStore(s => s.trackInfoMode);
|
||||
const setTrackInfoMode = usePlayerBarLayoutStore(s => s.setTrackInfoMode);
|
||||
|
||||
const itemsRef = useRef(items);
|
||||
// React Compiler refs rule: ref kept in sync with the latest value for use in handlers; not render data.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
itemsRef.current = items;
|
||||
|
||||
const transportItems = items.filter(i => PLAYER_BAR_LAYOUT_ZONES[i.id] === 'transport');
|
||||
const actionItems = items.filter(i => PLAYER_BAR_LAYOUT_ZONES[i.id] === 'actions');
|
||||
|
||||
// Reorder resolves by stable id against the FULL list, so the zone filter that
|
||||
// decides which rows are shown can never share an index space with the move
|
||||
// (the #1164 class of bug).
|
||||
const apply = useCallback((draggedId: string, target: ListReorderDropTarget) => {
|
||||
const next = applyListReorderById(itemsRef.current, draggedId, target);
|
||||
if (next) setItems(next);
|
||||
}, [setItems]);
|
||||
|
||||
const { isDragging, setContainer, onMouseMove, dropEdge } = useListReorderDnd({ type: REORDER_TYPE, apply });
|
||||
|
||||
const row = (it: PlayerBarLayoutItemConfig, draggable: boolean) => {
|
||||
const label = t(PLAYER_BAR_LAYOUT_LABEL_KEYS[it.id]);
|
||||
const edge = draggable && isDragging ? dropEdge(it.id) : null;
|
||||
return (
|
||||
<div
|
||||
key={it.id}
|
||||
data-reorder-id={draggable ? it.id : undefined}
|
||||
className="sidebar-customizer-row"
|
||||
style={{
|
||||
borderTop: edge === 'before' ? '2px solid var(--accent)' : undefined,
|
||||
borderBottom: edge === 'after' ? '2px solid var(--accent)' : undefined,
|
||||
}}
|
||||
>
|
||||
{draggable ? (
|
||||
<ReorderGripHandle id={it.id} type={REORDER_TYPE} label={label} />
|
||||
) : (
|
||||
// Transport items keep their fixed position, so they have no grip. The
|
||||
// spacer carries the same icon so it occupies the same width — an empty
|
||||
// span collapses and pulls the row out of line with the ones below.
|
||||
<span className="sidebar-customizer-grip" style={{ visibility: 'hidden' }} aria-hidden>
|
||||
<GripVertical size={16} />
|
||||
</span>
|
||||
)}
|
||||
{PLAYER_BAR_LAYOUT_ICONS[it.id]}
|
||||
<span style={{ flex: 1, fontSize: 14, opacity: it.visible ? 1 : 0.45 }}>{label}</span>
|
||||
<label className="toggle-switch" aria-label={label}>
|
||||
<input type="checkbox" checked={it.visible} onChange={() => toggleItem(it.id)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '4px 0' }}>
|
||||
{items.map((it) => {
|
||||
const label = t(PLAYER_BAR_LAYOUT_LABEL_KEYS[it.id]);
|
||||
return (
|
||||
<div key={it.id} className="sidebar-customizer-row">
|
||||
{PLAYER_BAR_LAYOUT_ICONS[it.id]}
|
||||
<span style={{ flex: 1, fontSize: 14, opacity: it.visible ? 1 : 0.45 }}>{label}</span>
|
||||
<label className="toggle-switch" aria-label={label}>
|
||||
<input type="checkbox" checked={it.visible} onChange={() => toggleItem(it.id)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="settings-group-title">{t('settings.playerBarTransportGroup')}</div>
|
||||
{transportItems.map(it => row(it, false))}
|
||||
|
||||
<div className="settings-group-title" style={{ marginTop: '0.75rem' }}>
|
||||
{t('settings.playerBarActionsGroup')}
|
||||
</div>
|
||||
<div ref={setContainer} onMouseMove={onMouseMove}>
|
||||
{actionItems.map(it => row(it, true))}
|
||||
</div>
|
||||
|
||||
<div className="settings-group-title" style={{ marginTop: '0.75rem' }}>
|
||||
{t('settings.playerBarTrackInfo')}
|
||||
</div>
|
||||
<SettingsSegmented<PlayerBarTrackInfoMode>
|
||||
options={[
|
||||
{ id: 'title', label: t('settings.playerBarTrackInfoTitle') },
|
||||
{ id: 'titleAlbum', label: t('settings.playerBarTrackInfoTitleAlbum') },
|
||||
]}
|
||||
value={trackInfoMode}
|
||||
onChange={setTrackInfoMode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
errorDetail,
|
||||
errorI18nKey,
|
||||
isMusicNetworkError,
|
||||
listPresets,
|
||||
@@ -33,8 +34,12 @@ export function ConnectProviderForm({
|
||||
p => !(p.manifest.credentials === 'bundled' && connectedPresetIds.includes(p.manifest.presetId)),
|
||||
);
|
||||
|
||||
const toMessage = (e: unknown): string =>
|
||||
isMusicNetworkError(e) ? t(errorI18nKey(e.code)) : t('musicNetwork.connectFailed');
|
||||
const toMessage = (e: unknown): string => {
|
||||
if (!isMusicNetworkError(e)) return t('musicNetwork.connectFailed');
|
||||
const message = t(errorI18nKey(e.code));
|
||||
const detail = errorDetail(e);
|
||||
return detail ? `${message} (${detail})` : message;
|
||||
};
|
||||
|
||||
const run = async (presetId: PresetId, payload: Record<string, string>) => {
|
||||
// Enforce the manifest's `required` fields client-side so an empty URL/token
|
||||
@@ -126,7 +131,9 @@ export function ConnectProviderForm({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{error && <p style={{ fontSize: 12, color: 'var(--danger)' }}>{error}</p>}
|
||||
{/* Selectable: the transport detail in a NETWORK error is what a bug report
|
||||
needs verbatim, and the app disables text selection everywhere else. */}
|
||||
{error && <p data-selectable style={{ fontSize: 12, color: 'var(--danger)' }}>{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,11 @@ export function useThemeUpdates(): RegistryTheme[] {
|
||||
|
||||
return useMemo(() => {
|
||||
if (!registry) return [];
|
||||
const installedVersionById = new Map(installed.map(t => [t.id, t.version]));
|
||||
// Dev theme-watch copies are session-only working state — offering a
|
||||
// registry "update" for them would overwrite the author's local work.
|
||||
const installedVersionById = new Map(
|
||||
installed.filter(t => !t.dev).map(t => [t.id, t.version]),
|
||||
);
|
||||
return registry.themes.filter(rt => {
|
||||
const current = installedVersionById.get(rt.id);
|
||||
return current != null && isNewer(rt.version, current);
|
||||
|
||||
@@ -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' }} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import axios from 'axios';
|
||||
import { getLuckyMixLibraryScopeOverride } from '@/lib/library/luckyMixScopeOverride';
|
||||
import md5 from 'md5';
|
||||
import { version } from '@/../package.json';
|
||||
import { SUBSONIC_CLIENT_ID } from '@/generated/appVersion';
|
||||
import { commands } from '@/generated/bindings';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import type { ServerProfile } from '@/store/authStoreTypes';
|
||||
@@ -9,7 +9,7 @@ import { connectBaseUrlForServer } from '@/lib/server/serverEndpoint';
|
||||
import { headersForServerRequest, serverHttpContextWireForProbe } from '@/lib/server/serverHttpHeaders';
|
||||
import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '@/lib/server/serverLookup';
|
||||
|
||||
export const SUBSONIC_CLIENT = `psysonic/${version}`;
|
||||
export const SUBSONIC_CLIENT = SUBSONIC_CLIENT_ID;
|
||||
|
||||
/** Subset of `ServerProfile` needed to attach gate headers on credential-based REST calls. */
|
||||
export type ServerHttpHeaderProfile = Pick<
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const AUTH_STORAGE_KEY = 'psysonic-auth';
|
||||
|
||||
/** Keep in sync with `public/startup-splash-preflight.js` and Rust `eval_startup_main_window_visibility`. */
|
||||
/** Keep in sync with `public/startup-splash-preflight.js` and `public/startup-splash-reveal.js`. */
|
||||
export const STARTUP_TRAY_HANDLED_KEY = 'psy-startup-tray-handled';
|
||||
|
||||
/** Read persisted "start minimized to tray" before Zustand rehydrates. */
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
validateThemeCss,
|
||||
injectTheme,
|
||||
syncInjectedThemes,
|
||||
gateInjectedThemes,
|
||||
} from '@/lib/themes/themeInjection';
|
||||
import type { InstalledTheme } from '@/store/installedThemesStore';
|
||||
|
||||
@@ -120,3 +121,32 @@ describe('syncInjectedThemes', () => {
|
||||
expect(injected()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gateInjectedThemes', () => {
|
||||
const media = (id: string) =>
|
||||
document.head.querySelector<HTMLStyleElement>(`style[${ATTR}="${id}"]`)?.media;
|
||||
|
||||
it('disables inactive styles via media="not all" and keeps active ones on', () => {
|
||||
syncInjectedThemes([mk('a', block('a')), mk('b', block('b')), mk('c', block('c'))]);
|
||||
gateInjectedThemes(['b']);
|
||||
expect(media('a')).toBe('not all');
|
||||
expect(media('b')).toBe('all');
|
||||
expect(media('c')).toBe('not all');
|
||||
});
|
||||
|
||||
it('re-enables a style when its theme becomes active', () => {
|
||||
syncInjectedThemes([mk('a', block('a')), mk('b', block('b'))]);
|
||||
gateInjectedThemes(['a']);
|
||||
gateInjectedThemes(['b']);
|
||||
expect(media('a')).toBe('not all');
|
||||
expect(media('b')).toBe('all');
|
||||
});
|
||||
|
||||
it('keeps every listed slot active (theme + scheduler day/night)', () => {
|
||||
syncInjectedThemes([mk('a', block('a')), mk('b', block('b')), mk('c', block('c'))]);
|
||||
gateInjectedThemes(['a', 'c']);
|
||||
expect(media('a')).toBe('all');
|
||||
expect(media('b')).toBe('not all');
|
||||
expect(media('c')).toBe('all');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,3 +97,25 @@ export function syncInjectedThemes(themes: InstalledTheme[]): void {
|
||||
});
|
||||
for (const theme of themes) injectTheme(theme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate injected theme styles by activity: only the styles for `activeIds`
|
||||
* (the applied theme plus the scheduler's day/night slots) participate in
|
||||
* style matching — every other installed theme's <style> gets
|
||||
* `media="not all"`, so the browser skips its rules entirely. With many
|
||||
* installed themes (a big collection, or a dev `--theme-watch` checkout
|
||||
* sweep), an ungated head makes every style recalculation — e.g. `:hover`
|
||||
* while moving the mouse across the Themes grid — walk all of their rules;
|
||||
* gating keeps recalc cost independent of the installed count. Switching
|
||||
* themes only flips the attribute: synchronous, no re-inject, no flash.
|
||||
*/
|
||||
export function gateInjectedThemes(activeIds: Iterable<string>): void {
|
||||
const active = new Set(activeIds);
|
||||
document.head
|
||||
.querySelectorAll<HTMLStyleElement>(`style[${ATTR}]`)
|
||||
.forEach((el) => {
|
||||
const id = el.getAttribute(ATTR);
|
||||
const media = id && active.has(id) ? 'all' : 'not all';
|
||||
if (el.media !== media) el.media = media;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,6 +11,8 @@ export const artistDetail = {
|
||||
playAllTooltip: 'Пусни цялата дискография',
|
||||
shuffleTooltip: 'Разбъркай цялата дискография',
|
||||
radioTooltip: 'Стартирай радио на базата на този изпълнител',
|
||||
enqueue: 'Добави в опашката',
|
||||
enqueueTooltip: 'Добави цялата дискография в опашката',
|
||||
loading: 'Зареждане…',
|
||||
noRadio: 'Няма намерени подобни песни за този изпълнител.',
|
||||
notFound: 'Изпълнителят не е намерен.',
|
||||
|
||||
@@ -38,6 +38,8 @@ export const player = {
|
||||
repeat: 'Повторение',
|
||||
repeatOff: 'Изключено',
|
||||
repeatAll: 'Всички',
|
||||
shuffleOn: 'Вкл.',
|
||||
shuffleOff: 'Изкл.',
|
||||
repeatOne: 'Една',
|
||||
progress: 'Прогрес на песента',
|
||||
volume: 'Сила на звука',
|
||||
|
||||
@@ -9,6 +9,7 @@ export const playlists = {
|
||||
create: 'Създай',
|
||||
cancel: 'Отказ',
|
||||
empty: 'Все още няма плейлисти.',
|
||||
noMatchingSearch: 'Няма плейлисти, които съответстват на търсенето.',
|
||||
emptyPlaylist: 'Този плейлист е празен.',
|
||||
addFirstSong: 'Добавете първата си песен',
|
||||
notFound: 'Плейлистът не е намерен.',
|
||||
|
||||
@@ -60,6 +60,9 @@ export const search = {
|
||||
scopeComposersPlaceholder: 'Търси композитор…',
|
||||
scopeComposersBadgeTooltip: 'Кликнете, за да премахнете',
|
||||
scopeComposersGhostTooltip: 'Кликнете, за да търсите само на тази страница',
|
||||
scopePlaylistsPlaceholder: 'Търси плейлист…',
|
||||
scopePlaylistsBadgeTooltip: 'Кликнете, за да премахнете',
|
||||
scopePlaylistsGhostTooltip: 'Кликнете, за да търсите само на тази страница',
|
||||
genres: 'Жанрове',
|
||||
shareLink: 'Връзка за споделяне',
|
||||
shareTrackTitle: 'Споделена песен',
|
||||
|
||||
@@ -295,8 +295,9 @@ export const settings = {
|
||||
linuxWaylandTextRenderGpu: 'Приоритет на GPU',
|
||||
linuxWaylandTextRenderMinimal: 'Минимално (изглаждане по подразбиране от CSS)',
|
||||
discordCoverTitle: 'Източник на корица',
|
||||
discordCoverDesc: 'Откъде идва обложката на албума в профила ти в Discord.',
|
||||
discordCoverDesc: 'Откъде идва обложката на албума в профила ти в Discord. Обложките от сървъра използват публична връзка към изображение без данни за достъп — всеки, който вижда профила ти, може да разбере публичния адрес на сървъра ти. Изисква публично достъпен сървър.',
|
||||
discordCoverNone: 'Няма (само икона на приложението)',
|
||||
discordCoverServer: 'Сървър (чрез информация за албума)',
|
||||
discordCoverApple: 'Apple Music',
|
||||
discordOptions: 'Разширени настройки за Discord',
|
||||
discordTemplates: 'Персонализирани текстови шаблони',
|
||||
@@ -542,6 +543,13 @@ export const settings = {
|
||||
playerBarPlaybackRate: 'Скорост на възпроизвеждане',
|
||||
playerBarEqualizer: 'Еквалайзер',
|
||||
playerBarMiniPlayer: 'Мини плейър',
|
||||
playerBarStop: 'Бутон за спиране',
|
||||
playerBarShuffle: 'Разбъркано възпроизвеждане',
|
||||
playerBarTransportGroup: 'Управление на възпроизвеждането',
|
||||
playerBarActionsGroup: 'Действия',
|
||||
playerBarTrackInfo: 'Информация за песента',
|
||||
playerBarTrackInfoTitle: 'Само заглавие',
|
||||
playerBarTrackInfoTitleAlbum: 'Заглавие + албум',
|
||||
playbackRateTitle: 'Скорост на възпроизвеждане',
|
||||
playbackRateEnabled: 'Включи скоростта на възпроизвеждане',
|
||||
playbackRateEnabledDesc: 'Променя скоростта глобално за всички песни. Не се прилага за радио или предварителни прослушвания.',
|
||||
@@ -702,9 +710,9 @@ export const settings = {
|
||||
queueBehaviourTitle: 'Поведение на опашката',
|
||||
preservePlayNextOrder: 'Запази реда на „Изпълни следваща“',
|
||||
preservePlayNextOrderDesc: 'Новодобавените елементи чрез „Изпълни следваща“ се нареждат зад по-ранните, вместо да изскачат отпред.',
|
||||
queueTrackListCovers: 'Album art in queue',
|
||||
queueTrackListCoversSub: 'Show a small album cover beside each track in the queue panel, mini player queue, and fullscreen Up next. Aggressive caching is recommended.',
|
||||
trackListCoverArtOnPages: 'Album art in track lists',
|
||||
queueTrackListCovers: 'Обложки в опашката',
|
||||
queueTrackListCoversSub: 'Миниатюра на албума до всяка песен в панела на опашката, опашката на мини плейъра и „Следващи“ на цял екран. Препоръчва се агресивно кеширане.',
|
||||
trackListCoverArtOnPages: 'Обложки в списъците с песни',
|
||||
trackListCoverArtOnPagesSub: 'Миниатюра на албума до песните в Песни, Търсене, плейлисти, любими, случаен микс и други списъци за преглед. Препоръчва се агресивно кеширане.',
|
||||
trackPreviewsTitle: 'Предварителни прослушвания',
|
||||
trackPreviewsToggle: 'Включи предварителните прослушвания',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const artistDetail = {
|
||||
playAllTooltip: 'Gesamte Diskografie abspielen',
|
||||
shuffleTooltip: 'Gesamte Diskografie zufällig abspielen',
|
||||
radioTooltip: 'Radio basierend auf diesem Künstler starten',
|
||||
enqueue: 'Einreihen',
|
||||
enqueueTooltip: 'Gesamte Diskografie zur Warteschlange hinzufügen',
|
||||
loading: 'Lädt…',
|
||||
noRadio: 'Keine ähnlichen Titel für diesen Künstler gefunden.',
|
||||
notFound: 'Künstler nicht gefunden.',
|
||||
|
||||
@@ -38,6 +38,8 @@ export const player = {
|
||||
repeat: 'Wiederholen',
|
||||
repeatOff: 'Aus',
|
||||
repeatAll: 'Alle',
|
||||
shuffleOn: 'An',
|
||||
shuffleOff: 'Aus',
|
||||
repeatOne: 'Einen',
|
||||
progress: 'Songfortschritt',
|
||||
volume: 'Lautstärke',
|
||||
|
||||
@@ -9,6 +9,7 @@ export const playlists = {
|
||||
create: 'Erstellen',
|
||||
cancel: 'Abbrechen',
|
||||
empty: 'Noch keine Playlists.',
|
||||
noMatchingSearch: 'Keine Playlists entsprechen deiner Suche.',
|
||||
emptyPlaylist: 'Diese Playlist ist leer.',
|
||||
addFirstSong: 'Ersten Song hinzufügen',
|
||||
notFound: 'Playlist nicht gefunden.',
|
||||
|
||||
@@ -60,6 +60,9 @@ export const search = {
|
||||
scopeComposersPlaceholder: 'Komponist suchen…',
|
||||
scopeComposersBadgeTooltip: 'Klicken — entfernen',
|
||||
scopeComposersGhostTooltip: 'Klicken — nur auf dieser Seite suchen',
|
||||
scopePlaylistsPlaceholder: 'Playlist suchen…',
|
||||
scopePlaylistsBadgeTooltip: 'Klicken — entfernen',
|
||||
scopePlaylistsGhostTooltip: 'Klicken — nur auf dieser Seite suchen',
|
||||
genres: 'Genres',
|
||||
shareLink: 'Share link',
|
||||
shareTrackTitle: 'Shared track',
|
||||
|
||||
@@ -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',
|
||||
@@ -498,6 +499,13 @@ export const settings = {
|
||||
playerBarPlaybackRate: 'Wiedergabegeschwindigkeit',
|
||||
playerBarEqualizer: 'Equalizer',
|
||||
playerBarMiniPlayer: 'Mini-Player',
|
||||
playerBarStop: 'Stopp-Schaltfläche',
|
||||
playerBarShuffle: 'Zufallswiedergabe',
|
||||
playerBarTransportGroup: 'Wiedergabesteuerung',
|
||||
playerBarActionsGroup: 'Aktionen',
|
||||
playerBarTrackInfo: 'Titelinfo',
|
||||
playerBarTrackInfoTitle: 'Nur Titel',
|
||||
playerBarTrackInfoTitleAlbum: 'Titel + Album',
|
||||
playbackRateTitle: 'Wiedergabegeschwindigkeit',
|
||||
playbackRateEnabled: 'Wiedergabegeschwindigkeit aktivieren',
|
||||
playbackRateEnabledDesc: 'Globale Geschwindigkeit für alle Titel. Gilt nicht für Radio und Vorschau.',
|
||||
@@ -635,10 +643,10 @@ export const settings = {
|
||||
queueBehaviourTitle: 'Warteschlangen-Verhalten',
|
||||
preservePlayNextOrder: '„Als Nächstes"-Reihenfolge bewahren',
|
||||
preservePlayNextOrderDesc: 'Neu hinzugefügte „Als Nächstes"-Titel reihen sich hinten an statt sich vorn einzuschieben.',
|
||||
queueTrackListCovers: 'Album art in queue',
|
||||
queueTrackListCoversSub: 'Show a small album cover beside each track in the queue panel, mini player queue, and fullscreen Up next. Aggressive caching is recommended.',
|
||||
trackListCoverArtOnPages: 'Album art in track lists',
|
||||
trackListCoverArtOnPagesSub: 'Kleines Albumcover neben Titeln in den Bereichen Titel, Suche, Playlists, Favoriten, Zufallsmix und anderen Übersichtslisten. Aggressives Caching wird empfohlen.',
|
||||
queueTrackListCovers: 'Albumcover in der Warteschlange',
|
||||
queueTrackListCoversSub: 'Kleines Albumcover neben jedem Titel im Warteschlangen-Panel, in der Mini-Player-Warteschlange und in „Als Nächstes“ im Vollbild. Aggressives Caching wird empfohlen.',
|
||||
trackListCoverArtOnPages: 'Albumcover in Tracklisten',
|
||||
trackListCoverArtOnPagesSub: 'Kleines Albumcover neben jedem Titel auf der Titel-Seite, in der Suche, in Playlists, Favoriten, im Zufallsmix und anderen Übersichtslisten. Aggressives Caching wird empfohlen.',
|
||||
trackPreviewsTitle: 'Track-Vorschau',
|
||||
trackPreviewsToggle: 'Track-Vorschau aktivieren',
|
||||
trackPreviewsDesc: 'Inline Play- und Vorschau-Buttons in den Tracklisten anzeigen für eine kurze Hörprobe mitten im Song.',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const artistDetail = {
|
||||
playAllTooltip: 'Play the whole discography',
|
||||
shuffleTooltip: 'Shuffle the whole discography',
|
||||
radioTooltip: 'Start a radio based on this artist',
|
||||
enqueue: 'Enqueue',
|
||||
enqueueTooltip: 'Add the whole discography to queue',
|
||||
loading: 'Loading…',
|
||||
noRadio: 'No similar tracks found for this artist.',
|
||||
notFound: 'Artist not found.',
|
||||
|
||||
@@ -38,6 +38,8 @@ export const player = {
|
||||
repeat: 'Repeat',
|
||||
repeatOff: 'Off',
|
||||
repeatAll: 'All',
|
||||
shuffleOn: 'On',
|
||||
shuffleOff: 'Off',
|
||||
repeatOne: 'One',
|
||||
progress: 'Song Progress',
|
||||
volume: 'Volume',
|
||||
|
||||
@@ -9,6 +9,7 @@ export const playlists = {
|
||||
create: 'Create',
|
||||
cancel: 'Cancel',
|
||||
empty: 'No playlists yet.',
|
||||
noMatchingSearch: 'No playlists match your search.',
|
||||
emptyPlaylist: 'This playlist is empty.',
|
||||
addFirstSong: 'Add your first song',
|
||||
notFound: 'Playlist not found.',
|
||||
|
||||
@@ -60,6 +60,9 @@ export const search = {
|
||||
scopeComposersPlaceholder: 'Search for composer…',
|
||||
scopeComposersBadgeTooltip: 'Click to remove',
|
||||
scopeComposersGhostTooltip: 'Click to search on this page only',
|
||||
scopePlaylistsPlaceholder: 'Search for playlist…',
|
||||
scopePlaylistsBadgeTooltip: 'Click to remove',
|
||||
scopePlaylistsGhostTooltip: 'Click to search on this page only',
|
||||
genres: 'Genres',
|
||||
shareLink: 'Share link',
|
||||
shareTrackTitle: 'Shared track',
|
||||
|
||||
@@ -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',
|
||||
@@ -542,6 +543,13 @@ export const settings = {
|
||||
playerBarPlaybackRate: 'Playback speed',
|
||||
playerBarEqualizer: 'Equalizer',
|
||||
playerBarMiniPlayer: 'Mini player',
|
||||
playerBarStop: 'Stop button',
|
||||
playerBarShuffle: 'Shuffle',
|
||||
playerBarTransportGroup: 'Transport',
|
||||
playerBarActionsGroup: 'Actions',
|
||||
playerBarTrackInfo: 'Track info',
|
||||
playerBarTrackInfoTitle: 'Title only',
|
||||
playerBarTrackInfoTitleAlbum: 'Title + album',
|
||||
playbackRateTitle: 'Playback speed',
|
||||
playbackRateEnabled: 'Enable playback speed',
|
||||
playbackRateEnabledDesc: 'Change speed globally for all tracks. Not applied to radio or previews.',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const artistDetail = {
|
||||
playAllTooltip: 'Reproducir toda la discografía',
|
||||
shuffleTooltip: 'Reproducir toda la discografía en aleatorio',
|
||||
radioTooltip: 'Iniciar una radio basada en este artista',
|
||||
enqueue: 'Agregar a la Cola',
|
||||
enqueueTooltip: 'Agregar toda la discografía a la cola',
|
||||
loading: 'Cargando…',
|
||||
noRadio: 'No se encontraron pistas similares para este artista.',
|
||||
notFound: 'Artista no encontrado.',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user