Files
GMW/docs/specs/2026-08-31_video-receive-phaseC-spec.md
T
asepharyana b3418ad799 feat: Implement video capture improvements and mobile navigation fixes
- Added eager selfbot voice connection establishment to ensure video capture works seamlessly during voice channel joins.
- Introduced a manual video watch command for selfbots to allow operators to initiate screen recording of other members' streams.
- Enhanced video recording functionality to split recordings into segments based on user activity, similar to voice recordings.
- Fixed mobile navigation issues by extending the navbar to include all items and ensuring responsive form controls.
- Standardized error handling across frontend components to improve user experience during failures.
2026-09-11 18:56:28 +07:00

6.1 KiB

Spec: Record Other Users' Video (Camera / Screen Share) — Phase C

Status: PLANNED (not built) Date: 2026-08-31 Author: Hermes Related: .hermes/plans/2026-08-30_video-record-receive-spec.md (Phase A/B — raw UDP hook, superseded for receive)

TL;DR — what changed vs Phase A/B

Phase A/B (commit 999c054b etc.) hooked @discordjs/voice's UDP socket to capture non-opus RTP and depacketize H264 → mp4. It captured ZERO video because @discordjs/voice never authorizes the bot to receive others' video (no STREAM_WATCH). This spec replaces that approach with the native, selfbot-lib receive path, which is battle-tested and does the authorization + decryption + ffmpeg muxing for us.

Ground truth (verified in discord.js-selfbot-v13 3.7.1 source)

  1. ClientVoiceManager.joinChannel(channel, config) → a selfbot VoiceConnection with .receiver (VoiceReceiver → PacketHandler). [ClientVoiceManager.js:102-118]
  2. VoiceConnection.receiver is created in the constructor. [VoiceConnection.js:140]
  3. VoiceReceiver.createVideoStream(user, output) → PacketHandler.makeVideoStream → Recorder (ffmpeg that muxes H264+Opus RTP over UDP → Matroska (.mkv)). [Receiver.js, Recorder.js]
  4. PacketHandler routes: video RTP → Recorder UDP 65506, opus RTP → UDP 65510; decodes all via connection.authentication.{secret_key, mode} (supports aead_aes256_gcm_rtpsize and aead_xchacha20_poly1305_rtpsize = DAVE-compatible). [PacketHandler.js:115-155, 195-240]
  5. StreamConnectionReadonly.joinStreamConnection(userId) + sendSignalScreenshare() sends gateway op STREAM_WATCH so Discord actually forwards the streamer's RTP to us. [VoiceConnection.js:1100-1240]
  6. VoiceState.streaming = data.self_stream ?? false — lets us detect a streamer on voice state update. [VoiceState.js:94]

Problem / the crux

The gateway's voice today is @discordjs/voice (audio + music + GoLive-send). The selfbot-lib video-receive path lives on the selfbot-lib VoiceConnection — a separate voice stack. Two options:

Keep @discordjs/voice for everything it does today. Add a second, selfbot-lib voice connection to the same channel whose ONLY job is to watch + record others' video.

  • Pros: zero regression risk to audio/music/screenshare-send; uses native createVideoStream → mk4.
  • Cons: two voice connections for the same bot user in one channel. Need to verify Discord tolerates it (real selfbots like Discord-RE do exactly this for multi-stream). The selfbot lib's joinChannel reuses ClientVoiceManager.connection (it's a singleton) — see caveat below.

Option B: Migrate primary voice to selfbot lib

Make the selfbot VoiceConnection THE voice layer (it also does audio via receiver.createStream).

  • Pros: one connection; video+audio unified.
  • Cons: large refactor; high regression risk to the entire existing audio/music/GoLive stack. NOT chosen now.

CAVEAT — ClientVoiceManager.connection is a singleton

ClientVoiceManager.connection is a single VoiceConnection. The gateway's @discordjs/voice adapter and the selfbot lib both drive the same client voice state. Need to verify whether client.voice.joinChannel() can coexist with the active @discordjs/voice session, or whether we must create the selfbot VoiceConnection manually / re-use the existing voice state. This is the #1 technical risk to validate in the spike before committing to Option A.

Implementation plan (Option A)

1. Streamer detector (new: modules/voice-recording/videoRecorder.ts)

  • Listen to voice state updates (client.on('voiceStateUpdate') or the existing voice-state hook).
  • When voiceState.streaming === true for a member in the bot's channel → candidate to record.
  • Skip bot's own user id (unless we also want self-video; default skip).

2. Watch + record wiring

  • Ensure a selfbot-lib VoiceConnection exists for the channel (spike: client.voice.joinChannel(channel), fallback: build a VoiceConnection directly from the existing voice auth).
  • await selfbotVoiceConn.joinStreamConnection(userId) → STREAM_WATCH op 20.
  • const recorder = selfbotVoiceConn.receiver.createVideoStream(userId, outPath) where outPath points under <RECORDINGS_DIR>/<uid>/video-<streamKey>-<ts>.mkv (Recorder outputs MKV natively).
  • On recorder.on('ready') → mark recording; recorder.on('closed') → finalize.
  • Transcript later: MKV → mp4 via ffmpeg (Phase B muxToMp4 can accept mkv) for dashboard playback.

3. Teardown

  • When voiceState.streaming === false / user leaves / channel emptied → recorder.destroy(), selfbotVoiceConn.streamWatchConnection.delete(userId) / sendStopScreenshare().

4. Frontend (Phase UI, later)

  • oRPC/backend list .mkv per call session + FE <video> player (mirror audio recordings UI).

Files touched

  • services/discord-gateway/src/modules/voice-recording/videoRecorder.ts (new)
  • services/discord-gateway/src/modules/voice-recording/recorder.ts (wire streamer detector on voice join)
  • Possibly voiceController.ts (voice state update subscription)
  • Tests: tests/videoRecorder.test.ts (mock selfbot VoiceConnection + Recorder)

Verification

  1. pnpm typecheck + pnpm build + biome clean in discord-gateway.
  2. Unit: Recorder wiring + streamer detection with mocked VoiceConnection.
  3. Live (deploy): user shares screen → journal shows STREAM_WATCH sent + Recorder ready + .mkv file appears under recordings dir; playable via ffmpeg.
  4. CI Build & Deploy (Nix) green.

Open questions for spike (before full build)

  • Can client.voice.joinChannel() run alongside the active @discordjs/voice session, or does the singleton ClientVoiceManager.connection collide / tear down the existing audio connection?
  • Does the selfbot VoiceConnection need the bot's video: true flag in IDENTIFY to receive video (it advertises streams in IDENTIFY — see BaseMediaConnection/identify vs selfbot VoiceConnection)?
  • Does Recorder (spawns system ffmpeg, UDP loopback on 65506/65510) work in the Nix store runtime (ffmpeg-headless on PATH confirmed; UDP loopback fine)?