All checks were successful
Deploy / deploy (push) Successful in 36s
Deletes the entire SMS path at the user's request — Gmail SMTP, phone number management, carrier gateway settings, and the daily SMS+calendar job are all gone. Home Assistant calendar events are now the only output. - scraper.py: get_week_menu() returns the whole school week in one call (the Nutrislice API is week-granular regardless of the date requested); drops the now-unused single-day get_lunch_menu() and the send_sms()/ CLI entrypoint that only the SMS path used. - app.py: push_week_to_calendar() fetches the week and pushes every day with a posted menu; runs on its own schedule (calendar_run_time/ calendar_run_days, tagged "calendar" jobs) fully decoupled from anything else, since neither calendar target supports delete-based dedup and double-firing would create duplicate events. Manual actions become "Push This Week to Calendar" and "Preview This Week's Menu" (now a real dry-run over the whole week, not just today). - Deleted the vacation/no-push date range feature: a day with no posted menu already produces empty items, and pushing already skips empty items — the extra vacation-range layer for suppressing days the school *did* post a menu for was judged unnecessary and removed along with its UI, settings field, and API endpoints. - Deleted scheduler.py, a dead alternate entrypoint (unused by Dockerfile/ docker-compose) that only worked via the now-deleted scraper.main(). - load_settings() now merges the file over _DEFAULTS instead of returning it verbatim — the server's existing settings.json predates this schema and has neither calendar_run_time nor calendar_run_days, which would otherwise silently schedule zero jobs after this deploys. Verified with a migration test using the server's actual old-schema settings shape. All of this was exercised for real: Docker builds, live containers against a mock webhook (single-day and full-week pushes, per-entity dedup behavior), a migration test with the pre-existing settings.json shape, and a Chrome pass over the rebuilt UI. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KsJqAZ82Veyf5oihBr71S5
491 lines
18 KiB
Python
491 lines
18 KiB
Python
"""
|
|
Lunch Notifier — pulls the Mariposa lunch menu and pushes it to a Home
|
|
Assistant calendar. Visit http://localhost:2323 to manage settings.
|
|
"""
|
|
import json
|
|
import logging
|
|
import os
|
|
import threading
|
|
import time
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
import requests
|
|
import schedule
|
|
from flask import Flask, jsonify, request
|
|
|
|
from scraper import get_week_menu
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
|
log = logging.getLogger(__name__)
|
|
|
|
app = Flask(__name__)
|
|
|
|
SETTINGS_FILE = Path(os.environ.get("SETTINGS_FILE", "/data/settings.json"))
|
|
PORT = int(os.environ.get("PORT", "2323"))
|
|
|
|
# Env-var defaults (used only when no settings file exists yet)
|
|
_DEFAULTS = {
|
|
"calendar_run_time": os.environ.get("CALENDAR_RUN_TIME", "18:00"),
|
|
"calendar_run_days": [d.strip() for d in os.environ.get("CALENDAR_RUN_DAYS", "sun").split(",") if d.strip()],
|
|
}
|
|
|
|
_status = {"last_calendar_run": None, "calendar_result": None}
|
|
_lock = threading.Lock()
|
|
|
|
|
|
# ── Settings ──────────────────────────────────────────────────────────────────
|
|
|
|
def load_settings() -> dict:
|
|
if SETTINGS_FILE.exists():
|
|
try:
|
|
with open(SETTINGS_FILE) as f:
|
|
# Merge over _DEFAULTS so a settings.json from before a schema
|
|
# change (e.g. the pre-calendar SMS-era file) still gets
|
|
# calendar_run_time/calendar_run_days rather than silently
|
|
# scheduling nothing.
|
|
return {**_DEFAULTS, **json.load(f)}
|
|
except Exception:
|
|
pass
|
|
return dict(_DEFAULTS)
|
|
|
|
|
|
def save_settings(s: dict) -> None:
|
|
SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(SETTINGS_FILE, "w") as f:
|
|
json.dump(s, f, indent=2)
|
|
|
|
|
|
# ── Calendar push ─────────────────────────────────────────────────────────────
|
|
|
|
def _push_to_home_assistant(items: list[str], date_str: str) -> None:
|
|
webhook_url = os.environ["HA_WEBHOOK_URL"]
|
|
log.info(f"Pushing {date_str} menu → Home Assistant webhook")
|
|
resp = requests.post(webhook_url, json={"date": date_str, "items": items}, timeout=10)
|
|
resp.raise_for_status()
|
|
log.info(f"Home Assistant push ✓ ({date_str})")
|
|
|
|
|
|
def _push_calendar(items: list[str], date_str: str | None) -> str:
|
|
"""Push one day to Home Assistant if configured. Returns a status string."""
|
|
if not items:
|
|
return "no items"
|
|
if not os.environ.get("HA_WEBHOOK_URL"):
|
|
return "not configured"
|
|
try:
|
|
_push_to_home_assistant(items, date_str)
|
|
return "ok"
|
|
except Exception as e:
|
|
log.error(f"Home Assistant push failed ({date_str}): {e}")
|
|
return f"error: {e}"
|
|
|
|
|
|
def push_week_to_calendar() -> None:
|
|
"""Fetch the current school week and push every day that has a posted
|
|
menu — days with nothing posted (weekends, breaks, holidays) are
|
|
skipped automatically since there's nothing to create an event from.
|
|
Runs on its own schedule (calendar_run_time/calendar_run_days),
|
|
independent of anything else."""
|
|
global _status
|
|
|
|
try:
|
|
week = get_week_menu()
|
|
except Exception as e:
|
|
log.exception("Week fetch failed")
|
|
_status = {**_status, "last_calendar_run": datetime.now().isoformat(), "calendar_result": f"error: {e}"}
|
|
return
|
|
|
|
pushed = errors = no_items = 0
|
|
for day in week:
|
|
result = _push_calendar(day["items"], day["date"])
|
|
if result == "ok":
|
|
pushed += 1
|
|
elif result == "no items":
|
|
no_items += 1
|
|
else:
|
|
errors += 1
|
|
|
|
log.info(f"Week push: {pushed} pushed, {errors} errors, {no_items} no menu")
|
|
|
|
if errors:
|
|
summary = f"{pushed} ok, {errors} error" + ("s" if errors != 1 else "")
|
|
elif pushed:
|
|
summary = f"ok ({pushed} day{'s' if pushed != 1 else ''})"
|
|
else:
|
|
summary = "no items"
|
|
|
|
_status = {
|
|
**_status,
|
|
"last_calendar_run": datetime.now().isoformat(),
|
|
"calendar_result": summary,
|
|
}
|
|
|
|
|
|
# ── Scheduler ─────────────────────────────────────────────────────────────────
|
|
|
|
DAY_MAP_FNS = {
|
|
"mon": lambda: schedule.every().monday,
|
|
"tue": lambda: schedule.every().tuesday,
|
|
"wed": lambda: schedule.every().wednesday,
|
|
"thu": lambda: schedule.every().thursday,
|
|
"fri": lambda: schedule.every().friday,
|
|
"sat": lambda: schedule.every().saturday,
|
|
"sun": lambda: schedule.every().sunday,
|
|
}
|
|
|
|
|
|
def apply_calendar_schedule(settings: dict) -> None:
|
|
with _lock:
|
|
schedule.clear("calendar")
|
|
run_time = settings.get("calendar_run_time", "18:00")
|
|
for day in settings.get("calendar_run_days", []):
|
|
day = day.strip().lower()
|
|
if day in DAY_MAP_FNS:
|
|
DAY_MAP_FNS[day]().at(run_time).do(push_week_to_calendar).tag("calendar")
|
|
log.info(f"Scheduled calendar pull: {day} at {run_time}")
|
|
|
|
|
|
def _schedule_loop() -> None:
|
|
while True:
|
|
with _lock:
|
|
schedule.run_pending()
|
|
time.sleep(30)
|
|
|
|
|
|
# ── Flask API ─────────────────────────────────────────────────────────────────
|
|
|
|
MASCOT_URL = "https://resources.finalsite.net/images/f_png,q_auto/v1708515016/kyreneorg/caewi1dctc6afx1d8nq1/Logo_Mariposa-Mascot.png"
|
|
|
|
@app.route("/")
|
|
def index():
|
|
return HTML, 200, {"Content-Type": "text/html"}
|
|
|
|
|
|
@app.route("/icon.png")
|
|
def icon():
|
|
r = requests.get(MASCOT_URL, timeout=10)
|
|
return r.content, 200, {"Content-Type": "image/png", "Cache-Control": "public, max-age=86400"}
|
|
|
|
|
|
@app.route("/api/settings", methods=["GET"])
|
|
def api_get_settings():
|
|
return jsonify(load_settings())
|
|
|
|
|
|
@app.route("/api/settings", methods=["POST"])
|
|
def api_post_settings():
|
|
data = request.get_json(force=True)
|
|
s = load_settings()
|
|
for key in ("calendar_run_time", "calendar_run_days"):
|
|
if key in data:
|
|
s[key] = data[key]
|
|
save_settings(s)
|
|
apply_calendar_schedule(s)
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
@app.route("/api/push-calendar", methods=["POST"])
|
|
def api_push_calendar():
|
|
threading.Thread(target=push_week_to_calendar, daemon=True).start()
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
@app.route("/api/preview")
|
|
def api_preview():
|
|
"""Dry-run week scrape: no Home Assistant push, no side effects."""
|
|
try:
|
|
week = get_week_menu()
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 502
|
|
return jsonify({"days": week})
|
|
|
|
|
|
@app.route("/api/integrations")
|
|
def api_integrations():
|
|
return jsonify({"ha_webhook_configured": bool(os.environ.get("HA_WEBHOOK_URL"))})
|
|
|
|
|
|
@app.route("/api/status")
|
|
def api_status():
|
|
with _lock:
|
|
next_runs = sorted([j.next_run for j in schedule.get_jobs("calendar") if j.next_run])
|
|
return jsonify({
|
|
**_status,
|
|
"next_calendar_run": next_runs[0].isoformat() if next_runs else None,
|
|
})
|
|
|
|
|
|
# ── Embedded UI ───────────────────────────────────────────────────────────────
|
|
|
|
HTML = """<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>🍱 Lunch Notifier</title>
|
|
<link rel="icon" href="https://resources.finalsite.net/images/f_auto,q_auto/v1708515016/kyreneorg/caewi1dctc6afx1d8nq1/Logo_Mariposa-Mascot.png">
|
|
<style>
|
|
:root {
|
|
--bg: #0d1117;
|
|
--card: #161b22;
|
|
--primary: #14b8a6;
|
|
--primary-hover: #0d9488;
|
|
--danger: #f87171;
|
|
--success: #4ade80;
|
|
--text: #e6edf3;
|
|
--muted: #8b949e;
|
|
--border: #30363d;
|
|
--radius: 12px;
|
|
--shadow: 0 1px 3px rgba(0,0,0,.4), 0 1px 2px rgba(0,0,0,.3);
|
|
}
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: var(--bg); color: var(--text); min-height: 100vh; }
|
|
.container { max-width: 620px; margin: 0 auto; padding: 24px 16px 48px; }
|
|
header { margin-bottom: 28px; }
|
|
header h1 { font-size: 1.5rem; font-weight: 700; }
|
|
header p { color: var(--muted); font-size: .875rem; margin-top: 2px; }
|
|
.card { background: var(--card); border-radius: var(--radius); padding: 20px;
|
|
margin-bottom: 16px; box-shadow: var(--shadow); border: 1px solid var(--border); }
|
|
.card-label { font-size: .7rem; font-weight: 700; text-transform: uppercase;
|
|
letter-spacing: .06em; color: var(--muted); margin-bottom: 14px; }
|
|
/* Status grid */
|
|
.stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
|
.stat dt { font-size: .75rem; color: var(--muted); margin-bottom: 2px; }
|
|
.stat dd { font-size: .875rem; font-weight: 500; }
|
|
/* Badges */
|
|
.badge { display: inline-block; padding: 2px 10px; border-radius: 99px; font-size: .75rem; font-weight: 600; }
|
|
.badge-green { background: #14532d; color: #4ade80; }
|
|
.badge-yellow { background: #713f12; color: #fbbf24; }
|
|
.badge-red { background: #7f1d1d; color: #fca5a5; }
|
|
.badge-blue { background: #1e3a5f; color: #7dd3fc; }
|
|
/* Buttons */
|
|
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
|
padding: 9px 18px; border-radius: 8px; font-size: .875rem; font-weight: 600;
|
|
cursor: pointer; border: none; transition: background .15s, opacity .15s; }
|
|
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
|
.btn-primary { background: var(--primary); color: #fff; }
|
|
.btn-primary:hover:not(:disabled) { background: var(--primary-hover); }
|
|
.btn-ghost { background: transparent; color: var(--muted); border: 1px solid var(--border); }
|
|
.btn-ghost:hover { background: var(--bg); }
|
|
.btn-danger { background: var(--card); color: var(--danger); border: 1px solid #7f1d1d; }
|
|
.btn-danger:hover { background: #1a0f0f; }
|
|
.btn-send { width: 100%; padding: 14px; font-size: 1rem; border-radius: 10px; }
|
|
.btn-sm { padding: 6px 12px; font-size: .75rem; }
|
|
/* Form elements */
|
|
.field { margin-bottom: 14px; }
|
|
.field:last-child { margin-bottom: 0; }
|
|
label.field-label { display: block; font-size: .8rem; font-weight: 600; color: var(--text);
|
|
margin-bottom: 6px; }
|
|
input[type=text], input[type=time] {
|
|
width: 100%; padding: 9px 12px; border: 1px solid var(--border); border-radius: 8px;
|
|
font-size: .875rem; color: var(--text); background: var(--card);
|
|
}
|
|
input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-color: transparent; }
|
|
/* Days toggle */
|
|
.days-row { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
.day-chip { padding: 6px 13px; border: 1.5px solid var(--border); border-radius: 99px;
|
|
font-size: .8rem; font-weight: 600; cursor: pointer; background: var(--card);
|
|
transition: all .15s; user-select: none; }
|
|
.day-chip.on { background: var(--primary); color: #fff; border-color: var(--primary); }
|
|
.item-sub { font-size: .75rem; color: var(--muted); margin-top: 1px; }
|
|
/* Divider */
|
|
.divider { border: none; border-top: 1px solid var(--border); margin: 14px 0; }
|
|
/* Toast */
|
|
.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(8px);
|
|
background: #1f2937; color: #fff; padding: 10px 20px; border-radius: 99px;
|
|
font-size: .875rem; font-weight: 500; opacity: 0; transition: opacity .25s, transform .25s;
|
|
pointer-events: none; white-space: nowrap; z-index: 100; }
|
|
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
.week-day { padding: 8px 0; border-bottom: 1px solid var(--border); }
|
|
.week-day:last-child { border-bottom: none; }
|
|
.week-day-date { font-size: .8rem; font-weight: 600; margin-bottom: 3px; }
|
|
.week-day-items { font-size: .8rem; color: #c9d1d9; white-space: pre-wrap; }
|
|
.week-day-empty { font-size: .8rem; color: var(--muted); font-style: italic; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<header>
|
|
<div style="display:flex;align-items:center;gap:16px;">
|
|
<img src="https://resources.finalsite.net/images/f_auto,q_auto/v1708515016/kyreneorg/caewi1dctc6afx1d8nq1/Logo_Mariposa-Mascot.png" alt="Chip the Challenger" style="height:80px;width:auto;">
|
|
<div>
|
|
<h1>Lunch Notifier</h1>
|
|
<p>Kyrene de la Mariposa · Menu → Home Assistant calendar</p>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Status -->
|
|
<div class="card">
|
|
<div class="card-label">Status</div>
|
|
<dl class="stat-grid">
|
|
<div class="stat"><dt>Last Pull</dt><dd id="s-last-run">—</dd></div>
|
|
<div class="stat"><dt>Result</dt><dd id="s-result">—</dd></div>
|
|
<div class="stat"><dt>Next Scheduled</dt><dd id="s-next">—</dd></div>
|
|
</dl>
|
|
</div>
|
|
|
|
<!-- Manual Actions -->
|
|
<div class="card">
|
|
<div class="card-label">Manual Actions</div>
|
|
<button id="push-cal-btn" class="btn btn-primary btn-send" onclick="pushCalendar()">
|
|
Push This Week to Calendar
|
|
</button>
|
|
<button id="preview-btn" class="btn btn-ghost" style="width:100%;margin-top:10px;" onclick="previewMenu()">
|
|
Preview This Week's Menu
|
|
</button>
|
|
<div id="preview-box" style="display:none;margin-top:14px;"></div>
|
|
</div>
|
|
|
|
<!-- Integrations -->
|
|
<div class="card">
|
|
<div class="card-label">Integrations</div>
|
|
<dl class="stat-grid">
|
|
<div class="stat"><dt>Home Assistant</dt><dd id="i-ha">—</dd></div>
|
|
</dl>
|
|
<div class="item-sub" style="margin-top:10px;">Configured via <code>.env</code> (<code>HA_WEBHOOK_URL</code>) on the server, not here.</div>
|
|
</div>
|
|
|
|
<!-- Schedule -->
|
|
<div class="card">
|
|
<div class="card-label">Calendar Pull Schedule</div>
|
|
<div class="field">
|
|
<label class="field-label">Pull Time</label>
|
|
<input type="time" id="f-time" style="max-width:160px">
|
|
</div>
|
|
<div class="field">
|
|
<label class="field-label">Pull Days</label>
|
|
<div class="days-row" id="days-row"></div>
|
|
<div class="item-sub" style="margin-top:8px;">Each day fires a full week pull. Picking more than one day will create duplicate calendar events for days already pushed — most people want just one (e.g. Sunday evening).</div>
|
|
</div>
|
|
<hr class="divider">
|
|
<button class="btn btn-primary" onclick="saveSchedule()">Save Schedule</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="toast" id="toast"></div>
|
|
|
|
<script>
|
|
const DAYS = ['mon','tue','wed','thu','fri','sat','sun'];
|
|
const DAY_LABELS = {mon:'Mon',tue:'Tue',wed:'Wed',thu:'Thu',fri:'Fri',sat:'Sat',sun:'Sun'};
|
|
let cfg = {};
|
|
|
|
async function load() {
|
|
const [s, st, ig] = await Promise.all([
|
|
fetch('/api/settings').then(r=>r.json()),
|
|
fetch('/api/status').then(r=>r.json()),
|
|
fetch('/api/integrations').then(r=>r.json()),
|
|
]);
|
|
cfg = s;
|
|
applySettings(s);
|
|
applyStatus(st);
|
|
applyIntegrations(ig);
|
|
}
|
|
|
|
function fmt(iso) {
|
|
if (!iso) return '—';
|
|
const d = new Date(iso);
|
|
return d.toLocaleDateString([], {month:'short',day:'numeric'}) + ' ' +
|
|
d.toLocaleTimeString([], {hour:'numeric',minute:'2-digit'});
|
|
}
|
|
|
|
function applyIntegrations(ig) {
|
|
document.getElementById('i-ha').innerHTML = ig.ha_webhook_configured
|
|
? '<span class="badge badge-green">Configured</span>'
|
|
: '<span class="badge badge-yellow">Not configured</span>';
|
|
}
|
|
|
|
function applySettings(s) {
|
|
document.getElementById('f-time').value = s.calendar_run_time || '18:00';
|
|
const row = document.getElementById('days-row');
|
|
row.innerHTML = DAYS.map(d =>
|
|
`<span class="day-chip ${(s.calendar_run_days||[]).includes(d)?'on':''}" data-day="${d}"
|
|
onclick="this.classList.toggle('on')">${DAY_LABELS[d]}</span>`
|
|
).join('');
|
|
}
|
|
|
|
function applyStatus(st) {
|
|
document.getElementById('s-last-run').textContent = fmt(st.last_calendar_run);
|
|
const res = st.calendar_result;
|
|
let cls = res==='ok' || res?.startsWith('ok') ? 'badge-green'
|
|
: res?.startsWith('skipped') || res === 'no items' ? 'badge-yellow'
|
|
: res ? 'badge-red' : '';
|
|
document.getElementById('s-result').innerHTML = res ? `<span class="badge ${cls}">${res}</span>` : '—';
|
|
document.getElementById('s-next').textContent = fmt(st.next_calendar_run);
|
|
}
|
|
|
|
async function saveSchedule() {
|
|
const calendar_run_time = document.getElementById('f-time').value;
|
|
const calendar_run_days = [...document.querySelectorAll('.day-chip.on')].map(c=>c.dataset.day);
|
|
await post('/api/settings', {calendar_run_time, calendar_run_days});
|
|
toast('Schedule saved');
|
|
load();
|
|
}
|
|
|
|
async function pushCalendar() {
|
|
const btn = document.getElementById('push-cal-btn');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Pushing…';
|
|
await post('/api/push-calendar', {});
|
|
toast('Triggered! Check status in a moment.');
|
|
setTimeout(() => { btn.disabled=false; btn.textContent='Push This Week to Calendar'; load(); }, 6000);
|
|
}
|
|
|
|
async function previewMenu() {
|
|
const btn = document.getElementById('preview-btn');
|
|
const box = document.getElementById('preview-box');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Loading…';
|
|
try {
|
|
const r = await fetch('/api/preview');
|
|
const data = await r.json();
|
|
if (data.error) {
|
|
box.innerHTML = `<div class="week-day-empty">Error: ${data.error}</div>`;
|
|
} else {
|
|
box.innerHTML = (data.days||[]).map(d => `
|
|
<div class="week-day">
|
|
<div class="week-day-date">${d.date}</div>
|
|
${d.items.length
|
|
? `<div class="week-day-items">${d.items.map(i=>'• '+i).join('\\n')}</div>`
|
|
: '<div class="week-day-empty">No menu posted</div>'}
|
|
</div>`).join('');
|
|
}
|
|
box.style.display = '';
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = "Preview This Week's Menu";
|
|
}
|
|
}
|
|
|
|
async function post(url, body) {
|
|
return fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
|
|
}
|
|
|
|
let toastTimer;
|
|
function toast(msg) {
|
|
const el = document.getElementById('toast');
|
|
el.textContent = msg;
|
|
el.classList.add('show');
|
|
clearTimeout(toastTimer);
|
|
toastTimer = setTimeout(() => el.classList.remove('show'), 3000);
|
|
}
|
|
|
|
load();
|
|
setInterval(load, 30000);
|
|
</script>
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
# ── Entrypoint ────────────────────────────────────────────────────────────────
|
|
|
|
if __name__ == "__main__":
|
|
if not SETTINGS_FILE.exists():
|
|
log.info(f"Creating default settings at {SETTINGS_FILE}")
|
|
save_settings(_DEFAULTS)
|
|
|
|
apply_calendar_schedule(load_settings())
|
|
threading.Thread(target=_schedule_loop, daemon=True).start()
|
|
log.info(f"Web UI → http://localhost:{PORT}")
|
|
app.run(host="0.0.0.0", port=PORT, debug=False, use_reloader=False)
|