# Cifrar tu vídeo

Source: https://airi.live/docs/empaquetar
Language: es. The other: https://airi.live/docs/empaquetar.md?lang=en
Index: https://airi.live/llms.txt — every page in one file: https://airi.live/llms-full.txt

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.

> **Cifrado desde el primer fotograma:** El script pasa `--clear_lead 0` al empaquetador. Por defecto, shaka-packager deja los primeros segundos **sin cifrar** para que la reproducción arranque antes de que llegue la licencia — pero eso significa que esos segundos los puede ver cualquiera, con licencia o sin ella. Si empaquetas por tu cuenta, no lo olvides.

## 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
```

> **ffmpeg solo no basta:** ffmpeg sabe cifrar, pero únicamente en `cenc` y sin las cabeceras de protección que cada sistema DRM necesita. `cenc` deja fuera a todos los dispositivos Apple, y sin esas cabeceras el reproductor no sabe a qué servidor de licencias preguntar. Por eso hacen falta las dos herramientas.

## Ejecutarlo

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

```
node protect.mjs \
  --input pelicula.mp4 \
  --content-id pelicula-1234 \
  --token kms_live_...
```

```
python protect.py \
  --input pelicula.mp4 \
  --content-id pelicula-1234 \
  --token kms_live_...
```

> **Usa identificadores desechables mientras pruebas:** El `contentId` es permanente: una vez que existe una clave para él, el proveedor no vuelve a emitir otra nunca. Prueba con algo como `test-2026-07-31-01`, no con el nombre real de tu catálogo.

### Lo que produce

```
protected/
├── HD.mp4          1080p, cifrado
├── SD.mp4          720p, cifrado
├── AUDIO.mp4       cifrado
├── manifest.mpd    DASH  — Widevine, PlayReady
└── master.m3u8     HLS   — FairPlay, 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.

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

> **Tu reproductor tiene que sobrevivir a una licencia denegada:** Es la diferencia entre «1080p no está disponible aquí» y «el vídeo está roto». En Shaka Player se hace con `drm.failureCallback`, marcando `error.handled = true`; sin marcarlo, el callback se ejecuta y la reproducción muere igual.

### 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](/docs/scripts/protect.mjs).

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

```

## El script en Python

Solo biblioteca estándar, sin `pip install`. Guárdalo como `protect.py` — o [descárgalo](/docs/scripts/protect.py).

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

```

## 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](/docs/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](/drm/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í:

```
https://TU-BUCKET.s3.us-west-002.backblazeb2.com/pelicula-1234/manifest.mpd
```

> **Sube cada tipo de archivo con su Content-Type:** Si el manifiesto se sirve como `application/octet-stream`, algunos reproductores lo rechazan sin decir por qué. `aws s3 sync` acierta con los `.mp4` pero no siempre con `.mpd` y `.m3u8`, así que conviene forzarlo.

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](/docs/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.
