Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d2f625ed18 | |||
| fb3d70e579 | |||
| ce37b45058 | |||
| 37786612b7 | |||
| 8ff4db4951 | |||
| a384cdc88d | |||
| 71bca064ed | |||
| eda5d4e62b | |||
| 9053b4ca3d | |||
| 251ab4d090 | |||
| b8ea17450b | |||
| c5449a278a |
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+3
-3
@@ -7,10 +7,10 @@
|
|||||||
- [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
|
- [x] Database V2 read-only parser
|
||||||
- [x] Configuration
|
- [x] Configuration
|
||||||
- [x] Logging
|
- [x] Logging
|
||||||
- [x] Matching engine
|
- [x] Matching engine
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Database V2 Read-only Parser
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Crates and files do not explain every orange track in Serato. The legacy
|
||||||
|
`database V2` contains Serato's library-level track paths and metadata, so it must
|
||||||
|
be inspected independently from crate references.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The parser reads the file as a big-endian tag-length-value stream. Top-level
|
||||||
|
`otrk` records contain nested fields including `pfil` (path), `tsng` (title),
|
||||||
|
`tart` (artist), `talb` (album), and `tgen` (genre). Text is UTF-16 big-endian.
|
||||||
|
|
||||||
|
Analysis reports total database entries, entries whose filenames occur in the
|
||||||
|
selected music scan, entries outside that scan, scanned tracks absent from the
|
||||||
|
database, and duplicate database paths. “Outside scan” is deliberately not called
|
||||||
|
missing because Serato databases can include samples and tracks from other roots.
|
||||||
|
|
||||||
|
## Safety
|
||||||
|
|
||||||
|
The parser calls only `read_bytes`; it never opens the database for writing. No
|
||||||
|
metadata values or personal paths are sent to logs or the dashboard.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Synthetic TLV fixtures cover version, paths, metadata, incomplete records, and
|
||||||
|
health integration. The sample library includes a generated ten-entry database.
|
||||||
@@ -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.
|
||||||
@@ -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 |
@@ -0,0 +1,37 @@
|
|||||||
|
# Local Web Interface
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
@@ -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"]
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ SAMPLE_ROOT = Path(__file__).parent
|
|||||||
SERATO_PATH_PREFIX = "Users/sample-user/OneDrive/Jukebox/"
|
SERATO_PATH_PREFIX = "Users/sample-user/OneDrive/Jukebox/"
|
||||||
|
|
||||||
|
|
||||||
|
def database_record(tag: bytes, payload: bytes) -> bytes:
|
||||||
|
return tag + len(payload).to_bytes(4, "big") + payload
|
||||||
|
|
||||||
|
|
||||||
def load_manifest() -> dict:
|
def load_manifest() -> dict:
|
||||||
return json.loads((SAMPLE_ROOT / "manifest.json").read_text(encoding="utf-8"))
|
return json.loads((SAMPLE_ROOT / "manifest.json").read_text(encoding="utf-8"))
|
||||||
|
|
||||||
@@ -28,18 +32,38 @@ 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
|
||||||
|
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()
|
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"))
|
||||||
|
|
||||||
|
database_records = [
|
||||||
|
database_record(b"vrsn", "2.0/Serato Doctor Fixture".encode("utf-16-be"))
|
||||||
|
]
|
||||||
|
for relative_path in manifest["tracks"]:
|
||||||
|
fields = database_record(
|
||||||
|
b"pfil", str(music_root / relative_path).encode("utf-16-be")
|
||||||
|
)
|
||||||
|
database_records.append(database_record(b"otrk", fields))
|
||||||
|
(serato_root / "database V2").write_bytes(b"".join(database_records))
|
||||||
|
|
||||||
return root
|
return root
|
||||||
|
|
||||||
|
|||||||
+20
-2
@@ -3,10 +3,11 @@ import argparse
|
|||||||
|
|
||||||
from serato_doctor.config import ScanConfig
|
from serato_doctor.config import ScanConfig
|
||||||
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.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 +57,14 @@ 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)
|
||||||
|
database_path = config.serato / "database V2"
|
||||||
|
database = parse_database(database_path) if database_path.is_file() else None
|
||||||
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,
|
||||||
|
database=database,
|
||||||
)
|
)
|
||||||
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,11 +96,23 @@ 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}"
|
||||||
)
|
)
|
||||||
|
print(f"Database Entries: {health.database_entries}")
|
||||||
|
print(f"Database / Library Matches: {health.database_library_matches}")
|
||||||
|
print(f"Database Entries Outside Scan: {health.database_unmatched_entries}")
|
||||||
|
print(f"Missing Tracks in Serato: {health.database_missing_paths}")
|
||||||
|
print(
|
||||||
|
"Unique Missing Tracks in Serato: "
|
||||||
|
f"{health.database_missing_unique_filenames}"
|
||||||
|
)
|
||||||
|
print(f"Tracks Missing From Database: {health.tracks_missing_from_database}")
|
||||||
|
print(f"Duplicate Database Paths: {health.duplicate_database_paths}")
|
||||||
else:
|
else:
|
||||||
write_csv(results, config.out)
|
write_csv(results, config.out)
|
||||||
write_missing_report(results, config.report)
|
write_missing_report(results, config.report)
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
}
|
||||||
|
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))
|
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)))
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Iterator, Optional, Tuple
|
||||||
|
|
||||||
|
from serato_doctor.models.database import DatabaseTrack, SeratoDatabase
|
||||||
|
|
||||||
|
|
||||||
|
TEXT_FIELDS = {
|
||||||
|
b"tsng": "title",
|
||||||
|
b"tart": "artist",
|
||||||
|
b"talb": "album",
|
||||||
|
b"tgen": "genre",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def iter_records(data: bytes) -> Iterator[Tuple[bytes, bytes]]:
|
||||||
|
"""Yield complete big-endian tag-length-value records."""
|
||||||
|
|
||||||
|
offset = 0
|
||||||
|
while offset + 8 <= len(data):
|
||||||
|
tag = data[offset : offset + 4]
|
||||||
|
length = int.from_bytes(data[offset + 4 : offset + 8], "big")
|
||||||
|
payload_start = offset + 8
|
||||||
|
payload_end = payload_start + length
|
||||||
|
if payload_end > len(data):
|
||||||
|
break
|
||||||
|
yield tag, data[payload_start:payload_end]
|
||||||
|
offset = payload_end
|
||||||
|
|
||||||
|
|
||||||
|
def decode_text(payload: bytes) -> Optional[str]:
|
||||||
|
value = payload.decode("utf-16-be", errors="ignore").strip("\x00").strip()
|
||||||
|
return value or None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_database_path(value: str) -> Path:
|
||||||
|
if value.startswith(("Users/", "Volumes/")):
|
||||||
|
value = "/" + value
|
||||||
|
return Path(value)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_track(payload: bytes) -> Optional[DatabaseTrack]:
|
||||||
|
fields: Dict[str, Optional[str]] = {}
|
||||||
|
path = None
|
||||||
|
for tag, value in iter_records(payload):
|
||||||
|
if tag == b"pfil":
|
||||||
|
decoded_path = decode_text(value)
|
||||||
|
if decoded_path:
|
||||||
|
path = normalize_database_path(decoded_path)
|
||||||
|
elif tag in TEXT_FIELDS:
|
||||||
|
fields[TEXT_FIELDS[tag]] = decode_text(value)
|
||||||
|
if path is None:
|
||||||
|
return None
|
||||||
|
return DatabaseTrack(path=path, filename=path.name, **fields)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_database(database_path: Path) -> SeratoDatabase:
|
||||||
|
data = database_path.read_bytes()
|
||||||
|
version = None
|
||||||
|
tracks = []
|
||||||
|
for tag, payload in iter_records(data):
|
||||||
|
if tag == b"vrsn":
|
||||||
|
version = decode_text(payload)
|
||||||
|
elif tag == b"otrk":
|
||||||
|
track = parse_track(payload)
|
||||||
|
if track is not None:
|
||||||
|
tracks.append(track)
|
||||||
|
return SeratoDatabase(database_path, version, tuple(tracks))
|
||||||
+38
-2
@@ -1,5 +1,7 @@
|
|||||||
|
from collections import Counter
|
||||||
|
|
||||||
from serato_doctor.duplicates import find_duplicate_groups
|
from serato_doctor.duplicates import find_duplicate_groups
|
||||||
from serato_doctor.matching import MatchingEngine
|
from serato_doctor.matching import MatchingEngine, normalize
|
||||||
from serato_doctor.models.crate import CrateKind
|
from serato_doctor.models.crate import CrateKind
|
||||||
from serato_doctor.models.duplicate import DuplicateKind
|
from serato_doctor.models.duplicate import DuplicateKind
|
||||||
from serato_doctor.models.health import HealthReport
|
from serato_doctor.models.health import HealthReport
|
||||||
@@ -44,6 +46,16 @@ def analyze_health(library: Library) -> HealthReport:
|
|||||||
bool(matcher.candidates_for(result.reference)) for result in missing
|
bool(matcher.candidates_for(result.reference)) for result in missing
|
||||||
)
|
)
|
||||||
|
|
||||||
|
database_tracks = library.database.tracks if library.database else ()
|
||||||
|
database_names = {normalize(track.filename) for track in database_tracks}
|
||||||
|
library_names = {normalize(track.filename) for track in library.tracks}
|
||||||
|
database_path_counts = Counter(
|
||||||
|
normalize(str(track.path)) for track in database_tracks
|
||||||
|
)
|
||||||
|
missing_database_tracks = [
|
||||||
|
track for track in database_tracks if not track.path.exists()
|
||||||
|
]
|
||||||
|
|
||||||
return HealthReport(
|
return HealthReport(
|
||||||
score=score,
|
score=score,
|
||||||
total_references=len(library.references),
|
total_references=len(library.references),
|
||||||
@@ -62,11 +74,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
|
||||||
@@ -76,4 +94,22 @@ def analyze_health(library: Library) -> HealthReport:
|
|||||||
for crate in library.crates
|
for crate in library.crates
|
||||||
if crate.kind is CrateKind.SMART
|
if crate.kind is CrateKind.SMART
|
||||||
),
|
),
|
||||||
|
database_present=library.database is not None,
|
||||||
|
database_entries=len(database_tracks),
|
||||||
|
database_library_matches=sum(
|
||||||
|
normalize(track.filename) in library_names for track in database_tracks
|
||||||
|
),
|
||||||
|
database_unmatched_entries=sum(
|
||||||
|
normalize(track.filename) not in library_names for track in database_tracks
|
||||||
|
),
|
||||||
|
database_missing_paths=len(missing_database_tracks),
|
||||||
|
database_missing_unique_filenames=len(
|
||||||
|
{normalize(track.filename) for track in missing_database_tracks}
|
||||||
|
),
|
||||||
|
tracks_missing_from_database=sum(
|
||||||
|
normalize(track.filename) not in database_names for track in library.tracks
|
||||||
|
) if library.database else 0,
|
||||||
|
duplicate_database_paths=sum(
|
||||||
|
count - 1 for count in database_path_counts.values() if count > 1
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from serato_doctor.models.crate import Crate, CrateKind
|
from serato_doctor.models.crate import Crate, CrateKind
|
||||||
|
from serato_doctor.models.database import DatabaseTrack, SeratoDatabase
|
||||||
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
|
||||||
@@ -9,13 +11,17 @@ from serato_doctor.models.track import DiskTrack
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"Crate",
|
"Crate",
|
||||||
"CrateKind",
|
"CrateKind",
|
||||||
|
"DatabaseTrack",
|
||||||
"DiskTrack",
|
"DiskTrack",
|
||||||
"DuplicateGroup",
|
"DuplicateGroup",
|
||||||
"DuplicateKind",
|
"DuplicateKind",
|
||||||
|
"BrokenSymlink",
|
||||||
|
"FilesystemScan",
|
||||||
"HealthReport",
|
"HealthReport",
|
||||||
"Library",
|
"Library",
|
||||||
"MatchEvidence",
|
"MatchEvidence",
|
||||||
"ReferenceResult",
|
"ReferenceResult",
|
||||||
|
"SeratoDatabase",
|
||||||
"TrackMatch",
|
"TrackMatch",
|
||||||
"TrackReference",
|
"TrackReference",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DatabaseTrack:
|
||||||
|
"""Read-only metadata extracted from one database V2 track record."""
|
||||||
|
|
||||||
|
path: Path
|
||||||
|
filename: str
|
||||||
|
title: Optional[str] = None
|
||||||
|
artist: Optional[str] = None
|
||||||
|
album: Optional[str] = None
|
||||||
|
genre: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SeratoDatabase:
|
||||||
|
path: Path
|
||||||
|
version: Optional[str]
|
||||||
|
tracks: Tuple[DatabaseTrack, ...]
|
||||||
@@ -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, ...]
|
||||||
@@ -19,10 +19,20 @@ 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
|
||||||
|
database_present: bool
|
||||||
|
database_entries: int
|
||||||
|
database_library_matches: int
|
||||||
|
database_unmatched_entries: int
|
||||||
|
database_missing_paths: int
|
||||||
|
database_missing_unique_filenames: int
|
||||||
|
tracks_missing_from_database: int
|
||||||
|
duplicate_database_paths: int
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def score_basis(self) -> str:
|
def score_basis(self) -> str:
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Iterable, Tuple
|
from typing import Iterable, Optional, Tuple
|
||||||
|
|
||||||
from serato_doctor.models.crate import Crate
|
from serato_doctor.models.crate import Crate
|
||||||
|
from serato_doctor.models.database import SeratoDatabase
|
||||||
|
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 +15,31 @@ class Library:
|
|||||||
references: Tuple[TrackReference, ...]
|
references: Tuple[TrackReference, ...]
|
||||||
tracks: Tuple[DiskTrack, ...]
|
tracks: Tuple[DiskTrack, ...]
|
||||||
crates: Tuple[Crate, ...] = ()
|
crates: Tuple[Crate, ...] = ()
|
||||||
|
broken_symlinks: Tuple[BrokenSymlink, ...] = ()
|
||||||
|
database: Optional[SeratoDatabase] = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def build(
|
def build(
|
||||||
cls,
|
cls,
|
||||||
references: Iterable[TrackReference],
|
references: Iterable[TrackReference],
|
||||||
tracks: Iterable[DiskTrack],
|
tracks: Iterable[DiskTrack],
|
||||||
|
broken_symlinks: Iterable[BrokenSymlink] = (),
|
||||||
|
database: Optional[SeratoDatabase] = None,
|
||||||
) -> "Library":
|
) -> "Library":
|
||||||
return cls(tuple(references), tuple(tracks))
|
return cls(
|
||||||
|
tuple(references),
|
||||||
|
tuple(tracks),
|
||||||
|
broken_symlinks=tuple(broken_symlinks),
|
||||||
|
database=database,
|
||||||
|
)
|
||||||
|
|
||||||
@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] = (),
|
||||||
|
database: Optional[SeratoDatabase] = None,
|
||||||
) -> "Library":
|
) -> "Library":
|
||||||
crate_tuple = tuple(crates)
|
crate_tuple = tuple(crates)
|
||||||
references = tuple(
|
references = tuple(
|
||||||
@@ -32,7 +47,13 @@ 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),
|
||||||
|
database,
|
||||||
|
)
|
||||||
|
|
||||||
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}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from collections import Counter
|
||||||
|
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.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"),
|
||||||
|
"/app.js": ("app.js", "text/javascript; charset=utf-8"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
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)
|
||||||
|
database_path = serato / "database V2"
|
||||||
|
database = parse_database(database_path) if database_path.is_file() else None
|
||||||
|
library = Library.from_crates(
|
||||||
|
crates,
|
||||||
|
filesystem.tracks,
|
||||||
|
filesystem.broken_symlinks,
|
||||||
|
database,
|
||||||
|
)
|
||||||
|
report = analyze_health(library)
|
||||||
|
result = asdict(report)
|
||||||
|
result["score_basis"] = report.score_basis
|
||||||
|
result["details"] = diagnostic_details(library)
|
||||||
|
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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Static assets for the local Serato Doctor web interface."""
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,147 @@
|
|||||||
|
const form = document.querySelector('#analysis-form');
|
||||||
|
const button = document.querySelector('#analyze-button');
|
||||||
|
const errorBox = document.querySelector('#error-message');
|
||||||
|
const results = document.querySelector('#dashboard');
|
||||||
|
const infoButtons = document.querySelectorAll('.info-button');
|
||||||
|
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) {
|
||||||
|
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) {
|
||||||
|
latestAnalysis = 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'})}`;
|
||||||
|
renderDetail(data.database_missing_paths > 0 ? 'database_missing_tracks' : 'old_crate_references');
|
||||||
|
results.hidden = false;
|
||||||
|
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) => {
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<!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 & 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">
|
||||||
|
<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><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"><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 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 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 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 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> 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 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 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 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 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>
|
||||||
|
</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>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<script src="/app.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+34
-1
@@ -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),
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from serato_doctor.database_parser import iter_records, parse_database
|
||||||
|
|
||||||
|
|
||||||
|
def record(tag, payload):
|
||||||
|
return tag + len(payload).to_bytes(4, "big") + payload
|
||||||
|
|
||||||
|
|
||||||
|
def text_record(tag, value):
|
||||||
|
return record(tag, value.encode("utf-16-be"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_database_reads_track_paths_and_metadata(tmp_path):
|
||||||
|
track = b"".join(
|
||||||
|
[
|
||||||
|
text_record(b"pfil", "Users/sample/Music/Track.mp3"),
|
||||||
|
text_record(b"tsng", "Track title"),
|
||||||
|
text_record(b"tart", "Test artist"),
|
||||||
|
text_record(b"talb", "Test album"),
|
||||||
|
text_record(b"tgen", "House"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
database_path = tmp_path / "database V2"
|
||||||
|
database_path.write_bytes(
|
||||||
|
text_record(b"vrsn", "2.0/Test Database") + record(b"otrk", track)
|
||||||
|
)
|
||||||
|
|
||||||
|
database = parse_database(database_path)
|
||||||
|
|
||||||
|
assert database.version == "2.0/Test Database"
|
||||||
|
assert len(database.tracks) == 1
|
||||||
|
parsed = database.tracks[0]
|
||||||
|
assert parsed.path == Path("/Users/sample/Music/Track.mp3")
|
||||||
|
assert parsed.filename == "Track.mp3"
|
||||||
|
assert parsed.title == "Track title"
|
||||||
|
assert parsed.artist == "Test artist"
|
||||||
|
assert parsed.album == "Test album"
|
||||||
|
assert parsed.genre == "House"
|
||||||
|
|
||||||
|
|
||||||
|
def test_iter_records_ignores_incomplete_trailing_record():
|
||||||
|
complete = record(b"vrsn", "2.0".encode("utf-16-be"))
|
||||||
|
incomplete = b"otrk\x00\x00\x00\x10short"
|
||||||
|
|
||||||
|
records = list(iter_records(complete + incomplete))
|
||||||
|
|
||||||
|
assert records == [(b"vrsn", "2.0".encode("utf-16-be"))]
|
||||||
+19
-2
@@ -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
|
||||||
@@ -57,19 +58,21 @@ 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():
|
||||||
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 +87,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 +108,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
|
||||||
|
|||||||
@@ -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
@@ -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 == ()
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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
|
||||||
|
assert result["database_entries"] == 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["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):
|
||||||
|
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())
|
||||||
|
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