diff --git a/database/music_database.py b/database/music_database.py index 97925c32..be64e2a1 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -13140,6 +13140,61 @@ class MusicDatabase: logger.error(f"Error getting discovery pool stats: {e}") return {'matched': 0, 'failed': 0} + def get_wing_it_pool(self, profile_id: int = None, playlist_id: int = None) -> list: + """Get tracks that were auto-matched by Wing It mode (best-effort, unverified). + + Wing-it tracks are persisted on the mirrored track's extra_data with + ``wing_it_fallback: true`` (set when a track couldn't match a metadata source and got a + raw-name stub). They count as 'discovered', so the Discovery Pool's failed list excludes + them — this is the only surface that lists them so the user can verify/re-match the guesses. + Excludes any the user has already manually matched (``manual_match: true``). + """ + try: + conn = self._get_connection() + cursor = conn.cursor() + query = """ + SELECT mpt.id, mpt.track_name, mpt.artist_name, mpt.album_name, + mpt.playlist_id, mp.name as playlist_name + FROM mirrored_playlist_tracks mpt + JOIN mirrored_playlists mp ON mpt.playlist_id = mp.id + WHERE mpt.extra_data LIKE '%"wing_it_fallback": true%' + AND mpt.extra_data NOT LIKE '%"manual_match": true%' + """ + params = [] + if playlist_id: + query += " AND mpt.playlist_id = ?" + params.append(playlist_id) + elif profile_id: + query += " AND mp.profile_id = ?" + params.append(profile_id) + query += " ORDER BY mp.name, mpt.track_name" + cursor.execute(query, params) + return [dict(row) for row in cursor.fetchall()] + except Exception as e: + logger.error(f"Error getting wing it pool: {e}") + return [] + + def get_wing_it_pool_stats(self, profile_id: int = None) -> dict: + """Count of unverified Wing It auto-matches (excludes manually-matched).""" + try: + conn = self._get_connection() + cursor = conn.cursor() + query = """ + SELECT COUNT(*) as cnt FROM mirrored_playlist_tracks mpt + JOIN mirrored_playlists mp ON mpt.playlist_id = mp.id + WHERE mpt.extra_data LIKE '%"wing_it_fallback": true%' + AND mpt.extra_data NOT LIKE '%"manual_match": true%' + """ + params = [] + if profile_id: + query += " AND mp.profile_id = ?" + params.append(profile_id) + cursor.execute(query, params) + return {'wing_it': cursor.fetchone()['cnt']} + except Exception as e: + logger.error(f"Error getting wing it pool stats: {e}") + return {'wing_it': 0} + # ==================== Retag Tool Methods ==================== def add_retag_group(self, group_type: str, artist_name: str, album_name: str, diff --git a/tests/test_wing_it_pool.py b/tests/test_wing_it_pool.py new file mode 100644 index 00000000..5b23aa50 --- /dev/null +++ b/tests/test_wing_it_pool.py @@ -0,0 +1,76 @@ +"""Wing It Pool query — surfaces tracks Wing It auto-matched (best-effort guesses). + +Wing-it tracks are persisted as the ``wing_it_fallback: true`` flag on a mirrored track's +extra_data and count as 'discovered', so the Discovery Pool's failed list excludes them. The +Wing It Pool is the only surface that lists them. It must: include unverified wing-it tracks, +exclude ones the user already manually matched, scope by playlist + profile, and never include +plain matched/failed tracks. +""" + +from __future__ import annotations + +import json + +from database.music_database import MusicDatabase + + +def _playlist(db, name, profile_id=1, source_id='pl1'): + with db._get_connection() as conn: + cur = conn.execute( + "INSERT INTO mirrored_playlists (source, source_playlist_id, name, profile_id) VALUES (?,?,?,?)", + ('spotify', source_id, name, profile_id)) + conn.commit() + return cur.lastrowid + + +def _track(db, playlist_id, pos, name, artist, extra): + with db._get_connection() as conn: + conn.execute( + "INSERT INTO mirrored_playlist_tracks (playlist_id, position, track_name, artist_name, extra_data) " + "VALUES (?,?,?,?,?)", + (playlist_id, pos, name, artist, json.dumps(extra) if extra is not None else None)) + conn.commit() + + +WING_IT = {'discovered': True, 'provider': 'wing_it_fallback', 'confidence': 0, 'wing_it_fallback': True} +WING_IT_FIXED = {'discovered': True, 'provider': 'spotify', 'confidence': 1.0, + 'wing_it_fallback': True, 'manual_match': True} +MATCHED = {'discovered': True, 'provider': 'spotify', 'confidence': 0.95} +FAILED = {'discovery_attempted': True, 'discovered': False} + + +def test_lists_only_unverified_wing_it_tracks(tmp_path): + db = MusicDatabase(database_path=str(tmp_path / "w.db")) + pid = _playlist(db, 'Liked Songs') + _track(db, pid, 0, 'Orbital Trans', 'Yoga Mao', WING_IT) # unverified wing-it -> include + _track(db, pid, 1, 'Dopamine', 'Rvdical the Kid', WING_IT_FIXED) # manually fixed -> exclude + _track(db, pid, 2, 'Real Match', 'Some Artist', MATCHED) # normal match -> exclude + _track(db, pid, 3, 'Lost Track', 'Nobody', FAILED) # failed -> exclude (it's Discovery Pool's) + + out = db.get_wing_it_pool(profile_id=1) + assert [t['track_name'] for t in out] == ['Orbital Trans'] + assert out[0]['artist_name'] == 'Yoga Mao' + assert out[0]['playlist_name'] == 'Liked Songs' + assert db.get_wing_it_pool_stats(profile_id=1) == {'wing_it': 1} + + +def test_scopes_by_playlist_and_profile(tmp_path): + db = MusicDatabase(database_path=str(tmp_path / "w2.db")) + a = _playlist(db, 'Playlist A', profile_id=1, source_id='a') + b = _playlist(db, 'Playlist B', profile_id=1, source_id='b') + other = _playlist(db, 'Other Profile', profile_id=2, source_id='c') + _track(db, a, 0, 'A Song', 'AA', WING_IT) + _track(db, b, 0, 'B Song', 'BB', WING_IT) + _track(db, other, 0, 'C Song', 'CC', WING_IT) + + assert {t['track_name'] for t in db.get_wing_it_pool(profile_id=1)} == {'A Song', 'B Song'} + assert [t['track_name'] for t in db.get_wing_it_pool(playlist_id=a)] == ['A Song'] + assert db.get_wing_it_pool_stats(profile_id=1) == {'wing_it': 2} + + +def test_empty_when_no_wing_it(tmp_path): + db = MusicDatabase(database_path=str(tmp_path / "w3.db")) + pid = _playlist(db, 'Clean') + _track(db, pid, 0, 'Matched', 'X', MATCHED) + assert db.get_wing_it_pool(profile_id=1) == [] + assert db.get_wing_it_pool_stats(profile_id=1) == {'wing_it': 0} diff --git a/web_server.py b/web_server.py index 0c81f2f3..c8dd461e 100644 --- a/web_server.py +++ b/web_server.py @@ -35008,6 +35008,35 @@ def get_discovery_pool(): logger.error(f"Error getting discovery pool: {e}") return jsonify({"error": str(e)}), 500 +@app.route('/api/wing-it-pool', methods=['GET']) +def get_wing_it_pool(): + """List Wing It auto-matched tracks (unverified best-effort guesses), optionally per-playlist. + + These are tracks that couldn't match a metadata source and got a raw-name Wing It stub. They + count as 'discovered' so the Discovery Pool hides them — this surfaces them so the user can + verify and re-match. Re-matching reuses the Discovery Pool's /api/discovery-pool/fix endpoint + (both key off the mirrored_playlist_tracks.id), and a manual match drops the track from here. + """ + try: + database = get_database() + profile_id = get_current_profile_id() + playlist_id = request.args.get('playlist_id', type=int) + + tracks = database.get_wing_it_pool(profile_id=profile_id, playlist_id=playlist_id) + stats = database.get_wing_it_pool_stats(profile_id=profile_id) + + playlists = database.get_mirrored_playlists(profile_id=profile_id) + playlist_options = [{'id': p['id'], 'name': p['name']} for p in playlists] + + return jsonify({ + 'tracks': tracks, + 'stats': stats, + 'playlists': playlist_options, + }) + except Exception as e: + logger.error(f"Error getting wing it pool: {e}") + return jsonify({"error": str(e)}), 500 + @app.route('/api/discovery-pool/fix', methods=['POST']) def fix_discovery_pool_track(): """Manually fix a failed discovery by linking a mirrored track to a Spotify/iTunes result.""" diff --git a/webui/index.html b/webui/index.html index 1b83fae9..f3680b89 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2041,6 +2041,7 @@

