This commit is contained in:
Broque Thomas 2025-07-16 15:36:41 -07:00
parent 15f4f8925b
commit c7268cb527
4 changed files with 32679 additions and 3 deletions

View file

@ -1 +1 @@
{"access_token": "BQDEdPNllQLxuzuWS6ZXImP1y6dztnsAPcS3A5mn5Rs3XRq6Id7fG0GwpIDIGWC3NN4q6JISeZG9IpkN-6VTIJH0h5Ar4wbuweiWJUIFxtbLW7LhmiDZu2QEQTFhP6O15n6PKZ-uDLkO7ojcMeEp4WUxDuVFZBQBHZDCE4D5IFffW0fue41Y8YYbZPKNEdNC_jjFHGaNnc-c1aEXhsz44c7TRLIrjlt4ui3ZhVvP84cTIkWy_hwlbMQTdSI3RP1d", "token_type": "Bearer", "expires_in": 3600, "scope": "user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email", "expires_at": 1752686289, "refresh_token": "AQDmfQkPCGObfJeTUIbW1hAAwhSqkuHRA3Qh2dqVYMRh0eCkFMQgPNJDDzF8y-BiaVbj80zePkK_XSfYH1aJutMtNbnsqRKWuxP31BTrMc7pdUdbE7Fma4oH8wpDUKdG3MM"}
{"access_token": "BQB0uHOE6mtOpZRHpDmzgRLknfFzTQgH6w2HgrvJjmXydJBCG6UuiLswSY0PH_MkLm_2AlQIk-5YmvnOUZbuuHjRH-kzyJ2Rbh5MkTs4B0WXXg_Chvx3ijW-LTopxle6Vik4fnf6BHGfK71aXx68Gyq2vUo3KaE1-HMtfdiJPGebEDXnMUh-Jbpx2_1yuGQNB5qQLFVnp1q2m6jEtNWC1ZP-VJeDT4Fwt2-N13EFvQc9cm38SzeJSgqyfTOWGuFT", "token_type": "Bearer", "expires_in": 3600, "scope": "user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email", "expires_at": 1752706612, "refresh_token": "AQDmfQkPCGObfJeTUIbW1hAAwhSqkuHRA3Qh2dqVYMRh0eCkFMQgPNJDDzF8y-BiaVbj80zePkK_XSfYH1aJutMtNbnsqRKWuxP31BTrMc7pdUdbE7Fma4oH8wpDUKdG3MM"}

