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
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ const APP_COMMANDS: &[&str] = &[
"lastfm_import_rename_batch",
"start_lastfm_import",
"sync_lastfm_plays",
"lastfm_sync_status",
"lastfm_import_review",
"lastfm_import_options",
"lastfm_import_count_mode",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!

[[permission]]
identifier = "allow-lastfm-sync-status"
description = "Enables the lastfm_sync_status command without any pre-configured scope."
commands.allow = ["lastfm_sync_status"]

[[permission]]
identifier = "deny-lastfm-sync-status"
description = "Denies the lastfm_sync_status command without any pre-configured scope."
commands.deny = ["lastfm_sync_status"]
7 changes: 7 additions & 0 deletions apps/desktop/src-tauri/permissions/importer.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@
identifier = "importer-commands"
description = "Minimal commands available to the Last.fm importer window."
permissions = [
"allow-lastfm-state",
"allow-connect-lastfm",
"allow-finish-lastfm",
"allow-sync-lastfm-plays",
"allow-open-preferences",
"allow-lastfm-sync-status",

"allow-get-appearance",
"allow-genre-values",
"allow-lastfm-import-state",
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src-tauri/src/lastfm_import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -872,6 +872,7 @@ fn state_view(session: Option<&LastFmImportSessionV2>) -> ImportStateView {
retryable_error: session.and_then(|session| session.retryable_error.clone()),
search_terms: session.map(|session| session.search_terms).unwrap_or(true),
syncing: false,
sync_progress: None,
last_synced_at: None,
pending_review: 0,
sync_problem: None,
Expand Down Expand Up @@ -936,6 +937,7 @@ fn suspended_state_view(session: &LastFmImportSessionV2) -> ImportStateView {
}),
search_terms: true,
syncing: false,
sync_progress: None,
last_synced_at: None,
pending_review: 0,
sync_problem: None,
Expand Down Expand Up @@ -1073,7 +1075,7 @@ fn cached_spotify_account(
spotify_membership: &crate::spotify_membership::SpotifyMembership,
) -> Option<String> {
let library = spotify_membership.snapshot();
library.is_exact().then_some(library.account_id)
(!library.account_id.is_empty()).then_some(library.account_id)
}

async fn recover_before_apply_job(
Expand Down
45 changes: 29 additions & 16 deletions apps/desktop/src-tauri/src/lastfm_import/application.rs
Original file line number Diff line number Diff line change
Expand Up @@ -493,28 +493,41 @@ where
StartWorker: FnMut(),
Changed: FnMut(),
{
let username = lastfm_username(self.lastfm).await?;
if self.service.next_apply_job().await.is_some()
|| self.service.sync_snapshot().await.accept_all.is_some()
{
start_worker();
}
self.lastfm.settle_before_import().await;
let Some(_run) = self.service.claim_sync_runner() else {
let Some(run) = self.service.claim_sync_runner() else {
return Ok(self.service.state().await);
};
changed();
let result = run_incremental_sync(
self.library,
self.membership,
self.lastfm,
self.service,
&username,
)
.await;
let result = async {
set_sync_problem(self.service, None).await?;
let username = lastfm_username(self.lastfm).await?;
if self.service.next_apply_job().await.is_some()
|| self.service.sync_snapshot().await.accept_all.is_some()
{
start_worker();
}
self.lastfm.settle_before_import().await;
if self.service.sync_snapshot().await.spotify_account_id.is_some()
&& super::cached_spotify_account(self.membership).is_none()
{
if !(self.connected)()? {
return Err("Connect Spotify, then retry Last.fm sync. Your saved progress is preserved.".into());
}
super::incremental::recover_sync_identity(self.membership, &self.provider).await?;
}
run_incremental_sync(
self.library,
self.membership,
self.lastfm,
self.service,
&username,
).await?;
set_sync_problem(self.service, None).await
}.await;
if let Err(error) = &result {
let _ = set_sync_problem(self.service, Some(error.clone())).await;
}
// All status writes, including preflight failures, belong to this runner.
drop(run);
let view = self.service.state().await;
changed();
result.map(|()| view)
Expand Down
17 changes: 17 additions & 0 deletions apps/desktop/src-tauri/src/lastfm_import/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,14 @@ fn queue_page_request(
Ok((cursor, limit))
}

#[tauri::command]
pub(crate) async fn lastfm_sync_status(app: tauri::AppHandle) -> super::model::SyncStatus {
app.state::<crate::AppState>()
.lastfm_import
.sync_status()
.await
}

#[tauri::command]
pub(crate) async fn sync_lastfm_plays(app: tauri::AppHandle) -> Result<ImportStateView, String> {
let state = app.state::<crate::AppState>();
Expand All @@ -250,6 +258,15 @@ pub(crate) async fn sync_lastfm_plays(app: tauri::AppHandle) -> Result<ImportSta
},
)
.await;
match &result {
Ok(view) if !view.syncing => {
log::info!(target: crate::diagnostics::LOG_TARGET, "Last.fm sync completed; {} plays pending review.", view.pending_review)
}
Ok(_) => {}
Err(error) => {
log::warn!(target: crate::diagnostics::LOG_TARGET, "Last.fm sync failed: {error}");
}
}
result
}

Expand Down
39 changes: 38 additions & 1 deletion apps/desktop/src-tauri/src/lastfm_import/incremental.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ pub(super) async fn set_sync_problem(
service: &Service,
message: Option<String>,
) -> Result<(), String> {
if let Some(problem) = &message {
if service.sync_status().await.sync_problem.as_ref() != Some(problem) {
log::warn!(target: crate::diagnostics::LOG_TARGET, "Last.fm sync: {problem}");
}
}
service
.mutate_sync(|state| {
state.sync_problem = message;
Expand Down Expand Up @@ -234,7 +239,11 @@ pub(super) async fn run_incremental_sync(
.as_deref()
.is_some_and(|expected| spotify_account_id.as_deref() != Some(expected))
{
let message = "Last.fm sync is suspended because the connected Spotify account changed.";
let message = if spotify_account_id.is_none() {
"Last.fm sync is paused until Spotify account identity is available. Connect Spotify and sync your library, then retry here."
} else {
"Last.fm sync is paused because a different Spotify account is connected. Reconnect the Spotify account used for this import, then retry. Your saved progress is preserved."
};
set_sync_problem(service, Some(message.into())).await?;
return Err(message.into());
}
Expand Down Expand Up @@ -410,3 +419,31 @@ pub(super) async fn wait_for_incremental_window_retry(
}
Ok(())
}

/// Resolve identity without requiring a complete saved-library download.
pub(super) async fn recover_sync_identity<T: retune_spotify::client::Transport>(
membership: &crate::spotify_membership::SpotifyMembership,
provider: impl FnOnce() -> Result<Arc<retune_spotify::client::SpotifyClient<T>>, String>,
) -> Result<(), String> {
let mut guard = membership.lock().await;
let mut cached = guard.snapshot();
if !cached.account_id.is_empty() {
return Ok(());
}
let profile = provider()?.me().await.map_err(|error| {
format!("Could not verify Spotify for Last.fm sync: {error}. Reconnect Spotify and retry.")
})?;
cached.account_id = profile
.account_id()
.ok_or_else(|| {
"Spotify did not return an account ID. Reconnect Spotify and retry Last.fm sync."
.to_string()
})?
.to_owned();
// Identity alone does not make the membership snapshot complete.
cached.complete = false;
guard
.replace(cached)
.await
.map_err(|error| error.to_string())
}
144 changes: 144 additions & 0 deletions apps/desktop/src-tauri/src/lastfm_import/integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11996,3 +11996,147 @@ async fn legacy_failed_job_without_known_code_restarts_as_apply_failed() {
assert_eq!(projected.error_code, Some(ApplyFailureCode::ApplyFailed));
assert_eq!(projected.retry_at, Some(9_999));
}

#[tokio::test]
async fn incremental_sync_recovers_missing_identity_without_full_library_sync() {
let directory = tempfile::tempdir().unwrap();
let (_, service, state) = test_app_state(directory.path(), Library::new(), &[]);
let client = Arc::new(fake_client(
[Response::json(
200,
serde_json::json!({
"id": "display-id", "account_id": "spotify"
}),
)],
"",
));
super::incremental::recover_sync_identity(&state.spotify_membership, || {
Ok(Arc::clone(&client))
})
.await
.unwrap();
assert_eq!(
cached_spotify_account(&state.spotify_membership).as_deref(),
Some("spotify")
);
assert!(!state.spotify_membership.snapshot().complete);
super::incremental::recover_sync_identity(&state.spotify_membership, || {
Ok(Arc::clone(&client))
})
.await
.unwrap();
assert_eq!(client.transport().requests().len(), 1);
service
.mutate_sync(|sync| {
sync.lastfm_username = Some("user".into());
sync.spotify_account_id = Some("spotify".into());
sync.synced_through = Some(u64::MAX);
sync.sync_problem = Some("previous failure".into());
Ok(())
})
.await
.unwrap();
let use_cases = application::UseCases::new(
application::Owners {
service: &service,
lastfm: &state.lastfm,
membership: &state.spotify_membership,
library: &state.library,
settings: &state.settings,
cooldown_store: &state.cooldown_store,
},
|| Err("provider should not be needed".into()),
|| Ok(true),
);
let view = use_cases.sync(|| {}, || {}).await.unwrap();
assert!(!view.syncing);
assert!(view.sync_problem.is_none());
state.spotify_membership.set_for_test(SpotifyLibraryState {
account_id: "different".into(),
..SpotifyLibraryState::default()
});
assert!(use_cases
.sync(|| {}, || {})
.await
.unwrap_err()
.contains("different Spotify account"));
assert!(!service.state().await.syncing);
assert_eq!(service.sync_snapshot().await.synced_through, Some(u64::MAX));
}

#[tokio::test]
async fn incremental_progress_projects_checkpoint_counts_separately_from_history() {
let directory = tempfile::tempdir().unwrap();
let service = Service::new(directory.path());
service
.mutate_sync(|sync| {
sync.lastfm_username = Some("user".into());
sync.synced_through = Some(10);
sync.active = Some(IncrementalRange {
from: 10,
to: 20,
query_from: 9,
query_to: 21,
cache_id: incremental_cache_id("user", 10, 20),
next_page: 5,
downloaded_pages: 3,
total_pages: Some(8),
..IncrementalRange::default()
});
Ok(())
})
.await
.unwrap();
let view = service.state().await;
assert_eq!(view.downloaded_pages, 0);
assert_eq!(
view.sync_progress.unwrap(),
super::model::SyncProgress {
downloaded_pages: 3,
total_pages: Some(8)
}
);
}

#[tokio::test]
async fn sync_preflight_failure_cannot_overwrite_another_runner() {
let directory = tempfile::tempdir().unwrap();
let (lastfm, service, state) = test_app_state(directory.path(), Library::new(), &[]);
lastfm.disconnect().await.unwrap();
let use_cases = application::UseCases::new(
application::Owners {
service: &service,
lastfm: &lastfm,
membership: &state.spotify_membership,
library: &state.library,
settings: &state.settings,
cooldown_store: &state.cooldown_store,
},
|| Err("provider should not be needed".into()),
|| Ok(true),
);
let active = service.claim_sync_runner().unwrap();
set_sync_problem(&service, Some("active retry status".into()))
.await
.unwrap();
let view = use_cases
.sync(
|| panic!("must not start work"),
|| panic!("must not publish a change"),
)
.await
.unwrap();
assert!(view.syncing);
assert_eq!(view.sync_problem.as_deref(), Some("active retry status"));
drop(active);
let mut changes = 0;
let error = use_cases
.sync(|| panic!("preflight must fail first"), || changes += 1)
.await
.unwrap_err();
let status = service.sync_status().await;
assert!(!status.syncing);
assert_eq!(status.sync_problem.as_deref(), Some(error.as_str()));
assert_eq!(changes, 2);
assert!(service.claim_sync_runner().is_some());
}
18 changes: 18 additions & 0 deletions apps/desktop/src-tauri/src/lastfm_import/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -406,13 +406,21 @@ pub(crate) struct ImportStateView {
pub retryable_error: Option<RetryableError>,
pub search_terms: bool,
pub syncing: bool,
pub sync_progress: Option<SyncProgress>,
pub last_synced_at: Option<u64>,
pub pending_review: usize,
pub sync_problem: Option<String>,
pub applying_all: bool,
pub spotify_limit: Option<crate::store::Cooldown>,
}

#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SyncProgress {
pub downloaded_pages: u32,
pub total_pages: Option<u32>,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum QueueStatus {
Expand Down Expand Up @@ -828,3 +836,13 @@ mod tests {
assert!(serde_json::from_str::<ReviewAction>("\"unknown\"").is_err());
}
}

#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SyncStatus {
pub syncing: bool,
pub sync_progress: Option<SyncProgress>,
pub sync_problem: Option<String>,
pub last_synced_at: Option<u64>,
pub pending_review: usize,
}
Loading
Loading