Skip to content
Merged
34 changes: 34 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand All @@ -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

Expand All @@ -61,6 +66,35 @@ 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.
The importer serializes each session mutation through durable replacement before
updating memory, and matching rechecks the expected account and phase at every
durable checkpoint and before entering review. Suspended state exposes no prior
account identity or queue.

## Cross-cutting rules

- Provider URI is the normal deduplication identity; local files use canonical
Expand Down
1 change: 0 additions & 1 deletion apps/desktop/src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
"permissions": [
"core:default",
"core:window:allow-set-title",
"dialog:default",
"opener:default"
]
}
13 changes: 13 additions & 0 deletions apps/desktop/src-tauri/capabilities/lastfm-importer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "lastfm-importer",
"description": "Permissions for the Last.fm importer window",
"windows": [
"lastfm-importer"
],
"permissions": [
"core:default",
"core:window:allow-set-title",
"opener:default"
]
}
69 changes: 68 additions & 1 deletion apps/desktop/src-tauri/src/lastfm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ fn write_secret_json<T: Serialize>(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())?;
Expand Down Expand Up @@ -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<u32> {
match self {
Expand Down Expand Up @@ -871,6 +878,66 @@ impl Service {
}
}

pub(crate) async fn import_recent_tracks_page(
&self,
username: &str,
page: u32,
to: u64,
) -> Result<Value, ImportFetchError> {
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;
Expand Down
Loading