diff --git a/web_server.py b/web_server.py index 7774f46c..79cb3fe9 100644 --- a/web_server.py +++ b/web_server.py @@ -41919,6 +41919,54 @@ def stats_recent(): except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 +@app.route('/api/stats/resolve-track', methods=['POST']) +def stats_resolve_track(): + """Resolve a track by title+artist to get its file_path for playback.""" + try: + data = request.get_json() + title = data.get('title', '') + artist = data.get('artist', '') + if not title: + return jsonify({'success': False, 'error': 'Title required'}), 400 + + database = get_database() + conn = database._get_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT t.id, t.title, t.file_path, t.bitrate, t.duration, + ar.name as artist_name, al.title as album_title, + al.thumb_url, t.artist_id, t.album_id + FROM tracks t + JOIN artists ar ON ar.id = t.artist_id + LEFT JOIN albums al ON al.id = t.album_id + WHERE LOWER(t.title) = LOWER(?) AND LOWER(ar.name) = LOWER(?) + AND t.file_path IS NOT NULL AND t.file_path != '' + LIMIT 1 + """, (title.strip(), artist.strip())) + row = cursor.fetchone() + conn.close() + + if not row: + return jsonify({'success': False, 'error': 'Track not found in library'}) + + return jsonify({ + 'success': True, + 'track': { + 'id': row[0], + 'title': row[1], + 'file_path': row[2], + 'bitrate': row[3], + 'duration': row[4], + 'artist_name': row[5], + 'album_title': row[6], + 'image_url': fix_artist_image_url(row[7]) if row[7] else None, + 'artist_id': row[8], + 'album_id': row[9], + } + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + @app.route('/api/listening-stats/sync', methods=['POST']) def listening_stats_sync(): """Trigger an immediate listening stats poll.""" diff --git a/webui/static/script.js b/webui/static/script.js index 1c1974f0..d29feed9 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -42622,6 +42622,7 @@ async function playLibraryTrack(track, albumTitle, artistName) { } if (!albumArt) albumArt = artistDetailPageState.enhancedData.artist?.thumb_url; } + if (!albumArt && track._stats_image) albumArt = track._stats_image; // Set track info in the media player UI setTrackInfo({ @@ -54944,6 +54945,7 @@ async function loadStatsData() {
${_esc(item.name)}
${item.artist_id ? `${_esc(item.artist || '')}` : _esc(item.artist || '')}${item.album ? ' · ' + _esc(item.album) : ''}
+ ${_fmt(item.play_count)} plays `); @@ -55127,6 +55129,33 @@ function _renderLibraryHealth(data) { } } +async function playStatsTrack(title, artist, album) { + try { + const resp = await fetch('/api/stats/resolve-track', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title, artist }), + }); + const data = await resp.json(); + if (!data.success || !data.track) { + showToast(data.error || 'Track not found in library', 'error'); + return; + } + const t = data.track; + playLibraryTrack({ + id: t.id, + title: t.title, + file_path: t.file_path, + bitrate: t.bitrate, + artist_id: t.artist_id, + album_id: t.album_id, + _stats_image: t.image_url || null, + }, t.album_title || album || '', t.artist_name || artist || ''); + } catch (e) { + showToast('Failed to play track', 'error'); + } +} + function _renderRecentPlays(tracks) { const el = document.getElementById('stats-recent-plays'); if (!el) return; @@ -55150,6 +55179,7 @@ function _renderRecentPlays(tracks) { el.innerHTML = tracks.map(t => `
+ ${_esc(t.title)} ${_esc(t.artist || '')} ${_ago(t.played_at)} diff --git a/webui/static/style.css b/webui/static/style.css index 4e1bd5df..cb624d75 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -33064,6 +33064,41 @@ body { color: rgb(var(--accent-rgb)); } +/* Play buttons */ +.stats-play-btn { + width: 28px; + height: 28px; + border-radius: 50%; + border: none; + background: rgba(var(--accent-rgb), 0.15); + color: rgb(var(--accent-rgb)); + font-size: 10px; + cursor: pointer; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + opacity: 0; +} + +.stats-ranked-item:hover .stats-play-btn, +.stats-recent-item:hover .stats-play-btn { + opacity: 1; +} + +.stats-play-btn:hover { + background: rgb(var(--accent-rgb)); + color: #fff; + transform: scale(1.1); +} + +.stats-play-btn-sm { + width: 22px; + height: 22px; + font-size: 8px; +} + /* Library health */ .stats-health-grid { display: grid;