#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
按需 TTS 服务（tingxie.ai-memo.cn 附属）

为「每日一练 / 用户自建词表」等不在预生成索引中的文本，按需用 edge-tts 合成音频。
- 监听 127.0.0.1:8899（仅本地，由 nginx 反代 /tts 暴露）
- GET /tts?text=XXX
    1) 计算文本 md5，若 /var/www/tingxie/audio/<md5>.mp3 已存在则直接返回
       （与预生成音频共用同一目录，nginx 后续可静态托管，无需再经本服务）
    2) 否则用 edge_tts 合成 -> ffmpeg 压缩(16k/22050) -> 缓存 -> 返回
- 带长度上限 + 按 md5 的合成锁，避免并发重复合成 / 写覆盖
- 合成失败返回 502，客户端可降级为静音

依赖（服务器 venv）：edge_tts；系统：ffmpeg
"""

import asyncio
import hashlib
import os
import subprocess
import threading
import urllib.parse
import edge_tts
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

AUDIO_DIR = "/var/www/tingxie/audio"
VOICE = "zh-CN-XiaoxiaoNeural"
MAX_LEN = 60          # 单条文本最大字符数（防滥用）
PORT = 8899
HTTP_TIMEOUT = 30     # 合成+压缩整体超时（秒）

_lock = threading.Lock()
_synth_locks = {}     # md5 -> threading.Lock


def get_synth_lock(key):
    with _lock:
        if key not in _synth_locks:
            _synth_locks[key] = threading.Lock()
        return _synth_locks[key]


async def _synth(text, out_path):
    """edge_tts 合成到 out_path（mp3）。"""
    comm = edge_tts.Communicate(text, VOICE)
    await comm.save(out_path)


def _compress(src, dst):
    """ffmpeg 压缩到 16kbps / 22050Hz，统一与预生成音频一致的体积。"""
    subprocess.run(
        ["ffmpeg", "-y", "-i", src, "-ar", "22050", "-b:a", "16k", dst],
        check=True,
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )


def synth_and_cache(text, md5, final_path):
    """线程内：若已存在则跳过；否则合成并压缩缓存。带按 md5 的锁。"""
    lk = get_synth_lock(md5)
    with lk:
        if os.path.exists(final_path):
            return
        raw = final_path + ".raw.mp3"
        tmp = final_path + ".tmp.mp3"
        try:
            asyncio.run(_synth(text, raw))
            _compress(raw, tmp)
            os.replace(tmp, final_path)  # 原子替换，避免半截文件被读到
        finally:
            for p in (raw, tmp):
                try:
                    os.remove(p)
                except OSError:
                    pass


class Handler(BaseHTTPRequestHandler):
    server_version = "TingxieTTS/1.0"

    def log_message(self, fmt, *args):
        # 静默常规访问日志，仅保留错误
        if fmt.startswith("%s \"GET /tts"):
            return
        super().log_message(fmt, *args)

    def _send_file(self, path):
        try:
            with open(path, "rb") as f:
                data = f.read()
        except OSError:
            self.send_error(404)
            return
        self.send_response(200)
        self.send_header("Content-Type", "audio/mpeg")
        self.send_header("Content-Length", str(len(data)))
        self.send_header("Cache-Control", "public, max-age=31536000")
        self.send_header("Access-Control-Allow-Origin", "*")
        self.end_headers()
        self.wfile.write(data)

    def do_GET(self):
        parsed = urllib.parse.urlparse(self.path)
        if parsed.path != "/tts":
            self.send_error(404)
            return
        qs = urllib.parse.parse_qs(parsed.query)
        texts = qs.get("text")
        text = (texts[0].strip() if texts and texts[0] else "") if texts else ""
        if not text:
            self.send_error(400, "missing text")
            return
        if len(text) > MAX_LEN:
            self.send_error(400, "text too long")
            return

        md5 = hashlib.md5(text.encode("utf-8")).hexdigest()
        final = os.path.join(AUDIO_DIR, md5 + ".mp3")

        # 已存在（预生成或既往合成）→ 直接返回
        if os.path.exists(final):
            self._send_file(final)
            return

        try:
            synth_and_cache(text, md5, final)
            self._send_file(final)
        except subprocess.CalledProcessError:
            self.send_error(502, "tts compress failed")
        except Exception as e:  # noqa: BLE001
            self.send_error(502, "tts failed: %s" % e)


def main():
    os.makedirs(AUDIO_DIR, exist_ok=True)
    server = ThreadingHTTPServer(("127.0.0.1", PORT), Handler)
    print("Tingxie TTS server listening on 127.0.0.1:%d" % PORT, flush=True)
    server.serve_forever()


if __name__ == "__main__":
    main()
