Compare commits

...

6 Commits

Author SHA1 Message Date
Philip Guzman a384cdc88d Discover Serato smart crate definitions 2026-07-01 08:06:19 -07:00
Philip Guzman 71bca064ed Merge feature/web-interface into develop 2026-07-01 08:03:19 -07:00
Philip Guzman eda5d4e62b Document approved dashboard direction 2026-07-01 07:55:00 -07:00
Philip Guzman 9053b4ca3d Add local web analysis dashboard 2026-07-01 07:52:33 -07:00
Philip Guzman 251ab4d090 Merge feature/broken-symlinks into develop 2026-07-01 07:46:30 -07:00
Philip Guzman b8ea17450b Add broken symlink diagnostics 2026-07-01 07:32:07 -07:00
29 changed files with 603 additions and 27 deletions
+1
View File
@@ -9,6 +9,7 @@ samples/small-library/generated/
*.db *.db
*.csv *.csv
*.html *.html
!serato_doctor/webui/*.html
# Never commit personal Serato data # Never commit personal Serato data
database V2 database V2
+11
View File
@@ -35,3 +35,14 @@ aggregate diagnostic log.
Serato Doctor never repairs files without an explicit future repair workflow, Serato Doctor never repairs files without an explicit future repair workflow,
preview, backup, and rollback path. preview, backup, and rollback path.
## Local Web Interface
Launch the responsive, local-only dashboard with:
```shell
serato-doctor-web
```
Then open `http://127.0.0.1:8765`. The interface exposes the same read-only health
analysis and never sends library paths or results to an external service.
+2 -2
View File
@@ -7,7 +7,7 @@
- [x] Serato crate parser - [x] Serato crate parser
- [x] Missing reference CSV report - [x] Missing reference CSV report
- [x] Grouped missing reference report - [x] Grouped missing reference report
- [ ] HTML health dashboard - [x] HTML health dashboard
- [x] Test suite - [x] Test suite
- [x] Sample library fixtures - [x] Sample library fixtures
- [ ] Database V2 read-only parser - [ ] Database V2 read-only parser
@@ -20,7 +20,7 @@
- [x] `serato-doctor analyze` command - [x] `serato-doctor analyze` command
- [x] Duplicate filename detection - [x] Duplicate filename detection
- [ ] Duplicate audio hash detection - [ ] Duplicate audio hash detection
- [ ] Broken symlink detection - [x] Broken symlink detection
- [ ] Orphaned audio detection - [ ] Orphaned audio detection
- [ ] OneDrive rename detection - [ ] OneDrive rename detection
- [x] Crate classification: static vs smart/dynamic - [x] Crate classification: static vs smart/dynamic
+27
View File
@@ -0,0 +1,27 @@
# Broken Symlink Detection
## Problem
Library migrations may leave symbolic links pointing to files or folders that no
longer exist. The filesystem scanner previously skipped those links silently.
## Architecture
One read-only filesystem traversal now returns audio tracks and broken symbolic
links. Each finding preserves the link path and its raw target when the operating
system can read it. The library and health report retain only immutable findings.
Broken links are reported separately and do not affect the reference-integrity
score. The detector does not follow, recreate, remove, or rewrite any link.
## Edge Cases
- Relative and absolute link targets.
- Links to missing files and missing directories.
- Link targets that cannot be read due to an operating-system error.
- Valid symlinks, which remain eligible for ordinary audio scanning.
## Verification
Tests create synthetic valid and broken links in temporary directories, verify the
raw target, and confirm health integration. Existing scanner behavior is preserved.
+7 -2
View File
@@ -9,10 +9,15 @@ broken manual references or given the same health-score weight.
## Architecture ## Architecture
Crates carry a `static`, `smart`, or `unknown` kind. Folder provenance is the Crates carry a `static`, `smart`, or `unknown` kind. Folder provenance is the
primary signal: Serato stores regular definitions in `Subcrates` and smart primary signal: Serato stores regular `.crate` files in `Subcrates` and smart
definitions in `Smartcrates`. `Compatible by key.crate` is also treated as smart `.scrate` definitions in `SmartCrates`. `Compatible by key.crate` is also treated as smart
when encountered in `Subcrates`, based on the original migration case study. when encountered in `Subcrates`, based on the original migration case study.
Smart crate names use `≫≫` to encode hierarchy. The model preserves those segments,
so `Compatible by key≫≫10A.scrate` has a parent of `Compatible by key` and a display
name of `10A`. Smart definitions and dynamic `.crate` containers are counted
separately.
The library loader reads both folders. Health analysis reports all references but The library loader reads both folders. Health analysis reports all references but
scores only non-smart references. Unknown crates remain scoreable so incomplete scores only non-smart references. Unknown crates remain scoreable so incomplete
classification cannot silently hide potential problems. classification cannot silently hide potential problems.
+19
View File
@@ -0,0 +1,19 @@
# Smart Crate Discovery Correction
## Problem
The first classifier searched `SmartCrates` for `*.crate`. Real Serato smart-crate
definitions use `.scrate`, so a library with 26 definitions displayed only the
single name-based `Compatible by key.crate` fallback.
## Correction
Library discovery now reads `.scrate` definitions case-insensitively from the
`SmartCrates` folder. Dynamic `.crate` containers remain excluded from manual
reference scoring but are reported separately. The `≫≫` filename separator is
preserved as smart-crate hierarchy metadata.
## Verification
Synthetic tests cover `.scrate` discovery, case-correct folder names, hierarchy,
definition/container counts, and the existing five-static/two-smart sample.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+37
View File
@@ -0,0 +1,37 @@
# Local Web Interface
![Approved Serato Doctor dashboard concept](web-dashboard-concept.png)
The generated concept above is the approved visual direction. The implemented UI
keeps its hierarchy, palette, safety emphasis, health ring, diagnostic cards, and
responsive behavior. Product truth takes precedence over mockup copy: a 77.8%
library is labeled for review rather than described as healthy.
## Problem
The command line is useful for automation but makes the growing diagnostic set
harder to explore. A visual dashboard lets users test analysis safely and understand
which findings affect health.
## Architecture
`serato-doctor-web` binds to `127.0.0.1:8765` by default and serves package-owned
HTML, CSS, and JavaScript with Python's standard library. A same-origin JSON endpoint
runs the existing read-only parser, scanner, matcher, duplicate detector, crate
classifier, and health engine. No web framework or external service is required.
The UI clearly labels read-only mode, separates scored health from informational
diagnostics, and adapts from a full sidebar layout to compact mobile navigation.
## Edge Cases
- Missing or invalid Serato and music directories.
- Empty libraries with no assessable health score.
- Large or malformed requests, capped at 64 KiB.
- HTML injection, avoided by rendering all results through `textContent`.
- Network exposure, avoided by a loopback-only default binding.
## Verification
Tests exercise the web analysis adapter and static package assets. Browser checks
cover real form submission, result rendering, error display, and responsive layout.
+4
View File
@@ -14,9 +14,13 @@ dev = ["pytest>=8,<9"]
[project.scripts] [project.scripts]
serato-doctor = "serato_doctor.cli:main" serato-doctor = "serato_doctor.cli:main"
serato-doctor-web = "serato_doctor.web:main"
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
include = ["serato_doctor*"] include = ["serato_doctor*"]
[tool.setuptools.package-data]
"serato_doctor.webui" = ["*.html", "*.css", "*.js"]
+16 -6
View File
@@ -28,18 +28,28 @@ def build_sample(output: Optional[Path] = None) -> Path:
) )
for crate in manifest["crates"]: for crate in manifest["crates"]:
folder_name = "Smartcrates" if crate["type"] == "smart" else "Subcrates" is_smart = crate["type"] == "smart"
folder_name = "SmartCrates" if is_smart else "Subcrates"
crate_root = serato_root / folder_name crate_root = serato_root / folder_name
crate_root.mkdir(parents=True, exist_ok=True) crate_root.mkdir(parents=True, exist_ok=True)
other_folder = "Subcrates" if folder_name == "Smartcrates" else "Smartcrates" output_name = (
stale_path = serato_root / other_folder / crate["name"] Path(crate["name"]).with_suffix(".scrate").name
if stale_path.exists(): if is_smart
stale_path.unlink() else crate["name"]
)
for other_folder in ("Subcrates", "Smartcrates", "SmartCrates"):
for stale_name in (
crate["name"],
Path(crate["name"]).with_suffix(".scrate").name,
):
stale_path = serato_root / other_folder / stale_name
if stale_path.exists() and stale_path != crate_root / output_name:
stale_path.unlink()
records = "".join( records = "".join(
f"{SERATO_PATH_PREFIX}{relative_path}otrk" f"{SERATO_PATH_PREFIX}{relative_path}otrk"
for relative_path in crate["references"] for relative_path in crate["references"]
) )
(crate_root / crate["name"]).write_bytes(records.encode("utf-16-le")) (crate_root / output_name).write_bytes(records.encode("utf-16-le"))
return root return root
+6 -2
View File
@@ -6,7 +6,7 @@ from serato_doctor.crate_parser import load_library_crates
from serato_doctor.health import analyze_health from serato_doctor.health import analyze_health
from serato_doctor.logging import configure_logging from serato_doctor.logging import configure_logging
from serato_doctor.models.library import Library from serato_doctor.models.library import Library
from serato_doctor.scanner import scan_audio from serato_doctor.scanner import scan_filesystem
from serato_doctor.report import write_csv, write_missing_report from serato_doctor.report import write_csv, write_missing_report
@@ -56,9 +56,11 @@ def main():
logger.debug("Music directory: %s", config.music) logger.debug("Music directory: %s", config.music)
crates = load_library_crates(config.serato, config.reference_roots) crates = load_library_crates(config.serato, config.reference_roots)
filesystem = scan_filesystem(config.music)
library = Library.from_crates( library = Library.from_crates(
crates=crates, crates=crates,
tracks=scan_audio(config.music), tracks=filesystem.tracks,
broken_symlinks=filesystem.broken_symlinks,
) )
results = library.reconcile_by_filename() results = library.reconcile_by_filename()
missing_count = sum(1 for result in results if not result.exists_by_filename) missing_count = sum(1 for result in results if not result.exists_by_filename)
@@ -90,8 +92,10 @@ def main():
) )
print(f"Unused Tracks: {health.unused_tracks}") print(f"Unused Tracks: {health.unused_tracks}")
print(f"Suggested Matches: {health.suggested_matches}") print(f"Suggested Matches: {health.suggested_matches}")
print(f"Broken Symlinks: {health.broken_symlinks}")
print(f"Static Crates: {health.static_crates}") print(f"Static Crates: {health.static_crates}")
print(f"Smart Crates: {health.smart_crates}") print(f"Smart Crates: {health.smart_crates}")
print(f"Smart Crate Containers: {health.smart_crate_containers}")
print( print(
f"Dynamic References Excluded: {health.dynamic_references_excluded}" f"Dynamic References Excluded: {health.dynamic_references_excluded}"
) )
+14 -4
View File
@@ -103,9 +103,19 @@ def load_library_crates(
serato_root: Path, reference_roots: Iterable[Path] = () serato_root: Path, reference_roots: Iterable[Path] = ()
) -> Tuple[Crate, ...]: ) -> Tuple[Crate, ...]:
reference_roots = tuple(reference_roots) reference_roots = tuple(reference_roots)
if not serato_root.is_dir():
return ()
crates = [] crates = []
for folder_name in ("Subcrates", "Smartcrates"): folder_patterns = {
folder = serato_root / folder_name "subcrates": ("*.crate",),
for crate_path in folder.rglob("*.crate"): "smartcrates": ("*.scrate", "*.crate"),
crates.append(load_crate(crate_path, reference_roots)) }
for folder in serato_root.iterdir():
patterns = folder_patterns.get(folder.name.casefold())
if patterns is None or not folder.is_dir():
continue
for pattern in patterns:
for crate_path in folder.rglob(pattern):
crates.append(load_crate(crate_path, reference_roots))
return tuple(sorted(crates, key=lambda crate: str(crate.path))) return tuple(sorted(crates, key=lambda crate: str(crate.path)))
+7 -1
View File
@@ -62,11 +62,17 @@ def analyze_health(library: Library) -> HealthReport:
), ),
unused_tracks=unused_count, unused_tracks=unused_count,
suggested_matches=suggested_count, suggested_matches=suggested_count,
broken_symlinks=len(library.broken_symlinks),
static_crates=sum( static_crates=sum(
crate.kind is CrateKind.STATIC for crate in library.crates crate.kind is CrateKind.STATIC for crate in library.crates
), ),
smart_crates=sum( smart_crates=sum(
crate.kind is CrateKind.SMART for crate in library.crates crate.kind is CrateKind.SMART and crate.is_smart_definition
for crate in library.crates
),
smart_crate_containers=sum(
crate.kind is CrateKind.SMART and not crate.is_smart_definition
for crate in library.crates
), ),
unknown_crates=sum( unknown_crates=sum(
crate.kind is CrateKind.UNKNOWN for crate in library.crates crate.kind is CrateKind.UNKNOWN for crate in library.crates
+3
View File
@@ -1,5 +1,6 @@
from serato_doctor.models.crate import Crate, CrateKind from serato_doctor.models.crate import Crate, CrateKind
from serato_doctor.models.duplicate import DuplicateGroup, DuplicateKind from serato_doctor.models.duplicate import DuplicateGroup, DuplicateKind
from serato_doctor.models.filesystem import BrokenSymlink, FilesystemScan
from serato_doctor.models.health import HealthReport from serato_doctor.models.health import HealthReport
from serato_doctor.models.library import Library from serato_doctor.models.library import Library
from serato_doctor.models.match import MatchEvidence, TrackMatch from serato_doctor.models.match import MatchEvidence, TrackMatch
@@ -12,6 +13,8 @@ __all__ = [
"DiskTrack", "DiskTrack",
"DuplicateGroup", "DuplicateGroup",
"DuplicateKind", "DuplicateKind",
"BrokenSymlink",
"FilesystemScan",
"HealthReport", "HealthReport",
"Library", "Library",
"MatchEvidence", "MatchEvidence",
+12
View File
@@ -19,3 +19,15 @@ class Crate:
path: Path path: Path
references: Tuple[TrackReference, ...] references: Tuple[TrackReference, ...]
kind: CrateKind = CrateKind.UNKNOWN kind: CrateKind = CrateKind.UNKNOWN
@property
def hierarchy(self) -> Tuple[str, ...]:
return tuple(self.path.stem.split("≫≫"))
@property
def display_name(self) -> str:
return self.hierarchy[-1]
@property
def is_smart_definition(self) -> bool:
return self.path.suffix.casefold() == ".scrate"
+21
View File
@@ -0,0 +1,21 @@
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, Tuple
from serato_doctor.models.track import DiskTrack
@dataclass(frozen=True)
class BrokenSymlink:
"""A symbolic link whose target cannot be resolved."""
path: Path
target: Optional[Path]
@dataclass(frozen=True)
class FilesystemScan:
"""Read-only findings from one traversal of a music folder."""
tracks: Tuple[DiskTrack, ...]
broken_symlinks: Tuple[BrokenSymlink, ...]
+2
View File
@@ -19,8 +19,10 @@ class HealthReport:
suspected_cloud_conflict_files: int suspected_cloud_conflict_files: int
unused_tracks: int unused_tracks: int
suggested_matches: int suggested_matches: int
broken_symlinks: int
static_crates: int static_crates: int
smart_crates: int smart_crates: int
smart_crate_containers: int
unknown_crates: int unknown_crates: int
dynamic_references_excluded: int dynamic_references_excluded: int
+18 -3
View File
@@ -2,6 +2,7 @@ from dataclasses import dataclass
from typing import Iterable, Tuple from typing import Iterable, Tuple
from serato_doctor.models.crate import Crate from serato_doctor.models.crate import Crate
from serato_doctor.models.filesystem import BrokenSymlink
from serato_doctor.models.reference import ReferenceResult, TrackReference from serato_doctor.models.reference import ReferenceResult, TrackReference
from serato_doctor.models.track import DiskTrack from serato_doctor.models.track import DiskTrack
@@ -13,18 +14,27 @@ class Library:
references: Tuple[TrackReference, ...] references: Tuple[TrackReference, ...]
tracks: Tuple[DiskTrack, ...] tracks: Tuple[DiskTrack, ...]
crates: Tuple[Crate, ...] = () crates: Tuple[Crate, ...] = ()
broken_symlinks: Tuple[BrokenSymlink, ...] = ()
@classmethod @classmethod
def build( def build(
cls, cls,
references: Iterable[TrackReference], references: Iterable[TrackReference],
tracks: Iterable[DiskTrack], tracks: Iterable[DiskTrack],
broken_symlinks: Iterable[BrokenSymlink] = (),
) -> "Library": ) -> "Library":
return cls(tuple(references), tuple(tracks)) return cls(
tuple(references),
tuple(tracks),
broken_symlinks=tuple(broken_symlinks),
)
@classmethod @classmethod
def from_crates( def from_crates(
cls, crates: Iterable[Crate], tracks: Iterable[DiskTrack] cls,
crates: Iterable[Crate],
tracks: Iterable[DiskTrack],
broken_symlinks: Iterable[BrokenSymlink] = (),
) -> "Library": ) -> "Library":
crate_tuple = tuple(crates) crate_tuple = tuple(crates)
references = tuple( references = tuple(
@@ -32,7 +42,12 @@ class Library:
for crate in crate_tuple for crate in crate_tuple
for reference in crate.references for reference in crate.references
) )
return cls(references, tuple(tracks), crate_tuple) return cls(
references,
tuple(tracks),
crate_tuple,
tuple(broken_symlinks),
)
def reconcile_by_filename(self) -> Tuple[ReferenceResult, ...]: def reconcile_by_filename(self) -> Tuple[ReferenceResult, ...]:
disk_names = {track.filename for track in self.tracks} disk_names = {track.filename for track in self.tracks}
+20 -2
View File
@@ -1,14 +1,23 @@
from pathlib import Path from pathlib import Path
from serato_doctor.models.filesystem import BrokenSymlink, FilesystemScan
from serato_doctor.models.track import DiskTrack from serato_doctor.models.track import DiskTrack
AUDIO_SUFFIXES = {".mp3", ".m4a", ".wav", ".aif", ".aiff", ".flac"} AUDIO_SUFFIXES = {".mp3", ".m4a", ".wav", ".aif", ".aiff", ".flac"}
def scan_audio(folder: Path) -> list[DiskTrack]: def scan_filesystem(folder: Path) -> FilesystemScan:
tracks = [] tracks = []
broken_symlinks = []
for path in folder.rglob("*"): for path in folder.rglob("*"):
if path.is_symlink() and not path.exists():
try:
target = path.readlink()
except OSError:
target = None
broken_symlinks.append(BrokenSymlink(path=path, target=target))
continue
if not path.is_file(): if not path.is_file():
continue continue
if path.suffix.lower() not in AUDIO_SUFFIXES: if path.suffix.lower() not in AUDIO_SUFFIXES:
@@ -28,4 +37,13 @@ def scan_audio(folder: Path) -> list[DiskTrack]:
) )
) )
return tracks return FilesystemScan(
tracks=tuple(tracks),
broken_symlinks=tuple(broken_symlinks),
)
def scan_audio(folder: Path) -> list[DiskTrack]:
"""Scan audio files while preserving the prototype API."""
return list(scan_filesystem(folder).tracks)
+110
View File
@@ -0,0 +1,110 @@
import argparse
import json
from dataclasses import asdict
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from importlib import resources
from pathlib import Path
from typing import Iterable
from serato_doctor.crate_parser import load_library_crates
from serato_doctor.health import analyze_health
from serato_doctor.models.library import Library
from serato_doctor.scanner import scan_filesystem
MAX_REQUEST_BYTES = 64 * 1024
STATIC_FILES = {
"/": ("index.html", "text/html; charset=utf-8"),
"/app.css": ("app.css", "text/css; charset=utf-8"),
"/app.js": ("app.js", "text/javascript; charset=utf-8"),
}
def analyze_paths(
serato: Path, music: Path, reference_roots: Iterable[Path] = ()
) -> dict:
serato = serato.expanduser()
music = music.expanduser()
reference_roots = tuple(root.expanduser() for root in reference_roots)
if not serato.is_dir():
raise ValueError(f"Serato folder does not exist: {serato}")
if not music.is_dir():
raise ValueError(f"Music folder does not exist: {music}")
crates = load_library_crates(serato, reference_roots)
filesystem = scan_filesystem(music)
library = Library.from_crates(
crates,
filesystem.tracks,
filesystem.broken_symlinks,
)
report = analyze_health(library)
result = asdict(report)
result["score_basis"] = report.score_basis
return result
class SeratoDoctorHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
asset = STATIC_FILES.get(self.path)
if asset is None:
self._json_response(404, {"error": "Not found"})
return
filename, content_type = asset
content = (
resources.files("serato_doctor.webui")
.joinpath(filename)
.read_bytes()
)
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(content)))
self.end_headers()
self.wfile.write(content)
def do_POST(self) -> None:
if self.path != "/api/analyze":
self._json_response(404, {"error": "Not found"})
return
try:
length = int(self.headers.get("Content-Length", "0"))
if length <= 0 or length > MAX_REQUEST_BYTES:
raise ValueError("Invalid request size")
payload = json.loads(self.rfile.read(length))
if not isinstance(payload, dict):
raise ValueError("Request body must be a JSON object")
roots = [Path(value) for value in payload.get("reference_roots", [])]
result = analyze_paths(
Path(payload["serato"]), Path(payload["music"]), roots
)
except (KeyError, TypeError, json.JSONDecodeError, ValueError) as error:
self._json_response(400, {"error": str(error)})
return
self._json_response(200, result)
def _json_response(self, status: int, payload: dict) -> None:
content = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(content)))
self.end_headers()
self.wfile.write(content)
def log_message(self, format: str, *args: object) -> None:
return
def main() -> None:
parser = argparse.ArgumentParser(prog="serato-doctor-web")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8765)
args = parser.parse_args()
server = ThreadingHTTPServer((args.host, args.port), SeratoDoctorHandler)
print(f"Serato Doctor web interface: http://{args.host}:{args.port}")
print("Press Ctrl+C to stop.")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
+1
View File
@@ -0,0 +1 @@
"""Static assets for the local Serato Doctor web interface."""
File diff suppressed because one or more lines are too long
+54
View File
@@ -0,0 +1,54 @@
const form = document.querySelector('#analysis-form');
const button = document.querySelector('#analyze-button');
const errorBox = document.querySelector('#error-message');
const results = document.querySelector('#dashboard');
function expandHome(path) {
return path.trim();
}
function render(data) {
document.querySelectorAll('[data-field]').forEach((element) => {
const value = data[element.dataset.field];
element.textContent = value ?? '—';
});
const score = data.score;
document.querySelector('#health-score').textContent = score == null ? '—' : `${score}%`;
document.querySelector('#score-ring').style.setProperty('--score', score ?? 0);
document.querySelector('#health-message').textContent = score == null
? 'Not enough data yet'
: score >= 95 ? 'Looking excellent' : score >= 80 ? 'A few things need attention' : 'Review recommended';
document.querySelector('#score-basis').textContent = data.score_basis;
document.querySelector('#analysis-time').textContent = `Completed ${new Date().toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})}`;
results.hidden = false;
results.scrollIntoView({behavior: 'smooth', block: 'start'});
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
errorBox.hidden = true;
button.disabled = true;
button.querySelector('span').textContent = 'Analyzing safely…';
const roots = document.querySelector('#reference-roots').value
.split('\n').map((value) => value.trim()).filter(Boolean);
try {
const response = await fetch('/api/analyze', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
serato: expandHome(document.querySelector('#serato-path').value),
music: expandHome(document.querySelector('#music-path').value),
reference_roots: roots,
}),
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Analysis failed');
render(data);
} catch (error) {
errorBox.textContent = error.message;
errorBox.hidden = false;
} finally {
button.disabled = false;
button.querySelector('span').textContent = 'Analyze library';
}
});
+87
View File
@@ -0,0 +1,87 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>Serato Doctor</title>
<link rel="stylesheet" href="/app.css">
</head>
<body>
<div class="ambient ambient-one"></div>
<div class="ambient ambient-two"></div>
<div class="shell">
<aside class="sidebar">
<a class="brand" href="/" aria-label="Serato Doctor home">
<span class="brand-mark">SD</span>
<span><strong>Serato</strong><small>Doctor</small></span>
</a>
<nav aria-label="Primary navigation">
<a class="nav-item active" href="#dashboard"><span></span> Dashboard</a>
<a class="nav-item" href="#scan"><span></span> New analysis</a>
<a class="nav-item" href="#diagnostics"><span></span> Diagnostics</a>
</nav>
<div class="safety-card">
<span class="safety-icon"></span>
<div><strong>Read-only mode</strong><p>Your library will not be modified.</p></div>
</div>
<div class="sidebar-foot">Local interface · v0.1</div>
</aside>
<main>
<header class="topbar">
<div><p class="eyebrow">Library intelligence</p><h1>Good evening.</h1></div>
<div class="status-pill"><span></span> Local &amp; private</div>
</header>
<section id="scan" class="scan-panel panel">
<div class="panel-copy">
<p class="eyebrow">Start here</p>
<h2>Analyze your library</h2>
<p>Point Serato Doctor at your Serato and music folders. We inspect references, duplicates, smart crates, and symlinks without changing a thing.</p>
</div>
<form id="analysis-form">
<label>Serato folder
<input id="serato-path" name="serato" value="~/Music/_Serato_" required>
</label>
<label>Music folder
<input id="music-path" name="music" value="~/Music/Jukebox" required>
</label>
<label class="wide">Historical reference roots <span>optional · one per line</span>
<textarea id="reference-roots" rows="2" placeholder="/Users/old-user/OneDrive/Jukebox"></textarea>
</label>
<button id="analyze-button" type="submit"><span>Analyze library</span><b></b></button>
</form>
<div id="error-message" class="error" role="alert" hidden></div>
</section>
<section id="dashboard" class="results" aria-live="polite" hidden>
<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">
<article class="score-card panel">
<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>
</article>
<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 warning"><span>Broken references</span><strong data-field="missing_references"></strong><small>static crate entries</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"><span>Suggested matches</span><strong data-field="suggested_matches"></strong><small>explainable candidates</small></article>
</div>
</div>
<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="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 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><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><span class="diag-icon red"></span><p><strong>Broken symlinks</strong><small><b data-field="broken_symlinks"></b> unresolved links</small></p></div>
</div>
</div>
</section>
</main>
</div>
<script src="/app.js" defer></script>
</body>
</html>
+34 -1
View File
@@ -6,6 +6,7 @@ from serato_doctor.crate_parser import (
classify_crate, classify_crate,
clean_path, clean_path,
load_crate, load_crate,
load_library_crates,
parse_crate, parse_crate,
parse_crates, parse_crates,
path_markers, path_markers,
@@ -92,7 +93,7 @@ def test_parse_crates_reuses_configured_roots_for_every_crate(tmp_path):
("relative_path", "expected"), ("relative_path", "expected"),
[ [
("_Serato_/Subcrates/House.crate", CrateKind.STATIC), ("_Serato_/Subcrates/House.crate", CrateKind.STATIC),
("_Serato_/Smartcrates/Warmup.crate", CrateKind.SMART), ("_Serato_/SmartCrates/Warmup.scrate", CrateKind.SMART),
("_Serato_/Subcrates/Compatible by key.crate", CrateKind.SMART), ("_Serato_/Subcrates/Compatible by key.crate", CrateKind.SMART),
("fixtures/Unknown.crate", CrateKind.UNKNOWN), ("fixtures/Unknown.crate", CrateKind.UNKNOWN),
], ],
@@ -101,3 +102,35 @@ def test_classify_crate_uses_provenance_and_known_dynamic_name(
tmp_path, relative_path, expected tmp_path, relative_path, expected
): ):
assert classify_crate(tmp_path / relative_path) is expected assert classify_crate(tmp_path / relative_path) is expected
def test_smart_crate_preserves_encoded_hierarchy(tmp_path):
crate_path = (
tmp_path / "_Serato_" / "SmartCrates" / "Compatible by key≫≫10A.scrate"
)
crate_path.parent.mkdir(parents=True)
crate_path.write_bytes(b"")
crate = load_crate(crate_path)
assert crate.kind is CrateKind.SMART
assert crate.hierarchy == ("Compatible by key", "10A")
assert crate.display_name == "10A"
assert crate.is_smart_definition
def test_load_library_crates_discovers_scrate_definitions(tmp_path):
smart_folder = tmp_path / "SmartCrates"
static_folder = tmp_path / "Subcrates"
smart_folder.mkdir()
static_folder.mkdir()
(smart_folder / "New EDM.scrate").write_bytes(b"")
(smart_folder / "Re-Drums.scrate").write_bytes(b"")
(static_folder / "House.crate").write_bytes(b"")
crates = load_library_crates(tmp_path)
assert [(crate.path.name, crate.kind) for crate in crates] == [
("New EDM.scrate", CrateKind.SMART),
("Re-Drums.scrate", CrateKind.SMART),
("House.crate", CrateKind.STATIC),
]
+17 -2
View File
@@ -2,6 +2,7 @@ from pathlib import Path
from serato_doctor.health import analyze_health from serato_doctor.health import analyze_health
from serato_doctor.models.crate import Crate, CrateKind from serato_doctor.models.crate import Crate, CrateKind
from serato_doctor.models.filesystem import BrokenSymlink
from serato_doctor.models.library import Library from serato_doctor.models.library import Library
from serato_doctor.models.reference import TrackReference from serato_doctor.models.reference import TrackReference
from serato_doctor.models.track import DiskTrack from serato_doctor.models.track import DiskTrack
@@ -62,14 +63,14 @@ def test_empty_library_has_no_health_score():
def test_smart_crate_references_are_reported_but_not_scored(): def test_smart_crate_references_are_reported_but_not_scored():
static_reference = reference("Found.mp3") static_reference = reference("Found.mp3")
smart_reference = TrackReference( smart_reference = TrackReference(
Path("Smartcrates/Dynamic.crate"), Path("SmartCrates/Dynamic.scrate"),
Path("/old/House/Dynamic.mp3"), Path("/old/House/Dynamic.mp3"),
"Dynamic.mp3", "Dynamic.mp3",
) )
crates = [ crates = [
Crate(Path("Subcrates/Static.crate"), (static_reference,), CrateKind.STATIC), Crate(Path("Subcrates/Static.crate"), (static_reference,), CrateKind.STATIC),
Crate( Crate(
Path("Smartcrates/Dynamic.crate"), Path("SmartCrates/Dynamic.scrate"),
(smart_reference,), (smart_reference,),
CrateKind.SMART, CrateKind.SMART,
), ),
@@ -84,6 +85,7 @@ def test_smart_crate_references_are_reported_but_not_scored():
assert report.missing_references == 0 assert report.missing_references == 0
assert report.static_crates == 1 assert report.static_crates == 1
assert report.smart_crates == 1 assert report.smart_crates == 1
assert report.smart_crate_containers == 0
assert report.dynamic_references_excluded == 1 assert report.dynamic_references_excluded == 1
@@ -104,3 +106,16 @@ def test_health_reports_cloud_conflicts_separately_from_exact_duplicates():
assert report.duplicate_files == 1 assert report.duplicate_files == 1
assert report.suspected_cloud_conflict_groups == 1 assert report.suspected_cloud_conflict_groups == 1
assert report.suspected_cloud_conflict_files == 1 assert report.suspected_cloud_conflict_files == 1
def test_health_reports_broken_symlinks_without_changing_score():
library = Library.build(
[reference("Found.mp3")],
[track("Found.mp3")],
[BrokenSymlink(Path("/music/Broken.mp3"), Path("missing.mp3"))],
)
report = analyze_health(library)
assert report.score == 100.0
assert report.broken_symlinks == 1
+3 -1
View File
@@ -24,7 +24,9 @@ def test_generated_sample_library_has_expected_scenario(tmp_path):
if not result.exists_by_filename if not result.exists_by_filename
} }
assert len(list((sample_root / "Serato").rglob("*.crate"))) == 7 crate_files = list((sample_root / "Serato").rglob("*.crate"))
smart_files = list((sample_root / "Serato").rglob("*.scrate"))
assert len(crate_files) + len(smart_files) == 7
assert len(library.references) == 13 assert len(library.references) == 13
assert len(tracks) == 10 assert len(tracks) == 10
assert missing == {"Missing.mp3", "Old Name.mp3"} assert missing == {"Missing.mp3", "Old Name.mp3"}
+34 -1
View File
@@ -1,4 +1,6 @@
from serato_doctor.scanner import scan_audio from pathlib import Path
from serato_doctor.scanner import scan_audio, scan_filesystem
def test_scan_audio_finds_supported_files(tmp_path): def test_scan_audio_finds_supported_files(tmp_path):
@@ -15,3 +17,34 @@ def test_scan_audio_finds_supported_files(tmp_path):
first = next(track for track in tracks if track.filename == "First.MP3") first = next(track for track in tracks if track.filename == "First.MP3")
assert first.suffix == ".mp3" assert first.suffix == ".mp3"
assert first.size == len(b"synthetic audio") assert first.size == len(b"synthetic audio")
def test_scan_filesystem_reports_broken_symlink(tmp_path):
music = tmp_path / "music"
music.mkdir()
link = music / "Missing.mp3"
link.symlink_to("not-there.mp3")
result = scan_filesystem(music)
assert result.tracks == ()
assert len(result.broken_symlinks) == 1
assert result.broken_symlinks[0].path == link
assert result.broken_symlinks[0].target == Path("not-there.mp3")
def test_valid_audio_symlink_is_scanned_normally(tmp_path):
music = tmp_path / "music"
music.mkdir()
target = music / "Target.mp3"
target.write_bytes(b"synthetic audio")
link = music / "Linked.mp3"
link.symlink_to(target)
result = scan_filesystem(music)
assert {track.filename for track in result.tracks} == {
"Linked.mp3",
"Target.mp3",
}
assert result.broken_symlinks == ()
+35
View File
@@ -0,0 +1,35 @@
import runpy
from pathlib import Path
import pytest
from serato_doctor.web import STATIC_FILES, analyze_paths
def test_web_analysis_uses_production_health_pipeline(tmp_path):
generator = runpy.run_path(
str(Path(__file__).parents[1] / "samples/small-library/generate.py")
)
sample = generator["build_sample"](tmp_path / "sample")
result = analyze_paths(
sample / "Serato" / "_Serato_", sample / "Music"
)
assert result["score"] == 77.8
assert result["disk_tracks"] == 10
assert result["missing_references"] == 2
assert result["static_crates"] == 5
assert result["smart_crates"] == 2
def test_web_analysis_rejects_missing_folders(tmp_path):
with pytest.raises(ValueError, match="Serato folder does not exist"):
analyze_paths(tmp_path / "missing", tmp_path)
def test_web_static_assets_are_declared_and_packaged():
asset_root = Path(__file__).parents[1] / "serato_doctor" / "webui"
assert set(STATIC_FILES) == {"/", "/app.css", "/app.js"}
assert all((asset_root / filename).is_file() for filename, _ in STATIC_FILES.values())