diff --git a/00 PictureCollector/preview_server.py b/00 PictureCollector/preview_server.py
new file mode 100644
index 0000000..3425748
--- /dev/null
+++ b/00 PictureCollector/preview_server.py
@@ -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'
'
+ card_cls = "card"
+ else:
+ img_tag = (
+ '
⚠ soubor nedostupný
'
+ )
+ card_cls = "card broken"
+
+ camera = " ".join(filter(None, [make_, model])) or "neznámá"
+ 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''
+ f'📍 {lat:.4f}, {lon:.4f}'
+ if lat and lon else ""
+ )
+ chyby_html = f'{chyby}
' if chyby else ""
+
+ cards.append(f"""
+ """)
+
+ missing_ids = set(ids) - {r[0] for r in rows}
+ missing_note = ""
+ if missing_ids:
+ missing_note = f'ID nenalezena v DB: {sorted(missing_ids)}
'
+
+ return f"""
+
+
+
+Preview fotek ({len(rows)})
+
+
+
+
+
![]()
+
⏳
+
+
+Preview fotek
+
+ Celkem ID: {len(ids)} ·
+ Načteno z DB: {len(rows)} ·
+ Obrázky dostupné: {ok}/{len(rows)}
+
+{missing_note}
+
+{"".join(cards)}
+
+
+"""
+
+
+# ── 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()
diff --git a/POSTUP.md b/POSTUP.md
index 75c9bf3..c5cb93b 100644
--- a/POSTUP.md
+++ b/POSTUP.md
@@ -82,6 +82,18 @@ Tyto cesty **nechceme** — `wanted` zůstane FALSE, nezpracovávat:
| Kamera / kritérium | wanted | category | Počet |
|---|---|---|---|
| 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ě