diff --git a/backend/core/restream.py b/backend/core/restream.py index e045d12..466733d 100644 --- a/backend/core/restream.py +++ b/backend/core/restream.py @@ -105,9 +105,10 @@ class BroadcastGroup: self._last_data_at: float = time.time() # ffmpeg process tracking - self._ffmpeg_pid: int | None = None - self._ffmpeg_cpu: float = 0.0 - self._cpu_task: asyncio.Task | None = None + self._ffmpeg_pid: int | None = None + self._ffmpeg_start_time: float | None = None # wall time when ffmpeg started + self._ffmpeg_cpu: float = 0.0 + self._cpu_task: asyncio.Task | None = None # ------------------------------------------------------------------ # Public API @@ -210,7 +211,7 @@ class BroadcastGroup: "stream_info": self.stream_info, # ffmpeg process info "ffmpeg_pid": self._ffmpeg_pid, - "ffmpeg_cpu_pct": round(self._ffmpeg_cpu, 1), + "ffmpeg_cpu_pct": round(self._ffmpeg_cpu, 2), } # ------------------------------------------------------------------ @@ -218,32 +219,31 @@ class BroadcastGroup: # ------------------------------------------------------------------ async def _monitor_cpu(self) -> None: - """Sample /proc/{pid}/stat every 2 s and update self._ffmpeg_cpu (%).""" + """Update self._ffmpeg_cpu every 3 s using cumulative average since start. + + Delta-based sampling fails for low-CPU processes (copy-mode ffmpeg uses + <1% CPU) because the tick delta in 2 s is 0-1 integer ticks, making the + result always 0.0%. Cumulative average ((total_cpu_ticks / clk_tck) / + elapsed_wall) gives a stable, accurate reading — the same method ps uses. + """ clk_tck = float(os.sysconf(os.sysconf_names.get("SC_CLK_TCK", 2)) or 100) - prev_ticks: int | None = None - prev_wall: float | None = None while self._running: - await asyncio.sleep(2) - pid = self._ffmpeg_pid - if pid is None: + await asyncio.sleep(3) + pid = self._ffmpeg_pid + start_time = self._ffmpeg_start_time + if pid is None or start_time is None: self._ffmpeg_cpu = 0.0 - prev_ticks = prev_wall = None continue try: with open(f"/proc/{pid}/stat") as f: parts = f.read().split() - ticks = int(parts[13]) + int(parts[14]) # utime + stime - wall = time.time() - if prev_ticks is not None and prev_wall is not None: - dt = wall - prev_wall - if dt > 0: - self._ffmpeg_cpu = (ticks - prev_ticks) / clk_tck / dt * 100 - prev_ticks = ticks - prev_wall = wall + cpu_ticks = int(parts[13]) + int(parts[14]) # utime + stime + elapsed = time.time() - start_time + if elapsed > 0: + self._ffmpeg_cpu = cpu_ticks / clk_tck / elapsed * 100 except (FileNotFoundError, IndexError, ValueError, OSError): self._ffmpeg_cpu = 0.0 - prev_ticks = prev_wall = None async def _probe_info(self) -> None: from .probe import probe_stream @@ -328,9 +328,10 @@ class BroadcastGroup: stderr=asyncio.subprocess.PIPE, ) - self._status = "ok" - self._last_data_at = time.time() - self._ffmpeg_pid = proc.pid + self._status = "ok" + self._last_data_at = time.time() + self._ffmpeg_pid = proc.pid + self._ffmpeg_start_time = time.time() logger.info( f"[ch{self.channel_id}] ffmpeg started pid={proc.pid} " f"(reconnects so far: {self._reconnect_count})" @@ -368,8 +369,9 @@ class BroadcastGroup: await self._dispatch(chunk, now) finally: - self._ffmpeg_pid = None - self._ffmpeg_cpu = 0.0 + self._ffmpeg_pid = None + self._ffmpeg_start_time = None + self._ffmpeg_cpu = 0.0 stderr_task.cancel() try: proc.kill() diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index b29076c..177838f 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -237,7 +237,7 @@ export default function Dashboard() { 80 ? 'text-red-400' : totalCpu > 40 ? 'text-yellow-400' : 'text-cyan-400'} />} label="ffmpeg CPU total" - value={`${totalCpu.toFixed(1)}%`} + value={`${totalCpu.toFixed(2)}%`} /> @@ -330,7 +330,7 @@ export default function Dashboard() { s.ffmpeg_cpu_pct > 40 ? 'text-yellow-400' : 'text-gray-400' }`}> - {s.ffmpeg_cpu_pct.toFixed(1)}% + {s.ffmpeg_cpu_pct.toFixed(2)}% )}