- ${detailLines(item)}
diff --git a/docs/design/diagnostic-drilldowns.md b/docs/design/diagnostic-drilldowns.md new file mode 100644 index 0000000..fa15799 --- /dev/null +++ b/docs/design/diagnostic-drilldowns.md @@ -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. diff --git a/serato_doctor/web.py b/serato_doctor/web.py index 2eedfcc..31fe2e6 100644 --- a/serato_doctor/web.py +++ b/serato_doctor/web.py @@ -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 diff --git a/serato_doctor/webui/app.css b/serato_doctor/webui/app.css index ec342c0..fa92a90 100644 --- a/serato_doctor/webui/app.css +++ b/serato_doctor/webui/app.css @@ -1,2 +1,3 @@ :root{--bg:#090b10;--panel:rgba(22,25,34,.86);--panel-2:#171a23;--line:rgba(255,255,255,.08);--text:#f4f5f7;--muted:#9298a7;--violet:#9b87f5;--cyan:#66d9e8;--amber:#f5b94c;--red:#f17878;--radius:22px;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--text);background:var(--bg)}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;min-height:100vh;background:radial-gradient(circle at 75% -10%,rgba(98,79,181,.16),transparent 32%),var(--bg)}.ambient{position:fixed;border-radius:999px;filter:blur(90px);pointer-events:none;opacity:.16}.ambient-one{width:420px;height:420px;background:#674ce0;top:10%;right:5%}.ambient-two{width:320px;height:320px;background:#1b8a9b;bottom:0;left:20%}.shell{display:grid;grid-template-columns:250px 1fr;min-height:100vh}.sidebar{position:sticky;top:0;height:100vh;padding:28px 22px;border-right:1px solid var(--line);background:rgba(10,12,17,.72);backdrop-filter:blur(24px);display:flex;flex-direction:column;z-index:2}.brand{display:flex;align-items:center;gap:12px;color:var(--text);text-decoration:none;margin:0 8px 42px}.brand-mark{display:grid;place-items:center;width:42px;height:42px;border-radius:13px;background:linear-gradient(145deg,var(--violet),#6651ce);font-weight:800;letter-spacing:-1px;box-shadow:0 10px 30px rgba(126,103,235,.28)}.brand strong,.brand small{display:block}.brand small{color:var(--muted);letter-spacing:.14em;text-transform:uppercase;font-size:9px;margin-top:2px}.sidebar nav{display:grid;gap:8px}.nav-item{padding:12px 14px;border-radius:12px;color:var(--muted);text-decoration:none;font-size:14px;display:flex;gap:11px;align-items:center}.nav-item:hover,.nav-item.active{color:var(--text);background:rgba(255,255,255,.06)}.nav-item.active{box-shadow:inset 2px 0 var(--violet)}.safety-card{margin-top:auto;display:flex;gap:10px;padding:14px;border:1px solid rgba(102,217,232,.18);border-radius:16px;background:rgba(31,79,87,.18)}.safety-icon{display:grid;place-items:center;width:25px;height:25px;border-radius:50%;background:rgba(102,217,232,.16);color:var(--cyan);font-weight:700;flex:0 0 auto}.safety-card strong{font-size:12px}.safety-card p{font-size:11px;color:var(--muted);line-height:1.45;margin:4px 0 0}.sidebar-foot{font-size:10px;color:#656b79;margin:18px 10px 0}.shell main{padding:36px clamp(24px,5vw,72px) 70px;max-width:1500px;width:100%;margin:0 auto}.topbar{display:flex;align-items:center;justify-content:space-between;margin-bottom:32px}.eyebrow{text-transform:uppercase;letter-spacing:.16em;color:var(--violet);font-size:10px;font-weight:750;margin:0 0 7px}.topbar h1{font-size:clamp(26px,4vw,38px);letter-spacing:-.04em;margin:0}.status-pill,.read-only-tag{font-size:11px;color:#b7bdc9;border:1px solid var(--line);border-radius:999px;padding:9px 12px;background:rgba(255,255,255,.03)}.status-pill span{display:inline-block;width:7px;height:7px;border-radius:50%;background:#54d49a;margin-right:7px;box-shadow:0 0 12px #54d49a}.panel{background:linear-gradient(145deg,rgba(28,31,42,.93),rgba(18,21,29,.88));border:1px solid var(--line);border-radius:var(--radius);box-shadow:0 24px 80px rgba(0,0,0,.2)}.scan-panel{padding:clamp(24px,4vw,40px);display:grid;grid-template-columns:minmax(220px,.7fr) minmax(360px,1.3fr);gap:42px;align-items:center}.panel-copy h2,.section-heading h2{font-size:24px;letter-spacing:-.03em;margin:0 0 11px}.panel-copy>p:last-child{color:var(--muted);line-height:1.65;font-size:13px;max-width:440px}.scan-panel form{display:grid;grid-template-columns:1fr 1fr;gap:14px}.scan-panel label{font-size:11px;color:#c7cad2;font-weight:650}.scan-panel label span{color:#696f7e;font-weight:400}.scan-panel input,.scan-panel textarea{display:block;width:100%;margin-top:7px;border:1px solid var(--line);background:rgba(6,8,12,.56);color:var(--text);padding:12px 13px;border-radius:11px;outline:none;font:inherit;font-size:12px;resize:vertical}.scan-panel input:focus,.scan-panel textarea:focus{border-color:rgba(155,135,245,.7);box-shadow:0 0 0 3px rgba(155,135,245,.08)}.wide{grid-column:1/-1}.scan-panel button{grid-column:1/-1;border:0;border-radius:12px;padding:13px 16px;background:linear-gradient(135deg,#a08cf5,#7763dc);color:#fff;font:inherit;font-size:13px;font-weight:750;display:flex;justify-content:space-between;cursor:pointer;box-shadow:0 12px 30px rgba(115,91,213,.25)}.scan-panel button:hover{filter:brightness(1.08);transform:translateY(-1px)}.scan-panel button:disabled{opacity:.6;cursor:wait}.error{grid-column:1/-1;margin-top:14px;padding:12px 14px;color:#ffc5c5;background:rgba(160,48,48,.18);border:1px solid rgba(241,120,120,.25);border-radius:12px;font-size:12px}.results{margin-top:42px}.section-heading{display:flex;align-items:flex-end;justify-content:space-between;margin:0 2px 18px}.section-heading h2{margin:0}.section-heading>span{color:var(--muted);font-size:11px}.hero-grid{display:grid;grid-template-columns:1fr 1.35fr;gap:18px}.score-card{padding:28px;display:flex;gap:25px;align-items:center}.score-ring{--score:0;display:grid;place-items:center;flex:0 0 auto;width:140px;height:140px;border-radius:50%;background:conic-gradient(var(--violet) calc(var(--score)*1%),rgba(255,255,255,.06) 0);position:relative}.score-ring:before{content:"";position:absolute;inset:9px;border-radius:50%;background:#151821}.score-ring>div{position:relative;text-align:center}.score-ring strong{font-size:28px;letter-spacing:-.05em;display:block}.score-ring span{font-size:10px;text-transform:uppercase;letter-spacing:.13em;color:var(--muted)}.score-label{color:var(--violet);font-size:11px;font-weight:700;margin:0 0 7px}.score-card h3{font-size:20px;margin:0 0 7px}.score-card p:last-child{font-size:11px;color:var(--muted);line-height:1.5;margin:0}.metrics-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}.metric{padding:20px}.metric span{display:block;font-size:11px;color:var(--muted)}.metric strong{font-size:28px;letter-spacing:-.04em;display:block;margin:9px 0 2px}.metric small{font-size:10px;color:#656b79}.metric.warning strong{color:var(--amber)}.diagnostics{margin-top:18px;padding:25px}.diagnostic-list{display:grid;grid-template-columns:1fr 1fr;gap:10px}.diagnostic-list>div{display:flex;gap:13px;align-items:center;padding:14px;border-radius:14px;background:rgba(255,255,255,.025);border:1px solid rgba(255,255,255,.045)}.diag-icon{display:grid;place-items:center;width:34px;height:34px;border-radius:10px;flex:0 0 auto}.diag-icon.violet{color:var(--violet);background:rgba(155,135,245,.11)}.diag-icon.amber{color:var(--amber);background:rgba(245,185,76,.1)}.diag-icon.blue{color:var(--cyan);background:rgba(102,217,232,.1)}.diag-icon.red{color:var(--red);background:rgba(241,120,120,.1)}.diagnostic-list p{margin:0}.diagnostic-list strong{display:block;font-size:12px}.diagnostic-list small{display:block;color:var(--muted);font-size:10px;margin-top:5px}.diagnostic-list b{color:#cdd1d9;font-weight:650}@media(max-width:980px){.shell{grid-template-columns:82px 1fr}.sidebar{padding:25px 13px}.brand{margin-left:6px}.brand>span:last-child,.nav-item:not(.active){font-size:0}.nav-item{justify-content:center}.nav-item span{font-size:17px}.safety-card div,.sidebar-foot{display:none}.safety-card{padding:12px;justify-content:center}.scan-panel{grid-template-columns:1fr}.hero-grid{grid-template-columns:1fr}}@media(max-width:680px){.shell{display:block}.sidebar{position:static;width:100%;height:auto;flex-direction:row;align-items:center;padding:14px 18px}.brand{margin:0 auto 0 0}.brand>span:last-child{display:block}.sidebar nav{display:flex}.nav-item{padding:10px}.nav-item:not(.active),.safety-card,.sidebar-foot{display:none}.shell main{padding:24px 16px 50px}.topbar{margin-bottom:22px}.status-pill{display:none}.scan-panel{padding:22px;gap:20px}.scan-panel form{grid-template-columns:1fr}.scan-panel label,.wide,.scan-panel button{grid-column:1}.hero-grid{grid-template-columns:1fr}.score-card{flex-direction:column;text-align:center}.metrics-grid,.diagnostic-list{grid-template-columns:1fr 1fr}.section-heading{align-items:flex-start}}@media(max-width:440px){.metrics-grid,.diagnostic-list{grid-template-columns:1fr}.score-ring{width:126px;height:126px}} .metric,.score-card{position:relative}.info-button{position:relative;display:grid;place-items:center;width:22px;height:22px;flex:0 0 auto;margin-left:auto;border:1px solid rgba(255,255,255,.14);border-radius:50%;background:rgba(255,255,255,.04);color:#aeb4c2;font:700 11px/1 Georgia,serif;cursor:pointer;z-index:3}.metric>.info-button,.score-card>.info-button{position:absolute;top:14px;right:14px}.info-button:hover,.info-button:focus-visible,.info-button.open{color:#fff;border-color:rgba(155,135,245,.75);background:rgba(155,135,245,.16);outline:none}.info-button:after{content:attr(data-info);position:absolute;right:0;bottom:calc(100% + 10px);width:min(280px,70vw);padding:11px 12px;border:1px solid rgba(155,135,245,.3);border-radius:11px;background:#202431;color:#e8eaf0;font:500 11px/1.5 Inter,ui-sans-serif,system-ui,sans-serif;text-align:left;box-shadow:0 14px 45px rgba(0,0,0,.42);opacity:0;visibility:hidden;transform:translateY(4px);transition:.15s ease;pointer-events:none}.info-button:hover:after,.info-button:focus-visible:after,.info-button.open:after{opacity:1;visibility:visible;transform:translateY(0)}.diagnostic-list p{min-width:0}.diagnostic-list .info-button{margin-left:auto}@media(max-width:680px){.info-button:after{position:fixed;left:16px;right:16px;bottom:18px;width:auto;font-size:12px;z-index:20}} +.drill-trigger{cursor:pointer;transition:border-color .15s ease,background .15s ease,transform .15s ease}.drill-trigger:hover,.drill-trigger:focus-visible,.drill-trigger.selected{border-color:rgba(155,135,245,.35);background:linear-gradient(145deg,rgba(155,135,245,.11),rgba(255,255,255,.025));outline:none}.drill-trigger:hover{transform:translateY(-1px)}.drilldowns{margin-top:18px;padding:25px}.drilldowns>p{max-width:760px;color:var(--muted);font-size:12px;line-height:1.6;margin:0 0 16px}.detail-list{display:grid;gap:10px;max-height:520px;overflow:auto;padding-right:4px}.detail-item,.empty-detail{padding:14px;border:1px solid rgba(255,255,255,.055);border-radius:14px;background:rgba(255,255,255,.025)}.detail-item strong{display:block;font-size:12px;margin:0 0 8px;color:#f7f7fa;overflow-wrap:anywhere}.detail-item ul{list-style:none;padding:0;margin:0;display:grid;gap:5px}.detail-item li{font-size:10px;line-height:1.45;color:var(--muted);overflow-wrap:anywhere}.empty-detail{color:var(--muted);font-size:12px}@media(max-width:440px){.detail-list{max-height:420px}} diff --git a/serato_doctor/webui/app.js b/serato_doctor/webui/app.js index e9ebfe1..8475ac9 100644 --- a/serato_doctor/webui/app.js +++ b/serato_doctor/webui/app.js @@ -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) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }[character])); +} + +function detailLines(item) { + if (item.files) { + return item.files.map((file) => `
Full picture
Crates— regular · — smart · — dynamic containers
Duplicate filenames— exact groups · — extra files
Possible cloud conflicts— groups · — extra files
Broken shortcuts— unresolved symbolic links
Old crate references— appearances · — unique filenames
Duplicate filenames— exact groups · — extra files · click for list
Possible cloud conflicts— groups · — extra files · click for list
Broken shortcuts— unresolved symbolic links · click for list
Old crate references— appearances · — unique filenames · click for list
Database coverage— Serato entries · — scanned tracks absent
Look closer
Click a metric above to see example files and saved paths behind that number.
+ +