Explorar el Código

Fix camera reconnect counter off-by-one and ffmpeg log flood (#925)

  Two bugs surfaced while investigating camera reconnect behaviour in #925.

  The camera page briefly displayed "Reconnecting attempt 6 of 5" before
  giving up, because the attempt counter could be incremented to the
  maximum while the reconnect banner was still rendering. The displayed
  value is now clamped to the configured maximum.

  Every failed ffmpeg spawn logged the full ~20-line ffmpeg version,
  configuration, and lib* banner, producing hundreds of lines of noise
  per failed camera click (one reported click produced 555 log lines
  across 30 retries). A new _summarize_ffmpeg_stderr helper strips the
  banner and caps output at the last 10 meaningful lines, applied at
  all three stderr log sites (immediate-failure, stream-ended,
  read-timeout). Covered by unit tests for empty input, banner
  stripping, line cap, blank-line filtering, and banner-only input.

  The underlying "camera service stops accepting connections after
  prolonged uptime" behaviour in the X1C firmware is still under
  investigation — these two fixes are independent of that root cause.
maziggy hace 1 mes
padre
commit
39a5840f67

+ 1 - 0
CHANGELOG.md

@@ -8,6 +8,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **LDAP Default Fallback Group** — Settings → Authentication → LDAP → Advanced now has a "Default group" selector. When an LDAP user authenticates but is not listed in any mapped LDAP group, they are automatically assigned to this fallback group instead of being left without permissions. Previously such users could log in successfully but landed on empty pages because every permission check failed. Leave the setting empty to preserve the old behavior. A warning is logged each time the fallback is applied so administrators can spot missing group assignments.
 - **LDAP Default Fallback Group** — Settings → Authentication → LDAP → Advanced now has a "Default group" selector. When an LDAP user authenticates but is not listed in any mapped LDAP group, they are automatically assigned to this fallback group instead of being left without permissions. Previously such users could log in successfully but landed on empty pages because every permission check failed. Leave the setting empty to preserve the old behavior. A warning is logged each time the fallback is applied so administrators can spot missing group assignments.
 
 
 ### Fixed
 ### Fixed
+- **Camera Stream "6 of 5" Reconnect Counter + ffmpeg Log Flood** ([#925](https://github.com/maziggy/bambuddy/issues/925)) — Two bugs surfaced while investigating camera reconnect behaviour. First, the camera page briefly displayed "Reconnecting attempt 6 of 5" before giving up, because the attempt counter could be incremented to the maximum while the reconnect banner was still rendering. The displayed value is now clamped to the configured maximum. Second, every failed ffmpeg spawn logged the full ~20-line ffmpeg version/configuration banner, producing hundreds of lines of noise per failed camera click (one reported click produced 555 log lines across 30 retries). A new stderr summarizer strips the ffmpeg banner before logging so only the actual error lines remain. The underlying "camera service stops accepting new connections after prolonged uptime" behaviour in the X1C firmware is still under investigation.
 - **LDAP POSIX Primary Group Ignored** — LDAP authentication only looked at groups that listed the user explicitly via `memberUid` (supplementary group membership). A user's POSIX primary group — referenced by the `gidNumber` attribute on the user object and matching the `gidNumber` on a `posixGroup` — was ignored entirely, so users whose role came from their primary group landed without the expected permissions. The authenticator now also searches for `posixGroup` entries whose `gidNumber` matches the user's primary `gidNumber`, and dedupes DNs case-insensitively before resolving the group mapping (LDAP DNs are case-insensitive by spec).
 - **LDAP POSIX Primary Group Ignored** — LDAP authentication only looked at groups that listed the user explicitly via `memberUid` (supplementary group membership). A user's POSIX primary group — referenced by the `gidNumber` attribute on the user object and matching the `gidNumber` on a `posixGroup` — was ignored entirely, so users whose role came from their primary group landed without the expected permissions. The authenticator now also searches for `posixGroup` entries whose `gidNumber` matches the user's primary `gidNumber`, and dedupes DNs case-insensitively before resolving the group mapping (LDAP DNs are case-insensitive by spec).
 - **Support Bundle Leaks Virtual Printer IP Address** — The debug support bundle included the `virtual_printer_remote_interface_ip` setting value unmasked in `support-info.json`. The setting key didn't match any of the existing sensitive-key filters, so the raw IP address was included in the bundle. Added `_ip` to the sensitive key filter so IP address settings are excluded from support bundles. Log file content was already covered by the existing IPv4 regex redaction.
 - **Support Bundle Leaks Virtual Printer IP Address** — The debug support bundle included the `virtual_printer_remote_interface_ip` setting value unmasked in `support-info.json`. The setting key didn't match any of the existing sensitive-key filters, so the raw IP address was included in the bundle. Added `_ip` to the sensitive key filter so IP address settings are excluded from support bundles. Log file content was already covered by the existing IPv4 regex redaction.
 - **"Build Plate Cleared" Button Unclickable After Second Print** ([#912](https://github.com/maziggy/bambuddy/issues/912)) — After completing the first queued print and confirming the plate was cleared, the "Build plate cleared — ready for next print" button became unresponsive after the second print finished. The React Query mutation's `isSuccess` state persisted from the first plate-clear confirmation, causing the component to render the static "Plate Ready" confirmation instead of the clickable button. The mutation state is now reset when the printer leaves the FINISH/FAILED state, so the button works correctly on every print cycle.
 - **"Build Plate Cleared" Button Unclickable After Second Print** ([#912](https://github.com/maziggy/bambuddy/issues/912)) — After completing the first queued print and confirming the plate was cleared, the "Build plate cleared — ready for next print" button became unresponsive after the second print finished. The React Query mutation's `isSuccess` state persisted from the first plate-clear confirmation, causing the component to render the static "Plate Ready" confirmation instead of the clickable button. The mutation state is now reset when the printer leaves the FINISH/FAILED state, so the button works correctly on every print cycle.

+ 36 - 3
backend/app/api/routes/camera.py

@@ -201,13 +201,46 @@ async def _terminate_ffmpeg(process: asyncio.subprocess.Process, stream_id: str
     _spawned_ffmpeg_pids.pop(process.pid, None)
     _spawned_ffmpeg_pids.pop(process.pid, None)
 
 
 
 
+def _summarize_ffmpeg_stderr(text: str | None) -> str:
+    """Strip ffmpeg's boilerplate banner and keep only actionable lines.
+
+    ffmpeg prints ~20 lines of version/build/configuration/lib headers before
+    any actual error message. Logging the full banner on every retry floods
+    the log (hundreds of lines per failed stream). This filter drops the
+    banner and caps output at the last 10 meaningful lines.
+    """
+    if not text:
+        return ""
+    banner_prefixes = (
+        "ffmpeg version ",
+        "  built with ",
+        "  configuration:",
+        "  libavutil ",
+        "  libavcodec ",
+        "  libavformat ",
+        "  libavdevice ",
+        "  libavfilter ",
+        "  libswscale ",
+        "  libswresample ",
+        "  libpostproc ",
+    )
+    meaningful = [ln for ln in text.splitlines() if ln.strip() and not ln.startswith(banner_prefixes)]
+    return "\n".join(meaningful[-10:])
+
+
 async def _read_ffmpeg_stderr(process: asyncio.subprocess.Process) -> str | None:
 async def _read_ffmpeg_stderr(process: asyncio.subprocess.Process) -> str | None:
-    """Read ffmpeg stderr for diagnostics (best-effort, non-blocking)."""
+    """Read ffmpeg stderr for diagnostics (best-effort, non-blocking).
+
+    Returns the stderr content with ffmpeg's boilerplate banner stripped,
+    so log output stays focused on the actual error.
+    """
     if not process or not process.stderr:
     if not process or not process.stderr:
         return None
         return None
     try:
     try:
         data = await asyncio.wait_for(process.stderr.read(), timeout=2.0)
         data = await asyncio.wait_for(process.stderr.read(), timeout=2.0)
-        return data.decode(errors="replace") if data else None
+        if not data:
+            return None
+        return _summarize_ffmpeg_stderr(data.decode(errors="replace")) or None
     except (TimeoutError, Exception):
     except (TimeoutError, Exception):
         return None
         return None
 
 
@@ -338,7 +371,7 @@ async def generate_rtsp_mjpeg_stream(
             await asyncio.sleep(0.1)
             await asyncio.sleep(0.1)
             if process.returncode is not None:
             if process.returncode is not None:
                 stderr = await process.stderr.read()
                 stderr = await process.stderr.read()
-                stderr_text = stderr.decode(errors="replace")
+                stderr_text = _summarize_ffmpeg_stderr(stderr.decode(errors="replace"))
                 logger.error("ffmpeg failed immediately (attempt %d): %s", reconnect_count + 1, stderr_text)
                 logger.error("ffmpeg failed immediately (attempt %d): %s", reconnect_count + 1, stderr_text)
                 _spawned_ffmpeg_pids.pop(process.pid, None)
                 _spawned_ffmpeg_pids.pop(process.pid, None)
                 if not got_any_frames and reconnect_count == 0:
                 if not got_any_frames and reconnect_count == 0:

+ 68 - 0
backend/tests/unit/test_camera_stderr_summary.py

@@ -0,0 +1,68 @@
+"""Tests for _summarize_ffmpeg_stderr (#925).
+
+The ffmpeg banner (version / build / configuration / lib*) dumps ~20 lines
+before any actual error. Before this fix, every failed camera retry logged
+the full banner, producing hundreds of lines per failure — see #925 where a
+single click produced 555 lines across 30 retries. The helper strips the
+banner so logs stay focused on the real error.
+"""
+
+from backend.app.api.routes.camera import _summarize_ffmpeg_stderr
+
+_FAKE_BANNER = """ffmpeg version 7.1.3-0+deb13u1 Copyright (c) 2000-2025 the FFmpeg developers
+  built with gcc 14 (Debian 14.2.0-19)
+  configuration: --prefix=/usr --extra-version=0+deb13u1 --toolchain=hardened --enable-gpl --enable-gnutls
+  libavutil      59. 39.100 / 59. 39.100
+  libavcodec     61. 19.101 / 61. 19.101
+  libavformat    61.  7.100 / 61.  7.100
+  libavdevice    61.  3.100 / 61.  3.100
+  libavfilter    10.  4.100 / 10.  4.100
+  libswscale      8.  3.100 /  8.  3.100
+  libswresample   5.  3.100 /  5.  3.100
+  libpostproc    58.  3.100 / 58.  3.100
+"""
+
+
+def test_empty_input():
+    assert _summarize_ffmpeg_stderr("") == ""
+    assert _summarize_ffmpeg_stderr(None) == ""
+
+
+def test_keeps_error_lines_drops_banner():
+    stderr = _FAKE_BANNER + (
+        "[in#0 @ 0x64a7cd6350c0] Error opening input: Invalid data found when processing input\n"
+        "Error opening input file rtsp://[CREDENTIALS]@192.0.2.1:322/streaming/live/1.\n"
+        "Error opening input files: Invalid data found when processing input\n"
+    )
+    result = _summarize_ffmpeg_stderr(stderr)
+
+    # Banner gone
+    assert "ffmpeg version" not in result
+    assert "configuration:" not in result
+    assert "libavcodec" not in result
+
+    # Real errors preserved
+    assert "Error opening input: Invalid data found when processing input" in result
+    assert "Error opening input file rtsp" in result
+
+
+def test_caps_at_10_lines():
+    stderr = _FAKE_BANNER + "\n".join(f"error line {i}" for i in range(25))
+    result = _summarize_ffmpeg_stderr(stderr)
+
+    lines = result.splitlines()
+    assert len(lines) == 10
+    # Keeps the *last* 10 lines (most recent errors closest to failure)
+    assert lines[-1] == "error line 24"
+    assert lines[0] == "error line 15"
+
+
+def test_drops_blank_lines():
+    stderr = "real error\n\n\n   \nsecond error\n"
+    result = _summarize_ffmpeg_stderr(stderr)
+    assert result == "real error\nsecond error"
+
+
+def test_banner_only_returns_empty():
+    """If ffmpeg prints only the banner (no errors), the summary should be empty."""
+    assert _summarize_ffmpeg_stderr(_FAKE_BANNER) == ""

+ 1 - 1
frontend/src/pages/CameraPage.tsx

@@ -697,7 +697,7 @@ export function CameraPage() {
                 <WifiOff className="w-10 h-10 text-orange-400 mx-auto mb-3" />
                 <WifiOff className="w-10 h-10 text-orange-400 mx-auto mb-3" />
                 <p className="text-white mb-2">{t('camera.connectionLost')}</p>
                 <p className="text-white mb-2">{t('camera.connectionLost')}</p>
                 <p className="text-sm text-bambu-gray mb-3">
                 <p className="text-sm text-bambu-gray mb-3">
-                  {t('camera.reconnecting', { countdown: reconnectCountdown, attempt: reconnectAttempts + 1, max: MAX_RECONNECT_ATTEMPTS })}
+                  {t('camera.reconnecting', { countdown: reconnectCountdown, attempt: Math.min(reconnectAttempts + 1, MAX_RECONNECT_ATTEMPTS), max: MAX_RECONNECT_ATTEMPTS })}
                 </p>
                 </p>
                 <button
                 <button
                   onClick={refresh}
                   onClick={refresh}

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-D6dfDqxE.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-LfULlGD1.js"></script>
+    <script type="module" crossorigin src="/assets/index-D6dfDqxE.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Caj-77TJ.css">
     <link rel="stylesheet" crossorigin href="/assets/index-Caj-77TJ.css">
   </head>
   </head>
   <body>
   <body>

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio