# Call recording

Server-side recording for dispute handling and compliance. A **recorder peer** joins the room
in-process (no extra service), receives every participant's media over WebRTC and writes one file
per participant and track kind. Nothing is mixed or transcoded: Opus audio is stored as-is in
`.ogg`, VP8 video as-is in `.ivf`.

Code: `internal/recording` (recorder, files), `internal/api/ext_recording.go` (routes).

## How it works

1. `POST /v1/rooms/{id}/recording/start` **arms** the room. If participants are already connected
   the recorder joins immediately; otherwise it joins when the first participant connects
   (for consultation rooms: when both parties are connected, so the meter logic is not disturbed).
2. The recorder joins the signaling hub as a virtual peer `rec:<room_id>` (name `recorder`,
   scope `subscriber`). Clients see it in `welcome.peers` / `peer-joined` like any other peer.
3. It sends every participant, including later joiners, a **receive-only SDP offer**
   (`type: "offer"`, from `rec:<room_id>`). The browser SDK surfaces this as an `incoming` event;
   calling `accept(localStream)` (what the demos do) answers with the participant's tracks.
   The participant's `answer` / `ice` messages are routed back to the recorder by the hub.
4. Every received track is written to disk as RTP arrives. For video the recorder sends a PLI
   right away and every 3 s so the file starts with a keyframe.
5. Recording stops on `POST .../recording/stop`, when the room empties, or when the room is closed
   (consultation end, `DELETE /v1/rooms/{id}`, `POST /v1/rooms/{id}/close`). Files are finalized
   (writers closed) and `recording.stopped` is emitted with the file list.

## Endpoints (application API key)

| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/v1/rooms/{id}/recording/start` | Arm/start. Body `{"consent": true, "consented_by": ["alice","bob"]}`. `400` unless `consent` is `true`; `409` if already armed/recording. |
| `POST` | `/v1/rooms/{id}/recording/stop` | Stop and finalize. `409` if nothing is running. |
| `GET` | `/v1/rooms/{id}/recording` | Status of the latest recording in this room (`state`: `none`, `armed`, `recording`, `stopped`). |
| `GET` | `/v1/calls/{id}/recordings` | Files of a call (live or from the on-disk snapshot). |
| `GET` | `/v1/recordings/{app_id}/{call_id}/{file}` | Download one file. `app_id` must be the caller's app (`404` otherwise). |

Status / list response (`recording` object):

```json
{
  "app_id": "app_…", "room_id": "room_…", "call_id": "call_…",
  "state": "stopped", "stop_reason": "api",
  "consent": {"consent": true, "consented_by": ["alice","bob"], "source": "api", "at": "…"},
  "requested_at": "…", "started_at": "…", "stopped_at": "…",
  "peers": ["alice","bob"],
  "files": [
    {"name": "alice-audio.ogg", "peer_id": "alice", "kind": "audio", "codec": "audio/opus",
     "size_bytes": 81234, "duration_seconds": 61.2, "packets": 3060, "complete": true,
     "started_at": "…", "ended_at": "…", "url": "/v1/recordings/app_…/call_…/alice-audio.ogg"}
  ]
}
```

`stop_reason` is `api`, `room_empty` or `room_closed`. `duration_seconds` is derived from RTP
timestamps of the packets written (0 when nothing arrived).

### Example

```bash
API=http://localhost:8080; KEY=ck_live_…   # from `make create-app`
ROOM=$(curl -s -X POST $API/v1/rooms -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
       -d '{"name":"support-42","max_peers":3}' | jq -r .room.id)
curl -s -X POST $API/v1/rooms/$ROOM/recording/start -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
     -d '{"consent":true,"consented_by":["alice","bob"]}'
# … participants join with the SDK / demo page and accept the recorder's incoming offer …
curl -s $API/v1/rooms/$ROOM/recording -H "Authorization: Bearer $KEY"
curl -s -X POST $API/v1/rooms/$ROOM/recording/stop -H "Authorization: Bearer $KEY"
CALL=$(curl -s $API/v1/rooms/$ROOM/recording -H "Authorization: Bearer $KEY" | jq -r .recording.call_id)
curl -s $API/v1/calls/$CALL/recordings -H "Authorization: Bearer $KEY"
curl -s -o alice.ogg "$API/v1/recordings/<app_id>/$CALL/alice-audio.ogg" -H "Authorization: Bearer $KEY"
```

## Consultations

Rooms created by accepting a consultation are recorded automatically when the consultation was
requested with metadata `record=true`:

```bash
curl -X POST $API/v1/consultations -H "Authorization: Bearer $KEY" \
     -d '{"consultant_id":"astro-1","user_id":"user-42","mode":"audio","max_seconds":600,"metadata":{"record":"true"}}'
