#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
带货短视频工作台 — 零依赖可视化操作界面 (纯Python标准库, 无需pip)
- 拖拽/粘贴产品图 -> 自动上传腾讯云COS
- 配置提示词/时长/分辨率/配音 -> autodl ComfyUI 图生视频
- 可选 TTS 带货配音 + ffmpeg 合成
- 历史记录, 可回看/下载/删除
启动: python3 studio.py   或   双击 启动工作台.command
"""
import base64, json, os, shutil, socket, ssl, subprocess, sys, threading, time, uuid
import urllib.request, urllib.error
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from pathlib import Path
from urllib.parse import urlparse, parse_qs, unquote

ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(ROOT))
DATA = ROOT / "studio_data"; DATA.mkdir(exist_ok=True)
UPLOADS = DATA / "uploads"; UPLOADS.mkdir(exist_ok=True)
OUT = DATA / "out"; OUT.mkdir(exist_ok=True)
JOBS_FILE = DATA / "jobs.json"
CONFIG_FILE = ROOT / "ui_config.json"
TEMPLATES_FILE = DATA / "templates.json"

DEFAULT_TEMPLATES = {
    "prompt": {
        "通用": [
            {"name": "产品展示", "text": "双手拿起产品轻轻旋转展示，镜头缓慢推进聚焦logo，柔和自然光，干净桌面背景，突出产品质感与做工细节"},
            {"name": "场景氛围", "text": "产品摆放在真实使用场景中，暖色调氛围光，镜头环绕缓缓移动，展示产品与生活场景的融合，画面高级有质感"},
            {"name": "口播种草", "text": "手持产品怼脸特写，快速切换多个角度，节奏明快，展示使用效果，突出产品核心卖点，带货感强"},
            {"name": "痛点解决", "text": "先展示使用前的问题场景，再切换到产品使用后的改善效果，前后对比明显，镜头语言清晰，突出产品解决痛点的能力"},
            {"name": "促销冲击", "text": "产品特写配合快速变焦，背景简洁明亮，节奏紧凑，突出限时优惠与抢购氛围，画面冲击力强"}
        ]
    },
    "narr": {
        "通用": [
            {"name": "通用卖点", "text": "家人们，这款产品真的绝了！做工精细，质感拉满，谁用谁知道，赶紧安排上！"},
            {"name": "限时促销", "text": "家人们抓紧了！这款现在下单立减还有赠品，库存不多，手慢就没了，赶紧冲！"},
            {"name": "痛点共鸣", "text": "姐妹们是不是也遇到过这些问题？别担心，有了这款，一次解决所有烦恼，用了就回不去了！"},
            {"name": "效果种草", "text": "不是我说，这款真的越用越上头，身边朋友都被我种草了，质量在线，闭眼入不踩雷！"},
            {"name": "送礼场景", "text": "马上要过节了不知道送什么？就送这个，高端大气上档次，收到的人都夸，倍有面子！"}
        ]
    }
}

def load_templates():
    if TEMPLATES_FILE.exists():
        try:
            d = json.loads(TEMPLATES_FILE.read_text())
            for k in ("prompt", "narr"):
                if k not in d or not isinstance(d.get(k), dict):
                    d[k] = json.loads(json.dumps(DEFAULT_TEMPLATES[k]))
            return d
        except Exception:
            pass
    return json.loads(json.dumps(DEFAULT_TEMPLATES))

def save_templates(d):
    tmp = TEMPLATES_FILE.with_suffix(".tmp")
    tmp.write_text(json.dumps(d, ensure_ascii=False, indent=1))
    tmp.replace(TEMPLATES_FILE)

def templates_snapshot():
    return json.loads(json.dumps(load_templates()))

import douyin_video

def load_config():
    d = {"autodl_token": "", "default_voice": ""}
    if CONFIG_FILE.exists():
        try: d.update(json.loads(CONFIG_FILE.read_text()))
        except Exception: pass
    tok = os.environ.get("AUTODL_TOKEN")
    if tok and not d.get("autodl_token"): d["autodl_token"] = tok
    return d

def save_config(c):
    tmp = CONFIG_FILE.with_suffix(".tmp")
    tmp.write_text(json.dumps(c, ensure_ascii=False, indent=2))
    tmp.replace(CONFIG_FILE)
    os.chmod(CONFIG_FILE, 0o600)

def load_jobs():
    m = {}
    if JOBS_FILE.exists():
        try: m = json.loads(JOBS_FILE.read_text())
        except Exception: pass
    for j in m.values():
        if j.get("status") == "running":
            j["status"] = "interrupted"
            for s in j.get("steps", []):
                if s["state"] == "running": s["state"] = "error"
    return m

JOBS = load_jobs()
AUTH_PWD = (load_config() or {}).get("access_pwd", "") or ""
THUMB_LOCK = threading.Lock()
LOCK = threading.Lock()

def save_jobs(m=None):
    m = m or JOBS
    tmp = JOBS_FILE.with_suffix(".tmp")
    tmp.write_text(json.dumps(m, ensure_ascii=False))
    tmp.replace(JOBS_FILE)

def set_step(job, idx, state, detail=""):
    with LOCK:
        job["steps"][idx]["state"] = state
        if detail: job["steps"][idx]["detail"] = detail
        job["updated"] = time.time()
        save_jobs()

def autodl_get(path, headers, timeout=60):
    req = urllib.request.Request(douyin_video.BASE + path, headers=headers)
    return json.load(urllib.request.urlopen(req, timeout=timeout))

def autodl_post(path, body, headers, timeout=60):
    req = urllib.request.Request(douyin_video.BASE + path, data=json.dumps(body).encode(),
                                 headers=headers, method="POST")
    return json.load(urllib.request.urlopen(req, timeout=timeout))

def download_file(url, dest):
    req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
    with urllib.request.urlopen(req, timeout=300) as r, open(dest, "wb") as f:
        shutil.copyfileobj(r, f)

def has_audio(path):
    r = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "a",
                        "-show_entries", "stream=codec_type", "-of", "csv=p=0", str(path)],
                       capture_output=True, text=True)
    return "audio" in r.stdout

def run_job(job):
    cfg = load_config()
    try:
        if not cfg.get("autodl_token"):
            raise RuntimeError("未配置 autodl token: 点击右上角 配置 填入 Token")
        hs = {"Authorization": cfg["autodl_token"], "Content-Type": "application/json"}
        # 1 图片
        set_step(job, 0, "running")
        urls = []
        for img in job["images"]:
            if str(img).startswith("http"):
                urls.append(str(img))
            else:
                fp = None
                for cand in (UPLOADS / img, DATA / img, ROOT / img):
                    if cand.exists():
                        fp = cand
                        break
                if fp is None:
                    raise RuntimeError("图片不存在: " + str(img))
                if not fp.exists(): raise RuntimeError("图片不存在: " + str(img))
                urls.append(douyin_video.ensure_public_url(str(fp)))
        set_step(job, 0, "done", "%d 张参考图就绪" % len(urls))
        # 2 提交
        set_step(job, 1, "running")
        body = {"prompt": job["prompt"], "duration": int(job["duration"]),
                "resolution": job["resolution"]}
        for i, u in enumerate(urls[:9]):
            body["ref_image_%d" % i] = u
        r = autodl_post("/comfyui_workflow/" + douyin_video.WF_IMAGE_AUDIO_VIDEO, body, hs)
        if r.get("code") != "Success":
            raise RuntimeError("提交失败: %s" % r.get("msg"))
        task_id = r["data"]["task_id"]
        job["autodl_task_id"] = task_id
        save_jobs()
        set_step(job, 1, "done", task_id)
        # 3 轮询
        set_step(job, 2, "running", "排队中...")
        t0 = time.time()
        video_url = None
        while True:
            d = autodl_get("/comfyui_workflow/result/" + task_id, hs)["data"]
            st = d.get("status"); el = int(time.time() - t0)
            set_step(job, 2, "running", "%s 已运行 %ds" % (st, el))
            if st == "SUCCESS":
                video_url = d["results"][0]["url"]; break
            if st in ("FAILED", "ERROR"):
                raise RuntimeError("autodl 任务失败: %s" % json.dumps(d, ensure_ascii=False)[:200])
            if el > 3600: raise TimeoutError("超过1小时未完成")
            time.sleep(8)
        # 4 下载
        set_step(job, 3, "running")
        jdir = OUT / job["id"]; jdir.mkdir(parents=True, exist_ok=True)
        vpath = jdir / "video.mp4"
        download_file(video_url, vpath)
        job["video"] = "out/" + job["id"] + "/video.mp4"
        save_jobs()
        set_step(job, 3, "done", "%dKB" % (vpath.stat().st_size // 1024))
        # 5 配音
        if job.get("narration"):
            voice = job.get("voice") or cfg.get("default_voice")
            if voice and not str(voice).startswith("http"):
                vp = UPLOADS / voice
                if not vp.exists(): vp = DATA / voice
                if not vp.exists(): vp = ROOT / voice
                if not vp.exists():
                    raise RuntimeError("音色文件不存在: " + str(voice))
                set_step(job, 4, "running", "上传音色文件到COS...")
                voice = douyin_video.ensure_public_url(str(vp))
            if not voice:
                # 无参考音色: 用 macOS 系统朗读生成默认音色参考音频
                set_step(job, 4, "running", "用系统朗读生成默认音色...")
                try:
                    if not shutil.which("say"):
                        raise RuntimeError("本机无 say 命令（仅 macOS 支持自动音色），请提供录音或音色URL")
                    ref_text = (job["narration"] or "")[:60] or "大家好，欢迎来到我的直播间"
                    aiff = jdir / "ref_voice.aiff"
                    subprocess.run(["say", "-v", "Tingting", "-o", str(aiff), ref_text], timeout=60)
                    if not aiff.exists():
                        raise RuntimeError("say 生成失败")
                    mp3 = jdir / "ref_voice.mp3"
                    subprocess.run(["ffmpeg", "-y", "-i", str(aiff), "-ar", "44100", "-ac", "1", "-b:a", "128k", str(mp3)], capture_output=True, timeout=60)
                    if not mp3.exists():
                        raise RuntimeError("转码失败")
                    voice = douyin_video.ensure_public_url(str(mp3))
                    job["voice_auto"] = True
                    save_jobs()
                except Exception as e:
                    raise RuntimeError("自动生成默认音色失败，请提供录音或音色URL: %s" % e)
                if not voice:
                    set_step(job, 4, "skipped", "未配置音色参考URL, 跳过配音")
                    set_step(job, 5, "skipped")
            else:
                set_step(job, 4, "running", "提交TTS...")
                ts = {"prompt_text": job["narration"], "prompt_simple": voice,
                      "emo_control_method": "与音色参考音频相同", "emo_happy": 0.6}
                r2 = autodl_post("/comfyui_workflow/" + douyin_video.WF_TTS, ts, hs)
                if r2.get("code") != "Success":
                    raise RuntimeError("TTS提交失败: %s" % r2.get("msg"))
                tid2 = r2["data"]["task_id"]
                ares = None
                while True:
                    d2 = autodl_get("/comfyui_workflow/result/" + tid2, hs)["data"]
                    s2 = d2.get("status")
                    set_step(job, 4, "running", "TTS: %s" % s2)
                    if s2 == "SUCCESS":
                        ares = [x for x in d2.get("results", []) if x.get("type") in ("audio", "video")][0]
                        break
                    if s2 in ("FAILED", "ERROR"):
                        raise RuntimeError("TTS失败: %s" % json.dumps(d2, ensure_ascii=False)[:200])
                    time.sleep(5)
                apath = jdir / ("narration." + ares.get("file_type", "mp3"))
                download_file(ares["url"], apath)
                set_step(job, 4, "done", apath.stat().st_size // 1024, )
                # 6 合成
                set_step(job, 5, "running", "ffmpeg 混合音轨...")
                fpath = jdir / "final.mp4"
                if has_audio(vpath):
                    cmd = ["ffmpeg", "-y", "-i", str(vpath), "-i", str(apath),
                           "-filter_complex", "[0:a]volume=0.4[bg];[1:a]volume=1.6[vo]",
                           "-map", "0:v", "-map", "[bg]", "-map", "[vo]",
                           "-c:v", "copy", "-shortest", str(fpath)]
                else:
                    cmd = ["ffmpeg", "-y", "-i", str(vpath), "-i", str(apath),
                           "-filter_complex", "[1:a]volume=1.6[vo]",
                           "-map", "0:v", "-map", "[vo]", "-c:v", "copy", "-shortest", str(fpath)]
                p = subprocess.run(cmd, capture_output=True, text=True)
                if p.returncode != 0:
                    raise RuntimeError("合成失败: " + (p.stderr or "")[-300:])
                job["video"] = "out/" + job["id"] + "/final.mp4"
                job["final"] = True
                save_jobs()
                set_step(job, 5, "done", "已合成成品")
        else:
            set_step(job, 4, "skipped")
            set_step(job, 5, "skipped")
        # 生成封面缩略图 (ffmpeg 抽帧, 失败不影响主流程)
        try:
            src_v = fpath if job.get("final") else vpath
            tthumb = Path(str(src_v).replace(".mp4", ".jpg"))
            if not tthumb.exists():
                subprocess.run(["ffmpeg", "-y", "-ss", "0.1", "-i", str(src_v), "-frames:v", "1", "-vf", "scale=320:-2", str(tthumb)], capture_output=True, timeout=60)
        except Exception:
            pass
        job["status"] = "success"
        job["duration_final"] = fpath.stat().st_size // 1024 if job.get("final") else vpath.stat().st_size // 1024
        job["costNote"] = "积分: 视频约20/条 (闲时10) 配音约20/条"
        save_jobs()
    except Exception as e:
        job["status"] = "failed"
        job["error"] = str(e)
        with LOCK:
            for s in job["steps"]:
                if s["state"] == "running": s["state"] = "error"
            save_jobs()

def page_html():
    p = ROOT / "ui.html"
    return p.read_text() if p.exists() else "<h1>ui.html 丢失</h1>"

class Handler(BaseHTTPRequestHandler):
    def log_message(self, *a): pass
    def _json(self, obj, code=200):
        b = json.dumps(obj, ensure_ascii=False).encode()
        self.send_response(code)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        self.wfile.write(b)
    def _file(self, path: Path, ctype):
        if not path.exists(): self._json({"ok": False, "error": "not found"}, 404); return
        b = path.read_bytes()
        self.send_response(200)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(b)))
        self.end_headers()
        self.wfile.write(b)
    def _body(self):
        n = int(self.headers.get("Content-Length") or 0)
        return self.rfile.read(n)
    def _auth_ok(self):
        if not AUTH_PWD:
            return True
        try:
            h = self.headers.get("Authorization") or ""
            if not h.startswith("Basic "):
                return False
            raw = base64.b64decode(h[6:]).decode("utf-8", "ignore")
            user, sep, pwd = raw.partition(":")
            return bool(sep) and pwd == AUTH_PWD
        except Exception:
            return False

    def _deny(self):
        body = b"auth required"
        self.send_response(401)
        self.send_header("WWW-Authenticate", 'Basic realm="DSH Workbench"')
        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)

    def do_GET(self):
        if not self._auth_ok():
            self._deny(); return
        u = urlparse(self.path); p = u.path
        try:
            if p == "/":
                ua = self.headers.get("User-Agent", "")
                is_mobile = ("desktop=1" not in self.path) and ("Mobile" in ua or "iPhone" in ua or "Android" in ua or "iPad" in ua)
                f = ROOT / ("mobile.html" if is_mobile else "ui.html")
                self._file(f, "text/html; charset=utf-8") if f.exists() else self.send_response(500)
            elif p in ("/m", "/mobile"):
                f = ROOT / "mobile.html"
                self._file(f, "text/html; charset=utf-8") if f.exists() else self.send_response(500)
            elif p == "/game":
                f = ROOT / "game.html"
                self._file(f, "text/html; charset=utf-8") if f.exists() else self.send_response(500)
            elif p == "/cert.crt":
                f = ROOT / "cert.pem"
                self._file(f, "application/x-x509-ca-cert") if f.exists() else self.send_response(404)
            elif p == "/api/jobs": self._json({"ok": True, "jobs": sorted(JOBS.values(), key=lambda j: -j.get("created",0))})
            elif p == "/api/templates": self._json({"ok": True, "templates": templates_snapshot()})
            elif p == "/api/config":
                c = load_config(); c.pop("autodl_token", None); c.pop("access_pwd", None)
                self._json({"ok": True, "config": c, "has_token": bool(load_config().get("autodl_token"))})
            elif p.startswith("/api/jobs/") and p.endswith("/steps"):
                jid = p.split("/")[3]
                j = JOBS.get(jid)
                if not j: self._json({"ok": False, "error": "no job"}, 404); return
                self._json({"ok": True, "job": j})
            elif p == "/api/thumb":
                jid = parse_qs(u.query).get("id", [""])[0]
                j = JOBS.get(jid)
                if not j or not j.get("video"):
                    self._json({"ok": False, "error": "no video"}, 404); return
                f = DATA / j["video"]
                if not f.exists():
                    self._json({"ok": False, "error": "no file"}, 404); return
                t = Path(str(f).replace(".mp4", ".jpg"))
                if not t.exists():
                    with THUMB_LOCK:
                        if not t.exists():
                            subprocess.run(["ffmpeg", "-y", "-ss", "0.1", "-i", str(f), "-frames:v", "1", "-vf", "scale=320:-2", str(t)], capture_output=True, timeout=60)
                if t.exists():
                    self._file(t, "image/jpeg")
                else:
                    self._json({"ok": False, "error": "thumb failed"}, 500)
            elif p == "/api/video":
                jid = parse_qs(u.query).get("id", [""])[0]
                j = JOBS.get(jid)
                if j and j.get("video"):
                    f = DATA / j["video"]
                    if f.exists():
                        b = f.read_bytes()
                        self.send_response(200); self.send_header("Content-Type","video/mp4")
                        self.send_header("Content-Length", str(len(b))); self.end_headers(); self.wfile.write(b); return
                self._json({"ok": False, "error": "no video"}, 404)
            elif p == "/api/img":
                sub = p[len("/api/img"):]
                f = UPLOADS / os.path.basename(sub)
                self._file(f, "image/png")
            else: self._json({"ok": False, "error": "unknown"}, 404)
        except Exception as e:
            print("POST %s ERROR: %s" % (p, e), flush=True)
            self._json({"ok": False, "error": str(e)}, 500)
    def do_POST(self):
        if not self._auth_ok():
            self._deny(); return
        u = urlparse(self.path); p = u.path
        try:
            if p == "/api/config":
                d = json.loads(self._body() or b"{}")
                c = load_config()
                if d.get("autodl_token"): c["autodl_token"] = d["autodl_token"].strip()
                if "default_voice" in d: c["default_voice"] = d["default_voice"].strip()
                save_config(c)
                self._json({"ok": True, "has_token": bool(c.get("autodl_token"))})
            elif p == "/api/upload":
                raw = self._body()
                ext = (self.headers.get("x-ext") or "").strip().lower()
                if ext not in (".png", ".jpg", ".jpeg", ".webp", ".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg"):
                    import mimetypes
                    ext = mimetypes.guess_extension((self.headers.get("Content-Type") or "").split(";")[0]) or ".png"
                if not ext.startswith("."): ext = "." + ext
                name = uuid.uuid4().hex + ext
                (UPLOADS / name).write_bytes(raw)
                print("upload %s %d bytes" % (name, len(raw)), flush=True)
                self._json({"ok": True, "name": name})
            elif p == "/api/templates/add":
                d = json.loads(self._body() or b"{}")
                t = d.get("type"); cat = (d.get("category") or "").strip()
                name = (d.get("name") or "").strip(); text = (d.get("text") or "").strip()
                if t not in ("prompt", "narr") or not cat or not name or not text:
                    self._json({"ok": False, "error": "type/category/name/text 必填"}, 400); return
                tpls = load_templates()
                tpls.setdefault(t, {}).setdefault(cat, [])
                if any(x.get("name") == name for x in tpls[t][cat]):
                    self._json({"ok": False, "error": "该分类下已有同名模板"}, 400); return
                dur = d.get("duration")
                item = {"name": name, "text": text}
                if t == "prompt" and isinstance(dur, (int, float)) and dur > 0:
                    item["duration"] = int(dur)
                tpls[t][cat].append(item)
                save_templates(tpls)
                self._json({"ok": True})
            elif p == "/api/templates/category":
                d = json.loads(self._body() or b"{}")
                t = d.get("type"); cat = (d.get("category") or "").strip()
                if t not in ("prompt", "narr") or not cat:
                    self._json({"ok": False, "error": "type/category 必填"}, 400); return
                tpls = load_templates()
                tpls.setdefault(t, {}).setdefault(cat, [])
                save_templates(tpls)
                self._json({"ok": True})
            elif p == "/api/templates/update":
                d = json.loads(self._body() or b"{}")
                t = d.get("type"); cat = (d.get("category") or "").strip()
                name = (d.get("name") or "").strip()
                if t not in ("prompt", "narr") or not cat or not name:
                    self._json({"ok": False, "error": "type/category/name 必填"}, 400); return
                tpls = load_templates()
                lst = tpls.get(t, {}).get(cat)
                item = None
                if isinstance(lst, list):
                    item = next((x for x in lst if x.get("name") == name), None)
                if item is None:
                    self._json({"ok": False, "error": "模板不存在"}, 404); return
                ncat = (d.get("new_category") or "").strip() or cat
                nname = (d.get("new_name") or "").strip() or name
                if ncat == cat:
                    if any(x.get("name") == nname and x is not item for x in lst):
                        self._json({"ok": False, "error": "该分类下已有同名模板"}, 400); return
                else:
                    other = tpls.get(t, {}).get(ncat) or []
                    if any(x.get("name") == nname for x in other):
                        self._json({"ok": False, "error": "目标分类下已有同名模板"}, 400); return
                item["name"] = nname
                if d.get("text") is not None:
                    item["text"] = str(d["text"]).strip()
                if t == "prompt":
                    dur = d.get("duration")
                    if isinstance(dur, (int, float)) and dur > 0:
                        item["duration"] = int(dur)
                    else:
                        item.pop("duration", None)
                if ncat != cat:
                    lst.remove(item)
                    tpls[t].setdefault(ncat, []).append(item)
                    if not lst:
                        del tpls[t][cat]
                save_templates(tpls)
                self._json({"ok": True})
            elif p == "/api/templates/delete":
                d = json.loads(self._body() or b"{}")
                t = d.get("type"); cat = d.get("category"); name = d.get("name")
                tpls = load_templates()
                lst = tpls.get(t, {}).get(cat) if t else None
                if isinstance(lst, list):
                    tpls[t][cat] = [x for x in lst if x.get("name") != name]
                    if not tpls[t][cat]: del tpls[t][cat]
                    save_templates(tpls)
                self._json({"ok": True})
            elif p == "/api/jobs":
                d = json.loads(self._body() or b"{}")
                jid = uuid.uuid4().hex[:8]
                steps = [{"name":"上传产品图","state":"wait"},{"name":"提交生成任务","state":"wait"},
                         {"name":"等待渲染","state":"wait"},{"name":"下载视频","state":"wait"},
                         {"name":"TTS配音","state":"wait"},{"name":"合成成品","state":"wait"}]
                job = {"id": jid, "created": time.time(), "updated": time.time(),
                       "status": "running", "steps": steps,
                       "images": d.get("images", []), "prompt": d.get("prompt", ""),
                       "duration": d.get("duration", 5), "resolution": d.get("resolution", "480p竖"),
                       "narration": d.get("narration", ""), "voice": d.get("voice", ""),
                       "video": None, "final": False, "error": None}
                JOBS[jid] = job
                save_jobs()
                threading.Thread(target=run_job, args=(job,), daemon=True).start()
                print("job %s submitted: %s" % (jid, (d.get("prompt") or "")[:40]), flush=True)
                self._json({"ok": True, "id": jid})
            elif p.startswith("/api/jobs/") and p.endswith("/delete"):
                jid = p.split("/")[3]; j = JOBS.pop(jid, None)
                if j: save_jobs()
                d_ = OUT / jid
                if d_.exists(): shutil.rmtree(d_, ignore_errors=True)
                self._json({"ok": True})
            else: self._json({"ok": False, "error": "unknown"}, 404)
        except Exception as e:
            print("POST %s ERROR: %s" % (p, e), flush=True)
            self._json({"ok": False, "error": str(e)}, 500)

def free_port(pref=8765):
    for p in (pref, 0):
        s = socket.socket()
        try:
            s.bind(("127.0.0.1", p)); port = s.getsockname()[1]; s.close(); return port
        except OSError:
            s.close()
        if p == 0: break
    raise RuntimeError("no port")

def lan_ip():
    try:
        t = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        t.connect(("8.8.8.8", 80))
        ip = t.getsockname()[0]
        t.close()
        return ip
    except Exception:
        return "127.0.0.1"

def backup_data():
    try:
        bdir = DATA / "backup"
        bdir.mkdir(exist_ok=True)
        ts = time.strftime("%Y%m%d_%H%M%S")
        for fn, base in (("jobs.json", DATA), ("templates.json", DATA), ("ui_config.json", ROOT)):
            f = base / fn
            if f.exists():
                shutil.copy2(f, bdir / ("%s_%s" % (ts, fn)))
        keep = sorted(bdir.iterdir())
        for old in keep[:-90]:
            try:
                old.unlink()
            except Exception:
                pass
    except Exception:
        pass

def main():
    backup_data()
    port = free_port()
    srv = ThreadingHTTPServer(("0.0.0.0", port), Handler)
    url = "http://127.0.0.1:%d" % port
    print("=" * 46)
    print("  🎬 带货短视频工作台已启动")
    print("  本机访问:       " + url)
    print("  手机(同一WiFi): http://%s:%d" % (lan_ip(), port))
    print("  Ctrl+C 退出")
    print("=" * 46)
    try:
        webbrowser = __import__("webbrowser")
        threading.Timer(0.6, lambda: webbrowser.open(url)).start()
    except Exception:
        pass
    hport = 8443
    try:
        ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
        ctx.load_cert_chain(str(ROOT / "cert.pem"), str(ROOT / "key.pem"))
        hsrv = ThreadingHTTPServer(("0.0.0.0", hport), Handler)
        hsrv.socket = ctx.wrap_socket(hsrv.socket, server_side=True)
        threading.Thread(target=hsrv.serve_forever, daemon=True).start()
        print("  HTTPS(存相册): https://%s:%d  (首次需装证书)" % (lan_ip(), hport))
    except Exception as e:
        print("  HTTPS 启动失败(跳过):", e)
    srv.serve_forever()

if __name__ == "__main__":
    main()
