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:
parent
54b02b86c1
commit
6101832ee1
3 changed files with 247 additions and 16 deletions
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -57605,6 +57605,7 @@ async function openDiscoveryPoolModal(playlistId = null) {
|
|||
<div class="pool-list-header">
|
||||
<button class="pool-back-btn" onclick="showPoolCategories()">← 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}×</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">×</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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue