Airi

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 #

  1. Asks Airi for one key per quality — 1080p, 720p and audio.
  2. Transcodes your video into those qualities with ffmpeg.
  3. Encrypts and packages into DASH and HLS with shaka-packager, in cbcs.
  4. Tells you what to upload and how to point a player at it.

Three keys and not one, on purpose

Rules apply per key. With a single key for the whole title, 1080p and 720p share a rule: either you demand hardware for both and lose viewers, or you demand it for neither and serve your 1080p with no real protection.

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.

What you need installed #

Two tools. ffmpeg builds the quality ladder; shaka-packager encrypts and writes the manifests.

macOS
brew install ffmpeg shaka-packager
Debian / Ubuntu
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
Windows
scoop install ffmpeg

# packager: download packager-win-x64.exe from the releases page,
# rename it to packager.exe and put it on PATH

Running it #

The token comes from Developers in your dashboard. It is shown once.

example
node protect.mjs \
  --input movie.mp4 \
  --content-id movie-1234 \
  --token kms_live_...
example
python protect.py \
  --input movie.mp4 \
  --content-id movie-1234 \
  --token kms_live_...

What it produces

example
protected/
├── HD.mp4          1080p, encrypted
├── SD.mp4          720p, encrypted
├── AUDIO.mp4       encrypted
├── manifest.mpd    DASHWidevine, PlayReady
└── master.m3u8     HLSFairPlay, 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.

example
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.

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.

example
#!/usr/bin/env node

/**

 * Protect a video with Airi: keystranscodeencryptready 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 transcodesit makes the quality ladderand

 * 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 cbcsthe 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 --outthe 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 itso 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 existedrepeating 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 quietthe manifest looks right and the video simply never

 * startswhich 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 itotherwise 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.mjsone 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 headersverified 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 onesthey

            // 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  Doneeverything 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 washardcoding 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(`  NOTEone 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.

example
#!/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 onlyno 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 needheaders 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 alla

        # 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 existedrepeating 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 quietthe 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 itotherwise 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 onceso

    # 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

        # cbcsthe 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 arriveswhich

        # 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 cbcsFairPlay 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 onesthey

            # 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("""  NOTEone 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.

Upload the package and hand out a link
# 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

Ask for the link at the moment somebody presses play, not when you publish the video. That way it expires on its own, and you can add a country restriction or a bandwidth cap depending on who is watching — all of it applied before a byte is served, so whatever is blocked is never billed to you either.

With your own bucket

Amazon S3
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.

Backblaze B2
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:

example
https://YOUR-BUCKET.s3.us-west-002.backblazeb2.com/movie-1234/manifest.mpd
Forcing the right types
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.

Bucket CORS configuration
[{
  "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

Playing on Apple devices requires a certificate issued by Apple to your developer account. Everything else works without it.