Compare commits
35
Commits
2f0a02131e
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9bd890d16
|
||
|
|
21a20b898c
|
||
|
|
6ac7e60a34
|
||
|
|
94daa39d87
|
||
|
|
c4a7e622b7
|
||
|
|
98dbb3d36d
|
||
|
|
0a039a97c4
|
||
|
|
f855e0e471
|
||
|
|
72cfb26e38
|
||
|
|
80368b3ea5
|
||
|
|
0c6705badd
|
||
|
|
9960434c1c
|
||
|
|
c58ca27490
|
||
|
|
98febe4bf3
|
||
|
|
f0a40efc25
|
||
|
|
7d9b3b6285
|
||
|
|
aa1d4e8f67
|
||
|
|
e00541c07d
|
||
|
|
3287a0ed7c
|
||
|
|
5f152e9db7
|
||
|
|
c1435a70fb
|
||
|
|
d4b77bd717
|
||
|
|
4bb103e9b2
|
||
|
|
fd1e59267d
|
||
|
|
a2c74a6e81
|
||
|
|
ad997d3b65
|
||
|
|
7262a06f28
|
||
|
|
ce064ba9c8
|
||
|
|
6168593755
|
||
|
|
4dfc33ca92
|
||
|
|
571125c5bf
|
||
|
|
1c38daeb51
|
||
|
|
aeaed9f670
|
||
|
|
de622816cc
|
||
|
|
76c051145f
|
@@ -0,0 +1,642 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
This file provides comprehensive guidance for AI agents working with this repository.
|
||||||
|
It is the canonical reference; CLAUDE.md may be a subset of this.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Summary
|
||||||
|
|
||||||
|
**RTMP-to-WHIP/WHEP bridge.** Accepts RTMP video+audio publish streams and re-streams to
|
||||||
|
browsers via WebRTC (WHEP). Also has a stub for WHIP ingest.
|
||||||
|
|
||||||
|
**Binary name:** `rtmp-to-whip`
|
||||||
|
**Workspace:** Rust 2024 edition, 3 crates (`server`, `entity`, `migration`)
|
||||||
|
**No automated tests exist** (except one ignored FLV replay test).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build (all crates)
|
||||||
|
cargo build
|
||||||
|
|
||||||
|
# Run (server binary)
|
||||||
|
cargo run
|
||||||
|
|
||||||
|
# Build (Nix)
|
||||||
|
nix build
|
||||||
|
|
||||||
|
# Dev shell (provides clang + mold linker + sea-orm-cli + opus + just)
|
||||||
|
nix develop
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workspace Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
crates/
|
||||||
|
server/ — main binary (RTMP, HTTP, WebRTC, codecs, audio)
|
||||||
|
entity/ — SeaORM entity definitions (users, stream_key, stream_session, auth_session)
|
||||||
|
migration/ — SeaORM migrations (4 files)
|
||||||
|
target/ — build output (gitignored)
|
||||||
|
db/ — SQLite database at runtime (db/db.sqlite, gitignored)
|
||||||
|
index.html — WHEP test page (browser-based viewer)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ports & Networking
|
||||||
|
|
||||||
|
| Port | Protocol | Purpose |
|
||||||
|
|-------|----------|----------------------------------|
|
||||||
|
| 1935 | TCP | RTMP ingest (OBS/encoder) |
|
||||||
|
| 3000 | TCP | HTTP API (axum) |
|
||||||
|
| 6969 | UDP | WebRTC media (configurable via RTC_PORT env) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|-----------------|---------|------------------------------------------------|
|
||||||
|
| `RTC_PORT` | `6969` | UDP port for WebRTC media traffic |
|
||||||
|
| `PUBLIC_DOMAIN` | — | Domain for ICE candidates (DNS-resolved); falls back to STUN discovery if unset |
|
||||||
|
| `SIGNUP_CODE` | — | Required token for `/api/user` signup; empty = disabled |
|
||||||
|
| `RUST_LOG` | — | Tracing filter (e.g. `info,warn`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entry Point — `crates/server/src/main.rs`
|
||||||
|
|
||||||
|
Initializes all components, spawns 4 workers in a `JoinSet`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
workers.spawn(http.run());
|
||||||
|
workers.spawn(proxy.run());
|
||||||
|
workers.spawn(webrtc.run());
|
||||||
|
workers.spawn(rtmp.run());
|
||||||
|
```
|
||||||
|
|
||||||
|
Shuts down on Ctrl-C or any worker exit (`workers.abort_all()`).
|
||||||
|
|
||||||
|
**`AppState`** — `Arc<Mutex<AppState>>` wrapping `DashMap<i32, StreamSession>`.
|
||||||
|
Keyed by `stream_key.id`.
|
||||||
|
|
||||||
|
**`StreamSession`** fields:
|
||||||
|
- `stream_key_id: i32`
|
||||||
|
- `stream_key_label: String`
|
||||||
|
- `frame_channel: async_broadcast::Sender<Arc<VideoFrame>>`
|
||||||
|
- `audio_channel: async_broadcast::Sender<Arc<OpusAudioFrame>>`
|
||||||
|
- `codec: Option<StreamCodec>` — `H264 | H265 | AV1`
|
||||||
|
- `started_at: DateTime<Utc>`
|
||||||
|
- `viewers: AtomicU32`
|
||||||
|
|
||||||
|
**Shared channels:**
|
||||||
|
- `offer_tx/offer_rx: mpsc::channel<(i32, i32, String)>(64)` — HTTP → WebRTC (request_id, stream_key_id, sdp_offer)
|
||||||
|
- `answer_tx/answer_rx: broadcast::<(i32, Option<String>)>(64)` — WebRTC → HTTP (request_id, answer_or_none)
|
||||||
|
|
||||||
|
**DB:** `sqlite://./db/db.sqlite?mode=rwc`. Migrations run at startup via `Migrator::up`.
|
||||||
|
`stream_session::Model::clean_unended_streams()` repairs crash-residual sessions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture — Signal Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
OBS/encoder → RTMP (port 1935) → H264Parser / H265Parser / Av1Parser → async_broadcast frame channel
|
||||||
|
↓
|
||||||
|
Browser ← WebRTC/UDP ← str0m Rtc ← WHEP HTTP POST /api/stream/{slug}
|
||||||
|
↑
|
||||||
|
WebrtcProxy (UDP port from RTC_PORT env, default 6969)
|
||||||
|
|
||||||
|
WHIP ingest: Browser/encoder → WHIP POST /api/whip → (stub: parses SDP, logs media)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RTMP Ingestion — `crates/server/src/rtmp.rs`
|
||||||
|
|
||||||
|
`pub struct Rtmp { listener, stream_sessions, db }`
|
||||||
|
|
||||||
|
`async fn run(self)` — accepts TCP connections on port 1935, spawns one task per connection:
|
||||||
|
|
||||||
|
1. **Handshake** — `rml_rtmp::Handshake` (C0+C1 → S0+S1+S2 → C2), 30s timeout.
|
||||||
|
2. **Session setup** — `rml_rtmp::ServerSession`.
|
||||||
|
3. **Event loop** — 4096-byte reads, 15s idle timeout:
|
||||||
|
- `ConnectionRequested` → accepted unconditionally.
|
||||||
|
- `PublishStreamRequested` → DB lookup of stream key; rejects if not found or already live.
|
||||||
|
Inserts `StreamSession` into `AppState` + DB row.
|
||||||
|
- `VideoDataReceived` → dispatched to `CodecParser::parse` (H.264, H.265, or AV1).
|
||||||
|
Frame broadcast on `frame_channel`.
|
||||||
|
- `AudioDataReceived` → `AACParser` decodes AAC → PCM, `AudioProcesser` resamples + encodes Opus.
|
||||||
|
`OpusAudioFrame` broadcast on `audio_channel`.
|
||||||
|
- `PublishStreamFinished` → removes `StreamSession` from `AppState`, sets `ended_at` in DB.
|
||||||
|
|
||||||
|
**Codec detection** (`parse_video_codec`):
|
||||||
|
- Enhanced RTMP (byte 0 bit 7 = 1): bytes 1–4 = FourCC (`hvc1`→H.265, `avc1`→H.264, `av01`→AV1).
|
||||||
|
- Legacy RTMP (byte 0 bit 7 = 0): nibble `& 0x0F == 7` → H.264.
|
||||||
|
- `StreamSession.codec` is stamped on first `VideoDataReceived` and never changed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HTTP API — `crates/server/src/http.rs`
|
||||||
|
|
||||||
|
`pub struct HttpServer` fields: `offer_tx`, `accept_rx`, `appstate`, `request_count: AtomicI32`, `db`, `config`.
|
||||||
|
|
||||||
|
Serves on `0.0.0.0:3000` with CORS for `http://localhost:5173` and `https://stream.h.doloro.co.uk`.
|
||||||
|
|
||||||
|
### Routes
|
||||||
|
|
||||||
|
| Method | Path | Handler | Auth? | Notes |
|
||||||
|
|--------|-------------------------|--------------------------------|-------|-------------------------------------------------|
|
||||||
|
| GET | `/api/catalog` | `catalog_handler` | No | Returns `{ active_streams: [...] }` from DB + AppState |
|
||||||
|
| POST | `/api/user` | `create_user_handler` | No | Signup; requires `SIGNUP_CODE` header |
|
||||||
|
| POST | `/api/login` | `login_handler` | No | Verifies password, creates auth_session, sets cookie |
|
||||||
|
| GET | `/api/stream-key` | `get_all_stream_keys` | Yes | Returns user's stream keys |
|
||||||
|
| POST | `/api/stream-key` | `create_stream_key_handler` | Yes | Creates key with UUID value (`stream-key-<uuid>`) |
|
||||||
|
| PATCH | `/api/stream-key` | `edit_stream_key` | Yes | Edits label (validated by `KEY_RE` regex) |
|
||||||
|
| POST | `/api/whip` | `handle_whip_injest` | No | **Stub** — parses SDP, logs media lines |
|
||||||
|
| POST | `/api/stream/{slug}` | `stream_handler` | No | WHEP offer; forwards to WebRTC, waits for answer |
|
||||||
|
| GET | `/api/meow` | `meow_handler` | No | Returns `"meow"` |
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
|
||||||
|
- Session token sent as `session` cookie or header.
|
||||||
|
- `AuthUser` extractor: looks up `auth_session` by token value, then joins to `users`.
|
||||||
|
- Protected routes require `AuthUser`.
|
||||||
|
- Cookie flags: `HttpOnly`, `Path=/`, `SameSite=None` (dev) or `Lax` (prod).
|
||||||
|
- `DevFlag` extractor: reads `?dev=1` query param.
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
- Username: max 32 chars, non-empty.
|
||||||
|
- Stream key label: 1–64 chars, regex `^[A-Za-z0-9 _-]{1,67}$`, must contain at least one letter.
|
||||||
|
- User has `stream_key_limit` (default 3) enforced at creation time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WebRTC Proxy — `crates/server/src/webrtc_proxy.rs`
|
||||||
|
|
||||||
|
`pub struct WebrtcProxy` — all fields `Arc`-wrapped, `Clone`-able.
|
||||||
|
|
||||||
|
- `clients_ufrag: DashMap<String, mpsc::Sender<(Bytes, SocketAddr)>>` — pending ICE ufrag → per-client channel
|
||||||
|
- `clients_addr: DashMap<SocketAddr, mpsc::Sender<(Bytes, SocketAddr)>>` — established addr → per-client channel
|
||||||
|
- `socket: Arc<UdpSocket>` — shared UDP socket bound to `0.0.0.0:{RTC_PORT}`
|
||||||
|
- `public_addr: SocketAddr` — from `PUBLIC_DOMAIN` DNS or STUN discovery
|
||||||
|
|
||||||
|
**`async fn run(self)`** — UDP receive loop:
|
||||||
|
1. Receives datagrams on shared socket.
|
||||||
|
2. If source addr in `clients_addr`, forwards to that client.
|
||||||
|
3. Otherwise parses STUN binding request to extract ufrag (username attribute, split on `:`),
|
||||||
|
looks up `clients_ufrag`, promotes to `clients_addr`, forwards.
|
||||||
|
|
||||||
|
**`fn add_client(ufrag)`** — called by Webrtc when setting up peer connection.
|
||||||
|
Returns `(Arc<UdpSocket>, Receiver<(Bytes, SocketAddr)>)`.
|
||||||
|
|
||||||
|
**Public IP discovery:**
|
||||||
|
- If `PUBLIC_DOMAIN` env set: DNS-resolve for IPv4.
|
||||||
|
- Else if `cfg!(debug_assertions)`: returns `127.0.0.1` (for testing).
|
||||||
|
- Else: sends STUN Binding Request to `stun.l.google.com:19302`, parses XOR-MAPPED-ADDRESS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WebRTC Negotiation — `crates/server/src/webrtc.rs`
|
||||||
|
|
||||||
|
`pub struct Webrtc { offer_rx, accept_tx, sessions_ref, db, proxy }`
|
||||||
|
|
||||||
|
`async fn run(mut self)` — receives `(request_id, stream_id, sdp_body)` from `offer_rx`:
|
||||||
|
|
||||||
|
1. Looks up `stream_key` in DB; rejects if not found.
|
||||||
|
2. Gets codec from `StreamSession.codec` (determines which str0m codec config to enable).
|
||||||
|
3. Configures str0m `Rtc::builder`:
|
||||||
|
- Opus always enabled.
|
||||||
|
- H.265: `cc.enable_h265(true)`.
|
||||||
|
- AV1: `cc.enable_av1(true)`.
|
||||||
|
- H.264 (default): adds 3 profiles — Constrained Baseline (0x42e01f), Main (0x4d001f), High (0x64001f).
|
||||||
|
Firefox only offers Constrained Baseline — without it, no codec matches.
|
||||||
|
4. Accepts SDP offer → produces SDP answer.
|
||||||
|
5. Validates answer has non-empty video PT list (str0m returns empty list if no codec matched).
|
||||||
|
6. Extracts ICE ufrag from answer, registers with proxy.
|
||||||
|
7. Broadcasts answer on `accept_tx`.
|
||||||
|
8. Spawns `detach_connection` per peer.
|
||||||
|
|
||||||
|
**`detach_connection`** per-peer loop:
|
||||||
|
- Drains `rtc.poll_output()` — transmits via proxy UDP, handles events.
|
||||||
|
- `Event::MediaAdded` — selects best video PT by profile_level_id (H.264),
|
||||||
|
h265_profile_tier_level (H.265), or level_idx (AV1).
|
||||||
|
- `Event::Connected` — starts subscribing to frame/audio channels from AppState.
|
||||||
|
- `tokio::select!` on: str0m deadline, incoming UDP from proxy, video frame, audio frame.
|
||||||
|
- **Keyframe gating:** drops non-keyframe frames until first keyframe is seen.
|
||||||
|
- **Overflow handling:** on ring buffer overflow, resets `saw_keyframe` to wait for next keyframe.
|
||||||
|
- Video RTP timestamp: `MediaTime::from_90khz(ts * 90)` where `ts` is VideoFrame timestamp in ms.
|
||||||
|
- Audio RTP timestamp: `MediaTime::new(ts * 48, Frequency::FORTY_EIGHT_KHZ)`.
|
||||||
|
- Drains up to 7 pending frames per tick to catch up.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Codecs — `crates/server/src/codec/`
|
||||||
|
|
||||||
|
### Trait — `CodecParser`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait CodecParser: Send {
|
||||||
|
fn parse(&mut self, data: &[u8], timestamp_ms: u32) -> Option<VideoFrame>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `VideoFrame`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct VideoFrame {
|
||||||
|
pub data: Bytes, // Annex-B elementary stream
|
||||||
|
pub is_keyframe: bool,
|
||||||
|
pub timestamp_ms: u32, // Presentation timestamp (DTS + CTS for B-frame streams)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### H.264 — `h264.rs`
|
||||||
|
|
||||||
|
Handles both legacy and enhanced RTMP.
|
||||||
|
|
||||||
|
**Legacy RTMP:** byte 0 = `(frame_type << 4) | codec_id`, byte 1 = packet type, bytes 2–4 = CTS.
|
||||||
|
**Enhanced RTMP:** byte 0 = `0x80 | (frame_type << 4) | packet_type`, bytes 1–4 = FourCC.
|
||||||
|
|
||||||
|
- Packet type `0`: parses `AVCDecoderConfigurationRecord`, caches SPS+PPS.
|
||||||
|
- Packet type `1`: AVCC → Annex-B conversion, prepends SPS+PPS before keyframes.
|
||||||
|
Uses `PTS = DTS + CTS` to avoid B-frame stuttering.
|
||||||
|
- Packet type `3`: same as type 1 but no CTS field.
|
||||||
|
|
||||||
|
### H.265 — `h265.rs`
|
||||||
|
|
||||||
|
Enhanced RTMP only (FourCC `hvc1`).
|
||||||
|
|
||||||
|
- Packet type `0`: bytes[5..] = `HEVCDecoderConfigurationRecord`.
|
||||||
|
- Packet type `1`: bytes 5–7 = CTS, bytes 8+ = HVCC NALUs. Uses `PTS = DTS + CTS`.
|
||||||
|
- Packet type `3`: bytes 5+ = HVCC NALUs, no CTS.
|
||||||
|
|
||||||
|
`HEVCDecoderConfigurationRecord` parsing: skips first 22 bytes (profile/level/tier),
|
||||||
|
parses arrays at byte 22, caches VPS (NAL type 32), SPS (33), PPS (34).
|
||||||
|
|
||||||
|
### AV1 — `av1.rs`
|
||||||
|
|
||||||
|
Enhanced RTMP only (FourCC `av01`).
|
||||||
|
|
||||||
|
- Packet type `0`: bytes[5..] = `AV1CodecConfigurationRecord`, extracts `configOBUs` from `payload[4..]`.
|
||||||
|
- Packet type `1`/`3`: bytes[5..] = OBU stream.
|
||||||
|
|
||||||
|
**Keyframe detection:**
|
||||||
|
1. RTMP FrameType=1.
|
||||||
|
2. Scan OBUs for `OBU_SEQUENCE_HEADER` (obu_type=1).
|
||||||
|
3. First coded frame after config (bootstrap fallback — OBS may never set FrameType=1 for AV1).
|
||||||
|
|
||||||
|
Config OBUs prepended to keyframe payloads so str0m's Av1Packetizer sees a Sequence Header OBU.
|
||||||
|
|
||||||
|
### FLV Replay Test — `flv_replay_test.rs`
|
||||||
|
|
||||||
|
Ignored test harness for codec validation:
|
||||||
|
```bash
|
||||||
|
FLV_IN=test.flv ES_OUT=output.es cargo test -p server flv_replay -- --ignored --nocapture
|
||||||
|
```
|
||||||
|
Parses FLV video tags, runs through codec parser, dumps Annex-B elementary stream with
|
||||||
|
temporal delimiter OBUs for ffprobe validation. Checks for duplicate PTS values.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Audio — `crates/server/src/audio.rs`
|
||||||
|
|
||||||
|
### AACParser
|
||||||
|
|
||||||
|
Parses RTMP audio payloads:
|
||||||
|
- Byte 0: `>> 4 == 10` → AAC codec.
|
||||||
|
- Byte 1: `0` = AudioSpecificConfig → initializes Symphonia AAC decoder with `extra_data`.
|
||||||
|
- Byte 1: `1` = raw AAC frame → decodes via Symphonia → interleaved f32 PCM.
|
||||||
|
|
||||||
|
### AudioFrame
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct AudioFrame {
|
||||||
|
pub data: Bytes, // interleaved f32 PCM, little-endian
|
||||||
|
pub timestamp_ms: u32,
|
||||||
|
pub sample_rate: u32, // typically 44100 Hz from AAC-LC
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### AudioProcesser
|
||||||
|
|
||||||
|
AAC → Opus transcoding pipeline:
|
||||||
|
- **Resampler:** `rubato::Fft` from 44100 Hz → 48000 Hz (if needed).
|
||||||
|
- **Encoder:** `opus::Encoder` — 48kHz stereo, `LowDelay` mode.
|
||||||
|
- **Frame size:** 960 samples per channel (20ms), 1920 interleaved.
|
||||||
|
- **Timestamp:** monotonic 48kHz counter: `timestamp_ms = samples_emitted / 48` (independent of RTMP timestamps).
|
||||||
|
- **Buffer:** accumulates resampled PCM until full Opus frame, then encodes and emits `OpusAudioFrame`.
|
||||||
|
|
||||||
|
### OpusAudioFrame
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct OpusAudioFrame {
|
||||||
|
pub data: Bytes,
|
||||||
|
pub timestamp_ms: u32,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Password Hashing — `crates/server/src/hash.rs`
|
||||||
|
|
||||||
|
- `hash_password(password)` — Argon2id with random `OsRng` salt, returns PHC-format string.
|
||||||
|
- `verify_password(password, hash)` — parses PHC string, verifies with Argon2.
|
||||||
|
**Panics** if `hash` is not valid PHC format (uses `.unwrap()` on `PasswordHash::new`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling — `crates/server/src/http_error.rs`
|
||||||
|
|
||||||
|
`#[derive(Error, Debug)] pub enum HttpError { ... }`
|
||||||
|
|
||||||
|
Maps to HTTP status codes:
|
||||||
|
- `DbErr`/`Hash`/`Internal` → 500
|
||||||
|
- `NotFound` → 404
|
||||||
|
- `Unauthorized` → 401
|
||||||
|
- `Forbidden` → 403
|
||||||
|
- `Conflict` → 409
|
||||||
|
- `BadRequest` → 400
|
||||||
|
- `Unprocessable` → 422
|
||||||
|
- `NotAcceptable` → 406
|
||||||
|
|
||||||
|
4xx errors return message body; 5xx returns empty body (no internal details leaked).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WebRTC Ingest (WHIP) — `crates/server/src/webrtc_ingest.rs`
|
||||||
|
|
||||||
|
**STUB — not implemented.** Currently:
|
||||||
|
- Accepts `POST /api/whip` with SDP body.
|
||||||
|
- Parses SDP via `str0m::change::SdpOffer`.
|
||||||
|
- Logs media lines.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database (SeaORM + SQLite)
|
||||||
|
|
||||||
|
**DB file:** `./db/db.sqlite`
|
||||||
|
**Migrations:** `crates/migration/` (4 migrations)
|
||||||
|
|
||||||
|
### Tables
|
||||||
|
|
||||||
|
#### `users`
|
||||||
|
| Column | Type | Constraints |
|
||||||
|
|---------------------|----------|--------------------------|
|
||||||
|
| `id` | INTEGER | PK, autoincrement |
|
||||||
|
| `username` | TEXT | NOT NULL, UNIQUE |
|
||||||
|
| `hashed_password` | TEXT | NOT NULL |
|
||||||
|
| `stream_key_limit` | INTEGER | NOT NULL, default 3 |
|
||||||
|
|
||||||
|
Relations: `has_many` → `stream_key`, `auth_session`
|
||||||
|
|
||||||
|
Custom methods:
|
||||||
|
- `Entity::create(db, username, password_hash)` — inserts user
|
||||||
|
- `Entity::find_by_username(db, username)` — lookup
|
||||||
|
- `Entity::find_by_auth_session(db, token)` — join auth_session → user
|
||||||
|
- `ActiveModel::update_username`, `update_password`, `change_stream_key_limit`
|
||||||
|
|
||||||
|
#### `stream_key`
|
||||||
|
| Column | Type | Constraints |
|
||||||
|
|--------------|----------|--------------------------|
|
||||||
|
| `id` | INTEGER | PK, autoincrement |
|
||||||
|
| `key_value` | TEXT | NOT NULL, UNIQUE |
|
||||||
|
| `user_id` | INTEGER | NOT NULL, FK → users |
|
||||||
|
| `label` | TEXT | NOT NULL |
|
||||||
|
| `is_active` | BOOLEAN | NOT NULL, default true |
|
||||||
|
| `is_unlisted`| BOOLEAN | NOT NULL, default true |
|
||||||
|
| `created_at` | DATETIME | NOT NULL |
|
||||||
|
|
||||||
|
Relations: `belongs_to` → `users`, `has_many` → `stream_session`
|
||||||
|
|
||||||
|
Custom methods:
|
||||||
|
- `Entity::create(db, user_id, key_value, label, is_unlisted)` — generates `stream-key-<uuid>` as key_value
|
||||||
|
- `Entity::find_by_key(db, key_value)` — lookup by raw key string
|
||||||
|
- `Entity::find_by_user(db, user_id)` — list user's keys
|
||||||
|
- `ActiveModel::change_label_value`
|
||||||
|
|
||||||
|
#### `stream_session`
|
||||||
|
| Column | Type | Constraints |
|
||||||
|
|----------------|----------|--------------------------|
|
||||||
|
| `id` | INTEGER | PK, autoincrement |
|
||||||
|
| `stream_key_id`| INTEGER | NOT NULL, FK → stream_key|
|
||||||
|
| `started_at` | DATETIME | NOT NULL |
|
||||||
|
| `ended_at` | DATETIME | nullable |
|
||||||
|
|
||||||
|
Relations: `belongs_to` → `stream_key`
|
||||||
|
|
||||||
|
Custom methods:
|
||||||
|
- `Model::create_stream_session(db, stream_key_id, started_at)` — creates open session
|
||||||
|
- `Model::get_stream_session(db, id)` — get by ID
|
||||||
|
- `Model::get_all_active_sessions(db)` — all sessions where `ended_at IS NULL`
|
||||||
|
- `Model::get_active_by_stream_key_id(db, key_id)` — single active session
|
||||||
|
- `Model::clean_unended_streams(db)` — sets `ended_at = started_at` for crash recovery
|
||||||
|
- `ActiveModel::finish_stream_session(db, ended_at)` — marks session ended
|
||||||
|
|
||||||
|
#### `auth_session`
|
||||||
|
| Column | Type | Constraints |
|
||||||
|
|-----------|----------|--------------------------|
|
||||||
|
| `id` | INTEGER | PK, autoincrement |
|
||||||
|
| `id_user` | INTEGER | NOT NULL, FK → users |
|
||||||
|
| `value` | TEXT | UUID v4 string |
|
||||||
|
|
||||||
|
Relations: `belongs_to` → `users`
|
||||||
|
|
||||||
|
Custom methods:
|
||||||
|
- `Entity::create(db, user_id)` — generates UUID token, creates session
|
||||||
|
- `Entity::find_by_user_id(db, user_id)` — lookup by user
|
||||||
|
|
||||||
|
### Conventions
|
||||||
|
- Query methods on `Entity` (e.g. `Entity::find_by_x`).
|
||||||
|
- Mutation helpers on `ActiveModel`.
|
||||||
|
- For destructive schema changes: expand-contract pattern (add → backfill → switch code → drop old).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### Server (`crates/server/Cargo.toml`)
|
||||||
|
|
||||||
|
| Crate | Version | Purpose |
|
||||||
|
|---------------|---------|-----------------------------|
|
||||||
|
| `tokio` | 1 | Async runtime (full) |
|
||||||
|
| `str0m` | 0.20.0 | WebRTC |
|
||||||
|
| `rml_rtmp` | 0.8.0 | RTMP server |
|
||||||
|
| `axum` | 0.8 | HTTP framework |
|
||||||
|
| `axum-extra` | 0.12.6 | Cookie extraction |
|
||||||
|
| `sea-orm` | 1 | ORM (SQLite, tokio-rustls) |
|
||||||
|
| `symphonia` | 0.5 | Audio decode (AAC) |
|
||||||
|
| `opus` | 0.3.1 | Audio encode (Opus) |
|
||||||
|
| `rubato` | 3.0.0 | Audio resampling |
|
||||||
|
| `argon2` | 0.5.3 | Password hashing |
|
||||||
|
| `uuid` | 1.23.3 | UUID v4 |
|
||||||
|
| `dashmap` | 6.2.1 | Concurrent map |
|
||||||
|
| `async-broadcast` | 0.7.2 | Ring buffer channels |
|
||||||
|
| `tower-http` | 0.6 | CORS |
|
||||||
|
| `regex` | 1 | Stream key label validation |
|
||||||
|
| `thiserror` | 2.0.18 | Error types |
|
||||||
|
| `tracing` | 0.1 | Structured logging |
|
||||||
|
| `chrono` | 0.4.45 | Date/time |
|
||||||
|
| `bytes` | 1 | Byte buffers |
|
||||||
|
| `futures` | 0.3.32 | async block_on |
|
||||||
|
| `serde` | 1.0.228 | Serialization |
|
||||||
|
|
||||||
|
### Entity (`crates/entity/Cargo.toml`)
|
||||||
|
|
||||||
|
| Crate | Version | Purpose |
|
||||||
|
|---------------|---------|----------------|
|
||||||
|
| `sea-orm` | 1 | ORM macros |
|
||||||
|
| `serde` | 1 | Serialization |
|
||||||
|
| `chrono` | 0.4 | DateTime |
|
||||||
|
| `rand` | 0.10.1 | Random salts |
|
||||||
|
| `argon2` | 0.5.3 | Password hash |
|
||||||
|
| `uuid` | 1.23.3 | UUID v4 |
|
||||||
|
|
||||||
|
### Migration (`crates/migration/Cargo.toml`)
|
||||||
|
|
||||||
|
| Crate | Version | Purpose |
|
||||||
|
|--------------------|---------|----------------|
|
||||||
|
| `sea-orm-migration`| 1 | Migration CLI |
|
||||||
|
| `tokio` | 1 | Async runtime |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build & Release
|
||||||
|
|
||||||
|
### Profiles (root `Cargo.toml`)
|
||||||
|
|
||||||
|
- **dev:** debug = true
|
||||||
|
- **release:** lto = true, codegen-units = 1, panic = "abort"
|
||||||
|
- **flamegraph:** inherits release + debug + force-frame-pointers
|
||||||
|
|
||||||
|
### Cross-compilation
|
||||||
|
|
||||||
|
- x86_64: clang linker + mold (via rustflags).
|
||||||
|
- aarch64: cross-toolchain (`aarch64-linux-gnu-gcc`), no mold.
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
- x86_64: multi-stage, strips binary, copies only needed .so deps.
|
||||||
|
- aarch64: QEMU cross-build from x86_64 builder, copies arm64 .so deps.
|
||||||
|
- Both use `scratch` final stage.
|
||||||
|
- Exposes: 1935/tcp, 3000/tcp, 6969/udp.
|
||||||
|
|
||||||
|
### Nix
|
||||||
|
|
||||||
|
- Uses `crane` for cargo builds.
|
||||||
|
- Dev shell: clang, mold, sea-orm-cli, cmake, opus, pkgconf, just.
|
||||||
|
- ADMIN_REF_CODE set in dev shell: `meowmeowpurrrmeow`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WHEP Test Page — `index.html`
|
||||||
|
|
||||||
|
- Connects to `http://localhost:5000/whep/test` (note: hardcoded to port 5000, not 3000).
|
||||||
|
- Creates RTCPeerConnection with video recvonly transceiver.
|
||||||
|
- Sends WHEP offer POST, receives SDP answer.
|
||||||
|
- Streams video to `<video>` element.
|
||||||
|
- Polls `pc.getStats()` every 500ms, displays:
|
||||||
|
- Server-to-client latency (network + jitter + decode)
|
||||||
|
- Network one-way RTT (from ICE candidate pair)
|
||||||
|
- Jitter buffer delay
|
||||||
|
- Decode latency
|
||||||
|
- FPS
|
||||||
|
- Packet loss ratio
|
||||||
|
- Jitter
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Implementation Details & Gotchas
|
||||||
|
|
||||||
|
1. **H.264 PT negotiation:** str0m uses `profile_level_id` to match H.264 profiles. Firefox only offers Constrained Baseline (0x42e01f). All three profiles must be configured or Firefox sees no codec.
|
||||||
|
|
||||||
|
2. **PTS vs DTS:** For both H.264 and H.265, presentation timestamp = DTS + CTS. B-frame streams stutter if DTS is used.
|
||||||
|
|
||||||
|
3. **AV1 keyframe detection:** OBS may never set RTMP FrameType=1 for AV1. The parser uses triple fallback: RTMP flag, OBU scan, first-frame bootstrap.
|
||||||
|
|
||||||
|
4. **H.265 support marked "very shity" (sic):** See `StreamCodec` enum doc comment in `main.rs:56`.
|
||||||
|
|
||||||
|
5. **No auth on WHEP endpoint:** `/api/stream/{slug}` is publicly accessible — any client who knows the stream key slug can get a WHEP answer.
|
||||||
|
|
||||||
|
6. **Request ID tracking:** `AtomicI32::fetch_add(1, Relaxed)` for WHEP request IDs. Monotonic, wraps at i32::MAX.
|
||||||
|
|
||||||
|
7. **Channel sizes:** broadcast channels (frame/audio) = 32, offer channel = 64, answer channel = 64, proxy client channel = 256.
|
||||||
|
|
||||||
|
8. **RTMP 4096-byte read buffer:** This is a fixed-size buffer. Large RTMP messages are split across multiple reads — `rml_rtmp` handles reassembly internally.
|
||||||
|
|
||||||
|
9. **Audio timestamp independence:** Opus RTP timestamps use a monotonic 48kHz counter, not RTMP timestamps. This ensures stable playback even if source timestamps are irregular.
|
||||||
|
|
||||||
|
10. **Stream session cleanup:** On any disconnect (error, timeout, finish), the cleanup closure removes from `AppState` and sets `ended_at` in DB. The `clean_unended_streams` at startup repairs crash-residual sessions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## README.md Todo List
|
||||||
|
|
||||||
|
Completed:
|
||||||
|
- [X] axum cookie jar support
|
||||||
|
- [X] H.265 codec support
|
||||||
|
- [X] AV1 codec support
|
||||||
|
- [X] Custom error types (`thiserror` + `IntoResponse`)
|
||||||
|
|
||||||
|
Incomplete:
|
||||||
|
- [ ] Stream status via RTC data channel (frame drops, codec errors)
|
||||||
|
- [ ] WHEP → WHIP support
|
||||||
|
- [ ] Replace `async_broadcast` with circular buffer for frame → RTC management
|
||||||
|
|
||||||
|
QoL:
|
||||||
|
- [ ] Stream key renaming (partially implemented via `edit_stream_key`)
|
||||||
|
- [ ] Admin panel (username lookup, disable stream keys, change limits, CPU usage)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Index
|
||||||
|
|
||||||
|
### Server source (`crates/server/src/`)
|
||||||
|
- `main.rs` — entry point, AppState, StreamSession, worker spawning
|
||||||
|
- `http.rs` — HTTP server, all route handlers, auth extractors
|
||||||
|
- `http_error.rs` — HttpError enum + IntoResponse impl
|
||||||
|
- `rtmp.rs` — RTMP listener, handshake, codec dispatch, frame broadcasting
|
||||||
|
- `webrtc.rs` — WebRTC negotiation, str0m Rtc, RTP sending, keyframe gating
|
||||||
|
- `webrtc_proxy.rs` — UDP proxy, STUN parsing, public IP discovery
|
||||||
|
- `webrtc_ingest.rs` — WHIP stub handler
|
||||||
|
- `audio.rs` — AACParser, AudioProcesser (resample + Opus encode)
|
||||||
|
- `hash.rs` — Argon2id password hashing
|
||||||
|
- `codec/mod.rs` — CodecParser trait, VideoFrame struct
|
||||||
|
- `codec/h264.rs` — H.264 AVCC/legacy → Annex-B
|
||||||
|
- `codec/h265.rs` — H.265 HVCC → Annex-B (enhanced RTMP only)
|
||||||
|
- `codec/av1.rs` — AV1 OBU parsing (enhanced RTMP only)
|
||||||
|
- `codec/flv_replay_test.rs` — ignored test for codec validation
|
||||||
|
|
||||||
|
### Entity source (`crates/entity/src/`)
|
||||||
|
- `lib.rs` — module exports
|
||||||
|
- `prelude.rs` — entity type aliases
|
||||||
|
- `users.rs` — User entity + methods
|
||||||
|
- `stream_key.rs` — StreamKey entity + methods
|
||||||
|
- `stream_session.rs` — StreamSession entity + methods
|
||||||
|
- `auth_session.rs` — AuthSession entity + methods
|
||||||
|
|
||||||
|
### Migration source (`crates/migration/src/`)
|
||||||
|
- `lib.rs` — Migrator trait, migration registry
|
||||||
|
- `main.rs` — migration binary entry
|
||||||
|
- `m20260616_000001_create_users.rs` — users table
|
||||||
|
- `m20260616_000002_create_stream_key.rs` — stream_key table
|
||||||
|
- `m20260616_000003_create_stream_session.rs` — stream_session table
|
||||||
|
- `m20260616_000004_create_auth_session.rs` — auth_session table
|
||||||
|
|
||||||
|
### Config
|
||||||
|
- `Cargo.toml` (root) — workspace, profiles
|
||||||
|
- `crates/server/Cargo.toml` — server dependencies
|
||||||
|
- `crates/entity/Cargo.toml` — entity dependencies
|
||||||
|
- `crates/migration/Cargo.toml` — migration dependencies
|
||||||
|
- `Dockerfile` — x86_64 multi-stage build
|
||||||
|
- `Dockerfile.aarch64` — arm64 cross-build
|
||||||
|
- `compose.yaml` — docker-compose deployment
|
||||||
|
- `flake.nix` — Nix build + dev shell
|
||||||
|
- `Justfile` — Docker build/push shortcuts
|
||||||
|
- `.gitignore` — target, db, devenv files
|
||||||
Generated
+375
-376
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -43,6 +43,6 @@ EXPOSE 1935
|
|||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
EXPOSE 6969/udp
|
EXPOSE 6969/udp
|
||||||
|
|
||||||
ENV RUST_LOG="info,warn"
|
ENV RUST_LOG="info"
|
||||||
|
|
||||||
CMD ["/rtmp-to-whip"]
|
CMD ["/rtmp-to-whip"]
|
||||||
|
|||||||
@@ -55,4 +55,6 @@ EXPOSE 1935
|
|||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
EXPOSE 6969/udp
|
EXPOSE 6969/udp
|
||||||
|
|
||||||
|
ENV RUST_LOG="info"
|
||||||
|
|
||||||
CMD ["/rtmp-to-whip"]
|
CMD ["/rtmp-to-whip"]
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
# Simple Instant Stream
|
# Simple Instant Stream
|
||||||
|
|
||||||
- [ ] axum cookie jar support (manual set-cookie headers isnt yum)
|
- [X] axum cookie jar support (manual set-cookie headers isnt yum)
|
||||||
- [X] more codecs
|
- [X] more codecs
|
||||||
- [X] h265
|
- [X] h265
|
||||||
- [X] av1
|
- [X] av1
|
||||||
- [ ] Stream status rtc data channel (i.e, 'you're dropping frames! ,, 'wrong codec!') which is shown in the stream player page
|
- [ ] Stream status rtc data channel (i.e, 'you're dropping frames! ,, 'wrong codec!') which is shown in the stream player page
|
||||||
- [ ] Whep -> Whip support
|
- [ ] Whep -> Whip support
|
||||||
- [ ] Write customs error types for everything that touches HTTP for [thiserror](https://docs.rs/thiserror/latest/thiserror/) and make it impl axum's IntoReponce
|
- [X] Write customs error types for everything that touches HTTP for [thiserror](https://docs.rs/thiserror/latest/thiserror/) and make it impl axum's IntoReponce
|
||||||
- [ ] HTTP
|
- [X] HTTP
|
||||||
- [ ] WebRTC offers
|
- [X] WebRTC offers
|
||||||
- [ ] DB actions
|
- [X] DB actions
|
||||||
- [ ] (optional) Replace frame -> rtc management with something other then async broadcast (circular buffer with frame data)
|
- [ ] (optional) Replace frame -> rtc management with something other then async broadcast (circular buffer with frame data)
|
||||||
|
|
||||||
## Qol
|
## Qol
|
||||||
@@ -20,7 +20,3 @@
|
|||||||
- [ ] Disabling Stream keys
|
- [ ] Disabling Stream keys
|
||||||
- [ ] Change stream key limit manually
|
- [ ] Change stream key limit manually
|
||||||
- [ ] See server cpu usage
|
- [ ] See server cpu usage
|
||||||
|
|
||||||
## Bugs
|
|
||||||
|
|
||||||
- [ ] Streams never timeout and remove their stream session entity
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# TODO
|
||||||
|
|
||||||
|
Audit findings from ponytail-audit (ranked biggest cut first). Net: ~-300 lines, -5 deps possible.
|
||||||
|
|
||||||
|
## Deletions
|
||||||
|
|
||||||
|
- [ ] Delete `Dockerfile.aarch64` — merge into `Dockerfile` with `ARG TARGETARCH` (buildx sets it); both files are 90% identical except the dep-copy block. [Dockerfile.aarch64]
|
||||||
|
- [ ] Delete entity dead methods: `update_username`, `update_password`, `change_stream_key_limit`, `find_by_user_id`, `get_stream_session`, `get_all_active_sessions`. Zero callers; scaffolding for the unbuilt admin panel. [crates/entity/src/users.rs:77, crates/entity/src/auth_session.rs:43, crates/entity/src/stream_session.rs:53]
|
||||||
|
- [ ] Delete `/api/health`, `/api/uptime`, `/api/version` + `HealthResponse`/`UptimeResponse` — all subsets of `/api/stats`; also kills `serde_json` (only used by `version_handler`). [crates/server/src/http.rs:497]
|
||||||
|
- [ ] Delete `SessionCookie` extractor — its value is only debug-logged, never used; kill the extractor + 2 logs. [crates/server/src/http.rs:213]
|
||||||
|
- [ ] Delete `WebrtcProxy::local_addr()` (no callers), dead `let _ = x_port ^ …` stmt, and single-field `WebRtcProxyConfig` struct → pass `u16` (also fixes i32 port type). [crates/server/src/webrtc_proxy.rs:167, crates/server/src/webrtc_proxy.rs:139]
|
||||||
|
- [ ] Delete `webrtc.rs` `mid` binding from `add_media` + `_hint_mid` param — passed straight into an underscore. [crates/server/src/webrtc.rs:142]
|
||||||
|
- [ ] Delete `stream_key` `is_active`/`is_unlisted` columns — never read anywhere; `create()` hardcodes `is_active=false` and `is_unlisted` is always passed `false`. Drop param + column (migration, expand-contract). [crates/entity/src/stream_key.rs:45]
|
||||||
|
- [ ] Delete `rtmp.rs` redundant `stream_id` var (warn can use `current_stream_key_id`), `warn!("")` empty-log on parse error, and `parse_video_codec`'s `Result<_, Box<dyn Error>>` → `Option`. [crates/server/src/rtmp.rs:206, crates/server/src/rtmp.rs:369]
|
||||||
|
- [ ] Delete `catalog_handler`'s `get_all_active_sessions` query — result only feeds a `debug!`; kills the entity method too. [crates/server/src/http.rs:185]
|
||||||
|
- [ ] Delete commented-out routes/code (`admin/server_stats`, duplicate whip route, `.max_age`, `fs::File`) + `#[axum::debug_handler]`. [crates/server/src/http.rs:109]
|
||||||
|
- [ ] Delete `StreamSession.active_clients` — written 0, never read; drops `AtomicU32` import. [crates/server/src/main.rs:65]
|
||||||
|
- [ ] Delete `webrtc_ingest` `_connected` flag — set true, never read. [crates/server/src/webrtc_ingest.rs:363]
|
||||||
|
- [ ] Delete `users.rs` `let user = …; user` pointless binding in `find_by_username`. [crates/entity/src/users.rs:70]
|
||||||
|
- [ ] Delete `meow_handler` + route — joke endpoint, zero consumers. [crates/server/src/http.rs:492]
|
||||||
|
- [ ] Delete `index.html` `prevStats` — assigned, never read. [index.html:57]
|
||||||
|
- [ ] Delete deps — server: `futures`, `rand`; entity: `rand`, `argon2` (hashing lives in server crate now); `serde_json` (with health/uptime/version cut). [crates/server/Cargo.toml, crates/entity/Cargo.toml]
|
||||||
|
|
||||||
|
## Shrinks
|
||||||
|
|
||||||
|
- [ ] Extract shared "remove session + finish_stream_session" fn — closure duplicated 3× (rtmp.rs cleanup, webrtc_ingest detach cleanup, whip DELETE handler). Also lets `handle_whip_injest` drop its manual `err()` closure for `Result<HttpError>` like its siblings. [crates/server/src/rtmp.rs:141, crates/server/src/webrtc_ingest.rs:338, crates/server/src/webrtc_ingest.rs:34]
|
||||||
|
- [ ] Extract `fixup_answer_sdp()` — `whip_sdp_probe.rs` re-encodes the SDP string-fixups verbatim; probe drops ~30 lines. [crates/server/tests/whip_sdp_probe.rs:66]
|
||||||
|
- [ ] Derive `thiserror` on `AudioParseError` instead of hand-rolled `Display`+`Error` impls (~15 lines). [crates/server/src/audio.rs:66]
|
||||||
|
- [ ] Collapse `webrtc.rs` four near-identical channel re-subscribe blocks (`is_none`/`is_closed` × video/audio) → one helper. [crates/server/src/webrtc.rs:336]
|
||||||
|
|
||||||
|
## Out of scope (correctness — route to normal review)
|
||||||
|
|
||||||
|
- `Local::now()` vs `Utc::now()` inconsistency in session creation
|
||||||
|
- `KEY_RE` `{1,67}` vs `MAX_LABEL_LEN = 64` mismatch
|
||||||
|
- `index.html` hardcoded port 5000
|
||||||
|
- `/api/stream/{slug}` unauthenticated
|
||||||
@@ -12,6 +12,8 @@ pub struct Model {
|
|||||||
pub label: String,
|
pub label: String,
|
||||||
pub is_active: bool,
|
pub is_active: bool,
|
||||||
pub is_unlisted: bool,
|
pub is_unlisted: bool,
|
||||||
|
pub password: Option<String>,
|
||||||
|
pub custom_id: Option<String>,
|
||||||
pub created_at: DateTimeUtc,
|
pub created_at: DateTimeUtc,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,4 +80,14 @@ impl Entity {
|
|||||||
.all(db)
|
.all(db)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn find_by_custom_id(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
custom_id: String,
|
||||||
|
) -> Result<Option<Model>, DbErr> {
|
||||||
|
Entity::find()
|
||||||
|
.filter(Column::CustomId.eq(custom_id))
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,32 +50,21 @@ impl Model {
|
|||||||
.insert(db)
|
.insert(db)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
pub async fn get_stream_session(
|
|
||||||
db: &DatabaseConnection,
|
|
||||||
stream_session_id: i32,
|
|
||||||
) -> Result<Model, DbErr> {
|
|
||||||
Entity::find_by_id(stream_session_id)
|
|
||||||
.one(db)
|
|
||||||
.await?
|
|
||||||
.ok_or(DbErr::RecordNotFound(format!(
|
|
||||||
"stream_session {stream_session_id}"
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
pub async fn get_all_active_sessions(db: &DatabaseConnection) -> Result<Vec<Model>, DbErr> {
|
pub async fn get_all_active_sessions(db: &DatabaseConnection) -> Result<Vec<Model>, DbErr> {
|
||||||
Ok(Entity::find()
|
Entity::find()
|
||||||
.filter(Column::EndedAt.is_null())
|
.filter(Column::EndedAt.is_null())
|
||||||
.all(db)
|
.all(db)
|
||||||
.await?)
|
.await
|
||||||
}
|
}
|
||||||
pub async fn get_active_by_stream_key_id(
|
pub async fn get_active_by_stream_key_id(
|
||||||
db: &DatabaseConnection,
|
db: &DatabaseConnection,
|
||||||
stream_key_id: i32,
|
stream_key_id: i32,
|
||||||
) -> Result<Option<Model>, DbErr> {
|
) -> Result<Option<Model>, DbErr> {
|
||||||
Ok(Entity::find()
|
Entity::find()
|
||||||
.filter(Column::StreamKeyId.eq(stream_key_id))
|
.filter(Column::StreamKeyId.eq(stream_key_id))
|
||||||
.filter(Column::EndedAt.is_null())
|
.filter(Column::EndedAt.is_null())
|
||||||
.one(db)
|
.one(db)
|
||||||
.await?)
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn clean_unended_streams(db: &DatabaseConnection) -> Result<u64, DbErr> {
|
pub async fn clean_unended_streams(db: &DatabaseConnection) -> Result<u64, DbErr> {
|
||||||
|
|||||||
@@ -58,18 +58,18 @@ impl Entity {
|
|||||||
if let Some(x) = sessions {
|
if let Some(x) = sessions {
|
||||||
Entity::find_by_id(x.id_user).one(db).await
|
Entity::find_by_id(x.id_user).one(db).await
|
||||||
} else {
|
} else {
|
||||||
return Ok(None);
|
Ok(None)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub async fn find_by_username(
|
pub async fn find_by_username(
|
||||||
db: &DatabaseConnection,
|
db: &DatabaseConnection,
|
||||||
username: String,
|
username: String,
|
||||||
) -> Result<Option<Model>, DbErr> {
|
) -> Result<Option<Model>, DbErr> {
|
||||||
let user = Entity::find()
|
|
||||||
|
Entity::find()
|
||||||
.filter(Column::Username.eq(username))
|
.filter(Column::Username.eq(username))
|
||||||
.one(db)
|
.one(db)
|
||||||
.await;
|
.await
|
||||||
user
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ mod m20260616_000001_create_users;
|
|||||||
mod m20260616_000002_create_stream_key;
|
mod m20260616_000002_create_stream_key;
|
||||||
mod m20260616_000003_create_stream_session;
|
mod m20260616_000003_create_stream_session;
|
||||||
mod m20260616_000004_create_auth_session;
|
mod m20260616_000004_create_auth_session;
|
||||||
|
mod m20260815_000005_add_password_to_stream_key;
|
||||||
|
mod m20260815_000006_set_stream_keys_unlisted;
|
||||||
|
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
@@ -15,6 +17,8 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20260616_000002_create_stream_key::Migration),
|
Box::new(m20260616_000002_create_stream_key::Migration),
|
||||||
Box::new(m20260616_000003_create_stream_session::Migration),
|
Box::new(m20260616_000003_create_stream_session::Migration),
|
||||||
Box::new(m20260616_000004_create_auth_session::Migration),
|
Box::new(m20260616_000004_create_auth_session::Migration),
|
||||||
|
Box::new(m20260815_000005_add_password_to_stream_key::Migration),
|
||||||
|
Box::new(m20260815_000006_set_stream_keys_unlisted::Migration),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ impl MigrationTrait for Migration {
|
|||||||
ColumnDef::new(StreamKey::IsUnlisted)
|
ColumnDef::new(StreamKey::IsUnlisted)
|
||||||
.boolean()
|
.boolean()
|
||||||
.not_null()
|
.not_null()
|
||||||
.default(true),
|
.default(false),
|
||||||
)
|
)
|
||||||
.col(ColumnDef::new(StreamKey::CreatedAt).date_time().not_null())
|
.col(ColumnDef::new(StreamKey::CreatedAt).date_time().not_null())
|
||||||
.foreign_key(
|
.foreign_key(
|
||||||
@@ -67,5 +67,7 @@ pub enum StreamKey {
|
|||||||
Label,
|
Label,
|
||||||
IsActive,
|
IsActive,
|
||||||
IsUnlisted,
|
IsUnlisted,
|
||||||
|
Password,
|
||||||
|
CustomId,
|
||||||
CreatedAt,
|
CreatedAt,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
use super::m20260616_000002_create_stream_key::StreamKey;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(StreamKey::Table)
|
||||||
|
.add_column(ColumnDef::new(StreamKey::Password).string().null())
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(StreamKey::Table)
|
||||||
|
.add_column(ColumnDef::new(StreamKey::CustomId).string().null())
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(StreamKey::Table)
|
||||||
|
.drop_column(StreamKey::Password)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(StreamKey::Table)
|
||||||
|
.drop_column(StreamKey::CustomId)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
use super::m20260616_000002_create_stream_key::StreamKey;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
// By accident the is_unlisted column defaulted to true; existing stream
|
||||||
|
// keys were meant to be listed. Reset all rows to false here.
|
||||||
|
manager
|
||||||
|
.exec_stmt(
|
||||||
|
Query::update()
|
||||||
|
.table(StreamKey::Table)
|
||||||
|
.values([(StreamKey::IsUnlisted, false.into())])
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "server"
|
name = "server"
|
||||||
version = "0.2.0"
|
version = "0.6.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[target.x86_64-unknown-linux-gnu]
|
[target.x86_64-unknown-linux-gnu]
|
||||||
@@ -15,23 +15,25 @@ path = "src/main.rs"
|
|||||||
async-broadcast = "0.7.2"
|
async-broadcast = "0.7.2"
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
dashmap = "6.2.1"
|
dashmap = "6.2.1"
|
||||||
rand = "0.10.1"
|
|
||||||
rml_rtmp = "0.8.0"
|
rml_rtmp = "0.8.0"
|
||||||
str0m = "0.20.0"
|
str0m = "0.20.0"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
axum = "0.8"
|
axum = { version = "0.8", features = ["macros"] }
|
||||||
serde = { version = "1.0.228", features = ["serde_derive"] }
|
serde = { version = "1.0.228", features = ["serde_derive"] }
|
||||||
serde_json = "1.0.150"
|
|
||||||
sea-orm = { version = "1", features = [ "sqlx-sqlite", "runtime-tokio-rustls", "macros" ] }
|
sea-orm = { version = "1", features = [ "sqlx-sqlite", "runtime-tokio-rustls", "macros" ] }
|
||||||
entity = {path = "../entity"}
|
entity = {path = "../entity"}
|
||||||
migration = {path = "../migration"}
|
migration = {path = "../migration"}
|
||||||
argon2 = "0.5.3"
|
argon2 = "0.5.3"
|
||||||
uuid = { version = "1.23.3", features = ["v4"] }
|
uuid = { version = "1.23.3", features = ["v4"] }
|
||||||
tower-http = { version = "0.6", features = ["cors"] }
|
tower-http = { version = "0.6", features = ["cors"] }
|
||||||
futures = "0.3.32"
|
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
symphonia = { version = "0.5", features = ["aac"] }
|
symphonia = { version = "0.5", features = ["aac"] }
|
||||||
opus = "0.3.1"
|
opus = "0.3.1"
|
||||||
rubato = "3.0.0"
|
rubato = "3.0.0"
|
||||||
chrono = "0.4.45"
|
chrono = "0.4.45"
|
||||||
|
time = "0.3"
|
||||||
|
thiserror = "2.0.18"
|
||||||
|
sysinfo = "0.36"
|
||||||
|
axum-extra = { version = "0.12.6", features = ["cookie"] }
|
||||||
|
serde_json = "1.0.151"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::{error::Error, fmt::Display};
|
use std::error::Error;
|
||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use rubato::{Fft, Resampler, audioadapter_buffers::direct::InterleavedSlice};
|
use rubato::{Fft, Resampler, audioadapter_buffers::direct::InterleavedSlice};
|
||||||
@@ -44,7 +44,7 @@ impl AudioProcesser {
|
|||||||
pub fn encode(&mut self, frame: AudioFrame) -> Vec<OpusAudioFrame> {
|
pub fn encode(&mut self, frame: AudioFrame) -> Vec<OpusAudioFrame> {
|
||||||
let mut samples: Vec<f32> = frame
|
let mut samples: Vec<f32> = frame
|
||||||
.data
|
.data
|
||||||
.chunks_exact(4)
|
.as_chunks::<4>().0.iter()
|
||||||
.map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
|
.map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -83,7 +83,6 @@ impl AudioProcesser {
|
|||||||
|
|
||||||
pub struct AudioFrame {
|
pub struct AudioFrame {
|
||||||
pub data: Bytes, // interleaved f32 PCM, little-endian
|
pub data: Bytes, // interleaved f32 PCM, little-endian
|
||||||
pub timestamp_ms: u32,
|
|
||||||
pub sample_rate: u32,
|
pub sample_rate: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,23 +95,6 @@ pub struct AACParser {
|
|||||||
decoder: Option<Box<dyn Decoder>>,
|
decoder: Option<Box<dyn Decoder>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
enum AudioParseError {
|
|
||||||
InvalidCodec,
|
|
||||||
NoConfigPacket,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Display for AudioParseError {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::InvalidCodec => write!(f, "Not the right codec provided"),
|
|
||||||
Self::NoConfigPacket => write!(f, "No config packet cached"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Error for AudioParseError {}
|
|
||||||
|
|
||||||
// Byte 0: upper nibble = sound format (10 = AAC)
|
// Byte 0: upper nibble = sound format (10 = AAC)
|
||||||
// Byte 1: 0 = AudioSpecificConfig, 1 = raw AAC frame
|
// Byte 1: 0 = AudioSpecificConfig, 1 = raw AAC frame
|
||||||
impl AACParser {
|
impl AACParser {
|
||||||
@@ -123,14 +105,14 @@ impl AACParser {
|
|||||||
pub fn parse(
|
pub fn parse(
|
||||||
&mut self,
|
&mut self,
|
||||||
bytes: &[u8],
|
bytes: &[u8],
|
||||||
timestamp_ms: u32,
|
_timestamp_ms: u32,
|
||||||
) -> Result<Option<AudioFrame>, Box<dyn Error>> {
|
) -> Result<Option<AudioFrame>, Box<dyn Error>> {
|
||||||
if bytes.len() < 2 {
|
if bytes.len() < 2 {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bytes[0] >> 4) != 10 {
|
if (bytes[0] >> 4) != 10 {
|
||||||
return Err(Box::new(AudioParseError::InvalidCodec));
|
return Err("not the right codec provided".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
if bytes[1] == 0 {
|
if bytes[1] == 0 {
|
||||||
@@ -143,10 +125,7 @@ impl AACParser {
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
let decoder = self
|
let decoder = self.decoder.as_mut().ok_or("no config packet cached")?;
|
||||||
.decoder
|
|
||||||
.as_mut()
|
|
||||||
.ok_or(AudioParseError::NoConfigPacket)?;
|
|
||||||
|
|
||||||
let packet = Packet::new_from_boxed_slice(
|
let packet = Packet::new_from_boxed_slice(
|
||||||
0,
|
0,
|
||||||
@@ -169,7 +148,6 @@ impl AACParser {
|
|||||||
|
|
||||||
Ok(Some(AudioFrame {
|
Ok(Some(AudioFrame {
|
||||||
data: Bytes::from(pcm_bytes),
|
data: Bytes::from(pcm_bytes),
|
||||||
timestamp_ms,
|
|
||||||
sample_rate: spec.rate,
|
sample_rate: spec.rate,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,9 @@ impl Av1CodecParser {
|
|||||||
let has_size = (header >> 1) & 1 != 0;
|
let has_size = (header >> 1) & 1 != 0;
|
||||||
i += 1;
|
i += 1;
|
||||||
if has_extension {
|
if has_extension {
|
||||||
if i >= data.len() { return false; }
|
if i >= data.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
if obu_type == 1 {
|
if obu_type == 1 {
|
||||||
@@ -61,13 +63,19 @@ impl Av1CodecParser {
|
|||||||
let mut size: usize = 0;
|
let mut size: usize = 0;
|
||||||
let mut shift = 0;
|
let mut shift = 0;
|
||||||
loop {
|
loop {
|
||||||
if i >= data.len() { return false; }
|
if i >= data.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
let b = data[i] as usize;
|
let b = data[i] as usize;
|
||||||
i += 1;
|
i += 1;
|
||||||
size |= (b & 0x7F) << shift;
|
size |= (b & 0x7F) << shift;
|
||||||
shift += 7;
|
shift += 7;
|
||||||
if b & 0x80 == 0 { break; }
|
if b & 0x80 == 0 {
|
||||||
if shift > 32 { return false; }
|
break;
|
||||||
|
}
|
||||||
|
if shift > 32 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
i += size;
|
i += size;
|
||||||
} else {
|
} else {
|
||||||
@@ -78,7 +86,11 @@ impl Av1CodecParser {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
fn obus_for_frame(&mut self, payload: &[u8], rtmp_is_keyframe: bool) -> Option<(Vec<u8>, bool)> {
|
fn obus_for_frame(
|
||||||
|
&mut self,
|
||||||
|
payload: &[u8],
|
||||||
|
rtmp_is_keyframe: bool,
|
||||||
|
) -> Option<(Vec<u8>, bool)> {
|
||||||
if payload.is_empty() {
|
if payload.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -95,14 +107,12 @@ impl Av1CodecParser {
|
|||||||
|
|
||||||
self.first_coded_frame = false;
|
self.first_coded_frame = false;
|
||||||
|
|
||||||
if is_keyframe {
|
if is_keyframe && let Some(config) = &self.config_obus {
|
||||||
if let Some(config) = &self.config_obus {
|
|
||||||
let mut out = Vec::with_capacity(config.len() + payload.len());
|
let mut out = Vec::with_capacity(config.len() + payload.len());
|
||||||
out.extend_from_slice(config);
|
out.extend_from_slice(config);
|
||||||
out.extend_from_slice(payload);
|
out.extend_from_slice(payload);
|
||||||
return Some((out, true));
|
return Some((out, true));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Some((payload.to_vec(), is_keyframe))
|
Some((payload.to_vec(), is_keyframe))
|
||||||
}
|
}
|
||||||
@@ -130,7 +140,11 @@ impl CodecParser for Av1CodecParser {
|
|||||||
// FourCC — enhanced RTMP only defines CTS for hvc1 CodedFrames.
|
// FourCC — enhanced RTMP only defines CTS for hvc1 CodedFrames.
|
||||||
let payload = data.get(5..)?;
|
let payload = data.get(5..)?;
|
||||||
let (obus, is_keyframe) = self.obus_for_frame(payload, rtmp_is_keyframe)?;
|
let (obus, is_keyframe) = self.obus_for_frame(payload, rtmp_is_keyframe)?;
|
||||||
Some(VideoFrame { data: Bytes::from(obus), is_keyframe, timestamp_ms })
|
Some(VideoFrame {
|
||||||
|
data: Bytes::from(obus),
|
||||||
|
is_keyframe,
|
||||||
|
timestamp_ms,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,12 +53,20 @@ impl H264CodecParser {
|
|||||||
}
|
}
|
||||||
let pts_ms = Self::pts_ms(timestamp_ms, &bytes[5..8]);
|
let pts_ms = Self::pts_ms(timestamp_ms, &bytes[5..8]);
|
||||||
let data = self.avcc_to_annexb(bytes.get(8..)?, is_keyframe)?;
|
let data = self.avcc_to_annexb(bytes.get(8..)?, is_keyframe)?;
|
||||||
Some(VideoFrame { data: Bytes::from(data), is_keyframe, timestamp_ms: pts_ms })
|
Some(VideoFrame {
|
||||||
|
data: Bytes::from(data),
|
||||||
|
is_keyframe,
|
||||||
|
timestamp_ms: pts_ms,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
3 => {
|
3 => {
|
||||||
// CodedFramesX: no CTS field, bytes 5+ = AVCC NALUs
|
// CodedFramesX: no CTS field, bytes 5+ = AVCC NALUs
|
||||||
let data = self.avcc_to_annexb(bytes.get(5..)?, is_keyframe)?;
|
let data = self.avcc_to_annexb(bytes.get(5..)?, is_keyframe)?;
|
||||||
Some(VideoFrame { data: Bytes::from(data), is_keyframe, timestamp_ms })
|
Some(VideoFrame {
|
||||||
|
data: Bytes::from(data),
|
||||||
|
is_keyframe,
|
||||||
|
timestamp_ms,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
@@ -77,7 +85,11 @@ impl H264CodecParser {
|
|||||||
// PTS = DTS + CTS — see note on CodedFrames above.
|
// PTS = DTS + CTS — see note on CodedFrames above.
|
||||||
let pts_ms = Self::pts_ms(timestamp_ms, &bytes[2..5]);
|
let pts_ms = Self::pts_ms(timestamp_ms, &bytes[2..5]);
|
||||||
let data = self.avcc_to_annexb(&bytes[5..], is_keyframe)?;
|
let data = self.avcc_to_annexb(&bytes[5..], is_keyframe)?;
|
||||||
Some(VideoFrame { data: Bytes::from(data), is_keyframe, timestamp_ms: pts_ms })
|
Some(VideoFrame {
|
||||||
|
data: Bytes::from(data),
|
||||||
|
is_keyframe,
|
||||||
|
timestamp_ms: pts_ms,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
@@ -148,14 +160,12 @@ impl H264CodecParser {
|
|||||||
|
|
||||||
// Prepend SPS+PPS before every keyframe so str0m's packetizer
|
// Prepend SPS+PPS before every keyframe so str0m's packetizer
|
||||||
// can bundle them into a STAP-A alongside the IDR NALU.
|
// can bundle them into a STAP-A alongside the IDR NALU.
|
||||||
if is_keyframe {
|
if is_keyframe && let (Some(sps), Some(pps)) = (&self.sps, &self.pps) {
|
||||||
if let (Some(sps), Some(pps)) = (&self.sps, &self.pps) {
|
|
||||||
out.extend_from_slice(&[0, 0, 0, 1]);
|
out.extend_from_slice(&[0, 0, 0, 1]);
|
||||||
out.extend_from_slice(sps);
|
out.extend_from_slice(sps);
|
||||||
out.extend_from_slice(&[0, 0, 0, 1]);
|
out.extend_from_slice(&[0, 0, 0, 1]);
|
||||||
out.extend_from_slice(pps);
|
out.extend_from_slice(pps);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Convert each length-prefixed NALU to an Annex B start-code NALU.
|
// Convert each length-prefixed NALU to an Annex B start-code NALU.
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
|
|||||||
@@ -74,8 +74,8 @@ impl H265CodecParser {
|
|||||||
fn hvcc_to_annexb(&self, payload: &[u8], is_keyframe: bool) -> Option<Vec<u8>> {
|
fn hvcc_to_annexb(&self, payload: &[u8], is_keyframe: bool) -> Option<Vec<u8>> {
|
||||||
let mut out = Vec::with_capacity(payload.len());
|
let mut out = Vec::with_capacity(payload.len());
|
||||||
|
|
||||||
if is_keyframe {
|
if is_keyframe && let (Some(vps), Some(sps), Some(pps)) = (&self.vps, &self.sps, &self.pps)
|
||||||
if let (Some(vps), Some(sps), Some(pps)) = (&self.vps, &self.sps, &self.pps) {
|
{
|
||||||
out.extend_from_slice(&[0, 0, 0, 1]);
|
out.extend_from_slice(&[0, 0, 0, 1]);
|
||||||
out.extend_from_slice(vps);
|
out.extend_from_slice(vps);
|
||||||
out.extend_from_slice(&[0, 0, 0, 1]);
|
out.extend_from_slice(&[0, 0, 0, 1]);
|
||||||
@@ -83,7 +83,6 @@ impl H265CodecParser {
|
|||||||
out.extend_from_slice(&[0, 0, 0, 1]);
|
out.extend_from_slice(&[0, 0, 0, 1]);
|
||||||
out.extend_from_slice(pps);
|
out.extend_from_slice(pps);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
while i + 4 <= payload.len() {
|
while i + 4 <= payload.len() {
|
||||||
@@ -129,12 +128,20 @@ impl CodecParser for H265CodecParser {
|
|||||||
let cts = i32::from_be_bytes([0, data[5], data[6], data[7]]) << 8 >> 8;
|
let cts = i32::from_be_bytes([0, data[5], data[6], data[7]]) << 8 >> 8;
|
||||||
let pts_ms = (timestamp_ms as i64 + cts as i64).max(0) as u32;
|
let pts_ms = (timestamp_ms as i64 + cts as i64).max(0) as u32;
|
||||||
let annexb = self.hvcc_to_annexb(data.get(8..)?, is_keyframe)?;
|
let annexb = self.hvcc_to_annexb(data.get(8..)?, is_keyframe)?;
|
||||||
Some(VideoFrame { data: Bytes::from(annexb), is_keyframe, timestamp_ms: pts_ms })
|
Some(VideoFrame {
|
||||||
|
data: Bytes::from(annexb),
|
||||||
|
is_keyframe,
|
||||||
|
timestamp_ms: pts_ms,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
3 => {
|
3 => {
|
||||||
// CodedFramesX: no CTS, bytes 5+ = HVCC
|
// CodedFramesX: no CTS, bytes 5+ = HVCC
|
||||||
let annexb = self.hvcc_to_annexb(data.get(5..)?, is_keyframe)?;
|
let annexb = self.hvcc_to_annexb(data.get(5..)?, is_keyframe)?;
|
||||||
Some(VideoFrame { data: Bytes::from(annexb), is_keyframe, timestamp_ms })
|
Some(VideoFrame {
|
||||||
|
data: Bytes::from(annexb),
|
||||||
|
is_keyframe,
|
||||||
|
timestamp_ms,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
|
|||||||
+385
-218
@@ -4,53 +4,63 @@ use std::{
|
|||||||
Arc,
|
Arc,
|
||||||
atomic::{AtomicI32, Ordering},
|
atomic::{AtomicI32, Ordering},
|
||||||
},
|
},
|
||||||
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use axum_extra::extract::{CookieJar, cookie::Cookie};
|
||||||
|
|
||||||
|
/// Allowed charset for labels, passwords and custom IDs: letters, numbers,
|
||||||
|
/// dashes and apostrophes — no spaces. Mirrors the frontend
|
||||||
|
/// `/^[A-Za-z0-9'-]{0,67}$/` used by the keys-page popups.
|
||||||
|
fn valid_charset(s: &str) -> bool {
|
||||||
|
s.len() <= 67
|
||||||
|
&& s.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '\'')
|
||||||
|
}
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
body::{Body, Bytes},
|
extract::{FromRequestParts, Path, State},
|
||||||
extract::{Form, FromRequestParts, Path, Query, State},
|
|
||||||
http::{
|
http::{
|
||||||
HeaderMap, HeaderName, Method, StatusCode,
|
HeaderMap, HeaderName, Method, StatusCode,
|
||||||
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, SET_COOKIE},
|
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
|
||||||
request::Parts,
|
request::Parts,
|
||||||
},
|
},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing::{get, post},
|
routing::{delete, get, post},
|
||||||
};
|
};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use entity::{auth_session, stream_key, stream_session, users};
|
use entity::{auth_session, stream_key, stream_session, users};
|
||||||
use sea_orm::{DatabaseConnection, EntityTrait, QueryFilter, prelude::DateTimeUtc};
|
use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, IntoActiveModel, Set};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sysinfo::System;
|
||||||
use tokio::{
|
use tokio::{
|
||||||
net::TcpListener,
|
net::TcpListener,
|
||||||
sync::{Mutex, mpsc::Sender},
|
sync::{Mutex, mpsc::Sender},
|
||||||
};
|
};
|
||||||
use tower_http::cors::CorsLayer;
|
use tower_http::cors::CorsLayer;
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
AppState,
|
AppState,
|
||||||
hash::{hash_password, verify_password},
|
hash::{hash_password, verify_password},
|
||||||
webrtc_ingest::handle_whip_injest,
|
http_error::HttpError,
|
||||||
|
webrtc_ingest::{handle_whip_injest, handle_whip_injest_delete, handle_whip_injest_patch},
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_USERNAME_LEN: usize = 32;
|
const MAX_USERNAME_LEN: usize = 32;
|
||||||
const MAX_LABEL_LEN: usize = 64;
|
const MAX_LABEL_LEN: usize = 64;
|
||||||
|
|
||||||
pub struct HttpServerConfig {
|
|
||||||
pub signup_code: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct HttpServer {
|
pub struct HttpServer {
|
||||||
pub offer_tx: Sender<(i32, i32, String)>,
|
pub offer_tx: Sender<(i32, i32, String)>,
|
||||||
pub accept_rx: async_broadcast::InactiveReceiver<(i32, Option<String>)>,
|
pub accept_rx: async_broadcast::InactiveReceiver<(i32, Result<String, String>)>,
|
||||||
pub appstate: Arc<Mutex<AppState>>,
|
pub appstate: Arc<Mutex<AppState>>,
|
||||||
pub request_count: AtomicI32,
|
pub request_count: AtomicI32,
|
||||||
pub db: DatabaseConnection,
|
pub db: DatabaseConnection,
|
||||||
pub config: Arc<HttpServerConfig>,
|
pub signup_code: String,
|
||||||
|
pub version: &'static str,
|
||||||
|
pub start_time: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HttpServer {
|
impl HttpServer {
|
||||||
@@ -63,7 +73,13 @@ impl HttpServer {
|
|||||||
];
|
];
|
||||||
|
|
||||||
let cors = CorsLayer::new()
|
let cors = CorsLayer::new()
|
||||||
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
|
.allow_methods([
|
||||||
|
Method::GET,
|
||||||
|
Method::POST,
|
||||||
|
Method::PATCH,
|
||||||
|
Method::DELETE,
|
||||||
|
Method::OPTIONS,
|
||||||
|
])
|
||||||
.allow_headers([
|
.allow_headers([
|
||||||
AUTHORIZATION,
|
AUTHORIZATION,
|
||||||
ACCEPT,
|
ACCEPT,
|
||||||
@@ -73,84 +89,176 @@ impl HttpServer {
|
|||||||
.allow_credentials(true)
|
.allow_credentials(true)
|
||||||
.allow_origin(origins);
|
.allow_origin(origins);
|
||||||
|
|
||||||
// TODO: Add an error type that impls IntoResponse, enum HttpError {}; impl IntoResponse for
|
|
||||||
// HttpError
|
|
||||||
// https://docs.rs/thiserror/latest/thiserror/
|
|
||||||
//
|
|
||||||
// Its rather shitty
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/api/catalog", get(catalog_handler))
|
.route("/api/catalog", get(catalog_handler))
|
||||||
.route("/api/user", post(create_user_handler))
|
.route("/api/user", post(create_user_handler))
|
||||||
.route(
|
.route(
|
||||||
"/api/stream-key",
|
"/api/stream-key",
|
||||||
post(create_stream_key_handler).get(get_all_stream_keys),
|
post(create_stream_key_handler)
|
||||||
|
.get(get_all_stream_keys)
|
||||||
|
.patch(edit_stream_key),
|
||||||
)
|
)
|
||||||
// .route("/api/admin/server_stats", get(todo!()))
|
|
||||||
.route("/api/whip", post(handle_whip_injest))
|
|
||||||
.route("/api/login", post(login_handler))
|
.route("/api/login", post(login_handler))
|
||||||
.route("/api/stream/{slug}", post(stream_handler))
|
.route("/api/stream/{slug}", post(stream_handler))
|
||||||
|
.route("/api/whip", post(handle_whip_injest))
|
||||||
|
.route(
|
||||||
|
"/api/whip/{slug}",
|
||||||
|
delete(handle_whip_injest_delete).patch(handle_whip_injest_patch),
|
||||||
|
)
|
||||||
.route("/api/meow", get(meow_handler))
|
.route("/api/meow", get(meow_handler))
|
||||||
|
.route("/api/health", get(health_handler))
|
||||||
|
.route("/api/stats", get(stats_handler))
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
let addr = SocketAddr::from(([0, 0, 0, 0], 3000));
|
let addr = SocketAddr::from(([0, 0, 0, 0], 3000));
|
||||||
let listener = TcpListener::bind(addr).await.unwrap();
|
let listener = TcpListener::bind(addr).await.unwrap();
|
||||||
axum::serve(listener, app).await.unwrap();
|
axum::serve(
|
||||||
|
listener,
|
||||||
|
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct StreamCatalog {
|
|
||||||
active_streams: Vec<StreamListing>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct StreamListing {
|
struct StreamListing {
|
||||||
label: String,
|
label: String,
|
||||||
|
custom_url_label: String,
|
||||||
id: i32,
|
id: i32,
|
||||||
user: String,
|
user: String,
|
||||||
started_at: DateTime<Utc>, //UNIX TIMESTAMP
|
started_at: DateTime<Utc>,
|
||||||
|
is_password_protected: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn catalog_handler(State(state): State<Arc<HttpServer>>) -> impl IntoResponse {
|
#[derive(Deserialize)]
|
||||||
let streams = stream_session::Model::get_all_active_sessions(&state.db)
|
struct EditStreamKeyRequest {
|
||||||
.await
|
id: i32,
|
||||||
.unwrap();
|
#[serde(default)]
|
||||||
|
new: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
password: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
unlisted: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
custom_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn edit_stream_key(
|
||||||
|
State(state): State<Arc<HttpServer>>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Json(payload): Json<EditStreamKeyRequest>,
|
||||||
|
) -> Result<impl IntoResponse, HttpError> {
|
||||||
|
// Trim surrounding whitespace so labels aren't stored with leading/trailing spaces.
|
||||||
|
let new_label = payload.new.as_deref().map(str::trim);
|
||||||
|
// Length (1..=67), allowed charset, and at least one letter.
|
||||||
|
if let Some(label) = new_label {
|
||||||
|
if label.is_empty() || !valid_charset(label) {
|
||||||
|
return Err(HttpError::BadRequest("invalid label".into()));
|
||||||
|
}
|
||||||
|
if !label.chars().any(|c| c.is_ascii_alphabetic()) {
|
||||||
|
return Err(HttpError::BadRequest("label must contain a letter".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Empty values are allowed (they clear the field).
|
||||||
|
if let Some(custom_id) = payload.custom_id.as_deref()
|
||||||
|
&& !custom_id.is_empty() && !valid_charset(custom_id) {
|
||||||
|
return Err(HttpError::BadRequest("invalid custom id".into()));
|
||||||
|
}
|
||||||
|
if let Some(pwd) = payload.password.as_deref()
|
||||||
|
&& !pwd.is_empty() && !valid_charset(pwd) {
|
||||||
|
return Err(HttpError::BadRequest("invalid password".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let stream_key = stream_key::Entity::find_by_id(payload.id)
|
||||||
|
.one(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or(HttpError::NotFound)?;
|
||||||
|
// Do they own the stream key
|
||||||
|
if stream_key.user_id != auth.0.id {
|
||||||
|
return Err(HttpError::Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
let lock = state.appstate.lock().await;
|
||||||
|
let mut ses = { lock.stream_sessions.get_mut(&payload.id) };
|
||||||
|
// drop(lock);
|
||||||
|
|
||||||
|
let mut am = stream_key.into_active_model();
|
||||||
|
if let Some(custom_id) = payload.custom_id {
|
||||||
|
am.custom_id = if custom_id.is_empty() {
|
||||||
|
if let Some(ref mut ses) = ses {
|
||||||
|
ses.custom_id = None;
|
||||||
|
}
|
||||||
|
Set(None)
|
||||||
|
} else {
|
||||||
|
// Check for conflict.
|
||||||
|
if stream_key::Entity::find_by_custom_id(&state.db, custom_id.clone())
|
||||||
|
.await?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Err(HttpError::Conflict);
|
||||||
|
}
|
||||||
|
if let Some(ref mut ses) = ses {
|
||||||
|
ses.custom_id = Some(custom_id.clone());
|
||||||
|
}
|
||||||
|
Set(Some(custom_id))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if let Some(label) = new_label {
|
||||||
|
if let Some(ref mut ses) = ses {
|
||||||
|
ses.stream_key_label = label.to_string();
|
||||||
|
}
|
||||||
|
am.label = Set(label.to_string());
|
||||||
|
}
|
||||||
|
if let Some(pwd) = payload.password {
|
||||||
|
am.password = if pwd.is_empty() {
|
||||||
|
if let Some(ref mut ses) = ses {
|
||||||
|
ses.password = None;
|
||||||
|
}
|
||||||
|
Set(None)
|
||||||
|
} else {
|
||||||
|
if let Some(ref mut ses) = ses {
|
||||||
|
ses.password = Some(pwd.clone());
|
||||||
|
}
|
||||||
|
Set(Some(pwd))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if let Some(unlisted) = payload.unlisted {
|
||||||
|
if let Some(ref mut ses) = ses {
|
||||||
|
ses.is_unlisted = unlisted;
|
||||||
|
}
|
||||||
|
am.is_unlisted = Set(unlisted);
|
||||||
|
}
|
||||||
|
// This hopefully will not fail, if it does, our values for the stream key will be mismatched,
|
||||||
|
// that would be bad
|
||||||
|
am.update(&state.db).await?;
|
||||||
|
|
||||||
|
Ok(StatusCode::OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn catalog_handler(
|
||||||
|
State(state): State<Arc<HttpServer>>,
|
||||||
|
) -> Result<impl IntoResponse, HttpError> {
|
||||||
|
let streams = stream_session::Model::get_all_active_sessions(&state.db).await?;
|
||||||
debug!("{:#?}", streams);
|
debug!("{:#?}", streams);
|
||||||
let catalog: Vec<StreamListing> = futures::future::join_all(streams.iter().map(|listing| {
|
let catalog: Vec<StreamListing> = state
|
||||||
let db = state.db.clone();
|
|
||||||
let stream_key_id = listing.stream_key_id;
|
|
||||||
let state2 = state.clone();
|
|
||||||
async move {
|
|
||||||
let key_info = stream_key::Entity::find_by_id(stream_key_id)
|
|
||||||
.one(&db)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
let user = users::Entity::find_by_id(key_info.user_id)
|
|
||||||
.one(&db)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
let meow = state2
|
|
||||||
.appstate
|
.appstate
|
||||||
.clone()
|
|
||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
.stream_sessions
|
.stream_sessions
|
||||||
.get(&key_info.id)
|
.iter()
|
||||||
.unwrap()
|
.filter(|x| !x.is_unlisted)
|
||||||
.started_at;
|
.map(|x| StreamListing {
|
||||||
StreamListing {
|
id: x.stream_key_id,
|
||||||
id: stream_key_id,
|
label: x.stream_key_label.clone(),
|
||||||
label: key_info.label,
|
custom_url_label: x.custom_id.clone().unwrap_or_default(),
|
||||||
user: user.username,
|
user: x.stream_key_user.clone(),
|
||||||
started_at: meow,
|
started_at: x.started_at,
|
||||||
}
|
is_password_protected: x.password.is_some(),
|
||||||
}
|
})
|
||||||
}))
|
.collect::<Vec<_>>();
|
||||||
.await;
|
Ok(Json(catalog))
|
||||||
Json(catalog)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -158,37 +266,27 @@ struct CreateStreamKeyBody {
|
|||||||
label: String,
|
label: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_session_token(headers: &HeaderMap) -> Option<String> {
|
|
||||||
// Check bare `session` header first (curl / API clients)
|
|
||||||
if let Some(v) = headers.get("session") {
|
|
||||||
return Some(v.to_str().ok()?.to_string());
|
|
||||||
}
|
|
||||||
// Fall back to Cookie header (browsers)
|
|
||||||
let cookie_header = headers.get("cookie")?.to_str().ok()?;
|
|
||||||
cookie_header
|
|
||||||
.split(';')
|
|
||||||
.find_map(|pair| {
|
|
||||||
let pair = pair.trim();
|
|
||||||
pair.strip_prefix("session=")
|
|
||||||
})
|
|
||||||
.map(|v| v.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AuthUser(entity::users::Model);
|
struct AuthUser(entity::users::Model);
|
||||||
|
|
||||||
impl FromRequestParts<Arc<HttpServer>> for AuthUser {
|
impl FromRequestParts<Arc<HttpServer>> for AuthUser {
|
||||||
type Rejection = StatusCode;
|
type Rejection = HttpError;
|
||||||
|
|
||||||
async fn from_request_parts(
|
async fn from_request_parts(
|
||||||
parts: &mut Parts,
|
parts: &mut Parts,
|
||||||
state: &Arc<HttpServer>,
|
state: &Arc<HttpServer>,
|
||||||
) -> Result<Self, StatusCode> {
|
) -> Result<Self, HttpError> {
|
||||||
let token = extract_session_token(&parts.headers).ok_or(StatusCode::UNAUTHORIZED)?;
|
let jar = CookieJar::from_headers(&parts.headers);
|
||||||
|
let session = jar.get("session").ok_or(HttpError::Unauthorized)?;
|
||||||
|
let session_val = session.value().to_string();
|
||||||
|
tracing::debug!(?session_val, "AuthUser: extracted session cookie");
|
||||||
|
|
||||||
let user = users::Entity::find_by_auth_session(&state.db, token)
|
let user = match users::Entity::find_by_auth_session(&state.db, session_val.clone()).await {
|
||||||
.await
|
Ok(u) => u.ok_or(HttpError::Unauthorized)?,
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
Err(e) => {
|
||||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
tracing::error!(?session_val, error = %e, "AuthUser: db query failed");
|
||||||
|
return Err(HttpError::DbErr(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
Ok(AuthUser(user))
|
Ok(AuthUser(user))
|
||||||
}
|
}
|
||||||
@@ -198,12 +296,11 @@ async fn create_stream_key_handler(
|
|||||||
State(state): State<Arc<HttpServer>>,
|
State(state): State<Arc<HttpServer>>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Json(payload): Json<CreateStreamKeyBody>,
|
Json(payload): Json<CreateStreamKeyBody>,
|
||||||
) -> impl IntoResponse {
|
) -> Result<impl IntoResponse, HttpError> {
|
||||||
let uuid = uuid::Uuid::new_v4();
|
let uuid = uuid::Uuid::new_v4();
|
||||||
let value = format!("stream-key-{uuid}");
|
let value = format!("stream-key-{uuid}");
|
||||||
let key_amount = stream_key::Entity::find_by_user(&state.db, auth.0.id)
|
let key_amount = stream_key::Entity::find_by_user(&state.db, auth.0.id)
|
||||||
.await
|
.await?
|
||||||
.unwrap()
|
|
||||||
.len();
|
.len();
|
||||||
if payload.label.is_empty() || payload.label.len() > MAX_LABEL_LEN {
|
if payload.label.is_empty() || payload.label.len() > MAX_LABEL_LEN {
|
||||||
warn!(
|
warn!(
|
||||||
@@ -212,7 +309,7 @@ async fn create_stream_key_handler(
|
|||||||
max = MAX_LABEL_LEN,
|
max = MAX_LABEL_LEN,
|
||||||
"stream key creation rejected: label length invalid"
|
"stream key creation rejected: label length invalid"
|
||||||
);
|
);
|
||||||
return StatusCode::UNPROCESSABLE_ENTITY;
|
return Err(HttpError::Unprocessable("label length invalid".into()));
|
||||||
}
|
}
|
||||||
if key_amount >= auth.0.stream_key_limit.try_into().unwrap() {
|
if key_amount >= auth.0.stream_key_limit.try_into().unwrap() {
|
||||||
warn!(
|
warn!(
|
||||||
@@ -220,40 +317,20 @@ async fn create_stream_key_handler(
|
|||||||
limit = auth.0.stream_key_limit,
|
limit = auth.0.stream_key_limit,
|
||||||
"stream key limit reached"
|
"stream key limit reached"
|
||||||
);
|
);
|
||||||
return StatusCode::NOT_ACCEPTABLE;
|
return Err(HttpError::NotAcceptable("stream key limit reached".into()));
|
||||||
}
|
}
|
||||||
let key = stream_key::Entity::create(&state.db, auth.0.id, value, payload.label, false).await;
|
let key = stream_key::Entity::create(&state.db, auth.0.id, value, payload.label, false).await?;
|
||||||
|
|
||||||
if let Ok(ref k) = key {
|
info!(user_id = auth.0.id, stream_key_id = key.id, label = %key.label, "stream key created");
|
||||||
info!(user_id = auth.0.id, stream_key_id = k.id, label = %k.label, "stream key created");
|
Ok(StatusCode::CREATED)
|
||||||
StatusCode::CREATED
|
|
||||||
} else {
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct StreamKeys {
|
|
||||||
keys: Vec<StreamKey>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct StreamKey {
|
|
||||||
id: i32,
|
|
||||||
label: String,
|
|
||||||
value: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_all_stream_keys(
|
async fn get_all_stream_keys(
|
||||||
State(state): State<Arc<HttpServer>>,
|
State(state): State<Arc<HttpServer>>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
) -> impl IntoResponse {
|
) -> Result<impl IntoResponse, HttpError> {
|
||||||
// let user_keys = stream_key::Entity::find_by_user(&state.db, auth.0.id)
|
let keys = stream_key::Entity::find_by_user(&state.db, auth.0.id).await?;
|
||||||
// .await
|
Ok(Json(keys))
|
||||||
// .unwrap();
|
|
||||||
|
|
||||||
match stream_key::Entity::find_by_user(&state.db, auth.0.id).await {
|
|
||||||
Ok(keys) => Json(keys).into_response(),
|
|
||||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -262,49 +339,63 @@ struct LoginForm {
|
|||||||
password: String,
|
password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
struct DevFlag(bool);
|
||||||
struct LoginResponse {
|
|
||||||
session_token: String,
|
impl<S> FromRequestParts<S> for DevFlag
|
||||||
|
where
|
||||||
|
S: Send + Sync,
|
||||||
|
{
|
||||||
|
type Rejection = std::convert::Infallible;
|
||||||
|
|
||||||
|
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||||
|
let query = parts.uri.query().unwrap_or("");
|
||||||
|
Ok(DevFlag(query.contains("dev=1")))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cookie_for_token(token: &str, dev: bool) -> Cookie<'static> {
|
||||||
|
let token = token.to_owned();
|
||||||
|
let mut cookie = Cookie::build(("session", token))
|
||||||
|
.path("/")
|
||||||
|
.max_age(time::Duration::days(30))
|
||||||
|
.same_site(if dev {
|
||||||
|
axum_extra::extract::cookie::SameSite::None
|
||||||
|
} else {
|
||||||
|
axum_extra::extract::cookie::SameSite::Lax
|
||||||
|
});
|
||||||
|
if dev {
|
||||||
|
cookie = cookie.secure(true);
|
||||||
|
}
|
||||||
|
cookie.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[axum::debug_handler]
|
||||||
async fn login_handler(
|
async fn login_handler(
|
||||||
State(state): State<Arc<HttpServer>>,
|
State(state): State<Arc<HttpServer>>,
|
||||||
|
DevFlag(dev): DevFlag,
|
||||||
Json(payload): Json<LoginForm>,
|
Json(payload): Json<LoginForm>,
|
||||||
) -> impl IntoResponse {
|
) -> Result<impl IntoResponse, HttpError> {
|
||||||
if let Ok(x) = users::Entity::find_by_username(&state.db, payload.username.clone()).await {
|
let user = users::Entity::find_by_username(&state.db, payload.username.clone())
|
||||||
if let Some(x) = x {
|
.await?
|
||||||
let pass = verify_password(&payload.password, &x.hashed_password);
|
.ok_or_else(|| {
|
||||||
if !pass {
|
|
||||||
warn!(username = %payload.username, "login failed: wrong password");
|
|
||||||
let mut meow = Response::new("".to_string());
|
|
||||||
*meow.status_mut() = StatusCode::UNAUTHORIZED;
|
|
||||||
return meow;
|
|
||||||
};
|
|
||||||
info!(user_id = x.id, username = %x.username, "login successful");
|
|
||||||
let auth = auth_session::Entity::create(&state.db, x.id).await.unwrap();
|
|
||||||
let token = auth.value;
|
|
||||||
let mut meow = Response::new("".to_string());
|
|
||||||
// TODO: Replace with axum cookie jar https://docs.rs/axum-extra/latest/axum_extra/extract/cookie/struct.CookieJar.html
|
|
||||||
meow.headers_mut().insert(
|
|
||||||
SET_COOKIE,
|
|
||||||
format!("session={token}; SameSite=Strict; Path=/; Max-Age=2592000")
|
|
||||||
.parse()
|
|
||||||
.unwrap(),
|
|
||||||
);
|
|
||||||
*meow.status_mut() = StatusCode::OK;
|
|
||||||
return meow;
|
|
||||||
} else {
|
|
||||||
warn!(username = %payload.username, "login failed: user not found");
|
warn!(username = %payload.username, "login failed: user not found");
|
||||||
let mut meow = Response::new("".to_string());
|
HttpError::Unauthorized
|
||||||
*meow.status_mut() = StatusCode::UNAUTHORIZED;
|
})?;
|
||||||
return meow;
|
|
||||||
}
|
if !verify_password(&payload.password, &user.hashed_password) {
|
||||||
} else {
|
warn!(username = %payload.username, "login failed: wrong password");
|
||||||
warn!(username = %payload.username, "login failed: DB error");
|
return Err(HttpError::Unauthorized);
|
||||||
let mut meow = Response::new("".to_string());
|
|
||||||
*meow.status_mut() = StatusCode::UNAUTHORIZED;
|
|
||||||
return meow;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
info!(user_id = user.id, username = %user.username, "login successful");
|
||||||
|
let auth = auth_session::Entity::create(&state.db, user.id).await?;
|
||||||
|
let token = auth.value;
|
||||||
|
|
||||||
|
let cookie = cookie_for_token(&token, dev);
|
||||||
|
Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
[(axum::http::header::SET_COOKIE, cookie.to_string())],
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -316,116 +407,192 @@ struct CreateUserForm {
|
|||||||
|
|
||||||
async fn create_user_handler(
|
async fn create_user_handler(
|
||||||
State(state): State<Arc<HttpServer>>,
|
State(state): State<Arc<HttpServer>>,
|
||||||
|
DevFlag(dev): DevFlag,
|
||||||
Json(payload): Json<CreateUserForm>,
|
Json(payload): Json<CreateUserForm>,
|
||||||
) -> (HeaderMap, StatusCode) {
|
) -> Result<impl IntoResponse, HttpError> {
|
||||||
if state.config.signup_code.is_empty() || payload.ref_token != state.config.signup_code {
|
if state.signup_code.is_empty() || payload.ref_token != state.signup_code {
|
||||||
warn!(username = %payload.username, "signup rejected: invalid signup code");
|
warn!(username = %payload.username, "signup rejected: invalid signup code");
|
||||||
return (HeaderMap::new(), StatusCode::UNAUTHORIZED);
|
return Err(HttpError::Unauthorized);
|
||||||
}
|
}
|
||||||
if payload.username.is_empty() || payload.username.len() > MAX_USERNAME_LEN {
|
if payload.username.is_empty() || payload.username.len() > MAX_USERNAME_LEN {
|
||||||
warn!(username = %payload.username, max = MAX_USERNAME_LEN, "signup rejected: username length invalid");
|
warn!(username = %payload.username, max = MAX_USERNAME_LEN, "signup rejected: username length invalid");
|
||||||
return (HeaderMap::new(), StatusCode::UNPROCESSABLE_ENTITY);
|
return Err(HttpError::Unprocessable("username length invalid".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let meow = users::Entity::create(
|
let user = users::Entity::create(
|
||||||
&state.db,
|
&state.db,
|
||||||
payload.username.clone(),
|
payload.username.clone(),
|
||||||
hash_password(&payload.password).unwrap(),
|
hash_password(&payload.password).map_err(|_| HttpError::Hash)?,
|
||||||
)
|
)
|
||||||
.await;
|
|
||||||
|
|
||||||
// Create session
|
|
||||||
let session: auth_session::Model = if let Ok(ref user) = meow {
|
|
||||||
info!(user_id = user.id, username = %user.username, "user created");
|
|
||||||
auth_session::Entity::create(&state.db, user.id)
|
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.map_err(|e| {
|
||||||
} else {
|
// A create failure here is almost always a username unique-constraint
|
||||||
warn!(username = %payload.username, "user creation failed (likely username conflict)");
|
// violation, so surface it as a conflict rather than a 500.
|
||||||
return (HeaderMap::new(), StatusCode::CONFLICT);
|
warn!(username = %payload.username, error = %e, "user creation failed (likely username conflict)");
|
||||||
};
|
HttpError::Conflict
|
||||||
|
})?;
|
||||||
|
|
||||||
|
info!(user_id = user.id, username = %user.username, "user created");
|
||||||
|
let session = auth_session::Entity::create(&state.db, user.id).await?;
|
||||||
let token = session.value;
|
let token = session.value;
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
// TODO: Replace with axum cookie jar https://docs.rs/axum-extra/latest/axum_extra/extract/cookie/struct.CookieJar.html
|
let cookie = cookie_for_token(&token, dev);
|
||||||
headers.insert(
|
Ok((
|
||||||
SET_COOKIE,
|
StatusCode::OK,
|
||||||
format!("session={token}; HttpOnly; SameSite=Strict; Path=/")
|
[(axum::http::header::SET_COOKIE, cookie.to_string())],
|
||||||
.parse()
|
))
|
||||||
.unwrap(),
|
|
||||||
);
|
|
||||||
(headers, StatusCode::OK)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn stream_handler(
|
async fn stream_handler(
|
||||||
State(state): State<Arc<HttpServer>>,
|
State(state): State<Arc<HttpServer>>,
|
||||||
Path(slug): Path<String>,
|
Path(slug): Path<String>,
|
||||||
|
headers: HeaderMap,
|
||||||
body: String,
|
body: String,
|
||||||
) -> impl IntoResponse {
|
) -> Result<impl IntoResponse, HttpError> {
|
||||||
let request_id_clone = state.request_count.fetch_add(1, Ordering::Relaxed);
|
let request_id_clone = state.request_count.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let app = state.appstate.lock().await;
|
||||||
|
|
||||||
let stream_key_id = {
|
let stream_key_id = {
|
||||||
let app = state.appstate.lock().await;
|
app.stream_sessions.iter().find(|e| {
|
||||||
app.stream_sessions
|
e.value().stream_key_id.to_string() == slug
|
||||||
.iter()
|
|| e.value().custom_id.as_deref() == Some(slug.as_str())
|
||||||
.find(|e| e.value().stream_key_id.to_string() == slug)
|
})
|
||||||
.map(|e| *e.key())
|
// .map(|e| *e.key())
|
||||||
};
|
};
|
||||||
|
|
||||||
let stream_key_id = if let Some(id) = stream_key_id {
|
let stream_key_id = stream_key_id.ok_or_else(|| {
|
||||||
id
|
// warn!(slug = %slug, "WHEP request for unknown or inactive stream");
|
||||||
|
HttpError::NotFound
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Dont return stream by its id in the db if its unlisted
|
||||||
|
if stream_key_id.is_unlisted && stream_key_id.custom_id.clone().ok_or("") != Ok(slug.clone()) {
|
||||||
|
return Err(HttpError::NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
let auth_header = headers.get("auth");
|
||||||
|
|
||||||
|
if let Some(password) = &stream_key_id.password {
|
||||||
|
if let Some(auth) = auth_header {
|
||||||
|
if auth.to_str().unwrap() != password {
|
||||||
|
return Err(HttpError::Unauthorized);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
warn!(slug = %slug, "WHEP request for unknown or inactive stream");
|
return Err(HttpError::Unauthorized);
|
||||||
return Response::builder()
|
};
|
||||||
.status(StatusCode::NOT_FOUND)
|
|
||||||
.header("content-type", "application/text")
|
|
||||||
.body("".to_string())
|
|
||||||
.unwrap();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
info!(request_id = request_id_clone, slug = %slug, stream_key_id, "WHEP offer received");
|
info!(request_id = request_id_clone, slug = %slug, stream_key_id.stream_key_id, "WHEP offer received");
|
||||||
let mut accept_rx = state.accept_rx.activate_cloned();
|
let accept_rx = state.accept_rx.activate_cloned();
|
||||||
let _ = state
|
// The webrtc worker owning the receiver died if this fails.
|
||||||
|
state
|
||||||
.offer_tx
|
.offer_tx
|
||||||
.send((request_id_clone, stream_key_id, body))
|
.send((request_id_clone, stream_key_id.stream_key_id, body))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.map_err(|_| HttpError::Internal)?;
|
||||||
debug!(
|
debug!(
|
||||||
request_id = request_id_clone,
|
request_id = request_id_clone,
|
||||||
"offer sent, waiting for answer"
|
"offer sent, waiting for answer"
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut reply_body = String::new();
|
// Bound the wait: a WebRTC setup failure (malformed SDP, codec mismatch)
|
||||||
while let Ok(answer) = accept_rx.recv().await {
|
// would otherwise leave this HTTP request hanging forever.
|
||||||
debug!(request_id = request_id_clone, "received answer candidate");
|
match tokio::time::timeout(Duration::from_secs(10), async {
|
||||||
if let Some(reply) = answer.1 {
|
let mut accept_rx = accept_rx;
|
||||||
if answer.0 == request_id_clone {
|
loop {
|
||||||
return Response::builder()
|
match accept_rx.recv().await {
|
||||||
|
Ok(answer) if answer.0 == request_id_clone => return Some(answer.1),
|
||||||
|
Ok(_) => continue,
|
||||||
|
Err(_) => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(Ok(reply))) => {
|
||||||
|
return Ok(Response::builder()
|
||||||
.status(StatusCode::CREATED)
|
.status(StatusCode::CREATED)
|
||||||
.header("content-type", "application/sdp")
|
.header("content-type", "application/sdp")
|
||||||
.body(reply)
|
.body(reply)
|
||||||
.unwrap();
|
.unwrap());
|
||||||
} else {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
} else if answer.0 == request_id_clone {
|
|
||||||
return Response::builder()
|
|
||||||
.status(StatusCode::UNSUPPORTED_MEDIA_TYPE)
|
|
||||||
.body(String::new())
|
|
||||||
.unwrap();
|
|
||||||
}
|
}
|
||||||
|
Ok(Some(Err(codec))) => {
|
||||||
|
return Err(HttpError::WhepCodecError(codec));
|
||||||
}
|
}
|
||||||
|
Ok(None) => {
|
||||||
info!(
|
info!(
|
||||||
request_id = request_id_clone,
|
request_id = request_id_clone,
|
||||||
"answer channel closed without a match"
|
"answer channel closed without a match"
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
warn!(
|
||||||
|
request_id = request_id_clone,
|
||||||
|
"timed out waiting for WHEP answer"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
Response::builder()
|
Ok(Response::builder()
|
||||||
.status(StatusCode::CREATED)
|
.status(StatusCode::GATEWAY_TIMEOUT)
|
||||||
.header("content-type", "application/sdp")
|
.body(String::new())
|
||||||
.body(reply_body)
|
.unwrap())
|
||||||
.unwrap()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn meow_handler() -> &'static str {
|
async fn meow_handler() -> &'static str {
|
||||||
"meow"
|
"meow"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct HealthResponse {
|
||||||
|
status: &'static str,
|
||||||
|
uptime_seconds: u64,
|
||||||
|
version: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn health_handler(
|
||||||
|
State(state): State<Arc<HttpServer>>,
|
||||||
|
) -> Result<impl IntoResponse, HttpError> {
|
||||||
|
let uptime = state.start_time.elapsed().as_secs();
|
||||||
|
Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(HealthResponse {
|
||||||
|
status: "ok",
|
||||||
|
uptime_seconds: uptime,
|
||||||
|
version: state.version,
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct StatsResponse {
|
||||||
|
version: &'static str,
|
||||||
|
uptime_seconds: u64,
|
||||||
|
cpu_usage_percent: f32,
|
||||||
|
active_streams: usize,
|
||||||
|
request_count: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stats_handler(
|
||||||
|
State(state): State<Arc<HttpServer>>,
|
||||||
|
) -> Result<impl IntoResponse, HttpError> {
|
||||||
|
let uptime = state.start_time.elapsed().as_secs();
|
||||||
|
let mut system = System::new();
|
||||||
|
system.refresh_cpu_all();
|
||||||
|
let cpu_usage = system.global_cpu_usage();
|
||||||
|
let active_streams = {
|
||||||
|
let app = state.appstate.lock().await;
|
||||||
|
app.stream_sessions.len()
|
||||||
|
};
|
||||||
|
let request_count = state.request_count.load(Ordering::Relaxed);
|
||||||
|
Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(StatsResponse {
|
||||||
|
version: state.version,
|
||||||
|
uptime_seconds: uptime,
|
||||||
|
cpu_usage_percent: cpu_usage,
|
||||||
|
active_streams,
|
||||||
|
request_count,
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
http::{Response, StatusCode},
|
||||||
|
response::IntoResponse,
|
||||||
|
};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum HttpError {
|
||||||
|
#[error("db shit itself: {0}")]
|
||||||
|
DbErr(#[from] sea_orm::DbErr),
|
||||||
|
#[error("hashing failed")]
|
||||||
|
Hash,
|
||||||
|
#[error("resource not found")]
|
||||||
|
NotFound,
|
||||||
|
#[error("unauthorized")]
|
||||||
|
Unauthorized,
|
||||||
|
#[error("forbidden")]
|
||||||
|
Forbidden,
|
||||||
|
#[error("conflict")]
|
||||||
|
Conflict,
|
||||||
|
#[error("bad request: {0}")]
|
||||||
|
BadRequest(String),
|
||||||
|
#[error("unprocessable entity: {0}")]
|
||||||
|
Unprocessable(String),
|
||||||
|
#[error("not acceptable: {0}")]
|
||||||
|
NotAcceptable(String),
|
||||||
|
#[error("unsupported codec: {0}")]
|
||||||
|
WhepCodecError(String),
|
||||||
|
#[error("internal error")]
|
||||||
|
Internal,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpError {
|
||||||
|
fn status(&self) -> StatusCode {
|
||||||
|
match self {
|
||||||
|
Self::DbErr(_) | Self::Hash | Self::Internal => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Self::NotFound => StatusCode::NOT_FOUND,
|
||||||
|
Self::Unauthorized => StatusCode::UNAUTHORIZED,
|
||||||
|
Self::Forbidden => StatusCode::FORBIDDEN,
|
||||||
|
Self::Conflict => StatusCode::CONFLICT,
|
||||||
|
Self::BadRequest(_) => StatusCode::BAD_REQUEST,
|
||||||
|
Self::Unprocessable(_) => StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
Self::NotAcceptable(_) => StatusCode::NOT_ACCEPTABLE,
|
||||||
|
Self::WhepCodecError(_) => StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for HttpError {
|
||||||
|
fn into_response(self) -> Response<Body> {
|
||||||
|
let status = self.status();
|
||||||
|
let body = match self {
|
||||||
|
// The WHEP client (frontend) reads this body as the rejected
|
||||||
|
// codec, so send it bare rather than the full error string.
|
||||||
|
Self::WhepCodecError(codec) => codec,
|
||||||
|
// Only surface a message body for client (4xx) errors; keep an
|
||||||
|
// empty body for 5xx so internal details aren't leaked.
|
||||||
|
e if status.is_client_error() => e.to_string(),
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
(status, body).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
-37
@@ -1,48 +1,40 @@
|
|||||||
use ::chrono::{DateTime, Utc};
|
use ::chrono::{DateTime, Utc};
|
||||||
use std::{env, error::Error, sync::Arc};
|
use std::{
|
||||||
use tracing::{info, level_filters::LevelFilter, warn};
|
env,
|
||||||
|
error::Error,
|
||||||
|
sync::{Arc, atomic::AtomicU32},
|
||||||
|
};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use async_broadcast::broadcast;
|
use async_broadcast::broadcast;
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use entity::stream_session;
|
use entity::stream_session;
|
||||||
use migration::{Migrator, MigratorTrait};
|
use migration::{Migrator, MigratorTrait};
|
||||||
use rml_rtmp::{
|
use sea_orm::Database;
|
||||||
handshake::{Handshake, HandshakeProcessResult, PeerType},
|
use tokio::{net::TcpListener, sync::Mutex, task::JoinSet};
|
||||||
sessions::{ServerSession, ServerSessionConfig, ServerSessionEvent, ServerSessionResult},
|
|
||||||
};
|
|
||||||
use sea_orm::{
|
|
||||||
Database, IntoActiveModel,
|
|
||||||
sqlx::types::chrono::{self, Local},
|
|
||||||
};
|
|
||||||
use tokio::{
|
|
||||||
fs,
|
|
||||||
io::{AsyncReadExt, AsyncWriteExt},
|
|
||||||
net::TcpListener,
|
|
||||||
sync::Mutex,
|
|
||||||
task::JoinSet,
|
|
||||||
time::Instant,
|
|
||||||
};
|
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
audio::OpusAudioFrame,
|
audio::OpusAudioFrame, codec::VideoFrame, http::HttpServer, webrtc_proxy::WebrtcProxy,
|
||||||
codec::VideoFrame,
|
|
||||||
http::{HttpServer, HttpServerConfig},
|
|
||||||
webrtc_proxy::WebRtcProxyConfig,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
mod audio;
|
mod audio;
|
||||||
mod codec;
|
mod codec;
|
||||||
mod hash;
|
mod hash;
|
||||||
mod http;
|
mod http;
|
||||||
|
mod http_error;
|
||||||
mod rtmp;
|
mod rtmp;
|
||||||
|
mod stream_session_2;
|
||||||
mod webrtc;
|
mod webrtc;
|
||||||
mod webrtc_ingest;
|
mod webrtc_ingest;
|
||||||
mod webrtc_proxy;
|
mod webrtc_proxy;
|
||||||
|
|
||||||
// #[derive(Debug)]
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub stream_sessions: Arc<DashMap<i32, StreamSession>>,
|
pub stream_sessions: Arc<DashMap<i32, StreamSession>>,
|
||||||
|
pub webrtc_proxy: WebrtcProxy,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -52,14 +44,42 @@ pub enum StreamCodec {
|
|||||||
AV1,
|
AV1,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl StreamCodec {
|
||||||
|
/// Map str0m's codec enum to ours; None for non-video codecs.
|
||||||
|
pub fn from_str0m(c: str0m::format::Codec) -> Option<Self> {
|
||||||
|
use str0m::format::Codec;
|
||||||
|
match c {
|
||||||
|
Codec::H264 => Some(Self::H264),
|
||||||
|
Codec::H265 => Some(Self::H265),
|
||||||
|
Codec::Av1 => Some(Self::AV1),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct StreamSession {
|
pub struct StreamSession {
|
||||||
pub stream_key_id: i32,
|
pub stream_key_id: i32,
|
||||||
pub stream_key_label: String,
|
pub stream_key_label: String,
|
||||||
|
pub stream_key_user: String,
|
||||||
|
pub custom_id: Option<String>,
|
||||||
|
pub is_unlisted: bool,
|
||||||
|
pub password: Option<String>,
|
||||||
pub frame_channel: async_broadcast::Sender<Arc<VideoFrame>>,
|
pub frame_channel: async_broadcast::Sender<Arc<VideoFrame>>,
|
||||||
pub audio_channel: async_broadcast::Sender<Arc<OpusAudioFrame>>,
|
pub audio_channel: async_broadcast::Sender<Arc<OpusAudioFrame>>,
|
||||||
pub codec: Option<StreamCodec>,
|
pub codec: Option<StreamCodec>,
|
||||||
//
|
// ---- WHIP-negotiated video codec details (populated by whip ingest) ----
|
||||||
|
/// Payload type from WHIP SDP negotiation.
|
||||||
|
pub video_pt: Option<u8>,
|
||||||
|
/// H.264 profile_level_id from WHIP SDP negotiation (e.g. 0x42e01f for CBP).
|
||||||
|
/// When set alongside `video_pt`, the WHEP answer only advertises this exact
|
||||||
|
/// profile instead of all three H.264 profiles.
|
||||||
|
pub video_profile_level_id: Option<u32>,
|
||||||
|
// ----
|
||||||
|
/// Unique id per publish. Lets a session's cleanup verify it is still the
|
||||||
|
/// live session for its key instead of clobbering a newer one.
|
||||||
|
pub session_id: Uuid,
|
||||||
pub started_at: DateTime<Utc>,
|
pub started_at: DateTime<Utc>,
|
||||||
|
pub active_clients: AtomicU32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -82,16 +102,23 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
stream_session::Model::clean_unended_streams(&db)
|
stream_session::Model::clean_unended_streams(&db)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
let proxyconfig = env::var("RTC_PORT")
|
||||||
|
.unwrap_or("6969".into())
|
||||||
|
.parse()
|
||||||
|
.expect("RTC_PORT needs to be a number (i32)");
|
||||||
|
let proxy = webrtc_proxy::WebrtcProxy::new(proxyconfig).await.unwrap();
|
||||||
|
|
||||||
let appstate = Arc::new(Mutex::new(AppState {
|
let appstate = Arc::new(Mutex::new(AppState {
|
||||||
stream_sessions: Arc::new(DashMap::new()),
|
stream_sessions: Arc::new(DashMap::new()),
|
||||||
|
webrtc_proxy: proxy.clone(),
|
||||||
}));
|
}));
|
||||||
let (offer_tx, offer_rx) = tokio::sync::mpsc::channel::<(i32, i32, String)>(64);
|
let (offer_tx, offer_rx) = tokio::sync::mpsc::channel::<(i32, i32, String)>(64);
|
||||||
|
|
||||||
// Request_Id,
|
// Request_Id,
|
||||||
// String_Label,
|
// String_Label,
|
||||||
// Offer_body
|
// Offer_body
|
||||||
|
|
||||||
let (answer_tx, answer_rx) = broadcast::<(i32, Option<String>)>(64);
|
let (answer_tx, answer_rx) = broadcast::<(i32, Result<String, String>)>(64);
|
||||||
// Request_Id,
|
// Request_Id,
|
||||||
// Answer_body
|
// Answer_body
|
||||||
|
|
||||||
@@ -101,21 +128,13 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
appstate: appstate.clone(),
|
appstate: appstate.clone(),
|
||||||
request_count: std::sync::atomic::AtomicI32::new(0),
|
request_count: std::sync::atomic::AtomicI32::new(0),
|
||||||
db: db.clone(),
|
db: db.clone(),
|
||||||
config: HttpServerConfig {
|
|
||||||
signup_code: env::var("SIGNUP_CODE").unwrap_or_else(|_| {
|
signup_code: env::var("SIGNUP_CODE").unwrap_or_else(|_| {
|
||||||
warn!("SIGNUP_CODE not set; signup will be disabled");
|
warn!("SIGNUP_CODE not set; signup will be disabled");
|
||||||
String::new()
|
String::new()
|
||||||
}),
|
}),
|
||||||
}
|
version: SERVER_VERSION,
|
||||||
.into(),
|
start_time: std::time::Instant::now(),
|
||||||
};
|
};
|
||||||
let proxyconfig = WebRtcProxyConfig {
|
|
||||||
proxy_port: env::var("RTC_PORT")
|
|
||||||
.unwrap_or("6969".into())
|
|
||||||
.parse()
|
|
||||||
.expect("RTC_PORT needs to be a number (i32)"),
|
|
||||||
};
|
|
||||||
let proxy = webrtc_proxy::WebrtcProxy::new(proxyconfig).await.unwrap();
|
|
||||||
|
|
||||||
let app = appstate.lock().await;
|
let app = appstate.lock().await;
|
||||||
let webrtc = webrtc::Webrtc {
|
let webrtc = webrtc::Webrtc {
|
||||||
@@ -123,7 +142,8 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
accept_tx: answer_tx,
|
accept_tx: answer_tx,
|
||||||
sessions_ref: app.stream_sessions.clone(),
|
sessions_ref: app.stream_sessions.clone(),
|
||||||
db: db.clone(),
|
db: db.clone(),
|
||||||
proxy: proxy.clone().into(),
|
proxy: proxy.clone().into(), // The entire of the proxy's internal are within an arc, cheap
|
||||||
|
// to clone
|
||||||
};
|
};
|
||||||
|
|
||||||
let rtmp = rtmp::Rtmp {
|
let rtmp = rtmp::Rtmp {
|
||||||
@@ -147,7 +167,6 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
res = workers.join_next() => {
|
res = workers.join_next() => {
|
||||||
if let Some(Err(e)) = res {
|
if let Some(Err(e)) = res {
|
||||||
tracing::error!("worker panicked: {:?}", e);
|
tracing::error!("worker panicked: {:?}", e);
|
||||||
// fs::File::
|
|
||||||
} else {
|
} else {
|
||||||
tracing::error!("a worker exited unexpectedly");
|
tracing::error!("a worker exited unexpectedly");
|
||||||
}
|
}
|
||||||
|
|||||||
+150
-94
@@ -1,20 +1,21 @@
|
|||||||
use std::{error::Error, sync::Arc};
|
use std::{error::Error, sync::Arc, time::Duration};
|
||||||
|
|
||||||
use async_broadcast::broadcast;
|
use async_broadcast::broadcast;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use entity::stream_session;
|
use entity::{stream_session, users};
|
||||||
use rml_rtmp::{
|
use rml_rtmp::{
|
||||||
handshake::{Handshake, HandshakeProcessResult, PeerType},
|
handshake::{Handshake, HandshakeProcessResult, PeerType},
|
||||||
sessions::{ServerSession, ServerSessionConfig, ServerSessionEvent, ServerSessionResult},
|
sessions::{ServerSession, ServerSessionConfig, ServerSessionEvent, ServerSessionResult},
|
||||||
};
|
};
|
||||||
use sea_orm::{DatabaseConnection, IntoActiveModel, sqlx::types::chrono::Local};
|
use sea_orm::{DatabaseConnection, EntityTrait, IntoActiveModel, sqlx::types::chrono::Local};
|
||||||
use tokio::{
|
use tokio::{
|
||||||
io::{AsyncReadExt, AsyncWriteExt},
|
io::{AsyncReadExt, AsyncWriteExt},
|
||||||
net::{TcpListener, TcpStream},
|
net::{TcpListener, TcpStream},
|
||||||
time::Instant,
|
time::timeout,
|
||||||
};
|
};
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
StreamCodec, StreamSession,
|
StreamCodec, StreamSession,
|
||||||
@@ -24,6 +25,8 @@ use crate::{
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
pub struct Rtmp {
|
pub struct Rtmp {
|
||||||
pub listener: TcpListener,
|
pub listener: TcpListener,
|
||||||
pub stream_sessions: Arc<DashMap<i32, StreamSession>>,
|
pub stream_sessions: Arc<DashMap<i32, StreamSession>>,
|
||||||
@@ -32,8 +35,11 @@ pub struct Rtmp {
|
|||||||
|
|
||||||
async fn write_outbound(socket: &mut TcpStream, results: Vec<ServerSessionResult>) {
|
async fn write_outbound(socket: &mut TcpStream, results: Vec<ServerSessionResult>) {
|
||||||
for r in results {
|
for r in results {
|
||||||
if let ServerSessionResult::OutboundResponse(p) = r {
|
if let ServerSessionResult::OutboundResponse(p) = r
|
||||||
socket.write_all(&p.bytes).await.unwrap();
|
&& let Err(e) = socket.write_all(&p.bytes).await
|
||||||
|
{
|
||||||
|
warn!("RTMP write error: {e}");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,7 +75,10 @@ impl Rtmp {
|
|||||||
let mut server = Handshake::new(PeerType::Server);
|
let mut server = Handshake::new(PeerType::Server);
|
||||||
|
|
||||||
let mut c0_c1 = [0u8; 1537];
|
let mut c0_c1 = [0u8; 1537];
|
||||||
socket.read_exact(&mut c0_c1).await?;
|
let n = timeout(HANDSHAKE_TIMEOUT, socket.read_exact(&mut c0_c1))
|
||||||
|
.await
|
||||||
|
.map_err(|_| "handshake C0+C1 timeout")??;
|
||||||
|
debug!("handshake C0+C1 read {} bytes", n);
|
||||||
let s0_s1_s2 = match server.process_bytes(&c0_c1) {
|
let s0_s1_s2 = match server.process_bytes(&c0_c1) {
|
||||||
Ok(HandshakeProcessResult::InProgress { response_bytes }) => response_bytes,
|
Ok(HandshakeProcessResult::InProgress { response_bytes }) => response_bytes,
|
||||||
x => return Err(format!("unexpected handshake state: {:?}", x).into()),
|
x => return Err(format!("unexpected handshake state: {:?}", x).into()),
|
||||||
@@ -77,7 +86,9 @@ impl Rtmp {
|
|||||||
socket.write_all(&s0_s1_s2).await?;
|
socket.write_all(&s0_s1_s2).await?;
|
||||||
|
|
||||||
let mut c2 = [0u8; 1536];
|
let mut c2 = [0u8; 1536];
|
||||||
socket.read_exact(&mut c2).await?;
|
timeout(HANDSHAKE_TIMEOUT, socket.read_exact(&mut c2))
|
||||||
|
.await
|
||||||
|
.map_err(|_| "handshake C2 timeout")??;
|
||||||
match server.process_bytes(&c2) {
|
match server.process_bytes(&c2) {
|
||||||
Ok(HandshakeProcessResult::Completed { .. }) => {}
|
Ok(HandshakeProcessResult::Completed { .. }) => {}
|
||||||
Ok(HandshakeProcessResult::InProgress { response_bytes }) => {
|
Ok(HandshakeProcessResult::InProgress { response_bytes }) => {
|
||||||
@@ -86,7 +97,8 @@ impl Rtmp {
|
|||||||
x => return Err(format!("unexpected handshake state: {:?}", x).into()),
|
x => return Err(format!("unexpected handshake state: {:?}", x).into()),
|
||||||
}
|
}
|
||||||
|
|
||||||
let (rtmp_session, init_bytes) = ServerSession::new(ServerSessionConfig::new()).unwrap();
|
let (rtmp_session, init_bytes) = ServerSession::new(ServerSessionConfig::new())
|
||||||
|
.map_err(|e| format!("ServerSession::new failed: {e}"))?;
|
||||||
write_outbound(&mut socket, init_bytes).await;
|
write_outbound(&mut socket, init_bytes).await;
|
||||||
|
|
||||||
Ok((rtmp_session, socket))
|
Ok((rtmp_session, socket))
|
||||||
@@ -99,7 +111,14 @@ impl Rtmp {
|
|||||||
db,
|
db,
|
||||||
} = self;
|
} = self;
|
||||||
loop {
|
loop {
|
||||||
let (socket, peer_addr) = listener.accept().await.unwrap();
|
let connection = listener.accept().await;
|
||||||
|
let (socket, peer_addr) = match connection {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Error on connection accept: {}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
info!(%peer_addr, "RTMP connection accepted");
|
info!(%peer_addr, "RTMP connection accepted");
|
||||||
let (mut session, mut socket) = match Rtmp::handshake(socket).await {
|
let (mut session, mut socket) = match Rtmp::handshake(socket).await {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
@@ -109,13 +128,17 @@ impl Rtmp {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
info!(%peer_addr, "RTMP handshake complete");
|
info!(%peer_addr, "RTMP handshake complete");
|
||||||
let (mut video_tx, mut video_rx) = broadcast::<Arc<VideoFrame>>(32);
|
// Ring sized by TIME, not frame count: 512 frames ≈ 5.7s @ 90fps,
|
||||||
let (mut audio_tx, mut audio_rx) = broadcast::<Arc<OpusAudioFrame>>(32);
|
// so transient ingest/send stalls can't overflow it (overflow drops
|
||||||
// video_rx.cycle
|
// the oldest frames and forces a freeze until the next keyframe).
|
||||||
|
// ponytail: fixed 512; per-stream dynamic sizing if memory ever matters.
|
||||||
|
let (mut video_tx, video_rx) = broadcast::<Arc<VideoFrame>>(512);
|
||||||
|
let (mut audio_tx, audio_rx) = broadcast::<Arc<OpusAudioFrame>>(512);
|
||||||
|
|
||||||
video_tx.set_overflow(true);
|
video_tx.set_overflow(true);
|
||||||
audio_tx.set_overflow(true);
|
audio_tx.set_overflow(true);
|
||||||
let mut parser: Option<Box<dyn CodecParser>> = None;
|
let mut parser: Option<Box<dyn CodecParser>> = None;
|
||||||
|
let mut stream_id: Option<i32> = None;
|
||||||
let mut aac_parser = AACParser::new();
|
let mut aac_parser = AACParser::new();
|
||||||
let mut audio_proc = AudioProcesser::new();
|
let mut audio_proc = AudioProcesser::new();
|
||||||
let db = db.clone();
|
let db = db.clone();
|
||||||
@@ -143,22 +166,30 @@ impl Rtmp {
|
|||||||
|
|
||||||
loop {
|
loop {
|
||||||
let mut buf = [0u8; 4096];
|
let mut buf = [0u8; 4096];
|
||||||
let n = match socket.read(&mut buf).await {
|
const IDLE_TIMEOUT: Duration = Duration::from_secs(15);
|
||||||
Ok(0) => {
|
let n = match timeout(IDLE_TIMEOUT, socket.read(&mut buf)).await {
|
||||||
|
Ok(Ok(0)) => {
|
||||||
debug!("RTMP connection closed by peer");
|
debug!("RTMP connection closed by peer");
|
||||||
if let Some(id) = current_stream_key_id {
|
if let Some(id) = current_stream_key_id {
|
||||||
cleanup(id).await;
|
cleanup(id).await;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Ok(n) => n,
|
Ok(Ok(n)) => n,
|
||||||
Err(e) => {
|
Ok(Err(e)) => {
|
||||||
warn!("RTMP read error: {e}");
|
warn!("RTMP read error: {e}");
|
||||||
if let Some(id) = current_stream_key_id {
|
if let Some(id) = current_stream_key_id {
|
||||||
cleanup(id).await;
|
cleanup(id).await;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
Err(_) => {
|
||||||
|
warn!("RTMP timed out (15 sec no packets) id: {:?}", stream_id);
|
||||||
|
if let Some(id) = current_stream_key_id {
|
||||||
|
cleanup(id).await;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let events = match session.handle_input(&buf[..n]) {
|
let events = match session.handle_input(&buf[..n]) {
|
||||||
Ok(e) => e,
|
Ok(e) => e,
|
||||||
@@ -177,12 +208,23 @@ impl Rtmp {
|
|||||||
for event in events {
|
for event in events {
|
||||||
match event {
|
match event {
|
||||||
ServerSessionResult::OutboundResponse(p) => {
|
ServerSessionResult::OutboundResponse(p) => {
|
||||||
socket.write_all(&p.bytes).await.unwrap();
|
if let Err(e) = socket.write_all(&p.bytes).await {
|
||||||
|
warn!("RTMP outbound write error: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ServerSessionResult::RaisedEvent(e) => match e {
|
ServerSessionResult::RaisedEvent(e) => match e {
|
||||||
ServerSessionEvent::ConnectionRequested { request_id, .. } => {
|
ServerSessionEvent::ConnectionRequested { request_id, .. } => {
|
||||||
debug!("RTMP ConnectionRequested, accepting");
|
debug!("RTMP ConnectionRequested, accepting");
|
||||||
let reply = session.accept_request(request_id).unwrap();
|
let reply = match session.accept_request(request_id) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
"Failed to accept connection request {request_id}: {e}"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
write_outbound(&mut socket, reply).await;
|
write_outbound(&mut socket, reply).await;
|
||||||
}
|
}
|
||||||
ServerSessionEvent::PublishStreamRequested {
|
ServerSessionEvent::PublishStreamRequested {
|
||||||
@@ -199,141 +241,155 @@ impl Rtmp {
|
|||||||
key
|
key
|
||||||
} else {
|
} else {
|
||||||
warn!(stream_key = %stream_key, "stream key not found, rejecting");
|
warn!(stream_key = %stream_key, "stream key not found, rejecting");
|
||||||
let reply = session
|
let reply = match session.reject_request(
|
||||||
.reject_request(request_id, "", "Stream key invalid")
|
request_id,
|
||||||
.unwrap();
|
"",
|
||||||
|
"Stream key invalid",
|
||||||
|
) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to reject stream key request: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
write_outbound(&mut socket, reply).await;
|
write_outbound(&mut socket, reply).await;
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
|
|
||||||
let already_live =
|
let already_live =
|
||||||
stream_session::Model::get_active_by_stream_key_id(
|
match stream_session::Model::get_active_by_stream_key_id(
|
||||||
&db, key.id,
|
&db, key.id,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
{
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("DB error checking active stream: {e}");
|
||||||
|
let reply = match session.reject_request(
|
||||||
|
request_id,
|
||||||
|
"",
|
||||||
|
"Internal error",
|
||||||
|
) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e2) => {
|
||||||
|
warn!(
|
||||||
|
"Failed to reject request on DB error: {e2}"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
write_outbound(&mut socket, reply).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
if already_live.is_some() {
|
if already_live.is_some() {
|
||||||
warn!(stream_key_id = key.id, label = %key.label, "stream key already live, rejecting duplicate publish");
|
warn!(stream_key_id = key.id, label = %key.label, "stream key already live, rejecting duplicate publish");
|
||||||
let reply = session
|
let reply = match session.reject_request(
|
||||||
.reject_request(
|
|
||||||
request_id,
|
request_id,
|
||||||
"",
|
"",
|
||||||
"You're already streaming...",
|
"You're already streaming...",
|
||||||
)
|
) {
|
||||||
.unwrap();
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to reject duplicate publish: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
write_outbound(&mut socket, reply).await;
|
write_outbound(&mut socket, reply).await;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
info!(stream_key_id = key.id, label = %key.label, "stream started");
|
info!(stream_key_id = key.id, label = %key.label, "stream started");
|
||||||
|
let user = users::Entity::find_by_id(key.user_id)
|
||||||
|
.one(&db)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
current_stream_key_id = Some(key.id);
|
current_stream_key_id = Some(key.id);
|
||||||
stream_sessions.insert(
|
stream_sessions.insert(
|
||||||
key.id,
|
key.id,
|
||||||
StreamSession {
|
StreamSession {
|
||||||
stream_key_id: key.id,
|
stream_key_id: key.id,
|
||||||
stream_key_label: key.label,
|
stream_key_label: key.label,
|
||||||
|
stream_key_user: user.username,
|
||||||
|
custom_id: key.custom_id,
|
||||||
|
is_unlisted: key.is_unlisted,
|
||||||
|
password: key.password,
|
||||||
frame_channel: video_tx.clone(),
|
frame_channel: video_tx.clone(),
|
||||||
audio_channel: audio_tx.clone(),
|
audio_channel: audio_tx.clone(),
|
||||||
codec: None,
|
codec: None,
|
||||||
started_at: Utc::now(),
|
started_at: Utc::now(),
|
||||||
|
active_clients: 0.into(),
|
||||||
|
session_id: Uuid::new_v4(),
|
||||||
|
video_pt: None,
|
||||||
|
video_profile_level_id: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let reply = session.accept_request(request_id).unwrap();
|
let reply = match session.accept_request(request_id) {
|
||||||
stream_session::Model::create_stream_session(
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to accept publish request: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = stream_session::Model::create_stream_session(
|
||||||
&db,
|
&db,
|
||||||
key.id,
|
key.id,
|
||||||
Local::now().into(),
|
Local::now().into(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
{
|
||||||
|
warn!("Failed to create stream session record: {e}");
|
||||||
|
let _ = session.reject_request(
|
||||||
|
request_id,
|
||||||
|
"",
|
||||||
|
"Internal error",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stream_id = Some(key.id);
|
||||||
write_outbound(&mut socket, reply).await;
|
write_outbound(&mut socket, reply).await;
|
||||||
}
|
}
|
||||||
ServerSessionEvent::PublishStreamFinished {
|
ServerSessionEvent::PublishStreamFinished {
|
||||||
stream_key, ..
|
stream_key, ..
|
||||||
} => {
|
} => {
|
||||||
info!(stream_key = %stream_key, "publish stream finished");
|
info!(stream_key = %stream_key, "publish stream finished");
|
||||||
let key =
|
match entity::stream_key::Entity::find_by_key(&db, &stream_key)
|
||||||
entity::stream_key::Entity::find_by_key(&db, &stream_key)
|
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
{
|
||||||
.unwrap();
|
Ok(Some(key)) => cleanup(key.id).await,
|
||||||
stream_sessions.remove(&key.id);
|
Ok(None) => {
|
||||||
stream_session::Model::get_active_by_stream_key_id(&db, key.id)
|
warn!(stream_key = %stream_key, "publish finished for unknown stream key");
|
||||||
.await
|
}
|
||||||
.unwrap()
|
Err(e) => {
|
||||||
.unwrap()
|
warn!(stream_key = %stream_key, "failed to look up stream key on finish: {e}");
|
||||||
.into_active_model()
|
}
|
||||||
.finish_stream_session(&db, Local::now().into())
|
}
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
current_stream_key_id = None;
|
current_stream_key_id = None;
|
||||||
}
|
}
|
||||||
// TODO: We can totally replace the broadcast with a
|
|
||||||
// circular_buff
|
|
||||||
// Arc<Vec<ArcSwap<Frame>>>
|
|
||||||
ServerSessionEvent::VideoDataReceived {
|
ServerSessionEvent::VideoDataReceived {
|
||||||
data, timestamp, ..
|
data, timestamp, ..
|
||||||
} => match Self::parse_video_codec(&data) {
|
} => match Self::parse_video_codec(&data) {
|
||||||
Ok(codec) => {
|
Ok(codec) => {
|
||||||
if !codec_stamped {
|
if !codec_stamped {
|
||||||
if let Some(id) = current_stream_key_id {
|
if let Some(id) = current_stream_key_id
|
||||||
if let Some(mut session) =
|
&& let Some(mut session) =
|
||||||
stream_sessions.get_mut(&id)
|
stream_sessions.get_mut(&id)
|
||||||
{
|
{
|
||||||
session.codec = Some(codec.clone());
|
session.codec = Some(codec.clone());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
codec_stamped = true;
|
codec_stamped = true;
|
||||||
}
|
}
|
||||||
match codec {
|
let p = parser.get_or_insert_with(|| match codec {
|
||||||
StreamCodec::H264 => {
|
StreamCodec::H264 => Box::new(H264CodecParser::new()),
|
||||||
let p = parser.get_or_insert_with(|| {
|
StreamCodec::H265 => Box::new(H265CodecParser::new()),
|
||||||
Box::new(H264CodecParser::new())
|
StreamCodec::AV1 => Box::new(Av1CodecParser::new()),
|
||||||
});
|
});
|
||||||
if let Some(frame) = p.parse(&data, timestamp.value)
|
if let Some(frame) = p.parse(&data, timestamp.value) {
|
||||||
{
|
|
||||||
video_tx.broadcast(Arc::new(frame)).await.ok();
|
video_tx.broadcast(Arc::new(frame)).await.ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
StreamCodec::H265 => {
|
|
||||||
let p = parser.get_or_insert_with(|| {
|
|
||||||
Box::new(H265CodecParser::new())
|
|
||||||
});
|
|
||||||
if let Some(frame) = p.parse(&data, timestamp.value)
|
|
||||||
{
|
|
||||||
video_tx.broadcast(Arc::new(frame)).await.ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
StreamCodec::AV1 => {
|
|
||||||
let p = parser.get_or_insert_with(|| {
|
|
||||||
Box::new(Av1CodecParser::new())
|
|
||||||
});
|
|
||||||
let pkt_type = data[0] & 0x0F;
|
|
||||||
match p.parse(&data, timestamp.value) {
|
|
||||||
Some(frame) => {
|
|
||||||
debug!(
|
|
||||||
pkt_type,
|
|
||||||
is_keyframe = frame.is_keyframe,
|
|
||||||
ts = frame.timestamp_ms,
|
|
||||||
bytes = frame.data.len(),
|
|
||||||
"AV1 frame → broadcast"
|
|
||||||
);
|
|
||||||
video_tx
|
|
||||||
.broadcast(Arc::new(frame))
|
|
||||||
.await
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
debug!(
|
|
||||||
pkt_type,
|
|
||||||
"AV1 packet produced no frame (seq header or unknown type)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_err) => {
|
Err(_err) => {
|
||||||
warn!("");
|
warn!("");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::StreamSession;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct StreamUpdateData {
|
||||||
|
viewers: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StreamSession {
|
||||||
|
pub fn stream_update_data(&self) -> StreamUpdateData {
|
||||||
|
StreamUpdateData {
|
||||||
|
viewers: self
|
||||||
|
.active_clients
|
||||||
|
.load(std::sync::atomic::Ordering::Relaxed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+157
-114
@@ -2,13 +2,18 @@ use bytes::Bytes;
|
|||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use entity::stream_key;
|
use entity::stream_key;
|
||||||
use sea_orm::{DatabaseConnection, EntityTrait};
|
use sea_orm::{DatabaseConnection, EntityTrait};
|
||||||
use std::{net::SocketAddr, sync::Arc, time::Instant};
|
use std::{
|
||||||
|
net::SocketAddr,
|
||||||
|
sync::Arc,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
use tokio::{net::UdpSocket, sync::mpsc::Receiver};
|
use tokio::{net::UdpSocket, sync::mpsc::Receiver};
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, trace, warn};
|
||||||
|
|
||||||
use str0m::{
|
use str0m::{
|
||||||
Candidate, Event, Input, Output, Rtc,
|
Candidate, Event, Input, Output, Rtc,
|
||||||
change::SdpOffer,
|
change::SdpOffer,
|
||||||
|
channel::ChannelId,
|
||||||
media::{Frequency, MediaKind, MediaTime, Mid, Pt},
|
media::{Frequency, MediaKind, MediaTime, Mid, Pt},
|
||||||
net::{Protocol, Receive},
|
net::{Protocol, Receive},
|
||||||
};
|
};
|
||||||
@@ -19,14 +24,12 @@ use crate::{
|
|||||||
|
|
||||||
pub struct Webrtc {
|
pub struct Webrtc {
|
||||||
pub offer_rx: Receiver<(i32, i32, String)>,
|
pub offer_rx: Receiver<(i32, i32, String)>,
|
||||||
pub accept_tx: async_broadcast::Sender<(i32, Option<String>)>,
|
pub accept_tx: async_broadcast::Sender<(i32, Result<String, String>)>,
|
||||||
pub sessions_ref: Arc<DashMap<i32, StreamSession>>,
|
pub sessions_ref: Arc<DashMap<i32, StreamSession>>,
|
||||||
pub proxy: Arc<WebrtcProxy>,
|
pub proxy: Arc<WebrtcProxy>,
|
||||||
pub db: DatabaseConnection,
|
pub db: DatabaseConnection,
|
||||||
}
|
}
|
||||||
|
|
||||||
const PER_CLIENT_CONNECTION_BUF: usize = 65535;
|
|
||||||
|
|
||||||
impl Webrtc {
|
impl Webrtc {
|
||||||
pub async fn run(mut self) {
|
pub async fn run(mut self) {
|
||||||
while let Some(offer) = self.offer_rx.recv().await {
|
while let Some(offer) = self.offer_rx.recv().await {
|
||||||
@@ -42,7 +45,10 @@ impl Webrtc {
|
|||||||
request_id,
|
request_id,
|
||||||
stream_id, "stream key not found in DB, rejecting offer"
|
stream_id, "stream key not found in DB, rejecting offer"
|
||||||
);
|
);
|
||||||
self.accept_tx.broadcast((request_id, None)).await.unwrap();
|
self.accept_tx
|
||||||
|
.broadcast((request_id, Err(String::new())))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Err(ref e) = stream_key {
|
if let Err(ref e) = stream_key {
|
||||||
@@ -50,48 +56,77 @@ impl Webrtc {
|
|||||||
request_id,
|
request_id,
|
||||||
stream_id, "DB error looking up stream key: {:?}", e
|
stream_id, "DB error looking up stream key: {:?}", e
|
||||||
);
|
);
|
||||||
self.accept_tx.broadcast((request_id, None)).await.unwrap();
|
self.accept_tx
|
||||||
|
.broadcast((request_id, Err(String::new())))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// let socket = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
// let local_addr = socket.local_addr().unwrap();
|
|
||||||
|
|
||||||
let local_addr = self.proxy.public_addr();
|
let local_addr = self.proxy.public_addr();
|
||||||
|
|
||||||
let stream_codec = self
|
let session = self.sessions_ref.get(&stream_id);
|
||||||
.sessions_ref
|
let stream_codec = session.as_ref().and_then(|s| s.codec.clone());
|
||||||
.get(&stream_id)
|
let video_pt = session.as_ref().and_then(|s| s.video_pt);
|
||||||
.and_then(|s| s.codec.clone());
|
let video_profile = session.as_ref().and_then(|s| s.video_profile_level_id);
|
||||||
|
drop(session);
|
||||||
|
|
||||||
info!(request_id, stream_id, codec = ?match &stream_codec {
|
info!(request_id, stream_id, codec = ?match &stream_codec {
|
||||||
Some(StreamCodec::H264) => "H264",
|
Some(StreamCodec::H264) => "H264",
|
||||||
Some(StreamCodec::H265) => "H265",
|
Some(StreamCodec::H265) => "H265",
|
||||||
Some(StreamCodec::AV1) => "AV1",
|
Some(StreamCodec::AV1) => "AV1",
|
||||||
None => "unknown",
|
None => "unknown",
|
||||||
}, "configuring RTC codec");
|
}, video_pt, video_profile, "configuring RTC codec");
|
||||||
|
|
||||||
let mut builder = Rtc::builder();
|
let mut builder = Rtc::builder();
|
||||||
{
|
{
|
||||||
|
use str0m::format::{Codec, FormatParams};
|
||||||
|
use str0m::media::Frequency;
|
||||||
|
|
||||||
let cc = builder.codec_config();
|
let cc = builder.codec_config();
|
||||||
cc.clear();
|
cc.clear();
|
||||||
cc.enable_opus(true);
|
cc.enable_opus(true);
|
||||||
match stream_codec {
|
|
||||||
Some(StreamCodec::H265) => {
|
// When video_pt is set (WHIP-negotiated), enable only that
|
||||||
|
// exact codec+PT+profile. Otherwise fall back to enabling all
|
||||||
|
// common profiles for the codec family (RTMP ingest doesn't
|
||||||
|
// carry profile info, so we cast a wide net for browser compat).
|
||||||
|
match (stream_codec.clone(), video_pt, video_profile) {
|
||||||
|
// --- Specific PT + profile (WHIP path) ---
|
||||||
|
(Some(StreamCodec::H264), Some(pt), Some(profile)) => {
|
||||||
|
cc.add_h264(pt.into(), None, true, profile);
|
||||||
|
}
|
||||||
|
(Some(StreamCodec::H264), Some(pt), None) => {
|
||||||
|
// PT known, profile unknown — default to Constrained
|
||||||
|
// Baseline (works everywhere but may mismatch for
|
||||||
|
// Main/High streams from OBS).
|
||||||
|
cc.add_h264(pt.into(), None, true, 0x42e01f);
|
||||||
|
}
|
||||||
|
(Some(StreamCodec::H265), Some(pt), _) => {
|
||||||
|
cc.add_h265(pt.into(), None, 1, 0, 180);
|
||||||
|
}
|
||||||
|
(Some(StreamCodec::AV1), Some(pt), _) => {
|
||||||
|
cc.add_config(
|
||||||
|
pt.into(),
|
||||||
|
None,
|
||||||
|
Codec::Av1,
|
||||||
|
Frequency::NINETY_KHZ,
|
||||||
|
None,
|
||||||
|
FormatParams::default(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Broad codec family (RTMP path, no WHIP info) ---
|
||||||
|
(Some(StreamCodec::H265), None, _) => {
|
||||||
cc.enable_h265(true);
|
cc.enable_h265(true);
|
||||||
}
|
}
|
||||||
Some(StreamCodec::AV1) => {
|
(Some(StreamCodec::AV1), None, _) => {
|
||||||
cc.enable_av1(true);
|
cc.enable_av1(true);
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// Advertise the common H.264 profiles. str0m matches an
|
cc.add_h264(102.into(), None, true, 0x42e01f);
|
||||||
// incoming offer's payload by profile-level-id, so we must
|
cc.add_h264(104.into(), None, true, 0x4d001f);
|
||||||
// list every profile a browser might offer or negotiation
|
cc.add_h264(106.into(), None, true, 0x64001f);
|
||||||
// fails and the m=video line comes back with an empty PT
|
|
||||||
// list. Firefox in particular only ever offers Constrained
|
|
||||||
// Baseline (0x42e01f) — without it Firefox sees no codec.
|
|
||||||
cc.add_h264(102.into(), None, true, 0x42e01f); // Constrained Baseline
|
|
||||||
cc.add_h264(104.into(), None, true, 0x4d001f); // Main
|
|
||||||
cc.add_h264(106.into(), None, true, 0x64001f); // High
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,7 +135,20 @@ impl Webrtc {
|
|||||||
let candidate = Candidate::host(local_addr, Protocol::Udp).unwrap();
|
let candidate = Candidate::host(local_addr, Protocol::Udp).unwrap();
|
||||||
rtc.add_local_candidate(candidate);
|
rtc.add_local_candidate(candidate);
|
||||||
|
|
||||||
let offer_sdp = SdpOffer::from_sdp_string(&sdp_body).unwrap();
|
// Malformed SDP from an unauthenticated client must not panic here:
|
||||||
|
// a panic kills the webrtc worker and main() aborts every other
|
||||||
|
// worker, taking the whole server down.
|
||||||
|
let offer_sdp = match SdpOffer::from_sdp_string(&sdp_body) {
|
||||||
|
Ok(sdp) => sdp,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(request_id, stream_id, "malformed SDP offer: {:?}", e);
|
||||||
|
self.accept_tx
|
||||||
|
.broadcast((request_id, Err(String::new())))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
let mut changes = rtc.sdp_api();
|
let mut changes = rtc.sdp_api();
|
||||||
let mid = changes.add_media(
|
let mid = changes.add_media(
|
||||||
MediaKind::Video,
|
MediaKind::Video,
|
||||||
@@ -125,7 +173,7 @@ impl Webrtc {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let answer_sdp = offer_answer.to_sdp_string();
|
let answer_sdp = offer_answer.to_sdp_string();
|
||||||
info!(request_id, "SDP answer:\n{}", answer_sdp);
|
trace!(request_id, "SDP answer:\n{}", answer_sdp);
|
||||||
|
|
||||||
// Detect the case where str0m couldn't match any video codec.
|
// Detect the case where str0m couldn't match any video codec.
|
||||||
// str0m serialises the m-line with an empty PT list, which is invalid SDP
|
// str0m serialises the m-line with an empty PT list, which is invalid SDP
|
||||||
@@ -142,7 +190,10 @@ impl Webrtc {
|
|||||||
"no video codec negotiated — browser likely doesn't support {:?}; rejecting offer",
|
"no video codec negotiated — browser likely doesn't support {:?}; rejecting offer",
|
||||||
stream_codec
|
stream_codec
|
||||||
);
|
);
|
||||||
self.accept_tx.broadcast((request_id, None)).await.unwrap();
|
self.accept_tx
|
||||||
|
.broadcast((request_id, Err(format!("{:?}", stream_codec))))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +207,7 @@ impl Webrtc {
|
|||||||
|
|
||||||
debug!(request_id, "sending answer back");
|
debug!(request_id, "sending answer back");
|
||||||
self.accept_tx
|
self.accept_tx
|
||||||
.broadcast((request_id, Some(answer_sdp)))
|
.broadcast((request_id, Ok(answer_sdp)))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -190,10 +241,34 @@ impl Webrtc {
|
|||||||
let mut video_pt = None;
|
let mut video_pt = None;
|
||||||
let mut audio_mid: Option<Mid> = None;
|
let mut audio_mid: Option<Mid> = None;
|
||||||
let mut audio_pt = None;
|
let mut audio_pt = None;
|
||||||
|
let mut channel_id = None;
|
||||||
let mut connected = false;
|
let mut connected = false;
|
||||||
let mut video_stream: Option<async_broadcast::Receiver<Arc<VideoFrame>>> = None;
|
let mut video_stream: Option<async_broadcast::Receiver<Arc<VideoFrame>>> = None;
|
||||||
let mut audio_stream: Option<async_broadcast::Receiver<Arc<OpusAudioFrame>>> = None;
|
let mut audio_stream: Option<async_broadcast::Receiver<Arc<OpusAudioFrame>>> = None;
|
||||||
let mut saw_keyframe = false;
|
let mut saw_keyframe = false;
|
||||||
|
// Update client with stream info through webrtc data channel ()
|
||||||
|
let mut tick = tokio::time::interval(Duration::from_millis(2000));
|
||||||
|
|
||||||
|
if let Some(ses) = sessions_ref.get(&stream_id) {
|
||||||
|
ses.active_clients
|
||||||
|
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
struct ActiveClientGuard {
|
||||||
|
sessions: Arc<DashMap<i32, StreamSession>>,
|
||||||
|
id: i32,
|
||||||
|
}
|
||||||
|
impl Drop for ActiveClientGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Some(ses) = self.sessions.get(&self.id) {
|
||||||
|
ses.active_clients
|
||||||
|
.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _active_client_guard = ActiveClientGuard {
|
||||||
|
sessions: sessions_ref.clone(),
|
||||||
|
id: stream_id,
|
||||||
|
};
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let deadline = loop {
|
let deadline = loop {
|
||||||
@@ -206,6 +281,9 @@ impl Webrtc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Output::Event(e)) => match e {
|
Ok(Output::Event(e)) => match e {
|
||||||
|
Event::ChannelOpen(channelId, _name) => {
|
||||||
|
channel_id = Some(channelId);
|
||||||
|
}
|
||||||
Event::MediaAdded(ma) => {
|
Event::MediaAdded(ma) => {
|
||||||
info!(stream_id, kind = ?ma.kind, mid = ?ma.mid, "MediaAdded");
|
info!(stream_id, kind = ?ma.kind, mid = ?ma.mid, "MediaAdded");
|
||||||
if ma.kind == MediaKind::Video {
|
if ma.kind == MediaKind::Video {
|
||||||
@@ -246,11 +324,12 @@ impl Webrtc {
|
|||||||
warn!(stream_id, mid = ?ma.mid, "no writer for video mid");
|
warn!(stream_id, mid = ?ma.mid, "no writer for video mid");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ma.kind == MediaKind::Audio {
|
if ma.kind == MediaKind::Audio
|
||||||
if let Some(writer) = rtc.writer(ma.mid) {
|
&& let Some(writer) = rtc.writer(ma.mid)
|
||||||
let best = writer.payload_params().max_by_key(|p| {
|
{
|
||||||
p.spec().format.profile_level_id.unwrap_or(0)
|
let best = writer
|
||||||
});
|
.payload_params()
|
||||||
|
.max_by_key(|p| p.spec().format.profile_level_id.unwrap_or(0));
|
||||||
if let Some(params) = best {
|
if let Some(params) = best {
|
||||||
info!(stream_id, pt = ?params.pt(), "selected audio PT");
|
info!(stream_id, pt = ?params.pt(), "selected audio PT");
|
||||||
audio_pt = Some(params.pt());
|
audio_pt = Some(params.pt());
|
||||||
@@ -258,7 +337,6 @@ impl Webrtc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Event::IceConnectionStateChange(state) => {
|
Event::IceConnectionStateChange(state) => {
|
||||||
use str0m::IceConnectionState;
|
use str0m::IceConnectionState;
|
||||||
info!("ICE state: {:?}", state);
|
info!("ICE state: {:?}", state);
|
||||||
@@ -273,8 +351,8 @@ impl Webrtc {
|
|||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
Err(e) => {
|
Err(_e) => {
|
||||||
error!("poll_output error (connection closing): {:?}", e);
|
// error!("poll_output error (connection closing): {:?}", e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -292,29 +370,27 @@ impl Webrtc {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(video) = &video_stream {
|
if let Some(video) = &video_stream
|
||||||
if video.is_closed() {
|
&& video.is_closed()
|
||||||
if let Some(session) = sessions_ref.get(&stream_id) {
|
&& let Some(session) = sessions_ref.get(&stream_id)
|
||||||
|
{
|
||||||
video_stream = Some(session.frame_channel.new_receiver());
|
video_stream = Some(session.frame_channel.new_receiver());
|
||||||
debug!(stream_id, "subscribed to video channel");
|
debug!(stream_id, "subscribed to video channel");
|
||||||
}
|
}
|
||||||
}
|
if audio_stream.is_none()
|
||||||
}
|
&& let Some(session) = sessions_ref.get(&stream_id)
|
||||||
if audio_stream.is_none() {
|
{
|
||||||
if let Some(session) = sessions_ref.get(&stream_id) {
|
|
||||||
audio_stream = Some(session.audio_channel.new_receiver());
|
audio_stream = Some(session.audio_channel.new_receiver());
|
||||||
debug!(stream_id, "subscribed to audio channel");
|
debug!(stream_id, "subscribed to audio channel");
|
||||||
}
|
}
|
||||||
}
|
if let Some(audio) = &audio_stream
|
||||||
if let Some(audio) = &audio_stream {
|
&& audio.is_closed()
|
||||||
if audio.is_closed() {
|
&& let Some(session) = sessions_ref.get(&stream_id)
|
||||||
if let Some(session) = sessions_ref.get(&stream_id) {
|
{
|
||||||
audio_stream = Some(session.audio_channel.new_receiver());
|
audio_stream = Some(session.audio_channel.new_receiver());
|
||||||
debug!(stream_id, "subscribed to audio channel");
|
debug!(stream_id, "subscribed to audio channel");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for whichever happens first: str0m's own deadline, an incoming UDP
|
// Wait for whichever happens first: str0m's own deadline, an incoming UDP
|
||||||
// packet, or a fresh video/audio frame. Waking directly on frame arrival
|
// packet, or a fresh video/audio frame. Waking directly on frame arrival
|
||||||
@@ -333,9 +409,9 @@ impl Webrtc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
result = rx.recv() => {
|
result = rx.recv() => {
|
||||||
if let Some((data, from)) = result {
|
if let Some((data, from)) = result
|
||||||
if let Ok(contents) = (&data[..]).try_into() {
|
&& let Ok(contents) = (&data[..]).try_into()
|
||||||
if let Err(e) = rtc.handle_input(Input::Receive(
|
&& let Err(e) = rtc.handle_input(Input::Receive(
|
||||||
Instant::now(),
|
Instant::now(),
|
||||||
Receive {
|
Receive {
|
||||||
proto: Protocol::Udp,
|
proto: Protocol::Udp,
|
||||||
@@ -348,20 +424,24 @@ impl Webrtc {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
res = Webrtc::recv_video(&mut video_stream), if video_stream.is_some() => {
|
res = Webrtc::recv_video(&mut video_stream), if video_stream.is_some() => {
|
||||||
match res {
|
match res {
|
||||||
Ok(frame) => {
|
Ok(frame) => {
|
||||||
|
// One frame per wake: the select! re-arms immediately while
|
||||||
|
// more frames are pending, so backlog catch-up is paced
|
||||||
|
// frame-by-frame instead of an 8-frame burst (which overflows
|
||||||
|
// the browser jitter buffer and self-reinforces the backlog).
|
||||||
Webrtc::write_video_frame(
|
Webrtc::write_video_frame(
|
||||||
frame, &mut saw_keyframe, video_pt, video_mid, &mut rtc, stream_id,
|
frame, &mut saw_keyframe, video_pt, video_mid, &mut rtc, stream_id,
|
||||||
);
|
);
|
||||||
Webrtc::drain_video(
|
|
||||||
&mut video_stream, &mut saw_keyframe, video_pt, video_mid, &mut rtc, stream_id,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Err(async_broadcast::RecvError::Closed) => {
|
Err(async_broadcast::RecvError::Closed) => {
|
||||||
debug!(stream_id, "video channel closed, stream ended");
|
debug!(stream_id, "video channel closed, stream ended");
|
||||||
|
// Drop the dead receiver so the select! branch disarms
|
||||||
|
// instead of spinning on Closed every iteration. The
|
||||||
|
// subscribe block above re-arms it if the same stream
|
||||||
|
// key is re-published.
|
||||||
|
video_stream = None;
|
||||||
}
|
}
|
||||||
Err(async_broadcast::RecvError::Overflowed(_)) => {
|
Err(async_broadcast::RecvError::Overflowed(_)) => {
|
||||||
// Frames were dropped from the ring buffer; resuming mid-GOP
|
// Frames were dropped from the ring buffer; resuming mid-GOP
|
||||||
@@ -375,14 +455,22 @@ impl Webrtc {
|
|||||||
match res {
|
match res {
|
||||||
Ok(frame) => {
|
Ok(frame) => {
|
||||||
Webrtc::write_audio_frame(frame, audio_pt, audio_mid, &mut rtc);
|
Webrtc::write_audio_frame(frame, audio_pt, audio_mid, &mut rtc);
|
||||||
Webrtc::drain_audio(&mut audio_stream, audio_pt, audio_mid, &mut rtc);
|
|
||||||
}
|
}
|
||||||
Err(async_broadcast::RecvError::Closed) => {
|
Err(async_broadcast::RecvError::Closed) => {
|
||||||
debug!("audio channel closed, stream ended");
|
debug!("audio channel closed, stream ended");
|
||||||
|
audio_stream = None;
|
||||||
}
|
}
|
||||||
Err(async_broadcast::RecvError::Overflowed(_)) => {}
|
Err(async_broadcast::RecvError::Overflowed(_)) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
_interval = tick.tick() => {
|
||||||
|
if let Some(id) = channel_id
|
||||||
|
&& let Some(session) = sessions_ref.get(&stream_id)
|
||||||
|
&& let Ok(json) = serde_json::to_vec(&session.stream_update_data())
|
||||||
|
&& let Some(mut ch) = rtc.channel(id) {
|
||||||
|
let _ = ch.write(false, &json);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -435,38 +523,6 @@ impl Webrtc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn drain_video(
|
|
||||||
stream: &mut Option<async_broadcast::Receiver<Arc<VideoFrame>>>,
|
|
||||||
saw_keyframe: &mut bool,
|
|
||||||
video_pt: Option<Pt>,
|
|
||||||
video_mid: Option<Mid>,
|
|
||||||
rtc: &mut Rtc,
|
|
||||||
stream_id: i32,
|
|
||||||
) {
|
|
||||||
let Some(s) = stream.as_mut() else { return };
|
|
||||||
for _ in 0..7 {
|
|
||||||
match s.try_recv() {
|
|
||||||
Ok(frame) => Webrtc::write_video_frame(
|
|
||||||
frame,
|
|
||||||
saw_keyframe,
|
|
||||||
video_pt,
|
|
||||||
video_mid,
|
|
||||||
rtc,
|
|
||||||
stream_id,
|
|
||||||
),
|
|
||||||
Err(async_broadcast::TryRecvError::Empty) => break,
|
|
||||||
Err(async_broadcast::TryRecvError::Closed) => {
|
|
||||||
warn!(stream_id, "video channel closed, stream ended");
|
|
||||||
*stream = None;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Err(async_broadcast::TryRecvError::Overflowed(_)) => {
|
|
||||||
*saw_keyframe = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_audio_frame(
|
fn write_audio_frame(
|
||||||
frame: Arc<OpusAudioFrame>,
|
frame: Arc<OpusAudioFrame>,
|
||||||
audio_pt: Option<Pt>,
|
audio_pt: Option<Pt>,
|
||||||
@@ -475,31 +531,18 @@ impl Webrtc {
|
|||||||
) {
|
) {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let rtp_time = MediaTime::new(frame.timestamp_ms as u64 * 48, Frequency::FORTY_EIGHT_KHZ);
|
let rtp_time = MediaTime::new(frame.timestamp_ms as u64 * 48, Frequency::FORTY_EIGHT_KHZ);
|
||||||
if let (Some(pt), Some(writer)) = (audio_pt, audio_mid.and_then(|m| rtc.writer(m))) {
|
if let (Some(pt), Some(writer)) = (audio_pt, audio_mid.and_then(|m| rtc.writer(m)))
|
||||||
if let Err(e) = writer.write(pt, now, rtp_time, frame.data.to_vec()) {
|
&& let Err(e) = writer.write(pt, now, rtp_time, frame.data.to_vec())
|
||||||
|
{
|
||||||
warn!("RTP write error: {:?}", e);
|
warn!("RTP write error: {:?}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fn drain_audio(
|
fn write_channel_data(rtc: &mut Rtc, channel_id: ChannelId, data: &[u8]) {
|
||||||
stream: &mut Option<async_broadcast::Receiver<Arc<OpusAudioFrame>>>,
|
if let Some(mut channel) = rtc.channel(channel_id)
|
||||||
audio_pt: Option<Pt>,
|
&& let Err(e) = channel.write(false, data)
|
||||||
audio_mid: Option<Mid>,
|
{
|
||||||
rtc: &mut Rtc,
|
warn!("Channel write error: {:?}", e)
|
||||||
) {
|
|
||||||
let Some(s) = stream.as_mut() else { return };
|
|
||||||
for _ in 0..7 {
|
|
||||||
match s.try_recv() {
|
|
||||||
Ok(frame) => Webrtc::write_audio_frame(frame, audio_pt, audio_mid, rtc),
|
|
||||||
Err(async_broadcast::TryRecvError::Empty) => break,
|
|
||||||
Err(async_broadcast::TryRecvError::Closed) => {
|
|
||||||
info!("audio channel closed, stream ended");
|
|
||||||
*stream = None;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Err(async_broadcast::TryRecvError::Overflowed(_)) => {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,633 @@
|
|||||||
use std::sync::Arc;
|
use std::{
|
||||||
|
net::SocketAddr,
|
||||||
|
sync::Arc,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
use axum::{extract::State, response::IntoResponse};
|
use async_broadcast::broadcast;
|
||||||
use str0m::change::SdpOffer;
|
use axum::{
|
||||||
use tracing::info;
|
extract::{ConnectInfo, Path, State},
|
||||||
|
http::{HeaderMap, StatusCode, header},
|
||||||
|
response::IntoResponse,
|
||||||
|
};
|
||||||
|
use bytes::Bytes;
|
||||||
|
use chrono::{Local, Utc};
|
||||||
|
use dashmap::DashMap;
|
||||||
|
use entity::{stream_key, stream_session, users};
|
||||||
|
use sea_orm::{DatabaseConnection, EntityTrait, IntoActiveModel};
|
||||||
|
use str0m::{
|
||||||
|
Candidate, Event, Input, Output, Rtc,
|
||||||
|
change::SdpOffer,
|
||||||
|
media::{MediaKind, Mid},
|
||||||
|
net::{Protocol, Receive},
|
||||||
|
};
|
||||||
|
use tokio::{net::UdpSocket, sync::mpsc::Receiver};
|
||||||
|
use tracing::{debug, error, info, trace, warn};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::http::HttpServer;
|
use crate::{
|
||||||
|
StreamSession, audio::OpusAudioFrame, codec::VideoFrame, http::HttpServer,
|
||||||
|
http_error::HttpError,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Kill a WHIP publish if it delivers no media (video or audio) this long.
|
||||||
|
const NO_MEDIA_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
fn bearer_token(headers: &HeaderMap) -> Option<String> {
|
||||||
|
headers
|
||||||
|
.get(header::AUTHORIZATION)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|v| v.strip_prefix("Bearer "))
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_whip_injest_delete(
|
||||||
|
State(state): State<Arc<HttpServer>>,
|
||||||
|
ConnectInfo(remote): ConnectInfo<SocketAddr>,
|
||||||
|
Path(_slug): Path<String>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<impl IntoResponse, HttpError> {
|
||||||
|
let Some(token) = bearer_token(&headers) else {
|
||||||
|
warn!("Whip: missing bearer token from {}", remote);
|
||||||
|
return Err(HttpError::Unauthorized);
|
||||||
|
};
|
||||||
|
|
||||||
|
let key = stream_key::Entity::find_by_key(&state.db, &token)
|
||||||
|
.await?
|
||||||
|
.ok_or(HttpError::Unauthorized)?;
|
||||||
|
|
||||||
|
// The detach task may have already cleaned up the session when ICE
|
||||||
|
// disconnected (OBS closes the PeerConnection before sending DELETE).
|
||||||
|
// Deleting an already-gone session is still a successful delete.
|
||||||
|
if let Some(active_session) =
|
||||||
|
stream_session::Model::get_active_by_stream_key_id(&state.db, key.id).await?
|
||||||
|
{
|
||||||
|
active_session
|
||||||
|
.into_active_model()
|
||||||
|
.finish_stream_session(&state.db, Utc::now())
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.appstate.lock().await.stream_sessions.remove(&key.id);
|
||||||
|
// Drop the trickle channel too, so PATCHes to a deleted session 404 and
|
||||||
|
// the lingering detach task's own cleanup can't reach a newer session.
|
||||||
|
state
|
||||||
|
.appstate
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.webrtc_proxy
|
||||||
|
.trickle_tx
|
||||||
|
.remove(&key.id);
|
||||||
|
|
||||||
|
Ok(StatusCode::OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PATCH /api/whip/{id} — Trickle ICE candidate delivery.
|
||||||
|
pub async fn handle_whip_injest_patch(
|
||||||
|
State(state): State<Arc<HttpServer>>,
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: String,
|
||||||
|
) -> Result<impl IntoResponse, HttpError> {
|
||||||
|
// The slug is the stream_key_id (set by the POST handler's Location header).
|
||||||
|
let stream_key_id: i32 = slug.parse().map_err(|e| {
|
||||||
|
warn!(%slug, "Whip PATCH: bad slug: {:?}", e);
|
||||||
|
HttpError::NotFound
|
||||||
|
})?;
|
||||||
|
|
||||||
|
info!(stream_key_id, ct = ?headers.get(header::CONTENT_TYPE), "Whip PATCH: trickle candidate");
|
||||||
|
|
||||||
|
// Look up the trickle sender for this session.
|
||||||
|
let trickle_map = state.appstate.lock().await.webrtc_proxy.trickle_tx.clone();
|
||||||
|
let tx = trickle_map
|
||||||
|
.get(&stream_key_id)
|
||||||
|
.map(|e| e.value().1.clone())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
warn!(stream_key_id, "Whip PATCH: no trickle channel for session");
|
||||||
|
HttpError::NotFound
|
||||||
|
})?;
|
||||||
|
|
||||||
|
debug!(stream_key_id, %body, "Whip PATCH: forwarding trickle candidate");
|
||||||
|
tx.send(body).ok();
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn handle_whip_injest(
|
pub async fn handle_whip_injest(
|
||||||
State(state): State<Arc<HttpServer>>,
|
State(state): State<Arc<HttpServer>>,
|
||||||
|
ConnectInfo(remote): ConnectInfo<SocketAddr>,
|
||||||
|
headers: HeaderMap,
|
||||||
offer: String,
|
offer: String,
|
||||||
) -> impl IntoResponse {
|
) -> axum::response::Response {
|
||||||
let sdp_offer = SdpOffer::from_sdp_string(&offer).unwrap();
|
let err = |status, msg: &str| -> axum::response::Response {
|
||||||
|
(
|
||||||
|
status,
|
||||||
|
[(header::CONTENT_TYPE, "text/plain")],
|
||||||
|
msg.to_string(),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
};
|
||||||
|
let public_addr = state.appstate.lock().await.webrtc_proxy.public_addr();
|
||||||
|
|
||||||
|
let Some(token) = bearer_token(&headers) else {
|
||||||
|
warn!("Whip: missing bearer token from {}", remote);
|
||||||
|
return err(StatusCode::UNAUTHORIZED, "missing bearer token");
|
||||||
|
};
|
||||||
|
|
||||||
|
let key = match stream_key::Entity::find_by_key(&state.db, &token).await {
|
||||||
|
Ok(Some(key)) => key,
|
||||||
|
Ok(None) => {
|
||||||
|
warn!("Whip: stream key not found, rejecting {}", remote);
|
||||||
|
return err(StatusCode::UNAUTHORIZED, "invalid stream key");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Whip: DB error looking up stream key: {:?}", e);
|
||||||
|
return err(StatusCode::INTERNAL_SERVER_ERROR, "db error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match stream_session::Model::get_active_by_stream_key_id(&state.db, key.id).await {
|
||||||
|
Ok(Some(_)) => {
|
||||||
|
warn!(stream_key_id = key.id, label = %key.label, "Whip: stream key already live, rejecting duplicate publish from {}", remote);
|
||||||
|
return err(StatusCode::CONFLICT, "stream already live");
|
||||||
|
}
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Whip: DB error checking active stream: {:?}", e);
|
||||||
|
return err(StatusCode::INTERNAL_SERVER_ERROR, "db error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
info!(stream_key_id = key.id, label = %key.label, "Whip authenticated stream key from {}", remote);
|
||||||
|
|
||||||
|
// Parse the SDP offer first so we can detect the codec and
|
||||||
|
// configure the Rtc before accepting.
|
||||||
|
let sdp_offer = match SdpOffer::from_sdp_string(&offer) {
|
||||||
|
Ok(o) => o,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Whip: cant parse offer from {}: {:?}", remote, e);
|
||||||
|
return err(StatusCode::BAD_REQUEST, "invalid SDP offer");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
info!(
|
||||||
|
"Whip offer from {} — {} media line(s):",
|
||||||
|
remote,
|
||||||
|
sdp_offer.media_lines.len()
|
||||||
|
);
|
||||||
for x in &sdp_offer.media_lines {
|
for x in &sdp_offer.media_lines {
|
||||||
info!(" {}", x);
|
info!(" {}", x);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect codec from the offer so we can enable matching codecs.
|
||||||
|
let stream_codec = video_codec_from_sdp_offer(&sdp_offer);
|
||||||
|
info!(?stream_codec, "detected codec from WHIP offer");
|
||||||
|
|
||||||
|
// Build Rtc with ICE-Lite (required by WHIP RFC 9728 §4.1) and
|
||||||
|
// matching codecs enabled.
|
||||||
|
// Not using ICE-Lite: full ICE lets the server initiate checks
|
||||||
|
// when OBS hasn't sent its candidates yet (Trickle ICE without PATCH).
|
||||||
|
let mut builder = Rtc::builder();
|
||||||
|
{
|
||||||
|
let cc = builder.codec_config();
|
||||||
|
cc.clear();
|
||||||
|
cc.enable_opus(true);
|
||||||
|
match &stream_codec {
|
||||||
|
Some(codec) => {
|
||||||
|
info!("Whip: enabling {codec:?} codec");
|
||||||
|
match codec {
|
||||||
|
crate::StreamCodec::H264 => cc.enable_h264(true),
|
||||||
|
crate::StreamCodec::H265 => cc.enable_h265(true),
|
||||||
|
crate::StreamCodec::AV1 => cc.enable_av1(true),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
warn!("Whip: no video codec detected in offer, enabling H.264 as fallback");
|
||||||
|
cc.enable_h264(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut rtc = builder.build(Instant::now());
|
||||||
|
let candidate = Candidate::host(public_addr, Protocol::Udp).unwrap();
|
||||||
|
rtc.add_local_candidate(candidate);
|
||||||
|
info!(%public_addr, "Whip: added local ICE candidate, accepting offer…");
|
||||||
|
|
||||||
|
let offer_answe = match rtc.sdp_api().accept_offer(sdp_offer) {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(e) => {
|
||||||
|
error!("cant accept inject offer: {:?}", e);
|
||||||
|
return err(StatusCode::BAD_REQUEST, "could not accept offer");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// OBS sends no a=candidate: lines (disableAutoGathering). Derive a
|
||||||
|
// remote host candidate from the HTTP source address so the server
|
||||||
|
// has somewhere to send STUN checks.
|
||||||
|
if let Ok(c) = Candidate::host(remote, Protocol::Udp) {
|
||||||
|
info!(%remote, "Whip: no candidates in offer, adding HTTP-derived remote host candidate");
|
||||||
|
rtc.add_remote_candidate(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract negotiated codec, PT, and profile from the answer
|
||||||
|
// so WHEP viewers can use the exact same codec config.
|
||||||
|
let (negotiated_codec, video_pt, video_profile) = extract_negotiated_codec_info(&offer_answe);
|
||||||
|
info!(
|
||||||
|
?negotiated_codec,
|
||||||
|
video_pt,
|
||||||
|
?video_profile,
|
||||||
|
"negotiated codec from WHIP answer"
|
||||||
|
);
|
||||||
|
if negotiated_codec.is_none() || video_pt.is_none() {
|
||||||
|
warn!("Whip: no common video codec negotiated, rejecting");
|
||||||
|
return err(StatusCode::NOT_ACCEPTABLE, "no common video codec");
|
||||||
|
}
|
||||||
|
|
||||||
|
let answer_sdp = offer_answe
|
||||||
|
.to_sdp_string()
|
||||||
|
// Strip a=ice-options:trickle so OBS starts ICE immediately.
|
||||||
|
.replace("a=ice-options:trickle\r\n", "")
|
||||||
|
.replace("a=ice-options:trickle\n", "");
|
||||||
|
// Fix up the answer for libdatachannel (OBS WHIP):
|
||||||
|
// 1. Strip a=group:BUNDLE — OBS doesn't negotiate it.
|
||||||
|
// 2. Add the host candidate to the video m= line.
|
||||||
|
let answer_sdp = answer_sdp
|
||||||
|
.replace("a=group:BUNDLE 0 1\r\n", "")
|
||||||
|
.replace("a=group:BUNDLE 0 1\n", "");
|
||||||
|
let answer_sdp =
|
||||||
|
if let Some(cand_line) = answer_sdp.lines().find(|l| l.starts_with("a=candidate:")) {
|
||||||
|
// Insert the candidate line after the video m= line.
|
||||||
|
let cand_replacement = format!(
|
||||||
|
"m=video 9 UDP/TLS/RTP/SAVPF 96\r\nc=IN IP4 {}\r\n{}\r\n",
|
||||||
|
public_addr.ip(),
|
||||||
|
cand_line
|
||||||
|
);
|
||||||
|
answer_sdp.replace(
|
||||||
|
"m=video 9 UDP/TLS/RTP/SAVPF 96\r\nc=IN IP4 0.0.0.0\r\n",
|
||||||
|
&cand_replacement,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
answer_sdp
|
||||||
|
};
|
||||||
|
info!("Serving Whip SDP answer to {}:\n{}", remote, answer_sdp);
|
||||||
|
|
||||||
|
let ufrag = answer_sdp
|
||||||
|
.lines()
|
||||||
|
.find(|l| l.starts_with("a=ice-ufrag:"))
|
||||||
|
.and_then(|l| l.strip_prefix("a=ice-ufrag:"))
|
||||||
|
.map(|s| s.trim().to_string());
|
||||||
|
let Some(ufrag) = ufrag else {
|
||||||
|
error!("Whip: answer has no a=ice-ufrag");
|
||||||
|
return err(StatusCode::INTERNAL_SERVER_ERROR, "no ice-ufrag in answer");
|
||||||
|
};
|
||||||
|
let ice_pwd = answer_sdp
|
||||||
|
.lines()
|
||||||
|
.find(|l| l.starts_with("a=ice-pwd:"))
|
||||||
|
.and_then(|l| l.strip_prefix("a=ice-pwd:"))
|
||||||
|
.map(|s| s.trim().to_string());
|
||||||
|
info!(
|
||||||
|
?ufrag,
|
||||||
|
?ice_pwd,
|
||||||
|
"Whip: registering ICE credentials with proxy"
|
||||||
|
);
|
||||||
|
let (socket, rx) = state.appstate.lock().await.webrtc_proxy.add_client(ufrag);
|
||||||
|
|
||||||
|
let stream_sessions = state.appstate.lock().await.stream_sessions.clone();
|
||||||
|
// Unique per publish: cleanup only removes the session this task created,
|
||||||
|
// never a newer one that re-published on the same stream key.
|
||||||
|
let session_id = Uuid::new_v4();
|
||||||
|
let (mut video_tx, video_rx) = broadcast::<Arc<VideoFrame>>(32);
|
||||||
|
let (mut audio_tx, audio_rx) = broadcast::<Arc<OpusAudioFrame>>(32);
|
||||||
|
// Never block the ingest loop on slow/missing viewers: overwrite the
|
||||||
|
// oldest frame instead (same as the RTMP path; the viewer re-waits for a
|
||||||
|
// keyframe on Overflowed). The undrained rx below must not stall sends.
|
||||||
|
video_tx.set_overflow(true);
|
||||||
|
audio_tx.set_overflow(true);
|
||||||
|
|
||||||
|
let user = users::Entity::find_by_id(key.user_id)
|
||||||
|
.one(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
stream_sessions.insert(
|
||||||
|
key.id,
|
||||||
|
StreamSession {
|
||||||
|
stream_key_id: key.id,
|
||||||
|
stream_key_label: key.label,
|
||||||
|
stream_key_user: user.username,
|
||||||
|
custom_id: key.custom_id,
|
||||||
|
is_unlisted: key.is_unlisted,
|
||||||
|
password: key.password,
|
||||||
|
frame_channel: video_tx,
|
||||||
|
audio_channel: audio_tx,
|
||||||
|
codec: negotiated_codec,
|
||||||
|
session_id,
|
||||||
|
started_at: Utc::now(),
|
||||||
|
active_clients: 0.into(),
|
||||||
|
video_pt,
|
||||||
|
video_profile_level_id: video_profile,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
info!(
|
||||||
|
stream_key_id = key.id,
|
||||||
|
"Whip: StreamSession inserted, spawning detach task"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Trickle-ICE channel: OBS can send candidates via PATCH after the
|
||||||
|
// initial offer. We forward them to the Rtc task for add_remote_candidate.
|
||||||
|
let (trickle_tx, trickle_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
let trickle_map = state.appstate.lock().await.webrtc_proxy.trickle_tx.clone();
|
||||||
|
trickle_map.insert(key.id, (session_id, trickle_tx));
|
||||||
|
|
||||||
|
let db = state.db.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
detach_inject_rtc(
|
||||||
|
stream_sessions,
|
||||||
|
key.id,
|
||||||
|
db,
|
||||||
|
socket,
|
||||||
|
rx,
|
||||||
|
trickle_rx,
|
||||||
|
rtc,
|
||||||
|
public_addr,
|
||||||
|
video_rx,
|
||||||
|
audio_rx,
|
||||||
|
session_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
// Drop only our own trickle channel; a newer session on the same key
|
||||||
|
// must keep its own.
|
||||||
|
trickle_map.remove_if(&key.id, |_, (sid, _)| *sid == session_id);
|
||||||
|
});
|
||||||
|
|
||||||
|
stream_session::Model::create_stream_session(&state.db, key.id, Local::now().into())
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
let location = format!("/api/whip/{}", key.id);
|
||||||
|
axum::response::Response::builder()
|
||||||
|
.status(StatusCode::CREATED)
|
||||||
|
.header(header::CONTENT_TYPE, "application/sdp")
|
||||||
|
.header(header::LOCATION, &location)
|
||||||
|
.body(axum::body::Body::from(answer_sdp))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn detach_inject_rtc(
|
||||||
|
sessions_ref: Arc<DashMap<i32, StreamSession>>,
|
||||||
|
stream_key_id: i32,
|
||||||
|
db: DatabaseConnection,
|
||||||
|
socket: Arc<UdpSocket>,
|
||||||
|
mut rx: Receiver<(Bytes, SocketAddr)>,
|
||||||
|
mut trickle_rx: tokio::sync::mpsc::UnboundedReceiver<String>,
|
||||||
|
mut rtc: Rtc,
|
||||||
|
local_addr: SocketAddr,
|
||||||
|
video_rx: async_broadcast::Receiver<Arc<VideoFrame>>,
|
||||||
|
audio_rx: async_broadcast::Receiver<Arc<OpusAudioFrame>>,
|
||||||
|
session_id: Uuid,
|
||||||
|
) {
|
||||||
|
let cleanup = || {
|
||||||
|
let sessions_ref = sessions_ref.clone();
|
||||||
|
let db = db.clone();
|
||||||
|
async move {
|
||||||
|
// Only clean up the session this task created. If the user has
|
||||||
|
// already opened another stream on the same key (e.g. DELETE then
|
||||||
|
// immediate republish), that newer session must not be touched.
|
||||||
|
let is_ours = sessions_ref
|
||||||
|
.get(&stream_key_id)
|
||||||
|
.is_some_and(|s| s.session_id == session_id);
|
||||||
|
if !is_ours {
|
||||||
|
debug!(
|
||||||
|
stream_key_id,
|
||||||
|
"skipping cleanup: session replaced by a newer stream on this key"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debug!("Cleaning up {:?}", &stream_key_id);
|
||||||
|
sessions_ref.remove(&stream_key_id);
|
||||||
|
if let Ok(Some(s)) =
|
||||||
|
stream_session::Model::get_active_by_stream_key_id(&db, stream_key_id).await
|
||||||
|
{
|
||||||
|
s.into_active_model()
|
||||||
|
.finish_stream_session(&db, Local::now().into())
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut video_mid: Option<Mid> = None;
|
||||||
|
let mut audio_mid: Option<Mid> = None;
|
||||||
|
let mut video_tx: Option<async_broadcast::Sender<Arc<VideoFrame>>> = None;
|
||||||
|
let mut audio_tx: Option<async_broadcast::Sender<Arc<OpusAudioFrame>>> = None;
|
||||||
|
let mut disconnect_timer: Option<Instant> = None;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Blankly using them so they don't drop (like RTMP).
|
||||||
|
let _ = video_rx.is_closed();
|
||||||
|
let _ = audio_rx.is_closed();
|
||||||
|
|
||||||
|
let deadline = loop {
|
||||||
|
match rtc.poll_output() {
|
||||||
|
Ok(Output::Timeout(t)) => break t,
|
||||||
|
Ok(Output::Transmit(t)) => {
|
||||||
|
// The keep alive loop, by default is every 1 second.
|
||||||
|
trace!(
|
||||||
|
"Whip TX: {} bytes → {}:{}",
|
||||||
|
t.contents.len(),
|
||||||
|
t.destination.ip(),
|
||||||
|
t.destination.port()
|
||||||
|
);
|
||||||
|
if let Err(e) = socket.send_to(&t.contents, t.destination).await {
|
||||||
|
warn!("Whip UDP send error: {:?}", e);
|
||||||
|
cleanup().await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 30 Sec time out if disconnected, then we clean up and disconnect.
|
||||||
|
if let Some(instant_since_disconnet) = disconnect_timer
|
||||||
|
&& Instant::now()
|
||||||
|
.duration_since(instant_since_disconnet)
|
||||||
|
.as_secs()
|
||||||
|
> NO_MEDIA_TIMEOUT.as_secs()
|
||||||
|
{
|
||||||
|
info!(
|
||||||
|
"WHIP connection on stream_key_id: {:?}, has been disconnected for {:?} seconds. Cleaning up and destroying the connection,",
|
||||||
|
stream_key_id,
|
||||||
|
NO_MEDIA_TIMEOUT.as_secs()
|
||||||
|
);
|
||||||
|
cleanup().await;
|
||||||
|
rtc.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Output::Event(e)) => match e {
|
||||||
|
Event::MediaAdded(ma) => {
|
||||||
|
info!(stream_key_id, kind = ?ma.kind, mid = ?ma.mid, "Whip MediaAdded");
|
||||||
|
if ma.kind == MediaKind::Video {
|
||||||
|
video_mid = Some(ma.mid);
|
||||||
|
}
|
||||||
|
if ma.kind == MediaKind::Audio {
|
||||||
|
audio_mid = Some(ma.mid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::MediaData(md) => {
|
||||||
|
// str0m depacketizes RTP into full codec frames (no decode).
|
||||||
|
// H.264/H.265 → Annex-B, AV1 → OBU, Opus → raw Opus packets.
|
||||||
|
// Same format as our RTMP codec parsers produce — push directly.
|
||||||
|
if Some(md.mid) == video_mid {
|
||||||
|
let ts_ms = (md.time.as_seconds() * 1000.0) as u32;
|
||||||
|
let frame = VideoFrame {
|
||||||
|
data: Bytes::copy_from_slice(&md.data),
|
||||||
|
is_keyframe: md.is_keyframe(),
|
||||||
|
timestamp_ms: ts_ms,
|
||||||
|
};
|
||||||
|
if let Some(tx) = &video_tx {
|
||||||
|
tx.broadcast(Arc::new(frame)).await.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if Some(md.mid) == audio_mid {
|
||||||
|
let ts_ms = (md.time.as_seconds() * 1000.0) as u32;
|
||||||
|
let frame = OpusAudioFrame {
|
||||||
|
data: Bytes::copy_from_slice(&md.data),
|
||||||
|
timestamp_ms: ts_ms,
|
||||||
|
};
|
||||||
|
if let Some(tx) = &audio_tx {
|
||||||
|
tx.broadcast(Arc::new(frame)).await.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::IceConnectionStateChange(state) => {
|
||||||
|
info!(stream_key_id, ?state, "Whip ICE state change");
|
||||||
|
match state {
|
||||||
|
str0m::IceConnectionState::Disconnected => {
|
||||||
|
info!(
|
||||||
|
"Whip ICE disconnected... (State changed to Disconnected for {:?}) ((This is usually due to network jitter))",
|
||||||
|
&stream_key_id
|
||||||
|
);
|
||||||
|
disconnect_timer = Some(Instant::now());
|
||||||
|
}
|
||||||
|
str0m::IceConnectionState::Connected
|
||||||
|
| str0m::IceConnectionState::Completed => {
|
||||||
|
disconnect_timer = None;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Connected => {
|
||||||
|
info!(
|
||||||
|
stream_key_id,
|
||||||
|
"Whip DTLS+ICE connected, wiring broadcast channels"
|
||||||
|
);
|
||||||
|
if let Some(session) = sessions_ref.get(&stream_key_id) {
|
||||||
|
video_tx = Some(session.frame_channel.clone());
|
||||||
|
audio_tx = Some(session.audio_channel.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
error!("Whip poll_output error (closing): {:?}", e);
|
||||||
|
cleanup().await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let sleep = tokio::time::sleep_until(deadline.max(Instant::now()).into());
|
||||||
|
tokio::select! {
|
||||||
|
_ = sleep => {
|
||||||
|
if let Err(e) = rtc.handle_input(Input::Timeout(Instant::now())) {
|
||||||
|
error!(stream_key_id, "Whip handle_input(Timeout) error: {:?}", e);
|
||||||
|
cleanup().await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Trickle-ICE candidates from PATCH /api/whip/{id}
|
||||||
|
Some(candidate_line) = trickle_rx.recv() => {
|
||||||
|
info!(stream_key_id, %candidate_line, "Whip: received trickle candidate");
|
||||||
|
// Parse the candidate string (without "a=candidate:" prefix if present).
|
||||||
|
let cand_str = candidate_line
|
||||||
|
.strip_prefix("a=candidate:")
|
||||||
|
.unwrap_or(&candidate_line);
|
||||||
|
match str0m::Candidate::from_sdp_string(cand_str) {
|
||||||
|
Ok(c) => {
|
||||||
|
info!(stream_key_id, "Whip: adding remote candidate");
|
||||||
|
rtc.add_remote_candidate(c);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(stream_key_id, %cand_str, "Whip: bad trickle candidate: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No UDP input at all for 30s: closing connection.
|
||||||
|
// (ICE keepalives still arrive when only the encoder is stalled,
|
||||||
|
// so this fires on a dead peer, not a paused one.)
|
||||||
|
result = tokio::time::timeout(NO_MEDIA_TIMEOUT, rx.recv()) => {
|
||||||
|
let Ok(Some((data, from))) = result else {
|
||||||
|
if result.is_err() {
|
||||||
|
warn!(stream_key_id, "Whip: no input for {NO_MEDIA_TIMEOUT:?}, closing connection");
|
||||||
|
rtc.disconnect();
|
||||||
|
} else {
|
||||||
|
info!(stream_key_id, "Whip proxy channel closed, cleaning up");
|
||||||
|
}
|
||||||
|
cleanup().await;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
trace!(
|
||||||
|
stream_key_id,
|
||||||
|
len = data.len(),
|
||||||
|
%from,
|
||||||
|
"Whip RX: {} bytes",
|
||||||
|
data.len()
|
||||||
|
);
|
||||||
|
if let Ok(contents) = (&data[..]).try_into()
|
||||||
|
&& let Err(e) = rtc.handle_input(Input::Receive(
|
||||||
|
Instant::now(),
|
||||||
|
Receive {
|
||||||
|
proto: Protocol::Udp,
|
||||||
|
source: from,
|
||||||
|
destination: local_addr,
|
||||||
|
contents,
|
||||||
|
},
|
||||||
|
)) {
|
||||||
|
error!("Whip handle_input(Receive) error: {:?}", e);
|
||||||
|
cleanup().await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the negotiated video codec, PT, and profile_level_id from an SDP answer.
|
||||||
|
///
|
||||||
|
/// Walks the answer's media lines, finds the first video m-line with
|
||||||
|
/// negotiated rtp_params, and returns the codec + PT + H.264 profile.
|
||||||
|
pub fn extract_negotiated_codec_info(
|
||||||
|
answer: &str0m::change::SdpAnswer,
|
||||||
|
) -> (Option<crate::StreamCodec>, Option<u8>, Option<u32>) {
|
||||||
|
for line in answer.media_lines.iter() {
|
||||||
|
for p in line.rtp_params() {
|
||||||
|
if p.spec().codec.is_video() {
|
||||||
|
return (
|
||||||
|
crate::StreamCodec::from_str0m(p.spec().codec),
|
||||||
|
Some(*p.pt()),
|
||||||
|
p.spec().format.profile_level_id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(None, None, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the video codec from an SDP offer's media lines.
|
||||||
|
///
|
||||||
|
/// Walks every m= line's `rtp_params()`; returns the first video codec found.
|
||||||
|
pub fn video_codec_from_sdp_offer(
|
||||||
|
sdp_offer: &str0m::change::SdpOffer,
|
||||||
|
) -> Option<crate::StreamCodec> {
|
||||||
|
for line in sdp_offer.media_lines.iter() {
|
||||||
|
for p in line.rtp_params() {
|
||||||
|
if p.spec().codec.is_video() {
|
||||||
|
return crate::StreamCodec::from_str0m(p.spec().codec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,16 +7,12 @@ use std::{
|
|||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use str0m::net::DatagramRecv;
|
|
||||||
use tokio::{
|
use tokio::{
|
||||||
net::UdpSocket,
|
net::UdpSocket,
|
||||||
sync::mpsc::{self, Receiver},
|
sync::mpsc::{self, Receiver},
|
||||||
};
|
};
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, trace, warn};
|
||||||
|
use uuid::Uuid;
|
||||||
pub struct WebRtcProxyConfig {
|
|
||||||
pub proxy_port: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct WebrtcProxy {
|
pub struct WebrtcProxy {
|
||||||
@@ -24,28 +20,47 @@ pub struct WebrtcProxy {
|
|||||||
clients_addr: Arc<DashMap<SocketAddr, tokio::sync::mpsc::Sender<(Bytes, SocketAddr)>>>,
|
clients_addr: Arc<DashMap<SocketAddr, tokio::sync::mpsc::Sender<(Bytes, SocketAddr)>>>,
|
||||||
socket: Arc<UdpSocket>,
|
socket: Arc<UdpSocket>,
|
||||||
public_addr: SocketAddr,
|
public_addr: SocketAddr,
|
||||||
|
/// Trickle-ICE candidate channels for WHIP ingest.
|
||||||
|
/// Keyed by stream_key_id; each entry is tagged with the owning session's
|
||||||
|
/// id so cleanup can remove only its own entry and never a newer session
|
||||||
|
/// that re-published on the same key.
|
||||||
|
pub trickle_tx: Arc<DashMap<i32, (Uuid, tokio::sync::mpsc::UnboundedSender<String>)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
const STUN_MAGIC: u32 = 0x2112A442;
|
const STUN_MAGIC: u32 = 0x2112A442;
|
||||||
|
|
||||||
impl WebrtcProxy {
|
impl WebrtcProxy {
|
||||||
pub async fn new(config: WebRtcProxyConfig) -> Result<Self, Box<dyn Error>> {
|
pub async fn new(proxy_port: i32) -> Result<Self, Box<dyn Error>> {
|
||||||
let sock = UdpSocket::bind(format!("0.0.0.0:{}", config.proxy_port)).await?;
|
let sock = UdpSocket::bind(format!("0.0.0.0:{}", proxy_port)).await?;
|
||||||
let port = sock.local_addr()?.port();
|
let port = sock.local_addr()?.port();
|
||||||
|
|
||||||
let public_ip = match env::var("PUBLIC_DOMAIN") {
|
let public_ip = match env::var("PUBLIC_DOMAIN")
|
||||||
Ok(domain) => {
|
.ok()
|
||||||
|
.filter(|s| !s.trim().is_empty())
|
||||||
|
{
|
||||||
|
Some(domain) => {
|
||||||
let ip = resolve_domain(&domain).await?;
|
let ip = resolve_domain(&domain).await?;
|
||||||
info!(%domain, %ip, "resolved PUBLIC_DOMAIN for WebRTC candidates");
|
info!(%domain, %ip, "resolved PUBLIC_DOMAIN for WebRTC candidates");
|
||||||
ip
|
ip
|
||||||
}
|
}
|
||||||
Err(_) => {
|
None => {
|
||||||
if cfg!(debug_assertions) {
|
if cfg!(debug_assertions) {
|
||||||
// For testing
|
// For testing — advertise a real interface IP. Loopback is
|
||||||
info!(
|
// unreachable from clients whose ICE stack pins its UDP
|
||||||
"using ip 0.0.0.0 for WebRTC candidates (because we are in a debug build)"
|
// sockets to a specific interface (OBS sets
|
||||||
|
// IP_UNICAST_IF), which makes checks to 127.0.0.1 vanish.
|
||||||
|
match default_iface_ipv4() {
|
||||||
|
Some(ip) => {
|
||||||
|
info!(%ip, "using default interface IP for WebRTC candidates (debug build)");
|
||||||
|
ip
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
warn!(
|
||||||
|
"no non-loopback IPv4 interface found, falling back to 127.0.0.1"
|
||||||
);
|
);
|
||||||
IpAddr::from([127, 0, 0, 1])
|
IpAddr::from([127, 0, 0, 1])
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
let ip = stun_public_ip().await?;
|
let ip = stun_public_ip().await?;
|
||||||
info!(%ip, "discovered public IP via STUN for WebRTC candidates");
|
info!(%ip, "discovered public IP via STUN for WebRTC candidates");
|
||||||
@@ -61,6 +76,7 @@ impl WebrtcProxy {
|
|||||||
clients_ufrag: Arc::new(DashMap::new()),
|
clients_ufrag: Arc::new(DashMap::new()),
|
||||||
clients_addr: Arc::new(DashMap::new()),
|
clients_addr: Arc::new(DashMap::new()),
|
||||||
public_addr,
|
public_addr,
|
||||||
|
trickle_tx: Arc::new(DashMap::new()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
pub async fn run(self) {
|
pub async fn run(self) {
|
||||||
@@ -81,6 +97,12 @@ impl WebrtcProxy {
|
|||||||
|
|
||||||
// By addr
|
// By addr
|
||||||
if let Some(tx) = by_addr.get(&from) {
|
if let Some(tx) = by_addr.get(&from) {
|
||||||
|
trace!(
|
||||||
|
"proxy: routing {} bytes by addr {}:{} → channel",
|
||||||
|
b,
|
||||||
|
from.ip(),
|
||||||
|
from.port()
|
||||||
|
);
|
||||||
match tx.try_send((data, from)) {
|
match tx.try_send((data, from)) {
|
||||||
Ok(_) => continue,
|
Ok(_) => continue,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -97,18 +119,30 @@ impl WebrtcProxy {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(ufrag) = self::WebrtcProxy::ufrag(&data) else {
|
let Some((part1, part2)) = self::WebrtcProxy::ufrag_pair(&data) else {
|
||||||
debug!("huh, packet isnt stun or added as client.");
|
trace!("huh, packet isnt stun or added as client.");
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
// Try both parts of the STUN username — the first packet
|
||||||
let Some((_, tx)) = by_ufrag.remove(&ufrag) else {
|
// might be a response to OUR STUN request (remote:local)
|
||||||
// warn!("STUN packet ({}), isnt registored", ufrag);
|
// or an incoming request from the remote peer (local:remote).
|
||||||
|
let part2_lookup = part2.clone();
|
||||||
|
let entry = by_ufrag
|
||||||
|
.remove(&part1)
|
||||||
|
.or_else(|| part2_lookup.and_then(|p2| by_ufrag.remove(&p2)));
|
||||||
|
let Some((_, tx)) = entry else {
|
||||||
|
warn!("STUN packet ({}/{:?}), isnt registored", part1, part2);
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
by_addr.insert(from, tx.clone());
|
by_addr.insert(from, tx.clone());
|
||||||
debug!("got ufrag {}", ufrag);
|
info!(
|
||||||
|
"proxy: STUN match → promoted {} → ufrag={} (match was {}/{})",
|
||||||
|
from,
|
||||||
|
part1,
|
||||||
|
part1,
|
||||||
|
part2.as_deref().unwrap_or("-")
|
||||||
|
);
|
||||||
debug!("sending data");
|
debug!("sending data");
|
||||||
if let Err(e) = tx.try_send((data, from)) {
|
if let Err(e) = tx.try_send((data, from)) {
|
||||||
match e {
|
match e {
|
||||||
@@ -129,14 +163,10 @@ impl WebrtcProxy {
|
|||||||
self.clients_ufrag.insert(ufrag, tx);
|
self.clients_ufrag.insert(ufrag, tx);
|
||||||
(self.socket.clone(), rx)
|
(self.socket.clone(), rx)
|
||||||
}
|
}
|
||||||
pub fn local_addr(&self) -> SocketAddr {
|
|
||||||
self.socket.local_addr().unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn public_addr(&self) -> SocketAddr {
|
pub fn public_addr(&self) -> SocketAddr {
|
||||||
self.public_addr
|
self.public_addr
|
||||||
}
|
}
|
||||||
pub fn ufrag(b: &Bytes) -> Option<String> {
|
pub fn ufrag_pair(b: &Bytes) -> Option<(String, Option<String>)> {
|
||||||
if b.len() <= 20 {
|
if b.len() <= 20 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -144,21 +174,37 @@ impl WebrtcProxy {
|
|||||||
if magic != STUN_MAGIC {
|
if magic != STUN_MAGIC {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
// attribies start at 20
|
let value = stun_attributes(b).find(|(t, _)| *t == 0x0006)?.1;
|
||||||
|
let value = std::str::from_utf8(value).ok()?;
|
||||||
|
let mut parts = value.split(':');
|
||||||
|
Some((
|
||||||
|
parts.next()?.to_string(),
|
||||||
|
parts.next().map(|s| s.to_string()),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterate `(attr_type, attr_value)` pairs over a STUN message's attributes
|
||||||
|
/// (RFC 5389 §15: header is 20 bytes, each attribute padded to a 4-byte boundary).
|
||||||
|
fn stun_attributes(data: &[u8]) -> impl Iterator<Item = (u16, &[u8])> {
|
||||||
let mut pos = 20usize;
|
let mut pos = 20usize;
|
||||||
while (pos + 4) <= b.len() {
|
std::iter::from_fn(move || {
|
||||||
let attr_type: u16 = u16::from_be_bytes(b[pos..pos + 2].try_into().ok()?);
|
let attr_type = u16::from_be_bytes(data.get(pos..pos + 2)?.try_into().ok()?);
|
||||||
let attr_len: u16 = u16::from_be_bytes(b[pos + 2..pos + 4].try_into().ok()?);
|
let attr_len = u16::from_be_bytes(data.get(pos + 2..pos + 4)?.try_into().ok()?) as usize;
|
||||||
pos = pos + 4;
|
pos += 4;
|
||||||
if attr_type == 0x0006 {
|
let value = data.get(pos..pos + attr_len)?;
|
||||||
let value = std::str::from_utf8(b[pos..pos + (attr_len as usize)].try_into().ok()?);
|
pos += (attr_len + 3) & !3;
|
||||||
let local = value.unwrap().split(":").next();
|
Some((attr_type, value))
|
||||||
return Some(local.unwrap().to_string());
|
})
|
||||||
}
|
|
||||||
pos += (attr_len as usize + 3) & !3;
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// IP of the interface holding the default route, via a UDP connect() trick:
|
||||||
|
/// connect() only does a route lookup (no packets sent), so the kernel binds
|
||||||
|
/// the source address the OS would use for outbound traffic.
|
||||||
|
fn default_iface_ipv4() -> Option<IpAddr> {
|
||||||
|
let sock = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
|
||||||
|
sock.connect("8.8.8.8:9").ok()?;
|
||||||
|
sock.local_addr().ok().map(|a| a.ip())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn resolve_domain(domain: &str) -> Result<std::net::IpAddr, Box<dyn Error>> {
|
async fn resolve_domain(domain: &str) -> Result<std::net::IpAddr, Box<dyn Error>> {
|
||||||
@@ -203,27 +249,17 @@ fn parse_xor_mapped_address(data: &[u8]) -> Option<std::net::IpAddr> {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut pos = 20usize;
|
let value = stun_attributes(data).find(|(t, _)| *t == 0x0020)?.1;
|
||||||
while pos + 4 <= data.len() {
|
if value.len() < 8 {
|
||||||
let attr_type = u16::from_be_bytes(data[pos..pos + 2].try_into().ok()?);
|
return None;
|
||||||
let attr_len = u16::from_be_bytes(data[pos + 2..pos + 4].try_into().ok()?) as usize;
|
|
||||||
pos += 4;
|
|
||||||
if pos + attr_len > data.len() {
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
if attr_type == 0x0020 && attr_len >= 8 {
|
|
||||||
// byte 0: reserved, byte 1: family (0x01=IPv4, 0x02=IPv6)
|
// byte 0: reserved, byte 1: family (0x01=IPv4, 0x02=IPv6)
|
||||||
let family = data[pos + 1];
|
if value[1] == 0x01 {
|
||||||
let x_port = u16::from_be_bytes(data[pos + 2..pos + 4].try_into().ok()?);
|
let x_addr = u32::from_be_bytes(value[4..8].try_into().ok()?);
|
||||||
let _ = x_port ^ (STUN_MAGIC >> 16) as u16; // port (unused here)
|
Some(std::net::IpAddr::V4(std::net::Ipv4Addr::from(
|
||||||
|
x_addr ^ magic,
|
||||||
if family == 0x01 {
|
)))
|
||||||
let x_addr = u32::from_be_bytes(data[pos + 4..pos + 8].try_into().ok()?);
|
} else {
|
||||||
let addr = x_addr ^ STUN_MAGIC;
|
|
||||||
return Some(std::net::IpAddr::V4(std::net::Ipv4Addr::from(addr)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pos += (attr_len + 3) & !3;
|
|
||||||
}
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
//! Temporary probe: replicate handle_whip_injest's Rtc setup, feed it a
|
||||||
|
//! realistic OBS/libdatachannel WHIP offer, print the answer SDP and check
|
||||||
|
//! whether the string-fixups in webrtc_ingest.rs actually match.
|
||||||
|
use std::{net::SocketAddr, time::Instant};
|
||||||
|
|
||||||
|
use str0m::{Candidate, Rtc, change::SdpOffer, net::Protocol};
|
||||||
|
|
||||||
|
fn obs_like_offer() -> String {
|
||||||
|
let mut s = String::new();
|
||||||
|
s.push_str("v=0\r\n");
|
||||||
|
s.push_str("o=- 4527835755568137757 2 IN IP4 127.0.0.1\r\n");
|
||||||
|
s.push_str("s=-\r\n");
|
||||||
|
s.push_str("t=0 0\r\n");
|
||||||
|
s.push_str("a=group:BUNDLE 0 1\r\n");
|
||||||
|
s.push_str("a=msid-semantic: WMS *\r\n");
|
||||||
|
s.push_str("m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n");
|
||||||
|
s.push_str("c=IN IP4 0.0.0.0\r\n");
|
||||||
|
s.push_str("a=rtcp:9 IN IP4 0.0.0.0\r\n");
|
||||||
|
s.push_str("a=ice-ufrag:obs_ufrag_audio\r\n");
|
||||||
|
s.push_str("a=ice-pwd:obs_pwd_audio\r\n");
|
||||||
|
s.push_str("a=ice-options:trickle\r\n");
|
||||||
|
s.push_str("a=fingerprint:sha-256 5A:6B:7C:8D:9E:AF:B0:C1:D2:E3:F4:05:16:27:38:49:5A:6B:7C:8D:9E:AF:B0:C1:D2:E3:F4:05:16:27:38:49:5A\r\n");
|
||||||
|
s.push_str("a=setup:actpass\r\n");
|
||||||
|
s.push_str("a=mid:0\r\n");
|
||||||
|
s.push_str("a=sendrecv\r\n");
|
||||||
|
s.push_str("a=rtcp-mux\r\n");
|
||||||
|
s.push_str("a=rtpmap:111 opus/48000/2\r\n");
|
||||||
|
s.push_str("a=rtcp-fb:111 transport-cc\r\n");
|
||||||
|
s.push_str("a=fmtp:111 minptime=10;useinbandfec=1\r\n");
|
||||||
|
s.push_str("m=video 9 UDP/TLS/RTP/SAVPF 96\r\n");
|
||||||
|
s.push_str("c=IN IP4 0.0.0.0\r\n");
|
||||||
|
s.push_str("a=rtcp:9 IN IP4 0.0.0.0\r\n");
|
||||||
|
s.push_str("a=ice-ufrag:obs_ufrag_video\r\n");
|
||||||
|
s.push_str("a=ice-pwd:obs_pwd_video\r\n");
|
||||||
|
s.push_str("a=ice-options:trickle\r\n");
|
||||||
|
s.push_str("a=fingerprint:sha-256 5A:6B:7C:8D:9E:AF:B0:C1:D2:E3:F4:05:16:27:38:49:5A:6B:7C:8D:9E:AF:B0:C1:D2:E3:F4:05:16:27:38:49:5A\r\n");
|
||||||
|
s.push_str("a=setup:actpass\r\n");
|
||||||
|
s.push_str("a=mid:1\r\n");
|
||||||
|
s.push_str("a=sendrecv\r\n");
|
||||||
|
s.push_str("a=rtcp-mux\r\n");
|
||||||
|
s.push_str("a=rtpmap:96 H264/90000\r\n");
|
||||||
|
s.push_str("a=rtcp-fb:96 nack\r\n");
|
||||||
|
s.push_str("a=rtcp-fb:96 nack pli\r\n");
|
||||||
|
s.push_str("a=rtcp-fb:96 transport-cc\r\n");
|
||||||
|
s.push_str(
|
||||||
|
"a=fmtp:96 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f\r\n",
|
||||||
|
);
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_whip_answer() {
|
||||||
|
let offer_sdp = obs_like_offer();
|
||||||
|
let sdp_offer = SdpOffer::from_sdp_string(&offer_sdp).expect("parse offer");
|
||||||
|
|
||||||
|
// Mirror handle_whip_injest's builder setup.
|
||||||
|
let mut builder = Rtc::builder();
|
||||||
|
{
|
||||||
|
let cc = builder.codec_config();
|
||||||
|
cc.clear();
|
||||||
|
cc.enable_opus(true);
|
||||||
|
cc.enable_h264(true);
|
||||||
|
}
|
||||||
|
let mut rtc = builder.build(Instant::now());
|
||||||
|
let public_addr: SocketAddr = "203.0.113.7:6969".parse().unwrap();
|
||||||
|
let candidate = Candidate::host(public_addr, Protocol::Udp).unwrap();
|
||||||
|
rtc.add_local_candidate(candidate);
|
||||||
|
|
||||||
|
let answer = rtc.sdp_api().accept_offer(sdp_offer).expect("accept offer");
|
||||||
|
let answer_sdp = answer.to_sdp_string();
|
||||||
|
|
||||||
|
println!("=== RAW ANSWER ===");
|
||||||
|
println!("{answer_sdp}");
|
||||||
|
println!("=== END RAW ANSWER ===");
|
||||||
|
|
||||||
|
// Now replicate the fixups from webrtc_ingest.rs verbatim.
|
||||||
|
let answer_sdp = answer_sdp
|
||||||
|
.replace("a=ice-options:trickle\r\n", "")
|
||||||
|
.replace("a=ice-options:trickle\n", "");
|
||||||
|
let answer_sdp = answer_sdp
|
||||||
|
.replace("a=group:BUNDLE 0 1\r\n", "")
|
||||||
|
.replace("a=group:BUNDLE 0 1\n", "");
|
||||||
|
let answer_sdp =
|
||||||
|
if let Some(cand_line) = answer_sdp.lines().find(|l| l.starts_with("a=candidate:")) {
|
||||||
|
let cand_replacement = format!(
|
||||||
|
"m=video 9 UDP/TLS/RTP/SAVPF 96\r\nc=IN IP4 127.0.0.1\r\n{}\r\n",
|
||||||
|
cand_line
|
||||||
|
);
|
||||||
|
answer_sdp.replace(
|
||||||
|
"m=video 9 UDP/TLS/RTP/SAVPF 96\r\nc=IN IP4 0.0.0.0\r\n",
|
||||||
|
&cand_replacement,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
answer_sdp
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("=== FIXED ANSWER ===");
|
||||||
|
println!("{answer_sdp}");
|
||||||
|
println!("=== END FIXED ANSWER ===");
|
||||||
|
|
||||||
|
// Diagnostics
|
||||||
|
let video_mline = answer_sdp
|
||||||
|
.lines()
|
||||||
|
.find(|l| l.starts_with("m=video"))
|
||||||
|
.unwrap();
|
||||||
|
println!("video m-line after fixup: {video_mline}");
|
||||||
|
println!(
|
||||||
|
"has a=candidate after fixup: {}",
|
||||||
|
answer_sdp.lines().any(|l| l.starts_with("a=candidate:"))
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"a=candidate lines: {:?}",
|
||||||
|
answer_sdp
|
||||||
|
.lines()
|
||||||
|
.filter(|l| l.starts_with("a=candidate:"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
}
|
||||||
Generated
+28
-7
@@ -2,11 +2,11 @@
|
|||||||
"nodes": {
|
"nodes": {
|
||||||
"crane": {
|
"crane": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1780532242,
|
"lastModified": 1788465171,
|
||||||
"narHash": "sha256-D+BsdpxmtUwtqGoY0IXPhHgTlmqgcZKCEo1oMyn7ep0=",
|
"narHash": "sha256-Y1/TTVXjYXGF068IThQH9fPSZ0SIE74PABlUxnWTUH0=",
|
||||||
"owner": "ipetkov",
|
"owner": "ipetkov",
|
||||||
"repo": "crane",
|
"repo": "crane",
|
||||||
"rev": "59a82a1222dd3b2080b5cc52a1a2e8d5f1b77f37",
|
"rev": "eb35abda9f232cc6610b1d1e3200d15c49b7ac54",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -35,11 +35,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1781454065,
|
"lastModified": 1789012029,
|
||||||
"narHash": "sha256-d2xfDjnfRuf/xYGdu9VVRHiav/2w5hDL/5cw2TuVAXw=",
|
"narHash": "sha256-1CBkBf+Nhggykzlx0jvXrj5rk20btl+tK8Fa8Ml/BL4=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "9eac87a12312b8f60dd52e1c6e1a265f6fc7f5fc",
|
"rev": "d5dfd8e6716dde34398bc14bc87c10dece9c8c68",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -53,7 +53,28 @@
|
|||||||
"inputs": {
|
"inputs": {
|
||||||
"crane": "crane",
|
"crane": "crane",
|
||||||
"flake-utils": "flake-utils",
|
"flake-utils": "flake-utils",
|
||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs",
|
||||||
|
"rust-overlay": "rust-overlay"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rust-overlay": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1789024335,
|
||||||
|
"narHash": "sha256-kCy/MVLRIr95DJ4vspVzWj+kO/x+JuwSHmYZCvShg8w=",
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"rev": "577bb1e1fc5af0713169176c5c76622c21fa3ec0",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"systems": {
|
"systems": {
|
||||||
|
|||||||
@@ -7,6 +7,12 @@
|
|||||||
crane.url = "github:ipetkov/crane";
|
crane.url = "github:ipetkov/crane";
|
||||||
|
|
||||||
flake-utils.url = "github:numtide/flake-utils";
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
|
||||||
|
rust-overlay = {
|
||||||
|
url = "github:oxalica/rust-overlay";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs =
|
outputs =
|
||||||
@@ -16,14 +22,27 @@
|
|||||||
crane,
|
crane,
|
||||||
flake-utils,
|
flake-utils,
|
||||||
...
|
...
|
||||||
}:
|
}@inputs:
|
||||||
flake-utils.lib.eachDefaultSystem (
|
flake-utils.lib.eachDefaultSystem (
|
||||||
system:
|
system:
|
||||||
let
|
let
|
||||||
pkgs = nixpkgs.legacyPackages.${system};
|
pkgs = import inputs.nixpkgs {
|
||||||
|
inherit system;
|
||||||
|
overlays = [ (import inputs.rust-overlay) ];
|
||||||
|
};
|
||||||
|
|
||||||
inherit (pkgs) lib;
|
inherit (pkgs) lib;
|
||||||
|
|
||||||
craneLib = crane.mkLib pkgs;
|
craneLib = (inputs.crane.mkLib pkgs).overrideToolchain (
|
||||||
|
p:
|
||||||
|
p.rust-bin.nightly.latest.default.override {
|
||||||
|
extensions = [
|
||||||
|
"rustc-codegen-cranelift-preview"
|
||||||
|
"rust-analyzer"
|
||||||
|
"rust-src"
|
||||||
|
];
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
# Common arguments can be set here to avoid repeating them later
|
# Common arguments can be set here to avoid repeating them later
|
||||||
# Note: changes here will rebuild all dependency crates
|
# Note: changes here will rebuild all dependency crates
|
||||||
@@ -56,7 +75,7 @@
|
|||||||
commonArgs
|
commonArgs
|
||||||
// {
|
// {
|
||||||
pname = "rtmp-to-whip";
|
pname = "rtmp-to-whip";
|
||||||
version = "0.1.0";
|
version = "0.4.0";
|
||||||
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
||||||
cargoExtraArgs = "-p server";
|
cargoExtraArgs = "-p server";
|
||||||
src = fileSetForCrate ./crates/server;
|
src = fileSetForCrate ./crates/server;
|
||||||
|
|||||||
Reference in New Issue
Block a user