Test it locally¶
The Node kit is the sdk/ folder of the example plugins (where to get it):
run.mjs (run one function), validate.mjs (check a plugin the way Kino does), init.mjs (scaffold
a new one), kino-shim.mjs (the kino API in Node), contract.mjs (the rules, read from
contract.json), seal.mjs (seals a secret for your manifest, and --keygen/--sign for a signed plugin) and
guide-tables.mjs (regenerates the guide's tables). There is nothing to
install. It needs Node 18 or newer (checked on 18.20, 20.11 and 24.14); node --test sdk/test/kit.test.mjs
runs its own tests.
node sdk/run.mjs ./plugin.js search "metropolis"
node sdk/run.mjs ./plugin.js home
node sdk/run.mjs ./plugin.js browse films 2
node sdk/run.mjs ./plugin.js episodes 'Dragnet1951'
node sdk/run.mjs ./plugin.js resolve 'Dragnet1951|Dragnet/Season 1/Dragnet (1951) - S01E01 - The Human Bomb.mp4'
The first argument is your entry file (or the folder that holds kino-plugin.json), then the
function, then its argument: the text to search for, the ref for episodes and resolve, or the
ref and an optional cursor for browse. The runner reads your manifest, provides the kino
global, calls that one function the way Kino does, checks the answer with the app's rules and
prints what Kino would keep as JSON on stdout; every entry Kino would drop is reported on stderr with
the reason (--raw prints your answer untouched). Logs, console.* and errors go to stderr, so you can
pipe the result (... | head -30, ... | jq). The exit code is 0 on success, 1 when your code
throws and 2 when the command is wrong. The runner only runs functions your manifest declares.
--config key=value(repeatable) sets a setting; the runner also readssdk/config.json({ "server": "http://192.168.1.10:8096", "user": "ana" }; keep it out of git). A required setting with no value stops the run withauth_required, as in the app.--record fixtures.jsonsaves everykino.fetchanswer;--replay fixtures.jsonanswers from that file only, with no network. Record once, then your tests run offline and always the same (the scaffold'stest/plugin.test.mjsdoes exactly that).KINO_TYPE=movie|series|anysets thetypeof the search (defaultany).- To fill the other fields of the query, pass the whole query as JSON:
node sdk/run.mjs ./plugin.js search '{"q":"dragnet","type":"series","year":1951}'(season,episode,tmdbIdandyearare0otherwise). - Under the Node kit
kino.storageis a file named.kino-storage.jsonand the cookie jar.kino-cookies.json, both next to your manifest. Add them to your.gitignore. Delete them to start from scratch. A plugin withsecretsalso reads.kino-secrets.jsonfrom the same folder (below).node sdk/init.mjsalready lists all three in the scaffold's.gitignore. node sdk/validate.mjs <folder>checks the manifest with every rule of the manifest (the same Spanish messages the app shows) and that each declared capability is exported, and prints the consent sheet's extra lines as the person will read them (the red ones, aninsecureHttphost,"liveStreamHosts": "any"or"streamHosts": "any", marked "(en rojo)";secretsadds "Usa datos sellados por su autor", with a note that only the app can check which repository they were sealed for);--run <function> [argument]also runs it and lists what Kino would drop. With--run liveCategories, every declared playlist is downloaded and parsed too: one that cannot be downloaded or parses to 0 channels is a problem, and its discarded entries are listed. Exit code 0 means Kino would accept it.- The
sdk/folder does not have to live in your repository. Copy it anywhere and runnode /path/to/sdk/run.mjs ./plugin.js .... - A stack trace names a temporary
plugin.mjs: the runner loads a copy of your file so that Node treats it as an ES module whatever its version andpackage.jsonsay. The line numbers are yourplugin.js's.
Sealed secrets (apiVersion 4)¶
The kit can never open a seal: it has no private key. So it reads the plain values straight from
.kino-secrets.json next to your manifest ({ "apiKey": "..." }; keep it out of git, as the
scaffold's .gitignore does) and simulates every rule of kino.secret: the
markers, the substitution inside kino.fetch, the manifest-hosts-over-https check on every hop, the
kino.crypto restrictions and the redaction of what comes back. --record never writes the plain
value to a fixtures file either: a canonical placeholder stands in for it, so a committed recording
never carries a secret however it is replayed later.
To make the seal itself: node sdk/seal.mjs --repo owner/repo --name apiKey, then type the value at
the hidden prompt (or pipe it on stdin). Seal for the repository people will install from, and test
the sealed build in the app installed from its default branch, with no @ref
(why).
Signed plugins (apiVersion 5)¶
validate.mjs checks a signed plugin the way Kino does: the signature field's shape,
the signature itself against your entry file (--repo owner/repo[/folder], or the folder's GitHub
origin when you omit it), that no *.pem is tracked by git, and it prints the author key's
fingerprint and the consent line "Firmado por su autor". It also refuses "entry": "./plugin.js"
(Kino 0.9.45 and older do not install it) and warns when hosts has more than 20 entries (Kino 0.9.44
and older refuse that). Sign again after every change to the entry file or the version.
Live channels (apiVersion 3)¶
The channels exports run through live, with the plugin folder first:
node sdk/run.mjs . live categories
node sdk/run.mjs . live channels noticias
node sdk/run.mjs . live channels noticias 2
node sdk/run.mjs . live guide canal1,canal2
node sdk/run.mjs . live search noticias
node sdk/run.mjs live playlist https://iptv-org.github.io/iptv/countries/co.m3u
node sdk/run.mjs live playlist ./lista.m3u --epg ./guia.xml.gz
live categoriescallsliveCategories()and prints what Kino keeps. Then, for each{ playlist }in the answer, it downloads the list as the app would (yourheaders, yourhostsor the person's server only, every redirect too) and prints, on stderr, the same summary aslive playlistand the list's groups as the categories people will see.live channels <categoryId> [cursor]callsliveChannels({ categoryId, cursor }), then plays the first channel that has arefand nostreamthe way Kino would: it sends thatreftoresolve()and checks the answer as a live channel's (so"liveStreamHosts": "any"applies). Withvalidate.mjs --run liveChannels, a refused answer there is a problem.live search <query>callsliveSearch({ query }), prints the channels Kino keeps (at most 100) and plays the first one with areflikelive channelsdoes.resolve <ref> --livechecks aresolve()answer as a live channel's. Without--livethe kit cannot know therefis a channel's and applies the strict rule; when only that stops the URL and your manifest has"liveStreamHosts": "any", it says "si este ref es de un canal en vivo, prueba con --live".live guide <id,id>callsguide()with those ids and a 24-hour window starting two hours ago.live playlist <url|file>needs no plugin: it reads any M3U list with Kino's own rules and printsN canales en M categorías; K entradas descartadas; L ocultas (adultos), the categories, and the first 20 channels asgroup › name url. With--epg <url|file>it also shows what each of those 20 has on now, or "sin guía". A guide that declares a DOCTYPE is refused, as in the app, and the command says so: "La guía declara un DOCTYPE; Kino la rechaza por seguridad". Use it on a list before you write a line of plugin.
The kit reads lists and guides with sdk/live-playlist.mjs, a copy of the app's readers pinned to
the same test files (docs/plugins/fixtures/live in Kino's repository): what it keeps is what Kino
keeps.
What's new in apiVersion 6¶
node sdk/run.mjs . section [tab] # needs "section" in the manifest
node sdk/run.mjs . categories # needs the browse capability
node sdk/run.mjs . theme # your colors, their contrast ratios and fallbacks
node sdk/run.mjs . settingsStatus
node sdk/run.mjs . action logout
node sdk/run.mjs . validateSettings '{"email":"ana@x.co"}'
node sdk/run.mjs --within '<ref>' ./plugin.js search "texto" # scopedSearch
node sdk/run.mjs ./plugin.js sign '{"url":"https://cdn.example/seg.ts","kind":"segment","ref":"<ref>","context":"<signContext>"}'
node sdk/run.mjs --retry conflict:1 ./plugin.js resolve '<ref>' # or conflict:1:409
node sdk/validate.mjs . --run liveSearch noticias # 18+ marks on liveSearch hits
node sdk/run.mjs ./plugin.js migrate '{"kind":"title","ref":"<old ref>"}'
run.mjs shows what Kino would drop as [dropped by Kino] (clearSettings, alternateHosts), prints
what the person would read for a userMessage (or why it would not be
shown), and validate.mjs warns about debug before publishing, about a scopedSearch that never reads
within and about a plugin that uses kino.crypto's key pairs without "apiVersion": 6. The pages:
The settings form, Signing every request,
Moving saved titles, Section, categories and colors,
Logs and telemetry.
kino.meta and kino.tmdb (Kino 0.9.53)¶
Both work in every function the runner calls, through the kit's stand-ins (any apiVersion):
KINO_META_FIXTURE=meta.json node sdk/run.mjs . home # kino.meta answers from meta.json; without it, null
KINO_TMDB_KEY=<your TMDB key> node sdk/run.mjs . home # kino.tmdb asks TMDB with YOUR key where Kino uses its own (or "tmdbKey" in sdk/config.json)
KINO_TMDB_FIXTURE=tmdb.json node sdk/run.mjs . search matrix # kino.tmdb answers offline from tmdb.json
meta.jsonmaps"<type>:<idKey>:<value>"or"<idKey>:<value>"("movie:imdb:tt0133093","tmdb:1399") to an answer; the first id of the query with an entry answers, elsenull(Kino's own TMDB/AniList lookup and the person's other plugins do not exist in Node).tmdb.jsonmaps"<path>?<params sorted by name, URL-encoded>"or just"<path>"to TMDB's body ("/trending/movie/week?language=es-MX"); a fixture stands in for TMDB and for a key, and a path it lacks answersnot_found.- Your key stands in for Kino's own, under Kino's limit for its key (20 calls per 10 s); the kit has no person's key to
fall back to, so past that limit it throws
rate_limited. - Without a key or a fixture,
kino.tmdbthrowsno_tmdb_key, as a Kino build without a key of its own does for a person who has none, and the runner prints the sentence Kino would show. Your key is never printed. - Validation, rate limits, the cache and the error codes are the app's (
kino.meta,kino.tmdb);node sdk/validate.mjswarns when your code calls either withouttypeof kino.<name> === "function"(older Kino has neither). Samples of both files are indocs/plugins/fixtures/kino-services/of Kino's repository.
What the Node kit does not reproduce¶
Kino is the authority; the kit only approximates it so you can iterate fast. Before you publish, install the plugin in the app and try it there. The differences:
kino.html.selectthrows (it uses Jsoup, which exists only in the app).- The kit's XMLTV reader is a tolerant regex walk, not the app's XML parser. It gives the app's answer on every shared test guide, but on malformed XML in mid-document it may keep more than the app (which stops at the first error and keeps what it read up to there).
- The rejection trap of Limits and engine quirks: Node catches what Kino 0.9.49 and older would not.
- Node has globals Kino lacks (
setTimeout,fetch,Buffer, ...): the plugin may pass under Node and fail in Kino. Kino'sURLhas no punycode. - The host, redirect and request-count rules are the same, and so are the cookie rules as far as Node's own parsing goes, but there is no refusal of names that resolve to private addresses, bodies are always read as UTF-8, and the 15 s timeout covers the wait for the response but not the download.
- The kit never asks about a host: an undeclared one fails as
host_not_allowedeven duringresolveorepisodes, where the app could ask the person. It has no broad video permission either;"streamHosts": "any"it does apply. - The per-call time limits, the memory limit and the size caps on requests, answers and selectors are not enforced.
- There is no
metacommand, andvalidate.mjsdoes not check that a plugin declaringmetaexports it: trymetain the app.kino.browser.captureandkino.browser.pagealways answerbrowser_unavailable(Hidden browser).