From 2efebb0be06c6b449909c046096b64effa08a041 Mon Sep 17 00:00:00 2001 From: Tony Tran Date: Fri, 8 May 2026 13:11:24 +0700 Subject: [PATCH] =?UTF-8?q?fix=20l=E1=BB=8Bch=20ghi=20h=C3=ACnh=20v=C3=A0?= =?UTF-8?q?=20ng=C3=A0y=20l=C6=B0u?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/main.py | 36 ++++++++++++++++++++++++++++++++++++ api/app/models.py | 5 +++++ api/app/scheduler.py | 6 +++--- src/pages/Settings.tsx | 38 ++++++++++++++++++++++++++++++++++++++ src/types/api.ts | 1 + 5 files changed, 83 insertions(+), 3 deletions(-) diff --git a/api/app/main.py b/api/app/main.py index 9e2c195..37d684a 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -21,6 +21,7 @@ from .models import ( MediaMTXAddCameraRequest, MediaMTXCamera, MediaMTXConfigView, + RecordDeleteAfterUpdate, RecordingToggle, ScheduleUpdate, SchedulerEnabled, @@ -111,9 +112,35 @@ def _set_recording_in_mediamtx_yml(enabled: bool) -> bool: return True +def _parse_record_delete_after_days(value: object) -> Optional[int]: + if not isinstance(value, str): + return None + text = value.strip().lower() + if text == "24h": + return 1 + if text == "72h": + return 3 + if text == "168h": + return 7 + return None + + +def _set_record_delete_after_days_in_mediamtx_yml(days: int) -> None: + if days not in {1, 3, 7}: + raise HTTPException(status_code=400, detail="invalid_record_delete_after_days") + data = _load_mediamtx_yml() + path_defaults = data.setdefault("pathDefaults", {}) + if not isinstance(path_defaults, dict): + raise HTTPException(status_code=400, detail="invalid_mediamtx_path_defaults") + path_defaults["recordDeleteAfter"] = f"{days * 24}h" + data["pathDefaults"] = path_defaults + _save_mediamtx_yml(data) + + def _build_mediamtx_view(data: dict, cfg: AppConfig) -> MediaMTXConfigView: path_defaults = data.get("pathDefaults") or {} record_enabled = bool(path_defaults.get("record", False)) + record_delete_after_days = _parse_record_delete_after_days(path_defaults.get("recordDeleteAfter")) cameras: list[MediaMTXCamera] = [] paths = data.get("paths") or {} @@ -130,6 +157,7 @@ def _build_mediamtx_view(data: dict, cfg: AppConfig) -> MediaMTXConfigView: api_url=cfg.mediamtx_api_url, webrtc_url=cfg.mediamtx_webrtc_url, record_enabled=record_enabled, + record_delete_after_days=record_delete_after_days, cameras=cameras, ) @@ -314,6 +342,14 @@ async def set_mediamtx_recording(data: RecordingToggle) -> MediaMTXConfigView: return _build_mediamtx_view(payload, cfg) +@app.post("/api/mediamtx/record-delete-after") +async def set_record_delete_after(data: RecordDeleteAfterUpdate) -> MediaMTXConfigView: + cfg = await store.load() + _set_record_delete_after_days_in_mediamtx_yml(data.days) + payload = _load_mediamtx_yml() + return _build_mediamtx_view(payload, cfg) + + @app.post("/api/mediamtx/restart") async def restart_mediamtx() -> dict: return _restart_mediamtx() diff --git a/api/app/models.py b/api/app/models.py index 51cbdbc..beb6eeb 100644 --- a/api/app/models.py +++ b/api/app/models.py @@ -61,8 +61,13 @@ class MediaMTXConfigView(BaseModel): api_url: str webrtc_url: str record_enabled: bool + record_delete_after_days: Optional[int] = None cameras: list[MediaMTXCamera] class MediaMTXAddCameraRequest(BaseModel): rtsp_url: str = Field(min_length=1, max_length=2048) + + +class RecordDeleteAfterUpdate(BaseModel): + days: int = Field(ge=1, le=7) diff --git a/api/app/scheduler.py b/api/app/scheduler.py index 9794d73..69e6540 100644 --- a/api/app/scheduler.py +++ b/api/app/scheduler.py @@ -40,7 +40,7 @@ class Scheduler: 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 + # Always enforce desired state to recover from external/manual drift. + await self.apply(state) + self._last_state = state diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index ad05b04..e8e0c96 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -30,6 +30,7 @@ export default function Settings() { const [mediamtxApiPass, setMediamtxApiPass] = useState(""); const [recordingsDir, setRecordingsDir] = useState(""); const [apiPort, setApiPort] = useState("8008"); + const [recordDeleteAfterDays, setRecordDeleteAfterDays] = useState("7"); const loadMtx = async () => { setMtxBusy(true); @@ -37,6 +38,7 @@ export default function Settings() { try { const data = await apiJson("/mediamtx/config", { method: "GET" }); setMtx(data); + setRecordDeleteAfterDays(String(data.record_delete_after_days ?? 7)); } catch (e) { if (typeof e === "object" && e && "status" in e) { const ex = e as { status: number; bodyText?: string }; @@ -180,6 +182,28 @@ export default function Settings() { } }; + const onChangeRecordDeleteAfter = async (days: number) => { + setMtxBusy(true); + setMtxError(null); + try { + const data = await apiJson("/mediamtx/record-delete-after", { + method: "POST", + body: JSON.stringify({ days }), + }); + setMtx(data); + setRecordDeleteAfterDays(String(data.record_delete_after_days ?? days)); + } catch (e) { + if (typeof e === "object" && e && "status" in e) { + const ex = e as { status: number; bodyText?: string }; + setMtxError(`http_${ex.status}${ex.bodyText ? `: ${ex.bodyText}` : ""}`); + } else { + setMtxError("failed_to_update_record_delete_after"); + } + } finally { + setMtxBusy(false); + } + }; + const onRestartDocker = async () => { setMtxBusy(true); setRestartMsg(null); @@ -365,6 +389,20 @@ export default function Settings() { MediaMTX record (pathDefaults.record) +
+ + +
+