186 lines
4.6 KiB
Python
186 lines
4.6 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from fastapi import FastAPI, HTTPException
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
from .config_store import default_store
|
|
from .mediamtx_client import MediaMTXClient
|
|
from .models import (
|
|
AppConfig,
|
|
Camera,
|
|
RecordingToggle,
|
|
ScheduleUpdate,
|
|
SchedulerEnabled,
|
|
)
|
|
from .recordings import list_recordings
|
|
from .scheduler import Scheduler
|
|
|
|
|
|
def _cors_origins() -> list[str]:
|
|
raw = os.getenv("CORS_ORIGINS", "http://localhost:5173")
|
|
return [x.strip() for x in raw.split(",") if x.strip()]
|
|
|
|
|
|
app = FastAPI(title="IPCam Dashboard API")
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=_cors_origins(),
|
|
allow_credentials=True,
|
|
allow_methods=["*"] ,
|
|
allow_headers=["*"] ,
|
|
)
|
|
|
|
store = default_store()
|
|
|
|
|
|
async def _apply_recording(enabled: bool) -> None:
|
|
cfg = await store.load()
|
|
names = [c.name for c in cfg.cameras]
|
|
if not names:
|
|
return
|
|
|
|
client = MediaMTXClient(
|
|
api_url=cfg.mediamtx_api_url,
|
|
username=os.getenv("MEDIAMTX_API_USER"),
|
|
password=os.getenv("MEDIAMTX_API_PASS"),
|
|
)
|
|
await client.set_recording_bulk(names, enabled)
|
|
|
|
|
|
scheduler = Scheduler(apply=_apply_recording)
|
|
|
|
|
|
async def _scheduler_loop() -> None:
|
|
while True:
|
|
try:
|
|
cfg = await store.load()
|
|
await scheduler.tick(cfg.schedule)
|
|
finally:
|
|
await asyncio.sleep(60)
|
|
|
|
|
|
@app.on_event("startup")
|
|
async def _startup() -> None:
|
|
cfg = await store.load()
|
|
recordings_dir = cfg.recordings_dir
|
|
if Path(recordings_dir).exists():
|
|
app.mount("/videos", StaticFiles(directory=recordings_dir), name="videos")
|
|
asyncio.create_task(_scheduler_loop())
|
|
|
|
|
|
@app.get("/api/health")
|
|
async def health() -> dict:
|
|
return {"status": "ok"}
|
|
|
|
|
|
@app.get("/api/config")
|
|
async def get_config() -> AppConfig:
|
|
return await store.load()
|
|
|
|
|
|
@app.post("/api/cameras")
|
|
async def add_camera(camera: Camera) -> AppConfig:
|
|
cfg = await store.load()
|
|
if any(c.name == camera.name for c in cfg.cameras):
|
|
raise HTTPException(status_code=409, detail="camera_already_exists")
|
|
|
|
cfg.cameras.append(camera)
|
|
await store.save(cfg)
|
|
|
|
client = MediaMTXClient(
|
|
api_url=cfg.mediamtx_api_url,
|
|
username=os.getenv("MEDIAMTX_API_USER"),
|
|
password=os.getenv("MEDIAMTX_API_PASS"),
|
|
)
|
|
await client.upsert_paths_sources_bulk({camera.name: camera.rtsp_url})
|
|
return cfg
|
|
|
|
|
|
@app.delete("/api/cameras/{name}")
|
|
async def delete_camera(name: str) -> AppConfig:
|
|
cfg = await store.load()
|
|
before = len(cfg.cameras)
|
|
cfg.cameras = [c for c in cfg.cameras if c.name != name]
|
|
if len(cfg.cameras) == before:
|
|
raise HTTPException(status_code=404, detail="camera_not_found")
|
|
await store.save(cfg)
|
|
|
|
client = MediaMTXClient(
|
|
api_url=cfg.mediamtx_api_url,
|
|
username=os.getenv("MEDIAMTX_API_USER"),
|
|
password=os.getenv("MEDIAMTX_API_PASS"),
|
|
)
|
|
await client.delete_path(name)
|
|
return cfg
|
|
|
|
|
|
@app.get("/api/paths")
|
|
async def list_paths() -> dict:
|
|
cfg = await store.load()
|
|
client = MediaMTXClient(
|
|
api_url=cfg.mediamtx_api_url,
|
|
username=os.getenv("MEDIAMTX_API_USER"),
|
|
password=os.getenv("MEDIAMTX_API_PASS"),
|
|
)
|
|
return await client.list_paths_status()
|
|
|
|
|
|
@app.post("/api/recording")
|
|
async def toggle_recording(data: RecordingToggle) -> dict:
|
|
await _apply_recording(data.enabled)
|
|
return {"enabled": data.enabled}
|
|
|
|
|
|
@app.post("/api/scheduler/enabled")
|
|
async def set_scheduler_enabled(data: SchedulerEnabled) -> AppConfig:
|
|
cfg = await store.load()
|
|
cfg.schedule.enabled = data.enabled
|
|
await store.save(cfg)
|
|
await scheduler.tick(cfg.schedule)
|
|
return cfg
|
|
|
|
|
|
@app.post("/api/scheduler/schedule")
|
|
async def update_schedule(data: ScheduleUpdate) -> AppConfig:
|
|
cfg = await store.load()
|
|
cfg.schedule.weekdays_from = data.weekdays_from
|
|
cfg.schedule.weekdays_to = data.weekdays_to
|
|
cfg.schedule.weekend_all_day = data.weekend_all_day
|
|
await store.save(cfg)
|
|
await scheduler.tick(cfg.schedule)
|
|
return cfg
|
|
|
|
|
|
@app.get("/api/recordings")
|
|
async def recordings(
|
|
camera: str,
|
|
date: Optional[str] = None,
|
|
limit: int = 200,
|
|
offset: int = 0,
|
|
) -> list[dict]:
|
|
cfg = await store.load()
|
|
if not any(c.name == camera for c in cfg.cameras):
|
|
raise HTTPException(status_code=404, detail="camera_not_found")
|
|
if limit < 1 or limit > 2000:
|
|
raise HTTPException(status_code=400, detail="invalid_limit")
|
|
if offset < 0:
|
|
raise HTTPException(status_code=400, detail="invalid_offset")
|
|
|
|
items = list_recordings(cfg.recordings_dir, camera, date, limit, offset)
|
|
return [
|
|
{
|
|
"camera": it.camera,
|
|
"filename": it.filename,
|
|
"timestamp": it.timestamp,
|
|
"url": it.url,
|
|
}
|
|
for it in items
|
|
]
|
|
|