This commit is contained in:
Broque Thomas 2025-07-10 20:43:17 -07:00
parent b912ae352c
commit 712d492ade
6 changed files with 2783 additions and 83 deletions

View file

@ -1 +1 @@
{"access_token": "BQCAapY2h9Qx4vb4MFeuaJdmgDbZvFn99VDepd7mYu-rccmTk0CSwQY3ONrb3FL3_BJ8jawbiwVjURbr7ARJ3CInbYoXquK94ywQMXiCftLB49DMAmJpGeT-8R1OGtXcvn2WBx-sJriuznVSHjTD_l6odjiSxiIoPpPuopqB6xU8lACeoxwW4bCGkxdRAMyHzRPG9DnbJdyNdSTNp3IHqBR6zMxXMtXIUjWsKPos-6LaEfcC96E47j5h5ucB8VAT", "token_type": "Bearer", "expires_in": 3600, "scope": "user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email", "expires_at": 1752197783, "refresh_token": "AQDmfQkPCGObfJeTUIbW1hAAwhSqkuHRA3Qh2dqVYMRh0eCkFMQgPNJDDzF8y-BiaVbj80zePkK_XSfYH1aJutMtNbnsqRKWuxP31BTrMc7pdUdbE7Fma4oH8wpDUKdG3MM"}
{"access_token": "BQA45hmTHB-qxEFzKWp_uj7aSKM-RT8L1E88T8GauYSMqzRJnx_cdm-fvYsHoeO5nJ62_sRg67Q8wX5MZeABH0mySVhP2f_V_NT6KHPt2nW32j3rcQAmaTxW7kUQwlKNJdbiXSP-p4inH13WunPa2tNSZf0Ym1YfxZgVUYWzqEDYxjMm327TQVM0kIqSoZM7NYCx2_FQnslF2EebhZqA0KzfMpwzSTgYCUVT9-wtD73r1z3WMqgDGrWgF4FBA6no", "token_type": "Bearer", "expires_in": 3600, "scope": "user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email", "expires_at": 1752205183, "refresh_token": "AQDmfQkPCGObfJeTUIbW1hAAwhSqkuHRA3Qh2dqVYMRh0eCkFMQgPNJDDzF8y-BiaVbj80zePkK_XSfYH1aJutMtNbnsqRKWuxP31BTrMc7pdUdbE7Fma4oH8wpDUKdG3MM"}

View file

