Merge pull request #493 from Nezreka/fix/findings-tab-empty

Fix/findings tab empty
This commit is contained in:
BoulderBadgeDad 2026-05-04 09:16:39 -07:00 committed by GitHub
commit 0eaac77627
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 368 additions and 49 deletions

View file

@ -212,7 +212,7 @@ class AcoustIDScannerJob(RepairJob):
)
if context.create_finding:
severity = 'warning' if best_score >= 0.90 else 'info'
context.create_finding(
inserted = context.create_finding(
job_id=self.job_id,
finding_type='acoustid_mismatch',
severity=severity,
@ -240,7 +240,10 @@ class AcoustIDScannerJob(RepairJob):
'track_number': expected.get('track_number'),
}
)
result.findings_created += 1
if inserted:
result.findings_created += 1
else:
result.findings_skipped_dedup += 1
def _load_db_tracks(self, context: JobContext) -> dict:
"""Load all tracks from DB keyed by track ID."""

View file

@ -234,7 +234,7 @@ class AlbumCompletenessJob(RepairJob):
)
if context.create_finding:
try:
context.create_finding(
inserted = context.create_finding(
job_id=self.job_id,
finding_type='incomplete_album',
severity='info',
@ -264,7 +264,10 @@ class AlbumCompletenessJob(RepairJob):
'artist_thumb_url': artist_thumb or None,
}
)
result.findings_created += 1
if inserted:
result.findings_created += 1
else:
result.findings_skipped_dedup += 1
except Exception as e:
logger.debug("Error creating completeness finding for album %s: %s", album_id, e)
result.errors += 1

View file

@ -288,7 +288,8 @@ class AlbumTagConsistencyJob(RepairJob):
variants_str = ' vs '.join(f'"{v}"' for v in inc['variants'][:3])
desc_parts.append(f"{inc['field']}: {variants_str}")
context.create_finding(
inserted = context.create_finding(
job_id=self.job_id,
finding_type='album_tag_inconsistency',
severity='warning',
entity_type='album',
@ -306,7 +307,10 @@ class AlbumTagConsistencyJob(RepairJob):
'file_path': t['file_path']} for t in tag_data],
}
)
result.findings_created += 1
if inserted:
result.findings_created += 1
else:
result.findings_skipped_dedup += 1
if context.report_progress:
context.report_progress(

View file

@ -11,6 +11,7 @@ class JobResult:
"""Result of a single job scan run."""
scanned: int = 0
findings_created: int = 0
findings_skipped_dedup: int = 0 # Findings the worker already had a row for
auto_fixed: int = 0
errors: int = 0
skipped: int = 0

View file

@ -120,7 +120,7 @@ class DeadFileCleanerJob(RepairJob):
)
if context.create_finding:
try:
context.create_finding(
inserted = context.create_finding(
job_id=self.job_id,
finding_type='dead_file',
severity='warning',
@ -139,7 +139,10 @@ class DeadFileCleanerJob(RepairJob):
'artist_thumb_url': artist_thumb or None,
}
)
result.findings_created += 1
if inserted:
result.findings_created += 1
else:
result.findings_skipped_dedup += 1
except Exception as e:
logger.debug("Error creating dead file finding for track %s: %s", track_id, e)
result.errors += 1

View file

@ -320,7 +320,7 @@ class DiscographyBackfillJob(RepairJob):
# Create finding
if context.create_finding:
try:
context.create_finding(
inserted = context.create_finding(
job_id=self.job_id,
finding_type='missing_discography_track',
severity='info',
@ -340,13 +340,18 @@ class DiscographyBackfillJob(RepairJob):
'source': source,
},
)
result.findings_created += 1
missing_count += 1
if inserted:
result.findings_created += 1
missing_count += 1
else:
result.findings_skipped_dedup += 1
# Auto-wishlist mode: also push to wishlist now. The
# finding still gets created so the user has a log of
# what the backfill picked up.
if auto_add:
# what the backfill picked up. Only fire on a NEW
# finding — skip if dedup-suppressed (already on the
# wishlist or already auto-added in a prior scan).
if auto_add and inserted:
try:
context.db.add_to_wishlist(
spotify_track_data=track_data,

View file

@ -271,7 +271,7 @@ class DuplicateDetectorJob(RepairJob):
if context.create_finding:
try:
group.sort(key=lambda t: (t['bitrate'] or 0), reverse=True)
context.create_finding(
inserted = context.create_finding(
job_id=self.job_id,
finding_type='duplicate_tracks',
severity='info',
@ -295,7 +295,10 @@ class DuplicateDetectorJob(RepairJob):
'artist_thumb_url': group[0].get('artist_thumb_url'),
}
)
result.findings_created += 1
if inserted:
result.findings_created += 1
else:
result.findings_skipped_dedup += 1
except Exception as e:
logger.debug("Error creating duplicate finding: %s", e)
result.errors += 1

View file

@ -110,7 +110,7 @@ class FakeLosslessDetectorJob(RepairJob):
log_type='error'
)
if context.create_finding:
context.create_finding(
inserted = context.create_finding(
job_id=self.job_id,
finding_type='fake_lossless',
severity='warning',
@ -134,7 +134,10 @@ class FakeLosslessDetectorJob(RepairJob):
'file_size': os.path.getsize(fpath),
}
)
result.findings_created += 1
if inserted:
result.findings_created += 1
else:
result.findings_skipped_dedup += 1
except Exception as e:
logger.debug("Error analyzing %s: %s", os.path.basename(fpath), e)

View file

@ -495,7 +495,7 @@ class LibraryReorganizeJob(RepairJob):
rel_actual = os.path.relpath(actual_norm, transfer)
rel_expected = os.path.relpath(expected_norm, transfer)
if context.create_finding:
context.create_finding(
inserted = context.create_finding(
job_id=self.job_id,
finding_type='path_mismatch',
severity='info',
@ -506,7 +506,10 @@ class LibraryReorganizeJob(RepairJob):
description=f'From: {rel_actual}\nTo: {rel_expected}',
details={'from': rel_actual, 'to': rel_expected}
)
result.findings_created += 1
if inserted:
result.findings_created += 1
else:
result.findings_skipped_dedup += 1
if context.report_progress:
context.report_progress(
scanned=i + 1, total=total,

View file

@ -207,7 +207,7 @@ class LiveCommentaryCleanerJob(RepairJob):
if context.create_finding:
try:
context.create_finding(
inserted = context.create_finding(
job_id=self.job_id,
finding_type='unwanted_content',
severity='info',
@ -239,7 +239,10 @@ class LiveCommentaryCleanerJob(RepairJob):
'artist_thumb_url': artist_thumb or None,
}
)
result.findings_created += 1
if inserted:
result.findings_created += 1
else:
result.findings_skipped_dedup += 1
except Exception as e:
logger.debug("Error creating finding: %s", e)
result.errors += 1

View file

@ -150,7 +150,7 @@ class LossyConverterJob(RepairJob):
if context.create_finding:
try:
file_size = os.path.getsize(resolved)
context.create_finding(
inserted = context.create_finding(
job_id=self.job_id,
finding_type='missing_lossy_copy',
severity='info',
@ -177,7 +177,10 @@ class LossyConverterJob(RepairJob):
'artist_thumb_url': artist_thumb or None,
}
)
result.findings_created += 1
if inserted:
result.findings_created += 1
else:
result.findings_skipped_dedup += 1
except Exception as e:
logger.debug("Error creating finding for track %s: %s", track_id, e)
result.errors += 1

View file

@ -616,7 +616,7 @@ class MbidMismatchDetectorJob(RepairJob):
)
if context.create_finding:
try:
context.create_finding(
inserted = context.create_finding(
job_id=self.job_id,
finding_type='album_mbid_mismatch',
severity='warning',
@ -648,7 +648,10 @@ class MbidMismatchDetectorJob(RepairJob):
'artist_thumb_url': track['artist_thumb'] or None,
}
)
result.findings_created += 1
if inserted:
result.findings_created += 1
else:
result.findings_skipped_dedup += 1
except Exception as e:
logger.debug("Error creating album MBID mismatch finding for track %s: %s",
track['track_id'], e)
@ -665,7 +668,7 @@ class MbidMismatchDetectorJob(RepairJob):
)
if context.create_finding:
try:
context.create_finding(
inserted = context.create_finding(
job_id=self.job_id,
finding_type='mbid_mismatch',
severity='warning',
@ -692,7 +695,10 @@ class MbidMismatchDetectorJob(RepairJob):
'artist_thumb_url': artist_thumb or None,
}
)
result.findings_created += 1
if inserted:
result.findings_created += 1
else:
result.findings_skipped_dedup += 1
except Exception as e:
logger.debug("Error creating MBID mismatch finding for track %s: %s", track_id, e)
result.errors += 1

