Skip to content
Merged
30 changes: 30 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,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
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"main"
"main",
"lastfm-importer"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding lastfm-importer to the main window's default capability grants the importer dialog:default and opener:default, even though this dedicated review surface does not need the main window's broad plugin grants. This widens the impact of a renderer compromise in the importer. Give it a separate capability with only its required core/window permissions and, if the attribution link requires it, the narrow opener permission rather than the dialog and opener defaults.

Reply inline to this comment.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d5360f1. The main default capability now covers only main and retains the dialog/opener permissions it uses. Added capabilities/lastfm-importer.json scoped to lastfm-importer with core/window-title and opener permissions only—no dialog permission. Frontend typecheck/build, Rust clippy, and workspace tests pass.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks—this addresses the capability-scope concern by isolating the importer and removing its dialog permission.

Summary:
Resolved: the importer now has a dedicated, least-privilege capability without dialog access; the main window retains its existing broader permissions.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-actionable in pass 2: the capability scope remains fixed in d5360f1. The importer capability is separate, has core/window-title and opener only, and has no dialog permission; no additional code change was needed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved: the importer remains isolated in a dedicated least-privilege capability with no dialog permission; no further change is needed.

],
"permissions": [
"core:default",
Expand Down
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