Encrypt your video
A script that goes from a video file to protected content ready to upload. Node or Python, whichever you prefer.
What the script does #
- Asks Airi for one key per quality — 1080p, 720p and audio.
- Transcodes your video into those qualities with ffmpeg.
- Encrypts and packages into DASH and HLS with shaka-packager, in
cbcs. - Tells you what to upload and how to point a player at it.
Three keys and not one, on purpose
Running it twice is safe and free: asking for a key that already exists returns the same one at no charge. If a transcode fails halfway, run it again — nothing is duplicated.
Encrypted from the first frame
--clear_lead 0 to the packager. By default shaka-packager leaves the opening seconds unencrypted so playback can start before the licence arrives — which also means anyone can watch those seconds, licence or not. If you package on your own, do not forget it.What you need installed #
Two tools. ffmpeg builds the quality ladder; shaka-packager encrypts and writes the manifests.
brew install ffmpeg shaka-packager
sudo apt install ffmpeg curl -L -o /usr/local/bin/packager \ https://github.com/shaka-project/shaka-packager/releases/latest/download/packager-linux-x64 chmod +x /usr/local/bin/packager
scoop install ffmpeg # packager: download packager-win-x64.exe from the releases page, # rename it to packager.exe and put it on PATH
ffmpeg alone is not enough
cenc and without the protection headers each DRM system needs. cenc locks out every Apple device, and without those headers a player cannot tell which license server to ask. Hence both tools.Running it #
The token comes from Developers in your dashboard. It is shown once.
node protect.mjs \ --input movie.mp4 \ --content-id movie-1234 \ --token kms_live_...
python protect.py \ --input movie.mp4 \ --content-id movie-1234 \ --token kms_live_...
Use disposable identifiers while testing
contentId is permanent: once a key exists for it, the provider will never issue another one. Test with something like test-2026-07-31-01, not your real catalogue name.What it produces
protected/ ├── HD.mp4 1080p, encrypted ├── SD.mp4 720p, encrypted ├── AUDIO.mp4 encrypted ├── manifest.mpd DASH — Widevine, PlayReady └── master.m3u8 HLS — FairPlay, and the others on Apple
One key per quality #
By default the whole title uses one key. With --per-quality each quality gets its own, which is what lets you demand hardware DRM for 1080p while 720p keeps playing on a laptop — the rule the large services use to reserve their high qualities.
node protect.mjs \ --input movie.mp4 \ --content-id movie-1234 \ --token kms_live_... \ --per-quality
Then set each quality’s rule under Playback rules, picking the track in the selector. What you demand for 1080p does not affect 720p or the audio.
What changes underneath
Each quality becomes an independent key, and the player asks for one licence per key instead of one in total. Two consequences are worth knowing before this reaches production: you are billed one licence per quality on every playback, and a viewer refused the 1080p key must keep watching in 720p rather than lose the video.
Your player has to survive a refused licence
drm.failureCallback with error.handled = true; without setting it, the callback runs and playback dies anyway.Widevine and PlayReady refuse differently
This is the part that surprises people. Widevine issues the licence and lets the device mark the key unusable, so the player drops a quality and carries on. PlayReady refuses at the server instead: demand a level the client cannot reach and it answers an error rather than a key, and to a player that is not "not this quality" but "not this playback".
The same rule reads identically in the dashboard and behaves the opposite way on each system. If your audience includes Windows or Xbox, demand hardware on Widevine and leave PlayReady at SL2000 — or check first, on a device that does NOT meet the rule, that your player survives the refused licence.
The Node script #
No dependencies: Node 18 or newer. Save it as protect.mjs — or download it.
#!/usr/bin/env node /** * Protect a video with Airi: keys → transcode → encrypt → ready to upload. * * node protect.mjs --input movie.mp4 --content-id movie-1234 --token kms_live_... * * Requires ffmpeg and shaka-packager on PATH. * * Why two tools: ffmpeg transcodes — it makes the quality ladder — and * shaka-packager encrypts and writes the manifests. ffmpeg can encrypt, but * only in `cenc`, and without the per-system protection headers that Widevine, * PlayReady and FairPlay each need. Packaging in `cenc` would also lock out * every Apple device, which only speaks `cbcs`. */ import { spawn } from 'node:child_process'; import { mkdir, rm, readFile, writeFile, unlink } from 'node:fs/promises'; import { join } from 'node:path'; const API = process.env.AIRI_API || 'https://kms.airi.live'; /** * The quality ladder. Every rung is encoded; whether each gets its own key * depends on --per-quality. */ const LADDER = [ { label: 'HD', height: 1080, bitrate: '5000k', kind: 'video' }, { label: 'SD', height: 720, bitrate: '2500k', kind: 'video' }, { label: 'AUDIO', kind: 'audio', bitrate: '128k' } ]; const DRM_SYSTEMS = ['widevine', 'playready', 'fairplay']; // ---------------------------------------------------------------- arguments function arg(name, fallback = undefined) { const i = process.argv.indexOf(`--${name}`); return i >= 0 ? process.argv[i + 1] : fallback; } const input = arg('input'); const contentId = arg('content-id'); const token = arg('token', process.env.AIRI_TOKEN); const outDir = arg('out', './protected'); const perQuality = process.argv.includes('--per-quality'); /** * cbcs reaches Apple; cenc reaches PlayReady in a browser. Many Edge clients * report PlayReady as hardware-backed and still cannot read cbcs — the licence * is issued and the CDM cannot decrypt. Needing both means packaging twice, * with the same keys. */ const scheme = arg('scheme', 'cbcs'); /** * Argument validation, deliberately not at module scope: the tests import * this file for its manifest merging, and a bare process.exit() at import * time would kill the test run instead of reporting anything. */ function validateArgs() { if (scheme !== 'cbcs' && scheme !== 'cenc') { console.error(`--scheme must be cbcs or cenc, got "${scheme}"`); process.exit(1); } if (!input || !contentId || !token) { console.error(` Usage: node protect.mjs --input <file> --content-id <id> --token <kms_live_...> --input Source video --content-id Your identifier for this title. PERMANENT: once a key exists for it, the provider will never issue another one. --token API credential, from the Developers page (or AIRI_TOKEN) --out Output directory (default ./protected) --per-quality One key per quality instead of one for the whole title. Lets you demand hardware DRM for 1080p while 720p stays playable on a laptop. Costs one licence request per quality. --scheme cbcs (default) or cenc. cbcs reaches Apple; cenc reaches PlayReady in a browser. Need both? Run it twice, same content-id, different --out — the keys are reused. `); process.exit(1); } if (!/^[A-Za-z0-9._-]+$/.test(contentId)) { console.error('The contentId may only contain letters, digits, dot, dash and underscore.'); process.exit(1); } } // ---------------------------------------------------------------- helpers /** Run a command, streaming its output so a long transcode shows progress. */ function run(command, args) { return new Promise((resolve, reject) => { const child = spawn(command, args, { stdio: 'inherit', shell: false }); child.on('error', err => reject( err.code === 'ENOENT' ? new Error(`${command} is not installed, or not on PATH.`) : err )); child.on('close', code => code === 0 ? resolve() : reject(new Error(`${command} exited with code ${code}`))); }); } /** * The API returns `key` in base64, because that is how the CPIX standard * defines it. Packagers want hexadecimal. Getting this wrong encrypts the video * with a key that is not yours, and nobody can ever play it — so it is worth * the two lines. */ const base64ToHex = (b64) => Buffer.from(b64, 'base64').toString('hex'); /** The keyId arrives as a dashed UUID; packagers want the bare hex. */ const uuidToHex = (uuid) => uuid.replace(/-/g, '').toLowerCase(); async function requestKey(track) { const res = await fetch(`${API}/v1/keys`, { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify({ contentId, drmSystems: DRM_SYSTEMS, ...(track ? { track } : {}) }) }); const body = await res.json(); if (!res.ok || body.success === false) { const error = body.error ?? {}; if (error.code === 'INSUFFICIENT_FUNDS') { throw new Error('Not enough balance. Top up at https://airi.live/app/billing'); } throw new Error(`${error.code ?? res.status}: ${error.message ?? 'request failed'}`); } // `created: false` means this key already existed — repeating a request is // safe and free, which is what makes the pipeline retryable. console.log(` ${(track || 'todo').padEnd(6)} ${body.keyId} ${body.created ? '(new)' : '(existing)'}`); return body; } /** * The provider's own protection headers, concatenated as hex for `--pssh`. * * Easy to skip and it fails late. shaka-packager will happily generate its own * PSSH from the key id, and the result looks correct, packages cleanly and * plays nowhere: the header the licence server expects is the one it issued, * carrying the identifiers it recognises. A generated one is answered with * "ContentID parameter is required" or "Keys not found", neither of which * points at the packaging command that caused it. */ function psshHex(drmSystemsData) { return (drmSystemsData ?? []) .filter(d => d.pssh) .map(d => Buffer.from(d.pssh, 'base64').toString('hex')) .join(''); } // ------------------------------------------------------- per-quality merging /** * Why per-quality keys need one packager run per quality. * * Each quality has its own key and therefore its own protection header. But * `--pssh` is a single global argument, and the packager keeps only the first * header per DRM system: pass all three and every quality ends up advertising * the first one. The player then asks for one licence, gets one key, and the * other two qualities have nothing to decrypt with. * * That failure is quiet — the manifest looks right and the video simply never * starts — which is what makes it worth the extra runs. Each quality is * packaged on its own so it carries its own header, and the outputs are then * spliced into one manifest, which is what these two functions do. */ /** One DASH manifest out of several single-track ones. */ async function mergeMpd(parts, output) { const docs = await Promise.all(parts.map(p => readFile(p, 'utf8'))); const sets = []; for (const doc of docs) { for (const m of doc.matchAll(/[ \t]*<AdaptationSet[\s\S]*?<\/AdaptationSet>/g)) sets.push(m[0]); } // The longest track decides the presentation duration: a shorter value // would cut playback off at the end of whichever track packaged first. const longest = docs .map(d => (d.match(/mediaPresentationDuration="([^"]+)"/) || [])[1]) .filter(Boolean) .sort((a, b) => durationSeconds(b) - durationSeconds(a))[0]; const head = docs[0].slice(0, docs[0].indexOf('<AdaptationSet')); const tail = docs[0].slice(docs[0].lastIndexOf('</AdaptationSet>') + '</AdaptationSet>'.length); // ids have to be unique across the merged document; each part numbered its // own from zero. const body = sets.map((s, i) => s.replace(/<AdaptationSet id="\d+"/, `<AdaptationSet id="${i}"`)).join('\n'); await writeFile(output, head.replace(/mediaPresentationDuration="[^"]+"/, `mediaPresentationDuration="${longest}"`) + body.trimStart() + tail); } function durationSeconds(iso) { const m = /PT(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?/.exec(iso || ''); return m ? (+m[1] || 0) * 3600 + (+m[2] || 0) * 60 + (+m[3] || 0) : 0; } /** * One HLS master out of several single-track ones. * * Audio becomes a rendition rather than a variant of its own, and every video * variant is pointed at it — otherwise a player picks the audio-only variant * the audio run emitted and plays a black screen with sound. */ async function mergeHls(parts, output) { const AUDIO_GROUP = 'audio'; const renditions = []; const variants = []; for (const { label, path, kind } of parts) { const lines = (await readFile(path, 'utf8')).split(/\r?\n/); for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (kind === 'audio' && line.startsWith('#EXT-X-MEDIA:')) { renditions.push(line.replace(/GROUP-ID="[^"]*"/, `GROUP-ID="${AUDIO_GROUP}"`)); } // The audio run also emits a variant for audio-only playback. Kept, // it competes with the video variants; dropped, audio is available // only as a rendition, which is what a video title wants. if (kind === 'video' && line.startsWith('#EXT-X-STREAM-INF:')) { const uri = lines[++i]; variants.push(`${line},AUDIO="${AUDIO_GROUP}"`, uri); } } void label; } await writeFile(output, [ '#EXTM3U', '## Merged by protect.mjs — one key per quality, one licence per quality', '', '#EXT-X-INDEPENDENT-SEGMENTS', '', ...renditions, '', ...variants, '' ].join('\n')); } // ---------------------------------------------------------------- pipeline async function main() { validateArgs(); console.log(`\nProtecting "${contentId}" in ${scheme} with ${DRM_SYSTEMS.join(', ')}\n`); // ---- 1. keys console.log(`1/4 Requesting keys (${perQuality ? 'one per quality' : 'one for the whole title'})`); // Default: one key for everything. Each key carries its own protection // header, and shaka-packager applies `--pssh` to every stream at once — // so per-quality keys mean the manifest ends up advertising one content // when the segments were encrypted for three. See the note printed at the // end of a --per-quality run. const keys = {}; if (perQuality) { for (const rung of LADDER) keys[rung.label] = await requestKey(rung.label); } else { const single = await requestKey(undefined); for (const rung of LADDER) keys[rung.label] = single; } const primary = keys[LADDER[0].label]; // FairPlay signals through the HLS manifest rather than a pssh box, so the // packager needs the key URI our API returns instead. const fairplay = primary.drmSystemsData?.find(d => d.systemName === 'FairPlay'); const hlsKeyUri = fairplay?.uriExtXKey ? `skd://${fairplay.uriExtXKey}` : null; // ---- 2. transcode console.log('\n2/4 Transcoding with ffmpeg'); const work = join(outDir, '.work'); await rm(outDir, { recursive: true, force: true }); await mkdir(work, { recursive: true }); for (const rung of LADDER) { const target = join(work, `${rung.label}.mp4`); console.log(` ${rung.label}`); const args = rung.kind === 'video' ? [ '-y', '-i', input, '-an', '-vf', `scale=-2:${rung.height}`, '-c:v', 'libx264', '-profile:v', 'main', '-preset', 'medium', '-b:v', rung.bitrate, // A fixed keyframe interval is what lets a player switch // quality mid-playback; without it the ladder is decorative. '-g', '48', '-keyint_min', '48', '-sc_threshold', '0', target ] : [ '-y', '-i', input, '-vn', '-c:a', 'aac', '-b:a', rung.bitrate, '-ac', '2', target ]; await run('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-stats', ...args]); } // ---- 3. encrypt and package console.log(`\n3/4 Encrypting with shaka-packager (${scheme})`); /** * Shared by both paths. Encrypting from the first frame is not a detail: * the default leaves the opening seconds in the clear so playback can start * before the licence arrives, which also means those seconds are watchable * by anyone, licence or not. */ const common = ['--enable_raw_key_encryption', '--protection_scheme', scheme, '--clear_lead', '0']; /** * FairPlay is signalled in the HLS playlist rather than by a pssh box, and * `--hls_key_uri` alone does not emit it: the packager writes the * `com.apple.streamingkeydelivery` line only when FairPlay is named as a * protection system. Naming it does not disturb the provider's Widevine and * PlayReady headers — verified against the packaged output. * * Only for cbcs, because FairPlay cannot read cenc at all. */ const fairplaySignalling = scheme === 'cbcs' ? ['--protection_systems', 'FairPlay'] : []; if (!perQuality) { const streams = LADDER.map(rung => `in=${join(work, `${rung.label}.mp4`)},stream=${rung.kind},` + `output=${join(outDir, `${rung.label}.mp4`)},drm_label=${rung.label}` ); const keySpec = LADDER.map(rung => { const k = keys[rung.label]; return `label=${rung.label}:key_id=${uuidToHex(k.keyId)}:key=${base64ToHex(k.key)}`; }).join(','); await run('packager', [ ...streams, '--keys', keySpec, // The provider's protection headers, not generated ones — they // carry the content identifier the licence server needs. '--pssh', psshHex(primary.drmSystemsData), ...common, ...fairplaySignalling, ...(hlsKeyUri ? ['--hls_key_uri', hlsKeyUri] : []), '--mpd_output', join(outDir, 'manifest.mpd'), '--hls_master_playlist_output', join(outDir, 'master.m3u8') ]); } else { // One run per quality, so each carries its own protection header. See // the comment above mergeMpd(). const parts = []; for (const rung of LADDER) { const k = keys[rung.label]; const fp = k.drmSystemsData?.find(d => d.systemName === 'FairPlay'); console.log(` ${rung.label}`); await run('packager', [ `in=${join(work, `${rung.label}.mp4`)},stream=${rung.kind},` + `output=${join(outDir, `${rung.label}.mp4`)},` + `drm_label=${rung.label},playlist_name=${rung.label}.m3u8`, '--keys', `label=${rung.label}:key_id=${uuidToHex(k.keyId)}:key=${base64ToHex(k.key)}`, '--pssh', psshHex(k.drmSystemsData), ...common, // Per quality too: the skd URI names the content, and each // quality is its own content at the provider. ...fairplaySignalling, ...(fp?.uriExtXKey ? ['--hls_key_uri', `skd://${fp.uriExtXKey}`] : []), '--mpd_output', join(outDir, `${rung.label}.mpd`), '--hls_master_playlist_output', join(outDir, `${rung.label}-master.m3u8`) ]); parts.push({ label: rung.label, kind: rung.kind, mpd: join(outDir, `${rung.label}.mpd`), master: join(outDir, `${rung.label}-master.m3u8`) }); } console.log(' merging manifests'); await mergeMpd(parts.map(p => p.mpd), join(outDir, 'manifest.mpd')); await mergeHls(parts.map(p => ({ label: p.label, kind: p.kind, path: p.master })), join(outDir, 'master.m3u8')); // The per-quality manifests were scaffolding. Left behind, they are // three more things that look loadable and are not. for (const p of parts) { await unlink(p.mpd).catch(() => {}); await unlink(p.master).catch(() => {}); } } await rm(work, { recursive: true, force: true }); // ---- 4. what to do next console.log(`\n4/4 Done — everything is in ${outDir}\n`); console.log('Upload it somewhere public (the media is encrypted; the keys are not in it):\n'); console.log(` aws s3 sync ${outDir} s3://YOUR-BUCKET/${contentId}/ --acl public-read\n`); console.log('Then point your player at:\n'); console.log(` DASH https://YOUR-BUCKET.s3.amazonaws.com/${contentId}/manifest.mpd`); console.log(` HLS https://YOUR-BUCKET.s3.amazonaws.com/${contentId}/master.m3u8`); /** * Printed from the response rather than written here. * * The endpoint depends on which key store issued the key, and the manifest * gives no hint which that was — hardcoding one is how a title ends up * asking the wrong server and getting "Keys not found". */ const urls = primary.licenseUrls ?? {}; console.log(` Licenses Widevine ${urls.widevine} PlayReady ${urls.playready} FairPlay ${urls.fairplay} Player setup: https://airi.live/docs/reproductores `); if (perQuality) { console.log(` NOTE — one key per quality. Each quality gets its own key inside this one title, which is what lets you demand hardware DRM for 1080p while 720p and the audio stay playable on a laptop. Two consequences worth knowing before you ship it: · A viewer whose device is refused the HD key still plays SD. Check that your player falls back rather than stopping. · One licence request per quality, so three charges per playback, not one. `); } } // Imported by the tests, which exercise the manifest merging without running // ffmpeg or spending a key. Running the file directly still just works. export { mergeMpd, mergeHls, durationSeconds }; if (import.meta.filename === process.argv[1]) { main().catch(err => { console.error(`\n${err.message}\n`); process.exit(1); }); }
The Python script #
Standard library only, no pip install. Save it as protect.py — or download it.
#!/usr/bin/env python3 """ Protect a video with Airi: keys -> transcode -> encrypt -> ready to upload. python protect.py --input movie.mp4 --content-id movie-1234 --token kms_live_... Requires ffmpeg and shaka-packager on PATH. Standard library only — no pip. Why two tools: ffmpeg transcodes (it builds the quality ladder) and shaka-packager encrypts and writes the manifests. ffmpeg can encrypt, but only in `cenc`, and without the per-system protection headers Widevine, PlayReady and FairPlay each need — headers the licence server relies on. Scheme: cbcs reaches Apple, cenc reaches PlayReady in a browser. Many Edge clients report PlayReady as hardware-backed and still cannot read cbcs. Needing both means running this twice with the same --content-id; the keys are reused. """ import argparse import base64 import json import os import re import shutil import subprocess import sys import urllib.error import urllib.request API = os.environ.get("AIRI_API", "https://kms.airi.live") # The quality ladder. Every rung is encoded; whether each gets its own key # depends on --per-quality. LADDER = [ {"label": "HD", "height": 1080, "bitrate": "5000k", "kind": "video"}, {"label": "SD", "height": 720, "bitrate": "2500k", "kind": "video"}, {"label": "AUDIO", "bitrate": "128k", "kind": "audio"}, ] DRM_SYSTEMS = ["widevine", "playready", "fairplay"] class Failed(Exception): """An error worth showing the user as-is, without a traceback.""" def run(command, args): """Run a command, streaming output so a long transcode shows progress.""" try: result = subprocess.run([command, *args], check=False) except FileNotFoundError: raise Failed(f"{command} is not installed, or not on PATH.") if result.returncode != 0: raise Failed(f"{command} exited with code {result.returncode}") def base64_to_hex(value: str) -> str: """ The API returns `key` in base64, because that is how CPIX defines it. Packagers want hexadecimal. Getting this wrong encrypts the video with a key that is not yours, and nobody will ever be able to play it. """ return base64.b64decode(value).hex() def uuid_to_hex(value: str) -> str: """The keyId arrives as a dashed UUID; packagers want the bare hex.""" return value.replace("-", "").lower() def pssh_hex(drm_systems_data: list) -> str: """ The provider's own protection headers, concatenated as hex for --pssh. Easy to skip and it fails late. shaka-packager will happily generate its own PSSH from the key id, and the result looks correct, packages cleanly and plays nowhere: the header the licence server expects is the one it issued, carrying the identifiers it recognises. A generated one is answered with "ContentID parameter is required" or "Keys not found", neither of which points at the packaging command that caused it. """ return "".join( base64.b64decode(d["pssh"]).hex() for d in (drm_systems_data or []) if d.get("pssh") ) def request_key(content_id: str, token: str, track: str | None) -> dict: body = {"contentId": content_id, "drmSystems": DRM_SYSTEMS} if track: body["track"] = track payload = json.dumps(body).encode() request = urllib.request.Request( f"{API}/v1/keys", data=payload, headers={ "Authorization": f"Bearer {token}", "Content-Type": "application/json", # urllib's default agent is "Python-urllib/3.x", which edge # protection in front of an API is entitled to treat as a bot. "User-Agent": "airi-protect/1.0", }, method="POST", ) try: with urllib.request.urlopen(request) as response: raw = response.read() except urllib.error.HTTPError as err: # An error body is not guaranteed to be JSON, or to exist at all — a # gateway can answer before the API does. Assuming otherwise turns a # readable failure into a stack trace about parsing. raw = err.read() or b"" try: error = json.loads(raw).get("error", {}) except (ValueError, AttributeError): snippet = raw.decode("utf-8", "replace").strip()[:120] raise Failed(f"HTTP {err.code} from {API}: {snippet or err.reason}") if error.get("code") == "INSUFFICIENT_FUNDS": raise Failed("Not enough balance. Top up at https://airi.live/app/billing") raise Failed(f"{error.get('code', err.code)}: {error.get('message', 'request failed')}") except urllib.error.URLError as err: raise Failed(f"Could not reach {API}: {err.reason}") try: body = json.loads(raw) except ValueError: raise Failed(f"{API} returned something that is not JSON.") # `created: false` means the key already existed — repeating a request is # safe and free, which is what makes this pipeline retryable. state = "(new)" if body.get("created") else "(existing)" print(f" {(track or 'todo'):<6} {body['keyId']} {state}") return body # --------------------------------------------------- per-quality merging # # Why per-quality keys need one packager run per quality. # # Each quality has its own key and therefore its own protection header. # # --pssh is global, and the packager keeps only the first header per DRM system: # pass all three and every quality ends up advertising the first one. The player # then asks for one licence, gets one key, and the other two qualities have # nothing to decrypt with. That failure is quiet — the manifest looks right and # the video simply never starts. # # Running the packager once per quality gives each its own header. The outputs # are then spliced into one manifest, which is what these two functions do. def duration_seconds(iso: str | None) -> float: m = re.match(r"PT(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?", iso or "") if not m: return 0.0 h, mi, s = m.groups() return int(h or 0) * 3600 + int(mi or 0) * 60 + float(s or 0) def merge_mpd(paths: list, output: str) -> None: """One DASH manifest out of several single-track ones.""" docs = [open(p, encoding="utf-8").read() for p in paths] sets = [] for doc in docs: sets += re.findall(r"[ \t]*<AdaptationSet.*?</AdaptationSet>", doc, re.S) # The longest track decides the presentation duration: a shorter value would # cut playback off at the end of whichever track packaged first. durations = [m.group(1) for m in (re.search(r'mediaPresentationDuration="([^"]+)"', d) for d in docs) if m] longest = max(durations, key=duration_seconds) if durations else None first = docs[0] head = first[:first.index("<AdaptationSet")] tail = first[first.rindex("</AdaptationSet>") + len("</AdaptationSet>"):] # ids have to be unique across the merged document; each part numbered its # own from zero. body = "\n".join( re.sub(r'<AdaptationSet id="\d+"', f'<AdaptationSet id="{i}"', s, count=1) for i, s in enumerate(sets) ) if longest: head = re.sub(r'mediaPresentationDuration="[^"]+"', f'mediaPresentationDuration="{longest}"', head) with open(output, "w", encoding="utf-8") as f: f.write(head + body.lstrip() + tail) def merge_hls(parts: list, output: str) -> None: """ One HLS master out of several single-track ones. Audio becomes a rendition rather than a variant of its own, and every video variant is pointed at it — otherwise a player picks the audio-only variant the audio run emitted and plays a black screen with sound. """ group = "audio" renditions, variants = [], [] for part in parts: lines = open(part["master"], encoding="utf-8").read().splitlines() i = 0 while i < len(lines): line = lines[i] if part["kind"] == "audio" and line.startswith("#EXT-X-MEDIA:"): renditions.append(re.sub(r'GROUP-ID="[^"]*"', f'GROUP-ID="{group}"', line)) # The audio run also emits a variant for audio-only playback. Kept, # it competes with the video variants; dropped, audio is available # only as a rendition, which is what a video title wants. if part["kind"] == "video" and line.startswith("#EXT-X-STREAM-INF:"): i += 1 variants += [f'{line},AUDIO="{group}"', lines[i]] i += 1 with open(output, "w", encoding="utf-8") as f: f.write("\n".join([ "#EXTM3U", "## Merged by protect.py — one key per quality, one licence per quality", "", "#EXT-X-INDEPENDENT-SEGMENTS", "", *renditions, "", *variants, "" ])) def main() -> None: parser = argparse.ArgumentParser( description="Encrypt a video with Airi and package it for DASH and HLS." ) parser.add_argument("--input", required=True, help="Source video") parser.add_argument( "--content-id", required=True, help="Your identifier for this title. PERMANENT: once a key exists for " "it, the provider will never issue another one.", ) parser.add_argument( "--token", default=os.environ.get("AIRI_TOKEN"), help="API credential from the Developers page (or AIRI_TOKEN)", ) parser.add_argument("--out", default="./protected", help="Output directory") parser.add_argument( "--scheme", default="cbcs", choices=["cbcs", "cenc"], help="cbcs (default) reaches Apple; cenc reaches PlayReady in a browser. " "Need both? Run twice with the same --content-id and a different " "--out; the keys are reused.", ) parser.add_argument( "--per-quality", action="store_true", help="One key per quality instead of one for the whole title. Read the " "note it prints before using it in production.", ) args = parser.parse_args() if not args.token: raise Failed("Missing --token (or set AIRI_TOKEN).") if not re.fullmatch(r"[A-Za-z0-9._-]+", args.content_id): raise Failed("The contentId may only contain letters, digits, dot, dash and underscore.") print(f'\nProtecting "{args.content_id}" in {args.scheme} ' f'with {", ".join(DRM_SYSTEMS)}\n') # ---- 1. keys mode = "one per quality" if args.per_quality else "one for the whole title" print(f"1/4 Requesting keys ({mode})") # Default: one key for everything. Each key carries its own protection # header, and shaka-packager applies --pssh to every stream at once — so # per-quality keys mean the manifest ends up advertising one content while # the qualities were encrypted under different keys. if args.per_quality: keys = {r["label"]: request_key(args.content_id, args.token, r["label"]) for r in LADDER} else: single = request_key(args.content_id, args.token, None) keys = {r["label"]: single for r in LADDER} primary = keys[LADDER[0]["label"]] # FairPlay signals through the HLS manifest rather than a pssh box, so the # packager needs the key URI our API returns instead. fairplay = next( (d for d in primary.get("drmSystemsData", []) if d.get("systemName") == "FairPlay"), None, ) hls_key_uri = f"skd://{fairplay['uriExtXKey']}" if fairplay and fairplay.get("uriExtXKey") else None # ---- 2. transcode print("\n2/4 Transcoding with ffmpeg") work = os.path.join(args.out, ".work") shutil.rmtree(args.out, ignore_errors=True) os.makedirs(work, exist_ok=True) for rung in LADDER: target = os.path.join(work, f"{rung['label']}.mp4") print(f" {rung['label']}") if rung["kind"] == "video": options = [ "-an", "-vf", f"scale=-2:{rung['height']}", "-c:v", "libx264", "-profile:v", "main", "-preset", "medium", "-b:v", rung["bitrate"], # A fixed keyframe interval is what lets a player switch quality # mid-playback; without it the ladder is decorative. "-g", "48", "-keyint_min", "48", "-sc_threshold", "0", ] else: options = ["-vn", "-c:a", "aac", "-b:a", rung["bitrate"], "-ac", "2"] run("ffmpeg", ["-hide_banner", "-loglevel", "error", "-stats", "-y", "-i", args.input, *options, target]) # ---- 3. encrypt and package print(f"\n3/4 Encrypting with shaka-packager ({args.scheme})") common = [ "--enable_raw_key_encryption", # cbcs reaches Apple; cenc reaches PlayReady in a browser. Many Edge # clients report PlayReady as hardware-backed and still cannot read # cbcs — the licence is issued and the CDM cannot decrypt. "--protection_scheme", args.scheme, # Encrypt from the first frame. The default leaves the opening seconds # in the clear so playback can start before the licence arrives — which # also means those seconds are watchable by anyone, licence or not. "--clear_lead", "0", ] # FairPlay is signalled in the HLS playlist rather than by a pssh box, and # --hls_key_uri alone does not emit it: the packager writes the # com.apple.streamingkeydelivery line only when FairPlay is named as a # protection system. Naming it leaves the provider's Widevine and PlayReady # headers untouched. Only for cbcs — FairPlay cannot read cenc at all. fairplay_signalling = ["--protection_systems", "FairPlay"] if args.scheme == "cbcs" else [] if not args.per_quality: streams = [ f"in={os.path.join(work, rung['label'])}.mp4,stream={rung['kind']}," f"output={os.path.join(args.out, rung['label'])}.mp4,drm_label={rung['label']}" for rung in LADDER ] key_spec = ",".join( f"label={rung['label']}" f":key_id={uuid_to_hex(keys[rung['label']]['keyId'])}" f":key={base64_to_hex(keys[rung['label']]['key'])}" for rung in LADDER ) run("packager", [ *streams, "--keys", key_spec, # The provider's protection headers, not generated ones — they # carry the content identifier the licence server needs. "--pssh", pssh_hex(primary.get("drmSystemsData")), *common, *fairplay_signalling, *(["--hls_key_uri", hls_key_uri] if hls_key_uri else []), "--mpd_output", os.path.join(args.out, "manifest.mpd"), "--hls_master_playlist_output", os.path.join(args.out, "master.m3u8"), ]) else: # One run per quality, so each carries its own protection header. See # merge_mpd() for why a single run cannot do it. parts = [] for rung in LADDER: k = keys[rung["label"]] fp = next((d for d in k.get("drmSystemsData", []) if d.get("systemName") == "FairPlay"), None) print(f" {rung['label']}") run("packager", [ f"in={os.path.join(work, rung['label'])}.mp4,stream={rung['kind']}," f"output={os.path.join(args.out, rung['label'])}.mp4," f"drm_label={rung['label']},playlist_name={rung['label']}.m3u8", "--keys", f"label={rung['label']}:key_id={uuid_to_hex(k['keyId'])}:key={base64_to_hex(k['key'])}", "--pssh", pssh_hex(k.get("drmSystemsData")), *common, *fairplay_signalling, # Per quality too: the skd URI names the content, and each # quality is its own content at the provider. *(["--hls_key_uri", f"skd://{fp['uriExtXKey']}"] if fp and fp.get("uriExtXKey") else []), "--mpd_output", os.path.join(args.out, f"{rung['label']}.mpd"), "--hls_master_playlist_output", os.path.join(args.out, f"{rung['label']}-master.m3u8"), ]) parts.append({ "label": rung["label"], "kind": rung["kind"], "mpd": os.path.join(args.out, f"{rung['label']}.mpd"), "master": os.path.join(args.out, f"{rung['label']}-master.m3u8"), }) print(" merging manifests") merge_mpd([p["mpd"] for p in parts], os.path.join(args.out, "manifest.mpd")) merge_hls(parts, os.path.join(args.out, "master.m3u8")) # The per-quality manifests were scaffolding. Left behind, they are # three more things that look loadable and are not. for p in parts: for f in (p["mpd"], p["master"]): try: os.remove(f) except OSError: pass shutil.rmtree(work, ignore_errors=True) # ---- 4. what to do next print(f"\n4/4 Done — everything is in {args.out}\n") print("Upload it somewhere public (the media is encrypted; the keys are not in it):\n") print(f" aws s3 sync {args.out} s3://YOUR-BUCKET/{args.content_id}/ --acl public-read\n") print("Then point your player at:\n") print(f" DASH https://YOUR-BUCKET.s3.amazonaws.com/{args.content_id}/manifest.mpd") print(f" HLS https://YOUR-BUCKET.s3.amazonaws.com/{args.content_id}/master.m3u8") # Printed from the response rather than written here: the endpoint depends # on which key store issued the key, and the manifest gives no hint which # that was. Hardcoding one is how a title ends up asking the wrong server. urls = primary.get("licenseUrls", {}) print(f""" Licenses Widevine {urls.get('widevine')} PlayReady {urls.get('playready')} FairPlay {urls.get('fairplay')} Player setup: https://airi.live/docs/reproductores """) if args.per_quality: print(""" NOTE — one key per quality. Each quality gets its own key inside this one title, which is what lets you demand hardware DRM for 1080p while 720p and the audio stay playable on a laptop. Two consequences worth knowing before you ship it: · A viewer whose device is refused the HD key still plays SD. Check that your player falls back rather than stopping. · One licence request per quality, so three charges per playback, not one. """) if __name__ == "__main__": try: main() except Failed as failure: print(f"\n{failure}\n", file=sys.stderr) sys.exit(1)
Uploading #
The output is safe to serve publicly: the video is encrypted and the keys are not in it. A player has to request a license, and that is where your rules are applied.
Even so, serving it from our storage saves you setting up a bucket, its permissions, its CORS and a CDN — and adds links that expire per viewer. If you already run your own infrastructure, the S3 route is further down.
With Airi storage
Upload every file of the package, then ask for a directory link for the manifest. That detail is not optional: a manifest references its segments by relative path, so the signature has to cover the whole folder or the segments would be left out of it.
# One file at a time: declare, send the bytes, complete. for f in ./protected/*; do RES=$(curl -s -X POST https://kms.airi.live/v1/storage/files \ -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \ -d "{\"name\":\"$(basename $f)\",\"sizeBytes\":$(stat -c%s $f)}") ID=$(echo $RES | jq -r .file.id) URL=$(echo $RES | jq -r .upload.url) curl -s -X PUT --data-binary @$f "$URL" curl -s -X POST https://kms.airi.live/v1/storage/files/$ID/complete \ -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{}' done # The manifest link, signed for the whole folder and for ten minutes. curl "https://kms.airi.live/v1/storage/files/$MANIFEST_ID/url\ ?expiresSeconds=600&directory=true" -H "Authorization: Bearer $TOKEN"
One link per viewer
With your own bucket
aws s3 sync ./protected s3://YOUR-BUCKET/movie-1234/ --acl public-read
With Backblaze B2
B2 speaks the same protocol as S3, so the same tool works with --endpoint-url added. It is considerably cheaper on storage and transfer, and it is what our own demo runs on.
export AWS_ACCESS_KEY_ID=<keyID> export AWS_SECRET_ACCESS_KEY=<applicationKey> aws s3 sync ./protected s3://YOUR-BUCKET/movie-1234/ \ --endpoint-url https://s3.us-west-002.backblazeb2.com
The endpoint region (us-west-002 above) is on your bucket’s page. The bucket has to be of type Public, and the playback URL then looks like this:
https://YOUR-BUCKET.s3.us-west-002.backblazeb2.com/movie-1234/manifest.mpd
Upload each file type with its Content-Type
application/octet-stream, some players reject it without saying why. aws s3 sync gets .mp4 right but not always .mpd and .m3u8, so it is worth forcing.S3="s3://YOUR-BUCKET/movie-1234/" EP="--endpoint-url https://s3.us-west-002.backblazeb2.com" aws s3 sync ./protected $S3 $EP --exclude "*" --include "*.mp4" \ --content-type video/mp4 aws s3 sync ./protected $S3 $EP --exclude "*" --include "*.mpd" \ --content-type application/dash+xml aws s3 sync ./protected $S3 $EP --exclude "*" --include "*.m3u8" \ --content-type application/vnd.apple.mpegurl
Two things to get right on the bucket
CORS. The player fetches the manifest and segments from the browser, so the bucket has to allow it. Without it nothing loads, and the error looks like a DRM problem when it is not.
[{ "AllowedOrigins": ["*"], "AllowedMethods": ["GET", "HEAD"], "AllowedHeaders": ["*"], "ExposeHeaders": ["Content-Length", "Content-Range"] }]
Range requests. Streaming depends on them. S3 supports them by default, but a CDN in front may not, and the symptom is a video that never starts.
Playing it #
- DASH
https://YOUR-BUCKET.s3.amazonaws.com/movie-1234/manifest.mpd- HLS
https://YOUR-BUCKET.s3.amazonaws.com/movie-1234/master.m3u8- Widevine
https://drm.airi.live/v2/widevine- PlayReady
https://drm.airi.live/v2/playready- FairPlay
https://drm.airi.live/fairplay
Concrete configuration for Shaka Player and Video.js is in Players.
FairPlay needs one more thing