Add Wing It mode — download or sync without metadata discovery

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.
pull/253/head
Broque Thomas 1 month ago
parent f58be8f05c
commit 9556fc9b5c

@ -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

@ -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/<playlist_mbid>', methods=['POST'])
def start_listenbrainz_sync(playlist_mbid):
"""Start sync process for a ListenBrainz playlist using discovered Spotify tracks"""

@ -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 = `
<div class="smart-delete-modal">
<div class="smart-delete-header">
<h3> Wing It</h3>
<button class="smart-delete-close">&times;</button>
</div>
<p class="smart-delete-desc">${trackCount} track${trackCount !== 1 ? 's' : ''} from ${source}. No metadata discovery uses raw names. Failed tracks won't be added to wishlist.</p>
<div class="smart-delete-options">
<button class="smart-delete-option" data-choice="download">
<div class="smart-delete-option-icon"></div>
<div class="smart-delete-option-info">
<div class="smart-delete-option-title" style="color:#4caf50">Download</div>
<div class="smart-delete-option-desc">Search and download each track using raw names.</div>
</div>
</button>
<button class="smart-delete-option" data-choice="sync">
<div class="smart-delete-option-icon">🔄</div>
<div class="smart-delete-option-info">
<div class="smart-delete-option-title" style="color:#64b5f6">Sync to Server</div>
<div class="smart-delete-option-desc">Mirror playlist and sync to your media server. Best-effort matching.</div>
</div>
</button>
</div>
</div>
`;
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 = ` <button class="modal-btn wing-it-btn" onclick="_wingItFromModal('${urlHash}')">⚡ Wing It</button>`;
if (isListenBrainz) {
return `<button class="modal-btn modal-btn-primary" onclick="startListenBrainzDiscovery('${urlHash}')">🔍 Start Discovery</button>`;
} else if (isTidal) {
return `<button class="modal-btn modal-btn-primary" onclick="startYouTubeDiscovery('${urlHash}')">🔍 Start Discovery</button>`;
} else if (isDeezer) {
return `<button class="modal-btn modal-btn-primary" onclick="startYouTubeDiscovery('${urlHash}')">🔍 Start Discovery</button>`;
} else if (isSpotifyPublic) {
return `<button class="modal-btn modal-btn-primary" onclick="startYouTubeDiscovery('${urlHash}')">🔍 Start Discovery</button>`;
} else if (isBeatport) {
return `<button class="modal-btn modal-btn-primary" onclick="startYouTubeDiscovery('${urlHash}')">🔍 Start Discovery</button>`;
return `<button class="modal-btn modal-btn-primary" onclick="startListenBrainzDiscovery('${urlHash}')">🔍 Start Discovery</button>${wingItBtn}`;
} else {
return `<button class="modal-btn modal-btn-primary" onclick="startYouTubeDiscovery('${urlHash}')">🔍 Start Discovery</button>`;
return `<button class="modal-btn modal-btn-primary" onclick="startYouTubeDiscovery('${urlHash}')">🔍 Start Discovery</button>${wingItBtn}`;
}
} else {
// Discovering phase - show progress
@ -30610,8 +30846,11 @@ function getModalActionButtons(urlHash, phase, state = null) {
buttons += `<button class="modal-btn modal-btn-secondary" onclick="resetYouTubePlaylist('${urlHash}')">🔄 Rediscover</button>`;
}
if (!buttons) {
buttons = `<div class="modal-info"> No Spotify matches found. Discovery complete but no tracks could be matched.</div>`;
// Wing It button — available in discovered phase for unmatched tracks
buttons += ` <button class="modal-btn wing-it-btn" onclick="_wingItFromModal('${urlHash}')">⚡ Wing It</button>`;
if (!buttons || buttons.trim().startsWith('<button class="modal-btn wing-it-btn"')) {
buttons = `<div class="modal-info"> No Spotify matches found.</div>` + buttons;
}
return buttons;
@ -51855,6 +52094,12 @@ function buildListenBrainzPlaylistsHtml(playlists, tabId) {
<span class="button-icon"></span>
<span class="button-text">Download</span>
</button>
<button class="action-button wing-it-btn-sm"
onclick="_wingItFromLBCard('${identifier}', '${escapeForInlineJs(title)}')"
title="Download using raw track names — no metadata discovery">
<span class="button-icon"></span>
<span class="button-text">Wing It</span>
</button>
<button class="action-button primary"
id="${playlistId}-sync-btn"
onclick="startListenBrainzPlaylistSync('${identifier}')"
@ -52125,6 +52370,15 @@ function displayListenBrainzTracks(tracks, playlistId) {
playlistContainer.innerHTML = html;
}
async function _wingItFromLBCard(identifier, title) {
const tracks = listenbrainzTracksCache[identifier];
if (!tracks || tracks.length === 0) {
showToast('No tracks cached for this playlist. Try opening the discovery modal first.', 'error');
return;
}
wingItDownload(tracks, title, 'ListenBrainz', identifier);
}
async function openDownloadModalForListenBrainzPlaylist(identifier, title) {
try {
const tracks = listenbrainzTracksCache[identifier];

@ -5169,6 +5169,38 @@ body.helper-mode-active #dashboard-activity-feed:hover {
}
.notif-entry-link:hover { opacity: 0.7; }
/* ── Wing It Button ── */
.wing-it-btn {
background: transparent !important;
border: 1px solid rgba(255, 183, 77, 0.3) !important;
color: #ffb74d !important;
font-size: 12px !important;
font-weight: 600 !important;
padding: 6px 14px !important;
border-radius: 8px !important;
cursor: pointer;
transition: all 0.2s;
}
.wing-it-btn:hover {
background: rgba(255, 183, 77, 0.1) !important;
border-color: rgba(255, 183, 77, 0.5) !important;
}
.wing-it-btn-sm {
background: transparent;
border: 1px solid rgba(255, 183, 77, 0.25);
color: #ffb74d;
font-size: 11px;
border-radius: 6px;
padding: 4px 10px;
cursor: pointer;
transition: all 0.2s;
}
.wing-it-btn-sm:hover {
background: rgba(255, 183, 77, 0.1);
border-color: rgba(255, 183, 77, 0.5);
}
/* Desktop-Only Optimizations */
@media (min-width: 769px) {
.main-container {

Loading…
Cancel
Save