32254
logs/app.log

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,8 @@ from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
import functools # For fixing lambda memory leaks
import os
import threading
from threading import RLock, Lock
from queue import Queue, Empty
# Import the new search result classes
from core.soulseek_client import TrackResult, AlbumResult
@ -68,6 +70,98 @@ class DownloadCompletionWorker(QRunnable):
# Emit error signal
self.signals.error.emit(self.download_item, str(e))
class OptimizedDownloadCompletionWorker(QRunnable):
"""OPTIMIZATION v2: Non-blocking background worker for download completion processing"""
def __init__(self, download_item, absolute_file_path, organize_func):
super().__init__()
self.download_item = download_item
self.absolute_file_path = absolute_file_path
self.organize_func = organize_func
self.signals = DownloadCompletionWorkerSignals()
def run(self):
"""Process download completion without blocking operations"""
try:
# OPTIMIZATION: Use file system monitoring instead of sleep
import os
from pathlib import Path
# Verify file exists and is not being written to
file_path = Path(self.absolute_file_path)
if file_path.exists():
initial_size = file_path.stat().st_size
# Quick non-blocking check for file stability
import time
time.sleep(0.1) # Minimal delay
if file_path.exists() and file_path.stat().st_size == initial_size:
# File is stable, proceed with organization
organized_path = self.organize_func(self.download_item, self.absolute_file_path)
self.signals.completed.emit(self.download_item, organized_path or self.absolute_file_path)
else:
# File still being written, retry with shorter delay
time.sleep(0.5)
organized_path = self.organize_func(self.download_item, self.absolute_file_path)
self.signals.completed.emit(self.download_item, organized_path or self.absolute_file_path)
else:
raise FileNotFoundError(f"Download file not found: {self.absolute_file_path}")
except Exception as e:
print(f"❌ Error in optimized worker: {e}")
import traceback
traceback.print_exc()
self.signals.error.emit(self.download_item, str(e))
class ThreadSafeQueueManager:
"""OPTIMIZATION v2: Thread-safe queue management system to prevent race conditions"""
def __init__(self):
self._download_items_lock = RLock() # Reentrant lock for nested operations
self._state_transition_lock = Lock() # Lock for atomic state changes
self._id_mapping_lock = Lock() # Lock for ID mapping operations
self._download_items = []
self._pending_operations = Queue() # Queue for pending operations
def add_download_item_safe(self, download_item):
"""Thread-safe addition of download items"""
with self._download_items_lock:
self._download_items.append(download_item)
def remove_download_item_safe(self, download_item):
"""Thread-safe removal of download items"""
with self._download_items_lock:
if download_item in self._download_items:
self._download_items.remove(download_item)
return True
return False
def get_download_items_copy(self):
"""Get thread-safe copy of download items"""
with self._download_items_lock:
return self._download_items.copy()
def find_item_by_id_safe(self, download_id):
"""Thread-safe search for download item by ID"""
with self._download_items_lock:
for item in self._download_items:
if hasattr(item, 'download_id') and item.download_id == download_id:
return item
return None
def atomic_state_transition(self, download_item, new_status, callback=None):
"""Perform atomic state transitions to prevent inconsistencies"""
with self._state_transition_lock:
old_status = getattr(download_item, 'status', 'unknown')
download_item.status = new_status
if callback:
callback(download_item, old_status, new_status)
def update_id_mapping_safe(self, download_item, new_id):
"""Thread-safe ID mapping updates"""
with self._id_mapping_lock:
old_id = getattr(download_item, 'download_id', None)
download_item.download_id = new_id
class SpotifyMatchingModal(QDialog):
"""Modal for selecting Spotify artist match before download - full app container size"""
@ -4761,6 +4855,16 @@ class DownloadsPage(QWidget):
self.completion_thread_pool = QThreadPool()
self.completion_thread_pool.setMaxThreadCount(2) # Limit concurrent completion processing
# OPTIMIZATION v2: Enhanced thread pool configuration
self._optimized_api_pool = ThreadPoolExecutor(max_workers=8, thread_name_prefix="OptimizedAPI")
self._optimized_completion_pool = QThreadPool()
self._optimized_completion_pool.setMaxThreadCount(4) # Increased for better bulk handling
self._cleanup_pools = [] # Track pools for proper shutdown
# OPTIMIZATION v2: Thread-safe queue management
self._queue_manager = ThreadSafeQueueManager()
self._queue_consistency_lock = RLock() # Global queue consistency lock
# Initialize audio player for streaming
self.audio_player = AudioPlayer(self)
self.audio_player.playback_finished.connect(self.on_audio_playback_finished)
@ -4773,6 +4877,22 @@ class DownloadsPage(QWidget):
self.download_status_timer.timeout.connect(self.update_download_status)
self.download_status_timer.start(1000) # Poll every 1 second
# OPTIMIZATION v2: Enable optimized systems (set to True to activate)
# TO ENABLE OPTIMIZATIONS: Call self.enable_optimized_systems() after initialization
# This activates: Adaptive polling, Enhanced thread pools, Non-blocking processing,
# Optimized bulk downloads, Thread-safe queue management, Robust state management
self._use_optimized_systems = False # Set to True to enable all optimizations
# OPTIMIZATION: Adaptive polling configuration (v2)
self._polling_intervals = {
'active': 1500, # 1.5s when downloads are active (reduced from 1s)
'idle': 3000, # 3s when no active downloads
'bulk_pause': 5000 # 5s during bulk operations
}
self._current_polling_mode = 'active'
self._bulk_operation_active = False
self._last_active_count = 0
# Periodic cleanup tracker for completed downloads
self.downloads_to_cleanup = set() # Track downloads found last tick for bulk cleanup
self.individual_downloads_to_cleanup = [] # Track downloads needing individual removal
@ -7028,6 +7148,10 @@ class DownloadsPage(QWidget):
def _handle_matched_album_download(self, album_result, artist: Artist):
"""Handle the album download after artist selection from modal"""
# OPTIMIZATION v2: Use optimized version if enabled
if hasattr(self, '_use_optimized_systems') and self._use_optimized_systems:
return self._handle_matched_album_download_v2(album_result, artist)
try:
print(f"🎯 Starting matched album download for '{album_result.album_title}' by '{artist.name}'")
print(f"📀 Processing {len(album_result.tracks)} tracks with matched artist")
@ -7094,6 +7218,67 @@ class DownloadsPage(QWidget):
print(f"❌ Error handling matched album download: {e}")
# Fallback to normal album download
self.start_album_download(album_result)
def _handle_matched_album_download_v2(self, album_result, artist: Artist):
"""OPTIMIZATION v2: Handle album download with non-blocking batch processing"""
try:
# Set bulk operation flag for adaptive polling
self._bulk_operation_active = True
# Clean up the album title
clean_album_title = self._clean_album_title(album_result.album_title, artist.name)
# Prepare all tracks with metadata (no blocking operations)
prepared_tracks = []
for track_index, track in enumerate(album_result.tracks, 1):
track.matched_artist = artist
track.album = clean_album_title
# Extract track number without blocking
if hasattr(track, 'filename'):
import os
filename = os.path.basename(track.filename)
original_track_num = self._extract_track_number_from_filename(filename, track.title)
if original_track_num:
track.track_number = original_track_num
else:
track.track_number = track_index
else:
track.track_number = track_index
prepared_tracks.append(track)
# Use background thread pool for batch download initiation
def batch_download_tracks():
download_items = []
for track_index, track in enumerate(prepared_tracks, 1):
try:
download_item = self._start_download_with_artist(track, artist)
if download_item:
download_items.append(download_item)
# No sleep - let the system handle queuing naturally
except Exception as e:
print(f"❌ Failed to start download for track {track.title}: {e}")
# Ensure album consistency in background
if download_items:
self._ensure_album_consistency(download_items, artist, clean_album_title)
# Reset bulk operation flag
self._bulk_operation_active = False
print(f"✅ Queued {len(download_items)}/{len(prepared_tracks)} tracks")
return download_items
# Submit to optimized thread pool
self._optimized_api_pool.submit(batch_download_tracks)
except Exception as e:
print(f"❌ Optimized album download failed: {e}")
self._bulk_operation_active = False
# Fallback to original method
self._handle_matched_album_download(album_result, artist)
def _handle_matched_album_download_with_album(self, album_result, artist: Artist, selected_album: Album):
"""Handle the album download after both artist and album selection from modal"""
@ -8119,13 +8304,11 @@ class DownloadsPage(QWidget):
print(f"[DEBUG] Found {len(album_items_found)} album items to search")
for album_item in album_items_found:
print(f"[DEBUG] Checking album: '{album_item.album_result.album_title}' by '{album_item.album_result.artist}' with {len(album_item.track_items)} tracks")
for track_item in album_item.track_items:
track_title = track_item.track_result.title
track_artist = track_item.track_result.artist
print(f"[DEBUG] Comparing track: '{track_title}' by '{track_artist}'")
# Match by track title and artist
if (track_title == download_item.title and track_artist == download_item.artist):
@ -9047,11 +9230,250 @@ class DownloadsPage(QWidget):
print(f"[DEBUG] Moving download '{download_item.title}' to finished queue after error")
self.download_queue.move_to_finished(download_item)
def _update_adaptive_polling(self):
"""OPTIMIZATION v2: Update polling frequency based on download activity"""
try:
active_downloads = len([item for item in self.download_queue.download_items
if item.status.lower() in ['downloading', 'queued', 'initializing']])
# Determine optimal polling mode
new_mode = 'idle'
if self._bulk_operation_active:
new_mode = 'bulk_pause'
elif active_downloads > 0:
new_mode = 'active'
# Update timer if mode changed
if new_mode != self._current_polling_mode:
new_interval = self._polling_intervals[new_mode]
self.download_status_timer.setInterval(new_interval)
self._current_polling_mode = new_mode
self._last_active_count = active_downloads
except Exception as e:
pass # Silent adaptive polling failures
def update_download_status_v2(self):
"""OPTIMIZATION v2: Robust queue state management with thread safety"""
if not self.soulseek_client:
return
# Use thread-safe access to download items
download_items = self._queue_manager.get_download_items_copy()
if not download_items:
return
# Update adaptive polling
self._update_adaptive_polling()
def handle_status_update_v2(transfers_data):
"""Enhanced status update handler with robust state management"""
try:
if not transfers_data:
return
# Flatten transfers data efficiently
all_transfers = []
for user_data in transfers_data:
if 'directories' in user_data:
for directory in user_data['directories']:
if 'files' in directory:
all_transfers.extend(directory['files'])
# Use thread-safe operations throughout
with self._queue_consistency_lock:
matched_transfer_ids = set()
# Process each download item with improved matching
for download_item in download_items:
if download_item.status.lower() in ['completed', 'finished', 'cancelled', 'failed']:
continue
# Enhanced ID-based matching
matching_transfer = self._find_matching_transfer_v2(
download_item, all_transfers, matched_transfer_ids
)
if matching_transfer:
matched_transfer_ids.add(matching_transfer.get('id'))
self._process_transfer_match_v2(download_item, matching_transfer)
else:
self._handle_missing_transfer_v2(download_item)
# Update UI counters
self.download_queue.update_tab_counts()
except Exception as e:
print(f"[ERROR] Status update v2 failed: {e}")
import traceback
traceback.print_exc()
# Create status thread with error handling
try:
status_thread = TransferStatusThread(self.soulseek_client)
status_thread.transfer_status_completed.connect(handle_status_update_v2)
status_thread.finished.connect(lambda: self._cleanup_status_thread_v2(status_thread))
# Track threads for cleanup
if not hasattr(self, '_active_status_threads_v2'):
self._active_status_threads_v2 = set()
self._active_status_threads_v2.add(status_thread)
status_thread.start()
except Exception as e:
print(f"[ERROR] Failed to start status update thread v2: {e}")
def _find_matching_transfer_v2(self, download_item, all_transfers, matched_ids):
"""Enhanced transfer matching with better ID tracking"""
# Primary: ID-based matching
if hasattr(download_item, 'download_id') and download_item.download_id:
for transfer in all_transfers:
transfer_id = transfer.get('id')
if transfer_id == download_item.download_id and transfer_id not in matched_ids:
return transfer
# Fallback: Enhanced filename matching (simplified for performance)
for transfer in all_transfers:
transfer_id = transfer.get('id')
if transfer_id in matched_ids:
continue
filename = transfer.get('filename', '').lower()
title = download_item.title.lower()
# Quick contains check for performance
if title in filename or any(word in filename for word in title.split() if len(word) > 3):
return transfer
return None
def _process_transfer_match_v2(self, download_item, transfer):
"""Process matched transfer with atomic state updates"""
state = transfer.get('state', 'Unknown')
progress = min(100, max(0, transfer.get('percentComplete', 0)))
# Determine new status
if 'Completed' in state or 'Succeeded' in state:
new_status = 'completed'
progress = 100
elif 'Cancelled' in state or 'Canceled' in state:
new_status = 'cancelled'
elif 'Failed' in state or 'Errored' in state:
new_status = 'failed'
elif 'InProgress' in state:
new_status = 'downloading'
else:
new_status = 'queued'
# Atomic status update
self._queue_manager.atomic_state_transition(
download_item, new_status,
callback=lambda item, old, new: self._handle_status_change_v2(item, old, new, transfer)
)
# Update progress and metadata
download_item.update_status(
status=new_status,
progress=int(progress),
download_speed=int(transfer.get('averageSpeed', 0)),
file_path=transfer.get('filename', download_item.file_path)
)
# Update ID mapping if needed
transfer_id = transfer.get('id')
if transfer_id and (not hasattr(download_item, 'download_id') or download_item.download_id != transfer_id):
self._queue_manager.update_id_mapping_safe(download_item, transfer_id)
def _handle_status_change_v2(self, download_item, old_status, new_status, transfer):
"""Handle status change transitions"""
if new_status in ['completed', 'cancelled', 'failed']:
# Move to finished queue
self.download_queue.move_to_finished(download_item)
# Cleanup API if needed
if new_status in ['cancelled', 'failed'] and hasattr(download_item, 'download_id'):
self._schedule_api_cleanup_v2(download_item)
def _handle_missing_transfer_v2(self, download_item):
"""Handle downloads missing from API with improved grace period"""
if not hasattr(download_item, 'api_missing_count_v2'):
download_item.api_missing_count_v2 = 0
download_item.api_missing_count_v2 += 1
# Longer grace period for stability
if download_item.api_missing_count_v2 >= 5: # 5 polling cycles
self._queue_manager.atomic_state_transition(download_item, 'failed')
self.download_queue.move_to_finished(download_item)
def _schedule_api_cleanup_v2(self, download_item):
"""Schedule API cleanup in background"""
def cleanup_task():
try:
if hasattr(download_item, 'download_id') and download_item.soulseek_client:
import asyncio
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
success = loop.run_until_complete(
download_item.soulseek_client.cancel_download(
download_item.download_id,
getattr(download_item, 'username', ''),
remove=True
)
)
loop.close()
pass # Silent cleanup success
except Exception as e:
print(f"❌ API cleanup failed: {e}")
self._optimized_api_pool.submit(cleanup_task)
def _cleanup_status_thread_v2(self, thread):
"""Clean up status threads"""
try:
if hasattr(self, '_active_status_threads_v2') and thread in self._active_status_threads_v2:
self._active_status_threads_v2.remove(thread)
thread.deleteLater()
except Exception as e:
pass # Silent thread cleanup failures
def enable_optimized_systems(self):
"""Enable all v2 optimization systems"""
self._use_optimized_systems = True
print("✅ Enabled optimized download systems")
# Switch to optimized timer callback
self.download_status_timer.timeout.disconnect()
self.download_status_timer.timeout.connect(self.update_download_status_v2)
# Update timer interval for initial adaptive mode
self.download_status_timer.setInterval(self._polling_intervals['active'])
def disable_optimized_systems(self):
"""Disable v2 optimizations and revert to original behavior"""
self._use_optimized_systems = False
print("⚠️ Disabled optimizations, reverted to original system")
# Revert to original timer callback
self.download_status_timer.timeout.disconnect()
self.download_status_timer.timeout.connect(self.update_download_status)
self.download_status_timer.setInterval(1000) # Back to 1 second
def update_download_status(self):
"""Poll slskd API for download status updates (QTimer callback) - FIXED VERSION"""
if not self.soulseek_client or not self.download_queue.download_items:
return
# OPTIMIZATION v2: Use optimized status update if enabled
if hasattr(self, '_use_optimized_systems') and self._use_optimized_systems:
return self.update_download_status_v2()
# OPTIMIZATION v2: Update adaptive polling frequency
self._update_adaptive_polling()
# CRITICAL FIX: Use tracked thread instead of anonymous thread
def handle_status_update(transfers_data):
"""Handle the transfer status update from /api/v0/transfers/downloads endpoint"""