From 4fcfbba311efa731d5789507a520df9d80bdc310 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Mon, 9 Nov 2026 16:03:21 -0500 Subject: [PATCH 1/3] fix: expose Last.fm sync progress and recover missing account identity --- apps/desktop/src-tauri/build.rs | 1 + .../autogenerated/lastfm_sync_status.toml | 11 ++ .../src-tauri/permissions/importer.toml | 7 ++ apps/desktop/src-tauri/src/lastfm_import.rs | 4 +- .../src/lastfm_import/application.rs | 34 ++++-- .../src-tauri/src/lastfm_import/commands.rs | 18 ++++ .../src/lastfm_import/incremental.rs | 39 ++++++- .../src/lastfm_import/integration_tests.rs | 101 ++++++++++++++++++ .../src-tauri/src/lastfm_import/model.rs | 18 ++++ .../src-tauri/src/lastfm_import/service.rs | 31 ++++++ apps/desktop/src-tauri/src/lib.rs | 1 + apps/desktop/src/App.css | 7 ++ apps/desktop/src/LastFmImporter.tsx | 4 +- apps/desktop/src/LastFmSync.tsx | 81 ++++++++++++++ apps/desktop/src/PreferencesWindow.tsx | 1 - apps/desktop/src/dialogViews.tsx | 16 ++- apps/desktop/src/lastfmGateway.ts | 1 + apps/desktop/src/lastfmImporter.css | 7 ++ apps/desktop/src/types.ts | 1 + apps/desktop/test/gateways.test.ts | 2 + apps/desktop/test/interactions.test.tsx | 39 ++++++- docs/architecture/library.md | 14 ++- scripts/check-tauri-acl.mjs | 13 +++ 23 files changed, 426 insertions(+), 25 deletions(-) create mode 100644 apps/desktop/src-tauri/permissions/autogenerated/lastfm_sync_status.toml create mode 100644 apps/desktop/src/LastFmSync.tsx diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs index 1919a1d..11a57cb 100644 --- a/apps/desktop/src-tauri/build.rs +++ b/apps/desktop/src-tauri/build.rs @@ -72,6 +72,7 @@ const APP_COMMANDS: &[&str] = &[ "lastfm_import_rename_batch", "start_lastfm_import", "sync_lastfm_plays", + "lastfm_sync_status", "lastfm_import_review", "lastfm_import_options", "lastfm_import_count_mode", diff --git a/apps/desktop/src-tauri/permissions/autogenerated/lastfm_sync_status.toml b/apps/desktop/src-tauri/permissions/autogenerated/lastfm_sync_status.toml new file mode 100644 index 0000000..09385a8 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/lastfm_sync_status.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-lastfm-sync-status" +description = "Enables the lastfm_sync_status command without any pre-configured scope." +commands.allow = ["lastfm_sync_status"] + +[[permission]] +identifier = "deny-lastfm-sync-status" +description = "Denies the lastfm_sync_status command without any pre-configured scope." +commands.deny = ["lastfm_sync_status"] diff --git a/apps/desktop/src-tauri/permissions/importer.toml b/apps/desktop/src-tauri/permissions/importer.toml index a3cd3a6..cedfabf 100644 --- a/apps/desktop/src-tauri/permissions/importer.toml +++ b/apps/desktop/src-tauri/permissions/importer.toml @@ -2,6 +2,13 @@ identifier = "importer-commands" description = "Minimal commands available to the Last.fm importer window." permissions = [ + "allow-lastfm-state", + "allow-connect-lastfm", + "allow-finish-lastfm", + "allow-sync-lastfm-plays", + "allow-open-preferences", + "allow-lastfm-sync-status", + "allow-get-appearance", "allow-genre-values", "allow-lastfm-import-state", diff --git a/apps/desktop/src-tauri/src/lastfm_import.rs b/apps/desktop/src-tauri/src/lastfm_import.rs index 4a50cc7..0ac09ba 100644 --- a/apps/desktop/src-tauri/src/lastfm_import.rs +++ b/apps/desktop/src-tauri/src/lastfm_import.rs @@ -872,6 +872,7 @@ fn state_view(session: Option<&LastFmImportSessionV2>) -> ImportStateView { retryable_error: session.and_then(|session| session.retryable_error.clone()), search_terms: session.map(|session| session.search_terms).unwrap_or(true), syncing: false, + sync_progress: None, last_synced_at: None, pending_review: 0, sync_problem: None, @@ -936,6 +937,7 @@ fn suspended_state_view(session: &LastFmImportSessionV2) -> ImportStateView { }), search_terms: true, syncing: false, + sync_progress: None, last_synced_at: None, pending_review: 0, sync_problem: None, @@ -1073,7 +1075,7 @@ fn cached_spotify_account( spotify_membership: &crate::spotify_membership::SpotifyMembership, ) -> Option { let library = spotify_membership.snapshot(); - library.is_exact().then_some(library.account_id) + (!library.account_id.is_empty()).then_some(library.account_id) } async fn recover_before_apply_job( diff --git a/apps/desktop/src-tauri/src/lastfm_import/application.rs b/apps/desktop/src-tauri/src/lastfm_import/application.rs index b97599e..42a2392 100644 --- a/apps/desktop/src-tauri/src/lastfm_import/application.rs +++ b/apps/desktop/src-tauri/src/lastfm_import/application.rs @@ -499,22 +499,36 @@ where { start_worker(); } - self.lastfm.settle_before_import().await; - let Some(_run) = self.service.claim_sync_runner() else { + let Some(run) = self.service.claim_sync_runner() else { return Ok(self.service.state().await); }; changed(); - let result = run_incremental_sync( - self.library, - self.membership, - self.lastfm, - self.service, - &username, - ) - .await; + self.lastfm.settle_before_import().await; + let result = async { + set_sync_problem(self.service, None).await?; + if self.service.sync_snapshot().await.spotify_account_id.is_some() + && super::cached_spotify_account(self.membership).is_none() + { + if !(self.connected)()? { + return Err("Connect Spotify, then retry Last.fm sync. Your saved progress is preserved.".into()); + } + super::incremental::recover_sync_identity(self.membership, &self.provider).await?; + } + run_incremental_sync( + self.library, + self.membership, + self.lastfm, + self.service, + &username, + ).await + }.await; if let Err(error) = &result { let _ = set_sync_problem(self.service, Some(error.clone())).await; } + if result.is_ok() { + set_sync_problem(self.service, None).await?; + } + drop(run); let view = self.service.state().await; changed(); result.map(|()| view) diff --git a/apps/desktop/src-tauri/src/lastfm_import/commands.rs b/apps/desktop/src-tauri/src/lastfm_import/commands.rs index 9ed1965..090f536 100644 --- a/apps/desktop/src-tauri/src/lastfm_import/commands.rs +++ b/apps/desktop/src-tauri/src/lastfm_import/commands.rs @@ -232,6 +232,14 @@ fn queue_page_request( Ok((cursor, limit)) } +#[tauri::command] +pub(crate) async fn lastfm_sync_status(app: tauri::AppHandle) -> super::model::SyncStatus { + app.state::() + .lastfm_import + .sync_status() + .await +} + #[tauri::command] pub(crate) async fn sync_lastfm_plays(app: tauri::AppHandle) -> Result { let state = app.state::(); @@ -250,6 +258,16 @@ pub(crate) async fn sync_lastfm_plays(app: tauri::AppHandle) -> Result { + log::info!(target: crate::diagnostics::LOG_TARGET, "Last.fm sync completed; {} plays pending review.", view.pending_review) + } + Ok(_) => {} + Err(error) => { + let _ = super::set_sync_problem(&state.lastfm_import, Some(error.clone())).await; + let _ = emit_import_invalidated(&app); + } + } result } diff --git a/apps/desktop/src-tauri/src/lastfm_import/incremental.rs b/apps/desktop/src-tauri/src/lastfm_import/incremental.rs index 0b0aebb..d6b8eba 100644 --- a/apps/desktop/src-tauri/src/lastfm_import/incremental.rs +++ b/apps/desktop/src-tauri/src/lastfm_import/incremental.rs @@ -6,6 +6,11 @@ pub(super) async fn set_sync_problem( service: &Service, message: Option, ) -> Result<(), String> { + if let Some(problem) = &message { + if service.sync_status().await.sync_problem.as_ref() != Some(problem) { + log::warn!(target: crate::diagnostics::LOG_TARGET, "Last.fm sync: {problem}"); + } + } service .mutate_sync(|state| { state.sync_problem = message; @@ -234,7 +239,11 @@ pub(super) async fn run_incremental_sync( .as_deref() .is_some_and(|expected| spotify_account_id.as_deref() != Some(expected)) { - let message = "Last.fm sync is suspended because the connected Spotify account changed."; + let message = if spotify_account_id.is_none() { + "Last.fm sync is paused until Spotify account identity is available. Connect Spotify and sync your library, then retry here." + } else { + "Last.fm sync is paused because a different Spotify account is connected. Reconnect the Spotify account used for this import, then retry. Your saved progress is preserved." + }; set_sync_problem(service, Some(message.into())).await?; return Err(message.into()); } @@ -410,3 +419,31 @@ pub(super) async fn wait_for_incremental_window_retry( } Ok(()) } + +/// Resolve identity without requiring a complete saved-library download. +pub(super) async fn recover_sync_identity( + membership: &crate::spotify_membership::SpotifyMembership, + provider: impl FnOnce() -> Result>, String>, +) -> Result<(), String> { + let mut guard = membership.lock().await; + let mut cached = guard.snapshot(); + if !cached.account_id.is_empty() { + return Ok(()); + } + let profile = provider()?.me().await.map_err(|error| { + format!("Could not verify Spotify for Last.fm sync: {error}. Reconnect Spotify and retry.") + })?; + cached.account_id = profile + .account_id() + .ok_or_else(|| { + "Spotify did not return an account ID. Reconnect Spotify and retry Last.fm sync." + .to_string() + })? + .to_owned(); + // Identity alone does not make the membership snapshot complete. + cached.complete = false; + guard + .replace(cached) + .await + .map_err(|error| error.to_string()) +} diff --git a/apps/desktop/src-tauri/src/lastfm_import/integration_tests.rs b/apps/desktop/src-tauri/src/lastfm_import/integration_tests.rs index 7c2d9e1..996f6e2 100644 --- a/apps/desktop/src-tauri/src/lastfm_import/integration_tests.rs +++ b/apps/desktop/src-tauri/src/lastfm_import/integration_tests.rs @@ -11996,3 +11996,104 @@ async fn legacy_failed_job_without_known_code_restarts_as_apply_failed() { assert_eq!(projected.error_code, Some(ApplyFailureCode::ApplyFailed)); assert_eq!(projected.retry_at, Some(9_999)); } + +#[tokio::test] +async fn incremental_sync_recovers_missing_identity_without_full_library_sync() { + let directory = tempfile::tempdir().unwrap(); + let (_, service, state) = test_app_state(directory.path(), Library::new(), &[]); + let client = Arc::new(fake_client( + [Response::json( + 200, + serde_json::json!({ + "id": "display-id", "account_id": "spotify" + }), + )], + "", + )); + super::incremental::recover_sync_identity(&state.spotify_membership, || { + Ok(Arc::clone(&client)) + }) + .await + .unwrap(); + assert_eq!( + cached_spotify_account(&state.spotify_membership).as_deref(), + Some("spotify") + ); + assert!(!state.spotify_membership.snapshot().complete); + super::incremental::recover_sync_identity(&state.spotify_membership, || { + Ok(Arc::clone(&client)) + }) + .await + .unwrap(); + assert_eq!(client.transport().requests().len(), 1); + service + .mutate_sync(|sync| { + sync.lastfm_username = Some("user".into()); + sync.spotify_account_id = Some("spotify".into()); + sync.synced_through = Some(u64::MAX); + sync.sync_problem = Some("previous failure".into()); + Ok(()) + }) + .await + .unwrap(); + let use_cases = application::UseCases::new( + application::Owners { + service: &service, + lastfm: &state.lastfm, + membership: &state.spotify_membership, + library: &state.library, + settings: &state.settings, + cooldown_store: &state.cooldown_store, + }, + || Err("provider should not be needed".into()), + || Ok(true), + ); + let view = use_cases.sync(|| {}, || {}).await.unwrap(); + assert!(!view.syncing); + assert!(view.sync_problem.is_none()); + state.spotify_membership.set_for_test(SpotifyLibraryState { + account_id: "different".into(), + ..SpotifyLibraryState::default() + }); + assert!(use_cases + .sync(|| {}, || {}) + .await + .unwrap_err() + .contains("different Spotify account")); + assert!(!service.state().await.syncing); + assert_eq!(service.sync_snapshot().await.synced_through, Some(u64::MAX)); +} + +#[tokio::test] +async fn incremental_progress_projects_checkpoint_counts_separately_from_history() { + let directory = tempfile::tempdir().unwrap(); + let service = Service::new(directory.path()); + service + .mutate_sync(|sync| { + sync.lastfm_username = Some("user".into()); + sync.synced_through = Some(10); + sync.active = Some(IncrementalRange { + from: 10, + to: 20, + query_from: 9, + query_to: 21, + cache_id: incremental_cache_id("user", 10, 20), + next_page: 5, + downloaded_pages: 3, + total_pages: Some(8), + ..IncrementalRange::default() + }); + Ok(()) + }) + .await + .unwrap(); + let view = service.state().await; + assert_eq!(view.downloaded_pages, 0); + assert_eq!( + view.sync_progress.unwrap(), + super::model::SyncProgress { + downloaded_pages: 3, + total_pages: Some(8) + } + ); +} diff --git a/apps/desktop/src-tauri/src/lastfm_import/model.rs b/apps/desktop/src-tauri/src/lastfm_import/model.rs index e6bd957..203cf9b 100644 --- a/apps/desktop/src-tauri/src/lastfm_import/model.rs +++ b/apps/desktop/src-tauri/src/lastfm_import/model.rs @@ -406,6 +406,7 @@ pub(crate) struct ImportStateView { pub retryable_error: Option, pub search_terms: bool, pub syncing: bool, + pub sync_progress: Option, pub last_synced_at: Option, pub pending_review: usize, pub sync_problem: Option, @@ -413,6 +414,13 @@ pub(crate) struct ImportStateView { pub spotify_limit: Option, } +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SyncProgress { + pub downloaded_pages: u32, + pub total_pages: Option, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "kebab-case")] pub(crate) enum QueueStatus { @@ -828,3 +836,13 @@ mod tests { assert!(serde_json::from_str::("\"unknown\"").is_err()); } } + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SyncStatus { + pub syncing: bool, + pub sync_progress: Option, + pub sync_problem: Option, + pub last_synced_at: Option, + pub pending_review: usize, +} diff --git a/apps/desktop/src-tauri/src/lastfm_import/service.rs b/apps/desktop/src-tauri/src/lastfm_import/service.rs index 9079433..5893b43 100644 --- a/apps/desktop/src-tauri/src/lastfm_import/service.rs +++ b/apps/desktop/src-tauri/src/lastfm_import/service.rs @@ -435,6 +435,13 @@ impl Service { view.last_synced_at = sync.last_synced_at; view.pending_review = sync.backlog.len(); view.sync_problem = sync.sync_problem.clone(); + view.sync_progress = sync + .active + .as_ref() + .map(|range| super::model::SyncProgress { + downloaded_pages: range.downloaded_pages, + total_pages: range.total_pages, + }); view.applying_all = sync.accept_all.is_some(); drop(sync); let previous = std::mem::replace(&mut *slot, SessionResidency::Parked { owner, view }); @@ -617,6 +624,23 @@ impl Service { Arc::clone(&self.restore_mutations) } + pub(crate) async fn sync_status(&self) -> super::model::SyncStatus { + let sync = self.sync_state.lock().await; + super::model::SyncStatus { + syncing: self.sync_running.load(Ordering::Acquire), + sync_progress: sync + .active + .as_ref() + .map(|range| super::model::SyncProgress { + downloaded_pages: range.downloaded_pages, + total_pages: range.total_pages, + }), + sync_problem: sync.sync_problem.clone(), + last_synced_at: sync.last_synced_at, + pending_review: sync.backlog.len(), + } + } + pub(crate) async fn state(&self) -> ImportStateView { if self.hydration.load(Ordering::Acquire) != 1 { let mut view = state_view(None); @@ -649,6 +673,13 @@ impl Service { view.last_synced_at = sync.last_synced_at; view.pending_review = sync.backlog.len(); view.sync_problem = sync.sync_problem.clone(); + view.sync_progress = sync + .active + .as_ref() + .map(|range| super::model::SyncProgress { + downloaded_pages: range.downloaded_pages, + total_pages: range.total_pages, + }); view.applying_all = sync.accept_all.is_some(); view } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index fe64908..3c532bd 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1489,6 +1489,7 @@ pub fn run() { lastfm_import::commands::lastfm_import_rename_batch, lastfm_import::commands::start_lastfm_import, lastfm_import::commands::sync_lastfm_plays, + lastfm_import::commands::lastfm_sync_status, lastfm_import::commands::lastfm_import_review, lastfm_import::commands::lastfm_import_options, lastfm_import::commands::lastfm_import_count_mode, diff --git a/apps/desktop/src/App.css b/apps/desktop/src/App.css index 134b3af..1031c82 100644 --- a/apps/desktop/src/App.css +++ b/apps/desktop/src/App.css @@ -500,3 +500,10 @@ button.spotify-page-art:focus-visible { outline: 2px solid var(--accent); outlin .shadow-progress { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 12px; color: var(--dim); } .shadow-progress progress { width: min(320px, 80vw); accent-color: var(--accent); } .shadow-error { padding: 8px 16px; color: var(--text); } + +.preference-bug { display: flex; flex-direction: column; gap: 16px; } +.preference-bug .preference-group { margin: 0; flex-shrink: 0; } +.preference-bug .diagnostic-group { flex: 1; min-height: 160px; display: flex; flex-direction: column; } +.preference-bug .diagnostic-card { flex: 1; min-height: 0; display: flex; flex-direction: column; } +.preference-bug .diagnostic-filters { flex-shrink: 0; } +.preference-bug .diagnostic-log { flex: 1; min-height: 0; height: auto; } diff --git a/apps/desktop/src/LastFmImporter.tsx b/apps/desktop/src/LastFmImporter.tsx index 630e53f..5d8c086 100644 --- a/apps/desktop/src/LastFmImporter.tsx +++ b/apps/desktop/src/LastFmImporter.tsx @@ -1,3 +1,4 @@ +import { LastFmSync } from './LastFmSync.tsx' import { listen } from '@tauri-apps/api/event' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type RefObject, type ReactNode } from 'react' import { flushSync } from 'react-dom' @@ -247,7 +248,7 @@ function DownloadPane({ state, defaults, busy, onDefaults, onStart }: { state: I if (busy || (!state.retryableError && (state.phase === 'downloading' || isAggregating))) { return } - if (isSetup && !state.username) return
Last.fm isn’t connectedConnect Last.fm in Preferences to import your listening history.
+ if (isSetup && !state.username) return
Last.fm isn’t connectedConnect Last.fm below to import your listening history.
return

LAST.FM HISTORY

@@ -1534,6 +1535,7 @@ export default function LastFmImporter() {

LAST.FM HISTORY

Last.fm importer

{state.applyingAll ? 'Applying confirmed Last.fm imports' : state.syncing ? 'Syncing new Last.fm plays' : importStatusText(state.phase, state.username, state.syncProblem)}{showsImportRemaining(state.phase) && state.remaining ? ` · ${state.remaining.toLocaleString()} left` : ''}{state.pendingReview && !state.remaining ? ` · ${state.pendingReview.toLocaleString()} pending review` : ''}

{ event.preventDefault(); void openExternalDestination({ kind: 'lastFm' }).catch(reportError) }}>Powered by Last.fm{reviewReady && <>Sort
{([['plays', 'Most to import'], ['artist', 'Artist A–Z'], ['batch', 'Batch size'], ['lastPlayed', 'Last played']] as const).map(([value, label]) => )}
}
{error ? setError(null)}>{error.message} : selectedError ?
This batch remains in the import queue because its last apply failed. Retry it from the batch.
: state.spotifyLimit &&
{state.spotifyLimit.kind === 'quota' ? 'Spotify Development Mode quota is cooling down.' : 'Spotify is rate limited.'}
} {initialLoad === 'loading' ? : initialLoad === 'error' ?
Couldn’t load Last.fm batches
: state.phase === 'downloading' || state.phase === 'aggregating' || state.phase === null || state.phase === 'suspended' ? void start()} /> :
{state.applyingAll ?
Applying confirmed imports…You can close this window; Retune will resume the queue after a restart.
: page ? void archiveBatch()} onIgnoreBatch={() => void ignoreBatch()} onError={reportError} onCollectionPage={(nextPage) => setPage(pageWithQueuePosition(nextPage, filteredQueue))} onTabToQueue={focusQueueTarget} onShortcuts={() => setShortcutsOpen(true)} onStatus={setShortcutStatus} onMutation={(running) => { queueMutationRunning.current = running; setPageMutationRunning(running) }} batchNameEdit={batchNameEdit} batchNameError={batchNameError} onBeginBatchRename={beginBatchRename} onChangeBatchName={updateBatchName} onCommitBatchName={commitBatchRename} onCancelBatchRename={cancelBatchRename} /> :
{queueView === 'archives' ? 'No archived batches' : emptyPage.title}{queueView === 'archives' ? 'Archive a batch to move it out of the import queue.' : emptyPage.detail}
}
} +
{initialLoad === 'ready' && reviewReady && {queueSummary.importedPlays.toLocaleString()} {queueSummary.importedPlays === 1 ? 'play' : 'plays'} imported / {queueSummary.remainingPlays.toLocaleString()} {queueSummary.remainingPlays === 1 ? 'play' : 'plays'} remaining · {queueSummary.importedBatches.toLocaleString()} {queueSummary.importedBatches === 1 ? 'batch' : 'batches'} imported / {queueSummary.remaining.toLocaleString()} {queueSummary.remaining === 1 ? 'batch remains' : 'batches remain'}}{ignoringBatches.size ? `Saving ${ignoringBatches.size} ignored ${ignoringBatches.size === 1 ? 'batch' : 'batches'}…` : shortcutStatus || (state.username ? `Last.fm: ${state.username}` : 'Account not connected')}
{acceptAllOpen && acceptAllSummary && { setAcceptAllOpen(false); setAcceptAllSummary(null) }} onConfirm={() => void acceptAll()} />} {shortcutsOpen && setShortcutsOpen(false)} />} diff --git a/apps/desktop/src/LastFmSync.tsx b/apps/desktop/src/LastFmSync.tsx new file mode 100644 index 0000000..fdd99cb --- /dev/null +++ b/apps/desktop/src/LastFmSync.tsx @@ -0,0 +1,81 @@ +import { useEffect, useRef, useState } from 'react' +import { appGateway } from './appGateway.ts' +import { initialState } from './appState.ts' +import { lastfmGateway } from './lastfmGateway.ts' +import type { LastFmImportState } from './types.ts' +import { useTauriInvalidationSnapshot } from './useTauriInvalidationSnapshot.ts' + +function message(reason: unknown): string { + return typeof reason === 'string' ? reason : reason instanceof Error ? reason.message : 'Couldn’t complete Last.fm sync. Retry or check the Bug tab in Preferences.' +} + +export function LastFmSync({ state }: { state: LastFmImportState }) { + const [account, setAccount] = useState(initialState.lastfm) + const [progress, setProgress] = useState> | null>(null) + const [pending, setPending] = useState<'sync' | 'connect' | 'finish' | 'preferences' | null>(null) + const [error, setError] = useState(null) + const running = useRef(false) + useTauriInvalidationSnapshot('lastfm-changed', lastfmGateway.accountState, (next) => { if (next) setAccount(next) }, (reason) => setError(message(reason))) + const syncing = pending === 'sync' || state.syncing + const view = syncing ? progress ?? state : state + useEffect(() => { + if (!state.syncing && pending !== 'sync') return + let active = true + let timer: ReturnType + const poll = async () => { + try { + const next = await lastfmGateway.syncStatus() + if (active) setProgress(next) + } catch (reason) { + if (active) setError(message(reason)) + } finally { + if (active) timer = setTimeout(() => void poll(), 750) + } + } + void poll() + return () => { active = false; clearTimeout(timer) } + }, [state.syncing, pending]) + // Invalidation snapshots remain authoritative once the operation finishes. + const displayed = syncing ? view : state + const action = async (kind: 'sync' | 'connect' | 'finish' | 'preferences') => { + if (running.current) return + running.current = true + setPending(kind) + setProgress(null) + setError(null) + try { + if (kind === 'sync') setProgress(await lastfmGateway.syncPlays()) + else if (kind === 'connect') setAccount(await lastfmGateway.connectAccount()) + else if (kind === 'finish') setAccount(await lastfmGateway.finishAccount()) + else await appGateway.openPreferences() + } catch (reason) { + setError(message(reason)) + } finally { + running.current = false + setPending(null) + void lastfmGateway.syncStatus().then(setProgress).catch(() => {}) + } + } + const needsConnection = !account.connected || account.reconnectRequired + const problem = error ?? account.problem ?? displayed.syncProblem + const pages = view.syncProgress + const status = problem ?? (!account.available ? 'Last.fm is unavailable in this build.' : syncing + ? pages?.totalPages != null + ? pages.downloadedPages >= pages.totalPages ? 'Reconciling downloaded plays…' : `Downloaded ${pages.downloadedPages.toLocaleString()} of ${pages.totalPages.toLocaleString()} pages` + : 'Preparing Last.fm sync…' + : displayed.lastSyncedAt ? `Last synced ${new Date(displayed.lastSyncedAt * 1000).toLocaleString()}${displayed.pendingReview ? ` · ${displayed.pendingReview.toLocaleString()} plays need review` : ''}` + : 'Sync new Last.fm plays into your library.') + return
+
Last.fm{account.username ? ` · ${account.username}` : ''} + {status} + {syncing && } +
+
+ {account.available && (account.pending + ? + : needsConnection ? + : )} + {problem && } +
+
+} diff --git a/apps/desktop/src/PreferencesWindow.tsx b/apps/desktop/src/PreferencesWindow.tsx index 1399130..c2669bd 100644 --- a/apps/desktop/src/PreferencesWindow.tsx +++ b/apps/desktop/src/PreferencesWindow.tsx @@ -51,7 +51,6 @@ export default function PreferencesWindow() { {settings ? setSettings({ ...settings, zoom })} onCancel={close} onLastfm={setLastfm} onImport={() => { void appGateway.openLastFmImporter().catch(() => setError('Couldn’t open the Last.fm importer. Try again.')) }} - onSyncLastfm={() => { void lastfmGateway.syncPlays().then(setLastfmImport).catch(() => setError('Couldn’t sync Last.fm. Try again.')) }} onSave={save} /> :

Loading preferences…

} } diff --git a/apps/desktop/src/dialogViews.tsx b/apps/desktop/src/dialogViews.tsx index c983363..7661b82 100644 --- a/apps/desktop/src/dialogViews.tsx +++ b/apps/desktop/src/dialogViews.tsx @@ -220,10 +220,12 @@ function BugPreferences() { const [copied, setCopied] = useState(false) useEffect(() => { let active = true - appGateway.diagnostics() + const refresh = () => appGateway.diagnostics() .then((next) => { if (active) setReport(next) }) .catch(() => { if (active) setError('Couldn’t load diagnostics. Try again.') }) - return () => { active = false } + void refresh() + const timer = window.setInterval(() => void refresh(), 3000) + return () => { active = false; window.clearInterval(timer) } }, []) const counts = Object.fromEntries(diagnosticLevels.map((level) => [level, report.entries.filter((entry) => entry.level === level).length])) as Record const visible = report.entries.filter((entry) => levels.has(entry.level)) @@ -274,7 +276,7 @@ function BugPreferences() { } -export function Preferences({ settings, saving = false, lastfm, lastfmImport, onZoom, onCancel, onLastfm, onImport, onSyncLastfm, onSave, onLibraryChanged = () => {} }: { +export function Preferences({ settings, saving = false, lastfm, lastfmImport, onZoom, onCancel, onLastfm, onImport, onSave, onLibraryChanged = () => {} }: { settings: Settings saving?: boolean lastfm: LastFmState @@ -283,7 +285,6 @@ export function Preferences({ settings, saving = false, lastfm, lastfmImport, on onCancel: () => void onLastfm: (state: LastFmState) => void onImport: () => void - onSyncLastfm: () => void onLibraryChanged?: () => void onSave: (settings: Pick) => void }) { @@ -341,7 +342,7 @@ export function Preferences({ settings, saving = false, lastfm, lastfmImport, on
{tabs.map(([value, glyph, label]) => )}
-
+
{tab === 'appearance' && <>

Theme

{themeOptions.map(([value, label, help]) => )} @@ -378,10 +379,6 @@ export function Preferences({ settings, saving = false, lastfm, lastfmImport, on
-
- - -
{lastfmImport.syncProblem ?? (lastfmImport.pendingReview ? `${lastfmImport.pendingReview.toLocaleString()} plays need review.` : lastfmImport.lastSyncedAt ? `Last synced ${new Date(lastfmImport.lastSyncedAt * 1000).toLocaleString()}.` : 'Exact play reconciliation starts with the next sync.')} @@ -395,6 +392,7 @@ export function Preferences({ settings, saving = false, lastfm, lastfmImport, on
} {lastfm.problem && lastfm.available && {lastfm.problem}} {lastfmError && lastfmError !== lastfm.problem && {lastfmError}} +
Powered by { event.preventDefault(); void openExternalDestination({ kind: 'lastFm' }).catch(() => setLastfmError('Couldn’t open Last.fm. Try again.')) }}>Last.fm.
diff --git a/apps/desktop/src/lastfmGateway.ts b/apps/desktop/src/lastfmGateway.ts index 5a95092..31d7f12 100644 --- a/apps/desktop/src/lastfmGateway.ts +++ b/apps/desktop/src/lastfmGateway.ts @@ -55,6 +55,7 @@ export function createLastFmGateway(invoke: Invoker) { connectAccount: () => invoke('connect_lastfm'), finishAccount: () => invoke('finish_lastfm'), disconnectAccount: () => invoke('disconnect_lastfm'), + syncStatus: () => invoke>('lastfm_sync_status'), syncPlays: () => invoke('sync_lastfm_plays'), } } diff --git a/apps/desktop/src/lastfmImporter.css b/apps/desktop/src/lastfmImporter.css index 929e5c2..c061aa4 100644 --- a/apps/desktop/src/lastfmImporter.css +++ b/apps/desktop/src/lastfmImporter.css @@ -278,3 +278,10 @@ button.import-picker-option { width: 100%; grid-template-columns: minmax(0, 1fr) .import-fuzzy-connector::after { top: auto; right: auto; bottom: 0; left: calc(50% - 4px); width: 7px; height: 7px; border: 0; border-right: 1px solid var(--accent); border-bottom: 1px solid var(--accent); transform: rotate(45deg); } .import-review-footer { align-items: flex-start; flex-direction: column; } } + +.import-sync { margin-top: auto; flex-shrink: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 10px 18px; padding: 10px 16px; border-top: 1px solid var(--border); background: var(--panel); } +.import-sync-copy { flex: 1 1 240px; min-width: 0; display: flex; flex-direction: column; gap: 5px; overflow-wrap: anywhere; } +.import-sync-copy > span { color: var(--dim); } +.import-sync-copy > [role="alert"] { color: var(--text); } +.import-sync-copy progress { width: 100%; max-width: 480px; accent-color: var(--accent); } +.import-sync-actions { display: flex; flex-wrap: wrap; gap: 8px; } diff --git a/apps/desktop/src/types.ts b/apps/desktop/src/types.ts index 172c7d4..3cac16a 100644 --- a/apps/desktop/src/types.ts +++ b/apps/desktop/src/types.ts @@ -80,6 +80,7 @@ export type LastFmImportState = { retryableError: { message: string; attempt: number; retryable: boolean } | null searchTerms: boolean syncing: boolean + syncProgress?: { downloadedPages: number; totalPages: number | null } | null lastSyncedAt: number | null pendingReview: number syncProblem: string | null diff --git a/apps/desktop/test/gateways.test.ts b/apps/desktop/test/gateways.test.ts index a346d6f..73a0269 100644 --- a/apps/desktop/test/gateways.test.ts +++ b/apps/desktop/test/gateways.test.ts @@ -299,6 +299,7 @@ test('Last.fm gateway preserves every command name and camel-case argument', asy await lastfm.finishAccount() await lastfm.disconnectAccount() await lastfm.syncPlays() + await lastfm.syncStatus() assert.deepEqual(calls, [ ['lastfm_import_state', undefined], @@ -330,6 +331,7 @@ test('Last.fm gateway preserves every command name and camel-case argument', asy ['finish_lastfm', undefined], ['disconnect_lastfm', undefined], ['sync_lastfm_plays', undefined], + ['lastfm_sync_status', undefined], ]) }) diff --git a/apps/desktop/test/interactions.test.tsx b/apps/desktop/test/interactions.test.tsx index 52ff9bf..365b368 100644 --- a/apps/desktop/test/interactions.test.tsx +++ b/apps/desktop/test/interactions.test.tsx @@ -29,6 +29,7 @@ vi.mock('@tauri-apps/api/window', () => ({ getCurrentWindow: () => ({ onDragDrop import App, { StatusBar, TransportBar } from '../src/App.tsx' import { defaultSettings } from '../src/appState.ts' +import { LastFmSync } from '../src/LastFmSync.tsx' import LastFmImporter, { ImportQueueFilter } from '../src/LastFmImporter.tsx' import { GetInfo, Preferences } from '../src/dialogViews.tsx' import PreferencesWindow from '../src/PreferencesWindow.tsx' @@ -3030,7 +3031,7 @@ describe('library track decisions', () => { const lastfm: LastFmState = { available: false, connected: false, username: null, pending: false, reconnectRequired: false, problem: null } const changed = vi.fn() invokeMock.mockImplementation(async (command) => command === 'removed_retune_tracks' ? [] : null) - const view = await render() + const view = await render() await act(async () => view.querySelector('#preferences-library-tab')!.click()) expect(view.textContent).toContain('Removed tracks') @@ -3200,3 +3201,39 @@ describe('native Preferences window', () => { expect(closeWindowMock).toHaveBeenCalledOnce() }) }) + + +describe('Last.fm sync footer', () => { + it('shows incremental progress, preserves the failure reason, and allows retry', async () => { + let state = idleLastFmImport({ syncing: true, syncProgress: { downloadedPages: 2, totalPages: 5 } }) + invokeMock.mockImplementation(async (command) => { + if (command === 'lastfm_state') return { available: true, connected: true, username: 'user', pending: false, reconnectRequired: false, problem: null } + if (command === 'lastfm_sync_status') return state + if (command === 'sync_lastfm_plays') throw 'Spotify account could not be verified. Reconnect Spotify.' + return null + }) + const view = await render() + expect(view.textContent).toContain('Downloaded 2 of 5 pages') + expect(view.querySelector('progress')?.value).toBe(2) + state = idleLastFmImport() + await act(async () => root!.render()) + await clickText(view, 'Sync now') + expect(view.textContent).toContain('Spotify account could not be verified. Reconnect Spotify.') + expect(view.querySelector('progress')).toBeNull() + expect([...view.querySelectorAll('button')].find((button) => button.textContent === 'Retry sync')?.disabled).toBe(false) + }) + + it('offers Last.fm reconnection and finish authorization in the footer', async () => { + const account = { available: true, connected: false, username: 'user', pending: false, reconnectRequired: true, problem: 'Last.fm session expired.' } + invokeMock.mockImplementation(async (command) => { + if (command === 'lastfm_state') return account + if (command === 'connect_lastfm') return { ...account, pending: true } + if (command === 'lastfm_sync_status') return idleLastFmImport() + return null + }) + const view = await render() + await clickText(view, 'Reconnect Last.fm') + expect(view.textContent).toContain('Finish connecting') + expect(invokeMock).toHaveBeenCalledWith('connect_lastfm', undefined) + }) +}) diff --git a/docs/architecture/library.md b/docs/architecture/library.md index ea65c35..3e760ca 100644 --- a/docs/architecture/library.md +++ b/docs/architecture/library.md @@ -259,12 +259,24 @@ does not create one. ### Last.fm incremental reconciliation +The importer footer owns manual sync, incremental page progress, Last.fm reconnect, +and persistent recovery feedback. The backend projects the active range's actual +checkpoint counts independently of historical import progress; the footer polls +a lightweight sync-only snapshot while a sync runs, without matching or provider +requests. Completion releases the runner before publishing its final +snapshot. Failures from manual, startup, and reconnect syncs enter diagnostics. +Account identity does not depend on a complete saved-library snapshot: a known +Spotify account remains valid during partial library sync. Missing identity is recovered through the shared Spotify profile client under +the membership owner lock, without marking membership complete. Unavailable +identity and an actual account mismatch preserve the incremental checkpoint and explain how +to resume; neither silently rebinds existing history to another account. + The first exact sync establishes `syncedThrough=now`; it does not backfill older post-enable history. Launch, reconnect, and the explicit “Sync Last.fm plays” action each process one fixed half-open window. The query is padded for Last.fm’s exclusive bounds and the exact window is filtered locally. Incremental download reuses the historical parser, 200-row pages, four-page downloader, -manifest/cache, retry protections, and oldest-to-newest checkpoints. It never +manifest/cache, retry protections, and oldest-to-newest checkpoints. The source downloader never calls Spotify and does not aggregate until the range is complete. Retune-origin scrobbles are represented by accepted local receipts and are diff --git a/scripts/check-tauri-acl.mjs b/scripts/check-tauri-acl.mjs index c04fe31..565441b 100644 --- a/scripts/check-tauri-acl.mjs +++ b/scripts/check-tauri-acl.mjs @@ -44,6 +44,13 @@ const permissionList = (file) => { }; const allow = (command) => `allow-${command.replaceAll("_", "-")}`; const importerCommands = [ + "lastfm_state", + "connect_lastfm", + "finish_lastfm", + "sync_lastfm_plays", + "open_preferences", + "lastfm_sync_status", + "get_appearance", "genre_values", "lastfm_import_state", @@ -75,6 +82,12 @@ const importerCommands = [ "open_external_destination", ]; const sharedImporterCommands = new Set([ + "lastfm_state", + "connect_lastfm", + "finish_lastfm", + "sync_lastfm_plays", + "open_preferences", + "get_appearance", "genre_values", "lastfm_import_state", From 391029726042c192bef3398743d5c6b95ebe626b Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Mon, 9 Nov 2026 16:07:48 -0500 Subject: [PATCH 2/3] fix: publish sync failures before releasing the runner --- .../src/lastfm_import/application.rs | 21 +++++---- .../src-tauri/src/lastfm_import/commands.rs | 3 +- .../src/lastfm_import/integration_tests.rs | 43 +++++++++++++++++++ 3 files changed, 54 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src-tauri/src/lastfm_import/application.rs b/apps/desktop/src-tauri/src/lastfm_import/application.rs index 42a2392..f36aba2 100644 --- a/apps/desktop/src-tauri/src/lastfm_import/application.rs +++ b/apps/desktop/src-tauri/src/lastfm_import/application.rs @@ -493,19 +493,19 @@ where StartWorker: FnMut(), Changed: FnMut(), { - let username = lastfm_username(self.lastfm).await?; - if self.service.next_apply_job().await.is_some() - || self.service.sync_snapshot().await.accept_all.is_some() - { - start_worker(); - } let Some(run) = self.service.claim_sync_runner() else { return Ok(self.service.state().await); }; changed(); - self.lastfm.settle_before_import().await; let result = async { set_sync_problem(self.service, None).await?; + let username = lastfm_username(self.lastfm).await?; + if self.service.next_apply_job().await.is_some() + || self.service.sync_snapshot().await.accept_all.is_some() + { + start_worker(); + } + self.lastfm.settle_before_import().await; if self.service.sync_snapshot().await.spotify_account_id.is_some() && super::cached_spotify_account(self.membership).is_none() { @@ -520,14 +520,13 @@ where self.lastfm, self.service, &username, - ).await + ).await?; + set_sync_problem(self.service, None).await }.await; if let Err(error) = &result { let _ = set_sync_problem(self.service, Some(error.clone())).await; } - if result.is_ok() { - set_sync_problem(self.service, None).await?; - } + // All status writes, including preflight failures, belong to this runner. drop(run); let view = self.service.state().await; changed(); diff --git a/apps/desktop/src-tauri/src/lastfm_import/commands.rs b/apps/desktop/src-tauri/src/lastfm_import/commands.rs index 090f536..1c5035b 100644 --- a/apps/desktop/src-tauri/src/lastfm_import/commands.rs +++ b/apps/desktop/src-tauri/src/lastfm_import/commands.rs @@ -264,8 +264,7 @@ pub(crate) async fn sync_lastfm_plays(app: tauri::AppHandle) -> Result {} Err(error) => { - let _ = super::set_sync_problem(&state.lastfm_import, Some(error.clone())).await; - let _ = emit_import_invalidated(&app); + log::warn!(target: crate::diagnostics::LOG_TARGET, "Last.fm sync failed: {error}"); } } result diff --git a/apps/desktop/src-tauri/src/lastfm_import/integration_tests.rs b/apps/desktop/src-tauri/src/lastfm_import/integration_tests.rs index 996f6e2..be85ba1 100644 --- a/apps/desktop/src-tauri/src/lastfm_import/integration_tests.rs +++ b/apps/desktop/src-tauri/src/lastfm_import/integration_tests.rs @@ -12097,3 +12097,46 @@ async fn incremental_progress_projects_checkpoint_counts_separately_from_history } ); } + +#[tokio::test] +async fn sync_preflight_failure_cannot_overwrite_another_runner() { + let directory = tempfile::tempdir().unwrap(); + let (lastfm, service, state) = test_app_state(directory.path(), Library::new(), &[]); + lastfm.disconnect().await.unwrap(); + let use_cases = application::UseCases::new( + application::Owners { + service: &service, + lastfm: &lastfm, + membership: &state.spotify_membership, + library: &state.library, + settings: &state.settings, + cooldown_store: &state.cooldown_store, + }, + || Err("provider should not be needed".into()), + || Ok(true), + ); + let active = service.claim_sync_runner().unwrap(); + set_sync_problem(&service, Some("active retry status".into())) + .await + .unwrap(); + let view = use_cases + .sync( + || panic!("must not start work"), + || panic!("must not publish a change"), + ) + .await + .unwrap(); + assert!(view.syncing); + assert_eq!(view.sync_problem.as_deref(), Some("active retry status")); + drop(active); + let mut changes = 0; + let error = use_cases + .sync(|| panic!("preflight must fail first"), || changes += 1) + .await + .unwrap_err(); + let status = service.sync_status().await; + assert!(!status.syncing); + assert_eq!(status.sync_problem.as_deref(), Some(error.as_str())); + assert_eq!(changes, 2); + assert!(service.claim_sync_runner().is_some()); +} From 031be8c9cca03e54f5d9092256f172862385fe28 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Mon, 9 Nov 2026 16:15:57 -0500 Subject: [PATCH 3/3] fix: reconcile Last.fm state across windows and retries --- apps/desktop/src-tauri/src/lib.rs | 1 + apps/desktop/src/LastFmSync.tsx | 18 +++-- apps/desktop/src/dialogViews.tsx | 9 ++- apps/desktop/test/interactions.test.tsx | 93 +++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 3c532bd..23e99c0 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1104,6 +1104,7 @@ fn finish_startup( Arc::new(move |_| { let _ = emit_main(&lastfm_app, "lastfm-changed", ()); let _ = lastfm_app.emit_to("preferences", "lastfm-changed", ()); + let _ = lastfm_app.emit_to("lastfm-importer", "lastfm-changed", ()); }), ); let restore_mutations = Arc::new(restore_latch::RestoreMutationState::default()); diff --git a/apps/desktop/src/LastFmSync.tsx b/apps/desktop/src/LastFmSync.tsx index fdd99cb..b090fad 100644 --- a/apps/desktop/src/LastFmSync.tsx +++ b/apps/desktop/src/LastFmSync.tsx @@ -14,22 +14,27 @@ export function LastFmSync({ state }: { state: LastFmImportState }) { const [progress, setProgress] = useState> | null>(null) const [pending, setPending] = useState<'sync' | 'connect' | 'finish' | 'preferences' | null>(null) const [error, setError] = useState(null) + const [pollError, setPollError] = useState(null) + const [accountError, setAccountError] = useState(null) + const generation = useRef(0) const running = useRef(false) - useTauriInvalidationSnapshot('lastfm-changed', lastfmGateway.accountState, (next) => { if (next) setAccount(next) }, (reason) => setError(message(reason))) + useTauriInvalidationSnapshot('lastfm-changed', lastfmGateway.accountState, (next) => { if (next) { setAccount(next); setAccountError(null) } }, (reason) => setAccountError(message(reason))) const syncing = pending === 'sync' || state.syncing const view = syncing ? progress ?? state : state useEffect(() => { if (!state.syncing && pending !== 'sync') return let active = true + const operation = generation.current + const current = () => active && operation === generation.current let timer: ReturnType const poll = async () => { try { const next = await lastfmGateway.syncStatus() - if (active) setProgress(next) + if (current()) { setProgress(next); setPollError(null) } } catch (reason) { - if (active) setError(message(reason)) + if (current()) setPollError(message(reason)) } finally { - if (active) timer = setTimeout(() => void poll(), 750) + if (current()) timer = setTimeout(() => void poll(), 750) } } void poll() @@ -40,9 +45,11 @@ export function LastFmSync({ state }: { state: LastFmImportState }) { const action = async (kind: 'sync' | 'connect' | 'finish' | 'preferences') => { if (running.current) return running.current = true + generation.current += 1 setPending(kind) setProgress(null) setError(null) + setPollError(null) try { if (kind === 'sync') setProgress(await lastfmGateway.syncPlays()) else if (kind === 'connect') setAccount(await lastfmGateway.connectAccount()) @@ -53,11 +60,10 @@ export function LastFmSync({ state }: { state: LastFmImportState }) { } finally { running.current = false setPending(null) - void lastfmGateway.syncStatus().then(setProgress).catch(() => {}) } } const needsConnection = !account.connected || account.reconnectRequired - const problem = error ?? account.problem ?? displayed.syncProblem + const problem = error ?? accountError ?? (syncing ? pollError : null) ?? account.problem ?? displayed.syncProblem const pages = view.syncProgress const status = problem ?? (!account.available ? 'Last.fm is unavailable in this build.' : syncing ? pages?.totalPages != null diff --git a/apps/desktop/src/dialogViews.tsx b/apps/desktop/src/dialogViews.tsx index 7661b82..de6547b 100644 --- a/apps/desktop/src/dialogViews.tsx +++ b/apps/desktop/src/dialogViews.tsx @@ -217,12 +217,13 @@ function BugPreferences() { const [report, setReport] = useState({ entries: [], emailAvailable: false }) const [levels, setLevels] = useState>(() => new Set(diagnosticLevels)) const [error, setError] = useState() + const [loadError, setLoadError] = useState() const [copied, setCopied] = useState(false) useEffect(() => { let active = true const refresh = () => appGateway.diagnostics() - .then((next) => { if (active) setReport(next) }) - .catch(() => { if (active) setError('Couldn’t load diagnostics. Try again.') }) + .then((next) => { if (active) { setReport(next); setLoadError(undefined) } }) + .catch(() => { if (active) setLoadError('Couldn’t load diagnostics. Try again.') }) void refresh() const timer = window.setInterval(() => void refresh(), 3000) return () => { active = false; window.clearInterval(timer) } @@ -270,6 +271,7 @@ function BugPreferences() {

Report a problem

{problemCount ? `Copies ${reportEntries.length} entries — the session run-up through the last problem.` : 'No warnings or errors this session.'}Filters change the view only. Reports always include the full diagnostic window.
+ {loadError && {loadError}} {error && {error}} {!report.emailAvailable && Email support is unavailable in this build. Copy Logs and share the report instead.}
@@ -302,7 +304,8 @@ export function Preferences({ settings, saving = false, lastfm, lastfmImport, on const [normalizeVolume, setNormalizeVolume] = useState(settings.normalizeVolume) const [gapless, setGapless] = useState(settings.gapless) const [playThresholdPercent, setPlayThresholdPercent] = useState(settings.playThresholdPercent) - const [lastfmScrobbling, setLastfmScrobbling] = useState(settings.lastfmScrobbling) + const [lastfmScrobblingDraft, setLastfmScrobbling] = useState(null) + const lastfmScrobbling = lastfmScrobblingDraft ?? settings.lastfmScrobbling const [lastfmBusy, setLastfmBusy] = useState(false) const [lastfmError, setLastfmError] = useState() const [removedTracksOpen, setRemovedTracksOpen] = useState(false) diff --git a/apps/desktop/test/interactions.test.tsx b/apps/desktop/test/interactions.test.tsx index 365b368..b544f97 100644 --- a/apps/desktop/test/interactions.test.tsx +++ b/apps/desktop/test/interactions.test.tsx @@ -3237,3 +3237,96 @@ describe('Last.fm sync footer', () => { expect(invokeMock).toHaveBeenCalledWith('connect_lastfm', undefined) }) }) + + +describe('Last.fm cross-window recovery', () => { + const connected = { available: true, connected: true, username: 'user', pending: false, reconnectRequired: false, problem: null } + it('refreshes the footer after connection changes in another window', async () => { + let account = connected + invokeMock.mockImplementation(async command => command === 'lastfm_state' ? account : null) + const view = await render() + expect(view.textContent).toContain('Sync now') + account = { ...connected, connected: false, reconnectRequired: true } + await emitNativeEvent('lastfm-changed', undefined) + expect(view.textContent).toContain('Reconnect Last.fm') + account = connected + await emitNativeEvent('lastfm-changed', undefined) + expect(view.textContent).toContain('Sync now') + expect(view.textContent).not.toContain('Reconnect Last.fm') + }) + + it('clears a transient status error after a successful poll', async () => { + vi.useFakeTimers() + let polls = 0 + invokeMock.mockImplementation(async command => { + if (command === 'lastfm_state') return connected + if (command === 'lastfm_sync_status') { + if (++polls === 1) throw 'Temporary status failure' + return idleLastFmImport({ syncing: true, syncProgress: { downloadedPages: 3, totalPages: 7 } }) + } + return null + }) + const view = await render() + expect(view.textContent).toContain('Temporary status failure') + await act(async () => vi.advanceTimersByTimeAsync(750)) + expect(view.textContent).not.toContain('Temporary status failure') + expect(view.textContent).toContain('Downloaded 3 of 7 pages') + }) + + it('ignores an old operation poll after the next sync starts', async () => { + const oldPoll = deferred() + const firstRun = deferred() + const secondRun = deferred() + let polls = 0 + let runs = 0 + invokeMock.mockImplementation(async command => { + if (command === 'lastfm_state') return connected + if (command === 'sync_lastfm_plays') return ++runs === 1 ? firstRun.promise : secondRun.promise + if (command === 'lastfm_sync_status') return ++polls === 1 ? oldPoll.promise : idleLastFmImport({ syncing: true, syncProgress: { downloadedPages: 4, totalPages: 8 } }) + return null + }) + const view = await render() + await clickText(view, 'Sync now') + await act(async () => firstRun.resolve(idleLastFmImport())) + await clickText(view, 'Sync now') + expect(view.textContent).toContain('Downloaded 4 of 8 pages') + await act(async () => oldPoll.resolve(idleLastFmImport({ syncing: true, syncProgress: { downloadedPages: 1, totalPages: 99 } }))) + expect(view.textContent).toContain('Downloaded 4 of 8 pages') + expect(polls).toBe(2) + await act(async () => secondRun.resolve(idleLastFmImport())) + }) + + it.each([false, true])('reconciles external scrobbling settings while preserving an intentional edit (edited=%s)', async edited => { + const save = vi.fn() + const props = { lastfm: connected, lastfmImport: idleLastFmImport(), onZoom: vi.fn(), onCancel: vi.fn(), onLastfm: vi.fn(), onImport: vi.fn(), onSave: save } + const view = await render() + await act(async () => view.querySelector('#preferences-library-tab')!.click()) + const checkbox = [...view.querySelectorAll('label')].find(label => label.textContent?.includes('Scrobble tracks to Last.fm'))!.querySelector('input')! + if (edited) await act(async () => { checkbox.click(); checkbox.click() }) + await act(async () => root!.render()) + expect(checkbox.checked).toBe(!edited) + await act(async () => view.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))) + expect(save).toHaveBeenCalledWith(expect.objectContaining({ lastfmScrobbling: !edited })) + }) + + it('clears recovered diagnostics load errors without clearing an email failure', async () => { + vi.useFakeTimers() + let loads = 0 + invokeMock.mockImplementation(async command => { + if (command === 'load_diagnostics') { + if (++loads === 1) throw 'temporary read failure' + return { emailAvailable: true, entries: [{ date: '2026-11-09', time: '12:00:00', level: 'ERROR', target: 'retune::diagnostics', message: 'Last.fm sync failed' }] } + } + if (command === 'email_diagnostics') throw 'mail unavailable' + return null + }) + const view = await render() + await act(async () => view.querySelector('#preferences-bug-tab')!.click()) + expect(view.textContent).toContain('Couldn’t load diagnostics') + await act(async () => vi.advanceTimersByTimeAsync(3000)) + expect(view.textContent).not.toContain('Couldn’t load diagnostics') + await clickText(view, 'Email…') + await act(async () => vi.advanceTimersByTimeAsync(3000)) + expect(view.textContent).toContain('Couldn’t email diagnostics') + }) +})