From 09167e44c746691ee45180cf093587f52516b8db Mon Sep 17 00:00:00 2001 From: Philip Guzman Date: Tue, 30 Jun 2026 10:10:14 -0700 Subject: [PATCH] Introduce core library model --- serato_doctor/cli.py | 30 +++++++------------ serato_doctor/crate_parser.py | 13 ++++++-- serato_doctor/models/__init__.py | 6 ++++ serato_doctor/models/crate.py | 13 ++++++++ serato_doctor/models/library.py | 31 ++++++++++++++++++++ serato_doctor/models/reference.py | 27 +++++++++++++++++ serato_doctor/{models.py => models/track.py} | 9 ++---- serato_doctor/report.py | 9 ++++-- serato_doctor/scanner.py | 2 +- 9 files changed, 108 insertions(+), 32 deletions(-) create mode 100644 serato_doctor/models/__init__.py create mode 100644 serato_doctor/models/crate.py create mode 100644 serato_doctor/models/library.py create mode 100644 serato_doctor/models/reference.py rename serato_doctor/{models.py => models/track.py} (63%) diff --git a/serato_doctor/cli.py b/serato_doctor/cli.py index cd48348..1df05c5 100644 --- a/serato_doctor/cli.py +++ b/serato_doctor/cli.py @@ -2,6 +2,7 @@ from pathlib import Path import argparse from serato_doctor.crate_parser import parse_crates +from serato_doctor.models.library import Library from serato_doctor.scanner import scan_audio from serato_doctor.report import write_csv, write_missing_report @@ -19,27 +20,18 @@ def main(): out = Path(args.out) report = Path(args.report) - refs = parse_crates(serato / "Subcrates") - disk = scan_audio(music) + library = Library.build( + references=parse_crates(serato / "Subcrates"), + tracks=scan_audio(music), + ) + results = library.reconcile_by_filename() + missing_count = sum(1 for result in results if not result.exists_by_filename) - disk_names = {t.filename for t in disk} + write_csv(results, out) + write_missing_report(results, report) - rows = [] - for ref in refs: - rows.append({ - "crate": str(ref.source), - "serato_path": str(ref.path), - "filename": ref.filename, - "exists_by_filename": ref.filename in disk_names, - }) - - missing_count = sum(1 for r in rows if not r["exists_by_filename"]) - - write_csv(rows, out) - write_missing_report(rows, report) - - print(f"Crate references: {len(refs)}") - print(f"Disk tracks: {len(disk)}") + print(f"Crate references: {len(library.references)}") + print(f"Disk tracks: {len(library.tracks)}") print(f"Missing by filename: {missing_count}") print(f"CSV: {out}") print(f"Report: {report}") diff --git a/serato_doctor/crate_parser.py b/serato_doctor/crate_parser.py index 6062380..b53e23f 100644 --- a/serato_doctor/crate_parser.py +++ b/serato_doctor/crate_parser.py @@ -1,6 +1,7 @@ from pathlib import Path -from serato_doctor.models import TrackReference +from serato_doctor.models.crate import Crate +from serato_doctor.models.reference import TrackReference def read_crate_text(crate_path: Path) -> str: @@ -20,7 +21,7 @@ def clean_path(raw: str) -> str: return raw.strip() -def parse_crate(crate_path: Path) -> list[TrackReference]: +def load_crate(crate_path: Path) -> Crate: text = read_crate_text(crate_path) refs = [] @@ -48,7 +49,13 @@ def parse_crate(crate_path: Path) -> list[TrackReference]: ) ) - return refs + return Crate(path=crate_path, references=tuple(refs)) + + +def parse_crate(crate_path: Path) -> list[TrackReference]: + """Parse references from one crate, preserving the prototype API.""" + + return list(load_crate(crate_path).references) def parse_crates(root: Path) -> list[TrackReference]: diff --git a/serato_doctor/models/__init__.py b/serato_doctor/models/__init__.py new file mode 100644 index 0000000..54dc054 --- /dev/null +++ b/serato_doctor/models/__init__.py @@ -0,0 +1,6 @@ +from serato_doctor.models.crate import Crate +from serato_doctor.models.library import Library +from serato_doctor.models.reference import ReferenceResult, TrackReference +from serato_doctor.models.track import DiskTrack + +__all__ = ["Crate", "DiskTrack", "Library", "ReferenceResult", "TrackReference"] diff --git a/serato_doctor/models/crate.py b/serato_doctor/models/crate.py new file mode 100644 index 0000000..dc588e9 --- /dev/null +++ b/serato_doctor/models/crate.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Tuple + +from serato_doctor.models.reference import TrackReference + + +@dataclass(frozen=True) +class Crate: + """A Serato crate and the track references parsed from it.""" + + path: Path + references: Tuple[TrackReference, ...] diff --git a/serato_doctor/models/library.py b/serato_doctor/models/library.py new file mode 100644 index 0000000..e099dff --- /dev/null +++ b/serato_doctor/models/library.py @@ -0,0 +1,31 @@ +from dataclasses import dataclass +from typing import Iterable, Tuple + +from serato_doctor.models.reference import ReferenceResult, TrackReference +from serato_doctor.models.track import DiskTrack + + +@dataclass(frozen=True) +class Library: + """The read-only view of crate references and audio found on disk.""" + + references: Tuple[TrackReference, ...] + tracks: Tuple[DiskTrack, ...] + + @classmethod + def build( + cls, + references: Iterable[TrackReference], + tracks: Iterable[DiskTrack], + ) -> "Library": + return cls(tuple(references), tuple(tracks)) + + def reconcile_by_filename(self) -> Tuple[ReferenceResult, ...]: + disk_names = {track.filename for track in self.tracks} + return tuple( + ReferenceResult( + reference=reference, + exists_by_filename=reference.filename in disk_names, + ) + for reference in self.references + ) diff --git a/serato_doctor/models/reference.py b/serato_doctor/models/reference.py new file mode 100644 index 0000000..522bf4e --- /dev/null +++ b/serato_doctor/models/reference.py @@ -0,0 +1,27 @@ +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class TrackReference: + """A track path referenced by a Serato crate.""" + + source: Path + path: Path + filename: str + + +@dataclass(frozen=True) +class ReferenceResult: + """The filename-level reconciliation result for a crate reference.""" + + reference: TrackReference + exists_by_filename: bool + + def as_row(self) -> dict: + return { + "crate": str(self.reference.source), + "serato_path": str(self.reference.path), + "filename": self.reference.filename, + "exists_by_filename": self.exists_by_filename, + } diff --git a/serato_doctor/models.py b/serato_doctor/models/track.py similarity index 63% rename from serato_doctor/models.py rename to serato_doctor/models/track.py index 22466b9..6ce7856 100644 --- a/serato_doctor/models.py +++ b/serato_doctor/models/track.py @@ -2,15 +2,10 @@ from dataclasses import dataclass from pathlib import Path -@dataclass(frozen=True) -class TrackReference: - source: Path - path: Path - filename: str - - @dataclass(frozen=True) class DiskTrack: + """An audio file discovered on disk.""" + path: Path filename: str size: int diff --git a/serato_doctor/report.py b/serato_doctor/report.py index 171a2f1..8adc7ff 100644 --- a/serato_doctor/report.py +++ b/serato_doctor/report.py @@ -1,9 +1,13 @@ from collections import Counter, defaultdict from pathlib import Path +from typing import Iterable import csv +from serato_doctor.models.reference import ReferenceResult -def write_missing_report(rows: list[dict], out: Path) -> None: + +def write_missing_report(results: Iterable[ReferenceResult], out: Path) -> None: + rows = [result.as_row() for result in results] missing = [r for r in rows if not r["exists_by_filename"]] crate_counts = Counter(r["crate"] for r in missing) @@ -32,7 +36,8 @@ def write_missing_report(rows: list[dict], out: Path) -> None: f.write(f"- {name}\n") -def write_csv(rows: list[dict], out: Path) -> None: +def write_csv(results: Iterable[ReferenceResult], out: Path) -> None: + rows = [result.as_row() for result in results] with out.open("w", newline="", encoding="utf-8") as f: writer = csv.DictWriter( f, diff --git a/serato_doctor/scanner.py b/serato_doctor/scanner.py index d12ee0e..319aa7e 100644 --- a/serato_doctor/scanner.py +++ b/serato_doctor/scanner.py @@ -1,6 +1,6 @@ from pathlib import Path -from serato_doctor.models import DiskTrack +from serato_doctor.models.track import DiskTrack AUDIO_SUFFIXES = {".mp3", ".m4a", ".wav", ".aif", ".aiff", ".flac"}