diff --git a/web_server.py b/web_server.py index c01effba..c47892c0 100644 --- a/web_server.py +++ b/web_server.py @@ -40177,6 +40177,66 @@ def delete_discovery_pool_cache_entry(entry_id): logger.error(f"Error deleting discovery cache entry: {e}") return jsonify({"error": str(e)}), 500 +@app.route('/api/discovery-pool/rematch', methods=['POST']) +def rematch_discovery_pool_track(): + """Replace a discovery cache entry with a new match chosen by the user.""" + try: + data = request.get_json() + cache_id = data.get('cache_id') + original_title = (data.get('original_title') or '').strip() + original_artist = (data.get('original_artist') or '').strip() + spotify_track = data.get('spotify_track') + + if not cache_id: + return jsonify({"error": "cache_id required"}), 400 + + database = get_database() + + # If no spotify_track provided, just delete the cache entry (phase 1 of rematch) + if not spotify_track: + database.delete_discovery_cache_entry(cache_id) + return jsonify({"success": True, "action": "cache_cleared"}) + + # spotify_track provided — delete old cache and save new match (phase 2) + database.delete_discovery_cache_entry(cache_id) + + # Build cache entry in same format as discovery flow + artists = spotify_track.get('artists', []) + album_raw = spotify_track.get('album', '') + album_obj = album_raw if isinstance(album_raw, dict) else {'name': album_raw or ''} + image_url = spotify_track.get('image_url', '') + if not image_url and isinstance(album_raw, dict): + images = album_raw.get('images', []) + image_url = images[0].get('url', '') if images else '' + + matched_data = { + 'id': spotify_track.get('id', ''), + 'name': spotify_track.get('name', ''), + 'artists': [{'name': a} if isinstance(a, str) else a for a in artists], + 'album': album_obj, + 'duration_ms': spotify_track.get('duration_ms', 0), + 'image_url': image_url, + 'source': 'spotify', + } + + # Save to discovery cache + normalized_title = matching_engine.normalize_string(original_title) if original_title else '' + normalized_artist = matching_engine.normalize_string(original_artist) if original_artist else '' + database.save_discovery_cache_match( + normalized_title=normalized_title, + normalized_artist=normalized_artist, + provider='spotify', + confidence=1.0, + matched_data=matched_data, + original_title=original_title, + original_artist=original_artist, + ) + + return jsonify({"success": True, "action": "rematched", "name": spotify_track.get('name', '')}) + except Exception as e: + logger.error(f"Error in discovery pool rematch: {e}") + return jsonify({"error": str(e)}), 500 + @app.route('/api/mirrored-playlists//prepare-discovery', methods=['POST']) def prepare_mirrored_discovery(playlist_id): """Register a mirrored playlist into youtube_playlist_states so the YouTube discovery pipeline can run.""" diff --git a/webui/static/script.js b/webui/static/script.js index 981ee05c..2326a14c 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -57605,6 +57605,7 @@ async function openDiscoveryPoolModal(playlistId = null) {
+
@@ -57692,6 +57693,10 @@ function showPoolList(category) { const titleEl = document.getElementById('pool-list-title'); if (titleEl) titleEl.textContent = category === 'failed' ? 'Failed Tracks' : 'Matched Tracks'; + // Clear search filter when switching views + const searchEl = document.getElementById('pool-list-search'); + if (searchEl) searchEl.value = ''; + renderPoolList(); } @@ -57735,10 +57740,23 @@ function renderPoolList() { const container = document.getElementById('pool-list-content'); if (!container || !_discoveryPoolData) return; + // Client-side search filter + const searchEl = document.getElementById('pool-list-search'); + const query = (searchEl ? searchEl.value : '').toLowerCase().trim(); + if (_discoveryPoolView === 'failed') { - const tracks = _discoveryPoolData.failed || []; + let tracks = _discoveryPoolData.failed || []; + 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 = '
No failed discoveries. All tracks matched successfully.
'; + container.innerHTML = query + ? '
No failed tracks match your filter.
' + : '
No failed discoveries. All tracks matched successfully.
'; return; } container.innerHTML = tracks.map(t => ` @@ -57754,9 +57772,20 @@ function renderPoolList() { `).join(''); } else { - const entries = _discoveryPoolData.matched || []; + let entries = _discoveryPoolData.matched || []; + if (query) { + entries = entries.filter(e => { + const md = e.matched_data || {}; + const matchedName = md.name || ''; + return (e.original_title || '').toLowerCase().includes(query) || + (e.original_artist || '').toLowerCase().includes(query) || + matchedName.toLowerCase().includes(query); + }); + } if (entries.length === 0) { - container.innerHTML = '
No cached discovery matches yet.
'; + container.innerHTML = query + ? '
No matched tracks match your filter.
' + : '
No cached discovery matches yet.
'; return; } container.innerHTML = entries.map(e => { @@ -57781,6 +57810,7 @@ function renderPoolList() { ${conf}% ${e.use_count}× + `; @@ -57788,6 +57818,84 @@ function renderPoolList() { } } +function rematchPoolCacheEntry(cacheId, originalTitle, originalArtist) { + // Open the fix modal in "rematch" mode — saves to cache instead of mirrored tracks + openPoolRematchModal(cacheId, originalTitle, originalArtist); +} + +function openPoolRematchModal(cacheId, trackName, artistName) { + // Reuses the fix modal UI but saves via the rematch endpoint + let fixOverlay = document.getElementById('pool-fix-overlay'); + if (fixOverlay) fixOverlay.remove(); + + fixOverlay = document.createElement('div'); + fixOverlay.className = 'pool-fix-overlay'; + fixOverlay.id = 'pool-fix-overlay'; + fixOverlay.addEventListener('mousedown', (e) => { + if (e.target === fixOverlay) { + e.preventDefault(); + closePoolFixModal(); + } + }); + + fixOverlay.innerHTML = ` +
+
+

Rematch Track

+ +
+
+
+
Current Match
+
+ ${_esc(trackName)} + + ${_esc(artistName)} +
+
+ +
+
+
Searching...
+
+
+
+ +
+ `; + + // Store rematch context + fixOverlay.dataset.mode = 'rematch'; + fixOverlay.dataset.cacheId = cacheId; + fixOverlay.dataset.originalTitle = trackName; + fixOverlay.dataset.originalArtist = artistName; + document.body.appendChild(fixOverlay); + + const trackInput = fixOverlay.querySelector('#pool-fix-track-input'); + const artistInput = fixOverlay.querySelector('#pool-fix-artist-input'); + const enterHandler = (e) => { if (e.key === 'Enter') searchPoolFix(); }; + trackInput.addEventListener('keypress', enterHandler); + artistInput.addEventListener('keypress', enterHandler); + trackInput.focus(); + trackInput.select(); + + setTimeout(() => searchPoolFix(), 500); +} + async function removePoolCacheEntry(entryId) { if (!await showConfirmDialog({ title: 'Remove Cache Entry', message: 'Remove this cached match? The track will be re-discovered fresh next time.' })) return; try { @@ -57938,22 +58046,42 @@ async function searchPoolFix() { async function selectPoolFixTrack(track) { const fixOverlay = document.getElementById('pool-fix-overlay'); if (!fixOverlay) return; - const trackId = parseInt(fixOverlay.dataset.trackId); - // Confirm selection to prevent accidental clicks from layout shift + // Confirm selection const artists = (track.artists || []).join(', '); if (!await showConfirmDialog({ title: 'Confirm Match', message: `Match to "${track.name}" by ${artists}?`, confirmText: 'Confirm' })) return; + const isRematch = fixOverlay.dataset.mode === 'rematch'; + try { - const res = await fetch('/api/discovery-pool/fix', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - track_id: trackId, - spotify_track: track, - }), - }); - const data = await res.json(); + let res, data; + if (isRematch) { + // Rematch mode: save new match to discovery cache + res = await fetch('/api/discovery-pool/rematch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + cache_id: parseInt(fixOverlay.dataset.cacheId), + original_title: fixOverlay.dataset.originalTitle, + original_artist: fixOverlay.dataset.originalArtist, + spotify_track: track, + }), + }); + data = await res.json(); + } else { + // Normal fix mode: save to mirrored track + const trackId = parseInt(fixOverlay.dataset.trackId); + res = await fetch('/api/discovery-pool/fix', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + track_id: trackId, + spotify_track: track, + }), + }); + data = await res.json(); + } + if (data.success) { showToast(`Matched: ${track.name}`, 'success'); closePoolFixModal(); diff --git a/webui/static/style.css b/webui/static/style.css index 30b58b10..fd15594b 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -8449,7 +8449,7 @@ body { .pool-list-header { display: flex; align-items: center; - gap: 16px; + gap: 12px; padding: 16px 0; border-bottom: 1px solid rgba(255, 255, 255, 0.08); margin-bottom: 8px; @@ -8458,6 +8458,49 @@ body { z-index: 3; } +.pool-list-search { + margin-left: auto; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + padding: 7px 12px; + color: #fff; + font-size: 13px; + font-family: inherit; + width: 200px; + transition: border-color 0.15s ease, width 0.2s ease; +} + +.pool-list-search:focus { + outline: none; + border-color: rgba(var(--accent-rgb), 0.4); + width: 260px; +} + +.pool-list-search::placeholder { + color: rgba(255, 255, 255, 0.3); +} + +.pool-rematch-btn { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + padding: 5px 10px; + color: rgba(255, 255, 255, 0.6); + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; + flex-shrink: 0; +} + +.pool-rematch-btn:hover { + background: rgba(var(--accent-rgb), 0.15); + border-color: rgba(var(--accent-rgb), 0.3); + color: rgba(255, 255, 255, 0.9); +} + .pool-back-btn { background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.12);