Implement LiveKit calls

This commit is contained in:
2026-04-22 23:19:38 +03:00
Unverified
parent b7d884df58
commit 66a1060685
16 changed files with 465 additions and 27 deletions
+3
View File
@@ -35,6 +35,9 @@ Temporary Items
# iCloud generated files # iCloud generated files
*.icloud *.icloud
### FromChat local tools (downloaded LiveKit server binary) ###
.tools/
### Node ### ### Node ###
# Logs # Logs
logs logs
+20 -2
View File
@@ -37,6 +37,24 @@
}, },
"isBackground": true "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)", "label": "Frontend (Electron)",
"type": "npm", "type": "npm",
@@ -58,7 +76,7 @@
{ {
"label": "Web", "label": "Web",
"dependsOn": ["Backend", "Frontend (Web)"], "dependsOn": ["LiveKit", "Backend", "Frontend (Web)"],
"dependsOrder": "parallel", "dependsOrder": "parallel",
"group": { "group": {
"kind": "build", "kind": "build",
@@ -76,7 +94,7 @@
}, },
{ {
"label": "Electron", "label": "Electron",
"dependsOn": ["Backend", "Frontend (Electron)"], "dependsOn": ["LiveKit", "Backend", "Frontend (Electron)"],
"dependsOrder": "parallel", "dependsOrder": "parallel",
"group": { "group": {
"kind": "build" "kind": "build"
+1
View File
@@ -19,3 +19,4 @@ slowapi>=0.1.9
firebase_admin>=7.1.0 firebase_admin>=7.1.0
PyNaCl>=1.5.0 PyNaCl>=1.5.0
numpy numpy
livekit-api>=0.8.0
+21 -10
View File
@@ -10,7 +10,7 @@ import logging
from sqlalchemy.orm.exc import DetachedInstanceError from sqlalchemy.orm.exc import DetachedInstanceError
# Import from same directory # 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 .models import User
from .constants import OWNER_USERNAME from .constants import OWNER_USERNAME
from .utils import get_client_ip from .utils import get_client_ip
@@ -284,17 +284,27 @@ if add_security_middleware:
add_security_middleware(app) add_security_middleware(app)
# CORS # 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( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=[ allow_origins=_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",
],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
@@ -307,6 +317,7 @@ app.include_router(messaging.router)
app.include_router(profile.router) app.include_router(profile.router)
app.include_router(push.router, prefix="/push") app.include_router(push.router, prefix="/push")
app.include_router(webrtc.router, prefix="/webrtc") app.include_router(webrtc.router, prefix="/webrtc")
app.include_router(livekit.router, prefix="/livekit")
app.include_router(devices.router, prefix="/devices") app.include_router(devices.router, prefix="/devices")
app.include_router(moderation.router) app.include_router(moderation.router)
app.include_router(download.router) app.include_router(download.router)
+100
View File
@@ -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)
+3 -1
View File
@@ -46,10 +46,12 @@ os.makedirs(FILES_ENCRYPTED_DIR, exist_ok=True)
def _get_file_storage_url() -> str: 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 ( return (
os.getenv("FILE_STORAGE_SERVICE_URL") os.getenv("FILE_STORAGE_SERVICE_URL")
or os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_URL")
or "http://127.0.0.1:8302" or default_fs
) )
_SPAM_WINDOW_SECONDS = 45 _SPAM_WINDOW_SECONDS = 45
+15 -8
View File
@@ -18,6 +18,13 @@ from fastapi import HTTPException, status
logger = logging.getLogger("uvicorn.error") 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(): def _get_messaging_module():
try: try:
from backend.services.messaging import main as messaging_module 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 # Out-of-process HTTP
# Prefer explicit FILE_STORAGE_URL, fall back to FILE_STORAGE_SERVICE_URL, default to localhost for dev # 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" url = f"{storage_url.rstrip('/')}/upload"
try: try:
try: try:
@@ -219,7 +226,7 @@ async def store_encrypted_file(
# Out-of-process HTTP # Out-of-process HTTP
# Prefer explicit FILE_STORAGE_URL, fall back to FILE_STORAGE_SERVICE_URL, default to localhost for dev # 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" url = f"{file_storage_url.rstrip('/')}/upload-base64"
try: try:
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) logger.error("In-process file_storage.init_resumable_upload failed: %s", e)
raise 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" url = f"{file_storage_url.rstrip('/')}/uploads/resumable/init"
payload = { payload = {
"filename": filename, "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) logger.error("In-process file_storage.get_resumable_upload_status failed: %s", e)
raise 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}" url = f"{file_storage_url.rstrip('/')}/uploads/resumable/{upload_id}"
import httpx 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) logger.error("In-process file_storage.upload_resumable_chunk failed: %s", e)
raise 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}" url = f"{file_storage_url.rstrip('/')}/uploads/resumable/{upload_id}"
payload = { payload = {
"offset": offset, "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) logger.error("In-process file_storage.complete_resumable_upload failed: %s", e)
raise 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" url = f"{file_storage_url.rstrip('/')}/uploads/resumable/{upload_id}/complete"
import httpx 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) logger.error("In-process file_storage.get_resumable_upload_data failed: %s", e)
raise 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" url = f"{file_storage_url.rstrip('/')}/uploads/resumable/{upload_id}/data-b64"
import httpx 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) logger.error("In-process file_storage.delete_resumable_upload failed: %s", e)
raise 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}" url = f"{file_storage_url.rstrip('/')}/uploads/resumable/{upload_id}"
import httpx import httpx
+5 -2
View File
@@ -4,8 +4,11 @@ import { resolve } from 'path';
const app = express(); const app = express();
const port = process.env.PORT || 3000; const port = process.env.PORT || 3000;
const backendHost = process.env.BACKEND_HOST || "http://localhost:8300"; const lan = process.env.LAN_IP;
const fileStorageHost = process.env.FILE_STORAGE_HOST || "http://localhost:8302"; 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 || "."; const filePath = process.env.STATIC_FILE_PATH || ".";
// API proxy middleware // API proxy middleware
+12
View File
@@ -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"
+7
View File
@@ -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 machines 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
+8 -2
View File
@@ -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({ export default defineConfig({
plugins: plugins, plugins: plugins,
resolve: { resolve: {
@@ -80,13 +83,16 @@ export default defineConfig({
strictPort: true, strictPort: true,
proxy: { proxy: {
"/api": { "/api": {
target: "http://127.0.0.1:8300/", target: _backendProxy,
changeOrigin: true, changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ""), rewrite: (path) => path.replace(/^\/api/, ""),
ws: true ws: true
} }
}, },
allowedHosts: ["beta.fromchat.ru"] allowedHosts: [
"beta.fromchat.ru",
...(_lan ? [_lan] : []),
]
}, },
appType: "spa", appType: "spa",
optimizeDeps: { optimizeDeps: {
+2
View File
@@ -9,6 +9,8 @@
"authors": "denis0001-dev", "authors": "denis0001-dev",
"scripts": { "scripts": {
"backend:run": "bash ./scripts/backend:run.sh", "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:dependencies": "python3 -m venv .venv && ./.venv/bin/pip3 install -r backend/requirements.txt",
"backend:reinstall": "rm -rf .venv && npm run backend:dependencies", "backend:reinstall": "rm -rf .venv && npm run backend:dependencies",
"backend:clean": "rm -rf backend/data", "backend:clean": "rm -rf backend/data",
+1 -1
View File
@@ -1,7 +1,7 @@
cd backend cd backend
dotenv -e ../deployment/.env -- \ dotenv -e ../deployment/.env -- \
../.venv/bin/uvicorn main:app \ ../.venv/bin/uvicorn main:app \
--host 127.0.0.1 \ --host 0.0.0.0 \
--port 8300 \ --port 8300 \
--reload \ --reload \
--reload-exclude './alembic' \ --reload-exclude './alembic' \
+242
View File
@@ -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())
+7
View File
@@ -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"
+17
View File
@@ -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"