Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d2f625ed18 |
@@ -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.
|
||||||
@@ -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
@@ -3,12 +3,69 @@ 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 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 ?? '—';
|
||||||
@@ -21,6 +78,7 @@ 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'});
|
||||||
}
|
}
|
||||||
@@ -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('click', () => closeInfoButtons());
|
||||||
document.addEventListener('keydown', (event) => {
|
document.addEventListener('keydown', (event) => {
|
||||||
if (event.key === 'Escape') closeInfoButtons();
|
if (event.key === 'Escape') closeInfoButtons();
|
||||||
|
|||||||
@@ -65,9 +65,9 @@
|
|||||||
</article>
|
</article>
|
||||||
<div class="metrics-grid">
|
<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"><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 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"><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 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"><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 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>
|
||||||
|
|
||||||
@@ -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="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> 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 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 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>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 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 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 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>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="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><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>
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ def test_web_analysis_uses_production_health_pipeline(tmp_path):
|
|||||||
assert result["database_missing_paths"] == 0
|
assert result["database_missing_paths"] == 0
|
||||||
assert result["database_missing_unique_filenames"] == 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):
|
||||||
@@ -41,4 +45,7 @@ def test_web_static_assets_are_declared_and_packaged():
|
|||||||
html = (asset_root / "index.html").read_text(encoding="utf-8")
|
html = (asset_root / "index.html").read_text(encoding="utf-8")
|
||||||
assert "Missing tracks in Serato" in html
|
assert "Missing tracks in Serato" in html
|
||||||
assert "Old crate references" 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
|
assert html.count('class="info-button"') >= 10
|
||||||
|
|||||||
Reference in New Issue
Block a user