fix bug
This commit is contained in:
+1
-11
@@ -1,14 +1,4 @@
|
||||
DASHBOARD_PORT=8008
|
||||
|
||||
MEDIAMTX_API_URL=http://127.0.0.1:9997
|
||||
MEDIAMTX_WEBRTC_URL=http://127.0.0.1:8889
|
||||
RECORDINGS_DIR=/recordings
|
||||
|
||||
MEDIAMTX_API_USER=
|
||||
MEDIAMTX_API_PASS=
|
||||
|
||||
CORS_ORIGINS=http://localhost:5173
|
||||
|
||||
# Frontend dev variables only.
|
||||
VITE_DEV_BACKEND_URL=http://localhost:8008
|
||||
VITE_API_BASE_URL=/api
|
||||
VITE_MEDIAMTX_WEBRTC_URL=
|
||||
|
||||
+13
-29
@@ -65,11 +65,11 @@ authInternalUsers:
|
||||
- action: api
|
||||
```
|
||||
|
||||
Sau đó set trong file `.env` của dashboard backend:
|
||||
Sau đó set trực tiếp trong `api/data/config.json` của dashboard backend:
|
||||
|
||||
```
|
||||
MEDIAMTX_API_USER=dashboard
|
||||
MEDIAMTX_API_PASS=dashboard_password
|
||||
"mediamtx_api_user": "dashboard",
|
||||
"mediamtx_api_pass": "dashboard_password"
|
||||
```
|
||||
|
||||
Trong `paths`, bạn có thể để dashboard tự thêm path bằng API (Settings → Add Camera).
|
||||
@@ -128,21 +128,16 @@ pip install -r api/requirements.txt
|
||||
|
||||
### 3.2 Cấu hình
|
||||
|
||||
Tạo file `.env`:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Backend sẽ tự load `.env` khi start.
|
||||
|
||||
Chạy backend lần đầu sẽ tự tạo `api/data/config.json` (lưu danh sách camera + schedule).
|
||||
Backend chỉ đọc cấu hình từ `api/data/config.json` (không đọc `.env`).
|
||||
Chạy lần đầu sẽ tự tạo file này (lưu camera + schedule + các tham số backend).
|
||||
|
||||
Bạn có thể chỉnh:
|
||||
|
||||
- `mediamtx_api_url` (mặc định `http://127.0.0.1:9997`)
|
||||
- `mediamtx_webrtc_url` (mặc định `http://127.0.0.1:8889`)
|
||||
- `recordings_dir` (mặc định `/recordings`)
|
||||
- `mediamtx_api_user` / `mediamtx_api_pass` (nếu bật auth API của MediaMTX)
|
||||
- `recordings_dir` (mặc định `./mediamtx/recordings` trong project)
|
||||
- `api_port` (mặc định `8008`)
|
||||
|
||||
Nếu chạy mô hình 2 thiết bị, set theo IP máy A (MediaMTX), ví dụ:
|
||||
|
||||
@@ -150,30 +145,20 @@ Nếu chạy mô hình 2 thiết bị, set theo IP máy A (MediaMTX), ví dụ:
|
||||
{
|
||||
"mediamtx_api_url": "http://192.168.88.10:9997",
|
||||
"mediamtx_webrtc_url": "http://192.168.88.10:8889",
|
||||
"recordings_dir": "/recordings",
|
||||
"mediamtx_api_user": null,
|
||||
"mediamtx_api_pass": null,
|
||||
"recordings_dir": "./mediamtx/recordings",
|
||||
"api_port": 8008,
|
||||
"cameras": [],
|
||||
"schedule": { "enabled": true, "weekdays_from": "18:00", "weekdays_to": "08:00", "weekend_all_day": true }
|
||||
}
|
||||
```
|
||||
|
||||
Hoặc set ENV trước lần chạy đầu tiên để tạo config mặc định:
|
||||
|
||||
```bash
|
||||
export MEDIAMTX_API_URL="http://192.168.88.10:9997"
|
||||
export MEDIAMTX_WEBRTC_URL="http://192.168.88.10:8889"
|
||||
export RECORDINGS_DIR="/recordings"
|
||||
```
|
||||
|
||||
Nếu MediaMTX API có auth, set trong `.env`:
|
||||
|
||||
- `MEDIAMTX_API_USER`
|
||||
- `MEDIAMTX_API_PASS`
|
||||
|
||||
### 3.3 Chạy backend
|
||||
|
||||
```bash
|
||||
source api/.venv/bin/activate
|
||||
python3 api/run.py
|
||||
python3 -m api.run
|
||||
```
|
||||
|
||||
## 4) Frontend (build static)
|
||||
@@ -234,7 +219,6 @@ After=network.target
|
||||
|
||||
[Service]
|
||||
WorkingDirectory=/opt/ipcam-dashboard
|
||||
EnvironmentFile=/opt/ipcam-dashboard/.env
|
||||
ExecStart=/opt/ipcam-dashboard/api/.venv/bin/python /opt/ipcam-dashboard/api/run.py
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
|
||||
@@ -25,21 +25,13 @@ Dashboard giám sát camera IP gọn nhẹ chạy trên Orange Pi, dựa trên M
|
||||
|
||||
## Chạy dev (máy dev)
|
||||
|
||||
### 0) Tạo file .env
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Sửa các biến trong `.env` theo IP/port thực tế (MediaMTX, recordings, CORS).
|
||||
|
||||
### 1) Chạy backend
|
||||
|
||||
```bash
|
||||
python3 -m venv api/.venv
|
||||
source api/.venv/bin/activate
|
||||
pip install -r api/requirements.txt
|
||||
python3 api/run.py
|
||||
python3 -m api.run
|
||||
```
|
||||
|
||||
### 2) Chạy frontend
|
||||
@@ -53,29 +45,42 @@ Frontend dev server đã được cấu hình proxy `/api` và `/videos` sang `h
|
||||
|
||||
## Cấu hình
|
||||
|
||||
Khuyến nghị cấu hình bằng file `.env` (copy từ `.env.example`).
|
||||
|
||||
Backend tự tạo file cấu hình tại `api/data/config.json` khi chạy lần đầu.
|
||||
Backend chỉ dùng file `api/data/config.json` (không đọc `.env`).
|
||||
|
||||
- `mediamtx_api_url`: ví dụ `http://127.0.0.1:9997`
|
||||
- `mediamtx_webrtc_url`: ví dụ `http://127.0.0.1:8889`
|
||||
- `recordings_dir`: ví dụ `/recordings`
|
||||
- `mediamtx_api_user`: username API (nếu bật auth trong MediaMTX)
|
||||
- `mediamtx_api_pass`: password API (nếu bật auth trong MediaMTX)
|
||||
- `recordings_dir`: ví dụ `./mediamtx/recordings` (cùng máy) hoặc đường dẫn mount NFS/SMB
|
||||
- `api_port`: cổng chạy backend (mặc định `8008`)
|
||||
- `cameras`: danh sách camera (name + rtsp_url)
|
||||
- `schedule`: lịch ghi hình
|
||||
|
||||
Có thể override WebRTC base URL ở frontend bằng biến môi trường `VITE_MEDIAMTX_WEBRTC_URL`.
|
||||
Ví dụ `config.json`:
|
||||
|
||||
Có thể set giá trị mặc định khi chạy lần đầu bằng ENV (backend):
|
||||
|
||||
- `MEDIAMTX_API_URL` (ví dụ `http://192.168.88.10:9997`)
|
||||
- `MEDIAMTX_WEBRTC_URL` (ví dụ `http://192.168.88.10:8889`)
|
||||
- `RECORDINGS_DIR` (ví dụ `/recordings` hoặc đường dẫn mount NFS/SMB)
|
||||
```json
|
||||
{
|
||||
"mediamtx_api_url": "http://127.0.0.1:9997",
|
||||
"mediamtx_webrtc_url": "http://127.0.0.1:8889",
|
||||
"mediamtx_api_user": null,
|
||||
"mediamtx_api_pass": null,
|
||||
"recordings_dir": "/mnt/ssd/IPCam_OrangePi_Dashboard/mediamtx/recordings",
|
||||
"api_port": 8008,
|
||||
"cameras": [],
|
||||
"schedule": {
|
||||
"enabled": true,
|
||||
"weekdays_from": "18:00",
|
||||
"weekdays_to": "08:00",
|
||||
"weekend_all_day": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Chạy tách 2 thiết bị (cùng LAN)
|
||||
|
||||
- Máy A (MediaMTX + ổ lưu recordings): chạy MediaMTX, mở cổng `9997/tcp`, `8889/tcp`, `8189/udp`, `8554/tcp`
|
||||
- Máy B (Dashboard backend + frontend): chạy FastAPI + serve web UI
|
||||
- Playback: máy B cần đọc được thư mục recordings của máy A (khuyến nghị mount NFS/SMB về `RECORDINGS_DIR`)
|
||||
- Playback: máy B cần đọc được thư mục recordings của máy A (khuyến nghị mount NFS/SMB và set `recordings_dir` trong `api/data/config.json`)
|
||||
|
||||
Chi tiết xem `INSTALL.md`.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user