Skip to content

ppmmx standalone self-hosted node functional test report

Deploy → publish → play → on-demand recording → playback URL → expiry cleanup

Attribute Value
Document type Functional test report
Subject ppmmx standalone self-hosted node (deployed with ppmmxDocker): ingest, WHEP playback, on-demand recording (POST /api/record/split), playback URLs, expired-recording cleanup
Tested 2026-10-23 (Beijing time)
Version under test ppmmx v1.19.1rc080 (linux/amd64, with standalone on-demand recording and local playback)
Environment One Ubuntu cloud host + Docker (network_mode: host); public IP and the Let's Encrypt https hostname mmx-xxxxxxxx.rent.pp-cdn.org are redacted; PPCDN production ppcenter (api.pp-cdn.org); one dedicated test App (appId written as app_xxx; the appSecret does not appear in this report)
Test stream A publisher continuously pushing dual SRT streams: app_xxx/B01-frontView/h264 (H264, three simulcast layers: 1080p / 720p / 360p) and app_xxx/B01-frontView/hevc (HEVC, three layers), Opus audio; the 1080p H264 layer is about 4 Mbps
Result Pass: all six functions behave as expected; recordings stay on the node's disk, round-end returns the playback URL directly, and the URL plays without any credentials; expiry cleanup is in §7

1. Goal

In the order a real self-hosting user would go, verify each step:

  1. How to deploy — deploy a standalone node with the one-line script; it registers with ppcenter and shows as running;
  2. How to publish — the publish address obtained from ppcenter points at your own node, and the stream shows up online on it;
  3. How to play — open it in a browser, use the standard WHEP URL, or get a playback URL from ppcenter;
  4. How to find the recording API address and record on demand — the API host must be your own node; one session is round-start → round-end;
  5. How to get the playback URL — round-end returns it directly; it downloads and plays in a browser;
  6. How to clean up expired recordings — automatic cleanup by recordDeleteAfter (10 days); the playback URL stops working afterwards.

2. How to deploy

2.1 Create the node and get a License Code

  1. Sign in to the PPCDN console → ppmmx nodes tab → Add ppmmx node, choose type standalone;
  2. After creation you get a License Code (like lic_xxx; copy it from the node list at any time).

2.2 One-line deploy on your own server

You run the script yourself on the target server. It needs only the License Code (the node type is looked up from it):

curl -fsSL https://raw.githubusercontent.com/ppcdn-org/ppmmxDocker/main/deploy.sh -o deploy.sh
chmod +x deploy.sh
./deploy.sh --license-code lic_xxx

The script installs Docker if missing, detects the public IP, requests a fixed https hostname from ppcenter and configures Caddy with an automatic certificate, writes .env, and starts the node with docker compose up -d --build. On Windows use deploy.ps1 -LicenseCode lic_xxx.

2.3 Open the ports

Port Protocol Purpose
8888 TCP WHIP/WHEP signaling; also the port of the recording API and playback URLs
8188 UDP WebRTC media (ICE)
7890 UDP SRT ingest (enabled on standalone by default)
80, 443 TCP Automatic HTTPS (certificate issuance + https entry)
8080 TCP Admin UI — operators only, do not expose it to the internet

2.4 Verify the deployment

docker ps                                   # ppmmx and ppmmx-caddy are Up
docker logs ppmmx 2>&1 | grep -E "mmx v1|MMX control connected"

Expect mmx v1.19.1rc0xx, linux, amd64 and MMX control connected to wss://api.pp-cdn.org/ws/mmx; the node shows as "Running" in the console node list.

Measured: after the new version started, MMX control connected appeared within 5 seconds, and both publishing streams reconnected on their own within about 40 seconds.

About the version used in this test

On-demand recording on standalone is a new capability: at test time the ppmmx published by ppmmxDocker was an older build. So this test used a node already deployed per §2.2 and replaced the binary by hand and rebuilt the image (steps in Appendix A). ppmmxDocker now downloads the ppmmx binary from its own GitHub Release when the image is built (no binary is stored in the repository, and the Release only keeps the newest version). Once a maintainer publishes the new version, newly deployed nodes have recording out of the box; existing nodes upgrade with git pull && docker compose build --no-cache ppmmx && docker compose up -d ppmmx.

3. How to publish

3.1 Get the publish address: it is your own node

A registered standalone node joins your own resource pool, and ppcenter only picks your own node for your App's ingest. Measured request for an SRT publish address:

curl -s -X POST https://api.pp-cdn.org/v1/publish/requests \
  -H "Content-Type: application/json" \
  -d '{"appId":"app_xxx","appSecret":"<your appSecret>","streamName":"B01-frontView","protocol":"srt","requestRegion":"cn"}'

