mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Implement LiveKit calls
This commit is contained in:
@@ -35,6 +35,9 @@ Temporary Items
|
||||
# iCloud generated files
|
||||
*.icloud
|
||||
|
||||
### FromChat local tools (downloaded LiveKit server binary) ###
|
||||
.tools/
|
||||
|
||||
### Node ###
|
||||
# Logs
|
||||
logs
|
||||
|
||||
Vendored
+20
-2
@@ -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"
|
||||
|
||||
@@ -19,3 +19,4 @@ slowapi>=0.1.9
|
||||
firebase_admin>=7.1.0
|
||||
PyNaCl>=1.5.0
|
||||
numpy
|
||||
livekit-api>=0.8.0
|
||||
@@ -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,9 +284,8 @@ if add_security_middleware:
|
||||
add_security_middleware(app)
|
||||
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
_lan_ip = os.getenv("LAN_IP", "").strip()
|
||||
_cors_origins = [
|
||||
"https://fromchat.ru",
|
||||
"https://beta.fromchat.ru",
|
||||
"https://www.fromchat.ru",
|
||||
@@ -294,7 +293,18 @@ app.add_middleware(
|
||||
"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=_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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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' \
|
||||
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user