diff --git a/config/settings.py b/config/settings.py index 97c958e7..e936e148 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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": "" diff --git a/core/lastfm_client.py b/core/lastfm_client.py index 6c5f7f1b..6f9f2bda 100644 --- a/core/lastfm_client.py +++ b/core/lastfm_client.py @@ -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. diff --git a/core/listenbrainz_client.py b/core/listenbrainz_client.py index 914b6e59..78f032e5 100644 --- a/core/listenbrainz_client.py +++ b/core/listenbrainz_client.py @@ -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) diff --git a/core/listening_stats_worker.py b/core/listening_stats_worker.py index 9a18074d..213c6306 100644 --- a/core/listening_stats_worker.py +++ b/core/listening_stats_worker.py @@ -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: diff --git a/database/music_database.py b/database/music_database.py index a61e8ce3..2a9ca271 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -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}") diff --git a/web_server.py b/web_server.py index 1eb5cbe7..7774f46c 100644 --- a/web_server.py +++ b/web_server.py @@ -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 """ + +

Last.fm Scrobbling Authorized!

+

You can close this window and return to SoulSync.

+ + + """ + 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 # ================================================================================================ diff --git a/webui/index.html b/webui/index.html index 0925dfc5..2518dfe7 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3589,6 +3589,12 @@ style="color: #eb743b;">ListenBrainz Settings
Self-hosted? Enter your server URL (e.g. http://localhost:8093)
+
+ +
@@ -3630,11 +3636,26 @@ +
+ + +
-
Get your API key from Get your API key and secret from Last.fm API Account
-
Used for artist tags, play counts, and similar artists. Callback URL is not used.
+
API key: used for metadata enrichment. API secret: required for scrobbling.
+
+
+ +
+
+ +
diff --git a/webui/static/script.js b/webui/static/script.js index 83c8f7c2..1c1974f0 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -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...`);