From 9847d6f0a9bd94f825289b64b89dceb851139a3b Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Thu, 25 Jun 2026 14:09:36 -0700 Subject: [PATCH] Wing It Pool: two-card landing (review + resolved), matching the Discovery Pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opens to the same category-card landing the Discovery Pool uses, with two cards: 'guesses to review' (unverified wing-it) and 'resolved manually' (ones you've Fixed) — click to drill in, Back to return. Previously it jumped straight to a single list. To populate the resolved list, the /fix endpoint now stamps was_wing_it on the rewritten extra_data (the wing_it_fallback flag is otherwise lost on fix), and get_wing_it_pool gained a resolved flag + the stats return both counts. Fixing/re-matching from either card refreshes in place. Seam test updated for both states. --- database/music_database.py | 62 ++++++++------ tests/test_wing_it_pool.py | 33 +++++--- web_server.py | 5 +- webui/static/stats-automations.js | 134 +++++++++++++++++++++++++----- 4 files changed, 172 insertions(+), 62 deletions(-) diff --git a/database/music_database.py b/database/music_database.py index be64e2a1..e73aa59a 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -13140,25 +13140,36 @@ 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 Pool: two states on a mirrored track's extra_data. Both key off wing_it_fallback, + # which is set by the wing-it stub and SURVIVES a manual fix (update_mirrored_track_extra_data + # merges rather than replaces), so the only difference is the manual_match flag: + # needs attention : wing_it_fallback=true AND NOT manual_match (unverified guess) + # resolved : wing_it_fallback=true AND manual_match=true (user fixed it — incl. fixes + # made before this feature existed, since the flag was never wiped) + _WING_IT_ATTENTION = ("mpt.extra_data LIKE '%\"wing_it_fallback\": true%' " + "AND mpt.extra_data NOT LIKE '%\"manual_match\": true%'") + _WING_IT_RESOLVED = ("mpt.extra_data LIKE '%\"wing_it_fallback\": true%' " + "AND mpt.extra_data LIKE '%\"manual_match\": true%'") - 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``). + def get_wing_it_pool(self, profile_id: int = None, playlist_id: int = None, + resolved: bool = False) -> list: + """Get Wing It tracks — the unverified guesses (default) or the ones you've resolved. + + Wing-it tracks are persisted on extra_data with ``wing_it_fallback: true`` (a best-effort + stub when a track couldn't match a metadata source). They count as 'discovered', so the + Discovery Pool hides them — this is the only surface that lists them. ``resolved=True`` + returns the ones a manual match has since fixed (carrying the ``was_wing_it`` marker). """ try: conn = self._get_connection() cursor = conn.cursor() - query = """ + where = self._WING_IT_RESOLVED if resolved else self._WING_IT_ATTENTION + query = f""" SELECT mpt.id, mpt.track_name, mpt.artist_name, mpt.album_name, - mpt.playlist_id, mp.name as playlist_name + mpt.playlist_id, mp.name as playlist_name, mpt.extra_data 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%' + WHERE {where} """ params = [] if playlist_id: @@ -13175,25 +13186,26 @@ class MusicDatabase: return [] def get_wing_it_pool_stats(self, profile_id: int = None) -> dict: - """Count of unverified Wing It auto-matches (excludes manually-matched).""" + """Counts for both Wing It states: unverified (``wing_it``) + resolved (``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']} + + def _count(where): + q = (f"SELECT COUNT(*) as cnt FROM mirrored_playlist_tracks mpt " + f"JOIN mirrored_playlists mp ON mpt.playlist_id = mp.id WHERE {where}") + params = [] + if profile_id: + q += " AND mp.profile_id = ?" + params.append(profile_id) + cursor.execute(q, params) + return cursor.fetchone()['cnt'] + + return {'wing_it': _count(self._WING_IT_ATTENTION), + 'matched': _count(self._WING_IT_RESOLVED)} except Exception as e: logger.error(f"Error getting wing it pool stats: {e}") - return {'wing_it': 0} + return {'wing_it': 0, 'matched': 0} # ==================== Retag Tool Methods ==================== diff --git a/tests/test_wing_it_pool.py b/tests/test_wing_it_pool.py index 5b23aa50..81b693a3 100644 --- a/tests/test_wing_it_pool.py +++ b/tests/test_wing_it_pool.py @@ -33,8 +33,11 @@ def _track(db, playlist_id, pos, name, artist, extra): 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} +# A resolved wing-it track: /fix MERGES extra_data, so wing_it_fallback survives alongside the +# new manual_match flag — that pairing is what marks it resolved (no separate marker needed). +WING_IT_RESOLVED = {'discovered': True, 'provider': 'spotify', 'confidence': 1.0, + 'wing_it_fallback': True, 'manual_match': True, + 'matched_data': {'name': 'Dopamine (Real)'}} MATCHED = {'discovered': True, 'provider': 'spotify', 'confidence': 0.95} FAILED = {'discovery_attempted': True, 'discovered': False} @@ -42,16 +45,20 @@ 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) + _track(db, pid, 0, 'Orbital Trans', 'Yoga Mao', WING_IT) # unverified -> attention + _track(db, pid, 1, 'Dopamine', 'Rvdical the Kid', WING_IT_RESOLVED) # resolved -> matched list + _track(db, pid, 2, 'Real Match', 'Some Artist', MATCHED) # normal match -> neither + _track(db, pid, 3, 'Lost Track', 'Nobody', FAILED) # failed -> 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} + attention = db.get_wing_it_pool(profile_id=1) + assert [t['track_name'] for t in attention] == ['Orbital Trans'] + assert attention[0]['artist_name'] == 'Yoga Mao' + assert attention[0]['playlist_name'] == 'Liked Songs' + + resolved = db.get_wing_it_pool(profile_id=1, resolved=True) + assert [t['track_name'] for t in resolved] == ['Dopamine'] + + assert db.get_wing_it_pool_stats(profile_id=1) == {'wing_it': 1, 'matched': 1} def test_scopes_by_playlist_and_profile(tmp_path): @@ -65,7 +72,7 @@ def test_scopes_by_playlist_and_profile(tmp_path): 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} + assert db.get_wing_it_pool_stats(profile_id=1) == {'wing_it': 2, 'matched': 0} def test_empty_when_no_wing_it(tmp_path): @@ -73,4 +80,4 @@ def test_empty_when_no_wing_it(tmp_path): 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} + assert db.get_wing_it_pool_stats(profile_id=1) == {'wing_it': 0, 'matched': 0} diff --git a/web_server.py b/web_server.py index c8dd461e..067d944a 100644 --- a/web_server.py +++ b/web_server.py @@ -35023,6 +35023,7 @@ def get_wing_it_pool(): playlist_id = request.args.get('playlist_id', type=int) tracks = database.get_wing_it_pool(profile_id=profile_id, playlist_id=playlist_id) + matched = database.get_wing_it_pool(profile_id=profile_id, playlist_id=playlist_id, resolved=True) stats = database.get_wing_it_pool_stats(profile_id=profile_id) playlists = database.get_mirrored_playlists(profile_id=profile_id) @@ -35030,6 +35031,7 @@ def get_wing_it_pool(): return jsonify({ 'tracks': tracks, + 'matched': matched, 'stats': stats, 'playlists': playlist_options, }) @@ -35080,7 +35082,8 @@ def fix_discovery_pool_track(): 'source': 'spotify', } - # Update the mirrored track's extra_data + # Update the mirrored track's extra_data (merges, so a wing-it track keeps its + # wing_it_fallback flag — that + manual_match is how the Wing It Pool lists resolved guesses). extra_data = { 'discovered': True, 'provider': 'spotify', diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index b7fae9dc..9073be1a 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -1298,10 +1298,12 @@ function closeDiscoveryPoolModal() { // a manual match drops the row from here on the next refresh. let _wingItPoolOverlay = null; let _wingItPoolData = null; +let _wingItPoolView = 'categories'; // 'categories' | 'attention' | 'matched' let _wingItPoolPlaylistFilter = null; async function openWingItPoolModal(playlistId = null) { _wingItPoolPlaylistFilter = playlistId; + _wingItPoolView = 'categories'; let url = '/api/wing-it-pool'; if (playlistId) url += `?playlist_id=${playlistId}`; try { @@ -1321,7 +1323,8 @@ async function openWingItPoolModal(playlistId = null) { const playlistOptions = (_wingItPoolData.playlists || []) .map(p => ``) .join(''); - const count = (_wingItPoolData.tracks || []).length; + const attentionCount = (_wingItPoolData.tracks || []).length; + const matchedCount = (_wingItPoolData.matched || []).length; overlay.innerHTML = `