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.
This commit is contained in:
parent
f58be8f05c
commit
9556fc9b5c
4 changed files with 381 additions and 17 deletions
|
|
@ -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">×</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…
Reference in a new issue