diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..4869b55
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,26 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ build-test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: '22'
+ cache: npm
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Type-check / build
+ run: npm run build
+
+ - name: Run hermetic tests
+ run: npm test
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7f1a4bb
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,15 @@
+node_modules/
+dist/
+recordings/
+*.log
+
+# Raw image-generator output (the finished hero lives in assets/).
+nanobanana-output/
+
+# Secrets — the refresh token grants full account access. Never commit it.
+.ring-token.json
+.env
+config.local.json
+
+# Stray image-generator outputs in assets (keep only curated hero.jpg)
+assets/*.png
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..0156a19
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Eli Fayerman
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/NOTICE b/NOTICE
new file mode 100644
index 0000000..5238832
--- /dev/null
+++ b/NOTICE
@@ -0,0 +1,36 @@
+Ring Local Recorder
+Copyright (c) 2026 Eli Fayerman
+Licensed under the MIT License (see LICENSE).
+
+================================================================================
+NOT AFFILIATED WITH RING
+================================================================================
+
+This project is an independent, community-built tool. It is NOT affiliated with,
+authorized, maintained, sponsored, or endorsed by Ring LLC, Amazon.com, Inc., or
+any of their subsidiaries or affiliates. "Ring" and related marks are trademarks
+of their respective owners and are used here only for identification purposes.
+
+================================================================================
+THIRD-PARTY COMPONENTS
+================================================================================
+
+This software depends on, but does not redistribute, the following packages.
+They are installed from the npm registry at install time under their own
+licenses:
+
+ - ring-client-api (MIT): https://github.com/dgreif/ring
+ Provides the Ring account/streaming integration. Uses Ring's unofficial
+ API; see the legal notes in README.md.
+ - rxjs (Apache-2.0)
+ - werift (MIT): WebRTC transport
+ - @homebridge/camera-utils (MIT)
+ - @eneris/push-receiver (MIT): push notification delivery
+
+ffmpeg:
+ Recording relies on ffmpeg. By default the binary is provided at install time
+ by the "ffmpeg-for-homebridge" package; alternatively you may point the tool at
+ a system ffmpeg via the RING_FFMPEG environment variable. ffmpeg is licensed
+ under the LGPL/GPL depending on how it was built. This project does NOT bundle
+ or redistribute any ffmpeg binary; it invokes whatever ffmpeg is present on
+ the host. See https://ffmpeg.org/legal.html.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..7abaec8
--- /dev/null
+++ b/README.md
@@ -0,0 +1,177 @@
+# Ring Camera Recorder
+
+
+
+
+
+Self-hosted tool that **locally records the live view from Ring cameras you own**. It captures
+the freely available live stream (not Ring's paid cloud storage), so it works **without a Ring
+Protect subscription**. It records your own cameras and your own footage. Read the
+[Legal & responsible use](#legal--responsible-use) section before using or sharing it.
+
+> **You are recording the live stream, not downloading cloud history.** Without a Ring
+> Protect subscription, Ring stores nothing in the cloud, so there is no event history to
+> retrieve. This tool grabs the *live view* feed (the same one the app shows you for free)
+> and writes it to disk, either on demand or automatically when a motion/doorbell event
+> fires. That is the only no-subscription path.
+
+## What it does
+
+- Authenticates to your Ring account once (email + password + 2FA) and stores a
+ **refresh token**. Your password is never saved or hardcoded.
+- Lists your cameras.
+- Records the live stream to `recordings/{camera}_{ISO8601}.mp4`.
+- Auto-records a clip when a camera reports **motion** or a doorbell **ding**.
+- Manual "record now for N seconds" and continuous modes.
+- Optional retention: delete clips older than N days.
+
+## Requirements
+
+- Node.js >= 20 (tested on 22).
+- `ffmpeg` available on `PATH` (`ffmpeg -version` should work).
+- A Ring account with at least one camera.
+
+## Setup
+
+```bash
+npm install
+
+# One-time interactive login. Prompts for email, password, and the 2FA code Ring
+# texts/emails you, then writes the refresh token to .ring-token.json.
+npm run auth
+
+# Optional: copy and edit the config.
+cp config.example.json config.json
+
+# List your cameras (confirms auth works and shows names/ids for the `cameras` filter).
+npm run list
+```
+
+## Usage
+
+```bash
+# Long-running service: watches for motion/ding and auto-records per config.json.
+npm run build && npm start
+# (during development you can skip the build step with: npm run dev)
+
+# Record a single clip right now (default camera selection, default length):
+npm run record -- --camera "Front Door" --seconds 30
+
+# Full end-to-end verification (run after `npm run auth`): authenticates, lists
+# cameras, captures a clip, probes it for video/audio, and (optionally) waits for
+# a real motion/ding to confirm auto-recording. Trigger motion when prompted.
+npm run verify -- --watch-motion 60
+```
+
+## Tests
+
+```bash
+npm test # builds, then runs hermetic logic tests (no Ring account needed)
+```
+
+Covers the filename convention, retention pruning, and the motion/ding trigger state
+machine (rising-edge detection, no-overlap-while-recording, cooldown suppression,
+doorbell-only-on-doorbots). The live network path is exercised separately by
+`npm run verify` once you have a token.
+
+## Configuration
+
+Config is layered: built-in defaults < `config.json` < `config.local.json` < environment
+variables. See `config.example.json`. Key fields:
+
+| Field | Default | Meaning |
+|-------|---------|---------|
+| `tokenPath` | `.ring-token.json` | Where the refresh token lives. |
+| `outputDir` | `recordings` | Where clips are written. |
+| `clipLengthSeconds` | `30` | Default clip length for events + manual record. |
+| `cameras` | `"all"` | `"all"` or an array of name substrings / numeric ids. |
+| `recordOnMotion` | `true` | Auto-record on motion events. |
+| `recordOnDing` | `true` | Auto-record on doorbell press. |
+| `motionCooldownSeconds` | `20` | Min gap between auto-recordings per camera. |
+| `retentionDays` | `null` | Delete clips older than N days. `null` = keep all. |
+| `retentionSweepMinutes` | `60` | How often retention runs while the service is up. |
+
+Environment overrides: `RING_TOKEN_PATH`, `RING_OUTPUT_DIR`, `RING_CLIP_SECONDS`,
+`RING_RETENTION_DAYS`, `RING_DEBUG=1`.
+
+## Triggering policy
+
+When a motion/ding event fires, `src/events.ts` decides whether to start a clip. The
+default `shouldTrigger()` policy:
+
+- **Skips** if a recording for that camera is already running (no overlapping ffmpeg jobs).
+- Enforces `motionCooldownSeconds` between the *start* of consecutive clips.
+
+If you want "extend while motion persists" instead of fixed-length clips, or a different
+cooldown, edit `shouldTrigger()`; it's deliberately isolated for that. One
+no-subscription caveat: a clip can only begin *after* the event arrives, so there's no
+pre-roll buffer (you lose the ~1-2s before the trigger).
+
+## Running as a background service
+
+- **systemd**: see `deploy/ring-camera-recorder.service` (header has install steps).
+- **pm2**: `pm2 start deploy/ecosystem.config.cjs`.
+
+Run `npm run auth` manually first: the login is interactive and the service only reads
+the saved token.
+
+## Limitations (no subscription)
+
+- **Live-only.** No access to past clips / cloud event history; those require Ring Protect.
+- Motion/ding **push notifications are still delivered free**, which is what triggers
+ auto-recording.
+- Battery-powered cameras spin up the radio for each live stream, so frequent
+ motion-triggered recording will drain the battery faster than passive monitoring.
+- Ring may throttle accounts that open live streams very aggressively; keep clip lengths
+ and cooldowns reasonable for 24/7 use, or use a locally-streaming camera instead.
+
+## Security
+
+The refresh token in `.ring-token.json` grants full access to your Ring account. It is
+gitignored, written with `0600` permissions, and rotated tokens are persisted
+automatically. Treat that file like a password.
+
+`npm audit` reports a high-severity advisory in a transitive WebRTC dependency (`ip` via
+`werift`). It is **not** in this project's code, is low-risk for personal use, and
+**cannot be fixed without breaking streaming**. Do not run `npm audit fix --force`. See
+[`SECURITY.md`](./SECURITY.md) for the full assessment and how to report issues.
+
+## Contributing
+
+Issues and pull requests are welcome. This is a small, best-effort project maintained in
+spare time. Keep PRs focused, and note that the Ring integration depends on an
+unofficial upstream API that can break without notice (see [`SECURITY.md`](./SECURITY.md)).
+For anything security-related, follow `SECURITY.md` rather than opening a public issue.
+
+By contributing, you agree your contributions are licensed under the MIT License (see
+[`LICENSE`](./LICENSE)).
+
+## Legal & responsible use
+
+**This is not legal advice.** Use this tool at your own risk. By using it you accept
+responsibility for complying with the laws and agreements that apply to you.
+
+- **Your own cameras only.** Use this exclusively with cameras on a Ring account you
+ control, authenticating with your own credentials. It is a tool for recording *your own*
+ footage, not for accessing anyone else's account, cameras, or data.
+
+- **Audio recording / consent.** Clips include **audio**. Many jurisdictions have
+ two-party / all-party consent ("wiretapping") laws. In some U.S. states and other
+ countries, recording other people's audio without consent can carry **civil and even
+ criminal** liability, independent of Ring. Recording your own property is generally fine;
+ capturing other people's conversations may not be. Know your local laws before recording,
+ and consider disabling audio where required.
+
+- **Ring Terms of Service.** This relies on `ring-client-api`, which uses Ring's
+ **unofficial** API. Automated/unofficial access may violate Ring's / Amazon's Terms of
+ Service, and **Ring could suspend accounts** that use it. There is no guarantee of
+ continued access; Ring can change or block the API at any time (e.g. the 2026
+ `/oauth/v2` auth migration; see the upstream `dgreif/ring` issues).
+
+- **Not affiliated with Ring.** This project is independent and is **not** affiliated with,
+ authorized, endorsed, or sponsored by Ring LLC or Amazon. "Ring" is a trademark of its
+ owner, used here only to describe interoperability. See [`NOTICE`](./NOTICE).
+
+- **No warranty.** Provided "as is" under the [MIT License](./LICENSE), without warranty of
+ any kind. The authors are not liable for account suspensions, lost recordings, missed
+ events, or any other damages arising from use.
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..9526925
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,41 @@
+# Security
+
+## Reporting a vulnerability
+
+Please report security issues privately via GitHub's "Report a vulnerability"
+(Security Advisories) on this repository, or by opening a minimal issue that does
+**not** include any secrets, tokens, or personal footage. We'll respond as soon as
+we reasonably can. This is a volunteer, best-effort project (see the no-warranty
+note in [LICENSE](./LICENSE)).
+
+## Handling of credentials
+
+- This tool never stores your Ring password. The interactive login exchanges it for
+ a **refresh token**, written to `.ring-token.json` with `0600` permissions.
+- That token grants full access to your Ring account; treat it like a password.
+- `.ring-token.json`, `.env`, and `config.local.json` are git-ignored. **Never commit
+ them.** Run `git status` before your first commit to confirm.
+- Rotated tokens are persisted automatically; the token value is never written to logs.
+- `RING_DEBUG=1` enables verbose `ring-client-api` logging, which may include sensitive
+ request details. Use it only for local troubleshooting, never in shared logs.
+
+## Known dependency advisory (`npm audit`)
+
+`npm audit` reports high-severity findings that all trace to a single transitive
+dependency: the `ip` package's `isPublic` SSRF miscategorization
+([GHSA-2p57-rm9w-gvfp](https://github.com/advisories/GHSA-2p57-rm9w-gvfp)), pulled in
+via `werift` / `werift-ice` (the WebRTC NAT-traversal stack) beneath `ring-client-api`.
+
+- **Not in this project's code.** Nothing here calls `ip` directly; it lives in the
+ WebRTC ICE candidate layer.
+- **Do NOT run `npm audit fix --force`.** Its only "fix" downgrades `ring-client-api`
+ to `9.13.0` (pre-WebRTC), which breaks streaming entirely. This project intentionally
+ pins `ring-client-api@14.3.0`.
+- **Cannot be resolved downstream.** The current latest `ring-client-api` still depends
+ on the affected versions; it will clear once `werift` updates upstream.
+- **Low practical risk for this use case.** The `ip` SSRF is relevant when an attacker
+ controls connection targets and the app relies on `ip.isPublic()` for SSRF protection.
+ Here, the WebRTC session connects to *your own* Ring account over authenticated,
+ DTLS-encrypted media; there is no untrusted-peer attack surface in a personal recorder.
+
+We track this and will bump `ring-client-api` when an upstream release clears the advisory.
diff --git a/assets/hero.jpg b/assets/hero.jpg
new file mode 100644
index 0000000..11bdf31
Binary files /dev/null and b/assets/hero.jpg differ
diff --git a/config.example.json b/config.example.json
new file mode 100644
index 0000000..9c31783
--- /dev/null
+++ b/config.example.json
@@ -0,0 +1,11 @@
+{
+ "tokenPath": ".ring-token.json",
+ "outputDir": "recordings",
+ "clipLengthSeconds": 30,
+ "cameras": "all",
+ "recordOnMotion": true,
+ "recordOnDing": true,
+ "motionCooldownSeconds": 20,
+ "retentionDays": 14,
+ "retentionSweepMinutes": 60
+}
diff --git a/deploy/ecosystem.config.cjs b/deploy/ecosystem.config.cjs
new file mode 100644
index 0000000..a95cab1
--- /dev/null
+++ b/deploy/ecosystem.config.cjs
@@ -0,0 +1,23 @@
+// pm2 process definition. Usage:
+// npm install && npm run build
+// npm run auth # interactive, once
+// pm2 start deploy/ecosystem.config.cjs
+// pm2 logs ring-camera-recorder
+// pm2 save && pm2 startup # survive reboots
+module.exports = {
+ apps: [
+ {
+ name: 'ring-camera-recorder',
+ script: 'dist/index.js',
+ cwd: __dirname + '/..',
+ autorestart: true,
+ restart_delay: 10000,
+ max_restarts: 20,
+ env: {
+ // RING_OUTPUT_DIR: '/var/lib/ring-recordings',
+ // RING_RETENTION_DAYS: '14',
+ // RING_DEBUG: '1',
+ },
+ },
+ ],
+};
diff --git a/deploy/ring-camera-recorder.service b/deploy/ring-camera-recorder.service
new file mode 100644
index 0000000..67ff527
--- /dev/null
+++ b/deploy/ring-camera-recorder.service
@@ -0,0 +1,34 @@
+# systemd unit for Ring Camera Recorder.
+#
+# Install:
+# 1. Build first: npm install && npm run build
+# 2. Edit User/paths below to match your machine.
+# 3. sudo cp deploy/ring-camera-recorder.service /etc/systemd/system/
+# 4. sudo systemctl daemon-reload
+# 5. sudo systemctl enable --now ring-camera-recorder
+# 6. journalctl -u ring-camera-recorder -f # follow logs
+#
+# Note: run `npm run auth` MANUALLY once (it is interactive) before enabling the
+# service. The service only reads the already-saved refresh token.
+
+[Unit]
+Description=Ring Camera Recorder (local live recording)
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+# Replace with the unprivileged user and the absolute path where you cloned this repo.
+User=youruser
+WorkingDirectory=/home/youruser/ring-camera-recorder
+ExecStart=/usr/bin/node dist/index.js
+Restart=on-failure
+RestartSec=10
+# Token rotation writes back to the token file, so the working dir must be writable.
+# Environment overrides (optional) — these win over config.json:
+# Environment=RING_OUTPUT_DIR=/var/lib/ring-recordings
+# Environment=RING_RETENTION_DAYS=14
+# Environment=RING_DEBUG=1
+
+[Install]
+WantedBy=multi-user.target
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..4a6ce6f
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,2009 @@
+{
+ "name": "ring-camera-recorder",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "ring-camera-recorder",
+ "version": "0.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "ring-client-api": "14.3.0"
+ },
+ "bin": {
+ "ring-camera-recorder": "dist/cli.js"
+ },
+ "devDependencies": {
+ "@types/node": "^22.9.0",
+ "tsx": "^4.19.2",
+ "typescript": "^5.6.3"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@eneris/push-receiver": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@eneris/push-receiver/-/push-receiver-4.3.0.tgz",
+ "integrity": "sha512-COn7fZIh1P+3FYEpE87E2KoACW/gYTxwURykYqQ1lKMO1O/vdfrWP4ECsew/Kto9Mec34J46hvD5I/2nNa8kfA==",
+ "license": "MIT",
+ "dependencies": {
+ "http_ece": "^1.2.1",
+ "jsonwebtoken": "^9.0.2",
+ "long": "^5.2.3",
+ "protobufjs": "^7.4.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@fidm/asn1": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@fidm/asn1/-/asn1-1.0.4.tgz",
+ "integrity": "sha512-esd1jyNvRb2HVaQGq2Gg8Z0kbQPXzV9Tq5Z14KNIov6KfFD6PTaRIO8UpcsYiTNzOqJpmyzWgVTrUwFV3UF4TQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@fidm/x509": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@fidm/x509/-/x509-1.2.1.tgz",
+ "integrity": "sha512-nwc2iesjyc9hkuzcrMCBXQRn653XuAUKorfWM8PZyJawiy1QzLj4vahwzaI25+pfpwOLvMzbJ0uKpWLDNmo16w==",
+ "license": "MIT",
+ "dependencies": {
+ "@fidm/asn1": "^1.0.4",
+ "tweetnacl": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@homebridge/camera-utils": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@homebridge/camera-utils/-/camera-utils-3.0.0.tgz",
+ "integrity": "sha512-lw+DflCBEgbcfeBfXrPGhbzYn63bj61hpETVwqTO3FCThIMa3VoFDUNEy7jQq6OBS+iaMeHSuv4gy6w8yxFK7A==",
+ "license": "MIT",
+ "dependencies": {
+ "execa": "^9.5.1",
+ "ffmpeg-for-homebridge": "^2.1.7",
+ "pick-port": "^2.1.0",
+ "rxjs": "^7.8.1",
+ "systeminformation": "^5.23.5"
+ }
+ },
+ "node_modules/@isaacs/fs-minipass": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
+ "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^7.0.4"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@leichtgewicht/ip-codec": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz",
+ "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
+ "license": "MIT"
+ },
+ "node_modules/@minhducsun2002/leb128": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@minhducsun2002/leb128/-/leb128-1.0.0.tgz",
+ "integrity": "sha512-eFrYUPDVHeuwWHluTG1kwNQUEUcFjVKYwPkU8z9DR1JH3AW7JtJsG9cRVGmwz809kKtGfwGJj58juCZxEvnI/g==",
+ "license": "MIT"
+ },
+ "node_modules/@noble/curves": {
+ "version": "1.9.7",
+ "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
+ "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/hashes": "1.8.0"
+ },
+ "engines": {
+ "node": "^14.21.3 || >=16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@noble/hashes": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
+ "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
+ "license": "MIT",
+ "engines": {
+ "node": "^14.21.3 || >=16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@peculiar/asn1-cms": {
+ "version": "2.8.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz",
+ "integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.8.0",
+ "@peculiar/asn1-x509": "^2.8.0",
+ "@peculiar/asn1-x509-attr": "^2.8.0",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-csr": {
+ "version": "2.8.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz",
+ "integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.8.0",
+ "@peculiar/asn1-x509": "^2.8.0",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-ecc": {
+ "version": "2.8.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz",
+ "integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.8.0",
+ "@peculiar/asn1-x509": "^2.8.0",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-pfx": {
+ "version": "2.8.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz",
+ "integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-cms": "^2.8.0",
+ "@peculiar/asn1-pkcs8": "^2.8.0",
+ "@peculiar/asn1-rsa": "^2.8.0",
+ "@peculiar/asn1-schema": "^2.8.0",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-pkcs8": {
+ "version": "2.8.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz",
+ "integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.8.0",
+ "@peculiar/asn1-x509": "^2.8.0",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-pkcs9": {
+ "version": "2.8.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz",
+ "integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-cms": "^2.8.0",
+ "@peculiar/asn1-pfx": "^2.8.0",
+ "@peculiar/asn1-pkcs8": "^2.8.0",
+ "@peculiar/asn1-schema": "^2.8.0",
+ "@peculiar/asn1-x509": "^2.8.0",
+ "@peculiar/asn1-x509-attr": "^2.8.0",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-rsa": {
+ "version": "2.8.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz",
+ "integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.8.0",
+ "@peculiar/asn1-x509": "^2.8.0",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-schema": {
+ "version": "2.8.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz",
+ "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/utils": "^2.0.2",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-x509": {
+ "version": "2.8.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz",
+ "integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.8.0",
+ "@peculiar/utils": "^2.0.2",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-x509-attr": {
+ "version": "2.8.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz",
+ "integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.8.0",
+ "@peculiar/asn1-x509": "^2.8.0",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/utils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
+ "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/x509": {
+ "version": "1.14.3",
+ "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz",
+ "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-cms": "^2.6.0",
+ "@peculiar/asn1-csr": "^2.6.0",
+ "@peculiar/asn1-ecc": "^2.6.0",
+ "@peculiar/asn1-pkcs9": "^2.6.0",
+ "@peculiar/asn1-rsa": "^2.6.0",
+ "@peculiar/asn1-schema": "^2.6.0",
+ "@peculiar/asn1-x509": "^2.6.0",
+ "pvtsutils": "^1.3.6",
+ "reflect-metadata": "^0.2.2",
+ "tslib": "^2.8.1",
+ "tsyringe": "^4.10.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@protobufjs/aspromise": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
+ "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/base64": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
+ "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/codegen": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
+ "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/eventemitter": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
+ "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/fetch": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
+ "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.1"
+ }
+ },
+ "node_modules/@protobufjs/float": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
+ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/path": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
+ "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/pool": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
+ "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/utf8": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
+ "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@sec-ant/readable-stream": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz",
+ "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==",
+ "license": "MIT"
+ },
+ "node_modules/@shinyoshiaki/binary-data": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/@shinyoshiaki/binary-data/-/binary-data-0.6.1.tgz",
+ "integrity": "sha512-7HDb/fQAop2bCmvDIzU5+69i+UJaFgIVp99h1VzK1mpg1JwSODOkjbqD7ilTYnqlnadF8C4XjpwpepxDsGY6+w==",
+ "license": "MIT",
+ "dependencies": {
+ "generate-function": "^2.3.1",
+ "is-plain-object": "^2.0.3"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@shinyoshiaki/jspack": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/@shinyoshiaki/jspack/-/jspack-0.0.6.tgz",
+ "integrity": "sha512-SdsNhLjQh4onBlyPrn4ia1Pdx5bXT88G/LIEpOYAjx2u4xeY/m/HB5yHqlkJB1uQR3Zw4R3hBWLj46STRAN0rg=="
+ },
+ "node_modules/@sindresorhus/merge-streams": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
+ "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "22.19.21",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz",
+ "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/aes-js": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.1.2.tgz",
+ "integrity": "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==",
+ "license": "MIT"
+ },
+ "node_modules/asn1js": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
+ "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "pvtsutils": "^1.3.6",
+ "pvutils": "^1.1.5",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/bignumber.js": {
+ "version": "9.3.1",
+ "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
+ "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/buffer": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
+ "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.2.1"
+ }
+ },
+ "node_modules/buffer-crc32": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz",
+ "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/chownr": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
+ "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/colors": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz",
+ "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.1.90"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/date-fns": {
+ "version": "2.30.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
+ "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.21.0"
+ },
+ "engines": {
+ "node": ">=0.11"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/date-fns"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/dns-packet": {
+ "version": "5.6.1",
+ "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz",
+ "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==",
+ "license": "MIT",
+ "dependencies": {
+ "@leichtgewicht/ip-codec": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.1",
+ "@esbuild/android-arm": "0.28.1",
+ "@esbuild/android-arm64": "0.28.1",
+ "@esbuild/android-x64": "0.28.1",
+ "@esbuild/darwin-arm64": "0.28.1",
+ "@esbuild/darwin-x64": "0.28.1",
+ "@esbuild/freebsd-arm64": "0.28.1",
+ "@esbuild/freebsd-x64": "0.28.1",
+ "@esbuild/linux-arm": "0.28.1",
+ "@esbuild/linux-arm64": "0.28.1",
+ "@esbuild/linux-ia32": "0.28.1",
+ "@esbuild/linux-loong64": "0.28.1",
+ "@esbuild/linux-mips64el": "0.28.1",
+ "@esbuild/linux-ppc64": "0.28.1",
+ "@esbuild/linux-riscv64": "0.28.1",
+ "@esbuild/linux-s390x": "0.28.1",
+ "@esbuild/linux-x64": "0.28.1",
+ "@esbuild/netbsd-arm64": "0.28.1",
+ "@esbuild/netbsd-x64": "0.28.1",
+ "@esbuild/openbsd-arm64": "0.28.1",
+ "@esbuild/openbsd-x64": "0.28.1",
+ "@esbuild/openharmony-arm64": "0.28.1",
+ "@esbuild/sunos-x64": "0.28.1",
+ "@esbuild/win32-arm64": "0.28.1",
+ "@esbuild/win32-ia32": "0.28.1",
+ "@esbuild/win32-x64": "0.28.1"
+ }
+ },
+ "node_modules/execa": {
+ "version": "9.6.1",
+ "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
+ "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==",
+ "license": "MIT",
+ "dependencies": {
+ "@sindresorhus/merge-streams": "^4.0.0",
+ "cross-spawn": "^7.0.6",
+ "figures": "^6.1.0",
+ "get-stream": "^9.0.0",
+ "human-signals": "^8.0.1",
+ "is-plain-obj": "^4.1.0",
+ "is-stream": "^4.0.1",
+ "npm-run-path": "^6.0.0",
+ "pretty-ms": "^9.2.0",
+ "signal-exit": "^4.1.0",
+ "strip-final-newline": "^4.0.0",
+ "yoctocolors": "^2.1.1"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.5.0"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/execa?sponsor=1"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
+ "node_modules/ffmpeg-for-homebridge": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/ffmpeg-for-homebridge/-/ffmpeg-for-homebridge-2.2.2.tgz",
+ "integrity": "sha512-A1bmqv9BTrpGZPCQ0XpWk4gmzheBGEv3eFQDKBKA74hwG/tJdt6JqcF+jlmlp5s/OqRdDrEsyveL2T+GvNf/fA==",
+ "hasInstallScript": true,
+ "dependencies": {
+ "tar": "^7.5.12"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "node_modules/figures": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz",
+ "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==",
+ "license": "MIT",
+ "dependencies": {
+ "is-unicode-supported": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/generate-function": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
+ "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "is-property": "^1.0.2"
+ }
+ },
+ "node_modules/get-stream": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz",
+ "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==",
+ "license": "MIT",
+ "dependencies": {
+ "@sec-ant/readable-stream": "^0.4.1",
+ "is-stream": "^4.0.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/http_ece": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.1.tgz",
+ "integrity": "sha512-+tzLoMYgXvicu60sVFoswTiu6BiQ6EX3DORRJQ3W2dNpNWCyZ3tcmRFZZ3jgVyw8ziWUCeUARKCkYDY6JgFx+w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/human-signals": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz",
+ "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/int64-buffer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/int64-buffer/-/int64-buffer-1.1.0.tgz",
+ "integrity": "sha512-94smTCQOvigN4d/2R/YDjz8YVG0Sufvv2aAh8P5m42gwhCsDAJqnbNOrxJsrADuAFAA69Q/ptGzxvNcNuIJcvw==",
+ "license": "MIT"
+ },
+ "node_modules/ip": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz",
+ "integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==",
+ "license": "MIT"
+ },
+ "node_modules/is-plain-obj": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
+ "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-plain-object": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
+ "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==",
+ "license": "MIT",
+ "dependencies": {
+ "isobject": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-property": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
+ "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
+ "license": "MIT"
+ },
+ "node_modules/is-stream": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz",
+ "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-unicode-supported": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz",
+ "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "license": "ISC"
+ },
+ "node_modules/isobject": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
+ "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/json-bigint": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
+ "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "bignumber.js": "^9.0.0"
+ }
+ },
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
+ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^4.0.1",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/jwa": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
+ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
+ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/lodash": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "license": "MIT"
+ },
+ "node_modules/long": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
+ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/minipass": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
+ "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/minizlib": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
+ "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
+ "license": "MIT",
+ "dependencies": {
+ "minipass": "^7.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/mp4box": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/mp4box/-/mp4box-0.5.4.tgz",
+ "integrity": "sha512-GcCH0fySxBurJtvr0dfhz0IxHZjc1RP+F+I8xw+LIwkU1a+7HJx8NCDiww1I5u4Hz6g4eR1JlGADEGJ9r4lSfA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/multicast-dns": {
+ "version": "7.2.5",
+ "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz",
+ "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==",
+ "license": "MIT",
+ "dependencies": {
+ "dns-packet": "^5.2.2",
+ "thunky": "^1.0.2"
+ },
+ "bin": {
+ "multicast-dns": "cli.js"
+ }
+ },
+ "node_modules/npm-run-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz",
+ "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==",
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^4.0.0",
+ "unicorn-magic": "^0.3.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/npm-run-path/node_modules/path-key": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz",
+ "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-cancelable": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
+ "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/parse-ms": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz",
+ "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pick-port": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/pick-port/-/pick-port-2.2.1.tgz",
+ "integrity": "sha512-uc29LvLYMTwN8chzRVwv8D9rkIa3hj7rvjX57JeBJ3VJpWK/3j0BxuZ5VwklAEgulpiDtPfxbwnaH952MyDBCQ==",
+ "license": "ISC",
+ "dependencies": {
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mediasoup"
+ }
+ },
+ "node_modules/pretty-ms": {
+ "version": "9.3.0",
+ "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz",
+ "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==",
+ "license": "MIT",
+ "dependencies": {
+ "parse-ms": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/protobufjs": {
+ "version": "7.6.4",
+ "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz",
+ "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==",
+ "hasInstallScript": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.2",
+ "@protobufjs/base64": "^1.1.2",
+ "@protobufjs/codegen": "^2.0.5",
+ "@protobufjs/eventemitter": "^1.1.1",
+ "@protobufjs/fetch": "^1.1.1",
+ "@protobufjs/float": "^1.0.2",
+ "@protobufjs/path": "^1.1.2",
+ "@protobufjs/pool": "^1.1.0",
+ "@protobufjs/utf8": "^1.1.1",
+ "@types/node": ">=13.7.0",
+ "long": "^5.3.2"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/pvtsutils": {
+ "version": "1.3.6",
+ "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
+ "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/pvutils": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
+ "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/reflect-metadata": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
+ "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/ring-client-api": {
+ "version": "14.3.0",
+ "resolved": "https://registry.npmjs.org/ring-client-api/-/ring-client-api-14.3.0.tgz",
+ "integrity": "sha512-JUGDMvAv3Y5DbaYbYYvr9JIeHonrtCmS46C/vRahg0OshnsOyurH+wG7+3os4caXhnQnTlikL0+lc4xNNKE+Mg==",
+ "funding": [
+ {
+ "type": "paypal",
+ "url": "https://www.paypal.me/dustingreif"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/dgreif"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@eneris/push-receiver": "4.3.0",
+ "@homebridge/camera-utils": "^3.0.0",
+ "colors": "1.4.0",
+ "debug": "^4.4.3",
+ "json-bigint": "^1.0.0",
+ "rxjs": "^7.8.2",
+ "systeminformation": "^5.30.7",
+ "undici": "^7.19.2",
+ "uuid": "^13.0.0",
+ "werift": "0.22.4"
+ },
+ "bin": {
+ "ring-auth-cli": "lib/ring-auth-cli.js",
+ "ring-device-data-cli": "lib/ring-device-data-cli.js"
+ },
+ "engines": {
+ "node": "^20 || ^22 || ^24"
+ }
+ },
+ "node_modules/rx.mini": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/rx.mini/-/rx.mini-1.4.0.tgz",
+ "integrity": "sha512-8w5cSc1mwNja7fl465DXOkVvIOkpvh2GW4jo31nAIvX4WTXCsRnKJGUfiDBzWtYRInEcHAUYIZfzusjIrea8gA=="
+ },
+ "node_modules/rxjs": {
+ "version": "7.8.2",
+ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
+ "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.1.0"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.8.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
+ "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/strip-final-newline": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz",
+ "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/systeminformation": {
+ "version": "5.31.7",
+ "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.7.tgz",
+ "integrity": "sha512-/8NC53e5nP9nmhn42/ncdOkyJnOoue/Vy+tJOyUGd1Yv66G069wK4rrziwhrqDETgk78CudTQupw5z19S5uoZw==",
+ "license": "MIT",
+ "os": [
+ "darwin",
+ "linux",
+ "win32",
+ "freebsd",
+ "openbsd",
+ "netbsd",
+ "sunos",
+ "android"
+ ],
+ "bin": {
+ "systeminformation": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ },
+ "funding": {
+ "type": "Buy me a coffee",
+ "url": "https://www.buymeacoffee.com/systeminfo"
+ }
+ },
+ "node_modules/tar": {
+ "version": "7.5.16",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz",
+ "integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/fs-minipass": "^4.0.0",
+ "chownr": "^3.0.0",
+ "minipass": "^7.1.2",
+ "minizlib": "^3.1.0",
+ "yallist": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/thunky": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
+ "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==",
+ "license": "MIT"
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/tsx": {
+ "version": "4.22.4",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
+ "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "~0.28.0"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
+ "node_modules/tsyringe": {
+ "version": "4.10.0",
+ "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
+ "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^1.9.3"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/tsyringe/node_modules/tslib": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
+ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
+ "license": "0BSD"
+ },
+ "node_modules/turbo-crc32": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/turbo-crc32/-/turbo-crc32-1.0.1.tgz",
+ "integrity": "sha512-8yyRd1ZdNp+AQLGqi3lTaA2k81JjlIZOyFQEsi7GQWBgirnQOxjqVtDEbYHM2Z4yFdJ5AQw0fxBLLnDCl6RXoQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/tweetnacl": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
+ "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
+ "license": "Unlicense"
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
+ "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "license": "MIT"
+ },
+ "node_modules/unicorn-magic": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",
+ "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/uuid": {
+ "version": "13.0.2",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz",
+ "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist-node/bin/uuid"
+ }
+ },
+ "node_modules/werift": {
+ "version": "0.22.4",
+ "resolved": "https://registry.npmjs.org/werift/-/werift-0.22.4.tgz",
+ "integrity": "sha512-N/Jgh82cjhxTusQSJzAmgQhrqPcUrytlybrh45VoggusHEsw7Q8S8uhPD+5nvgDM8FYyjJ0BcMGg5vbvalWi0A==",
+ "license": "MIT",
+ "dependencies": {
+ "@fidm/x509": "^1.2.1",
+ "@minhducsun2002/leb128": "^1.0.0",
+ "@noble/curves": "^1.8.1",
+ "@peculiar/x509": "^1.12.3",
+ "@shinyoshiaki/binary-data": "^0.6.1",
+ "@shinyoshiaki/jspack": "^0.0.6",
+ "aes-js": "^3.1.2",
+ "buffer": "^6.0.3",
+ "buffer-crc32": "^1.0.0",
+ "debug": "4.4.0",
+ "fast-deep-equal": "^3.1.3",
+ "int64-buffer": "1.1.0",
+ "ip": "^2.0.1",
+ "mp4box": "^0.5.3",
+ "multicast-dns": "^7.2.5",
+ "turbo-crc32": "^1.0.1",
+ "tweetnacl": "^1.0.3",
+ "werift-common": "*",
+ "werift-dtls": "*",
+ "werift-ice": "*",
+ "werift-rtp": "*",
+ "werift-sctp": "*"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/werift-common": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/werift-common/-/werift-common-0.0.3.tgz",
+ "integrity": "sha512-ma3E4BqKTyZVLhrdfTVs2T1tg9seeUtKMRn5e64LwgrogWa62+3LAUoLBUSl1yPWhgSkXId7GmcHuWDen9IJeQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@shinyoshiaki/jspack": "^0.0.6",
+ "debug": "^4.4.0"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/werift-dtls": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/werift-dtls/-/werift-dtls-0.5.7.tgz",
+ "integrity": "sha512-z2fjbP7fFUFmu/Ky4bCKXzdgPTtmSY1DYi0TUf3GG2zJT4jMQ3TQmGY8y7BSSNGetvL4h3pRZ5un0EcSOWpPog==",
+ "license": "MIT",
+ "dependencies": {
+ "@fidm/x509": "^1.2.1",
+ "@noble/curves": "^1.3.0",
+ "@peculiar/x509": "^1.9.2",
+ "@shinyoshiaki/binary-data": "^0.6.1",
+ "date-fns": "^2.29.3",
+ "lodash": "^4.17.21",
+ "rx.mini": "^1.2.2",
+ "tweetnacl": "^1.0.3"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/werift-ice": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/werift-ice/-/werift-ice-0.2.2.tgz",
+ "integrity": "sha512-td52pHp+JmFnUn5jfDr/SSNO0dMCbknhuPdN1tFp9cfRj5jaktN63qnAdUuZC20QCC3ETWdsOthcm+RalHpFCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@shinyoshiaki/jspack": "^0.0.6",
+ "buffer-crc32": "^1.0.0",
+ "debug": "^4.3.4",
+ "int64-buffer": "^1.0.1",
+ "ip": "^2.0.1",
+ "lodash": "^4.17.21",
+ "multicast-dns": "^7.2.5",
+ "p-cancelable": "^2.1.1",
+ "rx.mini": "^1.2.2"
+ }
+ },
+ "node_modules/werift-rtp": {
+ "version": "0.8.8",
+ "resolved": "https://registry.npmjs.org/werift-rtp/-/werift-rtp-0.8.8.tgz",
+ "integrity": "sha512-GiYMSdvCyScQaw5bnEsraSoHUVZpjfokJAiLV4R1FsiB06t6XiebPYPpkqB9nYNNKiA8Z/cYWsym7wISq1sYSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@minhducsun2002/leb128": "^1.0.0",
+ "@shinyoshiaki/jspack": "^0.0.6",
+ "aes-js": "^3.1.2",
+ "buffer": "^6.0.3",
+ "mp4box": "^0.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/werift-sctp": {
+ "version": "0.0.11",
+ "resolved": "https://registry.npmjs.org/werift-sctp/-/werift-sctp-0.0.11.tgz",
+ "integrity": "sha512-7109yuI5U7NTEHjqjn0A8VeynytkgVaxM6lRr1Ziv0D8bPcaB8A7U/P88M7WaCpWDoELHoXiRUjQycMWStIgjQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@shinyoshiaki/jspack": "^0.0.6"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/werift/node_modules/debug": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
+ "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/yallist": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
+ "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/yoctocolors": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz",
+ "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..9d694e6
--- /dev/null
+++ b/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "ring-camera-recorder",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Self-hosted tool to locally record the live view from Ring cameras you own. Captures the free live stream (not paid cloud storage), so it works without a Ring Protect subscription.",
+ "license": "MIT",
+ "author": "Eli Fayerman",
+ "type": "module",
+ "engines": {
+ "node": ">=20"
+ },
+ "bin": {
+ "ring-camera-recorder": "./dist/cli.js"
+ },
+ "scripts": {
+ "build": "tsc -p tsconfig.json",
+ "start": "node dist/index.js",
+ "dev": "tsx src/index.ts",
+ "auth": "tsx src/auth.ts",
+ "record": "tsx src/cli.ts record",
+ "list": "tsx src/cli.ts list",
+ "verify": "tsx src/verify.ts",
+ "test": "npm run build && node test/logic.test.mjs",
+ "typecheck": "tsc -p tsconfig.json --noEmit"
+ },
+ "dependencies": {
+ "ring-client-api": "14.3.0"
+ },
+ "devDependencies": {
+ "tsx": "^4.19.2",
+ "typescript": "^5.6.3",
+ "@types/node": "^22.9.0"
+ }
+}
diff --git a/src/auth.ts b/src/auth.ts
new file mode 100644
index 0000000..ded2519
--- /dev/null
+++ b/src/auth.ts
@@ -0,0 +1,85 @@
+import { createInterface, type Interface } from 'node:readline';
+import { RingRestClient } from 'ring-client-api/rest-client';
+import { loadConfig } from './config.js';
+import { writeToken, tokenFileExists } from './ring.js';
+import { log } from './log.js';
+
+/**
+ * Interactive one-time login. Prompts for email + password + 2FA code, acquires a
+ * refresh token via Ring's auth API, and writes it to the configured token file.
+ *
+ * The token (not the password) is what the service uses thereafter, and Ring
+ * rotates it on every connect — see `onRefreshTokenUpdated` handling in ring.ts.
+ */
+async function main(): Promise {
+ const cfg = loadConfig();
+
+ if (tokenFileExists(cfg) && !process.argv.includes('--force')) {
+ log.warn(`A token already exists at ${cfg.tokenPath}. Re-run with --force to overwrite.`);
+ process.exit(0);
+ }
+
+ const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
+ try {
+ const email = (await question(rl, 'Ring email: ')).trim();
+ const password = await hiddenQuestion(rl, 'Ring password: ');
+
+ const client = new RingRestClient({ email, password });
+
+ let ok = false;
+ try {
+ await client.getAuth();
+ ok = true;
+ } catch (err) {
+ // Expected when 2FA is enabled: the first getAuth triggers the code being
+ // sent and sets promptFor2fa with a human-readable instruction. Anything
+ // else (network, DNS, rate limit, bad credentials) should surface its cause.
+ if (!client.promptFor2fa) {
+ throw new Error(`Login failed (check email/password): ${(err as Error).message}`);
+ }
+ log.info(client.promptFor2fa);
+ const code = (await question(rl, '2FA code: ')).trim();
+ await client.getAuth(code);
+ ok = true;
+ }
+
+ const token = client.refreshToken;
+ if (!ok || !token) throw new Error('Authentication succeeded but no refresh token was returned.');
+
+ writeToken(cfg, token);
+ log.info(`Refresh token saved to ${cfg.tokenPath} (mode 0600). You can now run \`npm start\`.`);
+ } finally {
+ rl.close();
+ }
+}
+
+function question(rl: Interface, query: string): Promise {
+ return new Promise((resolve) => rl.question(query, resolve));
+}
+
+/** Prompt without echoing the typed characters (masks with '*'). */
+function hiddenQuestion(rl: Interface, query: string): Promise {
+ return new Promise((resolve) => {
+ const iface = rl as Interface & { output: NodeJS.WritableStream; _writeToOutput?: (s: string) => void };
+ let muted = false;
+ const original = iface._writeToOutput?.bind(iface);
+ iface._writeToOutput = (s: string) => {
+ if (!muted) {
+ iface.output.write(s);
+ return;
+ }
+ // Echo a mask for typed chars but let control sequences (newline) pass.
+ iface.output.write(s.includes('\n') ? '\n' : '*');
+ };
+ rl.question(query, (answer) => {
+ iface._writeToOutput = original;
+ resolve(answer);
+ });
+ muted = true;
+ });
+}
+
+main().catch((err) => {
+ log.error((err as Error).message);
+ process.exit(1);
+});
diff --git a/src/cli.ts b/src/cli.ts
new file mode 100644
index 0000000..e952c1d
--- /dev/null
+++ b/src/cli.ts
@@ -0,0 +1,93 @@
+import { loadConfig } from './config.js';
+import { createRingApi } from './ring.js';
+import { recordClip } from './recorder.js';
+import { log } from './log.js';
+
+/**
+ * Small CLI:
+ * ring-recorder list
+ * ring-recorder record --camera "Front Door" --seconds 30
+ */
+async function main(): Promise {
+ const [, , command, ...rest] = process.argv;
+
+ if (command !== 'list' && command !== 'record') {
+ log.error('Usage: ring-recorder ');
+ process.exit(2);
+ }
+
+ const cfg = loadConfig();
+ const api = createRingApi(cfg); // reads token; throws a friendly error if missing
+
+ try {
+ if (command === 'list') {
+ const cameras = await api.getCameras();
+ if (cameras.length === 0) {
+ log.warn('No cameras found on this account.');
+ return;
+ }
+ log.info(`Found ${cameras.length} camera(s):`);
+ for (const c of cameras) {
+ const kind = c.isDoorbot ? 'doorbell' : 'camera';
+ const battery = c.batteryLevel != null ? `${c.batteryLevel}%` : 'wired';
+ process.stdout.write(` #${c.id} ${c.name} [${kind}, ${c.model}, battery: ${battery}]\n`);
+ }
+ return;
+ }
+
+ if (command === 'record') {
+ const args = parseArgs(rest);
+ const seconds = args.seconds ? Number(args.seconds) : cfg.clipLengthSeconds;
+ if (!Number.isFinite(seconds) || seconds <= 0) throw new Error('--seconds must be a positive number');
+
+ const cameras = await api.getCameras();
+ const match = pickCamera(cameras, args.camera);
+ if (!match) {
+ throw new Error(
+ `No camera matched ${args.camera ? `"${args.camera}"` : '(none specified)'}. ` +
+ `Available: ${cameras.map((c) => `${c.name} (#${c.id})`).join(', ')}`,
+ );
+ }
+ const result = await recordClip(match, cfg, seconds);
+ log.info(`Done: ${result.path}`);
+ return;
+ }
+ } finally {
+ api.disconnect();
+ // ffmpeg/WebRTC teardown can leave handles open briefly; exit cleanly.
+ setTimeout(() => process.exit(process.exitCode ?? 0), 1500);
+ }
+}
+
+function parseArgs(argv: string[]): Record {
+ const out: Record = {};
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i];
+ if (a.startsWith('--')) {
+ const key = a.slice(2);
+ const next = argv[i + 1];
+ if (next && !next.startsWith('--')) {
+ out[key] = next;
+ i++;
+ } else {
+ out[key] = 'true';
+ }
+ }
+ }
+ return out;
+}
+
+function pickCamera(cameras: Awaited['getCameras']>>, sel?: string) {
+ if (!sel) return cameras[0]; // default to the first camera if unspecified
+ const byId = Number(sel);
+ if (Number.isFinite(byId)) {
+ const hit = cameras.find((c) => c.id === byId);
+ if (hit) return hit;
+ }
+ return cameras.find((c) => c.name.toLowerCase().includes(sel.toLowerCase()));
+}
+
+main().catch((err) => {
+ log.error((err as Error).message);
+ process.exit(1);
+});
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000..eb04d18
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,118 @@
+import { readFileSync, existsSync } from 'node:fs';
+import { resolve, isAbsolute } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { dirname } from 'node:path';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const PROJECT_ROOT = resolve(__dirname, '..');
+
+export interface AppConfig {
+ /** Path to the JSON file holding the Ring refresh token. Created by `npm run auth`. */
+ tokenPath: string;
+ /** Directory where recorded clips are written. */
+ outputDir: string;
+ /** Default clip length in seconds for event-triggered and manual recordings. */
+ clipLengthSeconds: number;
+ /**
+ * Camera selection. Either the string "all", or an array of camera names
+ * (case-insensitive substring match) / numeric device ids to include.
+ */
+ cameras: 'all' | Array;
+ /** Start a recording automatically when a camera reports motion. */
+ recordOnMotion: boolean;
+ /** Start a recording automatically when a doorbell is pressed (ding). */
+ recordOnDing: boolean;
+ /**
+ * Minimum seconds between two automatic recordings for the SAME camera.
+ * Prevents a burst of motion events from spawning overlapping ffmpeg jobs.
+ */
+ motionCooldownSeconds: number;
+ /**
+ * Delete clips older than this many days. 0 or null disables retention
+ * cleanup (keep everything).
+ */
+ retentionDays: number | null;
+ /** How often (minutes) to run the retention sweep while the service runs. */
+ retentionSweepMinutes: number;
+}
+
+const DEFAULTS: AppConfig = {
+ tokenPath: '.ring-token.json',
+ outputDir: 'recordings',
+ clipLengthSeconds: 30,
+ cameras: 'all',
+ recordOnMotion: true,
+ recordOnDing: true,
+ motionCooldownSeconds: 20,
+ retentionDays: null,
+ retentionSweepMinutes: 60,
+};
+
+/** Resolve a possibly-relative path against the project root. */
+export function resolveFromRoot(p: string): string {
+ return isAbsolute(p) ? p : resolve(PROJECT_ROOT, p);
+}
+
+/**
+ * Load config by layering: built-in DEFAULTS < config.json < config.local.json
+ * < environment variable overrides. Paths are resolved to absolute.
+ */
+export function loadConfig(): AppConfig {
+ let merged: AppConfig = { ...DEFAULTS };
+
+ for (const name of ['config.json', 'config.local.json']) {
+ const file = resolve(PROJECT_ROOT, name);
+ if (existsSync(file)) {
+ try {
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
+ merged = { ...merged, ...parsed };
+ } catch (err) {
+ throw new Error(`Failed to parse ${name}: ${(err as Error).message}`);
+ }
+ }
+ }
+
+ // Environment overrides (handy for systemd unit files / containers).
+ 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) {
+ // Ignore non-numeric env input rather than silently disabling retention.
+ const parsed = Number(process.env.RING_RETENTION_DAYS);
+ if (Number.isFinite(parsed)) merged.retentionDays = parsed > 0 ? parsed : null;
+ }
+
+ // Resolve paths to absolute so the rest of the app never guesses cwd.
+ merged.tokenPath = resolveFromRoot(merged.tokenPath);
+ merged.outputDir = resolveFromRoot(merged.outputDir);
+
+ validate(merged);
+ return merged;
+}
+
+function num(v: string, fallback: number): number {
+ const n = Number(v);
+ return Number.isFinite(n) ? n : fallback;
+}
+
+function validate(c: AppConfig): void {
+ if (!Number.isFinite(c.clipLengthSeconds) || c.clipLengthSeconds <= 0)
+ throw new Error('clipLengthSeconds must be a number > 0');
+ if (!Number.isFinite(c.motionCooldownSeconds) || c.motionCooldownSeconds < 0)
+ throw new Error('motionCooldownSeconds must be a number >= 0');
+ if (!Number.isFinite(c.retentionSweepMinutes) || c.retentionSweepMinutes <= 0)
+ throw new Error('retentionSweepMinutes must be a number > 0');
+ if (c.retentionDays !== null && (!Number.isFinite(c.retentionDays) || c.retentionDays < 0))
+ throw new Error('retentionDays must be a number >= 0 or null');
+ if (c.cameras !== 'all' && !Array.isArray(c.cameras)) {
+ throw new Error('cameras must be "all" or an array of names/ids');
+ }
+}
+
+/** True if a camera (by name + id) is selected by the config filter. */
+export function cameraSelected(cfg: AppConfig, name: string, id: number): boolean {
+ if (cfg.cameras === 'all') return true;
+ return cfg.cameras.some((sel) =>
+ typeof sel === 'number' ? sel === id : name.toLowerCase().includes(String(sel).toLowerCase()),
+ );
+}
diff --git a/src/events.ts b/src/events.ts
new file mode 100644
index 0000000..4b42685
--- /dev/null
+++ b/src/events.ts
@@ -0,0 +1,91 @@
+import type { Subscription } from 'rxjs';
+import type { RingCamera } from 'ring-client-api';
+import type { AppConfig } from './config.js';
+import { recordClip, type RecordResult } from './recorder.js';
+import { log } from './log.js';
+
+/** Recorder function shape — injectable so the trigger logic is unit-testable. */
+export type RecordFn = (camera: RingCamera, cfg: AppConfig, seconds: number) => Promise;
+
+/** Per-camera runtime state used to debounce overlapping triggers. */
+interface CameraState {
+ recording: boolean;
+ lastStartMs: number;
+ motionActive: boolean;
+}
+
+/**
+ * Decide whether an incoming motion/ding event should start a new recording.
+ *
+ * This is the core triggering policy. The default below:
+ * - skips if a recording for this camera is already in progress, and
+ * - enforces `motionCooldownSeconds` between the START of consecutive clips.
+ *
+ * Trade-offs you may want to change (see README "Triggering policy"):
+ * - "extend" instead of "skip": keep recording while motion persists (better
+ * coverage of long events, but unbounded clip length + battery drain).
+ * - shorter/zero cooldown: more clips, more overlap, more account API load.
+ */
+function shouldTrigger(state: CameraState, cfg: AppConfig, nowMs: number): boolean {
+ if (state.recording) return false;
+ const sinceLastMs = nowMs - state.lastStartMs;
+ if (sinceLastMs < cfg.motionCooldownSeconds * 1000) return false;
+ return true;
+}
+
+/**
+ * Wire motion + ding subscriptions for one camera. Returns the RxJS
+ * subscriptions so the caller can tear them down on shutdown.
+ */
+export function watchCamera(camera: RingCamera, cfg: AppConfig, recordFn: RecordFn = recordClip): Subscription[] {
+ const state: CameraState = { recording: false, lastStartMs: 0, motionActive: false };
+ const subs: Subscription[] = [];
+
+ const trigger = (reason: string) => {
+ const now = Date.now();
+ if (!shouldTrigger(state, cfg, now)) {
+ log.debug(`Ignoring ${reason} on "${camera.name}" (busy or within cooldown).`);
+ return;
+ }
+ state.recording = true;
+ state.lastStartMs = now;
+ log.info(`Trigger: ${reason} on "${camera.name}".`);
+ // Wrap in Promise.resolve().then(...) so a *synchronous* throw in recordFn
+ // still becomes a rejection (caught below) and can never leave state.recording
+ // stuck at true, which would permanently block this camera.
+ Promise.resolve()
+ .then(() => recordFn(camera, cfg, cfg.clipLengthSeconds))
+ .catch((err) => log.error(`Recording failed for "${camera.name}": ${(err as Error).message}`))
+ .finally(() => {
+ state.recording = false;
+ });
+ };
+
+ if (cfg.recordOnMotion) {
+ subs.push(
+ // onMotionDetected emits a boolean. Trigger only on the rising edge
+ // (false -> true), so sustained motion that emits repeated `true`s does
+ // not queue extra clips once cooldown/no-overlap would otherwise allow it.
+ camera.onMotionDetected.subscribe((active: boolean) => {
+ if (active && !state.motionActive) trigger('motion');
+ state.motionActive = active;
+ }),
+ );
+ }
+
+ if (cfg.recordOnDing && camera.isDoorbot) {
+ subs.push(
+ camera.onDoorbellPressed.subscribe(() => trigger('doorbell ding')),
+ );
+ }
+
+ const triggers: string[] = [];
+ if (cfg.recordOnMotion) triggers.push('motion');
+ if (cfg.recordOnDing && camera.isDoorbot) triggers.push('ding');
+ log.info(
+ `Watching "${camera.name}" (#${camera.id})` +
+ (triggers.length ? ` for: ${triggers.join(', ')}` : ' (no auto-triggers enabled)'),
+ );
+
+ return subs;
+}
diff --git a/src/files.ts b/src/files.ts
new file mode 100644
index 0000000..3d9071f
--- /dev/null
+++ b/src/files.ts
@@ -0,0 +1,75 @@
+import { mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs';
+import { join } from 'node:path';
+import { log } from './log.js';
+
+/**
+ * Build a recording filename: {camera}_{ISO8601}.mp4
+ *
+ * The camera name is slugified so it is always filesystem-safe, and the
+ * timestamp uses a colon-free ISO 8601 variant (colons are illegal on some
+ * filesystems and awkward in shells). Milliseconds are kept so two recordings
+ * of the same camera started within the same second don't collide and overwrite
+ * each other. Example:
+ * FrontDoor_2026-06-17T14-03-22-500Z.mp4
+ */
+export function clipFilename(cameraName: string, when: Date): string {
+ const slug = slugify(cameraName);
+ const ts = when.toISOString().replace(/[:.]/g, '-');
+ return `${slug}_${ts}.mp4`;
+}
+
+export function slugify(name: string): string {
+ return (
+ name
+ .normalize('NFKD')
+ .replace(/[^\w\s-]/g, '')
+ .trim()
+ .replace(/[\s_]+/g, '-')
+ .replace(/-+/g, '-') || 'camera'
+ );
+}
+
+/** Ensure the output directory exists; returns the absolute path passed in. */
+export function ensureDir(dir: string): string {
+ mkdirSync(dir, { recursive: true });
+ return dir;
+}
+
+/**
+ * Delete .mp4 files in `dir` whose mtime is older than `retentionDays`.
+ * Returns the list of deleted file paths. A null/0 retention is a no-op.
+ */
+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 (err) {
+ // A file vanishing mid-sweep (ENOENT) is expected; surface anything else
+ // (permissions, I/O) so expired clips aren't silently left in place.
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
+ log.warn(`Retention: could not process ${full}: ${(err as Error).message}`);
+ }
+ }
+ }
+ return deleted;
+}
diff --git a/src/index.ts b/src/index.ts
new file mode 100644
index 0000000..5905db7
--- /dev/null
+++ b/src/index.ts
@@ -0,0 +1,48 @@
+import type { Subscription } from 'rxjs';
+import { loadConfig } from './config.js';
+import { createRingApi, getSelectedCameras } from './ring.js';
+import { watchCamera } from './events.js';
+import { startRetention } from './retention.js';
+import { log } from './log.js';
+
+/**
+ * Long-running service: connect to Ring, watch the selected cameras for motion /
+ * doorbell events, and auto-record live clips. Also runs retention cleanup.
+ */
+async function main(): Promise {
+ const cfg = loadConfig();
+ log.info('Starting Ring local recorder.');
+ log.info(`Output: ${cfg.outputDir} | clip: ${cfg.clipLengthSeconds}s | cameras: ${JSON.stringify(cfg.cameras)}`);
+
+ const api = createRingApi(cfg);
+ const cameras = await getSelectedCameras(api, cfg);
+
+ if (cameras.length === 0) {
+ log.error('No cameras to watch. Check the `cameras` filter in config.json. Exiting.');
+ process.exit(1);
+ }
+
+ const subs: Subscription[] = [];
+ for (const camera of cameras) {
+ subs.push(...watchCamera(camera, cfg));
+ }
+
+ const stopRetention = startRetention(cfg);
+ log.info(`Ready. Watching ${cameras.length} camera(s). Press Ctrl-C to stop.`);
+
+ const shutdown = (signal: string) => {
+ 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);
+ };
+ process.on('SIGINT', () => shutdown('SIGINT'));
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
+}
+
+main().catch((err) => {
+ log.error(`Fatal: ${(err as Error).message}`);
+ process.exit(1);
+});
diff --git a/src/log.ts b/src/log.ts
new file mode 100644
index 0000000..ec8c1b4
--- /dev/null
+++ b/src/log.ts
@@ -0,0 +1,38 @@
+/**
+ * Minimal leveled logger. Writes a single line per event with an ISO timestamp
+ * so output is greppable and friendly to `journalctl`/pm2 log capture.
+ */
+type Level = 'info' | 'warn' | 'error' | 'debug';
+
+const DEBUG = process.env.RING_DEBUG === '1' || process.env.RING_DEBUG === 'true';
+
+function emit(level: Level, msg: string, extra?: unknown): void {
+ if (level === 'debug' && !DEBUG) return;
+ const stamp = new Date().toISOString();
+ const line = `${stamp} [${level.toUpperCase()}] ${msg}`;
+ const stream = level === 'error' || level === 'warn' ? process.stderr : process.stdout;
+ if (extra !== undefined) {
+ let extraStr: string;
+ if (typeof extra === 'string') {
+ extraStr = extra;
+ } else {
+ try {
+ extraStr = JSON.stringify(extra);
+ } catch {
+ // Circular refs (some Error objects, API responses) must not crash the
+ // logger — it is frequently called from catch blocks.
+ extraStr = String(extra);
+ }
+ }
+ stream.write(`${line} ${extraStr}\n`);
+ } else {
+ stream.write(`${line}\n`);
+ }
+}
+
+export const log = {
+ info: (msg: string, extra?: unknown) => emit('info', msg, extra),
+ warn: (msg: string, extra?: unknown) => emit('warn', msg, extra),
+ error: (msg: string, extra?: unknown) => emit('error', msg, extra),
+ debug: (msg: string, extra?: unknown) => emit('debug', msg, extra),
+};
diff --git a/src/recorder.ts b/src/recorder.ts
new file mode 100644
index 0000000..9b75606
--- /dev/null
+++ b/src/recorder.ts
@@ -0,0 +1,109 @@
+import { join } from 'node:path';
+import { statSync, rmSync } from 'node:fs';
+import { firstValueFrom } from 'rxjs';
+import type { RingCamera } from 'ring-client-api';
+import type { AppConfig } from './config.js';
+import { clipFilename, ensureDir } from './files.js';
+import { log } from './log.js';
+
+/**
+ * ffmpeg output args for a single timestamped clip.
+ *
+ * `+frag_keyframe+empty_moov+default_base_moof` makes a *fragmented* MP4: the
+ * header is written up front and self-contained fragments are flushed as the
+ * stream arrives. If the process is killed mid-recording (Ctrl-C on the service,
+ * a crash, a dropped stream), the file remains playable up to the last whole
+ * fragment — instead of the "moov atom not found" corruption you get from a
+ * plain MP4 that only writes its index at the very end.
+ */
+function clipOutputArgs(seconds: number, outPath: string): string[] {
+ return [
+ '-t', String(seconds),
+ '-movflags', '+frag_keyframe+empty_moov+default_base_moof',
+ '-f', 'mp4',
+ outPath,
+ ];
+}
+
+export interface RecordResult {
+ camera: string;
+ path: string;
+ bytes: number;
+ seconds: number;
+}
+
+/**
+ * Record a single live clip from `camera` for `seconds` to the output dir.
+ *
+ * Uses the library's `recordToFile`, which opens a WebRTC live call, transcodes
+ * via ffmpeg, and resolves when the duration elapses (`-t `). Throws if
+ * the live call never starts (camera offline / throttled / auth lapsed).
+ */
+export async function recordClip(
+ camera: RingCamera,
+ cfg: AppConfig,
+ seconds: number,
+): Promise {
+ ensureDir(cfg.outputDir);
+ const startedAt = new Date();
+ const filename = clipFilename(camera.name, startedAt);
+ const outPath = join(cfg.outputDir, filename);
+
+ log.info(`Recording ${seconds}s from "${camera.name}" → ${filename}`);
+
+ // Open a live WebRTC call and transcode to a fragmented MP4 (see clipOutputArgs).
+ // The call resolves via onCallEnded when ffmpeg exits (duration reached). A hung
+ // call would otherwise hang forever, so cap the wait with a generous margin.
+ const startTimeoutMs = 30_000;
+ const hardTimeoutMs = (seconds + 30) * 1000;
+ // Cap the call setup too: streamVideo() performs WebRTC signaling that can hang
+ // on flaky networks or the unofficial API, and the onCallEnded timeout below
+ // only starts counting once the session object exists.
+ const session = await withTimeout(
+ camera.streamVideo({ output: clipOutputArgs(seconds, outPath) }),
+ startTimeoutMs,
+ `live stream for "${camera.name}" did not start within ${startTimeoutMs / 1000}s`,
+ );
+ 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;
+ }
+
+ let bytes = 0;
+ try {
+ bytes = statSync(outPath).size;
+ } catch {
+ throw new Error(`Recording finished but no file was written at ${outPath}`);
+ }
+ if (bytes === 0) {
+ // A 0-byte file means the live stream never produced data — clean it up so it
+ // doesn't masquerade as a real clip (and survive a retention sweep).
+ rmSync(outPath, { force: true });
+ throw new Error(`Recording produced an empty file (stream never started) for "${camera.name}"`);
+ }
+
+ log.info(`Saved ${filename} (${(bytes / 1024 / 1024).toFixed(2)} MB)`);
+ return { camera: camera.name, path: outPath, bytes, seconds };
+}
+
+function withTimeout(p: Promise, ms: number, message: string): Promise {
+ return new Promise((resolve, reject) => {
+ const t = setTimeout(() => reject(new Error(message)), ms);
+ p.then(
+ (v) => {
+ clearTimeout(t);
+ resolve(v);
+ },
+ (e) => {
+ clearTimeout(t);
+ reject(e);
+ },
+ );
+ });
+}
diff --git a/src/retention.ts b/src/retention.ts
new file mode 100644
index 0000000..3326fa2
--- /dev/null
+++ b/src/retention.ts
@@ -0,0 +1,32 @@
+import type { AppConfig } from './config.js';
+import { pruneOldClips } from './files.js';
+import { log } from './log.js';
+
+/**
+ * Run one retention sweep now, then on an interval. Returns a stop() to clear
+ * the timer on shutdown. No-op (returns a no-op stop) if retention is disabled.
+ */
+export function startRetention(cfg: AppConfig): () => void {
+ if (!cfg.retentionDays || cfg.retentionDays <= 0) {
+ log.info('Retention disabled (keeping all clips).');
+ return () => {};
+ }
+
+ 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}`);
+ }
+ };
+
+ sweep(); // run immediately on startup
+ const handle = setInterval(sweep, cfg.retentionSweepMinutes * 60 * 1000);
+ handle.unref?.(); // don't keep the process alive solely for the sweep timer
+ log.info(`Retention: deleting clips older than ${cfg.retentionDays}d, sweeping every ${cfg.retentionSweepMinutes}m.`);
+
+ return () => clearInterval(handle);
+}
diff --git a/src/ring.ts b/src/ring.ts
new file mode 100644
index 0000000..7378b6b
--- /dev/null
+++ b/src/ring.ts
@@ -0,0 +1,95 @@
+import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync, renameSync } from 'node:fs';
+import { dirname } from 'node:path';
+import { RingApi, type RingCamera } from 'ring-client-api';
+import type { AppConfig } from './config.js';
+import { cameraSelected } from './config.js';
+import { log } from './log.js';
+
+interface TokenFile {
+ refreshToken: string;
+ /** ISO timestamp of the last time we wrote this file — informational only. */
+ updatedAt?: string;
+}
+
+export function tokenFileExists(cfg: AppConfig): boolean {
+ return existsSync(cfg.tokenPath);
+}
+
+export function readToken(cfg: AppConfig): string {
+ if (!existsSync(cfg.tokenPath)) {
+ throw new Error(
+ `No refresh token at ${cfg.tokenPath}. Run \`npm run auth\` first to log in.`,
+ );
+ }
+ let raw: TokenFile;
+ try {
+ raw = JSON.parse(readFileSync(cfg.tokenPath, 'utf8')) as TokenFile;
+ } catch (err) {
+ throw new Error(
+ `Token file ${cfg.tokenPath} is unreadable or not valid JSON (${(err as Error).message}). Re-run \`npm run auth\`.`,
+ );
+ }
+ if (!raw || !raw.refreshToken) {
+ throw new Error(`Token file ${cfg.tokenPath} is missing "refreshToken". Re-run \`npm run auth\`.`);
+ }
+ return raw.refreshToken;
+}
+
+/** 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() };
+ mkdirSync(dirname(cfg.tokenPath), { recursive: true });
+ // Write to a temp file then atomically rename, so an interruption (or a
+ // concurrent rotation) can't leave a truncated, unparseable token file that
+ // would break the next startup.
+ const tmp = `${cfg.tokenPath}.tmp`;
+ writeFileSync(tmp, JSON.stringify(body, null, 2), { mode: 0o600 });
+ renameSync(tmp, cfg.tokenPath);
+ try {
+ chmodSync(cfg.tokenPath, 0o600); // enforce perms even if the file pre-existed
+ } catch {
+ /* best effort on filesystems without unix perms */
+ }
+}
+
+/**
+ * Construct a RingApi from the stored token and wire up rotation persistence.
+ *
+ * Ring rotates refresh tokens continuously and expires the old one shortly after
+ * use, so an unattended service MUST persist every rotation — otherwise it works
+ * once and then fails to re-auth on the next restart. That is what
+ * `onRefreshTokenUpdated` is for.
+ */
+export function createRingApi(cfg: AppConfig): RingApi {
+ const refreshToken = readToken(cfg);
+
+ const api = new RingApi({
+ refreshToken,
+ // Poll camera status so battery/online changes are reflected; cheap.
+ cameraStatusPollingSeconds: 20,
+ // ffmpeg: bundled ffmpeg-for-homebridge by default. Override via RING_FFMPEG.
+ ...(process.env.RING_FFMPEG ? { ffmpegPath: process.env.RING_FFMPEG } : {}),
+ debug: process.env.RING_DEBUG === '1' || process.env.RING_DEBUG === 'true',
+ });
+
+ api.onRefreshTokenUpdated.subscribe(({ newRefreshToken, oldRefreshToken }) => {
+ if (!oldRefreshToken) return; // initial emit on first connect — nothing to replace yet
+ log.info('Ring refresh token rotated; persisting new token.');
+ writeToken(cfg, newRefreshToken);
+ });
+
+ return api;
+}
+
+/** Get cameras filtered by the config `cameras` selector. */
+export async function getSelectedCameras(api: RingApi, cfg: AppConfig): Promise {
+ const all = await api.getCameras();
+ const selected = all.filter((c) => cameraSelected(cfg, c.name, c.id));
+ if (selected.length === 0) {
+ log.warn(
+ `No cameras matched the config filter ${JSON.stringify(cfg.cameras)}. ` +
+ `Found: ${all.map((c) => `${c.name} (#${c.id})`).join(', ') || '(none)'}`,
+ );
+ }
+ return selected;
+}
diff --git a/src/verify.ts b/src/verify.ts
new file mode 100644
index 0000000..de93e34
--- /dev/null
+++ b/src/verify.ts
@@ -0,0 +1,168 @@
+import { execFile } from 'node:child_process';
+import { promisify } from 'node:util';
+import type { Subscription } from 'rxjs';
+import type { RingCamera } from 'ring-client-api';
+import { loadConfig } from './config.js';
+import { createRingApi } from './ring.js';
+import { recordClip } from './recorder.js';
+import { log } from './log.js';
+
+const execFileP = promisify(execFile);
+
+/**
+ * End-to-end verification harness. Run AFTER `npm run auth` has saved a token.
+ *
+ * npm run verify -- [--camera "Name"] [--seconds 10] [--watch-motion 60]
+ *
+ * Runs the goal's verify checklist against the real account:
+ * 1. authenticate + list real cameras
+ * 2. capture a live clip to disk
+ * 3. probe the clip for playable video (and report audio)
+ * 4. (optional) wait for a real motion/ding event and confirm auto-recording
+ */
+async function main(): Promise {
+ const args = parseArgs(process.argv.slice(2));
+ const seconds = numArg(args.seconds, 10, 1, '--seconds');
+ const watchMotion = numArg(args['watch-motion'], 0, 0, '--watch-motion');
+ const cfg = loadConfig();
+ const api = createRingApi(cfg);
+
+ let failures = 0;
+ const step = (n: number, msg: string) => log.info(`[${n}] ${msg}`);
+
+ try {
+ // 1. AUTH + LIST ---------------------------------------------------------
+ step(1, 'Authenticating and listing cameras…');
+ const cameras = await api.getCameras();
+ if (cameras.length === 0) throw new Error('Authenticated but found 0 cameras on the account.');
+ for (const c of cameras) {
+ const battery = c.batteryLevel != null ? `${c.batteryLevel}%` : 'wired';
+ log.info(` #${c.id} ${c.name} [${c.isDoorbot ? 'doorbell' : 'camera'}, ${c.model}, ${battery}]`);
+ }
+ log.info(` ✓ Auth OK, ${cameras.length} camera(s) listed.`);
+
+ const target = pickCamera(cameras, args.camera);
+ if (!target) throw new Error(`No camera matched "${args.camera}".`);
+
+ // 2. CAPTURE -------------------------------------------------------------
+ step(2, `Capturing a ${seconds}s live clip from "${target.name}"…`);
+ const clip = await recordClip(target, cfg, seconds);
+ log.info(` ✓ Wrote ${clip.path} (${(clip.bytes / 1024 / 1024).toFixed(2)} MB).`);
+
+ // 3. PROBE ---------------------------------------------------------------
+ step(3, 'Probing the clip for playable streams…');
+ const streams = await probeStreams(clip.path);
+ const hasVideo = streams.includes('video');
+ const hasAudio = streams.includes('audio');
+ if (!hasVideo) {
+ failures++;
+ log.error(' ✗ No video stream found — clip is not playable.');
+ } else {
+ log.info(' ✓ Video stream present, clip is playable.');
+ }
+ if (hasAudio) log.info(' ✓ Audio stream present.');
+ else log.warn(' ! No audio stream (Ring audio can be off in the app, or omitted for this device).');
+
+ // 4. MOTION TRIGGER (optional) ------------------------------------------
+ if (watchMotion > 0) {
+ step(4, `Waiting up to ${watchMotion}s for a real motion/ding on "${target.name}" — trigger it now (walk in front / press the doorbell)…`);
+ const fired = await waitForTriggeredRecording(target, cfg, watchMotion);
+ if (fired) log.info(' ✓ Motion/ding fired and an auto-recording completed end-to-end.');
+ else {
+ failures++;
+ log.error(` ✗ No motion/ding event within ${watchMotion}s. Re-run with a longer --watch-motion and trigger motion.`);
+ }
+ } else {
+ step(4, 'Skipping motion test (pass --watch-motion and trigger motion to verify it live).');
+ }
+
+ log.info(failures === 0 ? '\nVERIFY: all checks passed.' : `\nVERIFY: ${failures} check(s) failed.`);
+ } finally {
+ api.disconnect();
+ setTimeout(() => process.exit(failures === 0 ? 0 : 1), 1500);
+ }
+}
+
+/** Return the list of codec_types ("video"/"audio") present in a media file. */
+async function probeStreams(file: string): Promise {
+ const { stdout } = await execFileP('ffprobe', [
+ '-v', 'error',
+ '-show_entries', 'stream=codec_type',
+ '-of', 'json',
+ file,
+ ]);
+ const parsed = JSON.parse(stdout) as { streams?: Array<{ codec_type?: string }> };
+ return (parsed.streams ?? []).map((s) => s.codec_type ?? '').filter(Boolean);
+}
+
+/**
+ * Subscribe to motion + ding for one camera; on the first event, record a short
+ * clip and resolve true once it's on disk. Resolves false on timeout.
+ */
+function waitForTriggeredRecording(camera: RingCamera, cfg: ReturnType, timeoutSec: number): Promise {
+ return new Promise((resolve) => {
+ const subs: Subscription[] = [];
+ let done = false;
+ const finish = (val: boolean) => {
+ if (done) return;
+ done = true;
+ subs.forEach((s) => s.unsubscribe());
+ clearTimeout(timer);
+ resolve(val);
+ };
+ let triggered = false;
+ const onTrigger = (reason: string) => {
+ if (triggered) return; // record once; ignore further events for this camera
+ triggered = true;
+ subs.forEach((s) => s.unsubscribe()); // stop the event streams immediately
+ 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);
+ });
+ };
+ subs.push(camera.onMotionDetected.subscribe((active: boolean) => active && onTrigger('motion')));
+ if (camera.isDoorbot) subs.push(camera.onDoorbellPressed.subscribe(() => onTrigger('doorbell ding')));
+ const timer = setTimeout(() => finish(false), timeoutSec * 1000);
+ });
+}
+
+function pickCamera(cameras: RingCamera[], sel?: string): RingCamera | undefined {
+ if (!sel) return cameras[0];
+ const id = Number(sel);
+ if (Number.isFinite(id)) {
+ const hit = cameras.find((c) => c.id === id);
+ if (hit) return hit;
+ }
+ return cameras.find((c) => c.name.toLowerCase().includes(sel.toLowerCase()));
+}
+
+/** Parse a numeric CLI arg, rejecting non-numeric input instead of letting NaN flow into ffmpeg/timeout math. */
+function numArg(raw: string | undefined, fallback: number, min: number, flag: string): number {
+ if (raw === undefined) return fallback;
+ const n = Number(raw);
+ if (!Number.isFinite(n) || n < min) throw new Error(`${flag} must be a number >= ${min} (got "${raw}")`);
+ return n;
+}
+
+function parseArgs(argv: string[]): Record {
+ const out: Record = {};
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i];
+ if (a.startsWith('--')) {
+ const next = argv[i + 1];
+ if (next && !next.startsWith('--')) {
+ out[a.slice(2)] = next;
+ i++;
+ } else out[a.slice(2)] = 'true';
+ }
+ }
+ return out;
+}
+
+main().catch((err) => {
+ log.error((err as Error).message);
+ process.exit(1);
+});
diff --git a/test/logic.test.mjs b/test/logic.test.mjs
new file mode 100644
index 0000000..b3a98e2
--- /dev/null
+++ b/test/logic.test.mjs
@@ -0,0 +1,86 @@
+// Hermetic logic tests — no Ring account or network required.
+// Run with: npm test (builds to dist/ first, then runs this against dist).
+import { clipFilename, slugify, pruneOldClips, ensureDir } from '../dist/files.js';
+import { watchCamera } from '../dist/events.js';
+import { mkdtempSync, writeFileSync, utimesSync, readdirSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+const tick = () => new Promise((r) => setTimeout(r, 5));
+let pass = 0, fail = 0;
+const ok = (c, m) => (c ? (pass++, console.log(' PASS', m)) : (fail++, console.log(' FAIL', m)));
+
+console.log('files: naming + retention');
+{
+ const name = clipFilename('Front Door!', new Date('2026-06-17T14:03:22.500Z'));
+ ok(/^Front-Door_2026-06-17T14-03-22-500Z\.mp4$/.test(name), 'filename keeps ms, colons stripped (no same-second collision)');
+ ok(slugify(' Backyard / Garage ') === 'Backyard-Garage', 'slugify sanitizes unsafe chars');
+ ok(slugify('***') === 'camera', 'slugify falls back to "camera" for empty result');
+
+ const dir = mkdtempSync(join(tmpdir(), 'ring-ret-'));
+ ensureDir(dir);
+ const old = join(dir, 'Cam_old.mp4'); writeFileSync(old, 'x');
+ writeFileSync(join(dir, 'Cam_fresh.mp4'), 'x');
+ writeFileSync(join(dir, 'notes.txt'), 'x');
+ const tenDaysAgo = Date.now() / 1000 - 10 * 86400;
+ utimesSync(old, tenDaysAgo, tenDaysAgo);
+ const deleted = pruneOldClips(dir, 7, new Date());
+ const left = readdirSync(dir).sort();
+ ok(deleted.length === 1 && deleted[0].endsWith('Cam_old.mp4'), 'pruned exactly the >7d clip');
+ ok(left.includes('Cam_fresh.mp4'), 'kept the fresh clip');
+ ok(left.includes('notes.txt'), 'left non-mp4 file untouched');
+ ok(pruneOldClips(dir, null, new Date()).length === 0, 'retention=null is a no-op');
+}
+
+console.log('events: motion/ding trigger state machine');
+function obs() {
+ const subs = [];
+ return { subscribe(fn) { subs.push(fn); return { unsubscribe() {} }; }, next(v) { subs.forEach((f) => f(v)); } };
+}
+const fakeCamera = (isDoorbot) => ({ name: 'TestCam', id: 42, isDoorbot, onMotionDetected: obs(), onDoorbellPressed: obs() });
+const cfg = (over = {}) => ({ clipLengthSeconds: 10, recordOnMotion: true, recordOnDing: true, motionCooldownSeconds: 0, ...over });
+function spy() {
+ const calls = []; let resolveLast;
+ const fn = (camera, c, seconds) => { calls.push({ seconds }); return new Promise((res) => { resolveLast = () => res({ camera: camera.name, path: 'x', bytes: 1, seconds }); }); };
+ return { fn, calls, finish: () => resolveLast && resolveLast() };
+}
+
+{
+ const cam = fakeCamera(false); const s = spy();
+ watchCamera(cam, cfg(), s.fn);
+ cam.onMotionDetected.next(false); await tick();
+ ok(s.calls.length === 0, 'motion=false does not trigger');
+ cam.onMotionDetected.next(true); await tick();
+ ok(s.calls.length === 1 && s.calls[0].seconds === 10, 'rising edge (false->true) records one clip');
+ cam.onMotionDetected.next(true); await tick();
+ ok(s.calls.length === 1, 'sustained true (no new rising edge) does not re-trigger');
+ s.finish(); await tick();
+ cam.onMotionDetected.next(false); await tick();
+ cam.onMotionDetected.next(true); await tick();
+ ok(s.calls.length === 2, 'a new rising edge after motion ends triggers again');
+}
+{
+ const cam = fakeCamera(false); const s = spy();
+ watchCamera(cam, cfg({ motionCooldownSeconds: 3600 }), s.fn);
+ cam.onMotionDetected.next(true); await tick(); s.finish(); await tick();
+ cam.onMotionDetected.next(false); await tick();
+ cam.onMotionDetected.next(true); await tick();
+ ok(s.calls.length === 1, 'new rising edge within cooldown is skipped');
+}
+{
+ const cam = fakeCamera(true); const s = spy();
+ watchCamera(cam, cfg({ recordOnMotion: false }), s.fn);
+ cam.onMotionDetected.next(true); await tick();
+ ok(s.calls.length === 0, 'motion ignored when recordOnMotion=false');
+ cam.onDoorbellPressed.next({}); await tick();
+ ok(s.calls.length === 1, 'doorbell ding triggers a recording');
+}
+{
+ const cam = fakeCamera(false); const s = spy();
+ watchCamera(cam, cfg(), s.fn);
+ cam.onDoorbellPressed.next({}); await tick();
+ ok(s.calls.length === 0, 'ding on non-doorbot does nothing');
+}
+
+console.log(`\n${fail === 0 ? 'ALL PASS' : 'FAILURES'}: ${pass} passed, ${fail} failed`);
+process.exit(fail === 0 ? 0 : 1);
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..c1cd15c
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "nodenext",
+ "moduleResolution": "nodenext",
+ "lib": ["ES2022"],
+ "outDir": "dist",
+ "rootDir": "src",
+ "declaration": false,
+ "sourceMap": true,
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true
+ },
+ "include": ["src/**/*.ts"]
+}