Add enhanced search mode UI and retry logic for downloads

Introduces a search mode toggle in the downloads UI, allowing users to switch between basic and enhanced search modes. Adds new HTML structure, JavaScript logic, and CSS styles for the enhanced search interface (currently placeholder functionality). In the backend, implements a retry system for file discovery after download completion to handle race conditions, with cleanup of stale retry attempts to prevent memory leaks.
This commit is contained in:
Broque Thomas 2025-12-28 22:24:23 -08:00
parent 5d29131530
commit 14a5944ae1
4 changed files with 492 additions and 6 deletions

View file

@ -3476,10 +3476,39 @@ def get_download_status():
print(f"🎯 Found completed matched file on disk: {found_path}")
completed_matched_downloads.append((context_key, context, found_path))
# Don't add to _processed_download_ids yet - wait until thread starts successfully
# Clean up retry tracking if file was found after retries
with _download_retry_lock:
if context_key in _download_retry_attempts:
retry_count = _download_retry_attempts[context_key]['count']
elapsed = time.time() - _download_retry_attempts[context_key]['first_attempt']
print(f"✅ File found after {retry_count} retry attempt(s) ({elapsed:.1f}s): {os.path.basename(filename_from_api)}")
del _download_retry_attempts[context_key]
else:
print(f"❌ CRITICAL: Could not find '{os.path.basename(filename_from_api)}' on disk. Post-processing skipped.")
# Mark as processed to prevent endless retries
_processed_download_ids.add(context_key)
# File not found yet - implement retry logic instead of immediate give-up
# This fixes race condition where slskd reports completion before file is written to disk
with _download_retry_lock:
if context_key not in _download_retry_attempts:
# First retry attempt
_download_retry_attempts[context_key] = {
'count': 1,
'first_attempt': time.time()
}
print(f"⏳ File not found yet: '{os.path.basename(filename_from_api)}' - Will retry (attempt 1/{_download_retry_max})")
else:
# Increment retry count
_download_retry_attempts[context_key]['count'] += 1
retry_count = _download_retry_attempts[context_key]['count']
elapsed = time.time() - _download_retry_attempts[context_key]['first_attempt']
if retry_count >= _download_retry_max:
# Max retries reached, give up
print(f"❌ CRITICAL: Could not find '{os.path.basename(filename_from_api)}' after {retry_count} attempts over {elapsed:.1f}s. Giving up.")
_processed_download_ids.add(context_key)
# Clean up retry tracking
del _download_retry_attempts[context_key]
else:
print(f"⏳ File not found yet: '{os.path.basename(filename_from_api)}' - Will retry (attempt {retry_count}/{_download_retry_max}, elapsed: {elapsed:.1f}s)")
# If we found completed matched downloads, start processing them in background threads
if completed_matched_downloads:
@ -7747,6 +7776,13 @@ def _post_process_matched_download(context_key, context, file_path):
# Keep track of processed downloads to avoid re-processing
_processed_download_ids = set()
# --- File Discovery Retry System ---
# Prevents race condition where slskd reports completion before file is written to disk
# Tracks retry attempts per download to give files time to finish writing
_download_retry_attempts = {} # {context_key: {'count': N, 'first_attempt': timestamp}}
_download_retry_max = 10 # Max retries before giving up (10 seconds with 1s poll interval)
_download_retry_lock = threading.Lock()
def _check_and_remove_from_wishlist(context):
"""
Check if a successfully downloaded track should be removed from wishlist.
@ -8057,7 +8093,19 @@ def _simple_monitor_task():
# Use app_context to safely call endpoint logic from a thread
with app.app_context():
get_download_status()
# Cleanup stale retry attempts (older than 60 seconds)
# This prevents memory leaks from stuck/failed downloads
with _download_retry_lock:
current_time = time.time()
stale_keys = [
key for key, data in _download_retry_attempts.items()
if current_time - data['first_attempt'] > 60
]
for key in stale_keys:
print(f"🧹 Cleaning up stale retry attempt: {key}")
del _download_retry_attempts[key]
# Automatic search cleanup every hour (or initial cleanup)
current_time = time.time()
should_cleanup = (current_time - last_search_cleanup > search_cleanup_interval) or not initial_cleanup_done

View file

@ -1176,8 +1176,25 @@
<p class="downloads-subtitle">Search, discover, and download high-quality music</p>
</div>
<!-- Search Bar: Replicates create_elegant_search_bar() -->
<div class="search-bar-container">
<!-- Search Mode Toggle -->
<div class="search-mode-toggle-container">
<div class="search-mode-toggle">
<button class="search-mode-btn active" data-mode="basic">
<span class="mode-icon">🔍</span>
<span class="mode-label">Basic Search</span>
</button>
<button class="search-mode-btn" data-mode="enhanced">
<span class="mode-icon"></span>
<span class="mode-label">Enhanced Search</span>
</button>
<div class="toggle-slider"></div>
</div>
</div>
<!-- Basic Search Section (Current) -->
<div id="basic-search-section" class="search-section active">
<!-- Search Bar: Replicates create_elegant_search_bar() -->
<div class="search-bar-container">
<input type="text" id="downloads-search-input" placeholder="Search for music... (e.g., 'Virtual Mage', 'Queen Bohemian Rhapsody')">
<button id="downloads-cancel-btn" class="hidden">✕ Cancel</button>
<button id="downloads-search-btn">🔍 Search</button>
@ -1251,6 +1268,46 @@
</div>
</div>
</div>
</div>
<!-- End Basic Search Section -->
<!-- Enhanced Search Section (New) -->
<div id="enhanced-search-section" class="search-section">
<!-- Enhanced Search Bar -->
<div class="enhanced-search-bar-container">
<div class="enhanced-search-wrapper">
<div class="enhanced-search-icon"></div>
<input type="text" id="enhanced-search-input" placeholder="Enter artist, album, or track name for intelligent search...">
<button id="enhanced-cancel-btn" class="enhanced-cancel-btn hidden"></button>
</div>
<button id="enhanced-search-btn" class="enhanced-search-btn">
<span class="btn-icon">🔍</span>
<span class="btn-text">Search</span>
</button>
</div>
<!-- Enhanced Search Status -->
<div class="enhanced-search-status">
<div class="enhanced-status-indicator"></div>
<p id="enhanced-search-status-text">Ready • Enhanced search provides intelligent filtering and ranking</p>
</div>
<!-- Enhanced Search Results -->
<div class="enhanced-search-results-container">
<div class="enhanced-results-header">
<h3>Enhanced Results</h3>
<div class="results-count-badge" id="enhanced-results-count">0 results</div>
</div>
<div class="enhanced-results-scroll-area" id="enhanced-search-results-area">
<div class="enhanced-results-placeholder">
<div class="placeholder-icon"></div>
<p class="placeholder-title">Enhanced Search Ready</p>
<p class="placeholder-subtitle">Intelligent filtering • Smart ranking • Better results</p>
</div>
</div>
</div>
</div>
<!-- End Enhanced Search Section -->
</div>
<!-- ======================================================= -->

View file

@ -431,6 +431,7 @@ async function loadPageData(pageId) {
break;
case 'downloads':
initializeSearch();
initializeSearchModeToggle();
initializeFilters();
await loadDownloadsData();
break;
@ -2333,6 +2334,72 @@ function initializeSearch() {
}
}
// ===============================
// SEARCH MODE TOGGLE
// ===============================
function initializeSearchModeToggle() {
const toggleContainer = document.querySelector('.search-mode-toggle');
const modeBtns = document.querySelectorAll('.search-mode-btn');
const basicSection = document.getElementById('basic-search-section');
const enhancedSection = document.getElementById('enhanced-search-section');
if (!toggleContainer || !modeBtns.length || !basicSection || !enhancedSection) {
console.warn('Search mode toggle elements not found');
return;
}
modeBtns.forEach(btn => {
btn.addEventListener('click', () => {
const mode = btn.dataset.mode;
// Update button active states
modeBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// Update toggle slider position
toggleContainer.setAttribute('data-active', mode);
// Toggle sections
if (mode === 'basic') {
basicSection.classList.add('active');
enhancedSection.classList.remove('active');
console.log('Switched to basic search mode');
} else {
basicSection.classList.remove('active');
enhancedSection.classList.add('active');
console.log('Switched to enhanced search mode');
}
});
});
// Initialize enhanced search input handlers
const enhancedInput = document.getElementById('enhanced-search-input');
const enhancedSearchBtn = document.getElementById('enhanced-search-btn');
const enhancedCancelBtn = document.getElementById('enhanced-cancel-btn');
if (enhancedSearchBtn && enhancedInput) {
enhancedSearchBtn.addEventListener('click', () => {
console.log('Enhanced search clicked - functionality to be implemented');
showToast('Enhanced search coming soon!', 'info');
});
enhancedInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
console.log('Enhanced search Enter pressed - functionality to be implemented');
showToast('Enhanced search coming soon!', 'info');
}
});
}
if (enhancedCancelBtn) {
enhancedCancelBtn.addEventListener('click', () => {
console.log('Enhanced search cancelled');
// Cancel logic will be added when functionality is implemented
});
}
}
async function performSearch() {
const query = document.getElementById('search-input').value.trim();
if (!query) {

View file

@ -18796,3 +18796,317 @@ body {
}
}
/* ========================================= */
/* SEARCH MODE TOGGLE SYSTEM */
/* ========================================= */
/* Toggle Container */
.search-mode-toggle-container {
display: flex;
justify-content: center;
margin: 20px 0;
}
.search-mode-toggle {
position: relative;
background: rgba(30, 30, 30, 0.8);
border-radius: 12px;
padding: 4px;
display: inline-flex;
gap: 4px;
border: 1px solid rgba(64, 64, 64, 0.4);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.search-mode-btn {
position: relative;
z-index: 2;
background: transparent;
border: none;
padding: 10px 24px;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
gap: 8px;
font-family: 'Segoe UI', sans-serif;
font-size: 14px;
font-weight: 500;
color: rgba(255, 255, 255, 0.6);
}
.search-mode-btn:hover {
color: rgba(255, 255, 255, 0.9);
}
.search-mode-btn.active {
color: #ffffff;
}
.mode-icon {
font-size: 16px;
transition: transform 0.3s ease;
}
.search-mode-btn.active .mode-icon {
transform: scale(1.1);
}
.toggle-slider {
position: absolute;
top: 4px;
left: 4px;
width: calc(50% - 4px);
height: calc(100% - 8px);
background: linear-gradient(135deg, rgba(29, 185, 84, 0.9), rgba(24, 156, 71, 0.9));
border-radius: 8px;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1;
box-shadow: 0 2px 8px rgba(29, 185, 84, 0.4);
}
.search-mode-toggle[data-active="enhanced"] .toggle-slider {
transform: translateX(100%);
background: linear-gradient(135deg, rgba(138, 43, 226, 0.9), rgba(123, 31, 162, 0.9));
box-shadow: 0 2px 8px rgba(138, 43, 226, 0.4);
}
/* Search Section Visibility */
.search-section {
display: none;
}
.search-section.active {
display: block;
}
/* ========================================= */
/* ENHANCED SEARCH STYLING */
/* ========================================= */
/* Enhanced Search Bar */
.enhanced-search-bar-container {
background: linear-gradient(135deg, rgba(138, 43, 226, 0.15), rgba(75, 0, 130, 0.15));
border-radius: 16px;
border: 2px solid rgba(138, 43, 226, 0.3);
padding: 20px;
display: flex;
gap: 16px;
align-items: center;
box-shadow: 0 8px 24px rgba(138, 43, 226, 0.2);
backdrop-filter: blur(10px);
}
.enhanced-search-wrapper {
flex-grow: 1;
position: relative;
display: flex;
align-items: center;
background: rgba(20, 20, 20, 0.6);
border-radius: 12px;
border: 1px solid rgba(138, 43, 226, 0.4);
padding: 0 16px;
transition: all 0.3s ease;
}
.enhanced-search-wrapper:focus-within {
border-color: rgba(138, 43, 226, 0.8);
box-shadow: 0 0 0 3px rgba(138, 43, 226, 0.2);
}
.enhanced-search-icon {
font-size: 20px;
margin-right: 12px;
animation: sparkle 2s ease-in-out infinite;
}
@keyframes sparkle {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.7; transform: scale(1.1); }
}
#enhanced-search-input {
flex-grow: 1;
background: transparent;
border: none;
outline: none;
font-family: 'Segoe UI', sans-serif;
font-size: 15px;
color: #ffffff;
padding: 14px 0;
}
#enhanced-search-input::placeholder {
color: rgba(255, 255, 255, 0.5);
}
.enhanced-cancel-btn {
background: rgba(138, 43, 226, 0.3);
border: none;
border-radius: 6px;
padding: 6px 12px;
color: #ffffff;
cursor: pointer;
font-size: 14px;
transition: all 0.2s ease;
}
.enhanced-cancel-btn:hover {
background: rgba(138, 43, 226, 0.5);
}
.enhanced-search-btn {
background: linear-gradient(135deg, rgba(138, 43, 226, 0.9), rgba(123, 31, 162, 0.9));
border: none;
border-radius: 12px;
padding: 14px 28px;
color: #ffffff;
font-family: 'Segoe UI', sans-serif;
font-size: 14px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.3s ease;
box-shadow: 0 4px 12px rgba(138, 43, 226, 0.3);
}
.enhanced-search-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(138, 43, 226, 0.4);
}
.enhanced-search-btn:active {
transform: translateY(0);
}
.btn-icon {
font-size: 16px;
}
/* Enhanced Search Status */
.enhanced-search-status {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
margin-top: 16px;
background: rgba(20, 20, 20, 0.4);
border-radius: 10px;
border-left: 3px solid rgba(138, 43, 226, 0.8);
}
.enhanced-status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(138, 43, 226, 0.8);
animation: pulse-glow 2s ease-in-out infinite;
}
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 4px rgba(138, 43, 226, 0.6); }
50% { box-shadow: 0 0 12px rgba(138, 43, 226, 0.9); }
}
#enhanced-search-status-text {
font-family: 'Segoe UI', sans-serif;
font-size: 13px;
color: rgba(255, 255, 255, 0.8);
margin: 0;
}
/* Enhanced Search Results */
.enhanced-search-results-container {
background: rgba(20, 20, 20, 0.3);
border-radius: 16px;
border: 2px solid rgba(138, 43, 226, 0.2);
padding: 20px;
margin-top: 20px;
display: flex;
flex-direction: column;
flex-grow: 1;
overflow: hidden;
}
.enhanced-results-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(138, 43, 226, 0.2);
}
.enhanced-results-header h3 {
color: rgba(255, 255, 255, 0.95);
font-size: 16px;
font-weight: 600;
margin: 0;
}
.results-count-badge {
background: linear-gradient(135deg, rgba(138, 43, 226, 0.3), rgba(123, 31, 162, 0.3));
border: 1px solid rgba(138, 43, 226, 0.5);
border-radius: 20px;
padding: 6px 14px;
font-size: 12px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
}
.enhanced-results-scroll-area {
overflow-y: auto;
flex-grow: 1;
padding-right: 8px;
}
.enhanced-results-scroll-area::-webkit-scrollbar {
width: 8px;
}
.enhanced-results-scroll-area::-webkit-scrollbar-track {
background: rgba(20, 20, 20, 0.3);
border-radius: 4px;
}
.enhanced-results-scroll-area::-webkit-scrollbar-thumb {
background: linear-gradient(to bottom, rgba(138, 43, 226, 0.6), rgba(123, 31, 162, 0.6));
border-radius: 4px;
}
.enhanced-results-scroll-area::-webkit-scrollbar-thumb:hover {
background: linear-gradient(to bottom, rgba(138, 43, 226, 0.8), rgba(123, 31, 162, 0.8));
}
/* Enhanced Results Placeholder */
.enhanced-results-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
}
.placeholder-icon {
font-size: 48px;
margin-bottom: 16px;
animation: sparkle 2s ease-in-out infinite;
}
.placeholder-title {
font-size: 18px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
margin: 0 0 8px 0;
}
.placeholder-subtitle {
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
margin: 0;
}