From 9556fc9b5c9b4ceaab203f2255e024bdb29a77d8 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:16:51 -0700 Subject: [PATCH] =?UTF-8?q?Add=20Wing=20It=20mode=20=E2=80=94=20download?= =?UTF-8?q?=20or=20sync=20without=20metadata=20discovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wing It bypasses Spotify/iTunes/Deezer matching and uses raw track names directly. User chooses Download or Sync from a choice dialog. Download: opens Download Missing modal with force-download-all pre-checked. wing_it flag skips wishlist for failed tracks. Sync: new POST /api/wing-it/sync endpoint runs _run_sync_task with raw track dicts. Live inline sync status display on the LB card using the same progress elements as normal sync. Unmatched tracks skip wishlist via _skip_wishlist flag on sync_service. Button in three places: - Next to "Start Discovery" in all discovery modals (fresh phase) - Next to "Download Missing"/"Sync" after discovery (discovered phase) - Next to "Download" on ListenBrainz cards (Discover page) Fixed force-download toggle ID, sync progress field names (total_tracks/matched_tracks not total/matched). All changes purely additive — normal flows unaffected. --- services/sync_service.py | 5 +- web_server.py | 81 ++++++++++- webui/static/script.js | 280 +++++++++++++++++++++++++++++++++++++-- webui/static/style.css | 32 +++++ 4 files changed, 381 insertions(+), 17 deletions(-) diff --git a/services/sync_service.py b/services/sync_service.py index a296d1df..32c71bdc 100644 --- a/services/sync_service.py +++ b/services/sync_service.py @@ -309,8 +309,11 @@ class PlaylistSyncService: matched_tracks=len(matched_tracks), failed_tracks=failed_tracks) - # Auto-add unmatched tracks to wishlist + # Auto-add unmatched tracks to wishlist (skip in Wing It mode) wishlist_added_count = 0 + if unmatched_tracks and getattr(self, '_skip_wishlist', False): + logger.info(f"⚡ [Wing It] Skipping wishlist for {len(unmatched_tracks)} unmatched tracks") + unmatched_tracks = [] # Clear so the loop below doesn't run if unmatched_tracks: try: from core.wishlist_service import get_wishlist_service diff --git a/web_server.py b/web_server.py index b04dec4e..6285b236 100644 --- a/web_server.py +++ b/web_server.py @@ -24379,13 +24379,19 @@ def _process_failed_tracks_to_wishlist_exact(batch_id): from datetime import datetime print(f"🔍 [Wishlist Processing] Starting wishlist processing for batch {batch_id}") - + with tasks_lock: if batch_id not in download_batches: print(f"âš ī¸ [Wishlist Processing] Batch {batch_id} not found") return {'tracks_added': 0, 'errors': 0} - + batch = download_batches[batch_id] + + # Wing It mode — skip wishlist entirely for failed tracks + if batch.get('wing_it'): + failed_count = len(batch.get('permanently_failed_tracks', [])) + print(f"⚡ [Wing It] Skipping wishlist for {failed_count} failed tracks (wing it mode)") + return {'tracks_added': 0, 'errors': 0} permanently_failed_tracks = batch.get('permanently_failed_tracks', []) cancelled_tracks = batch.get('cancelled_tracks', set()) @@ -28847,6 +28853,7 @@ def start_missing_tracks_process(playlist_id): playlist_name = data.get('playlist_name', 'Unknown Playlist') force_download_all = data.get('force_download_all', False) playlist_folder_mode = data.get('playlist_folder_mode', False) + wing_it = data.get('wing_it', False) # Get album/artist context for artist album downloads is_album_download = data.get('is_album_download', False) @@ -28899,7 +28906,8 @@ def start_missing_tracks_process(playlist_id): # Album context for artist album downloads (explicit folder structure) 'is_album_download': is_album_download, 'album_context': album_context, - 'artist_context': artist_context + 'artist_context': artist_context, + 'wing_it': wing_it, } # Record sync history @@ -34682,6 +34690,11 @@ def _run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, # Attach original tracks map to sync_service for wishlist with album images sync_service._original_tracks_map = original_tracks_map + # Wing It mode — skip wishlist for unmatched tracks + with sync_lock: + is_wing_it = sync_states.get(playlist_id, {}).get('wing_it', False) + sync_service._skip_wishlist = is_wing_it + # Run the sync (this is a blocking call within this thread) result = run_async(sync_service.sync_playlist(playlist, download_missing=False, profile_id=profile_id)) @@ -40278,6 +40291,68 @@ def convert_listenbrainz_results_to_spotify_tracks(discovery_results): print(f"🔄 Converted {len(spotify_tracks)} ListenBrainz matches to Spotify tracks for sync") return spotify_tracks +@app.route('/api/wing-it/sync', methods=['POST']) +def wing_it_sync(): + """Sync a playlist to the media server using raw track names — no metadata discovery.""" + try: + data = request.get_json() + tracks_raw = data.get('tracks', []) + playlist_name = data.get('playlist_name', 'Wing It Playlist') + + if not tracks_raw: + return jsonify({"error": "No tracks provided"}), 400 + + # Convert raw tracks to dicts — _run_sync_task expects dicts with .get() + sync_tracks = [] + for t in tracks_raw: + artist_name = '' + if isinstance(t.get('artists'), list) and t['artists']: + a = t['artists'][0] + artist_name = a.get('name', str(a)) if isinstance(a, dict) else str(a) + elif t.get('artist_name'): + artist_name = t['artist_name'] + + album_name = '' + if isinstance(t.get('album'), dict): + album_name = t['album'].get('name', '') + elif isinstance(t.get('album'), str): + album_name = t['album'] + elif t.get('album_name'): + album_name = t['album_name'] + + sync_tracks.append({ + 'id': t.get('id', f"wing_it_{len(sync_tracks)}"), + 'name': t.get('name', t.get('track_name', 'Unknown')), + 'artists': [{'name': artist_name}] if artist_name else [{'name': 'Unknown'}], + 'album': album_name, + 'duration_ms': t.get('duration_ms', 0), + }) + + if not sync_tracks: + return jsonify({"error": "No valid tracks to sync"}), 400 + + sync_playlist_id = f"wing_it_sync_{int(time.time())}" + + add_activity_item("⚡", "Wing It Sync Started", f"'{playlist_name}' — {len(sync_tracks)} tracks", "Now") + + with sync_lock: + sync_states[sync_playlist_id] = {"status": "starting", "progress": {}} + + # Pass wing_it flag via sync state so _run_sync_task can skip wishlist + with sync_lock: + sync_states[sync_playlist_id]['wing_it'] = True + + future = sync_executor.submit(_run_sync_task, sync_playlist_id, playlist_name, sync_tracks, None, get_current_profile_id()) + active_sync_workers[sync_playlist_id] = future + + logger.info(f"⚡ [Wing It] Started sync for: {playlist_name} ({len(sync_tracks)} tracks)") + return jsonify({"success": True, "sync_playlist_id": sync_playlist_id}) + + except Exception as e: + logger.error(f"Error in Wing It sync: {e}", exc_info=True) + return jsonify({"error": str(e)}), 500 + + @app.route('/api/listenbrainz/sync/start/', methods=['POST']) def start_listenbrainz_sync(playlist_mbid): """Start sync process for a ListenBrainz playlist using discovered Spotify tracks""" diff --git a/webui/static/script.js b/webui/static/script.js index 7711a3f5..ac73400a 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -11591,6 +11591,245 @@ async function exportPlaylistAsM3U(playlistId) { console.log(`✅ Exported M3U - Total: ${process.tracks.length}, Available: ${availableCount}, Missing: ${missingCount}`); } +// ================================================================================== +// WING IT — Download without metadata discovery +// ================================================================================== + +async function wingItDownload(tracks, playlistName, source = 'playlist', cardIdentifier = null) { + if (!tracks || tracks.length === 0) { + showToast('No tracks to download', 'error'); + return; + } + + // Show choice: Download or Sync + const choice = await _showWingItChoiceDialog(tracks.length, source); + if (!choice) return; + + if (choice === 'sync') { + await _wingItSync(tracks, playlistName, source, cardIdentifier); + return; + } + + // choice === 'download' — continue with download flow + + // Normalize tracks to Spotify-compatible format + const formattedTracks = tracks.map(t => { + // Handle various artist formats + let artists = []; + if (t.artists) { + if (Array.isArray(t.artists)) { + artists = t.artists.map(a => typeof a === 'string' ? { name: a } : a); + } else if (typeof t.artists === 'string') { + artists = [{ name: t.artists }]; + } + } else if (t.artist_name) { + artists = [{ name: t.artist_name }]; + } else if (t.artist) { + artists = [{ name: t.artist }]; + } + if (artists.length === 0) artists = [{ name: 'Unknown' }]; + + // Handle album + let album = { name: '' }; + if (t.album) { + album = typeof t.album === 'string' ? { name: t.album } : t.album; + } else if (t.album_name) { + album = { name: t.album_name }; + } + + return { + id: t.id || t.source_track_id || `wing_it_${Date.now()}_${Math.random()}`, + name: t.name || t.track_name || 'Unknown Track', + artists: artists, + duration_ms: t.duration_ms || 0, + album: album, + }; + }); + + const virtualPlaylistId = `wing_it_${Date.now()}`; + + // Store wing_it flag BEFORE opening the modal + youtubePlaylistStates[virtualPlaylistId] = { + wing_it: true, + tracks: formattedTracks, + }; + + await openDownloadMissingModalForYouTube(virtualPlaylistId, `⚡ ${playlistName}`, formattedTracks); + + // Pre-check the Force Download toggle + setTimeout(() => { + const forceToggle = document.getElementById(`force-download-all-${virtualPlaylistId}`); + if (forceToggle && !forceToggle.checked) forceToggle.checked = true; + }, 800); +} + +function _showWingItChoiceDialog(trackCount, source) { + return new Promise(resolve => { + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:10000;display:flex;align-items:center;justify-content:center;'; + const close = val => { overlay.remove(); resolve(val); }; + overlay.onclick = e => { if (e.target === overlay) close(null); }; + + overlay.innerHTML = ` +
+
+

