RecordPlay
RecordPlay records what a browser sends (audio, video and data channel messages) into Janus .mjr files on the server, and plays a recording back to a browser later over WebRTC. Use it for voicemail-style messages, a test fixture that replays a known stream, or to capture a session for post-processing with janus-pp-rec. A browser either records or plays on a handle; the two never mix.
Playback does not deliver media yet
play and start complete, ICE and DTLS connect, and then the server sends no RTP: the playout loop exists but nothing starts it in the binary. Recording and listing work. See Known gaps.
Enable and configure
RecordPlay is in the default build, but it only starts when its config file names a folder. Without general.path the plugin is left out (the C server fails its own init the same way).
general: {
path = "/opt/janus/share/janus/recordings" # where .mjr and .nfo files go and are read from
#admin_key = "supersecret" # needed for update, and to list private recordings
#private = true # new recordings are private unless the request says otherwise
#events = false
}The full table is in the plugin files reference. That page marks path as "not found in the port", which is a grep artifact: the dev configuration sets path and the recordings land in that folder (verified).
The same folder is read for playback. Each recording has a <id>.nfo file in INI format that maps an id to its audio, video and data .mjr files:
[9002]
name = Docs demo recording
date = 2026-01-15 10:00:00
private = false
audio = docs-demo-audio.mjrA recording from another plugin (VideoRoom, Streaming) plays after you copy its .mjr files into the folder and write such an .nfo. A .nfo with no private line is treated as private.
Try it
Record from a browser
record needs an SDP offer in jsep. The answer comes with the recording event. After webrtcup the plugin writes the media to one .mjr file per medium: rec-<id>-audio.mjr, rec-<id>-video.mjr and, for data channels, rec-<id>-data.mjr in the recordings folder (with filename you choose the base path), plus the <id>.nfo. stop ends it; the recording is only listed once the session is over, after the done event.
This first transcript is the signalling with a hand-made offer, so the .mjr files have no frames. It shows record, pause, resume, stop, the list that follows and the errors.
// RecordPlay: list, update, configure are synchronous. record and play are asynchronous; this transcript records with a hand-made offer, so the .mjr file has a header and no frames.
// Produced by docs-site/scripts/record-plugins-core.mjs against the Rust binary. Recordings go to the folder in janus.plugin.recordplay.jcfg.
// → POST /janus
{
"janus": "create",
"transaction": "t-1"
}
// ← 200 OK
{
"janus": "success",
"transaction": "t-1",
"data": {
"id": 9873482636819065
}
}
// → POST /janus/{session_id}
{
"janus": "attach",
"plugin": "janus.plugin.recordplay",
"transaction": "t-2"
}
// ← 200 OK
{
"janus": "success",
"session_id": 9873482636819065,
"transaction": "t-2",
"data": {
"id": 9728245592380508
}
}
// → POST /janus/{session_id}/{handle_id}
{
"janus": "message",
"transaction": "t-3",
"body": {
"request": "list"
}
}
// ← 200 OK
{
"janus": "success",
"session_id": 9873482636819065,
"transaction": "t-3",
"sender": 9728245592380508,
"plugindata": {
"plugin": "janus.plugin.recordplay",
"data": {
"recordplay": "list",
"list": []
}
}
}
// → POST /janus/{session_id}/{handle_id}
{
"janus": "message",
"transaction": "t-4",
"body": {
"request": "update"
}
}
// ← 200 OK
{
"janus": "success",
"session_id": 9873482636819065,
"transaction": "t-4",
"sender": 9728245592380508,
"plugindata": {
"plugin": "janus.plugin.recordplay",
"data": {
"recordplay": "ok"
}
}
}
// → POST /janus/{session_id}/{handle_id}
{
"janus": "message",
"transaction": "t-5",
"body": {
"request": "configure",
"video-bitrate-max": 1024000,
"video-keyframe-interval": 10000
}
}
// ← 200 OK
{
"janus": "success",
"session_id": 9873482636819065,
"transaction": "t-5",
"sender": 9728245592380508,
"plugindata": {
"plugin": "janus.plugin.recordplay",
"data": {
"recordplay": "configure",
"status": "ok",
"settings": {
"video-keyframe-interval": 10000,
"video-bitrate-max": 1024000
}
}
}
}
// errors: play needs an id, and the id must exist
// → POST /janus/{session_id}/{handle_id}
{
"janus": "message",
"transaction": "t-6",
"body": {
"request": "play"
}
}
// ← 200 OK
{
"janus": "ack",
"session_id": 9873482636819065,
"transaction": "t-6"
}
// ⇠ event, pushed by the server (long poll of GET /janus/{session_id})
{
"janus": "event",
"session_id": 9873482636819065,
"transaction": "t-6",
"sender": 9728245592380508,
"plugindata": {
"plugin": "janus.plugin.recordplay",
"data": {
"recordplay": "event",
"error_code": 415,
"error": "Missing mandatory element (id)"
}
}
}
// → POST /janus/{session_id}/{handle_id}
{
"janus": "message",
"transaction": "t-7",
"body": {
"request": "play",
"id": 1
}
}
// ← 200 OK
{
"janus": "ack",
"session_id": 9873482636819065,
"transaction": "t-7"
}
// ⇠ event, pushed by the server (long poll of GET /janus/{session_id})
{
"janus": "event",
"session_id": 9873482636819065,
"transaction": "t-7",
"sender": 9728245592380508,
"plugindata": {
"plugin": "janus.plugin.recordplay",
"data": {
"recordplay": "event",
"error_code": 416,
"error": "No such recording"
}
}
}
// → POST /janus/{session_id}/{handle_id}
{
"janus": "message",
"transaction": "t-8",
"body": {
"request": "record"
}
}
// ← 200 OK
{
"janus": "ack",
"session_id": 9873482636819065,
"transaction": "t-8"
}
// ⇠ event, pushed by the server (long poll of GET /janus/{session_id})
{
"janus": "event",
"session_id": 9873482636819065,
"transaction": "t-8",
"sender": 9728245592380508,
"plugindata": {
"plugin": "janus.plugin.recordplay",
"data": {
"recordplay": "event",
"error_code": 415,
"error": "Missing SDP offer"
}
}
}
// record needs an offer in jsep; the answer comes with the recording event
// → POST /janus/{session_id}/{handle_id}
{
"janus": "message",
"transaction": "t-9",
"body": {
"request": "record",
"name": "docs-demo",
"id": 9001
},
"jsep": {
"type": "offer",
"sdp": "v=0\r\no=- 4661856518455979478 2 IN IP4 127.0.0.1\r\ns=-\r\n…"
}
}
// ← 200 OK
{
"janus": "ack",
"session_id": 9873482636819065,
"transaction": "t-9"
}
// ⇠ event, pushed by the server (long poll of GET /janus/{session_id})
{
"janus": "event",
"session_id": 9873482636819065,
"transaction": "t-9",
"sender": 9728245592380508,
"plugindata": {
"plugin": "janus.plugin.recordplay",
"data": {
"recordplay": "event",
"result": {
"status": "recording",
"id": 9001,
"is_private": false
}
}
},
"jsep": {
"type": "answer",
"sdp": "v=0\r\no=- 4672383433022915 1 IN IP4 192.168.1.10\r\ns=Recording 9001\r\n…"
}
}
// → POST /janus/{session_id}/{handle_id}
{
"janus": "message",
"transaction": "t-10",
"body": {
"request": "pause"
}
}
// ← 200 OK
{
"janus": "ack",
"session_id": 9873482636819065,
"transaction": "t-10"
}
// ⇠ event, pushed by the server (long poll of GET /janus/{session_id})
{
"janus": "event",
"session_id": 9873482636819065,
"transaction": "t-10",
"sender": 9728245592380508,
"plugindata": {
"plugin": "janus.plugin.recordplay",
"data": {
"recordplay": "event",
"result": {
"status": "paused",
"id": 9001
}
}
}
}
// → POST /janus/{session_id}/{handle_id}
{
"janus": "message",
"transaction": "t-11",
"body": {
"request": "resume"
}
}
// ← 200 OK
{
"janus": "ack",
"session_id": 9873482636819065,
"transaction": "t-11"
}
// ⇠ event, pushed by the server (long poll of GET /janus/{session_id})
{
"janus": "event",
"session_id": 9873482636819065,
"transaction": "t-11",
"sender": 9728245592380508,
"plugindata": {
"plugin": "janus.plugin.recordplay",
"data": {
"recordplay": "event",
"result": {
"status": "resumed",
"id": 9001
}
}
}
}
// stop: first the stopped status, then done when the PeerConnection is gone
// → POST /janus/{session_id}/{handle_id}
{
"janus": "message",
"transaction": "t-12",
"body": {
"request": "stop"
}
}
// ← 200 OK
{
"janus": "ack",
"session_id": 9873482636819065,
"transaction": "t-12"
}
// ⇠ event, pushed by the server (long poll of GET /janus/{session_id})
{
"janus": "event",
"session_id": 9873482636819065,
"transaction": "t-12",
"sender": 9728245592380508,
"plugindata": {
"plugin": "janus.plugin.recordplay",
"data": {
"recordplay": "event",
"result": {
"status": "stopped",
"id": 9001,
"is_private": 0
}
}
}
}
// ⇠ event, pushed by the server (long poll of GET /janus/{session_id})
{
"janus": "event",
"session_id": 9873482636819065,
"sender": 9728245592380508,
"plugindata": {
"plugin": "janus.plugin.recordplay",
"data": {
"recordplay": "event",
"result": {
"status": "done",
"id": 9001,
"is_private": 0
}
}
}
}
// the recording is listed once the session is over
// → POST /janus/{session_id}/{handle_id}
{
"janus": "message",
"transaction": "t-13",
"body": {
"request": "list"
}
}
// ← 200 OK
{
"janus": "success",
"session_id": 9873482636819065,
"transaction": "t-13",
"sender": 9728245592380508,
"plugindata": {
"plugin": "janus.plugin.recordplay",
"data": {
"recordplay": "list",
"list": [
{
"id": 9001,
"is_private": false,
"name": "docs-demo",
"date": "2026-11-04 21:06:42",
"audio": true,
"audio_codec": "opus",
"video": true,
"video_codec": "vp8",
"data": false
}
]
}
}
}
// → POST /janus/{session_id}
{
"janus": "destroy",
"transaction": "t-14"
}
// ← 200 OK
{
"janus": "success",
"session_id": 9873482636819065,
"transaction": "t-14"
}The same from Chrome, recorded in a Janus Bench run (recordplay.record-play): webrtcup, media events, stop, done, and list.
// Recording from Chrome: record with a real offer, webrtcup, stop, list. Cut from a Janus Bench run (recordplay.record-play).
// The recording is listed only after the done event: a list sent right after stopped can still be empty.
// → sent
{
"janus": "create",
"transaction": "bench-mw29kt8c-2u"
}
// ← received
{
"janus": "success",
"transaction": "bench-mw29kt8c-2u",
"data": {
"id": 9873482636819065
}
}
// → sent
{
"janus": "attach",
"plugin": "janus.plugin.recordplay",
"transaction": "bench-mw29kt8k-2v",
"session_id": 9873482636819065
}
// ← received
{
"janus": "success",
"session_id": 9873482636819065,
"transaction": "bench-mw29kt8k-2v",
"data": {
"id": 9728245592380508
}
}
// → sent
{
"janus": "message",
"handle_id": 9728245592380508,
"body": {
"request": "record",
"name": "bench-oc8i56"
},
"jsep": {
"type": "offer",
"sdp": "v=0\r\no=- 4661856518455979478 2 IN IP4 127.0.0.1\r\ns=-\r\n…"
},
"transaction": "bench-mw29kt93-2w",
"session_id": 9873482636819065
}
// ← received
{
"janus": "ack",
"session_id": 9873482636819065,
"transaction": "bench-mw29kt93-2w"
}
// ← received
{
"janus": "event",
"session_id": 9873482636819065,
"transaction": "bench-mw29kt93-2w",
"sender": 9728245592380508,
"plugindata": {
"plugin": "janus.plugin.recordplay",
"data": {
"recordplay": "event",
"result": {
"status": "recording",
"id": 4672383433022915,
"is_private": false
}
}
},
"jsep": {
"type": "answer",
"sdp": "v=0\r\no=- 1406700319406760 1 IN IP4 192.168.1.10\r\ns=Recording 4672383433022915\r\n…"
}
}
// → sent
{
"janus": "trickle",
"handle_id": 9728245592380508,
"candidate": {
"candidate": "candidate:8492722295 1 udp 3167449224 192.168.1.10 53688 typ host generation 0 ufrag xnuo network-cost 999",
"sdpMid": "0",
"sdpMLineIndex": 0
},
"transaction": "bench-mw29kt9f-2x",
"session_id": 9873482636819065
}
// ← received
{
"janus": "ack",
"session_id": 9873482636819065,
"transaction": "bench-mw29kt9f-2x"
}
// (1 more trickle ICE candidate message(s) and their acks omitted)
// → sent
{
"janus": "trickle",
"handle_id": 9728245592380508,
"candidate": {
"completed": true
},
"transaction": "bench-mw29ktcd-2z",
"session_id": 9873482636819065
}
// ← received
{
"janus": "ack",
"session_id": 9873482636819065,
"transaction": "bench-mw29ktcd-2z"
}
// ⇠ event pushed by the server
{
"janus": "webrtcup",
"session_id": 9873482636819065,
"sender": 9728245592380508
}
// ⇠ event pushed by the server
{
"janus": "media",
"session_id": 9873482636819065,
"sender": 9728245592380508,
"mid": "1",
"type": "video",
"receiving": true
}
// ⇠ event pushed by the server
{
"janus": "media",
"session_id": 9873482636819065,
"sender": 9728245592380508,
"mid": "0",
"type": "audio",
"receiving": true
}
// → sent
{
"janus": "message",
"handle_id": 9728245592380508,
"body": {
"request": "stop"
},
"transaction": "bench-mw29kvsa-30",
"session_id": 9873482636819065
}
// ← received
{
"janus": "ack",
"session_id": 9873482636819065,
"transaction": "bench-mw29kvsa-30"
}
// ← received
{
"janus": "event",
"session_id": 9873482636819065,
"transaction": "bench-mw29kvsa-30",
"sender": 9728245592380508,
"plugindata": {
"plugin": "janus.plugin.recordplay",
"data": {
"recordplay": "event",
"result": {
"status": "stopped",
"id": 4672383433022915,
"is_private": 0
}
}
}
}
// → sent
{
"janus": "message",
"handle_id": 9728245592380508,
"body": {
"request": "list"
},
"transaction": "bench-mw29kvsl-31",
"session_id": 9873482636819065
}
// ← received
{
"janus": "success",
"session_id": 9873482636819065,
"transaction": "bench-mw29kvsl-31",
"sender": 9728245592380508,
"plugindata": {
"plugin": "janus.plugin.recordplay",
"data": {
"recordplay": "list",
"list": []
}
}
}
// ⇠ event pushed by the server
{
"janus": "event",
"session_id": 9873482636819065,
"sender": 9728245592380508,
"plugindata": {
"plugin": "janus.plugin.recordplay",
"data": {
"recordplay": "event",
"result": {
"status": "done",
"id": 4672383433022915,
"is_private": 0
}
}
}
}
// ⇠ event pushed by the server
{
"janus": "hangup",
"session_id": 9873482636819065,
"sender": 9728245592380508,
"reason": "Close PC"
}
// → sent
{
"janus": "message",
"handle_id": 9728245592380508,
"body": {
"request": "list"
},
"transaction": "bench-mw29kw6r-32",
"session_id": 9873482636819065
}
// ← received
{
"janus": "success",
"session_id": 9873482636819065,
"transaction": "bench-mw29kw6r-32",
"sender": 9728245592380508,
"plugindata": {
"plugin": "janus.plugin.recordplay",
"data": {
"recordplay": "list",
"list": [
{
"id": 4672383433022915,
"is_private": false,
"name": "bench-oc8i56",
"date": "2026-11-04 20:51:04",
"audio": true,
"audio_codec": "opus",
"video": true,
"video_codec": "vp8",
"data": false
}
]
}
}
}
// → sent
{
"janus": "hangup",
"handle_id": 9728245592380508,
"transaction": "bench-mw29kw72-33",
"session_id": 9873482636819065
}List, update and play
list and update are synchronous. update re-reads the folder, so recordings you copy in by hand appear. play is asynchronous and the plugin makes the offer, so do not send a jsep with it.
This transcript writes a small Opus .mjr and its .nfo into the folder first, then runs update, list and play. It stops at the signalling: no browser connects here.
// Playing back a recording: update, list, play, then start with an answer.
// The recording is a file this script writes into the recordings folder: an Opus .mjr with five RTP packets and a .nfo that points to it. The offer is the plugin's; the answer is built from it by the script. No browser: ICE and DTLS do not complete, so this shows signalling only.
// → POST /janus
{
"janus": "create",
"transaction": "play-1"
}
// ← 200 OK
{
"janus": "success",
"transaction": "play-1",
"data": {
"id": 9873482636819065
}
}
// → POST /janus/{session_id}
{
"janus": "attach",
"plugin": "janus.plugin.recordplay",
"transaction": "play-2"
}
// ← 200 OK
{
"janus": "success",
"session_id": 9873482636819065,
"transaction": "play-2",
"data": {
"id": 9728245592380508
}
}
// A file dropped into the folder is not listed until update re-scans it.
// → POST /janus/{session_id}/{handle_id}
{
"janus": "message",
"transaction": "play-3",
"body": {
"request": "list"
}
}
// ← 200 OK
{
"janus": "success",
"session_id": 9873482636819065,
"transaction": "play-3",
"sender": 9728245592380508,
"plugindata": {
"plugin": "janus.plugin.recordplay",
"data": {
"recordplay": "list",
"list": [
{
"id": 9001,
"is_private": false,
"name": "docs-demo",
"date": "2026-11-04 21:06:42",
"audio": true,
"audio_codec": "opus",
"video": true,
"video_codec": "vp8",
"data": false
}
]
}
}
}
// → POST /janus/{session_id}/{handle_id}
{
"janus": "message",
"transaction": "play-4",
"body": {
"request": "update"
}
}
// ← 200 OK
{
"janus": "success",
"session_id": 9873482636819065,
"transaction": "play-4",
"sender": 9728245592380508,
"plugindata": {
"plugin": "janus.plugin.recordplay",
"data": {
"recordplay": "ok"
}
}
}
// → POST /janus/{session_id}/{handle_id}
{
"janus": "message",
"transaction": "play-5",
"body": {
"request": "list"
}
}
// ← 200 OK
{
"janus": "success",
"session_id": 9873482636819065,
"transaction": "play-5",
"sender": 9728245592380508,
"plugindata": {
"plugin": "janus.plugin.recordplay",
"data": {
"recordplay": "list",
"list": [
{
"id": 9002,
"is_private": false,
"name": "Docs demo recording",
"date": "2026-01-15 10:00:00",
"audio": true,
"audio_codec": "opus",
"video": false,
"data": false
},
{
"id": 9001,
"is_private": false,
"name": "docs-demo",
"date": "2026-11-04 21:06:42",
"audio": true,
"audio_codec": "opus",
"video": true,
"video_codec": "vp8",
"data": false
}
]
}
}
}
// play: ack, then a preparing event with the plugin's offer (audio only, because the recording has only audio)
// → POST /janus/{session_id}/{handle_id}
{
"janus": "message",
"transaction": "play-6",
"body": {
"request": "play",
"id": 9002
}
}
// ← 200 OK
{
"janus": "ack",
"session_id": 9873482636819065,
"transaction": "play-6"
}
// ⇠ event, pushed by the server (long poll of GET /janus/{session_id})
{
"janus": "event",
"session_id": 9873482636819065,
"transaction": "play-6",
"sender": 9728245592380508,
"plugindata": {
"plugin": "janus.plugin.recordplay",
"data": {
"recordplay": "event",
"result": {
"status": "preparing",
"id": 9002
}
}
},
"jsep": {
"type": "offer",
"sdp": "v=0\r\no=- 4661856518455979 1 IN IP4 192.168.1.10\r\ns=Recording 9002\r\n…"
}
}
// start with the answer
// → POST /janus/{session_id}/{handle_id}
{
"janus": "message",
"transaction": "play-7",
"body": {
"request": "start"
},
"jsep": {
"type": "answer",
"sdp": "v=0\r\no=- 5783672383433022915 2 IN IP4 127.0.0.1\r\ns=Recording 9002\r\n…"
}
}
// ← 200 OK
{
"janus": "ack",
"session_id": 9873482636819065,
"transaction": "play-7"
}
// ⇠ event, pushed by the server (long poll of GET /janus/{session_id})
{
"janus": "event",
"session_id": 9873482636819065,
"transaction": "play-7",
"sender": 9728245592380508,
"plugindata": {
"plugin": "janus.plugin.recordplay",
"data": {
"recordplay": "event",
"result": {
"status": "playing"
}
}
}
}
// → POST /janus/{session_id}/{handle_id}
{
"janus": "message",
"transaction": "play-8",
"body": {
"request": "stop"
}
}
// ← 200 OK
{
"janus": "ack",
"session_id": 9873482636819065,
"transaction": "play-8"
}
// ⇠ event, pushed by the server (long poll of GET /janus/{session_id})
{
"janus": "event",
"session_id": 9873482636819065,
"transaction": "play-8",
"sender": 9728245592380508,
"plugindata": {
"plugin": "janus.plugin.recordplay",
"data": {
"recordplay": "event",
"result": {
"status": "stopped",
"id": 9002,
"is_private": 0
}
}
}
}Private recordings and the admin key
// Private recordings and the admin key. This server's janus.plugin.recordplay.jcfg sets path, admin_key = "supersecret" and private = true, so every new recording is private by default.
// Produced by docs-site/scripts/record-plugins-core.mjs against the Rust binary. The recording is made with a hand-made offer, so the .mjr has no frames; only listing is shown.
// → POST /janus
{
"janus": "create",
"transaction": "t-1"
}
// ← 200 OK
{
"janus": "success",
"transaction": "t-1",
"data": {
"id": 9873482636819065
}
}
// → POST /janus/{session_id}
{
"janus": "attach",
"plugin": "janus.plugin.recordplay",
"transaction": "t-2"
}
// ← 200 OK
{
"janus": "success",
"session_id": 9873482636819065,
"transaction": "t-2",
"data": {
"id": 9728245592380508
}
}
// → POST /janus/{session_id}/{handle_id}
{
"janus": "message",
"transaction": "t-3",
"body": {
"request": "record",
"name": "secret demo",
"id": 7001
},
"jsep": {
"type": "offer",
"sdp": "v=0\r\no=- 4661856518455979478 2 IN IP4 127.0.0.1\r\ns=-\r\n…"
}
}
// ← 200 OK
{
"janus": "ack",
"session_id": 9873482636819065,
"transaction": "t-3"
}
// ⇠ event, pushed by the server (long poll of GET /janus/{session_id})
{
"janus": "event",
"session_id": 9873482636819065,
"transaction": "t-3",
"sender": 9728245592380508,
"plugindata": {
"plugin": "janus.plugin.recordplay",
"data": {
"recordplay": "event",
"result": {
"status": "recording",
"id": 7001,
"is_private": true
}
}
},
"jsep": {
"type": "answer",
"sdp": "v=0\r\no=- 4672383433022915 1 IN IP4 192.168.1.10\r\ns=Recording 7001\r\n…"
}
}
// → POST /janus/{session_id}/{handle_id}
{
"janus": "message",
"transaction": "t-4",
"body": {
"request": "stop"
}
}
// ← 200 OK
{
"janus": "ack",
"session_id": 9873482636819065,
"transaction": "t-4"
}
// ⇠ event, pushed by the server (long poll of GET /janus/{session_id})
{
"janus": "event",
"session_id": 9873482636819065,
"transaction": "t-4",
"sender": 9728245592380508,
"plugindata": {
"plugin": "janus.plugin.recordplay",
"data": {
"recordplay": "event",
"result": {
"status": "stopped",
"id": 7001,
"is_private": 1
}
}
}
}
// ⇠ event, pushed by the server (long poll of GET /janus/{session_id})
{
"janus": "event",
"session_id": 9873482636819065,
"sender": 9728245592380508,
"plugindata": {
"plugin": "janus.plugin.recordplay",
"data": {
"recordplay": "event",
"result": {
"status": "done",
"id": 7001,
"is_private": 1
}
}
}
}
// list without the key does not show the private recording; with the key it does.
// → POST /janus/{session_id}/{handle_id}
{
"janus": "message",
"transaction": "t-5",
"body": {
"request": "list"
}
}
// ← 200 OK
{
"janus": "success",
"session_id": 9873482636819065,
"transaction": "t-5",
"sender": 9728245592380508,
"plugindata": {
"plugin": "janus.plugin.recordplay",
"data": {
"recordplay": "list",
"list": []
}
}
}
// → POST /janus/{session_id}/{handle_id}
{
"janus": "message",
"transaction": "t-6",
"body": {
"request": "list",
"admin_key": "wrong"
}
}
// ← 200 OK
{
"janus": "success",
"session_id": 9873482636819065,
"transaction": "t-6",
"sender": 9728245592380508,
"plugindata": {
"plugin": "janus.plugin.recordplay",
"data": {
"recordplay": "event",
"error_code": 440,
"error": "Unauthorized (wrong admin_key)"
}
}
}
// → POST /janus/{session_id}/{handle_id}
{
"janus": "message",
"transaction": "t-7",
"body": {
"request": "list",
"admin_key": "supersecret"
}
}
// ← 200 OK
{
"janus": "success",
"session_id": 9873482636819065,
"transaction": "t-7",
"sender": 9728245592380508,
"plugindata": {
"plugin": "janus.plugin.recordplay",
"data": {
"recordplay": "list",
"list": [
{
"id": 7001,
"is_private": true,
"name": "secret demo",
"date": "2026-11-04 21:06:43",
"audio": true,
"audio_codec": "opus",
"video": true,
"video_codec": "vp8",
"data": false
}
]
}
}
}
// update needs the key when one is configured.
// → POST /janus/{session_id}/{handle_id}
{
"janus": "message",
"transaction": "t-8",
"body": {
"request": "update"
}
}
// ← 200 OK
{
"janus": "success",
"session_id": 9873482636819065,
"transaction": "t-8",
"sender": 9728245592380508,
"plugindata": {
"plugin": "janus.plugin.recordplay",
"data": {
"recordplay": "event",
"error_code": 415,
"error": "Missing mandatory element (admin_key)"
}
}
}
// → POST /janus/{session_id}/{handle_id}
{
"janus": "message",
"transaction": "t-9",
"body": {
"request": "update",
"admin_key": "supersecret"
}
}
// ← 200 OK
{
"janus": "success",
"session_id": 9873482636819065,
"transaction": "t-9",
"sender": 9728245592380508,
"plugindata": {
"plugin": "janus.plugin.recordplay",
"data": {
"recordplay": "ok"
}
}
}
// → POST /janus/{session_id}
{
"janus": "destroy",
"transaction": "t-10"
}
// ← 200 OK
{
"janus": "success",
"session_id": 9873482636819065,
"transaction": "t-10"
}Playback in Chrome (the gap)
This is the Bench run that plays the recording made in the record transcript. Everything succeeds up to the end, and then nothing is sent:
// KNOWN GAP. Playing the recording back in Chrome. Cut from the same Janus Bench run.
// play, preparing with an offer, start, playing, webrtcup all happen, then no RTP is sent: the port has no playout thread running in the binary, so the check sees 0 packets.
// → sent
{
"janus": "create",
"transaction": "bench-mw29kw7e-34"
}
// ← received
{
"janus": "success",
"transaction": "bench-mw29kw7e-34",
"data": {
"id": 987348263681906
}
}
// → sent
{
"janus": "attach",
"plugin": "janus.plugin.recordplay",
"transaction": "bench-mw29kw7o-35",
"session_id": 987348263681906
}
// ← received
{
"janus": "success",
"session_id": 987348263681906,
"transaction": "bench-mw29kw7o-35",
"data": {
"id": 6972824559238050
}
}
// → sent
{
"janus": "message",
"handle_id": 6972824559238050,
"body": {
"request": "play",
"id": 8366185651845597
},
"transaction": "bench-mw29kw7w-36",
"session_id": 987348263681906
}
// ← received
{
"janus": "ack",
"session_id": 987348263681906,
"transaction": "bench-mw29kw7w-36"
}
// ← received
{
"janus": "event",
"session_id": 987348263681906,
"transaction": "bench-mw29kw7w-36",
"sender": 6972824559238050,
"plugindata": {
"plugin": "janus.plugin.recordplay",
"data": {
"recordplay": "event",
"result": {
"status": "preparing",
"id": 8366185651845597
}
}
},
"jsep": {
"type": "offer",
"sdp": "v=0\r\no=- 9478367238343302 1 IN IP4 192.168.1.10\r\ns=Recording 8366185651845597\r\n…"
}
}
// → sent
{
"janus": "message",
"handle_id": 6972824559238050,
"body": {
"request": "start"
},
"jsep": {
"type": "answer",
"sdp": "v=0\r\no=- 3915040670031940676 2 IN IP4 127.0.0.1\r\ns=-\r\n…"
},
"transaction": "bench-mw29kw8k-37",
"session_id": 987348263681906
}
// ← received
{
"janus": "ack",
"session_id": 987348263681906,
"transaction": "bench-mw29kw8k-37"
}
// ← received
{
"janus": "event",
"session_id": 987348263681906,
"transaction": "bench-mw29kw8k-37",
"sender": 6972824559238050,
"plugindata": {
"plugin": "janus.plugin.recordplay",
"data": {
"recordplay": "event",
"result": {
"status": "playing"
}
}
}
}
// → sent
{
"janus": "trickle",
"handle_id": 6972824559238050,
"candidate": {
"candidate": "candidate:1849272229 1 udp 5216744922 192.168.1.10 49362 typ host generation 0 ufrag mJ9v network-cost 999",
"sdpMid": "0",
"sdpMLineIndex": 0
},
"transaction": "bench-mw29kw8v-38",
"session_id": 987348263681906
}
// ← received
{
"janus": "ack",
"session_id": 987348263681906,
"transaction": "bench-mw29kw8v-38"
}
// → sent
{
"janus": "trickle",
"handle_id": 6972824559238050,
"candidate": {
"completed": true
},
"transaction": "bench-mw29kwby-39",
"session_id": 987348263681906
}
// ← received
{
"janus": "ack",
"session_id": 987348263681906,
"transaction": "bench-mw29kwby-39"
}
// ⇠ event pushed by the server
{
"janus": "webrtcup",
"session_id": 987348263681906,
"sender": 6972824559238050
}Reference
Synchronous requests
| Request | Parameters | Reply recordplay |
|---|---|---|
list | admin_key (optional: also lists private recordings) | list, with list: per recording id, is_private, name, date, audio, video, data (booleans), audio_codec, video_codec |
update | admin_key (required when one is configured) | ok |
configure | video-bitrate-max (REMB cap for the recorder, bits per second; default 1048576), video-keyframe-interval (milliseconds between keyframe requests; default 15000) | configure, with status: "ok" and settings echoing both values (verified, including the defaults when you send neither) |
The C documentation shows audio, video and data in list as file names, and says the keyframe interval is in seconds. Both servers send booleans there (the file names are only in the .nfo) and both treat the interval as milliseconds (verified). date is the host's local time.
Asynchronous requests
Each returns an ack; the result is an event on the same handle, {"recordplay":"event","result":{…}}.
| Request | Parameters | result |
|---|---|---|
record | name, id (chosen if missing), is_private, filename (base path), audiocodec, videocodec, videoprofile, opusred, textdata (data channel as text, default, or binary); offer in jsep (required) | {"status":"recording","id":N,"is_private":B} with the answer in jsep |
pause | none | {"status":"paused","id":N} |
resume | none | {"status":"resumed","id":N} |
stop | none | {"status":"stopped","id":N,"is_private":B}, then {"status":"done",…} when the PeerConnection is gone |
play | id (required); no jsep | {"status":"preparing","id":N} with the plugin's offer in jsep |
start | none; the answer in jsep | {"status":"playing"} |
stop (playing) | none | {"status":"stopped"} |
Error codes
| Code | Text seen or from the C source | When |
|---|---|---|
| 411 | No message?? | No message. |
| 412 | JSON error: not an object | Not JSON. |
| 413 | Unknown request '…' | Unrecognised request. |
| 414 | Invalid element (…) | Wrong type. |
| 415 | Missing mandatory element (id), Missing SDP offer | A required parameter or the offer is absent. |
| 416 | No such recording | play of an id that is not listed. |
| 417 | Error opening recording files | The files are missing or have no readable frames. |
| 418 | Not a playout session, can't start (and similar) | For example start on a handle that never ran play (verified). |
| 419 | Invalid SDP | The offer or answer does not parse. |
| 420 | Recording N already exists | record with an id in use. |
| 440 | Unauthorized (wrong admin_key) | A wrong admin_key. |
| 499 | Unknown error |
Behaviour you may not expect
is_privateis a boolean inrecordingandlist, but an integer (0or1) in thestoppedanddoneevents. C does the same (json_integer), so clients that compare withfalsemiss it.- The recording is not in
listright afterstopped. It appears afterdone, which arrives a moment later (seen in the Bench run: alistbetween the two returned an empty list). - The plugin reads the folder at start-up, after
updateand after each recording. A file dropped into the folder later does not show up untilupdate. - A
.nfowith noprivateline means private (verified: it was missing fromlist), and a private recording is hidden fromlistunless the rightadmin_keyis sent. C also lets you play a private recording if you know the id; that was not tried here. stopbefore anything started answers{"status":"stopped"}with noid.- With
private = truein the config, every new recording withoutis_privatein the request is private. - The C documentation first says only audio and video can be recorded, then mentions data channel recordings. The code (and the port) record and replay data channels with the
datafile in the.nfo.
Known gaps
| Gap | Detail |
|---|---|
| Playback sends no media | After start, ICE connects and DTLS finishes (webrtcup) but the server sends 0 RTP packets. The loop that reads the .mjr file and sends frames at their recorded times (playout_task) is in the crate and tested, but the plugin does not start it. Evidence: Bench recordplay.record-play fails at "recorded audio arrives" with audio: in 0p out 0p in handle_info. The doc comment at the top of plugins/recordplay/src/lib.rs says so. |
| Data channel recording and playback | Ported, not compared with the C server, not run here. |
| Simulcast recording | The simulcast member of the record offer is handled; not run with a browser. |
Recording is checked end to end in Chrome: record, media in, stop, done, and the recording in list (the first steps of recordplay.record-play pass).
Test it in Janus Bench
Open the RecordPlay page, or run the check recordplay.record-play. It fails today at the last step (recorded audio arrives); every step before it passes. See Janus Bench.