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 generated files
|
||||||
*.icloud
|
*.icloud
|
||||||
|
|
||||||
|
### FromChat local tools (downloaded LiveKit server binary) ###
|
||||||
|
.tools/
|
||||||
|
|
||||||
### Node ###
|
### Node ###
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
|
|||||||
Vendored
+20
-2
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,9 +284,8 @@ if add_security_middleware:
|
|||||||
add_security_middleware(app)
|
add_security_middleware(app)
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
app.add_middleware(
|
_lan_ip = os.getenv("LAN_IP", "").strip()
|
||||||
CORSMiddleware,
|
_cors_origins = [
|
||||||
allow_origins=[
|
|
||||||
"https://fromchat.ru",
|
"https://fromchat.ru",
|
||||||
"https://beta.fromchat.ru",
|
"https://beta.fromchat.ru",
|
||||||
"https://www.fromchat.ru",
|
"https://www.fromchat.ru",
|
||||||
@@ -294,7 +293,18 @@ app.add_middleware(
|
|||||||
"http://127.0.0.1:8300",
|
"http://127.0.0.1:8300",
|
||||||
"http://localhost:8301",
|
"http://localhost:8301",
|
||||||
"http://localhost:8300",
|
"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_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)
|
||||||
|
|||||||
@@ -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:
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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({
|
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: {
|
||||||
|
|||||||
@@ -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,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' \
|
||||||
|
|||||||
@@ -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