From 6c6651b879620e5ca8c16d279a4425bb752dbdbd Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Thu, 19 Feb 2026 10:20:30 -0800 Subject: [PATCH] Add format summary tags to library release cards, wishlist modal, and artist hero --- database/music_database.py | 69 ++++++++++++++++++++++++---------- ui/pages/artists.py | 6 +-- web_server.py | 55 +++++++++++++++++---------- webui/static/script.js | 77 +++++++++++++++++++++++++++++++++++++- webui/static/style.css | 48 ++++++++++++++++++++++++ 5 files changed, 211 insertions(+), 44 deletions(-) diff --git a/database/music_database.py b/database/music_database.py index ced24eb9..71bc0ccb 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -2163,12 +2163,13 @@ class MusicDatabase: return max(0.0, similarity) - def check_album_completeness(self, album_id: int, expected_track_count: Optional[int] = None) -> Tuple[int, int, bool]: + def check_album_completeness(self, album_id: int, expected_track_count: Optional[int] = None) -> Tuple[int, int, bool, List[str]]: """ Check if we have all tracks for an album. Merges counts across split album entries (same title+year+artist) so that albums split by the media server (e.g. Navidrome) are treated as one. - Returns (owned_tracks, expected_tracks, is_complete) + Returns (owned_tracks, expected_tracks, is_complete, formats) + where formats is a list of distinct format strings like ["FLAC"] or ["FLAC", "MP3-320"] """ try: conn = self._get_connection() @@ -2179,7 +2180,7 @@ class MusicDatabase: album_info = cursor.fetchone() if not album_info: - return 0, 0, False + return 0, 0, False, [] # Find all album IDs that share the same title, year, and artist # This merges split albums (e.g. Navidrome splitting one album into multiple entries) @@ -2201,7 +2202,7 @@ class MusicDatabase: # 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 @@ -2210,34 +2211,64 @@ class MusicDatabase: else: # Fallback: if we have any tracks, consider it owned is_complete = owned_tracks > 0 - - return owned_tracks, expected_tracks or 0, is_complete - + + # Get distinct format strings for owned tracks + formats = self._get_album_formats(cursor, sibling_ids) + + return owned_tracks, expected_tracks or 0, is_complete, formats + except Exception as e: logger.error(f"Error checking album completeness for album_id {album_id}: {e}") - return 0, 0, False + return 0, 0, False, [] + + def _get_album_formats(self, cursor, sibling_ids: list) -> List[str]: + """Get distinct format strings for tracks in the given album IDs.""" + import os + try: + placeholders = ','.join('?' for _ in sibling_ids) + cursor.execute(f""" + SELECT DISTINCT file_path, bitrate FROM tracks + WHERE album_id IN ({placeholders}) AND file_path IS NOT NULL + """, sibling_ids) + + format_set = set() + for row in cursor.fetchall(): + ext = os.path.splitext(row['file_path'] or '')[1].lstrip('.').upper() + if not ext: + continue + if ext == 'MP3' and row['bitrate']: + format_set.add(f"MP3-{row['bitrate']}") + elif ext == 'MP3': + format_set.add('MP3') + else: + # FLAC, OGG, AAC, etc. โ€” just the extension + format_set.add(ext) + return sorted(format_set) + except Exception as e: + logger.error(f"Error getting album formats: {e}") + return [] - def check_album_exists_with_completeness(self, title: str, artist: str, expected_track_count: Optional[int] = None, confidence_threshold: float = 0.8, server_source: Optional[str] = None) -> Tuple[Optional[DatabaseAlbum], float, int, int, bool]: + def check_album_exists_with_completeness(self, title: str, artist: str, expected_track_count: Optional[int] = None, confidence_threshold: float = 0.8, server_source: Optional[str] = None) -> Tuple[Optional[DatabaseAlbum], float, int, int, bool, List[str]]: """ Check if an album exists in the database with completeness information. Enhanced to handle edition matching (standard <-> deluxe variants). - Returns (album, confidence, owned_tracks, expected_tracks, is_complete) + Returns (album, confidence, owned_tracks, expected_tracks, is_complete, formats) """ try: # Try enhanced edition-aware matching first with expected track count for Smart Edition Matching album, confidence = self.check_album_exists_with_editions(title, artist, confidence_threshold, expected_track_count, server_source) - + 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 - + return None, 0.0, 0, 0, False, [] + + # Now check completeness (includes formats) + owned_tracks, expected_tracks, is_complete, formats = self.check_album_completeness(album.id, expected_track_count) + + return album, confidence, owned_tracks, expected_tracks, is_complete, formats + 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 + return None, 0.0, 0, 0, False, [] def check_album_exists_with_editions(self, title: str, artist: str, confidence_threshold: float = 0.8, expected_track_count: Optional[int] = None, server_source: Optional[str] = None) -> Tuple[Optional[DatabaseAlbum], float]: """ diff --git a/ui/pages/artists.py b/ui/pages/artists.py index d658c786..abb53942 100644 --- a/ui/pages/artists.py +++ b/ui/pages/artists.py @@ -769,7 +769,7 @@ class DatabaseLibraryWorker(QThread): # Search database for this combination with completeness info print(f" ๐Ÿ” Searching database: album='{album_name}', artist='{artist_clean}'") - db_album, confidence, owned_tracks, expected_tracks, is_complete = db.check_album_exists_with_completeness( + 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, server_source=active_server ) @@ -788,7 +788,7 @@ class DatabaseLibraryWorker(QThread): # 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, owned_backup, expected_backup, complete_backup = db.check_album_exists_with_completeness( + 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, server_source=active_server ) @@ -805,7 +805,7 @@ class DatabaseLibraryWorker(QThread): artist_no_comma = artist.replace(',', '').strip() 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, owned_comma, expected_comma, complete_comma = db.check_album_exists_with_completeness( + 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, server_source=active_server ) diff --git a/web_server.py b/web_server.py index d5387be9..5160bfd0 100644 --- a/web_server.py +++ b/web_server.py @@ -5727,6 +5727,7 @@ def _check_album_completion(db, album_data: dict, artist_name: str, test_mode: b print(f"๐Ÿ” Checking album: '{album_name}' ({total_tracks} tracks)") + formats = [] if test_mode: # Generate test data to demonstrate the feature import random @@ -5740,7 +5741,7 @@ def _check_album_completion(db, album_data: dict, artist_name: str, test_mode: b try: # Get active server for database checking active_server = config_manager.get_active_media_server() - db_album, confidence, owned_tracks, expected_tracks, is_complete = db.check_album_exists_with_completeness( + db_album, confidence, owned_tracks, expected_tracks, is_complete, formats = db.check_album_exists_with_completeness( title=album_name, artist=artist_name, expected_track_count=total_tracks if total_tracks > 0 else None, @@ -5790,9 +5791,10 @@ def _check_album_completion(db, album_data: dict, artist_name: str, test_mode: b "expected_tracks": expected_tracks or total_tracks, "completion_percentage": round(completion_percentage, 1), "confidence": round(confidence, 2) if confidence else 0.0, - "found_in_db": db_album is not None + "found_in_db": db_album is not None, + "formats": formats } - + except Exception as e: print(f"โŒ Error checking album completion for '{album_data.get('name', 'Unknown')}': {e}") return { @@ -5803,7 +5805,8 @@ def _check_album_completion(db, album_data: dict, artist_name: str, test_mode: b "expected_tracks": album_data.get('total_tracks', 0), "completion_percentage": 0, "confidence": 0.0, - "found_in_db": False + "found_in_db": False, + "formats": [] } def _check_single_completion(db, single_data: dict, artist_name: str, test_mode: bool = False) -> dict: @@ -5813,14 +5816,15 @@ def _check_single_completion(db, single_data: dict, artist_name: str, test_mode: total_tracks = single_data.get('total_tracks', 1) single_id = single_data.get('id', '') album_type = single_data.get('album_type', 'single') - + formats = [] + print(f"๐ŸŽต Checking {album_type}: '{single_name}' ({total_tracks} tracks)") - + if test_mode: # Generate test data for singles/EPs import random if album_type == 'ep' or total_tracks > 1: - owned_tracks = random.randint(0, total_tracks) + owned_tracks = random.randint(0, total_tracks) expected_tracks = total_tracks confidence = random.uniform(0.7, 1.0) print(f"๐Ÿงช TEST MODE: EP with {owned_tracks}/{expected_tracks} tracks") @@ -5834,7 +5838,7 @@ def _check_single_completion(db, single_data: dict, artist_name: str, test_mode: try: # Get active server for database checking active_server = config_manager.get_active_media_server() - db_album, confidence, owned_tracks, expected_tracks, is_complete = db.check_album_exists_with_completeness( + db_album, confidence, owned_tracks, expected_tracks, is_complete, formats = db.check_album_exists_with_completeness( title=single_name, artist=artist_name, expected_track_count=total_tracks, @@ -5844,25 +5848,25 @@ def _check_single_completion(db, single_data: dict, artist_name: str, test_mode: except Exception as db_error: print(f"โš ๏ธ Database error for EP '{single_name}': {db_error}") owned_tracks, expected_tracks, confidence = 0, total_tracks, 0.0 - + # Calculate completion percentage if expected_tracks > 0: completion_percentage = (owned_tracks / expected_tracks) * 100 else: completion_percentage = (owned_tracks / total_tracks) * 100 - + # Determine status if completion_percentage >= 90 and owned_tracks > 0: status = "completed" elif completion_percentage >= 60: - status = "nearly_complete" + status = "nearly_complete" elif completion_percentage > 0: status = "partial" else: status = "missing" - + print(f" ๐Ÿ“Š EP Result: {owned_tracks}/{expected_tracks or total_tracks} tracks ({completion_percentage:.1f}%) - {status}") - + else: # Single track - just check if the track exists try: @@ -5874,15 +5878,24 @@ def _check_single_completion(db, single_data: dict, artist_name: str, test_mode: except Exception as db_error: print(f"โš ๏ธ Database error for single '{single_name}': {db_error}") db_track, confidence = None, 0.0 - + owned_tracks = 1 if db_track else 0 expected_tracks = 1 completion_percentage = 100 if db_track else 0 - + status = "completed" if db_track else "missing" - + + # Extract format from single track + if db_track and db_track.file_path: + import os + ext = os.path.splitext(db_track.file_path)[1].lstrip('.').upper() + if ext == 'MP3' and db_track.bitrate: + formats = [f"MP3-{db_track.bitrate}"] + elif ext: + formats = [ext] + print(f" ๐ŸŽต Single Result: {owned_tracks}/1 tracks ({completion_percentage:.1f}%) - {status}") - + return { "id": single_id, "name": single_name, @@ -5892,9 +5905,10 @@ def _check_single_completion(db, single_data: dict, artist_name: str, test_mode: "completion_percentage": round(completion_percentage, 1), "confidence": round(confidence, 2) if confidence else 0.0, "found_in_db": (db_album if album_type == 'ep' or total_tracks > 1 else db_track) is not None, - "type": album_type + "type": album_type, + "formats": formats } - + except Exception as e: print(f"โŒ Error checking single/EP completion for '{single_data.get('name', 'Unknown')}': {e}") return { @@ -5906,7 +5920,8 @@ def _check_single_completion(db, single_data: dict, artist_name: str, test_mode: "completion_percentage": 0, "confidence": 0.0, "found_in_db": False, - "type": single_data.get('album_type', 'single') + "type": single_data.get('album_type', 'single'), + "formats": [] } @app.route('/api/artist//completion-stream', methods=['POST']) diff --git a/webui/static/script.js b/webui/static/script.js index 6555b0be..f8b50202 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -11126,6 +11126,31 @@ async function lazyLoadTrackOwnership(artistName, tracks, sourceCard) { } }); + // Aggregate format summary from owned tracks + const formatSet = new Set(); + for (const trackName of Object.keys(ownership)) { + const td = ownership[trackName]; + if (td && td.owned && td.format) { + if (td.format === 'MP3' && td.bitrate) { + formatSet.add(`MP3-${td.bitrate}`); + } else { + formatSet.add(td.format); + } + } + } + if (formatSet.size > 0) { + const heroDetailsContainer = document.querySelector('.add-to-wishlist-modal-hero-details'); + if (heroDetailsContainer) { + // Remove any existing format tag + const existing = heroDetailsContainer.querySelector('.modal-format-tag'); + if (existing) existing.remove(); + const formatTag = document.createElement('span'); + formatTag.className = 'modal-format-tag'; + formatTag.textContent = [...formatSet].sort().join(' / '); + heroDetailsContainer.appendChild(formatTag); + } + } + // Update hero subtitle with missing count const missingCount = tracks.length - ownedCount; const heroDetails = document.querySelectorAll('.add-to-wishlist-modal-hero-detail'); @@ -26092,6 +26117,10 @@ function updateArtistGenres(genres) { genresContainer.innerHTML = ""; + // Clear any previous artist format tags (they arrive later via streaming) + const oldFormats = genresContainer.parentElement?.querySelector('.artist-formats'); + if (oldFormats) oldFormats.remove(); + if (genres && genres.length > 0) { genres.forEach(genre => { const genreTag = document.createElement("span"); @@ -26586,6 +26615,7 @@ async function checkLibraryCompletion(artistName, discography) { let buffer = ''; let ownedCounts = { albums: 0, eps: 0, singles: 0 }; let totalCounts = { albums: 0, eps: 0, singles: 0 }; + const artistFormatSet = new Set(); while (true) { const { done, value } = await reader.read(); @@ -26604,6 +26634,10 @@ async function checkLibraryCompletion(artistName, discography) { totalCounts[eventData.category]++; if (eventData.status !== 'missing' && eventData.status !== 'error') { ownedCounts[eventData.category]++; + // Accumulate formats for artist-level summary + if (eventData.formats) { + eventData.formats.forEach(f => artistFormatSet.add(f)); + } } // Update stats incrementally updateCategoryStatsFromStream( @@ -26614,7 +26648,7 @@ async function checkLibraryCompletion(artistName, discography) { } else if (eventData.type === 'complete') { console.log(`โœ… Library completion stream done: ${eventData.processed_count} items`); // Final stats recalculation - recalculateSummaryStats(); + recalculateSummaryStats(artistFormatSet); } } catch (parseError) { console.warn('Error parsing SSE event:', parseError, line); @@ -26701,6 +26735,22 @@ function updateLibraryReleaseCard(data) { completionFill.classList.add('missing'); } } + + // Display format tags on owned releases + if (isOwned && data.formats && data.formats.length > 0) { + // Store formats on release data for modal use + if (card._releaseData) { + card._releaseData.formats = data.formats; + } + // Remove any existing format tags + const existingFormats = card.querySelector('.release-formats'); + if (existingFormats) existingFormats.remove(); + + const formatsDiv = document.createElement('div'); + formatsDiv.className = 'release-formats'; + formatsDiv.innerHTML = data.formats.map(f => `${f}`).join(''); + card.appendChild(formatsDiv); + } } function updateCategoryStatsFromStream(category, ownedCount, missingCount) { @@ -26734,7 +26784,7 @@ function updateCategoryStatsFromStream(category, ownedCount, missingCount) { } } -function recalculateSummaryStats() { +function recalculateSummaryStats(artistFormatSet) { const disc = artistDetailPageState.currentDiscography; if (!disc) return; @@ -26773,6 +26823,29 @@ function recalculateSummaryStats() { const completionEl = document.getElementById("completion-percentage"); if (completionEl) completionEl.textContent = `${pct}%`; } + + // Display artist-level format summary + if (artistFormatSet && artistFormatSet.size > 0) { + const heroInfo = document.querySelector('.artist-hero-section .artist-info'); + if (heroInfo) { + // Remove any existing artist format tag + const existing = heroInfo.querySelector('.artist-formats'); + if (existing) existing.remove(); + + const formatsDiv = document.createElement('div'); + formatsDiv.className = 'artist-formats'; + formatsDiv.innerHTML = [...artistFormatSet].sort() + .map(f => `${f}`) + .join(''); + // Insert after genres container + const genresContainer = heroInfo.querySelector('.artist-genres-container'); + if (genresContainer && genresContainer.nextSibling) { + heroInfo.insertBefore(formatsDiv, genresContainer.nextSibling); + } else { + heroInfo.appendChild(formatsDiv); + } + } + } } // UI state management functions diff --git a/webui/static/style.css b/webui/static/style.css index d16daafe..b1b4e457 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -14226,6 +14226,54 @@ body { color: rgba(255, 255, 255, 0.45); } +/* โ”€โ”€ Format tags on release cards โ”€โ”€ */ +.release-formats { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 0 8px 6px; +} + +.release-format-tag { + font-size: 9px; + font-weight: 700; + letter-spacing: 0.5px; + padding: 1px 5px; + border-radius: 4px; + background: rgba(29, 185, 84, 0.15); + color: rgba(29, 185, 84, 0.9); + line-height: 1.4; +} + +/* โ”€โ”€ Format tag in wishlist modal hero โ”€โ”€ */ +.modal-format-tag { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.5px; + padding: 2px 8px; + border-radius: 6px; + background: rgba(29, 185, 84, 0.15); + color: rgba(29, 185, 84, 0.9); +} + +/* โ”€โ”€ Format tags in artist hero section โ”€โ”€ */ +.artist-formats { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 6px; +} + +.artist-format-tag { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.5px; + padding: 2px 8px; + border-radius: 6px; + background: rgba(29, 185, 84, 0.15); + color: rgba(29, 185, 84, 0.9); +} + /* Modal Footer */ .add-to-wishlist-modal-footer { background: linear-gradient(135deg,