Add format summary tags to library release cards, wishlist modal, and artist hero
This commit is contained in:
parent
3663a75769
commit
6c6651b879
5 changed files with 211 additions and 44 deletions
|
|
@ -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…
Reference in a new issue