diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index ac3710c..1fa5ba4 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -891,8 +891,8 @@ fn install_file_menu(app: &tauri::AppHandle, settings: &Settings) -> tauri::Resu "sync_spotify" => { let handle = app.clone(); tauri::async_runtime::spawn(async move { - if let Err(error) = sync_spotify(&handle).await { - notify_error(&handle, error); + if sync_spotify(&handle).await.is_err() { + notify_spotify_sync_error(&handle); } }); } @@ -950,6 +950,10 @@ fn notify_error(app: &tauri::AppHandle, error: String) { let _ = emit_main_event(app, main_events::MainEvent::OperationError(error)); } +pub(crate) fn notify_spotify_sync_error(app: &tauri::AppHandle) { + let _ = emit_main_event(app, main_events::MainEvent::SpotifySyncError); +} + pub(crate) fn emit_main_event( app: &tauri::AppHandle, event: main_events::MainEvent, @@ -1364,7 +1368,11 @@ fn finish_startup( }, }; if let Err(error) = result { - notify_error(&handle, error); + if startup_action == StartupAction::Sync { + notify_spotify_sync_error(&handle); + } else { + notify_error(&handle, error); + } } }); } diff --git a/apps/desktop/src-tauri/src/main_events.rs b/apps/desktop/src-tauri/src/main_events.rs index 058d0e4..7c4270b 100644 --- a/apps/desktop/src-tauri/src/main_events.rs +++ b/apps/desktop/src-tauri/src/main_events.rs @@ -15,6 +15,7 @@ pub(crate) enum MainEvent { PlayerState(PlayerStateEvent), PlaybackAuthorizationRequired(PlaybackAuthorizationPrompt), OperationError(String), + SpotifySyncError, OperationRecovered, LocalImportComplete(ImportSummary), StartupNotice(String), @@ -29,6 +30,7 @@ impl MainEvent { Some(MainEventKind::PlaybackAuthorizationRequired) } Self::OperationError(_) => Some(MainEventKind::OperationError), + Self::SpotifySyncError => Some(MainEventKind::OperationError), Self::OperationRecovered => None, Self::LocalImportComplete(_) => Some(MainEventKind::LocalImportComplete), Self::StartupNotice(_) => Some(MainEventKind::StartupNotice), @@ -239,6 +241,10 @@ mod tests { serde_json::to_value(MainEvent::OperationRecovered).unwrap(), serde_json::json!({ "type": "operationRecovered" }) ); + assert_eq!( + serde_json::to_value(MainEvent::SpotifySyncError).unwrap(), + serde_json::json!({ "type": "spotifySyncError" }) + ); } #[test] @@ -374,11 +380,31 @@ mod tests { assert!(sink.0.lock().unwrap().pending.is_empty()); } + #[test] + fn spotify_sync_error_uses_operation_error_retention() { + let sink = MainEventSink::default(); + sink.send(MainEvent::OperationError("stale".into())) + .unwrap(); + sink.send(MainEvent::SpotifySyncError).unwrap(); + assert_eq!( + sink.0.lock().unwrap().pending, + vec![MainEvent::SpotifySyncError] + ); + + sink.send(MainEvent::OperationError("latest".into())) + .unwrap(); + assert_eq!( + sink.0.lock().unwrap().pending, + vec![MainEvent::OperationError("latest".into())] + ); + } + #[test] fn recovery_clears_an_error_that_was_never_observed() { let sink = MainEventSink::default(); sink.send(MainEvent::OperationError("temporary".into())) .unwrap(); + sink.send(MainEvent::SpotifySyncError).unwrap(); sink.send(MainEvent::OperationRecovered).unwrap(); diff --git a/apps/desktop/src-tauri/src/spotify_commands.rs b/apps/desktop/src-tauri/src/spotify_commands.rs index e9dfb6a..ed6fa9e 100644 --- a/apps/desktop/src-tauri/src/spotify_commands.rs +++ b/apps/desktop/src-tauri/src/spotify_commands.rs @@ -12,7 +12,7 @@ use crate::{ album_id, auth, emit_connection_state_async, emit_main, emit_main_event, empty_player_state, image_url, image_url_at_least, library_commands::{rating_view, RatingView}, - main_events, notify_error, playlist_commands, + main_events, playlist_commands, provider::{ self, artist_albums_page, artist_descriptor, title_case, ArtistAlbumsPage, SearchGroup, SearchResults, SpotifySyncProvider, SyncBatch, @@ -233,6 +233,15 @@ pub(super) async fn sync_spotify(app: &tauri::AppHandle) -> Result<(), String> { run_sync_loop(app, run).await } +const SPOTIFY_SYNC_ERROR: &str = "Retune couldn’t sync Spotify; your cached library is unchanged. The network may be blocking Spotify. Try another network or VPN, then sync again."; +const SPOTIFY_SYNC_FAILURE_LOG_CATEGORY: &str = "spotify_sync_failed"; + +fn translate_spotify_sync_result(result: Result) -> Result<(), String> { + result + .map(|_| ()) + .map_err(|_| SPOTIFY_SYNC_ERROR.to_string()) +} + async fn run_sync_loop( app: &tauri::AppHandle, mut run: crate::sync_orchestrator::SyncRun, @@ -260,7 +269,10 @@ async fn run_sync_loop( } else if let Some(deadline) = fallback_deadline { schedule_auto_resume(app, deadline); } - return result.map(|_| ()); + if result.is_err() { + log::error!("Spotify sync failed ({SPOTIFY_SYNC_FAILURE_LOG_CATEGORY})"); + } + return translate_spotify_sync_result(result); } } @@ -324,8 +336,8 @@ pub(super) fn schedule_auto_resume(app: &tauri::AppHandle, deadline: u64) { state.sync_orchestrator.retry_fired(); if let Some(run) = state.sync_orchestrator.begin() { let _ = emit_spotify_sync_status(&handle); - if let Err(error) = Box::pin(run_sync_loop(&handle, run)).await { - notify_error(&handle, error); + if Box::pin(run_sync_loop(&handle, run)).await.is_err() { + crate::notify_spotify_sync_error(&handle); } } }); @@ -1947,8 +1959,8 @@ mod tests { use super::{ album_page_view, commit_sync_state, normalize_sync_baseline, playback_credentials, rating_view, rebase_sync_membership, remember_playback_profile_id, - required_artist_follow_state, validate_spotify_track_uris, web_oauth_tokens, - SpotifySession, + required_artist_follow_state, translate_spotify_sync_result, validate_spotify_track_uris, + web_oauth_tokens, SpotifySession, SPOTIFY_SYNC_ERROR, SPOTIFY_SYNC_FAILURE_LOG_CATEGORY, }; use crate::{ library_state::LibraryState, @@ -2149,6 +2161,19 @@ mod tests { assert!(error.contains("offline")); } + #[test] + fn spotify_sync_result_translation_is_safe_and_actionable() { + assert!(translate_spotify_sync_result::<()>(Ok(())).is_ok()); + + let canary = "response-body-access-token"; + let error = translate_spotify_sync_result::<()>(Err(canary.to_owned())).unwrap_err(); + + assert_eq!(error, SPOTIFY_SYNC_ERROR); + let log_message = format!("Spotify sync failed ({SPOTIFY_SYNC_FAILURE_LOG_CATEGORY})"); + assert!(!log_message.contains(canary)); + assert!(!SPOTIFY_SYNC_FAILURE_LOG_CATEGORY.contains(canary)); + } + #[test] fn web_oauth_replacement_requires_playback_reauthorization() { let tokens = web_oauth_tokens( diff --git a/apps/desktop/src/App.css b/apps/desktop/src/App.css index d944598..80184ac 100644 --- a/apps/desktop/src/App.css +++ b/apps/desktop/src/App.css @@ -219,7 +219,11 @@ button { color: inherit; } .spotify-result-actions button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } .spotify-result-actions button:disabled { opacity: .55; } .spotify-search-loading, .spotify-search-error, .spotify-search-more { display: flex; align-items: center; gap: 8px; padding: 6px 12px; color: var(--dim); } +.spotify-search-error { align-items: flex-start; } +.spotify-search-error .error-notice-copy { display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: baseline; } .spotify-search-error button, .spotify-search-more button { padding: 3px 9px; border: 1px solid var(--border); border-radius: 12px; color: var(--text); background: var(--control); } +.spotify-search-error .error-notice-actions { align-items: center; } +.spotify-search-error .error-notice-dismiss { padding: 0 4px; border: 0; color: var(--dim); background: transparent; font-size: 16px; } .spotify-page { min-height: 0; flex: 1; display: flex; flex-direction: column; overflow-y: auto; background: var(--list); } .spotify-page-back { width: 100%; padding: 9px 16px; border: 0; border-bottom: 1px solid var(--border); color: var(--accent); background: var(--list); text-align: left; font-size: 12px; } .spotify-page-header { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 13px; padding: 13px 15px 17px; border-bottom: 1px solid var(--border); } @@ -274,7 +278,18 @@ button.spotify-page-art:focus-visible { outline: 2px solid var(--accent); outlin .status-separator { flex: none; color: var(--dim); } .sync-status { display: flex; align-items: center; justify-content: center; gap: 8px; } .sync-meter { width: 120px; height: 5px; accent-color: var(--accent); } -.error-banner { position: absolute; right: 10px; bottom: 30px; max-width: 430px; padding: 5px 8px; border: 1px solid #b33; color: #fff; background: #8d2d2d; } +.error-notice { display: flex; align-items: flex-start; gap: 10px; } +.error-notice-copy { min-width: 0; flex: 1; } +.error-notice-actions { display: flex; align-items: flex-start; flex: none; gap: 6px; } +.error-notice-dismiss { min-width: 22px; padding: 0 4px; border: 0; color: inherit; background: transparent; font-size: 18px; line-height: 1.1; } +.error-notice-dismiss::before { content: '×'; } +.error-banner { position: absolute; right: 10px; bottom: 30px; max-width: 430px; padding: 7px 8px; border: 1px solid #b33; color: #fff; background: #8d2d2d; } +.error-banner .error-notice-dismiss { color: #fff; } +.spotify-page-error { margin: 12px; padding: 10px 12px; border: 1px solid #d66; border-radius: 5px; color: var(--text); background: color-mix(in srgb, #f88 12%, var(--list)); } +.spotify-page-error .error-notice-copy { display: flex; flex-direction: column; gap: 4px; } +.spotify-page-error .error-notice-copy small { color: var(--dim); } +.spotify-page-error button { padding: 4px 11px; border: 1px solid var(--border); border-radius: 12px; color: var(--text); background: var(--control); } +.spotify-page-error .error-notice-dismiss { padding: 0 4px; border: 0; background: transparent; } .dialog-error { color: var(--danger); } .startup-notice { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--notice-border); color: var(--notice-text); background: var(--notice-bg); } .startup-notice button { border: 0; color: inherit; background: transparent; font-size: 16px; } diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index acd47c6..7500ee2 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -8,7 +8,7 @@ import { GetInfo, MultipleItemInformation, PlaybackAuthorization, Preferences, S import { AlbumRatingStrip, BrowserPane, TrackCell, TrackContextMenu, TrackList } from './libraryViews.tsx' import { SpotifyPageBack, SpotifySearch } from './spotifyViews.tsx' import type { ActivePane, BrowseView, BrowserPanes, ColumnKey, LastFmImportState, PlaybackOrigin, PlaybackTrack, Playing, PlaylistListView, PlaylistSubject, PlaylistTrack, RepeatMode, Selection, SettingsPatch, Source, SpotifySyncStatus, Theme, Track } from './types.ts' -import { ArtworkLightbox, CheckboxMenu, ContextMenu, ModalDialog } from './viewShared.tsx' +import { ArtworkLightbox, CheckboxMenu, ContextMenu, ErrorNotice, ModalDialog } from './viewShared.tsx' import { importDownloadPercent, importDownloadProgressLabel, importStatusText } from './lastfmImportState.ts' import { libraryEvents, libraryGateway } from './libraryGateway.ts' import { playbackEvents, playbackGateway } from './playbackGateway.ts' @@ -21,6 +21,9 @@ import { lastfmGateway } from './lastfmGateway.ts' import { RemoveTrackDialog, TrackMergeDialog } from './trackDecisionDialogs.tsx' const LOCAL_PLAYLIST_HINT = "Selection includes local files — Spotify playlists can't contain them." +const SPOTIFY_SYNC_ERROR = 'Retune couldn’t sync Spotify; your cached library is unchanged. The network may be blocking Spotify. Try another network or VPN, then sync again.' +const SPOTIFY_SEARCH_ERROR = 'Couldn’t search Spotify. Existing results are unchanged. Try another network or VPN, then search again.' +const INFRASTRUCTURE_ERROR = 'Retune couldn’t refresh the app state. Your current view is unchanged. Try again.' const emptyTracks: Track[] = [] const ZOOM_MIN = 0.7 @@ -94,20 +97,20 @@ function usePlayer(connected: boolean, playbackAuthorized: boolean, playing: Pla dispatch({ type: 'playbackAuthorization', prompt }) }, [dispatch]) - const run = useCallback((request: () => Promise) => { - request().catch((error) => dispatch({ type: 'error', error: String(error) })) + const run = useCallback((request: () => Promise, message: string) => { + request().catch(() => dispatch({ type: 'error', error: message })) }, [dispatch]) const start = useCallback((id: number, tracks: readonly PlaybackTrack[], launchOrigin?: PlaybackOrigin) => { const target = tracks.find((track) => track.id === id) if (!target || (!target.uri.startsWith('file:') && !target.uri.startsWith('spotify:'))) { - dispatch({ type: 'error', error: `Unsupported playback URI: ${target?.uri ?? 'unknown'}` }) + dispatch({ type: 'error', error: 'This track can’t be played because its source isn’t supported.' }) return } const playable = playbackQueue(tracks, id) const targetIndex = playable.findIndex((track) => track.id === id) if (targetIndex < 0) { - dispatch({ type: 'error', error: `Unsupported playback URI: ${target.uri}` }) + dispatch({ type: 'error', error: 'This track can’t be played because its source isn’t supported.' }) return } const request = beginRequestGeneration(playGeneration) @@ -119,7 +122,7 @@ function usePlayer(connected: boolean, playbackAuthorized: boolean, playing: Pla // Kick off the OAuth flow instead of erroring; the pending play fires // once connection-changed reports connected. pendingPlay.current = { id, tracks: playable, origin: launchOrigin, awaitingPlaybackAuthorization: false } - run(spotifyGateway.connect) + run(spotifyGateway.connect, 'Couldn’t connect to Spotify. Try again.') return } playbackGateway.play(playable, targetIndex) @@ -129,8 +132,8 @@ function usePlayer(connected: boolean, playbackAuthorized: boolean, playing: Pla pendingPlay.current = { id: authorization.id, tracks: playable, origin: launchOrigin, awaitingPlaybackAuthorization: true } dispatch({ type: 'playbackAuthorization', prompt: authorization.prompt }) }) - .catch((error) => { - if (isCurrentRequestGeneration(request, playGeneration)) dispatch({ type: 'error', error: String(error) }) + .catch(() => { + if (isCurrentRequestGeneration(request, playGeneration)) dispatch({ type: 'error', error: 'Couldn’t start playback. Playback is unchanged. Try again.' }) }) }, [connected, dispatch, run]) @@ -150,7 +153,7 @@ function usePlayer(connected: boolean, playbackAuthorized: boolean, playing: Pla const toggle = useCallback(() => { if (!playingRef.current || playingRef.current.external) return - run(playbackGateway.toggle) + run(playbackGateway.toggle, 'Couldn’t change playback. Try again.') }, [run]) const playRequestedTrack = useCallback((track: SpotifyPlayRequest) => { @@ -164,17 +167,17 @@ function usePlayer(connected: boolean, playbackAuthorized: boolean, playing: Pla const step = useCallback((direction: number) => { const current = playingRef.current if (!current || current.external) return - run(direction < 0 ? playbackGateway.previous : playbackGateway.next) + run(direction < 0 ? playbackGateway.previous : playbackGateway.next, `Couldn’t skip to the ${direction < 0 ? 'previous' : 'next'} track. Playback is unchanged. Try again.`) }, [run]) const setVolume = useCallback((volume: number) => { window.clearTimeout(volumeTimer.current) - volumeTimer.current = window.setTimeout(() => run(() => playbackGateway.setVolume(volume)), 150) + volumeTimer.current = window.setTimeout(() => run(() => playbackGateway.setVolume(volume), 'Couldn’t change the volume. Playback is unchanged. Try again.'), 150) }, [run]) const seek = useCallback((seconds: number) => { if (!playingRef.current || playingRef.current.external) return - run(() => playbackGateway.seek(seconds)) + run(() => playbackGateway.seek(seconds), 'Couldn’t seek in this track. Playback is unchanged. Try again.') }, [run]) useEffect(() => () => window.clearTimeout(volumeTimer.current), []) @@ -187,13 +190,16 @@ function usePlayer(connected: boolean, playbackAuthorized: boolean, playing: Pla function App() { const [state, dispatch] = useReducer(reducer, initialState) - const fail = useCallback((error: unknown) => dispatch({ type: 'error', error: String(error) }), []) + const reportInfrastructureError = useCallback(() => dispatch({ type: 'error', error: INFRASTRUCTURE_ERROR }), []) + const reportMainOperationError = useCallback(() => dispatch({ type: 'error', error: INFRASTRUCTURE_ERROR }), []) + const reportSpotifySyncError = useCallback(() => dispatch({ type: 'error', error: SPOTIFY_SYNC_ERROR }), []) const [nativeDragActive, setNativeDragActive] = useState(false) const [activePane, setActivePane] = useState('track') const [playlists, setPlaylists] = useState() const [playlistSubject, setPlaylistSubject] = useState() const [artworkOpen, setArtworkOpen] = useState(false) const [trackDecision, setTrackDecision] = useState<{ kind: 'merge'; ids: number[] } | { kind: 'remove'; tracks: Pick[]; spotify: boolean }>() + const [spotifySearchRetry, setSpotifySearchRetry] = useState(0) const [playingMenu, setPlayingMenu] = useState<{ x: number; y: number; uri: string; name: string; trackId?: number }>() const [browserPlayKey, setBrowserPlayKey] = useState() const search = useRef(null) @@ -223,14 +229,15 @@ function App() { spotifyPlayRequested: player.playRequestedTrack, playerState: player.onState, playbackAuthorizationRequired: player.onAuthorizationRequired, - operationError: (error) => dispatch({ type: 'error', error }), + operationError: reportMainOperationError, + spotifySyncError: reportSpotifySyncError, operationRecovered: () => dispatch({ type: 'clear-error' }), localImportComplete: (summary) => dispatch({ type: 'importComplete', summary }), startupNotice: (notice) => dispatch({ type: 'notice', notice }), })) const persistSettings = useCallback((patch: SettingsPatch) => { - appGateway.updateSettings(patch).catch(fail) - }, [fail]) + appGateway.updateSettings(patch).catch(() => dispatch({ type: 'error', error: 'Couldn’t save settings. Try again.' })) + }, []) const updateSettings = useCallback((patch: SettingsPatch) => { dispatch({ type: 'settings', settings: patch }) persistSettings(patch) @@ -284,7 +291,7 @@ function App() { void loadCurrentGeneration(infoGeneration, () => libraryGateway.getTrack(id), (track) => dispatch({ type: 'info', info: { kind: 'single', track } }), - fail) + () => dispatch({ type: 'error', error: 'Couldn’t load track details. Your library is unchanged. Try again.' })) } const openInfo = (id?: number) => { cancelTrackInfoLoad(infoGeneration) @@ -311,10 +318,10 @@ function App() { const fallback = staleSelectionFacet(request.sel, next.facets) if (fallback) selectFacet(fallback, []) else dispatch({ type: 'view', view: next, key: request.key }) - }).catch((error) => { + }).catch(() => { if (!browseMounted.current || browseDesiredToken.current !== request.token) return dispatch({ type: 'browsePending', pending: false }) - fail(error) + dispatch({ type: 'error', error: 'Couldn’t load your library. Existing rows are unchanged. Try again.' }) }).finally(() => { browseInFlight.current = false drainBrowse() @@ -342,9 +349,9 @@ function App() { let active = true spotifyGateway.playlists() .then((rows) => active && setPlaylists(rows)) - .catch((error) => active && fail(error)) + .catch(() => active && dispatch({ type: 'error', error: 'Couldn’t load Spotify playlists. Existing playlists are unchanged. Try again.' })) return () => { active = false } - }, [state.playlistRevision, fail]) + }, [state.playlistRevision]) useEffect(() => { if (playlists && state.selectedPlaylist && !playlists.some((playlist) => playlist.id === state.selectedPlaylist)) { @@ -353,32 +360,32 @@ function App() { }, [playlists, state.selectedPlaylist]) useTauriInvalidationSnapshot('settings-changed', appGateway.settings, - (settings) => dispatch({ type: 'hydrateSettings', settings }), fail) + (settings) => dispatch({ type: 'hydrateSettings', settings }), reportInfrastructureError) useTauriInvalidationSnapshot(spotifyEvents.connectionChanged, spotifyGateway.connectionState, - (connection) => dispatch({ type: 'connection', connection }), fail) + (connection) => dispatch({ type: 'connection', connection }), reportInfrastructureError) useTauriInvalidationSnapshot(spotifyEvents.syncStatusChanged, spotifyGateway.syncStatus, - (status) => dispatch({ type: 'spotifySyncStatus', status }), fail) + (status) => dispatch({ type: 'spotifySyncStatus', status }), reportInfrastructureError) useTauriInvalidationSnapshot('lastfm-changed', lastfmGateway.accountState, - (lastfm) => dispatch({ type: 'lastfm', lastfm }), fail) + (lastfm) => dispatch({ type: 'lastfm', lastfm }), reportInfrastructureError) useTauriInvalidationSnapshot('lastfm-import-changed', lastfmGateway.state, (lastfmImport) => { dispatch({ type: 'lastfmImport', lastfmImport }) - spotifyGateway.syncStatus().then((status) => dispatch({ type: 'spotifySyncStatus', status })).catch(fail) - }, fail) + spotifyGateway.syncStatus().then((status) => dispatch({ type: 'spotifySyncStatus', status })).catch(reportInfrastructureError) + }, reportInfrastructureError) useEffect(() => subscribeMainEvents( handleMainEvent, - fail, - ), [fail]) + reportInfrastructureError, + ), [reportInfrastructureError]) - useTauriEvent('get-info', () => openInfo(), fail) - useTauriEvent(libraryEvents.changed, () => dispatch({ type: 'refresh' }), fail) - useTauriEvent(spotifyEvents.syncProgress, (phase) => dispatch({ type: 'syncPhase', phase: phase || undefined }), fail) - useTauriEvent<{ tracks: number; fraction: number }>(spotifyEvents.syncProgressCount, (progress) => dispatch({ type: 'syncProgress', progress }), fail) - useTauriEvent(spotifyEvents.playlistsChanged, () => dispatch({ type: 'playlistsRefresh' }), fail) - useTauriEvent(libraryEvents.localImportStarted, () => dispatch({ type: 'importStarted' }), fail) - useTauriEvent(libraryEvents.localImportFailed, () => dispatch({ type: 'importFailed' }), fail) - useTauriEvent(libraryEvents.localDragChanged, setNativeDragActive, fail) + useTauriEvent('get-info', () => openInfo(), reportInfrastructureError) + useTauriEvent(libraryEvents.changed, () => dispatch({ type: 'refresh' }), reportInfrastructureError) + useTauriEvent(spotifyEvents.syncProgress, (phase) => dispatch({ type: 'syncPhase', phase: phase || undefined }), reportInfrastructureError) + useTauriEvent<{ tracks: number; fraction: number }>(spotifyEvents.syncProgressCount, (progress) => dispatch({ type: 'syncProgress', progress }), reportInfrastructureError) + useTauriEvent(spotifyEvents.playlistsChanged, () => dispatch({ type: 'playlistsRefresh' }), reportInfrastructureError) + useTauriEvent(libraryEvents.localImportStarted, () => dispatch({ type: 'importStarted' }), reportInfrastructureError) + useTauriEvent(libraryEvents.localImportFailed, () => dispatch({ type: 'importFailed' }), reportInfrastructureError) + useTauriEvent(libraryEvents.localDragChanged, setNativeDragActive, reportInfrastructureError) useEffect(() => { if (!state.importStatus || state.importStatus === 'Importing local files…') return @@ -397,17 +404,23 @@ function App() { const timer = window.setTimeout(() => { spotifyGateway.search(query, 0) .then((results) => active && dispatch({ type: 'spotifyResults', results })) - .catch((error) => { + .catch(() => { if (!active) return - dispatch({ type: 'spotifySearching', searching: false }) - fail(error) + dispatch({ type: 'spotifySearchError', error: SPOTIFY_SEARCH_ERROR }) }) }, 300) return () => { active = false window.clearTimeout(timer) } - }, [state.scope, state.query, state.connection.connected, fail]) + }, [state.scope, state.query, state.connection.connected, spotifySearchRetry]) + + const retrySpotifySearch = useCallback(() => { + const query = state.query.trim() + if (!query || !state.connection.connected) return + dispatch({ type: 'spotifySearchError' }) + setSpotifySearchRetry((current) => current + 1) + }, [state.connection.connected, state.query]) useEffect(() => { const media = window.matchMedia('(prefers-color-scheme: dark)') @@ -425,28 +438,35 @@ function App() { useEffect(() => { const title = state.source === 'music' ? 'Retune — Library' : `Retune — ${labels[state.source].name}` - getCurrentWindow().setTitle(title).catch(fail) - }, [state.source, fail]) + getCurrentWindow().setTitle(title).catch(reportInfrastructureError) + }, [reportInfrastructureError, state.source]) const mutate = (mutation: () => Promise) => { mutation() .then(() => dispatch({ type: 'refresh' })) - .catch(fail) + .catch(() => dispatch({ type: 'error', error: 'Couldn’t update track playback inclusion. Your library is unchanged. Try again.' })) } const rate = (key: string, mutation: () => Promise) => { - void loadCurrentGeneration(entityRequestGeneration(ratingGenerations.current, key), mutation, () => dispatch({ type: 'refresh' }), fail) + void loadCurrentGeneration(entityRequestGeneration(ratingGenerations.current, key), mutation, () => dispatch({ type: 'refresh' }), () => dispatch({ type: 'error', error: 'Couldn’t update the rating. Your library is unchanged. Try again.' })) } const navigateSpotify = (track: Pick, destination: 'album' | 'artist') => spotifyGateway.resolveTrackDestination(track.uri, destination) .then((entry) => dispatch({ type: 'spotifyNavigate', entry })) - .catch(fail) + .catch(() => dispatch({ type: 'error', error: `Couldn’t open this Spotify ${destination}. Your library is unchanged. Try again.` })) const navigateFacetSpotify = async (facet: 'art' | 'alb', value: string) => { + let track: Track | undefined try { const selection = selectionAfterFacet(state.sel, facet, [value]) const result = await libraryGateway.browse(state.source, selection, state.query.trim() || undefined) - const track = result.tracks.find((track) => track.uri.startsWith('spotify:track:')) - if (!track) throw new Error(`This ${facet === 'alb' ? 'album' : 'artist'} has no Spotify tracks to open.`) - await navigateSpotify(track, facet === 'alb' ? 'album' : 'artist') - } catch (error) { fail(error) } + track = result.tracks.find((candidate) => candidate.uri.startsWith('spotify:track:')) + } catch { + dispatch({ type: 'error', error: `Couldn’t open this Spotify ${facet === 'alb' ? 'album' : 'artist'}. Your library is unchanged. Try again.` }) + return + } + if (!track) { + dispatch({ type: 'error', error: `This ${facet === 'alb' ? 'album' : 'artist'} has no Spotify tracks to open.` }) + return + } + void navigateSpotify(track, facet === 'alb' ? 'album' : 'artist') } const setZoom = useCallback((zoom: number) => { updateSettings({ zoom: normalizeZoom(zoom, ZOOM_MIN, ZOOM_MAX) }) @@ -456,16 +476,17 @@ function App() { dispatch({ type: 'preferences', open: true }) } const openLastfmImporter = () => { - appGateway.openLastFmImporter().catch(fail) + appGateway.openLastFmImporter().catch(() => dispatch({ type: 'error', error: 'Couldn’t open the Last.fm importer. Try again.' })) } const syncLastfm = () => { lastfmGateway.syncPlays() .then((lastfmImport) => dispatch({ type: 'lastfmImport', lastfmImport })) - .catch(fail) + .catch(() => dispatch({ type: 'error', error: 'Couldn’t sync Last.fm. Existing import state is unchanged. Try again.' })) } const syncSpotify = useCallback(() => { - spotifyGateway.sync().catch(fail) - }, [fail]) + dispatch({ type: 'clear-error' }) + spotifyGateway.sync().catch(() => dispatch({ type: 'error', error: SPOTIFY_SYNC_ERROR })) + }, []) const cancelPreferences = () => { dispatch({ type: 'settings', settings: { zoom: preferenceZoom.current } }) dispatch({ type: 'preferences', open: false }) @@ -538,13 +559,13 @@ function App() { else if (payload === 'toggle_zebra') updateSettings({ zebra: !state.settings.zebra }) else if (payload === 'toggle_browser') toggleBrowser() else if (payload.startsWith('theme_')) updateSettings({ theme: payload.slice(6) as Theme }) - }, fail) + }, reportInfrastructureError) useTauriEvent(playbackEvents.action, (payload) => { if (payload === 'play_pause') player.toggle() else player.step(payload === 'previous' ? -1 : 1) - }, fail) - useTauriEvent('open-preferences', openPreferences, fail) - useTauriEvent('open-setup', () => dispatch({ type: 'setup', open: true }), fail) + }, reportInfrastructureError) + useTauriEvent('open-preferences', openPreferences, reportInfrastructureError) + useTauriEvent('open-setup', () => dispatch({ type: 'setup', open: true }), reportInfrastructureError) const onKeyDown = useEffectEvent((event: KeyboardEvent) => { const modalOpen = Boolean(state.info || state.preferences || state.setup || state.playbackAuthorization || playlistSubject) @@ -684,9 +705,9 @@ function App() { onPlaylist={(id) => dispatch({ type: 'playlist', id })} onReorder={setPlaylists} onCollapse={() => updateSettings({ plCollapsed: !state.settings.plCollapsed })} - onShuffle={(shuffle) => playbackGateway.setShuffle(shuffle).then(() => dispatch({ type: 'settings', settings: { shuffle } })).catch(fail)} - onRepeat={(repeat) => playbackGateway.setRepeat(repeat).then(() => dispatch({ type: 'settings', settings: { repeat } })).catch(fail)} - onDrop={(id, subject) => addToPlaylist(id, subject).catch(fail)} + onShuffle={(shuffle) => playbackGateway.setShuffle(shuffle).then(() => dispatch({ type: 'settings', settings: { shuffle } })).catch(() => dispatch({ type: 'error', error: 'Couldn’t change shuffle. Playback is unchanged. Try again.' }))} + onRepeat={(repeat) => playbackGateway.setRepeat(repeat).then(() => dispatch({ type: 'settings', settings: { repeat } })).catch(() => dispatch({ type: 'error', error: 'Couldn’t change repeat mode. Playback is unchanged. Try again.' }))} + onDrop={(id, subject) => addToPlaylist(id, subject).catch(() => dispatch({ type: 'error', error: 'Couldn’t confirm adding this selection to the playlist. Refresh Spotify and try again.' }))} onError={(error) => dispatch({ type: 'error', error })} artwork={artworkOpen && state.playing?.uri ? : undefined} />
- {state.connection.needs_reauth &&
Spotify needs to be reconnected to enable playlists.
} + {state.connection.needs_reauth &&
Spotify needs to be reconnected to enable playlists.
} {spotifySearchActive ? ( !state.connectionHydrated ?
Checking Spotify connection…
: state.connection.connected ? spotifyGateway.addAlbum(album) - .catch((error) => { fail(error); throw error })} + .catch((error) => { dispatch({ type: 'error', error: 'Couldn’t confirm adding this album to Retune. Refresh your library and try again.' }); throw error })} onAddTrack={(uri) => spotifyGateway.addTrack(uri) - .catch((error) => { fail(error); throw error })} + .catch((error) => { dispatch({ type: 'error', error: 'Couldn’t confirm adding this track to Retune. Refresh your library and try again.' }); throw error })} onRemoveTrack={(uri) => spotifyGateway.removeTrack(uri) - .catch((error) => { fail(error); throw error })} + .catch((error) => { dispatch({ type: 'error', error: 'Couldn’t confirm removing this track from Retune. Refresh your library and try again.' }); throw error })} onPlay={player.start} onPlaylist={setPlaylistSubject} onClose={() => dispatch({ type: 'scope', scope: 'library' })} + onRetrySearch={retrySpotifySearch} + onDismissSearch={() => dispatch({ type: 'spotifySearchError' })} onError={(error) => dispatch({ type: 'error', error })} - /> :
Connect to Spotify to search artists and albums.
+ /> :
Connect to Spotify to search artists and albums.
) : selectedPlaylist ? dispatch({ type: 'playlist' })} onPlay={(id, tracks) => player.start(id, tracks, { kind: 'playlist', id: selectedPlaylist.id })} onRate={(id, stars) => rate(`track:${id}`, () => libraryGateway.clickTrackStar(id, stars))} - onOpen={(target) => spotifyGateway.openPlaylist(selectedPlaylist.id, target).catch(fail)} + onOpen={(target) => spotifyGateway.openPlaylist(selectedPlaylist.id, target).catch(() => dispatch({ type: 'error', error: 'Couldn’t open this playlist in Spotify. Try again.' }))} onPlaylist={setPlaylistSubject} onInfo={(tracks) => { if (tracks.length > 1 || tracks[0]?.id === null) { @@ -813,7 +837,7 @@ function App() { /> )} - {state.error &&
{state.error}
} + {state.error && dispatch({ type: 'clear-error' })}>{state.error}}
@@ -829,12 +853,12 @@ function App() { {trackDecision?.kind === 'remove' && setTrackDecision(undefined)} onChanged={() => { dispatch({ type: 'refresh' }); dispatch({ type: 'selection', ids: new Set() }) }} />} {state.setup && dispatch({ type: 'setup', open: false })} onConnect={(clientId) => saveSetupClientId(clientId) .then(spotifyGateway.connect) - .catch(fail)} onSync={(clientId) => saveSetupClientId(clientId) + .catch(() => dispatch({ type: 'error', error: 'Couldn’t connect to Spotify. Try again.' }))} onSync={(clientId) => saveSetupClientId(clientId) .then(() => { dispatch({ type: 'setup', open: false }) return spotifyGateway.sync() }) - .catch(fail)} />} + .catch(() => dispatch({ type: 'error', error: SPOTIFY_SYNC_ERROR }))} />} {state.preferences && dispatch({ type: 'settings', settings: { zoom } })} onCancel={cancelPreferences} onLastfm={(lastfm) => dispatch({ type: 'lastfm', lastfm })} onImport={openLastfmImporter} onSyncLastfm={syncLastfm} onLibraryChanged={() => dispatch({ type: 'refresh' })} onSave={({ browserPanes, ...settings }) => { const patch = { ...settings, browserPanes, zoom: state.settings.zoom } dispatch({ type: 'settings', settings }) @@ -1035,8 +1059,8 @@ function Sidebar({ state, playlists, onSource, onPlaylist, onReorder, onCollapse await spotifyGateway.createPlaylist(name) setName('') setCreating(false) - } catch (error) { - onError(String(error)) + } catch { + onError('Couldn’t confirm playlist creation. Refresh Spotify playlists and try again.') } finally { createPending.current.delete('create') setCreateBusy(false) @@ -1049,7 +1073,7 @@ function Sidebar({ state, playlists, onSource, onPlaylist, onReorder, onCollapse setInsertBefore(undefined) onReorder(reordered) try { await spotifyGateway.reorderPlaylists(ids) } - catch (error) { onReorder(playlists); onError(String(error)) } + catch { onReorder(playlists); onError('Couldn’t confirm playlist order. Refresh Spotify playlists and try again.') } } const cancelPlaylistDrag = () => { playlistDrag.current = undefined @@ -1063,8 +1087,8 @@ function Sidebar({ state, playlists, onSource, onPlaylist, onReorder, onCollapse try { await spotifyGateway.unfollowPlaylist(confirming.id) setConfirming(undefined) - } catch (error) { - onError(String(error)) + } catch { + onError('Couldn’t confirm unfollowing this playlist. Refresh Spotify playlists and try again.') } finally { setBusy(false) } @@ -1142,7 +1166,7 @@ function Sidebar({ state, playlists, onSource, onPlaylist, onReorder, onCollapse if (hasLocalTracks(subject)) onError(LOCAL_PLAYLIST_HINT) else onDrop(playlist.id, subject) } - catch { onError('Could not read the dragged playlist item.') } + catch { onError('Couldn’t read the dragged playlist item. Nothing was changed. Try again.') } }} onContextMenu={(event) => { event.preventDefault(); setMenu({ x: event.clientX, y: event.clientY, playlist }) }} > @@ -1211,10 +1235,10 @@ function PlaylistView({ playlist, backLabel, revision, libraryRevision, playing, let active = true spotifyGateway.playlistTracks(playlist.id) .then((rows) => active && setTrackState((current) => resolvedPlaylistRows(current, playlist.id, rows))) - .catch((error) => { + .catch(() => { if (!active) return setTrackState((current) => failedPlaylistRows(current, playlist.id)) - onErrorRef.current(String(error)) + onErrorRef.current('Couldn’t load this playlist. Existing tracks are unchanged. Try again.') }) return () => { active = false } }, [playlist.id, playlist.itemsAvailable, revision, libraryRevision]) @@ -1321,8 +1345,8 @@ function PlaylistView({ playlist, backLabel, revision, libraryRevision, playing, setInsertBefore(undefined) try { await spotifyGateway.reorderPlaylist(playlist.id, range.start, index, range.length) - } catch (error) { - onError(String(error)) + } catch { + onError('Couldn’t confirm track order in this playlist. Refresh Spotify and try again.') } finally { setSelected(new Set()) setSelectionAnchor(undefined) @@ -1340,8 +1364,8 @@ function PlaylistView({ playlist, backLabel, revision, libraryRevision, playing, setMutating(true) try { await spotifyGateway.removeFromPlaylist(playlist.id, indices) - } catch (error) { - onError(String(error)) + } catch { + onError('Couldn’t confirm removing tracks from this playlist. Refresh Spotify and try again.') } finally { setSelected(new Set()) setSelectionAnchor(undefined) @@ -1355,8 +1379,8 @@ function PlaylistView({ playlist, backLabel, revision, libraryRevision, playing, setMutating(true) try { await spotifyGateway.addTracks(uris) - } catch (error) { - onError(String(error)) + } catch { + onError('Couldn’t confirm adding these tracks to Retune. Refresh your library and try again.') } finally { setMutating(false) } @@ -1523,14 +1547,14 @@ function AddToPlaylist({ subject, revision, onAdd, onClose, onError }: { let active = true spotifyGateway.playlists(subject.kind === 'tracks' ? subject.uris : undefined) .then((rows) => active && setPlaylists(rows)) - .catch((error) => active && reportError(String(error))) + .catch(() => active && reportError('Couldn’t load your playlists. Existing playlists are unchanged. Try again.')) return () => { active = false } }, [revision, subject]) const add = async (id: string) => { if (!beginPendingEntity(pending.current, id)) return setBusy(pendingEntities(pending.current)) try { await onAdd(id, subject) } - catch (error) { onError(String(error)) } + catch { onError('Couldn’t confirm adding this selection to the playlist. Refresh Spotify and try again.') } finally { pending.current.delete(id); setBusy(pendingEntities(pending.current)) } } const create = async () => { @@ -1541,8 +1565,8 @@ function AddToPlaylist({ subject, revision, onAdd, onClose, onError }: { await onAdd(playlist.id, subject) setName('') setCreating(false) - } catch (error) { - onError(String(error)) + } catch { + onError('Couldn’t confirm creating the playlist or adding this selection. Refresh Spotify and try again.') } finally { pending.current.delete('new') setBusy(pendingEntities(pending.current)) diff --git a/apps/desktop/src/LastFmImporter.tsx b/apps/desktop/src/LastFmImporter.tsx index 283ccf0..630e53f 100644 --- a/apps/desktop/src/LastFmImporter.tsx +++ b/apps/desktop/src/LastFmImporter.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, typ import { flushSync } from 'react-dom' import { AutocompleteInput } from './dialogViews.tsx' import { SpotifyAlbumPresentation, type SpotifyAlbumPresentationData } from './spotifyViews.tsx' -import { ModalDialog } from './viewShared.tsx' +import { ErrorNotice, ModalDialog } from './viewShared.tsx' import type { Appearance, LastFmImportDefaults, LastFmImportState } from './types.ts' import { libraryGateway } from './libraryGateway.ts' import { lastfmEvents, lastfmGateway, type AlbumCandidate, type CollectionMatchView, type ImportPageOptions, type ImporterPlayback, type LibraryMatchInfo, type MatchResult, type PageItem, type PageView, type ReviewAction } from './lastfmGateway.ts' @@ -31,6 +31,10 @@ const emptyState: ImportStateView = { phase: null, username: null, spotifyAccoun const invalidApplyResultMessage = 'Retune received an invalid Last.fm import result.' type DisplayError = { message: string; code: ImportApplyErrorCode; retryAt: number | null } +function lastfmOperationError() { + return 'Couldn’t complete that Last.fm operation. Your import queue is unchanged. Try again.' +} + function SpotifyLimitNotice({ code, retryAt }: { code: ImportApplyErrorCode; retryAt?: number | null }) { const limited = code === 'spotify-rate-limited' || code === 'spotify-quota-exhausted' const [now, setNow] = useState(() => Date.now() / 1000) @@ -628,7 +632,7 @@ function ImportPage({ playback, page, onApply, failed, showQueries, onRefresh, o await onApply(page, selectedMappedIds, pageOptions(currentReview, page, new Set(selectedMappedIds)), shouldArchiveRemainder) accepted = true setApplyState('loading') - } catch (error) { setApplyState('error'); setPageError(String(error)); onError(error) } finally { optionWrites.current.accepting = false; onMutation(false); setBusy(false) } + } catch (error) { setApplyState('error'); setPageError('Couldn’t apply this batch. Your library is unchanged. Try again.'); onError(error) } finally { optionWrites.current.accepting = false; onMutation(false); setBusy(false) } if (accepted) { const hasRemainder = selectedMappedIds.length < reviewableAlbumRows.length await onApplied(shouldArchiveRemainder && hasRemainder, !shouldArchiveRemainder && hasRemainder) @@ -644,7 +648,7 @@ function ImportPage({ playback, page, onApply, failed, showQueries, onRefresh, o setApplyState('ready') } catch (error) { setApplyState('error') - setPageError(String(error)) + setPageError('Couldn’t retry this batch. Its choices are unchanged. Try again.') onError(error) } finally { onMutation(false) @@ -883,7 +887,7 @@ function ImportPage({ playback, page, onApply, failed, showQueries, onRefresh, o const row = selectRow(item.source.stableId, event)} onToggle={() => toggleRow(item.source.stableId)} onChangeTrack={() => openTrackPicker(item.source.stableId)} onUseTrack={(uri) => void runPageMutation(() => lastfmGateway.selectMatch(page.batchId, item.source.stableId, uri)).then((next) => { if (next && selectionTouched) setRowSelection((current) => ({ ids: new Set([...current.ids, ...mergedIds(item.source.stableId)]), anchor: current.anchor })) })} locked={failed || busy} /> return group && target ? entry.source)} targetUri={target} resultCount={page.resolvedCounts[target]} mode={page.countModes[target] ?? 'sum'} locked={failed || busy || page.lockedCountModes.includes(target)} expanded={!collapsedFuzzy.has(target)} onMode={(mode) => void run(() => lastfmGateway.countMode(target, mode))} onToggle={() => setCollapsedFuzzy((current) => { const next = new Set(current); if (next.has(target)) next.delete(target); else next.add(target); return next })} onSeparate={separateRows}>{row} : row })} - {pageError &&

{pageError}

} + {pageError && setPageError(undefined)}>{pageError}}
{applyState === 'enqueueing' ? 'Saving accepted batch…' : applyState === 'loading' ? 'Loading accepted changes…' : failed ? 'This batch failed and its choices are frozen.' : `${selectedMappedIds.length} selected · ${mappedIds.length} mapped · ${requiredMatchIds.length} unmapped`}
{failed ? : <>{suggestedMatches.length > 0 && }}
{collectionDialogOpen && collectionMatches && { setCollectionDialogOpen(false); setCollectionPreviewUri(undefined) }} onPage={onCollectionPage} onError={onError} />} {picker && pickerItem && setPicker(null)} onSearch={searchPicker} onChoose={choosePicker} />}} @@ -1072,7 +1076,7 @@ export default function LastFmImporter() { const [pageMutationRunning, setPageMutationRunning] = useState(false) const [batchNameEdit, setBatchNameEdit] = useState(null) const [batchNameError, setBatchNameError] = useState(null) - const reportError = useCallback((reason: unknown) => setError({ message: String(reason), code: 'apply-failed', retryAt: null }), []) + const reportError = useCallback((_reason: unknown) => setError({ message: lastfmOperationError(), code: 'apply-failed', retryAt: null }), []) useEffect(() => { let active = true const subscription = subscribeThenSnapshot( @@ -1231,8 +1235,8 @@ export default function LastFmImporter() { setPage((current) => current?.batchId === target.batchId ? { ...current, name } : current) setBatchNameEdit(null) setBatchNameError(null) - } catch (reason) { - setBatchNameError(String(reason)) + } catch { + setBatchNameError('Couldn’t rename this batch. The current name is unchanged. Try again.') } }, [batchNameEdit]) const skipQueueItem = async (item: ImportQueueItem) => { @@ -1271,7 +1275,7 @@ export default function LastFmImporter() { setError({ message: invalidApplyResultMessage, code: 'apply-failed', retryAt: null }) refreshSafely(refreshQueueOnly) } else if (result.status === 'failed') { - setError(result) + setError(result.code === 'apply-failed' ? { ...result, message: lastfmOperationError() } : result) if (result.batchId === selectedPageRef.current) refreshSafely(refresh) else refreshSafely(refreshQueueOnly) } else if (!advancingApply.current && result.batchId === selectedPageRef.current) { @@ -1510,7 +1514,6 @@ export default function LastFmImporter() { const spotifyFailure = code === 'spotify-rate-limited' || code === 'spotify-quota-exhausted' return { message: selected.error, code, retryAt: spotifyFailure ? state.spotifyLimit?.deadline ?? null : selected.retryAt ?? null } })() : null - const displayedError = error ?? selectedError const setBatchSelection = (pages: number[], checked: boolean) => setSelectedBatchPages((current) => { const next = new Set(current) for (const batchPage of pages) { @@ -1529,7 +1532,7 @@ export default function LastFmImporter() { } return

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]) => )}
}
- {displayedError ?
{displayedError.message}
: state.spotifyLimit &&
{state.spotifyLimit.kind === 'quota' ? 'Spotify Development Mode quota is cooling down.' : 'Spotify is rate limited.'}
} + {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()} />} diff --git a/apps/desktop/src/appState.ts b/apps/desktop/src/appState.ts index 4a59f21..b6f642b 100644 --- a/apps/desktop/src/appState.ts +++ b/apps/desktop/src/appState.ts @@ -33,6 +33,7 @@ export type State = { lastfmImport: LastFmImportState spotifyResults: SpotifyResults | null spotifySearching: boolean + spotifySearchError?: string spotifyNavigation?: SpotifyNavEntry selectedPlaylist?: string playlistRevision: number @@ -70,6 +71,7 @@ export type Action = | { type: 'lastfmImport'; lastfmImport: LastFmImportState } | { type: 'spotifyResults'; results: SpotifyResults | null } | { type: 'spotifySearching'; searching: boolean } + | { type: 'spotifySearchError'; error?: string } | { type: 'spotifyNavigate'; entry: SpotifyNavEntry } | { type: 'syncPhase'; phase?: string } | { type: 'syncProgress'; progress: { tracks: number; fraction: number } } @@ -141,7 +143,7 @@ export const initialState: State = { export function reducer(state: State, action: Action): State { switch (action.type) { case 'view': - return { ...state, view: action.view, viewKey: action.key, browsePending: false, error: undefined } + return { ...state, view: action.view, viewKey: action.key, browsePending: false } case 'browsePending': return { ...state, browsePending: action.pending } case 'error': @@ -157,7 +159,7 @@ export function reducer(state: State, action: Action): State { return { ...state, sel, savedSelections: rememberSelection(state.savedSelections, state.source, sel), selectedTrackIds: new Set(), selectionAnchor: undefined } } case 'query': - return { ...state, query: action.query, spotifyResults: null, spotifySearching: false, spotifyNavigation: undefined, selectedTrackIds: new Set(), selectionAnchor: undefined } + return { ...state, query: action.query, spotifyResults: null, spotifySearching: false, spotifySearchError: undefined, spotifyNavigation: undefined, selectedTrackIds: new Set(), selectionAnchor: undefined } case 'scope': return { ...state, scope: action.scope, spotifyNavigation: undefined } case 'selectTrack': @@ -211,9 +213,11 @@ export function reducer(state: State, action: Action): State { case 'lastfmImport': return { ...state, lastfmImport: action.lastfmImport } case 'spotifyResults': - return { ...state, spotifyResults: action.results, spotifySearching: false } + return { ...state, spotifyResults: action.results, spotifySearching: false, spotifySearchError: undefined } case 'spotifySearching': return { ...state, spotifySearching: action.searching } + case 'spotifySearchError': + return { ...state, spotifySearchError: action.error, spotifySearching: false } case 'spotifyNavigate': return { ...state, scope: 'spotify', query: '', queryReset: state.queryReset + 1, spotifyNavigation: action.entry, selectedPlaylist: undefined } case 'syncPhase': diff --git a/apps/desktop/src/dialogViews.tsx b/apps/desktop/src/dialogViews.tsx index 34ea999..6be3130 100644 --- a/apps/desktop/src/dialogViews.tsx +++ b/apps/desktop/src/dialogViews.tsx @@ -49,7 +49,7 @@ export function GetInfo({ track, onCancel, onSaved, onError }: { track: TrackInf const [decisionError, setDecisionError] = useState('') const reportError = useEffectEvent(onError) useEffect(() => { - libraryGateway.metadataValues().then(setSuggestions).catch((error) => reportError(String(error))) + libraryGateway.metadataValues().then(setSuggestions).catch(() => reportError('Couldn’t load metadata suggestions. Existing values are unchanged. Try again.')) }, []) const genres = useMemo(() => [...new Map([...suggestions.cats, ...track.genres].filter((genre) => genre && genre !== 'Uncategorized').map((genre) => [genre.toLowerCase(), genre] as const)).values()] .sort((left, right) => left.toLowerCase().localeCompare(right.toLowerCase())), [suggestions.cats, track.genres]) @@ -64,8 +64,8 @@ export function GetInfo({ track, onCancel, onSaved, onError }: { track: TrackInf const edit = Object.fromEntries(Object.entries(draft).filter(([, value]) => value.trim() !== '')) await libraryGateway.editTrack(track.id, { ...edit, ratingChange }) onSaved() - } catch (error) { - onError(String(error)) + } catch { + onError('Couldn’t save this track overlay. Your library is unchanged. Try again.') } finally { setBusy(false) } @@ -73,13 +73,13 @@ export function GetInfo({ track, onCancel, onSaved, onError }: { track: TrackInf const toggleEnabled = async (next: boolean) => { setBusy(true); setDecisionError('') try { await libraryGateway.setTrackEnabled(track.id, next); setEnabled(next) } - catch (error) { setDecisionError(String(error)) } + catch { setDecisionError('Couldn’t update playback inclusion. Your library is unchanged. Try again.') } finally { setBusy(false) } } const undoMerge = async () => { setBusy(true); setDecisionError('') try { await libraryGateway.undoMerge(track.id); onSaved() } - catch (error) { setDecisionError(String(error)); setBusy(false) } + catch { setDecisionError('Couldn’t undo this merge. Your library is unchanged. Try again.'); setBusy(false) } } const dirty = draft.name !== track.name || draft.art !== track.art || draft.alb !== track.alb || draft.cat !== (track.cat === 'Uncategorized' ? '' : track.cat) || rating?.stars !== track.rating?.stars || rating?.explicit !== track.rating?.explicit const field = (key: keyof typeof draft) => ({ @@ -117,7 +117,7 @@ export function MultipleItemInformation({ tracks, onCancel, onSaved, onError }: const [saving, setSaving] = useState(false) const reportError = useEffectEvent(onError) useEffect(() => { - libraryGateway.metadataValues().then(setSuggestions).catch((error) => reportError(String(error))) + libraryGateway.metadataValues().then(setSuggestions).catch(() => reportError('Couldn’t load metadata suggestions. Existing values are unchanged. Try again.')) }, []) const placeholder = (key: Field) => tracks.every((track) => track[key] === tracks[0][key]) ? tracks[0][key] : 'Mixed' const { ids, missingUris } = overlayEditTargets(tracks) @@ -128,8 +128,10 @@ export function MultipleItemInformation({ tracks, onCancel, onSaved, onError }: const targetIds = [...new Set([...ids, ...addedIds])] if (targetIds.length) await libraryGateway.editTracks(targetIds, { ...draft, ...(rating === undefined ? {} : { ratingChange: { stars: rating } }) }) onSaved() - } catch (error) { - onError(String(error)) + } catch { + onError(addMissing + ? 'Couldn’t finish adding the selected tracks to Retune. Refresh your library and try again.' + : 'Couldn’t save the selected track overlays. Your library is unchanged. Try again.') } finally { setSaving(false) } @@ -196,8 +198,8 @@ export function PlaybackAuthorization({ prompt, onCancel, onAuthorize }: { setAuthorizing(true) try { await onAuthorize() - } catch (error) { - setError(String(error)) + } catch { + setError('Couldn’t authorize Spotify playback. Your library connection is unchanged. Try again.') } finally { setAuthorizing(false) } @@ -220,7 +222,7 @@ function BugPreferences() { let active = true appGateway.diagnostics() .then((next) => { if (active) setReport(next) }) - .catch((reason) => { if (active) setError(String(reason)) }) + .catch(() => { if (active) setError('Couldn’t load diagnostics. Try again.') }) return () => { active = false } }, []) const counts = Object.fromEntries(diagnosticLevels.map((level) => [level, report.entries.filter((entry) => entry.level === level).length])) as Record @@ -232,15 +234,15 @@ function BugPreferences() { await navigator.clipboard.writeText(body) setCopied(true) window.setTimeout(() => setCopied(false), 1600) - } catch (reason) { - setError(String(reason)) + } catch { + setError('Couldn’t copy diagnostics. Try again.') } } const email = async () => { try { await appGateway.emailDiagnostics(body) - } catch (reason) { - setError(String(reason)) + } catch { + setError('Couldn’t email diagnostics. Copy Logs and share the report instead.') } } const toggleLevel = (level: DiagnosticLevel) => setLevels((current) => { @@ -326,8 +328,8 @@ export function Preferences({ settings, lastfm, lastfmImport, onZoom, onCancel, if (action === 'finish') { setLastfmScrobbling(true) } - } catch (error) { - setLastfmError(String(error)) + } catch { + setLastfmError(`Couldn’t ${action} Last.fm. Your account connection is unchanged. Try again.`) } finally { setLastfmBusy(false) } @@ -389,7 +391,7 @@ export function Preferences({ settings, lastfm, lastfmImport, onZoom, onCancel, {lastfm.problem && lastfm.available && {lastfm.problem}} {lastfmError && lastfmError !== lastfm.problem && {lastfmError}} - Powered by { event.preventDefault(); void openExternalDestination({ kind: 'lastFm' }).catch((error) => setLastfmError(String(error))) }}>Last.fm. + Powered by { event.preventDefault(); void openExternalDestination({ kind: 'lastFm' }).catch(() => setLastfmError('Couldn’t open Last.fm. Try again.')) }}>Last.fm.

Removed tracks

Recover tracks removed from RetuneRestore their metadata and play history without changing Spotify.
)} diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index a1a7a8e..3aa4d8b 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -12,6 +12,7 @@ export type MainEvent = | { type: 'playerState'; payload: PlayerState } | { type: 'playbackAuthorizationRequired'; payload: PlaybackAuthorizationPrompt } | { type: 'operationError'; payload: string } + | { type: 'spotifySyncError' } | { type: 'operationRecovered' } | { type: 'localImportComplete'; payload: ImportSummary } | { type: 'startupNotice'; payload: string } @@ -28,6 +29,7 @@ export function dispatchMainEvent(event: MainEvent, handlers: MainEventHandlers) case 'playerState': handlers.playerState(event.payload); break case 'playbackAuthorizationRequired': handlers.playbackAuthorizationRequired(event.payload); break case 'operationError': handlers.operationError(event.payload); break + case 'spotifySyncError': handlers.spotifySyncError(); break case 'operationRecovered': handlers.operationRecovered(); break case 'localImportComplete': handlers.localImportComplete(event.payload); break case 'startupNotice': handlers.startupNotice(event.payload); break diff --git a/apps/desktop/src/lastfmImporter.css b/apps/desktop/src/lastfmImporter.css index 0d9d6db..929e5c2 100644 --- a/apps/desktop/src/lastfmImporter.css +++ b/apps/desktop/src/lastfmImporter.css @@ -21,6 +21,7 @@ .import-query-toggle { color: var(--dim); font-size: 11px; white-space: nowrap; } .import-query-toggle input { margin: 0; } .import-error { margin: 9px 18px 0; padding: 8px 10px; border: 1px solid #d66; border-radius: 5px; color: #a22; background: color-mix(in srgb, #f88 12%, var(--list)); font-size: 12px; } +.import-error .error-notice-dismiss { min-height: 0; padding: 0 4px; border: 0; color: inherit; background: transparent; font-size: 18px; } .import-error, .import-limit { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; } .import-limit { margin: 9px 18px 0; padding: 8px 10px; border: 1px solid #c58a32; border-radius: 5px; color: #7d4d00; background: color-mix(in srgb, #e9a53a 12%, var(--list)); font-size: 12px; } :root[data-theme="dark"] .import-limit { color: #ffc271; } diff --git a/apps/desktop/src/spotifySearch.ts b/apps/desktop/src/spotifySearch.ts index 5257a57..63254b8 100644 --- a/apps/desktop/src/spotifySearch.ts +++ b/apps/desktop/src/spotifySearch.ts @@ -133,6 +133,13 @@ export const failSpotifySearchGroup = (state: SpotifySearchState, group: Spotify } } +export const dismissSpotifySearchGroup = (state: SpotifySearchState, group: SpotifyResultGroupKey): SpotifySearchState => { + if (!state.errors[group]) return state + const errors = { ...state.errors } + delete errors[group] + return { ...state, errors } +} + export const moreSpotifySearchLabel = (state: SpotifySearchState, group: SpotifyResultGroupKey) => { const remaining = state.groups[group].total - Math.min(state.visible[group], state.groups[group].total) return remaining > 0 && state.groups[group].nextOffset !== null diff --git a/apps/desktop/src/spotifyViews.tsx b/apps/desktop/src/spotifyViews.tsx index 41163aa..76b0162 100644 --- a/apps/desktop/src/spotifyViews.tsx +++ b/apps/desktop/src/spotifyViews.tsx @@ -1,8 +1,8 @@ -import { useCallback, useEffect, useEffectEvent, useRef, useState, type ReactNode } from 'react' +import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react' import type { AlbumPageView, ArtistAlbumsPage, ArtistPageView, PlaybackTrack, PlaylistSubject, SearchAlbum, SearchArtist, SearchTrack, SpotifyNavEntry, SpotifyResults } from './types.ts' -import { createSpotifySearchState, expandSpotifySearchGroup, failSpotifySearchGroup, moreSpotifySearchLabel, receiveSpotifySearchPage, replaceSpotifySearchResults, resetSpotifySearchQuery, retrySpotifySearchGroup, setSpotifySearchTab, spotifyMembership, spotifySearchGroupHeader, spotifySearchPendingPageKey, type SpotifyMembershipOverrides, type SpotifySearchState } from './spotifySearch.ts' +import { createSpotifySearchState, dismissSpotifySearchGroup, expandSpotifySearchGroup, failSpotifySearchGroup, moreSpotifySearchLabel, receiveSpotifySearchPage, replaceSpotifySearchResults, resetSpotifySearchQuery, retrySpotifySearchGroup, setSpotifySearchTab, spotifyMembership, spotifySearchGroupHeader, spotifySearchPendingPageKey, type SpotifyMembershipOverrides, type SpotifySearchState } from './spotifySearch.ts' import { beginPendingEntity, DRAG_TYPE, entityRequestGeneration, formatTime, loadCurrentGeneration, mergeByUri, pendingEntities, SYNTHETIC_BASE } from './ui.ts' -import { ArtworkLightbox, ContextMenu, RatingStars } from './viewShared.tsx' +import { ArtworkLightbox, ContextMenu, ErrorNotice, RatingStars } from './viewShared.tsx' import { libraryGateway } from './libraryGateway.ts' import { spotifyGateway } from './spotifyGateway.ts' @@ -82,7 +82,7 @@ function searchTrackPlayback(track: SearchTrack, index: number): PlaybackTrack { return { id: SYNTHETIC_BASE + index, uri: track.uri, name: track.name, art: track.artist, alb: track.alb, durationSecs: track.durationSecs, enabled: true } } -function SpotifySearchSection({ group, state, onMore, onRetry, children }: { group: keyof SpotifyResults; state: SpotifySearchState; onMore: () => void; onRetry: () => void; children: ReactNode }) { +function SpotifySearchSection({ group, state, onMore, onRetry, onDismiss, children }: { group: keyof SpotifyResults; state: SpotifySearchState; onMore: () => void; onRetry: () => void; onDismiss: () => void; children: ReactNode }) { const resultGroup = state.groups[group] const loading = state.loading.has(group) const error = state.errors[group] @@ -92,7 +92,7 @@ function SpotifySearchSection({ group, state, onMore, onRetry, children }: { gro {children} {!resultGroup.items.length && !loading && !error &&

No {group} found.

} {loading &&

Loading {group}…

} - {error &&
{error}
} + {error && Retry} onDismiss={onDismiss}>Couldn’t load more {group}. Cached {group} results are unchanged. Try another network or VPN, then retry.} {!loading && !error && more &&
} } @@ -161,7 +161,7 @@ function SpotifyAlbumPage({ entry, backLabel, adding, membership, playingUri, on onPlaylist: (subject: PlaylistSubject) => void onError: (error: string) => void }) { - const [loaded, setLoaded] = useState<{ uri: string; page?: AlbumPageView }>({ uri: entry.uri }) + const [loaded, setLoaded] = useState<{ uri: string; status: 'loading' | 'error' | 'ready'; page?: AlbumPageView }>({ uri: entry.uri, status: 'loading' }) const [artworkExpanded, setArtworkExpanded] = useState(false) const [revision, setRevision] = useState(0) const [busy, setBusy] = useState(false) @@ -170,30 +170,30 @@ function SpotifyAlbumPage({ entry, backLabel, adding, membership, playingUri, on const [menu, setMenu] = useState<{ x: number; y: number; index: number }>() const highlighted = useRef(null) const ratingGenerations = useRef(new Map()) - const reportError = useEffectEvent(onError) useEffect(() => { pendingTracks.current.clear() setTrackBusy(new Set()) }, [entry.uri]) useEffect(() => { let active = true - setLoaded({ uri: entry.uri }) + setLoaded({ uri: entry.uri, status: 'loading' }) spotifyGateway.albumPage(entry.uri) - .then((view) => active && setLoaded({ uri: entry.uri, page: view })) - .catch((error) => active && reportError(String(error))) + .then((view) => active && setLoaded({ uri: entry.uri, status: 'ready', page: view })) + .catch(() => active && setLoaded({ uri: entry.uri, status: 'error' })) return () => { active = false } }, [entry.uri, revision]) - const page = loaded.uri === entry.uri ? loaded.page : undefined + const page = loaded.uri === entry.uri && loaded.status === 'ready' ? loaded.page : undefined useEffect(() => { if (page && entry.highlight) highlighted.current?.scrollIntoView({ block: 'center' }) }, [entry.highlight, page]) + if (loaded.status === 'error') return
setRevision((current) => current + 1)}>Retry}>Couldn’t load this Spotify album.Your cached library is unchanged. Try another network or VPN, then retry.
if (!page) return
Loading album…
const tracks = albumPlaybackTracks(page) const savedAlbum = spotifyMembership(page.savedAlbum, page.uri, membership) const refresh = () => setRevision((current) => current + 1) const trackIsSavedIndividually = (track: AlbumPageView['tracks'][number]) => spotifyMembership(track.savedIndividually, track.uri, membership) - const rateAlbum = (stars: number) => void loadCurrentGeneration(entityRequestGeneration(ratingGenerations.current, `album:${page.uri}`), () => libraryGateway.setAlbumRating('music', page.artist, page.name, stars === page.albumRating ? null : stars), refresh, (error) => onError(String(error))) - const rateTrack = (id: number, stars: number) => void loadCurrentGeneration(entityRequestGeneration(ratingGenerations.current, `track:${id}`), () => libraryGateway.clickTrackStar(id, stars), refresh, (error) => onError(String(error))) + const rateAlbum = (stars: number) => void loadCurrentGeneration(entityRequestGeneration(ratingGenerations.current, `album:${page.uri}`), () => libraryGateway.setAlbumRating('music', page.artist, page.name, stars === page.albumRating ? null : stars), refresh, () => onError('Couldn’t update the album rating. Your library is unchanged. Try again.')) + const rateTrack = (id: number, stars: number) => void loadCurrentGeneration(entityRequestGeneration(ratingGenerations.current, `track:${id}`), () => libraryGateway.clickTrackStar(id, stars), refresh, () => onError('Couldn’t update the track rating. Your library is unchanged. Try again.')) const remove = async () => { setBusy(true) try { @@ -315,7 +315,7 @@ function SpotifyArtistPage({ id, backLabel, adding, membership, onBack, onAlbum, }, [id, loadArtist]) const artist = loaded.id === id ? loaded : { id, status: 'loading' as const } const discography = albums.id === id ? albums.page : { albums: [], nextOffset: 0, total: 0 } - if (artist.status === 'error') return

Artist details are unavailable.

{artist.error}

+ if (artist.status === 'error') return
Retry}>Artist details are unavailable.Your search results are unchanged. Try another network or VPN, then retry.
if (artist.status !== 'ready' || artist.page.id !== id) return
Loading artist…
const page = artist.page const loadMore = async () => { @@ -343,10 +343,10 @@ function SpotifyArtistPage({ id, backLabel, adding, membership, onBack, onAlbum, setToggling(true) try { await spotifyGateway.followArtist(page.id, following) - } catch (error) { + } catch { const restored = { ...page, following: !following } setLoaded({ id, status: 'ready', page: restored }) - onError(String(error)) + onError('Couldn’t confirm the artist follow state. Refresh Spotify and try again.') } finally { setToggling(false) } @@ -368,17 +368,18 @@ function SpotifyArtistPage({ id, backLabel, adding, membership, onBack, onAlbum,

Discography{discography.total ? ` · ${discography.albums.length} of ${discography.total}` : ''}

{discography.albums.map((album) => { void onAdd(album) }} onRemove={() => { void onRemove(album.uri) }} onOpen={() => onAlbum(album.uri)} onPlaylist={onPlaylist} showType />)} {loadingAlbums &&

Loading albums…

} - {albumsError &&
{albumsError}
} + {albumsError && void loadMore()}>Retry} onDismiss={() => setAlbumsError(undefined)}>Couldn’t load more albums. Cached releases are unchanged. Try another network or VPN, then retry.} {!loadingAlbums && !albumsError && !discography.albums.length && discography.nextOffset === null &&

No albums or singles found.

} {!loadingAlbums && !albumsError && discography.nextOffset !== null &&
} } -export function SpotifySearch({ query, searching, results, navigation, playingUri, onAdd, onAddTrack, onRemoveTrack, onPlay, onPlaylist, onClose, onError }: { +export function SpotifySearch({ query, searching, results, searchError, navigation, playingUri, onAdd, onAddTrack, onRemoveTrack, onPlay, onPlaylist, onClose, onRetrySearch, onDismissSearch, onError }: { query: string searching: boolean results: SpotifyResults | null + searchError?: string navigation?: SpotifyNavEntry playingUri: string | null onAdd: (album: { uri: string; name: string; artist: string }) => Promise @@ -387,6 +388,8 @@ export function SpotifySearch({ query, searching, results, navigation, playingUr onPlay: (id: number, tracks: readonly PlaybackTrack[]) => void onPlaylist: (subject: PlaylistSubject) => void onClose: () => void + onRetrySearch?: () => void + onDismissSearch?: () => void onError: (error: string) => void }) { const [searchState, setSearchState] = useState(() => createSpotifySearchState(query)) @@ -460,7 +463,7 @@ export function SpotifySearch({ query, searching, results, navigation, playingUr const add = (album: { uri: string; name: string; artist: string }) => mutateMembership(album.uri, true, () => onAdd(album)) const remove = (uri: string) => mutateMembership(uri, false, () => - spotifyGateway.removeAlbum(uri).catch((error) => { onError(String(error)); throw error })) + spotifyGateway.removeAlbum(uri).catch((error) => { onError('Couldn’t confirm removing this album from Retune. Refresh your library and try again.'); throw error })) const addTrack = (uri: string) => mutateMembership(uri, true, () => onAddTrack(uri)) const removeTrack = (uri: string) => mutateMembership(uri, false, () => onRemoveTrack(uri)) const playAlbum = async (album: SearchAlbum) => { @@ -469,8 +472,8 @@ export function SpotifySearch({ query, searching, results, navigation, playingUr const page = await spotifyGateway.albumPage(album.uri) const tracks = albumPlaybackTracks(page) if (tracks.length) onPlay(tracks[0].id, tracks) - } catch (error) { - onError(String(error)) + } catch { + onError('Couldn’t play this Spotify album. Playback is unchanged. Try again.') } finally { setPlayingAlbum(undefined) } @@ -496,18 +499,19 @@ export function SpotifySearch({ query, searching, results, navigation, playingUr { key: 'tracks', label: 'Tracks', count: counts.tracks }, ] return
+ {searchError && Retry : undefined} onDismiss={onDismissSearch}>{searchError}}
{tabs.map((item) => )} Spotify · "{query}"
- {(tab === 'all' || tab === 'artists') && requestGroup('artists')} onRetry={() => requestGroup('artists', true)}> + {(tab === 'all' || tab === 'artists') && requestGroup('artists')} onRetry={() => requestGroup('artists', true)} onDismiss={() => setSearchState((current) => dismissSpotifySearchGroup(current, 'artists'))}> {searchState.groups.artists.items.slice(0, searchState.visible.artists).map((artist) => setNav((current) => [...current, { kind: 'artist', id: artist.id }])} />)} } - {(tab === 'all' || tab === 'albums') && requestGroup('albums')} onRetry={() => requestGroup('albums', true)}> + {(tab === 'all' || tab === 'albums') && requestGroup('albums')} onRetry={() => requestGroup('albums', true)} onDismiss={() => setSearchState((current) => dismissSpotifySearchGroup(current, 'albums'))}> {searchState.groups.albums.items.slice(0, searchState.visible.albums).map((album) => { void add(album) }} onRemove={() => { void remove(album.uri) }} onOpen={() => pushAlbum(album.uri)} onPlaylist={onPlaylist} searchActions onPlay={() => { void playAlbum(album) }} playing={playingAlbum === album.uri || playingUri === album.uri} />)} } - {(tab === 'all' || tab === 'tracks') && requestGroup('tracks')} onRetry={() => requestGroup('tracks', true)}> + {(tab === 'all' || tab === 'tracks') && requestGroup('tracks')} onRetry={() => requestGroup('tracks', true)} onDismiss={() => setSearchState((current) => dismissSpotifySearchGroup(current, 'tracks'))}> {searchState.groups.tracks.items.slice(0, searchState.visible.tracks).map((track, index) => { const open = () => { if (track.albumUri) pushAlbum(track.albumUri, track.uri) } const playback = searchTrackPlayback(track, index) diff --git a/apps/desktop/src/trackDecisionDialogs.tsx b/apps/desktop/src/trackDecisionDialogs.tsx index a2e7581..c5b90cb 100644 --- a/apps/desktop/src/trackDecisionDialogs.tsx +++ b/apps/desktop/src/trackDecisionDialogs.tsx @@ -68,7 +68,7 @@ export function TrackMergeDialog({ ids, onClose, onChanged }: { ids: number[]; o libraryGateway.mergePreview(ids).then((value) => { if (request !== generation.current) return setPreview(value); setDraft(initialDraft(value)); setBusy(false) - }).catch((error) => { if (request === generation.current) { setError(String(error)); setBusy(false) } }) + }).catch(() => { if (request === generation.current) { setError('Couldn’t load the selected tracks. Your library is unchanged. Try Reload preview.'); setBusy(false) } }) return () => { beginRequestGeneration(generation); beginRequestGeneration(searchGeneration) } }, [ids]) const reload = async (targetUri?: string) => { @@ -78,7 +78,7 @@ export function TrackMergeDialog({ ids, onClose, onChanged }: { ids: number[]; o const value = await libraryGateway.mergePreview(ids, targetUri) if (request !== generation.current) return setPreview(value); setDraft(initialDraft(value)) - } catch (error) { if (request === generation.current) setError(String(error)) } + } catch { if (request === generation.current) setError('Couldn’t reload the merge preview. Your library is unchanged. Try again.') } finally { if (request === generation.current) setBusy(false) } } const search = async (offset = 0) => { @@ -90,7 +90,7 @@ export function TrackMergeDialog({ ids, onClose, onChanged }: { ids: number[]; o if (request !== searchGeneration.current) return setResults((previous) => offset ? [...previous, ...result.tracks.items.filter((track) => !previous.some((known) => known.uri === track.uri))] : result.tracks.items) setNextOffset(result.tracks.nextOffset) - } catch (error) { if (request === searchGeneration.current) setSearchError(String(error)) } + } catch { if (request === searchGeneration.current) setSearchError('Couldn’t search Spotify recordings. Existing matches are unchanged. Try again.') } finally { if (request === searchGeneration.current) setSearching(false) } } const all = preview ? contributors(preview) : [] @@ -103,14 +103,14 @@ export function TrackMergeDialog({ ids, onClose, onChanged }: { ids: number[]; o try { const id = await libraryGateway.mergeTracks(ids, preview.target.uri, { ...draft, rating: draft.rating === 'none' ? null : Number(draft.rating), playCount: mode === 'custom' ? { mode, value: total } : { mode } }, preview.revision) setMerged(id); onChanged(id) - } catch (error) { setError(String(error)) } + } catch { setError('Couldn’t merge these tracks. Your library is unchanged. Try again.') } finally { setBusy(false) } } const undo = async () => { if (merged === undefined) return setBusy(true); setError('') try { await libraryGateway.undoMerge(merged); onChanged(); onClose() } - catch (error) { setError(String(error)); setBusy(false) } + catch { setError('Couldn’t undo this merge. Your library is unchanged. Try again.'); setBusy(false) } } const { genres, ratings } = preview ? choices(preview) : { genres: [], ratings: [] } const added = all.flatMap((track) => track.addedAt === null ? [] : [track.addedAt]) @@ -129,7 +129,7 @@ export function TrackMergeDialog({ ids, onClose, onChanged }: { ids: number[]; o )}
{preview.target && !preview.tracks.some((track) => track.uri === preview.target?.uri) &&

Different recording selected

} -
Choose a different Spotify recording
{ searchGeneration.current++; setQuery(event.target.value); setResults([]); setNextOffset(null); setSearching(false) }} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); void search() } }} />
+
Choose a different Spotify recording
{ searchGeneration.current++; setQuery(event.target.value); setResults([]); setNextOffset(null); setSearching(false); setSearchError('') }} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); void search() } }} />
{searchError &&

{searchError}

}
{results.map((track) => )}
{nextOffset !== null && } @@ -168,7 +168,7 @@ export function RemoveTrackDialog({ tracks, spotify, onClose, onChanged }: { tra useEffect(() => { if (!spotify) return let active = true - libraryGateway.getTrack(tracks[0].id).then((value) => { if (active) setInfo(value) }).catch((error) => { if (active) setError(String(error)) }) + libraryGateway.getTrack(tracks[0].id).then((value) => { if (active) setInfo(value) }).catch(() => { if (active) setError('Couldn’t check Spotify membership. Your track is unchanged. Try again.') }) return () => { active = false } }, [spotify, tracks]) const canRemove = !spotify || Boolean(info?.sources.savedTrack && info.sources.membershipKnown) @@ -179,12 +179,17 @@ export function RemoveTrackDialog({ tracks, spotify, onClose, onChanged }: { tra if (spotify) await spotifyGateway.removeTrack(tracks[0].uri) else await libraryGateway.removeTracks(tracks.map((track) => track.id)) onChanged(); onClose() - } catch (error) { setError(String(error)); setBusy(false) } + } catch { + setError(spotify + ? `Couldn’t confirm removing ${tracks.length === 1 ? 'this track' : 'these tracks'} from Spotify. Refresh Spotify and try again.` + : `Couldn’t remove ${tracks.length === 1 ? 'this track' : 'these tracks'} from Retune. Your library is unchanged. Try again.`) + setBusy(false) + } } const exclude = async () => { setBusy(true); setError('') try { await libraryGateway.setTrackEnabled(tracks[0].id, false); onChanged(); onClose() } - catch (error) { setError(String(error)); setBusy(false) } + catch { setError('Couldn’t exclude this track from playback. Your library is unchanged. Try again.'); setBusy(false) } } const referenced = info && (info.sources.savedAlbums.length > 0 || info.mergedSources.some((track) => track.uri !== info.uri && (track.sources.savedTrack || track.sources.savedAlbums.length > 0))) return @@ -205,13 +210,13 @@ export function RemovedTracksManager({ onBack, onChanged }: { onBack: () => void setTracks(undefined) setError('') try { setTracks(await libraryGateway.removedTracks()) } - catch (error) { setError(String(error)) } + catch { setError('Couldn’t load removed tracks. Your library is unchanged. Try again.') } }, []) useEffect(() => { void load() }, [load]) const restore = async (uri: string) => { setBusy(uri); setError('') try { await libraryGateway.restoreTrack(uri); setTracks((tracks) => tracks?.filter((track) => track.uri !== uri)); onChanged() } - catch (error) { setError(String(error)) } + catch { setError('Couldn’t restore this track. Your library is unchanged. Try again.') } finally { setBusy(undefined) } } const filtered = tracks?.filter((track) => `${track.name} ${track.art} ${track.alb}`.toLowerCase().includes(query.toLowerCase())) diff --git a/apps/desktop/src/viewShared.tsx b/apps/desktop/src/viewShared.tsx index c728a8d..3df7c85 100644 --- a/apps/desktop/src/viewShared.tsx +++ b/apps/desktop/src/viewShared.tsx @@ -50,6 +50,19 @@ export function ArtworkLightbox({ artwork, name, onClose }: { artwork: string | } +export function ErrorNotice({ children, className, actions, onDismiss, dismissLabel = 'Dismiss error' }: { + children: ReactNode + className?: string + actions?: ReactNode + onDismiss?: () => void + dismissLabel?: string +}) { + return
+
{children}
+ {(actions || onDismiss) &&
{actions}{onDismiss &&
} +
+} + export function ContextMenu({ x, y, onClose, children }: { x: number; y: number; onClose: () => void; children: ReactNode }) { const menu = useRef(null) const previousFocus = useRef(null) diff --git a/apps/desktop/test/gateways.test.ts b/apps/desktop/test/gateways.test.ts index 58dedd8..36a6eea 100644 --- a/apps/desktop/test/gateways.test.ts +++ b/apps/desktop/test/gateways.test.ts @@ -143,14 +143,16 @@ test('main event dispatch is exhaustive over the tagged contract', () => { playerState: () => received.push('playerState'), playbackAuthorizationRequired: () => received.push('playbackAuthorizationRequired'), operationError: (payload: string) => received.push(payload), + spotifySyncError: () => received.push('spotifySyncError'), operationRecovered: () => received.push('operationRecovered'), localImportComplete: () => received.push('localImportComplete'), startupNotice: (payload: string) => received.push(payload), } dispatchMainEvent({ type: 'operationError', payload: 'error' }, handlers) + dispatchMainEvent({ type: 'spotifySyncError' }, handlers) dispatchMainEvent({ type: 'operationRecovered' }, handlers) dispatchMainEvent({ type: 'startupNotice', payload: 'notice' }, handlers) - assert.deepEqual(received, ['error', 'operationRecovered', 'notice']) + assert.deepEqual(received, ['error', 'spotifySyncError', 'operationRecovered', 'notice']) }) test('main event registration serializes StrictMode cleanup before replacement', async () => { diff --git a/apps/desktop/test/interactions.test.tsx b/apps/desktop/test/interactions.test.tsx index f41de93..5266be3 100644 --- a/apps/desktop/test/interactions.test.tsx +++ b/apps/desktop/test/interactions.test.tsx @@ -3,7 +3,7 @@ import { act, createRef, Profiler, StrictMode, useState } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AlbumPageView, ArtistPageView, BrowseView, DecisionTrack, LastFmImportState, LastFmState, LibrarySources, PlayerState, Settings, SpotifyNavEntry, SpotifySyncStatus, Track, TrackInfo, TrackMergePreview } from '../src/types.ts' +import type { AlbumPageView, ArtistPageView, BrowseView, DecisionTrack, LastFmImportState, LastFmState, LibrarySources, PlayerState, Settings, SpotifyNavEntry, SpotifyResults, SpotifySyncStatus, Track, TrackInfo, TrackMergePreview } from '../src/types.ts' import type { MainEvent } from '../src/ipc.ts' const invokeMock = vi.hoisted(() => vi.fn<(command: string, args?: Record) => Promise>(async () => null)) @@ -331,7 +331,7 @@ describe('mounted native interaction boundaries', () => { } rejectRequest = true await act(async () => play.click()) - expect(importer.querySelector('[role="alert"]')?.textContent).toContain('Playback request failed') + expect(importer.querySelector('[role="alert"]')?.textContent).toContain('Your import queue is unchanged') }) it('shows unfiltered play and completed-batch totals in the footer and refreshes them', async () => { @@ -437,11 +437,130 @@ describe('mounted native interaction boundaries', () => { await waitFor(() => expect(view.querySelector('[data-facet="cat"] [data-row-index="1"]')).not.toBeNull()) await act(async () => view.querySelector('[data-facet="cat"] [data-row-index="1"]')!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))) - await waitFor(() => expect(view.querySelector('.error-banner')?.textContent).toBe('Unsupported playback URI: fixture:track:included')) + await waitFor(() => expect(view.querySelector('.error-banner')?.textContent).toBe('This track can’t be played because its source isn’t supported.')) expect(view.querySelector('.lcd-copy .marquee')?.textContent).not.toBe('Included') expect(invokeMock.mock.calls.some(([command]) => command === 'play_tracks')).toBe(false) }) + it('does not expose a raw browse transport failure in the global error banner', async () => { + const settings: Settings = { ...defaultSettings, theme: 'light' } + const lastfm: LastFmState = { available: false, connected: false, username: null, pending: false, reconnectRequired: false, problem: null } + invokeMock.mockImplementation(async (command) => { + if (command === 'browse') throw new Error('reqwest::Error: network blocked by policy') + if (command === 'get_settings') return settings + if (command === 'connection_state') return { connected: false, needs_reauth: false, playback_authorized: false } + if (command === 'spotify_sync_status') return spotifyStatus() + if (command === 'lastfm_state') return lastfm + if (command === 'lastfm_import_state') return idleLastFmImport() + if (command === 'playlists_list') return [] + if (command === 'subscribe_main_events') return 1 + return null + }) + const view = await render() + await waitFor(() => expect(view.querySelector('.error-banner')).not.toBeNull()) + expect(view.querySelector('.error-banner')?.textContent).toContain('Couldn’t load your library') + expect(view.querySelector('.error-banner')?.textContent).not.toContain('reqwest') + expect(view.querySelector('.error-banner')?.textContent).not.toContain('network blocked') + }) + + it('dismisses a typed Spotify sync error and can show a later error from the native event boundary', async () => { + const tracks = [{ ...track(1, 'Included'), uri: 'spotify:track:included', enabled: true }] + const browse: BrowseView = { + facets: { cats: ['Rock'], arts: ['Artist'], albs: ['Album'] }, tracks, + albumRating: null, albumRatingArtist: null, albumRatingAmbiguous: false, + counts: { tracks: tracks.length, totalSecs: 180, perSource: { music: tracks.length, podcasts: 0, audiobooks: 0 } }, + } + const settings: Settings = { ...defaultSettings, theme: 'light' } + const lastfm: LastFmState = { available: false, connected: false, username: null, pending: false, reconnectRequired: false, problem: null } + const lastfmImport = idleLastFmImport() + let channel: { onmessage: (event: MainEvent) => void } | undefined + invokeMock.mockImplementation(async (command, args) => { + if (command === 'browse') return browse + if (command === 'get_settings') return settings + if (command === 'connection_state') return { connected: false, needs_reauth: false, playback_authorized: false } + if (command === 'spotify_sync_status') return spotifyStatus() + if (command === 'lastfm_state') return lastfm + if (command === 'lastfm_import_state') return lastfmImport + if (command === 'playlists_list') return [] + if (command === 'subscribe_main_events') { + channel = args?.channel as { onmessage: (event: MainEvent) => void } + return 1 + } + return null + }) + const view = await render() + await waitFor(() => expect(channel).toBeDefined()) + await act(async () => channel!.onmessage({ type: 'spotifySyncError' })) + expect(view.querySelector('[role="alert"]')?.textContent).toContain('cached library is unchanged') + expect(view.querySelector('[role="alert"] button[aria-label="Dismiss error"]')).not.toBeNull() + + await act(async () => view.querySelector('[role="alert"] button[aria-label="Dismiss error"]')!.click()) + expect(view.querySelector('.error-banner')).toBeNull() + + await act(async () => channel!.onmessage({ type: 'spotifySyncError' })) + expect(view.querySelector('.error-banner[role="alert"]')?.textContent).toContain('Try another network or VPN') + }) + + it('does not let a late Spotify search retry replace results for a newer query', async () => { + vi.useFakeTimers() + const browse: BrowseView = { + facets: { cats: [], arts: [], albs: [] }, tracks: [], + albumRating: null, albumRatingArtist: null, albumRatingAmbiguous: false, + counts: { tracks: 0, totalSecs: 0, perSource: { music: 0, podcasts: 0, audiobooks: 0 } }, + } + const settings: Settings = { ...defaultSettings, theme: 'light' } + const lastfm: LastFmState = { available: false, connected: false, username: null, pending: false, reconnectRequired: false, problem: null } + const oldResults: SpotifyResults = { + artists: { items: [{ id: 'old-artist', name: 'Old Artist', descriptor: 'Rock', imageUrl: null }], total: 1, nextOffset: null }, + albums: { items: [], total: 0, nextOffset: null }, tracks: { items: [], total: 0, nextOffset: null }, + } + const newResults: SpotifyResults = { + artists: { items: [{ id: 'new-artist', name: 'New Artist', descriptor: 'Jazz', imageUrl: null }], total: 1, nextOffset: null }, + albums: { items: [], total: 0, nextOffset: null }, tracks: { items: [], total: 0, nextOffset: null }, + } + let searchCalls = 0 + const pendingSearches: Array<{ query: string; resolve: (results: SpotifyResults) => void }> = [] + invokeMock.mockImplementation(async (command, args) => { + if (command === 'browse') return browse + if (command === 'get_settings') return settings + if (command === 'connection_state') return { connected: true, needs_reauth: false, playback_authorized: true } + if (command === 'spotify_sync_status') return spotifyStatus() + if (command === 'lastfm_state') return lastfm + if (command === 'lastfm_import_state') return idleLastFmImport() + if (command === 'playlists_list') return [] + if (command === 'subscribe_main_events') return 1 + if (command === 'spotify_search') { + searchCalls += 1 + if (searchCalls === 1) throw new Error('network blocked') + const pending = deferred() + pendingSearches.push({ query: String(args?.query), resolve: pending.resolve }) + return pending.promise + } + return null + }) + const view = await render() + await act(async () => [...view.querySelectorAll('.scope-pills button')].find((button) => button.textContent === 'Spotify')!.click()) + const input = view.querySelector('input.search')! + await typeInput(input, 'old') + await act(async () => { vi.advanceTimersByTime(100) }) + await act(async () => { vi.advanceTimersByTime(300); await Promise.resolve() }) + expect(searchCalls).toBe(1) + expect(view.querySelector('.spotify-search-error[role="alert"]')).not.toBeNull() + await act(async () => view.querySelector('.spotify-search-error button:not([aria-label="Dismiss error"])')!.click()) + await act(async () => { vi.advanceTimersByTime(300); await Promise.resolve() }) + expect(searchCalls).toBe(2) + + await typeInput(input, 'new') + await act(async () => { vi.advanceTimersByTime(100) }) + await act(async () => { vi.advanceTimersByTime(300); await Promise.resolve() }) + expect(searchCalls).toBe(3) + await act(async () => pendingSearches[0].resolve(oldResults)) + expect(view.textContent).not.toContain('Old Artist') + + await act(async () => pendingSearches[1].resolve(newResults)) + expect(view.textContent).toContain('New Artist') + }) + it('keeps unavailable artist follow state retryable and rejects a late stale retry', async () => { const lateArtistA = deferred() let artistACalls = 0 @@ -555,6 +674,69 @@ describe('mounted native interaction boundaries', () => { expect(invokeMock.mock.calls).toHaveLength(before) }) + it('stops loading a rejected Spotify album and retries into the ready view', async () => { + const page: AlbumPageView = { + uri: 'spotify:album:retry', name: 'Retry Release', artist: 'Artist', artistId: 'artist', albumType: 'Album', year: '2026', + imageUrl: null, totalDurationSecs: 180, savedAlbum: false, contentComplete: false, addedAt: null, albumRating: null, + tracks: [{ uri: 'spotify:track:retry', name: 'Retry Track', trackNo: 1, durationSecs: 180, enabled: true, trackId: null, savedIndividually: false, rating: null }], + } + let albumCalls = 0 + invokeMock.mockImplementation(async (command) => { + if (command !== 'spotify_album_page') return null + albumCalls += 1 + if (albumCalls === 1) throw new Error('network blocked') + return page + }) + const view = await render( {})} onAddTrack={vi.fn(async () => {})} onRemoveTrack={vi.fn(async () => {})} onPlay={vi.fn()} onPlaylist={vi.fn()} onClose={vi.fn()} onError={vi.fn()} />) + await waitFor(() => expect(view.querySelector('.spotify-page-error[role="alert"]')).not.toBeNull()) + expect(view.textContent).toContain('Couldn’t load this Spotify album.') + expect(view.textContent).toContain('cached library is unchanged') + expect(view.textContent).not.toContain('Loading album…') + expect([...view.querySelectorAll('.spotify-page-error button')].map((button) => button.textContent)).toEqual(['Retry']) + + await act(async () => view.querySelector('.spotify-page-error button')!.click()) + await waitFor(() => expect(view.querySelector('h1')?.textContent).toBe(page.name)) + expect(albumCalls).toBe(2) + }) + + it('keeps cached Spotify search rows while paging errors can be dismissed and retried', async () => { + const artists = Array.from({ length: 10 }, (_, index) => ({ id: `artist-${index}`, name: `Artist ${index}`, descriptor: 'Rock', imageUrl: null })) + const initial: SpotifyResults = { + artists: { items: artists, total: 20, nextOffset: 10 }, + albums: { items: [], total: 0, nextOffset: null }, + tracks: { items: [], total: 0, nextOffset: null }, + } + const next: SpotifyResults = { + ...initial, + artists: { items: artists.slice(5).map((artist, index) => ({ ...artist, id: `artist-${index + 10}`, name: `Artist ${index + 10}` })), total: 20, nextOffset: null }, + } + let pageCalls = 0 + invokeMock.mockImplementation(async (command) => { + if (command !== 'spotify_search') return null + pageCalls += 1 + if (pageCalls < 3) throw new Error('network blocked') + return next + }) + const view = await render( {})} onAddTrack={vi.fn(async () => {})} onRemoveTrack={vi.fn(async () => {})} onPlay={vi.fn()} onPlaylist={vi.fn()} onClose={vi.fn()} onError={vi.fn()} />) + const more = () => [...view.querySelectorAll('.spotify-search-more button')].find((button) => button.textContent?.includes('more artists')) + await waitFor(() => expect(more()).not.toBeUndefined()) + expect(view.querySelectorAll('.spotify-search-row')).toHaveLength(5) + + await act(async () => more()!.click()) + await waitFor(() => expect(view.querySelector('.spotify-search-error[role="alert"]')?.textContent).toContain('Cached artists results are unchanged')) + expect(view.querySelectorAll('.spotify-search-row')).toHaveLength(5) + await act(async () => view.querySelector('.spotify-search-error button[aria-label="Dismiss error"]')!.click()) + expect(view.querySelector('.spotify-search-error[role="alert"]')).toBeNull() + + await act(async () => more()!.click()) + await waitFor(() => expect(view.querySelector('.spotify-search-error[role="alert"]')).not.toBeNull()) + await act(async () => view.querySelector('.spotify-search-error button:not([aria-label="Dismiss error"])')!.click()) + await waitFor(() => expect(view.querySelectorAll('.spotify-search-row')).toHaveLength(15)) + expect(pageCalls).toBe(3) + }) + it.each([['alb', false], ['art', false], ['alb', true]] as const)('opens the right-clicked %s above separated column options (local only: %s)', async (facet, localOnly) => { const tracks = [ { ...track(1, 'Selected'), art: 'Old Artist', alb: 'Old Album' }, @@ -980,7 +1162,7 @@ describe('mounted native interaction boundaries', () => { expect(view.querySelector('[data-import-nav="queue"]')!.disabled).toBe(true) await act(async () => acknowledgement.resolve(null)) if (fail) { - await waitFor(() => expect(view.querySelector('[role="alert"]')?.textContent).toContain('Disk is full')) + await waitFor(() => expect(view.querySelector('[role="alert"]')?.textContent).toContain('Your import queue is unchanged')) expect(view.querySelector('#import-review-title')?.textContent).toContain('Release One') expect([...view.querySelectorAll('button')].find((button) => button.textContent === 'Accept')?.disabled).toBe(false) } else { @@ -1335,7 +1517,7 @@ describe('mounted native interaction boundaries', () => { expect(view.querySelector('#import-review-title')?.textContent).toContain('Release Three') expect(view.querySelectorAll('[data-import-nav="queue"]')).toHaveLength(1) await act(async () => secondSave.resolve()) - expect(view.querySelector('[role="alert"]')?.textContent).toContain('Could not ignore Release Two: Error: Disk unavailable') + expect(view.querySelector('[role="alert"]')?.textContent).toContain('Your import queue is unchanged') expect(view.querySelector('#import-review-title')?.textContent).toContain('Release Three') expect([...view.querySelectorAll('[data-import-nav="queue"]')].map((row) => row.textContent)).toEqual([expect.stringContaining('Release Two'), expect.stringContaining('Release Three')]) expect(queueReads()).toBe(initialReads + 1) @@ -1476,10 +1658,37 @@ describe('mounted native interaction boundaries', () => { expect(view.querySelector('.import-limit-reset')?.textContent).toContain('did not provide a reset time') await emitNativeEvent('lastfm-import-apply-finished', { status: 'failed', batchId: 1, code: 'apply-failed', message: 'Spotify rate limited until tomorrow.', retryAt }) - await waitFor(() => expect(view.querySelector('[role="alert"]')?.textContent).toContain('Spotify rate limited until tomorrow.')) + await waitFor(() => expect(view.querySelector('[role="alert"]')?.textContent).toContain('Your import queue is unchanged')) expect(view.querySelector('.import-limit-reset')).toBeNull() }) + it('dismisses a transient Last.fm apply alert while retaining the durable failed batch', async () => { + const fixtures = importerFixtures() + let queue: ImportQueueItem[] = fixtures.queue + invokeMock.mockImplementation(async (command, args) => { + if (command === 'lastfm_import_state') return fixtures.state + if (command === 'lastfm_import_queue') return { cursor: 0, items: queue, total: queue.length, nextCursor: null } + if (command === 'lastfm_import_page') return fixtures.pages.get(Number(args?.batchId)) + if (command === 'metadata_values') return { cats: [], arts: [], albs: [] } + if (command === 'get_appearance') return { theme: 'light' } + return null + }) + const view = await render() + await waitFor(() => expect(view.querySelector('#import-review-title')?.textContent).toContain('Release One')) + queue = fixtures.queue.map((item) => item.page === 1 + ? { ...item, status: 'failed' as const, error: 'Disk full', errorCode: 'apply-failed', retryAt: null } + : item) + await emitNativeEvent('lastfm-import-apply-finished', { status: 'failed', batchId: 1, code: 'apply-failed', message: 'provider response body: secret', retryAt: null }) + await waitFor(() => expect(view.querySelector('[role="alert"]')?.textContent).toContain('Your import queue is unchanged')) + expect(view.querySelector('[role="alert"]')?.textContent).not.toContain('provider response body: secret') + + await act(async () => view.querySelector('[role="alert"] button[aria-label="Dismiss error"]')!.click()) + await waitFor(() => expect(view.querySelector('[role="alert"]')).toBeNull()) + expect(view.textContent).toContain('Apply failed: Disk full') + expect(view.querySelector('.import-review-footer')?.textContent).toContain('This batch failed and its choices are frozen.') + expect(view.querySelector('.import-review-footer button')?.textContent).toBe('Retry Apply') + }) + it('rejects malformed apply events, clears limit metadata, and refreshes the durable queue', async () => { const fixtures = importerFixtures() let queue = [{ ...fixtures.queue[0], status: 'failed' as const, error: 'Spotify rate limited legacy prose', errorCode: 'unknown-code', retryAt: Math.ceil(Date.now() / 1000) + 60 }] @@ -2023,7 +2232,7 @@ describe('mounted native interaction boundaries', () => { if (fail) { await act(async () => acknowledgement.resolve(null)) expect(view.querySelector('#import-review-title')?.textContent).toContain('Release One') - expect(view.textContent).toContain('Disk is full') + expect(view.textContent).toContain('Your import queue is unchanged') } else { await act(async () => acknowledgement.resolve(fixtures.state)) expect(view.querySelector('#import-review-title')).toBeNull() @@ -2361,7 +2570,8 @@ describe('library track decisions', () => { writeFileSync(`${process.env.RETUNE_DECISIONS_PREVIEW}-review.html`, '' + copy.innerHTML + '') } await clickText(view, 'Merge tracks') - expect(view.querySelector('[role="alert"]')?.textContent).toContain('Disk unavailable') + expect(view.querySelector('[role="alert"]')?.textContent).toContain('Couldn’t merge these tracks. Your library is unchanged. Try again.') + expect(view.querySelector('[role="alert"]')?.textContent).not.toContain('Disk unavailable') expect(changed).not.toHaveBeenCalled() await clickText(view, 'Merge tracks') expect(invokeMock).toHaveBeenLastCalledWith('merge_library_tracks', { ids: [1, 2, 3], targetUri: fixture.target!.uri, edit: { name: 'My merged recording', art: 'Lauv', alb: fixture.target!.alb, cat: 'Rock', rating: 4, playCount: { mode: 'custom', value: 25 } }, expectedRevision: 'current' }) @@ -2431,7 +2641,8 @@ describe('library track decisions', () => { const changed = vi.fn() const view = await render() await clickText(view, 'Remove from Retune') - expect(view.querySelector('[role="alert"]')?.textContent).toContain('Write failed') + expect(view.querySelector('[role="alert"]')?.textContent).toContain('Couldn’t remove these tracks from Retune. Your library is unchanged. Try again.') + expect(view.querySelector('[role="alert"]')?.textContent).not.toContain('Write failed') await clickText(view, 'Remove from Retune') expect(invokeMock).toHaveBeenLastCalledWith('remove_retune_tracks', { ids: [1, 2] }) await act(async () => root!.render()) diff --git a/docs/architecture/spotify.md b/docs/architecture/spotify.md index f54ff71..35679f6 100644 --- a/docs/architecture/spotify.md +++ b/docs/architecture/spotify.md @@ -113,6 +113,8 @@ timestamp, an empty cooldown leaves the daily value in place, and an expired timestamp is removed. Disconnect and sign-out do not clear either value. Startup and the bottom status bar use the same effective deadline, and automatic resume uses that exact deadline without additional jitter. +Automatic startup and resume failures publish the payload-free `spotifySyncError` +main event, while a manual sync command returns its local actionable error copy. The shared cooldown store keeps one global Development Mode quota record under `__global_quota__` plus transient records by endpoint family; legacy per-endpoint diff --git a/docs/tauri.md b/docs/tauri.md index a3ae586..da4fa61 100644 --- a/docs/tauri.md +++ b/docs/tauri.md @@ -74,8 +74,11 @@ The core event bus carries targeted, payload-free invalidations such as library, playlist, connection, settings, appearance, and import-state changes. A consumer responds by fetching an authorized snapshot. Main-only player state, startup notices, operation errors, and local-import results use the private -main channel. Consumers subscribe before taking their initial snapshot so a -change cannot fall between snapshot and subscription. +main channel. Automatic Spotify sync failures use the payload-free +`spotifySyncError` variant so the frontend can select its closed recovery copy; +other operation failures use the generic `operationError` fallback. Consumers +subscribe before taking their initial snapshot so a change cannot fall between +snapshot and subscription. Native menu and media-key actions enter the same Rust-owned application paths as UI actions. Playback backends emit neutral events; the controller/reducer