Response (excerpt, tokens omitted):

{
  "originNodeId": 11,
  "ppcdnPublishEndpoint": "<node-public-ip>:7890",
  "publishToken": "v1.whip-1.…",
  "publishTokens": { "h264": "v1.whip-1.…", "hevc": "v1.whip-1.…" }
}

ppcdnPublishEndpoint is exactly the self-hosted node's public-ip:7890, and originNodeId is the node's id in ppcenter.

3.2 Publish

  • ppobs (recommended): Settings → Stream → Service PPCDN, enter appId / appSecret, choose SRT or WHIP as the ingest protocol, start streaming. The address and token are fetched automatically and it reconnects on its own after drops. See the ppobs guide.
  • Build the SRT URL yourself: srt://{ppcdnPublishEndpoint}?streamid=publish:{appId}/{streamName}/{codec}:{token}, where codec is h264 or hevc and token is the matching entry of publishTokens (tokens are bound to the exact path).
  • Stream-name rule: streamName must be the two-part {tableId}-{view} (here B01-frontView, i.e. tableId=B01, view=frontView); otherwise the node rejects the stream and on-demand recording cannot find it either.

3.3 Confirm the stream is online on the node

curl -s localhost:8080/v3/paths/list     # run on the node itself (the admin port is not public)
docker logs ppmmx 2>&1 | grep recv-stats | tail -3

Measured: both paths app_xxx/B01-frontView/h264 and app_xxx/B01-frontView/hevc are ready: true; the h264 path carries three H264 tracks (1920×1080 / 1280×720 / 640×360) plus one Opus track, the hevc path carries three H265 tracks plus Opus; the log prints one [SRT] … recv-stats line every 10 seconds (bitrate, loss, RTT, …).

4. How to play

Three ways, all landing on your own node.

4.1 Open it directly in a browser (fastest)

The node ships a viewing page:

https://mmx-xxxxxxxx.rent.pp-cdn.org/<appId>/<streamName>/h264

Measured: opening …/app_xxx/B01-frontView/h264 in a browser showed live video after about 4 seconds (the burned-in timestamp matched wall-clock time). For HEVC viewers end the URL with /hevc (the browser must decode HEVC).

4.2 Standard WHEP URL

Any WHEP player works:

https://mmx-xxxxxxxx.rent.pp-cdn.org/<appId>/<streamName>/h264/whep      # or /hevc/whep

standalone does not enforce playback auth by default (webrtcPlaybackAuthEnable: false), so the URL is usable as is. Measured: OPTIONS on it returns 204, and the browser completed WebRTC setup through it and rendered video.

4.3 Get a playback URL from ppcenter (for your own player page)

Use pplayer in your page, or have your backend ask ppcenter:

# 1) Get a viewer token (backend only — keep appSecret out of the frontend)
curl -s -X POST https://api.pp-cdn.org/auth/generate -H "Content-Type: application/json" \
  -d '{"appId":"app_xxx","appSecret":"<your appSecret>","stream":"B01-frontView"}'
# 2) Ask for the playback URL with Authorization: Bearer {appId}:{txTime}:{txSecret}
curl -s -X POST https://api.pp-cdn.org/v1/play/requests \
  -H "Authorization: Bearer app_xxx:<txTime>:<txSecret>" -H "Content-Type: application/json" \
  -d '{"streamName":"B01-frontView","clientId":"c1","requestRegion":"cn","capabilities":["whep"]}'

Measured response (excerpt):

{
  "mode": "edge-only",
  "edgeStreamUrl": "https://mmx-xxxxxxxx.rent.pp-cdn.org/app_xxx/B01-frontView?txTime=…&txSecret=…",
  "edge": { "nodeId": 11, "region": "self-hosted" }
}

edge.nodeId is the same node as originNodeId in §3.1: viewers are scheduled to your own standalone node. The player appends /h264/whep or /hevc/whep to edgeStreamUrl.

5. How to find the recording API address and record on demand

5.1 API address: it must be your own node

Recording happens on the node that receives the stream, so call the recording API on your own node:

POST {your-node-address}/api/record/split

{your-node-address} is MMX_WEBRTC_BASE_URL in the node's .env (with automatic HTTPS it is https://mmx-xxxxxxxx.rent.pp-cdn.org, otherwise http://<public-ip>:8888):

grep ^MMX_WEBRTC_BASE_URL= ~/ppmmxDocker/.env

Do not use api.pp-cdn.org

api.pp-cdn.org is PPCDN's hosted recorder; it only records streams published to the hosted nodes and cannot see streams on a self-hosted node. The official sample client record_api.py refuses that address outright.

