Add search bubble snapshot and download tracking system

Implements a persistent search bubble system for tracking album and track downloads in enhanced search. Adds backend API endpoints for saving and hydrating search bubble snapshots, frontend state management for search download bubbles, UI for displaying and managing active/completed downloads, and associated styles for search bubble cards. This enables users to resume and manage search downloads across page refreshes.
This commit is contained in:
Broque Thomas 2025-12-29 14:33:59 -08:00
parent a431ec0bd6
commit d20609c33b
3 changed files with 1360 additions and 1 deletions

View file

@ -15909,6 +15909,197 @@ def hydrate_artist_bubbles():
'error': str(e)
}), 500
# --- Search Bubble Snapshot System ---
@app.route('/api/search_bubbles/snapshot', methods=['POST'])
def save_search_bubble_snapshot():
"""
Saves a snapshot of current search bubble state for persistence across page refreshes.
"""
try:
import os
import json
from datetime import datetime
data = request.json
if not data or 'bubbles' not in data:
return jsonify({'success': False, 'error': 'No bubble data provided'}), 400
bubbles = data['bubbles']
# Create snapshot with timestamp
snapshot = {
'bubbles': bubbles,
'timestamp': datetime.now().isoformat(),
'snapshot_id': datetime.now().strftime('%Y%m%d_%H%M%S')
}
# Save to file
snapshot_file = os.path.join(os.path.dirname(__file__), 'search_bubble_snapshots.json')
with open(snapshot_file, 'w') as f:
json.dump(snapshot, f, indent=2)
bubble_count = len(bubbles)
print(f"📸 Saved search bubble snapshot: {bubble_count} albums/tracks")
return jsonify({
'success': True,
'message': f'Snapshot saved with {bubble_count} search bubbles',
'timestamp': snapshot['timestamp']
})
except Exception as e:
print(f"❌ Error saving search bubble snapshot: {e}")
import traceback
traceback.print_exc()
return jsonify({
'success': False,
'error': str(e)
}), 500
@app.route('/api/search_bubbles/hydrate', methods=['GET'])
def hydrate_search_bubbles():
"""
Loads search bubbles with live status by cross-referencing snapshots with active processes.
"""
try:
import os
import json
from datetime import datetime, timedelta
snapshot_file = os.path.join(os.path.dirname(__file__), 'search_bubble_snapshots.json')
# Load snapshot if it exists
if not os.path.exists(snapshot_file):
return jsonify({
'success': True,
'bubbles': {},
'message': 'No snapshots found'
})
with open(snapshot_file, 'r') as f:
snapshot_data = json.load(f)
saved_bubbles = snapshot_data.get('bubbles', {})
snapshot_time = snapshot_data.get('timestamp', '')
# Clean up old snapshots (older than 48 hours)
try:
if snapshot_time:
snapshot_dt = datetime.fromisoformat(snapshot_time.replace('Z', '+00:00'))
cutoff = datetime.now() - timedelta(hours=48)
if snapshot_dt < cutoff:
print(f"🧹 Cleaning up old search snapshot from {snapshot_time}")
os.remove(snapshot_file)
return jsonify({
'success': True,
'bubbles': {},
'message': 'Old snapshot cleaned up'
})
except (ValueError, OSError) as e:
print(f"⚠️ Error checking snapshot age: {e}")
# Get current active download processes for live status
current_processes = {}
try:
with tasks_lock:
for batch_id, batch_data in download_batches.items():
if batch_data.get('phase') not in ['complete', 'error', 'cancelled']:
playlist_id = batch_data.get('playlist_id')
if playlist_id:
current_processes[playlist_id] = {
'status': 'in_progress' if batch_data.get('phase') == 'downloading' else 'analyzing',
'batch_id': batch_id,
'phase': batch_data.get('phase')
}
except Exception as e:
print(f"⚠️ Error fetching active processes for hydration: {e}")
# If no active processes exist, the app likely restarted - clean up snapshots
if not current_processes:
print(f"🧹 No active processes found - app likely restarted, cleaning up search snapshot")
try:
os.remove(snapshot_file)
return jsonify({
'success': True,
'bubbles': {},
'message': 'Snapshot cleaned up after app restart'
})
except OSError as e:
print(f"⚠️ Error removing snapshot file: {e}")
return jsonify({
'success': True,
'bubbles': {},
'message': 'No active processes - returning empty bubbles'
})
# Update bubble statuses with live data (artist-grouped structure)
hydrated_bubbles = {}
for artist_name, bubble_data in saved_bubbles.items():
hydrated_bubble = {
'artist': bubble_data['artist'],
'downloads': []
}
for download in bubble_data.get('downloads', []):
virtual_playlist_id = download['virtualPlaylistId']
# Determine current live status
if virtual_playlist_id in current_processes:
process_info = current_processes[virtual_playlist_id]
live_status = 'in_progress'
print(f"🔄 Found active process for {download['item']['name']}: {process_info['phase']}")
else:
# No active process - likely completed
live_status = 'view_results'
print(f"✅ No active process for {download['item']['name']} - marking as completed")
# Create updated download entry
updated_download = {
'virtualPlaylistId': virtual_playlist_id,
'item': download['item'],
'type': download.get('type', 'album'),
'status': live_status,
'startTime': download.get('startTime', datetime.now().isoformat())
}
hydrated_bubble['downloads'].append(updated_download)
# Only include artists that still have downloads
if hydrated_bubble['downloads']:
hydrated_bubbles[artist_name] = hydrated_bubble
bubble_count = len(hydrated_bubbles)
active_count = sum(1 for bubble in hydrated_bubbles.values()
for download in bubble['downloads']
if download['status'] == 'in_progress')
completed_count = sum(1 for bubble in hydrated_bubbles.values()
for download in bubble['downloads']
if download['status'] == 'view_results')
print(f"🔄 Hydrated {bubble_count} search bubbles (artists): {active_count} active, {completed_count} completed")
return jsonify({
'success': True,
'bubbles': hydrated_bubbles,
'stats': {
'total_items': bubble_count,
'active_downloads': active_count,
'completed_downloads': completed_count,
'snapshot_time': snapshot_time
}
})
except Exception as e:
print(f"❌ Error hydrating search bubbles: {e}")
import traceback
traceback.print_exc()
return jsonify({
'success': False,
'error': str(e)
}), 500
# --- Watchlist API Endpoints ---
@app.route('/api/watchlist/count', methods=['GET'])

