diff --git a/docs/design/audio-comparison.md b/docs/design/audio-comparison.md new file mode 100644 index 0000000..78e4112 --- /dev/null +++ b/docs/design/audio-comparison.md @@ -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. diff --git a/serato_doctor/web.py b/serato_doctor/web.py index 3646af8..16f34f1 100644 --- a/serato_doctor/web.py +++ b/serato_doctor/web.py @@ -1,12 +1,18 @@ import argparse +import base64 +import binascii +import hmac import json +import mimetypes +import secrets +import subprocess from collections import Counter from dataclasses import asdict from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from importlib import resources from pathlib import Path from typing import Iterable, Optional -from urllib.parse import urlsplit +from urllib.parse import parse_qs, quote, urlsplit from serato_doctor.crate_parser import load_library_crates from serato_doctor.database_parser import parse_database @@ -28,6 +34,7 @@ from serato_doctor.repair import ( MAX_REQUEST_BYTES = 64 * 1024 DETAIL_LIMIT = 50 +FILE_TOKEN_SECRET = secrets.token_bytes(32) STATIC_FILES = { "/": ("index.html", "text/html; charset=utf-8"), "/app.css": ("app.css", "text/css; charset=utf-8"), @@ -41,6 +48,44 @@ def _display_path(path: Path) -> str: 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: for item in match.evidence: if item.matched: @@ -159,6 +204,9 @@ def diagnostic_details(library: Library, limit: int = DETAIL_LIMIT) -> dict: { "filename": group.display_name, "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] ], @@ -174,6 +222,9 @@ def diagnostic_details(library: Library, limit: int = DETAIL_LIMIT) -> dict: { "filename": group.display_name, "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] ], @@ -298,7 +349,15 @@ def backup_history(serato: Path) -> dict: class SeratoDoctorHandler(BaseHTTPRequestHandler): def do_GET(self) -> None: - asset = STATIC_FILES.get(urlsplit(self.path).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 (KeyError, IndexError, OSError, ValueError) as error: + self._json_response(404, {"error": str(error)}) + return + asset = STATIC_FILES.get(request.path) if asset is None: self._json_response(404, {"error": "Not found"}) return @@ -322,6 +381,7 @@ class SeratoDoctorHandler(BaseHTTPRequestHandler): "/api/duplicates/apply", "/api/backups/restore", "/api/backups", + "/api/reveal", } if self.path not in allowed: self._json_response(404, {"error": "Not found"}) @@ -338,6 +398,10 @@ class SeratoDoctorHandler(BaseHTTPRequestHandler): result = analyze_paths( 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": @@ -370,6 +434,38 @@ class SeratoDoctorHandler(BaseHTTPRequestHandler): return 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: content = json.dumps(payload).encode("utf-8") self.send_response(status) diff --git a/serato_doctor/webui/app.js b/serato_doctor/webui/app.js index 1791763..64c2aa5 100644 --- a/serato_doctor/webui/app.js +++ b/serato_doctor/webui/app.js @@ -18,6 +18,10 @@ 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'); let latestAnalysis = null; let selectedDuplicateGroup = null; let previewedRepair = null; @@ -84,8 +88,8 @@ backupList.addEventListener('click', async (event) => { }); function detailLines(item) { - if (item.files) { - return item.files.map((file) => `
  • ${escapeHtml(file)}
  • `).join(''); + if (item.file_previews) { + return item.file_previews.map((file) => `
  • ${escapeHtml(file.path)}
  • `).join(''); } const lines = []; if (item.artist || item.title) lines.push(`${item.artist || 'Unknown artist'} — ${item.title || item.filename}`); @@ -138,7 +142,7 @@ function chooseDuplicate(detailKey, index) { repairPreview.hidden = true; repairMessage.textContent = ''; repairChoice.innerHTML = group.files.map((file, fileIndex) => ` - +
    `).join(''); repairPanel.hidden = false; repairPanel.scrollIntoView({behavior: 'smooth', block: 'start'}); @@ -194,7 +198,33 @@ restoreRepairButton.addEventListener('click', async () => { }); repairChoice.addEventListener('change', () => { previewedRepair = null; applyRepairButton.disabled = true; repairPreview.hidden = true; repairMessage.textContent = 'Keeper changed. Preview the plan again.'; }); -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)); }); +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)) return; const item = event.target.closest('[data-duplicate-index]'); if (item) chooseDuplicate(document.querySelector('.drill-trigger.selected')?.dataset.detail, Number(item.dataset.duplicateIndex)); }); 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)); } }); function render(data) { diff --git a/serato_doctor/webui/index.html b/serato_doctor/webui/index.html index 8975ec4..2b1ee6a 100644 --- a/serato_doctor/webui/index.html +++ b/serato_doctor/webui/index.html @@ -116,8 +116,13 @@
    Analyze your library, then choose a duplicate to preview its automatic backup.
    + - + diff --git a/serato_doctor/webui/layout-fixes.css b/serato_doctor/webui/layout-fixes.css index d121363..5c67c2f 100644 --- a/serato_doctor/webui/layout-fixes.css +++ b/serato_doctor/webui/layout-fixes.css @@ -7,3 +7,112 @@ margin-top: 28px; } } + +.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; + } +} diff --git a/tests/test_web.py b/tests/test_web.py index 8574a8c..7a68cf1 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -3,7 +3,13 @@ from pathlib import Path 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, +) def test_web_analysis_uses_production_health_pipeline(tmp_path): @@ -77,3 +83,33 @@ def test_duplicate_repair_preview_does_not_change_files(tmp_path): 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"]