Add scrobbling to Last.fm/ListenBrainz + update What's New

Scrobbling:
- Last.fm: API signing, web auth flow, batch scrobble (50/request)
- ListenBrainz: submit_listens (batch 1000, listen_type=import)
- Worker hook: scrobbles unscrobbled events after each poll
- DB: scrobbled_lastfm/scrobbled_listenbrainz tracking columns
- Settings: API secret input, authorize button, scrobble toggles
- Config: lastfm.api_secret, lastfm.session_key, *.scrobble_enabled

What's New: added all features from this session (scrobbling,
personalized discovery, stats page, SoulID, lossy codecs, import,
hero redesign, Hydrabase, orphan fixes, year collection).
This commit is contained in:
Broque Thomas 2026-03-22 14:38:53 -07:00
parent 232481fd13
commit 9e75731f6c
8 changed files with 526 additions and 8 deletions

View file

@ -392,14 +392,18 @@ class ConfigManager:
},
"listenbrainz": {
"base_url": "",
"token": ""
"token": "",
"scrobble_enabled": False
},
"acoustid": {
"api_key": "",
"enabled": False # Disabled by default - requires API key and fpcalc
},
"lastfm": {
"api_key": ""
"api_key": "",
"api_secret": "",
"session_key": "",
"scrobble_enabled": False
},
"genius": {
"access_token": ""

View file

@ -41,12 +41,14 @@ def rate_limited(func):
class LastFMClient:
"""Client for interacting with the Last.fm API (read-only metadata)"""
"""Client for interacting with the Last.fm API (metadata + scrobbling)"""
BASE_URL = "https://ws.audioscrobbler.com/2.0/"
def __init__(self, api_key: str = ""):
def __init__(self, api_key: str = "", api_secret: str = "", session_key: str = ""):
self.api_key = api_key
self.api_secret = api_secret
self.session_key = session_key
self.session = requests.Session()
self.session.headers.update({
'User-Agent': 'SoulSync/1.0',
@ -54,6 +56,118 @@ class LastFMClient:
})
logger.info("Last.fm client initialized")
def _sign_request(self, params: dict) -> str:
"""Generate MD5 API signature for write operations."""
import hashlib
# Sort params alphabetically, concatenate key+value pairs, append secret
sorted_str = ''.join(f'{k}{v}' for k, v in sorted(params.items()))
return hashlib.md5((sorted_str + self.api_secret).encode('utf-8')).hexdigest()
def get_auth_url(self, callback_url: str) -> Optional[str]:
"""Generate the Last.fm authorization URL for scrobbling.
User visits this URL, authorizes SoulSync, gets redirected back with a token.
"""
if not self.api_key:
return None
return f"https://www.last.fm/api/auth/?api_key={self.api_key}&cb={callback_url}"
def get_session_key(self, token: str) -> Optional[str]:
"""Exchange an auth token for a session key (one-time after user authorizes).
Returns the session key string, or None on failure.
"""
if not self.api_key or not self.api_secret or not token:
return None
params = {
'method': 'auth.getSession',
'api_key': self.api_key,
'token': token,
}
params['api_sig'] = self._sign_request(params)
params['format'] = 'json'
try:
response = self.session.get(self.BASE_URL, params=params, timeout=10)
data = response.json()
session = data.get('session', {})
key = session.get('key')
if key:
self.session_key = key
logger.info(f"Last.fm session key obtained for user: {session.get('name', '?')}")
return key
else:
error = data.get('error', 'Unknown')
logger.error(f"Last.fm auth failed: {data.get('message', error)}")
return None
except Exception as e:
logger.error(f"Last.fm session key exchange failed: {e}")
return None
def can_scrobble(self) -> bool:
"""Check if scrobbling is possible (has api_key, api_secret, and session_key)."""
return bool(self.api_key and self.api_secret and self.session_key)
@rate_limited
def scrobble_tracks(self, tracks: List[Dict]) -> bool:
"""Scrobble up to 50 tracks at once via track.scrobble POST endpoint.
Args:
tracks: list of dicts with {artist, track, album, timestamp (unix int)}
Returns:
True if scrobble succeeded.
"""
if not self.can_scrobble():
return False
if not tracks:
return True
# Last.fm accepts max 50 scrobbles per request
batch = tracks[:50]
params = {
'method': 'track.scrobble',
'api_key': self.api_key,
'sk': self.session_key,
}
for i, t in enumerate(batch):
ts = t.get('timestamp')
if isinstance(ts, str):
try:
from datetime import datetime
dt = datetime.fromisoformat(ts.replace('Z', '+00:00'))
ts = int(dt.timestamp())
except Exception:
continue
params[f'artist[{i}]'] = t.get('artist', '')
params[f'track[{i}]'] = t.get('track', '')
params[f'timestamp[{i}]'] = str(ts)
if t.get('album'):
params[f'album[{i}]'] = t['album']
params['api_sig'] = self._sign_request(params)
params['format'] = 'json'
try:
response = self.session.post(self.BASE_URL, data=params, timeout=15)
data = response.json()
if 'scrobbles' in data:
accepted = data['scrobbles'].get('@attr', {}).get('accepted', 0)
logger.info(f"Last.fm scrobbled {accepted}/{len(batch)} tracks")
return True
else:
error = data.get('error', 'Unknown')
logger.warning(f"Last.fm scrobble failed: {data.get('message', error)}")
return False
except Exception as e:
logger.error(f"Last.fm scrobble error: {e}")
return False
def _make_request(self, method: str, params: Dict = None, timeout: int = 10, raise_on_transient: bool = False) -> Optional[Dict]:
"""Make a request to the Last.fm API.

View file

@ -85,6 +85,79 @@ class ListenBrainzClient:
"""Check if client is authenticated"""
return bool(self.token and self.username)
def submit_listens(self, listens: List[Dict]) -> bool:
"""Submit play events to ListenBrainz.
Args:
listens: list of dicts with {artist, track, album, timestamp (unix int)}
Returns:
True if submission succeeded.
"""
if not self.is_authenticated():
return False
if not listens:
return True
# Build payload per ListenBrainz API spec
payload_listens = []
for listen in listens:
ts = listen.get('timestamp')
if not ts:
continue
# Convert ISO string to unix timestamp if needed
if isinstance(ts, str):
try:
from datetime import datetime
dt = datetime.fromisoformat(ts.replace('Z', '+00:00'))
ts = int(dt.timestamp())
except Exception:
continue
track_metadata = {
'artist_name': listen.get('artist', ''),
'track_name': listen.get('track', ''),
}
if listen.get('album'):
track_metadata['release_name'] = listen['album']
payload_listens.append({
'listened_at': ts,
'track_metadata': track_metadata,
})
if not payload_listens:
return True
# Submit in batches of 1000 (ListenBrainz limit)
for i in range(0, len(payload_listens), 1000):
batch = payload_listens[i:i + 1000]
try:
url = f"{self.base_url}/submit-listens"
response = self._make_request_with_retry(
'POST', url,
json={
'listen_type': 'import',
'payload': batch,
},
headers={
'Authorization': f'Token {self.token}',
'Content-Type': 'application/json',
}
)
if response and response.status_code == 200:
logger.info(f"Submitted {len(batch)} listens to ListenBrainz")
else:
status = response.status_code if response else 'no response'
logger.warning(f"ListenBrainz submit failed: {status}")
return False
except Exception as e:
logger.error(f"ListenBrainz submit error: {e}")
return False
return True
def get_playlists_created_for_user(self, count: int = 25, offset: int = 0) -> List[Dict]:
"""
Fetch playlists created FOR the user (recommendations, personalized playlists)

View file

@ -197,7 +197,11 @@ class ListeningStatsWorker:
self.stats['tracks_updated'] += len(updates)
logger.info(f"Updated play counts for {len(updates)} tracks")
# Step 3: Pre-compute stats cache for all time ranges
# Step 3: Scrobble new events to ListenBrainz and Last.fm
self.current_item = "Scrobbling to external services..."
self._scrobble_new_events()
# Step 4: Pre-compute stats cache for all time ranges
self.current_item = "Building stats cache..."
self._build_stats_cache()
@ -312,6 +316,112 @@ class ListeningStatsWorker:
if conn:
conn.close()
def _scrobble_new_events(self):
"""Scrobble unscrobbled listening events to ListenBrainz and Last.fm."""
conn = None
try:
# ListenBrainz scrobbling
if self.config_manager.get('listenbrainz.scrobble_enabled', False):
lb_token = self.config_manager.get('listenbrainz.token', '')
if lb_token:
conn = self.db._get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT id, title, artist, album, played_at
FROM listening_history
WHERE scrobbled_listenbrainz = 0
ORDER BY played_at ASC
LIMIT 500
""")
rows = cursor.fetchall()
conn.close()
conn = None
if rows:
try:
from core.listenbrainz_client import ListenBrainzClient
lb_client = ListenBrainzClient(token=lb_token)
if lb_client.is_authenticated():
listens = [{
'artist': r[2] or '',
'track': r[1] or '',
'album': r[3] or '',
'timestamp': r[4],
} for r in rows]
if lb_client.submit_listens(listens):
# Mark as scrobbled
ids = [r[0] for r in rows]
conn = self.db._get_connection()
cursor = conn.cursor()
placeholders = ','.join(['?'] * len(ids))
cursor.execute(f"UPDATE listening_history SET scrobbled_listenbrainz = 1 WHERE id IN ({placeholders})", ids)
conn.commit()
conn.close()
conn = None
logger.info(f"Scrobbled {len(ids)} events to ListenBrainz")
except Exception as e:
logger.debug(f"ListenBrainz scrobble failed: {e}")
# Last.fm scrobbling
if self.config_manager.get('lastfm.scrobble_enabled', False):
api_key = self.config_manager.get('lastfm.api_key', '')
api_secret = self.config_manager.get('lastfm.api_secret', '')
session_key = self.config_manager.get('lastfm.session_key', '')
if api_key and api_secret and session_key:
conn = self.db._get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT id, title, artist, album, played_at
FROM listening_history
WHERE scrobbled_lastfm = 0
ORDER BY played_at ASC
LIMIT 200
""")
rows = cursor.fetchall()
conn.close()
conn = None
if rows:
try:
from core.lastfm_client import LastFMClient
lfm_client = LastFMClient(api_key=api_key, api_secret=api_secret, session_key=session_key)
# Process in batches of 50 (Last.fm limit)
all_scrobbled_ids = []
for i in range(0, len(rows), 50):
batch = rows[i:i + 50]
tracks = [{
'artist': r[2] or '',
'track': r[1] or '',
'album': r[3] or '',
'timestamp': r[4],
} for r in batch]
if lfm_client.scrobble_tracks(tracks):
all_scrobbled_ids.extend(r[0] for r in batch)
if all_scrobbled_ids:
conn = self.db._get_connection()
cursor = conn.cursor()
placeholders = ','.join(['?'] * len(all_scrobbled_ids))
cursor.execute(f"UPDATE listening_history SET scrobbled_lastfm = 1 WHERE id IN ({placeholders})", all_scrobbled_ids)
conn.commit()
conn.close()
conn = None
logger.info(f"Scrobbled {len(all_scrobbled_ids)} events to Last.fm")
except Exception as e:
logger.debug(f"Last.fm scrobble failed: {e}")
except Exception as e:
logger.error(f"Scrobble error: {e}")
finally:
if conn:
try:
conn.close()
except Exception:
pass
def _resolve_db_track_id(self, title, artist):
"""Try to match a server track to a local DB track by title+artist."""
if not title:

