Add duplicate audio comparison controls
This commit is contained in:
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user