This commit is contained in:
2026-04-28 11:05:42 +07:00
parent 7cace210d6
commit 81c727b7a6
8 changed files with 102 additions and 99 deletions
+6 -1
View File
@@ -28,7 +28,12 @@ class ConfigStore:
raw = self._file_path.read_text(encoding="utf-8")
data = json.loads(raw) if raw.strip() else {}
return AppConfig.model_validate(data)
cfg = AppConfig.model_validate(data)
# Auto-fill newly introduced config keys.
normalized = cfg.model_dump(mode="json")
if normalized != data:
self._write_unlocked(cfg)
return cfg
async def save(self, cfg: AppConfig) -> None:
async with self._lock:
+29 -22
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
import asyncio
import os
import logging
from pathlib import Path
from typing import Optional
@@ -23,19 +22,12 @@ from .models import (
from .recordings import list_recordings
from .scheduler import Scheduler
try:
from dotenv import load_dotenv
load_dotenv(Path(__file__).resolve().parents[2] / ".env")
except Exception:
pass
def _cors_origins() -> list[str]:
raw = os.getenv("CORS_ORIGINS", "http://localhost:5173")
return [x.strip() for x in raw.split(",") if x.strip()]
return ["http://localhost:5173"]
logger = logging.getLogger("ipcam_dashboard")
PROJECT_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_LOCAL_RECORDINGS_DIR = PROJECT_ROOT / "mediamtx" / "recordings"
app = FastAPI(title="IPCam Dashboard API")
@@ -58,8 +50,8 @@ async def _apply_recording(enabled: bool) -> None:
client = MediaMTXClient(
api_url=cfg.mediamtx_api_url,
username=os.getenv("MEDIAMTX_API_USER"),
password=os.getenv("MEDIAMTX_API_PASS"),
username=cfg.mediamtx_api_user,
password=cfg.mediamtx_api_pass,
)
await client.set_recording_bulk(names, enabled)
@@ -91,9 +83,24 @@ def _raise_mediamtx_http_error(err: httpx.HTTPError) -> None:
@app.on_event("startup")
async def _startup() -> None:
cfg = await store.load()
recordings_dir = cfg.recordings_dir
Path(recordings_dir).mkdir(parents=True, exist_ok=True)
app.mount("/videos", StaticFiles(directory=recordings_dir), name="videos")
recordings_dir = Path(cfg.recordings_dir)
# Backward-compatible migration: old defaults used "/recordings".
if str(recordings_dir) == "/recordings":
recordings_dir = DEFAULT_LOCAL_RECORDINGS_DIR
cfg.recordings_dir = str(recordings_dir)
await store.save(cfg)
try:
recordings_dir.mkdir(parents=True, exist_ok=True)
except PermissionError:
# Final fallback for local dev/deploy on same machine.
recordings_dir = DEFAULT_LOCAL_RECORDINGS_DIR
recordings_dir.mkdir(parents=True, exist_ok=True)
cfg.recordings_dir = str(recordings_dir)
await store.save(cfg)
app.mount("/videos", StaticFiles(directory=str(recordings_dir)), name="videos")
asyncio.create_task(_scheduler_loop())
@@ -118,8 +125,8 @@ async def add_camera(camera: Camera) -> AppConfig:
client = MediaMTXClient(
api_url=cfg.mediamtx_api_url,
username=os.getenv("MEDIAMTX_API_USER"),
password=os.getenv("MEDIAMTX_API_PASS"),
username=cfg.mediamtx_api_user,
password=cfg.mediamtx_api_pass,
)
try:
await client.upsert_paths_sources_bulk({camera.name: camera.rtsp_url})
@@ -139,8 +146,8 @@ async def delete_camera(name: str) -> AppConfig:
client = MediaMTXClient(
api_url=cfg.mediamtx_api_url,
username=os.getenv("MEDIAMTX_API_USER"),
password=os.getenv("MEDIAMTX_API_PASS"),
username=cfg.mediamtx_api_user,
password=cfg.mediamtx_api_pass,
)
try:
await client.delete_path(name)
@@ -154,8 +161,8 @@ 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"),
username=cfg.mediamtx_api_user,
password=cfg.mediamtx_api_pass,
)
try:
return await client.list_paths_status()
+10 -8
View File
@@ -1,7 +1,10 @@
import os
from pathlib import Path
from typing import Optional
from pydantic import BaseModel, Field
DEFAULT_RECORDINGS_DIR = str(Path(__file__).resolve().parents[2] / "mediamtx" / "recordings")
class Camera(BaseModel):
name: str = Field(min_length=1, max_length=64, pattern=r"^[a-zA-Z0-9_\-\/]+$")
@@ -16,13 +19,12 @@ class Schedule(BaseModel):
class AppConfig(BaseModel):
mediamtx_api_url: str = Field(
default_factory=lambda: os.getenv("MEDIAMTX_API_URL", "http://127.0.0.1:9997")
)
mediamtx_webrtc_url: str = Field(
default_factory=lambda: os.getenv("MEDIAMTX_WEBRTC_URL", "http://127.0.0.1:8889")
)
recordings_dir: str = Field(default_factory=lambda: os.getenv("RECORDINGS_DIR", "/recordings"))
mediamtx_api_url: str = "http://127.0.0.1:9997"
mediamtx_webrtc_url: str = "http://127.0.0.1:8889"
mediamtx_api_user: Optional[str] = None
mediamtx_api_pass: Optional[str] = None
recordings_dir: str = DEFAULT_RECORDINGS_DIR
api_port: int = 8008
cameras: list[Camera] = Field(default_factory=list)
schedule: Schedule = Field(default_factory=Schedule)
-1
View File
@@ -3,5 +3,4 @@ uvicorn[standard]>=0.27
httpx>=0.27
pydantic>=2.6
python-multipart>=0.0.9
python-dotenv>=1.0.1
+18 -7
View File
@@ -1,20 +1,31 @@
from __future__ import annotations
import os
import json
import sys
from pathlib import Path
import uvicorn
try:
from dotenv import load_dotenv
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
load_dotenv(Path(__file__).resolve().parents[1] / ".env")
except Exception:
pass
def _read_port_from_config() -> int:
cfg_path = PROJECT_ROOT / "api" / "data" / "config.json"
if not cfg_path.exists():
return 8008
try:
data = json.loads(cfg_path.read_text(encoding="utf-8"))
value = data.get("api_port", 8008)
port = int(value)
return port if 1 <= port <= 65535 else 8008
except Exception:
return 8008
def main() -> None:
port = int(os.getenv("DASHBOARD_PORT", "8008"))
port = _read_port_from_config()
uvicorn.run("api.app.main:app", host="0.0.0.0", port=port, reload=False)