"""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", "--noconfirm", "--distpath", str(BUILD_DIR / "app"), str(ROOT / "server.py"), ], ) exe = BUILD_DIR / "app" / "bililive-tts" / "bililive-tts.exe" internal = BUILD_DIR / "app" / "bililive-tts" / "_internal" base_lib = internal / "base_library.zip" if not exe.exists() or not base_lib.exists(): print(f"ERROR: PyInstaller build incomplete: exe={exe.exists()}, base_library.zip={base_lib.exists()}") 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. node_modules (if any) ─────────────────────────────────────── if (ROOT / "node_modules").exists(): step("Copying 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 / "english-kana-matcher.js", BUILD_DIR / "english-kana-matcher.js") shutil.copy2(ROOT / "package.json", BUILD_DIR / "package.json") if (ROOT / "event_rules.json").exists(): shutil.copy2(ROOT / "event_rules.json", BUILD_DIR / "event_rules.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())