Add diagnostic drilldowns
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user