Skip to content

Conversation

@patrickelectric
Copy link
Member

@patrickelectric patrickelectric commented Dec 5, 2025

Summary by Sourcery

Introduce a new Recorder Extractor service and UI for browsing, streaming, and downloading recorded MP4 sessions extracted from MCAP files.

New Features:

  • Add a FastAPI-based Recorder Extractor service that lists, serves, and deletes MP4 recordings with thumbnail generation and MCAP-to-MP4 extraction.
  • Expose the Recorder Extractor HTTP endpoint through nginx and the frontend dev proxy for external access.
  • Add a frontend Records view, menu entry, and Vuex store to browse recordings, preview thumbnails, play videos inline, and download or delete files.

Bug Fixes:

  • Harden file open detection by refactoring lsof usage and adding an asynchronous variant shared by sync and async callers.

Chores:

  • Register the Recorder Extractor service port in the helper service port list.

@sourcery-ai
Copy link

sourcery-ai bot commented Dec 5, 2025

Reviewer's Guide

Introduces a new Recorder Extractor service and UI to list, stream, download, delete, and generate thumbnails for MP4 recordings extracted from MCAP files, wiring it through nginx, frontend routing, and helper utilities (including improved file-in-use checks).

Sequence diagram for listing, viewing, and deleting recordings

sequenceDiagram
  actor User
  participant Browser
  participant RecordsView
  participant RecordsStore
  participant Nginx
  participant RecorderAPI
  participant RecorderDir as Recorder_directory

  User->>Browser: Open Records page
  Browser->>RecordsView: Mount RecordsView
  RecordsView->>RecordsStore: refresh()
  RecordsStore->>Nginx: GET /recorder-extractor/v1.0/recorder/files
  Nginx->>RecorderAPI: Proxy request
  RecorderAPI->>RecorderDir: Scan for *.mp4
  RecorderDir-->>RecorderAPI: List of MP4 files
  RecorderAPI-->>Nginx: 200 List[RecordingFile]
  Nginx-->>RecordsStore: 200 List[RecordingFile]
  RecordsStore-->>RecordsView: Update recordings state
  RecordsView-->>User: Show recording cards with thumbnails

  User->>RecordsView: Click thumbnail or play button
  RecordsView->>Browser: Open dialog with video player
  Browser->>Nginx: GET activeRecord.stream_url (MP4)
  Nginx->>RecorderAPI: Proxy to get_recording
  RecorderAPI->>RecorderDir: Open MP4 file
  RecorderDir-->>RecorderAPI: File stream
  RecorderAPI-->>Nginx: 200 video/mp4 (stream)
  Nginx-->>Browser: Stream video
  Browser-->>User: Play video

  User->>RecordsView: Click delete icon
  RecordsView->>RecordsStore: deleteRecording(file)
  RecordsStore->>Nginx: DELETE /recorder-extractor/v1.0/recorder/files/{file.path}
  Nginx->>RecorderAPI: Proxy to delete_recording
  RecorderAPI->>RecorderDir: Delete file
  RecorderDir-->>RecorderAPI: File removed
  RecorderAPI-->>Nginx: 204 No Content
  Nginx-->>RecordsStore: 204 No Content
  RecordsStore->>RecordsStore: fetchRecordings()
  RecordsStore->>Nginx: GET /recorder-extractor/v1.0/recorder/files
  Nginx->>RecorderAPI: Proxy request
  RecorderAPI->>RecorderDir: Rescan MP4 files
  RecorderDir-->>RecorderAPI: Updated list
  RecorderAPI-->>RecordsStore: 200 Updated list
  RecordsStore-->>RecordsView: Update recordings
  RecordsView-->>User: Updated list without deleted file
Loading

Sequence diagram for automatic MCAP extraction loop

sequenceDiagram
  participant ExtractLoop as extract_mcap_recordings
  participant RecorderDir as Recorder_directory
  participant FileCheck as file_is_open_async
  participant McapExtractor as mcap-foxglove-video-extract

  loop Every_10_seconds
    ExtractLoop->>RecorderDir: ensure_recorder_dir()
    RecorderDir-->>ExtractLoop: Base path
    ExtractLoop->>RecorderDir: rglob("*.mcap")
    RecorderDir-->>ExtractLoop: List of MCAP files
    loop For_each_mcap_path
      ExtractLoop->>RecorderDir: Compute output_dir (no .mcap suffix)
      alt output_dir exists
        ExtractLoop->>ExtractLoop: Skip (already extracted or deleted)
      else output_dir missing
        ExtractLoop->>FileCheck: file_is_open_async(mcap_path)
        alt File is open
          FileCheck-->>ExtractLoop: True
          ExtractLoop->>ExtractLoop: Log "Skipping MCAP extract, file in use"
        else File is not open
          FileCheck-->>ExtractLoop: False
          ExtractLoop->>McapExtractor: Spawn process with arguments
          McapExtractor-->>ExtractLoop: stdout, stderr, returncode
          alt returncode == 0
            ExtractLoop->>ExtractLoop: Log success
          else extraction failed
            ExtractLoop->>ExtractLoop: Log error with stderr
          end
        end
      end
    end
  end