5.2 One session = one start + one end

tableId is the prefix of the stream name {tableId}-{view} (here B01); gameId is your business session id; an empty gameRound means start, a non-empty one means end. The signature uses that appId's own appSecret (md5(appSecret + time + appId + tableId + [gameRound] + gameId)); the full protocol is in the per-session recording API.

Run a full session with the official sample client (Python standard library only):

cd ppmmxDocker/example/recordApi
export RECORD_API_BASE_URL=https://mmx-xxxxxxxx.rent.pp-cdn.org   # your own node
export RECORD_API_APP_ID=app_xxx
export RECORD_API_APP_SECRET=<your appSecret>
python record_api.py auto --table-id B01 --game-id my-game-001 --game-round gc001 --wait 30

Measured (--wait 30, i.e. a 30-second recording):

[round-start] HTTP 200
         response: {"code":200,"msg":"OK","data":null}
         ... round in progress, waiting 30s ...
[round-end (gameRound=gc…)] HTTP 200
         response: {"code":200,"msg":"OK","data":{"playbackUrls":[{"view":"frontView","url":"https://mmx-xxxxxxxx.rent.pp-cdn.org/api/record/play/app_xxx/splitrec_29c0….mp4","fileName":"B01-frontView-gc….mp4","sizeBytes":7892613,"durationSeconds":30}]}}

Notes:

  • On a self-hosted node round-start returns data: null; only round-end carries data.playbackUrls;
  • One (appId, tableId) can be held by only one gameId at a time; a round-start from another gameId is rejected;
  • If the tableId has no online stream, round-start returns code=50001;
  • Do not end right after starting (a gap shorter than the ~1 s segment duration fails with "nothing to finalize"); real sessions are far longer.

6. How to get the playback URL

6.1 Where it comes from

data.playbackUrls[].url in the round-end response is the playback URL, one entry per online view (view). Format:

{node-address}/api/record/play/<appId>/<record-id>.mp4
  • No authentication, no expiry: while the file is on the node, anyone who has the URL can play it; the record id is random and unguessable, but still do not hand the URL to people who should not see it;
  • The URL only opens records of that appId; requesting the same record id under another appId returns 404;
  • The file is served from the node's own disk: data/recordings/<date>/<appId>/<tableId>-<view>/<tableId>-<view>-<gameRound>.mp4 (docker volume mmx-data).

6.2 Measured verification

Check Method Result
Download without credentials curl -o rec.mp4 <url> HTTP 200, Content-Type: video/mp4, 7,892,613 bytes, equal to sizeBytes in the response
Range / seeking curl -H "Range: bytes=0-99" <url> HTTP 206, Content-Range: bytes 0-99/7892613
File structure Parse top-level boxes ftyp → moov → mdat (a plain mp4, moov first, plays while downloading)
Media info ffprobe H264 1920×1080 at about 30 fps + Opus audio, 30.05 s
Full decode ffmpeg -i rec.mp4 -f null - No errors
Browser playback Open the URL in a browser, read the <video> state duration=30.05, 1920×1080, paused=false, error=null, plays normally
Cross-app access Replace the appId in the URL HTTP 404 recording not found

7. How to clean up expired recordings

7.1 Mechanism

mmx has a built-in recording cleaner. It removes recordings older than the retention period by file modification time (≈ the end of the session):

  • Retention is paths.all.recordDeleteAfter in conf/standalone.yml; the template default is 240h (10 days);
  • The cleaner runs once at node start, then every min(30 minutes, retention / 2) (every 30 minutes by default);
  • After a file is deleted, the empty directories it leaves are removed layer by layer (the lowest at once, higher ones in later cycles); the old playback URL returns 404;
  • It covers raw recording segments as well as finished session files; finished session files are only cleaned on nodes with recordLocalPlayback: true (the default in the standalone / self-hosted record templates).

7.2 Change the retention

paths:
  all:
    recordDeleteAfter: 240h   # set what you need, e.g. 72h (3 days), 720h (30 days)

Edit conf/standalone.yml (or copy it to mmx.yml per the README "custom config" section and edit that), then docker compose up -d --build ppmmx. Plan disk as "recorded GB per day × retention days"; the forced reclaim triggered by recordMinFreeSpace (8G in the template) only deletes raw segments, never finished session files.

7.3 Manual cleanup

If you do not want to wait for the cycle, or want to delete some files earlier:

docker exec ppmmx find /app/data/recordings -name '*.mp4' -mtime +10 -delete    # delete files older than 10 days

7.4 Measured verification

