ProtoFlix

Documentation · Développer

Protocole des modules

Le dossier d’un module, ses capacités, le canal, les trames, les surfaces et le cycle de vie.

Version 1. Un module externe (ADR 0024) est un programme séparé et confiné. Ce document suffit pour en écrire un dans n'importe quel langage. Le code de référence est server/internal/modules/protocol.

Le dossier d'un module

<id>/
  manifest.json
  bin/<programme>
  assets/            facultatif : images des surfaces (PNG, JPEG, WebP)

Le programme doit être autonome : sous confinement, il ne lit que bin/, son dossier de données et /dev/null, /dev/urandom. Un programme lié dynamiquement ne trouverait pas ses bibliothèques.

json
{
  "protocol": 1,
  "id": "exemple.module",
  "version": "1.0.0",
  "name": "Exemple",
  "description": "Ce qu'il fait, pour l'administrateur.",
  "category": "metadata",
  "support": "experimental",
  "requires": [],
  "program": "exemple",
  "capabilities": {
    "network_hosts": [{ "host": "api.example.org", "why": "son API" }]
  },
  "settings": [
    { "key": "api_key", "label": "Clé API", "type": "secret", "required": true }
  ]
}

Un champ inconnu fait refuser le module : le serveur n'ignore jamais une capacité qu'il ne connaît pas.

ChampRègle
idMinuscules, chiffres, _ et ., sans ... Le dossier porte ce nom. Un identifiant déjà pris est refusé
categoryacquisition, live_tv, metadata, music
supportvalidated, experimental
programUn nom de fichier de bin/, sans chemin
settings[].typebool, int, text, choice, secret
surfacesFacultatif : ses places dans les clients, voir « Surfaces »

Capacités

Elles sont montrées à l'administrateur avant l'activation, et vérifiées par le serveur à chaque appel.

CapacitéEffet
network_hostsnet.fetch vers ces noms exacts, en HTTPS, adresses privées refusées, redirections vérifiées
network_configurednet.fetch vers les seules adresses d'un réglage, séparées par des espaces, en HTTP ou HTTPS, réseau local compris. Ce réglage est un texte obligatoire, sans valeur par défaut : c'est l'administrateur qui choisit
network_rawSockets Internet directes. Rien n'est plus contrôlé, réseau local et serveur compris, et l'interface le dit

Les autres capacités de l'ADR 0024 (media.read, catalog.read, results.write, jobs.run, events.read) n'existent pas encore : elles arriveront avec le premier module qui en a besoin.

Un module ne reçoit jamais de session, de jeton de compte ni d'information sur une personne. Le serveur applique ses contrôles de droits avant de l'appeler.

Le canal

La variable PROTOFLIX_MODULE_FD donne le numéro du descripteur, une socket déjà connectée au serveur. C'est le seul moyen de le joindre : sans network_raw, créer une socket est refusé, y compris Unix.

Le répertoire courant, HOME et TMPDIR sont le dossier de données. L'environnement ne contient rien d'autre.

Les trames

Chaque trame est une longueur sur 4 octets (gros-boutiste), suivie d'un message JSON.

json
{ "id": 7, "method": "net.fetch", "params": { } }
{ "id": 7, "result": { } }
{ "id": 7, "error": { "code": "forbidden", "message": "…" } }
{ "method": "log.write", "params": { } }
  • Chaque côté peut appeler l'autre, et les appels se chevauchent : une réponse porte l'id de son appel.
  • Sans id (ou avec 0), c'est une notification, sans réponse.
  • Une trame de plus de 2 Mio vers le serveur, ou de plus de 16 Mio vers le module, met fin à la conversation. Un message qui n'est ni un appel ni une réponse aussi.
  • Au-delà de 32 appels en cours, la réponse est busy : réessayer plus tard.
  • Un côté qui cesse de lire plus de 10 s perd la connexion.

Le module appelle

module.hello

Le premier appel, dans les 10 s suivant le démarrage. Avant lui, tout est refusé (not_ready).

json
{ "protocol": 1, "module": "exemple.module", "version": "1.0.0" }

