From 7be700a94e5c118e33a3dcb0645b36e02dffde9e Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Mon, 17 Aug 2026 18:44:02 -0400 Subject: [PATCH 01/11] feat(desktop): add resumable Last.fm importer --- ARCHITECTURE.md | 30 + .../src-tauri/capabilities/default.json | 3 +- apps/desktop/src-tauri/src/lastfm.rs | 69 +- apps/desktop/src-tauri/src/lastfm_import.rs | 3552 +++++++++++++++++ apps/desktop/src-tauri/src/lib.rs | 18 + apps/desktop/src-tauri/src/provider.rs | 40 + .../desktop/src-tauri/src/spotify_commands.rs | 63 +- apps/desktop/src/App.css | 2 + apps/desktop/src/App.tsx | 16 +- apps/desktop/src/LastFmImporter.tsx | 375 ++ apps/desktop/src/appState.ts | 7 +- apps/desktop/src/dialogViews.tsx | 4 +- apps/desktop/src/lastfmImportState.ts | 175 + apps/desktop/src/lastfmImporter.css | 145 + apps/desktop/src/main.tsx | 6 +- apps/desktop/src/types.ts | 20 + apps/desktop/test/ui.test.ts | 70 + crates/retune-spotify/src/client.rs | 15 +- docs/architecture/library.md | 29 + docs/architecture/persistence.md | 15 + docs/architecture/spotify.md | 15 + 21 files changed, 4643 insertions(+), 26 deletions(-) create mode 100644 apps/desktop/src-tauri/src/lastfm_import.rs create mode 100644 apps/desktop/src/LastFmImporter.tsx create mode 100644 apps/desktop/src/lastfmImportState.ts create mode 100644 apps/desktop/src/lastfmImporter.css diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7aa0c48..80e84b4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -13,6 +13,7 @@ desktop application shell ├── retune-spotify OAuth, Web API, normalization ├── retune-audio local-file scan, tags, decoding ├── playback controller + Spotify/file backends + ├── lastfm_import account-bound snapshot, matching, review, and apply boundary └── store app-data persistence ``` @@ -38,6 +39,10 @@ shell owns orchestration and persistence. Domain crates do not depend on Tauri. - The playback controller owns the canonical queue, active order, generation, and user-facing playback state. - React owns view selection, navigation, dialog state, and transient gestures. +- `lastfm_import` owns the resumable Last.fm snapshot, compact source variants, + Spotify matching results, review decisions, and account-bound application. + Its parsing and review helpers do not add network or filesystem concerns to + `retune-core`. ## Principal flows @@ -61,6 +66,31 @@ The controller selects the local-file engine or configured Spotify backend for the current URI. Backends emit neutral events; one reducer rejects stale events, updates state, advances the queue, and records threshold-based play counts. +### Last.fm import + +The Preferences action opens a second `lastfm-importer` WebviewWindow at +1320×840. The importer captures one fixed Last.fm `to` timestamp, fetches +`user.getRecentTracks` sequentially at 200 rows per page, skips now-playing and +undated rows, and atomically checkpoints compact aggregate variants plus the +next page. Its session defaults independently control content and historical +play counts (both on by default, with whole-album content mode off); at least +one remains selected. Matching then runs sequentially through the shared +Spotify client; album candidates are limited to ten and classified from real +track-set overlap without monopolizing the membership gate. + +Fuzzy count strategies are persisted once per Spotify track target for the +session, and the review page discloses every source row in the session that +resolves to that target. “Show Spotify search terms” is likewise one persisted +session preference, restored when the importer resumes rather than copied into +each page’s options. + +Whole-album acceptance sends one album URI to Spotify and updates +`SavedAlbumRecord`; selected-track acceptance sends only track URIs and updates +`saved_tracks`. Upstream membership completes before the atomic local history +and metadata mutation, and a durable decision is marked done only after that +mutation succeeds. The session is bound to both the Last.fm username and +Spotify `/me` account ID; a mismatch suspends it. + ## Cross-cutting rules - Provider URI is the normal deduplication identity; local files use canonical diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index 6a22dad..e6552b5 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -3,7 +3,8 @@ "identifier": "default", "description": "enables the default permissions", "windows": [ - "main" + "main", + "lastfm-importer" ], "permissions": [ "core:default", diff --git a/apps/desktop/src-tauri/src/lastfm.rs b/apps/desktop/src-tauri/src/lastfm.rs index 464f155..3ff337d 100644 --- a/apps/desktop/src-tauri/src/lastfm.rs +++ b/apps/desktop/src-tauri/src/lastfm.rs @@ -221,7 +221,7 @@ fn write_secret_json(path: &Path, value: &T) -> Result<(), String> atomic_write(path, &bytes, true) } -fn atomic_write(path: &Path, bytes: &[u8], secret: bool) -> Result<(), String> { +pub(crate) fn atomic_write(path: &Path, bytes: &[u8], secret: bool) -> Result<(), String> { let parent = path .parent() .ok_or_else(|| "Last.fm store path has no parent.".to_string())?; @@ -350,6 +350,13 @@ enum Failure { Response, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ImportFetchError { + pub message: String, + pub retryable: bool, + pub account_mismatch: bool, +} + impl Failure { fn code(self) -> Option { match self { @@ -871,6 +878,66 @@ impl Service { } } + pub(crate) async fn import_recent_tracks_page( + &self, + username: &str, + page: u32, + to: u64, + ) -> Result { + if let Err(message) = self.ensure_available().await { + return Err(ImportFetchError { + message, + retryable: false, + account_mismatch: false, + }); + } + let connected_username = self + .runtime + .lock() + .await + .session + .as_ref() + .map(|session| session.username.clone()); + if connected_username.as_deref() != Some(username) { + return Err(ImportFetchError { + message: + "The connected Last.fm account changed; resume the importer after reconnecting." + .into(), + retryable: false, + account_mismatch: true, + }); + } + let params = vec![ + ("user".into(), username.into()), + ("page".into(), page.to_string()), + ( + "limit".into(), + crate::lastfm_import::LASTFM_PAGE_LIMIT.to_string(), + ), + ("to".into(), to.to_string()), + ]; + for attempt in 0..=RETRY_DELAYS.len() { + match self + .post("user.getRecentTracks", params.clone(), None) + .await + { + Ok(value) => return Ok(value), + Err(failure) if is_retryable(failure) && attempt < RETRY_DELAYS.len() => { + tokio::time::sleep(retry_delay(attempt)).await; + } + Err(failure) => { + let retryable = is_retryable(failure); + return Err(ImportFetchError { + message: self.handle_failure(failure).await, + retryable, + account_mismatch: false, + }); + } + } + } + unreachable!("the Last.fm import retry loop always returns") + } + async fn send_now_playing(&self, scrobble: Scrobble) { let session = { let runtime = self.runtime.lock().await; diff --git a/apps/desktop/src-tauri/src/lastfm_import.rs b/apps/desktop/src-tauri/src/lastfm_import.rs new file mode 100644 index 0000000..cf1be0f --- /dev/null +++ b/apps/desktop/src-tauri/src/lastfm_import.rs @@ -0,0 +1,3552 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use retune_core::model::{AlbumKey, Library, Rating, TrackEdit}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; +use tokio::sync::Mutex; + +pub(crate) const SESSION_VERSION: u8 = 1; +pub(crate) const LASTFM_PAGE_LIMIT: u32 = 200; +pub(crate) const MAX_SERIALIZED_SESSION_BYTES: usize = 100 * 1024 * 1024; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum ImportPhase { + Downloading, + Matching, + Review, + Done, + Suspended, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum CountMode { + Sum, + Overwrite, + Zero, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum Confidence { + Exact, + Likely, + Low, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum AlbumRelation { + BestMatch, + SameSongs, + Superset, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ImportDefaults { + pub import_content: bool, + pub include_historical_play_counts: bool, + pub whole_album: bool, +} + +impl Default for ImportDefaults { + fn default() -> Self { + Self { + import_content: true, + include_historical_play_counts: true, + whole_album: false, + } + } +} + +impl ImportDefaults { + fn validate(&self) -> Result<(), String> { + if !self.import_content && !self.include_historical_play_counts { + return Err( + "Select content or historical play counts before starting the import.".into(), + ); + } + if self.whole_album && !self.import_content { + return Err("Whole-album import requires content import to be enabled.".into()); + } + Ok(()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SourceVariant { + pub artist: String, + pub album: String, + pub track: String, + pub play_count: u64, + pub earliest: u64, + pub latest: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SourceRow { + pub stable_id: String, + pub artist: String, + pub album: String, + pub track: String, + pub variants: Vec, + pub play_count: u64, + pub earliest: u64, + pub latest: u64, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AlbumCandidate { + pub uri: String, + pub name: String, + pub artist: String, + pub track_uris: Vec, + #[serde(default)] + pub track_names: Vec, + #[serde(default)] + pub track_artists: Vec, + #[serde(default)] + pub track_albums: Vec, + pub relation: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MatchResult { + pub source_id: String, + pub search_term: String, + pub confidence: Option, + pub selected_uri: Option, + pub candidates: Vec, + pub track_matches: BTreeMap, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum RowStatus { + Pending, + Done, + Skipped, + IgnoredAlbum, + IgnoredArtist, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RowDecision { + pub status: RowStatus, + pub excluded: bool, +} + +impl Default for RowDecision { + fn default() -> Self { + Self { + status: RowStatus::Pending, + excluded: false, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PageOptions { + pub import_content: bool, + pub include_historical_play_counts: bool, + pub whole_album: bool, + pub genre: Option, + pub rating: Option, + pub selected_track_ids: BTreeSet, +} + +impl Default for PageOptions { + fn default() -> Self { + Self { + import_content: true, + include_historical_play_counts: true, + whole_album: false, + genre: None, + rating: None, + selected_track_ids: BTreeSet::new(), + } + } +} + +impl PageOptions { + fn from_defaults(defaults: &ImportDefaults) -> Self { + Self { + import_content: defaults.import_content, + include_historical_play_counts: defaults.include_historical_play_counts, + whole_album: defaults.whole_album, + ..Self::default() + } + } + + fn validate(&self) -> Result<(), String> { + ImportDefaults { + import_content: self.import_content, + include_historical_play_counts: self.include_historical_play_counts, + whole_album: self.whole_album, + } + .validate()?; + if self.rating.is_some_and(|rating| !(1..=5).contains(&rating)) { + return Err("Rating must be between 1 and 5.".into()); + } + Ok(()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RetryableError { + pub message: String, + pub attempt: u32, + pub retryable: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ImportBatch { + pub page: u32, + pub source_ids: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LastFmImportSessionV1 { + pub version: u8, + pub lastfm_username: String, + pub spotify_account_id: String, + pub snapshot_to: u64, + pub next_page: u32, + pub total_pages: Option, + pub total_scrobbles: u64, + pub included_scrobbles: u64, + pub skipped_now_playing: u64, + pub skipped_undated: u64, + pub phase: ImportPhase, + pub retryable_error: Option, + pub defaults: ImportDefaults, + pub batches: Vec, + pub rows: Vec, + pub matches: BTreeMap, + pub decisions: BTreeMap, + pub page_options: BTreeMap, + pub count_modes: BTreeMap, + pub search_terms: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ImportStateView { + pub phase: Option, + pub username: Option, + pub spotify_account_id: Option, + pub next_page: u32, + pub total_pages: Option, + pub total_scrobbles: u64, + pub included_scrobbles: u64, + pub matched_rows: usize, + pub match_total: usize, + pub defaults: ImportDefaults, + pub remaining: usize, + pub retryable_error: Option, + pub search_terms: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum QueueStatus { + Done, + Skipped, + IgnoredAlbum, + IgnoredArtist, + Excluded, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ImportQueueItem { + pub artist: String, + pub album: String, + pub play_count: u64, + pub latest: u64, + pub source_ids: Vec, + pub remaining: bool, + pub album_entities: u32, + pub track_entities: u32, + pub status: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ImportPageItem { + pub source: SourceRow, + pub decision: RowDecision, + pub match_result: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ImportPageView { + pub state: ImportStateView, + pub artist: String, + pub album: String, + pub page_number: usize, + pub page_count: usize, + pub rows: Vec, + pub options: PageOptions, + pub fuzzy_groups: BTreeMap>, + pub count_modes: BTreeMap, + pub locked_count_modes: BTreeSet, +} + +impl LastFmImportSessionV1 { + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn new( + lastfm_username: String, + spotify_account_id: String, + snapshot_to: u64, + ) -> Self { + Self::new_with_defaults( + lastfm_username, + spotify_account_id, + snapshot_to, + ImportDefaults::default(), + ) + } + + pub(crate) fn new_with_defaults( + lastfm_username: String, + spotify_account_id: String, + snapshot_to: u64, + defaults: ImportDefaults, + ) -> Self { + Self { + version: SESSION_VERSION, + lastfm_username, + spotify_account_id, + snapshot_to, + next_page: 1, + total_pages: None, + total_scrobbles: 0, + included_scrobbles: 0, + skipped_now_playing: 0, + skipped_undated: 0, + phase: ImportPhase::Downloading, + retryable_error: None, + defaults, + batches: Vec::new(), + rows: Vec::new(), + matches: BTreeMap::new(), + decisions: BTreeMap::new(), + page_options: BTreeMap::new(), + count_modes: BTreeMap::new(), + search_terms: true, + } + } + + pub(crate) fn remaining(&self) -> usize { + self.rows + .iter() + .filter(|row| { + let decision = self + .decisions + .get(&row.stable_id) + .cloned() + .unwrap_or_default(); + matches!(decision.status, RowStatus::Pending | RowStatus::Skipped) + && !decision.excluded + }) + .count() + } + + fn options_for(&self, artist: &str, album: &str) -> PageOptions { + self.page_options + .get(&format!("{artist}\u{1f}{album}")) + .cloned() + .unwrap_or_else(|| { + let selected_track_ids = self + .rows + .iter() + .filter(|row| { + row.artist == artist + && row.album == album + && matches!( + default_decision(self, &row.stable_id).status, + RowStatus::Pending | RowStatus::Skipped + ) + && !default_decision(self, &row.stable_id).excluded + }) + .map(|row| row.stable_id.clone()) + .collect(); + PageOptions { + selected_track_ids, + ..PageOptions::from_defaults(&self.defaults) + } + }) + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct ParsedRecentTracksPage { + pub page: u32, + pub total_pages: Option, + pub total: Option, + pub tracks: Vec, + pub skipped_now_playing: u64, + pub skipped_undated: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ParsedScrobble { + pub artist: String, + pub album: String, + pub track: String, + pub timestamp: u64, +} + +pub(crate) fn parse_recent_tracks_page(value: &Value) -> Result { + let recent = value + .get("recenttracks") + .ok_or_else(|| "Last.fm response did not contain recent tracks.".to_string())?; + let attributes = recent.get("@attr"); + let page = attributes + .and_then(|value| value.get("page")) + .and_then(value_string) + .and_then(|value| value.parse().ok()) + .unwrap_or(0); + let total_pages = attributes + .and_then(|value| value.get("totalPages")) + .and_then(value_string) + .and_then(|value| value.parse().ok()); + let total = attributes + .and_then(|value| value.get("total")) + .and_then(value_string) + .and_then(|value| value.parse().ok()); + let entries = match recent.get("track") { + Some(Value::Array(entries)) => entries.iter().collect::>(), + Some(Value::Object(_)) => vec![recent.get("track").expect("track was just checked")], + Some(Value::Null) | None => Vec::new(), + Some(_) => return Err("Last.fm recent tracks had an invalid track list.".into()), + }; + let mut parsed = ParsedRecentTracksPage { + page, + total_pages, + total, + ..ParsedRecentTracksPage::default() + }; + for entry in entries { + if is_now_playing(entry) { + parsed.skipped_now_playing += 1; + continue; + } + let artist = entry.get("artist").and_then(value_text).unwrap_or_default(); + let track = entry.get("name").and_then(value_string).unwrap_or_default(); + let album = entry.get("album").and_then(value_text).unwrap_or_default(); + let timestamp = entry + .get("date") + .and_then(|date| date.get("uts")) + .and_then(value_string) + .and_then(|value| value.parse().ok()) + .filter(|timestamp| *timestamp > 0); + let Some(timestamp) = timestamp else { + parsed.skipped_undated += 1; + continue; + }; + if artist.trim().is_empty() || track.trim().is_empty() { + parsed.skipped_undated += 1; + continue; + } + parsed.tracks.push(ParsedScrobble { + artist: artist.trim().to_owned(), + album: album.trim().to_owned(), + track: track.trim().to_owned(), + timestamp, + }); + } + Ok(parsed) +} + +fn value_string(value: &Value) -> Option<&str> { + value.as_str() +} + +fn value_text(value: &Value) -> Option { + value + .as_str() + .map(str::to_owned) + .or_else(|| value.get("#text").and_then(value_string).map(str::to_owned)) + .or_else(|| value.get("text").and_then(value_string).map(str::to_owned)) +} + +fn is_now_playing(value: &Value) -> bool { + matches!( + value + .get("@attr") + .and_then(|attributes| attributes.get("nowplaying")), + Some(Value::String(value)) if value == "1" || value.eq_ignore_ascii_case("true") + ) || matches!( + value + .get("@attr") + .and_then(|attributes| attributes.get("nowplaying")), + Some(Value::Number(value)) if value.as_u64() == Some(1) + ) || matches!( + value + .get("@attr") + .and_then(|attributes| attributes.get("nowplaying")), + Some(Value::Bool(true)) + ) +} + +pub(crate) fn normalize_for_match(value: &str) -> String { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn source_id(artist: &str, album: &str, track: &str) -> String { + format!( + "{}\u{1f}{}\u{1f}{}", + normalize_for_match(artist), + normalize_for_match(album), + normalize_for_match(track) + ) +} + +pub(crate) fn aggregate_scrobbles(rows: &mut Vec, scrobbles: &[ParsedScrobble]) { + for scrobble in scrobbles { + let id = source_id(&scrobble.artist, &scrobble.album, &scrobble.track); + let Some(row) = rows.iter_mut().find(|row| row.stable_id == id) else { + rows.push(SourceRow { + stable_id: id, + artist: scrobble.artist.clone(), + album: scrobble.album.clone(), + track: scrobble.track.clone(), + variants: Vec::new(), + play_count: 0, + earliest: scrobble.timestamp, + latest: scrobble.timestamp, + }); + let row = rows.last_mut().expect("row was just pushed"); + add_variant(row, scrobble); + continue; + }; + add_variant(row, scrobble); + } +} + +fn add_variant(row: &mut SourceRow, scrobble: &ParsedScrobble) { + row.play_count = row.play_count.saturating_add(1); + row.earliest = row.earliest.min(scrobble.timestamp); + row.latest = row.latest.max(scrobble.timestamp); + if let Some(variant) = row.variants.iter_mut().find(|variant| { + variant.artist == scrobble.artist + && variant.album == scrobble.album + && variant.track == scrobble.track + }) { + variant.play_count = variant.play_count.saturating_add(1); + variant.earliest = variant.earliest.min(scrobble.timestamp); + variant.latest = variant.latest.max(scrobble.timestamp); + return; + } + row.variants.push(SourceVariant { + artist: scrobble.artist.clone(), + album: scrobble.album.clone(), + track: scrobble.track.clone(), + play_count: 1, + earliest: scrobble.timestamp, + latest: scrobble.timestamp, + }); +} + +pub(crate) fn resolved_play_count(rows: &[&SourceRow], mode: CountMode) -> u64 { + match mode { + CountMode::Sum => rows + .iter() + .map(|row| row.play_count) + .fold(0, u64::saturating_add), + CountMode::Overwrite => rows + .iter() + .flat_map(|row| row.variants.iter()) + .map(|variant| variant.play_count) + .max() + .unwrap_or(0), + CountMode::Zero => 0, + } +} + +pub(crate) fn resolved_timestamps(rows: &[&SourceRow]) -> Option<(u64, u64)> { + let earliest = rows.iter().map(|row| row.earliest).min()?; + let latest = rows.iter().map(|row| row.latest).max()?; + Some((earliest, latest)) +} + +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) fn classify_album_candidates( + source_track_uris: &[String], + candidates: &mut [AlbumCandidate], +) { + let source = source_track_uris.iter().collect::>(); + for candidate in candidates.iter_mut() { + let target = candidate.track_uris.iter().collect::>(); + let overlap = source.intersection(&target).count(); + candidate.relation = if overlap == source.len() && target.len() == source.len() { + Some(AlbumRelation::BestMatch) + } else if overlap == source.len() && target.len() > source.len() { + Some(AlbumRelation::Superset) + } else if overlap > 0 && overlap * 2 >= source.len().max(1) { + Some(AlbumRelation::SameSongs) + } else { + None + }; + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct HistoryUpdate { + pub uri: String, + pub play_count: Option, + pub earliest: Option, + pub latest: Option, +} + +pub(crate) fn apply_history_updates(library: &mut Library, updates: &[HistoryUpdate]) { + for update in updates { + let Some(track) = library + .tracks_mut() + .iter_mut() + .find(|track| track.uri == update.uri) + else { + continue; + }; + if let Some(play_count) = update.play_count { + track.play_count = track.play_count.max(play_count.min(u32::MAX as u64) as u32); + } + if let Some(latest) = update.latest { + track.last_played_at = Some(track.last_played_at.unwrap_or(0).max(latest)); + } + if let Some(earliest) = update.earliest { + track.added_at = Some(track.added_at.unwrap_or(earliest).min(earliest)); + } + } +} + +pub(crate) fn apply_metadata( + library: &mut Library, + tracks: &[String], + whole_album: bool, + genre: Option<&str>, + rating: Option, +) -> Result<(), String> { + let ids = library + .tracks() + .iter() + .filter(|track| tracks.iter().any(|uri| uri == &track.uri)) + .map(|track| track.id) + .collect::>(); + if let Some(genre) = genre.map(str::trim).filter(|genre| !genre.is_empty()) { + for id in &ids { + library + .edit( + *id, + TrackEdit { + cat: Some(genre.to_owned()), + ..TrackEdit::default() + }, + ) + .map_err(|error| error.to_string())?; + } + } + let Some(stars) = rating else { + return Ok(()); + }; + let rating = Rating::new(stars).ok_or_else(|| "Rating must be between 1 and 5.".to_string())?; + if whole_album { + let Some(first) = ids.first().and_then(|id| library.get(*id)) else { + return Ok(()); + }; + library.set_album_rating(AlbumKey::of(first), Some(rating)); + } else { + for id in ids { + library + .set_track_rating(id, Some(rating)) + .map_err(|error| error.to_string())?; + } + } + Ok(()) +} + +pub(crate) struct ImportSessionStore { + path: PathBuf, +} + +impl ImportSessionStore { + pub(crate) fn new(app_data_dir: impl AsRef) -> Self { + Self { + path: app_data_dir.as_ref().join("lastfm-import.json"), + } + } + + pub(crate) fn load(&self) -> Result, String> { + let bytes = match fs::read(&self.path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => return Err("Could not read the Last.fm import session.".into()), + }; + if bytes.len() > MAX_SERIALIZED_SESSION_BYTES { + self.quarantine()?; + return Ok(None); + } + let parsed = serde_json::from_slice::(&bytes); + match parsed { + Ok(session) + if session.version == SESSION_VERSION + && session.defaults.validate().is_ok() + && session + .page_options + .values() + .all(|options| options.validate().is_ok()) => + { + Ok(Some(session)) + } + Ok(_) | Err(_) => { + self.quarantine()?; + Ok(None) + } + } + } + + pub(crate) fn save(&self, session: &LastFmImportSessionV1) -> Result<(), String> { + let bytes = serde_json::to_vec(session) + .map_err(|_| "Could not serialize the Last.fm import session.".to_string())?; + if bytes.len() > MAX_SERIALIZED_SESSION_BYTES { + return Err("The Last.fm import session exceeds the 100 MB safety limit.".into()); + } + super::lastfm::atomic_write(&self.path, &bytes, true) + } + + fn quarantine(&self) -> Result<(), String> { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let target = self.path.with_extension(format!("quarantine-{stamp}")); + fs::rename(&self.path, target) + .map_err(|_| "Could not quarantine the Last.fm import session.".to_string()) + } +} + +pub(crate) struct Service { + store: ImportSessionStore, + session: Mutex>, + running: AtomicBool, +} + +impl Service { + pub(crate) fn new(app_data_dir: impl AsRef) -> Arc { + let store = ImportSessionStore::new(app_data_dir); + let session = match store.load() { + Ok(session) => session, + Err(error) => { + log::warn!("Last.fm importer state is unavailable: {error}"); + None + } + }; + Arc::new(Self { + store, + session: Mutex::new(session), + running: AtomicBool::new(false), + }) + } + + pub(crate) async fn state(&self) -> ImportStateView { + state_view(self.session.lock().await.as_ref()) + } + + async fn snapshot(&self) -> Option { + self.session.lock().await.clone() + } + + async fn save(&self, session: LastFmImportSessionV1) -> Result<(), String> { + self.store.save(&session)?; + *self.session.lock().await = Some(session); + Ok(()) + } + + async fn suspend_for_account_mismatch(&self) -> Result<(), String> { + let Some(mut session) = self.snapshot().await else { + return Ok(()); + }; + session.phase = ImportPhase::Suspended; + session.retryable_error = Some(RetryableError { + message: "This import belongs to a different Last.fm or Spotify account.".into(), + attempt: 0, + retryable: false, + }); + self.save(session).await + } + + pub(crate) async fn start_or_resume( + &self, + username: &str, + spotify_account_id: &str, + snapshot_to: u64, + defaults: Option, + ) -> Result { + if let Some(defaults) = &defaults { + defaults.validate()?; + } + let current = self.snapshot().await; + let session = match current { + Some(mut session) => { + if session.lastfm_username != username + || session.spotify_account_id != spotify_account_id + { + self.suspend_for_account_mismatch().await?; + return Err("The saved Last.fm import belongs to a different account; it is suspended for safety.".into()); + } + if session.phase == ImportPhase::Suspended { + session.phase = if session + .total_pages + .is_some_and(|total_pages| session.next_page > total_pages) + { + ImportPhase::Matching + } else { + ImportPhase::Downloading + }; + session.retryable_error = None; + self.save(session.clone()).await?; + } + session + } + None => { + let session = LastFmImportSessionV1::new_with_defaults( + username.to_owned(), + spotify_account_id.to_owned(), + snapshot_to, + defaults.unwrap_or_default(), + ); + self.save(session.clone()).await?; + session + } + }; + Ok(state_view(Some(&session))) + } + + async fn checkpoint_page( + &self, + page: u32, + parsed: &ParsedRecentTracksPage, + ) -> Result { + let Some(mut session) = self.snapshot().await else { + return Err("No Last.fm import session is active.".into()); + }; + if session.phase != ImportPhase::Downloading { + return Ok(state_view(Some(&session))); + } + if parsed.page != page { + return Err(format!( + "Last.fm response was for page {}, expected page {page}.", + parsed.page + )); + } + if page < session.next_page { + return Ok(state_view(Some(&session))); + } + if page > session.next_page { + return Err("Last.fm import pages must be checkpointed sequentially.".into()); + } + aggregate_scrobbles(&mut session.rows, &parsed.tracks); + session.total_pages = parsed.total_pages.or(session.total_pages); + session.total_scrobbles = parsed.total.unwrap_or(session.total_scrobbles); + session.included_scrobbles = session + .included_scrobbles + .saturating_add(parsed.tracks.len() as u64); + session.skipped_now_playing = session + .skipped_now_playing + .saturating_add(parsed.skipped_now_playing); + session.skipped_undated = session + .skipped_undated + .saturating_add(parsed.skipped_undated); + session.batches.push(ImportBatch { + page, + source_ids: parsed + .tracks + .iter() + .map(|track| source_id(&track.artist, &track.album, &track.track)) + .collect::>() + .into_iter() + .collect(), + }); + session.next_page = page.saturating_add(1); + if session + .total_pages + .is_some_and(|total_pages| session.next_page > total_pages) + || (parsed.total_pages.is_none() && parsed.tracks.len() < LASTFM_PAGE_LIMIT as usize) + { + session.phase = ImportPhase::Matching; + } + session.retryable_error = None; + self.save(session.clone()).await?; + Ok(state_view(Some(&session))) + } + + async fn set_retryable_error(&self, error: RetryableError) -> Result<(), String> { + let Some(mut session) = self.snapshot().await else { + return Ok(()); + }; + session.retryable_error = Some(error); + self.save(session).await + } + + async fn set_match(&self, result: MatchResult) -> Result<(), String> { + self.set_matches(vec![result]).await + } + + async fn set_matches(&self, results: Vec) -> Result<(), String> { + let Some(mut session) = self.snapshot().await else { + return Err("No Last.fm import session is active.".into()); + }; + for result in results { + session.matches.insert(result.source_id.clone(), result); + } + self.save(session).await + } + + async fn set_count_mode(&self, target_uri: &str, mode: CountMode) -> Result<(), String> { + let Some(mut session) = self.snapshot().await else { + return Err("No Last.fm import session is active.".into()); + }; + let current = session + .count_modes + .get(target_uri) + .copied() + .unwrap_or(CountMode::Sum); + if current == mode { + return Ok(()); + } + if locked_count_modes(&session).contains(target_uri) { + return Err("This Spotify target's play-count strategy is locked after import.".into()); + } + session.count_modes.insert(target_uri.to_owned(), mode); + self.save(session).await + } + + async fn set_search_terms(&self, search_terms: bool) -> Result<(), String> { + let Some(mut session) = self.snapshot().await else { + return Err("No Last.fm import session is active.".into()); + }; + session.search_terms = search_terms; + self.save(session).await + } + + async fn finish_matching(&self) -> Result<(), String> { + let Some(mut session) = self.snapshot().await else { + return Ok(()); + }; + session.phase = ImportPhase::Review; + session.retryable_error = None; + self.save(session).await + } + + pub(crate) async fn queue(&self) -> Vec { + let Some(session) = self.snapshot().await else { + return Vec::new(); + }; + let mut grouped = BTreeMap::<(String, String), Vec<&SourceRow>>::new(); + for row in &session.rows { + grouped + .entry((row.artist.clone(), row.album.clone())) + .or_default() + .push(row); + } + grouped + .into_iter() + .map(|((artist, album), rows)| { + let options = session.options_for(&artist, &album); + let remaining = rows.iter().any(|row| { + let decision = default_decision(&session, &row.stable_id); + matches!(decision.status, RowStatus::Pending | RowStatus::Skipped) + && !decision.excluded + }); + let selected = rows + .iter() + .filter(|row| { + let decision = default_decision(&session, &row.stable_id); + options.selected_track_ids.contains(&row.stable_id) + && matches!(decision.status, RowStatus::Pending | RowStatus::Skipped) + && !decision.excluded + }) + .collect::>(); + let mut album_entities = 0; + let mut track_uris = BTreeSet::new(); + if options.import_content { + if options.whole_album { + album_entities = selected + .iter() + .filter_map(|row| session.matches.get(&row.stable_id)) + .filter_map(|result| { + result.selected_uri.as_deref().or_else(|| { + best_candidate(result).map(|candidate| candidate.uri.as_str()) + }) + }) + .any(|uri| uri.starts_with("spotify:album:")) + as u32; + } else { + for row in &selected { + if let Some(result) = session.matches.get(&row.stable_id) { + if let Some(uri) = matched_track_uri_for_row(result, row) { + track_uris.insert(uri); + } + } + } + } + } + ImportQueueItem { + artist, + album, + play_count: rows + .iter() + .map(|row| row.play_count) + .fold(0, u64::saturating_add), + latest: rows.iter().map(|row| row.latest).max().unwrap_or_default(), + source_ids: rows.iter().map(|row| row.stable_id.clone()).collect(), + remaining, + album_entities, + track_entities: track_uris.len() as u32, + status: queue_status(&session, &rows), + } + }) + .collect() + } + + pub(crate) async fn page(&self, artist: &str, album: &str) -> Option { + let session = self.snapshot().await?; + let pages = session + .rows + .iter() + .map(|row| (row.artist.clone(), row.album.clone())) + .collect::>(); + let page_number = pages + .iter() + .position(|(page_artist, page_album)| page_artist == artist && page_album == album) + .map(|index| index + 1) + .unwrap_or(1); + let rows = session + .rows + .iter() + .filter(|row| row.artist == artist && row.album == album) + .map(|row| ImportPageItem { + source: row.clone(), + decision: default_decision(&session, &row.stable_id), + match_result: session.matches.get(&row.stable_id).cloned(), + }) + .collect(); + let options = session.options_for(artist, album); + let mut fuzzy_groups = BTreeMap::>::new(); + for row in &session.rows { + let decision = default_decision(&session, &row.stable_id); + let participates = !decision.excluded + && match decision.status { + RowStatus::Done => true, + RowStatus::Pending | RowStatus::Skipped => session + .options_for(&row.artist, &row.album) + .selected_track_ids + .contains(&row.stable_id), + RowStatus::IgnoredAlbum | RowStatus::IgnoredArtist => false, + }; + if !participates { + continue; + } + let Some(target_uri) = session + .matches + .get(&row.stable_id) + .and_then(|result| matched_track_uri(result, &row.stable_id)) + else { + continue; + }; + fuzzy_groups + .entry(target_uri) + .or_default() + .push(row.clone()); + } + fuzzy_groups + .retain(|_, rows| rows.len() > 1 || rows.iter().any(|row| row.variants.len() > 1)); + Some(ImportPageView { + state: state_view(Some(&session)), + artist: artist.to_owned(), + album: album.to_owned(), + page_number, + page_count: pages.len(), + rows, + options, + fuzzy_groups, + count_modes: session.count_modes.clone(), + locked_count_modes: locked_count_modes(&session), + }) + } + + async fn update_options( + &self, + artist: &str, + album: &str, + options: PageOptions, + ) -> Result<(), String> { + options.validate()?; + let Some(mut session) = self.snapshot().await else { + return Err("No Last.fm import session is active.".into()); + }; + session + .page_options + .insert(format!("{artist}\u{1f}{album}"), options); + self.save(session).await + } + + async fn review_action( + &self, + id: &str, + action: &str, + artist: &str, + album: &str, + ) -> Result<(), String> { + let Some(mut session) = self.snapshot().await else { + return Err("No Last.fm import session is active.".into()); + }; + match action { + "exclude" | "undo-exclude" => { + exclude_row(&mut session, id, action == "exclude"); + } + "ignore-album" => ignore_album(&mut session, artist, album), + "ignore-artist" => ignore_artist(&mut session, artist), + "skip-album" => skip_album(&mut session, artist, album), + "restore" => { + let ids = session + .rows + .iter() + .filter(|row| { + row.artist == artist + && row.album == album + && is_actionable(&session, &row.stable_id) + }) + .map(|row| row.stable_id.clone()) + .collect::>(); + for id in ids { + session.decisions.insert(id, RowDecision::default()); + } + } + _ => return Err("Unknown Last.fm import review action.".into()), + } + update_review_phase(&mut session); + self.save(session).await + } + + async fn commit_rows( + &self, + ids: &[String], + artist: &str, + album: &str, + options: PageOptions, + ) -> Result { + let Some(mut session) = self.snapshot().await else { + return Err("No Last.fm import session is active.".into()); + }; + session + .page_options + .insert(format!("{artist}\u{1f}{album}"), options); + for id in ids { + session.decisions.insert( + id.clone(), + RowDecision { + status: RowStatus::Done, + excluded: false, + }, + ); + } + if session.remaining() == 0 { + session.phase = ImportPhase::Done; + } + self.save(session.clone()).await?; + Ok(state_view(Some(&session))) + } + + async fn select_match(&self, source_id: &str, uri: &str) -> Result<(), String> { + let Some(mut session) = self.snapshot().await else { + return Err("No Last.fm import session is active.".into()); + }; + let Some((row_artist, row_album)) = session + .rows + .iter() + .find(|row| row.stable_id == source_id) + .map(|row| (row.artist.clone(), row.album.clone())) + else { + return Err("Unknown Last.fm import source row.".into()); + }; + let Some(candidate) = session + .matches + .get(source_id) + .and_then(|result| { + result + .candidates + .iter() + .find(|candidate| candidate.uri == uri) + }) + .cloned() + else { + return Err("This source row has no Spotify candidates.".into()); + }; + if candidate.relation.is_none() && !candidate.uri.starts_with("spotify:track:") { + return Err("That Spotify match is not supported by the source track set.".into()); + } + if candidate.uri.starts_with("spotify:album:") { + let related = session + .rows + .iter() + .filter(|row| row.artist == row_artist && row.album == row_album) + .map(|row| (row.stable_id.clone(), row.track.clone())) + .collect::>(); + for (id, track) in related { + let Some(result) = session.matches.get_mut(&id) else { + continue; + }; + let Some(candidate) = result + .candidates + .iter() + .find(|candidate| candidate.uri == uri) + .cloned() + else { + continue; + }; + update_selected_match(result, &id, &track, &candidate); + } + } else { + let row_track = session + .rows + .iter() + .find(|row| row.stable_id == source_id) + .map(|row| row.track.clone()) + .ok_or_else(|| "Unknown Last.fm import source row.".to_string())?; + if let Some(result) = session.matches.get_mut(source_id) { + update_selected_match(result, source_id, &row_track, &candidate); + } + } + self.save(session).await + } + + fn claim_runner(&self) -> bool { + !self.running.swap(true, Ordering::AcqRel) + } + + fn release_runner(&self) { + self.running.store(false, Ordering::Release); + } +} + +fn state_view(session: Option<&LastFmImportSessionV1>) -> ImportStateView { + ImportStateView { + phase: session.map(|session| session.phase), + username: session.map(|session| session.lastfm_username.clone()), + spotify_account_id: session.map(|session| session.spotify_account_id.clone()), + next_page: session.map(|session| session.next_page).unwrap_or(1), + total_pages: session.and_then(|session| session.total_pages), + total_scrobbles: session + .map(|session| session.total_scrobbles) + .unwrap_or_default(), + included_scrobbles: session + .map(|session| session.included_scrobbles) + .unwrap_or_default(), + matched_rows: session + .map(|session| session.matches.len()) + .unwrap_or_default(), + match_total: session + .map(|session| session.rows.len()) + .unwrap_or_default(), + defaults: session + .map(|session| session.defaults.clone()) + .unwrap_or_default(), + remaining: session + .map(LastFmImportSessionV1::remaining) + .unwrap_or_default(), + retryable_error: session.and_then(|session| session.retryable_error.clone()), + search_terms: session.map(|session| session.search_terms).unwrap_or(true), + } +} + +async fn connected_accounts(app: &tauri::AppHandle) -> Result<(String, String), String> { + let state = app.state::(); + let _membership_guard = state.spotify_library_gate.lock().await; + connected_accounts_locked(&state).await +} + +async fn connected_accounts_locked(state: &crate::AppState) -> Result<(String, String), String> { + let username = state + .lastfm + .state() + .await + .username + .ok_or_else(|| "Connect Last.fm before importing its history.".to_string())?; + let provider = crate::provider_from(state)?; + let spotify_account_id = provider + .me() + .await + .map_err(|error| format!("Could not identify the connected Spotify account: {error}"))? + .id; + Ok((username, spotify_account_id)) +} + +async fn assert_current_account( + app: &tauri::AppHandle, + service: &Service, +) -> Result<(String, String), String> { + let state = app.state::(); + let _membership_guard = state.spotify_library_gate.lock().await; + assert_current_account_locked(&state, service).await +} + +async fn assert_current_account_locked( + state: &crate::AppState, + service: &Service, +) -> Result<(String, String), String> { + let (username, spotify_account_id) = connected_accounts_locked(state).await?; + let Some(session) = service.snapshot().await else { + return Err("No Last.fm import session is active.".into()); + }; + if session.lastfm_username != username || session.spotify_account_id != spotify_account_id { + service.suspend_for_account_mismatch().await?; + return Err( + "The saved Last.fm import belongs to a different account; it is suspended for safety." + .into(), + ); + } + Ok((username, spotify_account_id)) +} + +async fn emit_import_changed( + app: &tauri::AppHandle, + service: &Service, +) -> Result { + let view = service.state().await; + app.emit("lastfm-import-changed", &view) + .map_err(|error| error.to_string())?; + Ok(view) +} + +async fn start_import( + app: tauri::AppHandle, + defaults: Option, +) -> Result { + let (username, spotify_account_id) = connected_accounts(&app).await?; + let state = app.state::(); + let service = Arc::clone(&state.lastfm_import); + let view = service + .start_or_resume(&username, &spotify_account_id, crate::unix_now(), defaults) + .await?; + app.emit("lastfm-import-changed", &view) + .map_err(|error| error.to_string())?; + if service.claim_runner() { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + run_import(app, service, username, spotify_account_id).await; + }); + } + Ok(view) +} + +async fn run_import( + app: tauri::AppHandle, + service: Arc, + username: String, + spotify_account_id: String, +) { + let result = async { + loop { + let Some(session) = service.snapshot().await else { + break; + }; + match session.phase { + ImportPhase::Downloading => { + let lastfm = Arc::clone(&app.state::().lastfm); + let payload = match lastfm + .import_recent_tracks_page( + &username, + session.next_page, + session.snapshot_to, + ) + .await + { + Ok(payload) => payload, + Err(error) => { + if error.account_mismatch { + service.suspend_for_account_mismatch().await?; + return Err(error.message); + } + let attempt = service + .snapshot() + .await + .and_then(|session| session.retryable_error) + .map(|error| error.attempt.saturating_add(1)) + .unwrap_or(1); + service + .set_retryable_error(RetryableError { + message: error.message.clone(), + attempt: if error.retryable { attempt } else { 0 }, + retryable: error.retryable, + }) + .await?; + return Err(error.message); + } + }; + let parsed = match parse_recent_tracks_page(&payload) { + Ok(parsed) => parsed, + Err(message) => { + let attempt = service + .snapshot() + .await + .and_then(|session| session.retryable_error) + .map(|error| error.attempt.saturating_add(1)) + .unwrap_or(1); + service + .set_retryable_error(RetryableError { + message: message.clone(), + attempt, + retryable: false, + }) + .await?; + return Err(message); + } + }; + service.checkpoint_page(session.next_page, &parsed).await?; + let _ = app.emit("lastfm-import-changed", service.state().await); + if parsed.tracks.len() == LASTFM_PAGE_LIMIT as usize { + tokio::time::sleep(Duration::from_millis(250)).await; + } + } + ImportPhase::Matching => { + run_matching(&app, &service, &spotify_account_id).await?; + } + ImportPhase::Review | ImportPhase::Done | ImportPhase::Suspended => break, + } + } + Ok::<(), String>(()) + } + .await; + if let Err(error) = result { + let already_recorded = service + .snapshot() + .await + .and_then(|session| session.retryable_error) + .is_some(); + if !already_recorded { + let _ = service + .set_retryable_error(RetryableError { + message: error, + attempt: 0, + retryable: true, + }) + .await; + } + } + service.release_runner(); + let _ = app.emit("lastfm-import-changed", service.state().await); +} + +fn album_search_term(artist: &str, album: &str) -> String { + let artist = artist.replace('"', " "); + let album = album.replace('"', " "); + format!("album:\"{album}\" artist:\"{artist}\"") +} + +fn track_search_term(artist: &str, track: &str) -> String { + let artist = artist.replace('"', " "); + let track = track.replace('"', " "); + format!("track:\"{track}\" artist:\"{artist}\"") +} + +pub(crate) fn classify_album_candidates_by_name( + source_track_names: &[String], + candidates: &mut [AlbumCandidate], +) { + let source = source_track_names + .iter() + .map(|name| normalize_for_match(name)) + .collect::>(); + for candidate in candidates.iter_mut() { + let target = candidate + .track_names + .iter() + .map(|name| normalize_for_match(name)) + .collect::>(); + let overlap = source.intersection(&target).count(); + candidate.relation = if overlap == source.len() && target.len() == source.len() { + Some(AlbumRelation::BestMatch) + } else if overlap == source.len() && target.len() > source.len() { + Some(AlbumRelation::Superset) + } else if overlap > 0 && overlap * 2 >= source.len().max(1) { + Some(AlbumRelation::SameSongs) + } else { + None + }; + } +} + +fn candidate_rank(relation: Option) -> u8 { + match relation { + Some(AlbumRelation::BestMatch) => 0, + Some(AlbumRelation::SameSongs) => 1, + Some(AlbumRelation::Superset) => 2, + None => 3, + } +} + +async fn run_matching( + app: &tauri::AppHandle, + service: &Service, + spotify_account_id: &str, +) -> Result<(), String> { + let state = app.state::(); + let (_, current_account_id) = assert_current_account(app, service).await?; + if current_account_id != spotify_account_id { + return Err("The connected Spotify account changed during matching.".into()); + } + let Some(session) = service.snapshot().await else { + return Ok(()); + }; + if session.spotify_account_id != spotify_account_id || session.phase != ImportPhase::Matching { + return Ok(()); + } + let provider = crate::provider_from(&state)?; + let mut groups = BTreeMap::<(String, String), Vec>::new(); + for row in session.rows { + if !session.matches.contains_key(&row.stable_id) { + groups + .entry((row.artist.clone(), row.album.clone())) + .or_default() + .push(row); + } + } + for ((artist, album), rows) in groups { + if album.is_empty() { + for row in rows { + let search_term = track_search_term(&artist, &row.track); + let results = + crate::provider::search_tracks(provider.as_ref(), &search_term).await?; + let mut candidates = results + .items + .into_iter() + .map(|track| AlbumCandidate { + uri: track.uri.clone(), + name: track.name.clone(), + artist: track.artist.clone(), + track_uris: vec![track.uri.clone()], + track_names: vec![track.name.clone()], + track_artists: vec![track.artist], + track_albums: vec![track.alb], + relation: None, + }) + .collect::>(); + classify_album_candidates_by_name( + std::slice::from_ref(&row.track), + &mut candidates, + ); + let selected = candidates + .iter() + .min_by_key(|candidate| candidate_rank(candidate.relation)); + let confidence = selected.map(|candidate| match candidate.relation { + Some(AlbumRelation::BestMatch) => Confidence::Exact, + Some(AlbumRelation::SameSongs | AlbumRelation::Superset) => Confidence::Likely, + None => Confidence::Low, + }); + let selected_uri = selected + .filter(|candidate| candidate.relation.is_some()) + .map(|candidate| candidate.uri.clone()); + let mut track_matches = BTreeMap::new(); + if let Some(uri) = selected_uri.clone() { + track_matches.insert(row.stable_id.clone(), uri); + } + service + .set_match(MatchResult { + source_id: row.stable_id, + search_term, + confidence, + selected_uri, + candidates, + track_matches, + }) + .await?; + let _ = app.emit("lastfm-import-changed", service.state().await); + } + continue; + } + let search_term = album_search_term(&artist, &album); + let results = crate::provider::search_albums(provider.as_ref(), &search_term).await?; + let mut candidates = Vec::new(); + for album_result in results.items.into_iter().take(10) { + let tracks = + crate::provider::album_tracks(provider.as_ref(), &album_result.uri).await?; + candidates.push(AlbumCandidate { + uri: album_result.uri, + name: album_result.name, + artist: album_result.artist, + track_uris: tracks.iter().map(|track| track.uri.clone()).collect(), + track_names: tracks.iter().map(|track| track.name.clone()).collect(), + track_artists: tracks.iter().map(|track| track.art.clone()).collect(), + track_albums: tracks.iter().map(|track| track.alb.clone()).collect(), + relation: None, + }); + } + let source_track_names = rows.iter().map(|row| row.track.clone()).collect::>(); + classify_album_candidates_by_name(&source_track_names, &mut candidates); + let selected = candidates + .iter() + .min_by_key(|candidate| candidate_rank(candidate.relation)); + let confidence = selected.map(|candidate| match candidate.relation { + Some(AlbumRelation::BestMatch) => Confidence::Exact, + Some(AlbumRelation::SameSongs | AlbumRelation::Superset) => Confidence::Likely, + None => Confidence::Low, + }); + let selected_uri = selected + .filter(|candidate| candidate.relation.is_some()) + .map(|candidate| candidate.uri.clone()); + let mut track_matches = BTreeMap::new(); + if let Some(selected) = selected.filter(|candidate| candidate.relation.is_some()) { + for row in &rows { + if let Some(index) = selected + .track_names + .iter() + .position(|name| normalize_for_match(name) == normalize_for_match(&row.track)) + { + if let Some(uri) = selected.track_uris.get(index) { + track_matches.insert(row.stable_id.clone(), uri.clone()); + } + } + } + } + let matches = rows + .into_iter() + .map(|row| MatchResult { + source_id: row.stable_id, + search_term: search_term.clone(), + confidence, + selected_uri: selected_uri.clone(), + candidates: candidates.clone(), + track_matches: track_matches.clone(), + }) + .collect(); + service.set_matches(matches).await?; + let _ = app.emit("lastfm-import-changed", service.state().await); + } + service.finish_matching().await +} + +fn matched_track_uri(result: &MatchResult, source_id: &str) -> Option { + result.track_matches.get(source_id).cloned().or_else(|| { + result + .selected_uri + .as_ref() + .filter(|uri| uri.starts_with("spotify:track:")) + .cloned() + }) +} + +fn best_candidate(result: &MatchResult) -> Option<&AlbumCandidate> { + result + .candidates + .iter() + .filter(|candidate| { + candidate.relation.is_some() || candidate.uri.starts_with("spotify:track:") + }) + .min_by_key(|candidate| candidate_rank(candidate.relation)) +} + +fn matched_track_uri_for_row(result: &MatchResult, row: &SourceRow) -> Option { + matched_track_uri(result, &row.stable_id).or_else(|| { + let candidate = best_candidate(result)?; + if candidate.uri.starts_with("spotify:track:") { + return Some(candidate.uri.clone()); + } + let index = candidate + .track_names + .iter() + .position(|name| normalize_for_match(name) == normalize_for_match(&row.track))?; + candidate.track_uris.get(index).cloned() + }) +} + +fn membership_uris_for_import( + import_content: bool, + whole_album: bool, + album_uri: Option<&str>, + track_uris: &[String], +) -> Option> { + if !import_content { + return None; + } + if whole_album { + return album_uri + .filter(|uri| uri.starts_with("spotify:album:")) + .map(|uri| vec![uri.to_owned()]); + } + let mut seen = BTreeSet::new(); + Some( + track_uris + .iter() + .filter(|uri| uri.starts_with("spotify:track:") && seen.insert((*uri).clone())) + .cloned() + .collect(), + ) +} + +fn committed_source_ids( + rows: &[SourceRow], + target_by_source: &BTreeMap, + import_content: bool, + whole_album: bool, + include_historical_play_counts: bool, +) -> Vec { + if import_content && whole_album && !include_historical_play_counts { + return rows.iter().map(|row| row.stable_id.clone()).collect(); + } + rows.iter() + .filter(|row| target_by_source.contains_key(&row.stable_id)) + .map(|row| row.stable_id.clone()) + .collect() +} + +fn historical_count_for_target( + session: &LastFmImportSessionV1, + target_uri: &str, + current_rows: &[&SourceRow], + current_options: &PageOptions, +) -> u64 { + let current_ids = current_rows + .iter() + .map(|row| row.stable_id.as_str()) + .collect::>(); + let mut relevant = Vec::new(); + for row in &session.rows { + let decision = default_decision(session, &row.stable_id); + let current_page = current_ids.contains(row.stable_id.as_str()); + let included = current_page || (decision.status == RowStatus::Done && !decision.excluded); + let page_options = if current_page { + current_options.clone() + } else { + session.options_for(&row.artist, &row.album) + }; + if included + && page_options.include_historical_play_counts + && session + .matches + .get(&row.stable_id) + .and_then(|result| matched_track_uri(result, &row.stable_id)) + .as_deref() + == Some(target_uri) + { + relevant.push(row); + } + } + resolved_play_count( + &relevant, + session + .count_modes + .get(target_uri) + .copied() + .unwrap_or(CountMode::Sum), + ) +} + +fn update_selected_match( + result: &mut MatchResult, + source_id: &str, + source_track: &str, + candidate: &AlbumCandidate, +) { + result.selected_uri = Some(candidate.uri.clone()); + result.confidence = Some(match candidate.relation { + Some(AlbumRelation::BestMatch) => Confidence::Exact, + Some(AlbumRelation::SameSongs | AlbumRelation::Superset) => Confidence::Likely, + None => Confidence::Low, + }); + result.track_matches.remove(source_id); + if let Some(index) = candidate + .track_names + .iter() + .position(|name| normalize_for_match(name) == normalize_for_match(source_track)) + { + if let Some(track_uri) = candidate.track_uris.get(index) { + result + .track_matches + .insert(source_id.to_owned(), track_uri.clone()); + } + } else if candidate.uri.starts_with("spotify:track:") { + result + .track_matches + .insert(source_id.to_owned(), candidate.uri.clone()); + } +} + +async fn apply_page( + app: &tauri::AppHandle, + service: &Service, + artist: &str, + album: &str, + selected_ids: &[String], + options: PageOptions, +) -> Result { + options.validate()?; + let state = app.state::(); + let _membership_guard = state.spotify_library_gate.lock().await; + let _ = assert_current_account_locked(&state, service).await?; + let Some(session) = service.snapshot().await else { + return Err("No Last.fm import session is active.".into()); + }; + let selected = selected_ids.iter().cloned().collect::>(); + let rows = session + .rows + .iter() + .filter(|row| row.artist == artist && row.album == album) + .filter(|row| selected.contains(&row.stable_id)) + .filter(|row| { + let decision = default_decision(&session, &row.stable_id); + !decision.excluded && matches!(decision.status, RowStatus::Pending | RowStatus::Skipped) + }) + .cloned() + .collect::>(); + if rows.is_empty() { + return Ok(state_view(Some(&session))); + } + + let mut target_by_source = BTreeMap::::new(); + for row in &rows { + if let Some(result) = session.matches.get(&row.stable_id) { + if let Some(uri) = matched_track_uri(result, &row.stable_id) { + target_by_source.insert(row.stable_id.clone(), uri); + } + } + } + let mut metadata_uris = target_by_source.values().cloned().collect::>(); + if options.import_content && options.whole_album { + let album_uri = rows + .iter() + .filter_map(|row| session.matches.get(&row.stable_id)) + .filter_map(|result| result.selected_uri.as_deref()) + .find(|uri| uri.starts_with("spotify:album:")) + .ok_or_else(|| { + "Choose a supported Spotify album match before accepting.".to_string() + })?; + let album_uri = membership_uris_for_import(true, true, Some(album_uri), &[]) + .and_then(|uris| uris.into_iter().next()) + .ok_or_else(|| "Expected a Spotify album URI for the import.".to_string())?; + let provider = crate::provider_from(&state)?; + let saved = crate::spotify_commands::save_album_operation( + &state, + provider.as_ref(), + &album_uri, + album, + artist, + crate::unix_now(), + ) + .await?; + if saved.album_uri != album_uri { + return Err("Spotify returned a different album than the selected match.".into()); + } + metadata_uris = saved.track_uris.iter().cloned().collect(); + } else if options.import_content { + let requested = target_by_source.values().cloned().collect::>(); + if !requested.is_empty() { + let requested = membership_uris_for_import( + true, + false, + None, + &requested.iter().cloned().collect::>(), + ) + .unwrap_or_default(); + let provider = crate::provider_from(&state)?; + crate::spotify_commands::save_tracks_operation( + &state, + provider.as_ref(), + requested, + crate::unix_now(), + ) + .await?; + } + } + + let mut by_target = BTreeMap::>::new(); + for row in &rows { + if let Some(uri) = target_by_source.get(&row.stable_id) { + by_target.entry(uri.clone()).or_default().push(row); + } + } + let updates = by_target + .iter() + .map(|(uri, rows)| { + let refs = rows.to_vec(); + let (earliest, latest) = resolved_timestamps(&refs).unwrap_or_default(); + HistoryUpdate { + uri: uri.clone(), + play_count: options + .include_historical_play_counts + .then(|| historical_count_for_target(&session, uri, rows, &options)), + earliest: (earliest > 0).then_some(earliest), + latest: options.include_historical_play_counts.then_some(latest), + } + }) + .collect::>(); + if !updates.is_empty() || !metadata_uris.is_empty() { + crate::mutate_library(&state, |library| { + apply_history_updates(library, &updates); + apply_metadata( + library, + &metadata_uris.iter().cloned().collect::>(), + options.whole_album, + options.genre.as_deref(), + options.rating, + ) + })?; + } + let committed = committed_source_ids( + &rows, + &target_by_source, + options.import_content, + options.whole_album, + options.include_historical_play_counts, + ); + service + .commit_rows(&committed, artist, album, options) + .await +} + +async fn album_candidates< + T: retune_spotify::client::Transport, + S: retune_spotify::tokens::TokenStore, +>( + provider: &retune_spotify::client::SpotifyClient, + query: &str, + source_track_names: &[String], +) -> Result, String> { + let results = crate::provider::search_albums(provider, query).await?; + let mut candidates = Vec::new(); + for album in results.items.into_iter().take(10) { + let tracks = crate::provider::album_tracks(provider, &album.uri).await?; + candidates.push(AlbumCandidate { + uri: album.uri, + name: album.name, + artist: album.artist, + track_uris: tracks.iter().map(|track| track.uri.clone()).collect(), + track_names: tracks.iter().map(|track| track.name.clone()).collect(), + track_artists: tracks.iter().map(|track| track.art.clone()).collect(), + track_albums: tracks.iter().map(|track| track.alb.clone()).collect(), + relation: None, + }); + } + classify_album_candidates_by_name(source_track_names, &mut candidates); + Ok(candidates) +} + +fn match_result_for( + source_id: String, + search_term: String, + mut candidates: Vec, + source_track: &str, + auto_select: bool, +) -> MatchResult { + let selected = auto_select + .then(|| { + candidates + .iter() + .min_by_key(|candidate| candidate_rank(candidate.relation)) + }) + .flatten(); + let confidence = selected.map(|candidate| match candidate.relation { + Some(AlbumRelation::BestMatch) => Confidence::Exact, + Some(AlbumRelation::SameSongs | AlbumRelation::Superset) => Confidence::Likely, + None => Confidence::Low, + }); + let selected_uri = selected + .filter(|candidate| candidate.relation.is_some()) + .map(|candidate| candidate.uri.clone()); + let mut track_matches = BTreeMap::new(); + if let Some(selected) = selected.filter(|candidate| candidate.relation.is_some()) { + if let Some(index) = selected + .track_names + .iter() + .position(|name| normalize_for_match(name) == normalize_for_match(source_track)) + { + if let Some(uri) = selected.track_uris.get(index) { + track_matches.insert(source_id.clone(), uri.clone()); + } + } else if selected.uri.starts_with("spotify:track:") { + track_matches.insert(source_id.clone(), selected.uri.clone()); + } + } + // Keep the list bounded even if a future provider adapter returns more. + candidates.truncate(10); + MatchResult { + source_id, + search_term, + confidence, + selected_uri, + candidates, + track_matches, + } +} + +#[tauri::command] +pub(crate) async fn open_lastfm_importer(app: tauri::AppHandle) -> Result<(), String> { + if let Some(window) = app.get_webview_window("lastfm-importer") { + window.show().map_err(|error| error.to_string())?; + window.set_focus().map_err(|error| error.to_string())?; + return Ok(()); + } + WebviewWindowBuilder::new( + &app, + "lastfm-importer", + WebviewUrl::App("index.html".into()), + ) + .title("Last.fm importer") + .inner_size(1320.0, 840.0) + .resizable(true) + .build() + .map(|_| ()) + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub(crate) async fn lastfm_import_state( + state: tauri::State<'_, crate::AppState>, +) -> Result { + Ok(state.lastfm_import.state().await) +} + +#[tauri::command] +pub(crate) async fn lastfm_import_queue( + state: tauri::State<'_, crate::AppState>, +) -> Result, String> { + Ok(state.lastfm_import.queue().await) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) async fn lastfm_import_page( + state: tauri::State<'_, crate::AppState>, + artist: String, + album: String, +) -> Result, String> { + Ok(state.lastfm_import.page(&artist, &album).await) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) async fn lastfm_import_review( + app: tauri::AppHandle, + id: String, + action: String, + artist: String, + album: String, +) -> Result { + let state = app.state::(); + state + .lastfm_import + .review_action(&id, &action, &artist, &album) + .await?; + emit_import_changed(&app, state.lastfm_import.as_ref()).await +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) async fn lastfm_import_options( + app: tauri::AppHandle, + artist: String, + album: String, + options: PageOptions, +) -> Result { + let state = app.state::(); + state + .lastfm_import + .update_options(&artist, &album, options) + .await?; + emit_import_changed(&app, state.lastfm_import.as_ref()).await +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) async fn lastfm_import_count_mode( + app: tauri::AppHandle, + target_uri: String, + mode: CountMode, +) -> Result { + let state = app.state::(); + state + .lastfm_import + .set_count_mode(&target_uri, mode) + .await?; + emit_import_changed(&app, state.lastfm_import.as_ref()).await +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) async fn lastfm_import_search_terms( + app: tauri::AppHandle, + show: bool, +) -> Result { + let state = app.state::(); + state.lastfm_import.set_search_terms(show).await?; + emit_import_changed(&app, state.lastfm_import.as_ref()).await +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) async fn lastfm_import_select_match( + app: tauri::AppHandle, + id: String, + uri: String, +) -> Result { + let state = app.state::(); + let _ = assert_current_account(&app, state.lastfm_import.as_ref()).await?; + state.lastfm_import.select_match(&id, &uri).await?; + emit_import_changed(&app, state.lastfm_import.as_ref()).await +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) async fn lastfm_import_change_track( + app: tauri::AppHandle, + id: String, + query: String, +) -> Result { + let state = app.state::(); + let _ = assert_current_account(&app, state.lastfm_import.as_ref()).await?; + let session = state + .lastfm_import + .snapshot() + .await + .ok_or_else(|| "No Last.fm import session is active.".to_string())?; + let row = session + .rows + .iter() + .find(|row| row.stable_id == id) + .ok_or_else(|| "Unknown Last.fm import source row.".to_string())?; + let search_term = if query.trim().is_empty() { + track_search_term(&row.artist, &row.track) + } else { + query.trim().to_owned() + }; + let provider = crate::provider_from(&state)?; + let results = crate::provider::search_tracks(provider.as_ref(), &search_term).await?; + let candidates = results + .items + .into_iter() + .map(|track| AlbumCandidate { + uri: track.uri.clone(), + name: track.name.clone(), + artist: track.artist.clone(), + track_uris: vec![track.uri.clone()], + track_names: vec![track.name.clone()], + track_artists: vec![track.artist], + track_albums: vec![track.alb], + relation: None, + }) + .collect::>(); + let mut candidates = candidates; + classify_album_candidates_by_name(std::slice::from_ref(&row.track), &mut candidates); + state + .lastfm_import + .set_match(match_result_for( + id, + search_term, + candidates, + &row.track, + false, + )) + .await?; + emit_import_changed(&app, state.lastfm_import.as_ref()).await +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) async fn lastfm_import_change_album( + app: tauri::AppHandle, + id: String, + query: String, +) -> Result { + let state = app.state::(); + let _ = assert_current_account(&app, state.lastfm_import.as_ref()).await?; + let session = state + .lastfm_import + .snapshot() + .await + .ok_or_else(|| "No Last.fm import session is active.".to_string())?; + let row = session + .rows + .iter() + .find(|row| row.stable_id == id) + .ok_or_else(|| "Unknown Last.fm import source row.".to_string())?; + let related = session + .rows + .iter() + .filter(|candidate| candidate.artist == row.artist && candidate.album == row.album) + .map(|candidate| candidate.track.clone()) + .collect::>(); + let search_term = if query.trim().is_empty() { + album_search_term(&row.artist, &row.album) + } else { + query.trim().to_owned() + }; + let provider = crate::provider_from(&state)?; + let candidates = album_candidates(provider.as_ref(), &search_term, &related).await?; + let matches = session + .rows + .iter() + .filter(|candidate| candidate.artist == row.artist && candidate.album == row.album) + .map(|candidate_row| { + match_result_for( + candidate_row.stable_id.clone(), + search_term.clone(), + candidates.clone(), + &candidate_row.track, + false, + ) + }) + .collect(); + state.lastfm_import.set_matches(matches).await?; + emit_import_changed(&app, state.lastfm_import.as_ref()).await +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) async fn lastfm_import_apply( + app: tauri::AppHandle, + artist: String, + album: String, + selected_ids: Vec, + options: PageOptions, +) -> Result { + let state = app.state::(); + let view = apply_page( + &app, + state.lastfm_import.as_ref(), + &artist, + &album, + &selected_ids, + options, + ) + .await?; + app.emit("lastfm-import-changed", &view) + .map_err(|error| error.to_string())?; + Ok(view) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) async fn lastfm_import_accept_all_page( + app: tauri::AppHandle, + artist: String, + album: String, +) -> Result { + let state = app.state::(); + let _ = assert_current_account(&app, state.lastfm_import.as_ref()).await?; + let Some(page) = state.lastfm_import.page(&artist, &album).await else { + return Ok(state.lastfm_import.state().await); + }; + let mut selected_album_uris = BTreeSet::new(); + for item in &page.rows { + if !page + .options + .selected_track_ids + .contains(&item.source.stable_id) + || item.decision.excluded + || !matches!( + item.decision.status, + RowStatus::Pending | RowStatus::Skipped + ) + { + continue; + } + let Some(result) = &item.match_result else { + continue; + }; + if result.selected_uri.is_some() { + continue; + } + let Some(candidate) = best_candidate(result) else { + continue; + }; + if candidate.uri.starts_with("spotify:album:") { + if selected_album_uris.insert(candidate.uri.clone()) { + state + .lastfm_import + .select_match(&item.source.stable_id, &candidate.uri) + .await?; + } + } else { + state + .lastfm_import + .select_match(&item.source.stable_id, &candidate.uri) + .await?; + } + } + let Some(page) = state.lastfm_import.page(&artist, &album).await else { + return Ok(state.lastfm_import.state().await); + }; + let selected_ids = page + .options + .selected_track_ids + .iter() + .filter(|id| { + page.rows.iter().any(|item| { + &item.source.stable_id == *id + && !item.decision.excluded + && matches!( + item.decision.status, + RowStatus::Pending | RowStatus::Skipped + ) + }) + }) + .cloned() + .collect::>(); + let view = apply_page( + &app, + state.lastfm_import.as_ref(), + &artist, + &album, + &selected_ids, + page.options, + ) + .await?; + app.emit("lastfm-import-changed", &view) + .map_err(|error| error.to_string())?; + Ok(view) +} + +#[tauri::command] +pub(crate) async fn start_lastfm_import( + app: tauri::AppHandle, + defaults: Option, +) -> Result { + start_import(app, defaults).await +} + +pub(crate) fn default_decision(session: &LastFmImportSessionV1, id: &str) -> RowDecision { + session.decisions.get(id).cloned().unwrap_or_default() +} + +fn locked_count_modes(session: &LastFmImportSessionV1) -> BTreeSet { + session + .rows + .iter() + .filter(|row| default_decision(session, &row.stable_id).status == RowStatus::Done) + .filter_map(|row| { + session + .matches + .get(&row.stable_id) + .and_then(|result| matched_track_uri(result, &row.stable_id)) + }) + .collect() +} + +fn queue_status(session: &LastFmImportSessionV1, rows: &[&SourceRow]) -> Option { + if rows + .iter() + .all(|row| default_decision(session, &row.stable_id).excluded) + { + return Some(QueueStatus::Excluded); + } + let first = rows + .first() + .map(|row| default_decision(session, &row.stable_id).status)?; + if first == RowStatus::Pending + || !rows + .iter() + .all(|row| default_decision(session, &row.stable_id).status == first) + { + return None; + } + Some(match first { + RowStatus::Done => QueueStatus::Done, + RowStatus::Skipped => QueueStatus::Skipped, + RowStatus::IgnoredAlbum => QueueStatus::IgnoredAlbum, + RowStatus::IgnoredArtist => QueueStatus::IgnoredArtist, + RowStatus::Pending => return None, + }) +} + +fn update_review_phase(session: &mut LastFmImportSessionV1) { + if session.remaining() == 0 { + session.phase = ImportPhase::Done; + } else if session.phase == ImportPhase::Done { + session.phase = ImportPhase::Review; + } +} + +fn exclude_row(session: &mut LastFmImportSessionV1, id: &str, excluded: bool) { + if is_reviewable(session, id) { + let decision = session.decisions.entry(id.to_owned()).or_default(); + decision.excluded = excluded; + } +} + +fn is_reviewable(session: &LastFmImportSessionV1, id: &str) -> bool { + matches!( + default_decision(session, id).status, + RowStatus::Pending | RowStatus::Skipped + ) +} + +fn is_actionable(session: &LastFmImportSessionV1, id: &str) -> bool { + is_reviewable(session, id) && !default_decision(session, id).excluded +} + +pub(crate) fn ignore_album(session: &mut LastFmImportSessionV1, artist: &str, album: &str) { + let ids = session + .rows + .iter() + .filter(|row| { + row.artist == artist && row.album == album && is_actionable(session, &row.stable_id) + }) + .map(|row| row.stable_id.clone()) + .collect::>(); + for id in ids { + session.decisions.insert( + id, + RowDecision { + status: RowStatus::IgnoredAlbum, + excluded: false, + }, + ); + } +} + +pub(crate) fn ignore_artist(session: &mut LastFmImportSessionV1, artist: &str) { + let ids = session + .rows + .iter() + .filter(|row| row.artist == artist && is_actionable(session, &row.stable_id)) + .map(|row| row.stable_id.clone()) + .collect::>(); + for id in ids { + session.decisions.insert( + id, + RowDecision { + status: RowStatus::IgnoredArtist, + excluded: false, + }, + ); + } +} + +pub(crate) fn skip_album(session: &mut LastFmImportSessionV1, artist: &str, album: &str) { + let ids = session + .rows + .iter() + .filter(|row| { + row.artist == artist && row.album == album && is_actionable(session, &row.stable_id) + }) + .map(|row| row.stable_id.clone()) + .collect::>(); + for id in ids { + session.decisions.insert( + id, + RowDecision { + status: RowStatus::Skipped, + excluded: false, + }, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::{SavedAlbumRecord, SpotifyLibraryState}; + use retune_core::model::SourceId; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + use std::{fs, time::Duration}; + + fn response(entries: Value) -> Value { + serde_json::json!({ + "recenttracks": { + "track": entries, + "@attr": {"page": "2", "totalPages": "4", "total": "601"} + } + }) + } + + fn scrobble(artist: &str, album: &str, track: &str, timestamp: u64) -> ParsedScrobble { + ParsedScrobble { + artist: artist.into(), + album: album.into(), + track: track.into(), + timestamp, + } + } + + #[test] + fn parses_nowplaying_and_undated_rows_without_retaining_them() { + let parsed = parse_recent_tracks_page(&response(serde_json::json!([ + {"artist": {"#text": "Artist"}, "album": {"#text": "Album"}, "name": "Song", "date": {"uts": "20"}}, + {"artist": {"#text": "Live"}, "name": "Now", "@attr": {"nowplaying": "1"}}, + {"artist": {"#text": "Live"}, "name": "Now too", "@attr": {"nowplaying": true}}, + {"artist": {"#text": "Old"}, "name": "Missing date"}, + ]))).unwrap(); + + assert_eq!(parsed.page, 2); + assert_eq!(parsed.total_pages, Some(4)); + assert_eq!(parsed.total, Some(601)); + assert_eq!(parsed.tracks.len(), 1); + assert_eq!(parsed.skipped_now_playing, 2); + assert_eq!(parsed.skipped_undated, 1); + assert_eq!(parsed.tracks[0].timestamp, 20); + } + + #[test] + fn parses_a_single_track_object_and_text_fields() { + let parsed = parse_recent_tracks_page(&response(serde_json::json!({ + "artist": "Artist", "album": "Album", "name": "Song", "date": {"uts": "9"} + }))) + .unwrap(); + assert_eq!(parsed.tracks, vec![scrobble("Artist", "Album", "Song", 9)]); + } + + #[test] + fn aggregation_keeps_compact_raw_variants_and_timestamps() { + let mut rows = Vec::new(); + aggregate_scrobbles( + &mut rows, + &[ + scrobble("Beyoncé", "Lemonade", "Sorry", 300), + scrobble("Beyoncé", "Lemonade", "Sorry!", 100), + scrobble("Beyoncé", "Lemonade", "Sorry", 200), + ], + ); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].play_count, 3); + assert_eq!((rows[0].earliest, rows[0].latest), (100, 300)); + assert_eq!(rows[0].variants.len(), 2); + assert_eq!(resolved_play_count(&[&rows[0]], CountMode::Sum), 3); + assert_eq!(resolved_play_count(&[&rows[0]], CountMode::Overwrite), 2); + assert_eq!(resolved_play_count(&[&rows[0]], CountMode::Zero), 0); + } + + #[test] + fn fuzzy_arithmetic_combines_rows_mapped_to_one_target() { + let mut rows = Vec::new(); + aggregate_scrobbles( + &mut rows, + &[ + scrobble("Artist", "Album", "Song", 10), + scrobble("Artist", "Album", "Song", 11), + scrobble("Artist", "Album", "Song (Live)", 12), + ], + ); + assert_eq!(rows.len(), 2); + let refs = rows.iter().collect::>(); + assert_eq!(resolved_play_count(&refs, CountMode::Sum), 3); + assert_eq!(resolved_play_count(&refs, CountMode::Overwrite), 2); + assert_eq!(resolved_timestamps(&refs), Some((10, 12))); + } + + #[tokio::test] + async fn page_fuzzy_groups_only_include_rows_selected_for_import() { + let dir = tempfile::tempdir().unwrap(); + let service = Service::new(dir.path()); + let mut session = LastFmImportSessionV1::new("user".into(), "spotify".into(), 10); + aggregate_scrobbles( + &mut session.rows, + &[ + scrobble("Artist", "Done", "Track", 1), + scrobble("Artist", "Selected", "Track", 2), + scrobble("Artist", "Skipped", "Track", 3), + scrobble("Artist", "Ignored", "Track", 4), + scrobble("Artist", "Excluded", "Track", 5), + scrobble("Artist", "Unchecked", "Track", 6), + ], + ); + let ids = session + .rows + .iter() + .map(|row| (row.album.clone(), row.stable_id.clone())) + .collect::>(); + let target = "spotify:track:target".to_owned(); + for row in &session.rows { + session.matches.insert( + row.stable_id.clone(), + MatchResult { + source_id: row.stable_id.clone(), + search_term: row.track.clone(), + confidence: Some(Confidence::Exact), + selected_uri: Some(target.clone()), + candidates: Vec::new(), + track_matches: BTreeMap::from([(row.stable_id.clone(), target.clone())]), + }, + ); + } + session.decisions.insert( + ids["Done"].clone(), + RowDecision { + status: RowStatus::Done, + excluded: false, + }, + ); + session.decisions.insert( + ids["Skipped"].clone(), + RowDecision { + status: RowStatus::Skipped, + excluded: false, + }, + ); + session.decisions.insert( + ids["Ignored"].clone(), + RowDecision { + status: RowStatus::IgnoredAlbum, + excluded: false, + }, + ); + session.decisions.insert( + ids["Excluded"].clone(), + RowDecision { + status: RowStatus::Pending, + excluded: true, + }, + ); + for (album, selected) in [ + ("Selected", true), + ("Skipped", true), + ("Ignored", true), + ("Excluded", true), + ("Unchecked", false), + ] { + session.page_options.insert( + format!("Artist\u{1f}{album}"), + PageOptions { + selected_track_ids: if selected { + BTreeSet::from([ids[album].clone()]) + } else { + BTreeSet::new() + }, + ..PageOptions::default() + }, + ); + } + service.save(session).await.unwrap(); + + let page = service.page("Artist", "Selected").await.unwrap(); + let included = page + .fuzzy_groups + .get(&target) + .unwrap() + .iter() + .map(|row| row.album.clone()) + .collect::>(); + assert_eq!( + included, + BTreeSet::from([ + "Done".to_owned(), + "Selected".to_owned(), + "Skipped".to_owned(), + ]) + ); + assert!(page.locked_count_modes.contains(&target)); + } + + #[tokio::test] + async fn count_mode_change_is_rejected_after_target_is_done() { + let dir = tempfile::tempdir().unwrap(); + let service = Service::new(dir.path()); + let mut session = LastFmImportSessionV1::new("user".into(), "spotify".into(), 10); + aggregate_scrobbles( + &mut session.rows, + &[scrobble("Artist", "Album", "Track", 1)], + ); + let source_id = session.rows[0].stable_id.clone(); + let target = "spotify:track:target"; + session.matches.insert( + source_id.clone(), + MatchResult { + source_id: source_id.clone(), + search_term: "Track".into(), + confidence: Some(Confidence::Exact), + selected_uri: Some(target.into()), + candidates: Vec::new(), + track_matches: BTreeMap::from([(source_id.clone(), target.into())]), + }, + ); + session.decisions.insert( + source_id, + RowDecision { + status: RowStatus::Done, + excluded: false, + }, + ); + session + .count_modes + .insert(target.into(), CountMode::Overwrite); + service.save(session).await.unwrap(); + + assert!(service + .set_count_mode(target, CountMode::Overwrite) + .await + .is_ok()); + assert!(service + .set_count_mode(target, CountMode::Zero) + .await + .is_err()); + assert_eq!( + service.snapshot().await.unwrap().count_modes.get(target), + Some(&CountMode::Overwrite) + ); + } + + #[test] + fn target_count_mode_is_session_scoped_across_pages_and_persisted() { + let mut session = LastFmImportSessionV1::new("user".into(), "spotify".into(), 10); + aggregate_scrobbles( + &mut session.rows, + &[ + scrobble("Artist", "First", "Song", 10), + scrobble("Artist", "First", "Song", 11), + scrobble("Artist", "Second", "Song!", 12), + scrobble("Artist", "Second", "Song!", 13), + ], + ); + let first = session.rows[0].stable_id.clone(); + let second = session.rows[1].stable_id.clone(); + for id in [&first, &second] { + session.matches.insert( + (*id).clone(), + MatchResult { + source_id: (*id).clone(), + search_term: String::new(), + confidence: Some(Confidence::Exact), + selected_uri: Some("spotify:track:target".into()), + candidates: Vec::new(), + track_matches: BTreeMap::from([((*id).clone(), "spotify:track:target".into())]), + }, + ); + } + let target = "spotify:track:target"; + session.decisions.insert( + first.clone(), + RowDecision { + status: RowStatus::Done, + excluded: false, + }, + ); + let current = vec![&session.rows[1]]; + session.count_modes.insert(target.into(), CountMode::Sum); + assert_eq!( + historical_count_for_target(&session, target, ¤t, &PageOptions::default()), + 4 + ); + session + .count_modes + .insert(target.into(), CountMode::Overwrite); + assert_eq!( + historical_count_for_target(&session, target, ¤t, &PageOptions::default()), + 2 + ); + session.count_modes.insert(target.into(), CountMode::Zero); + assert_eq!( + historical_count_for_target(&session, target, ¤t, &PageOptions::default()), + 0 + ); + + let dir = tempfile::tempdir().unwrap(); + let store = ImportSessionStore::new(dir.path()); + store.save(&session).unwrap(); + assert_eq!( + store.load().unwrap().unwrap().count_modes[target], + CountMode::Zero + ); + } + + #[test] + fn album_candidates_are_classified_from_track_set_overlap() { + let source = vec!["a".into(), "b".into()]; + let mut candidates = vec![ + AlbumCandidate { + uri: "best".into(), + name: "Best".into(), + artist: "A".into(), + track_uris: vec!["a".into(), "b".into()], + track_names: vec![], + track_artists: vec![], + track_albums: vec![], + relation: None, + }, + AlbumCandidate { + uri: "super".into(), + name: "Super".into(), + artist: "A".into(), + track_uris: vec!["a".into(), "b".into(), "c".into()], + track_names: vec![], + track_artists: vec![], + track_albums: vec![], + relation: None, + }, + AlbumCandidate { + uri: "same".into(), + name: "Same".into(), + artist: "A".into(), + track_uris: vec!["a".into(), "z".into()], + track_names: vec![], + track_artists: vec![], + track_albums: vec![], + relation: None, + }, + ]; + classify_album_candidates(&source, &mut candidates); + assert_eq!(candidates[0].relation, Some(AlbumRelation::BestMatch)); + assert_eq!(candidates[1].relation, Some(AlbumRelation::Superset)); + assert_eq!(candidates[2].relation, Some(AlbumRelation::SameSongs)); + } + + #[test] + fn fuzzy_strategy_remains_independent_per_target() { + let mut session = LastFmImportSessionV1::new("user".into(), "spotify".into(), 10); + aggregate_scrobbles( + &mut session.rows, + &[ + scrobble("Artist", "Album", "One", 1), + scrobble("artist", "album", "one", 2), + scrobble("Artist", "Album", "Two", 3), + ], + ); + let first = session.rows[0].stable_id.clone(); + let second = session.rows[1].stable_id.clone(); + session.matches.insert( + first.clone(), + MatchResult { + source_id: first.clone(), + search_term: String::new(), + confidence: Some(Confidence::Exact), + selected_uri: Some("spotify:track:one".into()), + candidates: Vec::new(), + track_matches: BTreeMap::from([(first.clone(), "spotify:track:one".into())]), + }, + ); + session.matches.insert( + second.clone(), + MatchResult { + source_id: second.clone(), + search_term: String::new(), + confidence: Some(Confidence::Exact), + selected_uri: Some("spotify:track:two".into()), + candidates: Vec::new(), + track_matches: BTreeMap::from([(second.clone(), "spotify:track:two".into())]), + }, + ); + session + .count_modes + .insert("spotify:track:one".into(), CountMode::Sum); + session + .count_modes + .insert("spotify:track:two".into(), CountMode::Zero); + assert_eq!( + historical_count_for_target( + &session, + "spotify:track:one", + &[&session.rows[0]], + &PageOptions::default() + ), + 2 + ); + assert_eq!( + historical_count_for_target( + &session, + "spotify:track:two", + &[&session.rows[1]], + &PageOptions::default() + ), + 0 + ); + } + + #[test] + fn history_is_max_count_and_max_latest_with_earliest_added_at() { + let mut library = Library::new(); + let id = library.add(retune_core::model::NewTrack { + uri: "spotify:track:song".into(), + source: SourceId::Music, + art: "Artist".into(), + alb: "Album".into(), + name: "Song".into(), + duration: Duration::from_secs(1), + added_at: Some(50), + ..retune_core::model::NewTrack::default() + }); + library.tracks_mut()[0].play_count = 8; + library.tracks_mut()[0].last_played_at = Some(90); + apply_history_updates( + &mut library, + &[HistoryUpdate { + uri: "spotify:track:song".into(), + play_count: Some(4), + earliest: Some(10), + latest: Some(100), + }], + ); + let track = library.get(id).unwrap(); + assert_eq!( + (track.play_count, track.last_played_at, track.added_at), + (8, Some(100), Some(10)) + ); + apply_history_updates( + &mut library, + &[HistoryUpdate { + uri: "spotify:track:song".into(), + play_count: Some(0), + earliest: None, + latest: None, + }], + ); + assert_eq!(library.get(id).unwrap().play_count, 8); + } + + #[test] + fn content_and_history_intents_are_independent_but_not_both_empty() { + let defaults = ImportDefaults::default(); + assert_eq!( + ( + defaults.import_content, + defaults.include_historical_play_counts, + defaults.whole_album + ), + (true, true, false) + ); + assert!(PageOptions { + import_content: true, + include_historical_play_counts: false, + ..PageOptions::default() + } + .validate() + .is_ok()); + assert!(PageOptions { + import_content: false, + include_historical_play_counts: true, + ..PageOptions::default() + } + .validate() + .is_ok()); + assert!(PageOptions { + import_content: false, + include_historical_play_counts: false, + ..PageOptions::default() + } + .validate() + .is_err()); + for rating in [0, 6] { + assert!(PageOptions { + rating: Some(rating), + ..PageOptions::default() + } + .validate() + .is_err()); + } + assert!(PageOptions { + import_content: false, + include_historical_play_counts: true, + whole_album: true, + ..PageOptions::default() + } + .validate() + .is_err()); + } + + #[test] + fn whole_album_history_keeps_unmatched_source_rows_pending() { + let rows = vec![ + SourceRow { + stable_id: "matched".into(), + artist: "Artist".into(), + album: "Album".into(), + track: "Matched".into(), + variants: Vec::new(), + play_count: 1, + earliest: 1, + latest: 1, + }, + SourceRow { + stable_id: "unmatched".into(), + artist: "Artist".into(), + album: "Album".into(), + track: "Unmatched".into(), + variants: Vec::new(), + play_count: 1, + earliest: 1, + latest: 1, + }, + ]; + let target_by_source = BTreeMap::from([("matched".into(), "spotify:track:one".into())]); + assert_eq!( + committed_source_ids(&rows, &target_by_source, true, true, true), + vec!["matched"] + ); + assert_eq!( + committed_source_ids(&rows, &target_by_source, true, true, false), + vec!["matched", "unmatched"] + ); + } + + #[test] + fn content_only_history_update_preserves_counts_and_last_played() { + let mut library = Library::new(); + let id = library.add(retune_core::model::NewTrack { + uri: "spotify:track:content".into(), + added_at: Some(50), + ..Default::default() + }); + library.tracks_mut()[0].play_count = 8; + library.tracks_mut()[0].last_played_at = Some(90); + apply_history_updates( + &mut library, + &[HistoryUpdate { + uri: "spotify:track:content".into(), + play_count: None, + earliest: Some(10), + latest: None, + }], + ); + let track = library.get(id).unwrap(); + assert_eq!( + (track.play_count, track.last_played_at, track.added_at), + (8, Some(90), Some(10)) + ); + } + + #[tokio::test] + async fn fake_spotify_transport_keeps_album_and_track_import_memberships_exact() { + let album = membership_uris_for_import( + true, + true, + Some("spotify:album:album"), + &["spotify:track:ignored".into()], + ) + .unwrap(); + let tracks = membership_uris_for_import( + true, + false, + None, + &[ + "spotify:track:one".into(), + "spotify:track:two".into(), + "spotify:track:one".into(), + ], + ) + .unwrap(); + let client = retune_spotify::client::fake_client( + [ + retune_spotify::client::Response::json(204, Value::Null), + retune_spotify::client::Response::json(204, Value::Null), + ], + "user-library-modify", + ); + client.save_to_library(&album).await.unwrap(); + client.save_to_library(&tracks).await.unwrap(); + let requests = client.transport().requests(); + assert_eq!(requests.len(), 2); + assert_eq!( + url::Url::parse(&requests[0].url) + .unwrap() + .query_pairs() + .find(|(key, _)| key == "uris") + .unwrap() + .1, + "spotify:album:album" + ); + assert_eq!( + url::Url::parse(&requests[1].url) + .unwrap() + .query_pairs() + .find(|(key, _)| key == "uris") + .unwrap() + .1, + "spotify:track:one,spotify:track:two" + ); + + let mut album_membership = SpotifyLibraryState { + account_id: "spotify-user".into(), + complete: true, + ..SpotifyLibraryState::default() + }; + album_membership.add_saved_album(SavedAlbumRecord { + uri: "spotify:album:album".into(), + name: "Album".into(), + artists: vec!["Artist".into()], + release_date: None, + album_type: None, + added_at: Some(100), + track_uris: vec!["spotify:track:one".into(), "spotify:track:two".into()], + }); + assert!(album_membership.saved_tracks.is_empty()); + assert_eq!(album_membership.saved_albums.len(), 1); + + let mut track_membership = SpotifyLibraryState { + account_id: "spotify-user".into(), + complete: true, + ..SpotifyLibraryState::default() + }; + for uri in ["spotify:track:one", "spotify:track:two"] { + track_membership.add_saved_track(uri.into(), Some(100)); + } + assert!(track_membership.saved_albums.is_empty()); + assert_eq!(track_membership.saved_tracks.len(), 2); + } + + #[test] + fn metadata_scope_and_blank_values_preserve_existing_data() { + let mut library = Library::new(); + let first = library.add(retune_core::model::NewTrack { + uri: "one".into(), + art: "A".into(), + alb: "B".into(), + cat: "Old".into(), + ..Default::default() + }); + let second = library.add(retune_core::model::NewTrack { + uri: "two".into(), + art: "A".into(), + alb: "B".into(), + cat: "Old".into(), + ..Default::default() + }); + apply_metadata( + &mut library, + &["one".into(), "two".into()], + false, + Some(" "), + Some(5), + ) + .unwrap(); + assert_eq!( + library.get(first).unwrap().rating.map(Rating::stars), + Some(5) + ); + assert_eq!( + library.get(second).unwrap().rating.map(Rating::stars), + Some(5) + ); + assert_eq!(library.get(first).unwrap().cat, "Old"); + apply_metadata( + &mut library, + &["one".into(), "two".into()], + true, + Some("Rock"), + Some(4), + ) + .unwrap(); + assert_eq!( + library + .album_rating(&AlbumKey { + source: SourceId::Music, + art: "A".into(), + alb: "B".into() + }) + .map(Rating::stars), + Some(4) + ); + assert_eq!( + library.get(first).unwrap().rating.map(Rating::stars), + Some(5) + ); + } + + #[test] + fn review_actions_cascade_and_remaining_count_is_durable() { + let mut session = LastFmImportSessionV1::new("user".into(), "spotify".into(), 10); + aggregate_scrobbles( + &mut session.rows, + &[ + scrobble("A", "Album", "One", 1), + scrobble("A", "Other", "Two", 2), + scrobble("B", "Album", "Three", 3), + ], + ); + assert_eq!(session.remaining(), 3); + skip_album(&mut session, "A", "Album"); + assert_eq!(session.remaining(), 3); + // Skipped pages remain revisitable; turning the page back to pending is undoable. + session.decisions.values_mut().for_each(|decision| { + if decision.status == RowStatus::Skipped { + decision.status = RowStatus::Pending + } + }); + ignore_artist(&mut session, "A"); + assert_eq!(session.remaining(), 1); + let open_id = session.rows[2].stable_id.clone(); + exclude_row(&mut session, &open_id, true); + assert!(session.decisions.values().any(|decision| decision.excluded)); + ignore_album(&mut session, "B", "Album"); + assert_eq!(session.remaining(), 0); + } + + #[tokio::test] + async fn fully_excluded_review_action_reaches_done_and_has_view_only_queue_status() { + let dir = tempfile::tempdir().unwrap(); + let service = Service::new(dir.path()); + service + .start_or_resume("lastfm-user", "spotify-user", 500, None) + .await + .unwrap(); + service + .checkpoint_page( + 1, + &ParsedRecentTracksPage { + page: 1, + total_pages: Some(1), + tracks: vec![ + scrobble("A", "Album", "One", 1), + scrobble("A", "Album", "Two", 2), + ], + ..ParsedRecentTracksPage::default() + }, + ) + .await + .unwrap(); + let mut session = service.snapshot().await.unwrap(); + session.phase = ImportPhase::Review; + service.save(session.clone()).await.unwrap(); + for row in &session.rows { + service + .review_action(&row.stable_id, "exclude", "A", "Album") + .await + .unwrap(); + } + let session = service.snapshot().await.unwrap(); + let refs = session.rows.iter().collect::>(); + assert_eq!(session.phase, ImportPhase::Done); + assert_eq!(queue_status(&session, &refs), Some(QueueStatus::Excluded)); + assert_eq!(session.remaining(), 0); + } + + #[test] + fn persistence_round_trip_quarantines_corrupt_unknown_and_rejects_oversize() { + let dir = tempfile::tempdir().unwrap(); + let store = ImportSessionStore::new(dir.path()); + let session = LastFmImportSessionV1::new("user".into(), "spotify".into(), 42); + store.save(&session).unwrap(); + assert_eq!(store.load().unwrap(), Some(session.clone())); + #[cfg(unix)] + assert_eq!( + fs::metadata(dir.path().join("lastfm-import.json")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + + let mut invalid_options = session.clone(); + invalid_options.page_options.insert( + "Artist\u{1f}Album".into(), + PageOptions { + rating: Some(6), + ..PageOptions::default() + }, + ); + store.save(&invalid_options).unwrap(); + assert_eq!(store.load().unwrap(), None); + + let mut invalid = session.clone(); + invalid.defaults = ImportDefaults { + import_content: false, + include_historical_play_counts: false, + whole_album: false, + }; + store.save(&invalid).unwrap(); + assert_eq!(store.load().unwrap(), None); + + fs::write(dir.path().join("lastfm-import.json"), br"not json").unwrap(); + assert_eq!(store.load().unwrap(), None); + assert!(fs::read_dir(dir.path()).unwrap().any(|entry| entry + .unwrap() + .file_name() + .to_string_lossy() + .contains("quarantine"))); + + let mut unknown = LastFmImportSessionV1::new("user".into(), "spotify".into(), 42); + unknown.version = 99; + store.save(&unknown).unwrap(); + assert_eq!(store.load().unwrap(), None); + assert!(fs::read_dir(dir.path()).unwrap().count() >= 2); + + let mut too_large = LastFmImportSessionV1::new("user".into(), "spotify".into(), 42); + too_large.rows.push(SourceRow { + stable_id: "x".into(), + artist: "a".into(), + album: "b".into(), + track: "c".into(), + variants: vec![SourceVariant { + artist: "a".into(), + album: "b".into(), + track: "c".into(), + play_count: 1, + earliest: 1, + latest: 1, + }], + play_count: 1, + earliest: 1, + latest: 1, + }); + too_large.rows[0].variants[0].track = "x".repeat(MAX_SERIALIZED_SESSION_BYTES); + assert!(store.save(&too_large).is_err()); + } + + #[tokio::test] + async fn page_checkpoint_resume_is_idempotent_and_account_mismatch_suspends() { + let dir = tempfile::tempdir().unwrap(); + let service = Service::new(dir.path()); + service + .start_or_resume("lastfm-user", "spotify-user", 500, None) + .await + .unwrap(); + let parsed = ParsedRecentTracksPage { + page: 1, + total_pages: Some(2), + total: Some(2), + tracks: vec![scrobble("Artist", "Album", "Track", 10)], + ..ParsedRecentTracksPage::default() + }; + service.checkpoint_page(1, &parsed).await.unwrap(); + service.checkpoint_page(1, &parsed).await.unwrap(); + let resumed = Service::new(dir.path()); + let session = resumed.snapshot().await.unwrap(); + assert_eq!(session.next_page, 2); + assert_eq!(session.rows.len(), 1); + assert_eq!(session.included_scrobbles, 1); + + let mismatch = resumed + .start_or_resume("other-user", "spotify-user", 600, None) + .await; + assert!(mismatch.is_err()); + assert_eq!( + resumed.snapshot().await.unwrap().phase, + ImportPhase::Suspended + ); + let resumed_for_owner = resumed + .start_or_resume("lastfm-user", "spotify-user", 600, None) + .await + .unwrap(); + assert_eq!(resumed_for_owner.phase, Some(ImportPhase::Downloading)); + } + + #[tokio::test] + async fn search_terms_preference_round_trips_on_resume() { + let dir = tempfile::tempdir().unwrap(); + let service = Service::new(dir.path()); + service + .start_or_resume("lastfm-user", "spotify-user", 500, None) + .await + .unwrap(); + service.set_search_terms(false).await.unwrap(); + assert!(!service.state().await.search_terms); + let resumed = Service::new(dir.path()); + assert!(!resumed.state().await.search_terms); + } + + #[tokio::test] + async fn mismatched_pages_do_not_advance_and_page_batches_are_compact() { + let dir = tempfile::tempdir().unwrap(); + let service = Service::new(dir.path()); + service + .start_or_resume("lastfm-user", "spotify-user", 500, None) + .await + .unwrap(); + let mismatched = ParsedRecentTracksPage { + page: 2, + tracks: vec![scrobble("Artist", "Album", "Track", 10)], + ..ParsedRecentTracksPage::default() + }; + assert!(service.checkpoint_page(1, &mismatched).await.is_err()); + assert_eq!(service.snapshot().await.unwrap().next_page, 1); + let duplicate_page = ParsedRecentTracksPage { + page: 1, + tracks: vec![ + scrobble("Artist", "Album", "Track", 10), + scrobble("artist", "album", "track", 20), + ], + ..ParsedRecentTracksPage::default() + }; + service.checkpoint_page(1, &duplicate_page).await.unwrap(); + let session = service.snapshot().await.unwrap(); + assert_eq!(session.batches[0].source_ids.len(), 1); + assert_eq!(session.next_page, 2); + } + + #[tokio::test] + async fn queue_reports_exact_entity_counts_for_current_page_choices() { + let dir = tempfile::tempdir().unwrap(); + let service = Service::new(dir.path()); + service + .start_or_resume("lastfm-user", "spotify-user", 500, None) + .await + .unwrap(); + let parsed = ParsedRecentTracksPage { + page: 1, + total_pages: Some(1), + total: Some(2), + tracks: vec![ + scrobble("Artist", "Album", "One", 10), + scrobble("Artist", "Album", "Two", 20), + ], + ..ParsedRecentTracksPage::default() + }; + service.checkpoint_page(1, &parsed).await.unwrap(); + let rows = service.snapshot().await.unwrap().rows; + for row in &rows { + let uri = format!("spotify:track:{}", row.track.to_lowercase()); + let mut track_matches = BTreeMap::new(); + track_matches.insert(row.stable_id.clone(), uri.clone()); + service + .set_match(MatchResult { + source_id: row.stable_id.clone(), + search_term: "album search".into(), + confidence: Some(Confidence::Exact), + selected_uri: Some("spotify:album:album".into()), + candidates: vec![AlbumCandidate { + uri: "spotify:album:album".into(), + name: "Album".into(), + artist: "Artist".into(), + track_uris: vec![uri], + track_names: vec![row.track.clone()], + track_artists: vec!["Artist".into()], + track_albums: vec!["Album".into()], + relation: Some(AlbumRelation::BestMatch), + }], + track_matches, + }) + .await + .unwrap(); + } + let queue = service.queue().await; + assert_eq!((queue[0].album_entities, queue[0].track_entities), (0, 2)); + + service + .update_options( + "Artist", + "Album", + PageOptions { + whole_album: true, + selected_track_ids: rows.iter().map(|row| row.stable_id.clone()).collect(), + ..PageOptions::default() + }, + ) + .await + .unwrap(); + let queue = service.queue().await; + assert_eq!((queue[0].album_entities, queue[0].track_entities), (1, 0)); + + let selected_track_ids = rows + .iter() + .map(|row| row.stable_id.clone()) + .collect::>(); + service + .update_options( + "Artist", + "Album", + PageOptions { + whole_album: false, + selected_track_ids, + ..PageOptions::default() + }, + ) + .await + .unwrap(); + let queue = service.queue().await; + assert_eq!((queue[0].album_entities, queue[0].track_entities), (0, 2)); + + service + .update_options( + "Artist", + "Album", + PageOptions { + import_content: false, + include_historical_play_counts: true, + ..PageOptions::default() + }, + ) + .await + .unwrap(); + let queue = service.queue().await; + assert_eq!((queue[0].album_entities, queue[0].track_entities), (0, 0)); + } + + #[tokio::test] + async fn selecting_an_album_candidate_remaps_every_related_source_track() { + let dir = tempfile::tempdir().unwrap(); + let service = Service::new(dir.path()); + service + .start_or_resume("lastfm-user", "spotify-user", 500, None) + .await + .unwrap(); + let parsed = ParsedRecentTracksPage { + page: 1, + total_pages: Some(1), + tracks: vec![ + scrobble("Artist", "Album", "One", 10), + scrobble("Artist", "Album", "Two", 20), + ], + ..ParsedRecentTracksPage::default() + }; + service.checkpoint_page(1, &parsed).await.unwrap(); + let rows = service.snapshot().await.unwrap().rows; + for row in &rows { + let old_track = format!("spotify:track:old-{}", row.track.to_lowercase()); + let new_track = format!("spotify:track:new-{}", row.track.to_lowercase()); + let mut track_matches = BTreeMap::new(); + track_matches.insert(row.stable_id.clone(), old_track.clone()); + service + .set_match(MatchResult { + source_id: row.stable_id.clone(), + search_term: "album search".into(), + confidence: Some(Confidence::Exact), + selected_uri: Some("spotify:album:old".into()), + candidates: vec![ + AlbumCandidate { + uri: "spotify:album:old".into(), + name: "Old release".into(), + artist: "Artist".into(), + track_uris: vec![old_track], + track_names: vec![row.track.clone()], + track_artists: vec!["Artist".into()], + track_albums: vec!["Old release".into()], + relation: Some(AlbumRelation::BestMatch), + }, + AlbumCandidate { + uri: "spotify:album:new".into(), + name: "Alternate release".into(), + artist: "Artist".into(), + track_uris: vec![new_track], + track_names: vec![row.track.clone()], + track_artists: vec!["Artist".into()], + track_albums: vec!["Alternate release".into()], + relation: Some(AlbumRelation::BestMatch), + }, + ], + track_matches, + }) + .await + .unwrap(); + } + + service + .select_match(&rows[0].stable_id, "spotify:album:new") + .await + .unwrap(); + let session = service.snapshot().await.unwrap(); + for row in rows { + let result = session.matches.get(&row.stable_id).unwrap(); + assert_eq!(result.selected_uri.as_deref(), Some("spotify:album:new")); + assert_eq!( + result.track_matches.get(&row.stable_id), + Some(&format!("spotify:track:new-{}", row.track.to_lowercase())) + ); + } + } +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 4f09b69..ecf5d8b 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod diagnostics; mod fixture; mod lastfm; +mod lastfm_import; mod library_commands; mod localfiles; mod media_keys; @@ -83,6 +84,7 @@ struct AppState { artwork_cache: Mutex>>, playback: Arc, lastfm: Arc, + lastfm_import: Arc, media_keys: media_keys::MediaKeys, sync_orchestrator: SyncOrchestrator, playlist_reauth_notified: AtomicBool, @@ -2264,6 +2266,20 @@ pub fn run() { lastfm::connect_lastfm, lastfm::finish_lastfm, lastfm::disconnect_lastfm, + lastfm_import::open_lastfm_importer, + lastfm_import::lastfm_import_state, + lastfm_import::lastfm_import_queue, + lastfm_import::lastfm_import_page, + lastfm_import::start_lastfm_import, + lastfm_import::lastfm_import_review, + lastfm_import::lastfm_import_options, + lastfm_import::lastfm_import_count_mode, + lastfm_import::lastfm_import_search_terms, + lastfm_import::lastfm_import_select_match, + lastfm_import::lastfm_import_change_track, + lastfm_import::lastfm_import_change_album, + lastfm_import::lastfm_import_apply, + lastfm_import::lastfm_import_accept_all_page, diagnostics::load_diagnostics, diagnostics::email_diagnostics ]) @@ -2329,6 +2345,7 @@ pub fn run() { use_dev_token_store, settings.lastfm_scrobbling, ); + let lastfm_import = lastfm_import::Service::new(&app_data_dir); // Native credential-store access can fail transiently; start // disconnected rather than aborting startup. let connection = match token_store.load() { @@ -2392,6 +2409,7 @@ pub fn run() { artwork_cache: Mutex::default(), playback: Arc::clone(&playback), lastfm: Arc::clone(&lastfm), + lastfm_import, media_keys, sync_orchestrator: SyncOrchestrator::default(), playlist_reauth_notified: AtomicBool::new(false), diff --git a/apps/desktop/src-tauri/src/provider.rs b/apps/desktop/src-tauri/src/provider.rs index 876a97f..408d433 100644 --- a/apps/desktop/src-tauri/src/provider.rs +++ b/apps/desktop/src-tauri/src/provider.rs @@ -1045,6 +1045,46 @@ pub async fn search( }) } +pub async fn search_albums( + client: &SpotifyClient, + query: &str, +) -> Result, String> { + let results = SpotifyClient::search_with_types(client, query, "album", 0, SEARCH_PAGE_SIZE) + .await + .map_err(|error| error.to_string())?; + Ok(search_group(results.albums, 0, search_album)) +} + +pub async fn search_tracks( + client: &SpotifyClient, + query: &str, +) -> Result, String> { + let results = SpotifyClient::search_with_types(client, query, "track", 0, SEARCH_PAGE_SIZE) + .await + .map_err(|error| error.to_string())?; + Ok(search_group(results.tracks, 0, |track| SearchTrack { + uri: track.uri, + name: track.name, + artist: track + .artists + .first() + .map(|artist| artist.name.clone()) + .unwrap_or_default(), + alb: track + .album + .as_ref() + .map(|album| album.name.clone()) + .unwrap_or_default(), + duration_secs: track.duration_ms.unwrap_or_default() / 1_000, + image_url: track + .album + .as_ref() + .and_then(|album| image_url(&album.images)), + album_uri: track.album.map(|album| album.uri), + in_library: false, + })) +} + pub async fn album_tracks( client: &SpotifyClient, album: &str, diff --git a/apps/desktop/src-tauri/src/spotify_commands.rs b/apps/desktop/src-tauri/src/spotify_commands.rs index 818ffba..e86fb5f 100644 --- a/apps/desktop/src-tauri/src/spotify_commands.rs +++ b/apps/desktop/src-tauri/src/spotify_commands.rs @@ -389,22 +389,42 @@ pub(super) async fn add_spotify_album( name: String, artist: String, ) -> Result<(), String> { - album_id(&uri).ok_or_else(|| "Expected a Spotify album URI".to_string())?; let state = app.state::(); let _membership_guard = state.spotify_library_gate.lock().await; let provider = provider_from(&state)?; - let added_at = unix_now(); - let (album, mut tracks) = - provider::album_content(provider.as_ref(), &uri, Some(added_at)).await?; + save_album_operation(&state, provider.as_ref(), &uri, &name, &artist, unix_now()).await?; + app.emit("library-changed", ()) + .map_err(|error| error.to_string()) +} + +pub(crate) struct AlbumSaveResult { + pub album_uri: String, + pub track_uris: Vec, +} + +/// Saves one album entity upstream and mirrors its content locally. The +/// caller holds `spotify_library_gate`; replaying this operation is safe +/// because Spotify's library PUT is idempotent and local upsert deduplicates. +pub(crate) async fn save_album_operation( + state: &AppState, + provider: &SpotifyProvider, + uri: &str, + name: &str, + artist: &str, + added_at: u64, +) -> Result { + album_id(uri).ok_or_else(|| "Expected a Spotify album URI".to_string())?; + let (album, mut tracks) = provider::album_content(provider, uri, Some(added_at)).await?; for track in &mut tracks { if track.alb.is_empty() { - track.alb.clone_from(&name); + track.alb = name.to_owned(); } if track.art.is_empty() { - track.art.clone_from(&artist); + track.art = artist.to_owned(); } } - let album_record = saved_album_record(&album, album_track_uris(&album), Some(added_at)); + let track_uris = album_track_uris(&album); + let album_record = saved_album_record(&album, track_uris.clone(), Some(added_at)); let album_uris = album_library_uris(&album.uri); let current = state .spotify_library @@ -427,7 +447,7 @@ pub(super) async fn add_spotify_album( .lock() .expect("Spotify library mutex poisoned") = next; } - mutate_library(&state, |library| { + mutate_library(state, |library| { for track in tracks { if spotify_track_match(library, &track).is_none_or(|existing| existing.uri == track.uri) { @@ -436,8 +456,10 @@ pub(super) async fn add_spotify_album( } Ok(()) })?; - app.emit("library-changed", ()) - .map_err(|error| error.to_string()) + Ok(AlbumSaveResult { + album_uri: album.uri, + track_uris, + }) } #[tauri::command] @@ -506,6 +528,21 @@ pub(super) async fn add_spotify_tracks( ) -> Result, String> { let state = app.state::(); let _membership_guard = state.spotify_library_gate.lock().await; + let provider = provider_from(&state)?; + let ids = save_tracks_operation(&state, provider.as_ref(), uris, unix_now()).await?; + app.emit("library-changed", ()) + .map_err(|error| error.to_string())?; + Ok(ids) +} + +/// Saves only track entities upstream and mirrors those tracks locally. The +/// caller holds `spotify_library_gate`; no album URI is synthesized here. +pub(crate) async fn save_tracks_operation( + state: &AppState, + provider: &SpotifyProvider, + uris: Vec, + added_at: u64, +) -> Result, String> { let mut seen = HashSet::new(); let uris = uris .into_iter() @@ -514,7 +551,6 @@ pub(super) async fn add_spotify_tracks( if uris.iter().any(|uri| track_id(uri).is_none()) { return Err("Expected Spotify track URIs".into()); } - let added_at = unix_now(); let mut ids = vec![]; let requested_uris = uris.clone(); if requested_uris.is_empty() { @@ -533,7 +569,6 @@ pub(super) async fn add_spotify_tracks( }, ); } - let provider = provider_from(&state)?; let mut tracks = Vec::with_capacity(missing_uris.len()); for uri in &missing_uris { let track = provider @@ -579,14 +614,12 @@ pub(super) async fn add_spotify_tracks( .lock() .expect("Spotify library mutex poisoned") = next; } - ids.extend(mutate_library(&state, |library| { + ids.extend(mutate_library(state, |library| { Ok(tracks .into_iter() .map(|track| library.upsert(track).0) .collect::>()) })?); - app.emit("library-changed", ()) - .map_err(|error| error.to_string())?; Ok(ids) } diff --git a/apps/desktop/src/App.css b/apps/desktop/src/App.css index 3628692..d1fd063 100644 --- a/apps/desktop/src/App.css +++ b/apps/desktop/src/App.css @@ -243,6 +243,8 @@ button { color: inherit; } .spotify-top-track-row > strong, .spotify-top-track-row > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .status-bar { height: 26px; flex: 0 0 26px; display: grid; grid-template-columns: minmax(0, 1fr); align-items: center; border-top: 1px solid var(--border); background: linear-gradient(var(--head1), var(--head2)); color: var(--dim); } .status-bar > span { text-align: center; } +.status-import-link { justify-self: center; padding: 0; border: 0; color: var(--accent); background: transparent; font: inherit; font-size: 11px; cursor: pointer; } +.status-import-link:hover, .status-import-link:focus-visible { text-decoration: underline; } .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; } diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 984b7d1..ad43860 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -8,7 +8,7 @@ import { defaultSettings, initialState, reducer, type Action, type State } from import { GetInfo, MultipleItemInformation, PlaybackAuthorization, Preferences, SetupLibrary } from './dialogViews.tsx' import { AlbumRatingStrip, BrowserPane, TrackCell, TrackList } from './libraryViews.tsx' import { SpotifyPageBack, SpotifySearch } from './spotifyViews.tsx' -import type { ActivePane, BrowseView, BrowserPanes, ColumnKey, ConnectionState, ImportSummary, LastFmState, PlaybackAuthorizationPrompt, PlaybackOrigin, PlaybackTrack, PlayOutcome, PlayerState, Playing, PlaylistListView, PlaylistSubject, PlaylistTrack, RepeatMode, Selection, Settings, Source, SpotifyNavEntry, SpotifyResults, Theme, Track, TrackInfo } from './types.ts' +import type { ActivePane, BrowseView, BrowserPanes, ColumnKey, ConnectionState, ImportSummary, LastFmImportState, LastFmState, PlaybackAuthorizationPrompt, PlaybackOrigin, PlaybackTrack, PlayOutcome, PlayerState, Playing, PlaylistListView, PlaylistSubject, PlaylistTrack, RepeatMode, Selection, Settings, Source, SpotifyNavEntry, SpotifyResults, Theme, Track, TrackInfo } from './types.ts' import { CheckboxMenu, ContextMenu, ModalDialog } from './viewShared.tsx' const LOCAL_PLAYLIST_HINT = "Selection includes local files — Spotify playlists can't contain them." @@ -270,6 +270,9 @@ function App() { invoke('lastfm_state') .then((lastfm) => dispatch({ type: 'lastfm', lastfm })) .catch(fail) + invoke('lastfm_import_state') + .then((lastfmImport) => dispatch({ type: 'lastfmImport', lastfmImport })) + .catch(fail) }, [fail]) const saveKey = useMemo( @@ -293,6 +296,7 @@ function App() { useTauriEvent('operation-recovered', () => dispatch({ type: 'clear-error' })) useTauriEvent('connection-changed', (connection) => dispatch({ type: 'connection', connection })) useTauriEvent('lastfm-changed', (lastfm) => dispatch({ type: 'lastfm', lastfm })) + useTauriEvent('lastfm-import-changed', (lastfmImport) => dispatch({ type: 'lastfmImport', lastfmImport })) useTauriEvent('settings-changed', (settings) => dispatch({ type: 'hydrateSettings', settings })) useTauriEvent('sync-progress', (phase) => dispatch({ type: 'syncPhase', phase: phase || undefined })) useTauriEvent<{ tracks: number; fraction: number }>('sync-progress-count', (progress) => dispatch({ type: 'syncProgress', progress })) @@ -387,6 +391,9 @@ function App() { preferenceZoom.current = state.settings.zoom dispatch({ type: 'preferences', open: true }) } + const openLastfmImporter = () => { + invoke('open_lastfm_importer').catch(fail) + } const cancelPreferences = () => { skipSettingsSave.current = true setZoom(preferenceZoom.current) @@ -681,7 +688,7 @@ function App() { )} {state.error &&
{state.error}
} - + {state.info?.kind === 'single' && dispatch({ type: 'info' })} onSaved={() => { @@ -697,7 +704,7 @@ function App() { return invoke('sync_from_spotify') }) .catch(fail)} />} - {state.preferences && dispatch({ type: 'lastfm', lastfm })} onSave={({ browserPanes, ...settings }) => { + {state.preferences && dispatch({ type: 'lastfm', lastfm })} onImport={openLastfmImporter} onSave={({ browserPanes, ...settings }) => { const audioChanged = settings.streamingBitrate !== state.settings.streamingBitrate || settings.normalizeVolume !== state.settings.normalizeVolume || settings.gapless !== state.settings.gapless @@ -1357,13 +1364,14 @@ function AddToPlaylist({ subject, revision, onAdd, onClose, onError }: { } -function StatusBar({ view, unit, syncPhase, syncProgress, importStatus, empty }: { view: BrowseView | null; unit: string; syncPhase?: string; syncProgress?: { tracks: number; fraction: number }; importStatus?: string; empty: boolean }) { +function StatusBar({ view, unit, syncPhase, syncProgress, importStatus, lastfmRemaining, onLastfmImport, empty }: { view: BrowseView | null; unit: string; syncPhase?: string; syncProgress?: { tracks: number; fraction: number }; importStatus?: string; lastfmRemaining: number; onLastfmImport: () => void; empty: boolean }) { const total = view?.counts.totalSecs ?? 0 const hours = Math.floor(total / 3600) const minutes = Math.floor((total % 3600) / 60) const count = view?.counts.tracks ?? 0 return
{syncProgress ? ⟳ Syncing from Spotify…{syncProgress.tracks} tracks synced + : lastfmRemaining > 0 ? : {syncPhase ?? importStatus ?? (empty ? 'No library — set up to begin' : `${count} ${count === 1 ? unit : `${unit}s`}, ${hours}:${String(minutes).padStart(2, '0')} hours`)}}
} diff --git a/apps/desktop/src/LastFmImporter.tsx b/apps/desktop/src/LastFmImporter.tsx new file mode 100644 index 0000000..4ae9513 --- /dev/null +++ b/apps/desktop/src/LastFmImporter.tsx @@ -0,0 +1,375 @@ +import { invoke } from '@tauri-apps/api/core' +import { listen } from '@tauri-apps/api/event' +import { getCurrentWindow } from '@tauri-apps/api/window' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { ModalDialog } from './viewShared.tsx' +import type { LastFmImportDefaults, Settings } from './types.ts' +import { excludedImportCount, nextRemainingImportQueue, resolveImportCount, restPendingImportCount, selectedImportCount, sortImportQueue, toggleImportRow, validImportIntent, type CountMode, type ImportQueueItem, type ImportSourceRow, type ReviewState } from './lastfmImportState.ts' +import './lastfmImporter.css' + +type ImportPhase = 'downloading' | 'matching' | 'review' | 'done' | 'suspended' +type ImportStateView = { + phase: ImportPhase | null + username: string | null + spotifyAccountId: string | null + nextPage: number + totalPages: number | null + totalScrobbles: number + includedScrobbles: number + matchedRows: number + matchTotal: number + defaults: LastFmImportDefaults + remaining: number + retryableError: { message: string; attempt: number; retryable: boolean } | null + searchTerms: boolean +} +type AlbumCandidate = { uri: string; name: string; artist: string; relation: 'best-match' | 'same-songs' | 'superset' | null; trackUris: string[]; trackNames: string[]; trackArtists: string[]; trackAlbums: string[] } +type MatchResult = { sourceId: string; searchTerm: string; confidence: 'exact' | 'likely' | 'low' | null; selectedUri: string | null; candidates: AlbumCandidate[]; trackMatches: Record } +type PageItem = { source: ImportSourceRow; decision: { status: 'pending' | 'done' | 'skipped' | 'ignored-album' | 'ignored-artist'; excluded: boolean }; matchResult: MatchResult | null } +type PageView = { state: ImportStateView; artist: string; album: string; pageNumber: number; pageCount: number; rows: PageItem[]; options: { importContent: boolean; includeHistoricalPlayCounts: boolean; wholeAlbum: boolean; genre: string | null; rating: number | null; selectedTrackIds: string[] }; fuzzyGroups: Record; countModes: Record; lockedCountModes: string[] } +type PickerKind = 'album' | 'track' +type PickerState = { kind: PickerKind; sourceId: string; query: string } +type FuzzyProps = { fuzzy?: ImportSourceRow[]; fuzzyTarget?: string; fuzzyExpanded: boolean; fuzzyMode: CountMode; fuzzyLocked: boolean; onFuzzyMode: (mode: CountMode) => void; onFuzzyToggle: () => void } + +const emptyDefaults: LastFmImportDefaults = { importContent: true, includeHistoricalPlayCounts: true, wholeAlbum: false } +const emptyState: ImportStateView = { phase: null, username: null, spotifyAccountId: null, nextPage: 1, totalPages: null, totalScrobbles: 0, includedScrobbles: 0, matchedRows: 0, matchTotal: 0, defaults: emptyDefaults, remaining: 0, retryableError: null, searchTerms: true } + +function reviewForPage(page: PageView): ReviewState { + const rows = page.rows.map((item) => item.source) + const decisions = Object.fromEntries(page.rows.map((item) => [item.source.stableId, item.decision])) + return { + rows, + decisions, + checked: new Set(page.options.selectedTrackIds), + importContent: page.options.importContent, + includeHistoricalPlayCounts: page.options.includeHistoricalPlayCounts, + wholeAlbum: page.options.wholeAlbum, + genre: page.options.genre ?? '', + rating: page.options.rating, + } +} + +function pageOptions(review: ReviewState) { + return { + importContent: review.importContent, + includeHistoricalPlayCounts: review.includeHistoricalPlayCounts, + wholeAlbum: review.wholeAlbum, + genre: review.genre || null, + rating: review.rating, + selectedTrackIds: [...review.checked], + } +} + +function pageWithQueuePosition(page: PageView | null, queue: ImportQueueItem[], sort: 'plays' | 'artist' | 'batch' | 'lastPlayed'): PageView | null { + if (!page) return null + const ordered = sortImportQueue(queue, sort) + const index = ordered.findIndex((item) => item.artist === page.artist && item.album === page.album) + return { ...page, pageNumber: index + 1, pageCount: ordered.length } +} + +function statusText(state: ImportStateView) { + if (state.phase === 'downloading') return `Downloading Last.fm history · page ${state.nextPage}${state.totalPages ? ` of ${state.totalPages}` : ''}` + if (state.phase === 'matching') return `Matching Last.fm history · ${state.matchedRows.toLocaleString()} of ${state.matchTotal.toLocaleString()} tracks` + if (state.phase === 'suspended') return 'Import suspended for account safety' + if (state.phase === 'done') return 'Import complete' + return state.username ? `Ready to review ${state.includedScrobbles.toLocaleString()} scrobbles` : 'Connect Last.fm and Spotify to begin' +} + +function matchedTrack(item: PageItem) { + const match = item.matchResult + if (!match) return null + const targetUri = match.trackMatches[item.source.stableId] ?? (match.selectedUri?.startsWith('spotify:track:') ? match.selectedUri : null) + if (!targetUri) return null + const candidate = match.candidates.find((entry) => entry.trackUris.includes(targetUri)) + if (!candidate) return null + const index = candidate.trackUris.indexOf(targetUri) + return { + uri: targetUri, + name: candidate.trackNames[index] || candidate.name, + artist: candidate.trackArtists[index] || candidate.artist, + album: candidate.trackAlbums[index] || candidate.name, + } +} + +function confidenceLabel(confidence: MatchResult['confidence']) { + return confidence === 'exact' ? 'Exact' : confidence === 'likely' ? 'Likely' : confidence === 'low' ? 'Low' : 'Unmatched' +} + +function relationLabel(relation: AlbumCandidate['relation']) { + return relation === 'best-match' ? 'Best match' : relation === 'same-songs' ? 'Same songs' : relation === 'superset' ? 'Superset' : 'Unclassified' +} + +function ImportIntentChecks({ defaults, disabled = false, onChange }: { defaults: LastFmImportDefaults; disabled?: boolean; onChange: (next: LastFmImportDefaults) => void }) { + const set = (key: 'importContent' | 'includeHistoricalPlayCounts', value: boolean) => { + const next = { ...defaults, [key]: value } + if (validImportIntent(next.importContent, next.includeHistoricalPlayCounts)) onChange(next) + } + return
+ Choose what Retune should import + + +
+} + +function DownloadPane({ state, defaults, busy, onDefaults, onStart }: { state: ImportStateView; defaults: LastFmImportDefaults; busy: boolean; onDefaults: (defaults: LastFmImportDefaults) => void; onStart: () => void }) { + const total = state.totalPages ?? 0 + const downloaded = Math.max(0, state.nextPage - 1) + const percent = total ? Math.min(100, Math.round((downloaded / total) * 100)) : 0 + const isSetup = state.phase === null + const isSuspended = state.phase === 'suspended' + return
+
+

LAST.FM HISTORY

+

{isSetup ? 'Import your complete Last.fm history' : isSuspended ? 'Import suspended for account safety' : 'Downloading your Last.fm history'}

+

{isSetup ? 'Retune takes a fixed snapshot and saves it page by page. You can review every match before anything is applied.' : isSuspended ? 'Reconnect the saved Last.fm and Spotify accounts before resuming this session.' : `Page ${state.nextPage}${state.totalPages ? ` of ${state.totalPages}` : ''} · ${state.includedScrobbles.toLocaleString()} scrobbles saved so far`}

+ {!isSetup && !isSuspended && <>{total ? `${downloaded} of ${total} pages downloaded` : 'Discovering the history size…'}} + +

You can leave this running — Retune keeps playing, and matching starts as soon as the history lands.

+ {state.retryableError &&

{state.retryableError.message} {state.retryableError.retryable ? `Attempt ${state.retryableError.attempt}. Resume to retry.` : ''}

} + +
+
+} + +function MatchingPane({ state }: { state: ImportStateView }) { + const [busy, setBusy] = useState(false) + const [error, setError] = useState() + const percent = state.matchTotal ? Math.round((state.matchedRows / state.matchTotal) * 100) : 0 + const resume = async () => { + setBusy(true) + setError(undefined) + try { await invoke('start_lastfm_import', { defaults: state.defaults }) } catch (reason) { setError(String(reason)) } finally { setBusy(false) } + } + return
+
+

SPOTIFY MATCH

+

Matching your Last.fm history to Spotify

+

Retune searches sequentially and saves each result so you can leave and resume safely.

+ + {state.matchedRows.toLocaleString()} of {state.matchTotal.toLocaleString()} source tracks matched +

You can leave this running — the review queue appears when matching is complete.

+ {state.retryableError &&

{state.retryableError.message}

} + {error &&

{error}

} + +
+
+} + +function MatchPickerDialog({ kind, query: initialQuery, candidates, selectedUri, busy, onCancel, onSearch, onChoose }: { kind: PickerKind; query: string; candidates: AlbumCandidate[]; selectedUri: string | null; busy: boolean; onCancel: () => void; onSearch: (query: string) => void; onChoose: (uri: string) => void }) { + const [query, setQuery] = useState(initialQuery) + const [choice, setChoice] = useState(selectedUri ?? '') + useEffect(() => { setQuery(initialQuery) }, [initialQuery]) + useEffect(() => { setChoice(selectedUri ?? '') }, [selectedUri]) + return { if (choice) void onChoose(choice) }}> +

{kind === 'album' ? 'CHANGE ALBUM' : 'CHANGE TRACK'}

{kind === 'album' ? 'Choose a Spotify release' : 'Choose a Spotify track'}

+
setQuery(event.target.value)} />
+
{candidates.length ? candidates.slice(0, 10).map((candidate) => ) :

Search to load up to 10 real Spotify candidates.

}
+ {kind === 'album' &&

Counts follow the tracks you keep. Choosing a release remaps this page together.

} +
+
+} + +function AcceptAllDialog({ albumEntities, trackEntities, busy, onCancel, onConfirm }: { albumEntities: number; trackEntities: number; busy: boolean; onCancel: () => void; onConfirm: () => void }) { + return +

Accept All Imports…

+

This will save {trackEntities.toLocaleString()} track {trackEntities === 1 ? 'entity' : 'entities'} and {albumEntities.toLocaleString()} album {albumEntities === 1 ? 'entity' : 'entities'} using the current choices.

+

Undecided rows use their best match. Sum is the default for each fuzzy target unless you chose another strategy. Retune applies this page by page and keeps partial progress if you leave or stop.

+
+
+} + +function FuzzyPanel({ rows, targetUri, mode, expanded, locked, onMode, onToggle }: { rows: ImportSourceRow[]; targetUri: string; mode: CountMode; expanded: boolean; locked: boolean; onMode: (mode: CountMode) => void; onToggle: () => void }) { + const variants = rows.flatMap((row) => row.variants) + return
FUZZY{variants.length} raw spellings · {resolveImportCount(rows, mode).toLocaleString()} resulting plays{locked && Locked after import}
{expanded &&
{variants.map((variant, index) => {variant.artist} · {variant.album} · {variant.track}{variant.playCount.toLocaleString()})}
}
Play counts{(['sum', 'overwrite', 'zero'] as CountMode[]).map((value) => )}
+} + +function ImporterRow({ item, checked, fuzzy, fuzzyTarget, onToggle, onExclude, onChangeTrack, onFuzzyMode, onFuzzyToggle, fuzzyExpanded, fuzzyMode, fuzzyLocked, showQuery }: { item: PageItem; checked: boolean; fuzzy?: ImportSourceRow[]; fuzzyTarget?: string; onToggle: () => void; onExclude: () => void; onChangeTrack: () => void; onFuzzyMode: (mode: CountMode) => void; onFuzzyToggle: () => void; fuzzyExpanded: boolean; fuzzyMode: CountMode; fuzzyLocked: boolean; showQuery: boolean }) { + const match = item.matchResult + const track = matchedTrack(item) + const excluded = item.decision.excluded + const disabled = excluded || item.decision.status === 'done' + return
+
{item.source.track}{item.source.playCount.toLocaleString()} plays · last {new Date(item.source.latest * 1000).toLocaleDateString()}{excluded && Excluded — won’t be imported or asked about again}{fuzzy && fuzzyTarget && }
+
{track ? <>{track.name}{track.artist} · {track.album}{confidenceLabel(match?.confidence ?? 'low')} : No supported match}{showQuery && match?.searchTerm && q={match.searchTerm}}
+
+} + +function ImportPage({ page, showQueries, onRefresh, onNext, onPrevious, onError }: { page: PageView; showQueries: boolean; onRefresh: () => Promise; onNext: (queue?: ImportQueueItem[]) => void; onPrevious: () => void; onError: (error: unknown) => void }) { + const [review, setReview] = useState(() => reviewForPage(page)) + const [busy, setBusy] = useState(false) + const [picker, setPicker] = useState(null) + const [expandedFuzzy, setExpandedFuzzy] = useState>(new Set()) + useEffect(() => { setReview(reviewForPage(page)); setExpandedFuzzy(new Set()) }, [page]) + const persist = async (next: ReviewState, refreshQueue = false) => { + setReview(next) + setBusy(true) + try { + await invoke('lastfm_import_options', { artist: page.artist, album: page.album, options: pageOptions(next) }) + if (refreshQueue) await onRefresh() + } catch (error) { onError(error) } finally { setBusy(false) } + } + const run = async (command: string, args: Record): Promise => { + setBusy(true) + try { + await invoke(command, args) + return await onRefresh() + } catch (error) { onError(error); return [] } finally { setBusy(false) } + } + const apply = async (advance: boolean) => { + setBusy(true) + try { + await invoke('lastfm_import_apply', { artist: page.artist, album: page.album, selectedIds: [...review.checked], options: pageOptions(review) }) + const nextQueue = await onRefresh() + if (advance) onNext(nextQueue) + } catch (error) { onError(error) } finally { setBusy(false) } + } + const fuzzyFor = (item: PageItem): { target: string; group: ImportSourceRow[] } | undefined => { + const target = item.matchResult?.trackMatches[item.source.stableId] + if (!target) return undefined + const group = page.fuzzyGroups[target] + const anchor = group?.find((row) => page.rows.some((entry) => entry.source.stableId === row.stableId)) + if (!group || !anchor || (group.length <= 1 && !group.some((entry) => entry.variants.length > 1)) || anchor.stableId !== item.source.stableId) return undefined + return { target, group } + } + const pickerItem = picker ? page.rows.find((item) => item.source.stableId === picker.sourceId) : undefined + const pickerMatch = pickerItem?.matchResult + const openTrackPicker = (sourceId: string) => { + const item = page.rows.find((entry) => entry.source.stableId === sourceId) + setPicker({ kind: 'track', sourceId, query: item?.matchResult?.searchTerm ?? item?.source.track ?? '' }) + } + const openAlbumPicker = () => setPicker({ kind: 'album', sourceId: page.rows[0]?.source.stableId ?? '', query: page.album }) + const searchPicker = async (query: string) => { + if (!picker) return + await run(picker.kind === 'album' ? 'lastfm_import_change_album' : 'lastfm_import_change_track', { id: picker.sourceId, query }) + setPicker({ ...picker, query }) + } + const choosePicker = async (uri: string) => { + if (!picker) return + await run('lastfm_import_select_match', { id: picker.sourceId, uri }) + setPicker(null) + } + const intentChange = (key: 'importContent' | 'includeHistoricalPlayCounts', checked: boolean) => { + const next = { ...review, [key]: checked } + if (!validImportIntent(next.importContent, next.includeHistoricalPlayCounts)) return + if (!next.importContent) next.wholeAlbum = false + void persist(next, true) + } + const fuzzy = (item: PageItem): FuzzyProps => { + const group = fuzzyFor(item) + if (!group) return { fuzzyExpanded: false, fuzzyMode: 'sum', fuzzyLocked: false, onFuzzyMode: () => {}, onFuzzyToggle: () => {} } + const mode = page.countModes[group.target] ?? 'sum' + return { + fuzzy: group.group, + fuzzyTarget: group.target, + fuzzyMode: mode, + fuzzyLocked: page.lockedCountModes.includes(group.target), + fuzzyExpanded: expandedFuzzy.has(group.target), + onFuzzyMode: (nextMode: CountMode) => void run('lastfm_import_count_mode', { targetUri: group.target, mode: nextMode }), + onFuzzyToggle: () => setExpandedFuzzy((current) => { const next = new Set(current); if (next.has(group.target)) next.delete(group.target); else next.add(group.target); return next }), + } + } + return
+

{page.artist}

{page.album || 'Singles'}

{page.rows.length} source tracks · {page.rows.reduce((total, item) => total + item.source.playCount, 0).toLocaleString()} plays

Page {page.pageNumber} of {page.pageCount}
+

WHAT I’M IMPORTING

{page.album || 'Singles'}{page.artist} · {page.rows.length} source tracks

SPOTIFY MATCH

{page.rows[0]?.matchResult?.candidates.find((candidate) => candidate.uri === page.rows[0]?.matchResult?.selectedUri)?.name ?? 'Choose a release'}{page.rows[0]?.matchResult?.confidence ? confidenceLabel(page.rows[0].matchResult.confidence) : 'No release selected'}
+
+ {review.wholeAlbum &&

Exclude removes only this Last.fm source row. A track inherently included by the whole album cannot be removed from Spotify here.

} +
{page.rows.map((item) => void persist(toggleImportRow(review, item.source.stableId), true)} onExclude={() => void run('lastfm_import_review', { id: item.source.stableId, action: item.decision.excluded ? 'undo-exclude' : 'exclude', artist: page.artist, album: page.album })} onChangeTrack={() => openTrackPicker(item.source.stableId)} {...fuzzy(item)} fuzzyExpanded={fuzzy(item).fuzzyExpanded ?? false} fuzzyMode={fuzzy(item).fuzzyMode ?? 'sum'} fuzzyLocked={fuzzy(item).fuzzyLocked ?? false} onFuzzyMode={fuzzy(item).onFuzzyMode ?? (() => {})} onFuzzyToggle={fuzzy(item).onFuzzyToggle ?? (() => {})} />)}
+
{selectedImportCount(review)} selected · {excludedImportCount(review)} excluded · {restPendingImportCount(review)} rest pending
+ {picker && pickerItem && setPicker(null)} onSearch={searchPicker} onChoose={choosePicker} />} +
+} + +export default function LastFmImporter() { + const [state, setState] = useState(emptyState) + const [queue, setQueue] = useState([]) + const [sort, setSort] = useState<'plays' | 'artist' | 'batch' | 'lastPlayed'>('plays') + const [showQueries, setShowQueries] = useState(true) + const [selected, setSelected] = useState(null) + const [page, setPage] = useState(null) + const [busy, setBusy] = useState(false) + const [error, setError] = useState() + const [acceptAllOpen, setAcceptAllOpen] = useState(false) + const [pendingDefaults, setPendingDefaults] = useState(emptyDefaults) + const orderedQueue = useMemo(() => sortImportQueue(queue, sort), [queue, sort]) + const selectedArtist = selected?.artist + const selectedAlbum = selected?.album + const refresh = useCallback(async (): Promise => { + try { + const [nextState, nextQueue] = await Promise.all([invoke('lastfm_import_state'), invoke('lastfm_import_queue')]) + setState(nextState) + setShowQueries(nextState.searchTerms) + setQueue(nextQueue) + setPendingDefaults(nextState.defaults) + const current = selectedArtist && selectedAlbum ? nextQueue.find((item) => item.artist === selectedArtist && item.album === selectedAlbum) : undefined + const firstRemaining = sortImportQueue(nextQueue, sort).find((item) => item.remaining) + const target = current ?? ((nextState.phase === 'review' || nextState.phase === 'done') ? firstRemaining : undefined) + if (target) { + if (target.artist !== selectedArtist || target.album !== selectedAlbum) setSelected(target) + setPage(pageWithQueuePosition(await invoke('lastfm_import_page', { artist: target.artist, album: target.album }), nextQueue, sort)) + } else if (nextState.phase !== 'review' && nextState.phase !== 'done') { + setSelected(null) + setPage(null) + } + return nextQueue + } catch (reason) { setError(String(reason)); return [] } + }, [selectedArtist, selectedAlbum, sort]) + useEffect(() => { + void refresh() + const subscription = listen('lastfm-import-changed', () => { void refresh() }) + return () => { void subscription.then((stop) => stop()) } + }, [refresh]) + useEffect(() => { setPage((current) => pageWithQueuePosition(current, queue, sort)) }, [queue, sort]) + useEffect(() => { + const media = window.matchMedia('(prefers-color-scheme: dark)') + let theme: Settings['theme'] = 'system' + const apply = (next: Settings['theme']) => { theme = next; document.documentElement.dataset.theme = next === 'system' ? media.matches ? 'dark' : 'light' : next } + const onMediaChange = () => apply(theme) + apply('system') + media.addEventListener('change', onMediaChange) + const subscription = listen('settings-changed', (event) => apply(event.payload.theme)) + void invoke('get_settings').then((settings) => apply(settings.theme)).catch(() => {}) + return () => { media.removeEventListener('change', onMediaChange); void subscription.then((stop) => stop()) } + }, []) + useEffect(() => { getCurrentWindow().setTitle('Last.fm importer').catch(() => {}) }, []) + const start = async () => { + if (!validImportIntent(pendingDefaults.importContent, pendingDefaults.includeHistoricalPlayCounts)) return + setBusy(true); setError(undefined) + try { await invoke('start_lastfm_import', { defaults: pendingDefaults }); await refresh() } catch (reason) { setError(String(reason)) } finally { setBusy(false) } + } + const openQueueItem = async (item: ImportQueueItem, queueSnapshot = queue) => { + setSelected(item) + try { setPage(pageWithQueuePosition(await invoke('lastfm_import_page', { artist: item.artist, album: item.album }), queueSnapshot, sort)) } catch (reason) { setError(String(reason)) } + } + const nextQueueItem = (queueSnapshot = queue) => { + const next = nextRemainingImportQueue(queueSnapshot, selected, sort) + if (next) void openQueueItem(next, queueSnapshot) + else { setSelected(null); setPage(null); void refresh() } + } + const previousQueueItem = () => { + const index = selected ? orderedQueue.findIndex((item) => item.artist === selected.artist && item.album === selected.album) : orderedQueue.length + const previous = orderedQueue.slice(0, index).reverse().find((item) => item.remaining) ?? orderedQueue.slice(index + 1).reverse().find((item) => item.remaining) + if (previous) void openQueueItem(previous) + } + const acceptAll = async () => { + setBusy(true); setError(undefined) + try { + for (const item of orderedQueue.filter((entry) => entry.remaining)) { await invoke('lastfm_import_accept_all_page', { artist: item.artist, album: item.album }); await refresh() } + setAcceptAllOpen(false) + } catch (reason) { setError(String(reason)) } finally { setBusy(false) } + } + const setSearchTerms = async (show: boolean) => { + setShowQueries(show) + setBusy(true) + try { await invoke('lastfm_import_search_terms', { show }) } catch (reason) { setError(String(reason)) } finally { setBusy(false) } + } + const reviewReady = state.phase === 'review' || state.phase === 'done' + const albumEntities = orderedQueue.filter((item) => item.remaining).reduce((total, item) => total + item.albumEntities, 0) + const trackEntities = orderedQueue.filter((item) => item.remaining).reduce((total, item) => total + item.trackEntities, 0) + return
+

LAST.FM HISTORY

Last.fm importer

{statusText(state)}{state.remaining ? ` · ${state.remaining.toLocaleString()} left` : ''}

Powered by Last.fm{reviewReady && <>Sort
{([['plays', 'Most played'], ['artist', 'Artist A–Z'], ['batch', 'Batch size'], ['lastPlayed', 'Last played']] as const).map(([value, label]) => )}
}{!reviewReady && state.phase !== 'downloading' && state.phase !== 'matching' && }
+ {error &&
{error}
} + {state.phase === 'downloading' || state.phase === null || state.phase === 'suspended' ? void start()} /> : state.phase === 'matching' ? :
{page ? setError(String(reason))} /> :
No review page selectedSelect an album from the queue.
}
} +
Last.fm is an absolute historical baseline. Existing Retune plays are never erased.{state.username ? `Last.fm: ${state.username}` : 'Account not connected'}
+ {acceptAllOpen && setAcceptAllOpen(false)} onConfirm={() => void acceptAll()} />} +
+} diff --git a/apps/desktop/src/appState.ts b/apps/desktop/src/appState.ts index 6bc0f69..c96eb1d 100644 --- a/apps/desktop/src/appState.ts +++ b/apps/desktop/src/appState.ts @@ -1,5 +1,5 @@ import { LIBRARY_DEFAULT_COLUMN_ORDER, LIBRARY_DEFAULT_HIDDEN_COLUMNS, rememberSelection, restoreSelection, selectionAfterFacet } from './ui.ts' -import type { BrowseView, BrowserPanes, ConnectionState, ImportSummary, InfoDialog, LastFmState, PlaybackAuthorizationPrompt, PlaybackOrigin, PlaybackTrack, PlayerState, Playing, Selection, Settings, Source, SpotifyNavEntry, SpotifyResults } from './types.ts' +import type { BrowseView, BrowserPanes, ConnectionState, ImportSummary, InfoDialog, LastFmImportState, LastFmState, PlaybackAuthorizationPrompt, PlaybackOrigin, PlaybackTrack, PlayerState, Playing, Selection, Settings, Source, SpotifyNavEntry, SpotifyResults } from './types.ts' const emptyTracks: PlaybackTrack[] = [] @@ -26,6 +26,7 @@ export type State = { playbackAuthorization: PlaybackAuthorizationPrompt | null connection: ConnectionState lastfm: LastFmState + lastfmImport: LastFmImportState spotifyResults: SpotifyResults | null spotifySearching: boolean spotifyNavigation?: SpotifyNavEntry @@ -65,6 +66,7 @@ export type Action = | { type: 'playbackAuthorization'; prompt: PlaybackAuthorizationPrompt | null } | { type: 'connection'; connection: ConnectionState } | { type: 'lastfm'; lastfm: LastFmState } + | { type: 'lastfmImport'; lastfmImport: LastFmImportState } | { type: 'spotifyResults'; results: SpotifyResults | null } | { type: 'spotifySearching'; searching: boolean } | { type: 'spotifyNavigate'; entry: SpotifyNavEntry } @@ -126,6 +128,7 @@ export const initialState: State = { playbackAuthorization: null, connection: { connected: false, needs_reauth: false, playback_authorized: false }, lastfm: { available: false, connected: false, username: null, pending: false, reconnectRequired: false, problem: null }, + lastfmImport: { phase: null, username: null, spotifyAccountId: null, nextPage: 1, totalPages: null, totalScrobbles: 0, includedScrobbles: 0, matchedRows: 0, matchTotal: 0, defaults: { importContent: true, includeHistoricalPlayCounts: true, wholeAlbum: false }, remaining: 0, retryableError: null, searchTerms: true }, spotifyResults: null, spotifySearching: false, playlistRevision: 0, @@ -220,6 +223,8 @@ export function reducer(state: State, action: Action): State { return { ...state, connection: action.connection, playbackAuthorization: action.connection.playback_authorized ? null : state.playbackAuthorization } case 'lastfm': return { ...state, lastfm: action.lastfm } + case 'lastfmImport': + return { ...state, lastfmImport: action.lastfmImport } case 'spotifyResults': return { ...state, spotifyResults: action.results, spotifySearching: false } case 'spotifySearching': diff --git a/apps/desktop/src/dialogViews.tsx b/apps/desktop/src/dialogViews.tsx index 1e00a71..53b4344 100644 --- a/apps/desktop/src/dialogViews.tsx +++ b/apps/desktop/src/dialogViews.tsx @@ -233,12 +233,13 @@ function BugPreferences() { } -export function Preferences({ settings, lastfm, onZoom, onCancel, onLastfm, onSave }: { +export function Preferences({ settings, lastfm, onZoom, onCancel, onLastfm, onImport, onSave }: { settings: Settings lastfm: LastFmState onZoom: (zoom: number) => void onCancel: () => void onLastfm: (state: LastFmState) => void + onImport: () => void onSave: (settings: Pick) => void }) { type PreferenceTab = 'appearance' | 'library' | 'audio' | 'bug' @@ -323,6 +324,7 @@ export function Preferences({ settings, lastfm, onZoom, onCancel, onLastfm, onSa + :