EP18: Recording & playback — adding VOD to live streaming¶
| Recap | EP17 "Deploy from scratch: run your own CDN in 15 minutes" |
|---|---|
| Next | EP19 "Load-testing methodology: from a single box to global load" |
0. Goals of this episode¶
By the end, the viewer should be able to explain three things:
- Where the requirement comes from: why scenarios like live-dealer games, auctions, and online classes need "per-round" recording, not recording the entire live stream from start to finish.
- How the recording API works: what the two-phase protocol (start/end) looks like, and an easily-missed constraint — a stream can only be held by one recording session at a time; two business parties can't both record the same stream concurrently.
- Where the playback URL comes from: how a recording file goes from landing on a node's disk to a URL you can open directly, and why that URL is currently single-Region.
1. Where the requirement comes from: why "per round," not "the whole stream"¶
Live-dealer games, auctions, and online classes share something in common: the stream is continuous, but what's "worth reviewing" is always one specific round, one specific auction, one specific class session — never the entire 24/7 stream. When a dispute needs review, what gets checked is "how this particular hand was dealt," not the full recording from go-live to sign-off.
Recording the entire stream runs into two problems right away:
- Storage and upload cost scales linearly with duration: a live stream might run for hours, but only a few minutes of it ever get reviewed; storing the whole thing just to keep those few minutes means most of that storage is wasted.
- No natural clip boundary: looking for "round 5" after the fact means guessing along a timeline inside a multi-hour file, instead of just getting back one file that contains only that round.
So PPCDN's recording capability was designed around "rounds" from day one: instead of "start recording the moment the stream goes live and never stop," the business backend explicitly tells the recording system, at the exact moment each round starts and ends, "start counting from here" / "end here, and save this segment."
flowchart LR
subgraph A["Full-stream recording"]
A1["Go live"] --> A2["Keep recording 24/7"] --> A3["Go off air"]
A3 -.->|"Need round 5 later"| A4["Manually scrub a timeline<br/>inside a multi-hour file"]
end
subgraph B["Per-round recording (PPCDN)"]
B1["Business backend says start"] --> B2["Record only this round"] --> B3["Business backend says end"]
B3 --> B4["Get back a standalone<br/>file for this round"]
end
Why must the business backend be the one to call start/end, rather than letting the recording system decide on its own? Because the boundary of "a round" is business semantics — only the business system itself knows when a game round starts or when an auction's hammer falls; the media server can't infer that from picture and audio alone. So the protocol hands this responsibility to the caller to express explicitly, and the recording system is only responsible for "cutting this segment out accurately and saving it, between the two calls."
2. The recording API: a two-phase protocol, one stream held by only one recording session¶
2.1 Two calls, one endpoint¶
POST https://api.pp-cdn.org/api/record/split
Whether the gameRound field is present decides whether this call is a "start" or an "end":
| Phase | gameRound |
Effect |
|---|---|---|
| Round start (round-start) | omitted | Marks this stream as "being recorded by me (this caller)," and starts accumulating recording segments |
| Round end (round-end) | a concrete value | Verifies the caller is the one who started this round, renames the segments into the final file, and triggers an asynchronous upload |
# round start
curl -X POST https://api.pp-cdn.org/api/record/split \
-H "Authorization: $AUTH" -H "Content-Type: application/json" \
-d '{"time":"1757280000","appId":"app_xxx","tableId":"table1","gameId":"p2w001"}'
# ...round in progress...
# round end
curl -X POST https://api.pp-cdn.org/api/record/split \
-H "Authorization: $AUTH" -H "Content-Type: application/json" \
-d '{"time":"1757280600","appId":"app_xxx","tableId":"table1","gameRound":"gc001","gameId":"p2w001"}'
Authentication is a signature over the caller's own appId-scoped appSecret
(md5(appSecret + time + appId + tableId + [gameRound] + gameId)); appId itself is also part of
the signature to prevent tampering — the same idea as EP06's "binding the signature to a specific
identity and resource."
2.2 The key constraint: one stream, only one recording session at a time¶
This is a rule you need to understand before integrating: what recording locks on is "this
stream" itself (uniquely identified by appId + tableId) — only one caller can be its recording
owner at any given moment. Whoever calls round-start first gets the recording rights to this
stream's current round; until that caller calls round-end to release it, any other caller trying to
call round-start on the same stream gets rejected outright.
sequenceDiagram
participant BE1 as Business backend A
participant BE2 as Business backend B (same stream)
participant R as Recording API
BE1->>R: round-start (table1)
R-->>BE1: 200 OK, table1's recording owner = A
BE2->>R: round-start (same table1)
R-->>BE2: 500, table1 is already held by another round
BE1->>R: round-end (table1, gameRound=gc001)
R-->>BE1: 200 OK, segment cut + renamed + upload triggered, owner released
BE2->>R: round-start (table1)
R-->>BE2: 200 OK, table1's recording owner = B
A rejection comes back as 500 (not 409), with a specific text message in msg, e.g.
game "p2w002" already has an active recording on table "table1" — integrate by checking this text
for the conflict, not the status code alone, because a genuine server error also returns 500.
Conversely, different streams are completely unaffected by each other: different tableIds
under the same appId, or simply different appIds altogether, each have their own independent
owner and recording state, and can all proceed at the same time — "one recording session per
stream" is about mutual exclusion within a single stream, not a statement that the whole system
can only serve one user at a time. Concurrent recording across many streams is the normal case; the
only thing this rule constrains is the one scenario of "two parties racing for the same stream at
once."
A few practical details that are easy to trip over:
- Calling round-end on a round that was never started: returns 200, succeeds silently, no error — the end signal itself is unconditional.
- No one is currently publishing to this stream: returns
code=50001, distinguished from a genuine server error, so you don't have to guess from the text. - Don't call end immediately after start: if the interval between the two calls is shorter than one segment's duration, no new data has been written yet, and end will fail with "nothing to finalize" — a normal round's duration is far beyond this scale; only integration/test scripts tend to hit it.
3. From live stream to playback URL: uploaded to S3, and why it's single-Region¶
3.1 After round-end, the recording node uploads the file itself¶
Once round-end finishes cutting the final file, it's uploaded to object storage asynchronously
and on a best-effort basis — this step doesn't go through the control plane at all; the recording
node PUTs the file to object storage directly with the S3 SDK itself. An upload failure doesn't
affect the 200 that round-end already returned.
sequenceDiagram
participant R as Recording node
participant S3 as Object storage (OVH net-storage, region=sgp)
participant PC as ppcenter
R->>R: round-end: cut + rename + faststart remux
R->>S3: upload directly with the S3 SDK (auto-retry on failure)
S3-->>R: upload succeeded
R->>R: assemble the playback URL from its own domain + bucket name
R->>PC: report objectKey + playback URL
3.2 What the playback URL looks like, and why it's "single-Region"¶
Production currently uses OVH net-storage, bucket ppcdn-net-storage, region sgp
(Singapore), with the access endpoint https://s3.sgp.io.cloud.ovh.net. Once the recording node
finishes uploading, it assembles the playback URL directly from this domain + bucket name + file
name, and reports it to ppcenter as-is — it isn't a short-lived credential issued on the spot,
just a plain public URL. A real address recorded in production looks like this:
https://ppcdn-net-storage.s3.sgp.io.cloud.ovh.net/test/B01-frontView-gc1791970055.mp4
The region (sgp) is baked directly into the domain — that's literally what "single-Region" means:
this bucket exists only in this one Singapore region, with no cross-region replication and no CDN
layered in front of it for acceleration from multiple locations. No matter where the viewer making
the playback request is located, this link always goes straight to this one storage bucket in
Singapore — the request path doesn't change at all based on where the viewer is.
flowchart TD
V1["Viewer (Asia-Pacific)"] --> S["The one storage bucket<br/>OVH net-storage · region=sgp"]
V2["Viewer (Europe)"] --> S
V3["Viewer (Americas)"] --> S
S -.->|"No nearby node<br/>No multi-region replication"| Note["Playback requests from<br/>every region hit the same Region"]
This is a different story from how the live stream itself is distributed: live viewers pulling the stream get scheduled to a nearby Edge node (the node-pool scheduling covered in earlier episodes), but recording playback currently has no such layer — it's just a direct link to a fixed-Region object store, and the farther you are from that region, the more your access latency depends on that region's outbound network quality, with no nearby-acceleration fallback to fall back on. This is worth knowing before you integrate: if your business's viewers are spread out geographically, the playback experience will differ by region — that's a real limitation of the current architecture, not a temporary outage.
4. One diagram tying the whole chain together¶
sequenceDiagram
participant BE as Business backend
participant R as Recording node
participant S3 as Object storage (single Region: sgp)
BE->>R: ① round-start (only succeeds if no one else is recording this stream)
R->>R: keep cutting recording segments for this stream
BE->>R: ② round-end (must be the same party that called start)
R->>R: cut + rename + faststart remux
R->>S3: ③ upload directly (retry on failure, doesn't affect round-end's 200)
S3-->>R: upload succeeded
R-->>BE: ④ this record now shows up in the recordings list, with its playback URL
Note over S3: the playback URL's domain embeds the region (sgp)<br/>viewers worldwide all hit this one Region
5. Wrap-up and next episode (script notes)¶
This episode covered "adding VOD to live streaming": recording is done per round because what gets reviewed after the fact is always one specific round, never the whole live stream — so the decision of when to "start" and "end" is handed to the only party that actually knows the business boundary: the business backend itself. Behind the two-phase protocol is a constraint that's easy to miss: a stream can only be held by one recording session at a time. That's "mutual exclusion within a single stream," not "the whole system serves only one user" — different streams can record concurrently without any problem. Once recording finishes, the file is uploaded directly to object storage by the recording node itself, and the playback URL you get back is a public link baked into one fixed Region, with no cross-region replication and no nearby acceleration — the more spread out your business's viewers are, the more this limitation is worth planning around ahead of time.
Next episode, we come back to a step that's often skipped but absolutely shouldn't be: load testing — methodology from single-box testing to global load testing, and why passing unit tests doesn't mean the system can hold up under real traffic.