From 78d037cba47c9ee8cf614d74eb43163630fbca76 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Wed, 6 Aug 2025 10:04:10 -0700 Subject: [PATCH] include deep search for artists. --- .../music_database.cpython-312.pyc | Bin 33414 -> 37717 bytes database/music_database.py | 109 +++++ ui/pages/__pycache__/artists.cpython-312.pyc | Bin 202036 -> 215573 bytes ui/pages/artists.py | 387 ++++++++++++++---- 4 files changed, 420 insertions(+), 76 deletions(-) diff --git a/database/__pycache__/music_database.cpython-312.pyc b/database/__pycache__/music_database.cpython-312.pyc index b392b24c0a4971ebb587c79381ad0c318fa8a622..a015a52762828526ad8023f4b336be0c3124e0f5 100644 GIT binary patch delta 4998 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 J8EraI^&i0!hK~RM delta 1562 zcmZ9MYfPI}7{_~F+EQq#7w!TrSLe&jbPz_O7Kww&a56@+K{_`mw51!pz-gHpml@p# z3^Fc#bjZw;cW&ZBcOx6Y}!nKQ{mHykYbl?kSrx%iYggRapdYfW{=)e;#G2G5U=94AJaQapD$F)ggbR6gQ>z| z-)v@ksir)SwNOocj^O1zhWveUw`$?F@{4Z;YJe7?Q~dQ%yCKgh?O2N6!o z48D7Ty&_y?Q7q5EMx1SYKaEsxH+vv>fo`!>?K7}_)ZnlebVBKoh#6hIy`G?(7eYw_ z{In~WPLqxz77+7}`sCX6pz2)-)uB1w4ip0&fD3p5I1I!akR5hfAjb5)E*|0Cv{YRs zuGfE~->9{0P6(=<=*QVZzy=_ps@siO=4YYp0s}h_~z=lM0_dH`qX-8}GmJ`yUS`&&yX)*o5(1BpQLoIbe zsR%R{?Z}~DTP?;BtOn?x)-C_3+|gz-nup*!35<&7{ZBAsL_12awX1a#FtyZ)L)WGf zg=>9M>rEHJ5g!+9A~y7XfX(X~Oaq;8j4IK{Kp?XzB3lR|^-Py00_SnABAx zKT8+ADjT6Qfdcx&dxuTa-NB5g&t}rcc} z{Vs&n{HI|T0vBZK_6=*5@QV;h5?aMS?#m0HM);Gn@i#@!BiPEDtlGq}p50+F+OQ#%`cAvC?YM1pZ z9N7dc!f)lf*Mx5J4HnBc!N_1)a?B8pxFTV?GH6eY8M!Ci?~m|BT5#mkoxw`wsv;g7 z+Rj)L^`B6)mJs55gk#;Or|_&iu9ne Tuple[int, int, bool]: + """ + Check if we have all tracks for an album. + Returns (owned_tracks, expected_tracks, is_complete) + """ + try: + conn = self._get_connection() + cursor = conn.cursor() + + # Get actual track count in our database + cursor.execute("SELECT COUNT(*) FROM tracks WHERE album_id = ?", (album_id,)) + owned_tracks = cursor.fetchone()[0] + + # Get expected track count from album table + cursor.execute("SELECT track_count FROM albums WHERE id = ?", (album_id,)) + result = cursor.fetchone() + + if not result: + return 0, 0, False + + stored_track_count = result[0] + + # Use provided expected count if available, otherwise use stored count + expected_tracks = expected_track_count if expected_track_count is not None else stored_track_count + + # Determine completeness with refined thresholds + if expected_tracks and expected_tracks > 0: + completion_ratio = owned_tracks / expected_tracks + # Complete: 90%+, Nearly Complete: 80-89%, Partial: <80% + is_complete = completion_ratio >= 0.9 and owned_tracks > 0 + else: + # Fallback: if we have any tracks, consider it owned + is_complete = owned_tracks > 0 + + return owned_tracks, expected_tracks or 0, is_complete + + except Exception as e: + logger.error(f"Error checking album completeness for album_id {album_id}: {e}") + return 0, 0, False + + def check_album_exists_with_completeness(self, title: str, artist: str, expected_track_count: Optional[int] = None, confidence_threshold: float = 0.8) -> Tuple[Optional[DatabaseAlbum], float, int, int, bool]: + """ + Check if an album exists in the database with completeness information. + Returns (album, confidence, owned_tracks, expected_tracks, is_complete) + """ + try: + # First find the album match + album, confidence = self.check_album_exists(title, artist, confidence_threshold) + + if not album: + return None, 0.0, 0, 0, False + + # Now check completeness + owned_tracks, expected_tracks, is_complete = self.check_album_completeness(album.id, expected_track_count) + + return album, confidence, owned_tracks, expected_tracks, is_complete + + except Exception as e: + logger.error(f"Error checking album existence with completeness for '{title}' by '{artist}': {e}") + return None, 0.0, 0, 0, False + + def get_album_completion_stats(self, artist_name: str) -> Dict[str, int]: + """ + Get completion statistics for all albums by an artist. + Returns dict with counts of complete, partial, and missing albums. + """ + try: + conn = self._get_connection() + cursor = conn.cursor() + + # Get all albums by this artist with track counts + cursor.execute(""" + SELECT albums.id, albums.track_count, COUNT(tracks.id) as actual_tracks + FROM albums + JOIN artists ON albums.artist_id = artists.id + LEFT JOIN tracks ON albums.id = tracks.album_id + WHERE artists.name LIKE ? + GROUP BY albums.id, albums.track_count + """, (f"%{artist_name}%",)) + + results = cursor.fetchall() + stats = { + 'complete': 0, # >=90% of tracks + 'nearly_complete': 0, # 80-89% of tracks + 'partial': 0, # 1-79% of tracks + 'missing': 0, # 0% of tracks + 'total': len(results) + } + + for row in results: + expected_tracks = row['track_count'] or 1 # Avoid division by zero + actual_tracks = row['actual_tracks'] + completion_ratio = actual_tracks / expected_tracks + + if actual_tracks == 0: + stats['missing'] += 1 + elif completion_ratio >= 0.9: + stats['complete'] += 1 + elif completion_ratio >= 0.8: + stats['nearly_complete'] += 1 + else: + stats['partial'] += 1 + + return stats + + except Exception as e: + logger.error(f"Error getting album completion stats for artist '{artist_name}': {e}") + return {'complete': 0, 'nearly_complete': 0, 'partial': 0, 'missing': 0, 'total': 0} + def get_database_info(self) -> Dict[str, Any]: """Get comprehensive database information""" try: diff --git a/ui/pages/__pycache__/artists.cpython-312.pyc b/ui/pages/__pycache__/artists.cpython-312.pyc index 4adfb4abcb5741f5d3f4523e8cf698a4c0e9bcfd..62a7ca02202982da26ae6a09522c88f266a74f7a 100644 GIT binary patch delta 35545 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 delta 25920 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 str: + """Get completion level as string""" + if not self.is_owned: + return "missing" + elif self.completion_ratio >= 0.9: + return "complete" + elif self.completion_ratio >= 0.8: + return "nearly_complete" + else: + return "partial" + class DownloadCompletionWorkerSignals(QObject): """Signals for the download completion worker""" completed = pyqtSignal(object, str) # download_item, organized_path @@ -423,9 +446,9 @@ class AlbumStatusProcessingWorker(QRunnable): class DatabaseLibraryWorker(QThread): - """Background worker for checking database library (replaces PlexLibraryWorker)""" - library_checked = pyqtSignal(set) # Set of owned album names (final result) - album_matched = pyqtSignal(str) # Individual album match (album name) + """Background worker for checking database library with completeness info (replaces PlexLibraryWorker)""" + library_checked = pyqtSignal(dict) # Dict of album_name -> AlbumOwnershipStatus + album_matched = pyqtSignal(str, object) # album_name, AlbumOwnershipStatus check_failed = pyqtSignal(str) def __init__(self, albums, matching_engine): @@ -440,8 +463,8 @@ class DatabaseLibraryWorker(QThread): def run(self): try: - print("🔍 Starting robust database album matching...") - owned_albums = set() + print("🔍 Starting robust database album matching with completeness checking...") + album_statuses = {} # album_name -> AlbumOwnershipStatus # Get database instance db = get_database() @@ -472,8 +495,14 @@ class DatabaseLibraryWorker(QThread): # Try different artist combinations artists_to_try = spotify_album.artists[:2] if spotify_album.artists else [""] - best_match = None + best_album = None best_confidence = 0.0 + best_owned_tracks = 0 + best_expected_tracks = 0 + best_is_complete = False + + # Get expected track count from Spotify + expected_track_count = getattr(spotify_album, 'total_tracks', None) # Search with different combinations for artist in artists_to_try: @@ -486,61 +515,112 @@ class DatabaseLibraryWorker(QThread): if self._stop_requested: return - # Search database for this combination (cleaned artist) + # Search database for this combination with completeness info print(f" 🔍 Searching database: album='{album_name}', artist='{artist_clean}'") - db_album, confidence = db.check_album_exists(album_name, artist_clean, confidence_threshold=0.7) + db_album, confidence, owned_tracks, expected_tracks, is_complete = db.check_album_exists_with_completeness( + album_name, artist_clean, expected_track_count, confidence_threshold=0.7 + ) if db_album and confidence > best_confidence: - best_match = db_album + best_album = db_album best_confidence = confidence - print(f" 📀 Found database match with confidence {confidence:.2f}") + best_owned_tracks = owned_tracks + best_expected_tracks = expected_tracks + best_is_complete = is_complete + print(f" 📀 Found database match with confidence {confidence:.2f} ({owned_tracks}/{expected_tracks} tracks)") # If we have a very confident match, we can stop searching for this album if confidence >= 0.95: break - # Backup search with original uncleaned artist name (for cases like "Tyler, The Creator") + # Backup search with original uncleaned artist name if not db_album and artist and artist != artist_clean: print(f" 🔄 Backup search with original artist: album='{album_name}', artist='{artist}'") - db_album_backup, confidence_backup = db.check_album_exists(album_name, artist, confidence_threshold=0.7) + db_album_backup, confidence_backup, owned_backup, expected_backup, complete_backup = db.check_album_exists_with_completeness( + album_name, artist, expected_track_count, confidence_threshold=0.7 + ) if db_album_backup and confidence_backup > best_confidence: - best_match = db_album_backup + best_album = db_album_backup best_confidence = confidence_backup - print(f" 📀 Found backup match with confidence {confidence_backup:.2f}") + best_owned_tracks = owned_backup + best_expected_tracks = expected_backup + best_is_complete = complete_backup + print(f" 📀 Found backup match with confidence {confidence_backup:.2f} ({owned_backup}/{expected_backup} tracks)") - # Additional fallback: remove commas (Tyler, The Creator -> Tyler The Creator) + # Additional fallback: remove commas if not db_album_backup and ',' in artist: artist_no_comma = artist.replace(',', '').strip() - # Clean up multiple spaces that might result from comma removal artist_no_comma = ' '.join(artist_no_comma.split()) print(f" 🔄 Comma-removal fallback: album='{album_name}', artist='{artist_no_comma}'") - db_album_comma, confidence_comma = db.check_album_exists(album_name, artist_no_comma, confidence_threshold=0.7) + db_album_comma, confidence_comma, owned_comma, expected_comma, complete_comma = db.check_album_exists_with_completeness( + album_name, artist_no_comma, expected_track_count, confidence_threshold=0.7 + ) if db_album_comma and confidence_comma > best_confidence: - best_match = db_album_comma + best_album = db_album_comma best_confidence = confidence_comma - print(f" 📀 Found comma-removal match with confidence {confidence_comma:.2f}") + best_owned_tracks = owned_comma + best_expected_tracks = expected_comma + best_is_complete = complete_comma + print(f" 📀 Found comma-removal match with confidence {confidence_comma:.2f} ({owned_comma}/{expected_comma} tracks)") # If we found a very confident match, stop searching other artists if best_confidence >= 0.95: break - # Check final result - if best_match and best_confidence >= 0.8: - owned_albums.add(spotify_album.name) - print(f"✅ Database match found: '{spotify_album.name}' -> '{best_match.title}' (confidence: {best_confidence:.2f})") + # Create ownership status + if best_album and best_confidence >= 0.8: + completion_ratio = best_owned_tracks / max(best_expected_tracks, 1) + is_nearly_complete = completion_ratio >= 0.8 and completion_ratio < 0.9 + status = AlbumOwnershipStatus( + album_name=spotify_album.name, + is_owned=True, + is_complete=best_is_complete, + is_nearly_complete=is_nearly_complete, + owned_tracks=best_owned_tracks, + expected_tracks=best_expected_tracks, + completion_ratio=completion_ratio + ) + album_statuses[spotify_album.name] = status + + # Log detailed result + if best_is_complete: + print(f"✅ Complete album: '{spotify_album.name}' -> '{best_album.title}' ({best_owned_tracks}/{best_expected_tracks} tracks)") + elif is_nearly_complete: + print(f"🔵 Nearly complete album: '{spotify_album.name}' -> '{best_album.title}' ({best_owned_tracks}/{best_expected_tracks} tracks)") + else: + print(f"⚠️ Partial album: '{spotify_album.name}' -> '{best_album.title}' ({best_owned_tracks}/{best_expected_tracks} tracks)") + # Emit individual match for real-time UI update - self.album_matched.emit(spotify_album.name) + self.album_matched.emit(spotify_album.name, status) else: - if best_match: + # Create status for missing album + status = AlbumOwnershipStatus( + album_name=spotify_album.name, + is_owned=False, + is_complete=False, + is_nearly_complete=False, + owned_tracks=0, + expected_tracks=expected_track_count or 0, + completion_ratio=0.0 + ) + album_statuses[spotify_album.name] = status + + if best_album: print(f"❌ No confident match for '{spotify_album.name}' (best: {best_confidence:.2f})") else: print(f"❌ No database candidates found for '{spotify_album.name}'") - print(f"🎯 Final result: {len(owned_albums)} owned albums out of {len(self.albums)}") - print(f"🚀 Emitting signal with owned_albums: {list(owned_albums)}") - self.library_checked.emit(owned_albums) + # Count results for summary + complete_count = sum(1 for status in album_statuses.values() if status.is_complete) + nearly_complete_count = sum(1 for status in album_statuses.values() if status.is_nearly_complete) + partial_count = sum(1 for status in album_statuses.values() if status.is_owned and not status.is_complete and not status.is_nearly_complete) + missing_count = sum(1 for status in album_statuses.values() if not status.is_owned) + + print(f"🎯 Final result: {complete_count} complete, {nearly_complete_count} nearly complete, {partial_count} partial, {missing_count} missing out of {len(self.albums)} albums") + print(f"🚀 Emitting detailed album statuses") + self.library_checked.emit(album_statuses) except Exception as e: if not self._stop_requested: @@ -985,6 +1065,7 @@ class AlbumCard(QFrame): super().__init__(parent) self.album = album self.is_owned = is_owned + self.ownership_status = None # Will store AlbumOwnershipStatus self.setup_ui() self.load_album_image() @@ -1146,18 +1227,63 @@ class AlbumCard(QFrame): def update_status_indicator(self): """Update the permanent status indicator""" if self.is_owned: - self.status_indicator.setStyleSheet(""" - QLabel { - background: rgba(29, 185, 84, 0.9); - border-radius: 12px; - color: white; - font-size: 14px; - font-weight: bold; - } - """) - self.status_indicator.setText("✓") - self.status_indicator.setToolTip("Album owned in Plex") + if self.ownership_status and self.ownership_status.is_complete: + # Complete album (90%+) - green checkmark + self.status_indicator.setStyleSheet(""" + QLabel { + background: rgba(29, 185, 84, 0.9); + border-radius: 12px; + color: white; + font-size: 14px; + font-weight: bold; + } + """) + self.status_indicator.setText("✓") + self.status_indicator.setToolTip(f"Complete album - {self.ownership_status.owned_tracks}/{self.ownership_status.expected_tracks} tracks ({int(self.ownership_status.completion_ratio * 100)}%)") + elif self.ownership_status and self.ownership_status.is_nearly_complete: + # Nearly complete album (80-89%) - blue half-circle + self.status_indicator.setStyleSheet(""" + QLabel { + background: rgba(13, 110, 253, 0.9); + border-radius: 12px; + color: white; + font-size: 14px; + font-weight: bold; + } + """) + self.status_indicator.setText("◐") + percentage = int(self.ownership_status.completion_ratio * 100) + missing_tracks = self.ownership_status.expected_tracks - self.ownership_status.owned_tracks + self.status_indicator.setToolTip(f"Nearly complete - {self.ownership_status.owned_tracks}/{self.ownership_status.expected_tracks} tracks ({percentage}%) • {missing_tracks} missing") + elif self.ownership_status and not self.ownership_status.is_complete and not self.ownership_status.is_nearly_complete: + # Partial album (<80%) - yellow warning + self.status_indicator.setStyleSheet(""" + QLabel { + background: rgba(255, 193, 7, 0.9); + border-radius: 12px; + color: #212529; + font-size: 14px; + font-weight: bold; + } + """) + self.status_indicator.setText("⚠") + percentage = int(self.ownership_status.completion_ratio * 100) + self.status_indicator.setToolTip(f"Partial album - {self.ownership_status.owned_tracks}/{self.ownership_status.expected_tracks} tracks ({percentage}%)") + else: + # Fallback for legacy owned albums without detailed status + self.status_indicator.setStyleSheet(""" + QLabel { + background: rgba(29, 185, 84, 0.9); + border-radius: 12px; + color: white; + font-size: 14px; + font-weight: bold; + } + """) + self.status_indicator.setText("✓") + self.status_indicator.setToolTip("Album owned in library") else: + # Missing album - red download icon self.status_indicator.setStyleSheet(""" QLabel { background: rgba(220, 53, 69, 0.8); @@ -1170,10 +1296,22 @@ class AlbumCard(QFrame): self.status_indicator.setText("📥") self.status_indicator.setToolTip("Album available for download") - def update_ownership(self, is_owned: bool): - """Update ownership status and refresh UI""" + def update_ownership(self, ownership_info): + """Update ownership status and refresh UI - supports bool or AlbumOwnershipStatus""" + if isinstance(ownership_info, bool): + # Legacy support for simple boolean + is_owned = ownership_info + self.ownership_status = None + else: + # New detailed ownership status + is_owned = ownership_info.is_owned + self.ownership_status = ownership_info + if self.is_owned != is_owned: # Only log if status actually changed - print(f"🔄 '{self.album.name}' ownership: {self.is_owned} -> {is_owned}") + if self.ownership_status: + print(f"🔄 '{self.album.name}' ownership: {self.is_owned} -> {is_owned} (complete: {self.ownership_status.is_complete})") + else: + print(f"🔄 '{self.album.name}' ownership: {self.is_owned} -> {is_owned}") self.is_owned = is_owned @@ -1182,18 +1320,64 @@ class AlbumCard(QFrame): # Update the hover overlay if self.is_owned: - self.overlay.setStyleSheet(""" - QLabel { - background: rgba(29, 185, 84, 0.8); - border-radius: 6px; - color: white; - font-size: 24px; - font-weight: bold; - } - """) - self.overlay.setText("✓") - self.overlay.setCursor(Qt.CursorShape.ArrowCursor) + if self.ownership_status and self.ownership_status.is_complete: + # Complete album (90%+) - green checkmark overlay + self.overlay.setStyleSheet(""" + QLabel { + background: rgba(29, 185, 84, 0.8); + border-radius: 6px; + color: white; + font-size: 16px; + font-weight: bold; + } + """) + self.overlay.setText("✓ Complete\nVerify tracks") + self.overlay.setCursor(Qt.CursorShape.PointingHandCursor) + elif self.ownership_status and self.ownership_status.is_nearly_complete: + # Nearly complete album (80-89%) - blue overlay + self.overlay.setStyleSheet(""" + QLabel { + background: rgba(13, 110, 253, 0.8); + border-radius: 6px; + color: white; + font-size: 14px; + font-weight: bold; + } + """) + percentage = int(self.ownership_status.completion_ratio * 100) + missing_tracks = self.ownership_status.expected_tracks - self.ownership_status.owned_tracks + self.overlay.setText(f"◐ {percentage}%\nGet {missing_tracks} missing") + self.overlay.setCursor(Qt.CursorShape.PointingHandCursor) + elif self.ownership_status: + # Partial album (<80%) - yellow warning overlay + self.overlay.setStyleSheet(""" + QLabel { + background: rgba(255, 193, 7, 0.8); + border-radius: 6px; + color: #212529; + font-size: 14px; + font-weight: bold; + } + """) + percentage = int(self.ownership_status.completion_ratio * 100) + missing_tracks = self.ownership_status.expected_tracks - self.ownership_status.owned_tracks + self.overlay.setText(f"⚠ {percentage}%\nGet {missing_tracks} missing") + self.overlay.setCursor(Qt.CursorShape.PointingHandCursor) + else: + # Legacy complete album - green checkmark overlay + self.overlay.setStyleSheet(""" + QLabel { + background: rgba(29, 185, 84, 0.8); + border-radius: 6px; + color: white; + font-size: 16px; + font-weight: bold; + } + """) + self.overlay.setText("✓ Complete\nVerify tracks") + self.overlay.setCursor(Qt.CursorShape.PointingHandCursor) else: + # Missing album - download overlay self.overlay.setStyleSheet(""" QLabel { background: rgba(0, 0, 0, 0.7); @@ -1294,6 +1478,7 @@ class AlbumCard(QFrame): # Don't allow downloads if already downloading if (event.button() == Qt.MouseButton.LeftButton and not self.progress_overlay.isVisible()): + print(f"🖱️ Album card clicked: {self.album.name} (owned: {self.is_owned})") self.download_requested.emit(self.album) super().mousePressEvent(event) @@ -2909,9 +3094,14 @@ class ArtistsPage(QWidget): # Start Plex library check in background - will update UI when complete self.start_plex_library_check(albums) - def display_albums(self, albums, owned_albums): - """Display albums in the grid""" - print(f"🎨 Displaying {len(albums)} albums, {len(owned_albums)} owned") + def display_albums(self, albums, ownership_info): + """Display albums in the grid - supports legacy set or new dict of AlbumOwnershipStatus""" + + # Handle both old format (set of owned album names) and new format (dict of statuses) + if isinstance(ownership_info, dict): + print(f"🎨 Displaying {len(albums)} albums with detailed ownership info") + else: + print(f"🎨 Displaying {len(albums)} albums, {len(ownership_info)} owned") # Clear existing albums self.clear_albums() @@ -2920,11 +3110,23 @@ class ArtistsPage(QWidget): max_cols = 5 for album in albums: - is_owned = album.name in owned_albums + if isinstance(ownership_info, dict): + # New format - use detailed ownership status + status = ownership_info.get(album.name) + if status: + card = AlbumCard(album, status.is_owned) + card.update_ownership(status) + else: + # Album not found in statuses - assume not owned + card = AlbumCard(album, False) + else: + # Legacy format - simple set of owned album names + is_owned = album.name in ownership_info + card = AlbumCard(album, is_owned) - card = AlbumCard(album, is_owned) - if not is_owned: - card.download_requested.connect(self.on_album_download_requested) + # Connect download signal for all albums - we can download missing tracks for partial albums + # and missing albums, but complete albums will show a different modal + card.download_requested.connect(self.on_album_download_requested) self.albums_grid_layout.addWidget(card, row, col) @@ -2952,33 +3154,60 @@ class ArtistsPage(QWidget): self.plex_library_worker.check_failed.connect(self.on_plex_library_check_failed) self.plex_library_worker.start() - def on_plex_library_checked(self, owned_albums): - """Handle final Plex library check completion""" - print(f"📨 Plex check completed: {len(owned_albums)} total matches") + def on_plex_library_checked(self, album_statuses): + """Handle final database library check completion with detailed status info""" + print(f"📨 Database check completed: {len(album_statuses)} album statuses") if not self.current_albums: print("📨 No current albums, skipping final update") return - # Update final status message - owned_count = len(owned_albums) + # Count different types of ownership + complete_count = sum(1 for status in album_statuses.values() if status.is_complete) + nearly_complete_count = sum(1 for status in album_statuses.values() if status.is_nearly_complete) + partial_count = sum(1 for status in album_statuses.values() if status.is_owned and not status.is_complete and not status.is_nearly_complete) + missing_count = sum(1 for status in album_statuses.values() if not status.is_owned) total_count = len(self.current_albums) - missing_count = total_count - owned_count - self.albums_status.setText(f"Found {total_count} albums • {owned_count} owned • {missing_count} available for download") + # Update final status message with all categories + status_parts = [] + if complete_count > 0: + status_parts.append(f"{complete_count} complete") + if nearly_complete_count > 0: + status_parts.append(f"{nearly_complete_count} nearly complete") + if partial_count > 0: + status_parts.append(f"{partial_count} partial") + if missing_count > 0: + status_parts.append(f"{missing_count} missing") - # Show toast with Plex check results + self.albums_status.setText(f"Found {total_count} albums • " + " • ".join(status_parts)) + + # Show toast with library check results if hasattr(self, 'toast_manager') and self.toast_manager: + owned_count = complete_count + nearly_complete_count + partial_count if owned_count == 0: - self.toast_manager.info(f"No albums found in your Plex library ({total_count} available for download)") + self.toast_manager.info(f"No albums found in your library ({total_count} available for download)") + elif nearly_complete_count > 0 or partial_count > 0: + if nearly_complete_count > 0: + self.toast_manager.success(f"Found {complete_count} complete, {nearly_complete_count} nearly complete, {partial_count} partial albums out of {total_count}") + else: + self.toast_manager.success(f"Found {complete_count} complete, {partial_count} partial albums out of {total_count}") else: - self.toast_manager.success(f"Found {owned_count} of {total_count} albums in your Plex library") + self.toast_manager.success(f"Found {complete_count} complete albums out of {total_count}") + + print(f"✅ Database check complete: {complete_count} complete, {nearly_complete_count} nearly complete, {partial_count} partial, {missing_count} missing out of {total_count} albums") - print(f"✅ Plex check complete: {owned_count}/{total_count} albums owned") + # Update the album display with the final ownership statuses + self.display_albums(self.current_albums, album_statuses) - def on_album_matched(self, album_name): - """Handle individual album match for real-time UI update""" - print(f"🎯 Real-time match: '{album_name}'") + def on_album_matched(self, album_name, ownership_status): + """Handle individual album match for real-time UI update with detailed status""" + if ownership_status.is_complete: + print(f"🎯 Real-time match: '{album_name}' (complete)") + elif ownership_status.is_nearly_complete: + print(f"🎯 Real-time match: '{album_name}' (nearly complete {int(ownership_status.completion_ratio * 100)}%)") + else: + print(f"🎯 Real-time match: '{album_name}' (partial {int(ownership_status.completion_ratio * 100)}%)") # Update match counter self.matched_count += 1 @@ -2995,8 +3224,14 @@ class ArtistsPage(QWidget): if item and item.widget(): album_card = item.widget() if hasattr(album_card, 'album') and album_card.album.name == album_name: - print(f"🔄 Real-time update: '{album_name}' -> owned") - album_card.update_ownership(True) + if ownership_status.is_complete: + status_text = "complete" + elif ownership_status.is_nearly_complete: + status_text = f"nearly complete ({int(ownership_status.completion_ratio * 100)}%)" + else: + status_text = f"partial ({int(ownership_status.completion_ratio * 100)}%)" + print(f"🔄 Real-time update: '{album_name}' -> {status_text}") + album_card.update_ownership(ownership_status) break def on_plex_library_check_failed(self, error):