From 7d43bda3e5818c00d974f061d2988d3495211643 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Wed, 9 Jul 2025 12:07:41 -0700 Subject: [PATCH] Initial commit --- .claude/settings.local.json | 9 + .gitattributes | 2 + .spotify_cache | 1 + CLAUDE.md | 42 ++ README.md | 2 + config/__pycache__/settings.cpython-310.pyc | Bin 0 -> 3963 bytes config/__pycache__/settings.cpython-312.pyc | Bin 0 -> 6332 bytes config/config.json | 27 + config/settings.py | 99 ++++ core/__pycache__/plex_client.cpython-312.pyc | Bin 0 -> 14506 bytes .../soulseek_client.cpython-312.pyc | Bin 0 -> 14952 bytes .../spotify_client.cpython-312.pyc | Bin 0 -> 9856 bytes core/matching_engine.py | 223 ++++++++ core/plex_client.py | 275 ++++++++++ core/soulseek_client.py | 300 +++++++++++ core/spotify_client.py | 194 +++++++ logs/app.log | 63 +++ logs/newmusic.log | 10 + main.py | 196 +++++++ project.txt | 8 + requirements.txt | 10 + services/sync_service.py | 271 ++++++++++ ui/__pycache__/sidebar.cpython-312.pyc | Bin 0 -> 13611 bytes ui/pages/__init__.py | 0 ui/pages/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 145 bytes ui/pages/__pycache__/artists.cpython-312.pyc | Bin 0 -> 14514 bytes .../__pycache__/dashboard.cpython-312.pyc | Bin 0 -> 10341 bytes .../__pycache__/downloads.cpython-312.pyc | Bin 0 -> 18608 bytes ui/pages/__pycache__/settings.cpython-312.pyc | Bin 0 -> 17935 bytes ui/pages/__pycache__/sync.cpython-312.pyc | Bin 0 -> 18912 bytes ui/pages/artists.py | 359 +++++++++++++ ui/pages/dashboard.py | 220 ++++++++ ui/pages/downloads.py | 488 ++++++++++++++++++ ui/pages/settings.py | 427 +++++++++++++++ ui/pages/sync.py | 464 +++++++++++++++++ ui/sidebar.py | 264 ++++++++++ .../logging_config.cpython-312.pyc | Bin 0 -> 3276 bytes utils/logging_config.py | 63 +++ 38 files changed, 4017 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .gitattributes create mode 100644 .spotify_cache create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 config/__pycache__/settings.cpython-310.pyc create mode 100644 config/__pycache__/settings.cpython-312.pyc create mode 100644 config/config.json create mode 100644 config/settings.py create mode 100644 core/__pycache__/plex_client.cpython-312.pyc create mode 100644 core/__pycache__/soulseek_client.cpython-312.pyc create mode 100644 core/__pycache__/spotify_client.cpython-312.pyc create mode 100644 core/matching_engine.py create mode 100644 core/plex_client.py create mode 100644 core/soulseek_client.py create mode 100644 core/spotify_client.py create mode 100644 logs/app.log create mode 100644 logs/newmusic.log create mode 100644 main.py create mode 100644 project.txt create mode 100644 requirements.txt create mode 100644 services/sync_service.py create mode 100644 ui/__pycache__/sidebar.cpython-312.pyc create mode 100644 ui/pages/__init__.py create mode 100644 ui/pages/__pycache__/__init__.cpython-312.pyc create mode 100644 ui/pages/__pycache__/artists.cpython-312.pyc create mode 100644 ui/pages/__pycache__/dashboard.cpython-312.pyc create mode 100644 ui/pages/__pycache__/downloads.cpython-312.pyc create mode 100644 ui/pages/__pycache__/settings.cpython-312.pyc create mode 100644 ui/pages/__pycache__/sync.cpython-312.pyc create mode 100644 ui/pages/artists.py create mode 100644 ui/pages/dashboard.py create mode 100644 ui/pages/downloads.py create mode 100644 ui/pages/settings.py create mode 100644 ui/pages/sync.py create mode 100644 ui/sidebar.py create mode 100644 utils/__pycache__/logging_config.cpython-312.pyc create mode 100644 utils/logging_config.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..122fc7b8 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(mkdir:*)", + "Bash(rm:*)" + ], + "deny": [] + } +} \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..dfe07704 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.spotify_cache b/.spotify_cache new file mode 100644 index 00000000..09e1b530 --- /dev/null +++ b/.spotify_cache @@ -0,0 +1 @@ +{"access_token": "BQDHuavPHFxKGfglVQ7zVOIVbF8ufUuQlH_d-mqEsy0PLmbsHR9OxUyB-QVgF_5-Nrbqwni67byBlNFhBg4dwx45A-SCedirLIMu5IIy6CSCszxwjHXLwhc4Kmz3iGbMytpLQpbsDfqTCTHk2xlfo2DY29XM_Be3ToRWv7LWFCadeIOBxjz2td_xRCDezEFjoZ-g1S9dbVO52dOwo2LPvUk4na5HKE8aH7fJ4uhZNDSyg-Isqm54JQGmWUOnGwxI", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "AQDmfQkPCGObfJeTUIbW1hAAwhSqkuHRA3Qh2dqVYMRh0eCkFMQgPNJDDzF8y-BiaVbj80zePkK_XSfYH1aJutMtNbnsqRKWuxP31BTrMc7pdUdbE7Fma4oH8wpDUKdG3MM", "scope": "user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email", "expires_at": 1752090138} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..8907c6e6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,42 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is a new music management application project that aims to create a Spotify-like desktop application with Python. The project is in its initial planning phase with only a project requirements document (`project.txt`) currently present. + +## Project Requirements + +The application will be a music management tool that: +- Connects to Spotify API and Plex Media Server +- Features an elegant, animated, vibrant theme similar to Spotify's desktop/web app +- Synchronizes Spotify playlists to Plex using robust matching systems +- Integrates with Soulseek for downloading FLAC/high-quality audio files +- Updates music metadata on Plex based on Spotify metadata including album art +- Provides core functionality that feels and looks like Spotify + +## Configuration + +The application will use a central `config.json` file to store: +- Spotify API credentials and login information +- Plex Media Server connection details +- Other connected service configurations + +## Development Status + +This is a greenfield project with no existing codebase. When implementing: +- Create a Python-based application with GUI framework (likely PyQt, Tkinter, or web-based with Flask/FastAPI) +- Implement modular architecture separating concerns for different services (Spotify, Plex, Soulseek) +- Focus on robust matching algorithms for music synchronization +- Prioritize user experience with Spotify-like interface design +- Ensure secure handling of API credentials and authentication tokens + +## Key Components to Implement + +1. **Configuration Management**: Secure handling of API keys and service credentials +2. **Spotify Integration**: Playlist retrieval and metadata extraction +3. **Plex Integration**: Media server synchronization and metadata updates +4. **Soulseek Integration**: Music discovery and download functionality +5. **Matching Engine**: Robust algorithms for matching tracks across services +6. **User Interface**: Spotify-inspired design with modern, animated elements \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..6d90f010 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# newMusic + diff --git a/config/__pycache__/settings.cpython-310.pyc b/config/__pycache__/settings.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a9d1f74f7975e351d84deb4ecfa49ed1c5d638cc GIT binary patch literal 3963 zcma)9TW=f36`q;Ba7itxo2}S#?8vTNfNeoF0eVYa*Qp&BMH?lk3#28|E-21WUTL{Y z&n~Tqpn#%sAB(>9tt}uu`Umtk^grxtpZuJ+6sh{1*_A@ABqSs_oOAZf%sJn=%-N*b zY%qNO{jd00;xqOiB4$4ph==&4zri4xcJFInU zW^PB`u->h6_H!m3>AqyrJrLc7Y85D)Q7ea@I-q*hnRyHY4?AfzSjmrmD~j&eY`vi8#M zy3&*Nm#kZp4LOH@Pu`PFWyzMRL#{q><-A8749wh7Pw2`F5~Pqc^lM~QU)BO z(H;2>aIZ=q(0(&-K1$+2xb;CV?rkak`BLAI>&0JZJ5Azzt{{RDVJl{Ur8)f`5w>?Q zWNgY0_>=`aWEh2{(VDVD`-qXVpR!kcqwQ!Hvbv7pbZ^p#=U^RZg{NGIE>pQY=u+8vJ5uwUA-o~kSh z8L9c-SGOp}}2zeLh)vGz&)OL@;-?%EmhGBYaLjHfFTg82>j+rfi#S zbIEV8eLm%f;)v;cnKk879EiXA`(i2xhL3@mWH!DB76%}2v-H+3=YWT^-<2R`&Y^q6 z_ARs8!s@13U7K1*ko8t| z8!|ej5cG`CCq@yUzj5T>9 zOv53KWpD*iKW0NC5wcZ`)>UqhXSo%hXXtZhw!#MF|I1eZ}6D=Iev zUVj@qzdpvNZRzh|UO2Oj*%^yTJ-v=0cl)NG>bHqg@wbJdO#GB;1KGSFJk)>&_XSFE z#cIf&iDRn7CyxCLZ3(Fg$A;`k6i%w8qa+Ild#|{D2aoYb&{lO=qH_EX{k0SM{{vnhWPXfca`XJzGjd;ju$Ly| zC{=3b^23*n^`%&;0A)&{K&A3tAp3QcC@~m0(N{{9D*WnHg$rWutmqjHC>KuhA^igg z$_MlixXx#%elZ7P8WJn2w}0ZN=w=usrPjPe;#=p{Og{GC5lI5&8%8 z>^=<-XsBdNGfYdY##+e_A(39gK&8XuEwm2e*cak|z9qi!ZSmj9jk9@lX7w2esie?- zVeSeFM7kOe(a;$Hnk8kRD(3}It0iTCD(40!>HdRSu-e9Cu`Ds2Q^+P)E35*#Nt1HC zbAsQbVMqhwWa%WKJ71z{RlsTs{Yz+W;Jmor;06j2$|v{iCy+Kh@Typ)_Unsk-Kog8 zxu*6?Q~S|gpAo%${vOS!^j2a-zehY%nck-k>on!V(& z_$$`Qw`Hw1jcnW3rbiJ;n!DNFi1OBGVzi^o%9qW%NwTGTqi1`afqCUDdgB_s8Aah{ p@m@p6=B?u53x>se)kQky-u(ix2NZec=JycBSKtO8ZC&G&{{vhZl9&Jh literal 0 HcmV?d00001 diff --git a/config/__pycache__/settings.cpython-312.pyc b/config/__pycache__/settings.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6d169b4944e444bcecd0dd56fb9a13715a98359c GIT binary patch literal 6332 zcmb_gO>7&-6`uX$pD0lz^+Q>bC0iCP+LqkdQR=#hUCFZJ6pa$ZPHVGnS&F-oC{v^| zyRt=sYTd#I2@PPWDIgkYYZ~Z5hI`1RMGFLIoJ$V9h*<{`HYysRx%fs)0SxHS_Pt#$ z#Xma%>ajIHZ{C|3zW2?0Z}uPc^$r5%lRw8N6HY?@jRUjrh0gXEbXJH+L?%XpM#}^l z7_%{UnhSE%e2||Of&zncT+A|U4O;a+AG1x{gLa0nyk{s`Upp(0Z!vbq7g;ohT=)h_Qs47O~k{o0HayXNi;4g(Bt0>E0d9eu-;e;gYEx7 zXN5=v4;W;{W|5J&aaLq+3PE1vBmw&TO+IMB;{>gu1!|kvBHAQQv`cp2u#a0r2XH%d zS{;me(FqcDFz1vwT&)fEfOYDDT3gkHx)QwTsTh?P@9w1K&w`NCJ`TxPP`tDhbJVe)kXASXa==bFo3Qv4r=_{{uxv& zL}8%OOPaY~=%-1jNR1L073D=7p|l}ErtD^aw>1sSkR-Xsj0OaXhN1|%;;F>264n}| zc!VZ(TZgVnNzECGCBkCJICy~7cv*^#>vA$4pu2l#;OxmO!!+^EtknG`O-xAr*okJLk}^xY?l z0xwWm%suD1vOPI-g~Fb$N;yK(oCzb{AC&D1zt3GQA@aKtY3>5)AukY_xy~}=GGX9f zlFV_J$#tfZ{%)T^4+x_v*FXiBSURS$*TG9Cq-)PI6A!0)>mA5GQRICQL(bQhU>azeS2xr|0n(lZ)=^jta z#>JD}8bbqC9helIx#m6x45JC<98RC5G(qVer1Mj90#J&+k=6s@9;+jfc0<457ttAS!)ziq1ypGdv!e*Sj=Nr1$Y_vY!WjZTJuy3(HdNlj%w|S)QTP3Kso{&-I+vSYl!V zUUjvPso${zhk42xcdVS_2dKy4n#wFj#A|SopXLnAJQZGTWU4ft!jf2u^DNBciWQ|e zU-rVP^@`h#y=JaE$Iv)KO7dN%rd%;ME|GhyOs?juQ7pdTO5|Sp26DxBT#4MCGC8j1 zIR%s7Y{9u&Mm2jR7KKzR6cx4lLRXd|@b1=Z2-(mqjcJ0CxC(DLyBw3Rib#RPYzQ<> z&ti2RDvcjYBx1UIF1+FBDV!U@>Q$&J9&q(hxqzWrDv7D~a?e*b`36d7p#nGUX<7FK za-M+dIlN%i)9ap`t4DS9LK1-wy2Cl|Vb$BeV1t(#WOl79?aS?%AFJ+w>O2H_Mg0*- z>h4#a16v$WzXWR2if`GMIRx)L)#=|7fNHE^&=0Ea{i?HP%ZhXxuI*dtSnkMN zRo#bF=iz*v#qOx~7x*DTPeB@Vh?jf?stM+r5Jb~rq|;07O8`%<0*Bdttwb&GeUqK4 z!1uK~Rl!cQ{0=@p1gxVecOhaA6d{;ut{@puOx0r?95C^ia_B=HlqruHu?BY0$yI}lh2%-7Zjdiq+cP6~&fh-2)_X$j_>S6o^3&Gw z50#HbJ~{v3{MzfHIwGm3$2XgNnWj6w+rI2kwe6VNbo|q%iy!iTz4-S_f4lTiOnv2o zdg9`yuOoBu&ZXOz*8C^c&hM(emmgdCMn@h}VFOkHCgm~my`x4mfbUg`ufISGeu0aJ zr=?IxbA&?h8D%zxeP<~2&TKeVnBioFQV;NJ!dOy~WV#>v`fHYAl1X{2FukIfvr>d@ z#T}=*Gso*l-@-~)X%Ks;lY2(IG#<(L>Ze%Y4Uiu}b(1`1d7&eFW}85hcRGaD?AzM} znk}Co9NB7Mg~7)*htRwv=YiUsy`VQA06&cKK8Mg@%yeXrBdH_rV}yg*kyY<%WYu?f zbeq61Z()Q3*`{n{^>}vj?p~xFKw4k+;%d*TyxMp7r%3D5X&Etle3i?dxEW2u78C$K9X7SlX5INW_;E~RcZ}} bytes: + key_file = self.config_path.parent / ".encryption_key" + if key_file.exists(): + with open(key_file, 'rb') as f: + return f.read() + else: + key = Fernet.generate_key() + with open(key_file, 'wb') as f: + f.write(key) + key_file.chmod(0o600) + return key + + def _load_config(self): + if not self.config_path.exists(): + raise FileNotFoundError(f"Configuration file not found: {self.config_path}") + + with open(self.config_path, 'r') as f: + self.config_data = json.load(f) + + def _save_config(self): + with open(self.config_path, 'w') as f: + json.dump(self.config_data, f, indent=2) + + def get(self, key: str, default: Any = None) -> Any: + keys = key.split('.') + value = self.config_data + + for k in keys: + if isinstance(value, dict) and k in value: + value = value[k] + else: + return default + + return value + + def set(self, key: str, value: Any): + keys = key.split('.') + config = self.config_data + + for k in keys[:-1]: + if k not in config: + config[k] = {} + config = config[k] + + config[keys[-1]] = value + self._save_config() + + def get_spotify_config(self) -> Dict[str, str]: + return self.get('spotify', {}) + + def get_plex_config(self) -> Dict[str, str]: + return self.get('plex', {}) + + def get_soulseek_config(self) -> Dict[str, str]: + return self.get('soulseek', {}) + + def get_settings(self) -> Dict[str, Any]: + return self.get('settings', {}) + + def get_database_config(self) -> Dict[str, str]: + return self.get('database', {}) + + def get_logging_config(self) -> Dict[str, str]: + return self.get('logging', {}) + + def is_configured(self) -> bool: + spotify = self.get_spotify_config() + plex = self.get_plex_config() + soulseek = self.get_soulseek_config() + + return ( + bool(spotify.get('client_id')) and + bool(spotify.get('client_secret')) and + bool(plex.get('base_url')) and + bool(plex.get('token')) and + bool(soulseek.get('slskd_url')) + ) + + def validate_config(self) -> Dict[str, bool]: + return { + 'spotify': bool(self.get('spotify.client_id')) and bool(self.get('spotify.client_secret')), + 'plex': bool(self.get('plex.base_url')) and bool(self.get('plex.token')), + 'soulseek': bool(self.get('soulseek.slskd_url')) + } + +config_manager = ConfigManager() \ No newline at end of file diff --git a/core/__pycache__/plex_client.cpython-312.pyc b/core/__pycache__/plex_client.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..94be68173acc3efbce40c967356e531d291a292c GIT binary patch literal 14506 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 K0mqbl!v6s^O#uA> literal 0 HcmV?d00001 diff --git a/core/__pycache__/soulseek_client.cpython-312.pyc b/core/__pycache__/soulseek_client.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ed08edcb6fd6d3d24ef0592da394cdcb30e0fb49 GIT binary patch literal 14952 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 literal 0 HcmV?d00001 diff --git a/core/__pycache__/spotify_client.cpython-312.pyc b/core/__pycache__/spotify_client.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1cb01d08ce628c8769977ac34197b71d8384a894 GIT binary patch literal 9856 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 literal 0 HcmV?d00001 diff --git a/core/matching_engine.py b/core/matching_engine.py new file mode 100644 index 00000000..e8f14193 --- /dev/null +++ b/core/matching_engine.py @@ -0,0 +1,223 @@ +from typing import List, Optional, Dict, Any, Tuple +import re +from dataclasses import dataclass +from difflib import SequenceMatcher +from utils.logging_config import get_logger +from core.spotify_client import Track as SpotifyTrack +from core.plex_client import PlexTrackInfo + +logger = get_logger("matching_engine") + +@dataclass +class MatchResult: + spotify_track: SpotifyTrack + plex_track: Optional[PlexTrackInfo] + confidence: float + match_type: str + + @property + def is_match(self) -> bool: + return self.plex_track is not None and self.confidence >= 0.7 + +class MusicMatchingEngine: + def __init__(self): + self.title_patterns = [ + r'\(.*?\)', + r'\[.*?\]', + r'\s*-\s*remaster.*', + r'\s*-\s*remix.*', + r'\s*-\s*live.*', + r'\s*-\s*acoustic.*', + r'\s*feat\..*', + r'\s*ft\..*', + r'\s*featuring.*', + ] + + self.artist_patterns = [ + r'\s*feat\..*', + r'\s*ft\..*', + r'\s*featuring.*', + r'\s*&.*', + r'\s*and.*', + ] + + def normalize_string(self, text: str) -> str: + if not text: + return "" + + text = text.lower().strip() + + text = re.sub(r'[^\w\s]', '', text) + + text = re.sub(r'\s+', ' ', text) + + return text + + def clean_title(self, title: str) -> str: + cleaned = title + + for pattern in self.title_patterns: + cleaned = re.sub(pattern, '', cleaned, flags=re.IGNORECASE) + + return self.normalize_string(cleaned) + + def clean_artist(self, artist: str) -> str: + cleaned = artist + + for pattern in self.artist_patterns: + cleaned = re.sub(pattern, '', cleaned, flags=re.IGNORECASE) + + return self.normalize_string(cleaned) + + def extract_main_artist(self, artists: List[str]) -> str: + if not artists: + return "" + + main_artist = artists[0] + return self.clean_artist(main_artist) + + def similarity_score(self, str1: str, str2: str) -> float: + if not str1 or not str2: + return 0.0 + + return SequenceMatcher(None, str1, str2).ratio() + + def duration_similarity(self, duration1: int, duration2: int) -> float: + if duration1 == 0 or duration2 == 0: + return 0.5 + + max_duration = max(duration1, duration2) + min_duration = min(duration1, duration2) + + if max_duration == 0: + return 0.5 + + diff_ratio = abs(max_duration - min_duration) / max_duration + + if diff_ratio <= 0.05: + return 1.0 + elif diff_ratio <= 0.1: + return 0.8 + elif diff_ratio <= 0.2: + return 0.6 + else: + return 0.3 + + def calculate_match_confidence(self, spotify_track: SpotifyTrack, plex_track: PlexTrackInfo) -> Tuple[float, str]: + spotify_title = self.clean_title(spotify_track.name) + plex_title = self.clean_title(plex_track.title) + + spotify_artist = self.extract_main_artist(spotify_track.artists) + plex_artist = self.clean_artist(plex_track.artist) + + spotify_album = self.normalize_string(spotify_track.album) + plex_album = self.normalize_string(plex_track.album) + + title_score = self.similarity_score(spotify_title, plex_title) + artist_score = self.similarity_score(spotify_artist, plex_artist) + album_score = self.similarity_score(spotify_album, plex_album) + + duration_score = self.duration_similarity( + spotify_track.duration_ms, + plex_track.duration * 1000 if plex_track.duration else 0 + ) + + if title_score >= 0.9 and artist_score >= 0.9 and album_score >= 0.8: + return 0.95, "exact_match" + elif title_score >= 0.8 and artist_score >= 0.8: + return 0.85, "high_confidence" + elif title_score >= 0.7 and artist_score >= 0.7: + return 0.75, "medium_confidence" + elif title_score >= 0.6 and artist_score >= 0.6: + return 0.65, "low_confidence" + else: + return 0.0, "no_match" + + def find_best_match(self, spotify_track: SpotifyTrack, plex_tracks: List[PlexTrackInfo]) -> MatchResult: + best_match = None + best_confidence = 0.0 + best_match_type = "no_match" + + for plex_track in plex_tracks: + confidence, match_type = self.calculate_match_confidence(spotify_track, plex_track) + + if confidence > best_confidence: + best_confidence = confidence + best_match = plex_track + best_match_type = match_type + + return MatchResult( + spotify_track=spotify_track, + plex_track=best_match, + confidence=best_confidence, + match_type=best_match_type + ) + + def match_playlist_tracks(self, spotify_tracks: List[SpotifyTrack], plex_tracks: List[PlexTrackInfo]) -> List[MatchResult]: + results = [] + + logger.info(f"Matching {len(spotify_tracks)} Spotify tracks against {len(plex_tracks)} Plex tracks") + + for spotify_track in spotify_tracks: + match_result = self.find_best_match(spotify_track, plex_tracks) + results.append(match_result) + + if match_result.is_match: + logger.debug(f"Matched: {spotify_track.name} by {spotify_track.artists[0]} -> {match_result.plex_track.title} (confidence: {match_result.confidence:.2f})") + else: + logger.debug(f"No match found for: {spotify_track.name} by {spotify_track.artists[0]}") + + matched_count = sum(1 for r in results if r.is_match) + logger.info(f"Successfully matched {matched_count}/{len(spotify_tracks)} tracks") + + return results + + def get_match_statistics(self, match_results: List[MatchResult]) -> Dict[str, Any]: + total_tracks = len(match_results) + matched_tracks = sum(1 for r in match_results if r.is_match) + + match_types = {} + for result in match_results: + if result.is_match: + match_types[result.match_type] = match_types.get(result.match_type, 0) + 1 + + confidence_distribution = { + "high (>0.8)": sum(1 for r in match_results if r.confidence > 0.8), + "medium (0.7-0.8)": sum(1 for r in match_results if 0.7 <= r.confidence <= 0.8), + "low (0.6-0.7)": sum(1 for r in match_results if 0.6 <= r.confidence < 0.7), + "no_match (<0.6)": sum(1 for r in match_results if r.confidence < 0.6) + } + + return { + "total_tracks": total_tracks, + "matched_tracks": matched_tracks, + "match_percentage": (matched_tracks / total_tracks * 100) if total_tracks > 0 else 0, + "match_types": match_types, + "confidence_distribution": confidence_distribution + } + + def create_search_queries(self, spotify_track: SpotifyTrack) -> List[str]: + queries = [] + + main_artist = self.extract_main_artist(spotify_track.artists) + clean_title = self.clean_title(spotify_track.name) + clean_album = self.normalize_string(spotify_track.album) + + queries.append(f"{clean_title} {main_artist}") + queries.append(f"{main_artist} {clean_title}") + queries.append(f"{clean_title} {main_artist} {clean_album}") + queries.append(f"{clean_album} {main_artist}") + + if len(spotify_track.artists) > 1: + all_artists = " ".join([self.clean_artist(a) for a in spotify_track.artists]) + queries.append(f"{clean_title} {all_artists}") + + return queries + + def generate_download_query(self, spotify_track: SpotifyTrack) -> str: + main_artist = self.extract_main_artist(spotify_track.artists) + clean_title = self.clean_title(spotify_track.name) + + return f"{main_artist} {clean_title}" + +matching_engine = MusicMatchingEngine() \ No newline at end of file diff --git a/core/plex_client.py b/core/plex_client.py new file mode 100644 index 00000000..5585d78b --- /dev/null +++ b/core/plex_client.py @@ -0,0 +1,275 @@ +from plexapi.server import PlexServer +from plexapi.library import LibrarySection, MusicSection +from plexapi.audio import Track as PlexTrack, Album as PlexAlbum, Artist as PlexArtist +from plexapi.playlist import Playlist as PlexPlaylist +from plexapi.exceptions import PlexApiException, NotFound +from typing import List, Optional, Dict, Any +from dataclasses import dataclass +import requests +from utils.logging_config import get_logger +from config.settings import config_manager + +logger = get_logger("plex_client") + +@dataclass +class PlexTrackInfo: + id: str + title: str + artist: str + album: str + duration: int + track_number: Optional[int] = None + year: Optional[int] = None + rating: Optional[float] = None + + @classmethod + def from_plex_track(cls, track: PlexTrack) -> 'PlexTrackInfo': + return cls( + id=str(track.ratingKey), + title=track.title, + artist=track.artist().title if track.artist() else "Unknown Artist", + album=track.album().title if track.album() else "Unknown Album", + duration=track.duration, + track_number=track.trackNumber, + year=track.year, + rating=track.userRating + ) + +@dataclass +class PlexPlaylistInfo: + id: str + title: str + description: Optional[str] + duration: int + leaf_count: int + tracks: List[PlexTrackInfo] + + @classmethod + def from_plex_playlist(cls, playlist: PlexPlaylist) -> 'PlexPlaylistInfo': + tracks = [] + for item in playlist.items(): + if isinstance(item, PlexTrack): + tracks.append(PlexTrackInfo.from_plex_track(item)) + + return cls( + id=str(playlist.ratingKey), + title=playlist.title, + description=playlist.summary, + duration=playlist.duration, + leaf_count=playlist.leafCount, + tracks=tracks + ) + +class PlexClient: + def __init__(self): + self.server: Optional[PlexServer] = None + self.music_library: Optional[MusicSection] = None + self._setup_client() + + def _setup_client(self): + config = config_manager.get_plex_config() + + if not config.get('base_url'): + logger.warning("Plex server URL not configured") + return + + try: + if config.get('token'): + self.server = PlexServer(config['base_url'], config['token']) + else: + logger.error("Plex token not configured") + return + + self._find_music_library() + logger.info(f"Successfully connected to Plex server: {self.server.friendlyName}") + + except Exception as e: + logger.error(f"Failed to connect to Plex server: {e}") + self.server = None + + def _find_music_library(self): + if not self.server: + return + + try: + for section in self.server.library.sections(): + if section.type == 'artist': + self.music_library = section + logger.info(f"Found music library: {section.title}") + break + + if not self.music_library: + logger.warning("No music library found on Plex server") + + except Exception as e: + logger.error(f"Error finding music library: {e}") + + def is_connected(self) -> bool: + return self.server is not None and self.music_library is not None + + def get_all_playlists(self) -> List[PlexPlaylistInfo]: + if not self.is_connected(): + logger.error("Not connected to Plex server") + return [] + + playlists = [] + + try: + for playlist in self.server.playlists(): + if playlist.playlistType == 'audio': + playlist_info = PlexPlaylistInfo.from_plex_playlist(playlist) + playlists.append(playlist_info) + + logger.info(f"Retrieved {len(playlists)} audio playlists") + return playlists + + except Exception as e: + logger.error(f"Error fetching playlists: {e}") + return [] + + def get_playlist_by_name(self, name: str) -> Optional[PlexPlaylistInfo]: + if not self.is_connected(): + return None + + try: + playlist = self.server.playlist(name) + if playlist.playlistType == 'audio': + return PlexPlaylistInfo.from_plex_playlist(playlist) + return None + + except NotFound: + logger.info(f"Playlist '{name}' not found") + return None + except Exception as e: + logger.error(f"Error fetching playlist '{name}': {e}") + return None + + def create_playlist(self, name: str, tracks: List[PlexTrackInfo]) -> bool: + if not self.is_connected(): + logger.error("Not connected to Plex server") + return False + + try: + plex_tracks = [] + for track_info in tracks: + plex_track = self._find_track(track_info.title, track_info.artist, track_info.album) + if plex_track: + plex_tracks.append(plex_track) + else: + logger.warning(f"Track not found in Plex: {track_info.title} by {track_info.artist}") + + if plex_tracks: + playlist = self.server.createPlaylist(name, plex_tracks) + logger.info(f"Created playlist '{name}' with {len(plex_tracks)} tracks") + return True + else: + logger.error(f"No tracks found for playlist '{name}'") + return False + + except Exception as e: + logger.error(f"Error creating playlist '{name}': {e}") + return False + + def update_playlist(self, playlist_name: str, tracks: List[PlexTrackInfo]) -> bool: + if not self.is_connected(): + return False + + try: + existing_playlist = self.server.playlist(playlist_name) + existing_playlist.delete() + + return self.create_playlist(playlist_name, tracks) + + except NotFound: + logger.info(f"Playlist '{playlist_name}' not found, creating new one") + return self.create_playlist(playlist_name, tracks) + except Exception as e: + logger.error(f"Error updating playlist '{playlist_name}': {e}") + return False + + def _find_track(self, title: str, artist: str, album: str) -> Optional[PlexTrack]: + if not self.music_library: + return None + + try: + search_results = self.music_library.search(title=title, artist=artist, album=album) + + for result in search_results: + if isinstance(result, PlexTrack): + if (result.title.lower() == title.lower() and + result.artist().title.lower() == artist.lower() and + result.album().title.lower() == album.lower()): + return result + + broader_search = self.music_library.search(title=title, artist=artist) + for result in broader_search: + if isinstance(result, PlexTrack): + if (result.title.lower() == title.lower() and + result.artist().title.lower() == artist.lower()): + return result + + return None + + except Exception as e: + logger.error(f"Error searching for track '{title}' by '{artist}': {e}") + return None + + def search_tracks(self, query: str, limit: int = 20) -> List[PlexTrackInfo]: + if not self.music_library: + return [] + + try: + results = self.music_library.search(query, limit=limit) + tracks = [] + + for result in results: + if isinstance(result, PlexTrack): + tracks.append(PlexTrackInfo.from_plex_track(result)) + + return tracks + + except Exception as e: + logger.error(f"Error searching tracks: {e}") + return [] + + def get_library_stats(self) -> Dict[str, int]: + if not self.music_library: + return {} + + try: + return { + 'artists': len(self.music_library.searchArtists()), + 'albums': len(self.music_library.searchAlbums()), + 'tracks': len(self.music_library.searchTracks()) + } + except Exception as e: + logger.error(f"Error getting library stats: {e}") + return {} + + def update_track_metadata(self, track_id: str, metadata: Dict[str, Any]) -> bool: + if not self.is_connected(): + return False + + try: + track = self.server.fetchItem(int(track_id)) + if isinstance(track, PlexTrack): + edits = {} + if 'title' in metadata: + edits['title'] = metadata['title'] + if 'artist' in metadata: + edits['artist'] = metadata['artist'] + if 'album' in metadata: + edits['album'] = metadata['album'] + if 'year' in metadata: + edits['year'] = metadata['year'] + + if edits: + track.edit(**edits) + logger.info(f"Updated metadata for track: {track.title}") + return True + + return False + + except Exception as e: + logger.error(f"Error updating track metadata: {e}") + return False \ No newline at end of file diff --git a/core/soulseek_client.py b/core/soulseek_client.py new file mode 100644 index 00000000..52679708 --- /dev/null +++ b/core/soulseek_client.py @@ -0,0 +1,300 @@ +import requests +import asyncio +import aiohttp +from typing import List, Optional, Dict, Any +from dataclasses import dataclass +import time +from pathlib import Path +from utils.logging_config import get_logger +from config.settings import config_manager + +logger = get_logger("soulseek_client") + +@dataclass +class SearchResult: + username: str + filename: str + size: int + bitrate: Optional[int] + duration: Optional[int] + quality: str + free_upload_slots: int + upload_speed: int + queue_length: int + + @property + def quality_score(self) -> float: + quality_weights = { + 'flac': 1.0, + 'mp3': 0.8, + 'ogg': 0.7, + 'aac': 0.6, + 'wma': 0.5 + } + + base_score = quality_weights.get(self.quality.lower(), 0.3) + + if self.bitrate: + if self.bitrate >= 320: + base_score += 0.2 + elif self.bitrate >= 256: + base_score += 0.1 + elif self.bitrate < 128: + base_score -= 0.2 + + if self.free_upload_slots > 0: + base_score += 0.1 + + if self.upload_speed > 100: + base_score += 0.05 + + if self.queue_length > 10: + base_score -= 0.1 + + return min(base_score, 1.0) + +@dataclass +class DownloadStatus: + id: str + filename: str + username: str + state: str + progress: float + size: int + transferred: int + speed: int + time_remaining: Optional[int] = None + +class SoulseekClient: + def __init__(self): + self.base_url: Optional[str] = None + self.api_key: Optional[str] = None + self.session: Optional[aiohttp.ClientSession] = None + self.download_path: Path = Path("./downloads") + self._setup_client() + + def _setup_client(self): + config = config_manager.get_soulseek_config() + + if not config.get('slskd_url'): + logger.warning("Soulseek slskd URL not configured") + return + + self.base_url = config['slskd_url'].rstrip('/') + self.api_key = config.get('api_key', '') + self.download_path = Path(config.get('download_path', './downloads')) + self.download_path.mkdir(parents=True, exist_ok=True) + + logger.info(f"Soulseek client configured with slskd at {self.base_url}") + + def _get_headers(self) -> Dict[str, str]: + headers = {'Content-Type': 'application/json'} + if self.api_key: + headers['X-API-Key'] = self.api_key + return headers + + async def _make_request(self, method: str, endpoint: str, **kwargs) -> Optional[Dict[str, Any]]: + if not self.base_url: + logger.error("Soulseek client not configured") + return None + + url = f"{self.base_url}/api/v0/{endpoint}" + + if not self.session: + self.session = aiohttp.ClientSession() + + try: + async with self.session.request( + method, + url, + headers=self._get_headers(), + **kwargs + ) as response: + if response.status == 200: + return await response.json() + else: + logger.error(f"API request failed: {response.status} - {await response.text()}") + return None + + except Exception as e: + logger.error(f"Error making API request: {e}") + return None + + async def search(self, query: str, timeout: int = 30) -> List[SearchResult]: + if not self.base_url: + logger.error("Soulseek client not configured") + return [] + + try: + search_data = { + 'searchText': query, + 'timeout': timeout * 1000, + 'filterResponses': True, + 'minimumResponseFileCount': 1, + 'minimumPeerUploadSpeed': 0 + } + + response = await self._make_request('POST', 'searches', json=search_data) + if not response: + return [] + + search_id = response.get('id') + if not search_id: + logger.error("No search ID returned") + return [] + + await asyncio.sleep(timeout) + + results_response = await self._make_request('GET', f'searches/{search_id}') + if not results_response: + return [] + + search_results = [] + for response_data in results_response.get('responses', []): + username = response_data.get('username', '') + + for file_data in response_data.get('files', []): + filename = file_data.get('filename', '') + size = file_data.get('size', 0) + + file_ext = Path(filename).suffix.lower().lstrip('.') + quality = file_ext if file_ext in ['flac', 'mp3', 'ogg', 'aac', 'wma'] else 'unknown' + + result = SearchResult( + username=username, + filename=filename, + size=size, + bitrate=file_data.get('bitRate'), + duration=file_data.get('length'), + quality=quality, + free_upload_slots=response_data.get('freeUploadSlots', 0), + upload_speed=response_data.get('uploadSpeed', 0), + queue_length=response_data.get('queueLength', 0) + ) + search_results.append(result) + + search_results.sort(key=lambda x: x.quality_score, reverse=True) + logger.info(f"Found {len(search_results)} results for query: {query}") + return search_results + + except Exception as e: + logger.error(f"Error searching: {e}") + return [] + + async def download(self, username: str, filename: str) -> Optional[str]: + if not self.base_url: + logger.error("Soulseek client not configured") + return None + + try: + download_data = { + 'username': username, + 'files': [filename] + } + + response = await self._make_request('POST', 'transfers/downloads', json=download_data) + if response: + logger.info(f"Started download: {filename} from {username}") + return filename + + return None + + except Exception as e: + logger.error(f"Error starting download: {e}") + return None + + async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]: + if not self.base_url: + return None + + try: + response = await self._make_request('GET', f'transfers/downloads/{download_id}') + if not response: + return None + + return DownloadStatus( + id=response.get('id', ''), + filename=response.get('filename', ''), + username=response.get('username', ''), + state=response.get('state', ''), + progress=response.get('percentComplete', 0.0), + size=response.get('size', 0), + transferred=response.get('bytesTransferred', 0), + speed=response.get('averageSpeed', 0), + time_remaining=response.get('timeRemaining') + ) + + except Exception as e: + logger.error(f"Error getting download status: {e}") + return None + + async def get_all_downloads(self) -> List[DownloadStatus]: + if not self.base_url: + return [] + + try: + response = await self._make_request('GET', 'transfers/downloads') + if not response: + return [] + + downloads = [] + for download_data in response: + status = DownloadStatus( + id=download_data.get('id', ''), + filename=download_data.get('filename', ''), + username=download_data.get('username', ''), + state=download_data.get('state', ''), + progress=download_data.get('percentComplete', 0.0), + size=download_data.get('size', 0), + transferred=download_data.get('bytesTransferred', 0), + speed=download_data.get('averageSpeed', 0), + time_remaining=download_data.get('timeRemaining') + ) + downloads.append(status) + + return downloads + + except Exception as e: + logger.error(f"Error getting downloads: {e}") + return [] + + async def cancel_download(self, download_id: str) -> bool: + if not self.base_url: + return False + + try: + response = await self._make_request('DELETE', f'transfers/downloads/{download_id}') + return response is not None + + except Exception as e: + logger.error(f"Error cancelling download: {e}") + return False + + async def search_and_download_best(self, query: str, preferred_quality: str = 'flac') -> Optional[str]: + results = await self.search(query) + + if not results: + logger.warning(f"No results found for: {query}") + return None + + preferred_results = [r for r in results if r.quality.lower() == preferred_quality.lower()] + + if preferred_results: + best_result = preferred_results[0] + else: + best_result = results[0] + logger.info(f"Preferred quality {preferred_quality} not found, using {best_result.quality}") + + logger.info(f"Downloading: {best_result.filename} ({best_result.quality}) from {best_result.username}") + return await self.download(best_result.username, best_result.filename) + + async def close(self): + if self.session: + await self.session.close() + + def __del__(self): + if self.session and not self.session.closed: + try: + asyncio.get_event_loop().run_until_complete(self.session.close()) + except: + pass \ No newline at end of file diff --git a/core/spotify_client.py b/core/spotify_client.py new file mode 100644 index 00000000..1959c757 --- /dev/null +++ b/core/spotify_client.py @@ -0,0 +1,194 @@ +import spotipy +from spotipy.oauth2 import SpotifyOAuth, SpotifyClientCredentials +from typing import Dict, List, Optional, Any +import time +from dataclasses import dataclass +from utils.logging_config import get_logger +from config.settings import config_manager + +logger = get_logger("spotify_client") + +@dataclass +class Track: + id: str + name: str + artists: List[str] + album: str + duration_ms: int + popularity: int + preview_url: Optional[str] = None + external_urls: Optional[Dict[str, str]] = None + + @classmethod + def from_spotify_track(cls, track_data: Dict[str, Any]) -> 'Track': + return cls( + id=track_data['id'], + name=track_data['name'], + artists=[artist['name'] for artist in track_data['artists']], + album=track_data['album']['name'], + duration_ms=track_data['duration_ms'], + popularity=track_data['popularity'], + preview_url=track_data.get('preview_url'), + external_urls=track_data.get('external_urls') + ) + +@dataclass +class Playlist: + id: str + name: str + description: Optional[str] + owner: str + public: bool + collaborative: bool + tracks: List[Track] + total_tracks: int + + @classmethod + def from_spotify_playlist(cls, playlist_data: Dict[str, Any], tracks: List[Track]) -> 'Playlist': + return cls( + id=playlist_data['id'], + name=playlist_data['name'], + description=playlist_data.get('description'), + owner=playlist_data['owner']['display_name'], + public=playlist_data['public'], + collaborative=playlist_data['collaborative'], + tracks=tracks, + total_tracks=playlist_data['tracks']['total'] + ) + +class SpotifyClient: + def __init__(self): + self.sp: Optional[spotipy.Spotify] = None + self.user_id: Optional[str] = None + self._setup_client() + + def _setup_client(self): + config = config_manager.get_spotify_config() + + if not config.get('client_id') or not config.get('client_secret'): + logger.warning("Spotify credentials not configured") + return + + try: + auth_manager = SpotifyOAuth( + client_id=config['client_id'], + client_secret=config['client_secret'], + redirect_uri="http://localhost:8888/callback", + scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email", + cache_path='.spotify_cache' + ) + + self.sp = spotipy.Spotify(auth_manager=auth_manager) + user_info = self.sp.current_user() + self.user_id = user_info['id'] + logger.info(f"Successfully authenticated with Spotify as {user_info['display_name']}") + + except Exception as e: + logger.error(f"Failed to authenticate with Spotify: {e}") + self.sp = None + + def is_authenticated(self) -> bool: + return self.sp is not None and self.user_id is not None + + def get_user_playlists(self) -> List[Playlist]: + if not self.is_authenticated(): + logger.error("Not authenticated with Spotify") + return [] + + playlists = [] + + try: + results = self.sp.current_user_playlists(limit=50) + + while results: + for playlist_data in results['items']: + if playlist_data['owner']['id'] == self.user_id or playlist_data['collaborative']: + logger.info(f"Fetching tracks for playlist: {playlist_data['name']}") + tracks = self._get_playlist_tracks(playlist_data['id']) + playlist = Playlist.from_spotify_playlist(playlist_data, tracks) + playlists.append(playlist) + + results = self.sp.next(results) if results['next'] else None + + logger.info(f"Retrieved {len(playlists)} playlists") + return playlists + + except Exception as e: + logger.error(f"Error fetching user playlists: {e}") + return [] + + def _get_playlist_tracks(self, playlist_id: str) -> List[Track]: + if not self.is_authenticated(): + return [] + + tracks = [] + + try: + results = self.sp.playlist_tracks(playlist_id, limit=100) + + while results: + for item in results['items']: + if item['track'] and item['track']['id']: + track = Track.from_spotify_track(item['track']) + tracks.append(track) + + results = self.sp.next(results) if results['next'] else None + + return tracks + + except Exception as e: + logger.error(f"Error fetching playlist tracks: {e}") + return [] + + def get_playlist_by_id(self, playlist_id: str) -> Optional[Playlist]: + if not self.is_authenticated(): + return None + + try: + playlist_data = self.sp.playlist(playlist_id) + tracks = self._get_playlist_tracks(playlist_id) + return Playlist.from_spotify_playlist(playlist_data, tracks) + + except Exception as e: + logger.error(f"Error fetching playlist {playlist_id}: {e}") + return None + + def search_tracks(self, query: str, limit: int = 20) -> List[Track]: + if not self.is_authenticated(): + return [] + + try: + results = self.sp.search(q=query, type='track', limit=limit) + tracks = [] + + for track_data in results['tracks']['items']: + track = Track.from_spotify_track(track_data) + tracks.append(track) + + return tracks + + except Exception as e: + logger.error(f"Error searching tracks: {e}") + return [] + + def get_track_features(self, track_id: str) -> Optional[Dict[str, Any]]: + if not self.is_authenticated(): + return None + + try: + features = self.sp.audio_features(track_id) + return features[0] if features else None + + except Exception as e: + logger.error(f"Error fetching track features: {e}") + return None + + def get_user_info(self) -> Optional[Dict[str, Any]]: + if not self.is_authenticated(): + return None + + try: + return self.sp.current_user() + except Exception as e: + logger.error(f"Error fetching user info: {e}") + return None \ No newline at end of file diff --git a/logs/app.log b/logs/app.log new file mode 100644 index 00000000..4be0660c --- /dev/null +++ b/logs/app.log @@ -0,0 +1,63 @@ +2025-07-09 11:36:49 - newmusic - INFO - setup_logging:57 - Logging initialized with level: DEBUG +2025-07-09 11:36:49 - newmusic.main - INFO - main:261 - Starting NewMusic application +2025-07-09 11:36:55 - newmusic.spotify_client - ERROR - _setup_client:87 - Failed to authenticate with Spotify: Server listening on localhost has not been accessed +2025-07-09 11:36:55 - newmusic.plex_client - INFO - _find_music_library:98 - Found music library: Music +2025-07-09 11:36:55 - newmusic.plex_client - INFO - _setup_client:84 - Successfully connected to Plex server: PLEX-MACHINE +2025-07-09 11:36:55 - newmusic.soulseek_client - INFO - _setup_client:88 - Soulseek client configured with slskd at http://localhost:5030 +2025-07-09 11:40:35 - newmusic - INFO - setup_logging:57 - Logging initialized with level: DEBUG +2025-07-09 11:40:35 - newmusic.main - INFO - main:261 - Starting NewMusic application +2025-07-09 11:40:38 - newmusic.spotify_client - ERROR - _setup_client:87 - Failed to authenticate with Spotify: error: upstream connect error or disconnect/reset before headers. reset reason: connection termination, error_description: None +2025-07-09 11:40:38 - newmusic.plex_client - INFO - _find_music_library:98 - Found music library: Music +2025-07-09 11:40:38 - newmusic.plex_client - INFO - _setup_client:84 - Successfully connected to Plex server: PLEX-MACHINE +2025-07-09 11:40:38 - newmusic.soulseek_client - INFO - _setup_client:88 - Soulseek client configured with slskd at http://localhost:5030 +2025-07-09 11:42:17 - newmusic - INFO - setup_logging:57 - Logging initialized with level: DEBUG +2025-07-09 11:42:17 - newmusic.main - INFO - main:261 - Starting NewMusic application +2025-07-09 11:42:18 - newmusic.spotify_client - INFO - _setup_client:84 - Successfully authenticated with Spotify as broquethomas +2025-07-09 11:42:18 - newmusic.plex_client - INFO - _find_music_library:98 - Found music library: Music +2025-07-09 11:42:18 - newmusic.plex_client - INFO - _setup_client:84 - Successfully connected to Plex server: PLEX-MACHINE +2025-07-09 11:42:18 - newmusic.soulseek_client - INFO - _setup_client:88 - Soulseek client configured with slskd at http://localhost:5030 +2025-07-09 11:42:24 - newmusic.spotify_client - INFO - get_user_playlists:106 - Fetching tracks for playlist: Aether +2025-07-09 11:42:24 - newmusic.spotify_client - INFO - get_user_playlists:106 - Fetching tracks for playlist: Favorite Artists +2025-07-09 11:42:26 - newmusic.spotify_client - INFO - get_user_playlists:106 - Fetching tracks for playlist: Brittnea +2025-07-09 11:42:27 - newmusic.spotify_client - INFO - get_user_playlists:106 - Fetching tracks for playlist: Baleigh +2025-07-09 11:42:29 - newmusic.spotify_client - INFO - get_user_playlists:106 - Fetching tracks for playlist: Extra Music +2025-07-09 11:42:29 - newmusic.spotify_client - INFO - get_user_playlists:106 - Fetching tracks for playlist: Maggi Main +2025-07-09 11:42:34 - newmusic.spotify_client - INFO - get_user_playlists:106 - Fetching tracks for playlist: Broque Main +2025-07-09 11:42:42 - newmusic.spotify_client - INFO - get_user_playlists:113 - Retrieved 7 playlists +2025-07-09 12:00:42 - newmusic - INFO - setup_logging:57 - Logging initialized with level: DEBUG +2025-07-09 12:00:42 - newmusic.main - INFO - main:173 - Starting NewMusic application +2025-07-09 12:00:42 - newmusic.spotify_client - INFO - _setup_client:84 - Successfully authenticated with Spotify as broquethomas +2025-07-09 12:00:42 - newmusic.plex_client - INFO - _find_music_library:98 - Found music library: Music +2025-07-09 12:00:42 - newmusic.plex_client - INFO - _setup_client:84 - Successfully connected to Plex server: PLEX-MACHINE +2025-07-09 12:00:42 - newmusic.soulseek_client - INFO - _setup_client:88 - Soulseek client configured with slskd at http://localhost:5030 +2025-07-09 12:01:02 - newmusic - INFO - setup_logging:57 - Logging initialized with level: DEBUG +2025-07-09 12:01:02 - newmusic.main - INFO - main:173 - Starting NewMusic application +2025-07-09 12:01:03 - newmusic.spotify_client - INFO - _setup_client:84 - Successfully authenticated with Spotify as broquethomas +2025-07-09 12:01:03 - newmusic.plex_client - INFO - _find_music_library:98 - Found music library: Music +2025-07-09 12:01:03 - newmusic.plex_client - INFO - _setup_client:84 - Successfully connected to Plex server: PLEX-MACHINE +2025-07-09 12:01:03 - newmusic.soulseek_client - INFO - _setup_client:88 - Soulseek client configured with slskd at http://localhost:5030 +2025-07-09 12:01:09 - newmusic - INFO - setup_logging:57 - Logging initialized with level: DEBUG +2025-07-09 12:01:09 - newmusic.main - INFO - main:173 - Starting NewMusic application +2025-07-09 12:01:09 - newmusic.spotify_client - INFO - _setup_client:84 - Successfully authenticated with Spotify as broquethomas +2025-07-09 12:01:09 - newmusic.plex_client - INFO - _find_music_library:98 - Found music library: Music +2025-07-09 12:01:09 - newmusic.plex_client - INFO - _setup_client:84 - Successfully connected to Plex server: PLEX-MACHINE +2025-07-09 12:01:09 - newmusic.soulseek_client - INFO - _setup_client:88 - Soulseek client configured with slskd at http://localhost:5030 +2025-07-09 12:03:25 - newmusic - INFO - setup_logging:57 - Logging initialized with level: DEBUG +2025-07-09 12:03:25 - newmusic.main - INFO - main:173 - Starting NewMusic application +2025-07-09 12:03:25 - newmusic.spotify_client - INFO - _setup_client:84 - Successfully authenticated with Spotify as broquethomas +2025-07-09 12:03:25 - newmusic.plex_client - INFO - _find_music_library:98 - Found music library: Music +2025-07-09 12:03:25 - newmusic.plex_client - INFO - _setup_client:84 - Successfully connected to Plex server: PLEX-MACHINE +2025-07-09 12:03:25 - newmusic.soulseek_client - INFO - _setup_client:88 - Soulseek client configured with slskd at http://localhost:5030 +2025-07-09 12:03:26 - newmusic.main - INFO - change_page:139 - Changed to page: dashboard +2025-07-09 12:03:32 - newmusic.main - INFO - change_page:139 - Changed to page: sync +2025-07-09 12:03:33 - newmusic.main - INFO - change_page:139 - Changed to page: downloads +2025-07-09 12:03:34 - newmusic.main - INFO - change_page:139 - Changed to page: artists +2025-07-09 12:03:34 - newmusic.main - INFO - change_page:139 - Changed to page: settings +2025-07-09 12:03:35 - newmusic.main - INFO - change_page:139 - Changed to page: dashboard +2025-07-09 12:06:27 - newmusic.main - INFO - change_page:139 - Changed to page: settings +2025-07-09 12:06:28 - newmusic.main - INFO - change_page:139 - Changed to page: sync +2025-07-09 12:06:28 - newmusic.main - INFO - change_page:139 - Changed to page: dashboard +2025-07-09 12:06:29 - newmusic.main - INFO - change_page:139 - Changed to page: sync +2025-07-09 12:06:29 - newmusic.main - INFO - change_page:139 - Changed to page: downloads +2025-07-09 12:06:30 - newmusic.main - INFO - change_page:139 - Changed to page: dashboard +2025-07-09 12:06:56 - newmusic.main - INFO - change_page:139 - Changed to page: settings diff --git a/logs/newmusic.log b/logs/newmusic.log new file mode 100644 index 00000000..d2293c5b --- /dev/null +++ b/logs/newmusic.log @@ -0,0 +1,10 @@ +2025-07-08 23:06:53 - newmusic - INFO - setup_logging:57 - Logging initialized with level: INFO +2025-07-08 23:06:53 - newmusic.main - INFO - main:259 - Starting NewMusic application +2025-07-08 23:06:53 - newmusic.spotify_client - WARNING - _setup_client:69 - Spotify credentials not configured +2025-07-08 23:06:53 - newmusic.plex_client - WARNING - _setup_client:73 - Plex server URL not configured +2025-07-08 23:06:53 - newmusic.soulseek_client - INFO - _setup_client:88 - Soulseek client configured with slskd at http://localhost:5030 +2025-07-09 11:26:03 - newmusic - INFO - setup_logging:57 - Logging initialized with level: INFO +2025-07-09 11:26:03 - newmusic.main - INFO - main:259 - Starting NewMusic application +2025-07-09 11:26:03 - newmusic.spotify_client - WARNING - _setup_client:69 - Spotify credentials not configured +2025-07-09 11:26:03 - newmusic.plex_client - WARNING - _setup_client:73 - Plex server URL not configured +2025-07-09 11:26:03 - newmusic.soulseek_client - INFO - _setup_client:88 - Soulseek client configured with slskd at http://localhost:5030 diff --git a/main.py b/main.py new file mode 100644 index 00000000..c11c1a03 --- /dev/null +++ b/main.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 + +import sys +import asyncio +from pathlib import Path +from PyQt6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QHBoxLayout, QWidget, QStackedWidget +from PyQt6.QtCore import QThread, pyqtSignal, QTimer +from PyQt6.QtGui import QFont, QPalette, QColor + +from config.settings import config_manager +from utils.logging_config import setup_logging, get_logger +from core.spotify_client import SpotifyClient +from core.plex_client import PlexClient +from core.soulseek_client import SoulseekClient + +from ui.sidebar import ModernSidebar +from ui.pages.dashboard import DashboardPage +from ui.pages.sync import SyncPage +from ui.pages.downloads import DownloadsPage +from ui.pages.artists import ArtistsPage +from ui.pages.settings import SettingsPage + +logger = get_logger("main") + +class ServiceStatusThread(QThread): + status_updated = pyqtSignal(str, bool) + + def __init__(self, spotify_client, plex_client, soulseek_client): + super().__init__() + self.spotify_client = spotify_client + self.plex_client = plex_client + self.soulseek_client = soulseek_client + self.running = True + + def run(self): + while self.running: + try: + # Check Spotify authentication + spotify_status = self.spotify_client.is_authenticated() + self.status_updated.emit("spotify", spotify_status) + + # Check Plex connection + plex_status = self.plex_client.is_connected() + self.status_updated.emit("plex", plex_status) + + # Check Soulseek connection + soulseek_status = self.soulseek_client.base_url is not None + self.status_updated.emit("soulseek", soulseek_status) + + self.msleep(3000) # Check every 3 seconds + + except Exception as e: + logger.error(f"Error checking service status: {e}") + self.msleep(5000) + + def stop(self): + self.running = False + self.quit() + self.wait() + +class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + self.spotify_client = SpotifyClient() + self.plex_client = PlexClient() + self.soulseek_client = SoulseekClient() + + self.status_thread = None + self.init_ui() + self.setup_status_monitoring() + + def init_ui(self): + self.setWindowTitle("NewMusic - Music Sync & Manager") + self.setGeometry(100, 100, 1400, 900) + + # Set dark theme palette + self.setStyleSheet(""" + QMainWindow { + background: #121212; + } + """) + + # Create central widget + central_widget = QWidget() + self.setCentralWidget(central_widget) + + # Main layout + main_layout = QHBoxLayout(central_widget) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + + # Create sidebar + self.sidebar = ModernSidebar() + self.sidebar.page_changed.connect(self.change_page) + main_layout.addWidget(self.sidebar) + + # Create stacked widget for pages + self.stacked_widget = QStackedWidget() + + # Create and add pages + self.dashboard_page = DashboardPage() + self.sync_page = SyncPage() + self.downloads_page = DownloadsPage() + self.artists_page = ArtistsPage() + self.settings_page = SettingsPage() + + self.stacked_widget.addWidget(self.dashboard_page) + self.stacked_widget.addWidget(self.sync_page) + self.stacked_widget.addWidget(self.downloads_page) + self.stacked_widget.addWidget(self.artists_page) + self.stacked_widget.addWidget(self.settings_page) + + main_layout.addWidget(self.stacked_widget) + + # Set dashboard as default page + self.change_page("dashboard") + + def setup_status_monitoring(self): + # Start status monitoring thread + self.status_thread = ServiceStatusThread( + self.spotify_client, + self.plex_client, + self.soulseek_client + ) + self.status_thread.status_updated.connect(self.update_service_status) + self.status_thread.start() + + def change_page(self, page_id: str): + page_map = { + "dashboard": 0, + "sync": 1, + "downloads": 2, + "artists": 3, + "settings": 4 + } + + if page_id in page_map: + self.stacked_widget.setCurrentIndex(page_map[page_id]) + logger.info(f"Changed to page: {page_id}") + + def update_service_status(self, service: str, connected: bool): + self.sidebar.update_service_status(service, connected) + + # Force a refresh of the Spotify client if needed + if service == "spotify" and not connected: + try: + self.spotify_client._setup_client() + except Exception as e: + logger.error(f"Error refreshing Spotify client: {e}") + + def closeEvent(self, event): + logger.info("Closing application...") + + # Stop status monitoring thread + if self.status_thread: + self.status_thread.stop() + + # Close Soulseek client + try: + loop = asyncio.get_event_loop() + loop.run_until_complete(self.soulseek_client.close()) + except Exception as e: + logger.error(f"Error closing Soulseek client: {e}") + + event.accept() + +def main(): + logging_config = config_manager.get_logging_config() + log_level = logging_config.get('level', 'INFO') + log_file = logging_config.get('path', 'logs/newmusic.log') + setup_logging(level=log_level, log_file=log_file) + + logger.info("Starting NewMusic application") + + if not config_manager.config_path.exists(): + logger.error("Configuration file not found. Please check config/config.json") + sys.exit(1) + + app = QApplication(sys.argv) + app.setApplicationName("NewMusic") + app.setApplicationVersion("1.0.0") + + main_window = MainWindow() + main_window.show() + + try: + sys.exit(app.exec()) + except KeyboardInterrupt: + logger.info("Application interrupted by user") + sys.exit(0) + except Exception as e: + logger.error(f"Unexpected error: {e}") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/project.txt b/project.txt new file mode 100644 index 00000000..fb4260dc --- /dev/null +++ b/project.txt @@ -0,0 +1,8 @@ +create me an app with python. it will have an elegant and animated and virant theme and layout similar to the Spotify desktop app or web app. +The app will connect to both spotify and my plex media server so i want a central file called config.json that stores all of the api and login information for connected services. +The app will have multiple functionalities. +the app will pull the data for all spotify user playlists. it will sync the playlists to plex mediaserver using an extensive, robust matching system, multiple methods. +The app will connect to soulseek and search for flac or user selected quality to download albums and singles by an artist with an extensive matching system. +the app will update the music metadata on plex based off the spotify metadata that is provided. including album art. + +the core functionality should feel and look like spotify. \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..f77d9c34 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +PyQt6>=6.6.0 +spotipy>=2.23.0 +PlexAPI>=4.17.0 +requests>=2.31.0 +asyncio-mqtt>=0.16.0 +python-dotenv>=1.0.0 +cryptography>=41.0.0 +mutagen>=1.47.0 +Pillow>=10.0.0 +aiohttp>=3.9.0 \ No newline at end of file diff --git a/services/sync_service.py b/services/sync_service.py new file mode 100644 index 00000000..bd945497 --- /dev/null +++ b/services/sync_service.py @@ -0,0 +1,271 @@ +import asyncio +from typing import List, Dict, Any, Optional +from dataclasses import dataclass +from datetime import datetime +from utils.logging_config import get_logger +from core.spotify_client import SpotifyClient, Playlist as SpotifyPlaylist +from core.plex_client import PlexClient, PlexTrackInfo +from core.soulseek_client import SoulseekClient +from core.matching_engine import matching_engine, MatchResult + +logger = get_logger("sync_service") + +@dataclass +class SyncResult: + playlist_name: str + total_tracks: int + matched_tracks: int + synced_tracks: int + downloaded_tracks: int + failed_tracks: int + sync_time: datetime + errors: List[str] + + @property + def success_rate(self) -> float: + if self.total_tracks == 0: + return 0.0 + return (self.synced_tracks / self.total_tracks) * 100 + +@dataclass +class SyncProgress: + current_step: str + current_track: str + progress: float + total_steps: int + current_step_number: int + +class PlaylistSyncService: + def __init__(self, spotify_client: SpotifyClient, plex_client: PlexClient, soulseek_client: SoulseekClient): + self.spotify_client = spotify_client + self.plex_client = plex_client + self.soulseek_client = soulseek_client + self.progress_callback = None + self.is_syncing = False + + def set_progress_callback(self, callback): + self.progress_callback = callback + + def _update_progress(self, step: str, track: str, progress: float, total_steps: int, current_step: int): + if self.progress_callback: + self.progress_callback(SyncProgress( + current_step=step, + current_track=track, + progress=progress, + total_steps=total_steps, + current_step_number=current_step + )) + + async def sync_playlist(self, playlist_name: str, download_missing: bool = False) -> SyncResult: + if self.is_syncing: + logger.warning("Sync already in progress") + return SyncResult( + playlist_name=playlist_name, + total_tracks=0, + matched_tracks=0, + synced_tracks=0, + downloaded_tracks=0, + failed_tracks=0, + sync_time=datetime.now(), + errors=["Sync already in progress"] + ) + + self.is_syncing = True + errors = [] + + try: + logger.info(f"Starting sync for playlist: {playlist_name}") + + self._update_progress("Fetching Spotify playlist", "", 0, 6, 1) + spotify_playlist = self._get_spotify_playlist(playlist_name) + if not spotify_playlist: + errors.append(f"Spotify playlist '{playlist_name}' not found") + return self._create_error_result(playlist_name, errors) + + self._update_progress("Fetching Plex library", "", 16, 6, 2) + plex_tracks = await self._get_plex_tracks() + + self._update_progress("Matching tracks", "", 33, 6, 3) + match_results = matching_engine.match_playlist_tracks( + spotify_playlist.tracks, + plex_tracks + ) + + matched_tracks = [r for r in match_results if r.is_match] + unmatched_tracks = [r for r in match_results if not r.is_match] + + logger.info(f"Found {len(matched_tracks)} matches out of {len(spotify_playlist.tracks)} tracks") + + downloaded_tracks = 0 + if download_missing and unmatched_tracks: + self._update_progress("Downloading missing tracks", "", 50, 6, 4) + downloaded_tracks = await self._download_missing_tracks(unmatched_tracks) + + self._update_progress("Creating/updating Plex playlist", "", 66, 6, 5) + plex_track_infos = [r.plex_track for r in matched_tracks if r.plex_track] + + sync_success = self.plex_client.update_playlist(playlist_name, plex_track_infos) + + synced_tracks = len(plex_track_infos) if sync_success else 0 + failed_tracks = len(spotify_playlist.tracks) - synced_tracks - downloaded_tracks + + self._update_progress("Sync completed", "", 100, 6, 6) + + result = SyncResult( + playlist_name=playlist_name, + total_tracks=len(spotify_playlist.tracks), + matched_tracks=len(matched_tracks), + synced_tracks=synced_tracks, + downloaded_tracks=downloaded_tracks, + failed_tracks=failed_tracks, + sync_time=datetime.now(), + errors=errors + ) + + logger.info(f"Sync completed: {result.success_rate:.1f}% success rate") + return result + + except Exception as e: + logger.error(f"Error during sync: {e}") + errors.append(str(e)) + return self._create_error_result(playlist_name, errors) + + finally: + self.is_syncing = False + + async def sync_multiple_playlists(self, playlist_names: List[str], download_missing: bool = False) -> List[SyncResult]: + results = [] + + for i, playlist_name in enumerate(playlist_names): + logger.info(f"Syncing playlist {i+1}/{len(playlist_names)}: {playlist_name}") + result = await self.sync_playlist(playlist_name, download_missing) + results.append(result) + + if i < len(playlist_names) - 1: + await asyncio.sleep(1) + + return results + + def _get_spotify_playlist(self, playlist_name: str) -> Optional[SpotifyPlaylist]: + try: + playlists = self.spotify_client.get_user_playlists() + for playlist in playlists: + if playlist.name.lower() == playlist_name.lower(): + return playlist + return None + except Exception as e: + logger.error(f"Error fetching Spotify playlist: {e}") + return None + + async def _get_plex_tracks(self) -> List[PlexTrackInfo]: + try: + return self.plex_client.search_tracks("", limit=10000) + except Exception as e: + logger.error(f"Error fetching Plex tracks: {e}") + return [] + + async def _download_missing_tracks(self, unmatched_tracks: List[MatchResult]) -> int: + downloaded_count = 0 + + for match_result in unmatched_tracks: + try: + query = matching_engine.generate_download_query(match_result.spotify_track) + logger.info(f"Attempting to download: {query}") + + download_id = await self.soulseek_client.search_and_download_best(query) + + if download_id: + downloaded_count += 1 + logger.info(f"Download started for: {match_result.spotify_track.name}") + else: + logger.warning(f"No download sources found for: {match_result.spotify_track.name}") + + await asyncio.sleep(1) + + except Exception as e: + logger.error(f"Error downloading track: {e}") + + return downloaded_count + + def _create_error_result(self, playlist_name: str, errors: List[str]) -> SyncResult: + return SyncResult( + playlist_name=playlist_name, + total_tracks=0, + matched_tracks=0, + synced_tracks=0, + downloaded_tracks=0, + failed_tracks=0, + sync_time=datetime.now(), + errors=errors + ) + + def get_sync_preview(self, playlist_name: str) -> Dict[str, Any]: + try: + spotify_playlist = self._get_spotify_playlist(playlist_name) + if not spotify_playlist: + return {"error": f"Playlist '{playlist_name}' not found"} + + plex_tracks = self.plex_client.search_tracks("", limit=1000) + + match_results = matching_engine.match_playlist_tracks( + spotify_playlist.tracks, + plex_tracks + ) + + stats = matching_engine.get_match_statistics(match_results) + + preview = { + "playlist_name": playlist_name, + "total_tracks": len(spotify_playlist.tracks), + "available_in_plex": stats["matched_tracks"], + "needs_download": stats["total_tracks"] - stats["matched_tracks"], + "match_percentage": stats["match_percentage"], + "confidence_breakdown": stats["confidence_distribution"], + "tracks_preview": [] + } + + for result in match_results[:10]: + track_info = { + "spotify_track": f"{result.spotify_track.name} - {result.spotify_track.artists[0]}", + "plex_match": result.plex_track.title if result.plex_track else None, + "confidence": result.confidence, + "status": "available" if result.is_match else "needs_download" + } + preview["tracks_preview"].append(track_info) + + return preview + + except Exception as e: + logger.error(f"Error generating sync preview: {e}") + return {"error": str(e)} + + def get_library_comparison(self) -> Dict[str, Any]: + try: + spotify_playlists = self.spotify_client.get_user_playlists() + plex_playlists = self.plex_client.get_all_playlists() + plex_stats = self.plex_client.get_library_stats() + + spotify_track_count = sum(len(p.tracks) for p in spotify_playlists) + + comparison = { + "spotify": { + "playlists": len(spotify_playlists), + "total_tracks": spotify_track_count + }, + "plex": { + "playlists": len(plex_playlists), + "artists": plex_stats.get("artists", 0), + "albums": plex_stats.get("albums", 0), + "tracks": plex_stats.get("tracks", 0) + }, + "sync_potential": { + "estimated_matches": min(spotify_track_count, plex_stats.get("tracks", 0)), + "potential_downloads": max(0, spotify_track_count - plex_stats.get("tracks", 0)) + } + } + + return comparison + + except Exception as e: + logger.error(f"Error generating library comparison: {e}") + return {"error": str(e)} \ No newline at end of file diff --git a/ui/__pycache__/sidebar.cpython-312.pyc b/ui/__pycache__/sidebar.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5e4f3d82f115db80b7d76048af74f14e01db8ea4 GIT binary patch literal 13611 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 G<^KR-++%D2 literal 0 HcmV?d00001 diff --git a/ui/pages/__init__.py b/ui/pages/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ui/pages/__pycache__/__init__.cpython-312.pyc b/ui/pages/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6bb0150004d71136b542212299935240971ff307 GIT binary patch literal 145 zcmX@j%ge<81Vy{@GC=fW5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!GH|tuaVp9$EKOAi zD9X=DO)e>p$xAKwEiKMWjw#KIDM(CDEslwg&&|C_Y7zLsBBOvPFpxz1Fg1QnF+xl1`L#(q&J^Vz@ITm)-~6 zGi!-^%ftzcga*zR2TEWDlHtX{2q@@)Qvw7zB!2<~I2@4t;AYoYVPGHzVg%Uy;@NWU z;9sfgc`i8=b+!+O8-hL4(>2}I)m`0HU)TP7I2>T$y7DiX`8^{H^WT^;E|323#ar<3 zJ|i+Bn_|*zj7_^@u5?4JA?=R2)1H_o?TvZUzL+n~#kjOT=1&J=fpjnyWbqqUDwGb# z!t}Wz)tGLIHKm(l%`D?$E-<3|ZASD+o`(%uTd@}U)(dZa5^KKQwZujIH6)1yRp0c@ zq&O!jYH<3-RQAqAVJVx#%qv#LJAF}@ky5I6`aBWR5)@v^$@5b=MagDV|MbOVMtWIH z;z!pKB%4Z25=nr}1(Fo?>ipAZv+0>ERH=rhuaN8X|;D%_zXk zmE@hYu$a*J*HXWp`QijT`~`?kVq&bw#9X3JWTghtCAq~0_;*VUv)l?Z%Zi@2!!eHt z-Hv%zm^*CDca4c~YG9HmNm)575K(n!Xh($9OfDTyWOLAVP$5F%bv+YG39=H;EsBC7 zi3zWb9TYA8e^G$sZ`k)4#VmZsTVURWVJ>(cvf4|QDX;}5?lMRUF39PF19T0LbDL#u z$QhG_DKHCRqr|#D)E`-9iFv?Yj`&oMoLiKL%EjZ!Oj3!*Nh^?$-I%mt(hiBr$&!*= zjOUW@fm@bRvyN2WMS(~l2!A}DfbPrjxa@U zxuFD&0%Kg4T?LmT=U&lkvYw46qffxA%TX4-ItrXmn=X;F zD6HOqWO3SUx(v$YhC;(a*QP?qDtnA~qF)RsID6=|&|^GV_k~{T*?2ORH=`ZsRfA)% zf^`)A#`o4;4AoIM=;1e!wKc2EQFj;Ij&mh!@lhw;0gG4T=^i6Ce&90bmpui~jxzQb zGs^f1B<69HSW_Lvw=EA-3t-;tGx}rQp=Zsuo_P!2uhlc}jy>~!+gfFfnb-n-8?yRj zJl$h#J@XZOJN9gs!QVHw4$yVH2P94G{yq3(cV-Cag4 z&^qTRQ~!<>0gJ*xV!=Xi;jkeQ>uyLC+6_9kYpx}f;^{T83!_%e##6z*&a#ee*!Ei3 zEuHFcTYUqHufv6HhgxDH-ItS{U>VeY0bC0G@D$L@8!feL75)cx1z0=-*5MohQ&Eia zgJUP~?}e>ISt3g05D~;=PLA>?7Vm6F6Xi!2@9=Uql@$5Gi6i(|Pr6)Jxy4#^KKrIb zc4BaxUiCH7UQh<>8XpyBP9B}8C$$={p)FZMx2ARllKel_v88ziJ2B(bJ&PGrS`$Z{ z#FfaVvIImvGmd}tY+@y|;5ZzTlX)r1kB@Dx!0Ja14YQ38fAo*4XObj^R1M9gubPb7 zXX*9A^%~lIc^^+b1KBFqTZa)2FVg(l+R={(pK5b)KACJE%L!C3=tfm*VamG0zSD}zW6;Yzbz%_=9MTv zlTC^BRairS#I`O!+e99f#N)@dRSlIpm%W`yWd%{letqe}kY;4f`>E$0Io4#jqsCaI z=6rs-xVIb8->JCC+>CHw?Bw(O=!v7x^Cu>r=Z}nxM(TxP4QQlG4S{QUK6ytHuYm@t zVaQ!mmQvESc}Y?Ts+_tD-kk*<1=mu(B#^meMpgqzwaHLYecm7L3@fsCMP zs$nyKJ|)blL7F`a#o+Gx;QQ;+9fkU_Gc+s(T8iq8bFso1g_ZiLkiY zE%pndsDU2@wVt3~ss`ZYHHAn@VqQf84Z(KetEy*;vyYa=8kdWq~9pEW| zkU~5~k&=+C2#O3D3Z{g##l$Gn77#rb)-eV>+bATIWHw`d8qkX4GfGAciTYHhxh9Q+ z1oT?~0EWmR;14IEagjdk6FOCOPB_SeLILO_$}#YO-(tY8u5w)^u4{F0RjzP@G~Zp~ zx;L0WPv{8~@Q0stGQ0M!UN5%{tau-Dja9C{#Pt^kiZg2iYcmyYl)j@?3{<$iPrS_D z!F#W)qs!h^<%UY!P?4;SRk#r|{}HJUA1@6buMVFr4WF%W=V-}ri5uQvd=Y5W=MQf* zGyZP9si6uNfoj`(R?mI->fgRvZW~$&Jmz*)Ipp*H(PGEiz)z%~rXHlKBWFq@XUcnC zs&JE(fG=_UdOtKiPP4rwu6I?baQkSkuf+9z>mOEb1sd`FW&;9QfkFQOY_a3frN9d;m zC2nASaH7H;rCF5OM+4P?vC_a;b>MVq;B>jz(4?>k-Y zJhKvh%(Ydyy(Mn%eW`e1ZMxj6@d_^oOWff4zM~cH7^OkhKN_nJj+X|Xd{5naZ{oP()giWkWDY2+I{jK8DG;cyqbDSOZ|&Go><1BUht z-8;0>^q2!8>>Zr~@Qk&t4~$p13476}ILSK^nChd=89AsKW{#1AnupA(U*YyxCA}YW z2MCWE*a-=skATq81tP8n;_-A=%%!AwTn)wJZ{&oOUeX{dL~TgIN`~kM)m=5Lqj3o- zm5Rq7umqtG8TLBnB~8N-*ddr8DkY=K^y{~oU%I@W&edxh4BXaadi&Q_dRxDK^GkgD zf6ZCe6VX`2uM_ZZUIs9>6>0)jmXlPl| zi!{(>o9xB6RB+fR&%dRKjv|!1Z9JL~<(~nWjCE~+C=CpfO0Eur#Ep#h=_2sGJqBs@ zNNq* zI01(liWHxh1lZ&W8uMzC{towEIPZ|vcKwB*&Gxtq;5h8!5szjx1kX*UeI65C3x@ls|w(-@~;%+l_m+8`jSd(=kxoS8g9(2|ngpt$lcn zJc8{I)_HhG*KY#5+lH6f)n08GEVT@lTlV8F52d(Mtj@y14W+v~tF6PO*5Pt%WQDUz z=V9@N(mj3E?xUsdqvh`7xW*daWtn25XahGCqrcSmHp)%wPG0*v2W z@ofB=h}bgX#5?iF8F-de7kuT^njKkkEjXb21-l+QQ`q#hz`~LrxJ+=FCx{NJK3z=- zBA+$1pUDA8^3h8|1~vpCtEF@ce_@+pS1L>-YB>1v)j%6#pyM6Z4~Sz(|B0z zuW&tJq;%|E{Ub|b_msFj_g^j!uZ{gA_tV0ILiO-VrNb|kdnPN~6jd_IctMMcU*~U? zyI#|+E>!LNx#G>WnQ|YrHAYRbeI;(+`u<}T?zm;)>;VP0SuPdsz^6@ZE8(qr>xPce z9q#X7km2O>8|$q#M-_7~Gxr8OCo;7ZTGlT(!!xx6C>->`(rB<88l13iIHxn~P|W?w zY#H^e#c0pE&%xpPkImHx>UPw|y4Kx}t=sk7)J)ORb3DD-sb3z)nr&#OV%2PF`ZAuL z>{PSkIf!UOnc=kXo39qGg$_%y#*;AuAY11)Y&icm6gJt?6)AYRh69{$xh4rDF|RK= z^2o?Y4HqNfHJAti!)M038PBkjW$|J?aF&833M+ zVb=8qdt^hDwn9=Cnp4;L=xjETlbuBHn*zuYcOI~t6tAzD`R1!q0#KaP60aku`QT+q z67^iZ@lsNjp}YJf#%9RE&$o<^5lZ63+_Pi!zc1s1qmoW$4&6?Q$~-W=b&hNrK|?&z zs7SJlhj2r6l9WD-TXEyR(#{VsnkA!J~D%{d{w zv1O302qTBa>W*Tz!%+>~c54q&q??fOtx`oIL$RI#8Q-c{Siug^IFi`3dn*%`@R0Od z#?EtyOl*93_XAz^=!jg>jWOY`zW$K>mf-ZQ;z6MpW3=5gcZx zP7w7^2^vTgV!vWR(TI$zjo>6wVEz>fhimpPx-KdQ`^l?{l$I&rOlm<$LgSD_!+L`W^FSR2YMyEDU+;gR+;&Rm(TS-rf|@|R(aZRobY$^avBXYr&n^2L zzxuSPW2I@UeQx^IUa-`EKkRdxpKp4b)Xz12ZkQr|4BMeHI#}a#<&}7V@1GBp#F~& zU#5<1OJRDEK!J$NuFC|wPcU*RU##;Dv~cd7?5n4{Ip$&aw-f3@62N8+4`TDJXeDOQ z6VPxXblEf{38x!Hl4=OKQ@}~swbfj)!pxnzxUKz*Ho+$AaNNo3sxv~J9K6lOo5fEw znwZaJUUy>4?!atk((J^Y)$n!UM_c4##ppY=|8%j~xZBpqvsy<<>OTp9SpO(QEh))_ zkaB9)R`Is6Rk{cCl!<0e#)-`Xwk=c9c|aXg(laynjAQZ?1|lK!h_BB}{1lulq$Ih9 zTV3sANWKb;@uILK^TJ#fhyquVFiFm*vP+_-7k4SmD1Td$Ue{?t7r}LoNsEh8Dw!cE ztO=8RVBA&{{JbJ3Ntqg+`PcrL79X`0=XW7DSK*f%sBPEaR~^6YWpP7D5|Bl3g;FZ(S0S&AP6Sm-ZwA}!i+5(K}{<0Z{TZ(P*ZmG~_EaZZz&*P~A?0zEdx zBtWyiQD0o+GX3d}l?QFZUj>DX@;Uf5kLuHNDx1_oZ@gM)i|W;aT2!AN&Z4>#@G~13 z2LV@sx=ZsAz6VA5fl2Fp*Z|Uv2K8PBVNm4%fdm|dU0ru0D?W4-Zt7=La1d{I6uS9) zojUrBjzSU4eFznRH~@sb;rM$9jBqcQ?9C7iP|TFOkLvLN2TR<+wJ|&wdjw};;9BT0 z0!6^h_kY&ax6U6ecO82+T>Z0-!MlH~I~~-^0I(i{1i;Inr)dKI&INOt&k;bM0|IO| zIvIbb9w{+G18VfpgyQoR?r?oLMWn<<*7m_`Th|(~YmL+!>F&SRwbJ+roc*0HcZ}(r zXrUJ1!F=uww+2>xPkNd0Q$M><9y|9rzoh{CN(z+jCJS$xdWC{gSk(e+E*G05A#BcnedZ8z~?{2L3!xCi zb&K_MXiei6mm{re)0L%Z<=Duyu6(j>RiNHCT>PY>i}i=0i&^kJb%TV(t<@!lMf+_E0jD1$wmXqvcGb<*h%(R ujFtY9K7Z+D*q;AlhMu_Zux#YL_9yToI5zS`X_#$bJKh`qlEFJ|_?hK^G5r~2E4p)GN+p7ayaY0cO2NdW(?H?jYgTlfFJj<6%?3_Fvquq){fyOW-L>Hul>Y00sMw1fUe4EBaQ*u5=Q;Ebhm7;KGKAw@T zrV{bkddQ(V<`j77T0ALHsJZ9fO06m?nY$WINQxrGw4rOcSFgO7fr~$Z*^;<0FLGgr z=n;9zDLUZ4Q*thPHn~L}-aZ(1)wYZ7d#ylYM z>&QBd+2^@+?h!xx$f>&J^qNFf5{blD<4Pm~V~~{eS|lA0c~qAyB^GH5sG$2WYlTep ztVO8=ljVyem(Z)*;_L-^+8$^uF}+s6jS6|tJ0l!({em^D;>YJ z7G09$TVhmRUP!?N99vtb9q@Ff#h8*~#z=75WdX9^e8@c`!Ta7K>BzO0iLgxqKMLLt zK9F+P^H-jdqc1>)i@7k@6F$mj?B@<#-7q8i>=*)AihRwS>8rIy!cmn_%sI_+M(c(v z>sm2Zhvh-hDZ23gV|`5UOCJq{4Nund+TMIhqt+Y8tuz}~)p7-wNnJA5T(x)T$8LOni-eP2 zv5e7a+}ZGEy{{c(gUyV1ZF{uL2z;Tox`9JA>ot02-_TCN@(uUU;i=KF|J7a|>rH5t zp}_3UU8qNUV9ENKQR$gsAi^KkG~?ZcXzcwZno6&VXNAG>)A)DpRiP9WC3=)b#dumi zE1X`tyO+#aVQlTLAcLzEg~7=a_*W~sQKQ@#*xBXOZHc~acoQtu>SOP~hF8<|n7D9e zYO+?e+V##GFiksX3Oi_i==ax z^LGbca(-X%We2yfCwHyXGO+2{CXE%+UnKqcf&4;Yps-LTW325!ksQeNmr3_a54XQ3 zcm3n!X2UjV)ighRGe2AyFLfU)ljE!rHEaz`l*#1Y;(wL?o9v@(<@m+o@r$MI=`xwA zd-T^HF7U>)p27T;QqR%N+3i4QB`{D74E$`Wu>VPaX>hU}m|~41#lXl`=xjM~Zac8A z66h`lx^sU}4vc<|FVlg5(p4ry>@{j@_)`T5uiA@c#O5wz!=Ls4 zP9yfecEo;Le)r8Q4K1`Ty@sbAtP_W723A(}t-+e?=EbtoKw|h+Ej5hMy%Ks=!dT5< z1NMDbz8~JiW zBeYOUy0j1-89y@$qEn;7>B&*y#IdnZEk8r03$;vh>|{OFw2(M-F%-WB9l;Feiyne3 z)J6~EHM%}JirG=j5Jl7g(k$d_xT9ya=|p^KH3{5|qAOE_X8o;1bV+Ss)k|<&qVx!= zLHMA@FhizBk3*(<-j#qEC+Qeg{OpnU7#$?+`dVs@aX2luK*vEU)UY?+AabaFL`8$W zDbig9t}%u|C*dwV$wUw+bqF33LzcVMM*GiB2EMN9W@#o7kS zqz7nNdw1^g$G0~9+hkvb^c6{8-jN42!)E$j&l3xCf0aldkHW z&Zp#{M%5T=IgRV!0)9sLiUudVh>e)x8k66IOy`6Xxp^dm6NOe*eCA}uuO^ThPVACV z_Yev|fdWvV02IHP_zDZHThv>_5Dm`p0>@Ir5Vd#~f6o^Nrg4vhsFo9QH$=F&um@sV zT-eKET7<>4f|re$&Q%EU#B}NaIyKtIsNZF%)KNc&PW{ZF2e?IB>Q{A}HI&b(nP8RP zVMh>%vQWGBnvt2@ZV?1!WCeS0yB{=Zln%d)kxUYsfY#rc((ze-4@&0%O6RapI{tN( z4(7@vb;ck8#`Mb#2lRwqr7mSnYQ$#SCd6cFaPFbOBri;Z8IY=WWNL8jp}`~*pn+dO zx?YzI4J<6;oXy;mQd9 z{umrsz@TnD8)&csqB72zjt1IR-i2Fv7r5sDxaa)AQv0yJJ3mw;hw`*AUZ79F>z!mS zlX-1gCIh=3ZeM$)Ww6*XSZW!<&A;JZ7h%5)ciTHFZAXf2M@nr+5jTvxD`gUbyWK*i z>r}DpRH^IC7U?i=Z}nmrfVpby=_wIjwJ@aj%vbyivoOW9K4Ud(@Rn_o=`~=bJ8W%= z7ulc1rW~)csVd=x?UmmDhBNE5vn0i|9oR6rl>m&Mr4^H~8ZGt#Q=2V>a|L&;bxE`k zPs{ks@FWXGH`F}@)td1Un=ORPwl@p?hk3QxsKHT!J~Lpw_7XMr4G`gcN=htr+0O{z4(_@b3(Oq9B2j5r7Va!Z6)2XYYirUfMA?Kpab;Nm^Pxf#4-|w% ze1S&k`VP*vMjIO_t{#oxnO4kbV;$e4Cs6JPX2|$LnvK$1*ucnJ@QyY_jhr#IR&S0s zK{(S@);gFGmxH6VM~}R z9eA&vK`oT}ps#U8tE>#l2#NJD>Sxq?%Vgy9rnb%CH`rO3t{pyOl%>6zP_FJ%+He9#hYj3zWJR9DsRb<^+4{XD59YBK3zr)UQvm^_ICIebn z3CM0d!E+ytxd4-3&AMi3Kj;W~LQV*!uBDXt;<|7ZJa{55E3)bs8_zUO2=e-BOcDhp zB}Uglu;FS#x+`3xuyc|#?(xynlczGRV^b#u2>YVgavWB+B)t!!jqk=~85g!Dtvbdh zGA-``)U1OxK}_9QO{AhC2sTYq1-lgHW6{-BNmL!D&t#h4m6WI$RiZ+A4GIYaRo`X( zAz9_8G7aBe71n5KiAu7JBb$ej3K*|X%NggH(?c2m^)=QLJiUVx7V>EJgW+Vh<5GRn zY8nnnL4ZXUHDmEyCCx>taagW^F1Tua9ZFVsPFkBU@pBhM5Lx--D)1pRE;D zu`vfi@@>e#lJ@t0yuKND*3_9hUuqiJbm4(u)BUEL1E}_MkGI9Y>3+G78=ZP`y>#T< zbI##AQ49=kP8XYoAGjZ+bIZ?KyC7^gRTwF?PHbM@4mAI0_Wo>cGT)Mq{-nLoQkZ+x z_B3#O*9{Nu60W&>OAiFc%mohPFoEhHUQzt4@WfHU(+`$b zOeiv10Dx>j$yoG8Kj6-tc4Vw3GG_vm3Ceg9YD3{_U`sbh>uEJR;k1Dlh{v%%3vAm* zK`oIia2Y;u84%9|2eAeY!Ue|f-5?vj4ZmW>60=?~d|%dYXx?amD2z>PFxz065&fA( zSJor?QTTn>ddvJrlgg_z5y%Es%(!B8?yWIEkg%I8YmfG03RK7R5kMR`HoKA6LbIJj zJv0YqS$HYa^qv%hD2ZUi!ZlpR;|UFy^M2EG;X?b##p|f||LbuiV;DPVG9h=!m4+3- z?f%7|-OB_GpDmyV%nV`##e>tQp(iy@n2?I z4F4)zN+s9e$0Aau-)KUO^C|S^_@j3r*_Z9{zDA$Q-*lqT%U8!h9i_;nOlrtwOX+6y$U) z257LDP9)Yd4HHb8T>F-PnhCt45i15anO+@q1OzeoF^8ZqY!hI1w3!+c0QpeI&VND5 zyiv3F(hrbBjTUBR#`5+iW$0hEz~4o@LXO5Ff}x;>Jx%6bNrNAav<0+XmB!iBeWnb= z;S|_>!7p8^&*>X6zb+J)WIBz#pl!0By0DWYINHJkE}tyH?_Q!xipog&t#+WpSQwf! zL$)wx4nyA+{xyioH(;3pn05Al654D+S{Ny{9Mx$dqkwvJuA_IW_jqaliOs+^d80zQ zi=_ME_57*A%rD%(415}>oR}`2m@f4|!0r+g7$}l~EjR)tQ@{ax4}NlUi*#<2&I%bR zl964`a~L*+9@r2zb6tnF4xKJ_p4n{LCi^SoaFHA?j1{6!TAsj}E9?lN4VC4AEe<{R zc-sA&{@rG7^yG8S=bJ)Ez5HM(*O8xm*4meM<)y;SCrYXHJR)jWB`{PB4CTdg;0Wvo zTiW2qIydvmR@3Mc|7YW$HkO($JnJ6#`whkOAlhMzcdbdcg7V1J=3Hj(5 zDCk+t&SBPp89GIZ?45oOGyHV)TbR|y;;vyMW)l3Dafbr2xG&*+w&OY3@9uv--s*0D zez?`${hx=AyZf|$QG7PEPi?xoKBt^KHm7LE{<3BLv#2%(lPFu}jBO#fQ^apIU_5v! zMI{!|rikWhgVFwWI<5t-*}jMQ=Zp9>_WX#`MuEQp_?n#8&Z#fZ??V;+B8w;PI(eS| z%E9yB;lJWAf5Gzq;0FJd8-D3J%y)0ze#t@c@+LpV4?Q^bl7r&qB7cYH`yRagC5I*J G=Dz{B)FQ$F literal 0 HcmV?d00001 diff --git a/ui/pages/__pycache__/downloads.cpython-312.pyc b/ui/pages/__pycache__/downloads.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ba47f4b51e2bbaba6c1689c1d85f49c6641c6b76 GIT binary patch literal 18608 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 R#ocVnn~#6W;4fjp{{ch{HzEK4 literal 0 HcmV?d00001 diff --git a/ui/pages/__pycache__/settings.cpython-312.pyc b/ui/pages/__pycache__/settings.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b7f8f532d96262e3642a3b5e201cd761fb8cc6f4 GIT binary patch literal 17935 zcmds9X>1(Hb)Ff{kj=SxA45`P^Cl(nK17aI_qAF}T1nJuy_2;E!|5hD)LicFS&8GB zWDzV78i5ldi@*Z3NG67VaIp(`4H)o`0Qr-Di4kzz6O7Q10Ra&L=%2htZ1`8+tM2OV znQ5-1tY9E+O2w|~SM{pid-ba7t1AAx&*!$m^EdyT-1^n3&Gugyp^r;@`Qih3`LT_& zarU?^VGr38j*uf!7phA*L(YUNJts2h6EF05{;q8ggfL;ctW0pH{?zDLOwg@ zb;SLNrce`kuZuS)T0$*$o5Oa=#yLN*aW3BbNuA0s)Jjt8A+>?G>#1!!%w*$JB$@On z4L9D7ahtrPcy8Q2o4R);vXjbSptXT+_u8A(bdmBt%a zVoCl27n9)iTq?1if{ggO#YgYpD|2Hl9ZRZ-xA=RKMtVs|Wzv}Hxp6TiBvihWcExc+ zg3twtNhzu}Lk*o;@9jUfNhBGAAJpY+KLp+7>^a9L4mH_s z%h|T6K6OePX!H|1rP#4eI<7_j8{%%(^xU9rX1`yNjwI`m=$%avNsSluDh*tgthgj1!e%U~QOs6T1aN_{#a@JZ+};czq_5yfy=L@NZY@Bd-; zLh#+QLh5@Np1m%lw)vV_N7jg1j1eMX$^_;@pw4=v0dmzzStj53w;m@Z8T`W z4=9A+e_;Eqqt)fRAN`#Te*G2$;SeRO==rp_esLQGSpv8bfu^^KBs~R|laB z{a~n`b3xd^f%gkBTmyuS90Q@7YlP6lxgqotPvHYk;Z?j;g4aR9QLVQFL)AMF4{;tM zRUX1YJ;Wz^@3;03pOB(#HoI-dCiFvOvQFrPU#b8ZP-rF{&cq-!fT=wg5Gf*OFbq|W zMAseWn(C@X*@>9ftm@7Z!-As!*7pP751WX1qgp+I&}OTL|D zM0EkQw8eRQ&Ti=u9US@jMDyI>Iqlo1|EhoD@AVaRvueMI0>-?CYuUHm;KcODY^N5U zx?;?mTb)~+TRh0aKE)pM6q}K0h2Ysg{@uT@YmxVOma=#{1<8@?U!} znZY76SQvt2$kf$a?wl%iPRX4!$OgW$MTRZ(mY6B{+TB;~nkjb8$X#>0UgK+DiJ5+k zRU!(%|qNmP3o3BR{60#qm5BFX;n|G2rBJ zXs~c>#vsBuIhW+6wcYm9H{54uX4bTMsp2{##3J#hIQh#qQU9kc z60-B)LN;QX8G&bEwJER(oZPbFPBO|yk{rvW?k3}@2*;)ceuIaZa+DXJqU4Q|))(;S z_@qZbt0KNhz!5;0#sH@^N&~tHTnON>tkl0voQ2{%3(FYQq0K`2q)VM{9V0ZF^YDn6sq+qF>k_S|~A#=r7oP_LdxYOY<4nX+O#o-rif62Y}Z+aRwtrW@LYK zxx^fMjMeGJ1p;`EQBpY)>n|}AkDEGn)#-V~{88`L1;9RDMYlE#RMKaPoIX#>*O{Dz zJ})AjMDN@nYV83R0muNX%Z>S`)6$ukOj^>Nf$o@GhBL_Nb0#vYLQ|-E%(E&SkC%PEVEQuFXW_+@7qJ zjWG@aCu<7bvmHpG+bU@K2<4$`r9IdFT>HfdA9=VQs;hiQu45bLWK|E>ODUi}9S6vM zt|}8mX?={`lEJl)rsg|yozLak`HHw6(z$l!x}M9m>zi-|9aw4K)JG4X(EBS$+qw*M z?{aQ0)Ji_5`y?pCVObA-21qEDYRjP7gYURh>A&_Ar&HOMGuJP(bO{oXVyUEn2KW@m z*QdCNr!=gkQ&MbWM>vH^xcoDg9&;*Aq_9hK^Xv0V>)a|o%PsOzZe@OMadu;wn~UFzeUe*LYY! zNI}JUJ12n;_R%_9$DSGwfSB| zyge6rfkoBzb0-``~-MkXa*569q;gdmySxaT&BG2D3IBaYzuP-be&vs}W z;;CpPzLgTC;L_~EY_|IXASr^nU=!ow9gamY-r;w$ZZ%5EXE5S;CPG1G1vSb%21XA~e^UWjg`uBJF%VT`xyud$2TsWe52v4mrs+Co6i)C7@OjF#w5gV|^WddRz0n)*R4%1=II zgt(TenZzYD4K2klYO2wSYSQCcaRz}_J7NT3qf8$*6wE^_&_iUVW%iPY)?B5L#IOpA zPyL|r7E!Tev278k&>Z^g?10t!+l2IO0+DzQmVpN?!pVXg(6mFaXzm!=A6k;zmvr{g7!bGowXjw6MN6w732lkS>ul0-mjxm_f)G7|U z11}flo=dw;51B5l9$a}WSR4z=W3QE%6Hofb_b1+v`@gl%^gd*I%glI@8UM7gJP|BT z1j`fWiW6$F@MW^dOzw@851%X^J}DnQU1H8WM!=A?BT-_G5=?-^;(KqFnAi09e*g6T zEEWST?d<#T+q>>Zor8tLa_5u=P3SP@laAi|7eD;2CNQ+=g0xEI@#Dqu~rbKy}BS1O^IjIdG%~UHQf6FOUBG zXnFozasHe?!gBh%~U3e%*6hrZWxI0{B*KBv{)QkEDxP1 z4q@ALlPR1pkE|3&R^$ z5_9@V`#`}Xw;$bYe8{wDpml4B86h!(H#^I0u*e2wmRNcBaN$k4dr=b$v5uRhYS5nu zFKGM8;>byPM73)QZpTS$Xe>su&=}gh(e6JhF_Q)i79eQ1hR2nd-UC1|eG_#NCJKN0 zAeaGh5fDrVCBVmOj{$eV)EHCfq2g+1yJ1lOv|A7LkJ8CXCpwYDVb%T2CLFB2iDBdQk6A=l2C zfH9!4=BqE)jl8vd0Z_C!B9Sve6bDe4wQpU9E>qBs(`vb11*Xgx^`vhAI&1r-2bQ6O z?FUlmkTG~ATvCz8fZOt|rmYo2p9qfO!m8@A*jj0`glo)OD>}Tw>F_`vVIGUce+Br~ zet>4bX2EaBOES22(^LWyJ&$Y0E8-f^xgtnp#Z?EdKzE(rge&OE%8JcEiZz89(6Oe_ zHCACgI;fWKGFguf?c}?2-7jc8-Osfi4>dvBd-DO4=Q5zUo?MTW3@bcW?VO}H*K4(7 z4s()26?2ka%Q;Cu)iG$W*NXcK&q)H78Z=voHHEGTs6pVR$ACVQBnN0)@`rMVtlCl` zNn;FP^?d!geye;SgBKbDa($LE&|yz#p*4kB3*DBUR_}{YhBbw5M?m4QtVbp2Xw~0z z?|EPudJh2#1SSWs375gVvn}W8$p;E(5MWYx13$K2C#N9~J*L#1N$v>WLym!~k$Z@f zMl%8)S7vGL#NbmLkA!bwZ~`fN=_mG9EWyJC0(JsGsZ^2^CxZe)f3a`FJSuh+vSs}` z?e&PX70i03XE?3dqEZh>pYMwN(~DTDZw*g`P*G(2&t#<36gef51{^6K6D5)50omMP zx8M}Xt!&?0X$}sbu?b#^z)OVP5K;+>KxRiVdjjBIKsDn##vzkLOcdeh8Y>BK1Yf+E zZN8G)gj3_224gp*uvdUnJP7BwUFR>Hee06qynOBAn@YpmXKr4*eC?9rx^VO6n>U5` zu-<;8zZi?d32xFtZ#=ar&P3AbX?XcMsM7_xRg{w;ngohw0H3@p@d=TfdpoD90?|no z>x|-!3k`!La3x}p0wUH5o+-W=Np1q}32(O}@eHr{5xo@QK8BVB$97>219G&^3xwoo z-5T5q5Z=Lb9I*s+zT})Axz)jQN-LHV#Va1`se5W!lLP8enh<6nsHdOmRf9g8^f<*3 z@h~|UvW|xuaXfnCqfydGr3DO1Gew%AxgrVkz*!@m2-~UnCDkHn7M~GEdM}L^A$j@N)~hl|W%^|a(?LWv0y#7YO?5TPR{A|G}4 z<5@)=8d9;-izVjL6BCYx$k(q20KN(nw2a_Kw+ZBF!jH_@89hjOsKm?=1dkl6)ME(5 z0YU_|I5hF#>OK>A)C)(iPRP9{DLRQ5P8iv)Mpq6NTb7a!tBYzhF9mGXE^?mnSGWhQxa4Sbl@;5ik$ zE({Yelpf!V!Z}m|-ch)2xj4Km53dk-&{`mPl9Y(Y5Qqv6KI-V*?>j1YOleTCiu59o z`J|)&e!lWUp}jn~R2*EA2alDQl_vnbcgr1zwVdkVQ%z(5QWB9thU3M-R}6ga?i90@!lVMy~JDwEd0R{ zQ_uMIAog3*Oj75&r1O~?ZESly#vK4+ryzm_(#*;jia@PJ@7RN>-KIx@fqiyX4$PT0 z81_dmm6+FI>j9AKz`W^0<<7$aRzu8$?4YQ|tsko-7CHb1X28jKj?y?)*+YPCdg2nC z%J>2BGWb#T=rrtK0ld6p$Ge5^z)Z?*cFp=N%;z2U|N0Aq6?Y4lXYr@@x`KKmLcjB1 zd38{~K4*FJujv6o8wUHCHhWtq^5X(Y!ikR~8%Ogj9M8=Vw;JKkz1yQA?o!Iu`y3 z0<-btAZ}qiWTvLhp0~`#)BM85bFapDw9D33)&G;Wot5XAjwYB_XF1lY{tHxfZ;hY8 z<%O#0?}N(76(e*Lz8BW?Z)!A6Z`fO?`epoU&5bp~nAN_aUE~i2V@VDc&r-_jLK*GB z%hlc*%h=Mckk46utr|`&2Ml@%4=PJ?`62H;0|u^3L_Ah@9K|XA_jPQOAJB)clEz=I9%PI zGc&G<&)4m3uFlU7^L7ZY)m?VLuiH+a%lmnM3j||+2v~UgpD{=y21yFLHrcK;UEjGO zEl=N&-X?!?L^K`xGoUY=L8<5M2snix7{Q+is3rI?ig&@tkwC>uOFWkncmZ$XDIS`1 zDHBr<3br9RX;lH=a37r@8Gm42Q~$ETNdY$%K*Toj6a>%e>~{O_91i=M{dYDa{EEDP mRd2KR{l<3aOJ|$i_oK^S+Thohhu^HXdw+E7D;xeM4E`6-+a@0X literal 0 HcmV?d00001 diff --git a/ui/pages/__pycache__/sync.cpython-312.pyc b/ui/pages/__pycache__/sync.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7c2d5e381017bd2b256d3873b303db0d22ef27da GIT binary patch literal 18912 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 literal 0 HcmV?d00001 diff --git a/ui/pages/artists.py b/ui/pages/artists.py new file mode 100644 index 00000000..1d50098a --- /dev/null +++ b/ui/pages/artists.py @@ -0,0 +1,359 @@ +from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QFrame, QPushButton, QLineEdit, QScrollArea, + QGridLayout, QComboBox, QProgressBar) +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QFont, QPixmap + +class ArtistCard(QFrame): + def __init__(self, name: str, album_count: int, track_count: int, last_updated: str, parent=None): + super().__init__(parent) + self.name = name + self.album_count = album_count + self.track_count = track_count + self.last_updated = last_updated + self.setup_ui() + + def setup_ui(self): + self.setFixedSize(280, 200) + self.setStyleSheet(""" + ArtistCard { + background: #282828; + border-radius: 8px; + border: 1px solid #404040; + } + ArtistCard:hover { + background: #333333; + border: 1px solid #1db954; + } + """) + + layout = QVBoxLayout(self) + layout.setContentsMargins(20, 20, 20, 20) + layout.setSpacing(10) + + # Artist image placeholder + image_placeholder = QLabel() + image_placeholder.setFixedSize(80, 80) + image_placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter) + image_placeholder.setStyleSheet(""" + QLabel { + background: #404040; + border-radius: 40px; + color: #b3b3b3; + font-size: 32px; + } + """) + image_placeholder.setText("🎡") + + # Artist name + name_label = QLabel(self.name) + name_label.setFont(QFont("Arial", 14, QFont.Weight.Bold)) + name_label.setStyleSheet("color: #ffffff;") + name_label.setWordWrap(True) + name_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + # Stats + stats_layout = QVBoxLayout() + stats_layout.setSpacing(5) + + albums_label = QLabel(f"{self.album_count} albums") + albums_label.setFont(QFont("Arial", 10)) + albums_label.setStyleSheet("color: #b3b3b3;") + albums_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + tracks_label = QLabel(f"{self.track_count} tracks") + tracks_label.setFont(QFont("Arial", 10)) + tracks_label.setStyleSheet("color: #b3b3b3;") + tracks_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + updated_label = QLabel(f"Updated: {self.last_updated}") + updated_label.setFont(QFont("Arial", 9)) + updated_label.setStyleSheet("color: #666666;") + updated_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + stats_layout.addWidget(albums_label) + stats_layout.addWidget(tracks_label) + stats_layout.addWidget(updated_label) + + # Action buttons + actions_layout = QHBoxLayout() + actions_layout.setSpacing(10) + + update_btn = QPushButton("Update") + update_btn.setFixedSize(60, 25) + update_btn.setStyleSheet(""" + QPushButton { + background: #1db954; + border: none; + border-radius: 12px; + color: #000000; + font-size: 9px; + font-weight: bold; + } + QPushButton:hover { + background: #1ed760; + } + """) + + download_btn = QPushButton("Download") + download_btn.setFixedSize(60, 25) + download_btn.setStyleSheet(""" + QPushButton { + background: transparent; + border: 1px solid #1db954; + border-radius: 12px; + color: #1db954; + font-size: 9px; + font-weight: bold; + } + QPushButton:hover { + background: rgba(29, 185, 84, 0.1); + } + """) + + actions_layout.addWidget(update_btn) + actions_layout.addWidget(download_btn) + + # Center the image + image_container = QHBoxLayout() + image_container.addStretch() + image_container.addWidget(image_placeholder) + image_container.addStretch() + + layout.addLayout(image_container) + layout.addWidget(name_label) + layout.addLayout(stats_layout) + layout.addStretch() + layout.addLayout(actions_layout) + +class ArtistsPage(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.setup_ui() + + def setup_ui(self): + self.setStyleSheet(""" + ArtistsPage { + background: #191414; + } + """) + + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(30, 30, 30, 30) + main_layout.setSpacing(25) + + # Header + header = self.create_header() + main_layout.addWidget(header) + + # Controls + controls = self.create_controls() + main_layout.addWidget(controls) + + # Artists grid + artists_section = self.create_artists_section() + main_layout.addWidget(artists_section) + + def create_header(self): + header = QWidget() + layout = QVBoxLayout(header) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(5) + + # Title + title_label = QLabel("Artists") + title_label.setFont(QFont("Arial", 28, QFont.Weight.Bold)) + title_label.setStyleSheet("color: #ffffff;") + + # Subtitle + subtitle_label = QLabel("Manage artist metadata and download complete discographies") + subtitle_label.setFont(QFont("Arial", 14)) + subtitle_label.setStyleSheet("color: #b3b3b3;") + + layout.addWidget(title_label) + layout.addWidget(subtitle_label) + + return header + + def create_controls(self): + controls = QWidget() + layout = QHBoxLayout(controls) + layout.setSpacing(15) + + # Search bar + search_bar = QLineEdit() + search_bar.setPlaceholderText("Search artists...") + search_bar.setFixedHeight(40) + search_bar.setStyleSheet(""" + QLineEdit { + background: #282828; + border: 1px solid #404040; + border-radius: 20px; + padding: 0 15px; + color: #ffffff; + font-size: 12px; + } + QLineEdit:focus { + border: 1px solid #1db954; + } + """) + + # Filter dropdown + filter_combo = QComboBox() + filter_combo.addItems(["All Artists", "Recently Updated", "Need Update", "Missing Albums"]) + filter_combo.setFixedHeight(40) + filter_combo.setStyleSheet(""" + QComboBox { + background: #282828; + border: 1px solid #404040; + border-radius: 20px; + padding: 0 15px; + color: #ffffff; + font-size: 12px; + min-width: 120px; + } + QComboBox::drop-down { + border: none; + } + QComboBox::down-arrow { + image: none; + } + """) + + # Scan all button + scan_btn = QPushButton("πŸ” Scan All Artists") + scan_btn.setFixedHeight(40) + scan_btn.setStyleSheet(""" + QPushButton { + background: #1db954; + border: none; + border-radius: 20px; + color: #000000; + font-size: 12px; + font-weight: bold; + padding: 0 20px; + } + QPushButton:hover { + background: #1ed760; + } + """) + + # Update all button + update_btn = QPushButton("πŸ“ Update All Metadata") + update_btn.setFixedHeight(40) + update_btn.setStyleSheet(""" + QPushButton { + background: transparent; + border: 1px solid #1db954; + border-radius: 20px; + color: #1db954; + font-size: 12px; + font-weight: bold; + padding: 0 20px; + } + QPushButton:hover { + background: rgba(29, 185, 84, 0.1); + } + """) + + layout.addWidget(search_bar) + layout.addWidget(filter_combo) + layout.addStretch() + layout.addWidget(scan_btn) + layout.addWidget(update_btn) + + return controls + + def create_artists_section(self): + section = QWidget() + layout = QVBoxLayout(section) + layout.setSpacing(20) + + # Progress bar for batch operations + progress_frame = QFrame() + progress_frame.setFixedHeight(60) + progress_frame.setStyleSheet(""" + QFrame { + background: #282828; + border-radius: 8px; + border: 1px solid #404040; + } + """) + + progress_layout = QVBoxLayout(progress_frame) + progress_layout.setContentsMargins(20, 15, 20, 15) + progress_layout.setSpacing(8) + + progress_label = QLabel("Scanning artists...") + progress_label.setFont(QFont("Arial", 11)) + progress_label.setStyleSheet("color: #ffffff;") + + progress_bar = QProgressBar() + progress_bar.setFixedHeight(6) + progress_bar.setValue(0) + progress_bar.setStyleSheet(""" + QProgressBar { + border: none; + border-radius: 3px; + background: #404040; + } + QProgressBar::chunk { + background: #1db954; + border-radius: 3px; + } + """) + + progress_layout.addWidget(progress_label) + progress_layout.addWidget(progress_bar) + + # Artists grid + artists_scroll = QScrollArea() + artists_scroll.setWidgetResizable(True) + artists_scroll.setStyleSheet(""" + QScrollArea { + border: none; + background: transparent; + } + QScrollBar:vertical { + background: #282828; + width: 8px; + border-radius: 4px; + } + QScrollBar::handle:vertical { + background: #1db954; + border-radius: 4px; + } + """) + + artists_widget = QWidget() + artists_grid = QGridLayout(artists_widget) + artists_grid.setSpacing(20) + + # Sample artists + artists = [ + ("The Beatles", 13, 213, "2 days ago"), + ("Pink Floyd", 15, 147, "1 week ago"), + ("Led Zeppelin", 8, 108, "3 days ago"), + ("Queen", 16, 186, "5 days ago"), + ("The Rolling Stones", 22, 289, "1 week ago"), + ("Bob Dylan", 38, 456, "2 weeks ago"), + ("David Bowie", 27, 342, "4 days ago"), + ("Radiohead", 9, 127, "6 days ago"), + ("The Who", 12, 156, "1 week ago"), + ("Nirvana", 5, 67, "3 weeks ago"), + ("AC/DC", 17, 198, "2 days ago"), + ("Fleetwood Mac", 18, 234, "1 week ago") + ] + + for i, (name, albums, tracks, updated) in enumerate(artists): + card = ArtistCard(name, albums, tracks, updated) + artists_grid.addWidget(card, i // 3, i % 3) + + # Add stretch to fill remaining space + artists_grid.setRowStretch(artists_grid.rowCount(), 1) + artists_scroll.setWidget(artists_widget) + + layout.addWidget(progress_frame) + layout.addWidget(artists_scroll) + + return section \ No newline at end of file diff --git a/ui/pages/dashboard.py b/ui/pages/dashboard.py new file mode 100644 index 00000000..2d537001 --- /dev/null +++ b/ui/pages/dashboard.py @@ -0,0 +1,220 @@ +from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QFrame, QGridLayout, QScrollArea, QSizePolicy) +from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtGui import QFont, QPalette + +class StatCard(QFrame): + def __init__(self, title: str, value: str, subtitle: str = "", parent=None): + super().__init__(parent) + self.setup_ui(title, value, subtitle) + + def setup_ui(self, title: str, value: str, subtitle: str): + self.setFixedHeight(120) + self.setStyleSheet(""" + StatCard { + background: #282828; + border-radius: 8px; + border: 1px solid #404040; + } + StatCard:hover { + background: #333333; + border: 1px solid #1db954; + } + """) + + layout = QVBoxLayout(self) + layout.setContentsMargins(20, 15, 20, 15) + layout.setSpacing(5) + + # Title + title_label = QLabel(title) + title_label.setFont(QFont("Arial", 10)) + title_label.setStyleSheet("color: #b3b3b3;") + + # Value + value_label = QLabel(value) + value_label.setFont(QFont("Arial", 24, QFont.Weight.Bold)) + value_label.setStyleSheet("color: #ffffff;") + + # Subtitle + if subtitle: + subtitle_label = QLabel(subtitle) + subtitle_label.setFont(QFont("Arial", 9)) + subtitle_label.setStyleSheet("color: #b3b3b3;") + layout.addWidget(subtitle_label) + + layout.addWidget(title_label) + layout.addWidget(value_label) + layout.addStretch() + +class ActivityItem(QWidget): + def __init__(self, icon: str, title: str, subtitle: str, time: str, parent=None): + super().__init__(parent) + self.setup_ui(icon, title, subtitle, time) + + def setup_ui(self, icon: str, title: str, subtitle: str, time: str): + self.setFixedHeight(60) + + layout = QHBoxLayout(self) + layout.setContentsMargins(15, 10, 15, 10) + layout.setSpacing(15) + + # Icon + icon_label = QLabel(icon) + icon_label.setFixedSize(32, 32) + icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + icon_label.setStyleSheet(""" + QLabel { + color: #1db954; + font-size: 18px; + background: rgba(29, 185, 84, 0.1); + border-radius: 16px; + } + """) + + # Text content + text_layout = QVBoxLayout() + text_layout.setSpacing(2) + + title_label = QLabel(title) + title_label.setFont(QFont("Arial", 10, QFont.Weight.Medium)) + title_label.setStyleSheet("color: #ffffff;") + + subtitle_label = QLabel(subtitle) + subtitle_label.setFont(QFont("Arial", 9)) + subtitle_label.setStyleSheet("color: #b3b3b3;") + + text_layout.addWidget(title_label) + text_layout.addWidget(subtitle_label) + + # Time + time_label = QLabel(time) + time_label.setFont(QFont("Arial", 9)) + time_label.setStyleSheet("color: #b3b3b3;") + time_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop) + + layout.addWidget(icon_label) + layout.addLayout(text_layout) + layout.addStretch() + layout.addWidget(time_label) + +class DashboardPage(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.setup_ui() + + def setup_ui(self): + self.setStyleSheet(""" + DashboardPage { + background: #191414; + } + """) + + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(30, 30, 30, 30) + main_layout.setSpacing(30) + + # Header + header = self.create_header() + main_layout.addWidget(header) + + # Stats grid + stats_grid = self.create_stats_grid() + main_layout.addWidget(stats_grid) + + # Recent activity + activity_section = self.create_activity_section() + main_layout.addWidget(activity_section) + + main_layout.addStretch() + + def create_header(self): + header = QWidget() + layout = QVBoxLayout(header) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(5) + + # Welcome message + welcome_label = QLabel("Welcome back!") + welcome_label.setFont(QFont("Arial", 28, QFont.Weight.Bold)) + welcome_label.setStyleSheet("color: #ffffff;") + + # Subtitle + subtitle_label = QLabel("Here's what's happening with your music library") + subtitle_label.setFont(QFont("Arial", 14)) + subtitle_label.setStyleSheet("color: #b3b3b3;") + + layout.addWidget(welcome_label) + layout.addWidget(subtitle_label) + + return header + + def create_stats_grid(self): + stats_widget = QWidget() + grid = QGridLayout(stats_widget) + grid.setSpacing(20) + + # Sample stats - these will be populated with real data later + stats = [ + ("Spotify Playlists", "12", "3 synced today"), + ("Plex Tracks", "2,847", "156 added this week"), + ("Missing Tracks", "23", "Ready to download"), + ("Artists Scanned", "89", "Metadata updated"), + ("Downloads", "5", "In progress"), + ("Sync Status", "98%", "Up to date") + ] + + for i, (title, value, subtitle) in enumerate(stats): + card = StatCard(title, value, subtitle) + grid.addWidget(card, i // 3, i % 3) + + return stats_widget + + def create_activity_section(self): + activity_widget = QWidget() + layout = QVBoxLayout(activity_widget) + layout.setSpacing(15) + + # Section header + header_label = QLabel("Recent Activity") + header_label.setFont(QFont("Arial", 16, QFont.Weight.Bold)) + header_label.setStyleSheet("color: #ffffff;") + + # Activity container + activity_container = QFrame() + activity_container.setStyleSheet(""" + QFrame { + background: #282828; + border-radius: 8px; + border: 1px solid #404040; + } + """) + + activity_layout = QVBoxLayout(activity_container) + activity_layout.setContentsMargins(0, 0, 0, 0) + activity_layout.setSpacing(1) + + # Sample activity items + activities = [ + ("πŸ”„", "Playlist Sync", "Synced 'Favorites' playlist to Plex", "2 min ago"), + ("πŸ“₯", "Download Complete", "Downloaded 'Song Title' by Artist", "5 min ago"), + ("🎡", "Artist Updated", "Updated metadata for 'Artist Name'", "1 hour ago"), + ("βœ…", "Sync Complete", "All playlists synchronized successfully", "3 hours ago"), + ("πŸ“Š", "Library Scan", "Scanned 156 new tracks in Plex", "1 day ago") + ] + + for icon, title, subtitle, time in activities: + item = ActivityItem(icon, title, subtitle, time) + activity_layout.addWidget(item) + + # Add separator (except for last item) + if (icon, title, subtitle, time) != activities[-1]: + separator = QFrame() + separator.setFixedHeight(1) + separator.setStyleSheet("background: #404040;") + activity_layout.addWidget(separator) + + layout.addWidget(header_label) + layout.addWidget(activity_container) + + return activity_widget \ No newline at end of file diff --git a/ui/pages/downloads.py b/ui/pages/downloads.py new file mode 100644 index 00000000..26f0e876 --- /dev/null +++ b/ui/pages/downloads.py @@ -0,0 +1,488 @@ +from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QFrame, QPushButton, QProgressBar, QListWidget, + QListWidgetItem, QComboBox, QLineEdit, QScrollArea) +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QFont + +class DownloadItem(QFrame): + def __init__(self, title: str, artist: str, status: str, progress: int = 0, parent=None): + super().__init__(parent) + self.title = title + self.artist = artist + self.status = status + self.progress = progress + self.setup_ui() + + def setup_ui(self): + self.setFixedHeight(80) + self.setStyleSheet(""" + DownloadItem { + background: #282828; + border-radius: 8px; + border: 1px solid #404040; + margin: 2px; + } + DownloadItem:hover { + background: #333333; + } + """) + + layout = QHBoxLayout(self) + layout.setContentsMargins(20, 15, 20, 15) + layout.setSpacing(15) + + # Status icon + status_icon = QLabel() + status_icon.setFixedSize(32, 32) + status_icon.setAlignment(Qt.AlignmentFlag.AlignCenter) + + if self.status == "downloading": + status_icon.setText("πŸ“₯") + status_icon.setStyleSheet(""" + QLabel { + color: #1db954; + font-size: 18px; + background: rgba(29, 185, 84, 0.1); + border-radius: 16px; + } + """) + elif self.status == "completed": + status_icon.setText("βœ…") + status_icon.setStyleSheet(""" + QLabel { + color: #1db954; + font-size: 18px; + background: rgba(29, 185, 84, 0.1); + border-radius: 16px; + } + """) + elif self.status == "failed": + status_icon.setText("❌") + status_icon.setStyleSheet(""" + QLabel { + color: #e22134; + font-size: 18px; + background: rgba(226, 33, 52, 0.1); + border-radius: 16px; + } + """) + else: + status_icon.setText("⏳") + status_icon.setStyleSheet(""" + QLabel { + color: #ffa500; + font-size: 18px; + background: rgba(255, 165, 0, 0.1); + border-radius: 16px; + } + """) + + # Content + content_layout = QVBoxLayout() + content_layout.setSpacing(5) + + # Title and artist + title_label = QLabel(self.title) + title_label.setFont(QFont("Arial", 12, QFont.Weight.Bold)) + title_label.setStyleSheet("color: #ffffff;") + + artist_label = QLabel(f"by {self.artist}") + artist_label.setFont(QFont("Arial", 10)) + artist_label.setStyleSheet("color: #b3b3b3;") + + content_layout.addWidget(title_label) + content_layout.addWidget(artist_label) + + # Progress section + progress_layout = QVBoxLayout() + progress_layout.setSpacing(5) + + # Progress bar + progress_bar = QProgressBar() + progress_bar.setFixedHeight(6) + progress_bar.setValue(self.progress) + progress_bar.setStyleSheet(""" + QProgressBar { + border: none; + border-radius: 3px; + background: #404040; + } + QProgressBar::chunk { + background: #1db954; + border-radius: 3px; + } + """) + + # Status text + status_text = f"{self.status.title()}" + if self.status == "downloading": + status_text += f" - {self.progress}%" + + status_label = QLabel(status_text) + status_label.setFont(QFont("Arial", 9)) + status_label.setStyleSheet("color: #b3b3b3;") + + progress_layout.addWidget(progress_bar) + progress_layout.addWidget(status_label) + + # Action button + action_btn = QPushButton() + action_btn.setFixedSize(80, 30) + + if self.status == "downloading": + action_btn.setText("Cancel") + action_btn.setStyleSheet(""" + QPushButton { + background: transparent; + border: 1px solid #e22134; + border-radius: 15px; + color: #e22134; + font-size: 10px; + font-weight: bold; + } + QPushButton:hover { + background: #e22134; + color: #ffffff; + } + """) + elif self.status == "failed": + action_btn.setText("Retry") + action_btn.setStyleSheet(""" + QPushButton { + background: transparent; + border: 1px solid #1db954; + border-radius: 15px; + color: #1db954; + font-size: 10px; + font-weight: bold; + } + QPushButton:hover { + background: #1db954; + color: #000000; + } + """) + else: + action_btn.setText("Details") + action_btn.setStyleSheet(""" + QPushButton { + background: transparent; + border: 1px solid #b3b3b3; + border-radius: 15px; + color: #b3b3b3; + font-size: 10px; + font-weight: bold; + } + QPushButton:hover { + background: #b3b3b3; + color: #000000; + } + """) + + layout.addWidget(status_icon) + layout.addLayout(content_layout) + layout.addStretch() + layout.addLayout(progress_layout) + layout.addWidget(action_btn) + +class DownloadQueue(QFrame): + def __init__(self, parent=None): + super().__init__(parent) + self.setup_ui() + + def setup_ui(self): + self.setStyleSheet(""" + DownloadQueue { + background: #282828; + border-radius: 8px; + border: 1px solid #404040; + } + """) + + layout = QVBoxLayout(self) + layout.setContentsMargins(20, 20, 20, 20) + layout.setSpacing(15) + + # Header + header_layout = QHBoxLayout() + + title_label = QLabel("Download Queue") + title_label.setFont(QFont("Arial", 16, QFont.Weight.Bold)) + title_label.setStyleSheet("color: #ffffff;") + + queue_count = QLabel("5 items") + queue_count.setFont(QFont("Arial", 11)) + queue_count.setStyleSheet("color: #b3b3b3;") + + header_layout.addWidget(title_label) + header_layout.addStretch() + header_layout.addWidget(queue_count) + + # Queue list + queue_scroll = QScrollArea() + queue_scroll.setWidgetResizable(True) + queue_scroll.setFixedHeight(300) + queue_scroll.setStyleSheet(""" + QScrollArea { + border: none; + background: transparent; + } + QScrollBar:vertical { + background: #404040; + width: 8px; + border-radius: 4px; + } + QScrollBar::handle:vertical { + background: #1db954; + border-radius: 4px; + } + """) + + queue_widget = QWidget() + queue_layout = QVBoxLayout(queue_widget) + queue_layout.setSpacing(8) + + # Sample download items + downloads = [ + ("Song Title 1", "Artist Name 1", "downloading", 75), + ("Song Title 2", "Artist Name 2", "downloading", 45), + ("Song Title 3", "Artist Name 3", "queued", 0), + ("Song Title 4", "Artist Name 4", "completed", 100), + ("Song Title 5", "Artist Name 5", "failed", 0) + ] + + for title, artist, status, progress in downloads: + item = DownloadItem(title, artist, status, progress) + queue_layout.addWidget(item) + + queue_layout.addStretch() + queue_scroll.setWidget(queue_widget) + + layout.addLayout(header_layout) + layout.addWidget(queue_scroll) + +class DownloadsPage(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.setup_ui() + + def setup_ui(self): + self.setStyleSheet(""" + DownloadsPage { + background: #191414; + } + """) + + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(30, 30, 30, 30) + main_layout.setSpacing(25) + + # Header + header = self.create_header() + main_layout.addWidget(header) + + # Content area + content_layout = QHBoxLayout() + content_layout.setSpacing(25) + + # Left side - Download queue + queue_section = DownloadQueue() + content_layout.addWidget(queue_section, 2) + + # Right side - Controls and stats + controls_section = self.create_controls_section() + content_layout.addWidget(controls_section, 1) + + main_layout.addLayout(content_layout) + + # Bottom section - Missing tracks + missing_section = self.create_missing_tracks_section() + main_layout.addWidget(missing_section) + + def create_header(self): + header = QWidget() + layout = QVBoxLayout(header) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(5) + + # Title + title_label = QLabel("Downloads") + title_label.setFont(QFont("Arial", 28, QFont.Weight.Bold)) + title_label.setStyleSheet("color: #ffffff;") + + # Subtitle + subtitle_label = QLabel("Manage your music downloads from Soulseek") + subtitle_label.setFont(QFont("Arial", 14)) + subtitle_label.setStyleSheet("color: #b3b3b3;") + + layout.addWidget(title_label) + layout.addWidget(subtitle_label) + + return header + + def create_controls_section(self): + section = QWidget() + layout = QVBoxLayout(section) + layout.setSpacing(20) + + # Download controls + controls_frame = QFrame() + controls_frame.setStyleSheet(""" + QFrame { + background: #282828; + border-radius: 8px; + border: 1px solid #404040; + } + """) + + controls_layout = QVBoxLayout(controls_frame) + controls_layout.setContentsMargins(20, 20, 20, 20) + controls_layout.setSpacing(15) + + # Controls title + controls_title = QLabel("Download Controls") + controls_title.setFont(QFont("Arial", 14, QFont.Weight.Bold)) + controls_title.setStyleSheet("color: #ffffff;") + + # Pause/Resume button + pause_btn = QPushButton("⏸️ Pause Downloads") + pause_btn.setFixedHeight(40) + pause_btn.setStyleSheet(""" + QPushButton { + background: #1db954; + border: none; + border-radius: 20px; + color: #000000; + font-size: 12px; + font-weight: bold; + } + QPushButton:hover { + background: #1ed760; + } + """) + + # Clear completed button + clear_btn = QPushButton("πŸ—‘οΈ Clear Completed") + clear_btn.setFixedHeight(35) + clear_btn.setStyleSheet(""" + QPushButton { + background: transparent; + border: 1px solid #e22134; + border-radius: 17px; + color: #e22134; + font-size: 11px; + font-weight: bold; + } + QPushButton:hover { + background: rgba(226, 33, 52, 0.1); + } + """) + + controls_layout.addWidget(controls_title) + controls_layout.addWidget(pause_btn) + controls_layout.addWidget(clear_btn) + + # Download stats + stats_frame = QFrame() + stats_frame.setStyleSheet(""" + QFrame { + background: #282828; + border-radius: 8px; + border: 1px solid #404040; + } + """) + + stats_layout = QVBoxLayout(stats_frame) + stats_layout.setContentsMargins(20, 20, 20, 20) + stats_layout.setSpacing(15) + + # Stats title + stats_title = QLabel("Download Statistics") + stats_title.setFont(QFont("Arial", 14, QFont.Weight.Bold)) + stats_title.setStyleSheet("color: #ffffff;") + + # Stats items + stats_items = [ + ("Total Downloads", "247"), + ("Completed", "238"), + ("Failed", "4"), + ("In Progress", "2"), + ("Queued", "3") + ] + + stats_layout.addWidget(stats_title) + + for label, value in stats_items: + item_layout = QHBoxLayout() + + label_widget = QLabel(label) + label_widget.setFont(QFont("Arial", 11)) + label_widget.setStyleSheet("color: #b3b3b3;") + + value_widget = QLabel(value) + value_widget.setFont(QFont("Arial", 11, QFont.Weight.Bold)) + value_widget.setStyleSheet("color: #ffffff;") + + item_layout.addWidget(label_widget) + item_layout.addStretch() + item_layout.addWidget(value_widget) + + stats_layout.addLayout(item_layout) + + layout.addWidget(controls_frame) + layout.addWidget(stats_frame) + layout.addStretch() + + return section + + def create_missing_tracks_section(self): + section = QFrame() + section.setFixedHeight(200) + section.setStyleSheet(""" + QFrame { + background: #282828; + border-radius: 8px; + border: 1px solid #404040; + } + """) + + layout = QVBoxLayout(section) + layout.setContentsMargins(20, 20, 20, 20) + layout.setSpacing(15) + + # Header + header_layout = QHBoxLayout() + + title_label = QLabel("Missing Tracks") + title_label.setFont(QFont("Arial", 16, QFont.Weight.Bold)) + title_label.setStyleSheet("color: #ffffff;") + + download_all_btn = QPushButton("πŸ“₯ Download All") + download_all_btn.setFixedSize(120, 35) + download_all_btn.setStyleSheet(""" + QPushButton { + background: #1db954; + border: none; + border-radius: 17px; + color: #000000; + font-size: 11px; + font-weight: bold; + } + QPushButton:hover { + background: #1ed760; + } + """) + + header_layout.addWidget(title_label) + header_layout.addStretch() + header_layout.addWidget(download_all_btn) + + # Missing tracks list (simplified) + missing_text = QLabel("23 tracks are missing from your Plex library and available for download") + missing_text.setFont(QFont("Arial", 12)) + missing_text.setStyleSheet("color: #b3b3b3;") + + layout.addLayout(header_layout) + layout.addWidget(missing_text) + layout.addStretch() + + return section \ No newline at end of file diff --git a/ui/pages/settings.py b/ui/pages/settings.py new file mode 100644 index 00000000..22f40977 --- /dev/null +++ b/ui/pages/settings.py @@ -0,0 +1,427 @@ +from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QFrame, QPushButton, QLineEdit, QComboBox, + QCheckBox, QSpinBox, QTextEdit, QGroupBox, QFormLayout) +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QFont + +class SettingsGroup(QGroupBox): + def __init__(self, title: str, parent=None): + super().__init__(title, parent) + self.setStyleSheet(""" + QGroupBox { + background: #282828; + border: 1px solid #404040; + border-radius: 8px; + font-size: 14px; + font-weight: bold; + color: #ffffff; + padding-top: 15px; + margin-top: 10px; + } + QGroupBox::title { + subcontrol-origin: margin; + left: 10px; + padding: 0 5px 0 5px; + } + """) + +class SettingsPage(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.setup_ui() + + def setup_ui(self): + self.setStyleSheet(""" + SettingsPage { + background: #191414; + } + """) + + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(30, 30, 30, 30) + main_layout.setSpacing(25) + + # Header + header = self.create_header() + main_layout.addWidget(header) + + # Settings content + content_layout = QHBoxLayout() + content_layout.setSpacing(30) + + # Left column + left_column = self.create_left_column() + content_layout.addWidget(left_column) + + # Right column + right_column = self.create_right_column() + content_layout.addWidget(right_column) + + main_layout.addLayout(content_layout) + main_layout.addStretch() + + # Save button + save_btn = QPushButton("πŸ’Ύ Save Settings") + save_btn.setFixedHeight(45) + save_btn.setStyleSheet(""" + QPushButton { + background: #1db954; + border: none; + border-radius: 22px; + color: #000000; + font-size: 14px; + font-weight: bold; + } + QPushButton:hover { + background: #1ed760; + } + """) + + main_layout.addWidget(save_btn) + + def create_header(self): + header = QWidget() + layout = QVBoxLayout(header) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(5) + + # Title + title_label = QLabel("Settings") + title_label.setFont(QFont("Arial", 28, QFont.Weight.Bold)) + title_label.setStyleSheet("color: #ffffff;") + + # Subtitle + subtitle_label = QLabel("Configure your music sync and download preferences") + subtitle_label.setFont(QFont("Arial", 14)) + subtitle_label.setStyleSheet("color: #b3b3b3;") + + layout.addWidget(title_label) + layout.addWidget(subtitle_label) + + return header + + def create_left_column(self): + column = QWidget() + layout = QVBoxLayout(column) + layout.setSpacing(20) + + # API Configuration + api_group = SettingsGroup("API Configuration") + api_layout = QVBoxLayout(api_group) + api_layout.setContentsMargins(20, 25, 20, 20) + api_layout.setSpacing(15) + + # Spotify settings + spotify_frame = QFrame() + spotify_layout = QFormLayout(spotify_frame) + spotify_layout.setSpacing(10) + + spotify_title = QLabel("Spotify") + spotify_title.setFont(QFont("Arial", 12, QFont.Weight.Bold)) + spotify_title.setStyleSheet("color: #1db954;") + + client_id_input = QLineEdit("512b25bd9e0d4ecd82140f6d1ce0c8e6") + client_id_input.setStyleSheet(self.get_input_style()) + + client_secret_input = QLineEdit("c3844dcbedbc4e09a6242a14b2e89e89") + client_secret_input.setEchoMode(QLineEdit.EchoMode.Password) + client_secret_input.setStyleSheet(self.get_input_style()) + + spotify_layout.addRow(spotify_title) + spotify_layout.addRow("Client ID:", client_id_input) + spotify_layout.addRow("Client Secret:", client_secret_input) + + # Plex settings + plex_frame = QFrame() + plex_layout = QFormLayout(plex_frame) + plex_layout.setSpacing(10) + + plex_title = QLabel("Plex") + plex_title.setFont(QFont("Arial", 12, QFont.Weight.Bold)) + plex_title.setStyleSheet("color: #e5a00d;") + + plex_url_input = QLineEdit("http://192.168.86.36:32400") + plex_url_input.setStyleSheet(self.get_input_style()) + + plex_token_input = QLineEdit("a9hTgvasV1aJMLSdoBkr") + plex_token_input.setEchoMode(QLineEdit.EchoMode.Password) + plex_token_input.setStyleSheet(self.get_input_style()) + + plex_layout.addRow(plex_title) + plex_layout.addRow("Server URL:", plex_url_input) + plex_layout.addRow("Token:", plex_token_input) + + # Soulseek settings + soulseek_frame = QFrame() + soulseek_layout = QFormLayout(soulseek_frame) + soulseek_layout.setSpacing(10) + + soulseek_title = QLabel("Soulseek") + soulseek_title.setFont(QFont("Arial", 12, QFont.Weight.Bold)) + soulseek_title.setStyleSheet("color: #ff6b35;") + + slskd_url_input = QLineEdit("http://localhost:5030") + slskd_url_input.setStyleSheet(self.get_input_style()) + + api_key_input = QLineEdit() + api_key_input.setPlaceholderText("Enter your slskd API key") + api_key_input.setEchoMode(QLineEdit.EchoMode.Password) + api_key_input.setStyleSheet(self.get_input_style()) + + soulseek_layout.addRow(soulseek_title) + soulseek_layout.addRow("slskd URL:", slskd_url_input) + soulseek_layout.addRow("API Key:", api_key_input) + + api_layout.addWidget(spotify_frame) + api_layout.addWidget(plex_frame) + api_layout.addWidget(soulseek_frame) + + # Test connections + test_layout = QHBoxLayout() + test_layout.setSpacing(10) + + test_spotify = QPushButton("Test Spotify") + test_spotify.setFixedHeight(30) + test_spotify.setStyleSheet(self.get_test_button_style()) + + test_plex = QPushButton("Test Plex") + test_plex.setFixedHeight(30) + test_plex.setStyleSheet(self.get_test_button_style()) + + test_soulseek = QPushButton("Test Soulseek") + test_soulseek.setFixedHeight(30) + test_soulseek.setStyleSheet(self.get_test_button_style()) + + test_layout.addWidget(test_spotify) + test_layout.addWidget(test_plex) + test_layout.addWidget(test_soulseek) + + api_layout.addLayout(test_layout) + + layout.addWidget(api_group) + layout.addStretch() + + return column + + def create_right_column(self): + column = QWidget() + layout = QVBoxLayout(column) + layout.setSpacing(20) + + # Download Settings + download_group = SettingsGroup("Download Settings") + download_layout = QVBoxLayout(download_group) + download_layout.setContentsMargins(20, 25, 20, 20) + download_layout.setSpacing(15) + + # Quality preference + quality_layout = QHBoxLayout() + quality_label = QLabel("Preferred Quality:") + quality_label.setStyleSheet("color: #ffffff; font-size: 12px;") + + quality_combo = QComboBox() + quality_combo.addItems(["FLAC", "320 kbps MP3", "256 kbps MP3", "192 kbps MP3", "Any"]) + quality_combo.setCurrentText("FLAC") + quality_combo.setStyleSheet(self.get_combo_style()) + + quality_layout.addWidget(quality_label) + quality_layout.addWidget(quality_combo) + quality_layout.addStretch() + + # Max concurrent downloads + concurrent_layout = QHBoxLayout() + concurrent_label = QLabel("Max Concurrent Downloads:") + concurrent_label.setStyleSheet("color: #ffffff; font-size: 12px;") + + concurrent_spin = QSpinBox() + concurrent_spin.setRange(1, 10) + concurrent_spin.setValue(5) + concurrent_spin.setStyleSheet(self.get_spin_style()) + + concurrent_layout.addWidget(concurrent_label) + concurrent_layout.addWidget(concurrent_spin) + concurrent_layout.addStretch() + + # Download timeout + timeout_layout = QHBoxLayout() + timeout_label = QLabel("Download Timeout (seconds):") + timeout_label.setStyleSheet("color: #ffffff; font-size: 12px;") + + timeout_spin = QSpinBox() + timeout_spin.setRange(30, 600) + timeout_spin.setValue(300) + timeout_spin.setStyleSheet(self.get_spin_style()) + + timeout_layout.addWidget(timeout_label) + timeout_layout.addWidget(timeout_spin) + timeout_layout.addStretch() + + # Download path + path_layout = QHBoxLayout() + path_label = QLabel("Download Path:") + path_label.setStyleSheet("color: #ffffff; font-size: 12px;") + + path_input = QLineEdit("./downloads") + path_input.setStyleSheet(self.get_input_style()) + + browse_btn = QPushButton("Browse") + browse_btn.setFixedSize(70, 30) + browse_btn.setStyleSheet(self.get_test_button_style()) + + path_layout.addWidget(path_label) + path_layout.addWidget(path_input) + path_layout.addWidget(browse_btn) + + download_layout.addLayout(quality_layout) + download_layout.addLayout(concurrent_layout) + download_layout.addLayout(timeout_layout) + download_layout.addLayout(path_layout) + + # Sync Settings + sync_group = SettingsGroup("Sync Settings") + sync_layout = QVBoxLayout(sync_group) + sync_layout.setContentsMargins(20, 25, 20, 20) + sync_layout.setSpacing(15) + + # Auto-sync checkbox + auto_sync = QCheckBox("Auto-sync playlists every hour") + auto_sync.setChecked(True) + auto_sync.setStyleSheet(self.get_checkbox_style()) + + # Update metadata checkbox + update_metadata = QCheckBox("Update metadata from Spotify") + update_metadata.setChecked(True) + update_metadata.setStyleSheet(self.get_checkbox_style()) + + # Download missing tracks checkbox + download_missing = QCheckBox("Automatically download missing tracks") + download_missing.setChecked(False) + download_missing.setStyleSheet(self.get_checkbox_style()) + + sync_layout.addWidget(auto_sync) + sync_layout.addWidget(update_metadata) + sync_layout.addWidget(download_missing) + + # Logging Settings + logging_group = SettingsGroup("Logging Settings") + logging_layout = QVBoxLayout(logging_group) + logging_layout.setContentsMargins(20, 25, 20, 20) + logging_layout.setSpacing(15) + + # Log level + log_level_layout = QHBoxLayout() + log_level_label = QLabel("Log Level:") + log_level_label.setStyleSheet("color: #ffffff; font-size: 12px;") + + log_level_combo = QComboBox() + log_level_combo.addItems(["DEBUG", "INFO", "WARNING", "ERROR"]) + log_level_combo.setCurrentText("DEBUG") + log_level_combo.setStyleSheet(self.get_combo_style()) + + log_level_layout.addWidget(log_level_label) + log_level_layout.addWidget(log_level_combo) + log_level_layout.addStretch() + + # Log file path + log_path_layout = QHBoxLayout() + log_path_label = QLabel("Log File Path:") + log_path_label.setStyleSheet("color: #ffffff; font-size: 12px;") + + log_path_input = QLineEdit("logs/app.log") + log_path_input.setStyleSheet(self.get_input_style()) + + log_path_layout.addWidget(log_path_label) + log_path_layout.addWidget(log_path_input) + + logging_layout.addLayout(log_level_layout) + logging_layout.addLayout(log_path_layout) + + layout.addWidget(download_group) + layout.addWidget(sync_group) + layout.addWidget(logging_group) + + return column + + def get_input_style(self): + return """ + QLineEdit { + background: #404040; + border: 1px solid #606060; + border-radius: 4px; + padding: 8px; + color: #ffffff; + font-size: 11px; + } + QLineEdit:focus { + border: 1px solid #1db954; + } + """ + + def get_combo_style(self): + return """ + QComboBox { + background: #404040; + border: 1px solid #606060; + border-radius: 4px; + padding: 8px; + color: #ffffff; + font-size: 11px; + min-width: 100px; + } + QComboBox:focus { + border: 1px solid #1db954; + } + QComboBox::drop-down { + border: none; + } + """ + + def get_spin_style(self): + return """ + QSpinBox { + background: #404040; + border: 1px solid #606060; + border-radius: 4px; + padding: 8px; + color: #ffffff; + font-size: 11px; + min-width: 80px; + } + QSpinBox:focus { + border: 1px solid #1db954; + } + """ + + def get_checkbox_style(self): + return """ + QCheckBox { + color: #ffffff; + font-size: 12px; + } + QCheckBox::indicator { + width: 16px; + height: 16px; + border-radius: 8px; + border: 2px solid #b3b3b3; + background: transparent; + } + QCheckBox::indicator:checked { + background: #1db954; + border: 2px solid #1db954; + } + """ + + def get_test_button_style(self): + return """ + QPushButton { + background: transparent; + border: 1px solid #1db954; + border-radius: 15px; + color: #1db954; + font-size: 10px; + font-weight: bold; + } + QPushButton:hover { + background: rgba(29, 185, 84, 0.1); + } + """ \ No newline at end of file diff --git a/ui/pages/sync.py b/ui/pages/sync.py new file mode 100644 index 00000000..26dec7de --- /dev/null +++ b/ui/pages/sync.py @@ -0,0 +1,464 @@ +from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QFrame, QPushButton, QListWidget, QListWidgetItem, + QProgressBar, QTextEdit, QCheckBox, QComboBox, + QScrollArea, QSizePolicy) +from PyQt6.QtCore import Qt, QThread, pyqtSignal +from PyQt6.QtGui import QFont + +class PlaylistItem(QFrame): + def __init__(self, name: str, track_count: int, sync_status: str, parent=None): + super().__init__(parent) + self.name = name + self.track_count = track_count + self.sync_status = sync_status + self.is_selected = False + self.setup_ui() + + def setup_ui(self): + self.setFixedHeight(80) + self.setStyleSheet(""" + PlaylistItem { + background: #282828; + border-radius: 8px; + border: 1px solid #404040; + } + PlaylistItem:hover { + background: #333333; + border: 1px solid #1db954; + } + """) + + layout = QHBoxLayout(self) + layout.setContentsMargins(20, 15, 20, 15) + layout.setSpacing(15) + + # Checkbox + self.checkbox = QCheckBox() + self.checkbox.setStyleSheet(""" + QCheckBox::indicator { + width: 18px; + height: 18px; + border-radius: 9px; + border: 2px solid #b3b3b3; + background: transparent; + } + QCheckBox::indicator:checked { + background: #1db954; + border: 2px solid #1db954; + } + QCheckBox::indicator:checked:hover { + background: #1ed760; + } + """) + + # Content layout + content_layout = QVBoxLayout() + content_layout.setSpacing(5) + + # Playlist name + name_label = QLabel(self.name) + name_label.setFont(QFont("Arial", 12, QFont.Weight.Bold)) + name_label.setStyleSheet("color: #ffffff;") + + # Track count and status + info_layout = QHBoxLayout() + info_layout.setSpacing(20) + + track_label = QLabel(f"{self.track_count} tracks") + track_label.setFont(QFont("Arial", 10)) + track_label.setStyleSheet("color: #b3b3b3;") + + status_label = QLabel(self.sync_status) + status_label.setFont(QFont("Arial", 10)) + if self.sync_status == "Synced": + status_label.setStyleSheet("color: #1db954;") + elif self.sync_status == "Needs Sync": + status_label.setStyleSheet("color: #ffa500;") + else: + status_label.setStyleSheet("color: #e22134;") + + info_layout.addWidget(track_label) + info_layout.addWidget(status_label) + info_layout.addStretch() + + content_layout.addWidget(name_label) + content_layout.addLayout(info_layout) + + # Action button + action_btn = QPushButton("View Details") + action_btn.setFixedSize(100, 30) + action_btn.setStyleSheet(""" + QPushButton { + background: transparent; + border: 1px solid #1db954; + border-radius: 15px; + color: #1db954; + font-size: 10px; + font-weight: bold; + } + QPushButton:hover { + background: #1db954; + color: #000000; + } + """) + + layout.addWidget(self.checkbox) + layout.addLayout(content_layout) + layout.addStretch() + layout.addWidget(action_btn) + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + self.checkbox.setChecked(not self.checkbox.isChecked()) + super().mousePressEvent(event) + +class SyncOptionsPanel(QFrame): + def __init__(self, parent=None): + super().__init__(parent) + self.setup_ui() + + def setup_ui(self): + self.setStyleSheet(""" + SyncOptionsPanel { + background: #282828; + border-radius: 8px; + border: 1px solid #404040; + } + """) + + layout = QVBoxLayout(self) + layout.setContentsMargins(20, 20, 20, 20) + layout.setSpacing(15) + + # Title + title_label = QLabel("Sync Options") + title_label.setFont(QFont("Arial", 14, QFont.Weight.Bold)) + title_label.setStyleSheet("color: #ffffff;") + + # Download missing tracks option + self.download_missing = QCheckBox("Download missing tracks from Soulseek") + self.download_missing.setChecked(True) + self.download_missing.setStyleSheet(""" + QCheckBox { + color: #ffffff; + font-size: 11px; + } + QCheckBox::indicator { + width: 16px; + height: 16px; + border-radius: 8px; + border: 2px solid #b3b3b3; + background: transparent; + } + QCheckBox::indicator:checked { + background: #1db954; + border: 2px solid #1db954; + } + """) + + # Quality selection + quality_layout = QHBoxLayout() + quality_label = QLabel("Preferred Quality:") + quality_label.setStyleSheet("color: #b3b3b3; font-size: 11px;") + + self.quality_combo = QComboBox() + self.quality_combo.addItems(["FLAC", "320 kbps MP3", "256 kbps MP3", "Any"]) + self.quality_combo.setCurrentText("FLAC") + self.quality_combo.setStyleSheet(""" + QComboBox { + background: #404040; + border: 1px solid #606060; + border-radius: 4px; + padding: 5px; + color: #ffffff; + font-size: 11px; + } + QComboBox::drop-down { + border: none; + } + QComboBox::down-arrow { + image: none; + border: none; + } + """) + + quality_layout.addWidget(quality_label) + quality_layout.addWidget(self.quality_combo) + quality_layout.addStretch() + + # Update metadata option + self.update_metadata = QCheckBox("Update metadata from Spotify") + self.update_metadata.setChecked(True) + self.update_metadata.setStyleSheet(self.download_missing.styleSheet()) + + layout.addWidget(title_label) + layout.addWidget(self.download_missing) + layout.addLayout(quality_layout) + layout.addWidget(self.update_metadata) + +class SyncPage(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.setup_ui() + + def setup_ui(self): + self.setStyleSheet(""" + SyncPage { + background: #191414; + } + """) + + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(30, 30, 30, 30) + main_layout.setSpacing(25) + + # Header + header = self.create_header() + main_layout.addWidget(header) + + # Content area + content_layout = QHBoxLayout() + content_layout.setSpacing(25) + + # Left side - Playlist list + playlist_section = self.create_playlist_section() + content_layout.addWidget(playlist_section, 2) + + # Right side - Options and actions + options_section = self.create_options_section() + content_layout.addWidget(options_section, 1) + + main_layout.addLayout(content_layout) + + # Progress section + progress_section = self.create_progress_section() + main_layout.addWidget(progress_section) + + def create_header(self): + header = QWidget() + layout = QVBoxLayout(header) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(5) + + # Title + title_label = QLabel("Playlist Sync") + title_label.setFont(QFont("Arial", 28, QFont.Weight.Bold)) + title_label.setStyleSheet("color: #ffffff;") + + # Subtitle + subtitle_label = QLabel("Synchronize your Spotify playlists with Plex") + subtitle_label.setFont(QFont("Arial", 14)) + subtitle_label.setStyleSheet("color: #b3b3b3;") + + layout.addWidget(title_label) + layout.addWidget(subtitle_label) + + return header + + def create_playlist_section(self): + section = QWidget() + layout = QVBoxLayout(section) + layout.setSpacing(15) + + # Section header + header_layout = QHBoxLayout() + + section_title = QLabel("Spotify Playlists") + section_title.setFont(QFont("Arial", 16, QFont.Weight.Bold)) + section_title.setStyleSheet("color: #ffffff;") + + refresh_btn = QPushButton("πŸ”„ Refresh") + refresh_btn.setFixedSize(100, 35) + refresh_btn.setStyleSheet(""" + QPushButton { + background: #1db954; + border: none; + border-radius: 17px; + color: #000000; + font-size: 11px; + font-weight: bold; + } + QPushButton:hover { + background: #1ed760; + } + QPushButton:pressed { + background: #1aa34a; + } + """) + + header_layout.addWidget(section_title) + header_layout.addStretch() + header_layout.addWidget(refresh_btn) + + # Playlist container + playlist_container = QScrollArea() + playlist_container.setWidgetResizable(True) + playlist_container.setStyleSheet(""" + QScrollArea { + border: none; + background: transparent; + } + QScrollBar:vertical { + background: #282828; + width: 8px; + border-radius: 4px; + } + QScrollBar::handle:vertical { + background: #1db954; + border-radius: 4px; + } + """) + + playlist_widget = QWidget() + playlist_layout = QVBoxLayout(playlist_widget) + playlist_layout.setSpacing(10) + + # Sample playlists + playlists = [ + ("Liked Songs", 247, "Synced"), + ("Discover Weekly", 30, "Needs Sync"), + ("Chill Vibes", 89, "Synced"), + ("Workout Mix", 156, "Needs Sync"), + ("Road Trip", 67, "Never Synced"), + ("Focus Music", 45, "Synced") + ] + + for name, count, status in playlists: + item = PlaylistItem(name, count, status) + playlist_layout.addWidget(item) + + playlist_layout.addStretch() + playlist_container.setWidget(playlist_widget) + + layout.addLayout(header_layout) + layout.addWidget(playlist_container) + + return section + + def create_options_section(self): + section = QWidget() + layout = QVBoxLayout(section) + layout.setSpacing(20) + + # Sync options + options_panel = SyncOptionsPanel() + layout.addWidget(options_panel) + + # Action buttons + actions_frame = QFrame() + actions_frame.setStyleSheet(""" + QFrame { + background: #282828; + border-radius: 8px; + border: 1px solid #404040; + } + """) + + actions_layout = QVBoxLayout(actions_frame) + actions_layout.setContentsMargins(20, 20, 20, 20) + actions_layout.setSpacing(15) + + # Sync button + sync_btn = QPushButton("Start Sync") + sync_btn.setFixedHeight(45) + sync_btn.setStyleSheet(""" + QPushButton { + background: #1db954; + border: none; + border-radius: 22px; + color: #000000; + font-size: 14px; + font-weight: bold; + } + QPushButton:hover { + background: #1ed760; + } + QPushButton:pressed { + background: #1aa34a; + } + """) + + # Preview button + preview_btn = QPushButton("Preview Changes") + preview_btn.setFixedHeight(35) + preview_btn.setStyleSheet(""" + QPushButton { + background: transparent; + border: 1px solid #1db954; + border-radius: 17px; + color: #1db954; + font-size: 12px; + font-weight: bold; + } + QPushButton:hover { + background: rgba(29, 185, 84, 0.1); + } + """) + + actions_layout.addWidget(sync_btn) + actions_layout.addWidget(preview_btn) + + layout.addWidget(actions_frame) + layout.addStretch() + + return section + + def create_progress_section(self): + section = QFrame() + section.setFixedHeight(150) + section.setStyleSheet(""" + QFrame { + background: #282828; + border-radius: 8px; + border: 1px solid #404040; + } + """) + + layout = QVBoxLayout(section) + layout.setContentsMargins(20, 15, 20, 15) + layout.setSpacing(10) + + # Progress header + progress_header = QLabel("Sync Progress") + progress_header.setFont(QFont("Arial", 14, QFont.Weight.Bold)) + progress_header.setStyleSheet("color: #ffffff;") + + # Progress bar + self.progress_bar = QProgressBar() + self.progress_bar.setFixedHeight(8) + self.progress_bar.setStyleSheet(""" + QProgressBar { + border: none; + border-radius: 4px; + background: #404040; + } + QProgressBar::chunk { + background: #1db954; + border-radius: 4px; + } + """) + + # Progress text + self.progress_text = QLabel("Ready to sync...") + self.progress_text.setFont(QFont("Arial", 11)) + self.progress_text.setStyleSheet("color: #b3b3b3;") + + # Log area + self.log_area = QTextEdit() + self.log_area.setMaximumHeight(60) + self.log_area.setStyleSheet(""" + QTextEdit { + background: #181818; + border: 1px solid #404040; + border-radius: 4px; + color: #ffffff; + font-size: 10px; + font-family: monospace; + } + """) + self.log_area.setPlainText("Waiting for sync to start...") + + layout.addWidget(progress_header) + layout.addWidget(self.progress_bar) + layout.addWidget(self.progress_text) + layout.addWidget(self.log_area) \ No newline at end of file diff --git a/ui/sidebar.py b/ui/sidebar.py new file mode 100644 index 00000000..c45bbff1 --- /dev/null +++ b/ui/sidebar.py @@ -0,0 +1,264 @@ +from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QPushButton, + QLabel, QFrame, QSizePolicy, QSpacerItem) +from PyQt6.QtCore import Qt, pyqtSignal, QPropertyAnimation, QEasingCurve, QRect +from PyQt6.QtGui import QFont, QPalette, QIcon, QPixmap, QPainter + +class SidebarButton(QPushButton): + def __init__(self, text: str, icon_text: str = "", parent=None): + super().__init__(parent) + self.text = text + self.icon_text = icon_text + self.is_active = False + self.setup_ui() + + def setup_ui(self): + self.setFixedHeight(50) + self.setFixedWidth(200) + self.setCursor(Qt.CursorShape.PointingHandCursor) + + layout = QHBoxLayout(self) + layout.setContentsMargins(20, 0, 20, 0) + layout.setSpacing(15) + + # Icon label + self.icon_label = QLabel(self.icon_text) + self.icon_label.setFixedSize(24, 24) + self.icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.icon_label.setStyleSheet(""" + QLabel { + color: #b3b3b3; + font-size: 16px; + font-weight: bold; + border-radius: 12px; + background: rgba(255, 255, 255, 0.1); + } + """) + + # Text label + self.text_label = QLabel(self.text) + self.text_label.setFont(QFont("Arial", 11, QFont.Weight.Medium)) + + layout.addWidget(self.icon_label) + layout.addWidget(self.text_label) + layout.addStretch() + + self.update_style() + + def set_active(self, active: bool): + self.is_active = active + self.update_style() + + def update_style(self): + if self.is_active: + self.setStyleSheet(""" + SidebarButton { + background: rgba(29, 185, 84, 0.2); + border-left: 3px solid #1db954; + border-radius: 0px; + text-align: left; + padding: 0px; + } + SidebarButton:hover { + background: rgba(29, 185, 84, 0.3); + } + """) + self.text_label.setStyleSheet("color: #1db954; font-weight: bold;") + self.icon_label.setStyleSheet(""" + QLabel { + color: #1db954; + font-size: 16px; + font-weight: bold; + border-radius: 12px; + background: rgba(29, 185, 84, 0.2); + } + """) + else: + self.setStyleSheet(""" + SidebarButton { + background: transparent; + border: none; + border-radius: 0px; + text-align: left; + padding: 0px; + } + SidebarButton:hover { + background: rgba(255, 255, 255, 0.1); + } + """) + self.text_label.setStyleSheet("color: #b3b3b3;") + self.icon_label.setStyleSheet(""" + QLabel { + color: #b3b3b3; + font-size: 16px; + font-weight: bold; + border-radius: 12px; + background: rgba(255, 255, 255, 0.1); + } + """) + +class StatusIndicator(QWidget): + def __init__(self, service_name: str, parent=None): + super().__init__(parent) + self.service_name = service_name + self.is_connected = False + self.setup_ui() + + def setup_ui(self): + layout = QHBoxLayout(self) + layout.setContentsMargins(15, 5, 15, 5) + layout.setSpacing(10) + + # Status dot + self.status_dot = QLabel("●") + self.status_dot.setFixedSize(12, 12) + self.status_dot.setAlignment(Qt.AlignmentFlag.AlignCenter) + + # Service name + self.service_label = QLabel(self.service_name) + self.service_label.setFont(QFont("Arial", 9)) + + layout.addWidget(self.status_dot) + layout.addWidget(self.service_label) + layout.addStretch() + + self.update_status(False) + + def update_status(self, connected: bool): + self.is_connected = connected + if connected: + self.status_dot.setStyleSheet("color: #1db954;") + self.service_label.setStyleSheet("color: #ffffff;") + else: + self.status_dot.setStyleSheet("color: #e22134;") + self.service_label.setStyleSheet("color: #b3b3b3;") + +class ModernSidebar(QWidget): + page_changed = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self.current_page = "dashboard" + self.buttons = {} + self.setup_ui() + + def setup_ui(self): + self.setFixedWidth(220) + self.setStyleSheet(""" + ModernSidebar { + background: #121212; + border-right: 1px solid #282828; + } + """) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # Header + header = self.create_header() + layout.addWidget(header) + + # Navigation buttons + nav_section = self.create_navigation() + layout.addWidget(nav_section) + + # Spacer + layout.addItem(QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)) + + # Status section + status_section = self.create_status_section() + layout.addWidget(status_section) + + def create_header(self): + header = QWidget() + header.setFixedHeight(80) + header.setStyleSheet("background: #121212; border-bottom: 1px solid #282828;") + + layout = QVBoxLayout(header) + layout.setContentsMargins(20, 20, 20, 20) + + # App name + app_name = QLabel("NewMusic") + app_name.setFont(QFont("Arial", 18, QFont.Weight.Bold)) + app_name.setStyleSheet("color: #ffffff;") + + # Subtitle + subtitle = QLabel("Music Sync & Manager") + subtitle.setFont(QFont("Arial", 9)) + subtitle.setStyleSheet("color: #b3b3b3;") + + layout.addWidget(app_name) + layout.addWidget(subtitle) + + return header + + def create_navigation(self): + nav_widget = QWidget() + layout = QVBoxLayout(nav_widget) + layout.setContentsMargins(0, 20, 0, 20) + layout.setSpacing(5) + + # Navigation buttons + nav_items = [ + ("dashboard", "Dashboard", "πŸ“Š"), + ("sync", "Playlist Sync", "πŸ”„"), + ("downloads", "Downloads", "πŸ“₯"), + ("artists", "Artists", "🎡"), + ("settings", "Settings", "βš™οΈ") + ] + + for page_id, title, icon in nav_items: + button = SidebarButton(title, icon) + button.clicked.connect(lambda checked, pid=page_id: self.change_page(pid)) + self.buttons[page_id] = button + layout.addWidget(button) + + # Set dashboard as active by default + self.buttons["dashboard"].set_active(True) + + return nav_widget + + def create_status_section(self): + status_widget = QWidget() + status_widget.setFixedHeight(120) + status_widget.setStyleSheet("background: #181818; border-top: 1px solid #282828;") + + layout = QVBoxLayout(status_widget) + layout.setContentsMargins(0, 15, 0, 15) + layout.setSpacing(8) + + # Status title + status_title = QLabel("Connection Status") + status_title.setFont(QFont("Arial", 10, QFont.Weight.Bold)) + status_title.setStyleSheet("color: #ffffff; padding: 0 15px;") + layout.addWidget(status_title) + + # Status indicators + self.spotify_status = StatusIndicator("Spotify") + self.plex_status = StatusIndicator("Plex") + self.soulseek_status = StatusIndicator("Soulseek") + + layout.addWidget(self.spotify_status) + layout.addWidget(self.plex_status) + layout.addWidget(self.soulseek_status) + + return status_widget + + def change_page(self, page_id: str): + if page_id != self.current_page: + # Update button states + for btn_id, button in self.buttons.items(): + button.set_active(btn_id == page_id) + + self.current_page = page_id + self.page_changed.emit(page_id) + + def update_service_status(self, service: str, connected: bool): + status_map = { + "spotify": self.spotify_status, + "plex": self.plex_status, + "soulseek": self.soulseek_status + } + + if service in status_map: + status_map[service].update_status(connected) \ No newline at end of file diff --git a/utils/__pycache__/logging_config.cpython-312.pyc b/utils/__pycache__/logging_config.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fedb41438f3bf03cc7e4c78af8dc77b8122e04d7 GIT binary patch literal 3276 zcmb6bOKcm*b(Y*+E=5w*hZ6O%6xuOOQLa>fl%`hFAhsgevTQMsTm+$wY+24yT53ru zyURKvs75XXSUwcBQ50g36jjkf9oRr+pg?>qdMH}-P!b|QVqya>s!MM&WS~Y)eY49Y zB|&b`Im~-+=FOWo?>m3@c$yH5GMk?Nvjd@jQNkKP8)Nq`0M-#km{CyFgiMqH+NRi2 z_NbkqYj%ZAu~F8*S%pjSQJz6IbP-{06=7aN$>y58J>Y@eB%%Ut#tzAe1&NoO-LQzA zaKg8TwT32m+_cI?UDyTOui{~`dzFp4ZAfxs&jY4*hi8cid;d*|=`cgs_hb4zKmo^e zNtaU+#UqQloJq%&cWvV$vLY^C5TvFTNc+P(?0ESI{;5|qaVS2fO zs=luEse4oFk$aJiiOTVj((#c^{MEvvg+GN#-@Cl!n_6>LJA2j_?k#MDN}Ypiu4nzB z!u9)|mGHSz_*`*d^zmDdhl;(I*7&l}^~CGDbMwwm3yCdnuqXs~;R*z?B#0uliYSsM zQA}lUR-w3C6hF?!410;9>IGIqYvQRHAucJ3D25re3$`?xOz*1H)PeD!MmPA9^?+)yS~Y0drq@#;DKz#i4A(;s!q}d4S?dj$ zw^_l-KrkoWjU5Y9dk$YuQnMczmi7$VqgTrTCLr}TYwfUlX=${~=Gkx4S@&C#XkB~A z){~lpv()7WMxL{z8}IEsunlCM-NV7CxAi`Yr;*6Ze4bDCTk8#&Jleo53@R=6khk>O zXPILbPXp$8>-J?w-tn!kZ+QQBLxzQ6$3BY)An!1X2-c0Sw1{ILN63=jfLO>QD1`fB ziT06cB0#xG(Q=a;2@b@xxY4z=z)&DKK=V3mqV?)mqf?Tm#pYl)*BbnAD3}@w;z01y zXz=Q2a5iis_C!i24%!3(xdsc4#h5Ckb&UwpC$grCnT6c+|8ZR+n~qN#+KdJV6ge%W zGhuA#*ia;J#VnKoIW6mQOp$XE4&0RW`GBGPXdvt(4k+c&Emh*ERaL@e7onJ|PB0a5 z!Y&m8#a)29eT7zhBFx9qSdmnXaB)S7sl+p@t5Pg=$y_FOZAl}%rsGUjHwqVV!`XUU zCQUJnYrHBA2Ev=BAY5tz%g`exphQ{1oQ-p=oK9rIPW3f7qXrE1XhAb;8&A~-HNDUj zvad{0q!FxCs9e;-=NG&f08Hbv})nfHoy#7{!=);3e5bv1TjvUEnb4 z^$=9c74)RV|Ak|tUZM(Ct(s{DfdGg`dA1;4+d8_lyl}MFs zudqi-?2*EUWj3_kHBfb5sJ6WRg0pw?MYdx{K%B3_21;z8c=Sw}Jqy=V*p3q0aW_+D zPyEy2ebVkP2)}%1O{fYjjoUWx=CvZdbadO_U-1u?{Db$uzvUlUb5{kQsZc>K3j^ET zflBvase7>6@}sIR1lRU7tu@1~&W?(3v?Lte7%mH^;UN{FyCifMzEc)L&)}s~x2B41 zXExv13>8~O%j{cKSL@1EqdO6nCRDuMX~3HFLqvmO_Y6#XyHK*;gquegzVAL? z?ECJP>r|0FRZ}|^lhYua2FP@{*}Q~)TWMD7^NRdqBd}Ha7{cpIi||`fY3Eemq!VrA zkR_0GyqR1^92qJQ7ZqJo>Puwya9RPs5#Bbx67Q%}KvI)w3QRk8hGD)&&0nLYr>N^G z>U)ZMU$9>0#NC?}|3Jw<@B#tx{Ma$3t logging.Logger: + log_level = getattr(logging, level.upper(), logging.INFO) + + logger = logging.getLogger("newmusic") + logger.setLevel(log_level) + + if logger.handlers: + logger.handlers.clear() + + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setLevel(log_level) + + console_formatter = ColoredFormatter( + fmt='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) + console_handler.setFormatter(console_formatter) + logger.addHandler(console_handler) + + if log_file: + log_path = Path(log_file) + log_path.parent.mkdir(parents=True, exist_ok=True) + + file_handler = logging.FileHandler(log_path) + file_handler.setLevel(log_level) + + file_formatter = logging.Formatter( + fmt='%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) + file_handler.setFormatter(file_formatter) + logger.addHandler(file_handler) + + logger.info(f"Logging initialized with level: {level}") + return logger + +def get_logger(name: str) -> logging.Logger: + return logging.getLogger(f"newmusic.{name}") + +main_logger = get_logger("main") \ No newline at end of file