Skip to content

Add Ring Camera Recorder (initial codebase)#1

Merged
fayerman-source merged 5 commits into
mainfrom
initial-codebase
Jun 17, 2026
Merged

Add Ring Camera Recorder (initial codebase)#1
fayerman-source merged 5 commits into
mainfrom
initial-codebase

Conversation

@fayerman-source

@fayerman-source fayerman-source commented Jun 17, 2026

Copy link
Copy Markdown
Owner

User description

Summary

Initial codebase for Ring Camera Recorder: a self-hosted tool that records the live view from Ring cameras you own to local disk, without a Ring Protect subscription. It captures the freely available live stream (not Ring's paid cloud storage), either on demand or automatically when a motion/doorbell event fires.

What's included

  • Interactive 2FA auth with automatic refresh-token rotation and persistence
  • Motion and doorbell ("ding") triggered recording (cooldown + no-overlap policy)
  • Manual one-shot recording and an end-to-end verify harness
  • Fragmented-MP4 output so interrupted clips stay playable
  • Layered config, configurable retention, CLI (list / record / verify)
  • Hermetic tests (filename convention, retention pruning, trigger state machine)
  • systemd and pm2 service units
  • CI workflow (build + test on every PR)
  • LICENSE (MIT), NOTICE (attribution + not-affiliated disclaimer), SECURITY.md

Verification

Verified against a real account during development: authenticated, listed the camera, captured playable clips (video + audio confirmed via ffprobe), and confirmed a motion event auto-records end to end. npm test passes 15/15 hermetic checks.

Notes for reviewers

  • Pins ring-client-api@14.3.0. It uses Ring's unofficial API, which can change without notice (see SECURITY.md).
  • npm audit reports a transitive WebRTC advisory (ip via werift) that cannot be resolved without downgrading to a broken version. It is documented in SECURITY.md; do not run npm audit fix --force.

Test plan

  • npm run build
  • npm test
  • CI green on this PR

🤖 Generated with Claude Code


CodeAnt-AI Description

Add a self-hosted Ring camera recorder with login, clip capture, and auto-triggered recording

What Changed

  • Adds a one-time Ring login flow that saves a refresh token for future runs
  • Lets users list cameras and record a live clip on demand from a chosen camera
  • Automatically records when supported cameras report motion or a doorbell press
  • Writes clips as playable MP4 files, creates the output folder as needed, and removes old clips when retention is enabled
  • Adds a simple verification command, background service setup for systemd and pm2, and project docs, license, and security notes

Impact

✅ Shorter setup for local Ring recording
✅ Fewer missed motion events
✅ Playable clips after interrupted recordings

💡 Usage Guide

Checking Your Pull Request

Every time you make a pull request, our system automatically looks through it. We check for security issues, mistakes in how you're setting up your infrastructure, and common code problems. We do this to make sure your changes are solid and won't cause any trouble later.

Talking to CodeAnt AI

Got a question or need a hand with something in your pull request? You can easily get in touch with CodeAnt AI right here. Just type the following in a comment on your pull request, and replace "Your question here" with whatever you want to ask:

@codeant-ai ask: Your question here

This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.

Example

@codeant-ai ask: Can you suggest a safer alternative to storing this secret?

Preserve Org Learnings with CodeAnt

You can record team preferences so CodeAnt AI applies them in future reviews. Reply directly to the specific CodeAnt AI suggestion (in the same thread) and replace "Your feedback here" with your input:

@codeant-ai: Your feedback here

This helps CodeAnt AI learn and adapt to your team's coding style and standards.

Example

@codeant-ai: Do not flag unused imports.

Retrigger review

Ask CodeAnt AI to review the PR again, by typing:

@codeant-ai: review

Check Your Repository Health

To analyze the health of your code repository, visit our dashboard at https://app.codeant.ai. This tool helps you identify potential issues and areas for improvement in your codebase, ensuring your repository maintains high standards of code health.

Summary by CodeRabbit

  • New Features

    • Ring Camera Recorder application for authenticating with Ring accounts and recording live video feeds
    • Motion detection and doorbell-triggered automatic recording
    • CLI commands for manual recording and camera listing
    • Configurable clip length, retention policies, and camera selection
    • Background service deployment support
  • Documentation

    • Comprehensive README with setup, configuration, and operational guidance
    • Security best practices for token and credential handling
    • Third-party licensing notices
  • Tests

    • Logic test suite for core functionality
  • Chores

    • CI/CD pipeline configuration
    • TypeScript and build setup
    • Deployment templates and example configuration

Self-hosted tool that records the live view from Ring cameras you own to
local disk, without a Ring Protect subscription. Includes:

- Interactive 2FA auth with automatic refresh-token rotation/persistence
- Motion/ding-triggered recording, plus manual one-shot capture
- Fragmented-MP4 output (interrupted clips stay playable)
- Configurable retention, layered config, CLI (list/record/verify)
- Hermetic tests (filename, retention, trigger state machine)
- systemd + pm2 service units, CI workflow, LICENSE/NOTICE/SECURITY docs
@codeant-ai

codeant-ai Bot commented Jun 17, 2026

Copy link
Copy Markdown

Thanks for using CodeAnt! 🎉

We're free for open-source projects. if you're enjoying it, help us grow by sharing.

Share on X ·
Reddit ·
LinkedIn

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@codeant-ai codeant-ai Bot added the size:XXL This PR changes 1000+ lines, ignoring generated files label Jun 17, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a self-hosted local recorder for Ring cameras, featuring motion and doorbell-triggered recording, automatic token rotation, and configurable clip retention. The reviewer provided constructive feedback focused on improving robustness and error handling. Key recommendations include preserving original error details during authentication failures, recursively creating directories before writing the token file, wrapping token parsing and logger serialization in try-catch blocks to prevent crashes, validating retention sweep intervals, and unsubscribing from event streams immediately in the verification harness to prevent concurrent overlapping recordings.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/auth.ts Outdated
Comment on lines +33 to +38
} catch {
// Expected when 2FA is enabled: the first getAuth triggers the code being
// sent and sets promptFor2fa with a human-readable instruction.
if (!client.promptFor2fa) {
throw new Error('Login failed (check email/password). Ring did not request a 2FA code.');
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The catch block masks the original error when client.getAuth() fails for reasons other than a 2FA prompt (e.g., network errors, DNS issues, or rate limits). This makes troubleshooting difficult. Preserving the original error message in the thrown error will greatly improve debuggability.

Suggested change
} catch {
// Expected when 2FA is enabled: the first getAuth triggers the code being
// sent and sets promptFor2fa with a human-readable instruction.
if (!client.promptFor2fa) {
throw new Error('Login failed (check email/password). Ring did not request a 2FA code.');
}
} catch (err) {
// Expected when 2FA is enabled: the first getAuth triggers the code being
// sent and sets promptFor2fa with a human-readable instruction.
if (!client.promptFor2fa) {
throw new Error('Login failed: ' + (err as Error).message);
}

Comment thread src/ring.ts Outdated
@@ -0,0 +1,79 @@
import { readFileSync, writeFileSync, existsSync, chmodSync } from 'node:fs';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Import mkdirSync from node:fs and dirname from node:path to support safe directory creation for the token file.

Suggested change
import { readFileSync, writeFileSync, existsSync, chmodSync } from 'node:fs';
import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';

Comment thread src/ring.ts Outdated
Comment on lines +23 to +25
const raw = JSON.parse(readFileSync(cfg.tokenPath, 'utf8')) as TokenFile;
if (!raw.refreshToken) throw new Error(`Token file ${cfg.tokenPath} is missing "refreshToken".`);
return raw.refreshToken;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Wrap readFileSync and JSON.parse in a try-catch block to handle cases where the token file is corrupted, unreadable, or contains invalid JSON, providing a clear and helpful error message.

Suggested change
const raw = JSON.parse(readFileSync(cfg.tokenPath, 'utf8')) as TokenFile;
if (!raw.refreshToken) throw new Error(`Token file ${cfg.tokenPath} is missing "refreshToken".`);
return raw.refreshToken;
try {
const raw = JSON.parse(readFileSync(cfg.tokenPath, 'utf8')) as TokenFile;
if (!raw || !raw.refreshToken) {
throw new Error('File is missing the "refreshToken" property.');
}
return raw.refreshToken;
} catch (err) {
throw new Error('Failed to read or parse token file at ' + cfg.tokenPath + ': ' + (err as Error).message);
}

Comment thread src/ring.ts Outdated
Comment on lines +29 to +31
export function writeToken(cfg: AppConfig, refreshToken: string): void {
const body: TokenFile = { refreshToken, updatedAt: new Date().toISOString() };
writeFileSync(cfg.tokenPath, JSON.stringify(body, null, 2), { mode: 0o600 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

If the directory containing cfg.tokenPath does not exist (e.g., if configured to a nested directory like secrets/ring-token.json), writeFileSync will throw an ENOENT error and crash the application. This is especially critical during background token rotation. Ensure the parent directory is created recursively before writing.

export function writeToken(cfg: AppConfig, refreshToken: string): void {
  const body: TokenFile = { refreshToken, updatedAt: new Date().toISOString() };
  const dir = dirname(cfg.tokenPath);
  if (dir && !existsSync(dir)) {
    mkdirSync(dir, { recursive: true });
  }
  writeFileSync(cfg.tokenPath, JSON.stringify(body, null, 2), { mode: 0o600 });

Comment thread src/config.ts
Comment on lines +94 to +101
function validate(c: AppConfig): void {
if (c.clipLengthSeconds <= 0) throw new Error('clipLengthSeconds must be > 0');
if (c.motionCooldownSeconds < 0) throw new Error('motionCooldownSeconds must be >= 0');
if (c.retentionDays !== null && c.retentionDays < 0) throw new Error('retentionDays must be >= 0 or null');
if (c.cameras !== 'all' && !Array.isArray(c.cameras)) {
throw new Error('cameras must be "all" or an array of names/ids');
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Add validation for retentionSweepMinutes to ensure it is strictly greater than 0. If configured to 0 or a negative value, setInterval in src/retention.ts could execute continuously, leading to high CPU usage or unexpected runtime errors.

Suggested change
function validate(c: AppConfig): void {
if (c.clipLengthSeconds <= 0) throw new Error('clipLengthSeconds must be > 0');
if (c.motionCooldownSeconds < 0) throw new Error('motionCooldownSeconds must be >= 0');
if (c.retentionDays !== null && c.retentionDays < 0) throw new Error('retentionDays must be >= 0 or null');
if (c.cameras !== 'all' && !Array.isArray(c.cameras)) {
throw new Error('cameras must be "all" or an array of names/ids');
}
}
function validate(c: AppConfig): void {
if (c.clipLengthSeconds <= 0) throw new Error('clipLengthSeconds must be > 0');
if (c.motionCooldownSeconds < 0) throw new Error('motionCooldownSeconds must be >= 0');
if (c.retentionDays !== null && c.retentionDays < 0) throw new Error('retentionDays must be >= 0 or null');
if (c.retentionSweepMinutes <= 0) throw new Error('retentionSweepMinutes must be > 0');
if (c.cameras !== 'all' && !Array.isArray(c.cameras)) {
throw new Error('cameras must be "all" or an array of names/ids');
}
}

Comment thread src/verify.ts
Comment on lines +113 to +121
const onTrigger = (reason: string) => {
log.info(` → ${reason} detected; recording a clip to prove the trigger path…`);
recordClip(camera, cfg, Math.min(cfg.clipLengthSeconds, 10))
.then(() => finish(true))
.catch((e) => {
log.error(` recording after trigger failed: ${(e as Error).message}`);
finish(false);
});
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

In waitForTriggeredRecording, if multiple events (e.g., rapid motion events or a doorbell press) fire before the first recordClip finishes, multiple concurrent recordings will be triggered on the same camera. This can cause overlapping ffmpeg writes and API throttling. Unsubscribe from the event streams immediately upon receiving the first trigger.

Suggested change
const onTrigger = (reason: string) => {
log.info(` → ${reason} detected; recording a clip to prove the trigger path…`);
recordClip(camera, cfg, Math.min(cfg.clipLengthSeconds, 10))
.then(() => finish(true))
.catch((e) => {
log.error(` recording after trigger failed: ${(e as Error).message}`);
finish(false);
});
};
const onTrigger = (reason: string) => {
subs.forEach((s) => s.unsubscribe());
subs.length = 0;
log.info(' → ' + reason + ' detected; recording a clip to prove the trigger path…');
recordClip(camera, cfg, Math.min(cfg.clipLengthSeconds, 10))
.then(() => finish(true))
.catch((e) => {
log.error(' recording after trigger failed: ' + (e as Error).message);
finish(false);
});
};

Comment thread src/log.ts
Comment on lines +14 to +16
if (extra !== undefined) {
stream.write(`${line} ${typeof extra === 'string' ? extra : JSON.stringify(extra)}\n`);
} else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

If extra is a complex object containing circular references (such as certain Error objects or API responses), JSON.stringify(extra) will throw a TypeError and crash the logger. Since logging is frequently used inside catch blocks, a logger crash will mask the original error and crash the application. Wrap the serialization in a try-catch block for safety.

  if (extra !== undefined) {
    let extraStr = '';
    try {
      extraStr = typeof extra === 'string' ? extra : JSON.stringify(extra);
    } catch {
      extraStr = '[Unserializable Object]';
    }
    stream.write(line + ' ' + extraStr + '\n');
  } else {

@coderabbitai

coderabbitai Bot commented Jun 17, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@fayerman-source, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 41 minutes and 42 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d8cac4c4-a76f-41d4-868c-23f07e54e857

📥 Commits

Reviewing files that changed from the base of the PR and between 1c4b7c4 and 11e2dd1.

📒 Files selected for processing (12)
  • .github/workflows/ci.yml
  • src/auth.ts
  • src/config.ts
  • src/events.ts
  • src/files.ts
  • src/log.ts
  • src/recorder.ts
  • src/retention.ts
  • src/ring.ts
  • src/verify.ts
  • test/logic.test.mjs
  • tsconfig.json
📝 Walkthrough

Walkthrough

This PR introduces the complete initial implementation of ring-camera-recorder, a self-hosted Node.js/TypeScript service that authenticates with the Ring API, records live camera streams to timestamped MP4 files, triggers recordings on motion/doorbell events with cooldown and overlap prevention, prunes clips by retention policy, and exposes auth/list/record/verify CLI commands alongside systemd and PM2 deploy configs.

Changes

Ring Camera Recorder — full initial implementation

Layer / File(s) Summary
Project scaffolding
package.json, tsconfig.json, .gitignore, .github/workflows/ci.yml
Defines the ESM package with Node ≥20 constraint, CLI binary, build/test/typecheck scripts, TypeScript ES2022 compiler config with bundler resolution, CI workflow running npm run build + npm test, and gitignore rules for secrets and generated files.
AppConfig, loadConfig, cameraSelected, and logger
src/config.ts, src/log.ts, config.example.json
Declares AppConfig with all recording and retention fields; implements layered config loading (defaults → JSON files → env overrides) with path resolution and validation; exports cameraSelected for name/id filtering; adds a minimal leveled logger with ISO timestamps and stdout/stderr routing.
File utilities and retention sweeper
src/files.ts, src/retention.ts
Provides clipFilename, slugify, ensureDir, and pruneOldClips for filesystem-safe filenames and mtime-based MP4 deletion; startRetention schedules an immediate + periodic pruneOldClips sweep and returns a stop function.
Ring token persistence and API client
src/ring.ts
Implements readToken/writeToken (0600 permissions, updatedAt tracking), createRingApi (token-based construction, persists rotated tokens via onRefreshTokenUpdated), and getSelectedCameras for filtered camera lists.
recordClip recording engine
src/recorder.ts
Exports RecordResult; recordClip starts a WebRTC/ffmpeg session with fragmented MP4 args, awaits onCallEnded under a hard timeout, validates output file size, and returns recording metadata. Includes internal withTimeout helper.
Camera event trigger state machine and hermetic tests
src/events.ts, test/logic.test.mjs
shouldTrigger enforces overlap prevention and motionCooldownSeconds; watchCamera wires RxJS motion (rising edge) and optional doorbell subscriptions with an injectable RecordFn; the test suite validates clipFilename, pruneOldClips, and all watchCamera trigger scenarios with fake observables.
Interactive auth and manual record/list CLI
src/auth.ts, src/cli.ts
auth.ts prompts for credentials with masked password input, authenticates via RingRestClient with 2FA fallback, and writes the refresh token; cli.ts implements list and record subcommands with parseArgs, pickCamera, API disconnect in finally, and delayed process.exit for ffmpeg teardown.
Service entrypoint and e2e verification harness
src/index.ts, src/verify.ts
index.ts wires config → API → cameras → per-camera watchCamera + startRetention with SIGINT/SIGTERM graceful shutdown; verify.ts records a clip, probes streams with ffprobe, and optionally waits for a triggered recording, exiting with pass/fail codes.
Deploy configs, docs, and legal files
deploy/*, README.md, SECURITY.md, LICENSE, NOTICE
Adds systemd unit and PM2 ecosystem config for production deployment; comprehensive README covering setup, triggering policy, config layering, limitations, and background-service operation; SECURITY.md with vulnerability reporting and npm advisory guidance; MIT LICENSE and NOTICE with third-party credits.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant auth as src/auth.ts
  participant RingRestClient
  participant ring as src/ring.ts
  participant index as src/index.ts
  participant watchCamera as src/events.ts
  participant recordClip as src/recorder.ts
  participant RingCamera

  User->>auth: npm run auth (email/password/2FA)
  auth->>RingRestClient: authenticate credentials
  RingRestClient-->>auth: refreshToken
  auth->>ring: writeToken(.ring-token.json, 0600)

  User->>index: npm start (long-running service)
  index->>ring: createRingApi (readToken)
  ring->>ring: subscribe onRefreshTokenUpdated → writeToken
  index->>ring: getSelectedCameras
  ring-->>index: filtered RingCamera list
  index->>watchCamera: watchCamera(camera, cfg, recordClip)

  RingCamera-->>watchCamera: motion active (rising edge) or ding
  watchCamera->>watchCamera: shouldTrigger? (no overlap, cooldown passed)
  watchCamera->>recordClip: recordClip(camera, cfg, clipLengthSeconds)
  recordClip->>RingCamera: streamVideo(fragmented MP4 args)
  RingCamera-->>recordClip: onCallEnded
  recordClip-->>watchCamera: RecordResult {path, bytes, seconds}
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐇 A new recorder hops into the scene,
With tokens and motion and ffmpeg's gleam,
It watches for rings and the doorbell's sweet chime,
Saves clips to a folder, one frame at a time.
The retention sweeps old files away with a bound,
And CI keeps the tests on solid ground! 🎥

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main objective of the PR: introducing the initial codebase for Ring Camera Recorder. It is clear, specific, and directly related to the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch initial-codebase

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread src/retention.ts
};

sweep(); // run immediately on startup
const handle = setInterval(sweep, cfg.retentionSweepMinutes * 60 * 1000);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: The sweep interval is scheduled from cfg.retentionSweepMinutes without validating it is a positive finite number. If config/env sets it to 0, negative, or NaN, Node timers effectively run almost continuously, causing a tight retention loop (high CPU + repeated filesystem churn). Reject invalid values before calling setInterval (or clamp to a safe minimum). [performance]

Severity Level: Major ⚠️
- ⚠️ Long-running service in `src/index.ts` can spike CPU.
- ⚠️ Excessive filesystem scans in `pruneOldClips` under misconfig.
- ⚠️ Retention logs may flood due to rapid sweep scheduling.
Steps of Reproduction ✅
1. Configure a positive retention window in the config layer: set `retentionDays` to a
value > 0 and `retentionSweepMinutes` to `0`, a negative number, or a non-numeric string
in `config.json` / `config.local.json` (these files are merged in `src/config.ts:60-73`).

2. Start the long-running service by executing the main entrypoint in `src/index.ts:12-45`
(e.g., the script that runs `main()`), which calls `loadConfig()` (`src/index.ts:13`) and
then `startRetention(cfg)` at `src/index.ts:30`.

3. Inside `startRetention` (`src/retention.ts:9-27`), the `cfg.retentionDays` guard at
lines 10-13 passes (since it is > 0), then `setInterval(sweep, cfg.retentionSweepMinutes *
60 * 1000)` at line 23 schedules the sweep with an interval derived from the misconfigured
`retentionSweepMinutes`.

4. Because Node.js coerces invalid/zero/negative timer values to a very short delay, the
`sweep` callback at `src/retention.ts:15-20` repeatedly calls `pruneOldClips()`
(`src/files.ts:39-64`) in a near-tight loop, causing continuous directory scans and
`statSync`/`unlinkSync` operations against `cfg.outputDir` and driving high CPU and
filesystem churn.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/retention.ts
**Line:** 23:23
**Comment:**
	*Performance: The sweep interval is scheduled from `cfg.retentionSweepMinutes` without validating it is a positive finite number. If config/env sets it to `0`, negative, or `NaN`, Node timers effectively run almost continuously, causing a tight retention loop (high CPU + repeated filesystem churn). Reject invalid values before calling `setInterval` (or clamp to a safe minimum).

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment thread src/ring.ts Outdated
/** Write the token with 0600 perms (it grants full account access). */
export function writeToken(cfg: AppConfig, refreshToken: string): void {
const body: TokenFile = { refreshToken, updatedAt: new Date().toISOString() };
writeFileSync(cfg.tokenPath, JSON.stringify(body, null, 2), { mode: 0o600 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: Writing the refresh token directly to the destination file is non-atomic; interruption or concurrent writes can leave a truncated/invalid JSON token file and break future startups. Persist to a temporary file and rename atomically to avoid partial-file corruption. [race condition]

Severity Level: Major ⚠️
- ❌ Token rotation persistence can leave a corrupted credential file.
- ❌ Main service in `src/index.ts` may fail to start afterward.
- ❌ CLI and verify scripts also break on corrupted token JSON.
Steps of Reproduction ✅
1. Run the long-lived recorder service via `main()` in `src/index.ts:12-45`, which creates
a `RingApi` instance by calling `createRingApi(cfg)` (`src/index.ts:17``src/ring.ts:47-65`), wiring `api.onRefreshTokenUpdated.subscribe(...)` at
`src/ring.ts:59-63`.

2. Allow the service to run until `RingApi` rotates the refresh token (per the documented
behavior in the comment at `src/ring.ts:40-45`), causing the `onRefreshTokenUpdated`
subscription to invoke `writeToken(cfg, newRefreshToken)` at `src/ring.ts:62`.

3. During `writeToken` (`src/ring.ts:29-37`), the call to `writeFileSync(cfg.tokenPath,
JSON.stringify(body, null, 2), { mode: 0o600 });` at line 31 writes JSON directly to the
final token path; if the process is terminated abruptly (e.g., `kill -9`, crash, power
loss) or a second process (e.g., `npm run verify` in `src/verify.ts:23-29`) accesses the
same `cfg.tokenPath` concurrently, the file can be left truncated or partially written.

4. On the next startup of any entrypoint that uses `createRingApi(cfg)` (service in
`src/index.ts`, CLI in `src/cli.ts:19-20`, or verify harness in `src/verify.ts:27-29`),
`readToken(cfg)` at `src/ring.ts:17-25` attempts `JSON.parse(readFileSync(cfg.tokenPath,
'utf8'))`; if the token file is corrupted by the non-atomic write, `JSON.parse` throws a
`SyntaxError`, preventing the recorder from authenticating until the operator reruns
`src/auth.ts:14-53` to regenerate a fresh token.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/ring.ts
**Line:** 31:31
**Comment:**
	*Race Condition: Writing the refresh token directly to the destination file is non-atomic; interruption or concurrent writes can leave a truncated/invalid JSON token file and break future startups. Persist to a temporary file and rename atomically to avoid partial-file corruption.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment thread src/config.ts Outdated
if (process.env.RING_TOKEN_PATH) merged.tokenPath = process.env.RING_TOKEN_PATH;
if (process.env.RING_OUTPUT_DIR) merged.outputDir = process.env.RING_OUTPUT_DIR;
if (process.env.RING_CLIP_SECONDS) merged.clipLengthSeconds = num(process.env.RING_CLIP_SECONDS, merged.clipLengthSeconds);
if (process.env.RING_RETENTION_DAYS) merged.retentionDays = num(process.env.RING_RETENTION_DAYS, 0) || null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: RING_RETENTION_DAYS uses num(..., 0) || null, so any non-numeric env value (for example a typo like RING_RETENTION_DAYS=abc) is silently converted to null and disables retention instead of keeping the previous configured value. This can unexpectedly stop pruning and grow disk usage indefinitely; use the current merged.retentionDays as fallback or reject invalid env input. [logic error]

Severity Level: Major ⚠️
- ❌ Retention disabled by typo in RING_RETENTION_DAYS env.
- ❌ Long-running service accumulates clips without any pruning.
- ⚠️ Log only shows generic "Retention disabled" message.
Steps of Reproduction ✅
1. Create a `config.json` at the project root
(`/workspace/ring-camera-recorder/config.json`) with a positive retention value, e.g. `{
"retentionDays": 7 }`, which loadConfig reads and merges at `src/config.ts:63-68`.

2. Start the long-running service entrypoint at `src/index.ts:12-33` (e.g. `node
dist/index.js`) with an invalid environment override, such as `RING_RETENTION_DAYS=abc`,
so `process.env.RING_RETENTION_DAYS` is a non-numeric string.

3. In `loadConfig()` at `src/config.ts:60-87`, the environment override block at line 79
executes: `num('abc', 0)` returns `0` (fallback), and `0 || null` sets
`merged.retentionDays` to `null`, overwriting the `7` from `config.json` without error or
warning.

4. Still in `index.ts`, `startRetention(cfg)` is called at line 30; inside
`startRetention` at `src/retention.ts:9-13`, the condition `if (!cfg.retentionDays ||
cfg.retentionDays <= 0)` treats `null` as falsy, logs `Retention disabled (keeping all
clips).` and returns a no-op, so `pruneOldClips()` at `src/files.ts:39-64` is never
invoked and old recordings are never pruned, allowing unbounded disk growth.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/config.ts
**Line:** 79:79
**Comment:**
	*Logic Error: `RING_RETENTION_DAYS` uses `num(..., 0) || null`, so any non-numeric env value (for example a typo like `RING_RETENTION_DAYS=abc`) is silently converted to `null` and disables retention instead of keeping the previous configured value. This can unexpectedly stop pruning and grow disk usage indefinitely; use the current `merged.retentionDays` as fallback or reject invalid env input.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment thread src/config.ts Outdated
}

function validate(c: AppConfig): void {
if (c.clipLengthSeconds <= 0) throw new Error('clipLengthSeconds must be > 0');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: The clip length validation only checks <= 0 and does not verify the value is actually numeric/finite, so a malformed config value like "clipLengthSeconds": "abc" passes validation and later propagates as NaN into recording logic (-t NaN, invalid timeout math). Enforce Number.isFinite (and numeric type) during validation. [type error]

Severity Level: Major ⚠️
- ❌ Motion-triggered recordings fail when clip length misconfigured.
- ❌ Verify harness can pass NaN/invalid duration to ffmpeg.
- ⚠️ Error messages include NaN timeouts, confusing diagnosis.
Steps of Reproduction ✅
1. Create `config.json` at the project root with a non-numeric clip length, e.g. `{
"clipLengthSeconds": "abc", "recordOnMotion": true }`, which is merged into the config in
`loadConfig()` at `src/config.ts:63-68`.

2. When `loadConfig()` calls `validate(merged)` at `src/config.ts:85`, the only check for
this field is `if (c.clipLengthSeconds <= 0)` at line 95; the comparison `"abc" <= 0`
coerces to `NaN <= 0` which is `false`, so validation passes and `clipLengthSeconds`
remains the string `"abc"`.

3. Start the main service at `src/index.ts:12-33` so it loads this config and begins
watching cameras; on a motion or ding event, `watchCamera()` at `src/events.ts:39-52`
calls `recordFn(camera, cfg, cfg.clipLengthSeconds)` at line 52, passing `"abc"` as the
`seconds` argument.

4. In `recordClip()` at `src/recorder.ts:42-58`, `seconds` is treated as numeric:
`clipOutputArgs(seconds, outPath)` builds an ffmpeg command with `'-t', String(seconds)`
producing `-t abc`, and `const hardTimeoutMs = (seconds + 30) * 1000` at line 57 yields
`NaN`, causing `withTimeout()` to use an immediate timeout; the recording either fails
quickly due to the invalid `-t` value or times out immediately, logging a failure in
`watchCamera()` at `src/events.ts:52-56` and producing no usable clip.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/config.ts
**Line:** 95:95
**Comment:**
	*Type Error: The clip length validation only checks `<= 0` and does not verify the value is actually numeric/finite, so a malformed config value like `"clipLengthSeconds": "abc"` passes validation and later propagates as `NaN` into recording logic (`-t NaN`, invalid timeout math). Enforce `Number.isFinite` (and numeric type) during validation.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment thread src/files.ts Outdated
Comment on lines +14 to +15
const ts = when.toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/:/g, '-');
return `${slug}_${ts}.mp4`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: The filename timestamp strips milliseconds, so two recordings from the same camera started within the same second will generate the exact same path and one clip can overwrite the other. Keep sub-second precision (or add a unique suffix) to prevent silent data loss on near-simultaneous recordings. [logic error]

Severity Level: Major ⚠️
- ❌ Concurrent recordings may overwrite earlier clip for same camera.
- ⚠️ Verify harness parallel runs can drop diagnostic clips.
Steps of Reproduction ✅
1. Invoke the manual recording CLI twice in parallel for the same camera by running
`ring-recorder record --camera "Front Door" --seconds 30` from two terminals; both
executions enter `main()` in `src/cli.ts:11-54`, which loads config and calls
`recordClip()` at `src/cli.ts:51`.

2. Each CLI process calls `recordClip()` in `src/recorder.ts:42-85`, which computes
`startedAt = new Date()` (line 48) and then `const filename = clipFilename(camera.name,
startedAt);` (line 49).

3. `clipFilename()` in `src/files.ts:12-15` builds the name by calling
`when.toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/:/g, '-')` (line 14), explicitly
stripping milliseconds, so both processes started within the same second compute an
identical timestamp string and thus the same `${slug}_${ts}.mp4` filename (line 15).

4. Both ffmpeg processes in `recordClip()` write to the same `outPath` (joined in
`src/recorder.ts:50`), so the second completion silently overwrites or corrupts the first
recording file for that camera; this cannot occur for auto-triggered recordings (debounced
by `watchCamera()` in `src/events.ts:39-56`), but is reproducible with concurrent
CLI/verify processes.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/files.ts
**Line:** 14:15
**Comment:**
	*Logic Error: The filename timestamp strips milliseconds, so two recordings from the same camera started within the same second will generate the exact same path and one clip can overwrite the other. Keep sub-second precision (or add a unique suffix) to prevent silent data loss on near-simultaneous recordings.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment thread src/files.ts Outdated
Comment on lines +45 to +48
try {
entries = readdirSync(dir);
} catch {
return []; // dir not created yet — nothing to prune

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: This catch block treats every directory read failure as "nothing to prune", which silently hides real runtime failures like permission errors or I/O faults and disables retention without warning. Only ignore expected not-found cases and surface other errors. [possible bug]

Severity Level: Critical 🚨
- ❌ Retention sweep silently disabled on directory permission failures.
- ⚠️ Old clips accumulate despite retentionDays configured.
Steps of Reproduction ✅
1. Enable retention by setting a positive `retentionDays` in config (validated in
`src/config.ts:94-101`) so that `startRetention()` in `src/retention.ts:9-27` is used
instead of exiting early at lines 10-13.

2. Misconfigure `outputDir` (e.g., point it at a directory without read permission for the
service user) in `config.json`, which is loaded and merged in `src/config.ts:60-84`, then
passed into `startRetention(cfg)` at `src/index.ts:30`.

3. On service startup, `startRetention()` defines `sweep()` at `src/retention.ts:15-20`
and immediately calls it at line 22; `sweep()` calls `pruneOldClips(cfg.outputDir,
cfg.retentionDays, new Date())` in `src/files.ts:39`.

4. Inside `pruneOldClips()` (`src/files.ts:39-65`), `readdirSync(dir)` at line 46 throws
(e.g., `EACCES` or other I/O error), the broad `catch { return []; }` at lines 45-48
treats this as "dir not created yet — nothing to prune", causing the sweep to silently do
nothing; subsequent sweeps behave the same, so configured retention is effectively
disabled without any log indication from `startRetention()` beyond the initial "Retention:
deleting clips older than…" message.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/files.ts
**Line:** 45:48
**Comment:**
	*Possible Bug: This catch block treats every directory read failure as "nothing to prune", which silently hides real runtime failures like permission errors or I/O faults and disables retention without warning. Only ignore expected not-found cases and surface other errors.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment thread src/files.ts Outdated
Comment on lines +60 to +62
} catch {
// File vanished mid-sweep (e.g. a concurrent record finished/rotated). Skip.
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: The inner catch suppresses all stat/unlink failures even though the comment only describes racey file disappearance; permission or filesystem errors will be silently ignored and old files will remain indefinitely. Restrict this to expected transient errors and report the rest. [possible bug]

Severity Level: Critical 🚨
- ❌ Some expired clips never deleted under filesystem errors.
- ⚠️ Disk usage grows silently when deletions repeatedly fail.
Steps of Reproduction ✅
1. Configure retention with `retentionDays > 0` (as in `src/config.ts:31-36` and enforced
in `src/retention.ts:9-13`) so that `startRetention()` schedules periodic calls to
`pruneOldClips()` via the `sweep` function at `src/retention.ts:15-24`.

2. Create an output directory with a mix of `.mp4` clip files older than the cutoff and at
least one file that the process cannot fully stat or delete (e.g., change its permissions
or ownership after creation); `pruneOldClips()` will be invoked with this directory from
`startRetention()` at `src/retention.ts:16`.

3. During `pruneOldClips()` (`src/files.ts:51-63`), for each `.mp4` entry it runs
`statSync(full)` (line 55) and, if old enough, `unlinkSync(full)` (line 57); any error
from either call (permission denied, transient filesystem error, I/O fault) is caught by
the blanket `catch { ... }` at lines 60-62, which is documented only for "File vanished
mid-sweep".

4. Because the catch block is empty, failures to delete old clips are silently ignored and
the offending files remain on disk indefinitely while `startRetention()` still logs only
successful deletions (via the `deleted` array) at `src/retention.ts:17-19`, masking
retention failures and allowing disk usage to grow unexpectedly.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/files.ts
**Line:** 60:62
**Comment:**
	*Possible Bug: The inner catch suppresses all stat/unlink failures even though the comment only describes racey file disappearance; permission or filesystem errors will be silently ignored and old files will remain indefinitely. Restrict this to expected transient errors and report the rest.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

- Atomic token write + create parent dir on rotation (no corrupt token file)
- Clear error when the token file is unreadable or invalid JSON
- Validate numeric config is finite; require retentionSweepMinutes > 0
- Ignore non-numeric RING_RETENTION_DAYS instead of silently disabling retention
- Preserve the original auth error when the failure is not a 2FA prompt
- Logger tolerates circular-reference objects (no crash from catch blocks)
- verify: record once and unsubscribe on the first trigger
- Filenames keep milliseconds to avoid same-second overwrite
- Retention sweep surfaces real I/O errors instead of swallowing them
@fayerman-source

Copy link
Copy Markdown
Owner Author

Thanks to the review bots. Addressed the actionable findings in f4c1bca:

  • Token persistence is now atomic (temp file + rename) and creates the parent dir, so rotation can't leave a corrupt token file. (gemini, codeant)
  • readToken returns a clear error on an unreadable / invalid-JSON token file. (gemini)
  • Config validation rejects non-finite numbers and requires retentionSweepMinutes > 0. (gemini, codeant)
  • RING_RETENTION_DAYS with a non-numeric value is now ignored instead of silently disabling retention. (codeant)
  • Auth failures that aren't a 2FA prompt preserve the original error. (gemini)
  • Logger tolerates circular-reference objects so it can't crash from a catch block. (gemini)
  • Verify harness records once and unsubscribes on the first trigger. (gemini)
  • Filenames keep milliseconds to avoid a same-second overwrite. (codeant)
  • Retention sweep surfaces real I/O errors instead of swallowing them. (codeant)

Hermetic tests pass locally (15/15); CI is re-running on the new commit.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (2)
src/index.ts (1)

33-42: ⚡ Quick win

Shutdown handler can be invoked twice if signals arrive in quick succession.

If both SIGINT and SIGTERM are received within the 1500ms delay window, shutdown() runs twice—calling api.disconnect() multiple times, which may throw or behave unexpectedly depending on internal state.

🛡️ Proposed guard to prevent reentrancy
+  let shuttingDown = false;
   const shutdown = (signal: string) => {
+    if (shuttingDown) return;
+    shuttingDown = true;
     log.info(`Received ${signal}, shutting down.`);
     for (const s of subs) s.unsubscribe();
     stopRetention();
     api.disconnect();
     // Give in-flight ffmpeg writes a moment to flush before exit.
     setTimeout(() => process.exit(0), 1500);
   };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/index.ts` around lines 33 - 42, The shutdown function can execute
multiple times if SIGINT and SIGTERM signals arrive in quick succession before
the process exits. Add a guard flag at the module level that tracks whether
shutdown has already been initiated. In the shutdown function, check this flag
at the beginning and return early if shutdown has already started. Set the flag
to true at the start of the shutdown function to prevent subsequent calls from
re-executing the cleanup logic (unsubscribe, stopRetention, api.disconnect,
etc.).
src/verify.ts (1)

98-126: 💤 Low value

Potential for overlapping recordings if events fire in quick succession.

onTrigger starts an async recordClip without immediately setting done = true. If motion and doorbell events fire within milliseconds, two recordings may start before the first completes and sets done. This is minor for a verification harness but could be guarded by setting done = true at the start of onTrigger.

♻️ Optional guard to prevent duplicate recordings
     const onTrigger = (reason: string) => {
+      if (done) return;
+      done = true; // Prevent a second event from starting another recording
       log.info(`    → ${reason} detected; recording a clip to prove the trigger path…`);
       recordClip(camera, cfg, Math.min(cfg.clipLengthSeconds, 10))
         .then(() => finish(true))
         .catch((e) => {
           log.error(`    recording after trigger failed: ${(e as Error).message}`);
           finish(false);
         });
     };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/verify.ts` around lines 98 - 126, In the `waitForTriggeredRecording`
function, the `onTrigger` callback can be invoked multiple times (from both
motion and doorbell events) before the first async `recordClip` call completes,
causing duplicate recordings to start. Fix this by setting `done = true` at the
very beginning of the `onTrigger` function, before calling `recordClip`, so that
subsequent trigger events are blocked by the guard check in the `finish`
function.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/ci.yml:
- Line 12: The GitHub Actions workflow is using version tag references (such as
`@v4`) for the actions/checkout action, which creates a supply-chain security
vulnerability by allowing GitHub to auto-update to any version. Replace the `@v4`
tag reference with the full commit SHA for actions/checkout. Visit the release
page for the version being used
(https://github.com/actions/checkout/releases/tag/v4) and copy the commit hash
from the "Use this version" section, then update the uses clause to use the
format actions/checkout@<commit-sha>. Apply this same pinning pattern to any
other GitHub Actions referenced in the workflow file that use version tags.
- Line 12: The actions/checkout@v4 action is not explicitly disabling credential
persistence, which poses a security risk if the build environment is
compromised. Add the `persist-credentials: false` parameter to the
actions/checkout@v4 step in the workflow to explicitly prevent GitHub
credentials from being persisted in the runner environment.

In `@src/events.ts`:
- Around line 40-64: The motion detection subscription is triggering on every
true emission from onMotionDetected instead of only on rising-edge transitions
(false to true). To fix this, add state tracking for the previous motion state
(either as a new property in the CameraState object or as a local variable in
the setupCamera function). Then modify the onMotionDetected subscription handler
to check if the current active state is true AND the previous motion state was
false before calling trigger('motion'), and always update the tracked motion
state after each emission so future comparisons are correct.

In `@src/verify.ts`:
- Around line 23-31: The main function uses Number() to convert CLI arguments
for seconds and watch-motion, but this can result in NaN if the user provides
non-numeric values like --seconds foo. Add validation after the Number()
conversions to check that the parsed values are valid numbers (not NaN), and
either throw an error with a helpful message or use a default fallback value.
Apply this validation to both the seconds assignment and the watchMotion
assignment to ensure these critical timeout parameters are always valid numbers.

---

Nitpick comments:
In `@src/index.ts`:
- Around line 33-42: The shutdown function can execute multiple times if SIGINT
and SIGTERM signals arrive in quick succession before the process exits. Add a
guard flag at the module level that tracks whether shutdown has already been
initiated. In the shutdown function, check this flag at the beginning and return
early if shutdown has already started. Set the flag to true at the start of the
shutdown function to prevent subsequent calls from re-executing the cleanup
logic (unsubscribe, stopRetention, api.disconnect, etc.).

In `@src/verify.ts`:
- Around line 98-126: In the `waitForTriggeredRecording` function, the
`onTrigger` callback can be invoked multiple times (from both motion and
doorbell events) before the first async `recordClip` call completes, causing
duplicate recordings to start. Fix this by setting `done = true` at the very
beginning of the `onTrigger` function, before calling `recordClip`, so that
subsequent trigger events are blocked by the guard check in the `finish`
function.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e0d3e700-948f-4f69-8eed-4aa4b8d59d20

📥 Commits

Reviewing files that changed from the base of the PR and between 67a0c6c and 1c4b7c4.

⛔ Files ignored due to path filters (2)
  • assets/hero.jpg is excluded by !**/*.jpg
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (23)
  • .github/workflows/ci.yml
  • .gitignore
  • LICENSE
  • NOTICE
  • README.md
  • SECURITY.md
  • config.example.json
  • deploy/ecosystem.config.cjs
  • deploy/ring-camera-recorder.service
  • package.json
  • src/auth.ts
  • src/cli.ts
  • src/config.ts
  • src/events.ts
  • src/files.ts
  • src/index.ts
  • src/log.ts
  • src/recorder.ts
  • src/retention.ts
  • src/ring.ts
  • src/verify.ts
  • test/logic.test.mjs
  • tsconfig.json

Comment thread .github/workflows/ci.yml Outdated
Comment thread src/events.ts Outdated
Comment thread src/verify.ts
@fayerman-source

Copy link
Copy Markdown
Owner Author

@gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a self-hosted local recorder for Ring cameras, featuring automated event-triggered recording, configuration layering, and retention pruning. The code review highlights several important improvement opportunities: adding a connection timeout to camera.streamVideo to prevent indefinite hangs, wrapping the trigger execution in a resolved promise to safely catch synchronous errors, refactoring the synchronous file-pruning operations to asynchronous APIs to avoid blocking the event loop, and updating the TypeScript module resolution to nodenext to align with Node.js ESM runtime behavior.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/recorder.ts
Comment on lines +57 to +68
const hardTimeoutMs = (seconds + 30) * 1000;
const session = await camera.streamVideo({ output: clipOutputArgs(seconds, outPath) });
try {
await withTimeout(
firstValueFrom(session.onCallEnded),
hardTimeoutMs,
`live call for "${camera.name}" exceeded ${hardTimeoutMs / 1000}s`,
);
} catch (err) {
session.stop(); // ensure the WebRTC session + ffmpeg are torn down on timeout
throw err;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Issue: The call to camera.streamVideo is executed without a timeout. If the WebRTC connection or signaling hangs indefinitely (a common issue with flaky networks and unofficial APIs), the entire recording process will hang. This keeps the trigger lock (state.recording = true) active forever, permanently preventing any future recordings until the service is restarted.\n\nRecommendation: Wrap the camera.streamVideo call in withTimeout to ensure connection establishment is capped (e.g., at 30 seconds).

  const hardTimeoutMs = (seconds + 30) * 1000;\n  const connectionTimeoutMs = 30000;\n  const session = await withTimeout(\n    camera.streamVideo({ output: clipOutputArgs(seconds, outPath) }),\n    connectionTimeoutMs,\n    `Establishing live stream session for "${camera.name}" timed out after 30s`,\n  );\n\n  try {\n    await withTimeout(\n      firstValueFrom(session.onCallEnded),\n      hardTimeoutMs,\n      `live call for "${camera.name}" exceeded ${hardTimeoutMs / 1000}s`,\n    );\n  } catch (err) {\n    session.stop(); // ensure the WebRTC session + ffmpeg are torn down on timeout\n    throw err;\n  }

Comment thread src/events.ts Outdated
Comment on lines +52 to +56
recordFn(camera, cfg, cfg.clipLengthSeconds)
.catch((err) => log.error(`Recording failed for "${camera.name}": ${(err as Error).message}`))
.finally(() => {
state.recording = false;
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Issue: If recordFn throws an error synchronously (before returning a Promise), the synchronous exception will bypass the .catch and .finally blocks. This will leave state.recording as true forever, permanently blocking any future recordings for that camera.\n\nRecommendation: Wrap the call in Promise.resolve().then(...) to guarantee that any synchronous throw is safely caught and handled as a promise rejection.

    Promise.resolve()\n      .then(() => recordFn(camera, cfg, cfg.clipLengthSeconds))\n      .catch((err) => log.error(`Recording failed for "${camera.name}": ${(err as Error).message}`))\n      .finally(() => {\n        state.recording = false;\n      });

Comment thread src/files.ts
Comment on lines +1 to +2
import { mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs';
import { join } from 'node:path';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Recommendation: Import promises as fs from node:fs to support asynchronous file system operations in pruneOldClips.

import { mkdirSync, promises as fs } from 'node:fs';\nimport { join } from 'node:path';

Comment thread src/files.ts
Comment on lines +41 to +70
export function pruneOldClips(dir: string, retentionDays: number | null, now: Date): string[] {
if (!retentionDays || retentionDays <= 0) return [];
const cutoff = now.getTime() - retentionDays * 24 * 60 * 60 * 1000;
const deleted: string[] = [];

let entries: string[];
try {
entries = readdirSync(dir);
} catch (err) {
// Output dir not created yet is fine; surface real failures (permissions,
// I/O) instead of silently disabling retention.
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw err;
}

for (const entry of entries) {
if (!entry.endsWith('.mp4')) continue;
const full = join(dir, entry);
try {
const st = statSync(full);
if (st.isFile() && st.mtimeMs < cutoff) {
unlinkSync(full);
deleted.push(full);
}
} catch {
// File vanished mid-sweep (e.g. a concurrent record finished/rotated). Skip.
}
}
return deleted;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Issue: pruneOldClips uses synchronous file system methods (readdirSync, statSync, unlinkSync) inside a setInterval loop. Since this is a real-time service that must respond to push notifications and events immediately, blocking the event loop with synchronous I/O can cause the service to miss incoming events or drop connections.\n\nRecommendation: Refactor pruneOldClips to use asynchronous file system APIs (fs.promises).

export async function pruneOldClips(dir: string, retentionDays: number | null, now: Date): Promise<string[]> {\n  if (!retentionDays || retentionDays <= 0) return [];\n  const cutoff = now.getTime() - retentionDays * 24 * 60 * 60 * 1000;\n  const deleted: string[] = [];\n\n  let entries: string[];\n  try {\n    entries = await fs.readdir(dir);\n  } catch (err) {\n    // Output dir not created yet is fine; surface real failures (permissions,\n    // I/O) instead of silently disabling retention.\n    if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];\n    throw err;\n  }\n\n  for (const entry of entries) {\n    if (!entry.endsWith('.mp4')) continue;\n    const full = join(dir, entry);\n    try {\n      const st = await fs.stat(full);\n      if (st.isFile() && st.mtimeMs < cutoff) {\n        await fs.unlink(full);\n        deleted.push(full);\n      }\n    } catch {\n      // File vanished mid-sweep (e.g. a concurrent record finished/rotated). Skip.\n    }\n  }\n  return deleted;\n}

Comment thread src/retention.ts
Comment on lines +15 to +24
const sweep = () => {
try {
const deleted = pruneOldClips(cfg.outputDir, cfg.retentionDays, new Date());
if (deleted.length) {
log.info(`Retention: deleted ${deleted.length} clip(s) older than ${cfg.retentionDays}d.`);
}
} catch (err) {
log.warn(`Retention sweep failed: ${(err as Error).message}`);
}
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Recommendation: Update the sweep function to await the asynchronous pruneOldClips call.

  const sweep = async () => {\n    try {\n      const deleted = await pruneOldClips(cfg.outputDir, cfg.retentionDays, new Date());\n      if (deleted.length) {\n        log.info(`Retention: deleted ${deleted.length} clip(s) older than ${cfg.retentionDays}d.`);\n      }\n    } catch (err) {\n      log.warn(`Retention sweep failed: ${(err as Error).message}`);\n    }\n  };

Comment thread test/logic.test.mjs
writeFileSync(join(dir, 'notes.txt'), 'x');
const tenDaysAgo = Date.now() / 1000 - 10 * 86400;
utimesSync(old, tenDaysAgo, tenDaysAgo);
const deleted = pruneOldClips(dir, 7, new Date());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Recommendation: Update the test to await the asynchronous pruneOldClips call.

  const deleted = await pruneOldClips(dir, 7, new Date());

Comment thread tsconfig.json Outdated
Comment on lines +4 to +5
"module": "ES2022",
"moduleResolution": "bundler",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Issue: "moduleResolution": "bundler" is used for a project that runs directly in Node.js (via node dist/index.js). For Node.js ESM projects, "moduleResolution": "nodenext" (or "node16") is the correct and standard setting to ensure TypeScript's module resolution matches Node's runtime behavior.\n\nRecommendation: Change "module" and "moduleResolution" to "nodenext".

Suggested change
"module": "ES2022",
"moduleResolution": "bundler",
"module": "nodenext",\n "moduleResolution": "nodenext",

- events: gate motion on the false->true rising edge (matches documented intent;
  sustained 'true' emissions no longer queue extra clips). CodeRabbit.
- retention: per-file sweep surfaces non-ENOENT errors instead of swallowing all.
  CodeAnt (critical).
- ci: pin actions/checkout and actions/setup-node to commit SHAs. CodeRabbit.
- verify: validate numeric CLI args (--seconds / --watch-motion) to reject NaN.
  CodeRabbit.
- tests updated for rising-edge semantics (15/15 pass).
@fayerman-source

Copy link
Copy Markdown
Owner Author

Round 2 addressed in the latest commit:

  • Rising-edge motion gating in events.ts (CodeRabbit): motion now triggers only on false -> true, so sustained motion can't queue extra clips. Tests updated.
  • Retention per-file errors in files.ts (CodeAnt, critical): the inner sweep catch now only ignores ENOENT and logs real permission/I/O failures instead of swallowing them.
  • Pinned CI actions to commit SHAs (CodeRabbit) for supply-chain safety.
  • Validated verify CLI args (CodeRabbit) so --seconds/--watch-motion reject non-numeric input instead of producing NaN.

Note: the re-posted comments on config.ts (retentionSweepMinutes), verify.ts (concurrent trigger), and log.ts (circular refs) were already fixed in the previous commit; they appear to be re-scans of the full file rather than new findings.

- recorder: add a start timeout around camera.streamVideo() so a hang during
  WebRTC signaling can't block forever (the onCallEnded timeout only covered the
  call after the session existed). gemini (high).
- events: dispatch recordFn via Promise.resolve().then() so a synchronous throw
  becomes a rejection and never leaves state.recording stuck true. gemini.
@fayerman-source

Copy link
Copy Markdown
Owner Author

Round 3:

Fixed:

  • recorder.ts (gemini, high): added a start timeout around camera.streamVideo(). The existing timeout only guarded onCallEnded after the session existed, so a hang during WebRTC signaling could block indefinitely. Now both setup and the call are bounded.
  • events.ts (gemini): the recorder is now dispatched via Promise.resolve().then(...) so a synchronous throw becomes a rejection instead of bypassing .catch/.finally and leaving state.recording stuck at true.

Intentionally not changing:

  • files.ts async-fs suggestion (gemini): pruneOldClips uses synchronous fs inside the retention sweep, which runs hourly over a clips directory. The block is sub-millisecond-to-millisecond for that workload, and converting to async would ripple through the sweep caller and the hermetic test for no meaningful real-world benefit. The latency-sensitive paths (event handling, streaming) are already async. Keeping this sync on purpose.

Stale re-posts (already fixed in earlier commits, re-surfaced by full-file re-scan): config.ts retentionSweepMinutes validation, verify.ts concurrent-trigger unsubscribe, log.ts circular-ref serialization, retention.ts sweep validation, verify.ts CLI NaN validation.

tsconfig used moduleResolution 'bundler' (intended for bundlers). This project
is Node ESM run via `node dist/`, so 'nodenext' matches the runtime. Relative
imports already use explicit .js extensions, so the switch builds cleanly. gemini.
@fayerman-source

Copy link
Copy Markdown
Owner Author

Applied the one new actionable item from this pass:

  • tsconfig.json (gemini): switched module/moduleResolution from ES2022/bundler to nodenext, which matches how this runs (Node ESM via node dist/). Imports already use explicit .js extensions, so it builds cleanly and tests stay green (15/15).

Everything else on the latest commit is either already fixed in an earlier commit (re-surfaced by the bots' full-file re-scans) or intentionally declined:

  • Already fixed: recorder.ts streamVideo timeout (HIGH), config.ts/retention.ts sweep-interval validation, verify.ts concurrent-trigger + CLI NaN, log.ts circular-ref serialization.
  • Intentionally not changing: pruneOldClips async-fs (and its await follow-ons in retention.ts/tests). It is an hourly maintenance sweep over a clips directory; sync I/O there is negligible and async would ripple through callers and the hermetic test for no real-world benefit. The latency-sensitive paths are already async.

This should be the steady state: remaining bot comments are re-scans of resolved items, not new findings.

@fayerman-source
fayerman-source merged commit f908286 into main Jun 17, 2026
2 checks passed
@fayerman-source
fayerman-source deleted the initial-codebase branch June 17, 2026 16:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL This PR changes 1000+ lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant