first commit

This commit is contained in:
2026-04-26 21:27:00 +07:00
commit 3ce6f0510b
48 changed files with 9700 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
+41
View File
@@ -0,0 +1,41 @@
import json
from pathlib import Path
from typing import Optional
import anyio
from .models import AppConfig
class ConfigStore:
def __init__(self, file_path: Path):
self._file_path = file_path
self._lock = anyio.Lock()
def _write_unlocked(self, cfg: AppConfig) -> None:
self._file_path.parent.mkdir(parents=True, exist_ok=True)
self._file_path.write_text(
json.dumps(cfg.model_dump(mode="json"), indent=2, ensure_ascii=False),
encoding="utf-8",
)
async def load(self) -> AppConfig:
async with self._lock:
if not self._file_path.exists():
cfg = AppConfig(cameras=[])
self._write_unlocked(cfg)
return cfg
raw = self._file_path.read_text(encoding="utf-8")
data = json.loads(raw) if raw.strip() else {}
return AppConfig.model_validate(data)
async def save(self, cfg: AppConfig) -> None:
async with self._lock:
self._write_unlocked(cfg)
def default_store(project_root: Optional[Path] = None) -> ConfigStore:
root = project_root or Path(__file__).resolve().parents[2]
return ConfigStore(root / "api" / "data" / "config.json")
+185
View File
@@ -0,0 +1,185 @@
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
]
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Optional
import httpx
@dataclass(frozen=True)
class MediaMTXClient:
api_url: str
username: Optional[str] = None
password: Optional[str] = None
def _auth(self) -> Optional[tuple[str, str]]:
if self.username and self.password:
return (self.username, self.password)
return None
async def list_paths_status(self) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=5) as client:
r = await client.get(f"{self.api_url}/v3/paths/list", auth=self._auth())
r.raise_for_status()
return r.json()
async def set_recording_bulk(self, names: list[str], enabled: bool) -> dict[str, Any]:
payload = {"paths": {name: {"record": enabled} for name in names}}
async with httpx.AsyncClient(timeout=8) as client:
r = await client.post(
f"{self.api_url}/v3/config/paths/patch",
json=payload,
auth=self._auth(),
)
r.raise_for_status()
return r.json()
async def upsert_paths_sources_bulk(self, sources: dict[str, str]) -> dict[str, Any]:
payload = {"paths": {name: {"source": url} for name, url in sources.items()}}
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(
f"{self.api_url}/v3/config/paths/patch",
json=payload,
auth=self._auth(),
)
if r.status_code != 404:
r.raise_for_status()
return r.json()
results: dict[str, Any] = {"fallback": []}
async with httpx.AsyncClient(timeout=10) as client:
for name, url in sources.items():
rr = await client.post(
f"{self.api_url}/v3/config/paths/add/{name}",
json={"source": url},
auth=self._auth(),
)
if rr.status_code == 409:
rr = await client.patch(
f"{self.api_url}/v3/config/paths/patch/{name}",
json={"source": url},
auth=self._auth(),
)
rr.raise_for_status()
results["fallback"].append({"name": name, "status": rr.status_code})
return results
async def delete_path(self, name: str) -> None:
async with httpx.AsyncClient(timeout=8) as client:
r = await client.delete(
f"{self.api_url}/v3/config/paths/delete/{name}",
auth=self._auth(),
)
if r.status_code in (404, 410):
return
if r.status_code == 405:
rr = await client.post(
f"{self.api_url}/v3/config/paths/patch",
json={"paths": {name: {"source": ""}}},
auth=self._auth(),
)
rr.raise_for_status()
return
r.raise_for_status()
+36
View File
@@ -0,0 +1,36 @@
from pydantic import BaseModel, Field
class Camera(BaseModel):
name: str = Field(min_length=1, max_length=64, pattern=r"^[a-zA-Z0-9_\-\/]+$")
rtsp_url: str = Field(min_length=1, max_length=2048)
class Schedule(BaseModel):
enabled: bool = True
weekdays_from: str = Field(default="18:00", pattern=r"^\d{2}:\d{2}$")
weekdays_to: str = Field(default="08:00", pattern=r"^\d{2}:\d{2}$")
weekend_all_day: bool = True
class AppConfig(BaseModel):
mediamtx_api_url: str = "http://127.0.0.1:9997"
mediamtx_webrtc_url: str = "http://127.0.0.1:8889"
recordings_dir: str = "/recordings"
cameras: list[Camera] = Field(default_factory=list)
schedule: Schedule = Field(default_factory=Schedule)
class RecordingToggle(BaseModel):
enabled: bool
class SchedulerEnabled(BaseModel):
enabled: bool
class ScheduleUpdate(BaseModel):
weekdays_from: str = Field(pattern=r"^\d{2}:\d{2}$")
weekdays_to: str = Field(pattern=r"^\d{2}:\d{2}$")
weekend_all_day: bool
+64
View File
@@ -0,0 +1,64 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Optional
@dataclass(frozen=True)
class RecordingItem:
camera: str
filename: str
timestamp: str
url: str
def _parse_filename(filename: str) -> Optional[datetime]:
if not filename.endswith(".fmp4"):
return None
stem = filename[:-5]
try:
return datetime.strptime(stem, "%Y-%m-%d_%H-%M-%S-%f")
except ValueError:
return None
def list_recordings(
recordings_dir: str,
camera: str,
date: Optional[str],
limit: int,
offset: int,
) -> list[RecordingItem]:
base = Path(recordings_dir)
cam_dir = base / camera
if not cam_dir.exists() or not cam_dir.is_dir():
return []
items: list[tuple[datetime, str]] = []
for p in cam_dir.iterdir():
if not p.is_file():
continue
dt = _parse_filename(p.name)
if dt is None:
continue
if date is not None and dt.strftime("%Y-%m-%d") != date:
continue
items.append((dt, p.name))
items.sort(key=lambda x: x[0], reverse=True)
sliced = items[offset : offset + limit]
out: list[RecordingItem] = []
for dt, name in sliced:
out.append(
RecordingItem(
camera=camera,
filename=name,
timestamp=dt.isoformat(),
url=f"/videos/{camera}/{name}",
)
)
return out
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, time
from typing import Awaitable, Callable, Optional
from .models import Schedule
def _parse_hhmm(value: str) -> time:
hh, mm = value.split(":")
return time(hour=int(hh), minute=int(mm))
def should_record_now(now: datetime, schedule: Schedule) -> bool:
if not schedule.enabled:
return False
weekday = now.weekday()
if weekday in (5, 6) and schedule.weekend_all_day:
return True
start = _parse_hhmm(schedule.weekdays_from)
end = _parse_hhmm(schedule.weekdays_to)
now_t = now.time().replace(second=0, microsecond=0)
if start == end:
return True
if start < end:
return start <= now_t < end
return now_t >= start or now_t < end
@dataclass
class Scheduler:
apply: Callable[[bool], Awaitable[None]]
_last_state: Optional[bool] = None
async def tick(self, schedule: Schedule) -> None:
now = datetime.now()
state = should_record_now(now, schedule)
if self._last_state != state:
await self.apply(state)
self._last_state = state