diff --git a/docs/design/crate-classification.md b/docs/design/crate-classification.md index e8f0d38..d6446a0 100644 --- a/docs/design/crate-classification.md +++ b/docs/design/crate-classification.md @@ -9,10 +9,15 @@ broken manual references or given the same health-score weight. ## Architecture Crates carry a `static`, `smart`, or `unknown` kind. Folder provenance is the -primary signal: Serato stores regular definitions in `Subcrates` and smart -definitions in `Smartcrates`. `Compatible by key.crate` is also treated as smart +primary signal: Serato stores regular `.crate` files in `Subcrates` and 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. +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 scores only non-smart references. Unknown crates remain scoreable so incomplete classification cannot silently hide potential problems. diff --git a/docs/design/smart-crate-discovery.md b/docs/design/smart-crate-discovery.md new file mode 100644 index 0000000..ed553ba --- /dev/null +++ b/docs/design/smart-crate-discovery.md @@ -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. diff --git a/samples/small-library/generate.py b/samples/small-library/generate.py index 84fa290..ae13268 100644 --- a/samples/small-library/generate.py +++ b/samples/small-library/generate.py @@ -28,18 +28,28 @@ def build_sample(output: Optional[Path] = None) -> Path: ) 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.mkdir(parents=True, exist_ok=True) - other_folder = "Subcrates" if folder_name == "Smartcrates" else "Smartcrates" - stale_path = serato_root / other_folder / crate["name"] - if stale_path.exists(): - stale_path.unlink() + output_name = ( + Path(crate["name"]).with_suffix(".scrate").name + 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() 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")) + (crate_root / output_name).write_bytes(records.encode("utf-16-le")) return root diff --git a/serato_doctor/cli.py b/serato_doctor/cli.py index 0ccdd35..435f37f 100644 --- a/serato_doctor/cli.py +++ b/serato_doctor/cli.py @@ -95,6 +95,7 @@ def main(): print(f"Broken Symlinks: {health.broken_symlinks}") print(f"Static Crates: {health.static_crates}") print(f"Smart Crates: {health.smart_crates}") + print(f"Smart Crate Containers: {health.smart_crate_containers}") print( f"Dynamic References Excluded: {health.dynamic_references_excluded}" ) diff --git a/serato_doctor/crate_parser.py b/serato_doctor/crate_parser.py index c8327a7..be0abda 100644 --- a/serato_doctor/crate_parser.py +++ b/serato_doctor/crate_parser.py @@ -103,9 +103,19 @@ def load_library_crates( serato_root: Path, reference_roots: Iterable[Path] = () ) -> Tuple[Crate, ...]: reference_roots = tuple(reference_roots) + if not serato_root.is_dir(): + return () + crates = [] - for folder_name in ("Subcrates", "Smartcrates"): - folder = serato_root / folder_name - for crate_path in folder.rglob("*.crate"): - crates.append(load_crate(crate_path, reference_roots)) + folder_patterns = { + "subcrates": ("*.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)) return tuple(sorted(crates, key=lambda crate: str(crate.path))) diff --git a/serato_doctor/health.py b/serato_doctor/health.py index c1cca6f..2cf4169 100644 --- a/serato_doctor/health.py +++ b/serato_doctor/health.py @@ -67,7 +67,12 @@ def analyze_health(library: Library) -> HealthReport: crate.kind is CrateKind.STATIC for crate in library.crates ), 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( crate.kind is CrateKind.UNKNOWN for crate in library.crates diff --git a/serato_doctor/models/crate.py b/serato_doctor/models/crate.py index 58a4789..f0906a3 100644 --- a/serato_doctor/models/crate.py +++ b/serato_doctor/models/crate.py @@ -19,3 +19,15 @@ class Crate: path: Path references: Tuple[TrackReference, ...] 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" diff --git a/serato_doctor/models/health.py b/serato_doctor/models/health.py index 8d0bed5..00fa3ab 100644 --- a/serato_doctor/models/health.py +++ b/serato_doctor/models/health.py @@ -22,6 +22,7 @@ class HealthReport: broken_symlinks: int static_crates: int smart_crates: int + smart_crate_containers: int unknown_crates: int dynamic_references_excluded: int diff --git a/serato_doctor/webui/index.html b/serato_doctor/webui/index.html index 8c69585..96df962 100644 --- a/serato_doctor/webui/index.html +++ b/serato_doctor/webui/index.html @@ -73,7 +73,7 @@

Full picture

Diagnostics

No changes made
-

Crates static · smart · dynamic references excluded

+

Crates static · smart · dynamic containers · references excluded

Duplicate filenames exact groups · extra files

Cloud conflicts suspected groups · extra files

Broken symlinks unresolved links

diff --git a/tests/test_crates.py b/tests/test_crates.py index 0c7b046..12441ed 100644 --- a/tests/test_crates.py +++ b/tests/test_crates.py @@ -6,6 +6,7 @@ from serato_doctor.crate_parser import ( classify_crate, clean_path, load_crate, + load_library_crates, parse_crate, parse_crates, path_markers, @@ -92,7 +93,7 @@ def test_parse_crates_reuses_configured_roots_for_every_crate(tmp_path): ("relative_path", "expected"), [ ("_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), ("fixtures/Unknown.crate", CrateKind.UNKNOWN), ], @@ -101,3 +102,35 @@ def test_classify_crate_uses_provenance_and_known_dynamic_name( tmp_path, relative_path, 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), + ] diff --git a/tests/test_health.py b/tests/test_health.py index 2ebf242..28ea77e 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -63,14 +63,14 @@ def test_empty_library_has_no_health_score(): def test_smart_crate_references_are_reported_but_not_scored(): static_reference = reference("Found.mp3") smart_reference = TrackReference( - Path("Smartcrates/Dynamic.crate"), + Path("SmartCrates/Dynamic.scrate"), Path("/old/House/Dynamic.mp3"), "Dynamic.mp3", ) crates = [ Crate(Path("Subcrates/Static.crate"), (static_reference,), CrateKind.STATIC), Crate( - Path("Smartcrates/Dynamic.crate"), + Path("SmartCrates/Dynamic.scrate"), (smart_reference,), CrateKind.SMART, ), @@ -85,6 +85,7 @@ def test_smart_crate_references_are_reported_but_not_scored(): assert report.missing_references == 0 assert report.static_crates == 1 assert report.smart_crates == 1 + assert report.smart_crate_containers == 0 assert report.dynamic_references_excluded == 1 diff --git a/tests/test_sample_library.py b/tests/test_sample_library.py index d2b583c..58c3cb8 100644 --- a/tests/test_sample_library.py +++ b/tests/test_sample_library.py @@ -24,7 +24,9 @@ def test_generated_sample_library_has_expected_scenario(tmp_path): 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(tracks) == 10 assert missing == {"Missing.mp3", "Old Name.mp3"}