Add format summary tags to library release cards, wishlist modal, and artist hero

This commit is contained in:
Broque Thomas 2026-02-19 10:20:30 -08:00
parent 3663a75769
commit 6c6651b879
5 changed files with 211 additions and 44 deletions

View file

@ -2163,12 +2163,13 @@ class MusicDatabase:
return max(0.0, similarity) 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. Check if we have all tracks for an album.
Merges counts across split album entries (same title+year+artist) so that 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. 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: try:
conn = self._get_connection() conn = self._get_connection()
@ -2179,7 +2180,7 @@ class MusicDatabase:
album_info = cursor.fetchone() album_info = cursor.fetchone()
if not album_info: if not album_info:
return 0, 0, False return 0, 0, False, []
# Find all album IDs that share the same title, year, and artist # 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) # This merges split albums (e.g. Navidrome splitting one album into multiple entries)
@ -2211,33 +2212,63 @@ class MusicDatabase:
# Fallback: if we have any tracks, consider it owned # Fallback: if we have any tracks, consider it owned
is_complete = owned_tracks > 0 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: except Exception as e:
logger.error(f"Error checking album completeness for album_id {album_id}: {e}") logger.error(f"Error checking album completeness for album_id {album_id}: {e}")
return 0, 0, False 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, server_source: Optional[str] = None) -> Tuple[Optional[DatabaseAlbum], float, int, int, bool]: 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, List[str]]:
""" """
Check if an album exists in the database with completeness information. Check if an album exists in the database with completeness information.
Enhanced to handle edition matching (standard <-> deluxe variants). 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:
# Try enhanced edition-aware matching first with expected track count for Smart Edition Matching # 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) album, confidence = self.check_album_exists_with_editions(title, artist, confidence_threshold, expected_track_count, server_source)
if not album: if not album:
return None, 0.0, 0, 0, False return None, 0.0, 0, 0, False, []
# Now check completeness # Now check completeness (includes formats)
owned_tracks, expected_tracks, is_complete = self.check_album_completeness(album.id, expected_track_count) 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 return album, confidence, owned_tracks, expected_tracks, is_complete, formats
except Exception as e: except Exception as e:
logger.error(f"Error checking album existence with completeness for '{title}' by '{artist}': {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]: 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]:
""" """

View file

@ -769,7 +769,7 @@ class DatabaseLibraryWorker(QThread):
# Search database for this combination with completeness info # Search database for this combination with completeness info
print(f" 🔍 Searching database: album='{album_name}', artist='{artist_clean}'") 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 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 # Backup search with original uncleaned artist name
if not db_album and artist and artist != artist_clean: if not db_album and artist and artist != artist_clean:
print(f" 🔄 Backup search with original artist: album='{album_name}', artist='{artist}'") 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 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 = artist.replace(',', '').strip()
artist_no_comma = ' '.join(artist_no_comma.split()) artist_no_comma = ' '.join(artist_no_comma.split())
print(f" 🔄 Comma-removal fallback: album='{album_name}', artist='{artist_no_comma}'") 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 album_name, artist_no_comma, expected_track_count, confidence_threshold=0.7, server_source=active_server
) )

View file

