Add unmatch button, video naming template, and fix slskd log spam

Unmatch: found tracks in playlist discovery now have a red X button
to remove bad matches. Clears match data, sets back to Not Found,
persists in DB for mirrored playlists, and respects user choice on
re-discovery runs (won't re-match automatically).

Video naming: new path template in Settings with $artist, $title,
$artistletter, $year variables. Default unchanged ($artist/$title-video)
so existing Plex setups aren't affected.

slskd logs: Clean Search History automation skips when Soulseek is
not the active download source, eliminating connection error spam.
This commit is contained in:
Broque Thomas 2026-04-19 17:16:22 -07:00
parent 122a6999b3
commit a3c8b9ecdd
3 changed files with 146 additions and 1 deletions

View file

@ -22529,6 +22529,7 @@ def get_version_info():
"• Reject Qobuz 30-second sample/preview downloads",
"• Fix library page crash on All filter — non-string soul_id broke card rendering",
"• Auto Wing It fallback for failed discovery — unmatched tracks download via Soulseek with raw metadata",
"• Unmatch discovery tracks — red ✕ button to remove bad matches from playlist discovery",
"• Customizable music video naming — path template with $artist, $title, $year variables",
"• Fix soulseek log spam when not configured as download source",
],
@ -34533,6 +34534,10 @@ def _run_playlist_discovery_worker(playlists, automation_id=None):
else:
# Incomplete discovery — re-discover to get full metadata
undiscovered_tracks.append(track)
elif existing_extra.get('unmatched_by_user'):
# User explicitly removed this match — respect their choice
pl_skipped += 1
total_skipped += 1
else:
undiscovered_tracks.append(track)
@ -37464,6 +37469,84 @@ def get_youtube_discovery_status(url_hash):
return jsonify({"error": str(e)}), 500
@app.route('/api/youtube/discovery/unmatch', methods=['POST'])
@app.route('/api/tidal/discovery/unmatch', methods=['POST'])
@app.route('/api/deezer/discovery/unmatch', methods=['POST'])
@app.route('/api/spotify-public/discovery/unmatch', methods=['POST'])
@app.route('/api/beatport/discovery/unmatch', methods=['POST'])
@app.route('/api/listenbrainz/discovery/unmatch', methods=['POST'])
def unmatch_discovery_track():
"""Remove a discovery match — sets track back to Not Found"""
try:
data = request.get_json()
identifier = data.get('identifier')
track_index = data.get('track_index')
if not identifier or track_index is None:
return jsonify({'success': False, 'error': 'Missing required fields'}), 400
# Find the state dict for this discovery
state = (youtube_playlist_states.get(identifier)
or tidal_discovery_states.get(identifier)
or deezer_discovery_states.get(identifier)
or spotify_public_discovery_states.get(identifier)
or beatport_chart_states.get(identifier)
or listenbrainz_playlist_states.get(identifier))
if not state:
return jsonify({'success': False, 'error': 'Discovery state not found'}), 404
results = state.get('discovery_results', [])
if track_index >= len(results):
return jsonify({'success': False, 'error': 'Invalid track index'}), 400
result = results[track_index]
old_status = result.get('status_class')
# Clear the match
result['status'] = 'Not Found'
result['status_class'] = 'not-found'
result['spotify_track'] = ''
result['spotify_artist'] = ''
result['spotify_album'] = ''
result['spotify_data'] = None
result['matched_data'] = None
result['match_data'] = None
result['confidence'] = 0
result['wing_it_fallback'] = False
result['manual_match'] = False
# Update match count
if old_status in ('found', 'wing-it'):
state['spotify_matches'] = max(0, state.get('spotify_matches', 0) - 1)
if old_status == 'wing-it':
state['wing_it_count'] = max(0, state.get('wing_it_count', 0) - 1)
# If mirrored playlist, also clear in DB
if identifier.startswith('mirrored_'):
try:
db = get_database()
tracks = state.get('tracks', [])
if track_index < len(tracks):
db_track_id = tracks[track_index].get('db_track_id')
if db_track_id:
db.update_mirrored_track_extra_data(db_track_id, {
'discovered': False,
'discovery_attempted': True,
'provider': '',
'unmatched_by_user': True,
})
except Exception as e:
print(f"Error clearing mirrored track match: {e}")
print(f"Unmatched discovery track {track_index}: {result.get('yt_track', result.get('lb_track', ''))}")
return jsonify({'success': True})
except Exception as e:
print(f"Error unmatching discovery track: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/youtube/discovery/update_match', methods=['POST'])
def update_youtube_discovery_match():
"""Update a YouTube discovery result with manually selected Spotify track"""

View file

@ -3602,6 +3602,7 @@ const WHATS_NEW = {
'2.33': [
// --- April 19, 2026 ---
{ date: 'April 19, 2026' },
{ title: 'Unmatch Discovery Tracks', desc: 'Found tracks in playlist discovery now have a red ✕ button to remove the match. Sets the track back to Not Found so it won\'t be downloaded. For mirrored playlists, the unmatch persists in the DB and is respected on re-discovery runs' },
{ title: 'Customizable Music Video Naming', desc: 'Music video file naming is now configurable via a path template in Settings → Library → Paths & Organization. Default unchanged (Artist/Title-video.mp4). Remove "-video" from the template to get clean filenames. Available variables: $artist, $artistletter, $title, $year', page: 'settings' },
{ title: 'Fix Soulseek Log Spam', desc: 'The "Clean Search History" automation no longer tries to connect to slskd when Soulseek is not the active download source, eliminating noisy connection error logs for users who don\'t use Soulseek' },
{ title: 'Auto Wing It Discovery Fallback', desc: 'When playlist discovery fails to match a track on any metadata API (Spotify, Deezer, iTunes, etc.), the track now automatically falls back to Wing It mode instead of being marked "Not Found". Stub metadata is built from the raw source title and artist, and the track flows through the normal download pipeline via Soulseek. Amber "Wing It" badge distinguishes these from API-matched tracks. Works across all discovery sources: YouTube, Tidal, Deezer, Beatport, ListenBrainz, and mirrored playlists. Wing It stubs persist in the DB for mirrored playlists and are re-attempted on future discovery runs so real matches can replace them' },

View file

@ -19666,10 +19666,67 @@ function updateDiscoveryModalSingleRow(platform, identifier, trackIndex) {
console.log(`✅ Updated row ${trackIndex} in discovery modal`);
}
async function unmatchDiscoveryTrack(platform, identifier, trackIndex) {
// Determine the correct API base for this platform
const apiBase = platform === 'tidal' ? '/api/tidal'
: platform === 'deezer' ? '/api/deezer'
: platform === 'spotify-public' ? '/api/spotify-public'
: platform === 'beatport' ? '/api/beatport'
: platform === 'listenbrainz' ? '/api/listenbrainz'
: '/api/youtube';
try {
const response = await fetch(`${apiBase}/discovery/unmatch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier, track_index: trackIndex })
});
const data = await response.json();
if (data.success) {
// Update the row in the discovery modal table
const state = youtubePlaylistStates[identifier]
|| (window.tidalDiscoveryStates && window.tidalDiscoveryStates[identifier])
|| {};
if (state.discovery_results && state.discovery_results[trackIndex]) {
const r = state.discovery_results[trackIndex];
r.status = '❌ Not Found';
r.status_class = 'not-found';
r.spotify_track = '-';
r.spotify_artist = '-';
r.spotify_album = '-';
r.spotify_data = null;
r.matched_data = null;
r.confidence = 0;
r.wing_it_fallback = false;
r.manual_match = false;
}
// Re-render the row — discovery rows use id="discovery-row-{urlHash}-{index}"
const row = document.getElementById(`discovery-row-${identifier}-${trackIndex}`);
if (row) {
const statusCell = row.querySelector('.discovery-status');
if (statusCell) { statusCell.textContent = '❌ Not Found'; statusCell.className = 'discovery-status not-found'; }
const matchedCells = row.querySelectorAll('.spotify-track, .spotify-artist, .spotify-album');
matchedCells.forEach(c => c.textContent = '-');
const actionsCell = row.querySelector('.discovery-actions');
if (actionsCell) {
actionsCell.innerHTML = `<button class="fix-match-btn" onclick="openDiscoveryFixModal('${platform}', '${identifier}', ${trackIndex})" title="Manually search for this track">🔧 Fix</button>`;
}
}
showToast('Match removed', 'success');
} else {
showToast(data.error || 'Failed to remove match', 'error');
}
} catch (e) {
console.error('Unmatch error:', e);
showToast('Failed to remove match', 'error');
}
}
// Make functions available globally for onclick handlers
window.openDiscoveryFixModal = openDiscoveryFixModal;
window.closeDiscoveryFixModal = closeDiscoveryFixModal;
window.searchDiscoveryFix = searchDiscoveryFix;
window.unmatchDiscoveryTrack = unmatchDiscoveryTrack;
window.openMatchingModal = openMatchingModal;
window.closeMatchingModal = closeMatchingModal;
window.selectArtist = selectArtist;
@ -34121,12 +34178,16 @@ function generateDiscoveryActionButton(result, identifier, platform) {
</button>`;
}
// For found matches, show optional re-match button
// For found matches, show re-match and unmatch buttons
if (isFound) {
return `<button class="rematch-btn"
onclick="openDiscoveryFixModal('${platform}', '${identifier}', ${result.index})"
title="Change this match">
</button><button class="rematch-btn" style="margin-left:4px;color:#ff6b6b"
onclick="unmatchDiscoveryTrack('${platform}', '${identifier}', ${result.index})"
title="Remove this match">
</button>`;
}