From 36c7e5922ed09169715985d29f399b48b3257821 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Wed, 9 Jul 2025 13:01:11 -0700 Subject: [PATCH] fix --- .spotify_cache | 2 +- config/config.json | 2 +- logs/app.log | 59 ++++ main.py | 2 +- ui/__pycache__/sidebar.cpython-312.pyc | Bin 15440 -> 15490 bytes ui/pages/__pycache__/artists.cpython-312.pyc | Bin 14514 -> 17193 bytes ui/pages/__pycache__/settings.cpython-312.pyc | Bin 17935 -> 28640 bytes ui/pages/__pycache__/sync.cpython-312.pyc | Bin 18520 -> 26527 bytes ui/pages/artists.py | 79 +++++- ui/pages/settings.py | 257 +++++++++++++++--- ui/pages/sync.py | 177 ++++++++++-- ui/sidebar.py | 4 +- 12 files changed, 508 insertions(+), 74 deletions(-) diff --git a/.spotify_cache b/.spotify_cache index 09e1b530..fda602d8 100644 --- a/.spotify_cache +++ b/.spotify_cache @@ -1 +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 +{"access_token": "BQA7NSoqYKl8-HgT1MaTChCe-DbSBPXUZG9_4AzQYIqZsO4MgnVjJyl35gsDKR36HejqrJK8DziDO-HIecF2nhX4OSro0ROIY71CCtjCboE_-BAHk7w-x1XtUyINP7ZNBARToEaketFUiguJAMXIH7NALut2AtQelLyuT7X_NZdINBNpUY6St3BoEAiOIJe5dkxy7UFdHgLZdBLDUZhXLs40zB73qONG4Ie3WjkCAH_RTa4owXn7wvSMLToXd3VH", "token_type": "Bearer", "expires_in": 3600, "scope": "user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email", "expires_at": 1752094286, "refresh_token": "AQDmfQkPCGObfJeTUIbW1hAAwhSqkuHRA3Qh2dqVYMRh0eCkFMQgPNJDDzF8y-BiaVbj80zePkK_XSfYH1aJutMtNbnsqRKWuxP31BTrMc7pdUdbE7Fma4oH8wpDUKdG3MM"} \ No newline at end of file diff --git a/config/config.json b/config/config.json index 4339e9e8..b4d13ba8 100644 --- a/config/config.json +++ b/config/config.json @@ -9,7 +9,7 @@ }, "soulseek": { "slskd_url": "http://localhost:5030", - "api_key": "", + "api_key": "123456789123456789", "download_path": "./downloads" }, "database": { diff --git a/logs/app.log b/logs/app.log index d1df26ad..101364c0 100644 --- a/logs/app.log +++ b/logs/app.log @@ -104,3 +104,62 @@ 2025-07-09 12:28:42 - newmusic.main - INFO - change_page:139 - Changed to page: downloads 2025-07-09 12:28:43 - newmusic.main - INFO - change_page:139 - Changed to page: artists 2025-07-09 12:28:44 - newmusic.main - INFO - change_page:139 - Changed to page: settings +2025-07-09 12:29:52 - newmusic.main - INFO - change_page:139 - Changed to page: downloads +2025-07-09 12:29:53 - newmusic.main - INFO - change_page:139 - Changed to page: artists +2025-07-09 12:29:54 - newmusic.main - INFO - change_page:139 - Changed to page: dashboard +2025-07-09 12:30:23 - newmusic.main - INFO - change_page:139 - Changed to page: settings +2025-07-09 12:30:24 - newmusic.main - INFO - change_page:139 - Changed to page: artists +2025-07-09 12:34:29 - newmusic.main - INFO - change_page:139 - Changed to page: settings +2025-07-09 12:34:56 - newmusic.main - INFO - change_page:139 - Changed to page: artists +2025-07-09 12:34:57 - newmusic.main - INFO - change_page:139 - Changed to page: downloads +2025-07-09 12:36:03 - newmusic.main - INFO - change_page:139 - Changed to page: sync +2025-07-09 12:37:31 - newmusic.main - INFO - closeEvent:152 - Closing application... +2025-07-09 12:40:45 - newmusic - INFO - setup_logging:57 - Logging initialized with level: DEBUG +2025-07-09 12:40:45 - newmusic.main - INFO - main:173 - Starting NewMusic application +2025-07-09 12:40:45 - newmusic.spotify_client - INFO - _setup_client:84 - Successfully authenticated with Spotify as broquethomas +2025-07-09 12:40:45 - newmusic.plex_client - INFO - _find_music_library:98 - Found music library: Music +2025-07-09 12:40:45 - newmusic.plex_client - INFO - _setup_client:84 - Successfully connected to Plex server: PLEX-MACHINE +2025-07-09 12:40:45 - newmusic.soulseek_client - INFO - _setup_client:88 - Soulseek client configured with slskd at http://localhost:5030 +2025-07-09 12:40:46 - newmusic.main - INFO - change_page:139 - Changed to page: dashboard +2025-07-09 12:40:48 - newmusic.main - INFO - change_page:139 - Changed to page: sync +2025-07-09 12:40:59 - newmusic.main - INFO - change_page:139 - Changed to page: downloads +2025-07-09 12:41:00 - newmusic.main - INFO - change_page:139 - Changed to page: artists +2025-07-09 12:41:41 - newmusic.main - INFO - change_page:139 - Changed to page: settings +2025-07-09 12:42:45 - newmusic.main - INFO - change_page:139 - Changed to page: dashboard +2025-07-09 12:42:47 - newmusic.main - INFO - change_page:139 - Changed to page: sync +2025-07-09 12:43:27 - newmusic.main - INFO - change_page:139 - Changed to page: settings +2025-07-09 12:47:49 - newmusic.main - INFO - closeEvent:152 - Closing application... +2025-07-09 12:51:25 - newmusic - INFO - setup_logging:57 - Logging initialized with level: DEBUG +2025-07-09 12:51:25 - newmusic.main - INFO - main:173 - Starting NewMusic application +2025-07-09 12:51:26 - newmusic.spotify_client - INFO - _setup_client:84 - Successfully authenticated with Spotify as broquethomas +2025-07-09 12:51:26 - newmusic.plex_client - INFO - _find_music_library:98 - Found music library: Music +2025-07-09 12:51:26 - newmusic.plex_client - INFO - _setup_client:84 - Successfully connected to Plex server: PLEX-MACHINE +2025-07-09 12:51:26 - newmusic.soulseek_client - INFO - _setup_client:88 - Soulseek client configured with slskd at http://localhost:5030 +2025-07-09 12:51:26 - newmusic.spotify_client - INFO - get_user_playlists:106 - Fetching tracks for playlist: Aether +2025-07-09 12:51:26 - newmusic.spotify_client - INFO - get_user_playlists:106 - Fetching tracks for playlist: Favorite Artists +2025-07-09 12:51:29 - newmusic.spotify_client - INFO - get_user_playlists:106 - Fetching tracks for playlist: Brittnea +2025-07-09 12:51:30 - newmusic.spotify_client - INFO - get_user_playlists:106 - Fetching tracks for playlist: Baleigh +2025-07-09 12:51:31 - newmusic.spotify_client - INFO - get_user_playlists:106 - Fetching tracks for playlist: Extra Music +2025-07-09 12:51:32 - newmusic.spotify_client - INFO - get_user_playlists:106 - Fetching tracks for playlist: Maggi Main +2025-07-09 12:51:38 - newmusic.spotify_client - INFO - get_user_playlists:106 - Fetching tracks for playlist: Broque Main +2025-07-09 12:51:45 - newmusic.spotify_client - INFO - get_user_playlists:113 - Retrieved 7 playlists +2025-07-09 12:58:18 - newmusic - INFO - setup_logging:57 - Logging initialized with level: DEBUG +2025-07-09 12:58:18 - newmusic.main - INFO - main:173 - Starting NewMusic application +2025-07-09 12:58:18 - newmusic.spotify_client - INFO - _setup_client:84 - Successfully authenticated with Spotify as broquethomas +2025-07-09 12:58:18 - newmusic.plex_client - INFO - _find_music_library:98 - Found music library: Music +2025-07-09 12:58:18 - newmusic.plex_client - INFO - _setup_client:84 - Successfully connected to Plex server: PLEX-MACHINE +2025-07-09 12:58:18 - newmusic.soulseek_client - INFO - _setup_client:88 - Soulseek client configured with slskd at http://localhost:5030 +2025-07-09 12:58:19 - newmusic.main - INFO - change_page:139 - Changed to page: dashboard +2025-07-09 12:58:19 - newmusic.spotify_client - INFO - get_user_playlists:106 - Fetching tracks for playlist: Aether +2025-07-09 12:58:19 - newmusic.spotify_client - INFO - get_user_playlists:106 - Fetching tracks for playlist: Favorite Artists +2025-07-09 12:58:22 - newmusic.spotify_client - INFO - get_user_playlists:106 - Fetching tracks for playlist: Brittnea +2025-07-09 12:58:23 - newmusic.spotify_client - INFO - get_user_playlists:106 - Fetching tracks for playlist: Baleigh +2025-07-09 12:58:24 - newmusic.main - INFO - change_page:139 - Changed to page: sync +2025-07-09 12:58:24 - newmusic.spotify_client - INFO - get_user_playlists:106 - Fetching tracks for playlist: Extra Music +2025-07-09 12:58:25 - newmusic.spotify_client - INFO - get_user_playlists:106 - Fetching tracks for playlist: Maggi Main +2025-07-09 12:58:30 - newmusic.spotify_client - INFO - get_user_playlists:106 - Fetching tracks for playlist: Broque Main +2025-07-09 12:58:37 - newmusic.spotify_client - INFO - get_user_playlists:113 - Retrieved 7 playlists +2025-07-09 12:58:50 - newmusic.main - INFO - change_page:139 - Changed to page: settings +2025-07-09 12:59:15 - newmusic.plex_client - INFO - _find_music_library:98 - Found music library: Music +2025-07-09 12:59:15 - newmusic.plex_client - INFO - _setup_client:84 - Successfully connected to Plex server: PLEX-MACHINE +2025-07-09 12:59:17 - newmusic.spotify_client - INFO - _setup_client:84 - Successfully authenticated with Spotify as broquethomas diff --git a/main.py b/main.py index c11c1a03..6edea7a3 100644 --- a/main.py +++ b/main.py @@ -99,7 +99,7 @@ class MainWindow(QMainWindow): # Create and add pages self.dashboard_page = DashboardPage() - self.sync_page = SyncPage() + self.sync_page = SyncPage(self.spotify_client, self.plex_client) self.downloads_page = DownloadsPage() self.artists_page = ArtistsPage() self.settings_page = SettingsPage() diff --git a/ui/__pycache__/sidebar.cpython-312.pyc b/ui/__pycache__/sidebar.cpython-312.pyc index b90eedaba3af8855e26a914a6e69d7689012c5d5..9ffac9b1a14d9d924701fdeb477ae2d0e5df8ec3 100644 GIT binary patch delta 97 zcmcam(NxKMnwOW00SJtb=4HIv$lJ!pXgzrfpVH)i{E|$GT$BIET25w>m6@C%$+KCK wKa)v7BRM}OzsO2KIms9Vtw9PmPZFHK$dZ1ACu6djuq{i-6`s(|>B28H0N3jhEB delta 80 zcmZpwyimb=nwOW00SI0n$jjKVk++SHQEBoNKBdj)`OYv2@h9i!4(!&x9ZL-IFlhBaMzH9!csdSGS%cW0f6@9KU1Xu{)T}INN%2xrlc*ty z7cz>dO{G*(QnZw+iJ6)7e7?p07>+G;k7lmSq%)E%CNh_1(@7d8r_@AdQjun-=KnuZ z%7AF!5BK{TL2N$=EA|%XcD{vuu$ymVZ}xNr!X!Io}{>{5FkLh9KYECw_~X3ys6&PgIXdlgXZhlBDM_ zF_o0$r1DeXTK=ZH%E3Qcj)S+`EpVEtdM{b7dKZg&nA;;jUx0^SG?{g`5e$m~SZT7t zMNxpqMLTw|3x}Tvc)?W-zrABD8nedfI`~r{1b+}4D4XYu(-r=ZpKtV3`Ld~&&XhGx z+hEQc;7k=}m{iv=(sm=vRqrCnlAQ=LEtG57qFsavN zO)%FLgrOduL(|Q4!KYs|XLW21-t#+Pwx`}zOF?@W)8ppzKMoEUMi#zwNYWBh;^oh2 zDk+`b=_yt0P%oxtX^W-lq095Nc9NsgjcU{6#r8wk?NB&+5Q?5boPhx{9fwbUk(q-G zST~e>JC`S=XQ!m|No`_AdLlI`X{pRi)S~zq?<|8MMmAqaDXKQ1WUiE)HFF{@&1Wua z@Lor_<138C*Y=`Bm5Exc4JAOQ%4x7hMdNGKRBu8o+8*%u;nAFVvnjGNzt$96CEg9< zT_>Re39TF|l6E*1ePgG?CyL~8aCQnyjq9YfKw4M!7fDZ7X}K#z?!fx$KNCNb>b}$?{Z_2gf?n3f00D)IfR@;5oyJV$_oSwoetdx zn0EPLm~ZM|k~vvFsng7qQ68GLYRQIbPkqK`tomfr0wvzoRoJ8GM_fWTi4wYVHiQ;A zjbdf&O6P{|IftGy7C9EVNLz3eoeNM^E*dmPjb2My7K@8Du#3o~nuzuulMs{GFa5mD zAafTx=*75d-c7N!u2v*Q&z;eutd0snvbG#JL9^p>1y@kiI;ODVKVr>R;)c`dPjRS( zhf8?;BX&~CHz`QNlwx5`0EI{={3RF#TkjtDf!zz|TRrf{Fc0T!Ua&>XCN;H?+%Lvp znEGvzb~wV@;lqdndK`^3&IL#ND6dz5zdO7%uC2OG_RlIwRZYrJXsu&JJ|;==z0!e7 z)?wafmwr#{DNk#Kd>HhUH#GM)l{c&w^iH?~%n{rF6l0F;fk$~aydSA67sFz&N9y4b zzF~=N809N^Gq8{DXTU^PGAPo_WU|C%6uHDDrWA#+r9K8R1~nx{O=Y!}95wY^Ny(E* z?K6&w&`LN}GSPn!Evcv_BQutgN{KtK&8Y05Wwp>z5n75m79yYXR@aj3%Ds^uk9wa5 zJc%}a!JkBPj!j>9C9~!m$T>EMuueJ)r1Mtnmh_72mEj^8DErEyTZ&PoQ8m>!E{Cpn zy%zo*_nVR5lD9%{B;M?JCw8}Ut^dde!LeJ%e->JiUW)uVI970v3g#{mb82aTocv^1QjgH*Xi{BUW*S-0*C z7M#JYFC#uw{{ZnW&z2wA{Of$Uz=u~7@AI8oM=;H>bqpgjeNR}iMbdW9(XvWfKG{Bl zQR`8x8tEtB4Z24n#=P3Os>gDEv{&G;3}^ep_$l~a|4E#K_5Nolo{N70KMObG0c(y1 zDRT^-fV*)Yo`lUf;31gZJ8w|3)DQRf4&gJ<{ZKm|%b$Aa6TDgX0ubCZnOseV$-W22ZnvFopkILm%sO2JbfU7<#vR>I@Croe50m zU*+P4z`v*QS!@v4!^P1+c>Ce+`dRqPh`CUC(@)9ZrHp(zojj=Afr))_aRKA#^@azC U{XV$LP3p05z5Qdve#=q+1uOfNn*aa+ delta 942 zcmaizU2IEX7{|}k)6<=ve%adfoVB)|wpgmHo3An5$}T2Bn2~iU7n*&9MT~BQC_&;Q zkqG-s*o92uD=Y-(u0n!@3lb5Fj}b(Kh-Hx&;zHtmH^rUz;&=Xe{?GfK=l$oL3;XHX zX6Jo}LlM|&yuNXrzv8NMi>UZyK{zgCg-*g%(QTX(Ll(TVdQ^3Qc&8wc&@+8p2|}9& zmr{jZ$wMXRQ-TPD?Kq>%#cit_S4w1jSIYnBHx*`_wt29^R)sS*H*%#;+_HMc`tn{b zWH)aYB0l4TZHGxpFK6$>ZkLA7Q`(`pUP|e%zm))*=a(Q>qhOQWfh(0-Z^VNB37^;~ zhYBB~vuK!7M$&!Qgwf)dF~gf6>Ow{h7tLkkA7Gbk7gE$Fq)crVoL582L17!ER~b?U0sZaK|51xgT<%~+8yqEe_O!-VT~T(J`U*K zu>4Q@f4i*t>P}16l0Oep(2JuLA^Z$E@yg6|cHmBhjdVb6#>42feV8mBh+;#}4;L<%w!xn9kYV8F=K$?75|x(gWe z@s$a1JBj&G-Exh4HAqLxaWQ%WTc;I~3;o`hstwAaXR`CDtUZ#o6TU%t(i@k0(1q82 zzwu>O&P=Vw<$0<|t+*SXOdBv9Uq#(mSi6t$UhO>E45|y*y4mp``M4hm(K;V>prHxw diff --git a/ui/pages/__pycache__/settings.cpython-312.pyc b/ui/pages/__pycache__/settings.cpython-312.pyc index b7f8f532d96262e3642a3b5e201cd761fb8cc6f4..066aa527d3fd4acb2c1d3de930b1d7ce78bc7977 100644 GIT binary patch literal 28640 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 DhE0ve delta 4024 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 SH@GIuY~S3)4;lQY^Zg&QziyTQ delta 2905 zcma)8YfK#16`ni$ntcNcy9*26f#tOUuknM2319-o1Q!!932tc8^|Cu&#@Scz%+^2` zVNy$>RU_NIQ4*W?wpf-Qh)VU2hs(LQIT_u?c6g zWV$3-I$fG9n=Vsjx`ZoPK3$F&mKkPZ`YTM#AQ;}(feD70u27{$DmCqsx>YGhrDg%` z>U!oaLF*=L3py0j#5pk*7i7gcF*T5$BPR438`O~>qDt}`DSTa&M z&-3I{wv#Md-9|H=OI_rhmU8m0wTZpY5XZ5~!hP%CEKP8RVv+>;R5~T&a@F8DL5$DJ zihdxSh!xK_c^*f8GUyR&>!=PsKw+U`JQ0~sh>|=a3&{|*k_NWicX{(t6>Rz!K37|xuQxngy_6qGvkIO0FFFc-Dy-Uxyh^5 zx7xtoO&#`u!mbr60Jks?@UT-PR}(PGRDQn_;%MP)SM6W3zwE>THJ0~kX7zza*w#d0 z!8SRcit{kUHE60J*?j0LKCB z6qJ%!`cf*9j>N)AQIf<|9G@nCYV7oMgT~_kJrsfl?KAqw;ihh*mM`+9rUPiMaINVT zw6qscBOZfIPXUYr?1lnQ!t`kh(!O|3fe=7~2R`W^SD24k*5Lhw8%Bm8R!xIKls>yd zxAS(tkG{NQXI^6jW*WtqX*Ome?*yCOE6g04Hcv7^PT?SQNInmXOcp+K% z^8n9M*sBzBwZ&)sImo900tI91dA9p7ec!AEU;)7WpcOwN*or-hg@`PKXN5>iz_^>_ zL*1x{{66HTwf@ggB|1SW4ha(Z{gPZd^bxIXxIZtp{SNt?sr3bW@gnfxyq0e@ zk}YK6$dPBBqdNGY<_3h~|IWGUGvv0IQ4G5wh&W2n(E|ZBw@Er*a2LZCdgz<}%#eYj z?`aORrz!XJ#mLLYer#Mc|h$p;eE6&91-$9G!ocSd4d_BWFN0k>Xi`GS(3^z?3IZMu(vlYp7(VpWpcU<GbR8wv<$ixZqQ&GG({5wwWF{?(7v}k5ns%;a2|X0IP{`US zY~6U}3V&9(fCXt5w-wHJ4eP2~sXS;5>K3e{;tN8IpG>FX(&hu$7b5@uqGt(e4Ict6 zsxvq%CKCLVI3q}#P^dVxvv4krUr1+V{){-c`R#o>%xCFU=AXu5W^<5=7i?pc53yQA zLJTaPN=LI2e2I&7unzt7vJ5H4L!!nbe_{5eF*00DENNq*JAg61Uc;&I*#a z5Sd8`DrG9>-35(R94f0ovxow(K^>ASJv?v4TF}BXa!M)7?DT|ils1%zm=ZAQ>#6T8 ziY2vuDTZh|OM8}4k|T0fQuHG2AS$2GwzY3)ES@5_dm4=YqRvXoE4bpkSJjYD z->K?Yac*#)b*^retIHo*;~KY(rV7i7d8>@6Ke)}X=GIl4f92%0hS%NqT>iX1FMNOM zj_cUU$qk!t-PW*bYskm$+FG^@RC9}C%wEmZ8dtx;RjqUNt6Y6PyvDVEz&X}A?<(hg zoy_%BqCnxBee*18B(^8ss#2SUWm1k{S)-22^dlqRrFVx!j(drx!cTtSswGQ}WyI;S z6#54Sk@+j2lG#(aM0_Vd>{gqHO3!L~O;kyvJhio`$v*-zXc9qA>knTAK_3|$n$jpP zO>PX;qtU`IDDF^(MNcWiTc{r1)*YW`f@NcyufnFQ0K4rD!!=p3t37WVr{nYnX*Ue3 z%-2#F`CKPL2Mf!iCs0WB;t^#0=<+2Wo_VH2WkCIYpXqsuGqZEl4t-0|NuhSMqREi? zu?FKc8Yk(E!kc4npryN9lh&W0odhkP-THt~aS9*}kO7zk_}_LI2O+@E=p#WqitTWl z^BdZ>2awC)-hONh89dwPz3c$}{p3lu+t94}0XxTnUXr+QZb`9?&rir5p$T~~jRjmv zmv9+?i-KY;E)Hi!?T>*Q*3^81m7BErfdKl!r(91m9m^(!KKv$~(f}|L^!6!_6Mfs?|PDnA1EEsqvn-MA2aZ)TK+FzwzuQ} diff --git a/ui/pages/artists.py b/ui/pages/artists.py index 1d50098a..b214b716 100644 --- a/ui/pages/artists.py +++ b/ui/pages/artists.py @@ -81,6 +81,7 @@ class ArtistCard(QFrame): update_btn = QPushButton("Update") update_btn.setFixedSize(60, 25) + update_btn.setToolTip("Update metadata for this artist only") update_btn.setStyleSheet(""" QPushButton { background: #1db954; @@ -97,6 +98,7 @@ class ArtistCard(QFrame): download_btn = QPushButton("Download") download_btn.setFixedSize(60, 25) + download_btn.setToolTip("Download complete discography for this artist") download_btn.setStyleSheet(""" QPushButton { background: transparent; @@ -177,9 +179,14 @@ class ArtistsPage(QWidget): def create_controls(self): controls = QWidget() - layout = QHBoxLayout(controls) + layout = QVBoxLayout(controls) layout.setSpacing(15) + # First row - search and filters + first_row = QWidget() + first_row_layout = QHBoxLayout(first_row) + first_row_layout.setSpacing(15) + # Search bar search_bar = QLineEdit() search_bar.setPlaceholderText("Search artists...") @@ -241,6 +248,7 @@ class ArtistsPage(QWidget): # Update all button update_btn = QPushButton("📝 Update All Metadata") update_btn.setFixedHeight(40) + update_btn.setToolTip("Batch update metadata for all artists (skips already completed)") update_btn.setStyleSheet(""" QPushButton { background: transparent; @@ -256,14 +264,73 @@ class ArtistsPage(QWidget): } """) - layout.addWidget(search_bar) - layout.addWidget(filter_combo) - layout.addStretch() - layout.addWidget(scan_btn) - layout.addWidget(update_btn) + first_row_layout.addWidget(search_bar) + first_row_layout.addWidget(filter_combo) + first_row_layout.addStretch() + first_row_layout.addWidget(scan_btn) + first_row_layout.addWidget(update_btn) + + # Second row - alphabet navigation + alphabet_row = self.create_alphabet_navigation() + + layout.addWidget(first_row) + layout.addWidget(alphabet_row) return controls + def create_alphabet_navigation(self): + alphabet_widget = QWidget() + alphabet_layout = QHBoxLayout(alphabet_widget) + alphabet_layout.setContentsMargins(0, 0, 0, 0) + alphabet_layout.setSpacing(3) + + # Create alphabet buttons + letters = ['All', '#'] + [chr(i) for i in range(ord('A'), ord('Z')+1)] + + for letter in letters: + btn = QPushButton(letter) + btn.setFixedSize(30, 30) + btn.setStyleSheet(""" + QPushButton { + background: #282828; + border: 1px solid #404040; + border-radius: 15px; + color: #b3b3b3; + font-size: 10px; + font-weight: bold; + } + QPushButton:hover { + background: #1db954; + border: 1px solid #1db954; + color: #000000; + } + QPushButton:pressed { + background: #1aa34a; + } + """) + + # Set "All" as active by default + if letter == 'All': + btn.setStyleSheet(""" + QPushButton { + background: #1db954; + border: 1px solid #1db954; + border-radius: 15px; + color: #000000; + font-size: 10px; + font-weight: bold; + } + QPushButton:hover { + background: #1ed760; + } + """) + + alphabet_layout.addWidget(btn) + + alphabet_layout.addStretch() + + return alphabet_widget + def create_artists_section(self): section = QWidget() layout = QVBoxLayout(section) diff --git a/ui/pages/settings.py b/ui/pages/settings.py index 22f40977..9f3644c9 100644 --- a/ui/pages/settings.py +++ b/ui/pages/settings.py @@ -1,8 +1,9 @@ from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, QFrame, QPushButton, QLineEdit, QComboBox, - QCheckBox, QSpinBox, QTextEdit, QGroupBox, QFormLayout) + QCheckBox, QSpinBox, QTextEdit, QGroupBox, QFormLayout, QMessageBox) from PyQt6.QtCore import Qt from PyQt6.QtGui import QFont +from config.settings import config_manager class SettingsGroup(QGroupBox): def __init__(self, title: str, parent=None): @@ -28,7 +29,10 @@ class SettingsGroup(QGroupBox): class SettingsPage(QWidget): def __init__(self, parent=None): super().__init__(parent) + self.config_manager = None + self.form_inputs = {} self.setup_ui() + self.load_config_values() def setup_ui(self): self.setStyleSheet(""" @@ -61,9 +65,10 @@ class SettingsPage(QWidget): main_layout.addStretch() # Save button - save_btn = QPushButton("💾 Save Settings") - save_btn.setFixedHeight(45) - save_btn.setStyleSheet(""" + self.save_btn = QPushButton("💾 Save Settings") + self.save_btn.setFixedHeight(45) + self.save_btn.clicked.connect(self.save_settings) + self.save_btn.setStyleSheet(""" QPushButton { background: #1db954; border: none; @@ -77,7 +82,173 @@ class SettingsPage(QWidget): } """) - main_layout.addWidget(save_btn) + main_layout.addWidget(self.save_btn) + + def load_config_values(self): + """Load current configuration values into form inputs""" + try: + # Load Spotify config + spotify_config = config_manager.get_spotify_config() + self.client_id_input.setText(spotify_config.get('client_id', '')) + self.client_secret_input.setText(spotify_config.get('client_secret', '')) + + # Load Plex config + plex_config = config_manager.get_plex_config() + self.plex_url_input.setText(plex_config.get('base_url', '')) + self.plex_token_input.setText(plex_config.get('token', '')) + + # Load Soulseek config + soulseek_config = config_manager.get_soulseek_config() + self.slskd_url_input.setText(soulseek_config.get('slskd_url', '')) + self.api_key_input.setText(soulseek_config.get('api_key', '')) + + except Exception as e: + QMessageBox.warning(self, "Error", f"Failed to load configuration: {e}") + + def save_settings(self): + """Save current form values to configuration""" + try: + # Save Spotify settings + config_manager.set('spotify.client_id', self.client_id_input.text()) + config_manager.set('spotify.client_secret', self.client_secret_input.text()) + + # Save Plex settings + config_manager.set('plex.base_url', self.plex_url_input.text()) + config_manager.set('plex.token', self.plex_token_input.text()) + + # Save Soulseek settings + config_manager.set('soulseek.slskd_url', self.slskd_url_input.text()) + config_manager.set('soulseek.api_key', self.api_key_input.text()) + + # Show success message + QMessageBox.information(self, "Success", "Settings saved successfully!") + + # Update button text temporarily + original_text = self.save_btn.text() + self.save_btn.setText("✓ Saved!") + self.save_btn.setStyleSheet(""" + QPushButton { + background: #1aa34a; + border: none; + border-radius: 22px; + color: #ffffff; + font-size: 14px; + font-weight: bold; + } + """) + + # Reset button after 2 seconds + from PyQt6.QtCore import QTimer + QTimer.singleShot(2000, lambda: self.reset_save_button(original_text)) + + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to save settings: {e}") + + def reset_save_button(self, original_text): + """Reset save button to original state""" + self.save_btn.setText(original_text) + self.save_btn.setStyleSheet(""" + QPushButton { + background: #1db954; + border: none; + border-radius: 22px; + color: #000000; + font-size: 14px; + font-weight: bold; + } + QPushButton:hover { + background: #1ed760; + } + """) + + def test_spotify_connection(self): + """Test Spotify API connection""" + try: + from core.spotify_client import SpotifyClient + + # Create temporary client with current form values + temp_config = config_manager.get_spotify_config().copy() + temp_config['client_id'] = self.client_id_input.text() + temp_config['client_secret'] = self.client_secret_input.text() + + # Save temporarily to test + original_client_id = config_manager.get('spotify.client_id') + original_client_secret = config_manager.get('spotify.client_secret') + + config_manager.set('spotify.client_id', temp_config['client_id']) + config_manager.set('spotify.client_secret', temp_config['client_secret']) + + # Test connection + client = SpotifyClient() + if client.is_authenticated(): + user_info = client.get_user_info() + username = user_info.get('display_name', 'Unknown') if user_info else 'Unknown' + QMessageBox.information(self, "Success", f"✓ Spotify connection successful!\nConnected as: {username}") + else: + QMessageBox.warning(self, "Failed", "✗ Spotify connection failed.\nCheck your credentials and try again.") + + # Restore original values + config_manager.set('spotify.client_id', original_client_id) + config_manager.set('spotify.client_secret', original_client_secret) + + except Exception as e: + QMessageBox.critical(self, "Error", f"✗ Spotify test failed:\n{str(e)}") + + def test_plex_connection(self): + """Test Plex server connection""" + try: + from core.plex_client import PlexClient + + # Save temporarily to test + original_base_url = config_manager.get('plex.base_url') + original_token = config_manager.get('plex.token') + + config_manager.set('plex.base_url', self.plex_url_input.text()) + config_manager.set('plex.token', self.plex_token_input.text()) + + # Test connection + client = PlexClient() + if client.is_connected(): + server_name = client.server.friendlyName if client.server else 'Unknown' + QMessageBox.information(self, "Success", f"✓ Plex connection successful!\nServer: {server_name}") + else: + QMessageBox.warning(self, "Failed", "✗ Plex connection failed.\nCheck your server URL and token.") + + # Restore original values + config_manager.set('plex.base_url', original_base_url) + config_manager.set('plex.token', original_token) + + except Exception as e: + QMessageBox.critical(self, "Error", f"✗ Plex test failed:\n{str(e)}") + + def test_soulseek_connection(self): + """Test Soulseek slskd connection""" + try: + import requests + + slskd_url = self.slskd_url_input.text() + api_key = self.api_key_input.text() + + if not slskd_url: + QMessageBox.warning(self, "Error", "Please enter slskd URL") + return + + # Test API endpoint + headers = {} + if api_key: + headers['X-API-Key'] = api_key + + response = requests.get(f"{slskd_url}/api/v0/session", headers=headers, timeout=5) + + if response.status_code == 200: + QMessageBox.information(self, "Success", "✓ Soulseek connection successful!\nslskd is responding.") + else: + QMessageBox.warning(self, "Failed", f"✗ Soulseek connection failed.\nHTTP {response.status_code}") + + except requests.exceptions.RequestException as e: + QMessageBox.critical(self, "Error", f"✗ Soulseek test failed:\n{str(e)}") + except Exception as e: + QMessageBox.critical(self, "Error", f"✗ Soulseek test failed:\n{str(e)}") def create_header(self): header = QWidget() @@ -120,16 +291,18 @@ class SettingsPage(QWidget): 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()) + self.client_id_input = QLineEdit() + self.client_id_input.setStyleSheet(self.get_input_style()) + self.form_inputs['spotify.client_id'] = self.client_id_input - client_secret_input = QLineEdit("c3844dcbedbc4e09a6242a14b2e89e89") - client_secret_input.setEchoMode(QLineEdit.EchoMode.Password) - client_secret_input.setStyleSheet(self.get_input_style()) + self.client_secret_input = QLineEdit() + self.client_secret_input.setEchoMode(QLineEdit.EchoMode.Password) + self.client_secret_input.setStyleSheet(self.get_input_style()) + self.form_inputs['spotify.client_secret'] = self.client_secret_input spotify_layout.addRow(spotify_title) - spotify_layout.addRow("Client ID:", client_id_input) - spotify_layout.addRow("Client Secret:", client_secret_input) + spotify_layout.addRow("Client ID:", self.client_id_input) + spotify_layout.addRow("Client Secret:", self.client_secret_input) # Plex settings plex_frame = QFrame() @@ -140,16 +313,18 @@ class SettingsPage(QWidget): 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()) + self.plex_url_input = QLineEdit() + self.plex_url_input.setStyleSheet(self.get_input_style()) + self.form_inputs['plex.base_url'] = self.plex_url_input - plex_token_input = QLineEdit("a9hTgvasV1aJMLSdoBkr") - plex_token_input.setEchoMode(QLineEdit.EchoMode.Password) - plex_token_input.setStyleSheet(self.get_input_style()) + self.plex_token_input = QLineEdit() + self.plex_token_input.setEchoMode(QLineEdit.EchoMode.Password) + self.plex_token_input.setStyleSheet(self.get_input_style()) + self.form_inputs['plex.token'] = self.plex_token_input plex_layout.addRow(plex_title) - plex_layout.addRow("Server URL:", plex_url_input) - plex_layout.addRow("Token:", plex_token_input) + plex_layout.addRow("Server URL:", self.plex_url_input) + plex_layout.addRow("Token:", self.plex_token_input) # Soulseek settings soulseek_frame = QFrame() @@ -160,17 +335,19 @@ class SettingsPage(QWidget): 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()) + self.slskd_url_input = QLineEdit() + self.slskd_url_input.setStyleSheet(self.get_input_style()) + self.form_inputs['soulseek.slskd_url'] = self.slskd_url_input - 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()) + self.api_key_input = QLineEdit() + self.api_key_input.setPlaceholderText("Enter your slskd API key") + self.api_key_input.setEchoMode(QLineEdit.EchoMode.Password) + self.api_key_input.setStyleSheet(self.get_input_style()) + self.form_inputs['soulseek.api_key'] = self.api_key_input soulseek_layout.addRow(soulseek_title) - soulseek_layout.addRow("slskd URL:", slskd_url_input) - soulseek_layout.addRow("API Key:", api_key_input) + soulseek_layout.addRow("slskd URL:", self.slskd_url_input) + soulseek_layout.addRow("API Key:", self.api_key_input) api_layout.addWidget(spotify_frame) api_layout.addWidget(plex_frame) @@ -182,14 +359,17 @@ class SettingsPage(QWidget): test_spotify = QPushButton("Test Spotify") test_spotify.setFixedHeight(30) + test_spotify.clicked.connect(self.test_spotify_connection) test_spotify.setStyleSheet(self.get_test_button_style()) test_plex = QPushButton("Test Plex") test_plex.setFixedHeight(30) + test_plex.clicked.connect(self.test_plex_connection) test_plex.setStyleSheet(self.get_test_button_style()) test_soulseek = QPushButton("Test Soulseek") test_soulseek.setFixedHeight(30) + test_soulseek.clicked.connect(self.test_soulseek_connection) test_soulseek.setStyleSheet(self.get_test_button_style()) test_layout.addWidget(test_spotify) @@ -288,19 +468,22 @@ class SettingsPage(QWidget): 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()) + # Sync interval + interval_layout = QHBoxLayout() + interval_label = QLabel("Sync Interval (minutes):") + interval_label.setStyleSheet("color: #ffffff; font-size: 12px;") - # Download missing tracks checkbox - download_missing = QCheckBox("Automatically download missing tracks") - download_missing.setChecked(False) - download_missing.setStyleSheet(self.get_checkbox_style()) + interval_spin = QSpinBox() + interval_spin.setRange(5, 1440) # 5 minutes to 24 hours + interval_spin.setValue(60) + interval_spin.setStyleSheet(self.get_spin_style()) + + interval_layout.addWidget(interval_label) + interval_layout.addWidget(interval_spin) + interval_layout.addStretch() sync_layout.addWidget(auto_sync) - sync_layout.addWidget(update_metadata) - sync_layout.addWidget(download_missing) + sync_layout.addLayout(interval_layout) # Logging Settings logging_group = SettingsGroup("Logging Settings") diff --git a/ui/pages/sync.py b/ui/pages/sync.py index 380c5c53..e9e1424b 100644 --- a/ui/pages/sync.py +++ b/ui/pages/sync.py @@ -1,10 +1,40 @@ 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 + QScrollArea, QSizePolicy, QMessageBox) +from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer from PyQt6.QtGui import QFont +class PlaylistLoaderThread(QThread): + playlist_loaded = pyqtSignal(object) # Single playlist + loading_finished = pyqtSignal(int) # Total count + loading_failed = pyqtSignal(str) # Error message + progress_updated = pyqtSignal(str) # Progress text + + def __init__(self, spotify_client): + super().__init__() + self.spotify_client = spotify_client + + def run(self): + try: + self.progress_updated.emit("Connecting to Spotify...") + if not self.spotify_client or not self.spotify_client.is_authenticated(): + self.loading_failed.emit("Spotify not authenticated") + return + + self.progress_updated.emit("Fetching playlists...") + playlists = self.spotify_client.get_user_playlists() + + for i, playlist in enumerate(playlists): + self.progress_updated.emit(f"Loading playlist {i+1}/{len(playlists)}: {playlist.name}") + self.playlist_loaded.emit(playlist) + self.msleep(50) # Small delay to show progressive loading + + self.loading_finished.emit(len(playlists)) + + except Exception as e: + self.loading_failed.emit(str(e)) + class PlaylistItem(QFrame): def __init__(self, name: str, track_count: int, sync_status: str, parent=None): super().__init__(parent) @@ -191,9 +221,16 @@ class SyncOptionsPanel(QFrame): layout.addLayout(quality_layout) class SyncPage(QWidget): - def __init__(self, parent=None): + def __init__(self, spotify_client=None, plex_client=None, parent=None): super().__init__(parent) + self.spotify_client = spotify_client + self.plex_client = plex_client + self.current_playlists = [] + self.playlist_loader = None self.setup_ui() + + # Start loading playlists asynchronously after UI is ready + QTimer.singleShot(100, self.load_playlists_async) def setup_ui(self): self.setStyleSheet(""" @@ -261,9 +298,10 @@ class SyncPage(QWidget): 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(""" + self.refresh_btn = QPushButton("🔄 Refresh") + self.refresh_btn.setFixedSize(100, 35) + self.refresh_btn.clicked.connect(self.refresh_playlists) + self.refresh_btn.setStyleSheet(""" QPushButton { background: #1db954; border: none; @@ -282,7 +320,7 @@ class SyncPage(QWidget): header_layout.addWidget(section_title) header_layout.addStretch() - header_layout.addWidget(refresh_btn) + header_layout.addWidget(self.refresh_btn) # Playlist container playlist_container = QScrollArea() @@ -303,26 +341,14 @@ class SyncPage(QWidget): } """) - playlist_widget = QWidget() - playlist_layout = QVBoxLayout(playlist_widget) - playlist_layout.setSpacing(10) + self.playlist_widget = QWidget() + self.playlist_layout = QVBoxLayout(self.playlist_widget) + self.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") - ] + # Playlists will be loaded asynchronously after UI setup - for name, count, status in playlists: - item = PlaylistItem(name, count, status) - playlist_layout.addWidget(item) - - playlist_layout.addStretch() - playlist_container.setWidget(playlist_widget) + self.playlist_layout.addStretch() + playlist_container.setWidget(self.playlist_widget) layout.addLayout(header_layout) layout.addWidget(playlist_container) @@ -455,4 +481,103 @@ class SyncPage(QWidget): 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 + layout.addWidget(self.log_area) + + return section + + def load_playlists_async(self): + """Start asynchronous playlist loading""" + if self.playlist_loader and self.playlist_loader.isRunning(): + return + + # Clear existing playlists + self.clear_playlists() + + # Show loading state + self.refresh_btn.setText("🔄 Loading...") + self.refresh_btn.setEnabled(False) + self.log_area.append("Starting playlist loading...") + + # Create and start loader thread + self.playlist_loader = PlaylistLoaderThread(self.spotify_client) + self.playlist_loader.playlist_loaded.connect(self.add_playlist_to_ui) + self.playlist_loader.loading_finished.connect(self.on_loading_finished) + self.playlist_loader.loading_failed.connect(self.on_loading_failed) + self.playlist_loader.progress_updated.connect(self.update_progress) + self.playlist_loader.start() + + def add_playlist_to_ui(self, playlist): + """Add a single playlist to the UI as it's loaded""" + # Simple sync status (placeholder for now) + sync_status = "Never Synced" # TODO: Check actual sync status + item = PlaylistItem(playlist.name, playlist.total_tracks, sync_status) + # Insert before the stretch item + self.playlist_layout.insertWidget(self.playlist_layout.count() - 1, item) + self.current_playlists.append(playlist) + + # Update log + self.log_area.append(f"Added playlist: {playlist.name} ({playlist.total_tracks} tracks)") + + def on_loading_finished(self, count): + """Handle completion of playlist loading""" + self.refresh_btn.setText("🔄 Refresh") + self.refresh_btn.setEnabled(True) + self.log_area.append(f"✓ Loaded {count} Spotify playlists successfully") + + def on_loading_failed(self, error_msg): + """Handle playlist loading failure""" + self.refresh_btn.setText("🔄 Refresh") + self.refresh_btn.setEnabled(True) + self.log_area.append(f"✗ Failed to load playlists: {error_msg}") + QMessageBox.critical(self, "Error", f"Failed to load playlists: {error_msg}") + + def update_progress(self, message): + """Update progress text""" + self.log_area.append(message) + + def load_initial_playlists(self): + """Load initial playlist data (placeholder or real)""" + if self.spotify_client and self.spotify_client.is_authenticated(): + self.refresh_playlists() + else: + # Show placeholder 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) + self.playlist_layout.addWidget(item) + + def refresh_playlists(self): + """Refresh playlists from Spotify API using async loader""" + if not self.spotify_client: + QMessageBox.warning(self, "Error", "Spotify client not available") + return + + if not self.spotify_client.is_authenticated(): + QMessageBox.warning(self, "Error", "Spotify not authenticated. Please check your settings.") + return + + # Use the async loader + self.load_playlists_async() + + def clear_playlists(self): + """Clear all playlist items from the layout""" + # Clear the current playlists list + self.current_playlists = [] + + # Remove all items except the stretch + for i in reversed(range(self.playlist_layout.count())): + item = self.playlist_layout.itemAt(i) + if item.widget(): + item.widget().deleteLater() + elif item.spacerItem(): + continue # Keep the stretch spacer + else: + self.playlist_layout.removeItem(item) \ No newline at end of file diff --git a/ui/sidebar.py b/ui/sidebar.py index 9cc8e876..e0006c36 100644 --- a/ui/sidebar.py +++ b/ui/sidebar.py @@ -63,7 +63,7 @@ class SidebarButton(QPushButton): transform: scale(1.02); } """) - self.text_label.setStyleSheet("color: #1db954; font-weight: bold;") + self.text_label.setStyleSheet("color: #1db954; font-weight: bold; background: transparent;") self.icon_label.setStyleSheet(""" QLabel { color: #1db954; @@ -87,7 +87,7 @@ class SidebarButton(QPushButton): border-left: 2px solid rgba(255, 255, 255, 0.3); } """) - self.text_label.setStyleSheet("color: #b3b3b3;") + self.text_label.setStyleSheet("color: #b3b3b3; background: transparent;") self.icon_label.setStyleSheet(""" QLabel { color: #b3b3b3;