Initial commit
This commit is contained in:
commit
7d43bda3e5
38 changed files with 4017 additions and 0 deletions
9
.claude/settings.local.json
Normal file
9
.claude/settings.local.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(mkdir:*)",
|
||||
"Bash(rm:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
}
|
||||
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
1
.spotify_cache
Normal file
1
.spotify_cache
Normal 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
42
CLAUDE.md
Normal 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
2
README.md
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# newMusic
|
||||
|
||||
BIN
config/__pycache__/settings.cpython-310.pyc
Normal file
BIN
config/__pycache__/settings.cpython-310.pyc
Normal file
Binary file not shown.
BIN
config/__pycache__/settings.cpython-312.pyc
Normal file
BIN
config/__pycache__/settings.cpython-312.pyc
Normal file
Binary file not shown.
27
config/config.json
Normal file
27
config/config.json
Normal 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
99
config/settings.py
Normal 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()
|
||||
BIN
core/__pycache__/plex_client.cpython-312.pyc
Normal file
BIN
core/__pycache__/plex_client.cpython-312.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/soulseek_client.cpython-312.pyc
Normal file
BIN
core/__pycache__/soulseek_client.cpython-312.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/spotify_client.cpython-312.pyc
Normal file
BIN
core/__pycache__/spotify_client.cpython-312.pyc
Normal file
Binary file not shown.
223
core/matching_engine.py
Normal file
223
core/matching_engine.py
Normal 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
275
core/plex_client.py
Normal 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
300
core/soulseek_client.py
Normal 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
194
core/spotify_client.py
Normal 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
63
logs/app.log
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
2025-07-09 11:36:49 - newmusic - [92mINFO[0m - setup_logging:57 - Logging initialized with level: DEBUG
|
||||
2025-07-09 11:36:49 - newmusic.main - [92mINFO[0m - main:261 - Starting NewMusic application
|
||||
2025-07-09 11:36:55 - newmusic.spotify_client - [91mERROR[0m - _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 - [92mINFO[0m - _find_music_library:98 - Found music library: Music
|
||||
2025-07-09 11:36:55 - newmusic.plex_client - [92mINFO[0m - _setup_client:84 - Successfully connected to Plex server: PLEX-MACHINE
|
||||
2025-07-09 11:36:55 - newmusic.soulseek_client - [92mINFO[0m - _setup_client:88 - Soulseek client configured with slskd at http://localhost:5030
|
||||
2025-07-09 11:40:35 - newmusic - [92mINFO[0m - setup_logging:57 - Logging initialized with level: DEBUG
|
||||
2025-07-09 11:40:35 - newmusic.main - [92mINFO[0m - main:261 - Starting NewMusic application
|
||||
2025-07-09 11:40:38 - newmusic.spotify_client - [91mERROR[0m - _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 - [92mINFO[0m - _find_music_library:98 - Found music library: Music
|
||||
2025-07-09 11:40:38 - newmusic.plex_client - [92mINFO[0m - _setup_client:84 - Successfully connected to Plex server: PLEX-MACHINE
|
||||
2025-07-09 11:40:38 - newmusic.soulseek_client - [92mINFO[0m - _setup_client:88 - Soulseek client configured with slskd at http://localhost:5030
|
||||
2025-07-09 11:42:17 - newmusic - [92mINFO[0m - setup_logging:57 - Logging initialized with level: DEBUG
|
||||
2025-07-09 11:42:17 - newmusic.main - [92mINFO[0m - main:261 - Starting NewMusic application
|
||||
2025-07-09 11:42:18 - newmusic.spotify_client - [92mINFO[0m - _setup_client:84 - Successfully authenticated with Spotify as broquethomas
|
||||
2025-07-09 11:42:18 - newmusic.plex_client - [92mINFO[0m - _find_music_library:98 - Found music library: Music
|
||||
2025-07-09 11:42:18 - newmusic.plex_client - [92mINFO[0m - _setup_client:84 - Successfully connected to Plex server: PLEX-MACHINE
|
||||
2025-07-09 11:42:18 - newmusic.soulseek_client - [92mINFO[0m - _setup_client:88 - Soulseek client configured with slskd at http://localhost:5030
|
||||
2025-07-09 11:42:24 - newmusic.spotify_client - [92mINFO[0m - get_user_playlists:106 - Fetching tracks for playlist: Aether
|
||||
2025-07-09 11:42:24 - newmusic.spotify_client - [92mINFO[0m - get_user_playlists:106 - Fetching tracks for playlist: Favorite Artists
|
||||
2025-07-09 11:42:26 - newmusic.spotify_client - [92mINFO[0m - get_user_playlists:106 - Fetching tracks for playlist: Brittnea
|
||||
2025-07-09 11:42:27 - newmusic.spotify_client - [92mINFO[0m - get_user_playlists:106 - Fetching tracks for playlist: Baleigh
|
||||
2025-07-09 11:42:29 - newmusic.spotify_client - [92mINFO[0m - get_user_playlists:106 - Fetching tracks for playlist: Extra Music
|
||||
2025-07-09 11:42:29 - newmusic.spotify_client - [92mINFO[0m - get_user_playlists:106 - Fetching tracks for playlist: Maggi Main
|
||||
2025-07-09 11:42:34 - newmusic.spotify_client - [92mINFO[0m - get_user_playlists:106 - Fetching tracks for playlist: Broque Main
|
||||
2025-07-09 11:42:42 - newmusic.spotify_client - [92mINFO[0m - get_user_playlists:113 - Retrieved 7 playlists
|
||||
2025-07-09 12:00:42 - newmusic - [92mINFO[0m - setup_logging:57 - Logging initialized with level: DEBUG
|
||||
2025-07-09 12:00:42 - newmusic.main - [92mINFO[0m - main:173 - Starting NewMusic application
|
||||
2025-07-09 12:00:42 - newmusic.spotify_client - [92mINFO[0m - _setup_client:84 - Successfully authenticated with Spotify as broquethomas
|
||||
2025-07-09 12:00:42 - newmusic.plex_client - [92mINFO[0m - _find_music_library:98 - Found music library: Music
|
||||
2025-07-09 12:00:42 - newmusic.plex_client - [92mINFO[0m - _setup_client:84 - Successfully connected to Plex server: PLEX-MACHINE
|
||||
2025-07-09 12:00:42 - newmusic.soulseek_client - [92mINFO[0m - _setup_client:88 - Soulseek client configured with slskd at http://localhost:5030
|
||||
2025-07-09 12:01:02 - newmusic - [92mINFO[0m - setup_logging:57 - Logging initialized with level: DEBUG
|
||||
2025-07-09 12:01:02 - newmusic.main - [92mINFO[0m - main:173 - Starting NewMusic application
|
||||
2025-07-09 12:01:03 - newmusic.spotify_client - [92mINFO[0m - _setup_client:84 - Successfully authenticated with Spotify as broquethomas
|
||||
2025-07-09 12:01:03 - newmusic.plex_client - [92mINFO[0m - _find_music_library:98 - Found music library: Music
|
||||
2025-07-09 12:01:03 - newmusic.plex_client - [92mINFO[0m - _setup_client:84 - Successfully connected to Plex server: PLEX-MACHINE
|
||||
2025-07-09 12:01:03 - newmusic.soulseek_client - [92mINFO[0m - _setup_client:88 - Soulseek client configured with slskd at http://localhost:5030
|
||||
2025-07-09 12:01:09 - newmusic - [92mINFO[0m - setup_logging:57 - Logging initialized with level: DEBUG
|
||||
2025-07-09 12:01:09 - newmusic.main - [92mINFO[0m - main:173 - Starting NewMusic application
|
||||
2025-07-09 12:01:09 - newmusic.spotify_client - [92mINFO[0m - _setup_client:84 - Successfully authenticated with Spotify as broquethomas
|
||||
2025-07-09 12:01:09 - newmusic.plex_client - [92mINFO[0m - _find_music_library:98 - Found music library: Music
|
||||
2025-07-09 12:01:09 - newmusic.plex_client - [92mINFO[0m - _setup_client:84 - Successfully connected to Plex server: PLEX-MACHINE
|
||||
2025-07-09 12:01:09 - newmusic.soulseek_client - [92mINFO[0m - _setup_client:88 - Soulseek client configured with slskd at http://localhost:5030
|
||||
2025-07-09 12:03:25 - newmusic - [92mINFO[0m - setup_logging:57 - Logging initialized with level: DEBUG
|
||||
2025-07-09 12:03:25 - newmusic.main - [92mINFO[0m - main:173 - Starting NewMusic application
|
||||
2025-07-09 12:03:25 - newmusic.spotify_client - [92mINFO[0m - _setup_client:84 - Successfully authenticated with Spotify as broquethomas
|
||||
2025-07-09 12:03:25 - newmusic.plex_client - [92mINFO[0m - _find_music_library:98 - Found music library: Music
|
||||
2025-07-09 12:03:25 - newmusic.plex_client - [92mINFO[0m - _setup_client:84 - Successfully connected to Plex server: PLEX-MACHINE
|
||||
2025-07-09 12:03:25 - newmusic.soulseek_client - [92mINFO[0m - _setup_client:88 - Soulseek client configured with slskd at http://localhost:5030
|
||||
2025-07-09 12:03:26 - newmusic.main - [92mINFO[0m - change_page:139 - Changed to page: dashboard
|
||||
2025-07-09 12:03:32 - newmusic.main - [92mINFO[0m - change_page:139 - Changed to page: sync
|
||||
2025-07-09 12:03:33 - newmusic.main - [92mINFO[0m - change_page:139 - Changed to page: downloads
|
||||
2025-07-09 12:03:34 - newmusic.main - [92mINFO[0m - change_page:139 - Changed to page: artists
|
||||
2025-07-09 12:03:34 - newmusic.main - [92mINFO[0m - change_page:139 - Changed to page: settings
|
||||
2025-07-09 12:03:35 - newmusic.main - [92mINFO[0m - change_page:139 - Changed to page: dashboard
|
||||
2025-07-09 12:06:27 - newmusic.main - [92mINFO[0m - change_page:139 - Changed to page: settings
|
||||
2025-07-09 12:06:28 - newmusic.main - [92mINFO[0m - change_page:139 - Changed to page: sync
|
||||
2025-07-09 12:06:28 - newmusic.main - [92mINFO[0m - change_page:139 - Changed to page: dashboard
|
||||
2025-07-09 12:06:29 - newmusic.main - [92mINFO[0m - change_page:139 - Changed to page: sync
|
||||
2025-07-09 12:06:29 - newmusic.main - [92mINFO[0m - change_page:139 - Changed to page: downloads
|
||||
2025-07-09 12:06:30 - newmusic.main - [92mINFO[0m - change_page:139 - Changed to page: dashboard
|
||||
2025-07-09 12:06:56 - newmusic.main - [92mINFO[0m - change_page:139 - Changed to page: settings
|
||||
10
logs/newmusic.log
Normal file
10
logs/newmusic.log
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
2025-07-08 23:06:53 - newmusic - [92mINFO[0m - setup_logging:57 - Logging initialized with level: INFO
|
||||
2025-07-08 23:06:53 - newmusic.main - [92mINFO[0m - main:259 - Starting NewMusic application
|
||||
2025-07-08 23:06:53 - newmusic.spotify_client - [93mWARNING[0m - _setup_client:69 - Spotify credentials not configured
|
||||
2025-07-08 23:06:53 - newmusic.plex_client - [93mWARNING[0m - _setup_client:73 - Plex server URL not configured
|
||||
2025-07-08 23:06:53 - newmusic.soulseek_client - [92mINFO[0m - _setup_client:88 - Soulseek client configured with slskd at http://localhost:5030
|
||||
2025-07-09 11:26:03 - newmusic - [92mINFO[0m - setup_logging:57 - Logging initialized with level: INFO
|
||||
2025-07-09 11:26:03 - newmusic.main - [92mINFO[0m - main:259 - Starting NewMusic application
|
||||
2025-07-09 11:26:03 - newmusic.spotify_client - [93mWARNING[0m - _setup_client:69 - Spotify credentials not configured
|
||||
2025-07-09 11:26:03 - newmusic.plex_client - [93mWARNING[0m - _setup_client:73 - Plex server URL not configured
|
||||
2025-07-09 11:26:03 - newmusic.soulseek_client - [92mINFO[0m - _setup_client:88 - Soulseek client configured with slskd at http://localhost:5030
|
||||
196
main.py
Normal file
196
main.py
Normal 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
8
project.txt
Normal 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
10
requirements.txt
Normal 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
271
services/sync_service.py
Normal 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)}
|
||||
BIN
ui/__pycache__/sidebar.cpython-312.pyc
Normal file
BIN
ui/__pycache__/sidebar.cpython-312.pyc
Normal file
Binary file not shown.
0
ui/pages/__init__.py
Normal file
0
ui/pages/__init__.py
Normal file
BIN
ui/pages/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
ui/pages/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
ui/pages/__pycache__/artists.cpython-312.pyc
Normal file
BIN
ui/pages/__pycache__/artists.cpython-312.pyc
Normal file
Binary file not shown.
BIN
ui/pages/__pycache__/dashboard.cpython-312.pyc
Normal file
BIN
ui/pages/__pycache__/dashboard.cpython-312.pyc
Normal file
Binary file not shown.
BIN
ui/pages/__pycache__/downloads.cpython-312.pyc
Normal file
BIN
ui/pages/__pycache__/downloads.cpython-312.pyc
Normal file
Binary file not shown.
BIN
ui/pages/__pycache__/settings.cpython-312.pyc
Normal file
BIN
ui/pages/__pycache__/settings.cpython-312.pyc
Normal file
Binary file not shown.
BIN
ui/pages/__pycache__/sync.cpython-312.pyc
Normal file
BIN
ui/pages/__pycache__/sync.cpython-312.pyc
Normal file
Binary file not shown.
359
ui/pages/artists.py
Normal file
359
ui/pages/artists.py
Normal 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
220
ui/pages/dashboard.py
Normal 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
488
ui/pages/downloads.py
Normal 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
427
ui/pages/settings.py
Normal 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
464
ui/pages/sync.py
Normal 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
264
ui/sidebar.py
Normal 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)
|
||||
BIN
utils/__pycache__/logging_config.cpython-312.pyc
Normal file
BIN
utils/__pycache__/logging_config.cpython-312.pyc
Normal file
Binary file not shown.
63
utils/logging_config.py
Normal file
63
utils/logging_config.py
Normal 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")
|
||||
Loading…
Reference in a new issue