feat(api): add backend
routes and WebSockets
This commit is contained in:
36
services/api/app/api/v1/endpoints/audio.py
Normal file
36
services/api/app/api/v1/endpoints/audio.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.repositories.audio_repository import AudioRepository
|
||||
from app.schemas.base import ApiResponse
|
||||
from app.schemas.audio import AudioPoint
|
||||
from app.services.audio_service import AudioService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/latest", response_model=ApiResponse[list[AudioPoint]])
|
||||
async def latest(
|
||||
limit: int = Query(100, ge=1, le=10000), db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
service = AudioService(AudioRepository(db))
|
||||
items = await service.latest(limit)
|
||||
return ApiResponse(data=items, count=len(items))
|
||||
|
||||
|
||||
@router.get("/range", response_model=ApiResponse[list[AudioPoint]])
|
||||
async def range_(
|
||||
time_from: datetime = Query(..., alias="from"),
|
||||
time_to: datetime = Query(..., alias="to"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if time_from >= time_to:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="'from' timestamp must be earlier than 'to' timestamp",
|
||||
)
|
||||
service = AudioService(AudioRepository(db))
|
||||
items = await service.range(time_from, time_to)
|
||||
return ApiResponse(data=items, count=len(items))
|
||||
35
services/api/app/api/v1/endpoints/events.py
Normal file
35
services/api/app/api/v1/endpoints/events.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.repositories.audio_repository import AudioRepository
|
||||
from app.schemas.base import ApiResponse
|
||||
from app.schemas.events import LoudEvent
|
||||
from app.services.events_service import EventsService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/loud", response_model=ApiResponse[list[LoudEvent]])
|
||||
async def loud_events(
|
||||
threshold: float = Query(
|
||||
default=-35.0, ge=-50.0, le=0.0, description="RMS dB threshold"
|
||||
),
|
||||
time_from: Optional[datetime] = Query(None, alias="from"),
|
||||
time_to: Optional[datetime] = Query(None, alias="to"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if time_from and time_to and time_from >= time_to:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="'from' timestamp must be earlier than 'to' timestamp",
|
||||
)
|
||||
|
||||
service = EventsService(AudioRepository(db))
|
||||
events = await service.loud_events(
|
||||
threshold=threshold, time_from=time_from, time_to=time_to
|
||||
)
|
||||
return ApiResponse(data=events, count=len(events))
|
||||
42
services/api/app/api/v1/endpoints/export.py
Normal file
42
services/api/app/api/v1/endpoints/export.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from datetime import datetime
|
||||
import csv
|
||||
import io
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.repositories.audio_repository import AudioRepository
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/csv")
|
||||
async def export_csv(
|
||||
time_from: datetime = Query(..., alias="from"),
|
||||
time_to: datetime = Query(..., alias="to"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if time_from >= time_to:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="'from' timestamp must be earlier than 'to' timestamp",
|
||||
)
|
||||
|
||||
repo = AudioRepository(db)
|
||||
rows = await repo.range(time_from, time_to)
|
||||
|
||||
buf = io.StringIO()
|
||||
w = csv.writer(buf)
|
||||
w.writerow(["time", "rms_db", "frequency_hz", "is_silence"])
|
||||
for r in rows:
|
||||
w.writerow([r.time.isoformat(), r.rms_db, r.frequency_hz, r.is_silence])
|
||||
buf.seek(0)
|
||||
|
||||
filename = f"audio_{time_from:%Y%m%d_%H%M%S}_to_{time_to:%Y%m%d_%H%M%S}.csv"
|
||||
return StreamingResponse(
|
||||
iter([buf.getvalue()]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
||||
)
|
||||
19
services/api/app/api/v1/endpoints/health.py
Normal file
19
services/api/app/api/v1/endpoints/health.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.schemas.base import ApiResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/live", response_model=ApiResponse[dict])
|
||||
async def live() -> ApiResponse[dict]:
|
||||
return ApiResponse(data={"status": "alive"})
|
||||
|
||||
|
||||
@router.get("/ready", response_model=ApiResponse[dict])
|
||||
async def ready(db: AsyncSession = Depends(get_db)) -> ApiResponse[dict]:
|
||||
await db.execute(text("SELECT 1"))
|
||||
return ApiResponse(data={"status": "ready", "db": "ok"})
|
||||
23
services/api/app/api/v1/endpoints/stats.py
Normal file
23
services/api/app/api/v1/endpoints/stats.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.repositories.audio_repository import AudioRepository
|
||||
from app.schemas.base import ApiResponse
|
||||
from app.schemas.stats import StatsSummary
|
||||
from app.services.stats_service import StatsService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/summary", response_model=ApiResponse[StatsSummary])
|
||||
async def summary(
|
||||
period: str = Query("1h", pattern="^(10s|1m|1h|6h|24h|7d|30d)$"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = StatsService(AudioRepository(db))
|
||||
try:
|
||||
data = await service.summary(period)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
return ApiResponse(data=data)
|
||||
9
services/api/app/api/v1/router.py
Normal file
9
services/api/app/api/v1/router.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from fastapi import APIRouter
|
||||
from app.api.v1.endpoints import audio, stats, events, export, health
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(health.router, prefix="/health", tags=["health"])
|
||||
router.include_router(audio.router, prefix="/audio", tags=["audio"])
|
||||
router.include_router(stats.router, prefix="/stats", tags=["stats"])
|
||||
router.include_router(events.router, prefix="/events", tags=["events"])
|
||||
router.include_router(export.router, prefix="/export", tags=["export"])
|
||||
11
services/api/app/core/config.py
Normal file
11
services/api/app/core/config.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@db:5432/audio_analyzer"
|
||||
API_V1_PREFIX: str = "/api/v1"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
5
services/api/app/db/base.py
Normal file
5
services/api/app/db/base.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
12
services/api/app/db/session.py
Normal file
12
services/api/app/db/session.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from typing import AsyncGenerator
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
engine = create_async_engine(settings.DATABASE_URL, echo=False, pool_pre_ping=True)
|
||||
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, autoflush=False)
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with SessionLocal() as session:
|
||||
yield session
|
||||
46
services/api/app/main.py
Normal file
46
services/api/app/main.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from fastapi import FastAPI
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.v1.router import router as v1_router
|
||||
from app.core.config import settings
|
||||
from app.ws.router import router as ws_router
|
||||
from app.ws.broadcaster import audio_live_broadcaster
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
task = asyncio.create_task(audio_live_broadcaster())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(
|
||||
title="Audio Analyzer API",
|
||||
version="1.0.0",
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(v1_router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(ws_router) # /ws/live
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
14
services/api/app/models/audio_data.py
Normal file
14
services/api/app/models/audio_data.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from sqlalchemy import Boolean, Integer, Float
|
||||
from sqlalchemy.dialects.postgresql import TIMESTAMP
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class AudioData(Base):
|
||||
__tablename__ = "audio_data"
|
||||
|
||||
time: Mapped[object] = mapped_column(TIMESTAMP(timezone=True), primary_key=True)
|
||||
rms_db: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
frequency_hz: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
is_silence: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
74
services/api/app/repositories/audio_repository.py
Normal file
74
services/api/app/repositories/audio_repository.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import and_, func, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.audio_data import AudioData
|
||||
|
||||
|
||||
class AudioRepository:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def latest(self, limit: int) -> list[AudioData]:
|
||||
q = select(AudioData).order_by(AudioData.time.desc()).limit(limit)
|
||||
res = await self.db.execute(q)
|
||||
return list(res.scalars().all())
|
||||
|
||||
async def range(self, time_from: datetime, time_to: datetime) -> list[AudioData]:
|
||||
q = (
|
||||
select(AudioData)
|
||||
.where(and_(AudioData.time >= time_from, AudioData.time <= time_to))
|
||||
.order_by(AudioData.time.asc())
|
||||
)
|
||||
res = await self.db.execute(q)
|
||||
return list(res.scalars().all())
|
||||
|
||||
async def loud_samples(
|
||||
self,
|
||||
threshold: float,
|
||||
time_from: datetime | None,
|
||||
time_to: datetime | None,
|
||||
) -> list[AudioData]:
|
||||
cond = [AudioData.rms_db >= threshold]
|
||||
if time_from:
|
||||
cond.append(AudioData.time >= time_from)
|
||||
if time_to:
|
||||
cond.append(AudioData.time <= time_to)
|
||||
|
||||
q = select(AudioData).where(and_(*cond)).order_by(AudioData.time.asc())
|
||||
res = await self.db.execute(q)
|
||||
return list(res.scalars().all())
|
||||
|
||||
async def summary_since(self, since: datetime) -> dict:
|
||||
q = select(
|
||||
func.avg(AudioData.rms_db).label("avg_db"),
|
||||
func.max(AudioData.rms_db).label("max_db"),
|
||||
func.sum(func.case((AudioData.is_silence.is_(True), 1), else_=0)).label(
|
||||
"silence_count"
|
||||
),
|
||||
func.count().label("total_count"),
|
||||
).where(AudioData.time >= since)
|
||||
|
||||
res = await self.db.execute(q)
|
||||
row = res.one()
|
||||
|
||||
# dominant freq excluding silence
|
||||
fq = (
|
||||
select(AudioData.frequency_hz, func.count().label("cnt"))
|
||||
.where(and_(AudioData.time >= since, AudioData.is_silence.is_(False)))
|
||||
.group_by(AudioData.frequency_hz)
|
||||
.order_by(text("cnt DESC"))
|
||||
.limit(1)
|
||||
)
|
||||
fres = await self.db.execute(fq)
|
||||
frow = fres.first()
|
||||
|
||||
return {
|
||||
"avg_db": float(row.avg_db or 0.0),
|
||||
"max_db": float(row.max_db or 0.0),
|
||||
"dominant_freq": int(frow[0]) if frow else 0,
|
||||
"silence_count": int(row.silence_count or 0),
|
||||
"total_count": int(row.total_count or 0),
|
||||
}
|
||||
9
services/api/app/schemas/audio.py
Normal file
9
services/api/app/schemas/audio.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AudioPoint(BaseModel):
|
||||
time: datetime
|
||||
rms_db: float
|
||||
frequency_hz: int
|
||||
is_silence: bool
|
||||
19
services/api/app/schemas/base.py
Normal file
19
services/api/app/schemas/base.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Generic, TypeVar
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class ApiError(BaseModel):
|
||||
code: str = Field(..., examples=["validation_error", "db_error"])
|
||||
message: str
|
||||
details: dict | None = None
|
||||
|
||||
|
||||
class ApiResponse(BaseModel, Generic[T]):
|
||||
success: bool = True
|
||||
errors: list[ApiError] | None = None
|
||||
count: int | None = None
|
||||
data: T | None = None
|
||||
9
services/api/app/schemas/events.py
Normal file
9
services/api/app/schemas/events.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class LoudEvent(BaseModel):
|
||||
time: datetime
|
||||
rms_db: float
|
||||
frequency_hz: int
|
||||
duration_sec: float | None = None
|
||||
8
services/api/app/schemas/stats.py
Normal file
8
services/api/app/schemas/stats.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class StatsSummary(BaseModel):
|
||||
avg_db: float
|
||||
max_db: float
|
||||
dominant_freq: int
|
||||
silence_percent: float
|
||||
16
services/api/app/services/audio_service.py
Normal file
16
services/api/app/services/audio_service.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from datetime import datetime
|
||||
from app.repositories.audio_repository import AudioRepository
|
||||
from app.schemas.audio import AudioPoint
|
||||
|
||||
|
||||
class AudioService:
|
||||
def __init__(self, repo: AudioRepository):
|
||||
self.repo = repo
|
||||
|
||||
async def latest(self, limit: int) -> list[AudioPoint]:
|
||||
rows = await self.repo.latest(limit)
|
||||
return [AudioPoint.model_validate(r, from_attributes=True) for r in rows]
|
||||
|
||||
async def range(self, time_from: datetime, time_to: datetime) -> list[AudioPoint]:
|
||||
rows = await self.repo.range(time_from, time_to)
|
||||
return [AudioPoint.model_validate(r, from_attributes=True) for r in rows]
|
||||
54
services/api/app/services/events_service.py
Normal file
54
services/api/app/services/events_service.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from app.repositories.audio_repository import AudioRepository
|
||||
from app.schemas.events import LoudEvent
|
||||
|
||||
|
||||
class EventsService:
|
||||
def __init__(self, repo: AudioRepository):
|
||||
self.repo = repo
|
||||
|
||||
async def loud_events(
|
||||
self,
|
||||
threshold: float,
|
||||
time_from: datetime | None,
|
||||
time_to: datetime | None,
|
||||
max_gap_sec: float = 1.0,
|
||||
) -> list[LoudEvent]:
|
||||
samples = await self.repo.loud_samples(threshold, time_from, time_to)
|
||||
if not samples:
|
||||
return []
|
||||
|
||||
events: list[LoudEvent] = []
|
||||
start = samples[0].time
|
||||
end = samples[0].time
|
||||
max_db = samples[0].rms_db
|
||||
freq = samples[0].frequency_hz
|
||||
|
||||
for s in samples[1:]:
|
||||
gap = (s.time - end).total_seconds()
|
||||
if gap <= max_gap_sec:
|
||||
end = s.time
|
||||
if s.rms_db > max_db:
|
||||
max_db = s.rms_db
|
||||
else:
|
||||
events.append(
|
||||
LoudEvent(
|
||||
time=start,
|
||||
rms_db=round(float(max_db), 2),
|
||||
frequency_hz=int(freq),
|
||||
duration_sec=round((end - start).total_seconds(), 2),
|
||||
)
|
||||
)
|
||||
start, end, max_db, freq = s.time, s.time, s.rms_db, s.frequency_hz
|
||||
|
||||
events.append(
|
||||
LoudEvent(
|
||||
time=start,
|
||||
rms_db=round(float(max_db), 2),
|
||||
frequency_hz=int(freq),
|
||||
duration_sec=round((end - start).total_seconds(), 2),
|
||||
)
|
||||
)
|
||||
return events
|
||||
35
services/api/app/services/stats_service.py
Normal file
35
services/api/app/services/stats_service.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from datetime import datetime, timedelta
|
||||
from app.repositories.audio_repository import AudioRepository
|
||||
from app.schemas.stats import StatsSummary
|
||||
|
||||
_PERIODS = {
|
||||
"10s": timedelta(seconds=10),
|
||||
"1m": timedelta(minutes=1),
|
||||
"1h": timedelta(hours=1),
|
||||
"6h": timedelta(hours=6),
|
||||
"24h": timedelta(hours=24),
|
||||
"7d": timedelta(days=7),
|
||||
"30d": timedelta(days=30),
|
||||
}
|
||||
|
||||
|
||||
class StatsService:
|
||||
def __init__(self, repo: AudioRepository):
|
||||
self.repo = repo
|
||||
|
||||
async def summary(self, period: str) -> StatsSummary:
|
||||
if period not in _PERIODS:
|
||||
raise ValueError(f"Unsupported period: {period}")
|
||||
|
||||
since = datetime.utcnow() - _PERIODS[period]
|
||||
raw = await self.repo.summary_since(since)
|
||||
|
||||
total = raw["total_count"]
|
||||
silence_percent = (raw["silence_count"] / total * 100.0) if total else 0.0
|
||||
|
||||
return StatsSummary(
|
||||
avg_db=round(raw["avg_db"], 2),
|
||||
max_db=round(raw["max_db"], 2),
|
||||
dominant_freq=raw["dominant_freq"],
|
||||
silence_percent=round(silence_percent, 2),
|
||||
)
|
||||
41
services/api/app/ws/broadcaster.py
Normal file
41
services/api/app/ws/broadcaster.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
from datetime import timezone
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.repositories.audio_repository import AudioRepository
|
||||
from app.ws.manager import manager
|
||||
|
||||
|
||||
def _iso_z(dt) -> str:
|
||||
# dt ожидается timezone-aware
|
||||
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
async def audio_live_broadcaster(poll_interval_sec: float = 0.2) -> None:
|
||||
last_time = None
|
||||
|
||||
while True:
|
||||
try:
|
||||
async with SessionLocal() as db: # AsyncSession
|
||||
repo = AudioRepository(db)
|
||||
rows = await repo.latest(1)
|
||||
if rows:
|
||||
row = rows[0]
|
||||
if last_time is None or row.time > last_time:
|
||||
last_time = row.time
|
||||
await manager.broadcast_json(
|
||||
{
|
||||
"time": _iso_z(row.time),
|
||||
"rms_db": float(row.rms_db),
|
||||
"freq_hz": int(row.frequency_hz),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
# чтобы WS не умирал из-за временных проблем с БД
|
||||
pass
|
||||
|
||||
await asyncio.sleep(poll_interval_sec)
|
||||
39
services/api/app/ws/manager.py
Normal file
39
services/api/app/ws/manager.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
from fastapi import WebSocket
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
def __init__(self) -> None:
|
||||
self._connections: set[WebSocket] = set()
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def connect(self, ws: WebSocket) -> None:
|
||||
await ws.accept()
|
||||
async with self._lock:
|
||||
self._connections.add(ws)
|
||||
|
||||
async def disconnect(self, ws: WebSocket) -> None:
|
||||
async with self._lock:
|
||||
self._connections.discard(ws)
|
||||
|
||||
async def broadcast_json(self, payload: dict[str, Any]) -> None:
|
||||
async with self._lock:
|
||||
conns = list(self._connections)
|
||||
|
||||
to_remove: list[WebSocket] = []
|
||||
for ws in conns:
|
||||
try:
|
||||
await ws.send_json(payload)
|
||||
except Exception:
|
||||
to_remove.append(ws)
|
||||
|
||||
if to_remove:
|
||||
async with self._lock:
|
||||
for ws in to_remove:
|
||||
self._connections.discard(ws)
|
||||
|
||||
|
||||
manager = ConnectionManager()
|
||||
19
services/api/app/ws/router.py
Normal file
19
services/api/app/ws/router.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from fastapi import APIRouter, WebSocket
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from app.ws.manager import manager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.websocket("/ws/live")
|
||||
async def ws_live(ws: WebSocket) -> None:
|
||||
await manager.connect(ws)
|
||||
try:
|
||||
# Держим соединение
|
||||
while True:
|
||||
await ws.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
await manager.disconnect(ws)
|
||||
except Exception:
|
||||
await manager.disconnect(ws)
|
||||
Reference in New Issue
Block a user