Mirrored Playlists

+
diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index f08e2e23..b7fae9dc 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -1290,6 +1290,147 @@ function closeDiscoveryPoolModal() { loadDiscoveryPoolStats(); } +// ── Wing It Pool ─────────────────────────────────────────────────────────── +// Lists tracks Wing It auto-matched on a best-effort guess (extra_data +// wing_it_fallback flag). They count as 'discovered' so the Discovery Pool hides +// them — this is the only place to review + re-match the guesses. Re-match reuses +// the Discovery Pool's openPoolFixModal / /api/discovery-pool/fix (same track id); +// a manual match drops the row from here on the next refresh. +let _wingItPoolOverlay = null; +let _wingItPoolData = null; +let _wingItPoolPlaylistFilter = null; + +async function openWingItPoolModal(playlistId = null) { + _wingItPoolPlaylistFilter = playlistId; + let url = '/api/wing-it-pool'; + if (playlistId) url += `?playlist_id=${playlistId}`; + try { + const res = await fetch(url); + _wingItPoolData = await res.json(); + } catch (err) { + showToast('Failed to load Wing It pool', 'error'); + return; + } + if (_wingItPoolOverlay) _wingItPoolOverlay.remove(); + + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.id = 'wing-it-pool-overlay'; + overlay.onclick = (e) => { if (e.target === overlay) closeWingItPoolModal(); }; + + const playlistOptions = (_wingItPoolData.playlists || []) + .map(p => ``) + .join(''); + const count = (_wingItPoolData.tracks || []).length; + + overlay.innerHTML = ` + + `; + + document.body.appendChild(overlay); + overlay.style.display = 'flex'; + _wingItPoolOverlay = overlay; + renderWingItPoolList(); +} + +function renderWingItPoolList() { + const container = document.getElementById('wing-it-list-content'); + if (!container || !_wingItPoolData) return; + + const searchEl = document.getElementById('wing-it-list-search'); + const query = (searchEl ? searchEl.value : '').toLowerCase().trim(); + let tracks = _wingItPoolData.tracks || []; + if (query) { + tracks = tracks.filter(t => + (t.track_name || '').toLowerCase().includes(query) || + (t.artist_name || '').toLowerCase().includes(query) || + (t.playlist_name || '').toLowerCase().includes(query) + ); + } + if (tracks.length === 0) { + container.innerHTML = query + ? '
No Wing It tracks match your filter.
' + : '
No Wing It tracks — nothing was auto-matched on a guess.
'; + return; + } + container.innerHTML = tracks.map(t => ` +
+
+
${_esc(t.track_name)}
+
+ ${_esc(t.artist_name)} + ${_esc(t.playlist_name)} +
+
+ +
+ `).join(''); +} + +async function filterWingItPool(playlistId) { + _wingItPoolPlaylistFilter = playlistId || null; + let url = '/api/wing-it-pool'; + if (playlistId) url += `?playlist_id=${playlistId}`; + try { + const res = await fetch(url); + _wingItPoolData = await res.json(); + } catch (e) { + showToast('Failed to filter Wing It pool', 'error'); + return; + } + renderWingItPoolList(); + const countEl = document.getElementById('wing-it-header-count'); + if (countEl) { + const c = (_wingItPoolData.tracks || []).length; + countEl.textContent = `${c} to review`; + countEl.classList.toggle('pool-header-failed-highlight', c > 0); + } +} + +// Re-fetch + re-render the open Wing It pool (used after a Fix Match resolves a track). +function refreshWingItPool() { + filterWingItPool(_wingItPoolPlaylistFilter || ''); +} + +function closeWingItPoolModal() { + if (_wingItPoolOverlay) { + _wingItPoolOverlay.remove(); + _wingItPoolOverlay = null; + } + _wingItPoolData = null; +} + function showPoolCategories() { _discoveryPoolView = 'categories'; const grid = document.getElementById('pool-category-grid'); @@ -1700,8 +1841,12 @@ async function selectPoolFixTrack(track) { if (data.success) { showToast(`Matched: ${track.name}`, 'success'); closePoolFixModal(); - // Refresh pool data - filterDiscoveryPool(_discoveryPoolPlaylistFilter || ''); + // Refresh whichever pool the fix was launched from (Discovery or Wing It). + if (typeof _wingItPoolOverlay !== 'undefined' && _wingItPoolOverlay) { + refreshWingItPool(); + } else { + filterDiscoveryPool(_discoveryPoolPlaylistFilter || ''); + } } else { showToast(data.error || 'Failed to fix track', 'error'); }