From 66a1060685664fb7733f69dc289bbf0b8f316625 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Wed, 22 Apr 2026 23:19:38 +0300 Subject: [PATCH] Implement LiveKit calls --- .gitignore | 3 + .vscode/tasks.json | 22 +- backend/requirements.txt | 3 +- backend/services/main/main.py | 31 ++- backend/services/main/routes/livekit.py | 100 +++++++++ backend/services/main/routes/messaging.py | 4 +- backend/services/main/service_calls.py | 23 +- deployment/frontend/server.ts | 7 +- deployment/livekit.dev.yaml | 12 ++ deployment/livekit.local.env.example | 7 + frontend/vite.config.ts | 10 +- package.json | 2 + scripts/backend:run.sh | 2 +- scripts/livekit/ensure.py | 242 ++++++++++++++++++++++ scripts/livekit:ensure.sh | 7 + scripts/livekit:run.sh | 17 ++ 16 files changed, 465 insertions(+), 27 deletions(-) create mode 100644 backend/services/main/routes/livekit.py create mode 100644 deployment/livekit.dev.yaml create mode 100644 deployment/livekit.local.env.example create mode 100644 scripts/livekit/ensure.py create mode 100644 scripts/livekit:ensure.sh create mode 100644 scripts/livekit:run.sh diff --git a/.gitignore b/.gitignore index 61b606c..823971d 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,9 @@ Temporary Items # iCloud generated files *.icloud +### FromChat local tools (downloaded LiveKit server binary) ### +.tools/ + ### Node ### # Logs logs diff --git a/.vscode/tasks.json b/.vscode/tasks.json index ae21fc9..466e1a5 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -37,6 +37,24 @@ }, "isBackground": true }, + { + "label": "LiveKit", + "type": "npm", + "script": "livekit:run", + "options": { + "cwd": "${workspaceFolder}" + }, + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + }, + "group": { + "kind": "build" + }, + "isBackground": true + }, { "label": "Frontend (Electron)", "type": "npm", @@ -58,7 +76,7 @@ { "label": "Web", - "dependsOn": ["Backend", "Frontend (Web)"], + "dependsOn": ["LiveKit", "Backend", "Frontend (Web)"], "dependsOrder": "parallel", "group": { "kind": "build", @@ -76,7 +94,7 @@ }, { "label": "Electron", - "dependsOn": ["Backend", "Frontend (Electron)"], + "dependsOn": ["LiveKit", "Backend", "Frontend (Electron)"], "dependsOrder": "parallel", "group": { "kind": "build" diff --git a/backend/requirements.txt b/backend/requirements.txt index 5b484a9..23f41c5 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -18,4 +18,5 @@ rich>=13.9.4 slowapi>=0.1.9 firebase_admin>=7.1.0 PyNaCl>=1.5.0 -numpy \ No newline at end of file +numpy +livekit-api>=0.8.0 \ No newline at end of file diff --git a/backend/services/main/main.py b/backend/services/main/main.py index d0dddaa..1c2a020 100644 --- a/backend/services/main/main.py +++ b/backend/services/main/main.py @@ -10,7 +10,7 @@ import logging from sqlalchemy.orm.exc import DetachedInstanceError # Import from same directory -from .routes import account, messaging, profile, push, webrtc, devices, moderation, download, keys, envelope_messaging +from .routes import account, messaging, profile, push, webrtc, devices, moderation, download, keys, envelope_messaging, livekit from .models import User from .constants import OWNER_USERNAME from .utils import get_client_ip @@ -284,17 +284,27 @@ if add_security_middleware: add_security_middleware(app) # CORS +_lan_ip = os.getenv("LAN_IP", "").strip() +_cors_origins = [ + "https://fromchat.ru", + "https://beta.fromchat.ru", + "https://www.fromchat.ru", + "http://127.0.0.1:8301", + "http://127.0.0.1:8300", + "http://localhost:8301", + "http://localhost:8300", +] +if _lan_ip: + _cors_origins.extend( + [ + f"http://{_lan_ip}:8301", + f"http://{_lan_ip}:8300", + ] + ) + app.add_middleware( CORSMiddleware, - allow_origins=[ - "https://fromchat.ru", - "https://beta.fromchat.ru", - "https://www.fromchat.ru", - "http://127.0.0.1:8301", - "http://127.0.0.1:8300", - "http://localhost:8301", - "http://localhost:8300", - ], + allow_origins=_cors_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -307,6 +317,7 @@ app.include_router(messaging.router) app.include_router(profile.router) app.include_router(push.router, prefix="/push") app.include_router(webrtc.router, prefix="/webrtc") +app.include_router(livekit.router, prefix="/livekit") app.include_router(devices.router, prefix="/devices") app.include_router(moderation.router) app.include_router(download.router) diff --git a/backend/services/main/routes/livekit.py b/backend/services/main/routes/livekit.py new file mode 100644 index 0000000..a4dedd3 --- /dev/null +++ b/backend/services/main/routes/livekit.py @@ -0,0 +1,100 @@ +""" +Mint LiveKit participant JWTs for DM calls. Requires LIVEKIT_API_KEY, LIVEKIT_API_SECRET, +and LIVEKIT_URL (WebSocket URL for clients, e.g. wss://livekit.example.com or ws://host:7880). +""" +from __future__ import annotations + +import logging +import os +import uuid +from datetime import timedelta + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from ..dependencies import get_current_user, get_db +from ..models import User + +logger = logging.getLogger("uvicorn.error") + +router = APIRouter() + + +class LiveKitTokenRequest(BaseModel): + peer_user_id: int = Field(..., description="The other participant (DM peer)") + room_name: str | None = Field( + None, + description="Existing room from an invite; omit to create a new room", + ) + + +class LiveKitTokenResponse(BaseModel): + server_url: str + token: str + room_name: str + + +def _livekit_env() -> tuple[str, str, str]: + api_key = os.getenv("LIVEKIT_API_KEY", "").strip() + api_secret = os.getenv("LIVEKIT_API_SECRET", "").strip() + server_url = os.getenv("LIVEKIT_URL", "").strip() + if not api_key or not api_secret or not server_url: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="LiveKit is not configured (LIVEKIT_API_KEY / LIVEKIT_API_SECRET / LIVEKIT_URL)", + ) + return api_key, api_secret, server_url + + +@router.post("/token", response_model=LiveKitTokenResponse) +async def create_livekit_token( + body: LiveKitTokenRequest, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """ + Issue a short-lived JWT for joining a 1:1 call room with peer_user_id. + """ + if body.peer_user_id == user.id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="peer_user_id must differ from caller") + + peer = db.query(User).filter(User.id == body.peer_user_id).first() + if not peer: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Peer user not found") + + api_key, api_secret, server_url = _livekit_env() + + if body.room_name and body.room_name.strip(): + room_name = body.room_name.strip() + else: + room_name = f"call-{uuid.uuid4().hex}" + + try: + from livekit.api import AccessToken, VideoGrants + except ImportError as e: + logger.exception("livekit-api not installed") + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="LiveKit SDK unavailable on server", + ) from e + + grants = VideoGrants( + room_join=True, + room=room_name, + can_publish=True, + can_subscribe=True, + can_publish_data=True, + ) + + token = ( + AccessToken(api_key, api_secret) + .with_identity(str(user.id)) + .with_name(user.username or str(user.id)) + .with_ttl(timedelta(hours=1)) + .with_grants(grants) + ) + + jwt_token = token.to_jwt() + + return LiveKitTokenResponse(server_url=server_url, token=jwt_token, room_name=room_name) diff --git a/backend/services/main/routes/messaging.py b/backend/services/main/routes/messaging.py index 8e363be..c48951d 100644 --- a/backend/services/main/routes/messaging.py +++ b/backend/services/main/routes/messaging.py @@ -46,10 +46,12 @@ os.makedirs(FILES_ENCRYPTED_DIR, exist_ok=True) def _get_file_storage_url() -> str: + lan = os.getenv("LAN_IP", "").strip() + default_fs = f"http://{lan}:8302" if lan else "http://127.0.0.1:8302" return ( os.getenv("FILE_STORAGE_SERVICE_URL") or os.getenv("FILE_STORAGE_URL") - or "http://127.0.0.1:8302" + or default_fs ) _SPAM_WINDOW_SECONDS = 45 diff --git a/backend/services/main/service_calls.py b/backend/services/main/service_calls.py index 408c9f9..cbc1fe4 100644 --- a/backend/services/main/service_calls.py +++ b/backend/services/main/service_calls.py @@ -18,6 +18,13 @@ from fastapi import HTTPException, status logger = logging.getLogger("uvicorn.error") +def _default_file_storage_base_url() -> str: + lan = os.getenv("LAN_IP", "").strip() + if lan: + return f"http://{lan}:8302" + return "http://127.0.0.1:8302" + + def _get_messaging_module(): try: from backend.services.messaging import main as messaging_module @@ -151,7 +158,7 @@ async def upload_file_to_storage(file_obj: Any, timeout: float = 30.0) -> Dict[s # Out-of-process HTTP # Prefer explicit FILE_STORAGE_URL, fall back to FILE_STORAGE_SERVICE_URL, default to localhost for dev - storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or "http://127.0.0.1:8302" + storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or _default_file_storage_base_url() url = f"{storage_url.rstrip('/')}/upload" try: try: @@ -219,7 +226,7 @@ async def store_encrypted_file( # Out-of-process HTTP # Prefer explicit FILE_STORAGE_URL, fall back to FILE_STORAGE_SERVICE_URL, default to localhost for dev - file_storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or "http://127.0.0.1:8302" + file_storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or _default_file_storage_base_url() url = f"{file_storage_url.rstrip('/')}/upload-base64" try: try: @@ -463,7 +470,7 @@ async def init_resumable_upload_in_storage( logger.error("In-process file_storage.init_resumable_upload failed: %s", e) raise - file_storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or "http://127.0.0.1:8302" + file_storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or _default_file_storage_base_url() url = f"{file_storage_url.rstrip('/')}/uploads/resumable/init" payload = { "filename": filename, @@ -493,7 +500,7 @@ async def get_resumable_upload_status_in_storage( logger.error("In-process file_storage.get_resumable_upload_status failed: %s", e) raise - file_storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or "http://127.0.0.1:8302" + file_storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or _default_file_storage_base_url() url = f"{file_storage_url.rstrip('/')}/uploads/resumable/{upload_id}" import httpx @@ -520,7 +527,7 @@ async def upload_resumable_chunk_in_storage( logger.error("In-process file_storage.upload_resumable_chunk failed: %s", e) raise - file_storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or "http://127.0.0.1:8302" + file_storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or _default_file_storage_base_url() url = f"{file_storage_url.rstrip('/')}/uploads/resumable/{upload_id}" payload = { "offset": offset, @@ -547,7 +554,7 @@ async def complete_resumable_upload_in_storage( logger.error("In-process file_storage.complete_resumable_upload failed: %s", e) raise - file_storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or "http://127.0.0.1:8302" + file_storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or _default_file_storage_base_url() url = f"{file_storage_url.rstrip('/')}/uploads/resumable/{upload_id}/complete" import httpx @@ -570,7 +577,7 @@ async def get_resumable_upload_data_in_storage( logger.error("In-process file_storage.get_resumable_upload_data failed: %s", e) raise - file_storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or "http://127.0.0.1:8302" + file_storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or _default_file_storage_base_url() url = f"{file_storage_url.rstrip('/')}/uploads/resumable/{upload_id}/data-b64" import httpx @@ -593,7 +600,7 @@ async def delete_resumable_upload_in_storage( logger.error("In-process file_storage.delete_resumable_upload failed: %s", e) raise - file_storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or "http://127.0.0.1:8302" + file_storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or _default_file_storage_base_url() url = f"{file_storage_url.rstrip('/')}/uploads/resumable/{upload_id}" import httpx diff --git a/deployment/frontend/server.ts b/deployment/frontend/server.ts index 1faa187..a4579a6 100644 --- a/deployment/frontend/server.ts +++ b/deployment/frontend/server.ts @@ -4,8 +4,11 @@ import { resolve } from 'path'; const app = express(); const port = process.env.PORT || 3000; -const backendHost = process.env.BACKEND_HOST || "http://localhost:8300"; -const fileStorageHost = process.env.FILE_STORAGE_HOST || "http://localhost:8302"; +const lan = process.env.LAN_IP; +const backendHost = + process.env.BACKEND_HOST || (lan ? `http://${lan}:8300` : "http://localhost:8300"); +const fileStorageHost = + process.env.FILE_STORAGE_HOST || (lan ? `http://${lan}:8302` : "http://localhost:8302"); const filePath = process.env.STATIC_FILE_PATH || "."; // API proxy middleware diff --git a/deployment/livekit.dev.yaml b/deployment/livekit.dev.yaml new file mode 100644 index 0000000..316e4a2 --- /dev/null +++ b/deployment/livekit.dev.yaml @@ -0,0 +1,12 @@ +# Local LiveKit SFU for FromChat development. +# Match keys in deployment/.env — see deployment/livekit.local.env.example +port: 7880 + +rtc: + tcp_port: 7881 + port_range_start: 50000 + port_range_end: 60000 + use_external_ip: false + +keys: + fromchat_dev: "local_dev_secret_must_be_at_least_32_characters_long" diff --git a/deployment/livekit.local.env.example b/deployment/livekit.local.env.example new file mode 100644 index 0000000..e34c5b7 --- /dev/null +++ b/deployment/livekit.local.env.example @@ -0,0 +1,7 @@ +# Append these lines to deployment/.env so the Python backend can mint LiveKit JWTs. +# Keys must match deployment/livekit.dev.yaml (keys.fromchat_dev). +# LAN_IP = this machine’s address on your LAN (phones/emulators use it to reach LiveKit). +LAN_IP=192.168.1.14 +LIVEKIT_API_KEY=fromchat_dev +LIVEKIT_API_SECRET=local_dev_secret_must_be_at_least_32_characters_long +LIVEKIT_URL=ws://192.168.1.14:7880 diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 32018c9..554b21f 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -66,6 +66,9 @@ if (process.env.VITE_ELECTRON) { ); } +const _lan = process.env.LAN_IP; +const _backendProxy = _lan ? `http://${_lan}:8300/` : "http://127.0.0.1:8300/"; + export default defineConfig({ plugins: plugins, resolve: { @@ -80,13 +83,16 @@ export default defineConfig({ strictPort: true, proxy: { "/api": { - target: "http://127.0.0.1:8300/", + target: _backendProxy, changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, ""), ws: true } }, - allowedHosts: ["beta.fromchat.ru"] + allowedHosts: [ + "beta.fromchat.ru", + ...(_lan ? [_lan] : []), + ] }, appType: "spa", optimizeDeps: { diff --git a/package.json b/package.json index 0707987..c514c21 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "authors": "denis0001-dev", "scripts": { "backend:run": "bash ./scripts/backend:run.sh", + "livekit:ensure": "bash ./scripts/livekit:ensure.sh", + "livekit:run": "bash ./scripts/livekit:run.sh", "backend:dependencies": "python3 -m venv .venv && ./.venv/bin/pip3 install -r backend/requirements.txt", "backend:reinstall": "rm -rf .venv && npm run backend:dependencies", "backend:clean": "rm -rf backend/data", diff --git a/scripts/backend:run.sh b/scripts/backend:run.sh index 80d0ffe..573e161 100644 --- a/scripts/backend:run.sh +++ b/scripts/backend:run.sh @@ -1,7 +1,7 @@ cd backend dotenv -e ../deployment/.env -- \ ../.venv/bin/uvicorn main:app \ - --host 127.0.0.1 \ + --host 0.0.0.0 \ --port 8300 \ --reload \ --reload-exclude './alembic' \ diff --git a/scripts/livekit/ensure.py b/scripts/livekit/ensure.py new file mode 100644 index 0000000..4777f8b --- /dev/null +++ b/scripts/livekit/ensure.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +""" +Download the LiveKit server binary for the current OS/arch from the latest GitHub release. + +macOS: GitHub releases often omit darwin assets. If `livekit-server` is missing, this script +runs `brew install livekit` automatically (Homebrew must be installed). + +Linux / Windows: download the matching .tar.gz / .zip from the latest release. +""" +from __future__ import annotations + +import json +import os +import platform +import shutil +import stat +import subprocess +import sys +import tarfile +import urllib.request +import zipfile +from pathlib import Path + +REPO = "livekit/livekit" +API_LATEST = f"https://api.github.com/repos/{REPO}/releases/latest" + + +def repo_root() -> Path: + return Path(__file__).resolve().parents[2] + + +def tools_dir(root: Path) -> Path: + return root / ".tools" / "livekit" + + +def platform_triple() -> tuple[str, str, str]: + """Returns (os_name, arch, archive_ext). archive_ext is tar.gz or zip.""" + system = platform.system().lower() + machine = platform.machine().lower() + if system == "darwin": + os_name = "darwin" + arch = "arm64" if machine in ("arm64", "aarch64") else "amd64" + return os_name, arch, "tar.gz" + if system == "linux": + os_name = "linux" + if machine in ("aarch64", "arm64"): + arch = "arm64" + elif machine in ("armv7l", "armv7"): + arch = "armv7" + else: + arch = "amd64" + return os_name, arch, "tar.gz" + if system == "windows": + os_name = "windows" + arch = "arm64" if machine in ("arm64", "aarch64") else "amd64" + return os_name, arch, "zip" + raise SystemExit(f"Unsupported OS: {system!r}") + + +def fetch_latest_release() -> dict: + req = urllib.request.Request( + API_LATEST, + headers={"Accept": "application/vnd.github+json", "User-Agent": "fromchat-livekit-ensure"}, + ) + with urllib.request.urlopen(req, timeout=120) as resp: + return json.load(resp) + + +def pick_asset(assets: list[dict], filename: str) -> dict | None: + for a in assets: + if a.get("name") == filename: + return a + return None + + +def download(url: str, dest: Path) -> None: + dest.parent.mkdir(parents=True, exist_ok=True) + req = urllib.request.Request(url, headers={"User-Agent": "fromchat-livekit-ensure"}) + with urllib.request.urlopen(req, timeout=300) as resp: + dest.write_bytes(resp.read()) + + +def chmod_plus_x(path: Path) -> None: + if path.suffix.lower() == ".exe" or platform.system().lower() == "windows": + return + mode = path.stat().st_mode + path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +def find_server_binary(extract_dir: Path) -> Path | None: + for name in ("livekit-server", "livekit-server.exe"): + for p in extract_dir.rglob(name): + if p.is_file(): + return p + return None + + +def resolve_macos_binary(td: Path) -> str | None: + w = shutil.which("livekit-server") + if w: + return w + # Homebrew default locations (Apple Silicon / Intel) + for candidate in ( + Path("/opt/homebrew/bin/livekit-server"), + Path("/usr/local/bin/livekit-server"), + ): + if candidate.is_file(): + return str(candidate) + return None + + +def find_brew() -> str | None: + w = shutil.which("brew") + if w: + return w + for candidate in ("/opt/homebrew/bin/brew", "/usr/local/bin/brew"): + p = Path(candidate) + if p.is_file(): + return str(p) + return None + + +def install_livekit_via_homebrew() -> bool: + brew = find_brew() + if not brew: + print( + "Homebrew not found. Install it from https://brew.sh then re-run this task.", + file=sys.stderr, + ) + return False + print("Installing LiveKit via Homebrew (brew install livekit) …", file=sys.stderr) + result = subprocess.run( + [brew, "install", "livekit"], + check=False, + ) + if result.returncode != 0: + print("brew install livekit failed.", file=sys.stderr) + return False + return True + + +def main() -> int: + root = repo_root() + td = tools_dir(root) + td.mkdir(parents=True, exist_ok=True) + + os_name, arch, ext = platform_triple() + + # macOS: GitHub release assets often omit darwin; use Homebrew (auto-install if needed). + if os_name == "darwin": + mac_bin = resolve_macos_binary(td) + if not mac_bin: + if not install_livekit_via_homebrew(): + return 1 + mac_bin = resolve_macos_binary(td) + if not mac_bin: + print( + "livekit-server still not found after brew install. " + "Open a new terminal or run: hash -r", + file=sys.stderr, + ) + return 1 + (td / ".version").write_text("system\n", encoding="utf-8") + print(f"Using LiveKit server: {mac_bin}", file=sys.stderr) + print(mac_bin) + return 0 + + release = fetch_latest_release() + tag = release.get("tag_name") or "" + if not tag.startswith("v"): + print("Unexpected release tag", tag, file=sys.stderr) + return 1 + ver = tag[1:] + assets = release.get("assets") or [] + + if ext == "zip": + archive_name = f"livekit_{ver}_{os_name}_{arch}.zip" + else: + archive_name = f"livekit_{ver}_{os_name}_{arch}.tar.gz" + + version_file = td / ".version" + bin_hint = td / ("livekit-server.exe" if ext == "zip" else "livekit-server") + + if ( + version_file.is_file() + and bin_hint.is_file() + and version_file.read_text(encoding="utf-8").strip() == tag + ): + print(f"LiveKit {tag} already present at {bin_hint}", file=sys.stderr) + print(str(bin_hint)) + return 0 + + asset = pick_asset(assets, archive_name) + if not asset: + print( + f"No GitHub asset {archive_name!r} for {tag}. See https://github.com/{REPO}/releases", + file=sys.stderr, + ) + return 1 + + url = asset["browser_download_url"] + staging = td / "_staging" + if staging.exists(): + shutil.rmtree(staging) + staging.mkdir(parents=True) + + archive = staging / asset["name"] + print(f"Downloading LiveKit {tag}: {asset['name']} …", file=sys.stderr) + download(url, archive) + + extract_dir = staging / "extract" + extract_dir.mkdir() + + if archive_name.endswith(".tar.gz"): + with tarfile.open(archive, "r:gz") as tf: + tf.extractall(extract_dir) + elif archive_name.endswith(".zip"): + with zipfile.ZipFile(archive, "r") as zf: + zf.extractall(extract_dir) + else: + print(f"Unsupported archive: {archive_name}", file=sys.stderr) + return 1 + + binary = find_server_binary(extract_dir) + if not binary: + print("Could not find livekit-server binary after extract.", file=sys.stderr) + return 1 + + if bin_hint.exists(): + bin_hint.unlink() + shutil.move(str(binary), str(bin_hint)) + chmod_plus_x(bin_hint) + + shutil.rmtree(staging) + version_file.write_text(tag + "\n", encoding="utf-8") + print(f"Installed LiveKit {tag} → {bin_hint}", file=sys.stderr) + print(str(bin_hint)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/livekit:ensure.sh b/scripts/livekit:ensure.sh new file mode 100644 index 0000000..420fd2e --- /dev/null +++ b/scripts/livekit:ensure.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Ensures a LiveKit server binary is available (downloads from GitHub on Linux/Windows; +# on macOS runs `brew install livekit` if needed — see scripts/livekit/ensure.py). +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +exec python3 "$ROOT/scripts/livekit/ensure.py" diff --git a/scripts/livekit:run.sh b/scripts/livekit:run.sh new file mode 100644 index 0000000..9945d18 --- /dev/null +++ b/scripts/livekit:run.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Start LiveKit with deployment/livekit.dev.yaml (after ensure.py). +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$ROOT" + +BIN="$(python3 "$ROOT/scripts/livekit/ensure.py" | tail -1)" +CONFIG="$ROOT/deployment/livekit.dev.yaml" + +if [[ ! -f "$CONFIG" ]]; then + echo "Missing $CONFIG" >&2 + exit 1 +fi + +echo "Starting LiveKit: $BIN --config $CONFIG" >&2 +exec "$BIN" --config "$CONFIG"