File diff suppressed because it is too large Load diff

View file

@ -19568,6 +19568,205 @@ body {
margin-left: 8px;
}
/* ========================================= */
/* SEARCH BUBBLE CARDS - Download Tracking */
/* ========================================= */
.search-bubble-section {
padding: 32px 24px;
}
.search-bubble-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.search-bubble-title {
font-size: 20px;
font-weight: 700;
color: #ffffff;
margin: 0;
letter-spacing: -0.4px;
}
.search-bubble-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 32px;
padding: 0 12px;
background: rgba(29, 185, 84, 0.15);
border: 1px solid rgba(29, 185, 84, 0.3);
border-radius: 16px;
font-size: 14px;
font-weight: 600;
color: rgba(29, 185, 84, 1);
}
.search-bubble-container {
display: flex;
flex-wrap: wrap;
gap: 24px;
justify-content: center;
align-items: center;
padding: 12px;
}
.search-bubble-card {
position: relative;
width: 150px;
height: 150px;
border-radius: 50%;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background: linear-gradient(135deg,
rgba(26, 26, 26, 0.95) 0%,
rgba(18, 18, 18, 0.98) 100%);
border: 2px solid rgba(255, 255, 255, 0.1);
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(29, 185, 84, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.search-bubble-card:hover {
transform: translateY(-3px) scale(1.05);
border-color: rgba(29, 185, 84, 0.3);
box-shadow:
0 8px 20px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(29, 185, 84, 0.2),
0 0 15px rgba(29, 185, 84, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.search-bubble-card.all-completed {
border-color: rgba(34, 197, 94, 0.4);
}
.search-bubble-card.all-completed:hover {
border-color: rgba(34, 197, 94, 0.6);
box-shadow:
0 8px 20px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(34, 197, 94, 0.3),
0 0 15px rgba(34, 197, 94, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.search-bubble-image {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
border-radius: 50%;
overflow: hidden;
}
.search-bubble-overlay {
position: absolute;
inset: 0;
background: linear-gradient(135deg,
rgba(0, 0, 0, 0.3) 0%,
rgba(0, 0, 0, 0.6) 100%);
border-radius: 50%;
}
.search-bubble-content {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16px;
text-align: center;
z-index: 2;
}
.search-bubble-name {
font-size: 14px;
font-weight: 700;
color: #ffffff;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
line-height: 1.3;
}
.search-bubble-artist {
font-size: 12px;
font-weight: 500;
color: rgba(255, 255, 255, 0.7);
margin-bottom: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.search-bubble-status {
display: flex;
align-items: center;
gap: 4px;
margin-top: auto;
padding: 4px 8px;
background: rgba(0, 0, 0, 0.4);
border-radius: 12px;
backdrop-filter: blur(4px);
}
.search-bubble-status-icon {
font-size: 12px;
}
.search-bubble-status-text {
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.search-bubble-type {
position: absolute;
top: 8px;
right: 8px;
padding: 4px 8px;
background: rgba(29, 185, 84, 0.9);
border-radius: 8px;
font-size: 10px;
font-weight: 600;
color: #ffffff;
backdrop-filter: blur(4px);
}
/* Responsive search bubbles */
@media (max-width: 768px) {
.search-bubble-card {
width: 120px;
height: 120px;
}
.search-bubble-name {
font-size: 12px;
}
.search-bubble-artist {
font-size: 11px;
}
}
/* ========================================= */
/* ENHANCED SEARCH CATEGORIZED RESULTS */
/* (OLD - REMOVE IF NOT NEEDED) */