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:
parent
a431ec0bd6
commit
d20609c33b
3 changed files with 1360 additions and 1 deletions
191
web_server.py
191
web_server.py
|
|
@ -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
|
|
@ -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) */
|
||||
|
|
|
|||
Loading…
Reference in a new issue