View file

@ -2694,6 +2694,16 @@ class MusicDatabase:
cursor.execute("ALTER TABLE tracks ADD COLUMN last_played TIMESTAMP")
logger.info("Added last_played column to tracks table")
# Add scrobble tracking columns to listening_history
cursor.execute("PRAGMA table_info(listening_history)")
lh_cols = [c[1] for c in cursor.fetchall()]
if 'scrobbled_lastfm' not in lh_cols:
cursor.execute("ALTER TABLE listening_history ADD COLUMN scrobbled_lastfm INTEGER DEFAULT 0")
logger.info("Added scrobbled_lastfm column to listening_history")
if 'scrobbled_listenbrainz' not in lh_cols:
cursor.execute("ALTER TABLE listening_history ADD COLUMN scrobbled_listenbrainz INTEGER DEFAULT 0")
logger.info("Added scrobbled_listenbrainz column to listening_history")
except Exception as e:
logger.error(f"Error creating listening_history table: {e}")

View file

@ -18368,6 +18368,117 @@ def get_version_info():
"title": "What's New in SoulSync",
"subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes",
"sections": [
{
"title": "🎧 Scrobbling to Last.fm & ListenBrainz",
"description": "Automatically scrobble your plays from Plex, Jellyfin, or Navidrome",
"features": [
"• Listen on your media server — SoulSync automatically scrobbles to Last.fm and/or ListenBrainz",
"• Last.fm: full web auth flow — enter API secret, click Authorize, session key stored permanently",
"• ListenBrainz: simple token-based — enable toggle and plays are submitted automatically",
"• Batch scrobbling with dedup tracking — events only scrobbled once, never duplicated",
"• Both services fully opt-in via Settings toggles"
]
},
{
"title": "🧠 Personalized Discovery",
"description": "Discovery playlists now use your listening history to recommend better music",
"features": [
"• Release Radar: genre affinity (+10), artist familiarity (+15), overplay penalty (-10) scoring",
"• Discovery Weekly: serendipity weighting — boosts unheard artists in your favorite genres",
"• Recent Albums: adaptive time window (21-60 days) based on how fast you consume music",
"• New 'Because You Listen To' sections: personalized carousels for your top 3 artists",
"• Familiar Favorites weighted by actual play count, not random sampling",
"• All personalization gracefully falls back for new users with no listening data"
]
},
{
"title": "📊 Listening Stats Page",
"description": "Full stats dashboard with Chart.js visualizations of your listening habits",
"features": [
"• Overview cards: total plays, listening time, unique artists/albums/tracks",
"• Listening activity timeline bar chart with 7d/30d/12m/all time ranges",
"• Genre breakdown doughnut chart with percentage legend",
"• Top artists visual bubbles with profile pictures + ranked lists",
"• Top albums and tracks with album art, clickable artist names",
"• Library health: format breakdown bar, unplayed tracks, enrichment coverage",
"• Sync Now button for instant data refresh from your media server",
"• Background worker polls Plex/Jellyfin/Navidrome every 30 minutes",
"• Pre-computed cache — tab switching is instant"
]
},
{
"title": "🆔 SoulID Worker",
"description": "Deterministic identity system for cross-node artist/album/track matching",
"features": [
"• Generates soul IDs using SHA-256 hash of normalized names",
"• Artists: hash(name + debut_year) — debut year from iTunes + Deezer API verification",
"• Albums: hash(artist + album), Tracks: dual IDs (song + album-specific)",
"• Dashboard worker button with rainbow spinner and hover tooltip",
"• SoulSync badge on library artist cards when matched"
]
},
{
"title": "🔊 Lossy Codec Expansion + Retroactive Converter",
"description": "Opus and AAC support for post-download conversion, plus a repair job for existing files",
"features": [
"• Lossy copy now supports MP3, Opus, and AAC (M4A) — configurable codec and bitrate",
"• Opus: -map 0:a for clean audio extraction, cover art embedded via METADATA_BLOCK_PICTURE",
"• AAC: MP4Cover embedding, -movflags +faststart for streaming optimization",
"• New Lossy Converter repair job: scans FLAC library, creates findings, Fix/Fix All converts",
"• Job reads codec/bitrate from current settings at fix time (change settings after scanning)",
"• Independent Blasphemy Mode toggle per job (separate from download-time setting)"
]
},
{
"title": "📥 Smarter Staging Import",
"description": "Tag-first matching and auto-grouping for the import workflow",
"features": [
"• Tags take priority over filename parsing — no more '08' as artist name",
"• Auto-detected album groups from file tags shown as one-click import cards",
"• Match scoring rebalanced: title (0.45) + artist (0.15) + track# (0.30) + album bonus (0.10)",
"• Filename parser pattern order fixed — track numbers no longer misidentified as artists"
]
},
{
"title": "🎨 Library Artist Hero Redesign",
"description": "Expanded artist detail section with Last.fm integration",
"features": [
"• Horizontal service badge row with hover lift animations",
"• Last.fm bio with Read More toggle, listener/play count stats",
"• Scrollable top 100 tracks from Last.fm in sidebar card",
"• Last.fm tags merged with existing genres",
"• Compact inline progress bars for Albums/EPs/Singles completion"
]
},
{
"title": "🌐 Hydrabase Search & Routing",
"description": "Hydrabase shows as a search tab with proper ID routing",
"features": [
"• Hydrabase appears as a source tab on enhanced search when connected",
"• Plugin-aware ID routing: numeric IDs → iTunes, alphanumeric → Spotify",
"• Artist images fetched from iTunes for Hydrabase results",
"• Full Spotify-compatible interface: get_album, get_artist, get_track_details"
]
},
{
"title": "🔧 Orphan File Detector + MusicBrainz Fixes",
"description": "Better orphan detection and album version matching",
"features": [
"• Orphan detector: normalized tag matching strips feat./parentheticals to reduce false positives",
"• Orphan fix now prompts 'Move to Staging' or 'Delete' instead of auto-deleting",
"• MusicBrainz release matching: version qualifier scoring prevents deluxe → standard MBID mismatch",
"• Playlist sync crash fixed: profile ID captured at request time, not in background thread"
]
},
{
"title": "📅 Release Year Collection",
"description": "Post-processing now collects release year from all metadata sources",
"features": [
"• Year extracted from MusicBrainz, Deezer, Tidal, Qobuz during post-processing",
"• First source to find a year wins — written to ORIGINALDATE/DATE tags and album DB year",
"• Library Reorganize API year lookup cap raised from 50 to 200"
]
},
{
"title": "🔍 Multi-Source Search Tabs",
"description": "View search results from Spotify, iTunes, and Deezer side by side",
@ -41181,6 +41292,52 @@ def get_artist_lastfm_top_tracks(artist_id):
return jsonify({'success': True, 'tracks': []})
# ================================================================================================
@app.route('/api/lastfm/auth-url', methods=['GET'])
def lastfm_auth_url():
"""Get the Last.fm authorization URL for scrobbling."""
try:
api_key = config_manager.get('lastfm.api_key', '')
if not api_key:
return jsonify({'success': False, 'error': 'Last.fm API key not configured'}), 400
# Build callback URL
callback = request.host_url.rstrip('/') + '/api/lastfm/callback'
auth_url = f"https://www.last.fm/api/auth/?api_key={api_key}&cb={callback}"
return jsonify({'success': True, 'url': auth_url})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/lastfm/callback', methods=['GET'])
def lastfm_callback():
"""Last.fm auth callback — exchanges token for session key."""
try:
token = request.args.get('token')
if not token:
return "Error: No token received from Last.fm", 400
api_key = config_manager.get('lastfm.api_key', '')
api_secret = config_manager.get('lastfm.api_secret', '')
if not api_key or not api_secret:
return "Error: Last.fm API key and secret must be configured in Settings", 400
from core.lastfm_client import LastFMClient
client = LastFMClient(api_key=api_key, api_secret=api_secret)
session_key = client.get_session_key(token)
if session_key:
config_manager.set('lastfm.session_key', session_key)
return """
<html><body style="background:#121212;color:#fff;font-family:sans-serif;text-align:center;padding-top:100px;">
<h2>Last.fm Scrobbling Authorized!</h2>
<p>You can close this window and return to SoulSync.</p>
<script>setTimeout(()=>window.close(),3000);</script>
</body></html>
"""
else:
return "Error: Failed to get session key from Last.fm. Check your API key and secret.", 400
except Exception as e:
return f"Error: {e}", 500
# END LAST.FM ENRICHMENT INTEGRATION
# ================================================================================================

View file

@ -3589,6 +3589,12 @@
style="color: #eb743b;">ListenBrainz Settings</a></div>
<div class="callback-help">Self-hosted? Enter your server URL (e.g. http://localhost:8093)</div>
</div>
<div class="form-group" style="margin-top: 12px;">
<label class="checkbox-label">
<input type="checkbox" id="listenbrainz-scrobble-enabled">
Scrobble plays to ListenBrainz
</label>
</div>
</div>
<!-- AcoustID Settings -->
@ -3630,11 +3636,26 @@
<input type="password" id="lastfm-api-key"
placeholder="Last.fm API Key">
</div>
<div class="form-group">
<label>API Secret:</label>
<input type="password" id="lastfm-api-secret"
placeholder="Last.fm API Secret (required for scrobbling)">
</div>
<div class="callback-info">
<div class="callback-help">Get your API key from <a
<div class="callback-help">Get your API key and secret from <a
href="https://www.last.fm/api/account/create" target="_blank"
style="color: #d51007;">Last.fm API Account</a></div>
<div class="callback-help">Used for artist tags, play counts, and similar artists. Callback URL is not used.</div>
<div class="callback-help">API key: used for metadata enrichment. API secret: required for scrobbling.</div>
</div>
<div class="form-group" style="margin-top: 12px;">
<label class="checkbox-label">
<input type="checkbox" id="lastfm-scrobble-enabled">
Scrobble plays to Last.fm
</label>
</div>
<div class="form-actions" id="lastfm-scrobble-actions">
<button class="test-button" onclick="authorizeLastfmScrobbling()">Authorize Scrobbling</button>
<span class="setting-help-text" id="lastfm-scrobble-status"></span>
</div>
</div>

View file

@ -5479,6 +5479,15 @@ async function loadSettingsData() {
// Populate Last.fm settings
document.getElementById('lastfm-api-key').value = settings.lastfm?.api_key || '';
document.getElementById('lastfm-api-secret').value = settings.lastfm?.api_secret || '';
document.getElementById('lastfm-scrobble-enabled').checked = settings.lastfm?.scrobble_enabled === true;
const lfmStatus = document.getElementById('lastfm-scrobble-status');
if (lfmStatus) {
lfmStatus.textContent = settings.lastfm?.session_key ? 'Authorized' : 'Not authorized';
}
// Populate ListenBrainz scrobble toggle
document.getElementById('listenbrainz-scrobble-enabled').checked = settings.listenbrainz?.scrobble_enabled === true;
// Populate Genius settings
document.getElementById('genius-access-token').value = settings.genius?.access_token || '';
@ -6481,7 +6490,8 @@ async function saveSettings(quiet = false) {
},
listenbrainz: {
base_url: document.getElementById('listenbrainz-base-url').value,
token: document.getElementById('listenbrainz-token').value
token: document.getElementById('listenbrainz-token').value,
scrobble_enabled: document.getElementById('listenbrainz-scrobble-enabled').checked,
},
acoustid: {
api_key: document.getElementById('acoustid-api-key').value,
@ -6489,6 +6499,8 @@ async function saveSettings(quiet = false) {
},
lastfm: {
api_key: document.getElementById('lastfm-api-key').value,
api_secret: document.getElementById('lastfm-api-secret').value,
scrobble_enabled: document.getElementById('lastfm-scrobble-enabled').checked,
embed_tags: document.getElementById('embed-lastfm').checked,
tags: _collectServiceTags('lastfm')
},
@ -6660,6 +6672,23 @@ async function saveSettings(quiet = false) {
}
}
async function authorizeLastfmScrobbling() {
try {
// Save settings first so API secret is stored
await saveSettings();
const resp = await fetch('/api/lastfm/auth-url');
const data = await resp.json();
if (data.success && data.url) {
window.open(data.url, '_blank', 'width=600,height=500');
showToast('Authorize SoulSync in the Last.fm window that opened', 'info');
} else {
showToast(data.error || 'Could not generate auth URL', 'error');
}
} catch (e) {
showToast('Failed to start Last.fm authorization', 'error');
}
}
async function testConnection(service) {
try {
showLoadingOverlay(`Testing ${service} connection...`);