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,177 @@
|
||||
"""Build portable zip package: bililive-touhou-tts-portable.zip
|
||||
|
||||
Usage:
|
||||
python build.py
|
||||
|
||||
Output: dist/bililive-touhou-tts-portable.zip
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).parent
|
||||
DIST = ROOT / "dist"
|
||||
BUILD_DIR = DIST / "portable"
|
||||
|
||||
|
||||
def step(msg: str) -> None:
|
||||
print(f"\n=== {msg} ===")
|
||||
|
||||
|
||||
def safe_copy_tree(src: Path, dst: Path, ignore_patterns: tuple = ()) -> None:
|
||||
"""Copy directory tree, skipping symlinks/recursion."""
|
||||
if dst.exists():
|
||||
shutil.rmtree(dst, ignore_errors=True)
|
||||
dst.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
to_visit = [(src, dst)]
|
||||
while to_visit:
|
||||
s, d = to_visit.pop()
|
||||
try:
|
||||
for entry in s.iterdir():
|
||||
if entry.name.startswith(".") and entry.name != ".npmignore":
|
||||
continue
|
||||
if entry.name in ignore_patterns:
|
||||
continue
|
||||
if entry.name == "__pycache__":
|
||||
continue
|
||||
|
||||
target = d / entry.name
|
||||
|
||||
if entry.is_symlink():
|
||||
continue # skip symlinks entirely
|
||||
|
||||
if entry.is_dir():
|
||||
if target.exists():
|
||||
try:
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
except OSError:
|
||||
continue
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
to_visit.append((entry, target))
|
||||
else:
|
||||
try:
|
||||
shutil.copy2(entry, target)
|
||||
except OSError:
|
||||
pass # skip unreadable files
|
||||
except OSError:
|
||||
pass # skip unreadable dirs
|
||||
|
||||
|
||||
def main() -> int:
|
||||
DIST.mkdir(exist_ok=True)
|
||||
if BUILD_DIR.exists():
|
||||
print(" Cleaning previous build...")
|
||||
for item in BUILD_DIR.iterdir():
|
||||
try:
|
||||
if item.is_dir():
|
||||
shutil.rmtree(item, ignore_errors=True)
|
||||
else:
|
||||
item.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
BUILD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ── 1. PyInstaller ─────────────────────────────────────────────────
|
||||
step("Building Python EXE with PyInstaller")
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable, "-m", "PyInstaller",
|
||||
"--onedir",
|
||||
"--name", "bililive-tts",
|
||||
"--add-data", f"{ROOT / 'static'}{os.pathsep}static",
|
||||
"--hidden-import", "blivedm",
|
||||
"--hidden-import", "blivedm.clients",
|
||||
"--hidden-import", "blivedm.clients.web",
|
||||
"--hidden-import", "blivedm.clients.ws_base",
|
||||
"--hidden-import", "blivedm.handlers",
|
||||
"--hidden-import", "blivedm.models",
|
||||
"--hidden-import", "blivedm.models.web",
|
||||
"--hidden-import", "blivedm.utils",
|
||||
"--hidden-import", "pypinyin",
|
||||
"--hidden-import", "sounddevice",
|
||||
"--hidden-import", "soundfile",
|
||||
"--hidden-import", "numpy",
|
||||
"--hidden-import", "aiohttp",
|
||||
"--hidden-import", "Brotli",
|
||||
"--hidden-import", "pure_protobuf",
|
||||
"--hidden-import", "yarl",
|
||||
"--hidden-import", "chinese2kana",
|
||||
"--hidden-import", "pinyin2kana_data",
|
||||
"--hidden-import", "audio_player",
|
||||
"--hidden-import", "danmaku_handler",
|
||||
"--hidden-import", "tts",
|
||||
"--hidden-import", "bili_login",
|
||||
"--distpath", str(BUILD_DIR / "app"),
|
||||
str(ROOT / "server.py"),
|
||||
],
|
||||
)
|
||||
exe = BUILD_DIR / "app" / "bililive-tts" / "bililive-tts.exe"
|
||||
if not exe.exists():
|
||||
print(f"ERROR: PyInstaller failed, {exe} not found")
|
||||
return 1
|
||||
print(f" OK: {exe} ({exe.stat().st_size // 1024 // 1024} MB)")
|
||||
|
||||
# ── 2. Portable Node.js ────────────────────────────────────────────
|
||||
step("Copying portable Node.js")
|
||||
node_src = Path(os.environ.get("NODE_PATH", r"C:\nvm4w\nodejs\node.exe"))
|
||||
shutil.copy2(node_src, BUILD_DIR / "node.exe")
|
||||
print(f" OK: node.exe")
|
||||
|
||||
# ── 3. aquestalk.js (only what's needed) ───────────────────────────
|
||||
step("Copying aquestalk.js runtime")
|
||||
aq_src = ROOT / "aquestalk.js"
|
||||
aq_dst = BUILD_DIR / "aquestalk.js"
|
||||
|
||||
print(" dist/ ...")
|
||||
safe_copy_tree(aq_src / "dist", aq_dst / "dist")
|
||||
print(" voices/ ...")
|
||||
safe_copy_tree(aq_src / "voices", aq_dst / "voices")
|
||||
print(" node_modules/ (symlink-safe) ...")
|
||||
safe_copy_tree(aq_src / "node_modules", aq_dst / "node_modules",
|
||||
ignore_patterns=("bililive-touhou-tts",))
|
||||
|
||||
# ── 4. kuroshiro node_modules ──────────────────────────────────────
|
||||
step("Copying kuroshiro node_modules")
|
||||
safe_copy_tree(ROOT / "node_modules", BUILD_DIR / "node_modules")
|
||||
|
||||
# ── 5. Bridge + config ─────────────────────────────────────────────
|
||||
step("Copying bridge files")
|
||||
shutil.copy2(ROOT / "tts_bridge.js", BUILD_DIR / "tts_bridge.js")
|
||||
shutil.copy2(ROOT / "package.json", BUILD_DIR / "package.json")
|
||||
print(" OK")
|
||||
|
||||
# ── 6. Launcher ────────────────────────────────────────────────────
|
||||
step("Creating launcher")
|
||||
bat = BUILD_DIR / "start.bat"
|
||||
bat.write_text(
|
||||
'@echo off\r\n'
|
||||
'cd /d "%~dp0"\r\n'
|
||||
'start "" "app\\bililive-tts\\bililive-tts.exe"\r\n',
|
||||
encoding="ascii",
|
||||
)
|
||||
print(f" OK")
|
||||
|
||||
# ── 7. Zip ─────────────────────────────────────────────────────────
|
||||
step("Creating zip")
|
||||
zip_path = DIST / "bililive-touhou-tts-portable.zip"
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for file in BUILD_DIR.rglob("*"):
|
||||
if ".git" in file.parts or "__pycache__" in file.parts:
|
||||
continue
|
||||
if file.is_dir():
|
||||
continue
|
||||
arcname = str(file.relative_to(BUILD_DIR))
|
||||
zf.write(file, arcname)
|
||||
|
||||
size_mb = zip_path.stat().st_size / (1024 * 1024)
|
||||
print(f"\n Done: {zip_path} ({size_mb:.1f} MB)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user