Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d2f625ed18 | |||
| fb3d70e579 |
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -60,7 +60,7 @@ def build_sample(output: Optional[Path] = None) -> Path:
|
|||||||
]
|
]
|
||||||
for relative_path in manifest["tracks"]:
|
for relative_path in manifest["tracks"]:
|
||||||
fields = database_record(
|
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))
|
database_records.append(database_record(b"otrk", fields))
|
||||||
(serato_root / "database V2").write_bytes(b"".join(database_records))
|
(serato_root / "database V2").write_bytes(b"".join(database_records))
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ def main():
|
|||||||
print(f"Database Entries: {health.database_entries}")
|
print(f"Database Entries: {health.database_entries}")
|
||||||
print(f"Database / Library Matches: {health.database_library_matches}")
|
print(f"Database / Library Matches: {health.database_library_matches}")
|
||||||
print(f"Database Entries Outside Scan: {health.database_unmatched_entries}")
|
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"Tracks Missing From Database: {health.tracks_missing_from_database}")
|
||||||
print(f"Duplicate Database Paths: {health.duplicate_database_paths}")
|
print(f"Duplicate Database Paths: {health.duplicate_database_paths}")
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ def analyze_health(library: Library) -> HealthReport:
|
|||||||
database_path_counts = Counter(
|
database_path_counts = Counter(
|
||||||
normalize(str(track.path)) for track in database_tracks
|
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(
|
return HealthReport(
|
||||||
score=score,
|
score=score,
|
||||||
@@ -99,9 +102,13 @@ def analyze_health(library: Library) -> HealthReport:
|
|||||||
database_unmatched_entries=sum(
|
database_unmatched_entries=sum(
|
||||||
normalize(track.filename) not in library_names for track in database_tracks
|
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(
|
tracks_missing_from_database=sum(
|
||||||
normalize(track.filename) not in database_names for track in library.tracks
|
normalize(track.filename) not in database_names for track in library.tracks
|
||||||
),
|
) if library.database else 0,
|
||||||
duplicate_database_paths=sum(
|
duplicate_database_paths=sum(
|
||||||
count - 1 for count in database_path_counts.values() if count > 1
|
count - 1 for count in database_path_counts.values() if count > 1
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ class HealthReport:
|
|||||||
database_entries: int
|
database_entries: int
|
||||||
database_library_matches: int
|
database_library_matches: int
|
||||||
database_unmatched_entries: int
|
database_unmatched_entries: int
|
||||||
|
database_missing_paths: int
|
||||||
|
database_missing_unique_filenames: int
|
||||||
tracks_missing_from_database: int
|
tracks_missing_from_database: int
|
||||||
duplicate_database_paths: int
|
duplicate_database_paths: int
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
from collections import Counter
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from importlib import resources
|
from importlib import resources
|
||||||
@@ -8,12 +9,17 @@ from typing import Iterable
|
|||||||
|
|
||||||
from serato_doctor.crate_parser import load_library_crates
|
from serato_doctor.crate_parser import load_library_crates
|
||||||
from serato_doctor.database_parser import parse_database
|
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.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.models.library import Library
|
||||||
from serato_doctor.scanner import scan_filesystem
|
from serato_doctor.scanner import scan_filesystem
|
||||||
|
|
||||||
|
|
||||||
MAX_REQUEST_BYTES = 64 * 1024
|
MAX_REQUEST_BYTES = 64 * 1024
|
||||||
|
DETAIL_LIMIT = 50
|
||||||
STATIC_FILES = {
|
STATIC_FILES = {
|
||||||
"/": ("index.html", "text/html; charset=utf-8"),
|
"/": ("index.html", "text/html; charset=utf-8"),
|
||||||
"/app.css": ("app.css", "text/css; 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(
|
def analyze_paths(
|
||||||
serato: Path, music: Path, reference_roots: Iterable[Path] = ()
|
serato: Path, music: Path, reference_roots: Iterable[Path] = ()
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -45,6 +221,7 @@ def analyze_paths(
|
|||||||
report = analyze_health(library)
|
report = analyze_health(library)
|
||||||
result = asdict(report)
|
result = asdict(report)
|
||||||
result["score_basis"] = report.score_basis
|
result["score_basis"] = report.score_basis
|
||||||
|
result["details"] = diagnostic_details(library)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -2,12 +2,70 @@ const form = document.querySelector('#analysis-form');
|
|||||||
const button = document.querySelector('#analyze-button');
|
const button = document.querySelector('#analyze-button');
|
||||||
const errorBox = document.querySelector('#error-message');
|
const errorBox = document.querySelector('#error-message');
|
||||||
const results = document.querySelector('#dashboard');
|
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) {
|
function expandHome(path) {
|
||||||
return path.trim();
|
return path.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? '').replace(/[&<>"']/g, (character) => ({
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
}[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 Serato’s 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) {
|
function render(data) {
|
||||||
|
latestAnalysis = data;
|
||||||
document.querySelectorAll('[data-field]').forEach((element) => {
|
document.querySelectorAll('[data-field]').forEach((element) => {
|
||||||
const value = data[element.dataset.field];
|
const value = data[element.dataset.field];
|
||||||
element.textContent = value ?? '—';
|
element.textContent = value ?? '—';
|
||||||
@@ -20,10 +78,45 @@ function render(data) {
|
|||||||
: score >= 95 ? 'Looking excellent' : score >= 80 ? 'A few things need attention' : 'Review recommended';
|
: score >= 95 ? 'Looking excellent' : score >= 80 ? 'A few things need attention' : 'Review recommended';
|
||||||
document.querySelector('#score-basis').textContent = data.score_basis;
|
document.querySelector('#score-basis').textContent = data.score_basis;
|
||||||
document.querySelector('#analysis-time').textContent = `Completed ${new Date().toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})}`;
|
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.hidden = false;
|
||||||
results.scrollIntoView({behavior: 'smooth', block: 'start'});
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
form.addEventListener('submit', async (event) => {
|
form.addEventListener('submit', async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
errorBox.hidden = true;
|
errorBox.hidden = true;
|
||||||
|
|||||||
@@ -59,28 +59,35 @@
|
|||||||
<div class="section-heading"><div><p class="eyebrow">Latest analysis</p><h2>Library health</h2></div><span id="analysis-time"></span></div>
|
<div class="section-heading"><div><p class="eyebrow">Latest analysis</p><h2>Library health</h2></div><span id="analysis-time"></span></div>
|
||||||
<div class="hero-grid">
|
<div class="hero-grid">
|
||||||
<article class="score-card panel">
|
<article class="score-card panel">
|
||||||
|
<button class="info-button" type="button" aria-label="About library health" aria-expanded="false" data-info="Your health score is the percentage of saved, non-smart crate entries whose filenames were found in the selected music folder. Smart crates are left out because Serato rebuilds them from rules.">i</button>
|
||||||
<div class="score-ring" id="score-ring"><div><strong id="health-score">—</strong><span>health</span></div></div>
|
<div class="score-ring" id="score-ring"><div><strong id="health-score">—</strong><span>health</span></div></div>
|
||||||
<div><p class="score-label">Reference integrity</p><h3 id="health-message">Ready to analyze</h3><p id="score-basis">We only score evidence we can defend.</p></div>
|
<div><p class="score-label">Reference integrity</p><h3 id="health-message">Ready to analyze</h3><p id="score-basis">We only score evidence we can defend.</p></div>
|
||||||
</article>
|
</article>
|
||||||
<div class="metrics-grid">
|
<div class="metrics-grid">
|
||||||
<article class="metric panel"><span>Tracks</span><strong data-field="disk_tracks">—</strong><small>audio files found</small></article>
|
<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"><span>Broken references</span><strong data-field="missing_references">—</strong><small>static crate entries</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"><span>Unused tracks</span><strong data-field="unused_tracks">—</strong><small>not referenced by crates</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"><span>Suggested matches</span><strong data-field="suggested_matches">—</strong><small>explainable candidates</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="diagnostics" class="diagnostics panel">
|
<div id="diagnostics" class="diagnostics panel">
|
||||||
<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="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 class="diagnostic-list">
|
||||||
<div><span class="diag-icon violet">◇</span><p><strong>Crates</strong><small><b data-field="static_crates">—</b> static · <b data-field="smart_crates">—</b> smart · <b data-field="smart_crate_containers">—</b> dynamic containers · <b data-field="dynamic_references_excluded">—</b> references excluded</small></p></div>
|
<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></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><span class="diag-icon blue">⌁</span><p><strong>Cloud conflicts</strong><small><b data-field="suspected_cloud_conflict_groups">—</b> suspected groups · <b data-field="suspected_cloud_conflict_files">—</b> extra files</small></p></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><span class="diag-icon red">↗</span><p><strong>Broken symlinks</strong><small><b data-field="broken_symlinks">—</b> unresolved links</small></p></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><span class="diag-icon violet">▦</span><p><strong>Serato database V2</strong><small><b data-field="database_entries">—</b> entries · <b data-field="database_library_matches">—</b> match scanned filenames · <b data-field="tracks_missing_from_database">—</b> scanned tracks absent</small></p></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 review</strong><small><b data-field="database_unmatched_entries">—</b> entries outside this music scan · <b data-field="duplicate_database_paths">—</b> duplicate paths</small></p></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>
|
</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>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ def test_empty_library_has_no_health_score():
|
|||||||
assert report.score is None
|
assert report.score is None
|
||||||
assert report.total_references == 0
|
assert report.total_references == 0
|
||||||
assert report.scored_references == 0
|
assert report.scored_references == 0
|
||||||
|
assert not report.database_present
|
||||||
|
assert report.tracks_missing_from_database == 0
|
||||||
|
|
||||||
|
|
||||||
def test_smart_crate_references_are_reported_but_not_scored():
|
def test_smart_crate_references_are_reported_but_not_scored():
|
||||||
|
|||||||
@@ -23,7 +23,13 @@ def test_web_analysis_uses_production_health_pipeline(tmp_path):
|
|||||||
assert result["smart_crates"] == 2
|
assert result["smart_crates"] == 2
|
||||||
assert result["database_entries"] == 10
|
assert result["database_entries"] == 10
|
||||||
assert result["database_library_matches"] == 10
|
assert result["database_library_matches"] == 10
|
||||||
|
assert result["database_missing_paths"] == 0
|
||||||
|
assert result["database_missing_unique_filenames"] == 0
|
||||||
assert result["tracks_missing_from_database"] == 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):
|
def test_web_analysis_rejects_missing_folders(tmp_path):
|
||||||
@@ -36,3 +42,10 @@ def test_web_static_assets_are_declared_and_packaged():
|
|||||||
|
|
||||||
assert set(STATIC_FILES) == {"/", "/app.css", "/app.js"}
|
assert set(STATIC_FILES) == {"/", "/app.css", "/app.js"}
|
||||||
assert all((asset_root / filename).is_file() for filename, _ in STATIC_FILES.values())
|
assert all((asset_root / filename).is_file() for filename, _ in STATIC_FILES.values())
|
||||||
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user