From bb74c14742fc3f52373d4a5130c3de22d652bd3a Mon Sep 17 00:00:00 2001 From: Philip Guzman Date: Tue, 30 Jun 2026 17:15:56 -0700 Subject: [PATCH] Add synthetic sample library --- .gitignore | 1 + ROADMAP.md | 2 +- docs/design/sample-library.md | 26 ++++++++++++++ samples/small-library/README.md | 25 +++++++++++++ samples/small-library/generate.py | 50 ++++++++++++++++++++++++++ samples/small-library/manifest.json | 56 +++++++++++++++++++++++++++++ tests/test_sample_library.py | 28 +++++++++++++++ 7 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 docs/design/sample-library.md create mode 100644 samples/small-library/README.md create mode 100644 samples/small-library/generate.py create mode 100644 samples/small-library/manifest.json create mode 100644 tests/test_sample_library.py diff --git a/.gitignore b/.gitignore index ad5ad1f..9c4908d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ __pycache__/ .venv/ *.egg-info/ reports/ +samples/small-library/generated/ *.sqlite *.db *.csv diff --git a/ROADMAP.md b/ROADMAP.md index 897e6c8..122d682 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,7 +9,7 @@ - [x] Grouped missing reference report - [ ] HTML health dashboard - [x] Test suite -- [ ] Sample library fixtures +- [x] Sample library fixtures - [ ] Database V2 read-only parser ## v0.2 — Diagnostics diff --git a/docs/design/sample-library.md b/docs/design/sample-library.md new file mode 100644 index 0000000..cd6f642 --- /dev/null +++ b/docs/design/sample-library.md @@ -0,0 +1,26 @@ +# Synthetic Sample Library + +## Problem + +Real Serato libraries contain private paths, listening history, and copyrighted +music. Contributors still need a repeatable library for tests and demonstrations. + +## Architecture + +`samples/small-library/manifest.json` describes a compact migration scenario. +`generate.py` turns that manifest into fake audio files and UTF-16-LE crate files +inside an ignored `generated` directory. The generated files are disposable and +contain no real audio or user library data. + +## Edge Cases + +- Five manually maintained crates and two smart/dynamic crates. +- Mixed supported audio extensions. +- One reference whose file is absent. +- One stale reference whose file has been renamed. +- References shared by static and smart crates. + +## Verification + +`pytest` generates the sample in a temporary directory and verifies its crate, +track, and missing-reference counts through the production parser and scanner. diff --git a/samples/small-library/README.md b/samples/small-library/README.md new file mode 100644 index 0000000..931f973 --- /dev/null +++ b/samples/small-library/README.md @@ -0,0 +1,25 @@ +# Small Synthetic Library + +This fixture models a small Serato migration without containing music or personal +library data. It has 10 fake audio files, five static crates, two smart crates, +one absent song, and one renamed song. + +Generate it with: + +```shell +python3 samples/small-library/generate.py +``` + +Analyze it with: + +```shell +python3 -m serato_doctor.cli \ + --serato samples/small-library/generated/Serato/_Serato_ \ + --music samples/small-library/generated/Music \ + --out samples/small-library/generated/scan.csv \ + --report samples/small-library/generated/report.txt +``` + +Expected CLI counts are 13 crate references, 10 disk tracks, and 2 references +missing by filename. Everything under `generated/` is disposable and ignored by +Git. diff --git a/samples/small-library/generate.py b/samples/small-library/generate.py new file mode 100644 index 0000000..4e0e846 --- /dev/null +++ b/samples/small-library/generate.py @@ -0,0 +1,50 @@ +"""Generate a disposable, synthetic Serato library from the sample manifest.""" + +import argparse +import json +from pathlib import Path +from typing import Optional + + +SAMPLE_ROOT = Path(__file__).parent +SERATO_PATH_PREFIX = "Users/djsplice/OneDrive/Jukebox/" + + +def load_manifest() -> dict: + return json.loads((SAMPLE_ROOT / "manifest.json").read_text(encoding="utf-8")) + + +def build_sample(output: Optional[Path] = None) -> Path: + root = output or SAMPLE_ROOT / "generated" + manifest = load_manifest() + music_root = root / "Music" + crate_root = root / "Serato" / "_Serato_" / "Subcrates" + crate_root.mkdir(parents=True, exist_ok=True) + + for relative_path in manifest["tracks"]: + track_path = music_root / relative_path + track_path.parent.mkdir(parents=True, exist_ok=True) + track_path.write_bytes( + f"Synthetic Serato Doctor fixture: {relative_path}\n".encode("utf-8") + ) + + for crate in manifest["crates"]: + records = "".join( + f"{SERATO_PATH_PREFIX}{relative_path}otrk" + for relative_path in crate["references"] + ) + (crate_root / crate["name"]).write_bytes(records.encode("utf-16-le")) + + return root + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + root = build_sample(args.output) + print(f"Generated synthetic library: {root}") + + +if __name__ == "__main__": + main() diff --git a/samples/small-library/manifest.json b/samples/small-library/manifest.json new file mode 100644 index 0000000..cc8506d --- /dev/null +++ b/samples/small-library/manifest.json @@ -0,0 +1,56 @@ +{ + "tracks": [ + "House/First.mp3", + "House/Second.m4a", + "Open Format/Third.wav", + "Open Format/Fourth.aif", + "Classics/Fifth.mp3", + "Classics/Sixth.flac", + "Warmup/Seventh.mp3", + "Warmup/Eighth.mp3", + "Renamed/New Name.mp3", + "Bonus/Ninth.MP3" + ], + "crates": [ + { + "name": "House.crate", + "type": "static", + "references": ["House/First.mp3", "House/Second.m4a", "House/Missing.mp3"] + }, + { + "name": "Open Format.crate", + "type": "static", + "references": ["Open Format/Third.wav", "Open Format/Fourth.aif"] + }, + { + "name": "Classics.crate", + "type": "static", + "references": ["Classics/Fifth.mp3", "Classics/Sixth.flac"] + }, + { + "name": "Renamed Tracks.crate", + "type": "static", + "references": ["Renamed/Old Name.mp3"] + }, + { + "name": "Bonus.crate", + "type": "static", + "references": ["Bonus/Ninth.MP3"] + }, + { + "name": "Compatible by key.crate", + "type": "smart", + "references": ["House/First.mp3", "House/Second.m4a"] + }, + { + "name": "Smart Warmup.crate", + "type": "smart", + "references": ["Warmup/Seventh.mp3", "Warmup/Eighth.mp3"] + } + ], + "expected": { + "disk_tracks": 10, + "crate_references": 13, + "missing_by_filename": ["Missing.mp3", "Old Name.mp3"] + } +} diff --git a/tests/test_sample_library.py b/tests/test_sample_library.py new file mode 100644 index 0000000..560e9fe --- /dev/null +++ b/tests/test_sample_library.py @@ -0,0 +1,28 @@ +import runpy +from pathlib import Path + +from serato_doctor.crate_parser import parse_crates +from serato_doctor.models.library import Library +from serato_doctor.scanner import scan_audio + + +def test_generated_sample_library_has_expected_scenario(tmp_path): + generator_path = ( + Path(__file__).parents[1] / "samples" / "small-library" / "generate.py" + ) + build_sample = runpy.run_path(str(generator_path))["build_sample"] + sample_root = build_sample(tmp_path / "sample") + + references = parse_crates(sample_root / "Serato" / "_Serato_" / "Subcrates") + tracks = scan_audio(sample_root / "Music") + results = Library.build(references, tracks).reconcile_by_filename() + missing = { + result.reference.filename + for result in results + if not result.exists_by_filename + } + + assert len(list((sample_root / "Serato").rglob("*.crate"))) == 7 + assert len(references) == 13 + assert len(tracks) == 10 + assert missing == {"Missing.mp3", "Old Name.mp3"}