Fix streaming source matching and global search download bubbles

Streaming matching: add artist gate rejecting candidates with artist
similarity below 0.4, raise threshold to 0.60, block fallback to
Soulseek filename matcher for Tidal/Qobuz/HiFi/Deezer. Fix single-
char artist containment bug where normalize_string strips non-ASCII
(e.g. "B小町" → "b") causing "b" to match any artist containing
that letter. Fixed in both score_track_match and the Soulseek scorer.
YouTube and Soulseek matching behavior unchanged.

Global search: add registerSearchDownload() calls to _gsClickAlbum
and _gsClickTrack so downloads create bubble snapshots on dashboard
and search page, matching the enhanced search standard.

Global search escaping: add _escAttr() helper to handle newlines in
album/artist names that broke inline onclick string literals.
pull/289/head
Broque Thomas 1 month ago
parent 410bddd102
commit 06defcfa3d

@ -269,7 +269,11 @@ class MusicMatchingEngine:
cand_artist_normalized = self.normalize_string(raw_cand_artist)
cand_artist_cleaned = self.clean_artist(raw_cand_artist)
# Check containment (e.g., "drake" in "drake 21 savage")
if src_artist and src_artist in cand_artist_normalized:
# Skip for very short names (≤2 chars) — "b" matches everything
if src_artist and len(src_artist) > 2 and src_artist in cand_artist_normalized:
best_artist_score = 1.0
break
elif src_artist and src_artist == cand_artist_normalized:
best_artist_score = 1.0
break
score = self.similarity_score(src_artist, cand_artist_cleaned)
@ -604,9 +608,13 @@ class MusicMatchingEngine:
best_artist_similarity = 0.0
for artist in spotify_artists_norm:
if artist in slskd_filename_norm:
# Skip containment for very short names (≤2 chars) — "b" matches everything
if artist and len(artist) > 2 and artist in slskd_filename_norm:
artist_score = 1.0 # Perfect match if any artist is found
break
elif artist and len(artist) <= 2 and re.search(r'\b' + re.escape(artist) + r'\b', slskd_filename_norm):
artist_score = 1.0
break
else:
# Try similarity matching as fallback for misspellings/variations
artist_ratio = SequenceMatcher(None, artist, slskd_filename_norm).ratio()

@ -25257,7 +25257,6 @@ def get_valid_candidates(results, spotify_track, query):
_streaming_sources = ("youtube", "tidal", "qobuz", "hifi", "deezer_dl")
if results[0].username in _streaming_sources:
source_label = results[0].username.replace('_dl', '').title()
expected_artists = spotify_track.artists if spotify_track else []
expected_title = spotify_track.name if spotify_track else ''
expected_duration = spotify_track.duration_ms if spotify_track else 0
@ -25298,9 +25297,37 @@ def get_valid_candidates(results, spotify_track, query):
is_wrong_version = True
break
# Artist gate — streaming APIs (Tidal/Qobuz/HiFi/Deezer) have reliable metadata,
# so "My Will" by "B. Starr" should never match expected "B小町".
# Skip for YouTube — artist is parsed from video titles and often unreliable.
if r.username != 'youtube':
from difflib import SequenceMatcher
import re as _re
_cand_artist_raw = r.artist or ''
_cand_artist = matching_engine.normalize_string(_cand_artist_raw)
_best_artist = 0.0
for _ea in expected_artists:
_ea_norm = matching_engine.normalize_string(_ea)
if not _ea_norm:
continue
# For short normalized names (e.g. "B小町"→"b"), containment is useless.
# Compare original Unicode strings directly via similarity instead.
if len(_ea_norm) <= 2:
_best_artist = max(_best_artist, SequenceMatcher(None, _ea.lower(), _cand_artist_raw.lower()).ratio())
elif _re.search(r'\b' + _re.escape(_ea_norm) + r'\b', _cand_artist):
_best_artist = 1.0
break
elif _ea_norm == _cand_artist:
_best_artist = 1.0
break
else:
_best_artist = max(_best_artist, SequenceMatcher(None, _ea_norm, _cand_artist).ratio())
if _best_artist < 0.4 and confidence < 0.85:
continue
r.confidence = confidence
r.version_type = 'wrong_version' if is_wrong_version else match_type
if confidence >= 0.55:
if confidence >= 0.60:
scored.append(r)
if scored:
@ -25311,8 +25338,12 @@ def get_valid_candidates(results, spotify_track, query):
f"(best: {best.confidence:.2f} '{best.artist} - {best.title}')")
return scored
else:
print(f"⚠️ [{source_label}] No streaming results passed validation (threshold: 0.55) — falling through to matching engine")
# Fall through to standard matching engine below
if results[0].username == 'youtube':
print(f"⚠️ [{source_label}] No streaming results passed validation — falling through to filename matching")
# YouTube artist data is unreliable, allow fallback to filename-based matching
else:
print(f"⚠️ [{source_label}] No streaming results passed validation (threshold: 0.60, artist gate: 0.40) — rejecting all candidates")
return [] # Tidal/Qobuz/HiFi/Deezer have structured metadata; don't fall back to filename matching
# Uses the existing, powerful matching engine for scoring (Soulseek P2P results)
_max_q = config_manager.get('soulseek.max_peer_queue', 0) or 0

