Add search filter and rematch to discovery pool modal

Discovery pool lists (matched and failed) now have a search input
that filters tracks client-side by name, artist, or playlist.

Matched tracks get a "Rematch" button that opens the fix modal in
cache-only mode — deletes the old cache entry and saves the new
match directly to the discovery cache via /api/discovery-pool/rematch.
This works regardless of whether a mirrored playlist track exists.

Failed tracks retain the existing "Fix Match" flow unchanged.
This commit is contained in:
Broque Thomas 2026-03-24 13:07:04 -07:00
parent 54b02b86c1
commit 6101832ee1
3 changed files with 247 additions and 16 deletions

View file

@ -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/<int:playlist_id>/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."""

View file

@ -57605,6 +57605,7 @@ async function openDiscoveryPoolModal(playlistId = null) {
<div class="pool-list-header">
<button class="pool-back-btn" onclick="showPoolCategories()">&larr; Back</button>
<span class="pool-list-title" id="pool-list-title"></span>
<input type="text" class="pool-list-search" id="pool-list-search" placeholder="Filter tracks..." oninput="renderPoolList()">
</div>
<div class="pool-list-content" id="pool-list-content"></div>
</div>
@ -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 = '<div class="pool-empty">No failed discoveries. All tracks matched successfully.</div>';
container.innerHTML = query
? '<div class="pool-empty">No failed tracks match your filter.</div>'
: '<div class="pool-empty">No failed discoveries. All tracks matched successfully.</div>';
return;
}
container.innerHTML = tracks.map(t => `
@ -57754,9 +57772,20 @@ function renderPoolList() {
</div>
`).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 = '<div class="pool-empty">No cached discovery matches yet.</div>';
container.innerHTML = query
? '<div class="pool-empty">No matched tracks match your filter.</div>'
: '<div class="pool-empty">No cached discovery matches yet.</div>';
return;
}
container.innerHTML = entries.map(e => {
@ -57781,6 +57810,7 @@ function renderPoolList() {
</div>
<span class="pool-confidence-badge ${confClass}">${conf}%</span>
<span class="pool-use-count">${e.use_count}&times;</span>
<button class="pool-rematch-btn" onclick="rematchPoolCacheEntry(${e.id}, '${_escAttr(e.original_title)}', '${_escAttr(e.original_artist)}')" title="Rematch this track">Rematch</button>
<button class="pool-remove-btn" onclick="removePoolCacheEntry(${e.id})" title="Remove cached match">&times;</button>
</div>
`;
@ -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 = `
<div class="pool-fix-modal" onmousedown="event.stopPropagation()">
<div class="pool-fix-header">
<h2>Rematch Track</h2>
<button class="pool-fix-close" onclick="closePoolFixModal()" title="Close"></button>
</div>
<div class="pool-fix-body">
<div class="pool-fix-source">
<div class="pool-fix-source-label">Current Match</div>
<div class="pool-fix-source-row">
<span class="pool-fix-source-title">${_esc(trackName)}</span>
<span class="pool-fix-source-sep"></span>
<span class="pool-fix-source-artist">${_esc(artistName)}</span>
</div>
</div>
<div class="pool-fix-search">
<div class="pool-fix-input-row">
<div class="pool-fix-input-wrap">
<label for="pool-fix-track-input">Track</label>
<input type="text" id="pool-fix-track-input" placeholder="Track name" value="${_escAttr(trackName)}">
</div>
<div class="pool-fix-input-wrap">
<label for="pool-fix-artist-input">Artist</label>
<input type="text" id="pool-fix-artist-input" placeholder="Artist name" value="${_escAttr(artistName)}">
</div>
<button class="pool-fix-search-btn" onclick="searchPoolFix()">Search</button>
</div>
</div>
<div class="pool-fix-results-area">
<div id="pool-fix-results" class="pool-fix-results-list">
<div class="pool-fix-empty">Searching...</div>
</div>
</div>
</div>
<div class="pool-fix-footer">
<button class="pool-fix-cancel" onclick="closePoolFixModal()">Cancel</button>
</div>
</div>
`;
// 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();

View file

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