# RTMP

A small RTMP ingest server for Elixir. No framework, no FFmpeg, no native code.

Point OBS, FFmpeg or a phone encoder at it, decide in one callback whether to
accept the stream key, and receive H.264 and AAC frames with timestamps.

> **Status: v0.1.** The support table below says what works today. Anything not
> marked "Supported" does not work yet.

It is a protocol library, not a media server: no playback, no relaying to
subscribers, no HLS or transcoding. See [`examples/flv_saver`](examples/flv_saver/README.md)
for what building on top of it looks like.

## Why this exists

I needed live video ingest for a production streaming app built on Elixir.
I searched Hex and found nothing I could build on, so I wrote the handshake,
chunk parsing and AMF handling myself, then spent months finding out what real
encoders and unreliable mobile networks do to a connection.

This library is that work, rewritten as a package other people can use.
It is built for the cases that break naive implementations: small or changing
chunk sizes, chunks from different streams interleaved, timestamps past the
24-bit boundary or wrapping at 32 bits, malformed or hostile input, and clients
that stop sending without closing.

## Quick start

```elixir
# mix.exs
{:rtmp, "~> 0.1"}
```

```elixir
defmodule MyIngest do
  use RTMP.Handler

  @impl true
  def handle_publish("live", key, state) do
    if valid_key?(key), do: {:ok, state}, else: {:reject, :bad_key, state}
  end

  def handle_publish(_other_app, _key, state), do: {:reject, :unknown_app, state}

  @impl true
  def handle_frame(%RTMP.Frame{type: :video, dts: _dts, nalus: _nalus}, state) do
    # forward to your packager, recorder or relay
    {:ok, state}
  end

  def handle_frame(%RTMP.Frame{type: :audio}, state), do: {:ok, state}

  defp valid_key?(key), do: key == "my_key"
end

# in your supervision tree
{RTMP.Listener, port: 1935, handler: MyIngest}
```

Test it without a camera:

```sh
ffmpeg -re -stream_loop -1 -i sample.mp4 -c copy -f flv rtmp://localhost:1935/live/my_key
```

In OBS: **Settings → Stream → Service: Custom**, server
`rtmp://your-host:1935/live`, stream key `my_key`.

## The handler

`handle_publish/3` and `handle_frame/2` are required. The rest have defaults
when you `use RTMP.Handler`. There's deliberately no default for
`handle_publish/3`: deciding which stream keys are valid is the one thing
every server must do on purpose.

| Callback               | When                                         | Can reject / disconnect |
|------------------------|----------------------------------------------|-------------------------|
| `init/2`               | TCP accepted, before the RTMP handshake      | `{:reject, reason}` — e.g. refuse an IP |
| `handle_connect/2`     | client sent `connect` (app, tcUrl, ...)      | `{:reject, reason, state}` |
| `handle_publish/3`     | client sent `publish` (app, stream key)      | `{:reject, reason, state}` |
| `handle_metadata/2`    | client sent `onMetaData`                     | `{:disconnect, reason, state}` |
| `handle_frame/2`       | every audio and video frame                  | `{:disconnect, reason, state}` |
| `handle_disconnect/2`  | the connection ended, with the reason        | — |

A frame (`RTMP.Frame`) carries `type`, `codec`, `dts` and `cts` (so
`RTMP.Frame.pts/1`), `keyframe?`, `sequence_header?`, the codec `data`, parsed
`config` for sequence headers, `nalus` for H.264, and the raw `payload` — which
is exactly an FLV tag body, ready to write to a file.

Callbacks run in the connection's own process, so `self()` inside a callback
is the connection. Keep it to kick a publisher later with
`RTMP.disconnect(pid, reason)`.

Every way a connection can end reaches `handle_disconnect/2` with a reason:
`:closed`, `:unpublished`, `:idle_timeout`, `:handshake_timeout`,
`{:rejected, reason}`, `{:disconnected, reason}`, `{:protocol_error, reason}`,
`{:handler_error, exception}`, `:shutdown` or `{:socket_error, reason}`.

## What is supported

| Area | v0.1 | Later | Not planned |
|---|---|---|---|
| Simple handshake | Supported | | |
| Digest (HMAC) handshake | | | Not planned (old Flash-era clients) |
| RTMPE (encrypted) | Rejected | | |
| Chunk streams: fmt 0–3, extended timestamps, interleaving | Supported | | |
| Protocol control: Set Chunk Size, Abort, Ack, Window Ack Size, Set Peer Bandwidth, User Control (Stream Begin, Ping) | Supported | | |
| AMF0 commands and metadata | Supported | | |
| AMF3 | Type 17/15 messages with an AMF0 body (the common case) | Maybe | |
| `connect`, `releaseStream`, `FCPublish`, `createStream`, `publish`, `FCUnpublish`, `deleteStream`, `closeStream` | Supported | | |
| `onMetaData` (with or without `@setDataFrame`), aggregate messages | Supported | | |
| H.264 video | Supported: decoder config (profile, level, SPS, PPS), NAL units, composition time | | |
| AAC audio | Supported: AudioSpecificConfig, raw frames | | |
| MP3, Speex, PCM, Nellymoser, G.711, Sorenson H.263, VP6, Screen Video | Passed through unparsed | | |
| HEVC, AV1, VP9, Opus (Enhanced RTMP) | Rejected with a clear error | Planned | |
| RTMPS (TLS) | Terminate TLS in front of it | Planned | |
| Publishing to other servers (client) | | Planned | |
| Playback to subscribers (`play`) | Rejected | | Not planned (use a media server) |
| Transcoding, HLS, DASH | | | Not planned (see examples) |

