feat: web UI + QR login + portable build
- Add web UI panel (dark theme) at / with REST API - Add Bilibili QR code scan login with browser header emulation - Add wanakana for English -> katakana conversion - Add volume control (0%-200%) - Add message format with '说、' pause marker - PyInstaller --onedir build for portable exe - Rate-limit danmaku logging (1/20) - Fix aiohttp.access log spam - Add README.md
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
"""Bilibili Live → Yukkuri TTS — Web UI Server."""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from danmaku_handler import DanmakuClient
|
||||
from chinese2kana import chinese_to_kana, filter_kana
|
||||
from tts import TTSBridge
|
||||
from audio_player import play_wav_async, get_output_devices
|
||||
from bili_login import BiliLoginSession
|
||||
|
||||
logger = logging.getLogger("bililive-tts-server")
|
||||
|
||||
def _get_static_dir() -> Path:
|
||||
if getattr(sys, "frozen", False):
|
||||
return Path(sys._MEIPASS) / "static"
|
||||
return Path(__file__).parent / "static"
|
||||
|
||||
STATIC_DIR = _get_static_dir()
|
||||
|
||||
|
||||
class TTSService:
|
||||
def __init__(self):
|
||||
self._bridge: TTSBridge | None = None
|
||||
self._danmaku_client: DanmakuClient | None = None
|
||||
self._queue: asyncio.Queue | None = None
|
||||
self._shutdown_event: asyncio.Event | None = None
|
||||
self._worker_task: asyncio.Task | None = None
|
||||
self._cookies: dict[str, str] | None = None
|
||||
self.running = False
|
||||
self.start_time: float = 0
|
||||
self.messages_processed = 0
|
||||
self.recent_messages: list[str] = []
|
||||
self.current_config: dict = {}
|
||||
self._volume: int = 100
|
||||
|
||||
def set_cookies(self, cookies: dict[str, str] | None) -> None:
|
||||
self._cookies = cookies
|
||||
|
||||
async def start(self, config: dict) -> None:
|
||||
if self.running:
|
||||
raise RuntimeError("Already running")
|
||||
room_id = config["room_id"]
|
||||
voice = config.get("voice", "f1")
|
||||
speed = config.get("speed", 100)
|
||||
self._volume = config.get("volume", 100)
|
||||
message_format = config.get("format", "{uname}\u8bf4\u3001 {msg}")
|
||||
convert_numbers = config.get("convert_numbers", True)
|
||||
self.current_config = config
|
||||
self.recent_messages = []
|
||||
self.messages_processed = 0
|
||||
self.start_time = time.time()
|
||||
self._queue = asyncio.Queue(maxsize=256)
|
||||
self._shutdown_event = asyncio.Event()
|
||||
self._bridge = TTSBridge(voice=voice, speed=speed)
|
||||
await self._bridge.start()
|
||||
self._danmaku_client = DanmakuClient(
|
||||
room_id=room_id, queue=self._queue,
|
||||
cookies=self._cookies, message_format=message_format,
|
||||
)
|
||||
await self._danmaku_client.start()
|
||||
self.running = True
|
||||
self._worker_task = asyncio.create_task(self._tts_worker(convert_numbers))
|
||||
|
||||
async def stop(self) -> None:
|
||||
if not self.running:
|
||||
return
|
||||
self.running = False
|
||||
if self._shutdown_event:
|
||||
self._shutdown_event.set()
|
||||
if self._worker_task:
|
||||
self._worker_task.cancel()
|
||||
try:
|
||||
await self._worker_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if self._danmaku_client:
|
||||
await self._danmaku_client.stop()
|
||||
if self._bridge:
|
||||
await self._bridge.stop()
|
||||
self._queue = None
|
||||
self._bridge = None
|
||||
self._danmaku_client = None
|
||||
|
||||
async def _tts_worker(self, convert_numbers: bool) -> None:
|
||||
total_in = 0
|
||||
total_out = 0
|
||||
while not self._shutdown_event.is_set():
|
||||
try:
|
||||
text = await asyncio.wait_for(self._queue.get(), timeout=0.5)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
total_in += 1
|
||||
try:
|
||||
kana = chinese_to_kana(text, convert_numbers=convert_numbers)
|
||||
kana = filter_kana(kana)
|
||||
if not kana.strip():
|
||||
self._add_message(f"SKIP(empty kana): {text}")
|
||||
self._queue.task_done()
|
||||
continue
|
||||
self._add_message(f"[{total_in}] {text}")
|
||||
logger.info("TTS #%d: %s -> %s", total_in, text[:40], kana[:40])
|
||||
wav_data = await self._bridge.synthesize(kana)
|
||||
await play_wav_async(wav_data, self._volume)
|
||||
total_out += 1
|
||||
self.messages_processed += 1
|
||||
except asyncio.TimeoutError:
|
||||
self._add_message(f"TIMEOUT: {text[:60]}")
|
||||
logger.error("TTS timeout for: %s", text[:60])
|
||||
except Exception as e:
|
||||
self._add_message(f"ERR: {text[:60]} | {e}")
|
||||
logger.error("TTS error: %s", str(e)[:200])
|
||||
finally:
|
||||
self._queue.task_done()
|
||||
|
||||
def _add_message(self, msg: str) -> None:
|
||||
self.recent_messages.append(msg)
|
||||
if len(self.recent_messages) > 200:
|
||||
self.recent_messages = self.recent_messages[-200:]
|
||||
|
||||
def status(self) -> dict:
|
||||
uptime = time.time() - self.start_time if self.start_time else 0
|
||||
return {
|
||||
"running": self.running,
|
||||
"uptime": int(uptime),
|
||||
"messages_processed": self.messages_processed,
|
||||
"recent_messages": self.recent_messages[-50:],
|
||||
"config": self.current_config,
|
||||
}
|
||||
|
||||
|
||||
tts_service = TTSService()
|
||||
login_session = BiliLoginSession()
|
||||
|
||||
|
||||
async def index_handler(request: web.Request) -> web.Response:
|
||||
return web.FileResponse(STATIC_DIR / "index.html")
|
||||
|
||||
|
||||
async def api_start(request: web.Request) -> web.Response:
|
||||
try:
|
||||
config = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"error": "Invalid JSON"}, status=400)
|
||||
room_id = config.get("room_id")
|
||||
if not room_id:
|
||||
return web.json_response({"error": "room_id is required"}, status=400)
|
||||
try:
|
||||
room_id = int(room_id)
|
||||
except (ValueError, TypeError):
|
||||
return web.json_response({"error": "room_id must be an integer"}, status=400)
|
||||
config["room_id"] = room_id
|
||||
config.setdefault("voice", "f1")
|
||||
config.setdefault("speed", 100)
|
||||
config.setdefault("volume", 100)
|
||||
config.setdefault("format", "{uname}\u8bf4\u3001 {msg}")
|
||||
config.setdefault("convert_numbers", True)
|
||||
if tts_service.running:
|
||||
try:
|
||||
await tts_service.stop()
|
||||
except Exception:
|
||||
pass
|
||||
tts_service.set_cookies(login_session.cookies if login_session.is_logged_in else None)
|
||||
try:
|
||||
await tts_service.start(config)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to start TTS service")
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
return web.json_response({"status": "started"})
|
||||
|
||||
|
||||
async def api_stop(request: web.Request) -> web.Response:
|
||||
try:
|
||||
await tts_service.stop()
|
||||
except Exception as e:
|
||||
logger.exception("Failed to stop TTS service")
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
return web.json_response({"status": "stopped"})
|
||||
|
||||
|
||||
async def api_status(request: web.Request) -> web.Response:
|
||||
s = tts_service.status()
|
||||
s["login"] = {
|
||||
"logged_in": login_session.is_logged_in,
|
||||
"username": login_session.username,
|
||||
"uid": login_session.uid,
|
||||
}
|
||||
return web.json_response(s)
|
||||
|
||||
|
||||
async def api_devices(request: web.Request) -> web.Response:
|
||||
try:
|
||||
devices = get_output_devices()
|
||||
return web.json_response({"devices": devices})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def api_qr_generate(request: web.Request) -> web.Response:
|
||||
try:
|
||||
result = await login_session.generate_qrcode()
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.exception("QR generate failed")
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def api_qr_poll(request: web.Request) -> web.Response:
|
||||
try:
|
||||
result = await login_session.poll_login()
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.exception("QR poll failed")
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def api_login_status(request: web.Request) -> web.Response:
|
||||
return web.json_response({
|
||||
"logged_in": login_session.is_logged_in,
|
||||
"username": login_session.username,
|
||||
"uid": login_session.uid,
|
||||
})
|
||||
|
||||
|
||||
async def api_logout(request: web.Request) -> web.Response:
|
||||
login_session.clear()
|
||||
return web.json_response({"status": "logged_out"})
|
||||
|
||||
|
||||
def create_app() -> web.Application:
|
||||
app = web.Application()
|
||||
app.router.add_get("/", index_handler)
|
||||
app.router.add_post("/api/start", api_start)
|
||||
app.router.add_post("/api/stop", api_stop)
|
||||
app.router.add_get("/api/status", api_status)
|
||||
app.router.add_get("/api/devices", api_devices)
|
||||
app.router.add_get("/api/qr/generate", api_qr_generate)
|
||||
app.router.add_get("/api/qr/poll", api_qr_poll)
|
||||
app.router.add_get("/api/login/status", api_login_status)
|
||||
app.router.add_get("/api/logout", api_logout)
|
||||
return app
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Bilibili Live → Yukkuri TTS Web UI")
|
||||
parser.add_argument("--port", "-p", type=int, default=8080)
|
||||
parser.add_argument("--host", type=str, default="127.0.0.1")
|
||||
parser.add_argument("--debug", "-d", action="store_true")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if args.debug else logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
logging.getLogger("aiohttp.access").setLevel(logging.WARNING)
|
||||
app = create_app()
|
||||
print(f"\n Bilibili Live → Yukkuri TTS Web UI")
|
||||
print(f" Open: http://{args.host}:{args.port}\n")
|
||||
web.run_app(app, host=args.host, port=args.port, print=lambda *a: None)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user