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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
}
Expand Down Expand Up @@ -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<R: tauri::Runtime>(
app: &tauri::AppHandle<R>,
event: main_events::MainEvent,
Expand Down Expand Up @@ -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);
}
}
});
}
Expand Down
26 changes: 26 additions & 0 deletions apps/desktop/src-tauri/src/main_events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pub(crate) enum MainEvent {
PlayerState(PlayerStateEvent),
PlaybackAuthorizationRequired(PlaybackAuthorizationPrompt),
OperationError(String),
SpotifySyncError,
OperationRecovered,
LocalImportComplete(ImportSummary),
StartupNotice(String),
Expand All @@ -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),
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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();

Expand Down
37 changes: 31 additions & 6 deletions apps/desktop/src-tauri/src/spotify_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<T>(result: Result<T, String>) -> 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,
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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);
}
}
});
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
17 changes: 16 additions & 1 deletion apps/desktop/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down Expand Up @@ -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; }
Expand Down
Loading
Loading