mirror of https://github.com/Nezreka/SoulSync.git
Watchlist will allow the user to select artist to 'watch' and the app will automatically add new releases by those artists to the wishlist for automatic download. included version information and modalpull/8/head
parent
d81e6ca72e
commit
3bd6a29bfd
@ -0,0 +1,477 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""
|
||||
Watchlist Scanner Service - Monitors watched artists for new releases
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime, timezone
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
from database.music_database import get_database, WatchlistArtist
|
||||
from core.spotify_client import SpotifyClient
|
||||
from core.wishlist_service import get_wishlist_service
|
||||
from core.matching_engine import MusicMatchingEngine
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("watchlist_scanner")
|
||||
|
||||
def clean_track_name_for_search(track_name):
|
||||
"""
|
||||
Intelligently cleans a track name for searching by removing noise while preserving important version information.
|
||||
Removes: (feat. Artist), (Explicit), (Clean), etc.
|
||||
Keeps: (Extended Version), (Live), (Acoustic), (Remix), etc.
|
||||
"""
|
||||
if not track_name or not isinstance(track_name, str):
|
||||
return track_name
|
||||
|
||||
cleaned_name = track_name
|
||||
|
||||
# Define patterns to REMOVE (noise that doesn't affect track identity)
|
||||
remove_patterns = [
|
||||
r'\s*\(explicit\)', # (Explicit)
|
||||
r'\s*\(clean\)', # (Clean)
|
||||
r'\s*\(radio\s*edit\)', # (Radio Edit)
|
||||
r'\s*\(radio\s*version\)', # (Radio Version)
|
||||
r'\s*\(feat\.?\s*[^)]+\)', # (feat. Artist) or (ft. Artist)
|
||||
r'\s*\(ft\.?\s*[^)]+\)', # (ft Artist)
|
||||
r'\s*\(featuring\s*[^)]+\)', # (featuring Artist)
|
||||
r'\s*\(with\s*[^)]+\)', # (with Artist)
|
||||
r'\s*\[[^\]]*explicit[^\]]*\]', # [Explicit] in brackets
|
||||
r'\s*\[[^\]]*clean[^\]]*\]', # [Clean] in brackets
|
||||
]
|
||||
|
||||
# Apply removal patterns
|
||||
for pattern in remove_patterns:
|
||||
cleaned_name = re.sub(pattern, '', cleaned_name, flags=re.IGNORECASE).strip()
|
||||
|
||||
# PRESERVE important version information (do NOT remove these)
|
||||
# These patterns are intentionally NOT in the remove list:
|
||||
# - (Extended Version), (Extended), (Long Version)
|
||||
# - (Live), (Live Version), (Concert)
|
||||
# - (Acoustic), (Acoustic Version)
|
||||
# - (Remix), (Club Mix), (Dance Mix)
|
||||
# - (Remastered), (Remaster)
|
||||
# - (Demo), (Studio Version)
|
||||
# - (Instrumental)
|
||||
# - Album/year info like (2023), (Deluxe Edition)
|
||||
|
||||
# If cleaning results in an empty string, return the original track name
|
||||
if not cleaned_name.strip():
|
||||
return track_name
|
||||
|
||||
# Log cleaning if significant changes were made
|
||||
if cleaned_name != track_name:
|
||||
logger.debug(f"🧹 Intelligent track cleaning: '{track_name}' -> '{cleaned_name}'")
|
||||
|
||||
return cleaned_name
|
||||
|
||||
@dataclass
|
||||
class ScanResult:
|
||||
"""Result of scanning a single artist"""
|
||||
artist_name: str
|
||||
spotify_artist_id: str
|
||||
albums_checked: int
|
||||
new_tracks_found: int
|
||||
tracks_added_to_wishlist: int
|
||||
success: bool
|
||||
error_message: Optional[str] = None
|
||||
|
||||
class WatchlistScanner:
|
||||
"""Service for scanning watched artists for new releases"""
|
||||
|
||||
def __init__(self, spotify_client: SpotifyClient, database_path: str = "database/music_library.db"):
|
||||
self.spotify_client = spotify_client
|
||||
self.database_path = database_path
|
||||
self._database = None
|
||||
self._wishlist_service = None
|
||||
self._matching_engine = None
|
||||
|
||||
@property
|
||||
def database(self):
|
||||
"""Get database instance (lazy loading)"""
|
||||
if self._database is None:
|
||||
self._database = get_database(self.database_path)
|
||||
return self._database
|
||||
|
||||
@property
|
||||
def wishlist_service(self):
|
||||
"""Get wishlist service instance (lazy loading)"""
|
||||
if self._wishlist_service is None:
|
||||
self._wishlist_service = get_wishlist_service()
|
||||
return self._wishlist_service
|
||||
|
||||
@property
|
||||
def matching_engine(self):
|
||||
"""Get matching engine instance (lazy loading)"""
|
||||
if self._matching_engine is None:
|
||||
self._matching_engine = MusicMatchingEngine()
|
||||
return self._matching_engine
|
||||
|
||||
def scan_all_watchlist_artists(self) -> List[ScanResult]:
|
||||
"""
|
||||
Scan all artists in the watchlist for new releases.
|
||||
Only checks releases after their last scan timestamp.
|
||||
"""
|
||||
logger.info("Starting watchlist scan for all artists")
|
||||
|
||||
try:
|
||||
# Get all watchlist artists
|
||||
watchlist_artists = self.database.get_watchlist_artists()
|
||||
if not watchlist_artists:
|
||||
logger.info("No artists in watchlist to scan")
|
||||
return []
|
||||
|
||||
logger.info(f"Found {len(watchlist_artists)} artists in watchlist")
|
||||
|
||||
scan_results = []
|
||||
for i, artist in enumerate(watchlist_artists):
|
||||
try:
|
||||
result = self.scan_artist(artist)
|
||||
scan_results.append(result)
|
||||
|
||||
if result.success:
|
||||
logger.info(f"✅ Scanned {artist.artist_name}: {result.new_tracks_found} new tracks found")
|
||||
else:
|
||||
logger.warning(f"❌ Failed to scan {artist.artist_name}: {result.error_message}")
|
||||
|
||||
# Rate limiting: Add a small delay between artists to avoid hitting Spotify limits
|
||||
# Spotify allows ~100 requests per minute, so 1-2 second delay is safe
|
||||
if i < len(watchlist_artists) - 1: # Don't delay after the last artist
|
||||
import time
|
||||
time.sleep(1.5) # 1.5 second delay between artists
|
||||
logger.debug(f"Rate limiting: waited 1.5s before scanning next artist")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error scanning artist {artist.artist_name}: {e}")
|
||||
scan_results.append(ScanResult(
|
||||
artist_name=artist.artist_name,
|
||||
spotify_artist_id=artist.spotify_artist_id,
|
||||
albums_checked=0,
|
||||
new_tracks_found=0,
|
||||
tracks_added_to_wishlist=0,
|
||||
success=False,
|
||||
error_message=str(e)
|
||||
))
|
||||
|
||||
# Log summary
|
||||
successful_scans = [r for r in scan_results if r.success]
|
||||
total_new_tracks = sum(r.new_tracks_found for r in successful_scans)
|
||||
total_added_to_wishlist = sum(r.tracks_added_to_wishlist for r in successful_scans)
|
||||
|
||||
logger.info(f"Watchlist scan complete: {len(successful_scans)}/{len(scan_results)} artists scanned successfully")
|
||||
logger.info(f"Found {total_new_tracks} new tracks, added {total_added_to_wishlist} to wishlist")
|
||||
|
||||
return scan_results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during watchlist scan: {e}")
|
||||
return []
|
||||
|
||||
def scan_artist(self, watchlist_artist: WatchlistArtist) -> ScanResult:
|
||||
"""
|
||||
Scan a single artist for new releases.
|
||||
Only checks releases after the last scan timestamp.
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Scanning artist: {watchlist_artist.artist_name}")
|
||||
|
||||
# Get artist discography from Spotify
|
||||
albums = self.get_artist_discography(watchlist_artist.spotify_artist_id, watchlist_artist.last_scan_timestamp)
|
||||
|
||||
if albums is None:
|
||||
return ScanResult(
|
||||
artist_name=watchlist_artist.artist_name,
|
||||
spotify_artist_id=watchlist_artist.spotify_artist_id,
|
||||
albums_checked=0,
|
||||
new_tracks_found=0,
|
||||
tracks_added_to_wishlist=0,
|
||||
success=False,
|
||||
error_message="Failed to get artist discography from Spotify"
|
||||
)
|
||||
|
||||
logger.info(f"Found {len(albums)} albums/singles to check for {watchlist_artist.artist_name}")
|
||||
|
||||
# Check each album/single for missing tracks
|
||||
new_tracks_found = 0
|
||||
tracks_added_to_wishlist = 0
|
||||
|
||||
for album in albums:
|
||||
try:
|
||||
# Get full album data with tracks
|
||||
album_data = self.spotify_client.get_album(album.id)
|
||||
if not album_data or 'tracks' not in album_data or not album_data['tracks'].get('items'):
|
||||
continue
|
||||
|
||||
tracks = album_data['tracks']['items']
|
||||
logger.debug(f"Checking album: {album_data.get('name', 'Unknown')} ({len(tracks)} tracks)")
|
||||
|
||||
# Check each track
|
||||
for track in tracks:
|
||||
if self.is_track_missing_from_library(track):
|
||||
new_tracks_found += 1
|
||||
|
||||
# Add to wishlist
|
||||
if self.add_track_to_wishlist(track, album_data, watchlist_artist):
|
||||
tracks_added_to_wishlist += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error checking album {album.name}: {e}")
|
||||
continue
|
||||
|
||||
# Update last scan timestamp for this artist
|
||||
self.update_artist_scan_timestamp(watchlist_artist.spotify_artist_id)
|
||||
|
||||
return ScanResult(
|
||||
artist_name=watchlist_artist.artist_name,
|
||||
spotify_artist_id=watchlist_artist.spotify_artist_id,
|
||||
albums_checked=len(albums),
|
||||
new_tracks_found=new_tracks_found,
|
||||
tracks_added_to_wishlist=tracks_added_to_wishlist,
|
||||
success=True
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error scanning artist {watchlist_artist.artist_name}: {e}")
|
||||
return ScanResult(
|
||||
artist_name=watchlist_artist.artist_name,
|
||||
spotify_artist_id=watchlist_artist.spotify_artist_id,
|
||||
albums_checked=0,
|
||||
new_tracks_found=0,
|
||||
tracks_added_to_wishlist=0,
|
||||
success=False,
|
||||
error_message=str(e)
|
||||
)
|
||||
|
||||
def get_artist_discography(self, spotify_artist_id: str, last_scan_timestamp: Optional[datetime] = None) -> Optional[List]:
|
||||
"""
|
||||
Get artist's discography from Spotify, optionally filtered by release date.
|
||||
|
||||
Args:
|
||||
spotify_artist_id: Spotify artist ID
|
||||
last_scan_timestamp: Only return releases after this date (for incremental scans)
|
||||
"""
|
||||
try:
|
||||
# Get all artist albums (albums + singles)
|
||||
albums = self.spotify_client.get_artist_albums(spotify_artist_id, album_type='album,single', limit=50)
|
||||
|
||||
if not albums:
|
||||
logger.warning(f"No albums found for artist {spotify_artist_id}")
|
||||
return []
|
||||
|
||||
# Filter by release date if we have a last scan timestamp
|
||||
if last_scan_timestamp:
|
||||
filtered_albums = []
|
||||
for album in albums:
|
||||
if self.is_album_after_timestamp(album, last_scan_timestamp):
|
||||
filtered_albums.append(album)
|
||||
|
||||
logger.info(f"Filtered {len(albums)} albums to {len(filtered_albums)} released after {last_scan_timestamp}")
|
||||
return filtered_albums
|
||||
|
||||
return albums
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting discography for artist {spotify_artist_id}: {e}")
|
||||
return None
|
||||
|
||||
def is_album_after_timestamp(self, album, timestamp: datetime) -> bool:
|
||||
"""Check if album was released after the given timestamp"""
|
||||
try:
|
||||
if not album.release_date:
|
||||
return True # Include albums with unknown release dates to be safe
|
||||
|
||||
# Parse release date - Spotify provides different precisions
|
||||
release_date_str = album.release_date
|
||||
|
||||
# Handle different date formats
|
||||
if len(release_date_str) == 4: # Year only (e.g., "2023")
|
||||
album_date = datetime(int(release_date_str), 1, 1, tzinfo=timezone.utc)
|
||||
elif len(release_date_str) == 7: # Year-month (e.g., "2023-10")
|
||||
year, month = release_date_str.split('-')
|
||||
album_date = datetime(int(year), int(month), 1, tzinfo=timezone.utc)
|
||||
elif len(release_date_str) == 10: # Full date (e.g., "2023-10-15")
|
||||
album_date = datetime.strptime(release_date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
logger.warning(f"Unknown release date format: {release_date_str}")
|
||||
return True # Include if we can't parse
|
||||
|
||||
# Ensure timestamp has timezone info
|
||||
if timestamp.tzinfo is None:
|
||||
timestamp = timestamp.replace(tzinfo=timezone.utc)
|
||||
|
||||
return album_date > timestamp
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error comparing album date {album.release_date} with timestamp {timestamp}: {e}")
|
||||
return True # Include if we can't determine
|
||||
|
||||
def is_track_missing_from_library(self, track) -> bool:
|
||||
"""
|
||||
Check if a track is missing from the local Plex library.
|
||||
Uses the same matching logic as the download missing tracks modals.
|
||||
"""
|
||||
try:
|
||||
# Handle both dict and object track formats
|
||||
if isinstance(track, dict):
|
||||
original_title = track.get('name', 'Unknown')
|
||||
track_artists = track.get('artists', [])
|
||||
artists_to_search = [artist.get('name', 'Unknown') for artist in track_artists] if track_artists else ["Unknown"]
|
||||
else:
|
||||
original_title = track.name
|
||||
artists_to_search = [artist.name for artist in track.artists] if track.artists else ["Unknown"]
|
||||
|
||||
# Generate title variations (same logic as sync page)
|
||||
title_variations = [original_title]
|
||||
|
||||
# Only add cleaned version if it removes clear noise
|
||||
cleaned_for_search = clean_track_name_for_search(original_title)
|
||||
if cleaned_for_search.lower() != original_title.lower():
|
||||
title_variations.append(cleaned_for_search)
|
||||
|
||||
# Use matching engine's conservative clean_title
|
||||
base_title = self.matching_engine.clean_title(original_title)
|
||||
if base_title.lower() not in [t.lower() for t in title_variations]:
|
||||
title_variations.append(base_title)
|
||||
|
||||
unique_title_variations = list(dict.fromkeys(title_variations))
|
||||
|
||||
# Search for each artist with each title variation
|
||||
|
||||
for artist_name in artists_to_search:
|
||||
for query_title in unique_title_variations:
|
||||
# Use same database check as modals
|
||||
db_track, confidence = self.database.check_track_exists(query_title, artist_name, confidence_threshold=0.7)
|
||||
|
||||
if db_track and confidence >= 0.7:
|
||||
logger.debug(f"✔️ Track found in library: '{original_title}' by '{artist_name}' (confidence: {confidence:.2f})")
|
||||
return False # Track exists in library
|
||||
|
||||
# No match found with any variation or artist
|
||||
logger.info(f"❌ Track missing from library: '{original_title}' by '{artists_to_search[0] if artists_to_search else 'Unknown'}' - adding to wishlist")
|
||||
return True # Track is missing
|
||||
|
||||
except Exception as e:
|
||||
# Handle both dict and object track formats for error logging
|
||||
track_name = track.get('name', 'Unknown') if isinstance(track, dict) else getattr(track, 'name', 'Unknown')
|
||||
logger.warning(f"Error checking if track exists: {track_name}: {e}")
|
||||
return True # Assume missing if we can't check
|
||||
|
||||
def add_track_to_wishlist(self, track, album, watchlist_artist: WatchlistArtist) -> bool:
|
||||
"""Add a missing track to the wishlist"""
|
||||
try:
|
||||
# Handle both dict and object track/album formats
|
||||
if isinstance(track, dict):
|
||||
track_id = track.get('id', '')
|
||||
track_name = track.get('name', 'Unknown')
|
||||
track_artists = track.get('artists', [])
|
||||
track_duration = track.get('duration_ms', 0)
|
||||
track_explicit = track.get('explicit', False)
|
||||
track_external_urls = track.get('external_urls', {})
|
||||
track_popularity = track.get('popularity', 0)
|
||||
track_preview_url = track.get('preview_url', None)
|
||||
track_number = track.get('track_number', 1)
|
||||
track_uri = track.get('uri', '')
|
||||
else:
|
||||
track_id = track.id
|
||||
track_name = track.name
|
||||
track_artists = [{'name': artist.name, 'id': artist.id} for artist in track.artists]
|
||||
track_duration = getattr(track, 'duration_ms', 0)
|
||||
track_explicit = getattr(track, 'explicit', False)
|
||||
track_external_urls = getattr(track, 'external_urls', {})
|
||||
track_popularity = getattr(track, 'popularity', 0)
|
||||
track_preview_url = getattr(track, 'preview_url', None)
|
||||
track_number = getattr(track, 'track_number', 1)
|
||||
track_uri = getattr(track, 'uri', '')
|
||||
|
||||
if isinstance(album, dict):
|
||||
album_name = album.get('name', 'Unknown')
|
||||
album_id = album.get('id', '')
|
||||
album_release_date = album.get('release_date', '')
|
||||
album_images = album.get('images', [])
|
||||
else:
|
||||
album_name = album.name
|
||||
album_id = album.id
|
||||
album_release_date = album.release_date
|
||||
album_images = album.images if hasattr(album, 'images') else []
|
||||
|
||||
# Create Spotify track data structure
|
||||
spotify_track_data = {
|
||||
'id': track_id,
|
||||
'name': track_name,
|
||||
'artists': track_artists,
|
||||
'album': {
|
||||
'name': album_name,
|
||||
'id': album_id,
|
||||
'release_date': album_release_date,
|
||||
'images': album_images
|
||||
},
|
||||
'duration_ms': track_duration,
|
||||
'explicit': track_explicit,
|
||||
'external_urls': track_external_urls,
|
||||
'popularity': track_popularity,
|
||||
'preview_url': track_preview_url,
|
||||
'track_number': track_number,
|
||||
'uri': track_uri,
|
||||
'is_local': False
|
||||
}
|
||||
|
||||
# Add to wishlist with watchlist context
|
||||
success = self.database.add_to_wishlist(
|
||||
spotify_track_data=spotify_track_data,
|
||||
failure_reason="Missing from library (found by watchlist scan)",
|
||||
source_type="watchlist",
|
||||
source_info={
|
||||
'watchlist_artist_name': watchlist_artist.artist_name,
|
||||
'watchlist_artist_id': watchlist_artist.spotify_artist_id,
|
||||
'album_name': album_name,
|
||||
'scan_timestamp': datetime.now().isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
if success:
|
||||
first_artist = track_artists[0].get('name', 'Unknown') if track_artists else 'Unknown'
|
||||
logger.debug(f"Added track to wishlist: {track_name} by {first_artist}")
|
||||
else:
|
||||
logger.warning(f"Failed to add track to wishlist: {track_name}")
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding track to wishlist: {track_name}: {e}")
|
||||
return False
|
||||
|
||||
def update_artist_scan_timestamp(self, spotify_artist_id: str) -> bool:
|
||||
"""Update the last scan timestamp for an artist"""
|
||||
try:
|
||||
with self.database._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE watchlist_artists
|
||||
SET last_scan_timestamp = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE spotify_artist_id = ?
|
||||
""", (spotify_artist_id,))
|
||||
|
||||
conn.commit()
|
||||
|
||||
if cursor.rowcount > 0:
|
||||
logger.debug(f"Updated scan timestamp for artist {spotify_artist_id}")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"No artist found with Spotify ID {spotify_artist_id}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating scan timestamp for artist {spotify_artist_id}: {e}")
|
||||
return False
|
||||
|
||||
# Singleton instance
|
||||
_watchlist_scanner_instance = None
|
||||
|
||||
def get_watchlist_scanner(spotify_client: SpotifyClient) -> WatchlistScanner:
|
||||
"""Get the global watchlist scanner instance"""
|
||||
global _watchlist_scanner_instance
|
||||
if _watchlist_scanner_instance is None:
|
||||
_watchlist_scanner_instance = WatchlistScanner(spotify_client)
|
||||
return _watchlist_scanner_instance
|
||||
@ -0,0 +1,286 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QFrame, QScrollArea, QWidget)
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtGui import QFont
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("version_info_modal")
|
||||
|
||||
class VersionInfoModal(QDialog):
|
||||
"""Modal displaying recent changes and version information"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("What's New in SoulSync v0.5")
|
||||
self.setModal(True)
|
||||
self.setFixedSize(600, 500)
|
||||
self.setup_ui()
|
||||
|
||||
def setup_ui(self):
|
||||
self.setStyleSheet("""
|
||||
VersionInfoModal {
|
||||
background: #1a1a1a;
|
||||
border-radius: 12px;
|
||||
}
|
||||
""")
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
# Header
|
||||
header = self.create_header()
|
||||
layout.addWidget(header)
|
||||
|
||||
# Content area with scroll
|
||||
content_area = self.create_content_area()
|
||||
layout.addWidget(content_area)
|
||||
|
||||
# Footer with close button
|
||||
footer = self.create_footer()
|
||||
layout.addWidget(footer)
|
||||
|
||||
def create_header(self):
|
||||
header = QFrame()
|
||||
header.setFixedHeight(80)
|
||||
header.setStyleSheet("""
|
||||
QFrame {
|
||||
background: #1a1a1a;
|
||||
border-top-left-radius: 12px;
|
||||
border-top-right-radius: 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
""")
|
||||
|
||||
layout = QVBoxLayout(header)
|
||||
layout.setContentsMargins(30, 20, 30, 15)
|
||||
layout.setSpacing(5)
|
||||
|
||||
# Title
|
||||
title = QLabel("What's New in SoulSync")
|
||||
title.setFont(QFont("SF Pro Display", 18, QFont.Weight.Bold))
|
||||
title.setStyleSheet("""
|
||||
color: #ffffff;
|
||||
letter-spacing: -0.5px;
|
||||
font-weight: 700;
|
||||
""")
|
||||
|
||||
# Version subtitle
|
||||
version_subtitle = QLabel("Version 0.5 - Latest Features & Improvements")
|
||||
version_subtitle.setFont(QFont("SF Pro Text", 11, QFont.Weight.Medium))
|
||||
version_subtitle.setStyleSheet("""
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
letter-spacing: 0.1px;
|
||||
margin-top: 2px;
|
||||
""")
|
||||
|
||||
layout.addWidget(title)
|
||||
layout.addWidget(version_subtitle)
|
||||
|
||||
return header
|
||||
|
||||
def create_content_area(self):
|
||||
# Scroll area for content
|
||||
scroll_area = QScrollArea()
|
||||
scroll_area.setWidgetResizable(True)
|
||||
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
scroll_area.setStyleSheet("""
|
||||
QScrollArea {
|
||||
border: none;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
QScrollBar:vertical {
|
||||
background: #2a2a2a;
|
||||
width: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QScrollBar::handle:vertical {
|
||||
background: #555555;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QScrollBar::handle:vertical:hover {
|
||||
background: #666666;
|
||||
}
|
||||
""")
|
||||
|
||||
# Content widget
|
||||
content_widget = QWidget()
|
||||
content_layout = QVBoxLayout(content_widget)
|
||||
content_layout.setContentsMargins(30, 25, 30, 25)
|
||||
content_layout.setSpacing(25)
|
||||
|
||||
# New Watchlist Feature
|
||||
watchlist_section = self.create_feature_section(
|
||||
"🔍 New Watchlist Feature",
|
||||
"Track your favorite artists and get notified when they release new music",
|
||||
[
|
||||
"• Automatically monitors your favorite artists for new releases",
|
||||
"• Smart scanning that checks only for releases since last scan",
|
||||
"• Real-time progress tracking with detailed status indicators",
|
||||
"• Seamless integration with your existing music library",
|
||||
"• Configurable scan intervals (now every 10 minutes for faster updates)",
|
||||
"• Search functionality for managing large artist lists (200+ artists)",
|
||||
"• Visual status icons showing scan recency and completion status"
|
||||
],
|
||||
"How to use: Go to the Artists page, click 'Add to Watchlist' on any artist card, then monitor progress in the new Watchlist Status modal accessible from the Dashboard."
|
||||
)
|
||||
content_layout.addWidget(watchlist_section)
|
||||
|
||||
# Enhanced Progress Tracking
|
||||
progress_section = self.create_feature_section(
|
||||
"📊 Enhanced Progress Tracking",
|
||||
"Better visibility into your music scanning and download progress",
|
||||
[
|
||||
"• Three-progress-bar system for Singles/EPs, Albums, and Overall progress",
|
||||
"• Per-artist progress tracking that resets for each new artist",
|
||||
"• Real-time updates during scanning with detailed completion metrics",
|
||||
"• Smart release categorization (≤3 tracks = Single/EP, ≥4 tracks = Album)",
|
||||
"• Improved mathematical accuracy for progress calculations"
|
||||
]
|
||||
)
|
||||
content_layout.addWidget(progress_section)
|
||||
|
||||
# Performance Improvements
|
||||
performance_section = self.create_feature_section(
|
||||
"⚡ Performance Improvements",
|
||||
"Faster scanning and better resource management",
|
||||
[
|
||||
"• Reduced scan intervals from 60 minutes to 10 minutes",
|
||||
"• Removed artificial 25-track processing limits",
|
||||
"• Optimized database queries for better responsiveness",
|
||||
"• Improved memory management during large scans"
|
||||
]
|
||||
)
|
||||
content_layout.addWidget(performance_section)
|
||||
|
||||
# UI/UX Enhancements
|
||||
ui_section = self.create_feature_section(
|
||||
"🎨 UI/UX Enhancements",
|
||||
"Cleaner interface and better user experience",
|
||||
[
|
||||
"• Replaced confusing colored status circles with intuitive icons",
|
||||
"• Added search functionality for large artist lists",
|
||||
"• Smart display logic showing last 5 artists when no search active",
|
||||
"• Removed unnecessary white borders for cleaner appearance",
|
||||
"• Improved status indicators with meaningful visual feedback"
|
||||
]
|
||||
)
|
||||
content_layout.addWidget(ui_section)
|
||||
|
||||
scroll_area.setWidget(content_widget)
|
||||
return scroll_area
|
||||
|
||||
def create_feature_section(self, title, description, features, usage_note=None):
|
||||
section = QFrame()
|
||||
section.setStyleSheet("""
|
||||
QFrame {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: 3px solid rgba(29, 185, 84, 0.4);
|
||||
border-radius: 0px;
|
||||
padding: 0px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
""")
|
||||
|
||||
layout = QVBoxLayout(section)
|
||||
layout.setContentsMargins(20, 18, 20, 18)
|
||||
layout.setSpacing(12)
|
||||
|
||||
# Section title
|
||||
title_label = QLabel(title)
|
||||
title_label.setFont(QFont("SF Pro Text", 14, QFont.Weight.Bold))
|
||||
title_label.setStyleSheet("""
|
||||
color: #1ed760;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.2px;
|
||||
margin-bottom: 3px;
|
||||
""")
|
||||
layout.addWidget(title_label)
|
||||
|
||||
# Description
|
||||
desc_label = QLabel(description)
|
||||
desc_label.setFont(QFont("SF Pro Text", 11))
|
||||
desc_label.setStyleSheet("""
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
line-height: 1.4;
|
||||
margin-bottom: 8px;
|
||||
""")
|
||||
desc_label.setWordWrap(True)
|
||||
layout.addWidget(desc_label)
|
||||
|
||||
# Features list
|
||||
for feature in features:
|
||||
feature_label = QLabel(feature)
|
||||
feature_label.setFont(QFont("SF Pro Text", 10))
|
||||
feature_label.setStyleSheet("""
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
line-height: 1.5;
|
||||
padding-left: 8px;
|
||||
margin: 2px 0px;
|
||||
""")
|
||||
feature_label.setWordWrap(True)
|
||||
layout.addWidget(feature_label)
|
||||
|
||||
# Usage note if provided
|
||||
if usage_note:
|
||||
usage_label = QLabel(f"💡 {usage_note}")
|
||||
usage_label.setFont(QFont("SF Pro Text", 10))
|
||||
usage_label.setStyleSheet("""
|
||||
color: #1ed760;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 8px 0px;
|
||||
margin-top: 8px;
|
||||
line-height: 1.4;
|
||||
font-style: italic;
|
||||
""")
|
||||
usage_label.setWordWrap(True)
|
||||
layout.addWidget(usage_label)
|
||||
|
||||
return section
|
||||
|
||||
def create_footer(self):
|
||||
footer = QFrame()
|
||||
footer.setFixedHeight(65)
|
||||
footer.setStyleSheet("""
|
||||
QFrame {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-bottom-left-radius: 12px;
|
||||
border-bottom-right-radius: 12px;
|
||||
}
|
||||
""")
|
||||
|
||||
layout = QHBoxLayout(footer)
|
||||
layout.setContentsMargins(30, 15, 30, 15)
|
||||
|
||||
# Close button
|
||||
close_button = QPushButton("Close")
|
||||
close_button.setFixedSize(100, 35)
|
||||
close_button.setFont(QFont("SF Pro Text", 10, QFont.Weight.Medium))
|
||||
close_button.setStyleSheet("""
|
||||
QPushButton {
|
||||
background: #1db954;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.1px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background: #1ed760;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background: #169c46;
|
||||
}
|
||||
""")
|
||||
close_button.clicked.connect(self.accept)
|
||||
|
||||
layout.addStretch()
|
||||
layout.addWidget(close_button)
|
||||
|
||||
return footer
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in new issue