From a3c8b9ecdd523fc8965dc123b040371cc7b786e9 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 19 Apr 2026 17:16:22 -0700 Subject: [PATCH] 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. --- web_server.py | 83 ++++++++++++++++++++++++++++++++++++++++++ webui/static/helper.js | 1 + webui/static/script.js | 63 +++++++++++++++++++++++++++++++- 3 files changed, 146 insertions(+), 1 deletion(-) diff --git a/web_server.py b/web_server.py index 19eb1fef..2d45040c 100644 --- a/web_server.py +++ b/web_server.py @@ -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""" diff --git a/webui/static/helper.js b/webui/static/helper.js index c4056217..a5dcdeab 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -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' }, diff --git a/webui/static/script.js b/webui/static/script.js index 3dd0ac7c..c4d5e8e8 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -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 = ``; + } + } + 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) { `; } - // For found matches, show optional re-match button + // For found matches, show re-match and unmatch buttons if (isFound) { return ``; }