# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Commands ```bash # Build cargo build # Run cargo run # Build (Nix) nix build # Dev shell (provides clang + mold linker) nix develop ``` No tests exist yet. ## Workspace layout ``` crates/ server/ — main binary (RTMP ingestion, HTTP API, WebRTC) entity/ — SeaORM entity definitions migration/ — SeaORM migrations ``` ## Architecture This is an RTMP-to-WHEP bridge: accepts an RTMP video publish stream and re-streams it to browsers via WebRTC (WHEP signaling protocol). **Signal flow:** ``` OBS/encoder → RTMP (port 8123) → H264Parser → async_broadcast channel ↓ Browser ← WebRTC/UDP ← str0m Rtc ← WHEP HTTP (port 3000) ``` **Runtime:** tokio (not smol). The HTTP server runs as a tokio task using hyper. ### RTMP ingestion (port 8123) — `crates/server/src/main.rs` Each incoming TCP connection is handled in a detached tokio task: 1. **Handshake** — reads C0+C1 (1537 bytes) via `rml_rtmp::Handshake`, sends S0+S1+S2, reads C2 (1536 bytes). 2. **Session setup** — creates an `rml_rtmp::ServerSession`, writes its initial response bytes. 3. **Event loop** — reads 4096-byte chunks, calls `rtmp_session.handle_input`. Handles: - `ConnectionRequested` → accepted unconditionally. - `PublishStreamRequested` → accepted for any stream key; a `StreamSession` with an `async_broadcast::Sender>` is inserted into `AppState`. - `VideoDataReceived` → HEVC check (bytes 1–4 == `hvc1`; drops connection if true), then passed to `H264Parser::parse`. Parsed frames are broadcast on the channel. - `PublishStreamFinished` → entry removed from `AppState`. `AppState` is a `Arc>` wrapping a `DashMap`. ### HTTP API (port 3000) — `crates/server/src/http.rs` Async hyper server running in a tokio task. Routes: - `GET /api/catalog` — returns JSON `{ active_streams: [String] }` listing currently publishing stream keys. - `GET /api/meow` — returns `"meow"` (health check / placeholder). - WHEP signaling is not yet wired into this HTTP server (see webrtc.rs for the channel plumbing). SDP offer/answer exchange uses `tokio::sync::mpsc` channels between HttpServer and the Webrtc task. ### WebRTC negotiation and media loop — `crates/server/src/webrtc.rs` The `Webrtc` task receives `(stream_key, sdp_body)` tuples from `offer_rx`: 1. Binds a UDP socket to `127.0.0.1:0` — only ICE candidate advertised (host, UDP, loopback). 2. Builds `str0m::Rtc` with H.264 explicitly configured for PTs 102, 104, 106 (profiles `0x42e01f`, `0x4d001f`, `0x64001f`). Default H.264 support is disabled first. 3. Adds a `SendOnly` video media track, calls `changes.accept_offer(offer_sdp)` to produce the SDP answer. 4. Sends the answer back on `accept_tx`, then spawns a per-connection tokio task. **Per-connection loop** (`Webrtc::detach_connection`): - Drains `rtc.poll_output()` until `Output::Timeout`. Each iteration sends UDP datagrams (`Output::Transmit`) or handles events: - `Event::MediaAdded` — picks the PT with the highest `profile_level_id`, stores in `video_pt`. - `Event::Connected` — sets `connected = true`; media sending begins. - When connected, lazily subscribes to the `async_broadcast` channel for the stream key, then drains up to 8 frames per iteration via `try_recv`, writing each with `writer.write(pt, now, rtp_time, frame.data)`. `rtp_time` is computed as `MediaTime::from_90khz(timestamp_ms * 90)`. - Waits (capped at 20 ms) with `tokio::select!` for either the str0m deadline or a UDP datagram. Incoming datagrams are fed to `rtc.handle_input(Input::Receive(...))`. ### H.264 parsing — `crates/server/src/media.rs` `H264Parser` converts raw RTMP `VideoDataReceived` payloads (AVCC) to Annex-B: - **Byte 0**: upper nibble = frame type (1 = keyframe), lower nibble = codec ID (7 = H.264; anything else dropped). - **Byte 1**: AVC packet type — `0` = sequence header, `1` = NAL unit data. - **Bytes 5+**: payload. Packet type `0` walks `AVCDecoderConfigurationRecord` to cache SPS and PPS byte arrays. Packet type `1` converts AVCC (4-byte big-endian length prefix per NALU) to Annex-B (`00 00 00 01` start code). Before the first NALU of every keyframe, prepends SPS+PPS in Annex-B form. ### Unused stub — `crates/server/src/rtmp.rs` Early manual RTMP handshake implementation, not used in the current flow. ## Database (SeaORM + SQLite) DB file: `stream.db`. Migrations: `crates/migration/`. Entities: `crates/entity/`. Run migrations: `Migrator::up(&db, None).await?` — idempotent, tracked in `seaql_migrations`. ### Entities **`users`** (`crates/entity/src/users.rs`) - Fields: `id`, `username`, `hashed_password` - Relations: `has_many` → `stream_key` - `Entity::create(db, username, hashed_password)` — inserts a new user - `ActiveModel::update_username(db, username)` — updates username - `ActiveModel::update_password(db, hashed_password)` — updates password hash - Passwords must be hashed before being passed to these methods **`stream_key`** (`crates/entity/src/stream_key.rs`) - Fields: `id`, `key_value` (unique), `user_id`, `label`, `is_active`, `is_unlisted`, `created_at` - Relations: `belongs_to` → `users`, `has_many` → `stream_session` **`stream_session`** (`crates/entity/src/stream_session.rs`) - Fields: `id`, `stream_key_id`, `started_at`, `ended_at` (nullable) - Relations: `belongs_to` → `stream_key` ### SeaORM conventions - Query methods go on `Entity` (e.g. `Entity::find_by_x`). - Mutation helpers that intercept save logic (e.g. setting timestamps, pre-save transforms) go on `ActiveModel`. - For destructive schema changes in prod, use expand-contract: add new structure → backfill → switch app code → drop old structure in a later migration. **Test page:** `index.html` — open in a browser to view the stream via WHEP without any extra tooling.