Réponse : { "protocol": 1, "capabilities": ["network.hosts"] }.

Un autre protocol (incompatible) ou un autre module (bad_request) fait marquer le programme comme indisponible, jusqu'au redémarrage du serveur.

Le moteur d'un module décrit (voir « Modules décrits ») ne connaît pas le module qu'il exécute : il se présente par engine, sans module, et le serveur lui donne le module dans described.

json
{ "protocol": 1, "module": "", "engine": "engine.blueprint", "version": "1.0.0" }
json
{ "protocol": 1, "capabilities": [],
  "described": { "module": "x.rss_accueil", "version": "1.0.0", "graph": { "format": 1, … },
                 "settings": { "feed_url": "text", "count": "int" }, "surfaces": { "row": "home", "page": "nav" } } }

net.fetch

json
{ "method": "POST", "url": "https://api.example.org/x", "headers": { "Accept": ["application/json"] }, "body": "base64" }

Réponse : { "status": 200, "headers": { }, "body": "base64" }.

Corps de 1 Mio au plus à l'envoi, de 8 Mio au plus à la réception, 30 s au plus, port 443 seulement (sauf une adresse de network_configured, sur son port), 4 appels en cours au plus (busy au-delà). Méthodes : GET, HEAD, POST, PUT, PATCH, DELETE. Host, Content-Length, Connection et les en-têtes de proxy sont refusés. Le serveur fixe le User-Agent.

log.write

json
{ "level": "info", "message": "…" }

level vaut debug, info, warn ou error. Au-delà de 20 lignes par seconde ou de 64 Kio par minute, les lignes sont perdues. Les textes sont coupés à 1 000 octets, sans caractères de contrôle.

Le serveur appelle

module.configure

{ "settings": { "api_key": "…" } }, secrets en clair, à chaque démarrage et à chaque changement. Cet appel peut arriver avant la réponse à module.hello.

module.check

Réponse :

json
{ "state": "ok" }
{ "state": "setup_needed", "message": "Aucun compte relié" }
{ "state": "error", "message": "Le service refuse la clé" }

Le module répond en moins de 10 s. Le message est montré à l'administrateur dans sa langue d'origine.

Points d'extension

Le serveur appelle ces méthodes sur le seul module qui remplit ce rôle, désigné par son identifiant.

ModuleMéthodeParamètresRéponse
debrid.megadebriddebrid.connectlogin, passwordtoken, name, premium_until
debrid.unlocktoken, linklink
debrid.submittoken, magnetref, name, size, ratio, peers, rate, link
debrid.torrenttoken, refname, size, ratio, peers, rate, link
downloads.bittorrenttorrent.addmagnetref, name, has_info, size, done, rate, peers, complete, files
torrent.statusrefla même réponse
torrent.stopref, deleterien

Le serveur reprend les fichiers de files (path, size) dans torrents/ du dossier de données, et ne s'y fie pas : chemin, taille, fichier ordinaire et lien unique sont vérifiés, puis le contenu est relu.

Un refus d'un débrideur porte l'un de ces codes, et son message est montré à l'administrateur : debrid_bad_credentials, debrid_token_expired, debrid_not_premium, debrid_refused, debrid_unavailable.

ModuleMéthodeParamètresRéponse
client.qbittorrent, client.transmissiontransfer.addmagnetcomme transfer.status, avec ref
transfer.statusrefname, size, ratio, peers, rate, done, files
search.torznabsearch.queryqueryresults (title, size, seeders, magnet, source), failed
subtitles.opensubtitlessubtitles.searchkind, title, year, imdb_id, tmdb_id, parent_imdb_id, parent_tmdb_id, season, episode, languages (deux lettres), moviehash, file_nameresults (id, language, release, downloads, hearing_impaired, hash_match, machine_made), credit, quota (remaining, reset_at)
subtitles.fetchidname, data (2 Mio au plus), quota

Quand done est vrai, files donne des chemins relatifs au dossier de téléchargement du client (path, size). Le serveur les lit dans le dossier réglé par l'administrateur, dont le module ne peut pas sortir. Un refus porte client_refused (inutile de réessayer) ou client_unavailable.

