"""Storyboard Studio – Web-UI für GPT Image 2 + Seedance Hybrid-Videos."""

from __future__ import annotations

import json
import mimetypes
import threading
import uuid
import webbrowser
from datetime import datetime
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse

ROOT = Path(__file__).resolve().parents[1]

try:
    from jam.jampulse_desktop import studio_root

    STUDIO_ROOT = studio_root() / "StoryboardStudio"
except ImportError:
    STUDIO_ROOT = Path.home() / "Desktop" / "VidPuls" / "StoryboardStudio"

JOBS_DIR = STUDIO_ROOT / "jobs"
WORK_ROOT = STUDIO_ROOT / "work"
OUTPUT_DIR = STUDIO_ROOT / "output"
PORT = 8795
SITE_URL = f"http://127.0.0.1:{PORT}/"

_lock = threading.Lock()
_active_job: str | None = None


def ensure_dirs() -> None:
    for d in (STUDIO_ROOT, JOBS_DIR, WORK_ROOT, OUTPUT_DIR):
        d.mkdir(parents=True, exist_ok=True)


def _job_path(job_id: str) -> Path:
    return JOBS_DIR / f"{job_id}.json"


def save_job(job: dict) -> None:
    ensure_dirs()
    _job_path(job["id"]).write_text(
        json.dumps(job, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )


def load_job(job_id: str) -> dict | None:
    p = _job_path(job_id)
    if not p.is_file():
        return None
    try:
        return json.loads(p.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError):
        return None


def list_jobs(limit: int = 20) -> list[dict]:
    ensure_dirs()
    files = sorted(JOBS_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
    out: list[dict] = []
    for p in files[:limit]:
        try:
            out.append(json.loads(p.read_text(encoding="utf-8")))
        except (json.JSONDecodeError, OSError):
            pass
    return out


def _set_job(job_id: str, **fields) -> None:
    job = load_job(job_id)
    if not job:
        return
    job.update(fields)
    job["updated_at"] = datetime.now().isoformat(timespec="seconds")
    save_job(job)


def _run_job(job_id: str) -> None:
    global _active_job
    job = load_job(job_id)
    if not job:
        return
    from jam import storyboard_api

    work = WORK_ROOT / job_id
    upload_path: Path | None = None
    up = str(job.get("storyboard_upload") or "").strip()
    if up and Path(up).is_file():
        upload_path = Path(up)
    hero_upload_path: Path | None = None
    hup = str(job.get("hero_upload") or "").strip()
    if hup and Path(hup).is_file():
        hero_upload_path = Path(hup)
    try:
        _set_job(job_id, status="running", message="Start …")
        result = storyboard_api.run_storyboard_job(
            work,
            concept=str(job.get("concept") or ""),
            motion_prompt=str(job.get("motion") or ""),
            mode=str(job.get("mode") or "grid"),
            preset=str(job.get("preset") or ""),
            engine=str(job.get("engine") or "auto"),
            duration=int(job.get("duration") or 8),
            aspect_ratio=str(job.get("aspect_ratio") or "9:16"),
            storyboard_path=upload_path,
            hero_upload=hero_upload_path,
            hero_shot=bool(job.get("hero_shot")),
            hero_concept=str(job.get("hero_concept") or ""),
            hero_motion=str(job.get("hero_motion") or ""),
            hero_duration=int(job.get("hero_duration") or 4),
            add_music=bool(job.get("add_music")),
            progress=lambda m: _set_job(job_id, message=m),
        )
        out_video = Path(result["video"])
        dest = OUTPUT_DIR / f"{job_id}.mp4"
        desktop_path = None
        if out_video.is_file():
            import shutil

            shutil.copy2(out_video, dest)
            result["local_video"] = str(dest)
            desktop_path = storyboard_api.export_to_desktop(dest, job_id)
            if result.get("poster"):
                try:
                    from jam.jampulse_desktop import videos_storyboard_dir
                    import shutil

                    poster_dest = videos_storyboard_dir() / f"storyboard_{job_id}_poster.jpg"
                    shutil.copy2(result["poster"], poster_dest)
                except (ImportError, OSError):
                    pass
        _set_job(
            job_id,
            status="done",
            message="Fertig",
            result=result,
            storyboard=result.get("storyboard"),
            hero=result.get("hero"),
            poster=result.get("poster"),
            local_video=str(dest) if dest.is_file() else result.get("video"),
            desktop_video=str(desktop_path) if desktop_path else "",
            engine=result.get("engine"),
        )
    except Exception as e:
        _set_job(job_id, status="error", message=str(e)[:400], error=str(e)[:500])
    finally:
        with _lock:
            if _active_job == job_id:
                _active_job = None


def start_job(payload: dict) -> dict:
    global _active_job
    from jam import storyboard_api

    ensure_dirs()
    has_upload = bool(str(payload.get("storyboard_base64") or "").strip())
    has_hero_upload = bool(str(payload.get("hero_base64") or "").strip())
    hero_shot = bool(payload.get("hero_shot"))
    ok, msg = storyboard_api.validate_ready(
        has_upload=has_upload,
        has_hero_upload=has_hero_upload,
        hero_shot=hero_shot,
    )
    if not ok:
        return {"ok": False, "error": msg}

    with _lock:
        if _active_job:
            active = load_job(_active_job)
            if active and active.get("status") in ("queued", "running"):
                return {
                    "ok": False,
                    "error": f"Job {_active_job} läuft noch – bitte warten.",
                }

    jid = uuid.uuid4().hex[:12]
    work = WORK_ROOT / jid
    work.mkdir(parents=True, exist_ok=True)
    upload_file = ""
    b64 = str(payload.get("storyboard_base64") or "").strip()
    if b64:
        import base64

        raw = base64.b64decode(b64.split(",", 1)[-1])
        up = work / "upload_storyboard.png"
        up.write_bytes(raw)
        upload_file = str(up)

    hero_upload_file = ""
    hb64 = str(payload.get("hero_base64") or "").strip()
    if hb64:
        import base64

        raw_h = base64.b64decode(hb64.split(",", 1)[-1])
        hup = work / "upload_hero.png"
        hup.write_bytes(raw_h)
        hero_upload_file = str(hup)

    job = {
        "id": jid,
        "status": "queued",
        "message": "In Warteschlange",
        "concept": str(payload.get("concept") or "").strip(),
        "motion": str(payload.get("motion") or "").strip(),
        "preset": str(payload.get("preset") or "").strip(),
        "mode": str(payload.get("mode") or "grid"),
        "engine": str(payload.get("engine") or "auto"),
        "duration": int(payload.get("duration") or 8),
        "aspect_ratio": str(payload.get("aspect_ratio") or "9:16"),
        "add_music": bool(payload.get("add_music")),
        "hero_shot": hero_shot,
        "hero_concept": str(payload.get("hero_concept") or "").strip(),
        "hero_motion": str(payload.get("hero_motion") or "").strip(),
        "hero_duration": int(payload.get("hero_duration") or 4),
        "storyboard_upload": upload_file,
        "hero_upload": hero_upload_file,
        "created_at": datetime.now().isoformat(timespec="seconds"),
        "updated_at": datetime.now().isoformat(timespec="seconds"),
    }
    save_job(job)
    with _lock:
        _active_job = jid
    t = threading.Thread(target=_run_job, args=(jid,), daemon=True)
    t.start()
    return {"ok": True, "jobId": jid, "job": job}


def studio_html() -> str:
    from jam import storyboard_api

    presets = storyboard_api.PRESETS
    preset_opts = "".join(
        f'<option value="{k}">{v["name"]}</option>' for k, v in presets.items()
    )
    st = storyboard_api.providers_status()
    warn = ""
    if not st["openai"]:
        warn += '<p class="warn">⚠ openai_api_key fehlt – Storyboard-Generierung nicht möglich (Upload weiterhin OK).</p>'
    if not st["runway"] and not st["pixverse"]:
        warn += '<p class="warn">⚠ runway_api_key oder pixverse_api_key fehlt – Animation nicht möglich.</p>'
    keys = (
        f"GPT Image: {'✓' if st['openai'] else '✗'} · "
        f"Seedance/Runway: {'✓' if st['runway'] else '✗'} · "
        f"PixVerse: {'✓' if st['pixverse'] else '✗'}"
    )
    from jam.vidpuls_lan import lan_urls

    urls = lan_urls()
    lan_line = f"WLAN-Link für alle: <a href=\"{urls['storyboard']}\" style=\"color:#a855f7\">{urls['storyboard']}</a>"
    return f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Storyboard Studio – VidPuls</title>
<style>
:root{{--bg:#07050f;--card:#12101f;--line:rgba(168,85,247,.35);--txt:#f1ecff;--muted:#9ca3af;--acc:#a855f7;--ok:#34d399}}
*{{box-sizing:border-box}}body{{margin:0;font-family:Segoe UI,sans-serif;background:var(--bg);color:var(--txt);min-height:100vh}}
.wrap{{max-width:720px;margin:0 auto;padding:1.25rem}}
h1{{font-size:1.5rem;background:linear-gradient(90deg,#a855f7,#ec4899);-webkit-background-clip:text;-webkit-text-fill-color:transparent}}
.sub{{color:var(--muted);font-size:.9rem;line-height:1.5}}
.card{{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:1.1rem;margin:1rem 0}}
label{{display:block;font-size:.8rem;color:var(--muted);margin:.65rem 0 .25rem}}
input,select,textarea{{width:100%;padding:.55rem .7rem;border-radius:10px;border:1px solid var(--line);background:#0c0a16;color:var(--txt);font:inherit}}
textarea{{min-height:72px;resize:vertical}}
.row{{display:grid;grid-template-columns:1fr 1fr;gap:.75rem}}
.btn{{display:inline-block;padding:.65rem 1.1rem;border:none;border-radius:12px;font-weight:600;cursor:pointer;background:linear-gradient(135deg,#a855f7,#ec4899);color:#fff;margin-top:.75rem}}
.btn:disabled{{opacity:.45;cursor:not-allowed}}
.badge{{display:inline-block;font-size:.72rem;padding:.2rem .5rem;border-radius:6px;background:rgba(52,211,153,.15);color:var(--ok);margin-left:.5rem}}
.status{{font-size:.85rem;color:var(--muted);margin-top:.5rem}}
.job{{border-top:1px solid var(--line);padding:.75rem 0}}
.job b{{color:var(--acc)}}
img,video{{max-width:100%;border-radius:12px;margin-top:.5rem}}
.keys{{font-size:.78rem;color:var(--muted);margin-top:.35rem}}
.warn{{color:#fbbf24;font-size:.82rem;margin:.5rem 0}}
.hint{{font-size:.78rem;color:#f472b6;margin-top:.25rem}}
.flow{{font-size:.82rem;color:var(--muted);line-height:1.6}}
.flow b{{color:var(--txt)}}
.btn-sm{{padding:.4rem .75rem;font-size:.8rem;margin-top:.35rem;margin-right:.35rem;background:#1f1633;border:1px solid var(--line);color:var(--txt)}}
.check{{display:flex;align-items:center;gap:.5rem;margin-top:.65rem;font-size:.85rem;color:var(--muted)}}
.check input{{width:auto}}
</style>
</head>
<body>
<div class="wrap">
  <h1>Storyboard Studio</h1>
  <p class="sub">Pollo-Style Hybrid: <b>GPT Image 2</b> Storyboard → <b>Seedance 2.0</b> Animation → lokaler Export.</p>
  <p class="keys">{keys}</p>
  <p class="keys">{lan_line}</p>
  {warn}

  <div class="card flow">
    <b>Pollo-Style (Hero + Storyboard)</b><br>
    1. <b>Hero-Shot</b> – einzelnes Keyframe (z.B. goldene Trophäe)<br>
    2. <b>Storyboard</b> – 3×3 Raster mit der Story<br>
    3. Beides animieren → Intro + Hauptteil → fertiges MP4
  </div>

  <div class="card">
    <label>Vorlage</label>
    <select id="preset">
      <option value="">Eigene Idee</option>
      {preset_opts}
    </select>

    <label class="check"><input type="checkbox" id="heroShot" checked/> Hero-Shot Intro (wie Pollo-Werbung)</label>

    <div id="heroBox">
      <label>Hero-Motiv (einzelnes Bild oben)</label>
      <textarea id="heroConcept" placeholder="z.B. goldene Trophäe auf Küchentheke …"></textarea>
      <label>Hero-Bewegung (Seedance Intro)</label>
      <textarea id="heroMotion" placeholder="Langsamer Dolly auf das Hero-Motiv …"></textarea>
      <div class="row">
        <div>
          <label>Hero-Dauer (Sek.)</label>
          <input id="heroDuration" type="number" min="4" max="6" value="4"/>
        </div>
        <div>
          <label>Hero-Bild Upload (optional)</label>
          <input id="heroUpload" type="file" accept="image/png,image/jpeg,image/webp"/>
        </div>
      </div>
    </div>

    <label>Konzept / Storyboard-Szenen (3×3)</label>
    <textarea id="concept" placeholder="z.B. Pixar-Koch backt goldene Trophäe als Kuchen …"></textarea>
    <button type="button" class="btn-sm" id="enhance">Prompt verbessern (GPT)</button>

    <label>Bewegung (Seedance-Prompt)</label>
    <textarea id="motion" placeholder="Optional – leer = Vorlage nutzen"></textarea>

    <label>Eigenes Storyboard (optional, 3×3-Raster)</label>
    <input id="upload" type="file" accept="image/png,image/jpeg,image/webp"/>
    <p class="hint">Überspringt GPT Image – direkt zu Seedance/PixVerse.</p>

    <div class="row">
      <div>
        <label>Modus</label>
        <select id="mode">
          <option value="grid">Schnell: ganzes Storyboard → 1 Video</option>
          <option value="frames">Präzise: 9 Panels einzeln → Schnitt</option>
        </select>
        <p class="hint" id="costHint">Präzise-Modus: ~9× API-Kosten.</p>
      </div>
      <div>
        <label>Engine</label>
        <select id="engine">
          <option value="auto">Auto (Runway → PixVerse)</option>
          <option value="runway">Runway Seedance 2.0</option>
          <option value="pixverse">PixVerse</option>
        </select>
      </div>
    </div>

    <div class="row">
      <div>
        <label>Dauer (Sek.)</label>
        <input id="duration" type="number" min="4" max="15" value="8"/>
      </div>
      <div>
        <label>Format</label>
        <select id="aspect">
          <option value="9:16">9:16 TikTok</option>
          <option value="16:9">16:9 YouTube</option>
          <option value="1:1">1:1 Quadrat</option>
        </select>
      </div>
    </div>

    <label class="check"><input type="checkbox" id="music"/> Leise Hintergrundmusik (lokal)</label>

    <button class="btn" id="go">Video erstellen</button>
    <div class="status" id="status"></div>
  </div>

  <div class="card" id="preview" style="display:none">
    <h3 style="margin:0 0 .5rem">Ergebnis</h3>
    <div id="previewBody"></div>
  </div>

  <div class="card">
    <h3 style="margin:0 0 .5rem">Letzte Jobs</h3>
    <div id="jobs">Lade …</div>
  </div>
</div>
<script>
const $ = id => document.getElementById(id);
const presets = {json.dumps({k: {"concept": v["concept"], "motion": v["motion"], "hero": v.get("hero", ""), "hero_motion": v.get("hero_motion", "")} for k, v in presets.items()}, ensure_ascii=False)};
let uploadB64 = '';
let heroB64 = '';

function toggleHeroBox() {{
  $('heroBox').style.display = $('heroShot').checked ? 'block' : 'none';
}}
$('heroShot').onchange = toggleHeroBox;
toggleHeroBox();

$('mode').onchange = () => {{
  $('costHint').style.display = $('mode').value === 'frames' ? 'block' : 'none';
}};
$('mode').dispatchEvent(new Event('change'));

$('upload').onchange = async (e) => {{
  const f = e.target.files && e.target.files[0];
  if (!f) {{ uploadB64 = ''; return; }}
  const buf = await f.arrayBuffer();
  const bytes = new Uint8Array(buf);
  let bin = '';
  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
  uploadB64 = 'data:' + f.type + ';base64,' + btoa(bin);
}};

$('heroUpload').onchange = async (e) => {{
  const f = e.target.files && e.target.files[0];
  if (!f) {{ heroB64 = ''; return; }}
  const buf = await f.arrayBuffer();
  const bytes = new Uint8Array(buf);
  let bin = '';
  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
  heroB64 = 'data:' + f.type + ';base64,' + btoa(bin);
}};

$('enhance').onclick = async () => {{
  const t = $('concept').value.trim();
  if (!t) return;
  $('enhance').disabled = true;
  const r = await api('/api/enhance', {{ text: t, kind: 'storyboard' }});
  if (r.ok && r.text) $('concept').value = r.text;
  $('enhance').disabled = false;
}};

$('preset').onchange = () => {{
  const key = $('preset').value;
  const p = presets[key];
  if (!p) return;
  if (!$('concept').value.trim()) $('concept').value = p.concept;
  if (!$('motion').value.trim()) $('motion').value = p.motion;
  if (p.hero) {{
    if (!$('heroConcept').value.trim()) $('heroConcept').value = p.hero;
    if (!$('heroMotion').value.trim()) $('heroMotion').value = p.hero_motion || '';
    if (key === 'cake_reveal') $('heroShot').checked = true;
    toggleHeroBox();
  }}
}};

async function api(path, body) {{
  const r = await fetch(path, {{
    method: body ? 'POST' : 'GET',
    headers: body ? {{'Content-Type':'application/json'}} : {{}},
    body: body ? JSON.stringify(body) : undefined
  }});
  return r.json();
}}

let pollTimer = null;
function pollJob(id) {{
  clearInterval(pollTimer);
  pollTimer = setInterval(async () => {{
    const j = await api('/api/job/' + id);
    $('status').textContent = (j.message || j.status || '') + (j.engine ? ' · ' + j.engine : '');
    if (j.status === 'done' || j.status === 'error') {{
      clearInterval(pollTimer);
      showPreview(j);
      loadJobs();
    }}
  }}, 2500);
}}

function showPreview(j) {{
  const box = $('preview');
  const body = $('previewBody');
  box.style.display = 'block';
  let html = '';
  if (j.poster) html += '<p>Pollo-Poster:</p><img src="/api/asset/' + j.id + '/ad_poster.jpg"/>';
  else {{
    if (j.hero) html += '<p>Hero:</p><img src="/api/asset/' + j.id + '/hero.png"/>';
    if (j.storyboard) html += '<p>Storyboard:</p><img src="/api/asset/' + j.id + '/storyboard.png"/>';
  }}
  if (j.local_video) html += '<p>Video:</p><video src="/api/video/' + j.id + '" controls playsinline></video>';
  if (j.desktop_video) html += '<p class="hint">Desktop: VidPuls\\\\Videos\\\\Storyboard\\\\storyboard_' + j.id + '.mp4</p>';
  if (j.status === 'error') html = '<p style="color:#f87171">' + (j.error || j.message) + '</p>';
  body.innerHTML = html;
}}

async function loadJobs() {{
  const r = await api('/api/jobs');
  const el = $('jobs');
  if (!r.jobs || !r.jobs.length) {{ el.textContent = 'Noch keine Jobs.'; return; }}
  el.innerHTML = r.jobs.map(j => `
    <div class="job" style="cursor:pointer" data-id="${{j.id}}">
      <b>${{j.id}}</b> <span class="badge">${{j.status}}</span><br>
      <span class="status">${{(j.concept||'').slice(0,80)}}…</span>
    </div>`).join('');
  el.querySelectorAll('.job').forEach(row => {{
    row.onclick = async () => {{
      const j = await api('/api/job/' + row.dataset.id);
      showPreview(j);
      $('status').textContent = j.message || j.status || '';
    }};
  }});
}}

$('go').onclick = async () => {{
  $('go').disabled = true;
  $('status').textContent = 'Starte …';
  const r = await api('/api/generate', {{
    concept: $('concept').value,
    motion: $('motion').value,
    preset: $('preset').value,
    mode: $('mode').value,
    engine: $('engine').value,
    duration: parseInt($('duration').value, 10) || 8,
    aspect_ratio: $('aspect').value,
    add_music: $('music').checked,
    hero_shot: $('heroShot').checked,
    hero_concept: $('heroConcept').value,
    hero_motion: $('heroMotion').value,
    hero_duration: parseInt($('heroDuration').value, 10) || 4,
    storyboard_base64: uploadB64 || undefined,
    hero_base64: heroB64 || undefined
  }});
  if (!r.ok) {{ $('status').textContent = r.error || 'Fehler'; $('go').disabled = false; return; }}
  pollJob(r.jobId);
  $('go').disabled = false;
}};

loadJobs();
</script>
</body>
</html>"""


class StudioHandler(SimpleHTTPRequestHandler):
    def log_message(self, fmt: str, *args) -> None:
        pass

    def _json(self, data: dict, code: int = 200) -> None:
        body = json.dumps(data, ensure_ascii=False).encode("utf-8")
        self.send_response(code)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def _read_json(self) -> dict:
        n = int(self.headers.get("Content-Length") or 0)
        if n <= 0:
            return {}
        return json.loads(self.rfile.read(n).decode("utf-8"))

    def do_GET(self):
        path = urlparse(self.path).path
        if path == "/api/health":
            from jam import storyboard_api
            from jam.vidpuls_lan import lan_urls

            return self._json({"ok": True, **storyboard_api.providers_status(), "urls": lan_urls()})
        if path == "/api/jobs":
            return self._json({"ok": True, "jobs": list_jobs()})
        if path.startswith("/api/job/"):
            jid = path.split("/")[-1]
            job = load_job(jid)
            if not job:
                return self._json({"ok": False, "error": "Nicht gefunden"}, 404)
            return self._json(job)
        if path.startswith("/api/video/"):
            jid = path.split("/")[-1].split("?")[0]
            job = load_job(jid)
            fp = Path(job.get("local_video") or "") if job else None
            if not fp or not fp.is_file():
                return self._json({"ok": False, "error": "Video fehlt"}, 404)
            data = fp.read_bytes()
            self.send_response(200)
            self.send_header("Content-Type", "video/mp4")
            self.send_header("Content-Length", str(len(data)))
            self.end_headers()
            self.wfile.write(data)
            return
        if path.startswith("/api/asset/"):
            parts = path.strip("/").split("/")
            if len(parts) >= 4:
                jid, name = parts[2], parts[3]
                fp = WORK_ROOT / jid / name
                if fp.is_file():
                    mime = mimetypes.guess_type(str(fp))[0] or "application/octet-stream"
                    data = fp.read_bytes()
                    self.send_response(200)
                    self.send_header("Content-Type", mime)
                    self.send_header("Content-Length", str(len(data)))
                    self.end_headers()
                    self.wfile.write(data)
                    return
            return self._json({"ok": False}, 404)
        if path in ("/", "/index.html"):
            body = studio_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)
            return
        self._json({"ok": False, "error": "Unbekannt"}, 404)

    def do_POST(self):
        path = urlparse(self.path).path
        if path == "/api/generate":
            return self._json(start_job(self._read_json()))
        if path == "/api/enhance":
            body = self._read_json()
            text = str(body.get("text") or "").strip()
            if not text:
                return self._json({"ok": False, "error": "Text fehlt"}, 400)
            from jam.jampulse_frame_ui import enhance_prompt

            improved = enhance_prompt(text)
            return self._json({"ok": True, "text": improved or text})
        return self._json({"ok": False, "error": "Unbekannt"}, 404)


def run_server(*, open_browser: bool = True, host: str = "127.0.0.1") -> None:
    ensure_dirs()
    httpd = ThreadingHTTPServer((host, PORT), StudioHandler)
    url = f"http://127.0.0.1:{PORT}/" if host == "0.0.0.0" else f"http://{host}:{PORT}/"
    print(f"Storyboard Studio → {url}")
    if host == "0.0.0.0":
        try:
            from jam.wan2gp_local import local_ip

            print(f"  WLAN: http://{local_ip()}:{PORT}/")
        except ImportError:
            pass
    if open_browser:
        webbrowser.open(url, new=1)
    try:
        httpd.serve_forever()
    except KeyboardInterrupt:
        print("\nBeendet.")
