Rust implementation of Last.fm API integration for mt desktop music player, providing OAuth 1.0a authentication, scrobbling, now playing updates, and loved tracks import.
- Architecture Overview
- Module Structure
- OAuth 1.0a Authentication Flow
- Signature Generation
- Rate Limiting
- Scrobbling Logic
- Database Schema
- Tauri Commands
- Frontend Integration
- Error Handling
- Testing
- Security Considerations
The Last.fm integration is implemented as a modular Rust backend with the following components:
crates/mt-tauri/src/
βββ lastfm/
β βββ mod.rs # Module exports
β βββ client.rs # HTTP client with reqwest
β βββ config.rs # API key configuration
β βββ signature.rs # MD5 signature generation
β βββ rate_limiter.rs # Request rate limiting
β βββ types.rs # Request/response types
βββ commands/
βββ lastfm.rs # Tauri commands exposed to frontend
Key Design Decisions:
- Async/await: All API calls use
tokioasync runtime - Rate limiting: Enforced at client level (5/sec, 333/day)
- Error handling: Custom error types with
thiserror - Type safety: Strong typing with
serdeserialization - Event system: Tauri events for real-time frontend updates
The LastFmClient provides the core HTTP interface to Last.fm API:
pub struct LastFmClient {
config: ApiKeyConfig,
rate_limiter: Arc<RateLimiter>,
http_client: reqwest::Client,
base_url: String,
}Key Methods:
api_call()- Generic authenticated API call with signature generationget_auth_url()- Get OAuth token and authorization URLget_session()- Exchange token for session keyget_loved_tracks()- Fetch loved tracks (paginated)update_now_playing()- Update "Now Playing" statusscrobble()- Submit a scrobble
Handles API key configuration with environment-based loading:
pub struct ApiKeyConfig {
pub api_key: Option<String>,
pub api_secret: Option<String>,
}Development builds: Reads from LASTFM_API_KEY and LASTFM_API_SECRET env vars
Release builds: TODO - Currently uses env vars, future implementation will use HMAC-SHA256 obfuscation to embed keys in binary
Implements Last.fm's MD5 signature generation:
pub fn sign_params(params: &BTreeMap<String, String>, api_secret: &str) -> StringSignature Algorithm:
- Sort all parameters alphabetically by key (excluding
format) - Concatenate as
"key1value1key2value2..." - Append API secret
- Compute MD5 hash
- Convert to lowercase hex string
Enforces Last.fm API rate limits using tokio::sync::Mutex:
pub struct RateLimiter {
requests: Mutex<Vec<u64>>,
daily_limit: usize, // 333 requests/day
per_second_limit: usize, // 5 requests/second
}Behavior:
- Tracks timestamps of all requests in rolling 24-hour window
- Blocks async execution if limits exceeded
- Automatically cleans up expired timestamps
Comprehensive type definitions for all API requests/responses using serde:
- Settings types:
LastfmSettings,LastfmSettingsUpdate - Auth types:
AuthUrlResponse,AuthCallbackResponse,SessionInfo - Scrobbling types:
ScrobbleRequest,NowPlayingRequest,ScrobbleResponse - Queue types:
QueueStatusResponse,QueueRetryResponse - Import types:
ImportLovedTracksResponse,LovedTracksResponse
Exposes 13 Tauri commands to frontend:
Settings:
lastfm_get_settings()- Get current settingslastfm_update_settings()- Update enabled/threshold
Authentication:
lastfm_get_auth_url()- Get OAuth URLlastfm_auth_callback()- Complete OAuthlastfm_disconnect()- Disconnect account
Scrobbling:
lastfm_now_playing()- Update now playinglastfm_scrobble()- Scrobble track
Queue:
lastfm_queue_status()- Get queue countlastfm_queue_retry()- Retry failed scrobbles
Import:
lastfm_import_loved_tracks()- Import loved trackslastfm_cache_loved_tracks()- Cache loved tracks from Last.fmlastfm_match_loved_tracks()- Match cached loved tracks to librarylastfm_loved_stats()- Get loved tracks import statistics
Last.fm uses OAuth 1.0a with MD5 signatures. The flow involves three steps:
#[tauri::command]
pub async fn lastfm_get_auth_url(app: AppHandle) -> Result<AuthUrlResponse, String>- Call
auth.getTokenAPI method to get request token - Construct authorization URL:
https://www.last.fm/api/auth/?api_key=XXX&token=YYY - Emit
lastfm:authevent with status"pending" - Return URL and token to frontend
Frontend Action: Open authorization URL in browser
User visits authorization URL, logs into Last.fm, and authorizes the mt application. Last.fm then redirects to callback URL (which we ignore in desktop app).
#[tauri::command]
pub async fn lastfm_auth_callback(
app: AppHandle,
db: State<'_, Database>,
token: String,
) -> Result<AuthCallbackResponse, String>- Call
auth.getSessionwith token and signature - Receive session key and username
- Store in database:
lastfm_session_keyβ session key (plaintext)lastfm_usernameβ usernamelastfm_scrobbling_enabledβtrue
- Emit
lastfm:authevent with status"authenticated"
Security Note: Session keys are stored plaintext in local database because:
- Database is on user's local machine
- Session key must be reversible to make API calls
- If attacker has database access, they already own the machine
- This is standard practice for OAuth session tokens
Last.fm requires MD5 signatures for all authenticated requests (write operations and session establishment).
fn sign_params(params: &BTreeMap<String, String>, api_secret: &str) -> String {
// 1. Concatenate sorted params (excluding 'format')
let mut signature_string = String::new();
for (key, value) in params.iter() {
if key != "format" {
signature_string.push_str(key);
signature_string.push_str(value);
}
}
// 2. Append API secret
signature_string.push_str(api_secret);
// 3. MD5 hash and hex encode
format!("{:x}", md5::compute(signature_string.as_bytes()))
}Request parameters:
api_key: "abc123"
method: "track.scrobble"
artist: "Test Artist"
track: "Test Track"
timestamp: "1234567890"
sk: "session_key_123"
Signature string (sorted, format excluded):
api_keyabc123artistTest Artistmethodtrack.scrobbleskSession_key_123timestampTest Track1234567890test_secret
MD5 hash: c28d80ed34429217b843d790ea55d9ca
Last.fm enforces strict API limits:
- 5 requests per second
- 333 requests per 24 hours (rolling window)
pub async fn wait_if_needed(&self) {
let mut requests = self.requests.lock().await;
let now = Self::current_timestamp();
// Clean old requests (> 24 hours)
requests.retain(|&req_time| now - req_time < 86400);
// Check daily limit
if requests.len() >= self.daily_limit {
let oldest = requests[0];
let wait_time = 86400 - (now - oldest);
sleep(Duration::from_secs(wait_time)).await;
}
// Check per-second limit
let recent = requests.iter()
.filter(|&&t| now - t < 1)
.count();
if recent >= self.per_second_limit {
sleep(Duration::from_secs(1)).await;
}
// Record this request
requests.push(now);
}The rate limiter is shared via Arc<RateLimiter> across all API calls, ensuring global enforcement.
Last.fm requires scrobbles meet specific criteria. Our implementation uses a configurable threshold (25-100%, default 90%).
fn should_scrobble(duration: f64, played_time: f64, threshold_percent: u8) -> bool {
if duration <= 0.0 {
return false;
}
let threshold_fraction = threshold_percent as f64 / 100.0;
let fraction_played = played_time / duration;
let threshold_time = duration * threshold_fraction;
// ALL three conditions must be met:
let meets_minimum = played_time >= 30.0; // Absolute minimum
let meets_fraction = fraction_played >= threshold_fraction; // Percentage
let meets_threshold_or_cap = played_time >= f64::min(threshold_time, 240.0); // Cap at 4 minutes
meets_minimum && meets_fraction && meets_threshold_or_cap
}Rules:
- Absolute minimum: Must play at least 30 seconds
- Percentage requirement: Must play >= threshold percentage of track
- Max cap: For long tracks, capped at 240 seconds (4 minutes) OR threshold percentage, whichever is reached first
Examples:
| Track Length | Threshold | Required Play Time | Reasoning |
|---|---|---|---|
| 200s (3:20) | 50% | 100s (50%) | Half of track |
| 60s (1:00) | 90% | 54s (90%) | 90% of track |
| 600s (10:00) | 50% | 300s (50%) | Half of track (cap doesn't apply) |
| 1200s (20:00) | 50% | 600s (50%) | Half of track (exceeds 240s cap but still needs 50%) |
| 20s (0:20) | 90% | Cannot scrobble | Less than 30s minimum |
"Now Playing" updates are non-critical and fail silently:
#[tauri::command]
pub async fn lastfm_now_playing(
db: State<'_, Database>,
request: NowPlayingRequest,
) -> Result<serde_json::Value, String> {
// ... authentication checks ...
match client.update_now_playing(...).await {
Ok(_) => Ok(json!({ "status": "success" })),
Err(e) => {
// Non-critical - just log and return error status
eprintln!("[lastfm] Now Playing update failed: {}", e);
Ok(json!({ "status": "error", "message": e.to_string() }))
}
}
}Scrobbles are critical and queue on failure:
#[tauri::command]
pub async fn lastfm_scrobble(
app: AppHandle,
db: State<'_, Database>,
request: ScrobbleRequest,
) -> Result<ScrobbleResponse, String> {
// ... threshold check ...
match client.scrobble(...).await {
Ok(accepted) if accepted > 0 => {
// Success - emit event
app.emit(ScrobbleStatusEvent::success(...));
Ok(ScrobbleResponse { status: "success", message: None })
}
Ok(_) | Err(_) => {
// Failed or not accepted - queue for retry
queue_scrobble_for_retry(&app, &db, &request)?;
app.emit(ScrobbleStatusEvent::queued(...));
Ok(ScrobbleResponse {
status: "queued",
message: Some("Scrobble queued for retry")
})
}
}
}Failed scrobbles are queued in the database and can be retried manually:
#[tauri::command]
pub async fn lastfm_queue_retry(
app: AppHandle,
db: State<'_, Database>,
) -> Result<QueueRetryResponse, String> {
// Get up to 100 queued scrobbles
let queued = db.with_conn(|conn|
scrobble::get_queued_scrobbles(conn, 100)
)?;
for queued_scrobble in queued {
match client.scrobble(...).await {
Ok(accepted) if accepted > 0 => {
// Success - remove from queue
scrobble::remove_queued_scrobble(conn, id)?;
app.emit(ScrobbleStatusEvent::success(...));
}
_ => {
// Failed - increment retry count
scrobble::increment_scrobble_retry(conn, id)?;
}
}
}
// Emit queue updated event
app.emit(LastfmQueueUpdatedEvent::new(remaining_count));
}Future Work: Implement automatic background retry task (every 5 minutes when authenticated).
Last.fm settings stored as key-value pairs in settings table:
| Key | Type | Description |
|---|---|---|
lastfm_session_key |
string | Session key from OAuth (plaintext) |
lastfm_username |
string | Last.fm username |
lastfm_scrobbling_enabled |
bool | Enable/disable scrobbling (1/0) |
lastfm_scrobble_threshold |
u8 | Scrobble threshold percentage (25-100) |
Failed scrobbles queued for retry in scrobble_queue table:
| Column | Type | Description |
|---|---|---|
id |
INTEGER PRIMARY KEY | Queue entry ID |
artist |
TEXT | Artist name |
track |
TEXT | Track title |
album |
TEXT | Album name (optional) |
timestamp |
INTEGER | Unix timestamp |
retry_count |
INTEGER | Number of retry attempts |
created_at |
TIMESTAMP | When queued |
Cached Last.fm loved tracks in lastfm_loved_tracks table:
| Column | Type | Description |
|---|---|---|
id |
INTEGER PRIMARY KEY | Cache entry ID |
artist |
TEXT NOT NULL | Artist name from Last.fm |
track |
TEXT NOT NULL | Track name from Last.fm |
loved_at |
INTEGER | Unix timestamp when loved |
matched_track_id |
INTEGER | FK to library.id (NULL if unmatched) |
last_checked_at |
INTEGER | When matching was last attempted |
created_at |
TIMESTAMP | When cached |
Constraints: UNIQUE(artist, track), FK on matched_track_id with ON DELETE SET NULL
Indexes: composite on (artist, track), partial on unmatched rows (WHERE matched_track_id IS NULL)
Favorites tracking in library table:
| Column | Type | Description |
|---|---|---|
id |
INTEGER PRIMARY KEY | Track ID |
is_favorite |
BOOLEAN | Favorite flag |
All commands are registered in crates/mt-tauri/src/lib.rs:
.invoke_handler(tauri::generate_handler![
// ... other commands ...
lastfm_get_settings,
lastfm_update_settings,
lastfm_get_auth_url,
lastfm_auth_callback,
lastfm_disconnect,
lastfm_now_playing,
lastfm_scrobble,
lastfm_queue_status,
lastfm_queue_retry,
lastfm_import_loved_tracks,
lastfm_cache_loved_tracks,
lastfm_match_loved_tracks,
lastfm_loved_stats,
])lastfm_get_settings()
Get current Last.fm settings.
const settings = await invoke('lastfm_get_settings');
// Returns: {
// enabled: boolean,
// username: string | null,
// authenticated: boolean,
// configured: boolean,
// scrobble_threshold: number
// }lastfm_update_settings(settingsUpdate)
Update scrobbling enabled flag and/or threshold.
await invoke('lastfm_update_settings', {
settingsUpdate: {
enabled: true,
scrobble_threshold: 90
}
});
// Returns: { updated: ["enabled", "scrobble_threshold"] }lastfm_get_auth_url()
Get Last.fm authorization URL and token.
const { auth_url, token } = await invoke('lastfm_get_auth_url');
// Open auth_url in browser
// Store token for callbacklastfm_auth_callback(token)
Complete OAuth authentication.
const result = await invoke('lastfm_auth_callback', { token });
// Returns: { status: "success", username: "johndoe", message: "..." }lastfm_disconnect()
Disconnect Last.fm account.
await invoke('lastfm_disconnect');
// Returns: { status: "success", message: "Disconnected from Last.fm" }lastfm_now_playing(request)
Update "Now Playing" status (non-critical).
await invoke('lastfm_now_playing', {
request: {
artist: "Artist Name",
track: "Track Title",
album: "Album Name",
duration: 180
}
});
// Returns: { status: "success" } or { status: "error", message: "..." }lastfm_scrobble(request)
Scrobble a track.
const result = await invoke('lastfm_scrobble', {
request: {
artist: "Artist Name",
track: "Track Title",
album: "Album Name",
timestamp: Math.floor(Date.now() / 1000),
duration: 180,
played_time: 162 // 90% of 180s
}
});
// Returns:
// { status: "success" } - scrobbled successfully
// { status: "queued", message: "..." } - queued for retry
// { status: "threshold_not_met" } - didn't play enough
// { status: "disabled" } - scrobbling disabledlastfm_queue_status()
Get number of queued scrobbles.
const { queued_scrobbles } = await invoke('lastfm_queue_status');
// Returns: { queued_scrobbles: 5 }lastfm_queue_retry()
Manually retry queued scrobbles.
const result = await invoke('lastfm_queue_retry');
// Returns: {
// status: "Successfully retried 3 scrobbles",
// remaining_queued: 2
// }lastfm_import_loved_tracks()
Import loved tracks from Last.fm and add to favorites.
const result = await invoke('lastfm_import_loved_tracks');
// Returns: {
// status: "success",
// total_loved_tracks: 150,
// imported_count: 120,
// message: "Imported 120 tracks, 10 already favorited, 20 not in library"
// }Loved tracks are synced in two phases: cache (fetch from Last.fm API) and match (compare against local library).
Fetches all loved tracks from the Last.fm API (paginated, 200/page) and bulk-inserts them into the lastfm_loved_tracks table in a single SQLite transaction. Supports incremental mode using the most recent loved_at timestamp.
Runs entirely offline (no API calls). Operates in a single transaction for all reads and writes. Uses find_track_by_artist_title() which requires both artist AND title to match at every tier:
-
Tier 1 - Case-insensitive exact:
title = ? COLLATE NOCASE AND artist = ? COLLATE NOCASE. Catches casing differences like"the beatles"vs"The Beatles". -
Tier 2 - Substring on both fields:
title LIKE %?% AND (artist LIKE %?% OR album_artist LIKE %?%). Catches partial artist name differences like"Beatles"vs"The Beatles", or compilation tracks where the artist field differs from album_artist. -
On match: Sets
matched_track_idin the cache and adds the track to favorites (if not already favorited). -
On no match: Marks the track as checked (
last_checked_at) so it can be re-attempted later if the library changes.
Both artist and title are always required to match. There is no fallback that drops the artist constraint, which prevents false positives from common/short track names (e.g. "Plans", "Human", "17") matching unrelated tracks.
Frontend JavaScript API client (app/frontend/js/api.js) provides hybrid Tauri/HTTP support:
const invoke = window.__TAURI__?.core?.invoke;
export const api = {
lastfm: {
async getSettings() {
if (invoke) {
// Tauri mode - use commands
return await invoke('lastfm_get_settings');
}
// Browser mode - use HTTP (fallback)
return request('/lastfm/settings');
},
async scrobble(scrobbleData) {
if (invoke) {
return await invoke('lastfm_scrobble', { request: scrobbleData });
}
return request('/lastfm/scrobble', {
method: 'POST',
body: JSON.stringify(scrobbleData),
});
},
// ... other methods ...
}
};Frontend can listen for Tauri events:
import { listen } from '@tauri-apps/api/event';
// Listen for auth events
listen('lastfm:auth', (event) => {
console.log('Auth event:', event.payload);
// payload: { status: "authenticated", username: "johndoe" }
// payload: { status: "pending" }
// payload: { status: "disconnected" }
});
// Listen for scrobble status
listen('lastfm:scrobble-status', (event) => {
console.log('Scrobble:', event.payload);
// payload: { status: "success", artist: "...", track: "..." }
// payload: { status: "queued", artist: "...", track: "..." }
});
// Listen for queue updates
listen('lastfm:queue-updated', (event) => {
console.log('Queue size:', event.payload.queued_scrobbles);
});#[derive(Debug, thiserror::Error)]
pub enum LastFmError {
#[error("Last.fm API not configured (missing API key or secret)")]
NotConfigured,
#[error("Network error: {0}")]
NetworkError(String),
#[error("Failed to parse response: {0}")]
ParseError(String),
#[error("Authentication failed")]
AuthenticationFailed,
#[error("Invalid or expired session")]
InvalidSession,
#[error("Last.fm service is offline")]
ServiceOffline,
#[error("Account suspended")]
Suspended,
#[error("Rate limit exceeded")]
RateLimitExceeded,
#[error("Last.fm API error {0}: {1}")]
ApiError(u32, String),
#[error("HTTP error {0}: {1}")]
HttpError(u16, String),
}Last.fm API error codes are mapped to specific error types:
| Code | Error Type | Description |
|---|---|---|
| 4 | AuthenticationFailed |
Invalid authentication token |
| 9 | InvalidSession |
Session expired or invalid |
| 11 | ServiceOffline |
Last.fm temporarily offline |
| 26 | Suspended |
Account suspended |
| 29 | RateLimitExceeded |
Rate limit hit |
| Other | ApiError(code, msg) |
Generic API error |
Frontend shows user-friendly error messages:
try {
await api.lastfm.connectLastfm();
} catch (error) {
const errorMsg = error.message || error.toString();
Alpine.store('ui').toast(
errorMsg.includes('API keys not configured')
? 'Last.fm API keys not configured. Set LASTFM_API_KEY and LASTFM_API_SECRET in .env file.'
: `Failed to connect: ${errorMsg}`,
'error'
);
}Comprehensive unit tests cover all modules:
Signature tests (lastfm/signature.rs):
- Basic signature generation
- Format parameter exclusion
- Sorted parameter order
- Session key handling
Rate limiter tests (lastfm/rate_limiter.rs):
- Allows initial requests
- Enforces per-second limit
- Cleans expired requests
Config tests (lastfm/config.rs):
- Missing keys handling
- Partial config detection
- Full config validation
Command helper tests (commands/lastfm.rs):
is_setting_truthy()- Boolean parsingparse_threshold()- Range clamping (25-100)should_scrobble()- Threshold logic (minimum time, percentage, max cap)
# Run all tests
cargo test
# Run Last.fm tests only
cargo test lastfm
# Run with output
cargo test -- --nocapture
# Run specific test
cargo test test_should_scrobble_basic- 13+ unit tests across all modules
- Signature verification against Python reference implementation
- Threshold logic extensively tested with edge cases
- Rate limiter tested with async timing
- Config parsing tested for all valid/invalid inputs
Current (Development):
- API keys stored in
.envfile - Read from environment variables at runtime
β οΈ Keys visible in memory and process environment
Future (Release Builds):
- Keys embedded in binary with HMAC-SHA256 obfuscation
- Salted hash verification at runtime
- Not true security (decompilation possible) but adds barrier
- Standard practice for desktop apps (Spotify, Discord, etc.)
Database Storage (Plaintext):
- Session keys stored unencrypted in local SQLite database
- β
This is correct and secure because:
- Database is on user's local machine only
- Session key must be reversible for API calls
- Hashing would make key unusable
- If attacker has database access, machine is already compromised
- Standard practice for OAuth session tokens
HTTPS Only:
- All API calls use HTTPS (
https://ws.audioscrobbler.com/2.0/) - No HTTP fallback
- Certificates verified by reqwest
Signature Security:
- MD5 signatures prevent request tampering
- API secret never transmitted over network
- Each request signed individually
Protection Against:
- Accidental abuse (runaway scripts)
- API key revocation due to excessive requests
- Service degradation
Implementation:
- Client-side rate limiting enforced before requests
- Server-side limits still apply (defense in depth)
β OAuth 1.0a authentication flow β Settings management β Scrobbling with threshold logic β Now playing updates β Offline queue with manual retry β Loved tracks import (paginated) β Rate limiting β Error handling β Frontend integration β Tauri event system
β³ Background Retry Task: Automatic retry of queued scrobbles every 5 minutes when authenticated
β³ API Key Obfuscation: HMAC-SHA256 salted hash for release builds
β³ E2E Tests: Playwright tests for full authentication and scrobbling flows