@ -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)") print(f"🔍 Checking album: '{album_name}' ({total_tracks} tracks)")
formats = []
if test_mode: if test_mode:
# Generate test data to demonstrate the feature # Generate test data to demonstrate the feature
import random import random
@ -5740,7 +5741,7 @@ def _check_album_completion(db, album_data: dict, artist_name: str, test_mode: b
try: try:
# Get active server for database checking # Get active server for database checking
active_server = config_manager.get_active_media_server() 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, title=album_name,
artist=artist_name, artist=artist_name,
expected_track_count=total_tracks if total_tracks > 0 else None, expected_track_count=total_tracks if total_tracks > 0 else None,
@ -5790,7 +5791,8 @@ def _check_album_completion(db, album_data: dict, artist_name: str, test_mode: b
"expected_tracks": expected_tracks or total_tracks, "expected_tracks": expected_tracks or total_tracks,
"completion_percentage": round(completion_percentage, 1), "completion_percentage": round(completion_percentage, 1),
"confidence": round(confidence, 2) if confidence else 0.0, "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: except Exception as e:
@ -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), "expected_tracks": album_data.get('total_tracks', 0),
"completion_percentage": 0, "completion_percentage": 0,
"confidence": 0.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: def _check_single_completion(db, single_data: dict, artist_name: str, test_mode: bool = False) -> dict:
@ -5813,6 +5816,7 @@ def _check_single_completion(db, single_data: dict, artist_name: str, test_mode:
total_tracks = single_data.get('total_tracks', 1) total_tracks = single_data.get('total_tracks', 1)
single_id = single_data.get('id', '') single_id = single_data.get('id', '')
album_type = single_data.get('album_type', 'single') album_type = single_data.get('album_type', 'single')
formats = []
print(f"🎵 Checking {album_type}: '{single_name}' ({total_tracks} tracks)") print(f"🎵 Checking {album_type}: '{single_name}' ({total_tracks} tracks)")
@ -5834,7 +5838,7 @@ def _check_single_completion(db, single_data: dict, artist_name: str, test_mode:
try: try:
# Get active server for database checking # Get active server for database checking
active_server = config_manager.get_active_media_server() 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, title=single_name,
artist=artist_name, artist=artist_name,
expected_track_count=total_tracks, expected_track_count=total_tracks,
@ -5881,6 +5885,15 @@ def _check_single_completion(db, single_data: dict, artist_name: str, test_mode:
status = "completed" if db_track else "missing" 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}") print(f" 🎵 Single Result: {owned_tracks}/1 tracks ({completion_percentage:.1f}%) - {status}")
return { return {
@ -5892,7 +5905,8 @@ def _check_single_completion(db, single_data: dict, artist_name: str, test_mode:
"completion_percentage": round(completion_percentage, 1), "completion_percentage": round(completion_percentage, 1),
"confidence": round(confidence, 2) if confidence else 0.0, "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, "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: except Exception as e:
@ -5906,7 +5920,8 @@ def _check_single_completion(db, single_data: dict, artist_name: str, test_mode:
"completion_percentage": 0, "completion_percentage": 0,
"confidence": 0.0, "confidence": 0.0,
"found_in_db": False, "found_in_db": False,
"type": single_data.get('album_type', 'single') "type": single_data.get('album_type', 'single'),
"formats": []
} }
@app.route('/api/artist/<artist_id>/completion-stream', methods=['POST']) @app.route('/api/artist/<artist_id>/completion-stream', methods=['POST'])

View file

@ -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 // Update hero subtitle with missing count
const missingCount = tracks.length - ownedCount; const missingCount = tracks.length - ownedCount;
const heroDetails = document.querySelectorAll('.add-to-wishlist-modal-hero-detail'); const heroDetails = document.querySelectorAll('.add-to-wishlist-modal-hero-detail');
@ -26092,6 +26117,10 @@ function updateArtistGenres(genres) {
genresContainer.innerHTML = ""; 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) { if (genres && genres.length > 0) {
genres.forEach(genre => { genres.forEach(genre => {
const genreTag = document.createElement("span"); const genreTag = document.createElement("span");
@ -26586,6 +26615,7 @@ async function checkLibraryCompletion(artistName, discography) {
let buffer = ''; let buffer = '';
let ownedCounts = { albums: 0, eps: 0, singles: 0 }; let ownedCounts = { albums: 0, eps: 0, singles: 0 };
let totalCounts = { albums: 0, eps: 0, singles: 0 }; let totalCounts = { albums: 0, eps: 0, singles: 0 };
const artistFormatSet = new Set();
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
@ -26604,6 +26634,10 @@ async function checkLibraryCompletion(artistName, discography) {
totalCounts[eventData.category]++; totalCounts[eventData.category]++;
if (eventData.status !== 'missing' && eventData.status !== 'error') { if (eventData.status !== 'missing' && eventData.status !== 'error') {
ownedCounts[eventData.category]++; ownedCounts[eventData.category]++;
// Accumulate formats for artist-level summary
if (eventData.formats) {
eventData.formats.forEach(f => artistFormatSet.add(f));
}
} }
// Update stats incrementally // Update stats incrementally
updateCategoryStatsFromStream( updateCategoryStatsFromStream(
@ -26614,7 +26648,7 @@ async function checkLibraryCompletion(artistName, discography) {
} else if (eventData.type === 'complete') { } else if (eventData.type === 'complete') {
console.log(`✅ Library completion stream done: ${eventData.processed_count} items`); console.log(`✅ Library completion stream done: ${eventData.processed_count} items`);
// Final stats recalculation // Final stats recalculation
recalculateSummaryStats(); recalculateSummaryStats(artistFormatSet);
} }
} catch (parseError) { } catch (parseError) {
console.warn('Error parsing SSE event:', parseError, line); console.warn('Error parsing SSE event:', parseError, line);
@ -26701,6 +26735,22 @@ function updateLibraryReleaseCard(data) {
completionFill.classList.add('missing'); 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 => `<span class="release-format-tag">${f}</span>`).join('');
card.appendChild(formatsDiv);
}
} }
function updateCategoryStatsFromStream(category, ownedCount, missingCount) { function updateCategoryStatsFromStream(category, ownedCount, missingCount) {
@ -26734,7 +26784,7 @@ function updateCategoryStatsFromStream(category, ownedCount, missingCount) {
} }
} }
function recalculateSummaryStats() { function recalculateSummaryStats(artistFormatSet) {
const disc = artistDetailPageState.currentDiscography; const disc = artistDetailPageState.currentDiscography;
if (!disc) return; if (!disc) return;
@ -26773,6 +26823,29 @@ function recalculateSummaryStats() {
const completionEl = document.getElementById("completion-percentage"); const completionEl = document.getElementById("completion-percentage");
if (completionEl) completionEl.textContent = `${pct}%`; 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 => `<span class="artist-format-tag">${f}</span>`)
.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 // UI state management functions

View file

@ -14226,6 +14226,54 @@ body {
color: rgba(255, 255, 255, 0.45); 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 */ /* Modal Footer */
.add-to-wishlist-modal-footer { .add-to-wishlist-modal-footer {
background: linear-gradient(135deg, background: linear-gradient(135deg,