Loading

Class diagram for Recorder Extractor backend and frontend types

classDiagram
  class RecordingFileModel {
    +str name
    +str path
    +int size_bytes
    +float modified
    +str download_url
    +str stream_url
    +str thumbnail_url
  }

  class RecorderRouter {
    +list_recordings() List~RecordingFileModel~
    +get_recording_thumbnail(filename str) StreamingResponse
    +delete_recording(filename str) None
    +get_recording(filename str) FileResponse
  }

  class RecorderExtractorApp {
    +FastAPI fast_api_app
    +VersionedFastAPI app
    +main() None
  }

  class ExtractLoop {
    +extract_mcap_recordings() None
  }

  class FileOpenUtils {
    +_file_is_open_command(path Path) list~str~
    +_file_is_open_logic_lsof(returncode int, stdout str, stderr str) bool
    +file_is_open(path Path) bool
    +file_is_open_async(path Path) bool
  }

  class RecordingFileTs {
    <<interface>>
    +string name
    +string path
    +number size_bytes
    +number modified
    +string download_url
    +string stream_url
    +string thumbnail_url
  }

  class RecordsStoreTs {
    +string API_URL
    +RecordingFileTs[] recordings
    +boolean loading
    +string error
    +setLoading(value boolean) void
    +setRecordings(files RecordingFileTs[]) void
    +setError(message string) void
    +fetchRecordings() Promise~void~
    +deleteRecording(file RecordingFileTs) Promise~void~
  }

  RecorderRouter --> RecordingFileModel : uses
  RecorderExtractorApp --> RecorderRouter : includes
  RecorderExtractorApp --> ExtractLoop : starts
  ExtractLoop --> FileOpenUtils : uses

  RecordsStoreTs --> RecordingFileTs : manages
  RecordingFileTs .. RecordingFileModel : mirrors_structure
Loading

Flow diagram for thumbnail generation from MP4 recordings

flowchart TD
  A_start["Request /files/{filename}/thumbnail"] --> B_resolve["resolve_recording(filename)"]
  B_resolve --> C_ok_path{Is path valid existing .mp4 file?}
  C_ok_path -- No --> X_http_error["Return HTTP 4xx (invalid or missing recording)"]
  C_ok_path -- Yes --> D_thread["asyncio.to_thread(build_thumbnail_bytes, path)"]
  D_thread --> E_discover["Run gst-discoverer-1.0 file://path"]
  E_discover --> F_rc_ok{returncode == 0?}
  F_rc_ok -- No --> Y_http_error500["Log error and return HTTP 500 (inspect failed)"]
  F_rc_ok -- Yes --> G_parse["parse_duration_ns(discover_output)"]
  G_parse --> H_target["Compute target_ns = duration_ns/2 (or 0) and target_sec"]
  H_target --> I_build_pipeline["Build gst-play-1.0 command with JPEG fdsink pipeline"]
  I_build_pipeline --> J_run_play["Run gst-play-1.0 at target_sec"]
  J_run_play --> K_success{returncode == 0 and stdout not empty?}
  K_success -- No --> Z_http_error500["Log error and return HTTP 500 (thumbnail failed)"]
  K_success -- Yes --> L_bytes["Return stdout bytes as JPEG data"]
  L_bytes --> M_stream["StreamingResponse(BytesIO(thumbnail_bytes), image/jpeg)"]
  M_stream --> N_end["Send JPEG thumbnail to client"]
Loading

File-Level Changes

Change Details Files
Refactor file-in-use detection and add an async variant for use by background services.
  • Extract lsof command construction into a reusable helper function.
  • Extract lsof result interpretation into a dedicated helper with logging on unexpected errors.
  • Update file_is_open to use the helpers, improve error logging, and ensure text mode stdout/stderr.
  • Introduce file_is_open_async using asyncio.create_subprocess_exec with timeout handling and shared logic parser.
