Musiksammlung/src/musiksammlung/scanner_server.py
dschlueter 32c84b9edb Add phone-based EAN scanning, scanner server for cover upload, Vision-LLM integration
New features:
- EAN/Barcode can now be entered by typing or by photographing the CD sleeve;
  Vision-LLM (extract_barcode_from_image) reads the barcode from the photo
- Scanner server (port 8765) starts at the beginning of every album loop,
  serving both EAN barcode scanning and back cover upload via QR code
- Vision-LLM analyses back cover in background thread while ripping;
  priority: Vision-LLM > MusicBrainz > CDDB
- _find_abcde_mbid reads MBID from abcde temp dirs for CAA cover download
  even when the CD barcode is not linked in MusicBrainz
- Concrete copy-paste apply commands shown after each album in 'Next steps'
- _sanitize_name: whitelist approach (removes brackets and punctuation)
- qrcode added as dependency for terminal QR code display

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 14:05:59 +01:00

274 lines
9.5 KiB
Python

"""Mini-HTTP-Server für Handy-basiertes Backcover-Upload.
Startet einen lokalen HTTP-Server (kein HTTPS nötig, da nur file-input,
kein getUserMedia), der eine mobile Upload-Seite ausliefert und
hochgeladene Fotos in einer Queue bereitstellt.
"""
from __future__ import annotations
import base64
import json
import logging
import queue
import socket
import tempfile
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
logger = logging.getLogger(__name__)
_UPLOAD_HTML = """\
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CD-Backcover</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
background: #1a1a2e; color: #eee;
min-height: 100vh; display: flex; flex-direction: column;
align-items: center; justify-content: flex-start;
padding: 1.5em 1em;
}
h2 { margin-bottom: 0.3em; font-size: 1.4em; }
p { color: #aaa; margin-bottom: 1.5em; font-size: 0.95em; }
label.btn {
display: block; width: 100%; max-width: 420px;
padding: 0.9em; background: #1a7bd4; color: #fff;
text-align: center; border-radius: 10px;
font-size: 1.1em; cursor: pointer; margin-bottom: 0.8em;
}
input[type=file] { display: none; }
#preview {
width: 100%; max-width: 420px; border-radius: 10px;
display: none; margin-bottom: 0.8em;
}
button#upload-btn {
display: none; width: 100%; max-width: 420px;
padding: 0.9em; background: #27ae60; color: #fff;
border: none; border-radius: 10px; font-size: 1.1em;
cursor: pointer; margin-bottom: 0.8em;
}
button#upload-btn:disabled { background: #555; cursor: default; }
#status {
width: 100%; max-width: 420px;
padding: 0.8em 1em; border-radius: 8px;
text-align: center; display: none; font-size: 1em;
}
.ok { background: #1e4d2b; color: #7dffaa; }
.err { background: #4d1e1e; color: #ff9999; }
.wait{ background: #1e2d4d; color: #99ccff; }
</style>
</head>
<body>
<h2>CD-Backcover hochladen</h2>
<p>Fotografiere die Rückseite der CD-Hülle und lade das Bild hoch.</p>
<label class="btn" for="photo">Foto aufnehmen / auswählen</label>
<input type="file" id="photo" accept="image/*">
<img id="preview" alt="Vorschau">
<button id="upload-btn">Hochladen</button>
<div id="status"></div>
<script>
const input = document.getElementById('photo');
const preview = document.getElementById('preview');
const btn = document.getElementById('upload-btn');
const status = document.getElementById('status');
input.onchange = () => {
const file = input.files[0];
if (!file) return;
preview.src = URL.createObjectURL(file);
preview.style.display = 'block';
btn.style.display = 'block';
status.style.display = 'none';
};
btn.onclick = async () => {
const file = input.files[0];
if (!file) return;
btn.disabled = true;
status.className = 'wait';
status.style.display = 'block';
status.textContent = 'Wird hochgeladen\u2026';
const reader = new FileReader();
reader.onload = async () => {
try {
const r = await fetch('/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: reader.result })
});
const data = await r.json();
if (data.status === 'ok') {
status.className = 'ok';
status.textContent = '\u2713 Erfolgreich hochgeladen! KI analysiert das Cover\u2026';
} else {
throw new Error(data.message || 'Unbekannter Fehler');
}
} catch (e) {
status.className = 'err';
status.textContent = 'Fehler: ' + e.message;
btn.disabled = false;
}
};
reader.readAsDataURL(file);
};
</script>
</body>
</html>
"""
def _get_local_ip() -> str:
"""Ermittelt die lokale LAN-IP-Adresse."""
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.connect(("8.8.8.8", 80))
return s.getsockname()[0]
except OSError:
return "127.0.0.1"
def print_qr(url: str) -> None:
"""Gibt einen QR-Code für die URL im Terminal aus.
Zeigt alternativ nur die URL, wenn qrcode nicht installiert ist.
"""
try:
import qrcode # type: ignore[import-untyped]
qr = qrcode.QRCode(border=1)
qr.add_data(url)
qr.make(fit=True)
qr.print_ascii(invert=True)
except ImportError:
pass # qrcode optional — URL wird sowieso separat ausgegeben
class ScannerServer:
"""Lokaler HTTP-Server für Backcover-Foto-Upload vom Handy.
Beispiel:
server = ScannerServer(port=8765)
server.start()
print(server.url())
photo_path = server.get_photo(timeout=300)
server.stop()
"""
def __init__(self, port: int = 8765, upload_dir: Path | None = None) -> None:
self._port = port
self._queue: queue.Queue[Path] = queue.Queue()
self._upload_dir = upload_dir or Path(tempfile.mkdtemp(prefix="ms_scan_"))
self._server: HTTPServer | None = None
self._thread: threading.Thread | None = None
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def start(self) -> None:
"""Startet den HTTP-Server im Hintergrund-Thread."""
server_instance = self
class _Handler(BaseHTTPRequestHandler):
def log_message(self, fmt: str, *args: object) -> None: # suppress output
logger.debug("Scanner HTTP: " + fmt, *args)
def do_GET(self) -> None: # noqa: N802
if self.path in ("/", "/index.html"):
body = _UPLOAD_HTML.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
else:
self.send_response(404)
self.end_headers()
def do_POST(self) -> None: # noqa: N802
if self.path != "/upload":
self.send_response(404)
self.end_headers()
return
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length)
try:
data = json.loads(body)
img_data: str = data["image"] # "data:<mime>;base64,<bytes>"
# Mime-Typ und Erweiterung bestimmen
mime = "image/jpeg"
if img_data.startswith("data:"):
mime = img_data.split(";")[0][5:]
ext = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
}.get(mime, ".jpg")
_, b64 = img_data.split(",", 1)
img_bytes = base64.b64decode(b64)
path = server_instance._upload_dir / f"backcover{ext}"
path.write_bytes(img_bytes)
logger.info("Backcover hochgeladen: %s (%d bytes)", path, len(img_bytes))
server_instance._queue.put(path)
resp = json.dumps({"status": "ok"}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(resp)))
self.end_headers()
self.wfile.write(resp)
except Exception as exc:
logger.warning("Upload-Fehler: %s", exc)
resp = json.dumps({"status": "error", "message": str(exc)}).encode()
self.send_response(400)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(resp)))
self.end_headers()
self.wfile.write(resp)
self._server = HTTPServer(("0.0.0.0", self._port), _Handler)
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
self._thread.start()
logger.info("Scanner-Server gestartet: %s", self.url())
def stop(self) -> None:
"""Beendet den HTTP-Server."""
if self._server:
self._server.shutdown()
self._server = None
logger.info("Scanner-Server gestoppt.")
def get_photo(self, timeout: float | None = None) -> Path | None:
"""Gibt den Pfad zum hochgeladenen Foto zurück (blockierend mit Timeout).
Args:
timeout: Wartezeit in Sekunden. None = unbegrenzt. 0 = sofort.
Returns:
Pfad zur Bilddatei oder None wenn Timeout abgelaufen.
"""
try:
if timeout == 0:
return self._queue.get_nowait()
return self._queue.get(timeout=timeout)
except queue.Empty:
return None
def url(self) -> str:
"""Gibt die URL des Servers im LAN zurück."""
return f"http://{_get_local_ip()}:{self._port}"