```

The recorder joins when both parties are connected (the moment the meter starts) and stops when
the consultation ends (the room is closed). Consent is recorded as `source: "consultation"` with
`consented_by: [<consultant_id>, <user_id>]`; the platform asserts it by setting the metadata. Use
`consultation.call_id` (or the `recording.stopped` event) to fetch the files.

## Events

Emitted through the normal event pipeline (analytics + the app's webhook):

- `recording.started`: `data.consented_by`, `data.source`, `data.peers`
- `recording.stopped`: `data.reason`, `data.duration_seconds`, `data.files[]` (`name`, `peer_id`, `kind`, `codec`, `size_bytes`, `duration_seconds`, `url`)

## File layout

```
<DATA_DIR>/recordings/<app_id>/<call_id>/
  recording.json          snapshot of the status object above (read by the list endpoints after a restart)
  <peer_id>-audio.ogg     Opus, 48 kHz, as received
  <peer_id>-video.ivf     VP8 (VP9/AV1 if ever negotiated), as received
  <peer_id>-audio-2.ogg   a peer that reconnected gets a numbered file
```

Peer ids are sanitized for file names: anything outside `[A-Za-z0-9._-]` becomes `_`
(`c:astro-1` → `c_astro-1-audio.ogg`). The `peer_id` field in the file list keeps the original id.
Files are never deleted by the server; add retention to your deploy (e.g. a cron that prunes
`recordings/` older than N days).

## Consent model

The server does not ask participants anything. The application must obtain consent and then
assert it: `start` is refused without `consent: true`, and the names in `consented_by` are stored
in `recording.json` and in the events for your audit trail. Participants can see the recorder in
the peer list (`rec:<room>`, name `recorder`); show that in your UI. For consultations the
`record=true` metadata is the assertion.

## Codecs and playback

| Received codec | File | Notes |
|---|---|---|
| `audio/opus` | `.ogg` | always; the recorder only offers Opus for audio |
| `video/VP8` | `.ivf` | the recorder only offers VP8 for video |
| `video/VP9`, `video/AV1` | `.ivf` | written if a future config negotiates them |
| anything else | skipped | logged as `recording: unsupported codec, track skipped` |

Play or convert with ffmpeg:

```bash
ffplay alice-audio.ogg
ffplay alice-video.ivf
# one mp4 per participant
ffmpeg -i alice-video.ivf -i alice-audio.ogg -c:v libx264 -c:a aac alice.mp4
# mix all audio of a call into one file
ffmpeg -i alice-audio.ogg -i bob-audio.ogg -filter_complex amix=inputs=2 call.ogg
```

Each file starts at the moment that track's first packet arrived, so tracks of a call are not
sample-aligned; `started_at` in the file list gives the offset you need when mixing.

## Limitations

- **Peer slot.** The recorder counts as a room member for `max_peers` (the hub does not
  distinguish virtual peers). Create recorded rooms with `max_peers` one higher than the number
  of participants (or `0`, which uses the app default; keep that ≥ participants + 1).
  Consultation rooms (`max_peers: 2`) are fine because the recorder joins after both parties.
- **Connectivity.** The recorder runs inside the API process and gathers host candidates plus the
  configured STUN/TURN (it mints its own TURN credential like `GET /v1/ice`). Behind Docker or a
  firewall it reaches browsers through TURN only, so a working relay is required in production.
- **Stopping while a consultation is active** keeps the (idle) recorder in the room until the room
  empties, because the consultation layer treats any peer leaving an active room as a hangup.
- **No mixing, no transcoding, no retention.** Files are raw per-track containers; use ffmpeg.
- **Single node, local disk.** Recordings live on the node that hosted the call. For multi-node
  deployments sync `recordings/` to object storage from a sidecar.
- **Client cooperation.** A participant whose client does not accept the recorder's offer (the
  SDK `incoming` event) is simply not recorded; `files` will be missing that peer.
- In-progress recordings are not resumed after a restart; their files remain and the snapshot
  shows `state: recording` with incomplete files.