core/libs/commonwealth/src/commonwealth/utils/general.py
Expose the new Recorder Extractor backend through nginx, dev proxy, and helper service metadata.
  • Add /recorder-extractor/ location in nginx with CORS, Range/If-Range headers, and disabled proxy buffering for streaming.
  • Add Vite dev-server proxy entry for /recorder-extractor to the backend server.
  • Register port 9150 in the helper service’s known ports set.
core/tools/nginx/nginx.conf
core/frontend/vite.config.js
core/services/helper/main.py
Implement Recorder Extractor FastAPI service to list, stream, delete recordings, generate thumbnails, and periodically extract MP4s from MCAP files.
  • Define data model for recording metadata and safe resolution of recording paths under /usr/blueos/userdata/recorder with extension and traversal checks.
  • Implement thumbnail generation via gst-discoverer-1.0 and gst-play-1.0 pipelines, including duration parsing and error handling.
  • Add background task that periodically scans for .mcap files and invokes mcap-foxglove-video-extract when files are not in use (using async file-is-open check).
  • Provide versioned FastAPI router with endpoints to list recordings, stream/download a file, delete a file, and fetch a thumbnail, wrapped in generic HTTP error handling.
  • Wire up the FastAPI app with Sentry, uvicorn server on port 9150, and minimal HTML root for quick service verification.
  • Declare recorder_extractor service dependencies and metadata in a dedicated pyproject.toml.
core/services/recorder_extractor/main.py
core/services/recorder_extractor/pyproject.toml
Add frontend Records page and Vuex store to browse, preview, download, and delete recordings via the Recorder Extractor API.
  • Create RecordsView.vue with a card grid showing thumbnails, size, and timestamp, plus delete and download actions and an in-dialog HTML5 video player.
  • Introduce Vuex module records.ts to call the backend /recorder-extractor/v1.0/recorder endpoints, manage loading/error state, and refresh list on delete.
  • Define shared TypeScript RecordingFile interface matching backend payload.
  • Register the Records view in the main menu and router under /tools/records.
core/frontend/src/views/RecordsView.vue
core/frontend/src/store/records.ts
core/frontend/src/types/records.ts
core/frontend/src/menus.ts
core/frontend/src/router/index.ts

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

sourcery-ai[bot]
sourcery-ai bot previously requested changes Dec 5, 2025
Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there - I've reviewed your changes and found some issues that need to be addressed.

Blocking issues:

  • Detected subprocess function 'run' without a static string. If this data can be controlled by a malicious actor, it may be an instance of command injection. Audit the use of this call to ensure it is not controllable by an external resource. You may consider using 'shlex.escape()'. (link)
  • Detected subprocess function 'run' without a static string. If this data can be controlled by a malicious actor, it may be an instance of command injection. Audit the use of this call to ensure it is not controllable by an external resource. You may consider using 'shlex.escape()'. (link)
  • Detected subprocess function 'run' without a static string. If this data can be controlled by a malicious actor, it may be an instance of command injection. Audit the use of this call to ensure it is not controllable by an external resource. You may consider using 'shlex.escape()'. (link)

General comments:

  • In RecordsView.vue, onThumbnailError is called with file.name but brokenThumbnails is keyed by path, so thumbnails will never be marked as broken; pass file.path (or change the keying) to ensure the fallback thumbnail is actually used.
  • In records.ts, the delete URL interpolates file.path directly into the path segment, but the backend expects a URL-encoded {filename:path} like the generated download_url; consider either URL-encoding file.path or issuing the DELETE against file.download_url to avoid issues with nested paths or special characters.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `RecordsView.vue`, `onThumbnailError` is called with `file.name` but `brokenThumbnails` is keyed by `path`, so thumbnails will never be marked as broken; pass `file.path` (or change the keying) to ensure the fallback thumbnail is actually used.
- In `records.ts`, the delete URL interpolates `file.path` directly into the path segment, but the backend expects a URL-encoded `{filename:path}` like the generated `download_url`; consider either URL-encoding `file.path` or issuing the DELETE against `file.download_url` to avoid issues with nested paths or special characters.

## Individual Comments

### Comment 1
<location> `core/frontend/src/views/RecordsView.vue:38-47` </location>
<code_context>
+              @error="onThumbnailError(file.name)"
</code_context>

<issue_to_address>
**issue (bug_risk):** Thumbnail error handler keys by name but lookup uses path, so the fallback thumbnail is never applied.

`thumbnailSrc` checks `this.brokenThumbnails[file.path]`, but the `@error` handler calls `onThumbnailError(file.name)`, so the flag is keyed by name while the lookup uses path. As a result, the fallback image is never used. Pass `file.path` to `onThumbnailError` (and/or rename the parameter) so the key matches the lookup.
</issue_to_address>

