From 6a1c91ded7ada19ae6a287fcb92d3e3b6b114d40 Mon Sep 17 00:00:00 2001 From: Maxim Isaev Date: Tue, 2 Jun 2026 14:21:03 +0300 Subject: [PATCH 1/4] fix(cover): follow connect-URL flips in library cover backfill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native cover backfill was configured once with a snapshot of the runtime connect URL. When a laptop moved off the LAN, the smart endpoint switch flipped the sticky connect URL to the public address (playback/UI covers rebuild it per request and followed it), but backfill kept fetching from the now-unreachable local address — flooding the log with "error sending request" failures. Make the connect cache observable (notify on effective flips) and have the backfill hook reconfigure when the resolved URL changes, forcing a pass so the .fetch-failed backoff from the stale address is cleared and those covers retry on the reachable endpoint. --- src/hooks/useLibraryCoverBackfill.ts | 23 +++++++--- src/utils/server/serverEndpoint.test.ts | 61 +++++++++++++++++++++++++ src/utils/server/serverEndpoint.ts | 38 +++++++++++++-- 3 files changed, 113 insertions(+), 9 deletions(-) diff --git a/src/hooks/useLibraryCoverBackfill.ts b/src/hooks/useLibraryCoverBackfill.ts index 9765b17f..eb21cd22 100644 --- a/src/hooks/useLibraryCoverBackfill.ts +++ b/src/hooks/useLibraryCoverBackfill.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useSyncExternalStore } from 'react'; import { coverCacheRestHost, libraryCoverBackfillConfigure, @@ -11,6 +11,7 @@ import { useAuthStore } from '../store/authStore'; import { useCoverStrategyStore } from '../store/coverStrategyStore'; import { subscribeLibraryCoverBackfillWake } from '../utils/library/coverBackfillWake'; import { serverIndexKeyForProfile } from '../utils/server/serverIndexKey'; +import { subscribeConnectCache } from '../utils/server/serverEndpoint'; /** * Library cover warm-up — configure session in Rust; full pass runs natively. @@ -26,7 +27,15 @@ export function useLibraryCoverBackfill(enabled = true): void { const server = useAuthStore(s => s.activeServerId ? s.servers.find(srv => srv.id === s.activeServerId) : undefined, ); - const getBaseUrl = useAuthStore(s => s.getBaseUrl); + // Re-read the runtime-probed connect URL whenever the sticky endpoint flips + // (e.g. laptop moves off the LAN). Backfill is configured natively with a + // fixed `rest_base_url`, so without this it would keep fetching covers from + // the now-unreachable local address while playback already switched to public. + const connectBaseUrl = useSyncExternalStore( + subscribeConnectCache, + () => useAuthStore.getState().getBaseUrl(), + () => useAuthStore.getState().getBaseUrl(), + ); useEffect(() => { const kick = () => { @@ -59,20 +68,22 @@ export function useLibraryCoverBackfill(enabled = true): void { } const indexKey = serverIndexKeyForProfile(server); - const baseUrl = getBaseUrl(); void (async () => { await libraryCoverBackfillConfigure({ enabled: true, serverIndexKey: indexKey, libraryServerId: librarySqlServerId(activeServerId), - restBaseUrl: baseUrl ? coverCacheRestHost(baseUrl) : '', + restBaseUrl: connectBaseUrl ? coverCacheRestHost(connectBaseUrl) : '', username: server.username, password: server.password, }); await libraryCoverBackfillResetCursor(); - await libraryCoverBackfillRunFullPass(); + // Force: a (re)configure — including a connect-URL flip — must clear the + // `.fetch-failed` backoff so covers that 404'd / timed out against the + // previous (now-stale) address are retried immediately on the new one. + await libraryCoverBackfillRunFullPass(true); })(); return disable; - }, [enabled, strategy, activeServerId, server?.url, server?.username, server?.password, getBaseUrl]); + }, [enabled, strategy, activeServerId, server?.url, server?.username, server?.password, connectBaseUrl]); } diff --git a/src/utils/server/serverEndpoint.test.ts b/src/utils/server/serverEndpoint.test.ts index 6bdc7385..d0d4d2de 100644 --- a/src/utils/server/serverEndpoint.test.ts +++ b/src/utils/server/serverEndpoint.test.ts @@ -15,6 +15,7 @@ import { pickReachableBaseUrl, serverAddressEndpoints, serverShareBaseUrl, + subscribeConnectCache, } from './serverEndpoint'; import type { ServerProfile } from '../../store/authStoreTypes'; @@ -377,6 +378,66 @@ describe('invalidateReachableEndpointCache', () => { }); }); +describe('subscribeConnectCache — connect-URL flip notifications', () => { + beforeEach(() => { + invalidateReachableEndpointCache(); + vi.mocked(pingWithCredentials).mockReset(); + }); + + it('notifies when a probe resolves a new endpoint and on a later flip', async () => { + const listener = vi.fn(); + const unsubscribe = subscribeConnectCache(listener); + const profile = makeProfile({ + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10', + }); + + // First probe: LAN answers → cache set → one notification. + vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); + await pickReachableBaseUrl(profile); + expect(listener).toHaveBeenCalledTimes(1); + + // LAN drops, public answers → cached URL flips → another notification. + vi.mocked(pingWithCredentials) + .mockResolvedValueOnce(pingFail()) + .mockResolvedValueOnce(pingOk()); + await pickReachableBaseUrl(profile); + expect(listener).toHaveBeenCalledTimes(2); + + unsubscribe(); + }); + + it('does not notify when the sticky endpoint is unchanged', async () => { + const profile = makeProfile({ url: 'http://192.168.0.10' }); + vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); + await pickReachableBaseUrl(profile); + + const listener = vi.fn(); + const unsubscribe = subscribeConnectCache(listener); + // Re-probe, same endpoint answers → cache value identical → no notification. + vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); + await pickReachableBaseUrl(profile); + expect(listener).not.toHaveBeenCalled(); + + unsubscribe(); + }); + + it('notifies on explicit cache invalidation when an entry existed', async () => { + vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); + await pickReachableBaseUrl(makeProfile({ id: 'a' })); + + const listener = vi.fn(); + const unsubscribe = subscribeConnectCache(listener); + invalidateReachableEndpointCache('a'); + expect(listener).toHaveBeenCalledTimes(1); + // No-op invalidation (nothing cached) must stay silent. + invalidateReachableEndpointCache('a'); + expect(listener).toHaveBeenCalledTimes(1); + + unsubscribe(); + }); +}); + describe('serverShareBaseUrl', () => { it('returns the single address for a single-URL profile', () => { expect(serverShareBaseUrl({ url: 'https://music.example.com' })).toBe( diff --git a/src/utils/server/serverEndpoint.ts b/src/utils/server/serverEndpoint.ts index bc541474..974da51c 100644 --- a/src/utils/server/serverEndpoint.ts +++ b/src/utils/server/serverEndpoint.ts @@ -165,6 +165,33 @@ export function serverShareBaseUrl( const connectCache = new Map(); +// ── Connect-cache change notifications ─────────────────────────────────────── +// The sticky connect URL flips silently (120-s probe tick / online event / +// switch). Long-lived consumers that snapshot the URL once — notably the native +// **library cover backfill**, which is configured with a fixed `rest_base_url` +// — need to react when a laptop moves off the LAN, or they keep hammering the +// now-unreachable local address. UI/playback rebuild the URL per request and +// don't need this. Listeners are notified only when a profile's cached URL +// actually changes value (set to a different endpoint, dropped, or cleared). +const connectCacheListeners = new Set<() => void>(); +let connectCacheVersion = 0; + +function notifyConnectCacheChanged(): void { + connectCacheVersion += 1; + connectCacheListeners.forEach(cb => cb()); +} + +/** Subscribe to connect-URL flips (any profile). Returns an unsubscribe fn. */ +export function subscribeConnectCache(cb: () => void): () => void { + connectCacheListeners.add(cb); + return () => connectCacheListeners.delete(cb); +} + +/** Monotonic version, bumped on every effective connect-cache change. */ +export function getConnectCacheVersion(): number { + return connectCacheVersion; +} + /** * In-flight probes keyed by `profile.id`. Three call sites (useConnectionStatus * 120-s tick, switchActiveServer, bindIndexedServer, plus retry / online @@ -208,14 +235,17 @@ export function connectBaseUrlForServer( */ export function invalidateReachableEndpointCache(profileId?: string): void { if (profileId === undefined) { - connectCache.clear(); // Don't clear in-flight slots — they're already racing against the // network, letting their own `finally` clean up keeps the dedup // invariant. Their results will still write to the (now empty) cache, // which is the right behaviour: the freshest probe wins. + if (connectCache.size > 0) { + connectCache.clear(); + notifyConnectCacheChanged(); + } return; } - connectCache.delete(profileId); + if (connectCache.delete(profileId)) notifyConnectCacheChanged(); } /** @@ -250,14 +280,16 @@ export async function pickReachableBaseUrl( for (const endpoint of endpoints) { const ping = await pingWithCredentials(endpoint.url, profile.username, profile.password); if (ping.ok) { + const prev = connectCache.get(profile.id); connectCache.set(profile.id, endpoint.url); + if (prev !== endpoint.url) notifyConnectCacheChanged(); return { ok: true, baseUrl: endpoint.url, endpoint, ping }; } } // Every endpoint failed — drop any stale cache entry so the next probe // starts from the natural LAN-first order. - connectCache.delete(profile.id); + if (connectCache.delete(profile.id)) notifyConnectCacheChanged(); return { ok: false, reason: 'unreachable' }; })(); From 5cf77808b0d1988e871f3cef93f195b9c6087db5 Mon Sep 17 00:00:00 2001 From: Maxim Isaev Date: Tue, 2 Jun 2026 14:22:25 +0300 Subject: [PATCH 2/4] docs(changelog): note cover backfill endpoint-switch fix (PR #952) --- CHANGELOG.md | 7 +++++++ src/config/settingsCredits.ts | 1 + 2 files changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e665711f..4ef54966 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -696,6 +696,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * It now treats a release as multi-disc only when each disc has a single consistent cover that differs across discs (a genuine box set); per-song ids collapse to one cover per album (≈ albums + artists). Fixed on both the Rust backfill path and the on-demand TS `albumHasDistinctDiscCovers`. * Failed cover downloads are now logged with the album/artist name and the server error (e.g. `fetch failed for album "X" — Artist (coverArtId=…): cover HTTP 503`). Backfill failures log at the normal level; incidental on-demand misses stay at the debug level. +### Cover backfill — follow the local/public endpoint switch + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#952](https://github.com/Psychotoxical/psysonic/pull/952)** + +* On a dual-address server, library cover backfill was configured once with a snapshot of the connect URL and never followed the smart LAN↔public switch. Moving a laptop off the LAN (internet still up, playback already on the public address) left backfill hammering the now-unreachable local address and flooding the log with `error sending request` failures. +* The connect cache is now observable and backfill reconfigures when the resolved URL flips, forcing a pass so covers that failed against the stale address retry on the reachable one. On-demand UI / playback covers already followed the switch. + ## [1.46.0] - 2026-05-18 > **🙏 Special thanks to [@zz5zz](https://github.com/zz5zz)** for his tireless quirk-spotting and bug reports on the [Psysonic Discord](https://discord.gg/AMnDRErm4u) — several of the polish fixes in this release landed directly off the back of his messages. diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 3ca718d7..73735beb 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -150,6 +150,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Performance Probe: live runtime logs tab with depth switch, line cap, and ordered include/exclude word filter (PR #946)', 'Performance Probe: on-demand (ui) cover throughput alongside backfill (lib) cpm (PR #947)', 'Performance Probe: throughput (analysis tpm, cover cpm) measured over a trailing 5s window so the rate reacts promptly instead of coasting on minute-long inertia (PR #948)', + 'Cover backfill: follow the smart local/public endpoint switch so off-LAN clients stop fetching covers from the unreachable local address (PR #952)', ], }, { From 27a06369ff319bd4eb015bf4db9afe69ee168bc5 Mon Sep 17 00:00:00 2001 From: Maxim Isaev Date: Tue, 2 Jun 2026 14:28:48 +0300 Subject: [PATCH 3/4] fix(cover): abort stale backfill pass + rerun on connect-URL flip Frontend reconfigure alone didn't fully cover the boot case: at startup the first backfill pass starts on the primary (LAN) URL before the reachability probe resolves, so when the probe flips to public the forced rerun was dropped by the pass_running guard and the slow LAN pass (every cover timing out) ran to completion with nothing re-running on the reachable address. set_session now bumps a session generation; the running pass checks it on every focus gate and abandons promptly when the URL flips (same server_index_key, new rest_base_url). try_schedule_full_pass records a rerun when a pass is in flight and drains it once the abandoned pass returns, so a fresh forced pass runs on the new address. --- CHANGELOG.md | 4 +- src-tauri/src/cover_cache/backfill_worker.rs | 82 ++++++++++++++++++-- 2 files changed, 77 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ef54966..26ac862d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -700,8 +700,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 **By [@cucadmuh](https://github.com/cucadmuh), PR [#952](https://github.com/Psychotoxical/psysonic/pull/952)** -* On a dual-address server, library cover backfill was configured once with a snapshot of the connect URL and never followed the smart LAN↔public switch. Moving a laptop off the LAN (internet still up, playback already on the public address) left backfill hammering the now-unreachable local address and flooding the log with `error sending request` failures. -* The connect cache is now observable and backfill reconfigures when the resolved URL flips, forcing a pass so covers that failed against the stale address retry on the reachable one. On-demand UI / playback covers already followed the switch. +* On a dual-address server, library cover backfill was configured once with a snapshot of the connect URL and never followed the smart LAN↔public switch. Starting already off the LAN — or moving off it mid-session — (internet up, playback already on the public address) left backfill hammering the now-unreachable local address and flooding the log with `error sending request` failures. +* The connect cache is now observable and backfill reconfigures when the resolved URL flips, forcing a pass so covers that failed against the stale address retry on the reachable one. A reconfigure now also bumps a session generation so any in-flight pass against the old address abandons promptly, and a rerun is queued if a pass was still running — covering the boot case where the initial pass starts on the primary URL before the first reachability probe resolves. On-demand UI / playback covers already followed the switch. ## [1.46.0] - 2026-05-18 diff --git a/src-tauri/src/cover_cache/backfill_worker.rs b/src-tauri/src/cover_cache/backfill_worker.rs index 74488573..78679124 100644 --- a/src-tauri/src/cover_cache/backfill_worker.rs +++ b/src-tauri/src/cover_cache/backfill_worker.rs @@ -86,6 +86,17 @@ pub struct CoverBackfillWorker { /// Epoch-ms of the last `sync-idle`-driven pass, to rate-limit the idle-gate /// disk walk against chatty syncs. 0 = never. last_sync_idle_ms: AtomicU64, + /// Bumped on every `set_session`. A running pass captures the value at start + /// and aborts the moment it changes, so a connect-URL flip (same server, new + /// `rest_base_url`) abandons the stale-address pass instead of grinding + /// through every cover against the unreachable endpoint. + session_gen: AtomicU64, + /// A schedule request that arrived while a pass was already running. Drained + /// when that pass finishes so a mid-pass reconfigure (e.g. LAN→public at + /// boot) still gets a fresh pass on the new address. + rerun_pending: AtomicBool, + /// Whether the queued rerun should force (bypass idle gate + clear backoff). + rerun_force: AtomicBool, } #[derive(Debug, Clone, Serialize)] @@ -124,6 +135,9 @@ impl CoverBackfillWorker { parallel: AtomicUsize::new(LIBRARY_BACKFILL_PARALLEL_DEFAULT), settled: Mutex::new(None), last_sync_idle_ms: AtomicU64::new(0), + session_gen: AtomicU64::new(0), + rerun_pending: AtomicBool::new(false), + rerun_force: AtomicBool::new(false), } } @@ -174,11 +188,19 @@ impl CoverBackfillWorker { // Server switch or enable/disable invalidates any settled state: re-arm // so the next idle event runs a real pass for the new focus. *self.settled.lock().await = None; + // Invalidate any in-flight pass: a connect-URL flip keeps the same + // `server_index_key`, so without this the running pass would keep using + // the old (now-unreachable) `rest_base_url` to completion. + self.session_gen.fetch_add(1, Ordering::SeqCst); if !enabled { *self.cursor.lock().await = String::new(); } } + fn session_generation(&self) -> u64 { + self.session_gen.load(Ordering::SeqCst) + } + pub async fn reset_cursor(&self) { *self.cursor.lock().await = String::new(); } @@ -204,11 +226,21 @@ fn session_matches_server(session: &CoverBackfillSession, server_id: &str) -> bo server_id == session.server_index_key || server_id == session.library_server_id } -/// Backfill runs only while this session is still the configured focus (active server). -async fn session_still_focused(worker: &CoverBackfillWorker, expected: &CoverBackfillSession) -> bool { +/// Backfill runs only while this session is still the configured focus (active +/// server) **and** the session generation captured at pass start is unchanged — +/// a reconfigure (server switch, enable/disable, or connect-URL flip) bumps the +/// generation so the running pass abandons promptly. +async fn session_still_focused( + worker: &CoverBackfillWorker, + expected: &CoverBackfillSession, + pass_gen: u64, +) -> bool { if !worker.enabled.load(Ordering::Relaxed) { return false; } + if worker.session_generation() != pass_gen { + return false; + } worker .session .lock() @@ -284,6 +316,10 @@ async fn run_full_pass(app: AppHandle, worker: Arc, force: let Some(session) = session else { return; }; + // Generation captured for this pass — if `set_session` bumps it (server + // switch / enable toggle / connect-URL flip), every focus check below trips + // and the pass abandons instead of fetching against a stale address. + let pass_gen = worker.session_generation(); let runtime = match app.try_state::() { Some(r) => r, @@ -375,7 +411,7 @@ async fn run_full_pass(app: AppHandle, worker: Arc, force: // Bail the moment the strategy flips to lazy / focus changes, so a // switch to "lazy" abandons the buffered backlog instead of // draining the whole channel (mirrors the producer's check). - if !session_still_focused(&worker_arc, &session).await { + if !session_still_focused(&worker_arc, &session, pass_gen).await { break; } let item = { @@ -423,7 +459,7 @@ async fn run_full_pass(app: AppHandle, worker: Arc, force: let mut rows_iter = raw_rows.into_iter(); let mut completed = false; loop { - if !session_still_focused(&worker, &session).await { + if !session_still_focused(&worker, &session, pass_gen).await { break; } if worker.ui_priority_hold.load(Ordering::Relaxed) { @@ -466,7 +502,7 @@ async fn run_full_pass(app: AppHandle, worker: Arc, force: break 'feed; } Err(tokio::sync::mpsc::error::TrySendError::Full(returned)) => { - if !session_still_focused(&worker, &session).await { + if !session_still_focused(&worker, &session, pass_gen).await { feed_closed = true; break 'feed; } @@ -531,13 +567,45 @@ pub async fn try_schedule_full_pass(app: &AppHandle, force: bool) -> bool { .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) .is_err() { + // A pass is already running. Record a rerun so a reconfigure that landed + // mid-pass (e.g. the connect URL flipped LAN→public at boot) still gets a + // fresh pass on the new address once the current one abandons. + worker.rerun_pending.store(true, Ordering::SeqCst); + if force { + worker.rerun_force.store(true, Ordering::SeqCst); + } return false; } let app = app.clone(); tauri::async_runtime::spawn(async move { - run_full_pass(app, worker.clone(), force).await; - worker.pass_running.store(false, Ordering::SeqCst); + run_full_pass(app.clone(), worker.clone(), force).await; + // Drain rerun requests queued while this (or a chained) pass ran. The + // generation check inside `run_full_pass` makes the abandoned pass return + // quickly, so this loop converges on a pass against the latest config. + loop { + worker.pass_running.store(false, Ordering::SeqCst); + if !worker.rerun_pending.swap(false, Ordering::SeqCst) { + break; + } + let rerun_force = worker.rerun_force.swap(false, Ordering::SeqCst); + if !worker.enabled.load(Ordering::Relaxed) { + break; + } + if worker + .pass_running + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_err() + { + // Another scheduler won the slot; hand the rerun intent back to it. + worker.rerun_pending.store(true, Ordering::SeqCst); + if rerun_force { + worker.rerun_force.store(true, Ordering::SeqCst); + } + break; + } + run_full_pass(app.clone(), worker.clone(), rerun_force).await; + } }); true } From 9fb6e255a65d12359f8f99707956741ab6958058 Mon Sep 17 00:00:00 2001 From: Maxim Isaev Date: Tue, 2 Jun 2026 15:56:59 +0300 Subject: [PATCH 4/4] fix(cover-backfill): resolve connect URL per fetch instead of baking it into the queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backfill worklist no longer carries a URL. Each cover fetch reads the current reachable address live from a single worker cell, so a LAN↔public flip is honoured even by the pass already in flight — its remaining covers download against the new endpoint without aborting/rebuilding the worklist. - Drop rest_base_url from CoverBackfillSession; add live base_url cell read in ensure_one. Remove the session-generation abort machinery (no longer needed). - New lightweight library_cover_backfill_set_base_url command pushes the URL on every connect-cache flip; a real change clears the stale .fetch-failed backoff and runs a forced pass so covers that timed out on the old address retry. - Split useLibraryCoverBackfill into a configure effect (server/creds/strategy) and a flip effect that only pushes the URL. - Keep a single rerun_pending flag for the boot case (flip mid-pass), since the finished pass re-arms the idle gate. --- CHANGELOG.md | 2 +- src-tauri/src/cover_cache/backfill_worker.rs | 108 +++++++++---------- src-tauri/src/cover_cache/mod.rs | 29 ++++- src-tauri/src/lib.rs | 1 + src/api/coverCache.ts | 10 ++ src/hooks/useLibraryCoverBackfill.ts | 39 +++++-- 6 files changed, 119 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26ac862d..43432bae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -701,7 +701,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 **By [@cucadmuh](https://github.com/cucadmuh), PR [#952](https://github.com/Psychotoxical/psysonic/pull/952)** * On a dual-address server, library cover backfill was configured once with a snapshot of the connect URL and never followed the smart LAN↔public switch. Starting already off the LAN — or moving off it mid-session — (internet up, playback already on the public address) left backfill hammering the now-unreachable local address and flooding the log with `error sending request` failures. -* The connect cache is now observable and backfill reconfigures when the resolved URL flips, forcing a pass so covers that failed against the stale address retry on the reachable one. A reconfigure now also bumps a session generation so any in-flight pass against the old address abandons promptly, and a rerun is queued if a pass was still running — covering the boot case where the initial pass starts on the primary URL before the first reachability probe resolves. On-demand UI / playback covers already followed the switch. +* The backfill worklist no longer carries a URL: each cover fetch now reads the current reachable address live, so a LAN↔public flip is honoured even by the pass already in flight (its remaining covers download against the new endpoint). The connect cache is observable and pushes the resolved URL to the native worker on every flip; a real change clears the stale `.fetch-failed` backoff and runs a forced pass so the handful of covers attempted against the old address retry on the reachable one. This also covers the boot case where the initial pass starts on the primary URL before the first reachability probe resolves. On-demand UI / playback covers already followed the switch. ## [1.46.0] - 2026-05-18 diff --git a/src-tauri/src/cover_cache/backfill_worker.rs b/src-tauri/src/cover_cache/backfill_worker.rs index 78679124..b3d1cb4e 100644 --- a/src-tauri/src/cover_cache/backfill_worker.rs +++ b/src-tauri/src/cover_cache/backfill_worker.rs @@ -51,7 +51,6 @@ fn now_ms() -> u64 { pub struct CoverBackfillSession { pub server_index_key: String, pub library_server_id: String, - pub rest_base_url: String, pub username: String, pub password: String, } @@ -86,17 +85,16 @@ pub struct CoverBackfillWorker { /// Epoch-ms of the last `sync-idle`-driven pass, to rate-limit the idle-gate /// disk walk against chatty syncs. 0 = never. last_sync_idle_ms: AtomicU64, - /// Bumped on every `set_session`. A running pass captures the value at start - /// and aborts the moment it changes, so a connect-URL flip (same server, new - /// `rest_base_url`) abandons the stale-address pass instead of grinding - /// through every cover against the unreachable endpoint. - session_gen: AtomicU64, - /// A schedule request that arrived while a pass was already running. Drained - /// when that pass finishes so a mid-pass reconfigure (e.g. LAN→public at - /// boot) still gets a fresh pass on the new address. + /// Live connect URL, resolved fresh per cover fetch rather than baked into + /// the worklist. The worklist holds URL-agnostic items; a LAN→public flip + /// just swaps this cell, so even the pass already in flight downloads its + /// remaining covers against the now-reachable endpoint. + base_url: std::sync::Mutex, + /// A forced retry requested while a pass was already running (e.g. the + /// connect URL flipped LAN→public at boot). The in-flight pass already + /// adopts the new URL live, but the handful of covers it attempted against + /// the stale address need one more forced pass once it finishes. rerun_pending: AtomicBool, - /// Whether the queued rerun should force (bypass idle gate + clear backoff). - rerun_force: AtomicBool, } #[derive(Debug, Clone, Serialize)] @@ -135,9 +133,8 @@ impl CoverBackfillWorker { parallel: AtomicUsize::new(LIBRARY_BACKFILL_PARALLEL_DEFAULT), settled: Mutex::new(None), last_sync_idle_ms: AtomicU64::new(0), - session_gen: AtomicU64::new(0), + base_url: std::sync::Mutex::new(String::new()), rerun_pending: AtomicBool::new(false), - rerun_force: AtomicBool::new(false), } } @@ -182,23 +179,38 @@ impl CoverBackfillWorker { next } - pub async fn set_session(&self, enabled: bool, session: Option) { + pub async fn set_session( + &self, + enabled: bool, + session: Option, + base_url: String, + ) { self.enabled.store(enabled, Ordering::Relaxed); *self.session.lock().await = session; + *self.base_url.lock().unwrap() = base_url; // Server switch or enable/disable invalidates any settled state: re-arm // so the next idle event runs a real pass for the new focus. *self.settled.lock().await = None; - // Invalidate any in-flight pass: a connect-URL flip keeps the same - // `server_index_key`, so without this the running pass would keep using - // the old (now-unreachable) `rest_base_url` to completion. - self.session_gen.fetch_add(1, Ordering::SeqCst); if !enabled { *self.cursor.lock().await = String::new(); } } - fn session_generation(&self) -> u64 { - self.session_gen.load(Ordering::SeqCst) + /// Current connect URL for backfill fetches. Read fresh per cover so a + /// LAN→public flip is honoured mid-pass without rebuilding the worklist. + pub fn base_url(&self) -> String { + self.base_url.lock().unwrap().clone() + } + + /// Swap the live connect URL. Returns `true` when it actually changed, so the + /// caller can clear the now-stale fetch-failed backoff and kick a retry pass. + pub fn set_base_url(&self, url: String) -> bool { + let mut cell = self.base_url.lock().unwrap(); + if *cell == url { + return false; + } + *cell = url; + true } pub async fn reset_cursor(&self) { @@ -227,20 +239,13 @@ fn session_matches_server(session: &CoverBackfillSession, server_id: &str) -> bo } /// Backfill runs only while this session is still the configured focus (active -/// server) **and** the session generation captured at pass start is unchanged — -/// a reconfigure (server switch, enable/disable, or connect-URL flip) bumps the -/// generation so the running pass abandons promptly. -async fn session_still_focused( - worker: &CoverBackfillWorker, - expected: &CoverBackfillSession, - pass_gen: u64, -) -> bool { +/// server). A connect-URL flip keeps the same `server_index_key` and is picked +/// up live via `worker.base_url()`, so it does not abort the pass — only a +/// server switch or disable does. +async fn session_still_focused(worker: &CoverBackfillWorker, expected: &CoverBackfillSession) -> bool { if !worker.enabled.load(Ordering::Relaxed) { return false; } - if worker.session_generation() != pass_gen { - return false; - } worker .session .lock() @@ -299,7 +304,7 @@ async fn ensure_one( cache_entity_id: item.cache_entity_id, cover_art_id: item.fetch_cover_art_id, tier: LIBRARY_COVER_CANONICAL_TIER, - rest_base_url: session.rest_base_url, + rest_base_url: worker.base_url(), username: session.username, password: session.password, library_bulk: true, @@ -316,10 +321,6 @@ async fn run_full_pass(app: AppHandle, worker: Arc, force: let Some(session) = session else { return; }; - // Generation captured for this pass — if `set_session` bumps it (server - // switch / enable toggle / connect-URL flip), every focus check below trips - // and the pass abandons instead of fetching against a stale address. - let pass_gen = worker.session_generation(); let runtime = match app.try_state::() { Some(r) => r, @@ -411,7 +412,7 @@ async fn run_full_pass(app: AppHandle, worker: Arc, force: // Bail the moment the strategy flips to lazy / focus changes, so a // switch to "lazy" abandons the buffered backlog instead of // draining the whole channel (mirrors the producer's check). - if !session_still_focused(&worker_arc, &session, pass_gen).await { + if !session_still_focused(&worker_arc, &session).await { break; } let item = { @@ -459,7 +460,7 @@ async fn run_full_pass(app: AppHandle, worker: Arc, force: let mut rows_iter = raw_rows.into_iter(); let mut completed = false; loop { - if !session_still_focused(&worker, &session, pass_gen).await { + if !session_still_focused(&worker, &session).await { break; } if worker.ui_priority_hold.load(Ordering::Relaxed) { @@ -502,7 +503,7 @@ async fn run_full_pass(app: AppHandle, worker: Arc, force: break 'feed; } Err(tokio::sync::mpsc::error::TrySendError::Full(returned)) => { - if !session_still_focused(&worker, &session, pass_gen).await { + if !session_still_focused(&worker, &session).await { feed_closed = true; break 'feed; } @@ -567,12 +568,12 @@ pub async fn try_schedule_full_pass(app: &AppHandle, force: bool) -> bool { .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) .is_err() { - // A pass is already running. Record a rerun so a reconfigure that landed - // mid-pass (e.g. the connect URL flipped LAN→public at boot) still gets a - // fresh pass on the new address once the current one abandons. - worker.rerun_pending.store(true, Ordering::SeqCst); + // A pass is already running. It reads the connect URL live per cover, so + // any flip that landed mid-pass already applies to its remaining work. + // A forced retry (URL flip) still queues a rerun so the few covers the + // in-flight pass attempted against the stale address get re-fetched. if force { - worker.rerun_force.store(true, Ordering::SeqCst); + worker.rerun_pending.store(true, Ordering::SeqCst); } return false; } @@ -580,16 +581,13 @@ pub async fn try_schedule_full_pass(app: &AppHandle, force: bool) -> bool { let app = app.clone(); tauri::async_runtime::spawn(async move { run_full_pass(app.clone(), worker.clone(), force).await; - // Drain rerun requests queued while this (or a chained) pass ran. The - // generation check inside `run_full_pass` makes the abandoned pass return - // quickly, so this loop converges on a pass against the latest config. + // Drain a forced rerun queued mid-pass (always forced: it bypasses the + // idle gate the just-finished pass re-armed and clears the stale backoff). loop { worker.pass_running.store(false, Ordering::SeqCst); - if !worker.rerun_pending.swap(false, Ordering::SeqCst) { - break; - } - let rerun_force = worker.rerun_force.swap(false, Ordering::SeqCst); - if !worker.enabled.load(Ordering::Relaxed) { + if !worker.rerun_pending.swap(false, Ordering::SeqCst) + || !worker.enabled.load(Ordering::Relaxed) + { break; } if worker @@ -597,14 +595,10 @@ pub async fn try_schedule_full_pass(app: &AppHandle, force: bool) -> bool { .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) .is_err() { - // Another scheduler won the slot; hand the rerun intent back to it. worker.rerun_pending.store(true, Ordering::SeqCst); - if rerun_force { - worker.rerun_force.store(true, Ordering::SeqCst); - } break; } - run_full_pass(app.clone(), worker.clone(), rerun_force).await; + run_full_pass(app.clone(), worker.clone(), true).await; } }); true diff --git a/src-tauri/src/cover_cache/mod.rs b/src-tauri/src/cover_cache/mod.rs index 4678a118..7284cfd2 100644 --- a/src-tauri/src/cover_cache/mod.rs +++ b/src-tauri/src/cover_cache/mod.rs @@ -720,7 +720,6 @@ pub async fn library_cover_backfill_configure( Some(CoverBackfillSession { server_index_key, library_server_id, - rest_base_url, username, password, }) @@ -728,7 +727,7 @@ pub async fn library_cover_backfill_configure( None }; worker - .set_session(enabled && session.is_some(), session) + .set_session(enabled && session.is_some(), session, rest_base_url) .await; if enabled { let _ = try_schedule_full_pass(&app, false).await; @@ -736,6 +735,32 @@ pub async fn library_cover_backfill_configure( Ok(()) } +/// Push the current reachable connect URL without rebuilding the backfill +/// session. The worklist holds URL-agnostic items and each fetch reads this +/// value live, so a LAN→public flip is honoured by the in-flight pass too. +/// When the URL actually changes, the stale `.fetch-failed` backoff (covers that +/// timed out against the old address) is cleared and a pass is kicked so they +/// retry on the now-reachable endpoint. +#[tauri::command] +pub async fn library_cover_backfill_set_base_url( + app: AppHandle, + rest_base_url: String, +) -> Result<(), String> { + let worker = app + .try_state::>() + .ok_or_else(|| "cover backfill worker not initialized".to_string())?; + if !worker.set_base_url(rest_base_url) { + return Ok(()); + } + // Forced retry: bypass the idle gate and clear the `.fetch-failed` backoff so + // covers that timed out against the old address are re-attempted on the new + // one. If a pass is in flight it already adopted the new URL live; the forced + // pass is queued to run right after it. + worker.rearm_idle_gate().await; + let _ = try_schedule_full_pass(&app, true).await; + Ok(()) +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CoverCachePeekItem { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5d77cc39..d46d5ddd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -761,6 +761,7 @@ pub fn run() { cover_cache::library_cover_catalog_size, cover_cache::library_cover_clear_fetch_failures, cover_cache::library_cover_backfill_configure, + cover_cache::library_cover_backfill_set_base_url, cover_cache::library_cover_backfill_pulse, cover_cache::library_cover_backfill_reset_cursor, cover_cache::library_cover_backfill_set_ui_priority, diff --git a/src/api/coverCache.ts b/src/api/coverCache.ts index b7e93018..eb88cc8f 100644 --- a/src/api/coverCache.ts +++ b/src/api/coverCache.ts @@ -210,6 +210,16 @@ export async function libraryCoverBackfillConfigure( return invoke('library_cover_backfill_configure', args); } +/** + * Push the current reachable connect URL to the native backfill worker without + * rebuilding the session. The worklist is URL-agnostic; each fetch reads this + * value live, so a LAN→public flip is honoured by the in-flight pass too. A real + * change clears the stale fetch-failed backoff and kicks a retry pass. + */ +export async function libraryCoverBackfillSetBaseUrl(restBaseUrl: string): Promise { + return invoke('library_cover_backfill_set_base_url', { restBaseUrl }); +} + export type CoverBackfillPulseResult = { scheduled: number; exhausted: boolean; diff --git a/src/hooks/useLibraryCoverBackfill.ts b/src/hooks/useLibraryCoverBackfill.ts index eb21cd22..9619f84e 100644 --- a/src/hooks/useLibraryCoverBackfill.ts +++ b/src/hooks/useLibraryCoverBackfill.ts @@ -4,6 +4,7 @@ import { libraryCoverBackfillConfigure, libraryCoverBackfillResetCursor, libraryCoverBackfillRunFullPass, + libraryCoverBackfillSetBaseUrl, librarySqlServerId, } from '../api/coverCache'; import { coverStrategyAllowsLibraryBackfill } from '../utils/library/coverStrategy'; @@ -27,10 +28,9 @@ export function useLibraryCoverBackfill(enabled = true): void { const server = useAuthStore(s => s.activeServerId ? s.servers.find(srv => srv.id === s.activeServerId) : undefined, ); - // Re-read the runtime-probed connect URL whenever the sticky endpoint flips - // (e.g. laptop moves off the LAN). Backfill is configured natively with a - // fixed `rest_base_url`, so without this it would keep fetching covers from - // the now-unreachable local address while playback already switched to public. + // Runtime-probed connect URL: it flips when the sticky endpoint changes (e.g. + // laptop moves off the LAN). The native worklist is URL-agnostic — we push the + // live URL separately (below) rather than baking it into the session. const connectBaseUrl = useSyncExternalStore( subscribeConnectCache, () => useAuthStore.getState().getBaseUrl(), @@ -45,6 +45,9 @@ export function useLibraryCoverBackfill(enabled = true): void { return unsubWake; }, []); + // Session config (server identity, credentials, strategy, enable). The connect + // URL is intentionally NOT a dependency here: it changes far more often than + // these, and the worklist no longer carries it — see the flip effect below. useEffect(() => { const disable = () => { void libraryCoverBackfillConfigure({ @@ -69,21 +72,37 @@ export function useLibraryCoverBackfill(enabled = true): void { const indexKey = serverIndexKeyForProfile(server); void (async () => { + // Seed the URL with the current best guess; the flip effect keeps it fresh. + const seedUrl = useAuthStore.getState().getBaseUrl(); await libraryCoverBackfillConfigure({ enabled: true, serverIndexKey: indexKey, libraryServerId: librarySqlServerId(activeServerId), - restBaseUrl: connectBaseUrl ? coverCacheRestHost(connectBaseUrl) : '', + restBaseUrl: seedUrl ? coverCacheRestHost(seedUrl) : '', username: server.username, password: server.password, }); await libraryCoverBackfillResetCursor(); - // Force: a (re)configure — including a connect-URL flip — must clear the - // `.fetch-failed` backoff so covers that 404'd / timed out against the - // previous (now-stale) address are retried immediately on the new one. - await libraryCoverBackfillRunFullPass(true); + await libraryCoverBackfillRunFullPass(); })(); return disable; - }, [enabled, strategy, activeServerId, server?.url, server?.username, server?.password, connectBaseUrl]); + }, [enabled, strategy, activeServerId, server?.url, server?.username, server?.password]); + + // Connect-URL flip: push the new reachable address live. The native worker + // swaps a single cell, so even an in-flight pass downloads its remaining + // covers against it; a real change also clears the stale fetch-failed backoff + // and kicks a retry pass for whatever failed on the old address. + useEffect(() => { + if ( + !enabled + || !coverStrategyAllowsLibraryBackfill(strategy) + || !activeServerId + || !server + || !connectBaseUrl + ) { + return; + } + void libraryCoverBackfillSetBaseUrl(coverCacheRestHost(connectBaseUrl)); + }, [connectBaseUrl, enabled, strategy, activeServerId, server?.url]); }