@ -12,6 +12,7 @@ logger = get_logger("soulseek_client")
@dataclass
class SearchResult:
"""Base class for search results"""
username: str
filename: str
size: int
@ -21,6 +22,7 @@ class SearchResult:
free_upload_slots: int
upload_speed: int
queue_length: int
result_type: str = "track" # "track" or "album"
@property
def quality_score(self) -> float:
@ -53,6 +55,137 @@ class SearchResult:
return min(base_score, 1.0)
@dataclass
class TrackResult(SearchResult):
"""Individual track search result"""
artist: Optional[str] = None
title: Optional[str] = None
album: Optional[str] = None
track_number: Optional[int] = None
def __post_init__(self):
self.result_type = "track"
# Try to extract metadata from filename if not provided
if not self.title or not self.artist:
self._parse_filename_metadata()
def _parse_filename_metadata(self):
"""Extract artist, title, album from filename patterns"""
import re
import os
# Get just the filename without extension and path
base_name = os.path.splitext(os.path.basename(self.filename))[0]
# Common patterns for track naming
patterns = [
r'^(\d+)\s*[-\.]\s*(.+?)\s*[-]\s*(.+)$', # "01 - Artist - Title" or "01. Artist - Title"
r'^(.+?)\s*[-]\s*(.+)$', # "Artist - Title"
r'^(\d+)\s*[-\.]\s*(.+)$', # "01 - Title" or "01. Title"
]
for pattern in patterns:
match = re.match(pattern, base_name)
if match:
groups = match.groups()
if len(groups) == 3: # Track number, artist, title
try:
self.track_number = int(groups[0])
self.artist = self.artist or groups[1].strip()
self.title = self.title or groups[2].strip()
except ValueError:
# First group might not be a number
self.artist = self.artist or groups[0].strip()
self.title = self.title or f"{groups[1]} - {groups[2]}".strip()
elif len(groups) == 2:
if groups[0].isdigit(): # Track number and title
try:
self.track_number = int(groups[0])
self.title = self.title or groups[1].strip()
except ValueError:
pass
else: # Artist and title
self.artist = self.artist or groups[0].strip()
self.title = self.title or groups[1].strip()
break
# Fallback: use filename as title if nothing was extracted
if not self.title:
self.title = base_name
# Try to extract album from directory path
if not self.album and '/' in self.filename:
path_parts = self.filename.split('/')
if len(path_parts) >= 2:
# Look for album-like directory names
for part in reversed(path_parts[:-1]): # Exclude filename
if part and not part.startswith('@'): # Skip system directories
# Clean up common patterns
cleaned = re.sub(r'^\d+\s*[-\.]\s*', '', part) # Remove leading numbers
if len(cleaned) > 3: # Must be substantial
self.album = cleaned
break
@dataclass
class AlbumResult:
"""Album/folder search result containing multiple tracks"""
username: str
album_path: str # Directory path
album_title: str
artist: Optional[str]
track_count: int
total_size: int
tracks: List[TrackResult]
dominant_quality: str # Most common quality in album
year: Optional[str] = None
free_upload_slots: int = 0
upload_speed: int = 0
queue_length: int = 0
result_type: str = "album"
@property
def quality_score(self) -> float:
"""Calculate album quality score based on dominant quality and track count"""
quality_weights = {
'flac': 1.0,
'mp3': 0.8,
'ogg': 0.7,
'aac': 0.6,
'wma': 0.5
}
base_score = quality_weights.get(self.dominant_quality.lower(), 0.3)
# Bonus for complete albums (typically 8-15 tracks)
if 8 <= self.track_count <= 20:
base_score += 0.1
elif self.track_count > 20:
base_score += 0.05
# User metrics (same as individual tracks)
if self.free_upload_slots > 0:
base_score += 0.1
if self.upload_speed > 100:
base_score += 0.05
if self.queue_length > 10:
base_score -= 0.1
return min(base_score, 1.0)
@property
def size_mb(self) -> int:
"""Album size in MB"""
return self.total_size // (1024 * 1024)
@property
def average_track_size_mb(self) -> float:
"""Average track size in MB"""
if self.track_count > 0:
return self.size_mb / self.track_count
return 0
@dataclass
class DownloadStatus:
id: str
@ -201,12 +334,19 @@ class SoulseekClient:
except:
pass
def _process_search_responses(self, responses_data: List[Dict[str, Any]]) -> List[SearchResult]:
"""Process search response data into SearchResult objects"""
search_results = []
def _process_search_responses(self, responses_data: List[Dict[str, Any]]) -> tuple[List[TrackResult], List[AlbumResult]]:
"""Process search response data into TrackResult and AlbumResult objects"""
from collections import defaultdict
import re
all_tracks = []
albums_by_path = defaultdict(list)
logger.debug(f"Processing {len(responses_data)} user responses")
# Audio file extensions to filter for
audio_extensions = {'.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a'}
for response_data in responses_data:
username = response_data.get('username', '')
files = response_data.get('files', [])
@ -217,9 +357,15 @@ class SoulseekClient:
size = file_data.get('size', 0)
file_ext = Path(filename).suffix.lower().lstrip('.')
# Only process audio files
if f'.{file_ext}' not in audio_extensions:
continue
quality = file_ext if file_ext in ['flac', 'mp3', 'ogg', 'aac', 'wma'] else 'unknown'
result = SearchResult(
# Create TrackResult
track = TrackResult(
username=username,
filename=filename,
size=size,
@ -230,14 +376,176 @@ class SoulseekClient:
upload_speed=response_data.get('uploadSpeed', 0),
queue_length=response_data.get('queueLength', 0)
)
search_results.append(result)
all_tracks.append(track)
# Group tracks by album path for album detection
album_path = self._extract_album_path(filename)
if album_path:
albums_by_path[(username, album_path)].append(track)
return search_results
# Create AlbumResults from grouped tracks
album_results = self._create_album_results(albums_by_path)
# Keep individual tracks that weren't grouped into albums
album_track_filenames = set()
for album in album_results:
for track in album.tracks:
album_track_filenames.add(track.filename)
# Individual tracks are those not part of any album
individual_tracks = [track for track in all_tracks if track.filename not in album_track_filenames]
logger.info(f"Found {len(individual_tracks)} individual tracks and {len(album_results)} albums")
logger.debug(f"Album detection details: {len(albums_by_path)} potential albums processed")
for (username, album_path), tracks in list(albums_by_path.items())[:3]: # Log first 3 for debugging
logger.debug(f"Album: {username}/{album_path} -> {len(tracks)} tracks")
return individual_tracks, album_results
async def search(self, query: str, timeout: int = 30, progress_callback=None) -> List[SearchResult]:
def _extract_album_path(self, filename: str) -> Optional[str]:
"""Extract potential album directory path from filename"""
# Handle both Windows (\) and Unix (/) path separators
if '/' not in filename and '\\' not in filename:
return None
# Normalize path separators to forward slashes for consistent processing
normalized_path = filename.replace('\\', '/')
path_parts = normalized_path.split('/')
if len(path_parts) < 2:
return None
# Take the directory containing the file as potential album path
album_dir = path_parts[-2] # Directory containing the file
# Skip system directories that start with @ or are too short
if album_dir.startswith('@') or len(album_dir) < 2:
return None
# Return the full path up to the album directory (keeping forward slashes)
return '/'.join(path_parts[:-1])
def _create_album_results(self, albums_by_path: dict) -> List[AlbumResult]:
"""Create AlbumResult objects from grouped tracks"""
import re
from collections import Counter
album_results = []
for (username, album_path), tracks in albums_by_path.items():
# Only create albums for paths with multiple tracks (2+ tracks)
if len(tracks) < 2:
continue
# Calculate album metadata
total_size = sum(track.size for track in tracks)
quality_counts = Counter(track.quality for track in tracks)
dominant_quality = quality_counts.most_common(1)[0][0]
# Extract album title from path
album_title = self._extract_album_title(album_path)
# Try to determine artist from tracks or path
artist = self._determine_album_artist(tracks, album_path)
# Extract year if present
year = self._extract_year(album_path, album_title)
# Use user metrics from first track (they should be the same for all tracks from same user)
first_track = tracks[0]
album = AlbumResult(
username=username,
album_path=album_path,
album_title=album_title,
artist=artist,
track_count=len(tracks),
total_size=total_size,
tracks=sorted(tracks, key=lambda t: t.track_number or 0), # Sort by track number
dominant_quality=dominant_quality,
year=year,
free_upload_slots=first_track.free_upload_slots,
upload_speed=first_track.upload_speed,
queue_length=first_track.queue_length
)
album_results.append(album)
return album_results
def _extract_album_title(self, album_path: str) -> str:
"""Extract album title from directory path"""
import re
# Get the last directory name as album title
album_dir = album_path.split('/')[-1]
# Clean up common patterns
# Remove leading numbers and separators
cleaned = re.sub(r'^\d+\s*[-\.\s]+', '', album_dir)
# Remove year patterns at the end: (2023), [2023], - 2023
cleaned = re.sub(r'\s*[-\(\[]?\d{4}[-\)\]]?\s*$', '', cleaned)
# Remove common separators and extra spaces
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
return cleaned if cleaned else album_dir
def _determine_album_artist(self, tracks: List[TrackResult], album_path: str) -> Optional[str]:
"""Determine album artist from track artists or path"""
from collections import Counter
# Get artist from tracks
track_artists = [track.artist for track in tracks if track.artist]
if track_artists:
# Use most common artist
artist_counts = Counter(track_artists)
return artist_counts.most_common(1)[0][0]
# Try to extract from path
import re
album_dir = album_path.split('/')[-1]
# Look for "Artist - Album" pattern
artist_match = re.match(r'^(.+?)\s*[-]\s*(.+)$', album_dir)
if artist_match:
potential_artist = artist_match.group(1).strip()
if len(potential_artist) > 1:
return potential_artist
return None
def _extract_year(self, album_path: str, album_title: str) -> Optional[str]:
"""Extract year from album path or title"""
import re
# Look for 4-digit year in parentheses, brackets, or after dash
text_to_search = f"{album_path} {album_title}"
year_patterns = [
r'\((\d{4})\)', # (2023)
r'\[(\d{4})\]', # [2023]
r'\s-(\d{4})$', # - 2023 at end
r'\s(\d{4})\s', # 2023 with spaces
r'\s(\d{4})$' # 2023 at end
]
for pattern in year_patterns:
match = re.search(pattern, text_to_search)
if match:
year = match.group(1)
# Validate year range (1900-2030)
if 1900 <= int(year) <= 2030:
return year
return None
async def search(self, query: str, timeout: int = 30, progress_callback=None) -> tuple[List[TrackResult], List[AlbumResult]]:
if not self.base_url:
logger.error("Soulseek client not configured")
return []
return [], []
try:
logger.info(f"Starting search for: '{query}'")
@ -256,18 +564,18 @@ class SoulseekClient:
response = await self._make_request('POST', 'searches', json=search_data)
if not response:
logger.error("No response from search POST request")
return []
return [], []
search_id = response.get('id')
if not search_id:
logger.error("No search ID returned from POST request")
logger.debug(f"Full response: {response}")
return []
return [], []
logger.info(f"Search initiated with ID: {search_id}")
# Poll for results instead of blocking sleep - like web interface does
all_results = []
# Poll for results - collect all responses first, then process at the end
all_responses = []
poll_interval = 1.5 # Check every 1.5 seconds for more responsive updates
max_polls = int(timeout / poll_interval) # 20 attempts over 30 seconds
@ -277,44 +585,49 @@ class SoulseekClient:
# Get current search responses
responses_data = await self._make_request('GET', f'searches/{search_id}/responses')
if responses_data and isinstance(responses_data, list):
current_results = self._process_search_responses(responses_data)
# Add new unique results
existing_filenames = {r.filename for r in all_results}
new_results = [r for r in current_results if r.filename not in existing_filenames]
all_results.extend(new_results)
if new_results:
logger.info(f"Found {len(new_results)} new results (total: {len(all_results)}) at {poll_count * poll_interval:.1f}s")
# Call progress callback with new results
# Check if we got new responses
new_response_count = len(responses_data) - len(all_responses)
if new_response_count > 0:
all_responses = responses_data
logger.info(f"Found {len(all_responses)} total responses at {poll_count * poll_interval:.1f}s")
# Call progress callback with current count
if progress_callback:
try:
progress_callback(new_results, len(all_results))
progress_callback([], len(all_responses))
except Exception as e:
logger.error(f"Error in progress callback: {e}")
# Early termination if we have enough results
if len(all_results) >= 45: # Stop after 45 results for better performance (3 pages of 15)
logger.info(f"Early termination: Found {len(all_results)} results, stopping search")
# Early termination if we have enough responses
if len(all_responses) >= 30: # Stop after 30 responses for better performance
logger.info(f"Early termination: Found {len(all_responses)} responses, stopping search")
break
elif len(all_results) > 0:
logger.debug(f"No new results, total still: {len(all_results)}")
elif len(all_responses) > 0:
logger.debug(f"No new responses, total still: {len(all_responses)}")
else:
logger.debug(f"Still waiting for results... ({poll_count * poll_interval:.1f}s elapsed)")
logger.debug(f"Still waiting for responses... ({poll_count * poll_interval:.1f}s elapsed)")
# Wait before next poll (unless this is the last attempt)
if poll_count < max_polls - 1:
await asyncio.sleep(poll_interval)
logger.info(f"Search completed. Found {len(all_results)} total results for query: {query}")
# Sort by quality score and return
all_results.sort(key=lambda x: x.quality_score, reverse=True)
return all_results
# Process all collected responses at the end
if all_responses:
tracks, albums = self._process_search_responses(all_responses)
# Sort tracks by quality score
tracks.sort(key=lambda x: x.quality_score, reverse=True)
albums.sort(key=lambda x: x.quality_score, reverse=True)
logger.info(f"Search completed. Found {len(tracks)} tracks and {len(albums)} albums for query: {query}")
return tracks, albums
else:
logger.info(f"Search completed with no results for query: {query}")
return [], []
except Exception as e:
logger.error(f"Error searching: {e}")
return []
return [], []
async def download(self, username: str, filename: str, file_size: int = 0) -> Optional[str]:
if not self.base_url:

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,9 @@ from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
import functools # For fixing lambda memory leaks
import os
# Import the new search result classes
from core.soulseek_client import TrackResult, AlbumResult
class AudioPlayer(QMediaPlayer):
"""Simple audio player for streaming music files"""
playback_finished = pyqtSignal()
@ -273,7 +276,7 @@ class ExploreApiThread(QThread):
self._stop_requested = True
class SearchThread(QThread):
search_completed = pyqtSignal(list) # List of search results
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(list, int) # Partial results, total count
@ -305,8 +308,9 @@ class SearchThread(QThread):
results = loop.run_until_complete(self._do_search())
if not self._stop_requested:
# Emit final completion with all results
self.search_completed.emit(self.all_results if self.all_results else results)
# 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:
@ -456,10 +460,9 @@ class StreamingThread(QThread):
if found_file:
print(f"✓ Found downloaded file: {found_file}")
# Move the file to Stream folder
file_extension = os.path.splitext(found_file)[1]
stream_filename = f"current_stream{file_extension}"
stream_path = os.path.join(stream_folder, stream_filename)
# 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
@ -592,6 +595,338 @@ class StreamingThread(QThread):
"""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
details = []
if self.track_result.track_number:
details.append(f"#{self.track_result.track_number:02d}")
details.append(self.track_result.quality.upper())
if self.track_result.bitrate:
details.append(f"{self.track_result.bitrate}kbps")
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);
}
""")
button_layout.addWidget(play_btn)
button_layout.addWidget(download_btn)
# Store button references for state management
self.play_btn = play_btn
self.download_btn = 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 set_loading_state(self):
"""Set play button to loading state"""
self.play_btn.setText("")
self.play_btn.setEnabled(False)
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
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
self.collapsed_height = 80
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(45, 45, 45, 0.9),
stop:1 rgba(35, 35, 35, 0.95));
border-radius: 12px;
border: 1px solid rgba(80, 80, 80, 0.4);
margin: 6px 4px;
}
AlbumResultItem:hover {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 rgba(55, 55, 55, 0.95),
stop:1 rgba(45, 45, 45, 0.98));
border: 1px solid rgba(29, 185, 84, 0.7);
}
""")
# 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(80)
self.header_widget.setStyleSheet("QWidget { background: transparent; }")
header_layout = QHBoxLayout(self.header_widget)
header_layout.setContentsMargins(16, 12, 16, 12)
header_layout.setSpacing(16)
# Album icon with expand indicator
icon_container = QVBoxLayout()
album_icon = QLabel("💿")
album_icon.setFixedSize(32, 32)
album_icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
album_icon.setStyleSheet("""
QLabel {
font-size: 20px;
background: rgba(29, 185, 84, 0.1);
border-radius: 16px;
border: 1px solid rgba(29, 185, 84, 0.3);
}
""")
# Expand indicator
self.expand_indicator = QLabel("")
self.expand_indicator.setFixedSize(16, 16)
self.expand_indicator.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.expand_indicator.setStyleSheet("""
QLabel {
color: rgba(29, 185, 84, 0.8);
font-size: 12px;
font-weight: bold;
}
""")
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
details = []
if self.album_result.artist:
details.append(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})")
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 button
self.download_btn = QPushButton("⬇️ Download Album")
self.download_btn.setFixedSize(120, 36)
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.9),
stop:1 rgba(24, 156, 71, 0.9));
border: none;
border-radius: 18px;
color: #000000;
font-size: 12px;
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));
}
""")
# Assembly header
header_layout.addLayout(icon_container)
header_layout.addLayout(info_section, 1)
header_layout.addWidget(self.download_btn)
# 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 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)
class SearchResultItem(QFrame):
download_requested = pyqtSignal(object) # SearchResult object
stream_requested = pyqtSignal(object) # SearchResult object for streaming
@ -943,33 +1278,63 @@ class SearchResultItem(QFrame):
return '/'.join(truncated_parts)
def _extract_song_info(self):
"""Extract song title and artist from filename"""
filename = self.search_result.filename
"""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
# Remove file extension
name_without_ext = filename.rsplit('.', 1)[0]
# 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'
}
# 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'
}
# 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"""
quality = self.search_result.quality.upper()
bitrate = self.search_result.bitrate
# 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"
@ -1004,8 +1369,11 @@ class SearchResultItem(QFrame):
def _create_compact_speed_indicator(self):
"""Create compact upload speed indicator"""
speed = self.search_result.upload_speed
slots = self.search_result.free_upload_slots
# Handle list case defensively
result = self.search_result[0] if isinstance(self.search_result, list) else self.search_result
speed = result.upload_speed
slots = result.free_upload_slots
if slots > 0 and speed > 100:
indicator_color = "#1db954"
@ -2430,12 +2798,20 @@ class DownloadsPage(QWidget):
self.search_btn.setText("🔍 Search")
self.search_btn.setEnabled(True)
# Use temp results from progressive loading if available, otherwise use results
if hasattr(self, '_temp_search_results') and self._temp_search_results:
self.search_results = self._temp_search_results
del self._temp_search_results # Clean up temp storage
# Handle tuple format (tracks, albums) from enhanced search
if isinstance(results, tuple) and len(results) == 2:
tracks, albums = results
# Combine into single list for display - albums first, then tracks
combined_results = albums + tracks
self.search_results = combined_results
self.track_results = tracks # Store separately for future use
self.album_results = albums # Store separately for future use
else:
# Fallback for old list format or empty results
self.search_results = results or []
self.track_results = results or [] # Assume all are tracks in old format
self.album_results = []
total_results = len(self.search_results)
@ -2446,12 +2822,24 @@ class DownloadsPage(QWidget):
self.update_search_status(f"✨ Search completed • Found {self.displayed_results} total results", "#1db954")
return
# Update status with album/track breakdown
album_count = len(self.album_results) if hasattr(self, 'album_results') else 0
track_count = len(self.track_results) if hasattr(self, 'track_results') else total_results
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"
# 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"✨ Found {total_results} results • Showing first {self.displayed_results} (scroll down for {remaining} more)", "#1db954")
self.update_search_status(f"✨ Found {result_summary} • Showing first {self.displayed_results} (scroll down for {remaining} more)", "#1db954")
else:
self.update_search_status(f"✨ Search completed • Showing all {total_results} results", "#1db954")
self.update_search_status(f"✨ Search completed • Found {result_summary}", "#1db954")
# If we have no displayed results yet, show the first batch
if self.displayed_results == 0 and total_results > 0:
@ -2459,12 +2847,14 @@ class DownloadsPage(QWidget):
def clear_search_results(self):
"""Clear all search result items from the layout"""
# Remove all SearchResultItem widgets (but keep stretch)
# 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() and isinstance(item.widget(), SearchResultItem):
items_to_remove.append(item.widget())
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)
@ -2501,10 +2891,21 @@ class DownloadsPage(QWidget):
# Add result items to UI
for i in range(start_index, end_index):
result = self.search_results[i]
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)
# Create appropriate UI component based on result type
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.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 track result item (play + download)
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 (which is always last)
insert_position = self.search_results_layout.count() - 1
self.search_results_layout.insertWidget(insert_position, result_item)
@ -2606,6 +3007,20 @@ class DownloadsPage(QWidget):
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}")
# 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 start_stream(self, search_result, result_item=None):
"""Start streaming a search result using StreamingThread"""
try:
@ -2671,11 +3086,15 @@ class DownloadsPage(QWidget):
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 the current stream file
# 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):
if filename.startswith('current_stream') and os.path.isfile(os.path.join(stream_folder, filename)):
stream_file = os.path.join(stream_folder, filename)
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):