Files
administrator 82de38f02a notebookVb
2026-06-05 07:03:21 +02:00

339 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
preview_server.py — HTTP server pro náhled fotek z DB.
Spustit v Docker kontejneru python-runner na Tower.
Spuštění:
docker exec -d python-runner python /scripts/preview_server.py
API:
GET /preview?ids=101,202,303
GET /preview?ids=101,202,303&thumb=400
GET /health
"""
import base64
import io
import socketserver
import urllib.parse
from http.server import BaseHTTPRequestHandler
import psycopg
from PIL import Image
# ── Konfigurace ───────────────────────────────────────────────────────────────
PORT = 8765
DB = dict(
host="192.168.1.76", port=5432,
user="vladimir.buzalka",
password="Vlado7309208104++",
dbname="fotky_buzalkovi"
)
# Prefix v DB (jak to zapsal collect_pictures.py na Tower1)
NAS_LINUX_PREFIX = "/mnt/user/ZalohaVsechObrazku/"
# Cesta uvnitř kontejneru (NFS mount přidaný v Unraid Docker UI)
NAS_CONTAINER_PATH = "/mnt/ZalohaVsechObrazku/"
# ── DB dotaz ──────────────────────────────────────────────────────────────────
QUERY = """
SELECT
z.id,
z.cesta_zalohy,
z.velikost,
p.taken_at,
p.taken_at_source,
p.camera_make,
p.camera_model,
p.width,
p.height,
p.megapixels,
p.gps_lat,
p.gps_lon,
COALESCE(
(SELECT string_agg(e.error_code, ', ')
FROM photo_errors e WHERE e.photo_id = p.id),
''
) AS chyby
FROM zaloha_obrazku z
JOIN photos p ON p.zaloha_id = z.id
WHERE z.id = ANY(%s)
"""
def linux_to_container(p: str) -> str:
rel = p.removeprefix(NAS_LINUX_PREFIX)
return NAS_CONTAINER_PATH + rel
def fetch_rows(ids: list[int]) -> list:
conn = psycopg.connect(**DB)
cur = conn.cursor()
cur.execute(QUERY, (ids,))
rows = cur.fetchall()
conn.close()
order = {rid: i for i, rid in enumerate(ids)}
return sorted(rows, key=lambda r: order.get(r[0], 9999))
# ── Thumbnaily ────────────────────────────────────────────────────────────────
def make_thumb_b64(path: str, size: int) -> str | None:
try:
img = Image.open(path)
img.thumbnail((size, size), Image.LANCZOS)
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=82)
return base64.b64encode(buf.getvalue()).decode()
except Exception:
return None
# ── HTML ──────────────────────────────────────────────────────────────────────
def format_size(n) -> str:
if n is None:
return "?"
for unit in ("B", "kB", "MB", "GB"):
if n < 1024:
return f"{n:.0f} {unit}"
n /= 1024
return f"{n:.1f} GB"
def render_html(rows: list, ids: list[int], thumb_size: int) -> str:
cards = []
ok = 0
for i, row in enumerate(rows, 1):
(rid, cesta, velikost, taken_at, taken_src,
make_, model, width, height, mp, lat, lon, chyby) = row
container_path = linux_to_container(cesta)
b64 = make_thumb_b64(container_path, thumb_size)
if b64:
ok += 1
img_tag = f'<img src="data:image/jpeg;base64,{b64}" alt="foto" data-id="{rid}" onclick="openLightbox(this)">'
card_cls = "card"
else:
img_tag = (
'<div class="no-img">⚠ soubor nedostupný</div>'
)
card_cls = "card broken"
camera = " ".join(filter(None, [make_, model])) or "<em>neznámá</em>"
date_str = taken_at.strftime("%Y-%m-%d %H:%M") if taken_at else "?"
res = f"{width}×{height}" if width and height else "?"
mp_str = f"{mp:.1f} Mpx" if mp else ""
gps_link = (
f'<a href="https://maps.google.com/?q={lat},{lon}" target="_blank">'
f'📍 {lat:.4f}, {lon:.4f}</a>'
if lat and lon else ""
)
chyby_html = f'<div class="err">{chyby}</div>' if chyby else ""
cards.append(f"""
<div class="{card_cls}">
<div class="thumb">{img_tag}</div>
<div class="meta">
<div class="idx">id={rid} &nbsp;#{i}</div>
<div><strong>{date_str}</strong> <span class="src">({taken_src})</span></div>
<div>📷 {camera}</div>
<div>{res} {mp_str} &nbsp; {format_size(velikost)}</div>
{"<div>" + gps_link + "</div>" if gps_link else ""}
{chyby_html}
<div class="path" title="{container_path}">{container_path}</div>
</div>
</div>""")
missing_ids = set(ids) - {r[0] for r in rows}
missing_note = ""
if missing_ids:
missing_note = f'<div class="warn">ID nenalezena v DB: {sorted(missing_ids)}</div>'
return f"""<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="utf-8">
<title>Preview fotek ({len(rows)})</title>
<style>
body {{ font-family: sans-serif; background: #1a1a2e; color: #eee; margin: 0; padding: 16px; }}
h1 {{ color: #e0c97f; margin-bottom: 4px; }}
.info {{ color: #aaa; margin-bottom: 16px; font-size: .9em; }}
.warn {{ color: #f99; margin-bottom: 12px; }}
.grid {{ display: flex; flex-wrap: wrap; gap: 16px; }}
.card {{ background: #16213e; border-radius: 8px; overflow: hidden;
width: {thumb_size + 24}px; box-shadow: 0 2px 8px #0006; }}
.card.broken {{ opacity: .55; }}
.thumb {{ display:flex; align-items:center; justify-content:center;
background:#0f3460; min-height: 120px; }}
.thumb img {{ max-width:100%; max-height:{thumb_size}px; display:block; }}
.no-img {{ color:#f88; padding:20px; text-align:center; font-size:.85em; }}
.meta {{ padding: 8px 12px; font-size: .78em; line-height: 1.75; }}
.meta strong {{ font-size: 1em; }}
.idx {{ float:right; color:#666; font-size:.75em; }}
.src {{ color:#888; }}
.err {{ color:#f99; }}
.path {{ color:#555; word-break:break-all; margin-top:4px; font-size:.7em; }}
a {{ color:#7ec8e3; }}
.thumb img {{ cursor: zoom-in; }}
/* Lightbox */
#lightbox {{
display: none; position: fixed; inset: 0;
background: #000c; z-index: 1000;
align-items: center; justify-content: center;
}}
#lightbox.active {{ display: flex; }}
#lightbox img {{
max-width: 95vw; max-height: 95vh;
object-fit: contain;
border-radius: 4px;
box-shadow: 0 0 40px #000a;
cursor: zoom-out;
}}
</style>
</head>
<body>
<div id="lightbox" onclick="closeLightbox()">
<img id="lightbox-img" src="" alt="" onclick="event.stopPropagation()">
<div id="lightbox-spin" style="display:none;color:#fff;font-size:2em;">⏳</div>
</div>
<script>
function openLightbox(thumb) {{
var lb = document.getElementById('lightbox');
var lbImg = document.getElementById('lightbox-img');
var spin = document.getElementById('lightbox-spin');
var id = thumb.dataset.id;
lbImg.src = '';
lbImg.style.display = 'none';
spin.style.display = 'block';
lb.classList.add('active');
var full = new Image();
full.onload = function() {{
lbImg.src = full.src;
lbImg.style.display = 'block';
spin.style.display = 'none';
}};
full.src = '/image?id=' + id;
}}
function closeLightbox() {{
document.getElementById('lightbox').classList.remove('active');
document.getElementById('lightbox-img').src = '';
}}
document.addEventListener('keydown', e => {{ if (e.key === 'Escape') closeLightbox(); }});
</script>
<h1>Preview fotek</h1>
<div class="info">
Celkem ID: <strong>{len(ids)}</strong> &nbsp;·&nbsp;
Načteno z DB: <strong>{len(rows)}</strong> &nbsp;·&nbsp;
Obrázky dostupné: <strong>{ok}/{len(rows)}</strong>
</div>
{missing_note}
<div class="grid">
{"".join(cards)}
</div>
</body>
</html>"""
# ── HTTP handler ──────────────────────────────────────────────────────────────
class Handler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
print(f"[{self.address_string()}] {format % args}", flush=True)
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
# ── /health ──────────────────────────────────────────────────────────
if parsed.path == "/health":
self._text(200, "OK")
return
# ── /image ───────────────────────────────────────────────────────────
if parsed.path == "/image":
params = urllib.parse.parse_qs(parsed.query)
try:
zid = int(params.get("id", [""])[0])
except ValueError:
self._text(400, "Chybí id")
return
conn = psycopg.connect(**DB)
cur = conn.cursor()
cur.execute(
"SELECT z.cesta_zalohy FROM zaloha_obrazku z JOIN photos p ON p.zaloha_id = z.id WHERE z.id = %s",
(zid,)
)
row = cur.fetchone()
conn.close()
if not row:
self._text(404, "ID nenalezeno")
return
path = linux_to_container(row[0])
try:
data = open(path, "rb").read()
self.send_response(200)
self.send_header("Content-Type", "image/jpeg")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
except Exception as ex:
self._text(404, f"Soubor nedostupný: {ex}")
return
# ── /preview ─────────────────────────────────────────────────────────
if parsed.path != "/preview":
self._text(404, "Not found. Použij /preview?ids=101,202,303")
return
params = urllib.parse.parse_qs(parsed.query)
ids_raw = params.get("ids", [""])[0]
thumb = int(params.get("thumb", ["320"])[0])
try:
ids = [int(x.strip()) for x in ids_raw.split(",") if x.strip()]
except ValueError:
self._text(400, "Chybné IDs — očekávaný formát: ?ids=101,202,303")
return
if not ids:
self._text(400, "Chybí IDs — např. /preview?ids=101,202,303")
return
print(f"{len(ids)} IDs, thumb={thumb}px", flush=True)
rows = fetch_rows(ids)
html = render_html(rows, ids, thumb)
encoded = 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(encoded)))
self.end_headers()
self.wfile.write(encoded)
def _text(self, code: int, msg: str):
body = msg.encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
# ── Main ──────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
print(f"Preview server běží na http://0.0.0.0:{PORT}", flush=True)
print(f"Test: http://tower:{PORT}/health", flush=True)
print(f"Použití: http://tower:{PORT}/preview?ids=101,202,303&thumb=400", flush=True)
socketserver.TCPServer.allow_reuse_address = True
with socketserver.TCPServer(("", PORT), Handler) as httpd:
httpd.serve_forever()