Compare commits

..

7 Commits

Author SHA1 Message Date
Philip Guzman 22c4f58646 Add defensible orphaned audio detection 2026-07-02 16:17:11 -07:00
Philip Guzman 4d04939846 Add duplicate audio hash detection 2026-07-01 16:36:21 -07:00
Philip Guzman ec646479d2 Show detailed batch repair preview 2026-07-01 16:26:04 -07:00
Philip Guzman f3cfed316d Add batch duplicate review workflow 2026-07-01 16:18:30 -07:00
Philip Guzman 4315d8d4d4 Add duplicate audio comparison controls 2026-07-01 16:12:35 -07:00
Philip Guzman 139822f8a5 Fix recovery layout and interactive assets 2026-07-01 16:03:28 -07:00
Philip Guzman da72419ce9 Add backup recovery center 2026-07-01 15:34:58 -07:00
19 changed files with 1421 additions and 57 deletions
+6 -6
View File
@@ -19,21 +19,21 @@
- [x] `serato-doctor analyze` command - [x] `serato-doctor analyze` command
- [x] Duplicate filename detection - [x] Duplicate filename detection
- [ ] Duplicate audio hash detection - [x] Duplicate audio hash detection
- [x] Broken symlink detection - [x] Broken symlink detection
- [ ] Orphaned audio detection - [x] Orphaned audio detection
- [ ] OneDrive rename detection - [ ] OneDrive rename detection
- [x] Crate classification: static vs smart/dynamic - [x] Crate classification: static vs smart/dynamic
- [x] Library health score - [x] Library health score
## v0.3 — Safe Repair ## v0.3 — Safe Repair
- [ ] Dry-run repair plan - [x] Dry-run repair plan
- [ ] Backup before repair - [x] Backup before repair
- [ ] Compatibility symlink creation - [x] Compatibility symlink creation
- [ ] Compatibility copy creation - [ ] Compatibility copy creation
- [ ] Rename repair - [ ] Rename repair
- [ ] Rollback log - [x] Rollback log and recovery center
## v0.4 — Migration Wizard ## v0.4 — Migration Wizard
+32
View File
@@ -0,0 +1,32 @@
# Audio comparison for duplicate review
## Problem
Filenames alone are weak evidence. DJs need to hear each candidate and locate it
on disk before deciding which copy is authoritative.
## Design
Exact-duplicate and cloud-conflict groups expose a shared audio player with a
preview action for each file. Switching files reuses the same player, making
back-to-back comparison quick. A separate action reveals the selected file in
macOS Finder.
The browser never receives unrestricted filesystem access. Each analyzed audio
path gets a short, process-local HMAC token. Preview and Finder endpoints reject
altered, expired, missing, non-audio, or otherwise unsigned paths. Audio serving
supports HTTP byte ranges so playback can seek without loading an entire track.
## Edge cases
- A file moved after analysis is rejected.
- Forged or stale tokens cannot select another local file.
- Preview controls remain useful if autoplay is blocked because native audio
controls stay visible.
- Finder failures are reported beside the button without affecting analysis.
## Tests
- Valid signed audio resolves to the analyzed file.
- A modified token is rejected.
- Duplicate detail payloads include preview and Finder controls for every file.
+30
View File
@@ -0,0 +1,30 @@
# Backup recovery center
## Problem
An immediate undo button is not enough. A DJ may restart Serato Doctor, notice
an issue later, or need to understand how much disk space safety snapshots use.
## Architecture
The recovery center reads the existing JSON manifests beneath
`_Serato_/.serato-doctor-backups/`. It reports creation time, selected keeper,
affected paths, restore status, and snapshot size. Invalid or incomplete backup
folders are ignored rather than presented as restorable.
Restore remains conservative: it only replaces a symbolic-link alias and
refuses to overwrite a real file. A successful restore records its timestamp in
the manifest so the UI cannot accidentally offer the same rollback twice.
## Edge cases
- No backup directory is treated as an empty history.
- Malformed manifests do not prevent valid backups from loading.
- Restored snapshots remain visible for audit purposes and disk accounting.
- Backups can only be restored through the Serato library that owns them.
## Tests
- Backup history reports size and ready status.
- Restore changes the persistent status to restored.
- Static recovery assets are included in the package.
+37
View File
@@ -0,0 +1,37 @@
# Batch duplicate review
## Problem
Opening a diagnostic, opening each group again, scrolling to a separate repair
form, and applying one change at a time creates unnecessary friction and many
small backups.
## Design
Duplicate and cloud-conflict diagnostics now open as a sequential review queue.
Each group presents its candidates together with audio preview and Finder
controls. Choosing a keeper records the decision and advances immediately.
Uncertain groups can be skipped without changing them.
All approved decisions are submitted as one dry-run plan. Apply creates one
backup containing every affected audio file plus the Serato metadata snapshot,
then installs all compatibility shortcuts as one rollback unit. Any validation
or filesystem failure prevents or rolls back the batch.
The dry-run opens in a closable modal that names every selected keeper and every
path that will become a compatibility shortcut. Closing the modal preserves the
queue position and all decisions so the DJ can continue reviewing before apply.
## Edge cases
- The same group cannot be submitted twice.
- A file cannot be both a keeper and a disposable file across choices.
- Every group is rescanned and revalidated before preview and apply.
- Skipped and unreviewed groups remain untouched.
- Revisiting a group preserves and visibly marks its selected keeper.
## Tests
- Two groups produce one backup and one restorable receipt.
- Batch preview reports combined changes without modifying files.
- Existing single-group repair remains a wrapper around the batch engine.
+34
View File
@@ -0,0 +1,34 @@
# Duplicate audio hashing
## Problem
Matching filenames do not prove matching content. Two files can be different DJ
edits, masters, encodes, or entirely different tracks. Removing either without
stronger evidence is unsafe.
## Design
Serato Doctor computes SHA-256 only for the duplicate group currently being
reviewed. This avoids hashing an entire 22,000-track library during every scan.
The review queue labels a group as byte-for-byte identical or warns that its
files differ. File size and a short fingerprint remain available as supporting
detail.
The final batch dry-run recomputes every approved comparison server-side and
includes the status beside each keeper decision. The hash is evidence, not an
automatic repair decision; the DJ remains in control.
## Edge cases
- Identical audio stored under different filenames is recognized.
- Metadata changes inside an audio container produce a different byte hash and
therefore a conservative warning.
- Missing or unreadable files fail verification rather than being treated as
identical.
- Hashing is streamed in chunks rather than loading whole tracks into memory.
## Tests
- Equal byte content produces identical SHA-256 fingerprints.
- Different content is labeled different.
- Batch previews carry the server-computed confidence status.
+34
View File
@@ -0,0 +1,34 @@
# Orphaned audio detection
## Problem
“Not in a crate” does not mean unused. Many DJs keep active tracks in Serato's
main library without placing them in a crate. Treating those files as orphans
would create dangerous deletion pressure.
## Design
Serato Doctor now separates two read-only findings:
- **Not in crates**: scanned tracks whose normalized filename is absent from all
loaded crate references. These may still be ordinary Serato library tracks.
- **Possible orphan files**: tracks absent by normalized filename from both
loaded crates and database V2.
When database V2 is unavailable, orphan status is reported as not assessed—not
zero. Orphan candidates include preview and Finder controls but no delete or
repair action.
## Edge cases
- Filename comparison is case- and Unicode-normalized.
- A track in several folders may still require duplicate review separately.
- Smart-crate materialized references count as crate evidence.
- Missing database V2 prevents a defensible orphan conclusion.
## Tests
- Tracks known only to database V2 are excluded from orphan candidates.
- Tracks in crates are excluded even if absent from database V2.
- A track absent from both sources is counted once.
- No database produces a not-assessed result.
+33
View File
@@ -0,0 +1,33 @@
"""Targeted content hashing for duplicate candidates."""
import hashlib
from pathlib import Path
from typing import Iterable
def sha256_file(path: Path, chunk_size: int = 1024 * 1024) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(chunk_size), b""):
digest.update(chunk)
return digest.hexdigest()
def compare_audio_files(paths: Iterable[Path]) -> dict:
paths = tuple(paths)
if len(paths) < 2:
raise ValueError("At least two files are required for comparison")
fingerprints = []
for path in paths:
fingerprints.append(
{
"path": str(path),
"size": path.stat().st_size,
"sha256": sha256_file(path),
}
)
identical = len({item["sha256"] for item in fingerprints}) == 1
return {
"status": "identical" if identical else "different",
"files": fingerprints,
}
+15 -2
View File
@@ -36,9 +36,13 @@ def analyze_health(library: Library) -> HealthReport:
for group in duplicate_groups for group in duplicate_groups
if group.kind is DuplicateKind.CLOUD_CONFLICT if group.kind is DuplicateKind.CLOUD_CONFLICT
] ]
referenced_names = {reference.filename for reference in library.references} referenced_names = {
normalize(reference.filename) for reference in library.references
}
unused_count = sum( unused_count = sum(
1 for track in library.tracks if track.filename not in referenced_names 1
for track in library.tracks
if normalize(track.filename) not in referenced_names
) )
matcher = MatchingEngine(library.tracks) matcher = MatchingEngine(library.tracks)
@@ -73,6 +77,15 @@ def analyze_health(library: Library) -> HealthReport:
group.extra_files for group in cloud_conflicts group.extra_files for group in cloud_conflicts
), ),
unused_tracks=unused_count, unused_tracks=unused_count,
orphan_candidates=(
sum(
normalize(track.filename) not in referenced_names
and normalize(track.filename) not in database_names
for track in library.tracks
)
if library.database
else None
),
suggested_matches=suggested_count, suggested_matches=suggested_count,
broken_symlinks=len(library.broken_symlinks), broken_symlinks=len(library.broken_symlinks),
static_crates=sum( static_crates=sum(
+1
View File
@@ -18,6 +18,7 @@ class HealthReport:
suspected_cloud_conflict_groups: int suspected_cloud_conflict_groups: int
suspected_cloud_conflict_files: int suspected_cloud_conflict_files: int
unused_tracks: int unused_tracks: int
orphan_candidates: Optional[int]
suggested_matches: int suggested_matches: int
broken_symlinks: int broken_symlinks: int
static_crates: int static_crates: int
+88 -6
View File
@@ -34,6 +34,20 @@ class RepairReceipt:
replaced: Tuple[Path, ...] replaced: Tuple[Path, ...]
@dataclass(frozen=True)
class BackupSummary:
path: Path
created_at: str
keeper: Path
replaced: Tuple[Path, ...]
size_bytes: int
restored_at: Optional[str]
@property
def status(self) -> str:
return "restored" if self.restored_at else "ready"
def plan_duplicate_repair( def plan_duplicate_repair(
keeper: Path, duplicates: Iterable[Path], serato_root: Path keeper: Path, duplicates: Iterable[Path], serato_root: Path
) -> DuplicateRepairPlan: ) -> DuplicateRepairPlan:
@@ -69,8 +83,28 @@ def apply_duplicate_repair(
backup_limit: Optional[int] = 10, backup_limit: Optional[int] = 10,
) -> RepairReceipt: ) -> RepairReceipt:
"""Create a complete rollback snapshot, then replace extras with symlinks.""" """Create a complete rollback snapshot, then replace extras with symlinks."""
return apply_duplicate_repair_batch((plan,), serato_root, backup_limit)
def apply_duplicate_repair_batch(
plans: Iterable[DuplicateRepairPlan],
serato_root: Path,
backup_limit: Optional[int] = 10,
) -> RepairReceipt:
"""Apply several approved duplicate choices as one atomic backup."""
plans = tuple(plans)
if not plans:
raise ValueError("Choose at least one duplicate group")
if backup_limit is not None and backup_limit < 1: if backup_limit is not None and backup_limit < 1:
raise ValueError("Backup limit must be at least 1, or unlimited") raise ValueError("Backup limit must be at least 1, or unlimited")
replaced_paths = tuple(
path for plan in plans for path in plan.replaced
)
if len(set(replaced_paths)) != len(replaced_paths):
raise ValueError("The same file appears in more than one repair choice")
keepers = {plan.keeper for plan in plans}
if keepers.intersection(replaced_paths):
raise ValueError("A selected keeper cannot be removed by another choice")
backup_root = serato_root.expanduser().resolve() / BACKUP_FOLDER backup_root = serato_root.expanduser().resolve() / BACKUP_FOLDER
backup = backup_root / _backup_name() backup = backup_root / _backup_name()
files_root = backup / "files" files_root = backup / "files"
@@ -79,35 +113,45 @@ def apply_duplicate_repair(
entries = [] entries = []
try: try:
for plan in plans:
for path in plan.replaced: for path in plan.replaced:
destination = files_root / _safe_backup_path(path) destination = files_root / _safe_backup_path(path)
destination.parent.mkdir(parents=True, exist_ok=True) destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, destination) shutil.copy2(path, destination)
entries.append({"original": str(path), "backup": str(destination)}) entries.append(
for path in plan.metadata_files: {
"original": str(path),
"backup": str(destination),
"keeper": str(plan.keeper),
}
)
for path in plans[0].metadata_files:
relative = path.relative_to(serato_root.expanduser().resolve()) relative = path.relative_to(serato_root.expanduser().resolve())
destination = metadata_root / relative destination = metadata_root / relative
destination.parent.mkdir(parents=True, exist_ok=True) destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, destination) shutil.copy2(path, destination)
manifest = { manifest = {
"created_at": datetime.now(timezone.utc).isoformat(), "created_at": datetime.now(timezone.utc).isoformat(),
"keeper": str(plan.keeper), "keeper": str(plans[0].keeper),
"keepers": [str(plan.keeper) for plan in plans],
"choice_count": len(plans),
"replaced": entries, "replaced": entries,
"strategy": "symlink", "strategy": "symlink",
} }
(backup / "manifest.json").write_text( (backup / "manifest.json").write_text(
json.dumps(manifest, indent=2), encoding="utf-8" json.dumps(manifest, indent=2), encoding="utf-8"
) )
for path in plan.replaced: for entry in entries:
path = Path(entry["original"])
path.unlink() path.unlink()
path.symlink_to(plan.keeper) path.symlink_to(Path(entry["keeper"]))
except Exception: except Exception:
_rollback_entries(entries) _rollback_entries(entries)
shutil.rmtree(backup, ignore_errors=True) shutil.rmtree(backup, ignore_errors=True)
raise raise
rotate_backups(backup_root, backup_limit) rotate_backups(backup_root, backup_limit)
return RepairReceipt(backup, plan.keeper, plan.replaced) return RepairReceipt(backup, plans[0].keeper, replaced_paths)
def restore_backup(backup: Path) -> Tuple[Path, ...]: def restore_backup(backup: Path) -> Tuple[Path, ...]:
@@ -124,9 +168,47 @@ def restore_backup(backup: Path) -> Tuple[Path, ...]:
original.parent.mkdir(parents=True, exist_ok=True) original.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(saved, original) shutil.copy2(saved, original)
restored.append(original) restored.append(original)
manifest["restored_at"] = datetime.now(timezone.utc).isoformat()
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
return tuple(restored) return tuple(restored)
def list_backups(serato_root: Path) -> Tuple[BackupSummary, ...]:
backup_root = serato_root.expanduser().resolve() / BACKUP_FOLDER
if not backup_root.is_dir():
return ()
summaries = []
for backup in backup_root.iterdir():
manifest_path = backup / "manifest.json"
if not manifest_path.is_file():
continue
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
replaced = tuple(
Path(entry["original"]) for entry in manifest["replaced"]
)
size = sum(
path.stat().st_size
for path in backup.rglob("*")
if path.is_file()
)
summaries.append(
BackupSummary(
path=backup.resolve(),
created_at=manifest["created_at"],
keeper=Path(manifest["keeper"]),
replaced=replaced,
size_bytes=size,
restored_at=manifest.get("restored_at"),
)
)
except (KeyError, OSError, TypeError, json.JSONDecodeError):
continue
return tuple(
sorted(summaries, key=lambda item: item.created_at, reverse=True)
)
def rotate_backups(backup_root: Path, limit: Optional[int]) -> None: def rotate_backups(backup_root: Path, limit: Optional[int]) -> None:
if limit is None or not backup_root.is_dir(): if limit is None or not backup_root.is_dir():
return return
+258 -6
View File
@@ -1,16 +1,24 @@
import argparse import argparse
import base64
import binascii
import hmac
import json import json
import mimetypes
import secrets
import subprocess
from collections import Counter from collections import Counter
from dataclasses import asdict from dataclasses import asdict
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from importlib import resources from importlib import resources
from pathlib import Path from pathlib import Path
from typing import Iterable, Optional from typing import Iterable, Optional
from urllib.parse import parse_qs, quote, urlsplit
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.database_parser import parse_database
from serato_doctor.duplicates import find_duplicate_groups from serato_doctor.duplicates import find_duplicate_groups
from serato_doctor.health import analyze_health from serato_doctor.health import analyze_health
from serato_doctor.hashing import compare_audio_files
from serato_doctor.matching import MatchingEngine, normalize 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
@@ -19,6 +27,8 @@ from serato_doctor.scanner import scan_filesystem
from serato_doctor.repair import ( from serato_doctor.repair import (
BACKUP_FOLDER, BACKUP_FOLDER,
apply_duplicate_repair, apply_duplicate_repair,
apply_duplicate_repair_batch,
list_backups,
plan_duplicate_repair, plan_duplicate_repair,
restore_backup, restore_backup,
) )
@@ -26,9 +36,12 @@ from serato_doctor.repair import (
MAX_REQUEST_BYTES = 64 * 1024 MAX_REQUEST_BYTES = 64 * 1024
DETAIL_LIMIT = 50 DETAIL_LIMIT = 50
FILE_TOKEN_SECRET = secrets.token_bytes(32)
STATIC_FILES = { STATIC_FILES = {
"/": ("index.html", "text/html; charset=utf-8"), "/": ("index.html", "text/html; charset=utf-8"),
"/app.css": ("app.css", "text/css; charset=utf-8"), "/app.css": ("app.css", "text/css; charset=utf-8"),
"/recovery.css": ("recovery.css", "text/css; charset=utf-8"),
"/layout-fixes.css": ("layout-fixes.css", "text/css; charset=utf-8"),
"/app.js": ("app.js", "text/javascript; charset=utf-8"), "/app.js": ("app.js", "text/javascript; charset=utf-8"),
} }
@@ -37,6 +50,44 @@ def _display_path(path: Path) -> str:
return str(path) return str(path)
def _file_token(path: Path) -> str:
encoded = base64.urlsafe_b64encode(str(path.resolve()).encode()).decode()
signature = hmac.digest(FILE_TOKEN_SECRET, encoded.encode(), "sha256").hex()
return f"{encoded}.{signature}"
def _verified_audio(token: str) -> Path:
try:
encoded, signature = token.rsplit(".", 1)
expected = hmac.digest(
FILE_TOKEN_SECRET, encoded.encode(), "sha256"
).hex()
if not hmac.compare_digest(signature, expected):
raise ValueError
path = Path(base64.urlsafe_b64decode(encoded.encode()).decode())
except (binascii.Error, ValueError, UnicodeDecodeError):
raise ValueError("Invalid or expired file preview")
if not path.is_file() or path.suffix.casefold() not in {
".mp3",
".m4a",
".wav",
".aif",
".aiff",
".flac",
}:
raise ValueError("Audio file is no longer available")
return path
def _file_preview(path: Path) -> dict:
token = _file_token(path)
return {
"path": _display_path(path),
"audio_url": f"/api/audio?token={quote(token)}",
"reveal_token": token,
}
def _first_reason(match) -> str: def _first_reason(match) -> str:
for item in match.evidence: for item in match.evidence:
if item.matched: if item.matched:
@@ -92,10 +143,24 @@ def diagnostic_details(library: Library, limit: int = DETAIL_LIMIT) -> dict:
database_filename_counts = Counter( database_filename_counts = Counter(
normalize(track.filename) for track in missing_database_tracks normalize(track.filename) for track in missing_database_tracks
) )
referenced_names = {reference.filename for reference in library.references} referenced_names = {
normalize(reference.filename) for reference in library.references
}
database_names = {normalize(track.filename) for track in database_tracks}
unused_tracks = [ unused_tracks = [
track for track in library.tracks if track.filename not in referenced_names track
for track in library.tracks
if normalize(track.filename) not in referenced_names
] ]
orphan_candidates = (
[
track
for track in unused_tracks
if normalize(track.filename) not in database_names
]
if library.database
else None
)
return { return {
"database_missing_tracks": { "database_missing_tracks": {
@@ -155,6 +220,9 @@ def diagnostic_details(library: Library, limit: int = DETAIL_LIMIT) -> dict:
{ {
"filename": group.display_name, "filename": group.display_name,
"files": [_display_path(track.path) for track in group.tracks], "files": [_display_path(track.path) for track in group.tracks],
"file_previews": [
_file_preview(track.path) for track in group.tracks
],
} }
for group in exact_duplicates[:limit] for group in exact_duplicates[:limit]
], ],
@@ -170,6 +238,9 @@ def diagnostic_details(library: Library, limit: int = DETAIL_LIMIT) -> dict:
{ {
"filename": group.display_name, "filename": group.display_name,
"files": [_display_path(track.path) for track in group.tracks], "files": [_display_path(track.path) for track in group.tracks],
"file_previews": [
_file_preview(track.path) for track in group.tracks
],
} }
for group in cloud_conflicts[:limit] for group in cloud_conflicts[:limit]
], ],
@@ -189,10 +260,10 @@ def diagnostic_details(library: Library, limit: int = DETAIL_LIMIT) -> dict:
], ],
}, },
"unused_tracks": { "unused_tracks": {
"title": "Unused tracks", "title": "Not in crates",
"summary": ( "summary": (
"These scanned files were not referenced by any loaded crate. " "These files are not in any loaded crate, but may still be "
"That does not mean they should be deleted." "normal tracks in Serato's main library."
), ),
"total": len(unused_tracks), "total": len(unused_tracks),
"items": [ "items": [
@@ -200,6 +271,24 @@ def diagnostic_details(library: Library, limit: int = DETAIL_LIMIT) -> dict:
for track in unused_tracks[:limit] for track in unused_tracks[:limit]
], ],
}, },
"orphan_candidates": {
"title": "Possible orphan files",
"summary": (
"These files were found in the music folder but not by filename "
"in loaded crates or Serato's database. Review only; this is "
"never an automatic deletion recommendation."
),
"total": len(orphan_candidates) if orphan_candidates is not None else None,
"assessed": orphan_candidates is not None,
"items": [
{
"filename": track.filename,
"path": _display_path(track.path),
**_file_preview(track.path),
}
for track in (orphan_candidates or ())[:limit]
],
},
} }
@@ -269,9 +358,100 @@ def duplicate_repair(
return result return result
def duplicate_repair_batch(
serato: Path,
music: Path,
choices: Iterable[dict],
backup_limit: Optional[int],
apply: bool = False,
) -> dict:
"""Validate and preview or apply several keeper choices together."""
serato = serato.expanduser().resolve()
music = music.expanduser().resolve()
if not serato.is_dir() or not music.is_dir():
raise ValueError("Analyze the library again before repairing duplicates")
groups = find_duplicate_groups(scan_filesystem(music).tracks)
valid_groups = [
{track.path.resolve() for track in group.tracks} for group in groups
]
plans = []
selected_groups = set()
for choice in choices:
requested = tuple(Path(value).expanduser().resolve() for value in choice["group_files"])
group_key = frozenset(requested)
if set(requested) not in valid_groups:
raise ValueError("A duplicate group changed; analyze the library again")
if group_key in selected_groups:
raise ValueError("A duplicate group was selected more than once")
selected_groups.add(group_key)
plans.append(
plan_duplicate_repair(Path(choice["keeper"]), requested, serato)
)
if not plans:
raise ValueError("Choose at least one duplicate group")
replaced = [str(path) for plan in plans for path in plan.replaced]
comparisons = [
compare_audio_files((plan.keeper,) + plan.replaced) for plan in plans
]
result = {
"choice_count": len(plans),
"replaced": replaced,
"decisions": [
{
"keeper": str(plan.keeper),
"replaced": [str(path) for path in plan.replaced],
"hash_status": comparison["status"],
}
for plan, comparison in zip(plans, comparisons)
],
"metadata_backups": len(plans[0].metadata_files),
"strategy": "shortcut",
"database_v2_modified": False,
}
if apply:
receipt = apply_duplicate_repair_batch(plans, serato, backup_limit)
result.update({"applied": True, "backup": str(receipt.backup)})
else:
result["applied"] = False
return result
def backup_history(serato: Path) -> dict:
serato = serato.expanduser().resolve()
if not serato.is_dir():
raise ValueError(f"Serato folder does not exist: {serato}")
backups = list_backups(serato)
return {
"total": len(backups),
"size_bytes": sum(backup.size_bytes for backup in backups),
"items": [
{
"path": str(backup.path),
"created_at": backup.created_at,
"keeper": str(backup.keeper),
"replaced": [str(path) for path in backup.replaced],
"size_bytes": backup.size_bytes,
"status": backup.status,
"restored_at": backup.restored_at,
}
for backup in backups
],
}
class SeratoDoctorHandler(BaseHTTPRequestHandler): class SeratoDoctorHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: def do_GET(self) -> None:
asset = STATIC_FILES.get(self.path) request = urlsplit(self.path)
if request.path == "/api/audio":
try:
token = parse_qs(request.query)["token"][0]
self._audio_response(_verified_audio(token))
except (BrokenPipeError, ConnectionResetError):
return
except (KeyError, IndexError, OSError, ValueError) as error:
self._json_response(404, {"error": str(error)})
return
asset = STATIC_FILES.get(request.path)
if asset is None: if asset is None:
self._json_response(404, {"error": "Not found"}) self._json_response(404, {"error": "Not found"})
return return
@@ -283,6 +463,7 @@ class SeratoDoctorHandler(BaseHTTPRequestHandler):
) )
self.send_response(200) self.send_response(200)
self.send_header("Content-Type", content_type) self.send_header("Content-Type", content_type)
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(content))) self.send_header("Content-Length", str(len(content)))
self.end_headers() self.end_headers()
self.wfile.write(content) self.wfile.write(content)
@@ -292,7 +473,12 @@ class SeratoDoctorHandler(BaseHTTPRequestHandler):
"/api/analyze", "/api/analyze",
"/api/duplicates/preview", "/api/duplicates/preview",
"/api/duplicates/apply", "/api/duplicates/apply",
"/api/duplicates/batch/preview",
"/api/duplicates/batch/apply",
"/api/duplicates/hash",
"/api/backups/restore", "/api/backups/restore",
"/api/backups",
"/api/reveal",
} }
if self.path not in allowed: if self.path not in allowed:
self._json_response(404, {"error": "Not found"}) self._json_response(404, {"error": "Not found"})
@@ -309,6 +495,12 @@ class SeratoDoctorHandler(BaseHTTPRequestHandler):
result = analyze_paths( result = analyze_paths(
Path(payload["serato"]), Path(payload["music"]), roots Path(payload["serato"]), Path(payload["music"]), roots
) )
elif self.path == "/api/reveal":
path = _verified_audio(payload["token"])
subprocess.run(["open", "-R", str(path)], check=True)
result = {"revealed": str(path)}
elif self.path == "/api/backups":
result = backup_history(Path(payload["serato"]))
elif self.path == "/api/backups/restore": elif self.path == "/api/backups/restore":
serato = Path(payload["serato"]).expanduser().resolve() serato = Path(payload["serato"]).expanduser().resolve()
backup = Path(payload["backup"]).expanduser().resolve() backup = Path(payload["backup"]).expanduser().resolve()
@@ -317,6 +509,34 @@ class SeratoDoctorHandler(BaseHTTPRequestHandler):
raise ValueError("That backup does not belong to this library") raise ValueError("That backup does not belong to this library")
restored = restore_backup(backup) restored = restore_backup(backup)
result = {"restored": [str(path) for path in restored]} result = {"restored": [str(path) for path in restored]}
elif self.path == "/api/duplicates/hash":
tokens = payload["tokens"]
if not isinstance(tokens, list) or not 2 <= len(tokens) <= 10:
raise ValueError("Compare between 2 and 10 audio files")
comparison = compare_audio_files(
_verified_audio(token) for token in tokens
)
result = {
"status": comparison["status"],
"files": [
{
"path": item["path"],
"size": item["size"],
"fingerprint": item["sha256"][:12],
}
for item in comparison["files"]
],
}
elif self.path.startswith("/api/duplicates/batch/"):
raw_limit = payload.get("backup_limit", 10)
backup_limit = None if raw_limit is None else int(raw_limit)
result = duplicate_repair_batch(
Path(payload["serato"]),
Path(payload["music"]),
payload["choices"],
backup_limit,
apply=self.path.endswith("/apply"),
)
else: else:
raw_limit = payload.get("backup_limit", 10) raw_limit = payload.get("backup_limit", 10)
backup_limit = None if raw_limit is None else int(raw_limit) backup_limit = None if raw_limit is None else int(raw_limit)
@@ -339,6 +559,38 @@ class SeratoDoctorHandler(BaseHTTPRequestHandler):
return return
self._json_response(200, result) self._json_response(200, result)
def _audio_response(self, path: Path) -> None:
size = path.stat().st_size
start, end = 0, size - 1
status = 200
range_header = self.headers.get("Range")
if range_header and range_header.startswith("bytes="):
raw_start, _, raw_end = range_header[6:].partition("-")
start = int(raw_start or 0)
end = min(int(raw_end) if raw_end else end, end)
if start < 0 or start > end:
raise ValueError("Invalid audio range")
status = 206
length = end - start + 1
self.send_response(status)
self.send_header(
"Content-Type", mimetypes.guess_type(path.name)[0] or "audio/mpeg"
)
self.send_header("Accept-Ranges", "bytes")
self.send_header("Content-Length", str(length))
if status == 206:
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
self.end_headers()
with path.open("rb") as audio:
audio.seek(start)
remaining = length
while remaining:
chunk = audio.read(min(64 * 1024, remaining))
if not chunk:
break
self.wfile.write(chunk)
remaining -= len(chunk)
def _json_response(self, status: int, payload: dict) -> None: def _json_response(self, status: int, payload: dict) -> None:
content = json.dumps(payload).encode("utf-8") content = json.dumps(payload).encode("utf-8")
self.send_response(status) self.send_response(status)
+203 -24
View File
@@ -15,10 +15,25 @@ const repairMessage = document.querySelector('#repair-message');
const previewRepairButton = document.querySelector('#preview-repair'); const previewRepairButton = document.querySelector('#preview-repair');
const applyRepairButton = document.querySelector('#apply-repair'); const applyRepairButton = document.querySelector('#apply-repair');
const restoreRepairButton = document.querySelector('#restore-repair'); const restoreRepairButton = document.querySelector('#restore-repair');
const loadBackupsButton = document.querySelector('#load-backups');
const backupSummary = document.querySelector('#backup-summary');
const backupList = document.querySelector('#backup-list');
const audioPreview = document.querySelector('#audio-preview');
const audioPreviewName = document.querySelector('#audio-preview-name');
const audioPlayer = document.querySelector('#audio-player');
const closeAudioPreview = document.querySelector('#close-audio-preview');
const batchPreviewModal = document.querySelector('#batch-preview-modal');
const batchPreviewSummary = document.querySelector('#batch-preview-summary');
const batchPreviewList = document.querySelector('#batch-preview-list');
const batchPreviewSafety = document.querySelector('#batch-preview-safety');
const closeBatchPreview = document.querySelector('#close-batch-preview');
const continueReviewing = document.querySelector('#continue-reviewing');
const acceptPreview = document.querySelector('#accept-preview');
let latestAnalysis = null; let latestAnalysis = null;
let selectedDuplicateGroup = null; let selectedDuplicateGroup = null;
let previewedRepair = null; let previewedRepair = null;
let latestBackup = null; let latestBackup = null;
let reviewState = null;
function expandHome(path) { function expandHome(path) {
return path.trim(); return path.trim();
@@ -34,9 +49,55 @@ function escapeHtml(value) {
}[character])); }[character]));
} }
function formatBytes(bytes) {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const unit = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
return `${(bytes / (1024 ** unit)).toFixed(unit ? 1 : 0)} ${units[unit]}`;
}
function formatDate(value) {
const date = new Date(value);
return Number.isNaN(date.valueOf()) ? value : date.toLocaleString([], {dateStyle: 'medium', timeStyle: 'short'});
}
async function loadBackups() {
loadBackupsButton.disabled = true;
backupSummary.textContent = 'Looking for recovery snapshots…';
try {
const response = await fetch('/api/backups', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({serato: expandHome(document.querySelector('#serato-path').value)})});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Could not load backups');
backupSummary.textContent = `${data.total} backup${data.total === 1 ? '' : 's'} · ${formatBytes(data.size_bytes)} on disk`;
backupList.innerHTML = data.items.length ? data.items.map((backup) => `
<article class="backup-item">
<div><span class="backup-status ${backup.status}">${backup.status === 'restored' ? 'Restored' : 'Ready to restore'}</span><strong>${formatDate(backup.created_at)}</strong><small>Kept: ${escapeHtml(backup.keeper)}</small><small>${backup.replaced.length} original file${backup.replaced.length === 1 ? '' : 's'} · ${formatBytes(backup.size_bytes)}</small></div>
<button type="button" data-restore-backup="${escapeHtml(backup.path)}" ${backup.status === 'restored' ? 'disabled' : ''}>${backup.status === 'restored' ? 'Already restored' : 'Restore'}</button>
</article>
`).join('') : '<div class="empty-detail">No repair backups found for this library yet.</div>';
} catch (error) {
backupSummary.textContent = error.message;
backupList.innerHTML = '';
} finally { loadBackupsButton.disabled = false; }
}
loadBackupsButton.addEventListener('click', loadBackups);
backupList.addEventListener('click', async (event) => {
const button = event.target.closest('[data-restore-backup]');
if (!button || button.disabled) return;
if (!window.confirm('Restore the original duplicate files from this backup? Existing real files will never be overwritten.')) return;
button.disabled = true; button.textContent = 'Restoring…';
try {
const response = await fetch('/api/backups/restore', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({serato: expandHome(document.querySelector('#serato-path').value), backup: button.dataset.restoreBackup})});
const result = await response.json();
if (!response.ok) throw new Error(result.error || 'Restore failed');
await loadBackups();
} catch (error) { backupSummary.textContent = error.message; button.disabled = false; button.textContent = 'Restore'; }
});
function detailLines(item) { function detailLines(item) {
if (item.files) { if (item.file_previews) {
return item.files.map((file) => `<li>${escapeHtml(file)}</li>`).join(''); return item.file_previews.map((file) => `<li class="file-compare-row"><span>${escapeHtml(file.path)}</span><div><button type="button" data-audio-url="${escapeHtml(file.audio_url)}" data-audio-name="${escapeHtml(file.path)}">▶ Play preview</button><button type="button" data-reveal-token="${escapeHtml(file.reveal_token)}">Show in Finder</button></div></li>`).join('');
} }
const lines = []; const lines = [];
if (item.artist || item.title) lines.push(`${item.artist || 'Unknown artist'}${item.title || item.filename}`); if (item.artist || item.title) lines.push(`${item.artist || 'Unknown artist'}${item.title || item.filename}`);
@@ -50,6 +111,11 @@ function detailLines(item) {
return lines.map((line) => `<li>${escapeHtml(line)}</li>`).join(''); return lines.map((line) => `<li>${escapeHtml(line)}</li>`).join('');
} }
function detailActions(item) {
if (!item.audio_url || !item.reveal_token) return '';
return `<div class="file-actions detail-actions"><button type="button" data-audio-url="${escapeHtml(item.audio_url)}" data-audio-name="${escapeHtml(item.path)}">▶ Play preview</button><button type="button" data-reveal-token="${escapeHtml(item.reveal_token)}">Show in Finder</button></div>`;
}
function renderDetail(key) { function renderDetail(key) {
const detail = latestAnalysis?.details?.[key]; const detail = latestAnalysis?.details?.[key];
if (!detail) return; if (!detail) return;
@@ -58,7 +124,7 @@ function renderDetail(key) {
trigger.classList.toggle('selected', trigger.dataset.detail === key); trigger.classList.toggle('selected', trigger.dataset.detail === key);
}); });
drilldownTitle.textContent = detail.title; drilldownTitle.textContent = detail.title;
drilldownCount.textContent = `${detail.total ?? 0} found`; drilldownCount.textContent = detail.assessed === false ? 'Not assessed' : `${detail.total ?? 0} found`;
drilldownSummary.textContent = detail.summary; drilldownSummary.textContent = detail.summary;
repairPanel.hidden = true; repairPanel.hidden = true;
@@ -66,39 +132,94 @@ function renderDetail(key) {
previewedRepair = null; previewedRepair = null;
latestBackup = null; latestBackup = null;
restoreRepairButton.hidden = true; restoreRepairButton.hidden = true;
if (detail.assessed === false) {
drilldownList.innerHTML = '<div class="empty-detail">Seratos database V2 was not available, so orphan status cannot be assessed safely.</div>';
return;
}
if (!detail.items?.length) { if (!detail.items?.length) {
drilldownList.innerHTML = '<div class="empty-detail">Nothing to review here. Tiny victory parade, very tasteful.</div>'; drilldownList.innerHTML = '<div class="empty-detail">Nothing to review here. Tiny victory parade, very tasteful.</div>';
return; return;
} }
if (detail.items[0].file_previews) {
reviewState = {key, index: 0, choices: new Map()};
repairPanel.hidden = false;
renderReviewGroup();
return;
}
drilldownList.innerHTML = detail.items.map((item, index) => ` drilldownList.innerHTML = detail.items.map((item, index) => `
<article class="detail-item${item.files ? ' selectable-duplicate' : ''}" ${item.files ? `data-duplicate-index="${index}" role="button" tabindex="0"` : ''}> <article class="detail-item">
<strong>${escapeHtml(item.filename || item.path || 'Untitled item')}</strong> <strong>${escapeHtml(item.filename || item.path || 'Untitled item')}</strong>
<ul>${detailLines(item)}</ul> <ul>${detailLines(item)}</ul>
${item.files ? '<small>Choose this group to review a safe cleanup →</small>' : ''} ${detailActions(item)}
</article> </article>
`).join(''); `).join('');
} }
function chooseDuplicate(detailKey, index) { function renderReviewGroup() {
const group = latestAnalysis?.details?.[detailKey]?.items?.[index]; const groups = latestAnalysis.details[reviewState.key].items;
if (!group?.files) return; const group = groups[reviewState.index];
selectedDuplicateGroup = group; const chosen = reviewState.choices.get(reviewState.index);
drilldownCount.textContent = `${reviewState.index + 1} of ${groups.length} · ${reviewState.choices.size} approved`;
drilldownSummary.textContent = 'Listen to each candidate, choose the keeper, and well move to the next group. Skip anything uncertain.';
drilldownList.innerHTML = `
<article class="review-workspace">
<div class="review-heading"><div><span>Comparing now</span><strong>${escapeHtml(group.filename)}</strong></div><div class="review-signals"><span id="hash-confidence" class="hash-confidence checking">Checking file identity…</span><span>${reviewState.choices.size} selected</span></div></div>
<div class="review-candidates">${group.file_previews.map((file, index) => `
<section class="review-candidate ${chosen === file.path ? 'winner' : ''}">
<span class="candidate-number">Option ${index + 1}</span>
<strong>${escapeHtml(file.path.split('/').pop())}</strong>
<small>${escapeHtml(file.path)}</small>
<div class="file-actions"><button type="button" data-audio-url="${escapeHtml(file.audio_url)}" data-audio-name="${escapeHtml(file.path)}">▶ Play preview</button><button type="button" data-reveal-token="${escapeHtml(file.reveal_token)}">Show in Finder</button></div>
<button class="choose-winner" type="button" data-choose-winner="${index}">${chosen === file.path ? '✓ Selected keeper' : 'Keep this one →'}</button>
</section>
`).join('')}</div>
<div class="review-navigation"><button type="button" data-review-previous ${reviewState.index === 0 ? 'disabled' : ''}>← Previous</button><button type="button" data-review-skip>Skip for now</button></div>
</article>`;
updateBatchSummary();
loadHashConfidence(group, reviewState.index, reviewState.key);
}
async function loadHashConfidence(group, groupIndex, detailKey) {
const badge = document.querySelector('#hash-confidence');
if (group.hashComparison) {
showHashConfidence(badge, group.hashComparison);
return;
}
try {
const response = await fetch('/api/duplicates/hash', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({tokens: group.file_previews.map((file) => file.reveal_token)})});
const result = await response.json();
if (!response.ok) throw new Error(result.error || 'Identity check failed');
group.hashComparison = result;
if (reviewState?.index === groupIndex && reviewState?.key === detailKey) showHashConfidence(document.querySelector('#hash-confidence'), result);
} catch (error) {
if (reviewState?.index === groupIndex && reviewState?.key === detailKey && badge) { badge.className = 'hash-confidence unavailable'; badge.textContent = 'Identity not verified'; }
}
}
function showHashConfidence(badge, comparison) {
if (!badge) return;
badge.className = `hash-confidence ${comparison.status}`;
badge.textContent = comparison.status === 'identical' ? '✓ Byte-for-byte identical' : '⚠ Files differ — listen carefully';
badge.title = comparison.files.map((file) => `${formatBytes(file.size)} · ${file.fingerprint}`).join('\n');
}
function updateBatchSummary() {
if (!reviewState) return;
const groups = latestAnalysis.details[reviewState.key].items;
repairChoice.innerHTML = `<div class="batch-summary"><strong>${reviewState.choices.size} group${reviewState.choices.size === 1 ? '' : 's'} approved</strong><span>${groups.length - reviewState.choices.size} skipped or still awaiting a decision</span></div>`;
previewRepairButton.disabled = reviewState.choices.size === 0;
previewedRepair = null; previewedRepair = null;
applyRepairButton.disabled = true; applyRepairButton.disabled = true;
repairPreview.hidden = true; repairPreview.hidden = true;
repairMessage.textContent = '';
repairChoice.innerHTML = group.files.map((file, fileIndex) => `
<label class="keeper-option"><input type="radio" name="keeper" value="${escapeHtml(file)}" ${fileIndex === 0 ? 'checked' : ''}><span><strong>${fileIndex === 0 ? 'Keep this file' : 'Keep instead'}</strong><small>${escapeHtml(file)}</small></span></label>
`).join('');
repairPanel.hidden = false;
repairPanel.scrollIntoView({behavior: 'smooth', block: 'start'});
} }
function repairPayload() { function repairPayload() {
const keeper = document.querySelector('input[name="keeper"]:checked')?.value; if (!reviewState?.choices.size) throw new Error('Choose at least one keeper');
if (!selectedDuplicateGroup || !keeper) throw new Error('Choose a file to keep'); const groups = latestAnalysis.details[reviewState.key].items;
return {serato: expandHome(document.querySelector('#serato-path').value), music: expandHome(document.querySelector('#music-path').value), keeper, group_files: selectedDuplicateGroup.files, backup_limit: document.querySelector('#keep-all-backups').checked ? null : Number(document.querySelector('#backup-limit').value)}; const choices = Array.from(reviewState.choices, ([index, keeper]) => ({keeper, group_files: groups[index].files}));
return {serato: expandHome(document.querySelector('#serato-path').value), music: expandHome(document.querySelector('#music-path').value), choices, backup_limit: document.querySelector('#keep-all-backups').checked ? null : Number(document.querySelector('#backup-limit').value)};
} }
async function requestRepair(endpoint) { async function requestRepair(endpoint) {
@@ -111,22 +232,34 @@ async function requestRepair(endpoint) {
previewRepairButton.addEventListener('click', async () => { previewRepairButton.addEventListener('click', async () => {
repairMessage.textContent = 'Checking the plan…'; applyRepairButton.disabled = true; repairMessage.textContent = 'Checking the plan…'; applyRepairButton.disabled = true;
try { try {
previewedRepair = await requestRepair('/api/duplicates/preview'); previewedRepair = await requestRepair('/api/duplicates/batch/preview');
repairPreview.innerHTML = `<strong>Ready to protect and consolidate</strong><p>${previewedRepair.replaced.length} duplicate file(s) will be backed up, then replaced with shortcuts to the keeper. ${previewedRepair.metadata_backups} Serato metadata file(s) will also be copied into the rollback snapshot. Database V2 will not be changed.</p>`; batchPreviewSummary.textContent = `${previewedRepair.choice_count} keeper decision(s) · ${previewedRepair.replaced.length} duplicate file(s) consolidated`;
batchPreviewList.innerHTML = previewedRepair.decisions.map((decision, index) => `
<article class="preview-decision"><span>Decision ${index + 1}<em class="hash-confidence ${decision.hash_status}">${decision.hash_status === 'identical' ? 'Identical' : 'Files differ'}</em></span><div class="winner-path"><b>Keep</b><strong>${escapeHtml(decision.keeper.split('/').pop())}</strong><small>${escapeHtml(decision.keeper)}</small></div><div class="replaced-paths"><b>Replace with a shortcut</b>${decision.replaced.map((path) => `<small>${escapeHtml(path)}</small>`).join('')}</div></article>
`).join('');
batchPreviewSafety.textContent = `${previewedRepair.metadata_backups} Serato metadata file(s) and every replaced audio file will be backed up. Database V2 will not be modified.`;
repairPreview.innerHTML = `<strong>Preview approved</strong><p>${previewedRepair.choice_count} keeper decision(s) are ready for one backed-up apply.</p>`;
repairPreview.hidden = false; applyRepairButton.disabled = false; repairPreview.hidden = false; applyRepairButton.disabled = false;
repairMessage.textContent = 'Preview complete. Nothing has changed yet.'; repairMessage.textContent = 'Preview complete. Nothing has changed yet.';
batchPreviewModal.showModal();
} catch (error) { repairMessage.textContent = error.message; } } catch (error) { repairMessage.textContent = error.message; }
}); });
function dismissBatchPreview() { batchPreviewModal.close(); }
closeBatchPreview.addEventListener('click', dismissBatchPreview);
continueReviewing.addEventListener('click', dismissBatchPreview);
acceptPreview.addEventListener('click', dismissBatchPreview);
applyRepairButton.addEventListener('click', async () => { applyRepairButton.addEventListener('click', async () => {
if (!previewedRepair) return; if (!previewedRepair) return;
applyRepairButton.disabled = true; repairMessage.textContent = 'Creating the backup before making changes…'; applyRepairButton.disabled = true; repairMessage.textContent = 'Creating the backup before making changes…';
try { try {
const result = await requestRepair('/api/duplicates/apply'); const result = await requestRepair('/api/duplicates/batch/apply');
latestBackup = result.backup; latestBackup = result.backup;
repairMessage.textContent = `Cleanup complete. Restore backup: ${result.backup}`; repairMessage.textContent = `Cleanup complete. Restore backup: ${result.backup}`;
previewRepairButton.disabled = true; previewRepairButton.disabled = true;
restoreRepairButton.hidden = false; restoreRepairButton.hidden = false;
await loadBackups();
} catch (error) { repairMessage.textContent = error.message; applyRepairButton.disabled = false; } } catch (error) { repairMessage.textContent = error.message; applyRepairButton.disabled = false; }
}); });
@@ -139,12 +272,58 @@ restoreRepairButton.addEventListener('click', async () => {
if (!response.ok) throw new Error(result.error || 'Restore failed'); if (!response.ok) throw new Error(result.error || 'Restore failed');
repairMessage.textContent = `Restore complete. ${result.restored.length} original file(s) returned.`; repairMessage.textContent = `Restore complete. ${result.restored.length} original file(s) returned.`;
restoreRepairButton.hidden = true; restoreRepairButton.hidden = true;
await loadBackups();
} catch (error) { repairMessage.textContent = error.message; restoreRepairButton.disabled = false; } } catch (error) { repairMessage.textContent = error.message; restoreRepairButton.disabled = false; }
}); });
repairChoice.addEventListener('change', () => { previewedRepair = null; applyRepairButton.disabled = true; repairPreview.hidden = true; repairMessage.textContent = 'Keeper changed. Preview the plan again.'; }); async function handleFileAction(event) {
drilldownList.addEventListener('click', (event) => { const item = event.target.closest('[data-duplicate-index]'); if (item) chooseDuplicate(document.querySelector('.drill-trigger.selected')?.dataset.detail, Number(item.dataset.duplicateIndex)); }); const previewButton = event.target.closest('[data-audio-url]');
drilldownList.addEventListener('keydown', (event) => { if (event.key !== 'Enter' && event.key !== ' ') return; const item = event.target.closest('[data-duplicate-index]'); if (item) { event.preventDefault(); chooseDuplicate(document.querySelector('.drill-trigger.selected')?.dataset.detail, Number(item.dataset.duplicateIndex)); } }); const revealButton = event.target.closest('[data-reveal-token]');
if (!previewButton && !revealButton) return false;
event.preventDefault(); event.stopPropagation();
if (previewButton) {
audioPlayer.src = previewButton.dataset.audioUrl;
audioPreviewName.textContent = previewButton.dataset.audioName.split('/').pop();
audioPreview.hidden = false;
try { await audioPlayer.play(); } catch (_) { /* Native controls remain available. */ }
} else {
const original = revealButton.textContent;
revealButton.disabled = true; revealButton.textContent = 'Opening…';
try {
const response = await fetch('/api/reveal', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({token: revealButton.dataset.revealToken})});
const result = await response.json();
if (!response.ok) throw new Error(result.error || 'Could not open Finder');
revealButton.textContent = 'Shown in Finder';
} catch (error) { revealButton.textContent = error.message; }
setTimeout(() => { revealButton.disabled = false; revealButton.textContent = original; }, 1800);
}
return true;
}
closeAudioPreview.addEventListener('click', () => { audioPlayer.pause(); audioPlayer.removeAttribute('src'); audioPlayer.load(); audioPreview.hidden = true; });
repairChoice.addEventListener('click', handleFileAction);
drilldownList.addEventListener('click', async (event) => {
if (await handleFileAction(event) || !reviewState) return;
const groups = latestAnalysis.details[reviewState.key].items;
const winner = event.target.closest('[data-choose-winner]');
if (winner) {
const fileIndex = Number(winner.dataset.chooseWinner);
reviewState.choices.set(reviewState.index, groups[reviewState.index].files[fileIndex]);
if (reviewState.index < groups.length - 1) reviewState.index += 1;
renderReviewGroup();
return;
}
if (event.target.closest('[data-review-skip]')) {
reviewState.choices.delete(reviewState.index);
if (reviewState.index < groups.length - 1) reviewState.index += 1;
renderReviewGroup();
return;
}
if (event.target.closest('[data-review-previous]') && reviewState.index > 0) {
reviewState.index -= 1;
renderReviewGroup();
}
});
function render(data) { function render(data) {
latestAnalysis = data; latestAnalysis = data;
+30 -7
View File
@@ -5,7 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark"> <meta name="color-scheme" content="dark">
<title>Serato Doctor</title> <title>Serato Doctor</title>
<link rel="stylesheet" href="/app.css"> <link rel="stylesheet" href="/app.css?v=3">
<link rel="stylesheet" href="/recovery.css?v=3">
<link rel="stylesheet" href="/layout-fixes.css?v=3">
</head> </head>
<body> <body>
<div class="ambient ambient-one"></div> <div class="ambient ambient-one"></div>
@@ -20,6 +22,7 @@
<a class="nav-item active" href="#dashboard"><span></span> Dashboard</a> <a class="nav-item active" href="#dashboard"><span></span> Dashboard</a>
<a class="nav-item" href="#scan"><span></span> New analysis</a> <a class="nav-item" href="#scan"><span></span> New analysis</a>
<a class="nav-item" href="#diagnostics"><span></span> Diagnostics</a> <a class="nav-item" href="#diagnostics"><span></span> Diagnostics</a>
<a class="nav-item" href="#recovery"><span></span> Recovery</a>
</nav> </nav>
<div class="safety-card"> <div class="safety-card">
<span class="safety-icon"></span> <span class="safety-icon"></span>
@@ -66,7 +69,7 @@
<div class="metrics-grid"> <div class="metrics-grid">
<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"><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 drill-trigger" role="button" tabindex="0" data-detail="database_missing_tracks"><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 · click for list</small></article> <article class="metric panel warning drill-trigger" role="button" tabindex="0" data-detail="database_missing_tracks"><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 · click for list</small></article>
<article class="metric panel drill-trigger" role="button" tabindex="0" data-detail="unused_tracks"><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 · click for list</small></article> <article class="metric panel drill-trigger" role="button" tabindex="0" data-detail="orphan_candidates"><button class="info-button" type="button" aria-label="About possible orphan files" aria-expanded="false" data-info="Files found in the music folder but not by filename in loaded crates or Serato's database. They require review and are never automatic deletion recommendations.">i</button><span>Possible orphan files</span><strong data-field="orphan_candidates"></strong><small>outside crates and database · click for list</small></article>
<article class="metric panel drill-trigger" role="button" tabindex="0" data-detail="suggested_matches"><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 · click for list</small></article> <article class="metric panel drill-trigger" role="button" tabindex="0" data-detail="suggested_matches"><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 · click for list</small></article>
</div> </div>
</div> </div>
@@ -79,6 +82,7 @@
<div class="drill-trigger" role="button" tabindex="0" data-detail="cloud_conflicts"><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 · click for list</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 class="drill-trigger" role="button" tabindex="0" data-detail="cloud_conflicts"><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 · click for list</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 class="drill-trigger" role="button" tabindex="0" data-detail="broken_symlinks"><span class="diag-icon red"></span><p><strong>Broken shortcuts</strong><small><b data-field="broken_symlinks"></b> unresolved symbolic links · click for list</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 class="drill-trigger" role="button" tabindex="0" data-detail="broken_symlinks"><span class="diag-icon red"></span><p><strong>Broken shortcuts</strong><small><b data-field="broken_symlinks"></b> unresolved symbolic links · click for list</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 class="drill-trigger" role="button" tabindex="0" data-detail="old_crate_references"><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 · click for list</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 class="drill-trigger" role="button" tabindex="0" data-detail="old_crate_references"><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 · click for list</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 class="drill-trigger" role="button" tabindex="0" data-detail="unused_tracks"><span class="diag-icon blue"></span><p><strong>Not in crates</strong><small><b data-field="unused_tracks"></b> tracks still potentially in Serato · click for list</small></p><button class="info-button" type="button" aria-label="About tracks not in crates" aria-expanded="false" data-info="Tracks that do not appear in loaded crates. They may still be normal, active tracks in Serato's main library and should not be treated as orphans.">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><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>
@@ -90,8 +94,8 @@
</div> </div>
<div id="duplicate-repair" class="repair-panel panel" hidden> <div id="duplicate-repair" class="repair-panel panel" hidden>
<div class="section-heading"><div><p class="eyebrow">Backup-first cleanup</p><h2>Safely consolidate duplicates</h2></div><span class="repair-tag">Preview required</span></div> <div class="section-heading"><div><p class="eyebrow">Batch cleanup</p><h2>Review all selected changes</h2></div><span class="repair-tag">One backup · one apply</span></div>
<p>Choose the real file to keep. Every other path will be backed up and replaced with a shortcut to it, so existing Serato crates remain mapped. Seratos database V2 is never edited.</p> <p>Approved choices are combined into one plan. Skipped groups remain untouched, and Seratos database V2 is never edited.</p>
<div id="repair-choice" class="repair-choice"></div> <div id="repair-choice" class="repair-choice"></div>
<div class="backup-options"> <div class="backup-options">
<label>Backups to keep <input id="backup-limit" type="number" min="1" value="10"></label> <label>Backups to keep <input id="backup-limit" type="number" min="1" value="10"></label>
@@ -99,15 +103,34 @@
</div> </div>
<div id="repair-preview" class="repair-preview" hidden></div> <div id="repair-preview" class="repair-preview" hidden></div>
<div class="repair-actions"> <div class="repair-actions">
<button id="preview-repair" type="button">Preview changes</button> <button id="preview-repair" type="button">Preview all selected changes</button>
<button id="apply-repair" class="danger-action" type="button" disabled>Apply backed-up cleanup</button> <button id="apply-repair" class="danger-action" type="button" disabled>Apply all selected changes</button>
<button id="restore-repair" type="button" hidden>Restore this backup</button> <button id="restore-repair" type="button" hidden>Restore this backup</button>
</div> </div>
<div id="repair-message" class="repair-message" role="status"></div> <div id="repair-message" class="repair-message" role="status"></div>
</div> </div>
</section> </section>
<section id="recovery" class="recovery-panel panel">
<div class="section-heading"><div><p class="eyebrow">Safety net</p><h2>Backup recovery</h2></div><button id="load-backups" type="button">Check backups</button></div>
<p>Repair backups appear here automatically before any duplicate is consolidated. Review saved snapshots, disk usage, and restore history.</p>
<div id="backup-summary" class="backup-summary">Analyze your library, then choose a duplicate to preview its automatic backup.</div>
<div id="backup-list" class="backup-list"></div>
</section>
<div id="audio-preview" class="audio-preview" hidden>
<div><span>Now previewing</span><strong id="audio-preview-name"></strong></div>
<audio id="audio-player" controls preload="metadata"></audio>
<button id="close-audio-preview" type="button" aria-label="Close audio preview">×</button>
</div>
<dialog id="batch-preview-modal" class="batch-preview-modal">
<div class="modal-heading"><div><p class="eyebrow">Final dry-run</p><h2>Heres exactly what will happen</h2></div><button id="close-batch-preview" type="button" aria-label="Close preview">×</button></div>
<p id="batch-preview-summary"></p>
<div id="batch-preview-list" class="batch-preview-list"></div>
<div class="modal-safety"><span></span><p><strong>Backup happens first</strong><small id="batch-preview-safety"></small></p></div>
<div class="modal-actions"><button id="continue-reviewing" type="button">Close and continue reviewing</button><button id="accept-preview" type="button">Looks right</button></div>
</dialog>
</main> </main>
</div> </div>
<script src="/app.js" defer></script> <script src="/app.js?v=7" defer></script>
</body> </body>
</html> </html>
+447
View File
@@ -0,0 +1,447 @@
.recovery-panel {
margin-top: 42px;
}
@media (max-width: 680px) {
.recovery-panel {
margin-top: 28px;
}
}
.review-workspace {
padding: clamp(16px, 3vw, 24px);
border: 1px solid rgba(155, 135, 245, .18);
border-radius: 16px;
background: rgba(255, 255, 255, .02);
}
.review-heading,
.review-navigation {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.review-heading span,
.batch-summary span {
color: var(--muted);
font-size: 10px;
}
.review-heading strong {
display: block;
margin-top: 5px;
font-size: 14px;
}
.review-signals {
display: flex;
align-items: flex-end;
gap: 7px;
flex-direction: column;
}
.hash-confidence {
display: inline-block;
padding: 5px 8px;
border-radius: 999px;
font-size: 9px!important;
font-style: normal;
font-weight: 750;
letter-spacing: .02em!important;
text-transform: none!important;
}
.hash-confidence.checking,
.hash-confidence.unavailable {
color: var(--muted);
background: rgba(255, 255, 255, .05);
}
.hash-confidence.identical {
color: #8ce4b8;
background: rgba(84, 212, 154, .11);
}
.hash-confidence.different {
color: var(--amber);
background: rgba(245, 185, 76, .1);
}
.preview-decision > span .hash-confidence {
display: block;
width: max-content;
margin-top: 8px;
}
.review-candidates {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin: 18px 0;
}
.review-candidate {
display: flex;
flex-direction: column;
gap: 9px;
min-width: 0;
padding: 16px;
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(6, 8, 12, .28);
}
.review-candidate.winner {
border-color: rgba(84, 212, 154, .52);
background: rgba(84, 212, 154, .06);
}
.candidate-number {
color: var(--violet);
font-size: 9px;
font-weight: 750;
text-transform: uppercase;
letter-spacing: .1em;
}
.review-candidate > strong,
.review-candidate > small {
overflow-wrap: anywhere;
}
.review-candidate > strong {
font-size: 12px;
}
.review-candidate > small {
flex: 1;
color: var(--muted);
font-size: 9px;
line-height: 1.5;
}
.choose-winner {
margin-top: 4px;
border: 0;
border-radius: 10px;
padding: 10px 12px;
background: linear-gradient(135deg, #907be9, #6e59cf);
color: white;
font: 750 11px/1 inherit;
cursor: pointer;
}
.winner .choose-winner {
background: rgba(84, 212, 154, .18);
color: #8ce4b8;
}
.review-navigation button {
border: 1px solid var(--line);
border-radius: 9px;
padding: 8px 11px;
background: rgba(255, 255, 255, .04);
color: var(--muted);
font: 700 10px/1 inherit;
cursor: pointer;
}
.review-navigation button:disabled {
opacity: .35;
}
.batch-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px;
border: 1px solid rgba(102, 217, 232, .2);
border-radius: 12px;
background: rgba(102, 217, 232, .05);
}
.batch-summary strong {
font-size: 12px;
}
@media (max-width: 760px) {
.review-candidates {
grid-template-columns: 1fr;
}
.review-heading,
.batch-summary {
align-items: flex-start;
flex-direction: column;
}
}
.batch-preview-modal {
width: min(820px, calc(100vw - 32px));
max-height: min(82vh, 760px);
padding: 24px;
overflow: auto;
border: 1px solid rgba(155, 135, 245, .3);
border-radius: 20px;
background: #161923;
color: var(--text);
box-shadow: 0 30px 100px rgba(0, 0, 0, .65);
}
.batch-preview-modal::backdrop {
background: rgba(4, 5, 8, .78);
backdrop-filter: blur(7px);
}
.modal-heading,
.modal-actions,
.modal-safety {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.modal-heading h2 {
margin: 0;
font-size: 23px;
letter-spacing: -.03em;
}
.modal-heading > button {
border: 0;
background: transparent;
color: var(--muted);
font-size: 28px;
cursor: pointer;
}
#batch-preview-summary {
color: var(--muted);
font-size: 12px;
}
.batch-preview-list {
display: grid;
gap: 10px;
max-height: 390px;
margin: 18px 0;
padding-right: 4px;
overflow: auto;
}
.preview-decision {
display: grid;
grid-template-columns: 80px 1fr 1fr;
gap: 13px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 13px;
background: rgba(255, 255, 255, .025);
}
.preview-decision > span,
.preview-decision b {
color: var(--muted);
font-size: 9px;
text-transform: uppercase;
letter-spacing: .08em;
}
.winner-path,
.replaced-paths {
min-width: 0;
}
.winner-path strong,
.winner-path small,
.replaced-paths small {
display: block;
margin-top: 5px;
overflow-wrap: anywhere;
}
.winner-path strong {
color: #8ce4b8;
font-size: 11px;
}
.winner-path small,
.replaced-paths small {
color: var(--muted);
font-size: 9px;
line-height: 1.45;
}
.modal-safety {
justify-content: flex-start;
padding: 13px;
border: 1px solid rgba(102, 217, 232, .2);
border-radius: 12px;
background: rgba(102, 217, 232, .05);
}
.modal-safety > span {
color: var(--cyan);
}
.modal-safety p,
.modal-safety strong,
.modal-safety small {
display: block;
margin: 0;
}
.modal-safety strong {
font-size: 11px;
}
.modal-safety small {
margin-top: 4px;
color: var(--muted);
font-size: 9px;
line-height: 1.45;
}
.modal-actions {
justify-content: flex-end;
margin-top: 16px;
}
.modal-actions button {
border: 1px solid var(--line);
border-radius: 10px;
padding: 10px 13px;
background: rgba(255, 255, 255, .04);
color: var(--text);
font: 700 11px/1 inherit;
cursor: pointer;
}
#accept-preview {
border-color: transparent;
background: #6f5bd0;
}
@media (max-width: 620px) {
.preview-decision {
grid-template-columns: 1fr;
}
.modal-actions {
align-items: stretch;
flex-direction: column-reverse;
}
}
.file-compare-row {
display: grid;
gap: 8px;
padding: 9px 0;
border-top: 1px solid rgba(255, 255, 255, .05);
}
.file-compare-row:first-child {
border-top: 0;
}
.file-compare-row div,
.file-actions {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.file-compare-row button,
.file-actions button {
border: 1px solid rgba(155, 135, 245, .25);
border-radius: 8px;
padding: 7px 9px;
background: rgba(155, 135, 245, .08);
color: #d8d2f6;
font: 700 10px/1 inherit;
cursor: pointer;
}
.keeper-option {
justify-content: space-between;
}
.keeper-option > label {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
cursor: pointer;
}
.audio-preview {
position: fixed;
right: 24px;
bottom: 20px;
display: grid;
grid-template-columns: minmax(150px, .7fr) minmax(240px, 1.3fr) auto;
align-items: center;
gap: 16px;
width: min(720px, calc(100vw - 48px));
padding: 13px 14px;
border: 1px solid rgba(102, 217, 232, .3);
border-radius: 14px;
background: rgba(16, 21, 29, .96);
box-shadow: 0 16px 48px rgba(0, 0, 0, .45);
z-index: 5;
}
.audio-preview[hidden] {
display: none;
}
.audio-preview span,
.audio-preview strong {
display: block;
}
.audio-preview span {
color: var(--cyan);
font-size: 9px;
text-transform: uppercase;
letter-spacing: .1em;
}
.audio-preview strong {
max-width: 360px;
margin-top: 4px;
overflow: hidden;
color: var(--text);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.audio-preview audio {
width: 100%;
height: 34px;
}
.audio-preview > button {
border: 0;
background: transparent;
color: var(--muted);
font-size: 22px;
cursor: pointer;
}
@media (max-width: 680px) {
.keeper-option,
.audio-preview {
align-items: stretch;
grid-template-columns: 1fr;
}
.keeper-option {
flex-direction: column;
}
}
+1
View File
@@ -0,0 +1 @@
.recovery-panel{margin-top:18px;padding:25px}.recovery-panel>p{color:var(--muted);font-size:12px;line-height:1.6}.recovery-panel .section-heading button{border:1px solid rgba(155,135,245,.35);border-radius:10px;padding:9px 12px;background:rgba(155,135,245,.12);color:#ddd8fa;font:700 11px inherit;cursor:pointer}.recovery-panel button:disabled{opacity:.5;cursor:not-allowed}.backup-summary{padding:11px 13px;border-radius:11px;background:rgba(255,255,255,.03);color:#bfc4cf;font-size:11px;margin:15px 0 10px}.backup-list{display:grid;gap:9px}.backup-item{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:14px;border:1px solid var(--line);border-radius:13px;background:rgba(255,255,255,.025)}.backup-item>div{min-width:0}.backup-item strong,.backup-item small{display:block}.backup-item strong{font-size:12px;margin:7px 0}.backup-item small{font-size:10px;color:var(--muted);line-height:1.5;overflow-wrap:anywhere}.backup-item>button{flex:0 0 auto;border:0;border-radius:10px;padding:10px 13px;background:#6f5bd0;color:#fff;font:700 11px inherit;cursor:pointer}.backup-status{display:inline-block;padding:4px 7px;border-radius:999px;font-size:9px;text-transform:uppercase;letter-spacing:.08em;color:var(--cyan);background:rgba(102,217,232,.1)}.backup-status.restored{color:#aeb4c2;background:rgba(255,255,255,.06)}@media(max-width:520px){.backup-item{align-items:stretch;flex-direction:column}.backup-item>button{width:100%}}
+25
View File
@@ -0,0 +1,25 @@
from serato_doctor.hashing import compare_audio_files, sha256_file
def test_equal_files_have_the_same_hash(tmp_path):
first = tmp_path / "First.mp3"
second = tmp_path / "Second.mp3"
first.write_bytes(b"same audio bytes")
second.write_bytes(b"same audio bytes")
result = compare_audio_files((first, second))
assert result["status"] == "identical"
assert sha256_file(first) == sha256_file(second)
def test_different_files_are_not_reported_as_identical(tmp_path):
first = tmp_path / "First.mp3"
second = tmp_path / "Second.mp3"
first.write_bytes(b"version one")
second.write_bytes(b"version two")
result = compare_audio_files((first, second))
assert result["status"] == "different"
assert result["files"][0]["sha256"] != result["files"][1]["sha256"]
+28
View File
@@ -2,6 +2,7 @@ from pathlib import Path
from serato_doctor.health import analyze_health from serato_doctor.health import analyze_health
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.filesystem import BrokenSymlink from serato_doctor.models.filesystem import BrokenSymlink
from serato_doctor.models.library import Library from serato_doctor.models.library import Library
from serato_doctor.models.reference import TrackReference from serato_doctor.models.reference import TrackReference
@@ -49,6 +50,7 @@ def test_health_report_exposes_each_metric():
assert report.duplicate_filename_groups == 1 assert report.duplicate_filename_groups == 1
assert report.duplicate_files == 1 assert report.duplicate_files == 1
assert report.unused_tracks == 3 assert report.unused_tracks == 3
assert report.orphan_candidates is None
assert report.suggested_matches == 1 assert report.suggested_matches == 1
@@ -60,6 +62,32 @@ def test_empty_library_has_no_health_score():
assert report.scored_references == 0 assert report.scored_references == 0
assert not report.database_present assert not report.database_present
assert report.tracks_missing_from_database == 0 assert report.tracks_missing_from_database == 0
assert report.orphan_candidates is None
def test_orphan_candidates_exclude_tracks_known_to_serato_or_crates():
tracks = [
track("Crated.mp3"),
track("LibraryOnly.mp3"),
track("OutsideEverything.mp3"),
]
database = SeratoDatabase(
Path("database V2"),
"test",
(
DatabaseTrack(
Path("/new/House/LibraryOnly.mp3"), "LibraryOnly.mp3"
),
),
)
library = Library.build(
[reference("Crated.mp3")], tracks, database=database
)
report = analyze_health(library)
assert report.unused_tracks == 2
assert report.orphan_candidates == 1
def test_smart_crate_references_are_reported_but_not_scored(): def test_smart_crate_references_are_reported_but_not_scored():
+34
View File
@@ -5,6 +5,8 @@ import pytest
from serato_doctor.repair import ( from serato_doctor.repair import (
BACKUP_FOLDER, BACKUP_FOLDER,
apply_duplicate_repair, apply_duplicate_repair,
apply_duplicate_repair_batch,
list_backups,
plan_duplicate_repair, plan_duplicate_repair,
restore_backup, restore_backup,
rotate_backups, rotate_backups,
@@ -39,10 +41,16 @@ def test_duplicate_repair_backs_up_then_preserves_old_path_as_link(tmp_path):
assert any((receipt.backup / "serato-metadata").rglob("House.crate")) assert any((receipt.backup / "serato-metadata").rglob("House.crate"))
assert any((receipt.backup / "serato-metadata").rglob("database V2")) assert any((receipt.backup / "serato-metadata").rglob("database V2"))
history = list_backups(serato)
assert len(history) == 1
assert history[0].status == "ready"
assert history[0].size_bytes > 0
restored = restore_backup(receipt.backup) restored = restore_backup(receipt.backup)
assert restored == (duplicate.resolve(),) assert restored == (duplicate.resolve(),)
assert not duplicate.is_symlink() assert not duplicate.is_symlink()
assert duplicate.read_bytes() == b"duplicate" assert duplicate.read_bytes() == b"duplicate"
assert list_backups(serato)[0].status == "restored"
def test_plan_rejects_keeper_outside_duplicate_group(tmp_path): def test_plan_rejects_keeper_outside_duplicate_group(tmp_path):
@@ -65,3 +73,29 @@ def test_backup_rotation_can_be_limited_or_unlimited(tmp_path):
assert len(list(root.iterdir())) == 3 assert len(list(root.iterdir())) == 3
rotate_backups(root, 2) rotate_backups(root, 2)
assert {path.name for path in root.iterdir()} == {"002", "003"} assert {path.name for path in root.iterdir()} == {"002", "003"}
def test_batch_repair_uses_one_backup_and_restores_every_group(tmp_path):
serato, first_keeper, first_duplicate = library(tmp_path)
second_keeper = tmp_path / "Music" / "Main" / "Other.mp3"
second_duplicate = tmp_path / "Music" / "Old" / "Other.mp3"
second_keeper.write_bytes(b"other-keeper")
second_duplicate.write_bytes(b"other-duplicate")
plans = (
plan_duplicate_repair(
first_keeper, (first_keeper, first_duplicate), serato
),
plan_duplicate_repair(
second_keeper, (second_keeper, second_duplicate), serato
),
)
receipt = apply_duplicate_repair_batch(plans, serato, backup_limit=10)
assert first_duplicate.is_symlink()
assert second_duplicate.is_symlink()
assert len(list((serato / BACKUP_FOLDER).iterdir())) == 1
assert len(receipt.replaced) == 2
restore_backup(receipt.backup)
assert first_duplicate.read_bytes() == b"duplicate"
assert second_duplicate.read_bytes() == b"other-duplicate"
+81 -2
View File
@@ -3,7 +3,14 @@ from pathlib import Path
import pytest import pytest
from serato_doctor.web import STATIC_FILES, analyze_paths, duplicate_repair from serato_doctor.web import (
STATIC_FILES,
_file_token,
_verified_audio,
analyze_paths,
duplicate_repair,
duplicate_repair_batch,
)
def test_web_analysis_uses_production_health_pipeline(tmp_path): def test_web_analysis_uses_production_health_pipeline(tmp_path):
@@ -30,6 +37,8 @@ def test_web_analysis_uses_production_health_pipeline(tmp_path):
assert result["details"]["old_crate_references"]["items"][0]["crate"] assert result["details"]["old_crate_references"]["items"][0]["crate"]
assert result["details"]["suggested_matches"]["total"] == 0 assert result["details"]["suggested_matches"]["total"] == 0
assert result["details"]["unused_tracks"]["total"] == 1 assert result["details"]["unused_tracks"]["total"] == 1
assert result["orphan_candidates"] == 0
assert result["details"]["orphan_candidates"]["assessed"] is True
def test_web_analysis_rejects_missing_folders(tmp_path): def test_web_analysis_rejects_missing_folders(tmp_path):
@@ -40,12 +49,22 @@ def test_web_analysis_rejects_missing_folders(tmp_path):
def test_web_static_assets_are_declared_and_packaged(): def test_web_static_assets_are_declared_and_packaged():
asset_root = Path(__file__).parents[1] / "serato_doctor" / "webui" asset_root = Path(__file__).parents[1] / "serato_doctor" / "webui"
assert set(STATIC_FILES) == {"/", "/app.css", "/app.js"} assert set(STATIC_FILES) == {
"/",
"/app.css",
"/recovery.css",
"/layout-fixes.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") html = (asset_root / "index.html").read_text(encoding="utf-8")
assert "Missing tracks in Serato" in html assert "Missing tracks in Serato" in html
assert "Old crate references" in html assert "Old crate references" in html
assert "Possible orphan files" in html
assert "Choose a diagnostic" in html assert "Choose a diagnostic" in html
assert "Backup recovery" in html
assert "Heres exactly what will happen" in html
assert 'id="batch-preview-modal"' in html
assert 'data-detail="database_missing_tracks"' in html assert 'data-detail="database_missing_tracks"' in html
assert 'data-detail="old_crate_references"' in html assert 'data-detail="old_crate_references"' in html
assert html.count('class="info-button"') >= 10 assert html.count('class="info-button"') >= 10
@@ -70,3 +89,63 @@ def test_duplicate_repair_preview_does_not_change_files(tmp_path):
assert result["database_v2_modified"] is False assert result["database_v2_modified"] is False
assert second.read_bytes() == b"second" assert second.read_bytes() == b"second"
assert not second.is_symlink() assert not second.is_symlink()
def test_audio_preview_tokens_only_open_signed_audio(tmp_path):
track = tmp_path / "Track.mp3"
track.write_bytes(b"audio")
token = _file_token(track)
assert _verified_audio(token) == track
with pytest.raises(ValueError, match="Invalid or expired"):
_verified_audio(token + "changed")
def test_duplicate_details_include_preview_and_finder_controls(tmp_path):
serato = tmp_path / "_Serato_"
serato.mkdir()
music = tmp_path / "Music"
first = music / "A" / "Track.mp3"
second = music / "B" / "Track.mp3"
first.parent.mkdir(parents=True)
second.parent.mkdir(parents=True)
first.write_bytes(b"first")
second.write_bytes(b"second")
result = analyze_paths(serato, music)
group = result["details"]["duplicate_filenames"]["items"][0]
assert len(group["file_previews"]) == 2
assert group["file_previews"][0]["audio_url"].startswith("/api/audio?")
assert group["file_previews"][0]["reveal_token"]
def test_batch_preview_combines_approved_groups_without_changes(tmp_path):
serato = tmp_path / "_Serato_"
serato.mkdir()
music = tmp_path / "Music"
choices = []
for filename in ("First.mp3", "Second.mp3"):
keeper = music / "A" / filename
duplicate = music / "B" / filename
keeper.parent.mkdir(parents=True, exist_ok=True)
duplicate.parent.mkdir(parents=True, exist_ok=True)
keeper.write_bytes(b"keeper")
duplicate.write_bytes(b"duplicate")
choices.append(
{"keeper": str(keeper), "group_files": [str(keeper), str(duplicate)]}
)
result = duplicate_repair_batch(
serato, music, choices, backup_limit=10
)
assert result["applied"] is False
assert result["choice_count"] == 2
assert len(result["replaced"]) == 2
assert len(result["decisions"]) == 2
assert result["decisions"][0]["keeper"].endswith("First.mp3")
assert result["decisions"][0]["hash_status"] == "different"
assert len(result["decisions"][0]["replaced"]) == 1
assert all(not Path(choice["group_files"][1]).is_symlink() for choice in choices)