To avoid waiting 10 days, the modification time of the test recording from §5 was set to 11 days ago, and the next cleaner pass was awaited:

docker exec ppmmx touch -d "11 days ago" /app/data/recordings/<date>/app_xxx/B01-frontView/B01-frontView-gc….mp4

Measured result: the file was deleted at 16:26:41, right after the cleaner's cycle point (node time 16:26:40):

  • find /app/data/recordings -name '*.mp4' no longer returns anything;
  • the old playback URL returns HTTP 404 with the body recording file is no longer available;
  • the lowest empty directory (B01-frontView) was removed at once; the empty date and appId directories above it are cleaned layer by layer in later cycles (they were still there when observed, taking no space).

8. Result summary

# Function Expected Measured Result
1 One-line deploy and registration Node registers with ppcenter, shows Running MMX control connected, within 5 s Pass
2 Ingest points at your own node /v1/publish/requests returns the self-hosted node's SRT address Returned <node-public-ip>:7890, originNodeId=11 Pass
3 Publishing is online h264 / hevc paths on the node, with simulcast and audio Both ready, three video layers + Opus each Pass
4 Direct browser playback The viewing page shows video Live video after about 4 s Pass
5 WHEP URL Standard WHEP connects OPTIONS 204, WebRTC connected Pass
6 Playback URL via ppcenter Scheduled to your own node edge.nodeId equals the publishing node Pass
7 Recording API on your own node round-start / round-end both 200 Both 200 Pass
8 round-end returns the playback URL data.playbackUrls with URL, size, duration Matches the actual file Pass
9 URL works without credentials, supports Range, plays in a browser 200 / 206 / plays 200 / 206 / plays, decodes without errors Pass
10 Playback URL isolation Another appId gets 404 404 Pass
11 Expired-recording cleanup Files past retention are deleted, URL returns 404 After setting the test recording's mtime to 11 days ago it was deleted at the next cleaner cycle (16:26:40); the old URL returns 404 Pass

9. Findings and limitations

  1. Recording captures only the top layer of the H264 path: the test stream has three simulcast layers (1080p / 720p / 360p); recording takes the first video track of the …/h264 path (1080p) plus audio. The log shows skipping track 2 / 3 (H264) warnings, which are expected. The …/hevc path is not recorded.
  2. Playback URLs are unauthenticated: a deliberate trade-off (the file is on your own node and the record id is unguessable). If you need access control, proxy through your backend or put access control in front of the node yourself.
  3. Playback URLs share the API port: 8888 (or the https hostname) is the publish/play entry and also the entry of the recording API and playback URLs; the firewall must allow both your backend and viewers.
  4. Cleanup runs periodically: every 30 minutes by default, so an expired file may stay up to one cycle longer; shorten recordDeleteAfter for tighter timing (the cycle is half of it, capped at 30 minutes).
  5. Disk planning: finished session files occupy disk for the whole retention period; size the disk as "recorded GB per day × retention days".
  6. One warn at startup: in the first second after a node restart [publish-whitelist] app sync fetch failed appears once; the connection to ppcenter is then established and syncing works, and signed recording API calls succeed afterwards. It does not affect use.
  7. Not covered: advance auth mode, the appEnv prefix, uploading with S3_* configured at the same time, simultaneous multi-view recording, hour-long recordings and concurrent sessions; this run verified a single view and a 30-second session only.

Appendix A: testing a pre-release build

For "the node was deployed per §2.2 and you want to verify a new build before it is released". The ppmmxDocker image build downloads the binary from MMX_BASE_URL (the GitHub Release by default), so for a test point it at your own temporary file server; nothing in the repository has to change:

# Cross-compile on your dev machine, write the checksum file, and serve a temporary directory
cd ppmmx && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /tmp/pre/mmx-linux-amd64 .
cd /tmp/pre && sha256sum mmx-linux-amd64 > mmx-linux-amd64.sha256 && python3 -m http.server 8000 &

# On the node: back up the current image, rebuild from the temporary address and restart only ppmmx (Caddy untouched)
ssh root@<node> 'cd ~/ppmmxDocker && docker tag ppmmx:latest ppmmx:backup-$(date +%Y%m%d%H%M%S) \
  && docker compose build --build-arg MMX_BASE_URL=http://<dev-machine>:8000 ppmmx && docker compose up -d ppmmx'

The restart drops publishing for a few seconds and the publisher reconnects on its own. Rollback: tag ppmmx:backup-<timestamp> back to ppmmx:latest, then docker compose up -d ppmmx. A maintainer does the real release by running ./build.sh --release in ppmmxDocker (see the "Maintainers" section of its README).