View file

@ -177,7 +177,7 @@ class MetadataGapFillerJob(RepairJob):
if context.create_finding:
try:
field_names = ', '.join(found_fields.keys())
context.create_finding(
inserted = context.create_finding(
job_id=self.job_id,
finding_type='metadata_gap',
severity='info',
@ -202,7 +202,10 @@ class MetadataGapFillerJob(RepairJob):
'artist_thumb_url': artist_thumb or None,
}
)
result.findings_created += 1
if inserted:
result.findings_created += 1
else:
result.findings_skipped_dedup += 1
except Exception as e:
logger.debug("Error creating metadata gap finding for track %s: %s", track_id, e)
result.errors += 1

View file

@ -138,7 +138,7 @@ class MissingCoverArtJob(RepairJob):
# Create finding for user to approve
if context.create_finding:
try:
context.create_finding(
inserted = context.create_finding(
job_id=self.job_id,
finding_type='missing_cover_art',
severity='info',
@ -156,7 +156,10 @@ class MissingCoverArtJob(RepairJob):
'artist_thumb_url': artist_thumb or None,
}
)
result.findings_created += 1
if inserted:
result.findings_created += 1
else:
result.findings_skipped_dedup += 1
except Exception as e:
logger.debug("Error creating cover art finding for album %s: %s", album_id, e)
result.errors += 1

View file

@ -217,7 +217,7 @@ class OrphanFileDetectorJob(RepairJob):
stat = os.stat(fpath)
ext = os.path.splitext(fpath)[1].lower().lstrip('.')
if context.create_finding:
context.create_finding(
inserted = context.create_finding(
job_id=self.job_id,
finding_type='orphan_file',
severity='warning' if mass_orphan else 'info',
@ -241,7 +241,10 @@ class OrphanFileDetectorJob(RepairJob):
'mass_orphan': mass_orphan,
}
)
result.findings_created += 1
if inserted:
result.findings_created += 1
else:
result.findings_skipped_dedup += 1
except Exception as e:
logger.debug("Error creating orphan finding for %s: %s", fpath, e)
result.errors += 1

View file

@ -196,7 +196,7 @@ class SingleAlbumDedupJob(RepairJob):
if context.create_finding:
try:
context.create_finding(
inserted = context.create_finding(
job_id=self.job_id,
finding_type='single_album_redundant',
severity='info',
@ -235,7 +235,10 @@ class SingleAlbumDedupJob(RepairJob):
'artist_thumb_url': best_album_match.get('artist_thumb_url') or single.get('artist_thumb_url'),
}
)
result.findings_created += 1
if inserted:
result.findings_created += 1
else:
result.findings_skipped_dedup += 1
except Exception as e:
logger.debug("Error creating finding: %s", e)
result.errors += 1

View file

