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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 43 additions & 14 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +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
├── lastfm_import account-bound snapshot, lazy matching, review, and apply boundary
└── store app-data persistence
```

Expand All @@ -39,8 +39,9 @@ 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.
- `lastfm_import` owns the resumable Last.fm snapshot, raw-page cache, compact
source variants, lazy Spotify matching results, review decisions, and
account-bound application.
Its parsing and review helpers do not add network or filesystem concerns to
`retune-core`.

Expand Down Expand Up @@ -69,14 +70,38 @@ 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.
1320×840. An account-bound `lastfmScrobblingProfile` records the first
successful connection/enable timestamp for each Last.fm username; toggling
preserves the same username's timestamp and a different username replaces it.
The importer captures that fixed `historyTo`, probes metadata once, and fetches
`user.getRecentTracks` at its documented 200-row limit from the oldest page
toward page 1. It skips now-playing and undated rows, writes parsed raw pages
under a snapshot-specific machine cache, discards rows at or after `historyTo`
before caching or counting, acknowledges each page in a manifest only after
the atomic page write, and enforces 100 MiB session/cache ceilings. The exact
Last.fm username is recorded in both manifest and page metadata. No
aggregation or Spotify request occurs during source work. Once every manifest
page is present, raw-page reads, sorting, and aggregation run off the async
runtime; the importer then atomically enters review, or Done when no rows remain,
before best-effort cache cleanup.

The source runner persists retry state after Last.fm's internal capped retry is
exhausted, waits at the capped delay, and retries the same probe/page in
process while the app remains running; a failed request never advances its
cursor. An acknowledged missing, corrupt, oversized, or metadata-mismatched
page quarantines the snapshot and starts a fresh V2 session. An unacknowledged
page file is an ignorable orphan and can be overwritten on retry. V1 caches are
quarantined because the fixed cutoff changes page boundaries. Relaunching with
a saved Downloading or Aggregating session resumes it once after hydration;
an empty state never creates a session implicitly.

Opening a visible review batch lazily searches through the shared Spotify
client/request gate, serializes duplicate batch requests, binds the Spotify
account on the first match, and caches the results. Revisiting a cached batch
does not call Spotify and there is no adjacent prefetch. Accept All is the
explicit bulk exception: it sequentially prepares every remaining batch,
shows global unique album/track URI counts, then applies only after
confirmation.

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
Expand All @@ -88,11 +113,15 @@ 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.
mutation succeeds. The source session is bound to Last.fm first; Spotify
`/me` is nullable until the first lazy match. A later Spotify mismatch
suspends Spotify-derived work without invalidating the source snapshot.
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
durable checkpoint and before entering review. Cached Spotify-derived pages
trust only an exact cached library identity; otherwise the current `/me` account
is resolved. Post-search ownership validation and match persistence hold the
shared Spotify membership gate together. Suspended state exposes no prior
account identity or queue.

## Cross-cutting rules
Expand Down
41 changes: 31 additions & 10 deletions apps/desktop/src-tauri/src/lastfm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,18 @@ pub(crate) struct ImportFetchError {
pub account_mismatch: bool,
}

fn import_recent_tracks_params(username: &str, page: u32, to: u64) -> Vec<(String, String)> {
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()),
]
}

impl Failure {
fn code(self) -> Option<u32> {
match self {
Expand Down Expand Up @@ -907,15 +919,7 @@ impl Service {
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()),
];
let params = import_recent_tracks_params(username, page, to);
for attempt in 0..=RETRY_DELAYS.len() {
match self
.post("user.getRecentTracks", params.clone(), None)
Expand Down Expand Up @@ -1547,6 +1551,10 @@ fn retry_delay(attempt: usize) -> Duration {
RETRY_DELAYS[attempt.min(RETRY_DELAYS.len() - 1)]
}

pub(crate) fn import_retry_delay(attempt: usize) -> Duration {
retry_delay(attempt)
}

pub(crate) fn scrobble_threshold_ms(duration_secs: u64) -> Option<u64> {
(duration_secs > 30).then(|| (duration_secs.saturating_mul(500)).min(240_000))
}
Expand All @@ -1568,7 +1576,7 @@ pub(crate) async fn finish_lastfm(app: tauri::AppHandle) -> Result<LastFmState,
let state = app.state::<crate::AppState>();
let result = state.lastfm.finish(&app).await?;
state.lastfm.set_enabled(true).await;
crate::set_lastfm_scrobbling(&app, true)?;
crate::set_lastfm_scrobbling(&app, true).await?;
Ok(result)
}

Expand Down Expand Up @@ -1703,6 +1711,19 @@ mod tests {
);
}

#[test]
fn recent_tracks_import_params_keep_the_fixed_cutoff_and_page_limit() {
assert_eq!(
import_recent_tracks_params("last.fm-user", 7, 1786804381),
vec![
("user".into(), "last.fm-user".into()),
("page".into(), "7".into()),
("limit".into(), "200".into()),
("to".into(), "1786804381".into()),
]
);
}

#[test]
fn metadata_filters_unknown_artist_and_album() {
assert!(Scrobble::from_track(&track("Unknown Artist", "Album"), 1).is_none());
Expand Down
Loading