commit 7d43bda3e5818c00d974f061d2988d3495211643 Author: Broque Thomas Date: Wed Jul 9 12:07:41 2025 -0700 Initial commit diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..122fc7b8 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(mkdir:*)", + "Bash(rm:*)" + ], + "deny": [] + } +} \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..dfe07704 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.spotify_cache b/.spotify_cache new file mode 100644 index 00000000..09e1b530 --- /dev/null +++ b/.spotify_cache @@ -0,0 +1 @@ +{"access_token": "BQDHuavPHFxKGfglVQ7zVOIVbF8ufUuQlH_d-mqEsy0PLmbsHR9OxUyB-QVgF_5-Nrbqwni67byBlNFhBg4dwx45A-SCedirLIMu5IIy6CSCszxwjHXLwhc4Kmz3iGbMytpLQpbsDfqTCTHk2xlfo2DY29XM_Be3ToRWv7LWFCadeIOBxjz2td_xRCDezEFjoZ-g1S9dbVO52dOwo2LPvUk4na5HKE8aH7fJ4uhZNDSyg-Isqm54JQGmWUOnGwxI", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "AQDmfQkPCGObfJeTUIbW1hAAwhSqkuHRA3Qh2dqVYMRh0eCkFMQgPNJDDzF8y-BiaVbj80zePkK_XSfYH1aJutMtNbnsqRKWuxP31BTrMc7pdUdbE7Fma4oH8wpDUKdG3MM", "scope": "user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email", "expires_at": 1752090138} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..8907c6e6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,42 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is a new music management application project that aims to create a Spotify-like desktop application with Python. The project is in its initial planning phase with only a project requirements document (`project.txt`) currently present. + +## Project Requirements + +The application will be a music management tool that: +- Connects to Spotify API and Plex Media Server +- Features an elegant, animated, vibrant theme similar to Spotify's desktop/web app +- Synchronizes Spotify playlists to Plex using robust matching systems +- Integrates with Soulseek for downloading FLAC/high-quality audio files +- Updates music metadata on Plex based on Spotify metadata including album art +- Provides core functionality that feels and looks like Spotify + +## Configuration + +The application will use a central `config.json` file to store: +- Spotify API credentials and login information +- Plex Media Server connection details +- Other connected service configurations + +## Development Status + +This is a greenfield project with no existing codebase. When implementing: +- Create a Python-based application with GUI framework (likely PyQt, Tkinter, or web-based with Flask/FastAPI) +- Implement modular architecture separating concerns for different services (Spotify, Plex, Soulseek) +- Focus on robust matching algorithms for music synchronization +- Prioritize user experience with Spotify-like interface design +- Ensure secure handling of API credentials and authentication tokens + +## Key Components to Implement + +1. **Configuration Management**: Secure handling of API keys and service credentials +2. **Spotify Integration**: Playlist retrieval and metadata extraction +3. **Plex Integration**: Media server synchronization and metadata updates +4. **Soulseek Integration**: Music discovery and download functionality +5. **Matching Engine**: Robust algorithms for matching tracks across services +6. **User Interface**: Spotify-inspired design with modern, animated elements \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..6d90f010 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# newMusic + diff --git a/config/__pycache__/settings.cpython-310.pyc b/config/__pycache__/settings.cpython-310.pyc new file mode 100644 index 00000000..a9d1f74f Binary files /dev/null and b/config/__pycache__/settings.cpython-310.pyc differ diff --git a/config/__pycache__/settings.cpython-312.pyc b/config/__pycache__/settings.cpython-312.pyc new file mode 100644 index 00000000..6d169b49 Binary files /dev/null and b/config/__pycache__/settings.cpython-312.pyc differ diff --git a/config/config.json b/config/config.json new file mode 100644 index 00000000..4339e9e8 --- /dev/null +++ b/config/config.json @@ -0,0 +1,27 @@ +{ + "spotify": { + "client_id": "512b25bd9e0d4ecd82140f6d1ce0c8e6", + "client_secret": "c3844dcbedbc4e09a6242a14b2e89e89" + }, + "plex": { + "base_url": "http://192.168.86.36:32400", + "token": "a9hTgvasV1aJMLSdoBkr" + }, + "soulseek": { + "slskd_url": "http://localhost:5030", + "api_key": "", + "download_path": "./downloads" + }, + "database": { + "path": "database/artists.db" + }, + "logging": { + "path": "logs/app.log", + "level": "DEBUG" + }, + "settings": { + "audio_quality": "flac", + "download_timeout": 300, + "max_concurrent_downloads": 5 + } +} \ No newline at end of file diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 00000000..bda94eb6 --- /dev/null +++ b/config/settings.py @@ -0,0 +1,99 @@ +import json +import os +from typing import Dict, Any, Optional +from cryptography.fernet import Fernet +from pathlib import Path + +class ConfigManager: + def __init__(self, config_path: str = "config/config.json"): + self.config_path = Path(config_path) + self.config_data: Dict[str, Any] = {} + self.encryption_key: Optional[bytes] = None + self._load_config() + + def _get_encryption_key(self) -> bytes: + key_file = self.config_path.parent / ".encryption_key" + if key_file.exists(): + with open(key_file, 'rb') as f: + return f.read() + else: + key = Fernet.generate_key() + with open(key_file, 'wb') as f: + f.write(key) + key_file.chmod(0o600) + return key + + def _load_config(self): + if not self.config_path.exists(): + raise FileNotFoundError(f"Configuration file not found: {self.config_path}") + + with open(self.config_path, 'r') as f: + self.config_data = json.load(f) + + def _save_config(self): + with open(self.config_path, 'w') as f: + json.dump(self.config_data, f, indent=2) + + def get(self, key: str, default: Any = None) -> Any: + keys = key.split('.') + value = self.config_data + + for k in keys: + if isinstance(value, dict) and k in value: + value = value[k] + else: + return default + + return value + + def set(self, key: str, value: Any): + keys = key.split('.') + config = self.config_data + + for k in keys[:-1]: + if k not in config: + config[k] = {} + config = config[k] + + config[keys[-1]] = value + self._save_config() + + def get_spotify_config(self) -> Dict[str, str]: + return self.get('spotify', {}) + + def get_plex_config(self) -> Dict[str, str]: + return self.get('plex', {}) + + def get_soulseek_config(self) -> Dict[str, str]: + return self.get('soulseek', {}) + + def get_settings(self) -> Dict[str, Any]: + return self.get('settings', {}) + + def get_database_config(self) -> Dict[str, str]: + return self.get('database', {}) + + def get_logging_config(self) -> Dict[str, str]: + return self.get('logging', {}) + + def is_configured(self) -> bool: + spotify = self.get_spotify_config() + plex = self.get_plex_config() + soulseek = self.get_soulseek_config() + + return ( + bool(spotify.get('client_id')) and + bool(spotify.get('client_secret')) and + bool(plex.get('base_url')) and + bool(plex.get('token')) and + bool(soulseek.get('slskd_url')) + ) + + def validate_config(self) -> Dict[str, bool]: + return { + 'spotify': bool(self.get('spotify.client_id')) and bool(self.get('spotify.client_secret')), + 'plex': bool(self.get('plex.base_url')) and bool(self.get('plex.token')), + 'soulseek': bool(self.get('soulseek.slskd_url')) + } + +config_manager = ConfigManager() \ No newline at end of file diff --git a/core/__pycache__/plex_client.cpython-312.pyc b/core/__pycache__/plex_client.cpython-312.pyc new file mode 100644 index 00000000..94be6817 Binary files /dev/null and b/core/__pycache__/plex_client.cpython-312.pyc differ diff --git a/core/__pycache__/soulseek_client.cpython-312.pyc b/core/__pycache__/soulseek_client.cpython-312.pyc new file mode 100644 index 00000000..ed08edcb Binary files /dev/null and b/core/__pycache__/soulseek_client.cpython-312.pyc differ diff --git a/core/__pycache__/spotify_client.cpython-312.pyc b/core/__pycache__/spotify_client.cpython-312.pyc new file mode 100644 index 00000000..1cb01d08 Binary files /dev/null and b/core/__pycache__/spotify_client.cpython-312.pyc differ diff --git a/core/matching_engine.py b/core/matching_engine.py new file mode 100644 index 00000000..e8f14193 --- /dev/null +++ b/core/matching_engine.py @@ -0,0 +1,223 @@ +from typing import List, Optional, Dict, Any, Tuple +import re +from dataclasses import dataclass +from difflib import SequenceMatcher +from utils.logging_config import get_logger +from core.spotify_client import Track as SpotifyTrack +from core.plex_client import PlexTrackInfo + +logger = get_logger("matching_engine") + +@dataclass +class MatchResult: + spotify_track: SpotifyTrack + plex_track: Optional[PlexTrackInfo] + confidence: float + match_type: str + + @property + def is_match(self) -> bool: + return self.plex_track is not None and self.confidence >= 0.7 + +class MusicMatchingEngine: + def __init__(self): + self.title_patterns = [ + r'\(.*?\)', + r'\[.*?\]', + r'\s*-\s*remaster.*', + r'\s*-\s*remix.*', + r'\s*-\s*live.*', + r'\s*-\s*acoustic.*', + r'\s*feat\..*', + r'\s*ft\..*', + r'\s*featuring.*', + ] + + self.artist_patterns = [ + r'\s*feat\..*', + r'\s*ft\..*', + r'\s*featuring.*', + r'\s*&.*', + r'\s*and.*', + ] + + def normalize_string(self, text: str) -> str: + if not text: + return "" + + text = text.lower().strip() + + text = re.sub(r'[^\w\s]', '', text) + + text = re.sub(r'\s+', ' ', text) + + return text + + def clean_title(self, title: str) -> str: + cleaned = title + + for pattern in self.title_patterns: + cleaned = re.sub(pattern, '', cleaned, flags=re.IGNORECASE) + + return self.normalize_string(cleaned) + + def clean_artist(self, artist: str) -> str: + cleaned = artist + + for pattern in self.artist_patterns: + cleaned = re.sub(pattern, '', cleaned, flags=re.IGNORECASE) + + return self.normalize_string(cleaned) + + def extract_main_artist(self, artists: List[str]) -> str: + if not artists: + return "" + + main_artist = artists[0] + return self.clean_artist(main_artist) + + def similarity_score(self, str1: str, str2: str) -> float: + if not str1 or not str2: + return 0.0 + + return SequenceMatcher(None, str1, str2).ratio() + + def duration_similarity(self, duration1: int, duration2: int) -> float: + if duration1 == 0 or duration2 == 0: + return 0.5 + + max_duration = max(duration1, duration2) + min_duration = min(duration1, duration2) + + if max_duration == 0: + return 0.5 + + diff_ratio = abs(max_duration - min_duration) / max_duration + + if diff_ratio <= 0.05: + return 1.0 + elif diff_ratio <= 0.1: + return 0.8 + elif diff_ratio <= 0.2: + return 0.6 + else: + return 0.3 + + def calculate_match_confidence(self, spotify_track: SpotifyTrack, plex_track: PlexTrackInfo) -> Tuple[float, str]: + spotify_title = self.clean_title(spotify_track.name) + plex_title = self.clean_title(plex_track.title) + + spotify_artist = self.extract_main_artist(spotify_track.artists) + plex_artist = self.clean_artist(plex_track.artist) + + spotify_album = self.normalize_string(spotify_track.album) + plex_album = self.normalize_string(plex_track.album) + + title_score = self.similarity_score(spotify_title, plex_title) + artist_score = self.similarity_score(spotify_artist, plex_artist) + album_score = self.similarity_score(spotify_album, plex_album) + + duration_score = self.duration_similarity( + spotify_track.duration_ms, + plex_track.duration * 1000 if plex_track.duration else 0 + ) + + if title_score >= 0.9 and artist_score >= 0.9 and album_score >= 0.8: + return 0.95, "exact_match" + elif title_score >= 0.8 and artist_score >= 0.8: + return 0.85, "high_confidence" + elif title_score >= 0.7 and artist_score >= 0.7: + return 0.75, "medium_confidence" + elif title_score >= 0.6 and artist_score >= 0.6: + return 0.65, "low_confidence" + else: + return 0.0, "no_match" + + def find_best_match(self, spotify_track: SpotifyTrack, plex_tracks: List[PlexTrackInfo]) -> MatchResult: + best_match = None + best_confidence = 0.0 + best_match_type = "no_match" + + for plex_track in plex_tracks: + confidence, match_type = self.calculate_match_confidence(spotify_track, plex_track) + + if confidence > best_confidence: + best_confidence = confidence + best_match = plex_track + best_match_type = match_type + + return MatchResult( + spotify_track=spotify_track, + plex_track=best_match, + confidence=best_confidence, + match_type=best_match_type + ) + + def match_playlist_tracks(self, spotify_tracks: List[SpotifyTrack], plex_tracks: List[PlexTrackInfo]) -> List[MatchResult]: + results = [] + + logger.info(f"Matching {len(spotify_tracks)} Spotify tracks against {len(plex_tracks)} Plex tracks") + + for spotify_track in spotify_tracks: + match_result = self.find_best_match(spotify_track, plex_tracks) + results.append(match_result) + + if match_result.is_match: + logger.debug(f"Matched: {spotify_track.name} by {spotify_track.artists[0]} -> {match_result.plex_track.title} (confidence: {match_result.confidence:.2f})") + else: + logger.debug(f"No match found for: {spotify_track.name} by {spotify_track.artists[0]}") + + matched_count = sum(1 for r in results if r.is_match) + logger.info(f"Successfully matched {matched_count}/{len(spotify_tracks)} tracks") + + return results + + def get_match_statistics(self, match_results: List[MatchResult]) -> Dict[str, Any]: + total_tracks = len(match_results) + matched_tracks = sum(1 for r in match_results if r.is_match) + + match_types = {} + for result in match_results: + if result.is_match: + match_types[result.match_type] = match_types.get(result.match_type, 0) + 1 + + confidence_distribution = { + "high (>0.8)": sum(1 for r in match_results if r.confidence > 0.8), + "medium (0.7-0.8)": sum(1 for r in match_results if 0.7 <= r.confidence <= 0.8), + "low (0.6-0.7)": sum(1 for r in match_results if 0.6 <= r.confidence < 0.7), + "no_match (<0.6)": sum(1 for r in match_results if r.confidence < 0.6) + } + + return { + "total_tracks": total_tracks, + "matched_tracks": matched_tracks, + "match_percentage": (matched_tracks / total_tracks * 100) if total_tracks > 0 else 0, + "match_types": match_types, + "confidence_distribution": confidence_distribution + } + + def create_search_queries(self, spotify_track: SpotifyTrack) -> List[str]: + queries = [] + + main_artist = self.extract_main_artist(spotify_track.artists) + clean_title = self.clean_title(spotify_track.name) + clean_album = self.normalize_string(spotify_track.album) + + queries.append(f"{clean_title} {main_artist}") + queries.append(f"{main_artist} {clean_title}") + queries.append(f"{clean_title} {main_artist} {clean_album}") + queries.append(f"{clean_album} {main_artist}") + + if len(spotify_track.artists) > 1: + all_artists = " ".join([self.clean_artist(a) for a in spotify_track.artists]) + queries.append(f"{clean_title} {all_artists}") + + return queries + + def generate_download_query(self, spotify_track: SpotifyTrack) -> str: + main_artist = self.extract_main_artist(spotify_track.artists) + clean_title = self.clean_title(spotify_track.name) + + return f"{main_artist} {clean_title}" + +matching_engine = MusicMatchingEngine() \ No newline at end of file diff --git a/core/plex_client.py b/core/plex_client.py new file mode 100644 index 00000000..5585d78b --- /dev/null +++ b/core/plex_client.py @@ -0,0 +1,275 @@ +from plexapi.server import PlexServer +from plexapi.library import LibrarySection, MusicSection +from plexapi.audio import Track as PlexTrack, Album as PlexAlbum, Artist as PlexArtist +from plexapi.playlist import Playlist as PlexPlaylist +from plexapi.exceptions import PlexApiException, NotFound +from typing import List, Optional, Dict, Any +from dataclasses import dataclass +import requests +from utils.logging_config import get_logger +from config.settings import config_manager + +logger = get_logger("plex_client") + +@dataclass +class PlexTrackInfo: + id: str + title: str + artist: str + album: str + duration: int + track_number: Optional[int] = None + year: Optional[int] = None + rating: Optional[float] = None + + @classmethod + def from_plex_track(cls, track: PlexTrack) -> 'PlexTrackInfo': + return cls( + id=str(track.ratingKey), + title=track.title, + artist=track.artist().title if track.artist() else "Unknown Artist", + album=track.album().title if track.album() else "Unknown Album", + duration=track.duration, + track_number=track.trackNumber, + year=track.year, + rating=track.userRating + ) + +@dataclass +class PlexPlaylistInfo: + id: str + title: str + description: Optional[str] + duration: int + leaf_count: int + tracks: List[PlexTrackInfo] + + @classmethod + def from_plex_playlist(cls, playlist: PlexPlaylist) -> 'PlexPlaylistInfo': + tracks = [] + for item in playlist.items(): + if isinstance(item, PlexTrack): + tracks.append(PlexTrackInfo.from_plex_track(item)) + + return cls( + id=str(playlist.ratingKey), + title=playlist.title, + description=playlist.summary, + duration=playlist.duration, + leaf_count=playlist.leafCount, + tracks=tracks + ) + +class PlexClient: + def __init__(self): + self.server: Optional[PlexServer] = None + self.music_library: Optional[MusicSection] = None + self._setup_client() + + def _setup_client(self): + config = config_manager.get_plex_config() + + if not config.get('base_url'): + logger.warning("Plex server URL not configured") + return + + try: + if config.get('token'): + self.server = PlexServer(config['base_url'], config['token']) + else: + logger.error("Plex token not configured") + return + + self._find_music_library() + logger.info(f"Successfully connected to Plex server: {self.server.friendlyName}") + + except Exception as e: + logger.error(f"Failed to connect to Plex server: {e}") + self.server = None + + def _find_music_library(self): + if not self.server: + return + + try: + for section in self.server.library.sections(): + if section.type == 'artist': + self.music_library = section + logger.info(f"Found music library: {section.title}") + break + + if not self.music_library: + logger.warning("No music library found on Plex server") + + except Exception as e: + logger.error(f"Error finding music library: {e}") + + def is_connected(self) -> bool: + return self.server is not None and self.music_library is not None + + def get_all_playlists(self) -> List[PlexPlaylistInfo]: + if not self.is_connected(): + logger.error("Not connected to Plex server") + return [] + + playlists = [] + + try: + for playlist in self.server.playlists(): + if playlist.playlistType == 'audio': + playlist_info = PlexPlaylistInfo.from_plex_playlist(playlist) + playlists.append(playlist_info) + + logger.info(f"Retrieved {len(playlists)} audio playlists") + return playlists + + except Exception as e: + logger.error(f"Error fetching playlists: {e}") + return [] + + def get_playlist_by_name(self, name: str) -> Optional[PlexPlaylistInfo]: + if not self.is_connected(): + return None + + try: + playlist = self.server.playlist(name) + if playlist.playlistType == 'audio': + return PlexPlaylistInfo.from_plex_playlist(playlist) + return None + + except NotFound: + logger.info(f"Playlist '{name}' not found") + return None + except Exception as e: + logger.error(f"Error fetching playlist '{name}': {e}") + return None + + def create_playlist(self, name: str, tracks: List[PlexTrackInfo]) -> bool: + if not self.is_connected(): + logger.error("Not connected to Plex server") + return False + + try: + plex_tracks = [] + for track_info in tracks: + plex_track = self._find_track(track_info.title, track_info.artist, track_info.album) + if plex_track: + plex_tracks.append(plex_track) + else: + logger.warning(f"Track not found in Plex: {track_info.title} by {track_info.artist}") + + if plex_tracks: + playlist = self.server.createPlaylist(name, plex_tracks) + logger.info(f"Created playlist '{name}' with {len(plex_tracks)} tracks") + return True + else: + logger.error(f"No tracks found for playlist '{name}'") + return False + + except Exception as e: + logger.error(f"Error creating playlist '{name}': {e}") + return False + + def update_playlist(self, playlist_name: str, tracks: List[PlexTrackInfo]) -> bool: + if not self.is_connected(): + return False + + try: + existing_playlist = self.server.playlist(playlist_name) + existing_playlist.delete() + + return self.create_playlist(playlist_name, tracks) + + except NotFound: + logger.info(f"Playlist '{playlist_name}' not found, creating new one") + return self.create_playlist(playlist_name, tracks) + except Exception as e: + logger.error(f"Error updating playlist '{playlist_name}': {e}") + return False + + def _find_track(self, title: str, artist: str, album: str) -> Optional[PlexTrack]: + if not self.music_library: + return None + + try: + search_results = self.music_library.search(title=title, artist=artist, album=album) + + for result in search_results: + if isinstance(result, PlexTrack): + if (result.title.lower() == title.lower() and + result.artist().title.lower() == artist.lower() and + result.album().title.lower() == album.lower()): + return result + + broader_search = self.music_library.search(title=title, artist=artist) + for result in broader_search: + if isinstance(result, PlexTrack): + if (result.title.lower() == title.lower() and + result.artist().title.lower() == artist.lower()): + return result + + return None + + except Exception as e: + logger.error(f"Error searching for track '{title}' by '{artist}': {e}") + return None + + def search_tracks(self, query: str, limit: int = 20) -> List[PlexTrackInfo]: + if not self.music_library: + return [] + + try: + results = self.music_library.search(query, limit=limit) + tracks = [] + + for result in results: + if isinstance(result, PlexTrack): + tracks.append(PlexTrackInfo.from_plex_track(result)) + + return tracks + + except Exception as e: + logger.error(f"Error searching tracks: {e}") + return [] + + def get_library_stats(self) -> Dict[str, int]: + if not self.music_library: + return {} + + try: + return { + 'artists': len(self.music_library.searchArtists()), + 'albums': len(self.music_library.searchAlbums()), + 'tracks': len(self.music_library.searchTracks()) + } + except Exception as e: + logger.error(f"Error getting library stats: {e}") + return {} + + def update_track_metadata(self, track_id: str, metadata: Dict[str, Any]) -> bool: + if not self.is_connected(): + return False + + try: + track = self.server.fetchItem(int(track_id)) + if isinstance(track, PlexTrack): + edits = {} + if 'title' in metadata: + edits['title'] = metadata['title'] + if 'artist' in metadata: + edits['artist'] = metadata['artist'] + if 'album' in metadata: + edits['album'] = metadata['album'] + if 'year' in metadata: + edits['year'] = metadata['year'] + + if edits: + track.edit(**edits) + logger.info(f"Updated metadata for track: {track.title}") + return True + + return False + + except Exception as e: + logger.error(f"Error updating track metadata: {e}") + return False \ No newline at end of file diff --git a/core/soulseek_client.py b/core/soulseek_client.py new file mode 100644 index 00000000..52679708 --- /dev/null +++ b/core/soulseek_client.py @@ -0,0 +1,300 @@ +import requests +import asyncio +import aiohttp +from typing import List, Optional, Dict, Any +from dataclasses import dataclass +import time +from pathlib import Path +from utils.logging_config import get_logger +from config.settings import config_manager + +logger = get_logger("soulseek_client") + +@dataclass +class SearchResult: + username: str + filename: str + size: int + bitrate: Optional[int] + duration: Optional[int] + quality: str + free_upload_slots: int + upload_speed: int + queue_length: int + + @property + def quality_score(self) -> float: + quality_weights = { + 'flac': 1.0, + 'mp3': 0.8, + 'ogg': 0.7, + 'aac': 0.6, + 'wma': 0.5 + } + + base_score = quality_weights.get(self.quality.lower(), 0.3) + + if self.bitrate: + if self.bitrate >= 320: + base_score += 0.2 + elif self.bitrate >= 256: + base_score += 0.1 + elif self.bitrate < 128: + base_score -= 0.2 + + 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) + +@dataclass +class DownloadStatus: + id: str + filename: str + username: str + state: str + progress: float + size: int + transferred: int + speed: int + time_remaining: Optional[int] = None + +class SoulseekClient: + def __init__(self): + self.base_url: Optional[str] = None + self.api_key: Optional[str] = None + self.session: Optional[aiohttp.ClientSession] = None + self.download_path: Path = Path("./downloads") + self._setup_client() + + def _setup_client(self): + config = config_manager.get_soulseek_config() + + if not config.get('slskd_url'): + logger.warning("Soulseek slskd URL not configured") + return + + self.base_url = config['slskd_url'].rstrip('/') + self.api_key = config.get('api_key', '') + self.download_path = Path(config.get('download_path', './downloads')) + self.download_path.mkdir(parents=True, exist_ok=True) + + logger.info(f"Soulseek client configured with slskd at {self.base_url}") + + def _get_headers(self) -> Dict[str, str]: + headers = {'Content-Type': 'application/json'} + if self.api_key: + headers['X-API-Key'] = self.api_key + return headers + + async def _make_request(self, method: str, endpoint: str, **kwargs) -> Optional[Dict[str, Any]]: + if not self.base_url: + logger.error("Soulseek client not configured") + return None + + url = f"{self.base_url}/api/v0/{endpoint}" + + if not self.session: + self.session = aiohttp.ClientSession() + + try: + async with self.session.request( + method, + url, + headers=self._get_headers(), + **kwargs + ) as response: + if response.status == 200: + return await response.json() + else: + logger.error(f"API request failed: {response.status} - {await response.text()}") + return None + + except Exception as e: + logger.error(f"Error making API request: {e}") + return None + + async def search(self, query: str, timeout: int = 30) -> List[SearchResult]: + if not self.base_url: + logger.error("Soulseek client not configured") + return [] + + try: + search_data = { + 'searchText': query, + 'timeout': timeout * 1000, + 'filterResponses': True, + 'minimumResponseFileCount': 1, + 'minimumPeerUploadSpeed': 0 + } + + response = await self._make_request('POST', 'searches', json=search_data) + if not response: + return [] + + search_id = response.get('id') + if not search_id: + logger.error("No search ID returned") + return [] + + await asyncio.sleep(timeout) + + results_response = await self._make_request('GET', f'searches/{search_id}') + if not results_response: + return [] + + search_results = [] + for response_data in results_response.get('responses', []): + username = response_data.get('username', '') + + for file_data in response_data.get('files', []): + filename = file_data.get('filename', '') + size = file_data.get('size', 0) + + file_ext = Path(filename).suffix.lower().lstrip('.') + quality = file_ext if file_ext in ['flac', 'mp3', 'ogg', 'aac', 'wma'] else 'unknown' + + result = SearchResult( + username=username, + filename=filename, + size=size, + bitrate=file_data.get('bitRate'), + duration=file_data.get('length'), + quality=quality, + free_upload_slots=response_data.get('freeUploadSlots', 0), + upload_speed=response_data.get('uploadSpeed', 0), + queue_length=response_data.get('queueLength', 0) + ) + search_results.append(result) + + search_results.sort(key=lambda x: x.quality_score, reverse=True) + logger.info(f"Found {len(search_results)} results for query: {query}") + return search_results + + except Exception as e: + logger.error(f"Error searching: {e}") + return [] + + async def download(self, username: str, filename: str) -> Optional[str]: + if not self.base_url: + logger.error("Soulseek client not configured") + return None + + try: + download_data = { + 'username': username, + 'files': [filename] + } + + response = await self._make_request('POST', 'transfers/downloads', json=download_data) + if response: + logger.info(f"Started download: {filename} from {username}") + return filename + + return None + + except Exception as e: + logger.error(f"Error starting download: {e}") + return None + + async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]: + if not self.base_url: + return None + + try: + response = await self._make_request('GET', f'transfers/downloads/{download_id}') + if not response: + return None + + return DownloadStatus( + id=response.get('id', ''), + filename=response.get('filename', ''), + username=response.get('username', ''), + state=response.get('state', ''), + progress=response.get('percentComplete', 0.0), + size=response.get('size', 0), + transferred=response.get('bytesTransferred', 0), + speed=response.get('averageSpeed', 0), + time_remaining=response.get('timeRemaining') + ) + + except Exception as e: + logger.error(f"Error getting download status: {e}") + return None + + async def get_all_downloads(self) -> List[DownloadStatus]: + if not self.base_url: + return [] + + try: + response = await self._make_request('GET', 'transfers/downloads') + if not response: + return [] + + downloads = [] + for download_data in response: + status = DownloadStatus( + id=download_data.get('id', ''), + filename=download_data.get('filename', ''), + username=download_data.get('username', ''), + state=download_data.get('state', ''), + progress=download_data.get('percentComplete', 0.0), + size=download_data.get('size', 0), + transferred=download_data.get('bytesTransferred', 0), + speed=download_data.get('averageSpeed', 0), + time_remaining=download_data.get('timeRemaining') + ) + downloads.append(status) + + return downloads + + except Exception as e: + logger.error(f"Error getting downloads: {e}") + return [] + + async def cancel_download(self, download_id: str) -> bool: + if not self.base_url: + return False + + try: + response = await self._make_request('DELETE', f'transfers/downloads/{download_id}') + return response is not None + + except Exception as e: + logger.error(f"Error cancelling download: {e}") + return False + + async def search_and_download_best(self, query: str, preferred_quality: str = 'flac') -> Optional[str]: + results = await self.search(query) + + if not results: + logger.warning(f"No results found for: {query}") + return None + + preferred_results = [r for r in results if r.quality.lower() == preferred_quality.lower()] + + if preferred_results: + best_result = preferred_results[0] + else: + best_result = results[0] + logger.info(f"Preferred quality {preferred_quality} not found, using {best_result.quality}") + + logger.info(f"Downloading: {best_result.filename} ({best_result.quality}) from {best_result.username}") + return await self.download(best_result.username, best_result.filename) + + async def close(self): + if self.session: + await self.session.close() + + def __del__(self): + if self.session and not self.session.closed: + try: + asyncio.get_event_loop().run_until_complete(self.session.close()) + except: + pass \ No newline at end of file diff --git a/core/spotify_client.py b/core/spotify_client.py new file mode 100644 index 00000000..1959c757 --- /dev/null +++ b/core/spotify_client.py @@ -0,0 +1,194 @@ +import spotipy +from spotipy.oauth2 import SpotifyOAuth, SpotifyClientCredentials +from typing import Dict, List, Optional, Any +import time +from dataclasses import dataclass +from utils.logging_config import get_logger +from config.settings import config_manager + +logger = get_logger("spotify_client") + +@dataclass +class Track: + id: str + name: str + artists: List[str] + album: str + duration_ms: int + popularity: int + preview_url: Optional[str] = None + external_urls: Optional[Dict[str, str]] = None + + @classmethod + def from_spotify_track(cls, track_data: Dict[str, Any]) -> 'Track': + return cls( + id=track_data['id'], + name=track_data['name'], + artists=[artist['name'] for artist in track_data['artists']], + album=track_data['album']['name'], + duration_ms=track_data['duration_ms'], + popularity=track_data['popularity'], + preview_url=track_data.get('preview_url'), + external_urls=track_data.get('external_urls') + ) + +@dataclass +class Playlist: + id: str + name: str + description: Optional[str] + owner: str + public: bool + collaborative: bool + tracks: List[Track] + total_tracks: int + + @classmethod + def from_spotify_playlist(cls, playlist_data: Dict[str, Any], tracks: List[Track]) -> 'Playlist': + return cls( + id=playlist_data['id'], + name=playlist_data['name'], + description=playlist_data.get('description'), + owner=playlist_data['owner']['display_name'], + public=playlist_data['public'], + collaborative=playlist_data['collaborative'], + tracks=tracks, + total_tracks=playlist_data['tracks']['total'] + ) + +class SpotifyClient: + def __init__(self): + self.sp: Optional[spotipy.Spotify] = None + self.user_id: Optional[str] = None + self._setup_client() + + def _setup_client(self): + config = config_manager.get_spotify_config() + + if not config.get('client_id') or not config.get('client_secret'): + logger.warning("Spotify credentials not configured") + return + + try: + auth_manager = SpotifyOAuth( + client_id=config['client_id'], + client_secret=config['client_secret'], + redirect_uri="http://localhost:8888/callback", + scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email", + cache_path='.spotify_cache' + ) + + self.sp = spotipy.Spotify(auth_manager=auth_manager) + user_info = self.sp.current_user() + self.user_id = user_info['id'] + logger.info(f"Successfully authenticated with Spotify as {user_info['display_name']}") + + except Exception as e: + logger.error(f"Failed to authenticate with Spotify: {e}") + self.sp = None + + def is_authenticated(self) -> bool: + return self.sp is not None and self.user_id is not None + + def get_user_playlists(self) -> List[Playlist]: + if not self.is_authenticated(): + logger.error("Not authenticated with Spotify") + return [] + + playlists = [] + + try: + results = self.sp.current_user_playlists(limit=50) + + while results: + for playlist_data in results['items']: + if playlist_data['owner']['id'] == self.user_id or playlist_data['collaborative']: + logger.info(f"Fetching tracks for playlist: {playlist_data['name']}") + tracks = self._get_playlist_tracks(playlist_data['id']) + playlist = Playlist.from_spotify_playlist(playlist_data, tracks) + playlists.append(playlist) + + results = self.sp.next(results) if results['next'] else None + + logger.info(f"Retrieved {len(playlists)} playlists") + return playlists + + except Exception as e: + logger.error(f"Error fetching user playlists: {e}") + return [] + + def _get_playlist_tracks(self, playlist_id: str) -> List[Track]: + if not self.is_authenticated(): + return [] + + tracks = [] + + try: + results = self.sp.playlist_tracks(playlist_id, limit=100) + + while results: + for item in results['items']: + if item['track'] and item['track']['id']: + track = Track.from_spotify_track(item['track']) + tracks.append(track) + + results = self.sp.next(results) if results['next'] else None + + return tracks + + except Exception as e: + logger.error(f"Error fetching playlist tracks: {e}") + return [] + + def get_playlist_by_id(self, playlist_id: str) -> Optional[Playlist]: + if not self.is_authenticated(): + return None + + try: + playlist_data = self.sp.playlist(playlist_id) + tracks = self._get_playlist_tracks(playlist_id) + return Playlist.from_spotify_playlist(playlist_data, tracks) + + except Exception as e: + logger.error(f"Error fetching playlist {playlist_id}: {e}") + return None + + def search_tracks(self, query: str, limit: int = 20) -> List[Track]: + if not self.is_authenticated(): + return [] + + try: + results = self.sp.search(q=query, type='track', limit=limit) + tracks = [] + + for track_data in results['tracks']['items']: + track = Track.from_spotify_track(track_data) + tracks.append(track) + + return tracks + + except Exception as e: + logger.error(f"Error searching tracks: {e}") + return [] + + def get_track_features(self, track_id: str) -> Optional[Dict[str, Any]]: + if not self.is_authenticated(): + return None + + try: + features = self.sp.audio_features(track_id) + return features[0] if features else None + + except Exception as e: + logger.error(f"Error fetching track features: {e}") + return None + + def get_user_info(self) -> Optional[Dict[str, Any]]: + if not self.is_authenticated(): + return None + + try: + return self.sp.current_user() + except Exception as e: + logger.error(f"Error fetching user info: {e}") + return None \ No newline at end of file diff --git a/logs/app.log b/logs/app.log new file mode 100644 index 00000000..4be0660c --- /dev/null +++ b/logs/app.log @@ -0,0 +1,63 @@ +2025-07-09 11:36:49 - newmusic - INFO - setup_logging:57 - Logging initialized with level: DEBUG +2025-07-09 11:36:49 - newmusic.main - INFO - main:261 - Starting NewMusic application +2025-07-09 11:36:55 - newmusic.spotify_client - ERROR - _setup_client:87 - Failed to authenticate with Spotify: Server listening on localhost has not been accessed +2025-07-09 11:36:55 - newmusic.plex_client - INFO - _find_music_library:98 - Found music library: Music +2025-07-09 11:36:55 - newmusic.plex_client - INFO - _setup_client:84 - Successfully connected to Plex server: PLEX-MACHINE +2025-07-09 11:36:55 - newmusic.soulseek_client - INFO - _setup_client:88 - Soulseek client configured with slskd at http://localhost:5030 +2025-07-09 11:40:35 - newmusic - INFO - setup_logging:57 - Logging initialized with level: DEBUG +2025-07-09 11:40:35 - newmusic.main - INFO - main:261 - Starting NewMusic application +2025-07-09 11:40:38 - newmusic.spotify_client - ERROR - _setup_client:87 - Failed to authenticate with Spotify: error: upstream connect error or disconnect/reset before headers. reset reason: connection termination, error_description: None +2025-07-09 11:40:38 - newmusic.plex_client - INFO - _find_music_library:98 - Found music library: Music +2025-07-09 11:40:38 - newmusic.plex_client - INFO - _setup_client:84 - Successfully connected to Plex server: PLEX-MACHINE +2025-07-09 11:40:38 - newmusic.soulseek_client - INFO - _setup_client:88 - Soulseek client configured with slskd at http://localhost:5030 +2025-07-09 11:42:17 - newmusic - INFO - setup_logging:57 - Logging initialized with level: DEBUG +2025-07-09 11:42:17 - newmusic.main - INFO - main:261 - Starting NewMusic application +2025-07-09 11:42:18 - newmusic.spotify_client - INFO - _setup_client:84 - Successfully authenticated with Spotify as broquethomas +2025-07-09 11:42:18 - newmusic.plex_client - INFO - _find_music_library:98 - Found music library: Music +2025-07-09 11:42:18 - newmusic.plex_client - INFO - _setup_client:84 - Successfully connected to Plex server: PLEX-MACHINE +2025-07-09 11:42:18 - newmusic.soulseek_client - INFO - _setup_client:88 - Soulseek client configured with slskd at http://localhost:5030 +2025-07-09 11:42:24 - newmusic.spotify_client - INFO - get_user_playlists:106 - Fetching tracks for playlist: Aether +2025-07-09 11:42:24 - newmusic.spotify_client - INFO - get_user_playlists:106 - Fetching tracks for playlist: Favorite Artists +2025-07-09 11:42:26 - newmusic.spotify_client - INFO - get_user_playlists:106 - Fetching tracks for playlist: Brittnea +2025-07-09 11:42:27 - newmusic.spotify_client - INFO - get_user_playlists:106 - Fetching tracks for playlist: Baleigh +2025-07-09 11:42:29 - newmusic.spotify_client - INFO - get_user_playlists:106 - Fetching tracks for playlist: Extra Music +2025-07-09 11:42:29 - newmusic.spotify_client - INFO - get_user_playlists:106 - Fetching tracks for playlist: Maggi Main +2025-07-09 11:42:34 - newmusic.spotify_client - INFO - get_user_playlists:106 - Fetching tracks for playlist: Broque Main +2025-07-09 11:42:42 - newmusic.spotify_client - INFO - get_user_playlists:113 - Retrieved 7 playlists +2025-07-09 12:00:42 - newmusic - INFO - setup_logging:57 - Logging initialized with level: DEBUG +2025-07-09 12:00:42 - newmusic.main - INFO - main:173 - Starting NewMusic application +2025-07-09 12:00:42 - newmusic.spotify_client - INFO - _setup_client:84 - Successfully authenticated with Spotify as broquethomas +2025-07-09 12:00:42 - newmusic.plex_client - INFO - _find_music_library:98 - Found music library: Music +2025-07-09 12:00:42 - newmusic.plex_client - INFO - _setup_client:84 - Successfully connected to Plex server: PLEX-MACHINE +2025-07-09 12:00:42 - newmusic.soulseek_client - INFO - _setup_client:88 - Soulseek client configured with slskd at http://localhost:5030 +2025-07-09 12:01:02 - newmusic - INFO - setup_logging:57 - Logging initialized with level: DEBUG +2025-07-09 12:01:02 - newmusic.main - INFO - main:173 - Starting NewMusic application +2025-07-09 12:01:03 - newmusic.spotify_client - INFO - _setup_client:84 - Successfully authenticated with Spotify as broquethomas +2025-07-09 12:01:03 - newmusic.plex_client - INFO - _find_music_library:98 - Found music library: Music +2025-07-09 12:01:03 - newmusic.plex_client - INFO - _setup_client:84 - Successfully connected to Plex server: PLEX-MACHINE +2025-07-09 12:01:03 - newmusic.soulseek_client - INFO - _setup_client:88 - Soulseek client configured with slskd at http://localhost:5030 +2025-07-09 12:01:09 - newmusic - INFO - setup_logging:57 - Logging initialized with level: DEBUG +2025-07-09 12:01:09 - newmusic.main - INFO - main:173 - Starting NewMusic application +2025-07-09 12:01:09 - newmusic.spotify_client - INFO - _setup_client:84 - Successfully authenticated with Spotify as broquethomas +2025-07-09 12:01:09 - newmusic.plex_client - INFO - _find_music_library:98 - Found music library: Music +2025-07-09 12:01:09 - newmusic.plex_client - INFO - _setup_client:84 - Successfully connected to Plex server: PLEX-MACHINE +2025-07-09 12:01:09 - newmusic.soulseek_client - INFO - _setup_client:88 - Soulseek client configured with slskd at http://localhost:5030 +2025-07-09 12:03:25 - newmusic - INFO - setup_logging:57 - Logging initialized with level: DEBUG +2025-07-09 12:03:25 - newmusic.main - INFO - main:173 - Starting NewMusic application +2025-07-09 12:03:25 - newmusic.spotify_client - INFO - _setup_client:84 - Successfully authenticated with Spotify as broquethomas +2025-07-09 12:03:25 - newmusic.plex_client - INFO - _find_music_library:98 - Found music library: Music +2025-07-09 12:03:25 - newmusic.plex_client - INFO - _setup_client:84 - Successfully connected to Plex server: PLEX-MACHINE +2025-07-09 12:03:25 - newmusic.soulseek_client - INFO - _setup_client:88 - Soulseek client configured with slskd at http://localhost:5030 +2025-07-09 12:03:26 - newmusic.main - INFO - change_page:139 - Changed to page: dashboard +2025-07-09 12:03:32 - newmusic.main - INFO - change_page:139 - Changed to page: sync +2025-07-09 12:03:33 - newmusic.main - INFO - change_page:139 - Changed to page: downloads +2025-07-09 12:03:34 - newmusic.main - INFO - change_page:139 - Changed to page: artists +2025-07-09 12:03:34 - newmusic.main - INFO - change_page:139 - Changed to page: settings +2025-07-09 12:03:35 - newmusic.main - INFO - change_page:139 - Changed to page: dashboard +2025-07-09 12:06:27 - newmusic.main - INFO - change_page:139 - Changed to page: settings +2025-07-09 12:06:28 - newmusic.main - INFO - change_page:139 - Changed to page: sync +2025-07-09 12:06:28 - newmusic.main - INFO - change_page:139 - Changed to page: dashboard +2025-07-09 12:06:29 - newmusic.main - INFO - change_page:139 - Changed to page: sync +2025-07-09 12:06:29 - newmusic.main - INFO - change_page:139 - Changed to page: downloads +2025-07-09 12:06:30 - newmusic.main - INFO - change_page:139 - Changed to page: dashboard +2025-07-09 12:06:56 - newmusic.main - INFO - change_page:139 - Changed to page: settings diff --git a/logs/newmusic.log b/logs/newmusic.log new file mode 100644 index 00000000..d2293c5b --- /dev/null +++ b/logs/newmusic.log @@ -0,0 +1,10 @@ +2025-07-08 23:06:53 - newmusic - INFO - setup_logging:57 - Logging initialized with level: INFO +2025-07-08 23:06:53 - newmusic.main - INFO - main:259 - Starting NewMusic application +2025-07-08 23:06:53 - newmusic.spotify_client - WARNING - _setup_client:69 - Spotify credentials not configured +2025-07-08 23:06:53 - newmusic.plex_client - WARNING - _setup_client:73 - Plex server URL not configured +2025-07-08 23:06:53 - newmusic.soulseek_client - INFO - _setup_client:88 - Soulseek client configured with slskd at http://localhost:5030 +2025-07-09 11:26:03 - newmusic - INFO - setup_logging:57 - Logging initialized with level: INFO +2025-07-09 11:26:03 - newmusic.main - INFO - main:259 - Starting NewMusic application +2025-07-09 11:26:03 - newmusic.spotify_client - WARNING - _setup_client:69 - Spotify credentials not configured +2025-07-09 11:26:03 - newmusic.plex_client - WARNING - _setup_client:73 - Plex server URL not configured +2025-07-09 11:26:03 - newmusic.soulseek_client - INFO - _setup_client:88 - Soulseek client configured with slskd at http://localhost:5030 diff --git a/main.py b/main.py new file mode 100644 index 00000000..c11c1a03 --- /dev/null +++ b/main.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 + +import sys +import asyncio +from pathlib import Path +from PyQt6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QHBoxLayout, QWidget, QStackedWidget +from PyQt6.QtCore import QThread, pyqtSignal, QTimer +from PyQt6.QtGui import QFont, QPalette, QColor + +from config.settings import config_manager +from utils.logging_config import setup_logging, get_logger +from core.spotify_client import SpotifyClient +from core.plex_client import PlexClient +from core.soulseek_client import SoulseekClient + +from ui.sidebar import ModernSidebar +from ui.pages.dashboard import DashboardPage +from ui.pages.sync import SyncPage +from ui.pages.downloads import DownloadsPage +from ui.pages.artists import ArtistsPage +from ui.pages.settings import SettingsPage + +logger = get_logger("main") + +class ServiceStatusThread(QThread): + status_updated = pyqtSignal(str, bool) + + def __init__(self, spotify_client, plex_client, soulseek_client): + super().__init__() + self.spotify_client = spotify_client + self.plex_client = plex_client + self.soulseek_client = soulseek_client + self.running = True + + def run(self): + while self.running: + try: + # Check Spotify authentication + spotify_status = self.spotify_client.is_authenticated() + self.status_updated.emit("spotify", spotify_status) + + # Check Plex connection + plex_status = self.plex_client.is_connected() + self.status_updated.emit("plex", plex_status) + + # Check Soulseek connection + soulseek_status = self.soulseek_client.base_url is not None + self.status_updated.emit("soulseek", soulseek_status) + + self.msleep(3000) # Check every 3 seconds + + except Exception as e: + logger.error(f"Error checking service status: {e}") + self.msleep(5000) + + def stop(self): + self.running = False + self.quit() + self.wait() + +class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + self.spotify_client = SpotifyClient() + self.plex_client = PlexClient() + self.soulseek_client = SoulseekClient() + + self.status_thread = None + self.init_ui() + self.setup_status_monitoring() + + def init_ui(self): + self.setWindowTitle("NewMusic - Music Sync & Manager") + self.setGeometry(100, 100, 1400, 900) + + # Set dark theme palette + self.setStyleSheet(""" + QMainWindow { + background: #121212; + } + """) + + # Create central widget + central_widget = QWidget() + self.setCentralWidget(central_widget) + + # Main layout + main_layout = QHBoxLayout(central_widget) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + + # Create sidebar + self.sidebar = ModernSidebar() + self.sidebar.page_changed.connect(self.change_page) + main_layout.addWidget(self.sidebar) + + # Create stacked widget for pages + self.stacked_widget = QStackedWidget() + + # Create and add pages + self.dashboard_page = DashboardPage() + self.sync_page = SyncPage() + self.downloads_page = DownloadsPage() + self.artists_page = ArtistsPage() + self.settings_page = SettingsPage() + + self.stacked_widget.addWidget(self.dashboard_page) + self.stacked_widget.addWidget(self.sync_page) + self.stacked_widget.addWidget(self.downloads_page) + self.stacked_widget.addWidget(self.artists_page) + self.stacked_widget.addWidget(self.settings_page) + + main_layout.addWidget(self.stacked_widget) + + # Set dashboard as default page + self.change_page("dashboard") + + def setup_status_monitoring(self): + # Start status monitoring thread + self.status_thread = ServiceStatusThread( + self.spotify_client, + self.plex_client, + self.soulseek_client + ) + self.status_thread.status_updated.connect(self.update_service_status) + self.status_thread.start() + + def change_page(self, page_id: str): + page_map = { + "dashboard": 0, + "sync": 1, + "downloads": 2, + "artists": 3, + "settings": 4 + } + + if page_id in page_map: + self.stacked_widget.setCurrentIndex(page_map[page_id]) + logger.info(f"Changed to page: {page_id}") + + def update_service_status(self, service: str, connected: bool): + self.sidebar.update_service_status(service, connected) + + # Force a refresh of the Spotify client if needed + if service == "spotify" and not connected: + try: + self.spotify_client._setup_client() + except Exception as e: + logger.error(f"Error refreshing Spotify client: {e}") + + def closeEvent(self, event): + logger.info("Closing application...") + + # Stop status monitoring thread + if self.status_thread: + self.status_thread.stop() + + # Close Soulseek client + try: + loop = asyncio.get_event_loop() + loop.run_until_complete(self.soulseek_client.close()) + except Exception as e: + logger.error(f"Error closing Soulseek client: {e}") + + event.accept() + +def main(): + logging_config = config_manager.get_logging_config() + log_level = logging_config.get('level', 'INFO') + log_file = logging_config.get('path', 'logs/newmusic.log') + setup_logging(level=log_level, log_file=log_file) + + logger.info("Starting NewMusic application") + + if not config_manager.config_path.exists(): + logger.error("Configuration file not found. Please check config/config.json") + sys.exit(1) + + app = QApplication(sys.argv) + app.setApplicationName("NewMusic") + app.setApplicationVersion("1.0.0") + + main_window = MainWindow() + main_window.show() + + try: + sys.exit(app.exec()) + except KeyboardInterrupt: + logger.info("Application interrupted by user") + sys.exit(0) + except Exception as e: + logger.error(f"Unexpected error: {e}") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/project.txt b/project.txt new file mode 100644 index 00000000..fb4260dc --- /dev/null +++ b/project.txt @@ -0,0 +1,8 @@ +create me an app with python. it will have an elegant and animated and virant theme and layout similar to the Spotify desktop app or web app. +The app will connect to both spotify and my plex media server so i want a central file called config.json that stores all of the api and login information for connected services. +The app will have multiple functionalities. +the app will pull the data for all spotify user playlists. it will sync the playlists to plex mediaserver using an extensive, robust matching system, multiple methods. +The app will connect to soulseek and search for flac or user selected quality to download albums and singles by an artist with an extensive matching system. +the app will update the music metadata on plex based off the spotify metadata that is provided. including album art. + +the core functionality should feel and look like spotify. \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..f77d9c34 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +PyQt6>=6.6.0 +spotipy>=2.23.0 +PlexAPI>=4.17.0 +requests>=2.31.0 +asyncio-mqtt>=0.16.0 +python-dotenv>=1.0.0 +cryptography>=41.0.0 +mutagen>=1.47.0 +Pillow>=10.0.0 +aiohttp>=3.9.0 \ No newline at end of file diff --git a/services/sync_service.py b/services/sync_service.py new file mode 100644 index 00000000..bd945497 --- /dev/null +++ b/services/sync_service.py @@ -0,0 +1,271 @@ +import asyncio +from typing import List, Dict, Any, Optional +from dataclasses import dataclass +from datetime import datetime +from utils.logging_config import get_logger +from core.spotify_client import SpotifyClient, Playlist as SpotifyPlaylist +from core.plex_client import PlexClient, PlexTrackInfo +from core.soulseek_client import SoulseekClient +from core.matching_engine import matching_engine, MatchResult + +logger = get_logger("sync_service") + +@dataclass +class SyncResult: + playlist_name: str + total_tracks: int + matched_tracks: int + synced_tracks: int + downloaded_tracks: int + failed_tracks: int + sync_time: datetime + errors: List[str] + + @property + def success_rate(self) -> float: + if self.total_tracks == 0: + return 0.0 + return (self.synced_tracks / self.total_tracks) * 100 + +@dataclass +class SyncProgress: + current_step: str + current_track: str + progress: float + total_steps: int + current_step_number: int + +class PlaylistSyncService: + def __init__(self, spotify_client: SpotifyClient, plex_client: PlexClient, soulseek_client: SoulseekClient): + self.spotify_client = spotify_client + self.plex_client = plex_client + self.soulseek_client = soulseek_client + self.progress_callback = None + self.is_syncing = False + + def set_progress_callback(self, callback): + self.progress_callback = callback + + def _update_progress(self, step: str, track: str, progress: float, total_steps: int, current_step: int): + if self.progress_callback: + self.progress_callback(SyncProgress( + current_step=step, + current_track=track, + progress=progress, + total_steps=total_steps, + current_step_number=current_step + )) + + async def sync_playlist(self, playlist_name: str, download_missing: bool = False) -> SyncResult: + if self.is_syncing: + logger.warning("Sync already in progress") + return SyncResult( + playlist_name=playlist_name, + total_tracks=0, + matched_tracks=0, + synced_tracks=0, + downloaded_tracks=0, + failed_tracks=0, + sync_time=datetime.now(), + errors=["Sync already in progress"] + ) + + self.is_syncing = True + errors = [] + + try: + logger.info(f"Starting sync for playlist: {playlist_name}") + + self._update_progress("Fetching Spotify playlist", "", 0, 6, 1) + spotify_playlist = self._get_spotify_playlist(playlist_name) + if not spotify_playlist: + errors.append(f"Spotify playlist '{playlist_name}' not found") + return self._create_error_result(playlist_name, errors) + + self._update_progress("Fetching Plex library", "", 16, 6, 2) + plex_tracks = await self._get_plex_tracks() + + self._update_progress("Matching tracks", "", 33, 6, 3) + match_results = matching_engine.match_playlist_tracks( + spotify_playlist.tracks, + plex_tracks + ) + + matched_tracks = [r for r in match_results if r.is_match] + unmatched_tracks = [r for r in match_results if not r.is_match] + + logger.info(f"Found {len(matched_tracks)} matches out of {len(spotify_playlist.tracks)} tracks") + + downloaded_tracks = 0 + if download_missing and unmatched_tracks: + self._update_progress("Downloading missing tracks", "", 50, 6, 4) + downloaded_tracks = await self._download_missing_tracks(unmatched_tracks) + + self._update_progress("Creating/updating Plex playlist", "", 66, 6, 5) + plex_track_infos = [r.plex_track for r in matched_tracks if r.plex_track] + + sync_success = self.plex_client.update_playlist(playlist_name, plex_track_infos) + + synced_tracks = len(plex_track_infos) if sync_success else 0 + failed_tracks = len(spotify_playlist.tracks) - synced_tracks - downloaded_tracks + + self._update_progress("Sync completed", "", 100, 6, 6) + + result = SyncResult( + playlist_name=playlist_name, + total_tracks=len(spotify_playlist.tracks), + matched_tracks=len(matched_tracks), + synced_tracks=synced_tracks, + downloaded_tracks=downloaded_tracks, + failed_tracks=failed_tracks, + sync_time=datetime.now(), + errors=errors + ) + + logger.info(f"Sync completed: {result.success_rate:.1f}% success rate") + return result + + except Exception as e: + logger.error(f"Error during sync: {e}") + errors.append(str(e)) + return self._create_error_result(playlist_name, errors) + + finally: + self.is_syncing = False + + async def sync_multiple_playlists(self, playlist_names: List[str], download_missing: bool = False) -> List[SyncResult]: + results = [] + + for i, playlist_name in enumerate(playlist_names): + logger.info(f"Syncing playlist {i+1}/{len(playlist_names)}: {playlist_name}") + result = await self.sync_playlist(playlist_name, download_missing) + results.append(result) + + if i < len(playlist_names) - 1: + await asyncio.sleep(1) + + return results + + def _get_spotify_playlist(self, playlist_name: str) -> Optional[SpotifyPlaylist]: + try: + playlists = self.spotify_client.get_user_playlists() + for playlist in playlists: + if playlist.name.lower() == playlist_name.lower(): + return playlist + return None + except Exception as e: + logger.error(f"Error fetching Spotify playlist: {e}") + return None + + async def _get_plex_tracks(self) -> List[PlexTrackInfo]: + try: + return self.plex_client.search_tracks("", limit=10000) + except Exception as e: + logger.error(f"Error fetching Plex tracks: {e}") + return [] + + async def _download_missing_tracks(self, unmatched_tracks: List[MatchResult]) -> int: + downloaded_count = 0 + + for match_result in unmatched_tracks: + try: + query = matching_engine.generate_download_query(match_result.spotify_track) + logger.info(f"Attempting to download: {query}") + + download_id = await self.soulseek_client.search_and_download_best(query) + + if download_id: + downloaded_count += 1 + logger.info(f"Download started for: {match_result.spotify_track.name}") + else: + logger.warning(f"No download sources found for: {match_result.spotify_track.name}") + + await asyncio.sleep(1) + + except Exception as e: + logger.error(f"Error downloading track: {e}") + + return downloaded_count + + def _create_error_result(self, playlist_name: str, errors: List[str]) -> SyncResult: + return SyncResult( + playlist_name=playlist_name, + total_tracks=0, + matched_tracks=0, + synced_tracks=0, + downloaded_tracks=0, + failed_tracks=0, + sync_time=datetime.now(), + errors=errors + ) + + def get_sync_preview(self, playlist_name: str) -> Dict[str, Any]: + try: + spotify_playlist = self._get_spotify_playlist(playlist_name) + if not spotify_playlist: + return {"error": f"Playlist '{playlist_name}' not found"} + + plex_tracks = self.plex_client.search_tracks("", limit=1000) + + match_results = matching_engine.match_playlist_tracks( + spotify_playlist.tracks, + plex_tracks + ) + + stats = matching_engine.get_match_statistics(match_results) + + preview = { + "playlist_name": playlist_name, + "total_tracks": len(spotify_playlist.tracks), + "available_in_plex": stats["matched_tracks"], + "needs_download": stats["total_tracks"] - stats["matched_tracks"], + "match_percentage": stats["match_percentage"], + "confidence_breakdown": stats["confidence_distribution"], + "tracks_preview": [] + } + + for result in match_results[:10]: + track_info = { + "spotify_track": f"{result.spotify_track.name} - {result.spotify_track.artists[0]}", + "plex_match": result.plex_track.title if result.plex_track else None, + "confidence": result.confidence, + "status": "available" if result.is_match else "needs_download" + } + preview["tracks_preview"].append(track_info) + + return preview + + except Exception as e: + logger.error(f"Error generating sync preview: {e}") + return {"error": str(e)} + + def get_library_comparison(self) -> Dict[str, Any]: + try: + spotify_playlists = self.spotify_client.get_user_playlists() + plex_playlists = self.plex_client.get_all_playlists() + plex_stats = self.plex_client.get_library_stats() + + spotify_track_count = sum(len(p.tracks) for p in spotify_playlists) + + comparison = { + "spotify": { + "playlists": len(spotify_playlists), + "total_tracks": spotify_track_count + }, + "plex": { + "playlists": len(plex_playlists), + "artists": plex_stats.get("artists", 0), + "albums": plex_stats.get("albums", 0), + "tracks": plex_stats.get("tracks", 0) + }, + "sync_potential": { + "estimated_matches": min(spotify_track_count, plex_stats.get("tracks", 0)), + "potential_downloads": max(0, spotify_track_count - plex_stats.get("tracks", 0)) + } + } + + return comparison + + except Exception as e: + logger.error(f"Error generating library comparison: {e}") + return {"error": str(e)} \ No newline at end of file diff --git a/ui/__pycache__/sidebar.cpython-312.pyc b/ui/__pycache__/sidebar.cpython-312.pyc new file mode 100644 index 00000000..5e4f3d82 Binary files /dev/null and b/ui/__pycache__/sidebar.cpython-312.pyc differ diff --git a/ui/pages/__init__.py b/ui/pages/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ui/pages/__pycache__/__init__.cpython-312.pyc b/ui/pages/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..6bb01500 Binary files /dev/null and b/ui/pages/__pycache__/__init__.cpython-312.pyc differ diff --git a/ui/pages/__pycache__/artists.cpython-312.pyc b/ui/pages/__pycache__/artists.cpython-312.pyc new file mode 100644 index 00000000..818ed777 Binary files /dev/null and b/ui/pages/__pycache__/artists.cpython-312.pyc differ diff --git a/ui/pages/__pycache__/dashboard.cpython-312.pyc b/ui/pages/__pycache__/dashboard.cpython-312.pyc new file mode 100644 index 00000000..3f2c0d5b Binary files /dev/null and b/ui/pages/__pycache__/dashboard.cpython-312.pyc differ diff --git a/ui/pages/__pycache__/downloads.cpython-312.pyc b/ui/pages/__pycache__/downloads.cpython-312.pyc new file mode 100644 index 00000000..ba47f4b5 Binary files /dev/null and b/ui/pages/__pycache__/downloads.cpython-312.pyc differ diff --git a/ui/pages/__pycache__/settings.cpython-312.pyc b/ui/pages/__pycache__/settings.cpython-312.pyc new file mode 100644 index 00000000..b7f8f532 Binary files /dev/null and b/ui/pages/__pycache__/settings.cpython-312.pyc differ diff --git a/ui/pages/__pycache__/sync.cpython-312.pyc b/ui/pages/__pycache__/sync.cpython-312.pyc new file mode 100644 index 00000000..7c2d5e38 Binary files /dev/null and b/ui/pages/__pycache__/sync.cpython-312.pyc differ diff --git a/ui/pages/artists.py b/ui/pages/artists.py new file mode 100644 index 00000000..1d50098a --- /dev/null +++ b/ui/pages/artists.py @@ -0,0 +1,359 @@ +from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QFrame, QPushButton, QLineEdit, QScrollArea, + QGridLayout, QComboBox, QProgressBar) +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QFont, QPixmap + +class ArtistCard(QFrame): + def __init__(self, name: str, album_count: int, track_count: int, last_updated: str, parent=None): + super().__init__(parent) + self.name = name + self.album_count = album_count + self.track_count = track_count + self.last_updated = last_updated + self.setup_ui() + + def setup_ui(self): + self.setFixedSize(280, 200) + self.setStyleSheet(""" + ArtistCard { + background: #282828; + border-radius: 8px; + border: 1px solid #404040; + } + ArtistCard:hover { + background: #333333; + border: 1px solid #1db954; + } + """) + + layout = QVBoxLayout(self) + layout.setContentsMargins(20, 20, 20, 20) + layout.setSpacing(10) + + # Artist image placeholder + image_placeholder = QLabel() + image_placeholder.setFixedSize(80, 80) + image_placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter) + image_placeholder.setStyleSheet(""" + QLabel { + background: #404040; + border-radius: 40px; + color: #b3b3b3; + font-size: 32px; + } + """) + image_placeholder.setText("🎡") + + # Artist name + name_label = QLabel(self.name) + name_label.setFont(QFont("Arial", 14, QFont.Weight.Bold)) + name_label.setStyleSheet("color: #ffffff;") + name_label.setWordWrap(True) + name_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + # Stats + stats_layout = QVBoxLayout() + stats_layout.setSpacing(5) + + albums_label = QLabel(f"{self.album_count} albums") + albums_label.setFont(QFont("Arial", 10)) + albums_label.setStyleSheet("color: #b3b3b3;") + albums_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + tracks_label = QLabel(f"{self.track_count} tracks") + tracks_label.setFont(QFont("Arial", 10)) + tracks_label.setStyleSheet("color: #b3b3b3;") + tracks_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + updated_label = QLabel(f"Updated: {self.last_updated}") + updated_label.setFont(QFont("Arial", 9)) + updated_label.setStyleSheet("color: #666666;") + updated_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + stats_layout.addWidget(albums_label) + stats_layout.addWidget(tracks_label) + stats_layout.addWidget(updated_label) + + # Action buttons + actions_layout = QHBoxLayout() + actions_layout.setSpacing(10) + + update_btn = QPushButton("Update") + update_btn.setFixedSize(60, 25) + update_btn.setStyleSheet(""" + QPushButton { + background: #1db954; + border: none; + border-radius: 12px; + color: #000000; + font-size: 9px; + font-weight: bold; + } + QPushButton:hover { + background: #1ed760; + } + """) + + download_btn = QPushButton("Download") + download_btn.setFixedSize(60, 25) + download_btn.setStyleSheet(""" + QPushButton { + background: transparent; + border: 1px solid #1db954; + border-radius: 12px; + color: #1db954; + font-size: 9px; + font-weight: bold; + } + QPushButton:hover { + background: rgba(29, 185, 84, 0.1); + } + """) + + actions_layout.addWidget(update_btn) + actions_layout.addWidget(download_btn) + + # Center the image + image_container = QHBoxLayout() + image_container.addStretch() + image_container.addWidget(image_placeholder) + image_container.addStretch() + + layout.addLayout(image_container) + layout.addWidget(name_label) + layout.addLayout(stats_layout) + layout.addStretch() + layout.addLayout(actions_layout) + +class ArtistsPage(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.setup_ui() + + def setup_ui(self): + self.setStyleSheet(""" + ArtistsPage { + background: #191414; + } + """) + + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(30, 30, 30, 30) + main_layout.setSpacing(25) + + # Header + header = self.create_header() + main_layout.addWidget(header) + + # Controls + controls = self.create_controls() + main_layout.addWidget(controls) + + # Artists grid + artists_section = self.create_artists_section() + main_layout.addWidget(artists_section) + + def create_header(self): + header = QWidget() + layout = QVBoxLayout(header) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(5) + + # Title + title_label = QLabel("Artists") + title_label.setFont(QFont("Arial", 28, QFont.Weight.Bold)) + title_label.setStyleSheet("color: #ffffff;") + + # Subtitle + subtitle_label = QLabel("Manage artist metadata and download complete discographies") + subtitle_label.setFont(QFont("Arial", 14)) + subtitle_label.setStyleSheet("color: #b3b3b3;") + + layout.addWidget(title_label) + layout.addWidget(subtitle_label) + + return header + + def create_controls(self): + controls = QWidget() + layout = QHBoxLayout(controls) + layout.setSpacing(15) + + # Search bar + search_bar = QLineEdit() + search_bar.setPlaceholderText("Search artists...") + search_bar.setFixedHeight(40) + search_bar.setStyleSheet(""" + QLineEdit { + background: #282828; + border: 1px solid #404040; + border-radius: 20px; + padding: 0 15px; + color: #ffffff; + font-size: 12px; + } + QLineEdit:focus { + border: 1px solid #1db954; + } + """) + + # Filter dropdown + filter_combo = QComboBox() + filter_combo.addItems(["All Artists", "Recently Updated", "Need Update", "Missing Albums"]) + filter_combo.setFixedHeight(40) + filter_combo.setStyleSheet(""" + QComboBox { + background: #282828; + border: 1px solid #404040; + border-radius: 20px; + padding: 0 15px; + color: #ffffff; + font-size: 12px; + min-width: 120px; + } + QComboBox::drop-down { + border: none; + } + QComboBox::down-arrow { + image: none; + } + """) + + # Scan all button + scan_btn = QPushButton("πŸ” Scan All Artists") + scan_btn.setFixedHeight(40) + scan_btn.setStyleSheet(""" + QPushButton { + background: #1db954; + border: none; + border-radius: 20px; + color: #000000; + font-size: 12px; + font-weight: bold; + padding: 0 20px; + } + QPushButton:hover { + background: #1ed760; + } + """) + + # Update all button + update_btn = QPushButton("πŸ“ Update All Metadata") + update_btn.setFixedHeight(40) + update_btn.setStyleSheet(""" + QPushButton { + background: transparent; + border: 1px solid #1db954; + border-radius: 20px; + color: #1db954; + font-size: 12px; + font-weight: bold; + padding: 0 20px; + } + QPushButton:hover { + background: rgba(29, 185, 84, 0.1); + } + """) + + layout.addWidget(search_bar) + layout.addWidget(filter_combo) + layout.addStretch() + layout.addWidget(scan_btn) + layout.addWidget(update_btn) + + return controls + + def create_artists_section(self): + section = QWidget() + layout = QVBoxLayout(section) + layout.setSpacing(20) + + # Progress bar for batch operations + progress_frame = QFrame() + progress_frame.setFixedHeight(60) + progress_frame.setStyleSheet(""" + QFrame { + background: #282828; + border-radius: 8px; + border: 1px solid #404040; + } + """) + + progress_layout = QVBoxLayout(progress_frame) + progress_layout.setContentsMargins(20, 15, 20, 15) + progress_layout.setSpacing(8) + + progress_label = QLabel("Scanning artists...") + progress_label.setFont(QFont("Arial", 11)) + progress_label.setStyleSheet("color: #ffffff;") + + progress_bar = QProgressBar() + progress_bar.setFixedHeight(6) + progress_bar.setValue(0) + progress_bar.setStyleSheet(""" + QProgressBar { + border: none; + border-radius: 3px; + background: #404040; + } + QProgressBar::chunk { + background: #1db954; + border-radius: 3px; + } + """) + + progress_layout.addWidget(progress_label) + progress_layout.addWidget(progress_bar) + + # Artists grid + artists_scroll = QScrollArea() + artists_scroll.setWidgetResizable(True) + artists_scroll.setStyleSheet(""" + QScrollArea { + border: none; + background: transparent; + } + QScrollBar:vertical { + background: #282828; + width: 8px; + border-radius: 4px; + } + QScrollBar::handle:vertical { + background: #1db954; + border-radius: 4px; + } + """) + + artists_widget = QWidget() + artists_grid = QGridLayout(artists_widget) + artists_grid.setSpacing(20) + + # Sample artists + artists = [ + ("The Beatles", 13, 213, "2 days ago"), + ("Pink Floyd", 15, 147, "1 week ago"), + ("Led Zeppelin", 8, 108, "3 days ago"), + ("Queen", 16, 186, "5 days ago"), + ("The Rolling Stones", 22, 289, "1 week ago"), + ("Bob Dylan", 38, 456, "2 weeks ago"), + ("David Bowie", 27, 342, "4 days ago"), + ("Radiohead", 9, 127, "6 days ago"), + ("The Who", 12, 156, "1 week ago"), + ("Nirvana", 5, 67, "3 weeks ago"), + ("AC/DC", 17, 198, "2 days ago"), + ("Fleetwood Mac", 18, 234, "1 week ago") + ] + + for i, (name, albums, tracks, updated) in enumerate(artists): + card = ArtistCard(name, albums, tracks, updated) + artists_grid.addWidget(card, i // 3, i % 3) + + # Add stretch to fill remaining space + artists_grid.setRowStretch(artists_grid.rowCount(), 1) + artists_scroll.setWidget(artists_widget) + + layout.addWidget(progress_frame) + layout.addWidget(artists_scroll) + + return section \ No newline at end of file diff --git a/ui/pages/dashboard.py b/ui/pages/dashboard.py new file mode 100644 index 00000000..2d537001 --- /dev/null +++ b/ui/pages/dashboard.py @@ -0,0 +1,220 @@ +from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QFrame, QGridLayout, QScrollArea, QSizePolicy) +from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtGui import QFont, QPalette + +class StatCard(QFrame): + def __init__(self, title: str, value: str, subtitle: str = "", parent=None): + super().__init__(parent) + self.setup_ui(title, value, subtitle) + + def setup_ui(self, title: str, value: str, subtitle: str): + self.setFixedHeight(120) + self.setStyleSheet(""" + StatCard { + background: #282828; + border-radius: 8px; + border: 1px solid #404040; + } + StatCard:hover { + background: #333333; + border: 1px solid #1db954; + } + """) + + layout = QVBoxLayout(self) + layout.setContentsMargins(20, 15, 20, 15) + layout.setSpacing(5) + + # Title + title_label = QLabel(title) + title_label.setFont(QFont("Arial", 10)) + title_label.setStyleSheet("color: #b3b3b3;") + + # Value + value_label = QLabel(value) + value_label.setFont(QFont("Arial", 24, QFont.Weight.Bold)) + value_label.setStyleSheet("color: #ffffff;") + + # Subtitle + if subtitle: + subtitle_label = QLabel(subtitle) + subtitle_label.setFont(QFont("Arial", 9)) + subtitle_label.setStyleSheet("color: #b3b3b3;") + layout.addWidget(subtitle_label) + + layout.addWidget(title_label) + layout.addWidget(value_label) + layout.addStretch() + +class ActivityItem(QWidget): + def __init__(self, icon: str, title: str, subtitle: str, time: str, parent=None): + super().__init__(parent) + self.setup_ui(icon, title, subtitle, time) + + def setup_ui(self, icon: str, title: str, subtitle: str, time: str): + self.setFixedHeight(60) + + layout = QHBoxLayout(self) + layout.setContentsMargins(15, 10, 15, 10) + layout.setSpacing(15) + + # Icon + icon_label = QLabel(icon) + icon_label.setFixedSize(32, 32) + icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + icon_label.setStyleSheet(""" + QLabel { + color: #1db954; + font-size: 18px; + background: rgba(29, 185, 84, 0.1); + border-radius: 16px; + } + """) + + # Text content + text_layout = QVBoxLayout() + text_layout.setSpacing(2) + + title_label = QLabel(title) + title_label.setFont(QFont("Arial", 10, QFont.Weight.Medium)) + title_label.setStyleSheet("color: #ffffff;") + + subtitle_label = QLabel(subtitle) + subtitle_label.setFont(QFont("Arial", 9)) + subtitle_label.setStyleSheet("color: #b3b3b3;") + + text_layout.addWidget(title_label) + text_layout.addWidget(subtitle_label) + + # Time + time_label = QLabel(time) + time_label.setFont(QFont("Arial", 9)) + time_label.setStyleSheet("color: #b3b3b3;") + time_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop) + + layout.addWidget(icon_label) + layout.addLayout(text_layout) + layout.addStretch() + layout.addWidget(time_label) + +class DashboardPage(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.setup_ui() + + def setup_ui(self): + self.setStyleSheet(""" + DashboardPage { + background: #191414; + } + """) + + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(30, 30, 30, 30) + main_layout.setSpacing(30) + + # Header + header = self.create_header() + main_layout.addWidget(header) + + # Stats grid + stats_grid = self.create_stats_grid() + main_layout.addWidget(stats_grid) + + # Recent activity + activity_section = self.create_activity_section() + main_layout.addWidget(activity_section) + + main_layout.addStretch() + + def create_header(self): + header = QWidget() + layout = QVBoxLayout(header) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(5) + + # Welcome message + welcome_label = QLabel("Welcome back!") + welcome_label.setFont(QFont("Arial", 28, QFont.Weight.Bold)) + welcome_label.setStyleSheet("color: #ffffff;") + + # Subtitle + subtitle_label = QLabel("Here's what's happening with your music library") + subtitle_label.setFont(QFont("Arial", 14)) + subtitle_label.setStyleSheet("color: #b3b3b3;") + + layout.addWidget(welcome_label) + layout.addWidget(subtitle_label) + + return header + + def create_stats_grid(self): + stats_widget = QWidget() + grid = QGridLayout(stats_widget) + grid.setSpacing(20) + + # Sample stats - these will be populated with real data later + stats = [ + ("Spotify Playlists", "12", "3 synced today"), + ("Plex Tracks", "2,847", "156 added this week"), + ("Missing Tracks", "23", "Ready to download"), + ("Artists Scanned", "89", "Metadata updated"), + ("Downloads", "5", "In progress"), + ("Sync Status", "98%", "Up to date") + ] + + for i, (title, value, subtitle) in enumerate(stats): + card = StatCard(title, value, subtitle) + grid.addWidget(card, i // 3, i % 3) + + return stats_widget + + def create_activity_section(self): + activity_widget = QWidget() + layout = QVBoxLayout(activity_widget) + layout.setSpacing(15) + + # Section header + header_label = QLabel("Recent Activity") + header_label.setFont(QFont("Arial", 16, QFont.Weight.Bold)) + header_label.setStyleSheet("color: #ffffff;") + + # Activity container + activity_container = QFrame() + activity_container.setStyleSheet(""" + QFrame { + background: #282828; + border-radius: 8px; + border: 1px solid #404040; + } + """) + + activity_layout = QVBoxLayout(activity_container) + activity_layout.setContentsMargins(0, 0, 0, 0) + activity_layout.setSpacing(1) + + # Sample activity items + activities = [ + ("πŸ”„", "Playlist Sync", "Synced 'Favorites' playlist to Plex", "2 min ago"), + ("πŸ“₯", "Download Complete", "Downloaded 'Song Title' by Artist", "5 min ago"), + ("🎡", "Artist Updated", "Updated metadata for 'Artist Name'", "1 hour ago"), + ("βœ…", "Sync Complete", "All playlists synchronized successfully", "3 hours ago"), + ("πŸ“Š", "Library Scan", "Scanned 156 new tracks in Plex", "1 day ago") + ] + + for icon, title, subtitle, time in activities: + item = ActivityItem(icon, title, subtitle, time) + activity_layout.addWidget(item) + + # Add separator (except for last item) + if (icon, title, subtitle, time) != activities[-1]: + separator = QFrame() + separator.setFixedHeight(1) + separator.setStyleSheet("background: #404040;") + activity_layout.addWidget(separator) + + layout.addWidget(header_label) + layout.addWidget(activity_container) + + return activity_widget \ No newline at end of file diff --git a/ui/pages/downloads.py b/ui/pages/downloads.py new file mode 100644 index 00000000..26f0e876 --- /dev/null +++ b/ui/pages/downloads.py @@ -0,0 +1,488 @@ +from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QFrame, QPushButton, QProgressBar, QListWidget, + QListWidgetItem, QComboBox, QLineEdit, QScrollArea) +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QFont + +class DownloadItem(QFrame): + def __init__(self, title: str, artist: str, status: str, progress: int = 0, parent=None): + super().__init__(parent) + self.title = title + self.artist = artist + self.status = status + self.progress = progress + self.setup_ui() + + def setup_ui(self): + self.setFixedHeight(80) + self.setStyleSheet(""" + DownloadItem { + background: #282828; + border-radius: 8px; + border: 1px solid #404040; + margin: 2px; + } + DownloadItem:hover { + background: #333333; + } + """) + + layout = QHBoxLayout(self) + layout.setContentsMargins(20, 15, 20, 15) + layout.setSpacing(15) + + # Status icon + status_icon = QLabel() + status_icon.setFixedSize(32, 32) + status_icon.setAlignment(Qt.AlignmentFlag.AlignCenter) + + if self.status == "downloading": + status_icon.setText("πŸ“₯") + status_icon.setStyleSheet(""" + QLabel { + color: #1db954; + font-size: 18px; + background: rgba(29, 185, 84, 0.1); + border-radius: 16px; + } + """) + elif self.status == "completed": + status_icon.setText("βœ…") + status_icon.setStyleSheet(""" + QLabel { + color: #1db954; + font-size: 18px; + background: rgba(29, 185, 84, 0.1); + border-radius: 16px; + } + """) + elif self.status == "failed": + status_icon.setText("❌") + status_icon.setStyleSheet(""" + QLabel { + color: #e22134; + font-size: 18px; + background: rgba(226, 33, 52, 0.1); + border-radius: 16px; + } + """) + else: + status_icon.setText("⏳") + status_icon.setStyleSheet(""" + QLabel { + color: #ffa500; + font-size: 18px; + background: rgba(255, 165, 0, 0.1); + border-radius: 16px; + } + """) + + # Content + content_layout = QVBoxLayout() + content_layout.setSpacing(5) + + # Title and artist + title_label = QLabel(self.title) + title_label.setFont(QFont("Arial", 12, QFont.Weight.Bold)) + title_label.setStyleSheet("color: #ffffff;") + + artist_label = QLabel(f"by {self.artist}") + artist_label.setFont(QFont("Arial", 10)) + artist_label.setStyleSheet("color: #b3b3b3;") + + content_layout.addWidget(title_label) + content_layout.addWidget(artist_label) + + # Progress section + progress_layout = QVBoxLayout() + progress_layout.setSpacing(5) + + # Progress bar + progress_bar = QProgressBar() + progress_bar.setFixedHeight(6) + progress_bar.setValue(self.progress) + progress_bar.setStyleSheet(""" + QProgressBar { + border: none; + border-radius: 3px; + background: #404040; + } + QProgressBar::chunk { + background: #1db954; + border-radius: 3px; + } + """) + + # Status text + status_text = f"{self.status.title()}" + if self.status == "downloading": + status_text += f" - {self.progress}%" + + status_label = QLabel(status_text) + status_label.setFont(QFont("Arial", 9)) + status_label.setStyleSheet("color: #b3b3b3;") + + progress_layout.addWidget(progress_bar) + progress_layout.addWidget(status_label) + + # Action button + action_btn = QPushButton() + action_btn.setFixedSize(80, 30) + + if self.status == "downloading": + action_btn.setText("Cancel") + action_btn.setStyleSheet(""" + QPushButton { + background: transparent; + border: 1px solid #e22134; + border-radius: 15px; + color: #e22134; + font-size: 10px; + font-weight: bold; + } + QPushButton:hover { + background: #e22134; + color: #ffffff; + } + """) + elif self.status == "failed": + action_btn.setText("Retry") + action_btn.setStyleSheet(""" + QPushButton { + background: transparent; + border: 1px solid #1db954; + border-radius: 15px; + color: #1db954; + font-size: 10px; + font-weight: bold; + } + QPushButton:hover { + background: #1db954; + color: #000000; + } + """) + else: + action_btn.setText("Details") + action_btn.setStyleSheet(""" + QPushButton { + background: transparent; + border: 1px solid #b3b3b3; + border-radius: 15px; + color: #b3b3b3; + font-size: 10px; + font-weight: bold; + } + QPushButton:hover { + background: #b3b3b3; + color: #000000; + } + """) + + layout.addWidget(status_icon) + layout.addLayout(content_layout) + layout.addStretch() + layout.addLayout(progress_layout) + layout.addWidget(action_btn) + +class DownloadQueue(QFrame): + def __init__(self, parent=None): + super().__init__(parent) + self.setup_ui() + + def setup_ui(self): + self.setStyleSheet(""" + DownloadQueue { + background: #282828; + border-radius: 8px; + border: 1px solid #404040; + } + """) + + layout = QVBoxLayout(self) + layout.setContentsMargins(20, 20, 20, 20) + layout.setSpacing(15) + + # Header + header_layout = QHBoxLayout() + + title_label = QLabel("Download Queue") + title_label.setFont(QFont("Arial", 16, QFont.Weight.Bold)) + title_label.setStyleSheet("color: #ffffff;") + + queue_count = QLabel("5 items") + queue_count.setFont(QFont("Arial", 11)) + queue_count.setStyleSheet("color: #b3b3b3;") + + header_layout.addWidget(title_label) + header_layout.addStretch() + header_layout.addWidget(queue_count) + + # Queue list + queue_scroll = QScrollArea() + queue_scroll.setWidgetResizable(True) + queue_scroll.setFixedHeight(300) + queue_scroll.setStyleSheet(""" + QScrollArea { + border: none; + background: transparent; + } + QScrollBar:vertical { + background: #404040; + width: 8px; + border-radius: 4px; + } + QScrollBar::handle:vertical { + background: #1db954; + border-radius: 4px; + } + """) + + queue_widget = QWidget() + queue_layout = QVBoxLayout(queue_widget) + queue_layout.setSpacing(8) + + # Sample download items + downloads = [ + ("Song Title 1", "Artist Name 1", "downloading", 75), + ("Song Title 2", "Artist Name 2", "downloading", 45), + ("Song Title 3", "Artist Name 3", "queued", 0), + ("Song Title 4", "Artist Name 4", "completed", 100), + ("Song Title 5", "Artist Name 5", "failed", 0) + ] + + for title, artist, status, progress in downloads: + item = DownloadItem(title, artist, status, progress) + queue_layout.addWidget(item) + + queue_layout.addStretch() + queue_scroll.setWidget(queue_widget) + + layout.addLayout(header_layout) + layout.addWidget(queue_scroll) + +class DownloadsPage(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.setup_ui() + + def setup_ui(self): + self.setStyleSheet(""" + DownloadsPage { + background: #191414; + } + """) + + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(30, 30, 30, 30) + main_layout.setSpacing(25) + + # Header + header = self.create_header() + main_layout.addWidget(header) + + # Content area + content_layout = QHBoxLayout() + content_layout.setSpacing(25) + + # Left side - Download queue + queue_section = DownloadQueue() + content_layout.addWidget(queue_section, 2) + + # Right side - Controls and stats + controls_section = self.create_controls_section() + content_layout.addWidget(controls_section, 1) + + main_layout.addLayout(content_layout) + + # Bottom section - Missing tracks + missing_section = self.create_missing_tracks_section() + main_layout.addWidget(missing_section) + + def create_header(self): + header = QWidget() + layout = QVBoxLayout(header) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(5) + + # Title + title_label = QLabel("Downloads") + title_label.setFont(QFont("Arial", 28, QFont.Weight.Bold)) + title_label.setStyleSheet("color: #ffffff;") + + # Subtitle + subtitle_label = QLabel("Manage your music downloads from Soulseek") + subtitle_label.setFont(QFont("Arial", 14)) + subtitle_label.setStyleSheet("color: #b3b3b3;") + + layout.addWidget(title_label) + layout.addWidget(subtitle_label) + + return header + + 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.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(200) + 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;") + + download_all_btn = QPushButton("πŸ“₯ Download All") + download_all_btn.setFixedSize(120, 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.addStretch() + header_layout.addWidget(download_all_btn) + + # Missing tracks list (simplified) + missing_text = QLabel("23 tracks are missing from your Plex library and available for download") + missing_text.setFont(QFont("Arial", 12)) + missing_text.setStyleSheet("color: #b3b3b3;") + + layout.addLayout(header_layout) + layout.addWidget(missing_text) + layout.addStretch() + + return section \ No newline at end of file diff --git a/ui/pages/settings.py b/ui/pages/settings.py new file mode 100644 index 00000000..22f40977 --- /dev/null +++ b/ui/pages/settings.py @@ -0,0 +1,427 @@ +from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QFrame, QPushButton, QLineEdit, QComboBox, + QCheckBox, QSpinBox, QTextEdit, QGroupBox, QFormLayout) +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QFont + +class SettingsGroup(QGroupBox): + def __init__(self, title: str, parent=None): + super().__init__(title, parent) + self.setStyleSheet(""" + QGroupBox { + background: #282828; + border: 1px solid #404040; + border-radius: 8px; + font-size: 14px; + font-weight: bold; + color: #ffffff; + padding-top: 15px; + margin-top: 10px; + } + QGroupBox::title { + subcontrol-origin: margin; + left: 10px; + padding: 0 5px 0 5px; + } + """) + +class SettingsPage(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.setup_ui() + + def setup_ui(self): + self.setStyleSheet(""" + SettingsPage { + background: #191414; + } + """) + + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(30, 30, 30, 30) + main_layout.setSpacing(25) + + # Header + header = self.create_header() + main_layout.addWidget(header) + + # Settings content + content_layout = QHBoxLayout() + content_layout.setSpacing(30) + + # Left column + left_column = self.create_left_column() + content_layout.addWidget(left_column) + + # Right column + right_column = self.create_right_column() + content_layout.addWidget(right_column) + + main_layout.addLayout(content_layout) + main_layout.addStretch() + + # Save button + save_btn = QPushButton("πŸ’Ύ Save Settings") + save_btn.setFixedHeight(45) + save_btn.setStyleSheet(""" + QPushButton { + background: #1db954; + border: none; + border-radius: 22px; + color: #000000; + font-size: 14px; + font-weight: bold; + } + QPushButton:hover { + background: #1ed760; + } + """) + + main_layout.addWidget(save_btn) + + def create_header(self): + header = QWidget() + layout = QVBoxLayout(header) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(5) + + # Title + title_label = QLabel("Settings") + title_label.setFont(QFont("Arial", 28, QFont.Weight.Bold)) + title_label.setStyleSheet("color: #ffffff;") + + # Subtitle + subtitle_label = QLabel("Configure your music sync and download preferences") + subtitle_label.setFont(QFont("Arial", 14)) + subtitle_label.setStyleSheet("color: #b3b3b3;") + + layout.addWidget(title_label) + layout.addWidget(subtitle_label) + + return header + + def create_left_column(self): + column = QWidget() + layout = QVBoxLayout(column) + layout.setSpacing(20) + + # API Configuration + api_group = SettingsGroup("API Configuration") + api_layout = QVBoxLayout(api_group) + api_layout.setContentsMargins(20, 25, 20, 20) + api_layout.setSpacing(15) + + # Spotify settings + spotify_frame = QFrame() + spotify_layout = QFormLayout(spotify_frame) + spotify_layout.setSpacing(10) + + spotify_title = QLabel("Spotify") + spotify_title.setFont(QFont("Arial", 12, QFont.Weight.Bold)) + spotify_title.setStyleSheet("color: #1db954;") + + client_id_input = QLineEdit("512b25bd9e0d4ecd82140f6d1ce0c8e6") + client_id_input.setStyleSheet(self.get_input_style()) + + client_secret_input = QLineEdit("c3844dcbedbc4e09a6242a14b2e89e89") + client_secret_input.setEchoMode(QLineEdit.EchoMode.Password) + client_secret_input.setStyleSheet(self.get_input_style()) + + spotify_layout.addRow(spotify_title) + spotify_layout.addRow("Client ID:", client_id_input) + spotify_layout.addRow("Client Secret:", client_secret_input) + + # Plex settings + plex_frame = QFrame() + plex_layout = QFormLayout(plex_frame) + plex_layout.setSpacing(10) + + plex_title = QLabel("Plex") + plex_title.setFont(QFont("Arial", 12, QFont.Weight.Bold)) + plex_title.setStyleSheet("color: #e5a00d;") + + plex_url_input = QLineEdit("http://192.168.86.36:32400") + plex_url_input.setStyleSheet(self.get_input_style()) + + plex_token_input = QLineEdit("a9hTgvasV1aJMLSdoBkr") + plex_token_input.setEchoMode(QLineEdit.EchoMode.Password) + plex_token_input.setStyleSheet(self.get_input_style()) + + plex_layout.addRow(plex_title) + plex_layout.addRow("Server URL:", plex_url_input) + plex_layout.addRow("Token:", plex_token_input) + + # Soulseek settings + soulseek_frame = QFrame() + soulseek_layout = QFormLayout(soulseek_frame) + soulseek_layout.setSpacing(10) + + soulseek_title = QLabel("Soulseek") + soulseek_title.setFont(QFont("Arial", 12, QFont.Weight.Bold)) + soulseek_title.setStyleSheet("color: #ff6b35;") + + slskd_url_input = QLineEdit("http://localhost:5030") + slskd_url_input.setStyleSheet(self.get_input_style()) + + api_key_input = QLineEdit() + api_key_input.setPlaceholderText("Enter your slskd API key") + api_key_input.setEchoMode(QLineEdit.EchoMode.Password) + api_key_input.setStyleSheet(self.get_input_style()) + + soulseek_layout.addRow(soulseek_title) + soulseek_layout.addRow("slskd URL:", slskd_url_input) + soulseek_layout.addRow("API Key:", api_key_input) + + api_layout.addWidget(spotify_frame) + api_layout.addWidget(plex_frame) + api_layout.addWidget(soulseek_frame) + + # Test connections + test_layout = QHBoxLayout() + test_layout.setSpacing(10) + + test_spotify = QPushButton("Test Spotify") + test_spotify.setFixedHeight(30) + test_spotify.setStyleSheet(self.get_test_button_style()) + + test_plex = QPushButton("Test Plex") + test_plex.setFixedHeight(30) + test_plex.setStyleSheet(self.get_test_button_style()) + + test_soulseek = QPushButton("Test Soulseek") + test_soulseek.setFixedHeight(30) + test_soulseek.setStyleSheet(self.get_test_button_style()) + + test_layout.addWidget(test_spotify) + test_layout.addWidget(test_plex) + test_layout.addWidget(test_soulseek) + + api_layout.addLayout(test_layout) + + layout.addWidget(api_group) + layout.addStretch() + + return column + + def create_right_column(self): + column = QWidget() + layout = QVBoxLayout(column) + layout.setSpacing(20) + + # Download Settings + download_group = SettingsGroup("Download Settings") + download_layout = QVBoxLayout(download_group) + download_layout.setContentsMargins(20, 25, 20, 20) + download_layout.setSpacing(15) + + # Quality preference + quality_layout = QHBoxLayout() + quality_label = QLabel("Preferred Quality:") + quality_label.setStyleSheet("color: #ffffff; font-size: 12px;") + + quality_combo = QComboBox() + quality_combo.addItems(["FLAC", "320 kbps MP3", "256 kbps MP3", "192 kbps MP3", "Any"]) + quality_combo.setCurrentText("FLAC") + quality_combo.setStyleSheet(self.get_combo_style()) + + quality_layout.addWidget(quality_label) + quality_layout.addWidget(quality_combo) + quality_layout.addStretch() + + # Max concurrent downloads + concurrent_layout = QHBoxLayout() + concurrent_label = QLabel("Max Concurrent Downloads:") + concurrent_label.setStyleSheet("color: #ffffff; font-size: 12px;") + + concurrent_spin = QSpinBox() + concurrent_spin.setRange(1, 10) + concurrent_spin.setValue(5) + concurrent_spin.setStyleSheet(self.get_spin_style()) + + concurrent_layout.addWidget(concurrent_label) + concurrent_layout.addWidget(concurrent_spin) + concurrent_layout.addStretch() + + # Download timeout + timeout_layout = QHBoxLayout() + timeout_label = QLabel("Download Timeout (seconds):") + timeout_label.setStyleSheet("color: #ffffff; font-size: 12px;") + + timeout_spin = QSpinBox() + timeout_spin.setRange(30, 600) + timeout_spin.setValue(300) + timeout_spin.setStyleSheet(self.get_spin_style()) + + timeout_layout.addWidget(timeout_label) + timeout_layout.addWidget(timeout_spin) + timeout_layout.addStretch() + + # Download path + path_layout = QHBoxLayout() + path_label = QLabel("Download Path:") + path_label.setStyleSheet("color: #ffffff; font-size: 12px;") + + path_input = QLineEdit("./downloads") + path_input.setStyleSheet(self.get_input_style()) + + browse_btn = QPushButton("Browse") + browse_btn.setFixedSize(70, 30) + browse_btn.setStyleSheet(self.get_test_button_style()) + + path_layout.addWidget(path_label) + path_layout.addWidget(path_input) + path_layout.addWidget(browse_btn) + + download_layout.addLayout(quality_layout) + download_layout.addLayout(concurrent_layout) + download_layout.addLayout(timeout_layout) + download_layout.addLayout(path_layout) + + # Sync Settings + sync_group = SettingsGroup("Sync Settings") + sync_layout = QVBoxLayout(sync_group) + sync_layout.setContentsMargins(20, 25, 20, 20) + sync_layout.setSpacing(15) + + # Auto-sync checkbox + auto_sync = QCheckBox("Auto-sync playlists every hour") + auto_sync.setChecked(True) + auto_sync.setStyleSheet(self.get_checkbox_style()) + + # Update metadata checkbox + update_metadata = QCheckBox("Update metadata from Spotify") + update_metadata.setChecked(True) + update_metadata.setStyleSheet(self.get_checkbox_style()) + + # Download missing tracks checkbox + download_missing = QCheckBox("Automatically download missing tracks") + download_missing.setChecked(False) + download_missing.setStyleSheet(self.get_checkbox_style()) + + sync_layout.addWidget(auto_sync) + sync_layout.addWidget(update_metadata) + sync_layout.addWidget(download_missing) + + # Logging Settings + logging_group = SettingsGroup("Logging Settings") + logging_layout = QVBoxLayout(logging_group) + logging_layout.setContentsMargins(20, 25, 20, 20) + logging_layout.setSpacing(15) + + # Log level + log_level_layout = QHBoxLayout() + log_level_label = QLabel("Log Level:") + log_level_label.setStyleSheet("color: #ffffff; font-size: 12px;") + + log_level_combo = QComboBox() + log_level_combo.addItems(["DEBUG", "INFO", "WARNING", "ERROR"]) + log_level_combo.setCurrentText("DEBUG") + log_level_combo.setStyleSheet(self.get_combo_style()) + + log_level_layout.addWidget(log_level_label) + log_level_layout.addWidget(log_level_combo) + log_level_layout.addStretch() + + # Log file path + log_path_layout = QHBoxLayout() + log_path_label = QLabel("Log File Path:") + log_path_label.setStyleSheet("color: #ffffff; font-size: 12px;") + + log_path_input = QLineEdit("logs/app.log") + log_path_input.setStyleSheet(self.get_input_style()) + + log_path_layout.addWidget(log_path_label) + log_path_layout.addWidget(log_path_input) + + logging_layout.addLayout(log_level_layout) + logging_layout.addLayout(log_path_layout) + + layout.addWidget(download_group) + layout.addWidget(sync_group) + layout.addWidget(logging_group) + + return column + + def get_input_style(self): + return """ + QLineEdit { + background: #404040; + border: 1px solid #606060; + border-radius: 4px; + padding: 8px; + color: #ffffff; + font-size: 11px; + } + QLineEdit:focus { + border: 1px solid #1db954; + } + """ + + def get_combo_style(self): + return """ + QComboBox { + background: #404040; + border: 1px solid #606060; + border-radius: 4px; + padding: 8px; + color: #ffffff; + font-size: 11px; + min-width: 100px; + } + QComboBox:focus { + border: 1px solid #1db954; + } + QComboBox::drop-down { + border: none; + } + """ + + def get_spin_style(self): + return """ + QSpinBox { + background: #404040; + border: 1px solid #606060; + border-radius: 4px; + padding: 8px; + color: #ffffff; + font-size: 11px; + min-width: 80px; + } + QSpinBox:focus { + border: 1px solid #1db954; + } + """ + + def get_checkbox_style(self): + return """ + QCheckBox { + color: #ffffff; + font-size: 12px; + } + QCheckBox::indicator { + width: 16px; + height: 16px; + border-radius: 8px; + border: 2px solid #b3b3b3; + background: transparent; + } + QCheckBox::indicator:checked { + background: #1db954; + border: 2px solid #1db954; + } + """ + + def get_test_button_style(self): + return """ + QPushButton { + background: transparent; + border: 1px solid #1db954; + border-radius: 15px; + color: #1db954; + font-size: 10px; + font-weight: bold; + } + QPushButton:hover { + background: rgba(29, 185, 84, 0.1); + } + """ \ No newline at end of file diff --git a/ui/pages/sync.py b/ui/pages/sync.py new file mode 100644 index 00000000..26dec7de --- /dev/null +++ b/ui/pages/sync.py @@ -0,0 +1,464 @@ +from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QFrame, QPushButton, QListWidget, QListWidgetItem, + QProgressBar, QTextEdit, QCheckBox, QComboBox, + QScrollArea, QSizePolicy) +from PyQt6.QtCore import Qt, QThread, pyqtSignal +from PyQt6.QtGui import QFont + +class PlaylistItem(QFrame): + def __init__(self, name: str, track_count: int, sync_status: str, parent=None): + super().__init__(parent) + self.name = name + self.track_count = track_count + self.sync_status = sync_status + self.is_selected = False + self.setup_ui() + + def setup_ui(self): + self.setFixedHeight(80) + self.setStyleSheet(""" + PlaylistItem { + background: #282828; + border-radius: 8px; + border: 1px solid #404040; + } + PlaylistItem:hover { + background: #333333; + border: 1px solid #1db954; + } + """) + + layout = QHBoxLayout(self) + layout.setContentsMargins(20, 15, 20, 15) + layout.setSpacing(15) + + # Checkbox + self.checkbox = QCheckBox() + self.checkbox.setStyleSheet(""" + QCheckBox::indicator { + width: 18px; + height: 18px; + border-radius: 9px; + border: 2px solid #b3b3b3; + background: transparent; + } + QCheckBox::indicator:checked { + background: #1db954; + border: 2px solid #1db954; + } + QCheckBox::indicator:checked:hover { + background: #1ed760; + } + """) + + # Content layout + content_layout = QVBoxLayout() + content_layout.setSpacing(5) + + # Playlist name + name_label = QLabel(self.name) + name_label.setFont(QFont("Arial", 12, QFont.Weight.Bold)) + name_label.setStyleSheet("color: #ffffff;") + + # Track count and status + info_layout = QHBoxLayout() + info_layout.setSpacing(20) + + track_label = QLabel(f"{self.track_count} tracks") + track_label.setFont(QFont("Arial", 10)) + track_label.setStyleSheet("color: #b3b3b3;") + + status_label = QLabel(self.sync_status) + status_label.setFont(QFont("Arial", 10)) + if self.sync_status == "Synced": + status_label.setStyleSheet("color: #1db954;") + elif self.sync_status == "Needs Sync": + status_label.setStyleSheet("color: #ffa500;") + else: + status_label.setStyleSheet("color: #e22134;") + + info_layout.addWidget(track_label) + info_layout.addWidget(status_label) + info_layout.addStretch() + + content_layout.addWidget(name_label) + content_layout.addLayout(info_layout) + + # Action button + action_btn = QPushButton("View Details") + action_btn.setFixedSize(100, 30) + action_btn.setStyleSheet(""" + QPushButton { + background: transparent; + border: 1px solid #1db954; + border-radius: 15px; + color: #1db954; + font-size: 10px; + font-weight: bold; + } + QPushButton:hover { + background: #1db954; + color: #000000; + } + """) + + layout.addWidget(self.checkbox) + layout.addLayout(content_layout) + layout.addStretch() + layout.addWidget(action_btn) + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + self.checkbox.setChecked(not self.checkbox.isChecked()) + super().mousePressEvent(event) + +class SyncOptionsPanel(QFrame): + def __init__(self, parent=None): + super().__init__(parent) + self.setup_ui() + + def setup_ui(self): + self.setStyleSheet(""" + SyncOptionsPanel { + background: #282828; + border-radius: 8px; + border: 1px solid #404040; + } + """) + + layout = QVBoxLayout(self) + layout.setContentsMargins(20, 20, 20, 20) + layout.setSpacing(15) + + # Title + title_label = QLabel("Sync Options") + title_label.setFont(QFont("Arial", 14, QFont.Weight.Bold)) + title_label.setStyleSheet("color: #ffffff;") + + # Download missing tracks option + self.download_missing = QCheckBox("Download missing tracks from Soulseek") + self.download_missing.setChecked(True) + self.download_missing.setStyleSheet(""" + QCheckBox { + color: #ffffff; + font-size: 11px; + } + QCheckBox::indicator { + width: 16px; + height: 16px; + border-radius: 8px; + border: 2px solid #b3b3b3; + background: transparent; + } + QCheckBox::indicator:checked { + background: #1db954; + border: 2px solid #1db954; + } + """) + + # Quality selection + quality_layout = QHBoxLayout() + quality_label = QLabel("Preferred Quality:") + quality_label.setStyleSheet("color: #b3b3b3; font-size: 11px;") + + self.quality_combo = QComboBox() + self.quality_combo.addItems(["FLAC", "320 kbps MP3", "256 kbps MP3", "Any"]) + self.quality_combo.setCurrentText("FLAC") + self.quality_combo.setStyleSheet(""" + QComboBox { + background: #404040; + border: 1px solid #606060; + border-radius: 4px; + padding: 5px; + color: #ffffff; + font-size: 11px; + } + QComboBox::drop-down { + border: none; + } + QComboBox::down-arrow { + image: none; + border: none; + } + """) + + quality_layout.addWidget(quality_label) + quality_layout.addWidget(self.quality_combo) + quality_layout.addStretch() + + # Update metadata option + self.update_metadata = QCheckBox("Update metadata from Spotify") + self.update_metadata.setChecked(True) + self.update_metadata.setStyleSheet(self.download_missing.styleSheet()) + + layout.addWidget(title_label) + layout.addWidget(self.download_missing) + layout.addLayout(quality_layout) + layout.addWidget(self.update_metadata) + +class SyncPage(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.setup_ui() + + def setup_ui(self): + self.setStyleSheet(""" + SyncPage { + background: #191414; + } + """) + + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(30, 30, 30, 30) + main_layout.setSpacing(25) + + # Header + header = self.create_header() + main_layout.addWidget(header) + + # Content area + content_layout = QHBoxLayout() + content_layout.setSpacing(25) + + # Left side - Playlist list + playlist_section = self.create_playlist_section() + content_layout.addWidget(playlist_section, 2) + + # Right side - Options and actions + options_section = self.create_options_section() + content_layout.addWidget(options_section, 1) + + main_layout.addLayout(content_layout) + + # Progress section + progress_section = self.create_progress_section() + main_layout.addWidget(progress_section) + + def create_header(self): + header = QWidget() + layout = QVBoxLayout(header) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(5) + + # Title + title_label = QLabel("Playlist Sync") + title_label.setFont(QFont("Arial", 28, QFont.Weight.Bold)) + title_label.setStyleSheet("color: #ffffff;") + + # Subtitle + subtitle_label = QLabel("Synchronize your Spotify playlists with Plex") + subtitle_label.setFont(QFont("Arial", 14)) + subtitle_label.setStyleSheet("color: #b3b3b3;") + + layout.addWidget(title_label) + layout.addWidget(subtitle_label) + + return header + + def create_playlist_section(self): + section = QWidget() + layout = QVBoxLayout(section) + layout.setSpacing(15) + + # Section header + header_layout = QHBoxLayout() + + section_title = QLabel("Spotify Playlists") + section_title.setFont(QFont("Arial", 16, QFont.Weight.Bold)) + section_title.setStyleSheet("color: #ffffff;") + + refresh_btn = QPushButton("πŸ”„ Refresh") + refresh_btn.setFixedSize(100, 35) + refresh_btn.setStyleSheet(""" + QPushButton { + background: #1db954; + border: none; + border-radius: 17px; + color: #000000; + font-size: 11px; + font-weight: bold; + } + QPushButton:hover { + background: #1ed760; + } + QPushButton:pressed { + background: #1aa34a; + } + """) + + header_layout.addWidget(section_title) + header_layout.addStretch() + header_layout.addWidget(refresh_btn) + + # Playlist container + playlist_container = QScrollArea() + playlist_container.setWidgetResizable(True) + playlist_container.setStyleSheet(""" + QScrollArea { + border: none; + background: transparent; + } + QScrollBar:vertical { + background: #282828; + width: 8px; + border-radius: 4px; + } + QScrollBar::handle:vertical { + background: #1db954; + border-radius: 4px; + } + """) + + playlist_widget = QWidget() + playlist_layout = QVBoxLayout(playlist_widget) + playlist_layout.setSpacing(10) + + # Sample playlists + playlists = [ + ("Liked Songs", 247, "Synced"), + ("Discover Weekly", 30, "Needs Sync"), + ("Chill Vibes", 89, "Synced"), + ("Workout Mix", 156, "Needs Sync"), + ("Road Trip", 67, "Never Synced"), + ("Focus Music", 45, "Synced") + ] + + for name, count, status in playlists: + item = PlaylistItem(name, count, status) + playlist_layout.addWidget(item) + + playlist_layout.addStretch() + playlist_container.setWidget(playlist_widget) + + layout.addLayout(header_layout) + layout.addWidget(playlist_container) + + return section + + def create_options_section(self): + section = QWidget() + layout = QVBoxLayout(section) + layout.setSpacing(20) + + # Sync options + options_panel = SyncOptionsPanel() + layout.addWidget(options_panel) + + # Action buttons + actions_frame = QFrame() + actions_frame.setStyleSheet(""" + QFrame { + background: #282828; + border-radius: 8px; + border: 1px solid #404040; + } + """) + + actions_layout = QVBoxLayout(actions_frame) + actions_layout.setContentsMargins(20, 20, 20, 20) + actions_layout.setSpacing(15) + + # Sync button + sync_btn = QPushButton("Start Sync") + sync_btn.setFixedHeight(45) + sync_btn.setStyleSheet(""" + QPushButton { + background: #1db954; + border: none; + border-radius: 22px; + color: #000000; + font-size: 14px; + font-weight: bold; + } + QPushButton:hover { + background: #1ed760; + } + QPushButton:pressed { + background: #1aa34a; + } + """) + + # Preview button + preview_btn = QPushButton("Preview Changes") + preview_btn.setFixedHeight(35) + preview_btn.setStyleSheet(""" + QPushButton { + background: transparent; + border: 1px solid #1db954; + border-radius: 17px; + color: #1db954; + font-size: 12px; + font-weight: bold; + } + QPushButton:hover { + background: rgba(29, 185, 84, 0.1); + } + """) + + actions_layout.addWidget(sync_btn) + actions_layout.addWidget(preview_btn) + + layout.addWidget(actions_frame) + layout.addStretch() + + return section + + def create_progress_section(self): + section = QFrame() + section.setFixedHeight(150) + section.setStyleSheet(""" + QFrame { + background: #282828; + border-radius: 8px; + border: 1px solid #404040; + } + """) + + layout = QVBoxLayout(section) + layout.setContentsMargins(20, 15, 20, 15) + layout.setSpacing(10) + + # Progress header + progress_header = QLabel("Sync Progress") + progress_header.setFont(QFont("Arial", 14, QFont.Weight.Bold)) + progress_header.setStyleSheet("color: #ffffff;") + + # Progress bar + self.progress_bar = QProgressBar() + self.progress_bar.setFixedHeight(8) + self.progress_bar.setStyleSheet(""" + QProgressBar { + border: none; + border-radius: 4px; + background: #404040; + } + QProgressBar::chunk { + background: #1db954; + border-radius: 4px; + } + """) + + # Progress text + self.progress_text = QLabel("Ready to sync...") + self.progress_text.setFont(QFont("Arial", 11)) + self.progress_text.setStyleSheet("color: #b3b3b3;") + + # Log area + self.log_area = QTextEdit() + self.log_area.setMaximumHeight(60) + self.log_area.setStyleSheet(""" + QTextEdit { + background: #181818; + border: 1px solid #404040; + border-radius: 4px; + color: #ffffff; + font-size: 10px; + font-family: monospace; + } + """) + self.log_area.setPlainText("Waiting for sync to start...") + + layout.addWidget(progress_header) + layout.addWidget(self.progress_bar) + layout.addWidget(self.progress_text) + layout.addWidget(self.log_area) \ No newline at end of file diff --git a/ui/sidebar.py b/ui/sidebar.py new file mode 100644 index 00000000..c45bbff1 --- /dev/null +++ b/ui/sidebar.py @@ -0,0 +1,264 @@ +from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QPushButton, + QLabel, QFrame, QSizePolicy, QSpacerItem) +from PyQt6.QtCore import Qt, pyqtSignal, QPropertyAnimation, QEasingCurve, QRect +from PyQt6.QtGui import QFont, QPalette, QIcon, QPixmap, QPainter + +class SidebarButton(QPushButton): + def __init__(self, text: str, icon_text: str = "", parent=None): + super().__init__(parent) + self.text = text + self.icon_text = icon_text + self.is_active = False + self.setup_ui() + + def setup_ui(self): + self.setFixedHeight(50) + self.setFixedWidth(200) + self.setCursor(Qt.CursorShape.PointingHandCursor) + + layout = QHBoxLayout(self) + layout.setContentsMargins(20, 0, 20, 0) + layout.setSpacing(15) + + # Icon label + self.icon_label = QLabel(self.icon_text) + self.icon_label.setFixedSize(24, 24) + self.icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.icon_label.setStyleSheet(""" + QLabel { + color: #b3b3b3; + font-size: 16px; + font-weight: bold; + border-radius: 12px; + background: rgba(255, 255, 255, 0.1); + } + """) + + # Text label + self.text_label = QLabel(self.text) + self.text_label.setFont(QFont("Arial", 11, QFont.Weight.Medium)) + + layout.addWidget(self.icon_label) + layout.addWidget(self.text_label) + layout.addStretch() + + self.update_style() + + def set_active(self, active: bool): + self.is_active = active + self.update_style() + + def update_style(self): + if self.is_active: + self.setStyleSheet(""" + SidebarButton { + background: rgba(29, 185, 84, 0.2); + border-left: 3px solid #1db954; + border-radius: 0px; + text-align: left; + padding: 0px; + } + SidebarButton:hover { + background: rgba(29, 185, 84, 0.3); + } + """) + self.text_label.setStyleSheet("color: #1db954; font-weight: bold;") + self.icon_label.setStyleSheet(""" + QLabel { + color: #1db954; + font-size: 16px; + font-weight: bold; + border-radius: 12px; + background: rgba(29, 185, 84, 0.2); + } + """) + else: + self.setStyleSheet(""" + SidebarButton { + background: transparent; + border: none; + border-radius: 0px; + text-align: left; + padding: 0px; + } + SidebarButton:hover { + background: rgba(255, 255, 255, 0.1); + } + """) + self.text_label.setStyleSheet("color: #b3b3b3;") + self.icon_label.setStyleSheet(""" + QLabel { + color: #b3b3b3; + font-size: 16px; + font-weight: bold; + border-radius: 12px; + background: rgba(255, 255, 255, 0.1); + } + """) + +class StatusIndicator(QWidget): + def __init__(self, service_name: str, parent=None): + super().__init__(parent) + self.service_name = service_name + self.is_connected = False + self.setup_ui() + + def setup_ui(self): + layout = QHBoxLayout(self) + layout.setContentsMargins(15, 5, 15, 5) + layout.setSpacing(10) + + # Status dot + self.status_dot = QLabel("●") + self.status_dot.setFixedSize(12, 12) + self.status_dot.setAlignment(Qt.AlignmentFlag.AlignCenter) + + # Service name + self.service_label = QLabel(self.service_name) + self.service_label.setFont(QFont("Arial", 9)) + + layout.addWidget(self.status_dot) + layout.addWidget(self.service_label) + layout.addStretch() + + self.update_status(False) + + def update_status(self, connected: bool): + self.is_connected = connected + if connected: + self.status_dot.setStyleSheet("color: #1db954;") + self.service_label.setStyleSheet("color: #ffffff;") + else: + self.status_dot.setStyleSheet("color: #e22134;") + self.service_label.setStyleSheet("color: #b3b3b3;") + +class ModernSidebar(QWidget): + page_changed = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self.current_page = "dashboard" + self.buttons = {} + self.setup_ui() + + def setup_ui(self): + self.setFixedWidth(220) + self.setStyleSheet(""" + ModernSidebar { + background: #121212; + border-right: 1px solid #282828; + } + """) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # Header + header = self.create_header() + layout.addWidget(header) + + # Navigation buttons + nav_section = self.create_navigation() + layout.addWidget(nav_section) + + # Spacer + layout.addItem(QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)) + + # Status section + status_section = self.create_status_section() + layout.addWidget(status_section) + + def create_header(self): + header = QWidget() + header.setFixedHeight(80) + header.setStyleSheet("background: #121212; border-bottom: 1px solid #282828;") + + layout = QVBoxLayout(header) + layout.setContentsMargins(20, 20, 20, 20) + + # App name + app_name = QLabel("NewMusic") + app_name.setFont(QFont("Arial", 18, QFont.Weight.Bold)) + app_name.setStyleSheet("color: #ffffff;") + + # Subtitle + subtitle = QLabel("Music Sync & Manager") + subtitle.setFont(QFont("Arial", 9)) + subtitle.setStyleSheet("color: #b3b3b3;") + + layout.addWidget(app_name) + layout.addWidget(subtitle) + + return header + + def create_navigation(self): + nav_widget = QWidget() + layout = QVBoxLayout(nav_widget) + layout.setContentsMargins(0, 20, 0, 20) + layout.setSpacing(5) + + # Navigation buttons + nav_items = [ + ("dashboard", "Dashboard", "πŸ“Š"), + ("sync", "Playlist Sync", "πŸ”„"), + ("downloads", "Downloads", "πŸ“₯"), + ("artists", "Artists", "🎡"), + ("settings", "Settings", "βš™οΈ") + ] + + for page_id, title, icon in nav_items: + button = SidebarButton(title, icon) + button.clicked.connect(lambda checked, pid=page_id: self.change_page(pid)) + self.buttons[page_id] = button + layout.addWidget(button) + + # Set dashboard as active by default + self.buttons["dashboard"].set_active(True) + + return nav_widget + + def create_status_section(self): + status_widget = QWidget() + status_widget.setFixedHeight(120) + status_widget.setStyleSheet("background: #181818; border-top: 1px solid #282828;") + + layout = QVBoxLayout(status_widget) + layout.setContentsMargins(0, 15, 0, 15) + layout.setSpacing(8) + + # Status title + status_title = QLabel("Connection Status") + status_title.setFont(QFont("Arial", 10, QFont.Weight.Bold)) + status_title.setStyleSheet("color: #ffffff; padding: 0 15px;") + layout.addWidget(status_title) + + # Status indicators + self.spotify_status = StatusIndicator("Spotify") + self.plex_status = StatusIndicator("Plex") + self.soulseek_status = StatusIndicator("Soulseek") + + layout.addWidget(self.spotify_status) + layout.addWidget(self.plex_status) + layout.addWidget(self.soulseek_status) + + return status_widget + + def change_page(self, page_id: str): + if page_id != self.current_page: + # Update button states + for btn_id, button in self.buttons.items(): + button.set_active(btn_id == page_id) + + self.current_page = page_id + self.page_changed.emit(page_id) + + def update_service_status(self, service: str, connected: bool): + status_map = { + "spotify": self.spotify_status, + "plex": self.plex_status, + "soulseek": self.soulseek_status + } + + if service in status_map: + status_map[service].update_status(connected) \ No newline at end of file diff --git a/utils/__pycache__/logging_config.cpython-312.pyc b/utils/__pycache__/logging_config.cpython-312.pyc new file mode 100644 index 00000000..fedb4143 Binary files /dev/null and b/utils/__pycache__/logging_config.cpython-312.pyc differ diff --git a/utils/logging_config.py b/utils/logging_config.py new file mode 100644 index 00000000..9bcf2a70 --- /dev/null +++ b/utils/logging_config.py @@ -0,0 +1,63 @@ +import logging +import sys +from pathlib import Path +from datetime import datetime +from typing import Optional + +class ColoredFormatter(logging.Formatter): + COLORS = { + 'DEBUG': '\033[94m', + 'INFO': '\033[92m', + 'WARNING': '\033[93m', + 'ERROR': '\033[91m', + 'CRITICAL': '\033[95m', + 'RESET': '\033[0m' + } + + def format(self, record): + log_color = self.COLORS.get(record.levelname, self.COLORS['RESET']) + reset_color = self.COLORS['RESET'] + + record.levelname = f"{log_color}{record.levelname}{reset_color}" + return super().format(record) + +def setup_logging(level: str = "INFO", log_file: Optional[str] = None) -> logging.Logger: + log_level = getattr(logging, level.upper(), logging.INFO) + + logger = logging.getLogger("newmusic") + logger.setLevel(log_level) + + if logger.handlers: + logger.handlers.clear() + + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setLevel(log_level) + + console_formatter = ColoredFormatter( + fmt='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) + console_handler.setFormatter(console_formatter) + logger.addHandler(console_handler) + + if log_file: + log_path = Path(log_file) + log_path.parent.mkdir(parents=True, exist_ok=True) + + file_handler = logging.FileHandler(log_path) + file_handler.setLevel(log_level) + + file_formatter = logging.Formatter( + fmt='%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) + file_handler.setFormatter(file_formatter) + logger.addHandler(file_handler) + + logger.info(f"Logging initialized with level: {level}") + return logger + +def get_logger(name: str) -> logging.Logger: + return logging.getLogger(f"newmusic.{name}") + +main_logger = get_logger("main") \ No newline at end of file