Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb3d70e579 | |||
| ce37b45058 | |||
| 37786612b7 | |||
| 8ff4db4951 |
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
- [x] HTML health dashboard
|
- [x] HTML health dashboard
|
||||||
- [x] Test suite
|
- [x] Test suite
|
||||||
- [x] Sample library fixtures
|
- [x] Sample library fixtures
|
||||||
- [ ] Database V2 read-only parser
|
- [x] Database V2 read-only parser
|
||||||
- [x] Configuration
|
- [x] Configuration
|
||||||
- [x] Logging
|
- [x] Logging
|
||||||
- [x] Matching engine
|
- [x] Matching engine
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Database V2 Read-only Parser
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Crates and files do not explain every orange track in Serato. The legacy
|
||||||
|
`database V2` contains Serato's library-level track paths and metadata, so it must
|
||||||
|
be inspected independently from crate references.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The parser reads the file as a big-endian tag-length-value stream. Top-level
|
||||||
|
`otrk` records contain nested fields including `pfil` (path), `tsng` (title),
|
||||||
|
`tart` (artist), `talb` (album), and `tgen` (genre). Text is UTF-16 big-endian.
|
||||||
|
|
||||||
|
Analysis reports total database entries, entries whose filenames occur in the
|
||||||
|
selected music scan, entries outside that scan, scanned tracks absent from the
|
||||||
|
database, and duplicate database paths. “Outside scan” is deliberately not called
|
||||||
|
missing because Serato databases can include samples and tracks from other roots.
|
||||||
|
|
||||||
|
## Safety
|
||||||
|
|
||||||
|
The parser calls only `read_bytes`; it never opens the database for writing. No
|
||||||
|
metadata values or personal paths are sent to logs or the dashboard.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Synthetic TLV fixtures cover version, paths, metadata, incomplete records, and
|
||||||
|
health integration. The sample library includes a generated ten-entry database.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Plain-language Diagnostics
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
“Broken references” combined crate occurrences with Serato's own missing-track
|
||||||
|
concept. DJs naturally compared that number with orange/unmapped tracks in Serato,
|
||||||
|
even though the two counts describe different layers.
|
||||||
|
|
||||||
|
## Language
|
||||||
|
|
||||||
|
- **Missing tracks in Serato** means database entries whose saved file location no
|
||||||
|
longer exists. This corresponds most closely to orange or unmapped tracks.
|
||||||
|
- **Old crate references** means saved appearances in regular crates whose exact
|
||||||
|
filename was not found in the selected music folder. One track can appear in
|
||||||
|
several crates, so both appearances and unique filenames are shown.
|
||||||
|
|
||||||
|
Every health card and diagnostic row has an accessible information button. Hover
|
||||||
|
shows its explanation on desktop; click or tap keeps it open; Escape or clicking
|
||||||
|
elsewhere closes it. Explanations describe uncertainty and avoid implying repair.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Health tests verify database missing-path and unique-filename counts. Browser tests
|
||||||
|
cover the two separate metrics, hover/click explanations, keyboard dismissal, and
|
||||||
|
mobile layout.
|
||||||
@@ -10,6 +10,10 @@ SAMPLE_ROOT = Path(__file__).parent
|
|||||||
SERATO_PATH_PREFIX = "Users/sample-user/OneDrive/Jukebox/"
|
SERATO_PATH_PREFIX = "Users/sample-user/OneDrive/Jukebox/"
|
||||||
|
|
||||||
|
|
||||||
|
def database_record(tag: bytes, payload: bytes) -> bytes:
|
||||||
|
return tag + len(payload).to_bytes(4, "big") + payload
|
||||||
|
|
||||||
|
|
||||||
def load_manifest() -> dict:
|
def load_manifest() -> dict:
|
||||||
return json.loads((SAMPLE_ROOT / "manifest.json").read_text(encoding="utf-8"))
|
return json.loads((SAMPLE_ROOT / "manifest.json").read_text(encoding="utf-8"))
|
||||||
|
|
||||||
@@ -51,6 +55,16 @@ def build_sample(output: Optional[Path] = None) -> Path:
|
|||||||
)
|
)
|
||||||
(crate_root / output_name).write_bytes(records.encode("utf-16-le"))
|
(crate_root / output_name).write_bytes(records.encode("utf-16-le"))
|
||||||
|
|
||||||
|
database_records = [
|
||||||
|
database_record(b"vrsn", "2.0/Serato Doctor Fixture".encode("utf-16-be"))
|
||||||
|
]
|
||||||
|
for relative_path in manifest["tracks"]:
|
||||||
|
fields = database_record(
|
||||||
|
b"pfil", str(music_root / relative_path).encode("utf-16-be")
|
||||||
|
)
|
||||||
|
database_records.append(database_record(b"otrk", fields))
|
||||||
|
(serato_root / "database V2").write_bytes(b"".join(database_records))
|
||||||
|
|
||||||
return root
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import argparse
|
|||||||
|
|
||||||
from serato_doctor.config import ScanConfig
|
from serato_doctor.config import ScanConfig
|
||||||
from serato_doctor.crate_parser import load_library_crates
|
from serato_doctor.crate_parser import load_library_crates
|
||||||
|
from serato_doctor.database_parser import parse_database
|
||||||
from serato_doctor.health import analyze_health
|
from serato_doctor.health import analyze_health
|
||||||
from serato_doctor.logging import configure_logging
|
from serato_doctor.logging import configure_logging
|
||||||
from serato_doctor.models.library import Library
|
from serato_doctor.models.library import Library
|
||||||
@@ -57,10 +58,13 @@ def main():
|
|||||||
|
|
||||||
crates = load_library_crates(config.serato, config.reference_roots)
|
crates = load_library_crates(config.serato, config.reference_roots)
|
||||||
filesystem = scan_filesystem(config.music)
|
filesystem = scan_filesystem(config.music)
|
||||||
|
database_path = config.serato / "database V2"
|
||||||
|
database = parse_database(database_path) if database_path.is_file() else None
|
||||||
library = Library.from_crates(
|
library = Library.from_crates(
|
||||||
crates=crates,
|
crates=crates,
|
||||||
tracks=filesystem.tracks,
|
tracks=filesystem.tracks,
|
||||||
broken_symlinks=filesystem.broken_symlinks,
|
broken_symlinks=filesystem.broken_symlinks,
|
||||||
|
database=database,
|
||||||
)
|
)
|
||||||
results = library.reconcile_by_filename()
|
results = library.reconcile_by_filename()
|
||||||
missing_count = sum(1 for result in results if not result.exists_by_filename)
|
missing_count = sum(1 for result in results if not result.exists_by_filename)
|
||||||
@@ -99,6 +103,16 @@ def main():
|
|||||||
print(
|
print(
|
||||||
f"Dynamic References Excluded: {health.dynamic_references_excluded}"
|
f"Dynamic References Excluded: {health.dynamic_references_excluded}"
|
||||||
)
|
)
|
||||||
|
print(f"Database Entries: {health.database_entries}")
|
||||||
|
print(f"Database / Library Matches: {health.database_library_matches}")
|
||||||
|
print(f"Database Entries Outside Scan: {health.database_unmatched_entries}")
|
||||||
|
print(f"Missing Tracks in Serato: {health.database_missing_paths}")
|
||||||
|
print(
|
||||||
|
"Unique Missing Tracks in Serato: "
|
||||||
|
f"{health.database_missing_unique_filenames}"
|
||||||
|
)
|
||||||
|
print(f"Tracks Missing From Database: {health.tracks_missing_from_database}")
|
||||||
|
print(f"Duplicate Database Paths: {health.duplicate_database_paths}")
|
||||||
else:
|
else:
|
||||||
write_csv(results, config.out)
|
write_csv(results, config.out)
|
||||||
write_missing_report(results, config.report)
|
write_missing_report(results, config.report)
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Iterator, Optional, Tuple
|
||||||
|
|
||||||
|
from serato_doctor.models.database import DatabaseTrack, SeratoDatabase
|
||||||
|
|
||||||
|
|
||||||
|
TEXT_FIELDS = {
|
||||||
|
b"tsng": "title",
|
||||||
|
b"tart": "artist",
|
||||||
|
b"talb": "album",
|
||||||
|
b"tgen": "genre",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def iter_records(data: bytes) -> Iterator[Tuple[bytes, bytes]]:
|
||||||
|
"""Yield complete big-endian tag-length-value records."""
|
||||||
|
|
||||||
|
offset = 0
|
||||||
|
while offset + 8 <= len(data):
|
||||||
|
tag = data[offset : offset + 4]
|
||||||
|
length = int.from_bytes(data[offset + 4 : offset + 8], "big")
|
||||||
|
payload_start = offset + 8
|
||||||
|
payload_end = payload_start + length
|
||||||
|
if payload_end > len(data):
|
||||||
|
break
|
||||||
|
yield tag, data[payload_start:payload_end]
|
||||||
|
offset = payload_end
|
||||||
|
|
||||||
|
|
||||||
|
def decode_text(payload: bytes) -> Optional[str]:
|
||||||
|
value = payload.decode("utf-16-be", errors="ignore").strip("\x00").strip()
|
||||||
|
return value or None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_database_path(value: str) -> Path:
|
||||||
|
if value.startswith(("Users/", "Volumes/")):
|
||||||
|
value = "/" + value
|
||||||
|
return Path(value)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_track(payload: bytes) -> Optional[DatabaseTrack]:
|
||||||
|
fields: Dict[str, Optional[str]] = {}
|
||||||
|
path = None
|
||||||
|
for tag, value in iter_records(payload):
|
||||||
|
if tag == b"pfil":
|
||||||
|
decoded_path = decode_text(value)
|
||||||
|
if decoded_path:
|
||||||
|
path = normalize_database_path(decoded_path)
|
||||||
|
elif tag in TEXT_FIELDS:
|
||||||
|
fields[TEXT_FIELDS[tag]] = decode_text(value)
|
||||||
|
if path is None:
|
||||||
|
return None
|
||||||
|
return DatabaseTrack(path=path, filename=path.name, **fields)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_database(database_path: Path) -> SeratoDatabase:
|
||||||
|
data = database_path.read_bytes()
|
||||||
|
version = None
|
||||||
|
tracks = []
|
||||||
|
for tag, payload in iter_records(data):
|
||||||
|
if tag == b"vrsn":
|
||||||
|
version = decode_text(payload)
|
||||||
|
elif tag == b"otrk":
|
||||||
|
track = parse_track(payload)
|
||||||
|
if track is not None:
|
||||||
|
tracks.append(track)
|
||||||
|
return SeratoDatabase(database_path, version, tuple(tracks))
|
||||||
+31
-1
@@ -1,5 +1,7 @@
|
|||||||
|
from collections import Counter
|
||||||
|
|
||||||
from serato_doctor.duplicates import find_duplicate_groups
|
from serato_doctor.duplicates import find_duplicate_groups
|
||||||
from serato_doctor.matching import MatchingEngine
|
from serato_doctor.matching import MatchingEngine, normalize
|
||||||
from serato_doctor.models.crate import CrateKind
|
from serato_doctor.models.crate import CrateKind
|
||||||
from serato_doctor.models.duplicate import DuplicateKind
|
from serato_doctor.models.duplicate import DuplicateKind
|
||||||
from serato_doctor.models.health import HealthReport
|
from serato_doctor.models.health import HealthReport
|
||||||
@@ -44,6 +46,16 @@ def analyze_health(library: Library) -> HealthReport:
|
|||||||
bool(matcher.candidates_for(result.reference)) for result in missing
|
bool(matcher.candidates_for(result.reference)) for result in missing
|
||||||
)
|
)
|
||||||
|
|
||||||
|
database_tracks = library.database.tracks if library.database else ()
|
||||||
|
database_names = {normalize(track.filename) for track in database_tracks}
|
||||||
|
library_names = {normalize(track.filename) for track in library.tracks}
|
||||||
|
database_path_counts = Counter(
|
||||||
|
normalize(str(track.path)) for track in database_tracks
|
||||||
|
)
|
||||||
|
missing_database_tracks = [
|
||||||
|
track for track in database_tracks if not track.path.exists()
|
||||||
|
]
|
||||||
|
|
||||||
return HealthReport(
|
return HealthReport(
|
||||||
score=score,
|
score=score,
|
||||||
total_references=len(library.references),
|
total_references=len(library.references),
|
||||||
@@ -82,4 +94,22 @@ def analyze_health(library: Library) -> HealthReport:
|
|||||||
for crate in library.crates
|
for crate in library.crates
|
||||||
if crate.kind is CrateKind.SMART
|
if crate.kind is CrateKind.SMART
|
||||||
),
|
),
|
||||||
|
database_present=library.database is not None,
|
||||||
|
database_entries=len(database_tracks),
|
||||||
|
database_library_matches=sum(
|
||||||
|
normalize(track.filename) in library_names for track in database_tracks
|
||||||
|
),
|
||||||
|
database_unmatched_entries=sum(
|
||||||
|
normalize(track.filename) not in library_names for track in database_tracks
|
||||||
|
),
|
||||||
|
database_missing_paths=len(missing_database_tracks),
|
||||||
|
database_missing_unique_filenames=len(
|
||||||
|
{normalize(track.filename) for track in missing_database_tracks}
|
||||||
|
),
|
||||||
|
tracks_missing_from_database=sum(
|
||||||
|
normalize(track.filename) not in database_names for track in library.tracks
|
||||||
|
) if library.database else 0,
|
||||||
|
duplicate_database_paths=sum(
|
||||||
|
count - 1 for count in database_path_counts.values() if count > 1
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from serato_doctor.models.crate import Crate, CrateKind
|
from serato_doctor.models.crate import Crate, CrateKind
|
||||||
|
from serato_doctor.models.database import DatabaseTrack, SeratoDatabase
|
||||||
from serato_doctor.models.duplicate import DuplicateGroup, DuplicateKind
|
from serato_doctor.models.duplicate import DuplicateGroup, DuplicateKind
|
||||||
from serato_doctor.models.filesystem import BrokenSymlink, FilesystemScan
|
from serato_doctor.models.filesystem import BrokenSymlink, FilesystemScan
|
||||||
from serato_doctor.models.health import HealthReport
|
from serato_doctor.models.health import HealthReport
|
||||||
@@ -10,6 +11,7 @@ from serato_doctor.models.track import DiskTrack
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"Crate",
|
"Crate",
|
||||||
"CrateKind",
|
"CrateKind",
|
||||||
|
"DatabaseTrack",
|
||||||
"DiskTrack",
|
"DiskTrack",
|
||||||
"DuplicateGroup",
|
"DuplicateGroup",
|
||||||
"DuplicateKind",
|
"DuplicateKind",
|
||||||
@@ -19,6 +21,7 @@ __all__ = [
|
|||||||
"Library",
|
"Library",
|
||||||
"MatchEvidence",
|
"MatchEvidence",
|
||||||
"ReferenceResult",
|
"ReferenceResult",
|
||||||
|
"SeratoDatabase",
|
||||||
"TrackMatch",
|
"TrackMatch",
|
||||||
"TrackReference",
|
"TrackReference",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DatabaseTrack:
|
||||||
|
"""Read-only metadata extracted from one database V2 track record."""
|
||||||
|
|
||||||
|
path: Path
|
||||||
|
filename: str
|
||||||
|
title: Optional[str] = None
|
||||||
|
artist: Optional[str] = None
|
||||||
|
album: Optional[str] = None
|
||||||
|
genre: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SeratoDatabase:
|
||||||
|
path: Path
|
||||||
|
version: Optional[str]
|
||||||
|
tracks: Tuple[DatabaseTrack, ...]
|
||||||
@@ -25,6 +25,14 @@ class HealthReport:
|
|||||||
smart_crate_containers: int
|
smart_crate_containers: int
|
||||||
unknown_crates: int
|
unknown_crates: int
|
||||||
dynamic_references_excluded: int
|
dynamic_references_excluded: int
|
||||||
|
database_present: bool
|
||||||
|
database_entries: int
|
||||||
|
database_library_matches: int
|
||||||
|
database_unmatched_entries: int
|
||||||
|
database_missing_paths: int
|
||||||
|
database_missing_unique_filenames: int
|
||||||
|
tracks_missing_from_database: int
|
||||||
|
duplicate_database_paths: int
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def score_basis(self) -> str:
|
def score_basis(self) -> str:
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Iterable, Tuple
|
from typing import Iterable, Optional, Tuple
|
||||||
|
|
||||||
from serato_doctor.models.crate import Crate
|
from serato_doctor.models.crate import Crate
|
||||||
|
from serato_doctor.models.database import SeratoDatabase
|
||||||
from serato_doctor.models.filesystem import BrokenSymlink
|
from serato_doctor.models.filesystem import BrokenSymlink
|
||||||
from serato_doctor.models.reference import ReferenceResult, TrackReference
|
from serato_doctor.models.reference import ReferenceResult, TrackReference
|
||||||
from serato_doctor.models.track import DiskTrack
|
from serato_doctor.models.track import DiskTrack
|
||||||
@@ -15,6 +16,7 @@ class Library:
|
|||||||
tracks: Tuple[DiskTrack, ...]
|
tracks: Tuple[DiskTrack, ...]
|
||||||
crates: Tuple[Crate, ...] = ()
|
crates: Tuple[Crate, ...] = ()
|
||||||
broken_symlinks: Tuple[BrokenSymlink, ...] = ()
|
broken_symlinks: Tuple[BrokenSymlink, ...] = ()
|
||||||
|
database: Optional[SeratoDatabase] = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def build(
|
def build(
|
||||||
@@ -22,11 +24,13 @@ class Library:
|
|||||||
references: Iterable[TrackReference],
|
references: Iterable[TrackReference],
|
||||||
tracks: Iterable[DiskTrack],
|
tracks: Iterable[DiskTrack],
|
||||||
broken_symlinks: Iterable[BrokenSymlink] = (),
|
broken_symlinks: Iterable[BrokenSymlink] = (),
|
||||||
|
database: Optional[SeratoDatabase] = None,
|
||||||
) -> "Library":
|
) -> "Library":
|
||||||
return cls(
|
return cls(
|
||||||
tuple(references),
|
tuple(references),
|
||||||
tuple(tracks),
|
tuple(tracks),
|
||||||
broken_symlinks=tuple(broken_symlinks),
|
broken_symlinks=tuple(broken_symlinks),
|
||||||
|
database=database,
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -35,6 +39,7 @@ class Library:
|
|||||||
crates: Iterable[Crate],
|
crates: Iterable[Crate],
|
||||||
tracks: Iterable[DiskTrack],
|
tracks: Iterable[DiskTrack],
|
||||||
broken_symlinks: Iterable[BrokenSymlink] = (),
|
broken_symlinks: Iterable[BrokenSymlink] = (),
|
||||||
|
database: Optional[SeratoDatabase] = None,
|
||||||
) -> "Library":
|
) -> "Library":
|
||||||
crate_tuple = tuple(crates)
|
crate_tuple = tuple(crates)
|
||||||
references = tuple(
|
references = tuple(
|
||||||
@@ -47,6 +52,7 @@ class Library:
|
|||||||
tuple(tracks),
|
tuple(tracks),
|
||||||
crate_tuple,
|
crate_tuple,
|
||||||
tuple(broken_symlinks),
|
tuple(broken_symlinks),
|
||||||
|
database,
|
||||||
)
|
)
|
||||||
|
|
||||||
def reconcile_by_filename(self) -> Tuple[ReferenceResult, ...]:
|
def reconcile_by_filename(self) -> Tuple[ReferenceResult, ...]:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from pathlib import Path
|
|||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
from serato_doctor.crate_parser import load_library_crates
|
from serato_doctor.crate_parser import load_library_crates
|
||||||
|
from serato_doctor.database_parser import parse_database
|
||||||
from serato_doctor.health import analyze_health
|
from serato_doctor.health import analyze_health
|
||||||
from serato_doctor.models.library import Library
|
from serato_doctor.models.library import Library
|
||||||
from serato_doctor.scanner import scan_filesystem
|
from serato_doctor.scanner import scan_filesystem
|
||||||
@@ -33,10 +34,13 @@ def analyze_paths(
|
|||||||
|
|
||||||
crates = load_library_crates(serato, reference_roots)
|
crates = load_library_crates(serato, reference_roots)
|
||||||
filesystem = scan_filesystem(music)
|
filesystem = scan_filesystem(music)
|
||||||
|
database_path = serato / "database V2"
|
||||||
|
database = parse_database(database_path) if database_path.is_file() else None
|
||||||
library = Library.from_crates(
|
library = Library.from_crates(
|
||||||
crates,
|
crates,
|
||||||
filesystem.tracks,
|
filesystem.tracks,
|
||||||
filesystem.broken_symlinks,
|
filesystem.broken_symlinks,
|
||||||
|
database,
|
||||||
)
|
)
|
||||||
report = analyze_health(library)
|
report = analyze_health(library)
|
||||||
result = asdict(report)
|
result = asdict(report)
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -2,6 +2,7 @@ const form = document.querySelector('#analysis-form');
|
|||||||
const button = document.querySelector('#analyze-button');
|
const button = document.querySelector('#analyze-button');
|
||||||
const errorBox = document.querySelector('#error-message');
|
const errorBox = document.querySelector('#error-message');
|
||||||
const results = document.querySelector('#dashboard');
|
const results = document.querySelector('#dashboard');
|
||||||
|
const infoButtons = document.querySelectorAll('.info-button');
|
||||||
|
|
||||||
function expandHome(path) {
|
function expandHome(path) {
|
||||||
return path.trim();
|
return path.trim();
|
||||||
@@ -24,6 +25,30 @@ function render(data) {
|
|||||||
results.scrollIntoView({behavior: 'smooth', block: 'start'});
|
results.scrollIntoView({behavior: 'smooth', block: 'start'});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeInfoButtons(except = null) {
|
||||||
|
infoButtons.forEach((infoButton) => {
|
||||||
|
if (infoButton !== except) {
|
||||||
|
infoButton.classList.remove('open');
|
||||||
|
infoButton.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
infoButtons.forEach((infoButton) => {
|
||||||
|
infoButton.addEventListener('click', (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const willOpen = !infoButton.classList.contains('open');
|
||||||
|
closeInfoButtons(infoButton);
|
||||||
|
infoButton.classList.toggle('open', willOpen);
|
||||||
|
infoButton.setAttribute('aria-expanded', String(willOpen));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', () => closeInfoButtons());
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape') closeInfoButtons();
|
||||||
|
});
|
||||||
|
|
||||||
form.addEventListener('submit', async (event) => {
|
form.addEventListener('submit', async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
errorBox.hidden = true;
|
errorBox.hidden = true;
|
||||||
|
|||||||
@@ -59,24 +59,27 @@
|
|||||||
<div class="section-heading"><div><p class="eyebrow">Latest analysis</p><h2>Library health</h2></div><span id="analysis-time"></span></div>
|
<div class="section-heading"><div><p class="eyebrow">Latest analysis</p><h2>Library health</h2></div><span id="analysis-time"></span></div>
|
||||||
<div class="hero-grid">
|
<div class="hero-grid">
|
||||||
<article class="score-card panel">
|
<article class="score-card panel">
|
||||||
|
<button class="info-button" type="button" aria-label="About library health" aria-expanded="false" data-info="Your health score is the percentage of saved, non-smart crate entries whose filenames were found in the selected music folder. Smart crates are left out because Serato rebuilds them from rules.">i</button>
|
||||||
<div class="score-ring" id="score-ring"><div><strong id="health-score">—</strong><span>health</span></div></div>
|
<div class="score-ring" id="score-ring"><div><strong id="health-score">—</strong><span>health</span></div></div>
|
||||||
<div><p class="score-label">Reference integrity</p><h3 id="health-message">Ready to analyze</h3><p id="score-basis">We only score evidence we can defend.</p></div>
|
<div><p class="score-label">Reference integrity</p><h3 id="health-message">Ready to analyze</h3><p id="score-basis">We only score evidence we can defend.</p></div>
|
||||||
</article>
|
</article>
|
||||||
<div class="metrics-grid">
|
<div class="metrics-grid">
|
||||||
<article class="metric panel"><span>Tracks</span><strong data-field="disk_tracks">—</strong><small>audio files found</small></article>
|
<article class="metric panel"><button class="info-button" type="button" aria-label="About tracks" aria-expanded="false" data-info="Audio files found inside the music folder you selected. This is the collection Serato Doctor compared with your crates and database.">i</button><span>Tracks scanned</span><strong data-field="disk_tracks">—</strong><small>audio files found</small></article>
|
||||||
<article class="metric panel warning"><span>Broken references</span><strong data-field="missing_references">—</strong><small>static crate entries</small></article>
|
<article class="metric panel warning"><button class="info-button" type="button" aria-label="About missing tracks in Serato" aria-expanded="false" data-info="Tracks in Serato's database whose saved file location no longer exists. This should be close to the orange or unmapped track count you see in Serato.">i</button><span>Missing tracks in Serato</span><strong data-field="database_missing_paths">—</strong><small><b data-field="database_missing_unique_filenames">—</b> unique filenames</small></article>
|
||||||
<article class="metric panel"><span>Unused tracks</span><strong data-field="unused_tracks">—</strong><small>not referenced by crates</small></article>
|
<article class="metric panel"><button class="info-button" type="button" aria-label="About unused tracks" aria-expanded="false" data-info="Files in the selected music folder whose filename is not used by any loaded crate. They may still be valid library tracks; this is informational, not a deletion recommendation.">i</button><span>Unused tracks</span><strong data-field="unused_tracks">—</strong><small>not referenced by crates</small></article>
|
||||||
<article class="metric panel"><span>Suggested matches</span><strong data-field="suggested_matches">—</strong><small>explainable candidates</small></article>
|
<article class="metric panel"><button class="info-button" type="button" aria-label="About suggested matches" aria-expanded="false" data-info="Missing crate entries with a filename-related candidate, such as an added OneDrive conflict number. Suggestions are evidence for review, never automatic repairs.">i</button><span>Suggested matches</span><strong data-field="suggested_matches">—</strong><small>explainable candidates</small></article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="diagnostics" class="diagnostics panel">
|
<div id="diagnostics" class="diagnostics panel">
|
||||||
<div class="section-heading"><div><p class="eyebrow">Full picture</p><h2>Diagnostics</h2></div><span class="read-only-tag">No changes made</span></div>
|
<div class="section-heading"><div><p class="eyebrow">Full picture</p><h2>Diagnostics</h2></div><span class="read-only-tag">No changes made</span></div>
|
||||||
<div class="diagnostic-list">
|
<div class="diagnostic-list">
|
||||||
<div><span class="diag-icon violet">◇</span><p><strong>Crates</strong><small><b data-field="static_crates">—</b> static · <b data-field="smart_crates">—</b> smart · <b data-field="smart_crate_containers">—</b> dynamic containers · <b data-field="dynamic_references_excluded">—</b> references excluded</small></p></div>
|
<div><span class="diag-icon violet">◇</span><p><strong>Crates</strong><small><b data-field="static_crates">—</b> regular · <b data-field="smart_crates">—</b> smart · <b data-field="smart_crate_containers">—</b> dynamic containers</small></p><button class="info-button" type="button" aria-label="About crates" aria-expanded="false" data-info="Regular crates are lists you maintain by hand. Smart crates are rebuilt by Serato from rules, so their generated references are not scored as broken.">i</button></div>
|
||||||
<div><span class="diag-icon amber">≋</span><p><strong>Duplicate filenames</strong><small><b data-field="duplicate_filename_groups">—</b> exact groups · <b data-field="duplicate_files">—</b> extra files</small></p></div>
|
<div><span class="diag-icon amber">≋</span><p><strong>Duplicate filenames</strong><small><b data-field="duplicate_filename_groups">—</b> exact groups · <b data-field="duplicate_files">—</b> extra files</small></p><button class="info-button" type="button" aria-label="About duplicate filenames" aria-expanded="false" data-info="Different files with the same filename after case and Unicode cleanup. They need review, but matching names alone do not mean either file should be deleted.">i</button></div>
|
||||||
<div><span class="diag-icon blue">⌁</span><p><strong>Cloud conflicts</strong><small><b data-field="suspected_cloud_conflict_groups">—</b> suspected groups · <b data-field="suspected_cloud_conflict_files">—</b> extra files</small></p></div>
|
<div><span class="diag-icon blue">⌁</span><p><strong>Possible cloud conflicts</strong><small><b data-field="suspected_cloud_conflict_groups">—</b> groups · <b data-field="suspected_cloud_conflict_files">—</b> extra files</small></p><button class="info-button" type="button" aria-label="About cloud conflicts" aria-expanded="false" data-info="Filename families such as Track.mp3 and Track 2.mp3. OneDrive often creates these during sync conflicts, but numbered song titles can also be legitimate.">i</button></div>
|
||||||
<div><span class="diag-icon red">↗</span><p><strong>Broken symlinks</strong><small><b data-field="broken_symlinks">—</b> unresolved links</small></p></div>
|
<div><span class="diag-icon red">↗</span><p><strong>Broken shortcuts</strong><small><b data-field="broken_symlinks">—</b> unresolved symbolic links</small></p><button class="info-button" type="button" aria-label="About broken shortcuts" aria-expanded="false" data-info="Shortcut-style symbolic links whose destination no longer exists. Serato Doctor reports them but never removes or recreates them automatically.">i</button></div>
|
||||||
|
<div><span class="diag-icon violet">▦</span><p><strong>Old crate references</strong><small><b data-field="missing_references">—</b> appearances · <b data-field="unique_missing_filenames">—</b> unique filenames</small></p><button class="info-button" type="button" aria-label="About old crate references" aria-expanded="false" data-info="Saved spots in regular crates whose exact filename was not found in the selected music folder. The same track can appear in several crates, so appearances are higher than unique filenames. These are separate from Serato's unmapped-track count.">i</button></div>
|
||||||
|
<div><span class="diag-icon amber">⌕</span><p><strong>Database coverage</strong><small><b data-field="database_entries">—</b> Serato entries · <b data-field="tracks_missing_from_database">—</b> scanned tracks absent</small></p><button class="info-button" type="button" aria-label="About database coverage" aria-expanded="false" data-info="Compares filenames in Serato's database with the selected music folder. A scanned track absent from the database may not have been imported, or may be represented under another filename.">i</button></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from serato_doctor.database_parser import iter_records, parse_database
|
||||||
|
|
||||||
|
|
||||||
|
def record(tag, payload):
|
||||||
|
return tag + len(payload).to_bytes(4, "big") + payload
|
||||||
|
|
||||||
|
|
||||||
|
def text_record(tag, value):
|
||||||
|
return record(tag, value.encode("utf-16-be"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_database_reads_track_paths_and_metadata(tmp_path):
|
||||||
|
track = b"".join(
|
||||||
|
[
|
||||||
|
text_record(b"pfil", "Users/sample/Music/Track.mp3"),
|
||||||
|
text_record(b"tsng", "Track title"),
|
||||||
|
text_record(b"tart", "Test artist"),
|
||||||
|
text_record(b"talb", "Test album"),
|
||||||
|
text_record(b"tgen", "House"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
database_path = tmp_path / "database V2"
|
||||||
|
database_path.write_bytes(
|
||||||
|
text_record(b"vrsn", "2.0/Test Database") + record(b"otrk", track)
|
||||||
|
)
|
||||||
|
|
||||||
|
database = parse_database(database_path)
|
||||||
|
|
||||||
|
assert database.version == "2.0/Test Database"
|
||||||
|
assert len(database.tracks) == 1
|
||||||
|
parsed = database.tracks[0]
|
||||||
|
assert parsed.path == Path("/Users/sample/Music/Track.mp3")
|
||||||
|
assert parsed.filename == "Track.mp3"
|
||||||
|
assert parsed.title == "Track title"
|
||||||
|
assert parsed.artist == "Test artist"
|
||||||
|
assert parsed.album == "Test album"
|
||||||
|
assert parsed.genre == "House"
|
||||||
|
|
||||||
|
|
||||||
|
def test_iter_records_ignores_incomplete_trailing_record():
|
||||||
|
complete = record(b"vrsn", "2.0".encode("utf-16-be"))
|
||||||
|
incomplete = b"otrk\x00\x00\x00\x10short"
|
||||||
|
|
||||||
|
records = list(iter_records(complete + incomplete))
|
||||||
|
|
||||||
|
assert records == [(b"vrsn", "2.0".encode("utf-16-be"))]
|
||||||
@@ -58,6 +58,8 @@ def test_empty_library_has_no_health_score():
|
|||||||
assert report.score is None
|
assert report.score is None
|
||||||
assert report.total_references == 0
|
assert report.total_references == 0
|
||||||
assert report.scored_references == 0
|
assert report.scored_references == 0
|
||||||
|
assert not report.database_present
|
||||||
|
assert report.tracks_missing_from_database == 0
|
||||||
|
|
||||||
|
|
||||||
def test_smart_crate_references_are_reported_but_not_scored():
|
def test_smart_crate_references_are_reported_but_not_scored():
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ def test_web_analysis_uses_production_health_pipeline(tmp_path):
|
|||||||
assert result["missing_references"] == 2
|
assert result["missing_references"] == 2
|
||||||
assert result["static_crates"] == 5
|
assert result["static_crates"] == 5
|
||||||
assert result["smart_crates"] == 2
|
assert result["smart_crates"] == 2
|
||||||
|
assert result["database_entries"] == 10
|
||||||
|
assert result["database_library_matches"] == 10
|
||||||
|
assert result["database_missing_paths"] == 0
|
||||||
|
assert result["database_missing_unique_filenames"] == 0
|
||||||
|
assert result["tracks_missing_from_database"] == 0
|
||||||
|
|
||||||
|
|
||||||
def test_web_analysis_rejects_missing_folders(tmp_path):
|
def test_web_analysis_rejects_missing_folders(tmp_path):
|
||||||
@@ -33,3 +38,7 @@ def test_web_static_assets_are_declared_and_packaged():
|
|||||||
|
|
||||||
assert set(STATIC_FILES) == {"/", "/app.css", "/app.js"}
|
assert set(STATIC_FILES) == {"/", "/app.css", "/app.js"}
|
||||||
assert all((asset_root / filename).is_file() for filename, _ in STATIC_FILES.values())
|
assert all((asset_root / filename).is_file() for filename, _ in STATIC_FILES.values())
|
||||||
|
html = (asset_root / "index.html").read_text(encoding="utf-8")
|
||||||
|
assert "Missing tracks in Serato" in html
|
||||||
|
assert "Old crate references" in html
|
||||||
|
assert html.count('class="info-button"') >= 10
|
||||||
|
|||||||
Reference in New Issue
Block a user