From 9ae8b3400571b805cd61a4434104543a24d45c38 Mon Sep 17 00:00:00 2001 From: h Date: Mon, 19 Oct 2026 03:21:57 +0000 Subject: [PATCH] feat(backend): capture status and the stretches the archive was not recording --- backend/src/api/app.py | 2 ++ backend/src/api/routers/capture.py | 32 ++++++++++++++++++++++ backend/src/utils/read/capture.py | 44 ++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 backend/src/api/routers/capture.py create mode 100644 backend/src/utils/read/capture.py diff --git a/backend/src/api/app.py b/backend/src/api/app.py index 96e84ba..0803b64 100644 --- a/backend/src/api/app.py +++ b/backend/src/api/app.py @@ -21,6 +21,7 @@ from api.routers import ( avatars, backfill, bots, + capture, chats, custom_emoji, discover, @@ -114,6 +115,7 @@ app.include_router(webhooks.router) app.include_router(shares.router) app.include_router(storage.router) app.include_router(files.router) +app.include_router(capture.router) app.mount("/mcp", mcp_app) diff --git a/backend/src/api/routers/capture.py b/backend/src/api/routers/capture.py new file mode 100644 index 0000000..67ea099 --- /dev/null +++ b/backend/src/api/routers/capture.py @@ -0,0 +1,32 @@ +from datetime import datetime +from typing import Annotated + +import asyncpg +from dishka.integrations.fastapi import DishkaRoute, FromDishka +from fastapi import APIRouter, Query + +from utils.read import capture +from utils.read.models import CaptureGap, CaptureStatus + +router = APIRouter(prefix="/api/capture", tags=["capture"], route_class=DishkaRoute) + +AccountId = Annotated[int, Query()] + + +@router.get("/status") +async def capture_status( + pool: FromDishka[asyncpg.Pool], account_id: AccountId +) -> CaptureStatus: + return await capture.capture_status(pool, account_id) + + +@router.get("/gaps") +async def capture_gaps( + pool: FromDishka[asyncpg.Pool], + account_id: AccountId, + date_from: Annotated[datetime | None, Query()] = None, + date_to: Annotated[datetime | None, Query()] = None, +) -> list[CaptureGap]: + return await capture.capture_gaps( + pool, account_id, date_from=date_from, date_to=date_to + ) diff --git a/backend/src/utils/read/capture.py b/backend/src/utils/read/capture.py new file mode 100644 index 0000000..282e4b5 --- /dev/null +++ b/backend/src/utils/read/capture.py @@ -0,0 +1,44 @@ +from datetime import datetime + +import asyncpg + +from utils.read.models import CaptureGap, CaptureStatus + +_STATUS = """ +SELECT started_at, seen_at, seen_at >= now() - interval '150 seconds' AS live +FROM capture_uptime WHERE account_id = $1 +ORDER BY started_at DESC LIMIT 1 +""" + +_GAPS = """ +SELECT seen_at AS start, next_start AS "end" +FROM ( + SELECT seen_at, + lead(started_at) OVER (ORDER BY started_at) AS next_start + FROM capture_uptime WHERE account_id = $1 +) stretches +WHERE next_start - seen_at > interval '30 minutes' + AND ($2::timestamptz IS NULL OR next_start >= $2) + AND ($3::timestamptz IS NULL OR seen_at <= $3) +ORDER BY seen_at +""" + + +async def capture_status(pool: asyncpg.Pool, account_id: int) -> CaptureStatus: + row = await pool.fetchrow(_STATUS, account_id) + if row is None: + return CaptureStatus(live=False, since=None, last_seen=None) + return CaptureStatus( + live=row["live"], since=row["started_at"], last_seen=row["seen_at"] + ) + + +async def capture_gaps( + pool: asyncpg.Pool, + account_id: int, + *, + date_from: datetime | None = None, + date_to: datetime | None = None, +) -> list[CaptureGap]: + rows = await pool.fetch(_GAPS, account_id, date_from, date_to) + return [CaptureGap(**dict(row)) for row in rows]