diff --git a/README.md b/README.md index e69de29..0212d77 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,37 @@ +# Serato Doctor + +Serato Doctor is a read-only-first toolkit for inspecting and eventually repairing +DJ libraries. Serato is the first supported engine. + +## Analyze a Library + +```shell +serato-doctor analyze \ + --serato ~/Music/_Serato_ \ + --music ~/Music/Jukebox +``` + +Analysis prints a transparent reference-integrity score plus broken references, +duplicate filenames, unused tracks, and suggested filename matches. It does not +modify the library or create report files. + +If crates contain an older library root, supply it explicitly: + +```shell +serato-doctor analyze --reference-root /Users/old-user/OneDrive/Jukebox +``` + +## Generate Detailed Reports + +Running without a command preserves the original scanner behavior and writes CSV +and text reports: + +```shell +serato-doctor --serato ~/Music/_Serato_ --music ~/Music/Jukebox +``` + +Use `--verbose` for progress on standard error or `--log-file PATH` for an +aggregate diagnostic log. + +Serato Doctor never repairs files without an explicit future repair workflow, +preview, backup, and rollback path. diff --git a/ROADMAP.md b/ROADMAP.md index 1ec0b14..6ddc306 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -17,6 +17,7 @@ ## v0.2 — Diagnostics +- [x] `serato-doctor analyze` command - [ ] Duplicate filename detection - [ ] Duplicate audio hash detection - [ ] Broken symlink detection diff --git a/docs/design/analyze-command.md b/docs/design/analyze-command.md new file mode 100644 index 0000000..7e7743d --- /dev/null +++ b/docs/design/analyze-command.md @@ -0,0 +1,27 @@ +# Analyze Command + +## Problem + +The health and matching engines are only Python APIs. Users need one safe command +that summarizes library integrity without first interpreting CSV files. + +## Architecture + +`serato-doctor analyze` reuses the existing configured crate parser and filesystem +scanner, then prints the immutable health report. The original no-command mode is +retained as the report-producing scan workflow. + +Analyze mode does not create CSV or text reports. Both modes remain read-only with +respect to Serato crates, databases, and music files. + +## Edge Cases + +- A library with no references displays `Not assessed` rather than a false score. +- Historical roots continue to work through repeatable `--reference-root` flags. +- Normal output remains separate from optional diagnostic logging. +- Existing scripts that invoke the CLI without a subcommand remain compatible. + +## Verification + +Tests prove the legacy five-line output and reports remain unchanged, while analyze +prints health metrics and does not create output files. diff --git a/serato_doctor/cli.py b/serato_doctor/cli.py index 3c565c6..859b78c 100644 --- a/serato_doctor/cli.py +++ b/serato_doctor/cli.py @@ -3,6 +3,7 @@ import argparse from serato_doctor.config import ScanConfig from serato_doctor.crate_parser import parse_crates +from serato_doctor.health import analyze_health from serato_doctor.logging import configure_logging from serato_doctor.models.library import Library from serato_doctor.scanner import scan_audio @@ -11,10 +12,23 @@ from serato_doctor.report import write_csv, write_missing_report def main(): parser = argparse.ArgumentParser(prog="serato-doctor") + parser.add_argument( + "command", nargs="?", choices=("scan", "analyze"), default="scan" + ) parser.add_argument("--serato", default=str(Path.home() / "Music/_Serato_")) - parser.add_argument("--music", default=str(Path.home() / "Library/CloudStorage/OneDrive-Personal/Jukebox")) - parser.add_argument("--out", default=str(Path.home() / "Desktop/serato_doctor_scan.csv")) - parser.add_argument("--report", default=str(Path.home() / "Desktop/serato_doctor_missing_report.txt")) + parser.add_argument( + "--music", + default=str( + Path.home() / "Library/CloudStorage/OneDrive-Personal/Jukebox" + ), + ) + parser.add_argument( + "--out", default=str(Path.home() / "Desktop/serato_doctor_scan.csv") + ) + parser.add_argument( + "--report", + default=str(Path.home() / "Desktop/serato_doctor_missing_report.txt"), + ) parser.add_argument( "--reference-root", action="append", @@ -37,7 +51,7 @@ def main(): log_file=Path(args.log_file) if args.log_file else None, ) logger = configure_logging(config.verbose, config.log_file) - logger.info("Starting read-only library scan") + logger.info("Starting read-only library inspection") logger.debug("Serato directory: %s", config.serato) logger.debug("Music directory: %s", config.music) @@ -53,15 +67,30 @@ def main(): logger.info("Scanned %d disk tracks", len(library.tracks)) logger.info("Found %d references missing by filename", missing_count) - write_csv(results, config.out) - write_missing_report(results, config.report) - logger.info("Wrote CSV and missing-reference reports") - - print(f"Crate references: {len(library.references)}") - print(f"Disk tracks: {len(library.tracks)}") - print(f"Missing by filename: {missing_count}") - print(f"CSV: {config.out}") - print(f"Report: {config.report}") + if args.command == "analyze": + health = analyze_health(library) + score = f"{health.score:.1f}%" if health.score is not None else "Not assessed" + logger.info("Calculated library health: %s", score) + print(f"Overall Health: {score}") + print(f"Score Basis: {health.score_basis}") + print(f"Tracks: {health.disk_tracks}") + print(f"Crate References: {health.total_references}") + print(f"Healthy References: {health.healthy_references}") + print(f"Broken References: {health.missing_references}") + print(f"Unique Missing Filenames: {health.unique_missing_filenames}") + print(f"Duplicate Filename Groups: {health.duplicate_filename_groups}") + print(f"Duplicate Files: {health.duplicate_files}") + print(f"Unused Tracks: {health.unused_tracks}") + print(f"Suggested Matches: {health.suggested_matches}") + else: + write_csv(results, config.out) + write_missing_report(results, config.report) + logger.info("Wrote CSV and missing-reference reports") + print(f"Crate references: {len(library.references)}") + print(f"Disk tracks: {len(library.tracks)}") + print(f"Missing by filename: {missing_count}") + print(f"CSV: {config.out}") + print(f"Report: {config.report}") if __name__ == "__main__": diff --git a/tests/test_cli.py b/tests/test_cli.py index 09878c6..0213661 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -84,3 +84,47 @@ def test_cli_accepts_old_reference_root(tmp_path, monkeypatch, capsys): output = capsys.readouterr().out assert "Crate references: 1" in output assert "Missing by filename: 0" in output + + +def test_analyze_prints_health_without_writing_reports(tmp_path, monkeypatch, capsys): + serato = tmp_path / "serato" + subcrates = serato / "Subcrates" + music = tmp_path / "music" + subcrates.mkdir(parents=True) + music.mkdir() + crate_text = ( + "Users/sample-user/Jukebox/Found.mp3otrk" + "Users/sample-user/Jukebox/Missing.mp3otrk" + ) + (subcrates / "Test.crate").write_bytes(crate_text.encode("utf-16-le")) + (music / "Found.mp3").write_bytes(b"synthetic audio") + csv_path = tmp_path / "scan.csv" + report_path = tmp_path / "report.txt" + monkeypatch.setattr( + sys, + "argv", + [ + "serato-doctor", + "analyze", + "--serato", + str(serato), + "--music", + str(music), + "--out", + str(csv_path), + "--report", + str(report_path), + ], + ) + + main() + + output = capsys.readouterr().out + assert "Overall Health: 50.0%" in output + assert "Tracks: 1" in output + assert "Crate References: 2" in output + assert "Healthy References: 1" in output + assert "Broken References: 1" in output + assert "Unused Tracks: 0" in output + assert not csv_path.exists() + assert not report_path.exists()