Airi

Cifrar tu vídeo

Un script que va de un archivo de vídeo a contenido protegido y listo para subir. En Node o en Python, el que prefieras.

Qué hace el script #

  1. Pide a Airi una clave por calidad — 1080p, 720p y audio.
  2. Transcodifica tu vídeo a esas calidades con ffmpeg.
  3. Cifra y empaqueta en DASH y HLS con shaka-packager, en cbcs.
  4. Te dice qué subir y cómo apuntar el reproductor.

Tres claves y no una, a propósito

Las reglas se aplican por clave. Con una sola clave para todo el contenido, el 1080p y el 720p comparten regla: o exiges hardware para ambos y pierdes espectadores, o no lo exiges para ninguno y sirves tu 1080p sin protección real.

Ejecutarlo dos veces es seguro y gratis: pedir una clave que ya existe devuelve la misma y no se cobra. Si falla la transcodificación a mitad, vuelves a lanzarlo sin duplicar nada.

Lo que necesitas instalado #

Dos herramientas. ffmpeg construye la escalera de calidades; shaka-packager cifra y escribe los manifiestos.

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: descarga packager-win-x64.exe de la pagina de releases,
# renombralo a packager.exe y ponlo en el PATH

Ejecutarlo #

El token sale de Desarrolladores en tu panel. Se muestra una sola vez.

ejemplo
node protect.mjs \
  --input pelicula.mp4 \
  --content-id pelicula-1234 \
  --token kms_live_...
ejemplo
python protect.py \
  --input pelicula.mp4 \
  --content-id pelicula-1234 \
  --token kms_live_...

Lo que produce

ejemplo
protected/
├── HD.mp4          1080p, cifrado
├── SD.mp4          720p, cifrado
├── AUDIO.mp4       cifrado
├── manifest.mpd    DASHWidevine, PlayReady
└── master.m3u8     HLSFairPlay, y los demas en Apple

Una clave por calidad #

Por defecto todo el título va con una sola clave. Con --per-quality cada calidad recibe la suya, que es lo que te permite exigir DRM por hardware al 1080p mientras el 720p sigue reproduciéndose en un portátil — la regla con la que los grandes servicios reservan sus calidades altas.

ejemplo
node protect.mjs \
  --input pelicula.mp4 \
  --content-id pelicula-1234 \
  --token kms_live_... \
  --per-quality

Después defines la regla de cada calidad en Reglas de reproducción, eligiendo la pista en el selector. El nivel que pidas para 1080p no afecta al 720p ni al audio.

Lo que cambia por dentro

Cada calidad se convierte en una clave independiente, y el reproductor pide una licencia por clave en vez de una sola. Eso tiene dos consecuencias que conviene saber antes de ponerlo en producción: se factura una licencia por calidad en cada reproducción, y un espectador al que le denieguen la clave de 1080p debe seguir viendo el 720p en lugar de quedarse sin vídeo.

Widevine y PlayReady no deniegan igual

Esta es la parte que sorprende. Widevine emite la licencia y deja que el dispositivo marque la clave como inservible, así que el reproductor baja de calidad y sigue. PlayReady, en cambio, rechaza en el servidor: si le exiges un nivel que el cliente no alcanza, responde un error en lugar de una clave, y para el reproductor eso no es «esta calidad no» sino «esta reproducción no».

La misma regla se escribe idéntica en el panel y se comporta al revés en cada sistema. Si tu audiencia incluye Windows o Xbox, exige hardware en Widevine y deja PlayReady en SL2000 — o comprueba primero, en un dispositivo que NO cumpla la regla, que tu reproductor sobrevive a la licencia denegada.

El script en Node #

Sin dependencias: solo Node 18 o superior. Guárdalo como protect.mjs — o descárgalo.

ejemplo
#!/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);

    });

}

El script en Python #

Solo biblioteca estándar, sin pip install. Guárdalo como protect.py — o descárgalo.

ejemplo
#!/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)

Subirlo #

Lo que sale se puede servir públicamente sin miedo: el vídeo está cifrado y las claves no están dentro. Un reproductor tiene que pedir la licencia, y ahí es donde se aplican tus reglas.

Aun así, servirlo desde nuestro almacenamiento te ahorra montar un bucket, sus permisos, su CORS y su CDN — y añade enlaces que caducan por espectador. Si ya tienes tu propia infraestructura, más abajo está el camino con S3.

Con el almacenamiento de Airi

Sube cada fichero del paquete y pide un enlace de directorio para el manifiesto. Ese detalle no es opcional: un manifiesto referencia sus segmentos por ruta relativa, así que la firma tiene que cubrir la carpeta entera o los segmentos se quedarían fuera.

Subir el paquete y repartir un enlace
# Un fichero a la vez: declarar, subir los bytes, cerrar.
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

# El enlace del manifiesto, firmado para toda la carpeta y por diez minutos.
curl "https://kms.airi.live/v1/storage/files/$MANIFEST_ID/url\
?expiresSeconds=600&directory=true" -H "Authorization: Bearer $TOKEN"

Un enlace por espectador

Pide el enlace en el momento en que alguien le da al play, no al publicar el vídeo. Así caduca solo, y puedes añadirle restricción de país o un tope de velocidad según quién sea — todo se aplica antes de servir un byte, así que lo que se bloquea tampoco se te factura.

Con tu propio bucket

Amazon S3
aws s3 sync ./protected s3://TU-BUCKET/pelicula-1234/ --acl public-read

Con Backblaze B2

B2 habla el mismo protocolo que S3, así que sirve la misma herramienta añadiendo --endpoint-url. Sale bastante más barato en almacenamiento y en transferencia, y es lo que usamos para nuestra propia demo.

Backblaze B2
export AWS_ACCESS_KEY_ID=<keyID>
export AWS_SECRET_ACCESS_KEY=<applicationKey>

aws s3 sync ./protected s3://TU-BUCKET/pelicula-1234/ \
  --endpoint-url https://s3.us-west-002.backblazeb2.com

La región del endpoint (us-west-002 arriba) aparece en la ficha de tu bucket. El bucket tiene que ser de tipo Public, y la URL de reproducción queda así:

ejemplo
https://TU-BUCKET.s3.us-west-002.backblazeb2.com/pelicula-1234/manifest.mpd
Forzando los tipos correctos
S3="s3://TU-BUCKET/pelicula-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

Dos cosas del bucket que hay que acertar

CORS. El reproductor descarga el manifiesto y los segmentos desde el navegador, así que el bucket tiene que permitirlo. Si falta, no carga nada y el error parece de DRM sin serlo.

Configuración CORS del bucket
[{
  "AllowedOrigins": ["*"],
  "AllowedMethods": ["GET", "HEAD"],
  "AllowedHeaders": ["*"],
  "ExposeHeaders": ["Content-Length", "Content-Range"]
}]

Peticiones por rango. El streaming depende de ellas. S3 las admite de serie, pero un CDN por delante puede no hacerlo, y el síntoma es un vídeo que no arranca nunca.

Reproducirlo #

DASH
https://TU-BUCKET.s3.amazonaws.com/pelicula-1234/manifest.mpd
HLS
https://TU-BUCKET.s3.amazonaws.com/pelicula-1234/master.m3u8
Widevine
https://drm.airi.live/v2/widevine
PlayReady
https://drm.airi.live/v2/playready
FairPlay
https://drm.airi.live/fairplay

La configuración concreta para Shaka Player y Video.js está en Reproductores.

FairPlay necesita algo más

Reproducir en dispositivos Apple requiere un certificado emitido por Apple a tu cuenta de desarrollador. Todo lo demás funciona sin él.