Add duplicate audio comparison controls

This commit is contained in:
Philip Guzman
2026-07-01 16:12:35 -07:00
parent 139822f8a5
commit 4315d8d4d4
6 changed files with 316 additions and 8 deletions
+32
View File
@@ -0,0 +1,32 @@
# Audio comparison for duplicate review
## Problem
Filenames alone are weak evidence. DJs need to hear each candidate and locate it
on disk before deciding which copy is authoritative.
## Design
Exact-duplicate and cloud-conflict groups expose a shared audio player with a
preview action for each file. Switching files reuses the same player, making
back-to-back comparison quick. A separate action reveals the selected file in
macOS Finder.
The browser never receives unrestricted filesystem access. Each analyzed audio
path gets a short, process-local HMAC token. Preview and Finder endpoints reject
altered, expired, missing, non-audio, or otherwise unsigned paths. Audio serving
supports HTTP byte ranges so playback can seek without loading an entire track.
## Edge cases
- A file moved after analysis is rejected.
- Forged or stale tokens cannot select another local file.
- Preview controls remain useful if autoplay is blocked because native audio
controls stay visible.
- Finder failures are reported beside the button without affecting analysis.
## Tests
- Valid signed audio resolves to the analyzed file.
- A modified token is rejected.
- Duplicate detail payloads include preview and Finder controls for every file.
+98 -2
View File
@@ -1,12 +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, Optional 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.crate_parser import load_library_crates
from serato_doctor.database_parser import parse_database from serato_doctor.database_parser import parse_database
@@ -28,6 +34,7 @@ from serato_doctor.repair import (
MAX_REQUEST_BYTES = 64 * 1024 MAX_REQUEST_BYTES = 64 * 1024
DETAIL_LIMIT = 50 DETAIL_LIMIT = 50
FILE_TOKEN_SECRET = secrets.token_bytes(32)
STATIC_FILES = { STATIC_FILES = {
"/": ("index.html", "text/html; charset=utf-8"), "/": ("index.html", "text/html; charset=utf-8"),
"/app.css": ("app.css", "text/css; charset=utf-8"), "/app.css": ("app.css", "text/css; charset=utf-8"),
@@ -41,6 +48,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:
@@ -159,6 +204,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]
], ],
@@ -174,6 +222,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]
], ],
@@ -298,7 +349,15 @@ def backup_history(serato: Path) -> dict:
class SeratoDoctorHandler(BaseHTTPRequestHandler): class SeratoDoctorHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: 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: if asset is None:
self._json_response(404, {"error": "Not found"}) self._json_response(404, {"error": "Not found"})
return return
@@ -322,6 +381,7 @@ class SeratoDoctorHandler(BaseHTTPRequestHandler):
"/api/duplicates/apply", "/api/duplicates/apply",
"/api/backups/restore", "/api/backups/restore",
"/api/backups", "/api/backups",
"/api/reveal",
} }
if self.path not in allowed: if self.path not in allowed:
self._json_response(404, {"error": "Not found"}) self._json_response(404, {"error": "Not found"})
@@ -338,6 +398,10 @@ class SeratoDoctorHandler(BaseHTTPRequestHandler):
result = analyze_paths( result = analyze_paths(
Path(payload["serato"]), Path(payload["music"]), roots Path(payload["serato"]), Path(payload["music"]), roots
) )
elif self.path == "/api/reveal":
path = _verified_audio(payload["token"])
subprocess.run(["open", "-R", str(path)], check=True)
result = {"revealed": str(path)}
elif self.path == "/api/backups": elif self.path == "/api/backups":
result = backup_history(Path(payload["serato"])) result = backup_history(Path(payload["serato"]))
elif self.path == "/api/backups/restore": elif self.path == "/api/backups/restore":
@@ -370,6 +434,38 @@ class SeratoDoctorHandler(BaseHTTPRequestHandler):
return return
self._json_response(200, result) self._json_response(200, result)
def _audio_response(self, path: Path) -> None:
size = path.stat().st_size
start, end = 0, size - 1
status = 200
range_header = self.headers.get("Range")
if range_header and range_header.startswith("bytes="):
raw_start, _, raw_end = range_header[6:].partition("-")
start = int(raw_start or 0)
end = min(int(raw_end) if raw_end else end, end)
if start < 0 or start > end:
raise ValueError("Invalid audio range")
status = 206
length = end - start + 1
self.send_response(status)
self.send_header(
"Content-Type", mimetypes.guess_type(path.name)[0] or "audio/mpeg"
)
self.send_header("Accept-Ranges", "bytes")
self.send_header("Content-Length", str(length))
if status == 206:
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
self.end_headers()
with path.open("rb") as audio:
audio.seek(start)
remaining = length
while remaining:
chunk = audio.read(min(64 * 1024, remaining))
if not chunk:
break
self.wfile.write(chunk)
remaining -= len(chunk)
def _json_response(self, status: int, payload: dict) -> None: def _json_response(self, status: int, payload: dict) -> None:
content = json.dumps(payload).encode("utf-8") content = json.dumps(payload).encode("utf-8")
self.send_response(status) self.send_response(status)
+34 -4
View File
@@ -18,6 +18,10 @@ const restoreRepairButton = document.querySelector('#restore-repair');
const loadBackupsButton = document.querySelector('#load-backups'); const loadBackupsButton = document.querySelector('#load-backups');
const backupSummary = document.querySelector('#backup-summary'); const backupSummary = document.querySelector('#backup-summary');
const backupList = document.querySelector('#backup-list'); 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 latestAnalysis = null;
let selectedDuplicateGroup = null; let selectedDuplicateGroup = null;
let previewedRepair = null; let previewedRepair = null;
@@ -84,8 +88,8 @@ backupList.addEventListener('click', async (event) => {
}); });
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}`);
@@ -138,7 +142,7 @@ function chooseDuplicate(detailKey, index) {
repairPreview.hidden = true; repairPreview.hidden = true;
repairMessage.textContent = ''; repairMessage.textContent = '';
repairChoice.innerHTML = group.files.map((file, fileIndex) => ` repairChoice.innerHTML = group.files.map((file, fileIndex) => `
<label class="keeper-option"><input type="radio" name="keeper" value="${escapeHtml(file)}" ${fileIndex === 0 ? 'checked' : ''}><span><strong>${fileIndex === 0 ? 'Keep this file' : 'Keep instead'}</strong><small>${escapeHtml(file)}</small></span></label> <div class="keeper-option"><label><input type="radio" name="keeper" value="${escapeHtml(file)}" ${fileIndex === 0 ? 'checked' : ''}><span><strong>${fileIndex === 0 ? 'Keep this file' : 'Keep instead'}</strong><small>${escapeHtml(file)}</small></span></label><div class="file-actions"><button type="button" data-audio-url="${escapeHtml(group.file_previews[fileIndex].audio_url)}" data-audio-name="${escapeHtml(file)}">▶ Play preview</button><button type="button" data-reveal-token="${escapeHtml(group.file_previews[fileIndex].reveal_token)}">Show in Finder</button></div></div>
`).join(''); `).join('');
repairPanel.hidden = false; repairPanel.hidden = false;
repairPanel.scrollIntoView({behavior: 'smooth', block: 'start'}); 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.'; }); 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)); } }); 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) { function render(data) {
+6 -1
View File
@@ -116,8 +116,13 @@
<div id="backup-summary" class="backup-summary">Analyze your library, then choose a duplicate to preview its automatic backup.</div> <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> <div id="backup-list" class="backup-list"></div>
</section> </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>
</main> </main>
</div> </div>
<script src="/app.js?v=3" defer></script> <script src="/app.js?v=4" defer></script>
</body> </body>
</html> </html>
+109
View File
@@ -7,3 +7,112 @@
margin-top: 28px; 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;
}
}
+37 -1
View File
@@ -3,7 +3,13 @@ from pathlib import Path
import pytest import pytest
from serato_doctor.web import STATIC_FILES, analyze_paths, duplicate_repair from serato_doctor.web import (
STATIC_FILES,
_file_token,
_verified_audio,
analyze_paths,
duplicate_repair,
)
def test_web_analysis_uses_production_health_pipeline(tmp_path): 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 result["database_v2_modified"] is False
assert second.read_bytes() == b"second" assert second.read_bytes() == b"second"
assert not second.is_symlink() assert not second.is_symlink()
def test_audio_preview_tokens_only_open_signed_audio(tmp_path):
track = tmp_path / "Track.mp3"
track.write_bytes(b"audio")
token = _file_token(track)
assert _verified_audio(token) == track
with pytest.raises(ValueError, match="Invalid or expired"):
_verified_audio(token + "changed")
def test_duplicate_details_include_preview_and_finder_controls(tmp_path):
serato = tmp_path / "_Serato_"
serato.mkdir()
music = tmp_path / "Music"
first = music / "A" / "Track.mp3"
second = music / "B" / "Track.mp3"
first.parent.mkdir(parents=True)
second.parent.mkdir(parents=True)
first.write_bytes(b"first")
second.write_bytes(b"second")
result = analyze_paths(serato, music)
group = result["details"]["duplicate_filenames"]["items"][0]
assert len(group["file_previews"]) == 2
assert group["file_previews"][0]["audio_url"].startswith("/api/audio?")
assert group["file_previews"][0]["reveal_token"]