⚡ Wing It

+ +
+

${trackCount} track${trackCount !== 1 ? 's' : ''} from ${source}. No metadata discovery — uses raw names. Failed tracks won't be added to wishlist.

+
+ + +
+
+ `; + + overlay.querySelectorAll('.smart-delete-option').forEach(btn => { + btn.addEventListener('click', () => close(btn.dataset.choice)); + }); + overlay.querySelector('.smart-delete-close').addEventListener('click', () => close(null)); + const escH = e => { if (e.key === 'Escape') { document.removeEventListener('keydown', escH); close(null); } }; + document.addEventListener('keydown', escH); + document.body.appendChild(overlay); + }); +} + +async function _wingItSync(tracks, playlistName, source, cardIdentifier = null) { + try { + showToast('Syncing playlist to server...', 'info'); + + // Format tracks for the sync endpoint + const syncTracks = tracks.map((t, i) => { + let artists = t.artists || []; + if (!Array.isArray(artists)) artists = [{ name: String(artists) }]; + return { + id: t.id || t.source_track_id || `wing_it_${i}`, + name: t.name || t.track_name || 'Unknown', + artists: artists.map(a => typeof a === 'string' ? { name: a } : a), + album: typeof t.album === 'object' ? t.album : { name: t.album || t.album_name || '' }, + duration_ms: t.duration_ms || 0, + artist_name: t.artist_name, + }; + }); + + const res = await fetch('/api/wing-it/sync', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tracks: syncTracks, playlist_name: playlistName }) + }); + const data = await res.json(); + + if (data.error) { + showToast(`Sync failed: ${data.error}`, 'error'); + return; + } + + // Show inline sync status on the card (same display as normal sync) + const playlistId = cardIdentifier ? `discover-lb-playlist-${cardIdentifier}` : null; + if (playlistId) { + const statusDisplay = document.getElementById(`${playlistId}-sync-status`); + if (statusDisplay) statusDisplay.style.display = 'block'; + // Disable sync/wing-it buttons during sync + const syncBtn = document.getElementById(`${playlistId}-sync-btn`); + if (syncBtn) { syncBtn.disabled = true; syncBtn.style.opacity = '0.5'; } + } + + // Poll for sync progress — update inline display + if (data.sync_playlist_id) { + _pollWingItSyncProgress(data.sync_playlist_id, playlistName, playlistId); + } + + } catch (e) { + showToast('Sync failed: ' + e.message, 'error'); + } +} + +function _pollWingItSyncProgress(syncPlaylistId, playlistName, cardPlaylistId) { + const poll = setInterval(async () => { + try { + const res = await fetch(`/api/sync/status/${syncPlaylistId}`); + const data = await res.json(); + + // Update inline status display if we have a card + if (cardPlaylistId && data.progress) { + const p = data.progress; + const total = p.total_tracks || p.total || 0; + const matched = p.matched_tracks || p.matched || 0; + const failed = p.failed_tracks || p.failed || 0; + const totalEl = document.getElementById(`${cardPlaylistId}-sync-total`); + const matchedEl = document.getElementById(`${cardPlaylistId}-sync-matched`); + const failedEl = document.getElementById(`${cardPlaylistId}-sync-failed`); + const pctEl = document.getElementById(`${cardPlaylistId}-sync-percentage`); + if (totalEl) totalEl.textContent = total; + if (matchedEl) matchedEl.textContent = matched; + if (failedEl) failedEl.textContent = failed; + if (pctEl) pctEl.textContent = total > 0 ? Math.round((matched / total) * 100) : 0; + } + + if (data.status === 'finished' || data.status === 'complete' || data.status === 'error') { + clearInterval(poll); + const matched = data.progress?.matched_tracks || data.progress?.matched || 0; + const total = data.progress?.total_tracks || data.progress?.total || 0; + + if (data.status === 'error') { + showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error'); + } else { + showToast(`Sync complete — ${matched}/${total} tracks matched to server`, 'success'); + } + + // Update card status display to show completion + if (cardPlaylistId) { + const statusLabel = document.querySelector(`#${cardPlaylistId}-sync-status .sync-status-label span:last-child`); + if (statusLabel) statusLabel.textContent = `Sync complete — ${matched}/${total} matched`; + const syncIcon = document.querySelector(`#${cardPlaylistId}-sync-status .sync-icon`); + if (syncIcon) syncIcon.textContent = '✓'; + } + } + } catch (e) { /* ignore poll errors */ } + }, 2000); + + // Safety timeout + setTimeout(() => clearInterval(poll), 180000); +} + +function _wingItFromModal(urlHash) { + // Extract tracks from the discovery modal state + const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash] || {}; + const tracks = state.tracks || state.rawTracks || []; + const name = state.playlistName || state.name || 'Playlist'; + const isTidal = state.is_tidal_playlist; + const isLB = state.is_listenbrainz_playlist; + const isBeatport = state.is_beatport_playlist; + const isDeezer = state.is_deezer_playlist; + const source = isLB ? 'ListenBrainz' : isTidal ? 'Tidal' : isDeezer ? 'Deezer' : isBeatport ? 'Beatport' : 'YouTube'; + + if (!tracks.length) { + showToast('No tracks available for Wing It', 'error'); + return; + } + + // Close the discovery modal first + const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); + if (modal) modal.remove(); + const overlay = document.getElementById(`youtube-discovery-overlay-${urlHash}`); + if (overlay) overlay.remove(); + + wingItDownload(tracks, name, source); +} + async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks, artist = null, album = null) { showLoadingOverlay('Loading YouTube playlist...'); // Check if a process is already active for this virtual playlist @@ -13636,9 +13875,12 @@ async function startMissingTracksProcess(playlistId) { if (selectAllCb) selectAllCb.disabled = true; // Prepare request body - add album/artist context for artist album downloads + const wingItState = youtubePlaylistStates[playlistId] || {}; + const isWingIt = wingItState.wing_it || false; const requestBody = { tracks: selectedTracks, - force_download_all: forceDownloadAll + force_download_all: forceDownloadAll || isWingIt, + wing_it: isWingIt, }; // If this is an artist album download, use album name and include full context @@ -30532,18 +30774,12 @@ function getModalActionButtons(urlHash, phase, state = null) { case 'discovering': // Show start discovery button for fresh playlists if (phase === 'fresh') { + const wingItBtn = ` `; + if (isListenBrainz) { - return ``; - } else if (isTidal) { - return ``; - } else if (isDeezer) { - return ``; - } else if (isSpotifyPublic) { - return ``; - } else if (isBeatport) { - return ``; + return `${wingItBtn}`; } else { - return ``; + return `${wingItBtn}`; } } else { // Discovering phase - show progress @@ -30610,8 +30846,11 @@ function getModalActionButtons(urlHash, phase, state = null) { buttons += ``; } - if (!buttons) { - buttons = ``; + // Wing It button — available in discovered phase for unmatched tracks + buttons += ` `; + + if (!buttons || buttons.trim().startsWith(' +