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 @@