fix(cover): follow connect-URL flips in library cover backfill (#952)

* fix(cover): follow connect-URL flips in library cover backfill

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.

* docs(changelog): note cover backfill endpoint-switch fix (PR #952)

* 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.

* fix(cover-backfill): resolve connect URL per fetch instead of baking it into the queue

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.
This commit is contained in:
cucadmuh
2026-06-02 16:01:01 +03:00
committed by GitHub
parent 81f900c7a6
commit 47832632fd
9 changed files with 245 additions and 16 deletions
+68 -6
View File
@@ -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,6 +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,
/// 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<String>,
/// 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,
}
#[derive(Debug, Clone, Serialize)]
@@ -124,6 +133,8 @@ impl CoverBackfillWorker {
parallel: AtomicUsize::new(LIBRARY_BACKFILL_PARALLEL_DEFAULT),
settled: Mutex::new(None),
last_sync_idle_ms: AtomicU64::new(0),
base_url: std::sync::Mutex::new(String::new()),
rerun_pending: AtomicBool::new(false),
}
}
@@ -168,9 +179,15 @@ impl CoverBackfillWorker {
next
}
pub async fn set_session(&self, enabled: bool, session: Option<CoverBackfillSession>) {
pub async fn set_session(
&self,
enabled: bool,
session: Option<CoverBackfillSession>,
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;
@@ -179,6 +196,23 @@ impl CoverBackfillWorker {
}
}
/// 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) {
*self.cursor.lock().await = String::new();
}
@@ -204,7 +238,10 @@ 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).
/// Backfill runs only while this session is still the configured focus (active
/// 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;
@@ -267,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,
@@ -531,13 +568,38 @@ 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. 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_pending.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 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)
|| !worker.enabled.load(Ordering::Relaxed)
{
break;
}
if worker
.pass_running
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
worker.rerun_pending.store(true, Ordering::SeqCst);
break;
}
run_full_pass(app.clone(), worker.clone(), true).await;
}
});
true
}
+27 -2
View File
@@ -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::<Arc<CoverBackfillWorker>>()
.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 {