Wing It Pool: review + re-match tracks Wing It auto-matched
Wing It auto-matches tracks to the server library on a best-effort guess; those tracks are flagged wing_it_fallback in extra_data and count as 'discovered', so the Discovery Pool hides them — there was no way to see or audit the guesses. New 'Wing It Pool' button (next to Discovery Pool on the Mirrored Playlists tab) opens a modal listing them with a per-playlist filter + search; 'Fix Match' reuses the Discovery Pool's fix flow (/api/discovery-pool/fix), and a manual match drops the track from the pool on refresh. No new table or provider hooks needed — the wing-it flag is already persisted, so this is a pure query (get_wing_it_pool / get_wing_it_pool_stats, cloning the failed-pool LIKE pattern) + a /api/wing-it-pool endpoint + a cloned modal. Found 81 wing-it tracks on a real library. Seam-tested (include unverified / exclude manual-matched / scope by playlist+profile).
This commit is contained in:
parent
e3915b63e6
commit
602b035bad
5 changed files with 308 additions and 2 deletions
|
|
@ -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,
|
||||
|
|
|
|||
76
tests/test_wing_it_pool.py
Normal file
76
tests/test_wing_it_pool.py
Normal file
|
|
@ -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}
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -2041,6 +2041,7 @@
|
|||
<div class="playlist-header">
|
||||
<h3>Mirrored Playlists</h3>
|
||||
<button class="pool-trigger-btn" onclick="openDiscoveryPoolModal()" title="View matched and failed discovery tracks">Discovery Pool</button>
|
||||
<button class="pool-trigger-btn" onclick="openWingItPoolModal()" title="Review tracks Wing It auto-matched on a best-effort guess — verify or re-match them">Wing It Pool</button>
|
||||
<button class="refresh-button mirrored" id="mirrored-refresh-btn">Update list</button>
|
||||
</div>
|
||||
<div class="playlist-scroll-container" id="mirrored-playlist-container">
|
||||
|
|
|
|||
|
|
@ -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 => `<option value="${p.id}" ${playlistId == p.id ? 'selected' : ''}>${_esc(p.name)}</option>`)
|
||||
.join('');
|
||||
const count = (_wingItPoolData.tracks || []).length;
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div class="modal-container playlist-modal">
|
||||
<div class="playlist-modal-header">
|
||||
<div class="playlist-header-content">
|
||||
<h2>Wing It Pool</h2>
|
||||
<div class="playlist-quick-info">
|
||||
<span class="playlist-owner ${count > 0 ? 'pool-header-failed-highlight' : ''}" id="wing-it-header-count">${count} to review</span>
|
||||
<select class="pool-playlist-filter" onchange="filterWingItPool(this.value)">
|
||||
<option value="">All Playlists</option>
|
||||
${playlistOptions}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<span class="playlist-modal-close" onclick="closeWingItPoolModal()">×</span>
|
||||
</div>
|
||||
|
||||
<div class="playlist-modal-body">
|
||||
<div class="pool-list-view" style="display: block;">
|
||||
<div class="pool-list-header">
|
||||
<span class="pool-list-title">⚡ Auto-matched by Wing It — verify or re-match</span>
|
||||
<input type="text" class="pool-list-search" id="wing-it-list-search" placeholder="Filter tracks..." oninput="renderWingItPoolList()">
|
||||
</div>
|
||||
<div class="pool-list-content" id="wing-it-list-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="playlist-modal-footer">
|
||||
<div class="playlist-modal-footer-left"></div>
|
||||
<div class="playlist-modal-footer-right">
|
||||
<button class="playlist-modal-btn playlist-modal-btn-secondary" onclick="closeWingItPoolModal()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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
|
||||
? '<div class="pool-empty">No Wing It tracks match your filter.</div>'
|
||||
: '<div class="pool-empty">No Wing It tracks — nothing was auto-matched on a guess.</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = tracks.map(t => `
|
||||
<div class="pool-track-row pool-failed">
|
||||
<div class="pool-track-info">
|
||||
<div class="pool-track-name">${_esc(t.track_name)}</div>
|
||||
<div class="pool-track-meta">
|
||||
<span class="pool-track-artist">${_esc(t.artist_name)}</span>
|
||||
<span class="pool-track-playlist-badge">${_esc(t.playlist_name)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="playlist-modal-btn playlist-modal-btn-primary pool-fix-btn" onclick="openPoolFixModal(${t.id}, '${_escJs(t.track_name)}', '${_escJs(t.artist_name)}')">Fix Match</button>
|
||||
</div>
|
||||
`).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');
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue