Initial commit

This commit is contained in:
Broque Thomas 2025-07-09 12:07:41 -07:00
commit 7d43bda3e5
38 changed files with 4017 additions and 0 deletions

View file

@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(mkdir:*)",
"Bash(rm:*)"
],
"deny": []
}
}

2
.gitattributes vendored Normal file
View file

@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

1
.spotify_cache Normal file
View file

@ -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}

42
CLAUDE.md Normal file
View file

@ -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

2
README.md Normal file
View file

@ -0,0 +1,2 @@
# newMusic

Binary file not shown.

Binary file not shown.

27
config/config.json Normal file
View file

@ -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
}
}

99
config/settings.py Normal file
View file

@ -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()

Binary file not shown.

Binary file not shown.

Binary file not shown.

223
core/matching_engine.py Normal file
View file

@ -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()

275
core/plex_client.py Normal file
View file

@ -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

300
core/soulseek_client.py Normal file
View file

@ -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

194
core/spotify_client.py Normal file
View file

@ -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

63
logs/app.log Normal file
View file

@ -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

10
logs/newmusic.log Normal file
View file

@ -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

196
main.py Normal file
View file

@ -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()

8
project.txt Normal file
View file

@ -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.

10
requirements.txt Normal file
View file

@ -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

271
services/sync_service.py Normal file
View file

@ -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)}

Binary file not shown.

0
ui/pages/__init__.py Normal file
View file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

359
ui/pages/artists.py Normal file
View file

@ -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

220
ui/pages/dashboard.py Normal file
View file

@ -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

488
ui/pages/downloads.py Normal file
View file

@ -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

427
ui/pages/settings.py Normal file
View file

@ -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);
}
"""

464
ui/pages/sync.py Normal file
View file

@ -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)

264
ui/sidebar.py Normal file
View file

@ -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)

Binary file not shown.

63
utils/logging_config.py Normal file
View file

@ -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")