mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
d50c9c444dc8412e47123c8ca4926116197de6ab
78 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d50c9c444d |
refactor(settings): reusable SettingsGroup/SettingsToggle + boxed sections across tabs (#1126)
* refactor(settings): extract reusable SettingsGroup component
Pull the boxed sub-section pattern (bordered panel + accent uppercase
header) introduced for the Audio tab into a reusable <SettingsGroup
title desc> component, and migrate NormalizationBlock and
PlaybackBehaviorBlock onto it. No visual change.
* feat(settings): box the System behavior section into groups
Split the System -> App Behavior card (which bundled tray toggles, Linux
rendering tweaks and the clock format) into titled SettingsGroup panels
(Tray / Linux rendering / Clock) for a clearer, consistent boxed look.
Adds the group titles across all 9 locales.
* feat(settings): box Appearance visual options; optional SettingsGroup title
Split the Appearance -> Visual options card into a Display group and a
Window group (Linux custom titlebar controls). SettingsGroup now allows an
optional/omitted title for plain boxed panels. Group titles added across
all 9 locales.
* feat(settings): box Discord cover source and templates
Group the Discord cover-source toggles and the activity templates into
separate SettingsGroup panels (templates reuse their existing title/desc),
dropping the manual indent and inline header. No new strings.
* feat(settings): box the Music Network section
Give the scrobble-master toggle, the enrichment-primary picker and the
provider list each a boxed SettingsGroup with an accent header, and switch
the add-a-service provider rows to the boxed panel style for consistent
contrast with the rest of settings.
* feat(settings): titled boxes for Integrations sections + cover source label
Wrap the Discord enable toggle, the Discord cover-source picker (now with a
"Cover art source" title and explainer), Bandsintown and Show-in-Now-Playing
in titled SettingsGroup panels so each single-item section gets the same
accent-headed boxed look. New cover-source strings across all 9 locales.
* feat(settings): box Lyrics tab sections
Wrap the lyrics-sources customizer and the sidebar-style picker in titled
SettingsGroup panels (reusing the existing section titles).
* refactor(settings): consistent content inset in SettingsGroup
Add a body wrapper with a small left inset so every boxed section indents its
controls uniformly (title stays flush at the box edge). Defined once in the
component instead of per section.
* feat(settings): box Storage tab sections incl. cover art cache
Wrap media directory, cover art cache strategy, next-track buffering and
downloads in titled SettingsGroup panels (reusing existing section titles);
align the cover-cache table's first column flush with the new content inset.
* feat(settings): box Library tab, relocate Lucky Mix, add SettingsToggle
- Add a reusable SettingsToggle component for the repeated label/desc/switch row.
- Box the Random Mix blacklist (grouping built-in keywords with the custom
filter), Ratings and the Analytics strategy section.
- Move the "Show Lucky Mix in menu" toggle out of the blacklist and up next to
split-mix navigation and now-playing-at-top in the sidebar customizer.
* feat(settings): box remaining Appearance sections
Wrap library grid, UI scale, font and seekbar style in titled SettingsGroup
panels (reusing the existing section titles).
* feat(settings): box System language, logging and backup sections
Wrap language and logging in titled SettingsGroup panels, and rework the
backup section to drop its redundant inline header (the subsection already
provides it) and box its content. About / Contributors / Licenses stay as
free display content.
* feat(settings): box remaining Audio sections
Wrap Hi-Res, equalizer, playback speed, audio output device and track
previews in titled SettingsGroup panels (reusing the existing section titles).
* feat(settings): box Input tab keybinding sections
Wrap the in-app and global shortcut lists in titled SettingsGroup panels.
* feat(settings): box the Personalisation tab
Convert every customizer (sidebar, home, artist, queue toolbar, playlist,
player bar) to plain inner containers and wrap each in a titled SettingsGroup,
so the boxed look is consistent without nested cards. Box the queue display
mode picker too.
* feat(settings): box the Themes sections above the store
Add a boxed option to the flat Themes sections and apply it to Your Themes,
Auto-Switch Theme and Import (the theme store stays unboxed). Drop the inner
settings-card from InstalledThemes, ThemeImportSection and the scheduler so
the SettingsGroup is the only frame.
* fix(settings): give the Themes boxes a card carrier and accent header
The flat Themes sections had no settings-card behind them, so the bg-app
SettingsGroup had no contrast against the page. Box each above-store section
as settings-card > titled SettingsGroup (accent header) like the rest.
* fix(settings): clean up the server library sync status line
Collapse the offline status to a single icon + phrase ("Server offline —
sync deferred") instead of a redundant "Deferred" badge alongside it, and
drop the now-unused string across all 9 locales.
* fix(queue): keep toolbar toggle buttons coloured active while hovered
The generic .queue-round-btn:hover rule outweighed .active in specificity, so
an active button kept the hover colour until the pointer left — the toggle
only looked on after moving the mouse away. Add an explicit active-hover rule
(mirroring the mini player) so active wins immediately on click.
* fix(settings): fix Lyrics triple title, drop dead Discord cover keys
- Remove LyricsSourcesCustomizer's own section header (the subsection already
titles it) and make its inner cards plain; the Lyrics boxes are now title-less.
- Drop the dead discordCoverSource/discordCoverSourceDesc i18n keys (superseded
by discordCoverTitle/Desc) and un-jam the libraryIndexServer status line in
all 9 locales.
* refactor(settings): drop redundant group titles and double frames
Single-group sections duplicated the subsection title in the group header — make
those groups title-less so the subsection header is the only title. A card whose
sole child is one group now collapses via CSS so single-group sections render a
single frame (matching the card-less Lyrics layout) instead of a card-in-group
double border. Multi-group cards keep their frame as the grouping container.
* fix(settings): keep the flat Themes cards framed when collapsing single-group cards
The single-group card collapse also flattened the Themes section cards, which —
not being inside an accordion — are themselves the contrast surface. Exclude
.themes-section descendants from the collapse.
* refactor(settings): use SettingsToggle for Appearance and System toggle rows
Replace the hand-rolled toggle-row markup in the Appearance visual options and
System tray/Linux/changelog rows with the shared SettingsToggle component, and
extend it with a searchText prop and ReactNode descriptions for the remaining
call sites.
* refactor(settings): roll SettingsToggle out to Integrations, Audio and Storage
Replace hand-rolled toggle rows in the Discord/Bandsintown/Now-Playing,
Hi-Res, hot cache and playback behaviour/rate sections with SettingsToggle.
Make its label optional (desc-only rows whose title is the group header) and
add searchText/id pass-throughs.
* refactor(settings): use SettingsToggle for the remaining standard toggle rows
Track previews master, sidebar lyrics style, YouLyPlus/static-only, queue
display mode and the sidebar split-nav/now-playing/lucky-mix rows now use the
shared component. Genuinely custom rows (drag lists, the skip-star threshold,
the AudioMuse row with its inline link, the fine-step advanced badge) stay
hand-rolled.
* fix(settings): drop duplicate titles on Hi-Res/Bandsintown/Now-Playing, un-jam locale line
These single-toggle sections kept a group title identical to their subsection
header; once the single-group card collapses the title rendered twice stacked.
Make them title-less (the subsection header is the label). Also split the
discordCoverTitle locale entry onto its own line in all 9 locales.
* feat(settings): restore section icons in boxed Themes headers
SettingsGroup gained an optional accent icon slot rendered before the
title; ThemesTab forwards the Palette/Clock/Upload icons the boxed
sections lost when they moved off the bespoke <h2> header.
* style(settings): re-indent SettingsGroup children to nesting level
Pure whitespace: children wrapped in SettingsGroup were left at their
pre-wrap indent. No content change (git diff -w is empty).
* fix(settings): clarify Native Hi-Res Playback description
The old copy led with the disabled-state behaviour ("forces 44.1 kHz"),
which read as if the toggle itself locked output to 44.1 kHz. Describe
what enabling actually does: play each track at its native sample rate
(reconfiguring the output device to match) instead of resampling to
44.1 kHz. Updated across all 9 locales.
* docs: changelog + what's-new for the settings layout refactor (#1126)
|
||
|
|
fde7ab432f |
feat: split AutoDJ into a standalone playback feature (#1124)
* feat(playback): add transition-mode helper Centralise the crossfade/AutoDJ/gapless mutual exclusivity in one place instead of the scattered setter combinations across the toolbar, mini player and settings. AutoDJ stays encoded as crossfade + trim-silence, so the persisted flags and the audio engine are unchanged. * feat(queue): split AutoDJ into its own toolbar button + playlist submenu - AutoDJ becomes a standalone toolbar button (Blend icon) next to crossfade, driven by the shared transition-mode helper. The crossfade right-click popover drops the mode switch and keeps only the seconds slider. - Save + load playlist collapse into one Playlist button opening a small submenu, freeing up toolbar space. - queueToolbarStore gains a position-preserving rehydrate migration (legacy save/load -> playlist, autodj inserted after crossfade) with unit tests. - Toolbar customizer and all 9 queue locales updated to match. * feat(mini-player): standalone AutoDJ button, shared transition helper Mirror the queue toolbar: AutoDJ gets its own Blend button, the crossfade popover keeps only the seconds slider. The mini player now drives all three transitions through a single additive `mini:set-transition-mode` event handled by the shared helper, replacing the per-flag mini events. Drop the now-dead crossfade-mode CSS. * feat(settings): segmented track-transition picker, regroup playback Replace the crossfade toggle + inner crossfade/AutoDJ switch with a single Off | Gapless | Crossfade | AutoDJ segmented control (mirroring the Normalization picker above), driven by the shared transition helper — the mutual exclusivity now reads at a glance. Crossfade keeps its seconds slider; AutoDJ shows its content-driven explainer. The block is regrouped under "Track transitions" and "Queue behaviour" headings. Drops the now unused crossfade/gapless description and not-available i18n keys across all 9 locales and adds the new transition strings. * fix(queue): open the playlist submenu inward The playlist submenu inherited the crossfade popover's right:0 anchor and, sitting on the left of the toolbar, opened out under the main container. Anchor it left:0 so it stays inside the queue panel. Update the toolbar test for the new playlist submenu (save/load moved off the toolbar). * feat(settings): box playback sub-sections into panels Wrap Normalization, Track transitions and Queue behaviour each in their own bordered panel with an accent uppercase header (new reusable .settings-group classes), so the sections read as distinct blocks instead of one wall of text. Drops the thin divider that separated them. * docs: changelog, credits and what's new for AutoDJ standalone Fold the standalone-AutoDJ changes into the existing AutoDJ entry in the changelog and the in-app What's New, and add the credit (#1124). |
||
|
|
a6ee0668c8 |
feat(crossfade): AutoDJ — content-aware silence-trimming crossfade (#1122)
* feat(crossfade): add "trim silence between tracks" toggle
New persisted setting `crossfadeTrimSilence` (default off; existing
installs rehydrate off via the persist default-merge). Surfaced in
Settings -> Audio and in the crossfade popovers of the queue toolbar
and the mini-player.
Crossfade buttons now separate the two actions: left-click toggles
crossfade on/off, right-click opens the settings popover (seconds +
trim). Shared the mini popover positioning into useMiniAnchoredPopover
(now backing both volume and crossfade). Mini bridge carries
crossfadeSecs/crossfadeTrimSilence and gains mini:set-crossfade-secs /
mini:set-crossfade-trim-silence.
The actual silence-trimming playback behaviour is wired in a follow-up;
this commit only persists the user intent. i18n added across 9 locales.
* feat(crossfade): trim silence between tracks (waveform-driven)
Wire the actual silence-aware crossfade behind the (default-off)
crossfadeTrimSilence toggle. Detection is derived on the fly from the
cached 500-bin waveform + track duration — no new analysis pass or
cache fields.
- waveformSilence.ts: computeWaveformSilence(bins, duration) → lead/trail
silence + content bounds, using the peak curve, a low absolute cut and
a per-side cap. Unit-tested.
- A-tail (JS): handleAudioProgress advances the crossfade early, at
contentEnd - crossfadeSecs, when the current track ends in real
trailing silence, so the fade overlaps music. Guarded once per play
generation.
- B-head: audio_play gains an additive optional start_secs; the freshly
built source is try_seek'd past the next track's leading silence before
append, then seek_offset/samples_played are re-anchored so position is
content-relative. Non-seekable / cold sources degrade to today.
- Pre-buffer: crossfade next-track download + B-head probe moved to
crossfadePreload.ts with a fixed ~30 s budget before the track needs to
play (widened by trailing silence so the early advance keeps the
budget). Also fired right after a seek into the window so jumping near
the end still buffers in time.
Checks: tsc, vitest (store suite + new units), cargo test/clippy for
psysonic-audio.
* feat(crossfade): recommend hot cache for trim; probe B-head regardless
Add a "for reliable results, enable the Hot playback cache" note to the
trim-silence toggle description across all 9 locales — hot cache keeps
the next track on disk so it starts instantly past its lead silence.
Fix: the leading-silence probe (B-head) now runs even when hot cache is
on; only the redundant byte pre-download is gated on !hotCache. Without
this, enabling hot cache (the recommended setting) would have skipped the
probe and disabled leading-silence trimming.
* feat(crossfade): content-driven smart crossfade overlap
Smart crossfade derives the per-transition overlap purely from the
waveform envelopes — max(A outro fade, B intro rise) clamped 0.5–12s —
instead of the fixed crossfadeSecs ("work by fact"). The JS early
advance arms the computed overlap and audio_play applies it through a
new crossfade_secs_override (capping only this swap's fade); plain
loud→loud endings fall back to the engine crossfade at crossfadeSecs.
* feat(crossfade): "Crossfade | Smart crossfade" mode switch in UI
Replace the standalone "trim silence" toggle with a Crossfade / Smart
crossfade segmented control in settings and both crossfade popovers
(queue toolbar + mini-player). Classic Crossfade shows the seconds
slider; Smart crossfade is content-driven with no duration to set.
Adds smartCrossfade / smartCrossfadeDesc strings to all nine locales
and the "smart crossfade" search keyword.
* feat(crossfade): don't double-fade a track that already fades out
Decouple the outgoing track's fade-out from the incoming fade-in. When
A carries its own recorded fade-out (outroFadeA ≥ 1s and ≥ B's intro
rise), planCrossfadeTransition now sets outgoingFadeSec = 0; the engine
then skips A's TriggeredFadeOut so A keeps full gain and its recording
carries it down while B rises underneath — no more double attenuation
that made A vanish early and B blare in. Hard-cut endings still get an
engine fade over the overlap.
audio_play gains outgoing_fade_secs_override (Some(0) = ride A's own
fade), threaded via SinkSwapInputs.outgoing_fade_secs; the JS advance
arms it alongside the overlap.
* feat(crossfade): rename the smart crossfade mode to "AutoDJ"
User-facing rename of the content-driven crossfade mode from "Smart
crossfade" to "AutoDJ" across the settings segmented switch, the queue
and mini-player popovers, all nine locales, and the settings search
keywords. The underlying store flag (crossfadeTrimSilence) is unchanged.
* feat(crossfade): standard ~2s blend for hard loud→loud meetings
When AutoDJ trims a track's protective trailing silence and the loud
ending butts straight into a loud intro, neither edge fades, so the old
0.5s anti-click floor sounded like an abrupt cut. Use a standard ~2s
equal-power crossfade for that case (both edges analysed, nothing
fades); real fade-outs/buildups keep their longer content-driven span,
and the bare floor only survives when an envelope is missing.
* fix(crossfade): keep B's fade-in across the B-head start-offset seek
EqualPowerFadeIn::try_seek jumped straight to unity gain for any seek
≥100ms, which also hit the initial start-offset seek that skips the
incoming track's leading silence — so a crossfaded track with trimmed
lead silence popped in at full gain instead of fading in. Only skip the
fade-in for mid-playback seeks (sample_count > 0); a seek before any
audio has played keeps the fade-in.
* feat(crossfade): gate AutoDJ early fade on next-track readiness
The early, content-driven advance now fires only when the next track's
audio is actually available — in the engine RAM preload slot
(enginePreloadedTrackId) or local on disk (offline library, favourite-auto
or hot-cache ephemeral) — via isCrossfadeNextReady(). Analysis alone is
not enough: a cold, still-buffering stream would fade in over silence.
When B isn't ready the gen guard stays unset so it re-checks on later
ticks; if B never readies, the plain engine crossfade handles the
transition (graceful degrade) instead of a broken fade. A RAM preload
copy suffices — the full track need not be cached to disk.
* feat(crossfade): suppress engine auto-crossfade for AutoDJ + eager preload
With the hot cache off the readiness gate alone wasn't enough: the engine's
progress task autonomously fires its crossfade audio:ended ~crossfadeSecs
before the end, independently of JS, and would start a still-buffering next
track and fade over it — an audible jump.
- Engine: add autodj_suppress_autocrossfade flag (audio_set_autodj_suppress);
the progress task treats it like crossfade-off, so the early timer never
fires and audio:ended only comes from real source exhaustion / watchdog.
- JS drives the transition: set the flag when a content fade is pending
(wantEarly) and clear it for plain loud→loud / non-AutoDJ, so the normal
engine crossfade is preserved there. When the next track never readies, A
plays out and we degrade to a clean sequential start instead of a jump.
- audio_preload gains an `eager` flag; the crossfade/AutoDJ pre-buffer passes
it to skip the 8s start throttle so the RAM slot fills before the fade.
* docs(changelog): AutoDJ content-aware crossfade (PR #1122)
Add the 1.49.0 Added entry and the cucadmuh credits line for AutoDJ.
|
||
|
|
ed52a9991f |
feat(albums): "Artist → Year" album sort option (#1120)
* feat(albums): "Artist → Year" sort option Adds a third album browse sort that groups albums by artist and orders each artist's albums chronologically (oldest first, title as a same-year tiebreak) — the double-sort requested in #1113. The local index sorts globally via [{artist},{year},{name}]; the server fallback fetches by artist and applies the year ordering per page (Subsonic has no compound sort). * i18n(albums): Artist → Year sort label (9 locales) * docs(changelog): add Artist → Year to the album sorting entry (#1120) |
||
|
|
ad74578ef6 |
feat(playlists): local playlist folders (sidebar + page, DnD, view toggle) (#1119)
* feat(playlists): playlist folder model — store + pure grouping core Local, per-server folder layer over the server's flat playlist list (the Subsonic API has no folder concept). Adds: - playlistFolders.ts: shared types + pure groupPlaylistsByFolder (used by every surface), with full unit coverage. - playlistFolderStore.ts: persisted Zustand store (create/rename/delete/ assign/collapse), per-server scoped; deleting a folder drops its assignments so playlists fall back to ungrouped. UI surfaces (sidebar + Playlists page) build on this in following commits. * i18n(playlists): folder strings across all 9 locales Adds the playlists.folders.* namespace (folder names, move/remove, expand/ collapse, group-by-folders toggle, count plurals, and the local-only notice explaining that Navidrome and the Subsonic API have no native folder support). * feat(playlists): folder views, drag-to-folder, move-to-folder menu, view toggle Surfaces the local folder layer on both the Playlists page and the sidebar: - Page: collapsible folder sections + ungrouped remainder, each reusing VirtualCardGrid; flat grid is kept verbatim when no folders exist or the group view is toggled off. - Drag-to-folder via the shared mouse-based psy-drop system (HTML5 DnD is unusable in WebKitGTK); the whole section is the drop zone, and the ungrouped zone appears during a drag so a playlist can always return to root. - "Move to folder" submenu in the playlist context menu (keyboard-accessible path; also creates folders on the fly) — stays available offline. - Header gets a "New folder" action and a "Group by folders" view toggle (both context-aware); a notice surfaces the local-only caveat. - Sidebar renders the same collapsible folder groups. - groupView preference added to the folder store. * docs(changelog): note playlist folders (#1119) |
||
|
|
15fb0f6c56 |
Theme store: version display + animated/static filter (#1104)
* feat(themes): show theme version in the store and installed list * feat(themes): filter the theme store by animated / static * docs(changelog): add 1.49.0 entry for theme store version + animated filter |
||
|
|
c1403f8bd6 |
feat: now-playing liveness dot + admin-gated radio management (#1086)
* feat(now-playing): liveness indicator dot in the listening popover Replace the raw "Nm ago" line in the "Who is listening?" popover with a derived presence dot (green playing / amber paused / dim idle). The presence is computed in one tested helper that unifies the playbackReport transport state with the legacy getNowPlaying recency, instead of formatting a raw timestamp inline. The dot carries the localized status as an aria-label and tooltip so it is not conveyed by colour alone. * feat(radio): gate station create/edit/delete behind Navidrome admin role Navidrome >= 0.62 restricts internet-radio management to admins (GHSA-jw24-qqrj-633c); non-admin requests fail. Hide Add Station, Search Directory, the per-card edit chip and delete button for confirmed standard Navidrome users via a canManageNavidromeRadio() helper on the existing useNavidromeAdminRole framework. Admins, non-Navidrome servers and transient states stay unrestricted; playback and favourites remain available to all. * docs(changelog): now-playing status dot + admin-gated radio (#1086) |
||
|
|
0b7d9eae2d |
feat(playback): Semitones strategy, 2-decimal speed label, advanced fine steps (#1084)
* feat(playback): varispeed-by-semitones strategy and 2-decimal speed label Adds a fourth playback-rate strategy "Varispeed (semitones)" — a frontend lens over varispeed where the user dials the pitch change directly in semitones (±12 st, 0.1 step) and speed = 2^(st/12). Engine contract is unchanged: the store maps it to the existing "varispeed" Rust strategy via engineStrategy(), and switching between the two varispeed lenses at the same speed no longer restarts the track. Also widens the speed readout to two decimals (formatSpeedLabel → toFixed(2)) so every 0.05 slider step is visible (1.05×, 1.10×, 1.15×), addressing the feedback on issue #531 that the label looked stuck between steps. Includes new i18n keys and updated hint across all 9 locales, plus Vitest coverage for the new helper, label formatting, and the no-restart lens switch. * refactor(playback): shorten Semitones strategy label, add per-strategy tooltips Renames the fourth strategy to a concise "Semitones" (was "Varispeed (semitones)") across all 9 locales, and lets the four strategy buttons share the row width so they fit on one line in Settings while still wrapping in the narrow player popover. Each strategy button now has a short hover tooltip explaining how it is built (incl. the 2^(st/12) and 12 × log2(speed) maths). * feat(playback): advanced fine-step precision for speed/pitch sliders Adds an opt-in "Fine adjustment" toggle in Settings → Audio (visible only in Advanced mode) that shrinks the playback-rate slider steps to 0.01× for speed and 0.01 st for pitch/semitones, with the pitch readout widening to two decimals to match. Default behaviour (0.05× / 0.1 st) is unchanged. The preference is UI-only (persisted, not sent to the engine) and also applies to the player-bar popover and wheel. Addresses the finer-precision request on issue #531 without cluttering the default UX. * docs: changelog and credits for playback speed follow-up (PR #1084) |
||
|
|
028eb65f7d |
feat(titlebar): selectable window button styles + minimize toggle (#1083)
* feat(titlebar): selectable window button styles + minimize toggle Custom title bar (Linux) gains a window-button style picker, mirroring the seekbar style picker pattern. Six form-named styles: dots, dotsGlyph, flat, pill, outline, glyph. All buttons now carry minimize/maximize/close glyphs for clear, colour-blind-friendly iconography; dots reveal glyphs on hover, dotsGlyph always shows them. - New authStore state windowButtonStyle (default dots) + showMinimizeButton, with rehydrate validation falling back to dots on unknown values. - WindowButtonPreview reuses the real .titlebar-btn classes for WYSIWYG tiles. - Picker + minimize toggle render under the Custom title bar setting, gated on the toggle being on. - Monochrome styles use --text-primary glyphs and stronger borders for contrast on dark themes. - Dev-build grey marker scoped to the real title bar so previews show true colours. - i18n keys in all 9 locales; setter and rehydrate tests. * docs(changelog): window button styles (#1083) |
||
|
|
3de7b57cc5 |
Fullscreen player polish + Discord Rich Presence fixes (#1068)
* fix(fullscreen-player): drop track-number prefix from title, stop clipping descenders
The big title showed a zero-padded queue position ("11. ") before the
song name; remove it (the position still lives in the top bar). Also bump
line-height 1.05 -> 1.25 so overflow:hidden no longer crops descenders
(g, j, p, q, y) at 46px.
* feat(settings): clarify built-in Discord RP vs official Navidrome plugin
Add a bold notice atop the Discord Rich Presence block explaining it is
the built-in integration, and that users wanting the official Navidrome
Discord RP plugin should leave it off and enable "Show in Now Playing"
instead. Add a matching bold note to the Now Playing setting. New i18n
keys discordRichPresenceNotice / nowPlayingPluginNote across all 5 locales.
* fix(discord-rp): use HTTPS cover URL instead of local file:// path
Discord Rich Presence images are fetched by Discord's servers, so
large_image must be a key or an https:// URL they can reach. Since the
cover pipeline moved to an on-disk webp cache, coverArtUrlForDiscord
returned a file://.../800.webp path on cache hit, which Discord cannot
load and silently falls back to the app icon. Always hand it the
getCoverArt URL; MPRIS keeps the local file:// path unchanged.
* i18n(settings): fill discordRichPresenceNotice + nowPlayingPluginNote for es, nb, ro, ru
The two new Discord-RP notice keys had only landed in 5 of the 9 locales;
complete the set so no locale falls back to English.
* docs(changelog): fullscreen title + Discord RP fixes (PR #1068)
|
||
|
|
1a82376f8c |
feat(music-network): unified scrobble & enrichment framework (replaces hard-wired Last.fm) (#1066)
* feat(music-network): core domain types and wire contracts
Foundation for the Music Network framework: provider-agnostic domain
types, capability model, typed errors, and account shapes under
src/music-network/core, plus the ScrobbleWire / EnrichmentWire /
PresetManifest / AuthStrategy contracts. No runtime wiring yet.
* feat(music-network): generic audioscrobbler/listenbrainz/maloja transports
Generalize the Rust remote layer for the Music Network framework. Add
provider-agnostic transports parameterized by base_url:
- audioscrobbler_request: Audioscrobbler v2 with caller-supplied endpoint
(Last.fm, Libre.fm, Rocksky, custom GNU FM, Maloja compat share it)
- listenbrainz_request: Token-header JSON (direct + Maloja LB compat)
- maloja_request: native /apis/mlj_1 JSON
lastfm_request stays as a thin transition delegate against the fixed
host; it is removed once the framework owns all call sites. Wiremock
tests cover audioscrobbler_request with a custom base_url and API-error
mapping.
* feat(music-network): audioscrobbler wire with last.fm + libre.fm presets
Add the Audioscrobbler v2 wire, the behavioural successor to the legacy
src/api/lastfm.ts, implementing the full EnrichmentWire surface (scrobble,
now playing, love/unlove, loved sync, similar artists, track/artist stats,
top lists, recent tracks, user profile, urls).
- client.ts: transport wrapper over audioscrobbler_request, classifying
failures into MusicNetworkError without touching any store
- sign.ts: TS mirror of the api_sig base-string ordering rule (unit-tested)
- auth/tokenPoll.ts: browser token-poll connect flow as a reusable strategy
- presets/lastfm.ts, presets/librefm.ts: bundled, enrichment-capable,
token-poll presets (both endpoints verified live)
Extends WireContext with profileBase and ConnectContext with authBase so
URL builders and connect flows need no preset lookup.
* feat(music-network): listenbrainz + maloja-native wires, paste-auth presets
Add the scrobble-destination wires and presets:
- ListenBrainz wire (scrobble + now playing via playing_now), backing both
the direct api.listenbrainz.org preset and the Maloja /apis/listenbrainz
compat preset (one wire, two presets, differing only by base URL)
- Maloja native wire (/apis/mlj_1/newscrobble, scrobble only — Maloja has
no now-playing endpoint)
- Shared api_key_only paste-auth strategy (token/key/session-key paste);
the Audioscrobbler wire now dispatches token-poll vs paste by preset
- Presets: listenbrainz, maloja_listenbrainz, maloja_native, rocksky
(scrobble-only, session-key paste, bundled keys — verified live), and
custom_gnufm (token-poll, user-supplied url/key/secret)
- Contracts: ConnectContext.authStrategy, PresetManifest.selfHostedApiSuffix
maloja_compat (the {url}/apis/audioscrobbler mode) is intentionally omitted:
its protocol cannot be verified and is almost certainly the legacy handshake,
not the 2.0 web API; Maloja is covered by the native and ListenBrainz modes.
* feat(music-network): registry, orchestrator, enrichment router + runtime facade
Wire the framework together behind a single facade:
- registry: wireRegistry (WireId -> wire), presetRegistry (the 7 built-in
presets), registerBuiltinWires (one-time side-effect registration)
- CapabilityProbe: wire probe overlaid by manifest staticCapabilities as the
final authority (lets two presets on one wire diverge, e.g. Rocksky's
nowPlaying:false over the Audioscrobbler wire's optimistic yes)
- ScrobbleOrchestrator: best-effort fan-out; flips the per-account
session-error flag on AUTH_SESSION_INVALID and clears it on next success
- EnrichmentRouter: resolves the single primary to its EnrichmentWire; the
type guard rejects non-enrichment wires (Maloja/ListenBrainz)
- MusicNetworkRuntime: the only app entry point — accounts, roles, fan-out,
enrichment, urls, probe. Reads/writes state through the MusicNetworkStore
port (Phase 5 backs it with the auth store) and a RuntimeHost for side effects
- getMusicNetworkRuntime singleton + index.ts public barrel
Tests cover fan-out, master toggle, capability gating, session-error
flip/clear, primary eligibility, and enrichment routing.
* feat(music-network): auth-store state + lossless legacy migration + runtime bridge
Add the persisted Music Network state to the auth store and wire the runtime,
all additively — nothing existing breaks yet.
- authStoreTypes: musicNetworkAccounts / enrichmentPrimaryId /
scrobblingMasterEnabled + actions; legacy lastfm* fields kept until Phase 6
- authMusicNetworkActions + defaults/wiring (synchronous localStorage)
- accountPersistence: migrateLegacyLastfm (lossless — preserves session key,
username and scrobbling preference; fills bundled Last.fm key from the preset;
sets the migrated account as enrichment primary) + sanitizeAccounts
- authStoreRehydrate: one-shot migration guarded by a sentinel so a later
disconnect cannot resurrect the account from still-present legacy fields
- musicNetworkBridge: backs the MusicNetworkStore port with the auth store and
the RuntimeHost with the Tauri shell; initialized in pre-React bootstrap
nowPlayingEnabled stays a global toggle (not a lastfm* field); the Phase 6
playback call-site will gate dispatchNowPlaying on it, preserving behaviour.
* feat(music-network): route playback, enrichment and love through the runtime
Migrate every Last.fm call-site onto the Music Network runtime, preserving
behaviour:
- playback (audioEventHandlers, playTrackAction): scrobble@50% and now-playing
via dispatchScrobble/dispatchNowPlaying; loved-fetch via isTrackLoved. Now-
playing follows scrobbling (as Last.fm did), Navidrome now-playing keeps the
nowPlayingEnabled gate
- enrichment: useArtistSimilarArtists, useNowPlayingFetchers, Statistics, and
the ArtistDetail similar-artists gate now use the runtime, gated on an
enrichment primary
- love: PlayerBar, PlayerTrackInfo, all context menus, useNowPlayingStarLove and
the startup loved-sync route through setTrackLoved / toggleNetworkLove
- player store: lastfmLoved/lastfmLovedCache -> networkLoved/networkLovedCache;
lastfmActions -> networkLoveActions; loved cache storage renamed with a
lossless legacy-key fallback
- getMusicNetworkRuntimeOrNull() for best-effort callers so they no-op (not
throw) before the runtime is initialized
src/api/lastfm.ts and the Integrations UI still use the legacy path; they are
migrated and removed in the next phase.
* feat(music-network): manifest-driven Integrations UI + scrobble batch format
Replace the Last.fm Integrations card with a manifest-driven Music Network
section, and fix Audioscrobbler scrobbling to the batch/array shape.
- settings/musicNetwork/: MusicNetworkSection (master toggle, destination
cards, enrichment-primary picker, Maloja proxy warning, add-a-service list)
driven entirely off the preset registry; icon map from PresetManifest.icon
- IntegrationsTab delegates to MusicNetworkSection (Discord/Bandsintown/
Navidrome now-playing unchanged)
- i18n: musicNetwork.* across all 9 locales, incl. a per-field help hint for
Rocksky's CLI session-key flow (rocksky login)
- scrobble now uses the documented array form (artist[0]/track[0]/…); the bare
single form is only tolerated by Last.fm, Rocksky requires the indexed form
- auth-error detection keys off the response message (not the ambiguous numeric
code) so a Rocksky server-500 no longer flips the account to a reconnect state
- PresetManifest.PresetField gains an optional helpKey
Rocksky's server rejects some non-ASCII track metadata with a 500 — a Rocksky
backend bug; the client call is correct (verified).
* fix(music-network): clearer Integrations layout + scrobble batch fix
Address UI feedback on the Music Network section:
- per-account scrobble toggle moves inside its account block (was a loose
row between cards — unclear which account it belonged to)
- master toggle and the primary-service picker are now boxed blocks at the top,
not bare rows
- primary-service copy reworked: 'Primary service' + a line spelling out that
liked tracks/similar artists/stats come from it while scrobbling still goes
to all enabled services
- distinct zones separated by dividers (master/primary · connected · add)
Also folds in the verified scrobble fixes: documented array form
(artist[0]/track[0]/…) so Rocksky accepts scrobbles, and auth-error detection
by message rather than the ambiguous numeric code (a Rocksky server-500 no
longer flips the account to a reconnect state). Rocksky session-key field gains
a CLI help hint (rocksky login), all 9 locales.
* feat(music-network): indicator + remove legacy lastfm path
Phase 7b/7c — finish the cutover and delete the old Last.fm path.
- LastfmIndicator -> MusicNetworkIndicator (shows the enrichment primary's
status, click -> Integrations)
- delete src/api/lastfm.ts; remove Rust lastfm_request (remote.rs + lib.rs)
- remove legacy authStore lastfm* fields, actions and types; delete
authLastfmActions.ts; rehydrate migration reads the legacy blob via a cast
- migrate the remaining NowPlaying call-sites (NowPlaying.tsx + the now-playing
fetchers/prewarm/star-love hooks) off lastfmSessionKey/lastfmUsername onto the
enrichment primary (gate + cache key)
- type imports LastfmTrackInfo/LastfmArtistStats -> music-network TrackStats/
ArtistStats; drop 8 stale api/lastfm test mocks and the obsolete Last.fm auth
tests; update settingsTabs + src/CLAUDE.md
No lastfm imports remain outside src/music-network/; lastfm_request removed
(acceptance §12). tsc clean, 1947 frontend tests + remote rust tests green.
* test(music-network): cover scrobble shape + error classification, drop dead i18n keys
Remove the 14 unused legacy scrobble/connection i18n keys across all 9
locales (settings.lfm*/scrobble*, connection.lastfm*); the live love,
profile-link and now-playing keys stay.
Add regression tests for the parity-critical transport logic: the indexed
batch/array scrobble body, the auth-vs-network error classification
(numeric codes collide across providers), and the manifest-overrides-probe
capability merge.
* feat(music-network): provider-agnostic UI + Maloja Audioscrobbler & Koito presets
- de-hardcode the single provider name across every enrichment surface
(love labels, now-playing badge, stats title); derive it from the
enrichment primary and interpolate via {{provider}} i18n params
- surface a toast when a paste-auth connect probe fails — a static
'supported' capability flag no longer masks a runtime probe error
- add the Maloja Audioscrobbler (GNU FM) preset (the third Maloja wire
mode) and a Koito preset (ListenBrainz-compatible), both data-only
- generalise PRIVACY.md and the scrobbling help entry to the framework
- rename residual lfm* identifiers to network*; strip legacy flat
lastfm* fields from the persisted blob; neutral transport error prefix
- tests: error classification, scrobble body, capability probe, registry
* fix(music-network): validate paste-auth keys on connect + UI polish
- AudioscrobblerWire.probe now validates an api_key_only session with a
signed call and reports scrobble:'error' only on a genuine auth failure
(a scrobble-only service that rejects user.getInfo is not a bad key), so
an invalid Maloja Audioscrobbler / Rocksky key surfaces a connect toast
instead of failing silently; WireContext carries the preset authStrategy
- drop the unreachable Statistics empty-state branch and its dead
lfmNotConnected i18n key; use the useEnrichmentPrimaryLabel hook there
- drive the love-button glyph from the enrichment primary's manifest icon;
neutral Music Network section icon; remove a dead LastfmIcon import
- tests: paste-auth vs token-poll probe behaviour
* chore(music-network): rename showLastfmSimilar → showNetworkSimilar, refresh stale comments
Post-parity polish: the similar-artists toggle now sources from the generic
enrichment runtime, so rename the lingering lastfm-flavoured identifier; drop
stale 'Mirrors today's LastfmX' doc comments referencing the removed legacy
types, and generalise the scrobble-point comment.
* docs(changelog): Music Network entry (PR #1066)
Co-Authored-By: cucadmuh <49571317+cucadmuh@users.noreply.github.com>
* fix(music-network): bound request timeout on the provider transports
audioscrobbler_request / listenbrainz_request / maloja_request built a
reqwest client with no timeout, so a hung provider left scrobble / probe /
loved-sync promises unresolved. Add a shared provider_http_client() with a
15s timeout, matching the sibling fetch_* commands. Addresses review C3.
* refactor(music-network): dedupe wire transport + no-enrichment helpers
The three provider clients repeated the same invoke -> classify-error ->
MusicNetworkError boilerplate, and the three probe() bodies repeated the
"mark every enrichment capability no" loop. Extract
wires/shared/invokeTransport() (each wire keeps its own arg shape + auth
rule) and markNoEnrichment() in core/capabilities.ts. Addresses review C4.
* refactor(music-network): drop write-only malojaWireMode dead state
malojaWireMode was written on connect but never read — the wire is resolved
by wireId and the Maloja base URL by the preset's selfHostedApiSuffix.
Remove the field, the MalojaWireMode type, the malojaWireModeFor helper,
the AccountPatch entry, the PresetField union member, and the export.
Addresses review C1.
* refactor(music-network): one useEnrichmentPrimary hook, drop lastfm fallback
The enrichment-primary lookup (accounts.find by enrichmentPrimaryId) was
duplicated across two hooks and inlined in the indicator and both
context-menu builders, two of them with a hardcoded 'lastfm' icon fallback.
Add one music-network/ui/useEnrichmentPrimary() returning
{account,label,icon}|null; useEnrichmentPrimaryLabel/Icon delegate to it and
the indicator + context menus consume it directly. Icon fallback is the
neutral 'custom' glyph, never a provider (provider-agnostic, §7.3).
Addresses review C2.
---------
Co-authored-by: cucadmuh <49571317+cucadmuh@users.noreply.github.com>
|
||
|
|
5cd01c90ac |
fix(library): multi-genre local index with track_genre and backfill (#1059)
* fix(library): multi-genre local index with track_genre and backfill Restore atomic genre browse, filters, and counts via track_genre: OpenSubsonic genres[] first with Navidrome-default split fallback, sync write path, read-path query switches, blocking startup backfill with progress, and v12 repair migration for DBs that recorded legacy 002–011. TS fallback adds genreTagsFor and migration gate i18n across locales. * fix(library): address multi-genre review — robust TS genres and scope join genreTagsFor routes raw genres through parseItemGenres (single-object Subsonic quirk and bare strings). Library-scoped genre browse/counts join track for raw_json library_id fallback. Statistics keeps empty-genre bucket. * docs: CHANGELOG and credits for multi-genre local index (PR #1059) * docs(changelog): credit HiveMind on Discord for multi-genre report (PR #1059) |
||
|
|
c7d71ea57c |
feat(whats-new): remote release notes with dev workspace mode (#1058)
* feat(whats-new): remote release notes with dev workspace mode Add WHATS_NEW.md, CI whats-new.md asset upload, and client fetch/cache with embedded fallbacks. Dev and -dev builds read the full file from the repo for debugging; RC/stable download the release asset on first use. * fix(whats-new): render ## headings and add changelog tab Parse h2 sections in release-notes markdown; load changelog alongside highlights and let users switch views on the What's New page. * fix(whats-new): prefetch on startup and fix CI typecheck prebuild Prefetch whats-new asset when the shell loads on RC/stable builds. Run prebuild:release-notes before tsc and coverage jobs so the gitignored generated bundle exists in CI. * docs: CHANGELOG and credits for What's New remote notes (PR #1058) * fix(whats-new): always slice embedded release notes to current line Drop full CHANGELOG embed for -dev bundles; tauri:dev still reads live markdown from the repo. Ignore all of src/generated/ in git. * fix(whats-new): fetch release asset via Rust to bypass CORS Route whats-new.md download through fetch_url_bytes; rename the technical tab label; add fetch unit tests (PR #1058 review). |
||
|
|
ae9be74719 |
feat(settings): compact server cards with capability badges (#1054)
* feat(settings): redesign server cards with identity line and capability badges Compact two-line server headers (entry name + user@host), HTTPS lock, and a clickable version info tooltip. Navidrome ≥0.62 shows a green AudioMuse inline badge; older Navidrome keeps the manual toggle row. Adds click-pinned tooltips via data-tooltip-click on TooltipPortal. * feat(settings): unify use/active slot and move delete into edit form Merge Active badge and Use button into one rightmost action; Active uses green styling. Reorder actions to edit, test, use/active. Remove the card delete icon — deletion lives in the server edit form footer. * docs: note compact server cards in CHANGELOG and credits (PR #1054) * fix(credits): move PR #1054 server cards line to cucadmuh block Was appended to Psychotoxical's contributions array by mistake; CHANGELOG already credited cucadmuh (gh pr view author). |
||
|
|
155ef88cc0 |
fix(themes): serve the Theme Store from GitHub raw, not jsDelivr (#1051)
Theme assets (registry.json, theme CSS, thumbnails) were served from the jsDelivr CDN's mutable @main edge, which caches up to 12h. The registry is purged on every themes push so updates were offered promptly — but the theme CSS could still be served stale, so an update stored pre-update CSS under the new version label and no further update was offered to correct it (a freshly animated theme would install without its @keyframes). Fetch everything from GitHub raw (permissive CORS, ~5-min server cache), which is always current. jsDelivr is dropped entirely: our request volume does not need a CDN, and its only real upside here (high-traffic edge serving) does not apply, while its staleness actively caused the bug. Rename cdnUrl -> assetUrl to reflect the source. |
||
|
|
316c99ba07 |
refactor(themes): drop unreliable popularity/download stats from the store (#1050)
The install/download numbers came from jsDelivr CDN hits, which are structurally unreliable: the stats API caps at the top 100 files, so most themes report 0 and the figures jump from day to day. Showing them — and a popularity bar and a most-popular sort built on them — only misled users. Remove the stats meta box (popularity bar + download count), move the author back under the theme name, and keep the reliable git-based last-changed date inline. Sort is now Newest (default) or Alphabetical. |
||
|
|
276903d0af |
i18n(settings): translate Cover art cache section into the remaining locales (#1047)
The Cover art cache settings section shipped with English (and Russian) strings only. Add the 23 keys (scope/strategy labels, Lazy/Aggressive descriptions, per-server clear dialog) for de, fr, es, nl, nb, ro, and zh. Placeholders and the technical progress-value string are left verbatim. |
||
|
|
cfc9419de7 |
feat(themes): sidebar notice when an installed theme has an update (#1041)
* feat(themes): sidebar notice when an installed theme has an update Adds a dismissible sidebar pill (sibling of the What's New banner) shown while an installed community theme has a newer version in the store. Clicking opens Settings -> Themes; dismiss hides it until a new update changes the set. The theme registry is now refreshed from source once per app launch instead of only when the Theme Store tab is opened, so newly published themes and updates surface without a manual refresh -- and feed this notice. * docs: add CHANGELOG entry for PR #1041 * feat(themes): in-place update control on installed theme cards Themes with a newer version in the store now show a centered update icon on their card in Settings -> Themes; clicking it fetches and reinstalls in place. Extracts the shared installThemeFromRegistry helper (fetch -> validate -> install) used by both the store list and the card control, and surfaces the full registry entry from useThemeUpdates so the card can update directly. |
||
|
|
6118b3940f |
feat(server): capability framework, AudioMuse sonic routing & PsyLab Connections (#1033)
* feat(server): probe AudioMuse via OpenSubsonic and add PsyLab Connections tab Navidrome ≥0.62: detect sonicSimilarity extension for reliable plugin signal; older servers keep the legacy getSimilarSongs probe. PsyLab gets a Connections tab with session, endpoint, and active-server capability details. * feat(psylab): polish Connections tab, admin role probe, and tab bar layout Status badges and Navidrome admin/user validation in Connections; prevent PsyLab tab row from vertically collapsing under the Logs flex layout. * docs: add CHANGELOG and credits for PR #1032 * feat(settings): auto-enable AudioMuse on Navidrome 0.62+ with status indicator Replace the per-server manual toggle with a probe-driven badge when sonicSimilarity is available; pre-0.62 Navidrome keeps the legacy toggle. * feat(server): capability framework with AudioMuse sonic routing Add a declarative server-capability catalog (src/serverCapabilities/) that picks a feature strategy per server generation, runs only the needed probes, and routes API calls. AudioMuse Instant Mix now prefers the OpenSubsonic sonicSimilarity endpoint (getSonicSimilarTracks) on Navidrome 0.62+ and falls back to legacy getSimilarSongs. - catalog/context/resolve: eligibility, detection, activation, call routing - storeView: read facade over the existing per-server probe maps - getSonicSimilarTracks API client + fetchSimilarTracksRouted router - route Instant Mix and Lucky Mix through the resolver - ServersTab + PsyLab Connections read the resolver (auto status vs toggle) - tests: resolve, storeView, router * docs: update CHANGELOG and credits for PR #1033 Renamed branch supersedes PR #1032: point changelog/credits at #1033 and document the server-capability framework, auto-managed AudioMuse indicator, and sonic Instant Mix routing. * refactor(server): address PR #1033 review — idempotent probe, drop dead code - Make scheduleInstantMixProbeForServer idempotent: skip when a definitive result is cached; re-probe only on force (add/edit/test server), a prior error, or a server version/type change (invalidated in setSubsonicServerIdentity). Removes the steady-state 120 s re-probe, the present→probing→present flicker, and the momentary legacy-fallback routing window. - Remove now-dead identity helpers (showAudiomuseNavidromeServerSetting, isAudiomusePluginAutoManaged, isNavidromeSonicSimilarityEligible, resolveAudiomusePluginProbeUiStatus + type) and the superseded probeAudiomusePluginWithCredentials; the catalog is the single source of truth. - Drop the never-emitted 'unsupported' AudiomusePluginProbeResult variant. - Fill audiomuseStatus* keys in all 8 non-en locales. - Tests: probe idempotency + version-change invalidation; retarget OpenSubsonic test to fetchOpenSubsonicExtensionsWithCredentials. |
||
|
|
0878cbf308 |
feat(themes): Theme Store install counts, downloads & sort (#1036)
* feat(themes): install counts, downloads, popularity & sort in the Theme Store Each registry theme now carries an install count and a last-changed date. Store rows show them in a dedicated meta panel (author, popularity bar, total downloads, last changed), plus a sort dropdown (most popular / newest / name), zebra-striped rows, a numbered pager, and a note that the stats refresh daily. Adds a shared formatRelativeTime helper (formatLastSeen now delegates to it). * test(themes): cover sort modes, pager jump and relative-time formatting * fix(build): gate nvidia_quirk_active to Linux Its only caller is the Linux branch of theme_animation_risk, so on Windows and macOS the function was dead code and tripped clippy's -D warnings. * docs: CHANGELOG + credits for theme store download stats & sort (#1036) |
||
|
|
88f7a7bc90 |
feat(themes): warn about animated themes on high-CPU setups (#1020)
* feat(themes): warn about animated themes on high-CPU setups Show a warning icon + tooltip on animated themes (those defining @keyframes) in the store and in Your Themes, on Linux setups where animation is costly — the Nvidia WebKit quirk is active or compositing is forced off. The Nvidia detection already runs once at startup; its result is recorded (theme_animation.rs) and read via a new theme_animation_risk command — no GPU re-probe. Display-only; never shown off Linux. The animated flag comes from the registry (store) or the theme CSS (Your Themes). * docs(themes): note the animated-theme warning PR in the Theme Store entry |
||
|
|
f4e1086131 |
feat(themes): fresher store refresh + external-services notice (#1016)
* feat(themes): fresher store refresh + external-services notice Manual refresh now fetches the registry from GitHub raw first (~5 min cache) and falls back to the jsDelivr CDN, so a freshly merged theme shows up without waiting on — or purging — the shared CDN @main edge (up to 12 h). Normal loads still use the CDN. Adds a notice on the Theme Store that the catalogue and previews load from external services (jsDelivr / GitHub), in line with the app's network transparency. i18n ×9. * docs(themes): note the store-refresh PR in the Theme Store entry Now that #1015 is merged, fold PR #1016 into the still-unreleased Theme Store changelog and credits entry. |
||
|
|
23f032b274 |
feat(themes): free-form community themes with a security floor (#1015)
* feat(themes): free-form community themes with a security floor Community themes are no longer token-only. The in-app guard (validateThemeCss) now enforces only a security floor — no network (@import / non-data url()), no scripts (<style>/<script>/expression()/ javascript:/-moz-binding), no @property, @keyframes namespaced as <id>-, and a 256 KB cap — and otherwise allows any selectors, structure and animations. validateThemePackage checks the manifest plus that floor. Themes can react to app state via same-element attributes set on the theme root: data-playing, data-fullscreen, data-sidebar-collapsed, data-lyrics-open. The local-import confirm dialog now notes that imported themes aren't reviewed and are installed at the user's own risk. Removes the now-unused bundled token whitelist. * docs(themes): note free-form themes in the Theme Store entry Add PR #1015 and a free-form bullet to the still-unreleased Theme Store changelog and credits entry. |
||
|
|
10fc489ce9 |
fix(themes): offline banner for the Theme Store (#1013)
* fix(themes): show an offline banner when the Theme Store is unavailable When the registry can't be fetched and no catalogue is cached, the store now shows a clear offline banner (icon, message, retry) in place of the list, and hides the search/filter toolbar — there is nothing to browse. The cached-catalogue fallback with its offline indicator is unchanged, so the store still works offline when a catalogue was previously cached. * docs(themes): note the offline-banner PR in the Theme Store entry Add PR #1013 to the still-unreleased Theme Store changelog and credits entry, alongside the other follow-ups. |
||
|
|
b3573e7107 |
feat(themes): import a theme from a local .zip (#1012)
* feat(themes): validate and extract locally imported theme packages Add the backend + validation half of local theme import: users will be able to load a theme packaged as a .zip (manifest.json + theme.css). - `import_theme_zip` Tauri command unpacks only manifest.json + theme.css from the archive, outside the webview, with an archive-size cap, per-entry uncompressed caps, and path-traversal rejection. - `validateThemePackage` runs the full theme-store contract in-app: the manifest schema (field patterns copied from the repo schema), the CSS token whitelist with all core tokens required, color-scheme matching the declared mode, data-URI restricted to the arrow token, and a guard against ids that collide with built-in themes. The existing `validateThemeCss` containment guard is reused and runs again at injection time. The contract token list is a byte-identical copy of the themes repo's allowed-tokens.json. Covered by validateThemePackage tests for every rejection class. * feat(themes): add the Import a theme section to the Themes tab A dedicated section between the theme scheduler and the Theme Store lets users import a theme from a local .zip. After the package validates, a confirmation dialog names the theme and its author before it is installed. A rejected import shows a plain-language explanation aimed at end users; the raw contract diagnostics (token names, missing fields) are tucked into a collapsible "Technical details" block for theme authors. Fully localised across all nine languages. * docs(themes): changelog + credits for local theme import Fold the local .zip import (and the store pagination / refresh-scroll polish) into the still-unreleased Theme Store entry rather than a new section, and extend the credits line. PRs #1011 and #1012. |
||
|
|
9fb0d5638b |
feat(themes): paginate theme store and keep scroll on refresh (#1011)
The community Theme Store grew large enough that browsing it meant scrolling through the whole catalogue. Paginate it (12 per page) with a Prev / "Page X of Y" / Next pager that resets to page 1 on filter changes and scrolls back to the top of the list when paging. Also fix the refresh button resetting the scroll position. It set the top-level loading state, which unmounted the list and collapsed the scroll viewport so it clamped back to the top. Refreshing now keeps the existing list mounted and only spins the refresh icon; the full-page loading and error placeholders are reserved for the initial load. Adds themeStorePagePrev / PageNext / PageStatus across all nine locales and a ThemeStoreSection test covering pagination, filtering, page reset, and the refresh-keeps-scroll behaviour. |
||
|
|
f9df918c72 |
feat(themes): community Theme Store + semantic-token refactor (#1009)
* feat(themes): add semantic tokens for the theme-store contract (B0 P1) Additive: define --highlight, --accent-2, --bg-deep, --bg-elevated and --text-on-accent on the :root base as --ctp-* mappings. They resolve per-theme automatically and nothing consumes them yet (zero behaviour change) — groundwork for replacing direct --ctp-* use in components. * refactor(themes): components consume semantic tokens, not --ctp-* (B0 P2) Replace every direct --ctp-* reference in component/layout/track CSS and TSX inline styles with the readable semantic token (--bg-app, --accent, --highlight, --text-on-accent, …). --ctp-* now survives only as the Catppuccin palette layer the base maps from, and as the deliberate categorical rainbow in Composers/Genres/artistsHelpers (left untouched). This is the readable contract surface for the community theme store. Divergences (theme set a semantic var != its --ctp- source) are corrections — the element now uses the theme's real semantic colour. * feat(themes): add player-bar title/artist color tokens New optional --player-title / --player-artist, defaulting to --text-primary / --text-secondary so nothing changes unless a theme overrides them. Lets a theme give the now-playing readout its own colour as a plain token. * refactor(themes): token-only theme library (flatten, whitelist, one file per theme) Turn every built-in theme into a single self-contained [data-theme] var block of semantic whitelist tokens (plus the internal --ctp-* palette layer): - Flatten all themes: drop structural override rules, @keyframes, and global-token overrides (radius / shadow-elevation / transition / spacing / font / focus-ring). Signature player-bar readout colours are preserved via the new --player-title / --player-artist tokens. - Normalize the var blocks to the semantic whitelist: drop the alternate token vocabulary (nav-active / scrollbar / bg-input / success / border-default / ...); rename --success -> --positive and --border-default -> --border where no whitelist equivalent was set. Migrate the few components that read those tokens to the whitelist equivalents. - Split multi-theme files so each theme ships as its own file, making the built-in set 1:1 with the per-theme store packaging. Kept as-is (built-in, not flattened): the two colour-blind-safe accessibility themes, plus the two curated core skins. * chore(themes): remove seven themes retired after the token refactor These themes leaned on heavy structural overrides and were dropped rather than flattened. Full removal each: the CSS file(s), the index.css import, the Theme type union, and the ThemePicker entry. * feat(themes): granular tokens — track lists Wire track rows to per-region tokens: row hover (--row-hover), the now-playing row + indicator (--row-playing-bg / --row-playing-text), track title/artist/ number/duration text, column-header text, row dividers, and the resize-handle active colour. Covers the desktop tracklist, the shared song-row (Tracks hub / search), and the mobile tracklist. Drop a baked border fallback. Visual no-op. * feat(themes): granular tokens — cards Wire album and artist cards to per-region tokens (--card-hover-border, --card-title, --card-subtitle, --card-placeholder-bg). Visual no-op. * fix(themes): drop undefined/baked colour aliases Replace the undefined --bg-surface (resolved to nothing — broken placeholder backgrounds and filter input) with --card-placeholder-bg / --input-bg, and the baked-hex aliases --color-error / --color-warning with --danger / --warning so themes can actually recolour them. * feat(themes): Spectrum demo theme + trim unused cascade tokens Add a loud built-in demo theme that gives each region its own hue (sidebar green, player pink, lists cyan, cards gold, menus red, controls blue) so the per-region granularity is obvious when you switch to it. Drop two unused cascade tokens (--sidebar-text-active, --row-active-bg) and the unused on-media block (those media surfaces stay static by design). * style(themes): make Spectrum demo brutally loud Full-saturation neon per region (toxic green sidebar, magenta player, cyan lists, acid-yellow cards, blood-red menus, electric-blue controls, purple scrollbar) so the per-region separation is unmistakable. The earlier soft tints were too subtle to read. * feat(themes): name the granular demo theme Braindead * feat(themes): granular per-region tokens — cascade layer + sidebar Add an optional per-region token layer (semantic-cascade.css) so a theme can recolour individual regions — sidebar hover, player controls, list rows, menus, inputs, on-media surfaces — independently of the global tokens. Every token defaults to its base token (or a media-safe literal), so this is a visual no-op until a theme overrides one; it only adds control points. Wire the sidebar region as the first consumer and drop the baked grey fallbacks (--bg-tertiary, etc.) that no theme could reach. * feat(themes): granular tokens — controls, menus, scrollbar Wire inputs, buttons, sliders, the custom-select, context menus, submenus and modals to per-region tokens, and tokenise the scrollbar. Complete B0 by dropping the last direct --ctp-* references in the input/button/progress/ scrollbar utility CSS. Fix three undefined-token bugs that fell back to nothing (so no theme could reach them): --surface-2 (context-menu hover had no highlight), --bg-surface (submenu create-input had no background), and the baked grey fallbacks in the custom-select. Every other new token defaults to today's value — a visual no-op that only adds override points. * feat(themes): granular tokens — player bar Wire the desktop player bar's transport controls, time toggle, and overflow menu to per-region tokens (--player-control, --player-time-toggle-*, etc.). Fix the undefined --surface-hover/--surface-active grey fallbacks on the time toggle. Visual no-op; defaults match today's values. * chore(themes): remove empty theme stub files Six theme CSS files were reduced to comment-only stubs by the flatten sweep but their files and @import lines remained. Five are empty structural companions of now-flattened themes (morpheus, p-dvd, aero-glass, luna-teal) and two are orphans of cut themes (order-of-the-phoenix, pandora). Removed the files and their imports. * feat(themes): runtime injection foundation for the theme store Plumbing for installed community themes ahead of the in-app store UI, nothing user-visible yet: - installedThemesStore: persisted (localStorage) record of installed community themes incl. their CSS text, so an active community theme is available synchronously at startup (no flash, fully offline). - themeInjection: reconcile <head> <style data-installed-theme> elements with the store; lightweight defense-in-depth sanitize on top of CI. - themeRegistry: jsDelivr registry client with a 12h localStorage cache and stale-on-error fallback. - App: inject installed themes before applying data-theme, in both webviews. - themeStore: widen the Theme type to accept dynamic installed ids. * feat(themes): dedicated Themes settings tab Move theme selection and the day/night scheduler out of Appearance into a new dedicated Themes tab — the future home of the community Theme Store. Appearance keeps grid columns, visual options, UI scale, font and seekbar. - ThemesTab: theme picker + scheduler (relocated verbatim). - AppearanceTab: drop the two relocated sections + now-unused imports. - Register the tab in settingsTabs (Tab union, resolveTab, search index) and Settings (tab bar, render, label map). - i18n: settings.tabThemes in all 9 locales. * feat(themes): community Theme Store browse + install Add the Theme Store section to the Themes tab: - Fetch the jsDelivr registry (12h cache, stale-on-error fallback). - Search by name/author/description + filter by light/dark + refresh. - Per-row CDN thumbnail, name, author, description and actions: Install / Apply / Update / Uninstall. Installing fetches the CSS, persists it (localStorage) and the runtime injection applies it; uninstalling the active theme falls back to the matching core. - Rating slot left reserved (deferred). - i18n: themeStore* keys in all 9 locales. * feat(themes): slim bundle to fixed cores + flat Themes tab Remove the 86 store palettes (incl. braindead) from the app bundle — the CSS files, their index.css imports, the Theme union and the picker data — leaving only the six fixed cores (Catppuccin Mocha/Latte, Kanagawa Wave, Stark HUD, Vision Dark/Navy). Everything else installs from the store. Themes tab is rebuilt flat (no collapsible accordions): - "Your Themes": one card grid of the fixed cores + installed community themes; click to apply, uninstall on community ones (active theme falls back to the matching core). Catppuccin prefix on Mocha/Latte; a CVD-safe pill on the colour-blind-safe Vision themes. - Scheduler day/night options include installed themes. - Theme Store: alphabetical order, thumbnail lightbox, and a submit hint above the search linking to the themes repository. - Nav order: Servers, Library, Audio, Themes, Appearance, Lyrics, … - ThemePicker accordion removed; fixed-theme data moved to fixedThemes.ts. - i18n for all new strings across 9 locales. * feat(themes): reset removed-from-bundle themes to a bundled fallback After slimming the bundle to the six fixed core themes, a profile upgraded from an older build may have an active or scheduler theme that is now store-only and not installed — it has no [data-theme] block and would render as unstyled :root. Reset any theme/themeDay/themeNight that is neither bundled nor installed to a bundled fallback: Mocha for the main + night slots, Latte for the day slot. Runs synchronously in runPreReactBootstrap, rewriting the persisted selection in localStorage before React mounts (no flash; Zustand rehydrates after first paint). No auto-install and no network — the fallback is always a bundled theme, so it works offline. * feat(themes): floating back-to-top button on the Themes tab The Themes tab can get long (theme grid + scheduler + full store list), so add a floating back-to-top affordance that appears once the page is scrolled and smooth-scrolls to the top. It is portalled into the route host and positioned absolute against it — the main scroll viewport sets contain: paint, which would otherwise make position: fixed resolve against the scrolling box and drift with the content. Reusable component (scroll viewport id + threshold props); i18n common.backToTop added in all nine locales. * feat(themes): accessibility + state polish for the Theme Store - Reuse the shared CoverLightbox for the thumbnail preview instead of a second inline dialog — gains a visible close button and a focus-managed, portalled dialog, and drops duplicated markup. - Theme cards expose aria-pressed so assistive tech announces the selected theme, not just the visual check. - Transient store messages get live-region roles (loading/empty/install failure = status, fetch error = alert). - Thumbnails degrade gracefully when offline/missing (hide the broken-image glyph; the thumbnail button no longer stretches with the row, so its background can't show as letterbox bars). * feat(themes): larger store-row thumbnails (120x75 -> 200x125) The list previews were too small to make a theme out; bump the display size (same 1.6 aspect). Thumbnails are now served at 720x450, so the larger display stays crisp. * fix(themes): bust thumbnail cache on registry change jsDelivr serves theme thumbnails with a 7-day max-age, so when a thumbnail is updated the webview keeps showing its cached old image (the path is unchanged). Append the registry's generatedAt as a cache-busting query to the thumbnail URLs (list + lightbox); it changes on every themes push, so a registry refresh makes the webview re-fetch and reflect the current CDN image instead of a stale one. * docs(themes): changelog + credits for the Theme Store Add the 1.48.0 "Themes — community Theme Store" changelog entry (PR #1009) and the matching line in the Psychotoxical credits. * fix(themes): address PR review (uninstall hygiene, validation, polish) Uninstall/scheduler & validation: - uninstallTheme() repairs every selection slot (active + day + night), not just the manual one, and is shared by both uninstall buttons (dedup). - Validate theme CSS at install time and skip persisting CSS that won't inject (no more "installed/active but renders nothing" with no feedback). - Harden the runtime validator: exactly one rule, scoped exactly to the theme's [data-theme='<id>'] selector (no unscoped/foreign selectors), no at-rules, url() only data:, no expression()/javascript:, size-capped. Tokens & polish: - Fix three dangling undefined tokens (--surface-2 x2, --bg-surface). - Finish the warning/success token sweep (--warning / --positive, themeable). - Apply the active theme synchronously before React mounts (no first-frame flash) and inject installed themes up front. - One-time, dismissible notice when the slim-bundle migration reset a theme. - Update badge uses semver, not string inequality. - Offline/stale indicator in the store; cross-window theme sync; drop the now dead REMOVED_THEME_REMAP and Card.mode field. Tests: themeInjection (validator + sync), themeRegistry (cache/force/stale/ malformed), uninstallTheme (slot repair), migration notice. i18n in all nine locales. Full suite green (1755 tests). * test(bootstrap): cover startup theme apply + cross-window sync The review fixes added applyThemeAtStartup / installCrossWindowThemeSync to bootstrap.ts (a hot-path file) without tests, dropping its coverage to 68.3% and failing the frontend hot-path coverage gate (>=70%). Add unit tests for both (and the no-op / malformed-storage paths); bootstrap.ts is back to ~98%. |
||
|
|
2d3c723a6e |
feat(offline): unify local playback, offline library, and favorites sync (#1008)
* feat(local-playback): LP-1 media layout and download_track_local
Add library-index-backed path builder in psysonic-core and a unified
Tauri download command that writes under media/{cache|library}/ with
layout fingerprints; legacy hot/offline commands unchanged for now.
* feat(local-playback): LP-2 localPlaybackStore and media tier Rust helpers
Add unified Zustand index with legacy offline/hot-cache import, media_layout
TS mirror, and Rust commands for tier size/purge/delete/promote.
* feat(local-playback): LP-3 wire prefetch and playback to unified index
Route downloads through download_track_local, delegate hot/offline shims
to localPlaybackStore, and update resolve/promote/prefetch plus key rewrite.
* feat(local-playback): LP-4–LP-6 offline UI, invalidation, and mediaDir
Offline Library loads pinned groups via library index; sync-idle invalidates
stale paths; Settings uses a single mediaDir with cache/library tier sizes.
* feat(local-playback): migrate legacy offline files to media/library layout
Move flat psysonic-offline downloads into nested media/library paths using
library index metadata, with retry on sync-idle when tracks are not yet indexed.
* feat(local-playback): simplify offline disk migration and restore Offline Library UI
Scan psysonic-offline on disk and relocate by library track id; restore pinSource
and cover art for migrated pins; add find_live_by_id for segment/key resolution.
* feat(local-playback): disk-first offline reconcile and fast library tier discovery
Reconcile library-tier index against on-disk files using candidate track IDs
instead of scanning the full catalog. Refresh Offline Library from disk on
open and focus so deleted folders drop out of the UI. Add Rust discover/prune
helpers and wire album/server reconcile through the unified path.
* fix(offline): resolve local playback URLs across server index-key variants
Offline Library play failed when library-tier files were indexed under a
host key while playback looked up only the active profile UUID. Use
findLocalPlaybackEntry for URL resolution, pin queueServerId to the card
server, and build play queues from tracks that still have on-disk bytes.
* fix(offline): playlist cards, playback from Offline Library, and local URL routing
Show playlists with name and quad/custom cover instead of the first track's
album artist. Build play queues with library-batch fallback and offline-only
server switch. Prefer library-tier URLs in playTrack; add playback-unavailable
toast and missing trackToSong import.
* fix(cache): ephemeral disk reconcile, empty-dir prune, and Storage UI
Sweep media/cache after eviction (orphan files, stale index, empty folders).
Settings: split media folder from cover cache; in-browser image cache lives
under Cover art cache with aligned columns; clear only IndexedDB images.
* feat(offline): show library disk usage in Offline Library header
Query media/library tier size on reconcile and display it in a right-aligned
stat block beside the page title and album count.
* fix(cache): defer unindexed hot-cache eviction; drop legacy offline size cap
Reconcile ephemeral cache without deleting files from other app instances;
evict unindexed hot-cache files oldest-first only when over hotCacheMaxMb.
Remove the hidden maxCacheMb gate and offline-full banner on album pages.
* feat(offline): play-all cache card and stable Offline Library grid rows
Add a shuffle-and-play card for all on-disk library pins plus hot-cache
tracks when buffering is enabled. Fix virtual row height for offline cards
and reserve the year line so grid rows no longer overlap.
* feat(offline): queue-cache grid card limited to media/cache
Replace the full-width play-all banner with a playlist-style grid tile.
Shuffle/enqueue only ephemeral hot-cache tracks when buffering is enabled,
not offline library pins.
* chore(licenses): regenerate bundled OSS list for 1.48.0-dev
Set GPL-3.0-or-later on workspace crates and extend cargo-about accepted
licenses so generate-licenses.mjs runs; refresh src/data/licenses.json.
* feat(favorites): auto-sync starred tracks into separate media/favorites tier
Keep manual Offline Library in media/library/ and favorites offline in
favorite-auto/index + media/favorites/ so toggling sync cannot purge
user-pinned bytes; playback resolves library before favorites.
* feat(favorites): compact offline toggle with disk icon and sync semaphore
Move control to the page header (disk + switch, tooltips); show red/yellow/green
LED when enabled instead of the full-width save-offline card.
* fix(favorites): trigger offline sync on star/unstar from anywhere
Hook star/unstar API so favorites offline reconcile runs globally (songs,
albums, artists); optimistic unstar removes local bytes; drop Favorites-page-only sync.
* fix(cache): skip hot-cache prefetch when favorites or library bytes exist
Treat favorite-auto tier like offline library for prefetch, stream promote,
and same-track replay so synced favorites are not duplicated in media/cache.
* fix(favorites): reconcile offline files on merged track union only
Dedupe artist/album/song stars into one target set per track id; drop eager
unstar deletes so overlapping favorites do not remove bytes still needed.
* feat(offline): add Favorites card to Offline Library
Mirror queue-cache card for favorite-auto tier with play, enqueue, and
navigation to Favorites on card click.
* feat(offline): show library+favorites disk total with icon breakdown
Sum media/library and media/favorites in the On disk widget and open an
icon popover on hover with per-tier sizes for screen readers and sighted users.
* feat(favorites): enable offline Favorites tab when auto-save is on
Keep Favorites in the sidebar when disconnected, land on /favorites without
manual pins, and load starred rows from the local library index.
* feat(favorites): cross-server offline browse with per-server covers
When auto-save is on, Favorites merges starred items from every indexed
server and syncs each server independently. Detail links carry ?server=
for offline album/artist pages; cover art resolves disk cache by entity
serverId instead of the active server only.
* feat(playback): mixed-server queue scope and cross-server favorites sync
Per-ref server identity for playback (URL index key in queue refs, profile
UUID for API): trackServerScope, playbackServer helpers, gapless/scrobble/covers
by playing ref. Remove cross-server enqueue block; remap queueItems on URL
remigration.
Favorites: star/unstar and favorite-auto sync target the owning serverId
(not only active); queueSongStar passes server through pending sync.
* fix(offline): suppress Subsonic calls during favorites and local playback
Add reachability guards so offline favorites browse, album detail, queue
sync, scrobble, and Now Playing metadata skip network when the server is
down or the track plays from psysonic-local. Load starred albums/tracks
from the library index only (not the full artist table), refresh favorites
from index first, and pass server scope in favorites navigation.
* fix(queue): export share and playlist save for active server only
Mixed-server queues now filter queue refs by the browsed server profile
before copying a share link or saving/updating a playlist from the toolbar.
* fix(offline): complete album pins and resume interrupted downloads
Prefer full getAlbum track lists when online so partial library index
does not truncate offline pins; refresh songs before pin from album
detail. Resume incomplete persisted pins after reconcile and reconnect,
cancel in-flight work on delete, and chunk library batch fetches past
100 refs.
* fix(offline): pin queue, queued UI, and re-pin after remove
Serialize album and playlist offline pins so parallel enqueue no longer
drops in-flight work. Show an explicit queued state on album and playlist
actions with dequeue on repeat click, sidebar tooltips for long labels,
and clear stale cancel flags so Make available offline works after remove.
* fix(offline): remove Offline Library cards without full page reload
Optimistically drop the deleted card from local grid state, show the
loading spinner only on first visit, and ignore stale disk refreshes so
pin updates no longer flash the whole library view.
* fix(offline): artist discography pin state and queue handling
Detect cached/queued/downloading from persisted album pins instead of
ephemeral bulkProgress, skip already offline or in-flight albums when
enqueueing discography, and show the correct hero button after revisit.
* fix(local-playback): address LP-1 review handoff (B1, M1–M7)
Harden media path sanitization and tier containment, align Rust/TS layout
fingerprints, serialize per-track downloads, and fix favorites re-enable,
multi-server debounce, prev-track promote key, now-playing reachability,
and ephemeral prefetch soft-skip for unindexed tracks.
* fix(offline): cancel in-flight favorites downloads on unstar
Abort Rust streams with the real favorites downloadId when sync is
rescheduled or disabled, and drop completed bytes that no longer belong
in the starred set so unstar does not leave orphan files on disk.
* fix(build): resolve TypeScript errors blocking prod nix build
Align mediaLayout with LibraryTrackDto camelCase, extend analysis-sync
reasons, fix OfflineLibrary grid cover typing, and tighten vitest mocks
so `tsc && vite build` passes under the flake beforeBuildCommand.
* fix(rust): satisfy clippy too_many_arguments for CI
Bundle offline-library analysis and local path/migration helpers into
parameter structs so `cargo clippy -D warnings` passes on the branch.
* fix(settings): show correct hot-cache track count in Buffering section
Count ephemeral localPlayback rows instead of prefix-matching index keys,
which always missed host:port server segments and showed zero tracks.
* fix(media-layout): align truncation threshold on code points (M1)
Rust sanitize_and_truncate_segment now uses char count like TS so long
non-ASCII metadata does not diverge layout fingerprints; add Cyrillic
parity tests and clarify ephemeral cold-miss doc on download_track_local.
* fix(test): use numeric cachedAt in hotCacheStore count test
Align test fixture with LocalPlaybackEntry type so tsc passes in CI.
* docs: add CHANGELOG and credits for offline experience PR #1008
* feat(offline): auto-sync manually cached playlists when track list changes
Re-download new tracks and prune removed ones for playlist pins only, triggered
from updatePlaylist, playlist detail load, smart-playlist polling, and reconnect.
* docs: note cached-playlist sync in CHANGELOG and credits for PR #1008
* fix(offline): exclude smart playlists from manual offline cache and sync
Hide cache-offline for psy-smart-* playlists, block download/sync paths, and
document the distinction in CHANGELOG.
* feat(offline): auto-sync cached albums and artist discographies
Generalize pinned playlist reconcile into pinnedOfflineSync so manually
pinned albums and artist discographies re-download added tracks and prune
removed ones on reopen, reconnect, and catalog changes.
* feat(offline): split pinned sync triggers by pin kind
Album and artist pins reconcile after library index sync and reconnect;
regular playlists reconcile hourly and on in-app playlist edits only.
Remove reconcile-on-open for album, artist, and playlist detail views.
* fix(offline): address PR #1008 review (N1, tests, pin queue)
Scope playlist reconcile to the owning server via getPlaylistForServer.
Add artist discography and mixed-server playlist tests; dedupe pending
sync jobs; skip pinTasks overwrite during active downloads.
|
||
|
|
40db6b08d2 |
chore(playback): remove Preload Next Track setting (#1007)
* chore(playback): remove Preload Next Track setting The configurable next-track RAM preload duplicated hot cache and is obsolete now that ranged streaming starts playback sooner. Gapless and crossfade still use the internal audio_preload backup when hot cache is off. * docs: add CHANGELOG entry for Preload Next Track removal (PR #1007) |
||
|
|
1c23305887 |
feat(queue): add Timeline display mode (#1004)
* feat(queue): add Timeline display mode A third queue display mode alongside Queue and Playlist. Timeline shows the full queue anchored on the current track — played history above (dimmed), upcoming below — with 'History' and 'Up next' dividers and the current row auto-centered. It already follows shuffle order since the queue is stored in play order. The header mode button now cycles queue → timeline → playlist; Settings gains a third option. * i18n(queue): Timeline mode strings (9 locales) * docs(changelog): note Timeline queue mode (#1004) |
||
|
|
41157ccaca |
chore(fullscreen-player): drop dead i18n keys and old CSS (#1002)
Follow-up cleanup after the old fullscreen player was removed (#1001): - remove the 9 now-unused settings i18n keys (fsPlayerSection, fsShowArtistPortrait(+Desc), fsPortraitDim, fsLyricsStyle(+Rail/Apple +Descs)) across all 9 locales. - reduce fullscreen-player-adaptive-portrait.css to the shared lyrics overlay styles (FsLyricsApple); drop the old player's .fs-*, the .fslm-* lyrics menu, the unused .fsa-fade-* and dead keyframes. |
||
|
|
dc4eef1a97 |
feat(fullscreen-player): rebuilt static fullscreen player (#1001)
* feat(player): static media-center fullscreen player (v1) New lean fullscreen player: sharp full-bleed background (artist photo, cover fallback), no blur / no continuous animations. Bottom-left big cover bottom- flush with a full-width semi-transparent text bar (title with queue position, artist, year/genre + rating stars, next-up); control row of transport · center time · actions; full-width seekbar; live clock. Only the seekbar, time readout and clock update at runtime, each owning its state (no per-tick re-render). Reuses FsSeekbar/FsPlayBtn, the cover/artist hooks, idle-fade and queue helpers. Wired in AppShell; old player kept for A/B. * feat(fullscreen-player): true waveform seekbar instead of thin bar Replace FsSeekbar with the real WaveformSeek (cucadmuh's idea). The taller canvas grows the bottom cluster upward, shifting the info/control rows up. Height clamped to 32-52px. * feat(fullscreen-player): up-next popover + control-bar styling - Queue button opens a semi-transparent 'Up next' popover anchored bottom right; clicking a row jumps to that queue item. - Larger bottom-right action buttons. - Control row gets its own darker semi-transparent bar (touches the info bar above) for contrast; play button matches the plain transport buttons (no white circle, same size). * feat(fullscreen-player): high-res (2000px) background cover Fetch the full-screen background cover at the 2000px tier via the existing on-demand fullRes path (same getCoverArt fetch, saved as a high-res WebP outside the backfill pipeline) instead of the low-res 500px pipeline tier. usePlaybackCoverArt now forwards a fullRes option to useCoverArt. * feat(fullscreen-player): scrolling lyrics overlay + control-bar polish - Lyrics button next to Queue toggles a centered, dark semi-transparent scrolling-lyrics overlay (reuses FsLyricsApple). - Control bar darkened to match the lyrics overlay (0.88). - Close button sized down to harmonize with the clock. * feat(fullscreen-player): make rating stars clickable The rating stars next to the year were display-only. Wire them to queueSongRating (same path as the player bar / context menu): click to set, click the current value to clear, with a hover preview. Keeps the lucide outline look to match the rest of the control bar. * fix(fullscreen-player): stable, high-res album cover The foreground cover was keyed per track, so Navidrome's per-track `mf-<id>` coverArt re-triggered the distinct-disc heuristic and reloaded the cover on every song change within the same album. Key it on albumId (via useAlbumCoverRef) so it stays put while the album is unchanged. It also used the low-res tier; reuse the fullRes 2000px cover already fetched for the background so the foreground is crisp and both share a single fetch/decode. * refactor(fullscreen-player): remove the old player and its settings The static rebuild is now the only fullscreen player. Delete the old FullscreenPlayer component, its old-only parts (FsArt, FsPortrait, FsSeekbar, FsLyricsRail, FsLyricsMenu, useFsDynamicAccent) and its test. Remove the now-orphaned settings (showFullscreenLyrics, fsLyricsStyle, showFsArtistPortrait, fsPortraitDim) along with the Appearance 'Fullscreen player' section and its search entry. The new player always shows the artist photo with cover fallback and has its own lyrics toggle. Shared building blocks used by the static player (FsLyricsApple, FsPlayBtn, FsClock, FsTimeReadout, FsQueueModal, useFsArtistPortrait) are kept. * i18n(fullscreen-player): translate the new player's strings Wire the static player's previously English-only strings through i18n across all 9 locales: now-playing label, track position, up-next label, queue/lyrics/shuffle controls, and the queue overlay (title, empty, close). Reuses existing queue.title / common.close keys; adds six new keys to the player namespace. * docs(changelog): note fullscreen player rebuild (#1001) |
||
|
|
c39465dfad |
feat(sidebar): toggle to pin Now Playing to the top (#1000)
* feat(sidebar): add toggle to pin Now Playing to the top The fixed Now Playing entry can now be pinned to the very top of the sidebar (above the Library label) instead of sitting above the bottom spacer. Adds a persisted `nowPlayingAtTop` setting (default off, so existing layouts are unchanged) and a toggle in the sidebar customizer next to the Mix-navigation split. The entry stays non-hideable and keeps its playing indicator in both positions. * i18n(settings): Now Playing-at-top toggle strings (9 locales) * docs(changelog): note Now Playing top toggle (#1000) |
||
|
|
47e16ebfef |
fix: RC3 queue link underline, genre album artist split, Artists "Other" bucket (#977)
* fix: RC3 queue link underline, genre album artist split, Artists "Other" bucket Three zunoz reports, one change: - Queue now-playing card: the artist and album links now underline on hover (not just recolour), matching clickable names everywhere else. The album link sits on .queue-current-sub itself; artist links are nested .is-link spans — both selectors covered. - Genre album cards split multi-artist credits into individual links like the rest of the app. Root cause was the local-index genre query hardcoding NULL for the album raw_json column, so OpenSubsonic artists[] never reached the card and it fell back to the flat "A • B" single link. Select a.raw_json instead (parity with the All Albums / advanced-search album queries). - Artists page alphabet index: # is now digits-only, and a new "Other" bucket collects accented Latin (Æ/Ø/Å…) and non-Latin scripts (CJK, Cyrillic, …) that previously fell into the # catch-all. Adds artistBucketKey/compareBuckets helpers (unit-tested), an OTHER bucket sorted last, and the artists.other label across 9 locales. * docs(changelog): queue link hover, genre card artist split, Artists Other bucket (#977) |
||
|
|
908c349cfd |
fix(mainstage): rename Home Page setting, start-page fallback + empty state (#975)
* fix(mainstage): rename Home Page setting, add start-page fallback + empty state - Rename "Home Page" personalisation section to "Mainstage" across 9 locales so the heading matches the sidebar entry; add "mainstage" search keyword. - Index route "/" now redirects to the first visible library item when the Mainstage sidebar entry is hidden, instead of stranding the app on a blank page. New pure resolveStartRoute() mirrors sidebar order + nav-mode gating. - Show a guided empty state on Mainstage when every section is toggled off, with a CTA into Settings -> Personalisation. - Unit tests for resolveStartRoute. * docs(changelog): Mainstage rename, start-page fallback + empty state (#975) |
||
|
|
c119a32277 |
Unify button tooltips across the app (#972)
* feat(tooltip): 2s open delay and shared tooltipAttrs helper Add a 2s hover open delay in TooltipPortal (single behaviour source) so tooltips no longer flash on quick pointer passes; hiding stays immediate. Add tooltipAttrs() to pair data-tooltip with a matching aria-label for buttons touched in the unification work. Covered by Vitest. * feat(tooltip): lower open delay to 1s 2s felt too long in testing; 1s gives the same anti-flash behaviour without making intentional hovers wait. * feat(tooltip): action tooltips on the artist overview Add tooltips describing the action to Last.fm, Wikipedia, Play All, Shuffle and Radio. Shuffle/Radio now show a tooltip on desktop too, not just mobile. Strings added to all 9 locales. * feat(tooltip): action tooltips on the album overview Add tooltips describing the action to the desktop Play, Artist Bio and Download (ZIP) buttons, matching the mobile layout. Strings added to all 9 locales. * feat(tooltip): action tooltips on the All Albums toolbar Add tooltips describing the action to the sort, year and genre filter buttons. SortDropdown gains an optional tooltip prop; the year and genre filter components carry their own, so the tooltips also appear on the other browse pages that reuse them. Strings added to all 9 locales. * feat(tooltip): action tooltips on song-list rows Add Play and Add-to-queue tooltips to the per-row icons in SongRow, used by the Tracks browse list, Search and Advanced Search. Also localizes the aria-labels, which were hardcoded English. New common.addToQueue in all 9 locales. * fix(tooltip): uniform tooltip placement on the Artists toolbar The favourite and multi-select buttons forced tooltips below while the view-mode buttons auto-flipped above, so the row looked inconsistent. Pin the view-mode buttons below too, matching the rest of the row and the Albums toolbar. * feat(tooltip): clarify and align the Advanced Search scope row Add a leading "Search in:" label and per-chip tooltips so the All/Artists/Albums/Songs row reads as a scope limiter. Drop the forced below-placement on the small star filter (used only here) so the favourites chip flips with the others instead of sitting alone below. Strings added to all 9 locales. * docs(changelog): tooltip unification (#972) |
||
|
|
f3a0b3f7af |
fix(artist-detail): Last.fm/Wikipedia/Favorite hover keeps button border (#966)
* fix(artist-detail): keep ext-link border visible on hover Hover used --border-subtle, which on Catppuccin matches --bg-card and visually erased the rim while only the fill changed. Match btn-surface: --ctp-surface1 border, --ctp-overlay0 on hover. * docs(changelog): credit zunoz on Psysonic Discord for PR #966 * fix(playlists): tooltips on Play/Add Songs and song count pluralization Add data-tooltip to Play and Add Songs in playlist hero; switch playlists.songs to count-based _one/_other forms (was {{n}} without i18next plural suffix, breaking spacing and singular). * fix(playlists): render BPM and optional cols in Suggested Songs rows PlaylistSuggestions shared column headers with the main tracklist but its row switch omitted bpm, genre, playCount, and lastPlayed. |
||
|
|
5990d84f5a |
fix(random-mix): keyword blocks and scoped genre list on Build a Mix (#965)
* fix(random-mix): honor keyword blocks and scope genre list to library Keyword filter was gated behind the audiobook exclusion checkbox, so blocked artists still appeared after Remix. Genre Mix now loads genres via fetchGenreCatalog (scoped index / library filter) instead of raw getGenres; show empty state when all tracks are filtered out. * docs(changelog): credit zunoz on Psysonic Discord for PR #965 |
||
|
|
cc04a0c93d |
Distinct circular song cards with jump-to-album badge (#953)
* feat(tracks): distinct circular song cards with jump-to-album badge Single-track cards looked identical to album tiles, so clicking the body read as album behaviour even though it starts playback. Give them a round vinyl-style cover (square stays album-only) via a shared .cover-circle utility, and add a 'To album' badge under the artist that navigates to the track's album. Card click still plays; the badge is the explicit nav path. * i18n(tracks): add toAlbum label across 9 locales * docs(changelog): track-card redesign + to-album badge (#953) |
||
|
|
4ac373a65b |
feat(search): scoped live search on browse pages (#938)
* feat(artists): scoped live search badge replaces page filter Move Artists browse text search into the header Live Search with a page scope badge (Users icon), field-local undo, and double-click/backspace to clear scope. Block the live-search dropdown while scoped so results only filter the Artists grid; mobile overlay follows the same rules. * fix(artists): plain grid for scoped search fixes broken card layout Route the browse grid through VirtualCardGrid, switch to non-virtual CSS grid when live search filters the catalog, reset scroll on filter changes, and skip content-visibility on plain tiles to avoid blank/black cards. * fix(search): scope badge double-Backspace and single clear control Require two Backspaces on an empty scoped field after prior text input; one Backspace still clears the badge when the field was never filled. Move live-search clear/advanced controls inside the field pill, drop the extra outer clear button, and use type=text to avoid native search clears. * fix(search): drop duplicate outer live-search clear button Keep the native in-field clear on type=search and the original pill layout; remove only the extra × control outside the search border. Reset dropdown state when the query is cleared via the native control. * refactor(search): generic scoped browse query helper, drop dead code Rename artistsBrowseSearchQuery to scopedBrowseSearchQuery with an expectedScope argument; wire Artists via useScopedBrowseSearchQuery. Remove unused liveSearchScoped dropdown helper (scoped mode blocks it). * feat(search): ghost scope badge and single-click badge remove After clearing the artists scope on /artists, show a faded ghost chip to restore page-only search while keeping the global search placeholder. Active badge removes on one click; tooltips and styles updated. * feat(search): scoped live search for All Albums and New Releases Wire albums and newReleases scope badges with debounced album title search (local index title-only FTS + filtered search3). Plain grid, scroll reset, and session query restore on album grid browse pages. * fix(browse): preserve scroll restore after album/artist detail back Only reset in-page scroll when filter resetKey changes, not when isScrollRestorePending clears after session restore. * feat(search): scoped live search for Tracks browse Wire /tracks to header live search with wide title/artist/album FTS, hide hero and discovery rails while search is active, and remove the inline search field from the browse list. * fix(search): clear header query when leaving scoped browse pages Prevent global live search from firing on album/detail routes after a scoped browse query; browse session stashes still restore on back. * fix(tracks): restore scroll after back from detail during scoped search Hold stashed song results across fetchSongPage churn, defer leave-stash teardown past AppShell scroll reset, restore tracks scroll after the list is ready, and save scroll snapshot when opening artist from song context menu. * fix(tracks): hide discovery headings during scoped search Hide the page subtitle and "Browse all tracks" section title when tracks search is active, matching hero/rails chrome behavior. * feat(search): scoped live search for Composers browse Wire /composers to header live search with composers scope badge, session stash, scroll restore, and plain grid/list during text filter. Remove the in-page filter input; add i18n and navigation helpers. * docs: CHANGELOG and credits for scoped browse live search (PR #938) |
||
|
|
ddf10ee01d |
feat(genres): local index genre browse with Subsonic fallback (#937)
* feat(genres): genre detail browse via local index with aligned counts Move genre detail albums/play/shuffle onto the local library index with Albums-style in-page scroll, session restore, and genre-scoped stash. Unify genre album totals between the cloud and detail pages via library_get_genre_album_counts, and fix grouped browse totals to count distinct albums rather than matching tracks. * perf(genres): local genre browse with scoped counts cache Add dedicated Rust genre album pagination and indexes, slice-mode grid loading, library-filter-aware counts, and a long-lived in-memory catalog cache invalidated on sync so genre pages avoid repeated full-library SQL. * fix(genres): restore scroll after album back; play hold-to-shuffle Pin restore display count in refs so clearing the return stash no longer reloads the genre grid mid-restore. Load the first SQL page only (60 rows), use long-press on Play for shuffle, and add genre play tooltips. * fix(genres): fall back to Subsonic byGenre when local index unavailable Genre detail album grid now matches All Albums: try library_list_albums_by_genre first, then getAlbumsByGenre when the index is off, not ready, or errors. * docs: add CHANGELOG and credits for PR #937 |
||
|
|
e734a8fc43 |
feat(genre): play, shuffle and queue buttons on the genre view (#926)
* feat(genre): add paginated songs-by-genre API Wraps the Subsonic getSongsByGenre endpoint plus a fetchAllSongsByGenre helper that paginates until exhausted, capped to keep the queue and the burst of requests bounded for very large genres. * refactor(playback): extract shared bulk play/shuffle/enqueue helper A single fetchTracks-driven core (loading flag, empty guard, canonical shuffleArray) so async detail-page play buttons stop growing divergent copies. Artist detail now reuses it, dropping its weaker sort-random shuffle. * feat(genre): play, shuffle and queue buttons on the genre view Header buttons load the genre's songs and start ordered or shuffled playback, or append them to the queue. The slice is bounded to stay within the queue resolver's cache budget so every row resolves instead of rendering as a placeholder. Strings added across all nine locales. * docs(changelog): genre play/shuffle buttons (#926) |
||
|
|
1de2b0e850 |
feat(queue): switchable queue display mode (Queue vs Playlist) (#922)
* feat(queue): add queueDisplayMode setting with rehydrate (default queue) * feat(queue): render upcoming-only or full timeline by mode, with header toggle * feat(settings): add queue display mode toggle to Personalisation * i18n: queue display mode strings across all locales * test(queue): display-mode rendering and absolute index mapping * docs: changelog + credits for queue display mode (#922) |
||
|
|
ae2e123a14 |
feat: add long press to shuffle with a wave animation (#888)
* feat: add long press to shuffle with a wave animation to singnify how long to press * refactor: long-press shuffle cleanup Follow-up on the long-press shuffle PR: shared hook/overlay, playback parity, pointer events, broader surface coverage, locales, and tests. * docs: credit ImAsra for long-press album shuffle (PR #888) Add CHANGELOG entry and Settings credits for the hold-to-shuffle play interaction shipped in Psychotoxical/psysonic#888. * fix: restore playAlbumShuffled and long-press hook wiring The follow-up merge dropped playAlbumShuffled and reverted the shared long-press hook in album play buttons, breaking tsc and vitest on PR #888. --------- Co-authored-by: cucadmuh <49571317+cucadmuh@users.noreply.github.com> |
||
|
|
455aec4def |
feat(discord): show track title in Discord member list (configurable name template) (#885)
* feat(discord): override activity name in member list with track title (configurable template)
The Discord member list and the collapsed Rich Presence card display the
activity's `name` field next to the music icon. Without an override
Discord falls back to the registered application name ("Psysonic"), so
the line reads "Psysonic" while every track plays instead of the actual
track title that comparable players show.
Add a fourth user-configurable template `discordTemplateName` (Settings ->
Integrations -> Discord Rich Presence). The Rust side passes it through
`Activity::new().name(...)` when the rendered template is non-empty; an
empty template falls back to Discord's default application name. Default
template is "{title}".
Tauri boundary: additive optional `nameTemplate` field on the existing
`discord_update_presence` command. Existing call sites that omit it keep
working -- the Rust handler applies the same "{title}" fallback.
* i18n(settings): discord name template label in all locales
Adds the discordTemplateName label across en, de, fr, es, nl, nb, ro,
zh, ru -- shown next to the new "User list line (name)" template input
under Discord Rich Presence.
* docs(release): CHANGELOG and credits for discord name template (PR #885)
|
||
|
|
403979b35d |
feat(input): opt-in WebKitGTK focus repaint workaround for Linux (#342, #782) (#884)
* feat(input): opt-in WebKitGTK focus repaint workaround for Linux (#342, #782) Some Linux users on WebKitGTK 2.50.x report a freeze when clicking text fields: the field receives focus (right-click paste still works) but the canvas never redraws until the window is resized. Likely a layer-flush / composition scheduling bug in 2.50.x's Skia rendering pipeline. Off by default. When enabled in Settings -> System -> Behavior, every input/textarea focus triggers a sync reflow read plus a one-frame translateZ(0) toggle on the input's parent so WebKit re-evaluates the layer tree. Side-effect: search-icon siblings flicker briefly on focus -- accepted trade-off, only paid by users who opt in. The toggle row is gated on IS_LINUX and sits next to the existing Linux WebKitGTK options. The effect subscribes to the auth store and re-attaches the focusin handler whenever the flag flips, so toggling off cleanly removes the listener. * i18n(settings): linux input repaint toggle in all locales Adds linuxWebkitInputForceRepaint label + description across en, de, fr, es, nl, nb, ro, zh, ru. * docs(release): CHANGELOG and credits for linux input freeze workaround (PR #884) |
||
|
|
9fa086c428 |
feat(server): dual server address (LAN + public) per profile (#880)
* feat(server): ServerProfile.alternateUrl + shareUsesLocalUrl
Additive optional fields on ServerProfile to back the dual-address work.
'alternateUrl' is the optional second endpoint (e.g. LAN counterpart of a
public 'url' or vice versa); 'shareUsesLocalUrl' chooses which of the two
goes into Orbit / entity / magic-string shares when both are set.
Pure schema change — no runtime consumers yet. Single-address profiles
keep the same persisted shape; the optional fields are simply absent.
* feat(server): serverEndpoint module with LAN classification + IPv6
Introduces a single home for connect-/share-layer URL utilities that
the dual-address work will build on:
* normalizeServerBaseUrl(raw) — aligned with serverProfileBaseUrl
* isLanUrl(url) — IPv4 (unchanged) + IPv6: ::1 loopback, fe80::/10
link-local, fc00::/7 ULA, and IPv4-mapped (both dot-decimal and the
URL-API-normalized hex form, since new URL() rewrites ::ffff:1.2.3.4
to ::ffff:HHHH:HHHH)
* allNormalizedAddresses(profile) — deduped [url, alternateUrl?]
* serverAddressEndpoints(profile) — LAN-first ServerEndpoint[]
isLanUrl moves out of useConnectionStatus.ts (the hook now imports +
re-exports it for backward compatibility); OrbitStartModal points at
the new path directly. 38 unit tests cover the IPv4/IPv6 matrix,
dedupe, ordering, and edge cases.
* feat(server): pickReachableBaseUrl with in-memory connect cache
Adds the runtime connect layer on top of serverEndpoint:
* pickReachableBaseUrl(profile) — sequential LAN-first ping via the
existing pingWithCredentials; first OK wins.
* ensureConnectUrlResolved(profile) — boot / switch / online-event
entry point (same mechanism, intent-named).
* In-memory cache keyed by profileId; sticky behaviour tries the
cached endpoint first, falls back to the natural order on miss.
* invalidateReachableEndpointCache(profileId?) — single-profile and
whole-cache flushes for profile-edit / credentials-change / online.
* getCachedConnectBaseUrl(profileId) — sync getter for getBaseUrl
fallback path (next commit).
The cache is **session-only**, never persisted. Single-address profiles
keep the same shape (one endpoint, one ping). 9 new unit tests cover
single + dual address, LAN-first preference, fallthrough, unreachable +
stale-cache clear, sticky hit, and the two invalidate flavours.
* feat(server): getBaseUrl reads connect cache, falls back to primary url
Dual-address profiles need 'getBaseUrl' to return the runtime-probed
connect URL (LAN at home, public elsewhere), not the literal primary
'url'. To keep the sync getter shape that ~60 call sites depend on,
the store now reads 'getCachedConnectBaseUrl(server.id)' from the
serverEndpoint cache.
If no probe has run yet (very early boot, before switchActiveServer
populates the cache), it falls back to the normalized primary URL —
identical to today's behaviour. Single-address profiles see no
difference; their cache entry equals serverProfileBaseUrl(url).
* feat(server): switch + connection-status probe via ensureConnectUrlResolved
Both surfaces that previously called pingWithCredentials(server.url, ...)
directly now route through the dual-address connect layer:
* switchActiveServer awaits ensureConnectUrlResolved(server), reads the
identity (type/serverVersion/openSubsonic) off the returned ping, and
hands probe.baseUrl (not server.url) to scheduleInstantMixProbeForServer
so the AudioMuse probe also hits the reachable endpoint.
* useConnectionStatus.check() does the same on every 120 s tick — sticky
cache fast-paths the steady state, and a network change naturally
flips the active endpoint without a manual retry.
* useConnectionStatus.retry() and the 'online' event flush the cached
entry for the active profile first, so the next probe starts from the
natural LAN-first order instead of revalidating a stale URL.
Single-address profiles behave identically: one endpoint in the list,
one ping per check. No behaviour change for them.
* feat(server): PickReachableResult.ping + connectBaseUrlForServer helper
Two additive extensions to the serverEndpoint surface that the upcoming
CONNECT migrations all need:
* 'ping: PingWithCredentialsResult' on the OK branch of
PickReachableResult so callers (switchActiveServer,
useConnectionStatus) can read type/serverVersion/openSubsonic from
the same probe instead of issuing a second pingWithCredentials.
* connectBaseUrlForServer(server) — sync getter for the connect URL
of *any* saved profile (active or not). Reads the cache, falls back
to normalized primary url on miss. Becomes the canonical helper
for non-active-server HTTP traffic (apiForServer, stream URLs,
cover fetches, library bind session).
Tests: pingOk() fixture now returns identity fields; the single
toEqual-asserting test in pickReachableBaseUrl became a guarded read
to keep equality concise while still asserting on the new ping field.
* feat(connect): apiForServer + stream URLs route via connectBaseUrlForServer
Non-active-server HTTP traffic (the apiForServer entry point, used by
QueuePanel cross-server cover fetches and share-paste resolution) and
the stream-URL builders previously read 'server.url' directly,
bypassing the dual-address connect cache. Both now go through
connectBaseUrlForServer, which serves the cached LAN/public endpoint
when one exists and falls back to the normalized primary url
otherwise.
buildStreamUrl(id) now uses the baseUrl it already pulled from
getBaseUrl() (which is connect-cache aware as of 2a6d8283) instead of
re-normalizing server.url — same value for single-address profiles,
correctly dual-address for the rest. Single-address callers see no
behaviour change.
* feat(connect): cover fetch + cover-cache ensure use connect URL
Both cover-fetch surfaces previously read 'scope.url' / 'server.url'
straight into HTTP requests, which would freeze the cover pipeline on
the primary URL even when a LAN endpoint was reachable:
* buildCoverArtFetchUrl(ref, tier) — for the 'server' scope (queue
rows cached against a non-active profile) and the 'playback' scope
(cross-server playback) now resolves the connect URL via
connectBaseUrlForServer before handing off to
buildCoverArtUrlForServer.
* ensureArgsFromRef in coverCache.ts — restBaseUrl for the Tauri
'cover_cache_ensure' invoke now uses the cached connect URL for
both 'server' and 'active'/'playback' scopes.
Scope.url itself remains the index-stable primary URL — that's what
storageKeys (INDEX) consume. Only the HTTP base shifts to the connect
endpoint, exactly the split the spec calls out.
* feat(connect): library bind + server-test probe via ensureConnectUrlResolved
Two more 'rebind / re-test an existing saved server' paths still pinged
the literal primary URL — both go through the dual-address connect
layer now:
* bindIndexedServer (librarySession.ts): used to ping server.url then
pass serverProfileBaseUrl(server) as the bind 'baseUrl' to Rust.
Now: one ensureConnectUrlResolved call covers both — probe.baseUrl
feeds librarySyncBindSession directly, so Rust's per-server library
sync uses whichever endpoint actually answered. Drops the
pingWithCredentials and serverProfileBaseUrl imports.
* testConnection (ServersTab.tsx): same shape — probe via the connect
layer, read identity from probe.ping, hand probe.baseUrl to
scheduleInstantMixProbeForServer so the AudioMuse probe also hits
the connect endpoint. Single-address profiles still ping once.
Add/edit save handlers (handleAddServer / handleEditServer) keep the
direct pingWithCredentials against the user-entered data.url — that
flow is the dual-address verify hook, which PR 2 extends.
* feat(server): serverShareBaseUrl — public-by-default share URL picker
Adds the share-layer companion to connectBaseUrlForServer. Different
intent: connect picks the reachable endpoint for HTTP, share picks the
URL that goes into Orbit invites / entity share payloads / queue share
links / magic strings — where a guest opening the link is not on the
host's LAN.
Logic per spec §5.1:
* Single-address profile → that one address (normalized).
* Both set, default flag → public.
* Both set, shareUsesLocalUrl flag → local.
* Edge cases (both LAN, both public, missing one of the two): fall
back to the first endpoint in the list so the function is total.
Empty profile returns the normalized url (possibly empty) — defensive,
never throws. 7 unit tests pin all six branches plus the empty case.
Call-site migration (Orbit, copyEntityShareLink, QueuePanel, magic
string srv field, findServerIdForShareUrl, Orbit LAN warning) lands
in the next commits.
* feat(share): Orbit + entity + queue shares use serverShareBaseUrl
Four share-encoding surfaces previously read 'getBaseUrl()' (connect)
or the raw 'server.url' (primary) when embedding the host into outgoing
share links — both wrong for a dual-address profile:
* copyEntityShareLink (track / album / artist / composer) — was
getBaseUrl(); now reads serverShareBaseUrl(active). Guests opening
the link are off-LAN, so public is the right default.
* OrbitStartModal — was raw server?.url for both 'buildOrbitShareLink'
and the LAN warning. Now goes through serverShareBaseUrl; the LAN
warning correspondingly reflects the address the guest will see,
not the host's primary.
* OrbitSharePopover — same fix on the host-only share popover.
* QueuePanel.handleCopyQueueShare — was getBaseUrl(); same migration.
Single-address profiles return exactly the same string from
serverShareBaseUrl as serverProfileBaseUrl(server.url) did before, so
no behaviour change for the common case. shareUsesLocalUrl flips the
default to LAN for the rare 'share into a LAN-only group' use; that
checkbox lands with the form UI in the next sub-phase.
findServerIdForShareUrl + paste-side share matchers are migrated in
the next commit (read-side of the same contract).
* feat(share): paste-side resolves dual-address profiles + connect URL
Read-side counterpart to the encode-side migration:
* findServerIdForShareUrl(servers, shareSrv) now matches a profile
when shareSrv normalizes to either profile.url OR profile.alternateUrl.
Without this, a paste of a link generated against the host's LAN
address (shareUsesLocalUrl=true) would fail to find the local saved
profile even though it's the same server.
* resolveSharedSong / resolveShareSearchAlbum / resolveShareSearchArtist
in enqueueShareSearchPayload now hand connectBaseUrlForServer(lookup.server)
to the *WithCredentials HTTP calls instead of the raw lookup.server.url.
The looked-up profile may be dual-address; this routes the song / album /
artist fetch through whichever endpoint is currently reachable.
Indirect consumers (shareServerOriginLabel, shareQueueServerContext) read
the same findServerIdForShareUrl, so no code change needed there — they
now match both addresses transparently.
* feat(verify): serverFingerprint + same-server verification
Pure-logic core of dual-address verify. Three exports:
* fetchServerFingerprint(baseUrl, user, pass) — one ping (envelope
'version' extracted alongside type/serverVersion/openSubsonic) plus
four soft-fail optional calls in parallel (getMusicFolders, getUser,
getLicense, getIndexes). Optional failures collapse to null fields,
not whole-fingerprint failure. Subsonic-generic — never branches on
type === 'navidrome'. indexesDigest is a hash of letter-count plus
sorted first 20 artist ids, so two probes against the same library
see the same digest without comparing full payloads.
* compareFingerprints(a, b) — strict on the ping triple
(type case-insensitive, serverVersion exact, openSubsonic boolean);
envelope apiVersion informational only. Body signals counted only
when both sides have a non-null value; empty musicFolders [] on both
sides still counts as a matching signal. Result: 'match' (>=1 common
signal all agreeing) / 'mismatch' (any common differs) /
'insufficient' (0 common). No 'save anyway' for insufficient in v1.
* verifySameServerEndpoints(profile, user, pass) — single-address
short-circuits to ok:true (nothing to verify). Otherwise parallel
fingerprint probes, then pairwise compare. Ping-fail on any
endpoint reports the offending host for the UI.
20 unit tests cover the compare matrix (every body signal in every
direction), Navidrome- vs minimal-Subsonic-shape probes, ping-fail,
and all four verify outcomes (ok / unreachable / mismatch /
insufficient). HTTP mocked via vi.stubGlobal('fetch') + plain-object
Response shape (avoiding any Response-polyfill dependency).
* feat(boundary): resolve_host_addresses Tauri command + TS wrapper
Adds one additive invoke for dual-address form hints. Spec §9.1
+ contracts.md §6.
Rust side (src-tauri/src/lib_commands/app_api/network.rs):
* #[tauri::command] resolve_host_addresses(hostname: String) ->
Result<Vec<String>, String>
* tokio::net::lookup_host with a port suffix (':0'); discards the
port from each SocketAddr, dedupes addresses via HashSet.
* strip_port helper handles 'host:port', 'ipv4:port',
'[ipv6]:port', '[ipv6]' (no port), and bare 'ipv6' (left as-is
for lookup_host to wrap). 7 unit tests cover each shape.
* Lookup failure → Ok(vec![]) so a DNS hiccup doesn't surface as
an error toast or block the save flow — form-hint only.
Frontend wrapper (src/api/network.ts):
* resolveHostAddresses(hostname) — trims, invokes, swallows errors
to an empty array so consumers get a clean total function.
§04 boundary note: additive command, no breaking change. Added to
the invoke_handler! generate_handler list in lib.rs at the natural
alphabetical-ish slot near migration_run.
* feat(form): AddServerForm — second optional address + share flag + DNS hint
UI side of dual-address. AddServerForm now carries the four new
moving parts spec §6 calls out:
* Second address field ('Second address (optional)') under the
primary URL. Placeholder flips to suggest the opposite kind
of address based on the primary's LAN classification.
* Contextual hint under the second field when it's empty —
'add a public address for outside-home use' (primary is LAN)
or 'add a local address for faster home access' (primary is
public). Hint disappears once the field has content.
* Two-LAN client-side check on submit: when both addresses
classify as LAN, save is blocked with a toast. Reverse case
(both public) intentionally allowed per spec §6.3.
* shareUsesLocalUrl checkbox — visibility rule per spec §5.3:
hidden until the second address has content; shows with the
persisted value on edit; cleared when the user empties the
second address before save.
DNS hint: on primary-URL blur we call the Tauri
resolve_host_addresses command and classify the response by
isLanUrl. Literal IPs skip DNS (already classified locally). DNS
miss → no hint surfaces (never blocks save). Used only for the
hint text — connect still goes through pingWithCredentials.
i18n: 9 new keys in en + ru (serverAlternateUrl*, shareUsesLocalUrl*,
serverBothLanError). Other locales fall back to English.
onSave signature widened to 'void | Promise<void>' — ServersTab /
Login both accept that already. The save flow itself still calls
the legacy pingWithCredentials path; verify wiring lands in 2e.
* feat(verify): wire verifySameServerEndpoints into add + dual-edit flows
Save flow now blocks persisting a dual-address profile that fails the
same-server check. Spec §6.4 + §7.4.
handleAddServer:
* When data.alternateUrl is non-empty, runs verifySameServerEndpoints
BEFORE the existing ping. mismatch / insufficient / unreachable each
surface a localized toast and abort the save (no addServer call).
* Single-address adds keep the legacy single-ping path — no extra
network round-trip when there's nothing to verify.
handleEditServer:
* Unconditional save remains the default — but if the edit either
introduces / changes alternateUrl OR changes url / username / password
while alternateUrl is set, verify runs first and may block.
* After persist, invalidates the reachable-endpoint cache for this
profile id so any sticky cached connect URL from before the edit is
re-probed on the next access (credentials may have changed, alternate
may have appeared).
i18n: 4 new toast keys in en + ru (dualAddressVerifying placeholder for
future progress UI, plus mismatch / insufficient / unreachable). Other
locales fall back to English.
announceVerifyResult helper keeps the toast routing in one place so
handleAddServer and handleEditServer share the same surface.
* i18n(settings): dual-address keys across all 9 locales
Adds the 13 new dual-address keys to the remaining 7 locales:
de · fr · es · nl · nb · ro · zh.
* serverAlternateUrl + placeholderPublic / placeholderLocal
* serverAlternateUrlHintAddPublic / HintAddLocal (contextual hints
under the second-address field)
* serverBothLanError (client-side two-LAN validation)
* dualAddressVerifying / Mismatch / Insufficient / Unreachable
(toast strings; Unreachable carries the {{host}} interpolation)
* shareUsesLocalUrl + Desc (checkbox label + short description)
Placeholders (example.com URL / 192.168.1.100:4533) kept identical
across all locales — same shape the existing serverUrlPlaceholder
already uses.
Single coordinated sweep so every supported language ships dual-
address fully translated rather than falling back to English.
* feat(cover): cover_cache_rename_server_bucket Tauri command
Adds the disk-side companion to the upcoming URL-change remigration
flow (dual-server-address spec §8.3). When a user edits the primary
url so the derived index key changes, the SQLite migration already
re-tags rows via the existing migration_run command — this command
moves the cover-cache bucket on disk so cached WebP tiles stay
reachable under the new key.
Behaviour:
* old_key == new_key → no-op.
* Old bucket missing → no-op success (nothing to migrate).
* New bucket missing → simple fs::rename (fastest path).
* Both exist → recursive merge with 'prefer existing' on file
collision (the newer bucket wins; data is never lost).
* Emits 'cover:bucket-renamed' with {oldKey, newKey} on success
so the frontend disk-src cache can invalidate stale URLs.
Sanitization: rejects empty, backslash, and '..' path segments at
the FS boundary. Forward slashes are legitimate (a Navidrome
mounted at a subpath like 'music.example.com/navidrome' produces
an index key with one); they're handled by Path::join.
4 unit tests cover key sanitization (accepts real keys, rejects
traversal/backslash) and the merge semantics (unique-file move +
prefer-existing on collision). Registered in lib.rs invoke handler.
* feat(remap): rewriteFrontendStoreKeysForRemap — explicit oldKey→newKey path
Adds the URL-change remigration entry point next to the existing
UUID→indexKey migration. Same plumbing (offline store, hot cache,
analysis-strategy maps) but driven by explicit { oldKey, newKey }[]
mappings instead of being derived from the current servers list.
Also folds in the player-side cleanup the existing path didn't have:
* queueServerId — if currently bound to a remapped index key,
repoint to the new one so playback continues through the rename
instead of looking unbound.
* analysisStrategyStore — runs the same map-key swap inline (the
'migrateServerOverrides' helper handles UUID→indexKey, not
indexKey→indexKey).
Same prefer-existing-on-collision semantics as the disk-side
cover_cache_rename_server_bucket — if a destination key already
carries data, we keep it. 8 unit tests cover no-ops, the four
store rewrites, the queue repoint, the 'leave other servers
untouched' guarantee, and the collision case.
* feat(remap): serverUrlRemigration orchestrator — 4-stage pipeline
Single-file orchestrator that runs the full URL-change remigration when
a profile edit shifts the primary url's derived index key. Spec §8 +
contracts.md §4.
Two exports:
* indexKeyRemapForUrlChange(prev, next): IndexKeyRemap | null
— short-circuits scheme-only edits ('http://x' ↔ 'https://x'),
trailing-slash differences, and alternateUrl-only changes by
normalizing both sides through serverIndexKeyFromUrl and
returning null when they match. Empty urls also return null.
* runIndexKeyRemigration(remap): Promise<IndexKeyRemigrationResult>
— runs inspect → run → frontend-rewrite → cover-rename in order,
aborts on the first failure and reports the offending stage.
Failure ordering rationale:
* inspect / run failures stop the destructive step — DB is
untouched, the caller can retry safely.
* frontend rewrite swallows errors (best-effort; zustand persist
catches up on rehydrate next session).
* cover-rename failure is reported but does NOT roll back the DB
rows (that would be even more destructive); covers under the
old key recover via the existing cover backfill on next access.
10 tests cover the detect logic (5 cases including the path-suffix
case) plus all four pipeline stages with mocked Tauri invoke —
including verifying the legacyId/indexKey mapping shape lands on
both inspect and run calls.
* feat(remap): wire URL-change remigration into ServersTab edit flow
handleEditServer now detects a primary-url index-key change BEFORE
any other save logic (verify, persist, etc.) and orchestrates the
full remigration when one is needed.
Flow:
* indexKeyRemapForUrlChange(prev, next) — null for scheme-only
edits / alternateUrl-only edits / no-op edits, so the common case
falls straight through without prompting the user.
* On a real remap → confirm modal via useConfirmModalStore.request
(danger style, plain-language oldKey → newKey copy, explicit
'cannot be undone'). User cancel → abort save entirely.
* On confirm → runIndexKeyRemigration pipeline. Failure surfaces a
stage-specific toast (inspect / run / cover-rename — wording per
spec §8.4 + the recoverability matrix in serverUrlRemigration.ts).
* On success → fall through to the existing dual-address verify,
then auth.updateServer + invalidate cache + ping (unchanged).
i18n: 7 new keys (urlRemigrationTitle / Message with oldKey + newKey
interpolation / Confirm / Progress / FailureInspect / FailureRun /
FailureCoverRename) — full sweep across all 9 locales (en, de, ru,
fr, es, nl, nb, ro, zh).
Test fixup: the offline-store collision test in rewriteFrontendStoreKeys
needed an 'as unknown as' cast — the existing OfflineTrackMeta type
doesn't carry the test-only marker property, and a one-step cast
TS rejected as not overlapping enough.
* feat(magic): magic-string v2 encode/decode with dual-address fields
Adds the v2 wire format for server invites. Spec §10 + contracts.md §5.2.
Encode is opportunistic: payloads with alternateUrl set OR
shareUsesLocalUrl=true emit v2; everything else stays v1 byte-identical.
This means single-address profiles keep producing exactly the same
invite they did before — older receivers can't tell the difference.
v2 shape: { v: 2, url, alt?, shareLocal?, u, w, n? }
* url — the host's share URL (public by default; LAN if shareLocal),
NOT necessarily the host's primary URL. Receiver treats it as the
primary of the new profile (their own index key).
* alt — the host's alternate address (the other half of the pair).
* shareLocal — mirrors the host's shareUsesLocalUrl preference so
onward shares from the receiver behave the same way.
Decode accepts both v1 and v2. v2-only fields are left undefined when
decoding a v1 payload (no '|| undefined' shim — kept absent so zustand
persist diffs stay clean). Defensive: an empty alt on a v2 invite
decodes as if the field were absent, never as ''.
Test updates: 'rejects a payload with the wrong version' now targets
v: 3 (v: 2 became valid). 7 new tests cover the v1/v2 wire-format
choice, both v2 round-trip shapes, the v1-decode-into-v2-fields-
undefined backward-compat case, and the empty-alt edge.
* feat(magic): wire magic-string v2 through invite-encode + paste consumers
Five surfaces now produce / accept v2 invites with the dual-address
fields end-to-end:
Encode side (admin → user invite):
* MagicStringModal — looks up the saved profile that matches the
serverUrl prop (primary OR alternateUrl normalize-match) and
encodes through serverShareBaseUrl + alternateUrl + the share
flag. Falls back to plain v1 for single-address profiles.
* UserForm — same lookup + encode plumbing on the in-form
'Save & get magic string' flow.
Decode side (paste invite into form):
* AddServerForm useEffect (initialInvite from outer state) +
handleMagicStringChange (live paste) both pull alternateUrl
and shareUsesLocalUrl off the decoded payload into form state,
so the second-address field + checkbox surface immediately on a
v2 paste.
* Login form state gains hidden alternateUrl + shareUsesLocalUrl
fields (not user-editable — Login stays single-address by
design); v2 paste / initial-invite both populate them so they
persist with the new profile via addServer. handleQuickConnect
likewise forwards the existing dual-address shape for
already-saved profiles.
* attemptConnect signature widened with the two optional fields;
addServer / updateServer call sites conditionally include them
only when alternateUrl is non-empty so single-address profiles
keep their lean persisted shape.
Both encode helpers share the same magicPayloadAddressFields lookup
(MagicStringModal + UserForm) — the duplication is acceptable for
two co-located small files but a single shared helper would be the
natural place to consolidate if a third encoder appears.
* fix(form): magic-string submit forwards decoded alternateUrl + share flag
AddServerForm.submit's magic-string branch was forwarding only
name/url/username/password from the decoded payload, silently dropping
alternateUrl + shareUsesLocalUrl. A v2 invite pasted into the
magic-string field and saved would land in the store as a single-
address profile even though handleMagicStringChange had already
populated the dual-address fields into form state for display.
Now the magic branch picks the dual-address fields off the decoded
payload (same conditional spread as the non-magic branch a few lines
down) so v2 invites round-trip end-to-end through the form.
* fix(verify): extractUserId drops username fallback to avoid false mismatches
Spec §7.2 footnote: 'fallback (normalized username) only if no id on
either side'. The old extract unconditionally fell back to the
authenticated username whenever user.id was empty — but the
comparator can't tell username-fallback apart from a server-supplied
id. So a server pair where endpoint A returns user.id='42' and
endpoint B only returns user.username='frank' would land in compare
with userId values '42' vs 'frank' and report mismatch on what is
actually the same user on the same server.
Now extractUserId returns null when user.id is absent. Both sides
returning null means compareFingerprints skips userId as a common
signal (correct behaviour per the §7.3 'both sides have a value'
rule). One new test pins it; the call site drops the redundant
fallbackUsername argument.
* fix(remap): rewriteFrontendStoreKeysForRemap migrates coverStrategyStore
Spec §8.2 lists 'analysis/cover strategy maps' as targets of the
URL-change remigration. The For-Remap path covered analysis but
missed cover — a user-set cover strategy override (e.g. 'aggressive'
for one server) would silently drop when the user edited that
profile's primary URL, because the key tagged under the old index
key never made it to the new one.
Same shape as the analysis remap inline: prefer-existing on
collision (newer key wins), delete the legacy entry afterwards.
One new test pins the move; setup adds a fresh useCoverStrategyStore
reset between cases.
* fix(cover): is_safe_index_key rejects absolute paths + drive letters
Defense-in-depth gap in cover_cache_rename_server_bucket. The old
sanitizer only rejected backslashes and '..' segments — '/etc/passwd'
on Unix and 'C:\Windows' on Windows both passed. Path::join with an
absolute argument REPLACES the base path, so root.join('/etc/passwd')
on Unix would walk out of cover-cache entirely.
Real index keys never reach this state — they come out of
serverIndexKeyFromUrl which strips schemes and trailing slashes, so
no leading separator and no drive-letter prefix is ever produced.
But the comment promises 'defense in depth' and that defense is
worth completing.
Now rejects:
* empty keys (would join to the cover-cache root itself)
* leading '/' or '\'
* Windows drive-letter prefix 'X:' (case-insensitive ASCII letter)
* backslashes anywhere (separators are forward-slash only)
* '..' segments after split('/')
One new Rust test covers all four cases.
* fix(connection): isLan badge reflects active endpoint, not primary url
useConnectionStatus.isLan was reading isLanUrl(server.url) — the
*primary* address — so a dual-address profile that had fallen over to
its public alternate kept advertising 'LAN' in the badge until the
user looked at the server URL itself. Spec call-site-checklist line
25: 'active endpoint kind for badge (LAN vs extern)'.
Now: ensureConnectUrlResolved returns probe.endpoint.kind on success;
the hook tracks the latest kind in local state and surfaces that.
Pre-first-probe fall-through keeps the old primary-URL classification
so the badge has something to render at mount time.
Three tests pin the three states (LAN-active after probe, public-
active after fallback, primary-fallback before first probe completes);
plus one for the online-event handler that invalidates the cache and
re-probes.
* fix(server): dedupe concurrent pickReachableBaseUrl calls for same profile
Multiple call sites probe on roughly the same beat — useConnectionStatus
120-s tick + online handler + initial mount, switchActiveServer,
bindIndexedServer, and ServersTab.testConnection. Two near-simultaneous
probes for the same profile both observed an empty cache, both pinged
every endpoint, and raced to write the sticky URL with last-write-wins.
On a dual-address profile the slower probe could stomp the correct
LAN sticky a millisecond after it was set.
Now: an in-flight Map<profileId, Promise<PickReachableResult>>; a
second call for the same id during an active probe returns the same
promise instead of starting a fresh one. The finally-clear keeps the
dedup window narrow (one settled probe → next call probes fresh).
invalidateReachableEndpointCache deliberately doesn't touch the
in-flight map — the racing probe's own finally will clean up.
Three tests pin it: two concurrent calls share one ping; a fresh
call after the previous settled does ping again; plus the existing
shareBaseUrl two-LAN-with-flag-on test + the two-public-default test
for completeness.
* refactor(magic): extract magicPayloadAddressFields to single shared helper
The lookup that turns a serverUrl into the v2-encode-ready
{ url, alternateUrl?, shareUsesLocalUrl? } shape was duplicated
byte-identically between MagicStringModal.tsx and UserForm.tsx —
both Navidrome admin user-mgmt encode sites. Workdocs §03 / §1a
'one source of truth for behavior'.
Now lives in src/utils/server/serverMagicString.ts as a named
export. Both call sites import + pass the current servers list
(via useAuthStore.getState().servers) — keeps the helper pure so
it's straight to unit-test if a third encoder ever appears.
* fix(cover): wire cover:bucket-renamed listener for URL-change remigration
cover_cache_rename_server_bucket emits cover:bucket-renamed on
successful rename / merge, but no frontend listener was wiring it in.
After a URL-change remigration the in-memory disk-src cache still
held entries pointing at `{root}/{oldKey}/…/.webp` paths the disk
no longer carries — the next read would serve a stale URL until
the entry naturally aged out.
New: forgetDiskSrcForServer(serverIndexKey) in diskSrcCache drops
every cover entry under one server key in one pass (the existing
forgetDiskSrcPrefix needs a non-empty coverArtId and can't blanket-
clear a server). useCoverArtBridge subscribes to cover:bucket-
renamed and calls it with oldKey.
Tests: forgetDiskSrcForServer happy path, empty-key defensive
no-op, no-match no-op; plus a regression-pin on the existing
forgetDiskSrcPrefix so the two helpers don't drift in semantics.
* test(dual-server): fill the high-impact coverage gaps from the branch review
Adds the tests the review identified as missing on the hot-path UI
flows + the partial-fail / alt-only edges:
* AddServerForm.test.tsx (new) — five component tests:
single-address save / dual-address save / two-LAN block-with-
toast / v2 magic-string paste forwarding alt + share flag /
edit-flow stripping alt+flag when the field is emptied.
* Login.test.tsx (new) — v2 invite paste persists alternateUrl +
shareUsesLocalUrl onto the saved profile; v1 invite leaves
those fields undefined.
* shareLink.test.ts — alternateUrl-only match for
findServerIdForShareUrl (the dual-address paste case where the
host shared the LAN URL via shareUsesLocalUrl=true); 'first hit
wins' across two profiles where both match.
* serverFingerprint.test.ts — partial-fail mix (folders+user ok,
license+indexes rejected) so a Promise.allSettled regression
wouldn't go silent.
* cover_cache/mod.rs — rename_bucket_inner extracted as a
testable FS-only helper (the Tauri command wrapper now just
locks state + calls it + emits the event), plus six tests
covering empty/unsafe keys, no-op-when-missing, no-op-when-
equal, simple-rename, merge-with-prefer-existing.
Also: every test fixture that I authored on this branch using
'frank' / 'frank@example.com' as a stand-in username/email
swapped to 'tester' / 'tester@example.com' — keeping personal
names out of test data is the codebase convention (see
factories.ts) and is now a memory rule.
* fix(test): typed mock access for pingWithCredentials in useConnectionStatus
vi.mocked(...) returns the mock typed properly — bare .mock fails
tsc because the imported function signature isn't a vi.Mock at
import time.
* docs(changelog): dual server address (PR #880)
* fix(test): align diskSrcCache tests with main cover storage key API
After rebase onto main, forgetDiskSrcPrefix takes a CoverArtRef-shaped
argument and storage keys include cacheKind; merge coverDiskUrl tests
from main with dual-address forgetDiskSrcForServer coverage.
|
||
|
|
ee5068c98c |
feat(artist): sort albums by year on artist detail (#877)
* feat(artist): year sort for albums section on artist detail Add a sort dropdown next to "Albums by …" with release-type grouping (default), newest-first, and oldest-first by album year. * chore: note PR #877 in CHANGELOG and settings credits * fix(artist): toggle year sort inside release groups, session per server Replace dropdown with a click-to-toggle newest/oldest button. Keep release-type blocks; sort albums by year within each group. Persist order in session store. |
||
|
|
a8cfff0b62 |
feat(library): local lossless index, filters, and conserve dedicated page (#871)
* feat(library): local lossless index, filters, and conserve dedicated page Add SQLite-backed lossless album browse and advanced-search filtering, wire All Albums and artist/album lossless drill-down mode, and hide the standalone /lossless-albums nav entry from sidebar visibility settings (conserved route, default off). * docs(release): note lossless local index in CHANGELOG and credits (PR #871) |
||
|
|
bc85065316 |
fix(analysis): persist failed tracks and reconcile progress counts (#867)
* fix(analysis): persist failed-track suppression and reduce aggressive polling Persist unsupported/broken analysis tracks as failed entries and expose them in Settings with track metadata, export, and targeted rescan actions. Also make aggressive-mode completion checks cheap by gating re-entry on live track-count changes with a startup seed and 5-minute recheck cadence. * fix(analysis): mark unsupported decode tracks as failed in cpu-seed When full-seed falls back to waveform-only (no EBU loudness) or enrichment decode fails, persist analysis_track status as failed so aggressive backfill does not requeue the same unsupported tracks indefinitely. * fix(analysis): reconcile legacy ready tracks without loudness Auto-mark legacy ready tracks that only miss loudness as failed during needs-work checks so analysis progress converges instead of staying permanently pending. * docs(changelog): add failed-analysis recovery notes for PR 867 Document persistent failed-track handling, analytics strategy controls, and low-cost aggressive-mode recheck behavior in the 1.47.0 changelog. * fix(i18n): align settings locale coverage across all languages Sync missing Settings keys for all shipped locales and replace recent English fallbacks with localized strings so analytics and backup UI text stays consistent outside en/ru. |