122 lines
3.7 KiB
Python
122 lines
3.7 KiB
Python
from pathlib import Path
|
|
from typing import Iterable, Tuple
|
|
|
|
from serato_doctor.models.crate import Crate, CrateKind
|
|
from serato_doctor.models.reference import TrackReference
|
|
|
|
|
|
def read_crate_text(crate_path: Path) -> str:
|
|
return crate_path.read_bytes().decode("utf-16-le", errors="ignore").replace("\x00", "")
|
|
|
|
|
|
def clean_path(raw: str) -> str:
|
|
# Common Serato decode artifacts where final extension char gets merged.
|
|
raw = raw.replace(".mp漳", ".mp3")
|
|
raw = raw.replace(".MP漳", ".MP3")
|
|
raw = raw.replace(".m4愠", ".m4a")
|
|
raw = raw.replace(".M4愠", ".M4A")
|
|
raw = raw.replace(".wa瘠", ".wav")
|
|
raw = raw.replace(".WA瘠", ".WAV")
|
|
raw = raw.replace(".ai映", ".aif")
|
|
raw = raw.replace(".AI映", ".AIF")
|
|
return raw.strip()
|
|
|
|
|
|
DEFAULT_PATH_MARKERS = ("Users/", "Volumes/")
|
|
|
|
|
|
def path_markers(reference_roots: Iterable[Path]) -> Tuple[str, ...]:
|
|
configured = tuple(
|
|
root.expanduser().as_posix().strip("/") + "/" for root in reference_roots
|
|
)
|
|
return configured or DEFAULT_PATH_MARKERS
|
|
|
|
|
|
def classify_crate(crate_path: Path) -> CrateKind:
|
|
parent_names = {parent.name.casefold() for parent in crate_path.parents}
|
|
if "smartcrates" in parent_names:
|
|
return CrateKind.SMART
|
|
if crate_path.name.casefold() == "compatible by key.crate":
|
|
return CrateKind.SMART
|
|
if "subcrates" in parent_names:
|
|
return CrateKind.STATIC
|
|
return CrateKind.UNKNOWN
|
|
|
|
|
|
def load_crate(crate_path: Path, reference_roots: Iterable[Path] = ()) -> Crate:
|
|
text = read_crate_text(crate_path)
|
|
refs = []
|
|
|
|
# Serato record markers seen after paths in UTF-16-LE decoded crate data.
|
|
stop_markers = ["牴k", "otrk", "ptrk", "tvcn", "ovct"]
|
|
|
|
for marker in path_markers(reference_roots):
|
|
for part in text.split(marker)[1:]:
|
|
candidate = marker + part
|
|
|
|
stops = [
|
|
candidate.find(stop)
|
|
for stop in stop_markers
|
|
if candidate.find(stop) != -1
|
|
]
|
|
if not stops:
|
|
continue
|
|
|
|
raw_path = "/" + candidate[: min(stops)]
|
|
raw_path = clean_path(raw_path)
|
|
|
|
path = Path(raw_path)
|
|
refs.append(
|
|
TrackReference(
|
|
source=crate_path,
|
|
path=path,
|
|
filename=path.name,
|
|
)
|
|
)
|
|
|
|
return Crate(
|
|
path=crate_path,
|
|
references=tuple(refs),
|
|
kind=classify_crate(crate_path),
|
|
)
|
|
|
|
|
|
def parse_crate(
|
|
crate_path: Path, reference_roots: Iterable[Path] = ()
|
|
) -> list[TrackReference]:
|
|
"""Parse references from one crate, preserving the prototype API."""
|
|
|
|
return list(load_crate(crate_path, reference_roots).references)
|
|
|
|
|
|
def parse_crates(
|
|
root: Path, reference_roots: Iterable[Path] = ()
|
|
) -> list[TrackReference]:
|
|
refs = []
|
|
reference_roots = tuple(reference_roots)
|
|
for crate in root.rglob("*.crate"):
|
|
refs.extend(parse_crate(crate, reference_roots))
|
|
return refs
|
|
|
|
|
|
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 = []
|
|
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)))
|