### Comment 2
<location> `core/services/recorder_extractor/main.py:128` </location>
<code_context>
+        f"Thumbnail target: duration_ns={duration_ns} target_ns={target_ns} target_sec={target_sec:.3f} file={path}"
+    )
+    logger.info(f"Thumbnail command: {' '.join(play_cmd)}")
+    result = subprocess.run(play_cmd, capture_output=True, check=False)
+    if result.returncode != 0 or not result.stdout:
+        stderr = result.stderr.decode("utf-8", "ignore")
</code_context>

<issue_to_address>
**issue (bug_risk):** Running gst-play without a timeout risks hanging request handlers if the subprocess stalls.

`subprocess.run` is called without a timeout, so if `gst-play-1.0` hangs (e.g. on malformed input), the FastAPI worker can block indefinitely. Please add a `timeout=` and handle `subprocess.TimeoutExpired` so the handler can fail gracefully instead of tying up the worker.
</issue_to_address>

### Comment 3
<location> `core/services/recorder_extractor/main.py:83-92` </location>
<code_context>
+                        mcap_path,
+                        stdout.decode("utf-8", "ignore").strip(),
+                    )
+        except Exception as exception:
+            logger.exception("MCAP extraction loop failed: %s", exception)
+
+
+def to_http_exception(endpoint: Callable[..., Any]) -> Callable[..., Any]:
+    is_async = asyncio.iscoroutinefunction(endpoint)
+
+    @wraps(endpoint)
+    async def wrapper(*args: Any, **kwargs: Any) -> Any:
+        try:
+            if is_async:
+                return await endpoint(*args, **kwargs)
+            return endpoint(*args, **kwargs)
+        except HTTPException as exception:
+            raise exception
+        except Exception as exception:
+            logger.exception("Recorder endpoint failed")
+            raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exception)) from exception
+
+    return wrapper
</code_context>

<issue_to_address>
**🚨 suggestion (security):** Wrapping unexpected exceptions with HTTPException using the raw exception string may leak internal details.

The wrapper currently returns a 500 with `detail=str(exception)`, which can reveal internal implementation details or sensitive information to clients. Instead, return a generic error message in `detail` and rely on `logger.exception` for full diagnostics.
</issue_to_address>

### Comment 4
<location> `core/libs/commonwealth/src/commonwealth/utils/general.py:187-194` </location>
<code_context>
        result = subprocess.run(
            cmd,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            timeout=5,
            check=False,
        )
</code_context>

<issue_to_address>
**security (python.lang.security.audit.dangerous-subprocess-use-audit):** Detected subprocess function 'run' without a static string. If this data can be controlled by a malicious actor, it may be an instance of command injection. Audit the use of this call to ensure it is not controllable by an external resource. You may consider using 'shlex.escape()'.

*Source: opengrep*
</issue_to_address>

### Comment 5
<location> `core/services/recorder_extractor/main.py:98` </location>
<code_context>
    discover = subprocess.run(discover_cmd, capture_output=True, text=True, check=False)
</code_context>

<issue_to_address>
**security (python.lang.security.audit.dangerous-subprocess-use-audit):** Detected subprocess function 'run' without a static string. If this data can be controlled by a malicious actor, it may be an instance of command injection. Audit the use of this call to ensure it is not controllable by an external resource. You may consider using 'shlex.escape()'.

*Source: opengrep*
</issue_to_address>

### Comment 6
<location> `core/services/recorder_extractor/main.py:128` </location>
<code_context>
    result = subprocess.run(play_cmd, capture_output=True, check=False)
</code_context>

<issue_to_address>
**security (python.lang.security.audit.dangerous-subprocess-use-audit):** Detected subprocess function 'run' without a static string. If this data can be controlled by a malicious actor, it may be an instance of command injection. Audit the use of this call to ensure it is not controllable by an external resource. You may consider using 'shlex.escape()'.

*Source: opengrep*
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@patrickelectric patrickelectric force-pushed the video-extract-service branch 7 times, most recently from 7178de2 to 4a53194 Compare December 6, 2025 15:01
@joaomariolago joaomariolago dismissed sourcery-ai[bot]’s stale review December 8, 2025 18:25

This sub process run is intended to run with dynamic command, in this context specifically will not introduce a direct security fail. Other points commented in review were adjusted.

@joaomariolago joaomariolago merged commit b461c75 into bluerobotics:master Dec 8, 2025
6 of 7 checks passed
@patrickelectric patrickelectric added the docs-needed Change needs to be documented label Dec 9, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs-needed Change needs to be documented

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants