Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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 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

> **🙏 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.
Expand Down
74 changes: 68 additions & 6 deletions src-tauri/src/cover_cache/backfill_worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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),
}
}

Expand Down Expand Up @@ -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;
Expand All @@ -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();
}
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand Down
29 changes: 27 additions & 2 deletions src-tauri/src/cover_cache/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -720,22 +720,47 @@ pub async fn library_cover_backfill_configure(
Some(CoverBackfillSession {
server_index_key,
library_server_id,
rest_base_url,
username,
password,
})
} else {
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;
}
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 {
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions src/api/coverCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
return invoke('library_cover_backfill_set_base_url', { restBaseUrl });
}

export type CoverBackfillPulseResult = {
scheduled: number;
exhausted: boolean;
Expand Down
1 change: 1 addition & 0 deletions src/config/settingsCredits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)',
],
},
{
Expand Down
40 changes: 35 additions & 5 deletions src/hooks/useLibraryCoverBackfill.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { useEffect } from 'react';
import { useEffect, useSyncExternalStore } from 'react';
import {
coverCacheRestHost,
libraryCoverBackfillConfigure,
libraryCoverBackfillResetCursor,
libraryCoverBackfillRunFullPass,
libraryCoverBackfillSetBaseUrl,
librarySqlServerId,
} from '../api/coverCache';
import { coverStrategyAllowsLibraryBackfill } from '../utils/library/coverStrategy';
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.
Expand All @@ -26,7 +28,14 @@ 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);
// 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(),
() => useAuthStore.getState().getBaseUrl(),
);

useEffect(() => {
const kick = () => {
Expand All @@ -36,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({
Expand All @@ -59,13 +71,14 @@ export function useLibraryCoverBackfill(enabled = true): void {
}

const indexKey = serverIndexKeyForProfile(server);
const baseUrl = getBaseUrl();
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: baseUrl ? coverCacheRestHost(baseUrl) : '',
restBaseUrl: seedUrl ? coverCacheRestHost(seedUrl) : '',
username: server.username,
password: server.password,
});
Expand All @@ -74,5 +87,22 @@ export function useLibraryCoverBackfill(enabled = true): void {
})();

return disable;
}, [enabled, strategy, activeServerId, server?.url, server?.username, server?.password, getBaseUrl]);
}, [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]);
}
61 changes: 61 additions & 0 deletions src/utils/server/serverEndpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
pickReachableBaseUrl,
serverAddressEndpoints,
serverShareBaseUrl,
subscribeConnectCache,
} from './serverEndpoint';
import type { ServerProfile } from '../../store/authStoreTypes';

Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading