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:
parent
232481fd13
commit
9e75731f6c
8 changed files with 526 additions and 8 deletions
|
|
@ -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": ""
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
157
web_server.py
157
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 """
|
||||
<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
|
||||
# ================================================================================================
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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...`);
|
||||
|
|
|
|||
Loading…
Reference in a new issue