@ -237,7 +237,7 @@ class TrackNumberRepairJob(RepairJob):
details['album_title'] = art_info['album_title']
if art_info.get('artist_name'):
details['artist_name'] = art_info['artist_name']
context.create_finding(
inserted = context.create_finding(
job_id=self.job_id,
finding_type='track_number_mismatch',
severity='warning',
@ -248,7 +248,10 @@ class TrackNumberRepairJob(RepairJob):
description=finding['description'],
details=details
)
result.findings_created += 1
if inserted:
result.findings_created += 1
else:
result.findings_skipped_dedup += 1
else:
if _repair_single_track(fpath, fname, api_tracks, len(api_tracks), title_sim, context):
result.auto_fixed += 1

View file

@ -190,7 +190,7 @@ class UnknownArtistFixerJob(RepairJob):
desc_parts.append(f'Path: → {expected_rel}')
if context.create_finding:
context.create_finding(
inserted = context.create_finding(
job_id=self.job_id,
finding_type='unknown_artist',
severity='warning',
@ -217,7 +217,10 @@ class UnknownArtistFixerJob(RepairJob):
'cover_url': corrected.get('image_url', ''),
}
)
result.findings_created += 1
if inserted:
result.findings_created += 1
else:
result.findings_skipped_dedup += 1
else:
# Live mode — apply fix
try:

View file

@ -655,8 +655,18 @@ class RepairWorker:
# ------------------------------------------------------------------
def _create_finding(self, job_id: str, finding_type: str, severity: str,
entity_type: str, entity_id: str, file_path: str,
title: str, description: str, details: dict = None):
"""Create a repair finding in the database."""
title: str, description: str, details: dict = None) -> bool:
"""Create a repair finding in the database.
Returns:
True a NEW pending row was inserted.
False dedup-skipped (an equivalent row already exists with
status pending/resolved/dismissed) OR a DB error
occurred. Callers should only increment their
``findings_created`` counter when this returns True
so the badge / scan log reports REAL new findings,
not silently-skipped duplicates.
"""
conn = None
try:
conn = self.db._get_connection()
@ -672,7 +682,7 @@ class RepairWorker:
""", (job_id, finding_type, entity_type, entity_id, file_path))
if cursor.fetchone():
return # Already exists or was already fixed
return False # Already exists or was already fixed
cursor.execute("""
INSERT INTO repair_findings
@ -685,8 +695,10 @@ class RepairWorker:
json.dumps(details) if details else '{}'
))
conn.commit()
return True
except Exception as e:
logger.debug("Error creating finding: %s", e)
return False
finally:
if conn:
conn.close()

View file

@ -127,7 +127,7 @@ def _make_context(conn):
report_progress=lambda *args, **kwargs: None,
sleep_or_stop=lambda seconds: False,
mb_client=_FakeMBClient(),
create_finding=lambda **kwargs: findings.append(kwargs),
create_finding=lambda **kwargs: (findings.append(kwargs) or True),
findings=findings,
)

View file

@ -528,7 +528,7 @@ def test_scan_uses_cached_api_track_count_without_expected_total_lookup(monkeypa
job._get_expected_total = spy
findings = []
context = _make_job_context(db, create_finding=lambda **kwargs: findings.append(kwargs))
context = _make_job_context(db, create_finding=lambda **kwargs: (findings.append(kwargs) or True))
result = job.scan(context)
@ -568,7 +568,7 @@ def test_scan_falls_back_to_api_and_persists_count_on_cache_miss(monkeypatch):
)
findings = []
context = _make_job_context(db, create_finding=lambda **kwargs: findings.append(kwargs))
context = _make_job_context(db, create_finding=lambda **kwargs: (findings.append(kwargs) or True))
job = AlbumCompletenessJob()
result = job.scan(context)
@ -606,7 +606,7 @@ def test_scan_ignores_track_count_completely(monkeypatch):
)
findings = []
context = _make_job_context(db, create_finding=lambda **kwargs: findings.append(kwargs))
context = _make_job_context(db, create_finding=lambda **kwargs: (findings.append(kwargs) or True))
job = AlbumCompletenessJob()
result = job.scan(context)

View file