## Behaviour under bad conditions

- A timeout on every handshake step, and an idle timeout once connected.
- Hard limits on chunk size, message size, open chunk streams, and total bytes
  held in partially received messages.
- A slow handler cannot make memory grow without bound (see Design notes).
- A malformed or hostile client closes its own connection and nothing else.
  Parsers return errors on bad input; they never raise.
- Telemetry events: `[:rtmp, :connection, :start | :stop | :rejected]`,
  `[:rtmp, :publish, :start | :stop]`, `[:rtmp, :bytes, :in]`,
  `[:rtmp, :protocol, :error]`. Stream keys are never included. See
  `RTMP.Telemetry`.

*Tested with: FFmpeg (libavformat 62.3) — H.264 + AAC, AAC only, MP3, and
timestamps past the 24-bit boundary, captured byte-for-byte as test fixtures.
Not yet tested against OBS or Larix directly; OBS uses librtmp's command
sequence, which matches FFmpeg's.*

## Configuration

`RTMP.Listener` options:

| Option               | Default     | |
|----------------------|-------------|-|
| `:handler`           | required    | Your `RTMP.Handler` module |
| `:handler_options`   | `[]`        | Passed to `init/2` for each connection |
| `:port`              | `1935`      | `0` picks a free port; read it with `RTMP.Listener.port/1` |
| `:acceptors`         | `10`        | Acceptor processes |
| `:max_connections`   | `16_384`    | Concurrent connections |
| `:limits`            | see below   | Keyword list or `%RTMP.Limits{}` |
| `:transport_options` | `[]`        | Passed to `:gen_tcp`, e.g. `[ip: {127, 0, 0, 1}]` |
| `:name`              | —           | Registered name |

Per-connection limits (`RTMP.Limits`):

| Limit                  | Default  | Bounds |
|------------------------|----------|--------|
| `:max_chunk_size`      | 1 MiB    | Largest chunk size a client may set |
| `:max_message_size`    | 8 MiB    | Largest single message (e.g. a keyframe) |
| `:max_chunk_streams`   | 16       | Chunk stream IDs per connection |
| `:max_buffered_bytes`  | 16 MiB   | Total bytes in partially received messages |
| `:idle_timeout`        | 30 s     | No data after the handshake |
| `:handshake_timeout`   | 10 s     | Per handshake step |

## Design notes

**Backpressure.** Frames are passed to your handler synchronously, in the
connection's own process, and no more data is read from the socket until your
callback returns. If your handler is slow, the publisher is slowed down by TCP
flow control. There's no internal queue, so there's nothing to grow. If you'd
rather not slow the publisher, hand frames to another process yourself and
decide what to drop.

**Failure isolation.** Each connection is its own process (on
[ThousandIsland](https://hex.pm/packages/thousand_island)). A protocol error,
a limit breach, a timeout or an exception in your handler closes that
connection cleanly — `handle_disconnect/2` still runs and the exception is
logged — and nothing else.

**Determinism.** Frames that fully arrived before a protocol error are
delivered before the connection closes, however TCP happened to split the
bytes.

## Known limitations

- Ingest only: no client, no `play`, no relaying.
- No RTMPS — terminate TLS in front (nginx `stream`, HAProxy, a load balancer).
- No Enhanced RTMP codecs; they're rejected with a reason rather than handed
  to you undecodable.
- Only H.264 and AAC are parsed; other legacy codecs are passed through.
- Simple handshake only.
- No AMF3 values (AMF3-typed messages with an AMF0 body work).
- One publish per connection, which is what every encoder does.
- Timestamps are RTMP's 32-bit milliseconds and wrap after about 49.7 days.
- Clients must honour the negotiated chunk size. A client that writes
  messages larger than the chunk size without continuation headers is
  breaking the spec, and its stream is rejected as malformed.
- Extended timestamps on continuation chunks are detected by value, as SRS and
  nginx-rtmp do, to support encoders that omit them. If a continuation
  chunk's first four payload bytes happen to equal the timestamp, they'd be
  misread.

## Examples

- [`examples/flv_saver`](examples/flv_saver/README.md): write every published stream to
  an FLV file (about 70 lines).
- `examples/hls_bridge/`: hand frames to a packager. *(planned)*

## Compared with other Elixir options

- **membrane_rtmp_plugin**: part of the Membrane framework, relies on FFmpeg.
  Choose it if you are already building a Membrane pipeline.
- **ex_rtmp**: server and client in one package.
- **eml_rtmp family**, **rtmpmsg**: lower-level building blocks.

Choose this library if you want a plain Elixir ingest server with a single
handler behaviour, strict limits and telemetry, and no dependency on a
framework. *(Check the claims above against each project's current README
before publishing.)*

## Testing

```sh
mix test
```

Unit tests for every protocol layer; property-based tests (StreamData) for
AMF0 round-trips, chunk encode/parse round-trips at random chunk sizes and
split points, randomly interleaved chunk streams, and fuzzing with random
bytes; socket-level tests replaying captured FFmpeg publishes in randomly
sized writes; and tests for every limit, rejection path, handler failure,
telemetry event and backpressure. CI also publishes a live FFmpeg test
pattern to the FLV saver example and checks the file with `ffprobe`.

## Roadmap

1. v0.1: ingest server (this README).
2. v0.2: RTMPS, Enhanced RTMP codecs.
3. v0.3: publisher (client) mode.
4. Outside this repo: publishers for React Native and Tauri.

## License

MIT — see [LICENSE](LICENSE).
