Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec646479d2 | |||
| f3cfed316d | |||
| 4315d8d4d4 | |||
| 139822f8a5 | |||
| da72419ce9 | |||
| c4f1b4535d |
+4
-4
@@ -28,12 +28,12 @@
|
|||||||
|
|
||||||
## 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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Duplicate repair
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Duplicate and cloud-conflict files waste space, but deleting either path can
|
||||||
|
unmap tracks in crates or Serato's database. DJs need to choose the authoritative
|
||||||
|
copy and understand every change before it happens.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
The web interface requires an analysis, an explicit keeper selection, and a
|
||||||
|
dry-run preview. Applying the plan first creates a timestamped backup beneath
|
||||||
|
`_Serato_/.serato-doctor-backups/`. The snapshot contains every replaced audio
|
||||||
|
file, loaded crate/smart-crate metadata, database V2, and a JSON restore manifest.
|
||||||
|
|
||||||
|
The non-kept audio path is then replaced with a symbolic link to the keeper.
|
||||||
|
This removes the extra audio payload while preserving every existing saved path.
|
||||||
|
Crate files and database V2 are never rewritten. The UI exposes immediate
|
||||||
|
restore using the manifest.
|
||||||
|
|
||||||
|
Users may retain all backups or set a positive rotation limit. Rotation occurs
|
||||||
|
only after a repair succeeds.
|
||||||
|
|
||||||
|
## Edge cases
|
||||||
|
|
||||||
|
- The duplicate group is rescanned and validated immediately before preview and
|
||||||
|
apply.
|
||||||
|
- Existing symlinks cannot be selected as disposable duplicate files.
|
||||||
|
- A partial failure restores already-changed files before reporting the error.
|
||||||
|
- Restore refuses to overwrite a real file.
|
||||||
|
- Healthy symlink aliases are excluded from future duplicate counts.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- Backup creation includes audio and Serato metadata.
|
||||||
|
- The old path resolves to the selected keeper after repair.
|
||||||
|
- Restore returns the original file contents.
|
||||||
|
- Invalid keeper choices are rejected.
|
||||||
|
- Limited and unlimited retention behave deterministically.
|
||||||
@@ -11,7 +11,9 @@ def find_duplicate_groups(
|
|||||||
) -> Tuple[DuplicateGroup, ...]:
|
) -> Tuple[DuplicateGroup, ...]:
|
||||||
"""Find exact-name duplicates and suspected numeric conflict copies."""
|
"""Find exact-name duplicates and suspected numeric conflict copies."""
|
||||||
|
|
||||||
track_tuple = tuple(tracks)
|
# Healthy symlinks preserve legacy Serato paths without consuming another
|
||||||
|
# copy of the audio, so they are aliases rather than duplicate files.
|
||||||
|
track_tuple = tuple(track for track in tracks if not track.path.is_symlink())
|
||||||
by_name: DefaultDict[str, List[DiskTrack]] = defaultdict(list)
|
by_name: DefaultDict[str, List[DiskTrack]] = defaultdict(list)
|
||||||
by_conflict_name: DefaultDict[str, List[DiskTrack]] = defaultdict(list)
|
by_conflict_name: DefaultDict[str, List[DiskTrack]] = defaultdict(list)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
"""Backup-first duplicate consolidation.
|
||||||
|
|
||||||
|
Serato's binary metadata is deliberately not rewritten. Removed duplicate files
|
||||||
|
are replaced with symbolic links, so every existing path continues to resolve.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable, Optional, Tuple
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
|
||||||
|
BACKUP_FOLDER = ".serato-doctor-backups"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DuplicateRepairPlan:
|
||||||
|
keeper: Path
|
||||||
|
replaced: Tuple[Path, ...]
|
||||||
|
metadata_files: Tuple[Path, ...]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def changes(self) -> int:
|
||||||
|
return len(self.replaced)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RepairReceipt:
|
||||||
|
backup: Path
|
||||||
|
keeper: 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(
|
||||||
|
keeper: Path, duplicates: Iterable[Path], serato_root: Path
|
||||||
|
) -> DuplicateRepairPlan:
|
||||||
|
keeper = keeper.expanduser().resolve()
|
||||||
|
candidates = tuple(path.expanduser().resolve() for path in duplicates)
|
||||||
|
if keeper not in candidates:
|
||||||
|
raise ValueError("The file to keep must belong to this duplicate group")
|
||||||
|
if not keeper.is_file():
|
||||||
|
raise ValueError(f"The file to keep no longer exists: {keeper}")
|
||||||
|
replaced = tuple(path for path in candidates if path != keeper)
|
||||||
|
if not replaced:
|
||||||
|
raise ValueError("Choose a duplicate group containing at least two files")
|
||||||
|
if any(not path.is_file() or path.is_symlink() for path in replaced):
|
||||||
|
raise ValueError("A duplicate changed since the analysis; analyze again")
|
||||||
|
metadata = tuple(
|
||||||
|
sorted(
|
||||||
|
path
|
||||||
|
for path in serato_root.expanduser().resolve().rglob("*")
|
||||||
|
if path.is_file()
|
||||||
|
and BACKUP_FOLDER not in path.parts
|
||||||
|
and (
|
||||||
|
path.name == "database V2"
|
||||||
|
or path.suffix.casefold() in {".crate", ".scrate"}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return DuplicateRepairPlan(keeper, replaced, metadata)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_duplicate_repair(
|
||||||
|
plan: DuplicateRepairPlan,
|
||||||
|
serato_root: Path,
|
||||||
|
backup_limit: Optional[int] = 10,
|
||||||
|
) -> RepairReceipt:
|
||||||
|
"""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:
|
||||||
|
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 = backup_root / _backup_name()
|
||||||
|
files_root = backup / "files"
|
||||||
|
metadata_root = backup / "serato-metadata"
|
||||||
|
backup.mkdir(parents=True)
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
try:
|
||||||
|
for plan in plans:
|
||||||
|
for path in plan.replaced:
|
||||||
|
destination = files_root / _safe_backup_path(path)
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(path, destination)
|
||||||
|
entries.append(
|
||||||
|
{
|
||||||
|
"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())
|
||||||
|
destination = metadata_root / relative
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(path, destination)
|
||||||
|
manifest = {
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"keeper": str(plans[0].keeper),
|
||||||
|
"keepers": [str(plan.keeper) for plan in plans],
|
||||||
|
"choice_count": len(plans),
|
||||||
|
"replaced": entries,
|
||||||
|
"strategy": "symlink",
|
||||||
|
}
|
||||||
|
(backup / "manifest.json").write_text(
|
||||||
|
json.dumps(manifest, indent=2), encoding="utf-8"
|
||||||
|
)
|
||||||
|
for entry in entries:
|
||||||
|
path = Path(entry["original"])
|
||||||
|
path.unlink()
|
||||||
|
path.symlink_to(Path(entry["keeper"]))
|
||||||
|
except Exception:
|
||||||
|
_rollback_entries(entries)
|
||||||
|
shutil.rmtree(backup, ignore_errors=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
rotate_backups(backup_root, backup_limit)
|
||||||
|
return RepairReceipt(backup, plans[0].keeper, replaced_paths)
|
||||||
|
|
||||||
|
|
||||||
|
def restore_backup(backup: Path) -> Tuple[Path, ...]:
|
||||||
|
manifest_path = backup.expanduser().resolve() / "manifest.json"
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
restored = []
|
||||||
|
for entry in manifest["replaced"]:
|
||||||
|
original = Path(entry["original"])
|
||||||
|
saved = Path(entry["backup"])
|
||||||
|
if original.exists() and not original.is_symlink():
|
||||||
|
raise ValueError(f"Restore would overwrite a real file: {original}")
|
||||||
|
if original.is_symlink():
|
||||||
|
original.unlink()
|
||||||
|
original.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(saved, 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)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
if limit is None or not backup_root.is_dir():
|
||||||
|
return
|
||||||
|
backups = sorted(
|
||||||
|
(path for path in backup_root.iterdir() if (path / "manifest.json").is_file()),
|
||||||
|
key=lambda path: path.name,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
for expired in backups[limit:]:
|
||||||
|
shutil.rmtree(expired)
|
||||||
|
|
||||||
|
|
||||||
|
def _backup_name() -> str:
|
||||||
|
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
return f"{stamp}-{uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_backup_path(path: Path) -> Path:
|
||||||
|
anchorless = path.as_posix().lstrip("/").replace(":", "_")
|
||||||
|
return Path(anchorless)
|
||||||
|
|
||||||
|
|
||||||
|
def _rollback_entries(entries: Iterable[dict]) -> None:
|
||||||
|
for entry in entries:
|
||||||
|
original = Path(entry["original"])
|
||||||
|
saved = Path(entry["backup"])
|
||||||
|
if original.is_symlink():
|
||||||
|
original.unlink()
|
||||||
|
if not original.exists() and saved.is_file():
|
||||||
|
original.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(saved, original)
|
||||||
|
|||||||
+276
-4
@@ -1,11 +1,18 @@
|
|||||||
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
|
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
|
||||||
@@ -16,13 +23,24 @@ 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.library import Library
|
from serato_doctor.models.library import Library
|
||||||
from serato_doctor.scanner import scan_filesystem
|
from serato_doctor.scanner import scan_filesystem
|
||||||
|
from serato_doctor.repair import (
|
||||||
|
BACKUP_FOLDER,
|
||||||
|
apply_duplicate_repair,
|
||||||
|
apply_duplicate_repair_batch,
|
||||||
|
list_backups,
|
||||||
|
plan_duplicate_repair,
|
||||||
|
restore_backup,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
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"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +49,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:
|
||||||
@@ -149,6 +205,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]
|
||||||
],
|
],
|
||||||
@@ -164,6 +223,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]
|
||||||
],
|
],
|
||||||
@@ -225,9 +287,134 @@ def analyze_paths(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def duplicate_repair(
|
||||||
|
serato: Path,
|
||||||
|
music: Path,
|
||||||
|
keeper: Path,
|
||||||
|
group_files: Iterable[Path],
|
||||||
|
backup_limit: Optional[int],
|
||||||
|
apply: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Validate a current duplicate group and preview or apply consolidation."""
|
||||||
|
serato = serato.expanduser().resolve()
|
||||||
|
music = music.expanduser().resolve()
|
||||||
|
keeper = keeper.expanduser().resolve()
|
||||||
|
requested = tuple(path.expanduser().resolve() for path in group_files)
|
||||||
|
if not serato.is_dir() or not music.is_dir():
|
||||||
|
raise ValueError("Analyze the library again before repairing duplicates")
|
||||||
|
tracks = scan_filesystem(music).tracks
|
||||||
|
groups = find_duplicate_groups(tracks)
|
||||||
|
valid_groups = [
|
||||||
|
{track.path.resolve() for track in group.tracks} for group in groups
|
||||||
|
]
|
||||||
|
if set(requested) not in valid_groups:
|
||||||
|
raise ValueError("This duplicate group changed; analyze the library again")
|
||||||
|
plan = plan_duplicate_repair(keeper, requested, serato)
|
||||||
|
result = {
|
||||||
|
"keeper": str(plan.keeper),
|
||||||
|
"replaced": [str(path) for path in plan.replaced],
|
||||||
|
"metadata_backups": len(plan.metadata_files),
|
||||||
|
"strategy": "shortcut",
|
||||||
|
"database_v2_modified": False,
|
||||||
|
}
|
||||||
|
if apply:
|
||||||
|
receipt = apply_duplicate_repair(plan, serato, backup_limit)
|
||||||
|
result.update({"applied": True, "backup": str(receipt.backup)})
|
||||||
|
else:
|
||||||
|
result["applied"] = False
|
||||||
|
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]
|
||||||
|
result = {
|
||||||
|
"choice_count": len(plans),
|
||||||
|
"replaced": replaced,
|
||||||
|
"decisions": [
|
||||||
|
{
|
||||||
|
"keeper": str(plan.keeper),
|
||||||
|
"replaced": [str(path) for path in plan.replaced],
|
||||||
|
}
|
||||||
|
for plan in plans
|
||||||
|
],
|
||||||
|
"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
|
||||||
@@ -239,12 +426,23 @@ 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)
|
||||||
|
|
||||||
def do_POST(self) -> None:
|
def do_POST(self) -> None:
|
||||||
if self.path != "/api/analyze":
|
allowed = {
|
||||||
|
"/api/analyze",
|
||||||
|
"/api/duplicates/preview",
|
||||||
|
"/api/duplicates/apply",
|
||||||
|
"/api/duplicates/batch/preview",
|
||||||
|
"/api/duplicates/batch/apply",
|
||||||
|
"/api/backups/restore",
|
||||||
|
"/api/backups",
|
||||||
|
"/api/reveal",
|
||||||
|
}
|
||||||
|
if self.path not in allowed:
|
||||||
self._json_response(404, {"error": "Not found"})
|
self._json_response(404, {"error": "Not found"})
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
@@ -254,15 +452,89 @@ class SeratoDoctorHandler(BaseHTTPRequestHandler):
|
|||||||
payload = json.loads(self.rfile.read(length))
|
payload = json.loads(self.rfile.read(length))
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
raise ValueError("Request body must be a JSON object")
|
raise ValueError("Request body must be a JSON object")
|
||||||
|
if self.path == "/api/analyze":
|
||||||
roots = [Path(value) for value in payload.get("reference_roots", [])]
|
roots = [Path(value) for value in payload.get("reference_roots", [])]
|
||||||
result = analyze_paths(
|
result = analyze_paths(
|
||||||
Path(payload["serato"]), Path(payload["music"]), roots
|
Path(payload["serato"]), Path(payload["music"]), roots
|
||||||
)
|
)
|
||||||
except (KeyError, TypeError, json.JSONDecodeError, ValueError) as error:
|
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":
|
||||||
|
serato = Path(payload["serato"]).expanduser().resolve()
|
||||||
|
backup = Path(payload["backup"]).expanduser().resolve()
|
||||||
|
backup_root = (serato / BACKUP_FOLDER).resolve()
|
||||||
|
if backup.parent != backup_root:
|
||||||
|
raise ValueError("That backup does not belong to this library")
|
||||||
|
restored = restore_backup(backup)
|
||||||
|
result = {"restored": [str(path) for path in restored]}
|
||||||
|
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:
|
||||||
|
raw_limit = payload.get("backup_limit", 10)
|
||||||
|
backup_limit = None if raw_limit is None else int(raw_limit)
|
||||||
|
result = duplicate_repair(
|
||||||
|
Path(payload["serato"]),
|
||||||
|
Path(payload["music"]),
|
||||||
|
Path(payload["keeper"]),
|
||||||
|
(Path(value) for value in payload["group_files"]),
|
||||||
|
backup_limit,
|
||||||
|
apply=self.path.endswith("/apply"),
|
||||||
|
)
|
||||||
|
except (
|
||||||
|
KeyError,
|
||||||
|
TypeError,
|
||||||
|
OSError,
|
||||||
|
json.JSONDecodeError,
|
||||||
|
ValueError,
|
||||||
|
) as error:
|
||||||
self._json_response(400, {"error": str(error)})
|
self._json_response(400, {"error": str(error)})
|
||||||
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)
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+229
-3
@@ -8,7 +8,32 @@ const drilldownTitle = document.querySelector('#drilldown-title');
|
|||||||
const drilldownCount = document.querySelector('#drilldown-count');
|
const drilldownCount = document.querySelector('#drilldown-count');
|
||||||
const drilldownSummary = document.querySelector('#drilldown-summary');
|
const drilldownSummary = document.querySelector('#drilldown-summary');
|
||||||
const drilldownList = document.querySelector('#drilldown-list');
|
const drilldownList = document.querySelector('#drilldown-list');
|
||||||
|
const repairPanel = document.querySelector('#duplicate-repair');
|
||||||
|
const repairChoice = document.querySelector('#repair-choice');
|
||||||
|
const repairPreview = document.querySelector('#repair-preview');
|
||||||
|
const repairMessage = document.querySelector('#repair-message');
|
||||||
|
const previewRepairButton = document.querySelector('#preview-repair');
|
||||||
|
const applyRepairButton = document.querySelector('#apply-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 previewedRepair = null;
|
||||||
|
let latestBackup = null;
|
||||||
|
let reviewState = null;
|
||||||
|
|
||||||
function expandHome(path) {
|
function expandHome(path) {
|
||||||
return path.trim();
|
return path.trim();
|
||||||
@@ -24,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}`);
|
||||||
@@ -51,12 +122,24 @@ function renderDetail(key) {
|
|||||||
drilldownCount.textContent = `${detail.total ?? 0} found`;
|
drilldownCount.textContent = `${detail.total ?? 0} found`;
|
||||||
drilldownSummary.textContent = detail.summary;
|
drilldownSummary.textContent = detail.summary;
|
||||||
|
|
||||||
|
repairPanel.hidden = true;
|
||||||
|
selectedDuplicateGroup = null;
|
||||||
|
previewedRepair = null;
|
||||||
|
latestBackup = null;
|
||||||
|
restoreRepairButton.hidden = true;
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
drilldownList.innerHTML = detail.items.map((item) => `
|
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) => `
|
||||||
<article class="detail-item">
|
<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>
|
||||||
@@ -64,6 +147,149 @@ function renderDetail(key) {
|
|||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderReviewGroup() {
|
||||||
|
const groups = latestAnalysis.details[reviewState.key].items;
|
||||||
|
const group = groups[reviewState.index];
|
||||||
|
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 we’ll 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><span>${reviewState.choices.size} selected</span></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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
applyRepairButton.disabled = true;
|
||||||
|
repairPreview.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function repairPayload() {
|
||||||
|
if (!reviewState?.choices.size) throw new Error('Choose at least one keeper');
|
||||||
|
const groups = latestAnalysis.details[reviewState.key].items;
|
||||||
|
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) {
|
||||||
|
const response = await fetch(endpoint, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(repairPayload())});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) throw new Error(data.error || 'Duplicate cleanup failed');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
previewRepairButton.addEventListener('click', async () => {
|
||||||
|
repairMessage.textContent = 'Checking the plan…'; applyRepairButton.disabled = true;
|
||||||
|
try {
|
||||||
|
previewedRepair = await requestRepair('/api/duplicates/batch/preview');
|
||||||
|
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}</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;
|
||||||
|
repairMessage.textContent = 'Preview complete. Nothing has changed yet.';
|
||||||
|
batchPreviewModal.showModal();
|
||||||
|
} 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 () => {
|
||||||
|
if (!previewedRepair) return;
|
||||||
|
applyRepairButton.disabled = true; repairMessage.textContent = 'Creating the backup before making changes…';
|
||||||
|
try {
|
||||||
|
const result = await requestRepair('/api/duplicates/batch/apply');
|
||||||
|
latestBackup = result.backup;
|
||||||
|
repairMessage.textContent = `Cleanup complete. Restore backup: ${result.backup}`;
|
||||||
|
previewRepairButton.disabled = true;
|
||||||
|
restoreRepairButton.hidden = false;
|
||||||
|
await loadBackups();
|
||||||
|
} catch (error) { repairMessage.textContent = error.message; applyRepairButton.disabled = false; }
|
||||||
|
});
|
||||||
|
|
||||||
|
restoreRepairButton.addEventListener('click', async () => {
|
||||||
|
if (!latestBackup) return;
|
||||||
|
restoreRepairButton.disabled = true; repairMessage.textContent = 'Restoring the duplicate files…';
|
||||||
|
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: latestBackup})});
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok) throw new Error(result.error || 'Restore failed');
|
||||||
|
repairMessage.textContent = `Restore complete. ${result.restored.length} original file(s) returned.`;
|
||||||
|
restoreRepairButton.hidden = true;
|
||||||
|
await loadBackups();
|
||||||
|
} catch (error) { repairMessage.textContent = error.message; restoreRepairButton.disabled = false; }
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleFileAction(event) {
|
||||||
|
const previewButton = event.target.closest('[data-audio-url]');
|
||||||
|
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;
|
||||||
document.querySelectorAll('[data-field]').forEach((element) => {
|
document.querySelectorAll('[data-field]').forEach((element) => {
|
||||||
|
|||||||
@@ -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,10 +22,11 @@
|
|||||||
<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>
|
||||||
<div><strong>Read-only mode</strong><p>Your library will not be modified.</p></div>
|
<div><strong>Protected mode</strong><p>Analysis is read-only. Repairs require a preview and backup.</p></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-foot">Local interface · v0.1</div>
|
<div class="sidebar-foot">Local interface · v0.1</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -88,9 +91,45 @@
|
|||||||
<p id="drilldown-summary">Click a metric above to see example files and saved paths behind that number.</p>
|
<p id="drilldown-summary">Click a metric above to see example files and saved paths behind that number.</p>
|
||||||
<div id="drilldown-list" class="detail-list"></div>
|
<div id="drilldown-list" class="detail-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="duplicate-repair" class="repair-panel panel" hidden>
|
||||||
|
<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>Approved choices are combined into one plan. Skipped groups remain untouched, and Serato’s database V2 is never edited.</p>
|
||||||
|
<div id="repair-choice" class="repair-choice"></div>
|
||||||
|
<div class="backup-options">
|
||||||
|
<label>Backups to keep <input id="backup-limit" type="number" min="1" value="10"></label>
|
||||||
|
<label class="check-label"><input id="keep-all-backups" type="checkbox"> Keep every backup</label>
|
||||||
|
</div>
|
||||||
|
<div id="repair-preview" class="repair-preview" hidden></div>
|
||||||
|
<div class="repair-actions">
|
||||||
|
<button id="preview-repair" type="button">Preview all selected changes</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>
|
||||||
|
</div>
|
||||||
|
<div id="repair-message" class="repair-message" role="status"></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>Here’s 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=6" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,407 @@
|
|||||||
|
.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-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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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%}}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from serato_doctor.repair import (
|
||||||
|
BACKUP_FOLDER,
|
||||||
|
apply_duplicate_repair,
|
||||||
|
apply_duplicate_repair_batch,
|
||||||
|
list_backups,
|
||||||
|
plan_duplicate_repair,
|
||||||
|
restore_backup,
|
||||||
|
rotate_backups,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def library(tmp_path):
|
||||||
|
serato = tmp_path / "_Serato_"
|
||||||
|
crate = serato / "Subcrates" / "House.crate"
|
||||||
|
crate.parent.mkdir(parents=True)
|
||||||
|
crate.write_bytes(b"crate-data")
|
||||||
|
(serato / "database V2").write_bytes(b"database-data")
|
||||||
|
music = tmp_path / "Music"
|
||||||
|
keeper = music / "Main" / "Track.mp3"
|
||||||
|
duplicate = music / "Old" / "Track.mp3"
|
||||||
|
keeper.parent.mkdir(parents=True)
|
||||||
|
duplicate.parent.mkdir(parents=True)
|
||||||
|
keeper.write_bytes(b"keeper")
|
||||||
|
duplicate.write_bytes(b"duplicate")
|
||||||
|
return serato, keeper, duplicate
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_repair_backs_up_then_preserves_old_path_as_link(tmp_path):
|
||||||
|
serato, keeper, duplicate = library(tmp_path)
|
||||||
|
plan = plan_duplicate_repair(keeper, (keeper, duplicate), serato)
|
||||||
|
|
||||||
|
receipt = apply_duplicate_repair(plan, serato, backup_limit=3)
|
||||||
|
|
||||||
|
assert duplicate.is_symlink()
|
||||||
|
assert duplicate.resolve() == keeper.resolve()
|
||||||
|
assert (receipt.backup / "manifest.json").is_file()
|
||||||
|
assert any((receipt.backup / "serato-metadata").rglob("House.crate"))
|
||||||
|
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)
|
||||||
|
assert restored == (duplicate.resolve(),)
|
||||||
|
assert not duplicate.is_symlink()
|
||||||
|
assert duplicate.read_bytes() == b"duplicate"
|
||||||
|
assert list_backups(serato)[0].status == "restored"
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_rejects_keeper_outside_duplicate_group(tmp_path):
|
||||||
|
serato, keeper, duplicate = library(tmp_path)
|
||||||
|
outsider = tmp_path / "outsider.mp3"
|
||||||
|
outsider.write_bytes(b"other")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="must belong"):
|
||||||
|
plan_duplicate_repair(outsider, (keeper, duplicate), serato)
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_rotation_can_be_limited_or_unlimited(tmp_path):
|
||||||
|
root = tmp_path / BACKUP_FOLDER
|
||||||
|
for name in ("001", "002", "003"):
|
||||||
|
backup = root / name
|
||||||
|
backup.mkdir(parents=True)
|
||||||
|
(backup / "manifest.json").write_text("{}", encoding="utf-8")
|
||||||
|
|
||||||
|
rotate_backups(root, None)
|
||||||
|
assert len(list(root.iterdir())) == 3
|
||||||
|
rotate_backups(root, 2)
|
||||||
|
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"
|
||||||
+98
-2
@@ -3,7 +3,14 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from serato_doctor.web import STATIC_FILES, analyze_paths
|
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):
|
||||||
@@ -40,12 +47,101 @@ 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 "Choose a diagnostic" in html
|
assert "Choose a diagnostic" in html
|
||||||
|
assert "Backup recovery" in html
|
||||||
|
assert "Here’s 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
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_repair_preview_does_not_change_files(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 = duplicate_repair(
|
||||||
|
serato, music, first, (first, second), backup_limit=10
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["applied"] is False
|
||||||
|
assert result["database_v2_modified"] is False
|
||||||
|
assert second.read_bytes() == b"second"
|
||||||
|
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 len(result["decisions"][0]["replaced"]) == 1
|
||||||
|
assert all(not Path(choice["group_files"][1]).is_symlink() for choice in choices)
|
||||||
|
|||||||
Reference in New Issue
Block a user