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

pull/153/head
Broque Thomas 3 months ago
parent 3663a75769
commit 6c6651b879

@ -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]:
"""

@ -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
)

@ -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/<artist_id>/completion-stream', methods=['POST'])

@ -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 => `<span class="release-format-tag">${f}</span>`).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 => `<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

@ -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,

Loading…
Cancel
Save