@ -16914,6 +16914,7 @@ function showToast(message, type = 'success', helpSection = null) {
}
function _escToast(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function _escAttr(s) { return _escToast(s).replace(/'/g, "\\'").replace(/\n/g, ' ').replace(/\r/g, ''); }
function _updateNotifBadge() {
const badge = document.getElementById('notif-bell-badge');
@ -17279,13 +17280,13 @@ function _gsRender(data) {
if (dbArtists.length) {
h += '<div class="gsearch-section-header">📚 In Your Library</div><div class="gsearch-grid">';
h += dbArtists.map(a => `<div class="gsearch-item" onclick="_gsClickArtist('${a.id}', '${_escToast(a.name).replace(/'/g, "\\'")}', true)"><div class="gsearch-item-art">${a.image_url ? `<img src="${a.image_url}" loading="lazy">` : '🎤'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div><div class="gsearch-item-sub">Library</div></div></div>`).join('');
h += dbArtists.map(a => `<div class="gsearch-item" onclick="_gsClickArtist('${a.id}', '${_escAttr(a.name)}', true)"><div class="gsearch-item-art">${a.image_url ? `<img src="${a.image_url}" loading="lazy">` : '🎤'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div><div class="gsearch-item-sub">Library</div></div></div>`).join('');
h += '</div>';
}
if (artists.length) {
h += `<div class="gsearch-section-header">🎤 Artists <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-grid" id="gsearch-artists-grid">`;
h += artists.map(a => `<div class="gsearch-item" onclick="_gsClickArtist('${a.id}', '${_escToast(a.name).replace(/'/g, "\\'")}', false)" ${!a.image_url ? `data-artist-id="${a.id}" data-needs-image="true"` : ''}><div class="gsearch-item-art">${a.image_url ? `<img src="${a.image_url}" loading="lazy">` : '🎤'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div></div></div>`).join('');
h += artists.map(a => `<div class="gsearch-item" onclick="_gsClickArtist('${a.id}', '${_escAttr(a.name)}', false)" ${!a.image_url ? `data-artist-id="${a.id}" data-needs-image="true"` : ''}><div class="gsearch-item-art">${a.image_url ? `<img src="${a.image_url}" loading="lazy">` : '🎤'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div></div></div>`).join('');
h += '</div>';
} else if (loading.has('artists')) {
h += `<div class="gsearch-section-header">🎤 Artists <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-section-loading"><div class="server-search-spinner" style="width:14px;height:14px"></div> Loading artists...</div>`;
@ -17299,7 +17300,7 @@ function _gsRender(data) {
const ar = a.artist || (a.artists ? a.artists.join(', ') : '');
const yr = a.release_date ? a.release_date.substring(0, 4) : '';
const img = (a.image_url || '').replace(/'/g, "\\'");
return `<div class="gsearch-item" onclick="_gsClickAlbum('${a.id}', '${_escToast(a.name).replace(/'/g, "\\'")}', '${_escToast(ar).replace(/'/g, "\\'")}', '${img}', '${activeSrc}')"><div class="gsearch-item-art">${a.image_url ? `<img src="${a.image_url}" loading="lazy">` : '💿'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div><div class="gsearch-item-sub">${_escToast(ar)}${yr ? ` · ${yr}` : ''}</div></div></div>`;
return `<div class="gsearch-item" onclick="_gsClickAlbum('${a.id}', '${_escAttr(a.name)}', '${_escAttr(ar)}', '${img}', '${activeSrc}')"><div class="gsearch-item-art">${a.image_url ? `<img src="${a.image_url}" loading="lazy">` : '💿'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div><div class="gsearch-item-sub">${_escToast(ar)}${yr ? ` · ${yr}` : ''}</div></div></div>`;
}).join('');
h += '</div>';
}
@ -17313,7 +17314,7 @@ function _gsRender(data) {
h += singles.map(a => {
const ar = a.artist || (a.artists ? a.artists.join(', ') : '');
const img = (a.image_url || '').replace(/'/g, "\\'");
return `<div class="gsearch-item" onclick="_gsClickAlbum('${a.id}', '${_escToast(a.name).replace(/'/g, "\\'")}', '${_escToast(ar).replace(/'/g, "\\'")}', '${img}', '${activeSrc}')"><div class="gsearch-item-art">${a.image_url ? `<img src="${a.image_url}" loading="lazy">` : '🎶'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div><div class="gsearch-item-sub">${_escToast(ar)}</div></div></div>`;
return `<div class="gsearch-item" onclick="_gsClickAlbum('${a.id}', '${_escAttr(a.name)}', '${_escAttr(ar)}', '${img}', '${activeSrc}')"><div class="gsearch-item-art">${a.image_url ? `<img src="${a.image_url}" loading="lazy">` : '🎶'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div><div class="gsearch-item-sub">${_escToast(ar)}</div></div></div>`;
}).join('');
h += '</div>';
}
@ -17323,7 +17324,7 @@ function _gsRender(data) {
h += tracks.map(t => {
const ar = t.artist || (t.artists ? t.artists.join(', ') : '');
const dur = t.duration_ms ? `${Math.floor(t.duration_ms / 60000)}:${String(Math.floor((t.duration_ms % 60000) / 1000)).padStart(2, '0')}` : '';
return `<div class="gsearch-track" onclick="_gsClickTrack('${_escToast(ar).replace(/'/g, "\\'")}', '${_escToast(t.name).replace(/'/g, "\\'")}', '${_escToast(t.album || '').replace(/'/g, "\\'")}', '${_escToast(t.id || '').replace(/'/g, "\\'")}', '${_escToast(t.image_url || '').replace(/'/g, "\\'")}', ${t.duration_ms || 0})"><div class="gsearch-item-art" style="width:32px;height:32px;border-radius:6px">${t.image_url ? `<img src="${t.image_url}" loading="lazy">` : '🎵'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(t.name)}</div><div class="gsearch-item-sub">${_escToast(ar)}${t.album ? ` · ${_escToast(t.album)}` : ''}</div></div><div class="gsearch-track-dur">${dur}</div><button class="gsearch-play-btn" onclick="event.stopPropagation(); _gsPlayTrack('${_escToast(t.name).replace(/'/g, "\\'")}', '${_escToast(ar).replace(/'/g, "\\'")}', '${_escToast(t.album || '').replace(/'/g, "\\'")}')" title="Stream">▶</button></div>`;
return `<div class="gsearch-track" onclick="_gsClickTrack('${_escAttr(ar)}', '${_escAttr(t.name)}', '${_escAttr(t.album || '')}', '${_escAttr(t.id || '')}', '${_escAttr(t.image_url || '')}', ${t.duration_ms || 0})"><div class="gsearch-item-art" style="width:32px;height:32px;border-radius:6px">${t.image_url ? `<img src="${t.image_url}" loading="lazy">` : '🎵'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(t.name)}</div><div class="gsearch-item-sub">${_escToast(ar)}${t.album ? ` · ${_escToast(t.album)}` : ''}</div></div><div class="gsearch-track-dur">${dur}</div><button class="gsearch-play-btn" onclick="event.stopPropagation(); _gsPlayTrack('${_escAttr(t.name)}', '${_escAttr(ar)}', '${_escAttr(t.album || '')}')" title="Stream">▶</button></div>`;
}).join('');
h += '</div>';
} else if (loading.has('tracks')) {
@ -17428,6 +17429,20 @@ async function _gsClickAlbum(albumId, albumName, artistName, imageUrl, source) {
await openDownloadMissingModalForArtistAlbum(virtualPlaylistId, `[${artistName}] ${albumData.name}`, enrichedTracks, albumObj, artistObj, false);
// Register download bubble (same pattern as enhanced search)
registerSearchDownload(
{
id: albumData.id,
name: albumData.name,
artist: artistName,
image_url: albumData.images?.[0]?.url || imageUrl || null,
images: albumData.images || []
},
'album',
virtualPlaylistId,
artistName
);
} catch (e) {
hideLoadingOverlay();
showToast('Failed to load album: ' + e.message, 'error');
@ -17460,6 +17475,20 @@ async function _gsClickTrack(artistName, trackName, albumName, trackId, imageUrl
await openDownloadMissingModalForArtistAlbum(
virtualPlaylistId, playlistName, [enrichedTrack], albumObject, artistObject, false
);
// Register download bubble (same pattern as enhanced search)
registerSearchDownload(
{
id: trackId || '',
name: trackName,
artist: artistName,
image_url: imageUrl || null,
images: imageUrl ? [{ url: imageUrl }] : []
},
'track',
virtualPlaylistId,
artistName
);
} catch (e) {
console.error('Error opening track download:', e);
// Fallback: navigate to enhanced search
@ -55153,7 +55182,7 @@ async function _dblLoadList() {
<div class="discover-blacklist-item">
<span class="discover-blacklist-name">${_escToast(e.artist_name)}</span>
<span class="discover-blacklist-date">${e.created_at ? new Date(e.created_at).toLocaleDateString() : ''}</span>
<button class="discover-blacklist-remove" onclick="unblockDiscoveryArtist(${e.id}, '${_escToast(e.artist_name).replace(/'/g, "\\'")}')" title="Unblock"></button>
<button class="discover-blacklist-remove" onclick="unblockDiscoveryArtist(${e.id}, '${_escAttr(e.artist_name)}')" title="Unblock"></button>
</div>
`).join('');
} catch (e) {

Loading…
Cancel
Save