11364 lines
No EOL
513 KiB
Python
11364 lines
No EOL
513 KiB
Python
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
||
QFrame, QPushButton, QProgressBar, QListWidget,
|
||
QListWidgetItem, QComboBox, QLineEdit, QScrollArea, QMessageBox,
|
||
QSplitter, QSizePolicy, QSpacerItem, QTabWidget, QDialog, QGridLayout)
|
||
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer, QUrl, QPropertyAnimation, QEasingCurve, QParallelAnimationGroup, QFileSystemWatcher, pyqtProperty, QObject, QRunnable, QThreadPool
|
||
from PyQt6.QtGui import QFont, QPainter, QPen, QColor, QPixmap
|
||
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
|
||
from config.settings import config_manager
|
||
# Import the new search result classes
|
||
from core.soulseek_client import TrackResult, AlbumResult
|
||
from core.spotify_client import SpotifyClient, Artist, Album
|
||
from core.matching_engine import MusicMatchingEngine
|
||
from core.lyrics_client import LyricsClient
|
||
import requests
|
||
from typing import List, Optional
|
||
from dataclasses import dataclass
|
||
# Metadata enhancement imports
|
||
from mutagen import File as MutagenFile
|
||
from mutagen.id3 import ID3, TIT2, TPE1, TALB, TDRC, TRCK, TCON, TPE2, TPOS, TXXX, APIC
|
||
from mutagen.flac import FLAC, Picture
|
||
from mutagen.mp4 import MP4, MP4Cover
|
||
from mutagen.oggvorbis import OggVorbis
|
||
import urllib.request
|
||
import mimetypes
|
||
|
||
@dataclass
|
||
class ArtistMatch:
|
||
"""Represents an artist match with confidence score"""
|
||
artist: Artist
|
||
confidence: float
|
||
match_reason: str = ""
|
||
|
||
@dataclass
|
||
class AlbumMatch:
|
||
"""Represents an album match with confidence score"""
|
||
album: Album
|
||
confidence: float
|
||
match_reason: str = ""
|
||
|
||
|
||
class ImageDownloaderSignals(QObject):
|
||
"""Signals for the ImageDownloader worker."""
|
||
finished = pyqtSignal(QLabel, QPixmap)
|
||
error = pyqtSignal(str)
|
||
|
||
class ImageDownloader(QRunnable):
|
||
"""Worker to download an image in the background."""
|
||
def __init__(self, url: str, target_label: QLabel):
|
||
super().__init__()
|
||
self.signals = ImageDownloaderSignals()
|
||
self.url = url
|
||
self.target_label = target_label
|
||
|
||
def run(self):
|
||
try:
|
||
if not self.url:
|
||
self.signals.error.emit("No image URL provided.")
|
||
return
|
||
|
||
response = requests.get(self.url, stream=True, timeout=10)
|
||
response.raise_for_status()
|
||
|
||
pixmap = QPixmap()
|
||
pixmap.loadFromData(response.content)
|
||
|
||
if not pixmap.isNull():
|
||
self.signals.finished.emit(self.target_label, pixmap)
|
||
else:
|
||
self.signals.error.emit("Failed to load image from data.")
|
||
|
||
except requests.RequestException as e:
|
||
self.signals.error.emit(f"Network error downloading image: {e}")
|
||
except Exception as e:
|
||
self.signals.error.emit(f"Error processing image: {e}")
|
||
|
||
|
||
class DownloadCompletionWorkerSignals(QObject):
|
||
"""Signals for the download completion worker"""
|
||
completed = pyqtSignal(object, str) # download_item, organized_path
|
||
error = pyqtSignal(object, str) # download_item, error_message
|
||
|
||
class DownloadCompletionWorker(QRunnable):
|
||
"""Background worker to handle download completion processing without blocking UI"""
|
||
|
||
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 in background thread"""
|
||
try:
|
||
print(f"🧵 Background worker processing: '{self.download_item.title}' by '{self.download_item.matched_artist.name}'")
|
||
|
||
# Add a small delay to ensure file is fully written
|
||
import time
|
||
time.sleep(1)
|
||
|
||
# Organize the file into Transfer folder structure
|
||
organized_path = self.organize_func(self.download_item, self.absolute_file_path)
|
||
|
||
# Emit completion signal
|
||
self.signals.completed.emit(self.download_item, organized_path or self.absolute_file_path)
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error in background worker: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
# Emit error signal
|
||
self.signals.error.emit(self.download_item, str(e))
|
||
|
||
|
||
# OPTIMIZATION: This worker runs the existing status update logic in the background.
|
||
class StatusProcessingWorkerSignals(QObject):
|
||
"""Defines the signals available from the StatusProcessingWorker."""
|
||
completed = pyqtSignal(list)
|
||
error = pyqtSignal(str)
|
||
|
||
class StatusProcessingWorker(QRunnable):
|
||
"""
|
||
Runs the expensive download status processing in a background thread to prevent UI lag.
|
||
It performs all the matching and logic, then returns a list of actions for the main thread to execute.
|
||
"""
|
||
def __init__(self, soulseek_client, download_items):
|
||
super().__init__()
|
||
self.signals = StatusProcessingWorkerSignals()
|
||
self.soulseek_client = soulseek_client
|
||
# We operate on a copy of the item data to avoid thread conflicts
|
||
self.download_items_data = [{
|
||
'widget_id': id(item),
|
||
'download_id': item.download_id,
|
||
'title': item.title,
|
||
'artist': item.artist,
|
||
'file_path': item.file_path,
|
||
'status': item.status,
|
||
'matched_artist': getattr(item, 'matched_artist', None)
|
||
} for item in download_items]
|
||
|
||
def run(self):
|
||
"""The main logic of the background worker."""
|
||
try:
|
||
# This is the core of your original update_download_status method.
|
||
import asyncio
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
|
||
transfers_data = loop.run_until_complete(
|
||
self.soulseek_client._make_request('GET', 'transfers/downloads')
|
||
)
|
||
loop.close()
|
||
|
||
results = []
|
||
if not transfers_data:
|
||
self.signals.completed.emit([])
|
||
return
|
||
|
||
all_transfers = [
|
||
file for user_data in transfers_data if 'directories' in user_data
|
||
for directory in user_data['directories'] if 'files' in directory
|
||
for file in directory['files']
|
||
]
|
||
|
||
# Create a lookup for faster matching
|
||
transfers_by_id = {t['id']: t for t in all_transfers}
|
||
|
||
# --- This is your existing, working logic, now running in the background ---
|
||
for item_data in self.download_items_data:
|
||
if item_data['status'].lower() in ['completed', 'finished', 'cancelled', 'failed']:
|
||
continue
|
||
|
||
matching_transfer = transfers_by_id.get(item_data['download_id'])
|
||
|
||
if not matching_transfer:
|
||
# Fallback to filename matching if ID match fails
|
||
import os
|
||
dl_filename = os.path.basename(item_data['file_path']).lower()
|
||
for t in all_transfers:
|
||
if os.path.basename(t.get('filename', '')).lower() == dl_filename:
|
||
matching_transfer = t
|
||
break
|
||
|
||
if matching_transfer:
|
||
state = matching_transfer.get('state', 'Unknown')
|
||
progress = matching_transfer.get('percentComplete', 0)
|
||
new_status = 'queued'
|
||
|
||
# Determine the new status with the correct order of checks.
|
||
# Terminal states like Cancelled and Failed must be checked BEFORE Completed.
|
||
if 'Cancelled' in state or 'Canceled' in state:
|
||
new_status = 'cancelled'
|
||
elif 'Failed' in state or 'Errored' in state:
|
||
new_status = 'failed'
|
||
elif 'Completed' in state or 'Succeeded' in state:
|
||
new_status = 'completed'
|
||
elif 'InProgress' in state:
|
||
new_status = 'downloading'
|
||
else:
|
||
new_status = 'queued'
|
||
|
||
# **CRITICAL FIX:** Package all necessary data for the main thread.
|
||
payload = {
|
||
'widget_id': item_data['widget_id'],
|
||
'status': new_status,
|
||
'progress': int(progress),
|
||
'speed': int(matching_transfer.get('averageSpeed', 0)),
|
||
'path': matching_transfer.get('filename', item_data['file_path']),
|
||
'transfer_id': matching_transfer.get('id'), # This is key for cleanup
|
||
'username': matching_transfer.get('username') # Also key for cleanup
|
||
}
|
||
|
||
if new_status == 'completed' and item_data['matched_artist']:
|
||
payload['action'] = 'process_matched_completion'
|
||
|
||
results.append(payload)
|
||
else:
|
||
# This handles downloads that have disappeared from the API (e.g., failed, cancelled by user).
|
||
# We add a grace period before marking as failed to handle temporary API glitches.
|
||
item_data['api_missing_count'] = item_data.get('api_missing_count', 0) + 1
|
||
|
||
if item_data['api_missing_count'] >= 3:
|
||
# After being missing for 3 update cycles (3 seconds), mark it as failed.
|
||
payload = {
|
||
'widget_id': item_data['widget_id'],
|
||
'status': 'failed',
|
||
'progress': item_data.get('progress', 0),
|
||
'speed': 0,
|
||
'path': item_data['file_path'],
|
||
'transfer_id': item_data['download_id'],
|
||
'username': item_data['artist']
|
||
}
|
||
results.append(payload)
|
||
|
||
self.signals.completed.emit(results)
|
||
except Exception as e:
|
||
import traceback
|
||
traceback.print_exc()
|
||
self.signals.error.emit(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):
|
||
"""A redesigned modal for matching downloads to Spotify artists and albums."""
|
||
|
||
match_confirmed = pyqtSignal(Artist, Album)
|
||
cancelled = pyqtSignal()
|
||
|
||
def __init__(self, original_result: object, spotify_client: SpotifyClient, matching_engine: MusicMatchingEngine, parent=None, is_album=False, album_result=None):
|
||
super().__init__(parent)
|
||
self.original_result = original_result
|
||
self.spotify_client = spotify_client
|
||
self.matching_engine = matching_engine
|
||
self.is_album = is_album
|
||
self.album_result = album_result
|
||
self.image_download_pool = QThreadPool()
|
||
self.image_download_pool.setMaxThreadCount(4)
|
||
self.selected_artist: Optional[Artist] = None
|
||
self.selected_album: Optional[Album] = None
|
||
|
||
self.setWindowTitle("Match Download to Spotify")
|
||
self.setModal(True)
|
||
self.resize(1100, 750)
|
||
|
||
self.setStyleSheet("""
|
||
QDialog { background-color: #121212; border: none; }
|
||
QLabel { color: #FFFFFF; }
|
||
QPushButton {
|
||
background-color: #1DB954; color: #FFFFFF; border: none;
|
||
border-radius: 15px; padding: 10px 20px; font-size: 14px; font-weight: 600;
|
||
}
|
||
QPushButton:hover { background-color: #1ED760; }
|
||
QPushButton:disabled { background-color: #2A2A2A; color: #535353; }
|
||
QPushButton#cancel, QPushButton#skip { background-color: #535353; }
|
||
QPushButton#cancel:hover, QPushButton#skip:hover { background-color: #6A6A6A; }
|
||
QLineEdit {
|
||
background-color: #2A2A2A; border: 2px solid #535353; border-radius: 15px;
|
||
color: white; padding: 12px; font-size: 14px;
|
||
}
|
||
QLineEdit:focus { border: 2px solid #1DB954; }
|
||
QFrame#card {
|
||
background-color: #1E1E1E; border: 2px solid #2A2A2A; border-radius: 12px;
|
||
}
|
||
QFrame#card:hover { border: 2px solid #1DB954; background-color: #282828; }
|
||
""")
|
||
|
||
self.current_stage = "artist"
|
||
self.setup_ui()
|
||
self.generate_auto_artist_suggestions()
|
||
|
||
def setup_ui(self):
|
||
main_layout = QVBoxLayout(self)
|
||
main_layout.setContentsMargins(20, 20, 20, 20)
|
||
main_layout.setSpacing(15)
|
||
|
||
# Different header text based on mode
|
||
if self.is_album:
|
||
header_text = "Step 1: Select the correct Artist"
|
||
else:
|
||
header_text = "Select the correct Artist for this Single"
|
||
self.header_label = QLabel(header_text)
|
||
self.header_label.setStyleSheet("font-size: 22px; font-weight: bold; color: #1DB954;")
|
||
main_layout.addWidget(self.header_label)
|
||
|
||
auto_title = QLabel("Top Suggestions")
|
||
auto_title.setStyleSheet("font-size: 16px; color: #B3B3B3;")
|
||
self.auto_suggestions_layout = QHBoxLayout()
|
||
self.auto_suggestions_layout.setSpacing(20)
|
||
|
||
main_layout.addWidget(auto_title)
|
||
main_layout.addLayout(self.auto_suggestions_layout)
|
||
|
||
manual_title = QLabel("Or, Search Manually")
|
||
manual_title.setStyleSheet("font-size: 16px; color: #B3B3B3; margin-top: 15px;")
|
||
self.search_input = QLineEdit()
|
||
self.search_input.setPlaceholderText("Manually search for an artist...")
|
||
self.search_input.textChanged.connect(self.on_search_text_changed)
|
||
self.manual_results_layout = QHBoxLayout()
|
||
self.manual_results_layout.setSpacing(20)
|
||
|
||
main_layout.addWidget(manual_title)
|
||
main_layout.addWidget(self.search_input)
|
||
main_layout.addLayout(self.manual_results_layout)
|
||
main_layout.addStretch()
|
||
|
||
button_layout = QHBoxLayout()
|
||
self.confirm_btn = QPushButton("Confirm Selection")
|
||
self.confirm_btn.clicked.connect(self.confirm_selection)
|
||
self.confirm_btn.setEnabled(False)
|
||
|
||
skip_btn = QPushButton("Skip Matching")
|
||
skip_btn.setObjectName("skip")
|
||
skip_btn.clicked.connect(self.skip_matching)
|
||
|
||
cancel_btn = QPushButton("Cancel")
|
||
cancel_btn.setObjectName("cancel")
|
||
cancel_btn.clicked.connect(self.reject)
|
||
|
||
button_layout.addWidget(skip_btn)
|
||
button_layout.addStretch()
|
||
button_layout.addWidget(self.confirm_btn)
|
||
button_layout.addWidget(cancel_btn)
|
||
main_layout.addLayout(button_layout)
|
||
|
||
self.search_timer = QTimer()
|
||
self.search_timer.setSingleShot(True)
|
||
self.search_timer.timeout.connect(self.perform_manual_search)
|
||
|
||
def download_and_set_image(self, url: str, target_label: QLabel):
|
||
"""Starts a background worker to download and set an image."""
|
||
worker = ImageDownloader(url, target_label)
|
||
worker.signals.finished.connect(self._on_image_downloaded)
|
||
worker.signals.error.connect(lambda msg: print(f"Image Error: {msg} for url {url}"))
|
||
self.image_download_pool.start(worker)
|
||
|
||
def _on_image_downloaded(self, target_label: QLabel, pixmap: QPixmap):
|
||
"""Slot to apply the downloaded pixmap to the target label."""
|
||
if target_label and not pixmap.isNull():
|
||
# Scale pixmap to fill the label, cropping if necessary
|
||
scaled_pixmap = pixmap.scaled(target_label.size(),
|
||
Qt.AspectRatioMode.KeepAspectRatioByExpanding,
|
||
Qt.TransformationMode.SmoothTransformation)
|
||
target_label.setPixmap(scaled_pixmap)
|
||
|
||
def generate_auto_artist_suggestions(self):
|
||
self._clear_layout(self.auto_suggestions_layout)
|
||
self._show_loading_cards(self.auto_suggestions_layout, "Finding artist...")
|
||
self.suggestion_thread = ArtistSuggestionThread(
|
||
self.original_result, self.spotify_client, self.matching_engine, self.is_album, self.album_result
|
||
)
|
||
self.suggestion_thread.suggestions_ready.connect(self.display_artist_suggestions)
|
||
self.suggestion_thread.start()
|
||
|
||
def generate_auto_album_suggestions(self):
|
||
self._clear_layout(self.auto_suggestions_layout)
|
||
self._show_loading_cards(self.auto_suggestions_layout, "Finding album...")
|
||
|
||
# Use the correct result object for album suggestions.
|
||
# If it's an album download, use the full album_result for context.
|
||
# Otherwise, use the original_result (a single track).
|
||
context_result = self.album_result if self.is_album and self.album_result else self.original_result
|
||
|
||
self.album_suggestion_thread = AlbumSuggestionThread(
|
||
self.selected_artist, context_result, self.spotify_client, self.matching_engine
|
||
)
|
||
self.album_suggestion_thread.suggestions_ready.connect(self.display_album_suggestions)
|
||
self.album_suggestion_thread.start()
|
||
|
||
def display_artist_suggestions(self, suggestions: List[ArtistMatch]):
|
||
self._clear_layout(self.auto_suggestions_layout)
|
||
if not suggestions:
|
||
self.auto_suggestions_layout.addWidget(QLabel("No automatic artist matches found."))
|
||
return
|
||
self.auto_suggestions_layout.addStretch()
|
||
for suggestion in suggestions[:4]:
|
||
self.auto_suggestions_layout.addWidget(self.create_artist_card(suggestion.artist, suggestion.confidence))
|
||
self.auto_suggestions_layout.addStretch()
|
||
|
||
def display_album_suggestions(self, suggestions: List[AlbumMatch]):
|
||
self._clear_layout(self.auto_suggestions_layout)
|
||
if not suggestions:
|
||
self.auto_suggestions_layout.addWidget(QLabel(f"No automatic album matches found for {self.selected_artist.name}."))
|
||
return
|
||
self.auto_suggestions_layout.addStretch()
|
||
for suggestion in suggestions[:4]:
|
||
self.auto_suggestions_layout.addWidget(self.create_album_card(suggestion.album, suggestion.confidence))
|
||
self.auto_suggestions_layout.addStretch()
|
||
|
||
def on_search_text_changed(self):
|
||
self.search_timer.stop()
|
||
if len(self.search_input.text().strip()) >= 2:
|
||
self.search_timer.start(400)
|
||
|
||
def perform_manual_search(self):
|
||
query = self.search_input.text().strip()
|
||
if not query: return
|
||
|
||
self._clear_layout(self.manual_results_layout)
|
||
|
||
if self.current_stage == "artist":
|
||
self._show_loading_cards(self.manual_results_layout, "Searching artists...")
|
||
self.search_thread = ArtistSearchThread(query, self.spotify_client, self.matching_engine, self.original_result)
|
||
self.search_thread.search_results.connect(self.display_manual_artist_results)
|
||
self.search_thread.start()
|
||
else:
|
||
self._show_loading_cards(self.manual_results_layout, "Searching albums...")
|
||
self.search_thread = AlbumSearchThread(query, self.selected_artist, self.spotify_client, self.matching_engine)
|
||
self.search_thread.search_results.connect(self.display_manual_album_results)
|
||
self.search_thread.start()
|
||
|
||
def display_manual_artist_results(self, results: List[ArtistMatch]):
|
||
self._clear_layout(self.manual_results_layout)
|
||
if not results:
|
||
self.manual_results_layout.addWidget(QLabel("No artists found."))
|
||
return
|
||
self.manual_results_layout.addStretch()
|
||
for result in results[:4]:
|
||
self.manual_results_layout.addWidget(self.create_artist_card(result.artist, result.confidence))
|
||
self.manual_results_layout.addStretch()
|
||
|
||
def display_manual_album_results(self, results: List[AlbumMatch]):
|
||
self._clear_layout(self.manual_results_layout)
|
||
if not results:
|
||
self.manual_results_layout.addWidget(QLabel("No albums found."))
|
||
return
|
||
self.manual_results_layout.addStretch()
|
||
for result in results[:4]:
|
||
self.manual_results_layout.addWidget(self.create_album_card(result.album, result.confidence))
|
||
self.manual_results_layout.addStretch()
|
||
|
||
def select_artist(self, artist: Artist):
|
||
self.selected_artist = artist
|
||
print(f"Artist selected: {artist.name}")
|
||
|
||
# For singles mode, we can confirm with just the artist selection
|
||
if not self.is_album:
|
||
self.confirm_btn.setEnabled(True)
|
||
self.confirm_btn.setText(f"Confirm: {artist.name[:30]}...")
|
||
# Update header to indicate completion
|
||
self.header_label.setText("✅ Artist Selected - Ready to Download")
|
||
else:
|
||
# For album mode, proceed to album selection
|
||
self.transition_to_album_stage()
|
||
|
||
def select_album(self, album: Album):
|
||
self.selected_album = album
|
||
print(f"Album selected: {album.name}")
|
||
self.confirm_btn.setEnabled(True)
|
||
self.confirm_btn.setText(f"Confirm: {album.name[:25]}...")
|
||
|
||
def transition_to_album_stage(self):
|
||
self.current_stage = "album"
|
||
self.header_label.setText(f"Step 2: Select Album for {self.selected_artist.name}")
|
||
self.search_input.setPlaceholderText("Manually search for an album...")
|
||
self.search_input.clear()
|
||
self.confirm_btn.setEnabled(False)
|
||
self.confirm_btn.setText("Confirm Selection")
|
||
self._clear_layout(self.manual_results_layout)
|
||
self.generate_auto_album_suggestions()
|
||
|
||
def confirm_selection(self):
|
||
if self.selected_artist:
|
||
# For singles mode, we only need the artist
|
||
if not self.is_album:
|
||
# Create a dummy album object for singles mode
|
||
dummy_album = Album(
|
||
id="singles-dummy",
|
||
name="Singles Collection",
|
||
artists=[self.selected_artist.name],
|
||
total_tracks=1,
|
||
release_date="",
|
||
album_type="single"
|
||
)
|
||
self.match_confirmed.emit(self.selected_artist, dummy_album)
|
||
self.accept()
|
||
# For album mode, we need both artist and album
|
||
elif self.selected_album:
|
||
self.match_confirmed.emit(self.selected_artist, self.selected_album)
|
||
self.accept()
|
||
|
||
def skip_matching(self):
|
||
self.skipped_matching = True
|
||
self.reject()
|
||
|
||
def reject(self):
|
||
if not hasattr(self, 'skipped_matching'):
|
||
self.skipped_matching = False
|
||
# Clean up the image download pool
|
||
self.image_download_pool.clear()
|
||
self.image_download_pool.waitForDone(5000) # Wait max 5 seconds for tasks to finish
|
||
self.cancelled.emit()
|
||
super().reject()
|
||
|
||
def create_artist_card(self, artist: Artist, confidence: float) -> QFrame:
|
||
card = QFrame()
|
||
card.setObjectName("card")
|
||
card.setFixedSize(220, 130)
|
||
# The main card needs a layout to stack widgets
|
||
card_layout = QGridLayout(card)
|
||
card_layout.setContentsMargins(0, 0, 0, 0)
|
||
|
||
# 1. Background Layer (for the image)
|
||
background_label = QLabel()
|
||
background_label.setScaledContents(True)
|
||
background_label.setStyleSheet("border-radius: 12px;")
|
||
|
||
# 2. Gradient Overlay Layer (for text readability)
|
||
gradient_overlay = QWidget()
|
||
gradient_overlay.setStyleSheet("""
|
||
QWidget {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(18, 18, 18, 0.4),
|
||
stop:0.6 rgba(18, 18, 18, 0.7),
|
||
stop:1 rgba(18, 18, 18, 0.9));
|
||
border-radius: 12px;
|
||
}
|
||
""")
|
||
|
||
# 3. Content Layer (the text)
|
||
content_layout = QVBoxLayout(gradient_overlay)
|
||
content_layout.setContentsMargins(12, 12, 12, 12)
|
||
|
||
name = QLabel(artist.name)
|
||
name.setWordWrap(True)
|
||
name.setStyleSheet("font-size: 16px; font-weight: bold; color: white; background: transparent; border: none;")
|
||
|
||
confidence_label = QLabel(f"{confidence:.0%} match")
|
||
confidence_label.setStyleSheet("color: #B3B3B3; font-size: 12px; background: transparent; border: none;")
|
||
|
||
content_layout.addWidget(name)
|
||
content_layout.addStretch()
|
||
content_layout.addWidget(confidence_label)
|
||
|
||
# Stack the layers
|
||
card_layout.addWidget(background_label, 0, 0)
|
||
card_layout.addWidget(gradient_overlay, 0, 0)
|
||
|
||
# Set up interaction
|
||
card.mousePressEvent = lambda event: self.select_artist(artist)
|
||
|
||
# Fetch the artist image in the background
|
||
if hasattr(artist, 'image_url') and artist.image_url:
|
||
self.download_and_set_image(artist.image_url, background_label)
|
||
|
||
return card
|
||
|
||
def create_album_card(self, album: Album, confidence: float) -> QFrame:
|
||
card = QFrame()
|
||
card.setObjectName("card")
|
||
card.setFixedSize(220, 130)
|
||
# The main card needs a layout to stack widgets
|
||
card_layout = QGridLayout(card)
|
||
card_layout.setContentsMargins(0, 0, 0, 0)
|
||
|
||
# 1. Background Layer (for the image)
|
||
background_label = QLabel()
|
||
background_label.setScaledContents(True)
|
||
background_label.setStyleSheet("border-radius: 12px;")
|
||
|
||
# 2. Gradient Overlay Layer (for text readability)
|
||
gradient_overlay = QWidget()
|
||
gradient_overlay.setStyleSheet("""
|
||
QWidget {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(18, 18, 18, 0.4),
|
||
stop:0.6 rgba(18, 18, 18, 0.7),
|
||
stop:1 rgba(18, 18, 18, 0.9));
|
||
border-radius: 12px;
|
||
}
|
||
""")
|
||
|
||
# 3. Content Layer (the text)
|
||
content_layout = QVBoxLayout(gradient_overlay)
|
||
content_layout.setContentsMargins(12, 12, 12, 12)
|
||
|
||
name = QLabel(album.name)
|
||
name.setWordWrap(True)
|
||
name.setStyleSheet("font-size: 14px; font-weight: bold; color: white; background: transparent; border: none;")
|
||
|
||
year = album.release_date.split('-')[0] if album.release_date else ""
|
||
details = QLabel(f"{album.album_type.title()} • {year}")
|
||
details.setStyleSheet("color: #B3B3B3; font-size: 12px; background: transparent; border: none;")
|
||
|
||
content_layout.addWidget(name)
|
||
content_layout.addWidget(details)
|
||
content_layout.addStretch()
|
||
|
||
# Stack the layers
|
||
card_layout.addWidget(background_label, 0, 0)
|
||
card_layout.addWidget(gradient_overlay, 0, 0)
|
||
|
||
# Set up interaction
|
||
card.mousePressEvent = lambda event: self.select_album(album)
|
||
|
||
# Fetch the album image in the background
|
||
if hasattr(album, 'image_url') and album.image_url:
|
||
self.download_and_set_image(album.image_url, background_label)
|
||
|
||
return card
|
||
|
||
def _show_loading_cards(self, layout: QHBoxLayout, text: str):
|
||
layout.addStretch()
|
||
for _ in range(4):
|
||
loading_card = QFrame()
|
||
loading_card.setObjectName("card")
|
||
loading_card.setFixedSize(220, 130)
|
||
card_layout = QVBoxLayout(loading_card)
|
||
card_layout.addWidget(QLabel(text), 0, Qt.AlignmentFlag.AlignCenter)
|
||
layout.addWidget(loading_card)
|
||
layout.addStretch()
|
||
|
||
def _clear_layout(self, layout: QHBoxLayout):
|
||
while layout.count():
|
||
child = layout.takeAt(0)
|
||
if child.widget():
|
||
child.widget().deleteLater()
|
||
|
||
class ArtistSuggestionThread(QThread):
|
||
"""Background thread for generating artist suggestions"""
|
||
|
||
suggestions_ready = pyqtSignal(list)
|
||
|
||
def __init__(self, track_result: TrackResult, spotify_client: SpotifyClient, matching_engine: MusicMatchingEngine, is_album=False, album_result=None):
|
||
super().__init__()
|
||
self.track_result = track_result
|
||
self.spotify_client = spotify_client
|
||
self.matching_engine = matching_engine
|
||
self.is_album = is_album
|
||
self.album_result = album_result
|
||
|
||
def run(self):
|
||
"""Generate artist suggestions"""
|
||
try:
|
||
print(f"🔍 Starting auto suggestions for: {self.track_result.artist} - {self.track_result.title}")
|
||
suggestions = self.generate_artist_suggestions()
|
||
print(f"✅ Generated {len(suggestions)} auto suggestions")
|
||
self.suggestions_ready.emit(suggestions)
|
||
except Exception as e:
|
||
print(f"❌ Error generating suggestions: {e}")
|
||
self.suggestions_ready.emit([])
|
||
|
||
def generate_artist_suggestions(self) -> List[ArtistMatch]:
|
||
"""Generate artist suggestions using multiple strategies"""
|
||
suggestions = []
|
||
|
||
# Debug logging
|
||
print(f"🔍 [DEBUG] Auto suggestion input data:")
|
||
print(f" track_result.artist: '{getattr(self.track_result, 'artist', 'NOT_FOUND')}'")
|
||
print(f" track_result.title: '{getattr(self.track_result, 'title', 'NOT_FOUND')}'")
|
||
print(f" track_result.album: '{getattr(self.track_result, 'album', 'NOT_FOUND')}'")
|
||
print(f" track_result type: {type(self.track_result)}")
|
||
print(f" spotify_client available: {self.spotify_client is not None}")
|
||
print(f" matching_engine available: {self.matching_engine is not None}")
|
||
if self.spotify_client:
|
||
print(f" spotify_client.is_authenticated(): {self.spotify_client.is_authenticated()}")
|
||
print(f" track_result attributes: {[attr for attr in dir(self.track_result) if not attr.startswith('_')]}")
|
||
|
||
# Special handling for albums - use album title to find artist instead of track data
|
||
if self.is_album and self.album_result and self.album_result.album_title:
|
||
print(f"🎵 [DEBUG] Album mode detected - using album title for artist search")
|
||
print(f" album_title: '{self.album_result.album_title}'")
|
||
print(f" album_artist: '{getattr(self.album_result, 'artist', 'NOT_FOUND')}'")
|
||
|
||
# Clean album title for searching (remove year prefixes like "(2005)")
|
||
album_title = self.album_result.album_title
|
||
import re
|
||
clean_album_title = re.sub(r'^\(\d{4}\)\s*', '', album_title).strip()
|
||
print(f" clean_album_title: '{clean_album_title}'")
|
||
|
||
# Strategy: Search tracks using album title to find the artist
|
||
print(f"🔍 Album Strategy: Searching tracks for album '{clean_album_title}'")
|
||
tracks = self.spotify_client.search_tracks(clean_album_title, limit=20)
|
||
print(f"📊 Found {len(tracks)} tracks from album search")
|
||
|
||
# Collect unique artist names and their associated tracks/albums first
|
||
unique_artists = {} # artist_name -> list of (track, album) tuples
|
||
for track in tracks:
|
||
for artist_name in track.artists:
|
||
if artist_name not in unique_artists:
|
||
unique_artists[artist_name] = []
|
||
unique_artists[artist_name].append((track, track.album))
|
||
|
||
print(f"🚀 [PERF] Found {len(unique_artists)} unique artists to lookup (down from {sum(len(track.artists) for track in tracks)} total)")
|
||
|
||
# Batch fetch artist objects using concurrent futures for speed
|
||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||
import time
|
||
|
||
start_time = time.time()
|
||
artist_objects = {} # artist_name -> Artist object
|
||
|
||
def fetch_artist(artist_name):
|
||
"""Fetch single artist with error handling"""
|
||
try:
|
||
matches = self.spotify_client.search_artists(artist_name, limit=1)
|
||
if matches:
|
||
return artist_name, matches[0]
|
||
except Exception as e:
|
||
print(f"⚠️ Error fetching artist '{artist_name}': {e}")
|
||
return artist_name, None
|
||
|
||
# Use limited concurrency to respect rate limits while improving speed
|
||
with ThreadPoolExecutor(max_workers=3) as executor:
|
||
future_to_artist = {executor.submit(fetch_artist, name): name for name in unique_artists.keys()}
|
||
|
||
for future in as_completed(future_to_artist):
|
||
artist_name, artist_obj = future.result()
|
||
if artist_obj:
|
||
artist_objects[artist_name] = artist_obj
|
||
|
||
fetch_time = time.time() - start_time
|
||
print(f"⚡ [PERF] Fetched {len(artist_objects)} artists in {fetch_time:.2f}s using concurrent API calls")
|
||
|
||
# Now calculate confidence scores for each artist
|
||
artist_scores = {}
|
||
for artist_name, track_album_pairs in unique_artists.items():
|
||
if artist_name not in artist_objects:
|
||
continue
|
||
|
||
artist = artist_objects[artist_name]
|
||
best_confidence = 0
|
||
best_album_match = ""
|
||
|
||
# Find the best confidence score across all albums for this artist
|
||
for track, album in track_album_pairs:
|
||
# Calculate confidence based on album title match
|
||
confidence = self.matching_engine.similarity_score(
|
||
self.matching_engine.normalize_string(clean_album_title),
|
||
self.matching_engine.normalize_string(album)
|
||
)
|
||
|
||
# Boost confidence if album artist matches
|
||
if hasattr(self.album_result, 'artist') and self.album_result.artist:
|
||
artist_confidence = self.matching_engine.similarity_score(
|
||
self.matching_engine.normalize_string(self.album_result.artist),
|
||
self.matching_engine.normalize_string(artist.name)
|
||
)
|
||
confidence = max(confidence, artist_confidence)
|
||
|
||
# Keep highest confidence for this artist
|
||
if confidence > best_confidence:
|
||
best_confidence = confidence
|
||
best_album_match = album
|
||
|
||
# Store the artist with their best confidence
|
||
artist_scores[artist.id] = {
|
||
'artist': artist,
|
||
'confidence': best_confidence,
|
||
'album_match': best_album_match
|
||
}
|
||
|
||
# Add high-confidence album artists to suggestions
|
||
for artist_data in artist_scores.values():
|
||
if artist_data['confidence'] >= 0.6: # Higher threshold for album matches
|
||
print(f"✅ Added album artist match: {artist_data['artist'].name} ({artist_data['confidence']:.2f}) via '{artist_data['album_match']}'")
|
||
suggestions.append(ArtistMatch(
|
||
artist=artist_data['artist'],
|
||
confidence=artist_data['confidence'],
|
||
match_reason=f"Album match via '{artist_data['album_match']}'"
|
||
))
|
||
|
||
print(f"🎯 [DEBUG] Album strategy generated {len(suggestions)} suggestions")
|
||
|
||
# If we found good album matches, return them (don't try track-based strategies)
|
||
if suggestions:
|
||
# Remove duplicates and sort by confidence
|
||
unique_suggestions = {}
|
||
for suggestion in suggestions:
|
||
if suggestion.artist.id not in unique_suggestions or unique_suggestions[suggestion.artist.id].confidence < suggestion.confidence:
|
||
unique_suggestions[suggestion.artist.id] = suggestion
|
||
|
||
final_suggestions = sorted(unique_suggestions.values(), key=lambda x: x.confidence, reverse=True)
|
||
print(f"🎯 [DEBUG] Returning {len(final_suggestions)} album-based suggestions")
|
||
return final_suggestions[:5]
|
||
|
||
# Try to get artist name from different sources (for singles or fallback)
|
||
artist_name = None
|
||
if self.track_result.artist and self.track_result.artist != "Unknown Artist":
|
||
artist_name = self.track_result.artist
|
||
elif hasattr(self.track_result, 'user') and self.track_result.user:
|
||
# Sometimes the artist might be in the user field
|
||
artist_name = self.track_result.user
|
||
elif hasattr(self.track_result, 'filename') and self.track_result.filename:
|
||
# Try to extract artist from filename
|
||
import os
|
||
filename = os.path.basename(self.track_result.filename)
|
||
if ' - ' in filename:
|
||
artist_name = filename.split(' - ')[0].strip()
|
||
|
||
print(f"🎯 [DEBUG] Determined artist name: '{artist_name}'")
|
||
|
||
# Strategy 1: Search for the artist name directly
|
||
if artist_name and artist_name != "Unknown Artist":
|
||
artist_query = self.matching_engine.normalize_string(artist_name)
|
||
print(f"🔍 Strategy 1: Searching for artist '{artist_query}'")
|
||
artists = self.spotify_client.search_artists(artist_query, limit=10)
|
||
print(f"📊 Found {len(artists)} artists from Spotify")
|
||
|
||
for artist in artists:
|
||
confidence = self.matching_engine.similarity_score(
|
||
self.matching_engine.normalize_string(artist_name),
|
||
self.matching_engine.normalize_string(artist.name)
|
||
)
|
||
|
||
if confidence >= 0.3: # Minimum threshold
|
||
print(f"✅ Added artist match: {artist.name} ({confidence:.2f})")
|
||
suggestions.append(ArtistMatch(
|
||
artist=artist,
|
||
confidence=confidence,
|
||
match_reason="Artist name match"
|
||
))
|
||
else:
|
||
print(f"❌ Strategy 1 skipped: artist_name='{artist_name}', original_artist='{getattr(self.track_result, 'artist', 'NO_ATTR')}'")
|
||
|
||
# Strategy 2: Search for "artist - title" combination
|
||
if artist_name and self.track_result.title:
|
||
combined_query = f"{artist_name} {self.track_result.title}"
|
||
print(f"🔍 Strategy 2: Searching for combined query '{combined_query}'")
|
||
tracks = self.spotify_client.search_tracks(combined_query, limit=10)
|
||
print(f"📊 Found {len(tracks)} tracks from Spotify")
|
||
|
||
for track in tracks:
|
||
for artist_name in track.artists:
|
||
# Find matching artist
|
||
artist_matches = self.spotify_client.search_artists(artist_name, limit=1)
|
||
if artist_matches:
|
||
artist = artist_matches[0]
|
||
|
||
# Calculate combined confidence based on artist and title match
|
||
artist_confidence = self.matching_engine.similarity_score(
|
||
self.matching_engine.normalize_string(artist_name),
|
||
self.matching_engine.normalize_string(artist.name)
|
||
)
|
||
title_confidence = self.matching_engine.similarity_score(
|
||
self.matching_engine.normalize_string(self.track_result.title),
|
||
self.matching_engine.normalize_string(track.name)
|
||
)
|
||
|
||
combined_confidence = (artist_confidence * 0.7 + title_confidence * 0.3)
|
||
|
||
if combined_confidence >= 0.4:
|
||
suggestions.append(ArtistMatch(
|
||
artist=artist,
|
||
confidence=combined_confidence,
|
||
match_reason="Track match"
|
||
))
|
||
|
||
# Remove duplicates and sort by confidence
|
||
unique_suggestions = {}
|
||
for suggestion in suggestions:
|
||
if suggestion.artist.id not in unique_suggestions or unique_suggestions[suggestion.artist.id].confidence < suggestion.confidence:
|
||
unique_suggestions[suggestion.artist.id] = suggestion
|
||
|
||
final_suggestions = sorted(unique_suggestions.values(), key=lambda x: x.confidence, reverse=True)
|
||
|
||
# Debug final results
|
||
print(f"🎯 [DEBUG] Final suggestions count: {len(final_suggestions)}")
|
||
for i, suggestion in enumerate(final_suggestions[:5]):
|
||
print(f" {i+1}. {suggestion.artist.name} ({suggestion.confidence:.2f}) - {suggestion.match_reason}")
|
||
|
||
return final_suggestions[:5]
|
||
|
||
class ArtistSearchThread(QThread):
|
||
"""Background thread for manual artist search"""
|
||
|
||
search_results = pyqtSignal(list)
|
||
|
||
def __init__(self, query: str, spotify_client: SpotifyClient, matching_engine: MusicMatchingEngine, track_result: TrackResult):
|
||
super().__init__()
|
||
self.query = query
|
||
self.spotify_client = spotify_client
|
||
self.matching_engine = matching_engine
|
||
self.track_result = track_result
|
||
|
||
def run(self):
|
||
"""Perform artist search"""
|
||
try:
|
||
artists = self.spotify_client.search_artists(self.query, limit=10)
|
||
results = []
|
||
|
||
for artist in artists:
|
||
# Calculate confidence based on search query match
|
||
confidence = self.matching_engine.similarity_score(
|
||
self.matching_engine.normalize_string(self.query),
|
||
self.matching_engine.normalize_string(artist.name)
|
||
)
|
||
|
||
# Boost confidence if it also matches the original track artist
|
||
if self.track_result.artist:
|
||
original_match = self.matching_engine.similarity_score(
|
||
self.matching_engine.normalize_string(self.track_result.artist),
|
||
self.matching_engine.normalize_string(artist.name)
|
||
)
|
||
confidence = max(confidence, original_match)
|
||
|
||
results.append(ArtistMatch(
|
||
artist=artist,
|
||
confidence=confidence,
|
||
match_reason="Search result"
|
||
))
|
||
|
||
# Sort by confidence
|
||
results.sort(key=lambda x: x.confidence, reverse=True)
|
||
self.search_results.emit(results)
|
||
|
||
except Exception as e:
|
||
print(f"Error searching artists: {e}")
|
||
self.search_results.emit([])
|
||
|
||
class AlbumSearchThread(QThread):
|
||
"""Background thread for album search by artist"""
|
||
|
||
search_results = pyqtSignal(list)
|
||
|
||
def __init__(self, query: str, artist: Artist, spotify_client: SpotifyClient, matching_engine: MusicMatchingEngine):
|
||
super().__init__()
|
||
self.query = query
|
||
self.artist = artist
|
||
self.spotify_client = spotify_client
|
||
self.matching_engine = matching_engine
|
||
|
||
def run(self):
|
||
"""Perform album search"""
|
||
try:
|
||
# Search for albums by the selected artist
|
||
search_query = f"artist:{self.artist.name} {self.query}"
|
||
albums = self.spotify_client.search_albums(search_query, limit=10)
|
||
results = []
|
||
|
||
for album in albums:
|
||
# Check if this album is actually by our selected artist
|
||
artist_match = False
|
||
for album_artist in album.artists:
|
||
if self.matching_engine.similarity_score(
|
||
self.matching_engine.normalize_string(self.artist.name),
|
||
self.matching_engine.normalize_string(album_artist)
|
||
) >= 0.8:
|
||
artist_match = True
|
||
break
|
||
|
||
if artist_match:
|
||
# Calculate confidence based on search query match with album name
|
||
confidence = self.matching_engine.similarity_score(
|
||
self.matching_engine.normalize_string(self.query),
|
||
self.matching_engine.normalize_string(album.name)
|
||
)
|
||
|
||
results.append(AlbumMatch(
|
||
album=album,
|
||
confidence=confidence,
|
||
match_reason="Album search result"
|
||
))
|
||
|
||
# Sort by confidence
|
||
results.sort(key=lambda x: x.confidence, reverse=True)
|
||
self.search_results.emit(results)
|
||
|
||
except Exception as e:
|
||
print(f"Error searching albums: {e}")
|
||
self.search_results.emit([])
|
||
|
||
class AlbumSuggestionThread(QThread):
|
||
"""Background thread for generating automatic album suggestions for a given artist."""
|
||
|
||
suggestions_ready = pyqtSignal(list)
|
||
|
||
def __init__(self, selected_artist: Artist, original_result: object, spotify_client: SpotifyClient, matching_engine: MusicMatchingEngine):
|
||
super().__init__()
|
||
self.artist = selected_artist
|
||
self.original_result = original_result
|
||
self.spotify_client = spotify_client
|
||
self.matching_engine = matching_engine
|
||
|
||
def run(self):
|
||
"""Fetch albums for the artist and find the best matches."""
|
||
try:
|
||
import re # Import the regular expression module
|
||
|
||
target_album_name = ""
|
||
if isinstance(self.original_result, AlbumResult):
|
||
target_album_name = self.original_result.album_title
|
||
print(f"🎵 Album context for auto-match from AlbumResult: '{target_album_name}'")
|
||
elif hasattr(self.original_result, 'album') and self.original_result.album:
|
||
target_album_name = self.original_result.album
|
||
print(f"🎵 Album context for auto-match from TrackResult: '{target_album_name}'")
|
||
else:
|
||
target_album_name = self.original_result.title
|
||
print(f"🎵 Album context for auto-match using fallback to track title: '{target_album_name}'")
|
||
|
||
if not target_album_name:
|
||
self.suggestions_ready.emit([])
|
||
return
|
||
|
||
# More aggressive cleaning for the search query.
|
||
# 1. Remove bracketed content like [flac], (Explicit), [2024], etc.
|
||
cleaned_search_term = re.sub(r'\s*[\[\(].*?[\]\)]', '', target_album_name).strip()
|
||
|
||
# 2. Remove the artist's name from the string if it's present.
|
||
artist_pattern = r'^' + re.escape(self.artist.name) + r'\s*-\s*'
|
||
cleaned_search_term = re.sub(artist_pattern, '', cleaned_search_term, flags=re.IGNORECASE)
|
||
|
||
# 3. Clean up any remaining leading/trailing dashes or spaces.
|
||
cleaned_search_term = cleaned_search_term.strip(' -')
|
||
|
||
print(f"🧹 Cleaned album search term: '{target_album_name}' -> '{cleaned_search_term}'")
|
||
|
||
search_query = f"artist:{self.artist.name} album:{cleaned_search_term}"
|
||
print(f"🔍 Searching Spotify for albums with query: '{search_query}'")
|
||
|
||
spotify_albums = self.spotify_client.search_albums(search_query, limit=10)
|
||
print(f"📊 Found {len(spotify_albums)} potential albums from Spotify search.")
|
||
|
||
suggestions = []
|
||
for album in spotify_albums:
|
||
is_correct_artist = any(self.matching_engine.similarity_score(self.artist.name, art) > 0.85 for art in album.artists)
|
||
|
||
if not is_correct_artist:
|
||
print(f"⚠️ Skipping album '{album.name}' as artist does not match '{self.artist.name}'")
|
||
continue
|
||
|
||
confidence = self.matching_engine.similarity_score(
|
||
self.matching_engine.normalize_string(target_album_name),
|
||
self.matching_engine.normalize_string(album.name)
|
||
)
|
||
|
||
if self.matching_engine.normalize_string(cleaned_search_term) in self.matching_engine.normalize_string(album.name):
|
||
confidence = max(confidence, 0.95)
|
||
|
||
if confidence >= 0.5:
|
||
print(f"✅ Found album match: '{album.name}' with confidence {confidence:.2f}")
|
||
suggestions.append(AlbumMatch(
|
||
album=album,
|
||
confidence=confidence,
|
||
match_reason="Direct album search"
|
||
))
|
||
|
||
# **IMPROVEMENT**: Intelligently de-duplicate results with the same name.
|
||
# This prefers versions marked as 'album' and those with more tracks.
|
||
unique_suggestions = {}
|
||
for suggestion in suggestions:
|
||
album_key = self.matching_engine.normalize_string(suggestion.album.name)
|
||
|
||
if album_key not in unique_suggestions:
|
||
unique_suggestions[album_key] = suggestion
|
||
else:
|
||
existing_suggestion = unique_suggestions[album_key]
|
||
is_new_better = False
|
||
|
||
# Higher confidence is always better
|
||
if suggestion.confidence > existing_suggestion.confidence:
|
||
is_new_better = True
|
||
elif suggestion.confidence == existing_suggestion.confidence:
|
||
# 'album' type is preferred over 'single'
|
||
if suggestion.album.album_type == 'album' and existing_suggestion.album.album_type != 'album':
|
||
is_new_better = True
|
||
# More tracks is better if types are the same
|
||
elif suggestion.album.album_type == existing_suggestion.album.album_type and hasattr(suggestion.album, 'total_tracks') and hasattr(existing_suggestion.album, 'total_tracks') and suggestion.album.total_tracks > existing_suggestion.album.total_tracks:
|
||
is_new_better = True
|
||
|
||
if is_new_better:
|
||
print(f"🔄 Replacing duplicate album '{album_key}' with a better version (type: {suggestion.album.album_type}, tracks: {getattr(suggestion.album, 'total_tracks', 'N/A')})")
|
||
unique_suggestions[album_key] = suggestion
|
||
|
||
final_suggestions = sorted(unique_suggestions.values(), key=lambda x: x.confidence, reverse=True)
|
||
|
||
print(f"✅ Generated {len(final_suggestions)} final album suggestions.")
|
||
self.suggestions_ready.emit(final_suggestions[:4])
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error generating album suggestions: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
self.suggestions_ready.emit([])
|
||
|
||
|
||
class BouncingDotsWidget(QWidget):
|
||
"""Animated bouncing dots loading indicator"""
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.setFixedSize(60, 20)
|
||
self.dots = ['●', '●', '●']
|
||
self._current_dot = 0
|
||
|
||
# Animation setup
|
||
self.setup_animation()
|
||
|
||
def setup_animation(self):
|
||
"""Set up the bouncing animation sequence"""
|
||
self.timer = QTimer()
|
||
self.timer.timeout.connect(self.update_dots)
|
||
|
||
def start_animation(self):
|
||
"""Start the bouncing animation"""
|
||
self.timer.start(400) # Update every 400ms for smoother bouncing
|
||
|
||
def stop_animation(self):
|
||
"""Stop the bouncing animation"""
|
||
if hasattr(self, 'timer'):
|
||
self.timer.stop()
|
||
self._current_dot = 0
|
||
self.update()
|
||
|
||
def update_dots(self):
|
||
"""Update which dot is bouncing"""
|
||
self._current_dot = (self._current_dot + 1) % 3
|
||
self.update()
|
||
|
||
def get_current_dot(self):
|
||
return self._current_dot
|
||
|
||
def set_current_dot(self, value):
|
||
self._current_dot = value
|
||
self.update()
|
||
|
||
# Create the Qt property for animation system
|
||
current_dot = pyqtProperty(int, get_current_dot, set_current_dot)
|
||
|
||
def paintEvent(self, event):
|
||
"""Custom paint event to draw the bouncing dots"""
|
||
painter = QPainter(self)
|
||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
|
||
# Set color and font
|
||
painter.setPen(QPen(Qt.GlobalColor.white, 2))
|
||
font = painter.font()
|
||
font.setPointSize(12)
|
||
painter.setFont(font)
|
||
|
||
# Draw three dots with bouncing effect
|
||
dot_width = 20
|
||
for i in range(3):
|
||
x = i * dot_width
|
||
y = 15 if i == self._current_dot else 10 # Bounce effect
|
||
|
||
# Make current dot larger and brighter
|
||
if i == self._current_dot:
|
||
painter.setPen(QPen(Qt.GlobalColor.green, 3))
|
||
else:
|
||
painter.setPen(QPen(Qt.GlobalColor.gray, 2))
|
||
|
||
painter.drawText(x, y, self.dots[i])
|
||
|
||
class SpinningCircleWidget(QWidget):
|
||
"""Animated spinning circle loading indicator"""
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.setFixedSize(60, 60) # Increased from 30x30 to 60x60
|
||
self._angle = 0
|
||
|
||
# Animation setup
|
||
self.animation = QPropertyAnimation(self, b"rotation_angle")
|
||
self.animation.setDuration(1000) # 1 second per rotation
|
||
self.animation.setStartValue(0)
|
||
self.animation.setEndValue(360)
|
||
self.animation.setLoopCount(-1) # Infinite loop
|
||
self.animation.setEasingCurve(QEasingCurve.Type.Linear)
|
||
|
||
def start_animation(self):
|
||
"""Start the spinning animation"""
|
||
self.animation.start()
|
||
|
||
def stop_animation(self):
|
||
"""Stop the spinning animation"""
|
||
self.animation.stop()
|
||
self._angle = 0
|
||
self.update()
|
||
|
||
def get_rotation_angle(self):
|
||
return self._angle
|
||
|
||
def set_rotation_angle(self, angle):
|
||
self._angle = angle
|
||
self.update()
|
||
|
||
rotation_angle = pyqtProperty(float, get_rotation_angle, set_rotation_angle)
|
||
|
||
def paintEvent(self, event):
|
||
"""Custom paint event to draw the spinning circle"""
|
||
painter = QPainter(self)
|
||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
|
||
# Set up the painting area
|
||
rect = self.rect()
|
||
center_x = rect.width() // 2
|
||
center_y = rect.height() // 2
|
||
radius = min(center_x, center_y) - 2
|
||
|
||
# Rotate the painter
|
||
painter.translate(center_x, center_y)
|
||
painter.rotate(self._angle)
|
||
|
||
# Draw circle segments with varying opacity
|
||
pen = QPen(Qt.GlobalColor.green, 3)
|
||
painter.setPen(pen)
|
||
|
||
# Draw 8 dots around the circle
|
||
import math
|
||
for i in range(8):
|
||
angle_step = 2 * math.pi / 8
|
||
dot_angle = i * angle_step
|
||
|
||
# Calculate position for each dot
|
||
x = radius * 0.7 * math.cos(dot_angle)
|
||
y = radius * 0.7 * math.sin(dot_angle)
|
||
|
||
# Fade effect - dots further from current position are dimmer
|
||
distance = abs(i - (self._angle / 45)) % 8
|
||
opacity = max(0.2, 1.0 - distance * 0.15)
|
||
|
||
# Set color with proper opacity using QColor and alpha channel
|
||
color = QColor(29, 185, 84) # App's green theme color
|
||
color.setAlpha(int(opacity * 255)) # Apply calculated opacity
|
||
pen.setColor(color)
|
||
painter.setPen(pen)
|
||
|
||
painter.drawEllipse(int(x-2), int(y-2), 4, 4)
|
||
|
||
class AudioPlayer(QMediaPlayer):
|
||
"""Simple audio player for streaming music files"""
|
||
playback_finished = pyqtSignal()
|
||
playback_error = pyqtSignal(str)
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
|
||
# Set up audio output
|
||
self.audio_output = QAudioOutput()
|
||
self.setAudioOutput(self.audio_output)
|
||
|
||
# Connect signals
|
||
self.mediaStatusChanged.connect(self._on_media_status_changed)
|
||
self.errorOccurred.connect(self._on_error_occurred)
|
||
self.playbackStateChanged.connect(self._on_playback_state_changed)
|
||
|
||
# Track current file
|
||
self.current_file_path = None
|
||
self.is_playing = False
|
||
|
||
def _on_playback_state_changed(self, state):
|
||
"""Keep is_playing flag synchronized with actual playback state"""
|
||
from PyQt6.QtMultimedia import QMediaPlayer
|
||
state_names = {
|
||
QMediaPlayer.PlaybackState.StoppedState: "STOPPED",
|
||
QMediaPlayer.PlaybackState.PlayingState: "PLAYING",
|
||
QMediaPlayer.PlaybackState.PausedState: "PAUSED"
|
||
}
|
||
print(f"🎵 AudioPlayer state changed to: {state_names.get(state, 'UNKNOWN')}")
|
||
self.is_playing = (state == QMediaPlayer.PlaybackState.PlayingState)
|
||
|
||
def play_file(self, file_path):
|
||
"""Play an audio file from the given path"""
|
||
try:
|
||
if not file_path or not os.path.exists(file_path):
|
||
self.playback_error.emit(f"File not found: {file_path}")
|
||
return False
|
||
|
||
# Stop any current playback
|
||
self.stop()
|
||
|
||
# Set the new media source
|
||
self.current_file_path = file_path
|
||
self.setSource(QUrl.fromLocalFile(file_path))
|
||
|
||
# Start playback
|
||
self.play()
|
||
# is_playing will be set automatically by _on_playback_state_changed
|
||
|
||
print(f"🎵 Started playing: {os.path.basename(file_path)}")
|
||
return True
|
||
|
||
except Exception as e:
|
||
error_msg = f"Error playing audio file: {str(e)}"
|
||
print(error_msg)
|
||
self.playback_error.emit(error_msg)
|
||
return False
|
||
|
||
def toggle_playback(self):
|
||
"""Toggle between play and pause"""
|
||
current_state = self.playbackState()
|
||
print(f"🔄 toggle_playback() - Current state: {current_state}")
|
||
print(f"🔄 toggle_playback() - Current source: {self.source().toString()}")
|
||
|
||
if current_state == QMediaPlayer.PlaybackState.PlayingState:
|
||
print("⏸️ AudioPlayer: Pausing playback")
|
||
self.pause()
|
||
# is_playing will be set automatically by _on_playback_state_changed
|
||
return False # Now paused
|
||
else:
|
||
print("▶️ AudioPlayer: Attempting to resume/play")
|
||
|
||
# Check if we have a valid source to play
|
||
if not self.source().isValid() and self.current_file_path:
|
||
print(f"🔧 AudioPlayer: No source set, restoring from: {self.current_file_path}")
|
||
self.setSource(QUrl.fromLocalFile(self.current_file_path))
|
||
|
||
self.play()
|
||
# is_playing will be set automatically by _on_playback_state_changed
|
||
return True # Now playing
|
||
|
||
def stop_playback(self):
|
||
"""Stop playback and reset"""
|
||
print("⏹️ AudioPlayer: stop_playback() called")
|
||
self.stop()
|
||
# is_playing will be set automatically by _on_playback_state_changed
|
||
self.release_file()
|
||
|
||
def release_file(self, clear_file_path=True):
|
||
"""Release the current file handle by clearing the media source
|
||
|
||
Args:
|
||
clear_file_path (bool): Whether to clear the stored file path.
|
||
Set to False to keep the path for potential resuming.
|
||
"""
|
||
print(f"🔓 AudioPlayer: release_file() called - clearing source: {self.source().toString()}")
|
||
self.setSource(QUrl()) # Clear the media source to release file handle
|
||
if clear_file_path:
|
||
self.current_file_path = None
|
||
print("🔓 Released audio file handle")
|
||
|
||
def _on_media_status_changed(self, status):
|
||
"""Handle media status changes"""
|
||
if status == QMediaPlayer.MediaStatus.EndOfMedia:
|
||
print("🎵 Playback finished")
|
||
# is_playing will be set automatically by _on_playback_state_changed
|
||
self.playback_finished.emit()
|
||
elif status == QMediaPlayer.MediaStatus.InvalidMedia:
|
||
error_msg = "Invalid media file or unsupported format"
|
||
print(f"❌ {error_msg}")
|
||
# is_playing will be set automatically by _on_playback_state_changed
|
||
self.playback_error.emit(error_msg)
|
||
|
||
def _on_error_occurred(self, error, error_string):
|
||
"""Handle playback errors"""
|
||
error_msg = f"Audio playback error: {error_string}"
|
||
print(f"❌ {error_msg}")
|
||
# is_playing will be set automatically by _on_playback_state_changed
|
||
self.playback_error.emit(error_msg)
|
||
|
||
class DownloadThread(QThread):
|
||
download_completed = pyqtSignal(str, object) # Download ID or success message, download_item
|
||
download_failed = pyqtSignal(str, object) # Error message, download_item
|
||
download_progress = pyqtSignal(str, object) # Progress message, download_item
|
||
|
||
def __init__(self, soulseek_client, search_result, download_item):
|
||
super().__init__()
|
||
self.soulseek_client = soulseek_client
|
||
self.search_result = search_result
|
||
self.download_item = download_item
|
||
self._stop_requested = False
|
||
|
||
def run(self):
|
||
loop = None
|
||
try:
|
||
import asyncio
|
||
self.download_progress.emit(f"Starting download: {self.search_result.filename}", self.download_item)
|
||
|
||
# Create a completely fresh event loop for this thread
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
|
||
# Perform download with proper error handling
|
||
download_id = loop.run_until_complete(self._do_download())
|
||
|
||
if not self._stop_requested:
|
||
if download_id:
|
||
self.download_completed.emit(f"Download started: {download_id}", self.download_item)
|
||
else:
|
||
self.download_failed.emit("Download failed to start", self.download_item)
|
||
|
||
# Give signals time to be processed before thread exits
|
||
import time
|
||
time.sleep(0.1)
|
||
|
||
except Exception as e:
|
||
if not self._stop_requested:
|
||
self.download_failed.emit(str(e), self.download_item)
|
||
# Give error signal time to be processed
|
||
import time
|
||
time.sleep(0.1)
|
||
finally:
|
||
# Ensure proper cleanup
|
||
if loop:
|
||
try:
|
||
# Close any remaining tasks
|
||
pending = asyncio.all_tasks(loop)
|
||
for task in pending:
|
||
task.cancel()
|
||
|
||
if pending:
|
||
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
||
|
||
loop.close()
|
||
except Exception as e:
|
||
print(f"Error cleaning up download event loop: {e}")
|
||
|
||
async def _do_download(self):
|
||
"""Perform the actual download with proper async handling"""
|
||
return await self.soulseek_client.download(
|
||
self.search_result.username,
|
||
self.search_result.filename,
|
||
self.search_result.size
|
||
)
|
||
|
||
def stop(self):
|
||
"""Stop the download gracefully"""
|
||
self._stop_requested = True
|
||
|
||
class SessionInfoThread(QThread):
|
||
session_info_completed = pyqtSignal(dict) # Session info dict
|
||
session_info_failed = pyqtSignal(str) # Error message
|
||
|
||
def __init__(self, soulseek_client):
|
||
super().__init__()
|
||
self.soulseek_client = soulseek_client
|
||
self._stop_requested = False
|
||
|
||
def run(self):
|
||
loop = None
|
||
try:
|
||
import asyncio
|
||
|
||
# Create a completely fresh event loop for this thread
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
|
||
# Check if stop was requested before starting
|
||
if self._stop_requested:
|
||
return
|
||
|
||
# Get session info
|
||
session_info = loop.run_until_complete(self._get_session_info())
|
||
|
||
# Only emit if not stopped
|
||
if not self._stop_requested:
|
||
self.session_info_completed.emit(session_info or {})
|
||
|
||
except Exception as e:
|
||
if not self._stop_requested:
|
||
self.session_info_failed.emit(str(e))
|
||
finally:
|
||
# Ensure proper cleanup
|
||
if loop:
|
||
try:
|
||
# Close any remaining tasks
|
||
pending = asyncio.all_tasks(loop)
|
||
for task in pending:
|
||
task.cancel()
|
||
|
||
if pending:
|
||
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
||
|
||
loop.close()
|
||
except Exception as e:
|
||
print(f"Error cleaning up session info event loop: {e}")
|
||
|
||
async def _get_session_info(self):
|
||
"""Get the session information"""
|
||
return await self.soulseek_client.get_session_info()
|
||
|
||
def stop(self):
|
||
"""Stop the session info gathering gracefully"""
|
||
self._stop_requested = True
|
||
|
||
class ExploreApiThread(QThread):
|
||
exploration_completed = pyqtSignal(dict) # API info dict
|
||
exploration_failed = pyqtSignal(str) # Error message
|
||
|
||
def __init__(self, soulseek_client):
|
||
super().__init__()
|
||
self.soulseek_client = soulseek_client
|
||
self._stop_requested = False
|
||
|
||
def run(self):
|
||
loop = None
|
||
try:
|
||
import asyncio
|
||
|
||
# Create a completely fresh event loop for this thread
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
|
||
# Check if stop was requested before starting
|
||
if self._stop_requested:
|
||
return
|
||
|
||
# Explore the API
|
||
api_info = loop.run_until_complete(self._explore_api())
|
||
|
||
# Only emit if not stopped
|
||
if not self._stop_requested:
|
||
self.exploration_completed.emit(api_info)
|
||
|
||
except Exception as e:
|
||
if not self._stop_requested:
|
||
self.exploration_failed.emit(str(e))
|
||
finally:
|
||
# Ensure proper cleanup
|
||
if loop:
|
||
try:
|
||
# Close any remaining tasks
|
||
pending = asyncio.all_tasks(loop)
|
||
for task in pending:
|
||
task.cancel()
|
||
|
||
if pending:
|
||
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
||
|
||
loop.close()
|
||
except Exception as e:
|
||
print(f"Error cleaning up exploration event loop: {e}")
|
||
|
||
async def _explore_api(self):
|
||
"""Perform the actual API exploration"""
|
||
return await self.soulseek_client.explore_api_endpoints()
|
||
|
||
def stop(self):
|
||
"""Stop the exploration gracefully"""
|
||
self._stop_requested = True
|
||
|
||
class TransferStatusThread(QThread):
|
||
"""Thread for fetching real-time download transfer status from slskd API"""
|
||
transfer_status_completed = pyqtSignal(object) # Transfer data from API
|
||
transfer_status_failed = pyqtSignal(str) # Error message
|
||
|
||
def __init__(self, soulseek_client):
|
||
super().__init__()
|
||
self.soulseek_client = soulseek_client
|
||
self._stop_requested = False
|
||
|
||
def run(self):
|
||
loop = None
|
||
try:
|
||
import asyncio
|
||
|
||
# Create a fresh event loop for this thread
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
|
||
# Check if stop was requested before starting
|
||
if self._stop_requested:
|
||
return
|
||
|
||
# Get transfer status data from /api/v0/transfers/downloads
|
||
transfer_data = loop.run_until_complete(self._get_transfer_status())
|
||
|
||
# Only emit if not stopped
|
||
if not self._stop_requested:
|
||
self.transfer_status_completed.emit(transfer_data or [])
|
||
|
||
except Exception as e:
|
||
if not self._stop_requested:
|
||
self.transfer_status_failed.emit(str(e))
|
||
finally:
|
||
# Ensure proper cleanup
|
||
if loop:
|
||
try:
|
||
# Close any remaining tasks
|
||
pending = asyncio.all_tasks(loop)
|
||
for task in pending:
|
||
task.cancel()
|
||
|
||
if pending:
|
||
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
||
|
||
loop.close()
|
||
except Exception as e:
|
||
print(f"Error cleaning up transfer status event loop: {e}")
|
||
|
||
async def _get_transfer_status(self):
|
||
"""Get the transfer status from slskd API"""
|
||
try:
|
||
# Use the soulseek client's _make_request method to get transfer data
|
||
response_data = await self.soulseek_client._make_request('GET', 'transfers/downloads')
|
||
return response_data
|
||
except Exception as e:
|
||
print(f"Error fetching transfer status: {e}")
|
||
return []
|
||
|
||
def stop(self):
|
||
"""Stop the transfer status gathering gracefully"""
|
||
self._stop_requested = True
|
||
|
||
class ApiCleanupThread(QThread):
|
||
"""Thread for signaling download completion to slskd API without blocking UI"""
|
||
cleanup_completed = pyqtSignal(bool, str, str) # success, download_id, username
|
||
|
||
def __init__(self, soulseek_client, download_id, username):
|
||
super().__init__()
|
||
self.soulseek_client = soulseek_client
|
||
self.download_id = download_id
|
||
self.username = username
|
||
|
||
def run(self):
|
||
"""Signal download completion in background thread"""
|
||
loop = None
|
||
try:
|
||
import asyncio
|
||
|
||
# Create a fresh event loop for this thread
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
|
||
# Signal download completion
|
||
success = loop.run_until_complete(
|
||
self.soulseek_client.signal_download_completion(
|
||
self.download_id,
|
||
self.username,
|
||
remove=True
|
||
)
|
||
)
|
||
|
||
# Emit completion signal
|
||
self.cleanup_completed.emit(success, self.download_id, self.username)
|
||
|
||
except Exception as e:
|
||
print(f"⚠️ Error in API cleanup thread: {e}")
|
||
self.cleanup_completed.emit(False, self.download_id, self.username)
|
||
finally:
|
||
# Ensure proper cleanup
|
||
if loop:
|
||
try:
|
||
# Close any remaining tasks
|
||
pending = asyncio.all_tasks(loop)
|
||
for task in pending:
|
||
task.cancel()
|
||
|
||
if pending:
|
||
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
||
|
||
loop.close()
|
||
except Exception as e:
|
||
print(f"Error cleaning up API cleanup event loop: {e}")
|
||
|
||
class SearchThread(QThread):
|
||
search_completed = pyqtSignal(object) # Tuple of (tracks, albums) or list for backward compatibility
|
||
search_failed = pyqtSignal(str) # Error message
|
||
search_progress = pyqtSignal(str) # Progress message
|
||
search_results_partial = pyqtSignal(object, object, int) # tracks, albums, response count
|
||
|
||
def __init__(self, soulseek_client, query):
|
||
super().__init__()
|
||
self.soulseek_client = soulseek_client
|
||
self.query = query
|
||
self._stop_requested = False
|
||
|
||
def progress_callback(self, tracks, albums, response_count):
|
||
"""Callback function for progressive search results"""
|
||
if not self._stop_requested:
|
||
# Emit live results immediately
|
||
self.search_results_partial.emit(tracks, albums, response_count)
|
||
# Update progress message with current count
|
||
self.search_progress.emit(f"Found {len(tracks)} tracks, {len(albums)} albums from {response_count} responses")
|
||
|
||
def run(self):
|
||
loop = None
|
||
try:
|
||
import asyncio
|
||
self.search_progress.emit(f"Searching for: {self.query}")
|
||
|
||
# Create a completely fresh event loop for this thread
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
|
||
# Perform search with progressive callback
|
||
results = loop.run_until_complete(self._do_search())
|
||
|
||
if not self._stop_requested:
|
||
# Emit final completion with proper tuple format
|
||
# results should be a tuple (tracks, albums) from the search client
|
||
self.search_completed.emit(results)
|
||
|
||
except Exception as e:
|
||
if not self._stop_requested:
|
||
self.search_failed.emit(str(e))
|
||
finally:
|
||
# Ensure proper cleanup
|
||
if loop:
|
||
try:
|
||
# Close any remaining tasks
|
||
pending = asyncio.all_tasks(loop)
|
||
for task in pending:
|
||
task.cancel()
|
||
|
||
if pending:
|
||
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
||
|
||
loop.close()
|
||
except Exception as e:
|
||
print(f"Error cleaning up event loop: {e}")
|
||
|
||
async def _do_search(self):
|
||
"""Perform the actual search with progressive callback"""
|
||
return await self.soulseek_client.search(self.query, progress_callback=self.progress_callback)
|
||
|
||
def stop(self):
|
||
"""Stop the search gracefully"""
|
||
self._stop_requested = True
|
||
|
||
class TrackedStatusUpdateThread(QThread):
|
||
"""Tracked status update thread that can be properly stopped and cleaned up"""
|
||
status_updated = pyqtSignal(list)
|
||
|
||
def __init__(self, soulseek_client, parent=None):
|
||
super().__init__(parent)
|
||
self.soulseek_client = soulseek_client
|
||
self._stop_requested = False
|
||
|
||
def run(self):
|
||
loop = None
|
||
try:
|
||
import asyncio
|
||
|
||
# Check if stop was requested before starting
|
||
if self._stop_requested:
|
||
return
|
||
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
downloads = loop.run_until_complete(self.soulseek_client.get_all_downloads())
|
||
|
||
# Only emit if not stopped
|
||
if not self._stop_requested:
|
||
self.status_updated.emit(downloads or [])
|
||
|
||
except Exception as e:
|
||
if not self._stop_requested:
|
||
print(f"Error fetching download status: {e}")
|
||
self.status_updated.emit([])
|
||
finally:
|
||
# Ensure proper cleanup
|
||
if loop:
|
||
try:
|
||
# Close any remaining tasks
|
||
pending = asyncio.all_tasks(loop)
|
||
for task in pending:
|
||
task.cancel()
|
||
|
||
if pending:
|
||
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
||
|
||
loop.close()
|
||
except Exception as e:
|
||
print(f"Error cleaning up status update event loop: {e}")
|
||
|
||
def stop(self):
|
||
"""Stop the status update thread gracefully"""
|
||
self._stop_requested = True
|
||
|
||
class StreamingThread(QThread):
|
||
"""Thread for streaming audio files without saving them permanently"""
|
||
streaming_started = pyqtSignal(str, object) # Message, search_result
|
||
streaming_finished = pyqtSignal(str, object) # Message, search_result
|
||
streaming_failed = pyqtSignal(str, object) # Error message, search_result
|
||
streaming_progress = pyqtSignal(float, object) # Progress percentage (0-100), search_result
|
||
streaming_queued = pyqtSignal(str, object) # Queue message, search_result
|
||
|
||
def __init__(self, soulseek_client, search_result):
|
||
super().__init__()
|
||
self.soulseek_client = soulseek_client
|
||
self.search_result = search_result
|
||
self._stop_requested = False
|
||
|
||
def run(self):
|
||
loop = None
|
||
try:
|
||
import asyncio
|
||
import os
|
||
import time
|
||
import shutil
|
||
import glob
|
||
from pathlib import Path
|
||
|
||
self.streaming_started.emit(f"Starting stream: {self.search_result.filename}", self.search_result)
|
||
|
||
# Create a fresh event loop for this thread
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
|
||
# Get paths
|
||
from config.settings import config_manager
|
||
download_path = config_manager.get('soulseek.download_path', './downloads')
|
||
|
||
# Use the Stream folder in project root (not inside downloads)
|
||
project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) # Go up from ui/pages/
|
||
stream_folder = os.path.join(project_root, 'Stream')
|
||
|
||
# Ensure Stream directory exists
|
||
os.makedirs(stream_folder, exist_ok=True)
|
||
|
||
# Clear any existing files in Stream folder (only one file at a time)
|
||
for existing_file in glob.glob(os.path.join(stream_folder, '*')):
|
||
try:
|
||
if os.path.isfile(existing_file):
|
||
os.remove(existing_file)
|
||
elif os.path.isdir(existing_file):
|
||
shutil.rmtree(existing_file)
|
||
except Exception as e:
|
||
print(f"Warning: Could not remove existing stream file: {e}")
|
||
|
||
# Start the download (goes to normal downloads folder initially)
|
||
download_result = loop.run_until_complete(self._do_stream_download())
|
||
|
||
if not self._stop_requested:
|
||
if download_result:
|
||
self.streaming_started.emit(f"Downloading for stream: {self.search_result.filename}", self.search_result)
|
||
|
||
# Standard streaming - wait for complete download
|
||
max_wait_time = 45 # Wait up to 45 seconds
|
||
poll_interval = 2 # Check every 2 seconds
|
||
|
||
last_progress_sent = 0.0
|
||
found_file = None # Initialize found_file outside the loop
|
||
|
||
# Queue state tracking
|
||
queue_start_time = None
|
||
queue_timeout = 15.0 # 15 seconds max in queue
|
||
last_download_state = None
|
||
actively_downloading = False
|
||
|
||
for wait_count in range(max_wait_time // poll_interval):
|
||
if self._stop_requested:
|
||
break
|
||
|
||
# Only use real API progress data - no time-based estimation
|
||
|
||
# Check download progress via slskd API
|
||
api_progress = None
|
||
download_state = None
|
||
try:
|
||
# Use the same API call as download queue monitoring for consistency
|
||
transfers_data = loop.run_until_complete(self.soulseek_client._make_request('GET', 'transfers/downloads'))
|
||
download_status = self._find_streaming_download_in_transfers(transfers_data)
|
||
if download_status:
|
||
api_progress = download_status.get('percentComplete', 0)
|
||
download_state = download_status.get('state', '').lower()
|
||
print(f"API Download - State: {download_status.get('state')}, Progress: {api_progress:.1f}%")
|
||
|
||
# Categorize download state (aligned with download queue logic)
|
||
original_state = download_status.get('state', '') # Keep original case for completion check
|
||
is_queued = any(keyword in download_state for keyword in ['queued', 'initializing', 'remote'])
|
||
is_downloading = 'inprogress' in download_state
|
||
is_completed = ('Succeeded' in original_state or ('Completed' in original_state and 'Errored' not in original_state)) or api_progress >= 100
|
||
|
||
# Track queue state timing
|
||
if is_queued and queue_start_time is None:
|
||
queue_start_time = time.time()
|
||
print(f"📋 Download entered queue state: {original_state}")
|
||
self.streaming_queued.emit(f"Queuing with uploader...", self.search_result)
|
||
elif is_downloading and not actively_downloading:
|
||
actively_downloading = True
|
||
queue_start_time = None # Reset queue timer
|
||
print(f"🚀 Download started actively downloading: {original_state}")
|
||
# Emit a progress update to indicate downloading has started
|
||
if api_progress > 0:
|
||
self.streaming_progress.emit(api_progress, self.search_result)
|
||
|
||
# Check for queue timeout
|
||
if is_queued and queue_start_time:
|
||
queue_elapsed = time.time() - queue_start_time
|
||
if queue_elapsed > queue_timeout:
|
||
print(f"⏰ Queue timeout after {queue_elapsed:.1f}s - download stuck in queue")
|
||
self.streaming_failed.emit(f"Queue timeout - uploader not responding. Try another source.", self.search_result)
|
||
return
|
||
|
||
# Check if download is complete
|
||
if is_completed:
|
||
print(f"✓ Download completed via API status: {original_state}")
|
||
# Try to find the actual file - with retries for file system sync
|
||
for retry_count in range(5): # Try up to 5 times with delays
|
||
found_file = self._find_downloaded_file(download_path)
|
||
if found_file:
|
||
print(f"✓ Found completed file after {retry_count} retries: {found_file}")
|
||
break
|
||
else:
|
||
print(f"⏳ File not found yet, waiting... (retry {retry_count + 1}/5)")
|
||
time.sleep(1) # Wait 1 second for file system to sync
|
||
|
||
if found_file:
|
||
print(f"✓ Found downloaded file: {found_file}")
|
||
|
||
# Move the file to Stream folder with original filename
|
||
original_filename = os.path.basename(found_file)
|
||
stream_path = os.path.join(stream_folder, original_filename)
|
||
|
||
try:
|
||
# Move file to Stream folder
|
||
shutil.move(found_file, stream_path)
|
||
print(f"✓ Moved file to stream folder: {stream_path}")
|
||
|
||
# Clean up empty directories left behind
|
||
self._cleanup_empty_directories(download_path, found_file)
|
||
|
||
# Signal that streaming is ready (100% progress)
|
||
self.streaming_progress.emit(100.0, self.search_result)
|
||
self.streaming_finished.emit(f"Stream ready: {os.path.basename(found_file)}", self.search_result)
|
||
self.temp_file_path = stream_path
|
||
print(f"✓ Stream file ready for playback: {stream_path}")
|
||
|
||
# Signal API that download is complete
|
||
try:
|
||
download_id = download_status.get('id', '')
|
||
if download_id and self.search_result.username:
|
||
import asyncio
|
||
from services.service_manager import service_manager
|
||
soulseek_client = service_manager.get_soulseek_client()
|
||
if soulseek_client:
|
||
# Run the async API call
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
success = loop.run_until_complete(
|
||
soulseek_client.signal_download_completion(download_id, self.search_result.username, remove=True)
|
||
)
|
||
loop.close()
|
||
if success:
|
||
print(f"✓ Successfully signaled completion for download {download_id}")
|
||
else:
|
||
print(f"⚠️ Failed to signal completion for download {download_id}")
|
||
except Exception as e:
|
||
print(f"⚠️ Error signaling download completion: {e}")
|
||
|
||
break # Exit main polling loop
|
||
|
||
except Exception as e:
|
||
print(f"Error moving file to stream folder: {e}")
|
||
self.streaming_failed.emit(f"Failed to prepare stream file: {e}", self.search_result)
|
||
break
|
||
else:
|
||
# Handle progress updates for active downloads
|
||
if is_downloading and actively_downloading and api_progress is not None and api_progress > 0:
|
||
if api_progress != last_progress_sent:
|
||
self.streaming_progress.emit(api_progress, self.search_result)
|
||
print(f"Progress update: {api_progress:.1f}% (Real API data)")
|
||
last_progress_sent = api_progress
|
||
except Exception as e:
|
||
print(f"Warning: Could not check download progress: {e}")
|
||
# Continue to next iteration if API call fails
|
||
continue
|
||
|
||
# Search for the downloaded file in the downloads directory
|
||
found_file = self._find_downloaded_file(download_path)
|
||
|
||
if found_file:
|
||
print(f"✓ Found downloaded file: {found_file}")
|
||
|
||
# Move the file to Stream folder with original filename
|
||
original_filename = os.path.basename(found_file)
|
||
stream_path = os.path.join(stream_folder, original_filename)
|
||
|
||
try:
|
||
# Move file to Stream folder
|
||
shutil.move(found_file, stream_path)
|
||
print(f"✓ Moved file to stream folder: {stream_path}")
|
||
|
||
# Clean up empty directories left behind
|
||
self._cleanup_empty_directories(download_path, found_file)
|
||
|
||
# Signal that streaming is ready (100% progress)
|
||
self.streaming_progress.emit(100.0, self.search_result)
|
||
self.streaming_finished.emit(f"Stream ready: {os.path.basename(found_file)}", self.search_result)
|
||
self.temp_file_path = stream_path
|
||
print(f"✓ Stream file ready for playback: {stream_path}")
|
||
break
|
||
|
||
except Exception as e:
|
||
print(f"Error moving file to stream folder: {e}")
|
||
self.streaming_failed.emit(f"Failed to prepare stream file: {e}", self.search_result)
|
||
break
|
||
else:
|
||
# Still downloading, wait a bit more
|
||
print(f"Waiting for download to complete... ({wait_count * poll_interval}s elapsed)")
|
||
time.sleep(poll_interval)
|
||
else:
|
||
# Timed out waiting for file
|
||
print(f"❌ Polling loop completed, timeout reached. found_file = {found_file}")
|
||
self.streaming_failed.emit("Stream download timed out - file not found", self.search_result)
|
||
|
||
else:
|
||
self.streaming_failed.emit("Streaming failed to start", self.search_result)
|
||
|
||
except Exception as e:
|
||
if not self._stop_requested:
|
||
self.streaming_failed.emit(str(e), self.search_result)
|
||
finally:
|
||
# Ensure proper cleanup
|
||
if loop:
|
||
try:
|
||
# Close any remaining tasks
|
||
pending = asyncio.all_tasks(loop)
|
||
for task in pending:
|
||
task.cancel()
|
||
|
||
if pending:
|
||
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
||
|
||
loop.close()
|
||
except Exception as e:
|
||
print(f"Error cleaning up streaming event loop: {e}")
|
||
|
||
def _find_streaming_download_in_transfers(self, transfers_data):
|
||
"""Find streaming download in transfer data using same logic as download queue"""
|
||
try:
|
||
if not transfers_data:
|
||
return None
|
||
|
||
# Flatten the transfers data structure (same as download queue logic)
|
||
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'])
|
||
|
||
# Look for our specific file by filename and username
|
||
target_filename = os.path.basename(self.search_result.filename)
|
||
target_username = self.search_result.username
|
||
|
||
print(f"🔍 Looking for streaming download - Target: {target_username}:{target_filename}")
|
||
print(f"🔍 Found {len(all_transfers)} total transfers in API")
|
||
|
||
for i, transfer in enumerate(all_transfers):
|
||
transfer_filename = os.path.basename(transfer.get('filename', ''))
|
||
transfer_username = transfer.get('username', '')
|
||
|
||
print(f"📁 Transfer {i+1}: {transfer_username}:{transfer_filename} - State: {transfer.get('state')} - Progress: {transfer.get('percentComplete', 0):.1f}%")
|
||
|
||
if (transfer_filename == target_filename and
|
||
transfer_username == target_username):
|
||
print(f"✅ Found matching streaming download: {transfer.get('state')} - {transfer.get('percentComplete', 0):.1f}%")
|
||
return transfer
|
||
|
||
print(f"❌ No matching streaming download found for {target_username}:{target_filename}")
|
||
return None
|
||
except Exception as e:
|
||
print(f"Error finding streaming download in transfers: {e}")
|
||
return None
|
||
|
||
def _find_downloaded_file(self, download_path):
|
||
"""Find the downloaded audio file in the downloads directory tree"""
|
||
import os
|
||
|
||
# Audio file extensions to look for
|
||
audio_extensions = {'.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a'}
|
||
|
||
# Get the base filename without path
|
||
target_filename = os.path.basename(self.search_result.filename)
|
||
|
||
try:
|
||
# Walk through the downloads directory to find the file
|
||
for root, dirs, files in os.walk(download_path):
|
||
for file in files:
|
||
# Check if this is our target file
|
||
if file == target_filename:
|
||
file_path = os.path.join(root, file)
|
||
# Verify it's an audio file and has content
|
||
if (os.path.splitext(file)[1].lower() in audio_extensions and
|
||
os.path.getsize(file_path) > 1024): # At least 1KB
|
||
return file_path
|
||
|
||
# Also check for any audio files that might match partially
|
||
# (in case filename is slightly different)
|
||
file_lower = file.lower()
|
||
target_lower = target_filename.lower()
|
||
|
||
# Remove common variations
|
||
target_clean = target_lower.replace(' ', '').replace('-', '').replace('_', '')
|
||
file_clean = file_lower.replace(' ', '').replace('-', '').replace('_', '')
|
||
|
||
if (os.path.splitext(file)[1].lower() in audio_extensions and
|
||
len(file_clean) > 10 and # Reasonable filename length
|
||
(target_clean in file_clean or file_clean in target_clean) and
|
||
os.path.getsize(os.path.join(root, file)) > 1024):
|
||
return os.path.join(root, file)
|
||
|
||
except Exception as e:
|
||
print(f"Error searching for downloaded file: {e}")
|
||
|
||
return None
|
||
|
||
def _cleanup_empty_directories(self, download_path, moved_file_path):
|
||
"""Clean up empty directories left after moving a file"""
|
||
import os
|
||
|
||
try:
|
||
# Get the directory that contained the moved file
|
||
file_dir = os.path.dirname(moved_file_path)
|
||
|
||
# Only clean up if it's a subdirectory of downloads (not the downloads folder itself)
|
||
if file_dir != download_path and file_dir.startswith(download_path):
|
||
# Check if directory is empty
|
||
if os.path.isdir(file_dir) and not os.listdir(file_dir):
|
||
print(f"Removing empty directory: {file_dir}")
|
||
os.rmdir(file_dir)
|
||
|
||
# Recursively check parent directories
|
||
parent_dir = os.path.dirname(file_dir)
|
||
if (parent_dir != download_path and
|
||
parent_dir.startswith(download_path) and
|
||
os.path.isdir(parent_dir) and
|
||
not os.listdir(parent_dir)):
|
||
print(f"Removing empty parent directory: {parent_dir}")
|
||
os.rmdir(parent_dir)
|
||
|
||
except Exception as e:
|
||
print(f"Warning: Could not clean up empty directories: {e}")
|
||
|
||
async def _do_stream_download(self):
|
||
"""Perform the streaming download using normal download mechanism"""
|
||
# Use the same download mechanism as regular downloads
|
||
# The file will be downloaded to the normal downloads folder first
|
||
return await self.soulseek_client.download(
|
||
self.search_result.username,
|
||
self.search_result.filename,
|
||
self.search_result.size
|
||
)
|
||
|
||
|
||
def stop(self):
|
||
"""Stop the streaming gracefully"""
|
||
self._stop_requested = True
|
||
|
||
class TrackItem(QFrame):
|
||
"""Individual track item within an album"""
|
||
track_download_requested = pyqtSignal(object) # TrackResult object
|
||
track_stream_requested = pyqtSignal(object) # TrackResult object
|
||
|
||
def __init__(self, track_result, parent=None):
|
||
super().__init__(parent)
|
||
self.track_result = track_result
|
||
self.setup_ui()
|
||
|
||
def setup_ui(self):
|
||
self.setFixedHeight(50)
|
||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||
|
||
self.setStyleSheet("""
|
||
TrackItem {
|
||
background: rgba(40, 40, 40, 0.5);
|
||
border-radius: 8px;
|
||
border: 1px solid rgba(60, 60, 60, 0.3);
|
||
margin: 2px 8px;
|
||
}
|
||
TrackItem:hover {
|
||
background: rgba(50, 50, 50, 0.7);
|
||
border: 1px solid rgba(29, 185, 84, 0.5);
|
||
}
|
||
""")
|
||
|
||
layout = QHBoxLayout(self)
|
||
layout.setContentsMargins(12, 8, 12, 8)
|
||
layout.setSpacing(12)
|
||
|
||
# Track info
|
||
track_info = QVBoxLayout()
|
||
track_info.setSpacing(2)
|
||
|
||
# Track title
|
||
title = QLabel(self.track_result.title or "Unknown Title")
|
||
title.setFont(QFont("Arial", 11, QFont.Weight.Bold))
|
||
title.setStyleSheet("color: #ffffff;")
|
||
|
||
# Track details - enhanced with more information including prominent artist display
|
||
details = []
|
||
if self.track_result.track_number:
|
||
details.append(f"#{self.track_result.track_number:02d}")
|
||
|
||
# Always show artist information prominently for tracks within albums
|
||
if self.track_result.artist:
|
||
details.append(f"🎤 {self.track_result.artist}")
|
||
|
||
details.append(self.track_result.quality.upper())
|
||
if self.track_result.bitrate:
|
||
details.append(f"{self.track_result.bitrate}kbps")
|
||
|
||
# Add duration if available
|
||
if self.track_result.duration:
|
||
duration_mins = self.track_result.duration // 60
|
||
duration_secs = self.track_result.duration % 60
|
||
details.append(f"{duration_mins}:{duration_secs:02d}")
|
||
|
||
details.append(f"{self.track_result.size // (1024*1024)}MB")
|
||
|
||
details_text = " • ".join(details)
|
||
track_details = QLabel(details_text)
|
||
track_details.setFont(QFont("Arial", 9))
|
||
track_details.setStyleSheet("color: rgba(179, 179, 179, 0.8);")
|
||
|
||
track_info.addWidget(title)
|
||
track_info.addWidget(track_details)
|
||
|
||
# Control buttons
|
||
button_layout = QHBoxLayout()
|
||
button_layout.setSpacing(8)
|
||
|
||
# Play button
|
||
play_btn = QPushButton("▶️")
|
||
play_btn.setFixedSize(32, 32)
|
||
play_btn.clicked.connect(self.request_stream)
|
||
play_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: rgba(29, 185, 84, 0.8);
|
||
border: none;
|
||
border-radius: 16px;
|
||
color: #000000;
|
||
font-size: 12px;
|
||
font-weight: bold;
|
||
}
|
||
QPushButton:hover {
|
||
background: rgba(30, 215, 96, 1.0);
|
||
}
|
||
""")
|
||
|
||
# Download button
|
||
download_btn = QPushButton("⬇️")
|
||
download_btn.setFixedSize(32, 32)
|
||
download_btn.clicked.connect(self.request_download)
|
||
download_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: rgba(64, 64, 64, 0.8);
|
||
border: 1px solid rgba(29, 185, 84, 0.6);
|
||
border-radius: 16px;
|
||
color: #1db954;
|
||
font-size: 10px;
|
||
}
|
||
QPushButton:hover {
|
||
background: rgba(29, 185, 84, 0.2);
|
||
}
|
||
""")
|
||
|
||
# Matched Download button
|
||
matched_download_btn = QPushButton("📱")
|
||
matched_download_btn.setFixedSize(32, 32)
|
||
matched_download_btn.clicked.connect(self.request_matched_download)
|
||
matched_download_btn.setToolTip("Download with Spotify Matching")
|
||
matched_download_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: rgba(64, 64, 64, 0.8);
|
||
border: 1px solid rgba(147, 51, 234, 0.6);
|
||
border-radius: 16px;
|
||
color: #9333ea;
|
||
font-size: 10px;
|
||
}
|
||
QPushButton:hover {
|
||
background: rgba(147, 51, 234, 0.2);
|
||
}
|
||
""")
|
||
|
||
button_layout.addWidget(play_btn)
|
||
button_layout.addWidget(download_btn)
|
||
button_layout.addWidget(matched_download_btn)
|
||
|
||
# Store button references for state management
|
||
self.play_btn = play_btn
|
||
self.download_btn = download_btn
|
||
self.matched_download_btn = matched_download_btn
|
||
|
||
# Assembly
|
||
layout.addLayout(track_info, 1)
|
||
layout.addLayout(button_layout)
|
||
|
||
def request_stream(self):
|
||
"""Request streaming of this track"""
|
||
self.track_stream_requested.emit(self.track_result)
|
||
|
||
def request_download(self):
|
||
"""Request download of this track"""
|
||
self.track_download_requested.emit(self.track_result)
|
||
|
||
def request_matched_download(self):
|
||
"""Request a matched download with Spotify integration"""
|
||
# Get reference to the DownloadsPage to handle matched download
|
||
downloads_page = self.get_downloads_page()
|
||
if downloads_page:
|
||
downloads_page.start_matched_download(self.track_result)
|
||
|
||
def get_downloads_page(self):
|
||
"""Get reference to the parent DownloadsPage"""
|
||
parent = self.parent()
|
||
while parent:
|
||
if hasattr(parent, 'audio_player'): # DownloadsPage has audio_player
|
||
return parent
|
||
parent = parent.parent()
|
||
return None
|
||
|
||
def set_loading_state(self):
|
||
"""Set play button to loading state"""
|
||
self.play_btn.setText("⏳")
|
||
self.play_btn.setEnabled(False)
|
||
|
||
def set_queue_state(self):
|
||
"""Set play button to queue state"""
|
||
self.play_btn.setText("📋")
|
||
self.play_btn.setEnabled(False)
|
||
self.play_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: rgba(255, 165, 0, 0.2);
|
||
border: 1px solid rgba(255, 165, 0, 0.4);
|
||
border-radius: 18px;
|
||
color: rgba(255, 165, 0, 0.8);
|
||
font-size: 12px;
|
||
}
|
||
""")
|
||
|
||
def set_download_queued_state(self):
|
||
"""Set download button to queued state (disabled, shows queued)"""
|
||
self.download_btn.setText("⏳")
|
||
self.download_btn.setEnabled(False)
|
||
self.download_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: rgba(100, 100, 100, 0.5);
|
||
border: 1px solid rgba(150, 150, 150, 0.3);
|
||
border-radius: 16px;
|
||
color: rgba(255, 255, 255, 0.6);
|
||
font-size: 10px;
|
||
}
|
||
""")
|
||
|
||
def set_download_downloading_state(self):
|
||
"""Set download button to downloading state"""
|
||
self.download_btn.setText("📥")
|
||
self.download_btn.setEnabled(False)
|
||
self.download_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: rgba(29, 185, 84, 0.3);
|
||
border: 1px solid rgba(29, 185, 84, 0.6);
|
||
border-radius: 16px;
|
||
color: #1db954;
|
||
font-size: 10px;
|
||
}
|
||
""")
|
||
|
||
def set_download_completed_state(self):
|
||
"""Set download button to completed state"""
|
||
self.download_btn.setText("✅")
|
||
self.download_btn.setEnabled(False)
|
||
self.download_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: rgba(40, 167, 69, 0.3);
|
||
border: 1px solid rgba(40, 167, 69, 0.6);
|
||
border-radius: 16px;
|
||
color: #28a745;
|
||
font-size: 10px;
|
||
}
|
||
""")
|
||
|
||
def reset_download_state(self):
|
||
"""Reset download button to default state"""
|
||
self.download_btn.setText("⬇️")
|
||
self.download_btn.setEnabled(True)
|
||
self.download_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: rgba(64, 64, 64, 0.8);
|
||
border: 1px solid rgba(29, 185, 84, 0.6);
|
||
border-radius: 16px;
|
||
color: #1db954;
|
||
font-size: 10px;
|
||
}
|
||
QPushButton:hover {
|
||
background: rgba(29, 185, 84, 0.2);
|
||
}
|
||
""")
|
||
|
||
def set_playing_state(self):
|
||
"""Set play button to playing/pause state"""
|
||
self.play_btn.setText("⏸️")
|
||
self.play_btn.setEnabled(True)
|
||
|
||
def reset_play_state(self):
|
||
"""Reset play button to default state"""
|
||
self.play_btn.setText("▶️")
|
||
self.play_btn.setEnabled(True)
|
||
|
||
class AlbumResultItem(QFrame):
|
||
"""Expandable UI component for displaying album search results"""
|
||
album_download_requested = pyqtSignal(object) # AlbumResult object
|
||
matched_album_download_requested = pyqtSignal(object) # AlbumResult object for matched download
|
||
track_download_requested = pyqtSignal(object) # TrackResult object
|
||
track_stream_requested = pyqtSignal(object, object) # TrackResult object, TrackItem object
|
||
|
||
def __init__(self, album_result, parent=None):
|
||
super().__init__(parent)
|
||
self.album_result = album_result
|
||
self.is_expanded = False
|
||
self.track_items = []
|
||
self.tracks_container = None
|
||
self.setup_ui()
|
||
|
||
def setup_ui(self):
|
||
# Dynamic height based on expansion state with better proportions
|
||
self.collapsed_height = 110 # Increased from 80px for better breathing room
|
||
self.setFixedHeight(self.collapsed_height)
|
||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||
|
||
# Enable mouse tracking for click detection
|
||
self.setMouseTracking(True)
|
||
|
||
self.setStyleSheet("""
|
||
AlbumResultItem {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(52, 52, 58, 0.95),
|
||
stop:1 rgba(42, 42, 48, 0.98));
|
||
border-radius: 20px;
|
||
border: 1px solid rgba(75, 75, 82, 0.5);
|
||
margin: 10px 5px;
|
||
}
|
||
AlbumResultItem:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(60, 60, 68, 0.98),
|
||
stop:1 rgba(50, 50, 58, 1.0));
|
||
border: 1px solid rgba(29, 185, 84, 0.8);
|
||
}
|
||
""")
|
||
|
||
# Main vertical layout for album header + tracks
|
||
main_layout = QVBoxLayout(self)
|
||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||
main_layout.setSpacing(0)
|
||
|
||
# Album header (always visible, clickable)
|
||
self.header_widget = QWidget()
|
||
self.header_widget.setFixedHeight(90) # Increased to match collapsed_height
|
||
self.header_widget.setStyleSheet("QWidget { background: transparent; }")
|
||
header_layout = QHBoxLayout(self.header_widget)
|
||
header_layout.setContentsMargins(16, 12, 16, 16) # More balanced padding - reduced top, added bottom
|
||
header_layout.setSpacing(16) # Consistent spacing with other elements
|
||
|
||
# Album icon with expand indicator
|
||
icon_container = QVBoxLayout()
|
||
album_icon = QLabel("💿")
|
||
album_icon.setFixedSize(48, 48) # Larger for better presence
|
||
album_icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
album_icon.setStyleSheet("""
|
||
QLabel {
|
||
font-size: 24px;
|
||
background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
|
||
stop:0 rgba(29, 185, 84, 0.2),
|
||
stop:1 rgba(24, 156, 71, 0.15));
|
||
border-radius: 24px;
|
||
border: 2px solid rgba(29, 185, 84, 0.4);
|
||
color: rgba(29, 185, 84, 1.0);
|
||
}
|
||
""")
|
||
|
||
# Expand indicator
|
||
self.expand_indicator = QLabel("▶")
|
||
self.expand_indicator.setFixedSize(20, 20) # Slightly larger
|
||
self.expand_indicator.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self.expand_indicator.setStyleSheet("""
|
||
QLabel {
|
||
color: rgba(29, 185, 84, 0.9);
|
||
font-size: 14px;
|
||
font-weight: bold;
|
||
background: rgba(29, 185, 84, 0.1);
|
||
border-radius: 10px;
|
||
border: 1px solid rgba(29, 185, 84, 0.2);
|
||
}
|
||
""")
|
||
|
||
icon_container.addWidget(album_icon)
|
||
icon_container.addWidget(self.expand_indicator)
|
||
|
||
# Album info section
|
||
info_section = QVBoxLayout()
|
||
info_section.setSpacing(2)
|
||
info_section.setContentsMargins(0, 0, 0, 0)
|
||
|
||
# Album title
|
||
album_title = QLabel(self.album_result.album_title)
|
||
album_title.setFont(QFont("Arial", 12, QFont.Weight.Bold))
|
||
album_title.setStyleSheet("color: #ffffff;")
|
||
|
||
# Artist and details - with prominent artist display
|
||
details = []
|
||
|
||
# Make artist more prominent by placing it first and with better formatting
|
||
if self.album_result.artist:
|
||
details.append(f"🎤 {self.album_result.artist}")
|
||
|
||
details.append(f"{self.album_result.track_count} tracks")
|
||
details.append(f"{self.album_result.size_mb}MB")
|
||
details.append(self.album_result.dominant_quality.upper())
|
||
if self.album_result.year:
|
||
details.append(f"({self.album_result.year})")
|
||
|
||
# Add speed information
|
||
speed_info = self._get_album_speed_display()
|
||
if speed_info:
|
||
details.append(speed_info)
|
||
|
||
details_text = " • ".join(details)
|
||
album_details = QLabel(details_text)
|
||
album_details.setFont(QFont("Arial", 10))
|
||
album_details.setStyleSheet("color: rgba(179, 179, 179, 0.9);")
|
||
|
||
# User info
|
||
user_info = QLabel(f"👤 {self.album_result.username}")
|
||
user_info.setFont(QFont("Arial", 9))
|
||
user_info.setStyleSheet("color: rgba(29, 185, 84, 0.8);")
|
||
|
||
info_section.addWidget(album_title)
|
||
info_section.addWidget(album_details)
|
||
info_section.addWidget(user_info)
|
||
|
||
# Download buttons layout
|
||
download_buttons_layout = QVBoxLayout()
|
||
download_buttons_layout.setSpacing(4)
|
||
|
||
# Download button
|
||
self.download_btn = QPushButton("⬇️ Download Album")
|
||
self.download_btn.setFixedSize(160, 36) # Slightly larger for better presence
|
||
self.download_btn.clicked.connect(self.request_album_download)
|
||
self.download_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(29, 185, 84, 0.95),
|
||
stop:1 rgba(24, 156, 71, 0.9));
|
||
border: 2px solid rgba(29, 185, 84, 0.3);
|
||
border-radius: 18px;
|
||
color: #000000;
|
||
font-size: 12px;
|
||
font-weight: bold;
|
||
padding: 8px 12px;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(30, 215, 96, 1.0),
|
||
stop:1 rgba(25, 180, 80, 1.0));
|
||
border: 2px solid rgba(29, 185, 84, 0.6);
|
||
}
|
||
QPushButton:pressed {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(20, 150, 70, 1.0),
|
||
stop:1 rgba(15, 120, 60, 1.0));
|
||
}
|
||
""")
|
||
|
||
# Matched Download button
|
||
self.matched_download_btn = QPushButton("🎯 Matched Album")
|
||
self.matched_download_btn.setFixedSize(160, 36) # Match the other button
|
||
self.matched_download_btn.clicked.connect(self.request_matched_album_download)
|
||
self.matched_download_btn.setToolTip("Download Album with Spotify Matching")
|
||
self.matched_download_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(147, 51, 234, 0.95),
|
||
stop:1 rgba(124, 43, 200, 0.9));
|
||
border: 2px solid rgba(147, 51, 234, 0.3);
|
||
border-radius: 18px;
|
||
color: #ffffff;
|
||
font-size: 12px;
|
||
font-weight: bold;
|
||
padding: 8px 12px;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(167, 71, 254, 1.0),
|
||
stop:1 rgba(144, 63, 220, 1.0));
|
||
border: 2px solid rgba(147, 51, 234, 0.6);
|
||
}
|
||
QPushButton:pressed {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(120, 50, 200, 1.0),
|
||
stop:1 rgba(100, 40, 180, 1.0));
|
||
}
|
||
""")
|
||
|
||
download_buttons_layout.addWidget(self.download_btn)
|
||
download_buttons_layout.addWidget(self.matched_download_btn)
|
||
|
||
# Set minimum width to ensure buttons always visible
|
||
self.setMinimumWidth(420) # Increased to accommodate larger buttons
|
||
|
||
# Assembly header
|
||
header_layout.addLayout(icon_container)
|
||
header_layout.addLayout(info_section, 1) # Flexible content area
|
||
header_layout.addLayout(download_buttons_layout, 0) # Fixed button area, always visible
|
||
|
||
# Tracks container (hidden by default)
|
||
self.tracks_container = QWidget()
|
||
self.tracks_container.setVisible(False)
|
||
tracks_layout = QVBoxLayout(self.tracks_container)
|
||
tracks_layout.setContentsMargins(16, 8, 16, 16)
|
||
tracks_layout.setSpacing(4)
|
||
|
||
# Create track items
|
||
for track in self.album_result.tracks:
|
||
track_item = TrackItem(track)
|
||
track_item.track_download_requested.connect(self.track_download_requested.emit)
|
||
# Use lambda to pass both track result and track item reference
|
||
track_item.track_stream_requested.connect(
|
||
lambda track_result, item=track_item: self.handle_track_stream_request(track_result, item)
|
||
)
|
||
tracks_layout.addWidget(track_item)
|
||
self.track_items.append(track_item)
|
||
|
||
# Assembly main layout
|
||
main_layout.addWidget(self.header_widget)
|
||
main_layout.addWidget(self.tracks_container)
|
||
|
||
# Make header clickable
|
||
self.header_widget.mousePressEvent = self.toggle_expansion
|
||
|
||
def request_album_download(self):
|
||
"""Request download of the entire album"""
|
||
self.download_btn.setText("⏳")
|
||
self.download_btn.setEnabled(False)
|
||
self.album_download_requested.emit(self.album_result)
|
||
|
||
def request_matched_album_download(self):
|
||
"""Request matched download of the entire album with Spotify integration"""
|
||
self.matched_download_btn.setText("⏳")
|
||
self.matched_download_btn.setEnabled(False)
|
||
self.matched_album_download_requested.emit(self.album_result)
|
||
|
||
def toggle_expansion(self, event):
|
||
"""Toggle album expansion to show/hide tracks"""
|
||
self.is_expanded = not self.is_expanded
|
||
|
||
if self.is_expanded:
|
||
# Expand to show tracks
|
||
self.tracks_container.setVisible(True)
|
||
self.expand_indicator.setText("▼")
|
||
# Calculate height: header + (tracks * track_height) + padding
|
||
track_height = 54 # 50px + margin
|
||
total_height = self.collapsed_height + (len(self.track_items) * track_height) + 24
|
||
self.setFixedHeight(total_height)
|
||
else:
|
||
# Collapse to hide tracks
|
||
self.tracks_container.setVisible(False)
|
||
self.expand_indicator.setText("▶")
|
||
self.setFixedHeight(self.collapsed_height)
|
||
|
||
# Force layout update
|
||
self.updateGeometry()
|
||
if self.parent():
|
||
self.parent().updateGeometry()
|
||
|
||
def handle_track_stream_request(self, track_result, track_item):
|
||
"""Handle stream request from a track item, passing the correct button reference"""
|
||
# Emit the stream request with the track item that contains the button
|
||
self.track_stream_requested.emit(track_result, track_item)
|
||
|
||
def _get_album_speed_display(self):
|
||
"""Get formatted speed display for album cards"""
|
||
# Get speed data from album result
|
||
speed = getattr(self.album_result, 'upload_speed', None) or 0
|
||
slots = getattr(self.album_result, 'free_upload_slots', None) or 0
|
||
|
||
if speed > 0:
|
||
# Use same logic as Singles but return text only (no icons for inline display)
|
||
if speed > 200:
|
||
icon = "🚀"
|
||
elif speed > 100:
|
||
icon = "🚀" if slots > 0 else "⚡"
|
||
elif speed > 50:
|
||
icon = "⚡"
|
||
else:
|
||
icon = "🐌"
|
||
|
||
# Convert to MB/s and format
|
||
speed_mb = speed / 1024
|
||
if speed_mb >= 1:
|
||
return f"{icon} {speed_mb:.1f}MB/s"
|
||
else:
|
||
return f"{icon} {speed}KB/s"
|
||
|
||
return None # No speed data
|
||
|
||
class SearchResultItem(QFrame):
|
||
download_requested = pyqtSignal(object) # SearchResult object
|
||
stream_requested = pyqtSignal(object) # SearchResult object for streaming
|
||
expansion_requested = pyqtSignal(object) # Signal when this item wants to expand
|
||
|
||
def __init__(self, search_result, parent=None):
|
||
super().__init__(parent)
|
||
self.search_result = search_result
|
||
self.is_downloading = False
|
||
self.is_expanded = False
|
||
self.setup_ui()
|
||
|
||
def setup_ui(self):
|
||
# Dynamic height based on state (compact: 85px, expanded: 200px for better visual breathing room)
|
||
self.compact_height = 85 # Increased from 75px to match Albums proportions
|
||
self.expanded_height = 200 # Increased from 180px for more comfortable content layout
|
||
self.setFixedHeight(self.compact_height)
|
||
|
||
# Ensure consistent sizing and layout behavior
|
||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||
|
||
# Enable mouse tracking for click detection
|
||
self.setMouseTracking(True)
|
||
|
||
self.setStyleSheet("""
|
||
SearchResultItem {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(48, 48, 52, 0.95),
|
||
stop:1 rgba(38, 38, 42, 0.98));
|
||
border-radius: 18px;
|
||
border: 1px solid rgba(70, 70, 76, 0.5);
|
||
margin: 8px 4px;
|
||
}
|
||
SearchResultItem:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(55, 55, 60, 0.98),
|
||
stop:1 rgba(45, 45, 50, 1.0));
|
||
border: 1px solid rgba(29, 185, 84, 0.8);
|
||
}
|
||
""")
|
||
|
||
layout = QHBoxLayout(self)
|
||
layout.setContentsMargins(16, 16, 16, 16) # Match Albums margins for consistency
|
||
layout.setSpacing(16) # Increased spacing for better visual separation
|
||
|
||
# Left section: Music icon + filename
|
||
left_section = QHBoxLayout()
|
||
left_section.setSpacing(12) # Increased from 8px for better separation
|
||
|
||
# Enhanced music icon with modern styling
|
||
music_icon = QLabel("🎵")
|
||
music_icon.setFixedSize(44, 44) # Slightly larger for better presence
|
||
music_icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
music_icon.setStyleSheet("""
|
||
QLabel {
|
||
background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
|
||
stop:0 rgba(29, 185, 84, 0.3),
|
||
stop:1 rgba(24, 156, 71, 0.2));
|
||
border-radius: 22px;
|
||
border: 2px solid rgba(29, 185, 84, 0.4);
|
||
font-size: 18px;
|
||
color: rgba(29, 185, 84, 1.0);
|
||
}
|
||
QLabel:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
|
||
stop:0 rgba(29, 185, 84, 0.5),
|
||
stop:1 rgba(24, 156, 71, 0.3));
|
||
border: 2px solid rgba(29, 185, 84, 0.7);
|
||
}
|
||
""")
|
||
|
||
# Content area that will change based on expanded state
|
||
self.content_widget = QWidget()
|
||
self.content_layout = QVBoxLayout(self.content_widget)
|
||
self.content_layout.setContentsMargins(0, 4, 0, 4) # Increased vertical margins for better centering
|
||
self.content_layout.setSpacing(2) # Reduced spacing to prevent text cut-off
|
||
|
||
# Extract song info
|
||
primary_info = self._extract_song_info()
|
||
|
||
# Create both compact and expanded content but show only one
|
||
self.create_persistent_content(primary_info)
|
||
|
||
# Right section: Play and download buttons
|
||
buttons_layout = QHBoxLayout()
|
||
buttons_layout.setSpacing(8) # Increased from 4px for better button separation
|
||
|
||
# Play button for streaming preview
|
||
self.play_btn = QPushButton("▶️")
|
||
self.play_btn.setFixedSize(46, 46) # Larger for better accessibility
|
||
self.play_btn.clicked.connect(self.request_stream)
|
||
self.play_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(255, 193, 7, 0.95),
|
||
stop:1 rgba(255, 152, 0, 0.9));
|
||
border: 2px solid rgba(255, 193, 7, 0.3);
|
||
border-radius: 23px;
|
||
color: #000000;
|
||
font-size: 18px;
|
||
font-weight: bold;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(255, 213, 79, 1.0),
|
||
stop:1 rgba(255, 171, 64, 1.0));
|
||
border: 2px solid rgba(255, 193, 7, 0.6);
|
||
}
|
||
QPushButton:pressed {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(255, 152, 0, 1.0),
|
||
stop:1 rgba(245, 124, 0, 1.0));
|
||
}
|
||
""")
|
||
|
||
# Download button
|
||
self.download_btn = QPushButton("⬇️")
|
||
self.download_btn.setFixedSize(46, 46) # Match play button size
|
||
self.download_btn.clicked.connect(self.request_download)
|
||
self.download_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(29, 185, 84, 0.95),
|
||
stop:1 rgba(24, 156, 71, 0.9));
|
||
border: 2px solid rgba(29, 185, 84, 0.3);
|
||
border-radius: 23px;
|
||
color: #000000;
|
||
font-size: 18px;
|
||
font-weight: bold;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(30, 215, 96, 1.0),
|
||
stop:1 rgba(25, 180, 80, 1.0));
|
||
border: 2px solid rgba(29, 185, 84, 0.6);
|
||
}
|
||
QPushButton:pressed {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(24, 156, 71, 1.0),
|
||
stop:1 rgba(20, 130, 60, 1.0));
|
||
}
|
||
""")
|
||
|
||
# Matched Download button
|
||
self.matched_download_btn = QPushButton("🎯")
|
||
self.matched_download_btn.setFixedSize(46, 46) # Match other buttons
|
||
self.matched_download_btn.clicked.connect(self.request_matched_download)
|
||
self.matched_download_btn.setToolTip("Download with Spotify Matching")
|
||
self.matched_download_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(147, 51, 234, 0.95),
|
||
stop:1 rgba(124, 43, 200, 0.9));
|
||
border: 2px solid rgba(147, 51, 234, 0.3);
|
||
border-radius: 23px;
|
||
color: #ffffff;
|
||
font-size: 18px;
|
||
font-weight: bold;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(167, 71, 254, 1.0),
|
||
stop:1 rgba(144, 63, 220, 1.0));
|
||
border: 2px solid rgba(147, 51, 234, 0.6);
|
||
}
|
||
QPushButton:pressed {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(124, 43, 200, 1.0),
|
||
stop:1 rgba(104, 33, 180, 1.0));
|
||
}
|
||
""")
|
||
|
||
# Assemble the layout
|
||
left_section.addWidget(music_icon)
|
||
left_section.addWidget(self.content_widget, 1)
|
||
|
||
buttons_layout.addWidget(self.play_btn)
|
||
buttons_layout.addWidget(self.download_btn)
|
||
buttons_layout.addWidget(self.matched_download_btn)
|
||
|
||
# Set minimum width to ensure buttons always visible
|
||
self.setMinimumWidth(300) # Ensure minimum space for content + buttons
|
||
|
||
layout.addLayout(left_section, 1) # Flexible content area
|
||
layout.addLayout(buttons_layout, 0) # Fixed button area, always visible
|
||
|
||
def create_persistent_content(self, primary_info):
|
||
"""Create both compact and expanded content with visibility control"""
|
||
# Title row (always visible) with character limit and ellipsis
|
||
title_text = primary_info['title']
|
||
if len(title_text) > 55: # Increased character limit since smaller font fits more text
|
||
title_text = title_text[:52] + "..."
|
||
|
||
self.title_label = QLabel(title_text)
|
||
self.title_label.setFont(QFont("Arial", 12, QFont.Weight.Bold)) # 12px matches Albums and prevents cut-off
|
||
self.title_label.setStyleSheet("color: #ffffff; letter-spacing: 0.2px;")
|
||
self.title_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||
# Ensure text doesn't overflow the label and allow click-through
|
||
self.title_label.setWordWrap(False)
|
||
# Remove text selection to allow clicks to propagate to parent widget
|
||
self.title_label.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction)
|
||
|
||
# Expand indicator with enhanced styling
|
||
self.expand_indicator = QLabel("⏵")
|
||
self.expand_indicator.setFixedSize(20, 20) # Increased size for better visibility
|
||
self.expand_indicator.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self.expand_indicator.setStyleSheet("""
|
||
QLabel {
|
||
color: rgba(255, 255, 255, 0.7);
|
||
font-size: 12px;
|
||
background: rgba(255, 255, 255, 0.1);
|
||
border-radius: 10px;
|
||
}
|
||
QLabel:hover {
|
||
color: rgba(29, 185, 84, 0.9);
|
||
background: rgba(29, 185, 84, 0.15);
|
||
}
|
||
""")
|
||
|
||
# Quality badge (now visible in compact view)
|
||
self.quality_badge = self._create_compact_quality_badge()
|
||
|
||
# Create uploader info label for compact view with artist information
|
||
result = self.search_result[0] if isinstance(self.search_result, list) else self.search_result
|
||
|
||
# Build secondary info with artist prominently displayed (excluding uploader)
|
||
info_parts = []
|
||
|
||
# Add artist information if available
|
||
if hasattr(result, 'artist') and result.artist:
|
||
info_parts.append(f"🎤 {result.artist}")
|
||
|
||
# Add quality info
|
||
quality_text = result.quality.upper()
|
||
if result.bitrate:
|
||
quality_text += f" • {result.bitrate}kbps"
|
||
info_parts.append(quality_text)
|
||
|
||
# Add size info
|
||
size_mb = result.size // (1024*1024)
|
||
info_parts.append(f"{size_mb}MB")
|
||
|
||
secondary_info_text = " • ".join(info_parts)
|
||
self.secondary_info = QLabel(secondary_info_text)
|
||
self.secondary_info.setFont(QFont("Arial", 9, QFont.Weight.Normal))
|
||
self.secondary_info.setStyleSheet("color: rgba(179, 179, 179, 0.8); letter-spacing: 0.1px;")
|
||
self.secondary_info.setWordWrap(False)
|
||
self.secondary_info.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction)
|
||
|
||
# Create separate uploader info with green styling like albums
|
||
self.uploader_info = QLabel(f"👤 {result.username}")
|
||
self.uploader_info.setFont(QFont("Arial", 9))
|
||
self.uploader_info.setStyleSheet("color: rgba(29, 185, 84, 0.8);")
|
||
self.uploader_info.setWordWrap(False)
|
||
self.uploader_info.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction)
|
||
|
||
title_row = QHBoxLayout()
|
||
title_row.setContentsMargins(0, 0, 0, 0)
|
||
title_row.addWidget(self.title_label)
|
||
title_row.addWidget(self.quality_badge)
|
||
title_row.addWidget(self.expand_indicator)
|
||
|
||
# Add secondary info row for compact view with uploader info
|
||
secondary_row = QHBoxLayout()
|
||
secondary_row.setContentsMargins(0, 0, 0, 0) # Remove margins to prevent cut-off
|
||
secondary_row.addWidget(self.secondary_info)
|
||
secondary_row.addStretch() # Push text to left
|
||
secondary_row.addWidget(self.uploader_info) # Add green uploader info on the right
|
||
|
||
# Expanded content (initially hidden)
|
||
self.expanded_content = QWidget()
|
||
expanded_layout = QVBoxLayout(self.expanded_content)
|
||
expanded_layout.setContentsMargins(0, 4, 0, 4) # Small margins for better text positioning
|
||
expanded_layout.setSpacing(4) # Increased from 1px to 4px for better readability
|
||
|
||
# Expanded content shows only unique information not in compact view
|
||
# Duration info (if available) - this is unique to expanded view
|
||
expanded_details = []
|
||
if self.search_result.duration:
|
||
duration_mins = self.search_result.duration // 60
|
||
duration_secs = self.search_result.duration % 60
|
||
expanded_details.append(f"Duration: {duration_mins}:{duration_secs:02d}")
|
||
|
||
# Full file path info (unique to expanded view)
|
||
result = self.search_result[0] if isinstance(self.search_result, list) else self.search_result
|
||
if hasattr(result, 'filename'):
|
||
expanded_details.append(f"File: {result.filename}")
|
||
|
||
if expanded_details:
|
||
self.expanded_details = QLabel(" • ".join(expanded_details))
|
||
self.expanded_details.setFont(QFont("Arial", 10))
|
||
self.expanded_details.setStyleSheet("color: rgba(136, 136, 136, 0.8); letter-spacing: 0.1px;")
|
||
self.expanded_details.setWordWrap(True) # Allow wrapping for long filenames
|
||
expanded_layout.addWidget(self.expanded_details)
|
||
|
||
# Speed indicator (unique to expanded view)
|
||
self.speed_indicator = self._create_compact_speed_indicator()
|
||
speed_row = QHBoxLayout()
|
||
speed_row.addWidget(self.speed_indicator)
|
||
speed_row.addStretch()
|
||
expanded_layout.addLayout(speed_row)
|
||
|
||
# Initially hide expanded content
|
||
self.expanded_content.hide()
|
||
|
||
# Add to main layout
|
||
self.content_layout.addLayout(title_row)
|
||
self.content_layout.addLayout(secondary_row) # Add secondary info row
|
||
self.content_layout.addWidget(self.expanded_content)
|
||
|
||
def update_expanded_state(self):
|
||
"""Update UI based on expanded state without recreating widgets"""
|
||
if self.is_expanded:
|
||
self.expand_indicator.setText("⏷")
|
||
self.expand_indicator.setStyleSheet("""
|
||
QLabel {
|
||
color: rgba(29, 185, 84, 0.9);
|
||
font-size: 14px;
|
||
background: rgba(29, 185, 84, 0.15);
|
||
border-radius: 10px;
|
||
}
|
||
""")
|
||
self.expanded_content.show()
|
||
else:
|
||
self.expand_indicator.setText("⏵")
|
||
self.expand_indicator.setStyleSheet("""
|
||
QLabel {
|
||
color: rgba(255, 255, 255, 0.7);
|
||
font-size: 14px;
|
||
background: rgba(255, 255, 255, 0.1);
|
||
border-radius: 10px;
|
||
}
|
||
QLabel:hover {
|
||
color: rgba(29, 185, 84, 0.9);
|
||
background: rgba(29, 185, 84, 0.15);
|
||
}
|
||
""")
|
||
self.expanded_content.hide()
|
||
|
||
# Quality badge is now always visible in compact view
|
||
|
||
def mousePressEvent(self, event):
|
||
"""Handle mouse clicks to toggle expand/collapse"""
|
||
# Only respond to left clicks and avoid clicks on the download button
|
||
if event.button() == Qt.MouseButton.LeftButton:
|
||
# Check if click is on download button (more precise detection)
|
||
button_rect = self.download_btn.geometry()
|
||
# Add some padding to the button area to be more forgiving
|
||
button_rect.adjust(-5, -5, 5, 5)
|
||
if not button_rect.contains(event.pos()):
|
||
# Emit signal to parent to handle accordion behavior
|
||
self.expansion_requested.emit(self)
|
||
super().mousePressEvent(event)
|
||
|
||
def set_expanded(self, expanded, animate=True):
|
||
"""Set expanded state externally (called by parent for accordion behavior)"""
|
||
if self.is_expanded == expanded:
|
||
return # No change needed
|
||
|
||
self.is_expanded = expanded
|
||
|
||
if animate:
|
||
self._animate_to_state()
|
||
else:
|
||
# Immediate state change without animation
|
||
if self.is_expanded:
|
||
self.setFixedHeight(self.expanded_height)
|
||
else:
|
||
self.setFixedHeight(self.compact_height)
|
||
self.update_expanded_state()
|
||
|
||
def toggle_expanded(self):
|
||
"""Toggle between compact and expanded states with animation"""
|
||
self.set_expanded(not self.is_expanded, animate=True)
|
||
|
||
def _animate_to_state(self):
|
||
"""Animate to the current expanded state with enhanced easing"""
|
||
from PyQt6.QtCore import QPropertyAnimation, QEasingCurve
|
||
|
||
# Start height animation with smoother easing
|
||
self.animation = QPropertyAnimation(self, b"minimumHeight")
|
||
self.animation.setDuration(300) # Slightly longer for smoother feel
|
||
self.animation.setEasingCurve(QEasingCurve.Type.OutQuart) # More elegant easing curve
|
||
|
||
if self.is_expanded:
|
||
# Expand animation
|
||
self.animation.setStartValue(self.compact_height)
|
||
self.animation.setEndValue(self.expanded_height)
|
||
# Show content immediately for expand (feels more responsive)
|
||
self.update_expanded_state()
|
||
else:
|
||
# Collapse animation
|
||
self.animation.setStartValue(self.expanded_height)
|
||
self.animation.setEndValue(self.compact_height)
|
||
# Hide content immediately for collapse (cleaner look)
|
||
self.update_expanded_state()
|
||
|
||
# Update fixed height when animation completes
|
||
self.animation.finished.connect(self._finalize_height)
|
||
self.animation.start()
|
||
|
||
def _finalize_height(self):
|
||
"""Set final height after animation completes"""
|
||
if self.is_expanded:
|
||
self.setFixedHeight(self.expanded_height)
|
||
else:
|
||
self.setFixedHeight(self.compact_height)
|
||
|
||
# Force parent layout update to ensure proper spacing
|
||
if self.parent():
|
||
self.parent().updateGeometry()
|
||
|
||
def sizeHint(self):
|
||
"""Provide consistent size hint for layout calculations"""
|
||
if self.is_expanded:
|
||
return self.size().expandedTo(self.minimumSize()).boundedTo(self.maximumSize())
|
||
else:
|
||
return self.size().expandedTo(self.minimumSize()).boundedTo(self.maximumSize())
|
||
|
||
def _truncate_file_path(self, username, filename):
|
||
"""Truncate file path to show max 3 levels: file + parent + grandparent folder"""
|
||
import os
|
||
|
||
# If username looks like a simple username (no path separators), return as-is
|
||
if '/' not in username and '\\' not in username:
|
||
return username
|
||
|
||
# Get filename without extension for comparison
|
||
file_base = os.path.splitext(os.path.basename(filename))[0]
|
||
|
||
# Split path using both Windows and Unix separators
|
||
path_parts = username.replace('\\', '/').split('/')
|
||
|
||
# Remove empty parts
|
||
path_parts = [part for part in path_parts if part.strip()]
|
||
|
||
# If path is already short, return as-is
|
||
if len(path_parts) <= 3:
|
||
return '/'.join(path_parts)
|
||
|
||
# Take last 3 components (file + parent + grandparent)
|
||
truncated_parts = path_parts[-3:]
|
||
|
||
# If we truncated, add ellipsis at the beginning
|
||
if len(path_parts) > 3:
|
||
return '.../' + '/'.join(truncated_parts)
|
||
else:
|
||
return '/'.join(truncated_parts)
|
||
|
||
def _extract_song_info(self):
|
||
"""Extract song title and artist from TrackResult"""
|
||
# Handle case where search_result is a list (shouldn't happen but be defensive)
|
||
if isinstance(self.search_result, list):
|
||
if len(self.search_result) > 0:
|
||
# Take the first item if it's a list
|
||
actual_result = self.search_result[0]
|
||
else:
|
||
# Empty list, return defaults
|
||
return {'title': 'Unknown Title', 'artist': 'Unknown Artist'}
|
||
else:
|
||
actual_result = self.search_result
|
||
|
||
# TrackResult objects have parsed metadata available
|
||
if hasattr(actual_result, 'title') and hasattr(actual_result, 'artist'):
|
||
# Use parsed metadata from TrackResult
|
||
return {
|
||
'title': actual_result.title or 'Unknown Title',
|
||
'artist': actual_result.artist or 'Unknown Artist'
|
||
}
|
||
|
||
# Fallback: parse from filename if metadata not available
|
||
if hasattr(actual_result, 'filename'):
|
||
filename = actual_result.filename
|
||
|
||
# Remove file extension
|
||
name_without_ext = filename.rsplit('.', 1)[0]
|
||
|
||
# Common patterns for artist - title separation
|
||
separators = [' - ', ' – ', ' — ', '_-_', ' | ']
|
||
|
||
for sep in separators:
|
||
if sep in name_without_ext:
|
||
parts = name_without_ext.split(sep, 1)
|
||
return {
|
||
'title': parts[1].strip(),
|
||
'artist': parts[0].strip()
|
||
}
|
||
|
||
# If no separator found, use filename as title
|
||
return {
|
||
'title': name_without_ext,
|
||
'artist': 'Unknown Artist'
|
||
}
|
||
else:
|
||
# No filename attribute, return defaults
|
||
return {
|
||
'title': 'Unknown Title',
|
||
'artist': 'Unknown Artist'
|
||
}
|
||
|
||
def _create_compact_quality_badge(self):
|
||
"""Create a compact quality indicator badge"""
|
||
# Handle list case defensively
|
||
result = self.search_result[0] if isinstance(self.search_result, list) else self.search_result
|
||
|
||
quality = result.quality.upper()
|
||
bitrate = result.bitrate
|
||
|
||
if quality == 'FLAC':
|
||
badge_text = "FLAC"
|
||
badge_color = "#1db954"
|
||
elif bitrate and bitrate >= 320:
|
||
badge_text = f"{bitrate}k"
|
||
badge_color = "#1db954"
|
||
elif bitrate and bitrate >= 256:
|
||
badge_text = f"{bitrate}k"
|
||
badge_color = "#ffa500"
|
||
elif bitrate and bitrate >= 192:
|
||
badge_text = f"{bitrate}k"
|
||
badge_color = "#ffaa00"
|
||
else:
|
||
badge_text = quality[:3] # Truncate for compact display
|
||
badge_color = "#e22134"
|
||
|
||
badge = QLabel(badge_text)
|
||
badge.setFont(QFont("Arial", 8, QFont.Weight.Bold))
|
||
badge.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
badge.setFixedSize(40, 16)
|
||
badge.setStyleSheet(f"""
|
||
QLabel {{
|
||
background: {badge_color};
|
||
color: #000000;
|
||
border-radius: 8px;
|
||
padding: 1px 4px;
|
||
}}
|
||
""")
|
||
|
||
return badge
|
||
|
||
def _create_compact_speed_indicator(self):
|
||
"""Create compact upload speed indicator"""
|
||
# Handle list case defensively
|
||
result = self.search_result[0] if isinstance(self.search_result, list) else self.search_result
|
||
|
||
# Get speed and slots data with fallback handling
|
||
speed = getattr(result, 'upload_speed', None) or 0
|
||
slots = getattr(result, 'free_upload_slots', None) or 0
|
||
|
||
# Debug: Print actual values to see what we're getting
|
||
print(f"[DEBUG] Speed indicator - speed: {speed}, slots: {slots}, user: {getattr(result, 'username', 'unknown')}")
|
||
|
||
# Speed-focused logic (slots as bonus, not requirement)
|
||
if speed > 200:
|
||
indicator_color = "#1db954"
|
||
icon = "🚀"
|
||
tooltip = f"Very Fast: {speed} KB/s" + (f", {slots} slots" if slots > 0 else "")
|
||
elif speed > 100:
|
||
indicator_color = "#1db954" if slots > 0 else "#4CAF50"
|
||
icon = "🚀" if slots > 0 else "⚡"
|
||
tooltip = f"Fast: {speed} KB/s" + (f", {slots} slots" if slots > 0 else "")
|
||
elif speed > 50:
|
||
indicator_color = "#ffa500"
|
||
icon = "⚡"
|
||
tooltip = f"Good: {speed} KB/s" + (f", {slots} slots" if slots > 0 else "")
|
||
elif speed > 0:
|
||
indicator_color = "#ffaa00"
|
||
icon = "🐌"
|
||
tooltip = f"Slow: {speed} KB/s" + (f", {slots} slots" if slots > 0 else "")
|
||
else:
|
||
indicator_color = "#e22134"
|
||
icon = "⏳"
|
||
tooltip = "No speed data available"
|
||
|
||
# Convert KB/s to MB/s and format nicely
|
||
if speed > 0:
|
||
speed_mb = speed / 1024 # Convert KB to MB
|
||
if speed_mb >= 1:
|
||
speed_display = f"{speed_mb:.1f}MB/s"
|
||
else:
|
||
speed_display = f"{speed}KB/s"
|
||
speed_text = f"{icon} {speed_display}"
|
||
else:
|
||
speed_text = icon
|
||
|
||
indicator = QLabel(speed_text)
|
||
indicator.setFont(QFont("Arial", 9)) # Slightly smaller to fit text
|
||
indicator.setStyleSheet(f"color: {indicator_color};")
|
||
indicator.setToolTip(tooltip) # Add tooltip for debugging
|
||
indicator.setMinimumWidth(60) # Allow space for icon + speed text
|
||
indicator.setFixedHeight(16)
|
||
|
||
return indicator
|
||
|
||
def _create_quality_badge(self):
|
||
"""Create a quality indicator badge (legacy - kept for compatibility)"""
|
||
return self._create_compact_quality_badge()
|
||
|
||
def _create_speed_indicator(self):
|
||
"""Create upload speed indicator (legacy - kept for compatibility)"""
|
||
return self._create_compact_speed_indicator()
|
||
|
||
def request_download(self):
|
||
if not self.is_downloading:
|
||
self.is_downloading = True
|
||
self.download_btn.setText("⏳")
|
||
self.download_btn.setEnabled(False)
|
||
self.download_requested.emit(self.search_result)
|
||
|
||
def request_matched_download(self):
|
||
"""Request a matched download with Spotify integration"""
|
||
if not self.is_downloading:
|
||
# Get reference to the DownloadsPage to handle matched download
|
||
downloads_page = self.get_downloads_page()
|
||
if downloads_page:
|
||
downloads_page.start_matched_download(self.search_result)
|
||
|
||
def request_stream(self):
|
||
"""Request streaming of this audio file"""
|
||
# Check if file is a valid audio type
|
||
audio_extensions = ['.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav']
|
||
filename_lower = self.search_result.filename.lower()
|
||
|
||
is_audio = any(filename_lower.endswith(ext) for ext in audio_extensions)
|
||
|
||
if is_audio:
|
||
# Get reference to the DownloadsPage to check audio player state
|
||
downloads_page = self.get_downloads_page()
|
||
|
||
# If this button is currently playing, toggle pause/resume
|
||
if (downloads_page and
|
||
downloads_page.currently_playing_button == self and
|
||
downloads_page.audio_player.is_playing):
|
||
|
||
# Toggle playback (pause/resume)
|
||
is_playing = downloads_page.audio_player.toggle_playback()
|
||
if is_playing:
|
||
self.set_playing_state()
|
||
else:
|
||
self.play_btn.setText("▶️") # Play icon when paused
|
||
self.play_btn.setEnabled(True)
|
||
return
|
||
|
||
# Otherwise, start new streaming
|
||
# Change button state to indicate streaming is starting
|
||
self.play_btn.setText("⏸️") # Pause icon to indicate playing
|
||
self.play_btn.setEnabled(False)
|
||
|
||
# Emit streaming request
|
||
self.stream_requested.emit(self.search_result)
|
||
|
||
# Note: Button state will be managed by the audio player callbacks
|
||
# No timer reset - the audio player will handle state changes
|
||
else:
|
||
print(f"Cannot stream non-audio file: {self.search_result.filename}")
|
||
|
||
def get_downloads_page(self):
|
||
"""Get reference to the parent DownloadsPage"""
|
||
parent = self.parent()
|
||
while parent:
|
||
if hasattr(parent, 'audio_player'): # DownloadsPage has audio_player
|
||
return parent
|
||
parent = parent.parent()
|
||
return None
|
||
|
||
def reset_play_state(self, original_text="▶️"):
|
||
"""Reset the play button state"""
|
||
self.play_btn.setText(original_text)
|
||
self.play_btn.setEnabled(True)
|
||
|
||
def set_playing_state(self):
|
||
"""Set button to playing state"""
|
||
self.play_btn.setText("⏸️")
|
||
self.play_btn.setEnabled(True)
|
||
|
||
def set_loading_state(self):
|
||
"""Set button to loading state"""
|
||
self.play_btn.setText("⌛")
|
||
self.play_btn.setEnabled(False)
|
||
|
||
def set_queue_state(self):
|
||
"""Set play button to queue state"""
|
||
self.play_btn.setText("📋")
|
||
self.play_btn.setEnabled(False)
|
||
self.play_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: rgba(255, 165, 0, 0.2);
|
||
border: 1px solid rgba(255, 165, 0, 0.4);
|
||
border-radius: 18px;
|
||
color: rgba(255, 165, 0, 0.8);
|
||
font-size: 12px;
|
||
}
|
||
""")
|
||
|
||
def reset_download_state(self):
|
||
"""Reset the download button state"""
|
||
self.is_downloading = False
|
||
self.download_btn.setText("⬇️")
|
||
self.download_btn.setEnabled(True)
|
||
|
||
class DownloadItem(QFrame):
|
||
def __init__(self, title: str, artist: str, status: str, progress: int = 0,
|
||
file_size: int = 0, download_speed: int = 0, file_path: str = "",
|
||
download_id: str = "", soulseek_client=None, parent=None):
|
||
super().__init__(parent)
|
||
self.title = title
|
||
self.artist = artist
|
||
self.status = status
|
||
self.progress = progress
|
||
self.file_size = file_size
|
||
self.download_speed = download_speed
|
||
self.file_path = file_path
|
||
self.download_id = download_id # Track download ID for cancellation
|
||
self.soulseek_client = soulseek_client # For cancellation functionality
|
||
|
||
# Add completion tracking to prevent duplicate processing
|
||
self._completion_processed = False
|
||
self._completion_lock = threading.Lock()
|
||
|
||
self.setup_ui()
|
||
|
||
def mark_completion_processed(self) -> bool:
|
||
"""Thread-safe method to mark download as completion-processed.
|
||
Returns True if this is the first time marking completion, False if already processed."""
|
||
with self._completion_lock:
|
||
if self._completion_processed:
|
||
return False # Already processed
|
||
self._completion_processed = True
|
||
return True # First time processing
|
||
|
||
def is_completion_processed(self) -> bool:
|
||
"""Check if completion has already been processed."""
|
||
with self._completion_lock:
|
||
return self._completion_processed
|
||
|
||
def setup_ui(self):
|
||
self.setFixedHeight(85) # More generous height for better spacing
|
||
self.setStyleSheet("""
|
||
DownloadItem {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(40, 40, 40, 0.95),
|
||
stop:1 rgba(32, 32, 32, 0.95));
|
||
border-radius: 12px;
|
||
border: 1px solid rgba(64, 64, 64, 0.4);
|
||
margin: 6px 4px;
|
||
}
|
||
DownloadItem:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(50, 50, 50, 0.95),
|
||
stop:1 rgba(40, 40, 40, 0.98));
|
||
border: 1px solid rgba(29, 185, 84, 0.7);
|
||
}
|
||
""")
|
||
|
||
# Main horizontal layout
|
||
layout = QHBoxLayout(self)
|
||
layout.setContentsMargins(16, 12, 16, 12)
|
||
layout.setSpacing(16)
|
||
|
||
# Left section: Filename + uploader (flexible)
|
||
left_section = QVBoxLayout()
|
||
left_section.setSpacing(4)
|
||
|
||
# Extract filename with extension from file_path
|
||
filename_with_ext = "Unknown File"
|
||
if self.file_path:
|
||
from pathlib import Path
|
||
try:
|
||
filename_with_ext = Path(self.file_path).name
|
||
except:
|
||
filename_with_ext = self.title # fallback
|
||
else:
|
||
filename_with_ext = self.title # fallback
|
||
|
||
# Filename with extension (main info)
|
||
filename_label = QLabel(filename_with_ext)
|
||
filename_label.setFont(QFont("Segoe UI", 13, QFont.Weight.Bold))
|
||
filename_label.setStyleSheet("color: #ffffff;")
|
||
filename_label.setWordWrap(False)
|
||
filename_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||
|
||
# Uploader info
|
||
uploader_label = QLabel(f"from {self.artist}")
|
||
uploader_label.setFont(QFont("Segoe UI", 10))
|
||
uploader_label.setStyleSheet("color: #b3b3b3;")
|
||
uploader_label.setWordWrap(False)
|
||
uploader_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||
|
||
left_section.addWidget(filename_label)
|
||
left_section.addWidget(uploader_label)
|
||
|
||
# Middle section: Progress (fixed width)
|
||
progress_widget = QWidget()
|
||
progress_widget.setFixedWidth(120)
|
||
progress_layout = QVBoxLayout(progress_widget)
|
||
progress_layout.setSpacing(6)
|
||
progress_layout.setContentsMargins(0, 0, 0, 0)
|
||
|
||
# Progress bar
|
||
self.progress_bar = QProgressBar()
|
||
self.progress_bar.setFixedHeight(8)
|
||
self.progress_bar.setValue(self.progress)
|
||
self.progress_bar.setStyleSheet("""
|
||
QProgressBar {
|
||
border: none;
|
||
border-radius: 4px;
|
||
background: #404040;
|
||
}
|
||
QProgressBar::chunk {
|
||
background: #1db954;
|
||
border-radius: 4px;
|
||
}
|
||
""")
|
||
|
||
# Status text
|
||
status_mapping = {
|
||
"completed, succeeded": "Finished",
|
||
"completed, cancelled": "Cancelled",
|
||
"completed": "Finished",
|
||
"cancelled": "Cancelled",
|
||
"downloading": "Downloading",
|
||
"failed": "Failed",
|
||
"queued": "Queued"
|
||
}
|
||
|
||
clean_status = status_mapping.get(self.status.lower(), self.status.title())
|
||
if self.status.lower() in ["downloading", "queued"]:
|
||
status_text = f"{clean_status} - {self.progress}%"
|
||
else:
|
||
status_text = clean_status
|
||
|
||
self.status_label = QLabel(status_text)
|
||
self.status_label.setFont(QFont("Segoe UI", 9))
|
||
self.status_label.setStyleSheet("color: #b3b3b3;")
|
||
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
|
||
progress_layout.addWidget(self.progress_bar)
|
||
progress_layout.addWidget(self.status_label)
|
||
|
||
# Right section: Action button (fixed width)
|
||
self.action_btn = QPushButton()
|
||
self.action_btn.setFixedSize(90, 36)
|
||
|
||
if self.status == "downloading":
|
||
self.action_btn.setText("Cancel")
|
||
self.action_btn.clicked.connect(self.cancel_download)
|
||
self.action_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(220, 53, 69, 0.8),
|
||
stop:1 rgba(220, 53, 69, 1.0));
|
||
color: white;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-weight: bold;
|
||
font-size: 11px;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(200, 33, 49, 0.9),
|
||
stop:1 rgba(200, 33, 49, 1.0));
|
||
}
|
||
""")
|
||
elif self.status == "failed":
|
||
self.action_btn.setText("Retry")
|
||
self.action_btn.clicked.connect(self.retry_download)
|
||
self.action_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(255, 193, 7, 0.8),
|
||
stop:1 rgba(255, 193, 7, 1.0));
|
||
color: #000;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-weight: bold;
|
||
font-size: 11px;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(235, 173, 0, 0.9),
|
||
stop:1 rgba(235, 173, 0, 1.0));
|
||
}
|
||
""")
|
||
else:
|
||
self.action_btn.setText("📂 Open")
|
||
self.action_btn.clicked.connect(self.open_download_location)
|
||
self.action_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(40, 167, 69, 0.8),
|
||
stop:1 rgba(40, 167, 69, 1.0));
|
||
color: white;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-weight: bold;
|
||
font-size: 11px;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(20, 147, 49, 0.9),
|
||
stop:1 rgba(20, 147, 49, 1.0));
|
||
}
|
||
""")
|
||
|
||
# Add everything to main layout
|
||
layout.addLayout(left_section, 1) # Flexible
|
||
layout.addWidget(progress_widget) # Fixed width
|
||
layout.addWidget(self.action_btn) # Fixed width
|
||
|
||
def open_download_location(self):
|
||
"""Open the download location in file explorer"""
|
||
import os
|
||
import platform
|
||
from pathlib import Path
|
||
|
||
if not self.file_path:
|
||
return
|
||
|
||
try:
|
||
file_path = Path(self.file_path)
|
||
if file_path.exists():
|
||
# Open the folder containing the file
|
||
folder_path = file_path.parent
|
||
|
||
system = platform.system()
|
||
if system == "Windows":
|
||
os.startfile(str(folder_path))
|
||
elif system == "Darwin": # macOS
|
||
os.system(f'open "{folder_path}"')
|
||
else: # Linux
|
||
os.system(f'xdg-open "{folder_path}"')
|
||
else:
|
||
# If file doesn't exist, try to open the download directory from config
|
||
from config.settings import config_manager
|
||
download_path = config_manager.get('soulseek.download_path', './downloads')
|
||
|
||
system = platform.system()
|
||
if system == "Windows":
|
||
os.startfile(download_path)
|
||
elif system == "Darwin": # macOS
|
||
os.system(f'open "{download_path}"')
|
||
else: # Linux
|
||
os.system(f'xdg-open "{download_path}"')
|
||
|
||
except Exception as e:
|
||
print(f"Error opening download location: {e}")
|
||
|
||
def update_status(self, status: str, progress: int = None, download_speed: int = None, file_path: str = None):
|
||
"""SAFE UPDATE: Update download item status without UI destruction"""
|
||
# Update properties
|
||
self.status = status
|
||
if progress is not None:
|
||
self.progress = progress
|
||
if download_speed is not None:
|
||
self.download_speed = download_speed
|
||
if file_path:
|
||
self.file_path = file_path
|
||
|
||
# SAFE UI UPDATES: Update widgets directly instead of recreating
|
||
try:
|
||
# Update progress bar safely
|
||
if hasattr(self, 'progress_bar') and self.progress_bar:
|
||
self.progress_bar.setValue(self.progress)
|
||
|
||
# Update status label safely
|
||
if hasattr(self, 'status_label') and self.status_label:
|
||
# Clean up status text display
|
||
status_mapping = {
|
||
"completed, succeeded": "Finished",
|
||
"completed, cancelled": "Cancelled",
|
||
"completed": "Finished",
|
||
"cancelled": "Cancelled",
|
||
"downloading": "Downloading",
|
||
"failed": "Failed",
|
||
"queued": "Queued"
|
||
}
|
||
|
||
clean_status = status_mapping.get(self.status.lower(), self.status.title())
|
||
status_text = clean_status
|
||
|
||
if self.status.lower() in ["downloading", "queued"]:
|
||
status_text += f" - {self.progress}%"
|
||
|
||
self.status_label.setText(status_text)
|
||
|
||
# Update action button based on status
|
||
if hasattr(self, 'action_btn') and self.action_btn:
|
||
if self.status == "downloading":
|
||
self.action_btn.setText("Cancel")
|
||
# Disconnect old connections
|
||
self.action_btn.clicked.disconnect()
|
||
self.action_btn.clicked.connect(self.cancel_download)
|
||
self.action_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(220, 53, 69, 0.8),
|
||
stop:1 rgba(220, 53, 69, 1.0));
|
||
color: white;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-weight: bold;
|
||
font-size: 11px;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(200, 33, 49, 0.9),
|
||
stop:1 rgba(200, 33, 49, 1.0));
|
||
}
|
||
""")
|
||
elif self.status == "failed":
|
||
self.action_btn.setText("Retry")
|
||
# Disconnect old connections
|
||
self.action_btn.clicked.disconnect()
|
||
self.action_btn.clicked.connect(self.retry_download)
|
||
self.action_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(255, 193, 7, 0.8),
|
||
stop:1 rgba(255, 193, 7, 1.0));
|
||
color: #000;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-weight: bold;
|
||
font-size: 11px;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(235, 173, 0, 0.9),
|
||
stop:1 rgba(235, 173, 0, 1.0));
|
||
}
|
||
""")
|
||
else:
|
||
self.action_btn.setText("📂 Open")
|
||
# Disconnect old connections
|
||
self.action_btn.clicked.disconnect()
|
||
self.action_btn.clicked.connect(self.open_download_location)
|
||
self.action_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(40, 167, 69, 0.8),
|
||
stop:1 rgba(40, 167, 69, 1.0));
|
||
color: white;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-weight: bold;
|
||
font-size: 11px;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(20, 147, 49, 0.9),
|
||
stop:1 rgba(20, 147, 49, 1.0));
|
||
}
|
||
""")
|
||
|
||
except Exception as e:
|
||
print(f"Error updating download item UI: {e}")
|
||
# Fallback: only recreate if safe update fails
|
||
self.setup_ui()
|
||
|
||
def cancel_download(self):
|
||
"""Cancel the download using the SoulseekClient"""
|
||
if not self.soulseek_client or not self.download_id:
|
||
print(f"Cannot cancel download: missing client or download ID")
|
||
return
|
||
|
||
# Find the parent DownloadsPage to use its async helper
|
||
parent_page = self.parent()
|
||
while parent_page and not hasattr(parent_page, '_run_async_operation'):
|
||
parent_page = parent_page.parent()
|
||
|
||
if parent_page:
|
||
# Use the parent's async helper for safe event loop management
|
||
def on_success(result):
|
||
if result:
|
||
print(f"Successfully cancelled download: {self.title}")
|
||
self.update_status("cancelled", progress=0)
|
||
|
||
# Find the parent TabbedDownloadManager and move to finished tab
|
||
parent_widget = self.parent()
|
||
while parent_widget:
|
||
if hasattr(parent_widget, 'move_to_finished'):
|
||
parent_widget.move_to_finished(self)
|
||
break
|
||
parent_widget = parent_widget.parent()
|
||
|
||
else:
|
||
print(f"Failed to cancel download: {self.title}")
|
||
|
||
def on_error(error):
|
||
print(f"Error cancelling download {self.title}: {error}")
|
||
|
||
parent_page._run_async_operation(
|
||
self.soulseek_client.cancel_download,
|
||
self.download_id,
|
||
success_callback=on_success,
|
||
error_callback=on_error
|
||
)
|
||
else:
|
||
print(f"[ERROR] Could not find parent DownloadsPage for async operation")
|
||
|
||
def retry_download(self):
|
||
"""Retry a failed download"""
|
||
# For now, just update status back to downloading
|
||
# In a full implementation, this would restart the download
|
||
self.update_status("downloading", progress=0)
|
||
print(f"Retry requested for: {self.title}")
|
||
|
||
def show_details(self):
|
||
"""Show download details"""
|
||
details = f"""
|
||
Download Details:
|
||
Title: {self.title}
|
||
Artist: {self.artist}
|
||
Status: {self.status}
|
||
Progress: {self.progress}%
|
||
File Size: {self.file_size // (1024*1024)}MB
|
||
Download ID: {self.download_id}
|
||
File Path: {self.file_path}
|
||
"""
|
||
print(details)
|
||
|
||
class CompactDownloadItem(QFrame):
|
||
"""Compact download item optimized for queue display"""
|
||
def __init__(self, title: str, artist: str, status: str = "queued",
|
||
progress: int = 0, file_size: int = 0, download_speed: int = 0,
|
||
file_path: str = "", download_id: str = "", username: str = "",
|
||
soulseek_client=None, queue_type: str = "active",
|
||
album: str = None, track_number: int = None,
|
||
parent=None):
|
||
super().__init__(parent)
|
||
self.title = title
|
||
self.artist = artist
|
||
self.status = status
|
||
self.progress = progress
|
||
self.file_size = file_size
|
||
self.download_speed = download_speed
|
||
self.file_path = file_path
|
||
self.download_id = download_id
|
||
self.username = username
|
||
self.soulseek_client = soulseek_client
|
||
self.queue_type = queue_type # "active" or "finished"
|
||
|
||
# Album metadata for matched downloads
|
||
self.album = album
|
||
self.track_number = track_number
|
||
|
||
# Add completion tracking to prevent duplicate processing
|
||
self._completion_processed = False
|
||
self._completion_lock = threading.Lock()
|
||
|
||
self.setup_ui()
|
||
|
||
def mark_completion_processed(self) -> bool:
|
||
"""Thread-safe method to mark download as completion-processed.
|
||
Returns True if this is the first time marking completion, False if already processed."""
|
||
with self._completion_lock:
|
||
if self._completion_processed:
|
||
return False # Already processed
|
||
self._completion_processed = True
|
||
return True # First time processing
|
||
|
||
def is_completion_processed(self) -> bool:
|
||
"""Check if completion has already been processed."""
|
||
with self._completion_lock:
|
||
return self._completion_processed
|
||
|
||
def setup_ui(self):
|
||
self.setMinimumHeight(85) # Further increased minimum to give more room for artist names
|
||
self.setMaximumHeight(140) # Increased maximum for better text accommodation
|
||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
|
||
self.setStyleSheet("""
|
||
CompactDownloadItem {
|
||
background: rgba(45, 45, 45, 0.95);
|
||
border-radius: 6px;
|
||
border: 1px solid rgba(60, 60, 60, 0.6);
|
||
margin: 2px 1px;
|
||
}
|
||
CompactDownloadItem:hover {
|
||
background: rgba(55, 55, 55, 1.0);
|
||
border: 1px solid rgba(29, 185, 84, 0.5);
|
||
}
|
||
""")
|
||
|
||
# Main vertical layout for better space utilization
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(12, 12, 12, 12) # Further increased margins for better text spacing
|
||
layout.setSpacing(10) # Increased spacing between filename and bottom row
|
||
|
||
# Top row: Filename with text wrapping
|
||
filename_with_ext = self.get_display_filename()
|
||
self.filename_label = QLabel(filename_with_ext)
|
||
self.filename_label.setFont(QFont("Segoe UI", 10, QFont.Weight.Medium))
|
||
self.filename_label.setStyleSheet("color: #ffffff; background: transparent;")
|
||
self.filename_label.setWordWrap(True) # Enable text wrapping
|
||
self.filename_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
|
||
self.filename_label.setToolTip(filename_with_ext)
|
||
|
||
# Bottom row: Uploader, Progress/Status, and Action button
|
||
bottom_layout = QHBoxLayout()
|
||
bottom_layout.setContentsMargins(0, 2, 0, 0) # Added small top margin for better spacing
|
||
bottom_layout.setSpacing(10) # Increased spacing between elements
|
||
|
||
# Uploader info - remove fixed width constraint and allow text wrapping
|
||
self.uploader_label = QLabel()
|
||
self.uploader_label.setFont(QFont("Segoe UI", 9, QFont.Weight.Normal))
|
||
self.uploader_label.setStyleSheet("color: #b8b8b8; background: transparent;")
|
||
self.uploader_label.setWordWrap(True) # Enable text wrapping
|
||
self.uploader_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) # Changed from Fixed to Minimum
|
||
self.uploader_label.setMinimumHeight(20) # Ensure minimum height for text visibility
|
||
self.uploader_label.setToolTip(f"Uploader: {self.artist}")
|
||
self.uploader_label.setText(self.artist) # Set text directly instead of using ellipsis function
|
||
|
||
# Conditional layout based on queue type - PRESERVE ALL EXISTING BUTTON CODE
|
||
if self.queue_type == "active":
|
||
# Section 3: Progress (KEEP EXISTING PROGRESS WIDGET EXACTLY AS IS)
|
||
progress_widget = QWidget()
|
||
progress_widget.setFixedWidth(90)
|
||
progress_layout = QVBoxLayout(progress_widget)
|
||
progress_layout.setContentsMargins(0, 0, 0, 0)
|
||
progress_layout.setSpacing(1)
|
||
|
||
# Compact progress bar - COMPLETELY UNCHANGED
|
||
self.progress_bar = QProgressBar()
|
||
self.progress_bar.setFixedHeight(6)
|
||
self.progress_bar.setValue(self.progress)
|
||
self.progress_bar.setStyleSheet("""
|
||
QProgressBar {
|
||
border: none;
|
||
border-radius: 3px;
|
||
background: rgba(60, 60, 60, 0.8);
|
||
}
|
||
QProgressBar::chunk {
|
||
background: rgba(29, 185, 84, 1.0);
|
||
border-radius: 3px;
|
||
}
|
||
""")
|
||
|
||
# Progress percentage - COMPLETELY UNCHANGED
|
||
self.progress_label = QLabel(f"{self.progress}%")
|
||
self.progress_label.setFont(QFont("Segoe UI", 8))
|
||
self.progress_label.setStyleSheet("color: #c0c0c0;")
|
||
self.progress_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
|
||
progress_layout.addWidget(self.progress_bar)
|
||
progress_layout.addWidget(self.progress_label)
|
||
|
||
# Section 4: Cancel button - PRESERVE EXACTLY AS IS, NO CHANGES
|
||
self.cancel_btn = QPushButton("Cancel")
|
||
self.cancel_btn.setFixedSize(60, 35)
|
||
self.cancel_btn.clicked.connect(self.cancel_download)
|
||
self.cancel_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: rgba(220, 53, 69, 0.9);
|
||
color: white;
|
||
border: 1px solid rgba(220, 53, 69, 0.6);
|
||
border-radius: 4px;
|
||
font-size: 9px;
|
||
font-weight: 500;
|
||
}
|
||
QPushButton:hover {
|
||
background: rgba(240, 73, 89, 1.0);
|
||
}
|
||
QPushButton:pressed {
|
||
background: rgba(200, 43, 58, 1.0);
|
||
}
|
||
""")
|
||
|
||
# Add to bottom layout
|
||
bottom_layout.addWidget(self.uploader_label, 1)
|
||
bottom_layout.addWidget(progress_widget)
|
||
bottom_layout.addWidget(self.cancel_btn)
|
||
|
||
else:
|
||
# Finished downloads - display a different widget based on the final status.
|
||
self.progress_bar = None
|
||
self.progress_label = None
|
||
|
||
final_status = self.status.lower()
|
||
|
||
action_widget = QWidget()
|
||
action_layout = QHBoxLayout(action_widget)
|
||
action_layout.setContentsMargins(0, 0, 0, 0)
|
||
action_layout.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||
action_widget.setFixedWidth(70)
|
||
|
||
if 'completed' in final_status or 'succeeded' in final_status:
|
||
# For successfully completed downloads, show the 'Open' button.
|
||
open_btn = QPushButton("📂 Open")
|
||
open_btn.setFixedSize(60, 35)
|
||
open_btn.clicked.connect(self.open_download_location)
|
||
open_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: rgba(40, 167, 69, 0.9); color: white; border: 1px solid rgba(29, 185, 84, 0.6);
|
||
border-radius: 4px; font-size: 9px; font-weight: 500;
|
||
}
|
||
QPushButton:hover { background: rgba(50, 187, 79, 1.0); }
|
||
QPushButton:pressed { background: rgba(32, 140, 58, 1.0); }
|
||
""")
|
||
action_layout.addWidget(open_btn)
|
||
|
||
elif 'cancelled' in final_status or 'canceled' in final_status:
|
||
# For cancelled downloads, show a "Cancelled" label.
|
||
status_label = QLabel("Cancelled")
|
||
status_label.setStyleSheet("color: #ffa500; font-weight: bold; font-size: 10px;")
|
||
status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
action_layout.addWidget(status_label)
|
||
|
||
elif 'failed' in final_status or 'errored' in final_status:
|
||
# For failed or errored downloads, show a "Failed" label.
|
||
status_label = QLabel("Failed")
|
||
status_label.setStyleSheet("color: #e22134; font-weight: bold; font-size: 10px;")
|
||
status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
action_layout.addWidget(status_label)
|
||
|
||
else: # Fallback for any other unexpected status
|
||
open_btn = QPushButton("📂 Open")
|
||
open_btn.setFixedSize(60, 35)
|
||
open_btn.clicked.connect(self.open_download_location)
|
||
action_layout.addWidget(open_btn)
|
||
|
||
# Add the uploader label and the new action_widget to the layout.
|
||
bottom_layout.addWidget(self.uploader_label, 1)
|
||
bottom_layout.addWidget(action_widget)
|
||
|
||
# Add both sections to main layout
|
||
layout.addWidget(self.filename_label)
|
||
layout.addLayout(bottom_layout)
|
||
|
||
def _set_ellipsis_text(self, label, text, max_width):
|
||
"""Set text with ellipsis if it's too long for the given width"""
|
||
font_metrics = label.fontMetrics()
|
||
# Reserve some padding space (8px total)
|
||
available_width = max_width - 8
|
||
|
||
if font_metrics.horizontalAdvance(text) <= available_width:
|
||
label.setText(text)
|
||
else:
|
||
# Truncate with ellipsis
|
||
ellipsis_width = font_metrics.horizontalAdvance("...")
|
||
available_for_text = available_width - ellipsis_width
|
||
|
||
# Binary search for the right length
|
||
left, right = 0, len(text)
|
||
while left < right:
|
||
mid = (left + right + 1) // 2
|
||
if font_metrics.horizontalAdvance(text[:mid]) <= available_for_text:
|
||
left = mid
|
||
else:
|
||
right = mid - 1
|
||
|
||
truncated_text = text[:left] + "..."
|
||
label.setText(truncated_text)
|
||
|
||
def get_display_filename(self):
|
||
"""Extract just the filename with extension for display"""
|
||
if self.file_path:
|
||
from pathlib import Path
|
||
try:
|
||
return Path(self.file_path).name
|
||
except:
|
||
pass
|
||
# Fallback to title if no file_path or error
|
||
return self.title if self.title else "Unknown File"
|
||
|
||
def get_status_text(self):
|
||
"""Get appropriate status text for display"""
|
||
status_mapping = {
|
||
"completed, succeeded": "Done",
|
||
"completed, cancelled": "Cancelled",
|
||
"completed": "Done",
|
||
"cancelled": "Cancelled",
|
||
"downloading": f"{self.progress}%",
|
||
"failed": "Failed",
|
||
"queued": "Queued"
|
||
}
|
||
return status_mapping.get(self.status.lower(), self.status.title())
|
||
|
||
def update_status(self, status: str, progress: int = None, download_speed: int = None, file_path: str = None):
|
||
"""Update the status and progress of the download item"""
|
||
self.status = status
|
||
if progress is not None:
|
||
self.progress = progress
|
||
if download_speed is not None:
|
||
self.download_speed = download_speed
|
||
if file_path:
|
||
self.file_path = file_path
|
||
# Update filename display if file_path changed
|
||
if hasattr(self, 'filename_label') and self.filename_label:
|
||
try:
|
||
filename_with_ext = self.get_display_filename()
|
||
self.filename_label.setText(filename_with_ext)
|
||
self.filename_label.setToolTip(filename_with_ext)
|
||
except RuntimeError:
|
||
# Qt object has been deleted, skip update
|
||
pass
|
||
|
||
# Update progress components for active downloads only
|
||
if self.queue_type == "active":
|
||
if hasattr(self, 'progress_bar') and self.progress_bar:
|
||
try:
|
||
self.progress_bar.setValue(self.progress)
|
||
except RuntimeError:
|
||
# Qt object has been deleted, skip update
|
||
pass
|
||
if hasattr(self, 'progress_label') and self.progress_label:
|
||
try:
|
||
self.progress_label.setText(f"{self.progress}%")
|
||
except RuntimeError:
|
||
# Qt object has been deleted, skip update
|
||
pass
|
||
|
||
# Update cancel button state based on status
|
||
if hasattr(self, 'cancel_btn') and self.cancel_btn:
|
||
try:
|
||
if status.lower() in ['cancelled', 'canceled', 'failed']:
|
||
# Disable button and update text for cancelled/failed downloads
|
||
self.cancel_btn.setText("Cancelled")
|
||
self.cancel_btn.setEnabled(False)
|
||
self.cancel_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: rgba(100, 100, 100, 0.5);
|
||
color: rgba(255, 255, 255, 0.6);
|
||
border: 1px solid rgba(100, 100, 100, 0.4);
|
||
border-radius: 4px;
|
||
font-size: 9px;
|
||
font-weight: 500;
|
||
}
|
||
""")
|
||
elif status.lower() in ['downloading', 'queued']:
|
||
# Re-enable button for active downloads
|
||
self.cancel_btn.setText("Cancel")
|
||
self.cancel_btn.setEnabled(True)
|
||
self.cancel_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: rgba(220, 53, 69, 0.9);
|
||
color: white;
|
||
border: 1px solid rgba(220, 53, 69, 0.6);
|
||
border-radius: 4px;
|
||
font-size: 9px;
|
||
font-weight: 500;
|
||
}
|
||
QPushButton:hover {
|
||
background: rgba(240, 73, 89, 1.0);
|
||
}
|
||
QPushButton:pressed {
|
||
background: rgba(200, 43, 58, 1.0);
|
||
}
|
||
""")
|
||
except RuntimeError:
|
||
# Qt object has been deleted, skip update
|
||
pass
|
||
|
||
def cancel_download(self):
|
||
"""Cancel the download using soulseek client"""
|
||
print(f"[DEBUG] Cancel button clicked - download_id: {self.download_id}, username: {self.username}, title: {self.title}")
|
||
if self.soulseek_client and self.download_id:
|
||
print(f"🚫 Cancelling download: {self.download_id}")
|
||
|
||
# Find the parent DownloadsPage to use its async helper
|
||
parent_page = self.parent()
|
||
while parent_page and not hasattr(parent_page, '_run_async_operation'):
|
||
parent_page = parent_page.parent()
|
||
|
||
if parent_page:
|
||
# Use the parent's async helper for safe event loop management
|
||
def on_success(result):
|
||
print(f"[DEBUG] Cancel result: {result}")
|
||
if result:
|
||
print(f"✅ Successfully cancelled download: {self.title}")
|
||
self.update_status("cancelled")
|
||
else:
|
||
print(f"❌ Failed to cancel download: {self.title}")
|
||
|
||
def on_error(error):
|
||
print(f"❌ Failed to cancel download: {error}")
|
||
|
||
parent_page._run_async_operation(
|
||
self.soulseek_client.cancel_download,
|
||
self.download_id, self.username,
|
||
success_callback=on_success,
|
||
error_callback=on_error
|
||
)
|
||
else:
|
||
print(f"[ERROR] Could not find parent DownloadsPage for async operation")
|
||
else:
|
||
print(f"[DEBUG] Cancel failed - soulseek_client: {self.soulseek_client}, download_id: {self.download_id}")
|
||
|
||
def retry_download(self):
|
||
"""Retry a failed download"""
|
||
print(f"🔄 Retrying download: {self.title}")
|
||
# This would trigger a new download attempt
|
||
# Implementation depends on how retries are handled in the main system
|
||
self.update_status("queued", 0)
|
||
|
||
def open_download_location(self):
|
||
"""Open the download location in file explorer"""
|
||
import os
|
||
import platform
|
||
from pathlib import Path
|
||
|
||
print(f"[DEBUG] Open button clicked - file_path: {self.file_path}, title: {self.title}")
|
||
|
||
if not self.file_path:
|
||
print(f"[DEBUG] No file_path set for download: {self.title}")
|
||
# Fallback to opening the general downloads folder
|
||
try:
|
||
from config.settings import config_manager
|
||
download_path = config_manager.get('soulseek.download_path', './downloads')
|
||
|
||
system = platform.system()
|
||
if system == "Windows":
|
||
os.startfile(download_path)
|
||
elif system == "Darwin": # macOS
|
||
os.system(f'open "{download_path}"')
|
||
else: # Linux
|
||
os.system(f'xdg-open "{download_path}"')
|
||
|
||
print(f"📂 Opened downloads folder: {download_path}")
|
||
except Exception as e:
|
||
print(f"❌ Failed to open downloads folder: {e}")
|
||
return
|
||
|
||
try:
|
||
file_path = Path(self.file_path)
|
||
print(f"[DEBUG] Checking file existence: {file_path}")
|
||
|
||
if file_path.exists():
|
||
folder_path = file_path.parent
|
||
print(f"[DEBUG] Opening folder: {folder_path}")
|
||
|
||
system = platform.system()
|
||
if system == "Windows":
|
||
os.startfile(folder_path)
|
||
elif system == "Darwin": # macOS
|
||
os.system(f"open '{folder_path}'")
|
||
else: # Linux
|
||
os.system(f"xdg-open '{folder_path}'")
|
||
|
||
print(f"📂 Opened folder: {folder_path}")
|
||
else:
|
||
print(f"❌ File not found: {file_path}")
|
||
# Try to find the file in the downloads directory using the filename
|
||
filename = os.path.basename(self.file_path)
|
||
print(f"[DEBUG] Searching for file: {filename}")
|
||
|
||
from config.settings import config_manager
|
||
download_path = config_manager.get('soulseek.download_path', './downloads')
|
||
|
||
# Search for the file in the downloads directory tree
|
||
found_file = None
|
||
for root, dirs, files in os.walk(download_path):
|
||
for file in files:
|
||
if file == filename:
|
||
found_file = os.path.join(root, file)
|
||
print(f"[DEBUG] Found file at: {found_file}")
|
||
break
|
||
if found_file:
|
||
break
|
||
|
||
if found_file:
|
||
folder_path = os.path.dirname(found_file)
|
||
print(f"[DEBUG] Opening found folder: {folder_path}")
|
||
|
||
system = platform.system()
|
||
if system == "Windows":
|
||
os.startfile(folder_path)
|
||
elif system == "Darwin": # macOS
|
||
os.system(f'open "{folder_path}"')
|
||
else: # Linux
|
||
os.system(f'xdg-open "{folder_path}"')
|
||
|
||
print(f"📂 Opened folder: {folder_path}")
|
||
else:
|
||
print(f"❌ Could not find file {filename} in downloads directory")
|
||
# Fallback to opening the downloads folder
|
||
system = platform.system()
|
||
if system == "Windows":
|
||
os.startfile(download_path)
|
||
elif system == "Darwin": # macOS
|
||
os.system(f'open "{download_path}"')
|
||
else: # Linux
|
||
os.system(f'xdg-open "{download_path}"')
|
||
|
||
print(f"📂 Opened downloads folder as fallback: {download_path}")
|
||
|
||
except Exception as e:
|
||
print(f"❌ Failed to open download location: {e}")
|
||
|
||
class DownloadQueue(QFrame):
|
||
def __init__(self, title="Download Queue", queue_type="active", parent=None):
|
||
super().__init__(parent)
|
||
self.queue_title = title
|
||
self.queue_type = queue_type # "active" or "finished"
|
||
|
||
# Widget lifecycle optimization - batch widget deletions
|
||
self.deletion_timer = QTimer()
|
||
self.deletion_timer.setSingleShot(True)
|
||
self.deletion_timer.timeout.connect(self._process_pending_deletions)
|
||
self.deletion_timer.setInterval(100) # 100ms delay for batching
|
||
self.pending_deletions = []
|
||
|
||
self.setup_ui()
|
||
|
||
def setup_ui(self):
|
||
self.setStyleSheet("""
|
||
DownloadQueue {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(45, 45, 45, 0.9),
|
||
stop:1 rgba(35, 35, 35, 0.95));
|
||
border-radius: 10px;
|
||
border: 1px solid rgba(80, 80, 80, 0.5);
|
||
}
|
||
""")
|
||
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(12, 8, 12, 12) # Slightly increased top padding
|
||
layout.setSpacing(8) # Increased spacing for better visual breathing room
|
||
|
||
# Header
|
||
header_layout = QHBoxLayout()
|
||
header_layout.setContentsMargins(0, 0, 0, 0)
|
||
|
||
self.title_label = QLabel(self.queue_title)
|
||
self.title_label.setFont(QFont("Segoe UI", 11, QFont.Weight.Bold))
|
||
self.title_label.setStyleSheet("""
|
||
color: rgba(255, 255, 255, 0.95);
|
||
font-weight: 600;
|
||
padding: 0;
|
||
margin: 0;
|
||
""")
|
||
|
||
queue_count = QLabel("Empty")
|
||
queue_count.setFont(QFont("Segoe UI", 9))
|
||
queue_count.setStyleSheet("""
|
||
color: rgba(255, 255, 255, 0.6);
|
||
padding: 0;
|
||
margin: 0;
|
||
""")
|
||
|
||
header_layout.addWidget(self.title_label)
|
||
header_layout.addStretch()
|
||
header_layout.addWidget(queue_count)
|
||
|
||
# Queue list
|
||
queue_scroll = QScrollArea()
|
||
queue_scroll.setWidgetResizable(True)
|
||
queue_scroll.setMinimumHeight(200)
|
||
queue_scroll.setStyleSheet("""
|
||
QScrollArea {
|
||
border: none;
|
||
background: transparent;
|
||
padding: 0px;
|
||
margin: 0px;
|
||
}
|
||
QScrollArea > QWidget > QWidget {
|
||
background: transparent;
|
||
}
|
||
QScrollBar:vertical {
|
||
background: #404040;
|
||
width: 8px;
|
||
border-radius: 4px;
|
||
margin: 0px;
|
||
}
|
||
QScrollBar::handle:vertical {
|
||
background: #1db954;
|
||
border-radius: 4px;
|
||
margin: 0px;
|
||
}
|
||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
|
||
border: none;
|
||
background: none;
|
||
height: 0px;
|
||
}
|
||
""")
|
||
|
||
queue_widget = QWidget()
|
||
queue_layout = QVBoxLayout(queue_widget)
|
||
queue_layout.setContentsMargins(0, 0, 0, 0) # Remove any internal margins
|
||
queue_layout.setSpacing(6) # Increased spacing between download items for better readability
|
||
|
||
# Dynamic download items - initially empty
|
||
self.queue_layout = queue_layout
|
||
self.queue_count_label = queue_count
|
||
self.download_items = []
|
||
|
||
# Add initial message when queue is empty
|
||
self.empty_message = QLabel("No downloads yet.")
|
||
self.empty_message.setFont(QFont("Arial", 10))
|
||
self.empty_message.setStyleSheet("color: rgba(255, 255, 255, 0.5); padding: 15px; text-align: center;")
|
||
self.empty_message.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
queue_layout.addWidget(self.empty_message)
|
||
|
||
queue_layout.addStretch()
|
||
queue_scroll.setWidget(queue_widget)
|
||
|
||
layout.addLayout(header_layout)
|
||
layout.addWidget(queue_scroll)
|
||
|
||
def add_download_item(self, title: str, artist: str, status: str = "queued",
|
||
progress: int = 0, file_size: int = 0, download_speed: int = 0,
|
||
file_path: str = "", download_id: str = "", username: str = "",
|
||
soulseek_client=None, album: str = None, track_number: int = None):
|
||
"""Add a new download item to the queue"""
|
||
# Hide empty message if this is the first item
|
||
if len(self.download_items) == 0:
|
||
self.empty_message.hide()
|
||
|
||
# Create new compact download item with queue type
|
||
item = CompactDownloadItem(title, artist, status, progress, file_size, download_speed,
|
||
file_path, download_id, username, soulseek_client, self.queue_type,
|
||
album, track_number)
|
||
self.download_items.append(item)
|
||
|
||
# Insert before the stretch (which is always last)
|
||
insert_index = self.queue_layout.count() - 1
|
||
self.queue_layout.insertWidget(insert_index, item)
|
||
|
||
# Update count
|
||
self.update_queue_count()
|
||
|
||
return item
|
||
|
||
def update_queue_count(self):
|
||
"""Update the queue count label"""
|
||
count = len(self.download_items)
|
||
if count == 0:
|
||
self.queue_count_label.setText("Empty")
|
||
if not self.empty_message.isHidden():
|
||
self.empty_message.show()
|
||
else:
|
||
self.queue_count_label.setText(f"{count} item{'s' if count != 1 else ''}")
|
||
|
||
def remove_download_item(self, item):
|
||
"""Remove a download item from the queue"""
|
||
print(f"[DEBUG] remove_download_item() called for '{item.title}' with status '{item.status}'")
|
||
print(f"[DEBUG] Queue has {len(self.download_items)} items before removal")
|
||
|
||
if item in self.download_items:
|
||
print(f"[DEBUG] Item found in download_items list, removing...")
|
||
self.download_items.remove(item)
|
||
print(f"[DEBUG] Removed from download_items list. New count: {len(self.download_items)}")
|
||
|
||
print(f"[DEBUG] Removing widget from queue_layout...")
|
||
self.queue_layout.removeWidget(item)
|
||
print(f"[DEBUG] Scheduling widget deletion...")
|
||
self._schedule_widget_deletion(item)
|
||
|
||
print(f"[DEBUG] Updating queue count...")
|
||
self.update_queue_count()
|
||
|
||
# Notify parent download manager to update tab counts
|
||
print(f"[DEBUG] Finding parent to update tab counts...")
|
||
parent_widget = self.parent()
|
||
while parent_widget and not hasattr(parent_widget, 'update_tab_counts'):
|
||
parent_widget = parent_widget.parent()
|
||
if parent_widget and hasattr(parent_widget, 'update_tab_counts'):
|
||
print(f"[DEBUG] Calling parent.update_tab_counts()...")
|
||
parent_widget.update_tab_counts()
|
||
else:
|
||
print(f"[DEBUG] No parent with update_tab_counts found")
|
||
|
||
print(f"[DEBUG] remove_download_item() completed for '{item.title}'")
|
||
else:
|
||
print(f"[DEBUG] Item '{item.title}' NOT found in download_items list!")
|
||
|
||
def _schedule_widget_deletion(self, widget):
|
||
"""Schedule a widget for batched deletion to improve performance"""
|
||
self.pending_deletions.append(widget)
|
||
if not self.deletion_timer.isActive():
|
||
self.deletion_timer.start()
|
||
|
||
def _process_pending_deletions(self):
|
||
"""Process all pending widget deletions in a batch"""
|
||
print(f"[DEBUG] Processing {len(self.pending_deletions)} pending widget deletions")
|
||
for widget in self.pending_deletions:
|
||
try:
|
||
widget.deleteLater()
|
||
except Exception as e:
|
||
print(f"[DEBUG] Error deleting widget: {e}")
|
||
self.pending_deletions.clear()
|
||
|
||
def clear_completed_downloads(self):
|
||
"""Remove all completed and cancelled download items"""
|
||
print(f"[DEBUG] DownloadQueue.clear_completed_downloads() called with {len(self.download_items)} items")
|
||
items_to_remove = []
|
||
|
||
for item in self.download_items:
|
||
print(f"[DEBUG] Checking item '{item.title}' with status '{item.status}'")
|
||
|
||
# Normalize status for comparison (handle compound statuses like "Completed, Succeeded")
|
||
status_lower = item.status.lower()
|
||
should_remove = False
|
||
|
||
# Check for exact matches
|
||
# Check for terminal states with the correct priority to ensure proper cleanup.
|
||
# Cancelled and Failed must be checked BEFORE more general states like Completed.
|
||
if any(keyword in status_lower for keyword in ["cancelled", "canceled", "failed", "errored"]):
|
||
should_remove = True
|
||
print(f"[DEBUG] Matched terminal state (Cancelled/Failed): '{item.status}'")
|
||
elif any(keyword in status_lower for keyword in ["completed", "finished", "succeeded"]):
|
||
should_remove = True
|
||
print(f"[DEBUG] Matched terminal state (Completed): '{item.status}'")
|
||
|
||
if should_remove:
|
||
print(f"[DEBUG] Item '{item.title}' marked for removal (status: '{item.status}')")
|
||
items_to_remove.append(item)
|
||
else:
|
||
print(f"[DEBUG] Item '{item.title}' NOT marked for removal (status: '{item.status}')")
|
||
|
||
print(f"[DEBUG] Removing {len(items_to_remove)} items from queue")
|
||
for item in items_to_remove:
|
||
print(f"[DEBUG] Removing item: '{item.title}'")
|
||
self.remove_download_item(item)
|
||
|
||
print(f"[DEBUG] DownloadQueue.clear_completed_downloads() finished. Remaining items: {len(self.download_items)}")
|
||
|
||
class TabbedDownloadManager(QTabWidget):
|
||
"""Tabbed interface for managing active and finished downloads"""
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
|
||
# UI update batching to prevent excessive updates during transitions
|
||
self.update_timer = QTimer()
|
||
self.update_timer.setSingleShot(True)
|
||
self.update_timer.timeout.connect(self._perform_batched_update)
|
||
self.update_timer.setInterval(50) # 50ms batch window
|
||
self.pending_updates = set()
|
||
|
||
self.setup_ui()
|
||
|
||
def setup_ui(self):
|
||
"""Setup the tabbed interface with active and finished download queues"""
|
||
self.setStyleSheet("""
|
||
QTabWidget::pane {
|
||
border: 1px solid #404040;
|
||
border-radius: 8px;
|
||
background: #282828;
|
||
padding: 0px;
|
||
margin: 0px;
|
||
}
|
||
QTabWidget::tab-bar {
|
||
alignment: center;
|
||
}
|
||
QTabBar::tab {
|
||
background: #404040;
|
||
color: #ffffff;
|
||
border: 1px solid #606060;
|
||
border-bottom: none;
|
||
border-top-left-radius: 8px;
|
||
border-top-right-radius: 8px;
|
||
padding: 6px 12px;
|
||
margin-right: 1px;
|
||
font-size: 10px;
|
||
font-weight: bold;
|
||
min-width: 80px;
|
||
}
|
||
QTabBar::tab:selected {
|
||
background: #1db954;
|
||
color: #000000;
|
||
border: 1px solid #1db954;
|
||
}
|
||
QTabBar::tab:hover:!selected {
|
||
background: #505050;
|
||
}
|
||
""")
|
||
|
||
# Create two download queues with appropriate titles and queue types
|
||
self.active_queue = DownloadQueue("Active Downloads", "active")
|
||
self.finished_queue = DownloadQueue("Finished Downloads", "finished")
|
||
|
||
# Update the finished queue count label
|
||
self.finished_queue.queue_count_label.setText("Empty")
|
||
|
||
# Add tabs
|
||
self.addTab(self.active_queue, "Download Queue")
|
||
self.addTab(self.finished_queue, "Finished Downloads")
|
||
|
||
# Set initial tab counts
|
||
self.update_tab_counts()
|
||
|
||
def add_download_item(self, title: str, artist: str, status: str = "queued",
|
||
progress: int = 0, file_size: int = 0, download_speed: int = 0,
|
||
file_path: str = "", download_id: str = "", username: str = "",
|
||
soulseek_client=None, album: str = None, track_number: int = None):
|
||
"""Add a new download item to the active queue"""
|
||
item = self.active_queue.add_download_item(
|
||
title, artist, status, progress, file_size, download_speed,
|
||
file_path, download_id, username, soulseek_client, album, track_number
|
||
)
|
||
self.update_tab_counts()
|
||
return item
|
||
|
||
def move_to_finished(self, download_item):
|
||
"""Move a download item from active to finished queue"""
|
||
|
||
# Performance monitoring
|
||
import time
|
||
start_time = time.time()
|
||
|
||
if download_item in self.active_queue.download_items:
|
||
# Remove from active queue
|
||
self.active_queue.remove_download_item(download_item)
|
||
|
||
# Ensure completed downloads have 100% progress
|
||
final_progress = download_item.progress
|
||
if download_item.status == 'completed':
|
||
final_progress = 100
|
||
print(f"[DEBUG] Ensuring completed download '{download_item.title}' has 100% progress")
|
||
|
||
# Add to finished queue
|
||
finished_item = self.finished_queue.add_download_item(
|
||
title=download_item.title,
|
||
artist=download_item.artist,
|
||
status=download_item.status,
|
||
progress=final_progress,
|
||
file_size=download_item.file_size,
|
||
download_speed=download_item.download_speed,
|
||
file_path=download_item.file_path,
|
||
download_id=download_item.download_id,
|
||
username=download_item.username,
|
||
soulseek_client=download_item.soulseek_client
|
||
)
|
||
|
||
# Signal API that download is complete (only for completed downloads)
|
||
# Note: Cancelled downloads already have their API signal sent by cancel_download()
|
||
try:
|
||
if (download_item.status == 'completed' and
|
||
download_item.download_id and download_item.username and download_item.soulseek_client):
|
||
|
||
# PERFORMANCE FIX: Use dedicated thread for API cleanup to prevent UI blocking
|
||
# Find the parent DownloadsPage that manages the API cleanup threads
|
||
parent_page = self.parent()
|
||
while parent_page and not hasattr(parent_page, 'api_cleanup_threads'):
|
||
parent_page = parent_page.parent()
|
||
|
||
if parent_page and hasattr(parent_page, 'api_cleanup_threads'):
|
||
# Create and start API cleanup thread
|
||
cleanup_thread = ApiCleanupThread(
|
||
download_item.soulseek_client,
|
||
download_item.download_id,
|
||
download_item.username
|
||
)
|
||
cleanup_thread.cleanup_completed.connect(parent_page.api_cleanup_finished)
|
||
cleanup_thread.finished.connect(lambda: self._cleanup_api_thread(cleanup_thread))
|
||
|
||
# Track the thread
|
||
parent_page.api_cleanup_threads.append(cleanup_thread)
|
||
|
||
# Start the thread
|
||
cleanup_thread.start()
|
||
|
||
print(f"🧵 Started API cleanup thread for download {download_item.download_id}")
|
||
else:
|
||
print(f"⚠️ Cannot find parent DownloadsPage for API cleanup thread")
|
||
# Fallback: Skip API cleanup to prevent blocking
|
||
print(f"⚠️ Skipping API cleanup for download {download_item.download_id}")
|
||
|
||
except Exception as e:
|
||
print(f"⚠️ Error setting up download completion cleanup: {e}")
|
||
|
||
self.update_tab_counts()
|
||
|
||
# Emit signal for session download tracking
|
||
parent_page = self.parent()
|
||
while parent_page and not hasattr(parent_page, 'download_session_completed'):
|
||
parent_page = parent_page.parent()
|
||
if parent_page and hasattr(parent_page, 'download_session_completed'):
|
||
parent_page.download_session_completed.emit(download_item.title, download_item.artist)
|
||
|
||
# Performance monitoring
|
||
end_time = time.time()
|
||
duration_ms = (end_time - start_time) * 1000
|
||
print(f"⏱️ move_to_finished completed in {duration_ms:.2f}ms for '{download_item.title}'")
|
||
|
||
return finished_item
|
||
|
||
# Performance monitoring for early return
|
||
end_time = time.time()
|
||
duration_ms = (end_time - start_time) * 1000
|
||
print(f"⏱️ move_to_finished early return in {duration_ms:.2f}ms (item not in active queue)")
|
||
return None
|
||
|
||
def _cleanup_api_thread(self, thread):
|
||
"""Clean up API cleanup thread when it finishes"""
|
||
try:
|
||
# Find the parent DownloadsPage that manages the API cleanup threads
|
||
parent_page = self.parent()
|
||
while parent_page and not hasattr(parent_page, 'api_cleanup_threads'):
|
||
parent_page = parent_page.parent()
|
||
|
||
if parent_page and hasattr(parent_page, 'api_cleanup_threads'):
|
||
if thread in parent_page.api_cleanup_threads:
|
||
parent_page.api_cleanup_threads.remove(thread)
|
||
|
||
# Clean up the thread
|
||
if thread.isRunning():
|
||
thread.wait(1000) # Wait up to 1 second for completion
|
||
thread.deleteLater()
|
||
|
||
print(f"🧹 Cleaned up API cleanup thread")
|
||
except Exception as e:
|
||
print(f"⚠️ Error cleaning up API cleanup thread: {e}")
|
||
|
||
def update_tab_counts(self):
|
||
"""Schedule a batched tab count update to prevent excessive UI updates"""
|
||
self.pending_updates.add('tab_counts')
|
||
if not self.update_timer.isActive():
|
||
self.update_timer.start()
|
||
|
||
def _perform_batched_update(self):
|
||
"""Perform all pending UI updates in a single batch"""
|
||
if 'tab_counts' in self.pending_updates:
|
||
self._update_tab_counts_immediate()
|
||
|
||
# Clear pending updates
|
||
self.pending_updates.clear()
|
||
|
||
def _update_tab_counts_immediate(self):
|
||
"""Immediately update tab labels with current counts (internal use only)"""
|
||
active_count = len(self.active_queue.download_items)
|
||
finished_count = len(self.finished_queue.download_items)
|
||
|
||
self.setTabText(0, f"Download Queue ({active_count})")
|
||
self.setTabText(1, f"Finished Downloads ({finished_count})")
|
||
|
||
# Also update the download manager stats if they exist
|
||
# Find the DownloadsPage in the parent hierarchy
|
||
parent_widget = self.parent()
|
||
while parent_widget and not hasattr(parent_widget, 'update_download_manager_stats'):
|
||
parent_widget = parent_widget.parent()
|
||
|
||
if parent_widget and hasattr(parent_widget, 'update_download_manager_stats'):
|
||
parent_widget.update_download_manager_stats(active_count, finished_count)
|
||
else:
|
||
print(f"[DEBUG] Could not find parent with update_download_manager_stats method")
|
||
|
||
def clear_completed_downloads(self):
|
||
"""Clear completed and cancelled downloads from both slskd backend and local queues"""
|
||
# Delegate to parent (DownloadsPage) which has access to soulseek_client
|
||
if hasattr(self.parent(), 'clear_completed_downloads'):
|
||
self.parent().clear_completed_downloads()
|
||
else:
|
||
# Fallback to local clearing if parent method not available
|
||
print("[DEBUG] No parent clear method found, clearing locally only")
|
||
# Clear from both active and finished queues
|
||
self.active_queue.clear_completed_downloads()
|
||
self.finished_queue.clear_completed_downloads()
|
||
self.update_tab_counts()
|
||
|
||
def clear_local_queues_only(self):
|
||
"""Clear only the local UI queues without backend operations (for use by parent)"""
|
||
print("[DEBUG] TabbedDownloadManager.clear_local_queues_only() called")
|
||
print(f"[DEBUG] Active queue has {len(self.active_queue.download_items)} items")
|
||
print(f"[DEBUG] Finished queue has {len(self.finished_queue.download_items)} items")
|
||
|
||
# Clear from both active and finished queues
|
||
print("[DEBUG] Clearing active queue...")
|
||
self.active_queue.clear_completed_downloads()
|
||
print("[DEBUG] Clearing finished queue...")
|
||
self.finished_queue.clear_completed_downloads()
|
||
print("[DEBUG] Updating tab counts...")
|
||
self.update_tab_counts()
|
||
|
||
print(f"[DEBUG] After clearing - Active: {len(self.active_queue.download_items)}, Finished: {len(self.finished_queue.download_items)}")
|
||
|
||
@property
|
||
def download_items(self):
|
||
"""Return all download items from active queue for compatibility"""
|
||
return self.active_queue.download_items
|
||
|
||
class DownloadsPage(QWidget):
|
||
# Signals for media player communication
|
||
track_started = pyqtSignal(object) # Track result object
|
||
track_paused = pyqtSignal()
|
||
track_resumed = pyqtSignal()
|
||
track_stopped = pyqtSignal()
|
||
track_finished = pyqtSignal()
|
||
track_position_updated = pyqtSignal(float, float) # current_position, duration in seconds
|
||
track_loading_started = pyqtSignal(object) # Track result object when streaming starts
|
||
track_loading_finished = pyqtSignal(object) # Track result object when streaming completes
|
||
track_loading_progress = pyqtSignal(float, object) # Progress percentage (0-100), track result object
|
||
|
||
# Signal for dashboard stats tracking
|
||
download_session_completed = pyqtSignal(str, str) # Emitted when a download completes (title, artist)
|
||
|
||
# Signals for dashboard activity tracking
|
||
download_activity = pyqtSignal(str, str, str, str) # icon, title, subtitle, time
|
||
|
||
# Signal for clear completed downloads completion (thread-safe communication)
|
||
clear_completed_finished = pyqtSignal(bool, object) # backend_success, ui_callback
|
||
|
||
# Signal for API cleanup completion (thread-safe communication)
|
||
api_cleanup_finished = pyqtSignal(bool, str, str) # success, download_id, username
|
||
|
||
def __init__(self, soulseek_client=None, parent=None):
|
||
super().__init__(parent)
|
||
self.soulseek_client = soulseek_client
|
||
|
||
# --- FIX: Ensure the soulseek_client uses the download path from config ---
|
||
if self.soulseek_client:
|
||
from config.settings import config_manager
|
||
download_path = config_manager.get('soulseek.download_path')
|
||
if download_path and hasattr(self.soulseek_client, 'download_path'):
|
||
self.soulseek_client.download_path = download_path
|
||
print(f"✅ Set soulseek_client download path to: {download_path}")
|
||
# --- END FIX ---
|
||
|
||
self.search_thread = None
|
||
self.explore_thread = None
|
||
self.session_thread = None
|
||
self.download_threads = []
|
||
self.status_update_threads = []
|
||
self.api_cleanup_threads = []
|
||
self.search_results = []
|
||
self.current_filtered_results = []
|
||
self.download_items = []
|
||
self.displayed_results = 0
|
||
self.results_per_page = 15
|
||
self.is_loading_more = False
|
||
self._results_to_load_queue = []
|
||
self.status_processing_pool = QThreadPool()
|
||
self.status_processing_pool.setMaxThreadCount(1)
|
||
self._is_status_update_running = False
|
||
|
||
self.spotify_client = SpotifyClient()
|
||
self.matching_engine = MusicMatchingEngine()
|
||
|
||
import threading
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
self.album_cache_lock = threading.Lock()
|
||
self.album_groups = {}
|
||
self.album_artists = {}
|
||
self.album_editions = {}
|
||
self.album_name_cache = {}
|
||
|
||
self.api_thread_pool = ThreadPoolExecutor(max_workers=3, thread_name_prefix="SpotifyAPI")
|
||
self.active_suggestion_threads = set()
|
||
|
||
self.completion_thread_pool = QThreadPool()
|
||
self.completion_thread_pool.setMaxThreadCount(2)
|
||
|
||
self._optimized_api_pool = ThreadPoolExecutor(max_workers=8, thread_name_prefix="OptimizedAPI")
|
||
self._optimized_completion_pool = QThreadPool()
|
||
self._optimized_completion_pool.setMaxThreadCount(4)
|
||
self._cleanup_pools = []
|
||
|
||
self._queue_manager = ThreadSafeQueueManager()
|
||
self._queue_consistency_lock = RLock()
|
||
|
||
self.audio_player = AudioPlayer(self)
|
||
self.audio_player.playback_finished.connect(self.on_audio_playback_finished)
|
||
self.audio_player.playback_error.connect(self.on_audio_playback_error)
|
||
self.currently_playing_button = None
|
||
self.currently_expanded_item = None
|
||
|
||
self.download_status_timer = QTimer()
|
||
self.download_status_timer.timeout.connect(self.update_download_status)
|
||
self.download_status_timer.start(1000)
|
||
|
||
self._use_optimized_systems = False
|
||
|
||
self._polling_intervals = {
|
||
'active': 1500,
|
||
'idle': 3000,
|
||
'bulk_pause': 5000
|
||
}
|
||
self._current_polling_mode = 'active'
|
||
self._bulk_operation_active = False
|
||
self._last_active_count = 0
|
||
|
||
self.downloads_to_cleanup = set()
|
||
self.individual_downloads_to_cleanup = []
|
||
|
||
self.clear_completed_finished.connect(self._handle_clear_completion)
|
||
|
||
self.api_cleanup_finished.connect(self._handle_api_cleanup_completion)
|
||
|
||
self.setup_ui()
|
||
|
||
def set_toast_manager(self, toast_manager):
|
||
"""Set the toast manager for showing notifications"""
|
||
self.toast_manager = toast_manager
|
||
|
||
|
||
|
||
def setup_ui(self):
|
||
self.setStyleSheet("""
|
||
DownloadsPage {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(25, 20, 20, 1.0),
|
||
stop:1 rgba(15, 15, 15, 1.0));
|
||
}
|
||
""")
|
||
|
||
main_layout = QVBoxLayout(self)
|
||
# Responsive margins that adapt to window size
|
||
main_layout.setContentsMargins(20, 16, 20, 20) # Increased for better breathing room
|
||
main_layout.setSpacing(16) # Increased spacing for better visual hierarchy
|
||
|
||
# Elegant Header
|
||
header = self.create_elegant_header()
|
||
main_layout.addWidget(header)
|
||
|
||
# Main Content Area with responsive splitter
|
||
content_splitter = QSplitter(Qt.Orientation.Horizontal)
|
||
content_splitter.setChildrenCollapsible(False) # Prevent panels from collapsing completely
|
||
|
||
# LEFT: Search & Results section
|
||
search_and_results = self.create_search_and_results_section()
|
||
search_and_results.setMinimumWidth(400) # Minimum width for usability
|
||
content_splitter.addWidget(search_and_results)
|
||
|
||
# RIGHT: Controls Panel
|
||
controls_panel = self.create_collapsible_controls_panel()
|
||
controls_panel.setMinimumWidth(280) # Minimum width for controls
|
||
controls_panel.setMaximumWidth(400) # Maximum width to prevent overgrowth
|
||
content_splitter.addWidget(controls_panel)
|
||
|
||
# Set initial splitter proportions (roughly 70/30)
|
||
content_splitter.setSizes([700, 300])
|
||
content_splitter.setStretchFactor(0, 1) # Search results gets priority for extra space
|
||
content_splitter.setStretchFactor(1, 0) # Controls panel stays fixed width when possible
|
||
|
||
main_layout.addWidget(content_splitter)
|
||
|
||
def create_elegant_header(self):
|
||
"""Create an elegant, minimal header"""
|
||
header = QFrame()
|
||
header.setMinimumHeight(80) # Minimum height, can grow if needed
|
||
header.setMaximumHeight(120) # Maximum to prevent overgrowth
|
||
header.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||
header.setStyleSheet("""
|
||
QFrame {
|
||
background: transparent;
|
||
border: none;
|
||
}
|
||
""")
|
||
|
||
layout = QHBoxLayout(header)
|
||
layout.setContentsMargins(20, 16, 20, 16) # Increased padding for better header prominence
|
||
layout.setSpacing(16) # Increased spacing for better hierarchy
|
||
|
||
# Icon and Title
|
||
title_section = QVBoxLayout()
|
||
title_section.setSpacing(6) # Increased for better title hierarchy
|
||
|
||
title_label = QLabel("🎵 Music Downloads")
|
||
title_label.setFont(QFont("Segoe UI", 28, QFont.Weight.Bold)) # Larger for better prominence
|
||
title_label.setStyleSheet("""
|
||
color: #ffffff;
|
||
font-weight: 700;
|
||
letter-spacing: 1px;
|
||
""")
|
||
|
||
subtitle_label = QLabel("Search, discover, and download high-quality music")
|
||
subtitle_label.setFont(QFont("Segoe UI", 13))
|
||
subtitle_label.setStyleSheet("""
|
||
color: rgba(255, 255, 255, 0.85);
|
||
font-weight: 300;
|
||
letter-spacing: 0.5px;
|
||
margin-top: 4px;
|
||
""")
|
||
|
||
title_section.addWidget(title_label)
|
||
title_section.addWidget(subtitle_label)
|
||
|
||
layout.addLayout(title_section)
|
||
layout.addStretch()
|
||
|
||
return header
|
||
|
||
def create_search_and_results_section(self):
|
||
"""Create the main search and results area - the star of the show"""
|
||
section = QFrame()
|
||
section.setStyleSheet("""
|
||
QFrame {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(40, 40, 40, 0.4),
|
||
stop:1 rgba(30, 30, 30, 0.6));
|
||
border-radius: 16px;
|
||
border: 1px solid rgba(64, 64, 64, 0.3);
|
||
}
|
||
""")
|
||
|
||
layout = QVBoxLayout(section)
|
||
layout.setContentsMargins(16, 12, 16, 12) # Responsive spacing consistent with main layout
|
||
layout.setSpacing(12) # Consistent 12px spacing
|
||
|
||
# Elegant Search Bar
|
||
search_container = self.create_elegant_search_bar()
|
||
layout.addWidget(search_container)
|
||
|
||
# Filter Controls (initially hidden until we have results)
|
||
self.filter_container = self.create_filter_controls()
|
||
self.filter_container.setVisible(False) # Hide until we have search results
|
||
layout.addWidget(self.filter_container)
|
||
|
||
# Search Status with better visual feedback and loading animations
|
||
status_container = QWidget()
|
||
status_layout = QHBoxLayout(status_container)
|
||
status_layout.setContentsMargins(10, 8, 10, 8)
|
||
status_layout.setSpacing(12)
|
||
|
||
# Search status label
|
||
self.search_status = QLabel("Ready to search • Enter artist, song, or album name")
|
||
self.search_status.setFont(QFont("Arial", 11))
|
||
self.search_status.setStyleSheet("""
|
||
color: rgba(255, 255, 255, 0.7);
|
||
padding: 2px 8px;
|
||
""")
|
||
|
||
# Loading animations (initially hidden)
|
||
self.bouncing_dots = BouncingDotsWidget()
|
||
self.bouncing_dots.setVisible(False)
|
||
|
||
self.spinning_circle = SpinningCircleWidget()
|
||
self.spinning_circle.setVisible(False)
|
||
|
||
# Add to status layout
|
||
status_layout.addWidget(self.spinning_circle)
|
||
status_layout.addWidget(self.search_status)
|
||
status_layout.addWidget(self.bouncing_dots)
|
||
status_layout.addStretch()
|
||
|
||
# Style the container
|
||
status_container.setStyleSheet("""
|
||
QWidget {
|
||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||
stop:0 rgba(29, 185, 84, 0.12),
|
||
stop:1 rgba(29, 185, 84, 0.08));
|
||
border-radius: 10px;
|
||
border: 1px solid rgba(29, 185, 84, 0.25);
|
||
}
|
||
""")
|
||
|
||
layout.addWidget(status_container)
|
||
|
||
# Search Results - The main attraction
|
||
results_container = QFrame()
|
||
results_container.setStyleSheet("""
|
||
QFrame {
|
||
background: rgba(20, 20, 20, 0.3);
|
||
border-radius: 12px;
|
||
border: 1px solid rgba(64, 64, 64, 0.2);
|
||
}
|
||
""")
|
||
|
||
results_layout = QVBoxLayout(results_container)
|
||
results_layout.setContentsMargins(16, 12, 16, 16) # Improved responsive spacing for better breathing room
|
||
results_layout.setSpacing(12) # Increased spacing for better visual hierarchy
|
||
|
||
# Results header
|
||
results_header = QLabel("Search Results")
|
||
results_header.setFont(QFont("Segoe UI", 14, QFont.Weight.Bold))
|
||
results_header.setStyleSheet("""
|
||
color: rgba(255, 255, 255, 0.95);
|
||
font-weight: 600;
|
||
padding: 4px 8px;
|
||
""")
|
||
results_layout.addWidget(results_header)
|
||
|
||
# Scrollable results area - this gets ALL remaining space
|
||
self.search_results_scroll = QScrollArea()
|
||
self.search_results_scroll.setWidgetResizable(True)
|
||
self.search_results_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||
self.search_results_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||
self.search_results_scroll.setStyleSheet("""
|
||
QScrollArea {
|
||
border: none;
|
||
background: transparent;
|
||
border-radius: 8px;
|
||
}
|
||
QScrollBar:vertical {
|
||
background: rgba(64, 64, 64, 0.3);
|
||
width: 8px;
|
||
border-radius: 4px;
|
||
margin: 0;
|
||
}
|
||
QScrollBar::handle:vertical {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(29, 185, 84, 0.8),
|
||
stop:1 rgba(29, 185, 84, 0.6));
|
||
border-radius: 4px;
|
||
min-height: 20px;
|
||
}
|
||
QScrollBar::handle:vertical:hover {
|
||
background: rgba(29, 185, 84, 1.0);
|
||
}
|
||
""")
|
||
|
||
self.search_results_widget = QWidget()
|
||
self.search_results_layout = QVBoxLayout(self.search_results_widget)
|
||
self.search_results_layout.setSpacing(8) # Reduced spacing for more compact search results
|
||
self.search_results_layout.setContentsMargins(12, 12, 12, 12) # Increased for better edge spacing
|
||
|
||
# Add centered loading animation for search results area
|
||
self.results_loading_container = QWidget()
|
||
results_loading_layout = QVBoxLayout(self.results_loading_container)
|
||
results_loading_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
|
||
self.results_spinning_circle = SpinningCircleWidget()
|
||
self.results_loading_label = QLabel("Searching for results...")
|
||
self.results_loading_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self.results_loading_label.setStyleSheet("""
|
||
color: rgba(255, 255, 255, 0.7);
|
||
font-size: 14px;
|
||
margin-top: 10px;
|
||
""")
|
||
|
||
results_loading_layout.addWidget(self.results_spinning_circle, 0, Qt.AlignmentFlag.AlignCenter)
|
||
results_loading_layout.addWidget(self.results_loading_label, 0, Qt.AlignmentFlag.AlignCenter)
|
||
self.results_loading_container.setVisible(False) # Initially hidden
|
||
|
||
# Add to main results layout
|
||
self.search_results_layout.addWidget(self.results_loading_container)
|
||
self.search_results_layout.addStretch()
|
||
self.search_results_scroll.setWidget(self.search_results_widget)
|
||
|
||
# Connect scroll detection for automatic loading
|
||
scroll_bar = self.search_results_scroll.verticalScrollBar()
|
||
scroll_bar.valueChanged.connect(self.on_scroll_changed)
|
||
|
||
results_layout.addWidget(self.search_results_scroll)
|
||
layout.addWidget(results_container, 1) # This takes all remaining space
|
||
|
||
return section
|
||
|
||
def create_elegant_search_bar(self):
|
||
"""Create a beautiful, modern search bar"""
|
||
container = QFrame()
|
||
container.setFixedHeight(70)
|
||
container.setStyleSheet("""
|
||
QFrame {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(50, 50, 50, 0.8),
|
||
stop:1 rgba(40, 40, 40, 0.9));
|
||
border-radius: 12px;
|
||
border: 1px solid rgba(29, 185, 84, 0.3);
|
||
}
|
||
""")
|
||
|
||
layout = QHBoxLayout(container)
|
||
layout.setContentsMargins(20, 16, 20, 16) # Increased responsive spacing for better visual balance
|
||
layout.setSpacing(16) # Increased spacing for better visual hierarchy
|
||
|
||
# Search input with enhanced styling
|
||
self.search_input = QLineEdit()
|
||
self.search_input.setPlaceholderText("Search for music... (e.g., 'Virtual Mage', 'Queen Bohemian Rhapsody')")
|
||
self.search_input.setFixedHeight(40)
|
||
self.search_input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) # Responsive width
|
||
self.search_input.returnPressed.connect(self.perform_search)
|
||
self.search_input.setStyleSheet("""
|
||
QLineEdit {
|
||
background: rgba(60, 60, 60, 0.7);
|
||
border: 2px solid rgba(100, 100, 100, 0.3);
|
||
border-radius: 20px;
|
||
padding: 0 20px;
|
||
color: #ffffff;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
}
|
||
QLineEdit:focus {
|
||
border: 2px solid rgba(29, 185, 84, 0.8);
|
||
background: rgba(70, 70, 70, 0.9);
|
||
}
|
||
QLineEdit::placeholder {
|
||
color: rgba(255, 255, 255, 0.5);
|
||
}
|
||
""")
|
||
|
||
# Enhanced search button
|
||
self.search_btn = QPushButton("🔍 Search")
|
||
self.search_btn.setFixedSize(120, 40)
|
||
self.search_btn.clicked.connect(self.perform_search)
|
||
self.search_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(29, 185, 84, 1.0),
|
||
stop:1 rgba(24, 156, 71, 1.0));
|
||
border: none;
|
||
border-radius: 20px;
|
||
color: #000000;
|
||
font-size: 13px;
|
||
font-weight: bold;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(30, 215, 96, 1.0),
|
||
stop:1 rgba(25, 180, 80, 1.0));
|
||
}
|
||
QPushButton:pressed {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(24, 156, 71, 1.0),
|
||
stop:1 rgba(20, 130, 60, 1.0));
|
||
}
|
||
QPushButton:disabled {
|
||
background: rgba(100, 100, 100, 0.3);
|
||
color: rgba(255, 255, 255, 0.3);
|
||
}
|
||
""")
|
||
|
||
# Cancel search button (initially hidden)
|
||
self.cancel_search_btn = QPushButton("✕ Cancel")
|
||
self.cancel_search_btn.setFixedSize(100, 40)
|
||
self.cancel_search_btn.clicked.connect(self.cancel_search)
|
||
self.cancel_search_btn.setVisible(False) # Hidden by default
|
||
self.cancel_search_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(220, 53, 69, 0.9),
|
||
stop:1 rgba(200, 43, 58, 0.9));
|
||
border: none;
|
||
border-radius: 20px;
|
||
color: #ffffff;
|
||
font-size: 13px;
|
||
font-weight: bold;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(240, 73, 89, 1.0),
|
||
stop:1 rgba(220, 63, 79, 1.0));
|
||
}
|
||
QPushButton:pressed {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(200, 43, 58, 1.0),
|
||
stop:1 rgba(180, 33, 48, 1.0));
|
||
}
|
||
""")
|
||
|
||
layout.addWidget(self.search_input)
|
||
layout.addWidget(self.cancel_search_btn)
|
||
layout.addWidget(self.search_btn)
|
||
|
||
return container
|
||
|
||
def create_filter_controls(self):
|
||
"""Create elegant collapsible filter controls for Albums vs Singles, File Formats, and Sorting"""
|
||
container = QFrame()
|
||
container.setStyleSheet("""
|
||
QFrame {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(45, 45, 45, 0.6),
|
||
stop:1 rgba(35, 35, 35, 0.8));
|
||
border-radius: 10px;
|
||
border: 1px solid rgba(80, 80, 80, 0.25);
|
||
}
|
||
""")
|
||
|
||
main_layout = QVBoxLayout(container)
|
||
main_layout.setContentsMargins(16, 8, 16, 8)
|
||
main_layout.setSpacing(6)
|
||
|
||
# Initialize collapse state
|
||
self.filters_collapsed = True
|
||
|
||
# Toggle button row
|
||
toggle_row = QHBoxLayout()
|
||
toggle_row.setSpacing(8)
|
||
|
||
self.filter_toggle_btn = QPushButton("⏷ Filters")
|
||
self.filter_toggle_btn.setFixedHeight(32)
|
||
self.filter_toggle_btn.setMinimumWidth(100)
|
||
self.filter_toggle_btn.clicked.connect(self.toggle_filter_panel)
|
||
self.filter_toggle_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(80, 80, 80, 0.9),
|
||
stop:1 rgba(70, 70, 70, 0.95));
|
||
border: 1px solid rgba(100, 100, 100, 0.3);
|
||
border-radius: 6px;
|
||
color: rgba(255, 255, 255, 0.8);
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
letter-spacing: 0.3px;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(90, 90, 90, 0.9),
|
||
stop:1 rgba(80, 80, 80, 0.95));
|
||
color: rgba(255, 255, 255, 0.9);
|
||
}
|
||
QPushButton:pressed {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(60, 60, 60, 0.9),
|
||
stop:1 rgba(50, 50, 50, 0.95));
|
||
}
|
||
""")
|
||
|
||
toggle_row.addWidget(self.filter_toggle_btn)
|
||
toggle_row.addStretch()
|
||
main_layout.addLayout(toggle_row)
|
||
|
||
# Collapsible content container
|
||
self.filter_content = QWidget()
|
||
self.filter_content_layout = QVBoxLayout(self.filter_content)
|
||
self.filter_content_layout.setContentsMargins(0, 0, 0, 0)
|
||
self.filter_content_layout.setSpacing(6)
|
||
|
||
# First row: Type filters (Albums vs Singles)
|
||
type_row = QHBoxLayout()
|
||
type_row.setSpacing(8)
|
||
|
||
type_label = QLabel("Type:")
|
||
type_label.setStyleSheet("""
|
||
QLabel {
|
||
color: rgba(255, 255, 255, 0.8);
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
letter-spacing: 0.3px;
|
||
}
|
||
""")
|
||
|
||
# Initialize filter and sort state
|
||
self.current_filter = "all" # "all", "albums", "singles"
|
||
self.current_format_filter = "all" # "all", "flac", "mp3", "ogg", "aac", "wma"
|
||
self.current_sort = "relevance" # "relevance", "quality", "size", "name", "uploader", "bitrate", "duration", "availability", "speed"
|
||
self.reverse_order = False # False = normal order, True = reverse order
|
||
self.current_search_query = "" # Store search query for relevance calculation
|
||
|
||
# Type filter buttons
|
||
self.filter_all_btn = QPushButton("All")
|
||
self.filter_albums_btn = QPushButton("Albums")
|
||
self.filter_singles_btn = QPushButton("Singles")
|
||
|
||
# Store type buttons for easy access
|
||
self.filter_buttons = {
|
||
"all": self.filter_all_btn,
|
||
"albums": self.filter_albums_btn,
|
||
"singles": self.filter_singles_btn
|
||
}
|
||
|
||
# Connect type button signals
|
||
self.filter_all_btn.clicked.connect(lambda: self.set_filter("all"))
|
||
self.filter_albums_btn.clicked.connect(lambda: self.set_filter("albums"))
|
||
self.filter_singles_btn.clicked.connect(lambda: self.set_filter("singles"))
|
||
|
||
# Apply styling to type buttons
|
||
for btn_key, btn in self.filter_buttons.items():
|
||
btn.setFixedHeight(28)
|
||
btn.setMinimumWidth(60)
|
||
self.update_filter_button_style(btn, btn_key == "all")
|
||
|
||
type_row.addWidget(type_label)
|
||
type_row.addWidget(self.filter_all_btn)
|
||
type_row.addWidget(self.filter_albums_btn)
|
||
type_row.addWidget(self.filter_singles_btn)
|
||
type_row.addStretch()
|
||
|
||
# Second row: Format filters
|
||
format_row = QHBoxLayout()
|
||
format_row.setSpacing(8)
|
||
|
||
format_label = QLabel("Format:")
|
||
format_label.setStyleSheet("""
|
||
QLabel {
|
||
color: rgba(255, 255, 255, 0.8);
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
letter-spacing: 0.3px;
|
||
}
|
||
""")
|
||
|
||
# Format filter buttons
|
||
self.format_all_btn = QPushButton("All")
|
||
self.format_flac_btn = QPushButton("FLAC")
|
||
self.format_mp3_btn = QPushButton("MP3")
|
||
self.format_ogg_btn = QPushButton("OGG")
|
||
self.format_aac_btn = QPushButton("AAC")
|
||
self.format_wma_btn = QPushButton("WMA")
|
||
|
||
# Store format buttons for easy access
|
||
self.format_buttons = {
|
||
"all": self.format_all_btn,
|
||
"flac": self.format_flac_btn,
|
||
"mp3": self.format_mp3_btn,
|
||
"ogg": self.format_ogg_btn,
|
||
"aac": self.format_aac_btn,
|
||
"wma": self.format_wma_btn
|
||
}
|
||
|
||
# Connect format button signals
|
||
self.format_all_btn.clicked.connect(lambda: self.set_format_filter("all"))
|
||
self.format_flac_btn.clicked.connect(lambda: self.set_format_filter("flac"))
|
||
self.format_mp3_btn.clicked.connect(lambda: self.set_format_filter("mp3"))
|
||
self.format_ogg_btn.clicked.connect(lambda: self.set_format_filter("ogg"))
|
||
self.format_aac_btn.clicked.connect(lambda: self.set_format_filter("aac"))
|
||
self.format_wma_btn.clicked.connect(lambda: self.set_format_filter("wma"))
|
||
|
||
# Apply styling to format buttons
|
||
for btn_key, btn in self.format_buttons.items():
|
||
btn.setFixedHeight(28)
|
||
btn.setMinimumWidth(50)
|
||
self.update_filter_button_style(btn, btn_key == "all")
|
||
|
||
format_row.addWidget(format_label)
|
||
format_row.addWidget(self.format_all_btn)
|
||
format_row.addWidget(self.format_flac_btn)
|
||
format_row.addWidget(self.format_mp3_btn)
|
||
format_row.addWidget(self.format_ogg_btn)
|
||
format_row.addWidget(self.format_aac_btn)
|
||
format_row.addWidget(self.format_wma_btn)
|
||
format_row.addStretch()
|
||
|
||
# Third row: Sorting controls
|
||
sort_row = QHBoxLayout()
|
||
sort_row.setSpacing(8)
|
||
|
||
sort_label = QLabel("Sort by:")
|
||
sort_label.setStyleSheet("""
|
||
QLabel {
|
||
color: rgba(255, 255, 255, 0.8);
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
letter-spacing: 0.3px;
|
||
}
|
||
""")
|
||
|
||
# Reverse order toggle button - simple arrows
|
||
self.reverse_order_btn = QPushButton("↓")
|
||
self.reverse_order_btn.setFixedSize(28, 28) # Square button
|
||
self.reverse_order_btn.clicked.connect(self.toggle_reverse_order)
|
||
self.update_filter_button_style(self.reverse_order_btn, False) # Start inactive
|
||
|
||
# Sort buttons
|
||
self.sort_relevance_btn = QPushButton("Relevance")
|
||
self.sort_quality_btn = QPushButton("Quality")
|
||
self.sort_size_btn = QPushButton("Size")
|
||
self.sort_name_btn = QPushButton("Name")
|
||
self.sort_uploader_btn = QPushButton("Uploader")
|
||
self.sort_bitrate_btn = QPushButton("Bitrate")
|
||
self.sort_duration_btn = QPushButton("Duration")
|
||
self.sort_availability_btn = QPushButton("Available")
|
||
self.sort_speed_btn = QPushButton("Speed")
|
||
|
||
# Store sort buttons for easy access
|
||
self.sort_buttons = {
|
||
"relevance": self.sort_relevance_btn,
|
||
"quality": self.sort_quality_btn,
|
||
"size": self.sort_size_btn,
|
||
"name": self.sort_name_btn,
|
||
"uploader": self.sort_uploader_btn,
|
||
"bitrate": self.sort_bitrate_btn,
|
||
"duration": self.sort_duration_btn,
|
||
"availability": self.sort_availability_btn,
|
||
"speed": self.sort_speed_btn
|
||
}
|
||
|
||
# Connect sort button signals
|
||
self.sort_relevance_btn.clicked.connect(lambda: self.set_sort("relevance"))
|
||
self.sort_quality_btn.clicked.connect(lambda: self.set_sort("quality"))
|
||
self.sort_size_btn.clicked.connect(lambda: self.set_sort("size"))
|
||
self.sort_name_btn.clicked.connect(lambda: self.set_sort("name"))
|
||
self.sort_uploader_btn.clicked.connect(lambda: self.set_sort("uploader"))
|
||
self.sort_bitrate_btn.clicked.connect(lambda: self.set_sort("bitrate"))
|
||
self.sort_duration_btn.clicked.connect(lambda: self.set_sort("duration"))
|
||
self.sort_availability_btn.clicked.connect(lambda: self.set_sort("availability"))
|
||
self.sort_speed_btn.clicked.connect(lambda: self.set_sort("speed"))
|
||
|
||
# Apply styling to sort buttons
|
||
for btn_key, btn in self.sort_buttons.items():
|
||
btn.setFixedHeight(28)
|
||
btn.setMinimumWidth(55)
|
||
self.update_filter_button_style(btn, btn_key == "relevance")
|
||
|
||
sort_row.addWidget(sort_label)
|
||
sort_row.addWidget(self.reverse_order_btn)
|
||
sort_row.addWidget(self.sort_relevance_btn)
|
||
sort_row.addWidget(self.sort_quality_btn)
|
||
sort_row.addWidget(self.sort_size_btn)
|
||
sort_row.addWidget(self.sort_name_btn)
|
||
sort_row.addWidget(self.sort_uploader_btn)
|
||
sort_row.addWidget(self.sort_bitrate_btn)
|
||
sort_row.addWidget(self.sort_duration_btn)
|
||
sort_row.addWidget(self.sort_availability_btn)
|
||
sort_row.addWidget(self.sort_speed_btn)
|
||
sort_row.addStretch()
|
||
|
||
# Add all filter rows to the collapsible content
|
||
self.filter_content_layout.addLayout(type_row)
|
||
self.filter_content_layout.addLayout(format_row)
|
||
self.filter_content_layout.addLayout(sort_row)
|
||
|
||
# Add collapsible content to main layout
|
||
main_layout.addWidget(self.filter_content)
|
||
|
||
# Start collapsed
|
||
self.filter_content.setVisible(False)
|
||
container.setFixedHeight(50) # Height for toggle button only
|
||
|
||
return container
|
||
|
||
def toggle_filter_panel(self):
|
||
"""Toggle the filter panel between collapsed and expanded states"""
|
||
self.filters_collapsed = not self.filters_collapsed
|
||
|
||
if self.filters_collapsed:
|
||
# Collapse
|
||
self.filter_content.setVisible(False)
|
||
self.filter_toggle_btn.setText("⏷ Filters")
|
||
self.filter_container.setFixedHeight(50)
|
||
else:
|
||
# Expand
|
||
self.filter_content.setVisible(True)
|
||
self.filter_toggle_btn.setText("⏶ Filters")
|
||
self.filter_container.setFixedHeight(175) # Height for all content
|
||
|
||
def update_filter_button_style(self, button, is_active):
|
||
"""Update the visual style of filter buttons based on active state"""
|
||
if is_active:
|
||
button.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 #1ed760,
|
||
stop:1 #1db954);
|
||
border: none;
|
||
border-radius: 16px;
|
||
color: #000000;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
padding: 0 12px;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 #1fdf64,
|
||
stop:1 #1ed760);
|
||
}
|
||
QPushButton:pressed {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 #1ca851,
|
||
stop:1 #169c46);
|
||
}
|
||
""")
|
||
else:
|
||
button.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(80, 80, 80, 0.4),
|
||
stop:1 rgba(60, 60, 60, 0.6));
|
||
border: 1px solid rgba(120, 120, 120, 0.3);
|
||
border-radius: 16px;
|
||
color: rgba(255, 255, 255, 0.8);
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
padding: 0 12px;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(100, 100, 100, 0.5),
|
||
stop:1 rgba(80, 80, 80, 0.7));
|
||
border: 1px solid rgba(140, 140, 140, 0.4);
|
||
color: rgba(255, 255, 255, 0.9);
|
||
}
|
||
QPushButton:pressed {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(60, 60, 60, 0.6),
|
||
stop:1 rgba(40, 40, 40, 0.8));
|
||
}
|
||
""")
|
||
|
||
def set_filter(self, filter_type):
|
||
"""Set the active filter and update UI"""
|
||
self.current_filter = filter_type
|
||
|
||
# Update button styles
|
||
for btn_key, btn in self.filter_buttons.items():
|
||
self.update_filter_button_style(btn, btn_key == filter_type)
|
||
|
||
# Apply the filter to current results
|
||
self.apply_filter()
|
||
|
||
def set_format_filter(self, format_type):
|
||
"""Set the current format filter and update button styles"""
|
||
self.current_format_filter = format_type
|
||
|
||
# Update format button styles
|
||
for btn_key, btn in self.format_buttons.items():
|
||
self.update_filter_button_style(btn, btn_key == format_type)
|
||
|
||
# Apply the filter to current results
|
||
self.apply_filter()
|
||
|
||
def set_sort(self, sort_type):
|
||
"""Set the current sort type and update button styles"""
|
||
self.current_sort = sort_type
|
||
|
||
# Update sort button styles
|
||
for btn_key, btn in self.sort_buttons.items():
|
||
self.update_filter_button_style(btn, btn_key == sort_type)
|
||
|
||
# Apply the sort to current results
|
||
self.apply_filter()
|
||
|
||
def toggle_reverse_order(self):
|
||
"""Toggle the reverse order setting and update button styles"""
|
||
self.reverse_order = not self.reverse_order
|
||
|
||
# Update arrow direction and button style
|
||
if self.reverse_order:
|
||
self.reverse_order_btn.setText("↑") # Up arrow for reverse order
|
||
else:
|
||
self.reverse_order_btn.setText("↓") # Down arrow for normal order
|
||
|
||
self.update_filter_button_style(self.reverse_order_btn, self.reverse_order)
|
||
|
||
# Apply the new sort order to current results
|
||
self.apply_filter()
|
||
|
||
def sort_results(self, results):
|
||
"""Sort search results based on current sort type and reverse order setting"""
|
||
if not results or not hasattr(self, 'current_sort'):
|
||
return results
|
||
|
||
# Define default reverse logic for each sort type (normal behavior)
|
||
default_reverse_logic = {
|
||
"relevance": True, # High relevance first
|
||
"quality": True, # High quality first
|
||
"size": True, # Large files first
|
||
"name": False, # A-Z alphabetical
|
||
"uploader": False, # A-Z alphabetical
|
||
"bitrate": True, # High bitrate first
|
||
"duration": True, # Long duration first
|
||
"availability": True, # More available first
|
||
"speed": True # Fast speed first
|
||
}
|
||
|
||
# Get the default reverse setting for current sort type
|
||
default_reverse = default_reverse_logic.get(self.current_sort, False)
|
||
|
||
# Apply user's reverse order toggle (XOR logic)
|
||
# If reverse_order is True, flip the default behavior
|
||
final_reverse = default_reverse if not self.reverse_order else not default_reverse
|
||
|
||
# Apply the appropriate sorting
|
||
if self.current_sort == "relevance":
|
||
sorted_results = sorted(results, key=self._sort_by_relevance, reverse=final_reverse)
|
||
elif self.current_sort == "quality":
|
||
sorted_results = sorted(results, key=self._sort_by_quality, reverse=final_reverse)
|
||
elif self.current_sort == "size":
|
||
sorted_results = sorted(results, key=self._sort_by_size, reverse=final_reverse)
|
||
elif self.current_sort == "name":
|
||
sorted_results = sorted(results, key=self._sort_by_name, reverse=final_reverse)
|
||
elif self.current_sort == "uploader":
|
||
sorted_results = sorted(results, key=self._sort_by_uploader, reverse=final_reverse)
|
||
elif self.current_sort == "bitrate":
|
||
sorted_results = sorted(results, key=self._sort_by_bitrate, reverse=final_reverse)
|
||
elif self.current_sort == "duration":
|
||
sorted_results = sorted(results, key=self._sort_by_duration, reverse=final_reverse)
|
||
elif self.current_sort == "availability":
|
||
sorted_results = sorted(results, key=self._sort_by_availability, reverse=final_reverse)
|
||
elif self.current_sort == "speed":
|
||
sorted_results = sorted(results, key=self._sort_by_speed, reverse=final_reverse)
|
||
else:
|
||
sorted_results = results
|
||
|
||
return sorted_results
|
||
|
||
def _sort_by_relevance(self, result):
|
||
"""Sort by relevance score combining search matching, quality, completeness, and availability"""
|
||
if not hasattr(self, 'current_search_query') or not self.current_search_query:
|
||
# Fallback to quality score if no search query
|
||
return self._sort_by_quality(result)
|
||
|
||
score = 0.0
|
||
query_terms = self.current_search_query.lower().split()
|
||
|
||
# 1. Search Term Matching (40% weight - 0.4 max)
|
||
search_score = self._calculate_search_match_score(result, query_terms)
|
||
score += search_score * 0.4
|
||
|
||
# 2. Quality Score (25% weight - 0.25 max)
|
||
quality_score = self._sort_by_quality(result)
|
||
score += quality_score * 0.25
|
||
|
||
# 3. File Completeness (20% weight - 0.2 max)
|
||
completeness_score = self._calculate_completeness_score(result)
|
||
score += completeness_score * 0.2
|
||
|
||
# 4. User Reliability (10% weight - 0.1 max)
|
||
reliability_score = self._calculate_reliability_score(result)
|
||
score += reliability_score * 0.1
|
||
|
||
# 5. File Freshness (5% weight - 0.05 max)
|
||
freshness_score = self._calculate_freshness_score(result)
|
||
score += freshness_score * 0.05
|
||
|
||
return score
|
||
|
||
def _calculate_search_match_score(self, result, query_terms):
|
||
"""Calculate search term matching score (0.0 to 1.0)"""
|
||
if not query_terms:
|
||
return 0.0
|
||
|
||
# Get searchable text
|
||
searchable_text = ""
|
||
if hasattr(result, 'album_title'): # AlbumResult
|
||
searchable_text = f"{result.album_title} {result.artist or ''}"
|
||
elif hasattr(result, 'filename'): # TrackResult
|
||
searchable_text = f"{result.filename} {result.artist or ''} {result.title or ''} {result.album or ''}"
|
||
|
||
searchable_text = searchable_text.lower()
|
||
full_query = self.current_search_query.lower()
|
||
|
||
score = 0.0
|
||
|
||
# Exact match bonus (1.0 points)
|
||
if full_query in searchable_text:
|
||
score += 1.0
|
||
|
||
# Individual term matches (0.5 points each)
|
||
term_matches = 0
|
||
for term in query_terms:
|
||
if term in searchable_text:
|
||
term_matches += 1
|
||
score += (term_matches / len(query_terms)) * 0.5
|
||
|
||
# Position bonus (0.3 points if terms appear early)
|
||
position_bonus = 0.0
|
||
for term in query_terms:
|
||
pos = searchable_text.find(term)
|
||
if pos >= 0:
|
||
# Earlier positions get higher bonus
|
||
position_bonus += max(0, (50 - pos) / 50) * 0.3
|
||
score += position_bonus / len(query_terms)
|
||
|
||
return min(score, 1.0)
|
||
|
||
def _calculate_completeness_score(self, result):
|
||
"""Calculate file completeness score (0.0 to 1.0)"""
|
||
score = 0.0
|
||
|
||
if hasattr(result, 'tracks'): # AlbumResult
|
||
# Complete albums bonus
|
||
track_count = len(result.tracks)
|
||
if 8 <= track_count <= 20:
|
||
score += 0.8
|
||
elif 5 <= track_count <= 25:
|
||
score += 0.6
|
||
elif track_count > 25:
|
||
score += 0.4
|
||
else:
|
||
score += 0.2
|
||
|
||
# Album metadata bonus
|
||
if result.artist and result.album_title:
|
||
score += 0.2
|
||
else: # TrackResult
|
||
# Popular song length bonus
|
||
if hasattr(result, 'duration') and result.duration:
|
||
if 180 <= result.duration <= 300: # 3-5 minutes
|
||
score += 0.6
|
||
elif 120 <= result.duration <= 360: # 2-6 minutes
|
||
score += 0.4
|
||
else:
|
||
score += 0.2
|
||
else:
|
||
score += 0.3 # Default if no duration
|
||
|
||
# Track metadata bonus
|
||
if result.artist and result.title:
|
||
score += 0.4
|
||
elif result.artist or result.title:
|
||
score += 0.2
|
||
|
||
return min(score, 1.0)
|
||
|
||
def _calculate_reliability_score(self, result):
|
||
"""Calculate user reliability score (0.0 to 1.0)"""
|
||
score = 0.0
|
||
|
||
# High upload speed bonus
|
||
if hasattr(result, 'upload_speed'):
|
||
if result.upload_speed > 500:
|
||
score += 0.3
|
||
elif result.upload_speed > 200:
|
||
score += 0.2
|
||
elif result.upload_speed > 100:
|
||
score += 0.1
|
||
|
||
# Available slots bonus
|
||
if hasattr(result, 'free_upload_slots') and result.free_upload_slots > 0:
|
||
score += 0.2
|
||
|
||
# Low queue bonus
|
||
if hasattr(result, 'queue_length'):
|
||
if result.queue_length < 5:
|
||
score += 0.1
|
||
elif result.queue_length > 20:
|
||
score -= 0.1
|
||
|
||
return max(0.0, min(score, 1.0))
|
||
|
||
def _calculate_freshness_score(self, result):
|
||
"""Calculate file freshness/naming quality score (0.0 to 1.0)"""
|
||
score = 0.0
|
||
|
||
filename = ""
|
||
if hasattr(result, 'album_title'): # AlbumResult
|
||
filename = result.album_title
|
||
elif hasattr(result, 'filename'): # TrackResult
|
||
filename = result.filename
|
||
|
||
if filename:
|
||
# Proper naming patterns bonus
|
||
if any(pattern in filename.lower() for pattern in [' - ', '_', ' / ', ' & ']):
|
||
score += 0.2
|
||
|
||
# Standard format bonus
|
||
if any(ext in filename.lower() for ext in ['.flac', '.mp3', '.ogg', '.aac']):
|
||
score += 0.1
|
||
|
||
# Avoid weird characters penalty
|
||
if any(char in filename for char in ['@', '#', '$', '%', '!', '?']):
|
||
score -= 0.1
|
||
|
||
# Length bonus (not too short, not too long)
|
||
if 10 <= len(filename) <= 100:
|
||
score += 0.1
|
||
|
||
return max(0.0, min(score, 1.0))
|
||
|
||
def _sort_by_quality(self, result):
|
||
"""Sort by quality score (higher is better)"""
|
||
if hasattr(result, 'quality_score'):
|
||
return result.quality_score
|
||
return 0
|
||
|
||
def _sort_by_size(self, result):
|
||
"""Sort by file/album size (larger first)"""
|
||
size = 0
|
||
if hasattr(result, 'total_size'): # AlbumResult
|
||
size = result.total_size
|
||
elif hasattr(result, 'size'): # TrackResult
|
||
size = result.size
|
||
return size
|
||
|
||
def _sort_by_name(self, result):
|
||
"""Sort alphabetically by filename/album title"""
|
||
name = ""
|
||
if hasattr(result, 'album_title'): # AlbumResult
|
||
name = result.album_title.lower()
|
||
elif hasattr(result, 'filename'): # TrackResult
|
||
name = result.filename.lower()
|
||
return name
|
||
|
||
def _sort_by_uploader(self, result):
|
||
"""Sort alphabetically by username"""
|
||
return result.username.lower() if hasattr(result, 'username') else ""
|
||
|
||
def _sort_by_bitrate(self, result):
|
||
"""Sort by bitrate (higher first)"""
|
||
if hasattr(result, 'bitrate') and result.bitrate:
|
||
return result.bitrate
|
||
# For albums, get average bitrate from tracks
|
||
elif hasattr(result, 'tracks') and result.tracks:
|
||
bitrates = [track.bitrate for track in result.tracks if track.bitrate]
|
||
return sum(bitrates) / len(bitrates) if bitrates else 0
|
||
return 0
|
||
|
||
def _sort_by_duration(self, result):
|
||
"""Sort by duration (longer first)"""
|
||
if hasattr(result, 'duration') and result.duration:
|
||
return result.duration
|
||
# For albums, sum all track durations
|
||
elif hasattr(result, 'tracks') and result.tracks:
|
||
durations = [track.duration for track in result.tracks if track.duration]
|
||
return sum(durations) if durations else 0
|
||
return 0
|
||
|
||
def _sort_by_availability(self, result):
|
||
"""Sort by availability (free slots high, queue length low is better)"""
|
||
free_slots = result.free_upload_slots if hasattr(result, 'free_upload_slots') else 0
|
||
queue_length = result.queue_length if hasattr(result, 'queue_length') else 0
|
||
# Higher free slots and lower queue length = more available
|
||
return free_slots - (queue_length * 0.1)
|
||
|
||
def _sort_by_speed(self, result):
|
||
"""Sort by upload speed (faster first)"""
|
||
return result.upload_speed if hasattr(result, 'upload_speed') else 0
|
||
|
||
def apply_filter(self):
|
||
"""Apply the current type and format filters to search results"""
|
||
if not hasattr(self, '_temp_tracks') or not hasattr(self, '_temp_albums'):
|
||
return
|
||
|
||
# First, filter by type (Albums vs Singles)
|
||
if self.current_filter == "all":
|
||
type_filtered = self._temp_albums + self._temp_tracks
|
||
elif self.current_filter == "albums":
|
||
type_filtered = self._temp_albums
|
||
elif self.current_filter == "singles":
|
||
type_filtered = self._temp_tracks
|
||
else:
|
||
type_filtered = self._temp_albums + self._temp_tracks
|
||
|
||
# Then, filter by format
|
||
if self.current_format_filter == "all":
|
||
filtered_results = type_filtered
|
||
else:
|
||
# Filter results by file format
|
||
filtered_results = []
|
||
for result in type_filtered:
|
||
# For albums, check if any tracks match the format
|
||
if hasattr(result, 'tracks') and result.tracks:
|
||
# Album result - check if any tracks match format
|
||
matching_tracks = [track for track in result.tracks
|
||
if track.quality.lower() == self.current_format_filter.lower()]
|
||
if matching_tracks:
|
||
# Create a copy of the album with only matching tracks
|
||
filtered_album = result
|
||
filtered_album.tracks = matching_tracks
|
||
filtered_results.append(filtered_album)
|
||
else:
|
||
# Single track result - check format directly
|
||
if hasattr(result, 'quality') and result.quality.lower() == self.current_format_filter.lower():
|
||
filtered_results.append(result)
|
||
|
||
# Apply sorting to filtered results
|
||
sorted_results = self.sort_results(filtered_results)
|
||
# Update the filtered results cache for pagination
|
||
self.current_filtered_results = sorted_results
|
||
|
||
# Clear current display
|
||
self.clear_search_results()
|
||
self.displayed_results = 0
|
||
self.currently_expanded_item = None # Reset expanded state when applying filters
|
||
|
||
# Show sorted results (respecting pagination)
|
||
remaining_slots = self.results_per_page
|
||
results_to_show = sorted_results[:remaining_slots]
|
||
|
||
# Temporarily disable layout updates for smoother batch loading
|
||
self.search_results_widget.setUpdatesEnabled(False)
|
||
|
||
for result in results_to_show:
|
||
if isinstance(result, AlbumResult):
|
||
# Create expandable album result item
|
||
result_item = AlbumResultItem(result)
|
||
result_item.album_download_requested.connect(self.start_album_download)
|
||
result_item.matched_album_download_requested.connect(self.start_matched_album_download)
|
||
result_item.track_download_requested.connect(self.start_download)
|
||
result_item.track_stream_requested.connect(lambda search_result, track_item: self.start_stream(search_result, track_item))
|
||
else:
|
||
# Create individual track result item
|
||
result_item = SearchResultItem(result)
|
||
result_item.download_requested.connect(self.start_download)
|
||
result_item.stream_requested.connect(lambda search_result, item=result_item: self.start_stream(search_result, item))
|
||
result_item.expansion_requested.connect(self.handle_expansion_request)
|
||
|
||
# Insert before the stretch
|
||
insert_position = self.search_results_layout.count() - 1
|
||
self.search_results_layout.insertWidget(insert_position, result_item)
|
||
|
||
self.displayed_results = len(results_to_show)
|
||
|
||
# Re-enable layout updates
|
||
self.search_results_widget.setUpdatesEnabled(True)
|
||
|
||
# Update status to show filter results
|
||
total_albums = len(self._temp_albums)
|
||
total_tracks = len(self._temp_tracks)
|
||
total_filtered = len(sorted_results)
|
||
|
||
if self.current_filter == "all":
|
||
filter_status = f"Showing all {total_filtered} results"
|
||
elif self.current_filter == "albums":
|
||
filter_status = f"Showing {total_albums} albums"
|
||
elif self.current_filter == "singles":
|
||
filter_status = f"Showing {total_tracks} singles"
|
||
else:
|
||
filter_status = f"Showing {total_filtered} results"
|
||
|
||
# Update the search status to reflect filtering
|
||
if total_filtered > 0:
|
||
if total_filtered > self.results_per_page:
|
||
filter_status += f" (showing first {len(results_to_show)})"
|
||
self.search_status.setText(f"✨ {filter_status} • {total_albums} albums, {total_tracks} singles")
|
||
else:
|
||
self.search_status.setText(f"No results found for '{self.current_filter}' filter")
|
||
|
||
def create_collapsible_controls_panel(self):
|
||
"""Create a compact, elegant controls panel"""
|
||
panel = QFrame()
|
||
panel.setStyleSheet("""
|
||
QFrame {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(40, 40, 40, 0.85),
|
||
stop:1 rgba(25, 25, 25, 0.95));
|
||
border-radius: 18px;
|
||
border: 1px solid rgba(80, 80, 80, 0.4);
|
||
}
|
||
""")
|
||
|
||
layout = QVBoxLayout(panel)
|
||
layout.setContentsMargins(8, 14, 8, 16) # Reduced side margins for wider download queue
|
||
layout.setSpacing(14) # Increased spacing for better section separation
|
||
|
||
# Panel header
|
||
header = QLabel("Download Manager")
|
||
header.setFont(QFont("Arial", 12, QFont.Weight.Bold))
|
||
header.setStyleSheet("color: rgba(255, 255, 255, 0.9); padding: 6px 0; margin: 0;")
|
||
layout.addWidget(header)
|
||
|
||
# Quick stats with improved styling
|
||
stats_frame = QFrame()
|
||
stats_frame.setStyleSheet("""
|
||
QFrame {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(45, 45, 45, 0.7),
|
||
stop:1 rgba(35, 35, 35, 0.8));
|
||
border-radius: 10px;
|
||
border: 1px solid rgba(80, 80, 80, 0.3);
|
||
}
|
||
""")
|
||
stats_layout = QVBoxLayout(stats_frame)
|
||
stats_layout.setContentsMargins(10, 8, 10, 8)
|
||
stats_layout.setSpacing(4)
|
||
|
||
self.active_downloads_label = QLabel("• Active Downloads: 0")
|
||
self.active_downloads_label.setFont(QFont("Arial", 9))
|
||
self.active_downloads_label.setStyleSheet("color: rgba(255, 255, 255, 0.8); margin: 0; padding: 2px 0;")
|
||
|
||
self.finished_downloads_label = QLabel("• Finished Downloads: 0")
|
||
self.finished_downloads_label.setFont(QFont("Arial", 9))
|
||
self.finished_downloads_label.setStyleSheet("color: rgba(255, 255, 255, 0.8); margin: 0; padding: 2px 0;")
|
||
|
||
stats_layout.addWidget(self.active_downloads_label)
|
||
stats_layout.addWidget(self.finished_downloads_label)
|
||
layout.addWidget(stats_frame)
|
||
|
||
# Control buttons with enhanced styling
|
||
controls_frame = QFrame()
|
||
controls_frame.setStyleSheet("""
|
||
QFrame {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(50, 50, 50, 0.6),
|
||
stop:1 rgba(30, 30, 30, 0.7));
|
||
border-radius: 10px;
|
||
border: 1px solid rgba(70, 70, 70, 0.4);
|
||
}
|
||
""")
|
||
controls_layout = QVBoxLayout(controls_frame)
|
||
controls_layout.setContentsMargins(10, 10, 10, 10)
|
||
controls_layout.setSpacing(6)
|
||
|
||
clear_btn = QPushButton("🗑️ Clear Completed")
|
||
clear_btn.setFixedHeight(28)
|
||
clear_btn.clicked.connect(self.clear_completed_downloads)
|
||
clear_btn.setStyleSheet(self._get_control_button_style("#e22134"))
|
||
|
||
controls_layout.addWidget(clear_btn)
|
||
layout.addWidget(controls_frame)
|
||
|
||
# Download Queue Section - Now with tabs for active and finished downloads
|
||
queue_container = QFrame()
|
||
queue_container.setStyleSheet("""
|
||
QFrame {
|
||
background: transparent;
|
||
border: none;
|
||
margin-top: 5px;
|
||
}
|
||
""")
|
||
queue_layout = QVBoxLayout(queue_container)
|
||
queue_layout.setContentsMargins(0, 0, 0, 0)
|
||
|
||
self.download_queue = TabbedDownloadManager(self)
|
||
queue_layout.addWidget(self.download_queue)
|
||
layout.addWidget(queue_container)
|
||
|
||
# Force initial counter update after queue is set up
|
||
if self.download_queue:
|
||
self.download_queue.update_tab_counts()
|
||
|
||
# Initialize stats display
|
||
self.update_download_manager_stats(0, 0)
|
||
|
||
|
||
return panel
|
||
|
||
def update_download_manager_stats(self, active_count, finished_count):
|
||
"""Update the download manager statistics display"""
|
||
if hasattr(self, 'active_downloads_label'):
|
||
self.active_downloads_label.setText(f"• Active Downloads: {active_count}")
|
||
if hasattr(self, 'finished_downloads_label'):
|
||
self.finished_downloads_label.setText(f"• Finished Downloads: {finished_count}")
|
||
|
||
def create_compact_status_bar(self):
|
||
"""Create a minimal status bar"""
|
||
status_bar = QFrame()
|
||
status_bar.setFixedHeight(40)
|
||
status_bar.setStyleSheet("""
|
||
QFrame {
|
||
background: rgba(20, 20, 20, 0.8);
|
||
border-radius: 8px;
|
||
border: 1px solid rgba(64, 64, 64, 0.2);
|
||
}
|
||
""")
|
||
|
||
layout = QHBoxLayout(status_bar)
|
||
layout.setContentsMargins(16, 8, 16, 8)
|
||
layout.setSpacing(12)
|
||
|
||
connection_status = QLabel("🟢 slskd Connected")
|
||
connection_status.setFont(QFont("Arial", 10))
|
||
connection_status.setStyleSheet("color: rgba(29, 185, 84, 0.9);")
|
||
|
||
layout.addWidget(connection_status)
|
||
layout.addStretch()
|
||
|
||
download_path_info = QLabel(f"📁 Downloads: {self.soulseek_client.download_path if self.soulseek_client else './downloads'}")
|
||
download_path_info.setFont(QFont("Arial", 9))
|
||
download_path_info.setStyleSheet("color: rgba(255, 255, 255, 0.6);")
|
||
layout.addWidget(download_path_info)
|
||
|
||
return status_bar
|
||
|
||
def _get_control_button_style(self, color):
|
||
"""Get consistent button styling with improved aesthetics"""
|
||
return f"""
|
||
QPushButton {{
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba{tuple(int(color[i:i+2], 16) for i in (1, 3, 5)) + (40,)},
|
||
stop:1 rgba{tuple(int(color[i:i+2], 16) for i in (1, 3, 5)) + (25,)});
|
||
border: 1px solid rgba{tuple(int(color[i:i+2], 16) for i in (1, 3, 5)) + (80,)};
|
||
border-radius: 14px;
|
||
color: {color};
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
padding: 5px 10px;
|
||
text-align: center;
|
||
}}
|
||
QPushButton:hover {{
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba{tuple(int(color[i:i+2], 16) for i in (1, 3, 5)) + (60,)},
|
||
stop:1 rgba{tuple(int(color[i:i+2], 16) for i in (1, 3, 5)) + (40,)});
|
||
border: 1px solid {color};
|
||
color: #ffffff;
|
||
}}
|
||
QPushButton:pressed {{
|
||
background: rgba{tuple(int(color[i:i+2], 16) for i in (1, 3, 5)) + (80,)};
|
||
border: 1px solid {color};
|
||
}}
|
||
"""
|
||
|
||
def create_search_section(self):
|
||
section = QFrame()
|
||
section.setFixedHeight(350)
|
||
section.setStyleSheet("""
|
||
QFrame {
|
||
background: #282828;
|
||
border-radius: 8px;
|
||
border: 1px solid #404040;
|
||
}
|
||
""")
|
||
|
||
layout = QVBoxLayout(section)
|
||
layout.setContentsMargins(20, 20, 20, 20)
|
||
layout.setSpacing(15)
|
||
|
||
# Search header
|
||
search_header = QLabel("Search & Download")
|
||
search_header.setFont(QFont("Arial", 16, QFont.Weight.Bold))
|
||
search_header.setStyleSheet("color: #ffffff;")
|
||
|
||
# Search input and button
|
||
search_layout = QHBoxLayout()
|
||
|
||
self.search_input = QLineEdit()
|
||
self.search_input.setPlaceholderText("Search for music (e.g., 'Artist - Song Title')")
|
||
self.search_input.setFixedHeight(40)
|
||
self.search_input.returnPressed.connect(self.perform_search)
|
||
self.search_input.setStyleSheet("""
|
||
QLineEdit {
|
||
background: #404040;
|
||
border: 1px solid #606060;
|
||
border-radius: 20px;
|
||
padding: 0 15px;
|
||
color: #ffffff;
|
||
font-size: 12px;
|
||
}
|
||
QLineEdit:focus {
|
||
border: 1px solid #1db954;
|
||
}
|
||
""")
|
||
|
||
self.search_btn = QPushButton("🔍 Search")
|
||
self.search_btn.setFixedSize(100, 40)
|
||
self.search_btn.clicked.connect(self.perform_search)
|
||
self.search_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: #1db954;
|
||
border: none;
|
||
border-radius: 20px;
|
||
color: #000000;
|
||
font-size: 12px;
|
||
font-weight: bold;
|
||
}
|
||
QPushButton:hover {
|
||
background: #1ed760;
|
||
}
|
||
QPushButton:disabled {
|
||
background: #404040;
|
||
color: #666666;
|
||
}
|
||
""")
|
||
|
||
search_layout.addWidget(self.search_input)
|
||
search_layout.addWidget(self.search_btn)
|
||
|
||
# Search status
|
||
self.search_status = QLabel("Enter a search term and click Search")
|
||
self.search_status.setFont(QFont("Arial", 10))
|
||
self.search_status.setStyleSheet("color: #b3b3b3;")
|
||
|
||
# Search results
|
||
self.search_results_scroll = QScrollArea()
|
||
self.search_results_scroll.setWidgetResizable(True)
|
||
self.search_results_scroll.setStyleSheet("""
|
||
QScrollArea {
|
||
border: none;
|
||
background: transparent;
|
||
}
|
||
QScrollBar:vertical {
|
||
background: #404040;
|
||
width: 8px;
|
||
border-radius: 4px;
|
||
}
|
||
QScrollBar::handle:vertical {
|
||
background: #1db954;
|
||
border-radius: 4px;
|
||
}
|
||
""")
|
||
|
||
self.search_results_widget = QWidget()
|
||
self.search_results_layout = QVBoxLayout(self.search_results_widget)
|
||
self.search_results_layout.setSpacing(5)
|
||
|
||
# Just add stretch - no load more button needed with auto-scroll
|
||
self.search_results_layout.addStretch()
|
||
self.search_results_scroll.setWidget(self.search_results_widget)
|
||
|
||
layout.addWidget(search_header)
|
||
layout.addLayout(search_layout)
|
||
layout.addWidget(self.search_status)
|
||
layout.addWidget(self.search_results_scroll)
|
||
|
||
return section
|
||
|
||
def perform_search(self):
|
||
query = self.search_input.text().strip()
|
||
if not query:
|
||
self.update_search_status("⚠️ Please enter a search term", "#ffa500")
|
||
return
|
||
|
||
if not self.soulseek_client:
|
||
self.update_search_status("❌ Soulseek client not available", "#e22134")
|
||
return
|
||
|
||
# Stop any existing search
|
||
if self.search_thread and self.search_thread.isRunning():
|
||
self.search_thread.stop()
|
||
self.search_thread.wait(1000) # Wait up to 1 second
|
||
if self.search_thread.isRunning():
|
||
self.search_thread.terminate()
|
||
|
||
# Clear previous results and reset state
|
||
self.clear_search_results()
|
||
self.displayed_results = 0
|
||
self.is_loading_more = False
|
||
self.currently_expanded_item = None # Reset expanded state
|
||
|
||
# Reset filter to "all" and sort to "relevance", hide filter controls
|
||
self.current_filter = "all"
|
||
self.current_sort = "relevance"
|
||
self.reverse_order = False # Reset reverse order to normal
|
||
self.current_search_query = query # Store search query for relevance calculation
|
||
if hasattr(self, 'filter_buttons'):
|
||
for btn_key, btn in self.filter_buttons.items():
|
||
self.update_filter_button_style(btn, btn_key == "all")
|
||
if hasattr(self, 'format_buttons'):
|
||
for btn_key, btn in self.format_buttons.items():
|
||
self.update_filter_button_style(btn, btn_key == "all")
|
||
if hasattr(self, 'sort_buttons'):
|
||
for btn_key, btn in self.sort_buttons.items():
|
||
self.update_filter_button_style(btn, btn_key == "relevance")
|
||
if hasattr(self, 'reverse_order_btn'):
|
||
self.reverse_order_btn.setText("↓") # Reset to down arrow
|
||
self.update_filter_button_style(self.reverse_order_btn, False) # Reset to inactive
|
||
self.filter_container.setVisible(False)
|
||
|
||
# Enhanced searching state with animation
|
||
self.search_btn.setText("🔍 Searching...")
|
||
self.search_btn.setEnabled(False)
|
||
self.update_search_status(f"Searching for '{query}'... Results will appear as they are found", "#1db954")
|
||
|
||
# Emit activity signal for search start
|
||
self.download_activity.emit("🔍", "Search Started", f"Searching for '{query}'", "Now")
|
||
|
||
# Show loading animations
|
||
self.start_search_animations()
|
||
|
||
# Start new search thread
|
||
self.search_thread = SearchThread(self.soulseek_client, query)
|
||
self.search_thread.search_completed.connect(self.on_search_completed)
|
||
self.search_thread.search_failed.connect(self.on_search_failed)
|
||
self.search_thread.search_progress.connect(self.on_search_progress)
|
||
self.search_thread.search_results_partial.connect(self.on_search_results_partial)
|
||
self.search_thread.finished.connect(self.on_search_thread_finished)
|
||
self.search_thread.start()
|
||
|
||
# Show cancel button and hide search button during search
|
||
self.cancel_search_btn.setVisible(True)
|
||
self.search_btn.setVisible(False)
|
||
|
||
def cancel_search(self):
|
||
"""Cancel the current search operation"""
|
||
if self.search_thread and self.search_thread.isRunning():
|
||
# Stop the search thread
|
||
self.search_thread.stop()
|
||
self.search_thread.wait(1000) # Wait up to 1 second
|
||
if self.search_thread.isRunning():
|
||
self.search_thread.terminate()
|
||
|
||
# Reset UI state
|
||
self.search_btn.setText("🔍 Search")
|
||
self.search_btn.setEnabled(True)
|
||
self.search_btn.setVisible(True)
|
||
self.cancel_search_btn.setVisible(False)
|
||
|
||
# Stop animations and update status
|
||
self.stop_search_animations()
|
||
self.update_search_status("Search cancelled", "#ffa500")
|
||
|
||
def update_search_status(self, message, color="#ffffff"):
|
||
"""Update search status with enhanced styling"""
|
||
self.search_status.setText(message)
|
||
|
||
if color == "#1db954": # Success/searching
|
||
bg_color = "rgba(29, 185, 84, 0.15)"
|
||
border_color = "rgba(29, 185, 84, 0.3)"
|
||
elif color == "#ffa500": # Warning
|
||
bg_color = "rgba(255, 165, 0, 0.15)"
|
||
border_color = "rgba(255, 165, 0, 0.3)"
|
||
elif color == "#e22134": # Error
|
||
bg_color = "rgba(226, 33, 52, 0.15)"
|
||
border_color = "rgba(226, 33, 52, 0.3)"
|
||
else: # Default
|
||
bg_color = "rgba(100, 100, 100, 0.1)"
|
||
border_color = "rgba(100, 100, 100, 0.2)"
|
||
|
||
self.search_status.setStyleSheet(f"""
|
||
color: {color};
|
||
padding: 2px 8px;
|
||
""")
|
||
|
||
def start_search_animations(self):
|
||
"""Start all search loading animations"""
|
||
# Show and start status area animations
|
||
self.spinning_circle.setVisible(True)
|
||
self.spinning_circle.start_animation()
|
||
self.bouncing_dots.setVisible(True)
|
||
self.bouncing_dots.start_animation()
|
||
|
||
# Show and start results area loading
|
||
self.results_loading_container.setVisible(True)
|
||
self.results_spinning_circle.start_animation()
|
||
|
||
def stop_search_animations(self):
|
||
"""Stop and hide all search loading animations"""
|
||
# Stop and hide status area animations
|
||
self.spinning_circle.stop_animation()
|
||
self.spinning_circle.setVisible(False)
|
||
self.bouncing_dots.stop_animation()
|
||
self.bouncing_dots.setVisible(False)
|
||
|
||
# Stop and hide results area loading
|
||
self.results_spinning_circle.stop_animation()
|
||
self.results_loading_container.setVisible(False)
|
||
|
||
def on_search_thread_finished(self):
|
||
"""Clean up when search thread finishes"""
|
||
if self.search_thread:
|
||
self.search_thread.deleteLater()
|
||
self.search_thread = None
|
||
|
||
def clear_search_results(self):
|
||
# Remove all result items except the stretch
|
||
for i in reversed(range(self.search_results_layout.count())):
|
||
item = self.search_results_layout.itemAt(i)
|
||
if item.widget():
|
||
item.widget().deleteLater()
|
||
elif item.spacerItem():
|
||
continue # Keep the stretch spacer
|
||
else:
|
||
self.search_results_layout.removeItem(item)
|
||
|
||
def on_search_results_partial(self, tracks, albums, response_count):
|
||
"""Handle progressive search results as they come in"""
|
||
# Combine tracks and albums into a single list for display (albums first, then tracks)
|
||
combined_results = albums + tracks
|
||
|
||
# Initialize temp results if not exists
|
||
if not hasattr(self, '_temp_search_results'):
|
||
self._temp_search_results = []
|
||
if not hasattr(self, '_temp_tracks'):
|
||
self._temp_tracks = []
|
||
if not hasattr(self, '_temp_albums'):
|
||
self._temp_albums = []
|
||
|
||
# Store tracks and albums separately and combined
|
||
self._temp_tracks = tracks.copy() # Replace with full updated list
|
||
self._temp_albums = albums.copy() # Replace with full updated list
|
||
self._temp_search_results = combined_results.copy()
|
||
|
||
# Update filtered results cache to match current filter and apply sorting
|
||
if hasattr(self, 'current_filter'):
|
||
if self.current_filter == "all":
|
||
filtered_results = combined_results.copy()
|
||
elif self.current_filter == "albums":
|
||
filtered_results = albums.copy()
|
||
elif self.current_filter == "singles":
|
||
filtered_results = tracks.copy()
|
||
else:
|
||
filtered_results = combined_results.copy()
|
||
else:
|
||
filtered_results = combined_results.copy()
|
||
|
||
# Apply sorting to filtered results
|
||
self.current_filtered_results = self.sort_results(filtered_results)
|
||
|
||
# Clear existing results and display the updated complete set
|
||
# This ensures proper sorting and no duplicates
|
||
self.clear_search_results()
|
||
self.displayed_results = 0
|
||
|
||
# Only display up to the current page limit
|
||
remaining_slots = self.results_per_page
|
||
results_to_show = combined_results[:remaining_slots]
|
||
|
||
# Temporarily disable layout updates for smoother batch loading
|
||
self.search_results_widget.setUpdatesEnabled(False)
|
||
|
||
for result in results_to_show:
|
||
if isinstance(result, AlbumResult):
|
||
# Create expandable album result item
|
||
result_item = AlbumResultItem(result)
|
||
result_item.album_download_requested.connect(self.start_album_download)
|
||
result_item.matched_album_download_requested.connect(self.start_matched_album_download)
|
||
result_item.track_download_requested.connect(self.start_download) # Individual track downloads
|
||
result_item.track_stream_requested.connect(lambda search_result, track_item: self.start_stream(search_result, track_item)) # Individual track streaming
|
||
else:
|
||
# Create individual track result item
|
||
result_item = SearchResultItem(result)
|
||
result_item.download_requested.connect(self.start_download)
|
||
result_item.stream_requested.connect(lambda search_result, item=result_item: self.start_stream(search_result, item))
|
||
result_item.expansion_requested.connect(self.handle_expansion_request)
|
||
|
||
# Insert before the stretch
|
||
insert_position = self.search_results_layout.count() - 1
|
||
self.search_results_layout.insertWidget(insert_position, result_item)
|
||
|
||
# Re-enable updates and force layout refresh
|
||
self.search_results_widget.setUpdatesEnabled(True)
|
||
self.search_results_widget.updateGeometry()
|
||
self.search_results_layout.update()
|
||
self.search_results_scroll.updateGeometry()
|
||
|
||
self.displayed_results = len(results_to_show)
|
||
|
||
# Show filter controls during live search when we have meaningful results
|
||
total_results = len(tracks) + len(albums)
|
||
should_show_filters = (
|
||
# Show if we have both albums and tracks (diverse results)
|
||
(len(albums) > 0 and len(tracks) > 0) or
|
||
# Or if we have enough results to make filtering useful
|
||
total_results >= 5
|
||
)
|
||
|
||
if should_show_filters and not self.filter_container.isVisible():
|
||
self.filter_container.setVisible(True)
|
||
|
||
# Update status message with real-time feedback
|
||
if self.displayed_results < self.results_per_page:
|
||
self.update_search_status(f"✨ Found {total_results} results ({len(tracks)} tracks, {len(albums)} albums) from {response_count} users • Live updating...", "#1db954")
|
||
else:
|
||
self.update_search_status(f"✨ Found {total_results} results ({len(tracks)} tracks, {len(albums)} albums) from {response_count} users • Showing first {self.results_per_page} (scroll for more)", "#1db954")
|
||
|
||
def on_search_completed(self, results):
|
||
self.search_btn.setText("🔍 Search")
|
||
self.search_btn.setEnabled(True)
|
||
self.search_btn.setVisible(True)
|
||
self.cancel_search_btn.setVisible(False)
|
||
|
||
# Stop loading animations
|
||
self.stop_search_animations()
|
||
|
||
# Use the temp results that have been accumulating during live updates
|
||
if hasattr(self, '_temp_tracks') and hasattr(self, '_temp_albums'):
|
||
tracks = self._temp_tracks
|
||
albums = self._temp_albums
|
||
combined_results = self._temp_search_results
|
||
elif isinstance(results, tuple) and len(results) == 2:
|
||
# Fallback to final results if temp not available
|
||
tracks, albums = results
|
||
combined_results = albums + tracks
|
||
else:
|
||
# Fallback for old list format or empty results
|
||
tracks = results or []
|
||
albums = []
|
||
combined_results = results or []
|
||
|
||
# Store final results
|
||
self.search_results = combined_results
|
||
self.current_filtered_results = self.sort_results(combined_results) # Initialize with sorted results
|
||
self.track_results = tracks
|
||
self.album_results = albums
|
||
|
||
total_results = len(combined_results)
|
||
|
||
if total_results == 0:
|
||
if self.displayed_results == 0:
|
||
self.update_search_status("😔 No results found • Try a different search term or artist name", "#ffa500")
|
||
else:
|
||
self.update_search_status(f"✨ Search completed • Found {self.displayed_results} total results", "#1db954")
|
||
# Hide filter controls when no results
|
||
self.filter_container.setVisible(False)
|
||
return
|
||
|
||
# Update status with album/track breakdown
|
||
album_count = len(albums)
|
||
track_count = len(tracks)
|
||
|
||
status_parts = []
|
||
if album_count > 0:
|
||
status_parts.append(f"{album_count} album{'s' if album_count != 1 else ''}")
|
||
if track_count > 0:
|
||
status_parts.append(f"{track_count} track{'s' if track_count != 1 else ''}")
|
||
|
||
result_summary = " • ".join(status_parts) if status_parts else f"{total_results} results"
|
||
|
||
# Show filter controls when we have results
|
||
self.filter_container.setVisible(True)
|
||
|
||
# Emit activity signal for search completion
|
||
search_query = self.search_input.text().strip()
|
||
self.download_activity.emit("✅", "Search Complete", f"Found {total_results} results for '{search_query}'", "Now")
|
||
|
||
# Update status based on whether there are more results to load
|
||
if self.displayed_results < total_results:
|
||
remaining = total_results - self.displayed_results
|
||
self.update_search_status(f"✅ Search completed • Found {result_summary} • Showing first {self.displayed_results} (scroll down for {remaining} more)", "#1db954")
|
||
else:
|
||
self.update_search_status(f"✅ Search completed • Found {result_summary}", "#1db954")
|
||
|
||
def clear_search_results(self):
|
||
"""Clear all search result items from the layout"""
|
||
# Remove all SearchResultItem and AlbumResultItem widgets (but keep stretch)
|
||
items_to_remove = []
|
||
for i in range(self.search_results_layout.count()):
|
||
item = self.search_results_layout.itemAt(i)
|
||
if item and item.widget():
|
||
widget = item.widget()
|
||
if isinstance(widget, (SearchResultItem, AlbumResultItem)):
|
||
items_to_remove.append(widget)
|
||
|
||
for widget in items_to_remove:
|
||
self.search_results_layout.removeWidget(widget)
|
||
widget.deleteLater()
|
||
|
||
def on_scroll_changed(self, value):
|
||
"""Handle scroll changes to implement lazy loading"""
|
||
if self.is_loading_more or not self.current_filtered_results:
|
||
return
|
||
|
||
scroll_bar = self.search_results_scroll.verticalScrollBar()
|
||
|
||
# Check if we're near the bottom (90% scrolled)
|
||
if scroll_bar.maximum() > 0:
|
||
scroll_percentage = value / scroll_bar.maximum()
|
||
|
||
if scroll_percentage >= 0.9 and self.displayed_results < len(self.current_filtered_results):
|
||
self.load_more_results()
|
||
|
||
# In downloads.py, find and REPLACE the existing load_more_results method with this:
|
||
|
||
def load_more_results(self):
|
||
"""
|
||
Prepares a batch of results and initiates the staggered loading process.
|
||
"""
|
||
if self.is_loading_more or not self.current_filtered_results:
|
||
return
|
||
|
||
self.is_loading_more = True
|
||
|
||
# Calculate how many more results to show
|
||
start_index = self.displayed_results
|
||
end_index = min(start_index + self.results_per_page, len(self.current_filtered_results))
|
||
|
||
# Get the batch of results to load and add them to our queue
|
||
results_to_add = self.current_filtered_results[start_index:end_index]
|
||
self._results_to_load_queue.extend(results_to_add)
|
||
|
||
# Update status to show that we are loading more
|
||
self.update_search_status(f"✨ Loading more results...", "#1db954")
|
||
|
||
# Kick off the staggered loading process by loading the first item
|
||
if self._results_to_load_queue:
|
||
QTimer.singleShot(0, self._load_next_result_item)
|
||
else:
|
||
self.is_loading_more = False
|
||
|
||
|
||
def handle_expansion_request(self, requesting_item):
|
||
"""Handle accordion-style expansion where only one item can be expanded at a time"""
|
||
# If there's a currently expanded item and it's not the requesting item, collapse it
|
||
if self.currently_expanded_item and self.currently_expanded_item != requesting_item:
|
||
try:
|
||
self.currently_expanded_item.set_expanded(False, animate=True)
|
||
except RuntimeError:
|
||
# Widget has been deleted, just clear the reference
|
||
self.currently_expanded_item = None
|
||
|
||
# Toggle the requesting item
|
||
new_expanded_state = not requesting_item.is_expanded
|
||
requesting_item.set_expanded(new_expanded_state, animate=True)
|
||
|
||
# Update tracking
|
||
if new_expanded_state:
|
||
self.currently_expanded_item = requesting_item
|
||
else:
|
||
self.currently_expanded_item = None
|
||
|
||
def on_search_failed(self, error_msg):
|
||
self.search_btn.setText("🔍 Search")
|
||
self.search_btn.setEnabled(True)
|
||
self.search_btn.setVisible(True)
|
||
self.cancel_search_btn.setVisible(False)
|
||
|
||
# Stop loading animations
|
||
self.stop_search_animations()
|
||
|
||
self.update_search_status(f"❌ Search failed: {error_msg}", "#e22134")
|
||
|
||
def on_search_progress(self, message):
|
||
self.update_search_status(f"🔍 {message}", "#1db954")
|
||
|
||
def start_download(self, search_result):
|
||
"""Start downloading a search result using threaded approach"""
|
||
try:
|
||
# Extract track info for queue display
|
||
full_filename = search_result.filename
|
||
|
||
# Extract just the filename part (without directory path)
|
||
import os
|
||
filename = os.path.basename(full_filename)
|
||
|
||
# Use TrackResult's parsed metadata if available, otherwise parse filename
|
||
if hasattr(search_result, 'title') and search_result.title:
|
||
title = search_result.title
|
||
print(f"[DEBUG] Using TrackResult title: '{title}'")
|
||
else:
|
||
# Fallback: Parse title from filename
|
||
name_without_ext = filename
|
||
if '.' in name_without_ext:
|
||
name_without_ext = '.'.join(name_without_ext.split('.')[:-1])
|
||
|
||
# Check for track number prefix and remove it
|
||
import re
|
||
track_number_match = re.match(r'^(\d+)\.\s*(.+)', name_without_ext)
|
||
if track_number_match:
|
||
name_without_track_num = track_number_match.group(2)
|
||
else:
|
||
name_without_track_num = name_without_ext
|
||
|
||
# Extract just the title (remove artist if present)
|
||
parts = name_without_track_num.split(' - ')
|
||
if len(parts) >= 2:
|
||
title = ' - '.join(parts[1:]).strip() # Everything after first " - "
|
||
else:
|
||
title = name_without_track_num.strip()
|
||
|
||
print(f"[DEBUG] Parsed title from filename: '{title}'")
|
||
|
||
# Use TrackResult's artist if available, otherwise parse or use username
|
||
if hasattr(search_result, 'artist') and search_result.artist:
|
||
artist = search_result.artist
|
||
print(f"[DEBUG] Using TrackResult artist: '{artist}'")
|
||
else:
|
||
# Fallback: Parse artist from filename or use uploader
|
||
name_without_ext = filename
|
||
if '.' in name_without_ext:
|
||
name_without_ext = '.'.join(name_without_ext.split('.')[:-1])
|
||
|
||
# Remove track number prefix - handle multiple formats
|
||
import re
|
||
name_without_track_num = name_without_ext
|
||
|
||
# Try different track number patterns
|
||
track_patterns = [
|
||
r'^(\d+)\.\s*(.+)', # "06. Artist - Track"
|
||
r'^(\d+)\s*-\s*(.+)', # "06 - Artist - Track"
|
||
r'^(\d+)\s+(.+)' # "06 Artist - Track"
|
||
]
|
||
|
||
for pattern in track_patterns:
|
||
match = re.match(pattern, name_without_ext)
|
||
if match:
|
||
name_without_track_num = match.group(2)
|
||
break
|
||
|
||
# Extract artist (first part before " - ")
|
||
parts = name_without_track_num.split(' - ')
|
||
if len(parts) >= 2:
|
||
# First part is artist, second part is track title
|
||
artist = parts[0].strip()
|
||
# Verify this doesn't look like a track number
|
||
if re.match(r'^\d+$', artist.strip()):
|
||
# If first part is just a number, use username as fallback
|
||
artist = search_result.username
|
||
else:
|
||
artist = search_result.username
|
||
|
||
print(f"[DEBUG] Parsed artist from filename: '{artist}'")
|
||
|
||
# Final cleanup - ensure we have meaningful values
|
||
if not title or title == '':
|
||
title = filename # Ultimate fallback
|
||
if not artist or artist == '':
|
||
artist = search_result.username # Ultimate fallback
|
||
|
||
print(f"[DEBUG] Extracted title info from '{full_filename}' -> title: '{title}', artist: '{artist}'")
|
||
|
||
# Extract album context from search_result if available (for matched album downloads)
|
||
album_name = None
|
||
track_number = None
|
||
|
||
if hasattr(search_result, 'album') and search_result.album:
|
||
album_name = search_result.album
|
||
print(f"[DEBUG] Found album context: '{album_name}'")
|
||
|
||
if hasattr(search_result, 'track_number') and search_result.track_number:
|
||
track_number = search_result.track_number
|
||
print(f"[DEBUG] Found track number: {track_number}")
|
||
|
||
# Generate a unique download ID for tracking and cancellation
|
||
import time
|
||
import uuid
|
||
timestamp = time.time()
|
||
unique_suffix = str(uuid.uuid4())[:8] # Short unique identifier
|
||
download_id = f"{search_result.username}_{filename}_{int(timestamp)}_{unique_suffix}"
|
||
|
||
# Add to download queue immediately as "downloading" with album context
|
||
download_item = self.download_queue.add_download_item(
|
||
title=title,
|
||
artist=artist,
|
||
status="downloading",
|
||
progress=0,
|
||
file_size=search_result.size,
|
||
download_id=download_id,
|
||
username=search_result.username,
|
||
file_path=full_filename, # Store the full path for matching
|
||
soulseek_client=self.soulseek_client,
|
||
album=album_name,
|
||
track_number=track_number
|
||
)
|
||
|
||
print(f"[DEBUG] Created download item with album context: album='{album_name}', track_number={track_number}")
|
||
|
||
# Emit activity signal for download start
|
||
self.download_activity.emit("📥", "Download Started", f"'{title}' by {artist}", "Now")
|
||
|
||
# Create and start download thread
|
||
download_thread = DownloadThread(self.soulseek_client, search_result, download_item)
|
||
download_thread.download_completed.connect(self.on_download_completed, Qt.ConnectionType.QueuedConnection)
|
||
download_thread.download_failed.connect(self.on_download_failed, Qt.ConnectionType.QueuedConnection)
|
||
download_thread.download_progress.connect(self.on_download_progress, Qt.ConnectionType.QueuedConnection)
|
||
download_thread.finished.connect(
|
||
functools.partial(self.on_download_thread_finished, download_thread),
|
||
Qt.ConnectionType.QueuedConnection
|
||
)
|
||
|
||
# Track the thread
|
||
self.download_threads.append(download_thread)
|
||
|
||
# Start the download
|
||
download_thread.start()
|
||
|
||
# Download started - feedback will appear in download queue
|
||
|
||
except Exception as e:
|
||
print(f"Failed to start download: {str(e)}")
|
||
|
||
def start_album_download(self, album_result):
|
||
"""Start downloading all tracks in an album"""
|
||
try:
|
||
print(f"🎵 Starting album download: {album_result.album_title} by {album_result.artist}")
|
||
|
||
# First, find and disable all track download buttons for this album
|
||
self.disable_album_track_buttons(album_result)
|
||
|
||
# Download each track in the album
|
||
for track in album_result.tracks:
|
||
self.start_download(track)
|
||
|
||
print(f"✓ Queued {len(album_result.tracks)} tracks for download from album: {album_result.album_title}")
|
||
|
||
except Exception as e:
|
||
print(f"Failed to start album download: {str(e)}")
|
||
|
||
def disable_album_track_buttons(self, album_result):
|
||
"""Disable all track download buttons for an album to prevent duplicate downloads"""
|
||
# Find the AlbumResultItem that contains these tracks
|
||
for album_item in self.findChildren(AlbumResultItem):
|
||
if (album_item.album_result.album_title == album_result.album_title and
|
||
album_item.album_result.artist == album_result.artist):
|
||
|
||
# Disable all track download buttons in this album
|
||
for track_item in album_item.track_items:
|
||
track_item.set_download_queued_state()
|
||
print(f"[DEBUG] Disabled {len(album_item.track_items)} track download buttons for album: {album_result.album_title}")
|
||
break
|
||
|
||
def start_matched_download(self, search_result):
|
||
"""Start a matched download for a single track using the new modal."""
|
||
try:
|
||
if not self.spotify_client.is_authenticated():
|
||
self.start_download(search_result)
|
||
return
|
||
|
||
modal = SpotifyMatchingModal(search_result, self.spotify_client, self.matching_engine, self, is_album=False)
|
||
# Use a lambda to pass the original search_result to the handler
|
||
modal.match_confirmed.connect(
|
||
lambda artist, album: self._handle_match_confirmed(search_result, artist, album, is_album_download=False)
|
||
)
|
||
|
||
if modal.exec() == QDialog.DialogCode.Accepted:
|
||
print("✅ Match confirmed via modal.")
|
||
elif hasattr(modal, 'skipped_matching') and modal.skipped_matching:
|
||
print("🔄 Matching skipped, proceeding with normal download.")
|
||
self.start_download(search_result)
|
||
else:
|
||
print("🚫 Match process cancelled by user.")
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error in matched download process: {e}")
|
||
self.start_download(search_result)
|
||
|
||
def start_matched_album_download(self, album_result):
|
||
"""Start a matched download for a full album using the new modal."""
|
||
try:
|
||
if not self.spotify_client.is_authenticated():
|
||
self.start_album_download(album_result)
|
||
return
|
||
|
||
# Use the first track as a reference for the modal
|
||
first_track = album_result.tracks[0] if album_result.tracks else None
|
||
if not first_track:
|
||
print("❌ Cannot start matched download for an empty album.")
|
||
self.start_album_download(album_result)
|
||
return
|
||
|
||
modal = SpotifyMatchingModal(first_track, self.spotify_client, self.matching_engine, self, is_album=True, album_result=album_result)
|
||
modal.match_confirmed.connect(
|
||
lambda artist, album: self._handle_match_confirmed(album_result, artist, album, is_album_download=True)
|
||
)
|
||
|
||
if modal.exec() == QDialog.DialogCode.Accepted:
|
||
print("✅ Album match confirmed via modal.")
|
||
elif hasattr(modal, 'skipped_matching') and modal.skipped_matching:
|
||
print("🔄 Album matching skipped, proceeding with normal download.")
|
||
self.start_album_download(album_result)
|
||
else:
|
||
print("🚫 Album match process cancelled by user.")
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error in matched album download process: {e}")
|
||
self.start_album_download(album_result)
|
||
|
||
|
||
def _handle_matched_download(self, search_result, artist: Artist):
|
||
"""Handle the download after artist selection from modal"""
|
||
try:
|
||
print(f"🎯 Starting matched download for '{search_result.title}' by '{artist.name}'")
|
||
|
||
# Store the selected artist metadata with the search result
|
||
search_result.matched_artist = artist
|
||
|
||
# Start the download with normal process but enhanced with Spotify metadata
|
||
download_item = self._start_download_with_artist(search_result, artist)
|
||
|
||
if download_item:
|
||
print(f"✅ Successfully created matched download for '{download_item.title}'")
|
||
else:
|
||
print(f"❌ Failed to create matched download, falling back to normal download")
|
||
self.start_download(search_result)
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error handling matched download: {e}")
|
||
# Fallback to normal download
|
||
self.start_download(search_result)
|
||
|
||
def _handle_match_confirmed(self, original_result, artist: Artist, album: Album, is_album_download=False):
|
||
"""
|
||
Handles the final confirmed match from the modal for both single tracks and full albums.
|
||
"""
|
||
try:
|
||
if is_album_download:
|
||
# This is a full album download
|
||
print(f"🎯 Confirmed album match: '{album.name}' by '{artist.name}'")
|
||
# Now, we process each track in the original album_result
|
||
for track in original_result.tracks:
|
||
track.matched_artist = artist
|
||
track.matched_album = album
|
||
track.album = album.name # Overwrite album name for consistency
|
||
self._start_download_with_artist(track, artist)
|
||
else:
|
||
# This is a single track download
|
||
print(f"🎯 Confirmed single track match: '{original_result.title}' -> Artist: '{artist.name}', Album: '{album.name}'")
|
||
original_result.matched_artist = artist
|
||
original_result.matched_album = album
|
||
|
||
# For singles-only mode, don't overwrite with dummy album name
|
||
if album.id != "singles-dummy":
|
||
original_result.album = album.name
|
||
# If it's our dummy album, keep the original album name from the search result
|
||
|
||
self._start_download_with_artist(original_result, artist)
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error handling confirmed match: {e}")
|
||
# Fallback to normal download
|
||
if is_album_download:
|
||
self.start_album_download(original_result)
|
||
else:
|
||
self.start_download(original_result)
|
||
|
||
|
||
def _start_download_with_artist(self, search_result, artist: Artist):
|
||
"""Start download and immediately assign matched artist - no race conditions"""
|
||
try:
|
||
# Extract track info for queue display (same as start_download)
|
||
full_filename = search_result.filename
|
||
import os
|
||
filename = os.path.basename(full_filename)
|
||
|
||
# Use TrackResult's parsed metadata if available, otherwise parse filename
|
||
if hasattr(search_result, 'title') and search_result.title:
|
||
title = search_result.title
|
||
else:
|
||
# Fallback parsing logic (copied from start_download)
|
||
name_without_ext = filename
|
||
if '.' in name_without_ext:
|
||
name_without_ext = '.'.join(name_without_ext.split('.')[:-1])
|
||
|
||
import re
|
||
track_number_match = re.match(r'^(\d+)\.\s*(.+)', name_without_ext)
|
||
if track_number_match:
|
||
name_without_track_num = track_number_match.group(2)
|
||
else:
|
||
name_without_track_num = name_without_ext
|
||
|
||
parts = name_without_track_num.split(' - ')
|
||
if len(parts) >= 2:
|
||
title = ' - '.join(parts[1:]).strip()
|
||
else:
|
||
title = name_without_track_num.strip()
|
||
|
||
# Use TrackResult's artist if available, otherwise parse or use username
|
||
if hasattr(search_result, 'artist') and search_result.artist:
|
||
original_artist = search_result.artist
|
||
else:
|
||
# Same fallback logic as start_download
|
||
name_without_ext = filename
|
||
if '.' in name_without_ext:
|
||
name_without_ext = '.'.join(name_without_ext.split('.')[:-1])
|
||
|
||
import re
|
||
name_without_track_num = name_without_ext
|
||
|
||
# Try different track number patterns
|
||
track_patterns = [
|
||
r'^(\d+)\.\s*(.+)', # "06. Artist - Track"
|
||
r'^(\d+)\s*-\s*(.+)', # "06 - Artist - Track"
|
||
r'^(\d+)\s+(.+)' # "06 Artist - Track"
|
||
]
|
||
|
||
for pattern in track_patterns:
|
||
match = re.match(pattern, name_without_ext)
|
||
if match:
|
||
name_without_track_num = match.group(2)
|
||
break
|
||
|
||
parts = name_without_track_num.split(' - ')
|
||
if len(parts) >= 2:
|
||
original_artist = parts[0].strip()
|
||
# Verify this doesn't look like a track number
|
||
if re.match(r'^\d+$', original_artist.strip()):
|
||
# If first part is just a number, use username as fallback
|
||
original_artist = search_result.username
|
||
else:
|
||
original_artist = search_result.username
|
||
|
||
# Final cleanup
|
||
if not title or title == '':
|
||
title = filename
|
||
if not original_artist or original_artist == '':
|
||
original_artist = search_result.username
|
||
|
||
# Extract album context
|
||
album_name = getattr(search_result, 'album', None)
|
||
track_number = getattr(search_result, 'track_number', None)
|
||
|
||
# If no track number found, try to extract from filename
|
||
if not track_number:
|
||
track_number = self._extract_track_number_from_filename(filename, title)
|
||
if track_number:
|
||
print(f"✅ Extracted track number from filename: {track_number}")
|
||
else:
|
||
print(f"❌ Could not extract track number from filename: '{filename}'")
|
||
|
||
# Generate download ID
|
||
import time
|
||
import uuid
|
||
timestamp = time.time()
|
||
unique_suffix = str(uuid.uuid4())[:8] # Short unique identifier
|
||
download_id = f"{search_result.username}_{filename}_{int(timestamp)}_{unique_suffix}"
|
||
|
||
# Create download item with matched artist immediately
|
||
download_item = self.download_queue.add_download_item(
|
||
title=title,
|
||
artist=original_artist,
|
||
status="downloading",
|
||
progress=0,
|
||
file_size=search_result.size,
|
||
download_id=download_id,
|
||
username=search_result.username,
|
||
file_path=full_filename,
|
||
soulseek_client=self.soulseek_client,
|
||
album=album_name,
|
||
track_number=track_number
|
||
)
|
||
|
||
# Immediately assign the matched artist - no timing delays
|
||
if download_item:
|
||
download_item.matched_artist = artist
|
||
print(f"✅ Matched artist '{artist.name}' assigned to download item '{download_item.title}'")
|
||
|
||
# Emit activity signal for download start (with matched artist)
|
||
self.download_activity.emit("📥", "Download Started", f"'{title}' by {artist.name if artist else original_artist}", "Now")
|
||
|
||
# Start the download thread
|
||
download_thread = DownloadThread(self.soulseek_client, search_result, download_item)
|
||
download_thread.download_completed.connect(self.on_download_completed, Qt.ConnectionType.QueuedConnection)
|
||
download_thread.download_failed.connect(self.on_download_failed, Qt.ConnectionType.QueuedConnection)
|
||
download_thread.download_progress.connect(self.on_download_progress, Qt.ConnectionType.QueuedConnection)
|
||
download_thread.finished.connect(
|
||
functools.partial(self.on_download_thread_finished, download_thread),
|
||
Qt.ConnectionType.QueuedConnection
|
||
)
|
||
|
||
self.download_threads.append(download_thread)
|
||
download_thread.start()
|
||
|
||
return download_item
|
||
|
||
except Exception as e:
|
||
print(f"❌ Failed to start download with artist: {str(e)}")
|
||
return None
|
||
|
||
def _ensure_album_consistency(self, download_items, artist: Artist, album_name: str):
|
||
"""Ensure all download items have consistent album naming for proper grouping"""
|
||
try:
|
||
with self.album_cache_lock:
|
||
# Create cache key for this album
|
||
album_key = f"{artist.name}::{album_name}"
|
||
|
||
# Store the definitive album name
|
||
self.album_name_cache[album_key] = album_name
|
||
|
||
print(f"🔒 Cached album name: '{album_name}' for key: '{album_key}'")
|
||
|
||
# Ensure all download items use the same album name
|
||
for download_item in download_items:
|
||
if hasattr(download_item, 'album'):
|
||
download_item.album = album_name
|
||
print(f" ✅ Set album name for '{download_item.title}': '{album_name}'")
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error ensuring album consistency: {e}")
|
||
|
||
def _assign_matched_artist_to_download_item(self, search_result, artist: Artist):
|
||
"""Assign matched artist to the corresponding download item"""
|
||
try:
|
||
print(f"🔍 Looking for download item matching: '{search_result.title}' by '{search_result.artist}'")
|
||
print(f"📋 Current download items: {len(self.download_queue.download_items)}")
|
||
|
||
matched = False
|
||
# Find the download item for this search result
|
||
for i, download_item in enumerate(self.download_queue.download_items):
|
||
print(f" Item {i}: '{getattr(download_item, 'title', 'NO_TITLE')}' by '{getattr(download_item, 'artist', 'NO_ARTIST')}'")
|
||
|
||
if (hasattr(download_item, 'title') and download_item.title == search_result.title and
|
||
hasattr(download_item, 'artist') and download_item.artist == search_result.artist):
|
||
download_item.matched_artist = artist
|
||
print(f"✅ Assigned matched artist '{artist.name}' to download item '{download_item.title}'")
|
||
matched = True
|
||
break
|
||
|
||
if not matched:
|
||
print(f"❌ Could not find matching download item for '{search_result.title}' by '{search_result.artist}'")
|
||
# Try a more lenient search
|
||
for i, download_item in enumerate(self.download_queue.download_items):
|
||
if (hasattr(download_item, 'title') and
|
||
self.matching_engine.normalize_string(download_item.title) == self.matching_engine.normalize_string(search_result.title)):
|
||
download_item.matched_artist = artist
|
||
print(f"✅ Assigned matched artist '{artist.name}' to download item '{download_item.title}' (lenient match)")
|
||
matched = True
|
||
break
|
||
|
||
if not matched:
|
||
print(f"❌ Still could not find matching download item - assignment failed")
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error assigning matched artist to download item: {e}")
|
||
|
||
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")
|
||
|
||
# Store the selected artist metadata and album context with each track
|
||
print(f"🔍 Album context being set:")
|
||
print(f" Album result title: '{album_result.album_title}'")
|
||
print(f" Matched artist: '{artist.name}'")
|
||
|
||
# Clean up the album title - remove "Album - Artist -" prefix if present
|
||
clean_album_title = self._clean_album_title(album_result.album_title, artist.name)
|
||
print(f" Cleaned album title: '{clean_album_title}'")
|
||
|
||
for track_index, track in enumerate(album_result.tracks, 1):
|
||
track.matched_artist = artist
|
||
|
||
# Preserve album context - this is CRITICAL for proper album detection
|
||
track.album = clean_album_title # Use cleaned album title
|
||
|
||
# Extract original track number from filename instead of using sequential index
|
||
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
|
||
print(f" 🎵 Preserved original track number: {original_track_num}")
|
||
else:
|
||
# Only use sequential as fallback if no original number found
|
||
track.track_number = track_index
|
||
print(f" ⚠️ Using fallback sequential track number: {track_index}")
|
||
else:
|
||
# Fallback to sequential numbering if no filename available
|
||
track.track_number = track_index
|
||
print(f" ⚠️ Using fallback sequential track number (no filename): {track_index}")
|
||
|
||
# Clean up track title - remove artist prefix if present
|
||
clean_track_title = self._clean_track_title(track.title, artist.name)
|
||
track.title = clean_track_title
|
||
|
||
print(f" 🎵 Track {track_index}: '{clean_track_title}' -> Artist: '{artist.name}', Album: '{clean_album_title}', Track#: {track.track_number}")
|
||
|
||
# Start downloading all tracks with matched artist immediately
|
||
import time
|
||
download_items = []
|
||
for track_index, track in enumerate(album_result.tracks, 1):
|
||
print(f"🎬 Starting download {track_index}/{len(album_result.tracks)}: {track.title}")
|
||
download_item = self._start_download_with_artist(track, artist)
|
||
if download_item:
|
||
download_items.append(download_item)
|
||
# Small delay between downloads to avoid overwhelming Soulseek
|
||
time.sleep(0.1)
|
||
|
||
print(f"✅ Successfully queued {len(download_items)}/{len(album_result.tracks)} tracks with matched artist")
|
||
|
||
# Pre-calculate and cache the album name to ensure consistency
|
||
if download_items:
|
||
self._ensure_album_consistency(download_items, artist, clean_album_title)
|
||
|
||
print(f"✓ Queued {len(album_result.tracks)} tracks for matched download from album: {album_result.album_title}")
|
||
print(f"🎯 All tracks have album context preserved: '{album_result.album_title}'")
|
||
|
||
except Exception as e:
|
||
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"""
|
||
try:
|
||
print(f"🎯 Starting matched album download with FORCED album selection")
|
||
print(f" Original album: '{album_result.album_title}'")
|
||
print(f" Selected artist: '{artist.name}'")
|
||
print(f" Selected album: '{selected_album.name}'")
|
||
print(f" 🔒 ALL tracks will be forced into: '{selected_album.name}'")
|
||
|
||
# Fetch official track titles from Spotify album
|
||
print(f"🎵 Fetching official track titles from Spotify album...")
|
||
spotify_tracks = self._get_spotify_album_tracks(selected_album)
|
||
|
||
download_items = []
|
||
|
||
# Process all tracks and FORCE them into the selected album
|
||
for track_index, track in enumerate(album_result.tracks, 1):
|
||
track.matched_artist = artist
|
||
track.album = selected_album.name # FORCE album name
|
||
track.matched_album = selected_album # Store the full album object
|
||
track._force_album_name = selected_album.name # Flag to prevent album detection override
|
||
track._force_album_mode = True # Flag to skip individual Spotify lookups
|
||
|
||
# Extract original track number from filename
|
||
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
|
||
|
||
# Match to Spotify track title if available
|
||
spotify_title = self._match_track_to_spotify_title(track, spotify_tracks)
|
||
if spotify_title:
|
||
print(f" 🎵 Track {track_index}: '{track.title}' -> Spotify title: '{spotify_title}'")
|
||
track._spotify_title = spotify_title # Store the official Spotify title
|
||
track._spotify_clean_title = spotify_title # This will be used for file naming
|
||
else:
|
||
print(f" 🎵 Track {track_index}: '{track.title}' -> No Spotify match found, using original")
|
||
|
||
print(f" 🔒 FORCED into Album: {selected_album.name}")
|
||
|
||
# Start individual track download with enhanced metadata
|
||
download_item = self._start_download_with_artist(track, artist)
|
||
if download_item:
|
||
# Also apply the forced album to the download item
|
||
download_item._force_album_name = selected_album.name
|
||
download_item._force_album_mode = True
|
||
|
||
# Apply Spotify title to download item if available
|
||
if hasattr(track, '_spotify_clean_title'):
|
||
download_item._spotify_clean_title = track._spotify_clean_title
|
||
print(f"✅ Applied Spotify title to download item: '{track._spotify_clean_title}'")
|
||
|
||
download_items.append(download_item)
|
||
print(f"✓ Successfully queued track: {track.title}")
|
||
else:
|
||
print(f"❌ Failed to queue track: {track.title}")
|
||
|
||
# Ensure all download items have consistent album information
|
||
for download_item in download_items:
|
||
if hasattr(download_item, 'matched_artist'):
|
||
download_item._force_album_name = selected_album.name
|
||
download_item._force_album_mode = True
|
||
|
||
print(f"✓ Queued {len(album_result.tracks)} tracks for matched download - ALL FORCED into album: {selected_album.name}")
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error handling matched album download with album selection: {e}")
|
||
# Fallback to normal album download
|
||
self.start_album_download(album_result)
|
||
|
||
def _assign_matched_artist_to_album_downloads(self, album_result, artist: Artist):
|
||
"""Assign matched artist to all download items for an album"""
|
||
try:
|
||
print(f"📋 Assigning matched artist '{artist.name}' to all album download items")
|
||
print(f"📀 Album has {len(album_result.tracks)} tracks")
|
||
print(f"📋 Current download queue has {len(self.download_queue.download_items)} items")
|
||
|
||
assigned_count = 0
|
||
|
||
# Find download items for all tracks in this album
|
||
for track_idx, track in enumerate(album_result.tracks):
|
||
print(f"🔍 Looking for track {track_idx + 1}: '{track.title}' by '{track.artist}'")
|
||
|
||
matched = False
|
||
for download_item in self.download_queue.download_items:
|
||
if (hasattr(download_item, 'title') and download_item.title == track.title and
|
||
hasattr(download_item, 'artist') and download_item.artist == track.artist):
|
||
download_item.matched_artist = artist
|
||
assigned_count += 1
|
||
print(f" ✅ Assigned to: {download_item.title}")
|
||
matched = True
|
||
break
|
||
|
||
if not matched:
|
||
print(f" ❌ Could not find download item for: {track.title}")
|
||
# Try a more lenient search
|
||
for download_item in self.download_queue.download_items:
|
||
if (hasattr(download_item, 'title') and
|
||
self.matching_engine.normalize_string(download_item.title) == self.matching_engine.normalize_string(track.title)):
|
||
download_item.matched_artist = artist
|
||
assigned_count += 1
|
||
print(f" ✅ Assigned to: {download_item.title} (lenient match)")
|
||
matched = True
|
||
break
|
||
|
||
if not matched:
|
||
print(f" ❌ Still could not find download item for: {track.title}")
|
||
|
||
print(f"✅ Successfully assigned matched artist to {assigned_count}/{len(album_result.tracks)} album tracks")
|
||
|
||
# If we didn't assign to all tracks, let's try again with a longer delay
|
||
if assigned_count < len(album_result.tracks):
|
||
print(f"⏰ Some assignments failed, will retry in 1 second...")
|
||
QTimer.singleShot(1000, lambda: self._retry_album_assignment(album_result, artist, assigned_count))
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error assigning matched artist to album download items: {e}")
|
||
|
||
def _retry_album_assignment(self, album_result, artist: Artist, previous_count: int):
|
||
"""Retry assignment for album tracks that failed the first time"""
|
||
try:
|
||
print(f"🔄 Retrying album assignment for remaining tracks...")
|
||
new_assigned = 0
|
||
|
||
for track in album_result.tracks:
|
||
# Only try to assign if not already assigned
|
||
found_assigned = False
|
||
for download_item in self.download_queue.download_items:
|
||
if (hasattr(download_item, 'title') and download_item.title == track.title and
|
||
hasattr(download_item, 'matched_artist') and download_item.matched_artist):
|
||
found_assigned = True
|
||
break
|
||
|
||
if not found_assigned:
|
||
# Try to find and assign
|
||
for download_item in self.download_queue.download_items:
|
||
if (hasattr(download_item, 'title') and
|
||
self.matching_engine.normalize_string(download_item.title) == self.matching_engine.normalize_string(track.title)):
|
||
download_item.matched_artist = artist
|
||
new_assigned += 1
|
||
print(f" ✅ Retry assigned to: {download_item.title}")
|
||
break
|
||
|
||
total_assigned = previous_count + new_assigned
|
||
print(f"🔄 Retry complete: {total_assigned}/{len(album_result.tracks)} tracks now assigned")
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error in retry assignment: {e}")
|
||
|
||
def _handle_modal_cancelled(self, search_result):
|
||
"""Handle when modal is cancelled for single track downloads"""
|
||
print(f"🚫 Modal cancelled for track: {search_result.title}")
|
||
# Re-enable any disabled download buttons for this track
|
||
# Since track downloads don't disable buttons, this is mainly for consistency
|
||
|
||
def _handle_album_modal_cancelled(self, album_result):
|
||
"""Handle when modal is cancelled for album downloads - re-enable buttons"""
|
||
print(f"🚫 Album modal cancelled for: {album_result.album_title}")
|
||
|
||
# Re-enable all track download buttons for this album
|
||
self._enable_album_track_buttons(album_result)
|
||
|
||
def _enable_album_track_buttons(self, album_result):
|
||
"""Re-enable all track download buttons for an album"""
|
||
try:
|
||
# Find the AlbumResultItem and re-enable its buttons
|
||
for i in range(self.search_results_layout.count()):
|
||
item = self.search_results_layout.itemAt(i)
|
||
if item and item.widget():
|
||
widget = item.widget()
|
||
if hasattr(widget, 'album_result') and widget.album_result == album_result:
|
||
# Re-enable the matched download button
|
||
if hasattr(widget, 'matched_download_btn'):
|
||
widget.matched_download_btn.setText("🎯 Download w/ Matching")
|
||
widget.matched_download_btn.setEnabled(True)
|
||
|
||
# Re-enable the regular download button
|
||
if hasattr(widget, 'download_btn'):
|
||
widget.download_btn.setText("⬇️ Download Album")
|
||
widget.download_btn.setEnabled(True)
|
||
|
||
print(f"✅ Re-enabled buttons for album: {album_result.album_title}")
|
||
break
|
||
except Exception as e:
|
||
print(f"❌ Error re-enabling album buttons: {e}")
|
||
|
||
|
||
def _cleanup_empty_directories(self, download_path, moved_file_path):
|
||
"""
|
||
Clean up empty directories left after moving a file, ignoring hidden files.
|
||
Walks up the directory tree until it finds a non-empty folder or the root download path.
|
||
"""
|
||
import os
|
||
|
||
try:
|
||
# Start with the directory that contained the moved file
|
||
# For you, this will be 'downloads/SomeAlbumFolder'
|
||
current_dir = os.path.dirname(moved_file_path)
|
||
|
||
# This loop will continue as long as the directory is inside the main download path
|
||
while current_dir != download_path and current_dir.startswith(download_path):
|
||
# Check if the directory is empty, IGNORING hidden files like .DS_Store
|
||
is_empty = True
|
||
for entry in os.listdir(current_dir):
|
||
if not entry.startswith('.'): # This is the key fix
|
||
is_empty = False
|
||
break
|
||
|
||
if is_empty:
|
||
print(f"Removing empty directory (ignoring hidden files): {current_dir}")
|
||
try:
|
||
os.rmdir(current_dir)
|
||
# After deleting, move up to the parent to check it too
|
||
current_dir = os.path.dirname(current_dir)
|
||
except OSError as e:
|
||
print(f"Warning: Could not remove directory {current_dir}: {e}")
|
||
break # Stop if we can't remove a directory
|
||
else:
|
||
# If the directory is not empty, the job is done.
|
||
print(f"Stopping cleanup at non-empty directory: {current_dir}")
|
||
break
|
||
|
||
except Exception as e:
|
||
print(f"Warning: An error occurred during directory cleanup: {e}")
|
||
|
||
|
||
def _organize_matched_download(self, download_item, original_file_path: str) -> Optional[str]:
|
||
"""Organize a matched download into the Transfer folder structure"""
|
||
try:
|
||
import os
|
||
import shutil
|
||
from pathlib import Path
|
||
|
||
if not hasattr(download_item, 'matched_artist'):
|
||
print("❌ No matched artist information found")
|
||
return None
|
||
|
||
artist = download_item.matched_artist
|
||
print(f"🎯 Organizing download for artist: {artist.name}")
|
||
|
||
# --- FIX: Get transfer directory from config instead of hardcoding ---
|
||
# OLD CODE:
|
||
# project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||
# transfer_dir = os.path.join(project_root, 'Transfer')
|
||
|
||
# NEW CODE:
|
||
transfer_dir = config_manager.get('soulseek.transfer_path', './Transfer')
|
||
os.makedirs(transfer_dir, exist_ok=True)
|
||
|
||
# Create artist directory
|
||
artist_dir = os.path.join(transfer_dir, self._sanitize_filename(artist.name))
|
||
os.makedirs(artist_dir, exist_ok=True)
|
||
|
||
# Determine if this is a single or album track
|
||
album_info = self._detect_album_info(download_item, artist)
|
||
|
||
# Resolve consistent album name for grouping tracks from same album
|
||
if album_info and album_info['is_album']:
|
||
print(f"\n🎯 SMART ALBUM GROUPING for track: '{download_item.title}'")
|
||
print(f" Original album: '{getattr(download_item, 'album', 'None')}'")
|
||
print(f" Detected album: '{album_info.get('album_name', 'None')}'")
|
||
|
||
consistent_album_name = self._resolve_album_group(download_item, artist, album_info)
|
||
album_info['album_name'] = consistent_album_name
|
||
|
||
print(f" Final album name: '{consistent_album_name}'")
|
||
print(f"🔗 ✅ Album grouping complete!\n")
|
||
|
||
if album_info and album_info['is_album']:
|
||
# Album track structure: Transfer/ARTIST/ARTIST - ALBUM/TRACK# TRACK.ext
|
||
print(f"🔍 Creating album folder:")
|
||
print(f" Artist name: '{artist.name}'")
|
||
print(f" Album name from album_info: '{album_info['album_name']}'")
|
||
print(f" Original download item title: '{download_item.title}'")
|
||
|
||
# Use the Spotify title information if available (most accurate for matched tracks)
|
||
clean_track_name = download_item.title
|
||
if hasattr(download_item, '_spotify_clean_title') and download_item._spotify_clean_title:
|
||
clean_track_name = download_item._spotify_clean_title
|
||
elif album_info.get('clean_track_name'):
|
||
clean_track_name = album_info['clean_track_name']
|
||
|
||
print(f" Clean track name to use: '{clean_track_name}'")
|
||
|
||
album_folder_name = f"{self._sanitize_filename(artist.name)} - {self._sanitize_filename(album_info['album_name'])}"
|
||
album_dir = os.path.join(artist_dir, album_folder_name)
|
||
os.makedirs(album_dir, exist_ok=True)
|
||
|
||
# Create track filename with number (just track number + clean title, NO artist)
|
||
file_ext = os.path.splitext(original_file_path)[1]
|
||
track_number = album_info.get('track_number', 1)
|
||
track_filename = f"{track_number:02d} - {self._sanitize_filename(clean_track_name)}{file_ext}"
|
||
new_file_path = os.path.join(album_dir, track_filename)
|
||
|
||
print(f"📁 Album folder created: '{album_folder_name}'")
|
||
print(f"🎵 Track filename: '{track_filename}'")
|
||
|
||
else:
|
||
# Single track structure: Transfer/ARTIST/ARTIST - SINGLE/SINGLE.ext
|
||
# Use the Spotify title information if available (most accurate for matched tracks)
|
||
clean_track_name = download_item.title
|
||
if hasattr(download_item, '_spotify_clean_title') and download_item._spotify_clean_title:
|
||
clean_track_name = download_item._spotify_clean_title
|
||
elif album_info and album_info.get('clean_track_name'):
|
||
clean_track_name = album_info['clean_track_name']
|
||
|
||
print(f" Original download item title: '{download_item.title}'")
|
||
print(f" Clean track name to use: '{clean_track_name}'")
|
||
|
||
single_folder_name = f"{self._sanitize_filename(artist.name)} - {self._sanitize_filename(clean_track_name)}"
|
||
single_dir = os.path.join(artist_dir, single_folder_name)
|
||
os.makedirs(single_dir, exist_ok=True)
|
||
|
||
# Create single filename with clean track name
|
||
file_ext = os.path.splitext(original_file_path)[1]
|
||
single_filename = f"{self._sanitize_filename(clean_track_name)}{file_ext}"
|
||
new_file_path = os.path.join(single_dir, single_filename)
|
||
|
||
print(f"📁 Single track: {single_folder_name}/{single_filename}")
|
||
|
||
# Check if source file exists, and try to find it if not
|
||
if not os.path.exists(original_file_path):
|
||
print(f"❌ Source file not found: {original_file_path}")
|
||
|
||
# Try to find the file using different methods
|
||
found_file = self._find_downloaded_file(original_file_path, download_item)
|
||
if found_file:
|
||
print(f"✅ Found file at: {found_file}")
|
||
original_file_path = found_file
|
||
else:
|
||
print(f"❌ Could not locate downloaded file anywhere")
|
||
return None
|
||
|
||
# File organization and overwrite logic will be handled after metadata enhancement
|
||
|
||
# 🆕 METADATA ENHANCEMENT - Enhance BEFORE moving to avoid race conditions
|
||
if self._enhance_file_metadata(original_file_path, download_item, artist, album_info):
|
||
print(f"✅ Metadata enhanced with Spotify data")
|
||
else:
|
||
print(f"⚠️ Metadata enhancement failed, using original tags")
|
||
|
||
# Verify source file exists before attempting move
|
||
if not os.path.exists(original_file_path):
|
||
print(f"❌ Source file not found: {original_file_path}")
|
||
return None
|
||
|
||
# Explicit overwrite: check if file exists, remove it, then move
|
||
if os.path.isfile(new_file_path):
|
||
os.remove(new_file_path)
|
||
|
||
# Move the enhanced file to destination
|
||
shutil.move(original_file_path, new_file_path)
|
||
|
||
# Verify the move was successful
|
||
if not os.path.exists(new_file_path):
|
||
print(f"❌ File move failed - destination file not found: {new_file_path}")
|
||
return None
|
||
|
||
if os.path.exists(original_file_path):
|
||
try:
|
||
os.remove(original_file_path)
|
||
except Exception as cleanup_error:
|
||
print(f"⚠️ Could not remove original file: {cleanup_error}")
|
||
|
||
# Clean up any empty directories left in the downloads folder
|
||
try:
|
||
downloads_path = config_manager.get('soulseek.download_path', './Downloads')
|
||
self._cleanup_empty_directories(downloads_path, original_file_path)
|
||
except Exception as cleanup_error:
|
||
print(f"⚠️ Could not clean up empty directories: {cleanup_error}")
|
||
|
||
# Download cover art for both albums and singles
|
||
if album_info and album_info['is_album']:
|
||
# Album track - download to album directory
|
||
self._download_cover_art(artist, album_info, os.path.dirname(new_file_path))
|
||
else:
|
||
# Single track - create minimal album_info for cover art download
|
||
single_album_info = self._create_single_track_album_info(download_item, artist)
|
||
if single_album_info:
|
||
self._download_cover_art(artist, single_album_info, os.path.dirname(new_file_path))
|
||
|
||
# Generate LRC lyrics file at final location (elegant addition)
|
||
self._generate_lrc_file(new_file_path, download_item, artist, album_info)
|
||
|
||
print(f"✅ Successfully organized matched download: {new_file_path}")
|
||
return new_file_path
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error organizing matched download: {e}")
|
||
return None
|
||
|
||
|
||
|
||
|
||
def _sanitize_filename(self, filename: str) -> str:
|
||
"""Sanitize filename for file system compatibility"""
|
||
import re
|
||
# Replace invalid characters with underscores
|
||
sanitized = re.sub(r'[<>:"/\\|?*]', '_', filename)
|
||
# Remove multiple spaces and trim
|
||
sanitized = re.sub(r'\s+', ' ', sanitized).strip()
|
||
# Windows forbids trailing dots/spaces on files and folders
|
||
sanitized = sanitized.rstrip('. ') or '_'
|
||
# Windows reserved device names
|
||
if re.match(r'^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)', sanitized, re.IGNORECASE):
|
||
sanitized = '_' + sanitized
|
||
# Limit length to avoid filesystem issues
|
||
return sanitized[:200] if len(sanitized) > 200 else sanitized
|
||
|
||
def _clean_album_title(self, album_title: str, artist_name: str) -> str:
|
||
"""Clean up album title by removing common prefixes, suffixes, and artist redundancy"""
|
||
import re
|
||
|
||
# Start with the original title
|
||
original = album_title.strip()
|
||
cleaned = original
|
||
print(f"🧹 Album Title Cleaning: '{original}' (artist: '{artist_name}')")
|
||
|
||
# Remove "Album - " prefix
|
||
cleaned = re.sub(r'^Album\s*-\s*', '', cleaned, flags=re.IGNORECASE)
|
||
|
||
# Remove artist name prefix if it appears at the beginning
|
||
# This handles cases like "Kendrick Lamar - good kid, m.A.A.d city"
|
||
artist_pattern = re.escape(artist_name) + r'\s*-\s*'
|
||
cleaned = re.sub(f'^{artist_pattern}', '', cleaned, flags=re.IGNORECASE)
|
||
|
||
# Remove common Soulseek suffixes in square brackets and parentheses
|
||
# Examples: [Deluxe Edition] [2012] [320 Kbps] [Album+iTunes+Bonus Tracks] [F10]
|
||
# (Deluxe Edition) (2012) (320 Kbps) etc.
|
||
# Remove year patterns like [2012], (2020), etc.
|
||
cleaned = re.sub(r'\s*[\[\(]\d{4}[\]\)]\s*', ' ', cleaned)
|
||
|
||
# Remove quality/format indicators
|
||
quality_patterns = [
|
||
r'\s*[\[\(][0-9]+\s*kbps[\]\)]\s*',
|
||
r'\s*[\[\(][0-9]+k[\]\)]\s*',
|
||
r'\s*[\[\(]320[\]\)]\s*',
|
||
r'\s*[\[\(]flac[\]\)]\s*',
|
||
r'\s*[\[\(]mp3[\]\)]\s*',
|
||
r'\s*[\[\(]wav[\]\)]\s*',
|
||
r'\s*[\[\(]lossless[\]\)]\s*'
|
||
]
|
||
for pattern in quality_patterns:
|
||
cleaned = re.sub(pattern, ' ', cleaned, flags=re.IGNORECASE)
|
||
|
||
# Remove source/torrent indicators
|
||
source_patterns = [
|
||
r'\s*[\[\(]album[\]\)]\s*',
|
||
r'\s*[\[\(]itunes[\]\)]\s*',
|
||
r'\s*[\[\(]spotify[\]\)]\s*',
|
||
r'\s*[\[\(]cd[\]\)]\s*',
|
||
r'\s*[\[\(]web[\]\)]\s*',
|
||
r'\s*[\[\(]f\d+[\]\)]\s*', # [F10], [F24], etc.
|
||
r'\s*[\[\(]bonus\s*tracks?[\]\)]\s*',
|
||
r'\s*[\[\(]remaster(ed)?[\]\)]\s*',
|
||
r'\s*[\[\(]clean[\]\)]\s*',
|
||
r'\s*[\[\(]explicit[\]\)]\s*',
|
||
r'\s*[\[\(]album\+itunes\+bonus\s*tracks?[\]\)]\s*', # [Album+iTunes+Bonus Tracks]
|
||
r'\s*[\[\(]itunes\+bonus[\]\)]\s*',
|
||
r'\s*[\[\(]bonus[\]\)]\s*'
|
||
]
|
||
for pattern in source_patterns:
|
||
cleaned = re.sub(pattern, ' ', cleaned, flags=re.IGNORECASE)
|
||
|
||
# Remove edition indicators but preserve meaningful ones like "Deluxe Edition"
|
||
# Only remove if they're standalone or with obvious non-meaningful text
|
||
cleaned = re.sub(r'\s*[\[\(](deluxe\s*edition|special\s*edition|expanded\s*edition)[\]\)]\s*', r' (\1)', cleaned, flags=re.IGNORECASE)
|
||
|
||
# Clean up multiple spaces, leading/trailing spaces and dashes
|
||
cleaned = re.sub(r'\s+', ' ', cleaned)
|
||
cleaned = re.sub(r'^[-\s]+', '', cleaned)
|
||
cleaned = re.sub(r'[-\s]+$', '', cleaned)
|
||
cleaned = cleaned.strip()
|
||
|
||
# If everything was removed, try a more conservative approach
|
||
if not cleaned or len(cleaned) < 3:
|
||
# Extract just the main album name before any brackets
|
||
fallback = re.split(r'[\[\(]', album_title)[0].strip()
|
||
# Remove artist prefix from fallback
|
||
fallback = re.sub(f'^{re.escape(artist_name)}\\s*-\\s*', '', fallback, flags=re.IGNORECASE)
|
||
cleaned = fallback.strip() if fallback.strip() else album_title
|
||
print(f"🧹 Album Title used fallback: '{fallback}'")
|
||
|
||
print(f"🧹 Album Title Result: '{original}' -> '{cleaned}'")
|
||
return cleaned
|
||
|
||
def _clean_track_title(self, track_title: str, artist_name: str) -> str:
|
||
"""Clean up track title by removing artist prefix and other unwanted elements"""
|
||
import re
|
||
|
||
# Start with the original title
|
||
original = track_title.strip()
|
||
cleaned = original
|
||
print(f"🧹 Track Title Cleaning: '{original}' (artist: '{artist_name}')")
|
||
|
||
# Remove track numbers from the beginning if present
|
||
# Handles cases like "01 - Track Name", "1. Track Name", "01. Track Name"
|
||
cleaned = re.sub(r'^\d{1,2}[\.\s\-]+', '', cleaned)
|
||
|
||
# Remove artist name prefix if it appears at the beginning
|
||
# This handles cases like "Kendrick Lamar - Track Name"
|
||
artist_pattern = re.escape(artist_name) + r'\s*-\s*'
|
||
cleaned = re.sub(f'^{artist_pattern}', '', cleaned, flags=re.IGNORECASE)
|
||
|
||
# Remove album name prefix if it appears (e.g., "GNX - 01 - wacced out murals")
|
||
# Look for pattern: "WORD - NUMBER - actual_title"
|
||
cleaned = re.sub(r'^[A-Za-z0-9\.]+\s*-\s*\d{1,2}\s*-\s*', '', cleaned)
|
||
|
||
# Remove common file quality indicators from track titles
|
||
quality_patterns = [
|
||
r'\s*[\[\(][0-9]+\s*kbps[\]\)]\s*',
|
||
r'\s*[\[\(]flac[\]\)]\s*',
|
||
r'\s*[\[\(]mp3[\]\)]\s*',
|
||
r'\s*[\[\(]320[\]\)]\s*'
|
||
]
|
||
for pattern in quality_patterns:
|
||
cleaned = re.sub(pattern, '', cleaned, flags=re.IGNORECASE)
|
||
|
||
# Clean up any remaining extra spaces, dashes, or dots at the start/end
|
||
cleaned = re.sub(r'^[-\s\.]+', '', cleaned)
|
||
cleaned = re.sub(r'[-\s\.]+$', '', cleaned)
|
||
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
|
||
|
||
# If everything was removed, try more conservative approach
|
||
if not cleaned or len(cleaned) < 2:
|
||
# Remove just the most obvious prefixes
|
||
fallback = track_title.strip()
|
||
fallback = re.sub(r'^\d{1,2}[\.\s\-]+', '', fallback) # Remove track numbers
|
||
fallback = re.sub(f'^{re.escape(artist_name)}\\s*-\\s*', '', fallback, flags=re.IGNORECASE)
|
||
cleaned = fallback.strip() if fallback.strip() else track_title
|
||
print(f"🧹 Track Title used fallback: '{fallback}'")
|
||
|
||
print(f"🧹 Track Title Result: '{original}' -> '{cleaned}'")
|
||
return cleaned
|
||
|
||
def _resolve_album_group(self, download_item, artist: Artist, album_info: dict) -> str:
|
||
"""
|
||
Smart album grouping: Start with standard, upgrade to deluxe if ANY track is deluxe.
|
||
This ensures all tracks from the same album get the same folder name.
|
||
"""
|
||
try:
|
||
with self.album_cache_lock:
|
||
# Get the original album name from the download item (if it has one)
|
||
original_album = getattr(download_item, 'album', None)
|
||
detected_album = album_info.get('album_name', '')
|
||
|
||
# Extract base album name (without edition indicators)
|
||
if album_info.get('spotify_track'):
|
||
# Use Spotify album name for base
|
||
base_album = self._get_base_album_name(detected_album)
|
||
elif original_album:
|
||
# Clean the original Soulseek album name
|
||
cleaned_original = self._clean_album_title(original_album, artist.name)
|
||
base_album = self._get_base_album_name(cleaned_original)
|
||
else:
|
||
base_album = self._get_base_album_name(detected_album)
|
||
|
||
# Normalize the base name (handle case variations, etc.)
|
||
base_album = self._normalize_base_album_name(base_album, artist.name)
|
||
|
||
# Create a key for this album group (artist + base album)
|
||
album_key = f"{artist.name}::{base_album}"
|
||
|
||
# Check if we already have a cached result for this album
|
||
if album_key in self.album_name_cache:
|
||
cached_name = self.album_name_cache[album_key]
|
||
print(f"🔍 Using cached album name for '{album_key}': '{cached_name}'")
|
||
return cached_name
|
||
|
||
print(f"🔍 Album grouping - Key: '{album_key}', Detected: '{detected_album}'")
|
||
|
||
# Check if this track indicates a deluxe edition
|
||
is_deluxe_track = False
|
||
if album_info.get('spotify_track'):
|
||
is_deluxe_track = self._detect_deluxe_edition(detected_album)
|
||
elif original_album:
|
||
is_deluxe_track = self._detect_deluxe_edition(original_album)
|
||
|
||
# Get current edition level for this album group (default to standard)
|
||
current_edition = self.album_editions.get(album_key, "standard")
|
||
|
||
# SMART ALGORITHM: Upgrade to deluxe if this track is deluxe
|
||
if is_deluxe_track and current_edition == "standard":
|
||
print(f"🎯 UPGRADE: Album '{base_album}' upgraded from standard to deluxe!")
|
||
self.album_editions[album_key] = "deluxe"
|
||
current_edition = "deluxe"
|
||
|
||
# Build final album name based on edition level
|
||
if current_edition == "deluxe":
|
||
final_album_name = f"{base_album} (Deluxe Edition)"
|
||
else:
|
||
final_album_name = base_album
|
||
|
||
# Store the resolution in both caches
|
||
self.album_groups[album_key] = final_album_name
|
||
self.album_name_cache[album_key] = final_album_name
|
||
self.album_artists[album_key] = artist.name
|
||
|
||
print(f"🔗 Album resolution: '{detected_album}' -> '{final_album_name}' (edition: {current_edition})")
|
||
|
||
return final_album_name
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error resolving album group: {e}")
|
||
return album_info.get('album_name', download_item.title)
|
||
|
||
def _normalize_base_album_name(self, base_album: str, artist_name: str) -> str:
|
||
"""
|
||
Normalize the base album name to handle case variations and known corrections.
|
||
"""
|
||
import re
|
||
|
||
# Apply known album corrections for consistent naming
|
||
normalized_lower = base_album.lower().strip()
|
||
|
||
known_corrections = {
|
||
'good kid maad city': 'good kid, m.A.A.d city',
|
||
'good kid m.a.a.d city': 'good kid, m.A.A.d city',
|
||
'good kid m.a.a.d. city': 'good kid, m.A.A.d city',
|
||
'good kid m a a d city': 'good kid, m.A.A.d city',
|
||
'good kid m.a.a.d city': 'good kid, m.A.A.d city'
|
||
}
|
||
|
||
for key, correction in known_corrections.items():
|
||
if key == normalized_lower:
|
||
print(f"📝 Base album correction: '{base_album}' -> '{correction}'")
|
||
return correction
|
||
|
||
# If no specific correction, return cleaned version
|
||
return base_album.strip()
|
||
|
||
def _normalize_spotify_album_variants(self, album_name: str, artist_name: str) -> str:
|
||
"""
|
||
Normalize different Spotify album variants to a consistent name.
|
||
E.g., 'good kid, m.A.A.d city' and 'good kid, m.A.A.d city (Deluxe)'
|
||
should both resolve to 'good kid, m.A.A.d city (Deluxe Edition)'
|
||
"""
|
||
import re
|
||
|
||
normalized = album_name.strip()
|
||
|
||
# Convert various deluxe indicators to standard format
|
||
deluxe_patterns = [
|
||
(r'\s*\(deluxe\)\s*$', ' (Deluxe Edition)'),
|
||
(r'\s*\[deluxe\]\s*$', ' (Deluxe Edition)'),
|
||
(r'\s*deluxe\s*$', ' (Deluxe Edition)'),
|
||
(r'\s*\(deluxe\s+edition\)\s*$', ' (Deluxe Edition)'),
|
||
(r'\s*\[deluxe\s+edition\]\s*$', ' (Deluxe Edition)')
|
||
]
|
||
|
||
for pattern, replacement in deluxe_patterns:
|
||
if re.search(pattern, normalized, re.IGNORECASE):
|
||
normalized = re.sub(pattern, replacement, normalized, flags=re.IGNORECASE)
|
||
break
|
||
|
||
# Normalize case inconsistencies for known albums
|
||
# This handles cases like "good kid, m.A.A.d city" vs "Good Kid M.A.A.D City"
|
||
known_album_corrections = {
|
||
'good kid maad city': 'good kid, m.A.A.d city',
|
||
'good kid m.a.a.d city': 'good kid, m.A.A.d city',
|
||
'good kid m.a.a.d. city': 'good kid, m.A.A.d city',
|
||
'good kid m.a.a.d city': 'good kid, m.A.A.d city',
|
||
'good kid m a a d city': 'good kid, m.A.A.d city'
|
||
}
|
||
|
||
normalized_lower = normalized.lower()
|
||
for key, correction in known_album_corrections.items():
|
||
if key in normalized_lower:
|
||
# Preserve any edition suffix
|
||
suffix = ''
|
||
if '(deluxe edition)' in normalized_lower:
|
||
suffix = ' (Deluxe Edition)'
|
||
elif '(deluxe)' in normalized_lower:
|
||
suffix = ' (Deluxe Edition)'
|
||
|
||
normalized = correction + suffix
|
||
break
|
||
|
||
print(f"📀 Album variant normalization: '{album_name}' -> '{normalized}'")
|
||
return normalized
|
||
|
||
def _detect_deluxe_edition(self, album_name: str) -> bool:
|
||
"""
|
||
Detect if an album name indicates a deluxe/special edition.
|
||
Returns True if it's a deluxe variant, False for standard.
|
||
"""
|
||
if not album_name:
|
||
return False
|
||
|
||
album_lower = album_name.lower()
|
||
|
||
# Check for deluxe indicators
|
||
deluxe_indicators = [
|
||
'deluxe',
|
||
'deluxe edition',
|
||
'special edition',
|
||
'expanded edition',
|
||
'extended edition',
|
||
'bonus',
|
||
'remastered',
|
||
'anniversary',
|
||
'collectors edition',
|
||
'limited edition'
|
||
]
|
||
|
||
for indicator in deluxe_indicators:
|
||
if indicator in album_lower:
|
||
print(f"🎯 Detected deluxe edition: '{album_name}' contains '{indicator}'")
|
||
return True
|
||
|
||
return False
|
||
|
||
def _get_base_album_name(self, album_name: str) -> str:
|
||
"""
|
||
Extract the base album name without edition indicators.
|
||
E.g., 'good kid, m.A.A.d city (Deluxe Edition)' -> 'good kid, m.A.A.d city'
|
||
"""
|
||
import re
|
||
|
||
# Remove common edition suffixes
|
||
base_name = album_name
|
||
|
||
# Remove edition indicators in parentheses or brackets
|
||
base_name = re.sub(r'\s*[\[\(](deluxe|special|expanded|extended|bonus|remastered|anniversary|collectors?|limited).*?[\]\)]\s*$', '', base_name, flags=re.IGNORECASE)
|
||
|
||
# Remove standalone edition words at the end
|
||
base_name = re.sub(r'\s+(deluxe|special|expanded|extended|bonus|remastered|anniversary|collectors?|limited)\s*(edition)?\s*$', '', base_name, flags=re.IGNORECASE)
|
||
|
||
return base_name.strip()
|
||
|
||
def _detect_album_info(self, download_item, artist: Artist) -> Optional[dict]:
|
||
"""Detect if track is part of an album using Spotify API as primary source"""
|
||
try:
|
||
print(f"🔍 Album detection for '{download_item.title}' by '{artist.name}':")
|
||
print(f" Has album attr: {hasattr(download_item, 'album')}")
|
||
if hasattr(download_item, 'album'):
|
||
print(f" Album value: '{download_item.album}'")
|
||
|
||
# CHECK FOR FORCED ALBUM MODE FIRST
|
||
if hasattr(download_item, '_force_album_mode') and download_item._force_album_mode:
|
||
print(f"🔒 FORCED ALBUM MODE DETECTED - Using forced album name")
|
||
forced_album = getattr(download_item, '_force_album_name', 'Unknown Album')
|
||
print(f" Forced album: '{forced_album}'")
|
||
|
||
# Try to get album image URL from matched_album if available
|
||
album_image_url = None
|
||
if hasattr(download_item, 'matched_album') and download_item.matched_album:
|
||
album_image_url = download_item.matched_album.image_url
|
||
|
||
return {
|
||
'is_album': True,
|
||
'album_name': forced_album,
|
||
'track_number': getattr(download_item, 'track_number', 1),
|
||
'clean_track_name': download_item.title,
|
||
'confidence': 1.0, # 100% confidence since it's forced
|
||
'source': 'forced_user_selection',
|
||
'album_image_url': album_image_url
|
||
}
|
||
|
||
# PRIORITY 1: Try album-aware search if we have album context
|
||
if hasattr(download_item, 'album') and download_item.album and download_item.album.strip() and download_item.album != "Unknown Album":
|
||
print(f"🎯 ALBUM-AWARE SEARCH: Looking for '{download_item.title}' in album '{download_item.album}'")
|
||
album_result = self._search_track_in_album_context(download_item, artist)
|
||
if album_result:
|
||
print(f"✅ Found track in album context - using album classification")
|
||
return album_result
|
||
else:
|
||
print(f"⚠️ Track not found in album context, falling back to individual search")
|
||
|
||
# PRIORITY 2: Fallback to individual track search for clean metadata
|
||
print(f"🔍 Searching Spotify for individual track info (PRIORITY 2)...")
|
||
|
||
# Clean the track title before searching - remove artist prefix
|
||
clean_title = self._clean_track_title(download_item.title, artist.name)
|
||
print(f"🧹 Cleaned title: '{download_item.title}' -> '{clean_title}'")
|
||
|
||
# Search for the track by artist and cleaned title
|
||
query = f"artist:{artist.name} track:{clean_title}"
|
||
tracks = self.spotify_client.search_tracks(query, limit=5)
|
||
|
||
# Find the best matching track
|
||
best_match = None
|
||
best_confidence = 0
|
||
|
||
if tracks:
|
||
for track in tracks:
|
||
# Calculate confidence based on artist and title similarity
|
||
artist_confidence = self.matching_engine.similarity_score(
|
||
self.matching_engine.normalize_string(artist.name),
|
||
self.matching_engine.normalize_string(track.artists[0])
|
||
)
|
||
title_confidence = self.matching_engine.similarity_score(
|
||
self.matching_engine.normalize_string(clean_title),
|
||
self.matching_engine.normalize_string(track.name)
|
||
)
|
||
|
||
combined_confidence = (artist_confidence * 0.6 + title_confidence * 0.4)
|
||
|
||
if combined_confidence > best_confidence and combined_confidence > 0.6: # Lower threshold for better matches
|
||
best_match = track
|
||
best_confidence = combined_confidence
|
||
|
||
# If we found a good Spotify match, use it for clean metadata
|
||
if best_match and best_confidence > 0.6:
|
||
print(f"✅ Found matching Spotify track: '{best_match.name}' - Album: '{best_match.album}' (confidence: {best_confidence:.2f})")
|
||
|
||
# Get detailed track information using Spotify's track API
|
||
detailed_track = None
|
||
if hasattr(best_match, 'id') and best_match.id:
|
||
print(f"🔍 Getting detailed track info from Spotify API for track ID: {best_match.id}")
|
||
detailed_track = self.spotify_client.get_track_details(best_match.id)
|
||
|
||
# Use detailed track data if available
|
||
if detailed_track:
|
||
print(f"✅ Got detailed track data from Spotify API")
|
||
album_name = self._clean_album_title(detailed_track['album']['name'], artist.name)
|
||
clean_track_name = detailed_track['name'] # Use Spotify's clean track name
|
||
album_type = detailed_track['album'].get('album_type', 'album')
|
||
total_tracks = detailed_track['album'].get('total_tracks', 1)
|
||
spotify_track_number = detailed_track.get('track_number', 1)
|
||
|
||
print(f"📀 Spotify album info: '{album_name}' (type: {album_type}, total_tracks: {total_tracks}, track#: {spotify_track_number})")
|
||
print(f"🎵 Clean track name from Spotify: '{clean_track_name}'")
|
||
|
||
# Enhanced album detection using detailed API data
|
||
is_album = (
|
||
# Album type is 'album' (not 'single')
|
||
album_type == 'album' and
|
||
# Album has multiple tracks
|
||
total_tracks > 1 and
|
||
# Album name different from track name
|
||
self.matching_engine.normalize_string(album_name) != self.matching_engine.normalize_string(clean_track_name) and
|
||
# Album name is not just the artist name
|
||
self.matching_engine.normalize_string(album_name) != self.matching_engine.normalize_string(artist.name)
|
||
)
|
||
|
||
track_num = spotify_track_number
|
||
print(f"🎯 Using Spotify track number: {track_num}")
|
||
|
||
# Store the clean Spotify track name for use in file organization (only if not already set)
|
||
if not hasattr(download_item, '_spotify_clean_title') or not download_item._spotify_clean_title:
|
||
download_item._spotify_clean_title = clean_track_name
|
||
download_item._spotify_clean_album = album_name
|
||
|
||
# Extract album image URL from detailed track data
|
||
album_image_url = None
|
||
if detailed_track.get('album', {}).get('images'):
|
||
# Get the largest image (first one in Spotify's array)
|
||
album_image_url = detailed_track['album']['images'][0]['url']
|
||
|
||
if is_album:
|
||
print(f"🎯 Spotify detection: Album track - '{album_name}'")
|
||
return {
|
||
'is_album': True,
|
||
'album_name': album_name,
|
||
'track_number': track_num,
|
||
'spotify_track': best_match,
|
||
'clean_track_name': clean_track_name,
|
||
'album_image_url': album_image_url
|
||
}
|
||
else:
|
||
print(f"🎯 Spotify detection: Single track - using clean track name")
|
||
return {
|
||
'is_album': False,
|
||
'album_name': clean_track_name, # Use clean track name for single structure
|
||
'track_number': 1,
|
||
'spotify_track': best_match,
|
||
'clean_track_name': clean_track_name,
|
||
'album_image_url': album_image_url
|
||
}
|
||
|
||
else:
|
||
print(f"⚠️ Could not get detailed track data, using basic Spotify search data")
|
||
album_name = self._clean_album_title(best_match.album, artist.name)
|
||
clean_track_name = best_match.name
|
||
|
||
# Fallback album detection logic
|
||
is_album = (
|
||
# Album name different from track name (indicates multi-track album)
|
||
self.matching_engine.normalize_string(album_name) != self.matching_engine.normalize_string(clean_track_name) and
|
||
# Album name doesn't contain "single" or similar terms
|
||
not any(term in album_name.lower() for term in ['single', 'ep']) and
|
||
# Album name is not just the artist name
|
||
self.matching_engine.normalize_string(album_name) != self.matching_engine.normalize_string(artist.name)
|
||
)
|
||
|
||
# Get track number from metadata or filename as fallback
|
||
track_num = self._extract_track_number(download_item)
|
||
|
||
# Only set if not already set (preserve original Spotify title from modal)
|
||
if not hasattr(download_item, '_spotify_clean_title') or not download_item._spotify_clean_title:
|
||
download_item._spotify_clean_title = clean_track_name
|
||
download_item._spotify_clean_album = album_name
|
||
|
||
# Try to get album image URL from matched_album if available
|
||
album_image_url = None
|
||
if hasattr(download_item, 'matched_album') and download_item.matched_album:
|
||
album_image_url = download_item.matched_album.image_url
|
||
|
||
return {
|
||
'is_album': is_album,
|
||
'album_name': album_name if is_album else clean_track_name,
|
||
'track_number': track_num if is_album else 1,
|
||
'spotify_track': best_match,
|
||
'clean_track_name': clean_track_name,
|
||
'album_image_url': album_image_url
|
||
}
|
||
|
||
# PRIORITY 3: Fallback to Soulseek album context if Spotify search failed
|
||
print(f"🔍 No good Spotify match found (confidence: {best_confidence:.2f}), checking Soulseek album context...")
|
||
|
||
if hasattr(download_item, 'album') and download_item.album and download_item.album != "Unknown Album":
|
||
clean_album = self._clean_album_title(download_item.album, artist.name)
|
||
clean_title = self._clean_track_title(download_item.title, artist.name)
|
||
track_num = self._extract_track_number(download_item)
|
||
|
||
print(f"✅ Using cleaned Soulseek album context: '{clean_album}' (cleaned from '{download_item.album}')")
|
||
print(f"🧹 Cleaned track title: '{clean_title}' (cleaned from '{download_item.title}')")
|
||
|
||
# Only set if not already set (preserve original Spotify title from modal)
|
||
if not hasattr(download_item, '_spotify_clean_title') or not download_item._spotify_clean_title:
|
||
download_item._spotify_clean_title = clean_title
|
||
download_item._spotify_clean_album = clean_album
|
||
|
||
# Try to get album image URL from matched_album if available
|
||
album_image_url = None
|
||
if hasattr(download_item, 'matched_album') and download_item.matched_album:
|
||
album_image_url = download_item.matched_album.image_url
|
||
|
||
return {
|
||
'is_album': True,
|
||
'album_name': clean_album,
|
||
'track_number': track_num,
|
||
'spotify_track': None,
|
||
'clean_track_name': clean_title,
|
||
'album_image_url': album_image_url
|
||
}
|
||
|
||
# PRIORITY 4: Complete fallback - single track with cleaned title
|
||
print(f"🎯 No album context found, defaulting to single track structure with cleaned title")
|
||
clean_title = self._clean_track_title(download_item.title, artist.name)
|
||
|
||
# Only set if not already set (preserve original Spotify title from modal)
|
||
if not hasattr(download_item, '_spotify_clean_title') or not download_item._spotify_clean_title:
|
||
download_item._spotify_clean_title = clean_title
|
||
|
||
# Try to get album image URL from matched_album if available
|
||
album_image_url = None
|
||
if hasattr(download_item, 'matched_album') and download_item.matched_album:
|
||
album_image_url = download_item.matched_album.image_url
|
||
|
||
return {
|
||
'is_album': False,
|
||
'album_name': clean_title, # Use clean track name as single name
|
||
'track_number': 1,
|
||
'spotify_track': None,
|
||
'clean_track_name': clean_title,
|
||
'album_image_url': album_image_url
|
||
}
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error detecting album info: {e}")
|
||
# Emergency fallback to single structure with basic cleaning
|
||
clean_title = self._clean_track_title(download_item.title, artist.name)
|
||
|
||
# Try to get album image URL from matched_album if available
|
||
album_image_url = None
|
||
if hasattr(download_item, 'matched_album') and download_item.matched_album:
|
||
album_image_url = download_item.matched_album.image_url
|
||
|
||
return {
|
||
'is_album': False,
|
||
'album_name': clean_title,
|
||
'track_number': 1,
|
||
'spotify_track': None,
|
||
'clean_track_name': clean_title,
|
||
'album_image_url': album_image_url
|
||
}
|
||
|
||
def _search_track_in_album_context(self, download_item, artist: Artist) -> Optional[dict]:
|
||
"""Search for a track within its album context to avoid promotional single confusion"""
|
||
try:
|
||
album_name = download_item.album
|
||
track_title = download_item.title
|
||
|
||
print(f"🎯 Album-aware search: '{track_title}' in album '{album_name}' by '{artist.name}'")
|
||
|
||
# Clean the album name for better search results
|
||
clean_album = self._clean_album_title(album_name, artist.name)
|
||
clean_track = self._clean_track_title(track_title, artist.name)
|
||
|
||
# Search for the specific album first
|
||
album_query = f"album:{clean_album} artist:{artist.name}"
|
||
print(f"🔍 Searching albums: {album_query}")
|
||
albums = self.spotify_client.search_albums(album_query, limit=5)
|
||
|
||
if not albums:
|
||
print(f"❌ No albums found for query: {album_query}")
|
||
return None
|
||
|
||
# Check each album to see if our track is in it
|
||
for album in albums:
|
||
print(f"🎵 Checking album: '{album.name}' ({album.total_tracks} tracks)")
|
||
|
||
# Get tracks from this album
|
||
album_tracks_data = self.spotify_client.get_album_tracks(album.id)
|
||
if not album_tracks_data or 'items' not in album_tracks_data:
|
||
print(f"❌ Could not get tracks for album: {album.name}")
|
||
continue
|
||
|
||
# Check if our track is in this album
|
||
for track_data in album_tracks_data['items']:
|
||
track_name = track_data['name']
|
||
track_number = track_data['track_number']
|
||
|
||
# Calculate similarity between our track and this album track
|
||
similarity = self.matching_engine.similarity_score(
|
||
self.matching_engine.normalize_string(clean_track),
|
||
self.matching_engine.normalize_string(track_name)
|
||
)
|
||
|
||
# Use higher threshold for remix matching to ensure precision
|
||
is_remix = any(word in clean_track.lower() for word in ['remix', 'mix', 'edit', 'version'])
|
||
threshold = 0.9 if is_remix else 0.7 # Much stricter for remixes
|
||
|
||
if similarity > threshold:
|
||
print(f"✅ FOUND: '{track_name}' (track #{track_number}) matches '{clean_track}' (similarity: {similarity:.2f})")
|
||
print(f"🎯 Forcing album classification for track in '{album.name}'")
|
||
|
||
# Return album info - force album classification!
|
||
return {
|
||
'is_album': True, # Always true - we found it in an album!
|
||
'album_name': album.name,
|
||
'track_number': track_number,
|
||
'clean_track_name': clean_track, # Use the ORIGINAL download title, not the database match
|
||
'album_image_url': album.image_url,
|
||
'confidence': similarity,
|
||
'source': 'album_context_search'
|
||
}
|
||
|
||
print(f"❌ Track '{clean_track}' not found in album '{album.name}'")
|
||
|
||
print(f"❌ Track '{clean_track}' not found in any matching albums")
|
||
return None
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error in album-aware search: {e}")
|
||
return None
|
||
|
||
def _download_cover_art(self, artist: Artist, album_info: dict, target_dir: str):
|
||
"""Download cover art for the album, prioritizing album artwork from Spotify"""
|
||
try:
|
||
import requests
|
||
import os
|
||
|
||
cover_path = os.path.join(target_dir, "cover.jpg")
|
||
|
||
# Skip if cover already exists
|
||
if os.path.exists(cover_path):
|
||
print("📷 Cover art already exists")
|
||
return
|
||
|
||
image_url = None
|
||
source_description = ""
|
||
|
||
# Priority 1: Use album artwork from album_info if available
|
||
if album_info.get('album_image_url'):
|
||
image_url = album_info['album_image_url']
|
||
source_description = f"album artwork for '{album_info.get('album_name', 'Unknown Album')}'"
|
||
print(f"📷 Using album artwork from album_info")
|
||
|
||
# Priority 2: Try to get album artwork via Spotify search if we have album name
|
||
elif album_info.get('album_name') and hasattr(self, 'spotify_client'):
|
||
try:
|
||
print(f"📷 Searching Spotify for album artwork: '{album_info['album_name']}' by '{artist.name}'")
|
||
# Search for the specific album
|
||
search_query = f"album:{album_info['album_name']} artist:{artist.name}"
|
||
albums = self.spotify_client.search_albums(search_query, limit=1)
|
||
|
||
if albums and len(albums) > 0:
|
||
album = albums[0]
|
||
if album.image_url:
|
||
image_url = album.image_url
|
||
source_description = f"Spotify album artwork for '{album.name}'"
|
||
print(f"📷 Found album artwork via Spotify search")
|
||
else:
|
||
print(f"📷 Album found but no image available")
|
||
else:
|
||
print(f"📷 No album found in Spotify search")
|
||
except Exception as e:
|
||
print(f"📷 Error searching Spotify for album artwork: {e}")
|
||
|
||
# Priority 3: Fall back to artist image
|
||
if not image_url and artist.image_url:
|
||
image_url = artist.image_url
|
||
source_description = f"artist image for '{artist.name}'"
|
||
print(f"📷 Falling back to artist image")
|
||
|
||
# No image available
|
||
if not image_url:
|
||
print("📷 No cover art available (no album artwork or artist image)")
|
||
return
|
||
|
||
print(f"📷 Downloading {source_description} from: {image_url}")
|
||
response = requests.get(image_url, timeout=10)
|
||
response.raise_for_status()
|
||
|
||
with open(cover_path, 'wb') as f:
|
||
f.write(response.content)
|
||
|
||
print(f"✅ Cover art downloaded: {cover_path}")
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error downloading cover art: {e}")
|
||
|
||
def _create_single_track_album_info(self, download_item, artist: Artist) -> Optional[dict]:
|
||
"""Create album_info dict for single track cover art download"""
|
||
try:
|
||
# Check if we have matched_album from Spotify matching
|
||
if hasattr(download_item, 'matched_album') and download_item.matched_album:
|
||
album = download_item.matched_album
|
||
print(f"📷 Single track has matched album: '{album.name}' with image: {bool(album.image_url)}")
|
||
return {
|
||
'album_name': album.name,
|
||
'album_image_url': album.image_url,
|
||
'is_album': False, # This is still a single track
|
||
'track_number': 1,
|
||
'clean_track_name': download_item.title
|
||
}
|
||
|
||
# If no matched_album, try to detect from track title or existing album field
|
||
album_name = None
|
||
if hasattr(download_item, 'album') and download_item.album and download_item.album.strip():
|
||
album_name = download_item.album.strip()
|
||
print(f"📷 Using album name from download_item: '{album_name}'")
|
||
else:
|
||
print(f"📷 No album information available for single track")
|
||
return None
|
||
|
||
# Create basic album_info for cover art search
|
||
return {
|
||
'album_name': album_name,
|
||
'album_image_url': None, # Will be searched in _download_cover_art
|
||
'is_album': False, # This is still a single track
|
||
'track_number': 1,
|
||
'clean_track_name': download_item.title
|
||
}
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error creating single track album info: {e}")
|
||
return None
|
||
|
||
def _generate_lrc_file(self, file_path: str, download_item, artist, album_info: dict) -> bool:
|
||
"""
|
||
Generate LRC lyrics file using LRClib API.
|
||
Elegant addition to post-processing - extracts metadata from existing context.
|
||
"""
|
||
try:
|
||
# Initialize lyrics client if not already done
|
||
if not hasattr(self, 'lyrics_client'):
|
||
self.lyrics_client = LyricsClient()
|
||
|
||
if not self.lyrics_client:
|
||
return False
|
||
|
||
# Get track metadata from download_item
|
||
track_name = getattr(download_item, 'title', 'Unknown Track')
|
||
|
||
# Handle artist parameter (can be dict or object)
|
||
if isinstance(artist, dict):
|
||
artist_name = artist.get('name', 'Unknown Artist')
|
||
elif hasattr(artist, 'name'):
|
||
artist_name = artist.name
|
||
else:
|
||
artist_name = str(artist) if artist else 'Unknown Artist'
|
||
|
||
album_name = None
|
||
duration_seconds = None
|
||
|
||
# Get album name if available
|
||
if album_info and album_info.get('is_album'):
|
||
album_name = album_info.get('album_name')
|
||
|
||
# Get duration from download_item if available
|
||
if hasattr(download_item, 'duration') and download_item.duration:
|
||
try:
|
||
duration_seconds = int(download_item.duration)
|
||
except (ValueError, TypeError):
|
||
pass
|
||
|
||
# Generate LRC file using lyrics client
|
||
success = self.lyrics_client.create_lrc_file(
|
||
audio_file_path=file_path,
|
||
track_name=track_name,
|
||
artist_name=artist_name,
|
||
album_name=album_name,
|
||
duration_seconds=duration_seconds
|
||
)
|
||
|
||
if success:
|
||
print(f"🎵 LRC file generated for: {track_name}")
|
||
else:
|
||
print(f"🎵 No lyrics found for: {track_name}")
|
||
|
||
return success
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error generating LRC file for {file_path}: {e}")
|
||
return False
|
||
|
||
def _extract_track_number(self, download_item, spotify_track=None) -> int:
|
||
"""Extract track number from various sources"""
|
||
try:
|
||
print(f"🔢 Extracting track number for: '{download_item.title}'")
|
||
|
||
# Method 1: Check if download_item has track_number attribute (explicit metadata)
|
||
if hasattr(download_item, 'track_number') and download_item.track_number:
|
||
track_num = int(download_item.track_number)
|
||
print(f" ✅ Found track_number attribute: {track_num}")
|
||
return track_num
|
||
|
||
# Method 2: Parse from filename (e.g., "01. Track Name.mp3", "01 - Track Name.flac")
|
||
if hasattr(download_item, 'title'):
|
||
import re
|
||
# Look for patterns like "01. ", "01 ", "01-", "1. ", etc.
|
||
patterns = [
|
||
r'^(\d{1,2})[\.\s\-]+', # "01. " or "01 " or "01-"
|
||
r'(\d{1,2})\s*[\.\-]\s*', # "01." or "01-" with optional spaces
|
||
]
|
||
|
||
for pattern in patterns:
|
||
match = re.match(pattern, download_item.title.strip())
|
||
if match:
|
||
track_num = int(match.group(1))
|
||
print(f" ✅ Parsed from title pattern '{pattern}': {track_num}")
|
||
return track_num
|
||
|
||
# Method 3: Parse from filename if available
|
||
if hasattr(download_item, 'filename'):
|
||
import re
|
||
import os
|
||
# Get just the filename without extension and path
|
||
base_name = os.path.splitext(os.path.basename(download_item.filename))[0]
|
||
|
||
patterns = [
|
||
r'^(\d{1,2})[\.\s\-]+', # "01. " or "01 " or "01-"
|
||
r'(\d{1,2})\s*[\.\-]\s*', # "01." or "01-" with optional spaces
|
||
]
|
||
|
||
for pattern in patterns:
|
||
match = re.match(pattern, base_name.strip())
|
||
if match:
|
||
track_num = int(match.group(1))
|
||
print(f" ✅ Parsed from filename pattern '{pattern}': {track_num}")
|
||
return track_num
|
||
|
||
# Method 4: Get from Spotify track data (would need album API call)
|
||
if spotify_track:
|
||
# This would require additional Spotify API call to get full album
|
||
# For now, we'll skip this but could be enhanced later
|
||
print(f" ⏭️ Spotify track data available but not implemented yet")
|
||
pass
|
||
|
||
# Default to 1 if no track number found
|
||
print(f" ⚠️ No track number found, defaulting to 1")
|
||
return 1
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error extracting track number: {e}")
|
||
return 1
|
||
|
||
def _find_downloaded_file(self, original_file_path: str, download_item) -> Optional[str]:
|
||
"""Try to find the downloaded file using various methods"""
|
||
try:
|
||
import os
|
||
import glob
|
||
from pathlib import Path
|
||
|
||
print(f"🔍 Searching for downloaded file...")
|
||
print(f" Original path: {original_file_path}")
|
||
|
||
# Get the download directory
|
||
download_dir = self.soulseek_client.download_path if self.soulseek_client else './downloads'
|
||
print(f" Download directory: {download_dir}")
|
||
|
||
# Normalize path separators (convert Windows \\ to /)
|
||
normalized_path = original_file_path.replace('\\', '/')
|
||
|
||
# Extract filename from the API path
|
||
api_filename = os.path.basename(normalized_path)
|
||
print(f" Looking for filename: {api_filename}")
|
||
|
||
# Method 1: Try the exact path first (but normalized)
|
||
if os.path.exists(original_file_path):
|
||
print(f" ✅ Found exact path: {original_file_path}")
|
||
return original_file_path
|
||
|
||
# Method 2: Search in the download directory recursively
|
||
search_patterns = [
|
||
os.path.join(download_dir, "**", api_filename),
|
||
os.path.join(download_dir, "**", f"*{download_item.title}*"),
|
||
os.path.join(download_dir, "**", f"*{download_item.artist}*")
|
||
]
|
||
|
||
for pattern in search_patterns:
|
||
print(f" Searching pattern: {pattern}")
|
||
matches = glob.glob(pattern, recursive=True)
|
||
if matches:
|
||
# Filter for audio files
|
||
audio_extensions = {'.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a'}
|
||
audio_matches = [f for f in matches if os.path.splitext(f)[1].lower() in audio_extensions]
|
||
|
||
if audio_matches:
|
||
# Return the first match that exists and has reasonable size
|
||
for match in audio_matches:
|
||
if os.path.exists(match) and os.path.getsize(match) > 1024: # At least 1KB
|
||
print(f" ✅ Found match: {match}")
|
||
return match
|
||
|
||
# Method 3: Look for recently modified files in download directory
|
||
print(f" Searching for recent files in download directory...")
|
||
recent_files = []
|
||
audio_extensions = {'.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a'}
|
||
|
||
# Debug: List all files in download directory
|
||
print(f" 📂 Files in download directory:")
|
||
for root, dirs, files in os.walk(download_dir):
|
||
for file in files:
|
||
file_path = os.path.join(root, file)
|
||
rel_path = os.path.relpath(file_path, download_dir)
|
||
file_size = os.path.getsize(file_path) if os.path.exists(file_path) else 0
|
||
print(f" {rel_path} ({file_size} bytes)")
|
||
|
||
if os.path.splitext(file)[1].lower() in audio_extensions:
|
||
# Check if file was modified recently (within last 5 minutes)
|
||
import time
|
||
if time.time() - os.path.getmtime(file_path) < 300: # 5 minutes
|
||
recent_files.append((file_path, os.path.getmtime(file_path)))
|
||
|
||
# Sort by modification time (most recent first)
|
||
recent_files.sort(key=lambda x: x[1], reverse=True)
|
||
|
||
for file_path, _ in recent_files[:5]: # Check top 5 most recent
|
||
print(f" Checking recent file: {file_path}")
|
||
# Simple filename matching
|
||
if (download_item.title.lower() in os.path.basename(file_path).lower() or
|
||
download_item.artist.lower() in os.path.basename(file_path).lower()):
|
||
print(f" ✅ Found recent match: {file_path}")
|
||
return file_path
|
||
|
||
print(f" ❌ No matching files found")
|
||
return None
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error searching for downloaded file: {e}")
|
||
return None
|
||
|
||
def update_album_track_button_states(self, download_item, status):
|
||
"""Update track download button states based on download progress"""
|
||
|
||
# Find the track item that corresponds to this download
|
||
album_items_found = self.findChildren(AlbumResultItem)
|
||
|
||
for album_item in album_items_found:
|
||
|
||
for track_item in album_item.track_items:
|
||
track_title = track_item.track_result.title
|
||
track_artist = track_item.track_result.artist
|
||
|
||
|
||
# Match by track title and artist
|
||
if (track_title == download_item.title and track_artist == download_item.artist):
|
||
|
||
|
||
# Update button state based on download status
|
||
if status == 'downloading':
|
||
track_item.set_download_downloading_state()
|
||
print(f"[DEBUG] Set button to downloading state (📥)")
|
||
elif status in ['completed', 'finished']:
|
||
track_item.set_download_completed_state()
|
||
print(f"[DEBUG] Set button to completed state (✅)")
|
||
elif status in ['queued', 'initializing']:
|
||
track_item.set_download_queued_state()
|
||
print(f"[DEBUG] Set button to queued state (⏳)")
|
||
elif status in ['failed', 'cancelled', 'canceled']:
|
||
track_item.reset_download_state() # Allow retry
|
||
print(f"[DEBUG] 🔓 RESET button to downloadable state (⬇️) - track can now be downloaded again!")
|
||
else:
|
||
print(f"[DEBUG] ⚠️ Unknown status '{status}' - no button update performed")
|
||
|
||
return
|
||
|
||
|
||
def start_stream(self, search_result, result_item=None):
|
||
"""Start streaming a search result using StreamingThread or toggle if same track"""
|
||
try:
|
||
# Check if this is the same track that's currently playing
|
||
current_track_id = getattr(self, 'current_track_id', None)
|
||
new_track_id = f"{search_result.username}:{search_result.filename}"
|
||
|
||
print(f"🎮 start_stream() called for: {search_result.filename}")
|
||
print(f"🎮 Current track ID: {current_track_id}")
|
||
print(f"🎮 New track ID: {new_track_id}")
|
||
print(f"🎮 Currently playing button: {self.currently_playing_button}")
|
||
print(f"🎮 Result item: {result_item}")
|
||
print(f"🎮 Button match: {self.currently_playing_button == result_item}")
|
||
print(f"🎮 Track ID match: {current_track_id == new_track_id}")
|
||
|
||
if current_track_id == new_track_id and self.currently_playing_button == result_item:
|
||
# Same track clicked - toggle playback
|
||
print(f"🔄 Toggling playback for: {search_result.filename}")
|
||
|
||
toggle_result = self.audio_player.toggle_playback()
|
||
print(f"🔄 toggle_playback() returned: {toggle_result}")
|
||
|
||
if toggle_result:
|
||
# Now playing
|
||
result_item.set_playing_state()
|
||
self.track_resumed.emit()
|
||
print("🎵 Song card: Resumed playback")
|
||
else:
|
||
# Now paused
|
||
result_item.set_loading_state() # Use loading as "paused" state
|
||
self.track_paused.emit()
|
||
print("⏸️ Song card: Paused playback")
|
||
|
||
return
|
||
else:
|
||
print(f"🆕 Different track or button - starting new stream")
|
||
|
||
print(f"Starting stream: {search_result.filename} from {search_result.username}")
|
||
|
||
# Different track - stop current and start new
|
||
if self.currently_playing_button:
|
||
self.audio_player.stop_playback()
|
||
try:
|
||
self.currently_playing_button.reset_play_state()
|
||
except RuntimeError:
|
||
# Button was deleted, ignore
|
||
pass
|
||
|
||
# Stop any existing streaming threads AND cancel their downloads
|
||
self._stop_all_streaming_threads()
|
||
self._cancel_current_streaming_download_sync()
|
||
|
||
# Track the new currently playing button and track
|
||
self.currently_playing_button = result_item
|
||
self.current_track_id = new_track_id
|
||
self.current_track_result = search_result
|
||
|
||
# Clear Stream folder before starting new stream (release current file since we're switching)
|
||
self.clear_stream_folder(release_current_file=True)
|
||
|
||
# Check if file is a valid audio type
|
||
audio_extensions = ['.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav']
|
||
filename_lower = search_result.filename.lower()
|
||
|
||
is_audio = any(filename_lower.endswith(ext) for ext in audio_extensions)
|
||
|
||
if is_audio:
|
||
print(f"✓ Streaming audio file: {search_result.filename}")
|
||
print(f" Quality: {search_result.quality}")
|
||
print(f" Size: {search_result.size // (1024*1024)}MB")
|
||
print(f" User: {search_result.username}")
|
||
|
||
# Track current streaming download for potential cancellation
|
||
self.current_streaming_download = {
|
||
'username': search_result.username,
|
||
'filename': search_result.filename,
|
||
'download_id': None # Will be set when download starts
|
||
}
|
||
print(f"🎯 Tracking new streaming download: {search_result.username}:{search_result.filename}")
|
||
|
||
# Create and start streaming thread
|
||
streaming_thread = StreamingThread(self.soulseek_client, search_result)
|
||
streaming_thread.streaming_started.connect(self.on_streaming_started, Qt.ConnectionType.QueuedConnection)
|
||
streaming_thread.streaming_finished.connect(self.on_streaming_finished, Qt.ConnectionType.QueuedConnection)
|
||
streaming_thread.streaming_progress.connect(self.on_streaming_progress, Qt.ConnectionType.QueuedConnection)
|
||
streaming_thread.streaming_queued.connect(self.on_streaming_queued, Qt.ConnectionType.QueuedConnection)
|
||
streaming_thread.streaming_failed.connect(self.on_streaming_failed, Qt.ConnectionType.QueuedConnection)
|
||
streaming_thread.finished.connect(
|
||
functools.partial(self.on_streaming_thread_finished, streaming_thread),
|
||
Qt.ConnectionType.QueuedConnection
|
||
)
|
||
|
||
# Track the streaming thread
|
||
if not hasattr(self, 'streaming_threads'):
|
||
self.streaming_threads = []
|
||
self.streaming_threads.append(streaming_thread)
|
||
|
||
# Start the streaming
|
||
streaming_thread.start()
|
||
|
||
else:
|
||
print(f"✗ Cannot stream non-audio file: {search_result.filename}")
|
||
|
||
except Exception as e:
|
||
print(f"Failed to start stream: {str(e)}")
|
||
|
||
def on_streaming_started(self, message, search_result):
|
||
"""Handle streaming start"""
|
||
print(f"Streaming started: {message}")
|
||
# Set button to loading state while file is being prepared
|
||
if self.currently_playing_button:
|
||
try:
|
||
self.currently_playing_button.set_loading_state()
|
||
except RuntimeError:
|
||
# Button was deleted, ignore
|
||
pass
|
||
|
||
# Emit signal for media player loading animation
|
||
self.track_loading_started.emit(search_result)
|
||
|
||
def on_streaming_finished(self, message, search_result):
|
||
"""Handle streaming completion - start actual audio playback"""
|
||
print(f"Streaming finished: {message}")
|
||
|
||
# Check if this streaming result is for the currently requested track
|
||
# Prevent old downloads from interrupting new songs
|
||
if hasattr(self, 'current_track_result') and self.current_track_result:
|
||
current_track_id = f"{self.current_track_result.username}:{self.current_track_result.filename}"
|
||
finished_track_id = f"{search_result.username}:{search_result.filename}"
|
||
|
||
if current_track_id != finished_track_id:
|
||
print(f"🚫 Ignoring old streaming result for: {search_result.filename}")
|
||
print(f" Current track: {current_track_id}")
|
||
print(f" Finished track: {finished_track_id}")
|
||
return
|
||
|
||
try:
|
||
# Find the stream file in the Stream folder
|
||
project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) # Go up from ui/pages/
|
||
stream_folder = os.path.join(project_root, 'Stream')
|
||
|
||
# Find any audio file in the stream folder (should only be one)
|
||
stream_file = None
|
||
audio_extensions = {'.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a'}
|
||
|
||
for filename in os.listdir(stream_folder):
|
||
file_path = os.path.join(stream_folder, filename)
|
||
if (os.path.isfile(file_path) and
|
||
os.path.splitext(filename)[1].lower() in audio_extensions):
|
||
stream_file = file_path
|
||
break
|
||
|
||
if stream_file and os.path.exists(stream_file):
|
||
# Start audio playback
|
||
success = self.audio_player.play_file(stream_file)
|
||
if success:
|
||
print(f"🎵 Started audio playback: {os.path.basename(stream_file)}")
|
||
# Set button to playing state
|
||
if self.currently_playing_button:
|
||
try:
|
||
self.currently_playing_button.set_playing_state()
|
||
except RuntimeError:
|
||
# Button was deleted, ignore
|
||
pass
|
||
# Emit track started signal for sidebar media player
|
||
if hasattr(self, 'current_track_result') and self.current_track_result:
|
||
self.track_loading_finished.emit(self.current_track_result)
|
||
self.track_started.emit(self.current_track_result)
|
||
else:
|
||
print(f"❌ Failed to start audio playback")
|
||
# Reset button on failure
|
||
if self.currently_playing_button:
|
||
try:
|
||
self.currently_playing_button.reset_play_state()
|
||
except RuntimeError:
|
||
# Button was deleted, ignore
|
||
pass
|
||
self.currently_playing_button = None
|
||
else:
|
||
print(f"❌ Stream file not found in {stream_folder}")
|
||
# Reset button on failure
|
||
if self.currently_playing_button:
|
||
try:
|
||
self.currently_playing_button.reset_play_state()
|
||
except RuntimeError:
|
||
# Button was deleted, ignore
|
||
pass
|
||
self.currently_playing_button = None
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error starting audio playback: {e}")
|
||
# Reset button on error
|
||
if self.currently_playing_button:
|
||
try:
|
||
self.currently_playing_button.reset_play_state()
|
||
except RuntimeError:
|
||
# Button was deleted, ignore
|
||
pass
|
||
self.currently_playing_button = None
|
||
|
||
def on_streaming_progress(self, progress_percent, search_result):
|
||
"""Handle streaming progress updates"""
|
||
print(f"Streaming progress: {progress_percent:.1f}% for {search_result.filename}")
|
||
|
||
# Check if this progress is for the currently requested track
|
||
if hasattr(self, 'current_track_result') and self.current_track_result:
|
||
current_track_id = f"{self.current_track_result.username}:{self.current_track_result.filename}"
|
||
progress_track_id = f"{search_result.username}:{search_result.filename}"
|
||
|
||
if current_track_id == progress_track_id:
|
||
# Emit progress signal for media player
|
||
self.track_loading_progress.emit(progress_percent, search_result)
|
||
else:
|
||
print(f"🚫 Ignoring progress for old streaming result: {search_result.filename}")
|
||
|
||
def on_streaming_queued(self, queue_msg, search_result):
|
||
"""Handle streaming queue state updates"""
|
||
print(f"Queue status: {queue_msg} for {search_result.filename}")
|
||
|
||
# Check if this queue status is for the currently requested track
|
||
if hasattr(self, 'current_track_result') and self.current_track_result:
|
||
current_track_id = f"{self.current_track_result.username}:{self.current_track_result.filename}"
|
||
queued_track_id = f"{search_result.username}:{search_result.filename}"
|
||
|
||
if current_track_id == queued_track_id:
|
||
# Show queue status in button
|
||
if self.currently_playing_button:
|
||
try:
|
||
self.currently_playing_button.set_queue_state()
|
||
except RuntimeError:
|
||
# Button was deleted, ignore
|
||
pass
|
||
print(f"📋 Showing queue status for current track")
|
||
else:
|
||
print(f"🚫 Ignoring queue status for old streaming result: {search_result.filename}")
|
||
|
||
def on_streaming_failed(self, error_msg, search_result):
|
||
"""Handle streaming failure"""
|
||
print(f"Streaming failed: {error_msg}")
|
||
# Reset any play button that might be waiting
|
||
if self.currently_playing_button:
|
||
try:
|
||
self.currently_playing_button.reset_play_state()
|
||
except RuntimeError:
|
||
# Button was deleted, ignore
|
||
pass
|
||
self.currently_playing_button = None
|
||
|
||
def _stop_all_streaming_threads(self):
|
||
"""Stop all active streaming threads to prevent old downloads from interrupting new streams"""
|
||
if hasattr(self, 'streaming_threads'):
|
||
print(f"🛑 Stopping {len(self.streaming_threads)} active streaming threads")
|
||
|
||
for thread in self.streaming_threads[:]: # Use slice copy to avoid modification during iteration
|
||
try:
|
||
if thread.isRunning():
|
||
print(f"🛑 Stopping streaming thread for: {getattr(thread.search_result, 'filename', 'unknown')}")
|
||
thread.stop() # Request stop
|
||
|
||
# Give thread more time to stop gracefully (3 seconds)
|
||
if not thread.wait(3000): # Wait up to 3 seconds
|
||
print(f"⚠️ Streaming thread taking longer to stop, giving more time...")
|
||
# Try one more time with longer wait
|
||
if not thread.wait(2000): # Additional 2 seconds
|
||
print(f"⚠️ Force terminating unresponsive streaming thread")
|
||
thread.terminate()
|
||
thread.wait(1000) # Wait for termination
|
||
else:
|
||
print(f"✓ Streaming thread stopped gracefully (delayed)")
|
||
else:
|
||
print(f"✓ Streaming thread stopped gracefully")
|
||
|
||
# Remove from list
|
||
if thread in self.streaming_threads:
|
||
self.streaming_threads.remove(thread)
|
||
|
||
except Exception as e:
|
||
print(f"⚠️ Error stopping streaming thread: {e}")
|
||
|
||
print(f"✓ All streaming threads stopped")
|
||
|
||
async def _cancel_current_streaming_download(self):
|
||
"""Cancel the current streaming download via slskd API to prevent queue clogging"""
|
||
if not hasattr(self, 'current_streaming_download') or not self.current_streaming_download:
|
||
return
|
||
|
||
try:
|
||
username = self.current_streaming_download['username']
|
||
filename = self.current_streaming_download['filename']
|
||
print(f"🚫 Attempting to cancel streaming download: {username}:{os.path.basename(filename)}")
|
||
|
||
# Find the download ID by searching current transfers
|
||
all_transfers = await self.soulseek_client._make_request('GET', 'transfers/downloads')
|
||
download_id = None
|
||
|
||
if all_transfers:
|
||
# Flatten transfer data to find our download
|
||
for user_data in all_transfers:
|
||
if user_data.get('username') == username:
|
||
for directory in user_data.get('directories', []):
|
||
for file_data in directory.get('files', []):
|
||
if os.path.basename(file_data.get('filename', '')) == os.path.basename(filename):
|
||
download_id = file_data.get('id')
|
||
break
|
||
if download_id:
|
||
break
|
||
if download_id:
|
||
break
|
||
|
||
if download_id:
|
||
print(f"🚫 Found streaming download ID: {download_id}")
|
||
# Cancel the download with remove=False (slskd won't allow remove=True for active downloads)
|
||
success = await self.soulseek_client.cancel_download(download_id, username, remove=False)
|
||
if success:
|
||
print(f"✓ Successfully cancelled streaming download: {os.path.basename(filename)}")
|
||
else:
|
||
print(f"⚠️ Failed to cancel streaming download: {os.path.basename(filename)}")
|
||
# Try without remove flag as fallback
|
||
try:
|
||
success = await self.soulseek_client.cancel_download(download_id, username, remove=False)
|
||
if success:
|
||
print(f"✓ Cancelled streaming download with fallback method: {os.path.basename(filename)}")
|
||
except Exception as fallback_e:
|
||
print(f"⚠️ Fallback cancellation also failed: {fallback_e}")
|
||
else:
|
||
print(f"⚠️ Could not find download ID for streaming download: {os.path.basename(filename)}")
|
||
|
||
except Exception as e:
|
||
print(f"⚠️ Error cancelling streaming download: {e}")
|
||
# Continue with graceful fallback - don't let cancellation errors break streaming
|
||
print(f"🔄 Continuing with new stream despite cancellation error")
|
||
finally:
|
||
# Clean up any partial files from the cancelled streaming download
|
||
if hasattr(self, 'current_streaming_download') and self.current_streaming_download:
|
||
await self._cleanup_cancelled_streaming_files(self.current_streaming_download)
|
||
|
||
# Clear tracking regardless of success to prevent stuck state
|
||
self.current_streaming_download = None
|
||
print(f"🧹 Cleared streaming download tracking")
|
||
|
||
# Also clean up any completed streaming downloads to prevent queue clogging
|
||
await self._cleanup_completed_streaming_downloads()
|
||
|
||
async def _cleanup_completed_streaming_downloads(self):
|
||
"""Remove completed streaming downloads from slskd to prevent queue clogging"""
|
||
try:
|
||
print(f"🧹 Cleaning up completed streaming downloads...")
|
||
|
||
# Get current transfers to find completed ones
|
||
all_transfers = await self.soulseek_client._make_request('GET', 'transfers/downloads')
|
||
completed_streaming_downloads = []
|
||
|
||
if all_transfers:
|
||
# Look for completed downloads that might be from streaming
|
||
for user_data in all_transfers:
|
||
username = user_data.get('username', '')
|
||
for directory in user_data.get('directories', []):
|
||
for file_data in directory.get('files', []):
|
||
state = file_data.get('state', '')
|
||
filename = file_data.get('filename', '')
|
||
download_id = file_data.get('id', '')
|
||
|
||
# Check if this is a completed download
|
||
if ('Completed' in state and 'Succeeded' in state) and download_id:
|
||
# Consider audio files as potential streaming downloads
|
||
audio_extensions = {'.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a'}
|
||
file_ext = os.path.splitext(filename)[1].lower()
|
||
|
||
if file_ext in audio_extensions:
|
||
completed_streaming_downloads.append({
|
||
'id': download_id,
|
||
'username': username,
|
||
'filename': filename
|
||
})
|
||
|
||
# Remove completed streaming downloads (limit to prevent excessive cleanup)
|
||
max_cleanup = 5 # Only clean up 5 at a time to be conservative
|
||
for download in completed_streaming_downloads[:max_cleanup]:
|
||
try:
|
||
success = await self.soulseek_client.cancel_download(
|
||
download['id'], download['username'], remove=True
|
||
)
|
||
if success:
|
||
print(f"🧹 Cleaned up completed streaming download: {os.path.basename(download['filename'])}")
|
||
else:
|
||
print(f"⚠️ Failed to clean up: {os.path.basename(download['filename'])}")
|
||
except Exception as e:
|
||
print(f"⚠️ Error cleaning up download {download['id']}: {e}")
|
||
|
||
if completed_streaming_downloads:
|
||
print(f"🧹 Completed streaming download cleanup: {len(completed_streaming_downloads[:max_cleanup])} items removed")
|
||
else:
|
||
print(f"🧹 No completed streaming downloads found to clean up")
|
||
|
||
except Exception as e:
|
||
print(f"⚠️ Error during streaming download cleanup: {e}")
|
||
|
||
async def _cleanup_cancelled_streaming_files(self, download_info):
|
||
"""Clean up partial files from cancelled streaming downloads"""
|
||
try:
|
||
username = download_info.get('username', '')
|
||
filename = download_info.get('filename', '')
|
||
|
||
if not username or not filename:
|
||
return
|
||
|
||
print(f"🧹 Cleaning up cancelled streaming files for: {os.path.basename(filename)}")
|
||
|
||
# Get downloads directory from config
|
||
from config.settings import config_manager
|
||
downloads_config = config_manager.get_downloads_config()
|
||
download_path = downloads_config.get('path', './downloads')
|
||
|
||
# Look for partial/completed files in downloads directory
|
||
filename_base = os.path.splitext(os.path.basename(filename))[0]
|
||
|
||
# Search for files that might match this download
|
||
for root, dirs, files in os.walk(download_path):
|
||
for file in files:
|
||
# Check if this file could be from our cancelled download
|
||
if (filename_base.lower() in file.lower() or
|
||
os.path.basename(filename).lower() == file.lower()):
|
||
|
||
file_path = os.path.join(root, file)
|
||
try:
|
||
print(f"🗑️ Removing cancelled streaming file: {file_path}")
|
||
os.remove(file_path)
|
||
|
||
# Clean up empty directories
|
||
self._cleanup_empty_directories(download_path, file_path)
|
||
|
||
except Exception as e:
|
||
print(f"⚠️ Error removing file {file_path}: {e}")
|
||
|
||
except Exception as e:
|
||
print(f"⚠️ Error cleaning up cancelled streaming files: {e}")
|
||
|
||
def _cancel_current_streaming_download_sync(self):
|
||
"""Synchronous wrapper for cancelling current streaming download"""
|
||
if hasattr(self, 'current_streaming_download') and self.current_streaming_download:
|
||
# Use async event loop to run the cancellation
|
||
import asyncio
|
||
import threading
|
||
|
||
try:
|
||
# Try to get existing event loop first
|
||
try:
|
||
loop = asyncio.get_running_loop()
|
||
# Loop is already running, we need to run in a thread
|
||
def run_in_thread():
|
||
new_loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(new_loop)
|
||
try:
|
||
new_loop.run_until_complete(self._cancel_current_streaming_download())
|
||
finally:
|
||
new_loop.close()
|
||
|
||
thread = threading.Thread(target=run_in_thread)
|
||
thread.start()
|
||
thread.join(timeout=5.0) # Wait max 5 seconds
|
||
return
|
||
except RuntimeError:
|
||
# No event loop in current thread
|
||
pass
|
||
|
||
# Create and use new event loop
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
try:
|
||
loop.run_until_complete(self._cancel_current_streaming_download())
|
||
finally:
|
||
loop.close()
|
||
|
||
except Exception as e:
|
||
print(f"⚠️ Error in sync streaming download cancellation: {e}")
|
||
print(f"🔄 Continuing with new stream despite sync cancellation error")
|
||
# Clear tracking as fallback to prevent stuck state
|
||
self.current_streaming_download = None
|
||
|
||
def on_streaming_thread_finished(self, thread):
|
||
"""Clean up when streaming thread finishes"""
|
||
try:
|
||
if hasattr(self, 'streaming_threads') and thread in self.streaming_threads:
|
||
self.streaming_threads.remove(thread)
|
||
|
||
# Disconnect all signals to prevent stale connections
|
||
try:
|
||
thread.streaming_started.disconnect()
|
||
thread.streaming_finished.disconnect()
|
||
thread.streaming_failed.disconnect()
|
||
thread.finished.disconnect()
|
||
except Exception:
|
||
pass # Ignore if signals are already disconnected
|
||
|
||
# Ensure thread is properly stopped before deletion
|
||
if thread.isRunning():
|
||
thread.stop()
|
||
thread.wait(1000) # Wait up to 1 second
|
||
|
||
# Use QTimer.singleShot for delayed cleanup
|
||
QTimer.singleShot(100, thread.deleteLater)
|
||
|
||
except Exception as e:
|
||
print(f"Error cleaning up finished streaming thread: {e}")
|
||
|
||
def on_audio_playback_finished(self):
|
||
"""Handle when audio playback finishes"""
|
||
print("🎵 Audio playback completed")
|
||
# Reset the play button to play state
|
||
if self.currently_playing_button:
|
||
try:
|
||
self.currently_playing_button.reset_play_state()
|
||
except RuntimeError:
|
||
# Button was deleted, ignore
|
||
pass
|
||
self.currently_playing_button = None
|
||
|
||
# Emit track finished signal for sidebar media player
|
||
self.track_finished.emit()
|
||
|
||
# Clear Stream folder when playback finishes (release file since playback is done)
|
||
self.clear_stream_folder(release_current_file=True)
|
||
|
||
# Clear track state
|
||
self.current_track_id = None
|
||
self.current_track_result = None
|
||
|
||
def on_audio_playback_error(self, error_msg):
|
||
"""Handle audio playback errors"""
|
||
print(f"❌ Audio playback error: {error_msg}")
|
||
# Reset the play button to play state
|
||
if self.currently_playing_button:
|
||
try:
|
||
self.currently_playing_button.reset_play_state()
|
||
except RuntimeError:
|
||
# Button was deleted, ignore
|
||
pass
|
||
self.currently_playing_button = None
|
||
|
||
# Emit track stopped signal for sidebar media player
|
||
self.track_stopped.emit()
|
||
|
||
# Clear Stream folder when playback errors (release file since there's an error)
|
||
self.clear_stream_folder(release_current_file=True)
|
||
|
||
# Clear track state
|
||
self.current_track_id = None
|
||
self.current_track_result = None
|
||
|
||
def handle_sidebar_play_pause(self):
|
||
"""Handle play/pause request from sidebar media player"""
|
||
# Use the actual QMediaPlayer state instead of manual flag
|
||
from PyQt6.QtMultimedia import QMediaPlayer
|
||
|
||
current_state = self.audio_player.playbackState()
|
||
print(f"🎮 handle_sidebar_play_pause() - Current state: {current_state}")
|
||
print(f"🎮 handle_sidebar_play_pause() - Current source: {self.audio_player.source().toString()}")
|
||
|
||
if current_state == QMediaPlayer.PlaybackState.PlayingState:
|
||
print("⏸️ Sidebar: Pausing playback")
|
||
self.audio_player.pause()
|
||
# is_playing will be set automatically by _on_playback_state_changed
|
||
if self.currently_playing_button:
|
||
try:
|
||
self.currently_playing_button.set_loading_state() # Use as "paused" state
|
||
except RuntimeError:
|
||
# Button was deleted, ignore
|
||
pass
|
||
self.track_paused.emit()
|
||
print("⏸️ Paused from sidebar")
|
||
else:
|
||
print("▶️ Sidebar: Attempting to resume/play")
|
||
self.audio_player.play()
|
||
# is_playing will be set automatically by _on_playback_state_changed
|
||
if self.currently_playing_button:
|
||
try:
|
||
self.currently_playing_button.set_playing_state()
|
||
except RuntimeError:
|
||
# Button was deleted, ignore
|
||
pass
|
||
self.track_resumed.emit()
|
||
print("🎵 Resumed from sidebar")
|
||
|
||
def handle_sidebar_stop(self):
|
||
"""Handle stop request from sidebar media player"""
|
||
self.audio_player.stop_playback()
|
||
if self.currently_playing_button:
|
||
try:
|
||
self.currently_playing_button.reset_play_state()
|
||
except RuntimeError:
|
||
# Button was deleted, ignore
|
||
pass
|
||
self.currently_playing_button = None
|
||
|
||
# Emit track stopped signal
|
||
self.track_stopped.emit()
|
||
|
||
# Clear Stream folder when stopping (release file since user explicitly stopped)
|
||
self.clear_stream_folder(release_current_file=True)
|
||
|
||
# Clear track state
|
||
self.current_track_id = None
|
||
self.current_track_result = None
|
||
print("⏹️ Stopped from sidebar")
|
||
|
||
def handle_sidebar_volume(self, volume):
|
||
"""Handle volume change from sidebar media player"""
|
||
self.audio_player.audio_output.setVolume(volume)
|
||
print(f"🔊 Volume set to {int(volume * 100)}% from sidebar")
|
||
|
||
def clear_stream_folder(self, release_current_file=True):
|
||
"""Clear all files from the Stream folder to prevent playing wrong files
|
||
|
||
Args:
|
||
release_current_file (bool): Whether to release the current audio file handle.
|
||
Set to False if you want to clear old files but keep current playback.
|
||
"""
|
||
try:
|
||
# Only release file handles if explicitly requested
|
||
if release_current_file and hasattr(self, 'audio_player') and self.audio_player:
|
||
self.audio_player.release_file()
|
||
print("🔓 Released audio player file handle before clearing")
|
||
|
||
project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) # Go up from ui/pages/
|
||
stream_folder = os.path.join(project_root, 'Stream')
|
||
|
||
if os.path.exists(stream_folder):
|
||
for filename in os.listdir(stream_folder):
|
||
file_path = os.path.join(stream_folder, filename)
|
||
if os.path.isfile(file_path):
|
||
try:
|
||
os.remove(file_path)
|
||
print(f"🗑️ Cleared old stream file: {filename}")
|
||
except Exception as e:
|
||
print(f"⚠️ Could not remove stream file {filename}: {e}")
|
||
|
||
except Exception as e:
|
||
print(f"⚠️ Error clearing stream folder: {e}")
|
||
|
||
def on_download_completed(self, message, download_item):
|
||
"""Handle successful download start (NOT completion)"""
|
||
print(f"Download started: {message}")
|
||
|
||
# Extract download ID from message if available
|
||
if "Download started:" in message and download_item:
|
||
# Message format is "Download started: <download_id>"
|
||
download_id_part = message.replace("Download started:", "").strip()
|
||
|
||
# Check if this looks like a UUID (real download ID) vs filename
|
||
import re
|
||
uuid_pattern = r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
||
if re.match(uuid_pattern, download_id_part, re.IGNORECASE):
|
||
download_item.download_id = download_id_part
|
||
print(f"[DEBUG] Stored real download ID: {download_id_part}")
|
||
else:
|
||
print(f"[DEBUG] Using filename as download ID: {download_id_part}")
|
||
download_item.download_id = download_id_part
|
||
|
||
# Set status to downloading, not completed!
|
||
download_item.status = "downloading"
|
||
download_item.progress = 0
|
||
|
||
def on_download_failed(self, error_msg, download_item):
|
||
"""Handle download failure"""
|
||
print(f"Download failed: {error_msg}")
|
||
# Update download item status to failed
|
||
download_item.status = "failed"
|
||
download_item.progress = 0
|
||
|
||
# Emit activity signal for download failure
|
||
self.download_activity.emit("❌", "Download Failed", f"'{download_item.title}' - {error_msg}", "Now")
|
||
|
||
# Error logged to console for debugging
|
||
|
||
def on_download_progress(self, message, download_item):
|
||
"""Handle download progress updates"""
|
||
print(f"Download progress: {message}")
|
||
# Extract progress percentage if available from message
|
||
# For now just show as downloading
|
||
download_item.status = "downloading"
|
||
|
||
def on_download_thread_finished(self, thread):
|
||
"""Clean up when download thread finishes"""
|
||
try:
|
||
if thread in self.download_threads:
|
||
self.download_threads.remove(thread)
|
||
|
||
# Disconnect all signals to prevent stale connections
|
||
try:
|
||
thread.download_completed.disconnect()
|
||
thread.download_failed.disconnect()
|
||
thread.download_progress.disconnect()
|
||
thread.finished.disconnect()
|
||
except Exception:
|
||
pass # Ignore if signals are already disconnected
|
||
|
||
# Ensure thread is properly stopped before deletion
|
||
if thread.isRunning():
|
||
thread.stop()
|
||
thread.wait(1000) # Wait up to 1 second
|
||
|
||
# Use QTimer.singleShot for delayed cleanup to ensure signal processing is complete
|
||
QTimer.singleShot(100, thread.deleteLater)
|
||
|
||
except Exception as e:
|
||
print(f"Error cleaning up finished download thread: {e}")
|
||
|
||
def _run_async_operation(self, async_func, *args, success_callback=None, error_callback=None):
|
||
"""Helper method to run async operations safely with proper event loop management"""
|
||
import asyncio
|
||
import threading
|
||
|
||
def run_operation():
|
||
"""Run the async operation in a separate thread with its own event loop"""
|
||
try:
|
||
# Create a fresh event loop for this thread
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
|
||
# Run the async operation
|
||
result = loop.run_until_complete(async_func(*args))
|
||
|
||
# Schedule success callback on main thread if provided
|
||
if success_callback:
|
||
QTimer.singleShot(0, lambda: success_callback(result))
|
||
|
||
return result
|
||
|
||
except Exception as e:
|
||
print(f"[ERROR] Exception in async operation: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
# Schedule error callback on main thread if provided
|
||
if error_callback:
|
||
# Capture the error in a closure to avoid lambda variable issues
|
||
def call_error_callback(error=e):
|
||
error_callback(error)
|
||
QTimer.singleShot(0, call_error_callback)
|
||
|
||
return False
|
||
|
||
finally:
|
||
# Always close the loop we created
|
||
try:
|
||
loop.close()
|
||
except Exception as close_e:
|
||
print(f"[WARNING] Error closing event loop: {close_e}")
|
||
|
||
try:
|
||
# Run the operation in a separate thread
|
||
operation_thread = threading.Thread(target=run_operation, daemon=True)
|
||
operation_thread.start()
|
||
|
||
except Exception as e:
|
||
print(f"[ERROR] Exception starting async operation thread: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
def clear_completed_downloads(self):
|
||
"""Clear completed and cancelled downloads from both slskd backend and local queues"""
|
||
print("[DEBUG] DownloadsPage.clear_completed_downloads() method called!")
|
||
print(f"[DEBUG] Current download queue stats:")
|
||
print(f"[DEBUG] - Active queue: {len(self.download_queue.active_queue.download_items)} items")
|
||
print(f"[DEBUG] - Finished queue: {len(self.download_queue.finished_queue.download_items)} items")
|
||
|
||
if not self.soulseek_client:
|
||
print("[ERROR] No soulseek client available for clearing downloads")
|
||
return
|
||
|
||
# Run async clear operation using threading to avoid event loop conflicts
|
||
import asyncio
|
||
import threading
|
||
|
||
# Define UI update callback outside the thread (with proper self reference)
|
||
def update_ui_callback():
|
||
"""UI update callback that runs on main thread"""
|
||
print("[DEBUG] *** UI CALLBACK EXECUTED *** - Starting UI clear operations...")
|
||
try:
|
||
# Step 1: Clear local queues
|
||
print("[DEBUG] Step 1: Calling clear_local_queues_only()...")
|
||
self.download_queue.clear_local_queues_only()
|
||
print("[DEBUG] Step 1 completed successfully")
|
||
|
||
# Step 2: Update download status
|
||
print("[DEBUG] Step 2: Calling update_download_status()...")
|
||
self.update_download_status()
|
||
print("[DEBUG] Step 2 completed successfully")
|
||
|
||
print("[DEBUG] *** UI CALLBACK COMPLETED *** - All UI clear operations finished")
|
||
except Exception as e:
|
||
print(f"[ERROR] Exception in UI callback: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
# Even if there's an error, try to update the display
|
||
try:
|
||
print("[DEBUG] Attempting fallback queue update...")
|
||
if hasattr(self, 'download_queue'):
|
||
self.download_queue.update_tab_counts()
|
||
except Exception as fallback_e:
|
||
print(f"[ERROR] Fallback update also failed: {fallback_e}")
|
||
|
||
def run_clear_operation():
|
||
"""Run the clear operation in a separate thread with its own event loop"""
|
||
success = False
|
||
try:
|
||
# Create a fresh event loop for this thread
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
|
||
print("[DEBUG] 🗑️ Clearing all completed/cancelled downloads from slskd backend...")
|
||
success = loop.run_until_complete(self.soulseek_client.clear_all_completed_downloads())
|
||
|
||
if success:
|
||
print("[DEBUG] ✅ Successfully cleared completed/cancelled downloads from backend")
|
||
else:
|
||
print("[WARNING] ❌ Backend reported failure, but proceeding with UI clearing anyway")
|
||
print("[WARNING] (Web UI may have cleared successfully despite backend failure report)")
|
||
|
||
except Exception as e:
|
||
print(f"[ERROR] Exception during clear completed downloads: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
finally:
|
||
# Always close the loop we created
|
||
try:
|
||
loop.close()
|
||
except Exception as close_e:
|
||
print(f"[WARNING] Error closing event loop: {close_e}")
|
||
|
||
# CRITICAL: Use signal to communicate with main thread (thread-safe)
|
||
print("[DEBUG] Thread completed, emitting completion signal...")
|
||
self.clear_completed_finished.emit(success, update_ui_callback)
|
||
|
||
try:
|
||
# Run the clear operation in a separate thread
|
||
clear_thread = threading.Thread(target=run_clear_operation, daemon=True)
|
||
clear_thread.start()
|
||
|
||
except Exception as e:
|
||
print(f"[ERROR] Exception starting clear completed downloads thread: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
def _handle_clear_completion(self, backend_success, ui_callback):
|
||
"""Handle completion of clear operation on main thread"""
|
||
print(f"[DEBUG] _handle_clear_completion called on main thread - backend_success: {backend_success}")
|
||
|
||
# ALWAYS clear UI regardless of backend success/failure
|
||
# This ensures UI stays in sync even if backend reports false negatives
|
||
print("[DEBUG] Executing UI callback on main thread...")
|
||
try:
|
||
ui_callback()
|
||
except Exception as e:
|
||
print(f"[ERROR] Exception executing UI callback: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
def _handle_api_cleanup_completion(self, success, download_id, username):
|
||
"""Handle completion of API cleanup operation on main thread"""
|
||
if success:
|
||
print(f"✓ Successfully signaled completion for download {download_id}")
|
||
else:
|
||
print(f"⚠️ Failed to signal completion for download {download_id}")
|
||
|
||
def _on_download_completion_finished(self, download_item, organized_path):
|
||
"""Handle successful completion of background download processing"""
|
||
print(f"✅ Background processing completed for '{download_item.title}' -> {organized_path}")
|
||
|
||
# Update the download item status and progress on main thread
|
||
download_item.update_status(
|
||
status='completed',
|
||
progress=100,
|
||
download_speed=0,
|
||
file_path=organized_path
|
||
)
|
||
|
||
# Move completed items to finished queue on main thread
|
||
print(f"[DEBUG] Moving completed download '{download_item.title}' to finished queue")
|
||
self.download_queue.move_to_finished(download_item)
|
||
|
||
def _on_download_completion_error(self, download_item, error_message):
|
||
"""Handle error in background download processing"""
|
||
print(f"❌ Background processing failed for '{download_item.title}': {error_message}")
|
||
|
||
# Still mark as completed but with original path, move to finished queue
|
||
download_item.update_status(
|
||
status='completed',
|
||
progress=100,
|
||
download_speed=0,
|
||
file_path=download_item.file_path # Keep original path on error
|
||
)
|
||
|
||
# Move to finished queue even on error
|
||
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"""
|
||
import time
|
||
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()
|
||
|
||
# Enhanced logging: periodic queue health summary
|
||
if not hasattr(self, '_last_queue_summary_time'):
|
||
self._last_queue_summary_time = 0
|
||
if time.time() - self._last_queue_summary_time > 30: # Every 30 seconds
|
||
self._log_queue_health_summary(download_items)
|
||
self._last_queue_summary_time = time.time()
|
||
|
||
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 _log_queue_health_summary(self, download_items):
|
||
"""Log periodic queue health summary for debugging stuck downloads"""
|
||
import time
|
||
|
||
try:
|
||
if not download_items:
|
||
return
|
||
|
||
# Count downloads by status
|
||
status_counts = {}
|
||
stuck_downloads = []
|
||
|
||
for item in download_items:
|
||
status = item.status.lower()
|
||
status_counts[status] = status_counts.get(status, 0) + 1
|
||
|
||
# Check for potentially stuck downloads
|
||
if hasattr(item, 'queue_start_time') and item.queue_start_time:
|
||
queue_age = time.time() - item.queue_start_time
|
||
if queue_age > 15: # More than 15 seconds in queue
|
||
stuck_downloads.append((item.title, queue_age))
|
||
|
||
# Check for items with high API missing counts
|
||
if hasattr(item, 'api_missing_count_v2') and item.api_missing_count_v2 > 1:
|
||
stuck_downloads.append((item.title, f"API missing {item.api_missing_count_v2} cycles"))
|
||
|
||
# Log summary
|
||
print(f"📊 Queue Health: {status_counts}")
|
||
if stuck_downloads:
|
||
print(f"⚠️ Potentially stuck downloads: {len(stuck_downloads)}")
|
||
for title, issue in stuck_downloads[:3]: # Show first 3
|
||
if isinstance(issue, str):
|
||
print(f" - {title}: {issue}")
|
||
else:
|
||
print(f" - {title}: queued for {issue:.1f}s")
|
||
|
||
except Exception as e:
|
||
pass # Silent logging failures
|
||
|
||
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 and progressive timeout logic"""
|
||
import time
|
||
|
||
state = transfer.get('state', 'Unknown')
|
||
progress = min(100, max(0, transfer.get('percentComplete', 0)))
|
||
|
||
# Initialize queue tracking if needed
|
||
if not hasattr(download_item, 'queue_start_time'):
|
||
download_item.queue_start_time = None
|
||
|
||
# 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'
|
||
|
||
# Track queue state transitions for progressive timeout
|
||
if new_status in ['queued', 'initializing'] and download_item.queue_start_time is None:
|
||
download_item.queue_start_time = time.time()
|
||
print(f"🕐 Download entered queue: {download_item.title}")
|
||
elif new_status in ['downloading', 'completed', 'cancelled', 'failed']:
|
||
if download_item.queue_start_time:
|
||
queue_duration = time.time() - download_item.queue_start_time
|
||
print(f"⏱️ Download '{download_item.title}' was in queue for {queue_duration:.1f}s")
|
||
download_item.queue_start_time = None # Reset queue timer
|
||
|
||
# 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)
|
||
)
|
||
|
||
# Reset API missing count when transfer is found and add enhanced logging
|
||
if hasattr(download_item, 'api_missing_count_v2') and download_item.api_missing_count_v2 > 0:
|
||
print(f"✅ Download reconnected to API after {download_item.api_missing_count_v2} missing cycles: {download_item.title}")
|
||
download_item.api_missing_count_v2 = 0
|
||
|
||
# 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)
|
||
|
||
# Emit specific activity signal for failures
|
||
if new_status == 'failed':
|
||
self.download_activity.emit("❌", "Download Failed", f"'{download_item.title}' by {download_item.artist}", "Now")
|
||
elif new_status == 'cancelled':
|
||
self.download_activity.emit("⏹️", "Download Cancelled", f"'{download_item.title}' by {download_item.artist}", "Now")
|
||
|
||
# Cleanup API if needed
|
||
if new_status in ['cancelled', 'failed'] and hasattr(download_item, 'download_id'):
|
||
self._schedule_api_cleanup_v2(download_item)
|
||
|
||
# Schedule fallback cleanup check for errored downloads
|
||
if new_status == 'failed':
|
||
self._schedule_fallback_cleanup_check()
|
||
|
||
def _handle_missing_transfer_v2(self, download_item):
|
||
"""Handle downloads missing from API with improved grace period and queue age tracking"""
|
||
import time
|
||
|
||
# Initialize tracking attributes
|
||
if not hasattr(download_item, 'api_missing_count_v2'):
|
||
download_item.api_missing_count_v2 = 0
|
||
if not hasattr(download_item, 'queue_start_time'):
|
||
download_item.queue_start_time = None
|
||
|
||
# Track when downloads first enter queued state
|
||
current_status = download_item.status.lower()
|
||
if current_status in ['queued', 'initializing'] and download_item.queue_start_time is None:
|
||
download_item.queue_start_time = time.time()
|
||
print(f"🕐 Started tracking queue time for: {download_item.title}")
|
||
elif current_status not in ['queued', 'initializing']:
|
||
download_item.queue_start_time = None # Reset if no longer queued
|
||
|
||
download_item.api_missing_count_v2 += 1
|
||
|
||
# Enhanced timeout logic with queue age consideration
|
||
queue_timeout_exceeded = False
|
||
if download_item.queue_start_time:
|
||
queue_age = time.time() - download_item.queue_start_time
|
||
queue_timeout = 180.0 # 3 minutes max in queue
|
||
if queue_age > queue_timeout:
|
||
queue_timeout_exceeded = True
|
||
print(f"⏰ Queue timeout exceeded: {download_item.title} stuck in queue for {queue_age:.1f}s")
|
||
|
||
# Fail download if API missing for 3 cycles OR queue timeout exceeded
|
||
if download_item.api_missing_count_v2 >= 3 or queue_timeout_exceeded:
|
||
if queue_timeout_exceeded:
|
||
print(f"⚠️ Download failed due to queue timeout: {download_item.title}")
|
||
else:
|
||
print(f"⚠️ Download missing from API for {download_item.api_missing_count_v2} cycles: {download_item.title}")
|
||
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 with robust retry mechanism"""
|
||
def cleanup_task_with_retry():
|
||
import time
|
||
import asyncio
|
||
|
||
max_retries = 3
|
||
retry_delays = [2, 5, 10] # Progressive delays in seconds
|
||
|
||
for attempt in range(max_retries):
|
||
try:
|
||
if not (hasattr(download_item, 'download_id') and download_item.soulseek_client):
|
||
return
|
||
|
||
# Wait before cleanup to let API stabilize
|
||
if attempt == 0:
|
||
time.sleep(1) # Initial delay for API stabilization
|
||
else:
|
||
time.sleep(retry_delays[attempt - 1])
|
||
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
|
||
try:
|
||
success = loop.run_until_complete(
|
||
download_item.soulseek_client.cancel_download(
|
||
download_item.download_id,
|
||
getattr(download_item, 'username', ''),
|
||
remove=True
|
||
)
|
||
)
|
||
|
||
if success:
|
||
return # Success, exit retry loop
|
||
else:
|
||
if attempt == max_retries - 1:
|
||
print(f"❌ API cleanup failed after {max_retries} attempts: {download_item.title}")
|
||
|
||
finally:
|
||
loop.close()
|
||
|
||
except Exception as e:
|
||
if attempt == max_retries - 1:
|
||
print(f"❌ API cleanup failed after {max_retries} attempts: {e}")
|
||
# Continue to next retry
|
||
|
||
self._optimized_api_pool.submit(cleanup_task_with_retry)
|
||
|
||
def _schedule_fallback_cleanup_check(self):
|
||
"""Schedule a fallback cleanup check for persistent errored entries"""
|
||
def fallback_cleanup_task():
|
||
import time
|
||
import asyncio
|
||
|
||
# Wait longer before fallback cleanup
|
||
time.sleep(30) # 30 second delay
|
||
|
||
try:
|
||
if not self.soulseek_client:
|
||
return
|
||
|
||
# Get current transfers to find persistent errored entries
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
|
||
try:
|
||
transfers_data = loop.run_until_complete(
|
||
self.soulseek_client.get_all_downloads()
|
||
)
|
||
|
||
if transfers_data:
|
||
# Find errored entries that should have been cleaned up
|
||
persistent_errors = []
|
||
for user_data in transfers_data:
|
||
if 'directories' in user_data:
|
||
for directory in user_data['directories']:
|
||
if 'files' in directory:
|
||
for transfer in directory['files']:
|
||
state = transfer.get('state', '')
|
||
if any(error_state in state for error_state in ['Failed', 'Errored', 'Cancelled']):
|
||
persistent_errors.append(transfer)
|
||
|
||
# Clean up persistent errors (max 5 at a time to be conservative)
|
||
for transfer in persistent_errors[:5]:
|
||
try:
|
||
success = loop.run_until_complete(
|
||
self.soulseek_client.cancel_download(
|
||
transfer.get('id'),
|
||
transfer.get('username', ''),
|
||
remove=True
|
||
)
|
||
)
|
||
if success:
|
||
print(f"✅ Fallback cleanup successful for persistent error")
|
||
except Exception as e:
|
||
pass # Silent fallback failures
|
||
|
||
finally:
|
||
loop.close()
|
||
|
||
except Exception as e:
|
||
pass # Silent fallback failures
|
||
|
||
# Schedule fallback cleanup
|
||
self._optimized_api_pool.submit(fallback_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
|
||
|
||
# In class DownloadsPage, REPLACE your old update_download_status method with this one.
|
||
|
||
def update_download_status(self):
|
||
"""
|
||
Starts the background worker to process download statuses without blocking the UI.
|
||
This method is called by the QTimer.
|
||
"""
|
||
if self._is_status_update_running or not self.soulseek_client:
|
||
return # Don't start a new worker if one is already processing.
|
||
|
||
# Check if there are any active downloads to process
|
||
active_items = [item for item in self.download_queue.active_queue.download_items]
|
||
if not active_items:
|
||
self._is_status_update_running = False
|
||
return
|
||
|
||
self._is_status_update_running = True
|
||
|
||
# Create and start the background worker
|
||
worker = StatusProcessingWorker(
|
||
soulseek_client=self.soulseek_client,
|
||
download_items=active_items
|
||
)
|
||
|
||
# Connect signals from the worker to the main thread handler
|
||
worker.signals.completed.connect(self._handle_processed_status_updates)
|
||
worker.signals.error.connect(lambda e: print(f"Status Worker Error: {e}"))
|
||
|
||
# The worker will automatically be cleaned up by the thread pool.
|
||
self.status_processing_pool.start(worker)
|
||
|
||
|
||
def _periodic_cleanup_check(self):
|
||
"""Check for completed downloads and clean them up intelligently"""
|
||
if not self.soulseek_client:
|
||
return
|
||
|
||
try:
|
||
# Clean up bulk downloads found in previous tick
|
||
if self.downloads_to_cleanup:
|
||
print(f"[CLEANUP] Bulk cleaning {len(self.downloads_to_cleanup)} completed downloads from backend")
|
||
self._cleanup_backend_downloads(self.downloads_to_cleanup)
|
||
self.downloads_to_cleanup.clear()
|
||
|
||
# Clean up individual downloads found in previous tick (errored ones)
|
||
if self.individual_downloads_to_cleanup:
|
||
print(f"[CLEANUP] Individually cleaning {len(self.individual_downloads_to_cleanup)} errored downloads from backend")
|
||
self._cleanup_individual_downloads(self.individual_downloads_to_cleanup)
|
||
self.individual_downloads_to_cleanup.clear()
|
||
|
||
# Find new completed downloads for next tick
|
||
self._find_completed_downloads_for_cleanup()
|
||
|
||
except Exception as e:
|
||
print(f"[ERROR] Error in periodic cleanup: {e}")
|
||
|
||
def _find_completed_downloads_for_cleanup(self):
|
||
"""Find downloads that need cleanup in the next tick"""
|
||
try:
|
||
# Get current downloads from backend
|
||
async def check_backend_downloads():
|
||
try:
|
||
result = await self.soulseek_client.get_all_downloads()
|
||
if result:
|
||
bulk_cleanup_states = {'Completed, Succeeded', 'Completed, Cancelled', 'Cancelled', 'Canceled'}
|
||
individual_cleanup_states = {'Completed, Errored', 'Failed', 'Errored'}
|
||
|
||
new_bulk_cleanup = set()
|
||
new_individual_cleanup = []
|
||
|
||
for download in result:
|
||
if download.state in bulk_cleanup_states:
|
||
# These can be cleared with bulk clear operation
|
||
download_key = f"{download.username}:{download.id}"
|
||
new_bulk_cleanup.add(download_key)
|
||
elif download.state in individual_cleanup_states:
|
||
# These need individual removal calls
|
||
download_info = {
|
||
'username': download.username,
|
||
'id': download.id,
|
||
'state': download.state
|
||
}
|
||
new_individual_cleanup.append(download_info)
|
||
|
||
if new_bulk_cleanup or new_individual_cleanup:
|
||
print(f"[CLEANUP] Found {len(new_bulk_cleanup)} bulk + {len(new_individual_cleanup)} individual downloads needing cleanup")
|
||
self.downloads_to_cleanup.update(new_bulk_cleanup)
|
||
if new_individual_cleanup:
|
||
# Store individual cleanup items separately
|
||
if not hasattr(self, 'individual_downloads_to_cleanup'):
|
||
self.individual_downloads_to_cleanup = []
|
||
self.individual_downloads_to_cleanup.extend(new_individual_cleanup)
|
||
except Exception as e:
|
||
print(f"[ERROR] Error checking backend downloads: {e}")
|
||
|
||
# Run in background to avoid blocking UI
|
||
def run_check():
|
||
import asyncio
|
||
try:
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
loop.run_until_complete(check_backend_downloads())
|
||
finally:
|
||
try:
|
||
loop.close()
|
||
except Exception:
|
||
pass
|
||
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||
executor.submit(run_check)
|
||
|
||
except Exception as e:
|
||
print(f"[ERROR] Error finding downloads for cleanup: {e}")
|
||
|
||
def _cleanup_backend_downloads(self, download_keys):
|
||
"""Clean up specific downloads from backend"""
|
||
try:
|
||
def do_cleanup():
|
||
try:
|
||
# Use the existing clear all completed downloads method
|
||
# This is simpler and more reliable than individual cleanup
|
||
success = self.soulseek_client.clear_all_completed_downloads()
|
||
if success:
|
||
print(f"[CLEANUP] Successfully cleared completed downloads from backend")
|
||
else:
|
||
print(f"[CLEANUP] Failed to clear completed downloads from backend")
|
||
except Exception as e:
|
||
print(f"[ERROR] Error during backend cleanup: {e}")
|
||
|
||
# Run cleanup in background
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||
executor.submit(do_cleanup)
|
||
|
||
except Exception as e:
|
||
print(f"[ERROR] Error in cleanup backend downloads: {e}")
|
||
|
||
def _cleanup_individual_downloads(self, download_infos):
|
||
"""Clean up specific errored downloads individually using cancel_download with remove=True"""
|
||
try:
|
||
def do_individual_cleanup():
|
||
try:
|
||
import asyncio
|
||
|
||
async def cleanup_downloads():
|
||
success_count = 0
|
||
for download_info in download_infos:
|
||
try:
|
||
username = download_info['username']
|
||
download_id = download_info['id']
|
||
state = download_info['state']
|
||
|
||
print(f"[CLEANUP] Removing {state} download: {username}/{download_id}")
|
||
success = await self.soulseek_client.cancel_download(
|
||
download_id=download_id,
|
||
username=username,
|
||
remove=True
|
||
)
|
||
|
||
if success:
|
||
success_count += 1
|
||
print(f"[CLEANUP] ✅ Successfully removed {state} download: {username}/{download_id}")
|
||
else:
|
||
print(f"[CLEANUP] ❌ Failed to remove {state} download: {username}/{download_id}")
|
||
|
||
except Exception as e:
|
||
print(f"[ERROR] Error removing individual download {download_info}: {e}")
|
||
|
||
print(f"[CLEANUP] Individual cleanup completed: {success_count}/{len(download_infos)} removed")
|
||
|
||
# Run the async cleanup
|
||
asyncio.run(cleanup_downloads())
|
||
|
||
except Exception as e:
|
||
print(f"[ERROR] Error during individual cleanup: {e}")
|
||
|
||
# Run cleanup in background
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||
executor.submit(do_individual_cleanup)
|
||
|
||
except Exception as e:
|
||
print(f"[ERROR] Error in cleanup individual downloads: {e}")
|
||
|
||
|
||
def cleanup_all_threads(self):
|
||
"""Stop and cleanup all active threads"""
|
||
try:
|
||
# Stop download status timer first
|
||
if hasattr(self, 'download_status_timer'):
|
||
self.download_status_timer.stop()
|
||
|
||
# Stop search thread
|
||
if self.search_thread and self.search_thread.isRunning():
|
||
self.search_thread.stop()
|
||
self.search_thread.wait(2000) # Wait up to 2 seconds
|
||
if self.search_thread.isRunning():
|
||
self.search_thread.terminate()
|
||
self.search_thread.wait(1000)
|
||
self.search_thread.deleteLater()
|
||
self.search_thread = None
|
||
|
||
# Stop explore thread
|
||
if self.explore_thread and self.explore_thread.isRunning():
|
||
self.explore_thread.stop()
|
||
self.explore_thread.wait(2000) # Wait up to 2 seconds
|
||
if self.explore_thread.isRunning():
|
||
self.explore_thread.terminate()
|
||
self.explore_thread.wait(1000)
|
||
self.explore_thread.deleteLater()
|
||
self.explore_thread = None
|
||
|
||
# Stop session thread
|
||
if self.session_thread and self.session_thread.isRunning():
|
||
self.session_thread.stop()
|
||
self.session_thread.wait(2000) # Wait up to 2 seconds
|
||
if self.session_thread.isRunning():
|
||
self.session_thread.terminate()
|
||
self.session_thread.wait(1000)
|
||
self.session_thread.deleteLater()
|
||
self.session_thread = None
|
||
|
||
# CRITICAL FIX: Stop all status update threads
|
||
for status_thread in self.status_update_threads[:]: # Copy list to avoid modification during iteration
|
||
try:
|
||
# Disconnect signals first
|
||
try:
|
||
status_thread.status_updated.disconnect()
|
||
status_thread.finished.disconnect()
|
||
except Exception:
|
||
pass # Ignore if signals are already disconnected
|
||
|
||
if status_thread.isRunning():
|
||
status_thread.stop()
|
||
status_thread.wait(2000) # Wait up to 2 seconds
|
||
if status_thread.isRunning():
|
||
status_thread.terminate()
|
||
status_thread.wait(1000)
|
||
status_thread.deleteLater()
|
||
except Exception as e:
|
||
print(f"Error cleaning up status update thread: {e}")
|
||
|
||
self.status_update_threads.clear()
|
||
|
||
# Stop all download threads with proper cleanup
|
||
for download_thread in self.download_threads[:]: # Copy list to avoid modification during iteration
|
||
try:
|
||
# Disconnect signals first
|
||
try:
|
||
download_thread.download_completed.disconnect()
|
||
download_thread.download_failed.disconnect()
|
||
download_thread.download_progress.disconnect()
|
||
download_thread.finished.disconnect()
|
||
except Exception:
|
||
pass # Ignore if signals are already disconnected
|
||
|
||
if download_thread.isRunning():
|
||
download_thread.stop()
|
||
download_thread.wait(2000) # Wait up to 2 seconds
|
||
if download_thread.isRunning():
|
||
download_thread.terminate()
|
||
download_thread.wait(1000)
|
||
download_thread.deleteLater()
|
||
except Exception as e:
|
||
print(f"Error cleaning up download thread: {e}")
|
||
|
||
self.download_threads.clear()
|
||
|
||
# Stop all API cleanup threads
|
||
for cleanup_thread in self.api_cleanup_threads[:]: # Copy list to avoid modification during iteration
|
||
try:
|
||
# Disconnect signals first
|
||
try:
|
||
cleanup_thread.cleanup_completed.disconnect()
|
||
cleanup_thread.finished.disconnect()
|
||
except Exception:
|
||
pass # Ignore if signals are already disconnected
|
||
|
||
if cleanup_thread.isRunning():
|
||
cleanup_thread.wait(2000) # Wait up to 2 seconds for completion
|
||
if cleanup_thread.isRunning():
|
||
cleanup_thread.terminate()
|
||
cleanup_thread.wait(1000)
|
||
cleanup_thread.deleteLater()
|
||
except Exception as e:
|
||
print(f"Error cleaning up API cleanup thread: {e}")
|
||
|
||
self.api_cleanup_threads.clear()
|
||
|
||
except Exception as e:
|
||
print(f"Error during thread cleanup: {e}")
|
||
|
||
def closeEvent(self, event):
|
||
"""Handle widget close event"""
|
||
self.cleanup_all_threads()
|
||
super().closeEvent(event)
|
||
|
||
def __del__(self):
|
||
"""Destructor - ensure cleanup happens even if closeEvent isn't called"""
|
||
try:
|
||
self.cleanup_all_threads()
|
||
except:
|
||
pass # Ignore errors during destruction
|
||
|
||
def on_paths_updated(self, key: str, value: str):
|
||
"""Handle settings path updates for immediate effect"""
|
||
# No action needed - paths are fetched dynamically via config_manager.get()
|
||
# This method exists for future extensibility if caching is added later
|
||
pass
|
||
|
||
def create_controls_section(self):
|
||
section = QWidget()
|
||
layout = QVBoxLayout(section)
|
||
layout.setSpacing(20)
|
||
|
||
# Download controls
|
||
controls_frame = QFrame()
|
||
controls_frame.setStyleSheet("""
|
||
QFrame {
|
||
background: #282828;
|
||
border-radius: 8px;
|
||
border: 1px solid #404040;
|
||
}
|
||
""")
|
||
|
||
controls_layout = QVBoxLayout(controls_frame)
|
||
controls_layout.setContentsMargins(20, 20, 20, 20)
|
||
controls_layout.setSpacing(15)
|
||
|
||
# Controls title
|
||
controls_title = QLabel("Download Controls")
|
||
controls_title.setFont(QFont("Arial", 14, QFont.Weight.Bold))
|
||
controls_title.setStyleSheet("color: #ffffff;")
|
||
|
||
# Pause/Resume button
|
||
pause_btn = QPushButton("⏸️ Pause Downloads")
|
||
pause_btn.setFixedHeight(40)
|
||
pause_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: #1db954;
|
||
border: none;
|
||
border-radius: 20px;
|
||
color: #000000;
|
||
font-size: 12px;
|
||
font-weight: bold;
|
||
}
|
||
QPushButton:hover {
|
||
background: #1ed760;
|
||
}
|
||
""")
|
||
|
||
# Clear completed button
|
||
clear_btn = QPushButton("🗑️ Clear Completed")
|
||
clear_btn.setFixedHeight(35)
|
||
clear_btn.clicked.connect(self.clear_completed_downloads) # Connect to the clearing method
|
||
clear_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: transparent;
|
||
border: 1px solid #e22134;
|
||
border-radius: 17px;
|
||
color: #e22134;
|
||
font-size: 11px;
|
||
font-weight: bold;
|
||
}
|
||
QPushButton:hover {
|
||
background: rgba(226, 33, 52, 0.1);
|
||
}
|
||
""")
|
||
|
||
controls_layout.addWidget(controls_title)
|
||
controls_layout.addWidget(pause_btn)
|
||
controls_layout.addWidget(clear_btn)
|
||
|
||
# Download stats
|
||
stats_frame = QFrame()
|
||
stats_frame.setStyleSheet("""
|
||
QFrame {
|
||
background: #282828;
|
||
border-radius: 8px;
|
||
border: 1px solid #404040;
|
||
}
|
||
""")
|
||
|
||
stats_layout = QVBoxLayout(stats_frame)
|
||
stats_layout.setContentsMargins(20, 20, 20, 20)
|
||
stats_layout.setSpacing(15)
|
||
|
||
# Stats title
|
||
stats_title = QLabel("Download Statistics")
|
||
stats_title.setFont(QFont("Arial", 14, QFont.Weight.Bold))
|
||
stats_title.setStyleSheet("color: #ffffff;")
|
||
|
||
# Stats items
|
||
stats_items = [
|
||
("Total Downloads", "247"),
|
||
("Completed", "238"),
|
||
("Failed", "4"),
|
||
("In Progress", "2"),
|
||
("Queued", "3")
|
||
]
|
||
|
||
stats_layout.addWidget(stats_title)
|
||
|
||
for label, value in stats_items:
|
||
item_layout = QHBoxLayout()
|
||
|
||
label_widget = QLabel(label)
|
||
label_widget.setFont(QFont("Arial", 11))
|
||
label_widget.setStyleSheet("color: #b3b3b3;")
|
||
|
||
value_widget = QLabel(value)
|
||
value_widget.setFont(QFont("Arial", 11, QFont.Weight.Bold))
|
||
value_widget.setStyleSheet("color: #ffffff;")
|
||
|
||
item_layout.addWidget(label_widget)
|
||
item_layout.addStretch()
|
||
item_layout.addWidget(value_widget)
|
||
|
||
stats_layout.addLayout(item_layout)
|
||
|
||
layout.addWidget(controls_frame)
|
||
layout.addWidget(stats_frame)
|
||
layout.addStretch()
|
||
|
||
return section
|
||
|
||
def create_missing_tracks_section(self):
|
||
section = QFrame()
|
||
section.setFixedHeight(250)
|
||
section.setStyleSheet("""
|
||
QFrame {
|
||
background: #282828;
|
||
border-radius: 8px;
|
||
border: 1px solid #404040;
|
||
}
|
||
""")
|
||
|
||
layout = QVBoxLayout(section)
|
||
layout.setContentsMargins(20, 20, 20, 20)
|
||
layout.setSpacing(15)
|
||
|
||
# Header
|
||
header_layout = QHBoxLayout()
|
||
|
||
title_label = QLabel("Missing Tracks")
|
||
title_label.setFont(QFont("Arial", 16, QFont.Weight.Bold))
|
||
title_label.setStyleSheet("color: #ffffff;")
|
||
|
||
count_label = QLabel("23 tracks")
|
||
count_label.setFont(QFont("Arial", 11))
|
||
count_label.setStyleSheet("color: #b3b3b3;")
|
||
|
||
download_all_btn = QPushButton("📥 Download All")
|
||
download_all_btn.setFixedSize(150, 35)
|
||
download_all_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: #1db954;
|
||
border: none;
|
||
border-radius: 17px;
|
||
color: #000000;
|
||
font-size: 11px;
|
||
font-weight: bold;
|
||
}
|
||
QPushButton:hover {
|
||
background: #1ed760;
|
||
}
|
||
""")
|
||
|
||
header_layout.addWidget(title_label)
|
||
header_layout.addWidget(count_label)
|
||
header_layout.addStretch()
|
||
header_layout.addWidget(download_all_btn)
|
||
|
||
# Missing tracks scroll area
|
||
missing_scroll = QScrollArea()
|
||
missing_scroll.setWidgetResizable(True)
|
||
missing_scroll.setStyleSheet("""
|
||
QScrollArea {
|
||
border: none;
|
||
background: transparent;
|
||
}
|
||
QScrollBar:vertical {
|
||
background: #404040;
|
||
width: 6px;
|
||
border-radius: 3px;
|
||
}
|
||
QScrollBar::handle:vertical {
|
||
background: #1db954;
|
||
border-radius: 3px;
|
||
}
|
||
""")
|
||
|
||
missing_widget = QWidget()
|
||
missing_layout = QVBoxLayout(missing_widget)
|
||
missing_layout.setSpacing(8)
|
||
missing_layout.setContentsMargins(0, 0, 0, 0)
|
||
|
||
# Sample missing tracks with playlist info
|
||
missing_tracks = [
|
||
("Song Title 1", "Artist Name 1", "Liked Songs"),
|
||
("Another Track", "Different Artist", "Road Trip Mix"),
|
||
("Cool Song", "Band Name", "Workout Playlist"),
|
||
("Missing Hit", "Popular Artist", "Discover Weekly"),
|
||
("Rare Track", "Indie Artist", "Chill Vibes")
|
||
]
|
||
|
||
for track_title, artist, playlist in missing_tracks:
|
||
track_item = self.create_missing_track_item(track_title, artist, playlist)
|
||
missing_layout.addWidget(track_item)
|
||
|
||
missing_layout.addStretch()
|
||
missing_scroll.setWidget(missing_widget)
|
||
|
||
layout.addLayout(header_layout)
|
||
layout.addWidget(missing_scroll)
|
||
|
||
return section
|
||
|
||
def create_missing_track_item(self, track_title: str, artist: str, playlist: str):
|
||
item = QFrame()
|
||
item.setFixedHeight(45)
|
||
item.setStyleSheet("""
|
||
QFrame {
|
||
background: #333333;
|
||
border-radius: 6px;
|
||
border: 1px solid #404040;
|
||
}
|
||
QFrame:hover {
|
||
background: #3a3a3a;
|
||
border: 1px solid #1db954;
|
||
}
|
||
""")
|
||
|
||
layout = QHBoxLayout(item)
|
||
layout.setContentsMargins(12, 8, 12, 8)
|
||
layout.setSpacing(10)
|
||
|
||
# Track info
|
||
info_layout = QVBoxLayout()
|
||
info_layout.setSpacing(2)
|
||
|
||
track_label = QLabel(f"{track_title} - {artist}")
|
||
track_label.setFont(QFont("Arial", 10, QFont.Weight.Medium))
|
||
track_label.setStyleSheet("color: #ffffff;")
|
||
|
||
playlist_label = QLabel(f"from: {playlist}")
|
||
playlist_label.setFont(QFont("Arial", 9))
|
||
playlist_label.setStyleSheet("color: #1db954;")
|
||
|
||
info_layout.addWidget(track_label)
|
||
info_layout.addWidget(playlist_label)
|
||
|
||
# Download button
|
||
download_btn = QPushButton("📥")
|
||
download_btn.setFixedSize(30, 30)
|
||
download_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: rgba(29, 185, 84, 0.2);
|
||
border: 1px solid #1db954;
|
||
border-radius: 15px;
|
||
color: #1db954;
|
||
font-size: 12px;
|
||
}
|
||
QPushButton:hover {
|
||
background: #1db954;
|
||
color: #000000;
|
||
}
|
||
""")
|
||
|
||
layout.addLayout(info_layout)
|
||
layout.addStretch()
|
||
layout.addWidget(download_btn)
|
||
|
||
return item
|
||
|
||
def _extract_track_number_from_filename(self, filename: str, title: str = None) -> Optional[int]:
|
||
"""Extract track number from filename or title"""
|
||
try:
|
||
import re
|
||
import os
|
||
|
||
# Try extracting from title first if available
|
||
if title:
|
||
patterns = [
|
||
r'^(\d{1,2})[\.\s\-_]+', # "01. " or "01 " or "01-" or "01_"
|
||
r'^(\d{1,2})\s*[\.\-_]\s*', # "01." or "01-" or "01_" with optional spaces
|
||
r'^(\d{1,2})\s+', # "01 " (space only)
|
||
r'^(\d{1,2})[\)\]\}]\s*', # "01) " or "01] " or "01} "
|
||
r'^\[(\d{1,2})\]', # "[01]" bracket format
|
||
r'^\((\d{1,2})\)', # "(01)" parenthesis format
|
||
]
|
||
|
||
for pattern in patterns:
|
||
match = re.match(pattern, title.strip())
|
||
if match:
|
||
track_num = int(match.group(1))
|
||
print(f" 🎵 Found track number in title '{title}': {track_num}")
|
||
return track_num
|
||
|
||
# Try extracting from filename
|
||
base_name = os.path.splitext(filename)[0] # Remove extension
|
||
|
||
patterns = [
|
||
r'^(\d{1,2})[\.\s\-_]+', # "01. " or "01 " or "01-" or "01_"
|
||
r'^(\d{1,2})\s*[\.\-_]\s*', # "01." or "01-" or "01_" with optional spaces
|
||
r'^(\d{1,2})\s+', # "01 " (space only)
|
||
r'^(\d{1,2})[\)\]\}]\s*', # "01) " or "01] " or "01} "
|
||
r'^\[(\d{1,2})\]', # "[01]" bracket format
|
||
r'^\((\d{1,2})\)', # "(01)" parenthesis format
|
||
r'^Track\s*(\d{1,2})', # "Track 01" or "Track01"
|
||
r'^T(\d{1,2})', # "T01" format
|
||
]
|
||
|
||
for pattern in patterns:
|
||
match = re.match(pattern, base_name.strip())
|
||
if match:
|
||
track_num = int(match.group(1))
|
||
print(f" 🎵 Found track number in filename '{filename}': {track_num}")
|
||
return track_num
|
||
|
||
print(f" ❌ No track number found in filename: '{filename}'")
|
||
return None
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error extracting track number from filename: {e}")
|
||
return None
|
||
|
||
def _get_spotify_album_tracks(self, selected_album: Album) -> List[dict]:
|
||
"""Fetch all tracks from the selected Spotify album"""
|
||
try:
|
||
print(f"🎵 Fetching tracks from Spotify album: {selected_album.name}")
|
||
tracks_data = self.spotify_client.get_album_tracks(selected_album.id)
|
||
|
||
if tracks_data and 'items' in tracks_data:
|
||
tracks = []
|
||
for track_data in tracks_data['items']:
|
||
tracks.append({
|
||
'name': track_data['name'],
|
||
'track_number': track_data['track_number'],
|
||
'duration_ms': track_data['duration_ms'],
|
||
'id': track_data['id']
|
||
})
|
||
print(f"✅ Found {len(tracks)} tracks in Spotify album")
|
||
return tracks
|
||
else:
|
||
print(f"❌ No tracks found in Spotify album")
|
||
return []
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error fetching Spotify album tracks: {e}")
|
||
return []
|
||
|
||
def _match_track_to_spotify_title(self, track, spotify_tracks: List[dict]) -> Optional[str]:
|
||
"""Match a downloaded track to a Spotify track title using similarity scoring"""
|
||
try:
|
||
if not spotify_tracks:
|
||
return None
|
||
|
||
original_title = track.title
|
||
print(f"🔍 Matching track: '{original_title}'")
|
||
|
||
# Clean the original title by removing track number prefixes
|
||
import re
|
||
cleaned_original = original_title
|
||
track_num_match = re.match(r'^(\d+)\s*[\.\-_]\s*(.+)', cleaned_original.strip())
|
||
if track_num_match:
|
||
cleaned_original = track_num_match.group(2).strip()
|
||
print(f" 🧹 Cleaned title (removed track number): '{cleaned_original}'")
|
||
|
||
best_match = None
|
||
best_score = 0.0
|
||
|
||
# Try matching by track number first (most reliable)
|
||
if hasattr(track, 'track_number') and track.track_number:
|
||
for spotify_track in spotify_tracks:
|
||
if spotify_track['track_number'] == track.track_number:
|
||
print(f"✅ Matched by track number {track.track_number}: '{spotify_track['name']}'")
|
||
return spotify_track['name']
|
||
|
||
# Fallback to title similarity matching using cleaned titles
|
||
for spotify_track in spotify_tracks:
|
||
# Normalize both titles for comparison (use cleaned original)
|
||
normalized_original = self.matching_engine.normalize_string(cleaned_original)
|
||
normalized_spotify = self.matching_engine.normalize_string(spotify_track['name'])
|
||
|
||
print(f" 📊 Comparing: '{normalized_original}' vs '{normalized_spotify}'")
|
||
|
||
# Calculate similarity score
|
||
score = self.matching_engine.similarity_score(normalized_original, normalized_spotify)
|
||
|
||
if score > best_score:
|
||
best_score = score
|
||
best_match = spotify_track
|
||
print(f" ⬆️ New best match ({score:.2f}): '{spotify_track['name']}'")
|
||
|
||
# Only return match if confidence is high enough
|
||
if best_match and best_score >= 0.6: # 60% similarity threshold
|
||
print(f"✅ Matched by title similarity ({best_score:.2f}): '{best_match['name']}'")
|
||
return best_match['name']
|
||
else:
|
||
print(f"❌ No good title match found (best score: {best_score:.2f})")
|
||
return None
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error matching track to Spotify title: {e}")
|
||
return None
|
||
|
||
# In downloads.py, add this new method inside the DownloadsPage class (e.g., after load_more_results)
|
||
|
||
def _load_next_result_item(self):
|
||
"""
|
||
Processes one item from the loading queue and schedules the next.
|
||
This breaks up the work into small, non-blocking chunks.
|
||
"""
|
||
if not self._results_to_load_queue:
|
||
self.is_loading_more = False
|
||
return
|
||
|
||
# Pop one result from the front of the queue
|
||
result = self._results_to_load_queue.pop(0)
|
||
|
||
# Create the appropriate widget for the result
|
||
if isinstance(result, AlbumResult):
|
||
result_item = AlbumResultItem(result)
|
||
result_item.album_download_requested.connect(self.start_album_download)
|
||
result_item.matched_album_download_requested.connect(self.start_matched_album_download)
|
||
result_item.track_download_requested.connect(self.start_download)
|
||
result_item.track_stream_requested.connect(lambda r, i=result_item: self.start_stream(r, i))
|
||
else:
|
||
result_item = SearchResultItem(result)
|
||
result_item.download_requested.connect(self.start_download)
|
||
result_item.stream_requested.connect(lambda r, i=result_item: self.start_stream(r, i))
|
||
result_item.expansion_requested.connect(self.handle_expansion_request)
|
||
|
||
# Insert the newly created widget into the layout
|
||
insert_position = self.search_results_layout.count() - 1
|
||
self.search_results_layout.insertWidget(insert_position, result_item)
|
||
self.displayed_results += 1
|
||
|
||
# If there are more items in the queue, schedule the next one immediately
|
||
if self._results_to_load_queue:
|
||
QTimer.singleShot(0, self._load_next_result_item)
|
||
else:
|
||
# All items for this batch are loaded
|
||
self.is_loading_more = False
|
||
# Update the status message now that loading is complete
|
||
total_filtered = len(self.current_filtered_results)
|
||
if self.displayed_results < total_filtered:
|
||
remaining = total_filtered - self.displayed_results
|
||
self.update_search_status(f"✨ Showing {self.displayed_results} of {total_filtered} results (scroll for {remaining} more)", "#1db954")
|
||
else:
|
||
self.update_search_status(f"✨ Showing all {total_filtered} results", "#1db954")
|
||
|
||
# In class DownloadsPage, add this new method
|
||
|
||
def _handle_processed_status_updates(self, results):
|
||
"""
|
||
This runs on the main thread and applies updates from the background worker.
|
||
It crucially updates download_id and username before moving items to ensure
|
||
API cleanup works correctly.
|
||
"""
|
||
# Create a lookup for active items by their memory ID for fast access
|
||
items_by_id = {id(item): item for item in self.download_queue.active_queue.download_items}
|
||
|
||
items_to_move = []
|
||
|
||
# Batch UI updates to prevent multiple repaints
|
||
self.download_queue.setUpdatesEnabled(False)
|
||
|
||
for result in results:
|
||
download_item = items_by_id.get(result['widget_id'])
|
||
if not download_item:
|
||
continue
|
||
|
||
# **THE CRITICAL FIX IS HERE:**
|
||
# Update the item with the real transfer ID and username from the API.
|
||
# This ensures that when move_to_finished is called, it has the correct data for API cleanup.
|
||
if result.get('transfer_id'):
|
||
download_item.download_id = result['transfer_id']
|
||
if result.get('username'):
|
||
download_item.username = result['username']
|
||
|
||
# Update the item's visual status
|
||
download_item.update_status(
|
||
status=result['status'],
|
||
progress=result['progress'],
|
||
download_speed=result['speed'],
|
||
file_path=result['path']
|
||
)
|
||
|
||
action = result.get('action')
|
||
new_status = result['status']
|
||
|
||
if new_status == 'completed' and action == 'process_matched_completion':
|
||
# This is a matched download that just finished.
|
||
if download_item.mark_completion_processed():
|
||
worker = DownloadCompletionWorker(
|
||
download_item=download_item,
|
||
absolute_file_path=result['path'],
|
||
organize_func=self._organize_matched_download
|
||
)
|
||
worker.signals.completed.connect(self._on_download_completion_finished)
|
||
worker.signals.error.connect(self._on_download_completion_error)
|
||
self.completion_thread_pool.start(worker)
|
||
|
||
elif new_status in ['completed', 'failed', 'cancelled']:
|
||
# For regular completed items or any failed/cancelled item.
|
||
if download_item not in items_to_move:
|
||
items_to_move.append(download_item)
|
||
|
||
# Now, move all the items that need moving in one go
|
||
for item in items_to_move:
|
||
self.download_queue.move_to_finished(item)
|
||
|
||
# Re-enable UI updates and trigger a single repaint
|
||
self.download_queue.setUpdatesEnabled(True)
|
||
self.download_queue.update()
|
||
self.download_queue.update_tab_counts()
|
||
|
||
# Allow the next worker to run
|
||
self._is_status_update_running = False
|
||
|
||
# =====================================
|
||
# METADATA ENHANCEMENT SYSTEM
|
||
# =====================================
|
||
|
||
def _enhance_file_metadata(self, file_path: str, download_item, artist: Artist, album_info: dict) -> bool:
|
||
"""
|
||
Core function to enhance audio file metadata using Spotify data
|
||
|
||
Args:
|
||
file_path: Path to the audio file in Transfer folder
|
||
download_item: Original search result with attached Spotify metadata
|
||
artist: Matched Spotify Artist object
|
||
album_info: Album detection results with track numbering
|
||
|
||
Returns:
|
||
bool: Success/failure of metadata enhancement
|
||
"""
|
||
try:
|
||
# Check if metadata enhancement is enabled
|
||
if not config_manager.get('metadata_enhancement.enabled', True):
|
||
print("🎵 Metadata enhancement disabled in config")
|
||
return True
|
||
|
||
print(f"🎵 Enhancing metadata for: {os.path.basename(file_path)}")
|
||
|
||
# Load the audio file
|
||
audio_file = MutagenFile(file_path)
|
||
if audio_file is None:
|
||
print(f"❌ Could not load audio file with Mutagen: {file_path}")
|
||
return False
|
||
|
||
# Extract comprehensive metadata from Spotify
|
||
metadata = self._extract_spotify_metadata(download_item, artist, album_info)
|
||
if not metadata:
|
||
print(f"⚠️ Could not extract Spotify metadata, preserving original tags")
|
||
return True
|
||
|
||
# Determine file format and apply appropriate tags
|
||
file_format = self._detect_audio_format(file_path, audio_file)
|
||
success = False
|
||
|
||
if file_format == 'mp3':
|
||
print(f"🎵 Applying ID3 tags for {file_path}")
|
||
success = self._apply_id3_tags(audio_file, metadata, file_path)
|
||
elif file_format == 'flac':
|
||
print(f"🎵 Applying FLAC tags for {file_path}")
|
||
success = self._apply_flac_tags(audio_file, metadata, file_path)
|
||
elif file_format in ['mp4', 'm4a']:
|
||
print(f"🎵 Applying MP4 tags for {file_path}")
|
||
success = self._apply_mp4_tags(audio_file, metadata, file_path)
|
||
elif file_format == 'ogg':
|
||
print(f"🎵 Applying OGG tags for {file_path}")
|
||
success = self._apply_ogg_tags(audio_file, metadata, file_path)
|
||
else:
|
||
print(f"⚠️ Unsupported audio format for metadata enhancement: {file_format}")
|
||
return True
|
||
|
||
if success:
|
||
# Optionally embed album art
|
||
if config_manager.get('metadata_enhancement.embed_album_art', True):
|
||
self._embed_album_art_metadata(file_path, audio_file, metadata, file_format)
|
||
|
||
print(f"✅ Metadata enhanced with Spotify data")
|
||
return True
|
||
else:
|
||
print(f"⚠️ Metadata enhancement failed, original tags preserved")
|
||
return False
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error enhancing metadata for {file_path}: {e}")
|
||
return False
|
||
|
||
def _extract_spotify_metadata(self, download_item, artist: Artist, album_info: dict) -> dict:
|
||
"""
|
||
Extract comprehensive metadata from Spotify objects
|
||
|
||
Returns complete metadata dictionary with:
|
||
- Basic tags (title, artist, album, year, track#)
|
||
- Advanced tags (genres, album artist, total tracks)
|
||
- Plex-specific optimizations
|
||
- Album art URL for embedding
|
||
- Spotify IDs for future enhancements
|
||
"""
|
||
try:
|
||
metadata = {}
|
||
|
||
# Debug: Log what we're working with
|
||
print(f"🔍 Extracting metadata for: {download_item.title}")
|
||
print(f" - Artist: {artist.name if artist else 'None'}")
|
||
print(f" - Album info: {album_info}")
|
||
print(f" - Has _spotify_clean_title: {hasattr(download_item, '_spotify_clean_title')}")
|
||
print(f" - Has matched_album: {hasattr(download_item, 'matched_album')}")
|
||
|
||
if not artist:
|
||
print(f"❌ No artist provided for metadata extraction")
|
||
return {}
|
||
|
||
# Basic track information
|
||
metadata['title'] = getattr(download_item, '_spotify_clean_title', download_item.title)
|
||
metadata['artist'] = artist.name
|
||
metadata['album_artist'] = artist.name # Critical for Plex
|
||
|
||
# Album information
|
||
if album_info and album_info.get('is_album'):
|
||
metadata['album'] = album_info.get('album_name', 'Unknown Album')
|
||
metadata['track_number'] = album_info.get('track_number', 1)
|
||
metadata['total_tracks'] = album_info.get('total_tracks', 1)
|
||
if album_info.get('disc_number'):
|
||
metadata['disc_number'] = album_info['disc_number']
|
||
else:
|
||
# Single track
|
||
metadata['album'] = metadata['title'] # For singles, album = title
|
||
metadata['track_number'] = 1
|
||
metadata['total_tracks'] = 1
|
||
|
||
# Release date
|
||
if hasattr(download_item, 'matched_album') and download_item.matched_album:
|
||
if hasattr(download_item.matched_album, 'release_date'):
|
||
metadata['date'] = download_item.matched_album.release_date[:4] if download_item.matched_album.release_date else None
|
||
|
||
# Genre information from artist
|
||
if hasattr(artist, 'genres') and artist.genres:
|
||
# Use first genre or combine multiple
|
||
if len(artist.genres) == 1:
|
||
metadata['genre'] = artist.genres[0]
|
||
else:
|
||
# Combine up to 3 genres
|
||
metadata['genre'] = ', '.join(artist.genres[:3])
|
||
|
||
# Album art URL
|
||
if hasattr(download_item, 'matched_album') and download_item.matched_album:
|
||
if hasattr(download_item.matched_album, 'image_url') and download_item.matched_album.image_url:
|
||
metadata['album_art_url'] = download_item.matched_album.image_url
|
||
|
||
# Spotify IDs for future enhancements
|
||
metadata['spotify_artist_id'] = artist.id if hasattr(artist, 'id') else None
|
||
if hasattr(download_item, 'matched_album') and download_item.matched_album:
|
||
metadata['spotify_album_id'] = getattr(download_item.matched_album, 'id', None)
|
||
|
||
print(f"🎯 Extracted metadata summary:")
|
||
print(f" - Title: {metadata.get('title')}")
|
||
print(f" - Artist: {metadata.get('artist')}")
|
||
print(f" - Album: {metadata.get('album')}")
|
||
print(f" - Track #: {metadata.get('track_number')}")
|
||
print(f" - Date: {metadata.get('date')}")
|
||
print(f" - Genre: {metadata.get('genre')}")
|
||
print(f" - Album art: {'Yes' if metadata.get('album_art_url') else 'No'}")
|
||
print(f" - Total fields: {len(metadata)}")
|
||
|
||
# Special debugging for problematic tracks
|
||
if metadata.get('title') == 'Tell Me What You Want' or metadata.get('track_number') == 13:
|
||
print(f"🔍 [DEBUG] Special track detected - Tell Me What You Want:")
|
||
print(f" - Full metadata dict: {metadata}")
|
||
print(f" - Album info dict: {album_info}")
|
||
print(f" - Artist object: {artist}")
|
||
if hasattr(download_item, 'matched_album'):
|
||
print(f" - matched_album: {download_item.matched_album}")
|
||
else:
|
||
print(f" - No matched_album attribute")
|
||
|
||
return metadata
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error extracting Spotify metadata: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return {}
|
||
|
||
def _detect_audio_format(self, file_path: str, audio_file) -> str:
|
||
"""Detect the audio format for appropriate tag handling"""
|
||
try:
|
||
file_ext = os.path.splitext(file_path)[1].lower()
|
||
|
||
# Direct extension mapping
|
||
format_map = {
|
||
'.mp3': 'mp3',
|
||
'.flac': 'flac',
|
||
'.m4a': 'm4a',
|
||
'.mp4': 'mp4',
|
||
'.ogg': 'ogg',
|
||
'.oga': 'ogg'
|
||
}
|
||
|
||
if file_ext in format_map:
|
||
return format_map[file_ext]
|
||
|
||
# Fallback to mutagen detection
|
||
if hasattr(audio_file, 'mime'):
|
||
mime_type = audio_file.mime[0] if audio_file.mime else ''
|
||
if 'mp3' in mime_type or 'mpeg' in mime_type:
|
||
return 'mp3'
|
||
elif 'flac' in mime_type:
|
||
return 'flac'
|
||
elif 'mp4' in mime_type or 'm4a' in mime_type:
|
||
return 'mp4'
|
||
elif 'ogg' in mime_type:
|
||
return 'ogg'
|
||
|
||
return 'unknown'
|
||
|
||
except Exception as e:
|
||
print(f"⚠️ Could not detect audio format: {e}")
|
||
return 'unknown'
|
||
|
||
def _apply_id3_tags(self, audio_file, metadata: dict, file_path: str) -> bool:
|
||
"""Handle MP3 ID3v2.4 tags with full Unicode support"""
|
||
try:
|
||
# Ensure ID3 tags exist
|
||
if not hasattr(audio_file, 'tags') or audio_file.tags is None:
|
||
audio_file.add_tags()
|
||
|
||
tags = audio_file.tags
|
||
|
||
# Basic tags
|
||
tags.setall('TIT2', [TIT2(encoding=3, text=metadata.get('title', ''))]) # Title
|
||
tags.setall('TPE1', [TPE1(encoding=3, text=metadata.get('artist', ''))]) # Artist
|
||
tags.setall('TPE2', [TPE2(encoding=3, text=metadata.get('album_artist', ''))]) # Album Artist
|
||
tags.setall('TALB', [TALB(encoding=3, text=metadata.get('album', ''))]) # Album
|
||
|
||
# Date
|
||
if metadata.get('date'):
|
||
tags.setall('TDRC', [TDRC(encoding=3, text=metadata['date'])])
|
||
|
||
# Track number
|
||
track_text = f"{metadata.get('track_number', 1)}/{metadata.get('total_tracks', 1)}"
|
||
tags.setall('TRCK', [TRCK(encoding=3, text=track_text)])
|
||
|
||
# Genre
|
||
if metadata.get('genre'):
|
||
tags.setall('TCON', [TCON(encoding=3, text=metadata['genre'])])
|
||
|
||
# Disc number
|
||
if metadata.get('disc_number'):
|
||
tags.setall('TPOS', [TPOS(encoding=3, text=str(metadata['disc_number']))])
|
||
|
||
# Spotify IDs for future reference
|
||
if metadata.get('spotify_artist_id'):
|
||
tags.setall('TXXX:Spotify Artist ID', [TXXX(encoding=3, desc='Spotify Artist ID', text=metadata['spotify_artist_id'])])
|
||
if metadata.get('spotify_album_id'):
|
||
tags.setall('TXXX:Spotify Album ID', [TXXX(encoding=3, desc='Spotify Album ID', text=metadata['spotify_album_id'])])
|
||
|
||
|
||
|
||
audio_file.save()
|
||
return True
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error applying ID3 tags: {e}")
|
||
return False
|
||
|
||
def _apply_flac_tags(self, audio_file, metadata: dict, file_path: str) -> bool:
|
||
"""Handle FLAC Vorbis comments for lossless files"""
|
||
try:
|
||
print(f"🎵 Applying FLAC tags with {len(metadata)} metadata fields")
|
||
|
||
# Read existing tags first to preserve non-empty values
|
||
existing_title = audio_file.get('TITLE', [''])[0] if audio_file.get('TITLE') else ''
|
||
existing_artist = audio_file.get('ARTIST', [''])[0] if audio_file.get('ARTIST') else ''
|
||
existing_album = audio_file.get('ALBUM', [''])[0] if audio_file.get('ALBUM') else ''
|
||
existing_date = audio_file.get('DATE', [''])[0] if audio_file.get('DATE') else ''
|
||
existing_genre = audio_file.get('GENRE', [''])[0] if audio_file.get('GENRE') else ''
|
||
|
||
print(f" - Existing tags: Title='{existing_title}', Artist='{existing_artist}', Album='{existing_album}'")
|
||
|
||
# Only update if we have non-empty Spotify data OR if existing tag is empty
|
||
if metadata.get('title') and metadata.get('title').strip():
|
||
audio_file['TITLE'] = metadata['title']
|
||
print(f" - Updated TITLE: '{existing_title}' → '{metadata['title']}'")
|
||
elif existing_title:
|
||
print(f" - Preserving existing TITLE: '{existing_title}' (Spotify data empty)")
|
||
|
||
if metadata.get('artist') and metadata.get('artist').strip():
|
||
audio_file['ARTIST'] = metadata['artist']
|
||
print(f" - Updated ARTIST: '{existing_artist}' → '{metadata['artist']}'")
|
||
elif existing_artist:
|
||
print(f" - Preserving existing ARTIST: '{existing_artist}' (Spotify data empty)")
|
||
|
||
if metadata.get('album_artist') and metadata.get('album_artist').strip():
|
||
audio_file['ALBUMARTIST'] = metadata['album_artist']
|
||
elif not audio_file.get('ALBUMARTIST'):
|
||
audio_file['ALBUMARTIST'] = metadata.get('album_artist', '')
|
||
|
||
if metadata.get('album') and metadata.get('album').strip():
|
||
audio_file['ALBUM'] = metadata['album']
|
||
print(f" - Updated ALBUM: '{existing_album}' → '{metadata['album']}'")
|
||
elif existing_album:
|
||
print(f" - Preserving existing ALBUM: '{existing_album}' (Spotify data empty)")
|
||
|
||
# Date - only update if we have a date and existing is empty OR we have better data
|
||
if metadata.get('date') and metadata.get('date').strip():
|
||
audio_file['DATE'] = metadata['date']
|
||
print(f" - Updated DATE: '{existing_date}' → '{metadata['date']}'")
|
||
elif existing_date:
|
||
print(f" - Preserving existing DATE: '{existing_date}' (Spotify data empty)")
|
||
|
||
# Track number - always update since this comes from album detection
|
||
audio_file['TRACKNUMBER'] = str(metadata.get('track_number', 1))
|
||
audio_file['TRACKTOTAL'] = str(metadata.get('total_tracks', 1))
|
||
|
||
# Genre - only update if we have genre data and existing is empty OR we have better data
|
||
if metadata.get('genre') and metadata.get('genre').strip():
|
||
audio_file['GENRE'] = metadata['genre']
|
||
print(f" - Updated GENRE: '{existing_genre}' → '{metadata['genre']}'")
|
||
elif existing_genre:
|
||
print(f" - Preserving existing GENRE: '{existing_genre}' (Spotify data empty)")
|
||
|
||
# Disc number
|
||
if metadata.get('disc_number'):
|
||
audio_file['DISCNUMBER'] = str(metadata['disc_number'])
|
||
|
||
# Spotify IDs
|
||
if metadata.get('spotify_artist_id'):
|
||
audio_file['SPOTIFY_ARTIST_ID'] = metadata['spotify_artist_id']
|
||
if metadata.get('spotify_album_id'):
|
||
audio_file['SPOTIFY_ALBUM_ID'] = metadata['spotify_album_id']
|
||
|
||
|
||
print(f" - Saving FLAC file with enhanced metadata...")
|
||
audio_file.save()
|
||
print(f" - ✅ FLAC tags saved successfully")
|
||
return True
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error applying FLAC tags: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return False
|
||
|
||
def _apply_mp4_tags(self, audio_file, metadata: dict, file_path: str) -> bool:
|
||
"""Handle MP4/M4A iTunes-style tags"""
|
||
try:
|
||
# Basic tags
|
||
audio_file['\xa9nam'] = [metadata.get('title', '')] # Title
|
||
audio_file['\xa9ART'] = [metadata.get('artist', '')] # Artist
|
||
audio_file['aART'] = [metadata.get('album_artist', '')] # Album Artist
|
||
audio_file['\xa9alb'] = [metadata.get('album', '')] # Album
|
||
|
||
# Date
|
||
if metadata.get('date'):
|
||
audio_file['\xa9day'] = [metadata['date']]
|
||
|
||
# Track number
|
||
track_num = metadata.get('track_number', 1)
|
||
total_tracks = metadata.get('total_tracks', 1)
|
||
audio_file['trkn'] = [(track_num, total_tracks)]
|
||
|
||
# Genre
|
||
if metadata.get('genre'):
|
||
audio_file['\xa9gen'] = [metadata['genre']]
|
||
|
||
# Disc number
|
||
if metadata.get('disc_number'):
|
||
audio_file['disk'] = [(metadata['disc_number'], 0)]
|
||
|
||
# Spotify IDs (using custom tags)
|
||
if metadata.get('spotify_artist_id'):
|
||
audio_file['----:com.apple.iTunes:Spotify Artist ID'] = [metadata['spotify_artist_id'].encode('utf-8')]
|
||
if metadata.get('spotify_album_id'):
|
||
audio_file['----:com.apple.iTunes:Spotify Album ID'] = [metadata['spotify_album_id'].encode('utf-8')]
|
||
|
||
|
||
|
||
audio_file.save()
|
||
return True
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error applying MP4 tags: {e}")
|
||
return False
|
||
|
||
def _apply_ogg_tags(self, audio_file, metadata: dict, file_path: str) -> bool:
|
||
"""Handle OGG Vorbis comments"""
|
||
try:
|
||
# Basic tags
|
||
audio_file['TITLE'] = metadata.get('title', '')
|
||
audio_file['ARTIST'] = metadata.get('artist', '')
|
||
audio_file['ALBUMARTIST'] = metadata.get('album_artist', '')
|
||
audio_file['ALBUM'] = metadata.get('album', '')
|
||
|
||
# Date
|
||
if metadata.get('date'):
|
||
audio_file['DATE'] = metadata['date']
|
||
|
||
# Track number
|
||
audio_file['TRACKNUMBER'] = str(metadata.get('track_number', 1))
|
||
audio_file['TRACKTOTAL'] = str(metadata.get('total_tracks', 1))
|
||
|
||
# Genre
|
||
if metadata.get('genre'):
|
||
audio_file['GENRE'] = metadata['genre']
|
||
|
||
# Disc number
|
||
if metadata.get('disc_number'):
|
||
audio_file['DISCNUMBER'] = str(metadata['disc_number'])
|
||
|
||
# Spotify IDs
|
||
if metadata.get('spotify_artist_id'):
|
||
audio_file['SPOTIFY_ARTIST_ID'] = metadata['spotify_artist_id']
|
||
if metadata.get('spotify_album_id'):
|
||
audio_file['SPOTIFY_ALBUM_ID'] = metadata['spotify_album_id']
|
||
|
||
audio_file.save()
|
||
return True
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error applying OGG tags: {e}")
|
||
return False
|
||
|
||
def _embed_album_art_metadata(self, file_path: str, audio_file, metadata: dict, file_format: str) -> bool:
|
||
"""Download and embed high-quality Spotify album art"""
|
||
try:
|
||
if not metadata.get('album_art_url'):
|
||
print("🎨 No album art URL available for embedding")
|
||
return True
|
||
|
||
print(f"🎨 Downloading album art for embedding...")
|
||
|
||
# Download album art
|
||
album_art_url = metadata['album_art_url']
|
||
response = urllib.request.urlopen(album_art_url, timeout=10)
|
||
image_data = response.read()
|
||
|
||
if not image_data:
|
||
print("❌ Failed to download album art data")
|
||
return False
|
||
|
||
# Determine image format
|
||
image_format = 'image/jpeg' # Spotify typically uses JPEG
|
||
|
||
# Embed based on format
|
||
if file_format == 'mp3':
|
||
if not hasattr(audio_file, 'tags') or audio_file.tags is None:
|
||
audio_file.add_tags()
|
||
|
||
audio_file.tags.setall('APIC', [APIC(
|
||
encoding=3, # UTF-8
|
||
mime=image_format,
|
||
type=3, # Cover (front)
|
||
desc='Cover',
|
||
data=image_data
|
||
)])
|
||
|
||
elif file_format == 'flac':
|
||
picture = Picture()
|
||
picture.data = image_data
|
||
picture.type = 3 # Cover (front)
|
||
picture.mime = image_format
|
||
picture.width = 640
|
||
picture.height = 640
|
||
picture.depth = 24
|
||
audio_file.add_picture(picture)
|
||
|
||
elif file_format in ['mp4', 'm4a']:
|
||
if image_format == 'image/jpeg':
|
||
audio_file['covr'] = [MP4Cover(image_data, imageformat=MP4Cover.FORMAT_JPEG)]
|
||
else:
|
||
audio_file['covr'] = [MP4Cover(image_data, imageformat=MP4Cover.FORMAT_PNG)]
|
||
|
||
# Save with embedded art
|
||
audio_file.save()
|
||
print(f"🎨 ✅ Album art successfully embedded")
|
||
return True
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error embedding album art: {e}")
|
||
return False
|
||
|
||
def cleanup_resources(self):
|
||
"""Clean up resources when page is destroyed"""
|
||
try:
|
||
# Shutdown thread pools
|
||
if hasattr(self, 'api_thread_pool'):
|
||
self.api_thread_pool.shutdown(wait=False)
|
||
print("🧹 API thread pool shutdown")
|
||
|
||
if hasattr(self, 'completion_thread_pool'):
|
||
self.completion_thread_pool.waitForDone(3000) # Wait up to 3 seconds for completion
|
||
print("🧹 Completion thread pool shutdown")
|
||
except Exception as e:
|
||
print(f"❌ Error during resource cleanup: {e}") |