@ -233,6 +233,8 @@ def _build_context(rows: list, tmp_path: Path) -> SimpleNamespace:
def _create_finding(**kwargs):
findings_created.append(kwargs)
# Mirror real `_create_finding` contract: True on insert.
return True
ctx = SimpleNamespace(
db=_FakeDB(),

View file

@ -0,0 +1,208 @@
"""Pin the bug fix where `_create_finding` silently dedup-skipped while
the caller's `findings_created` counter incremented anyway, causing
the maintenance job badge to inflate (e.g. "364 findings" displayed
when 0 new pending rows existed in the DB).
Now `_create_finding` returns:
- True a NEW pending row was inserted.
- False dedup-skipped (an equivalent row already exists with status
pending/resolved/dismissed) OR a DB error occurred.
Callers must only increment `findings_created` on True. Skipped-dedup
counter exposed separately as `findings_skipped_dedup` for log
transparency.
"""
from __future__ import annotations
import os
import sqlite3
import tempfile
from unittest.mock import MagicMock
import pytest
from core.repair_jobs.base import JobResult
from core.repair_worker import RepairWorker
@pytest.fixture
def repair_worker_with_temp_db():
"""A RepairWorker wired to a temporary SQLite db with the
`repair_findings` table created. Returns the worker; tears the db
down after the test."""
fd, path = tempfile.mkstemp(suffix='.db')
os.close(fd)
conn = sqlite3.connect(path)
conn.execute("""
CREATE TABLE repair_findings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id TEXT NOT NULL,
finding_type TEXT NOT NULL,
severity TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
entity_type TEXT,
entity_id TEXT,
file_path TEXT,
title TEXT,
description TEXT,
details_json TEXT,
user_action TEXT,
resolved_at TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)
""")
conn.commit()
conn.close()
db_mock = MagicMock()
db_mock._get_connection = lambda: sqlite3.connect(path)
worker = RepairWorker.__new__(RepairWorker)
worker.db = db_mock
yield worker, path
try:
os.remove(path)
except OSError:
pass
def test_create_finding_returns_true_on_first_insert(repair_worker_with_temp_db):
worker, _ = repair_worker_with_temp_db
result = worker._create_finding(
job_id='dup_detector',
finding_type='duplicate_tracks',
severity='info',
entity_type='track',
entity_id='123',
file_path='/foo/bar.mp3',
title='Test finding',
description='desc',
)
assert result is True
def test_create_finding_returns_false_on_dedup_pending(repair_worker_with_temp_db):
worker, _ = repair_worker_with_temp_db
# First insert
worker._create_finding(
job_id='dup_detector', finding_type='duplicate_tracks',
severity='info', entity_type='track', entity_id='123',
file_path='/foo/bar.mp3', title='T', description='D',
)
# Re-call with same args — should dedup
result = worker._create_finding(
job_id='dup_detector', finding_type='duplicate_tracks',
severity='info', entity_type='track', entity_id='123',
file_path='/foo/bar.mp3', title='T', description='D',
)
assert result is False
def test_create_finding_returns_false_on_dedup_dismissed(repair_worker_with_temp_db):
"""The user dismissed a finding in a prior session. A new scan
that re-discovers the same issue must NOT increment the badge
the row exists with status='dismissed'."""
worker, path = repair_worker_with_temp_db
# Seed the DB with a dismissed finding
conn = sqlite3.connect(path)
conn.execute("""
INSERT INTO repair_findings
(job_id, finding_type, severity, status, entity_type, entity_id,
file_path, title, description)
VALUES (?, ?, 'info', 'dismissed', 'track', '123', '/foo/bar.mp3', 'T', 'D')
""", ('dup_detector', 'duplicate_tracks'))
conn.commit()
conn.close()
result = worker._create_finding(
job_id='dup_detector', finding_type='duplicate_tracks',
severity='info', entity_type='track', entity_id='123',
file_path='/foo/bar.mp3', title='T', description='D',
)
assert result is False
def test_create_finding_returns_false_on_dedup_resolved(repair_worker_with_temp_db):
"""An auto-fix or manual repair previously resolved a finding.
Re-discovery of the same issue should NOT inflate the badge."""
worker, path = repair_worker_with_temp_db
conn = sqlite3.connect(path)
conn.execute("""
INSERT INTO repair_findings
(job_id, finding_type, severity, status, entity_type, entity_id,
file_path, title, description)
VALUES (?, ?, 'info', 'resolved', 'track', '123', '/foo/bar.mp3', 'T', 'D')
""", ('dup_detector', 'duplicate_tracks'))
conn.commit()
conn.close()
result = worker._create_finding(
job_id='dup_detector', finding_type='duplicate_tracks',
severity='info', entity_type='track', entity_id='123',
file_path='/foo/bar.mp3', title='T', description='D',
)
assert result is False
def test_create_finding_inserts_again_when_distinct_entity(repair_worker_with_temp_db):
"""A different entity (different track id) is a NEW finding —
must NOT be dedup-blocked by an unrelated finding's existence."""
worker, _ = repair_worker_with_temp_db
worker._create_finding(
job_id='dup_detector', finding_type='duplicate_tracks',
severity='info', entity_type='track', entity_id='123',
file_path='/foo/bar.mp3', title='T', description='D',
)
result = worker._create_finding(
job_id='dup_detector', finding_type='duplicate_tracks',
severity='info', entity_type='track', entity_id='456',
file_path='/foo/baz.mp3', title='T', description='D',
)
assert result is True
# ---------------------------------------------------------------------------
# JobResult — findings_skipped_dedup field is exposed
# ---------------------------------------------------------------------------
def test_job_result_has_skipped_dedup_field():
result = JobResult()
assert result.findings_skipped_dedup == 0
result.findings_skipped_dedup += 1
assert result.findings_skipped_dedup == 1
# ---------------------------------------------------------------------------
# End-to-end pattern: caller counts only true inserts
# ---------------------------------------------------------------------------
def test_caller_pattern_counts_only_real_inserts(repair_worker_with_temp_db):
"""Simulate a job loop calling create_finding 5 times for the
same finding. Only the FIRST should count toward findings_created;
the remaining 4 should count toward findings_skipped_dedup. Badge
must reflect 1, not 5."""
worker, _ = repair_worker_with_temp_db
result = JobResult()
for _ in range(5):
inserted = worker._create_finding(
job_id='dup_detector', finding_type='duplicate_tracks',
severity='info', entity_type='track', entity_id='123',
file_path='/foo/bar.mp3', title='T', description='D',
)
if inserted:
result.findings_created += 1
else:
result.findings_skipped_dedup += 1
assert result.findings_created == 1
assert result.findings_skipped_dedup == 4

View file

@ -66,6 +66,8 @@ class _FakeContext:
def _create_finding(self, **kwargs):
self.findings.append(kwargs)
# Mirror real `_create_finding` contract: True on insert.
return True
# ---------------------------------------------------------------------------

View file

@ -119,7 +119,8 @@ def _make_context(conn, prefer_source=None):
wait_if_paused=lambda: False,
update_progress=lambda *args, **kwargs: None,
report_progress=lambda *args, **kwargs: None,
create_finding=lambda **kwargs: findings.append(kwargs),
# Mirror real `_create_finding` contract: True on insert.
create_finding=lambda **kwargs: (findings.append(kwargs) or True),
findings=findings,
)

View file

@ -33079,7 +33079,9 @@ try:
state['status'] = status
state['progress'] = 100
state['finished_at'] = datetime.now(timezone.utc).isoformat()
summary = f'Done: {result.scanned} scanned, {result.auto_fixed} fixed, {result.findings_created} findings, {result.errors} errors'
skipped_dedup = getattr(result, 'findings_skipped_dedup', 0) or 0
existing_part = f' ({skipped_dedup} already existed)' if skipped_dedup else ''
summary = f'Done: {result.scanned} scanned, {result.auto_fixed} fixed, {result.findings_created} findings{existing_part}, {result.errors} errors'
state['log'].append({'type': 'success' if status == 'finished' else 'error', 'text': summary})
try:
socketio.emit('repair:progress', {job_id: dict(state)})

View file

@ -6356,7 +6356,7 @@
<option value="info">Info</option>
<option value="warning">Warning</option>
</select>
<select id="repair-findings-status-filter" onchange="_repairFindingsPage=0;loadRepairFindings()">
<select id="repair-findings-status-filter" onchange="_repairFindingsPage=0;_repairFindingsAutoSwitched=true;loadRepairFindings()">
<option value="pending">Pending</option>
<option value="">All Status</option>
<option value="resolved">Resolved</option>

View file

@ -1425,6 +1425,7 @@ let _repairCurrentTab = 'jobs';
let _repairFindingsPage = 0;
let _repairSelectedFindings = new Set();
let _repairFindingsTotal = 0;
let _repairFindingsAutoSwitched = false; // Set after auto-switching to "All Status" so we don't loop
const REPAIR_FINDINGS_PAGE_SIZE = 30;
let _repairJobsCache = {}; // Cache job data for help modal
@ -2459,6 +2460,38 @@ async function loadRepairFindings() {
if (selectAllCb) { selectAllCb.checked = false; selectAllCb.indeterminate = false; }
if (items.length === 0) {
// If the user is on the default "pending" filter and there are
// ZERO pending rows but other statuses (dismissed/resolved) do
// have rows, auto-switch the filter to "All Status" so the user
// sees the carry-over findings instead of an empty pane. Common
// case: scanner re-found same issues that were dismissed
// previously — dedup-skip means no new pending row, so the
// default-filtered tab looks empty even though the badge count
// referenced existing dismissed rows.
if (statusFilter && statusFilter.value === 'pending' && !_repairFindingsAutoSwitched) {
try {
const countsResp = await fetch('/api/repair/findings/counts');
if (countsResp.ok) {
const counts = await countsResp.json();
const otherTotal = (counts.resolved || 0) + (counts.dismissed || 0) + (counts.auto_fixed || 0);
if (otherTotal > 0) {
_repairFindingsAutoSwitched = true;
statusFilter.value = '';
_repairFindingsPage = 0;
await loadRepairFindings();
const list = document.getElementById('repair-findings-list');
if (list && !list.querySelector('.repair-auto-switch-notice')) {
const notice = document.createElement('div');
notice.className = 'repair-auto-switch-notice';
notice.style.cssText = 'background:rgba(96,165,250,0.08);border:1px solid rgba(96,165,250,0.25);border-radius:8px;padding:10px 14px;margin-bottom:12px;font-size:13px;color:#cbd5e1;';
notice.innerHTML = `No <b>pending</b> findings, but ${otherTotal.toLocaleString()} carry-over (resolved/dismissed/auto-fixed). Showing <b>All Status</b> — change the filter above to switch back.`;
list.parentNode.insertBefore(notice, list);
}
return;
}
}
} catch (e) { /* fall through to empty state */ }
}
container.innerHTML = `<div class="repair-empty-state">
<div class="repair-empty-icon">&#10003;</div>
<div class="repair-empty-title">All Clear</div>

View file

@ -3435,6 +3435,7 @@ const WHATS_NEW = {
{ title: 'Internal: Typed Metadata Foundation', desc: 'internal — first step of a multi-pr migration to give the metadata pipeline a real contract. the codebase historically grew duck-typed extractors (`_extract_lookup_value(album_data, "id", "album_id", "collectionId", "release_id", default=...)`) at every consumer site because each provider returns its own response shape. ~150 of those across the codebase. new `core/metadata/types.py` defines canonical typed `Album` / `Track` / `Artist` dataclasses with strict required fields. per-source classmethod converters (from_spotify_dict, from_itunes_dict, from_deezer_dict, from_discogs_dict, from_musicbrainz_dict, from_hydrabase_dict) are the SINGLE place that knows each provider\'s wire shape. zero behavior changes in this pr — pure additive foundation. follow-up prs migrate consumers one at a time. full migration plan documented at docs/metadata-types-migration.md.', page: 'library' },
{ title: 'Internal: Migrate Album-Info Builders to Typed Path', desc: 'internal — steps 2+3 of the typed metadata migration in one pr. two album-info builders now route through `Album.from_<source>_dict()` when the caller passes a known source: `_build_album_info` (used by every album-tracks lookup) and the embedded album section of `_build_single_import_context_payload` (used by single-track import context resolution). legacy duck-typed extraction stays as the fallback when source is empty/unknown, raw input isn\'t a dict, or the typed converter raises — so a converter bug can\'t break album resolution or import context. caller-provided album_id / album_name / artist_name fallbacks apply on the typed path the same way they did on legacy. zero behavior change for existing callers since they don\'t pass a source yet — opt-in only. 22 new tests pin the typed path, the legacy fallback, and parametrized coverage across registered providers.' },
{ title: 'Internal: Migrate Discography + Quality Scanner to Typed Path', desc: 'internal — next round of the typed metadata migration. three more album-shape consumers now route through `Album.from_<source>_dict()` when the caller passes a known source: `_build_discography_release_dict` (artist discography release cards), `_build_artist_detail_release_card` (artist detail page release cards), and `_normalize_track_album` (quality scanner result normalization). legacy duck-typed extraction stays as the fallback when source is empty/unknown, raw input isn\'t a dict, or the typed converter raises — same safety contract as the prior migration steps. 20 new tests pin the typed path + legacy fallback + parametrized coverage across registered providers.' },
{ title: 'Fix: Maintenance Findings Badge Showed Inflated Count With Empty Findings Tab', desc: 'discord report (husoyo): duplicate detector and cover art filler badges showed "364 findings" / "31 findings" after a scan, but clicking into the findings tab showed nothing. cause: `_create_finding` silently dedup-skipped re-discovered issues (when an equivalent row already existed with status pending/resolved/dismissed) but the caller incremented `result.findings_created` regardless of whether a row was actually inserted. so on a re-scan that found the same problems as a prior scan, the badge snapshot recorded 364 even though zero NEW pending rows hit the db. fix: `_create_finding` now returns a bool (True on insert, False on dedup-skip / db error). all 16 repair jobs updated to only increment `findings_created` on True. new `findings_skipped_dedup` counter added to job results and surfaced in the scan log: "Done: 2791 scanned, 0 fixed, 0 findings (363 already existed), 0 errors" — so re-scans show a real count, and you can see at a glance how many findings were carried over from prior scans. also fixed a missing `job_id` kwarg in the album tag consistency job that was silently breaking finding creation for that scan. companion ux improvement: findings tab now auto-switches its status filter from "pending" to "all status" when 0 pending rows exist but resolved/dismissed/auto-fixed rows do — with a small notice so you can see what carried over instead of staring at an "all clear" empty state.', page: 'library' },
{ title: 'Discogs Collection in "Your Albums"', desc: 'discord request: pull your discogs collection into the your albums section on discover, similar to spotify liked albums. set your discogs personal access token on settings → connections (already there from prior work) and add discogs as one of the configured sources via the gear button on your albums. background fetcher pulls your full collection (all folders, all pages — capped at 5000 releases), normalizes artist names (strips discogs `(N)` disambiguation suffix), dedupes against any spotify/tidal/deezer-saved versions of the same album. clicking a discogs-only album opens with discogs context — full release detail (year, format, label, country, tracklist) from the /releases endpoint. clicking an album that exists in both your spotify saved AND discogs collection prefers spotify (download flow is more direct). discogs is physical-media-first so many releases won\'t have streaming equivalents — those still show in the grid but the modal flow may need to fall back to a name search to find a downloadable digital version.', page: 'discover' },
{ title: 'Drop Redundant "Your Spotify Library" Section on Discover', desc: 'discover page used to show two near-identical sections: "Your Albums" (cross-source aggregator across spotify/deezer/etc) AND "Your Spotify Library" (spotify-only). same UI, same grid, same filter / sort / download-missing controls — the spotify-only one was a strict subset of what your albums already covers. removed it. spotify saved albums still surface via the your albums section with spotify as one of its configured sources (gear button → configure sources). backend collection / storage is unchanged — the watchlist scanner still populates the spotify_library_albums cache for your albums to read.', page: 'discover' },
{ title: 'Library Disk Usage on Stats Page', desc: 'discord request (samuel [KC]): show how much disk space the library takes. new card on stats → system statistics shows total bytes + per-format breakdown (FLAC vs MP3 vs M4A bars). data comes from `tracks.file_size` populated during deep scan from whatever the media server already returns (plex MediaPart.size, jellyfin MediaSources[].Size, navidrome song.size, soulsync standalone os.path.getsize) — zero filesystem walk overhead. existing libraries see "Run a Deep Scan to populate" until the next deep scan fills in sizes; partial coverage shown as "X tracks measured (+Y pending)". migration is additive (NULL on legacy rows) so upgrading users have nothing to do.', page: 'stats' },