Surfaces

Un module peut apparaître sur la TV et sur le site sans mise à jour de ces clients (ADR 0030). Il y déclare des surfaces, qu'il remplit de blocs, à la demande, dans le vocabulaire de api/surfaces/schema.json. Il ne fournit jamais de code, de HTML ou d'adresse extérieure.

json
"surfaces": [
  { "name": "page", "placement": "nav", "clients": { "tv": false, "web": true }, "audience": "everyone",
    "refresh_s": 30, "label": { "fr": "Démonstration", "en": "Demo" }, "icon": "sparkles" }
]
ChampRègle
name^[a-z][a-z0-9_]{0,31}$, unique dans le module ; 16 surfaces au plus
placementnav (une page du rail ou du menu), home (une rangée de l'accueil), title (une section de la fiche), title.action (un bouton de la fiche), player (une ligne du lecteur)
clientstv, web, mobile : affichée par défaut ou non. Sans mobile, l'application du téléphone suit web. L'administrateur choisit ensuite, client par client
audienceeveryone, admins, downloads_allowed, live_tv_allowed
refresh_s0 (jamais relue), ou de 5 à 86400
labelfr et en, 60 caractères au plus chacun
iconUn nom de la liste x-icons du schéma
since, essentialVersion des blocs exigée (1 par défaut), et si un client trop ancien doit le dire plutôt que de la sauter

surface.render

Le serveur appelle :

json
{ "surface": "page", "params": { "scope": "mine" }, "context": { "item_id": "itm_…", "position_ms": 3720000 },
  "locale": "fr", "audience": "member", "subject": "Zx…" }

Le module répond avec la page, { "title": "…", "blocks": [ … ] }, telle que le schéma la décrit. Le contexte ne contient que ce que l'emplacement donne : item_id pour title, title.action et player, et position_ms (par tranches de 10 s) pour player.

Le module ne reçoit jamais le compte. subject est un pseudonyme propre au module et au compte : le même à chaque appel, différent pour un autre module, et impossible à relier au compte sans la clé du serveur. Il suffit pour garder des données par personne, dans le dossier de données du module. Une purge du module efface sa clé, et les pseudonymes changent alors.

Une image s'écrit { "asset": "logo.png" } (un fichier du dossier assets/ du module) ou { "item_id": "itm_…" } (l'illustration d'une œuvre). Le serveur la remplace par une adresse à lui : un client ne voit jamais d'adresse extérieure.

subject.forget

{ "subject": "Zx…" } : un compte a été supprimé, et le module efface ce qu'il gardait sous ce pseudonyme. Le serveur garde la liste des pseudonymes à oublier pour chaque module qui en a. Il la donne au démarrage du module, puis dès qu'un autre compte disparaît, au prochain appel de surface. Un module qui ne connaît pas l'appel (unknown) est considéré comme ne gardant rien par personne : la liste est vidée. Une autre erreur laisse le pseudonyme dans la liste, pour la fois suivante. La purge du module efface la liste avec sa clé.

surface.action

C'est la même requête, avec en plus action et les values d'un formulaire. Le serveur a déjà vérifié le public et les valeurs. Le module répond avec l'un des résultats du schéma : toast, refresh, open_surface, open_item ou form.

Limites

  • Délai : 2 s par appel. Au-delà, le serveur abandonne et sert la dernière page valide, marquée stale.
  • Taille : 256 Kio pour une page, 64 Kio pour le résultat d'une action.
  • Contrôle : le serveur valide chaque réponse contre le schéma. Une réponse refusée devient un bloc notice, et l'erreur est journalisée pour l'administrateur.
  • Images : une image fait 512 Kio au plus. Son type est vérifié sur ses octets, quel que soit son nom, et seuls PNG, JPEG et WebP sont acceptés. Le serveur la lit dans le paquet, signé avec le module, sans appeler le programme : elle s'affiche même module arrêté.
  • Disponibilité : un module n'a de surface que s'il est activé.

Le module server/modules/exemple_surfaces utilise chaque bloc et chaque emplacement. Pour le charger :

bash
deploy/package/dev-modules.sh /dossier/de/dev arm64 exemple_surfaces
PROTOFLIX_MODULES_DEV_DIR=/dossier/de/dev protoflix serve

Modules décrits

Un module décrit (ADR 0038) est fait sans code : son dossier contient un graphe de cartes au lieu d'un programme, et le moteur officiel engine.blueprint l'exécute, confiné comme n'importe quel module.

<id>/
  manifest.json      "engine" au lieu de "program"
  graph.json         la logique : api/blueprints/graph.schema.json
  assets/            les images des surfaces
json
"engine": { "id": "engine.blueprint", "format": 1 }
  • format est celui de graph.json : 1, ou 2 pour un graphe qui a des écrans dessinés (ADR 0037). Le serveur choisit un moteur qui lit ce format ; le moteur officiel déclare "formats": [1, 2].
  • Les capacités se déduisent du graphe : les hôtes écrits dans ses requêtes HTTP vont dans network_hosts (avec leur why), et le réglage dont une requête lit l'adresse dans network_configured. Le serveur recalcule ces capacités au chargement et refuse un manifeste qui en déclare d'autres. network_raw est refusé.
  • Chaque réglage que lit le graphe, et chaque surface qu'il remplit, est déclaré dans le manifeste ; chaque surface du manifeste a son événement d'affichage.
  • Le serveur lance le moteur à la place d'un programme et lui donne le graphe dans la réponse à module.hello. Le moteur ne lit aucun fichier du module.

Le moteur répond lui-même à module.configure, module.check, surface.render et surface.action, dans les budgets de l'ADR 0038 (10 000 cartes, 1,8 s pour une surface, 20 requêtes par exécution). Un dépassement ou une erreur d'une carte rend unavailable avec la carte en cause, et une ligne au journal.

Le moteur est lui-même un paquet du dépôt officiel, au manifeste "kind": "engine" : il s'installe une fois, sans réglage ni processus propre, et sert tous les modules décrits. Sans lui, un module décrit est indisponible et le dit. En développement, il se pose dans le même dossier :

bash
deploy/package/dev-modules.sh /dossier/de/dev arm64 blueprint api/blueprints/examples/rss-accueil

Pour publier un module décrit, ses fichiers sont les mêmes sur chaque machine : pfrepo release -module linux/amd64=<dossier> -module linux/arm64=<dossier> …. pfrepo refuse de signer un graphe qui ne correspond pas à son manifeste.

Le graphe api/blueprints/examples/exemple-surfaces refait le module exemple.surfaces ; api/blueprints/examples/rss-accueil montre un flux RSS dans une rangée de l'accueil.

Erreurs

forbidden, bad_request, unknown, busy, unavailable, host_refused, too_large, incompatible, not_ready.

Une erreur peut porter aussi reason, la cause d'un refus, et retry_after, en secondes. Le serveur s'en sert pour écrire le message dans la langue de l'administrateur, et message reste pour un serveur qui ne connaît pas la cause. Un module de sous-titres refuse avec subtitles_refused et l'une de ces causes : quota (téléchargements du jour épuisés, retry_after jusqu'au renouvellement), busy (trop de demandes) ou login (compte refusé).

Cycle de vie

  • Activer le module démarre le programme ; le désactiver l'arrête par SIGTERM, puis SIGKILL après 5 s, avec tout ce qu'il a lancé.
  • Un programme qui s'arrête redémarre, avec une attente croissante. Après 5 échecs d'affilée, le module passe en erreur, jusqu'à ce que l'administrateur le réactive.
  • Ses données restent à la désactivation. La purge les efface.

Développer un module

PROTOFLIX_MODULES_DEV_DIR=/chemin/absolu charge les sous-dossiers de ce dossier, sans signature. Cette variable ne se règle pas depuis l'interface. Le module porte l'avis development dans l'administration (notices), et reste experimental quoi que dise son manifeste.

Le confinement reste actif. Sans Landlock sur la machine, l'avis no_confinement s'y ajoute, avec la raison.

Source : docs/api/module-protocol.md, dans le dépôt de ProtoFlix.