From 81c727b7a6254c7e0928bd4ab7cd6174e4457ec5 Mon Sep 17 00:00:00 2001 From: Tony Tran Date: Tue, 28 Apr 2026 11:05:42 +0700 Subject: [PATCH] fix bug --- .env.example | 12 +--------- INSTALL.md | 42 +++++++++++---------------------- README.md | 45 ++++++++++++++++++++---------------- api/app/config_store.py | 7 +++++- api/app/main.py | 51 +++++++++++++++++++++++------------------ api/app/models.py | 18 ++++++++------- api/requirements.txt | 1 - api/run.py | 25 ++++++++++++++------ 8 files changed, 102 insertions(+), 99 deletions(-) diff --git a/.env.example b/.env.example index 97db6bf..7f6a2d5 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/INSTALL.md b/INSTALL.md index 6ff8bc4..269b7ed 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -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 diff --git a/README.md b/README.md index 166973d..f34258d 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/api/app/config_store.py b/api/app/config_store.py index 946056b..002076f 100644 --- a/api/app/config_store.py +++ b/api/app/config_store.py @@ -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: diff --git a/api/app/main.py b/api/app/main.py index 8191cd4..885a7a3 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -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() diff --git a/api/app/models.py b/api/app/models.py index c10d264..7ffa323 100644 --- a/api/app/models.py +++ b/api/app/models.py @@ -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) diff --git a/api/requirements.txt b/api/requirements.txt index df26fa5..446d91c 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -3,5 +3,4 @@ uvicorn[standard]>=0.27 httpx>=0.27 pydantic>=2.6 python-multipart>=0.0.9 -python-dotenv>=1.0.1 diff --git a/api/run.py b/api/run.py index b944eb6..32bac5f 100644 --- a/api/run.py +++ b/api/run.py @@ -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)