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>
This commit is contained in:
parent
6c12510f76
commit
32c84b9edb
15 changed files with 1027 additions and 92 deletions
274
src/musiksammlung/scanner_server.py
Normal file
274
src/musiksammlung/scanner_server.py
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
"""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}"
|
||||
Loading…
Add table
Add a link
Reference in a new issue