From 689a488cbbe4c416178555732bc43a06765ad004 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Wed, 6 Aug 2025 23:59:22 -0700 Subject: [PATCH] artist.py update --- ui/pages/__pycache__/artists.cpython-312.pyc | Bin 217391 -> 238974 bytes .../__pycache__/downloads.cpython-312.pyc | Bin 512571 -> 511378 bytes ui/pages/artists.py | 554 ++++++++++++++++-- 3 files changed, 501 insertions(+), 53 deletions(-) diff --git a/ui/pages/__pycache__/artists.cpython-312.pyc b/ui/pages/__pycache__/artists.cpython-312.pyc index f43816b0880c05795937f4c34b98977448b1a019..305a229986d0017611eb3681b14795d3d38c79ed 100644 GIT binary patch delta 33978 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 diff --git a/ui/pages/__pycache__/downloads.cpython-312.pyc b/ui/pages/__pycache__/downloads.cpython-312.pyc index 04cfa9767bcb63e390a667283c22b59722b6ba3a..97c86929d786e54d982889a2e52b24c5b483bf1f 100644 GIT binary patch delta 2385 zcmZuxd014}6@TZwckZ2q8HZ(t&0!IOplBFz7YUou8cjmoT1SK6v#~XjIMw=T9&xFd z-?SQWPv?m546 z=ltF~ZzitWnCLv>aM+aKFY>puBX=!xRyv{5sa86*N~hlF#NXGbR~)pr2Ak9#>rAU* zkCmxwA+UPBT`U`;Mu}7Fb-SqEjyoY%B<;dza0HcyRoKftrd1rU>F(sXS_rjJ0w_zw z1xr-e=iBf46vc=au3G$o+S3%r4e*G0wYUSkqUTQZdJ+t8t$H`HqKDbTNZhC!GH;%j zC5rN$I9^LK!`AP{_~>NQwNWt~)mnEikTA=$M1CxKMAi-&Fy%4Ry-}%F?*lX4 z6>53d0}qfU7T4ntacm!2oq-h(1bT}db=X_%y`e>lXB*IFnI(HWRo$z(E0#}r!N~c~% z^WMV~pdF7&70)$cENyARfp^XSG-EvZ4q%hE#P3$N6-P*Ud_+N%RIRRjkm}Qj5mKIT zW(4I>?^K6!(ruULlga5*eM+0PK$Awh!#cIU*6wjm&CuFYG3d#_pr?N*>a+SIwD#dn zzeDS=1fv}el%9@=V6?++^$*rMTnSFUPwViZGy+@X+KM`29;*5P%K$D@*CD)NQEz(b z>M>lzVGPYZiMy?^hg1-&gc8p^j)|{t;n*RA{=XnjNhJ)f zurVkklR|QEXsLBGDiAaO9%E(35HaUACT0xX#3m>YxQBHY1H~9_x{PqsDqHHtP_gPZ z#);@}F_w zG9MM9q#C?2S(IMGQpdwdYmg^FSaU5SC9t_Gy)gvg*3s#g%qQw%m|qNy zWhaVfq$mdm7I3LP$QhL=)sdbUl)c7eN!1z|8I-+81%0Rb6=h2Gd0c@-sxv9pvJ~x1 zW`1T_f_63rq;WO@%TlN$j-6pJgYuGDvnn|ZsqCm7UZxKpXEqM+(W$}gXB^H_Q7*e4 z1_lj(ij7wxkzO0ma;>m{{xX@ZamqcPGK;;Y!3H`rn^iK<=qE3-S{sB@#A5atgHpP* zl%3b$CF)qgMyXIq$*-~&gkRIwtC=S(xau3&R=e8Si)!9vBdm}{XLqqB4E$8qz)k}^ zO?mrSt1b!Veut%N@Egi~pRG_~I~{Ch?;N7;C$-b$ej zAF~NM#L|TmELDXpia5n`0dlCgojtEXA-#Q?WtPq1U8?G0M^uTt^d=jt!8zJ_M{b{bEw!-^U!@FerWzj?05;0a=Fh8Op_)N_2|xj*4dKgG zc#5_U;~P|%LWMbelLjw~Gf(g~m`QKv@fIDH(ehDz5{GJP8^>2_aDYmlSUnr_{BTS8(-WIxVc_ zV=ORCH1Fe0Fp`!w@Yx{8{C6WiY=KGC@IHSWo1NbTJ_1E-R+asveK+QGe)6nu20Y2Ki>D8 z_dV}<-`{!f{hj;!Em42^LzMo6P8T5XCls$=eC5ze{VF}I(u=F~(ki{`h#oz+Q{1Dc z$I|df=G}>&6$3kDja?hc1J;7;6}OhI%oUCF+(wm_ZrhFaN^vsKuBF%{h4VpB1u{lA zYTa620^ef~leJp}yKbA*3^yzwI8$T|JrKFluWzqV5X*Kv9oK@UKpbu0 zPKc*lTTmYT`~Zrh2fmSlY4iaUjI50oS)yHulF1((Yvy~7kS$z}ltcK=sIRfEc-dmN zHt2XCZ;H_Ni=Z6`P{^R0!W)D7HFXsIu!RHru}pn!|CYzntb?c?5@_2&R0N}`;SkD% zF*o|3L>C`IBcx>eShn#H2Lt2i(U(x7lqiQ%+e{AT`-IB5j=fav>!WyD~T2$kzSXAM(Ra@;f_0H-VoBhYl*(U#!w|ZU73)lq~F(RQ^N8+#)7k^C|bI%REZwfZAv{Go2VpZb;V}) zEuN%gDL{Hge?|nF(B-Nl4?eXLvl9XN?i_;)Sz` zTHkm=T(M3#7c*JnlXv4a#R1a0N%~?Wc?HBRUWB;C8{qSLb(-P?$!pdZ$4lO5zc(KF zED3&ZqNZe^5|?k`3{8AWZJ$Kn_!fO~2P7H45J~%W z^xLpo6KC?n9LDK;@;17zjhJY|cPNlod~pyltN8Ua%0JTDwNncK~z)y+FBRGYB z`W@OtrpP`v%JQuXg>1Tm_lZoEvEQ~M8T)J(zDH>s5B{0XP{Mtb1iXqhfu#`Yk)j+f8dMvnp>4Abd5fL|STlV>X~7a!#J za%0G2Aw@svdZ-tcOAiY55I=*xA>wqEy>0}5Q+l^QbHrNYaNmv%tqWZ>ORTjHo2z^= zXU4kGee3gGj#`K7VIPS)mRHoef1XWS3El*G@H zDzvDkuDaIYsIr#VINgrrZu-0imn01TpRqMAtM5ke^FRAegOO;+iV}p{0sLR;YadZ( zs#x#zV27n(0(#)F?Gem9S;qMi;#njMtC!8cd31az|RnsLo}Ixxiy6yyUawbn1F? zE4y+lm7FT2s#-BG={7iNlM$aOC|3)wkDW}oI-Z^##np*$u!M9?Yf3ke$HQ zJCm}_TwO3vPUPx+wZ@^A-Ve$g>5!yL6M#=XkdQ;oT21%@1~;SOxI=_#Y;F`jsf8vM zGXe(?=wf4%a3z5&Y|Fj)%K@;Qt(%JTL`Y`mbMZtCtY(pgxJeJ6u>JG#(-Q1ssdl^y zLkN4d5;q4x46CTaUJP}Nf8!rX@DxjW9A}HLi7kHucOv*B%X}J}2f&+b?t1)!R#XjT z*SF&|4UA{^?#3%H)UcOZ@OuCY7(Ix)Rq#7j-HPKRc#&;*89yb$t1PSyw;?#qdXC}k z5`4+(kK;H|)JC%%f5CZJG(@xBw{ejQMzO5F;b;-ESVcFU2>gcG<-v<2n8OU`a3+Q) zSmAjb2k;Di^aB1I9%f4};x|QD#>QU4PpaVvJNhwBCZMp7FXIy;ul(p0JY9mTOuEXM zN>s$LNJKH7o!Uuq)sRO+T1Yz-u$~sO z7&!KfgXAqWR50x;DcJH*iF?h%B8`dIg_WW)&+X^Sfr*F8imw3ux7G(luy` s3h9sHUcsk3?&-`rHu185+&f*(=DbPbNbsV0029{cd@1+`t{e{HKky AlbumOwnershipStatus + release_matched = pyqtSignal(str, object) # release_name, AlbumOwnershipStatus + check_failed = pyqtSignal(str) + + def __init__(self, releases, matching_engine): + super().__init__() + self.releases = releases + self.matching_engine = matching_engine + self._stop_requested = False + + def stop(self): + """Request to stop the check""" + self._stop_requested = True + + def run(self): + try: + print("🔍 Starting track-level matching for singles and EPs...") + release_statuses = {} # release_name -> AlbumOwnershipStatus + + # Get database instance + db = get_database() + + if self._stop_requested: + return + + print(f"🎵 Checking {len(self.releases)} singles/EPs against database...") + + for i, release in enumerate(self.releases): + if self._stop_requested: + return + + print(f"🎵 Checking release {i+1}/{len(self.releases)}: {release.name} ({release.total_tracks} tracks)") + + if release.total_tracks == 1: + # SINGLE: Use track-level matching + status = self._check_single_ownership(release, db) + else: + # EP: Use track-by-track matching for completion percentage + status = self._check_ep_ownership(release, db) + + release_statuses[release.name] = status + + # Emit individual match for real-time UI update + self.release_matched.emit(release.name, status) + + print(f"🎯 Singles/EPs check complete: {len(release_statuses)} releases processed") + self.check_completed.emit(release_statuses) + + except Exception as e: + error_msg = f"Error checking singles/EPs library: {e}" + print(f"❌ {error_msg}") + import traceback + traceback.print_exc() + self.check_failed.emit(error_msg) + + def _check_single_ownership(self, single_release, db): + """Check if a single track exists anywhere in the library""" + try: + # For singles, we need to get the track info from Spotify first + # Since the release object might not have track details + from core.spotify_client import SpotifyClient + spotify_client = SpotifyClient() + + if not spotify_client.is_authenticated(): + # Fallback: use release name as track name + track_name = single_release.name + artist_name = single_release.artists[0] if single_release.artists else "" + else: + try: + # Get full album data to get track info + album_data = spotify_client.get_album(single_release.id) + if album_data and album_data.get('tracks') and album_data['tracks']: + track_name = album_data['tracks'][0]['name'] + artist_name = album_data['tracks'][0]['artists'][0]['name'] if album_data['tracks'][0]['artists'] else single_release.artists[0] + else: + # Fallback + track_name = single_release.name + artist_name = single_release.artists[0] if single_release.artists else "" + except: + # Fallback if Spotify call fails + track_name = single_release.name + artist_name = single_release.artists[0] if single_release.artists else "" + + print(f" 🔍 Searching for single track: '{track_name}' by '{artist_name}'") + + # Search for the track anywhere in the library + db_track, confidence = db.check_track_exists(track_name, artist_name, confidence_threshold=0.7) + + if db_track and confidence >= 0.7: + print(f" ✅ Single found: '{track_name}' in album '{db_track.album_title}' (confidence: {confidence:.2f})") + + # For singles, if we find the track, it's "complete" + return AlbumOwnershipStatus( + album_name=single_release.name, + is_owned=True, + is_complete=True, + is_nearly_complete=False, + owned_tracks=1, + expected_tracks=1, + completion_ratio=1.0 + ) + else: + print(f" ❌ Single not found: '{track_name}'") + return AlbumOwnershipStatus( + album_name=single_release.name, + is_owned=False, + is_complete=False, + is_nearly_complete=False, + owned_tracks=0, + expected_tracks=1, + completion_ratio=0.0 + ) + + except Exception as e: + print(f" ❌ Error checking single '{single_release.name}': {e}") + return AlbumOwnershipStatus( + album_name=single_release.name, + is_owned=False, + is_complete=False, + is_nearly_complete=False, + owned_tracks=0, + expected_tracks=1, + completion_ratio=0.0 + ) + + def _check_ep_ownership(self, ep_release, db): + """Check EP ownership by checking individual tracks""" + try: + # Get EP tracks from Spotify + from core.spotify_client import SpotifyClient + spotify_client = SpotifyClient() + + if not spotify_client.is_authenticated(): + print(f" ⚠️ Spotify not available, cannot check EP tracks for '{ep_release.name}'") + return AlbumOwnershipStatus( + album_name=ep_release.name, + is_owned=False, + is_complete=False, + is_nearly_complete=False, + owned_tracks=0, + expected_tracks=ep_release.total_tracks, + completion_ratio=0.0 + ) + + try: + album_data = spotify_client.get_album(ep_release.id) + if not album_data or not album_data.get('tracks'): + raise Exception("No track data available") + + tracks = album_data['tracks'] + except: + print(f" ⚠️ Could not fetch EP tracks for '{ep_release.name}'") + return AlbumOwnershipStatus( + album_name=ep_release.name, + is_owned=False, + is_complete=False, + is_nearly_complete=False, + owned_tracks=0, + expected_tracks=ep_release.total_tracks, + completion_ratio=0.0 + ) + + print(f" 🔍 Checking {len(tracks)} tracks in EP '{ep_release.name}'") + + owned_tracks = 0 + expected_tracks = len(tracks) + + for track in tracks: + if self._stop_requested: + break + + track_name = track['name'] + artist_name = track['artists'][0]['name'] if track['artists'] else (ep_release.artists[0] if ep_release.artists else "") + + # Search for this track + db_track, confidence = db.check_track_exists(track_name, artist_name, confidence_threshold=0.7) + + if db_track and confidence >= 0.7: + owned_tracks += 1 + print(f" ✅ Track found: '{track_name}'") + else: + print(f" ❌ Track missing: '{track_name}'") + + completion_ratio = owned_tracks / max(expected_tracks, 1) + is_complete = completion_ratio >= 0.9 + is_nearly_complete = completion_ratio >= 0.8 and completion_ratio < 0.9 + is_owned = owned_tracks > 0 + + print(f" 📊 EP '{ep_release.name}': {owned_tracks}/{expected_tracks} tracks ({int(completion_ratio * 100)}%)") + + return AlbumOwnershipStatus( + album_name=ep_release.name, + is_owned=is_owned, + is_complete=is_complete, + is_nearly_complete=is_nearly_complete, + owned_tracks=owned_tracks, + expected_tracks=expected_tracks, + completion_ratio=completion_ratio + ) + + except Exception as e: + print(f" ❌ Error checking EP '{ep_release.name}': {e}") + return AlbumOwnershipStatus( + album_name=ep_release.name, + is_owned=False, + is_complete=False, + is_nearly_complete=False, + owned_tracks=0, + expected_tracks=ep_release.total_tracks, + completion_ratio=0.0 + ) + class DatabaseLibraryWorker(QThread): """Background worker for checking database library with completeness info (replaces PlexLibraryWorker)""" library_checked = pyqtSignal(dict) # Dict of album_name -> AlbumOwnershipStatus @@ -1413,27 +1627,42 @@ class AlbumCard(QFrame): pass # Update status indicator - self.status_indicator.setStyleSheet(""" - QLabel { - background: rgba(255, 193, 7, 0.9); - border-radius: 12px; - color: white; - font-size: 12px; - font-weight: bold; - } - """) - self.status_indicator.setText("⏳") - self.status_indicator.setToolTip("Album downloading...") + try: + if hasattr(self, 'status_indicator') and self.status_indicator: + self.status_indicator.setStyleSheet(""" + QLabel { + background: rgba(255, 193, 7, 0.9); + border-radius: 12px; + color: white; + font-size: 12px; + font-weight: bold; + } + """) + self.status_indicator.setText("⏳") + self.status_indicator.setToolTip("Album downloading...") + except (RuntimeError, AttributeError): + # Object has been deleted or is invalid, skip + pass def update_download_progress(self, completed_tracks: int, total_tracks: int, percentage: int): """Update download progress display""" - progress_text = f"📥 Downloading\n{completed_tracks}/{total_tracks} tracks\n{percentage}%" - self.progress_overlay.setText(progress_text) - self.progress_overlay.show() + try: + progress_text = f"📥 Downloading\n{completed_tracks}/{total_tracks} tracks\n{percentage}%" + if hasattr(self, 'progress_overlay') and self.progress_overlay: + self.progress_overlay.setText(progress_text) + self.progress_overlay.show() + except (RuntimeError, AttributeError): + # Progress overlay has been deleted, skip + pass # Update status indicator with progress - self.status_indicator.setText(f"{percentage}%") - self.status_indicator.setToolTip(f"Downloading: {completed_tracks}/{total_tracks} tracks ({percentage}%)") + try: + if hasattr(self, 'status_indicator') and self.status_indicator: + self.status_indicator.setText(f"{percentage}%") + self.status_indicator.setToolTip(f"Downloading: {completed_tracks}/{total_tracks} tracks ({percentage}%)") + except (RuntimeError, AttributeError): + # Status indicator has been deleted, skip + pass def set_download_completed(self): """Set album card to download completed state""" @@ -1939,8 +2168,13 @@ class DownloadMissingAlbumTracksModal(QDialog): def on_begin_search_clicked(self): """Handle Begin Search button click - starts Plex analysis""" # Trigger UI updates on album card - if self.album_card: - self.album_card.set_download_in_progress() + try: + if self.album_card and hasattr(self.album_card, 'set_download_in_progress'): + self.album_card.set_download_in_progress() + except (RuntimeError, AttributeError): + # Album card object has been deleted, skip UI update + print("⚠️ Album card object deleted, skipping progress update") + pass self.begin_search_btn.hide() self.cancel_btn.show() @@ -2577,10 +2811,14 @@ class ArtistsPage(QWidget): # State management self.selected_artist = None self.current_albums = [] + self.all_releases = [] # Store all releases (albums + singles + eps) + self.albums_only = [] # Store only studio albums + self.singles_and_eps = [] # Store singles and EPs self.matched_count = 0 self.artist_search_worker = None self.album_fetch_worker = None self.plex_library_worker = None + self.singles_eps_worker = None # Album download tracking self.album_downloads = {} # {album_id: {total_tracks: X, completed_tracks: Y, active_downloads: [download_ids], album_card: card_ref}} @@ -2942,18 +3180,63 @@ class ArtistsPage(QWidget): albums_layout.setContentsMargins(20, 16, 20, 20) albums_layout.setSpacing(16) - # Albums header + # Albums header with filter toggle buttons albums_header_layout = QHBoxLayout() - albums_title = QLabel("Albums") + # Left side: Title and filter buttons + title_and_filters_layout = QHBoxLayout() + title_and_filters_layout.setSpacing(15) + + albums_title = QLabel("Releases") albums_title.setFont(QFont("Arial", 16, QFont.Weight.Bold)) albums_title.setStyleSheet("color: #ffffff;") - self.albums_status = QLabel("Loading albums...") + # Toggle buttons for filtering + self.current_filter = "albums" # Default filter + + self.albums_button = QPushButton("Albums") + self.albums_button.setCheckable(True) + self.albums_button.setChecked(True) + self.albums_button.clicked.connect(lambda: self.set_filter("albums")) + + self.singles_eps_button = QPushButton("Singles & EPs") + self.singles_eps_button.setCheckable(True) + self.singles_eps_button.clicked.connect(lambda: self.set_filter("singles_eps")) + + # Style the toggle buttons + toggle_button_style = """ + QPushButton { + background-color: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 12px; + padding: 6px 12px; + color: #b3b3b3; + font-size: 11px; + font-weight: 500; + } + QPushButton:checked { + background-color: rgba(29, 185, 84, 0.8); + border-color: rgba(29, 185, 84, 1.0); + color: #ffffff; + } + QPushButton:hover:!checked { + background-color: rgba(255, 255, 255, 0.15); + color: #ffffff; + } + """ + + self.albums_button.setStyleSheet(toggle_button_style) + self.singles_eps_button.setStyleSheet(toggle_button_style) + + title_and_filters_layout.addWidget(albums_title) + title_and_filters_layout.addWidget(self.albums_button) + title_and_filters_layout.addWidget(self.singles_eps_button) + + self.albums_status = QLabel("Loading releases...") self.albums_status.setFont(QFont("Arial", 11)) self.albums_status.setStyleSheet("color: #b3b3b3;") - albums_header_layout.addWidget(albums_title) + albums_header_layout.addLayout(title_and_filters_layout) albums_header_layout.addStretch() albums_header_layout.addWidget(self.albums_status) @@ -2994,6 +3277,61 @@ class ArtistsPage(QWidget): return widget + def set_filter(self, filter_type): + """Handle filter toggle button clicks""" + self.current_filter = filter_type + + # Update button states + self.albums_button.setChecked(filter_type == "albums") + self.singles_eps_button.setChecked(filter_type == "singles_eps") + + # Filter and display appropriate releases + if self.all_releases: # Only filter if we have data + self.filter_and_display_releases() + + def classify_releases(self, releases): + """Classify releases into albums, singles, and EPs""" + albums = [] + singles = [] + eps = [] + + for release in releases: + if release.album_type == 'album': + albums.append(release) + elif release.album_type == 'single': + if release.total_tracks == 1: + singles.append(release) + else: # 2+ tracks = EP + eps.append(release) + + return albums, singles, eps + + def filter_and_display_releases(self): + """Filter releases based on current filter and display them""" + if self.current_filter == "albums": + releases_to_show = self.albums_only + status_text = f"Found {len(releases_to_show)} albums" + else: # singles_eps + releases_to_show = self.singles_and_eps + singles_count = len([r for r in releases_to_show if r.total_tracks == 1]) + eps_count = len([r for r in releases_to_show if r.total_tracks > 1]) + status_text = f"Found {singles_count} singles, {eps_count} EPs" + + # Update status + self.albums_status.setText(status_text) + + # Store current releases for ownership checking + self.current_albums = releases_to_show + + # Display releases immediately (without ownership info) + self.display_albums(releases_to_show, set()) + + # Start appropriate ownership check in background + if self.current_filter == "albums": + self.start_plex_library_check(releases_to_show) # Use existing album-level matching + else: + self.start_singles_eps_library_check(releases_to_show) # New track-level matching + def perform_artist_search(self): """Perform artist search""" query = self.search_input.text().strip() @@ -3111,23 +3449,32 @@ class ArtistsPage(QWidget): self.album_fetch_worker.start() def on_albums_found(self, albums, artist): - """Handle album fetch results""" + """Handle album fetch results - now handles all release types""" if not albums: - self.albums_status.setText("No albums found") + self.albums_status.setText("No releases found") return - self.current_albums = albums - self.albums_status.setText(f"Found {len(albums)} releases • Checking Plex library...") - + print(f"📀 Processing {len(albums)} releases for {artist.name}") + + # Store all releases and classify them + self.all_releases = albums + self.albums_only, singles, eps = self.classify_releases(albums) + self.singles_and_eps = singles + eps + + print(f"📊 Classification: {len(self.albums_only)} albums, {len(singles)} singles, {len(eps)} EPs") # Initialize match counter for real-time updates self.matched_count = 0 - # Display albums immediately (without ownership info) - self.display_albums(albums, set()) + # Auto-switch to Singles & EPs if no albums available + if len(self.albums_only) == 0 and len(self.singles_and_eps) > 0: + print("📀 No albums found, automatically switching to Singles & EPs view") + self.current_filter = "singles_eps" + self.albums_button.setChecked(False) + self.singles_eps_button.setChecked(True) - # Start Plex library check in background - will update UI when complete - self.start_plex_library_check(albums) + # Display based on current filter + self.filter_and_display_releases() def display_albums(self, albums, ownership_info): """Display albums in the grid - supports legacy set or new dict of AlbumOwnershipStatus""" @@ -3170,6 +3517,32 @@ class ArtistsPage(QWidget): col = 0 row += 1 + def start_singles_eps_library_check(self, releases): + """Start track-level library check for singles and EPs""" + if not releases: + return + + # Update status to show we're checking + singles_count = len([r for r in releases if r.total_tracks == 1]) + eps_count = len([r for r in releases if r.total_tracks > 1]) + self.albums_status.setText(f"Found {singles_count} singles, {eps_count} EPs • Checking library...") + + # Show toast for library check start + if hasattr(self, 'toast_manager') and self.toast_manager: + self.toast_manager.info("Checking your library for owned singles and EPs...") + + # Stop any existing worker + if hasattr(self, 'singles_eps_worker') and self.singles_eps_worker: + self.singles_eps_worker.stop() + self.singles_eps_worker.wait() + + # Start new worker for track-level matching + self.singles_eps_worker = SinglesEPsLibraryWorker(releases, MusicMatchingEngine()) + self.singles_eps_worker.release_matched.connect(self.on_single_ep_matched) + self.singles_eps_worker.check_completed.connect(self.on_singles_eps_library_checked) + self.singles_eps_worker.check_failed.connect(self.on_singles_eps_check_failed) + self.singles_eps_worker.start() + def start_plex_library_check(self, albums): """Start Plex library check in background""" # Show toast for Plex check start @@ -3284,9 +3657,77 @@ class ArtistsPage(QWidget): # Display albums without ownership info self.display_albums(self.current_albums, set()) + def on_single_ep_matched(self, release_name, ownership_status): + """Handle real-time single/EP match results""" + if ownership_status.is_owned: + if ownership_status.is_complete: + print(f"🎯 Single/EP match: '{release_name}' (complete)") + else: + print(f"🎯 Single/EP match: '{release_name}' (partial {int(ownership_status.completion_ratio * 100)}%)") + + # Find the corresponding card and update it + for i in range(self.albums_grid_layout.count()): + item = self.albums_grid_layout.itemAt(i) + if item: + card = item.widget() + if isinstance(card, AlbumCard) and card.album.name == release_name: + card.update_ownership(ownership_status) + break + + def on_singles_eps_library_checked(self, release_statuses): + """Handle singles/EPs library check completion""" + print(f"📨 Singles/EPs check completed: {len(release_statuses)} statuses") + + # Count results for summary + complete_count = sum(1 for status in release_statuses.values() if status.is_complete) + nearly_complete_count = sum(1 for status in release_statuses.values() if status.is_nearly_complete) + partial_count = sum(1 for status in release_statuses.values() if status.is_owned and not status.is_complete and not status.is_nearly_complete) + missing_count = sum(1 for status in release_statuses.values() if not status.is_owned) + total_count = len(release_statuses) + + # Update status text with results + singles_count = len([r for r in self.singles_and_eps if r.total_tracks == 1]) + eps_count = len([r for r in self.singles_and_eps if r.total_tracks > 1]) + owned_count = complete_count + nearly_complete_count + partial_count + self.albums_status.setText(f"Found {singles_count} singles, {eps_count} EPs • {owned_count} owned") + + # Show toast notifications + if hasattr(self, 'toast_manager') and self.toast_manager: + if owned_count == 0: + self.toast_manager.info(f"No releases found in your library ({total_count} available for download)") + else: + if complete_count > 0 and (nearly_complete_count > 0 or partial_count > 0): + self.toast_manager.success(f"Found {complete_count} complete, {nearly_complete_count + partial_count} partial releases") + elif complete_count > 0: + self.toast_manager.success(f"Found {complete_count} complete releases") + else: + self.toast_manager.success(f"Found {owned_count} partial releases") + + print(f"✅ Singles/EPs check complete: {complete_count} complete, {nearly_complete_count} nearly complete, {partial_count} partial, {missing_count} missing out of {total_count} releases") + + # Update the display with the final ownership statuses + self.display_albums(self.current_albums, release_statuses) + + def on_singles_eps_check_failed(self, error): + """Handle singles/EPs check failure""" + print(f"❌ Singles/EPs library check failed: {error}") + + # Update status + singles_count = len([r for r in self.singles_and_eps if r.total_tracks == 1]) + eps_count = len([r for r in self.singles_and_eps if r.total_tracks > 1]) + self.albums_status.setText(f"Found {singles_count} singles, {eps_count} EPs • Check failed") + + # Show error toast + if hasattr(self, 'toast_manager') and self.toast_manager: + self.toast_manager.error("Library connection failed - cannot check owned releases") + + if self.current_albums: + # Display releases without ownership info + self.display_albums(self.current_albums, set()) + def on_album_fetch_failed(self, error): """Handle album fetch failure""" - self.albums_status.setText(f"Failed to load albums: {error}") + self.albums_status.setText(f"Failed to load releases: {error}") def on_album_download_requested(self, album: Album): """Handle album download request from an AlbumCard using new modal system.""" @@ -3317,31 +3758,33 @@ class ArtistsPage(QWidget): # Check if there's already an active session for this album if album.id in self.active_album_sessions: - print(f"🔄 Resuming existing download session for album: {album.name}") existing_session = self.active_album_sessions[album.id] existing_modal = existing_session.get('modal') - # Show toast notification for already active session - if hasattr(self, 'toast_manager') and self.toast_manager: - self.toast_manager.info(f"Downloads already in progress for '{album.name}'") - # Check if the modal still exists and is valid - if existing_modal and not existing_modal.isVisible(): - try: - # Show the existing modal - existing_modal.show() + try: + if existing_modal and existing_modal.isVisible(): + print(f"🔄 Resuming existing active modal for album: {album.name}") + # Modal is already visible and active, just bring it to front existing_modal.activateWindow() existing_modal.raise_() + + # Show toast notification + if hasattr(self, 'toast_manager') and self.toast_manager: + self.toast_manager.info(f"Downloads already in progress for '{album.name}'") return - except RuntimeError: - # Modal was deleted, remove from sessions - print("⚠️ Existing modal was deleted, creating new session") + elif existing_modal: + # Modal exists but is not visible - might be finished/cancelled + print("⚠️ Found hidden modal - likely finished or cancelled, creating fresh modal") del self.active_album_sessions[album.id] - elif existing_modal and existing_modal.isVisible(): - # Modal is already visible, just bring it to front - existing_modal.activateWindow() - existing_modal.raise_() - return + else: + # No modal reference, clean up stale session + print("⚠️ Stale session found, cleaning up") + del self.active_album_sessions[album.id] + except RuntimeError: + # Modal was deleted, remove from sessions + print("⚠️ Existing modal was deleted, creating fresh session") + del self.active_album_sessions[album.id] print("🚀 Fetching album tracks and creating DownloadMissingAlbumTracksModal...") @@ -3422,13 +3865,13 @@ class ArtistsPage(QWidget): # Clean up the session when modal is definitely closing if album_id in self.active_album_sessions: + # Always remove session when modal closes (whether accepted or cancelled) + # This ensures a fresh modal is created when user clicks the album again + del self.active_album_sessions[album_id] if result == 1: # QDialog.Accepted = 1 (downloads completed or all tracks exist) - # Remove session since downloads are complete - del self.active_album_sessions[album_id] print(f"🗑️ Removed completed session for album {album_id}") else: - # Keep session for resumption, but hide the modal for now - print(f"💾 Keeping session for album {album_id} for potential resumption") + print(f"🗑️ Removed cancelled session for album {album_id} - fresh modal will be created on next click") if album_card: try: @@ -4263,6 +4706,11 @@ class ArtistsPage(QWidget): else: print(" ⚠️ Plex library worker did not stop within timeout") self.plex_library_worker = None + + if hasattr(self, 'singles_eps_worker') and self.singles_eps_worker: + self.singles_eps_worker.stop() + self.singles_eps_worker.wait() + self.singles_eps_worker = None workers_stopped += 1 if workers_stopped > 0: