Add diagnostic drilldowns

This commit is contained in:
Philip Guzman
2026-07-01 09:17:25 -07:00
parent fb3d70e579
commit d2f625ed18
6 changed files with 301 additions and 7 deletions
+35
View File
@@ -0,0 +1,35 @@
# Diagnostic Drill-Downs
## Problem
Aggregate health numbers are useful, but DJs need to understand what is behind
each number before they can trust it. A count like "60 missing tracks" should be
clickable enough to answer: which tracks, which saved paths, and why did Serato
Doctor count them?
## Architecture
The health engine remains responsible for aggregate scoring. The web layer adds
a separate `details` payload beside the existing health report so the UI can show
examples without changing the core score model.
The drill-down payload is intentionally capped. Serato Doctor should explain the
finding quickly in the local browser, not dump an entire user library into the
page.
## Edge Cases
- Smart/dynamic crate references stay excluded from old-reference scoring.
- The same missing filename can appear more than once in Serato's database.
- Duplicate filename and cloud-conflict groups are informational only.
- Suggested matches remain read-only evidence and must never trigger repair.
- Empty drill-downs should feel reassuring, not broken.
## Testing
- Web analysis should include detail sections for old crate references,
suggested matches, unused tracks, duplicates, cloud conflicts, broken
symlinks, and missing Serato database tracks.
- Static assets should expose clickable diagnostic hooks.
- Browser behavior should be verified manually when the local browser policy
allows access to the development server.
+177
View File
@@ -1,5 +1,6 @@
import argparse
import json
from collections import Counter
from dataclasses import asdict
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from importlib import resources
@@ -8,12 +9,17 @@ from typing import Iterable
from serato_doctor.crate_parser import load_library_crates
from serato_doctor.database_parser import parse_database
from serato_doctor.duplicates import find_duplicate_groups
from serato_doctor.health import analyze_health
from serato_doctor.matching import MatchingEngine, normalize
from serato_doctor.models.crate import CrateKind
from serato_doctor.models.duplicate import DuplicateKind
from serato_doctor.models.library import Library
from serato_doctor.scanner import scan_filesystem
MAX_REQUEST_BYTES = 64 * 1024
DETAIL_LIMIT = 50
STATIC_FILES = {
"/": ("index.html", "text/html; charset=utf-8"),
"/app.css": ("app.css", "text/css; charset=utf-8"),
@@ -21,6 +27,176 @@ STATIC_FILES = {
}
def _display_path(path: Path) -> str:
return str(path)
def _first_reason(match) -> str:
for item in match.evidence:
if item.matched:
return item.explanation
return "Filename-related candidate"
def diagnostic_details(library: Library, limit: int = DETAIL_LIMIT) -> dict:
dynamic_sources = {
crate.path for crate in library.crates if crate.kind is CrateKind.SMART
}
results = tuple(
result
for result in library.reconcile_by_filename()
if result.reference.source not in dynamic_sources
)
missing = [result for result in results if not result.exists_by_filename]
matcher = MatchingEngine(library.tracks)
suggested_matches = []
for result in missing:
candidates = matcher.candidates_for(result.reference)
if not candidates:
continue
best = candidates[0]
suggested_matches.append(
{
"filename": result.reference.filename,
"crate": _display_path(result.reference.source),
"saved_path": _display_path(result.reference.path),
"candidate": _display_path(best.track.path),
"score": f"{best.score_percent}%",
"reason": _first_reason(best),
}
)
duplicate_groups = find_duplicate_groups(library.tracks)
exact_duplicates = [
group
for group in duplicate_groups
if group.kind is DuplicateKind.EXACT_NAME
]
cloud_conflicts = [
group
for group in duplicate_groups
if group.kind is DuplicateKind.CLOUD_CONFLICT
]
database_tracks = library.database.tracks if library.database else ()
missing_database_tracks = [
track for track in database_tracks if not track.path.exists()
]
database_filename_counts = Counter(
normalize(track.filename) for track in missing_database_tracks
)
referenced_names = {reference.filename for reference in library.references}
unused_tracks = [
track for track in library.tracks if track.filename not in referenced_names
]
return {
"database_missing_tracks": {
"title": "Missing tracks in Serato",
"summary": (
"These are Serato database entries whose saved file location "
"does not currently exist on disk."
),
"total": len(missing_database_tracks),
"items": [
{
"filename": track.filename,
"saved_path": _display_path(track.path),
"artist": track.artist or "Unknown artist",
"title": track.title or track.filename,
"repeated_filename": database_filename_counts[
normalize(track.filename)
]
> 1,
}
for track in missing_database_tracks[:limit]
],
},
"old_crate_references": {
"title": "Old crate references",
"summary": (
"These are regular crate appearances whose exact filename was "
"not found in the selected music folder."
),
"total": len(missing),
"items": [
{
"filename": result.reference.filename,
"crate": _display_path(result.reference.source),
"saved_path": _display_path(result.reference.path),
}
for result in missing[:limit]
],
},
"suggested_matches": {
"title": "Suggested matches",
"summary": (
"These are read-only guesses where Serato Doctor found a "
"filename-related candidate on disk."
),
"total": len(suggested_matches),
"items": suggested_matches[:limit],
},
"duplicate_filenames": {
"title": "Duplicate filenames",
"summary": (
"These groups contain different files with the same cleaned-up "
"filename. Review before making any decisions."
),
"total": len(exact_duplicates),
"items": [
{
"filename": group.display_name,
"files": [_display_path(track.path) for track in group.tracks],
}
for group in exact_duplicates[:limit]
],
},
"cloud_conflicts": {
"title": "Possible cloud conflicts",
"summary": (
"These filename families look like cloud sync conflict copies, "
"such as a duplicate ending in a number."
),
"total": len(cloud_conflicts),
"items": [
{
"filename": group.display_name,
"files": [_display_path(track.path) for track in group.tracks],
}
for group in cloud_conflicts[:limit]
],
},
"broken_symlinks": {
"title": "Broken shortcuts",
"summary": (
"These symbolic links point somewhere that no longer resolves."
),
"total": len(library.broken_symlinks),
"items": [
{
"path": _display_path(link.path),
"target": _display_path(link.target) if link.target else "Unknown",
}
for link in library.broken_symlinks[:limit]
],
},
"unused_tracks": {
"title": "Unused tracks",
"summary": (
"These scanned files were not referenced by any loaded crate. "
"That does not mean they should be deleted."
),
"total": len(unused_tracks),
"items": [
{"filename": track.filename, "path": _display_path(track.path)}
for track in unused_tracks[:limit]
],
},
}
def analyze_paths(
serato: Path, music: Path, reference_roots: Iterable[Path] = ()
) -> dict:
@@ -45,6 +221,7 @@ def analyze_paths(
report = analyze_health(library)
result = asdict(report)
result["score_basis"] = report.score_basis
result["details"] = diagnostic_details(library)
return result
File diff suppressed because one or more lines are too long
+68
View File
@@ -3,12 +3,69 @@ const button = document.querySelector('#analyze-button');
const errorBox = document.querySelector('#error-message');
const results = document.querySelector('#dashboard');
const infoButtons = document.querySelectorAll('.info-button');
const drillTriggers = document.querySelectorAll('[data-detail]');
const drilldownTitle = document.querySelector('#drilldown-title');
const drilldownCount = document.querySelector('#drilldown-count');
const drilldownSummary = document.querySelector('#drilldown-summary');
const drilldownList = document.querySelector('#drilldown-list');
let latestAnalysis = null;
function expandHome(path) {
return path.trim();
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, (character) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;',
}[character]));
}
function detailLines(item) {
if (item.files) {
return item.files.map((file) => `<li>${escapeHtml(file)}</li>`).join('');
}
const lines = [];
if (item.artist || item.title) lines.push(`${item.artist || 'Unknown artist'}${item.title || item.filename}`);
if (item.crate) lines.push(`Crate: ${item.crate}`);
if (item.saved_path) lines.push(`Saved path: ${item.saved_path}`);
if (item.candidate) lines.push(`Candidate: ${item.candidate}`);
if (item.path) lines.push(`File: ${item.path}`);
if (item.target) lines.push(`Target: ${item.target}`);
if (item.score || item.reason) lines.push(`${item.score || 'Match'} · ${item.reason || 'Candidate found'}`);
if (item.repeated_filename) lines.push('Same filename appears more than once in Seratos missing list.');
return lines.map((line) => `<li>${escapeHtml(line)}</li>`).join('');
}
function renderDetail(key) {
const detail = latestAnalysis?.details?.[key];
if (!detail) return;
drillTriggers.forEach((trigger) => {
trigger.classList.toggle('selected', trigger.dataset.detail === key);
});
drilldownTitle.textContent = detail.title;
drilldownCount.textContent = `${detail.total ?? 0} found`;
drilldownSummary.textContent = detail.summary;
if (!detail.items?.length) {
drilldownList.innerHTML = '<div class="empty-detail">Nothing to review here. Tiny victory parade, very tasteful.</div>';
return;
}
drilldownList.innerHTML = detail.items.map((item) => `
<article class="detail-item">
<strong>${escapeHtml(item.filename || item.path || 'Untitled item')}</strong>
<ul>${detailLines(item)}</ul>
</article>
`).join('');
}
function render(data) {
latestAnalysis = data;
document.querySelectorAll('[data-field]').forEach((element) => {
const value = data[element.dataset.field];
element.textContent = value ?? '—';
@@ -21,6 +78,7 @@ function render(data) {
: score >= 95 ? 'Looking excellent' : score >= 80 ? 'A few things need attention' : 'Review recommended';
document.querySelector('#score-basis').textContent = data.score_basis;
document.querySelector('#analysis-time').textContent = `Completed ${new Date().toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})}`;
renderDetail(data.database_missing_paths > 0 ? 'database_missing_tracks' : 'old_crate_references');
results.hidden = false;
results.scrollIntoView({behavior: 'smooth', block: 'start'});
}
@@ -44,6 +102,16 @@ infoButtons.forEach((infoButton) => {
});
});
drillTriggers.forEach((trigger) => {
trigger.addEventListener('click', () => renderDetail(trigger.dataset.detail));
trigger.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
renderDetail(trigger.dataset.detail);
}
});
});
document.addEventListener('click', () => closeInfoButtons());
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeInfoButtons();
+13 -7
View File
@@ -65,9 +65,9 @@
</article>
<div class="metrics-grid">
<article class="metric panel"><button class="info-button" type="button" aria-label="About tracks" aria-expanded="false" data-info="Audio files found inside the music folder you selected. This is the collection Serato Doctor compared with your crates and database.">i</button><span>Tracks scanned</span><strong data-field="disk_tracks"></strong><small>audio files found</small></article>
<article class="metric panel warning"><button class="info-button" type="button" aria-label="About missing tracks in Serato" aria-expanded="false" data-info="Tracks in Serato's database whose saved file location no longer exists. This should be close to the orange or unmapped track count you see in Serato.">i</button><span>Missing tracks in Serato</span><strong data-field="database_missing_paths"></strong><small><b data-field="database_missing_unique_filenames"></b> unique filenames</small></article>
<article class="metric panel"><button class="info-button" type="button" aria-label="About unused tracks" aria-expanded="false" data-info="Files in the selected music folder whose filename is not used by any loaded crate. They may still be valid library tracks; this is informational, not a deletion recommendation.">i</button><span>Unused tracks</span><strong data-field="unused_tracks"></strong><small>not referenced by crates</small></article>
<article class="metric panel"><button class="info-button" type="button" aria-label="About suggested matches" aria-expanded="false" data-info="Missing crate entries with a filename-related candidate, such as an added OneDrive conflict number. Suggestions are evidence for review, never automatic repairs.">i</button><span>Suggested matches</span><strong data-field="suggested_matches"></strong><small>explainable candidates</small></article>
<article class="metric panel warning drill-trigger" role="button" tabindex="0" data-detail="database_missing_tracks"><button class="info-button" type="button" aria-label="About missing tracks in Serato" aria-expanded="false" data-info="Tracks in Serato's database whose saved file location no longer exists. This should be close to the orange or unmapped track count you see in Serato.">i</button><span>Missing tracks in Serato</span><strong data-field="database_missing_paths"></strong><small><b data-field="database_missing_unique_filenames"></b> unique filenames · click for list</small></article>
<article class="metric panel drill-trigger" role="button" tabindex="0" data-detail="unused_tracks"><button class="info-button" type="button" aria-label="About unused tracks" aria-expanded="false" data-info="Files in the selected music folder whose filename is not used by any loaded crate. They may still be valid library tracks; this is informational, not a deletion recommendation.">i</button><span>Unused tracks</span><strong data-field="unused_tracks"></strong><small>not referenced by crates · click for list</small></article>
<article class="metric panel drill-trigger" role="button" tabindex="0" data-detail="suggested_matches"><button class="info-button" type="button" aria-label="About suggested matches" aria-expanded="false" data-info="Missing crate entries with a filename-related candidate, such as an added OneDrive conflict number. Suggestions are evidence for review, never automatic repairs.">i</button><span>Suggested matches</span><strong data-field="suggested_matches"></strong><small>explainable candidates · click for list</small></article>
</div>
</div>
@@ -75,13 +75,19 @@
<div class="section-heading"><div><p class="eyebrow">Full picture</p><h2>Diagnostics</h2></div><span class="read-only-tag">No changes made</span></div>
<div class="diagnostic-list">
<div><span class="diag-icon violet"></span><p><strong>Crates</strong><small><b data-field="static_crates"></b> regular · <b data-field="smart_crates"></b> smart · <b data-field="smart_crate_containers"></b> dynamic containers</small></p><button class="info-button" type="button" aria-label="About crates" aria-expanded="false" data-info="Regular crates are lists you maintain by hand. Smart crates are rebuilt by Serato from rules, so their generated references are not scored as broken.">i</button></div>
<div><span class="diag-icon amber"></span><p><strong>Duplicate filenames</strong><small><b data-field="duplicate_filename_groups"></b> exact groups · <b data-field="duplicate_files"></b> extra files</small></p><button class="info-button" type="button" aria-label="About duplicate filenames" aria-expanded="false" data-info="Different files with the same filename after case and Unicode cleanup. They need review, but matching names alone do not mean either file should be deleted.">i</button></div>
<div><span class="diag-icon blue"></span><p><strong>Possible cloud conflicts</strong><small><b data-field="suspected_cloud_conflict_groups"></b> groups · <b data-field="suspected_cloud_conflict_files"></b> extra files</small></p><button class="info-button" type="button" aria-label="About cloud conflicts" aria-expanded="false" data-info="Filename families such as Track.mp3 and Track 2.mp3. OneDrive often creates these during sync conflicts, but numbered song titles can also be legitimate.">i</button></div>
<div><span class="diag-icon red"></span><p><strong>Broken shortcuts</strong><small><b data-field="broken_symlinks"></b> unresolved symbolic links</small></p><button class="info-button" type="button" aria-label="About broken shortcuts" aria-expanded="false" data-info="Shortcut-style symbolic links whose destination no longer exists. Serato Doctor reports them but never removes or recreates them automatically.">i</button></div>
<div><span class="diag-icon violet"></span><p><strong>Old crate references</strong><small><b data-field="missing_references"></b> appearances · <b data-field="unique_missing_filenames"></b> unique filenames</small></p><button class="info-button" type="button" aria-label="About old crate references" aria-expanded="false" data-info="Saved spots in regular crates whose exact filename was not found in the selected music folder. The same track can appear in several crates, so appearances are higher than unique filenames. These are separate from Serato's unmapped-track count.">i</button></div>
<div class="drill-trigger" role="button" tabindex="0" data-detail="duplicate_filenames"><span class="diag-icon amber"></span><p><strong>Duplicate filenames</strong><small><b data-field="duplicate_filename_groups"></b> exact groups · <b data-field="duplicate_files"></b> extra files · click for list</small></p><button class="info-button" type="button" aria-label="About duplicate filenames" aria-expanded="false" data-info="Different files with the same filename after case and Unicode cleanup. They need review, but matching names alone do not mean either file should be deleted.">i</button></div>
<div class="drill-trigger" role="button" tabindex="0" data-detail="cloud_conflicts"><span class="diag-icon blue"></span><p><strong>Possible cloud conflicts</strong><small><b data-field="suspected_cloud_conflict_groups"></b> groups · <b data-field="suspected_cloud_conflict_files"></b> extra files · click for list</small></p><button class="info-button" type="button" aria-label="About cloud conflicts" aria-expanded="false" data-info="Filename families such as Track.mp3 and Track 2.mp3. OneDrive often creates these during sync conflicts, but numbered song titles can also be legitimate.">i</button></div>
<div class="drill-trigger" role="button" tabindex="0" data-detail="broken_symlinks"><span class="diag-icon red"></span><p><strong>Broken shortcuts</strong><small><b data-field="broken_symlinks"></b> unresolved symbolic links · click for list</small></p><button class="info-button" type="button" aria-label="About broken shortcuts" aria-expanded="false" data-info="Shortcut-style symbolic links whose destination no longer exists. Serato Doctor reports them but never removes or recreates them automatically.">i</button></div>
<div class="drill-trigger" role="button" tabindex="0" data-detail="old_crate_references"><span class="diag-icon violet"></span><p><strong>Old crate references</strong><small><b data-field="missing_references"></b> appearances · <b data-field="unique_missing_filenames"></b> unique filenames · click for list</small></p><button class="info-button" type="button" aria-label="About old crate references" aria-expanded="false" data-info="Saved spots in regular crates whose exact filename was not found in the selected music folder. The same track can appear in several crates, so appearances are higher than unique filenames. These are separate from Serato's unmapped-track count.">i</button></div>
<div><span class="diag-icon amber"></span><p><strong>Database coverage</strong><small><b data-field="database_entries"></b> Serato entries · <b data-field="tracks_missing_from_database"></b> scanned tracks absent</small></p><button class="info-button" type="button" aria-label="About database coverage" aria-expanded="false" data-info="Compares filenames in Serato's database with the selected music folder. A scanned track absent from the database may not have been imported, or may be represented under another filename.">i</button></div>
</div>
</div>
<div id="drilldowns" class="drilldowns panel">
<div class="section-heading"><div><p class="eyebrow">Look closer</p><h2 id="drilldown-title">Choose a diagnostic</h2></div><span id="drilldown-count">Read-only examples</span></div>
<p id="drilldown-summary">Click a metric above to see example files and saved paths behind that number.</p>
<div id="drilldown-list" class="detail-list"></div>
</div>
</section>
</main>
</div>
+7
View File
@@ -26,6 +26,10 @@ def test_web_analysis_uses_production_health_pipeline(tmp_path):
assert result["database_missing_paths"] == 0
assert result["database_missing_unique_filenames"] == 0
assert result["tracks_missing_from_database"] == 0
assert result["details"]["old_crate_references"]["total"] == 2
assert result["details"]["old_crate_references"]["items"][0]["crate"]
assert result["details"]["suggested_matches"]["total"] == 0
assert result["details"]["unused_tracks"]["total"] == 1
def test_web_analysis_rejects_missing_folders(tmp_path):
@@ -41,4 +45,7 @@ def test_web_static_assets_are_declared_and_packaged():
html = (asset_root / "index.html").read_text(encoding="utf-8")
assert "Missing tracks in Serato" in html
assert "Old crate references" in html
assert "Choose a diagnostic" in html
assert 'data-detail="database_missing_tracks"' in html
assert 'data-detail="old_crate_references"' in html
assert html.count('class="info-button"') >= 10