From ad657f02a88c49725268781d0f5a00ed8c20a4c4 Mon Sep 17 00:00:00 2001 From: ramonskie Date: Thu, 25 Jun 2026 13:43:24 +0200 Subject: [PATCH 01/49] Fix playlist sync status labels --- tests/test_playlist_sync_status.py | 65 ++++++++++++++++++++++++++++++ web_server.py | 59 ++++++++++++++++++++++++--- webui/static/sync-spotify.js | 2 +- 3 files changed, 119 insertions(+), 7 deletions(-) create mode 100644 tests/test_playlist_sync_status.py diff --git a/tests/test_playlist_sync_status.py b/tests/test_playlist_sync_status.py new file mode 100644 index 00000000..317b8a6f --- /dev/null +++ b/tests/test_playlist_sync_status.py @@ -0,0 +1,65 @@ +from datetime import datetime, timedelta + +from web_server import ( + _format_playlist_sync_status, + _resolve_spotify_playlist_sync_status, +) + + +class _StubDatabase: + def __init__(self, mirrored): + self._mirrored = mirrored + + def get_mirrored_playlist_by_source(self, source, source_playlist_id, profile_id): + assert source == 'spotify' + assert source_playlist_id == 'spotify-playlist-1' + assert profile_id == 7 + return self._mirrored + + +def _status(minutes_ago=0, **overrides): + timestamp = (datetime(2026, 6, 25, 12, 0, 0) - timedelta(minutes=minutes_ago)).isoformat() + return {'last_synced': timestamp, **overrides} + + +def test_resolve_spotify_playlist_sync_status_uses_mirrored_auto_sync_status(): + sync_statuses = { + 'auto_mirror_42': _status(matched_tracks=12), + } + + status = _resolve_spotify_playlist_sync_status( + 'spotify-playlist-1', + sync_statuses, + database=_StubDatabase({'id': 42}), + profile_id=7, + ) + + assert status['matched_tracks'] == 12 + + +def test_resolve_spotify_playlist_sync_status_prefers_newest_status(): + sync_statuses = { + 'spotify-playlist-1': _status(minutes_ago=20, matched_tracks=1), + 'auto_mirror_42': _status(minutes_ago=5, matched_tracks=2), + } + + status = _resolve_spotify_playlist_sync_status( + 'spotify-playlist-1', + sync_statuses, + database=_StubDatabase({'id': 42}), + profile_id=7, + ) + + assert status['matched_tracks'] == 2 + + +def test_format_playlist_sync_status_treats_missing_snapshot_as_synced(): + status = _status() + + assert _format_playlist_sync_status(status, 'current-snapshot') == 'Synced: Jun 25, 12:00' + + +def test_format_playlist_sync_status_marks_snapshot_mismatch_as_last_sync(): + status = _status(snapshot_id='old-snapshot') + + assert _format_playlist_sync_status(status, 'current-snapshot') == 'Last Sync: Jun 25, 12:00' diff --git a/web_server.py b/web_server.py index 4e330c92..63cab778 100644 --- a/web_server.py +++ b/web_server.py @@ -20765,6 +20765,56 @@ def _save_sync_status_file(sync_statuses): except Exception as e: logger.error(f"Error saving sync status: {e}") +def _sync_status_timestamp(status_info): + """Return comparable timestamp for a persisted sync-status record.""" + if not status_info or 'last_synced' not in status_info: + return None + try: + return datetime.fromisoformat(status_info['last_synced']) + except (TypeError, ValueError): + return None + +def _latest_sync_status(*status_infos): + """Pick the newest non-empty sync-status record.""" + candidates = [s for s in status_infos if s] + if not candidates: + return {} + return max(candidates, key=lambda s: _sync_status_timestamp(s) or datetime.min) + +def _mirrored_spotify_sync_status(playlist_id, sync_statuses, *, database=None, profile_id=None): + """Return auto-sync status for a Spotify playlist mirrored into SoulSync.""" + try: + db = database or get_database() + profile = profile_id if profile_id is not None else get_current_profile_id() + mirrored = db.get_mirrored_playlist_by_source('spotify', str(playlist_id), profile) + if not mirrored: + return {} + return sync_statuses.get(f"auto_mirror_{mirrored.get('id')}", {}) + except Exception as e: + logger.debug("Spotify mirrored sync-status lookup failed for %s: %s", playlist_id, e) + return {} + +def _resolve_spotify_playlist_sync_status(playlist_id, sync_statuses, *, database=None, profile_id=None): + """Resolve direct or mirrored sync status for a Spotify playlist card.""" + direct_status = sync_statuses.get(playlist_id, {}) + mirrored_status = _mirrored_spotify_sync_status( + playlist_id, + sync_statuses, + database=database, + profile_id=profile_id, + ) + return _latest_sync_status(direct_status, mirrored_status) + +def _format_playlist_sync_status(status_info, playlist_snapshot): + """Build user-facing sync-status text from persisted status + snapshot.""" + if 'last_synced' not in status_info: + return "Never Synced" + last_sync_time = datetime.fromisoformat(status_info['last_synced']).strftime('%b %d, %H:%M') + stored_snapshot = status_info.get('snapshot_id') + if stored_snapshot and playlist_snapshot and playlist_snapshot != stored_snapshot: + return f"Last Sync: {last_sync_time}" + return f"Synced: {last_sync_time}" + def _update_and_save_sync_status(playlist_id, playlist_name, playlist_owner, snapshot_id, **kwargs): """Updates the sync status for a given playlist and saves to file (same logic as GUI).""" try: @@ -20807,16 +20857,14 @@ def get_spotify_playlists(): # Add regular playlists first for p in playlists: - status_info = sync_statuses.get(p.id, {}) - sync_status = "Never Synced" + status_info = _resolve_spotify_playlist_sync_status(p.id, sync_statuses) # Handle snapshot_id safely - may not exist in core Playlist class playlist_snapshot = getattr(p, 'snapshot_id', '') + sync_status = _format_playlist_sync_status(status_info, playlist_snapshot) if 'last_synced' in status_info: stored_snapshot = status_info.get('snapshot_id') - last_sync_time = datetime.fromisoformat(status_info['last_synced']).strftime('%b %d, %H:%M') - if playlist_snapshot != stored_snapshot: - sync_status = f"Last Sync: {last_sync_time}" + if stored_snapshot and playlist_snapshot and playlist_snapshot != stored_snapshot: logger.info( "Playlist sync status: name=%s id=%s snapshot=%r stored_snapshot=%r result=Needs Sync display=%s", p.name, @@ -20826,7 +20874,6 @@ def get_spotify_playlists(): sync_status, ) else: - sync_status = f"Synced: {last_sync_time}" logger.info( "Playlist sync status: name=%s id=%s snapshot=%r stored_snapshot=%r result=Synced display=%s", p.name, diff --git a/webui/static/sync-spotify.js b/webui/static/sync-spotify.js index 6af72167..cc888f1f 100644 --- a/webui/static/sync-spotify.js +++ b/webui/static/sync-spotify.js @@ -1639,7 +1639,7 @@ function renderSpotifyPlaylists() { container.innerHTML = spotifyPlaylists.map(p => { let statusClass = 'status-never-synced'; if (p.sync_status.startsWith('Synced')) statusClass = 'status-synced'; - if (p.sync_status === 'Needs Sync') statusClass = 'status-needs-sync'; + if (p.sync_status === 'Needs Sync' || p.sync_status.startsWith('Last Sync')) statusClass = 'status-needs-sync'; // This HTML structure creates the interactive playlist cards return ` From 7a8b66fd2ee9e2f5e767d85f6996570f82dd3e9d Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Thu, 25 Jun 2026 13:06:07 -0700 Subject: [PATCH 02/49] Auto-Sync Manager: redesign hourly + weekly boards as horizontal lanes Replace the side-scrolling column board with vertically-stacked interval lanes (hourly) and day lanes Mon-Sun (weekly). Empty intervals/days collapse to thin dashed strips, busy ones grow; scheduled playlists flow as cards within a lane. Kills the horizontal scroll + the wasted whitespace of the old kanban columns, and the two boards now share one cohesive design. Polish: accent gradient wash + gradient interval numerals + count badge on filled lanes, drag-over glow/lift, card pop-in animation, hover states. Also preserves the board's scroll position across the full re-render so dropping/removing a playlist no longer snaps it back to the top. Same drag-and-drop handlers + scheduled-card content reused; old column CSS is now unused (harmless). --- webui/static/auto-sync.js | 61 ++++++++++---- webui/static/style.css | 165 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+), 15 deletions(-) diff --git a/webui/static/auto-sync.js b/webui/static/auto-sync.js index 8a91d3ad..74f08428 100644 --- a/webui/static/auto-sync.js +++ b/webui/static/auto-sync.js @@ -73,6 +73,17 @@ function autoSyncIntervalLabel(hours) { return `Every ${hours} hour${hours === 1 ? '' : 's'}`; } +// Short cadence word for the lane badge (under the interval number). +function autoSyncLaneCadence(hours) { + if (hours === 1) return 'Hourly'; + if (hours === 12) return 'Twice a day'; + if (hours === 24) return 'Daily'; + if (hours === 168) return 'Weekly'; + if (hours < 24) return `Every ${hours}h`; + const days = hours / 24; + return `Every ${days} days`; +} + // Browser-detected default tz for new schedules. Used when the user // creates a weekly schedule and hasn't picked an explicit tz — falls // back to UTC on browsers where Intl is unavailable (very old ones). @@ -354,6 +365,12 @@ function renderAutoSyncScheduleModal() { const overlay = document.getElementById('auto-sync-schedule-modal'); if (!overlay) return; + // Preserve the visible lane board's scroll across the full re-render so dropping / + // removing a playlist doesn't snap it back to the top (the user used to have to scroll + // back). Targets the ACTIVE tab so it works for both the hourly + weekly boards. + const _prevLanes = overlay.querySelector('.auto-sync-tab-panel.active .auto-sync-lanes'); + const _prevScroll = _prevLanes ? _prevLanes.scrollTop : null; + const { playlists, playlistSchedules, weeklySchedules, automationPipelines, runHistory, runHistoryTotal } = _autoSyncScheduleState; const scheduledCount = Object.keys(playlistSchedules).length + Object.keys(weeklySchedules || {}).length; const enabledCount = Object.values(playlistSchedules).filter(s => s.enabled).length @@ -408,6 +425,11 @@ function renderAutoSyncScheduleModal() { `; populateAutoSyncHistoryList(overlay); bindAutoSyncHistoryCardInteractions(overlay); + + if (_prevScroll != null) { + const nl = overlay.querySelector('.auto-sync-tab-panel.active .auto-sync-lanes'); + if (nl) nl.scrollTop = _prevScroll; + } } function setAutoSyncTab(tab) { @@ -479,17 +501,23 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) { .map(s => parseInt(s?.hours, 10)) .filter(h => Number.isFinite(h) && h > 0 && !AUTO_SYNC_BUCKETS.includes(h)); const allBuckets = [...new Set([...AUTO_SYNC_BUCKETS, ...customHours])].sort((a, b) => a - b); + // Concept 1 — interval LANES (horizontal rows) instead of columns: no side-scroll, + // empty intervals collapse to thin strips, busy ones grow. Same drag handlers + card. const bucketHtml = allBuckets.map(hours => { const assigned = schedulablePlaylists.filter(p => playlistSchedules[p.id]?.hours === hours); const isCustom = !AUTO_SYNC_BUCKETS.includes(hours); + const filled = assigned.length > 0; return ` -
-
- ${autoSyncBucketLabel(hours)}${isCustom ? ' custom' : ''} - ${assigned.length} playlist${assigned.length === 1 ? '' : 's'} +
+
+ ${autoSyncBucketLabel(hours)} + ${_esc(autoSyncLaneCadence(hours))}${isCustom ? ' · custom' : ''} + ${filled ? `${assigned.length}` : ''}
-
- ${assigned.length ? assigned.map(p => autoSyncScheduledCardHtml(p, playlistSchedules[p.id])).join('') : '
Drop hereSchedule playlists at this interval
'} +
+ ${filled + ? assigned.map(p => autoSyncScheduledCardHtml(p, playlistSchedules[p.id])).join('') + : `
+ Drag a playlist here to sync ${_esc(autoSyncIntervalLabel(hours).toLowerCase())}
`}
`; @@ -514,7 +542,7 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) {
${sidebarHtml}${unavailableHtml}
-
${bucketHtml}
+
${bucketHtml}
`; } @@ -598,21 +626,24 @@ function renderAutoSyncWeeklyPanel(playlists, playlistSchedules) { }); }); + // Day LANES (horizontal rows, Mon–Sun) — mirrors the hourly board's lane layout. const dayColumnsHtml = AUTO_SYNC_WEEKDAYS.map(day => { const cards = cardsByDay[day]; - const cardHtml = cards.length + const filled = cards.length > 0; + const cardHtml = filled ? cards.map(({ playlist, schedule }) => autoSyncWeeklyCardHtml(playlist, schedule)).join('') - : '
Drop hereSchedule playlists on this day
'; + : `
+ Drag a playlist here to sync every ${_esc(AUTO_SYNC_WEEKDAY_LABELS[day])}
`; return ` -
-
- ${AUTO_SYNC_WEEKDAY_LABELS[day]} - ${cards.length} playlist${cards.length === 1 ? '' : 's'} +
+ ${_esc(AUTO_SYNC_WEEKDAY_LABELS[day])} + Weekly + ${filled ? `${cards.length}` : ''}
-
${cardHtml}
+
${cardHtml}
`; }).join(''); @@ -637,7 +668,7 @@ function renderAutoSyncWeeklyPanel(playlists, playlistSchedules) {
${sidebarHtml}${unavailableHtml}
-
${dayColumnsHtml}
+
${dayColumnsHtml}
${editorHtml} `; diff --git a/webui/static/style.css b/webui/static/style.css index 4939df93..6b0f8677 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -12465,6 +12465,171 @@ body.helper-mode-active #dashboard-activity-feed:hover { color: rgba(255, 255, 255, 0.45); } +/* ── Hourly board: interval LANES (horizontal rows — Concept 1) ───────────── */ +.auto-sync-lanes { + min-width: 0; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding: 16px 20px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.auto-sync-lane { + display: flex; + align-items: stretch; + gap: 12px; + padding: 11px 14px; + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 16px; + background: rgba(255, 255, 255, 0.025); + transition: border-color 0.22s ease, background 0.22s ease, + box-shadow 0.22s ease, transform 0.18s ease; +} + +.auto-sync-lane.empty { + background: transparent; + border-style: dashed; + border-color: rgba(255, 255, 255, 0.08); +} + +.auto-sync-lane.filled { + border-color: rgba(var(--accent-rgb), 0.22); + background: + linear-gradient(100deg, rgba(var(--accent-rgb), 0.07), rgba(var(--accent-rgb), 0.012) 55%), + rgba(255, 255, 255, 0.02); +} + +.auto-sync-lane:hover { + border-color: rgba(var(--accent-rgb), 0.3); +} + +.auto-sync-lane.drag-over { + border-color: rgba(var(--accent-rgb), 0.65); + border-style: solid; + background: rgba(var(--accent-rgb), 0.1); + box-shadow: + inset 0 0 0 1px rgba(var(--accent-rgb), 0.35), + 0 10px 30px rgba(var(--accent-rgb), 0.16); + transform: translateY(-1px); +} + +.auto-sync-lane-badge { + position: relative; + flex: 0 0 86px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; + padding: 4px 14px 4px 4px; + border-right: 1px solid rgba(255, 255, 255, 0.07); +} + +.auto-sync-lane-badge b { + font-size: 21px; + font-weight: 800; + line-height: 1; + letter-spacing: -0.02em; + color: rgba(255, 255, 255, 0.92); +} + +.auto-sync-lane.filled .auto-sync-lane-badge b { + background: linear-gradient(160deg, rgb(var(--accent-rgb)), rgba(var(--accent-rgb), 0.6)); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} + +.auto-sync-lane-badge span { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: rgba(255, 255, 255, 0.4); + white-space: nowrap; +} + +.auto-sync-lane-count { + position: absolute; + top: 0; + right: 10px; + min-width: 17px; + height: 17px; + padding: 0 4px; + display: grid; + place-items: center; + font-size: 10px; + font-weight: 800; + font-style: normal; + border-radius: 999px; + color: #fff; + background: rgba(var(--accent-rgb), 0.9); + box-shadow: 0 2px 8px rgba(var(--accent-rgb), 0.35); +} + +.auto-sync-lane-track { + flex: 1; + min-width: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 2px 0; +} + +.auto-sync-lane-hint { + display: flex; + align-items: center; + gap: 9px; + padding: 7px 2px; + color: rgba(255, 255, 255, 0.32); + font-size: 12.5px; + font-weight: 500; +} + +.auto-sync-lane-hint-ic { + display: grid; + place-items: center; + width: 22px; + height: 22px; + border-radius: 7px; + border: 1px dashed rgba(255, 255, 255, 0.2); + font-size: 15px; + line-height: 1; +} + +.auto-sync-lane.drag-over .auto-sync-lane-hint { color: rgb(var(--accent-rgb)); } +.auto-sync-lane.drag-over .auto-sync-lane-hint-ic { + border-style: solid; + border-color: rgba(var(--accent-rgb), 0.6); + color: rgb(var(--accent-rgb)); +} + +/* Scheduled cards flow horizontally inside a lane (override the column full-width) */ +.auto-sync-lane .auto-sync-scheduled-card { + width: auto; + min-width: 212px; + max-width: 264px; + margin-bottom: 0; + padding: 10px 12px; + border-radius: 12px; + background: rgba(0, 0, 0, 0.28); + border: 1px solid rgba(255, 255, 255, 0.07); + animation: autoSyncPop 0.24s ease both; +} + +.auto-sync-lane .auto-sync-scheduled-card:hover { + border-color: rgba(var(--accent-rgb), 0.4); + background: rgba(0, 0, 0, 0.36); +} + +@keyframes autoSyncPop { + from { opacity: 0; transform: translateY(5px) scale(0.97); } + to { opacity: 1; transform: none; } +} + .auto-sync-board { min-width: 0; min-height: 0; From 528aedbdfea947c8c991ec1c7b36752071e6773c Mon Sep 17 00:00:00 2001 From: ragnarlotus Date: Thu, 25 Jun 2026 22:54:43 +0200 Subject: [PATCH 03/49] fix(reorganize): include MusicBrainz release IDs --- core/library_reorganize.py | 1 + tests/test_reorganize_canonical_source.py | 24 +++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/core/library_reorganize.py b/core/library_reorganize.py index 574d5ee7..a7e4fa37 100644 --- a/core/library_reorganize.py +++ b/core/library_reorganize.py @@ -100,6 +100,7 @@ _ALBUM_ID_COLUMNS = { 'deezer': 'deezer_id', 'discogs': 'discogs_id', 'hydrabase': 'soul_id', + 'musicbrainz': 'musicbrainz_release_id', } # Human-facing label for each source. diff --git a/tests/test_reorganize_canonical_source.py b/tests/test_reorganize_canonical_source.py index 711638ed..bb39c436 100644 --- a/tests/test_reorganize_canonical_source.py +++ b/tests/test_reorganize_canonical_source.py @@ -73,3 +73,27 @@ def test_no_canonical_unchanged(monkeypatch): album_data = {"spotify_album_id": "sp1"} source, _, _ = lr._resolve_source(album_data, "spotify") assert source == "spotify" + + +def test_musicbrainz_release_id_is_used_by_priority_walk(monkeypatch): + _patch_fetch(monkeypatch, { + ('musicbrainz', 'mb-release-1'): [{'name': 'x'}], + }) + monkeypatch.setattr( + lr, + 'get_source_priority', + lambda primary: ['musicbrainz', 'spotify'], + ) + + album_data = { + 'musicbrainz_release_id': 'mb-release-1', + } + + source, api_album, items = lr._resolve_source( + album_data, + 'musicbrainz', + ) + + assert source == 'musicbrainz' + assert api_album == {'name': 'musicbrainz:mb-release-1'} + assert items == [{'name': 'x'}] From e3915b63e67fb6e1c3945dd09ebe1d2ce569f85e Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Thu, 25 Jun 2026 13:57:34 -0700 Subject: [PATCH 04/49] Library cards: in-card badges no longer trigger artist-detail navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card is an link and the shell's capture-phase link handler navigated to artist-detail before the grid's bubble-phase badge handler could preventDefault — so clicking the watchlist eye or a source badge opened the detail page (and the badge's own link too). The shell handler now bails when the click lands on an in-card control (.source-card-icon or [data-no-card-nav]), letting the badge do only its own thing. --- webui/static/shell-bridge.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/webui/static/shell-bridge.js b/webui/static/shell-bridge.js index 1f93f2c3..6263b6c8 100644 --- a/webui/static/shell-bridge.js +++ b/webui/static/shell-bridge.js @@ -211,6 +211,11 @@ function _handleShellLinkClick(event) { if (!anchor || (anchor.target && anchor.target !== '_self')) return; if (anchor.hasAttribute('download')) return; + // In-card controls (source/watchlist badges, etc.) handle their OWN click — don't let + // this capture-phase handler hijack it into the surrounding card's navigation. Their + // bubble-phase handlers preventDefault, but that runs after capture, so we opt out here. + if (event.target?.closest?.('.source-card-icon, [data-no-card-nav]')) return; + const href = anchor.getAttribute('href'); if (!href || href === '#' || href.startsWith('javascript:')) return; From 602b035bad3422fa9987c62eac44243f54d5df5c Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Thu, 25 Jun 2026 13:57:50 -0700 Subject: [PATCH 05/49] Wing It Pool: review + re-match tracks Wing It auto-matched MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- database/music_database.py | 55 +++++++++++ tests/test_wing_it_pool.py | 76 +++++++++++++++ web_server.py | 29 ++++++ webui/index.html | 1 + webui/static/stats-automations.js | 149 +++++++++++++++++++++++++++++- 5 files changed, 308 insertions(+), 2 deletions(-) create mode 100644 tests/test_wing_it_pool.py 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'); } From 9847d6f0a9bd94f825289b64b89dceb851139a3b Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Thu, 25 Jun 2026 14:09:36 -0700 Subject: [PATCH 06/49] 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 = `