Wing It Pool: two-card landing (review + resolved), matching the Discovery Pool
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.
This commit is contained in:
parent
602b035bad
commit
9847d6f0a9
4 changed files with 172 additions and 62 deletions
|
|
@ -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 ====================
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 => `<option value="${p.id}" ${playlistId == p.id ? 'selected' : ''}>${_esc(p.name)}</option>`)
|
||||
.join('');
|
||||
const count = (_wingItPoolData.tracks || []).length;
|
||||
const attentionCount = (_wingItPoolData.tracks || []).length;
|
||||
const matchedCount = (_wingItPoolData.matched || []).length;
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div class="modal-container playlist-modal">
|
||||
|
|
@ -1329,7 +1332,8 @@ async function openWingItPoolModal(playlistId = null) {
|
|||
<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>
|
||||
<span class="playlist-owner ${attentionCount > 0 ? 'pool-header-failed-highlight' : ''}" id="wing-it-header-attention">${attentionCount} to review</span>
|
||||
<span class="playlist-track-count" id="wing-it-header-matched">${matchedCount} resolved</span>
|
||||
<select class="pool-playlist-filter" onchange="filterWingItPool(this.value)">
|
||||
<option value="">All Playlists</option>
|
||||
${playlistOptions}
|
||||
|
|
@ -1340,9 +1344,33 @@ async function openWingItPoolModal(playlistId = null) {
|
|||
</div>
|
||||
|
||||
<div class="playlist-modal-body">
|
||||
<div class="pool-list-view" style="display: block;">
|
||||
<div class="pool-category-grid" id="wing-it-category-grid">
|
||||
<div class="pool-category-card failed" onclick="showWingItList('attention')">
|
||||
<div class="pool-category-fallback failed"></div>
|
||||
<div class="pool-category-overlay"></div>
|
||||
<div class="pool-category-content">
|
||||
<div class="pool-category-icon">⚡</div>
|
||||
<div class="pool-category-count failed" id="wing-it-cat-attention-count">${attentionCount}</div>
|
||||
<div class="pool-category-label">guesses to review</div>
|
||||
</div>
|
||||
<div class="pool-category-top-bar failed"></div>
|
||||
</div>
|
||||
<div class="pool-category-card matched" onclick="showWingItList('matched')">
|
||||
<div class="pool-category-fallback matched"></div>
|
||||
<div class="pool-category-overlay"></div>
|
||||
<div class="pool-category-content">
|
||||
<div class="pool-category-icon">✓</div>
|
||||
<div class="pool-category-count matched" id="wing-it-cat-matched-count">${matchedCount}</div>
|
||||
<div class="pool-category-label">resolved manually</div>
|
||||
</div>
|
||||
<div class="pool-category-top-bar matched"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pool-list-view" id="wing-it-list-view" style="display: none;">
|
||||
<div class="pool-list-header">
|
||||
<span class="pool-list-title">⚡ Auto-matched by Wing It — verify or re-match</span>
|
||||
<button class="pool-back-btn" onclick="showWingItCategories()">← Back</button>
|
||||
<span class="pool-list-title" id="wing-it-list-title"></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>
|
||||
|
|
@ -1361,16 +1389,45 @@ async function openWingItPoolModal(playlistId = null) {
|
|||
document.body.appendChild(overlay);
|
||||
overlay.style.display = 'flex';
|
||||
_wingItPoolOverlay = overlay;
|
||||
}
|
||||
|
||||
function showWingItList(view) {
|
||||
_wingItPoolView = view;
|
||||
const grid = document.getElementById('wing-it-category-grid');
|
||||
const listView = document.getElementById('wing-it-list-view');
|
||||
const title = document.getElementById('wing-it-list-title');
|
||||
const search = document.getElementById('wing-it-list-search');
|
||||
if (grid) grid.style.display = 'none';
|
||||
if (listView) listView.style.display = 'block';
|
||||
if (title) title.textContent = view === 'matched'
|
||||
? '✓ Resolved Wing It guesses' : '⚡ Guesses to review';
|
||||
if (search) search.value = '';
|
||||
renderWingItPoolList();
|
||||
}
|
||||
|
||||
function showWingItCategories() {
|
||||
_wingItPoolView = 'categories';
|
||||
const grid = document.getElementById('wing-it-category-grid');
|
||||
const listView = document.getElementById('wing-it-list-view');
|
||||
if (grid) grid.style.display = 'grid';
|
||||
if (listView) listView.style.display = 'none';
|
||||
}
|
||||
|
||||
function _wingItMatchedName(t) {
|
||||
try {
|
||||
const md = JSON.parse(t.extra_data || '{}').matched_data || {};
|
||||
return md.name || '';
|
||||
} catch (e) { return ''; }
|
||||
}
|
||||
|
||||
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 || [];
|
||||
const isMatched = _wingItPoolView === 'matched';
|
||||
let tracks = (isMatched ? _wingItPoolData.matched : _wingItPoolData.tracks) || [];
|
||||
if (query) {
|
||||
tracks = tracks.filter(t =>
|
||||
(t.track_name || '').toLowerCase().includes(query) ||
|
||||
|
|
@ -1379,23 +1436,44 @@ function renderWingItPoolList() {
|
|||
);
|
||||
}
|
||||
if (tracks.length === 0) {
|
||||
const emptyMsg = isMatched
|
||||
? 'No resolved Wing It tracks yet — ones you Fix here will land in this list.'
|
||||
: 'No Wing It guesses to review.';
|
||||
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>';
|
||||
? '<div class="pool-empty">No tracks match your filter.</div>'
|
||||
: `<div class="pool-empty">${emptyMsg}</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>
|
||||
container.innerHTML = tracks.map(t => {
|
||||
if (isMatched) {
|
||||
const matchedName = _wingItMatchedName(t);
|
||||
return `
|
||||
<div class="pool-track-row pool-matched">
|
||||
<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>
|
||||
${matchedName ? `<span class="pool-track-arrow">→</span><span class="pool-match-name">${_esc(matchedName)}</span>` : ''}
|
||||
<span class="pool-track-playlist-badge">${_esc(t.playlist_name)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="pool-rematch-btn" onclick="openPoolFixModal(${t.id}, '${_escJs(t.track_name)}', '${_escJs(t.artist_name)}')" title="Change this match">Re-match</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return `
|
||||
<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>
|
||||
<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('');
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function filterWingItPool(playlistId) {
|
||||
|
|
@ -1409,13 +1487,23 @@ async function filterWingItPool(playlistId) {
|
|||
showToast('Failed to filter Wing It pool', 'error');
|
||||
return;
|
||||
}
|
||||
_updateWingItHeaderCounts();
|
||||
if (_wingItPoolView === 'categories') 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);
|
||||
}
|
||||
}
|
||||
|
||||
function _updateWingItHeaderCounts() {
|
||||
if (!_wingItPoolData) return;
|
||||
const a = (_wingItPoolData.tracks || []).length;
|
||||
const m = (_wingItPoolData.matched || []).length;
|
||||
const aEl = document.getElementById('wing-it-header-attention');
|
||||
const mEl = document.getElementById('wing-it-header-matched');
|
||||
const aCat = document.getElementById('wing-it-cat-attention-count');
|
||||
const mCat = document.getElementById('wing-it-cat-matched-count');
|
||||
if (aEl) { aEl.textContent = `${a} to review`; aEl.classList.toggle('pool-header-failed-highlight', a > 0); }
|
||||
if (mEl) mEl.textContent = `${m} resolved`;
|
||||
if (aCat) aCat.textContent = a;
|
||||
if (mCat) mCat.textContent = m;
|
||||
}
|
||||
|
||||
// Re-fetch + re-render the open Wing It pool (used after a Fix Match resolves a track).
|
||||
|
|
|
|||
Loading…
Reference in a new issue