Reference integrity
Ready to analyze
We only score evidence we can defend.
diff --git a/docs/design/plain-language-diagnostics.md b/docs/design/plain-language-diagnostics.md new file mode 100644 index 0000000..59a2d50 --- /dev/null +++ b/docs/design/plain-language-diagnostics.md @@ -0,0 +1,25 @@ +# Plain-language Diagnostics + +## Problem + +“Broken references” combined crate occurrences with Serato's own missing-track +concept. DJs naturally compared that number with orange/unmapped tracks in Serato, +even though the two counts describe different layers. + +## Language + +- **Missing tracks in Serato** means database entries whose saved file location no + longer exists. This corresponds most closely to orange or unmapped tracks. +- **Old crate references** means saved appearances in regular crates whose exact + filename was not found in the selected music folder. One track can appear in + several crates, so both appearances and unique filenames are shown. + +Every health card and diagnostic row has an accessible information button. Hover +shows its explanation on desktop; click or tap keeps it open; Escape or clicking +elsewhere closes it. Explanations describe uncertainty and avoid implying repair. + +## Verification + +Health tests verify database missing-path and unique-filename counts. Browser tests +cover the two separate metrics, hover/click explanations, keyboard dismissal, and +mobile layout. diff --git a/samples/small-library/generate.py b/samples/small-library/generate.py index 160abd3..503bc92 100644 --- a/samples/small-library/generate.py +++ b/samples/small-library/generate.py @@ -60,7 +60,7 @@ def build_sample(output: Optional[Path] = None) -> Path: ] for relative_path in manifest["tracks"]: fields = database_record( - b"pfil", f"{SERATO_PATH_PREFIX}{relative_path}".encode("utf-16-be") + b"pfil", str(music_root / relative_path).encode("utf-16-be") ) database_records.append(database_record(b"otrk", fields)) (serato_root / "database V2").write_bytes(b"".join(database_records)) diff --git a/serato_doctor/cli.py b/serato_doctor/cli.py index 0b12c2e..011c3d4 100644 --- a/serato_doctor/cli.py +++ b/serato_doctor/cli.py @@ -106,6 +106,11 @@ def main(): print(f"Database Entries: {health.database_entries}") print(f"Database / Library Matches: {health.database_library_matches}") print(f"Database Entries Outside Scan: {health.database_unmatched_entries}") + print(f"Missing Tracks in Serato: {health.database_missing_paths}") + print( + "Unique Missing Tracks in Serato: " + f"{health.database_missing_unique_filenames}" + ) print(f"Tracks Missing From Database: {health.tracks_missing_from_database}") print(f"Duplicate Database Paths: {health.duplicate_database_paths}") else: diff --git a/serato_doctor/health.py b/serato_doctor/health.py index aaa5768..ff46437 100644 --- a/serato_doctor/health.py +++ b/serato_doctor/health.py @@ -52,6 +52,9 @@ def analyze_health(library: Library) -> HealthReport: database_path_counts = Counter( normalize(str(track.path)) for track in database_tracks ) + missing_database_tracks = [ + track for track in database_tracks if not track.path.exists() + ] return HealthReport( score=score, @@ -99,9 +102,13 @@ def analyze_health(library: Library) -> HealthReport: database_unmatched_entries=sum( normalize(track.filename) not in library_names for track in database_tracks ), + database_missing_paths=len(missing_database_tracks), + database_missing_unique_filenames=len( + {normalize(track.filename) for track in missing_database_tracks} + ), tracks_missing_from_database=sum( normalize(track.filename) not in database_names for track in library.tracks - ), + ) if library.database else 0, duplicate_database_paths=sum( count - 1 for count in database_path_counts.values() if count > 1 ), diff --git a/serato_doctor/models/health.py b/serato_doctor/models/health.py index bdb2bf7..9fae79a 100644 --- a/serato_doctor/models/health.py +++ b/serato_doctor/models/health.py @@ -29,6 +29,8 @@ class HealthReport: database_entries: int database_library_matches: int database_unmatched_entries: int + database_missing_paths: int + database_missing_unique_filenames: int tracks_missing_from_database: int duplicate_database_paths: int diff --git a/serato_doctor/webui/app.css b/serato_doctor/webui/app.css index 7b289c8..ec342c0 100644 --- a/serato_doctor/webui/app.css +++ b/serato_doctor/webui/app.css @@ -1 +1,2 @@ :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}} diff --git a/serato_doctor/webui/app.js b/serato_doctor/webui/app.js index be52602..e9ebfe1 100644 --- a/serato_doctor/webui/app.js +++ b/serato_doctor/webui/app.js @@ -2,6 +2,7 @@ const form = document.querySelector('#analysis-form'); const button = document.querySelector('#analyze-button'); const errorBox = document.querySelector('#error-message'); const results = document.querySelector('#dashboard'); +const infoButtons = document.querySelectorAll('.info-button'); function expandHome(path) { return path.trim(); @@ -24,6 +25,30 @@ function render(data) { results.scrollIntoView({behavior: 'smooth', block: 'start'}); } +function closeInfoButtons(except = null) { + infoButtons.forEach((infoButton) => { + if (infoButton !== except) { + infoButton.classList.remove('open'); + infoButton.setAttribute('aria-expanded', 'false'); + } + }); +} + +infoButtons.forEach((infoButton) => { + infoButton.addEventListener('click', (event) => { + event.stopPropagation(); + const willOpen = !infoButton.classList.contains('open'); + closeInfoButtons(infoButton); + infoButton.classList.toggle('open', willOpen); + infoButton.setAttribute('aria-expanded', String(willOpen)); + }); +}); + +document.addEventListener('click', () => closeInfoButtons()); +document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') closeInfoButtons(); +}); + form.addEventListener('submit', async (event) => { event.preventDefault(); errorBox.hidden = true; diff --git a/serato_doctor/webui/index.html b/serato_doctor/webui/index.html index 94f3e48..6511ca2 100644 --- a/serato_doctor/webui/index.html +++ b/serato_doctor/webui/index.html @@ -59,26 +59,27 @@
Latest analysis
Reference integrity
We only score evidence we can defend.
Full picture
Crates— static · — smart · — dynamic containers · — references excluded
Duplicate filenames— exact groups · — extra files
Cloud conflicts— suspected groups · — extra files
Broken symlinks— unresolved links
Serato database V2— entries · — match scanned filenames · — scanned tracks absent
Database review— entries outside this music scan · — duplicate paths
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
Database coverage— Serato entries · — scanned tracks absent