Add metadata cache maintenance and health monitoring
Cache maintenance: - Input validation rejects junk entities (Unknown Artist, empty names) from being cached, with exemptions for synthetic entries (_features, _tracks suffixes) - CacheEvictorJob expanded to 4 phases: TTL eviction, junk cleanup, orphaned search cleanup, MusicBrainz failed lookup cleanup - MusicBrainz null results now expire after 30 days (was 90) so failed lookups get retried sooner Cache health UI: - Polished modal accessible from Dashboard "Cache Health" button and repair dashboard health bar - Shows health status banner (healthy/fair/poor), stat cards, source breakdown with colored progress bars, type pills, and metrics table - Repair dashboard shows compact bar with health dot indicator
This commit is contained in:
parent
4e6b424bc7
commit
59587162cd
8 changed files with 540 additions and 16 deletions
|
|
@ -90,12 +90,27 @@ class MetadataCache:
|
||||||
logger.debug(f"Cache lookup error ({source}/{entity_type}/{entity_id}): {e}")
|
logger.debug(f"Cache lookup error ({source}/{entity_type}/{entity_id}): {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Names that indicate junk/placeholder data — should not be cached
|
||||||
|
_JUNK_NAMES = frozenset({
|
||||||
|
'', 'unknown', 'unknown artist', 'unknown album', 'unknown track',
|
||||||
|
'untitled', 'none', 'n/a', 'null',
|
||||||
|
})
|
||||||
|
|
||||||
|
def _is_junk_entity(self, fields: dict) -> bool:
|
||||||
|
"""Check if extracted fields represent junk/placeholder data."""
|
||||||
|
name = (fields.get('name') or '').strip().lower()
|
||||||
|
return name in self._JUNK_NAMES
|
||||||
|
|
||||||
def store_entity(self, source: str, entity_type: str, entity_id: str, raw_data: dict) -> None:
|
def store_entity(self, source: str, entity_type: str, entity_id: str, raw_data: dict) -> None:
|
||||||
"""Store an entity in the cache. Extracts structured fields from raw_data."""
|
"""Store an entity in the cache. Extracts structured fields from raw_data."""
|
||||||
if not entity_id or not raw_data:
|
if not entity_id or not raw_data:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
fields = self._extract_fields(source, entity_type, raw_data)
|
fields = self._extract_fields(source, entity_type, raw_data)
|
||||||
|
# Skip validation for synthetic cache entries (_features, _tracks suffixes)
|
||||||
|
if not entity_id.endswith('_features') and not entity_id.endswith('_tracks') and self._is_junk_entity(fields):
|
||||||
|
logger.debug(f"Rejecting junk entity ({source}/{entity_type}/{entity_id}): name='{fields.get('name')}'")
|
||||||
|
return
|
||||||
raw_json = json.dumps(raw_data, default=str)
|
raw_json = json.dumps(raw_data, default=str)
|
||||||
db = self._get_db()
|
db = self._get_db()
|
||||||
conn = db._get_connection()
|
conn = db._get_connection()
|
||||||
|
|
@ -175,6 +190,8 @@ class MetadataCache:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
fields = self._extract_fields(source, entity_type, raw_data)
|
fields = self._extract_fields(source, entity_type, raw_data)
|
||||||
|
if self._is_junk_entity(fields):
|
||||||
|
continue
|
||||||
raw_json = json.dumps(raw_data, default=str)
|
raw_json = json.dumps(raw_data, default=str)
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
INSERT OR REPLACE INTO metadata_cache_entities
|
INSERT OR REPLACE INTO metadata_cache_entities
|
||||||
|
|
@ -556,6 +573,187 @@ class MetadataCache:
|
||||||
logger.error(f"Cache eviction error: {e}")
|
logger.error(f"Cache eviction error: {e}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
def clean_junk_entities(self) -> int:
|
||||||
|
"""Delete cached entities with empty/placeholder names."""
|
||||||
|
try:
|
||||||
|
db = self._get_db()
|
||||||
|
conn = db._get_connection()
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
junk_names = "', '".join(self._JUNK_NAMES - {''}) # exclude empty, handled separately
|
||||||
|
cursor.execute(f"""
|
||||||
|
DELETE FROM metadata_cache_entities
|
||||||
|
WHERE (name IS NULL
|
||||||
|
OR TRIM(name) = ''
|
||||||
|
OR LOWER(TRIM(name)) IN ('{junk_names}'))
|
||||||
|
AND entity_id NOT LIKE '%\\_features' ESCAPE '\\'
|
||||||
|
AND entity_id NOT LIKE '%\\_tracks' ESCAPE '\\'
|
||||||
|
""")
|
||||||
|
count = cursor.rowcount
|
||||||
|
conn.commit()
|
||||||
|
if count > 0:
|
||||||
|
logger.info(f"Cleaned {count} junk entities from cache")
|
||||||
|
return count
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Junk cleanup error: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def clean_orphaned_searches(self) -> int:
|
||||||
|
"""Delete search results where <50% of referenced entities still exist."""
|
||||||
|
try:
|
||||||
|
db = self._get_db()
|
||||||
|
conn = db._get_connection()
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT id, source, search_type, result_ids FROM metadata_cache_searches")
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
dead_ids = []
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
result_ids = json.loads(row['result_ids'] or '[]')
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
dead_ids.append(row['id'])
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not result_ids:
|
||||||
|
dead_ids.append(row['id'])
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check how many referenced entities still exist
|
||||||
|
placeholders = ','.join('?' * len(result_ids))
|
||||||
|
cursor.execute(f"""
|
||||||
|
SELECT COUNT(*) FROM metadata_cache_entities
|
||||||
|
WHERE source = ? AND entity_type = ? AND entity_id IN ({placeholders})
|
||||||
|
""", [row['source'], row['search_type']] + list(result_ids))
|
||||||
|
found = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
if found < len(result_ids) * 0.5:
|
||||||
|
dead_ids.append(row['id'])
|
||||||
|
|
||||||
|
if dead_ids:
|
||||||
|
# Delete in chunks to stay under SQLite variable limit
|
||||||
|
for i in range(0, len(dead_ids), 400):
|
||||||
|
chunk = dead_ids[i:i + 400]
|
||||||
|
placeholders = ','.join('?' * len(chunk))
|
||||||
|
cursor.execute(f"DELETE FROM metadata_cache_searches WHERE id IN ({placeholders})", chunk)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
count = len(dead_ids)
|
||||||
|
if count > 0:
|
||||||
|
logger.info(f"Cleaned {count} orphaned search results from cache")
|
||||||
|
return count
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Orphan search cleanup error: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def clean_stale_musicbrainz_nulls(self, max_age_days: int = 30) -> int:
|
||||||
|
"""Delete MusicBrainz cache entries where lookup found nothing (null MBID) and age > max_age_days."""
|
||||||
|
try:
|
||||||
|
db = self._get_db()
|
||||||
|
conn = db._get_connection()
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
DELETE FROM musicbrainz_cache
|
||||||
|
WHERE musicbrainz_id IS NULL
|
||||||
|
AND julianday('now') - julianday(last_updated) > ?
|
||||||
|
""", (max_age_days,))
|
||||||
|
count = cursor.rowcount
|
||||||
|
conn.commit()
|
||||||
|
if count > 0:
|
||||||
|
logger.info(f"Cleaned {count} stale MusicBrainz null entries (>{max_age_days} days)")
|
||||||
|
return count
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"MusicBrainz null cleanup error: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def get_health_stats(self) -> dict:
|
||||||
|
"""Return cache health statistics for the repair dashboard."""
|
||||||
|
try:
|
||||||
|
db = self._get_db()
|
||||||
|
conn = db._get_connection()
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
stats = {}
|
||||||
|
|
||||||
|
# Total counts
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM metadata_cache_entities")
|
||||||
|
stats['total_entities'] = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM metadata_cache_searches")
|
||||||
|
stats['total_searches'] = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
# Junk entity count
|
||||||
|
junk_names = "', '".join(self._JUNK_NAMES - {''})
|
||||||
|
cursor.execute(f"""
|
||||||
|
SELECT COUNT(*) FROM metadata_cache_entities
|
||||||
|
WHERE (name IS NULL OR TRIM(name) = '' OR LOWER(TRIM(name)) IN ('{junk_names}'))
|
||||||
|
AND entity_id NOT LIKE '%\\_features' ESCAPE '\\'
|
||||||
|
AND entity_id NOT LIKE '%\\_tracks' ESCAPE '\\'
|
||||||
|
""")
|
||||||
|
stats['junk_entities'] = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
# By source
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT source, COUNT(*) FROM metadata_cache_entities GROUP BY source
|
||||||
|
""")
|
||||||
|
stats['by_source'] = {row[0]: row[1] for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
# By entity type
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT entity_type, COUNT(*) FROM metadata_cache_entities GROUP BY entity_type
|
||||||
|
""")
|
||||||
|
stats['by_type'] = {row[0]: row[1] for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
# Expiring soon
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT COUNT(*) FROM metadata_cache_entities
|
||||||
|
WHERE julianday('now') - julianday(updated_at) > ttl_days - 1
|
||||||
|
""")
|
||||||
|
stats['expiring_24h'] = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT COUNT(*) FROM metadata_cache_entities
|
||||||
|
WHERE julianday('now') - julianday(updated_at) > ttl_days - 7
|
||||||
|
""")
|
||||||
|
stats['expiring_7d'] = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
# Average age
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT AVG(julianday('now') - julianday(updated_at)) FROM metadata_cache_entities
|
||||||
|
""")
|
||||||
|
avg = cursor.fetchone()[0]
|
||||||
|
stats['avg_age_days'] = round(avg, 1) if avg else 0
|
||||||
|
|
||||||
|
# MusicBrainz stats
|
||||||
|
try:
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM musicbrainz_cache")
|
||||||
|
stats['total_musicbrainz'] = cursor.fetchone()[0]
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM musicbrainz_cache WHERE musicbrainz_id IS NULL")
|
||||||
|
stats['stale_mb_nulls'] = cursor.fetchone()[0]
|
||||||
|
except Exception:
|
||||||
|
stats['total_musicbrainz'] = 0
|
||||||
|
stats['stale_mb_nulls'] = 0
|
||||||
|
|
||||||
|
# Total access hits
|
||||||
|
cursor.execute("SELECT SUM(access_count) FROM metadata_cache_entities")
|
||||||
|
hits = cursor.fetchone()[0]
|
||||||
|
stats['total_access_hits'] = hits or 0
|
||||||
|
|
||||||
|
return stats
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Cache health stats error: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
def clear(self, source: str = None, entity_type: str = None) -> int:
|
def clear(self, source: str = None, entity_type: str = None) -> int:
|
||||||
"""Clear cache entries. Optional filters by source and/or entity_type."""
|
"""Clear cache entries. Optional filters by source and/or entity_type."""
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -60,10 +60,11 @@ class MusicBrainzService:
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
|
|
||||||
if row:
|
if row:
|
||||||
# Don't use cache if it's older than 90 days
|
# Shorter TTL for null results (failed lookups) so they get retried sooner
|
||||||
last_updated = datetime.fromisoformat(row[3]) if row[3] else None
|
last_updated = datetime.fromisoformat(row[3]) if row[3] else None
|
||||||
if last_updated and (datetime.now() - last_updated).days > 90:
|
ttl_days = 30 if row[0] is None else 90 # row[0] is musicbrainz_id
|
||||||
logger.debug(f"Cache entry for {entity_type} '{entity_name}' is stale (> 90 days)")
|
if last_updated and (datetime.now() - last_updated).days > ttl_days:
|
||||||
|
logger.debug(f"Cache entry for {entity_type} '{entity_name}' is stale (> {ttl_days} days)")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Parse JSON with error handling
|
# Parse JSON with error handling
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Metadata Cache Evictor Job — periodically cleans expired cache entries."""
|
"""Metadata Cache Maintenance Job — cleans expired, junk, and orphaned cache entries."""
|
||||||
|
|
||||||
from core.repair_jobs import register_job
|
from core.repair_jobs import register_job
|
||||||
from core.repair_jobs.base import JobContext, JobResult, RepairJob
|
from core.repair_jobs.base import JobContext, JobResult, RepairJob
|
||||||
|
|
@ -10,14 +10,17 @@ logger = get_logger("repair_job.cache_evictor")
|
||||||
@register_job
|
@register_job
|
||||||
class CacheEvictorJob(RepairJob):
|
class CacheEvictorJob(RepairJob):
|
||||||
job_id = 'cache_evictor'
|
job_id = 'cache_evictor'
|
||||||
display_name = 'Cache Evictor'
|
display_name = 'Cache Maintenance'
|
||||||
description = 'Removes expired metadata cache entries'
|
description = 'Removes expired, junk, and orphaned metadata cache entries'
|
||||||
help_text = (
|
help_text = (
|
||||||
'Automatically cleans up stale metadata cache entries from the database. '
|
'Maintains the metadata cache that stores search results, album metadata, '
|
||||||
'SoulSync caches search results, album metadata, and cover art URLs to avoid '
|
'and cover art URLs from Spotify, iTunes, and Deezer.\n\n'
|
||||||
'hitting API rate limits. Over time these entries expire and take up space.\n\n'
|
'Runs four maintenance phases:\n'
|
||||||
'This is the only fully automatic job — it runs silently and removes expired '
|
'1. TTL eviction — removes entries past their expiration date\n'
|
||||||
'entries without creating findings. Safe to leave enabled at all times.'
|
'2. Junk cleanup — removes entries with empty or placeholder names (Unknown Artist, etc.)\n'
|
||||||
|
'3. Orphan cleanup — removes search results pointing to deleted entities\n'
|
||||||
|
'4. MusicBrainz cleanup — removes stale "not found" entries older than 30 days\n\n'
|
||||||
|
'Fully automatic — runs silently without creating findings. Safe to leave enabled.'
|
||||||
)
|
)
|
||||||
icon = 'repair-icon-cache'
|
icon = 'repair-icon-cache'
|
||||||
default_enabled = True
|
default_enabled = True
|
||||||
|
|
@ -33,13 +36,57 @@ class CacheEvictorJob(RepairJob):
|
||||||
logger.debug("No metadata cache available — skipping")
|
logger.debug("No metadata cache available — skipping")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
# Phase 1: Evict expired entries (existing behavior)
|
||||||
try:
|
try:
|
||||||
evicted = cache.evict_expired()
|
evicted = cache.evict_expired()
|
||||||
result.auto_fixed = evicted
|
result.auto_fixed += evicted
|
||||||
result.scanned = evicted
|
result.scanned += evicted
|
||||||
logger.info("Cache evictor: removed %d expired entries", evicted)
|
if evicted > 0:
|
||||||
|
logger.info("Phase 1 — TTL eviction: removed %d expired entries", evicted)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Cache eviction failed: %s", e, exc_info=True)
|
logger.error("TTL eviction failed: %s", e, exc_info=True)
|
||||||
result.errors = 1
|
result.errors += 1
|
||||||
|
|
||||||
|
if context.check_stop():
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Phase 2: Clean junk entities (empty/placeholder names)
|
||||||
|
try:
|
||||||
|
junk = cache.clean_junk_entities()
|
||||||
|
result.auto_fixed += junk
|
||||||
|
result.scanned += junk
|
||||||
|
if junk > 0:
|
||||||
|
logger.info("Phase 2 — junk cleanup: removed %d junk entries", junk)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Junk cleanup failed: %s", e, exc_info=True)
|
||||||
|
result.errors += 1
|
||||||
|
|
||||||
|
if context.check_stop():
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Phase 3: Clean orphaned search results
|
||||||
|
try:
|
||||||
|
orphans = cache.clean_orphaned_searches()
|
||||||
|
result.auto_fixed += orphans
|
||||||
|
result.scanned += orphans
|
||||||
|
if orphans > 0:
|
||||||
|
logger.info("Phase 3 — orphan cleanup: removed %d orphaned searches", orphans)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Orphan cleanup failed: %s", e, exc_info=True)
|
||||||
|
result.errors += 1
|
||||||
|
|
||||||
|
if context.check_stop():
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Phase 4: Clean stale MusicBrainz null results (failed lookups > 30 days)
|
||||||
|
try:
|
||||||
|
mb_nulls = cache.clean_stale_musicbrainz_nulls(max_age_days=30)
|
||||||
|
result.auto_fixed += mb_nulls
|
||||||
|
result.scanned += mb_nulls
|
||||||
|
if mb_nulls > 0:
|
||||||
|
logger.info("Phase 4 — MB null cleanup: removed %d stale null entries", mb_nulls)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("MB null cleanup failed: %s", e, exc_info=True)
|
||||||
|
result.errors += 1
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
|
|
@ -19071,6 +19071,17 @@ def get_version_info():
|
||||||
"title": "What's New in SoulSync",
|
"title": "What's New in SoulSync",
|
||||||
"subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes",
|
"subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes",
|
||||||
"sections": [
|
"sections": [
|
||||||
|
{
|
||||||
|
"title": "🧹 Metadata Cache Maintenance",
|
||||||
|
"description": "The cache evictor now runs four maintenance phases to keep the metadata cache clean",
|
||||||
|
"features": [
|
||||||
|
"• Input validation prevents junk entities (Unknown Artist, empty names) from being cached",
|
||||||
|
"• Junk cleanup removes existing placeholder entries from the cache",
|
||||||
|
"• Orphan cleanup removes search results pointing to deleted entities",
|
||||||
|
"• MusicBrainz null cleanup removes failed lookups after 30 days (was 90) so they get retried",
|
||||||
|
"• Health stats available in the repair dashboard"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"title": "🔧 Fix Wishlist Download Selection Ignoring Checkboxes",
|
"title": "🔧 Fix Wishlist Download Selection Ignoring Checkboxes",
|
||||||
"description": "Download Selection now respects which tracks are checked in the wishlist overview",
|
"description": "Download Selection now respects which tracks are checked in the wishlist overview",
|
||||||
|
|
@ -44129,6 +44140,17 @@ def repair_findings_counts():
|
||||||
logger.error(f"Error getting findings counts: {e}")
|
logger.error(f"Error getting findings counts: {e}")
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/repair/cache-health', methods=['GET'])
|
||||||
|
def repair_cache_health():
|
||||||
|
"""Get metadata cache health stats for the repair dashboard"""
|
||||||
|
try:
|
||||||
|
from core.metadata_cache import get_metadata_cache
|
||||||
|
cache = get_metadata_cache()
|
||||||
|
return jsonify(cache.get_health_stats()), 200
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting cache health: {e}")
|
||||||
|
return jsonify({}), 500
|
||||||
|
|
||||||
@app.route('/api/repair/findings/<int:finding_id>/fix', methods=['POST'])
|
@app.route('/api/repair/findings/<int:finding_id>/fix', methods=['POST'])
|
||||||
def repair_finding_fix(finding_id):
|
def repair_finding_fix(finding_id):
|
||||||
"""Execute the actual fix action for a finding"""
|
"""Execute the actual fix action for a finding"""
|
||||||
|
|
|
||||||
|
|
@ -1016,6 +1016,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="tool-card-controls">
|
<div class="tool-card-controls">
|
||||||
<button id="mcache-browse-button" onclick="openMetadataCacheModal()">Browse Cache</button>
|
<button id="mcache-browse-button" onclick="openMetadataCacheModal()">Browse Cache</button>
|
||||||
|
<button onclick="openCacheHealthModal()" class="tool-card-btn-secondary">Cache Health</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3403,6 +3403,7 @@ function closeHelperSearch() {
|
||||||
const WHATS_NEW = {
|
const WHATS_NEW = {
|
||||||
'2.1': [
|
'2.1': [
|
||||||
// Newest features first
|
// Newest features first
|
||||||
|
{ title: 'Cache Maintenance', desc: 'Cache evictor now cleans junk entities, orphaned searches, and stale MusicBrainz nulls' },
|
||||||
{ title: 'Fix Wishlist Download Selection', desc: 'Download Selection now only downloads checked tracks instead of the entire category' },
|
{ title: 'Fix Wishlist Download Selection', desc: 'Download Selection now only downloads checked tracks instead of the entire category' },
|
||||||
{ title: 'Fix Tidal OAuth in Docker', desc: 'Tidal redirect URI now uses configured setting instead of Docker container hostname' },
|
{ title: 'Fix Tidal OAuth in Docker', desc: 'Tidal redirect URI now uses configured setting instead of Docker container hostname' },
|
||||||
{ title: 'High-Res Cover Art', desc: 'Album art now sourced from Cover Art Archive (1200x1200+) when MusicBrainz release ID is available' },
|
{ title: 'High-Res Cover Art', desc: 'Album art now sourced from Cover Art Archive (1200x1200+) when MusicBrainz release ID is available' },
|
||||||
|
|
|
||||||
|
|
@ -55468,12 +55468,148 @@ async function loadRepairFindingsDashboard() {
|
||||||
}
|
}
|
||||||
|
|
||||||
dashboard.innerHTML = html;
|
dashboard.innerHTML = html;
|
||||||
|
|
||||||
|
// Load cache health stats
|
||||||
|
_loadCacheHealthStats(dashboard);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading findings dashboard:', error);
|
console.error('Error loading findings dashboard:', error);
|
||||||
dashboard.innerHTML = '';
|
dashboard.innerHTML = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function _loadCacheHealthStats(dashboard) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/repair/cache-health');
|
||||||
|
if (!response.ok) return;
|
||||||
|
const stats = await response.json();
|
||||||
|
if (!stats.total_entities && !stats.total_searches) return;
|
||||||
|
|
||||||
|
const healthScore = stats.junk_entities === 0 && stats.stale_mb_nulls === 0 ? 'healthy' : stats.junk_entities > 50 ? 'poor' : 'fair';
|
||||||
|
const healthLabel = healthScore === 'healthy' ? 'Healthy' : healthScore === 'fair' ? 'Needs Cleanup' : 'Needs Attention';
|
||||||
|
|
||||||
|
const section = document.createElement('div');
|
||||||
|
section.className = 'repair-cache-health';
|
||||||
|
section.innerHTML = `
|
||||||
|
<div class="repair-cache-health-bar" onclick="openCacheHealthModal()">
|
||||||
|
<span class="repair-cache-health-dot ${healthScore}"></span>
|
||||||
|
<span class="repair-cache-health-title">Metadata Cache</span>
|
||||||
|
<span class="repair-cache-health-summary">${stats.total_entities.toLocaleString()} entities · ${healthLabel}</span>
|
||||||
|
<span class="repair-cache-health-action">View Details ›</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
dashboard.appendChild(section);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading cache health:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCacheHealthModal() {
|
||||||
|
if (document.getElementById('cache-health-modal-overlay')) return;
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.id = 'cache-health-modal-overlay';
|
||||||
|
overlay.className = 'modal-overlay';
|
||||||
|
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
|
||||||
|
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="cache-health-modal">
|
||||||
|
<div class="cache-health-header">
|
||||||
|
<div class="cache-health-header-content">
|
||||||
|
<div class="cache-health-header-icon">📊</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="cache-health-title">Cache Health</h2>
|
||||||
|
<p class="cache-health-subtitle">Metadata cache status across all sources</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="watch-all-close" onclick="document.getElementById('cache-health-modal-overlay').remove()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="cache-health-body">
|
||||||
|
<div class="cache-health-loading">
|
||||||
|
<div class="watch-all-loading-spinner"></div>
|
||||||
|
<div>Loading cache stats...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cache-health-footer">
|
||||||
|
<button class="watch-all-btn watch-all-btn-cancel" onclick="document.getElementById('cache-health-modal-overlay').remove()">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/repair/cache-health');
|
||||||
|
if (!response.ok) throw new Error('Failed to load');
|
||||||
|
const s = await response.json();
|
||||||
|
|
||||||
|
const body = overlay.querySelector('.cache-health-body');
|
||||||
|
const healthScore = s.junk_entities === 0 && s.stale_mb_nulls === 0 ? 'healthy' : s.junk_entities > 50 ? 'poor' : 'fair';
|
||||||
|
const healthEmoji = healthScore === 'healthy' ? '✓' : healthScore === 'fair' ? '⚠' : '❌';
|
||||||
|
const healthLabel = healthScore === 'healthy' ? 'Cache is healthy' : healthScore === 'fair' ? 'Minor issues detected' : 'Cleanup recommended';
|
||||||
|
|
||||||
|
body.innerHTML = `
|
||||||
|
<div class="cache-health-status ${healthScore}">
|
||||||
|
<div class="cache-health-status-icon">${healthEmoji}</div>
|
||||||
|
<div class="cache-health-status-text">${healthLabel}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cache-health-cards">
|
||||||
|
<div class="cache-health-card">
|
||||||
|
<div class="cache-health-card-value">${s.total_entities.toLocaleString()}</div>
|
||||||
|
<div class="cache-health-card-label">Total Entities</div>
|
||||||
|
</div>
|
||||||
|
<div class="cache-health-card">
|
||||||
|
<div class="cache-health-card-value">${s.total_searches.toLocaleString()}</div>
|
||||||
|
<div class="cache-health-card-label">Search Results</div>
|
||||||
|
</div>
|
||||||
|
<div class="cache-health-card">
|
||||||
|
<div class="cache-health-card-value ${s.junk_entities > 0 ? 'warn' : ''}">${s.junk_entities}</div>
|
||||||
|
<div class="cache-health-card-label">Junk Entries</div>
|
||||||
|
</div>
|
||||||
|
<div class="cache-health-card">
|
||||||
|
<div class="cache-health-card-value ${s.stale_mb_nulls > 10 ? 'warn' : ''}">${s.stale_mb_nulls}</div>
|
||||||
|
<div class="cache-health-card-label">Failed Lookups</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cache-health-section">
|
||||||
|
<div class="cache-health-section-title">By Source</div>
|
||||||
|
<div class="cache-health-source-bars">
|
||||||
|
${Object.entries(s.by_source || {}).map(([src, count]) => {
|
||||||
|
const pct = s.total_entities > 0 ? Math.round(count / s.total_entities * 100) : 0;
|
||||||
|
const color = src === 'spotify' ? '#1DB954' : src === 'itunes' ? '#FC3C44' : src === 'deezer' ? '#A238FF' : '#666';
|
||||||
|
return `<div class="cache-health-source-row">
|
||||||
|
<span class="cache-health-source-name">${src}</span>
|
||||||
|
<div class="cache-health-source-track"><div class="cache-health-source-fill" style="width:${pct}%;background:${color}"></div></div>
|
||||||
|
<span class="cache-health-source-count">${count.toLocaleString()}</span>
|
||||||
|
</div>`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cache-health-section">
|
||||||
|
<div class="cache-health-section-title">By Type</div>
|
||||||
|
<div class="cache-health-type-pills">
|
||||||
|
${Object.entries(s.by_type || {}).map(([type, count]) => `<span class="cache-health-pill">${type}s <strong>${count.toLocaleString()}</strong></span>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cache-health-section">
|
||||||
|
<div class="cache-health-section-title">Metrics</div>
|
||||||
|
<div class="cache-health-metrics">
|
||||||
|
<div class="cache-health-metric"><span class="cache-health-metric-label">Average Age</span><span class="cache-health-metric-value">${s.avg_age_days} days</span></div>
|
||||||
|
<div class="cache-health-metric"><span class="cache-health-metric-label">Total Cache Hits</span><span class="cache-health-metric-value">${s.total_access_hits.toLocaleString()}</span></div>
|
||||||
|
<div class="cache-health-metric"><span class="cache-health-metric-label">Expiring in 24h</span><span class="cache-health-metric-value">${s.expiring_24h}</span></div>
|
||||||
|
<div class="cache-health-metric"><span class="cache-health-metric-label">Expiring in 7 days</span><span class="cache-health-metric-value">${s.expiring_7d}</span></div>
|
||||||
|
<div class="cache-health-metric"><span class="cache-health-metric-label">MusicBrainz Entries</span><span class="cache-health-metric-value">${s.total_musicbrainz}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} catch (error) {
|
||||||
|
const body = overlay.querySelector('.cache-health-body');
|
||||||
|
body.innerHTML = '<div class="cache-health-loading">Failed to load cache stats</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function filterFindingsByJob(jobId) {
|
function filterFindingsByJob(jobId) {
|
||||||
const jobFilter = document.getElementById('repair-findings-job-filter');
|
const jobFilter = document.getElementById('repair-findings-job-filter');
|
||||||
if (!jobFilter) return;
|
if (!jobFilter) return;
|
||||||
|
|
|
||||||
|
|
@ -46650,6 +46650,124 @@ tr.tag-diff-same {
|
||||||
box-shadow: 0 0 4px rgba(59, 130, 246, 0.3);
|
box-shadow: 0 0 4px rgba(59, 130, 246, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Cache Health Bar (Repair Dashboard) ── */
|
||||||
|
.repair-cache-health { margin-top: 12px; }
|
||||||
|
.repair-cache-health-bar {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 10px 16px; border-radius: 10px; cursor: pointer;
|
||||||
|
background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.repair-cache-health-bar:hover { background: rgba(255,255,255,0.05); border-color: rgba(var(--accent-rgb), 0.15); }
|
||||||
|
.repair-cache-health-dot {
|
||||||
|
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.repair-cache-health-dot.healthy { background: #22c55e; box-shadow: 0 0 6px rgba(34,197,94,0.4); }
|
||||||
|
.repair-cache-health-dot.fair { background: #f59e0b; box-shadow: 0 0 6px rgba(245,158,11,0.4); }
|
||||||
|
.repair-cache-health-dot.poor { background: #ef4444; box-shadow: 0 0 6px rgba(239,68,68,0.4); }
|
||||||
|
.repair-cache-health-title { font-size: 13px; font-weight: 600; color: rgba(255,255,255,0.7); }
|
||||||
|
.repair-cache-health-summary { flex: 1; font-size: 12px; color: rgba(255,255,255,0.3); }
|
||||||
|
.repair-cache-health-action { font-size: 12px; color: rgb(var(--accent-rgb)); white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ── Cache Health Modal ── */
|
||||||
|
.cache-health-modal {
|
||||||
|
width: 680px; max-width: 95vw; max-height: 85vh;
|
||||||
|
background: linear-gradient(135deg, rgba(20,20,20,0.97) 0%, rgba(12,12,12,0.99) 100%);
|
||||||
|
backdrop-filter: blur(20px) saturate(1.2);
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.15);
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.6), 0 8px 32px rgba(0,0,0,0.4),
|
||||||
|
0 0 40px rgba(var(--accent-rgb), 0.08), inset 0 1px 0 rgba(255,255,255,0.1);
|
||||||
|
display: flex; flex-direction: column; overflow: hidden;
|
||||||
|
}
|
||||||
|
.cache-health-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 20px 24px;
|
||||||
|
background: linear-gradient(90deg, rgba(var(--accent-rgb), 0.06) 0%, transparent 60%);
|
||||||
|
border-bottom: 1px solid rgba(var(--accent-rgb), 0.12);
|
||||||
|
}
|
||||||
|
.cache-health-header-content { display: flex; align-items: center; gap: 14px; }
|
||||||
|
.cache-health-header-icon { font-size: 28px; opacity: 0.9; }
|
||||||
|
.cache-health-title {
|
||||||
|
font-size: 20px; font-weight: 700; color: #fff; letter-spacing: -0.3px;
|
||||||
|
font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
}
|
||||||
|
.cache-health-subtitle { font-size: 13px; color: rgba(255,255,255,0.45); margin-top: 2px; }
|
||||||
|
.cache-health-body { flex: 1; overflow-y: auto; padding: 20px 24px; }
|
||||||
|
.cache-health-footer {
|
||||||
|
display: flex; justify-content: flex-end; padding: 16px 24px;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.06); background: rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
.cache-health-loading {
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
padding: 48px; gap: 12px; color: rgba(255,255,255,0.5); font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status banner */
|
||||||
|
.cache-health-status {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 14px 18px; border-radius: 12px; margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.cache-health-status.healthy { background: rgba(34,197,94,0.08); border: 1px solid rgba(34,197,94,0.2); }
|
||||||
|
.cache-health-status.fair { background: rgba(245,158,11,0.08); border: 1px solid rgba(245,158,11,0.2); }
|
||||||
|
.cache-health-status.poor { background: rgba(239,68,68,0.08); border: 1px solid rgba(239,68,68,0.2); }
|
||||||
|
.cache-health-status-icon { font-size: 20px; }
|
||||||
|
.cache-health-status.healthy .cache-health-status-icon { color: #22c55e; }
|
||||||
|
.cache-health-status.fair .cache-health-status-icon { color: #f59e0b; }
|
||||||
|
.cache-health-status.poor .cache-health-status-icon { color: #ef4444; }
|
||||||
|
.cache-health-status-text { font-size: 14px; font-weight: 600; color: rgba(255,255,255,0.8); }
|
||||||
|
|
||||||
|
/* Stat cards */
|
||||||
|
.cache-health-cards {
|
||||||
|
display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.cache-health-card {
|
||||||
|
text-align: center; padding: 14px 8px; border-radius: 12px;
|
||||||
|
background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06);
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.cache-health-card:hover { border-color: rgba(255,255,255,0.1); }
|
||||||
|
.cache-health-card-value { font-size: 22px; font-weight: 700; color: #fff; }
|
||||||
|
.cache-health-card-value.warn { color: rgba(255,180,80,0.9); }
|
||||||
|
.cache-health-card-label { font-size: 10px; color: rgba(255,255,255,0.35); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 4px; }
|
||||||
|
|
||||||
|
/* Sections */
|
||||||
|
.cache-health-section { margin-bottom: 16px; }
|
||||||
|
.cache-health-section-title {
|
||||||
|
font-size: 11px; font-weight: 600; color: rgba(255,255,255,0.35);
|
||||||
|
text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Source bars */
|
||||||
|
.cache-health-source-bars { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.cache-health-source-row { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.cache-health-source-name { font-size: 13px; color: rgba(255,255,255,0.6); width: 60px; text-transform: capitalize; }
|
||||||
|
.cache-health-source-track {
|
||||||
|
flex: 1; height: 8px; border-radius: 4px; background: rgba(255,255,255,0.06); overflow: hidden;
|
||||||
|
}
|
||||||
|
.cache-health-source-fill { height: 100%; border-radius: 4px; transition: width 0.5s ease; }
|
||||||
|
.cache-health-source-count { font-size: 12px; color: rgba(255,255,255,0.4); width: 50px; text-align: right; }
|
||||||
|
|
||||||
|
/* Type pills */
|
||||||
|
.cache-health-type-pills { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.cache-health-pill {
|
||||||
|
padding: 6px 14px; border-radius: 20px; font-size: 13px; color: rgba(255,255,255,0.5);
|
||||||
|
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.06);
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
.cache-health-pill strong { color: #fff; margin-left: 4px; }
|
||||||
|
|
||||||
|
/* Metrics grid */
|
||||||
|
.cache-health-metrics { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.cache-health-metric {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 6px 0; border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||||
|
}
|
||||||
|
.cache-health-metric:last-child { border-bottom: none; }
|
||||||
|
.cache-health-metric-label { font-size: 13px; color: rgba(255,255,255,0.4); }
|
||||||
|
.cache-health-metric-value { font-size: 13px; font-weight: 600; color: rgba(255,255,255,0.8); }
|
||||||
|
|
||||||
/* ── Findings Tab ── */
|
/* ── Findings Tab ── */
|
||||||
.repair-findings-toolbar {
|
.repair-findings-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue