notebookVb
This commit is contained in:
@@ -0,0 +1,338 @@
|
|||||||
|
#!/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} #{i}</div>
|
||||||
|
<div><strong>{date_str}</strong> <span class="src">({taken_src})</span></div>
|
||||||
|
<div>📷 {camera}</div>
|
||||||
|
<div>{res} {mp_str} {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> ·
|
||||||
|
Načteno z DB: <strong>{len(rows)}</strong> ·
|
||||||
|
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()
|
||||||
@@ -82,6 +82,18 @@ Tyto cesty **nechceme** — `wanted` zůstane FALSE, nezpracovávat:
|
|||||||
| Kamera / kritérium | wanted | category | Počet |
|
| Kamera / kritérium | wanted | category | Počet |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| BolyMedia SG520 | TRUE | Fotopast | 42 688 |
|
| BolyMedia SG520 | TRUE | Fotopast | 42 688 |
|
||||||
|
| Apple iPhones + iPad (viz update_wanted.py) | TRUE | Rodina | — |
|
||||||
|
| 15 kompaktů (Panasonic, Canon, GoPro, …) | TRUE | Rodina | — |
|
||||||
|
| Samsung + profesionální (Nikon D5/D4…) | TRUE | Rodina | — |
|
||||||
|
| 32 dalších fotoaparátů (update_wanted_batch2.py) | TRUE | Rodina | 13 056 |
|
||||||
|
|
||||||
|
### Vyloučené skenery (wanted=FALSE, category='Skener-nechceme')
|
||||||
|
|
||||||
|
| Skener | Počet | Poznámka |
|
||||||
|
|---|---|---|
|
||||||
|
| CanoScan 8800F (`make IS NULL`) | 1 567 | Skeny, nechceme |
|
||||||
|
|
||||||
|
> Ostatní skenery (LiDE 300/210/100, 4400F, HP Scanjet G2710, pls3015, djf300/2100/2200, MP210/190) — **dosud nerozhodnuto**.
|
||||||
|
|
||||||
### Na řadě
|
### Na řadě
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user