diff --git a/serato_doctor/cli.py b/serato_doctor/cli.py index e69de29..8ba0026 100644 --- a/serato_doctor/cli.py +++ b/serato_doctor/cli.py @@ -0,0 +1,46 @@ +from pathlib import Path +import argparse +import csv + +from serato_doctor.crate_parser import parse_crates +from serato_doctor.scanner import scan_audio + + +def main(): + parser = argparse.ArgumentParser(prog="serato-doctor") + 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")) + args = parser.parse_args() + + serato = Path(args.serato) + music = Path(args.music) + out = Path(args.out) + + refs = parse_crates(serato / "Subcrates") + disk = scan_audio(music) + + disk_names = {t.filename for t in disk} + + 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, + }) + + with out.open("w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=["crate", "serato_path", "filename", "exists_by_filename"]) + writer.writeheader() + writer.writerows(rows) + + print(f"Crate references: {len(refs)}") + print(f"Disk tracks: {len(disk)}") + print(f"Missing by filename: {sum(1 for r in rows if not r['exists_by_filename'])}") + print(f"Wrote: {out}") + + +if __name__ == "__main__": + main() diff --git a/serato_doctor/crate_parser.py b/serato_doctor/crate_parser.py index e69de29..947a538 100644 --- a/serato_doctor/crate_parser.py +++ b/serato_doctor/crate_parser.py @@ -0,0 +1,47 @@ +from pathlib import Path +import re + +from serato_doctor.models import TrackReference + +AUDIO_EXTS = "mp3|m4a|wav|aif|aiff|flac|MP3|M4A|WAV|AIF|AIFF|FLAC" + + +def read_crate_text(crate_path: Path) -> str: + raw = crate_path.read_bytes() + for enc in ("utf-16-be", "utf-16-le", "utf-8", "latin1"): + text = raw.decode(enc, errors="ignore") + if "Users" in text or "Jukebox" in text: + return text + return raw.decode("latin1", errors="ignore") + + +def parse_crate(crate_path: Path) -> list[TrackReference]: + text = read_crate_text(crate_path) + + # Serato crate files often decode with weird spacing/null-ish characters. + # This finds paths from /Users/... through the audio extension without + # greedily scanning the entire file. + pattern = rf"/?Users/[^\r\n]+?\.(?:{AUDIO_EXTS})" + + refs = [] + for match in re.finditer(pattern, text): + raw_path = "/" + match.group(0).lstrip("/") + raw_path = raw_path.replace("\x00", "") + path = Path(raw_path) + + refs.append( + TrackReference( + source=crate_path, + path=path, + filename=path.name, + ) + ) + + return refs + + +def parse_crates(root: Path) -> list[TrackReference]: + refs = [] + for crate in root.rglob("*.crate"): + refs.extend(parse_crate(crate)) + return refs diff --git a/serato_doctor/models.py b/serato_doctor/models.py new file mode 100644 index 0000000..22466b9 --- /dev/null +++ b/serato_doctor/models.py @@ -0,0 +1,17 @@ +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class TrackReference: + source: Path + path: Path + filename: str + + +@dataclass(frozen=True) +class DiskTrack: + path: Path + filename: str + size: int + suffix: str diff --git a/serato_doctor/scanner.py b/serato_doctor/scanner.py index e69de29..d12ee0e 100644 --- a/serato_doctor/scanner.py +++ b/serato_doctor/scanner.py @@ -0,0 +1,31 @@ +from pathlib import Path + +from serato_doctor.models import DiskTrack + +AUDIO_SUFFIXES = {".mp3", ".m4a", ".wav", ".aif", ".aiff", ".flac"} + + +def scan_audio(folder: Path) -> list[DiskTrack]: + tracks = [] + + for path in folder.rglob("*"): + if not path.is_file(): + continue + if path.suffix.lower() not in AUDIO_SUFFIXES: + continue + + try: + stat = path.stat() + except OSError: + continue + + tracks.append( + DiskTrack( + path=path, + filename=path.name, + size=stat.st_size, + suffix=path.suffix.lower(), + ) + ) + + return tracks